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.
This commit is contained in:
parent
25dbf132e7
commit
aaed5d388f
44 changed files with 7847 additions and 60 deletions
|
|
@ -0,0 +1,71 @@
|
|||
"""Catalog growth: packshots + N distractor references from unrelated shelves.
|
||||
Compares exhaustive local matching vs a global RootSIFT descriptor index
|
||||
(vote -> shortlist K -> geometric verification). No DINO pass."""
|
||||
import sys,os,glob,json,time,numpy as np,cv2,random
|
||||
sys.path.insert(0,'code'); sys.path.insert(0,os.path.dirname(__file__)); os.environ['YOLO_OFFLINE']='1'
|
||||
from detector import ShelfProductDetector
|
||||
from diag import crops, sku_of
|
||||
from PIL import Image
|
||||
S=os.path.dirname(__file__); random.seed(0)
|
||||
sift=cv2.SIFT_create(nfeatures=2000)
|
||||
def root(d):
|
||||
if d is None: return None
|
||||
d=d/(np.abs(d).sum(1,keepdims=True)+1e-7); return np.sqrt(d).astype(np.float32)
|
||||
def feat(img,h):
|
||||
if h and img.shape[0]>h: img=cv2.resize(img,(max(1,int(img.shape[1]*h/img.shape[0])),h),interpolation=cv2.INTER_AREA)
|
||||
g=cv2.cvtColor(img,cv2.COLOR_BGR2GRAY); kp,d=sift.detectAndCompute(g,None); return kp,root(d),g.shape
|
||||
# references
|
||||
refs=[]
|
||||
for p in sorted(glob.glob('stock-lopia-clean/*.jpg')):
|
||||
img=cv2.imread(p); k=sku_of(os.path.basename(p)[:-4])
|
||||
for h in (200,400): kp,d,sh=feat(img,h); refs.append((k,kp,d,sh))
|
||||
det=ShelfProductDetector(); dis=[]
|
||||
for p in sorted(glob.glob('add/*')):
|
||||
if 'image6' in p: continue
|
||||
im=cv2.cvtColor(np.array(Image.open(p).convert('RGB')),cv2.COLOR_RGB2BGR)
|
||||
for j,b in enumerate(det.detect(im).get('boxes',[])):
|
||||
x1,y1,x2,y2=[int(v) for v in b['box']]; c=im[max(0,y1):y2,max(0,x1):x2]
|
||||
if min(c.shape[:2])>=40: dis.append((f'd_{os.path.basename(p)}_{j}',c))
|
||||
random.shuffle(dis); print('distractor refs available',len(dis))
|
||||
L=json.load(open(f'{S}/image6_v2.json')); Q=[(fc,c) for fc,c in crops('add/image6.jpg',L,True) if fc['label']!='exclude' and min(c.shape[:2])>=8]
|
||||
Qf=[(fc['label'],)+feat(c,None) for fc,c in Q]
|
||||
bf=cv2.BFMatcher(cv2.NORM_L2)
|
||||
def verify(kq,dq,r):
|
||||
k,kp,d,sh=r
|
||||
if dq is None or d is None or len(d)<2 or len(dq)<2: return 0
|
||||
good=[m for m,n in (p for p in bf.knnMatch(dq,d,k=2) if len(p)==2) if m.distance<0.75*n.distance]
|
||||
if len(good)<4: return 0
|
||||
src=np.float32([kq[m.queryIdx].pt for m in good]); dst=np.float32([kp[m.trainIdx].pt for m in good])
|
||||
H,mask=cv2.estimateAffinePartial2D(src,dst,method=cv2.RANSAC,ransacReprojThreshold=0.02*max(sh))
|
||||
return int(mask.sum()) if mask is not None else 0
|
||||
E={r[0] for r in refs}
|
||||
for N in [0,50,200,len(dis)]:
|
||||
R=refs+[(k,)+feat(c,None) for k,c in dis[:N]]
|
||||
# exhaustive
|
||||
t=time.perf_counter(); ok=0; fa=0; n=0
|
||||
best_all=[]
|
||||
for truth,kq,dq,_ in Qf:
|
||||
sc={}
|
||||
for r in R: sc[r[0]]=max(sc.get(r[0],0),verify(kq,dq,r))
|
||||
b=max(sc,key=sc.get); best_all.append(sc)
|
||||
if truth in E: n+=1; ok+= b==truth
|
||||
ex_ms=(time.perf_counter()-t)*1000/len(Qf)
|
||||
# descriptor index
|
||||
D=np.vstack([r[2] for r in R if r[2] is not None]); owner=np.concatenate([[i]*len(r[2]) for i,r in enumerate(R) if r[2] is not None])
|
||||
t0=time.perf_counter(); fl=cv2.FlannBasedMatcher(dict(algorithm=1,trees=4),dict(checks=64)); fl.add([D]); fl.train(); build=time.perf_counter()-t0
|
||||
rec={5:0,10:0,20:0}; t=time.perf_counter(); ok_idx=0
|
||||
for truth,kq,dq,_ in Qf:
|
||||
votes={}
|
||||
if dq is not None and len(dq)>=2:
|
||||
for m,nn in (p for p in fl.knnMatch(dq,k=2) if len(p)==2):
|
||||
if m.distance<0.8*nn.distance:
|
||||
k=R[owner[m.trainIdx]][0]; votes[k]=votes.get(k,0)+1
|
||||
order=sorted(votes,key=lambda k:-votes[k])
|
||||
if truth in E:
|
||||
for K in rec: rec[K]+= truth in order[:K]
|
||||
short=set(order[:10]); sc={}
|
||||
for r in R:
|
||||
if r[0] in short: sc[r[0]]=max(sc.get(r[0],0),verify(kq,dq,r))
|
||||
if truth in E and sc: ok_idx+= max(sc,key=sc.get)==truth
|
||||
ix_ms=(time.perf_counter()-t)*1000/len(Qf)
|
||||
print(f'refs={len(R):4d} skus={len({r[0] for r in R}):4d} | exhaustive top1 {ok}/{n} {ex_ms:6.1f} ms/crop | index recall@5/10/20 {rec[5]}/{rec[10]}/{rec[20]} of {n}, index+verify(K=10) top1 {ok_idx}/{n} {ix_ms:6.1f} ms/crop, build {build*1000:.0f} ms, {len(D)} descriptors')
|
||||
Loading…
Add table
Add a link
Reference in a new issue