All checks were successful
Build & Deploy / deploy (push) Successful in 1m29s
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>
122 lines
4.4 KiB
Python
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()
|
|
|