feat(identity): local-feature matcher plus text lane as the default backend

Replaces the DINO gallery as the recognition path with the measured design
from docs/research/2026-09-28-recognition-architecture/DECISION.md: RootSIFT
over a per-SKU reference pyramid, similarity-constrained RANSAC and a hue
check, fused with an OCR text lane that vetoes a match whose package text
names a different catalog row and answers on its own when geometry finds
nothing.

- identity_lanes.py: LocalLane, TextLane, DecisionPolicy. The text lane
  builds each row's signature from the catalog name plus its packshot OCR, so
  a row with no enrolled image is still answerable.
- engine.py: every backend groups scores by canonical SKU, so no
  view-suffixed id reaches the wire and a name and price always resolve.
  decide_crop fuses the lanes; score_crop_lanes feeds the live path. Both
  lanes are built in warm_up (non-fatally) so the first frame is not the slow
  one, and the text lane shares the engine's OCR lock.
- live.py: decides per crop on the lane's own rank order. The lane's order
  comes from its rank key and its value is only the gate, so re-sorting by
  value named a different SKU than the lane chose; averaging in an empty
  observation also diluted a good confidence below the accept line.
- identity.py: a `local` score scale whose gate is one absolute line, because
  the value is already a margin over the runner-up.
- app.py, run-demo-server.sh: `--identity local` is the default; the demo
  server no longer starts on the leaked `stock-lopia` catalog.
- eval_live.py: the replay vocabulary accepts `local`, so a recording from
  the shipped server can be scored.
- README.md: documents the three backends and what each is for.

Measured on the corrected image6 labels at live resolution: 36/61 in-catalog
facings named at 95% precision, 0 of 48 unknowns accepted, 11 labels sourced
from the text lane alone. Through the real server: /v1/recognize 90%
precision, /v1/stream 100% precision, no view-suffixed ids on either.
This commit is contained in:
tngo 2026-09-28 22:27:21 +09:00
parent 25dbf132e7
commit aaed5d388f
44 changed files with 7847 additions and 60 deletions

View file

@ -814,7 +814,7 @@ class LiveSession:
submitted_box = list(track.box)
track_id = track.track_id
try:
future = self._executor.submit(self.engine.score_crop, crop, method)
future = self._executor.submit(self.engine.score_crop_lanes, crop, method)
except RuntimeError: # executor already shut down
with self._lock:
track.in_flight = max(0, track.in_flight - 1)
@ -989,7 +989,7 @@ class LiveSession:
self._crops_rejected += 1
return _crop_error("CROP_STALE", stale)
try:
future = self._executor.submit(self.engine.score_crop, crop, method)
future = self._executor.submit(self.engine.score_crop_lanes, crop, method)
except RuntimeError: # executor already shut down
self._release_crop_slot(record)
self._crops_rejected += 1
@ -1094,9 +1094,9 @@ class LiveSession:
source: str = SOURCE_FRAME,
) -> None:
try:
scores = future.result()
scores, text_rank = future.result()
except Exception:
scores = {}
scores, text_rank = {}, []
finished_at = time.perf_counter()
with self._lock:
track = self._tracks.get(track_id)
@ -1134,7 +1134,7 @@ class LiveSession:
track.score_n += 1
self._classify_times.append(finished_at - submitted_at)
if not any(track.observations):
if not any(track.observations) and not text_rank:
if track.attempts >= self.settings.max_attempts:
track.state = STATE_UNRECOGNIZED
track.confirmed = True
@ -1143,10 +1143,13 @@ class LiveSession:
track.candidates = []
return
from identity import aggregate_votes, decide_identity
from identity_lanes import DecisionPolicy
mean_scores = aggregate_votes(track.observations)
if getattr(self.engine, "identity_scale", "inlier") == "cosine":
scale = getattr(self.engine, "identity_scale", "inlier")
if scale == "cosine":
# score_crop only returns scores the gallery already accepted, so
# the aggregate must not be re-gated at COSINE_ACCEPT: that line is
# above the head's own 0.40 and refused matches the gallery had
@ -1162,8 +1165,17 @@ class LiveSession:
mean_scores, method=method, scale="cosine",
min_score=line, min_confidence=line,
)
elif scale == "local":
# The local lane's order and value are different quantities: the
# order comes from its rank key, the value is the margin over the
# runner-up. `score_crop` returns that order, so it is used as-is;
# re-sorting by value would name a different SKU than the lane
# chose. The newest crop is the evidence, not the mean: averaging
# in an empty observation halves a good confidence below the
# accept line, which is the dilution the cosine branch guards too.
decision = DecisionPolicy().decide(list(scores.items()), text_rank)
else:
decision = decide_identity(mean_scores, method=method)
decision = decide_identity(mean_scores, method=method, scale=scale)
track.candidates = [
{"sku_id": row.get("sku_id"), "score": row.get("score")}
for row in (decision.get("ranked_candidates") or [])[:3]