Aus Linux-Magazin 10/2022

Go organisiert Fotos mit Datum

© Alexandr Shirokov / 123RF.com

Fotos vom Handy oder der SD-Karte kopiert Mike Schilli mit einem Go-Programm in eine datumsbasierte Dateistruktur auf dem Rechner. Ein Cache sorgt dafür, dass nur Neues übertragen und keine Zeit verplempert wird.

Fotos vom Handy oder der SD-Karte meiner nagelneuen Mirrorless-Kamera importiere ich regelmäßig zum Archivieren der besten Exemplare auf den Heimcomputer. Dort bugsiert eine selbst geschriebene Software sie in eine Ordnerstruktur, die für jedes Jahr, jeden Monat und jeden Tag ein eigenes Verzeichnis anlegt. Nach dem Import verbleiben die Bilder meist auf der Karte oder dem Telefon. Der Importierer sollte jedoch 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.

Die Fotos liegen auf der SD-Karte als Dateien im Format »DSCNummer.JPG« vor. Auf dem Telefon haben sie einem anderen Dateinamen, etwa »IMG_Nummer.JPG«. Die laufende Nummer neu geschossener Fotos erhöhen Kameras und Foto-Apps bei jeder Aufnahme um eins. Wie das genau aussieht, beschreibt das Standard-Dokument “Design rule for Camera File system” [1]. Es definiert das Format der Dateinamen mitsamt deren Zählern und legt fest, was passiert, wenn ein Zähler überläuft oder die Kamera feststellt, dass der User zwischenzeitlich andere SD-Karten mit eigenen Zählern genutzt hat.

Abbildung 1 zeigt das typische, DCF-konforme Dateilayout auf der Karte. Auf einer frisch formatierten Card speichert die Kamera die ersten Bilder als »DSC00001.JPG«, »DSC00002.JPG« und so weiter im Unterverzeichnis »100MSDCF/«, das wiederum im Ordner »DCIM« liegt. 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 wieder bei »DSC00001.JPG« beginnen.

Abbildung 1: Das Dateisystem auf der SD-Karte.

Abbildung 1: Das Dateisystem auf der SD-Karte.

Interessantes passiert, falls ein Fotograf SD-Karten wechselt, ohne die frisch eingelegte neu zu formatieren. Dann schnackelt der kamerainterne Zähler vom bislang monoton anwachsenden Wert auf den Wert des Bilds mit dem höchsten Zähler auf der SD-Karte um. Wechselt also der Fotograf zum Beispiel nach der Aufnahme von »DSC02001.JPG« auf eine SD-Karte, die bereits das Foto »DSC09541.JPG« enthält, macht die Kamera dort bei »DSC09542.JPG« weiter, selbst wenn »DSC02002.JPG« noch verfügbar wäre. Je nach Kameramodell und Softwareversion können sich aber Abweichungen einschleichen.

Loser Standard

Als Experiment habe ich eine SD-Karte aus meiner Sony A7 manipuliert. Deren Verzeichnis »100MSDCF/« war mit Bildern von »DSC00205.JPG« bis »DSC00952.JPG« gefüllt, und ich schob ihr auf dem Rechner das neue Foto »DSC99999.JPG« unter. Wieder in die Kamera eingelegt, erzeugte deren Software auf der Karte doch tatsächlich das neue Verzeichnis »101MSDCF/« (parallel zu »100MSDCF/«) und speicherte dort neu aufgenommene Bilder als »DSC00953.JPG«, »DSC00954.JPG« und so weiter (Abbildung 2)!

Abbildung 2: Nach dem manuellen Einfügen der Datei »DSC99999.JPG« legt die Kamera einen neuen Ordner an.

Abbildung 2: Nach dem manuellen Einfügen der Datei »DSC99999.JPG« legt die Kamera einen neuen Ordner an.

Die Kamera merkt sich also – auch nachdem sie aus- und wieder eingeschaltet wurde – das letzte aufgenommene Bild und den Ordner, in dem sie die Aufnahme 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. Würde sich ein Algorithmus also beim Import der Fotos nur auf den Dateinamen als Schlüssel verlassen, überschriebe er entweder bereits bestehende Dateien im Rechnerarchiv oder käme zu dem Schluss, dass manche Dateien bereits vorher importiert wurden und somit beim aktuellen Import zu ignorieren wären. Mit beidem läge er falsch. Stattdessen muss der Importierer alle Fotos neu archivieren, die sich noch nicht im Archiv befinden.

Prüfe und spare

Wie kann nun ein Importprogramm feststellen, ob eine Datei auf der SD-Karte tatsächlich neu ist, auch wenn es im Archiv schon ein Bild mit demselben Namen gibt? Das im Folgenden vorgestellte Go-Programm behilft sich mit einer Cache-Datei, die für importierte Fotos deren Elternverzeichnisse sowie eine UUID für die jeweilige SD-Karte protokolliert.

Abbildung 3 zeigt den Importierer in Aktion. Mit dem Namen des Fotoverzeichnisses aufgerufen (im Normalfall also dem der eingehängten SD-Karte), arbeitet er sich durch die einzelnen Aufnahmen in den Tiefen der Kartenstruktur. Er prüft, ob das jeweilige Foto gemäß der Cache-Daten vorher schon kopiert wurde. Falls nicht, bugsiert er es in eine datumsbasierte Dateistruktur (Abbildung 4).

Abbildung 3: Der erste Aufruf des Importierers kopiert drei neue Dateien, der zweite tut nichts mehr.

Abbildung 3: Der erste Aufruf des Importierers kopiert drei neue Dateien, der zweite tut nichts mehr.

Abbildung 4: Abgelegte Fotos in der datumsbasierten Dateistruktur.

Abbildung 4: Abgelegte Fotos in der datumsbasierten Dateistruktur.

Knopf im Taschentuch

Listing 1 implementiert das Kurzzeitgedächtnis, mit dessen Hilfe das Programm sich merkt, welche Fotos »importer« bereits kopiert hat. Dazu nutzt es deren Namen und Dateigröße. Als Cache dient eine Go-Map vom Typ »map[string]bool«, die jedem Fotopfad (als String) den Wert »true« zuweist, falls das jeweilige Foto bereits kopiert wurde. Dabei spielt in den Fotopfad nicht nur der Name der Fotodatei mit hinein, sondern auch der des Verzeichnisses, in dem es auf der Karte liegt (in Abbildung 5 beispielsweise »100MSDCF/«).

Abbildung 5: In der Cache-Datei merkt sich der Importierer Dateien mitsamt der UUID der verwendeten SD-Karte.

Abbildung 5: In der Cache-Datei merkt sich der Importierer Dateien mitsamt der UUID der verwendeten SD-Karte.

Zur Identifizierung der jeweiligen SD-Karte nutzt das Programm eine 36-stellige UUID. Die erzeugt es beim ersten Import in der Datei ».uuid« im obersten Verzeichnis der Karte und liest die ID für folgende Importversuche von dort auch wieder ein. Wie man in Abbildung 5 sieht, ist die UUID der Karte auch Teil des Schlüssels bereits importierter Fotos im Cache. So weiß das Programm genau, von welcher Karte ein bestimmtes Foto kam.

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 speichert den Zeiger in einer Variablen wie »cache«. Tippt der Programmierer dann »cache.Funktion()«, schleift Go den Struktur-Pointer mit seinem Receiver-Mechanismus bei Aufrufen von Funktionen mit – Objektorientierung in Go.

Listing 1

cacher.go

 package main
 import (
   "bufio"
   "fmt"
   "github.com/google/uuid"
   "io/ioutil"
   "os"
   "path"
   "strings"
 )
 const uuidFile = ".uuid"
 const cacheFile = ".idb-import-cache"
 type Cache struct {
   uuid      string
   iPath     string
   uuidPath  string
   cachePath string
   cache     map[string]bool
 }
 func NewCache(ipath string) *Cache {
   return &Cache{
     uuid:      "",
     uuidPath:  path.Join(ipath, uuidFile),
     iPath:     ipath,
     cachePath: "",
     cache:     map[string]bool{},
   }
 }
 func (cache *Cache) Init() {
   buf, err := ioutil.ReadFile(cache.uuidPath)
   if err == nil {
     cache.uuid = strings.TrimSpace(string(buf))
   } else {
     if os.IsNotExist(err) {
       uuid := uuid.New().String()
       err := ioutil.WriteFile(cache.uuidPath, []byte(uuid), 0644)
       panicOnErr(err)
       cache.uuid = uuid
     } else {
       panicOnErr(err)
     }
   }
   homedir, err := os.UserHomeDir()
   panicOnErr(err)
   cache.cachePath = path.Join(homedir, cacheFile)
 }
 func (cache *Cache) Read() {
   f, err := os.Open(cache.cachePath)
   if os.IsNotExist(err) {
     return
   }
   panicOnErr(err)
   defer f.Close()
   scanner := bufio.NewScanner(f)
   for scanner.Scan() {
     line := scanner.Text()
     cache.cache[line] = true
   }
   return
 }
 func (cache Cache) Write() {
   f, err := os.OpenFile(cache.cachePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
   panicOnErr(err)
   defer f.Close()
   for k, _ := range cache.cache {
     fmt.Fprintf(f, "%s\n", k)
   }
   return
 }
 func (cache Cache) Exists(key string) bool {
   _, ok := cache.cache[cache.uuid+":"+key]
   return ok
 }
 func (cache Cache) Set(key string) {
   cache.cache[cache.uuid+":"+key] = true
 }

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 booleschen 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 Änderung 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 alten 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 das und erzeugt mit dem Github-Paket uuid aus dem Hause Google in Zeile 40 eine neue UUID, falls Zeile 36 vorher noch keine gefunden hat. Dieser 36-stellige String ist jedes Mal garantiert einmalig, sodass er auch in Zukunft damit markierte Karten eindeutig identifiziert [2].

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 JPEG-Bilds 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. Das nutzt »importer« später dazu, die verschachtelte Dateistruktur zur Aufbewahrung der Fotos zu erzeugen und zum Speichern zu nutzen.

Listing 2

util.go

 package main
 import (
   "fmt"
   exif "github.com/xor-gate/goexif2/exif"
   "io"
   "os"
   "path"
 )
 func photoDate(path string) ([]int, error) {
   dt := []int{}
   f, err := os.Open(path)
   if err != nil {
     return dt, err
   }
   x, err := exif.Decode(f)
   if err != nil {
     return dt, err
   }
   t, err := x.DateTime()
   if err != nil {
     return dt, err
   }
   return []int{int(t.Year()), int(t.Month()), int(t.Day()),
                int(t.Hour()), int(t.Minute()), int(t.Second())}, nil
 }
 func copy(src, dst string) (int64, error) {
   sourceFileStat, err := os.Stat(src)
   if err != nil {
     return 0, err
   }
   if !sourceFileStat.Mode().IsRegular() {
     return 0, fmt.Errorf("%s is not a regular file", src)
   }
   source, err := os.Open(src)
   if err != nil {
     return 0, err
   }
   defer source.Close()
   dest, err := os.Create(dst)
   if err != nil {
     return 0, err
   }
   defer dest.Close()
   nBytes, err := io.Copy(dest, source)
   return nBytes, err
 }
 func targetDir() string {
   homedir, err := os.UserHomeDir()
   panicOnErr(err)
   return path.Join(homedir, "/idb")
 }

Allerdings findet sich nirgendwo in der Go-Standard-Library eine Funktion zum Kopieren von Dateien. Daher muss »copy()« ab Zeile 30 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.

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 Importverzeichnisses ab und bearbeitet alle dort gefundenen JPEG-Dateien.

Listing 3

importer.go

package main
import (
  "errors"
  "flag"
  "fmt"
  "os"
  "path"
  "path/filepath"
  rex "regexp"
)
func main() {
  flag.Usage = func() {
    fmt.Printf("Usage: %s dir\n", path.Base(os.Args[0]))
    os.Exit(1)
  }
  flag.Parse()
  if flag.NArg() < 1 {
    flag.Usage()
  }
  idir := flag.Args()[0]
  tDir := targetDir()
  _, err := os.Stat(tDir)
  if errors.Is(err, os.ErrNotExist) {
    err := os.Mkdir(tDir, 0755)
    panicOnErr(err)
  }
  cache := NewCache(idir)
  cache.Init()
  cache.Read()
  filepath.Walk(idir, func(ipath string, f os.FileInfo, err error) error {
    jpgMatch := rex.MustCompile(`(?i)^\w.*JPG$`)
    dir, bpath := path.Split(ipath)
    match := jpgMatch.MatchString(bpath)
    if !match {
      return nil
    }
    dir = path.Base(dir)
    twoPath := path.Join(dir, bpath) // parent/file
    ok := cache.Exists(twoPath)
    if ok {
      return nil // already archived
    }
    dt, err := photoDate(ipath)
    if err != nil {
      fmt.Printf("Error: %s: %s\n", ipath, err)
      return nil
    }
    dstDir := fmt.Sprintf("%s/%d/%02d/%02d", tDir, dt[0], dt[1], dt[2])
    os.MkdirAll(dstDir, 0755)
    newFile := path.Base(ipath)
    dst := fmt.Sprintf("%s/%d%02d%02d%02d%02d%02d-%s",
      dstDir, dt[0], dt[1], dt[2], dt[3], dt[4], dt[5], newFile)
    fmt.Printf("Copying %s to %s\n", ipath, dst)
    _, err = copy(ipath, dst)
    panicOnErr(err)
    cache.Set(twoPath)
    return nil
  })
  cache.Write()
}
func panicOnErr(err error) {
  if err != nil {
    panic(err)
  }
}

Nur JPEGs

Der reguläre Ausdruck in Zeile 38 filtert alle Nicht-JPEGs aus und lässt den Walker bei Fremdkörpern unverrichteter Dinge zurückkehren. Handelt es sich offensichtlich um ein reguläres Foto, dann 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, ob der kurze Pfad schon im Cache existiert, also die Datei vorher schon einmal archiviert wurde. Falls ja, kehrt der Callback »Walk()« in Zeile 50 ohne weitere Aktion zurück. Liegt jedoch ein bislang nicht archiviertes Foto vor, extrahiert »photoDate()« in Zeile 53 Jahr, Monat und Tag der Aufnahme aus deren Exif-Header. Die Funktion bestimmt daraus das Zielverzeichnis im Archiv als »idb/Jahr/Monat/Tag« und legt es auch gleich an, falls es 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, das mit der Option »-xlink« alle mit einem bestimmten Tag versehenen Fotos in ein Verzeichnis verlinkt. 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 kompilieren Sie das Go-Programm, dessen Quellen Sie im Download-Bereich zu diesem Artikel finden, mit dem Dreisatz aus Listing 4. 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.

Listing 4

Kompilieren

$ go mod init importer
$ go mod tidy
$ go build importer.go cacher.go util.go

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.

Der Grund für diesen radikalen Schnitt: Der Reformatierungsprozess ermittelt auch gleich die schlechten Blöcke auf der Karte und ersetzt sie durch gute. Beim bloßen Löschen von Fotos nach deren Archivierung unterbleibt dieser wichtige Schritt, und früher oder später sitzt der Fotograf vor einer defekten Karte und rauft sich die Haare, weil sich die frisch gemachten Hochzeitsfotos nicht mehr auslesen lassen.

Beim Formatieren der SD-Karte verschwindet darauf auch die ».uuid«-Datei, und der Importer legt beim nächsten Archivierungslauf eine neue an. Die Namen der Fotos auf der Karte werden damit in einem eigenen Namensraum behandelt, und wiederverwendete Dateinamen stellen kein Problem dar.

Warum überhaupt das ganze Getue um die UUID und Unterverzeichnisse, wenn man ganz einfach anhand des Datums der Aufnahme feststellen könnte, ob ein Foto schon im Archiv liegt oder noch nicht? Hier geht es um Performance: Den Namen und Pfad einer Datei kann das Betriebssystem ratzfatz aus der Inode-Tabelle fischen, während es zum Lesen der Exif-Header mit dem Datum den Inhalt der Datei auslesen müsste – und das ist um Größenordnungen langsamer.

Infos

  1. Snapshot: Mike Schilli, “Digitaler Schuhkarton”, LM 09/2022, S. 84, https://www.lm-online.de/47382
  2. “Design rule for Camera File system”, Wikipedia: https://de.wikipedia.org/wiki/Design_rule_for_Camera_File_system
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 6 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
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