Files
Copies/tests/test_split_orientation.py
T
sebastien 0a4f4a80d0 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.
2026-11-04 21:07:20 +01:00

291 lines
15 KiB
Python

import hashlib
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
import numpy as np
import pymupdf
from pydantic import ValidationError
from copienator.auto_page_split import run
from copienator.split_orientation import GeminiVerifier, PageOrder, sheets, validate_order
class SheetOrientationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.source = self.root/'copy.pdf'
self.statement = self.root/'enonce.pdf'
with pymupdf.open() as d:
d.new_page().insert_text((40,40),'Full exam statement')
d.save(self.statement)
self.analyses = []
def create(self, formats=('a3','a3'), rotations=None, mixed=False):
with pymupdf.open() as d:
for i,kind in enumerate(formats):
p = d.new_page(width=1190 if kind=='a3' else 595,height=842)
if kind=='a3':
p.insert_text((180,280) if mixed and i==0 else (50,70),
str(4 if i==0 else 2), fontsize=24,
rotate=180 if mixed and i==0 else 0)
p.insert_text((650,70),str(1 if i==0 else 3),fontsize=24)
else:
p.insert_text((50,70),f'A4-{i}',fontsize=24)
p.set_rotation((rotations or [0]*len(formats))[i])
d.save(self.source)
self.original = self.source.read_bytes()
def analyse(self, page, dpi, kind, *, blank=()):
self.analyses.append((page.number,page.rotation))
w = 200 if kind=='a3' else 100
outputs = []
for j,side in enumerate(['left','right'] if kind=='a3' else ['whole']):
keep = (page.number,j) not in blank
outputs.append(dict(side=side, keep=keep, state='content' if keep else 'blank',
rect_px=[j*100,0,(j+1)*100,100]))
return dict(format=kind,size_px=[w,100],outputs=outputs,crossings=[],
extensions_mm=[0,0],review_reasons=[]),np.full((100,w,3),255,np.uint8)
def execute(self, flags, verdicts=(), permutation=None, blank=()):
screen = Mock()
screen.predict.side_effect = [dict(flag=f,score=.8 if f else .1,threshold=.3)for f in flags]
verifier = Mock()
verifier.orientation.side_effect = list(verdicts)
verifier.order.return_value = permutation
with (patch('copienator.fast_page_split.analyse_pdf_page_fast',
side_effect=lambda p,d,k:self.analyse(p,d,k,blank=blank)),
patch('copienator.split_orientation.detector',return_value=screen),
patch('copienator.split_orientation.GeminiVerifier',return_value=verifier)):
summary = run(self.source,self.root/'result',dpi=100,orientation=True,statement=self.statement)
self.assertEqual(self.source.read_bytes(),self.original)
report = json.loads((self.root/'result/report.json').read_text())
return summary,report,screen,verifier
def texts(self):
with pymupdf.open(self.root/'result/pdfs/copy.pdf')as d:
return [p.get_text().strip()for p in d]
def test_upright_a3_booklet_order_and_no_requests(self):
self.create()
_,report,_,verifier = self.execute([False]*4)
self.assertEqual(self.texts(),['1','2','3','4'])
verifier.orientation.assert_not_called()
verifier.order.assert_not_called()
self.assertEqual([o['output_page']for p in report['pages']for o in p['outputs']],[4,1,2,3])
def test_a4_inversion_is_independent_and_keeps_positions(self):
self.create(('a4','a4'),rotations=[180,0])
_,report,_,verifier = self.execute([True,False],['inverted'])
self.assertEqual(self.texts(),['A4-0','A4-1'])
self.assertEqual(verifier.orientation.call_count,1)
verifier.order.assert_not_called()
self.assertEqual(report['pages'][0]['applied_rotation'],180)
self.assertNotIn('applied_rotation',report['pages'][1])
self.assertEqual(self.analyses,[(0,180),(1,0),(0,0)])
def test_entire_a3_sheet_inverted_reanalyses_both_source_faces(self):
self.create(rotations=[180,180])
_,report,_,verifier = self.execute([True,False,False,False],['inverted']*4)
self.assertEqual(self.texts(),['1','2','3','4'])
self.assertEqual(verifier.orientation.call_count,4)
verifier.order.assert_not_called()
self.assertEqual(self.analyses,[(0,180),(1,180),(0,0),(1,0)])
self.assertTrue(all(p['applied_rotation']==180 for p in report['pages']))
self.assertTrue(all('orientation_before'in p for p in report['pages']))
def test_blank_siblings_are_not_sent_to_gemini_and_are_retained_until_ordering(self):
self.create(rotations=[180,180])
summary,_,screen,verifier=self.execute([True],['inverted'],blank=((0,1),(1,0),(1,1)))
self.assertEqual(screen.predict.call_count,1)
self.assertEqual(verifier.orientation.call_count,1)
self.assertEqual(summary['blank_removed'],3)
self.assertEqual(len(self.texts()),1)
self.assertEqual(len(self.analyses),4)
def test_inconclusive_candidate_assumed_upright_without_sibling_requests(self):
self.create()
_,report,_,verifier=self.execute([True,False,False,False],['inconclusive'])
self.assertEqual(verifier.orientation.call_count,1)
self.assertEqual(len(self.analyses),2)
self.assertEqual(self.texts(),['1','2','3','4'])
self.assertEqual(report['pages'][0]['outputs'][0]['orientation']['verdict'],'upright')
def test_mixed_sheet_rotates_only_inverted_logical_page_and_uses_given_order(self):
self.create(mixed=True)
_,report,_,verifier=self.execute([True,False,False,False],
['inverted','upright','inconclusive','upright'],[2,3,4,1])
self.assertEqual(len(self.analyses),2)
self.assertEqual(self.texts(),['1','2','3','4'])
verifier.order.assert_called_once()
images,blank=verifier.order.call_args.args
self.assertEqual(len(images),4)
self.assertEqual(blank,[False]*4)
self.assertEqual(report['pages'][0]['outputs'][0]['rotation'],180)
with pymupdf.open(self.root/'result/pdfs/copy.pdf')as d:
self.assertGreater(d[3].get_text('blocks')[0][1],400)
def test_inconclusive_sibling_takes_mixed_branch(self):
self.create()
_,_,_,verifier=self.execute([True,False,False,False],
['inverted','inconclusive','inverted','inverted'],[2,3,4,1])
verifier.order.assert_called_once()
self.assertEqual(len(self.analyses),2)
def test_invalid_gemini_order_aborts_without_touching_source(self):
self.create()
with self.assertRaises(ValueError):
self.execute([True,False,False,False],['inverted','upright','upright','upright'],[1,1,3,4])
self.assertEqual(self.source.read_bytes(),self.original)
self.assertFalse((self.root/'result/pdfs/copy.pdf').exists())
def test_api_failure_aborts_without_guessing_upright(self):
self.create()
with self.assertRaises(RuntimeError):
self.execute([True,False,False,False],[RuntimeError('API unavailable')])
self.assertEqual(self.source.read_bytes(),self.original)
def test_orphan_a3_is_flagged_and_a4_breaks_pairing(self):
self.assertEqual(list(sheets([{'format':k}for k in ['a3','a4','a3','a3','a4']])),
[[0],[1],[2,3],[4]])
self.create(('a3',))
_,report,_,_=self.execute([False,False])
self.assertIn('unpaired-a3-sheet',report['pages'][0]['review_reasons'])
def review_saved(self, report, restore_blank=False):
import shutil
from copienator import EvaluationWorkspace
from copienator.commands.automatic_page_splitter import decision_path
from copienator.commands.page_splitter import PDFPreviewer
workspace=EvaluationWorkspace(self.root)
(self.root/'labels').write_text('Ex 1\n')
workspace.original_copies_dir.mkdir()
workspace.copies_dir.mkdir()
original=workspace.original_copies_dir/self.source.name
output=workspace.copies_dir/self.source.name
shutil.copy2(self.source,original)
shutil.copy2(self.root/'result/pdfs/copy.pdf',output)
path=decision_path(workspace,'copy');path.parent.mkdir(parents=True)
path.write_text(json.dumps(dict(source_sha256=hashlib.sha256(original.read_bytes()).hexdigest(),
output_sha256=hashlib.sha256(output.read_bytes()).hexdigest(),pages=report['pages'])))
expected=pymupdf.open(output)
self.addCleanup(lambda document=expected: None if document.is_closed else document.close())
preview=PDFPreviewer.__new__(PDFPreviewer)
preview.workspace=workspace;preview.inputs=[original]
preview.num=preview.global_rotation=0;preview.history=[];preview.output_dir=None
preview.failed=False;preview.master=Mock();preview.load_page=Mock()
self.assertTrue(preview.setup_next_file())
preview._initialize_current_page_settings()
for _ in range(2):preview.confirm_and_next_page(keep='both' if restore_blank else None)
self.assertFalse(preview.failed)
self.assertEqual(original.read_bytes(),self.original)
with pymupdf.open(output)as actual:
if restore_blank:
self.assertEqual([p.get_text().strip()for p in actual],['1','2','3','4'])
expected.close()
expected=pymupdf.open(output)
self.addCleanup(lambda document=expected: None if document.is_closed else document.close())
self.assertEqual(len(actual),len(expected))
for a,b in zip(actual,expected):
pa,pb=a.get_pixmap(dpi=40),b.get_pixmap(dpi=40)
self.assertEqual((pa.width,pa.height),(pb.width,pb.height))
diff=np.abs(np.frombuffer(pa.samples,np.uint8).astype(float)-np.frombuffer(pb.samples,np.uint8))
self.assertLess(diff.mean(),.1)
from copienator.orientation import load_provenance
self.assertEqual([p['origin']for p in load_provenance(workspace,output,4)],[1,2,2,1])
def test_manual_reinstatement_of_a_blank_page_uses_its_original_booklet_slot(self):
self.create()
_,report,_,_=self.execute([False]*3,blank=((0,0),))
self.assertEqual(report['pages'][0]['outputs'][0]['reading_order'],4)
self.assertEqual(self.texts(),['1','2','3'])
self.review_saved(report,restore_blank=True)
def test_manual_acceptance_preserves_uniform_source_rotation_and_booklet_order(self):
self.create(rotations=[180,180])
_,report,_,_=self.execute([True,False,False,False],['inverted']*4)
self.review_saved(report)
def test_manual_acceptance_preserves_mixed_logical_rotation_and_gemini_order(self):
self.create(mixed=True)
_,report,_,_=self.execute([True,False,False,False],
['inverted','upright','upright','upright'],[2,3,4,1])
self.review_saved(report)
def test_manual_review_keeps_a4_before_following_a3_pages(self):
from copienator.commands.page_splitter import PDFPreviewer
with pymupdf.open()as d:
d.new_page(width=595,height=842).insert_text((50,70),'A4')
p=d.new_page(width=1190,height=842)
p.insert_text((50,70),'B');p.insert_text((650,70),'C');d.save(self.source)
preview=PDFPreviewer.__new__(PDFPreviewer)
preview.doc=pymupdf.open(self.source)
preview.file_rotation=preview.global_rotation=0
preview.base_name='copy';preview.split_dir=self.root/'split';preview.reorder_dir=self.root/'reorder'
preview.final_file=self.root/'final.pdf';preview.output_dir=None;preview.automatic_review=True
preview.page_settings=[
dict(line_x=297.5,rotation=0,keep='as_is',automatic_outputs=[
dict(side='whole',crop_points=[0,0,595,842],reading_order=1)]),
dict(line_x=595,rotation=0,keep='both',automatic_outputs=[
dict(side='left',crop_points=[0,0,595,842],reading_order=2),
dict(side='right',crop_points=[595,0,1190,842],reading_order=3)])]
preview.split_pdf();preview.reorder_pdfs();preview.concate_files()
with pymupdf.open(preview.final_file)as d:
self.assertEqual([p.get_text().strip()for p in d],['A4','B','C'])
def test_secondary_cuts_follow_the_corrected_half_display(self):
from copienator.commands.page_splitter import PDFPreviewer
with pymupdf.open()as d:
p=d.new_page(width=1190,height=842)
for x,label in [(50,'A'),(400,'B'),(650,'C')]:p.insert_text((x,70),label,fontsize=24)
d.save(self.source)
preview=PDFPreviewer.__new__(PDFPreviewer)
preview.doc=pymupdf.open(self.source)
preview.file_rotation=preview.global_rotation=0
preview.base_name='copy';preview.split_dir=self.root/'split';preview.reorder_dir=self.root/'reorder'
preview.final_file=self.root/'final.pdf';preview.output_dir=None;preview.automatic_review=True
preview.page_settings=[dict(line_x=595,rotation=0,keep='both',secondary_splitters=[297.5],
automatic_outputs=[dict(side='left',crop_points=[0,0,595,842],rotation=180,reading_order=1),
dict(side='right',crop_points=[595,0,1190,842],reading_order=2)])]
preview.split_pdf();preview.reorder_pdfs();preview.concate_files()
with pymupdf.open(preview.final_file)as d:
self.assertEqual([p.get_text().strip()for p in d],['B','A','C'])
def test_order_schema_rejects_booleans_strings_duplicates_and_omissions(self):
for order in [[True,2,3,4],['1',2,3,4]]:
with self.assertRaises(ValidationError):PageOrder.model_validate({'order':order})
for order in [[1,2,2,4],[1,2,3],[1,2,3,5]]:
with self.assertRaises(ValueError):validate_order(order,4)
def test_gemini_order_includes_entire_statement_and_all_images(self):
verifier=GeminiVerifier(self.statement)
verifier.client=Mock()
verifier.client.models.generate_content.return_value.text='{"order":[2,3,4,1]}'
self.assertEqual(verifier.order([b'png']*4,[False,True,False,False]),[2,3,4,1])
config=verifier.client.models.generate_content.call_args.kwargs['config']
self.assertEqual(config.thinking_config.thinking_level.value,'MEDIUM')
parts=verifier.client.models.generate_content.call_args.kwargs['contents'][0].parts
blobs=[p.inline_data for p in parts if p.inline_data]
self.assertEqual([b.mime_type for b in blobs],['application/pdf']+['image/png']*4)
self.assertEqual(blobs[0].data,self.statement.read_bytes())
self.assertEqual(sum(b.data==b'png'for b in blobs),4)
def test_gemini_orientation_uses_low_thinking(self):
verifier=GeminiVerifier()
verifier.client=Mock()
verifier.client.models.generate_content.return_value.text='{"orientation":"upright"}'
self.assertEqual(verifier.orientation(b'png'),'upright')
config=verifier.client.models.generate_content.call_args.kwargs['config']
self.assertEqual(config.thinking_config.thinking_level.value,'LOW')
def test_missing_statement_is_an_error_for_mixed_ordering(self):
verifier=GeminiVerifier()
verifier.client=Mock()
with self.assertRaisesRegex(ValueError,'énoncé'):
verifier.order([b'png']*4,[False]*4)
verifier.client.models.generate_content.assert_not_called()