Hintergrundverarbeitung, Bildaufbereitung, Sammelexport entfernt
All checks were successful
Build & Deploy / deploy (push) Successful in 1m24s
All checks were successful
Build & Deploy / deploy (push) Successful in 1m24s
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>
This commit is contained in:
@@ -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"
|
||||
|
||||
89
tests/test_bildaufbereitung.py
Normal file
89
tests/test_bildaufbereitung.py
Normal file
@@ -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))
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user