product-recognition-demo/recognization/docs/research/2026-09-28-recognition-architecture/evidence/scale.py
tngo b6c87c45ea Make the catalog hash honest, share the text helpers, drop the duplicate labels
`catalog_hash` hashed the CSV and the view *names*, so replacing
`sku_l09.jpg` with a different product's packshot left the hash unchanged and
a report could claim the same catalog while every l09 decision in it had
moved. It hashes each view's bytes now.

`identity_lanes` carried its own `normalize_text` and `bigrams`, near-copies
of the ones in `rebuild_catalog` that had already drifted (the lane's table
folded ユ to itself, the catalog's did not). The lane imports the catalog's
now, so one read scores the same either way.

`evidence/image6_v2.json` was byte-identical to `eval/labels/image6.json`; the
evidence scripts and the decision document point at the canonical file and the
copy is gone. `score_crop`'s docstring now states the contract its callers
already depend on: the dict's insertion order is the lane's ranking, and
re-sorting by value names a different SKU than the lane chose.

Tests: the lane is shared by the classify pool, so a concurrent-ranking test
pins that one BFMatcher does not cross crops under eight threads.
2026-09-29 00:48:03 +09:00

71 lines
4 KiB
Python

"""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}/../../../../eval/labels/image6.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')