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:
parent
25dbf132e7
commit
aaed5d388f
44 changed files with 7847 additions and 60 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue