Bundle the trained heatmap/image orientation model and run it after provisional blank-page and split analysis. Confirm candidates with Gemini using low thinking; resolve mixed A3 sheet ordering with the complete statement and medium thinking. Keep A4 scans independent and export paired A3 scans in booklet reading order. Reanalyse uniformly inverted source sheets, validate ordering permutations, and preserve source provenance, corrected rotations and reading slots in manual review. Document the experiments, rejected approaches, calibration and runtime limits. Validation: focused splitting/orientation regression suites pass; bundled model checksum and wheel assets verified. Full Interro07 offline run: 44.26 seconds, eight candidates, mocked upright confirmations, source hashes and geometry intact.
409 lines
21 KiB
Python
409 lines
21 KiB
Python
"""Experimental, non-destructive spread splitting with an HTML review gallery.
|
|
|
|
python -m copienator.auto_page_split INPUT OUTPUT [--workers 4]
|
|
Geometry-only by default; --orientation confirms inversions and orders A3 sheets.
|
|
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) -> list[dict]:
|
|
source_s, destination_s, dpi = job[:3]
|
|
orientation = len(job) > 3
|
|
statement = job[3] if orientation else None
|
|
source, destination = Path(source_s), Path(destination_s)
|
|
cv2.setNumThreads(1)
|
|
digest = hashlib.sha256(source.read_bytes()).hexdigest()
|
|
records, overviews = [], []
|
|
with pymupdf.open(source) as doc, pymupdf.open() as result:
|
|
from copienator.fast_page_split import analyse_pdf_page_fast
|
|
for i, page in enumerate(doc):
|
|
kind = classify_format(page.rect.width, page.rect.height)
|
|
plan, rgb = analyse_pdf_page_fast(page, dpi, kind)
|
|
plan.update(file=source.name, source_page=i+1, source_sha256=digest,
|
|
source_rotation=page.rotation, source_cropbox=list(page.cropbox),
|
|
preview=f'review/{source.stem}-{i+1:03}.jpg')
|
|
records.append(plan)
|
|
overviews.append(rgb)
|
|
order = [(i,j) for i,r in enumerate(records) for j in range(len(r['outputs']))]
|
|
if orientation:
|
|
from copienator.split_orientation import resolve
|
|
if statement:
|
|
from copienator import configuration
|
|
configuration.use_evaluation(Path(statement).parent)
|
|
order = resolve(doc, records, overviews, dpi, statement=statement)
|
|
for position, (i,j) in enumerate(order, 1):
|
|
records[i]['outputs'][j]['reading_order'] = position
|
|
for i, plan in enumerate(records):
|
|
page = doc[i]
|
|
save_preview(overviews[i], plan, destination/plan['preview'])
|
|
pixel_width, pixel_height = plan['size_px']
|
|
rotation = page.rotation
|
|
w,h = page.rect.width,page.rect.height
|
|
with pymupdf.open() as normalized:
|
|
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)
|
|
if flat.get_contents():
|
|
target.show_pdf_page(target.rect,normalized,0,clip=clip)
|
|
if output.get('rotation'):
|
|
target.set_rotation(output['rotation'])
|
|
target.remove_rotation()
|
|
output['output_page'] = len(result)
|
|
kept = [records[i]['outputs'][j] for i,j in order if records[i]['outputs'][j]['keep']]
|
|
if len(result):
|
|
result.select([o['output_page']-1 for o in kept])
|
|
for number, output in enumerate(kept, 1):
|
|
output['output_page'] = number
|
|
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,
|
|
orientation: bool = False, statement: Path | None = None) -> 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]
|
|
if orientation:
|
|
jobs = [(*job, str(statement) if statement else None) for job in jobs]
|
|
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))
|
|
if any('sheet_source_pages' in r for r in records):
|
|
initial = [o for r in records for o in r.get('orientation_before', r['outputs'])]
|
|
summary.update(
|
|
orientation_candidates=sum(bool(o.get('orientation', {}).get('flag')) for o in initial),
|
|
orientation_confirmations=sum('confirmation' in o.get('orientation', {}) for o in initial),
|
|
turned_source_faces=sum(bool(r.get('applied_rotation')) for r in records),
|
|
turned_logical_pages=sum(bool(o.get('rotation')) for r in records for o in r['outputs']),
|
|
gemini_sheet_orders=sum(bool(r.get('sheet_order')) and r['source_page']==min(r['sheet_source_pages'])
|
|
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>Output page links show the exported reading order. '
|
|
'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)
|
|
parser.add_argument('--orientation', action='store_true', help='Confirm orientation and reorder A3 sheets')
|
|
parser.add_argument('--statement', type=Path, help='Full énoncé PDF for mixed A3 ordering')
|
|
args = parser.parse_args(argv)
|
|
print(json.dumps(run(args.input,args.output,dpi=args.dpi,workers=args.workers,
|
|
orientation=args.orientation,statement=args.statement),indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|