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

65 lines
4.5 KiB
Python

"""Matcher variants. Each variant returns per-SKU evidence dicts so ranking and
open-set features can be analysed separately."""
import sys, os, json, time, glob, pickle, argparse
import cv2, numpy as np
sys.path.insert(0, os.path.dirname(__file__)); from diag import crops, sku_of
class Matcher:
def __init__(self, stock, tmpl_h=None, root=False, geom='homog', nfeat=2000, scales=(None,)):
self.sift = cv2.SIFT_create(nfeatures=nfeat); self.root = root; self.geom = geom
self.T = []
for p in sorted(glob.glob(f'{stock}/*.jp*g')+glob.glob(f'{stock}/*.png')):
img = cv2.imread(p); name = os.path.splitext(os.path.basename(p))[0]
for h in scales:
im = img if h is None else cv2.resize(img, (max(1,int(img.shape[1]*h/img.shape[0])), h), interpolation=cv2.INTER_AREA)
g = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY); kp, des = self.sift.detectAndCompute(g, None)
des = self._root(des)
hsv = cv2.cvtColor(im, cv2.COLOR_BGR2HSV)
self.T.append(dict(name=name, sku=sku_of(name), kp=kp, des=des, shape=g.shape, hist=self._hist(hsv)))
self.bf = cv2.BFMatcher(cv2.NORM_L2)
def _root(self, des):
if des is None or not self.root: return des
des = des / (np.abs(des).sum(1, keepdims=True) + 1e-7); return np.sqrt(des).astype(np.float32)
@staticmethod
def _hist(hsv):
m = (hsv[...,1] > 60) & (hsv[...,2] > 50) # chromatic pixels only
h = cv2.calcHist([hsv], [0], m.astype(np.uint8), [18], [0, 180]).ravel()
return h / (h.sum() + 1e-6)
def score(self, crop):
g = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY); kq, dq = self.sift.detectAndCompute(g, None); dq = self._root(dq)
hq = self._hist(cv2.cvtColor(crop, cv2.COLOR_BGR2HSV))
ev = {}
for t in self.T:
e = dict(inliers=0, cover=0.0, color=float(np.minimum(hq, t['hist']).sum()), nq=len(kq))
if dq is not None and t['des'] is not None and len(dq) >= 2 and len(t['des']) >= 2:
good = [m for m, n in (p for p in self.bf.knnMatch(dq, t['des'], k=2) if len(p) == 2) if m.distance < 0.75*n.distance]
if len(good) >= 4:
src = np.float32([kq[m.queryIdx].pt for m in good]); dst = np.float32([t['kp'][m.trainIdx].pt for m in good])
if self.geom == 'homog':
H, mask = cv2.findHomography(src, dst, cv2.RANSAC, 0.02*max(t['shape']))
else:
H, mask = cv2.estimateAffinePartial2D(src, dst, method=cv2.RANSAC, ransacReprojThreshold=0.02*max(t['shape']))
if mask is not None:
inl = mask.ravel().astype(bool); n = int(inl.sum())
if n >= 3:
pts = dst[inl]; hull = cv2.convexHull(pts)
e['inliers'] = n; e['cover'] = float(cv2.contourArea(hull) / (t['shape'][0]*t['shape'][1]))
# crop-side coverage too: does the match span the query?
qh = cv2.convexHull(src[inl]); e['qcover'] = float(cv2.contourArea(qh)/(g.shape[0]*g.shape[1]))
k = t['sku']
if k not in ev or e['inliers'] > ev[k]['inliers']: ev[k] = e
return ev
if __name__ == '__main__':
ap = argparse.ArgumentParser(); ap.add_argument('--stock'); ap.add_argument('--labels'); ap.add_argument('--live', action='store_true')
ap.add_argument('--tmpl-h', type=int, default=None); ap.add_argument('--root', action='store_true'); ap.add_argument('--geom', default='homog')
ap.add_argument('--scales', default=''); ap.add_argument('--out')
a = ap.parse_args(); L = json.load(open(a.labels))
scales = tuple(int(s) for s in a.scales.split(',')) if a.scales else ((a.tmpl_h,) if a.tmpl_h else (None,))
t0 = time.perf_counter(); m = Matcher(a.stock, root=a.root, geom=a.geom, scales=scales); init = time.perf_counter()-t0
rows = []
for fc, c in crops('add/image6.jpg', L, a.live):
if c.size == 0 or min(c.shape[:2]) < 8: rows.append(dict(index=fc['index'], ev={}, ms=0)); continue
t = time.perf_counter(); ev = m.score(c); rows.append(dict(index=fc['index'], ev=ev, ms=(time.perf_counter()-t)*1000, shape=c.shape[:2]))
pickle.dump(dict(rows=rows, enrolled=sorted({t['sku'] for t in m.T}), init_s=init, args=vars(a)), open(a.out, 'wb'))
print(a.out, 'init', round(init,2), 'median ms', round(float(np.median([r['ms'] for r in rows if r['ms']])),1))