fix: count products, not facings, and demand an anchor before accusing
Two false positives came out of running the rules against a real shelf, and both had the same shape: the rules were arguing with our own detector instead of with the shelf. 1. One tag per product, not per facing. Measured: a row carries 16-18 facings but only 4-5 tags, because four facings of a mayonnaise share a single tag. Counting facings invents missing tags on a shelf that is perfectly labelled. The rules now group a row's facings into products - by identity when the facings were recognised, by size and spacing otherwise - and count products. 2. A product's tag may sit anywhere along its run. Measured: a run of ピュアセレクト facings was split by geometry because one facing was detected narrower than its neighbours (54 px beside 150 px), and the fragment was then reported as missing the tag that was sitting further along its own run. A product now counts as missing only when no tag exists for its SKU anywhere in the row, and a fragment far narrower than the row's typical product is not judged at all. 3. A mismatch needs one trustworthy anchor. Measured noise: a wrong identity plus a garbled tag read (name match 0.11, no JAN) produced a confident "tag says 90, catalog says 188" that was really two bad reads disagreeing. Without the tag's JAN or a name that resembles the product, that is now a hint, not a violation. Measured on add/image6.jpg through the real still path: 0 false violations without identities (was 1), and with identities the only finding left is a genuine tag-versus-catalog price difference. Coverage hints now read "tags detected for 2 of 4 products in this row". 143 tests pass. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
02c0fd1f4f
commit
cf7e8a5a88
2 changed files with 228 additions and 35 deletions
|
|
@ -147,13 +147,18 @@ def track(track_id, box, sku="sku_003", state="recognized"):
|
|||
}
|
||||
|
||||
|
||||
BANDS = [band(1, [0, 300, 700, 340]), band(2, [0, 600, 700, 640])]
|
||||
# Wide enough that separate products can sit apart in a row.
|
||||
BANDS = [band(1, [0, 300, 3000, 340]), band(2, [0, 600, 3000, 640])]
|
||||
|
||||
|
||||
class ComplianceTests(unittest.TestCase):
|
||||
def test_missing_tag_on_a_covered_row_is_a_violation(self):
|
||||
# 3 of 4 facings carry tag evidence (75% >= the 70% bar), one has none.
|
||||
tracks = [track(index, [10 + index * 150, 100, 140 + index * 150, 300]) for index in range(4)]
|
||||
# Four separate products (wide gaps), three with tag evidence - 75%, above
|
||||
# the 70% bar - and one with none.
|
||||
tracks = [
|
||||
track(index, [10 + index * 300, 100, 140 + index * 300, 300], sku=f"sku_{index}")
|
||||
for index in range(4)
|
||||
]
|
||||
tags = {
|
||||
0: tag(state=TAG_STATE_READABLE, price=168, id="tag_a"),
|
||||
1: tag(state=TAG_STATE_READABLE, price=178, id="tag_b"),
|
||||
|
|
@ -164,12 +169,12 @@ class ComplianceTests(unittest.TestCase):
|
|||
rules = [finding.rule for finding in findings]
|
||||
self.assertIn(RULE_MISSING_TAG, rules)
|
||||
missing = [finding for finding in findings if finding.rule == RULE_MISSING_TAG][0]
|
||||
self.assertEqual(missing.count, 1, "one finding per row, counting the facings")
|
||||
self.assertEqual(missing.count, 1, "one finding per row, counting the products")
|
||||
|
||||
def test_a_row_with_poor_tag_coverage_is_not_evaluated(self):
|
||||
"""The real failure mode: our detector found tags for only a few of many
|
||||
facings. Claiming the rest are missing would be a false violation."""
|
||||
tracks = [track(index, [10 + index * 100, 100, 90 + index * 100, 300]) for index in range(6)]
|
||||
tracks = [track(index, [10 + index * 300, 100, 90 + index * 300, 300]) for index in range(6)]
|
||||
tags = {index: tag(state=TAG_STATE_ABSENT) for index in range(6)}
|
||||
tags[0] = tag(state=TAG_STATE_READABLE, price=168, id="tag_a")
|
||||
findings = evaluate_compliance(tracks, tags, [tags[0]], BANDS)
|
||||
|
|
@ -201,9 +206,12 @@ class ComplianceTests(unittest.TestCase):
|
|||
self.assertEqual(findings, [])
|
||||
|
||||
def test_group_of_consecutive_facings_without_tags(self):
|
||||
# Coverage counts facings with *any* tag evidence, so the row needs enough
|
||||
# of it to be judged at all (bar is 70%).
|
||||
tracks = [track(index, [10 + index * 70, 100, 70 + index * 70, 300]) for index in range(10)]
|
||||
# Coverage counts *products* with any tag evidence, so the row needs enough
|
||||
# of it to be judged at all (bar is 70%: 7 of 10 products).
|
||||
tracks = [
|
||||
track(index, [10 + index * 300, 100, 70 + index * 300, 300], sku=f"sku_{index}")
|
||||
for index in range(10)
|
||||
]
|
||||
tags = {index: tag(state=TAG_STATE_READABLE, price=168 + index, id=f"tag_{index}") for index in range(10)}
|
||||
for absent in (2, 3, 4):
|
||||
tags[absent] = tag(state=TAG_STATE_ABSENT, id=None)
|
||||
|
|
@ -212,10 +220,30 @@ class ComplianceTests(unittest.TestCase):
|
|||
self.assertEqual(len(groups), 1)
|
||||
self.assertEqual(groups[0].count, 3)
|
||||
self.assertEqual(groups[0].row_index, 1)
|
||||
self.assertIn("3 facings", groups[0].detail)
|
||||
self.assertIn("3 products", groups[0].detail)
|
||||
|
||||
def test_two_missing_facings_are_not_a_group(self):
|
||||
tracks = [track(index, [10 + index * 150, 100, 140 + index * 150, 300]) for index in range(4)]
|
||||
def test_a_product_whose_tag_sits_elsewhere_in_the_row_is_not_missing(self):
|
||||
"""Measured false positive: a run of ピュアセレクト facings was split by
|
||||
geometry, and the piece was reported as missing the tag that was sitting
|
||||
further along its own run. Identity and the row's tags both guard it."""
|
||||
tracks = [
|
||||
track(0, [10, 100, 160, 300], sku="sku_a"),
|
||||
track(1, [170, 100, 320, 300], sku="sku_a"),
|
||||
track(2, [330, 100, 460, 300], sku="sku_a"),
|
||||
]
|
||||
tags = {
|
||||
0: tag(state=TAG_STATE_READABLE, price=209, id="tag_a"),
|
||||
1: tag(state=TAG_STATE_ABSENT),
|
||||
2: tag(state=TAG_STATE_ABSENT),
|
||||
}
|
||||
findings = evaluate_compliance(tracks, tags, [tags[0]], BANDS)
|
||||
self.assertEqual(findings, [], "the product's tag is on its own run")
|
||||
|
||||
def test_two_missing_products_are_not_a_group(self):
|
||||
tracks = [
|
||||
track(index, [10 + index * 300, 100, 140 + index * 300, 300], sku=f"sku_{index}")
|
||||
for index in range(4)
|
||||
]
|
||||
tags = {
|
||||
0: tag(state=TAG_STATE_READABLE, price=168, id="tag_a"),
|
||||
1: tag(state=TAG_STATE_ABSENT),
|
||||
|
|
@ -228,6 +256,21 @@ class ComplianceTests(unittest.TestCase):
|
|||
self.assertEqual(len(missing), 1)
|
||||
self.assertEqual(missing[0].count, 1)
|
||||
|
||||
def test_facings_of_one_product_share_a_tag(self):
|
||||
"""The measured domain rule: this store prints one tag per product, not per
|
||||
facing - a row carries ~16 facings but only ~4 tags. Counting facings would
|
||||
accuse a perfectly labelled shelf."""
|
||||
# Four facings of the same product (touching, same size) under one tag.
|
||||
tracks = [track(index, [10 + index * 80, 100, 90 + index * 80, 300]) for index in range(4)]
|
||||
tags = {
|
||||
0: tag(state=TAG_STATE_READABLE, price=269, id="tag_a"),
|
||||
1: tag(state=TAG_STATE_ABSENT),
|
||||
2: tag(state=TAG_STATE_ABSENT),
|
||||
3: tag(state=TAG_STATE_ABSENT),
|
||||
}
|
||||
findings = evaluate_compliance(tracks, tags, [tags[0]], BANDS)
|
||||
self.assertEqual(findings, [], "one tag covers the whole product: nothing is wrong")
|
||||
|
||||
def test_orphan_tag(self):
|
||||
tracks = [track(1, [10, 100, 90, 300])]
|
||||
tags = {1: tag(state=TAG_STATE_READABLE, price=168, id="tag_a")}
|
||||
|
|
@ -238,20 +281,35 @@ class ComplianceTests(unittest.TestCase):
|
|||
self.assertEqual(orphans[0].box, [300, 600, 400, 640])
|
||||
|
||||
def test_price_mismatch_becomes_a_finding(self):
|
||||
# A reportable mismatch carries an anchor: the tag's JAN was read, so we
|
||||
# know which tag we are comparing.
|
||||
tracks = [track(1, [10, 100, 90, 300])]
|
||||
tags = {1: tag(state=TAG_STATE_READABLE, price=269)}
|
||||
tags = {1: tag(state=TAG_STATE_READABLE, price=269, jan="357")}
|
||||
reconciliations = {1: reconcile(accepted(), tags[1], product(price=168.0))}
|
||||
findings = evaluate_compliance(tracks, tags, [tags[1]], BANDS, reconciliations)
|
||||
mismatches = [finding for finding in findings if finding.rule == RULE_PRICE_MISMATCH]
|
||||
self.assertEqual(len(mismatches), 1)
|
||||
self.assertIn("269", mismatches[0].detail)
|
||||
|
||||
def test_a_mismatch_without_an_anchor_is_only_a_hint(self):
|
||||
"""Measured noise: a wrong identity plus a garbled tag read (name match
|
||||
0.11, no JAN) produced a confident "tag says 90, catalog says 188" that was
|
||||
really two bad reads disagreeing. Without an anchor it is a hint."""
|
||||
tracks = [track(1, [10, 100, 90, 300])]
|
||||
tags = {1: tag(state=TAG_STATE_READABLE, price=90, name="ラスお要をおいしくだべおまにすくg)")}
|
||||
reconciliations = {1: reconcile(accepted(), tags[1], product(price=188.0))}
|
||||
findings = evaluate_compliance(tracks, tags, [tags[1]], BANDS, reconciliations)
|
||||
self.assertNotIn(RULE_PRICE_MISMATCH, [finding.rule for finding in findings])
|
||||
hints = [finding for finding in findings if finding.severity == "hint"]
|
||||
self.assertTrue(hints)
|
||||
self.assertEqual(summarize_findings(findings)["violations"], 0)
|
||||
|
||||
def test_summary_counts_only_violations(self):
|
||||
findings = [
|
||||
evaluate_compliance(
|
||||
[track(1, [10, 100, 90, 300])],
|
||||
{1: tag(state=TAG_STATE_READABLE, price=269)},
|
||||
[tag(state=TAG_STATE_READABLE, price=269)],
|
||||
{1: tag(state=TAG_STATE_READABLE, price=269, jan="357")},
|
||||
[tag(state=TAG_STATE_READABLE, price=269, jan="357")],
|
||||
BANDS,
|
||||
{1: reconcile(accepted(), tag(price=269), product(price=168.0))},
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue