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:
parent
aa838f7b2b
commit
ddead84546
2 changed files with 101 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue