| """OCR helper for part B. Wraps the local `tesseract` binary via pytesseract. |
| |
| Deterministic on identical pixels; no network. We drew the overlay text |
| ourselves (clean, high-contrast), so tesseract reads it back reliably. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import re |
|
|
| import pytesseract |
| from PIL import Image |
|
|
|
|
| def read_text(image: Image.Image) -> str: |
| """Return the raw OCR text of ``image``. |
| |
| Convert to luminance ("L") first: tesseract binarizes internally, and on the |
| raw RGB composite (colored background + light-on-dark text bands) its global |
| threshold drops every glyph. A single-channel luminance image gives a clean |
| histogram it reads reliably. |
| """ |
| return pytesseract.image_to_string(image.convert("L")) |
|
|
|
|
| def read_band(image: Image.Image, band: tuple[float, float]) -> str: |
| """OCR ONE horizontal band with single-line page segmentation. |
| |
| Whole-image OCR with Tesseract's DEFAULT page segmentation silently drops text regions when |
| they are widely separated — measured at 151/1088 of our combos. Cropping to the band and |
| forcing --psm 7 (treat the image as a single text line) reads them reliably. |
| """ |
| width, height = image.size |
| y0, y1 = band |
| crop = image.crop((0, int(height * y0), width, int(height * y1))) |
| return pytesseract.image_to_string(crop.convert("L"), config="--psm 7") |
|
|
|
|
| def _normalize(s: str) -> str: |
| """Lowercase; collapse every run of non-alphanumerics to a single space.""" |
| return re.sub(r"[^a-z0-9]+", " ", s.lower()).strip() |
|
|
|
|
| def text_present(ocr_text: str, phrase: str, cutoff: float = 0.8) -> bool: |
| """True if ``phrase`` appears in ``ocr_text`` (normalized). |
| |
| Exact normalized substring wins; otherwise fall back to token overlap |
| (fraction of the phrase's tokens found) against ``cutoff``. |
| """ |
| norm_ocr = _normalize(ocr_text) |
| norm_phrase = _normalize(phrase) |
| if not norm_phrase: |
| return True |
| if norm_phrase in norm_ocr: |
| return True |
| tokens = norm_phrase.split() |
| if not tokens: |
| return True |
| hits = sum(1 for t in tokens if t in norm_ocr) |
| return hits / len(tokens) >= cutoff |
|
|