Initial cropping support
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user