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:
@@ -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 "-_")
|
||||
|
||||
Reference in New Issue
Block a user