feat: run the tag pass in parallel with recognition, in live and still modes

The engine owns the OCR backend, a shared OCR lock and a dedicated tag worker, so
a tag pass can never occupy a classification worker or slow a frame. A live
session plans rail bands on the frame loop (cheap), runs them on that worker, and
attaches whatever the cache already knows; a still runs one synchronous pass.
Tracks and detections now carry price_tag and reconciliation, and the summary
carries price_tags plus compliance. Config can turn tags off per session.

Compliance now fails closed on evidence quality. The first real run produced 106
"missing tag" violations on a shelf where only 8 tags were readable - our tag
*detection* was incomplete, not the shelf. A row is only judged when at least
half its facings carry tag evidence; otherwise it reports "tags detected for 14
of 91 facings in this row" as a hint. Missing-tag findings are one per row with a
count, not one per facing, and price_mismatch still reports every disagreement.
On add/image6.jpg the result is now 7 real price mismatches and no false
violations.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kiyo9w 2026-09-24 13:58:18 +09:00
parent 71b8bdd876
commit a27841afa4
6 changed files with 407 additions and 27 deletions

View file

@ -95,6 +95,7 @@ class ServerSettings:
max_sessions: int = DEFAULT_MAX_SESSIONS
live_method: str = "hybrid"
live_conf: float = 0.1
live_tags: bool = True
log_level: str = "info"
ssl_certfile: Optional[str] = None
ssl_keyfile: Optional[str] = None
@ -110,6 +111,7 @@ def server_settings_from_env() -> ServerSettings:
max_sessions=int(os.environ.get("DORAYAKI_MAX_SESSIONS", base.max_sessions)),
live_method=os.environ.get("DORAYAKI_LIVE_METHOD", base.live_method),
live_conf=float(os.environ.get("DORAYAKI_LIVE_CONF", base.live_conf)),
live_tags=os.environ.get("DORAYAKI_LIVE_TAGS", "1").lower() not in ("0", "false", "no"),
log_level=os.environ.get("DORAYAKI_LOG_LEVEL", base.log_level),
ssl_certfile=os.environ.get("DORAYAKI_SSL_CERTFILE") or None,
ssl_keyfile=os.environ.get("DORAYAKI_SSL_KEYFILE") or None,
@ -315,6 +317,7 @@ def create_app(
method: Optional[str] = None,
conf: Optional[float] = None,
empty_slots: bool = True,
tags: bool = False,
) -> Dict[str, Any]:
if method is not None:
_require_method(method)
@ -338,7 +341,7 @@ def create_app(
except UndecodableImage as exc:
raise _error("UNDECODABLE_IMAGE", str(exc), 422)
try:
return engine.recognize(
result = engine.recognize(
image,
classify=classify,
method=method,
@ -348,6 +351,49 @@ def create_app(
except UnknownMethod as exc:
raise _error("BAD_REQUEST", str(exc), 400)
if tags:
result = _attach_still_tags(result, image)
return result
def _attach_still_tags(result: Dict[str, Any], image: Any) -> Dict[str, Any]:
"""Run one synchronous tag pass over a still and merge its evidence."""
ocr_engine = getattr(engine, "ocr_engine", None)
if ocr_engine is None or not getattr(ocr_engine, "available", lambda: False)():
result["price_tags"] = {"readable": 0, "unreadable": 0, "absent": 0}
result["compliance"] = {"violations": 0, "by_rule": {}, "rows": [], "hints": []}
result["ocr_engine"] = "none"
return result
from reconcile import attach_tags_to_boxes
from tags import TagService, TagSettings
boxes = [
{"id": detection["id"], "box": detection["box"]} for detection in result["detections"]
]
identities = {
int(detection["id"]): detection["identity"] for detection in result["detections"]
}
catalog_by_sku = {product["sku_id"]: product for product in engine.catalog()}
service = TagService(ocr_engine, settings=TagSettings())
started = time.perf_counter()
bundle = attach_tags_to_boxes(service, image, boxes, catalog_by_sku, identities)
elapsed_ms = (time.perf_counter() - started) * 1000.0
for detection in result["detections"]:
box_id = int(detection["id"])
tag = bundle["by_box"].get(box_id)
if tag is not None:
detection["price_tag"] = tag
reconciliation = bundle["reconciliations"].get(box_id)
if reconciliation is not None:
detection["reconciliation"] = reconciliation
result["price_tags"] = bundle["counts"]
result["compliance"] = bundle["compliance"]
result["ocr_engine"] = ocr_engine.name
result["tag_timing_ms"] = round(elapsed_ms, 1)
return result
@app.post("/v1/images/{image_id}/classify", dependencies=[Depends(authorize)])
async def classify_image(image_id: str, body: ClassifyBody) -> Dict[str, Any]:
method = _require_method(body.method)
@ -418,9 +464,12 @@ def create_app(
method=settings.live_method,
conf=settings.live_conf,
max_inflight_per_session=engine.settings.live_inflight,
tags=settings.live_tags,
),
)
session.refresh_catalog()
if settings.live_tags:
session.enable_tags()
sessions[session_id] = session
await websocket.accept()
send_lock = asyncio.Lock()
@ -506,6 +555,7 @@ def create_app(
method=message.get("method"),
conf=message.get("conf"),
empty_slots=message.get("empty_slots"),
tags=message.get("tags"),
)
except (TypeError, ValueError):
await send({"type": "error", "code": "BAD_MESSAGE", "message": "bad config values"})
@ -733,6 +783,7 @@ def main() -> None:
parser.add_argument("--classify-workers", type=int, default=None)
parser.add_argument("--live-method", type=str, default=None, choices=list(CLASSIFY_METHODS))
parser.add_argument("--live-conf", type=float, default=None)
parser.add_argument("--no-tags", action="store_true", help="disable price-tag OCR")
parser.add_argument("--max-sessions", type=int, default=None)
parser.add_argument("--ssl-certfile", type=str, default=None)
parser.add_argument("--ssl-keyfile", type=str, default=None)
@ -755,6 +806,8 @@ def main() -> None:
server.live_method = args.live_method
if args.live_conf is not None:
server.live_conf = args.live_conf
if args.no_tags:
server.live_tags = False
if args.ssl_certfile is not None:
server.ssl_certfile = args.ssl_certfile
if args.ssl_keyfile is not None: