All checks were successful
Build & Deploy / deploy (push) Successful in 1m24s
Auslesen im Hintergrund: /api/scan antwortet, sobald die Karten freigestellt und gespeichert sind, und stößt die Extraktion als Task an. Ein Zwanzigerstapel blockierte vorher den Upload für die ganze Dauer aller Modellaufrufe. Die Oberfläche zeigt "wird gelesen" und lädt nach, solange etwas offen ist. Zwei Folgen davon sind mitbehandelt: Beim Schreiben der Ergebnisse steht COALESCE, damit ein Handeintrag während des Lesens nicht überschrieben wird, und offene Karten werden beim Start nachgeholt, statt dauerhaft in der Warteschlange zu hängen. Bildaufbereitung des Zuschnitts: - Der Einzug zieht die erkannten Ecken um 1,5 % zur Mitte, damit kein Untergrund im Zuschnitt bleibt. - Die Beleuchtung wird ausgeglichen (Division durch eine weichgezeichnete Fassung), damit das Papier weiß wird statt grau. Dunkle Karten bleiben unangetastet - bei ihnen ist das Dunkle das Papier, kein Schatten. - Das Modell meldet die nötige Drehung im Schema; das gespeicherte Bild wird entsprechend gedreht. Geometrisch ist die Lage nicht bestimmbar. Sammelexport entfernt: Der Button "Alle als vCard" ist weg, mit ihm der Endpunkt /vcf sowie vcard.build_many und db.execute_many, die dadurch keinen Aufrufer mehr hatten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
93 lines
3.2 KiB
Python
93 lines
3.2 KiB
Python
"""EXIF-Auswertung und Bild-Hilfsfunktionen."""
|
|
import io
|
|
from datetime import datetime, timezone
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageOps
|
|
|
|
# EXIF-Tags, die uns interessieren (Nummern statt Namen: stabil ueber Pillow-Versionen).
|
|
_TAG_DATETIME_ORIGINAL = 0x9003
|
|
_TAG_OFFSET_ORIGINAL = 0x9011
|
|
_TAG_GPS_IFD = 0x8825
|
|
_GPS_LAT_REF, _GPS_LAT = 1, 2
|
|
_GPS_LON_REF, _GPS_LON = 3, 4
|
|
|
|
|
|
def _to_degrees(value) -> float | None:
|
|
try:
|
|
d, m, s = (float(x) for x in value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return d + m / 60.0 + s / 3600.0
|
|
|
|
|
|
def read_metadata(raw: bytes) -> dict:
|
|
"""Aufnahmezeitpunkt und GPS aus den EXIF-Daten des Originalfotos.
|
|
|
|
Der Zeitpunkt des Fotos ist verlaesslicher als der des Uploads: Karten
|
|
werden oft erst Stunden spaeter am Schreibtisch abfotografiert.
|
|
"""
|
|
out: dict = {"captured_at": None, "lat": None, "lon": None}
|
|
try:
|
|
exif = Image.open(io.BytesIO(raw)).getexif()
|
|
except Exception:
|
|
return out
|
|
if not exif:
|
|
return out
|
|
|
|
taken = exif.get(_TAG_DATETIME_ORIGINAL)
|
|
if not taken:
|
|
ifd = exif.get_ifd(0x8769) # Exif-IFD
|
|
taken = ifd.get(_TAG_DATETIME_ORIGINAL) if ifd else None
|
|
offset = ifd.get(_TAG_OFFSET_ORIGINAL) if ifd else None
|
|
else:
|
|
offset = exif.get(_TAG_OFFSET_ORIGINAL)
|
|
if taken:
|
|
try:
|
|
dt = datetime.strptime(str(taken).strip(), "%Y:%m:%d %H:%M:%S")
|
|
iso = f"{dt.isoformat()}{offset}" if offset else dt.isoformat()
|
|
parsed = datetime.fromisoformat(iso)
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
out["captured_at"] = parsed.astimezone(timezone.utc).isoformat()
|
|
except ValueError:
|
|
pass
|
|
|
|
gps = exif.get_ifd(_TAG_GPS_IFD)
|
|
if gps:
|
|
lat = _to_degrees(gps.get(_GPS_LAT))
|
|
lon = _to_degrees(gps.get(_GPS_LON))
|
|
if lat is not None and lon is not None:
|
|
if str(gps.get(_GPS_LAT_REF, "N")).upper().startswith("S"):
|
|
lat = -lat
|
|
if str(gps.get(_GPS_LON_REF, "E")).upper().startswith("W"):
|
|
lon = -lon
|
|
out["lat"], out["lon"] = round(lat, 6), round(lon, 6)
|
|
return out
|
|
|
|
|
|
def decode(raw: bytes) -> np.ndarray:
|
|
"""Bytes zu einem BGR-Array, EXIF-Drehung bereits angewandt."""
|
|
image = Image.open(io.BytesIO(raw))
|
|
image = ImageOps.exif_transpose(image).convert("RGB")
|
|
return np.asarray(image)[:, :, ::-1].copy()
|
|
|
|
|
|
def encode_jpeg(rgb_or_bgr: np.ndarray, quality: int = 88, bgr: bool = True) -> bytes:
|
|
array = rgb_or_bgr[:, :, ::-1] if bgr else rgb_or_bgr
|
|
buffer = io.BytesIO()
|
|
Image.fromarray(array).save(buffer, format="JPEG", quality=quality, optimize=True)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def rotate_jpeg(raw: bytes, degrees: int) -> bytes:
|
|
"""Im Uhrzeigersinn drehen. Arbeitet auf dem fertigen JPEG, nicht auf dem
|
|
grossen Rohbild - gebraucht wird das nur fuer einzelne Karten."""
|
|
if degrees % 360 == 0:
|
|
return raw
|
|
image = Image.open(io.BytesIO(raw)).rotate(-degrees, expand=True)
|
|
buffer = io.BytesIO()
|
|
image.save(buffer, format="JPEG", quality=88, optimize=True)
|
|
return buffer.getvalue()
|
|
|