Visitenkarten-Scanner: Stapelscan, Extraktion, Übersicht, vCard-Export

Selbst gehostete PWA, die Visitenkarten von einem Foto freistellt, ausliest
und als Kontakt bereitstellt.

- Stapelscan: OpenCV findet die Kartenrechtecke über mehrere Binärmasken,
  entzerrt sie perspektivisch und schneidet sie einzeln aus. Ohne Fund gilt
  das ganze Foto als eine Karte.
- Extraktion: ein Aufruf je Zuschnitt an das Vision-Modell mit
  JSON-Schema. Kein vorgeschaltetes OCR - das würde Layout und
  Schriftgrößen wegwerfen, aus denen die Feldzuordnung entsteht.
- Metadaten: Aufnahmezeit und GPS aus den EXIF-Daten des Fotos, Ortsname
  über Nominatim, Browserstandort nur als Rückfallebene.
- Übersicht mit Volltextsuche und Filtern, Detailansicht mit Korrekturmaske.
- Notizfeld je Karte, Erinnerungen per Mail inklusive Nachholen verpasster
  Termine nach einem Neustart.
- vCard 3.0 einzeln und als Sammeldatei, Karte gilt danach als exportiert.
- Anmeldung über ein Passwort, Sitzung als signiertes Cookie.
- Deployment per Tag-Push nach den Konventionen in DEPLOY.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-09-06 11:45:56 +02:00
commit ab993e98e1
37 changed files with 3458 additions and 0 deletions

16
tests/conftest.py Normal file
View File

@@ -0,0 +1,16 @@
"""Testumgebung: eigenes Datenverzeichnis, keine Anmeldung, kein Netzverkehr.
Muss vor dem Import von app.* laufen - config liest die Umgebung beim Import.
"""
import os
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
os.environ["DATA_DIR"] = tempfile.mkdtemp(prefix="bcs-test-")
os.environ["APP_PASSWORD"] = ""
os.environ["ANTHROPIC_API_KEY"] = ""
os.environ["REVERSE_GEOCODE"] = "0"
os.environ["SMTP_HOST"] = ""

59
tests/helpers.py Normal file
View File

@@ -0,0 +1,59 @@
"""Erzeugt Testfotos: mehrere Visitenkarten auf einem dunklen Untergrund."""
import io
import random
import numpy as np
from PIL import Image, ImageDraw
CARD_RATIO = 85.6 / 54.0
def _card(width: int = 420, seed: int = 0) -> Image.Image:
"""Eine weisse Karte mit ein paar dunklen Textzeilen."""
height = round(width / CARD_RATIO)
card = Image.new("RGB", (width, height), "white")
draw = ImageDraw.Draw(card)
rng = random.Random(seed)
draw.line([(0.08 * width, 0.30 * height), (0.62 * width, 0.30 * height)], fill="black", width=6)
draw.line([(0.08 * width, 0.44 * height), (0.48 * width, 0.44 * height)], fill=(70, 70, 70), width=3)
for index in range(3):
y = (0.62 + index * 0.10) * height
draw.line(
[(0.08 * width, y), ((0.30 + rng.random() * 0.35) * width, y)],
fill=(90, 90, 90),
width=2,
)
return card
def photo_with_cards(count: int = 6, size=(2400, 1800), angle_range: int = 8) -> np.ndarray:
"""Foto im BGR-Format mit `count` Karten in einem Raster."""
background = Image.new("RGB", size, (38, 40, 44))
rng = random.Random(count)
columns = 2 if count <= 6 else 3
rows = (count + columns - 1) // columns
cell_width = size[0] // columns
cell_height = size[1] // rows
card_width = int(min(cell_width * 0.72, cell_height * 0.72 * CARD_RATIO))
for index in range(count):
column, row = index % columns, index // columns
card = _card(card_width, seed=index)
angle = rng.uniform(-angle_range, angle_range)
rotated = card.rotate(angle, expand=True, resample=Image.BICUBIC)
mask = Image.new("L", card.size, 255).rotate(
angle, expand=True, resample=Image.BICUBIC
)
x = column * cell_width + (cell_width - rotated.width) // 2
y = row * cell_height + (cell_height - rotated.height) // 2
background.paste(rotated, (x, y), mask)
return np.asarray(background)[:, :, ::-1].copy()
def photo_bytes(count: int = 6) -> bytes:
array = photo_with_cards(count)
buffer = io.BytesIO()
Image.fromarray(array[:, :, ::-1]).save(buffer, format="JPEG", quality=92)
return buffer.getvalue()

183
tests/test_api.py Normal file
View File

@@ -0,0 +1,183 @@
"""Durchstich durch die HTTP-Schnittstelle.
Ohne ANTHROPIC_API_KEY laeuft die Extraktion in den Leerlauf - Freistellen,
Speichern, Suchen, Bearbeiten und Export werden trotzdem vollstaendig gepruft.
"""
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from app import db, mailer, reminders
from app.main import app
from tests.helpers import photo_bytes
@pytest.fixture(scope="module")
def client():
with TestClient(app) as test_client:
yield test_client
@pytest.fixture(scope="module")
def scanned(client):
response = client.post(
"/api/scan", files={"file": ("tisch.jpg", photo_bytes(4), "image/jpeg")}
)
assert response.status_code == 200
return response.json()
def test_healthz(client):
assert client.get("/healthz").json() == {"status": "ok"}
def test_scan_legt_je_karte_einen_datensatz_an(scanned):
assert scanned["count"] == 4
assert scanned["fallback"] is False
assert len({card["id"] for card in scanned["cards"]}) == 4
def test_jede_karte_hat_ein_gespeichertes_bild(client, scanned):
for card in scanned["cards"]:
image = client.get(card["image_url"])
assert image.status_code == 200
assert image.headers["content-type"] == "image/jpeg"
assert len(image.content) > 1000
def test_liste_zaehlt_und_liefert_die_karten(client, scanned):
data = client.get("/api/cards").json()
assert data["stats"]["total"] >= 4
assert data["stats"]["offen"] >= 4
def test_bearbeiten_und_suchen(client, scanned):
card_id = scanned["cards"][0]["id"]
patched = client.patch(
f"/api/cards/{card_id}",
json={"full_name": "Anna Berg", "company": "Berg & Partner", "notes": "Messe"},
)
assert patched.status_code == 200
assert patched.json()["full_name"] == "Anna Berg"
hits = client.get("/api/cards", params={"q": "berg"}).json()["cards"]
assert [c["id"] for c in hits] == [card_id]
by_note = client.get("/api/cards", params={"q": "Messe"}).json()["cards"]
assert card_id in [c["id"] for c in by_note]
def test_unbekannte_felder_werden_abgewiesen(client, scanned):
response = client.patch(
f"/api/cards/{scanned['cards'][0]['id']}", json={"extract_status": "ok"}
)
assert response.status_code == 400
def test_vcard_download_markiert_die_karte_als_exportiert(client, scanned):
card_id = scanned["cards"][1]["id"]
client.patch(f"/api/cards/{card_id}", json={"full_name": "Bea Kurz"})
response = client.get(f"/vcf/{card_id}")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/vcard")
assert "Bea-Kurz.vcf" in response.headers["content-disposition"]
assert "FN:Bea Kurz" in response.text
assert client.get(f"/api/cards/{card_id}").json()["exported_at"] is not None
def test_filter_offen_blendet_exportierte_karten_aus(client, scanned):
offen = client.get("/api/cards", params={"filter": "offen"}).json()["cards"]
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)
created = client.post(
f"/api/cards/{card_id}/reminders",
json={"text": "Angebot nachfassen", "due_at": due.isoformat()},
)
assert created.status_code == 200
detail = client.get(f"/api/cards/{card_id}").json()
assert len(detail["reminders"]) == 1
mit_erinnerung = client.get("/api/cards", params={"filter": "erinnerung"}).json()
assert card_id in [c["id"] for c in mit_erinnerung["cards"]]
assert mit_erinnerung["stats"]["erinnerungen"] >= 1
def test_erinnerung_ohne_datum_wird_abgewiesen(client, scanned):
response = client.post(
f"/api/cards/{scanned['cards'][2]['id']}/reminders",
json={"text": "Ohne Termin", "due_at": "morgen"},
)
assert response.status_code == 400
def test_faellige_erinnerung_wird_verschickt(client, scanned, monkeypatch):
card_id = scanned["cards"][3]["id"]
client.patch(f"/api/cards/{card_id}", json={"full_name": "Carl Spät"})
past = datetime.now(timezone.utc) - timedelta(minutes=5)
client.post(
f"/api/cards/{card_id}/reminders",
json={"text": "Rueckruf", "due_at": past.isoformat()},
)
verschickt = []
monkeypatch.setattr(mailer, "configured", lambda: True)
monkeypatch.setattr(mailer, "send", lambda subject, body: verschickt.append((subject, body)))
assert reminders.process_due() == 1
assert "Carl Spät" in verschickt[0][0]
assert "Rueckruf" in verschickt[0][1]
# Zweiter Durchlauf darf nicht erneut verschicken.
assert reminders.process_due() == 0
def test_loeschen_entfernt_karte_und_bild(client):
created = client.post(
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
).json()
card_id = created["cards"][0]["id"]
image_file = db.query_one("SELECT image_file FROM cards WHERE id = ?", (card_id,))["image_file"]
from app import config
assert (config.IMAGE_DIR / image_file).exists()
assert client.delete(f"/api/cards/{card_id}").status_code == 200
assert client.get(f"/api/cards/{card_id}").status_code == 404
assert not (config.IMAGE_DIR / image_file).exists()
def test_kaputter_upload_gibt_400(client):
response = client.post(
"/api/scan", files={"file": ("kaputt.jpg", b"kein bild", "image/jpeg")}
)
assert response.status_code == 400
def test_pwa_dateien_werden_ausgeliefert(client):
assert client.get("/").status_code == 200
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

72
tests/test_imaging.py Normal file
View File

@@ -0,0 +1,72 @@
import io
from PIL import Image
from app import imaging
def _jpeg_with_exif(tags: dict) -> bytes:
image = Image.new("RGB", (64, 40), "white")
exif = Image.Exif()
for tag, value in tags.items():
exif[tag] = value
buffer = io.BytesIO()
image.save(buffer, format="JPEG", exif=exif.tobytes())
return buffer.getvalue()
def test_aufnahmezeit_wird_gelesen():
raw = _jpeg_with_exif({0x9003: "2026:09:01 14:23:05"})
meta = imaging.read_metadata(raw)
assert meta["captured_at"].startswith("2026-09-01T14:23:05")
def test_gps_wird_in_dezimalgrad_umgerechnet():
raw = _jpeg_with_exif(
{0x8825: {1: "N", 2: (52.0, 19.0, 18.0), 3: "E", 4: (9.0, 48.0, 28.0)}}
)
meta = imaging.read_metadata(raw)
assert meta["lat"] == 52.321667
assert meta["lon"] == 9.807778
def test_suedliche_und_westliche_koordinaten_werden_negativ():
raw = _jpeg_with_exif(
{0x8825: {1: "S", 2: (33.0, 55.0, 0.0), 3: "W", 4: (18.0, 25.0, 0.0)}}
)
meta = imaging.read_metadata(raw)
assert meta["lat"] < 0
assert meta["lon"] < 0
def test_foto_ohne_exif_liefert_leere_metadaten():
buffer = io.BytesIO()
Image.new("RGB", (32, 32), "white").save(buffer, format="JPEG")
meta = imaging.read_metadata(buffer.getvalue())
assert meta == {"captured_at": None, "lat": None, "lon": None}
def test_kaputte_datei_wirft_nicht():
assert imaging.read_metadata(b"kein bild") == {
"captured_at": None,
"lat": None,
"lon": None,
}
def test_dekodieren_liefert_bgr_array():
buffer = io.BytesIO()
Image.new("RGB", (20, 10), (255, 0, 0)).save(buffer, format="PNG")
array = imaging.decode(buffer.getvalue())
assert array.shape == (10, 20, 3)
assert tuple(array[0, 0]) == (0, 0, 255) # Rot liegt in BGR hinten

45
tests/test_segment.py Normal file
View File

@@ -0,0 +1,45 @@
import numpy as np
from app import segment
from tests.helpers import photo_with_cards
def test_findet_alle_karten_im_stapel():
photo = photo_with_cards(count=6)
crops, fallback = segment.segment(photo)
assert fallback is False
assert len(crops) == 6
def test_zuschnitte_haben_kartenformat():
crops, _ = segment.segment(photo_with_cards(count=4))
for crop in crops:
height, width = crop.shape[:2]
assert (width, height) == (segment.OUT_WIDTH, segment.OUT_HEIGHT)
def test_zuschnitt_enthaelt_die_karte_und_nicht_den_tisch():
crops, _ = segment.segment(photo_with_cards(count=4))
for crop in crops:
# Die Karte ist weiss, der Untergrund dunkel. Ein Zuschnitt, der
# danebenliegt, waere im Mittel dunkel.
assert crop.mean() > 170
def test_grosser_stapel():
crops, fallback = segment.segment(photo_with_cards(count=12))
assert fallback is False
assert len(crops) == 12
def test_einzelne_formatfuellende_karte_faellt_auf_gesamtbild_zurueck():
# Randlose Karte: es gibt keine Kante zum Untergrund, also keinen Fund.
card = np.full((1080, 1712, 3), 245, dtype=np.uint8)
crops, fallback = segment.segment(card)
assert fallback is True
assert len(crops) == 1

71
tests/test_vcard.py Normal file
View File

@@ -0,0 +1,71 @@
from app import vcard
BASE = {
"full_name": "Dr. Anna Berg",
"first_name": "Anna",
"last_name": "Berg",
"job_title": "Geschäftsführerin",
"company": "Berg & Partner GmbH",
"email": "a.berg@berg-partner.de",
"phone": "+49 511 1234560",
"mobile": "+49 170 1234567",
"website": "berg-partner.de",
"street": "Musterweg 3",
"postal_code": "30159",
"city": "Hannover",
"country": "Deutschland",
"notes": "Sucht ein neues CRM.",
"captured_at": "2026-09-01T10:15:00+00:00",
"place": "Hannover, Messegelände",
"lat": 52.3216,
"lon": 9.8078,
}
def test_enthaelt_die_pflichtzeilen():
output = vcard.build(BASE)
assert output.startswith("BEGIN:VCARD\r\nVERSION:3.0\r\n")
assert output.endswith("END:VCARD\r\n")
assert "FN:Dr. Anna Berg" in output
assert "N:Berg;Anna;;;" in output
assert "TEL;TYPE=CELL:+49 170 1234567" in output
def test_kommas_und_semikolons_werden_maskiert():
output = vcard.build({**BASE, "company": "Berg, Partner; Co"})
assert "ORG:Berg\\, Partner\\; Co" in output
def test_notiz_traegt_zeit_und_ort():
note = [line for line in vcard.build(BASE).splitlines() if line.startswith("NOTE:")][0]
assert "Sucht ein neues CRM." in note
assert "01.09.2026" in note
assert "Messegelände" in note
def test_leere_karte_bekommt_trotzdem_einen_namen():
output = vcard.build({"notes": ""})
assert "FN:Unbenannt" in output
def test_firma_ersetzt_den_namen_wenn_keiner_erkannt_wurde():
output = vcard.build({"company": "Berg & Partner GmbH"})
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"
assert vcard.filename({"full_name": "?!"}) == "kontakt.vcf"