Score package text against the row's name, not its packshot's OCR

The text lane built each row's signature from its printed name plus the OCR of
its enrolled packshot, and scored a crop against that whole string. l09's
packshot prints "マヨネーズ", which is l10's actual name, so l09 could explain
an l10 crop's text better than l10's own name could. Every l10 facing reads
"ピュア セレクト マヨネーズ" with no "コクうま" and no "65", and l09 still won
or tied them.

The signature is now the row's name alone, weighted by inverse document
frequency over the catalog so copy every sibling prints counts for little and
the words that name one product dominate. The packshot OCR is gone: every row
has a name, so it added nothing but the confusion.

Measured on image6, local lane at live resolution:

  l10 correct    2/11 -> 5/11
  wrong          3 -> 1
  precision      95% -> 97%
  l09 hub share  34% (alarm) -> 29% (clear)

Full resolution end-to-end 59% at 88% precision, live 56% at 97%.
This commit is contained in:
tngo 2026-09-29 00:37:56 +09:00
parent 8ab17ccd5d
commit b6c7c4b667
2 changed files with 108 additions and 25 deletions

View file

@ -8,9 +8,8 @@ evidence into accepted / ambiguous / unknown / rejected:
ranks fine-grained siblings correctly: 40/42 enrolled facings first at full ranks fine-grained siblings correctly: 40/42 enrolled facings first at full
resolution, against 32/42 for Van's composite. resolution, against 32/42 for Van's composite.
- `TextLane` reads the package text and compares it to each catalog row's - `TextLane` reads the package text and compares it to each catalog row's
signature (its printed name plus the OCR of its enrolled packshot). It is the printed name. It is the only measured mechanism that rejects a near-unknown
only measured mechanism that rejects a near-unknown sibling, and it can name sibling, and it can name catalog rows that have no enrolled image at all.
catalog rows that have no enrolled image at all.
Why a policy and not a threshold: the matcher's raw score is not comparable Why a policy and not a threshold: the matcher's raw score is not comparable
across references of different resolution, and a single unverified match used across references of different resolution, and a single unverified match used
@ -24,6 +23,7 @@ calibrated model. E-4 replaces it once Tier-2 held-out labels exist.
from __future__ import annotations from __future__ import annotations
import math
import os import os
import re import re
import sys import sys
@ -303,34 +303,75 @@ def carries_identity(read: str) -> bool:
return len(identity_bigrams(read)) >= IDENTITY_BIGRAM_FLOOR return len(identity_bigrams(read)) >= IDENTITY_BIGRAM_FLOOR
def text_similarity(read: str, signature: str) -> float: def text_similarity(read: str, signature: str, weights: Optional[Dict[str, float]] = None) -> float:
"""Share of the read text's bigrams that the signature explains. """Share of the read text's bigrams that the signature explains.
Asymmetric on purpose: the crop shows a few words of a package, the Asymmetric on purpose: the crop shows a few words of a package, the
signature holds the whole name, so the question is how much of what was signature holds the whole name, so the question is how much of what was
read the catalog row accounts for. read the catalog row accounts for.
`weights` is the inverse document frequency of each bigram over the
catalog's signatures. Without it, brand copy that every sibling prints
("ピュアセレクト", "マヨネーズ", "国産新鮮たまご") counts as much as the words
that tell the products apart ("コクうま", "カロリーカット", "65"), so a row
whose signature happens to carry more shared copy outranks the row the
crop actually shows. Measured on image6: every l10 facing reads
"ピュア セレクト マヨネーズ" with no "コクうま" and no "65", yet l09 tied or
won on shared brand text alone.
""" """
read_grams = bigrams(normalize_text(read)) read_grams = bigrams(normalize_text(read))
if not read_grams: if not read_grams:
return 0.0 return 0.0
return len(read_grams & bigrams(normalize_text(signature))) / max(1, len(read_grams)) shared = read_grams & bigrams(normalize_text(signature))
if not shared:
return 0.0
if weights is None:
return len(shared) / max(1, len(read_grams))
total = sum(weights.get(gram, 1.0) for gram in read_grams)
if total <= 0:
return 0.0
return sum(weights.get(gram, 1.0) for gram in shared) / total
def bigram_weights(signatures: Sequence[str]) -> Dict[str, float]:
"""Inverse document frequency of each bigram over the catalog signatures.
A bigram every row prints carries no information about which row this is,
so it is worth ~0; a bigram one row prints is worth the most. Smoothed and
floored at 0.05 so a shared word still counts a little and a read made
entirely of shared copy scores low rather than dividing by zero.
"""
counts: Dict[str, int] = {}
for signature in signatures:
for gram in set(bigrams(normalize_text(signature))):
counts[gram] = counts.get(gram, 0) + 1
total = max(1, len(signatures))
return {
gram: max(0.05, math.log((total + 1) / (count + 1)))
for gram, count in counts.items()
}
class TextLane: class TextLane:
"""Package text against each catalog row's signature. """Package text against each catalog row's printed name.
The signature is the row's printed name plus the OCR of its enrolled The signature is the row's name, so it is built from the catalog and never
packshot, so it is built from the catalog and never from the evaluation from the evaluation photo. Rows with no image still get a signature, which
photo. Rows with no image still get a signature from their name, which is is how the lane can name a SKU no matcher can see.
how the lane can name a SKU no matcher can see.
""" """
name = "text" name = "text"
def __init__( def __init__(
self, signatures: Dict[str, str], ocr_engine: Any = None, lock: Any = None self,
signatures: Dict[str, str],
ocr_engine: Any = None,
lock: Any = None,
) -> None: ) -> None:
self.signatures = {str(sku): text for sku, text in signatures.items()} self.signatures = {str(sku): text for sku, text in signatures.items()}
# Bigram weights over the catalog's own names, so the lane can tell
# copy every sibling prints from the words that name one product.
self.weights = bigram_weights(list(self.signatures.values()))
self.ocr_engine = ocr_engine self.ocr_engine = ocr_engine
# The engine's OCR lock. Vision tolerates concurrency but the CPU does # The engine's OCR lock. Vision tolerates concurrency but the CPU does
# not, and this lane runs one OCR per crop on the classify pool, so it # not, and this lane runs one OCR per crop on the classify pool, so it
@ -350,19 +391,7 @@ class TextLane:
sku = str(row.get("sku_id") or "") sku = str(row.get("sku_id") or "")
if not sku: if not sku:
continue continue
signature = str(row.get("product_name") or "") signatures[sku] = str(row.get("product_name") or "")
if ocr_engine is not None and getattr(ocr_engine, "available", lambda: False)():
path = os.path.join(stock_dir, f"{sku}.jpg")
if os.path.exists(path):
image = cv2.imread(path)
if image is not None:
try:
result = ocr_engine.read(image)
except Exception:
result = None
if result is not None:
signature += " " + " ".join(line.text for line in result.lines)
signatures[sku] = signature
return cls(signatures, ocr_engine=ocr_engine, lock=lock) return cls(signatures, ocr_engine=ocr_engine, lock=lock)
def read(self, crop: Any) -> str: def read(self, crop: Any) -> str:
@ -393,10 +422,19 @@ class TextLane:
return self.scores_from_text(self.read(crop)) return self.scores_from_text(self.read(crop))
def scores_from_text(self, read: str) -> Dict[str, float]: def scores_from_text(self, read: str) -> Dict[str, float]:
"""Bigram similarity per catalog row, against the row's name.
The name only. Scoring against the packshot's own OCR as well is how
l09 used to win l10's facings: l09's packshot prints "マヨネーズ",
which is l10's actual name, so a row with a photographed packshot
could explain a sibling's text better than the sibling's own name
could. Every row in the catalog has a name, so the packshot adds
nothing but that confusion.
"""
if len(normalize_text(read)) < 4: if len(normalize_text(read)) < 4:
return {} return {}
return { return {
sku: text_similarity(read, signature) sku: text_similarity(read, signature, self.weights)
for sku, signature in self.signatures.items() for sku, signature in self.signatures.items()
} }

View file

@ -30,6 +30,51 @@ from engine import ( # noqa: E402
) )
class TextLaneSignatureTests(unittest.TestCase):
"""The lane must score a crop against the row's name, not its packshot.
l09's packshot prints "マヨネーズ", which is l10's actual name. Scoring the
packshot's OCR as part of l09's signature let l09 explain an l10 crop's
text better than l10's own name could, so l09 won l10's facings. Measured
on image6: l10 correct went 2 -> 5 of 11, wrong 3 -> 1, precision 95% ->
97%, and the l09 hub alarm cleared.
"""
ROWS = [
{"sku_id": "sku_l09", "product_name": "ピュアセレクト コクうま カロリーカット65% 360g"},
{"sku_id": "sku_l10", "product_name": "ピュアセレクト マヨネーズ 450g"},
{"sku_id": "sku_l03", "product_name": "キューピー からしマヨネーズ 200g"},
]
def lane(self):
from identity_lanes import TextLane
return TextLane.from_catalog(self.ROWS, "no-such-dir", ocr_engine=None)
def test_a_read_of_the_plain_mayonnaise_names_the_plain_mayonnaise(self):
scores = self.lane().scores_from_text(
"AJINOMOTO とれて3日以内の 国産新鮮たまご ピュア セレクト マヨネーズ"
)
self.assertEqual(max(scores, key=scores.get), "sku_l10")
def test_a_read_of_the_variant_names_the_variant(self):
scores = self.lane().scores_from_text("ピュアセレクト コクうま カロリーカット65%")
self.assertEqual(max(scores, key=scores.get), "sku_l09")
def test_shared_brand_copy_alone_does_not_name_a_row(self):
"""Every row prints マヨネーズ, so it must not decide between them.
The lane scores l10 and l03 equally on that word alone; the policy's
margin gate is what turns the tie into no answer.
"""
from identity_lanes import DecisionPolicy
scores = self.lane().scores_from_text("マヨネーズ")
self.assertEqual(scores["sku_l10"], scores["sku_l03"])
decision = DecisionPolicy().decide([], scores, text_read="マヨネーズ")
self.assertIsNone(decision["best_sku"])
class FakeDetector: class FakeDetector:
"""Records concurrency and returns fixed boxes.""" """Records concurrency and returns fixed boxes."""