PoC: historische Luftbilder des Kantons Zürich mit lokalen Modellen verorten (DINOv2 + LightGlue, swisstopo-Daten)
See the codeHistorische Senkrecht-Luftbilder des Kantons Zürich werden allein aus dem Bildinhalt verortet: Bildumriss und Zentrum (LV95/WGS84), Konfidenz, Gemeinden und abgebildete Ortsnamen mit Koordinaten. Alle Modelle laufen lokal (Hugging Face; Apple-GPU/MPS oder CPU); Referenzdaten kommen aus offenen swisstopo-Diensten.
Online-Beispiel: Testbilder, Vorhersage und Überblendung «damals/heute» (die verorteten Bilder aus dem Lauf mit 30 StAZH-Luftbildern von 1981)
Wo wurde dieses alte Luftbild aufgenommen? Das Programm beantwortet sie, indem es nur das Bild selbst anschaut (ohne Dateiangaben und Archiv-Metadaten) und die passende Stelle im heutigen Luftbild des Kantons Zürich sucht. Am Ende stehen:
Zwei Beispiele aus dem Lauf mit 30 StAZH-Luftbildern von 1981 (Ausgabe von locate.py: das Luftbild liegt halbtransparent
auf dem heutigen Orthofoto, rot der berechnete Umriss, gelb die Ortsnamen im Bild):
Horgen (Foto 1981_012_1813): Sicherheit «hoch» (1'303 übereinstimmende Punktpaare), Bildmitte LV95 2687238 / 1234239, Gemeinde Horgen, Orte u. a. Horgen, Horgenberg, Moorschwand und Oberhof. Das Bild ist gegenüber Norden um 90° gedreht; auch das findet das Programm selbst heraus.

Flugplatz Dübendorf (Foto 1981_014_2340): Sicherheit «hoch» (1'483 Punktpaare), Bildmitte LV95 2690248 / 1250735, Gemeinden Dietlikon, Dübendorf und Wangen-Brüttisellen, Orte u. a. Dübendorf (Flugplatzareal), Gfenn und Sonnenberg.

Hintergrund: SWISSIMAGE © swisstopo; Luftbilder 1981 © GIS-ZH, Kanton Zürich.
Eine ortskundige Person würde in zwei Stufen vorgehen. Zuerst ein grober Eindruck («Das sieht nach dem Stadtrand von Winterthur aus»), dann die Kontrolle am Detail («Diese Kreuzung und diese Bahnlinie finde ich auf der heutigen Karte genau so wieder»). Das Programm macht dasselbe automatisch, für den ganzen Kanton. Als Vergleich dient das heutige Luftbild von swisstopo (SWISSIMAGE).
altes Luftbild (nur die Bildpunkte)
│
▼
① Grobsuche rund 36'000 Ausschnitte des heutigen Kantons → die 50 ähnlichsten
│
▼
② Feinprüfung welcher dieser 50 passt im Detail wirklich?
│
▼
③ Ergebnis Lage · Umriss · Sicherheit · Gemeinden · Ortsnamen
① Grobsuche: «Was sieht ähnlich aus?» Der Kanton wird in rund 36'000 sich überlappende Ausschnitte zerlegt, kleine und grosse, denn niemand weiss, aus welcher Höhe das alte Bild aufgenommen wurde. Ein KI-Modell (DINOv2) rechnet jeden Ausschnitt in eine Zahlenreihe um, eine Art «Fingerabdruck» des Bildinhalts: Ähnliche Landschaften ergeben ähnliche Zahlen. Dasselbe passiert mit dem alten Bild, in allen vier Drehungen, denn auch die Ausrichtung ist unbekannt. Die 50 Ausschnitte mit den ähnlichsten Fingerabdrücken kommen in die engere Wahl. Das geht schnell, ist aber nur ein Eindruck: «könnte passen».
② Feinprüfung: «Passen die Einzelheiten?» Bei jedem Kandidaten sucht ein zweites KI-Modell (SuperPoint und LightGlue) markante Punkte in beiden Bildern, etwa Gebäudeecken, Kreuzungen oder Feldränder, und ordnet sie einander zu: «Diese Kreuzung links oben im alten Bild ist dieselbe wie die hier im heutigen.» Zeigen beide Bilder wirklich dieselbe Gegend, lassen sich fast alle Paare mit einer einzigen Verschiebung, Drehung und Skalierung ineinander überführen. Zufällige, falsche Paare passen dazu nicht und fallen heraus. Je mehr Paare zusammenpassen, desto sicherer ist die Stelle. Damit schwarz-weisse und farbige Bilder vergleichbar sind, werden beide vorher in Graustufen umgewandelt und im Kontrast angeglichen.
③ Ergebnis Aus den zusammenpassenden Paaren folgt, wie das alte Bild auf die Karte gelegt werden muss. Zur Kontrolle holt das Programm das heutige Luftbild noch einmal exakt für das gefundene Gebiet und gleicht erneut ab. Daraus entstehen Umriss und Mittelpunkt; in den Datenbanken von swisstopo werden die Gemeinden und Ortsnamen nachgeschlagen, die in diesem Gebiet liegen.
Das Mass ist die Zahl der zusammenpassenden Punktpaare:
| Zusammenpassende Paare | Einstufung |
|---|---|
| 40 und mehr | hoch |
| 20 bis 39 | mittel |
| 10 bis 19 | tief |
| unter 10 | nicht verortet: Das Programm meldet lieber nichts, als zu raten |
Jüngster Lauf: 30 Luftbilder der StAZH-Sammlung von 1981 (Swissair-Aufnahmen von 30 verschiedenen Fluglinien, zufällig gewählt). Das Programm kannte weder Ort noch Metadaten:
Früherer Lauf mit Bildern aus swisstopo LUBIS und GIS-ZH (1930–2000): Die 140 verorteten Bilder lagen im Median 75 m neben der Lage laut Archiv, kein einziges mehr als 500 m.
Details siehe Ergebnisse.
Alle KI-Modelle laufen auf dem eigenen Rechner. Das Luftbild wird nirgends hochgeladen; aus dem Internet werden nur Karten- und Ortsdaten von swisstopo abgerufen (siehe Daten: lokal oder live?).
.venv/bin/python src/locate.py pfad/zum/bild.tif
Akzeptiert JPG/PNG/TIFF, ignoriert Metadaten, schneidet den Filmrand automatisch ab.
Schreibt bild.json (Koordinaten, Umriss, Orte) und bild.html (Karte mit Overlay) neben das Bild
(oder nach --out ordner/). Dauer: 30–60 s pro Bild.
uv venv -p 3.12 .venv && uv pip install -p .venv/bin/python -r requirements.txt
cd src
../.venv/bin/python fetch_images.py --n-lubis 200 --n-zh 40 # Bilder holen, Metadaten -> data/gt/
../.venv/bin/python reference.py # SWISSIMAGE-Mosaik 5 m/px
../.venv/bin/python retrieval.py # DINOv2-Suchindex (~15 min)
../.venv/bin/python localize.py # Verortung (~16 s/Bild)
../.venv/bin/python localize.py --retry 50 # zweiter Durchgang für unsichere Bilder
../.venv/bin/python evaluate.py # output/results.csv, results.geojson
../.venv/bin/python make_map.py # output/map/index.html (vorschau.html lokal)
Zusatz-Testset mit den Luftbildern 1981 aus der StAZH-Liste (Excel-Datei mit Bildnummern und Koordinaten); Ablauf wie
oben, mit --set stazh, Ergebnisse unter output/stazh/:
cd src
../.venv/bin/python fetch_stazh.py pfad/zur/liste.xlsx --n 30 # je Fluglinie ein Bild -> data/images_stazh/
../.venv/bin/python localize.py --set stazh
../.venv/bin/python evaluate.py --set stazh # output/stazh/results.csv, results.geojson
../.venv/bin/python make_map.py --set stazh --demo 40 --only-localized --out ../docs # Online-Demo für GitHub Pages
swissNAMES3D muss einmalig nach data/cache/names/ entpackt werden:
https://data.geo.admin.ch/ch.swisstopo.swissnames3d/swissnames3d_2025/swissnames3d_2025_2056.csv.zip
Vorverarbeitung. Abfragebild und Referenz werden in Graustufen umgewandelt und mit CLAHE (lokaler
Kontrastausgleich) angeglichen; das neutralisiert Film-, Farb- und Epochenunterschiede. locate.py skaliert das
Abfragebild auf höchstens 2048 px, schneidet einen dunklen Film-/Scanrand automatisch ab (auto_crop) und zusätzlich
5 % pro Seite.
Grobsuche (src/retrieval.py, src/reference.py). Referenz ist ein
SWISSIMAGE-Mosaik des Kantons (plus 3 km Rand) mit 5 m/px. Es wird mit quadratischen Fenstern in 7 Grössen
(0,8 / 1,2 / 1,8 / 2,7 / 4 / 6 / 9 km; Schrittweite 40 % der Fenstergrösse; nur Fenster im Kanton plus 500 m)
abgedeckt: 35'860 Fenster. Jedes Fenster (auf 322 × 322 px skaliert) wird mit DINOv2 zu einem Vektor mit 1536 Zahlen
(CLS-Token und GeM-Pooling der Patch-Tokens, normalisiert). Das Abfragebild wird in 4 Drehungen eingebettet; per
Kosinus-Ähnlichkeit ergibt sich je Fenster die beste Drehung. Überlappende Fenster ähnlicher Grösse werden unterdrückt
(Non-Maximum-Suppression), die besten K = 50 (locate.py: 80) sind die Kandidaten. Der Index entsteht einmalig
(~15 min) und liegt in data/cache/index_dinov2.npz.
Feinabgleich (src/localize.py, src/matching.py). Pro Kandidat wird der SWISSIMAGE-Ausschnitt (Fenster × 1,6, damit Kontext dabei ist) mit 1024 px geholt. SuperPoint findet Keypoints, LightGlue ordnet sie zu, alle 4 Drehungen in einem Batch. OpenCV schätzt aus den Zuordnungen mit MAGSAC (Toleranz 8 px) eine Homographie; die Zahl der Inlier ist das Gütemass. Unplausible Lösungen (nicht konvex, Seitenverhältnis > 2, stark ungleiche Diagonalen) werden verworfen. Der Kandidat mit den meisten Inliern gewinnt; ab 80 Inliern wird die Suche früh abgebrochen, unter 10 gilt das Bild als nicht verortet. Findet diese Runde weniger als 20 Inlier, folgt eine feinere Runde über die besten 30 Kandidaten: Referenz mit 1536 px, das Abfragebild im Massstab des Kandidatenfensters (960 px). LightGlue verträgt nur etwa Faktor 1,5 Massstabsunterschied; bei 1024 px zeigt der Referenzausschnitt ein Bild von 2,4 km auf 4,3 km Kontext und passt nicht mehr.
Verfeinern. Zweimal: Referenz exakt um den vorhergesagten Umriss neu holen (1,2 × längste Ausdehnung, 1280 px) und erneut abgleichen. Das Ergebnis wird nur übernommen, wenn mindestens 80 % der bisherigen Inlier erreicht werden.
Ergebnis. Die 3×3-Matrix bildet Bildpixel auf LV95 (EPSG:2056) ab: Bildecken ergeben den Umriss, die Bildmitte das Zentrum (zusätzlich in WGS84). Konfidenz nach Inliern: ≥ 40 hoch, ≥ 20 mittel, darunter tief (unter 10: nicht verortet); die Schwellen wurden am Testlauf kalibriert (src/evaluate.py).
Ortsnamen (src/names.py). swissNAMES3D (Polygone, Punkte, Linien) mit ausgewählten Objektarten
(Orte, Ortsteile, Quartiere, Gewässer, Erhebungen, Areale u. a.) im Kanton plus 5 km liegt als räumlicher Index im
Speicher. names_in() liefert alle Punkte im Umriss, nach Bedeutung sortiert (Objektart, Einwohnerkategorie), jeden
Namen einmal, höchstens 40. Die Gemeinden kommen aus der geo.admin-API (swissBOUNDARIES3D, Stand 2025).
Zweiter Durchgang. localize.py --retry N wiederholt Bilder ohne Ergebnis oder mit weniger als 40 Inliern mit
N Kandidaten und übernimmt das neue Ergebnis nur, wenn es mehr Inlier hat.
| Begriff | Bedeutung |
|---|---|
| Orthofoto, SWISSIMAGE | entzerrtes, massstabsgetreues Luftbild von heute (swisstopo); dient als Referenz |
| LV95 | Schweizer Landeskoordinaten (EPSG:2056) in Metern: E (Ost) / N (Nord) |
| Embedding | Zahlenvektor, der den Inhalt eines Bildes zusammenfasst («Fingerabdruck»); ähnliche Bilder ergeben ähnliche Vektoren |
| Keypoint | markanter, wiedererkennbarer Punkt im Bild (Ecke, Kreuzung) |
| Homographie | 3×3-Matrix, die ein Bild auf ein anderes abbildet (Verschiebung, Drehung, Massstab, perspektivische Verzerrung); für Senkrechtaufnahmen über weitgehend ebenem Gelände eine gute Näherung |
| Inlier | Zuordnung, die zur geschätzten Homographie passt (Gegenteil: Ausreisser); ihre Anzahl ist das Gütemass |
| MAGSAC | robustes Schätzverfahren, das die Homographie auch findet, wenn viele Zuordnungen falsch sind |
| CLAHE | lokaler Kontrastausgleich |
| IoU | Überlappung zweier Flächen (Schnittfläche geteilt durch Vereinigungsfläche) |
| Ground Truth | Soll-Wert: Lage laut Archiv-Metadaten, nur für die Auswertung genutzt |
| Modell | Hugging Face | Grösse | Aufgabe |
|---|---|---|---|
| DINOv2 (base) | facebook/dinov2-base | ca. 350 MB | Grobsuche (Embeddings) |
| SuperPoint + LightGlue | ETH-CVG/lightglue_superpoint | ca. 55 MB | Feinabgleich |
Beide werden beim ersten Start vom Hugging Face Hub geladen und danach lokal in ~/.cache/huggingface/ abgelegt.
src/matching.py kennt zwei weitere Matcher (zju-community/matchanything_eloftr,
zju-community/efficientloftr), die die Pipeline nicht verwendet.
Rechenintensiv sind der einmalige Index (35'860 Fenster durch DINOv2) und pro Bild bis zu 50 (bzw. 80) Kandidaten ×
4 Drehungen im Matcher. Der Code nutzt die Apple-GPU (PyTorch mps) und fällt sonst auf die CPU zurück (langsamer;
nicht getestet). CUDA (NVIDIA) ist nicht eingebaut. Richtwerte auf einem Mac mit Apple-GPU: Index ~15 min (einmalig),
Pipeline ~15 s pro Bild (240 Bilder: ~1 h), locate.py 30–60 s pro Bild inklusive Laden der Modelle.
| Was | Wo | Wie |
|---|---|---|
| SWISSIMAGE-Mosaik, 5 m/px (≈ 54 × 67 km) | data/cache/mosaic_5m.png, ca. 280 MB | einmal per WMS geholt, dann lokal |
| DINOv2-Index (35'860 Fenster) | data/cache/index_dinov2.npz, ca. 210 MB | einmal aus dem Mosaik berechnet, dann lokal |
| SWISSIMAGE-Ausschnitte für den Feinabgleich | data/cache/ref/ | live von wms.geo.admin.ch, danach gecacht |
| Ortsnamen (swissNAMES3D) | data/cache/names/, ca. 115 MB | einmalig von Hand entpackt, lokal |
| Gemeinden | geo.admin-API | live, bei jedem Bild, nicht gecacht |
| Modellgewichte | ~/.cache/huggingface/ | beim ersten Start, danach lokal |
Die Grobsuche läuft rein lokal. Der Feinabgleich holt pro neues Bild alle Kandidaten-Ausschnitte vorab und parallel
(auch wenn die Suche danach früh abbricht): ca. 0,4 MB pro Ausschnitt, also rund 20 MB bei 50 und rund 30 MB bei
80 Kandidaten (locate.py). Bekannte Gegenden kommen aus dem Cache; nach den 240 Testbildern umfasst data/cache/ref/
rund 5'700 Ausschnitte (2,2 GB). data/cache/ ist nicht im Repo (.gitignore): Ein frischer Klon baut Mosaik (per WMS)
und Index (~15 min) beim ersten Start selbst auf; nur swissNAMES3D muss von Hand entpackt werden (siehe oben).
Die Verortung wird blind getestet: src/fetch_images.py lädt 240 Senkrechtbilder (200 aus
swisstopo LUBIS, 1930er–2000er, je 25 pro Jahrzehnt; 40 von GIS-ZH, 1981–2000), verkleinert sie auf höchstens 2048 px,
schneidet 5 % Rand ab und speichert sie als PNG mit Zufallsnamen und ohne Metadaten in data/images/. Lage, Umriss und
Jahr laut Archiv liegen getrennt in data/gt/ground_truth.json; der Localizer liest sie nie.
src/evaluate.py vergleicht danach Zentrumsfehler (m), Überlappung der Umrisse (IoU) und die
Übereinstimmung der Siedlungs- und Quartiernamen (Jaccard) mit der Ground Truth.
Die Archiv-Metadaten sind selbst nicht exakt (bei GIS-ZH nur ein achsparalleles Rechteck um das Bild); die gemessenen Abweichungen sind deshalb als Grössenordnung zu lesen.
Zusätzlich gibt es ein Testset aus der StAZH-Liste der Luftbilder 1981 (1'292 Bilder auf 31 Fluglinien,
src/fetch_stazh.py): Aus 30 der 31 Fluglinien wird je ein Bild zufällig gewählt (Seed 42; Bilder, die
schon im ersten Testset sind, ausgenommen), vom GIS-ZH-Scan (rund 190 MB je TIFF) auf höchstens 2048 px verkleinert, 5 % Rand
abgeschnitten und ohne Metadaten in data/images_stazh/ gespeichert. Lage und Umriss laut Liste liegen in
data/gt/stazh_1981.json. Verortung und Auswertung laufen wie oben, mit --set stazh.
Jüngster Lauf: 30 StAZH-Bilder von 1981 (output/stazh/results.csv).
Früherer Lauf mit Bildern aus swisstopo LUBIS und GIS-ZH, 1930–2000 (output/results.csv; der zweite Durchgang
--retry wurde bei Bild 212 von 240 beendet).
| Quelle | Zeitraum | verortete Bilder | Median-Fehler |
|---|---|---|---|
| swisstopo (LUBIS) | 1930er | 6 | 61 m |
| swisstopo (LUBIS) | 1940er | 11 | 79 m |
| swisstopo (LUBIS) | 1950er | 10 | 122 m |
| swisstopo (LUBIS) | 1960er | 17 | 159 m |
| swisstopo (LUBIS) | 1970er | 16 | 106 m |
| swisstopo (LUBIS) | 1980er | 19 | 60 m |
| swisstopo (LUBIS) | 1990er | 8 | 83 m |
| swisstopo (LUBIS) | 2000er | 18 | 16 m |
| GIS-ZH | 1981–2000 | 35 | 65 m |
src/make_map.py entzerrt jedes verortete Bild mit seiner Homographie auf ein Nord-oben-Raster (Web Mercator, WebP) und rechnet denselben Ausschnitt aus dem heutigen SWISSIMAGE dazu; src/map_template.html (Leaflet) macht daraus eine eigenständige Seite:
--demo N erzeugt eine kleine öffentliche Variante: eine zufällige, nach Quelle und Jahrzehnt geschichtete Auswahl von
etwa N Bildern, die Kennzahlen im Kopf gelten weiterhin für alle. Mit --only-localized bleiben Bilder ohne Ergebnis
weg. Das ist die Seite in docs/, die GitHub Pages ausliefert: die 29 verorteten Bilder des Laufs mit 30 StAZH-Bildern
(~16 MB). Einzige externe Ressource ist die Kartenbibliothek Leaflet von cdnjs.cloudflare.com; Web-Fonts werden nicht
geladen.
Code:
| Datei | Aufgabe |
|---|---|
src/locate.py | Einzelbild verorten (Ende-zu-Ende), schreibt .json und .html |
src/fetch_images.py | Testdatensatz holen (LUBIS, GIS-ZH), Metadaten abtrennen |
src/fetch_stazh.py | Zusatz-Testset aus der StAZH-Liste 1981 holen |
src/reference.py | SWISSIMAGE per WMS: Kantons-Mosaik und beliebige Ausschnitte (gecacht) |
src/retrieval.py | DINOv2-Index und Grobsuche |
src/matching.py | SuperPoint/LightGlue-Abgleich, Homographie, Plausibilitätsprüfung |
src/localize.py | Verortung des Testdatensatzes, schreibt output/predictions.json |
src/names.py | Ortsnamen (swissNAMES3D) und Gemeinden |
src/evaluate.py | Vergleich mit der Ground Truth, results.csv und results.geojson |
src/make_map.py, src/map_template.html | interaktive Karte, --demo für die Online-Demo |
src/common.py | Pfade, HTTP-Session, Kantonsgrenze |
Daten und Ergebnisse:
| Pfad | Inhalt |
|---|---|
data/images/ | Abfragebilder ohne Metadaten (Zufallsnamen); nicht im Repo |
data/gt/ground_truth.json | Archiv-Metadaten (nur für die Auswertung) |
data/images_stazh/, data/gt/stazh_1981.json | Zusatz-Testset: 30 Bilder von 1981 aus der StAZH-Liste (src/fetch_stazh.py); Bilder nicht im Repo |
data/cache/ | Mosaik, Index, Referenzausschnitte, Ortsnamen; nicht im Repo |
output/predictions.json | Rohvorhersagen inkl. Homographie Bild→LV95 |
output/predictions_pass1.json | Stand nach dem ersten Durchgang (vor --retry) |
output/results.csv, results.geojson | Ergebnisse mit Fehlern, Gemeinden, Orten |
output/summary.txt | Kennzahlen des Testlaufs (Ausgabe von evaluate.py) |
output/stazh/ | dasselbe für den Lauf mit den 30 StAZH-Bildern (predictions.json, results.csv, results.geojson, summary.txt) |
output/map/ | Vollkarte; lokal erzeugt, nicht im Repo |
docs/ | Online-Demo für GitHub Pages |
beispiele/ | Screenshots der Beispiele oben (stazh_*.jpg) sowie ältere Beispielbilder und -ergebnisse für locate.py |
Luftbilder (LUBIS), SWISSIMAGE, swissNAMES3D, swissBOUNDARIES3D © swisstopo, freie Nutzung mit Quellenangabe
(Nutzungsbedingungen).
Historische Luftbilder 1981–2000 © Kanton Zürich, GIS-ZH (wms.zh.ch/HistLuftZHWMS): offene Daten mit der
opendata.swiss-Nutzungsbedingung «Open use» (auch kommerziell erlaubt, Quellenangabe empfohlen; Stand 12.10.2026,
Datensatz).
Modelle: DINOv2 steht unter Apache-2.0. SuperPoint (in ETH-CVG/lightglue_superpoint) ist nur für akademische oder
gemeinnützige nichtkommerzielle Forschung lizenziert (Magic Leap; die LightGlue-Model-Card übernimmt das). Das Repo
verteilt keine Gewichte, sie werden beim ersten Start von Hugging Face geladen. Für einen produktiven Betrieb ist das
zu klären; auf Hugging Face als Apache-2.0 ausgewiesen sind zju-community/matchanything_eloftr,
zju-community/efficientloftr (beide in src/matching.py vorgesehen) und ETH-CVG/lightglue_disk. Wie gut sie hier
verorten, ist nicht dokumentiert.
PoC: historische Luftbilder des Kantons Zürich mit lokalen Modellen verorten (DINOv2 + LightGlue, swisstopo-Daten)
See the codeHistorische Senkrecht-Luftbilder des Kantons Zürich werden allein aus dem Bildinhalt verortet: Bildumriss und Zentrum (LV95/WGS84), Konfidenz, Gemeinden und abgebildete Ortsnamen mit Koordinaten. Alle Modelle laufen lokal (Hugging Face; Apple-GPU/MPS oder CPU); Referenzdaten kommen aus offenen swisstopo-Diensten.
Online-Beispiel: Testbilder, Vorhersage und Überblendung «damals/heute» (die verorteten Bilder aus dem Lauf mit 30 StAZH-Luftbildern von 1981)
Wo wurde dieses alte Luftbild aufgenommen? Das Programm beantwortet sie, indem es nur das Bild selbst anschaut (ohne Dateiangaben und Archiv-Metadaten) und die passende Stelle im heutigen Luftbild des Kantons Zürich sucht. Am Ende stehen:
Zwei Beispiele aus dem Lauf mit 30 StAZH-Luftbildern von 1981 (Ausgabe von locate.py: das Luftbild liegt halbtransparent
auf dem heutigen Orthofoto, rot der berechnete Umriss, gelb die Ortsnamen im Bild):
Horgen (Foto 1981_012_1813): Sicherheit «hoch» (1'303 übereinstimmende Punktpaare), Bildmitte LV95 2687238 / 1234239, Gemeinde Horgen, Orte u. a. Horgen, Horgenberg, Moorschwand und Oberhof. Das Bild ist gegenüber Norden um 90° gedreht; auch das findet das Programm selbst heraus.

Flugplatz Dübendorf (Foto 1981_014_2340): Sicherheit «hoch» (1'483 Punktpaare), Bildmitte LV95 2690248 / 1250735, Gemeinden Dietlikon, Dübendorf und Wangen-Brüttisellen, Orte u. a. Dübendorf (Flugplatzareal), Gfenn und Sonnenberg.

Hintergrund: SWISSIMAGE © swisstopo; Luftbilder 1981 © GIS-ZH, Kanton Zürich.
Eine ortskundige Person würde in zwei Stufen vorgehen. Zuerst ein grober Eindruck («Das sieht nach dem Stadtrand von Winterthur aus»), dann die Kontrolle am Detail («Diese Kreuzung und diese Bahnlinie finde ich auf der heutigen Karte genau so wieder»). Das Programm macht dasselbe automatisch, für den ganzen Kanton. Als Vergleich dient das heutige Luftbild von swisstopo (SWISSIMAGE).
altes Luftbild (nur die Bildpunkte)
│
▼
① Grobsuche rund 36'000 Ausschnitte des heutigen Kantons → die 50 ähnlichsten
│
▼
② Feinprüfung welcher dieser 50 passt im Detail wirklich?
│
▼
③ Ergebnis Lage · Umriss · Sicherheit · Gemeinden · Ortsnamen
① Grobsuche: «Was sieht ähnlich aus?» Der Kanton wird in rund 36'000 sich überlappende Ausschnitte zerlegt, kleine und grosse, denn niemand weiss, aus welcher Höhe das alte Bild aufgenommen wurde. Ein KI-Modell (DINOv2) rechnet jeden Ausschnitt in eine Zahlenreihe um, eine Art «Fingerabdruck» des Bildinhalts: Ähnliche Landschaften ergeben ähnliche Zahlen. Dasselbe passiert mit dem alten Bild, in allen vier Drehungen, denn auch die Ausrichtung ist unbekannt. Die 50 Ausschnitte mit den ähnlichsten Fingerabdrücken kommen in die engere Wahl. Das geht schnell, ist aber nur ein Eindruck: «könnte passen».
② Feinprüfung: «Passen die Einzelheiten?» Bei jedem Kandidaten sucht ein zweites KI-Modell (SuperPoint und LightGlue) markante Punkte in beiden Bildern, etwa Gebäudeecken, Kreuzungen oder Feldränder, und ordnet sie einander zu: «Diese Kreuzung links oben im alten Bild ist dieselbe wie die hier im heutigen.» Zeigen beide Bilder wirklich dieselbe Gegend, lassen sich fast alle Paare mit einer einzigen Verschiebung, Drehung und Skalierung ineinander überführen. Zufällige, falsche Paare passen dazu nicht und fallen heraus. Je mehr Paare zusammenpassen, desto sicherer ist die Stelle. Damit schwarz-weisse und farbige Bilder vergleichbar sind, werden beide vorher in Graustufen umgewandelt und im Kontrast angeglichen.
③ Ergebnis Aus den zusammenpassenden Paaren folgt, wie das alte Bild auf die Karte gelegt werden muss. Zur Kontrolle holt das Programm das heutige Luftbild noch einmal exakt für das gefundene Gebiet und gleicht erneut ab. Daraus entstehen Umriss und Mittelpunkt; in den Datenbanken von swisstopo werden die Gemeinden und Ortsnamen nachgeschlagen, die in diesem Gebiet liegen.
Das Mass ist die Zahl der zusammenpassenden Punktpaare:
| Zusammenpassende Paare | Einstufung |
|---|---|
| 40 und mehr | hoch |
| 20 bis 39 | mittel |
| 10 bis 19 | tief |
| unter 10 | nicht verortet: Das Programm meldet lieber nichts, als zu raten |
Jüngster Lauf: 30 Luftbilder der StAZH-Sammlung von 1981 (Swissair-Aufnahmen von 30 verschiedenen Fluglinien, zufällig gewählt). Das Programm kannte weder Ort noch Metadaten:
Früherer Lauf mit Bildern aus swisstopo LUBIS und GIS-ZH (1930–2000): Die 140 verorteten Bilder lagen im Median 75 m neben der Lage laut Archiv, kein einziges mehr als 500 m.
Details siehe Ergebnisse.
Alle KI-Modelle laufen auf dem eigenen Rechner. Das Luftbild wird nirgends hochgeladen; aus dem Internet werden nur Karten- und Ortsdaten von swisstopo abgerufen (siehe Daten: lokal oder live?).
.venv/bin/python src/locate.py pfad/zum/bild.tif
Akzeptiert JPG/PNG/TIFF, ignoriert Metadaten, schneidet den Filmrand automatisch ab.
Schreibt bild.json (Koordinaten, Umriss, Orte) und bild.html (Karte mit Overlay) neben das Bild
(oder nach --out ordner/). Dauer: 30–60 s pro Bild.
uv venv -p 3.12 .venv && uv pip install -p .venv/bin/python -r requirements.txt
cd src
../.venv/bin/python fetch_images.py --n-lubis 200 --n-zh 40 # Bilder holen, Metadaten -> data/gt/
../.venv/bin/python reference.py # SWISSIMAGE-Mosaik 5 m/px
../.venv/bin/python retrieval.py # DINOv2-Suchindex (~15 min)
../.venv/bin/python localize.py # Verortung (~16 s/Bild)
../.venv/bin/python localize.py --retry 50 # zweiter Durchgang für unsichere Bilder
../.venv/bin/python evaluate.py # output/results.csv, results.geojson
../.venv/bin/python make_map.py # output/map/index.html (vorschau.html lokal)
Zusatz-Testset mit den Luftbildern 1981 aus der StAZH-Liste (Excel-Datei mit Bildnummern und Koordinaten); Ablauf wie
oben, mit --set stazh, Ergebnisse unter output/stazh/:
cd src
../.venv/bin/python fetch_stazh.py pfad/zur/liste.xlsx --n 30 # je Fluglinie ein Bild -> data/images_stazh/
../.venv/bin/python localize.py --set stazh
../.venv/bin/python evaluate.py --set stazh # output/stazh/results.csv, results.geojson
../.venv/bin/python make_map.py --set stazh --demo 40 --only-localized --out ../docs # Online-Demo für GitHub Pages
swissNAMES3D muss einmalig nach data/cache/names/ entpackt werden:
https://data.geo.admin.ch/ch.swisstopo.swissnames3d/swissnames3d_2025/swissnames3d_2025_2056.csv.zip
Vorverarbeitung. Abfragebild und Referenz werden in Graustufen umgewandelt und mit CLAHE (lokaler
Kontrastausgleich) angeglichen; das neutralisiert Film-, Farb- und Epochenunterschiede. locate.py skaliert das
Abfragebild auf höchstens 2048 px, schneidet einen dunklen Film-/Scanrand automatisch ab (auto_crop) und zusätzlich
5 % pro Seite.
Grobsuche (src/retrieval.py, src/reference.py). Referenz ist ein
SWISSIMAGE-Mosaik des Kantons (plus 3 km Rand) mit 5 m/px. Es wird mit quadratischen Fenstern in 7 Grössen
(0,8 / 1,2 / 1,8 / 2,7 / 4 / 6 / 9 km; Schrittweite 40 % der Fenstergrösse; nur Fenster im Kanton plus 500 m)
abgedeckt: 35'860 Fenster. Jedes Fenster (auf 322 × 322 px skaliert) wird mit DINOv2 zu einem Vektor mit 1536 Zahlen
(CLS-Token und GeM-Pooling der Patch-Tokens, normalisiert). Das Abfragebild wird in 4 Drehungen eingebettet; per
Kosinus-Ähnlichkeit ergibt sich je Fenster die beste Drehung. Überlappende Fenster ähnlicher Grösse werden unterdrückt
(Non-Maximum-Suppression), die besten K = 50 (locate.py: 80) sind die Kandidaten. Der Index entsteht einmalig
(~15 min) und liegt in data/cache/index_dinov2.npz.
Feinabgleich (src/localize.py, src/matching.py). Pro Kandidat wird der SWISSIMAGE-Ausschnitt (Fenster × 1,6, damit Kontext dabei ist) mit 1024 px geholt. SuperPoint findet Keypoints, LightGlue ordnet sie zu, alle 4 Drehungen in einem Batch. OpenCV schätzt aus den Zuordnungen mit MAGSAC (Toleranz 8 px) eine Homographie; die Zahl der Inlier ist das Gütemass. Unplausible Lösungen (nicht konvex, Seitenverhältnis > 2, stark ungleiche Diagonalen) werden verworfen. Der Kandidat mit den meisten Inliern gewinnt; ab 80 Inliern wird die Suche früh abgebrochen, unter 10 gilt das Bild als nicht verortet. Findet diese Runde weniger als 20 Inlier, folgt eine feinere Runde über die besten 30 Kandidaten: Referenz mit 1536 px, das Abfragebild im Massstab des Kandidatenfensters (960 px). LightGlue verträgt nur etwa Faktor 1,5 Massstabsunterschied; bei 1024 px zeigt der Referenzausschnitt ein Bild von 2,4 km auf 4,3 km Kontext und passt nicht mehr.
Verfeinern. Zweimal: Referenz exakt um den vorhergesagten Umriss neu holen (1,2 × längste Ausdehnung, 1280 px) und erneut abgleichen. Das Ergebnis wird nur übernommen, wenn mindestens 80 % der bisherigen Inlier erreicht werden.
Ergebnis. Die 3×3-Matrix bildet Bildpixel auf LV95 (EPSG:2056) ab: Bildecken ergeben den Umriss, die Bildmitte das Zentrum (zusätzlich in WGS84). Konfidenz nach Inliern: ≥ 40 hoch, ≥ 20 mittel, darunter tief (unter 10: nicht verortet); die Schwellen wurden am Testlauf kalibriert (src/evaluate.py).
Ortsnamen (src/names.py). swissNAMES3D (Polygone, Punkte, Linien) mit ausgewählten Objektarten
(Orte, Ortsteile, Quartiere, Gewässer, Erhebungen, Areale u. a.) im Kanton plus 5 km liegt als räumlicher Index im
Speicher. names_in() liefert alle Punkte im Umriss, nach Bedeutung sortiert (Objektart, Einwohnerkategorie), jeden
Namen einmal, höchstens 40. Die Gemeinden kommen aus der geo.admin-API (swissBOUNDARIES3D, Stand 2025).
Zweiter Durchgang. localize.py --retry N wiederholt Bilder ohne Ergebnis oder mit weniger als 40 Inliern mit
N Kandidaten und übernimmt das neue Ergebnis nur, wenn es mehr Inlier hat.
| Begriff | Bedeutung |
|---|---|
| Orthofoto, SWISSIMAGE | entzerrtes, massstabsgetreues Luftbild von heute (swisstopo); dient als Referenz |
| LV95 | Schweizer Landeskoordinaten (EPSG:2056) in Metern: E (Ost) / N (Nord) |
| Embedding | Zahlenvektor, der den Inhalt eines Bildes zusammenfasst («Fingerabdruck»); ähnliche Bilder ergeben ähnliche Vektoren |
| Keypoint | markanter, wiedererkennbarer Punkt im Bild (Ecke, Kreuzung) |
| Homographie | 3×3-Matrix, die ein Bild auf ein anderes abbildet (Verschiebung, Drehung, Massstab, perspektivische Verzerrung); für Senkrechtaufnahmen über weitgehend ebenem Gelände eine gute Näherung |
| Inlier | Zuordnung, die zur geschätzten Homographie passt (Gegenteil: Ausreisser); ihre Anzahl ist das Gütemass |
| MAGSAC | robustes Schätzverfahren, das die Homographie auch findet, wenn viele Zuordnungen falsch sind |
| CLAHE | lokaler Kontrastausgleich |
| IoU | Überlappung zweier Flächen (Schnittfläche geteilt durch Vereinigungsfläche) |
| Ground Truth | Soll-Wert: Lage laut Archiv-Metadaten, nur für die Auswertung genutzt |
| Modell | Hugging Face | Grösse | Aufgabe |
|---|---|---|---|
| DINOv2 (base) | facebook/dinov2-base | ca. 350 MB | Grobsuche (Embeddings) |
| SuperPoint + LightGlue | ETH-CVG/lightglue_superpoint | ca. 55 MB | Feinabgleich |
Beide werden beim ersten Start vom Hugging Face Hub geladen und danach lokal in ~/.cache/huggingface/ abgelegt.
src/matching.py kennt zwei weitere Matcher (zju-community/matchanything_eloftr,
zju-community/efficientloftr), die die Pipeline nicht verwendet.
Rechenintensiv sind der einmalige Index (35'860 Fenster durch DINOv2) und pro Bild bis zu 50 (bzw. 80) Kandidaten ×
4 Drehungen im Matcher. Der Code nutzt die Apple-GPU (PyTorch mps) und fällt sonst auf die CPU zurück (langsamer;
nicht getestet). CUDA (NVIDIA) ist nicht eingebaut. Richtwerte auf einem Mac mit Apple-GPU: Index ~15 min (einmalig),
Pipeline ~15 s pro Bild (240 Bilder: ~1 h), locate.py 30–60 s pro Bild inklusive Laden der Modelle.
| Was | Wo | Wie |
|---|---|---|
| SWISSIMAGE-Mosaik, 5 m/px (≈ 54 × 67 km) | data/cache/mosaic_5m.png, ca. 280 MB | einmal per WMS geholt, dann lokal |
| DINOv2-Index (35'860 Fenster) | data/cache/index_dinov2.npz, ca. 210 MB | einmal aus dem Mosaik berechnet, dann lokal |
| SWISSIMAGE-Ausschnitte für den Feinabgleich | data/cache/ref/ | live von wms.geo.admin.ch, danach gecacht |
| Ortsnamen (swissNAMES3D) | data/cache/names/, ca. 115 MB | einmalig von Hand entpackt, lokal |
| Gemeinden | geo.admin-API | live, bei jedem Bild, nicht gecacht |
| Modellgewichte | ~/.cache/huggingface/ | beim ersten Start, danach lokal |
Die Grobsuche läuft rein lokal. Der Feinabgleich holt pro neues Bild alle Kandidaten-Ausschnitte vorab und parallel
(auch wenn die Suche danach früh abbricht): ca. 0,4 MB pro Ausschnitt, also rund 20 MB bei 50 und rund 30 MB bei
80 Kandidaten (locate.py). Bekannte Gegenden kommen aus dem Cache; nach den 240 Testbildern umfasst data/cache/ref/
rund 5'700 Ausschnitte (2,2 GB). data/cache/ ist nicht im Repo (.gitignore): Ein frischer Klon baut Mosaik (per WMS)
und Index (~15 min) beim ersten Start selbst auf; nur swissNAMES3D muss von Hand entpackt werden (siehe oben).
Die Verortung wird blind getestet: src/fetch_images.py lädt 240 Senkrechtbilder (200 aus
swisstopo LUBIS, 1930er–2000er, je 25 pro Jahrzehnt; 40 von GIS-ZH, 1981–2000), verkleinert sie auf höchstens 2048 px,
schneidet 5 % Rand ab und speichert sie als PNG mit Zufallsnamen und ohne Metadaten in data/images/. Lage, Umriss und
Jahr laut Archiv liegen getrennt in data/gt/ground_truth.json; der Localizer liest sie nie.
src/evaluate.py vergleicht danach Zentrumsfehler (m), Überlappung der Umrisse (IoU) und die
Übereinstimmung der Siedlungs- und Quartiernamen (Jaccard) mit der Ground Truth.
Die Archiv-Metadaten sind selbst nicht exakt (bei GIS-ZH nur ein achsparalleles Rechteck um das Bild); die gemessenen Abweichungen sind deshalb als Grössenordnung zu lesen.
Zusätzlich gibt es ein Testset aus der StAZH-Liste der Luftbilder 1981 (1'292 Bilder auf 31 Fluglinien,
src/fetch_stazh.py): Aus 30 der 31 Fluglinien wird je ein Bild zufällig gewählt (Seed 42; Bilder, die
schon im ersten Testset sind, ausgenommen), vom GIS-ZH-Scan (rund 190 MB je TIFF) auf höchstens 2048 px verkleinert, 5 % Rand
abgeschnitten und ohne Metadaten in data/images_stazh/ gespeichert. Lage und Umriss laut Liste liegen in
data/gt/stazh_1981.json. Verortung und Auswertung laufen wie oben, mit --set stazh.
Jüngster Lauf: 30 StAZH-Bilder von 1981 (output/stazh/results.csv).
Früherer Lauf mit Bildern aus swisstopo LUBIS und GIS-ZH, 1930–2000 (output/results.csv; der zweite Durchgang
--retry wurde bei Bild 212 von 240 beendet).
| Quelle | Zeitraum | verortete Bilder | Median-Fehler |
|---|---|---|---|
| swisstopo (LUBIS) | 1930er | 6 | 61 m |
| swisstopo (LUBIS) | 1940er | 11 | 79 m |
| swisstopo (LUBIS) | 1950er | 10 | 122 m |
| swisstopo (LUBIS) | 1960er | 17 | 159 m |
| swisstopo (LUBIS) | 1970er | 16 | 106 m |
| swisstopo (LUBIS) | 1980er | 19 | 60 m |
| swisstopo (LUBIS) | 1990er | 8 | 83 m |
| swisstopo (LUBIS) | 2000er | 18 | 16 m |
| GIS-ZH | 1981–2000 | 35 | 65 m |
src/make_map.py entzerrt jedes verortete Bild mit seiner Homographie auf ein Nord-oben-Raster (Web Mercator, WebP) und rechnet denselben Ausschnitt aus dem heutigen SWISSIMAGE dazu; src/map_template.html (Leaflet) macht daraus eine eigenständige Seite:
--demo N erzeugt eine kleine öffentliche Variante: eine zufällige, nach Quelle und Jahrzehnt geschichtete Auswahl von
etwa N Bildern, die Kennzahlen im Kopf gelten weiterhin für alle. Mit --only-localized bleiben Bilder ohne Ergebnis
weg. Das ist die Seite in docs/, die GitHub Pages ausliefert: die 29 verorteten Bilder des Laufs mit 30 StAZH-Bildern
(~16 MB). Einzige externe Ressource ist die Kartenbibliothek Leaflet von cdnjs.cloudflare.com; Web-Fonts werden nicht
geladen.
Code:
| Datei | Aufgabe |
|---|---|
src/locate.py | Einzelbild verorten (Ende-zu-Ende), schreibt .json und .html |
src/fetch_images.py | Testdatensatz holen (LUBIS, GIS-ZH), Metadaten abtrennen |
src/fetch_stazh.py | Zusatz-Testset aus der StAZH-Liste 1981 holen |
src/reference.py | SWISSIMAGE per WMS: Kantons-Mosaik und beliebige Ausschnitte (gecacht) |
src/retrieval.py | DINOv2-Index und Grobsuche |
src/matching.py | SuperPoint/LightGlue-Abgleich, Homographie, Plausibilitätsprüfung |
src/localize.py | Verortung des Testdatensatzes, schreibt output/predictions.json |
src/names.py | Ortsnamen (swissNAMES3D) und Gemeinden |
src/evaluate.py | Vergleich mit der Ground Truth, results.csv und results.geojson |
src/make_map.py, src/map_template.html | interaktive Karte, --demo für die Online-Demo |
src/common.py | Pfade, HTTP-Session, Kantonsgrenze |
Daten und Ergebnisse:
| Pfad | Inhalt |
|---|---|
data/images/ | Abfragebilder ohne Metadaten (Zufallsnamen); nicht im Repo |
data/gt/ground_truth.json | Archiv-Metadaten (nur für die Auswertung) |
data/images_stazh/, data/gt/stazh_1981.json | Zusatz-Testset: 30 Bilder von 1981 aus der StAZH-Liste (src/fetch_stazh.py); Bilder nicht im Repo |
data/cache/ | Mosaik, Index, Referenzausschnitte, Ortsnamen; nicht im Repo |
output/predictions.json | Rohvorhersagen inkl. Homographie Bild→LV95 |
output/predictions_pass1.json | Stand nach dem ersten Durchgang (vor --retry) |
output/results.csv, results.geojson | Ergebnisse mit Fehlern, Gemeinden, Orten |
output/summary.txt | Kennzahlen des Testlaufs (Ausgabe von evaluate.py) |
output/stazh/ | dasselbe für den Lauf mit den 30 StAZH-Bildern (predictions.json, results.csv, results.geojson, summary.txt) |
output/map/ | Vollkarte; lokal erzeugt, nicht im Repo |
docs/ | Online-Demo für GitHub Pages |
beispiele/ | Screenshots der Beispiele oben (stazh_*.jpg) sowie ältere Beispielbilder und -ergebnisse für locate.py |
Luftbilder (LUBIS), SWISSIMAGE, swissNAMES3D, swissBOUNDARIES3D © swisstopo, freie Nutzung mit Quellenangabe
(Nutzungsbedingungen).
Historische Luftbilder 1981–2000 © Kanton Zürich, GIS-ZH (wms.zh.ch/HistLuftZHWMS): offene Daten mit der
opendata.swiss-Nutzungsbedingung «Open use» (auch kommerziell erlaubt, Quellenangabe empfohlen; Stand 12.10.2026,
Datensatz).
Modelle: DINOv2 steht unter Apache-2.0. SuperPoint (in ETH-CVG/lightglue_superpoint) ist nur für akademische oder
gemeinnützige nichtkommerzielle Forschung lizenziert (Magic Leap; die LightGlue-Model-Card übernimmt das). Das Repo
verteilt keine Gewichte, sie werden beim ersten Start von Hugging Face geladen. Für einen produktiven Betrieb ist das
zu klären; auf Hugging Face als Apache-2.0 ausgewiesen sind zju-community/matchanything_eloftr,
zju-community/efficientloftr (beide in src/matching.py vorgesehen) und ETH-CVG/lightglue_disk. Wie gut sie hier
verorten, ist nicht dokumentiert.