Files
business-card-scanner/tests/helpers.py
Lucas Orth ab993e98e1 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>
2026-09-06 11:45:56 +02:00

60 lines
2.2 KiB
Python

"""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()