fix(recognition): calibrate the accept lines against unrelated shelves

Every threshold was fitted on image6, where each crop is a facing of a product
the shelf holds, so the set could not price a false accept. Measured on 4580
crops from 64 unrelated Wikimedia shelf photos at the shipped configuration:

  LOCAL_ACCEPT 2.0 -> 4.0   false accepts 60 (1.31%) -> 3 (0.07%)
  TEXT_MIN_SHARED 2 -> 4    the two-bigram reads that named sku_l12 via おい/いし

On image6 that is 44 shown / 42 correct at full resolution - 95.5% precision,
65.6% coverage - the highest-coverage cell of the joint sweep that passes both
G2 (>= 95%) and G3 (<= 1%). At live resolution 56% end-to-end at 97%.

The text lane's evidence floor gates its answer only: zeroing the row's score
would reorder the ranking and make the margin gate compare a different pair
than the one it was measured on.

code/sweep_accept_lines.py sweeps both axes jointly through the shipped
DecisionPolicy; the earlier table swept one axis while pinning the other, so
its baseline rows were not the old line.
This commit is contained in:
kiyo9w 2026-09-29 09:28:13 +09:00
parent 4ce41ecb17
commit 5094b5f6ca
12 changed files with 815 additions and 195 deletions

View file

@ -306,6 +306,9 @@ class LiveSession:
self._frames_dropped = 0
self._process_times: "collections.deque[float]" = collections.deque(maxlen=STATS_WINDOW)
self._classify_times: "collections.deque[float]" = collections.deque(maxlen=STATS_WINDOW)
# A lane that raised is an operational failure, not "nothing matched".
# Counted so a broken run is never read as a coverage change.
self._classify_failures = 0
self._slot_history: "collections.deque[List[Dict[str, Any]]]" = collections.deque(
maxlen=EMPTY_SLOT_HISTORY
)
@ -1198,7 +1201,14 @@ class LiveSession:
) -> None:
try:
scores, text_rank, text_read = future.result()
except Exception:
except Exception as exc:
# A lane that raised is an operational failure, not a crop that
# matched nothing. Swallowing it made a broken run (tracks, no
# labels, clean log) indistinguishable from a coverage change, so
# it is counted and logged before the empty answer is used.
with self._lock:
self._classify_failures += 1
LOGGER.exception("classification failed for track %s: %s", track_id, exc)
scores, text_rank, text_read = {}, [], ""
finished_at = time.perf_counter()
# Resolved before the session lock is taken. `identity_text_lane` builds
@ -1457,6 +1467,7 @@ class LiveSession:
"frames_dropped": self._frames_dropped,
"server_fps": fps,
"classify_pending": sum(track.in_flight for track in self._tracks.values()),
"classify_failures": self._classify_failures,
"classify_ms_p50": classify_p50,
}