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

41 lines
2.2 KiB
Python

"""Diagnostic identity harness. Logs full per-facing score vectors so every
ceiling (catalog, identity rank, decision) is separable. Read-only on product code."""
import sys, os, json, time, argparse, pickle
sys.path.insert(0, 'code')
import cv2, numpy as np
from feature_matcher import ProductFeatureMatcher
def sku_of(name):
p = name.split('_'); return '_'.join(p[:2])
def crops(image_path, labels, live, bug=False, long_side=1280):
im = cv2.imread(image_path); f = 1.0
if live:
h, w = im.shape[:2]; f = long_side / max(h, w)
im = cv2.resize(im, (int(w*f), int(h*f)), interpolation=cv2.INTER_AREA)
out = []
for fc in labels['facings']:
b = fc['box'] if bug else [int(round(v*f)) for v in fc['box']]
x1, y1, x2, y2 = b
out.append((fc, im[max(0,y1):y2, max(0,x1):x2]))
return out
def run(stock, image, labels, live, bug, method):
m = ProductFeatureMatcher(stock_dir=stock)
rows = []
for fc, c in crops(image, labels, live, bug):
if c.size == 0 or min(c.shape[:2]) < 8: rows.append(dict(index=fc["index"], truth=fc["label"], shape=c.shape[:2], scores={}, templ={}, sub={}, ms=0.0)); continue
t = time.perf_counter(); s, det = m.score_crop(c, method=method); dt = time.perf_counter()-t
g = {}
for k, v in s.items(): g[sku_of(k)] = max(g.get(sku_of(k), 0.0), v)
sub = {k: det[k].get('sub_details') for k in det}
rows.append(dict(index=fc['index'], truth=fc['label'], shape=c.shape[:2], scores=g, templ=s, sub=sub, ms=dt*1000))
return dict(stock=stock, live=live, bug=bug, method=method, enrolled=sorted({sku_of(k) for k in m.db.templates}), rows=rows)
if __name__ == '__main__':
ap = argparse.ArgumentParser(); ap.add_argument('--stock'); ap.add_argument('--labels'); ap.add_argument('--live', action='store_true')
ap.add_argument('--bug', action='store_true'); ap.add_argument('--method', default='hybrid'); ap.add_argument('--out')
a = ap.parse_args(); L = json.load(open(a.labels))
r = run(a.stock, 'add/image6.jpg', L, a.live, a.bug, a.method)
pickle.dump(r, open(a.out, 'wb'))
print(a.out, len(r['rows']), 'rows', 'median ms', round(float(np.median([x['ms'] for x in r['rows']])),1))