Files
Copies/copienator/paper_background.py
2026-09-09 14:53:04 +02:00

147 lines
6.8 KiB
Python

"""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)