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
|
import anthropic
|
||||||
|
|
||||||
from . import config
|
from . import config, imaging
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -123,11 +123,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 +138,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 +150,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))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
22
app/main.py
22
app/main.py
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user