From 200328527e9e66f03a219baaff4e2302c8862eaa Mon Sep 17 00:00:00 2001 From: tngo Date: Mon, 28 Sep 2026 22:45:25 +0900 Subject: [PATCH] feat: report views below the enrollment pyramid floor The protocol asks for a source at least 800px tall so a view can be stored as a 200/400/800px pyramid. Nothing enforced it, and the clean catalog's l09 view is 305px - the documented defect where its inlier scores dwarf every other reference (85-129 against 10-40) and it wins facings it should not. 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. The check names the view and its height so the re-sourcing has a target. Suite 272 -> 274. --- recognization/code/rebuild_catalog.py | 38 ++++++++++++++++++++-- recognization/code/test_rebuild_catalog.py | 24 ++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/recognization/code/rebuild_catalog.py b/recognization/code/rebuild_catalog.py index 32392e3..5682858 100644 --- a/recognization/code/rebuild_catalog.py +++ b/recognization/code/rebuild_catalog.py @@ -73,7 +73,14 @@ MIN_NAME_OCR = 0.70 OCR_SCALE = 2.0 # Two views of one SKU this similar are the same photograph counted twice. DUPLICATE_COSINE = 0.98 -# One view cannot be checked against anything; two is the floor for a vote. + +# 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. +PYRAMID_FLOOR = 800 + # Official pack-shot pages publish a single front view per JAN, so a catalog # built from them runs at 1 and says so; the floor exists to catch accidental # single-view enrollment from shelf crops, where several views are available. @@ -373,6 +380,27 @@ 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 ".") @@ -477,8 +505,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) verdict = live_ready_verdict(kept, names, load_shelf_skus(args.shelf_tags)) - report = { "source": args.source, "output": args.output, @@ -490,6 +518,7 @@ 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, "completeness": verdict, "skus": {sku: views for sku, views in sorted(kept.items())}, "refused": refused, @@ -508,6 +537,11 @@ 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 verdict["live_ready"] is None: print(f"\ncompleteness: unknown - {verdict['reason']}") elif verdict["live_ready"]: diff --git a/recognization/code/test_rebuild_catalog.py b/recognization/code/test_rebuild_catalog.py index 12a5d94..32fb330 100644 --- a/recognization/code/test_rebuild_catalog.py +++ b/recognization/code/test_rebuild_catalog.py @@ -23,7 +23,9 @@ import cv2 # noqa: E402 import numpy as np # noqa: E402 from rebuild_catalog import ( # noqa: E402 + PYRAMID_FLOOR, RebuildError, + below_pyramid_floor, live_ready_verdict, load_shelf_skus, text_discriminated_siblings, @@ -43,6 +45,7 @@ def artwork(mark=None): class SiblingFlagTests(unittest.TestCase): """The near-unknown case: same artwork, different product.""" + def make_source(self, images): root = tempfile.mkdtemp(prefix="siblings-") for name, image in images.items(): @@ -72,6 +75,27 @@ 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.""" + + def make_source(self, height): + root = tempfile.mkdtemp(prefix="floor-") + image = cv2.resize(artwork(), (300, height), interpolation=cv2.INTER_NEAREST) + 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_full_size_view_is_not_reported(self): + root = self.make_source(1000) + self.assertEqual(below_pyramid_floor({"sku_x1": ["sku_x1.jpg"]}, root), []) + class CompletenessVerdictTests(unittest.TestCase): """The enrollment protocol's completeness rule, as a verdict."""