Commit graph

13 commits

Author SHA1 Message Date
kiyo9w
7736465bee docs(decision): correct the record against the re-measured evidence
The audit's own numbers were right; several claims built on them were not, and
the advisors found each one:

- 'wins both axes at both resolutions' - false at live, where the lane trades
  59% coverage for 93% precision against Van's 66% at 39%. Stated as the trade
  it is.
- G1's bootstrap now exists: the local lane's correct set is a strict superset
  of Van's on the stream, so the 95% lower bound is a hard +0.0 pp - a floor of
  that pairing, not a margin. tracks_final.json is level with Van and does not
  clear it.
- The far-unknown rate is 0.066% on 4580 crops at the shipped configuration,
  not 0.30% on 2650 at the old one.
- The E-3 ablation compared text-presence *and* a different argmax; both arms
  now go through the same DecisionPolicy ordering. Text adds 9-10 pp of
  coverage at full resolution while raising precision.
- The sweep table swept one axis while pinning the other; replaced with the
  joint sweep.
- The two Re-measured tables are dated snapshots, labeled as such.
2026-09-29 09:28:19 +09:00
kiyo9w
07de2bd37d fix(identity): calibrate both accept lines against unrelated shelves
The gates were tuned on image6 alone, where every crop is a facing of a product
the shelf actually holds. Wikimedia supermarket photos of milk, soy and snack
shelves share nothing with the LOPIA catalog, so every accept there is a false
accept by construction - and at the old lines there were 83 of them.

Text lane: TEXT_MIN_SHARED 2 -> 4. The vocabulary filter drops bigrams no name
contains, so a short garbage read can end up with a perfect ratio on two
coincidental pairs. 20 of 26 text-lane answers on those shelves rested on
exactly two: '避くて おいしい' named sku_l12 through おい/いし.

Local lane: LOCAL_ACCEPT 2.0 -> 4.0. The residual accepts were geometric
matches with no text overlap at all, scoring 2.26-4.81.

Measured, 2297 distractor crops and 109 image6 facings:
  accept  floor   image6 shown/correct   precision   false accepts
  2.0     2       54 / 49                90.7%       83 (3.61%)
  2.0     4       52 / 48                92.3%       30 (1.31%)
  4.0     4       44 / 42                95.5%        8 (0.35%)
  5.0     6       36 / 36               100.0%        0 (0.00%)

4.0/4 is the only region passing both G2 (>=95% precision) and G3 (<=1%
far-unknown) with coverage left. On the shipped evaluator: 66% end-to-end at
95% precision, 2 false accepts, against 77%/91%/5 before.

The two tests that pinned the old floors now pin the new ones, with the reads
that separate them.
2026-09-29 04:08:59 +09:00
kiyo9w
ddead84546 fix(identity): veto a local answer the read contradicts on package size
The documented near-unknown case could not be rejected. Crops 82-97 of
add/image6.jpg are Kewpie 450g bags the catalog does not hold, and they read
'マヨネーズ 450g'. Every mayonnaise row explains 'マヨネーズ' equally, so the
text ranking ties at 1.0 and the strict-greater veto in DecisionPolicy never
fires - the local lane's answer stood and the bag was labelled as a catalog
row. No amount of IDF tuning fixes this: the veto compares two catalog rows,
and the crop's product is not one of them.

The size is the part that differs and the part printed plainly, so the policy
now checks it. Only a mass in PACKAGE_MASS_RANGE counts: below it are
fragments of a longer number ('20' out of '200g') and above it are two numbers
run together ('5210' out of '210g'), and vetoing on either would cost correct
answers. Percentages are excluded for the same reason - 'カロリー 50% カット'
is a claim about the contents, not a 50 g package.

The veto also blocks the text lane's fill-in, or it would only move the
answer: the local row is blocked and the text lane then names that same row.

Measured on the corrected image6 labels, local lane at full resolution:
precision 86% -> 91%, false accepts 8 -> 5, coverage unchanged at 79%. At live
resolution 95% and 2 false accepts, unchanged. 307 tests pass.
2026-09-29 03:04:21 +09:00
bf9c04bce4 Improve identity lanes: text evidence floor, motion-compensated labels
The text lane now requires shared bigrams between the read and the winning
row's name before it will answer, and the local lane passes the catalog
signatures through so the decision can weigh text evidence. On the labeled
shelf this moves the local lane from 66% to 80% end-to-end at full
resolution (91% precision) and 66% at live resolution (95% precision, 2
false accepts), against 57%/35% for the matcher alone.

Live fixes:
- `_plan_classification` counts `in_flight` again. The increment had been
  dropped while removing a `label_frame_id` write, so the guard never
  blocked, every frame re-planned the same tracks, and the executor queue
  grew without bound until the server stopped answering.
- Label memory boxes carry the shelf origin they were measured against and
  are shifted before the IoU test, so a panning camera no longer slides a
  stale box onto its neighbour.
- The text lane is resolved before the session lock is taken, removing a
  lock-ordering hazard against the lane's own build lock.
- A native crop always reads; the frame crop skips OCR only when it already
  has a reading.

Stream (20 s pan, 4 runs): 71-79% coverage at 76-84% precision, against
62-71% at 73-75% before.
2026-09-29 02:08:23 +09:00
771e9285d7 Attach the catalog row to accepted still-image answers
`classify_boxes` returned only the identity, so every accepted still-image
answer reached the client as a bare SKU id with no name or price. Measured
through the real server on add/image6.jpg: 41 of 41 accepted detections had no
`product`. The live path attached the row; the still path did not, which is
the F-02 finding the decision document recorded as still live in production.

The row is attached where the decision is made, so both paths carry it, and
`recognize` passes it through to the wire. Verified through the running server:
41 of 41 accepted answers now carry product_name, price, and stock counts.

Also corrects the decision document's full-resolution claim, which was
measured before the reference-upscaling and text-signature fixes: it said 77%
precision with 11 false accepts and a hub alarm, where the current lane reads
88% with 5 and no alarm. The accept line does not need to differ by input path
- correct facings sit at p25 2.07 (full) and 2.87 (live), wrong ones at p75
1.00 and 1.66, so 2.0 is the boundary in both - and the results table and the
stream G1 numbers are refreshed to the current measurements.
2026-09-29 00:53:56 +09:00
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
b6c7c4b667 Score package text against the row's name, not its packshot's OCR
The text lane built each row's signature from its printed name plus the OCR of
its enrolled packshot, and scored a crop against that whole string. l09's
packshot prints "マヨネーズ", which is l10's actual name, so l09 could explain
an l10 crop's text better than l10's own name could. Every l10 facing reads
"ピュア セレクト マヨネーズ" with no "コクうま" and no "65", and l09 still won
or tied them.

The signature is now the row's name alone, weighted by inverse document
frequency over the catalog so copy every sibling prints counts for little and
the words that name one product dominate. The packshot OCR is gone: every row
has a name, so it added nothing but the confusion.

Measured on image6, local lane at live resolution:

  l10 correct    2/11 -> 5/11
  wrong          3 -> 1
  precision      95% -> 97%
  l09 hub share  34% (alarm) -> 29% (clear)

Full resolution end-to-end 59% at 88% precision, live 56% at 97%.
2026-09-29 00:37:56 +09:00
ff11529476 fix: cut whole quantity tokens, and gate the fill-in by default
Review 2, item 2. The first version of this gate counted bigrams with a
non-digit character, which let the unit's own letters through: "400g 50%"
left "0G" and "0%", "450g カロリー50%" left "0%", and "500ml" left
"0M". All three still named products.

The quantity token is now cut whole - digits plus unit, in either script -
before counting, and the floor is three bigrams rather than two, because
"カロリ" is two and it is a descriptor every low-calorie package prints.

DecisionPolicy.decide gated the fill-in only when a read was passed, so the
unsafe behaviour was the default. Omitting the read now means no fill-in: a
caller with a ranking and no read has not shown the text can name anything.
TextLane.carries_identity was a dead duplicate of the module function; deleted.

Tests: the five cases from the review plus the default-gate case. Suite 284 ->
286. Evaluator unchanged: local 61%/77% full, 59%/95% live.
2026-09-28 23:46:03 +09:00
fd72251160 feat: add Van's real baseline lane and stop size tokens naming products
Two review findings, both confirmed by measurement.

1. The evaluator had no always-argmax lane, so every "vs Van" claim compared
   against the *gated* matcher, which is not Van's contract - the original demo
   answered every crop with its best-scoring SKU and had no accept line. The new
   'van' lane is that contract: same scores, no gate. Against it the local lane
   wins both axes at both resolutions (full 61%/77% vs 52%/29%; live 59%/95% vs
   57%/32%), but the live coverage margin is one facing, not seven, and the 95%
   figure is live-resolution only - at full resolution the same lane reads 77%
   with 11 false accepts and a hub alarm.

2. A crop reading only "400g" scored 0.667 on three SKUs, above TEXT_ACCEPT, so
   the text lane named a product from a size line. The fill-in job now requires
   two non-digit bigrams; the veto is unchanged, so "65%" still contradicts a
   local answer. The raw OCR read travels with the ranking through
   score_crop_lanes for that check.

Tests: 3 for the size gate (policy + live) and the van lane's own row. Removing
the gate fails all three. Suite 276 -> 280.
2026-09-28 23:12:44 +09:00
890e186182 test: cover the local scale branch and the decision policy
The shipped default scale had no test: make_session only builds matcher-backend
engines, so scale == "local" in _on_classified never ran in the suite - and
that is the branch that was wrong twice (the rank re-sort, and the early return
that made the text lane's fill-in job unreachable).

Adds LocalScaleEngine, which injects the lanes' evidence into the live path, and
two live tests: a text-only read still labels the track, and a text veto blocks a
local accept. Adds DecisionPolicyTests for the veto/fill-in boundary, including
the lane-order-vs-value case that the rank re-sort broke.

Both regressions were re-injected to confirm the tests fail for the intended
reason: restoring the early return fails the fill-in test, and re-sorting the
lane output by value fails the order test. Suite 251 -> 261.
2026-09-28 22:41:24 +09:00
aaed5d388f 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.
2026-09-28 22:27:21 +09:00
kiyo9w
ac315eb48e fix: refuse unavailable gallery and pin head trainer 2026-09-28 14:41:57 +09:00
kiyo9w
90cce64cc8 test: defend engine and tracking contracts
test_engine: detection normalization, one-caller-at-a-time detection, method
validation, undecodable payloads, crop isolation, classify_boxes accepting the
detection dicts the API actually hands it, summary counting, empty-slot
normalization across frame sizes, the still-image store (roundtrip, TTL, bound),
catalog/stock behaviour, and template-image path resolution that refuses
anything outside the catalog.

test_tracking: per-session id numbering, the case that matters (constructing a
second tracker must not reset a running session's ids), independent sessions,
pan continuity, buffer expiry producing a new id, detection index mapping, and
reset scoping.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 12:43:32 +09:00