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.
267 lines
16 KiB
Python
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()
|