product-recognition-demo/recognization/docs/research/2026-09-28-recognition-architecture/evidence/analyze.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

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