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
|
|
@ -0,0 +1,59 @@
|
|||
import pickle, json, sys, collections, numpy as np
|
||||
sys.path.insert(0,'code')
|
||||
from identity import decide_identity
|
||||
def load_labels(p): return {f['index']: f['label'] for f in json.load(open(p))['facings']}
|
||||
def feats(sc):
|
||||
r = sorted(sc.items(), key=lambda kv: -kv[1])
|
||||
if not r: return None, 0.0, 0.0
|
||||
top = r[0][1]; run = r[1][1] if len(r) > 1 else 0.0
|
||||
return r[0][0], top, (top-run)/max(top,1.0)
|
||||
def analyze(run, labels, verbose=False):
|
||||
E = set(run['enrolled']); rows = [x for x in run['rows'] if labels[x['index']] != 'exclude']
|
||||
T = lambda x: labels[x['index']]
|
||||
inc = [x for x in rows if T(x) != 'unknown']; ince = [x for x in inc if T(x) in E]
|
||||
unk = [x for x in rows if T(x) == 'unknown']
|
||||
# identity ceiling: ungated argmax on enrolled in-catalog
|
||||
ranks = []
|
||||
for x in ince:
|
||||
order = [k for k, _ in sorted(x['scores'].items(), key=lambda kv: -kv[1])]
|
||||
ranks.append(order.index(T(x))+1 if T(x) in order and x['scores'].get(T(x),0)>0 else 99)
|
||||
top1 = sum(r == 1 for r in ranks)
|
||||
# Van contract: argmax always
|
||||
arg_correct = top1; answered = sum(1 for x in rows if x['scores'] and max(x['scores'].values())>0)
|
||||
# current decision rule
|
||||
acc = collections.Counter(); c = w = wu = wi = 0
|
||||
for x in rows:
|
||||
d = decide_identity(x['scores'], method='hybrid')
|
||||
b = d['best_sku'] if d['identity_level']=='accepted' else None
|
||||
if not b: continue
|
||||
acc[b]+=1
|
||||
if b == T(x): c += 1
|
||||
else:
|
||||
w += 1
|
||||
if T(x)=='unknown': wu+=1
|
||||
else: wi+=1
|
||||
hub = max(acc.values())/sum(acc.values()) if acc else 0
|
||||
out = dict(n=len(rows), in_catalog=len(inc), enrolled_in=len(ince), unknown=len(unk),
|
||||
identity_top1=f"{top1}/{len(ince)}={top1/max(1,len(ince)):.0%}",
|
||||
argmax_e2e=f"{arg_correct}/{len(inc)}={arg_correct/max(1,len(inc)):.0%}",
|
||||
argmax_precision=f"{arg_correct}/{answered}={arg_correct/max(1,answered):.0%}",
|
||||
gated=f"correct {c} wrong {w} (unk {wu}, in-cat {wi}) cov_enr {c/max(1,len(ince)):.0%} prec {c/max(1,c+w):.0%} hub {hub:.0%}",
|
||||
ms=round(float(np.median([x['ms'] for x in run['rows'] if x['ms']>0] or [0])),1))
|
||||
return out, ranks
|
||||
def sweep(run, labels, feature='margin'):
|
||||
"""Pareto of (correct accepted, precision) over a single threshold on a feature."""
|
||||
E=set(run['enrolled']); rows=[x for x in run['rows'] if labels[x['index']]!='exclude']
|
||||
pts=[]
|
||||
for x in rows:
|
||||
b,top,mar=feats(x['scores']); t=labels[x['index']]
|
||||
v={'margin':mar,'top':top,'both':min(mar/0.3, top/40)}[feature]
|
||||
pts.append((v, b==t and b is not None, b))
|
||||
pts.sort(key=lambda p:-p[0]); res=[]; c=a=0
|
||||
for v,ok,b in pts:
|
||||
a+=1; c+=ok; res.append((v,c,a,c/a))
|
||||
return res
|
||||
if __name__=='__main__':
|
||||
labels=load_labels(sys.argv[1])
|
||||
for p in sys.argv[2:]:
|
||||
r=pickle.load(open(p,'rb')); o,ranks=analyze(r,labels)
|
||||
print(p.split('/')[-1], json.dumps(o, ensure_ascii=False))
|
||||
Loading…
Add table
Add a link
Reference in a new issue