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
|
|
@ -650,6 +650,38 @@ class ProductGallery:
|
|||
return GalleryMatch(sku_id=top_sku, similarity=top_score, runner_up=runner_sku,
|
||||
runner_up_similarity=runner_score)
|
||||
|
||||
def rank(self, crop: Any, limit: Optional[int] = None) -> List[Tuple[str, float]]:
|
||||
"""Every enrolled SKU by similarity, best first, with no rejection.
|
||||
|
||||
`match()` answers "is anything close enough"; a shortlist needs "what are
|
||||
the closest candidates", including when none of them is close. Gating a
|
||||
shortlist at the raw threshold empties it for exactly the crops the
|
||||
verifier exists to rescue.
|
||||
"""
|
||||
if not self._available or self._matrix is None:
|
||||
return []
|
||||
import numpy as np
|
||||
|
||||
embedding = self.embed(crop)
|
||||
if self._head is not None:
|
||||
import torch
|
||||
with torch.no_grad():
|
||||
projected = torch.nn.functional.normalize(
|
||||
self._head["projection"](torch.from_numpy(embedding).float()), dim=0
|
||||
)
|
||||
similarity = (self._head["prototypes"] @ projected).numpy()
|
||||
ranked = sorted(zip(self._head["class_ids"], similarity.tolist()),
|
||||
key=lambda kv: kv[1], reverse=True)
|
||||
else:
|
||||
similarity = self._matrix @ embedding
|
||||
order, grouped, starts = self._rank_index()
|
||||
if not order:
|
||||
return []
|
||||
best_by_sku = np.maximum.reduceat(similarity[grouped], starts)
|
||||
ranked = sorted(zip(order, best_by_sku.tolist()), key=lambda item: item[1], reverse=True)
|
||||
return [(str(sku), float(score)) for sku, score in ranked[:limit]] if limit else \
|
||||
[(str(sku), float(score)) for sku, score in ranked]
|
||||
|
||||
def head_loaded(self) -> bool:
|
||||
return self._head is not None
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue