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")