Detect and confirm page orientation during automatic splitting

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.
This commit is contained in:
sebastien committed 2026-11-04 21:07:20 +01:00
1 parent 78f3e4b2f0
commit 0a4f4a80d0
21 files changed
+1574 -51

No files matched your search

+68 -37
View File
@@ -1,8 +1,8 @@
"""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.
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
@@ -253,47 +253,62 @@ def save_preview(rgb: np.ndarray, plan: dict, path: Path) -> None:
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
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 = []
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):
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, rgb = analyse_pdf_page_fast(page, dpi, kind)
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)
source_rotation=page.rotation, source_cropbox=list(page.cropbox),
preview=f'review/{source.stem}-{i+1:03}.jpg')
records.append(plan)
normalized.close()
# An entirely blank copy has no output PDF; its report is still kept.
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}')
@@ -306,7 +321,8 @@ def process_pdf(job: tuple[str, str, int]) -> list[dict]:
return records
def run(source: Path, destination: Path, *, dpi: int = 200, workers: int = 1) -> dict:
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]
@@ -317,6 +333,8 @@ def run(source: Path, destination: Path, *, dpi: int = 200, workers: int = 1) ->
(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)
@@ -340,6 +358,16 @@ def write_report(records: list[dict], destination: Path, file_count: int) -> dic
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:
@@ -356,7 +384,7 @@ def write_report(records: list[dict], destination: Path, file_count: int) -> dic
'<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. '
'<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
@@ -368,8 +396,11 @@ def main(argv=None) -> int:
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),indent=2))
print(json.dumps(run(args.input,args.output,dpi=args.dpi,workers=args.workers,
orientation=args.orientation,statement=args.statement),indent=2))
return 0