From b6c7c4b667781bc883c77d7eddd4c607282ac8b3 Mon Sep 17 00:00:00 2001 From: tngo Date: Tue, 29 Sep 2026 00:37:56 +0900 Subject: [PATCH] Score package text against the row's name, not its packshot's OCR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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%. --- recognization/code/identity_lanes.py | 88 ++++++++++++++++++++-------- recognization/code/test_engine.py | 45 ++++++++++++++ 2 files changed, 108 insertions(+), 25 deletions(-) diff --git a/recognization/code/identity_lanes.py b/recognization/code/identity_lanes.py index ea6f231..b6af8b3 100644 --- a/recognization/code/identity_lanes.py +++ b/recognization/code/identity_lanes.py @@ -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() } diff --git a/recognization/code/test_engine.py b/recognization/code/test_engine.py index f814299..7bafafa 100644 --- a/recognization/code/test_engine.py +++ b/recognization/code/test_engine.py @@ -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."""