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>
This commit is contained in:
kiyo9w 2026-09-25 01:30:16 +09:00
parent 99bce4d7bf
commit e67ab8bfb4
5 changed files with 287 additions and 149 deletions

View file

@ -272,6 +272,12 @@ def create_app(
"warm": True,
},
"catalog": {"version": engine.catalog_version(), "sku_count": engine.sku_count()},
"ocr": {
# Without this an operator could not tell a broken OCR from a shelf
# with no readable tags.
"engine": getattr(engine.ocr_engine, "name", "none"),
"available": bool(getattr(engine.ocr_engine, "available", lambda: False)()),
},
"sessions_active": len(sessions),
"uptime_s": round(engine.uptime_s(), 1),
}
@ -312,144 +318,12 @@ def create_app(
return {"samples": listing}
# ---------------------------------------------------------------- demo camera
class DemoRecognition:
"""The demo shortcut.
# The demo replay lives in its own module: it replaces the live tracks with a
# still recognition mapped into each walk frame, which is a film-making device,
# not a perception result. Every payload it produces is marked synthetic.
demo_recognition = None
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) -> None:
self._cache: Dict[str, Any] = {}
self._lock = threading.Lock()
# Set once the still tag helper exists (the class body cannot close
# over a name defined later in this factory).
self.tag_attacher: Optional[Any] = None
self.catalog_by_sku: Dict[str, Any] = {}
def steps(self, sample_id: str) -> int:
return demo_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 = samples[sample_id]["path"]
image = cv2.imread(path)
if image is None:
raise DemoSourceError(f"cannot read sample '{sample_id}'")
result = 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 = demo_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": []}),
}
result["demo"] = {"sample": sample_id, "step": step, "bay": position["bay_index"] + 1}
return result
demo_recognition = DemoRecognition() if settings.demo_walk else None
def _demo_samples(raw: Optional[str]) -> List[str]:
if raw:
@ -465,8 +339,10 @@ def create_app(
return [preferred]
return [next(iter(samples))] if samples else []
# Which sample the walk uses, resolved once. The replay object itself is built
# later, because it needs the still tag pass.
demo_sample_ids: List[str] = []
if demo_recognition is not None:
if settings.demo_walk:
try:
demo_sample_ids = _demo_samples(None)
demo_camera.warm(demo_sample_ids)
@ -662,14 +538,14 @@ def create_app(
engine.image_store.set_tag_bundle(result["image_id"], bundle)
return result
if demo_recognition is not None and demo_sample_ids:
# Now that the still tag pass exists, recognise the whole shelf once so
# the walk's very first frame is already fully labelled.
if settings.demo_walk and demo_sample_ids:
# Built here because the replay needs the still tag pass, and it recognises
# the whole shelf once up front so the walk's first frame is already
# labelled. Every payload it produces is marked synthetic.
try:
demo_recognition.tag_attacher = _attach_still_tags
demo_recognition.catalog_by_sku = {
product["sku_id"]: product for product in engine.catalog()
}
from demo_replay import DemoRecognition
demo_recognition = DemoRecognition(engine, demo_camera, samples, _attach_still_tags)
demo_recognition.warm(demo_sample_ids[0])
LOGGER.info("demo walk: '%s' recognised up front", demo_sample_ids[0])
except Exception as exc:
@ -767,6 +643,7 @@ def create_app(
method=settings.live_method,
conf=settings.live_conf,
max_inflight_per_session=engine.settings.live_inflight,
max_attempts=engine.settings.live_attempts,
tags=settings.live_tags,
),
)
@ -1099,6 +976,10 @@ 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("--live-inflight", type=int, default=None,
help="classifications in flight per session (demo walks want a larger budget)")
parser.add_argument("--live-attempts", type=int, default=None,
help="classification attempts per track")
parser.add_argument("--demo-walk", action="store_true",
help="serve the demo walk's pre-recognised shelf as live tracks")
parser.add_argument("--no-tags", action="store_true", help="disable price-tag OCR")
@ -1136,6 +1017,10 @@ def main() -> None:
server.log_level = args.log_level
engine_settings = settings_from_env()
if args.live_inflight is not None:
engine_settings.live_inflight = args.live_inflight
if args.live_attempts is not None:
engine_settings.live_attempts = args.live_attempts
if args.device is not None:
engine_settings.device = args.device
if args.imgsz is not None: