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
|
|
@ -96,6 +96,14 @@ WS_IDLE_TIMEOUT_S = 120.0
|
|||
CATALOG_VERSION_FALLBACK = "unknown"
|
||||
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]]:
|
||||
"""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.
|
||||
sample_dimensions: Dict[str, Tuple[int, int, int, int]] = {}
|
||||
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_lock = asyncio.Lock()
|
||||
app.state.engine = engine
|
||||
|
|
@ -398,6 +409,61 @@ def create_app(
|
|||
sample_dimensions[path] = (key[0], key[1], 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]]:
|
||||
listing = []
|
||||
for item in samples.values():
|
||||
|
|
@ -414,11 +480,22 @@ def create_app(
|
|||
return {"samples": await loop.run_in_executor(None, _sample_listing)}
|
||||
|
||||
@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)
|
||||
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)
|
||||
return FileResponse(item["path"])
|
||||
if pan is None:
|
||||
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
|
||||
@app.post("/v1/recognize", dependencies=[Depends(authorize)])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue