feat: add bounded crop evidence and keyed gallery cache
This commit is contained in:
parent
69de9ea418
commit
0d0e9f82b9
8 changed files with 1065 additions and 41 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue