132 lines
5.8 KiB
Python
132 lines
5.8 KiB
Python
"""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 n’a é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 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 l’analyse. 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 = sum(row["top_removed_mm"]+row["bottom_removed_mm"] > 0 for row in records)
|
||
print(f"Terminé : {cropped}/{len(records)} pages rognées ; "
|
||
f"{len(files)} PDF remplacés dans Copies.", 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())
|