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:
parent
99bce4d7bf
commit
e67ab8bfb4
5 changed files with 287 additions and 149 deletions
162
recognization/code/demo_replay.py
Normal file
162
recognization/code/demo_replay.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue