Commit graph

6 commits

Author SHA1 Message Date
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
e25fcda67d fix(eval): confirm a leak signature before calling it one, and score external sets in code
Enrolling a packshot from an external set is normal - it is how F-12 was
closed - and it silently turns one of those rows into training data. The
external sets were scored by an ad-hoc script that never checked, so
sku_l06_1.jpg was being reported as a held-out correct accept while being
byte-identical to the enrolled view.

code/eval_external.py now scores those sets with the same leaked() check the
shelf evaluator runs, and refuses a row that has since been enrolled.

That check needed fixing first. Its 64x64 signature fires on two different
photographs of the same product: the l09 rear label from a retailer page sits
19.5 from the enrolled front packshot, under the 20.0 line, while being a
different photograph (resized MAD 26.3). A signature hit is now confirmed by
correlation - a genuine re-cut, re-encode or rescale correlates at 0.99+, that
pair at 0.28 - so the two signals together are decisive where either alone is
not. Verified against both directions: identical, q55 re-encode and 700px
rescale are caught; the different-photo pair is not.

Also adds the van backend (always-argmax, no gate) so the stream comparison in
DECISION.md is reproducible rather than asserted, with two tests pinning that
it answers where the shipped gate abstains and still refuses an empty score set.

External result on the corrected basis: 19 scored, 12 correct, 7 abstain,
0 false accepts, 1 leak excluded. Suite 307 -> 309.
2026-09-29 03:29:56 +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
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
356f7aa502 feat: identity decision contract and session-local observation model
Keeps the model's uncertainty out of the UI: decide_identity() separates raw
matcher scores into accepted / ambiguous / unknown / rejected, and only an
accepted result can name a SKU. Zero evidence is rejected, never a product.

observe.py adds frame-local observations and one-zone scan sessions (draft,
finalize, cancel, rescan) with replay, staleness and backpressure rules.
pipeline.process_click() now reports the identity level instead of failing
when the match is not accepted.

Incorporates earlier uncommitted session work in this repository
(identity.py, observe.py, yolo_adapter.py, test_observe.py, and the pipeline
hunk those needed); yolo_adapter keeps its decoded frame per thread so
concurrent sessions cannot swap images.

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