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.
57 lines
3.2 KiB
Python
57 lines
3.2 KiB
Python
import sys,os,json,pickle,csv,numpy as np
|
|
S=os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0,S)
|
|
from ocr_fuse import norm,sim # noqa (module runs OCR of packshots on import; cheap)
|
|
sig=pickle.load(open(f'{S}/sig.pkl','rb')); O=pickle.load(open(f'{S}/ocr.pkl','rb'))
|
|
L={f['index']:f['label'] for f in json.load(open(f'{S}/image6_v2.json'))['facings']}
|
|
def conf(ev):
|
|
r=sorted(ev.items(),key=lambda kv:-kv[1]['inliers']*(0.25+kv[1]['color']))
|
|
(k1,e1),(k2,e2)=r[0],r[1]; i1,i2=e1['inliers'],e2['inliers']
|
|
return k1, i1*(1-i2/max(i1,1))*e1['color']
|
|
for res,up,run in [('full',1,'v_ms_root_aff_full.pkl'),('live',2,'v_ms_root_aff_live.pkl')]:
|
|
ocr={r['index']:r['text'] for r in O[res] if r['up']==up}
|
|
M={x['index']:x for x in pickle.load(open(f'{S}/{run}','rb'))['rows']}
|
|
n_in=sum(1 for t in L.values() if t not in ('unknown','exclude'))
|
|
# OCR-alone identity among ALL catalog SKUs
|
|
ok=tot=0
|
|
for i,t in L.items():
|
|
if t in ('unknown','exclude') or i not in ocr: continue
|
|
tx=ocr[i];
|
|
if len(norm(tx))<4: continue
|
|
s={k:sim(tx,v) for k,v in sig.items()}; tot+=1; ok+= max(s,key=s.get)==t
|
|
print(f'== {res}: OCR-alone top-1 over all 12 catalog SKUs, on facings with text: {ok}/{tot}')
|
|
for delta in [None,0.0,0.1,0.2]:
|
|
line=[]
|
|
for tau in [0.5,1,2,3]:
|
|
c=a=0
|
|
for i,t in L.items():
|
|
if t=='exclude' or i not in M or not M[i]['ev']: continue
|
|
k,cf=conf(M[i]['ev'])
|
|
if cf<tau: continue
|
|
if delta is not None and len(norm(ocr.get(i,'')))>=4:
|
|
s={kk:sim(ocr[i],v) for kk,v in sig.items()}; j=max(s,key=s.get)
|
|
if j!=k and s[j]-s[k]>delta: continue # text names a different catalog product: veto
|
|
a+=1; c+= k==t
|
|
line.append(f'tau{tau}: {c}/{n_in} p={c/max(a,1):.0%}')
|
|
print(' veto' if delta is not None else ' matcher only', delta, ' | '.join(line))
|
|
print('== union: matcher (veto) OR OCR-only identity')
|
|
for res,up,run in [('full',1,'v_ms_root_aff_full.pkl'),('live',2,'v_ms_root_aff_live.pkl')]:
|
|
ocr={r['index']:r['text'] for r in O[res] if r['up']==up}
|
|
M={x['index']:x for x in pickle.load(open(f'{S}/{run}','rb'))['rows']}
|
|
n_in=sum(1 for t in L.values() if t not in ('unknown','exclude'))
|
|
for tau in [0.5,1,2]:
|
|
for sg,mu in [(0.5,0.15),(0.6,0.2),(0.7,0.25)]:
|
|
c=a=0; fa_unk=0; bysrc={'m':0,'o':0}
|
|
for i,t in L.items():
|
|
if t=='exclude' or i not in M: continue
|
|
tx=ocr.get(i,''); s={kk:sim(tx,v) for kk,v in sig.items()} if len(norm(tx))>=4 else {}
|
|
j=max(s,key=s.get) if s else None
|
|
ans=None
|
|
if M[i]['ev']:
|
|
k,cf=conf(M[i]['ev'])
|
|
if cf>=tau and not (j and j!=k and s[j]-s[k]>0): ans=k; src='m'
|
|
if ans is None and s:
|
|
r=sorted(s.values(),reverse=True)
|
|
if r[0]>=sg and r[0]-r[1]>=mu: ans=j; src='o'
|
|
if ans:
|
|
a+=1; c+= ans==t; bysrc[src]+= ans==t; fa_unk+= t=='unknown'
|
|
print(f' {res} tau{tau} ocr>={sg} m{mu}: correct {c}/{n_in} ({c/n_in:.0%}) precision {c/max(a,1):.0%} accepted-unknown {fa_unk}/48 from {bysrc}')
|