"""Replay a recorded walk through LiveSession and count what it actually decided. The recording format is FrameRecorder's: DIR//index.jsonl plus the JPEG bytes the client sent. Optional labels live in labels.json next to that index (or at --labels). A label is {"frame", "box", "sku_id"}. Template leakage is refused, not scored. A label whose crop matches a catalog template (byte-identical, or IoU >= 0.98 against a same-sized template pasted into the frame) is excluded and counted under leakage. Those crops are how the store was enrolled; scoring them is training-set accuracy. python code/eval_live.py RECORDING_DIR [--stock-dir DIR] [--labels PATH] [--json PATH] [--iou 0.5] [--replay] [--identity matcher|local|gallery|van] [--device cpu|mps|cuda] Prints a human count table and writes the same report as JSON (default /eval_live.json). Missing input exits 2 with {"error": ...} on stdout. A recording that names identity_backend is scored as that backend unless --identity repeats it. A recording that does not name one requires --identity; omitting it does not score the matcher. --identity gallery uses the same LiveSession path as the server and exits 2 when that gallery cannot load; it does not score the matcher instead. The report names the backend, whether the head loaded, and the weight pins. The same pinned recording, catalog, weights, and labels produce the same counts; timings are omitted because they are not. No held-out labels means denominators and fingerprints only. That is not a walk accuracy number. """ from __future__ import annotations import argparse import hashlib import json import os import sys from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple current_dir = os.path.dirname(os.path.abspath(__file__)) if current_dir not in sys.path: sys.path.insert(0, current_dir) LEVELS = ("accepted", "ambiguous", "rejected", "unknown", "unsettled") TAG_STATES = ("readable", "unreadable", "absent", "detected") class EvalInputError(ValueError): """The recording, labels, or catalog cannot be scored as given.""" def file_sha256(path: str) -> Optional[str]: digest = hashlib.sha256() try: with open(path, "rb") as handle: for chunk in iter(lambda: handle.read(1 << 20), b""): digest.update(chunk) except OSError: return None return digest.hexdigest() def content_sha256(payload: bytes) -> str: return hashlib.sha256(payload).hexdigest() # Every backend the server can be started with, so a recording made by the # shipped `run-demo-server.sh` (--identity local) can be replayed. `local` is # the default; `matcher` is Van's scorer kept as the E-1 baseline lane. IDENTITY_BACKENDS = ("matcher", "local", "gallery", "van") def _iou(a: Sequence[float], b: Sequence[float]) -> float: if len(a) != 4 or len(b) != 4: return 0.0 ax1, ay1, ax2, ay2 = (float(v) for v in a) bx1, by1, bx2, by2 = (float(v) for v in b) ix1, iy1 = max(ax1, bx1), max(ay1, by1) ix2, iy2 = min(ax2, bx2), min(ay2, by2) inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1) if inter <= 0.0: return 0.0 area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1) area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1) union = area_a + area_b - inter return inter / union if union > 0.0 else 0.0 def load_index(recording_dir: str) -> List[Dict[str, Any]]: path = os.path.join(recording_dir, "index.jsonl") if not os.path.isfile(path): raise EvalInputError(f"missing recording index: {path}") rows: List[Dict[str, Any]] = [] with open(path, "r", encoding="utf-8") as handle: for number, line in enumerate(handle, start=1): line = line.strip() if not line: continue try: entry = json.loads(line) except json.JSONDecodeError as exc: raise EvalInputError(f"{path}:{number}: {exc.msg}") from exc if not isinstance(entry, dict) or "frame" not in entry or "path" not in entry: raise EvalInputError(f"{path}:{number}: line needs frame and path") rows.append(entry) if not rows: raise EvalInputError(f"empty recording index: {path}") return rows def recorded_identity(rows: Sequence[Dict[str, Any]]) -> Optional[str]: """Backend named by the recording, when every present value agrees. FrameRecorder writes identity_backend on each line when the server constructed it with the active backend. Older indexes may omit it, or name it once under identity.backend. Silence is not a backend. A mix is not a default. """ found: List[str] = [] for row in rows: raw = row.get("identity_backend") if raw is None and isinstance(row.get("identity"), dict): raw = row["identity"].get("backend") if raw is None: continue name = str(raw).strip().lower() if name not in IDENTITY_BACKENDS: raise EvalInputError( f"recorded identity backend is not one of {', '.join(IDENTITY_BACKENDS)}: {raw}" ) found.append(name) unique = set(found) if len(unique) > 1: raise EvalInputError("recording names more than one identity backend") return found[0] if found else None def resolve_identity(requested: Optional[str], recorded: Optional[str]) -> str: """CLI and recording must name the same backend. Silence is not matcher.""" chosen = (requested or recorded or "").strip().lower() if not chosen: raise EvalInputError( "recording does not name an identity backend; pass --identity " + " or --identity ".join(IDENTITY_BACKENDS) ) if chosen not in IDENTITY_BACKENDS: raise EvalInputError( f"identity must be one of {', '.join(IDENTITY_BACKENDS)}, not {requested or recorded}" ) if recorded and requested and recorded != chosen: raise EvalInputError( f"recording identity is {recorded}; pass --identity {recorded}" ) return chosen def recording_fingerprint(recording_dir: str, rows: Sequence[Dict[str, Any]]) -> Dict[str, Any]: """Hash the JPEGs the client sent, in index order. Missing files are an error.""" digest = hashlib.sha256() missing: List[str] = [] for row in rows: name = str(row["path"]) path = os.path.join(recording_dir, name) payload_hash = file_sha256(path) if payload_hash is None: missing.append(name) continue digest.update(f"{row['frame']}:{name}:{payload_hash}\n".encode("utf-8")) if missing: raise EvalInputError("missing recorded frames: " + ", ".join(missing[:8])) return {"sha256": digest.hexdigest(), "frames": len(rows)} def load_labels(path: Optional[str]) -> List[Dict[str, Any]]: if not path: return [] if not os.path.isfile(path): raise EvalInputError(f"missing labels: {path}") try: with open(path, "r", encoding="utf-8") as handle: payload = json.loads(handle.read()) except json.JSONDecodeError as exc: raise EvalInputError(f"labels are not JSON: {exc.msg}") from exc rows = payload.get("labels") if isinstance(payload, dict) else payload if not isinstance(rows, list): raise EvalInputError("labels must be a list or {\"labels\": [...]}") cleaned: List[Dict[str, Any]] = [] for index, row in enumerate(rows): if not isinstance(row, dict): raise EvalInputError(f"labels[{index}] is not an object") if "frame" not in row or "sku_id" not in row or "box" not in row: raise EvalInputError(f"labels[{index}] needs frame, box, sku_id") box = row["box"] if not isinstance(box, (list, tuple)) or len(box) != 4: raise EvalInputError(f"labels[{index}].box must be [x1, y1, x2, y2]") cleaned.append( { "frame": int(row["frame"]), "box": [int(v) for v in box], "sku_id": str(row["sku_id"]), "source": row.get("source"), } ) return cleaned def catalog_fingerprint(stock_dir: str, stock_csv: str = "stock.csv") -> Dict[str, Any]: """Same inputs as RecognitionEngine.catalog_version, plus the full digest and head.""" digest = hashlib.sha256() csv_path = os.path.join(stock_dir, stock_csv) csv_hash = file_sha256(csv_path) if csv_hash is None: digest.update(b"missing-csv") else: digest.update(bytes.fromhex(csv_hash)) try: entries = sorted( (name, os.path.getsize(os.path.join(stock_dir, name))) for name in os.listdir(stock_dir) if name.lower().endswith((".jpeg", ".jpg", ".png")) ) except OSError as exc: raise EvalInputError(f"cannot read catalog {stock_dir}: {exc.strerror}") from exc for name, size in entries: digest.update(f"{name}:{size}".encode("utf-8")) full = digest.hexdigest() head_path = os.path.join(stock_dir, "identity_head.pt") return { "stock_dir": os.path.basename(os.path.abspath(stock_dir)), "version": full[:12], "sha256": full, "templates": len(entries), "csv_sha256": csv_hash, "identity_head_sha256": file_sha256(head_path), } def model_fingerprint( weights_path: str, *, identity: str, gallery: Any = None, ) -> Dict[str, Any]: """YOLO pin plus, for gallery, the backbone and head that actually loaded.""" try: import ultralytics library = f"ultralytics-{ultralytics.__version__}" except Exception: library = "ultralytics-unknown" pin: Dict[str, Any] = { "version": f"{os.path.basename(weights_path)}@{library}" if weights_path else f"none@{library}", "weights_sha256": file_sha256(weights_path) if weights_path else None, "identity": identity, } if identity != "gallery" or gallery is None: if identity == "gallery": pin["gallery_loaded"] = False pin["gallery_head_loaded"] = False return pin from gallery import DINOV2_SHA256, dinov2_weights_path backbone = dinov2_weights_path() digest = file_sha256(backbone) pin.update( { "gallery_loaded": True, "gallery_head": getattr(gallery, "head_reason", None), "gallery_head_loaded": getattr(gallery, "_head", None) is not None, "gallery_views": gallery.view_count() if hasattr(gallery, "view_count") else None, "dinov2_path": backbone, "dinov2_sha256": digest, "dinov2_sha256_expected": DINOV2_SHA256, "dinov2_pinned": digest == DINOV2_SHA256, } ) return pin def _decode_image(payload: bytes) -> Any: import cv2 import numpy as np image = cv2.imdecode(np.frombuffer(payload, dtype=np.uint8), cv2.IMREAD_COLOR) if image is None: raise EvalInputError("recorded frame is not a decodable image") return image def template_crops(stock_dir: str) -> List[Tuple[str, Any]]: """Catalog photos keyed by SKU stem (sku_l05, sku_l05_1, ...).""" import cv2 crops: List[Tuple[str, Any]] = [] try: names = sorted(os.listdir(stock_dir)) except OSError: return crops for name in names: stem, extension = os.path.splitext(name) if extension.lower() not in (".jpeg", ".jpg", ".png"): continue image = cv2.imread(os.path.join(stock_dir, name)) if image is None or image.size == 0: continue crops.append((stem, image)) return crops def _same_picture(crop: Any, template: Any) -> bool: """True when two same-sized images are the same photo after JPEG.""" import numpy as np left = crop.astype("float32").reshape(-1) right = template.astype("float32").reshape(-1) if left.size < 16 or left.size != right.size: return False left = left - float(left.mean()) right = right - float(right.mean()) denom = float(np.sqrt((left * left).sum() * (right * right).sum())) if denom <= 1e-6: return bool(np.max(np.abs(crop.astype("int16") - template.astype("int16"))) <= 2) return float((left * right).sum() / denom) >= 0.98 def leaked_labels( recording_dir: str, rows: Sequence[Dict[str, Any]], labels: Sequence[Dict[str, Any]], stock_dir: str, ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: """Drop labels whose crop is a catalog template. Byte identity catches a frame that is the template file. Geometric identity catches a template pasted into a larger frame at the labelled box. """ if not labels: return [], [] templates = template_crops(stock_dir) template_hashes = {} try: names = os.listdir(stock_dir) except OSError: names = [] for name in names: digest = file_sha256(os.path.join(stock_dir, name)) if digest: template_hashes.setdefault(digest, os.path.splitext(name)[0]) by_frame: Dict[int, List[Dict[str, Any]]] = {} for label in labels: by_frame.setdefault(int(label["frame"]), []).append(label) held: List[Dict[str, Any]] = [] leaked: List[Dict[str, Any]] = [] frames = {int(row["frame"]): row for row in rows} for frame_id, frame_labels in by_frame.items(): row = frames.get(frame_id) image = None payload_hash = None if row is not None: path = os.path.join(recording_dir, str(row["path"])) try: with open(path, "rb") as handle: payload = handle.read() except OSError: payload = b"" payload_hash = content_sha256(payload) if payload else None if payload: image = _decode_image(payload) for label in frame_labels: reason = None matched = None if payload_hash in template_hashes: reason = "frame_is_template" matched = template_hashes[payload_hash] elif image is not None: x1, y1, x2, y2 = label["box"] crop = image[max(0, y1) : max(0, y2), max(0, x1) : max(0, x2)] if crop.size: import cv2 ch, cw = crop.shape[:2] for stem, template in templates: th, tw = template.shape[:2] if abs(th - ch) > 1 or abs(tw - cw) > 1: continue resized = template if (th, tw) == (ch, cw) else cv2.resize(template, (cw, ch)) # JPEG re-encoding keeps layout and drops exact pixels. if _same_picture(crop, resized): reason = "crop_is_template" matched = stem break if reason: leaked.append({**label, "leak": reason, "template": matched}) else: held.append(label) return held, leaked def identity_level(track: Dict[str, Any]) -> str: identity = track.get("identity") or {} level = identity.get("identity_level") if level in LEVELS: return str(level) state = identity.get("state") if state == "recognized": return "accepted" if state == "needs_confirmation": return "ambiguous" if state == "unrecognized": return "rejected" return "unsettled" def tag_state(track: Dict[str, Any]) -> str: tag = track.get("price_tag") or track.get("tag") or {} state = tag.get("state") if state in TAG_STATES: return str(state) if tag: return "detected" return "absent" def score_tracks(tracks: Iterable[Dict[str, Any]]) -> Dict[str, Any]: """Denominators over settled decisions. Unsettled tracks are not in the rate.""" counts = {level: 0 for level in LEVELS} tags = {state: 0 for state in TAG_STATES} for track in tracks: counts[identity_level(track)] += 1 tags[tag_state(track)] += 1 settled = counts["accepted"] + counts["ambiguous"] + counts["rejected"] + counts["unknown"] return { "tracks": sum(counts.values()), "settled": settled, "accepted": counts["accepted"], "ambiguous": counts["ambiguous"], "rejected": counts["rejected"], "unknown": counts["unknown"], "unsettled": counts["unsettled"], "accepted_rate": _rate(counts["accepted"], settled), "ambiguous_rate": _rate(counts["ambiguous"], settled), "rejected_rate": _rate(counts["rejected"], settled), "tags_detected": tags["detected"] + tags["readable"] + tags["unreadable"], "tags_readable": tags["readable"], "tags_unreadable": tags["unreadable"], "tags_absent": tags["absent"], } def _rate(numerator: int, denominator: int) -> Optional[float]: if denominator <= 0: return None return round(numerator / denominator, 4) def track_churn(frames: Sequence[Dict[str, Any]]) -> Dict[str, Any]: """How often one track id changes the SKU it is willing to name. A change counts only after the id has already named a SKU. Restarting or dropping the label (relabels/restarts) is reported beside that, from the counters LiveSession already publishes. """ previous: Dict[int, Optional[str]] = {} changes: Dict[int, int] = {} seen: Dict[int, int] = {} relabels: Dict[int, int] = {} restarts: Dict[int, int] = {} for frame in frames: for track in frame.get("tracks") or []: track_id = track.get("track_id") if track_id is None: continue track_id = int(track_id) seen[track_id] = seen.get(track_id, 0) + 1 relabels[track_id] = max(relabels.get(track_id, 0), int(track.get("relabels") or 0)) restarts[track_id] = max(restarts.get(track_id, 0), int(track.get("restarts") or 0)) named = None if identity_level(track) == "accepted": named = (track.get("identity") or {}).get("sku_id") prior = previous.get(track_id) if prior and named and named != prior: changes[track_id] = changes.get(track_id, 0) + 1 if named: previous[track_id] = named churned = sum(1 for count in changes.values() if count) return { "tracks": len(seen), "tracks_with_sku_change": churned, "sku_changes": sum(changes.values()), "relabels": sum(relabels.values()), "restarts": sum(restarts.values()), "per_track": [ { "track_id": track_id, "observations": seen[track_id], "sku_changes": changes.get(track_id, 0), "relabels": relabels.get(track_id, 0), "restarts": restarts.get(track_id, 0), } for track_id in sorted(seen) if changes.get(track_id, 0) or relabels.get(track_id, 0) or restarts.get(track_id, 0) ], } def labelled_top1( frames: Sequence[Dict[str, Any]], labels: Sequence[Dict[str, Any]], *, iou_min: float, ) -> Dict[str, Any]: """Top-1 over held-out labels. Accepted wrong SKU is a miss, not a reject. Denominator is labels that overlap a track. A label with no overlapping track is unmatched and stays out of the top-1 rate. """ by_frame: Dict[int, List[Dict[str, Any]]] = {} for frame in frames: by_frame.setdefault(int(frame.get("frame", frame.get("frame_id"))), []).append(frame) correct = wrong = rejected = ambiguous = missed = 0 matched = 0 for label in labels: observations = by_frame.get(int(label["frame"]), []) best_iou = 0.0 best: Optional[Dict[str, Any]] = None for observation in observations: for track in observation.get("tracks") or []: score = _iou(label["box"], track.get("box") or []) if score > best_iou: best_iou = score best = track if best is None or best_iou < iou_min: missed += 1 continue matched += 1 level = identity_level(best) guess = (best.get("identity") or {}).get("sku_id") if level == "accepted" and guess == label["sku_id"]: correct += 1 elif level == "accepted": wrong += 1 elif level == "ambiguous": ambiguous += 1 else: rejected += 1 return { "labels": len(labels), "matched": matched, "unmatched": missed, "correct": correct, "wrong": wrong, "rejected": rejected, "ambiguous": ambiguous, "top1": _rate(correct, matched), "iou_min": iou_min, } def score_recording( recording_dir: str, *, stock_dir: str, weights_path: str = "", labels_path: Optional[str] = None, iou_min: float = 0.5, live_frames: Optional[Sequence[Dict[str, Any]]] = None, identity: str, gallery: Any = None, ) -> Dict[str, Any]: """Score one session directory. live_frames, when given, are the replayed results. Without live_frames the recorded index is what the server already decided. That still produces denominators; it is not a fresh model run. identity is the backend those frames were named with, not a claim that this process loaded it. gallery, when passed, is the loaded ProductGallery. """ recording_dir = os.path.abspath(recording_dir) rows = load_index(recording_dir) sample = recording_fingerprint(recording_dir, rows) labels_file = labels_path or os.path.join(recording_dir, "labels.json") labels = load_labels(labels_file if os.path.isfile(labels_file) or labels_path else None) held, leaked = leaked_labels(recording_dir, rows, labels, stock_dir) source = list(live_frames) if live_frames is not None else rows tracks = [track for frame in source for track in (frame.get("tracks") or [])] decisions = score_tracks(tracks) backend = identity.strip().lower() if backend not in IDENTITY_BACKENDS: raise EvalInputError( f"identity must be one of {', '.join(IDENTITY_BACKENDS)}, not {identity}" ) report = { "recording": recording_dir, "source": "live_replay" if live_frames is not None else "recorded_index", "identity": { "backend": backend, "gallery_loaded": gallery is not None if backend == "gallery" else None, "gallery_head": getattr(gallery, "head_reason", None) if gallery is not None else None, "gallery_head_loaded": ( bool(getattr(gallery, "_head", None) is not None) if gallery is not None else None ), }, "fingerprints": { "sample": sample, "model": model_fingerprint(weights_path, identity=backend, gallery=gallery), "catalog": catalog_fingerprint(stock_dir), }, "decisions": decisions, "tags": { "detected": decisions["tags_detected"], "readable": decisions["tags_readable"], "unreadable": decisions["tags_unreadable"], "absent": decisions["tags_absent"], }, "churn": track_churn(source), "leakage": {"excluded": len(leaked), "labels": leaked}, "labelled_top1": labelled_top1(source, held, iou_min=iou_min), "evidence": _evidence(labels, held, leaked, live_frames is not None), } return report def _evidence( labels: Sequence[Dict[str, Any]], held: Sequence[Dict[str, Any]], leaked: Sequence[Dict[str, Any]], replayed: bool, ) -> List[str]: gaps: List[str] = [] if not replayed: gaps.append("counts are from the recorded index, not a fresh LiveSession replay") if not labels: gaps.append("no held-out labels; labelled top-1 is not a walk accuracy") elif not held: gaps.append("every label matched a catalog template and was excluded") if leaked: gaps.append(f"{len(leaked)} template-derived label(s) excluded") return gaps def replay_live( recording_dir: str, engine: Any, *, settings: Optional[Any] = None, ) -> List[Dict[str, Any]]: """Feed recorded JPEGs through one LiveSession, in index order. Classification is asynchronous. Each frame is scored only after its in-flight crops finish, then processed again so the label is on the payload. Without that wait the same recording does not replay twice. """ from live import LiveSession rows = load_index(recording_dir) session = LiveSession("eval", engine, settings=settings) session.refresh_catalog() if hasattr(session, "enable_tags"): session.enable_tags() frames: List[Dict[str, Any]] = [] for row in rows: path = os.path.join(recording_dir, str(row["path"])) with open(path, "rb") as handle: payload = handle.read() frame_id = int(row["frame"]) result = session.process_frame(frame_id, payload) # min_hits is 2, so the first pass only detects. Keep passing the same # bytes until a pass schedules nothing and everything in flight lands. for _ in range(4): _wait_until_settled(session) result = session.process_frame(frame_id, payload) if int((session.snapshot().get("stats") or {}).get("classify_pending") or 0) == 0: break result["frame"] = frame_id frames.append(result) engine_shutdown = getattr(engine, "shutdown", None) if callable(engine_shutdown): engine_shutdown() return frames def _wait_until_settled(session: Any, timeout: float = 30.0) -> None: import time deadline = time.monotonic() + timeout while time.monotonic() < deadline: pending = int((session.snapshot().get("stats") or {}).get("classify_pending") or 0) if pending == 0: return time.sleep(0.02) raise EvalInputError("classification did not finish before the replay timeout") def format_report(report: Dict[str, Any]) -> str: decisions = report["decisions"] tags = report["tags"] top1 = report["labelled_top1"] churn = report["churn"] fingerprints = report["fingerprints"] identity = report.get("identity") or {} model = fingerprints["model"] head = identity.get("gallery_head") head_note = f" head {head}" if head else "" lines = [ f"source: {report['source']}", f"identity: {identity.get('backend')}{head_note}", ( f"decisions: accepted {decisions['accepted']} ambiguous {decisions['ambiguous']} " f"rejected {decisions['rejected']} unknown {decisions['unknown']} " f"unsettled {decisions['unsettled']} settled {decisions['settled']}" ), ( f"rates: accepted {decisions['accepted_rate']} " f"ambiguous {decisions['ambiguous_rate']} rejected {decisions['rejected_rate']} " f"(denominator settled)" ), ( f"tags: detected {tags['detected']} readable {tags['readable']} " f"unreadable {tags['unreadable']} absent {tags['absent']}" ), ( f"churn: tracks {churn['tracks']} sku_changes {churn['sku_changes']} " f"relabels {churn['relabels']} restarts {churn['restarts']}" ), ( f"labelled top-1: {top1['correct']}/{top1['matched']} = {top1['top1']} " f"wrong {top1['wrong']} unmatched {top1['unmatched']} " f"leakage excluded {report['leakage']['excluded']}" ), ( f"fingerprints: sample {fingerprints['sample']['sha256'][:12]} " f"model {model['version']} " f"weights {str(model.get('weights_sha256') or 'none')[:12]} " f"catalog {fingerprints['catalog']['version']}" ), ] if model.get("identity") == "gallery": lines.append( f"gallery: loaded {model.get('gallery_loaded')} " f"head {model.get('gallery_head')} " f"dinov2 {str(model.get('dinov2_sha256') or 'missing')[:12]} " f"pinned {model.get('dinov2_pinned')}" ) for gap in report["evidence"]: lines.append(f"gap: {gap}") return "\n".join(lines) def replay_engine(stock_dir: str, weights: str, identity: str, device: Optional[str]) -> Tuple[Any, str, Any]: """Build the engine the server would, and refuse a gallery that did not load. The backbone file is checked before YOLO loads. A missing or unpinned file is the same refusal the server makes, without scoring the matcher. """ from engine import EngineSettings, build_default_engine if identity == "gallery": from gallery import DINOV2_SHA256, dinov2_weights_path backbone = dinov2_weights_path() digest = file_sha256(backbone) if digest is None: raise EvalInputError( f"gallery identity backend requires local DINOv2 weights at {backbone}; " "refusing to score the matcher" ) if digest != DINOV2_SHA256: raise EvalInputError( f"DINOv2 weights at {backbone} are sha256 {digest}, expected {DINOV2_SHA256}" ) settings = EngineSettings(stock_dir=stock_dir, identity_backend=identity) if weights: settings.weights_path = weights if device: settings.device = device engine = build_default_engine(settings) chosen = (engine.settings.identity_backend or "matcher").lower() if chosen != identity: engine.shutdown() raise EvalInputError(f"engine identity is {chosen}, requested {identity}") gallery = None if identity == "gallery": gallery = engine.identity_gallery() if gallery is None: engine.shutdown() raise EvalInputError( "gallery identity backend did not load; refusing to score the matcher" ) return engine, engine.settings.weights_path, gallery def main(argv: Optional[Sequence[str]] = None) -> int: parser = argparse.ArgumentParser(description="Score a recorded live walk.") parser.add_argument("recording", nargs="?", help="session directory with index.jsonl") parser.add_argument("--stock-dir", default="stock") parser.add_argument("--weights", default="") parser.add_argument("--labels", default=None) parser.add_argument("--json", dest="json_path", default=None) parser.add_argument("--iou", type=float, default=0.5) parser.add_argument( "--replay", action="store_true", help="re-run frames through LiveSession (needs the recognition stack)", ) parser.add_argument("--method", default="hybrid") parser.add_argument( "--identity", default=None, choices=list(IDENTITY_BACKENDS), help="which mechanism names products (required when the recording omits identity_backend)", ) parser.add_argument("--device", default=None, help="cpu | mps | cuda | cuda:0") args = parser.parse_args(argv) def fail(message: str) -> int: print(json.dumps({"error": message}, ensure_ascii=False)) print(f"error: {message}", file=sys.stderr) return 2 if not args.recording: return fail("recording directory is required") recording = os.path.abspath(args.recording) if not os.path.isdir(recording): return fail(f"recording directory not found: {recording}") try: rows = load_index(recording) identity = resolve_identity(args.identity, recorded_identity(rows)) live_frames = None gallery = None weights = args.weights if args.replay: from live import LiveSettings engine, weights, gallery = replay_engine( args.stock_dir, args.weights, identity, args.device ) live_frames = replay_live( recording, engine, settings=LiveSettings(method=args.method, tags=True) ) report = score_recording( recording, stock_dir=args.stock_dir, weights_path=weights, labels_path=args.labels, iou_min=args.iou, live_frames=live_frames, identity=identity, gallery=gallery, ) except EvalInputError as exc: return fail(str(exc)) text = json.dumps(report, ensure_ascii=False, indent=1, sort_keys=True) destination = args.json_path or os.path.join(recording, "eval_live.json") try: with open(destination, "w", encoding="utf-8") as handle: handle.write(text + "\n") except OSError as exc: return fail(f"cannot write {destination}: {exc.strerror}") print(format_report(report)) print(f"json: {destination}") return 0 if __name__ == "__main__": raise SystemExit(main())