product-recognition-demo/recognization/code/test_rebuild_catalog.py
tngo 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

278 lines
12 KiB
Python

"""
Contract tests for the enrollment tool's reporting checks.
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.
"""
from __future__ import annotations
import os
import sys
import tempfile
import unittest
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
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,
)
def artwork(mark=None):
"""A package face; `mark` adds the small text that separates siblings."""
image = np.full((400, 300, 3), 200, dtype="uint8")
cv2.rectangle(image, (20, 20), (280, 380), (40, 40, 180), -1)
cv2.putText(image, "MAYO", (60, 200), cv2.FONT_HERSHEY_SIMPLEX, 2.0, (255, 255, 255), 4)
if mark:
cv2.putText(image, mark, (60, 300), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (255, 255, 255), 3)
return image
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():
cv2.imwrite(os.path.join(root, name), image)
return root
def test_shared_artwork_across_skus_is_flagged(self):
root = self.make_source({"sku_x1.jpg": artwork(), "sku_x2.jpg": artwork("HALF")})
flagged = text_discriminated_siblings({"sku_x1": ["sku_x1.jpg"], "sku_x2": ["sku_x2.jpg"]}, root)
self.assertEqual(len(flagged), 1, flagged)
self.assertEqual(flagged[0]["sku"], "sku_x1")
self.assertEqual(flagged[0]["sibling_sku"], "sku_x2")
self.assertGreater(flagged[0]["cosine"], 0.98)
def test_distinct_artwork_is_not_flagged(self):
other = np.full((400, 300, 3), 30, dtype="uint8")
cv2.circle(other, (150, 200), 90, (200, 200, 40), -1)
root = self.make_source({"sku_x1.jpg": artwork(), "sku_x2.jpg": other})
self.assertEqual(
text_discriminated_siblings({"sku_x1": ["sku_x1.jpg"], "sku_x2": ["sku_x2.jpg"]}, root), []
)
def test_two_views_of_one_sku_are_not_siblings(self):
"""The same-SKU duplicate rule already covers these; this flag is cross-SKU."""
root = self.make_source({"sku_x1.jpg": artwork(), "sku_x1_2.jpg": artwork()})
self.assertEqual(
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."""
def test_without_a_shelf_list_the_verdict_is_unknown(self):
"""Not a guess: the rule needs the shelf's SKU list to mean anything."""
verdict = live_ready_verdict({"sku_a": ["a.jpg"]}, {"sku_a": "A"}, None)
self.assertIsNone(verdict["live_ready"])
self.assertIn("shelf", verdict["reason"])
def test_every_shelf_sku_enrolled_is_live_ready(self):
verdict = live_ready_verdict({"sku_a": ["a.jpg"]}, {"sku_a": "A"}, {"sku_a"})
self.assertTrue(verdict["live_ready"])
def test_a_text_only_row_satisfies_the_rule(self):
"""A CSV row with no image still names and vetoes from its text."""
verdict = live_ready_verdict({"sku_a": ["a.jpg"]}, {"sku_a": "A", "sku_b": "B"}, {"sku_a", "sku_b"})
self.assertTrue(verdict["live_ready"])
def test_a_shelf_sku_absent_from_the_catalog_fails(self):
verdict = live_ready_verdict({"sku_a": ["a.jpg"]}, {"sku_a": "A"}, {"sku_a", "sku_z"})
self.assertFalse(verdict["live_ready"])
self.assertEqual(verdict["missing_from_catalog"], ["sku_z"])
class ShelfTagListTests(unittest.TestCase):
def write(self, text):
path = os.path.join(tempfile.mkdtemp(prefix="tags-"), "tags.json")
with open(path, "w", encoding="utf-8") as handle:
handle.write(text)
return path
def test_a_json_list_is_read(self):
self.assertEqual(load_shelf_skus(self.write('["sku_a", "sku_b"]')), {"sku_a", "sku_b"})
def test_one_sku_per_line_is_read(self):
self.assertEqual(load_shelf_skus(self.write("sku_a\nsku_b\n")), {"sku_a", "sku_b"})
def test_no_path_means_no_list(self):
self.assertIsNone(load_shelf_skus(None))
def test_a_json_object_is_refused(self):
with self.assertRaises(RebuildError):
load_shelf_skus(self.write('{"sku_a": 1}'))
class BadShelfListFailsBeforeTheSwapTests(unittest.TestCase):
"""A bad --shelf-tags must not leave a replaced catalog behind.
The list is read before write_catalog runs, so a malformed file or a missing
path fails with the tool's own message and the destination is untouched. Read
after the swap, the same error would escape as a traceback over a catalog
that had already been replaced.
"""
def make_source(self):
root = tempfile.mkdtemp(prefix="guard-src-")
cv2.imwrite(os.path.join(root, "sku_x1.jpg"), artwork())
with open(os.path.join(root, "stock.csv"), "w", encoding="utf-8") as handle:
handle.write("SKU_ID,product_name,price,number_in,number_out,number_on\n")
handle.write("sku_x1,Alpha,100,1,0,1\n")
return root
def run_tool(self, shelf_tags):
from rebuild_catalog import main
source = self.make_source()
output = os.path.join(tempfile.mkdtemp(prefix="guard-out-"), "catalog")
with self.assertRaises(SystemExit) as caught:
main(["--source", source, "--output", output, "--csv",
os.path.join(source, "stock.csv"), "--min-views", "1",
"--ocr", "none", "--shelf-tags", shelf_tags])
return str(caught.exception), output
def test_a_malformed_list_fails_cleanly_and_writes_nothing(self):
path = os.path.join(tempfile.mkdtemp(prefix="bad-tags-"), "tags.json")
with open(path, "w", encoding="utf-8") as handle:
handle.write('{"sku_x1": 1}')
message, output = self.run_tool(path)
self.assertIn("expected a JSON list", message)
self.assertFalse(os.path.exists(output), "the catalog was replaced before the check")
def test_a_missing_list_fails_cleanly_and_writes_nothing(self):
message, output = self.run_tool("/tmp/definitely-not-a-shelf-list.json")
self.assertIn("No such file", message)
self.assertFalse(os.path.exists(output), "the catalog was replaced before the check")
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"])