Ein kommandozeilenbasierter Frontend kategorisiert und archiviert Schnappschüsse und stöbert in digitalen Bildersammlungen.
Als ich entdeckte, dass man unter Linux ganz einfach auf die Bilder einer per USB angestöpselten digitalen Kamera zugreifen kann, nahm ich mir vor, endlich Ordnung in meine etwa 5000 privaten Schnappschüsse zu bringen, die seit einigen Jahren in loser Ordnung als JPGs auf meiner Festplatte herumlungern.
Das Problem ist freilich, die Bilder konsequent zu beschriften. Wer aus dem Urlaub mit 500 digitalen Fotos heimkommt, setzt sich nicht hin, um alle mit Texten wie ``Ich mit Blumenkette neben dem Mietauto auf Hawaii 2002'' zu versehen. Vielmehr landen alle 500 unbeschriftet in einem Verzeichnis und vier Wochen später hat man sie vergessen. Sucht man nach Jahren die Hawaii-Fotos von 2002, geht die Sucherei los.
Programme wie ``iphoto'' auf dem Mac und das brandheiße ``Photoshop Album'' von Adobe sind zwar ganz brauchbar, doch grafische Oberflächen von proprietären Produkten sind schnell ausgereizt -- üblicherweise dauert es nur wenige Tage bis ich an die Grenzen stoße und lautstark eine programmierbare Schnittstelle fordere.
Über diese und die nächste Ausgabe des Snapshots verteilt bauen wir deshalb eine programmierbare Bilderverwaltung mit allen Schikanen, die man auch (aber nicht nur) von der Kommandozeile aus bedienen kann, um Abfragen in alter Unix-Tradition mit Pipes zu verknüpfen und zu filtern, dass es nur so schnackelt.
Als erstes stellt sich die Frage: Wie bezeichnet man die Bilder eindeutig, welche ID weist man jedem zu? Nach einigem Herumprobieren fand ich es am einfachsten, das genaue Datum herzunehmen, 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 man das Datum an der
Kamera richtig eingestellt hat und nicht mehr als ein Bild pro Sekunde
schießt.
Digitale Kameras speichern die sekundengenaue Information in entsprechenden
Tags in der Bilddatei ab,
von wo man sie einfach mit Modulen wie Image::Info
extrahieren kann. So kriegt man auch Bilder, die aus Versehen in
der falschen Reihenfolge archiviert wurden oder gar in einem
alten Unterverzeichnis verstaubten, leicht wieder geordnet.
Außerdem lässt sich so blitzschnell feststellen, ob ein unbekannt
aussehendes Bild schon in der Datenbank liegt oder nicht, da das Datum
ein eindeutiger Stempel ist.
Zur Beschriftung eignen sich Text-Tags, die man auf einen Schlag gleich einem Schuhkarton voll Bildern zuordnen kann -- das spart Zeit. Ein Bild kann beliebig viele Tags erhalten, ein und dasselbe Tag kann vielen Bildern zugeordnet sein.
Dabei liegen die Bilder in per Jahr, Monat und Tag aufgeteilten Unterverzeichnissen auf der Festplatte und die Tag-Informationen in einer MySQL-Datenbank. Abbildung zeigt die praktische Umsetzung mit Tags die (beispielhaft) in Ober- und Unterkategorien aufgeteilt sind.
Abbildung 1: Bild 2002-03-01_22:13:01 gehört zur Kategorie "Freunde" und ebenfalls zu "Max", Bild 2002-03-01_23:22:17 ist ebenfalls das Tag "Freunde" zugeordnet, es gehört aber zusätzlich zur Kategorie "Schorsch". |
idb
für Image Database heisst der heute vorgestellte
Kommandozeilen-Frontend für das neue System.
Ein paar Beispiele gefällig? Gerade aus den Bahamas zurückgekommen,
hängen wir die Kamera an die Linuxkiste und tippen
$ idb --import --tag "Bahamas 2003" /mnt/cam/dcim/100_fuji
und schon wandern alle JPGs aus dem Kameraverzeichnis 100_fuji
in die Datenbank. Liegen
diese stattdessen in einigen Verzeichnisse auf dem Laptop, ist auch
das kein Problem, und sogar die
länglichen Optionen können verkürzt werden:
$ idb -i -t "Tirol 2002" /samba/laptop/fotos/*
liest alle per Kommandozeile gefundenen Ordner und die darin liegenden Dateien ein und beschriftet alle Bildchen mit dem Tag ``Tirol 2002''. Gerade irgend ein altes JPG gefunden? Obwohl Tags später hilfreich sind, zwingt uns niemand dazu, gleich eines anzugeben -- also vorerst mal nur archivieren:
$ idb -i uralt.jpg 2002-03-01_23:22:17
Jeder --import
-Aufruf gibt die neuen ID(s)
der eingespeicherten
Fotos zurück.
Auch nachträglich lässt sich noch ein Tag an ein Bild hängen,
wenn die ID angegeben wird:
$ idb -t "Freunde" 2002-03-01_23:22:17
Statt der ID geht auch der Name der Datei,
denn idb
wird dessen Zeitstempel auslesen, zur Datenbank gehen,
die ID ermitteln und den Tag anhängen:
$ idb -t "Max" uralt.jpg
Und auch mehr als ein ``Tag'' pro Bild sind natürlich erlaubt, im vorliegenden
Fall wurde das Bild sowohl mit ``Freunde'' als auch ``Max''
verknüpft. Die List-Funktion zeigt nun für uralt.jpg
zwei Tags an:
$ idb -l uralt.jpg uralt.jpg: Freunde, Max
Um, umgekehrt, die Bilder zu einem gegebenen Tag
aus der Datenbank hervorzukitzeln, dient die Option --search
,
abgekürzt -s
:
$ idb -s "Max" 2002-03-01_23:22:17
Um statt der ID den Pfad zur Bild-Datei zu erhalten, dient die Option -p
,
und als reguläre Ausdrücke sind SQL-Formate erlaubt:
$ idb -s "Freu%" -p /store17/pics/2002/03/01/23:22:17.jpg
Wenn's zu viele Ergebnisse hagelt, lässt sich idb
mit -g
(--grep
) auch als Filter verwenden. Der Aufruf
$ idb -s "%Italien%" | idb -g "%Meer%" -p /store17/pics/2001/07/2001-07-07_12:21:15.jpg
filtert aus allen irgendwie mit
``Italien'' gekennzeichneten Bildern nur diejenigen heraus,
die ein Tag führen, in dem ``Meer'' vorkommt.
Durch die Pipe kommen zeilenweise passende IDs geflogen, die der
zweite Aufruf von idb
am anderen Ende der Pipe gierig aufschnappt
und weiteren Filterkriterien unterwirft.
Die -g
-Option
gibt, im Gegensatz zu -s
an,
dass idb
nicht auf Bildern in der Datenbank operiert,
sondern auf den per ID durch STDIN hereinkommenden.
Und, natürlich kann man ein versehentlich getaggtes Bild wieder von der zugeordneten Kategorie befreien. Der Aufruf
$ idb -u "Max" 2002-03-01_23:22:17
nimmt den ``Max'', aber belässt die ``Freunde''-Kategorie.
idb
zieht eine Reihe von Zusatzmodulen herein, die allesamt leicht
vom CPAN zu holen und zu installieren sind: Log::Log4perl für Warn-
und Fehlermeldungen,
Image::Info, um die Tag-Informationen aus JPG-Bildern zu extrahieren,
File::Path zum Erzeugen beliebig tiefer Verzeichnisse mittels mkpath()
,
File::Copy zum Kopieren von Dateien, das weiter unten besprochene
Getopt::Long, sowie Pod::Usage, um dem Benutzer
die als POD angehängte Bedienungsanleitung mittels pod2usage()
um die Ohren zu schlagen,
falls er ungültige Optionen eingibt.
Weiter nutzt es CameraStore
, die Datenbankschnittstelle, die im
nächsten Snapshot detailliert besprochen wird.
idb
speichert Informationen auf zweierlei Art: Image-Dateien
wandern in Jahr/Monat/Tag-spezifische Verzeichnisse unterhalb
$IMG_FILE_DIR
(Zeile 8 in idb
).
Ist dieses, wie im Skript auf /ms2/PHOTOS
gesetzt,
kopiert idb
das Bild mit der ID 2002-03-01_23:22:17 einfach
nach
/ms2/PHOTOS/2002/03/01/23:22:17.jpg
und erzeugt die dafür notwendigen Unterverzeichnisse automatisch.
Die Datenbank erhält hierüber folgende Informationen: Die ID des
Bildes, den Pfad, unter dem es abgespeichert wurde und dazu etwaige
Tag-Informationen, die dem Bild mittels der Option -t
angeheftet wurden.
Kommen später weitere Bilder in beliebiger Reihenfolge hinzu, sortiert sie das Verfahren automatisch in die richtige Aufnahmereihenfolge. Auch kann man schön mit einem Browser oder Viewer durch die einzelnen Tage streunen und das Geschehen verfolgen ...
Ein vielfältiges Programm wie idb
muss eine Vielzahl von
Kommandozeilenoptionen verstehen. Zum Glück erleichtert
Getopt::Long
vom CPAN die Arbeit. Man gibt, wie in den Zeilen
29 bis 37 im Listing idb
gezeigt, einfach die ausgeschriebenen
Optionen (z.B. --list
) an, definiert, ob die jeweilige
Option für sich steht oder ein String-Argument erwartet (=s
) und weist
ihr jeweils eine Referenz auf einen Skalar zu. Dieser enthält dann bei
binären Werten entweder einen wahren (Option gesetzt) oder falschen Wert
(Option nicht gesetzt). Bei String-Optionen steht der auf der
Kommandozeile zugeordnete Wert drin. Falls eine eindeutige Zuweisung
möglich ist, versteht Getopt::Long
sofort sinnvolle Abkürzungen,
also zum Beispiel -l
statt --list
, wenn keine andere Option
mit -l
anfängt.
Die in den Zeilen 18, 20 und 22 vorcompilierten regulären Ausdrücke
$ID_REGEX
, $TS_REGEX
und $PIC_REGEX
dienen später dazu,
richtige IDs, Datumsstempel und Dateinamen
von JPEG-Bildern als solche zu erkennen und mittels Klammern
interessante Informationen zu extrahieren, die später als $1, $2, usw.
vorliegen.
Da das Skript das Modul Log::Log4perl zur Ausgabe von Warn- und Fehlermeldungen ausgibt, initialisiert Zeile 24 es auf dem WARN-Level, leitet Meldungen nach STDERR um und legt das Format mit ``%p %m%n'' so fest, dass der Meldung mit Zeilenumbruch auch die Priorität (WARN, ERROR etc.) beiliegt.
Zeile 40 initialisiert ein CameraStore
-Objekt, die Schnittstelle
zur Datenbank, deren Methoden später genutzt werden, um den Inhalt
zu manipulieren.
Wurde --search
angewählt, möchte der Benutzer offensichtlich nach
Tag-Begriffen in der Datenbank stöbern. Dann ist $search
gesetzt
und Zeile 42 verzweigt und zur in Zeile 43 ausgeführten
Datenbankmethode, die im Erfolgsfall eine Liste mit Image-IDs
zurückliefert, die wiederum die nachfolgende print
-Funktion
einfach Zeilenweise auf STDOUT ausgibt. So können auf der
Kommandozeile nachgeschalte Filter sie nutzen, um ihrerseits das
Ergebnis zu verfeinern. Zeile 46 bricht nach getaner Arbeit das
Programm ab, der Rest von idb
beschäftigt sich mit entweder
via Kommandozeile oder STDIN hereinkommenden Dateien und IDs.
Hierzu holt sich Zeile 49 in $in
einen Funktionspointer,
den die ab Zeile 144 definierte Funktion get_input_sub()
zurückliefert.
Es geht darum, einen Pointer zu einer Funktion zu erhalten, die
bei jedem Aufruf die nächste zu bearbeitende Datei (oder ID) liefert,
unabhängig davon, wie der Benutzer dies auf der Kommandozeile
festlegte. Egal ob Dateien oder Verzeichnisse hereinkommen
(letztere werden transparent ausgelesen), und ebenfalls unabhängig
davon ob via @ARGV oder 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.
Diese etwas unkonventionelle Technik wird durch eine sogenannte
Closure realisiert, die die in Zeile 146 definierte lexikalische
Variable @items
einschließt und mit Dateien oder Verzeichnissen
füllt. Zeile 157 gibt dann eine Referenz auf eine Funktion zurück,
die den Array @items
einsperrt und damit bei jedem Aufruf Zugriff auf
dessen Werte hat. Eine Art Instanzvariable für Arme.
Ist der nächste vorliegende Wert ein Verzeichnis, löschen
die Zeilen 160 und 161 das entsprechende Arrayelement und schaufeln
statt dessen (unter Umständen viele) in ihm liegende Bilddateien,
die auf den regulären Ausdruck $PIC_REGEX
passen, nach.
Zurück ins Hauptprogramm ab Zeile 51: Die while
-Schleife erhält
durch Ausführen der als Referenz vorliegenden Closure-Funktion
$in->()
und dem gerade beschriebenen Mechanismus eine Bilddatei
nach der anderen. Wurde 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. add_file
wird dann in Zeile
86 die Funktion file_info
aufrufen, die das Bild öffnet,
das Datum extrahiert und die daraus resultierenden Werte für
$stamp
(ID), $dir
(das Bildverzeichnis .../JJ/MM/DD) und
$file
(den Bildnamen SS:MM:ss.jpg)
als Liste ans Hauptprogramm zurückreicht.
file_info()
nutzt hierzu die ab Zeile 128 definierte
Funktion image_date()
, die ihrerseits das Modul Image::Info
vom CPAN nutzt, um die Datumsinformation aus dem von der
digitalen Kamera aufgenommenen JPG-Bild herauszuholen.
In $TS_REGEX
liegt der reguläre Ausdruck, der auf den in meiner
Fuji Finepix verwendeten Zeitstempel (Format ``JJ:MM:TT HH:mm:ss'')
passt -- für andere Kameras ist der eventuell entsprechend zu ändern.
Da er Klammern enthält, kann Zeile 140 auf die in
Zeile 136 ermittelten Teilkomponenten per $1, $2, usw. zugreifen.
Wieder zurück zum Hauptprogramm: Zeile 58 transformiert nun
jedes Bild, das noch als Datei vorliegt, in die entsprechende
ID, auch wiederum mittels file_info()
, von dem sie nur das
erste Argument auffängt und den Rest ignoriert.
Falls von dort nichts Gescheites zurückkommt, liegt das Bild
offensichtlich nicht in der Datenbank, worauf idb
einen Fehler
meldet und mit next
zum nächsten Bild weiterspringt.
Tags zu Bildern hinzuzufügen und wieder zu entfernen ist eine einfache Aufgabe für die Datenbankschnittstelle, die Zeilen 66 und 68 rufen einfach die entsprechenden Methoden auf und übergeben ihnen diese ID des entsprechenden Bildes und den Tagwert.
Ist die --grep
-Option aktiv, gibt Zeile 70 die ID des
gerade untersuchten Bildes aus, falls 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 idb
in den if
-Block
ab Zeile 73 springen, den Namen des Bildes ausgeben und die von
der Datenbank mit list_tags()
ermittelten Tags
kommasepariert danebenstellen. Fertig!
Folgende Kommandos (unter root
) schalten unter einem standardmäßig
ausgelieferten RedHat-8.0-System das USB-Modul zu und mounten die
Kamera unter /mnt/cam
:
# modprobe usbcore # modprobe usb-uhci # modprobe usb-storage # mount -t auto /dev/sda1 /mnt/cam
Um die gerade auf der Kamera gespeicherten Bilder in die Image-Datenbank zu importieren, tippt man (am Beispiel einer Fuji Finepix) einfach
idb -i -t "Erste Bilder" /mnt/cam/dcim/100_fuji
und mit dem nächsten Mal vorgestellen CameraStore.pm
kann's dann losgehen. Bis denn!
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 = "/ms2/PHOTOS"; 009 010 use CameraStore; 011 use Log::Log4perl qw(:easy); 012 use Image::Info qw(image_info); 013 use File::Path; 014 use File::Basename; 015 use File::Copy; 016 use Getopt::Long; 017 use Pod::Usage; 018 019 my $ID_REGEX = qr#^(\d{4})-(\d\d)-(\d\d) 020 _(\d\d):(\d\d):(\d\d)$#x; 021 my $TS_REGEX = qr#^(\d{4}):(\d\d):(\d\d)\s 022 (\d\d):(\d\d):(\d\d)$#x; 023 my $PIC_REGEX = qr#(\.jpg)$#i; 024 025 Log::Log4perl->easy_init( 026 { file => 'stderr', 027 level => $WARN, 028 layout => "%p %m%n"}); 029 030 GetOptions( 031 "import" => \my $import, 032 "tag=s" => \my $tag, 033 "untag=s" => \my $untag, 034 "filter" => \my $filter, 035 "list" => \my $list, 036 "xlink" => \my $xlink, 037 "grep=s" => \my $grep, 038 "search=s" => \my $search, 039 "paths" => \my $paths) or 040 pod2usage(); 041 042 my $db = CameraStore->new(); 043 044 if($search) { 045 for($db->search_tag($search, $paths)) { 046 if($xlink) { 047 symlink $_, basename($_) or warn "Cannot symlink $_ ($!)"; 048 } 049 print "$_\n"; 050 } 051 exit 0; 052 } 053 054 my $in = get_input_sub(); 055 056 while($_ = $in->()) { 057 058 if($import) { 059 add_file($db, $_, $tag); 060 next; 061 } 062 063 my($id) = (-f) ? file_info($_) : $_; 064 unless(defined $id and 065 $id =~ /$ID_REGEX/) { 066 ERROR "Image $_ not in DB"; 067 next; 068 } 069 070 if($tag) { 071 $db->add_tag($tag, $id); 072 } elsif($untag) { 073 $db->delete_tag($untag, $id); 074 } elsif($grep) { 075 print "$_\n" for 076 $db->search_tag($grep, 077 $paths, $id); 078 } elsif($list) { 079 print "$_: ", join(', ', 080 $db->list_tags($id)), "\n"; 081 } else { 082 pod2usage("Options error"); 083 } 084 } 085 086 ########################################### 087 sub add_file { 088 ########################################### 089 my($db, $ofile, $tag) = @_; 090 091 my($stamp, $dir, $file) = 092 file_info($ofile); 093 094 return undef unless defined $file; 095 096 if(!-d $dir) { 097 mkpath($dir) or 098 LOGDIE "Cannot mkpath $dir"; 099 } 100 101 copy($ofile, "$dir/$file") or 102 LOGDIE "$ofile > $dir/$file failed"; 103 104 $db->add_image($stamp, 105 "$dir/$file", $tag); 106 print "$stamp\n"; 107 } 108 109 ########################################### 110 sub file_info { 111 ########################################### 112 my($ofile) = @_; 113 114 my ($suffix) = ($ofile =~ $PIC_REGEX); 115 116 unless($suffix) { 117 ERROR "Unknown image type: $ofile"; 118 return undef; 119 } 120 121 my($y, $m, $d, $h, $mi, $s) = 122 image_date($ofile); 123 return undef unless defined $s; 124 125 my $stamp = "$y-$m-${d}_$h:$mi:$s"; 126 my $dir = "$IMG_FILE_DIR/$y/$m/$d"; 127 my $file = "$h:$mi:$s$suffix"; 128 129 return($stamp, $dir, $file); 130 } 131 132 ########################################### 133 sub image_date { 134 ########################################### 135 my($file) = @_; 136 137 my $info = image_info($file); 138 139 if ($info->{error} or 140 ! exists $info->{DateTime} or 141 $info->{DateTime} !~ $TS_REGEX) { 142 WARN "No timestamp from $file"; 143 return undef; 144 } 145 return($1, $2, $3, $4, $5, $6); 146 } 147 148 ########################################### 149 sub get_input_sub { 150 ########################################### 151 my @items = (); 152 153 if(@ARGV) { 154 push @items, @ARGV; 155 } else { 156 while(<STDIN>) { 157 chomp; 158 push @items, $_; 159 } 160 } 161 162 return sub { 163 if(@items and -d $items[0]) { 164 my $dir = shift @items; 165 unshift @items, 166 grep /$PIC_REGEX/, <$dir/*>; 167 } 168 return shift @items; 169 }; 170 } 171 172 __END__ 173 174 =head1 NAME 175 176 idb - Image database client 177 178 =head1 SYNOPSIS 179 180 # Import and tag 181 idb -i -t tag [file|dir] ... 182 # Filter files and tag 183 ls *.jpg | idb -t tag 184 # DB search for tags, print paths 185 idb -s search_pattern -p 186 # Grep for tags in files 187 idb -g search_pattern [file|dir] ... 188 # List files/tags 189 idb -l [file|dir] ...
Michael Schilliarbeitet als Software-Engineer bei Yahoo! in Sunnyvale, 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: http://perlmeister.com. |