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.
This commit is contained in:
parent
443a1957a9
commit
68061635d9
63 changed files with 63861 additions and 3 deletions
|
|
@ -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:
|
||||
|
|
@ -535,6 +563,7 @@ 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, shelf_skus)
|
||||
report = {
|
||||
"source": args.source,
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue