Initial cropping support

This commit is contained in:
2026-09-09 14:53:04 +02:00
parent 359c62004c
commit 3969580e01
13 changed files with 1092 additions and 14 deletions
+131
View File
@@ -0,0 +1,131 @@
"""Optional preprocessing: replace split copies with ink-guided crops."""
from __future__ import annotations
import argparse
import hashlib
import json
import multiprocessing
import signal
import shutil
import tempfile
import cv2
from collections.abc import Sequence
from pathlib import Path
from copienator.cli import CliError, ExitCode, execute, target_parser, workspace_from_target
from copienator.crop_blank_margins import process_pdf
from copienator.filesystem import staged_files
from copienator.workspace import EvaluationWorkspace
def selected_files(workspace: EvaluationWorkspace, target: Path) -> list[Path]:
workspace.require_directories("Copies")
copies = workspace.copies_dir.resolve()
target = target.resolve()
if target.is_file():
if target.parent != copies or target.suffix.lower() != ".pdf":
raise CliError("La cible doit être un PDF du dossier Copies.", ExitCode.INVALID_ARGUMENTS)
files = [target]
elif target in (workspace.root, copies):
files = sorted(copies.glob("*.pdf"), key=lambda path: path.name.casefold())
else:
raise CliError("Cible attendue : évaluation, dossier Copies ou PDF dans Copies.",
ExitCode.INVALID_ARGUMENTS)
for source in files:
if source.is_symlink():
raise CliError(f"Lien symbolique non pris en charge : {source}")
if source.with_suffix(".json").exists():
raise CliError(
f"{source.name} possède déjà des coordonnées de labels. "
"Le rognage doit précéder leur détection. Pour reprendre le prétraitement, "
"mettez de côté le JSON associé, puis régénérez la découpe des marges et les labels. "
"Aucun PDF na été remplacé.")
return files
def _initialize_worker() -> None:
# Five copies should use five cores, not five OpenCV thread pools. MuPDF
# must also stay isolated in separate processes rather than Python threads.
cv2.setNumThreads(1)
signal.signal(signal.SIGINT, signal.SIG_IGN)
def _process_copy(job: tuple[Path, Path]) -> list[dict]:
source, destination = job
def progress(page, total, row):
removed = row["top_removed_mm"]+row["bottom_removed_mm"]
print(f"{source.name} — Page {page}/{total} : {removed:.1f} mm retirés", flush=True)
return process_pdf(source, destination, None, 200, 6, 5, progress=progress)
def process_copies(files: list[Path], staging: Path, workers: int) -> list[dict]:
jobs = [(source, staging/source.name) for source in files]
count = min(workers, len(jobs))
print(f"Rognage de {len(files)} copies avec {count} traitement(s) en parallèle.", flush=True)
if count == 1:
previous_threads = cv2.getNumThreads()
cv2.setNumThreads(1)
try:
batches = [_process_copy(job) for job in jobs]
finally:
cv2.setNumThreads(previous_threads)
else:
# spawn works on Windows and avoids inheriting GUI/native-library state.
# Pool's context terminates and joins workers on errors or cancellation
# before staged_files removes the unpublished PDFs.
with multiprocessing.get_context("spawn").Pool(count, _initialize_worker) as pool:
batches = list(pool.imap_unordered(_process_copy, jobs))
order = {source.name: i for i, source in enumerate(files)}
return sorted((row for batch in batches for row in batch),
key=lambda row: (order[row["file"]], row["page"]))
def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> ExitCode:
if workers < 1:
raise CliError("Le nombre de traitements parallèles doit être positif.",
ExitCode.INVALID_ARGUMENTS)
files = selected_files(workspace, target)
if not files:
raise CliError("Aucun PDF trouvé dans Copies.", ExitCode.INVALID_ARGUMENTS)
# Prepare the whole batch before replacing any copy. A detection failure or
# interruption leaves the working PDFs intact; commit errors roll back.
with staged_files(workspace.copies_dir) as staging:
records = process_copies(files, staging, workers)
# Detect edits made while the batch was being analysed, before saving
# backups or publishing results derived from an obsolete source.
digests = {row["file"]: row["source_sha256"] for row in records}
for source in files:
if hashlib.sha256(source.read_bytes()).hexdigest() != digests[source.name]:
raise CliError(f"{source.name} a changé pendant lanalyse. Aucun PDF remplacé.")
workspace.runs_dir.mkdir(parents=True, exist_ok=True)
backup = Path(tempfile.mkdtemp(prefix="crop-margins-", dir=workspace.runs_dir))
originals = backup/"Copies"
originals.mkdir()
for source in files:
shutil.copy2(source, originals/source.name)
(backup/"report.json").write_text(json.dumps(records, ensure_ascii=False, indent=2),
encoding="utf-8")
print(f"Sauvegarde des PDF non rognés : {originals}", flush=True)
cropped = sum(row["top_removed_mm"]+row["bottom_removed_mm"] > 0 for row in records)
print(f"Terminé : {cropped}/{len(records)} pages rognées ; "
f"{len(files)} PDF remplacés dans Copies.", flush=True)
return ExitCode.SUCCESS
def main(argv: Sequence[str] | None = None) -> int:
parser = target_parser("Rogner les zones vides des PDF dans Copies, avant les labels")
parser.add_argument("--workers", type=int, default=5,
help="Nombre de copies traitées en parallèle (défaut : 5)")
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target, workers=args.workers)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+137
View File
@@ -0,0 +1,137 @@
"""Propose conservative top/bottom crops for scanned, optionally ruled PDFs.
Run with ``python -m copienator.crop_blank_margins INPUT_DIR OUTPUT_DIR``.
Only PDFs directly in INPUT_DIR are processed. Originals are never modified.
Analysis is deskewed; output keeps the original scan and changes its CropBox.
This is a heuristic review utility, not a guarantee that a scan contains no ink.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import html
import json
from pathlib import Path
import cv2
import numpy as np
import pymupdf
from PIL import Image, ImageDraw
from copienator.ink_detection import detect_bounds
def apply_bounds(page: pymupdf.Page, top: float, bottom: float) -> None:
"""Apply fractional bounds in displayed orientation, respecting old CropBox."""
rect = page.rect
visible = pymupdf.Rect(0, top*rect.height, rect.width, bottom*rect.height)
box = visible * page.derotation_matrix
box += (page.cropbox_position.x, page.cropbox_position.y,
page.cropbox_position.x, page.cropbox_position.y)
page.set_cropbox(box)
def process_pdf(source: Path, destination: Path, review: Path | None,
dpi: float, padding_mm: float, min_crop_mm: float,
progress=None) -> list[dict]:
digest = hashlib.sha256(source.read_bytes()).hexdigest()
rows = []
with pymupdf.open(source) as doc:
for index, page in enumerate(doc):
pix = page.get_pixmap(dpi=round(dpi), colorspace=pymupdf.csRGB, alpha=False)
rgb = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3)
result = detect_bounds(rgb, dpi, padding_mm, min_crop_mm)
top, bottom = result.pop('top_px'), result.pop('bottom_px')
row = dict(file=source.name, page=index+1, **result,
top_removed_mm=round(top/pix.height*page.rect.height*25.4/72, 2),
bottom_removed_mm=round((pix.height-bottom)/pix.height*page.rect.height*25.4/72, 2),
original_cropbox=list(page.cropbox), rotation=page.rotation,
source_sha256=digest)
if review is not None:
preview = Image.fromarray(rgb)
preview.thumbnail((500, 700))
overlay = Image.new('RGBA', preview.size)
draw = ImageDraw.Draw(overlay)
y0, y1 = top/pix.height*preview.height, bottom/pix.height*preview.height
if top:
draw.rectangle((0, 0, preview.width, y0), fill=(255, 40, 40, 85))
draw.line((0, y0, preview.width, y0), fill=(230, 0, 0, 255), width=2)
if bottom < pix.height:
draw.rectangle((0, y1, preview.width, preview.height), fill=(255, 40, 40, 85))
draw.line((0, y1, preview.width, y1), fill=(230, 0, 0, 255), width=2)
preview = Image.alpha_composite(preview.convert('RGBA'), overlay).convert('RGB')
preview.save(review/f'{source.stem}-{index+1:03}.jpg', quality=85)
if top or bottom < pix.height:
apply_bounds(page, top/pix.height, bottom/pix.height)
row['output_cropbox'] = list(page.cropbox)
rows.append(row)
if progress is not None:
progress(index+1, len(doc), row)
doc.save(destination, garbage=3, deflate=True)
with pymupdf.open(destination) as check:
if len(check) != len(rows):
raise RuntimeError(f'Page count changed: {source}')
for page in check:
if page.rect.is_empty:
raise RuntimeError(f'Empty output page: {destination}')
if hashlib.sha256(source.read_bytes()).hexdigest() != digest:
raise RuntimeError(f'Source changed during processing: {source}')
return rows
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('input', type=Path)
parser.add_argument('output', type=Path)
parser.add_argument('--dpi', type=int, default=200)
parser.add_argument('--padding-mm', type=float, default=6)
parser.add_argument('--min-crop-mm', type=float, default=5)
args = parser.parse_args()
if args.dpi < 100 or args.padding_mm < 0 or args.min_crop_mm < 0:
parser.error('Use dpi >= 100 and nonnegative margins.')
sources = sorted(args.input.glob('*.pdf')) if args.input.is_dir() else [args.input]
if not sources or any(not p.is_file() for p in sources):
parser.error('No input PDFs found.')
if any(p.resolve() == (args.output/p.name).resolve() for p in sources):
parser.error('Output must not overwrite input PDFs.')
args.output.mkdir(parents=True, exist_ok=True)
review = args.output/'review'
review.mkdir(exist_ok=True)
cv2.setNumThreads(2)
rows = []
for i, source in enumerate(sources):
batch = process_pdf(source, args.output/source.name, review,
args.dpi, args.padding_mm, args.min_crop_mm)
rows.extend(batch)
print(f'[{i+1}/{len(sources)}] {source.name}: {len(batch)} pages, '
f'{sum(r["top_removed_mm"] > 0 or r["bottom_removed_mm"] > 0 for r in batch)} cropped',
flush=True)
(args.output/'report.json').write_text(json.dumps(rows, indent=2)+'\n')
with (args.output/'report.csv').open('w') as stream:
writer = csv.DictWriter(stream, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
cards = []
for row in rows:
stem = Path(row['file']).stem
cards.append(f'<article><a href="{html.escape(row["file"])}#page={row["page"]}">'
f'{html.escape(stem)} / {row["page"]}</a>'
f'<p>Top: {row["top_removed_mm"]} mm · Bottom: {row["bottom_removed_mm"]} mm'
f' · {row["status"]}</p>'
f'<img loading="lazy" src="review/{html.escape(stem)}-{row["page"]:03}.jpg"></article>')
(args.output/'index.html').write_text(
'<!doctype html><meta charset="utf-8"><title>Crop review</title>'
'<style>body{font:15px system-ui;background:#eee;margin:24px}'
'main{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:20px}'
'article{background:white;padding:12px}img{width:100%}p{font-size:12px}</style>'
'<h1>Crop review</h1><p>Red shading shows the removed areas on the original scan. '
'Click a page title to open the processed PDF. Review statuses flag uncertain detections.</p>'
'<main>'+''.join(cards)+'</main>')
changed = sum(r['top_removed_mm'] > 0 or r['bottom_removed_mm'] > 0 for r in rows)
print(f'Done: {len(sources)} PDFs, {len(rows)} pages, {changed} cropped. Review: {args.output / "index.html"}')
if __name__ == '__main__':
main()
+1
View File
@@ -17,6 +17,7 @@ COMMANDS: dict[str, Command] = {
"statement-personal": Command("enonce_info", "Generate personal statement metadata"),
"copies": Command("copies_tools", "Rotate or rename scanned copies"),
"page-split": Command("page_splitter", "Split and reorder scanned PDF pages"),
"crop-margins": Command("crop_margins", "Trim blank top and bottom margins in Copies"),
"crop-labels": Command("cutleft", "Crop the label margin from copies"),
"labels": Command("gemini_for_labels", "Detect question labels with Gemini"),
"review-labels": Command("plotting", "Review detected labels interactively"),
+260
View File
@@ -0,0 +1,260 @@
"""Detect top and bottom content bounds in scanned student work.
Strong coloured strokes are never erased merely because they coincide with
paper ruling. The detector supports coloured handwriting and dark ruled scans;
it remains conservative for pencil-only scans and heavily saturated grids.
"""
from functools import lru_cache
import cv2
import numpy as np
from copienator.paper_background import _skew, _ruling, _foreground, _content_mask
@lru_cache(maxsize=8)
def _paper_kernel(sigma: float) -> np.ndarray:
"""Match OpenCV's uint8 Gaussian coefficients, including error diffusion.
See getGaussianKernelFixedPoint_ED in OpenCV's smooth.dispatch.cpp.
The 8-bit coefficients allow exact sums in the faster float filter path.
"""
size = round(sigma*6+1) | 1
kernel = cv2.getGaussianKernel(size, sigma).ravel()
fixed = np.zeros(size, np.float32)
error = 0.0
for i in range(size//2):
value = kernel[i]*256+error
weight = round(value)
error = value-weight
fixed[i] = fixed[-1-i] = weight
fixed[size//2] = 256-fixed.sum()
fixed /= 256
return fixed
def _paper_blur(gray: np.ndarray, sigma: float) -> np.ndarray:
kernel = _paper_kernel(sigma)
blurred = cv2.sepFilter2D(gray, cv2.CV_32F, kernel, kernel)
# GaussianBlur rounds positive half-integers upward, rather than to even.
np.add(blurred, .5, out=blurred)
np.floor(blurred, out=blurred)
return blurred.astype(np.uint8)
def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float):
"""Refine confirmed ruling, only accepting substantial extra blank margins.
Short directional openings tolerate locally bent/broken paper lines. A
physical component-size threshold rejects their remaining tiny fragments.
This is deliberately limited to already-confirmed ruled paper.
"""
px = dpi / 25.4
height, width = gray.shape
dark = 255-gray
length = max(9, round(2.5*px))
tolerance = max(3, round(.6*px) | 1)
lines = []
for horizontal in (True, False):
broadened = cv2.dilate(dark, np.ones(
(tolerance, 1) if horizontal else (1, tolerance), np.uint8))
lines.append(cv2.morphologyEx(broadened, cv2.MORPH_OPEN, np.ones(
(1, length) if horizontal else (length, 1), np.uint8)))
residual = cv2.subtract(dark, np.maximum(*lines))
n, labels, stats, centers = cv2.connectedComponentsWithStats(
(residual > 35).astype(np.uint8), 8)
keep = np.zeros(n, bool)
ww, hh, area = stats[1:, 2], stats[1:, 3], stats[1:, 4]
keep[1:] = ((area >= .8*px*px) & (np.minimum(ww, hh) >= .6*px)
& (area / (ww*hh) > .3)
& (np.maximum(ww, hh) < 4*np.minimum(ww, hh)))
# Use side columns as a prior, then require repeated size and alignment. An
# isolated note in the same column remains eligible to protect the margin.
xx = stats[1:, 0]
candidates = np.flatnonzero(
((xx < 15*px) | (xx+ww > width-15*px))
& (ww > px) & (ww < 9*px) & (hh > px) & (hh < 12*px)) + 1
if len(candidates) > 128:
# Bound the matching cost and retain ambiguous, very noisy margins.
return clean, 0, False
holes = set()
for i in candidates:
matches = []
for j in candidates:
if (abs(centers[i, 0]-centers[j, 0]) < 2*px
and .6 < stats[j, 2]/stats[i, 2] < 1.6
and .6 < stats[j, 3]/stats[i, 3] < 1.6):
matches.append(j)
if len(matches) >= 3 and np.ptp(centers[matches, 1]) > height*.35:
holes.update(matches)
keep[list(holes)] = False
refined = keep[labels]
# Directional opening also removes long fraction bars. Protect very dark,
# thick straight strokes independently, even if ruling crosses their ends.
long_strokes = cv2.morphologyEx((gray < 50).astype(np.uint8), cv2.MORPH_OPEN,
np.ones((1, max(9, round(width*.1))), np.uint8))
count, lab, st, _ = cv2.connectedComponentsWithStats(long_strokes, 8)
bars = np.zeros(count, bool)
bw, bh, ba = st[1:, 2], st[1:, 3], st[1:, 4]
bars[1:] = ((bw > width*.1) & (bh >= .3*px) & (bh < height*.015)
& (bw > 8*bh) & (ba/(bw*bh) > .5))
strong_ruling, strong_lines = _ruling((gray < 50).astype(np.uint8)*255, True)
if strong_lines:
# A family of equally dark parallel lines is paper, not fraction bars.
overlap = np.bincount(lab[strong_ruling > 0], minlength=count)
bars &= overlap < st[:, 4]*.5
refined |= bars[lab]
ys = np.flatnonzero(np.any(refined, axis=1))
original = np.flatnonzero(np.any(clean, axis=1))
if not len(ys) or not len(original):
return clean, 0, False
# Leave ordinary small crops to the more permissive detector. Keep a
# recovery neighbourhood around the refined bounds for broken/faint strokes.
top, bottom = max(0, int(ys.min()-2*px)), min(height, int(ys.max()+1+2*px))
result = clean.copy()
changed = False
if top-original.min() >= 30*px:
result[:top] = 0
changed = True
if original.max()+1-bottom >= 30*px:
result[bottom:] = 0
changed = True
return result, len(holes), changed
def _neutral_paper_foreground(gray: np.ndarray, chroma: np.ndarray, dpi: float):
"""Clean confirmed dark ruling before it can seed whole pages.
Returns None for ordinary ink components or unconfirmed paper geometry.
The mask is transformed back to the original displayed pixel coordinates.
"""
height, width = gray.shape
# Only neutral darkness is relevant here: long blue equations are not
# evidence of dark paper. Broken ruling can form several medium-sized
# components instead of one page-spanning component. The broader threshold
# also admits faded gray grids; periodic ruling must still be confirmed.
_, _, stats, _ = cv2.connectedComponentsWithStats(
((gray < 160) & (chroma < 30)).astype(np.uint8), 8)
spans = np.maximum(stats[1:,2]/width, stats[1:,3]/height)
if not (np.any(spans > .35) or np.count_nonzero(spans > .1) >= 3):
return None, dict(paper_cleanup=False)
angle = _skew(gray, angle_step=.5)
matrix = cv2.getRotationMatrix2D((width/2, height/2), angle, 1)
corners = np.array([[0,0],[width,0],[0,height],[width,height]], dtype=float)
corners = cv2.transform(corners[None], matrix)[0]
origin = np.floor(corners.min(axis=0))
size = np.ceil(corners.max(axis=0)-origin).astype(int)
matrix[:, 2] -= origin
deskewed = cv2.warpAffine(gray, matrix, tuple(size), borderValue=255)
background = _paper_blur(deskewed, dpi/8)
normalized = cv2.divide(deskewed, np.maximum(background,1), scale=255)
block = max(15, int(dpi/5) | 1)
binary = cv2.adaptiveThreshold(normalized,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV,block,9)
horizontal, nh = _ruling(binary,True)
vertical, nv = _ruling(binary,False)
if not (nh or nv):
return None, dict(paper_cleanup=False)
# The permissive mask retains faint, isolated marks.
# Bands identify where ruling is expected, but only actual dark pixels
# inside them may be suppressed. Erasing the complete band loses faint
# writing alongside a dark grid line.
paper_pixels = (horizontal|vertical) & ((normalized < 160).astype(np.uint8)*255)
clean, holes = _content_mask(_foreground(normalized,dpi,9,paper_pixels),dpi,nh,nv)
clean, repeated_holes, refined = _large_blank_ink(deskewed, clean, dpi)
restored = cv2.warpAffine(clean, cv2.invertAffineTransform(matrix),
(width,height), flags=cv2.INTER_NEAREST, borderValue=0)
return restored > 0, dict(paper_cleanup=True, angle_deg=round(angle,3),
horizontal_lines=nh,vertical_lines=nv,
edge_artifacts=holes+repeated_holes,
large_blank_refinement=refined)
def _seed_mask(mask: np.ndarray, px: float) -> np.ndarray:
n, labels, stats, _ = cv2.connectedComponentsWithStats(mask.astype(np.uint8), 8)
keep = np.zeros(n, bool)
keep[1:] = ((stats[1:, 4] >= max(4, .12*px*px))
& (stats[1:, 2] >= max(2, round(.35*px)))
& (stats[1:, 3] >= max(2, round(.35*px))))
return keep[labels]
def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
min_crop_mm: float = 5) -> dict:
"""Locate strong ink, recover adjacent faint strokes, and retain padding.
Neutral punched-hole shadows usually have neither sufficient chroma nor
sufficient darkness to seed a region. Nothing is discarded merely because
it is in a side margin. Two seed thresholds expose unstable boundaries.
"""
px = dpi/25.4
h, w = rgb.shape[:2]
red, green, blue = cv2.split(rgb)
lowest = cv2.min(cv2.min(red, green), blue)
chroma = cv2.subtract(cv2.max(cv2.max(red, green), blue), lowest)
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
darkness = 255-lowest
length = max(15, round(5.2*px)) | 1
horizontal = cv2.morphologyEx(darkness, cv2.MORPH_OPEN,
np.ones((1, length), np.uint8))
vertical = cv2.morphologyEx(darkness, cv2.MORPH_OPEN,
np.ones((length, 1), np.uint8))
residual = cv2.subtract(darkness, np.maximum(horizontal, vertical))
# Strong strokes bypass the line-background estimate entirely: even a long
# isolated black or coloured fraction bar must survive. Local contrast is
# used only to recover weaker surrounding strokes.
neutral_ink = gray < 95
cleaned, paper_info = _neutral_paper_foreground(gray, chroma, dpi)
if cleaned is not None:
neutral_ink = cleaned
weak = ((chroma > 60) | ((gray < 175) & (residual > 25))).astype(np.uint8)
radius = max(1, round(2*px))
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*radius+1, 2*radius+1))
extents = []
counts = []
same_thresholds = not np.any((chroma > 100) & (chroma <= 115) & ~neutral_ink)
for threshold in (100, 115):
if threshold == 115 and same_thresholds:
counts.append(counts[0])
if extents:
extents.append(extents[0])
continue
seeds = _seed_mask((chroma > threshold) | neutral_ink, px)
counts.append(int(np.count_nonzero(seeds)))
if not np.any(seeds):
continue
# Limit weak recovery to a physical neighbourhood: faint grid lines
# connected to a letter cannot grow into a full-page foreground mask.
nearby = cv2.dilate(seeds.astype(np.uint8), kernel)
candidate = (weak & nearby) | seeds.astype(np.uint8)
n, labels = cv2.connectedComponents(candidate, 8)
seeded_labels = np.zeros(n, bool)
seeded_labels[np.unique(labels[seeds])] = True
seeded_labels[0] = False
ys = np.flatnonzero(np.any(seeded_labels[labels], axis=1))
extents.append((int(ys.min()), int(ys.max())+1))
result = dict(top_px=0, bottom_px=h, angle_deg=None,
horizontal_lines=0, vertical_lines=0, edge_artifacts=0,
detector="ink", seed_pixels=counts, status="review-no-ink-seeds")
result.update(paper_info)
if not extents:
return result
pad = padding_mm*px
top = max(0, int(np.floor(min(e[0] for e in extents)-pad)))
bottom = min(h, int(np.ceil(max(e[1] for e in extents)+pad)))
uncertain = []
if len(extents) < 2:
uncertain.append('seed-threshold')
else:
for edge, name in ((0, 'top'), (1, 'bottom')):
if abs(extents[0][edge]-extents[1][edge]) > max(pad, 3*px):
uncertain.append(name)
if top < min_crop_mm*px:
top = 0
if h-bottom < min_crop_mm*px:
bottom = h
result.update(top_px=top, bottom_px=bottom,
status=('review-'+'-'.join(uncertain) if uncertain else
'cropped' if top or bottom < h else 'unchanged'))
return result
+146
View File
@@ -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)