Files
business-card-scanner/app/geocode.py
Lucas Orth ab993e98e1 Visitenkarten-Scanner: Stapelscan, Extraktion, Übersicht, vCard-Export
Selbst gehostete PWA, die Visitenkarten von einem Foto freistellt, ausliest
und als Kontakt bereitstellt.

- Stapelscan: OpenCV findet die Kartenrechtecke über mehrere Binärmasken,
  entzerrt sie perspektivisch und schneidet sie einzeln aus. Ohne Fund gilt
  das ganze Foto als eine Karte.
- Extraktion: ein Aufruf je Zuschnitt an das Vision-Modell mit
  JSON-Schema. Kein vorgeschaltetes OCR - das würde Layout und
  Schriftgrößen wegwerfen, aus denen die Feldzuordnung entsteht.
- Metadaten: Aufnahmezeit und GPS aus den EXIF-Daten des Fotos, Ortsname
  über Nominatim, Browserstandort nur als Rückfallebene.
- Übersicht mit Volltextsuche und Filtern, Detailansicht mit Korrekturmaske.
- Notizfeld je Karte, Erinnerungen per Mail inklusive Nachholen verpasster
  Termine nach einem Neustart.
- vCard 3.0 einzeln und als Sammeldatei, Karte gilt danach als exportiert.
- Anmeldung über ein Passwort, Sitzung als signiertes Cookie.
- Deployment per Tag-Push nach den Konventionen in DEPLOY.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:45:56 +02:00

53 lines
1.8 KiB
Python

"""Reverse-Geocoding ueber Nominatim, Ergebnisse werden dauerhaft gecacht."""
import json
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from . import config, db
def _label(address: dict) -> str:
city = (
address.get("city")
or address.get("town")
or address.get("village")
or address.get("municipality")
or address.get("county")
)
detail = address.get("suburb") or address.get("neighbourhood") or address.get("road")
parts = [p for p in (city, detail) if p]
if not parts:
return address.get("state") or address.get("country") or ""
return ", ".join(parts[:2])
def reverse(lat: float | None, lon: float | None) -> str | None:
if lat is None or lon is None or not config.REVERSE_GEOCODE:
return None
# Auf ~100 m runden: benachbarte Scans teilen sich denselben Cache-Eintrag.
key = f"{lat:.3f},{lon:.3f}"
cached = db.query_one("SELECT place FROM geocache WHERE key = ?", (key,))
if cached is not None:
return cached["place"]
url = config.NOMINATIM_URL + "?" + urllib.parse.urlencode(
{"lat": lat, "lon": lon, "format": "jsonv2", "zoom": "16", "accept-language": "de"}
)
place = None
try:
request = urllib.request.Request(url, headers={"User-Agent": config.USER_AGENT})
with urllib.request.urlopen(request, timeout=6) as response:
data = json.loads(response.read().decode("utf-8"))
place = _label(data.get("address") or {}) or data.get("display_name")
except Exception:
# Ohne Ortsnamen bleiben die Koordinaten - kein Grund, den Scan abzubrechen.
return None
db.execute(
"INSERT OR REPLACE INTO geocache (key, place, fetched_at) VALUES (?, ?, ?)",
(key, place, datetime.now(timezone.utc).isoformat()),
)
return place