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

+38 -3
View File
@@ -125,6 +125,10 @@ class PDFPreviewer:
saved = load_decisions(self.workspace, self.pdf_path)
self.automatic_plans = {r['source_page']: r for r in saved} if saved else {}
self.automatic_review = bool(self.automatic_plans)
self._user_reordered = False
for number, plan in self.automatic_plans.items():
page = self.doc[number-1]
page.set_rotation((page.rotation + plan.get('applied_rotation', 0)) % 360)
except (OSError, RuntimeError, ValueError) as e:
self.failed = True
self._temporary_directory.cleanup()
@@ -317,7 +321,12 @@ class PDFPreviewer:
mat = pymupdf.Matrix(self.current_zoom, self.current_zoom)
pix = page.get_pixmap(matrix=mat, alpha=False)
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
# A mixed sheet may have one corrected logical half, not a turned source face.
original_image = img.copy()
for output in getattr(self, 'current_automatic_outputs', None) or []:
if output.get('rotation'):
box = tuple(round(v*self.current_zoom) for v in output['crop_points'])
img.paste(original_image.crop(box).rotate(180), box[:2])
# Apply rotation if needed *after* drawing the line
if (self.current_rotation + self.file_rotation + self.global_rotation) % 360 != 0:
@@ -386,6 +395,7 @@ class PDFPreviewer:
if self.processing or not len(self.doc):
return
self.doc.select(list(reversed(range(len(self.doc)))))
self._user_reordered = True
self.current_page_index = 0
self.page_settings = []
self._initialize_current_page_settings()
@@ -451,6 +461,7 @@ class PDFPreviewer:
# Move the current page to the end of the document
# -1 as the destination puts it after the last page
self.doc.move_page(self.current_page_index, -1)
self._user_reordered = True
# Initialize settings for the page that shifted into the current slot
self._initialize_current_page_settings()
@@ -563,6 +574,7 @@ class PDFPreviewer:
self.clean_up_dir(self.split_dir)
self.fragment_origins = {}
self.fragment_order = {}
for i, settings in enumerate(self.page_settings):
page = self.doc.load_page(i)
origin = getattr(self, 'original_page_ids', {}).get(page.xref, i + 1)
@@ -581,8 +593,14 @@ class PDFPreviewer:
flat.set_rotation((rotation_settings+self.file_rotation+self.global_rotation) % 360)
flat.remove_rotation()
sides = []
side_metadata = {}
if keep == "as_is":
sides.append((0, width, self.split_filename_left(i)))
filename = self.split_filename_left(i)
sides.append((0, width, filename))
proposed = settings.get('automatic_outputs') or []
whole = next((o for o in proposed if o['side']=='whole'), None)
if whole is not None:
side_metadata[str(filename)] = whole
elif settings.get('automatic_outputs') is not None:
for output in settings['automatic_outputs']:
side = output['side']
@@ -590,6 +608,7 @@ class PDFPreviewer:
x0,_,x1,_ = output['crop_points']
filename = self.split_filename_left(i) if side=='left' else self.split_filename_right(i)
sides.append((x0,x1,filename))
side_metadata[str(filename)] = output
else:
if keep in {"both", "left"} and line_x > 0:
sides.append((0, line_x, self.split_filename_left(i)))
@@ -597,6 +616,7 @@ class PDFPreviewer:
sides.append((line_x, width, self.split_filename_right(i)))
for left, right, output_path in sides:
metadata = side_metadata.get(str(output_path), {})
cuts = settings.get("secondary_splitters", [])
boundaries = [left, *sorted({x for x in cuts if left < x < right}), right]
# Each side is a multi-page PDF, keeping its subpages together
@@ -604,13 +624,21 @@ class PDFPreviewer:
origins = []
with pymupdf.open() as output:
for x0, x1 in zip(boundaries, boundaries[1:]):
clip = pymupdf.Rect(x0, 0, x1, height)
# Cuts are placed on the displayed (corrected) logical page.
# Turning a half also reverses its secondary strips.
clip = (pymupdf.Rect(left+right-x1, 0, left+right-x0, height)
if metadata.get('rotation') else pymupdf.Rect(x0, 0, x1, height))
target = output.new_page(width=clip.width, height=clip.height)
if flat.get_contents():
target.show_pdf_page(target.rect, normalized, 0, clip=clip)
if metadata.get('rotation'):
target.set_rotation(metadata['rotation'])
target.remove_rotation()
origins.append({'origin': origin, 'crop_points': list(clip)})
output.save(output_path)
self.fragment_origins[str(output_path)] = origins
self.fragment_order[str(output_path)] = metadata.get('reading_order',
metadata.get('output_page', 100000+i*2+(left>0)))
normalized.close()
self.doc.close()
@@ -629,6 +657,13 @@ class PDFPreviewer:
if hasattr(self, 'fragment_origins'):
self.fragment_origins[str(destination)] = self.fragment_origins[str(source)]
if (getattr(self, 'automatic_review', False) and getattr(self, 'fragment_order', None)
and not getattr(self, '_user_reordered', False)):
# Saved output numbers include booklet/Gemini order; do not reorder twice.
for ri, source in enumerate(sorted(self.fragment_order, key=self.fragment_order.get)):
copy_side(source, self.reorder_filename(ri))
return
while i < len(ps):
psk = ps[i]['keep']