product-recognition-demo/recognization/code/demo_replay.py
kiyo9w e67ab8bfb4 chore: the film-making shortcut leaves the server, and failures stop hiding
From the deep review's hygiene list:

- The demo replay (a still recognition replayed as if it were live tracking) moves
  out of app.py into demo_replay.py, and every payload it produces is marked
  `synthetic: true`. It had been committed inside an unrelated feature commit, which
  made a film-making device look like part of the server.
- `/v1/health` reports the OCR engine and whether it is available. Without it an
  operator could not tell a broken OCR from a shelf with no readable tags.
- A failed OCR pass is counted and logged instead of swallowed: `TagService.stats()`
  now carries `passes_failed` and `last_pass_error`.
- The live classification budget goes back to the measured production defaults
  (16 in flight, 2 attempts); a demo walk over a nearly static shelf asks for more
  through `--live-inflight` / `--live-attempts` rather than silently changing the
  default for everyone.

162 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 01:30:16 +09:00

162 lines
6.9 KiB
Python

"""
Demo-only: replay a shelf's recognition as if it were live tracking.
`DemoRecognition` recognises the whole shelf once, with the real model and the real
matcher, and then hands those results to the walk's frames as if the live session
had produced them. That is useful for filming and for exercising a client without a
camera, and it is *not* a perception result: the boxes do not come from the frame
being processed, and no tracking or classification happened on it. Every payload it
touches is marked `synthetic: true` so a client can tell.
It lives here rather than in `app.py` because it was committed inside an unrelated
feature once, which made a film-making device look like part of the server.
"""
from __future__ import annotations
import threading
from typing import Any, Dict, List, Optional
from demo import DemoSourceError
class DemoRecognition:
"""The demo shortcut.
A real walk recognises each facing as it appears, which on a shelf the
catalog only partly covers leaves most boxes unlabelled. For a demo the
shelf is recognised ONCE, in full, with the same model and matcher, and
those results are then handed to the walk's frames as if the live session
had just produced them - every box already green, with name and price.
Demo-only: enabled by --demo-walk.
"""
_STILL_TO_LIVE = {
# The still payload already speaks the live vocabulary for its states;
# the decision-level names are kept for older shapes.
"recognized": "recognized",
"needs_confirmation": "needs_confirmation",
"unrecognized": "unrecognized",
"accepted": "recognized",
"ambiguous": "needs_confirmation",
"unknown": "unrecognized",
"rejected": "unrecognized",
}
def __init__(self, engine: Any, camera: Any, samples: Dict[str, Any], tag_attacher: Any) -> None:
self._engine = engine
self._camera = camera
self._samples = samples
self._tag_attacher = tag_attacher
self._catalog_by_sku = {product["sku_id"]: product for product in engine.catalog()}
self._cache: Dict[str, Any] = {}
self._lock = threading.Lock()
def steps(self, sample_id: str) -> int:
return self._camera.plan([sample_id], (1280, 720)).get("steps", 0)
def recognition(self, sample_id: str) -> Any:
cached = self._cache.get(sample_id)
if cached is not None:
return cached
import cv2
path = self._samples[sample_id]["path"]
image = cv2.imread(path)
if image is None:
raise DemoSourceError(f"cannot read sample '{sample_id}'")
result = self._engine.recognize(image, classify=True, method="hybrid", with_empty_slots=True)
if self._tag_attacher is not None:
result = self._tag_attacher(result, image)
with self._lock:
self._cache[sample_id] = (result, int(image.shape[1]), int(image.shape[0]))
return self._cache[sample_id]
def warm(self, sample_id: str) -> None:
self.recognition(sample_id)
def apply(self, result: Dict[str, Any], sample_id: str, step: int) -> Dict[str, Any]:
"""Swap the live tracks for the shelf's recognised ones, in frame space."""
still, _, _ = self.recognition(sample_id)
position = self._camera.window_at([sample_id], step)
ox, oy = position["x"], position["y"]
win_w, win_h = position["width"], position["height"]
tracks = []
for index, detection in enumerate(still.get("detections") or []):
box = [int(v) for v in detection.get("box") or []]
if len(box) != 4:
continue
x1, y1, x2, y2 = box
if x2 - ox <= 0 or y2 - oy <= 0 or x1 - ox >= win_w or y1 - oy >= win_h:
continue
frame_box = [x1 - ox, y1 - oy, x2 - ox, y2 - oy]
identity = detection.get("identity") or {}
state = self._STILL_TO_LIVE.get(identity.get("state") or "", "unrecognized")
sku_id = identity.get("sku_id")
product = self._catalog_by_sku.get(sku_id or "") if sku_id else None
if state == "recognized" and product is None:
state = "unrecognized"
payload: Dict[str, Any] = {
"track_id": index + 1,
"box": frame_box,
"raw_box": frame_box,
"confidence": round(float(detection.get("confidence") or 0.0), 4),
"first_seen_seq": step,
"relabels": 0,
"restarts": 0,
"inherited": False,
"identity": {
"state": state,
"sku_id": sku_id if state == "recognized" else None,
"identity_level": identity.get("identity_level"),
"confidence": identity.get("confidence"),
"score": identity.get("score"),
"candidates": list(identity.get("candidates") or []),
"attempts": 1,
"method": "hybrid",
},
}
if product is not None and state == "recognized":
payload["product"] = {
"sku_id": product.get("sku_id"),
"product_name": product.get("product_name"),
"price": product.get("price"),
}
tracks.append(payload)
slots = []
for slot in still.get("empty_slots") or []:
box = [int(v) for v in slot.get("box") or []]
if len(box) != 4:
continue
x1, y1, x2, y2 = box
if x2 - ox <= 0 or y2 - oy <= 0 or x1 - ox >= win_w or y1 - oy >= win_h:
continue
entry = dict(slot)
entry["box"] = [x1 - ox, y1 - oy, x2 - ox, y2 - oy]
slots.append(entry)
result["tracks"] = tracks
result["empty_slots"] = slots
counts: Dict[str, int] = {}
for track in tracks:
counts[track["identity"]["state"]] = counts.get(track["identity"]["state"], 0) + 1
result["summary"] = {
"in_view": len(tracks),
"recognized": counts.get("recognized", 0),
"needs_confirmation": counts.get("needs_confirmation", 0),
"unrecognized": counts.get("unrecognized", 0),
"detected": 0,
"unconfirmed": counts.get("unconfirmed", 0),
"identifying": 0,
"empty_slots": len(slots),
"price_tags": (still.get("price_tags") or {"readable": 0, "unreadable": 0}),
"reconciliation": (still.get("reconciliation") or {}),
"compliance": (still.get("compliance") or {"violations": 0, "rows": [], "hints": []}),
}
# A client must be able to tell these boxes did not come from the frame it
# just sent.
result["synthetic"] = True
result["demo"] = {"sample": sample_id, "step": step, "bay": position["bay_index"] + 1}
return result