Commit graph

61 commits

Author SHA1 Message Date
kiyo9w
eca03e0c91 refactor: delete the legacy session model
Some checks failed
tests / model-free (pull_request) Has been cancelled
Two session models shared one process. `/v1/*` and `/v1/stream` are the live
one; `/api/sessions*` and `/api/compare_live_vs_still` were the earlier model,
still constructed on every `create_app()` through `build_legacy_scans` even
though nothing called them. The web client and `stream_client.py` speak only
`/v1`.

Deleted: `observe.py` (ScanService/PerceptionService/PerceptionAdapter),
`yolo_adapter.py` (imported only by the legacy builder), `pipeline.py` and
`test_suite.py` (the old single-image CLI, reachable only from each other),
`test_observe.py` (its Session* tests), the eight legacy route handlers,
`build_legacy_scans` with its two facades, `create_fastapi_app`, and the
`scans` parameter on `create_app`.

`IdentityRankingTests` moved to `test_identity.py` before `test_observe.py` was
deleted: those five ranking rules are the live path's contract, not the legacy
model's. `test_api.py` keeps every `/v1` and WebSocket test.

Verified: 246 tests OK (2 skipped), and a route dump of a freshly built app
shows no `/api/*` path with all nine `/v1` routes and the WebSocket intact.
2026-09-28 17:47:50 +09:00
kiyo9w
004212a8cf fix: gate the live cosine aggregate at the gallery's own line
Some checks failed
tests / model-free (pull_request) Has been cancelled
The previous commit stopped the live path re-gating gallery scores at
COSINE_ACCEPT, but it passed zero accept lines. `aggregate_votes` divides by the
whole observation window, so one rejected crop beside one accepted 0.45 averages
to 0.225 - and a zero gate labelled that, at a score the gallery itself calls
unknown.

The aggregate now clears the gallery's own line: `head_threshold` (0.40) when a
fine-tuned head is loaded, `threshold` (0.55) otherwise. The margin gate is
still the only extra gate, so a head match between 0.40 and 0.60 is labelled.

Regression test: a rejected crop followed by an accepted 0.45 must not label the
track (fails on the zero-gate version).

Verified: `python -m unittest discover -s code -p 'test_*.py'` - 258 tests, OK,
2 skipped.
2026-09-28 17:36:43 +09:00
kiyo9w
84ca113b90 fix: stop re-gating gallery scores in the live path
Some checks failed
tests / model-free (pull_request) Has been cancelled
`score_crop` returns only scores the gallery already accepted: the raw 0.55
line, or the fine-tuned head's own 0.40. The live path then ranked that
aggregate with `decide_identity(scale="cosine")`, whose defaults rewrite to
COSINE_ACCEPT 0.60 / COSINE_AMBIGUOUS 0.50 - a second, higher line. Every head
match between 0.40 and 0.60 was therefore refused or downgraded to ambiguous,
which is the live regression: the head's own training views score 0.57-0.77.

`engine.inspect` and `_classify_with_gallery` already avoid this by ranking
through `decision_for_match`, which applies only the published margin. The live
path now does the same: on the cosine scale it passes explicit zero accept
lines, so the margin gate is the only extra gate.

Regression tests: a 0.45 head-scale match is labelled (fails on the previous
code), and a 0.45/0.44 pair inside the margin stays unnamed.

Verified: `python -m unittest discover -s code -p 'test_*.py'` - 257 tests, OK,
2 skipped.
2026-09-28 17:34:47 +09:00
kiyo9w
3614b23a2c fix: serialize gallery inference so Metal cannot be entered twice
The server segfaulted a few seconds into every live scan that used the crop
lane. The crash report put the fault in libtorch's Metal shader library
(`MetalShaderLibrary::exec_unary_kernel` inside an unordered_map insert),
reached from `copy_cast_kernel_mps` under `clone`/`reshape`.

Reproduced deterministically: eight threads calling `embed()` on a fresh
process crash it, while the same eight threads after one warm forward are
fine. Metal's shader library is process-global and is not thread-safe while
kernels are still being compiled into it, which is exactly what the first
forward does. The detector already serializes its predict for this reason;
the gallery did not.

Two changes:

* A module-level `_MPS_INFER_LOCK` covers the whole MPS region of a forward -
  model load, `.to(device)`, the forward, and the normalize that follows it.
  Normalize dispatches its own Metal kernels, so leaving it outside the lock
  left the same first-compile race open. Module-level because the state being
  protected is the process's, not one gallery's.
* A cache hit skips the embedding forward, so the model used to be built and
  the kernels compiled by whichever classify worker asked first - up to eight
  racing into one process-wide cache. The load path now warms the backbone
  single-threaded under the existing lock, on the cache-hit branch too. The
  readiness path loads the gallery before the socket accepts, so the warmup
  is free. It is Metal-only and best-effort: a CPU gallery has no shader
  library to compile, and a cache hit is served without a usable backbone by
  contract.

The regression test records overlap rather than timing: a fake backbone that
sleeps and counts concurrent entries, run from four threads, asserting the
count never exceeds one. It fails on the previous gallery.py and needs no MPS.
2026-09-28 17:22:55 +09:00
kiyo9w
aa25f99699 fix: bound and gate the crop evidence lane
The crop lane asked the phone for a full-resolution crop of a facing the
frame could not decide. Four defects made that unsafe or useless:

* A capability was half-applied. `crop: true` without a usable
  `max_crop_bytes` enabled the lane on the client's say-so, leaving the two
  sides believing different limits. A capability is now believed whole or
  refused with BAD_MESSAGE, and the limit must be an integer in 1..1048576
  (bool is an int in Python and is not a limit).
* A small JPEG could claim enormous dimensions. The header is now read
  without decoding and anything over 4 megapixels is refused before the
  decode allocates it.
* A request was single-use, so an expired one stranded a facing only the
  crop lane could decide. The lane now spends the same max_attempts budget
  the frame path spends, refilled on method change and on settled-label
  drift, and a phone that never answers still cannot loop.
* A candidate could be asked about from a box the frame path would refuse:
  clipped by the frame edge, carried over from an earlier frame, or measured
  at a score the tracker does not trust. Both lanes now share one edge gate,
  and only a track whose box this frame actually measured is asked about.

Drift and method change also cleared `identity` but left `confirmed = True`,
which permanently blocked both the crop lane and label inheritance for that
track; both now clear it, without which the budget reset was dead code.

Blur and glare are refused as whole-capture faults, while `too_far` is
exempt on purpose: a distant shelf is the one frame a native-resolution crop
exists to decide.
2026-09-28 17:22:52 +09:00
kiyo9w
02e69acac7 fix: require cached gallery rows to keep the enrolled order
A cache key proves the store, not the rows inside the file. The old check
asked only that every cached row appear somewhere in the enrolled list, so a
row moved across SKUs still passed: the matrix that travelled with it would
then be filed under the wrong product, and a valid key would rename a SKU.

Rows now walk the enrolled list forward and never backwards, so a moved,
repeated or foreign row has nowhere left to match. Views that could not be
decoded stay absent without disturbing the order of the ones after them,
which is what keeps a shorter cache a hit rather than a re-embed.
2026-09-28 17:22:46 +09:00
kiyo9w
13a4e0f475 fix: reject crop evidence after track drift 2026-09-28 16:11:37 +09:00
kiyo9w
0d0e9f82b9 feat: add bounded crop evidence and keyed gallery cache 2026-09-28 15:49:34 +09:00
kiyo9w
6cad638c26 fix: record and require the walk identity backend 2026-09-28 14:42:03 +09:00
kiyo9w
ac315eb48e fix: refuse unavailable gallery and pin head trainer 2026-09-28 14:41:57 +09:00
kiyo9w
d5a7a56dff fix: bind tag projection to each frame origin 2026-09-28 14:41:49 +09:00
kiyo9w
44badcf193 fix: replay recorded walks with selected identity backend 2026-09-28 14:17:14 +09:00
kiyo9w
2798322417 fix: constrain trained head labels to enrolled catalog 2026-09-28 14:07:02 +09:00
kiyo9w
b937be8e30 perf: await pending frames without idle worker 2026-09-28 14:06:57 +09:00
kiyo9w
1064e356de perf: reuse sample dimensions and catalog digest 2026-09-28 14:06:34 +09:00
kiyo9w
7142f10cfe chore: remove stale gallery import 2026-09-28 13:44:46 +09:00
kiyo9w
19af7ce545 refactor: keep tag state in streamed track only 2026-09-28 13:41:59 +09:00
kiyo9w
ada00da88b fix: require explicit safe shelf enrollment paths 2026-09-28 13:41:43 +09:00
kiyo9w
745a29d979 fix: guard reserved sessions and bounded response state 2026-09-28 13:41:35 +09:00
kiyo9w
0e85c399bb fix: load hash-pinned DINOv2 weights without network 2026-09-28 13:41:27 +09:00
kiyo9w
f0e2baaf3d fix: anchor OCR passes and tags to shelf coordinates 2026-09-28 13:41:12 +09:00
kiyo9w
06f9fb4819 fix: include latest shelf tag in live inspection 2026-09-28 13:11:06 +09:00
kiyo9w
2894979a35 fix: keep catalog IO off the live event loop 2026-09-28 12:57:21 +09:00
kiyo9w
f2345e482c fix: reject oversized bodies before app response 2026-09-28 12:40:59 +09:00
kiyo9w
934615b593 fix: restore reliable authenticated live recognition 2026-09-28 12:38:15 +09:00
kiyo9w
0e23425ef0 feat: a live path that uses the enrolled store, and a demo server a phone can reach
The live session scored crops with the matcher regardless of the configured identity
backend, so a live scan used the old mechanism while the still path used the enrolled
gallery - measured, the matcher accepts look-alikes and cannot say "unknown", which is
exactly what a real shelf needs it to do. score_crop now routes to the gallery, and
the decision uses a similarity scale (accept at 0.60 with a 0.04 margin) rather than
inlier-count thresholds that mean nothing on a cosine.

The synthetic walk is gone: demo.py, demo_replay.py, the /v1/demo endpoints and the
walk benchmark are deleted, and the app's frame source is the camera on a device (the
single-image replay stays for the simulator, which has no camera).

stock-demo merges both demo shelves (27 products, 62 views, trained head) so one
server recognises either image, and run-demo-server.sh starts it bound to 0.0.0.0 and
prints the exact URL to put in Config.xcconfig.

Verified over the LAN with 1280x960 frames: 40 frames in 5.3s, 9 products recognised
on demo image 1.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 10:51:57 +09:00
kiyo9w
383ff9cf2d feat: two demo shelves, enrolled, plus the price-card filter they forced out
Two real store photographs, chosen for density and for products that look different
from each other, each with its own enrolled store:

  demo-images/demo-image-1-mediya-dairy.jpg   CC0        MEDIYA dairy case
      21 products kept (13 price cards dropped), 16 recognised, all 14 products named
  demo-images/demo-image-2-milk-case.jpg      CC BY 2.0  supermarket milk case
      56 detections, 49 recognised, all 13 products named

Both were enrolled the way a store would be: the products were read off a contact
sheet of the detections, their crops became the store's gallery (stock-mediya,
stock-milk150), and a head was trained on them. `build_store_from_shelf.py` is that
flow.

The filter came out of the first image. Every Japanese supermarket photo has the same
artifact: the detector boxes the shelf-edge price cards as products, because a card is
roughly the shape of a pack - 13 of 34 detections on image 1 were cards. Their text
separates them cleanly: a card's characters are 30-44% digits and it prints the
tax-excluded price (本体), while a product's are 3-19% digits. Cards are now dropped
before classification, and the count is reported as `price_cards_dropped` rather than
hidden.

demo-images/README.md records how to run both, what the demo shows, and the honest
caveat: both shelves are enrolled, so the recognition rate is in-distribution - what
the app does on a shelf it has been onboarded to, not on one it has never seen.

175 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 09:34:29 +09:00
kiyo9w
2bf916f2aa feat: a store's fine-tuned head, and the two-stage gate it needs
The head is now part of a store rather than a script: `finetune_identity.py --stock
stock-lopia` trains on that store's enrolled views (27 views over 7 products, 12
augmented copies each) and writes `<stock>/identity_head.pt`, which ProductGallery
loads automatically.

The first attempt exposed a trap worth recording. A discriminative head has no "none
of the above": trained with an angular margin to separate seven products, it maps
everything confidently onto those seven, including cartons it has never seen.

  raw features, threshold 0.55   home shelf 78% accepted   foreign shelves  5% accepted
  trained head, threshold 0.40   home shelf 58% accepted   foreign shelves 44% accepted

Rejection - the gallery's whole point - disappears. The fix needs no retraining: the
two mechanisms fail in opposite directions, so raw features decide *whether* to answer
(they generalise) and the head decides *what* to answer (it discriminates). Measured:
47% accepted on the home shelf, 5% on foreign ones. Lower coverage than raw-only,
which is the honest trade.

The head also needs its own threshold (0.40 against the raw 0.55): a trained head's
similarity scale is its own, and reusing the raw line rejected almost everything it
said.

175 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 03:21:22 +09:00
kiyo9w
4fd5059ef0 feat: fine-tune the identity head, and measure what it buys
Part 2 ended on the number that matters: enrolling a real shelf from itself leaves
off-the-shelf DINOv2 at ~25% top-1, because real milk cartons differ by small text
and a colour band. This trains a small projection head on frozen features - 384 -> 128,
cosine classifier, angular margin - with augmentation standing in for the second visit
we do not have, and measures it on 72 held-out facings over 8 products.

  raw DINOv2 cosine               top-1  2/72 (2%)   balanced  1%
  raw, rejecting below 0.55       top-1  2/72 (2%)   balanced  1%   (5% of answered right)
  fine-tuned + margin             top-1 28/72 (38%)  balanced 73%
  fine-tuned, rejecting below 0.4 top-1 17/72 (23%)  balanced 58%  (94% of answered right)

So fine-tuning is the difference between working and not (balanced 1% -> 73%), and
with a rejection threshold the model becomes precise - it answers 18 of 72 facings and
is right on 94% of them, which is the operating point a store wants. The raw model
cannot do that at any threshold.

Where it fails is where my labels are coarse: MEGMILK, which the contact sheet shows
is probably several products under one brand, scores 1/32 because the class is
internally inconsistent and the model correctly refuses most of it.

Honest limit, stated in the doc: enrolment and held-out facings come from one
photograph, so these are upper bounds; only a recorded second visit settles the real
number. What is settled is the mechanism - a head trained on labels the pipeline can
produce itself moves fine-grained identity from useless to usable.

175 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 03:15:31 +09:00
kiyo9w
7d01de8062 feat: an embedding identity backend with a calibrated rejection
The external evaluation showed the matcher cannot say "unknown": on 20 shelves whose
products are absent it claimed 47.8% of facings as recognised, and its raw inlier
score cannot be thresholded (foreign shelves' median 6.0 overlaps the real shelf's
5.2 p10, and the worst offenders score 50-190). So the gallery becomes a selectable
identity backend:

    --identity gallery        (or DORAYAKI_IDENTITY=gallery)

Measured on the same 20 foreign shelves: false accepts fall from 47.8% to 13.8% at
the default threshold and to **5%** at 0.60, with a smooth curve to pick an operating
point from. Detection is unchanged (the gallery only names boxes).

What it does NOT fix, measured on a milk shelf where 14 of 107 facings really are
明治 おいしい牛乳 (labelled by eye): precision is 28-45%, no better than the matcher's
39%, and enrolling the shelf from itself (1-3 views per product) is flat at ~25%
top-1. Off-the-shelf DINOv2 is not discriminative enough for fine-grained SKUs - the
same conclusion the literature reaches, now measured here.

The packaging's own text is a real channel and resolution-bound: 51% recall
single-scale, 60% with the tag reader's multi-scale + red-channel trick, 85% where
the text is large (MEGMILK) and 28% where it is small vertical Japanese.

docs/EXTERNAL-EVAL.md records all of it, including the identity architecture it
implies: reject by default, name from the tag when readable, from the packaging text
when legible, from a fine-tuned embedding otherwise, and hand the rest to the
operator.

175 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 03:04:18 +09:00
kiyo9w
f74366d6a4 test: evaluate on 54 real store photos the pipeline has never seen
Everything else here is measured on two photographs. This collects real store
shelves from Wikimedia Commons (scriptable API, explicit licences: CC0, CC BY,
CC BY-SA, public domain, each recorded in a manifest) and evaluates the pipeline on
them. 46 of 54 images carry a shelf; the rest are book scans and a shop front.

What it found, and it is not what I expected:

- **Detection transfers.** 2,258 detections across dairy cases, soy-sauce shelves,
  spice racks, a confectionery aisle, an Okinawan supermarket and a Hong Kong
  grocery - tight boxes on images the model has never seen, and correctly nothing on
  the book scans.
- **Identity transfers when the product is genuinely there.** On a 2011 milk-case
  photo it labelled six facings of 明治 おいしい牛乳 correctly, and 野菜生活100 and
  R-1 elsewhere. The matcher does reach new photographs of products it has templates
  for.
- **Precision is the failure, and rejection is missing.** In that same photograph it
  labelled 低脂肪殺菌牛乳, 有機豆乳, 特濃4.5, 無調整豆乳 and 北海道牛乳 as おいしい牛乳 -
  they are all white milk cartons. Across 24 shelves whose products are not in the
  catalog it accepted 451 identities, and the score cannot be thresholded to fix it:
  the real shelf's median is 18.2 with a p10 of 5.2, while the foreign shelves' median
  is 6.0 and the worst offenders score 50.5 (a bento), 80.6 (a shop interior) and
  189.9 (deer cookies). A threshold that catches the foreigners also throws away the
  real matches.

That confirms on real data what the literature and the review predicted: the
matcher's raw inlier score is not a confidence, and fine-grained look-alikes are the
failure mode. Step 4 of the plan - shortlist, local re-rank, calibrated rejection -
is the fix, not a refinement.

docs/EXTERNAL-EVAL.md records the numbers, the method and the attribution; the
images themselves are not redistributed. 175 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 02:21:00 +09:00
kiyo9w
b451e7096f feat: a shelf map, so a facing outlives the track id that found it
The camera moves and the shelf does not, but every measurement so far treats a
frame as the world: a facing is "a track id", and that id dies the moment the camera
pans away and comes back. Label inheritance, drift checks and restart counters were
each correct fixes for that, and all of them are symptoms.

`shelf_map.py` keeps a per-session map of the shelf plane. Frames are registered by
their content shift, detections are placed in shelf coordinates, and a detection that
lands where a facing already is merges into it - so identity votes and tag readings
pool on the physical facing.

Measured, and reported honestly:

- Registration is exact on the synthetic walk: 0.01 px median error against the demo
  camera's own offsets, over 23 consecutive pairs, with both phase correlation and
  ORB + RANSAC. That validates the harness, not the method - the walk is a pure
  translation of one photograph. Real video needs step 1's footage.
- Pooling works: a facing seen 15 times across 2 track ids reports one identity at
  1.00 agreement.
- De-duplication is NOT yet better than the tracker: 179 facings against 156 track
  ids, where the shelf holds roughly 70. Both over-count, the map slightly more.

Two bugs were found by measuring, and both were silent: the content shift is the
negation of the camera's motion (getting it backwards turned ~60 facings into 857),
and matching purely by box overlap lets jitter split a facing (210 facings, 103 of
them seen once; matching by track continuity first brought it to 179 with 38).

175 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 01:54:38 +09:00
kiyo9w
a718acdc15 feat: record a live session so it can become data
The deep review's first recommendation was real data, because every accuracy number
so far comes from one or two photographs - the identity benchmark even takes its
gallery and its queries from the same photo. This is the mechanism for replacing
that.

`--record-frames DIR` writes each processed frame, sampled every Nth, with a JSON
line describing what the server saw in it: the boxes, the identity channels and the
tag reading. The lines are the labelling input - `labelling_candidates()` returns
the facings the tag channel named with a corroborated price, which are seeds for the
confirmation screen rather than guesses. Nothing here is a training set on its own.

Sampling keeps each frame's own number in the index, so a sampled recording says so
instead of hiding it, and `/v1/health` reports how much has been written. Measured
on a 163-frame walk: 32 frames and their lines.

`docs/DATA.md` is the runbook: what to capture (a slow walk per rail, a close pass
for the barcode, and the same shelf on a second visit), how to turn it into labels,
and how to freeze a benchmark with a cross-visit split.

167 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 01:34:15 +09:00
kiyo9w
dc83e75a34 test: the live tag fixture carries a tax-included price
A tag is only readable when its own arithmetic corroborates the price, so the
fixture needs the 162.00 that belongs with its 150 (150 x 1.08 = 162.00). Without
it the fixture asserted the old behaviour.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 01:30:28 +09:00
kiyo9w
e67ab8bfb4 chore: the film-making shortcut leaves the server, and failures stop hiding
From the deep review's hygiene list:

- The demo replay (a still recognition replayed as if it were live tracking) moves
  out of app.py into demo_replay.py, and every payload it produces is marked
  `synthetic: true`. It had been committed inside an unrelated feature commit, which
  made a film-making device look like part of the server.
- `/v1/health` reports the OCR engine and whether it is available. Without it an
  operator could not tell a broken OCR from a shelf with no readable tags.
- A failed OCR pass is counted and logged instead of swallowed: `TagService.stats()`
  now carries `passes_failed` and `last_pass_error`.
- The live classification budget goes back to the measured production defaults
  (16 in flight, 2 attempts); a demo walk over a nearly static shelf asks for more
  through `--live-inflight` / `--live-attempts` rather than silently changing the
  default for everyone.

162 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 01:30:16 +09:00
kiyo9w
99bce4d7bf fix: a tag may not accuse on weak evidence, nor name a distant product
Three findings from the deep review, all about the tag channel claiming more than it
can support.

1. The exact signal is a barcode. The reconciliation compared `tag.jan` against the
   catalog's JAN, but that field held the regular price - a number compared against
   a product code. It now reads a `barcode` field (populated only by a close-up
   decode) and the printed number is compared as a price.

2. A price-mismatch violation required "an anchor", which was `tag.jan` - a price.
   The anchor is now the tag's own arithmetic agreeing, or a name that actually
   resembles the product, and the name signal carries its score as a field instead
   of being parsed back out of a human-readable note string.

3. Tag identity could reach anywhere along a row: a facing whose own tag was not
   detected inherited whichever readable tag was nearest, which can be a different
   product several columns away. Assignment is now capped at the product's own run
   (2.5 facing widths, at least 200 px), and a tag may only overrule the packaging
   channel when it is corroborated or its name matches at 0.6 rather than 0.4.

162 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 01:30:03 +09:00
kiyo9w
3a960b1471 fix: the number on the tag is the regular price, and a price must be corroborated
Two corrections, both found by the deep review.

1. The number printed large beside the sale price is NOT the JAN's last three
   digits. Checked against the manufacturer, キユーピー ゼロ ノンコレステロール 310g
   is JAN 4901577092381 - it ends in 381, not the 357 the tag prints. The number is
   the regular (pre-discount) price, and all three tags on the demo shelf confirm it
   against their printed discount: (357-269)/357 = 24.6 % -> 2.4割引,
   (400-379)/400 = 5.25 % -> 0.5割引, (248-199)/248 = 19.8 % -> 1.9割引. The field
   is renamed `regular_price`, the barcode keeps its own name, and the discount is
   now a second arithmetic check alongside the tax-included price - so a tag can be
   checked against itself twice.

2. A price is only readable when that arithmetic corroborates it. Measured on a live
   walk before this: 90 tracks carried a tag marked readable, with prices of 20, 23,
   36, 50, 72, 2002 and 4508. The gate has three strengths - an exact tax pair, an
   agreeing printed discount, or both plus agreeing digits - and `TagRecord.state`
   now consults it, so the wire cannot call an uncorroborated read readable.

Also fixed while measuring: voting picks each field independently, so a record could
publish a price from one frame beside a tax-included price from another (measured:
price 149 with unit 214.92, which never appeared together - 214.92/1.08 = 199). The
arithmetic is now re-run on the values actually being published, stale flags are
dropped, and an implausible regular price (3050 beside 349) is suppressed rather
than published with a discount derived from it.

Measured through the real still path: readable tags 9 -> 5, every published price
now satisfies its own tax-included price, and the live walk publishes none instead
of publishing garbage. 162 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 01:29:46 +09:00
kiyo9w
e854aea23d feat: a multi-view embedding gallery, because one template per SKU cannot work
The user's objection was right and it changed the design: the tag channel is
sparse in the real world (measured: 0 tags readable at ~2 m on the main shelf), so
packaging recognition has to carry identity. Measured on a 12-class, 57-facing
evaluation set read off the demo shelf, the packaging channel as shipped cannot:

  views per SKU     embedding top-1     shipped matcher top-1
        1               57 %                  33 %
        2               72 %                  27 %
        3               75 %                  20 %
        4               76 %                   5 %

Two things follow. First, two views per SKU is where most of the gain is - that is
the enrollment spec (a first scan already produces several facings per product).
Second, the matcher gets *worse* with more views, because its raw inlier score is
not calibrated to be aggregated and it cannot express two views of one SKU at all:
it keys templates by filename.

So identity becomes retrieval: `gallery.py` embeds every enrolled view with DINOv2
ViT-S/14, scores a SKU by its best view, and rejects when nothing is close enough
(an unknown product must come back unknown, not as the nearest stranger). Measured
with held-out views - the gallery never contains its own queries - 2 views per SKU
over 12 SKUs gives 69 % top-1 with rejection working, and the threshold moves the
rejection line.

It is affordable in the live loop only on the GPU: 159 ms per crop on CPU versus
3.6 ms on the M5's MPS, and a live frame only needs its new tracks embedded.

157 tests pass; test_gallery.py covers the retrieval contract (best view wins,
several views are one answer, strangers are rejected) with injected embeddings so
it needs no torch.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 23:43:27 +09:00
kiyo9w
52905e73f6 feat: let the shelf's own price tag name the product
Stage 2 of the plan is "identity as retrieval", so the first thing measured was
whether retrieval can even work here. It cannot, on its own: an off-the-shelf
DINOv2 embedding does group identical products (two orange juices, two apple
juices, two almond drinks) but the fine-grained cases collapse - 32 cartons into
one cluster, and nine different bottled drinks grouped by *container shape*. That
matches the literature (RP2K, RPC all fine-tune; nobody ships off-the-shelf
features for SKU identity).

Meanwhile the shelf has been carrying the answer. Every product has a tag printed
under it, and the tag spells the product's name out in clean text, once per
product - the store's own label. This adds that as an identity channel:

- a tag with a readable name is matched against the catalog by name similarity;
- facings are assigned to the *nearest tag along their row* (the tags mark where
  products sit), not by geometric grouping, which was measured to merge different
  products into a 945 px "product" that then swallowed the wrong tag;
- rows are clustered first, because the same column on six shelves would
  otherwise become one product.

Then the demo catalog itself had to be fixed: it listed another store's products
(senbei, Tropicana) while the shelf holds mayonnaise, so nothing could match. The
catalog for this shelf was read off the tags - 12 products with names and prices -
and the products were enrolled as packaging templates by cropping the facings the
tags had just named.

Measured through the real still path on add/image6.jpg (111 detections):

  tag named the product          28 facings   (all 28 verified correct by eye)
  packaging named the product    74 facings
  both spoke                     23 facings, agreeing 16 (69 %)
  facings with any identity      79

So packaging recognition makes a ~30 % error rate on the facings the tag can
check, and every disagreement carried a low packaging score. The tag therefore
wins a conflict, and the conflict is recorded (`identity_conflict`) instead of
hidden - a product whose tag says one thing and whose packaging says another is a
real finding, not noise to suppress.

144 tests pass; test_tag_identity.py adds 6.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 23:28:36 +09:00
kiyo9w
cf7e8a5a88 fix: count products, not facings, and demand an anchor before accusing
Two false positives came out of running the rules against a real shelf, and both
had the same shape: the rules were arguing with our own detector instead of with
the shelf.

1. One tag per product, not per facing. Measured: a row carries 16-18 facings but
   only 4-5 tags, because four facings of a mayonnaise share a single tag. Counting
   facings invents missing tags on a shelf that is perfectly labelled. The rules
   now group a row's facings into products - by identity when the facings were
   recognised, by size and spacing otherwise - and count products.

2. A product's tag may sit anywhere along its run. Measured: a run of
   ピュアセレクト facings was split by geometry because one facing was detected
   narrower than its neighbours (54 px beside 150 px), and the fragment was then
   reported as missing the tag that was sitting further along its own run. A
   product now counts as missing only when no tag exists for its SKU anywhere in
   the row, and a fragment far narrower than the row's typical product is not
   judged at all.

3. A mismatch needs one trustworthy anchor. Measured noise: a wrong identity plus
   a garbled tag read (name match 0.11, no JAN) produced a confident "tag says 90,
   catalog says 188" that was really two bad reads disagreeing. Without the tag's
   JAN or a name that resembles the product, that is now a hint, not a violation.

Measured on add/image6.jpg through the real still path: 0 false violations without
identities (was 1), and with identities the only finding left is a genuine
tag-versus-catalog price difference. Coverage hints now read "tags detected for 2
of 4 products in this row". 143 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 22:44:50 +09:00
kiyo9w
02c0fd1f4f feat: see the tags we cannot read, so "no tag" means no tag
Until now a tag reached the rules only if its text could be read. A tag that was
there but unreadable was indistinguishable from a missing one, so a missing-tag
claim rested on the reader's failures rather than on the shelf - the false
violation that made an earlier run report 106 of them.

Two measurements shaped the detector:

- The tags are printed ON the rail: a red brick-patterned strip running the width
  of the shelf. The rail is therefore findable without reading anything, and it is
  where tags must be looked for.
- On the rail, a tag is a run of columns carrying paper (bright, unsaturated)
  against the brick. Projecting onto x finds 27 tags on add/image6.jpg where
  connected components found 9, because tag text fragments a component at every
  printed line.

The pipeline now merges the two views: a tag whose text was read keeps its fields,
a tag that was only seen is reported unreadable with its box. Measured on
add/image6.jpg: unreadable tags 8 -> 17, "no tag" 94 -> 85, and the per-row hints
became specific - "tags detected for 8 of 16 facings in this row".

That last number is also a domain fact worth keeping: this store prints one tag
per product, not per facing, so facings are the wrong denominator for a
missing-tag rule. The coverage bar keeps the rules silent until that is fixed,
which is the honest behaviour in the meantime.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 22:19:57 +09:00
kiyo9w
8e33b3ebdd feat: carry the JAN and the price's provenance through the tag pipeline
The card reader produces a JAN (the last three digits printed on the tag) and
says whether a price was measured from the big digits or derived from the
tax-included one; the session record and the wire payload dropped both, so the
strongest identity anchor the tag carries never left the reader.

Also reads each card at three scales rather than two. The tax-included price is
small print and only resolves at 3x; without it the price falls back to the
stylised digits, which misread (measured: 30 instead of 269, 20 instead of 199).

Measured through the real still path on add/image6.jpg, the tags now attached to
products read: 269/357, 379/400, 199/248, 209/500, 219/200, 349/150 - prices and
JANs, where the same run previously reported tags that were not in the readable
row at all and prices like 2630 from another tag's text.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 22:11:51 +09:00
kiyo9w
23fd087611 feat: read a price tag as a card, with the JAN and a tax check
The tag pass read a whole shelf rail and then guessed where one tag ended and
the next began. Measured on the demo shelf, that meant the four tags of the
readable row were not reported at all, and the tags that were reported carried
another tag's text (a 649/754.9 tag came back as price 2630, name
"コプサラダトレッシング"). The box it published was the band, so a tag box lined up
with nothing on the shelf.

Tags are now cards. The OCR engine's own line boxes define them: lines are
clustered into rows (a band's crop also catches the packaging text above and
below), the tag row is chosen by how much it looks like tags, and the cards are
split on the horizontal gap between them. A card's box is the union of its lines,
so it wraps the printed tag. Each card is then re-read at several scales and in
the red channel, and the reads are pooled for voting.

Two things make the fields trustworthy rather than merely plausible:

- Layout separates the price from the JAN. The price is the largest text on the
  tag, so it comes from the tallest line; the JAN's last three digits come from a
  small line. Getting this backwards made a 269 price look like a JAN and vanish.
- The tag checks itself. The small clean number is the tax-included price and the
  big stylised one is the pre-tax price: measured, 269 x 1.08 = 290.52, 379 x 1.08
  = 409.32, 199 x 1.08 = 214.92. When the two agree the price is "measured"; when
  the big digits cannot be read the price is "derived" from the small ones and
  flagged, because a tag that is 3.2x too small to read is still arithmetic.

The JAN's last three digits are now read (they never were before) - on a real
LOPIA tag that is the staff-facing identity anchor printed next to the discount.

Measured on the readable row of add/image6.jpg, against values read by eye:
15 of 15 fields correct across three tags (JAN, price, tax-included price,
discount, name), up from 0 tags reported. 141 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 22:07:41 +09:00
kiyo9w
d38d863873 perf: bound OCR so a walking scan stays real-time, and add walk pacing
macOS Vision holds the GIL for the ~124 ms it takes to read a rail band, so the
tag pass decides the live frame rate: with 2 bands every 15 frames a walking
session fell to 1.7 results/s, and 1 band every 30 frames brings it back to 7.2
with tags still settling in a second or two. Measured, not guessed - the same
walk, same method (orb), only the OCR duty cycle changed.

The walk also gained a `pan` parameter (default 0.06, clamped 0.005-0.5) so a
demo can choose its pace: pan=0.02 sweeps a row over ~8 s, which reads like a
person rather than a time-lapse.

Demo configuration measured end to end on the walk (20 s, 1280x720, one client):
orb, pan=0.02, 1 band/30 frames -> 7.2 results/s, 178 labelled tracks, 21
distinct SKUs, first label 0.7 s, 3 readable price tags.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 18:14:32 +09:00
kiyo9w
00367a95c9 feat: a synthetic store walk, and the live tuning it forced out
/v1/demo/plan + /v1/demo/frame give a demo the one thing a still image cannot:
motion. The virtual camera pans a shelf row left to right, steps up, and pans
back - a store employee sweeping a shelf - over a real shelf photo, ~1 ms per
frame, deterministic, and wrapping so an unattended demo loops. The client drives
only `step`, so the walk's speed follows whatever frame rate it can sustain.

Three real defects came out of measuring the walk rather than a still:

- Bands were keyed by on-screen position, so a walking camera minted a new key
  every few pixels of pan and re-read the same shelf continuously. Keyed by row
  now; OCR stops chasing the window.
- The classification budget was consumed by retries of products already leaving
  the frame. Retries now skip anything near the edge, and a track gets 2 attempts.
- The pan advance was 0.22 of the window per step: a facing was in view for one
  or two frames, so nothing could ever be classified (9 labelled tracks in 20 s).
  0.06 keeps a product in view for about a second.

Measured on the walk (20 s, 1280x720 frames, one client): orb labels 296 tracks
across 21 SKUs with the first label at 0.2 s, while hybrid manages 13 tracks /
4 SKUs at 5.4 s - so the live default for a demo is the fast method, with hybrid
kept for tap-to-inspect.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 18:09:23 +09:00
kiyo9w
8f750c15c5 feat: price-tag UI, still-image tag passes, and honest tag evidence
Web client: price-tag boxes in a distinct style (teal, chip with the parsed
price), an OCR section in the product card showing the tag's price, name,
discount and the reconciliation verdict with both values, and a compliance strip
under the stage. The photo stage now draws the image into the canvas and uses the
same containment as the media, so boxes cannot drift from the product (they did:
the canvas stretched to the stage while the photo was contained).

Server fixes the browser runs forced out:
- Photo mode reconciled before identities existed, so every tag read "tag only".
  The tag bundle is cached with the still and classify re-reconciles against the
  identities it just produced, returning them per detection.
- The still path only ever read the first four bands (plan hands out two per
  call); it now drains every band, twice at different upscales so the vote has
  independent reads.
- Voting is confidence-weighted and a dead heat stays unread; the plausible
  shelf-price range is 10-9999, which stops a misread from becoming a ¥26,300
  price. The compliance coverage bar moved to 0.7 after 0.5 produced a false
  "group without price tag" on a shelf where every facing has one.

Measured on add/image6.jpg: 8 readable tags with names and prices, 942 ms for
the whole still, 7 real price mismatches, no false violations, and hints that
say plainly when a row could not be read.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 14:11:23 +09:00
kiyo9w
a27841afa4 feat: run the tag pass in parallel with recognition, in live and still modes
The engine owns the OCR backend, a shared OCR lock and a dedicated tag worker, so
a tag pass can never occupy a classification worker or slow a frame. A live
session plans rail bands on the frame loop (cheap), runs them on that worker, and
attaches whatever the cache already knows; a still runs one synchronous pass.
Tracks and detections now carry price_tag and reconciliation, and the summary
carries price_tags plus compliance. Config can turn tags off per session.

Compliance now fails closed on evidence quality. The first real run produced 106
"missing tag" violations on a shelf where only 8 tags were readable - our tag
*detection* was incomplete, not the shelf. A row is only judged when at least
half its facings carry tag evidence; otherwise it reports "tags detected for 14
of 91 facings in this row" as a hint. Missing-tag findings are one per row with a
count, not one per facing, and price_mismatch still reports every disagreement.
On add/image6.jpg the result is now 7 real price mismatches and no false
violations.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 13:58:18 +09:00
kiyo9w
71b8bdd876 feat: reconciliation and the price-tag compliance rule family
reconcile() compares one accepted identity with its tag and the catalog entry and
publishes a state plus the individual signals (JAN exact, name by longest common
substring, price against either the sale or the regular price). It never changes
the product identity: a disagreement is a second opinion shown next to the first,
and only a checksummed barcode outranks the matcher.

evaluate_compliance() is the first rule family: missing_price_tag, the owner's
missing_tag_group (>= 3 consecutive facings without a tag), orphan_tag,
price_mismatch, plus an unreadable_tags hint. Every rule fails closed on evidence
quality - a row whose tags could not be read produces "move closer", never a
violation. Findings must appear in 3 of the last 5 frames before they publish, so
the UI never flickers.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 13:54:00 +09:00
kiyo9w
faff07f252 feat: OCR engine, price-tag geometry, and the cached tag pass
ocr.py: one interface, backends by availability - macOS Vision through pyobjc
(measured 182-246 ms per rail band), a fake engine for tests, and an explicit
"none". The owner chose the macOS backend; the interface keeps a cross-platform
engine a drop-in, and the module says so.

tags.py: rail bands from the detector's own row clustering (no new geometry
model), clustering of OCR lines into tag-shaped regions, deterministic Japanese
price-tag parsing (sale price by printed area, regular price, discount, unit
price, name, tax/size artifacts rejected), and TagService - a per-session cache
with multi-pass voting so a static tag becomes one settled reading instead of
thirty noisy ones, and so the frame loop never waits for OCR.

Measured on add/image6.jpg: 6 bands, 12 tags, 8 readable with real prices and
names (305 エッグケア, 357 ゼロノンコレステロール, 400 フィッテ), 39-254 ms per
band. On image.jpg (2 m) it correctly finds nothing readable - the tags are too
small there, which is the capture requirement recorded in the design.

Tests found four real bugs: unit prices leaking into the price field, packaging
sizes like <310g> read as prices, cluster_tags dropping the line boxes (so the
big sale price was never preferred), and brittle tag-to-product association when
a cluster spans facings.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 13:52:15 +09:00
kiyo9w
5f93f6e278 fix: use the tracker imports that exist in ultralytics 8.4.153
STrack lives in ultralytics.trackers.byte_tracker and the YAML loader is
ultralytics.utils.YAML; the basetrack/yaml_load names this module used are gone
in the pinned version. Caught by running the tracker tests against the real
dependency.

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