Cropping of individual exos

This commit is contained in:
2026-09-09 15:24:39 +02:00
parent 3969580e01
commit 0a167072ed
7 changed files with 719 additions and 0 deletions
@@ -0,0 +1,197 @@
"""Optionally replace split-answer PDFs with large bottom crops."""
from __future__ import annotations
import argparse
import hashlib
import json
import multiprocessing
import shutil
import signal
import tempfile
from collections.abc import Sequence
from pathlib import Path
import cv2
from copienator.cli import CliError, ExitCode, execute, target_parser, workspace_from_target
from copienator.crop_exercise_bottoms import _full_page_height, process_exercise_pdf
from copienator.workspace import EvaluationWorkspace
def selected_files(workspace: EvaluationWorkspace, target: Path) -> list[Path]:
workspace.require_directories("Copies")
resolved = target.resolve()
if resolved not in {workspace.root.resolve(), workspace.copies_dir.resolve()}:
raise CliError(
"La cible doit être l’évaluation ou son dossier Copies.",
ExitCode.INVALID_ARGUMENTS,
)
files = sorted(
workspace.copies_dir.glob("Copie*/*.pdf"),
key=lambda path: (path.parent.name.casefold(), path.name.casefold()),
)
for source in files:
if source.is_symlink():
raise CliError(f"Lien symbolique non pris en charge : {source}")
return files
def _initialize_worker() -> None:
cv2.setNumThreads(1)
signal.signal(signal.SIGINT, signal.SIG_IGN)
def _process_file(job: tuple[Path, Path, float]) -> list[dict]:
source, destination, full_height = job
records = process_exercise_pdf(
source, destination, None, full_height, dpi=200, padding_mm=6
)
changed = sum(record["status"] == "cropped" for record in records)
if changed:
print(
f"{source.parent.name}/{source.name} : {changed}/{len(records)} page(s) rognée(s)",
flush=True,
)
return records
def process_files(
workspace: EvaluationWorkspace,
files: list[Path],
staging: Path,
workers: int,
) -> list[dict]:
heights: dict[str, float] = {}
for copy_name in sorted({source.parent.name for source in files}):
copy_pdf = workspace.copies_dir / f"{copy_name}.pdf"
if not copy_pdf.is_file():
raise CliError(f"PDF source introuvable : {copy_pdf}")
heights[copy_name] = _full_page_height(copy_pdf)
jobs = [
(
source,
staging / source.relative_to(workspace.copies_dir),
heights[source.parent.name],
)
for source in files
]
count = min(workers, len(jobs))
print(
f"Analyse de {len(files)} PDF de réponses avec {count} traitement(s) en parallèle.",
flush=True,
)
if count == 1:
previous_threads = cv2.getNumThreads()
cv2.setNumThreads(1)
try:
batches = [_process_file(job) for job in jobs]
finally:
cv2.setNumThreads(previous_threads)
else:
with multiprocessing.get_context("spawn").Pool(
count, _initialize_worker
) as pool:
batches = list(pool.imap_unordered(_process_file, jobs))
order = {source.as_posix(): index for index, source in enumerate(files)}
return sorted(
(record for batch in batches for record in batch),
key=lambda record: (order[record["file"]], record["page"]),
)
def _publish(
workspace: EvaluationWorkspace,
changed_files: list[Path],
staging: Path,
records: list[dict],
) -> Path:
workspace.runs_dir.mkdir(parents=True, exist_ok=True)
run_dir = Path(
tempfile.mkdtemp(prefix="crop-exercise-bottoms-", dir=workspace.runs_dir)
)
backup_root = run_dir / "Copies"
replaced: list[Path] = []
try:
for source in changed_files:
backup = backup_root / source.relative_to(workspace.copies_dir)
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, backup)
(run_dir / "report.json").write_text(
json.dumps(records, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
for source in changed_files:
prepared = staging / source.relative_to(workspace.copies_dir)
prepared.replace(source)
replaced.append(source)
except BaseException:
for source in replaced:
backup = backup_root / source.relative_to(workspace.copies_dir)
if backup.is_file():
shutil.copy2(backup, source)
shutil.rmtree(run_dir, ignore_errors=True)
raise
return backup_root
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 de réponse trouvé dans Copies/CopieXX/.",
ExitCode.INVALID_ARGUMENTS,
)
with tempfile.TemporaryDirectory(
prefix=".crop-exercise-bottoms-", dir=workspace.root
) as directory:
staging = Path(directory)
records = process_files(workspace, files, staging, workers)
changed_names = {
record["file"] for record in records if record["status"] == "cropped"
}
changed_files = [source for source in files if source.as_posix() in changed_names]
digests = {record["file"]: record["source_sha256"] for record in records}
for source in files:
if hashlib.sha256(source.read_bytes()).hexdigest() != digests[source.as_posix()]:
raise CliError(
f"{source} a changé pendant lanalyse. Aucun PDF remplacé."
)
if not changed_files:
print("Terminé : aucun PDF ne remplit les critères de rognage.", flush=True)
return ExitCode.SUCCESS
backup = _publish(workspace, changed_files, staging, records)
cropped_pages = sum(record["status"] == "cropped" for record in records)
print(f"Sauvegarde des PDF non rognés : {backup}", flush=True)
print(
f"Terminé : {cropped_pages} page(s) rognée(s) dans "
f"{len(changed_files)} PDF remplacé(s).",
flush=True,
)
return ExitCode.SUCCESS
def main(argv: Sequence[str] | None = None) -> int:
parser = target_parser(
"Rogner les grands espaces vides au bas des réponses déjà découpées"
)
parser.add_argument(
"--workers",
type=int,
default=5,
help="Nombre de PDF traités en parallèle (défaut : 5)",
)
def handle(arguments: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(arguments)
return run(workspace, target, workers=arguments.workers)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())