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

213 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Card Scanner — Image Preprocessing
═══════════════════════════════════
Handles:
- Loading images from bytes
- Detecting card-shaped quadrilaterals via contour analysis
- Perspective-warping detected cards to a flat rectangle
- Cropping the title bar region for OCR
"""
import numpy as np
try:
import cv2
except ImportError:
cv2 = None
print("WARNING: opencv-python not installed. Card scanning will be unavailable.")
print(" Install with: pip install opencv-python")
# Standard MTG card aspect ratio ≈ 63mm × 88mm ≈ 0.716
CARD_ASPECT_RATIO = 0.716
ASPECT_TOLERANCE = 0.25 # ± tolerance for aspect ratio matching
# Target warped card size (pixels)
WARPED_WIDTH = 480
WARPED_HEIGHT = int(WARPED_WIDTH / CARD_ASPECT_RATIO) # ~670
# Title bar crop: top ~8% of the card height
TITLE_TOP_FRAC = 0.03
TITLE_BOTTOM_FRAC = 0.11
TITLE_LEFT_FRAC = 0.05
TITLE_RIGHT_FRAC = 0.82
def load_image_from_bytes(image_bytes: bytes) -> "np.ndarray":
"""Decode image bytes (JPEG, PNG, etc.) into a BGR numpy array."""
if cv2 is None:
raise RuntimeError("opencv-python is not installed")
arr = np.frombuffer(image_bytes, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if img is None:
raise ValueError("Could not decode image from provided bytes")
return img
def find_card_quads(image: "np.ndarray", min_area_frac: float = 0.005, max_area_frac: float = 0.5) -> list:
"""
Find card-shaped quadrilaterals in the image.
Returns a list of 4-point contours (np.ndarray of shape (4,2)) sorted
left-to-right, top-to-bottom for consistent ordering.
Args:
image: BGR input image
min_area_frac: Minimum contour area as a fraction of total image area
max_area_frac: Maximum contour area as a fraction of total image area
"""
if cv2 is None:
raise RuntimeError("opencv-python is not installed")
h, w = image.shape[:2]
total_area = h * w
min_area = total_area * min_area_frac
max_area = total_area * max_area_frac
# Convert to grayscale and blur
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
# Try multiple edge detection approaches for robustness
quads = []
# Approach 1: Canny edge detection
edges = cv2.Canny(blurred, 50, 150)
# Dilate to close gaps in edges
kernel = np.ones((3, 3), np.uint8)
edges = cv2.dilate(edges, kernel, iterations=1)
quads.extend(_find_quads_from_edges(edges, min_area, max_area))
# Approach 2: Adaptive threshold (catches cards with low contrast borders)
if len(quads) == 0:
thresh = cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, 11, 2)
thresh = cv2.bitwise_not(thresh)
quads.extend(_find_quads_from_edges(thresh, min_area, max_area))
# Deduplicate overlapping quads
quads = _deduplicate_quads(quads)
# Sort: top-to-bottom, then left-to-right
quads.sort(key=lambda q: (q[:, 1].mean(), q[:, 0].mean()))
return quads
def _find_quads_from_edges(edges: "np.ndarray", min_area: float, max_area: float) -> list:
"""Extract 4-point polygons from an edge/threshold image."""
if cv2 is None:
return []
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
quads = []
for cnt in contours:
area = cv2.contourArea(cnt)
if area < min_area or area > max_area:
continue
peri = cv2.arcLength(cnt, True)
approx = cv2.approxPolyDP(cnt, 0.02 * peri, True)
if len(approx) == 4 and cv2.isContourConvex(approx):
pts = approx.reshape(4, 2).astype(np.float32)
# Check aspect ratio
rect = cv2.minAreaRect(approx)
(_, (rw, rh), _) = rect
if rw == 0 or rh == 0:
continue
aspect = min(rw, rh) / max(rw, rh)
if abs(aspect - CARD_ASPECT_RATIO) < ASPECT_TOLERANCE:
quads.append(pts)
return quads
def _deduplicate_quads(quads: list, overlap_threshold: float = 50.0) -> list:
"""Remove quads whose centers are too close to each other."""
if len(quads) <= 1:
return quads
result = []
centers = []
for q in quads:
cx, cy = q[:, 0].mean(), q[:, 1].mean()
# Check if this center is too close to an existing one
too_close = False
for (ex, ey) in centers:
if abs(cx - ex) < overlap_threshold and abs(cy - ey) < overlap_threshold:
too_close = True
break
if not too_close:
result.append(q)
centers.append((cx, cy))
return result
def _order_points(pts: "np.ndarray") -> "np.ndarray":
"""
Order 4 points as: top-left, top-right, bottom-right, bottom-left.
This is required for correct perspective transform.
"""
rect = np.zeros((4, 2), dtype=np.float32)
s = pts.sum(axis=1)
rect[0] = pts[np.argmin(s)] # top-left has smallest sum
rect[2] = pts[np.argmax(s)] # bottom-right has largest sum
d = np.diff(pts, axis=1)
rect[1] = pts[np.argmin(d)] # top-right has smallest difference
rect[3] = pts[np.argmax(d)] # bottom-left has largest difference
return rect
def warp_card(image: "np.ndarray", quad: "np.ndarray") -> "np.ndarray":
"""
Apply perspective transform to extract a flat, rectangular card image.
Args:
image: Source BGR image
quad: 4-point polygon (shape (4,2))
Returns:
Warped card image of size WARPED_WIDTH × WARPED_HEIGHT
"""
if cv2 is None:
raise RuntimeError("opencv-python is not installed")
ordered = _order_points(quad)
dst = np.array([
[0, 0],
[WARPED_WIDTH - 1, 0],
[WARPED_WIDTH - 1, WARPED_HEIGHT - 1],
[0, WARPED_HEIGHT - 1],
], dtype=np.float32)
M = cv2.getPerspectiveTransform(ordered, dst)
warped = cv2.warpPerspective(image, M, (WARPED_WIDTH, WARPED_HEIGHT))
# Check if card is upside down (title bar should be at top)
# Compare brightness of top vs bottom strips — title bar is typically lighter
top_strip = warped[:WARPED_HEIGHT // 6, :]
bot_strip = warped[-WARPED_HEIGHT // 6:, :]
if top_strip.mean() < bot_strip.mean() - 10:
# Likely upside down, rotate 180°
warped = cv2.rotate(warped, cv2.ROTATE_180)
return warped
def extract_title_bar(card_img: "np.ndarray") -> "np.ndarray":
"""
Crop the title bar region from a warped card image.
The title bar is approximately the top 3–11% of the card,
inset slightly from left/right edges to avoid border artifacts.
"""
h, w = card_img.shape[:2]
y1 = int(h * TITLE_TOP_FRAC)
y2 = int(h * TITLE_BOTTOM_FRAC)
x1 = int(w * TITLE_LEFT_FRAC)
x2 = int(w * TITLE_RIGHT_FRAC)
return card_img[y1:y2, x1:x2]