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.
59 lines
2.9 KiB
Python
59 lines
2.9 KiB
Python
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))
|