feat: report views below the enrollment pyramid floor
Some checks failed
tests / model-free (pull_request) Has been cancelled
Some checks failed
tests / model-free (pull_request) Has been cancelled
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.
This commit is contained in:
parent
b38c66aa7b
commit
200328527e
2 changed files with 60 additions and 2 deletions
|
|
@ -73,7 +73,14 @@ MIN_NAME_OCR = 0.70
|
||||||
OCR_SCALE = 2.0
|
OCR_SCALE = 2.0
|
||||||
# Two views of one SKU this similar are the same photograph counted twice.
|
# Two views of one SKU this similar are the same photograph counted twice.
|
||||||
DUPLICATE_COSINE = 0.98
|
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
|
# 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
|
# 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.
|
# 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")
|
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 ".")
|
||||||
|
|
@ -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.
|
# 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)
|
||||||
verdict = live_ready_verdict(kept, names, load_shelf_skus(args.shelf_tags))
|
verdict = live_ready_verdict(kept, names, load_shelf_skus(args.shelf_tags))
|
||||||
|
|
||||||
report = {
|
report = {
|
||||||
"source": args.source,
|
"source": args.source,
|
||||||
"output": args.output,
|
"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",
|
"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,
|
||||||
"completeness": verdict,
|
"completeness": verdict,
|
||||||
"skus": {sku: views for sku, views in sorted(kept.items())},
|
"skus": {sku: views for sku, views in sorted(kept.items())},
|
||||||
"refused": refused,
|
"refused": refused,
|
||||||
|
|
@ -508,6 +537,11 @@ 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 verdict["live_ready"] is None:
|
if verdict["live_ready"] is None:
|
||||||
print(f"\ncompleteness: unknown - {verdict['reason']}")
|
print(f"\ncompleteness: unknown - {verdict['reason']}")
|
||||||
elif verdict["live_ready"]:
|
elif verdict["live_ready"]:
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ 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
|
||||||
|
PYRAMID_FLOOR,
|
||||||
RebuildError,
|
RebuildError,
|
||||||
|
below_pyramid_floor,
|
||||||
live_ready_verdict,
|
live_ready_verdict,
|
||||||
load_shelf_skus,
|
load_shelf_skus,
|
||||||
text_discriminated_siblings,
|
text_discriminated_siblings,
|
||||||
|
|
@ -43,6 +45,7 @@ def artwork(mark=None):
|
||||||
class SiblingFlagTests(unittest.TestCase):
|
class SiblingFlagTests(unittest.TestCase):
|
||||||
"""The near-unknown case: same artwork, different product."""
|
"""The near-unknown case: same artwork, different product."""
|
||||||
|
|
||||||
|
|
||||||
def make_source(self, images):
|
def make_source(self, images):
|
||||||
root = tempfile.mkdtemp(prefix="siblings-")
|
root = tempfile.mkdtemp(prefix="siblings-")
|
||||||
for name, image in images.items():
|
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), []
|
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):
|
class CompletenessVerdictTests(unittest.TestCase):
|
||||||
"""The enrollment protocol's completeness rule, as a verdict."""
|
"""The enrollment protocol's completeness rule, as a verdict."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue