From 99bce4d7bfe84bc0569e09f6aa3357aac9131ff0 Mon Sep 17 00:00:00 2001 From: kiyo9w Date: Fri, 25 Sep 2026 01:30:03 +0900 Subject: [PATCH] fix: a tag may not accuse on weak evidence, nor name a distant product Three findings from the deep review, all about the tag channel claiming more than it can support. 1. The exact signal is a barcode. The reconciliation compared `tag.jan` against the catalog's JAN, but that field held the regular price - a number compared against a product code. It now reads a `barcode` field (populated only by a close-up decode) and the printed number is compared as a price. 2. A price-mismatch violation required "an anchor", which was `tag.jan` - a price. The anchor is now the tag's own arithmetic agreeing, or a name that actually resembles the product, and the name signal carries its score as a field instead of being parsed back out of a human-readable note string. 3. Tag identity could reach anywhere along a row: a facing whose own tag was not detected inherited whichever readable tag was nearest, which can be a different product several columns away. Assignment is now capped at the product's own run (2.5 facing widths, at least 200 px), and a tag may only overrule the packaging channel when it is corroborated or its name matches at 0.6 rather than 0.4. 162 tests pass. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- recognization/code/reconcile.py | 70 ++++++++++++++++++++----- recognization/code/test_reconcile.py | 19 ++++--- recognization/code/test_tag_identity.py | 18 ++++++- 3 files changed, 86 insertions(+), 21 deletions(-) diff --git a/recognization/code/reconcile.py b/recognization/code/reconcile.py index 49986d9..ef3c414 100644 --- a/recognization/code/reconcile.py +++ b/recognization/code/reconcile.py @@ -34,6 +34,15 @@ RECONCILED_TAG_ONLY = "tag_only" RULE_MISSING_TAG = "missing_price_tag" RULE_MISSING_TAG_GROUP = "missing_tag_group" + +# How far along a row a tag may name facings: enough to cover the product's own run +# of facings (measured: up to four facings, 70-120 px each), not enough to reach +# across the shelf. The cap exists because a product whose own tag was not detected +# would otherwise inherit whichever readable tag happens to be nearest. +TAG_REACH_FACINGS = 2.5 +TAG_REACH_PX = 200 +# The name similarity a tag needs before it may overrule the packaging channel. +TAG_OVERRIDE_SIMILARITY = 0.6 RULE_ORPHAN_TAG = "orphan_tag" RULE_PRICE_MISMATCH = "price_mismatch" RULE_DUPLICATE_TAG = "duplicate_tag" @@ -81,16 +90,19 @@ def longest_common_substring_ratio(a: str, b: str) -> float: @dataclass class Signal: - kind: str # "jan" | "name" | "price" + kind: str # "barcode" | "name" | "price" agrees: bool item: Any tag: Any note: Optional[str] = None + score: Optional[float] = None def as_dict(self) -> Dict[str, Any]: payload = {"kind": self.kind, "agrees": self.agrees, "item": self.item, "tag": self.tag} if self.note: payload["note"] = self.note + if self.score is not None: + payload["score"] = round(float(self.score), 3) return payload @@ -125,11 +137,14 @@ def reconcile( signals: List[Signal] = [] - # JAN is the only exact signal; a checksummed barcode is not a guess. - item_jan = str((product or {}).get("jan") or "").strip() - tag_jan = str((tag or {}).get("jan") or "").strip() - if item_jan and tag_jan: - signals.append(Signal("jan", item_jan == tag_jan, item_jan, tag_jan)) + # A barcode is the only exact signal; a checksummed code is not a guess. The + # number a tag prints large beside the sale price is NOT a barcode - it is the + # regular price (verified against the manufacturer: 4901577092381 for a tag + # printing 357), so it is compared as a price below. + item_barcode = str((product or {}).get("jan") or "").strip() + tag_barcode = str((tag or {}).get("barcode") or "").strip() + if item_barcode and tag_barcode: + signals.append(Signal("barcode", item_barcode == tag_barcode, item_barcode, tag_barcode)) item_name = normalize_name((product or {}).get("product_name")) tag_name = normalize_name((tag or {}).get("name")) @@ -137,7 +152,7 @@ def reconcile( ratio = longest_common_substring_ratio(item_name, tag_name) signals.append( Signal("name", ratio >= NAME_MATCH_THRESHOLD, item_name[:32], tag_name[:32], - note=f"match {ratio:.2f}") + note=f"match {ratio:.2f}", score=ratio) ) item_price = (product or {}).get("price") @@ -155,8 +170,8 @@ def reconcile( "message": "tag read, but nothing comparable in the catalog", } - if any(signal.kind == "jan" for signal in signals): - decided = [signal for signal in signals if signal.kind == "jan"][0] + if any(signal.kind == "barcode" for signal in signals): + decided = [signal for signal in signals if signal.kind == "barcode"][0] state = RECONCILED_CONFIRMED if decided.agrees else RECONCILED_MISMATCH elif any(signal.agrees for signal in signals): state = RECONCILED_CONFIRMED @@ -479,9 +494,12 @@ def evaluate_compliance( name_signal = next( (signal for signal in signals if signal.get("kind") == "name"), None ) - has_anchor = bool(tag_for_track.get("jan")) or bool( - name_signal and float(name_signal.get("note", "0").split()[-1] or 0) >= 0.35 - ) + # The anchor is the tag's own arithmetic agreeing, or a name that actually + # resembles the product. (It used to be `tag.jan`, which held a *price*.) + flags = set(tag_for_track.get("flags") or []) + corroborated = bool({"tax_consistent", "discount_consistent"} & flags) + name_score = float(name_signal.get("score") or 0.0) if name_signal else 0.0 + has_anchor = corroborated or name_score >= 0.35 if not has_anchor: findings.append( Finding( @@ -663,8 +681,20 @@ def identities_from_tags( for track in row: box = track.get("box") or track.get("raw_box") center = (box[0] + box[2]) / 2.0 + facing_width = max(1, box[2] - box[0]) + # A tag names the product at its own position on the rail. Without a + # distance cap a facing whose own tag was not detected inherits the + # nearest readable tag's product, which can belong to a different + # product several columns away. + limit = max(TAG_REACH_FACINGS * facing_width, TAG_REACH_PX) + candidates = [ + tag for tag in row_tags + if abs(center - (tag["box"][0] + tag["box"][2]) / 2.0) <= limit + ] + if not candidates: + continue nearest = min( - row_tags, + candidates, key=lambda tag: abs(center - (tag["box"][0] + tag["box"][2]) / 2.0), ) sku_id = nearest.get("sku_from_name") @@ -737,6 +767,20 @@ def attach_tags_to_boxes( current = track["identity"] or {} if current.get("state") == "recognized": if current.get("sku_id") != derived["sku_id"]: + # Only a corroborated tag, or a name that closely resembles the + # product, may overrule the packaging channel. A weak 0.40 name + # match used to be enough, which is not evidence. + tag_flags = set((tag_by_track.get(int(track["track_id"])) or {}).get("flags") or []) + strong = bool({"tax_consistent", "discount_consistent"} & tag_flags) or \ + float(derived.get("score") or 0.0) >= TAG_OVERRIDE_SIMILARITY + if not strong: + track["identity_conflict"] = { + "packaging": current.get("sku_id"), + "tag": derived["sku_id"], + "packaging_score": current.get("score"), + "note": "tag name match too weak to overrule packaging", + } + continue # Two channels disagree. Measured on the demo shelf: where both # named the same facing they agreed only 69% of the time, and the # packaging side of every disagreement carried a low score - so diff --git a/recognization/code/test_reconcile.py b/recognization/code/test_reconcile.py index a53ea79..6ca84bf 100644 --- a/recognization/code/test_reconcile.py +++ b/recognization/code/test_reconcile.py @@ -50,6 +50,8 @@ def tag(state=TAG_STATE_READABLE, **overrides): "price": None, "regular_price": None, "name": None, + "barcode": None, + "flags": [], "confidence": 0.8, "observations": 3, "settled": True, @@ -96,12 +98,14 @@ class ReconcileTests(unittest.TestCase): result = reconcile(accepted(), tag(name="Tropicana エッセンシャルズ"), product(price=None, name="Tropicana エッセンシャルズ マルチビタミン")) self.assertEqual(result["state"], RECONCILED_CONFIRMED) - def test_jan_disagreement_is_a_mismatch(self): - result = reconcile(accepted(), tag(price=168, jan="4902105000001"), product(price=168.0, jan="4902105000002")) + def test_barcode_disagreement_is_a_mismatch(self): + result = reconcile(accepted(), tag(price=168, barcode="4902105000001"), + product(price=168.0, jan="4902105000002")) self.assertEqual(result["state"], RECONCILED_MISMATCH) - def test_jan_agreement_confirms(self): - result = reconcile(accepted(), tag(price=None, jan="4902105000001"), product(price=None, jan="4902105000001")) + def test_barcode_agreement_confirms(self): + result = reconcile(accepted(), tag(price=None, barcode="4902105000001"), + product(price=None, jan="4902105000001")) self.assertEqual(result["state"], RECONCILED_CONFIRMED) def test_tag_read_but_nothing_comparable(self): @@ -284,7 +288,8 @@ class ComplianceTests(unittest.TestCase): # 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, jan="357")} + # The anchor is the tag's own arithmetic agreeing, not a price field. + tags = {1: tag(state=TAG_STATE_READABLE, price=269, flags=["tax_consistent"])} 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] @@ -308,8 +313,8 @@ class ComplianceTests(unittest.TestCase): findings = [ evaluate_compliance( [track(1, [10, 100, 90, 300])], - {1: tag(state=TAG_STATE_READABLE, price=269, jan="357")}, - [tag(state=TAG_STATE_READABLE, price=269, jan="357")], + {1: tag(state=TAG_STATE_READABLE, price=269, flags=["tax_consistent"])}, + [tag(state=TAG_STATE_READABLE, price=269, flags=["tax_consistent"])], BANDS, {1: reconcile(accepted(), tag(price=269), product(price=168.0))}, ) diff --git a/recognization/code/test_tag_identity.py b/recognization/code/test_tag_identity.py index 69bdd21..e98cfb1 100644 --- a/recognization/code/test_tag_identity.py +++ b/recognization/code/test_tag_identity.py @@ -41,7 +41,8 @@ def facing(track_id, box, sku=None): def tag(box, name=None, state=TAG_STATE_READABLE): - return {"state": state, "box": box, "name": name, "jan": None, "price": None} + return {"state": state, "box": box, "name": name, "regular_price": None, "price": None, + "barcode": None, "flags": []} class NameMatchTests(unittest.TestCase): @@ -71,6 +72,21 @@ class AssignmentTests(unittest.TestCase): self.assertEqual(len(found), 4, "one tag covers the whole run of facings") self.assertTrue(all(info["sku_id"] == "sku_l05" for info in found.values())) + def test_a_distant_facing_does_not_inherit_a_tag(self): + """A product whose own tag was not detected must not take its neighbour's. + + Without a reach limit the nearest readable tag names it, which is how a + facing several columns away can acquire the wrong product. + """ + tracks = [ + facing(0, [10, 100, 100, 300]), + facing(1, [1600, 100, 1690, 300]), # far along the same row + ] + tags = [tag([20, 320, 180, 380], name="キューピー ゼロ ノンコレステロール 310g")] + found = identities_from_tags(tracks, {}, CATALOG, all_tags=tags) + self.assertIn(0, found) + self.assertNotIn(1, found, "out of reach of that tag") + def test_the_nearest_tag_wins_along_a_row(self): # Two products side by side, one tag each. tracks = [