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
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue