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."""