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
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue