From a7fbc15792039bd01d9a98d94feb56ddaa5d46ee Mon Sep 17 00:00:00 2001 From: Lucas Orth Date: Sun, 6 Sep 2026 12:56:58 +0200 Subject: [PATCH] Hintergrundverarbeitung, Bildaufbereitung, Sammelexport entfernt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 24 ++++++-- app/db.py | 7 --- app/extract.py | 24 ++++++-- app/imaging.py | 10 ++-- app/main.py | 100 +++++++++++++++++++++----------- app/segment.py | 34 ++++++++++- app/vcard.py | 4 -- static/app.js | 48 ++++++++++++---- tests/test_api.py | 101 +++++++++++++++++++++++++++++---- tests/test_bildaufbereitung.py | 89 +++++++++++++++++++++++++++++ tests/test_extract.py | 12 +++- tests/test_vcard.py | 7 --- 12 files changed, 369 insertions(+), 91 deletions(-) create mode 100644 tests/test_bildaufbereitung.py diff --git a/README.md b/README.md index e7be845..402f7a0 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,23 @@ Kontakt aufs iPhone holen. Selbst gehostete PWA, ein Container. **Stapelscan.** Ein Foto von zwanzig Karten auf dem Tisch reicht. OpenCV findet die Kartenrechtecke, entzerrt sie perspektivisch und schneidet sie einzeln aus – -ohne manuelle Nacharbeit. Jeder Zuschnitt geht als Bild an das Vision-Modell, -das Name, Firma, Position und Kontaktdaten als strukturiertes JSON zurückgibt. -Ein vorgeschalteter OCR-Schritt wäre kontraproduktiv: er würde Layout und -Schriftgrößen wegwerfen, also genau die Information, aus der die Feldzuordnung -entsteht. +ohne manuelle Nacharbeit. Der Zuschnitt wird dabei leicht enger gesetzt als die +erkannte Kante, damit kein Untergrund stehen bleibt, und die Beleuchtung wird +ausgeglichen, damit das Papier weiß wird statt grau. + +Jeder Zuschnitt geht als Bild an das Vision-Modell, das Name, Firma, Position +und Kontaktdaten als strukturiertes JSON zurückgibt. Ein vorgeschalteter +OCR-Schritt wäre kontraproduktiv: er würde Layout und Schriftgrößen wegwerfen, +also genau die Information, aus der die Feldzuordnung entsteht. Das Modell sagt +außerdem, wie herum die Karte gehört – geometrisch ist das nicht bestimmbar – +und das gespeicherte Bild wird entsprechend gedreht. + +**Die Antwort wartet nicht auf das Modell.** Der Scan liefert die freigestellten +Karten sofort zurück, das Auslesen läuft danach im Hintergrund weiter; die +Oberfläche lädt nach. Ein Zwanzigerstapel blockiert damit keine Minute im +Upload. Wer eine Karte in der Zwischenzeit von Hand ergänzt, verliert die +Eingabe nicht – das Modell füllt nur leere Felder. Bricht der Server mitten im +Lauf ab, holt er die offenen Karten beim nächsten Start nach. **Metadaten.** Aufnahmezeit und GPS kommen aus den EXIF-Daten des Fotos, nicht aus dem Browser – Karten werden oft erst abends am Schreibtisch abfotografiert, @@ -24,6 +36,8 @@ Filter für „nicht exportiert“, „unvollständig“ und „mit Erinnerung **Export.** Ein Tippen auf *In Kontakte speichern* liefert eine vCard 3.0, die iOS direkt in die Kontakte-App übernimmt. Die Karte gilt danach als exportiert. +Bewusst einzeln: ein Sammelexport schiebt zwanzig Kontakte ungeprüft ins +Adressbuch. **Notizen und Erinnerungen.** Freitext pro Karte. Erinnerungen mit Termin gehen als Mail raus; verpasste Termine holt der Scheduler beim nächsten Start nach. diff --git a/app/db.py b/app/db.py index 8e26728..fa068e7 100644 --- a/app/db.py +++ b/app/db.py @@ -91,10 +91,3 @@ def execute(sql: str, params: Iterable[Any] = ()) -> None: conn.execute(sql, tuple(params)) conn.commit() - -def execute_many(statements: list[tuple[str, Iterable[Any]]]) -> None: - with _lock: - conn = connect() - for sql, params in statements: - conn.execute(sql, tuple(params)) - conn.commit() diff --git a/app/extract.py b/app/extract.py index 74b2d62..001be7a 100644 --- a/app/extract.py +++ b/app/extract.py @@ -28,9 +28,17 @@ SCHEMA = { "type": "boolean", "description": "False, wenn der Ausschnitt keine Visitenkarte zeigt.", }, + "rotation": { + "type": "integer", + "enum": [0, 90, 180, 270], + "description": ( + "Grad im Uhrzeigersinn, um die das Bild gedreht werden muss, " + "damit die Schrift aufrecht steht." + ), + }, **{name: {"type": ["string", "null"]} for name in FIELDS}, }, - "required": ["is_business_card", *FIELDS], + "required": ["is_business_card", "rotation", *FIELDS], "additionalProperties": False, } @@ -48,7 +56,10 @@ Regeln: first_name und last_name ohne Titel. - website ohne Protokollpraefix. - Zeigt der Ausschnitt keine Visitenkarte (Tischkante, Notizzettel, leere - Flaeche), setze is_business_card auf false und alle Felder auf null.""" + Flaeche), setze is_business_card auf false und alle Felder auf null. +- rotation sagt, um wie viel Grad im Uhrzeigersinn das Bild gedreht werden + muss, damit die Schrift aufrecht steht. Steht sie schon richtig, ist der + Wert 0. Steht die Karte auf dem Kopf, ist er 180.""" PROMPT = "Lies diese Visitenkarte aus." @@ -80,7 +91,8 @@ def _get_client() -> anthropic.AsyncAnthropic: def _empty(reason: str) -> dict: - return {"is_business_card": False, "_error": reason, **{f: None for f in FIELDS}} + return {"is_business_card": False, "rotation": 0, "_error": reason, + **{f: None for f in FIELDS}} def _has_content(result: dict) -> bool: @@ -154,10 +166,12 @@ async def extract(image_jpeg: bytes) -> dict: if not _has_content(result): try: - rotated = await asyncio.to_thread(imaging.rotate_jpeg_180, image_jpeg) + rotated = await asyncio.to_thread(imaging.rotate_jpeg, image_jpeg, 180) retry = await _ask(rotated) if _has_content(retry): - retry["_rotated"] = True + # Der zweite Anlauf sah das Bild gedreht - dann stimmt auch + # seine Rotationsangabe relativ zum gedrehten Bild. + retry["rotation"] = (int(retry.get("rotation") or 0) + 180) % 360 return retry except Exception: log.exception("Zweiter Versuch (gedreht) fehlgeschlagen") diff --git a/app/imaging.py b/app/imaging.py index 162688d..b1c4b90 100644 --- a/app/imaging.py +++ b/app/imaging.py @@ -80,10 +80,12 @@ def encode_jpeg(rgb_or_bgr: np.ndarray, quality: int = 88, bgr: bool = True) -> return buffer.getvalue() -def rotate_jpeg_180(raw: bytes) -> bytes: - """Nur fuer den zweiten Anlauf einer leer gebliebenen Karte gedacht - - deshalb aus dem JPEG heraus statt aus dem grossen Rohbild.""" - image = Image.open(io.BytesIO(raw)).transpose(Image.ROTATE_180) +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() diff --git a/app/main.py b/app/main.py index 942c261..8d9a956 100644 --- a/app/main.py +++ b/app/main.py @@ -33,6 +33,14 @@ async def lifespan(app: FastAPI): log.warning("APP_PASSWORD ist leer - die App ist ohne Anmeldung erreichbar.") if not extract.available(): log.warning("ANTHROPIC_API_KEY fehlt - Karten werden nur zugeschnitten, nicht ausgelesen.") + + # Karten, die beim letzten Herunterfahren noch in der Warteschlange + # standen, wuerden sonst dauerhaft auf "wird gelesen" stehen bleiben. + unfinished = db.query("SELECT id FROM cards WHERE extract_status = 'offen'") + if unfinished: + log.info("%d Karte(n) aus dem letzten Lauf werden nachgeholt", len(unfinished)) + start_reading([row["id"] for row in unfinished]) + task = asyncio.create_task(reminders.run_forever()) try: yield @@ -116,6 +124,53 @@ def _process_photo(raw: bytes) -> tuple: return meta, encoded, fallback +# Laufende Lesevorgaenge festhalten, sonst raeumt die Garbage Collection sie +# mitten im Lauf weg. +_reading: set = set() + + +def start_reading(card_ids: list) -> None: + task = asyncio.create_task(_read_cards(card_ids)) + _reading.add(task) + task.add_done_callback(_reading.discard) + + +async def _read_cards(card_ids: list) -> None: + """Das Auslesen laeuft nach der Antwort weiter - der Nutzer sieht seine + Karten sofort, die Felder tropfen nach.""" + rows = [db.query_one("SELECT * FROM cards WHERE id = ?", (i,)) for i in card_ids] + rows = [row for row in rows if row is not None] + images = [(config.IMAGE_DIR / row["image_file"]).read_bytes() for row in rows] + + results = await extract.extract_all(images) + + for row, result in zip(rows, results): + rotation = int(result.get("rotation") or 0) % 360 + if rotation: + # Die Lage der Karte auf dem Tisch ist geometrisch nicht + # bestimmbar - das Modell sagt uns, wie herum sie gehoert. + path = config.IMAGE_DIR / row["image_file"] + path.write_bytes( + await asyncio.to_thread(imaging.rotate_jpeg, path.read_bytes(), rotation) + ) + fields = {f: (result.get(f) or None) for f in extract.FIELDS} + # COALESCE, weil das Auslesen laeuft, waehrend die Karte schon + # sichtbar ist: was in der Zwischenzeit von Hand eingetragen wurde, + # darf das Modell nicht ueberschreiben. + assignments = ", ".join(f"{name} = COALESCE({name}, ?)" for name in fields) + db.execute( + f"UPDATE cards SET {assignments}, extract_status = ?, extract_error = ? " + "WHERE id = ? AND extract_status = 'offen'", + [ + *fields.values(), + "fehler" if result.get("_error") else "ok", + result.get("_error"), + row["id"], + ], + ) + log.info("%d Karte(n) ausgelesen", len(rows)) + + @app.post("/api/scan", dependencies=[auth]) async def scan( file: UploadFile, @@ -144,13 +199,11 @@ async def scan( geo_source = None place = await asyncio.to_thread(geocode.reverse, meta["lat"], meta["lon"]) - results = await extract.extract_all(encoded) - batch_id = uuid.uuid4().hex created = now_iso() source = "einzel" if fallback else "stapel" cards = [] - for upright, result in zip(encoded, results): + for upright in encoded: card_id = uuid.uuid4().hex image_file = f"{card_id}.jpg" (config.IMAGE_DIR / image_file).write_bytes(upright) @@ -166,11 +219,8 @@ async def scan( "lon": meta["lon"], "place": place, "geo_source": geo_source, - "extract_status": "fehler" if result.get("_error") else "ok", - "extract_error": result.get("_error"), + "extract_status": "offen", } - for field in extract.FIELDS: - columns[field] = (result.get(field) or None) names = ", ".join(columns) placeholders = ", ".join("?" for _ in columns) db.execute( @@ -178,6 +228,9 @@ async def scan( ) cards.append(card_dict(db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,)))) + # Antwort geht sofort raus, das Auslesen laeuft danach weiter. + start_reading([card["id"] for card in cards]) + log.info("Scan %s: %d Karte(n), Fallback=%s", batch_id, len(cards), fallback) return JSONResponse( {"batch_id": batch_id, "count": len(cards), "fallback": fallback, "cards": cards} @@ -202,7 +255,8 @@ def list_cards(q: str = "", filter: str = "alle") -> dict: where.append("exported_at IS NULL") elif filter == "unvollstaendig": where.append( - "(extract_status <> 'ok' OR (full_name IS NULL AND company IS NULL))" + "(extract_status = 'fehler' " + "OR (extract_status = 'ok' AND full_name IS NULL AND company IS NULL))" ) elif filter == "erinnerung": where.append( @@ -216,7 +270,8 @@ def list_cards(q: str = "", filter: str = "alle") -> dict: totals = db.query_one( """ SELECT COUNT(*) AS total, - SUM(CASE WHEN exported_at IS NULL THEN 1 ELSE 0 END) AS offen + SUM(CASE WHEN exported_at IS NULL THEN 1 ELSE 0 END) AS offen, + SUM(CASE WHEN extract_status = 'offen' THEN 1 ELSE 0 END) AS laeuft FROM cards """ ) @@ -229,6 +284,7 @@ def list_cards(q: str = "", filter: str = "alle") -> dict: "total": totals["total"] or 0, "offen": totals["offen"] or 0, "erinnerungen": pending["n"] or 0, + "laeuft": totals["laeuft"] or 0, }, } @@ -288,17 +344,10 @@ def card_image(card_id: str) -> FileResponse: # ---------------------------------------------------------------------------- Export -def _mark_exported(ids: list) -> None: - stamp = now_iso() - db.execute_many( - [("UPDATE cards SET exported_at = ? WHERE id = ?", (stamp, i)) for i in ids] - ) - - @app.get("/vcf/{card_id}", dependencies=[auth]) def download_vcard(card_id: str) -> Response: card = dict(_card_or_404(card_id)) - _mark_exported([card_id]) + db.execute("UPDATE cards SET exported_at = ? WHERE id = ?", (now_iso(), card_id)) return Response( content=vcard.build(card), media_type="text/vcard; charset=utf-8", @@ -306,23 +355,6 @@ def download_vcard(card_id: str) -> Response: ) -@app.get("/vcf", dependencies=[auth]) -def download_vcards(ids: str = "") -> Response: - wanted = [i for i in ids.split(",") if i.strip()] - if not wanted: - raise HTTPException(status_code=400, detail="Keine Karten ausgewaehlt") - placeholders = ", ".join("?" for _ in wanted) - rows = db.query(f"SELECT * FROM cards WHERE id IN ({placeholders})", wanted) - if not rows: - raise HTTPException(status_code=404, detail="Karten nicht gefunden") - _mark_exported([r["id"] for r in rows]) - return Response( - content=vcard.build_many([dict(r) for r in rows]), - media_type="text/vcard; charset=utf-8", - headers={"Content-Disposition": 'attachment; filename="kontakte.vcf"'}, - ) - - # ----------------------------------------------------------------------- Erinnerungen @app.get("/api/reminders", dependencies=[auth]) diff --git a/app/segment.py b/app/segment.py index 07c60fd..af42ef4 100644 --- a/app/segment.py +++ b/app/segment.py @@ -15,6 +15,10 @@ OUT_WIDTH = 1400 # Obergrenze; kleinere Karten werden nicht hochskaliert MIN_WIDTH = 600 OUT_HEIGHT = round(OUT_WIDTH / CARD_RATIO) +# Das gefundene Rechteck liegt auf der Kartenkante. Ein schmaler Streifen +# davon ist noch Tisch - der faellt weg, bevor zugeschnitten wird. +INSET = 0.015 + DETECT_EDGE = 1600 # Aufloesung, auf der gesucht wird MIN_AREA_FRACTION = 0.004 # kleiner ist Rauschen, kein Kartenfund MAX_AREA_FRACTION = 0.60 # groesser ist der Tisch, nicht die Karte @@ -130,17 +134,41 @@ def segment(image: np.ndarray) -> tuple[list[np.ndarray], bool]: crops = [] for rect in _reading_order(rects): quad = _order_quad(cv2.boxPoints(rect)) / scale # zurueck auf volle Aufloesung + quad = _shrink(quad) width, height = _target_size(quad) target = np.array( [[0, 0], [width, 0], [width, height], [0, height]], dtype="float32" ) matrix = cv2.getPerspectiveTransform(quad, target) - crops.append( - cv2.warpPerspective(image, matrix, (width, height), flags=cv2.INTER_CUBIC) - ) + crop = cv2.warpPerspective(image, matrix, (width, height), flags=cv2.INTER_CUBIC) + crops.append(flatten(crop)) return crops, False +def _shrink(quad: np.ndarray, factor: float = INSET) -> np.ndarray: + """Ecken zur Mitte ziehen, damit kein Untergrund im Zuschnitt bleibt.""" + center = quad.mean(axis=0) + return (center + (quad - center) * (1.0 - factor)).astype("float32") + + +def flatten(card: np.ndarray) -> np.ndarray: + """Beleuchtung ausgleichen, damit das Papier weiss wird statt grau. + + Geteilt wird durch eine stark weichgezeichnete Fassung des Bildes - das + ist die Beleuchtung. Uebrig bleibt der Aufdruck. Dunkle Karten bleiben + unangetastet: bei ihnen ist das Dunkle das Papier, kein Schatten. + """ + gray = cv2.cvtColor(card, cv2.COLOR_BGR2GRAY) + if np.median(gray) < 120: + return card + + sigma = max(card.shape[1] / 16.0, 3.0) + illumination = cv2.GaussianBlur(gray, (0, 0), sigma).astype(np.float32) + gain = np.clip(250.0 / np.maximum(illumination, 1.0), 1.0, 2.2) + lifted = card.astype(np.float32) * gain[:, :, None] + return np.clip(lifted, 0, 255).astype(np.uint8) + + def _target_size(quad: np.ndarray) -> tuple: """Zuschnitt so gross wie die Vorlage, hoechstens OUT_WIDTH. diff --git a/app/vcard.py b/app/vcard.py index f7b90f6..2387f8c 100644 --- a/app/vcard.py +++ b/app/vcard.py @@ -80,10 +80,6 @@ def build(card: dict) -> str: return "\r\n".join(lines) + "\r\n" -def build_many(cards: list) -> str: - return "".join(build(card) for card in cards) - - def filename(card: dict) -> str: base = _display_name(card).replace(" ", "-") safe = "".join(c for c in base if c.isalnum() or c in "-_") diff --git a/static/app.js b/static/app.js index df762fa..c475ff4 100644 --- a/static/app.js +++ b/static/app.js @@ -116,10 +116,14 @@ function cardTags(card) { const tags = []; if (card.place) tags.push(["", card.place]); - tags.push( - card.exported_at ? ["pill-solid", "exportiert"] : ["", "nicht exportiert"] - ); - if (card.extract_status !== "ok") tags.push(["", "Lesefehler"]); + if (card.extract_status === "offen") { + tags.push(["pill-solid", "wird gelesen"]); + } else { + tags.push( + card.exported_at ? ["pill-solid", "exportiert"] : ["", "nicht exportiert"] + ); + if (card.extract_status === "fehler") tags.push(["", "Lesefehler"]); + } return tags .map(([extra, text]) => `${esc(text)}`) .join(""); @@ -166,7 +170,9 @@ const result = await api("/api/scan", { method: "POST", body }); renderScanResult(result); await refreshStats(); - toast(`${result.count} Karte${result.count === 1 ? "" : "n"} erfasst`); + toast( + `${result.count} Karte${result.count === 1 ? "" : "n"} erfasst, wird gelesen` + ); } catch (error) { toast(error.message); } finally { @@ -181,9 +187,9 @@ ` ) .join(""); @@ -212,11 +218,24 @@ state.stats = data.stats; updateCounters(); renderCards(); + schedulePoll(); } catch (error) { toast(error.message); } } + /** Das Auslesen laeuft auf dem Server weiter, nachdem der Scan geantwortet + * hat. Solange noch etwas offen ist, holen wir den Stand nach. */ + function schedulePoll() { + clearTimeout(schedulePoll.timer); + if (!state.stats.laeuft) return; + schedulePoll.timer = setTimeout(() => { + if (state.route === "karten") loadCards(); + else if (state.route === "karte") loadCard(state.cardId); + else refreshStats(); + }, 3000); + } + function renderCards() { const list = $("cards-list"); if (!state.cards.length) { @@ -230,11 +249,10 @@ return; } - const selectable = state.cards.map((c) => c.id).join(","); list.innerHTML = ` -
+
${state.cards.length} Karte${state.cards.length === 1 ? "" : "n"} - Alle als vCard + ${state.stats.laeuft ? `${state.stats.laeuft} werden gelesen` : ""}
${state.cards @@ -261,6 +279,10 @@ try { const card = await api(`/api/cards/${cardId}`); renderCard(card); + if (card.extract_status === "offen") { + state.stats = { ...state.stats, laeuft: 1 }; + schedulePoll(); + } } catch (error) { screens.karte.innerHTML = `

${esc(error.message)}

`; } @@ -341,7 +363,10 @@ In Kontakte speichern → - ${card.extract_status !== "ok" + ${card.extract_status === "offen" + ? `

Die Karte wird gerade ausgelesen – die Felder erscheinen gleich.

` + : ""} + ${card.extract_status === "fehler" ? `

Lesefehler: ${esc(card.extract_error || "unbekannt")}

` : ""} @@ -460,6 +485,7 @@ const data = await api("/api/cards?filter=alle"); state.stats = data.stats; updateCounters(); + schedulePoll(); } catch (_) { /* Zähler sind nicht kritisch */ } } diff --git a/tests/test_api.py b/tests/test_api.py index bd29018..6b362f0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,16 +3,31 @@ Ohne ANTHROPIC_API_KEY laeuft die Extraktion in den Leerlauf - Freistellen, Speichern, Suchen, Bearbeiten und Export werden trotzdem vollstaendig gepruft. """ +import asyncio +import time from datetime import datetime, timedelta, timezone import pytest from fastapi.testclient import TestClient -from app import db, mailer, reminders +from app import db, extract, mailer, reminders from app.main import app from tests.helpers import photo_bytes +def warte_bis_gelesen(client, card_ids, timeout=15.0): + """Das Auslesen laeuft im Hintergrund - hier auf das Ende warten.""" + frist = time.monotonic() + timeout + while time.monotonic() < frist: + stati = [ + client.get(f"/api/cards/{i}").json()["extract_status"] for i in card_ids + ] + if all(status != "offen" for status in stati): + return stati + time.sleep(0.1) + raise AssertionError("Karten wurden nicht fertig gelesen") + + @pytest.fixture(scope="module") def client(): with TestClient(app) as test_client: @@ -25,7 +40,9 @@ def scanned(client): "/api/scan", files={"file": ("tisch.jpg", photo_bytes(4), "image/jpeg")} ) assert response.status_code == 200 - return response.json() + data = response.json() + warte_bis_gelesen(client, [c["id"] for c in data["cards"]]) + return data def test_healthz(client): @@ -97,14 +114,6 @@ def test_filter_offen_blendet_exportierte_karten_aus(client, scanned): assert all(card["exported_at"] is None for card in offen) -def test_sammelexport_mehrerer_karten(client, scanned): - ids = ",".join(card["id"] for card in scanned["cards"][:3]) - - response = client.get("/vcf", params={"ids": ids}) - - assert response.text.count("BEGIN:VCARD") == 3 - - def test_erinnerung_anlegen_und_filtern(client, scanned): card_id = scanned["cards"][2]["id"] due = datetime.now(timezone.utc) + timedelta(days=3) @@ -181,3 +190,75 @@ def test_pwa_dateien_werden_ausgeliefert(client): assert client.get("/sw.js").status_code == 200 assert client.get("/manifest.webmanifest").status_code == 200 assert client.get("/static/app.js").status_code == 200 + + +def test_scan_antwortet_bevor_das_auslesen_fertig_ist(client, monkeypatch): + """Der Kern der Hintergrundverarbeitung: die Antwort wartet nicht auf das + Modell. Ohne das haengt ein Zwanzigerstapel eine Minute im Upload.""" + original = extract.extract_all + + async def langsam(images): + await asyncio.sleep(1.5) + return await original(images) + + monkeypatch.setattr(extract, "extract_all", langsam) + + start = time.monotonic() + response = client.post( + "/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")} + ) + dauer = time.monotonic() - start + card_id = response.json()["cards"][0]["id"] + + assert dauer < 1.2, f"Antwort brauchte {dauer:.2f}s, hat also gewartet" + assert client.get(f"/api/cards/{card_id}").json()["extract_status"] == "offen" + + monkeypatch.undo() + warte_bis_gelesen(client, [card_id]) + + +def test_offene_karten_zaehlen_nicht_als_unvollstaendig(client): + """Waehrend des Lesens sind Felder naturgemaess leer - das ist kein Mangel.""" + created = client.post( + "/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")} + ).json() + card_id = created["cards"][0]["id"] + warte_bis_gelesen(client, [card_id]) # sonst ueberschreibt der Hintergrundlauf + db.execute("UPDATE cards SET extract_status = 'offen' WHERE id = ?", (card_id,)) + + unvollstaendig = client.get( + "/api/cards", params={"filter": "unvollstaendig"} + ).json()["cards"] + + assert card_id not in [c["id"] for c in unvollstaendig] + assert client.get("/api/cards").json()["stats"]["laeuft"] >= 1 + + db.execute("UPDATE cards SET extract_status = 'ok' WHERE id = ?", (card_id,)) + + +def test_sammelexport_ist_entfernt(client): + assert client.get("/vcf", params={"ids": "egal"}).status_code == 404 + + +def test_handeintrag_ueberlebt_den_hintergrundlauf(client, monkeypatch): + """Die Karte ist sichtbar, bevor sie gelesen ist. Wer in der Zwischenzeit + etwas eintraegt, darf es nicht wieder verlieren.""" + original = extract.extract_all + + async def langsam(images): + await asyncio.sleep(1.0) + return await original(images) + + monkeypatch.setattr(extract, "extract_all", langsam) + card_id = client.post( + "/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")} + ).json()["cards"][0]["id"] + + client.patch(f"/api/cards/{card_id}", json={"full_name": "Von Hand", "notes": "Messe"}) + + monkeypatch.undo() + warte_bis_gelesen(client, [card_id]) + + card = client.get(f"/api/cards/{card_id}").json() + assert card["full_name"] == "Von Hand" + assert card["notes"] == "Messe" diff --git a/tests/test_bildaufbereitung.py b/tests/test_bildaufbereitung.py new file mode 100644 index 0000000..89a96c0 --- /dev/null +++ b/tests/test_bildaufbereitung.py @@ -0,0 +1,89 @@ +"""Zuschnitt aufhellen, Untergrund abschneiden, richtig herum drehen.""" +import io + +import numpy as np +from PIL import Image + +from app import imaging, segment +from tests.helpers import photo_with_cards + + +def _jpeg(width: int, height: int, color=(255, 255, 255)) -> bytes: + buffer = io.BytesIO() + Image.new("RGB", (width, height), color).save(buffer, format="JPEG") + return buffer.getvalue() + + +def _size(raw: bytes) -> tuple: + return Image.open(io.BytesIO(raw)).size + + +def test_drehung_um_null_grad_laesst_die_datei_unangetastet(): + raw = _jpeg(40, 20) + + assert imaging.rotate_jpeg(raw, 0) is raw + assert imaging.rotate_jpeg(raw, 360) is raw + + +def test_drehung_um_180_grad_behaelt_das_format(): + assert _size(imaging.rotate_jpeg(_jpeg(40, 20), 180)) == (40, 20) + + +def test_drehung_um_90_grad_tauscht_die_seiten(): + assert _size(imaging.rotate_jpeg(_jpeg(40, 20), 90)) == (20, 40) + assert _size(imaging.rotate_jpeg(_jpeg(40, 20), 270)) == (20, 40) + + +def test_drehrichtung_ist_im_uhrzeigersinn(): + # Oben links schwarz: nach 90 Grad im Uhrzeigersinn liegt es oben rechts. + image = np.full((20, 40, 3), 255, dtype=np.uint8) + image[:10, :20] = 0 + buffer = io.BytesIO() + Image.fromarray(image).save(buffer, format="PNG") + gedreht = np.asarray( + Image.open(io.BytesIO(imaging.rotate_jpeg(buffer.getvalue(), 90))) + ) + + assert gedreht[:10, -10:].mean() < 60 # oben rechts jetzt dunkel + assert gedreht[:10, :10].mean() > 195 # oben links jetzt hell + + +def test_helle_karte_wird_aufgehellt(): + # Karte mit Helligkeitsverlauf, wie ihn schraeges Licht erzeugt. + verlauf = np.linspace(150, 225, 400, dtype=np.float32) + karte = np.repeat(verlauf[None, :, None], 250, axis=0).repeat(3, axis=2) + karte = karte.astype(np.uint8) + + aufgehellt = segment.flatten(karte) + + assert aufgehellt.mean() > karte.mean() + 20 + # Der Verlauf ist danach deutlich flacher als vorher. + vorher = float(karte[:, -1].mean() - karte[:, 0].mean()) + nachher = float(aufgehellt[:, -1].mean() - aufgehellt[:, 0].mean()) + assert nachher < vorher / 2 + + +def test_dunkle_karte_bleibt_dunkel(): + dunkel = np.full((250, 400, 3), 40, dtype=np.uint8) + + assert np.array_equal(segment.flatten(dunkel), dunkel) + + +def test_zuschnitt_laesst_den_untergrund_draussen(): + # Der Untergrund im Testfoto ist dunkel. Bleibt ein Streifen davon im + # Zuschnitt, waeren die Randspalten deutlich dunkler als die Mitte. + crops, _ = segment.segment(photo_with_cards(count=4)) + + for crop in crops: + rand = np.concatenate([crop[:, :3], crop[:, -3:]], axis=1) + assert rand.mean() > 170 + + +def test_einzug_zieht_die_ecken_zur_mitte(): + quad = np.array([[0, 0], [100, 0], [100, 100], [0, 100]], dtype="float32") + + kleiner = segment._shrink(quad, factor=0.1) + + assert kleiner[0][0] > quad[0][0] + assert kleiner[2][0] < quad[2][0] + assert np.allclose(kleiner.mean(axis=0), quad.mean(axis=0)) diff --git a/tests/test_extract.py b/tests/test_extract.py index 28246e3..8dd677d 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -30,6 +30,16 @@ def test_output_config_ohne_effort_bleibt_gueltig(): def test_schema_verlangt_alle_felder_und_verbietet_zusaetze(): assert extract.SCHEMA["additionalProperties"] is False - assert set(extract.SCHEMA["required"]) == {"is_business_card", *extract.FIELDS} + assert set(extract.SCHEMA["required"]) == { + "is_business_card", + "rotation", + *extract.FIELDS, + } for field in extract.FIELDS: assert extract.SCHEMA["properties"][field]["type"] == ["string", "null"] + + +def test_rotation_ist_auf_vier_werte_beschraenkt(): + """Das Modell sagt uns, wie herum die Karte gehoert - geometrisch ist das + nicht bestimmbar.""" + assert extract.SCHEMA["properties"]["rotation"]["enum"] == [0, 90, 180, 270] diff --git a/tests/test_vcard.py b/tests/test_vcard.py index da1b045..917d081 100644 --- a/tests/test_vcard.py +++ b/tests/test_vcard.py @@ -58,13 +58,6 @@ def test_firma_ersetzt_den_namen_wenn_keiner_erkannt_wurde(): assert "FN:Berg & Partner GmbH" in output -def test_mehrere_karten_in_einer_datei(): - output = vcard.build_many([BASE, {"full_name": "Bea Kurz"}]) - - assert output.count("BEGIN:VCARD") == 2 - assert output.count("END:VCARD") == 2 - - def test_dateiname_ist_dateisystemtauglich(): assert vcard.filename(BASE) == "Dr-Anna-Berg.vcf" assert vcard.filename({}) == "Unbenannt.vcf"