Files
Copies/tests/test_automatic_page_splitter.py
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

206 lines
9.7 KiB
Python

import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
import numpy as np
import pymupdf
from copienator import EvaluationWorkspace, ExitCode
from copienator.commands import automatic_page_splitter as automatic
from copienator.commands.page_splitter import PDFPreviewer, _selected_inputs, main
from copienator.copy_errors import copy_errors
from copienator.orientation import load_provenance
from copienator_gui.workflow import build_command, build_workflow
class AutomaticPageSplitterTests(unittest.TestCase):
def make_source(self, root, *, blank=False):
(root / "labels").write_text("Ex 1\n")
source = root / "Copie01.pdf"
with pymupdf.open() as doc:
page = doc.new_page(width=420 * 72 / 25.4, height=297 * 72 / 25.4)
if not blank:
page.insert_text((60, 100), "Answer on the left page", fontsize=16)
page.insert_text((750, 100), "Answer on the right page", fontsize=16)
page.insert_text((560, 300), "x + y = z", fontsize=16)
doc.new_page(width=420 * 72 / 25.4, height=297 * 72 / 25.4)
page = doc.new_page(width=210 * 72 / 25.4, height=297 * 72 / 25.4)
page.insert_text((60, 100), "Exercise 3", fontsize=16)
doc.save(source)
return source
def test_gui_choices_build_manual_and_automatic_commands(self):
step = next(s for s in build_workflow(False) if s.id == "page_splitter")
self.assertEqual(
[v.label for v in step.variants], ["Manuellement", "Automatiquement"]
)
manual, auto = [
build_command(Path.cwd(), step, v, {"workers": 3}, "Exam")
for v in step.variants
]
self.assertNotIn("--automatic", manual)
self.assertNotIn("--workers", manual)
self.assertIn("--automatic", auto)
self.assertEqual(auto[auto.index("--workers") + 1], "3")
def test_cli_publishes_without_tk_and_preserves_original_and_provenance(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = self.make_source(root)
before = source.read_bytes()
workspace = EvaluationWorkspace(root)
with patch("copienator.commands.page_splitter.tk.Tk") as tk:
self.assertEqual(main([str(root), "--automatic", "--workers", "1"]), 0)
tk.assert_not_called()
preserved = workspace.original_copies_dir / source.name
self.assertEqual(preserved.read_bytes(), before)
self.assertFalse(source.exists())
output = workspace.copies_dir / source.name
with pymupdf.open(output) as doc:
self.assertEqual(len(doc), 3)
origins = load_provenance(workspace, output, len(doc))
self.assertEqual([o["origin"] for o in origins], [1, 1, 3])
# The outer right is page 1; the overflowing outer left is page 4.
self.assertGreater(doc[1].rect.width, 210 * 72 / 25.4)
saved = automatic.load_decisions(workspace, preserved)
self.assertEqual(len(saved), 3)
self.assertEqual(_selected_inputs(workspace, root), [preserved])
self.assertFalse(copy_errors(workspace))
runs = list(workspace.runs_dir.glob("page-split-*"))
self.assertTrue((runs[0] / "result/pdfs" / source.name).exists())
self.assertEqual(
automatic.run(workspace, [preserved], workers=1), ExitCode.SUCCESS
)
self.assertEqual(preserved.read_bytes(), before)
def test_processing_failure_leaves_existing_files_in_place(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = self.make_source(root)
before = source.read_bytes()
workspace = EvaluationWorkspace(root)
workspace.copies_dir.mkdir()
output = workspace.copies_dir / source.name
output.write_bytes(b"previous processed copy")
with (
patch.object(
automatic,
"analyse_batch",
side_effect=RuntimeError("analysis failed"),
),
self.assertRaisesRegex(RuntimeError, "analysis failed"),
):
automatic.run(workspace, [source], workers=1)
self.assertEqual(source.read_bytes(), before)
self.assertEqual(output.read_bytes(), b"previous processed copy")
self.assertFalse(workspace.original_copies_dir.exists())
def test_parallel_run_preserves_unselected_copies_and_ignores_stale_proposals(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = self.make_source(root)
second = root / "Copie02.pdf"
shutil.copy2(source, second)
workspace = EvaluationWorkspace(root)
workspace.copies_dir.mkdir()
other = workspace.copies_dir / "Copie99.pdf"
other.write_bytes(b"unselected copy")
automatic.run(workspace, [source, second], workers=2)
self.assertEqual(other.read_bytes(), b"unselected copy")
original = workspace.original_copies_dir / source.name
self.assertIsNotNone(automatic.load_decisions(workspace, original))
output = workspace.copies_dir / source.name
output.write_bytes(b"edited after automatic splitting")
self.assertIsNone(automatic.load_decisions(workspace, original))
def test_wholly_blank_copy_is_preserved_and_flagged_without_stale_output(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = self.make_source(root, blank=True)
before = source.read_bytes()
workspace = EvaluationWorkspace(root)
workspace.copies_dir.mkdir()
(workspace.copies_dir / source.name).write_bytes(b"stale output")
automatic.run(workspace, [source], workers=1)
self.assertFalse((workspace.copies_dir / source.name).exists())
self.assertEqual(
(workspace.original_copies_dir / source.name).read_bytes(), before
)
self.assertIn(source.name, copy_errors(workspace))
run = next(workspace.runs_dir.glob("page-split-*"))
self.assertEqual(
(run / "previous_copies" / source.name).read_bytes(), b"stale output"
)
def test_manual_acceptance_preserves_saved_overlap_blanks_and_order(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = self.make_source(root)
workspace = EvaluationWorkspace(root)
automatic.run(workspace, [source], workers=1)
original = workspace.original_copies_dir / source.name
before = original.read_bytes()
output = workspace.copies_dir / source.name
expected = pymupdf.open(output)
self.addCleanup(expected.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())
self.assertTrue(preview.automatic_review)
preview._initialize_current_page_settings()
for _ in range(3):
preview.confirm_and_next_page()
self.assertFalse(preview.failed)
self.assertEqual(
[s["keep"] for s in preview.page_settings], ["both", "none", "as_is"]
)
self.assertEqual(original.read_bytes(), before)
with pymupdf.open(output) as actual:
self.assertEqual(len(actual), len(expected))
for a, b in zip(actual, expected):
self.assertAlmostEqual(a.rect.width, b.rect.width, places=3)
aa, bb = a.get_pixmap(dpi=40), b.get_pixmap(dpi=40)
self.assertEqual((aa.width, aa.height), (bb.width, bb.height))
difference = np.abs(
np.frombuffer(aa.samples, np.uint8).astype(float)
- np.frombuffer(bb.samples, np.uint8)
)
self.assertLess(difference.mean(), 0.1)
def test_accepting_wholly_blank_proposal_never_creates_an_empty_pdf(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = self.make_source(root, blank=True)
workspace = EvaluationWorkspace(root)
automatic.run(workspace, [source], workers=1)
original = workspace.original_copies_dir / source.name
before = original.read_bytes()
preview = PDFPreviewer.__new__(PDFPreviewer)
preview.workspace = workspace
preview.inputs = [original]
preview.num = preview.global_rotation = 0
preview.history = []
preview.failed = False
preview.master = Mock()
preview.load_page = Mock()
preview.setup_next_file()
preview._initialize_current_page_settings()
preview.confirm_and_next_page()
self.assertFalse(preview.failed)
self.assertEqual(original.read_bytes(), before)
self.assertFalse((workspace.copies_dir / source.name).exists())
self.assertNotIn(source.name, copy_errors(workspace))
if __name__ == "__main__":
unittest.main()