"""Shared geometry and foreground helpers for scanned paper backgrounds.""" import cv2 import numpy as np def _runs(values: np.ndarray) -> list[tuple[int, int]]: edges = np.diff(np.r_[False, values, False].astype(np.int8)) return list(zip(np.flatnonzero(edges == 1), np.flatnonzero(edges == -1))) def _skew(gray: np.ndarray, angle_step: float = .1) -> float: """Use the dominant near-horizontal/vertical Hough angle, at reduced size.""" scale = min(1.0, 1200 / max(gray.shape)) small = cv2.resize(gray, None, fx=scale, fy=scale) edges = cv2.Canny(small, 40, 120) lines = cv2.HoughLinesP(edges, 1, np.deg2rad(angle_step), 60, minLineLength=min(small.shape) * .16, maxLineGap=12) if lines is None: return 0.0 angles, weights = [], [] for x0, y0, x1, y1 in lines.reshape(-1, 4): a = (np.degrees(np.arctan2(y1-y0, x1-x0)) + 45) % 90 - 45 if abs(a) <= 5: angles.append(a) weights.append(np.hypot(x1-x0, y1-y0)) return _dominant_angle(angles, weights) def _dominant_angle(angles, weights) -> float: if len(angles) < 4: return 0.0 angles, weights = np.array(angles), np.array(weights) bins = np.arange(-5.125, 5.126, .25) hist, _ = np.histogram(angles, bins, weights=weights) peak = (bins[hist.argmax()] + bins[hist.argmax()+1]) / 2 near = abs(angles-peak) < .4 if weights[near].sum() < .35 * weights.sum(): return 0.0 return float(np.average(angles[near], weights=weights[near])) def _ruling(binary: np.ndarray, horizontal: bool) -> tuple[np.ndarray, int]: """Accept a family of long lines only when positions are largely periodic.""" h, w = binary.shape length = max(25, int((w if horizontal else h) * .12)) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (length, 1) if horizontal else (1, length)) connected = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, np.ones((1, 3) if horizontal else (3, 1), np.uint8)) lines = cv2.morphologyEx(connected, cv2.MORPH_OPEN, kernel) counts = np.count_nonzero(lines, axis=1 if horizontal else 0) bands = _runs(counts > (w if horizontal else h) * .18) if len(bands) < 5: return np.zeros_like(binary), 0 centers = np.array([(a+b)/2 for a, b in bands]) gaps = np.diff(centers) # Missing lines and major/minor rulings may have integer-multiple spacing. candidates = gaps[gaps >= 4] regular = any(np.mean(abs(gaps / d - np.round(gaps / d)) < .16) >= .75 for d in candidates) if not regular: return np.zeros_like(binary), 0 accepted = np.zeros_like(binary) for a, b in bands: if horizontal: accepted[max(0, a-2):b+2] = 255 else: accepted[:, max(0, a-2):b+2] = 255 # Real scans have local warp as well as global skew. Recover shorter line # segments close to the established ruling family, without extending the # entire family into large empty gaps. short = max(25, int((w if horizontal else h)*.035)) joined = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, np.ones((1, 7) if horizontal else (7, 1), np.uint8)) tolerant = cv2.dilate(joined, np.ones((3, 1) if horizontal else (1, 3), np.uint8)) fragments = cv2.morphologyEx(tolerant, cv2.MORPH_OPEN, np.ones((1, short) if horizontal else (short, 1), np.uint8)) nearby = cv2.dilate(accepted, np.ones((15, 1) if horizontal else (1, 15), np.uint8)) accepted |= fragments & nearby if horizontal else fragments return accepted, len(bands) def _foreground(gray: np.ndarray, dpi: float, threshold: int, ruling: np.ndarray) -> np.ndarray: block = max(15, int(dpi / 5) | 1) binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, block, threshold) # Only suppress near the fitted paper lines. Preserve unusually dark ink # crossing pale ruling by comparing against the typical line intensity. mask = cv2.dilate(ruling, np.ones((3, 3), np.uint8)) > 0 if np.any(ruling): samples = ((ruling > 0) & (binary > 0)).astype(np.float32) window = max(31, int(dpi*.4) | 1) weight = cv2.boxFilter(samples, -1, (window, window)) total = cv2.boxFilter(gray.astype(np.float32)*samples, -1, (window, window)) typical = total / np.maximum(weight, 1e-6) excess_ink = (gray.astype(float) < typical - 40).astype(np.uint8) # A dark, one-pixel remnant of a paper line is still paper. Only retain # locally thicker excess strokes inside the suppression mask. excess_ink = cv2.erode(excess_ink, np.ones((2, 2), np.uint8)) > 0 mask &= ~excess_ink binary[mask] = 0 return binary def _content_mask(binary: np.ndarray, dpi: float, nh: int = 0, nv: int = 0) -> tuple[np.ndarray, int]: """Filter only tiny speckles and repeated, matching edge-hole components.""" px = dpi / 25.4 # Small closing reconnects strokes interrupted by paper-line suppression. grouped = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, np.ones((3, 3), np.uint8)) n, labels, stats, _ = cv2.connectedComponentsWithStats(grouped, 8) keep = np.zeros(n, dtype=bool) x, y, w, h, area = stats[1:].T keep[1:] = (area >= max(4, .035*px*px)) & (np.maximum(w, h) >= .35*px) # Thin straight residuals on confirmed ruled paper are not credible ink. thin = max(2, round(.3*px)) if nv: keep[1:] &= ~((w <= thin) & (h >= 3*w)) if nh: keep[1:] &= ~((h <= thin) & (w >= 3*h)) # Hole shadows form recurring shapes close to a physical side edge. Never # discard an entire margin strip: other writing there must remain visible. height, width = binary.shape candidates = np.flatnonzero(keep[1:] & ((x+w < 12*px) | (x > width-12*px)) & (w > .8*px) & (w < 9*px) & (h > px) & (h < 12*px)) + 1 holes = set() for i in candidates: x, y, w, h, area = stats[i] similar = [] a = cv2.resize((labels[y:y+h, x:x+w] == i).astype(np.uint8), (24, 32)) > 0 for j in candidates: xx, yy, ww, hh, aa = stats[j] if abs(x-xx) > 2*px or not (.7 < ww/w < 1.4 and .7 < hh/h < 1.4): continue b = cv2.resize((labels[yy:yy+hh, xx:xx+ww] == j).astype(np.uint8), (24, 32)) > 0 if np.count_nonzero(a & b) / max(1, np.count_nonzero(a | b)) > .60: similar.append(j) if len(similar) >= 4 and np.ptp(stats[similar, 1]) > height*.45: holes.update(similar) if holes: keep[list(holes)] = False # Bound original residual ink, not the expanded/grouped mask. return (keep[labels] & (binary > 0)).astype(np.uint8), len(holes)