product-recognition-demo/recognization/docs/research/2026-09-28-recognition-architecture/evidence/fuse_eval.py
tngo aaed5d388f 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.
2026-09-28 22:27:21 +09:00

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}')