diff --git a/recognization/code/app.py b/recognization/code/app.py index 810c20b..ddfacfb 100644 --- a/recognization/code/app.py +++ b/recognization/code/app.py @@ -245,6 +245,7 @@ def create_app( scans = scans if scans is not None else build_legacy_scans(engine) samples = {item["id"]: item for item in get_available_shelf_images("add")} demo_camera = DemoCamera(samples) + demo_cameras: Dict[float, DemoCamera] = {} sessions: Dict[str, LiveSession] = {} sessions_lock = asyncio.Lock() app.state.engine = engine @@ -328,22 +329,34 @@ def create_app( raise _error("BAD_REQUEST", "window must be between 160x120 and 2048x2048", 400) return win_w, win_h + def _demo_camera(pan: Optional[float]) -> DemoCamera: + """One camera per pacing value (the walk geometry depends on it).""" + if pan is None: + return demo_camera + key = round(float(pan), 4) + camera = demo_cameras.get(key) + if camera is None: + camera = DemoCamera(samples, pan_overlap=key) + demo_cameras[key] = camera + return camera + @app.get("/v1/demo/plan", dependencies=[Depends(authorize)]) async def demo_plan(samples_param: Optional[str] = None, width: Optional[int] = None, - height: Optional[int] = None) -> Dict[str, Any]: + height: Optional[int] = None, pan: Optional[float] = None) -> Dict[str, Any]: """Geometry of the virtual walk (demo-only capture source).""" try: ids = _demo_samples(samples_param) if not ids: raise _error("NOT_FOUND", "no samples available", 404) - return {"status": "success", **demo_camera.plan(ids, _demo_window(width, height))} + camera = _demo_camera(pan) + return {"status": "success", **camera.plan(ids, _demo_window(width, height))} except DemoSourceError as exc: raise _error("NOT_FOUND", str(exc), 404) @app.get("/v1/demo/frame", dependencies=[Depends(authorize)]) async def demo_frame(step: int = 0, samples_param: Optional[str] = None, width: Optional[int] = None, height: Optional[int] = None, - quality: int = 65) -> Response: + quality: int = 65, pan: Optional[float] = None) -> Response: """One JPEG frame of the virtual walk: a store employee sweeping the shelf. Demo-only. The client advances `step` (it wraps), so the walk's speed @@ -353,7 +366,7 @@ def create_app( ids = _demo_samples(samples_param) if not ids: raise _error("NOT_FOUND", "no samples available", 404) - payload, position = demo_camera.frame( + payload, position = _demo_camera(pan).frame( ids, step, _demo_window(width, height), max(30, min(95, int(quality))) ) except DemoSourceError as exc: diff --git a/recognization/code/demo.py b/recognization/code/demo.py index 819e7f6..8613b21 100644 --- a/recognization/code/demo.py +++ b/recognization/code/demo.py @@ -56,9 +56,15 @@ class Bay: class DemoCamera: """Plans and renders the virtual walk over one or more shelf photos.""" - def __init__(self, samples: Dict[str, Dict[str, str]], window: Tuple[int, int] = DEFAULT_WINDOW): + def __init__(self, samples: Dict[str, Dict[str, str]], window: Tuple[int, int] = DEFAULT_WINDOW, + pan_overlap: float = PAN_OVERLAP): self._samples = samples self._window = window + # How far the window advances per step. The demo's pacing knob: a smaller + # value is a slower, more deliberate sweep (more steps, a facing in view + # for longer). Clamped so a client cannot ask for a frozen or teleporting + # camera. + self.pan_overlap = max(0.005, min(0.5, float(pan_overlap))) self._lock = threading.Lock() self._images: Dict[str, Any] = {} self._sizes: Dict[str, Tuple[int, int]] = {} @@ -104,7 +110,7 @@ class DemoCamera: def _pan_steps(self, image_width: int, window_width: int) -> int: if window_width >= image_width: return 1 - stride = max(1, int(window_width * PAN_OVERLAP)) + stride = max(1, int(window_width * self.pan_overlap)) return max(1, int(math.ceil((image_width - window_width) / stride)) + 1) def plan(self, sample_ids: Sequence[str], window: Optional[Tuple[int, int]] = None) -> Dict[str, Any]: @@ -136,7 +142,7 @@ class DemoCamera: ], "steps": total, "row_overlap": ROW_OVERLAP, - "pan_overlap": PAN_OVERLAP, + "pan_overlap": self.pan_overlap, } # ------------------------------------------------------------------ rendering @@ -181,7 +187,7 @@ class DemoCamera: # Ascending: band 0 is the bottom of the shelf. y_bottom = min(image_height - win_h, band * stride_y) y = max(0, image_height - win_h - y_bottom) - stride_x = max(1, int(win_w * PAN_OVERLAP)) + stride_x = max(1, int(win_w * self.pan_overlap)) max_x = max(0, image_width - win_w) if within < per_band: pan = within diff --git a/recognization/code/tags.py b/recognization/code/tags.py index 36c3091..e43b2f6 100644 --- a/recognization/code/tags.py +++ b/recognization/code/tags.py @@ -363,8 +363,13 @@ def parse_price_tag(texts: Sequence[str], lines: Optional[Sequence[OcrLine]] = N @dataclass class TagSettings: enabled: bool = True - pass_interval_frames: int = 15 - max_bands_per_pass: int = 2 + # OCR is the most expensive thing the server does (~124 ms per rail band with + # macOS Vision, and pyobjc holds the GIL for the duration), so in live mode it + # must not run often. Measured: at 15 frames / 2 bands a walking session + # dropped to ~1.5 results/s; 30 frames / 1 band keeps detection real-time + # while tags still settle within a second or two. + pass_interval_frames: int = 30 + max_bands_per_pass: int = 1 settle_observations: int = SETTLE_OBSERVATIONS settle_passes: int = SETTLE_PASSES min_band_width_ratio: float = 0.10