fix(catalog): re-source sku_l09 and enforce the pyramid floor
sku_l09.jpg was 407x305, so its 400 px pyramid level was a 305->400 upscale: a blur with no high-frequency detail to disagree with, which won 31 of 54 crops on the shelf it was cut from. Re-sourced at 1000x1000. The floor is now a hard gate in rebuild_catalog.py - a view under 800 px tall is refused, not reported - because reporting it is what let the 305 px view ship. data/packshots/source/sku_l07.jpg (600x600) is refused on this rule. README records the real sizes and the provenance for every row.
This commit is contained in:
parent
5094b5f6ca
commit
730a5e782b
4 changed files with 274 additions and 102 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue