Files
Copies/copienator/commands/crop_margins.py
T
2026-09-10 20:03:23 +02:00

157 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Optional preprocessing: replace split copies with ink-guided crops."""
from __future__ import annotations
import argparse
import hashlib
import json
import multiprocessing
import signal
import shutil
import tempfile
import cv2
from collections.abc import Sequence
from pathlib import Path
from copienator.cli import CliError, ExitCode, execute, target_parser, workspace_from_target
from copienator.crop_blank_margins import process_pdf
from copienator.filesystem import staged_files
from copienator.workspace import EvaluationWorkspace
def selected_files(workspace: EvaluationWorkspace, target: Path) -> list[Path]:
workspace.require_directories("Copies")
copies = workspace.copies_dir.resolve()
target = target.resolve()
if target.is_file():
if target.parent != copies or target.suffix.lower() != ".pdf":
raise CliError("La cible doit être un PDF du dossier Copies.", ExitCode.INVALID_ARGUMENTS)
files = [target]
elif target in (workspace.root, copies):
files = sorted(copies.glob("*.pdf"), key=lambda path: path.name.casefold())
else:
raise CliError("Cible attendue : évaluation, dossier Copies ou PDF dans Copies.",
ExitCode.INVALID_ARGUMENTS)
for source in files:
if source.is_symlink():
raise CliError(f"Lien symbolique non pris en charge : {source}")
if source.with_suffix(".json").exists():
raise CliError(
f"{source.name} possède déjà des coordonnées de labels. "
"Le rognage doit précéder leur détection. Pour reprendre le prétraitement, "
"mettez de côté le JSON associé, puis régénérez la découpe des marges et les labels. "
"Aucun PDF na été remplacé.")
return files
def _initialize_worker() -> None:
# Five copies should use five cores, not five OpenCV thread pools. MuPDF
# must also stay isolated in separate processes rather than Python threads.
cv2.setNumThreads(1)
signal.signal(signal.SIGINT, signal.SIG_IGN)
def _process_copy(job: tuple[Path, Path]) -> list[dict]:
source, destination = job
def progress(page, total, row):
removed = row["top_removed_mm"]+row["bottom_removed_mm"]
print(f"{source.name} — Page {page}/{total} : {removed:.1f} mm retirés", flush=True)
return process_pdf(source, destination, None, 200, 6, 5, progress=progress)
def process_copies(files: list[Path], staging: Path, workers: int) -> list[dict]:
jobs = [(source, staging/source.name) for source in files]
count = min(workers, len(jobs))
print(f"Rognage de {len(files)} copies avec {count} traitement(s) en parallèle.", flush=True)
if count == 1:
previous_threads = cv2.getNumThreads()
cv2.setNumThreads(1)
try:
batches = [_process_copy(job) for job in jobs]
finally:
cv2.setNumThreads(previous_threads)
else:
# spawn works on Windows and avoids inheriting GUI/native-library state.
# Pool's context terminates and joins workers on errors or cancellation
# before staged_files removes the unpublished PDFs.
with multiprocessing.get_context("spawn").Pool(count, _initialize_worker) as pool:
batches = list(pool.imap_unordered(_process_copy, jobs))
order = {source.name: i for i, source in enumerate(files)}
return sorted((row for batch in batches for row in batch),
key=lambda row: (order[row["file"]], row["page"]))
def crop_statistics(records: list[dict]) -> tuple[int, float, int]:
"""Return cropped page count, their mean removed percentage, and >30% count."""
percentages: list[float] = []
for record in records:
removed_mm = record["top_removed_mm"] + record["bottom_removed_mm"]
if removed_mm <= 0:
continue
x0, y0, x1, y1 = record["original_cropbox"]
original_height_points = (
x1 - x0 if record.get("rotation", 0) % 180 else y1 - y0
)
if original_height_points <= 0:
continue
original_height_mm = original_height_points * 25.4 / 72
percentages.append(min(100.0, removed_mm / original_height_mm * 100))
mean_percentage = sum(percentages) / len(percentages) if percentages else 0.0
over_thirty = sum(percentage > 30 for percentage in percentages)
return len(percentages), mean_percentage, over_thirty
def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> ExitCode:
if workers < 1:
raise CliError("Le nombre de traitements parallèles doit être positif.",
ExitCode.INVALID_ARGUMENTS)
files = selected_files(workspace, target)
if not files:
raise CliError("Aucun PDF trouvé dans Copies.", ExitCode.INVALID_ARGUMENTS)
# Prepare the whole batch before replacing any copy. A detection failure or
# interruption leaves the working PDFs intact; commit errors roll back.
with staged_files(workspace.copies_dir) as staging:
records = process_copies(files, staging, workers)
# Detect edits made while the batch was being analysed, before saving
# backups or publishing results derived from an obsolete source.
digests = {row["file"]: row["source_sha256"] for row in records}
for source in files:
if hashlib.sha256(source.read_bytes()).hexdigest() != digests[source.name]:
raise CliError(f"{source.name} a changé pendant lanalyse. Aucun PDF remplacé.")
workspace.runs_dir.mkdir(parents=True, exist_ok=True)
backup = Path(tempfile.mkdtemp(prefix="crop-margins-", dir=workspace.runs_dir))
originals = backup/"Copies"
originals.mkdir()
for source in files:
shutil.copy2(source, originals/source.name)
(backup/"report.json").write_text(json.dumps(records, ensure_ascii=False, indent=2),
encoding="utf-8")
print(f"Sauvegarde des PDF non rognés : {originals}", flush=True)
cropped, mean_percentage, over_thirty = crop_statistics(records)
print(f"Terminé : {cropped}/{len(records)} pages rognées ; "
f"{len(files)} PDF remplacés dans Copies.", flush=True)
print(
f"Rognage moyen des pages modifiées : {mean_percentage:.1f} % ; "
f"{over_thirty} page(s) rognée(s) de plus de 30 %.",
flush=True,
)
return ExitCode.SUCCESS
def main(argv: Sequence[str] | None = None) -> int:
parser = target_parser("Rogner les zones vides des PDF dans Copies, avant les labels")
parser.add_argument("--workers", type=int, default=5,
help="Nombre de copies traitées en parallèle (défaut : 5)")
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target, workers=args.workers)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())