Files
business-card-scanner/app/imaging.py
Lucas Orth a7fbc15792
All checks were successful
Build & Deploy / deploy (push) Successful in 1m24s
Hintergrundverarbeitung, Bildaufbereitung, Sammelexport entfernt
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>
2026-09-06 12:56:58 +02:00

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()