Aus Linux-Magazin 04/2003

Digitale Bilder mit Perl archivieren und verwalten

Der Schuhkarton als Archivbox für die Fotosammlung hat ausgedient: Bilder sind heute digital und landen auf der Platte. Ein kommandozeilenbasiertes Perl-Frontend kategorisiert und archiviert die Schnappschüsse und stöbert in der privaten Bildersammlung.

Neulich entdeckte ich, wie leicht Linux per USB die Bilder von einer Digitalkamera liest. Bei der Gelegenheit nahm ich mir vor, endlich Ordnung in alle neuen Schnappschüsse sowie die 5000 privaten Fotos zu bringen, die seit einigen Jahren als JPEG-Bilder auf meiner Festplatte herumlungern. Das Problem ist nur, die Files konsequent zu beschriften.

Wer aus dem Urlaub mit 500 Fotos heimkommt, setzt sich nicht hin, um alle mit Texten wie “Ich mit Blumenkette neben dem Mietauto auf Hawaii 2002” zu beschreiben. Meist landen alle Bilder unkommentiert in einem Verzeichnis. Vier Wochen später sind sie vergessen. Will man nach Jahren die Hawaii-Fotos von 2002 ansehen, geht die Sucherei los.

Programme wie iPhoto auf dem Mac oder das brandaktuelle Adobe Photoshop Album erfüllen zwar die meisten Aufgaben, aber grafische Oberflächen von proprietären Produkten sind schnell ausgereizt. Üblicherweise dauert es nur wenige Tage, bis ich an ihre Grenzen stoße und lautstark eine programmierbare Schnittstelle fordere.

Diese und die nächste Ausgabe des Perl-Snapshots zeigen eine programmierbare Bilderverwaltung mit allen Schikanen, die sich auch – aber nicht nur – von der Kommandozeile aus bedienen lässt. So kann man Abfragen in alter Unix-Tradition mit Pipes verknüpfen und filtern, dass es nur so schnackelt.

Die Namensfrage

Als Erstes war zu klären, wie die Bilder eindeutig zu bezeichnen sind, also welche ID jedes Foto erhält. Nach einigem Herumprobieren fand ich es am einfachsten, das genaue Datum und die Uhrzeit zu verwenden, an dem ein Bild aufgenommen wurde:

2002-03-01_22:13:01
2002-03-01_23:22:17
...
2003-02-13_11:07:13

Voraussetzung dafür ist allerdings, dass das Datum in der Kamera richtig eingestellt ist und man nicht mehr als ein Bild pro Sekunde schießt. Digicams speichern die Sekunden-genauen Informationen als Metadaten in der Bilddatei; Perl-Module wie »Image::Info« extrahieren die Daten wieder. So lassen sich auch Fotos richtig einordnen, die aus Versehen in falscher Reihenfolge archiviert wurden oder in einem alten Unterverzeichnis verstauben. Außerdem kann man blitzschnell feststellen, ob ein unbekannt aussehendes Bild schon in der Datenbank liegt oder nicht: Das Datum dient dabei als eindeutiger Stempel.

Es wäre mühsam, jede Datei einzeln zu beschreiben, gerade wenn sich viele Bilder stark ähneln. Praktischer sind daher Text-Tags, die auf einen Schlag gleich einem Schuhkarton voller Bilder zugeordnet sind. Ein Bild kann beliebig viele Tags erhalten, ein Tag kann beliebig vielen Bildern zugeordnet sein.

Die Bilder liegen – nach Datum sortiert – in Unterverzeichnissen auf der Festplatte, die beschreibenden Tags in einer MySQL-Datenbank. Abbildung 1 zeigt die Umsetzung. Das Beispiel verwendet Ober- und Unterkategorien: Alle Bilder, die Freunde zeigen, erhalten das Freunde-Tag; für Max und Schorsch gibt es zusätzlich ein eigenes Tag.

Abbildung 1: Bild »2002-03-01_22:13:01« (rot) gehört zu den Kategorien »Freunde« und »Max«. Dem zweiten Bild (grün) ist ebenfalls das »Freunde«-Tag zugeordnet, es gehört aber zusätzlich zur Kategorie »Schorsch«.

Abbildung 1: Bild »2002-03-01_22:13:01« (rot) gehört zu den Kategorien »Freunde« und »Max«. Dem zweiten Bild (grün) ist ebenfalls das »Freunde«-Tag zugeordnet, es gehört aber zusätzlich zur Kategorie »Schorsch«.

Ab in die Datenbank

Image Database (kurz »idb«) heißt das im Folgenden vorgestellte Kommandozeilen-Frontend für das neue System. Gerade von den Bahamas zurückgekommen hänge ich die Kamera an meine Linux-Kiste und tippe:

$ idb --import --tag "Bahamas 2003"   /mnt/cam/dcim/100_fuji

Schon wandern alle JPEGs aus dem Kameraverzeichnis »100_fuji« in die Datenbank. Liegen die Bilder verstreut über einige Verzeichnisse auf dem Laptop, ist auch das kein Problem. Sogar die länglichen Optionen lassen sich abkürzen:

$ idb -i -t "Tirol 2002" /samba/laptop/fotos/*

Die Shell ersetzt das Sternchen durch alle Dateien und Unterverzeichnisse in »/samba/laptop/fotos/«; das »idb«-Kommando liest daraus alle Bilder und beschriftet sie mit dem Tag »Tirol 2002«.

Manchmal tauchen beim Durchforsten des Verzeichnis-Waldes alte JPEGs auf, die man nicht mühsam durchsehen und mit Tags beschreiben will. Obwohl Tags später hilfreich sind, ist niemand gezwungen gleich eins anzugeben. Also erst mal nur archivieren:

$ idb -i uralt.jpg
2002-03-01_23:22:17

Jeder »–import«-Aufruf gibt die neuen ID(s) der archivierten Fotos aus. Auch nachträglich lässt sich noch ein Tag an ein Bild hängen, wenn man die ID angibt. Statt der ID genügt auch der Name der Datei, denn »idb« wird den Zeitstempel auslesen, zur Datenbank gehen, die ID ermitteln und das Tag anhängen:

$ idb -t "Freunde" 2002-03-01_23:22:17
$ idb -t "Schorsch" uralt.jpg

Pro Bild sind beliebig viele Tags erlaubt, im vorliegenden Fall ist das Bild sowohl mit »Freunde« als auch mit »Schorsch« verknüpft. Die List-Funktion zeigt nun für »uralt.jpg« zwei Tags an:

$ idb -l uralt.jpg
uralt.jpg: Freunde, Schorsch

Bilder wiederfinden

Um die Bilder zu einem gegebenen Tag aus der Datenbank zu holen, kommt die Option »–search« zum Einsatz, abgekürzt »-s«:

$ idb -s "Schorsch"
2002-03-01_23:22:17

Wer statt der ID den Pfad zur Bilddatei benötigt, setzt zusätzlich die Option »-p« ein. Eine exakte Tag-Angabe ist nicht nötig: Die SQL-Wildcard »%« ersetzt beliebig viele beliebige Zeichen.

$ idb -s "Freu%" -p
/pics/2002/03/01/22:13:01.jpg
/pics/2002/03/01/23:22:17.jpg

Wenn es zu viele Treffer hagelt, kommt »idb -g« (Grep) als Filter zum Einsatz. Der folgende Aufruf holt aus allen mit »Italien« gekennzeichneten Bildern nur jene heraus, die ein Tag führen, in dem »Meer« vorkommt:

$ idb -s "%Italien%" | idb -g "%Meer%" -p
/pics/2001/07/2001-07-07_12:21:15.jpg

Das erste »idb« schickt zeilenweise passende IDs durch die Pipe, die der zweite Aufruf am anderen Ende aufschnappt und weiteren Filterkriterien unterwirft. Die »-g«-Option bewirkt, dass »idb« nicht alle Bilder in der Datenbank durchsucht, sondern nur die, deren IDs durch Stdin hereinkommen.

Natürlich kann man ein getaggtes Bild wieder von seiner Kategorie befreien:

$ idb -u "Schorsch" 2002-03-01_23:22:17

Der Aufruf entfernt das »Schorsch«-Tag von Bild »2002-03-01_23:22:17«, es belässt aber die »Freunde«-Kategorie.

Module, Module

Zu Beginn zieht »idb« (siehe Listing 1) eine Reihe von Zusatzmodulen herein, die allesamt leicht vom CPAN zu holen und zu installieren sind. »Log::Log4perl« gibt Warn- und Fehlermeldungen aus, »Image::Info« extrahiert Metadaten aus JPEG-Bildern, »File::Path« erzeugt mit »mkpath()« beliebig tiefe Verzeichnisse und »File::Copy« kopiert Dateien.

Das später besprochene »Getopt::Long« schlägt über »Pod::Usage« dem Benutzer die Anleitung um die Ohren, falls er ungültige Optionen eingibt. Das Programm nutzt auch das Modul »CameraStore«: Diese Datenbankschnittstelle wird im nächsten Snapshot besprochen.

Die Image-Datenbank speichert Informationen auf zweierlei Art: Bilddateien wandern in Jahr/Monat/Tag-spezifische Verzeichnisse unter »$IMG_FILE_DIR« (Zeile 8 in »idb«). Ist es auf »/pics« gesetzt, kopiert »idb« ein Bild mit der ID »2002-03-01_23:22:17« nach »/pics/2002 /03/01/23:22:17.jpg« und erzeugt die dafür notwendigen Unterverzeichnisse automatisch.

Listing 1: Image Database »idb«

001 #!/usr/bin/perl
002 ###########################################
003 # Mike Schilli, 2003 (m@perlmeister.com)
004 ###########################################
005 use warnings;
006 use strict;
007 
008 my $IMG_FILE_DIR = "/pics";
009 
010 use CameraStore;
011 use Log::Log4perl qw(:easy);
012 use Image::Info qw(image_info);
013 use File::Path;
014 use File::Copy;
015 use Getopt::Long;
016 use Pod::Usage;
017 
018 my $ID_REGEX  = qr#^(d{4})-(dd)-(dd)
019                   _(dd):(dd):(dd)$#x;
020 my $TS_REGEX  = qr#^(d{4}):(dd):(dd)s
021                    (dd):(dd):(dd)$#x;
022 my $PIC_REGEX = qr#(.jpg)$#i;
023 
024 Log::Log4perl->easy_init(
025     { file   => 'stderr',
026       level  => $WARN,
027       layout => "%p %m%n"});
028 
029 GetOptions(
030   "import"   => my $import,
031   "tag=s"    => my $tag,
032   "untag=s"  => my $untag,
033   "filter"   => my $filter,
034   "list"     => my $list,
035   "grep=s"   => my $grep,
036   "search=s" => my $search,
037   "paths"    => my $paths) or
038 pod2usage();
039 
040 my $db = CameraStore->new();
041 
042 if($search) {
043     for($db->search_tag($search, $paths)) {
044         print "$_n";
045     }
046     exit 0;
047 }
048 
049 my $in = get_input_sub();
050 
051 while($_ = $in->()) {
052 
053     if($import) {
054         add_file($db, $_, $tag);
055         next;
056     }
057 
058     my($id) = (-f) ? file_info($_) : $_;
059     unless(defined $id and
060            $id =~ /$ID_REGEX/) {
061         ERROR "Image $_ not in DB";
062         next;
063     }
064 
065     if($tag) {
066         $db->add_tag($tag, $id);
067     } elsif($untag) {
068         $db->delete_tag($untag, $id);
069     } elsif($grep) {
070         print "$_n" for
071             $db->search_tag($grep,
072                             $paths, $id);
073     } elsif($list) {
074         print "$_: ", join(', ',
075             $db->list_tags($id)), "n";
076     } else {
077         pod2usage("Options error");
078     }
079 }
080 
081 ###########################################
082 sub add_file {
083 ###########################################
084     my($db, $ofile, $tag) = @_;
085 
086     my($stamp, $dir, $file) =
087                       file_info($ofile);
088 
089     return undef unless defined $file;
090 
091     if(!-d $dir) {
092         mkpath($dir) or
093                LOGDIE "Cannot mkpath $dir";
094     }
095 
096     copy($ofile, "$dir/$file") or
097        LOGDIE "$ofile > $dir/$file failed";
098 
099     $db->add_image($stamp,
100                    "$dir/$file", $tag);
101     print "$stampn";
102 }
103 
104 ###########################################
105 sub file_info {
106 ###########################################
107     my($ofile) = @_;
108 
109     my ($suffix) = ($ofile =~ $PIC_REGEX);
110 
111     unless($suffix) {
112         ERROR "Unknown image type: $ofile";
113         return undef;
114     }
115 
116     my($y, $m, $d, $h, $mi, $s) =
117                        image_date($ofile);
118     return undef unless defined $s;
119 
120     my $stamp = "$y-$m-${d}_$h:$mi:$s";
121     my $dir   = "$IMG_FILE_DIR/$y/$m/$d";
122     my $file  = "$h:$mi:$s$suffix";
123 
124     return($stamp, $dir, $file);
125 }
126 
127 ###########################################
128 sub image_date {
129 ###########################################
130     my($file) = @_;
131 
132     my $info = image_info($file);
133 
134     if ($info->{error} or
135         ! exists $info->{DateTime} or
136         $info->{DateTime} !~ $TS_REGEX) {
137         WARN "No timestamp from $file";
138         return undef;
139     }
140     return($1, $2, $3, $4, $5, $6);
141 }
142 
143 ###########################################
144 sub get_input_sub {
145 ###########################################
146     my @items = ();
147 
148     if(@ARGV) {
149         push @items, @ARGV;
150     } else {
151         while(<STDIN>) {
152             chomp;
153             push @items, $_;
154         }
155     }
156 
157     return sub {
158         if(@items and -d $items[0]) {
159             my $dir = shift @items;
160             unshift @items,
161                grep /$PIC_REGEX/, <$dir/*>;
162         }
163         return shift @items;
164     };
165 }
166 
167 __END__
168 
169 =head1 NAME
170 
171 idb - Image database client
172 
173 =head1 SYNOPSIS
174 
175         # Import and tag
176     idb -i -t tag [file|dir] ...
177         # Filter files and tag
178     ls *.jpg | idb -t tag
179         # DB search for tags, print paths
180     idb -s search_pattern -p
181         # Grep for tags in files
182     idb -g search_pattern [file|dir] ...
183         # List files/tags
184     idb -l [file|dir] ...

Datenbank und Directory

Die Datenbank erhält hierüber folgende Informationen: die ID des Bildes, den Pfad, unter dem es abgespeichert wurde, und etwaige Tags, die am Bild kleben (Option »-t«). Kommen später weitere Bilder hinzu, sortiert sie das Verfahren automatisch in die richtige Aufnahme-Reihenfolge. So kann man gemütlich per Filebrowser oder Imageviewer durch die Verzeichnisse streunen und das Geschehen verfolgen.

Ein vielseitiges Programm wie »idb« versteht viele Optionen. Zum Glück erleichtert »Getopt::Long« die Arbeit (Zeile 29 bis 37): Man gibt die ausgeschriebenen Optionen an (»list« für »–list«), definiert, ob sie allein stehen oder ein String-Argument erwarten (»=s«), und weist Referenzen auf Skalare zu. Die Skalare erhalten bei Flags einen binären Wert (wahr: Option ist gesetzt). Bei String-Optionen erhalten sie den Wert, den der Benutzer per Kommandozeile angegeben hat. Ist eine eindeutige Zuordnung möglich, versteht »Getopt::Long« auch Abkürzungen, etwa »-l« statt »–list«, wenn keine andere Option mit »l« anfängt.

Die Zeilen 18, 20 und 22 kompilieren als Vorbereitung die regulären Ausdrücke »$ID_REGEX«, »$TS_REGEX« und »$PIC_REGEX«. Sie dienen dazu, richtige IDs, Datumsstempel und Dateinamen von JPEG-Bildern zu erkennen. Durch die Klammern extrahieren sie die interessanten Informationen, die später als »$1«, »$2« und so weiter vorliegen.

Das Skript nutzt »Log::Log4perl« für Warn- und Fehlermeldungen. Zeile 24 initialisiert es auf den Warn-Level, leitet Meldungen nach Stderr um und legt das Format auf »%p%m%n« fest. Das bedeutet: erst die Priorität (»%p«: Warn, Error …), dann die Meldung »%m« und am Ende ein Newline (»%n«). Zeile 40 initialisiert ein »CameraStore«-Objekt. Die Methoden dieser Schnittstelle zur Datenbank nutzt das Skript später, um den Inhalt zu manipulieren.

Wenn der Benutzer »–search« wählt, möchte er nach Tags in der Datenbank stöbern. Dann ist »$search« gesetzt und Zeile 43 führt eine Datenbankmethode aus, die eine Liste mit den passenden Image-IDs zurückliefert. Die nachfolgende »print«-Funktion schreibt die IDs einfach zeilenweise auf Stdout. So können auf der Kommandozeile nachgeschaltete Filter die IDs nutzen, um das Ergebnis zu verfeinern. Zeile 46 bricht nach getaner Arbeit das Programm ab.

Der Rest von »idb« beschäftigt sich mit Dateien und IDs, die via Kommandozeile oder Stdin hereinkommen. Hierzu holt sich Zeile 49 einen Funktionszeiger, den sie von »get_input_sub()« erhält (ab Zeile 144 definiert). Dieser Pointer zeigt auf eine Funktion, die bei jedem Aufruf die nächste zu bearbeitende Datei (oder ID) liefert, unabhängig davon, wie der Benutzer dies auf der Kommandozeile festlegt. Egal, ob Dateien oder Verzeichnisse hereinkommen (Letztere werden transparent ausgelesen), und unabhängig davon, ob via »@ARGV« oder durch Stdin: Die von »get_input_sub()« als Referenz zurückgegebene Funktion gibt bei jedem Aufruf die nächste zu bearbeitende Datei (oder ID) zurück.

Geschlossene Gesellschaft

Diese etwas unkonventionelle Technik lässt sich durch eine Closure[2] realisieren. Das ist eine anonyme Funktion (sie hat keinen Namen) und lässt sich daher nur über einen Funktionspointer ansprechen. Sie läuft in dem Kontext, in dem sie erzeugt wurde, egal von wo aus man sie aufruft – eine Closure schließt dazu alle lexikalischen Variablen ein, auf die sie zugreift. Das macht sich »get_input_sub()« zu Nutze:

  • Zeile 146 definiert das lokale Array »@items«.
  • Die Zeilen 148 bis 155 füllen das Array mit Dateien oder
    Verzeichnissen, ob von der Kommandozeile oder von Stdin.
  • Zeile 157 gibt eine Referenz auf eine namenlose Funktion
    zurück, die in den Zeilen 158 bis 163 definiert ist.
  • Diese Funktion (die Closure) nutzt das eben erzeugte Array
    »@items« und hat bei jedem Aufruf Zugriff auf dessen
    Werte.
  • Das Array ist in der Closure also eine Art Instanzvariable
    für Arme.

Ist der nächste Wert ein Verzeichnis, entfernen die Zeilen 160 und 161 das Element aus dem Array und schaufeln stattdessen (unter Umständen viele) in ihm liegende Bilddateien nach – vorausgesetzt die Files passen zum regulären Ausdruck »$PIC_REGEX«.

Die While-Schleife in Zeile 51 ruft die Closure-Funktion über ihre Referenz auf: »$in->()« liefert mit dem eben beschriebenen Mechanismus eine Bilddatei nach der anderen. Ist das Import-Flag gesetzt, ruft Zeile 54 die ab Zeile 82 definierte Funktion »add_file()« auf und übergibt ihr eine Referenz auf das »CameraStore«-Objekt, den Dateinamen und ein eventuell gesetztes Tag.

Die Funktion »add_file()« nimmt in Zeile 86 die Dienste von »file_info()« zu Hilfe. Diese Info-Funktion öffnet das Bild, extrahiert das Datum und gibt die folgenden Werte als Liste zurück: »$stamp« (ID), »$dir« (das Bildverzeichnis »…/JJ /MM/DD«) und »$file« (Bildname »SS :MM:ss.jpg«). »file_info()« nutzt hierzu »image_date()« (sie ist ab Zeile 128 definiert), die ihrerseits das CPAN-Modul »Image::Info« verwendet, um die Datumsinformation aus dem JPEG-Bild herauszuholen. In »$TS_REGEX« liegt der reguläre Ausdruck, der auf den Zeitstempel in meiner Fuji Finepix passt (Format »JJ:MM:TT HH:mm:ss«). Für andere Kameras ist der Ausdruck eventuell anzupassen. Da er Klammern enthält, kann Zeile 140 auf die in Zeile 136 ermittelten Teilkomponenten per »$1«, »$2« und so weiter zugreifen.

Wieder zurück zum Hauptprogramm: Zeile 58 ermittelt zu jedem Bild, das noch als Datei vorliegt, die entsprechende ID. Dazu greift sie ebenfalls auf »file_info()« zurück, fängt aber nur das erste Argument auf und ignoriert den Rest. Falls von dort nichts Gescheites zurückkommt, liegt das Bild offensichtlich nicht in der Datenbank, woraufhin das Programm in Zeile 61 einen Fehler meldet und mit »next« zum nächsten Bild weiterspringt.

Tags zu Bildern hinzufügen und wieder entfernen ist Aufgabe der Datenbankschnittstelle, die Zeilen 66 und 68 rufen einfach die entsprechenden Methoden auf und übergeben ihnen die ID des entsprechenden Bildes und den Tag-Wert. Ist die Grep-Option aktiv, gibt Zeile 70 die ID des gerade untersuchten Bildes aus – aber nur, wenn die Datenbankschnittstelle mittels »search_tag()« bestätigt, dass ihm das Tag tatsächlich anhaftet. Falls nicht, gibt sie nichts aus und der nächste Filter wird die ID nie zu Gesicht bekommen.

Die List-Option schließlich lässt das Skript in den Elsif-Block ab Zeile 73 springen, den Namen des Bildes ausgeben und die von der Datenbank mit »list_tags()« ermittelten Tags kommasepariert daneben stellen. Fertig!

Kamera läuft

Um die gerade auf der Kamera gespeicherten Bilder in die Image-Datenbank zu importieren, tippt man (am Beispiel der Fuji Finepix) einfach Folgendes ein:

$ idb -i -t "Erste Bilder" /mnt/cam/dcim/100_fuji

Mit dem im nächsten Shop vorgestellten »CameraStore.pm« kann\’s anschließend losgehen. Bis dann! (fjl)

USB-Kamera

Folgende Root-Kommandos schalten unter einem standardmäßig ausgelieferten Red-Hat-8.0-System das USB-Modul zu und mounten die Digitalkamera ins Verzeichnis »/mnt/cam«:

# modprobe usbcore
# modprobe usb-uhci
# modprobe usb-storage
# mount -t auto /dev/sda1 /mnt/cam

Infos

[1] Listings: [ftp://www.linux-magazin.de/pub/listings/magazin/2003/04/Perl]

[2] Information zu Closures: [http://www.perl.com/pub/a/2002/05/29/closure.html]

Der
Autor


Michael Schilli arbeitet als Web-Engineer für AOL/Netscape in Mountain View, Kalifornien. Er hat “Goto Perl 5” (deutsch) und “Perl Power” (englisch) für Addison-Wesley geschrieben und ist unter [mschilli@perlmeister.com] zu erreichen. Seine Homepage ist [http://perlmeister.com].

Copyright © 2002 Linux New Media AG

LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben