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:
parent
4b00323ae2
commit
4ce41ecb17
3 changed files with 106 additions and 2 deletions
3
recognization/.gitignore
vendored
3
recognization/.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)])
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue