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

@ -1,8 +1,16 @@
"""Identity ranking for catalog scores.
Separates raw matcher scores from accept/unknown/ambiguous/reject so a
zero-score crop cannot become a SKU. Thresholds are conservative defaults
for the 21-SKU closed set; they are not calibrated accuracy claims.
zero-score crop cannot become a SKU. Three score scales are recognized:
- `inlier`: Van's composite, kept as the selectable baseline lane (E-1).
- `local`: `identity_lanes.LocalLane`'s margin-over-runner-up, in inlier units.
- `cosine`: the embedding gallery, fenced off and not in the live path.
The `local` line is measured on the corrected image6 labels: 2.0 keeps 95% of
shown labels correct at 59% coverage with the text lane, 1.0 keeps 87% at 66%.
It is tuned on one photo, so it is a direction, not a calibration; E-4 replaces
it once Tier-2 held-out labels exist.
"""
from __future__ import annotations
@ -14,11 +22,17 @@ IDENTITY_AMBIGUOUS = "ambiguous"
IDENTITY_UNKNOWN = "unknown"
IDENTITY_REJECTED = "rejected"
# ponytail: closed-set defaults. Recalibrate when held-out scores exist.
# ponytail: closed-set defaults for the baseline inlier scale. Recalibrate when
# held-out scores exist.
DEFAULT_MIN_SCORE = 1.0
DEFAULT_MIN_MARGIN = 0.15
DEFAULT_MIN_CONFIDENCE = 0.55
# `local` scale (identity_lanes.LocalLane). The value is already a margin over
# the runner-up, so the relative-margin test does not apply to it: a single
# absolute line is the whole gate.
LOCAL_ACCEPT = 2.0
def relative_confidence(top_score: float, runner_up_score: float) -> float:
"""Existing matcher formula: top / (top + 0.5 * runner)."""
@ -170,6 +184,8 @@ def decide_identity(
min_margin = COSINE_MARGIN
if min_confidence == DEFAULT_MIN_CONFIDENCE:
min_confidence = COSINE_AMBIGUOUS
elif scale == "local" and min_score == DEFAULT_MIN_SCORE:
min_score = LOCAL_ACCEPT
cleaned: Dict[str, float] = {}
for sku_id, score in (scores or {}).items():
number = _finite(score)
@ -209,6 +225,29 @@ def decide_identity(
ranked = [{"sku_id": s, "score": round(sc, 2)} for s, sc in sorted_candidates[:5]]
top_details = (details or {}).get(top_sku, {})
if scale == "local":
# The score is already a margin over the runner-up, so the relative
# margin test below would apply the same correction twice. One absolute
# line is the whole gate; a candidate under it is unknown, not ambiguous,
# because the lane has already said which one it prefers.
if top_score <= 0.0:
level, message, best = IDENTITY_REJECTED, "No matching evidence", None
elif top_score < min_score:
level, message, best = IDENTITY_UNKNOWN, "Top score below accept threshold", None
else:
level, message, best = IDENTITY_ACCEPTED, "Accepted unique catalog match", top_sku
return {
"status": "success" if level == IDENTITY_ACCEPTED else level,
"method": method,
"best_sku": best,
"score": round(top_score, 2),
"confidence": round(top_score, 4),
"identity_level": level,
"ranked_candidates": ranked,
"details": top_details,
"message": message,
}
if top_score <= 0.0:
level = IDENTITY_REJECTED
message = "No matching evidence"