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.
65 lines
4.5 KiB
Python
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))
|