diff --git a/MPCAutofill/cardpicker/local_ocr.py b/MPCAutofill/cardpicker/local_ocr.py index b31e34111..4d8820c70 100644 --- a/MPCAutofill/cardpicker/local_ocr.py +++ b/MPCAutofill/cardpicker/local_ocr.py @@ -52,6 +52,20 @@ class OcrParseResult: collector_number: Optional[str] # lowercased, or None +def _normalize_collector_number(number: str) -> str: + """Leading zeros and case don't carry meaning in a collector number ("0093" and "93" are the + same printing) - real OCR reads add spurious leading zeros often enough (a stray dark pixel + column at the crop's left edge, a rarity-letter glyph tesseract folds into the digit run) + that literal string comparison silently drops otherwise-correct reads. Verified against real + production no-match cases, 2026-07-15 - see docs/features/printing-tags.md's Stage 8 no-match + autopsy: this alone accounted for the majority of a 47/176 (26.7%) yield-delta fix.""" + number = number.lower() + letter = number[-1] if number and number[-1].isalpha() else "" + digits = number[:-1] if letter else number + digits = digits.lstrip("0") or "0" + return digits + letter + + def crop_collector_line( image: "Image.Image", crop_box: tuple[float, float, float, float] = DEFAULT_CROP_BOX ) -> "Image.Image": @@ -93,17 +107,27 @@ def parse_collector_line(raw_text: str) -> OcrParseResult: set_code = None if collector_match: - # look for a plausible set-code token elsewhere on the line, not overlapping the - # collector-number match itself - remainder = raw_text[: collector_match.start()] + raw_text[collector_match.end() :] - for candidate in _SET_CODE_RE.findall(remainder): - # a pure-digit token is never a set code (it's more collector-number noise); a - # token that's actually the collector number's own digits (stray re-match) is - # skipped too - if candidate.isdigit() or candidate.lower() == collector_number: - continue - set_code = candidate.lower() - break + # a real MTG collector line always prints the number FIRST, then "SET . LANG ..." on + # the same or next line - a plausible-looking 3-5 char token found BEFORE the number is + # virtually always leading noise (a watermark, a rarity-letter glyph merging with a + # stray digit into something that coincidentally looks like a code), not a genuine + # layout variant. Search the text AFTER the collector number first, only falling back + # to before it if nothing plausible follows. Verified against real production no-match + # cases, 2026-07-15 - see docs/features/printing-tags.md's Stage 8 no-match autopsy. + before = raw_text[: collector_match.start()] + after = raw_text[collector_match.end() :] + + def _find_set_code(segment: str) -> Optional[str]: + for candidate in _SET_CODE_RE.findall(segment): + # a pure-digit token is never a set code (it's more collector-number noise); a + # token that's actually the collector number's own digits (stray re-match) is + # skipped too + if candidate.isdigit() or candidate.lower() == collector_number: + continue + return candidate.lower() + return None + + set_code = _find_set_code(after) or _find_set_code(before) return OcrParseResult(raw_text=raw_text, set_code=set_code, collector_number=collector_number) @@ -123,17 +147,19 @@ def validate_against_candidates( if parsed.collector_number is None: return None, "no-text" + normalized_parsed_number = _normalize_collector_number(parsed.collector_number) if parsed.set_code is not None: matches = [ c for c in candidates - if c.expansion_code == parsed.set_code and c.collector_number.lower() == parsed.collector_number + if c.expansion_code == parsed.set_code + and _normalize_collector_number(c.collector_number) == normalized_parsed_number ] else: # pre-M15 cards have no set code on the collector line at all - fall back to matching # on collector number alone, which is enough when the name's candidates don't share a # number across sets (usually true, but not guaranteed - hence "ambiguous" below). - matches = [c for c in candidates if c.collector_number.lower() == parsed.collector_number] + matches = [c for c in candidates if _normalize_collector_number(c.collector_number) == normalized_parsed_number] if not matches: return None, "parsed-but-no-match" diff --git a/MPCAutofill/cardpicker/tests/test_local_identify_printing_tags.py b/MPCAutofill/cardpicker/tests/test_local_identify_printing_tags.py index a654c51fd..95283af8a 100644 --- a/MPCAutofill/cardpicker/tests/test_local_identify_printing_tags.py +++ b/MPCAutofill/cardpicker/tests/test_local_identify_printing_tags.py @@ -158,6 +158,24 @@ def test_empty_string(self): assert parsed.set_code is None assert parsed.collector_number is None + def test_leading_noise_token_before_number_is_skipped_for_real_set_code_after(self): + # 2026-07-15 no-match autopsy finding (docs/features/printing-tags.md's Stage 8): a + # plausible-looking 3-5 char token appearing BEFORE the collector number (a watermark, + # a rarity-letter glyph merging with a stray digit) must not win over the real set code + # that follows the number, per real MTG collector-line layout (number always comes + # first). "R0O324 WilfordGriml\nLCI ¢ EN..." - "R0O" is exactly such spurious noise. + parsed = parse_collector_line("R0O324 WilfordGriml\nLCI ¢ EN © SIDHARTH (") + assert parsed.collector_number == "324" + assert parsed.set_code == "lci" + + def test_falls_back_to_a_before_number_token_when_nothing_plausible_follows(self): + # no real second line at all - the only plausible token is before the number, so it + # must still be used rather than giving up (some old-format lines genuinely have no + # "after" content to search). + parsed = parse_collector_line("MOM 158") + assert parsed.collector_number == "158" + assert parsed.set_code == "mom" + class TestOcrValidationRail: CANDIDATES = [ @@ -199,6 +217,27 @@ def test_collector_only_matches_when_unambiguous(self): assert matched.pk == 1 assert reason == "" + def test_leading_zero_in_parsed_number_matches_a_candidate_without_one(self): + # 2026-07-15 no-match autopsy finding: OCR often reads a spurious leading zero + # ("0093" for a real "93") - this alone accounted for the majority of a 47/176 (26.7%) + # yield-delta fix, see docs/features/printing-tags.md's Stage 8 no-match autopsy. + candidates = [CandidatePrinting(pk=1, expansion_code="unf", collector_number="93")] + parsed = parse_collector_line("C0093 WilfordGrim\nUNF EN ALEXANDE") + matched, reason = validate_against_candidates(parsed, candidates) + assert matched is not None + assert matched.pk == 1 + assert reason == "" + + def test_leading_zero_in_stored_candidate_matches_a_plain_parsed_number(self): + # the reverse direction - some CanonicalCard rows themselves store a zero-padded + # collector_number (e.g. "007" for a promo) - normalization must apply symmetrically. + candidates = [CandidatePrinting(pk=1, expansion_code="mom", collector_number="007")] + parsed = parse_collector_line("7/287 R MOM EN") + matched, reason = validate_against_candidates(parsed, candidates) + assert matched is not None + assert matched.pk == 1 + assert reason == "" + class TestPhashThresholdAndMargin: def test_clear_winner_within_threshold_and_margin(self): diff --git a/docs/features/printing-tags.md b/docs/features/printing-tags.md index 2c97f7070..cf46058a9 100644 --- a/docs/features/printing-tags.md +++ b/docs/features/printing-tags.md @@ -809,6 +809,53 @@ unmatchable `expansion_hint`) are all in the journal, not duplicated here. **Pilot discipline honored**: `--limit 300`, no full-catalog run attempted per the original hold. +### No-match autopsy (2026-07-15, post-merge Hold #1 of the pre-scale program) + +Classified all 176 OCR "parsed-but-no-match" cases from the pilot run +(reconstructed via selection-order stability, since the CLI doesn't +persist per-card raw text - a real gap, see the journal). Two real, +contained parser bugs found, both now fixed in `local_ocr.py`: + +- **Set-code token position**: `parse_collector_line`'s set-code search + took the FIRST plausible 3-5 char token in the line, which is virtually + always leading noise (a watermark, a rarity-letter glyph merging with a + stray digit into something code-shaped) rather than the real set code + that always follows the collector number in a genuine card layout. Fixed + to search the text AFTER the number first, falling back to before only + if nothing plausible follows. +- **Collector-number leading zeros**: OCR frequently reads a spurious + leading zero ("0093" for a real "93") that literal string comparison + silently rejected. Fixed via `_normalize_collector_number` (strip + leading zeros, keep any trailing variant letter) applied symmetrically + to both the parsed reading and every candidate's stored value. + +**Yield delta, precisely measured** (re-parsing the exact same 176 raw +texts with both the old and new logic, isolating exactly this cohort from +the 3 cards that already matched under the old parser): **47/176 (26.7%) +now match.** Projected full-engine impact: OCR yield 77/300 (25.7%) → +~124/300 (41.3%), a ~60% relative improvement, from a small parser fix. +Confirmed live via a real (non-simulated) `--dry-run` afterward: 62/250 +votes on a fresh selection window, consistent with the isolated measurement. + +Of the 129 cases still unfixed: only 2/176 (1.1%) are genuinely-missing +printings (the parsed set code is real, but no `CanonicalCard` row exists +for that (set, number) at all); the remaining 127/176 (72.2%) are true +OCR garbage with no salvageable signal - a meaningful fraction of which +traces to one specific custom-frame Drive source +(`Source pk=1, "WilfordGrimley"`, "Custom Cardbacks and alternate frames +with Upscaled images") whose non-standard branding text sits inside the +collector-line crop region and defeats OCR outright; not something a +parsing fix can address. + +**Cross-check against the filename tag-gap census (1,097 unresolved cards +with an unmatchable `expansion_hint`, from the pre-pilot addendum): NOT +the same root cause.** All 1,097 have a fully _recognized_ +`CanonicalExpansion` code (0 unknown) - the gap is a name-matching problem +(many are `(Front)`/`(Back)` filename-parsing artifacts on basic lands), +unrelated to the OCR token-position bug above. Two separate fixes, not +one parser fix arriving twice - the D2.5 deterministic tier is **not** +implied by this OCR fix and was not built. + ## Key files - Backend: `cardpicker/printing_consensus.py`,