Initial cropping support
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user