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.
206 lines
9.7 KiB
Python
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()
|