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
resolution, against 32/42 for Van's composite.
- `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
only measured mechanism that rejects a near-unknown sibling, and it can name
catalog rows that have no enrolled image at all.
printed name. It is the only measured mechanism that rejects a near-unknown
sibling, and it can name catalog rows that have no enrolled image at all.
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
@ -24,6 +23,7 @@ calibrated model. E-4 replaces it once Tier-2 held-out labels exist.
from __future__ import annotations
import math
import os
import re
import sys
@ -303,34 +303,75 @@ def carries_identity(read: str) -> bool:
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.
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
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))
if not read_grams:
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:
"""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
packshot, so it is built from the catalog and never from the evaluation
photo. Rows with no image still get a signature from their name, which is
how the lane can name a SKU no matcher can see.
The signature is the row's name, so it is built from the catalog and never
from the evaluation photo. Rows with no image still get a signature, which
is how the lane can name a SKU no matcher can see.
"""
name = "text"
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:
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
# 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
@ -350,19 +391,7 @@ class TextLane:
sku = str(row.get("sku_id") or "")
if not sku:
continue
signature = 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
signatures[sku] = str(row.get("product_name") or "")
return cls(signatures, ocr_engine=ocr_engine, lock=lock)
def read(self, crop: Any) -> str:
@ -393,10 +422,19 @@ class TextLane:
return self.scores_from_text(self.read(crop))
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:
return {}
return {
sku: text_similarity(read, signature)
sku: text_similarity(read, signature, self.weights)
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:
"""Records concurrency and returns fixed boxes."""