Wolle mer se reilasse? (Linux-Magazin, Oktober 2022)

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.

Loser Standard

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.

Chaos vorprogrammiert

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.

Prüfe und spare

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.

Knopf im Taschentuch

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!

Listing 1: cacher.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 }

Karten markieren

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]).

Datum aus Exif-Headern

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.

Listing 2: util.go

    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.

Listing 3: importer.go

    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.

Nur JPGs

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.

Installation

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.

Profi-Tipp: Formatieren

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.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2022/09/snapshot/

[2]

"Design rule for Camera File system", Wikipedia, https://de.wikipedia.org/wiki/Design_rule_for_Camera_File_system

[3]

UUID, https://de.wikipedia.org/wiki/Universally_Unique_Identifier

[4]

Michael Schilli, "Digitaler Schuhkarton": Linux-Magazin 09/2022, S.XXX, <U>https://www.linux-magazin.de/ausgaben/2022/09/snapshot/<U>

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc