Rückkehr an den Ort der Tat (Linux-Magazin, Juni 2019)

Neulich sperrte unvermittelt mein Lieblingsrestaurant "Chow" in San Francisco zu und gepaart mit dem Trauma, eine neue gute Wirtschaft zu finden, überfiel mich das Verlangen, alte Fotos des Etablissments aus den guten alten Zeiten auf meinem Handy aufzustöbern. Aber wie? Getaggt hatte ich sie sicher nicht, wer macht das schon. Aber jedes Handy-Foto speichert ja GPS-Informationen, und das Handy gruppiert sie auf angesteuerten Punkten auf einer Landkarte. Allerdings hatte ich die Fotos über die Jahre auf andere Medien ausgelagert, aber da meine neue Lieblingssprache Go Bildverarbeitungsroutinen mitliefert, beschloss ich, meine Fotosammlung nach Aufnahmen im Umfeld des Restaurants zu durchforsten.

Das Unix-Werkzeug exiftool findet blitzschnell die Metadaten einer JPG-Datei heraus und der geneigte Social-Media-Nutzer wird sich vielleicht wundern, welche saftigen Datenhappen er da Facebook & Co zuspielt, falls er es postet. Neben Datum und Uhrzeit, der Höhe über dem Meeresspiegel, der Kamerarichtung stehen dort auch GPS-Koordinaten, die den genauen Ort auf der Erdoberfläche festlegen, an dem die Aufnahme entstanden ist (Abbildung 1). In [5] berichtet Online-Schlawiner Kevin Mitnick gar, dass die Behörden einst einem Drogenbaron auf die Schliche kamen, weil dieser ein Urlaubsfoto veröffentlicht hatte, in dem die Metadaten seines geheimen Aufenthaltsorts noch enthalten waren. Ups.

Abbildung 1: Das Handyfoto enthält die GPS-Daten der Aufnahme

Plaudernde Fotos

Erstaunlicherweise stehen im EXIF-Teil des Bildes Metadaten wie die geografische Breite und Länge des Ortes einer Aufnahme, aber keineswegs in einem computerfreundlichen Format. Vielmehr vermerkt das Handy unter dem Tag "GPS Longitude" den String "122 deg 25' 46.82'' W" und unter "GPS Longitude Ref" nochmal den Buchstaben "W" für westliche Länge. Um diesen String in eine Fließkommazahl umzuwandeln, muss eine Library-Funktion her, die 122 Grad, 25 Winkelminuten und 46.82 Winkelsekunden in die Fließkommadarstellung umwandelt, sowie aus dem "W" für westliche Länge den Zahlenwert negiert, denn nur östliche Längen sind positiv. Heraus kommt der Wert -122.429672 für die geografische Länge, aus der dann, mitsamt der auf ähnlichem Weg ermittelten geografischen Breite, eine weitere Library wiederum den geografischen Abstand zu den Längen- und Breitengraden eines anderen Bildes ermitteln kann.

Nun ist die Distanz zweier als geografische Breite und Länge vorliegender Punkte auf der Erdoberfläche nicht ganz so trivial zu berechnen, wie zum Beispiel innerhalb eines zweidimensionalen Koordinatensystems, aber wer nochmal schnell seinen Trigonometrie-Werkzeuggürtel aus Schulzeiten umschnallt, bekommt's dennoch heraus. Das Schlagwort heißt "Orthodrome" ([3]), die Länge eines Teilsegments des Großkreises auf der (approximierten) Kugeloberfläche der Erde, weswegen das Ganze auf Englisch auch "Great-Circle Distance" heißt. Dank Internet muss man's nicht mal von Hand eintippen, sondern darf eine Go-Library wie [4] verwenden.

Abbildung 2: Die kürzeste Verbindung zweier Punkte auf einer Kugeloberfläche (Quelle: Wikipedia)

Listing 1: geosearch.go

    01 package main
    02 
    03 import (
    04   geo "github.com/kellydunn/golang-geo"
    05   exif "github.com/xor-gate/goexif2/exif"
    06   "log"
    07   "os"
    08   "path/filepath"
    09   rex "regexp"
    10 )
    11 
    12 const maxDist = 0.1
    13 
    14 type Walker struct {
    15   refLat  float64
    16   refLong float64
    17 }
    18 
    19 func main() {
    20   if len(os.Args) != 3 {
    21     panic("usage: " + os.Args[0] +
    22       " refimg searchpath")
    23   }
    24   refImg := os.Args[1]
    25   searchPath := os.Args[2]
    26 
    27   log.Printf("Pics close to %s\n", refImg)
    28   geoSearch(refImg, searchPath)
    29 }
    30 
    31 func geoSearch(refImg string,
    32                searchPath string) {
    33   log.Printf("Walking %s\n", searchPath)
    34 
    35   refLat, refLong, err := GeoPos(refImg)
    36   if err != nil {
    37     panic(err)
    38   }
    39 
    40   w := &Walker{
    41     refLat:  refLat,
    42     refLong: refLong,
    43   }
    44 
    45   err = filepath.Walk(searchPath, w.Visit)
    46   if err != nil {
    47     panic(err)
    48   }
    49 }
    50 
    51 func (w *Walker) Visit(path string,
    52       f os.FileInfo, err error) error {
    53   jpgMatch := rex.MustCompile("(?i)JPG$")
    54   match := jpgMatch.MatchString(path)
    55   if !match {
    56     return nil
    57   }
    58 
    59   lat, long, err := GeoPos(path)
    60   if err != nil {
    61     return nil
    62   }
    63   p1 := geo.NewPoint(w.refLat, w.refLong)
    64   p2 := geo.NewPoint(lat, long)
    65 
    66   dist := p1.GreatCircleDistance(p2)
    67   if dist > maxDist {
    68     return nil
    69   }
    70 
    71   log.Printf("File: %s\n", path)
    72   log.Printf("Distance: %f\n", dist)
    73   return nil
    74 }
    75 
    76 func GeoPos(path string) (float64,
    77        float64, error) {
    78   f, err := os.Open(path)
    79   if err != nil {
    80     return 0, 0, err
    81   }
    82 
    83   x, err := exif.Decode(f)
    84   if err != nil {
    85     return 0, 0, err
    86   }
    87 
    88   lat, long, err := x.LatLong()
    89   if err != nil {
    90     return 0, 0, err
    91   }
    92 
    93   return lat, long, nil
    94 }

Wer stöbert, findet

Damit steht der Algorithmus im heute gezeigten Skripts fest (Listing 1). Es nimmt ein Referenzbild entgegen und einen Suchpfad mit der Fotosammlung. Aus ersterem extrahiert es die Referenz-GPS-Koordinaten, um anschließend der Reihe nach alle Bilder in der Fotosammlung einzulesen, ihre GPS-Koordinaten jeweils mit der Referenz zu vergleichen und beim Unterschreiten eines voreingestellten Mindestabstands zum Referenzbild einen Treffer zu melden.

Die Dateien der als Verzeichnis referenzierten Fotosammlung klappert die Funktion filepath.Walk aus Gos Core-Fundus in beliebiger Verschachtelungstiefe ab. Der Aufruf in Zeile 45 nimmt eine Callback-Funktion entgegen (Visit() ab Zeile 51), und damit diese bei jedem Aufruf gleich die GPS-Daten des Referenzbildes parat hat, bekommt sie eine Struktur vom Typ Walker (definiert ab Zeile 14) mit. Erst setzen die Zeilen 40-43 in der Instanz w die Attribute refLat und refLong auf die GPS-Daten des Referenzbildes, dann bekommt Walk() in Zeile 45 das Bündel w.Visit mit, und Visit() ab Zeile 51 ist mit einem sogenannten "Receiver" vom Typ *Walker definiert. Das hat zur Folge, dass der Marschierer durchs Dateisystem bei jeder gefundenen Datei die Callback-Funktion Visit() aufruft, ihr aber jedesmal auch noch die mit den Referenzdaten gefüllte Walker-Struktur mitgibt, sodass Visit() sie in Zeile 63 bequem abgreifen und zur Distanzbereichnung verwenden kann.

Der Callback prüft außerdem mit einem regulären Ausdruck in Zeile 53, ob die gerade gefundene Datei auch einen ".jpg"-Suffix im Namen führt, wegen des Ausdrucks "(?i)" im Regex-String passt dies auch auf Großbuchstaben wie in .JPG. Das etwas komisch anmutende MustCompile() zum Kompilieren des regulären Ausdrucks in Zeile 53 erklärt sich mit Gos strengem Fehlermanagement: Bei jedem Aufruf einer Funktion, der eventuell schiefgehen kann, muss das Programm prüfen, ob alles im grünen Bereich ist oder im Fehlerfall Rettungsaktionen einleiten. Nun könnte theoretisch auch das Kompilieren eines regulären Ausdrucks schiefgehen (zum Beispiel wenn er illegale Syntax enthält), aber bei einem statischen (und hoffentlich getesteten) String ist das unmöglich. Funktionen mit einem "Must" im Namen, wie MustCompile() für reguläre Ausdrücke, ersparen dem Programmierer deswegen die Fehlerbehandlung, indem sie intern das Programm mit Panic() abbrechen, falls es etwas schiefläuft. Die Funktion selbst gibt definitionsgemäß keinen Fehler zurückt, weil sie nur zurückkehrt, falls alles glatt gelaufen ist.

Zwei Libraries für ein Halleluja

Auf Github buhlen mehrere Bibliotheken zum Einlesen von Exif-Metadaten aus JPG-Fotos um die Gunst der Go-Programmierer. Am praktischsten fand ich das Paket "goexif2", das nicht lang mit tausend Optionen herumeiert, sondern mit Decode() ein Bild analysiert und mit LatLong() dessen Längen- und Breitengrad im Fließkommaformat zurückgibt. Das Projekt von Github-User xor-gate wird zwar laut README nicht mehr weiterentwickelt, funktionierte aber für die gestellte Aufgabe noch einwandfrei. Wer Weiterentwicklungen sucht, findet etwa ein Dutzend Forks, aber die Entscheidung für einen bestimmten gleicht einem Münzwurf. Das Original kommt wie in Go üblich mit

    $ go get github.com/xor-gate/goexif2/exif

auf den heimischen Rechner und ab dann können Programme wie Listing 1 wie in Zeile 5 die Funktionen nutzen. Die Funktion GeoPos() ab Zeile 76 nimmt einen Bildpfad als String entgegen und gibt zwei Floats und einen Fehlerwert zum Aufrufer zurück. Erst öffnet sie die Bilddatei zum Lesen, interpretiert die Jpeg-Daten mit Decode(), um dann mittels LatLong() nicht nur die Exif-Tags GPS Latitude/Longitude auszulesen, sondern auch ihre Winkelminuten und -sekunden zu analysieren und in eine Fließkommazahl umzurechnen. Als Bibliothek mit den Geometriefunktionen zur Bestimmung des Abstandes zweier Orte erhielt golang-geo den Zuschlag. Zeile 4 zieht sie unter dem Kürzel geo ins Programm, die Installation muss ebenfalls vorher wie oben gezeigt mit git get erfolgen.

Abstand auf Großkreis

Den Abstand zwischen dem aktuellen und dem Referenzbild ermittelt die Programmlogik ab Zeile 63. Aus beiden Längen- und Breitengradenpaaren füllt sie mit NewPoint() jeweils eine Go-Struktur für golang-geo und ruft dann vom Startpunkt p1 aus die Funktion GreadCircleDistance() mit dem Argument des Endpunktes p2 auf. Zurück kommt die Distanz der zwei Punkte in Kilometern als Fließkommazahl. Liegt sie über der in Zeile 12 festgelegten Maximaldistanz von 100 Metern (0.1km), ignoriert die return-Anweisung in Zeile 68 das Bild, andernfalls spucken die Zeilen 71 und 72 den Namen des Trefferbildes und dessen Distanz zum Referenzbild aus.

Bei der Suche nach Bildern des eingangs erwähnten, nun geschlossenen Restaurants stieß der Algorithmus übrigens tatsächlich noch auf ein weiteres Bild, nachdem ich ihm das Foto der einmal abfotografierten Speisekarte (Abbildung 2) als Lockmittel gab: Es zeigt einen Holztisch mit geleerten Bier- und Weingläsern, nachdem der Ober die Rechnung, wie in diesem Restaurant üblich, in einem sauberen Wasserglas gebracht hatte (Abbildung 3). Ein historisch belegter Moment.

Abbildung 3: Referenzfoto für die Bildersuche: Speisekarte des Restaurants

Abbildung 4: Gemeldeter Treffer: Foto mit geleerten Bier- und Weingläsern nach Erhalt der Rechnung im gleichen Restaurant an einem anderen Tag.

Großer KI-Bruder

Liegen die GPS-Daten einer Fotosammlung vor, sind der Fantasie bei der Analyse keine Grenzen mehr gesetzt. So könnte ein KI-Programm mittels der Nearest-Neighbor-Methode aus den Knips-Orten Cluster bilden, wie schon einmal in einem zurückliegenden Snapshot erläutert ([6]), und damit die häufigsten Aufenthaltsorte des Handy-Besitzers ermitteln. Schon leicht bedenklich, was so geht, aber die sozialen Medien leben bekanntlich davon.

Infos

[1]

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

[2]

Michael Schilli, "Titel": Linux-Magazin 12/16, S.104, <U>http://www.linux-magazin.de/Ausgaben/2016/12/Perl-Snapshot<U>

[3]

Orthodrome, Verbindung zweier Punkte auf einer Kugeloberfläche: https://de.wikipedia.org/wiki/Orthodrome

[4]

"golang-geo", Bibliothek zur Berechnung von Abständen zwischen geografischen Punkten, github.com/kellydunn/golang-geo"

[5]

Kevin Mitnick, "The Art of Invisibility", 2017, https://www.amazon.com/Art-Invisibility-Worlds-Teaches-Brother/dp/0316380504

[6]

"Sehen lernen", Michael Schilli, Linux-Magazin 11/2012, https://www.linux-magazin.de/ausgaben/2012/11/perl-snapshot/

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