fix: bound and gate the crop evidence lane

The crop lane asked the phone for a full-resolution crop of a facing the
frame could not decide. Four defects made that unsafe or useless:

* A capability was half-applied. `crop: true` without a usable
  `max_crop_bytes` enabled the lane on the client's say-so, leaving the two
  sides believing different limits. A capability is now believed whole or
  refused with BAD_MESSAGE, and the limit must be an integer in 1..1048576
  (bool is an int in Python and is not a limit).
* A small JPEG could claim enormous dimensions. The header is now read
  without decoding and anything over 4 megapixels is refused before the
  decode allocates it.
* A request was single-use, so an expired one stranded a facing only the
  crop lane could decide. The lane now spends the same max_attempts budget
  the frame path spends, refilled on method change and on settled-label
  drift, and a phone that never answers still cannot loop.
* A candidate could be asked about from a box the frame path would refuse:
  clipped by the frame edge, carried over from an earlier frame, or measured
  at a score the tracker does not trust. Both lanes now share one edge gate,
  and only a track whose box this frame actually measured is asked about.

Drift and method change also cleared `identity` but left `confirmed = True`,
which permanently blocked both the crop lane and label inheritance for that
track; both now clear it, without which the budget reset was dead code.

Blur and glare are refused as whole-capture faults, while `too_far` is
exempt on purpose: a distant shelf is the one frame a native-resolution crop
exists to decide.
This commit is contained in:
kiyo9w 2026-09-28 17:22:52 +09:00
parent 02e69acac7
commit aa25f99699
5 changed files with 504 additions and 46 deletions

View file

@ -94,6 +94,37 @@ MAX_FRAME_BYTES = 4 * 1024 * 1024
DEFAULT_MAX_SESSIONS = 4
WS_IDLE_TIMEOUT_S = 120.0
CATALOG_VERSION_FALLBACK = "unknown"
MAX_CROP_PIXELS = 4_000_000
def _jpeg_dimensions(data: bytes) -> Optional[Tuple[int, int]]:
"""Read JPEG SOF dimensions without allocating a decoded image."""
if not data.startswith(b"\xff\xd8"):
return None
offset = 2
while offset + 4 <= len(data):
if data[offset] != 0xff:
return None
while offset < len(data) and data[offset] == 0xff:
offset += 1
if offset >= len(data):
return None
marker = data[offset]
offset += 1
if marker in (0xd8, 0x01) or 0xd0 <= marker <= 0xd7:
continue
if marker in (0xd9, 0xda) or offset + 2 > len(data):
return None
length = int.from_bytes(data[offset:offset + 2], "big")
if length < 2 or offset + length > len(data):
return None
if marker in (0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf):
if length < 7:
return None
return (int.from_bytes(data[offset + 5:offset + 7], "big"),
int.from_bytes(data[offset + 3:offset + 5], "big"))
offset += length
return None
# ---------------------------------------------------------------------------
@ -821,13 +852,20 @@ def create_app(
"""A client that can answer with a full-resolution crop says so here.
Until this arrives the session keeps the protocol-v1 behaviour: no
result carries a crop request, so a v1 client is never asked.
result carries a crop request, so a v1 client is never asked. A
capability is believed whole or not at all: a message that does not
name the lane with a limit the server can honour is refused and the
lane stays off, rather than half-applied on the client's say-so.
"""
try:
capabilities = session.enable_crops(message.get("max_crop_bytes"))
except (TypeError, ValueError):
if message.get("crop") is not True or "max_crop_bytes" not in message:
await send({"type": "error", "code": "BAD_MESSAGE",
"message": "bad max_crop_bytes"})
"message": "crop must be true and state max_crop_bytes"})
return
try:
capabilities = session.enable_crops(message["max_crop_bytes"])
except ValueError:
await send({"type": "error", "code": "BAD_MESSAGE",
"message": "max_crop_bytes must be 1..%d" % MAX_CROP_BYTES})
return
await send({"type": "capabilities_ack", "capabilities": capabilities})
@ -855,6 +893,11 @@ def create_app(
await send({"type": "crop_error", "request_id": request_id,
"code": "CROP_BAD_MESSAGE", "message": "jpeg is not valid base64"})
return
dimensions = _jpeg_dimensions(crop_bytes)
if dimensions is not None and dimensions[0] * dimensions[1] > MAX_CROP_PIXELS:
await send({"type": "crop_error", "request_id": request_id,
"code": "CROP_TOO_LARGE", "message": "crop dimensions exceed the limit"})
return
reply = await loop.run_in_executor(
None, session.submit_crop, request_id, message.get("track_id"),
message.get("frame_id"), crop_bytes,