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"