Automatic page splitting

This commit is contained in:
sebastien committed 2026-11-03 22:08:36 +01:00
1 parent d4404e4c27
commit 78f3e4b2f0
13 files changed
+1959 -40

No files matched your search

+377
View File
@@ -0,0 +1,377 @@
"""Experimental, non-destructive spread splitting with an HTML review gallery.
python -m copienator.auto_page_split INPUT OUTPUT [--workers 4]
No booklet reordering, secondary cuts, or GUI changes. Unknown formats and
uncertain content are retained. OUTPUT must not already exist.
"""
from __future__ import annotations
import argparse
from concurrent.futures import ProcessPoolExecutor
import hashlib
import html
import json
import multiprocessing
from pathlib import Path
from urllib.parse import quote
import cv2
import numpy as np
import pymupdf
from PIL import Image, ImageDraw
from copienator.ink_detection import detect_bounds, detect_page_content
def classify_format(width: float, height: float) -> str:
"""Dimensions are displayed PDF points; tolerate 5% scan size error."""
w, h = width * 25.4 / 72, height * 25.4 / 72
if abs(min(w, h) / 210 - 1) < .05 and abs(max(w, h) / 297 - 1) < .05:
return 'a4'
if abs(w / 420 - 1) < .05 and abs(h / 297 - 1) < .05:
return 'a3'
return 'unknown'
def analyse_ink(rgb: np.ndarray, dpi: float, *, spread: bool = False) -> tuple[np.ndarray, dict]:
"""Use normal rognage on logical pages; colour cleanup only for overflow.
Blankness uses the shared crop detector's content-analysis entry point.
No independent weak-pixel veto or secondary artifact detector is applied.
"""
if not spread:
return detect_page_content(rgb,dpi)
masks = {}
result = detect_bounds(rgb, dpi, content_masks=masks,
colour_cleanup=True, edge_mark_cleanup=False)
ink = masks['foreground']
# For ownership, follow connected faint pen strokes beyond rognage's local
# 2 mm recovery radius. Do not restore page-wide connected ruling networks.
candidate = ink | masks['weak_candidates']
n,labels,stats,_ = cv2.connectedComponentsWithStats(candidate.astype(np.uint8),8)
seeded = np.zeros(n,bool)
seeded[np.unique(labels[ink])] = True
seeded[0] = False
giant = (stats[:,2] > .65*ink.shape[1]) & (stats[:,3] > .4*ink.shape[0])
seeded[giant] = False
ink = ink | seeded[labels]
return ink, result
def _analyse_page_full(rgb: np.ndarray, dpi: float, kind: str) -> dict:
"""Keep spread geometry analysis separate from logical-page blankness."""
h, w = rgb.shape[:2]
boundaries = [0, w//2, w] if kind == 'a3' else [0, w]
content = np.zeros((h,w), bool)
evidence = []
for x0,x1 in zip(boundaries,boundaries[1:]):
foreground, result = analyse_ink(rgb[:,x0:x1],dpi)
content[:,x0:x1] = foreground
evidence.append(result)
ink = analyse_ink(rgb,dpi,spread=True)[0] if kind == 'a3' else content
plan = plan_masks(ink,dpi,kind,content_ink=content,rgb=rgb)
for output,result in zip(plan['outputs'],evidence):
output['crop_analysis'] = result
return plan
def analyse_page(rgb: np.ndarray, dpi: float, kind: str) -> dict:
"""Analyse a raster with the standard coarse-to-fine splitter."""
from copienator.fast_page_split import analyse_page_fast
return analyse_page_fast(rgb, dpi, kind)
def _fold_regions(ink: np.ndarray, credible: np.ndarray, dpi: float,
rgb: np.ndarray | None = None) -> tuple[list[dict], np.ndarray]:
"""Grow local word groups towards page anchors, stopping at supported text.
Broader gaps are allowed for ownership evidence without merging both pages
into one giant line or changing output bounds to include the anchor word.
"""
import heapq
px = dpi/25.4
h,w = ink.shape
middle = w//2
ignored = []
if rgb is not None:
# Remove weak thin ruling *before* word grouping: otherwise a nearby
# dark word can lend its colour/anchor evidence to a pale grid fragment.
count,raw_labels,raw_stats,_ = cv2.connectedComponentsWithStats(ink.astype(np.uint8),8)
ink = ink.copy()
for i,(x,y,bw,bh,area) in enumerate(raw_stats[1:],1):
if not (x < middle+2*px and x+bw > middle-2*px and bh<px and bw>4*bh):
continue
region = raw_labels[y:y+bh,x:x+bw]==i
values = rgb[y:y+bh,x:x+bw][region].astype(np.int16)
if np.median(np.ptp(values,axis=1))<=80 and np.median(values.min(axis=1))>=110:
ink[y:y+bh,x:x+bw][region] = False
ignored.append(dict(owner='artifact',box=[int(x),int(y),int(x+bw),int(y+bh)],
component_id=0,anchor_boxes=[None,None],anchor_distance_mm=[None,None],reason='weak-thin-ruling'))
kernel = np.ones((max(1,round(.5*px)),max(1,round(3*px))),np.uint8)
joined = cv2.dilate(ink.astype(np.uint8),kernel)
n,labels,stats,_ = cv2.connectedComponentsWithStats(joined,8)
boxes = np.zeros((n,4),np.int32)
areas = np.bincount(labels[ink],minlength=n)
pen = np.zeros(n,bool)
for i in range(1,n):
x,y,bw,bh,_ = stats[i]
yy,xx = np.nonzero((labels[y:y+bh,x:x+bw]==i) & ink[y:y+bh,x:x+bw])
boxes[i] = [x+xx.min(),y+yy.min(),x+xx.max()+1,y+yy.max()+1]
if rgb is not None and len(xx)>=.4*px*px:
values=rgb[y+yy,x+xx].astype(np.int16)
# Between the crop detector's weak (60) and strong (100) colour
# thresholds: pale blue ruling must not become an ownership anchor.
pen[i] = np.median(np.ptp(values,axis=1))>80 or np.median(values.min(axis=1))<110
support = credible & ink
# The blank classifier deliberately ignores short isolated pen marks.
# A two-dimensional letter/terminal stroke can still link to its page here.
support |= pen[labels] & ink
left_limit,right_limit = max(0,round(middle-12*px)),min(w,round(middle+12*px))
left_counts = np.bincount(labels[:,:left_limit][support[:,:left_limit]],minlength=n)
right_counts = np.bincount(labels[:,right_limit:][support[:,right_limit:]],minlength=n)
anchors = np.c_[left_counts>=max(2,.2*px*px),right_counts>=max(2,.2*px*px)]
backed = np.bincount(labels[support],minlength=n)>=max(2,.12*px*px)
backed[0] = False
near = max(1,round(2*px))
roots = np.unique(labels[:,max(0,middle-near):middle+near])
regions = ignored
for root in roots:
if root == 0:continue
x0,y0,x1,y1 = boxes[root]
if x1 < middle-near or x0 > middle+near:continue
thin_unanchored = (not anchors[root].any() and
((not pen[root] and y1-y0<px and x1-x0>4*(y1-y0)) or areas[root]<.4*px*px))
if thin_unanchored:
regions.append(dict(owner='artifact',box=boxes[root].tolist(),component_id=int(root),anchor_boxes=[None,None],anchor_distance_mm=[None,None]))
continue
distances = np.full(n,np.inf)
distances[root]=0
queue=[(0.,int(root))]
reached=[np.inf,np.inf]
evidence=[None,None]
# Only supported writing may carry a connection onwards. Otherwise
# rows of grid fragments could form a bridge all the way into an answer.
allowed = backed.copy();allowed[root]=True
while queue:
cost,node=heapq.heappop(queue)
if cost != distances[node] or cost>45*px:continue
if anchors[node].any():
for side in (0,1):
if anchors[node,side] and cost<reached[side]:
reached[side]=cost;evidence[side]=boxes[node].tolist()
continue
a,b,c,d = boxes[node]
dx=np.maximum(0,np.maximum(boxes[:,0]-c,a-boxes[:,2]))
dy=np.maximum(0,np.maximum(boxes[:,1]-d,b-boxes[:,3]))
# Wide mathematical spaces are plausible between strong pen
# regions; unsupported shadows get only the shorter word gap.
gap = 32*px if pen[root] else 8*px
neighbours=np.flatnonzero(allowed & (dx<=gap) & (dy<=2*px))
for other in neighbours:
step=float(np.hypot(dx[other],3*dy[other]))+1
new=cost+step
if new<distances[other]:
distances[other]=new;heapq.heappush(queue,(new,int(other)))
if not np.isfinite(reached).any():owner='artifact'
elif abs(reached[0]-reached[1])<=px:owner='uncertain'
else:owner='left' if reached[0]<reached[1] else 'right'
regions.append(dict(owner=owner,box=[int(x0),int(y0),int(x1),int(y1)],
component_id=int(root),anchor_boxes=evidence,anchor_distance_mm=[round(c/px,2) if np.isfinite(c) else None for c in reached]))
return regions,labels
def plan_masks(ink: np.ndarray, dpi: float, kind: str, *,
content_ink: np.ndarray | None = None,
rgb: np.ndarray | None = None,
_remaining_content: dict | None = None) -> dict:
"""Produce rectangles in image coordinates and explicit review reasons."""
h, w = ink.shape
px = dpi / 25.4
middle = w // 2
reasons = []
crossings = []
left_end, right_start = middle, middle
owned = (ink if content_ink is None else content_ink).copy()
left_owned, right_owned = owned[:, :middle].copy(), owned[:, middle:].copy()
if kind == 'a3':
pad = round(2*px)
crossings,group_labels = _fold_regions(ink,owned,dpi,rgb)
for crossing in crossings:
owner = crossing['owner']
x0,y0,x1,y1 = crossing['box']
if owner == 'artifact':
continue
if owner == 'left':
left_end = max(left_end,min(w,x1+pad))
if x1>middle:
right_owned[y0:y1,max(x0,middle)-middle:x1-middle] &= ~(ink[y0:y1,max(x0,middle):x1] & (group_labels[y0:y1,max(x0,middle):x1]==crossing['component_id']))
elif owner == 'right':
right_start = min(right_start,max(0,x0-pad))
if x0<middle:
left_owned[y0:y1,x0:min(x1,middle)] &= ~(ink[y0:y1,x0:min(x1,middle)] & (group_labels[y0:y1,x0:min(x1,middle)]==crossing['component_id']))
else:
left_end = max(left_end,min(w,x1+pad))
right_start = min(right_start,max(0,x0-pad))
reasons.append('ambiguous-centre-writing')
regions = [('left', [0,0,left_end,h], left_owned),
('right', [right_start,0,w,h], right_owned)]
else:
regions = [('whole', [0,0,w,h], owned)]
if kind == 'unknown':
reasons.append('unknown-format-or-orientation')
outputs = []
if _remaining_content is not None:
_remaining_content.update({side: content for side,_,content in regions})
for side, rect, content in regions:
state = 'content' if content.any() else 'blank'
if kind == 'unknown':
state = 'uncertain'
outputs.append(dict(side=side, rect_px=rect, state=state,
keep=state != 'blank', ink_pixels=int(content.sum())))
return dict(format=kind, size_px=[w,h], outputs=outputs, crossings=crossings,
review_reasons=sorted(set(reasons)),
extensions_mm=[round((left_end-middle)/px,2),
round((middle-right_start)/px,2)] if kind == 'a3' else [0,0])
def save_preview(rgb: np.ndarray, plan: dict, path: Path) -> None:
im = Image.fromarray(rgb)
im.thumbnail((1200, 1000))
sx, sy = im.width/plan['size_px'][0], im.height/plan['size_px'][1]
overlay = Image.new('RGBA', im.size)
draw = ImageDraw.Draw(overlay)
for output in plan['outputs']:
x0,y0,x1,y1 = output['rect_px']
box = (x0*sx,y0*sy,x1*sx-1,y1*sy-1)
colour = '#187941' if output['side'] == 'left' else '#a832a6'
draw.rectangle(box, outline=colour, width=3,
fill=(255,30,30,65) if not output['keep'] else None)
for crossing in plan['crossings']:
x0,y0,x1,y1 = crossing['box']
draw.rectangle((x0*sx,y0*sy,x1*sx,y1*sy), outline='#999999' if crossing['owner']=='artifact' else '#e88b00', width=2)
Image.alpha_composite(im.convert('RGBA'), overlay).convert('RGB').save(path, quality=90)
def process_pdf(job: tuple[str, str, int]) -> list[dict]:
source_s, destination_s, dpi = job
source, destination = Path(source_s), Path(destination_s)
cv2.setNumThreads(1)
digest = hashlib.sha256(source.read_bytes()).hexdigest()
records = []
with pymupdf.open(source) as doc, pymupdf.open() as result:
for i, page in enumerate(doc):
rotation = page.rotation
kind = classify_format(page.rect.width, page.rect.height)
from copienator.fast_page_split import analyse_pdf_page_fast
plan,rgb = analyse_pdf_page_fast(page,dpi,kind)
pixel_width,pixel_height = plan['size_px']
plan.update(file=source.name, source_page=i+1, source_sha256=digest,
source_rotation=rotation, source_cropbox=list(page.cropbox))
preview = f'{source.stem}-{i+1:03}.jpg'
plan['preview'] = f'review/{preview}'
save_preview(rgb, plan, destination/'review'/preview)
# Normalize through a full-page PDF form: remove_rotation() loses
# non-default CropBoxes on quarter-turn pages in some MuPDF builds.
w,h = page.rect.width,page.rect.height
normalized = pymupdf.open()
flat = normalized.new_page(width=w,height=h)
page.set_rotation(0)
if page.get_contents():
flat.show_pdf_page(flat.rect,doc,i,rotate=-rotation)
for output in plan['outputs']:
x0,y0,x1,y1 = output['rect_px']
clip = pymupdf.Rect(x0/pixel_width*w,y0/pixel_height*h,
x1/pixel_width*w,y1/pixel_height*h)
output['crop_points'] = list(clip)
if output['keep']:
target = result.new_page(width=clip.width,height=clip.height)
# Truly empty PDF pages have no content stream to show.
if flat.get_contents():
target.show_pdf_page(target.rect,normalized,0,clip=clip)
output['output_page'] = len(result)
records.append(plan)
normalized.close()
# An entirely blank copy has no output PDF; its report is still kept.
if len(result):
result.save(destination/'pdfs'/source.name, garbage=3, deflate=True)
if hashlib.sha256(source.read_bytes()).hexdigest() != digest:
raise RuntimeError(f'Source changed during analysis: {source}')
output_path = destination/'pdfs'/source.name
expected = sum(o['keep'] for r in records for o in r['outputs'])
if expected:
with pymupdf.open(output_path) as check:
if len(check) != expected or any(p.rect.is_empty for p in check):
raise RuntimeError(f'Invalid output: {output_path}')
return records
def run(source: Path, destination: Path, *, dpi: int = 200, workers: int = 1) -> dict:
if dpi < 100 or workers < 1:
raise ValueError('Use dpi >= 100 and workers >= 1')
sources = sorted(source.glob('*.pdf')) if source.is_dir() else [source]
if not sources or any(not p.is_file() or p.suffix.lower() != '.pdf' for p in sources):
raise ValueError('No input PDFs found')
# Refuse reuse: this makes accidental source replacement impossible.
destination.mkdir(parents=True, exist_ok=False)
(destination/'review').mkdir()
(destination/'pdfs').mkdir()
jobs = [(str(p),str(destination),dpi) for p in sources]
records = []
if workers == 1:
batches = map(process_pdf, jobs)
for batch in batches:
records.extend(batch)
print(f'{batch[0]["file"]}: {len(batch)} scanned pages', flush=True)
else:
with ProcessPoolExecutor(max_workers=workers,
mp_context=multiprocessing.get_context('spawn')) as pool:
for batch in pool.map(process_pdf,jobs):
records.extend(batch)
print(f'{batch[0]["file"]}: {len(batch)} scanned pages', flush=True)
return write_report(records,destination,len(sources))
def write_report(records: list[dict], destination: Path, file_count: int) -> dict:
"""Rebuild the gallery from saved decisions without reanalysing scans."""
summary = dict(files=file_count, source_pages=len(records),
a3=sum(r['format']=='a3' for r in records), a4=sum(r['format']=='a4' for r in records),
output_pages=sum(o['keep'] for r in records for o in r['outputs']),
blank_removed=sum(not o['keep'] for r in records for o in r['outputs']),
review_pages=sum(bool(r['review_reasons']) for r in records),
extended_spreads=sum(any(r['extensions_mm']) for r in records))
(destination/'report.json').write_text(json.dumps(dict(summary=summary,pages=records),indent=2)+'\n')
cards = []
for row in records:
name = html.escape(row['file'])
states = ', '.join(f'{o["side"]}: {o["state"]}' for o in row['outputs'])
reasons = ', '.join(row['review_reasons']) or 'No review flag'
links = ' · '.join(f'<a href="pdfs/{quote(row["file"])}#page={o["output_page"]}">{o["side"]} PDF</a>'
for o in row['outputs'] if o['keep'])
cards.append(f'<article class="{"review" if row["review_reasons"] else "ok"}">'
f'<h2>{name} / {row["source_page"]}</h2><p>{row["format"]} | {states}</p>'
f'<p>Extensions L/R: {row["extensions_mm"]} mm | {reasons}</p><p>{links}</p>'
f'<a href="{html.escape(row["preview"])}"><img loading="lazy" src="{html.escape(row["preview"])}"></a></article>')
(destination/'index.html').write_text('<!doctype html><meta charset="utf-8">'
'<title>Automatic page splitting - review</title><style>'
'body{font:16px system-ui;margin:24px;background:#eee}main{display:grid;grid-template-columns:repeat(auto-fit,minmax(550px,1fr));gap:16px}'
'article{background:white;padding:16px}article.review{border-top:5px solid #e88b00}img{width:100%}h2{font-size:18px}p{font-size:13px}</style>'
'<h1>Automatic page splitting - experimental</h1><p>Source order; no booklet reordering. '
'Green: left output. Purple: right/whole output. Orange: centre writing. Red shading: removed blank.</p>'
f'<pre>{html.escape(json.dumps(summary,indent=2))}</pre><main>'+''.join(cards)+'</main>')
return summary
def main(argv=None) -> int:
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('--workers',type=int,default=1)
args = parser.parse_args(argv)
print(json.dumps(run(args.input,args.output,dpi=args.dpi,workers=args.workers),indent=2))
return 0
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,183 @@
"""Publish automatic splits into an evaluation, preserving sources and review data."""
from __future__ import annotations
import hashlib
import json
import shutil
import tempfile
from pathlib import Path
from copienator import CliError, ExitCode
from copienator.auto_page_split import run as analyse_batch
from copienator.copy_errors import clear_copy_error, copy_errors, mark_copy_error
from copienator.filesystem import publish_file_sets
from copienator.orientation import report_path
def decision_path(workspace, stem):
return workspace.metadata_dir / "page_splitting" / f"{stem}.json"
def load_decisions(workspace, source):
"""Only reuse decisions for the exact preserved input and published output."""
try:
data = json.loads(decision_path(workspace, source.stem).read_text())
if data["source_sha256"] != hashlib.sha256(source.read_bytes()).hexdigest():
return None
output = workspace.copies_dir / source.name
digest = (
hashlib.sha256(output.read_bytes()).hexdigest() if output.exists() else None
)
if digest != data["output_sha256"]:
return None
pages = data["pages"]
if not isinstance(pages, list) or not all(
isinstance(row, dict)
and type(row.get("source_page")) is int
and isinstance(row.get("outputs"), list)
and all(
isinstance(o, dict)
and o.get("side") in {"left", "right", "whole"}
and type(o.get("keep")) is bool
and isinstance(o.get("crop_points"), list)
and len(o["crop_points"]) == 4
for o in row["outputs"]
)
for row in pages
):
return None
return pages
except (OSError, ValueError, KeyError, TypeError, AttributeError):
return None
def run(workspace, inputs: list[Path], *, workers=4):
if workers < 1:
raise CliError(
"Le nombre de traitements parallèles doit être positif.",
ExitCode.INVALID_ARGUMENTS,
)
if not inputs:
print("Aucune copie PDF à traiter.", flush=True)
return ExitCode.SUCCESS
if len({p.name for p in inputs}) != len(inputs):
raise CliError(
"Plusieurs copies portent le même nom.", ExitCode.INVALID_ARGUMENTS
)
copy_errors(workspace) # Validate existing review metadata before publishing.
workspace.runs_dir.mkdir(parents=True, exist_ok=True)
run_dir = Path(tempfile.mkdtemp(prefix="page-split-", dir=workspace.runs_dir))
snapshots = run_dir / "inputs"
snapshots.mkdir()
for source in inputs:
shutil.copy2(source, snapshots / source.name)
result = run_dir / "result"
print(f"Découpage automatique de {len(inputs)} copie(s).", flush=True)
summary = analyse_batch(snapshots, result, workers=workers)
records = json.loads((result / "report.json").read_text())["pages"]
stages = {
name: run_dir / name for name in ("copies", "originals", "origins", "decisions")
}
for stage in stages.values():
stage.mkdir()
for source in inputs:
rows = [r for r in records if r["file"] == source.name]
expected = rows[0]["source_sha256"]
if hashlib.sha256(source.read_bytes()).hexdigest() != expected:
raise CliError(
f"{source.name} a changé pendant l’analyse. Aucun PDF remplacé."
)
shutil.copy2(snapshots / source.name, stages["originals"] / source.name)
for directory, label in (
(workspace.copies_dir, "previous_copies"),
(workspace.original_copies_dir, "previous_originals"),
):
prior = directory / source.name
if prior.exists():
backup = run_dir / label
backup.mkdir(exist_ok=True)
shutil.copy2(prior, backup / source.name)
output = result / "pdfs" / source.name
digest = (
hashlib.sha256(output.read_bytes()).hexdigest() if output.exists() else None
)
if digest:
shutil.copy2(output, stages["copies"] / source.name)
origins = [
{"origin": r["source_page"], "crop_points": o["crop_points"]}
for r in rows
for o in r["outputs"]
if o["keep"]
]
if digest:
(stages["origins"] / (source.stem + ".json")).write_text(
json.dumps({"version": 1, "sha256": digest, "pages": origins}),
encoding="utf-8",
)
(stages["decisions"] / (source.stem + ".json")).write_text(
json.dumps(
{
"version": 1,
"source_sha256": expected,
"output_sha256": digest,
"pages": rows,
}
),
encoding="utf-8",
)
names = [p.name for p in inputs]
for source in inputs:
if source.read_bytes() != (snapshots / source.name).read_bytes():
raise CliError(
f"{source.name} a changé pendant l’analyse. Aucun PDF remplacé."
)
publish_file_sets(
{
workspace.copies_dir: (stages["copies"], names),
workspace.original_copies_dir: (stages["originals"], []),
workspace.metadata_dir / "page_origins": (
stages["origins"],
[p.stem + ".json" for p in inputs],
),
workspace.metadata_dir / "page_splitting": (stages["decisions"], []),
}
)
for source in inputs:
if (
source.parent.resolve()
not in {
workspace.copies_dir.resolve(),
workspace.original_copies_dir.resolve(),
}
and source.read_bytes() == (snapshots / source.name).read_bytes()
):
# Root scans become preserved originals, as in the manual workflow.
source.unlink()
rows = [r for r in records if r["file"] == source.name]
output = workspace.copies_dir / source.name
clear_copy_error(workspace, output, source="orientation")
report_path(workspace, source.stem).unlink(missing_ok=True)
reasons = {reason for r in rows for reason in r["review_reasons"]}
if reasons or not output.exists():
mark_copy_error(
workspace,
output,
"Découpage automatique à vérifier manuellement."
if reasons
else "Toutes les pages semblent vides : vérifier l’original.",
source="page_splitter",
)
else:
clear_copy_error(workspace, output, source="page_splitter")
print(
f"Terminé : {summary['output_pages']} pages conservées, {summary['blank_removed']} pages vides retirées. "
"Ordre des pages inchangé.",
flush=True,
)
print(
f"Rapport et sauvegardes : {run_dir}\n"
"Choisissez Manuellement pour vérifier ou modifier les propositions.",
flush=True,
)
return ExitCode.SUCCESS
+100 -23
View File
@@ -27,7 +27,7 @@ from copienator import (
)
from copienator.platform import launch_pdf_arranger
from copienator.copy_errors import marked_copy_paths, clear_copy_error
from copienator.orientation import save_provenance, report_path
from copienator.orientation import save_provenance, report_path, provenance_path
# --- Constants ---
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
@@ -49,7 +49,7 @@ def _temporary_sibling(path: Path, purpose: str) -> Path:
def commit_processed_pdf(
workspace: EvaluationWorkspace,
original_path: Path,
generated_path: Path,
generated_path: Path | None,
) -> Path:
"""Commit a processed copy and its original backup with rollback."""
backup_path = workspace.original_copies_dir / original_path.name
@@ -73,8 +73,9 @@ def commit_processed_pdf(
backup_replaced = True
if output_path.exists():
output_path.replace(saved_output)
generated_path.replace(output_path)
output_replaced = True
if generated_path is not None:
generated_path.replace(output_path)
output_replaced = True
if original_path.resolve() not in {
backup_path.resolve(),
output_path.resolve(),
@@ -120,6 +121,10 @@ class PDFPreviewer:
try:
self.doc = pymupdf.open(self.pdf_path)
self.original_page_ids = {page.xref: index + 1 for index, page in enumerate(self.doc)}
from copienator.commands.automatic_page_splitter import load_decisions
saved = load_decisions(self.workspace, self.pdf_path)
self.automatic_plans = {r['source_page']: r for r in saved} if saved else {}
self.automatic_review = bool(self.automatic_plans)
except (OSError, RuntimeError, ValueError) as e:
self.failed = True
self._temporary_directory.cleanup()
@@ -176,6 +181,12 @@ class PDFPreviewer:
f"{fmt('add_secondary_splitter')}: add secondary line, {fmt('delete_secondary_splitter')}: delete newest (arrows move newest; main line red, active secondary orange)\n"
f"'{fmt('reverse_pages')}': reverse page order and restart from the new first page\n"
)
if self.automatic_review:
instructions += (
"\nPropositions automatiques : la touche suivante valide le choix affiché. "
"Croix rouge : page retirée.\n"
"Déplacer une ligne reprend le découpage manuel pour cette page."
)
self.info_label = tk.Label(master, text=instructions, justify=tk.LEFT)
self.info_label.pack(pady=5, side=tk.TOP)
@@ -255,6 +266,25 @@ class PDFPreviewer:
self.current_line_x = page.rect.width / 2
self.current_rotation = 0
self.secondary_splitters = []
origin = getattr(self, 'original_page_ids', {}).get(page.xref, self.current_page_index+1)
plan = getattr(self, 'automatic_plans', {}).get(origin)
self.current_automatic_outputs = plan['outputs'] if plan else None
def _automatic_outputs(self):
"""Express saved asymmetric crops in the current preview orientation."""
outputs = getattr(self, 'current_automatic_outputs', None)
if outputs is None:
return None
flipped = (self.current_rotation+self.file_rotation+self.global_rotation) % 360
if not flipped:
return outputs
width, height = self.doc[self.current_page_index].rect.width, self.doc[self.current_page_index].rect.height
result = []
for output in outputs:
x0,y0,x1,y1 = output['crop_points']
result.append(dict(output, side={'left':'right','right':'left'}.get(output['side'], 'whole'),
crop_points=[width-x1,height-y1,width-x0,height-y0]))
return result
def load_page(self):
"""Loads and displays the current page on the canvas, scaled to fit."""
@@ -296,9 +326,20 @@ class PDFPreviewer:
# --- Draw Line and Rotate ---
draw = ImageDraw.Draw(img)
# The line position is scaled by the same zoom factor
line_x_scaled = self.current_line_x * self.current_zoom
draw.line([(line_x_scaled, 0), (line_x_scaled, pix.height)],
fill="red", width=3)
proposed = self._automatic_outputs()
if proposed is not None:
for output in proposed:
x0,y0,x1,y1 = output['crop_points']
box = tuple(v*self.current_zoom for v in (x0,y0,x1,y1))
draw.rectangle(box, outline='green' if output['side']=='left' else 'purple', width=3)
if not output['keep']:
draw.line((box[0],box[1],box[2],box[3]), fill='red', width=3)
draw.line((box[0],box[3],box[2],box[1]), fill='red', width=3)
self.page_label.config(text=self.page_label.cget('text')+' — Proposition automatique (croix rouge : page retirée)')
else:
line_x_scaled = self.current_line_x * self.current_zoom
draw.line([(line_x_scaled, 0), (line_x_scaled, pix.height)],
fill="red", width=3)
for index, position in enumerate(self.secondary_splitters):
x = position * self.current_zoom
active = index == len(self.secondary_splitters) - 1
@@ -353,6 +394,7 @@ class PDFPreviewer:
def add_secondary_splitter(self, event=None):
"""Start a new line in the widest remaining interval of this page."""
width = self.doc.load_page(self.current_page_index).rect.width
self.current_automatic_outputs = None
boundaries = sorted({0, width, self.current_line_x, *self.secondary_splitters})
left, right = max(zip(boundaries, boundaries[1:]), key=lambda pair: pair[1] - pair[0])
self.secondary_splitters.append((left + right) / 2)
@@ -364,6 +406,7 @@ class PDFPreviewer:
self.load_page()
def _move_active_line(self, delta):
self.current_automatic_outputs = None
width = self.doc.load_page(self.current_page_index).rect.width
if self.secondary_splitters:
self.secondary_splitters[-1] = min(width, max(0, self.secondary_splitters[-1] + delta))
@@ -415,13 +458,19 @@ class PDFPreviewer:
# Reload the canvas to show the new page
self.load_page()
def confirm_and_next_page(self, event=None, keep="both"):
def confirm_and_next_page(self, event=None, keep=None):
"""Saves the settings for the current page and moves to the next."""
proposed = self._automatic_outputs()
if keep is None:
sides = {o['side'] for o in proposed if o['keep']} if proposed is not None else {'left','right'}
keep = ('as_is' if 'whole' in sides else 'both' if len(sides)==2
else next(iter(sides)) if sides else 'none')
self.page_settings.append({
"line_x": self.current_line_x,
"secondary_splitters": list(self.secondary_splitters),
"rotation": self.current_rotation,
"keep": keep
"keep": keep,
"automatic_outputs": proposed,
})
self.current_page_index += 1
@@ -442,12 +491,18 @@ class PDFPreviewer:
def finish_and_process(self) -> bool:
"""Render and transactionally install the processed PDF."""
try:
self.split_pdf()
self.reorder_pdfs()
self.concate_files()
output = commit_processed_pdf(self.workspace, self.pdf_path, self.final_file)
save_provenance(self.workspace, output, self.output_origins)
if all(s['keep']=='none' for s in self.page_settings):
self.doc.close()
output = commit_processed_pdf(self.workspace, self.pdf_path, None)
provenance_path(self.workspace, output.stem).unlink(missing_ok=True)
else:
self.split_pdf()
self.reorder_pdfs()
self.concate_files()
output = commit_processed_pdf(self.workspace, self.pdf_path, self.final_file)
save_provenance(self.workspace, output, self.output_origins)
clear_copy_error(self.workspace, output, source='orientation')
clear_copy_error(self.workspace, output, source='page_splitter')
report_path(self.workspace, output.stem).unlink(missing_ok=True)
except Exception as exc: # noqa: BLE001 - interactive boundary
self.failed = True
@@ -514,15 +569,27 @@ class PDFPreviewer:
line_x = settings['line_x']
rotation_settings = settings['rotation']
keep = settings['keep']
# Bake source rotation and the preview's extra rotation into content,
# so all cut coordinates refer to the displayed page.
page.remove_rotation()
page.set_rotation((rotation_settings + self.file_rotation + self.global_rotation) % 360)
page.remove_rotation()
# Normalize the displayed page without losing an offset CropBox.
# This matches the automatic export's coordinate system.
source_rotation = page.rotation
width, height = page.rect.width, page.rect.height
normalized = pymupdf.open()
flat = normalized.new_page(width=width, height=height)
page.set_rotation(0)
if page.get_contents():
flat.show_pdf_page(flat.rect, self.doc, i, rotate=-source_rotation)
flat.set_rotation((rotation_settings+self.file_rotation+self.global_rotation) % 360)
flat.remove_rotation()
sides = []
if keep == "as_is":
sides.append((0, width, self.split_filename_left(i)))
elif settings.get('automatic_outputs') is not None:
for output in settings['automatic_outputs']:
side = output['side']
if keep == 'both' or keep == side:
x0,_,x1,_ = output['crop_points']
filename = self.split_filename_left(i) if side=='left' else self.split_filename_right(i)
sides.append((x0,x1,filename))
else:
if keep in {"both", "left"} and line_x > 0:
sides.append((0, line_x, self.split_filename_left(i)))
@@ -539,10 +606,12 @@ class PDFPreviewer:
for x0, x1 in zip(boundaries, boundaries[1:]):
clip = pymupdf.Rect(x0, 0, x1, height)
target = output.new_page(width=clip.width, height=clip.height)
target.show_pdf_page(target.rect, self.doc, i, clip=clip)
if flat.get_contents():
target.show_pdf_page(target.rect, normalized, 0, clip=clip)
origins.append({'origin': origin, 'crop_points': list(clip)})
output.save(output_path)
self.fragment_origins[str(output_path)] = origins
normalized.close()
self.doc.close()
print(f"\nProcessing complete. Files are in '{self.split_dir}' directory.")
@@ -564,7 +633,7 @@ class PDFPreviewer:
psk = ps[i]['keep']
# Si c'est une copie double (on s'assure qu'on a bien 2 pages consécutives modifiables)
if psk in ["both", "right", "left", "none"] and i < len(ps)-1 and ps[i+1]['keep'] in ["both", "right", "left", "none"]:
if not getattr(self, 'automatic_review', False) and psk in ["both", "right", "left", "none"] and i < len(ps)-1 and ps[i+1]['keep'] in ["both", "right", "left", "none"]:
# 1. Page de garde (Extérieur Droit)
if ps[i]['keep'] in ["both", "right"]:
@@ -678,11 +747,17 @@ def _selected_inputs(
]
else:
candidates = list_pdf_files(directory, workspace)
if target == workspace.root and not candidates:
candidates = list_pdf_files(workspace.original_copies_dir, workspace)
return list(reversed(candidates))
def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False) -> ExitCode:
def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False,
automatic: bool = False, workers: int = 4) -> ExitCode:
inputs = list(reversed(marked_copy_paths(workspace, originals=True))) if marked else _selected_inputs(workspace, target)
if automatic:
from copienator.commands.automatic_page_splitter import run as run_automatic
return run_automatic(workspace, list(reversed(inputs)), workers=workers)
if not inputs:
print(f"No PDF files found in {target}")
return ExitCode.SUCCESS
@@ -695,6 +770,8 @@ def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False) -
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Interactively split and reorder scanned PDF pages")
parser.add_argument("--marked", action="store_true", help="Process only copies flagged during margin review")
parser.add_argument("--automatic", action="store_true", help="Split automatically and save editable proposals without reordering")
parser.add_argument("--workers", type=int, default=4, help="Copies analysed simultaneously in automatic mode")
return parser
@@ -703,7 +780,7 @@ def main(argv: Sequence[str] | None = None) -> int:
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target, marked=args.marked)
return run(workspace, target, marked=args.marked, automatic=args.automatic, workers=args.workers)
return execute(parser, argv, handle)
+3 -3
View File
@@ -20,7 +20,7 @@ def _validate(value) -> dict[str, dict[str, str]]:
if isinstance(flags, str):
flags = {"cutleft": flags}
if (not isinstance(flags, dict) or not flags
or any(step not in {"cutleft", "plotting", "orientation"} or not isinstance(reason, str)
or any(step not in {"cutleft", "plotting", "orientation", "page_splitter"} or not isinstance(reason, str)
for step, reason in flags.items())):
raise CliError("Invalid copy_errors.json: invalid step flags")
normalized[name] = dict(flags)
@@ -35,7 +35,7 @@ def copy_errors(workspace: EvaluationWorkspace) -> dict[str, str]:
def mark_copy_error(workspace: EvaluationWorkspace, pdf: Path, reason: str,
*, source: str = "cutleft") -> None:
if source not in {"cutleft", "plotting", "orientation"}:
if source not in {"cutleft", "plotting", "orientation", "page_splitter"}:
raise ValueError(f"Unknown copy flag source: {source}")
def update(errors):
normalized = _validate(errors)
@@ -47,7 +47,7 @@ def mark_copy_error(workspace: EvaluationWorkspace, pdf: Path, reason: str,
def clear_copy_error(workspace: EvaluationWorkspace, pdf: Path,
*, source: str = "cutleft") -> None:
if source not in {"cutleft", "plotting", "orientation"}:
if source not in {"cutleft", "plotting", "orientation", "page_splitter"}:
raise ValueError(f"Unknown copy flag source: {source}")
if not _path(workspace).exists():
return
+276
View File
@@ -0,0 +1,276 @@
"""Standard coarse-to-fine splitting: cheap positive content gate, precise fold.
The whole-page gate only establishes obvious writing. All uncertain/sparse pages
use the reference blank detector. Full-resolution fold processing is restricted
to a central strip; edge-reaching overflow and ambiguous ownership fall back to
the complete reference analysis. Full-spread analysis remains an internal fallback.
"""
from __future__ import annotations
import cv2
import numpy as np
import pymupdf
from copienator.auto_page_split import analyse_ink, _analyse_page_full, plan_masks
def quick_page_content(rgb, dpi, target_dpi=75, *, native=True, _fallback=None):
"""Positive-only coarse gate. Ambiguous/sparse pages use full detection."""
h, w = rgb.shape[:2]
small = cv2.resize(
rgb,
(round(w * target_dpi / dpi), round(h * target_dpi / dpi)),
interpolation=cv2.INTER_AREA,
)
px = target_dpi / 25.4
lo = small.min(axis=2)
ch = small.max(axis=2) - lo
gray = cv2.cvtColor(small, cv2.COLOR_RGB2GRAY)
strong = (ch > 100) | (gray < 95)
n, lab, stats, _ = cv2.connectedComponentsWithStats(strong.astype("uint8"), 8)
ok = np.zeros(n, bool)
x, y, bw, bh, area = stats[1:].T
# Only compact interior pen components can establish an obvious written page.
ok[1:] = (
(area >= 0.25 * px * px)
& (bw >= 0.35 * px)
& (bh >= 0.5 * px)
& (bw < 20 * px)
& (bh < 15 * px)
& (x > 0.09 * small.shape[1])
& (x + bw < 0.91 * small.shape[1])
)
selected = ok[lab]
grouped = cv2.dilate(
selected.astype("uint8"),
np.ones((max(1, round(px)), max(1, round(3 * px))), np.uint8),
)
ng, groups, gs, _ = cv2.connectedComponentsWithStats(grouped, 8)
comp_group = np.zeros(n, np.int32)
ys, xs = np.nonzero(selected)
comp_group[lab[ys, xs]] = groups[ys, xs]
counts = np.bincount(comp_group[ok], minlength=ng)
amounts = np.bincount(groups[selected], minlength=ng)
good = (
(counts >= 4)
& (amounts >= 3 * px * px)
& (gs[:, 2] >= 8 * px)
& (gs[:, 3] >= 2 * px)
)
good[0] = False
if good.any():
# Retain all strong ink for ownership support; the gate alone is interior-only.
return (
strong
if native
else cv2.resize(
strong.astype("uint8"), (w, h), interpolation=cv2.INTER_NEAREST
)
> 0
), True
return (_fallback() if _fallback is not None else analyse_ink(rgb, dpi)[0]), False
def _a4_plan(mask, gate, h, w, dpi, gate_dpi):
count = int(mask.sum())
return dict(
format="a4",
size_px=[w, h],
outputs=[
dict(
side="whole",
rect_px=[0, 0, w, h],
state="content" if count else "blank",
keep=bool(count),
ink_pixels=count,
content_dpi=gate_dpi if gate else dpi,
)
],
crossings=[],
review_reasons=[],
extensions_mm=[0, 0],
analysis=dict(
mode="fast",
early_written=[gate],
fold_fraction=0,
gate_dpi=gate_dpi,
fallback=None,
),
)
def analyse_page_fast(rgb, dpi, kind, *, fold_fraction=0.1, gate_dpi=75):
"""Fast analysis for an existing raster image."""
if kind not in {"a3", "a4"}:
return _analyse_page_full(rgb, dpi, kind)
gate_dpi = min(gate_dpi, dpi)
h, w = rgb.shape[:2]
if kind == "a4":
mask, gate = quick_page_content(rgb, dpi, gate_dpi, native=True)
return _a4_plan(mask, gate, h, w, dpi, gate_dpi)
middle = w // 2
halves = []
early = []
for a, b in ((0, middle), (middle, w)):
mask, gate = quick_page_content(rgb[:, a:b], dpi, gate_dpi, native=True)
halves.append(mask)
early.append(gate)
return _assemble_fast(
halves,
early,
h,
w,
dpi,
fold_fraction,
gate_dpi,
lambda x0, x1: rgb[:, x0:x1],
lambda: _analyse_page_full(rgb, dpi, kind),
)
def _assemble_fast(
halves, early, h, w, dpi, fold_fraction, gate_dpi, render_roi, reference
):
middle = w // 2
half = max(1, round(w * fold_fraction / 2))
x0 = max(0, middle - half)
x1 = min(w, middle + half)
roi = render_roi(x0, x1)
content = np.zeros(roi.shape[:2], bool)
for i, (a, b) in enumerate(((0, middle), (middle, w))):
lo = max(a, x0)
hi = min(b, x1)
mask = halves[i]
ys = np.arange(h) * mask.shape[0] // h
xs = (np.arange(lo, hi) - a) * mask.shape[1] // (b - a)
content[:, lo - x0 : hi - x0] = mask[ys[:, None], xs[None, :]]
ink, _ = analyse_ink(roi, dpi, spread=True)
remaining = {}
plan = plan_masks(
ink, dpi, "a3", content_ink=content, rgb=roi, _remaining_content=remaining
)
# If a proposed overflow reaches the strip's edge or ownership remains
# ambiguous, use the complete reference analysis rather than truncate it.
margin = round(2 * dpi / 25.4)
if (
plan["review_reasons"]
or plan["outputs"][0]["rect_px"][2] >= x1 - x0 - margin
or plan["outputs"][1]["rect_px"][0] <= margin
):
plan = reference()
plan["analysis"] = dict(mode="fast", fallback="full-spread")
return plan
for i, (a, b) in enumerate(((0, middle), (middle, w))):
mask = halves[i]
side = "left" if i == 0 else "right"
if not early[i]:
lo = max(a, x0)
hi = min(b, x1)
mask[:, lo - a : hi - a] = remaining[side]
count = int(mask.sum())
else:
# Exact count of the implied nearest-neighbour full-resolution mask,
# without allocating it. Coarse masks establish obvious content only.
wy = np.diff(
np.ceil(np.arange(mask.shape[0] + 1) * h / mask.shape[0]).astype(
"int64"
)
)
wx = np.diff(
np.ceil(np.arange(mask.shape[1] + 1) * (b - a) / mask.shape[1]).astype(
"int64"
)
)
count = int(np.sum(mask * wy[:, None] * wx[None, :]))
o = plan["outputs"][i]
o["ink_pixels"] = count
o["keep"] = count > 0
o["state"] = "content" if count else "blank"
o["rect_px"] = (
[0, 0, o["rect_px"][2] + x0, h]
if i == 0
else [o["rect_px"][0] + x0, 0, w, h]
)
for c in plan["crossings"]:
c["box"][0] += x0
c["box"][2] += x0
for box in c.get("anchor_boxes", []):
if box is not None:
box[0] += x0
box[2] += x0
plan["size_px"] = [w, h]
plan["analysis"] = dict(
mode="fast",
gate_dpi=gate_dpi,
fold_fraction=fold_fraction,
early_written=early,
fallback=None,
)
return plan
def analyse_pdf_page_fast(page, dpi, kind, *, fold_fraction=0.1, gate_dpi=75):
"""Render a small overview and the precise fold directly from the PDF.
Full-resolution halves are rendered only when the positive overview gate
cannot establish writing. Raster clips are aligned to the same integer
pixel grid as a complete reference render, including rotated CropBoxes.
"""
gate_dpi = min(gate_dpi, dpi)
bounds = (page.rect * pymupdf.Matrix(dpi / 72, dpi / 72)).irect
h, w = bounds.height, bounds.width
coarse_pix = page.get_pixmap(
dpi=round(gate_dpi), colorspace=pymupdf.csRGB, alpha=False
)
coarse = np.frombuffer(coarse_pix.samples, np.uint8).reshape(
coarse_pix.height, coarse_pix.width, 3
)
def render(x0, x1):
rect = pymupdf.Rect(x0 * 72 / dpi, 0, x1 * 72 / dpi, page.rect.height)
pix = page.get_pixmap(
dpi=round(dpi), clip=rect, colorspace=pymupdf.csRGB, alpha=False
)
data = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3)
out = np.full((h, x1 - x0, 3), 255, np.uint8)
left = max(x0, pix.x)
top = max(0, pix.y)
right = min(x1, pix.x + pix.width)
bottom = min(h, pix.y + pix.height)
out[top:bottom, left - x0 : right - x0] = data[
top - pix.y : bottom - pix.y, left - pix.x : right - pix.x
]
return out
def reference():
return _analyse_page_full(render(0, w), dpi, kind)
if kind not in {"a3", "a4"}:
return reference(), coarse
if kind == "a4":
mask, gate = quick_page_content(
coarse,
gate_dpi,
gate_dpi,
native=True,
_fallback=lambda: analyse_ink(render(0, w), dpi)[0],
)
return _a4_plan(mask, gate, h, w, dpi, gate_dpi), coarse
middle = w // 2
cm = coarse.shape[1] // 2
halves = []
early = []
for a, b, ca, cb in ((0, middle, 0, cm), (middle, w, cm, coarse.shape[1])):
mask, gate = quick_page_content(
coarse[:, ca:cb],
gate_dpi,
gate_dpi,
native=True,
_fallback=lambda a=a, b=b: analyse_ink(render(a, b), dpi)[0],
)
halves.append(mask)
early.append(gate)
plan = _assemble_fast(
halves, early, h, w, dpi, fold_fraction, gate_dpi, render, reference
)
plan["analysis"]["rendering"] = "direct-pdf-roi"
return plan, coarse
+192 -5
View File
@@ -45,12 +45,15 @@ def _paper_blur(gray: np.ndarray, sigma: float) -> np.ndarray:
return blurred.astype(np.uint8)
def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float):
def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float, *,
content_only: bool = False):
"""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.
Crop refinement is limited to already-confirmed ruled paper. Content-only
analysis also uses this stroke filter on seedless plain pages and can return
an empty mask; the normal crop path deliberately preserves its input then.
"""
px = dpi / 25.4
height, width = gray.shape
@@ -120,6 +123,14 @@ def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float):
overlap = np.bincount(lab[strong_ruling > 0], minlength=count)
bars &= overlap < st[:, 4]*.5
refined |= bars[lab]
if content_only:
# A crop must preserve its input when there is no reliable content.
# A blank-page classifier needs that empty result instead. Recover the
# same nearby original strokes without restoring remote paper artifacts.
radius = max(1, round(2*px))
nearby = cv2.dilate(refined.astype(np.uint8),
cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(2*radius+1,2*radius+1))) > 0
return refined | ((clean > 0) & nearby), len(holes), True
ys = np.flatnonzero(np.any(refined, axis=1))
original = np.flatnonzero(np.any(clean, axis=1))
if not len(ys) or not len(original):
@@ -138,8 +149,171 @@ def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float):
return result, len(holes), changed
def _blank_content_support(content: np.ndarray, dpi: float) -> tuple[np.ndarray, dict]:
"""Require a glyph, neighbouring strokes, or a substantial diagram.
A few remote dots and a short loose curve do not establish a written page.
Grouping protects fragmented pencil letters without a page-wide ink quota.
This evidence filter is for blank classification, never margin cropping.
"""
px = dpi/25.4
n,labels,stats,_ = cv2.connectedComponentsWithStats(content.astype(np.uint8),8)
if n == 1:
return content,dict(content_supported_groups=0,content_unsupported_pixels=0)
radius = max(1,round(1.5*px))
joined = cv2.dilate(content.astype(np.uint8),
cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(2*radius+1,2*radius+1)))
_,groups = cv2.connectedComponents(joined,8)
ys,xs = np.nonzero(content)
component_groups = np.zeros(n,np.int32)
component_groups[labels[ys,xs]] = groups[ys,xs]
area = stats[:,4]
bw,bh = stats[:,2],stats[:,3]
density = area/np.maximum(1,bw*bh)
letter = ((area >= .8*px*px) & (np.minimum(bw,bh) >= .6*px)
& (density >= .18))
# A dense sub-2 mm ink spot is not a glyph on its own. Keep it eligible
# for a supported group (decimal points, dots over letters, punctuation).
compact_spot = np.zeros(n,bool)
spot_candidates = np.flatnonzero((density > .55) & (np.maximum(bw,bh) < 2*px))
for i in spot_candidates:
if i == 0:
continue
x,y,ww,hh,_ = stats[i]
yy,xx = np.nonzero(labels[y:y+hh,x:x+ww] == i)
hull_area = cv2.contourArea(cv2.convexHull(np.c_[xx,yy].astype(np.int32)))
# Small x's have empty wedges inside their convex hull; solid spots
# fill it. Pixel-centre hull area can be smaller than the pixel count.
compact_spot[i] = area[i] >= .9*max(1.,hull_area)
letter &= ~compact_spot
substantial = (area >= 4*px*px) & (np.maximum(bw,bh) >= 5*px)
# A lone short smear at the physical top/bottom edge can pass both area
# tests. Require its pixels to be almost collinear before discounting it;
# normal digits, letters and closed diagrams have broader stroke geometry.
# Neighbouring meaningful strokes still support it through the group rule.
near_edge = (stats[:,1] < 15*px) | (stats[:,1]+bh > content.shape[0]-15*px)
edge_smear = np.zeros(n,bool)
candidates = np.flatnonzero(near_edge & (area < 6*px*px)
& (np.maximum(bw,bh) < 8*px)
& (np.minimum(bw,bh) > 2*px) & (density < .35))
for i in candidates:
if i == 0:
continue
x,y,ww,hh,_ = stats[i]
yy,xx = np.nonzero(labels[y:y+hh,x:x+ww] == i)
values = np.linalg.eigvalsh(np.cov(np.c_[xx,yy].T))
edge_smear[i] = values[0] < .05*values[1]
letter &= ~edge_smear
substantial &= ~edge_smear
credible = area >= .2*px*px
credible[0] = False
counts = np.bincount(component_groups[credible],minlength=int(groups.max())+1)
totals = np.bincount(component_groups,weights=area,minlength=len(counts))
supported = (counts >= 2) & (totals >= 1.2*px*px)
supported[component_groups[letter | substantial]] = True
supported[0] = False
retained = supported[groups] & content
return retained,dict(content_supported_groups=int(supported.sum()),
content_unsupported_pixels=int(content.sum()-retained.sum()),
content_compact_spots=int(compact_spot[1:].sum()),
content_edge_smears=int(edge_smear.sum()))
def detect_page_content(rgb: np.ndarray, dpi: float = 200) -> tuple[np.ndarray, dict]:
"""Expose rognage's content evidence on a single logical page.
Normal crop behaviour stays unchanged. On confirmed ruled paper, use its
large-blank refinement even when that refinement finds no content, instead of
the crop-specific fallback that keeps the original page. No separate weak-mark
detector is used. The returned mask is in the input image coordinates.
"""
content_masks = {}
paper_geometry = {}
result = detect_bounds(rgb,dpi,content_masks=content_masks,_paper_geometry=paper_geometry)
content = content_masks['foreground']
gray = cv2.cvtColor(rgb,cv2.COLOR_RGB2GRAY)
# Use the same deskew and paper-family confirmation as the crop pipeline.
h,w = gray.shape
if paper_geometry:
matrix,size,deskewed = (paper_geometry[key] for key in ('matrix','size','deskewed'))
else:
angle = _skew(gray,angle_step=.5)
matrix = cv2.getRotationMatrix2D((w/2,h/2),angle,1)
corners = cv2.transform(np.array([[[0,0],[w,0],[0,h],[w,h]]],float),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)
binary = cv2.adaptiveThreshold(deskewed,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV,max(15,int(dpi/5)|1),9)
horizontal,nh = _ruling(binary,True)
vertical,nv = _ruling(binary,False)
confirmed = ((nh and _edge_connected_family(binary,horizontal,True)) or
(nv and _edge_connected_family(binary,vertical,False)))
if not confirmed:
# Slight local warp breaks one-pixel pale grid lines. Confirm the same
# periodic, edge-reaching family with a small physical-width tolerance.
tolerance = max(3,round(.3*dpi/25.4)|1)
tolerant = cv2.dilate(binary,np.ones((tolerance,tolerance),np.uint8))
th,tnh = _ruling(tolerant,True)
tv,tnv = _ruling(tolerant,False)
confirmed = ((tnh and _edge_connected_family(tolerant,th,True)) or
(tnv and _edge_connected_family(tolerant,tv,False)))
result['content_tolerant_ruling'] = bool(confirmed)
# With no seeds, the same coherent-stroke filter can recover pencil-only
# writing on plain paper. Arbitrary weak pixels alone are not a veto.
refine = bool(confirmed) or not content.any()
result['content_refinement'] = refine
result['content_ruling_confirmed'] = bool(confirmed)
if refine:
clean = cv2.warpAffine(content.astype(np.uint8),matrix,tuple(size),
flags=cv2.INTER_NEAREST,borderValue=0)
refined,holes,_ = _large_blank_ink(deskewed,clean,dpi,content_only=True)
content = cv2.warpAffine(refined.astype(np.uint8),cv2.invertAffineTransform(matrix),
(w,h),flags=cv2.INTER_NEAREST,borderValue=0)>0
result['content_holes'] = holes
result['content_pixels'] = int(content.sum())
if not confirmed and not content.any():
# The directional refinement can erase very thin pencil letters on
# plain paper. Reuse the crop pipeline's adaptive foreground instead,
# requiring coherent letter-sized components rather than arbitrary
# weak pixels. This fallback is confined to seedless/plain pages.
weak = _foreground(gray,dpi,9,np.zeros_like(gray))
weak,_ = _content_mask(weak,dpi)
count,labels,stats,_ = cv2.connectedComponentsWithStats(weak,8)
keep = np.zeros(count,bool)
px = dpi/25.4
keep[1:] = ((stats[1:,4] >= .5*px*px)
& (stats[1:,2] >= px) & (stats[1:,3] >= px))
# Faint scanner borders can be one huge connected component. They are
# not coherent writing merely because their bounding box is large.
x,y,bw,bh,area = stats[1:].T
sparse = area / np.maximum(1,bw*bh) < .1
edge_frame = (((bw > .9*w) & ((y < px) | (y+bh > h-px)))
| ((bh > .9*h) & ((x < px) | (x+bw > w-px))))
edge_frame |= ((bw > .8*w) & ((x < 2*px) | (x+bw > w-2*px)))
keep[1:] &= ~(sparse & edge_frame)
# A faint narrow scanner-edge band is not a pencil letter. Require
# both edge placement and pale neutral appearance, not position alone.
for i in np.flatnonzero(keep[1:])+1:
xx,yy,ww,hh,_ = stats[i]
if ww < 3*px and (xx < 2*px or xx+ww > w-2*px) and hh > ww:
values = rgb[yy:yy+hh,xx:xx+ww][labels[yy:yy+hh,xx:xx+ww]==i]
if np.median(values.min(axis=1)) > 205 and np.median(np.ptp(values.astype(np.int16),axis=1)) < 30:
keep[i] = False
content = keep[labels]
result['content_pixels'] = int(content.sum())
result['content_pencil_recovery'] = bool(content.any())
content,support = _blank_content_support(content,dpi)
result.update(support)
result['content_pixels'] = int(content.sum())
result['content_state'] = 'content' if content.any() else 'blank'
return content,result
def _neutral_paper_foreground(gray: np.ndarray, chroma: np.ndarray, dpi: float,
*, diagnostics: dict | None = None):
*, diagnostics: dict | None = None,
geometry: dict | None = None):
"""Clean confirmed dark ruling before it can seed whole pages.
Returns None for ordinary ink components or unconfirmed paper geometry.
@@ -163,6 +337,8 @@ def _neutral_paper_foreground(gray: np.ndarray, chroma: np.ndarray, dpi: float,
size = np.ceil(corners.max(axis=0)-origin).astype(int)
matrix[:, 2] -= origin
deskewed = cv2.warpAffine(gray, matrix, tuple(size), borderValue=255)
if geometry is not None:
geometry.update(matrix=matrix,size=size,deskewed=deskewed)
background = _paper_blur(deskewed, dpi/8)
normalized = cv2.divide(deskewed, np.maximum(background,1), scale=255)
block = max(15, int(dpi/5) | 1)
@@ -220,7 +396,9 @@ def _seed_mask(mask: np.ndarray, px: float, *, paper_nearby=None,
def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
min_crop_mm: float = 5, *, diagnostics: dict | None = None,
colour_cleanup: bool = False, edge_mark_cleanup: bool = True) -> dict:
colour_cleanup: bool = False, edge_mark_cleanup: bool = True,
content_masks: dict | None = None,
_paper_geometry: dict | None = None) -> dict:
"""Locate strong ink, recover adjacent faint strokes, and retain padding.
Neutral punched-hole shadows usually have neither sufficient chroma nor
@@ -237,6 +415,9 @@ def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
Optional diagnostics are written to a separate caller-owned dictionary.
Its masks use the original image coordinates and never influence decisions
or enter the JSON-serializable return value.
content_masks requests only foreground and weak-candidate masks, avoiding
diagnostic component explanations and intermediate paper-mask exports.
"""
px = dpi/25.4
h, w = rgb.shape[:2]
@@ -259,7 +440,8 @@ def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
diagnostics['masks'] = {}
diagnostics['thresholds'] = []
masks = diagnostics['masks'] if diagnostics is not None else None
cleaned, paper_info = _neutral_paper_foreground(gray, chroma, dpi, diagnostics=masks)
cleaned, paper_info = _neutral_paper_foreground(gray, chroma, dpi, diagnostics=masks,
geometry=_paper_geometry)
if cleaned is not None:
# Adaptive neutral cleanup includes faint paper fragments. Such pixels
# need local contrast or substantial darkness before they seed a crop.
@@ -279,6 +461,9 @@ def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
masks['isolated_edge_marks'] = np.zeros((h,w), bool)
weak = ((chroma > 60) | ((gray < 175) & (residual > 25))).astype(np.uint8)
weak[suppressed] = 0
if content_masks is not None:
content_masks.clear()
content_masks.update(foreground=np.zeros((h,w),bool),weak_candidates=weak>0)
if masks is not None:
masks['strong_seeds'] = np.zeros((h,w), bool)
masks['foreground'] = np.zeros((h,w), bool)
@@ -329,6 +514,8 @@ def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
masks['isolated_edge_marks'] |= discarded[labels]
seeded_labels = remaining
retained = seeded_labels[labels]
if content_masks is not None:
content_masks['foreground'] |= retained
if masks is not None:
masks['strong_seeds'] |= seeds & retained
ys = np.flatnonzero(np.any(retained, axis=1))
+9 -6
View File
@@ -93,7 +93,6 @@ def ruling_suppression(rgb: np.ndarray, dpi: float):
matrix[:,2] -= origin
image = cv2.warpAffine(rgb, matrix, tuple(size), borderValue=(255,255,255))
dark = 255-image
dark16 = dark.astype(np.int16)
darkness = _maximum(dark)
binary = cv2.adaptiveThreshold(_minimum(image), 255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV,
@@ -119,7 +118,7 @@ def ruling_suppression(rgb: np.ndarray, dpi: float):
support = _maximum(model) > 9
if count:
support &= bands > 0
close = _maximum(np.abs(dark16-model.astype(np.int16))) < 40
close = _maximum(cv2.absdiff(dark,model)) < 40
sample = support & (darkness > 9) & close & ~thick
# Neutral, red, green and blue line families are modelled separately so
# a red margin rule cannot be mistaken for unusually dark blue writing.
@@ -128,7 +127,10 @@ def ruling_suppression(rgb: np.ndarray, dpi: float):
classes[second <= third] = 1
classes[(first <= second) & (first <= third)] = 0
classes[cv2.subtract(_maximum(model),_minimum(model)) < 25] = 3
credible = np.zeros(binary.shape, bool)
# Rejected classes have ceiling -1, below every uint8 darkness value.
# Compute each class ceiling once, then test pixels by lookup instead
# of subtracting and splitting full RGB images for every colour class.
ceilings = np.full((4,3),-1,np.int16)
for colour in range(4):
selected = sample & (classes == colour)
if selected.sum() < 100:
@@ -143,9 +145,10 @@ def ruling_suppression(rgb: np.ndarray, dpi: float):
# An isolated long fraction bar must not define its own paper colour.
# The upper quantile admits darker major rules within an established
# family while rejecting excess ink on most of its pixels.
ceiling = np.floor(np.quantile(dark[selected],.99,axis=0)+25).astype(np.int16)
credible |= ((classes == colour)
& (_maximum(dark16-ceiling) <= 0))
ceilings[colour] = np.floor(np.quantile(dark[selected],.99,axis=0)+25).astype(np.int16)
credible = ((dark[:,:,0] <= ceilings[classes,0])
& (dark[:,:,1] <= ceilings[classes,1])
& (dark[:,:,2] <= ceilings[classes,2]))
match = close & credible & (darkness > 9) & ~thick
removed |= support & match
protected |= support & ~match & (binary > 0)