Files
commander-ai-lab/scanner/ocr.py
T
2026-03-10 12:13:56 -04:00

182 lines
6.1 KiB
Python

"""
Card Scanner — OCR (Optical Character Recognition)
═══════════════════════════════════════════════════
Reads card titles from cropped title bar images using Tesseract OCR.
Includes preprocessing (threshold, denoise, sharpen) to improve accuracy.
"""
import re
import numpy as np
try:
import cv2
except ImportError:
cv2 = None
try:
import pytesseract
except ImportError:
pytesseract = None
print("WARNING: pytesseract not installed. Card scanning will be unavailable.")
print(" Install with: pip install pytesseract")
print(" Also install Tesseract OCR: https://github.com/UB-Mannheim/tesseract/wiki")
# Characters that are legal in MTG card names
_CARD_NAME_CHARS = re.compile(r"[^a-zA-Z0-9\s',\-/]")
def read_title(image: "np.ndarray") -> str:
"""
Read the card title from a cropped title bar image.
Applies preprocessing (grayscale, resize, sharpen, threshold) then OCR.
Returns cleaned text string, or empty string on failure.
"""
if cv2 is None or pytesseract is None:
return ""
try:
# Convert to grayscale
if len(image.shape) == 3:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
else:
gray = image.copy()
# Upscale for better OCR accuracy (Tesseract works best at ~300 DPI)
h, w = gray.shape[:2]
if w < 600:
scale = 600 / w
gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
# Denoise
denoised = cv2.fastNlMeansDenoising(gray, h=10)
# Sharpen — makes letter edges crisper for OCR
sharpen_kernel = np.array([[-1, -1, -1],
[-1, 9, -1],
[-1, -1, -1]])
sharpened = cv2.filter2D(denoised, -1, sharpen_kernel)
# Build candidate images with various preprocessing
candidates = []
# 1. Otsu threshold on sharpened
_, otsu = cv2.threshold(sharpened, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
candidates.append(otsu)
# 2. Inverted Otsu (dark cards with light text)
candidates.append(cv2.bitwise_not(otsu))
# 3. Adaptive threshold on sharpened
adaptive = cv2.adaptiveThreshold(sharpened, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, 21, 6)
candidates.append(adaptive)
# 4. High-contrast threshold (typical white/cream title bars)
_, hi = cv2.threshold(sharpened, 160, 255, cv2.THRESH_BINARY)
candidates.append(hi)
# 5. Denoised grayscale (let Tesseract do its own binarization)
candidates.append(denoised)
# 6. CLAHE enhanced + Otsu (handles uneven lighting)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
enhanced = clahe.apply(denoised)
_, clahe_otsu = cv2.threshold(enhanced, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
candidates.append(clahe_otsu)
# 7. Morphological close then Otsu (connects broken letter strokes)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
closed = cv2.morphologyEx(sharpened, cv2.MORPH_CLOSE, kernel)
_, morph_otsu = cv2.threshold(closed, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
candidates.append(morph_otsu)
# PSM configs optimized for card titles:
# 7 = single text line
# 13 = raw line (no dictionaries)
# 8 = single word (for short card names)
configs = [
"--psm 7 --oem 3",
"--psm 13 --oem 3",
"--psm 8 --oem 3",
]
best_text = ""
best_conf = 0.0
for cfg in configs:
for img in candidates:
try:
data = pytesseract.image_to_data(img, config=cfg, output_type=pytesseract.Output.DICT)
texts = []
confs = []
for i, txt in enumerate(data["text"]):
txt = txt.strip()
conf = int(data["conf"][i]) if str(data["conf"][i]) != "-1" else 0
if txt and conf > 15:
texts.append(txt)
confs.append(conf)
if texts:
full_text = " ".join(texts)
avg_conf = sum(confs) / len(confs)
# Bonus for results that look like card names (mostly alpha)
alpha_ratio = sum(1 for c in full_text if c.isalpha()) / max(len(full_text), 1)
adjusted_conf = avg_conf * (0.5 + 0.5 * alpha_ratio)
if adjusted_conf > best_conf:
best_conf = adjusted_conf
best_text = full_text
except Exception:
continue
return _clean_ocr_text(best_text)
except Exception:
return ""
def _clean_ocr_text(text: str) -> str:
"""
Clean OCR output to produce a valid card name.
Fixes common OCR artifacts and normalizes whitespace.
"""
if not text:
return ""
# Remove leading/trailing whitespace and newlines
text = text.strip().replace("\n", " ").replace("\r", "")
# Common OCR substitutions for card text
# l/I confusion, 0/O confusion, etc.
replacements = {
"|": "l",
"¢": "c",
"©": "c",
"®": "",
"™": "",
"€": "e",
"£": "",
"¥": "",
"§": "s",
"°": "o",
}
for old, new in replacements.items():
text = text.replace(old, new)
# Remove common OCR artifacts (keep letters, digits, spaces, apostrophes, commas, hyphens)
text = _CARD_NAME_CHARS.sub("", text)
# Collapse multiple spaces
text = re.sub(r"\s+", " ", text)
# Remove leading/trailing punctuation (except apostrophes mid-word)
text = text.strip(" ,.-/")
# Remove very short results (likely noise)
if len(text) < 2:
return ""
return text