Fotos vom Handy oder der SD-Karte meiner niegelnagelneuen "Mirrorless"-Kamera importiere ich regelmäßig auf den Heimcomputer zum Archivieren der besten Exemplare. Dort bugsiert selbstgeschriebene Software sie in eine Ordnerstruktur, die für jedes Jahr, Monat und Tag ein eigenes Verzeichnis anlegt. Nach dem Import verbleiben die Bilder meist auf der Karte oder dem Telefon, aber der Importierer sollte beim nächsten Aufruf bereits vorher importierte Bilder nicht noch einmal kopieren, sondern dort weitermachen, wo er beim letzten Mal aufgehört hat. Kommen dabei mehrere SD-Karten zum Einsatz, gilt es, den Überblick zu bewahren, denn sie verwenden zum Teil überlappende Dateinamen.
Fotos liegen auf der SD-Karte als Dateien im Format DSC00001.JPG vor, und auf dem Telefon unter einem anderen Dateinamen, wie zum Beispiel IMG_0001.JPG. Die laufende Nummer neu geschossener Fotos erhöhen Kameras und Photo-Apps dabei bei jeder Aufnahme um Eins. Wie das genau aussieht, ist lose im Standard-Dokument "Design rule for Camera File system" ([2]) definiert. Es legt das Format der Dateinamen mitsamt deren Zählern fest, sowie das, was passiert, wenn ein Zähler überläuft, oder wenn die Kamera feststellt, dass der User zwischenzeitlich andere SD-Karten mit eigenen Zählern genutzt hat.
Abbildung 1: Das Dateisystem auf der SD-Karte |
Abbildung 1 zeigt das typische, DCF-konforme Datei-Layout auf der Karte. Ist letztere frisch formatiert, speichert die Kamera die ersten Bilder als DSC00001.JPG
, DSC00002.JPG
, und so weiter ab, und zwar alle im Unterverzeichnis 100MSDCF
, was sich wiederum im Verzeichnis DCIM
befindet. Nun wird zwar kaum jemand 99.999 Bilder auf einer Karte speichern, aber falls ein verrückter Fotograf tatsächlich so viele Fotos schösse, würde die Kamera ein neues Verzeichnis 101MSDCF
anlegen, und nach der nächsten Aufnahme dort bei DSC00001.JPG
weitermachen.
Interessantes passiert, falls ein Fotograf SD-Karten wechselt, ohne die frisch eingelegte neu zu formatieren. Dann schnackelt der Kamera-interne Zähler vom bislang monoton anwachsenden Wert auf den Wert des Bildes mit dem höchsten Zähler auf der SD-Karte um! Wechselt der Fotograf zum Beispiel nach der Aufnahme von DSC02001.JPG
also auf eine SD-Karte, die schon das Foto DSC09541.JPG
enthält, macht der Fotoapparat dort bei DSC09542.JPG
weiter, obwohl vielleicht DSC02002.JPG
verfügbar wäre. Je nach Kameramodell oder Softwareversion können sich aber Abweichungen einschleichen.
Als Experiment habe ich mal eine SD-Karte aus meiner Sony a7 manipuliert. Deren 100MSDCF-Verzeichnis war mit Bildern von DSC00205.JPG bis DSC00952.JPG gefüllt, und ich schob ihr auf dem Rechner ein neues Foto mit dem Pfad DSC99999.JPG unter. Wieder in die Kamera eingelegt, begann deren Software auf der Karte doch tatsächlich mit einem neuen Verzeichnis 101MSDCF (parallel zu 100MSDCF), und speicherte dort neu aufgenommene Bilder als DSC00953.JPG, DSC00954.JPG und so weiter ab (Abbildung 2)!
Die Kamera merkt sich also (auch nachdem sie aus- und wieder eingeschaltet wurde) das letzte aufgenommene Bild und den Ordner (100MSDCF oder 101MSDCF), in dem sie es abgelegt hat. Als ich das Fake-Bild DSC99999.JPG wieder aus 100MSDCF gelöscht hatte, machte die Kamera trotzdem mit DSC00954.JPG im Verzeichnis 101MSDCF weiter.
Wer nun routinemäßig SD-Karten tauscht, findet auf ihnen Dateien mit Namen, unter denen bereits andere Fotos ins Archiv befördert wurden. Verließe sich ein Algorithmus also beim Import der Fotos nur auf den Dateinamen als Schlüssel, überschriebe er entweder bereits bestehende Dateien im Rechner-Archiv, oder käme zu dem Schluss, dass manche Dateien bereits vorher importiert worden waren, und somit beim aktuellen Import zu ignorieren wären. Beides wäre falsch, vielmehr muss der Importierer alle Foto neu archivieren, die noch nicht im Archiv sind.
Abbildung 2: Auf eine manuell eingefügte Datei DSC99999.JPG hin legt die Kamera einen neuen Ordner an. |
Wie nun kann ein Import-Programm feststellen, ob eine Datei auf der SD-Karte tatsächlich neu ist, auch wenn im Archiv schon eine mit dem gleichen Namen residiert? Das Go-Programm in dieser Ausgabe behilft sich mit einer Cache-Datei, die importierte Fotos mit ihren Parent-Verzeichnissen, sowie einer UUID für die jeweilige SD-Karte mitprotokolliert.
Abbildung 3: Der erste Aufruf des Importers kopiert drei neue Dateien, der zweite tut nichts mehr. |
Abbildung 3 zeigt den Importierer in Aktion. Mit dem Namen des Foto-Verzeichnisses aufgerufen (im Normalfall das der gemounteten SD-Karte), arbeitet er sich durch die einzelnen Aufnahmen in den Tiefen der Kartenstruktur, prüft, ob das jeweilige Foto gemäß der Cache-Daten vorher schon kopiert wurde, und falls nicht, bugsiert er es in die datumsbasierte Dateistruktur nach Abbildung 4.
Abbildung 4: Abgelegte Fotos in der datumsbasierten Datei-Struktur. |
Listing 1 implementiert das Kurzzeitgedächtnis, das sich merkt, welche Fotos importer
bereits kopiert hat, anhand deren Namen und Dateigröße. Als Cache nutzt es eine Go-Map vom Typ map[string]bool
, die jedem Foto-Pfad (als String) einen wahren Wert zuweist, falls das jeweilige Foto schon kopiert wurde. Dabei spielt in den Foto-Pfad nicht nur der Name der Fotodatei mit hinein, sondern auch das Verzeichnis, in dem es auf der Karte liegt (zum Beispiel 100MSDCF, wie in Abbildung 5).
Zur Identifizierung der jeweiligen SD-Karte nutzt es weiterhin eine 36-stellige UUID, die es beim ersten Import dort in der Datei .uuid
im obersten Verzeichnis der Karte frisch erzeugt und für folgende Import-Versuche von dort wieder einliest. Wie aus Abbildung 5 ersichtlich ist auch die UUID der Karte ist Teil des Schlüssels bereits importierter Fotos im Cache, sodass dieser genau weiß, von welcher Karte ein bestimmtes Foto kam.
Abbildung 5: In der Cache-Datei merkt sich der Importierer Dateien mitsamt der UUID der verwendeten SD-Karte. |
In Listing 1 definiert die Struktur Cache
ab Zeile 16 die Daten einer Cache-Instanz für eine gerade bearbeitete Karte. Der Konstruktor NewCache()
ab Zeile 24 gibt die Struktur vorinitialisiert und als Pointer an den Aufrufer zurück der diesen in einer Variablen wie cache
speichert. Tippt der Programmierer dann cache.Funktion()
, schleift Go den Struktur-Pointer mit seinem Receiver-Mechanismus bei Aufrufen von Funktionen mit. So geht Objektorientierung in Go!
01 package main 02 03 import ( 04 "bufio" 05 "fmt" 06 "github.com/google/uuid" 07 "io/ioutil" 08 "os" 09 "path" 10 "strings" 11 ) 12 13 const uuidFile = ".uuid" 14 const cacheFile = ".idb-import-cache" 15 16 type Cache struct { 17 uuid string 18 iPath string 19 uuidPath string 20 cachePath string 21 cache map[string]bool 22 } 23 24 func NewCache(ipath string) *Cache { 25 return &Cache{ 26 uuid: "", 27 uuidPath: path.Join(ipath, uuidFile), 28 iPath: ipath, 29 cachePath: "", 30 cache: map[string]bool{}, 31 } 32 } 33 34 func (cache *Cache) Init() { 35 buf, err := ioutil.ReadFile(cache.uuidPath) 36 if err == nil { 37 cache.uuid = strings.TrimSpace(string(buf)) 38 } else { 39 if os.IsNotExist(err) { 40 uuid := uuid.New().String() 41 err := ioutil.WriteFile(cache.uuidPath, []byte(uuid), 0644) 42 panicOnErr(err) 43 cache.uuid = uuid 44 } else { 45 panicOnErr(err) 46 } 47 } 48 49 homedir, err := os.UserHomeDir() 50 panicOnErr(err) 51 cache.cachePath = path.Join(homedir, cacheFile) 52 } 53 54 func (cache *Cache) Read() { 55 f, err := os.Open(cache.cachePath) 56 if os.IsNotExist(err) { 57 return 58 } 59 panicOnErr(err) 60 defer f.Close() 61 62 scanner := bufio.NewScanner(f) 63 for scanner.Scan() { 64 line := scanner.Text() 65 cache.cache[line] = true 66 } 67 68 return 69 } 70 71 func (cache Cache) Write() { 72 f, err := os.OpenFile(cache.cachePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) 73 panicOnErr(err) 74 defer f.Close() 75 76 for k, _ := range cache.cache { 77 fmt.Fprintf(f, "%s\n", k) 78 } 79 return 80 } 81 82 func (cache Cache) Exists(key string) bool { 83 _, ok := cache.cache[cache.uuid+":"+key] 84 return ok 85 } 86 87 func (cache Cache) Set(key string) { 88 cache.cache[cache.uuid+":"+key] = true 89 }
Auf diese Weise liest Read()
ab Zeile 54 die Daten aus der Cache-Datei und verwandelt sie in eine Go-Map, die Fotopfade boolschen Werten zuweist. Dazu öffnet sie die Datei mit os.Open()
und spannt für den daraus resultierenden Reader ab Zeile 62 einen Scanner aus dem Paket bufio
ein. Der wanzt sich mit Scan()
in Zeile 63 durch jede einzelne Zeile der Cache-Datei und holt mit Text()
deren Text als String, exklusive des Zeilenumbruchs. Die Zuweisung in Zeile 65 legt für jeden Cache-Eintrag einen Schlüssel in der Map cache
an, und weist ihm einen wahren Wert zu. Die Map bleibt in der Instanzstruktur als cache.cache
gespeichert, und andere Funktionen wie cache.Exists()
oder cache.Set()
können später darauf zugreifen.
Um die Arbeit am Cache nach getaner Arbeit wieder in der Cache-Datei zu sichern, schreibt die Funktion Write()
ab Zeile 71 die modifizierte Map wieder zurück. Dazu öffnet sie die Cache-Datei mit OpenFile()
in Zeile 72, und iteriert über die Map-Einträge, um sie mit fmt.Fprintf
einzeln in die Cache-Datei zurückzuschreiben, wobei sie die alte wegen der Optionen O_TRUNC
überschreibt.
Bislang ungesehene SD-Karten weisen in ihren Root-Verzeichnissen keine .uuid
-Dateien auf. Die Funktion Init()
ab Zeile 34 prüft dies und erzeugt mit dem Github-Paket uuid
aus dem Hause Google in Zeile 40 eine neue, falls Zeile 36 vorher noch keine gefunden hat. Dieser 36-stellige String ist jedesmal garantiert einmalig, sodass er auch in Zukunft damit markierte Karten eindeutig identifiziert ([3]).
Das Datum der Aufnahme eines Fotos ermittelt die Funktion photoDate()
ab Zeile 11 in Listing 2. Das Paket exif
aus dem Projekt goexif2
auf Github stellt komfortable Funktionen bereit, die den Exif-Header eines JPG-Bildes auslesen, dekodieren, und als Variable vom Go-Typ time.Time
zurückgeben. Dessen Funktionen Year()
, Month()
und Day()
wandeln das Aufnahmedatum in Jahr, Monat und Tag um, was importer
später dazu nutzt, die verschachtelte Dateistruktur zur Aufbewahrung der Fotos zu erzeugen und zum Speichern zu nutzen.
01 package main 02 03 import ( 04 "fmt" 05 exif "github.com/xor-gate/goexif2/exif" 06 "io" 07 "os" 08 "path" 09 ) 10 11 func photoDate(path string) ([]int, error) { 12 dt := []int{} 13 14 f, err := os.Open(path) 15 if err != nil { 16 return dt, err 17 } 18 19 x, err := exif.Decode(f) 20 if err != nil { 21 return dt, err 22 } 23 24 t, err := x.DateTime() 25 if err != nil { 26 return dt, err 27 } 28 29 return []int{int(t.Year()), int(t.Month()), int(t.Day()), 30 int(t.Hour()), int(t.Minute()), int(t.Second())}, nil 31 } 32 33 func copy(src, dst string) (int64, error) { 34 sourceFileStat, err := os.Stat(src) 35 if err != nil { 36 return 0, err 37 } 38 39 if !sourceFileStat.Mode().IsRegular() { 40 return 0, fmt.Errorf("%s is not a regular file", src) 41 } 42 43 source, err := os.Open(src) 44 if err != nil { 45 return 0, err 46 } 47 defer source.Close() 48 49 dest, err := os.Create(dst) 50 if err != nil { 51 return 0, err 52 } 53 defer dest.Close() 54 nBytes, err := io.Copy(dest, source) 55 return nBytes, err 56 } 57 58 func targetDir() string { 59 homedir, err := os.UserHomeDir() 60 panicOnErr(err) 61 return path.Join(homedir, "/idb") 62 }
Leider findet sich nirgendwo in der Go-Standard-Library eine Funktion zum Kopieren von Dateien, und so muss copy()
ab Zeile 30 in Listing 2 Ursprungs- und Zieldatei öffnen, und mit io.Copy()
blockweise aus der Quelle source
lesen sowie ins Ziel dest
schreiben. Als Archivverzeichnis für den Importierer dient ~/idb
im Home-Verzeichnis, dessen Pfad die Funktion targetDir()
ab Zeile 55 in Listing 2 ermittelt und zurückgibt.
01 package main 02 03 import ( 04 "errors" 05 "flag" 06 "fmt" 07 "os" 08 "path" 09 "path/filepath" 10 rex "regexp" 11 ) 12 13 func main() { 14 flag.Usage = func() { 15 fmt.Printf("Usage: %s dir\n", path.Base(os.Args[0])) 16 os.Exit(1) 17 } 18 19 flag.Parse() 20 if flag.NArg() < 1 { 21 flag.Usage() 22 } 23 24 idir := flag.Args()[0] 25 26 tDir := targetDir() 27 _, err := os.Stat(tDir) 28 if errors.Is(err, os.ErrNotExist) { 29 err := os.Mkdir(tDir, 0755) 30 panicOnErr(err) 31 } 32 33 cache := NewCache(idir) 34 cache.Init() 35 cache.Read() 36 37 filepath.Walk(idir, func(ipath string, f os.FileInfo, err error) error { 38 jpgMatch := rex.MustCompile(`(?i)^\w.*JPG$`) 39 dir, bpath := path.Split(ipath) 40 match := jpgMatch.MatchString(bpath) 41 if !match { 42 return nil 43 } 44 45 dir = path.Base(dir) 46 twoPath := path.Join(dir, bpath) // parent/file 47 48 ok := cache.Exists(twoPath) 49 if ok { 50 return nil // already archived 51 } 52 53 dt, err := photoDate(ipath) 54 if err != nil { 55 fmt.Printf("Error: %s: %s\n", ipath, err) 56 return nil 57 } 58 dstDir := fmt.Sprintf("%s/%d/%02d/%02d", tDir, dt[0], dt[1], dt[2]) 59 os.MkdirAll(dstDir, 0755) 60 newFile := path.Base(ipath) 61 dst := fmt.Sprintf("%s/%d%02d%02d%02d%02d%02d-%s", 62 dstDir, dt[0], dt[1], dt[2], dt[3], dt[4], dt[5], newFile) 63 fmt.Printf("Copying %s to %s\n", ipath, dst) 64 _, err = copy(ipath, dst) 65 panicOnErr(err) 66 67 cache.Set(twoPath) 68 return nil 69 }) 70 71 cache.Write() 72 } 73 74 func panicOnErr(err error) { 75 if err != nil { 76 panic(err) 77 } 78 }
Im Hauptprogramm in Listing 3 prüft main()
zunächst, ob dem Aufruf auch ein Verzeichnis zum Importieren von Fotos beiliegt. Nach dem Einlesen der Cache-Datei in Zeile 32 steigt die Funktion Walk()
aus dem Standard-Paket filepath
in die Untiefen des angegebenen Import-Verzeichnisses und bearbeitet alle dort gefundenen JPG-Dateien.
Der reguläre Ausdruck in Zeile 38 filtert alle Nicht-JPGs aus und lässt den Walker bei Fremdkörpern unverrichteter Dinge zurückkehren. Handelt es sich offensichtlich um ein reguläres Foto, trennt Zeile 39 den Pfad in Verzeichnis und Dateiname auf, und Zeile 45 schneidet von ersterem alles bis auf den letzten Teilpfad ab. Daraus, und aus dem Dateinamen macht dann Zeile 46 in twoPath
den kurzen Pfad aus Elternverzeichnis und Dateiname, den der Cache später als Schlüssel nutzt.
Zeile 48 prüft dann, ob der kurze Pfad schon im Cache existiert, also die Datei vorher schon einmal archiviert wurde. Falls ja, kehrt der Callback Walk()
Zeile 50 unverrichteter Dinge zurück. Liegt aber offensichtlich ein bislang unarchiviertes Foto vor, extrahiert photoDate()
in Zeile 53 Jahr, Monat und Tag der Aufnahme aus dessen Exif-Headern und bestimmt daraus das Zielverzeichnis im Archiv als idb/jahr/monat/tag
, das es auch gleich anlegt, falls es bis dato noch nicht existiert.
Nun geht es ans Kopieren des Fotos ins Archiv. In den Namen der Zieldatei im Archivverzeichnis baut Zeile 61 noch einmal das Datum der Aufnahme mit ein. Grund für diese scheinbare Redundanz ist das Tool idb
aus der letzten Ausgabe ([4]), das mit der Option -xlink
alle mit einem bestimmten Tag versehenen Fotos in ein Verzeichnis verlinkt, und dort könnten sonst mehrere Fotodateien mit dem Namen DSC00001.JPG
landen, da die Sequenznummern von der Kamera auf neu formatierten Karten wieder und wieder verwendet werden.
Nach getaner Kopierarbeit markiert Zeile 67 die Datei mitsamt der UUID der Karte im Cache, den Zeile 71 am Ende der Funktion wieder auf die Festplatte schreibt.
Wie immer kompiliert sich das Go-Programm aus den Sourcen mit dem Dreisatz
$ go mod init importer
$ go mod tidy
$ go build importer.go cacher.go util.go
Das erzeugte Binary importer
enthält dann alle von Github hereingezogenen Abhängigkeiten und lässt sich problemlos auf Systeme ähnlicher Architektur kopieren und ausführen.
Profis raten übrigens dazu, auf SD-Karten für Kameras niemals Bilder einzeln zu löschen, sondern gleich die ganze Karte zu formatieren, wenn sie sich zu sehr füllt. Grund dafür diesen radikalen Schnitt ist, dass der Reformatierungsprozess auch gleich die schlechten Blöcke auf der Karte neu ermittelt und durch gute ersetzt. Beim bloßen Löschen von Fotos nach deren Archivierung unterbleibt dieser wichtige Schritt, und früher oder später sitzt der Fotograf auf einer korrupten Karte und rauft sich die Haare während eine Hochzeitspaar sich gegenseitig die Ringe ansteckt.
Falls die SD-Karte tatsächlich neu formatiert wird, verschwindet auf ihr auch die .uuid
-Datei und der Importer wird beim nächsten Archivvorgang wieder eine neue anlegen. Die Namen der Fotos auf der Karte werden damit in einem eigenen Namensraum behandelt und wiederverwendete Dateinamen stellen kein Problem dar. Warum übrigens das ganze Gewause um die uuid und Unterverzeichnisse, wenn man ganz einfach anhand des Datums der Aufnahme feststellen könnte, ob ein Foto schon im Archiv ist oder noch nicht? Der Grund ist Performance, denn den Namen und Pfad einer Datei kann das Betriebssystem ratz-fatz aus der Inode-Tabelle aus lesen, während zum Lesen der Exif-Header mit dem Datum der Inhalt der Datei auszulesen wäre, und das ist um Größenordnungen langsamer.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2022/09/snapshot/
"Design rule for Camera File system", Wikipedia, https://de.wikipedia.org/wiki/Design_rule_for_Camera_File_system
UUID, https://de.wikipedia.org/wiki/Universally_Unique_Identifier
Michael Schilli, "Digitaler Schuhkarton": Linux-Magazin 09/2022, S.XXX, <U>https://www.linux-magazin.de/ausgaben/2022/09/snapshot/<U>
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc