Compare commits

...

34 commits

Author SHA1 Message Date
kiyo9w
a716f5aab1 test(server): final smoke with the calibrated gates live
Still endpoint: 111 detections, 44 named, 42 correct, 95.5% precision, 2 wrong
(both unknown facings the local lane matches geometrically).

Stream, 20 s pan 12: 62 tracks labelled, 0 label changes, 0 unexplained, 17/27
facings named at 84% precision, 0 wrong, 0 unenrolled false accepts.

Both recordings committed under server-smoke/ and stream-gated/.
2026-09-29 04:32:38 +09:00
kiyo9w
684934e042 docs(decision): re-measure E-3 and fix the labels README's dead reference
E-3 with the calibrated lines: 66% at 95% precision with the text lane, 55% at
81% without, 2 false accepts against 8. The lane adds 11 pp of coverage while
raising precision 14 pp, so the answer stands and the margin is wider than the
audit's.

The labels README pointed at evidence/image6_v2.json, which was a byte-identical
duplicate of eval/labels/image6.json and is now removed; the README says so
instead of describing a copy that no longer exists.
2026-09-29 04:31:59 +09:00
kiyo9w
4d1c17f15d docs(decision): date the header and re-measure the OCR cost
The header said 'Audited 2026-09-28' while the document now carries 2026-09-29
implementation results; it says both, and that audit-time figures are kept
beside the later ones rather than replaced.

The OCR cost row claimed 18.0 ms on 40 production crops with no committed
script behind it. code/bench_ocr.py is that measurement: 23.3 ms p50, 37.2 ms
p95, mean 23.1 - inside the recorded 18-26 ms band, at its upper half.
2026-09-29 04:30:02 +09:00
kiyo9w
81a2ab10e7 docs(decision): measure G1 against Van on the same stream and catalog
The G1 row compared against a van run whose server had loaded the legacy
stock-lopia catalog, so its ids were template-level (sku_005) and every answer
scored wrong. Re-run with both servers on stock-lopia-clean, 20 s pan 12:

  van    17/27 (63%) coverage   53% precision   7 wrong   6 unenrolled accepts
  local  19-20/27 (70-74%)      77-85%          0 wrong   0 unenrolled accepts

G1 asks the new system to be no worse than Van on coverage; it is better on
both axes, so G1 now reads as passing on the stream rather than unmeasured.
Both recordings are committed under stream-gated/.
2026-09-29 04:28:20 +09:00
kiyo9w
01d9a5e614 docs(decision): re-measure the stream and live rows after the calibration
The stream row was recorded before the accept lines moved. Three fresh 20 s pan
replays through the shipped lane: 70-74% coverage at 77-85% precision, 0 wrong
labels in all three (0-1 before), 8-14 unknown false accepts - the near-unknown
case, which the size gate and catalog completeness own rather than the accept
line.

Live resolution on the shipped evaluator: 56% end-to-end at 97% precision with
1 false accept, so the calibration is not a full-resolution artifact.

The recordings are committed under stream-gated/ so the row is reproducible.
2026-09-29 04:22:46 +09:00
kiyo9w
5eb4bdf6c8 chore(eval): reconcile the distractor sets and refresh the sweep numbers
The Wikimedia collector truncated the whole basename at 60 characters, so 13
files lost their extension and cv2 could not read them by name - the distractor
sets were quietly scoring 13 images fewer than they held. It now truncates the
stem and keeps the extension, and the files on disk are renamed to match.

The search terms also pull in .djvu volumes whose page 1 Commons serves as a
JPEG thumbnail. Twelve book scans were in the sets; they contributed 0 false
accepts, and they are not shelves, so the collector filters them and the
manifests no longer list them.

With all 2650 crops readable the sweep is re-measured end to end: 3.09% at the
old lines, 0.30% at the shipped 4.0/4, image6 44 shown / 42 correct / 95.5%.
The accept sweep table and the verdict's far-unknown sentence now carry those
numbers instead of the pre-calibration ones.

Also: the fetched images are gitignored (manifests and results are not), the
l10/l11 shelf-tag crops that back the catalog corrections are committed, and a
stale pre-gate eval.json and a duplicate of eval/labels/image6.json are gone.
2026-09-29 04:17:54 +09:00
kiyo9w
4bdad66bbe docs(decision): record the far-unknown calibration and the gate status
The far-unknown claim in the verdict said 0.2-0.7% on 602 crops. That was the
audit's variant matcher on the add/ photos; the shipped lane at the shipped
lines was 3.61% on the Wikimedia sets. Both are now stated, with the sweep that
chose 4.0/4 and the live-endpoint result: 44 named, 42 correct, 95.5% precision
against 53/48/90.6% before.

Gate status is recorded per gate rather than as a blanket claim: G2 and G3's
far half pass offline, G8 passes, and G1/G4/G5/G6/G7 are not yet measured on
held-out data because Tier 2 does not exist.

The Wikimedia manifests and results are committed; the 59 MB of images are not,
because collect_shelf_images.py fetches them from the source URLs the manifests
record.
2026-09-29 04:11:00 +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
d13ce94123 test(server): final smoke with every catalog correction live
/v1/recognize: 111 detections, 53 named in 2.38 s, no view-suffixed ids, no
missing product info. l06 now names all 4 of its facings - it was 1/4 at live
resolution before the current package was enrolled, which is the F-12 fix
visible in the product.

/v1/stream, 20 s pan: 82 tracks labelled, 0 label changes, 0 unexplained. The
label-memory fixes hold under a pan.
2026-09-29 03:41:05 +09:00
kiyo9w
21f1d97cca docs(decision): label the candidate-table figures as audit-time
The F row's veto numbers were measured on v1 labels, which the audit itself
found to be 23% wrong. They are kept as the record of what was found, marked as
audit-time, and pointed at the current measurement rather than left to read as
present-day results. The OCR cost figure was re-measured: 18.0 ms/crop on 40
production-shaped crops, inside the recorded band.
2026-09-29 03:40:17 +09:00
kiyo9w
962cdaab5f test(tier3): run the production pipeline over both reusable clips
The two clips were staged but never exercised. One frame per second through the
production detector and the shipped local lane:

- The 1080p supermarket pan: 227 crops, 0 accepted. Correct open-set behaviour -
  the frame is German pickle jars, not catalog products.
- The 360p mayonnaise bay: 1623 crops, 24 accepted (1.5%), across seven SKUs.
  The clip visibly contains Kewpie products, so those are not automatically
  false accepts, but at 640x360 the labels are too soft for per-SKU ground
  truth.

Recorded as an accept-rate probe with false_accepts and accuracy left null
rather than estimated. Tier 3 still needs an operator-recorded walk.
2026-09-29 03:39:33 +09:00
kiyo9w
11826cc43c feat(eval): report the external benchmark per SKU
A set total hides which products the lane can and cannot name. Every SKU in
these sets has images from two independent source domains, so a SKU that scores
the same on both is not one lucky photograph.

The breakdown is what makes the two interesting results visible: l01, l02, l03,
l07 and l09 are 2/2 correct on both their domains, l07 with no enrolled image
at all, while l11 abstains on both and l04 abstains on all four of its images.
2026-09-29 03:37:04 +09:00
kiyo9w
8db280d220 feat(enroll): report views with no recorded source
Six enrolled views predate ATTRIBUTION.json and their source URLs were never
recorded. They are not wrong - each is independent of the evaluation photo
(highest template match 0.166 against add/image6.jpg) and each is above the
enrollment floor - but the provenance is gone, and provenance is what lets a
later audit tell a re-sourced packshot from a leaked shelf crop.

rebuild_catalog now names them instead of leaving the gap to be rediscovered,
and ATTRIBUTION.json records both the l06 replacement and the gap itself, with
what is verifiable about those six.

Reported, not refused: refusing would drop six working references to punish a
missing field.
2026-09-29 03:35:41 +09:00
kiyo9w
20fb459ee7 docs(decision): mark the audit findings that are now fixed
The ceilings table, the stale-reference line and the metadata-integrity
paragraph all described the audit-time state as current. Each now says what was
found and what changed: the catalog ceiling is 53/64 = 83% (l10 and l06
enrolled, l12's facings labelled), the l06 package and l09 view are replaced,
and every stock.csv defect named there is corrected.

The OCR cost figure was re-measured rather than assumed: 18.0 ms/crop on 40
production-shaped crops, inside the recorded 18-26 ms band.
2026-09-29 03:33:54 +09:00
kiyo9w
0376a74418 docs(labels): the README described the pre-correction label set
Three claims were stale: it said l12 has no facing on the shelf (facings #46-48
now carry it), that the file uses sku_l01-sku_l11, and that the set is 61
in-catalog / 48 unknown across 11 SKUs. It is 64 / 45 across 12.

The #46-48 note it pointed at as an open question is now answered, so the note
reference goes with it.
2026-09-29 03:33:16 +09:00
kiyo9w
a1c69f72fe feat(eval): add --no-text so the E-3 claim is reproducible
E-3 asks what the text lane is worth, and the record answered it with numbers
that no committed command could produce - the evaluator had no way to run the
local lane without the text lane. --no-text is that way, and it keeps the
comparison to one variable by building the lane either way, so the OCR cost is
paid in both arms.

Measured: full resolution 77% end-to-end at 91% precision with text against 61%
at 66% without (5 false accepts against 20); live 62% at 93% against 56% at 84%
(3 against 7). The lane adds 16 pp and 6 pp of coverage while raising precision,
against E-3's +5 pp bar.

Also refreshes the re-measured block above it, which still printed the
pre-correction 61/48 label counts and a stray 'HUB!' marker.
2026-09-29 03:32:38 +09:00
kiyo9w
56cff09292 docs(decision): report the external benchmark on the corrected basis
19 scored rather than 20: sku_l06_1.jpg is now the enrolled l06 view, so it is
reported as leaked instead of as a held-out correct accept. 12 correct,
7 abstain, 0 false accepts.

Both leak checks are named in the record and in the external README, since the
enrollment that closed F-12 is exactly the kind of change that silently
converts a held-out row into training data.
2026-09-29 03:30:16 +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
kiyo9w
dfdaef4e61 docs(decision): record the external benchmark and the F-12 fix
Twenty independent-source images across eight SKUs, leak-tested (max template
match against add/image6.jpg is 0.675) and scored through the shipped lane:
13 correct, 7 abstain, 0 false accepts. The seven abstentions are all
unenrolled or weakly-read SKUs.

The result worth keeping is sku_l07: no enrolled image, both images accepted on
text alone. That is the text lane's coverage claim holding on data it had no
part in building.

F-12 is marked fixed with its measured effect, and the packshot-OCR check that
would have caught it is recorded as implemented but not yet trustworthy - it is
a false negative on l04 and l07.
2026-09-29 03:23:31 +09:00
kiyo9w
6a45fb990d fix(catalog): enroll the current l06 package, closing F-12
The enrolled sku_l06.jpg showed the superseded 内臓脂肪ケア wrapper. The shelf
and the current retail package both read 内臓脂肪を減らす キューピーフィッテ
210g, so the reference image was of a design the store no longer sells - the
catalog was pointing at the wrong package.

F-12 was recorded as blocked on a capture because Kewpie's own site serves the
old design for that JAN. An independent retailer listing carries the current
one, and it matches the shelf facings exactly (verified by eye against a crop
of add/image6.jpg before enrolling).

Measured effect on the corrected labels: live decision 60% -> 64%, end-to-end
59% -> 62%, full-resolution decision 81% -> 83%. Suite still 307.

Also lands 12 more external-proxy images (six SKUs, two source domains each)
and their scored result. Across both external sets - 20 images - the lane is
13 correct, 7 abstain, 0 false accepts.
2026-09-29 03:22:03 +09:00
5fe3258614 test(external): add 12 kewpie external-proxy images with provenance
Two visually verified non-enrollment images per SKU for sku_l01, l02, l03,
l05, l06 and l11, sourced from Amazon retailer galleries and Yahoo
retailer/customer listings. sku_l06 uses the current 内臓脂肪を減らす package
(F-12); three customer listings showing the superseded wrapper were rejected.
Collection scratch files are removed.
2026-09-29 03:19:23 +09:00
kiyo9w
8754738dc1 docs(decision): refresh every number the corrected labels moved
Five blocks still printed pre-correction figures. Each was re-run through the
evaluator and replaced with what it actually outputs now:

- The lane comparison: local 75% at 91% against Van 64% at 38% (full), and
  59% at 93% against 66% at 39% (live). At live resolution Van now answers more
  facings correctly and is wrong on 67 of them to the local lane's 3 - stated
  plainly rather than left as a win on both axes.
- The accept sweep: 75/67/67/62% end-to-end at 91/91/93/95% precision, with the
  evaluator's own column definitions spelled out, since my first re-derivation
  used the wrong denominator.
- The G1 stream claim: four pan replays give 63-70% coverage at 76-84%
  precision, not 75% at 100%.
- The upscale paragraph: the guard is intact, but sku_l09.jpg is now a
  1000x1000 packshot, so the current run no longer exercises it.
- catalog_hash and the pyramid floor: the hash covers view bytes, not just
  names, and every view is now above the 800 px floor.

Also refreshes the server smoke against the renamed catalog: 53 named in 2.58 s,
no view-suffixed ids, no missing product info. l12 is not among them - it has
no enrolled view, which is the documented gap.
2026-09-29 03:18:30 +09:00
kiyo9w
306ea9b3e8 test(external): score 8 independent-source images, 0 false accepts
Eight images for sku_l04, sku_l07 and sku_l09 from six source domains, scored
through the shipped LocalLaneAdapter and DecisionPolicy against
stock-lopia-clean: 4 correct accepts, 4 abstains, no false accepts.

Leak-tested first: the highest template match of any image against
add/image6.jpg is 0.675, so none is a crop of the evaluation photo.

The result that matters is sku_l07. It has no enrolled image, and both its
images accept through the text lane alone - one reads ラッキー フヨネーズ, the
other the rear label's 製造者:丸和油脂株式会社 with 内容量:200g, which
together name the row 丸和油脂 ラッキーマヨネーズ 200g. The text lane's
coverage claim holds on data that was not used to build it.

The four l04 abstentions are correct behaviour: l04 is also unenrolled and its
reads are weak (one reads only コと旨 タ 味, another scores 0.379 against a
0.45 accept line).

Also records the l12 reconciliation, the Kewpie 450 g research, and two
reusable pan clips with 11 rejected candidates for a future Tier-3 replay.
Nothing here is Tier 2 or Tier 3 and every manifest says so.
2026-09-29 03:13:16 +09:00
kiyo9w
30bfe42bf6 docs(decision): record the catalog corrections and the size gate
The reproduction block still printed the pre-enrollment numbers (catalog 69%,
decision 86%); it now prints what the command actually outputs today, with the
catalog figure's fall from 87% to 83% explained rather than left to look like a
regression.

Adds the three row corrections with their evidence, the near-unknown fix and
its measured effect, and the state of the four unenrolled SKUs - including that
l08's row name and size are themselves unverified, since no retailer listing
for a 30g スパイシーストーム ペリペリミックス exists.
2026-09-29 03:09:55 +09:00
kiyo9w
22db40eb93 fix(labels): the tare facings are sku_l12, not unknown
Facings #46-48 carried the note 'possibly what stock.csv calls l12'. The l12
reconciliation settled it: the shelf tag under them reads ユーラス 野菜を
おいしく食べる旨タレ (380g) at ¥299, the price the l12 row carries, and the row
has been renamed to match. Leaving them unknown understated the catalog and
overstated precision.

Counts move from 61 in-catalog / 48 unknown to 64 / 45. The enrollment ceiling
falls 87% -> 83% because l12's three facings now count as in-catalog with no
enrolled view - an honest catalog gap, not a recognition regression.

Also records the Tier-3 source search: two reusable clips (CC BY 3.0 Wikimedia
1920x1080 supermarket pan; CC BY YouTube 360p mayonnaise-bay pan), 11 rejected
candidates, and the LOPIA walk that carries the standard YouTube licence and
was therefore not downloaded. Both clips are labelled tier3_proxy_candidate,
never Tier 3: that still needs the operator's own recorded walks.
2026-09-29 03:09:15 +09:00
kiyo9w
1e62554944 chore(evidence): stop committing the leave-facing-out catalogs
cat_full's *_shelf.jpg views are crops of add/image6.jpg itself - template
matching puts each one back at 0.998-0.999 confidence - so committing them
reintroduced the evaluation photo as a catalog, which is the leak the audit
removed from stock-lopia. cat_pack is the same views without the shelf copies.

Both are regenerated by lfo_setup.py from stock-lopia and the labels, so the
evidence stays reproducible without the repository carrying the leak. Verified
after removal: lfo_setup.py rebuilds all 16 views.
2026-09-29 03:08:12 +09:00
kiyo9w
a9f0f61611 fix(catalog): sku_l12 is a vegetable tare, not 焼肉のたれ 中辛
The row named a product that is not on the shelf. The tag under facings #46-48
reads ユーラス 野菜をおいしく食べる旨タレ (380g) at ¥299 - the exact price the
l12 row already carried - and the package reads お子さまOK 甘めテイスト, the
opposite of 中辛. Category, heat level and brand were all wrong.

This one is worse than a wrong size: the text lane scores a crop against the
row's printed name, so a correct read of the real bottle would have been
vetoed by a row describing a different product.

The 2023 green-label predecessor (380ml, JAN 4571233473887, checksum verified)
is recorded in the manifest as predecessor-only evidence; no current JAN is
claimed, because the current white package's rear label has not been
photographed. One in-store rear-label photo would close that.
2026-09-29 03:07:39 +09:00
kiyo9w
74e9afed7d test(server): record the real-endpoint smoke after the size gate
/v1/recognize on add/image6.jpg against stock-lopia-clean: 111 detections,
53 named, every one carrying product_name and price, no view-suffixed ids.
sku_l10 answers 9 facings, which it could not do before it was enrolled.

The live WebSocket path was exercised too: 217 frames, 77 tracks labelled,
0 unexplained label changes, l09 and l10 in the final summary.
2026-09-29 03:06:28 +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
kiyo9w
aa838f7b2b docs(packshots): record l04/l07/l10 identity provenance
Three enrollment views were refused by the name/OCR check at 0.70, and the
research says all three refusals are false negatives:

- sku_l04: the packshot is byte-identical (MD5 510db1965bc483899c0cce17c72ba10e)
  to Amazon media 61yWDtDjDuL for ASIN B0197VWHRG, やまや めんたいマヨネーズタイプ
  200g, JAN 4903085062863. 明太子屋さんがつくった is a package slogan, not a
  competing brand. Caveat recorded: 200g is superseded by the current 300g.
- sku_l07: 丸和油脂 ラッキーマヨネーズ 200g, JAN 4978931401615, corroborated by
  Amazon, Rakuten, Yahoo Shopping and Kakaku. The front prints the product name
  but never the maker or size, so full-name overlap cannot reach 0.70.
- sku_l10: exact 400g front, JAN 4901001114030, with the 450g rejection.

Also adds the missing sku_l10 OCR read, generated with the current engine, and
l10's attribution entry. Manifests are tracked; the retailer images they cite
are restricted-research and stay untracked, matching the existing precedent.
2026-09-29 02:51:55 +09:00
kiyo9w
d604c6a20a fix(catalog): correct sku_l11 to 400g
Both CSVs called sku_l11 キューピー ハーフマヨネーズ 450g. The shelf tag under
the l11 row in add/image6.jpg reads キユーピー ハーフマヨネーズ〈400g〉, and the
enrolled packshot's own OCR reads 400g, so the catalog shipped a size the
product does not have - the same defect class as sku_l10.

Measured on the corrected image6 labels, local lane: full 86% precision (was
84%), live 95% (was 91%), false accepts 8 and 2. 303 tests pass.
2026-09-29 02:50:24 +09:00
kiyo9w
2b1566e8e2 fix(catalog): commit the enrolled sku_l10 view
The previous commit corrected stock-lopia-clean/stock.csv to reference
sku_l10.jpg but left the view itself untracked, so a fresh checkout would
have loaded a catalog whose CSV names an image that is not there.
2026-09-29 02:49:10 +09:00
kiyo9w
601d72296c fix(catalog): correct sku_l10 to 400g and enroll its packshot
Ajinomoto ピュアセレクト マヨネーズ has no 450g size: the official lineup is
250g/400g/600g/1kg, the shelf tag in add/image6.jpg reads 400g, and DECISION.md
already recorded the mismatch. Both CSVs carried 450g, so every l10 answer
shipped a wrong size.

The packshot is the exact 400g front (JAN 4901001114030, 1000x1000) with its
source URL and the 450g rejection recorded in
data/packshots/research/sku_l10/manifest.json.

Enrolling it moves the catalog ceiling from 69% to 87% of in-catalog facings
answerable by image (l10 was 11 unenrolled facings). Measured on the corrected
image6 labels: local lane 92% identity at 84% precision full, 85% at 91% live.

Also points external-eval defaults at the repository-local data path; the
licensed Wikimedia corpus remains uncommitted pending manifest review.
2026-09-29 02:45:21 +09:00
68061635d9 Enroll from a repo data dir; read packshots in every orientation
Two enrollment defects, both found by trying to enroll real packshots.

`rebuild_catalog.py` crashed on every successful run: the report referenced
`small` but the call to `below_pyramid_floor` was missing, so the pyramid-floor
check the enrollment protocol asks for never ran and the tool died with a
NameError after writing the catalog.

The name/OCR check read one orientation. A packshot whose identifying text runs
vertically - the やまや めんたいマヨネーズ image reads 明太手屋さんが only when
rotated - was refused as a mismatch, which is the worst failure this check has:
it rejects good data while claiming to validate it. `best_orientation_text`
tries all four right-angle rotations and picks the one that explains the most of
the catalog name, not the one that reads the most characters, because the
upright pass returns stray kana that concatenate longer than the real read.

The regression test goes through `audit`, not the helper: the bug was that
`audit` called a single-orientation read, and a helper unit test would have
passed on the broken code. Verified to fail before the fix and pass after.

Data now lives in the repo, not /tmp:

  data/packshots/source/   packshots as downloaded, with ATTRIBUTION.json
  data/evaluations/        per-session run artifacts, so a number in
                           DECISION.md traces to the run that produced it

sku_l09 is re-sourced at 1000x1000 from Matsukiyo's Rakuten image, replacing
the 305px view that the README documents as the reason l09 wins facings it
should not.
2026-09-29 02:33:47 +09:00
184 changed files with 80074 additions and 139 deletions

View file

@ -25,3 +25,14 @@ stock-*/identity_head.pt
.gallery_cache_*.npz
.demo-token
config/local.env
# Fetched images. Every set keeps its manifest.json (source URL, licence,
# artist) and its results in git; the pixels are re-fetchable from those URLs
# and are 59 MB for the Wikimedia shelves alone.
data/external-shelves/*/*.jpg
data/external-shelves/*/*.jpeg
data/external-shelves/*/*.JPG
data/external-shelves/*/*.png
data/packshots/research/*/images/
data/packshots/research/*/pages/
data/packshots/research/*/*.jpg

View file

@ -1010,8 +1010,9 @@ def main() -> None:
parser.add_argument("--live-method", type=str, default=None, choices=list(CLASSIFY_METHODS))
parser.add_argument("--live-conf", type=float, default=None)
parser.add_argument("--identity", type=str, default=None,
choices=["matcher", "local", "gallery"],
help="which mechanism names products (default local)")
choices=["matcher", "local", "gallery", "van"],
help="which mechanism names products (default local; "
"van is the always-argmax baseline)")
parser.add_argument("--live-inflight", type=int, default=None,
help="classifications in flight per session")
parser.add_argument("--live-attempts", type=int, default=None,

View file

@ -0,0 +1,42 @@
"""OCR cost per crop, on production-shaped crops.
The decision record claims ~18-26 ms per crop on Mac and re-measured 18.0 ms on
40 production crops. This is that measurement, so the number is reproducible
rather than remembered.
.venv/bin/python code/bench_ocr.py
"""
import json, statistics, sys, time
sys.path.insert(0, 'code')
import cv2
from eval_identity import LocalLaneAdapter, crop_of
lane = LocalLaneAdapter('stock-lopia-clean')
labels = json.load(open('eval/labels/image6.json'))
image = cv2.imread('add/image6.jpg')
crops = []
for facing in labels['facings']:
if facing.get('label') in (None, 'exclude'):
continue
crop = crop_of(image, facing['box'])
if crop is not None and crop.size:
crops.append(crop)
if len(crops) >= 40:
break
# Warm the recognizer: the first call loads the Vision model.
lane.text_lane.read(crops[0])
times = []
for crop in crops:
started = time.perf_counter()
lane.text_lane.read(crop)
times.append((time.perf_counter() - started) * 1000)
times.sort()
print(f'{len(times)} crops')
print(f' p50 {statistics.median(times):.1f} ms')
print(f' mean {statistics.mean(times):.1f} ms')
print(f' p95 {times[int(len(times) * 0.95)]:.1f} ms')
print(f' min {times[0]:.1f} max {times[-1]:.1f}')

View file

@ -129,7 +129,9 @@ def image_info(titles: list) -> dict:
def main() -> None:
root = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/dorayaki-data/shelf-images")
default = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data", "external-shelves", "wikimedia-commons")
root = sys.argv[1] if len(sys.argv) > 1 else default
os.makedirs(root, exist_ok=True)
print("searching Commons...")
@ -139,7 +141,7 @@ def main() -> None:
info = image_info(sorted(found))
print(f" metadata for {len(info)} files")
kept, skipped = [], {"license": 0, "size": 0, "missing": 0}
kept, skipped = [], {"license": 0, "size": 0, "missing": 0, "not_a_photo": 0}
for title, entry in sorted(info.items()):
if not entry.get("url"):
skipped["missing"] += 1
@ -148,6 +150,12 @@ def main() -> None:
if not any(token in licence for token in PERMISSIVE):
skipped["license"] += 1
continue
# A book scan is not a shelf. The search terms pull in .djvu volumes
# whose page 1 is a title page, and Commons serves those as JPEG
# thumbnails, so the extension check has to be on the source title.
if any(title.lower().endswith(ext) for ext in (".djvu", ".pdf", ".svg", ".tif", ".tiff")):
skipped["not_a_photo"] += 1
continue
if (entry.get("width") or 0) < 1000 or (entry.get("height") or 0) < 700:
skipped["size"] += 1
continue
@ -156,9 +164,13 @@ def main() -> None:
print(f" keeping {len(kept)} (skipped: {skipped})")
manifest = []
for index, (title, entry) in enumerate(kept):
stem = os.path.basename(urllib.parse.urlparse(entry["url"]).path).replace(" ", "_")
stem = urllib.parse.unquote(stem)[:60]
name = f"{index:03d}_{stem}"
# Truncate the stem, never the extension: a 60-character cut on the
# whole basename drops it, and the file then has no type for cv2 or
# for anyone reading the directory.
stem, extension = os.path.splitext(
os.path.basename(urllib.parse.urlparse(entry["url"]).path).replace(" ", "_")
)
name = f"{index:03d}_{urllib.parse.unquote(stem)[:60]}{extension.lower()}"
path = os.path.join(root, name)
if not os.path.exists(path):
try:

View file

@ -89,8 +89,11 @@ class EngineSettings:
# calibrated open-set gate: measured on the corrected image6 labels it shows
# 36 of 61 in-catalog facings at 95% precision with the text lane, against
# 35 at 35% for Van's always-argmax matcher. `matcher` keeps Van's composite
# as a selectable baseline lane (E-1). `gallery` is fenced off: raw DINOv2
# is 12-21% top-1 on 7 SKUs and its recall@10 falls to 7/42 at 493 SKUs.
# as a selectable baseline lane (E-1). `van` is that same scorer with the
# gate removed - it answers its top SKU every time, which is Van's actual
# contract and the only way to reproduce the stream comparison in
# DECISION.md. `gallery` is fenced off: raw DINOv2 is 12-21% top-1 on 7
# SKUs and its recall@10 falls to 7/42 at 493 SKUs.
identity_backend: str = "local"
# Cosine similarity below which the gallery calls a facing unknown. Measured:
# same-product pairs sit above 0.6, different products below 0.5.
@ -469,6 +472,8 @@ class RecognitionEngine:
return "cosine"
if backend == "local":
return "local"
if backend == "van":
return "van"
return "inlier"
def identity_text_lane(self) -> Any:

View file

@ -0,0 +1,116 @@
"""Open-world false-accept probe: unrelated shelf photos through the shipped lane.
data/external-shelves/ holds Wikimedia Commons supermarket photos of shelves
that share nothing with the LOPIA catalog. Every accept here is a false accept
by construction, which is what makes the set useful: the image6 shelf can only
measure false accepts on the products it happens to contain.
The stored eval.json predates the open-set gate, so it is re-run rather than
trusted.
.venv/bin/python code/eval_distractors.py --stock stock-lopia-clean \
data/external-shelves/wikimedia-commons
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import sys
import time
from typing import Any, Dict, List
current_dir = os.path.dirname(os.path.abspath(__file__))
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
import cv2 # noqa: E402
from eval_identity import LOCAL_ACCEPT, LocalLaneAdapter # noqa: E402
def main(argv: List[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directories", nargs="+")
parser.add_argument("--stock", default="stock-lopia-clean")
parser.add_argument("--write", action="store_true")
parser.add_argument("--limit", type=int, default=0, help="images per set (0 = all)")
args = parser.parse_args(argv)
from engine import build_default_engine, EngineSettings
engine = build_default_engine(EngineSettings(stock_dir=args.stock))
lane = LocalLaneAdapter(args.stock)
for directory in args.directories:
manifest_path = os.path.join(directory, "manifest.json")
try:
with open(manifest_path) as handle:
manifest = json.load(handle)
except (OSError, ValueError):
manifest = {"images": []}
meta = {row["file"]: row for row in manifest.get("images", [])}
rows: List[Dict[str, Any]] = []
paths = sorted(
path for path in glob.glob(os.path.join(directory, "*"))
if os.path.splitext(path)[1].lower() in (".jpg", ".jpeg", ".png")
)
if args.limit:
paths = paths[: args.limit]
for path in paths:
name = os.path.basename(path)
image = cv2.imread(path)
if image is None:
continue
started = time.perf_counter()
detections = engine.detect(image)
accepted = 0
for detection in detections:
box = detection.get("box") or detection.get("bbox")
if not box:
continue
x1, y1, x2, y2 = [int(v) for v in box]
crop = image[max(0, y1):y2, max(0, x1):x2]
if crop.size == 0:
continue
decision = lane.decide(crop, lane.rank(crop), accept=LOCAL_ACCEPT,
margin=0.0, scale="full")
if decision.get("best_sku"):
accepted += 1
rows.append({
"file": name,
"title": meta.get(name, {}).get("title", ""),
"detections": len(detections),
"false_accepts": accepted,
"ms": round((time.perf_counter() - started) * 1000),
})
detections = sum(r["detections"] for r in rows)
accepts = sum(r["false_accepts"] for r in rows)
report = {
"dataset": os.path.basename(directory.rstrip("/")),
"catalog": args.stock,
"note": "Unrelated shelves: every accept is a false accept by construction.",
"images": len(rows),
"detections": detections,
"false_accepts": accepts,
"false_accept_rate": round(accepts / detections, 4) if detections else None,
"rows": rows,
}
print(f"{report['dataset']}: {len(rows)} images, {detections} detections, "
f"{accepts} false accepts ({report['false_accept_rate'] or 0:.2%})")
for row in sorted(rows, key=lambda r: -r["false_accepts"])[:5]:
if row["false_accepts"]:
print(f" {row['false_accepts']:>3} {row['file'][:55]}")
if args.write:
out = os.path.join(directory, "eval_result.json")
with open(out, "w") as handle:
json.dump(report, handle, ensure_ascii=False, indent=1)
print(f" wrote {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,127 @@
"""Score external-proxy image sets through the shipped identity lane.
These sets are not Tier 2 (see data/external-eval/README.md). They exist to test
the pipeline on data it had no part in building, so the one thing this script
must never do is score an image that has since been enrolled - that is
training-set accuracy wearing a held-out label.
`leaked()` is the same check the shelf evaluator runs, and it is applied here
for that reason: enrolling a packshot from an external set is a normal thing to
do (it is how F-12 was closed), and it silently converts one of these rows into
a leak unless something refuses it.
.venv/bin/python code/eval_external.py --stock stock-lopia-clean \
data/external-eval/kewpie data/external-eval/other-mayo
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import sys
from typing import Any, Dict, List
current_dir = os.path.dirname(os.path.abspath(__file__))
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
import cv2 # noqa: E402
from eval_identity import LOCAL_ACCEPT, LocalLaneAdapter, leaked # noqa: E402
def score_set(lane: Any, directory: str, stock_dir: str) -> Dict[str, Any]:
rows: List[Dict[str, Any]] = []
for path in sorted(glob.glob(os.path.join(directory, "*.jpg"))):
name = os.path.basename(path)
truth = "sku_" + name.split("_")[1]
image = cv2.imread(path)
if image is None:
rows.append({"file": name, "truth": truth, "verdict": "unreadable"})
continue
# The whole image is the crop: an external packshot is not a shelf facing.
box = (0, 0, image.shape[1], image.shape[0])
leak = leaked(image, box, stock_dir)
if leak:
rows.append({
"file": name, "truth": truth, "verdict": "leaked",
"leaked_into": leak,
"note": "enrolled in the catalog; scoring it would be training accuracy",
})
continue
decision = lane.decide(
image, lane.rank(image), accept=LOCAL_ACCEPT, margin=0.0, scale="full"
)
best = decision.get("best_sku")
verdict = "correct" if best == truth else ("abstain" if not best else "wrong")
rows.append({
"file": name, "truth": truth, "best": best,
"confidence": decision.get("confidence"),
"status": decision.get("status"),
"source": decision.get("source"),
"verdict": verdict,
})
scored = [r for r in rows if r["verdict"] in ("correct", "abstain", "wrong")]
counts = {v: sum(1 for r in scored if r["verdict"] == v)
for v in ("correct", "abstain", "wrong")}
# Per SKU, because a set total hides which products the lane can and cannot
# name. Every SKU here has images from two independent source domains, so a
# SKU that scores the same on both is not one lucky photograph.
by_sku: Dict[str, Dict[str, int]] = {}
for row in scored:
bucket = by_sku.setdefault(row["truth"], {"correct": 0, "abstain": 0, "wrong": 0})
bucket[row["verdict"]] += 1
return {
"dataset": os.path.basename(directory.rstrip("/")),
"catalog": stock_dir,
"note": "External proxy images, not Tier 2. Scored through the shipped "
"LocalLaneAdapter + DecisionPolicy.",
"rows": rows,
"scored": len(scored),
"leaked": sum(1 for r in rows if r["verdict"] == "leaked"),
"by_sku": by_sku,
**counts,
}
def main(argv: List[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directories", nargs="+", help="external set directories")
parser.add_argument("--stock", default="stock-lopia-clean")
parser.add_argument("--write", action="store_true",
help="write eval_result.json into each set directory")
args = parser.parse_args(argv)
lane = LocalLaneAdapter(args.stock)
totals = {"correct": 0, "abstain": 0, "wrong": 0, "leaked": 0, "scored": 0}
for directory in args.directories:
report = score_set(lane, directory, args.stock)
for key in totals:
totals[key] += report[key]
print(f"{report['dataset']}: correct {report['correct']} | "
f"abstain {report['abstain']} | wrong {report['wrong']} | "
f"leaked {report['leaked']} of {report['scored']} scored")
for sku, bucket in sorted(report["by_sku"].items()):
print(f" {sku:<10} correct {bucket['correct']} "
f"abstain {bucket['abstain']} wrong {bucket['wrong']}")
for row in report["rows"]:
if row["verdict"] in ("wrong", "leaked"):
print(f" {row['verdict'].upper():<7} {row['file']} "
f"truth={row['truth']} best={row.get('best')} "
f"{row.get('leaked_into', '')}")
if args.write:
out = os.path.join(directory, "eval_result.json")
with open(out, "w") as handle:
json.dump(report, handle, ensure_ascii=False, indent=1)
print(f" wrote {out}")
print(f"\nTOTAL: {totals['scored']} scored, correct {totals['correct']}, "
f"abstain {totals['abstain']}, FALSE ACCEPTS {totals['wrong']}, "
f"leaked {totals['leaked']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,10 +1,14 @@
"""
Run the pipeline over store photos it has never seen.
python code/eval_external_images.py [~/dorayaki-data/shelf-images]
python code/eval_external_images.py [data/external-shelves/wikimedia-commons]
Reports detection and identity per image, and writes a JSON summary. See
docs/EXTERNAL-EVAL.md for what the numbers mean.
The images live in the repository (`data/external-shelves/`), not in a home
directory: they are evaluation evidence, and evidence that only exists on one
machine cannot be re-checked.
"""
import json, os, sys, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@ -13,8 +17,9 @@ from engine import build_default_engine, EngineSettings
from tags import TagService, TagSettings
from reconcile import attach_tags_to_boxes
ROOT = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/dorayaki-data/shelf-images")
manifest = json.load(open(os.path.join(ROOT, "manifest.json")))
DEFAULT_ROOT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data", "external-shelves", "wikimedia-commons")
ROOT = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else DEFAULT_ROOT)
engine = build_default_engine(EngineSettings(stock_dir="stock-lopia"))
catalog_by_sku = {p["sku_id"]: p for p in engine.catalog()}

View file

@ -78,6 +78,12 @@ LIVE_LONG_SIDE = 1280
# the enrollment protocol's session manifest (DECISION §8); this is the cheap
# backstop that runs without one.
LEAK_SIGNATURE_MAD = 20.0
# A signature hit is confirmed by correlation before it counts as a leak. A
# genuine re-cut, re-encode or rescale of an enrolled view correlates at 0.99+;
# two different photographs of the same product sit near 0.3. Measured on the
# l09 pair that the signature alone misjudged (0.28) and on re-encoded and
# rescaled copies of the enrolled view (0.999, 0.997).
LEAK_CORRELATION = 0.90
def load_labels(path: str) -> Dict[str, Any]:
@ -219,6 +225,24 @@ def _catalog_templates(stock_dir: str) -> List[Tuple[str, str, Tuple[int, int],
return cached
def _correlation(left: Any, right: Any) -> float:
"""Mean-subtracted correlation of two images, resizing `right` to `left`.
Scale- and re-encode-tolerant, unlike a pixel comparison: the enrolled view
and a re-saved or rescaled copy of it correlate at 0.99+, while two
different photographs of the same product sit near 0.3.
"""
if right.shape[:2] != left.shape[:2]:
right = cv2.resize(right, (left.shape[1], left.shape[0]),
interpolation=cv2.INTER_AREA)
a = cv2.cvtColor(left, cv2.COLOR_BGR2GRAY).astype(np.float32).ravel()
b = cv2.cvtColor(right, cv2.COLOR_BGR2GRAY).astype(np.float32).ravel()
a -= a.mean()
b -= b.mean()
denom = float(np.linalg.norm(a) * np.linalg.norm(b))
return float((a * b).sum() / denom) if denom else 0.0
def leaked(image: Any, box: Sequence[int], stock_dir: str) -> Optional[str]:
"""Name of the catalog template this crop is, or None when it is held out.
@ -238,9 +262,21 @@ def leaked(image: Any, box: Sequence[int], stock_dir: str) -> Optional[str]:
return None
crop_signatures = _signatures(crop)
for stem, name, shape, signatures in _catalog_templates(stock_dir):
if min(float(np.abs(signature - crop_signature).mean())
for signature in signatures for crop_signature in crop_signatures) < LEAK_SIGNATURE_MAD:
return stem
close = min(float(np.abs(signature - crop_signature).mean())
for signature in signatures for crop_signature in crop_signatures)
if close < LEAK_SIGNATURE_MAD:
# The signature is a 64x64 grayscale thumbnail, so it is coarse
# enough to fire on two different photos 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). Confirm before calling it a leak.
# A genuine re-cut or re-encode correlates at 0.99+; that pair
# correlates at 0.28, so the two signals together are decisive
# where either alone is not.
template = cv2.imread(os.path.join(stock_dir, name))
if template is None or _correlation(crop, template) >= LEAK_CORRELATION:
return stem
continue
if shape != crop.shape[:2]:
continue
template = cv2.imread(os.path.join(stock_dir, name))
@ -377,10 +413,14 @@ class LocalLaneAdapter(Lane):
name = "local"
def __init__(self, stock_dir: str, ocr: Any = None) -> None:
def __init__(self, stock_dir: str, ocr: Any = None, use_text: bool = True) -> None:
from identity_lanes import LocalLane, TextLane
self.lane = LocalLane(stock_dir)
# E-3 asks what the text lane is worth, which is only answerable by
# running the same crops without it. Building the lane anyway keeps the
# comparison to one variable: the OCR cost is paid either way.
self.use_text = use_text
self.text_lane = TextLane.from_catalog(
_catalog_rows(stock_dir), stock_dir, ocr if ocr is not None else _ocr_engine()
)
@ -392,6 +432,10 @@ class LocalLaneAdapter(Lane):
margin: float, scale: str) -> Dict[str, Any]:
from identity_lanes import DecisionPolicy
if not self.use_text:
from identity import decide_identity
return decide_identity(dict(ranked), scale="local", min_score=accept)
read = self.text_lane.read(crop)
text_rank = sorted(
self.text_lane.scores_from_text(read).items(),
@ -605,6 +649,8 @@ def main() -> int:
parser.add_argument("--lanes", default="local,matcher,gallery,hybrid-verify")
parser.add_argument("--shortlist", type=int, default=5)
parser.add_argument("--accept", type=float, default=None, help="override the lane's own floor")
parser.add_argument("--no-text", action="store_true",
help="run the local lane without the text lane (E-3)")
parser.add_argument("--margin", type=float, default=None)
parser.add_argument("--live", action="store_true", help="also score at live resolution")
parser.add_argument("--json", help="write the report here")
@ -644,7 +690,7 @@ def main() -> int:
if "van" in wanted:
lanes["van"] = VanLane(args.stock_dir)
if "local" in wanted:
lanes["local"] = LocalLaneAdapter(args.stock_dir)
lanes["local"] = LocalLaneAdapter(args.stock_dir, use_text=not args.no_text)
if "gallery" in wanted or "hybrid-verify" in wanted:
lanes["gallery"] = GalleryLane(args.stock_dir, catalog_skus)
if "hybrid-verify" in wanted:

View file

@ -292,7 +292,9 @@ def main() -> None:
parser.add_argument("--epochs", type=int, default=60)
parser.add_argument("--views", type=int, default=2, help="enrolment views per class")
parser.add_argument("--augment", type=int, default=12, help="augmented copies per enrolment view")
parser.add_argument("--image", default=os.path.expanduser("~/dorayaki-data/shelf-images"))
parser.add_argument("--image", default=os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data", "external-shelves", "wikimedia-commons"))
parser.add_argument("--stock", default=None,
help="train on a store's enrolled views instead of the milk shelf, "
"and write <stock>/identity_head.pt for the gallery to load")

View file

@ -30,8 +30,11 @@ DEFAULT_MIN_CONFIDENCE = 0.55
# `local` scale (identity_lanes.LocalLane). The value is already a margin over
# the runner-up, so the relative-margin test does not apply to it: a single
# absolute line is the whole gate.
LOCAL_ACCEPT = 2.0
# absolute line is the whole gate. Kept equal to identity_lanes.LOCAL_ACCEPT,
# which is what the engine's DecisionPolicy uses; this copy exists for the
# evaluator's `--no-text` path, and two different lines would score the same
# crops two ways.
LOCAL_ACCEPT = 4.0
def relative_confidence(top_score: float, runner_up_score: float) -> float:
@ -248,6 +251,26 @@ def decide_identity(
"message": message,
}
if scale == "van":
# Van's contract: the top SKU, always answered. No gate, no abstention -
# this is the baseline the decision is measured against, and it exists
# so the stream comparison is reproducible rather than asserted.
if top_score <= 0.0:
level, message, best = IDENTITY_REJECTED, "No matching evidence", None
else:
level, message, best = IDENTITY_ACCEPTED, "Always-argmax baseline", top_sku
return {
"status": "success" if level == IDENTITY_ACCEPTED else level,
"method": method,
"best_sku": best,
"score": round(top_score, 2),
"confidence": confidence,
"identity_level": level,
"ranked_candidates": ranked,
"details": top_details,
"message": message,
}
if top_score <= 0.0:
level = IDENTITY_REJECTED
message = "No matching evidence"

View file

@ -28,7 +28,7 @@ import os
import re
import sys
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Sequence, Tuple
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
current_dir = os.path.dirname(os.path.abspath(__file__))
if current_dir not in sys.path:
@ -43,7 +43,14 @@ from rebuild_catalog import bigrams, normalize_text, signature_bigrams, sku_of
# on the corrected image6 labels: 2.0 keeps 95% of shown labels correct at 59%
# coverage, 1.0 keeps 87% at 66%. The stricter line is the default because a
# wrong label on a shelf is worse than a missing one.
LOCAL_ACCEPT = 2.0
#
# 4.0 since 2026-09-29: the line was tuned on image6 alone, where every crop is
# a facing of a product the shelf actually holds. On 2297 crops from unrelated
# Wikimedia shelves, 2.0 accepted 25 of them on geometry alone (scores 2.26-4.81
# with no text overlap at all), a 1.1% far-unknown rate against G3's 1%. At 4.0
# that falls to 8 (0.35%) while image6 keeps 42 of 48 correct labels at 95.5%
# precision, which is the only region passing both G2 and G3 with coverage left.
LOCAL_ACCEPT = 4.0
# The text lane's own accept line: a bigram overlap this high, with this much
# margin over the runner-up, names the row on its own. Measured: the text lane
# picks the right SKU on 41-42 of the 55 facings where it reads anything.
@ -51,12 +58,16 @@ TEXT_ACCEPT = 0.5
TEXT_MARGIN = 0.15
# How many of the read's bigrams the winning row must actually contain. The
# ratio alone is not evidence: a two-pair read the row happens to contain scores
# 1.0, and "サソース" named sku_l08 that way. Two pairs is the floor that keeps
# every correct text answer on image6 (40 of 40) while dropping the wrong ones
# from 6 to 2, taking shown-label precision from 87% to 95%. It is a floor on
# evidence, not on the ratio, so a long read of shared brand copy still scores
# low and is still refused by the margin.
TEXT_MIN_SHARED = 2
# 1.0, and "サソース" named sku_l08 that way. Two pairs was the old floor, and it
# is too low once the lane answers on unrelated shelves: on 2297 crops from
# Wikimedia milk, soy and snack shelves, 20 of 26 text-lane answers rested on
# exactly two shared bigrams ("避くて おいしい" -> sku_l12 via おい/いし), an
# 11.4% false accept rate against G3's 1%. Four is where the two sides separate
# once the local line is also at 4.0: false accepts fall to 8 (0.35%) while
# image6 keeps 42 of its 48 correct labels. It is a floor on evidence, not on
# the ratio, so a long read of shared brand copy still scores low and is still
# refused by the margin.
TEXT_MIN_SHARED = 4
# Reference pyramid heights. A ~1000 px packshot against a 110-250 px crop is a
# 4-8x ratio, and ORB's 8x1.2 pyramid tops out at 3.6x, which is why ORB and
# BRISK find zero inliers at live resolution. Scaling each reference to the
@ -73,6 +84,15 @@ HUE_VALUE_MIN = 50
# Quantity tokens: a number with its unit, in either script. Cut whole, because
# the unit's own letters ("g", "ml", "%") are what survived a digits-only rule.
_QUANTITY = re.compile(r"\d+(?:\.\d+)?\s*(?:KG|G|ML|L|%|個|本|枚|入|袋|缶|パック)")
# A package's own size, in mass units. Only mass can contradict a package size:
# a percentage ("カロリー 50% カット") is a claim about the contents, not the
# package, and counting it made every low-calorie crop look like a 50 g jar.
_MASS = re.compile(r"(\d+(?:\.\d+)?)\s*(KG|G)", re.IGNORECASE)
# The range a retail package of this class falls in. Below it are fragments of a
# longer number ("20" read out of "200g"); above it are two numbers run together
# ("5210" out of "210g"). Both are OCR noise, not a different package, and
# either would veto a correct answer if taken at face value.
PACKAGE_MASS_RANGE = (100.0, 2000.0)
# How many surviving bigrams a read needs before it may name a product.
IDENTITY_BIGRAM_FLOOR = 3
@ -516,6 +536,40 @@ class DecisionPolicy:
return self.text_min_shared
return len(bigrams(read) & signature_bigrams(signature))
def _contradicts_size(self, read: str, sku: str) -> bool:
"""Does the read print a package size the row's name does not have?
The near-unknown case the text ranking cannot catch. A crop of a
Kewpie 450 g bag reads "マヨネーズ 450g", and every mayonnaise row in
the catalog explains "マヨネーズ" equally well, so the ranking ties at
1.0 and the strict-greater veto above never fires - the local lane's
answer stands. The size is the part that differs, and it is printed
plainly, so it is the part worth checking.
Only a mass in `PACKAGE_MASS_RANGE` counts. Outside it the read is
noise, not a package: "20" is a fragment of "200g" and "5210" is two
numbers run together, and vetoing on either would cost correct
answers. Measured on image6, this vetoes 3 false accepts at full
resolution and 1 at live, and no correct answer at either.
"""
signature = self.signatures.get(sku)
if signature is None:
return False
read_masses = {
float(number) for number, _unit in _MASS.findall(normalize_text(read))
}
plausible = {
mass for mass in read_masses
if PACKAGE_MASS_RANGE[0] <= mass <= PACKAGE_MASS_RANGE[1]
}
if not plausible:
return False
name_masses = {
float(number) for number, _unit in _MASS.findall(normalize_text(signature))
}
return not (read_masses & name_masses)
def decide(
self,
local_rank: Any,
@ -550,6 +604,11 @@ class DecisionPolicy:
and (text_top[1] - text_runner) >= self.text_margin
and carries_identity(text_read or "")
and self._shared_evidence(text_read or "", text_top[0]) >= self.text_min_shared
# A row the read contradicts on size cannot be named by that read
# either. Without this the veto above only moves the answer: the
# local lane's row is blocked and the text lane then names the same
# row, because "マヨネーズ" is what both of them matched.
and not self._contradicts_size(text_read or "", text_top[0])
else None
)
@ -562,6 +621,8 @@ class DecisionPolicy:
# artwork, so the geometry is real and only the words differ.
if text_top and text_top[0] != candidate and text_top[1] > local_text_floor(text, candidate):
answer = None
elif self._contradicts_size(text_read or "", candidate):
answer = None
else:
answer, source = candidate, "local"
if answer is None and text_names:

View file

@ -1301,6 +1301,10 @@ class LiveSession:
decision = DecisionPolicy(
signatures=text_lane.signatures if text_lane else None
).decide(list(scores.items()), text_rank, text_read=text_read)
elif scale == "van":
# Van's contract on the stream: the aggregate's top SKU, always
# answered. Same scorer as `matcher`, no gate.
decision = decide_identity(mean_scores, method=method, scale="van")
else:
decision = decide_identity(mean_scores, method=method, scale=scale)
track.candidates = [

View file

@ -170,6 +170,34 @@ def name_ocr_similarity(name: str, ocr_text: str) -> float:
return len(name_bigrams & bigrams(ocr_text)) / max(1, len(name_bigrams))
def best_orientation_text(ocr_engine: Any, image: np.ndarray, name: str = "") -> str:
"""OCR text from whichever of the four right-angle rotations matches best.
A packshot's identifying text is not always horizontal: the やまや
めんたいマヨネーズ packshot reads 明太手屋さんが only at 270 degrees, and
the upright pass returns three stray characters. Checking one orientation
refused a correct image, which is the worst failure this check has - it
rejects good data while claiming to validate it.
The winner is the rotation that explains the most of the catalog name, not
the one that reads the most characters: the upright pass on that packshot
returns stray kana that concatenate longer than the real read, so length
picks the wrong one. With no name to compare against, length is the only
signal left.
"""
rotations = (
image,
cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE),
cv2.rotate(image, cv2.ROTATE_180),
cv2.rotate(image, cv2.ROTATE_90_COUNTERCLOCKWISE),
)
reads = [" ".join(line.text for line in ocr_engine.read(upscale(rotated)).lines)
for rotated in rotations]
if name:
return max(reads, key=lambda text: name_ocr_similarity(name, text))
return max(reads, key=lambda text: len(text.strip()))
def collect(source: str) -> Dict[str, List[str]]:
by_sku: Dict[str, List[str]] = {}
for name in sorted(os.listdir(source)):
@ -291,7 +319,7 @@ def audit(
continue
if ocr_engine is not None and names is not None:
try:
text = " ".join(line.text for line in ocr_engine.read(upscale(image)).lines)
text = best_orientation_text(ocr_engine, image, names.get(sku, ""))
except OcrUnavailable as error:
print(f"OCR skipped for {name}: {error}", file=sys.stderr)
else:
@ -317,6 +345,25 @@ def audit(
return kept, refused
def unattributed(source: str) -> List[str]:
"""SKUs whose views have no entry in the source's ATTRIBUTION.json.
Reported, not refused. A view with no recorded source is still a usable
reference - the six that predate the attribution file are independent of
the evaluation photo and their OCR matches their catalog names - but the
provenance is gone, and provenance is what lets a later audit tell a
re-sourced packshot from a leaked shelf crop. Naming them keeps the gap
from growing quietly.
"""
path = os.path.join(source, "ATTRIBUTION.json")
try:
with open(path) as handle:
recorded = json.load(handle)
except (OSError, ValueError):
recorded = {}
return sorted(sku for sku in collect(source) if sku not in recorded)
def text_discriminated_siblings(
kept: Dict[str, List[str]], source: str, names: Optional[Dict[str, str]] = None
) -> List[Dict[str, Any]]:
@ -535,6 +582,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
# and vetoes it from the CSV alone, so it is a coverage gap, not a refusal.
text_only = sorted(set(names) - set(kept))
siblings = text_discriminated_siblings(kept, args.source, names)
small = below_pyramid_floor(kept, args.source)
no_source = unattributed(args.source)
verdict = live_ready_verdict(kept, names, shelf_skus)
report = {
"source": args.source,
@ -548,6 +597,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
"text_only_skus": text_only,
"text_discriminated_siblings": siblings,
"below_pyramid_floor": small,
"unattributed_skus": no_source,
"completeness": verdict,
"skus": {sku: views for sku, views in sorted(kept.items())},
"refused": refused,
@ -571,6 +621,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
for row in small:
print(f" {row['sku']:<10} {row['view']:<20} {row['height']}px tall - "
"scores are not comparable with the rest; re-source this view")
if no_source:
print(f"\nno recorded source ({len(no_source)}): {', '.join(no_source)} - "
"usable, but provenance is missing; re-sourcing would close it")
if verdict["live_ready"] is None:
print(f"\ncompleteness: unknown - {verdict['reason']}")
elif verdict["live_ready"]:

View file

@ -136,14 +136,19 @@ class TextLaneSignatureTests(unittest.TestCase):
self.assertGreaterEqual(ranked[0][1], TEXT_ACCEPT)
self.assertGreaterEqual(ranked[0][1] - ranked[1][1], TEXT_MARGIN)
def test_one_shared_pair_is_not_enough_to_name_a_row(self):
"""A ratio of 1.0 on a single pair is not evidence.
def test_a_thin_read_is_not_enough_to_name_a_row(self):
"""A ratio of 1.0 on a few pairs is not evidence.
"XXイス" contains exactly one pair the catalog knows, and the row that
contains it scores 1.0 with a full margin over every other row. On
image6 that shape named sku_l08 from the read "サソース". The floor
asks how much of the read the row actually explains, so one pair is
refused while the same read with two is accepted.
image6 that shape named sku_l08 from the read "サソース". The floor asks
how much of the read the row actually explains, so one pair and two
pairs are both refused while a real read of the name is accepted.
Two pairs used to be enough. It is not: on 2297 crops from unrelated
Wikimedia shelves, 20 of 26 text-lane answers rested on exactly two
shared bigrams - "避くて おいしい" named sku_l12 through おい/いし - for
an 11.4% false accept rate. The floor is 4.
"""
from identity_lanes import DecisionPolicy, TextLane
@ -153,16 +158,22 @@ class TextLaneSignatureTests(unittest.TestCase):
]
lane = TextLane.from_catalog(rows, "no-such-dir", ocr_engine=None)
thin = sorted(lane.scores_from_text("XXイス").items(), key=lambda kv: kv[1], reverse=True)
self.assertEqual(thin[0][1], 1.0, "the ratio alone cannot see the difference")
self.assertIsNone(
DecisionPolicy(signatures=lane.signatures).decide([], thin, text_read="XXイス")["best_sku"]
)
for thin_read in ("XXイス", "ZZソース"):
thin = sorted(lane.scores_from_text(thin_read).items(),
key=lambda kv: kv[1], reverse=True)
self.assertEqual(thin[0][1], 1.0, "the ratio alone cannot see the difference")
self.assertIsNone(
DecisionPolicy(signatures=lane.signatures).decide(
[], thin, text_read=thin_read
)["best_sku"],
f"{thin_read!r} shares too few pairs to name a row",
)
enough = sorted(lane.scores_from_text("ZZソース").items(), key=lambda kv: kv[1], reverse=True)
enough = sorted(lane.scores_from_text("オイスター").items(),
key=lambda kv: kv[1], reverse=True)
self.assertEqual(
DecisionPolicy(signatures=lane.signatures).decide(
[], enough, text_read="ZZソース"
[], enough, text_read="オイスター"
)["best_sku"],
"sku_l08",
)
@ -616,8 +627,11 @@ class DecisionPolicyTests(unittest.TestCase):
the margin over the runner-up. Re-sorting by value names a different SKU
than the lane chose - here sku_b has the larger value but sku_a is the
lane's own first choice, and sku_a is what must be answered.
Both values clear LOCAL_ACCEPT, so the test is about the ordering and
not about the gate.
"""
decision = self.policy().decide([("sku_a", 3.0), ("sku_b", 5.0)], [])
decision = self.policy().decide([("sku_a", 4.5), ("sku_b", 5.0)], [])
self.assertEqual(decision["best_sku"], "sku_a")
def test_a_size_only_read_does_not_fill_in(self):
@ -632,6 +646,56 @@ class DecisionPolicyTests(unittest.TestCase):
)
self.assertIsNone(decision["best_sku"])
def test_a_read_printing_another_size_vetoes_the_local_answer(self):
"""The documented near-unknown: a Kewpie 450 g bag the catalog lacks.
The crop reads "マヨネーズ 450g", which every mayonnaise row explains
equally, so the text ranking ties at 1.0 and the strict-greater veto
cannot fire. The size is what differs, and the catalog's Kewpie rows
are 200 g, so the read contradicts the row the local lane chose.
"""
from identity_lanes import DecisionPolicy
policy = DecisionPolicy(signatures={"sku_a": "キューピー ハーフマヨネーズ 400g"})
decision = policy.decide(
[("sku_a", 5.0)], [("sku_a", 1.0)], text_read="ピーマヨネーズ 450g"
)
self.assertIsNone(decision["best_sku"])
def test_a_read_agreeing_on_size_does_not_veto(self):
"""The same read with the row's own size must still answer."""
from identity_lanes import DecisionPolicy
policy = DecisionPolicy(signatures={"sku_a": "キューピー ハーフマヨネーズ 400g"})
decision = policy.decide(
[("sku_a", 5.0)], [("sku_a", 1.0)], text_read="ピーマヨネーズ 400g"
)
self.assertEqual(decision["best_sku"], "sku_a")
def test_ocr_noise_around_a_size_does_not_veto(self):
"""A fragment or a run-together number is noise, not a package size.
Live reads of a correct 200 g row came back as "20" and "5210"; both
are outside the range a package of this class falls in, and vetoing on
either would cost a correct answer.
"""
from identity_lanes import DecisionPolicy
policy = DecisionPolicy(signatures={"sku_a": "キューピー ハーフマヨネーズ 200g"})
for read in ("アマニ油 20gs", "内臓脂訪を 滅与ず ッデュービーフィ5 210g", "n0gベ% 50"):
decision = policy.decide([("sku_a", 5.0)], [("sku_a", 1.0)], text_read=read)
self.assertEqual(decision["best_sku"], "sku_a", read)
def test_a_percentage_is_not_a_package_size(self):
"""A low-calorie claim must not read as a 50 g package."""
from identity_lanes import DecisionPolicy
policy = DecisionPolicy(signatures={"sku_a": "キューピー ハーフマヨネーズ 400g"})
decision = policy.decide(
[("sku_a", 5.0)], [("sku_a", 1.0)], text_read="カロリー 50% カット 400g"
)
self.assertEqual(decision["best_sku"], "sku_a")
def test_a_named_read_with_a_size_still_fills_in(self):
decision = self.policy().decide([], [("sku_b", 0.9)], text_read="コクうま 65%")
self.assertEqual(decision["best_sku"], "sku_b")

View file

@ -50,3 +50,20 @@ class IdentityRankingTests(unittest.TestCase):
self.assertEqual(ranked["identity_level"], IDENTITY_ACCEPTED)
self.assertEqual(ranked["best_sku"], "sku_a")
self.assertEqual(ranked["status"], "success")
def test_van_answers_what_the_gate_would_refuse(self):
# Van's contract is the baseline the decision is measured against, so it
# must answer exactly where the shipped gate abstains. A weak score and
# a close pair are both refused above; here both are named.
weak = decide_identity({"sku_a": 0.4, "sku_b": 0.0}, scale="van")
self.assertEqual(weak["identity_level"], IDENTITY_ACCEPTED)
self.assertEqual(weak["best_sku"], "sku_a")
close = decide_identity({"sku_a": 20.0, "sku_b": 19.5}, scale="van")
self.assertEqual(close["best_sku"], "sku_a")
def test_van_still_rejects_when_there_is_no_evidence(self):
# Always-argmax is not always-answer: with nothing to rank there is no
# argmax, and naming a SKU from an empty score set would be a fabrication.
ranked = decide_identity({}, scale="van")
self.assertEqual(ranked["identity_level"], IDENTITY_REJECTED)
self.assertIsNone(ranked["best_sku"])

View file

@ -184,5 +184,95 @@ class BadShelfListFailsBeforeTheSwapTests(unittest.TestCase):
self.assertFalse(os.path.exists(output), "the catalog was replaced before the check")
if __name__ == "__main__":
unittest.main()
class OrientationTests(unittest.TestCase):
"""A packshot whose identifying text is not horizontal.
The やまや めんたいマヨネーズ packshot reads its brand only when rotated;
the upright pass returns stray kana. Checking one orientation refused a
correct image, which is the worst failure this check has - it rejects good
data while claiming to validate it.
"""
class FakeOcr:
"""Reads a fixed string per rotation.
Rotations are identified by aspect ratio, not by exact shape: the
caller upscales before OCR, so the fake sees scaled dimensions. The
upright and 180-degree passes keep the source's orientation (wide),
the two 90-degree passes swap it (tall). A corner marker cannot tell
them apart, because `cv2.rotate` carries the corner value through.
"""
name = "fake"
def __init__(self, by_orientation):
self.by_orientation = by_orientation
def read(self, image):
class Line:
def __init__(self, text):
self.text = text
class Read:
def __init__(self, text):
self.lines = [Line(part) for part in text.split("|") if part]
height, width = image.shape[:2]
key = "wide" if width > height else "tall"
return Read(self.by_orientation.get(key, ""))
def marked(self, height, width):
return np.zeros((height, width, 3), dtype="uint8")
def test_a_rotated_read_wins_when_it_matches_the_name(self):
"""The upright pass reads noise; the rotated pass reads the product."""
from rebuild_catalog import best_orientation_text
engine = self.FakeOcr({"wide": "つくつ|た", "tall": "明太手屋さんが|マヨネーズ"})
text = best_orientation_text(engine, self.marked(40, 60), "やまや めんたいマヨネーズタイプ 200g")
self.assertIn("明太手屋さんが", text)
def test_the_upright_read_wins_when_it_matches(self):
from rebuild_catalog import best_orientation_text
engine = self.FakeOcr({"wide": "ピュアセレクト|マヨネーズ|450g", "tall": "ノイズ"})
text = best_orientation_text(engine, self.marked(40, 60), "ピュアセレクト マヨネーズ 450g")
self.assertIn("ピュアセレクト", text)
def test_without_a_name_the_longest_read_wins(self):
from rebuild_catalog import best_orientation_text
engine = self.FakeOcr({"wide": "ab", "tall": "abcdefgh"})
self.assertEqual(best_orientation_text(engine, self.marked(40, 60)), "abcdefgh")
def test_audit_keeps_a_view_whose_name_only_reads_rotated(self):
"""The regression: `audit` must try every orientation before refusing.
This is the boundary that matters. `best_orientation_text` passing its
own unit test proves nothing about enrollment - the bug was that
`audit` called a single-orientation read, so a correct packshot whose
brand text runs vertically was refused as a name/OCR mismatch.
"""
from rebuild_catalog import audit
root = tempfile.mkdtemp(prefix="orient-")
# Wider than tall, so the upright pass is the "wide" read (noise) and
# only a 90-degree rotation produces the "tall" read that matches.
cv2.imwrite(os.path.join(root, "sku_x1.jpg"), self.marked(300, 400))
engine = self.FakeOcr({"wide": "つくつ|た", "tall": "キユーピー|エッグケア|305g"})
kept, refused = audit(root, min_views=1, names={"sku_x1": "キューピー エッグケア 305g"},
ocr_engine=engine)
self.assertEqual(kept, {"sku_x1": ["sku_x1.jpg"]}, refused)
self.assertEqual(refused, [])
def test_audit_still_refuses_a_view_the_name_never_matches(self):
"""The check must keep its teeth: a wrong product is still refused."""
from rebuild_catalog import audit
root = tempfile.mkdtemp(prefix="orient-bad-")
cv2.imwrite(os.path.join(root, "sku_x1.jpg"), self.marked(400, 300))
engine = self.FakeOcr({"wide": "キューピー|マヨネーズ", "tall": "キューピー|マヨネーズ"})
kept, refused = audit(root, min_views=1, names={"sku_x1": "やまや めんたいマヨネーズタイプ 200g"},
ocr_engine=engine)
self.assertEqual(kept, {})
self.assertEqual(len(refused), 1, refused)
self.assertIn("name/OCR", refused[0]["reason"])

View file

@ -0,0 +1,34 @@
# Recognition data
Everything the identity work needs to be reproducible: sourced packshots,
evaluation runs, and the audits that produced them. Nothing here is ephemeral;
a run that matters is written here, not to a temp directory.
## `packshots/source/`
Front packshots as downloaded, before enrollment. `ATTRIBUTION.json` records the
source URL, the product as printed, and the manufacturer for each. `ocr-reads.json`
holds the OCR text each image produced, so a name/OCR refusal can be re-checked
without re-running Vision.
`rebuild_catalog.py` reads this directory and writes a catalog:
.venv/bin/python code/rebuild_catalog.py --source data/packshots/source \
--output stock-lopia-clean --csv stock-lopia/stock.csv --min-views 1 --replace
## `evaluations/`
One directory per session. Each holds the raw artifacts a claim was computed
from, so a number in DECISION.md can be traced back to the run that produced it.
| Directory | Contents |
|---|---|
| `stream/` | WebSocket track dumps from `stream_client.py`, scored by `score_stream.py`. |
| `still/` | `/v1/recognize` responses for the still-image path. |
| `catalog-audit/` | `rebuild_catalog.py --json` reports. |
| `scratch/` | Intermediate images (rotations, crops) kept because they document a finding. |
Scoring a stream dump:
.venv/bin/python code/score_stream.py data/evaluations/<session>/stream/<file>.json \
--labels eval/labels/image6.json --stock-dir stock-lopia-clean

View file

@ -0,0 +1,76 @@
{
"source": "data/packshots/source",
"output": "stock-lopia-clean",
"min_width": 120,
"min_height": 160,
"min_views": 1,
"duplicate_cosine": 0.98,
"min_name_ocr": 0.7,
"ocr": "vision",
"text_only_skus": [
"sku_l04",
"sku_l07",
"sku_l08",
"sku_l12"
],
"text_discriminated_siblings": [
{
"sku": "sku_l09",
"view": "sku_l09.jpg",
"sibling_sku": "sku_l10",
"sibling_view": "sku_l10.jpg",
"cosine": 0.9817,
"name_similarity": 0.333
}
],
"below_pyramid_floor": [],
"completeness": {
"live_ready": null,
"reason": "no --shelf-tags given; the completeness rule needs the shelf's SKU list",
"enrolled": 8,
"text_only": [
"sku_l04",
"sku_l07",
"sku_l08",
"sku_l12"
]
},
"skus": {
"sku_l01": [
"sku_l01.jpg"
],
"sku_l02": [
"sku_l02.jpg"
],
"sku_l03": [
"sku_l03.jpg"
],
"sku_l05": [
"sku_l05.jpg"
],
"sku_l06": [
"sku_l06.jpg"
],
"sku_l09": [
"sku_l09.jpg"
],
"sku_l10": [
"sku_l10.jpg"
],
"sku_l11": [
"sku_l11.jpg"
]
},
"refused": [
{
"sku": "sku_l04",
"view": "sku_l04.jpg",
"reason": "name/OCR 0.00 below 0.70: 明太手屋さんが つくつ た コ"
},
{
"sku": "sku_l07",
"view": "sku_l07.jpg",
"reason": "name/OCR 0.57 below 0.70: ラッキー マヨネーズ 調味料(アミノ酸)不使用"
}
]
}

View file

@ -0,0 +1,221 @@
{
"image": "add/image6.jpg",
"stock_dir": "stock-lopia-clean",
"labels": "eval/labels/image6.json",
"provenance": {
"catalog_hash": "3f1653d9d962073af96121250006b9b6d86113556797bbee65de68b60e87a64d",
"labels_hash": "5aabf029921ced767d4e14a18e8bccb55812dd974852c56e0931a98fa10366cf",
"image_hash": "a08b89ba37d27c9d5a81bcd56bcd042e6baf54d2f182bbaaf47e97f14c9cd525",
"code_revision": "68061635d9fef3097609945bfd9ac1935791b398"
},
"facings": 111,
"leakage": {
"excluded": 0,
"labels": []
},
"hub_share_limit": 0.3,
"enrollment": {
"catalog_skus": [
"sku_l01",
"sku_l02",
"sku_l03",
"sku_l04",
"sku_l05",
"sku_l06",
"sku_l07",
"sku_l08",
"sku_l09",
"sku_l10",
"sku_l11",
"sku_l12"
],
"enrolled_skus": [
"sku_l01",
"sku_l02",
"sku_l03",
"sku_l05",
"sku_l06",
"sku_l09",
"sku_l10",
"sku_l11"
],
"unenrolled_skus": {
"sku_l07": 6,
"sku_l04": 1,
"sku_l08": 1
},
"facings_without_enrollment": 8,
"scored_facings": 109
},
"results": {
"full": [
{
"lane": "local",
"accept": 2.0,
"margin": 0.15,
"in_catalog": 61,
"out_catalog": 48,
"enrolled": 53,
"correct": 48,
"wrong": 9,
"rejected": 52,
"catalog_ceiling": 0.8689,
"identity_ceiling": 0.9245,
"decision_ceiling": 0.8113,
"end_to_end_coverage": 0.7869,
"false_accepts": 9,
"unknown_false_accepts": 8,
"unenrolled_false_accepts": 0,
"false_accept_rate": 0.1667,
"near_unknown_false_accept_rate": 0.0,
"precision": 0.8421,
"max_sku_share": 0.2281,
"hub_sku": "sku_l09",
"hub_alarm": false,
"histogram": {
"sku_l09": 13,
"sku_l11": 10,
"sku_l10": 9,
"sku_l01": 5,
"sku_l03": 5,
"sku_l07": 5,
"sku_l06": 4,
"sku_l02": 3,
"sku_l05": 3
},
"per_sku": {
"sku_l01": {
"correct": 5,
"total": 5
},
"sku_l02": {
"correct": 2,
"total": 4
},
"sku_l03": {
"correct": 4,
"total": 4
},
"sku_l04": {
"correct": 0,
"total": 1
},
"sku_l05": {
"correct": 3,
"total": 3
},
"sku_l06": {
"correct": 4,
"total": 4
},
"sku_l07": {
"correct": 5,
"total": 6
},
"sku_l08": {
"correct": 0,
"total": 1
},
"sku_l09": {
"correct": 11,
"total": 13
},
"sku_l10": {
"correct": 9,
"total": 11
},
"sku_l11": {
"correct": 5,
"total": 9
}
},
"ms_per_crop": 26.7
}
],
"live": [
{
"lane": "local",
"accept": 2.0,
"margin": 0.15,
"in_catalog": 61,
"out_catalog": 48,
"enrolled": 53,
"correct": 41,
"wrong": 4,
"rejected": 64,
"catalog_ceiling": 0.8689,
"identity_ceiling": 0.8491,
"decision_ceiling": 0.6604,
"end_to_end_coverage": 0.6721,
"false_accepts": 4,
"unknown_false_accepts": 3,
"unenrolled_false_accepts": 0,
"false_accept_rate": 0.0625,
"near_unknown_false_accept_rate": 0.0,
"precision": 0.9111,
"max_sku_share": 0.2,
"hub_sku": "sku_l09",
"hub_alarm": false,
"histogram": {
"sku_l09": 9,
"sku_l10": 9,
"sku_l11": 7,
"sku_l07": 6,
"sku_l01": 4,
"sku_l03": 3,
"sku_l05": 3,
"sku_l06": 2,
"sku_l02": 1,
"sku_l04": 1
},
"per_sku": {
"sku_l01": {
"correct": 4,
"total": 5
},
"sku_l02": {
"correct": 1,
"total": 4
},
"sku_l03": {
"correct": 3,
"total": 4
},
"sku_l04": {
"correct": 1,
"total": 1
},
"sku_l05": {
"correct": 3,
"total": 3
},
"sku_l06": {
"correct": 2,
"total": 4
},
"sku_l07": {
"correct": 5,
"total": 6
},
"sku_l08": {
"correct": 0,
"total": 1
},
"sku_l09": {
"correct": 9,
"total": 13
},
"sku_l10": {
"correct": 9,
"total": 11
},
"sku_l11": {
"correct": 4,
"total": 9
}
},
"ms_per_crop": 20.9
}
]
}
}

View file

@ -0,0 +1,70 @@
{
"source": "data/packshots/source",
"output": "/tmp/lopia-audit",
"min_width": 120,
"min_height": 160,
"min_views": 1,
"duplicate_cosine": 0.98,
"min_name_ocr": 0.7,
"ocr": "vision",
"text_only_skus": [
"sku_l04",
"sku_l07",
"sku_l08",
"sku_l09",
"sku_l10",
"sku_l12"
],
"text_discriminated_siblings": [],
"below_pyramid_floor": [],
"completeness": {
"live_ready": null,
"reason": "no --shelf-tags given; the completeness rule needs the shelf's SKU list",
"enrolled": 6,
"text_only": [
"sku_l04",
"sku_l07",
"sku_l08",
"sku_l09",
"sku_l10",
"sku_l12"
]
},
"skus": {
"sku_l01": [
"sku_l01.jpg"
],
"sku_l02": [
"sku_l02.jpg"
],
"sku_l03": [
"sku_l03.jpg"
],
"sku_l05": [
"sku_l05.jpg"
],
"sku_l06": [
"sku_l06.jpg"
],
"sku_l11": [
"sku_l11.jpg"
]
},
"refused": [
{
"sku": "sku_l04",
"view": "sku_l04.jpg",
"reason": "name/OCR 0.00 below 0.70: 明太手屋さんが つくつ た コ"
},
{
"sku": "sku_l07",
"view": "sku_l07.jpg",
"reason": "name/OCR 0.57 below 0.70: ラッキー マヨネーズ 調味料(アミノ酸)不使用"
},
{
"sku": "sku_l09",
"view": "sku_l09.jpg",
"reason": "name/OCR 0.05 below 0.70: キューピーマヨネーズ KEWPIE MAYONNAISE ® 450g"
}
]
}

View file

@ -0,0 +1,27 @@
"""Which facings are falsely accepted, and as what."""
import sys, cv2
sys.path.insert(0, "code")
from eval_identity import LocalLaneAdapter, load_labels, at_live_resolution, leaked, UNKNOWN, crop_of
from identity_lanes import DecisionPolicy
payload = load_labels("eval/labels/image6.json")
image = cv2.imread("add/image6.jpg")
facings = [f for f in payload["facings"] if f["label"] != "exclude"]
facings = [f for f in facings if leaked(image, f["box"], "stock-lopia-clean") is None]
enrolled = {"sku_l01","sku_l02","sku_l03","sku_l05","sku_l06","sku_l09","sku_l10","sku_l11"}
for live in (False, True):
picture = at_live_resolution(image) if live else image
scaled = facings if not live else [
{**f, "box": [int(round(v * picture.shape[1] / image.shape[1])) for v in f["box"]]}
for f in facings]
lane = LocalLaneAdapter("stock-lopia-clean")
print(f"=== {'live' if live else 'full'} ===")
for f in scaled:
crop = crop_of(picture, f["box"])
if crop.size == 0: continue
ranked = lane.rank(crop)
d = lane.decide(crop, ranked, accept=2.0, margin=0.0, scale="local")
ans = d.get("best_sku") if d.get("identity_level") == "accepted" else None
if ans and ans != f["label"]:
print(f" #{f['index']:>3} truth={f['label']:<9} answered={ans:<9} enrolled={f['label'] in enrolled}")

View file

@ -0,0 +1,54 @@
"""What the falsely-accepted crops actually read, and what the text lane scores.
The advisory says crops 82-97 are Kewpie Mayonnaise 450g - a product the
catalog does not hold - and that no catalog row can therefore win the text
ranking, so the veto at DecisionPolicy.decide never fires. This prints the
reads and the scores that claim depends on.
"""
import csv
import sys
import cv2
import numpy as np
sys.path.insert(0, "code")
from identity_lanes import TextLane, carries_identity # noqa: E402
from ocr import build_ocr_engine # noqa: E402
INDICES = [73, 82, 83, 84, 86, 94, 95, 96, 97]
rows = []
with open("stock-lopia-clean/stock.csv", encoding="utf-8") as handle:
for record in csv.reader(handle):
if len(record) >= 2 and record[0].startswith("sku_"):
rows.append({"sku_id": record[0], "product_name": record[1]})
engine = build_ocr_engine("vision")
lane = TextLane.from_catalog(rows, "stock-lopia-clean", ocr_engine=engine)
labels = {f["index"]: f for f in __import__("json").load(open("eval/labels/image6.json"))["facings"]}
image = cv2.imread("add/image6.jpg")
tiles = []
for index in INDICES:
facing = labels[index]
x1, y1, x2, y2 = facing["box"]
crop = image[y1:y2, x1:x2]
read = lane.read(crop)
scores = lane.scores_from_text(read)
top = sorted(scores.items(), key=lambda kv: -kv[1])[:3]
print(f"#{index:>3} truth={facing['label']:<9} carries={carries_identity(read)}")
print(f" read: {read[:110]}")
print(f" text top: {[(s, round(v, 3)) for s, v in top]}")
height, width = crop.shape[:2]
scale = 260.0 / max(1, height)
resized = cv2.resize(crop, (max(1, int(width * scale)), 260), interpolation=cv2.INTER_CUBIC)
canvas = np.full((300, 220, 3), 255, np.uint8)
canvas[30:290, : min(resized.shape[1], 220)] = resized[:, :220]
cv2.putText(canvas, str(index), (6, 22), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
tiles.append(canvas)
grid = np.vstack([np.hstack(tiles[:5]), np.hstack(tiles[5:])])
cv2.imwrite("data/evaluations/2026-09-29-session/scratch/fa_tiles.jpg", grid)
print("tile:", grid.shape)

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

View file

@ -0,0 +1,71 @@
"""Mass-only size contradiction, with full reads.
The first pass counted every quantity token, including percentages, so a crop
reading "カロリー 50% カット" looked like a 50 g package. Only mass units can
contradict a package size, so this restricts to g/kg and prints the whole read
for every accepted facing that carries one.
"""
import csv
import json
import re
import sys
import cv2
sys.path.insert(0, "code")
from eval_identity import LocalLaneAdapter, at_live_resolution, crop_of, leaked # noqa: E402
from identity_lanes import TextLane, normalize_text # noqa: E402
from ocr import build_ocr_engine # noqa: E402
MASS = re.compile(r"(\d+(?:\.\d+)?)\s*(KG|G)", re.IGNORECASE)
# Package sizes this product class actually comes in. Below it are OCR fragments
# of a longer number ("20" from 200); above it are concatenations ("5210").
PLAUSIBLE = (100.0, 2000.0)
def masses(text: str):
return {float(number) for number, _unit in MASS.findall(normalize_text(text))}
rows = []
with open("stock-lopia-clean/stock.csv", encoding="utf-8") as handle:
for record in csv.reader(handle):
if len(record) >= 2 and record[0].startswith("sku_"):
rows.append({"sku_id": record[0], "product_name": record[1]})
names = {row["sku_id"]: row["product_name"] for row in rows}
engine = build_ocr_engine("vision")
text_lane = TextLane.from_catalog(rows, "stock-lopia-clean", ocr_engine=engine)
local_lane = LocalLaneAdapter("stock-lopia-clean")
payload = json.load(open("eval/labels/image6.json"))
image = cv2.imread("add/image6.jpg")
facings = [f for f in payload["facings"] if f["label"] != "exclude"]
facings = [f for f in facings if leaked(image, f["box"], "stock-lopia-clean") is None]
for live in (False, True):
picture = at_live_resolution(image) if live else image
scaled = facings if not live else [
{**f, "box": [int(round(v * picture.shape[1] / image.shape[1])) for v in f["box"]]}
for f in facings]
print(f"=== {'live' if live else 'full'} ===")
for facing in scaled:
crop = crop_of(picture, facing["box"])
if crop.size == 0:
continue
ranked = local_lane.rank(crop)
decision = local_lane.decide(crop, ranked, accept=2.0, margin=0.0, scale="local")
if decision.get("identity_level") != "accepted":
continue
answer = decision.get("best_sku")
read = text_lane.read(crop)
read_masses = masses(read)
name_masses = masses(names.get(answer, ""))
if not read_masses or not name_masses:
continue
plausible = {m for m in read_masses if PLAUSIBLE[0] <= m <= PLAUSIBLE[1]}
veto = bool(plausible) and not (read_masses & name_masses)
correct = "correct" if answer == facing["label"] else "WRONG"
print(f" #{facing['index']:>3} {'VETO' if veto else 'keep'} {correct:<7} "
f"answer={answer:<9} read_m={sorted(read_masses)} name_m={sorted(name_masses)}")
print(f" read: {read[:120]}")

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

View file

@ -0,0 +1,28 @@
{
"endpoint": "/v1/recognize",
"image": "add/image6.jpg",
"catalog": "stock-lopia-clean",
"gates": "LOCAL_ACCEPT=4.0 TEXT_MIN_SHARED=4",
"still": {
"detections": 111,
"named": 44,
"matched": 44,
"correct": 42,
"wrong": 2,
"precision": 0.955
},
"stream": {
"seconds": 20,
"pan": 12,
"tracks_seen": 165,
"tracks_labelled": 62,
"label_changes": 0,
"unexplained_label_changes": 0,
"facings_named": "17/27",
"precision": 0.84,
"wrong": 0,
"unenrolled_false_accepts": 0,
"unknown_false_accepts": 8
},
"note": "Final state with every catalog correction and both calibrated accept lines live."
}

View file

@ -0,0 +1,24 @@
{
"endpoint": "/v1/recognize",
"image": "add/image6.jpg",
"catalog": "stock-lopia-clean",
"gates": "LOCAL_ACCEPT=4.0 TEXT_MIN_SHARED=4",
"detections": 111,
"named": 44,
"matched": 44,
"correct": 42,
"wrong": 2,
"precision": 0.955,
"wrong_detail": [
"sku_l07 vs unknown",
"sku_l03 vs unknown"
],
"previous": {
"gates": "2.0/2",
"named": 53,
"correct": 48,
"wrong": 5,
"precision": 0.906
},
"note": "Live still endpoint, box-IoU matched to the corrected labels. The stricter gates trade 9 names for 3 wrong labels."
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,23 @@
{
"endpoint": "/v1/recognize",
"image": "add/image6.jpg",
"catalog": "stock-lopia-clean",
"catalog_version": "b862c55f4623",
"detections": 111,
"named": 53,
"named_without_product_info": 0,
"view_suffixed_ids": [],
"elapsed_s": 2.38,
"by_sku": {
"sku_l10": 9,
"sku_l11": 5,
"sku_l01": 5,
"sku_l06": 4,
"sku_l09": 13,
"sku_l07": 6,
"sku_l02": 3,
"sku_l03": 5,
"sku_l05": 3
},
"note": "Final state: l06 current package enrolled (4/4 facings named, was 1/4 at live), l10/l11 400g, l12 renamed. l12 not named: no enrolled view."
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,214 @@
{
"image": "add/image6.jpg",
"stock_dir": "stock-lopia-clean",
"labels": "eval/labels/image6.json",
"provenance": {
"catalog_hash": "10f55211435aa64c0ed4ca24a7b902ce4ed8a30299409680942b78ec9b3d08c8",
"labels_hash": "5aabf029921ced767d4e14a18e8bccb55812dd974852c56e0931a98fa10366cf",
"image_hash": "a08b89ba37d27c9d5a81bcd56bcd042e6baf54d2f182bbaaf47e97f14c9cd525",
"code_revision": "b6c87c45ea2aa3ed59f5764274341f3ffed30fe0"
},
"facings": 111,
"leakage": {
"excluded": 0,
"labels": []
},
"hub_share_limit": 0.3,
"enrollment": {
"catalog_skus": [
"sku_l01",
"sku_l02",
"sku_l03",
"sku_l04",
"sku_l05",
"sku_l06",
"sku_l07",
"sku_l08",
"sku_l09",
"sku_l10",
"sku_l11",
"sku_l12"
],
"enrolled_skus": [
"sku_l01",
"sku_l02",
"sku_l03",
"sku_l05",
"sku_l06",
"sku_l09",
"sku_l11"
],
"unenrolled_skus": {
"sku_l10": 11,
"sku_l07": 6,
"sku_l04": 1,
"sku_l08": 1
},
"facings_without_enrollment": 19,
"scored_facings": 109
},
"results": {
"full": [
{
"lane": "van",
"accept": 1.0,
"margin": 0.15,
"in_catalog": 61,
"out_catalog": 48,
"enrolled": 42,
"correct": 32,
"wrong": 77,
"rejected": 0,
"catalog_ceiling": 0.6885,
"identity_ceiling": 0.7619,
"decision_ceiling": 0.7619,
"end_to_end_coverage": 0.5246,
"false_accepts": 77,
"unknown_false_accepts": 48,
"unenrolled_false_accepts": 19,
"false_accept_rate": 1.0,
"near_unknown_false_accept_rate": 1.0,
"precision": 0.2936,
"max_sku_share": 0.2569,
"hub_sku": "sku_l09",
"hub_alarm": false,
"histogram": {
"sku_l09": 28,
"sku_l05": 19,
"sku_l02": 15,
"sku_l03": 14,
"sku_l06": 13,
"sku_l11": 12,
"sku_l01": 8
},
"per_sku": {
"sku_l01": {
"correct": 5,
"total": 5
},
"sku_l02": {
"correct": 4,
"total": 4
},
"sku_l03": {
"correct": 4,
"total": 4
},
"sku_l04": {
"correct": 0,
"total": 1
},
"sku_l05": {
"correct": 3,
"total": 3
},
"sku_l06": {
"correct": 0,
"total": 4
},
"sku_l07": {
"correct": 0,
"total": 6
},
"sku_l08": {
"correct": 0,
"total": 1
},
"sku_l09": {
"correct": 13,
"total": 13
},
"sku_l10": {
"correct": 0,
"total": 11
},
"sku_l11": {
"correct": 3,
"total": 9
}
},
"ms_per_crop": 15.4
},
{
"lane": "local",
"accept": 2.0,
"margin": 0.15,
"in_catalog": 61,
"out_catalog": 48,
"enrolled": 42,
"correct": 36,
"wrong": 5,
"rejected": 68,
"catalog_ceiling": 0.6885,
"identity_ceiling": 0.9286,
"decision_ceiling": 0.8571,
"end_to_end_coverage": 0.5902,
"false_accepts": 5,
"unknown_false_accepts": 4,
"unenrolled_false_accepts": 0,
"false_accept_rate": 0.0833,
"near_unknown_false_accept_rate": 0.0,
"precision": 0.878,
"max_sku_share": 0.2927,
"hub_sku": "sku_l09",
"hub_alarm": false,
"histogram": {
"sku_l09": 12,
"sku_l11": 8,
"sku_l03": 6,
"sku_l01": 5,
"sku_l07": 4,
"sku_l02": 3,
"sku_l05": 3
},
"per_sku": {
"sku_l01": {
"correct": 5,
"total": 5
},
"sku_l02": {
"correct": 2,
"total": 4
},
"sku_l03": {
"correct": 4,
"total": 4
},
"sku_l04": {
"correct": 0,
"total": 1
},
"sku_l05": {
"correct": 3,
"total": 3
},
"sku_l06": {
"correct": 0,
"total": 4
},
"sku_l07": {
"correct": 4,
"total": 6
},
"sku_l08": {
"correct": 0,
"total": 1
},
"sku_l09": {
"correct": 12,
"total": 13
},
"sku_l10": {
"correct": 0,
"total": 11
},
"sku_l11": {
"correct": 6,
"total": 9
}
},
"ms_per_crop": 26.6
}
]
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,554 @@
{
"windows": [
[
0,
640,
1280,
1360
],
[
12,
640,
1292,
1360
],
[
24,
640,
1304,
1360
],
[
36,
640,
1316,
1360
],
[
48,
640,
1328,
1360
],
[
60,
640,
1340,
1360
],
[
72,
640,
1352,
1360
],
[
84,
640,
1364,
1360
],
[
96,
640,
1376,
1360
],
[
108,
640,
1388,
1360
],
[
120,
640,
1400,
1360
],
[
132,
640,
1412,
1360
],
[
144,
640,
1424,
1360
],
[
156,
640,
1436,
1360
],
[
168,
640,
1448,
1360
],
[
180,
640,
1460,
1360
],
[
192,
640,
1472,
1360
],
[
204,
640,
1484,
1360
],
[
216,
640,
1496,
1360
],
[
220,
640,
1500,
1360
],
[
216,
640,
1496,
1360
],
[
204,
640,
1484,
1360
],
[
192,
640,
1472,
1360
],
[
180,
640,
1460,
1360
],
[
168,
640,
1448,
1360
],
[
156,
640,
1436,
1360
],
[
144,
640,
1424,
1360
],
[
132,
640,
1412,
1360
],
[
120,
640,
1400,
1360
],
[
108,
640,
1388,
1360
],
[
96,
640,
1376,
1360
],
[
84,
640,
1364,
1360
],
[
72,
640,
1352,
1360
],
[
60,
640,
1340,
1360
],
[
48,
640,
1328,
1360
],
[
36,
640,
1316,
1360
],
[
24,
640,
1304,
1360
],
[
12,
640,
1292,
1360
],
[
0,
640,
1280,
1360
],
[
12,
640,
1292,
1360
],
[
24,
640,
1304,
1360
],
[
36,
640,
1316,
1360
],
[
48,
640,
1328,
1360
],
[
60,
640,
1340,
1360
],
[
72,
640,
1352,
1360
],
[
84,
640,
1364,
1360
],
[
96,
640,
1376,
1360
],
[
108,
640,
1388,
1360
],
[
120,
640,
1400,
1360
],
[
132,
640,
1412,
1360
],
[
144,
640,
1424,
1360
],
[
156,
640,
1436,
1360
],
[
168,
640,
1448,
1360
],
[
180,
640,
1460,
1360
],
[
192,
640,
1472,
1360
],
[
204,
640,
1484,
1360
],
[
216,
640,
1496,
1360
],
[
220,
640,
1500,
1360
],
[
216,
640,
1496,
1360
],
[
204,
640,
1484,
1360
],
[
192,
640,
1472,
1360
],
[
180,
640,
1460,
1360
],
[
168,
640,
1448,
1360
],
[
156,
640,
1436,
1360
],
[
144,
640,
1424,
1360
],
[
132,
640,
1412,
1360
],
[
120,
640,
1400,
1360
],
[
108,
640,
1388,
1360
],
[
96,
640,
1376,
1360
],
[
84,
640,
1364,
1360
],
[
72,
640,
1352,
1360
],
[
60,
640,
1340,
1360
],
[
48,
640,
1328,
1360
],
[
36,
640,
1316,
1360
],
[
24,
640,
1304,
1360
],
[
12,
640,
1292,
1360
],
[
0,
640,
1280,
1360
],
[
12,
640,
1292,
1360
],
[
24,
640,
1304,
1360
],
[
36,
640,
1316,
1360
],
[
48,
640,
1328,
1360
],
[
60,
640,
1340,
1360
],
[
72,
640,
1352,
1360
],
[
84,
640,
1364,
1360
],
[
96,
640,
1376,
1360
],
[
108,
640,
1388,
1360
],
[
120,
640,
1400,
1360
],
[
132,
640,
1412,
1360
],
[
144,
640,
1424,
1360
],
[
156,
640,
1436,
1360
],
[
168,
640,
1448,
1360
]
],
"source": "add/image6.jpg",
"pace": "send",
"seconds": 8.1,
"tracks": []
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,721 @@
{
"source": "add/image6.jpg",
"pace": "result",
"seconds": 12.0,
"tracks": [
{
"track_id": 1,
"sku_id": "sku_l09",
"label_box": [
580,
122,
666,
332
],
"label_frame_id": 0,
"result_frame_id": 108,
"inherited": true,
"restarts": 2
},
{
"track_id": 2,
"sku_id": "sku_l09",
"label_box": [
659,
760,
746,
972
],
"label_frame_id": 71,
"result_frame_id": 81,
"inherited": true,
"restarts": 2
},
{
"track_id": 4,
"sku_id": "sku_l09",
"label_box": [
586,
119,
674,
333
],
"label_frame_id": 0,
"result_frame_id": 115,
"inherited": true,
"restarts": 5
},
{
"track_id": 5,
"sku_id": "sku_l09",
"label_box": [
512,
755,
604,
972
],
"label_frame_id": 98,
"result_frame_id": 114,
"inherited": true,
"restarts": 0
},
{
"track_id": 6,
"sku_id": "sku_l09",
"label_box": [
937,
764,
1036,
975
],
"label_frame_id": 16,
"result_frame_id": 25,
"inherited": true,
"restarts": 0
},
{
"track_id": 8,
"sku_id": "sku_l09",
"label_box": [
291,
754,
389,
971
],
"label_frame_id": 107,
"result_frame_id": 114,
"inherited": true,
"restarts": 0
},
{
"track_id": 9,
"sku_id": "sku_l09",
"label_box": [
459,
1068,
555,
1251
],
"label_frame_id": 67,
"result_frame_id": 89,
"inherited": true,
"restarts": 0
},
{
"track_id": 11,
"sku_id": "sku_l09",
"label_box": [
688,
1057,
789,
1142
],
"label_frame_id": 50,
"result_frame_id": 59,
"inherited": false,
"restarts": 1
},
{
"track_id": 12,
"sku_id": "sku_l09",
"label_box": [
1254,
1053,
1334,
1244
],
"label_frame_id": 61,
"result_frame_id": 69,
"inherited": true,
"restarts": 1
},
{
"track_id": 13,
"sku_id": "sku_l09",
"label_box": [
1079,
762,
1176,
973
],
"label_frame_id": 89,
"result_frame_id": 115,
"inherited": true,
"restarts": 1
},
{
"track_id": 14,
"sku_id": "sku_l09",
"label_box": [
1277,
1065,
1345,
1239
],
"label_frame_id": 52,
"result_frame_id": 63,
"inherited": true,
"restarts": 2
},
{
"track_id": 15,
"sku_id": "sku_l09",
"label_box": [
798,
765,
966,
971
],
"label_frame_id": 83,
"result_frame_id": 114,
"inherited": true,
"restarts": 0
},
{
"track_id": 16,
"sku_id": "sku_l09",
"label_box": [
430,
1070,
543,
1250
],
"label_frame_id": 99,
"result_frame_id": 113,
"inherited": false,
"restarts": 1
},
{
"track_id": 17,
"sku_id": "sku_l10",
"label_box": [
878,
1146,
970,
1245
],
"label_frame_id": 90,
"result_frame_id": 106,
"inherited": false,
"restarts": 0
},
{
"track_id": 18,
"sku_id": "sku_l09",
"label_box": [
1128,
763,
1271,
973
],
"label_frame_id": 99,
"result_frame_id": 112,
"inherited": false,
"restarts": 10
},
{
"track_id": 19,
"sku_id": "sku_l09",
"label_box": [
680,
1058,
780,
1142
],
"label_frame_id": 97,
"result_frame_id": 110,
"inherited": false,
"restarts": 2
},
{
"track_id": 21,
"sku_id": "sku_l10",
"label_box": [
673,
1146,
773,
1247
],
"label_frame_id": 98,
"result_frame_id": 111,
"inherited": false,
"restarts": 0
},
{
"track_id": 22,
"sku_id": "sku_l09",
"label_box": [
287,
775,
347,
969
],
"label_frame_id": 2,
"result_frame_id": 18,
"inherited": true,
"restarts": 0
},
{
"track_id": 23,
"sku_id": "sku_l10",
"label_box": [
917,
1146,
999,
1244
],
"label_frame_id": 98,
"result_frame_id": 112,
"inherited": true,
"restarts": 0
},
{
"track_id": 25,
"sku_id": "sku_l09",
"label_box": [
488,
1078,
570,
1248
],
"label_frame_id": 33,
"result_frame_id": 53,
"inherited": true,
"restarts": 1
},
{
"track_id": 26,
"sku_id": "sku_l09",
"label_box": [
198,
755,
258,
972
],
"label_frame_id": 2,
"result_frame_id": 15,
"inherited": false,
"restarts": 0
},
{
"track_id": 27,
"sku_id": "sku_l09",
"label_box": [
484,
1062,
572,
1248
],
"label_frame_id": 89,
"result_frame_id": 97,
"inherited": true,
"restarts": 2
},
{
"track_id": 29,
"sku_id": "sku_l09",
"label_box": [
879,
1055,
972,
1141
],
"label_frame_id": 57,
"result_frame_id": 70,
"inherited": true,
"restarts": 4
},
{
"track_id": 32,
"sku_id": "sku_l10",
"label_box": [
67,
422,
248,
613
],
"label_frame_id": 0,
"result_frame_id": 47,
"inherited": true,
"restarts": 2
},
{
"track_id": 35,
"sku_id": "sku_l09",
"label_box": [
1117,
763,
1179,
973
],
"label_frame_id": 85,
"result_frame_id": 113,
"inherited": true,
"restarts": 1
},
{
"track_id": 58,
"sku_id": "sku_l09",
"label_box": [
1328,
1065,
1379,
1238
],
"label_frame_id": 20,
"result_frame_id": 23,
"inherited": true,
"restarts": 0
},
{
"track_id": 59,
"sku_id": "sku_l09",
"label_box": [
951,
124,
1090,
218
],
"label_frame_id": 0,
"result_frame_id": 104,
"inherited": true,
"restarts": 5
},
{
"track_id": 114,
"sku_id": "sku_l09",
"label_box": [
1191,
1054,
1271,
1245
],
"label_frame_id": 99,
"result_frame_id": 111,
"inherited": false,
"restarts": 3
},
{
"track_id": 127,
"sku_id": "sku_l09",
"label_box": [
243,
755,
298,
972
],
"label_frame_id": 35,
"result_frame_id": 40,
"inherited": true,
"restarts": 1
},
{
"track_id": 135,
"sku_id": "sku_l09",
"label_box": [
521,
118,
608,
330
],
"label_frame_id": 0,
"result_frame_id": 115,
"inherited": true,
"restarts": 4
},
{
"track_id": 136,
"sku_id": "sku_l09",
"label_box": [
1072,
1054,
1147,
1245
],
"label_frame_id": 53,
"result_frame_id": 59,
"inherited": true,
"restarts": 1
},
{
"track_id": 163,
"sku_id": "sku_l09",
"label_box": [
125,
752,
191,
971
],
"label_frame_id": 32,
"result_frame_id": 48,
"inherited": true,
"restarts": 0
},
{
"track_id": 178,
"sku_id": "sku_l10",
"label_box": [
156,
419,
307,
616
],
"label_frame_id": 0,
"result_frame_id": 41,
"inherited": true,
"restarts": 5
},
{
"track_id": 262,
"sku_id": "sku_l09",
"label_box": [
79,
113,
152,
331
],
"label_frame_id": 0,
"result_frame_id": 74,
"inherited": true,
"restarts": 1
},
{
"track_id": 269,
"sku_id": "sku_l09",
"label_box": [
197,
755,
257,
973
],
"label_frame_id": 48,
"result_frame_id": 57,
"inherited": false,
"restarts": 1
},
{
"track_id": 295,
"sku_id": "sku_l09",
"label_box": [
84,
1059,
179,
1148
],
"label_frame_id": 78,
"result_frame_id": 90,
"inherited": true,
"restarts": 1
},
{
"track_id": 302,
"sku_id": "sku_l09",
"label_box": [
941,
410,
1009,
605
],
"label_frame_id": 0,
"result_frame_id": 50,
"inherited": true,
"restarts": 0
},
{
"track_id": 315,
"sku_id": "sku_l09",
"label_box": [
764,
414,
848,
505
],
"label_frame_id": 0,
"result_frame_id": 51,
"inherited": true,
"restarts": 0
},
{
"track_id": 331,
"sku_id": "sku_l09",
"label_box": [
399,
1057,
532,
1255
],
"label_frame_id": 105,
"result_frame_id": 115,
"inherited": true,
"restarts": 9
},
{
"track_id": 345,
"sku_id": "sku_l09",
"label_box": [
763,
415,
854,
499
],
"label_frame_id": 0,
"result_frame_id": 115,
"inherited": true,
"restarts": 3
},
{
"track_id": 353,
"sku_id": "sku_l09",
"label_box": [
1119,
1053,
1180,
1245
],
"label_frame_id": 99,
"result_frame_id": 113,
"inherited": false,
"restarts": 2
},
{
"track_id": 370,
"sku_id": "sku_l09",
"label_box": [
83,
117,
129,
333
],
"label_frame_id": 0,
"result_frame_id": 69,
"inherited": true,
"restarts": 1
},
{
"track_id": 378,
"sku_id": "sku_l09",
"label_box": [
576,
758,
667,
971
],
"label_frame_id": 106,
"result_frame_id": 114,
"inherited": true,
"restarts": 1
},
{
"track_id": 404,
"sku_id": "sku_l09",
"label_box": [
68,
753,
131,
971
],
"label_frame_id": 78,
"result_frame_id": 85,
"inherited": true,
"restarts": 1
},
{
"track_id": 416,
"sku_id": "sku_l09",
"label_box": [
1089,
416,
1147,
604
],
"label_frame_id": 0,
"result_frame_id": 83,
"inherited": true,
"restarts": 1
},
{
"track_id": 499,
"sku_id": "sku_l09",
"label_box": [
443,
767,
510,
968
],
"label_frame_id": 95,
"result_frame_id": 97,
"inherited": true,
"restarts": 0
},
{
"track_id": 507,
"sku_id": "sku_l09",
"label_box": [
149,
757,
208,
971
],
"label_frame_id": 84,
"result_frame_id": 90,
"inherited": true,
"restarts": 1
},
{
"track_id": 552,
"sku_id": "sku_l09",
"label_box": [
757,
413,
849,
507
],
"label_frame_id": 0,
"result_frame_id": 105,
"inherited": true,
"restarts": 1
},
{
"track_id": 582,
"sku_id": "sku_l09",
"label_box": [
701,
413,
842,
505
],
"label_frame_id": 0,
"result_frame_id": 102,
"inherited": true,
"restarts": 1
},
{
"track_id": 613,
"sku_id": "sku_l09",
"label_box": [
477,
767,
537,
969
],
"label_frame_id": 104,
"result_frame_id": 108,
"inherited": true,
"restarts": 0
},
{
"track_id": 673,
"sku_id": "sku_l09",
"label_box": [
1087,
1051,
1143,
1245
],
"label_frame_id": 108,
"result_frame_id": 113,
"inherited": true,
"restarts": 0
}
]
}

View file

@ -0,0 +1,791 @@
{
"source": "add/image6.jpg",
"pace": "send",
"seconds": 12.0,
"tracks": [
{
"track_id": 1,
"sku_id": "sku_l09",
"label_box": [
527,
122,
613,
331
],
"label_frame_id": 0,
"result_frame_id": 104,
"inherited": true,
"restarts": 2
},
{
"track_id": 2,
"sku_id": "sku_l09",
"label_box": [
696,
760,
783,
972
],
"label_frame_id": 70,
"result_frame_id": 81,
"inherited": true,
"restarts": 2
},
{
"track_id": 4,
"sku_id": "sku_l09",
"label_box": [
494,
120,
582,
332
],
"label_frame_id": 0,
"result_frame_id": 108,
"inherited": true,
"restarts": 5
},
{
"track_id": 5,
"sku_id": "sku_l09",
"label_box": [
390,
757,
482,
971
],
"label_frame_id": 106,
"result_frame_id": 112,
"inherited": true,
"restarts": 0
},
{
"track_id": 6,
"sku_id": "sku_l09",
"label_box": [
949,
763,
1049,
973
],
"label_frame_id": 16,
"result_frame_id": 25,
"inherited": true,
"restarts": 0
},
{
"track_id": 8,
"sku_id": "sku_l09",
"label_box": [
399,
754,
497,
971
],
"label_frame_id": 98,
"result_frame_id": 112,
"inherited": true,
"restarts": 0
},
{
"track_id": 9,
"sku_id": "sku_l09",
"label_box": [
441,
1071,
536,
1250
],
"label_frame_id": 74,
"result_frame_id": 89,
"inherited": false,
"restarts": 0
},
{
"track_id": 12,
"sku_id": "sku_l09",
"label_box": [
1194,
1053,
1274,
1244
],
"label_frame_id": 50,
"result_frame_id": 69,
"inherited": true,
"restarts": 1
},
{
"track_id": 13,
"sku_id": "sku_l09",
"label_box": [
1127,
763,
1224,
973
],
"label_frame_id": 97,
"result_frame_id": 112,
"inherited": true,
"restarts": 1
},
{
"track_id": 14,
"sku_id": "sku_l09",
"label_box": [
1277,
1065,
1345,
1239
],
"label_frame_id": 52,
"result_frame_id": 63,
"inherited": true,
"restarts": 2
},
{
"track_id": 15,
"sku_id": "sku_l09",
"label_box": [
805,
764,
976,
972
],
"label_frame_id": 89,
"result_frame_id": 109,
"inherited": false,
"restarts": 0
},
{
"track_id": 16,
"sku_id": "sku_l09",
"label_box": [
583,
1069,
683,
1251
],
"label_frame_id": 99,
"result_frame_id": 112,
"inherited": true,
"restarts": 1
},
{
"track_id": 17,
"sku_id": "sku_l10",
"label_box": [
867,
1147,
958,
1245
],
"label_frame_id": 98,
"result_frame_id": 109,
"inherited": false,
"restarts": 0
},
{
"track_id": 18,
"sku_id": "sku_l09",
"label_box": [
926,
123,
1068,
332
],
"label_frame_id": 0,
"result_frame_id": 102,
"inherited": true,
"restarts": 9
},
{
"track_id": 19,
"sku_id": "sku_l09",
"label_box": [
694,
1057,
794,
1142
],
"label_frame_id": 108,
"result_frame_id": 112,
"inherited": true,
"restarts": 2
},
{
"track_id": 20,
"sku_id": "sku_l10",
"label_box": [
211,
1128,
285,
1260
],
"label_frame_id": 6,
"result_frame_id": 17,
"inherited": false,
"restarts": 2
},
{
"track_id": 21,
"sku_id": "sku_l10",
"label_box": [
848,
1145,
948,
1246
],
"label_frame_id": 98,
"result_frame_id": 112,
"inherited": true,
"restarts": 0
},
{
"track_id": 22,
"sku_id": "sku_l09",
"label_box": [
287,
775,
347,
969
],
"label_frame_id": 2,
"result_frame_id": 18,
"inherited": true,
"restarts": 0
},
{
"track_id": 23,
"sku_id": "sku_l10",
"label_box": [
869,
1144,
952,
1244
],
"label_frame_id": 104,
"result_frame_id": 112,
"inherited": true,
"restarts": 0
},
{
"track_id": 25,
"sku_id": "sku_l09",
"label_box": [
488,
1078,
570,
1248
],
"label_frame_id": 33,
"result_frame_id": 53,
"inherited": true,
"restarts": 1
},
{
"track_id": 26,
"sku_id": "sku_l09",
"label_box": [
198,
755,
258,
972
],
"label_frame_id": 2,
"result_frame_id": 14,
"inherited": false,
"restarts": 0
},
{
"track_id": 27,
"sku_id": "sku_l09",
"label_box": [
522,
1067,
610,
1248
],
"label_frame_id": 87,
"result_frame_id": 92,
"inherited": true,
"restarts": 2
},
{
"track_id": 29,
"sku_id": "sku_l09",
"label_box": [
947,
1055,
1038,
1139
],
"label_frame_id": 58,
"result_frame_id": 73,
"inherited": true,
"restarts": 4
},
{
"track_id": 32,
"sku_id": "sku_l10",
"label_box": [
67,
422,
248,
613
],
"label_frame_id": 0,
"result_frame_id": 47,
"inherited": true,
"restarts": 2
},
{
"track_id": 35,
"sku_id": "sku_l09",
"label_box": [
1057,
764,
1118,
972
],
"label_frame_id": 75,
"result_frame_id": 112,
"inherited": true,
"restarts": 1
},
{
"track_id": 58,
"sku_id": "sku_l09",
"label_box": [
1264,
1065,
1315,
1238
],
"label_frame_id": 14,
"result_frame_id": 23,
"inherited": true,
"restarts": 0
},
{
"track_id": 59,
"sku_id": "sku_l09",
"label_box": [
1092,
126,
1229,
218
],
"label_frame_id": 0,
"result_frame_id": 112,
"inherited": true,
"restarts": 5
},
{
"track_id": 114,
"sku_id": "sku_l09",
"label_box": [
1191,
1054,
1271,
1245
],
"label_frame_id": 99,
"result_frame_id": 108,
"inherited": false,
"restarts": 3
},
{
"track_id": 127,
"sku_id": "sku_l09",
"label_box": [
354,
756,
409,
973
],
"label_frame_id": 26,
"result_frame_id": 40,
"inherited": true,
"restarts": 1
},
{
"track_id": 135,
"sku_id": "sku_l09",
"label_box": [
658,
760,
744,
972
],
"label_frame_id": 92,
"result_frame_id": 104,
"inherited": true,
"restarts": 3
},
{
"track_id": 136,
"sku_id": "sku_l09",
"label_box": [
1076,
1053,
1152,
1245
],
"label_frame_id": 54,
"result_frame_id": 59,
"inherited": true,
"restarts": 1
},
{
"track_id": 163,
"sku_id": "sku_l09",
"label_box": [
111,
754,
177,
970
],
"label_frame_id": 32,
"result_frame_id": 50,
"inherited": true,
"restarts": 0
},
{
"track_id": 178,
"sku_id": "sku_l10",
"label_box": [
156,
419,
307,
616
],
"label_frame_id": 0,
"result_frame_id": 41,
"inherited": true,
"restarts": 5
},
{
"track_id": 262,
"sku_id": "sku_l09",
"label_box": [
449,
763,
522,
968
],
"label_frame_id": 44,
"result_frame_id": 55,
"inherited": false,
"restarts": 0
},
{
"track_id": 269,
"sku_id": "sku_l09",
"label_box": [
113,
758,
172,
972
],
"label_frame_id": 46,
"result_frame_id": 55,
"inherited": true,
"restarts": 1
},
{
"track_id": 295,
"sku_id": "sku_l09",
"label_box": [
84,
1059,
179,
1148
],
"label_frame_id": 78,
"result_frame_id": 90,
"inherited": true,
"restarts": 1
},
{
"track_id": 302,
"sku_id": "sku_l09",
"label_box": [
929,
414,
995,
603
],
"label_frame_id": 0,
"result_frame_id": 51,
"inherited": true,
"restarts": 0
},
{
"track_id": 315,
"sku_id": "sku_l09",
"label_box": [
757,
413,
849,
507
],
"label_frame_id": 0,
"result_frame_id": 67,
"inherited": true,
"restarts": 1
},
{
"track_id": 331,
"sku_id": "sku_l09",
"label_box": [
422,
1057,
555,
1255
],
"label_frame_id": 100,
"result_frame_id": 112,
"inherited": true,
"restarts": 9
},
{
"track_id": 345,
"sku_id": "sku_l09",
"label_box": [
835,
1055,
927,
1140
],
"label_frame_id": 107,
"result_frame_id": 111,
"inherited": true,
"restarts": 2
},
{
"track_id": 353,
"sku_id": "sku_l09",
"label_box": [
1119,
1053,
1180,
1245
],
"label_frame_id": 99,
"result_frame_id": 111,
"inherited": false,
"restarts": 2
},
{
"track_id": 370,
"sku_id": "sku_l09",
"label_box": [
48,
118,
91,
331
],
"label_frame_id": 0,
"result_frame_id": 66,
"inherited": true,
"restarts": 1
},
{
"track_id": 378,
"sku_id": "sku_l09",
"label_box": [
696,
758,
787,
971
],
"label_frame_id": 97,
"result_frame_id": 112,
"inherited": true,
"restarts": 1
},
{
"track_id": 404,
"sku_id": "sku_l09",
"label_box": [
45,
112,
83,
331
],
"label_frame_id": 0,
"result_frame_id": 71,
"inherited": true,
"restarts": 0
},
{
"track_id": 416,
"sku_id": "sku_l09",
"label_box": [
1089,
416,
1147,
604
],
"label_frame_id": 0,
"result_frame_id": 83,
"inherited": true,
"restarts": 1
},
{
"track_id": 476,
"sku_id": "sku_l10",
"label_box": [
63,
519,
138,
614
],
"label_frame_id": 0,
"result_frame_id": 80,
"inherited": true,
"restarts": 0
},
{
"track_id": 499,
"sku_id": "sku_l09",
"label_box": [
79,
113,
152,
331
],
"label_frame_id": 0,
"result_frame_id": 112,
"inherited": true,
"restarts": 1
},
{
"track_id": 507,
"sku_id": "sku_l09",
"label_box": [
128,
114,
187,
331
],
"label_frame_id": 0,
"result_frame_id": 85,
"inherited": true,
"restarts": 1
},
{
"track_id": 536,
"sku_id": "sku_l09",
"label_box": [
619,
1075,
689,
1248
],
"label_frame_id": 100,
"result_frame_id": 108,
"inherited": true,
"restarts": 1
},
{
"track_id": 552,
"sku_id": "sku_l09",
"label_box": [
757,
413,
849,
507
],
"label_frame_id": 0,
"result_frame_id": 105,
"inherited": true,
"restarts": 1
},
{
"track_id": 568,
"sku_id": "sku_l10",
"label_box": [
45,
512,
143,
619
],
"label_frame_id": 0,
"result_frame_id": 90,
"inherited": true,
"restarts": 0
},
{
"track_id": 582,
"sku_id": "sku_l09",
"label_box": [
684,
413,
826,
506
],
"label_frame_id": 0,
"result_frame_id": 102,
"inherited": true,
"restarts": 1
},
{
"track_id": 606,
"sku_id": "sku_l09",
"label_box": [
204,
758,
247,
971
],
"label_frame_id": 102,
"result_frame_id": 112,
"inherited": false,
"restarts": 1
},
{
"track_id": 613,
"sku_id": "sku_l09",
"label_box": [
453,
773,
510,
969
],
"label_frame_id": 100,
"result_frame_id": 111,
"inherited": false,
"restarts": 0
},
{
"track_id": 653,
"sku_id": "sku_l10",
"label_box": [
60,
510,
163,
622
],
"label_frame_id": 0,
"result_frame_id": 104,
"inherited": true,
"restarts": 0
},
{
"track_id": 673,
"sku_id": "sku_l09",
"label_box": [
1087,
1051,
1143,
1245
],
"label_frame_id": 108,
"result_frame_id": 112,
"inherited": true,
"restarts": 0
}
]
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,60 @@
# External evidence (2026-09-29)
Independent-source media used to stress the identity pipeline. **None of this is
Tier 2.** DECISION.md's Tier 2 is a second labelled store visit; these are
`external_proxy` artifacts, and every manifest here says so.
| Directory | What it holds |
|---|---|
| `other-mayo/` | 8 accepted images for `sku_l04`, `sku_l07`, `sku_l09` from 6 source domains, plus the scored result |
| `l12-reconciliation/` | What `sku_l12` actually is, from the shelf tag and package pixels |
| `kewpie/` | 12 accepted images for six SKUs from 6 source domains, plus the scored result. One is now enrolled, so it is excluded from scoring. |
| `tier3-sources/` | Two reusable pan clips and 11 rejected candidates for a future Tier-3 replay |
## Leak checks
Two, both in `code/eval_external.py`, both run before any number is reported:
- **Against the evaluation photo.** Every image is template-matched against
`add/image6.jpg`. The highest score across all 20 is 0.675 (`sku_l07_1`), and
the 0.95+ band that identifies a crop of the evaluation photo is empty.
- **Against the catalog.** An image that has since been enrolled is refused
rather than scored. `kewpie/sku_l06_1.jpg` is one: it is now the enrolled l06
view, so scoring it would be training accuracy wearing a held-out label.
## Result: 19 scored, 0 false accepts
.venv/bin/python code/eval_external.py --stock stock-lopia-clean \
data/external-eval/kewpie data/external-eval/other-mayo
| Set | Scored | Correct | Abstain | False accepts | Leaked |
|---|---|---|---|---|---|
| `kewpie/` | 11 | 8 | 3 | **0** | 1 |
| `other-mayo/` | 8 | 4 | 4 | **0** | 0 |
| **Total** | **19** | **12** | **7** | **0** | **1** |
Two things this establishes that the image6 shelf cannot:
- **`sku_l07` is answered with no enrolled image.** Both l07 images accept
through the text lane alone: `sku_l07_1` reads ラッキー フヨネーズ, and
`sku_l07_2` reads the rear label's 製造者:丸和油脂株式会社 with 内容量:200g.
The catalog row is 丸和油脂 ラッキーマヨネーズ 200g, so the manufacturer and
the size together name it. This is the text lane's coverage claim - that a
product with no reference image can still be named - holding on data that was
not used to build it.
- **The l04 abstentions are correct behaviour, not a miss.** l04 has no
enrolled image either, and its reads are weak: `sku_l04_2` reads only
コと旨 タ 味 (all rows score 0.0), and `sku_l04_4` reads the rear label with
やまや garbled to まや, scoring 0.379 - below the accept line. Abstaining on
a weak read is the designed outcome.
`sku_l07_2` accepts with a negative local score (-0.55) on text evidence alone.
That is the lane doing its job, but it is worth naming: the accept rests on two
matched tokens (manufacturer + size), not on geometry.
## What is still missing
No external source gives per-SKU identity for the whole catalog, and no
reusable clip combines the target category with shelf-distance text
resolution. The Tier-2 and Tier-3 gates in DECISION.md still need the
operator's own recorded walks.

View file

@ -0,0 +1,150 @@
{
"dataset": "kewpie",
"catalog": "stock-lopia-clean",
"note": "External proxy images, not Tier 2. Scored through the shipped LocalLaneAdapter + DecisionPolicy.",
"rows": [
{
"file": "sku_l01_1.jpg",
"truth": "sku_l01",
"best": "sku_l01",
"confidence": 13.1618,
"status": "success",
"source": "local",
"verdict": "correct"
},
{
"file": "sku_l01_2.jpg",
"truth": "sku_l01",
"best": "sku_l01",
"confidence": 20.986,
"status": "success",
"source": "local",
"verdict": "correct"
},
{
"file": "sku_l02_1.jpg",
"truth": "sku_l02",
"best": "sku_l02",
"confidence": 10.4288,
"status": "success",
"source": "text",
"verdict": "correct"
},
{
"file": "sku_l02_2.jpg",
"truth": "sku_l02",
"best": "sku_l02",
"confidence": 7.542,
"status": "success",
"source": "local",
"verdict": "correct"
},
{
"file": "sku_l03_1.jpg",
"truth": "sku_l03",
"best": "sku_l03",
"confidence": 14.3581,
"status": "success",
"source": "local",
"verdict": "correct"
},
{
"file": "sku_l03_2.jpg",
"truth": "sku_l03",
"best": "sku_l03",
"confidence": 48.9767,
"status": "success",
"source": "local",
"verdict": "correct"
},
{
"file": "sku_l05_1.jpg",
"truth": "sku_l05",
"best": null,
"confidence": 0.0,
"status": "unknown",
"source": null,
"verdict": "abstain"
},
{
"file": "sku_l05_2.jpg",
"truth": "sku_l05",
"best": "sku_l05",
"confidence": -3.9674,
"status": "success",
"source": "text",
"verdict": "correct"
},
{
"file": "sku_l06_1.jpg",
"truth": "sku_l06",
"verdict": "leaked",
"leaked_into": "sku_l06",
"note": "enrolled in the catalog; scoring it would be training accuracy"
},
{
"file": "sku_l06_2.jpg",
"truth": "sku_l06",
"best": "sku_l06",
"confidence": 294.8985,
"status": "success",
"source": "local",
"verdict": "correct"
},
{
"file": "sku_l11_1.jpg",
"truth": "sku_l11",
"best": null,
"confidence": -29.9175,
"status": "unknown",
"source": null,
"verdict": "abstain"
},
{
"file": "sku_l11_2.jpg",
"truth": "sku_l11",
"best": null,
"confidence": 14.1552,
"status": "ambiguous",
"source": null,
"verdict": "abstain"
}
],
"scored": 11,
"leaked": 1,
"by_sku": {
"sku_l01": {
"correct": 2,
"abstain": 0,
"wrong": 0
},
"sku_l02": {
"correct": 2,
"abstain": 0,
"wrong": 0
},
"sku_l03": {
"correct": 2,
"abstain": 0,
"wrong": 0
},
"sku_l05": {
"correct": 1,
"abstain": 1,
"wrong": 0
},
"sku_l06": {
"correct": 1,
"abstain": 0,
"wrong": 0
},
"sku_l11": {
"correct": 0,
"abstain": 2,
"wrong": 0
}
},
"correct": 8,
"abstain": 3,
"wrong": 0
}

View file

@ -0,0 +1,191 @@
{
"dataset_type": "external_proxy",
"overall_note": "Independent-source Internet media for recognition stress testing. This is an external_proxy set: it is not held-out store-visit Tier 2 data and does not satisfy the DECISION.md Tier-2 capture protocol. Every accepted file was visually inspected for the exact Kewpie product and package size. Official Kewpie front packshots already enrolled in stock-lopia-clean were excluded. The two sku_l06 files are current-package retailer images showing 内臓脂肪を減らす キユーピーフィッテ; customer-listing photos showing the superseded 内臓脂肪ケア wrapper were inspected and rejected.",
"license_note": "All accepted files come from commercial retailer or customer marketplace pages without an explicit reuse grant. They are marked restricted-research for internal evaluation only and should not be redistributed.",
"coverage": {
"accepted_images": 12,
"accepted_skus": [
"sku_l01",
"sku_l02",
"sku_l03",
"sku_l05",
"sku_l06",
"sku_l11"
],
"two_images_per_sku": [
"sku_l01",
"sku_l02",
"sku_l03",
"sku_l05",
"sku_l06",
"sku_l11"
],
"two_source_domains_per_sku": [
"sku_l01",
"sku_l02",
"sku_l03",
"sku_l05",
"sku_l06",
"sku_l11"
],
"not_found": []
},
"images": [
{
"file": "sku_l01_1.jpg",
"sku": "sku_l01",
"product": "キユーピー エッグケア 305g",
"source_page": "https://paypayfleamarket.yahoo.co.jp/item/z686464976",
"source_title": "キユーピー エッグケア 卵を使っていないマヨネーズタイプ 305g",
"media_url": "https://auctions.c.yimg.jp/images.auctions.yahoo.co.jp/image/dr000/auc0209/users/ad8016fc0a2ac99427f4bd970e286c7709594046/i-img1200x1200-1789785070735v2rkwx.jpg",
"width": 1200,
"height": 1200,
"view_type": "customer_front",
"license_status": "restricted-research",
"notes": "Customer-taken marketplace photo on a household surface. The front package visibly reads エッグケア and 305g; exact title corroborates the variant. Not the official Kewpie enrollment packshot. Source domain: paypayfleamarket.yahoo.co.jp; media host: auctions.c.yimg.jp."
},
{
"file": "sku_l01_2.jpg",
"sku": "sku_l01",
"product": "キユーピー エッグケア 305g",
"source_page": "https://www.amazon.co.jp/dp/B0BSVY8NNH",
"source_title": "キユーピー エッグケア (卵不使用) マヨネーズ タイプ 305g",
"media_url": "https://m.media-amazon.com/images/I/71lzmm5n5cL._AC_SL1500_.jpg",
"width": 1500,
"height": 1500,
"view_type": "retailer_in_use",
"license_status": "restricted-research",
"notes": "Retailer alternate image with the exact 305g package beside prepared potato salad; package text and size are visible. Not the enrolled plain front packshot. Source/media domain: amazon.co.jp / media-amazon.com."
},
{
"file": "sku_l02_1.jpg",
"sku": "sku_l02",
"product": "キユーピー アマニ油マヨネーズ 200g",
"source_page": "https://paypayfleamarket.yahoo.co.jp/item/z599237268",
"source_title": "キユーピー アマニ油マヨネーズ 200g 2個セット 血圧が高めの方に",
"media_url": "https://auctions.c.yimg.jp/images.auctions.yahoo.co.jp/image/dr000/auc0204/users/f571f9a659b398818c6c54e9862bf90f0e2d78ec/i-img900x1200-1777250065888ef77yp.jpg",
"width": 900,
"height": 1200,
"view_type": "customer_front_pair",
"license_status": "restricted-research",
"notes": "Customer-taken two-pack photo. Both fronts visibly read 血圧ケア アマニ油マヨネーズ and 200g; exact title corroborates size. Source domain: paypayfleamarket.yahoo.co.jp; media host: auctions.c.yimg.jp."
},
{
"file": "sku_l02_2.jpg",
"sku": "sku_l02",
"product": "キユーピー アマニ油マヨネーズ 200g",
"source_page": "https://www.amazon.co.jp/dp/B01F8L6KSQ",
"source_title": "キユーピー アマニ油マヨネーズ 200g [機能性表示食品]",
"media_url": "https://m.media-amazon.com/images/I/71s+50hXsPL._AC_SL1005_.jpg",
"width": 1005,
"height": 937,
"view_type": "back_label",
"license_status": "restricted-research",
"notes": "Retailer alternate rear-label view from the exact 200g listing. The unfolded back panel shows the アマニ油マヨネーズ label and 200g content declaration. Not a front enrollment image. Source/media domain: amazon.co.jp / media-amazon.com."
},
{
"file": "sku_l03_1.jpg",
"sku": "sku_l03",
"product": "キユーピー からしマヨネーズ 200g",
"source_page": "https://paypayfleamarket.yahoo.co.jp/item/z624032610",
"source_title": "キユーピー からしマヨネーズ 200g 4本セット",
"media_url": "https://auctions.c.yimg.jp/images.auctions.yahoo.co.jp/image/dr000/auc0206/users/198e53fdfc1edde3b4933f53fd4f0c02c3346cf5/i-img1200x714-1781002147108o12vd1.jpg",
"width": 1200,
"height": 714,
"view_type": "customer_front_bundle",
"license_status": "restricted-research",
"notes": "Customer-taken four-pack photo with visible からしマヨネーズ fronts; listing title fixes each unit at 200g. Household lighting and grouped packages differ from enrollment. Source domain: paypayfleamarket.yahoo.co.jp; media host: auctions.c.yimg.jp."
},
{
"file": "sku_l03_2.jpg",
"sku": "sku_l03",
"product": "キユーピー からしマヨネーズ 200g",
"source_page": "https://www.amazon.co.jp/dp/B0069K29EK",
"source_title": "キユーピー からしマヨネーズ 200g",
"media_url": "https://m.media-amazon.com/images/I/81ZODR6dv8L._AC_SL1500_.jpg",
"width": 1500,
"height": 1500,
"view_type": "retailer_in_use",
"license_status": "restricted-research",
"notes": "Retailer alternate image showing the exact 200g からしマヨネーズ package beside a prepared dish; the package front and 200g declaration are legible. Not the enrolled plain front packshot. Source/media domain: amazon.co.jp / media-amazon.com."
},
{
"file": "sku_l05_1.jpg",
"sku": "sku_l05",
"product": "キユーピー ゼロ ノンコレステロール 310g",
"source_page": "https://www.amazon.co.jp/dp/B0CBPWDXZJ",
"source_title": "キユーピー ゼロ ノンコレステロール マヨネーズ タイプ 310g",
"media_url": "https://m.media-amazon.com/images/I/81ykD7vVmIL._AC_SL1500_.jpg",
"width": 1305,
"height": 1500,
"view_type": "retailer_in_use_collage",
"license_status": "restricted-research",
"notes": "Retailer alternate usage collage with the exact キユーピーゼロ 310g package among prepared dishes. The composition differs materially from the enrolled front packshot. Source/media domain: amazon.co.jp / media-amazon.com."
},
{
"file": "sku_l05_2.jpg",
"sku": "sku_l05",
"product": "キユーピー ゼロ ノンコレステロール 310g",
"source_page": "https://store.shopping.yahoo.co.jp/eight-cat-store/s-b0cnsbqcly-20260717.html",
"source_title": "キユーピー ゼロ ノンコレステロール マヨネーズ タイプ 310g ×4個",
"media_url": "https://item-shopping.c.yimg.jp/i/n/eight-cat-store_s-b0cnsbqcly-20260717_4_d_20260717075244",
"width": 900,
"height": 900,
"view_type": "retailer_in_use_collage",
"license_status": "restricted-research",
"notes": "Independent retailer-hosted usage collage with the exact キユーピーゼロ 310g package shown among prepared dishes; the package front and 310g declaration are legible. Chosen over the same listing's plain front image so the set stays non-enrollment. Source domain: store.shopping.yahoo.co.jp; media host: item-shopping.c.yimg.jp."
},
{
"file": "sku_l06_1.jpg",
"sku": "sku_l06",
"product": "内臓脂肪を減らす キユーピーフィッテ 210g",
"source_page": "https://www.amazon.co.jp/dp/B08V2676LK",
"source_title": "キユーピー フィッテ 210g",
"media_url": "https://m.media-amazon.com/images/I/81zckoUh+vL._AC_SL1500_.jpg",
"width": 654,
"height": 1500,
"view_type": "retailer_current_package_front",
"license_status": "restricted-research",
"notes": "F-12 current-package evidence. The wrapper visibly says 内臓脂肪を減らす キユーピーフィッテ and 210g; the product page repeats 内臓脂肪を減らす and contains no 内臓脂肪ケア wording. This is not the superseded enrolled package. Source/media domain: amazon.co.jp / media-amazon.com."
},
{
"file": "sku_l06_2.jpg",
"sku": "sku_l06",
"product": "内臓脂肪を減らす キユーピーフィッテ 210g",
"source_page": "https://store.shopping.yahoo.co.jp/ladydrugheartshop/4901577083600.html",
"source_title": "キユーピー フィッテ 210g×5個",
"media_url": "https://item-shopping.c.yimg.jp/i/n/ladydrugheartshop_4901577083600_i_20260902221452",
"width": 500,
"height": 500,
"view_type": "retailer_current_package_front",
"license_status": "restricted-research",
"notes": "Second-domain F-12 corroboration. The current wrapper visibly says 内臓脂肪を減らす and 210g. Three PayPay Flea Market listings were also inspected, but all showed the superseded 内臓脂肪ケア wrapper and were rejected. Source domain: store.shopping.yahoo.co.jp; media host: item-shopping.c.yimg.jp."
},
{
"file": "sku_l11_1.jpg",
"sku": "sku_l11",
"product": "キユーピー ハーフ 400g",
"source_page": "https://paypayfleamarket.yahoo.co.jp/item/z692700538",
"source_title": "キユーピー ハーフ マヨネーズ 400g 2本セット カロリー50%カット",
"media_url": "https://auctions.c.yimg.jp/images.auctions.yahoo.co.jp/image/dr000/auc0209/users/67aace4c38f98ea5a750a86a39665a4c7bc9898d/i-img1200x1200-1790465979767g4gnwe.jpg",
"width": 1200,
"height": 1200,
"view_type": "customer_front_pair",
"license_status": "restricted-research",
"notes": "Customer-taken two-pack photo. Both fronts visibly show キユーピーハーフ; the title identifies each as 400g. Recorded as 400g, not the stock CSV's 450g wording. Source domain: paypayfleamarket.yahoo.co.jp; media host: auctions.c.yimg.jp."
},
{
"file": "sku_l11_2.jpg",
"sku": "sku_l11",
"product": "キユーピー ハーフ 400g",
"source_page": "https://www.amazon.co.jp/dp/B007XT4FIO",
"source_title": "キユーピー ハーフ 400g",
"media_url": "https://m.media-amazon.com/images/I/81u8+jlddyL._AC_SL1500_.jpg",
"width": 1406,
"height": 1500,
"view_type": "retailer_marketing_composite",
"license_status": "restricted-research",
"notes": "Retailer alternate marketing composite with the exact 400g package and product messaging. Not the plain official Kewpie front packshot. Recorded as 400g, matching the pictured package and listing title. Source/media domain: amazon.co.jp / media-amazon.com."
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Some files were not shown because too many files have changed in this diff Show more