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:
1 parent
78f3e4b2f0
commit
0a4f4a80d0
21 files changed
+1574
-51
No files matched your search
@@ -74,7 +74,8 @@ def run(workspace, inputs: list[Path], *, workers=4):
|
||||
shutil.copy2(source, snapshots / source.name)
|
||||
result = run_dir / "result"
|
||||
print(f"Découpage automatique de {len(inputs)} copie(s).", flush=True)
|
||||
summary = analyse_batch(snapshots, result, workers=workers)
|
||||
summary = analyse_batch(snapshots, result, workers=workers,
|
||||
orientation=True, statement=workspace.statement_pdf_file)
|
||||
records = json.loads((result / "report.json").read_text())["pages"]
|
||||
stages = {
|
||||
name: run_dir / name for name in ("copies", "originals", "origins", "decisions")
|
||||
@@ -104,11 +105,16 @@ def run(workspace, inputs: list[Path], *, workers=4):
|
||||
)
|
||||
if digest:
|
||||
shutil.copy2(output, stages["copies"] / source.name)
|
||||
ordered = sorted(
|
||||
[(r,o) for r in rows for o in r['outputs'] if o['keep']],
|
||||
key=lambda item: item[1]['output_page'],
|
||||
)
|
||||
origins = [
|
||||
{"origin": r["source_page"], "crop_points": o["crop_points"]}
|
||||
for r in rows
|
||||
for o in r["outputs"]
|
||||
if o["keep"]
|
||||
{"origin": r["source_page"], "crop_points": o["crop_points"],
|
||||
"source_rotation_correction": r.get('applied_rotation', 0),
|
||||
"sheet_source_pages": r.get('sheet_source_pages', [r['source_page']]),
|
||||
"logical_rotation": o.get('rotation', 0)}
|
||||
for r,o in ordered
|
||||
]
|
||||
if digest:
|
||||
(stages["origins"] / (source.stem + ".json")).write_text(
|
||||
@@ -172,7 +178,7 @@ def run(workspace, inputs: list[Path], *, workers=4):
|
||||
clear_copy_error(workspace, output, source="page_splitter")
|
||||
print(
|
||||
f"Terminé : {summary['output_pages']} pages conservées, {summary['blank_removed']} pages vides retirées. "
|
||||
"Ordre des pages inchangé.",
|
||||
"Orientation vérifiée ; feuilles A3 mises dans l’ordre de lecture, A4 conservées à leur place.",
|
||||
flush=True,
|
||||
)
|
||||
print(
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
Reference in new issue
Block a user