diff --git a/recognization/code/rebuild_catalog.py b/recognization/code/rebuild_catalog.py index d7078ed..c146453 100644 --- a/recognization/code/rebuild_catalog.py +++ b/recognization/code/rebuild_catalog.py @@ -7,7 +7,9 @@ its own exam. This rebuilds a catalog directory from named packshots and refuses, per view: - * a view shorter than MIN_HEIGHT or narrower than MIN_WIDTH, + * a view shorter than MIN_HEIGHT, narrower than MIN_WIDTH, or shorter than + PYRAMID_FLOOR (a source under it cannot be stored as the protocol's + 200/400/800 px pyramid, so its scores would not be comparable with the rest), * a view whose capture session appears in --eval-manifest, * a packshot whose catalog name is not supported by its OCR text, * a view that is a near-duplicate of another view of the same SKU, @@ -63,10 +65,16 @@ MIN_WIDTH = 120 # The enrollment protocol's floor: a reference view under 160 px tall carries no # text geometry (facings under 60 px wide failed every measured lane). MIN_HEIGHT = 160 -# Correct packshots measured 0.79-1.00 of the catalog name's bigrams in their -# OCR text; the wrong-size Kewpie 450g packshot tops out at 0.64 against every -# name in the catalog. 0.70 sits between them with room for OCR noise. -MIN_NAME_OCR = 0.70 +# The name/OCR gate, re-measured on the shipped packshots with the strict name +# side in place, each image read four ways and scored against all 12 catalog +# names: correct packshots 0.9167-1.0000 (seven read their whole name; l11's +# read omits "マヨネーズ" and is the floor), the wrong-product packshot in +# references/rejected/ 0.0476-0.5833. 0.75 is the midpoint: 0.167 of headroom +# above every correct packshot and 0.167 below the wrong-product ceiling, so +# OCR noise on either side does not move a verdict. Siblings are not this +# gate's job - the コクうま packshot scores 0.85 against the マヨネーズ name, +# which is what text_discriminated_siblings flags, not a gap a threshold closes. +MIN_NAME_OCR = 0.75 # Vision drops small print at native scale: the l11 packshot reads ハーフ only # when upscaled (0.57 -> 0.79). Reading at 2x is what makes the check about the # package rather than about Vision's recall. @@ -75,10 +83,12 @@ OCR_SCALE = 2.0 DUPLICATE_COSINE = 0.98 # The enrollment protocol's pyramid target: a source at least this tall can be -# stored as a 200/400/800 px pyramid. Below it the view still enrolls - the hard -# reject is MIN_HEIGHT - but its scores are not comparable with the rest, which -# is the documented l09 defect: at 305 px its inlier scores dwarf every other -# reference (85-129 against 10-40) and it wins facings it should not. +# stored as a 200/400/800 px pyramid, so a view under it cannot be scored +# against the rest. Refused, not reported: the l09 defect was a 305 px view +# whose inlier scores (85-129) dwarfed every other reference (10-40) and it won +# facings it should not, and a report is exactly the kind of thing that gets +# read once and then ignored. The fix is re-sourcing, which is a data task +# this tool should not let through the door in the first place. PYRAMID_FLOOR = 800 # Official pack-shot pages publish a single front view per JAN, so a catalog @@ -317,6 +327,12 @@ def audit( refused.append({"sku": sku, "view": name, "reason": f"{image.shape[1]}px wide, below the {MIN_WIDTH}px floor"}) continue + if image.shape[0] < PYRAMID_FLOOR: + refused.append({"sku": sku, "view": name, + "reason": f"{image.shape[0]}px tall, below the {PYRAMID_FLOOR}px " + "pyramid floor; its scores would not be comparable " + "with the rest - re-source this view"}) + continue if ocr_engine is not None and names is not None: try: text = best_orientation_text(ocr_engine, image, names.get(sku, "")) @@ -453,27 +469,6 @@ def load_shelf_skus(path: Optional[str]) -> Optional[Set[str]]: raise RebuildError(f"{path}: expected a JSON list or one SKU per line") -def below_pyramid_floor(kept: Dict[str, List[str]], source: str) -> List[Dict[str, Any]]: - """Enrolled views too small to store as the protocol's pyramid. - - Reported, not refused: the view is usable and dropping it would remove the - SKU from the catalog entirely, which is worse than an incomparable score. - The fix is re-sourcing the packshot, which is a data task. - """ - small: List[Dict[str, Any]] = [] - for sku, filenames in sorted(kept.items()): - for filename in filenames: - image = cv2.imread(os.path.join(source, filename)) - if image is not None and image.shape[0] < PYRAMID_FLOOR: - small.append({ - "sku": sku, - "view": filename, - "height": int(image.shape[0]), - "floor": PYRAMID_FLOOR, - }) - return small - - def write_catalog(kept: Dict[str, List[str]], source: str, output: str, csv_path: Optional[str]) -> None: """Copy the kept views and the whole CSV into a staging dir, then swap it in.""" staging = tempfile.mkdtemp(prefix=".catalog-", dir=os.path.dirname(os.path.abspath(output)) or ".") @@ -582,7 +577,6 @@ 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 = { @@ -596,7 +590,6 @@ def main(argv: Optional[Sequence[str]] = None) -> int: "ocr": engine.name if ocr_engine is not None else "skipped", "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())}, @@ -616,11 +609,6 @@ def main(argv: Optional[Sequence[str]] = None) -> int: for row in siblings: print(f" {row['sku']:<10} {row['view']:<20} ~ {row['sibling_sku']} " f"({row['sibling_view']}) cosine {row['cosine']}") - if small: - print(f"\nbelow the {PYRAMID_FLOOR}px pyramid floor ({len(small)}):") - 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") diff --git a/recognization/code/test_rebuild_catalog.py b/recognization/code/test_rebuild_catalog.py index 3cb3d1d..6d10dfe 100644 --- a/recognization/code/test_rebuild_catalog.py +++ b/recognization/code/test_rebuild_catalog.py @@ -1,11 +1,13 @@ """ -Contract tests for the enrollment tool's reporting checks. +Contract tests for the enrollment tool's gates. -The refusal rules (size floors, session leakage, name/OCR, duplicates) are what -the tool exists for; these tests cover the two checks that report rather than -refuse, because both are properties of the catalog as a whole and neither is -visible from a single view: cross-SKU near-duplicates, and the completeness -rule that decides whether a bay is live-ready. +The refusal rules (size floors including the pyramid floor, session leakage, +name/OCR, duplicates) are what the tool exists for, and they are pinned here +against the measured distribution rather than against invented numbers. The +two checks that report rather than refuse are covered too, because both are +properties of the catalog as a whole and neither is visible from a single +view: cross-SKU near-duplicates, and the completeness rule that decides whether +a bay is live-ready. """ from __future__ import annotations @@ -23,11 +25,13 @@ import cv2 # noqa: E402 import numpy as np # noqa: E402 from rebuild_catalog import ( # noqa: E402 + MIN_NAME_OCR, PYRAMID_FLOOR, RebuildError, - below_pyramid_floor, + audit, live_ready_verdict, load_shelf_skus, + name_ocr_similarity, text_discriminated_siblings, ) @@ -75,8 +79,13 @@ class SiblingFlagTests(unittest.TestCase): text_discriminated_siblings({"sku_x1": ["sku_x1.jpg", "sku_x1_2.jpg"]}, root), [] ) + class PyramidFloorTests(unittest.TestCase): - """The l09 defect: a view too small to store as the protocol's pyramid.""" + """The l09 defect: a 305 px view whose scores dwarf every other reference. + + Refused, not reported. Reporting it is what let the defect ship - the view + enrolled anyway, because a line in a catalog README is not a gate. + """ def make_source(self, height): root = tempfile.mkdtemp(prefix="floor-") @@ -84,17 +93,112 @@ class PyramidFloorTests(unittest.TestCase): cv2.imwrite(os.path.join(root, "sku_x1.jpg"), image) return root - def test_a_small_view_is_reported(self): - root = self.make_source(305) - small = below_pyramid_floor({"sku_x1": ["sku_x1.jpg"]}, root) - self.assertEqual(len(small), 1, small) - self.assertEqual(small[0]["sku"], "sku_x1") - self.assertEqual(small[0]["height"], 305) - self.assertEqual(small[0]["floor"], PYRAMID_FLOOR) + def test_a_view_under_the_floor_is_refused(self): + kept, refused = audit(self.make_source(305), min_views=1) + self.assertEqual(kept, {}, refused) + self.assertEqual(len(refused), 1, refused) + self.assertIn("305px", refused[0]["reason"]) + self.assertIn("pyramid floor", refused[0]["reason"]) - def test_a_full_size_view_is_not_reported(self): - root = self.make_source(1000) - self.assertEqual(below_pyramid_floor({"sku_x1": ["sku_x1.jpg"]}, root), []) + def test_the_floor_is_exact(self): + """799 refused, 800 kept: the boundary is the number, not a rounding.""" + under = audit(self.make_source(PYRAMID_FLOOR - 1), min_views=1) + self.assertEqual(under[0], {}, under[1]) + at = audit(self.make_source(PYRAMID_FLOOR), min_views=1) + self.assertEqual(at[0], {"sku_x1": ["sku_x1.jpg"]}, at[1]) + self.assertEqual(at[1], []) + + def test_a_full_size_view_still_enrolls(self): + kept, refused = audit(self.make_source(1000), min_views=1) + self.assertEqual(kept, {"sku_x1": ["sku_x1.jpg"]}, refused) + self.assertEqual(refused, []) + + +class NameOcrTests(unittest.TestCase): + """The wrong-product gate: a packshot's OCR must explain its catalog row. + + Pinned to the measured distribution, not to invented numbers. The reads are + what the vision backend returned for the shipped packshots and for the + rejected Kewpie 450g; recording them keeps the test on the pure function + instead of on Vision's recall, which moves with the macOS version. + """ + + # Worst correct packshot in stock-lopia-clean: the l11 read carries + # "キューピー ハーフ" and "400g" but never "マヨネーズ". + WORST_CORRECT = ( + "キューピー ハーフマヨネーズ 400g", + "2 キューピーハーフ New! 卵黄ならではのコクとうま味 " + "※写真はイメージです。 カロリー 50%ット 当社マヨネーズ比 400g", + 0.9167, + ) + # The recorded l09/l10 mix-up: Kewpie Mayonnaise 450g, filed under an + # Ajinomoto row. This is its best score against any of the 12 catalog names. + WRONG_PRODUCT = ( + "キューピー からしマヨネーズ 200g", + "キューピーマヨネーズ KEWPIE MAYONNAISE ® 450g", + 0.5833, + ) + + def test_a_correct_packshot_clears_the_gate(self): + name, text, measured = self.WORST_CORRECT + self.assertAlmostEqual(name_ocr_similarity(name, text), measured, places=3) + self.assertGreaterEqual(name_ocr_similarity(name, text), MIN_NAME_OCR) + + def test_the_wrong_size_packshot_is_refused(self): + """The check that exists to stop the l09/l10 mix-up keeps its teeth.""" + name, text, measured = self.WRONG_PRODUCT + self.assertAlmostEqual(name_ocr_similarity(name, text), measured, places=3) + self.assertLess(name_ocr_similarity(name, text), MIN_NAME_OCR) + + def test_the_threshold_sits_between_the_two_measured_bands(self): + """The recalibration pin. Lower it and the wrong product enrolls.""" + worst_correct = name_ocr_similarity(*self.WORST_CORRECT[:2]) + best_wrong = name_ocr_similarity(*self.WRONG_PRODUCT[:2]) + self.assertGreater(worst_correct, best_wrong, "the bands overlap; no threshold separates them") + self.assertGreaterEqual(MIN_NAME_OCR, best_wrong) + self.assertLessEqual(MIN_NAME_OCR, worst_correct) + self.assertGreaterEqual(MIN_NAME_OCR - best_wrong, 0.1, "no headroom below the wrong-product ceiling") + self.assertGreaterEqual(worst_correct - MIN_NAME_OCR, 0.1, "no headroom above the worst correct packshot") + + def test_a_maker_prefix_gap_does_not_admit_a_wrong_row(self): + """l07: its product tokens all read, its maker and size do not. + + The catalog names l07 "丸和油脂 ラッキーマヨネーズ 200g" and the packshot + reads "ラッキー マヨネーズ 調味料(アミノ酸)不使用" - every product bigram, + none of the maker or the size. The 0.5714 it scores is inside the + wrong-product band and stays refused: the gap is a name-token problem, + not a threshold to move. Scoping the check to the product tokens would + admit it; that is a change to this gate, not a reason to lower it. + """ + score = name_ocr_similarity("丸和油脂 ラッキーマヨネーズ 200g", + "ラッキー マヨネーズ 調味料(アミノ酸)不使用") + self.assertAlmostEqual(score, 0.5714, places=3) + self.assertLess(score, MIN_NAME_OCR) + + def test_audit_refuses_a_packshot_the_name_never_supports(self): + """l04 at 0.00: the upright read is three stray characters.""" + root = tempfile.mkdtemp(prefix="name-ocr-") + cv2.imwrite(os.path.join(root, "sku_x1.jpg"), + cv2.resize(artwork(), (300, 800), interpolation=cv2.INTER_NEAREST)) + + class FixedOcr: + name = "fixed" + + def read(self, image): + class Line: + text = "明太手屋さんが つくつ た コ" + + class Read: + lines = [Line()] + + return Read() + + kept, refused = audit(root, min_views=1, + names={"sku_x1": "やまや めんたいマヨネーズタイプ 200g"}, + ocr_engine=FixedOcr()) + self.assertEqual(kept, {}, refused) + self.assertEqual(len(refused), 1, refused) + self.assertIn("name/OCR", refused[0]["reason"]) class CompletenessVerdictTests(unittest.TestCase): @@ -257,7 +361,7 @@ class OrientationTests(unittest.TestCase): 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)) + cv2.imwrite(os.path.join(root, "sku_x1.jpg"), self.marked(800, 1000)) engine = self.FakeOcr({"wide": "つくつ|た", "tall": "キユーピー|エッグケア|305g"}) kept, refused = audit(root, min_views=1, names={"sku_x1": "キューピー エッグケア 305g"}, ocr_engine=engine) @@ -269,7 +373,7 @@ class OrientationTests(unittest.TestCase): from rebuild_catalog import audit root = tempfile.mkdtemp(prefix="orient-bad-") - cv2.imwrite(os.path.join(root, "sku_x1.jpg"), self.marked(400, 300)) + cv2.imwrite(os.path.join(root, "sku_x1.jpg"), self.marked(800, 300)) engine = self.FakeOcr({"wide": "キューピー|マヨネーズ", "tall": "キューピー|マヨネーズ"}) kept, refused = audit(root, min_views=1, names={"sku_x1": "やまや めんたいマヨネーズタイプ 200g"}, ocr_engine=engine) diff --git a/recognization/docs/API.md b/recognization/docs/API.md index 6f8c11d..73911ee 100644 --- a/recognization/docs/API.md +++ b/recognization/docs/API.md @@ -257,7 +257,8 @@ so the client can pair a result with the frame it sent. "by_sku": [{"sku_id": "sku_005", "facings": 3}]}, "timing_ms": {"decode": 3.1, "detect": 21.4, "track": 0.8, "total": 25.3}, "stats": {"frames_received": 264, "frames_processed": 264, "frames_dropped": 0, - "server_fps": 13.2, "classify_pending": 3, "classify_ms_p50": 88.9}} + "server_fps": 13.2, "classify_pending": 3, "classify_failures": 0, + "classify_ms_p50": 88.9}} ``` The optional crop lane is additive to protocol `1`. Without `capabilities`, the server sends no `crop_request` and existing clients keep their frame/control flow. @@ -271,6 +272,8 @@ does not take a frame id of its own. `crop_ack` means accepted for asynchronous classification, not recognized; subsequent results carry the updated identity and `identity.source` (`frame`, `crop`, or `inherited`). `stats.crops` has `requested`, `accepted`, and `rejected` counts. +`stats.classify_failures` counts classifications that raised. A non-zero value +means labels are missing for an operational reason, not because nothing matched. The hello limit is 1 MiB of **decoded crop bytes**. The `max_crop_bytes` in `capabilities` states the client's limit and must be an integer in 1..1048576; a diff --git a/recognization/stock-lopia-clean/README.md b/recognization/stock-lopia-clean/README.md index f9b9f95..2a57a5a 100644 --- a/recognization/stock-lopia-clean/README.md +++ b/recognization/stock-lopia-clean/README.md @@ -3,73 +3,150 @@ Official manufacturer pack shots, one front view per SKU. This replaces the views in `../stock-lopia/`, which were 71-119 px crops cut from the test shelf `add/image6.jpg` itself: one 18 px sliver, duplicate crops, a wrong product on -`sku_l09`, and five SKUs with no view at all. Enrolling from the test shelf is +`sku_l09`, and four SKUs with no view at all. Enrolling from the test shelf is why every dark package collapsed onto one SKU and why the identity head was trained on its own exam. -Rebuild or extend with: +Eight of the twelve rows are enrolled; `sku_l04`, `sku_l07`, `sku_l08` and +`sku_l12` stay text-only (see *Known gaps*). Every enrolled view is at least +1000 px tall. - python code/rebuild_catalog.py --source stock-lopia-clean --output stock-lopia-clean \ - --replace --min-views 1 --csv stock-lopia/stock.csv +Rebuild from the staged packshots with: + + python code/rebuild_catalog.py --source data/packshots/source \ + --output stock-lopia-clean --csv stock-lopia/stock.csv \ + --replace --min-views 1 `--min-views 1` because official pages publish a single front view per JAN. The default floor of 2 exists to catch accidental single-view enrollment from shelf crops, where several views are available. +To re-run the audit without touching the catalog, point `--output` at a +scratch path inside the repo and keep the JSON: + + python code/rebuild_catalog.py --source data/packshots/source \ + --output data/packshots/audit-check --csv stock-lopia/stock.csv \ + --min-views 1 --json data/packshots/audit-check.json + ## Provenance -| SKU | Product | Source | Image | -|---|---|---|---| -| sku_l01 | キユーピー エッグケア 305g | [kewpie.co.jp](https://www.kewpie.co.jp/products/detail/4901577090301/) | `4901577090301L.jpg` | -| sku_l02 | キユーピー 血圧ケア アマニ油マヨネーズ 200g | [kewpie.co.jp](https://www.kewpie.co.jp/products/detail/4901577060052/) | `4901577060052L.jpg` | -| sku_l03 | キユーピー からしマヨネーズ 200g | [kewpie.co.jp](https://www.kewpie.co.jp/products/detail/4901577338779/) | `4901577338779L.jpg` | -| sku_l05 | キユーピー ゼロ ノンコレステロール 310g | [kewpie.co.jp](https://www.kewpie.co.jp/products/detail/4901577092381/) | `4901577092381L.jpg` | -| sku_l06 | キユーピー 内臓脂肪ケア フィッテ 210g | [kewpie.co.jp](https://www.kewpie.co.jp/products/close/) (discontinued 2026秋) | `4901577083600L.jpg` | -| sku_l09 | ピュアセレクト コクうま カロリーカット65% 360g | [ajinomoto.co.jp](https://www.ajinomoto.co.jp/products/detail/?ProductName=pureselect_2) | `A140600321` | -| sku_l11 | キユーピー ハーフ 400g | [kewpie.co.jp](https://www.kewpie.co.jp/products/detail/4901577031083/) | `4901577031083L.jpg` | +Product names are the catalog's, verbatim from `stock.csv`; sizes are measured. -Kewpie images follow `https://img.kewpie.co.jp/products_src/g3/imgs/L.jpg`; -Ajinomoto images follow -`https://www.ajinomoto.co.jp/products/Image.aspx?PicType=0&PIMNO=`. +| SKU | Catalog name | View | Recorded source | +|---|---|---|---| +| sku_l01 | キューピー エッグケア 305g | 455x1000 | not recorded | +| sku_l02 | キューピー アマニ油マヨネーズ 200g | 468x1000 | not recorded | +| sku_l03 | キューピー からしマヨネーズ 200g | 450x1000 | not recorded | +| sku_l05 | キューピー ゼロ ノンコレステロール 310g | 461x1000 | not recorded | +| sku_l06 | キューピー フィッテ 210g | 654x1500 | [amazon.co.jp](https://www.amazon.co.jp/dp/B08V2676LK) | +| sku_l09 | ピュアセレクト コクうま カロリーカット65% 360g | 1000x1000 | not recorded | +| sku_l10 | ピュアセレクト マヨネーズ 400g | 1000x1000 | [rakuten.co.jp](https://item.rakuten.co.jp/matsukiyo/4901001114030/) (JAN 4901001114030) | +| sku_l11 | キューピー ハーフマヨネーズ 400g | 482x1000 | not recorded | + +`data/packshots/source/ATTRIBUTION.json` records a URL for `sku_l06` and +`sku_l10` only. Its `_provenance_gap` note names the other six - l01, l02, l03, +l05, l09, l11 - as enrolled before any provenance was tracked, and the audit +reports them under `unattributed_skus` on every run. What *is* verified about +them, per that note: each is independent of the evaluation photo (template +match against `add/image6.jpg` peaks at 0.166, against 0.95 for a crop of it), +each is 1000 px or taller, and each one's own OCR matches its catalog row (see +*Name/OCR gate*). That is a traceability gap, not a correctness one. + +`sku_l06` is the current package (`内臓脂肪を減らす`), which replaced the +superseded `内臓脂肪ケア` view on 2026-09-29. The catalog name was shortened to +match; the shelf tags were not part of that change. + +## Name/OCR gate + +A packshot's OCR has to explain its own catalog row's name, or it is refused. +Measured over the shipped packshots - every image read in all four rotations, +every read scored against all 12 catalog names: + +| Class | n | Score | +|---|---|---| +| Correct packshot vs its own row | 8 | 0.9167 - 1.0000 | +| Wrong product vs any row | 1 image x 12 names | 0.0476 - 0.5833 | + +`MIN_NAME_OCR = 0.75` is the midpoint of those two bands: 0.167 of headroom +above the worst correct packshot (l11, whose read omits `マヨネーズ`) and 0.167 +below the wrong-product ceiling. The wrong-product image is +`references/rejected/kewpie-mayonnaise-450g.jpg`; its best score anywhere is +0.5833, tied against `キューピー からしマヨネーズ 200g` and +`キューピー ハーフマヨネーズ 400g`, and it bottoms out at 0.0476 against +`ピュアセレクト コクうま カロリーカット65% 360g`. + +Reproduce the band with (run from the recognition root; unindented so it runs +verbatim): + +```python +import csv, os, sys, cv2 +sys.path.insert(0, 'code') +from ocr import build_ocr_engine +from rebuild_catalog import best_orientation_text, name_ocr_similarity + +clean = 'stock-lopia-clean' +names = {r['SKU_ID']: r['product_name'] for r in csv.DictReader(open(clean + '/stock.csv'))} +engine = build_ocr_engine(None) +for n in sorted(os.listdir(clean)): + if n.endswith('.jpg'): + sku = n[:-4] + read = best_orientation_text(engine, cv2.imread(os.path.join(clean, n)), names[sku]) + print(sku, round(name_ocr_similarity(names[sku], read), 4)) + +rej = 'references/rejected/kewpie-mayonnaise-450g.jpg' +read = best_orientation_text(engine, cv2.imread(os.path.join(clean, rej)), names['sku_l01']) +print('rejected max', max((round(name_ocr_similarity(v, read), 4), k) for k, v in names.items())) +``` + +The gate cannot separate siblings, and does not try: the `sku_l09` packshot +scores 0.8462 against the `sku_l10` name, because both are Ajinomoto +ピュアセレクト mayonnaise. That case is what `text_discriminated_siblings` flags +(l09 ~ l10, cosine 0.9817) - it is a cross-SKU geometry problem, not a gap a +threshold closes. + +## Pyramid floor + +Every enrolled view must be at least 800 px tall, or it is refused. This is a +hard gate, not a report. It exists because of `sku_l09`: the view enrolled at +305 px could not be stored as a 200/400/800 px pyramid, so its local lane +upscaled 305->400 px and scored 85-129 inliers against 10-40 for every other +reference, winning facings it should not. It was re-sourced at 1000x1000 on +2026-09-29. Reporting the defect instead of refusing it is what let it ship. + +`data/packshots/source/sku_l07.jpg` is 600x600 and is refused on this rule +before OCR even runs. ## Known gaps -Five SKUs in `../stock-lopia/stock.csv` have no official pack shot here, so no -image lane can match them. They stay in `stock.csv` as text-only rows: the text -lane builds a signature from the printed name, which is how it can name or veto -a near-unknown the matcher cannot see. They are the current image-coverage -ceiling, not a recognition failure: +Four rows in `../stock-lopia/stock.csv` have no enrolled image, so no image +lane can match them. They stay in `stock.csv` as text-only rows: the text lane +builds a signature from the printed name, which is how it can name or veto a +near-unknown the matcher cannot see. They are the current image-coverage +ceiling, not a recognition failure. -| SKU | Catalog name | Why it is missing | +| SKU | Catalog name | Why there is no view | |---|---|---| -| sku_l04 | やまや めんたいマヨネーズタイプ 200g | Yamaya's current lineup has no 200 g mentaiko mayo | -| sku_l07 | 丸和油脂 ラッキーマヨネーズ 200g | Maruwa's current catalog has no 200 g Lucky Mayonnaise | +| sku_l04 | やまや めんたいマヨネーズタイプ 200g | A packshot is staged and refused at name/OCR 0.00: the image reads only `明太手屋さんが つくつ た コ`, brand text that runs vertically. A correctly oriented or higher-resolution shot is what would clear it. | +| sku_l07 | 丸和油脂 ラッキーマヨネーズ 200g | A packshot is staged and refused twice: it is 600x600, under the pyramid floor, and scores name/OCR 0.5714. Every product bigram reads (`ラッキー マヨネーズ`); the six it misses are the maker prefix `丸和`/`和油`/`油脂` and the size `20`/`0G`/`00`, which the package does not print. | | sku_l08 | スパイシーストーム ペリペリミックス 30g | No official 30 g Peri-Peri Mix found | -| sku_l10 | ピュアセレクト マヨネーズ 450g | Ajinomoto's current lineup is 400/250/600 g/1 kg; no 450 g | -| sku_l12 | 焼肉のたれ 中辛 | Brand not recorded in the catalog; ambiguous | +| sku_l12 | ユーラス 野菜をおいしく食べる旨たれ 380g | No pack shot sourced | -The catalog names for these five disagree with the manufacturers' current -lineups, so the shelf tags and the CSV need reconciling before enrollment. +`sku_l10` left this table on 2026-09-29. Its catalog name said 450 g, which +Ajinomoto does not make; the shelf tag in `add/image6.jpg` and the +manufacturer's lineup both say 400 g, so `stock.csv` was corrected and a +1000x1000 packshot (JAN 4901001114030) enrolled. See +`data/packshots/source/ATTRIBUTION.json`. Keep the text-only rows when rebuilding: the CSV carry-over must preserve every -source row, not just the SKUs with a kept view. Dropping these five costs the +source row, not just the SKUs with a kept view. Dropping these four costs the text lane the signatures that cover them. -`sku_l09.jpg` is enrolled but only 305 px tall, below the 800 px source floor -the enrollment protocol wants (§8 of the architecture decision). Its matcher -scores dwarf the other references (85-129 inliers against 10-40), so it wins -facings it should not; re-source it before trusting l09 precision. Measured on -the corrected image6 labels, l09 is the largest single-SKU share of accepts at -both resolutions: 18 of 48 accepts (37.5%) at full, 11 of 38 (28.9%) at live, -against a true share of 13 of 61 facings (21.3%). That is a hubness ratio of -1.76 and 1.36 against the completion gate's limit of 2.0, so G7 passes; the -stricter internal 30% share heuristic is what raises the alarm at full -resolution. Re-sourcing the reference is the fix, not a threshold change. - ## Rejected `references/rejected/kewpie-mayonnaise-450g.jpg` is Kewpie Mayonnaise 450g (JAN -4901577042072). It was briefly filed as `sku_l09` and then as `sku_l10` because -the catalog's names pointed there, but it is neither product: l09 is Ajinomoto -Pure Select コクうま カロリーカット 65% 360g and l10 is Pure Select マヨネーズ -450g. It is kept as provenance for that mix-up and is never enrolled. +4901577042072). It was filed as `sku_l09` and then as `sku_l10` because the +catalog's names pointed there, but it is neither: l09 is Ajinomoto Pure Select +コクうま カロリーカット 65% 360g and l10 is Pure Select マヨネーズ 400g. Both now +hold their own correct views. The rejected image is kept as provenance for that +mix-up and is never enrolled; the name/OCR gate scores it at most 0.5833 +against any catalog name, below the 0.75 threshold.