product-recognition-demo/recognization/docs/research/2026-09-29-shelf-intelligence/analyze.py
tngo 0cf64a18bf Coverage-first live labelling, merged catalog, shelf-intelligence decision
The live path abstained too much: a detected product whose best candidate
missed the accept line was left blank, so a shelf where 59% of in-catalog
facings were shown read as broken. Show the best candidate immediately as
`provisional`, keep the track re-classifiable so a later attempt upgrades or
corrects it, and never blank a label already on screen.

- identity.py / identity_lanes.py: every decision now carries
  `provisional_sku` - the row the evidence supports well enough to show,
  separate from `best_sku`, which still means "cleared the gate". The accept
  line does not move: far-unknown false accepts stay at 0.066%.
- live.py: a below-gate candidate labels the track with
  `identity_level: provisional` and `confirmed = False`, so the planner and
  the crop lane keep offering it. Accepted labels stay frozen. Inherited
  provisional labels stay re-classifiable.
- Clients render provisional distinctly (amber chip, dashed box) via
  `LiveScanOverlayPolicy.isProvisional(identity)`, which now reads the wire
  level instead of only the state.
- build_stock_all.py merges stock, stock-lopia, stock-mediya and
  stock-milk150 into stock-all (111 images, 60 SKUs). The server could only
  read one folder, so anything outside it came back blank. stock-demo is
  byte-identical to mediya+milk150 and is not a source.

Measured on add/image6.jpg at live resolution: in-catalog facings shown
59.4% -> 86.0%, correct shown 38 -> 55, precision 97.4% -> 87.3%. On the
wire at 8fps 0.5x: 65 of 79 tracks labelled, 742 accepted + 309 provisional
observations, first label 1.27s.

docs/research/2026-09-29-shelf-intelligence/ carries the experiments and the
decision: group signals (combined AUC 0.991 sibling / 0.990 unrelated),
OCR scheduling (group representatives cut calls 4.4x and wall 3.3x), and the
finding that group propagation damages more labels than it rescues on this
shelf, so groups schedule compute rather than overwrite labels.

331 tests pass.
2026-09-29 13:45:14 +09:00

267 lines
16 KiB
Python

"""A-D/F: paired policies, geometry-only rows, label-blind inference, oracle membership.
No production edits. All accuracy counters use the frozen observation file.
"""
from pathlib import Path
import sys
import json
import time
import itertools
import math
from collections import Counter
import numpy as np
ROOT=Path(__file__).resolve().parents[3]
sys.path.insert(0,str(ROOT/'code'))
from identity_lanes import DecisionPolicy
DIR=Path(__file__).resolve().parent
POLICIES={'strict':4.,'balanced':2.,'coverage-first':.5,'near-argmax':0.}
def decide(o, policy, signatures, use_text=True):
p=DecisionPolicy(local_accept=POLICIES[policy],signatures=signatures)
text=o['text'] if use_text else {}
read=o['read'] if use_text else ''
d=p.decide(o['rank'],text,text_read=read)
answer=d['best_sku']
# Near-argmax retains actual geometric candidates even when the confidence
# margin is negative; no arbitrary catalog row when there are no features.
if policy=='near-argmax' and not answer and o['rank']:
candidate=o['rank'][0][0]
tr=sorted(text.items(),key=lambda x:x[1],reverse=True)
if not p._contradicts_size(read,candidate) and not (tr and tr[0][0]!=candidate and tr[0][1]>text.get(candidate,0)):
answer=candidate
return answer
def pair_value(p, name):
s=p['signals']
if name=='combined':
return .35*s['hue']+.35*s['pixels']+.30*min(1,s['inliers']/10)
return s[name]
def geometric_edge(p):
s=p['signals']
return -.25<=s['gap']<=.75 and s['height']>=.65 and s['bottom']<=.25
def infer_groups(data, edge_yes, anchors):
"""Contiguous appearance-supported runs; strong contradiction is a hard cut."""
edges={(p['a'],p['b']):edge_yes[i] and geometric_edge(p) for i,p in enumerate(data['pairs'])}
groups=[]
for row in data['rows']:
group=[]; support=None
for item in row:
anchor=anchors.get(item)
if group and (not edges.get((group[-1],item),False) or (anchor and support and anchor!=support)):
groups.append(group); group=[]; support=None
group.append(item)
if anchor: support=anchor
if group: groups.append(group)
return groups
def strength(o, answer):
if not answer: return 0.
if o['rank'] and o['rank'][0][0]==answer:
return max(0,o['rank'][0][1])+2*o['text'].get(answer,0)
return 2*o['text'].get(answer,0)
def propagate(observations, groups, answers, signatures, guarded=False):
"""One strongest independently supported prototype, not majority voting.
Group evidence can rescue weak members, never overwrite strict contradiction.
"""
lookup={x['id']:x for x in observations}
result=dict(answers); rescued=[]
strict={i:decide(o,'strict',signatures) for i,o in lookup.items()}
guard=DecisionPolicy(signatures=signatures)
for group in groups:
seeds=[i for i in group if strict[i]]
if not seeds: continue
seed=max(seeds,key=lambda i:strength(lookup[i],strict[i]))
sku=strict[seed]
if guarded:
# Two distinct facings corroborate a seed, or one unusually strong
# geometric observation does. Repeated reads of one crop do not.
corroborated=sum(strict[i]==sku for i in seeds)>=2
strong_visual=bool(lookup[seed]['rank'] and lookup[seed]['rank'][0][0]==sku and lookup[seed]['rank'][0][1]>=8)
if not (corroborated or strong_visual): continue
# Size contradictions belong to the entire repeated-artwork run.
# Abstain from propagation rather than spread a lone OCR mistake.
if any(guard._contradicts_size(lookup[i]['read'],sku) for i in group): continue
for i in group:
o=lookup[i]
if strict[i] and strict[i]!=sku: continue
if guard._contradicts_size(o['read'],sku): continue
# Independently decisive text also splits a weak visual member.
text_sku=guard.decide([],o['text'],text_read=o['read'])['best_sku']
if text_sku and text_sku!=sku: continue
if result[i]!=sku:
rescued.append({'id':i,'seed':seed,'from':result[i],'to':sku})
result[i]=sku
return result,rescued
def metrics(observations, answers, families, before=None):
total=len(observations); known=sum(o['truth']!='unknown' for o in observations)
shown=correct=unknown=sibling=wrong_known=0
rescue=correction=damage=0
for o in observations:
i=o['id']; a=answers.get(i); t=o['truth']
shown+=bool(a); correct+=bool(a and a==t)
unknown+=bool(a and t=='unknown')
wrong_known+=bool(a and t!='unknown' and a!=t)
sibling+=bool(a and a!=t and t in families and families.get(a)==families[t])
if before is not None:
rescue+=bool(not before.get(i) and a==t)
correction+=bool(before.get(i) and before[i]!=t and a==t)
damage+=bool(before.get(i)==t and a!=t)
return {'facings':total,'catalog_facings':known,'shown':shown,'correct':correct,'wrong':shown-correct,
'visible_coverage':shown/total,'correct_coverage':correct/known,'precision':correct/shown if shown else None,
'false_label_rate':(shown-correct)/total,'unknown_false_accepts':unknown,'unknown_total':total-known,
'sibling_confusions':sibling,'wrong_catalog':wrong_known,'blank_ratio':1-shown/total,
'rescued_correct':rescue,'corrected_wrong':correction,'damaged_correct':damage}
def confusion(actual,predicted):
tp=sum(a and p for a,p in zip(actual,predicted)); fp=sum(not a and p for a,p in zip(actual,predicted))
tn=sum(not a and not p for a,p in zip(actual,predicted)); fn=sum(a and not p for a,p in zip(actual,predicted))
return {'tp':tp,'fp':fp,'tn':tn,'fn':fn,'precision':tp/(tp+fp) if tp+fp else None,'recall':tp/(tp+fn) if tp+fn else None,'boundary_accuracy':(tp+tn)/max(1,len(actual)),'boundary_recall':tn/(tn+fp) if tn+fp else None}
def auc(pos,neg):
return sum((a>b)+.5*(a==b) for a in pos for b in neg)/(len(pos)*len(neg)) if pos and neg else None
def fit_threshold(pairs, name):
# Weighted classification error, so long repeated runs cannot hide boundaries.
candidates=sorted(set(pair_value(p,name) for p in pairs))
candidates=[-1e-9]+[(a+b)/2 for a,b in zip(candidates,candidates[1:])]+[max(candidates,default=0)+1e-9]
def loss(t):
yes=[p for p in pairs if p['same']]; no=[p for p in pairs if not p['same']]
return sum(pair_value(p,name)<t for p in yes)/max(1,len(yes))+sum(pair_value(p,name)>=t for p in no)/max(1,len(no))
return min(candidates,key=lambda t:(loss(t),-t))
def chain_dp(data, edge_yes, observations, signatures):
"""Potts chain MAP by Viterbi, O(n*K^2), no framework or truth input."""
lookup={o['id']:o for o in observations}; skus=[None]+list(signatures)
edge={(p['a'],p['b']):edge_yes[i] and geometric_edge(p) for i,p in enumerate(data['pairs'])}
result={}
for row in data['rows']:
unary=[]
for i in row:
o=lookup[i]; values={s:e['inliers']*(.25+e['color']) for s,e in o['local'].items()}
maximum=max(values.values(),default=1.)
u=[.4]+[values.get(s,0)/maximum+.4*o['text'].get(s,0) for s in skus[1:]]
anchor=decide(o,'strict',signatures)
if anchor:
u=[v if s==anchor else v-4 for s,v in zip(skus,u)]
unary.append(np.asarray(u))
score=unary[0]; backs=[]
for n in range(1,len(row)):
reward=.6 if edge.get((row[n-1],row[n]),False) else 0.
table=score[:,None]+np.eye(len(skus))*reward
back=table.argmax(axis=0); score=unary[n]+table[back,np.arange(len(skus))]; backs.append(back)
best=int(score.argmax()); labels=[best]
for back in reversed(backs): best=int(back[best]); labels.append(best)
for i,k in zip(row,reversed(labels)): result[i]=skus[k]
return result
def main():
evidence=json.loads((DIR/'evidence/observations.json').read_text()); annotation=json.loads((DIR/'group-truth.json').read_text())
families=annotation['families']; report={'provenance':evidence['provenance'],'resolutions':{}}
for res,data in evidence['resolutions'].items():
observations=data['observations']; lookup={o['id']:o for o in observations}
keys={o['id']:o['truth'] for o in observations if o['truth']!='unknown'}
family=dict(families)
for g in annotation['unknown_visual_groups']:
keys.update({i:g['key'] for i in g['members']}); family[g['key']]=g['family']
scored=[]
for p in data['pairs']:
if p['a'] not in keys or p['b'] not in keys: continue
a,b=keys[p['a']],keys[p['b']]
scored.append({**p,'same':a==b,'class':'same' if a==b else 'sibling' if family[a]==family[b] else 'unrelated'})
signal_report={}
names=['hue','pixels','logo_pixels','edge','inliers','match_fraction','layout_cover','aspect','height','ocr_jaccard','combined']
predictions={}
for name in names:
thresholds={r:fit_threshold([p for p in scored if p['row']!=r],name) for r in range(len(data['rows']))}
predictions[name]=[pair_value(p,name)>=thresholds[p['row']] and geometric_edge(p) for p in data['pairs']]
byclass={c:[pair_value(p,name) for p in scored if p['class']==c] for c in ['same','sibling','unrelated']}
signal_report[name]={'thresholds_leave_row_out':thresholds,'same_vs_sibling_auc':auc(byclass['same'],byclass['sibling']),'same_vs_unrelated_auc':auc(byclass['same'],byclass['unrelated']), 'classes':{c:{'n':len(v),'p50':float(np.median(v)) if v else None} for c,v in byclass.items()},'held_row_confusion':confusion([p['same'] for p in scored],[pair_value(p,name)>=thresholds[p['row']] and geometric_edge(p) for p in scored])}
# Preselected combined arm; do not pick the best held-row score after seeing truth.
edge_yes=predictions['combined']
anchors={o['id']:decide(o,'strict',evidence['signatures']) for o in observations}
start=time.perf_counter(); groups=infer_groups(data,edge_yes,anchors); group_ms=(time.perf_counter()-start)*1000
# One boundary variable: correspondence is required in addition to the
# combined score. Stops color-only joins between different artworks.
guarded_edges=[yes and p['signals']['inliers']>=4 for yes,p in zip(edge_yes,data['pairs'])]
guarded_groups=infer_groups(data,guarded_edges,anchors)
appearance_groups=infer_groups(data,guarded_edges,{})
oracle=[]
for row in data['rows']:
run=[]; key=None
for i in row:
nextkey=keys.get(i,('unscored',i))
if run and key!=nextkey: oracle.append(run); run=[]
key=nextkey; run.append(i)
if run:oracle.append(run)
all_pairs=list(itertools.combinations(sorted(keys),2))
def memberships(gs): return {i:g for g,ids in enumerate(gs) for i in ids}
membership=memberships(groups); oracle_membership=memberships(oracle)
group_eval=confusion([oracle_membership[a]==oracle_membership[b] for a,b in all_pairs],[membership[a]==membership[b] for a,b in all_pairs])
guarded_membership=memberships(guarded_groups)
guarded_eval=confusion([oracle_membership[a]==oracle_membership[b] for a,b in all_pairs],[guarded_membership[a]==guarded_membership[b] for a,b in all_pairs])
boundary_eval=confusion([p['same'] for p in scored],[guarded_membership[p['a']]==guarded_membership[p['b']] for p in scored])
results={}; answers_output={}
for policy in POLICIES:
base={o['id']:decide(o,policy,evidence['signatures']) for o in observations}
results[policy]={}; answers_output[policy]={}
for mode,gs in [('individual',[[o['id']] for o in observations]),('oracle',oracle),('inferred',groups),('seed_guard',groups),('boundary_guard',guarded_groups),('guarded',guarded_groups)]:
start=time.perf_counter(); answers,changed=propagate(observations,gs,base,evidence['signatures'],guarded=mode in ('seed_guard','guarded')) if mode!='individual' else (base,[]); fusion_ms=(time.perf_counter()-start)*1000
results[policy][mode]={**metrics(observations,answers,families,base),'changes':changed,'fusion_ms':fusion_ms}
answers_output[policy][mode]=answers
start=time.perf_counter(); dp=chain_dp(data,edge_yes,observations,evidence['signatures']); dp_ms=(time.perf_counter()-start)*1000
results['dp']={**metrics(observations,dp,families,answers_output['coverage-first']['individual']),'inference_ms':dp_ms}
# Utility sensitivity, static one-second exposure proxy; temporal loss is in replay.
sensitivity={}
for wrong_cost in [.5,1.,2.,4.,8.]:
cells={f'{p}/{mode}':(m['facings']-m['shown']+wrong_cost*m['wrong'])/m['facings'] for p,arms in results.items() if p!='dp' for mode,m in arms.items()}
sensitivity[str(wrong_cost)]={'loss':cells,'best':min(cells,key=cells.get)}
deployable={k:v for k,v in cells.items() if not k.endswith('/oracle')}
sensitivity[str(wrong_cost)]['best_nonoracle']=min(deployable,key=deployable.get)
report['resolutions'][res]={'pair_counts':dict(Counter(p['class'] for p in scored)), 'signals':signal_report,'pairs':scored,'groups':groups,'oracle_groups':oracle,'group_ms':group_ms,'group_membership':group_eval,'policies':results,'answers':answers_output,'utility_sensitivity':sensitivity}
report['resolutions'][res]['guarded_groups']=guarded_groups
report['resolutions'][res]['appearance_groups']=appearance_groups
report['resolutions'][res]['guarded_group_membership']=guarded_eval
report['resolutions'][res]['guarded_adjacent_boundaries']=boundary_eval
report['resolutions'][res]['guarded_edges']=guarded_edges
print(res,'groups',len(groups),'pair counts',report['resolutions'][res]['pair_counts'])
for p in POLICIES:
print(p,{mode:(m['shown'],m['correct'],m['wrong'],m['rescued_correct'],m['damaged_correct']) for mode,m in results[p].items()})
(DIR/'evidence/analysis.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
plot(report)
def plot(report):
# SVG uses stdlib only; both axes and denominators are explicit.
colors={'strict':'#475569','balanced':'#2563eb','coverage-first':'#059669','near-argmax':'#dc2626'}
parts=['<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="510" viewBox="0 0 1000 510"><rect width="1000" height="510" fill="white"/>']
for j,res in enumerate(['full','live']):
x0=70+j*490; y0=405
parts.append(f'<text x="{x0}" y="30" font-size="18">{res}: visible coverage / precision</text><path d="M{x0},50 V405 H{x0+390}" fill="none" stroke="black"/>')
for t in [0,.25,.5,.75,1]:
parts.append(f'<text x="{x0+390*t-10}" y="430" font-size="12">{t:.0%}</text><text x="{x0-40}" y="{y0-350*t}" font-size="12">{t:.0%}</text>')
for p in POLICIES:
for mode in ['individual','guarded']:
m=report['resolutions'][res]['policies'][p][mode]; x=x0+390*m['visible_coverage']; y=y0-350*(m['precision'] or 0)
parts.append(f'<circle cx="{x}" cy="{y}" r="5" fill="{colors[p] if mode=="guarded" else "white"}" stroke="{colors[p]}"><title>{p}/{mode}: {m["shown"]}/109 shown, {m["correct"]}/64 correct, {m["wrong"]} false</title></circle>')
parts.append(f'<text x="{x0}" y="458" font-size="12">Any labels / 109; filled = guarded groups, hollow = individual</text>')
for n,(policy,color) in enumerate(colors.items()):
parts.append(f'<text x="{70+n*225}" y="491" font-size="14" fill="{color}">{policy}</text>')
parts.append('</svg>');(DIR/'evidence/frontier.svg').write_text(''.join(parts))
if __name__=='__main__': main()