Files
business-card-scanner/app/imaging.py
Lucas Orth 1e8918e0cd
All checks were successful
Build & Deploy / deploy (push) Successful in 1m29s
Bildeditor, klickbare Felder, Einstellungen; Kopfzeile und Namensfeld raus
Bildbearbeitung im Nachgang: Drehen in 90-Grad-Schritten, Ausrichten per
Schieberegler, Zuschneiden per Rahmen mit anfassbaren Ecken. Der Server
rechnet jede Korrektur vom unbearbeiteten Original, das beim ersten
Eingriff daneben abgelegt wird - sonst summieren sich die Verluste über
mehrere Korrekturen, und Zurücksetzen wäre nicht möglich. Zwei
Zuschnitte hintereinander werden ineinander verrechnet.

Der Ort ließ sich nicht übernehmen, weil beide Quellen versagen können:
iOS entfernt beim Weitergeben an eine Webseite oft die GPS-Daten aus dem
Foto, und der Browserstandort scheiterte bisher stumm. Jetzt meldet er
seinen Grund ("Standortfreigabe fehlt"), die Detailansicht zeigt die
Herkunft des Ortes, und der Scan-Ort ist von Hand nachtragbar.

E-Mail, Web, Telefon und Mobil sind in der Tabelle jetzt Links; Straße,
PLZ, Ort und Land stehen als eine Adresszeile, die die eingestellte
Navigations-App öffnet.

Einstellungen als vierter Reiter: Navigations-App (Apple Karten, Google
Maps, OpenStreetMap), Statusübersicht und Abmelden - der Knopf saß
vorher in der Kopfzeile.

Die Kopfzeile ist weg. Sie kostete auf dem Telefon eine Bildschirmzeile
und wiederholte nur, was die Tableiste schon sagt.

Kein full_name mehr, nur Vor- und Nachname; akademische Titel gehören in
den Vornamen. Bestehende Datensätze werden bei der Migration am letzten
Leerzeichen aufgeteilt. Die Spalte bleibt ungenutzt stehen: SQLite baut
zum Löschen die ganze Tabelle neu, der Gewinn wären ein paar Byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:19:10 +02:00

122 lines
4.4 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 edit_jpeg(raw: bytes, rotate: int = 0, angle: float = 0.0, crop: dict = None) -> bytes:
"""Drehen, ausrichten, zuschneiden - in dieser Reihenfolge.
Wird immer auf das unbearbeitete Original angewandt, nie auf ein schon
bearbeitetes Bild: sonst summieren sich Verluste bei jeder Korrektur.
"""
image = Image.open(io.BytesIO(raw))
if rotate % 360:
image = image.rotate(-rotate, expand=True)
if angle:
# Ohne expand: der Rahmen bleibt, die Ecken laufen weiss aus. Was
# dabei ueber den Rand geht, schneidet der Nutzer anschliessend weg.
image = image.rotate(
-angle, expand=False, resample=Image.BICUBIC, fillcolor=(255, 255, 255)
)
if crop:
width, height = image.size
left = max(0, round(crop["x"] * width))
top = max(0, round(crop["y"] * height))
right = min(width, round((crop["x"] + crop["w"]) * width))
bottom = min(height, round((crop["y"] + crop["h"]) * height))
if right - left > 10 and bottom - top > 10:
image = image.crop((left, top, right, bottom))
buffer = io.BytesIO()
image.convert("RGB").save(buffer, format="JPEG", quality=90, 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()