Jedes mit dem Handy geschossene Foto speichert den Ort der Aufnahme in seinen Exif-Daten. Ein Go-Programm findet in der Fotosammlung von Mike Schilli weitere Bilder im Umkreis von wenigen Metern.
Neulich sperrte unvermittelt mein Lieblingsrestaurant “Chow” in San Francisco zu. Gepaart mit dem Trauma, eine neue gute Wirtschaft zu finden, überfiel mich das Verlangen, alte Fotos des Etablissements 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 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 und der Kamerarichtung stehen dort auch GPS-Koordinaten, die den genauen Ort auf der Erdoberfläche festhalten, an dem die Aufnahme entstanden ist (Abbildung 1). In [2] 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.
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« noch einmal 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 etwa innerhalb eines zweidimensionalen Koordinatensystems. Aber wer schnell seinen Trigonometrie-Werkzeuggürtel aus Schulzeiten umschnallt, bekommt es dennoch heraus. Das Schlagwort zur Lösung heißt “Orthodrome” ([3], Abbildung 2) 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, gemeinfrei, CC BY-SA 3.0
Damit steht der Algorithmus im heute gezeigten Skript 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.
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 }
Tieftaucher
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 bis 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 so genannten 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 jedes Mal noch die mit den Referenzdaten gefüllte »Walker«-Struktur mitgibt, sodass »Visit()« sie in Zeile 63 bequem abgreifen und zur Distanzberechnung 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, etwa »MustCompile()« für reguläre Ausdrücke, ersparen dem Programmierer deswegen die Fehlerbehandlung, indem sie intern das Programm mit »Panic()« abbrechen, falls etwas schiefläuft. Die Funktion selbst gibt definitionsgemäß keinen Fehler zurück, weil sie nur zurückkehrt, falls alles geklappt hat.
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 lange 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 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 mit »LatLong()« nicht nur die Exif-Tags »GPS Latitude« und »Longitude« auszulesen, sondern auch ihre Winkelminuten und -sekunden zu analysieren und in eine Fließkommazahl umzurechnen.
Als Bibliothek mit den Geometriefunktionen zur Bestimmung des Abstands zweier Orte erhielt Golang-geo [4] den Zuschlag. Zeile 4 zieht sie unter dem Kürzel »geo« ins Programm, die Installation muss ebenfalls vorher – wie oben gezeigt – mit »go 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 Breitengrad-Paaren füllt sie mit »NewPoint()« jeweils eine Go-Struktur für »golang-geo« und ruft dann vom Startpunkt »p1« aus die Funktion »GreatCircleDistance()« mit dem Argument des Endpunkts »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,1« Kilometer), ignoriert die »return«-Anweisung in Zeile 68 das Bild, andernfalls geben die Zeilen 71 und 72 den Namen des Trefferbilds und dessen Distanz zum Referenzbild aus.

Abbildung 3: Das Referenzfoto für die Bildersuche zeigt die Speisekarte des Lieblingsrestaurants von Mike Schilli.
Bei der Suche nach Bildern des eingangs erwähnten, nun aber geschlossenen Restaurants stieß der Algorithmus übrigens tatsächlich noch auf ein weiteres Bild, nachdem ich ihm das Foto der einmal abfotografierten Speisekarte (Abbildung 3) 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 4). Ein historisch belegter Augenblick.

Abbildung 4: Gemeldeter Treffer: Foto nach Erhalt der Rechnung im selben Restaurant an einem anderen Tag.
Großer KI-Bruder
Liegen die GPS-Daten einer Fotosammlung vor, sind der Fantasie bei der Analyse keine Grenzen gesetzt. So könnte ein KI-Programm mit der Nearest-Neighbor-Methode aus den Knips-Orten Cluster bilden – wie schon einmal in einem Snapshot erläutert [5] – und damit die häufigsten Aufenthaltsorte des Handybesitzers ermitteln. Schon leicht bedenklich, was so geht, aber die sozialen Medien leben bekanntlich davon. (uba)
Online PLUS
Im Screencast demonstriert Michael Schilli das Beispiel: http://www.linux-magazin.de/videos/
Infos
-
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2019/06/snapshot/
-
Kevin Mitnick, “The Art of Invisibility”: https://www.amazon.com/Art-Invisibility-Worlds-Teaches-Brother/dp/0316380504
-
Orthodrome, Verbindung zweier Punkte auf einer Kugeloberfläche: https://de.wikipedia.org/wiki/Orthodrome
-
Golang-geo, Bibliothek zur Berechnung von Abständen zwischen geografischen Punkten: https://www.github.com/kellydunn/golang-geo
-
Michael Schilli, “Sehen lernen”: Linux-Magazin 11/12, S. 104, https://www.linux-magazin.de/ausgaben/2012/11/perl-snapshot/







