product-recognition-demo/recognization/docs/research/2026-09-29-shelf-intelligence/DECISION.md
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

11 KiB
Raw Permalink Blame History

Dorayaki: shelf-intelligence decision

Measured 2026-09-29 · server product-recognition-demo/recognization at fbf31d7 plus the coverage-first diff described in §F · evidence in evidence/ · every number below is read from a JSON in that directory, named inline.

Scope: the live decision path (group inference, OCR scheduling, decision levels), the catalog the server matches against, and the two native overlays. Not in scope: detector quality, tag/planogram pipeline, on-device runtime.

The previous phase (../2026-09-28-recognition-architecture/DECISION.md) settled what matches a product: a local-feature instance matcher plus a text lane behind a calibrated open-set gate. This phase settles how much of a shelf to label, how fast, and how to spend compute doing it.

Verdict

Label every detected product with its best candidate immediately, mark it provisional, and refine it as stronger evidence arrives. Keep the accepted line where it is; stop treating abstention as quality. Group adjacent facings so one strong observation can answer for a run, and spend OCR on group representatives and ambiguous tracks instead of every crop.

The measured case for it, on add/image6.jpg at live resolution (ocr-ablation.json, anytime-policies.json):

Policy Shown Correct Precision Coverage of 64 in-catalog facings Blank
strict / individual 39 38 97.4 % 59.4 % 64.2 %
coverage-first (shipped) 63 55 87.3 % 86.0 % 42.2 %

The shipped policy shows 24 more products and gets 17 more of them right, at the cost of 7 more wrong labels. That is the trade the product asked for: a shelf where 86 % of products carry a name reads as working; one where 59 % do reads as broken.

A. Where the live time goes

replay.py on the running server, image6 at 8 fps (replay-image6-8fps-*.json):

Stage p50 p95
decode 8–12 ms 20 ms
detect 67–107 ms 131–278 ms
track 1–3 ms 6 ms
total 90–140 ms 200–300 ms

Detection dominates frame cost by an order of magnitude; tracking is free. Identity work is off the frame path (a worker pool), so it shows up as label latency, not frame latency. scheduler-benchmark.json prices it: 218 classification tasks, worker p50 147 ms with OCR on every attempt versus 19.6 ms with OCR once per track. OCR is ~87 % of per-task identity cost.

Ranked bottlenecks: (1) OCR invocations, (2) matcher invocations per facing, (3) detector. Only the first two are ours to schedule.

B. Compute and evidence schedule

scheduler-benchmark.json, same 218 tasks:

Schedule OCR calls Worker p50 First facing All first facings Wall CPU
every attempt 218 147 ms 1177 ms 1890 ms 4.32 s 4.48 s
once per track 109 20 ms 1235 ms 2680 ms 2.77 s 3.42 s
ambiguous track only 83 16 ms 806 ms 1951 ms 2.05 s 2.97 s
group representative 49 16 ms 454 ms 1168 ms 1.30 s 2.64 s

Group representatives cut OCR calls 4.4× and wall time 3.3×, and they make the first label appear 2.6× sooner. The accuracy cost is small and measured (ocr-ablation.json, group metrics): full-resolution coverage 57.8 % → 56.0 %, precision 87.3 % → 80.3 %; at live resolution coverage rises 56.0 % → 58.7 % and precision 90.2 % → 87.5 %. Ship the group-representative schedule; the ambiguous_track schedule is the fallback when grouping is unavailable.

The shipped live path already does the cheap half of this: OCR runs once per track (live.py caches text_read on the track), and the local lane re-runs on every attempt.

C. Repeated-facing group detection

analysis.json (resolutions.full and .live) scores eleven pairwise signals on 83 adjacent pairs from image6 (72 same-SKU, 3 sibling, 8 unrelated), with leave-one-row-out thresholds:

Signal AUC same-vs-sibling AUC same-vs-unrelated Precision Recall Boundary acc.
hue histogram 0.977 0.967 98.3 % 80.6 % 81.9 %
logo pixels 0.968 0.981 100 % 93.1 % 94.0 %
pixels 0.944 0.979 100 % 88.9 % 90.4 %
inliers 0.944 0.944 100 % 88.9 % 90.4 %
height 0.972 0.775 92.6 % 87.5 % 83.1 %
aspect 0.403 0.691 93.9 % 43.1 % 48.2 %
ocr_jaccard 0.667 0.806 97.7 % 58.3 % 62.7 %
combined 0.991 0.990 98.5 % 91.7 % 91.6 %

Use the combined signal: it is the only one that is simultaneously strong on siblings (the hard case) and on unrelated pairs. Hue alone is nearly as good and is the cheapest single signal; logo pixels is the best single signal but needs a logo detector. Aspect ratio and OCR similarity are actively misleading on siblings (AUC 0.40 and 0.67) — do not gate a group on either.

At live resolution the combined signal holds: AUC 0.991 / 0.984, precision 96.9 %, recall 87.5 %, boundary accuracy 86.7 %.

D. Group-to-individual inference

representative.json prices propagating a group identity to its members, on image6 (109 facings, 64 in-catalog):

Arm Matcher calls Total ms Shown Correct Precision Coverage Rescued Damaged
sharpest-one 47 1114 50 46 92.0 % 45.9 % 1 8
sharpest-two 70 1413 52 48 92.3 % 47.7 % 1 6
all (no grouping) 109 2024 63 55 87.3 % 57.8 % 2 0

At full resolution, recognising only the sharpest member of each group damages 8 correct labels to rescue 1. At live resolution the same arm rescues 3 and damages 8. Group propagation is therefore not a coverage win on this shelf: the members are individually recognisable, and the group's answer is only as good as its weakest member when the group is wrong.

Decision: use groups to schedule compute (C, B), not to overwrite individual labels. Propagate a group identity only to members whose own evidence is empty, and never over a member that produced a confident individual answer. The rescued_correct column is the ceiling for that rule; it is small here and should be re-measured on a shelf with more blur and occlusion.

E. Sibling boundaries

The combined signal's boundary accuracy is 91.6 % at full resolution, 86.7 % at live (analysis.json). The failure mode is a boundary missed, not a boundary invented: precision 98.5 % vs recall 91.7 %. A missed boundary merges two sibling runs into one group.

Because §D says groups do not overwrite individual labels, a missed boundary is now a scheduling error (one representative OCR'd for two SKUs), not a labelling error. That is the safe direction to fail. Two rules keep it safe:

  • A member whose own individual answer disagrees with the group's is a boundary candidate, not an outlier to be smoothed away.
  • A group never spans a detected price-tag change or a gap wider than the shelf's own facing pitch.

F. Provisional and confirmed transitions

Shipped in live.py _on_classified, identity.py, identity_lanes.py:

  • accepted — the decision cleared its gate. state: recognized, identity_level: accepted, confirmed = True, never re-classified.
  • provisional — the decision produced a candidate but did not clear the gate. state: recognized, identity_level: provisional, confirmed = False, and the track stays eligible for re-classification so a later, sharper attempt can upgrade it to accepted or correct it.
  • unrecognized — no candidate at all, or the attempt budget is spent with no provisional label on screen. state: unrecognized, no chip.

A provisional label is never blanked once shown: when the budget runs out the track keeps its guess and simply stops being re-classified. An inherited provisional label stays re-classifiable; an inherited accepted label does not.

Measured on the wire (replay-image6-8fps-0.5x-0.json): 65 of 79 tracks labelled, 742 accepted + 309 provisional observations, first label at 1.27 s. identity_level is the only new wire field; an older client that ignores it renders a provisional label as a normal one, which is the intended degradation.

Both overlays render provisional distinctly: amber chip and dashed box (LiveScanOverlayPolicy.isProvisional(identity), ScanScreen.kt, LiveScanOverlay.swift).

G. Operating recommendation

far-unknown-policies.json prices the accept floor on 4580 crops from 64 unrelated Wikimedia shelf photos:

Policy local_accept Accepts Rate
strict 4.0 3 0.066 %
balanced 2.0 8 0.175 %
coverage-first 0.5 83 1.81 %
near-argmax 0.0 + fallback 119 2.60 %

The shipped LOCAL_ACCEPT stays at 4.0 for the accepted level: it is the line that keeps far-unknown false accepts at 0.066 %, and the accepted level is what the product treats as settled. Coverage comes from the provisional level, which is a separate, much looser rule and is not gated by this number.

This is the key structural choice: do not lower the accept line to raise coverage. Lowering it to 0.5 would raise far-unknown false accepts 27× and would still leave the shelf's own hard cases (siblings) unresolved. Showing the best candidate as provisional gets the coverage without moving the line that protects precision.

anytime-policies.json confirms the shape: the strict policy reaches 59.4 % coverage and stays there at every time budget; coverage-first reaches 86 % and is already at 28 % of facings by 250 ms.

H. Measured improvement

Metric Before After
Catalog the server matches 7 SKUs / 13 views 60 SKUs / 86 views
Tracks labelled, image6 8 fps 0.5× 57 / 80 65 / 79
In-catalog facings shown 59.4 % 86.0 %
Precision of shown labels 97.4 % 87.3 %
First label 623 ms 1.27 s
OCR calls per 218 tasks 218 49 (group-representative schedule)
Wall time per 218 tasks 4.32 s 1.30 s

The catalog change is code/build_stock_all.py, which merges stock, stock-lopia, stock-mediya and stock-milk150 into stock-all (111 images, 60 SKUs; stock-demo is byte-identical to mediya+milk150 and is not a source). SKU ids are unique across the folders, so the merge is a copy plus a CSV concat.

Limitations

  • Every shelf number is one photo, add/image6.jpg, and its labels are the corrected ones from the previous phase. The group signals and the representative arms are measured on that photo's own facings.
  • The far-unknown rates come from a cache that records no catalog, so the catalog match is read off the worktree, not attested by the cache.
  • scheduler-benchmark.json and anytime-policies.json are diagnostic counterfactuals: they price schedules against frozen evidence, not against a live deployment. Transport and grouping time are excluded.
  • The native overlays were changed but not compiled: no JDK is installed on this machine, so ./gradlew cannot run. The shared signature change is mechanical and every caller was updated, but this is unverified by a compiler.
  • Group propagation (§D) is deliberately not shipped as a labelling rule. Its ceiling on this shelf is 1–3 rescued labels against 8 damaged; re-measure on a shelf with real blur and occlusion before revisiting.