From bc1981f1bdbbbe2f26265e8f8cbf1e8319e61c2c Mon Sep 17 00:00:00 2001 From: Lucas Orth Date: Sun, 6 Sep 2026 12:22:37 +0200 Subject: [PATCH] Scan-Pfad entschlacken, Container-Limits setzen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/extract.py | 18 ++++++++++-------- app/imaging.py | 9 +++++++++ app/main.py | 22 ++++++++++------------ app/segment.py | 26 ++++++++++++++++++++------ docker-compose.yml | 5 +++++ tests/test_segment.py | 11 ++++++++++- 6 files changed, 64 insertions(+), 27 deletions(-) diff --git a/app/extract.py b/app/extract.py index 4a0eba1..54cfda7 100644 --- a/app/extract.py +++ b/app/extract.py @@ -11,7 +11,7 @@ import logging import anthropic -from . import config +from . import config, imaging log = logging.getLogger(__name__) @@ -123,11 +123,12 @@ async def _ask(image_jpeg: bytes) -> dict: 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. Bleibt das Ergebnis leer, wird einmal um 180 Grad gedreht nachgefasst: 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(): return _empty("Kein ANTHROPIC_API_KEY gesetzt") @@ -137,9 +138,10 @@ async def extract(image_jpeg: bytes, rotated_jpeg: bytes = None) -> dict: log.exception("Extraktion fehlgeschlagen") return _empty(f"{type(exc).__name__}: {exc}") - if not _has_content(result) and rotated_jpeg is not None: + if not _has_content(result): 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): retry["_rotated"] = True return retry @@ -148,12 +150,12 @@ async def extract(image_jpeg: bytes, rotated_jpeg: bytes = None) -> dict: return result -async def extract_all(pairs: list) -> list: +async def extract_all(images: list) -> list: """Alle Karten eines Fotos parallel auslesen.""" 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: - 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)) diff --git a/app/imaging.py b/app/imaging.py index 61c92ea..162688d 100644 --- a/app/imaging.py +++ b/app/imaging.py @@ -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) 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() + diff --git a/app/main.py b/app/main.py index dbefcb2..942c261 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,6 @@ from contextlib import asynccontextmanager from datetime import datetime, timezone from pathlib import Path -import cv2 from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, UploadFile from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles @@ -104,17 +103,16 @@ def logout(response: Response) -> dict: # ------------------------------------------------------------------------------ Scan 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) - image = imaging.decode(raw) - crops, fallback = segment.segment(image) - encoded = [ - ( - imaging.encode_jpeg(crop), - imaging.encode_jpeg(cv2.rotate(crop, cv2.ROTATE_180)), - ) - for crop in crops - ] + crops, fallback = segment.segment(imaging.decode(raw)) + encoded = [] + while crops: + encoded.append(imaging.encode_jpeg(crops.pop(0))) return meta, encoded, fallback @@ -152,7 +150,7 @@ async def scan( created = now_iso() source = "einzel" if fallback else "stapel" cards = [] - for (upright, _), result in zip(encoded, results): + for upright, result in zip(encoded, results): card_id = uuid.uuid4().hex image_file = f"{card_id}.jpg" (config.IMAGE_DIR / image_file).write_bytes(upright) diff --git a/app/segment.py b/app/segment.py index dedaf63..07c60fd 100644 --- a/app/segment.py +++ b/app/segment.py @@ -11,7 +11,8 @@ import numpy as np CARD_RATIO = 85.6 / 54.0 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) DETECT_EDGE = 1600 # Aufloesung, auf der gesucht wird @@ -129,19 +130,32 @@ 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 + width, height = _target_size(quad) target = np.array( - [[0, 0], [OUT_WIDTH, 0], [OUT_WIDTH, OUT_HEIGHT], [0, OUT_HEIGHT]], - dtype="float32", + [[0, 0], [width, 0], [width, height], [0, height]], dtype="float32" ) matrix = cv2.getPerspectiveTransform(quad, target) crops.append( - cv2.warpPerspective( - image, matrix, (OUT_WIDTH, OUT_HEIGHT), flags=cv2.INTER_CUBIC - ) + cv2.warpPerspective(image, matrix, (width, height), flags=cv2.INTER_CUBIC) ) 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: """Ohne Fund: das ganze Bild als eine Karte behandeln.""" height, width = image.shape[:2] diff --git a/docker-compose.yml b/docker-compose.yml index 70cc68a..9f08801 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,11 @@ services: restart: unless-stopped # Wird im Workflow aus dem Secret DOTENV erzeugt. 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: - business-card-scanner-data:/data networks: diff --git a/tests/test_segment.py b/tests/test_segment.py index 446739f..93e28d0 100644 --- a/tests/test_segment.py +++ b/tests/test_segment.py @@ -17,7 +17,16 @@ def test_zuschnitte_haben_kartenformat(): for crop in crops: 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():