Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 39 additions & 13 deletions MPCAutofill/cardpicker/local_ocr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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)

Expand All @@ -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"
Expand Down
39 changes: 39 additions & 0 deletions MPCAutofill/cardpicker/tests/test_local_identify_printing_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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):
Expand Down
47 changes: 47 additions & 0 deletions docs/features/printing-tags.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Loading