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

@ -129,8 +129,12 @@ class FakeInventory:
def make_session(matcher=None, settings=None, engine_cls=RecognitionEngine):
detector = FakeDetector()
matcher = matcher or FakeMatcher()
# These fixtures inject a fake matcher, so they exercise the `matcher`
# backend explicitly: the shipped default is the `local` lane, which reads
# real reference images and would ignore the fake.
engine = engine_cls(detector, matcher, FakeInventory(),
settings=__import__("engine").EngineSettings(classify_workers=2))
settings=__import__("engine").EngineSettings(
classify_workers=2, identity_backend="matcher"))
session = LiveSession("test", engine, settings=settings or LiveSettings(method="hybrid"))
session.refresh_catalog()
return session, engine, detector, matcher
@ -256,7 +260,10 @@ class CosineScaleTests(unittest.TestCase):
def test_a_close_cosine_pair_stays_ambiguous(self):
"""The margin gate still applies: two near-equal scores must not name a SKU."""
session, engine, _, _ = make_session(FakeMatcher(score=0.45), engine_cls=CosineEngine)
engine.matcher.score_crop = lambda crop, method=None: {"sku_a": 0.45, "other": 0.44}
# The matcher contract is (scores, details); a bare dict unpacks into two
# strings and raises inside the future callback, where the failure is
# swallowed and the test passes for the wrong reason.
engine.matcher.score_crop = lambda crop, method=None: ({"sku_a": 0.45, "other": 0.44}, {})
for frame_id in range(1, 8):
process(session, frame_id)
result = process(session, 9)
@ -608,7 +615,8 @@ def make_crop_session(matcher=None, settings=None, detector=None):
detector = detector or SmallBoxDetector()
engine = RecognitionEngine(
detector, matcher or FakeMatcher(), FakeInventory(),
settings=__import__("engine").EngineSettings(classify_workers=2),
settings=__import__("engine").EngineSettings(
classify_workers=2, identity_backend="matcher"),
)
session = LiveSession("crop", engine, settings=settings or LiveSettings(method="hybrid"))
session.refresh_catalog()