Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7fbc15792 |
24
README.md
24
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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
100
app/main.py
100
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])
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 "-_")
|
||||
|
||||
@@ -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]) => `<span class="pill ${extra}">${esc(text)}</span>`)
|
||||
.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 @@
|
||||
<button class="row" data-card="${esc(card.id)}">
|
||||
<span class="row-head">
|
||||
<span class="row-title">${esc(displayName(card))}</span>
|
||||
<span class="row-meta">${card.has_contact ? "Gelesen" : "Leer"}</span>
|
||||
<span class="row-meta">wird gelesen</span>
|
||||
</span>
|
||||
<span class="row-sub">${esc(card.company || card.email || "Keine Daten erkannt")}</span>
|
||||
<span class="row-sub">${esc(card.company || card.email || "Zuschnitt gespeichert")}</span>
|
||||
</button>`
|
||||
)
|
||||
.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 = `
|
||||
<div class="strip" style="border-top:3px solid #000">
|
||||
<div class="strip">
|
||||
<span class="label">${state.cards.length} Karte${state.cards.length === 1 ? "" : "n"}</span>
|
||||
<a class="badge" href="/vcf?ids=${esc(selectable)}" download>Alle als vCard</a>
|
||||
${state.stats.laeuft ? `<span class="pill">${state.stats.laeuft} werden gelesen</span>` : ""}
|
||||
</div>
|
||||
<div class="rows">
|
||||
${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 = `<div class="empty"><p class="label">${esc(error.message)}</p></div>`;
|
||||
}
|
||||
@@ -341,7 +363,10 @@
|
||||
In Kontakte speichern <span class="arrow">→</span>
|
||||
</a>
|
||||
|
||||
${card.extract_status !== "ok"
|
||||
${card.extract_status === "offen"
|
||||
? `<p class="hint">Die Karte wird gerade ausgelesen – die Felder erscheinen gleich.</p>`
|
||||
: ""}
|
||||
${card.extract_status === "fehler"
|
||||
? `<p class="hint">Lesefehler: ${esc(card.extract_error || "unbekannt")}</p>`
|
||||
: ""}
|
||||
|
||||
@@ -460,6 +485,7 @@
|
||||
const data = await api("/api/cards?filter=alle");
|
||||
state.stats = data.stats;
|
||||
updateCounters();
|
||||
schedulePoll();
|
||||
} catch (_) { /* Zähler sind nicht kritisch */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -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