feat(server): walk the replayed sample instead of freezing it

A still replayed as frames gave every frame the same boxes, so the live path
never saw a track move. /v1/samples/{id}?pan=N now returns a portrait 720x1280
window slid N*PAN_STEP pixels along the sample, reversing at each edge, at the
frame size and JPEG quality the phone sends.

The decoded image is cached by (mtime, size): re-reading a 1500x2000 JPEG per
frame cost more than the encode and made the walk slower than PAN_STEP says.
Measured after the cache: 3.3 ms mean per panned frame, 301 frames/s ceiling.

Two tests, both verified to fail before the change.
This commit is contained in:
kiyo9w 2026-09-29 09:28:07 +09:00
parent 4b00323ae2
commit 4ce41ecb17
3 changed files with 106 additions and 2 deletions

View file

@ -36,3 +36,6 @@ data/external-shelves/*/*.png
data/packshots/research/*/images/ data/packshots/research/*/images/
data/packshots/research/*/pages/ data/packshots/research/*/pages/
data/packshots/research/*/*.jpg data/packshots/research/*/*.jpg
# Rebuildable sweep cache (code/sweep_accept_lines.py --cache).
*.pkl

View file

@ -96,6 +96,14 @@ WS_IDLE_TIMEOUT_S = 120.0
CATALOG_VERSION_FALLBACK = "unknown" CATALOG_VERSION_FALLBACK = "unknown"
MAX_CROP_PIXELS = 4_000_000 MAX_CROP_PIXELS = 4_000_000
# The replayed-sample pan: a portrait window at the phone's frame size, JPEG at
# the quality the device camera sends. `PAN_STEP` is pixels per frame, so at the
# scan's ~10 fps the shelf walks ~80 px/s - brisk enough to cover the shelf in
# about 9 s without jumping past a facing between frames.
PAN_LONG_EDGE = 1280
PAN_STEP = 8
PAN_JPEG_QUALITY = 70
def _jpeg_dimensions(data: bytes) -> Optional[Tuple[int, int]]: def _jpeg_dimensions(data: bytes) -> Optional[Tuple[int, int]]:
"""Read JPEG SOF dimensions without allocating a decoded image.""" """Read JPEG SOF dimensions without allocating a decoded image."""
@ -299,6 +307,9 @@ def create_app(
# path -> (mtime_ns, size, width, height). Dropped when the file changes. # path -> (mtime_ns, size, width, height). Dropped when the file changes.
sample_dimensions: Dict[str, Tuple[int, int, int, int]] = {} sample_dimensions: Dict[str, Tuple[int, int, int, int]] = {}
sample_dimensions_lock = threading.Lock() sample_dimensions_lock = threading.Lock()
# path -> ((mtime_ns, size), decoded image). The pan re-cuts this per frame.
sample_images: Dict[str, Any] = {}
sample_image_lock = threading.Lock()
sessions: Dict[str, LiveSession] = {} sessions: Dict[str, LiveSession] = {}
sessions_lock = asyncio.Lock() sessions_lock = asyncio.Lock()
app.state.engine = engine app.state.engine = engine
@ -398,6 +409,61 @@ def create_app(
sample_dimensions[path] = (key[0], key[1], width, height) sample_dimensions[path] = (key[0], key[1], width, height)
return width, height return width, height
def _panned_jpeg(path: str, index: int) -> Optional[bytes]:
"""One portrait window of `path`, slid `index` steps along the shelf.
The window is the shape the phone sends - long edge `PAN_LONG_EDGE`,
9:16 - so a simulator sees the frame size the live path really gets.
It steps `PAN_STEP` pixels per frame and reverses at each edge, so the
walk is continuous rather than a jump back to the start.
The decoded image is cached: re-reading a 1500x2000 JPEG per frame cost
more than the encode, which made the walk slower than `PAN_STEP` says.
"""
import cv2
image = _decoded_sample(path)
if image is None:
return None
height, width = image.shape[:2]
window_h = min(PAN_LONG_EDGE, height)
window_w = min(int(window_h * 9 / 16), width)
max_x = max(0, width - window_w)
travel = index * PAN_STEP
if max_x == 0:
x = 0
else:
# Triangle wave: pan out to the edge, then back, forever.
period = 2 * max_x
phase = travel % period
x = phase if phase <= max_x else period - phase
y = max(0, (height - window_h) // 2)
window = image[y:y + window_h, x:x + window_w]
ok, encoded = cv2.imencode(".jpg", window, [int(cv2.IMWRITE_JPEG_QUALITY), PAN_JPEG_QUALITY])
return encoded.tobytes() if ok else None
def _decoded_sample(path: str) -> Any:
"""The sample decoded once, re-read only when the file changes."""
try:
stat = os.stat(path)
except OSError:
with sample_image_lock:
sample_images.pop(path, None)
return None
key = (stat.st_mtime_ns, stat.st_size)
with sample_image_lock:
cached = sample_images.get(path)
if cached is not None and cached[0] == key:
return cached[1]
import cv2
image = cv2.imread(path)
if image is None:
return None
with sample_image_lock:
sample_images[path] = (key, image)
return image
def _sample_listing() -> List[Dict[str, Any]]: def _sample_listing() -> List[Dict[str, Any]]:
listing = [] listing = []
for item in samples.values(): for item in samples.values():
@ -414,11 +480,22 @@ def create_app(
return {"samples": await loop.run_in_executor(None, _sample_listing)} return {"samples": await loop.run_in_executor(None, _sample_listing)}
@app.get("/v1/samples/{sample_id}", dependencies=[Depends(authorize)]) @app.get("/v1/samples/{sample_id}", dependencies=[Depends(authorize)])
async def get_sample(sample_id: str) -> Response: async def get_sample(sample_id: str, pan: Optional[int] = None) -> Response:
item = samples.get(sample_id) item = samples.get(sample_id)
if not item or not await asyncio.get_running_loop().run_in_executor(None, os.path.exists, item["path"]): if not item or not await asyncio.get_running_loop().run_in_executor(None, os.path.exists, item["path"]):
raise _error("NOT_FOUND", f"no sample '{sample_id}'", 404) raise _error("NOT_FOUND", f"no sample '{sample_id}'", 404)
if pan is None:
return FileResponse(item["path"]) return FileResponse(item["path"])
# A still replayed as frames is a frozen shelf: every frame carries the
# same boxes, so the live path never exercises a track that moves. `pan`
# is the frame index, and the window slides one step per frame and
# reverses at the edges - the same walk `stream_client.py` drives, so a
# simulator sees what a real slow pan produces.
loop = asyncio.get_running_loop()
jpeg = await loop.run_in_executor(None, _panned_jpeg, item["path"], max(0, pan))
if jpeg is None:
raise _error("NOT_FOUND", f"cannot read sample '{sample_id}'", 404)
return Response(content=jpeg, media_type="image/jpeg")
# ---------------------------------------------------------------- recognize # ---------------------------------------------------------------- recognize
@app.post("/v1/recognize", dependencies=[Depends(authorize)]) @app.post("/v1/recognize", dependencies=[Depends(authorize)])

View file

@ -69,6 +69,30 @@ class RestContractTests(unittest.TestCase):
self.assertIn("version", body["model"]) self.assertIn("version", body["model"])
self.assertEqual(body["catalog"]["sku_count"], 2) self.assertEqual(body["catalog"]["sku_count"], 2)
def test_a_panned_sample_walks_and_stays_portrait(self):
# The debug replay source walks a shelf by asking for a frame index. A
# pan that returned the same window every time would freeze the stage
# while the boxes moved, which is the bug this pins.
first = self.client.get("/v1/samples/image6?pan=0")
self.assertEqual(first.status_code, 200)
self.assertEqual(first.headers["content-type"], "image/jpeg")
later = self.client.get("/v1/samples/image6?pan=40")
self.assertEqual(later.status_code, 200)
self.assertNotEqual(first.content, later.content, "a later pan must show a different window")
import cv2
import numpy as np
decoded = cv2.imdecode(np.frombuffer(later.content, dtype="uint8"), cv2.IMREAD_COLOR)
height, width = decoded.shape[:2]
self.assertEqual((width, height), (720, 1280), "the window must match the phone's portrait frame")
def test_a_sample_without_pan_is_the_whole_image(self):
response = self.client.get("/v1/samples/image6")
self.assertEqual(response.status_code, 200)
self.assertGreater(len(response.content), 1024)
def test_catalog_shape_has_no_local_paths(self): def test_catalog_shape_has_no_local_paths(self):
body = self.client.get("/v1/catalog").json() body = self.client.get("/v1/catalog").json()
product = body["products"][0] product = body["products"][0]