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>
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
22
app/main.py
22
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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user