feat: a synthetic store walk, and the live tuning it forced out
/v1/demo/plan + /v1/demo/frame give a demo the one thing a still image cannot: motion. The virtual camera pans a shelf row left to right, steps up, and pans back - a store employee sweeping a shelf - over a real shelf photo, ~1 ms per frame, deterministic, and wrapping so an unattended demo loops. The client drives only `step`, so the walk's speed follows whatever frame rate it can sustain. Three real defects came out of measuring the walk rather than a still: - Bands were keyed by on-screen position, so a walking camera minted a new key every few pixels of pan and re-read the same shelf continuously. Keyed by row now; OCR stops chasing the window. - The classification budget was consumed by retries of products already leaving the frame. Retries now skip anything near the edge, and a track gets 2 attempts. - The pan advance was 0.22 of the window per step: a facing was in view for one or two frames, so nothing could ever be classified (9 labelled tracks in 20 s). 0.06 keeps a product in view for about a second. Measured on the walk (20 s, 1280x720 frames, one client): orb labels 296 tracks across 21 SKUs with the first label at 0.2 s, while hybrid manages 13 tracks / 4 SKUs at 5.4 s - so the live default for a demo is the fast method, with hybrid kept for tap-to-inspect. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
a18a80a21e
commit
00367a95c9
5 changed files with 466 additions and 16 deletions
|
|
@ -70,6 +70,7 @@ from engine import ( # noqa: E402
|
|||
identity_payload,
|
||||
settings_from_env,
|
||||
)
|
||||
from demo import DemoCamera, DemoSourceError # noqa: E402
|
||||
from live import LiveSession, LiveSettings # noqa: E402
|
||||
|
||||
LOGGER = logging.getLogger("dorayaki")
|
||||
|
|
@ -243,6 +244,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)
|
||||
sessions: Dict[str, LiveSession] = {}
|
||||
sessions_lock = asyncio.Lock()
|
||||
app.state.engine = engine
|
||||
|
|
@ -295,12 +297,79 @@ def create_app(
|
|||
# ---------------------------------------------------------------- samples
|
||||
@app.get("/v1/samples", dependencies=[Depends(authorize)])
|
||||
async def list_samples() -> Dict[str, Any]:
|
||||
return {
|
||||
"samples": [
|
||||
{"id": item["id"], "label": item["label"], "url": f"/v1/samples/{item['id']}"}
|
||||
for item in samples.values()
|
||||
]
|
||||
}
|
||||
listing = []
|
||||
for item in samples.values():
|
||||
entry = {"id": item["id"], "label": item["label"], "url": f"/v1/samples/{item['id']}"}
|
||||
try: # size is a convenience for clients that plan a demo walk
|
||||
width, height = demo_camera._size(item["id"])
|
||||
entry["width"], entry["height"] = width, height
|
||||
except DemoSourceError:
|
||||
pass
|
||||
listing.append(entry)
|
||||
return {"samples": listing}
|
||||
|
||||
# ---------------------------------------------------------------- demo camera
|
||||
def _demo_samples(raw: Optional[str]) -> List[str]:
|
||||
if raw:
|
||||
wanted = [part.strip() for part in raw.split(",") if part.strip()]
|
||||
unknown = [part for part in wanted if part not in samples]
|
||||
if unknown:
|
||||
raise _error("NOT_FOUND", f"unknown sample(s): {', '.join(unknown)}", 404)
|
||||
return wanted
|
||||
for preferred in ("image6", "main_shelf"):
|
||||
if preferred in samples:
|
||||
return [preferred]
|
||||
return [next(iter(samples))] if samples else []
|
||||
|
||||
def _demo_window(width: Optional[int], height: Optional[int]) -> tuple:
|
||||
win_w = int(width or 960)
|
||||
win_h = int(height or 540)
|
||||
if not (160 <= win_w <= 2048 and 120 <= win_h <= 2048):
|
||||
raise _error("BAD_REQUEST", "window must be between 160x120 and 2048x2048", 400)
|
||||
return win_w, win_h
|
||||
|
||||
@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]:
|
||||
"""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))}
|
||||
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:
|
||||
"""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
|
||||
follows whatever frame rate the client can sustain.
|
||||
"""
|
||||
try:
|
||||
ids = _demo_samples(samples_param)
|
||||
if not ids:
|
||||
raise _error("NOT_FOUND", "no samples available", 404)
|
||||
payload, position = demo_camera.frame(
|
||||
ids, step, _demo_window(width, height), max(30, min(95, int(quality)))
|
||||
)
|
||||
except DemoSourceError as exc:
|
||||
raise _error("NOT_FOUND", str(exc), 404)
|
||||
return Response(
|
||||
content=payload,
|
||||
media_type="image/jpeg",
|
||||
headers={
|
||||
"X-Demo-Sample": str(position["sample"]),
|
||||
"X-Demo-Bay": str(position["bay_index"] + 1),
|
||||
"X-Demo-Step": str(position["step"]),
|
||||
"X-Demo-Steps": str(position["steps"]),
|
||||
"X-Demo-X": str(position["x"]),
|
||||
"X-Demo-Y": str(position["y"]),
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/v1/samples/{sample_id}", dependencies=[Depends(authorize)])
|
||||
async def get_sample(sample_id: str) -> Response:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue