feat: add bounded crop evidence and keyed gallery cache

This commit is contained in:
kiyo9w 2026-09-28 15:49:34 +09:00
parent 69de9ea418
commit 0d0e9f82b9
8 changed files with 1065 additions and 41 deletions

View file

@ -20,6 +20,7 @@ from __future__ import annotations
import argparse
import asyncio
import base64
import binascii
from contextlib import asynccontextmanager
from dataclasses import dataclass
import json
@ -73,7 +74,7 @@ from engine import ( # noqa: E402
settings_from_env,
)
from record import FrameRecorder # noqa: E402
from live import LiveSession, LiveSettings # noqa: E402
from live import MAX_CROP_BYTES, LiveSession, LiveSettings # noqa: E402
from transport import ( # noqa: E402
BodyLimit,
ServiceAdvertiser,
@ -473,7 +474,16 @@ def create_app(
raise _error("BAD_REQUEST", str(exc), 400)
if tags:
result = await loop.run_in_executor(None, _attach_still_tags, result, image)
# One submit for the whole still-tag operation, on the same
# single-worker executor the live sessions read tags on. The default
# pool would let a photo's OCR run beside a live session's Vision
# work: pyobjc holds the GIL, so the two would interleave and both
# would slow down. `_attach_still_tags` calls `run_pass` itself
# (reconcile.attach_tags_to_boxes), never submits, so nothing here
# queues behind the worker it is running on.
result = await loop.run_in_executor(
engine.tag_executor, _attach_still_tags, result, image
)
return result
def _is_price_card(crop: Any) -> bool:
@ -485,6 +495,13 @@ def create_app(
digits and it prints the tax-excluded price (本体), while a product's are 3-19 %
digits. Reporting a price card as a product would put a box labelled with a
product name on a price tag, which is worse than not detecting it.
ponytail: every surviving detection pays one Vision read. There is no cheap
pre-filter to add, because a card is only distinguishable by its text - its
box is pack-shaped, so the aspect/position bounds in tags.py (measured for
real tag regions, 1.8-3.8:1) would exclude the boxes cards actually arrive
in. Re-measure boxed-card aspect from real photos before narrowing this; a
card that slips through is a product name printed on a price tag.
"""
ocr_engine = getattr(engine, "ocr_engine", None)
if ocr_engine is None or not getattr(ocr_engine, "available", lambda: False)():
@ -733,7 +750,10 @@ def create_app(
"imgsz": getattr(engine.settings, "imgsz", 640),
},
"config": session.config(),
"limits": {"max_frame_bytes": MAX_FRAME_BYTES},
"limits": {
"max_frame_bytes": MAX_FRAME_BYTES,
"max_crop_bytes": MAX_CROP_BYTES,
},
}
)
except BaseException:
@ -797,6 +817,50 @@ def create_app(
payload.update(result)
await send(payload)
async def handle_capabilities(message: Dict[str, Any]) -> None:
"""A client that can answer with a full-resolution crop says so here.
Until this arrives the session keeps the protocol-v1 behaviour: no
result carries a crop request, so a v1 client is never asked.
"""
try:
capabilities = session.enable_crops(message.get("max_crop_bytes"))
except (TypeError, ValueError):
await send({"type": "error", "code": "BAD_MESSAGE",
"message": "bad max_crop_bytes"})
return
await send({"type": "capabilities_ack", "capabilities": capabilities})
async def handle_crop(message: Dict[str, Any]) -> None:
"""Score a crop the client returned for a track we asked about.
base64 travels in a text control message, so the binary frame path
(4-byte id + JPEG) is untouched and the frame id space is unchanged.
"""
request_id = message.get("request_id")
encoded = message.get("jpeg")
if not isinstance(encoded, str) or not encoded:
await send({"type": "crop_error", "request_id": request_id,
"code": "CROP_BAD_MESSAGE", "message": "jpeg is required"})
return
# Base64 expands ~4/3. Refuse on the wire length before decoding it,
# the same way the REST frame route does.
if len(encoded) > (MAX_CROP_BYTES * 4) // 3 + 8:
await send({"type": "crop_error", "request_id": request_id,
"code": "CROP_TOO_LARGE", "message": "crop over the limit"})
return
try:
crop_bytes = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError):
await send({"type": "crop_error", "request_id": request_id,
"code": "CROP_BAD_MESSAGE", "message": "jpeg is not valid base64"})
return
reply = await loop.run_in_executor(
None, session.submit_crop, request_id, message.get("track_id"),
message.get("frame_id"), crop_bytes,
)
await send(reply)
processing = asyncio.create_task(processing_loop())
authed = not settings.token or bearer_matches(
@ -835,6 +899,10 @@ def create_app(
await send({"type": "pong", "t": message.get("t")})
elif kind == "inspect":
await handle_inspect(message)
elif kind == "capabilities":
await handle_capabilities(message)
elif kind == "crop":
await handle_crop(message)
else:
await send({"type": "error", "code": "BAD_MESSAGE", "message": f"unknown type '{kind}'"})
return True