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

@ -63,6 +63,11 @@ def file_sha256(path: str) -> Optional[str]:
def content_sha256(payload: bytes) -> str:
return hashlib.sha256(payload).hexdigest()
# Every backend the server can be started with, so a recording made by the
# shipped `run-demo-server.sh` (--identity local) can be replayed. `local` is
# the default; `matcher` is Van's scorer kept as the E-1 baseline lane.
IDENTITY_BACKENDS = ("matcher", "local", "gallery")
def _iou(a: Sequence[float], b: Sequence[float]) -> float:
if len(a) != 4 or len(b) != 4:
@ -117,8 +122,10 @@ def recorded_identity(rows: Sequence[Dict[str, Any]]) -> Optional[str]:
if raw is None:
continue
name = str(raw).strip().lower()
if name not in ("matcher", "gallery"):
raise EvalInputError(f"recorded identity backend is not matcher or gallery: {raw}")
if name not in IDENTITY_BACKENDS:
raise EvalInputError(
f"recorded identity backend is not one of {', '.join(IDENTITY_BACKENDS)}: {raw}"
)
found.append(name)
unique = set(found)
if len(unique) > 1:
@ -131,10 +138,13 @@ def resolve_identity(requested: Optional[str], recorded: Optional[str]) -> str:
chosen = (requested or recorded or "").strip().lower()
if not chosen:
raise EvalInputError(
"recording does not name an identity backend; pass --identity matcher or --identity gallery"
"recording does not name an identity backend; pass --identity "
+ " or --identity ".join(IDENTITY_BACKENDS)
)
if chosen not in IDENTITY_BACKENDS:
raise EvalInputError(
f"identity must be one of {', '.join(IDENTITY_BACKENDS)}, not {requested or recorded}"
)
if chosen not in ("matcher", "gallery"):
raise EvalInputError(f"identity must be matcher or gallery, not {requested or recorded}")
if recorded and requested and recorded != chosen:
raise EvalInputError(
f"recording identity is {recorded}; pass --identity {recorded}"
@ -572,8 +582,10 @@ def score_recording(
tracks = [track for frame in source for track in (frame.get("tracks") or [])]
decisions = score_tracks(tracks)
backend = identity.strip().lower()
if backend not in ("matcher", "gallery"):
raise EvalInputError(f"identity must be matcher or gallery, not {identity}")
if backend not in IDENTITY_BACKENDS:
raise EvalInputError(
f"identity must be one of {', '.join(IDENTITY_BACKENDS)}, not {identity}"
)
report = {
"recording": recording_dir,
"source": "live_replay" if live_frames is not None else "recorded_index",
@ -790,7 +802,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
parser.add_argument(
"--identity",
default=None,
choices=["matcher", "gallery"],
choices=list(IDENTITY_BACKENDS),
help="which mechanism names products (required when the recording omits identity_backend)",
)
parser.add_argument("--device", default=None, help="cpu | mps | cuda | cuda:0")