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
|
|
@ -7,7 +7,9 @@ its own exam.
|
||||||
|
|
||||||
This rebuilds a catalog directory from named packshots and refuses, per view:
|
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 view whose capture session appears in --eval-manifest,
|
||||||
* a packshot whose catalog name is not supported by its OCR text,
|
* 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,
|
* 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
|
# 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).
|
# text geometry (facings under 60 px wide failed every measured lane).
|
||||||
MIN_HEIGHT = 160
|
MIN_HEIGHT = 160
|
||||||
# Correct packshots measured 0.79-1.00 of the catalog name's bigrams in their
|
# The name/OCR gate, re-measured on the shipped packshots with the strict name
|
||||||
# OCR text; the wrong-size Kewpie 450g packshot tops out at 0.64 against every
|
# side in place, each image read four ways and scored against all 12 catalog
|
||||||
# name in the catalog. 0.70 sits between them with room for OCR noise.
|
# names: correct packshots 0.9167-1.0000 (seven read their whole name; l11's
|
||||||
MIN_NAME_OCR = 0.70
|
# 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
|
# 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
|
# when upscaled (0.57 -> 0.79). Reading at 2x is what makes the check about the
|
||||||
# package rather than about Vision's recall.
|
# package rather than about Vision's recall.
|
||||||
|
|
@ -75,10 +83,12 @@ OCR_SCALE = 2.0
|
||||||
DUPLICATE_COSINE = 0.98
|
DUPLICATE_COSINE = 0.98
|
||||||
|
|
||||||
# The enrollment protocol's pyramid target: a source at least this tall can be
|
# 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
|
# stored as a 200/400/800 px pyramid, so a view under it cannot be scored
|
||||||
# reject is MIN_HEIGHT - but its scores are not comparable with the rest, which
|
# against the rest. Refused, not reported: the l09 defect was a 305 px view
|
||||||
# is the documented l09 defect: at 305 px its inlier scores dwarf every other
|
# whose inlier scores (85-129) dwarfed every other reference (10-40) and it won
|
||||||
# reference (85-129 against 10-40) and it wins facings it should not.
|
# 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
|
PYRAMID_FLOOR = 800
|
||||||
|
|
||||||
# Official pack-shot pages publish a single front view per JAN, so a catalog
|
# 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,
|
refused.append({"sku": sku, "view": name,
|
||||||
"reason": f"{image.shape[1]}px wide, below the {MIN_WIDTH}px floor"})
|
"reason": f"{image.shape[1]}px wide, below the {MIN_WIDTH}px floor"})
|
||||||
continue
|
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:
|
if ocr_engine is not None and names is not None:
|
||||||
try:
|
try:
|
||||||
text = best_orientation_text(ocr_engine, image, names.get(sku, ""))
|
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")
|
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:
|
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."""
|
"""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 ".")
|
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.
|
# and vetoes it from the CSV alone, so it is a coverage gap, not a refusal.
|
||||||
text_only = sorted(set(names) - set(kept))
|
text_only = sorted(set(names) - set(kept))
|
||||||
siblings = text_discriminated_siblings(kept, args.source, names)
|
siblings = text_discriminated_siblings(kept, args.source, names)
|
||||||
small = below_pyramid_floor(kept, args.source)
|
|
||||||
no_source = unattributed(args.source)
|
no_source = unattributed(args.source)
|
||||||
verdict = live_ready_verdict(kept, names, shelf_skus)
|
verdict = live_ready_verdict(kept, names, shelf_skus)
|
||||||
report = {
|
report = {
|
||||||
|
|
@ -596,7 +590,6 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||||
"ocr": engine.name if ocr_engine is not None else "skipped",
|
"ocr": engine.name if ocr_engine is not None else "skipped",
|
||||||
"text_only_skus": text_only,
|
"text_only_skus": text_only,
|
||||||
"text_discriminated_siblings": siblings,
|
"text_discriminated_siblings": siblings,
|
||||||
"below_pyramid_floor": small,
|
|
||||||
"unattributed_skus": no_source,
|
"unattributed_skus": no_source,
|
||||||
"completeness": verdict,
|
"completeness": verdict,
|
||||||
"skus": {sku: views for sku, views in sorted(kept.items())},
|
"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:
|
for row in siblings:
|
||||||
print(f" {row['sku']:<10} {row['view']:<20} ~ {row['sibling_sku']} "
|
print(f" {row['sku']:<10} {row['view']:<20} ~ {row['sibling_sku']} "
|
||||||
f"({row['sibling_view']}) cosine {row['cosine']}")
|
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:
|
if no_source:
|
||||||
print(f"\nno recorded source ({len(no_source)}): {', '.join(no_source)} - "
|
print(f"\nno recorded source ({len(no_source)}): {', '.join(no_source)} - "
|
||||||
"usable, but provenance is missing; re-sourcing would close it")
|
"usable, but provenance is missing; re-sourcing would close it")
|
||||||
|
|
|
||||||
|
|
@ -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 refusal rules (size floors including the pyramid floor, session leakage,
|
||||||
the tool exists for; these tests cover the two checks that report rather than
|
name/OCR, duplicates) are what the tool exists for, and they are pinned here
|
||||||
refuse, because both are properties of the catalog as a whole and neither is
|
against the measured distribution rather than against invented numbers. The
|
||||||
visible from a single view: cross-SKU near-duplicates, and the completeness
|
two checks that report rather than refuse are covered too, because both are
|
||||||
rule that decides whether a bay is live-ready.
|
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
|
from __future__ import annotations
|
||||||
|
|
@ -23,11 +25,13 @@ import cv2 # noqa: E402
|
||||||
import numpy as np # noqa: E402
|
import numpy as np # noqa: E402
|
||||||
|
|
||||||
from rebuild_catalog import ( # noqa: E402
|
from rebuild_catalog import ( # noqa: E402
|
||||||
|
MIN_NAME_OCR,
|
||||||
PYRAMID_FLOOR,
|
PYRAMID_FLOOR,
|
||||||
RebuildError,
|
RebuildError,
|
||||||
below_pyramid_floor,
|
audit,
|
||||||
live_ready_verdict,
|
live_ready_verdict,
|
||||||
load_shelf_skus,
|
load_shelf_skus,
|
||||||
|
name_ocr_similarity,
|
||||||
text_discriminated_siblings,
|
text_discriminated_siblings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -75,8 +79,13 @@ class SiblingFlagTests(unittest.TestCase):
|
||||||
text_discriminated_siblings({"sku_x1": ["sku_x1.jpg", "sku_x1_2.jpg"]}, root), []
|
text_discriminated_siblings({"sku_x1": ["sku_x1.jpg", "sku_x1_2.jpg"]}, root), []
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PyramidFloorTests(unittest.TestCase):
|
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):
|
def make_source(self, height):
|
||||||
root = tempfile.mkdtemp(prefix="floor-")
|
root = tempfile.mkdtemp(prefix="floor-")
|
||||||
|
|
@ -84,17 +93,112 @@ class PyramidFloorTests(unittest.TestCase):
|
||||||
cv2.imwrite(os.path.join(root, "sku_x1.jpg"), image)
|
cv2.imwrite(os.path.join(root, "sku_x1.jpg"), image)
|
||||||
return root
|
return root
|
||||||
|
|
||||||
def test_a_small_view_is_reported(self):
|
def test_a_view_under_the_floor_is_refused(self):
|
||||||
root = self.make_source(305)
|
kept, refused = audit(self.make_source(305), min_views=1)
|
||||||
small = below_pyramid_floor({"sku_x1": ["sku_x1.jpg"]}, root)
|
self.assertEqual(kept, {}, refused)
|
||||||
self.assertEqual(len(small), 1, small)
|
self.assertEqual(len(refused), 1, refused)
|
||||||
self.assertEqual(small[0]["sku"], "sku_x1")
|
self.assertIn("305px", refused[0]["reason"])
|
||||||
self.assertEqual(small[0]["height"], 305)
|
self.assertIn("pyramid floor", refused[0]["reason"])
|
||||||
self.assertEqual(small[0]["floor"], PYRAMID_FLOOR)
|
|
||||||
|
|
||||||
def test_a_full_size_view_is_not_reported(self):
|
def test_the_floor_is_exact(self):
|
||||||
root = self.make_source(1000)
|
"""799 refused, 800 kept: the boundary is the number, not a rounding."""
|
||||||
self.assertEqual(below_pyramid_floor({"sku_x1": ["sku_x1.jpg"]}, root), [])
|
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):
|
class CompletenessVerdictTests(unittest.TestCase):
|
||||||
|
|
@ -257,7 +361,7 @@ class OrientationTests(unittest.TestCase):
|
||||||
root = tempfile.mkdtemp(prefix="orient-")
|
root = tempfile.mkdtemp(prefix="orient-")
|
||||||
# Wider than tall, so the upright pass is the "wide" read (noise) and
|
# Wider than tall, so the upright pass is the "wide" read (noise) and
|
||||||
# only a 90-degree rotation produces the "tall" read that matches.
|
# 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"})
|
engine = self.FakeOcr({"wide": "つくつ|た", "tall": "キユーピー|エッグケア|305g"})
|
||||||
kept, refused = audit(root, min_views=1, names={"sku_x1": "キューピー エッグケア 305g"},
|
kept, refused = audit(root, min_views=1, names={"sku_x1": "キューピー エッグケア 305g"},
|
||||||
ocr_engine=engine)
|
ocr_engine=engine)
|
||||||
|
|
@ -269,7 +373,7 @@ class OrientationTests(unittest.TestCase):
|
||||||
from rebuild_catalog import audit
|
from rebuild_catalog import audit
|
||||||
|
|
||||||
root = tempfile.mkdtemp(prefix="orient-bad-")
|
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": "キューピー|マヨネーズ"})
|
engine = self.FakeOcr({"wide": "キューピー|マヨネーズ", "tall": "キューピー|マヨネーズ"})
|
||||||
kept, refused = audit(root, min_views=1, names={"sku_x1": "やまや めんたいマヨネーズタイプ 200g"},
|
kept, refused = audit(root, min_views=1, names={"sku_x1": "やまや めんたいマヨネーズタイプ 200g"},
|
||||||
ocr_engine=engine)
|
ocr_engine=engine)
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,8 @@ so the client can pair a result with the frame it sent.
|
||||||
"by_sku": [{"sku_id": "sku_005", "facings": 3}]},
|
"by_sku": [{"sku_id": "sku_005", "facings": 3}]},
|
||||||
"timing_ms": {"decode": 3.1, "detect": 21.4, "track": 0.8, "total": 25.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,
|
"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
|
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.
|
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
|
classification, not recognized; subsequent results carry the updated identity and
|
||||||
`identity.source` (`frame`, `crop`, or `inherited`). `stats.crops` has `requested`,
|
`identity.source` (`frame`, `crop`, or `inherited`). `stats.crops` has `requested`,
|
||||||
`accepted`, and `rejected` counts.
|
`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
|
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
|
`capabilities` states the client's limit and must be an integer in 1..1048576; a
|
||||||
|
|
|
||||||
|
|
@ -3,73 +3,150 @@
|
||||||
Official manufacturer pack shots, one front view per SKU. This replaces the
|
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
|
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
|
`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
|
why every dark package collapsed onto one SKU and why the identity head was
|
||||||
trained on its own exam.
|
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 \
|
Rebuild from the staged packshots with:
|
||||||
--replace --min-views 1 --csv stock-lopia/stock.csv
|
|
||||||
|
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
|
`--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
|
default floor of 2 exists to catch accidental single-view enrollment from shelf
|
||||||
crops, where several views are available.
|
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
|
## Provenance
|
||||||
|
|
||||||
| SKU | Product | Source | Image |
|
Product names are the catalog's, verbatim from `stock.csv`; sizes are measured.
|
||||||
|---|---|---|---|
|
|
||||||
| 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` |
|
|
||||||
|
|
||||||
Kewpie images follow `https://img.kewpie.co.jp/products_src/g3/imgs/<JAN13>L.jpg`;
|
| SKU | Catalog name | View | Recorded source |
|
||||||
Ajinomoto images follow
|
|---|---|---|---|
|
||||||
`https://www.ajinomoto.co.jp/products/Image.aspx?PicType=0&PIMNO=<PIMNO>`.
|
| 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
|
## Known gaps
|
||||||
|
|
||||||
Five SKUs in `../stock-lopia/stock.csv` have no official pack shot here, so no
|
Four rows in `../stock-lopia/stock.csv` have no enrolled image, so no image
|
||||||
image lane can match them. They stay in `stock.csv` as text-only rows: the text
|
lane can match them. They stay in `stock.csv` as text-only rows: the text lane
|
||||||
lane builds a signature from the printed name, which is how it can name or veto
|
builds a signature from the printed name, which is how it can name or veto a
|
||||||
a near-unknown the matcher cannot see. They are the current image-coverage
|
near-unknown the matcher cannot see. They are the current image-coverage
|
||||||
ceiling, not a recognition failure:
|
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_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 | Maruwa's current catalog has no 200 g Lucky Mayonnaise |
|
| 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_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 | ユーラス 野菜をおいしく食べる旨たれ 380g | No pack shot sourced |
|
||||||
| sku_l12 | 焼肉のたれ 中辛 | Brand not recorded in the catalog; ambiguous |
|
|
||||||
|
|
||||||
The catalog names for these five disagree with the manufacturers' current
|
`sku_l10` left this table on 2026-09-29. Its catalog name said 450 g, which
|
||||||
lineups, so the shelf tags and the CSV need reconciling before enrollment.
|
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
|
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.
|
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
|
## Rejected
|
||||||
|
|
||||||
`references/rejected/kewpie-mayonnaise-450g.jpg` is Kewpie Mayonnaise 450g (JAN
|
`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
|
4901577042072). It was filed as `sku_l09` and then as `sku_l10` because the
|
||||||
the catalog's names pointed there, but it is neither product: l09 is Ajinomoto
|
catalog's names pointed there, but it is neither: l09 is Ajinomoto Pure Select
|
||||||
Pure Select コクうま カロリーカット 65% 360g and l10 is Pure Select マヨネーズ
|
コクうま カロリーカット 65% 360g and l10 is Pure Select マヨネーズ 400g. Both now
|
||||||
450g. It is kept as provenance for that mix-up and is never enrolled.
|
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue