perf: bound OCR so a walking scan stays real-time, and add walk pacing
macOS Vision holds the GIL for the ~124 ms it takes to read a rail band, so the tag pass decides the live frame rate: with 2 bands every 15 frames a walking session fell to 1.7 results/s, and 1 band every 30 frames brings it back to 7.2 with tags still settling in a second or two. Measured, not guessed - the same walk, same method (orb), only the OCR duty cycle changed. The walk also gained a `pan` parameter (default 0.06, clamped 0.005-0.5) so a demo can choose its pace: pan=0.02 sweeps a row over ~8 s, which reads like a person rather than a time-lapse. Demo configuration measured end to end on the walk (20 s, 1280x720, one client): orb, pan=0.02, 1 band/30 frames -> 7.2 results/s, 178 labelled tracks, 21 distinct SKUs, first label 0.7 s, 3 readable price tags. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
00367a95c9
commit
d38d863873
3 changed files with 34 additions and 10 deletions
|
|
@ -245,6 +245,7 @@ def create_app(
|
||||||
scans = scans if scans is not None else build_legacy_scans(engine)
|
scans = scans if scans is not None else build_legacy_scans(engine)
|
||||||
samples = {item["id"]: item for item in get_available_shelf_images("add")}
|
samples = {item["id"]: item for item in get_available_shelf_images("add")}
|
||||||
demo_camera = DemoCamera(samples)
|
demo_camera = DemoCamera(samples)
|
||||||
|
demo_cameras: Dict[float, DemoCamera] = {}
|
||||||
sessions: Dict[str, LiveSession] = {}
|
sessions: Dict[str, LiveSession] = {}
|
||||||
sessions_lock = asyncio.Lock()
|
sessions_lock = asyncio.Lock()
|
||||||
app.state.engine = engine
|
app.state.engine = engine
|
||||||
|
|
@ -328,22 +329,34 @@ def create_app(
|
||||||
raise _error("BAD_REQUEST", "window must be between 160x120 and 2048x2048", 400)
|
raise _error("BAD_REQUEST", "window must be between 160x120 and 2048x2048", 400)
|
||||||
return win_w, win_h
|
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)])
|
@app.get("/v1/demo/plan", dependencies=[Depends(authorize)])
|
||||||
async def demo_plan(samples_param: Optional[str] = None, width: Optional[int] = None,
|
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)."""
|
"""Geometry of the virtual walk (demo-only capture source)."""
|
||||||
try:
|
try:
|
||||||
ids = _demo_samples(samples_param)
|
ids = _demo_samples(samples_param)
|
||||||
if not ids:
|
if not ids:
|
||||||
raise _error("NOT_FOUND", "no samples available", 404)
|
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:
|
except DemoSourceError as exc:
|
||||||
raise _error("NOT_FOUND", str(exc), 404)
|
raise _error("NOT_FOUND", str(exc), 404)
|
||||||
|
|
||||||
@app.get("/v1/demo/frame", dependencies=[Depends(authorize)])
|
@app.get("/v1/demo/frame", dependencies=[Depends(authorize)])
|
||||||
async def demo_frame(step: int = 0, samples_param: Optional[str] = None,
|
async def demo_frame(step: int = 0, samples_param: Optional[str] = None,
|
||||||
width: Optional[int] = None, height: Optional[int] = 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.
|
"""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
|
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)
|
ids = _demo_samples(samples_param)
|
||||||
if not ids:
|
if not ids:
|
||||||
raise _error("NOT_FOUND", "no samples available", 404)
|
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)))
|
ids, step, _demo_window(width, height), max(30, min(95, int(quality)))
|
||||||
)
|
)
|
||||||
except DemoSourceError as exc:
|
except DemoSourceError as exc:
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,15 @@ class Bay:
|
||||||
class DemoCamera:
|
class DemoCamera:
|
||||||
"""Plans and renders the virtual walk over one or more shelf photos."""
|
"""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._samples = samples
|
||||||
self._window = window
|
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._lock = threading.Lock()
|
||||||
self._images: Dict[str, Any] = {}
|
self._images: Dict[str, Any] = {}
|
||||||
self._sizes: Dict[str, Tuple[int, int]] = {}
|
self._sizes: Dict[str, Tuple[int, int]] = {}
|
||||||
|
|
@ -104,7 +110,7 @@ class DemoCamera:
|
||||||
def _pan_steps(self, image_width: int, window_width: int) -> int:
|
def _pan_steps(self, image_width: int, window_width: int) -> int:
|
||||||
if window_width >= image_width:
|
if window_width >= image_width:
|
||||||
return 1
|
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)
|
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]:
|
def plan(self, sample_ids: Sequence[str], window: Optional[Tuple[int, int]] = None) -> Dict[str, Any]:
|
||||||
|
|
@ -136,7 +142,7 @@ class DemoCamera:
|
||||||
],
|
],
|
||||||
"steps": total,
|
"steps": total,
|
||||||
"row_overlap": ROW_OVERLAP,
|
"row_overlap": ROW_OVERLAP,
|
||||||
"pan_overlap": PAN_OVERLAP,
|
"pan_overlap": self.pan_overlap,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------ rendering
|
# ------------------------------------------------------------------ rendering
|
||||||
|
|
@ -181,7 +187,7 @@ class DemoCamera:
|
||||||
# Ascending: band 0 is the bottom of the shelf.
|
# Ascending: band 0 is the bottom of the shelf.
|
||||||
y_bottom = min(image_height - win_h, band * stride_y)
|
y_bottom = min(image_height - win_h, band * stride_y)
|
||||||
y = max(0, image_height - win_h - y_bottom)
|
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)
|
max_x = max(0, image_width - win_w)
|
||||||
if within < per_band:
|
if within < per_band:
|
||||||
pan = within
|
pan = within
|
||||||
|
|
|
||||||
|
|
@ -363,8 +363,13 @@ def parse_price_tag(texts: Sequence[str], lines: Optional[Sequence[OcrLine]] = N
|
||||||
@dataclass
|
@dataclass
|
||||||
class TagSettings:
|
class TagSettings:
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
pass_interval_frames: int = 15
|
# OCR is the most expensive thing the server does (~124 ms per rail band with
|
||||||
max_bands_per_pass: int = 2
|
# 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_observations: int = SETTLE_OBSERVATIONS
|
||||||
settle_passes: int = SETTLE_PASSES
|
settle_passes: int = SETTLE_PASSES
|
||||||
min_band_width_ratio: float = 0.10
|
min_band_width_ratio: float = 0.10
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue