fix(identity): veto a local answer the read contradicts on package size

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.
This commit is contained in:
kiyo9w 2026-09-29 03:04:21 +09:00
parent aa838f7b2b
commit ddead84546
2 changed files with 101 additions and 1 deletions

View file

@ -28,7 +28,7 @@ import os
import re import re
import sys import sys
from dataclasses import dataclass 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__)) current_dir = os.path.dirname(os.path.abspath(__file__))
if current_dir not in sys.path: 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 # 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. # 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|%|個|本|枚|入|袋|缶|パック)") _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. # How many surviving bigrams a read needs before it may name a product.
IDENTITY_BIGRAM_FLOOR = 3 IDENTITY_BIGRAM_FLOOR = 3
@ -516,6 +525,40 @@ class DecisionPolicy:
return self.text_min_shared return self.text_min_shared
return len(bigrams(read) & signature_bigrams(signature)) 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( def decide(
self, self,
local_rank: Any, local_rank: Any,
@ -550,6 +593,11 @@ class DecisionPolicy:
and (text_top[1] - text_runner) >= self.text_margin and (text_top[1] - text_runner) >= self.text_margin
and carries_identity(text_read or "") and carries_identity(text_read or "")
and self._shared_evidence(text_read or "", text_top[0]) >= self.text_min_shared 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 else None
) )
@ -562,6 +610,8 @@ class DecisionPolicy:
# artwork, so the geometry is real and only the words differ. # 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): if text_top and text_top[0] != candidate and text_top[1] > local_text_floor(text, candidate):
answer = None answer = None
elif self._contradicts_size(text_read or "", candidate):
answer = None
else: else:
answer, source = candidate, "local" answer, source = candidate, "local"
if answer is None and text_names: if answer is None and text_names:

View file

@ -632,6 +632,56 @@ class DecisionPolicyTests(unittest.TestCase):
) )
self.assertIsNone(decision["best_sku"]) 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): def test_a_named_read_with_a_size_still_fills_in(self):
decision = self.policy().decide([], [("sku_b", 0.9)], text_read="コクうま 65%") decision = self.policy().decide([], [("sku_b", 0.9)], text_read="コクうま 65%")
self.assertEqual(decision["best_sku"], "sku_b") self.assertEqual(decision["best_sku"], "sku_b")