2 Commits

Author SHA1 Message Date
Lucas Orth
1f29d5eb27 effort-Parameter modellabhängig senden, Design-Hierarchie, iOS-Datumsfeld
All checks were successful
Build & Deploy / deploy (push) Successful in 1m14s
Fehler: claude-haiku-4-5 lehnt output_config.effort mit einem 400 ab, jede
Karte lief in einen Lesefehler. effort und der server-seitige Fallback
werden jetzt nur an Modelle geschickt, die sie annehmen; ein unbekanntes
Modell mit engerem Parametersatz wird einmal ohne Zusatzparameter
wiederholt, statt die Karte zu verlieren. Tests decken die Zuordnung ab.

Design: Grau kommt als Hierarchiestufe dazu. Drei Ebenen für Fläche
(weiß / grau gefüllt / schwarz), Text (schwarz / --ink-2 / --ink-3) und
Linie (3px / 1px schwarz / graue Haarlinie). Vorher trug alles dieselbe
3px-Kante und dasselbe Schwarz, dadurch war keine Ordnung erkennbar.
Konkret: nicht gewählte Filter treten zurück, Listenzeilen bekommen drei
Textstufen und Statuspillen statt einer gleichförmigen Zeile, das
Kopfband über Listen ist grau hinterlegt, zweitrangige und zerstörende
Aktionen sind leiser als die primäre.

iOS: Datumsfelder zentrieren ihren Wert und sacken in der Höhe ein. Die
WebKit-Pseudoelemente ziehen sie auf die Form der übrigen Felder.

Dazu die Domain in der Dokumentation auf scanner.lucas-orth.de.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:36:24 +02:00
Lucas Orth
bc1981f1bd Scan-Pfad entschlacken, Container-Limits setzen
Drei Änderungen, die den Spitzenverbrauch eines Stapelscans senken:

- Zuschnitte werden nicht mehr auf 1400 px hochgerechnet. Auf einem
  Stapelfoto ist eine Karte nur ein paar hundert Pixel breit; sie
  aufzublasen erzeugt vier Mal so viele Pixel ohne mehr Information und
  kostet zusätzlich Bildtokens beim Modellaufruf. OUT_WIDTH ist jetzt
  eine Obergrenze.
- Die um 180 Grad gedrehte Fassung entsteht erst, wenn ein Ergebnis leer
  bleibt, statt für jede Karte auf Vorrat. Das halbiert die
  JPEG-Kodierungen im Normalfall.
- Zuschnitte werden einzeln kodiert und sofort freigegeben, statt
  gesammelt im Speicher zu liegen.

Dazu mem_limit und cpus für den App-Container: ein Ausreißer soll den
Container treffen, nicht den Host, auf dem auch Jitsi und MySQL laufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:22:37 +02:00
12 changed files with 388 additions and 169 deletions

View File

@@ -149,7 +149,7 @@ Neuer Proxy Host:
| Feld | Wert | | Feld | Wert |
|---|---| |---|---|
| Domain Names | `business-card-scanner.lucas-orth.de` | | Domain Names | `scanner.lucas-orth.de` |
| Scheme | `http` | | Scheme | `http` |
| Forward Hostname / IP | `business-card-scanner` (= `container_name`) | | Forward Hostname / IP | `business-card-scanner` (= `container_name`) |
| Forward Port | `8080` | | Forward Port | `8080` |

View File

@@ -56,7 +56,7 @@ Tag-Push nach den Konventionen in [DEPLOY.md](DEPLOY.md). Vor dem ersten Tag:
nach dem Muster in [.env.example](.env.example). `SECRET_KEY` mit nach dem Muster in [.env.example](.env.example). `SECRET_KEY` mit
`openssl rand -hex 32` erzeugen. `openssl rand -hex 32` erzeugen.
2. **Proxy Host** im Nginx Proxy Manager: 2. **Proxy Host** im Nginx Proxy Manager:
`business-card-scanner.lucas-orth.de` → `business-card-scanner` : `8080`, `scanner.lucas-orth.de` → `business-card-scanner` : `8080`,
danach Let's-Encrypt-Zertifikat und „Force SSL“. danach Let's-Encrypt-Zertifikat und „Force SSL“.
3. **Deployen:** `git tag v1.0.0 && git push origin v1.0.0` 3. **Deployen:** `git tag v1.0.0 && git push origin v1.0.0`

View File

@@ -11,7 +11,7 @@ import logging
import anthropic import anthropic
from . import config from . import config, imaging
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -52,9 +52,22 @@ Regeln:
PROMPT = "Lies diese Visitenkarte aus." PROMPT = "Lies diese Visitenkarte aus."
# Nicht jedes Modell nimmt jeden Parameter an: die kleinen Modelle kennen
# output_config.effort nicht (400), der server-seitige Fallback gilt nur fuer
# die groessten.
_NO_EFFORT = ("claude-haiku-", "claude-sonnet-4-5")
_WITH_FALLBACK = ("claude-opus-5", "claude-fable-")
_client = None _client = None
def _output_config(with_effort: bool) -> dict:
settings = {"format": {"type": "json_schema", "schema": SCHEMA}}
if with_effort:
settings["effort"] = "low" # reines Ablesen, keine Denkarbeit
return settings
def available() -> bool: def available() -> bool:
return bool(config.ANTHROPIC_API_KEY) return bool(config.ANTHROPIC_API_KEY)
@@ -92,27 +105,28 @@ async def _ask(image_jpeg: bytes) -> dict:
], ],
} }
] ]
common = dict( model = config.ANTHROPIC_MODEL
model=config.ANTHROPIC_MODEL, common = dict(model=model, max_tokens=2000, system=SYSTEM, messages=messages)
max_tokens=2000, effort = not model.startswith(_NO_EFFORT)
system=SYSTEM,
messages=messages,
output_config={
"format": {"type": "json_schema", "schema": SCHEMA},
"effort": "low", # reines Ablesen, keine Denkarbeit
},
)
try: try:
response = await client.beta.messages.create( if model.startswith(_WITH_FALLBACK):
betas=["server-side-fallback-2026-07-01"], response = await client.beta.messages.create(
fallbacks="default", betas=["server-side-fallback-2026-07-01"],
**common, fallbacks="default",
output_config=_output_config(effort),
**common,
)
else:
response = await client.messages.create(
output_config=_output_config(effort), **common
)
except anthropic.BadRequestError as exc:
# Ein Modell mit engerem Parametersatz, als die Listen oben kennen.
# Einmal ohne Zusatzparameter nachfassen, statt die Karte zu verlieren.
log.warning("Anfrage abgelehnt (%s) - Wiederholung ohne Zusatzparameter", exc)
response = await client.messages.create(
output_config=_output_config(with_effort=False), **common
) )
except anthropic.BadRequestError:
# Der Fallback-Parameter haengt am Modell. Ohne ihn laeuft die Anfrage
# weiter, sie bricht bei einer Ablehnung nur ohne Ersatzmodell ab.
log.warning("Server-side fallback abgelehnt, Anfrage ohne Fallback")
response = await client.messages.create(**common)
if response.stop_reason == "refusal": if response.stop_reason == "refusal":
return _empty("Vom Modell abgelehnt") return _empty("Vom Modell abgelehnt")
@@ -123,11 +137,12 @@ async def _ask(image_jpeg: bytes) -> dict:
return json.loads(text) return json.loads(text)
async def extract(image_jpeg: bytes, rotated_jpeg: bytes = None) -> dict: async def extract(image_jpeg: bytes) -> dict:
"""Ein Kartenbild auslesen. """Ein Kartenbild auslesen.
Bleibt das Ergebnis leer, wird einmal um 180 Grad gedreht nachgefasst: Bleibt das Ergebnis leer, wird einmal um 180 Grad gedreht nachgefasst:
wie herum die Karte auf dem Tisch lag, ist geometrisch nicht bestimmbar. wie herum die Karte auf dem Tisch lag, ist geometrisch nicht bestimmbar.
Die gedrehte Fassung entsteht erst hier, nicht auf Vorrat fuer jede Karte.
""" """
if not available(): if not available():
return _empty("Kein ANTHROPIC_API_KEY gesetzt") return _empty("Kein ANTHROPIC_API_KEY gesetzt")
@@ -137,9 +152,10 @@ async def extract(image_jpeg: bytes, rotated_jpeg: bytes = None) -> dict:
log.exception("Extraktion fehlgeschlagen") log.exception("Extraktion fehlgeschlagen")
return _empty(f"{type(exc).__name__}: {exc}") return _empty(f"{type(exc).__name__}: {exc}")
if not _has_content(result) and rotated_jpeg is not None: if not _has_content(result):
try: try:
retry = await _ask(rotated_jpeg) rotated = await asyncio.to_thread(imaging.rotate_jpeg_180, image_jpeg)
retry = await _ask(rotated)
if _has_content(retry): if _has_content(retry):
retry["_rotated"] = True retry["_rotated"] = True
return retry return retry
@@ -148,12 +164,12 @@ async def extract(image_jpeg: bytes, rotated_jpeg: bytes = None) -> dict:
return result return result
async def extract_all(pairs: list) -> list: async def extract_all(images: list) -> list:
"""Alle Karten eines Fotos parallel auslesen.""" """Alle Karten eines Fotos parallel auslesen."""
limit = asyncio.Semaphore(max(1, config.EXTRACT_CONCURRENCY)) limit = asyncio.Semaphore(max(1, config.EXTRACT_CONCURRENCY))
async def one(upright: bytes, rotated: bytes) -> dict: async def one(image_jpeg: bytes) -> dict:
async with limit: async with limit:
return await extract(upright, rotated) return await extract(image_jpeg)
return await asyncio.gather(*(one(u, r) for u, r in pairs)) return await asyncio.gather(*(one(image) for image in images))

View File

@@ -79,3 +79,12 @@ def encode_jpeg(rgb_or_bgr: np.ndarray, quality: int = 88, bgr: bool = True) ->
Image.fromarray(array).save(buffer, format="JPEG", quality=quality, optimize=True) Image.fromarray(array).save(buffer, format="JPEG", quality=quality, optimize=True)
return buffer.getvalue() 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)
buffer = io.BytesIO()
image.save(buffer, format="JPEG", quality=88, optimize=True)
return buffer.getvalue()

View File

@@ -6,7 +6,6 @@ from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import cv2
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, UploadFile from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -104,17 +103,16 @@ def logout(response: Response) -> dict:
# ------------------------------------------------------------------------------ Scan # ------------------------------------------------------------------------------ Scan
def _process_photo(raw: bytes) -> tuple: def _process_photo(raw: bytes) -> tuple:
"""Rechenlastiger Teil: dekodieren, freistellen, als JPEG kodieren.""" """Rechenlastiger Teil: dekodieren, freistellen, als JPEG kodieren.
Die Zuschnitte werden einzeln kodiert und sofort freigegeben - bei zwanzig
Karten liegen sonst rund 70 MB Rohbild gleichzeitig im Speicher.
"""
meta = imaging.read_metadata(raw) meta = imaging.read_metadata(raw)
image = imaging.decode(raw) crops, fallback = segment.segment(imaging.decode(raw))
crops, fallback = segment.segment(image) encoded = []
encoded = [ while crops:
( encoded.append(imaging.encode_jpeg(crops.pop(0)))
imaging.encode_jpeg(crop),
imaging.encode_jpeg(cv2.rotate(crop, cv2.ROTATE_180)),
)
for crop in crops
]
return meta, encoded, fallback return meta, encoded, fallback
@@ -152,7 +150,7 @@ async def scan(
created = now_iso() created = now_iso()
source = "einzel" if fallback else "stapel" source = "einzel" if fallback else "stapel"
cards = [] cards = []
for (upright, _), result in zip(encoded, results): for upright, result in zip(encoded, results):
card_id = uuid.uuid4().hex card_id = uuid.uuid4().hex
image_file = f"{card_id}.jpg" image_file = f"{card_id}.jpg"
(config.IMAGE_DIR / image_file).write_bytes(upright) (config.IMAGE_DIR / image_file).write_bytes(upright)

View File

@@ -11,7 +11,8 @@ import numpy as np
CARD_RATIO = 85.6 / 54.0 CARD_RATIO = 85.6 / 54.0
RATIO_MIN, RATIO_MAX = 1.25, 2.05 RATIO_MIN, RATIO_MAX = 1.25, 2.05
OUT_WIDTH = 1400 OUT_WIDTH = 1400 # Obergrenze; kleinere Karten werden nicht hochskaliert
MIN_WIDTH = 600
OUT_HEIGHT = round(OUT_WIDTH / CARD_RATIO) OUT_HEIGHT = round(OUT_WIDTH / CARD_RATIO)
DETECT_EDGE = 1600 # Aufloesung, auf der gesucht wird DETECT_EDGE = 1600 # Aufloesung, auf der gesucht wird
@@ -129,19 +130,32 @@ def segment(image: np.ndarray) -> tuple[list[np.ndarray], bool]:
crops = [] crops = []
for rect in _reading_order(rects): for rect in _reading_order(rects):
quad = _order_quad(cv2.boxPoints(rect)) / scale # zurueck auf volle Aufloesung quad = _order_quad(cv2.boxPoints(rect)) / scale # zurueck auf volle Aufloesung
width, height = _target_size(quad)
target = np.array( target = np.array(
[[0, 0], [OUT_WIDTH, 0], [OUT_WIDTH, OUT_HEIGHT], [0, OUT_HEIGHT]], [[0, 0], [width, 0], [width, height], [0, height]], dtype="float32"
dtype="float32",
) )
matrix = cv2.getPerspectiveTransform(quad, target) matrix = cv2.getPerspectiveTransform(quad, target)
crops.append( crops.append(
cv2.warpPerspective( cv2.warpPerspective(image, matrix, (width, height), flags=cv2.INTER_CUBIC)
image, matrix, (OUT_WIDTH, OUT_HEIGHT), flags=cv2.INTER_CUBIC
)
) )
return crops, False return crops, False
def _target_size(quad: np.ndarray) -> tuple:
"""Zuschnitt so gross wie die Vorlage, hoechstens OUT_WIDTH.
Auf einem Stapelfoto ist eine Karte nur ein paar hundert Pixel breit.
Sie auf 1400 hochzurechnen erzeugt vier Mal so viele Pixel ohne ein
Quentchen mehr Information - und kostet Speicher, Bildgroesse und
Bildtokens beim Modellaufruf.
"""
long_edge = max(
np.linalg.norm(quad[1] - quad[0]), np.linalg.norm(quad[2] - quad[3])
)
width = int(min(OUT_WIDTH, max(MIN_WIDTH, round(long_edge))))
return width, round(width / CARD_RATIO)
def _fit_whole(image: np.ndarray) -> np.ndarray: def _fit_whole(image: np.ndarray) -> np.ndarray:
"""Ohne Fund: das ganze Bild als eine Karte behandeln.""" """Ohne Fund: das ganze Bild als eine Karte behandeln."""
height, width = image.shape[:2] height, width = image.shape[:2]

View File

@@ -12,6 +12,11 @@ services:
restart: unless-stopped restart: unless-stopped
# Wird im Workflow aus dem Secret DOTENV erzeugt. # Wird im Workflow aus dem Secret DOTENV erzeugt.
env_file: .env env_file: .env
# Deckel gegen Ausreisser: ein Stapelscan braucht im Normalfall deutlich
# unter 500 MB. Laeuft er aus dem Ruder, stirbt der Container - nicht der
# Host, auf dem auch Jitsi und die Datenbank liegen.
mem_limit: 1g
cpus: 1.5
volumes: volumes:
- business-card-scanner-data:/data - business-card-scanner-data:/data
networks: networks:

View File

@@ -111,6 +111,20 @@
}); });
} }
/** Zustand einer Karte als abgestufte Pillen: schwarz, was erledigt ist,
* grau umrandet, was noch offen oder auffaellig ist. */
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"]);
return tags
.map(([extra, text]) => `<span class="pill ${extra}">${esc(text)}</span>`)
.join("");
}
// ----------------------------------------------------------------- Routing // ----------------------------------------------------------------- Routing
function go(route, cardId) { function go(route, cardId) {
@@ -167,7 +181,7 @@
<button class="row" data-card="${esc(card.id)}"> <button class="row" data-card="${esc(card.id)}">
<span class="row-head"> <span class="row-head">
<span class="row-title">${esc(displayName(card))}</span> <span class="row-title">${esc(displayName(card))}</span>
<span class="row-meta">${card.has_contact ? "OK" : "Leer"}</span> <span class="row-meta">${card.has_contact ? "Gelesen" : "Leer"}</span>
</span> </span>
<span class="row-sub">${esc(card.company || card.email || "Keine Daten erkannt")}</span> <span class="row-sub">${esc(card.company || card.email || "Keine Daten erkannt")}</span>
</button>` </button>`
@@ -234,15 +248,7 @@
<span class="row-sub"> <span class="row-sub">
${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")} ${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")}
</span> </span>
<span class="row-sub">${esc( <span class="row-tags">${cardTags(card)}</span>
[
card.place,
card.exported_at ? "exportiert" : "nicht exportiert",
card.extract_status !== "ok" ? "Lesefehler" : null,
]
.filter(Boolean)
.join(" · ")
)}</span>
</button>` </button>`
) )
.join("")} .join("")}
@@ -367,7 +373,7 @@
</form> </form>
<div class="section-label"><span class="label">Karte entfernen</span></div> <div class="section-label"><span class="label">Karte entfernen</span></div>
<button class="btn btn-danger" data-delete-card="${esc(card.id)}"> <button class="btn btn-quiet" data-delete-card="${esc(card.id)}">
Endgültig löschen <span class="arrow">×</span> Endgültig löschen <span class="arrow">×</span>
</button>`; </button>`;

View File

@@ -57,7 +57,7 @@
Foto aufnehmen <span class="arrow">→</span> Foto aufnehmen <span class="arrow">→</span>
<input id="input-camera" type="file" accept="image/*" capture="environment" hidden> <input id="input-camera" type="file" accept="image/*" capture="environment" hidden>
</label> </label>
<label class="btn" for="input-library"> <label class="btn btn-quiet" for="input-library">
Aus Fotos wählen <span class="arrow">↗</span> Aus Fotos wählen <span class="arrow">↗</span>
<input id="input-library" type="file" accept="image/*" hidden> <input id="input-library" type="file" accept="image/*" hidden>
</label> </label>

View File

@@ -1,14 +1,24 @@
/* Schwarzweiss, harte Kanten, keine Rundungen. Mobile first. */ /* Schwarzweiss mit Grau als Hierarchiestufe. Harte Kanten, keine Rundungen.
Mobile first.
Drei Ebenen, konsequent durchgehalten:
Flaeche weiss -> --fill (gruppiert) -> schwarz (das eine Wichtige)
Text --ink -> --ink-2 (erlaeuternd) -> --ink-3 (Beiwerk)
Linie 3px schwarz (traegt) -> 1px schwarz (gliedert) -> --rule (trennt) */
:root { :root {
color-scheme: light; color-scheme: light;
--fg: #000;
--ink: #000;
--ink-2: #4a4a4a;
--ink-3: #8a8a8a;
--bg: #fff; --bg: #fff;
--muted: #6b6b6b; --fill: #f4f4f4;
--hair: #d5d5d5; --page: #e4e4e4;
--wash: #f2f2f2; --rule: #d4d4d4;
--sans: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif; --sans: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
--shell: 3px;
--pad: 18px; --pad: 18px;
} }
@@ -17,8 +27,8 @@
html, body { html, body {
margin: 0; margin: 0;
padding: 0; padding: 0;
background: var(--wash); background: var(--page);
color: var(--fg); color: var(--ink);
font-family: var(--sans); font-family: var(--sans);
font-size: 15px; font-size: 15px;
line-height: 1.45; line-height: 1.45;
@@ -42,36 +52,37 @@ a { color: inherit; }
.display { .display {
font-weight: 800; font-weight: 800;
font-stretch: 85%; font-stretch: 85%;
letter-spacing: -0.02em; letter-spacing: -0.025em;
text-transform: uppercase; text-transform: uppercase;
line-height: 0.95; line-height: 0.94;
margin: 0; margin: 0;
} }
.d-xl { font-size: 40px; } .d-xl { font-size: 42px; }
.d-lg { font-size: 28px; } .d-lg { font-size: 30px; }
.d-md { font-size: 20px; } .d-md { font-size: 19px; letter-spacing: -0.01em; }
.label { .label {
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.16em; letter-spacing: 0.18em;
text-transform: uppercase; text-transform: uppercase;
margin: 0; margin: 0;
} }
.muted { color: var(--muted); } .muted { color: var(--ink-2); }
.hint { font-size: 12.5px; line-height: 1.5; color: var(--ink-3); }
.mono-num { font-variant-numeric: tabular-nums; } .mono-num { font-variant-numeric: tabular-nums; }
/* ---------------------------------------------------------------------- Gerüst */ /* ---------------------------------------------------------------------- Geruest */
.app { .app {
max-width: 560px; max-width: 560px;
margin: 0 auto; margin: 0 auto;
min-height: 100vh; min-height: 100vh;
background: var(--bg); background: var(--bg);
border-left: 1px solid var(--fg); border-left: 1px solid var(--ink);
border-right: 1px solid var(--fg); border-right: 1px solid var(--ink);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -83,83 +94,94 @@ a { color: inherit; }
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 14px var(--pad); padding: 13px var(--pad);
background: var(--bg); background: var(--bg);
border-bottom: var(--shell) solid var(--fg); border-bottom: 3px solid var(--ink);
} }
.mark { .mark {
width: 34px; width: 32px;
height: 34px; height: 32px;
flex: 0 0 34px; flex: 0 0 32px;
background: var(--fg); background: var(--ink);
display: grid; display: grid;
place-items: center; place-items: center;
} }
.mark svg { width: 20px; height: 20px; } .mark svg { width: 19px; height: 19px; }
.wordmark { line-height: 1; } .wordmark { line-height: 1; }
.wordmark .d-md { display: block; } .wordmark .d-md { display: block; }
.wordmark .label { color: var(--muted); margin-top: 3px; } .wordmark .label { color: var(--ink-3); margin-top: 4px; }
.topbar .spacer { margin-left: auto; } .topbar .spacer { margin-left: auto; }
main { flex: 1; display: flex; flex-direction: column; } main { flex: 1; display: flex; flex-direction: column; }
.screen { flex: 1; padding: var(--pad) var(--pad) 28px; } .screen { flex: 1; padding: var(--pad) var(--pad) 30px; }
.screen[hidden] { display: none; } .screen[hidden] { display: none; }
.stack > * + * { margin-top: 14px; } .stack > * + * { margin-top: 12px; }
.stack-tight > * + * { margin-top: 8px; } .stack-tight > * + * { margin-top: 8px; }
/* ---------------------------------------------------------------------- Blöcke */ /* ---------------------------------------------------------------------- Bloecke */
.block {
border: var(--shell) solid var(--fg);
padding: 16px;
}
/* Oberste Ebene: das eine Wichtige pro Ansicht. Nur einmal verwenden. */
.block-invert { .block-invert {
background: var(--fg); background: var(--ink);
color: var(--bg); color: var(--bg);
border: var(--shell) solid var(--fg); padding: 20px 18px;
}
.block-invert .muted { color: #a9a9a9; }
.block-invert .label { color: #a9a9a9; }
/* Mittlere Ebene: gruppiert, tritt zurueck. */
.block {
background: var(--fill);
border: 1px solid var(--rule);
padding: 16px; padding: 16px;
} }
.block-invert .muted { color: #b4b4b4; } /* Kopfband ueber einer Liste - ordnet zu, konkurriert aber nicht. */
.strip { .strip {
border: var(--shell) solid var(--fg);
border-top: none;
padding: 12px 16px;
display: flex; display: flex;
align-items: baseline; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 11px 14px;
background: var(--fill);
border: 1px solid var(--ink);
border-bottom: none;
} }
.strip .label { color: var(--ink-2); }
.section-label { .section-label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 12px;
margin: 26px 0 10px; margin: 34px 0 12px;
} }
.section-label .label { color: var(--ink); }
.section-label::after { .section-label::after {
content: ""; content: "";
flex: 1; flex: 1;
height: 1px; height: 1px;
background: var(--fg); background: var(--rule);
} }
.figures { .figures {
display: flex; display: flex;
gap: 30px; gap: 32px;
margin-top: 18px; margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.28);
} }
.figure { display: flex; flex-direction: column; } .figure { display: flex; flex-direction: column; }
.figure .label { color: inherit; opacity: 0.7; margin-bottom: 5px; } .figure .label { margin-bottom: 6px; }
/* --------------------------------------------------------------------- Buttons */ /* --------------------------------------------------------------------- Buttons */
@@ -171,7 +193,7 @@ main { flex: 1; display: flex; flex-direction: column; }
width: 100%; width: 100%;
padding: 16px; padding: 16px;
background: var(--bg); background: var(--bg);
border: var(--shell) solid var(--fg); border: 2px solid var(--ink);
font-weight: 800; font-weight: 800;
font-size: 14px; font-size: 14px;
letter-spacing: 0.06em; letter-spacing: 0.06em;
@@ -181,54 +203,81 @@ main { flex: 1; display: flex; flex-direction: column; }
text-align: left; text-align: left;
} }
.btn-primary { background: var(--fg); color: var(--bg); } /* Was man hier tun soll. */
.btn-primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
/* Vorhanden, aber leise. */
.btn-quiet {
border: 1px solid var(--rule);
color: var(--ink-2);
font-weight: 700;
}
.btn:disabled { opacity: 0.35; cursor: not-allowed; } .btn:disabled { opacity: 0.35; cursor: not-allowed; }
.btn .arrow { font-weight: 400; font-size: 17px; } .btn .arrow { font-weight: 400; font-size: 17px; }
.btn-quiet .arrow { color: var(--ink-3); }
.btn-danger { border-color: var(--fg); color: var(--fg); }
/* --------------------------------------------------------------------- Formular */ /* --------------------------------------------------------------------- Formular */
.field { display: block; } .field { display: block; }
.field .label { color: var(--muted); margin-bottom: 5px; } .field .label { color: var(--ink-3); margin-bottom: 6px; }
.field input, .field input,
.field textarea, .field textarea {
.field select {
width: 100%; width: 100%;
padding: 11px 12px; padding: 11px 12px;
background: var(--bg); background: var(--bg);
border: 2px solid var(--fg); border: 1px solid var(--ink);
} }
.field input:focus, .field input:focus,
.field textarea:focus, .field textarea:focus { outline: 2px solid var(--ink); outline-offset: 1px; }
.field select:focus { outline: 3px solid var(--fg); outline-offset: 1px; }
.field textarea { min-height: 90px; resize: vertical; } .field textarea { min-height: 88px; resize: vertical; }
.search { /* iOS rendert Datumsfelder eigenwillig: der Wert sitzt zentriert, die Hoehe
display: flex; sackt ein, der Innenabstand kommt vom System. Das zieht sie auf die Form
border: var(--shell) solid var(--fg); der uebrigen Felder. */
.field input[type="datetime-local"] {
min-height: 46px;
line-height: 1.2;
text-align: left;
font-variant-numeric: tabular-nums;
} }
.search input { flex: 1; border: none; padding: 14px; background: var(--bg); } .field input[type="datetime-local"]::-webkit-date-and-time-value {
text-align: left;
margin: 0;
min-height: 20px;
}
.field input[type="datetime-local"]::-webkit-calendar-picker-indicator {
opacity: 0.5;
filter: grayscale(1);
}
.search { display: flex; border: 2px solid var(--ink); }
.search input { flex: 1; border: none; padding: 13px 14px; background: var(--bg); }
.search input:focus { outline: none; } .search input:focus { outline: none; }
.search button { .search button {
border: none; border: none;
border-left: 2px solid var(--fg); border-left: 1px solid var(--rule);
background: var(--bg); background: var(--fill);
color: var(--ink-2);
padding: 0 16px; padding: 0 16px;
cursor: pointer; cursor: pointer;
font-weight: 800; font-weight: 700;
} }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } .chips { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
/* Ungewaehlt zurueckgenommen, damit die Auswahl ueberhaupt auffaellt. */
.chip { .chip {
padding: 7px 11px; padding: 7px 11px;
border: 2px solid var(--fg); border: 1px solid var(--rule);
background: var(--bg); background: var(--bg);
color: var(--ink-3);
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.12em; letter-spacing: 0.12em;
@@ -236,11 +285,15 @@ main { flex: 1; display: flex; flex-direction: column; }
cursor: pointer; cursor: pointer;
} }
.chip[aria-pressed="true"] { background: var(--fg); color: var(--bg); } .chip[aria-pressed="true"] {
background: var(--ink);
border-color: var(--ink);
color: var(--bg);
}
/* --------------------------------------------------------------------- Tabellen */ /* --------------------------------------------------------------------- Listen */
.rows { border-top: 1px solid var(--fg); } .rows { border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink); }
.row { .row {
display: block; display: block;
@@ -248,30 +301,93 @@ main { flex: 1; display: flex; flex-direction: column; }
text-align: left; text-align: left;
background: var(--bg); background: var(--bg);
border: none; border: none;
border-bottom: 1px solid var(--hair); border-bottom: 1px solid var(--rule);
padding: 14px 0; padding: 14px;
cursor: pointer; cursor: pointer;
} }
.row:last-child { border-bottom: 1px solid var(--fg); } .row:last-child { border-bottom: none; }
.row-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.row-title { font-weight: 800; font-size: 16px; } .row-head {
.row-sub { color: var(--muted); font-size: 13px; margin-top: 2px; } display: flex;
.row-meta { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); white-space: nowrap; } align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.row-title { font-weight: 800; font-size: 17px; letter-spacing: -0.01em; }
.row-sub { color: var(--ink-2); font-size: 13.5px; margin-top: 3px; }
.row-meta {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-3);
white-space: nowrap;
}
.row-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; }
.pill {
display: inline-block;
padding: 3px 7px;
border: 1px solid var(--rule);
background: var(--bg);
color: var(--ink-3);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.pill-solid { background: var(--ink); border-color: var(--ink); color: var(--bg); }
.badge {
display: inline-block;
padding: 5px 9px;
border: 1px solid var(--ink);
background: var(--bg);
color: var(--ink);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
text-decoration: none;
cursor: pointer;
}
/* --------------------------------------------------------------------- Tabelle */
.data {
width: 100%;
border-collapse: collapse;
border-top: 1px solid var(--ink);
border-bottom: 1px solid var(--ink);
}
.data tr:nth-child(even) { background: var(--fill); }
.data th, .data td { padding: 10px 12px; text-align: left; vertical-align: top; }
.data th {
width: 36%;
font-weight: 600;
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-3);
padding-top: 13px;
}
.data { width: 100%; border-collapse: collapse; }
.data th, .data td { padding: 11px 0; border-bottom: 1px solid var(--hair); text-align: left; vertical-align: top; }
.data th { width: 38%; font-weight: 400; color: var(--muted); font-size: 13px; }
.data td { font-weight: 600; word-break: break-word; } .data td { font-weight: 600; word-break: break-word; }
.data tr:last-child th, .data tr:last-child td { border-bottom: none; }
/* ----------------------------------------------------------------------- Bilder */ /* ----------------------------------------------------------------------- Bilder */
.card-image { .card-image {
display: block; display: block;
width: 100%; width: 100%;
border: var(--shell) solid var(--fg); border: 1px solid var(--ink);
background: var(--wash); background: var(--fill);
} }
/* -------------------------------------------------------------------- Navigation */ /* -------------------------------------------------------------------- Navigation */
@@ -282,15 +398,16 @@ main { flex: 1; display: flex; flex-direction: column; }
z-index: 20; z-index: 20;
display: flex; display: flex;
background: var(--bg); background: var(--bg);
border-top: var(--shell) solid var(--fg); border-top: 3px solid var(--ink);
} }
.tab { .tab {
flex: 1; flex: 1;
padding: 14px 4px calc(14px + env(safe-area-inset-bottom)); padding: 12px 4px calc(12px + env(safe-area-inset-bottom));
background: var(--bg); background: var(--bg);
border: none; border: none;
border-left: 1px solid var(--fg); border-left: 1px solid var(--rule);
color: var(--ink-3);
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.12em; letter-spacing: 0.12em;
@@ -299,35 +416,47 @@ main { flex: 1; display: flex; flex-direction: column; }
} }
.tab:first-child { border-left: none; } .tab:first-child { border-left: none; }
.tab[aria-selected="true"] { background: var(--fg); color: var(--bg); } .tab[aria-selected="true"] { background: var(--ink); color: var(--bg); }
.tab .count { display: block; font-size: 16px; font-weight: 800; margin-bottom: 2px; }
/* ------------------------------------------------------------------- Zustände */ .tab .count {
display: block;
.empty { border: 2px dashed var(--fg); padding: 28px 18px; text-align: center; } font-size: 17px;
font-weight: 800;
.badge { color: var(--ink);
display: inline-block; margin-bottom: 1px;
padding: 3px 7px;
border: 2px solid var(--fg);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
} }
.tab[aria-selected="true"] .count { color: var(--bg); }
/* ------------------------------------------------------------------- Zustaende */
.empty {
border: 1px dashed var(--rule);
background: var(--fill);
padding: 30px 18px;
text-align: center;
}
.empty .label { color: var(--ink-2); }
.overlay { .overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 60; z-index: 60;
background: rgba(0, 0, 0, 0.82); background: rgba(0, 0, 0, 0.86);
display: grid; display: grid;
place-items: center; place-items: center;
padding: var(--pad); padding: var(--pad);
} }
.overlay[hidden] { display: none; } .overlay[hidden] { display: none; }
.overlay .block { background: var(--bg); width: 100%; max-width: 380px; }
.overlay .block {
background: var(--bg);
border: 3px solid var(--ink);
width: 100%;
max-width: 380px;
}
.spinner { .spinner {
width: 26px; width: 26px;
@@ -347,7 +476,7 @@ main { flex: 1; display: flex; flex-direction: column; }
z-index: 70; z-index: 70;
max-width: 90vw; max-width: 90vw;
padding: 12px 16px; padding: 12px 16px;
background: var(--fg); background: var(--ink);
color: var(--bg); color: var(--bg);
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
@@ -355,10 +484,8 @@ main { flex: 1; display: flex; flex-direction: column; }
.toast[hidden] { display: none; } .toast[hidden] { display: none; }
.hint { font-size: 12px; color: var(--muted); }
@media (min-width: 600px) { @media (min-width: 600px) {
:root { --pad: 24px; } :root { --pad: 26px; }
.app { border: 1px solid var(--fg); margin: 24px auto; min-height: calc(100vh - 48px); } .app { border: 1px solid var(--ink); margin: 26px auto; min-height: calc(100vh - 52px); }
.d-xl { font-size: 48px; } .d-xl { font-size: 52px; }
} }

35
tests/test_extract.py Normal file
View File

@@ -0,0 +1,35 @@
"""Der Parametersatz haengt am Modell - das war ein echter Produktionsfehler:
claude-haiku-4-5 lehnt output_config.effort mit einem 400 ab.
"""
from app import extract
def test_effort_nur_fuer_modelle_die_es_koennen():
assert "claude-haiku-4-5".startswith(extract._NO_EFFORT)
assert "claude-sonnet-4-5".startswith(extract._NO_EFFORT)
assert not "claude-opus-5".startswith(extract._NO_EFFORT)
assert not "claude-sonnet-5".startswith(extract._NO_EFFORT)
def test_server_seitiger_fallback_nur_fuer_die_grossen():
assert "claude-opus-5".startswith(extract._WITH_FALLBACK)
assert "claude-fable-5-1".startswith(extract._WITH_FALLBACK)
assert not "claude-haiku-4-5".startswith(extract._WITH_FALLBACK)
assert not "claude-sonnet-5".startswith(extract._WITH_FALLBACK)
def test_output_config_ohne_effort_bleibt_gueltig():
ohne = extract._output_config(with_effort=False)
mit = extract._output_config(with_effort=True)
assert "effort" not in ohne
assert ohne["format"]["type"] == "json_schema"
assert mit["effort"] == "low"
assert mit["format"] == ohne["format"]
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}
for field in extract.FIELDS:
assert extract.SCHEMA["properties"][field]["type"] == ["string", "null"]

View File

@@ -17,7 +17,16 @@ def test_zuschnitte_haben_kartenformat():
for crop in crops: for crop in crops:
height, width = crop.shape[:2] height, width = crop.shape[:2]
assert (width, height) == (segment.OUT_WIDTH, segment.OUT_HEIGHT) assert abs(width / height - segment.CARD_RATIO) < 0.01
assert segment.MIN_WIDTH <= width <= segment.OUT_WIDTH
def test_zuschnitt_wird_nicht_hochskaliert():
# Auf einem Stapelfoto ist eine Karte schmaler als OUT_WIDTH - der
# Zuschnitt darf die Vorlage nicht kuenstlich aufblasen.
crops, _ = segment.segment(photo_with_cards(count=12))
assert all(crop.shape[1] < segment.OUT_WIDTH for crop in crops)
def test_zuschnitt_enthaelt_die_karte_und_nicht_den_tisch(): def test_zuschnitt_enthaelt_die_karte_und_nicht_den_tisch():