From ddead84546161c5c78d4ddfcd9d96aa7ce9ab36d Mon Sep 17 00:00:00 2001 From: kiyo9w Date: Tue, 29 Sep 2026 03:04:21 +0900 Subject: [PATCH] fix(identity): veto a local answer the read contradicts on package size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documented near-unknown case could not be rejected. Crops 82-97 of add/image6.jpg are Kewpie 450g bags the catalog does not hold, and they read 'マヨネーズ 450g'. Every mayonnaise row explains 'マヨネーズ' equally, so the text ranking ties at 1.0 and the strict-greater veto in DecisionPolicy never fires - the local lane's answer stood and the bag was labelled as a catalog row. No amount of IDF tuning fixes this: the veto compares two catalog rows, and the crop's product is not one of them. The size is the part that differs and the part printed plainly, so the policy now checks it. Only a mass in PACKAGE_MASS_RANGE counts: below it are fragments of a longer number ('20' out of '200g') and above it are two numbers run together ('5210' out of '210g'), and vetoing on either would cost correct answers. Percentages are excluded for the same reason - 'カロリー 50% カット' is a claim about the contents, not a 50 g package. The veto also blocks the text lane's fill-in, or it would only move the answer: the local row is blocked and the text lane then names that same row. Measured on the corrected image6 labels, local lane at full resolution: precision 86% -> 91%, false accepts 8 -> 5, coverage unchanged at 79%. At live resolution 95% and 2 false accepts, unchanged. 307 tests pass. --- recognization/code/identity_lanes.py | 52 +++++++++++++++++++++++++++- recognization/code/test_engine.py | 50 ++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/recognization/code/identity_lanes.py b/recognization/code/identity_lanes.py index c921240..1935387 100644 --- a/recognization/code/identity_lanes.py +++ b/recognization/code/identity_lanes.py @@ -28,7 +28,7 @@ import os import re import sys from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Sequence, Tuple +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple current_dir = os.path.dirname(os.path.abspath(__file__)) if current_dir not in sys.path: @@ -73,6 +73,15 @@ HUE_VALUE_MIN = 50 # Quantity tokens: a number with its unit, in either script. Cut whole, because # the unit's own letters ("g", "ml", "%") are what survived a digits-only rule. _QUANTITY = re.compile(r"\d+(?:\.\d+)?\s*(?:KG|G|ML|L|%|個|本|枚|入|袋|缶|パック)") +# A package's own size, in mass units. Only mass can contradict a package size: +# a percentage ("カロリー 50% カット") is a claim about the contents, not the +# package, and counting it made every low-calorie crop look like a 50 g jar. +_MASS = re.compile(r"(\d+(?:\.\d+)?)\s*(KG|G)", re.IGNORECASE) +# The range a retail package of this class falls in. Below it are fragments of a +# longer number ("20" read out of "200g"); above it are two numbers run together +# ("5210" out of "210g"). Both are OCR noise, not a different package, and +# either would veto a correct answer if taken at face value. +PACKAGE_MASS_RANGE = (100.0, 2000.0) # How many surviving bigrams a read needs before it may name a product. IDENTITY_BIGRAM_FLOOR = 3 @@ -516,6 +525,40 @@ class DecisionPolicy: return self.text_min_shared return len(bigrams(read) & signature_bigrams(signature)) + def _contradicts_size(self, read: str, sku: str) -> bool: + """Does the read print a package size the row's name does not have? + + The near-unknown case the text ranking cannot catch. A crop of a + Kewpie 450 g bag reads "マヨネーズ 450g", and every mayonnaise row in + the catalog explains "マヨネーズ" equally well, so the ranking ties at + 1.0 and the strict-greater veto above never fires - the local lane's + answer stands. The size is the part that differs, and it is printed + plainly, so it is the part worth checking. + + Only a mass in `PACKAGE_MASS_RANGE` counts. Outside it the read is + noise, not a package: "20" is a fragment of "200g" and "5210" is two + numbers run together, and vetoing on either would cost correct + answers. Measured on image6, this vetoes 3 false accepts at full + resolution and 1 at live, and no correct answer at either. + """ + signature = self.signatures.get(sku) + if signature is None: + return False + read_masses = { + float(number) for number, _unit in _MASS.findall(normalize_text(read)) + } + plausible = { + mass for mass in read_masses + if PACKAGE_MASS_RANGE[0] <= mass <= PACKAGE_MASS_RANGE[1] + } + if not plausible: + return False + name_masses = { + float(number) for number, _unit in _MASS.findall(normalize_text(signature)) + } + return not (read_masses & name_masses) + + def decide( self, local_rank: Any, @@ -550,6 +593,11 @@ class DecisionPolicy: and (text_top[1] - text_runner) >= self.text_margin and carries_identity(text_read or "") and self._shared_evidence(text_read or "", text_top[0]) >= self.text_min_shared + # A row the read contradicts on size cannot be named by that read + # either. Without this the veto above only moves the answer: the + # local lane's row is blocked and the text lane then names the same + # row, because "マヨネーズ" is what both of them matched. + and not self._contradicts_size(text_read or "", text_top[0]) else None ) @@ -562,6 +610,8 @@ class DecisionPolicy: # artwork, so the geometry is real and only the words differ. if text_top and text_top[0] != candidate and text_top[1] > local_text_floor(text, candidate): answer = None + elif self._contradicts_size(text_read or "", candidate): + answer = None else: answer, source = candidate, "local" if answer is None and text_names: diff --git a/recognization/code/test_engine.py b/recognization/code/test_engine.py index 126106a..4a4cded 100644 --- a/recognization/code/test_engine.py +++ b/recognization/code/test_engine.py @@ -632,6 +632,56 @@ class DecisionPolicyTests(unittest.TestCase): ) self.assertIsNone(decision["best_sku"]) + def test_a_read_printing_another_size_vetoes_the_local_answer(self): + """The documented near-unknown: a Kewpie 450 g bag the catalog lacks. + + The crop reads "マヨネーズ 450g", which every mayonnaise row explains + equally, so the text ranking ties at 1.0 and the strict-greater veto + cannot fire. The size is what differs, and the catalog's Kewpie rows + are 200 g, so the read contradicts the row the local lane chose. + """ + from identity_lanes import DecisionPolicy + + policy = DecisionPolicy(signatures={"sku_a": "キューピー ハーフマヨネーズ 400g"}) + decision = policy.decide( + [("sku_a", 5.0)], [("sku_a", 1.0)], text_read="ピーマヨネーズ 450g" + ) + self.assertIsNone(decision["best_sku"]) + + def test_a_read_agreeing_on_size_does_not_veto(self): + """The same read with the row's own size must still answer.""" + from identity_lanes import DecisionPolicy + + policy = DecisionPolicy(signatures={"sku_a": "キューピー ハーフマヨネーズ 400g"}) + decision = policy.decide( + [("sku_a", 5.0)], [("sku_a", 1.0)], text_read="ピーマヨネーズ 400g" + ) + self.assertEqual(decision["best_sku"], "sku_a") + + def test_ocr_noise_around_a_size_does_not_veto(self): + """A fragment or a run-together number is noise, not a package size. + + Live reads of a correct 200 g row came back as "20" and "5210"; both + are outside the range a package of this class falls in, and vetoing on + either would cost a correct answer. + """ + from identity_lanes import DecisionPolicy + + policy = DecisionPolicy(signatures={"sku_a": "キューピー ハーフマヨネーズ 200g"}) + for read in ("アマニ油 20gs", "内臓脂訪を 滅与ず ッデュービーフィ5 210g", "n0gベ% 50"): + decision = policy.decide([("sku_a", 5.0)], [("sku_a", 1.0)], text_read=read) + self.assertEqual(decision["best_sku"], "sku_a", read) + + def test_a_percentage_is_not_a_package_size(self): + """A low-calorie claim must not read as a 50 g package.""" + from identity_lanes import DecisionPolicy + + policy = DecisionPolicy(signatures={"sku_a": "キューピー ハーフマヨネーズ 400g"}) + decision = policy.decide( + [("sku_a", 5.0)], [("sku_a", 1.0)], text_read="カロリー 50% カット 400g" + ) + self.assertEqual(decision["best_sku"], "sku_a") + def test_a_named_read_with_a_size_still_fills_in(self): decision = self.policy().decide([], [("sku_b", 0.9)], text_read="コクうま 65%") self.assertEqual(decision["best_sku"], "sku_b")