Compare commits

..
2 Commits
Author SHA1 Message Date
sebastien 0a167072ed Cropping of individual exos 2026-09-09 15:24:39 +02:00
sebastien 3969580e01 Initial cropping support 2026-09-09 14:53:04 +02:00
17 changed files with 1811 additions and 14 deletions
+2 -1
View File
@@ -7,4 +7,5 @@ dist/
*.egg-info/
config.py
.copienator-gui.json
.copienator/
.copienator/
tmp/
@@ -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())
+131
View File
@@ -0,0 +1,131 @@
"""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 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 = 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())
+137
View File
@@ -0,0 +1,137 @@
"""Propose conservative top/bottom crops for scanned, optionally ruled PDFs.
Run with ``python -m copienator.crop_blank_margins INPUT_DIR OUTPUT_DIR``.
Only PDFs directly in INPUT_DIR are processed. Originals are never modified.
Analysis is deskewed; output keeps the original scan and changes its CropBox.
This is a heuristic review utility, not a guarantee that a scan contains no ink.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import html
import json
from pathlib import Path
import cv2
import numpy as np
import pymupdf
from PIL import Image, ImageDraw
from copienator.ink_detection import detect_bounds
def apply_bounds(page: pymupdf.Page, top: float, bottom: float) -> None:
"""Apply fractional bounds in displayed orientation, respecting old CropBox."""
rect = page.rect
visible = pymupdf.Rect(0, top*rect.height, rect.width, bottom*rect.height)
box = visible * page.derotation_matrix
box += (page.cropbox_position.x, page.cropbox_position.y,
page.cropbox_position.x, page.cropbox_position.y)
page.set_cropbox(box)
def process_pdf(source: Path, destination: Path, review: Path | None,
dpi: float, padding_mm: float, min_crop_mm: float,
progress=None) -> list[dict]:
digest = hashlib.sha256(source.read_bytes()).hexdigest()
rows = []
with pymupdf.open(source) as doc:
for index, page in enumerate(doc):
pix = page.get_pixmap(dpi=round(dpi), colorspace=pymupdf.csRGB, alpha=False)
rgb = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3)
result = detect_bounds(rgb, dpi, padding_mm, min_crop_mm)
top, bottom = result.pop('top_px'), result.pop('bottom_px')
row = dict(file=source.name, page=index+1, **result,
top_removed_mm=round(top/pix.height*page.rect.height*25.4/72, 2),
bottom_removed_mm=round((pix.height-bottom)/pix.height*page.rect.height*25.4/72, 2),
original_cropbox=list(page.cropbox), rotation=page.rotation,
source_sha256=digest)
if review is not None:
preview = Image.fromarray(rgb)
preview.thumbnail((500, 700))
overlay = Image.new('RGBA', preview.size)
draw = ImageDraw.Draw(overlay)
y0, y1 = top/pix.height*preview.height, bottom/pix.height*preview.height
if top:
draw.rectangle((0, 0, preview.width, y0), fill=(255, 40, 40, 85))
draw.line((0, y0, preview.width, y0), fill=(230, 0, 0, 255), width=2)
if bottom < pix.height:
draw.rectangle((0, y1, preview.width, preview.height), fill=(255, 40, 40, 85))
draw.line((0, y1, preview.width, y1), fill=(230, 0, 0, 255), width=2)
preview = Image.alpha_composite(preview.convert('RGBA'), overlay).convert('RGB')
preview.save(review/f'{source.stem}-{index+1:03}.jpg', quality=85)
if top or bottom < pix.height:
apply_bounds(page, top/pix.height, bottom/pix.height)
row['output_cropbox'] = list(page.cropbox)
rows.append(row)
if progress is not None:
progress(index+1, len(doc), row)
doc.save(destination, garbage=3, deflate=True)
with pymupdf.open(destination) as check:
if len(check) != len(rows):
raise RuntimeError(f'Page count changed: {source}')
for page in check:
if page.rect.is_empty:
raise RuntimeError(f'Empty output page: {destination}')
if hashlib.sha256(source.read_bytes()).hexdigest() != digest:
raise RuntimeError(f'Source changed during processing: {source}')
return rows
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('input', type=Path)
parser.add_argument('output', type=Path)
parser.add_argument('--dpi', type=int, default=200)
parser.add_argument('--padding-mm', type=float, default=6)
parser.add_argument('--min-crop-mm', type=float, default=5)
args = parser.parse_args()
if args.dpi < 100 or args.padding_mm < 0 or args.min_crop_mm < 0:
parser.error('Use dpi >= 100 and nonnegative margins.')
sources = sorted(args.input.glob('*.pdf')) if args.input.is_dir() else [args.input]
if not sources or any(not p.is_file() for p in sources):
parser.error('No input PDFs found.')
if any(p.resolve() == (args.output/p.name).resolve() for p in sources):
parser.error('Output must not overwrite input PDFs.')
args.output.mkdir(parents=True, exist_ok=True)
review = args.output/'review'
review.mkdir(exist_ok=True)
cv2.setNumThreads(2)
rows = []
for i, source in enumerate(sources):
batch = process_pdf(source, args.output/source.name, review,
args.dpi, args.padding_mm, args.min_crop_mm)
rows.extend(batch)
print(f'[{i+1}/{len(sources)}] {source.name}: {len(batch)} pages, '
f'{sum(r["top_removed_mm"] > 0 or r["bottom_removed_mm"] > 0 for r in batch)} cropped',
flush=True)
(args.output/'report.json').write_text(json.dumps(rows, indent=2)+'\n')
with (args.output/'report.csv').open('w') as stream:
writer = csv.DictWriter(stream, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
cards = []
for row in rows:
stem = Path(row['file']).stem
cards.append(f'<article><a href="{html.escape(row["file"])}#page={row["page"]}">'
f'{html.escape(stem)} / {row["page"]}</a>'
f'<p>Top: {row["top_removed_mm"]} mm · Bottom: {row["bottom_removed_mm"]} mm'
f' · {row["status"]}</p>'
f'<img loading="lazy" src="review/{html.escape(stem)}-{row["page"]:03}.jpg"></article>')
(args.output/'index.html').write_text(
'<!doctype html><meta charset="utf-8"><title>Crop review</title>'
'<style>body{font:15px system-ui;background:#eee;margin:24px}'
'main{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:20px}'
'article{background:white;padding:12px}img{width:100%}p{font-size:12px}</style>'
'<h1>Crop review</h1><p>Red shading shows the removed areas on the original scan. '
'Click a page title to open the processed PDF. Review statuses flag uncertain detections.</p>'
'<main>'+''.join(cards)+'</main>')
changed = sum(r['top_removed_mm'] > 0 or r['bottom_removed_mm'] > 0 for r in rows)
print(f'Done: {len(sources)} PDFs, {len(rows)} pages, {changed} cropped. Review: {args.output / "index.html"}')
if __name__ == '__main__':
main()
+298
View File
@@ -0,0 +1,298 @@
"""Propose large bottom-only crops for PDFs already split into exercises."""
from __future__ import annotations
import argparse
import csv
import hashlib
import html
import json
import multiprocessing
import signal
from pathlib import Path
from urllib.parse import quote
import cv2
import numpy as np
import pymupdf
from PIL import Image, ImageDraw
from copienator.crop_blank_margins import apply_bounds
from copienator.filesystem import staged_directory
from copienator.ink_detection import detect_bounds
LINES_PER_PAGE = 36
MINIMUM_HEIGHT_LINES = 10
IGNORED_BOTTOM_LINES = 0.75
MINIMUM_CROP_LINES = 4
def _displayed_media_height(page: pymupdf.Page) -> float:
"""Return the uncropped sheet height in the page's displayed orientation."""
return page.mediabox.width if page.rotation % 180 else page.mediabox.height
def _full_page_height(copy_pdf: Path) -> float:
with pymupdf.open(copy_pdf) as document:
if not len(document):
raise ValueError(f"PDF sans page : {copy_pdf}")
return max(_displayed_media_height(page) for page in document)
def _save_review(rgb: np.ndarray, bottom: int, destination: Path) -> None:
preview = Image.fromarray(rgb)
preview.thumbnail((500, 700))
overlay = Image.new("RGBA", preview.size)
draw = ImageDraw.Draw(overlay)
y = bottom / rgb.shape[0] * preview.height
draw.rectangle((0, y, preview.width, preview.height), fill=(255, 40, 40, 85))
draw.line((0, y, preview.width, y), fill=(230, 0, 0, 255), width=2)
destination.parent.mkdir(parents=True, exist_ok=True)
Image.alpha_composite(preview.convert("RGBA"), overlay).convert("RGB").save(
destination, quality=88
)
def process_exercise_pdf(
source: Path,
destination: Path,
review_dir: Path | None,
full_page_height: float,
*,
dpi: int = 200,
padding_mm: float = 6,
) -> list[dict]:
"""Crop qualifying pages and save the PDF only when at least one changes."""
digest = hashlib.sha256(source.read_bytes()).hexdigest()
line_points = full_page_height / LINES_PER_PAGE
records: list[dict] = []
changed = False
with pymupdf.open(source) as document:
page_count = len(document)
for index, page in enumerate(document):
visible_height = page.rect.height
record = {
"file": source.as_posix(),
"page": index + 1,
"page_count": page_count,
"source_sha256": digest,
"height_lines": round(visible_height / line_points, 2),
"bottom_removed_lines": 0.0,
"bottom_removed_mm": 0.0,
"status": "skipped-short",
}
if visible_height + 1e-6 < MINIMUM_HEIGHT_LINES * line_points:
records.append(record)
continue
pixmap = page.get_pixmap(
dpi=dpi, colorspace=pymupdf.csRGB, alpha=False
)
rgb = np.frombuffer(pixmap.samples, np.uint8).reshape(
pixmap.height, pixmap.width, 3
)
pixels_per_point = pixmap.height / visible_height
ignored_pixels = min(
pixmap.height - 1,
round(IGNORED_BOTTOM_LINES * line_points * pixels_per_point),
)
analysis_bottom = pixmap.height - ignored_pixels
detection = detect_bounds(
rgb[:analysis_bottom], dpi=dpi, padding_mm=padding_mm, min_crop_mm=0
)
proposed_bottom = detection["bottom_px"]
removed_points = (pixmap.height - proposed_bottom) / pixels_per_point
removed_lines = removed_points / line_points
record["detector_status"] = detection["status"]
record["proposed_bottom_removed_lines"] = round(removed_lines, 2)
if removed_lines + 1e-6 < MINIMUM_CROP_LINES:
record["status"] = "unchanged-small-crop"
records.append(record)
continue
apply_bounds(page, 0, proposed_bottom / pixmap.height)
record.update(
bottom_removed_lines=round(removed_lines, 2),
bottom_removed_mm=round(removed_points * 25.4 / 72, 2),
status="cropped",
output_height_points=round(page.rect.height, 3),
)
if review_dir is not None:
record["output"] = destination.relative_to(review_dir.parent).as_posix()
review_path = review_dir / source.parent.name / (
f"{source.stem}-p{index + 1:02}.jpg"
)
_save_review(rgb, proposed_bottom, review_path)
record["review"] = review_path.relative_to(review_dir.parent).as_posix()
changed = True
records.append(record)
if changed:
destination.parent.mkdir(parents=True, exist_ok=True)
document.save(destination, garbage=3, deflate=True)
if changed:
with pymupdf.open(destination) as check:
if len(check) != page_count:
raise RuntimeError(f"Nombre de pages modifié : {source}")
for page in check:
if page.rect.is_empty:
raise RuntimeError(f"Page vide produite : {destination}")
page.get_pixmap(matrix=pymupdf.Matrix(0.25, 0.25))
if hashlib.sha256(source.read_bytes()).hexdigest() != digest:
raise RuntimeError(f"PDF source modifié pendant le rognage : {source}")
return records
def _initialize_worker() -> None:
cv2.setNumThreads(1)
signal.signal(signal.SIGINT, signal.SIG_IGN)
def _process_job(job: tuple[Path, Path, Path, float, int, float]) -> list[dict]:
source, destination, review_dir, full_height, dpi, padding = job
records = process_exercise_pdf(
source, destination, review_dir, full_height, dpi=dpi, padding_mm=padding
)
changed = sum(record["status"] == "cropped" for record in records)
print(f"{source.parent.name}/{source.name} : {changed}/{len(records)} page(s) rognée(s)",
flush=True)
return records
def _write_index(output: Path, records: list[dict]) -> None:
changed = [record for record in records if record["status"] == "cropped"]
changed_files: dict[str, list[int]] = {}
for record in changed:
changed_files.setdefault(record["file"], []).append(record["page"])
(output / "cropped-files.txt").write_text(
"".join(
f"{path} - page(s) {', '.join(map(str, pages))}\n"
for path, pages in changed_files.items()
),
encoding="utf-8",
)
cards = []
for record in changed:
relative = Path(record["file"])
output_pdf = quote(record["output"])
preview = quote(record["review"])
name = html.escape(f"{relative.parent.name}/{relative.name} - page {record['page']}")
cards.append(
f'<article><a href="{output_pdf}#page={record["page"]}">{name}</a>'
f'<p>{record["bottom_removed_lines"]:.2f} lignes '
f'({record["bottom_removed_mm"]:.1f} mm) retirées</p>'
f'<img loading="lazy" src="{preview}"></article>'
)
(output / "index.html").write_text(
'<!doctype html><meta charset="utf-8"><title>Rognage bas des exercices</title>'
'<style>body{font:15px system-ui;background:#eee;margin:24px}'
'main{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:20px}'
'article{background:white;padding:12px}img{width:100%}p{font-size:12px}</style>'
f'<h1>{len(changed)} pages rognées</h1>'
'<p>Le rouge montre la zone retirée. Seuls les PDF modifiés sont présents dans cropped/.</p>'
'<main>' + ''.join(cards) + '</main>',
encoding="utf-8",
)
def run(
input_path: Path,
output: Path,
*,
dpi: int = 200,
padding_mm: float = 6,
workers: int = 5,
) -> list[dict]:
copies = input_path / "Copies" if (input_path / "Copies").is_dir() else input_path
if not copies.is_dir():
raise ValueError(f"Dossier Copies introuvable : {input_path}")
sources = sorted(
copies.glob("Copie*/*.pdf"),
key=lambda path: (path.parent.name.casefold(), path.name.casefold()),
)
if not sources:
raise ValueError(f"Aucun PDF d'exercice trouvé dans {copies}")
if workers < 1:
raise ValueError("Le nombre de traitements parallèles doit être positif")
heights: dict[str, float] = {}
for copy_name in sorted({source.parent.name for source in sources}):
copy_pdf = copies / f"{copy_name}.pdf"
if not copy_pdf.is_file():
raise ValueError(f"PDF source introuvable : {copy_pdf}")
heights[copy_name] = _full_page_height(copy_pdf)
with staged_directory(output) as staging:
cropped_dir = staging / "cropped"
review_dir = staging / "review"
jobs = [
(
source,
cropped_dir / source.relative_to(copies),
review_dir,
heights[source.parent.name],
dpi,
padding_mm,
)
for source in sources
]
count = min(workers, len(jobs))
if count == 1:
previous_threads = cv2.getNumThreads()
cv2.setNumThreads(1)
try:
batches = [_process_job(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_job, jobs))
order = {source.as_posix(): i for i, source in enumerate(sources)}
records = sorted(
(record for batch in batches for record in batch),
key=lambda record: (order[record["file"]], record["page"]),
)
(staging / "report.json").write_text(
json.dumps(records, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
with (staging / "report.csv").open("w", encoding="utf-8", newline="") as stream:
fields = sorted({key for record in records for key in record})
writer = csv.DictWriter(stream, fieldnames=fields)
writer.writeheader()
writer.writerows(records)
_write_index(staging, records)
return records
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path, help="Évaluation ou dossier Copies")
parser.add_argument("output", type=Path)
parser.add_argument("--dpi", type=int, default=200)
parser.add_argument("--padding-mm", type=float, default=6)
parser.add_argument("--workers", type=int, default=5)
arguments = parser.parse_args()
if arguments.dpi < 100 or arguments.padding_mm < 0:
parser.error("Utilisez dpi >= 100 et une marge positive ou nulle")
try:
records = run(
arguments.input,
arguments.output,
dpi=arguments.dpi,
padding_mm=arguments.padding_mm,
workers=arguments.workers,
)
except ValueError as error:
parser.error(str(error))
cropped = sum(record["status"] == "cropped" for record in records)
files = len({record["file"] for record in records if record["status"] == "cropped"})
print(f"Terminé : {cropped} page(s) dans {files} PDF rognée(s). Revue : "
f"{arguments.output / 'index.html'}")
if __name__ == "__main__":
main()
+4
View File
@@ -17,6 +17,10 @@ COMMANDS: dict[str, Command] = {
"statement-personal": Command("enonce_info", "Generate personal statement metadata"),
"copies": Command("copies_tools", "Rotate or rename scanned copies"),
"page-split": Command("page_splitter", "Split and reorder scanned PDF pages"),
"crop-margins": Command("crop_margins", "Trim blank top and bottom margins in Copies"),
"crop-answer-bottoms": Command(
"crop_exercise_bottoms", "Trim large blank bottoms from split answers"
),
"crop-labels": Command("cutleft", "Crop the label margin from copies"),
"labels": Command("gemini_for_labels", "Detect question labels with Gemini"),
"review-labels": Command("plotting", "Review detected labels interactively"),
+260
View File
@@ -0,0 +1,260 @@
"""Detect top and bottom content bounds in scanned student work.
Strong coloured strokes are never erased merely because they coincide with
paper ruling. The detector supports coloured handwriting and dark ruled scans;
it remains conservative for pencil-only scans and heavily saturated grids.
"""
from functools import lru_cache
import cv2
import numpy as np
from copienator.paper_background import _skew, _ruling, _foreground, _content_mask
@lru_cache(maxsize=8)
def _paper_kernel(sigma: float) -> np.ndarray:
"""Match OpenCV's uint8 Gaussian coefficients, including error diffusion.
See getGaussianKernelFixedPoint_ED in OpenCV's smooth.dispatch.cpp.
The 8-bit coefficients allow exact sums in the faster float filter path.
"""
size = round(sigma*6+1) | 1
kernel = cv2.getGaussianKernel(size, sigma).ravel()
fixed = np.zeros(size, np.float32)
error = 0.0
for i in range(size//2):
value = kernel[i]*256+error
weight = round(value)
error = value-weight
fixed[i] = fixed[-1-i] = weight
fixed[size//2] = 256-fixed.sum()
fixed /= 256
return fixed
def _paper_blur(gray: np.ndarray, sigma: float) -> np.ndarray:
kernel = _paper_kernel(sigma)
blurred = cv2.sepFilter2D(gray, cv2.CV_32F, kernel, kernel)
# GaussianBlur rounds positive half-integers upward, rather than to even.
np.add(blurred, .5, out=blurred)
np.floor(blurred, out=blurred)
return blurred.astype(np.uint8)
def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float):
"""Refine confirmed ruling, only accepting substantial extra blank margins.
Short directional openings tolerate locally bent/broken paper lines. A
physical component-size threshold rejects their remaining tiny fragments.
This is deliberately limited to already-confirmed ruled paper.
"""
px = dpi / 25.4
height, width = gray.shape
dark = 255-gray
length = max(9, round(2.5*px))
tolerance = max(3, round(.6*px) | 1)
lines = []
for horizontal in (True, False):
broadened = cv2.dilate(dark, np.ones(
(tolerance, 1) if horizontal else (1, tolerance), np.uint8))
lines.append(cv2.morphologyEx(broadened, cv2.MORPH_OPEN, np.ones(
(1, length) if horizontal else (length, 1), np.uint8)))
residual = cv2.subtract(dark, np.maximum(*lines))
n, labels, stats, centers = cv2.connectedComponentsWithStats(
(residual > 35).astype(np.uint8), 8)
keep = np.zeros(n, bool)
ww, hh, area = stats[1:, 2], stats[1:, 3], stats[1:, 4]
keep[1:] = ((area >= .8*px*px) & (np.minimum(ww, hh) >= .6*px)
& (area / (ww*hh) > .3)
& (np.maximum(ww, hh) < 4*np.minimum(ww, hh)))
# Use side columns as a prior, then require repeated size and alignment. An
# isolated note in the same column remains eligible to protect the margin.
xx = stats[1:, 0]
candidates = np.flatnonzero(
((xx < 15*px) | (xx+ww > width-15*px))
& (ww > px) & (ww < 9*px) & (hh > px) & (hh < 12*px)) + 1
if len(candidates) > 128:
# Bound the matching cost and retain ambiguous, very noisy margins.
return clean, 0, False
holes = set()
for i in candidates:
matches = []
for j in candidates:
if (abs(centers[i, 0]-centers[j, 0]) < 2*px
and .6 < stats[j, 2]/stats[i, 2] < 1.6
and .6 < stats[j, 3]/stats[i, 3] < 1.6):
matches.append(j)
if len(matches) >= 3 and np.ptp(centers[matches, 1]) > height*.35:
holes.update(matches)
keep[list(holes)] = False
refined = keep[labels]
# Directional opening also removes long fraction bars. Protect very dark,
# thick straight strokes independently, even if ruling crosses their ends.
long_strokes = cv2.morphologyEx((gray < 50).astype(np.uint8), cv2.MORPH_OPEN,
np.ones((1, max(9, round(width*.1))), np.uint8))
count, lab, st, _ = cv2.connectedComponentsWithStats(long_strokes, 8)
bars = np.zeros(count, bool)
bw, bh, ba = st[1:, 2], st[1:, 3], st[1:, 4]
bars[1:] = ((bw > width*.1) & (bh >= .3*px) & (bh < height*.015)
& (bw > 8*bh) & (ba/(bw*bh) > .5))
strong_ruling, strong_lines = _ruling((gray < 50).astype(np.uint8)*255, True)
if strong_lines:
# A family of equally dark parallel lines is paper, not fraction bars.
overlap = np.bincount(lab[strong_ruling > 0], minlength=count)
bars &= overlap < st[:, 4]*.5
refined |= bars[lab]
ys = np.flatnonzero(np.any(refined, axis=1))
original = np.flatnonzero(np.any(clean, axis=1))
if not len(ys) or not len(original):
return clean, 0, False
# Leave ordinary small crops to the more permissive detector. Keep a
# recovery neighbourhood around the refined bounds for broken/faint strokes.
top, bottom = max(0, int(ys.min()-2*px)), min(height, int(ys.max()+1+2*px))
result = clean.copy()
changed = False
if top-original.min() >= 30*px:
result[:top] = 0
changed = True
if original.max()+1-bottom >= 30*px:
result[bottom:] = 0
changed = True
return result, len(holes), changed
def _neutral_paper_foreground(gray: np.ndarray, chroma: np.ndarray, dpi: float):
"""Clean confirmed dark ruling before it can seed whole pages.
Returns None for ordinary ink components or unconfirmed paper geometry.
The mask is transformed back to the original displayed pixel coordinates.
"""
height, width = gray.shape
# Only neutral darkness is relevant here: long blue equations are not
# evidence of dark paper. Broken ruling can form several medium-sized
# components instead of one page-spanning component. The broader threshold
# also admits faded gray grids; periodic ruling must still be confirmed.
_, _, stats, _ = cv2.connectedComponentsWithStats(
((gray < 160) & (chroma < 30)).astype(np.uint8), 8)
spans = np.maximum(stats[1:,2]/width, stats[1:,3]/height)
if not (np.any(spans > .35) or np.count_nonzero(spans > .1) >= 3):
return None, dict(paper_cleanup=False)
angle = _skew(gray, angle_step=.5)
matrix = cv2.getRotationMatrix2D((width/2, height/2), angle, 1)
corners = np.array([[0,0],[width,0],[0,height],[width,height]], dtype=float)
corners = cv2.transform(corners[None], matrix)[0]
origin = np.floor(corners.min(axis=0))
size = np.ceil(corners.max(axis=0)-origin).astype(int)
matrix[:, 2] -= origin
deskewed = cv2.warpAffine(gray, matrix, tuple(size), borderValue=255)
background = _paper_blur(deskewed, dpi/8)
normalized = cv2.divide(deskewed, np.maximum(background,1), scale=255)
block = max(15, int(dpi/5) | 1)
binary = cv2.adaptiveThreshold(normalized,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV,block,9)
horizontal, nh = _ruling(binary,True)
vertical, nv = _ruling(binary,False)
if not (nh or nv):
return None, dict(paper_cleanup=False)
# The permissive mask retains faint, isolated marks.
# Bands identify where ruling is expected, but only actual dark pixels
# inside them may be suppressed. Erasing the complete band loses faint
# writing alongside a dark grid line.
paper_pixels = (horizontal|vertical) & ((normalized < 160).astype(np.uint8)*255)
clean, holes = _content_mask(_foreground(normalized,dpi,9,paper_pixels),dpi,nh,nv)
clean, repeated_holes, refined = _large_blank_ink(deskewed, clean, dpi)
restored = cv2.warpAffine(clean, cv2.invertAffineTransform(matrix),
(width,height), flags=cv2.INTER_NEAREST, borderValue=0)
return restored > 0, dict(paper_cleanup=True, angle_deg=round(angle,3),
horizontal_lines=nh,vertical_lines=nv,
edge_artifacts=holes+repeated_holes,
large_blank_refinement=refined)
def _seed_mask(mask: np.ndarray, px: float) -> np.ndarray:
n, labels, stats, _ = cv2.connectedComponentsWithStats(mask.astype(np.uint8), 8)
keep = np.zeros(n, bool)
keep[1:] = ((stats[1:, 4] >= max(4, .12*px*px))
& (stats[1:, 2] >= max(2, round(.35*px)))
& (stats[1:, 3] >= max(2, round(.35*px))))
return keep[labels]
def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
min_crop_mm: float = 5) -> dict:
"""Locate strong ink, recover adjacent faint strokes, and retain padding.
Neutral punched-hole shadows usually have neither sufficient chroma nor
sufficient darkness to seed a region. Nothing is discarded merely because
it is in a side margin. Two seed thresholds expose unstable boundaries.
"""
px = dpi/25.4
h, w = rgb.shape[:2]
red, green, blue = cv2.split(rgb)
lowest = cv2.min(cv2.min(red, green), blue)
chroma = cv2.subtract(cv2.max(cv2.max(red, green), blue), lowest)
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
darkness = 255-lowest
length = max(15, round(5.2*px)) | 1
horizontal = cv2.morphologyEx(darkness, cv2.MORPH_OPEN,
np.ones((1, length), np.uint8))
vertical = cv2.morphologyEx(darkness, cv2.MORPH_OPEN,
np.ones((length, 1), np.uint8))
residual = cv2.subtract(darkness, np.maximum(horizontal, vertical))
# Strong strokes bypass the line-background estimate entirely: even a long
# isolated black or coloured fraction bar must survive. Local contrast is
# used only to recover weaker surrounding strokes.
neutral_ink = gray < 95
cleaned, paper_info = _neutral_paper_foreground(gray, chroma, dpi)
if cleaned is not None:
neutral_ink = cleaned
weak = ((chroma > 60) | ((gray < 175) & (residual > 25))).astype(np.uint8)
radius = max(1, round(2*px))
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*radius+1, 2*radius+1))
extents = []
counts = []
same_thresholds = not np.any((chroma > 100) & (chroma <= 115) & ~neutral_ink)
for threshold in (100, 115):
if threshold == 115 and same_thresholds:
counts.append(counts[0])
if extents:
extents.append(extents[0])
continue
seeds = _seed_mask((chroma > threshold) | neutral_ink, px)
counts.append(int(np.count_nonzero(seeds)))
if not np.any(seeds):
continue
# Limit weak recovery to a physical neighbourhood: faint grid lines
# connected to a letter cannot grow into a full-page foreground mask.
nearby = cv2.dilate(seeds.astype(np.uint8), kernel)
candidate = (weak & nearby) | seeds.astype(np.uint8)
n, labels = cv2.connectedComponents(candidate, 8)
seeded_labels = np.zeros(n, bool)
seeded_labels[np.unique(labels[seeds])] = True
seeded_labels[0] = False
ys = np.flatnonzero(np.any(seeded_labels[labels], axis=1))
extents.append((int(ys.min()), int(ys.max())+1))
result = dict(top_px=0, bottom_px=h, angle_deg=None,
horizontal_lines=0, vertical_lines=0, edge_artifacts=0,
detector="ink", seed_pixels=counts, status="review-no-ink-seeds")
result.update(paper_info)
if not extents:
return result
pad = padding_mm*px
top = max(0, int(np.floor(min(e[0] for e in extents)-pad)))
bottom = min(h, int(np.ceil(max(e[1] for e in extents)+pad)))
uncertain = []
if len(extents) < 2:
uncertain.append('seed-threshold')
else:
for edge, name in ((0, 'top'), (1, 'bottom')):
if abs(extents[0][edge]-extents[1][edge]) > max(pad, 3*px):
uncertain.append(name)
if top < min_crop_mm*px:
top = 0
if h-bottom < min_crop_mm*px:
bottom = h
result.update(top_px=top, bottom_px=bottom,
status=('review-'+'-'.join(uncertain) if uncertain else
'cropped' if top or bottom < h else 'unchanged'))
return result
+146
View File
@@ -0,0 +1,146 @@
"""Shared geometry and foreground helpers for scanned paper backgrounds."""
import cv2
import numpy as np
def _runs(values: np.ndarray) -> list[tuple[int, int]]:
edges = np.diff(np.r_[False, values, False].astype(np.int8))
return list(zip(np.flatnonzero(edges == 1), np.flatnonzero(edges == -1)))
def _skew(gray: np.ndarray, angle_step: float = .1) -> float:
"""Use the dominant near-horizontal/vertical Hough angle, at reduced size."""
scale = min(1.0, 1200 / max(gray.shape))
small = cv2.resize(gray, None, fx=scale, fy=scale)
edges = cv2.Canny(small, 40, 120)
lines = cv2.HoughLinesP(edges, 1, np.deg2rad(angle_step), 60,
minLineLength=min(small.shape) * .16, maxLineGap=12)
if lines is None:
return 0.0
angles, weights = [], []
for x0, y0, x1, y1 in lines.reshape(-1, 4):
a = (np.degrees(np.arctan2(y1-y0, x1-x0)) + 45) % 90 - 45
if abs(a) <= 5:
angles.append(a)
weights.append(np.hypot(x1-x0, y1-y0))
return _dominant_angle(angles, weights)
def _dominant_angle(angles, weights) -> float:
if len(angles) < 4:
return 0.0
angles, weights = np.array(angles), np.array(weights)
bins = np.arange(-5.125, 5.126, .25)
hist, _ = np.histogram(angles, bins, weights=weights)
peak = (bins[hist.argmax()] + bins[hist.argmax()+1]) / 2
near = abs(angles-peak) < .4
if weights[near].sum() < .35 * weights.sum():
return 0.0
return float(np.average(angles[near], weights=weights[near]))
def _ruling(binary: np.ndarray, horizontal: bool) -> tuple[np.ndarray, int]:
"""Accept a family of long lines only when positions are largely periodic."""
h, w = binary.shape
length = max(25, int((w if horizontal else h) * .12))
kernel = cv2.getStructuringElement(cv2.MORPH_RECT,
(length, 1) if horizontal else (1, length))
connected = cv2.morphologyEx(binary, cv2.MORPH_CLOSE,
np.ones((1, 3) if horizontal else (3, 1), np.uint8))
lines = cv2.morphologyEx(connected, cv2.MORPH_OPEN, kernel)
counts = np.count_nonzero(lines, axis=1 if horizontal else 0)
bands = _runs(counts > (w if horizontal else h) * .18)
if len(bands) < 5:
return np.zeros_like(binary), 0
centers = np.array([(a+b)/2 for a, b in bands])
gaps = np.diff(centers)
# Missing lines and major/minor rulings may have integer-multiple spacing.
candidates = gaps[gaps >= 4]
regular = any(np.mean(abs(gaps / d - np.round(gaps / d)) < .16) >= .75
for d in candidates)
if not regular:
return np.zeros_like(binary), 0
accepted = np.zeros_like(binary)
for a, b in bands:
if horizontal:
accepted[max(0, a-2):b+2] = 255
else:
accepted[:, max(0, a-2):b+2] = 255
# Real scans have local warp as well as global skew. Recover shorter line
# segments close to the established ruling family, without extending the
# entire family into large empty gaps.
short = max(25, int((w if horizontal else h)*.035))
joined = cv2.morphologyEx(binary, cv2.MORPH_CLOSE,
np.ones((1, 7) if horizontal else (7, 1), np.uint8))
tolerant = cv2.dilate(joined, np.ones((3, 1) if horizontal else (1, 3), np.uint8))
fragments = cv2.morphologyEx(tolerant, cv2.MORPH_OPEN,
np.ones((1, short) if horizontal else (short, 1), np.uint8))
nearby = cv2.dilate(accepted, np.ones((15, 1) if horizontal else (1, 15), np.uint8))
accepted |= fragments & nearby if horizontal else fragments
return accepted, len(bands)
def _foreground(gray: np.ndarray, dpi: float, threshold: int,
ruling: np.ndarray) -> np.ndarray:
block = max(15, int(dpi / 5) | 1)
binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV, block, threshold)
# Only suppress near the fitted paper lines. Preserve unusually dark ink
# crossing pale ruling by comparing against the typical line intensity.
mask = cv2.dilate(ruling, np.ones((3, 3), np.uint8)) > 0
if np.any(ruling):
samples = ((ruling > 0) & (binary > 0)).astype(np.float32)
window = max(31, int(dpi*.4) | 1)
weight = cv2.boxFilter(samples, -1, (window, window))
total = cv2.boxFilter(gray.astype(np.float32)*samples, -1, (window, window))
typical = total / np.maximum(weight, 1e-6)
excess_ink = (gray.astype(float) < typical - 40).astype(np.uint8)
# A dark, one-pixel remnant of a paper line is still paper. Only retain
# locally thicker excess strokes inside the suppression mask.
excess_ink = cv2.erode(excess_ink, np.ones((2, 2), np.uint8)) > 0
mask &= ~excess_ink
binary[mask] = 0
return binary
def _content_mask(binary: np.ndarray, dpi: float, nh: int = 0,
nv: int = 0) -> tuple[np.ndarray, int]:
"""Filter only tiny speckles and repeated, matching edge-hole components."""
px = dpi / 25.4
# Small closing reconnects strokes interrupted by paper-line suppression.
grouped = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, np.ones((3, 3), np.uint8))
n, labels, stats, _ = cv2.connectedComponentsWithStats(grouped, 8)
keep = np.zeros(n, dtype=bool)
x, y, w, h, area = stats[1:].T
keep[1:] = (area >= max(4, .035*px*px)) & (np.maximum(w, h) >= .35*px)
# Thin straight residuals on confirmed ruled paper are not credible ink.
thin = max(2, round(.3*px))
if nv:
keep[1:] &= ~((w <= thin) & (h >= 3*w))
if nh:
keep[1:] &= ~((h <= thin) & (w >= 3*h))
# Hole shadows form recurring shapes close to a physical side edge. Never
# discard an entire margin strip: other writing there must remain visible.
height, width = binary.shape
candidates = np.flatnonzero(keep[1:]
& ((x+w < 12*px) | (x > width-12*px))
& (w > .8*px) & (w < 9*px) & (h > px) & (h < 12*px)) + 1
holes = set()
for i in candidates:
x, y, w, h, area = stats[i]
similar = []
a = cv2.resize((labels[y:y+h, x:x+w] == i).astype(np.uint8), (24, 32)) > 0
for j in candidates:
xx, yy, ww, hh, aa = stats[j]
if abs(x-xx) > 2*px or not (.7 < ww/w < 1.4 and .7 < hh/h < 1.4):
continue
b = cv2.resize((labels[yy:yy+hh, xx:xx+ww] == j).astype(np.uint8), (24, 32)) > 0
if np.count_nonzero(a & b) / max(1, np.count_nonzero(a | b)) > .60:
similar.append(j)
if len(similar) >= 4 and np.ptp(stats[similar, 1]) > height*.45:
holes.update(similar)
if holes:
keep[list(holes)] = False
# Bound original residual ink, not the expanded/grouped mask.
return (keep[labels] & (binary > 0)).astype(np.uint8), len(holes)
+3 -3
View File
@@ -589,7 +589,7 @@ class CopienatorApp(tk.Tk):
return
self._rendering = True
redo = step.section == REFAIRE_SECTION
expanded_form = redo or step.id in {"page_splitter", "cutleft", "labels"}
expanded_form = redo or step.id in {"page_splitter", "crop_blank_margins", "cutleft", "labels"}
self.console.configure(height=8 if expanded_form else 14)
self.rowconfigure(1, weight=4 if expanded_form else 3)
self.rowconfigure(2, weight=1 if expanded_form else 2)
@@ -731,7 +731,7 @@ class CopienatorApp(tk.Tk):
return row + 1
if step.id in {"review_persp", "correction"}:
row = self._correction_folder_buttons(row)
if step.id in {"page_splitter", "cutleft", "labels"}:
if step.id in {"page_splitter", "crop_blank_margins", "cutleft", "labels"}:
evaluation = self.evaluation
paths = copy_pdf_paths(evaluation) if evaluation else []
self.copy_paths = {path.name: path for path in paths}
@@ -762,7 +762,7 @@ class CopienatorApp(tk.Tk):
command=self._open_selected_copy,
state="normal" if names else "disabled",
).grid(row=1, column=1, padx=(6, 0), pady=(7, 0))
if step.id in {"page_splitter", "cutleft", "labels"}:
if step.id in {"page_splitter", "crop_blank_margins", "cutleft", "labels"}:
actions = ttk.Frame(copies)
actions.grid(row=2, column=0, columnspan=2, sticky="w", pady=(7, 0))
label = "Refaire la copie sélectionnée" if step.id == "page_splitter" else "Cibler la copie sélectionnée"
+34 -10
View File
@@ -176,6 +176,19 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
arguments=(arg_target(), ArgumentSpec("marked", "Copies signalées uniquement", "bool", "--marked")),
artifacts=("Copies", "Copies Originales"),
),
StepDefinition(
"crop_blank_margins",
"Prétraitement des copies",
"Rogner les zones vides",
"Facultatif : détecte les zones vides en haut et en bas malgré les lignes et les perforations, "
"puis remplace les PDF dans Copies. Les versions non rognées sont sauvegardées. "
"À effectuer avant la détection des labels. Traite plusieurs copies en parallèle.",
(python("default", "Rognage des zones vides", "crop-margins"),),
arguments=(arg_target("Dossier de l’évaluation ou PDF dans Copies"),
ArgumentSpec("workers", "Copies traitées en parallèle", "int", "--workers", default=5)),
optional=True,
requires=("Copies",),
),
StepDefinition(
"cutleft",
"Prétraitement des copies",
@@ -224,6 +237,27 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
artifacts=("Copies/Copie*/*",),
auto_start_first_visit=True,
),
StepDefinition(
"crop_exercise_bottoms",
"Labels et regroupement",
"Rogner le bas des réponses",
"Facultatif après le découpage par labels : rogne uniquement les grands espaces "
"vides au bas des réponses. Les PDF modifiés sont remplacés, les originaux sont "
"sauvegardés et plusieurs fichiers sont analysés en parallèle.",
(python("default", "Rognage du bas", "crop-answer-bottoms"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec(
"workers",
"PDF traités en parallèle",
"int",
"--workers",
default=5,
),
),
optional=True,
requires=("Copies/Copie*/*.pdf",),
),
StepDefinition(
"grouping",
"Labels et regroupement",
@@ -235,16 +269,6 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
artifacts=("Par label",),
auto_start_first_visit=True,
),
StepDefinition(
"verify_groups",
"Labels et regroupement",
"Vérifier les groupes produits",
"Vérifie que chaque réponse PDF apparaît dans les métadonnées des groupes.",
(python("default", "Vérification des groupes", "verify-groups"),),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
requires=("Copies", "Par label"),
),
StepDefinition(
"correction",
"Correction",
+1
View File
@@ -9,6 +9,7 @@ description = "Workflow assistant for preparing and correcting scanned exams"
requires-python = ">=3.11"
dependencies = [
"numpy",
"opencv-python-headless>=4.8",
"pandas",
"matplotlib",
"Pillow",
+30
View File
@@ -0,0 +1,30 @@
import unittest
import numpy as np
import pymupdf
from copienator.crop_blank_margins import apply_bounds
class CropBlankMarginsTests(unittest.TestCase):
def test_cropbox_coordinates_with_rotation_and_existing_crop(self):
for rotation in (0, 90, 180, 270):
with self.subTest(rotation=rotation), pymupdf.open() as doc:
page = doc.new_page(width=600, height=800)
page.set_cropbox(pymupdf.Rect(30, 40, 570, 760))
page.set_rotation(rotation)
before = page.rect
page.insert_text((80, 220), 'Visible content', fontsize=20)
pix = page.get_pixmap()
original = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3)
apply_bounds(page, .2, .8)
self.assertAlmostEqual(page.rect.width, before.width)
self.assertAlmostEqual(page.rect.height, before.height*.6, places=3)
self.assertEqual(page.rotation, rotation)
pix = page.get_pixmap()
cropped = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3)
np.testing.assert_array_equal(cropped, original[int(before.height*.2):int(before.height*.8)])
if __name__ == '__main__':
unittest.main()
+59
View File
@@ -0,0 +1,59 @@
import tempfile
import unittest
from pathlib import Path
import pymupdf
from copienator.crop_exercise_bottoms import process_exercise_pdf
class CropExerciseBottomsTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.review = self.root / "review"
def make_pdf(self, name: str, height: float, text_y: float, footer=True) -> Path:
path = self.root / name
with pymupdf.open() as document:
page = document.new_page(width=600, height=height)
page.insert_text((80, text_y), "student answer", fontsize=16)
if footer:
page.insert_text((20, height - 3), "Ex 2", fontsize=10)
document.save(path)
return path
def test_short_page_is_skipped(self):
source = self.make_pdf("short.pdf", 200, 100)
destination = self.root / "out" / source.name
records = process_exercise_pdf(
source, destination, self.review, 800, dpi=150
)
self.assertEqual(records[0]["status"], "skipped-short")
self.assertFalse(destination.exists())
def test_footer_fragment_is_ignored_for_a_large_bottom_crop(self):
source = self.make_pdf("large.pdf", 400, 100)
destination = self.root / "out" / source.name
records = process_exercise_pdf(
source, destination, self.review, 800, dpi=150
)
self.assertEqual(records[0]["status"], "cropped")
self.assertGreaterEqual(records[0]["bottom_removed_lines"], 4)
with pymupdf.open(destination) as result:
self.assertLess(result[0].rect.height, 250)
self.assertAlmostEqual(result[0].rect.width, 600)
def test_crop_smaller_than_four_lines_is_not_written(self):
source = self.make_pdf("small.pdf", 400, 330, footer=False)
destination = self.root / "out" / source.name
records = process_exercise_pdf(
source, destination, self.review, 800, dpi=150
)
self.assertEqual(records[0]["status"], "unchanged-small-crop")
self.assertFalse(destination.exists())
if __name__ == "__main__":
unittest.main()
+123
View File
@@ -0,0 +1,123 @@
import contextlib
import io
import tempfile
import unittest
from pathlib import Path
import pymupdf
from copienator.cli import CliError
from copienator.commands import crop_exercise_bottoms
from copienator.commands.clean import apply_cleanup, build_cleanup_plan
from copienator.dispatcher import main
from copienator.workspace import EvaluationWorkspace
from copienator_gui.workflow import build_workflow
class CropExerciseBottomsCommandTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.workspace = EvaluationWorkspace(Path(self.temp.name) / "Évaluation")
self.workspace.copies_dir.mkdir(parents=True)
self.copy_pdf = self.workspace.copies_dir / "Copie01.pdf"
with pymupdf.open() as document:
document.new_page(width=600, height=800)
document.save(self.copy_pdf)
self.answers = self.workspace.copies_dir / "Copie01"
self.answers.mkdir()
self.large = self.answers / "Ex 1.pdf"
self.short = self.answers / "Ex 2.pdf"
self._make_answer(self.large, 400, 100, footer=True)
self._make_answer(self.short, 200, 100, footer=False)
self.large_original = self.large.read_bytes()
self.short_original = self.short.read_bytes()
@staticmethod
def _make_answer(path: Path, height: float, text_y: float, *, footer: bool) -> None:
with pymupdf.open() as document:
page = document.new_page(width=600, height=height)
page.insert_text((80, text_y), "student answer", fontsize=16)
if footer:
page.insert_text((20, height - 3), "Ex 2", fontsize=10)
document.save(path)
def test_dispatcher_replaces_only_changed_answers_and_backs_them_up(self):
with contextlib.redirect_stdout(io.StringIO()) as log:
self.assertEqual(
main(["crop-answer-bottoms", str(self.workspace.root), "--workers", "2"]),
0,
)
self.assertIn("1 PDF remplacé", log.getvalue())
with pymupdf.open(self.large) as cropped:
self.assertLess(cropped[0].rect.height, 250)
self.assertEqual(self.short.read_bytes(), self.short_original)
backups = list(
self.workspace.runs_dir.glob(
"crop-exercise-bottoms-*/Copies/Copie01/Ex 1.pdf"
)
)
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_bytes(), self.large_original)
self.assertFalse(
list(
self.workspace.runs_dir.glob(
"crop-exercise-bottoms-*/Copies/Copie01/Ex 2.pdf"
)
)
)
self.assertTrue((backups[0].parents[2] / "report.json").is_file())
def test_detection_failure_does_not_publish_an_earlier_result(self):
broken = self.answers / "Ex 3.pdf"
broken.write_bytes(b"not a PDF")
with contextlib.redirect_stdout(io.StringIO()), self.assertRaises(Exception):
crop_exercise_bottoms.run(
self.workspace, self.workspace.root, workers=1
)
self.assertEqual(self.large.read_bytes(), self.large_original)
self.assertEqual(self.short.read_bytes(), self.short_original)
self.assertEqual(broken.read_bytes(), b"not a PDF")
self.assertFalse(self.workspace.runs_dir.exists())
def test_invalid_target_and_worker_count_are_rejected(self):
with self.assertRaises(CliError):
crop_exercise_bottoms.run(self.workspace, self.large, workers=1)
with self.assertRaises(CliError):
crop_exercise_bottoms.run(self.workspace, self.workspace.root, workers=0)
def test_archiving_removes_the_retained_originals_and_report(self):
with contextlib.redirect_stdout(io.StringIO()):
crop_exercise_bottoms.run(
self.workspace, self.workspace.root, workers=1
)
run_dirs = list(
self.workspace.runs_dir.glob("crop-exercise-bottoms-*")
)
self.assertEqual(len(run_dirs), 1)
self.workspace.correction_file.write_text("{}")
student = self.workspace.return_dir / "Student"
student.mkdir(parents=True)
(student / "answer.jpg").write_bytes(b"return image")
(student / "score.json").write_text("{}")
plan = build_cleanup_plan(self.workspace)
self.assertTrue(
any(path.name == "report.json" for path in plan.deleted_files)
)
with contextlib.redirect_stdout(io.StringIO()):
apply_cleanup(self.workspace, plan)
self.assertFalse(self.workspace.runs_dir.exists())
def test_optional_step_follows_splitting_and_precedes_grouping(self):
steps = build_workflow(False)
index = next(
i for i, step in enumerate(steps) if step.id == "crop_exercise_bottoms"
)
self.assertEqual(steps[index - 1].id, "splitting")
self.assertEqual(steps[index + 1].id, "grouping")
self.assertTrue(steps[index].optional)
self.assertFalse(steps[index].auto_start_first_visit)
if __name__ == "__main__":
unittest.main()
+189
View File
@@ -0,0 +1,189 @@
import contextlib
import io
import os
import signal
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import pymupdf
from copienator.cli import CliError
from copienator.commands import crop_margins
from copienator.commands.clean import build_cleanup_plan, apply_cleanup
from copienator.dispatcher import main
from copienator.workspace import EvaluationWorkspace
from copienator_gui.workflow import build_workflow
class CropMarginsCommandTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.workspace = EvaluationWorkspace(Path(self.temp.name)/"Évaluation")
self.workspace.copies_dir.mkdir(parents=True)
self.source = self.workspace.copies_dir/"Copie01.pdf"
with pymupdf.open() as doc:
page = doc.new_page(width=300, height=420)
page.insert_text((50, 180), "answer = 42", fontsize=15)
doc.new_page(width=300, height=420)
doc.save(self.source)
self.original = self.source.read_bytes()
def test_dispatcher_replaces_copies_and_keeps_recoverable_original(self):
with contextlib.redirect_stdout(io.StringIO()) as log:
self.assertEqual(main(["crop-margins", str(self.workspace.root)]), 0)
self.assertIn("Page 2/2", log.getvalue())
with pymupdf.open(self.source) as result:
self.assertEqual(len(result), 2)
self.assertLess(result[0].rect.height, 150)
self.assertIn("answer = 42", result[0].get_text())
self.assertEqual(result[1].rect.height, 420)
for page in result:
self.assertEqual(page.rect.width, 300)
page.get_pixmap()
backups = list(self.workspace.runs_dir.glob("crop-margins-*/Copies/Copie01.pdf"))
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_bytes(), self.original)
self.assertTrue((backups[0].parent.parent/"report.json").is_file())
def test_failure_or_interruption_never_publishes_partial_batch(self):
second = self.workspace.copies_dir/"Copie02.pdf"
second.write_bytes(self.original)
real = crop_margins.process_pdf
for error in (OSError("bad scan"), KeyboardInterrupt()):
with self.subTest(error=type(error).__name__):
def process(source, *args, **kwargs):
if source == second:
raise error
return real(source, *args, **kwargs)
with patch.object(crop_margins, "process_pdf", side_effect=process):
with contextlib.redirect_stdout(io.StringIO()):
with self.assertRaises(type(error)):
crop_margins.run(self.workspace, self.workspace.root, workers=1)
self.assertEqual(self.source.read_bytes(), self.original)
self.assertEqual(second.read_bytes(), self.original)
def test_existing_coordinates_are_not_silently_invalidated(self):
self.source.with_suffix(".json").write_text('{"list": []}')
with self.assertRaisesRegex(CliError, "coordonnées"):
crop_margins.run(self.workspace, self.workspace.root)
self.assertEqual(self.source.read_bytes(), self.original)
def test_archiving_removes_crop_backups_but_keeps_processed_pdf_and_logs(self):
with contextlib.redirect_stdout(io.StringIO()):
crop_margins.run(self.workspace, self.workspace.root)
processed = self.source.read_bytes()
self.workspace.correction_file.write_text('{}')
student = self.workspace.return_dir/"Student"
student.mkdir(parents=True)
(student/"answer.jpg").write_bytes(b"return image")
(student/"score.json").write_text('{}')
self.workspace.logs_dir.mkdir(parents=True)
log = self.workspace.logs_dir/"crop_blank_margins.log"
log.write_text("Completed crop")
backups = list(self.workspace.runs_dir.glob("crop-margins-*/Copies/*.pdf"))
reports = list(self.workspace.runs_dir.glob("crop-margins-*/report.json"))
self.assertTrue(backups)
self.assertTrue(reports)
plan = build_cleanup_plan(self.workspace)
for path in backups+reports:
self.assertIn(path, plan.deleted_files)
self.assertIn(self.source, plan.kept_files)
self.assertIn(log, plan.kept_files)
with contextlib.redirect_stdout(io.StringIO()):
apply_cleanup(self.workspace, plan)
self.assertFalse(self.workspace.runs_dir.exists())
self.assertEqual(self.source.read_bytes(), processed)
self.assertEqual(log.read_text(), "Completed crop")
self.assertTrue((student/"answer.jpg").exists())
self.assertTrue((student/"score.json").exists())
def test_single_copy_selection_cannot_target_original_scans(self):
self.assertEqual(crop_margins.selected_files(self.workspace, self.source), [self.source])
original = self.workspace.root/"Original.pdf"
original.write_bytes(self.original)
with self.assertRaises(CliError):
crop_margins.selected_files(self.workspace, original)
def test_parallel_workers_match_serial_results_in_copy_and_page_order(self):
second = self.workspace.copies_dir/"Copie02.pdf"
second.write_bytes(self.original)
serial, parallel = self.workspace.root/"serial", self.workspace.root/"parallel"
serial.mkdir()
parallel.mkdir()
files = [self.source, second]
with contextlib.redirect_stdout(io.StringIO()):
expected = crop_margins.process_copies(files, serial, 1)
actual = crop_margins.process_copies(files, parallel, 2)
self.assertEqual(actual, expected)
self.assertEqual([(r['file'],r['page']) for r in actual],
[(p.name,i) for p in files for i in (1,2)])
for source in files:
with pymupdf.open(serial/source.name) as a, pymupdf.open(parallel/source.name) as b:
self.assertEqual([list(p.cropbox) for p in a], [list(p.cropbox) for p in b])
def test_worker_failure_does_not_replace_any_copy(self):
broken = self.workspace.copies_dir/"Copie02.pdf"
broken.write_bytes(b"not a PDF")
with contextlib.redirect_stdout(io.StringIO()):
with self.assertRaises(Exception):
crop_margins.run(self.workspace, self.workspace.root, workers=2)
self.assertEqual(self.source.read_bytes(), self.original)
self.assertEqual(broken.read_bytes(), b"not a PDF")
self.assertFalse(list(self.workspace.root.glob(".Copies.*.files.tmp")))
def test_invalid_worker_count_is_rejected_before_processing(self):
with self.assertRaises(CliError):
crop_margins.run(self.workspace, self.workspace.root, workers=0)
self.assertEqual(self.source.read_bytes(), self.original)
@unittest.skipIf(os.name == "nt", "SIGINT subprocess check uses Unix signals")
def test_interrupt_stops_parallel_workers_before_removing_staging(self):
import select
with pymupdf.open() as doc:
for _ in range(30):
page = doc.new_page(width=595, height=842)
page.insert_text((100,400), "answer = 42", fontsize=20)
data = doc.tobytes()
self.source.write_bytes(data)
second = self.workspace.copies_dir/"Copie02.pdf"
second.write_bytes(data)
proc = subprocess.Popen(
[sys.executable,"-u","-m","copienator","crop-margins",
str(self.workspace.root),"--workers","2"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
cwd=Path(__file__).resolve().parents[1])
try:
while True:
ready, _, _ = select.select([proc.stdout], [], [], 20)
self.assertTrue(ready, "No progress from parallel crop command")
line = proc.stdout.readline()
self.assertTrue(line, "Crop command exited before processing a page")
if "Page " in line:
break
proc.send_signal(signal.SIGINT)
output, _ = proc.communicate(timeout=20)
self.assertEqual(proc.returncode, 130, output)
finally:
if proc.poll() is None:
proc.kill()
proc.communicate()
self.assertEqual(self.source.read_bytes(), data)
self.assertEqual(second.read_bytes(), data)
self.assertFalse(list(self.workspace.root.glob(".Copies.*.files.tmp")))
def test_optional_step_sits_between_page_split_and_label_crop(self):
steps = build_workflow(False)
index = next(i for i, step in enumerate(steps) if step.id == "crop_blank_margins")
self.assertEqual(steps[index-1].id, "page_splitter")
self.assertEqual(steps[index+1].id, "cutleft")
self.assertTrue(steps[index].optional)
self.assertFalse(steps[index].auto_start_first_visit)
if __name__ == "__main__":
unittest.main()
+34
View File
@@ -52,6 +52,40 @@ class GuiConvenienceTests(unittest.TestCase):
self.app.open_evaluation_button.invoke()
opened.assert_called_once_with(self.evaluation)
def test_optional_blank_crop_can_target_a_copy_or_be_skipped(self):
copies = self.evaluation/"Copies"
copies.mkdir()
source = copies/"Copie01.pdf"
source.touch()
self.app.tree.selection_set("crop_blank_margins")
self.app.update()
self.assertEqual(self.app.current_step.id, "crop_blank_margins")
self.assertEqual(str(self.app.skip_button.cget("state")), "normal")
self.app.copy_var.set(source.name)
self.app._target_selected_copy()
command = self.app._make_command()
self.assertEqual(command[-4:], ["crop-margins", str(source), "--workers", "5"])
self.app._skip_step()
self.assertEqual(self.app.state_store.step("crop_blank_margins")["status"], "skipped")
def test_optional_answer_bottom_crop_uses_parallel_workers_and_can_be_skipped(self):
answers = self.evaluation / "Copies" / "Copie01"
answers.mkdir(parents=True)
(answers / "Ex 1.pdf").touch()
self.app.tree.selection_set("crop_exercise_bottoms")
self.app.update()
self.assertEqual(self.app.current_step.id, "crop_exercise_bottoms")
self.assertEqual(str(self.app.skip_button.cget("state")), "normal")
command = self.app._make_command()
self.assertEqual(
command[-4:],
["crop-answer-bottoms", self.app._evaluation_arg(), "--workers", "5"],
)
self.app._skip_step()
self.assertEqual(
self.app.state_store.step("crop_exercise_bottoms")["status"], "skipped"
)
def test_redo_targets_selected_copy_and_copies_runnable_command(self):
for folder in ("Copies", "Copies Originales"):
(self.evaluation / folder).mkdir()
+163
View File
@@ -0,0 +1,163 @@
import unittest
import cv2
import numpy as np
from copienator.ink_detection import detect_bounds, _paper_blur
class InkDetectionTests(unittest.TestCase):
def test_fast_background_blur_matches_opencv_pixel_for_pixel(self):
rng = np.random.default_rng(42)
for shape in ((97,131), (241,319)):
gray = rng.integers(0,256,shape,dtype=np.uint8)
for dpi in (100,150,200,300):
with self.subTest(shape=shape,dpi=dpi):
expected = cv2.GaussianBlur(gray,(0,0),dpi/8)
np.testing.assert_array_equal(_paper_blur(gray,dpi/8),expected)
def scan(self):
image = np.full((1200, 850, 3), 255, np.uint8)
for y in range(20, 1200, 20):
cv2.line(image, (0, y), (849, y), (160, 155, 205), 1)
for x in range(10, 850, 20):
cv2.line(image, (x, 0), (x, 1199), (160, 155, 205), 1)
for y in (100, 180, 400, 480, 800, 880, 1050, 1130):
cv2.ellipse(image, (25, y), (12, 20), 0, 0, 300, (155,155,155), 2)
cv2.putText(image, 'x + y = 2', (110, 400), cv2.FONT_HERSHEY_SIMPLEX,
1, (20, 90, 190), 2)
cv2.putText(image, 'answer = 42', (110, 700), cv2.FONT_HERSHEY_SIMPLEX,
1, (20, 90, 190), 2)
return image
def test_crops_past_holes_and_grid(self):
r = detect_bounds(self.scan(), dpi=150)
self.assertGreater(r['top_px'], 250)
self.assertLess(r['top_px'], 370)
self.assertGreater(r['bottom_px'], 700)
self.assertLess(r['bottom_px'], 800)
def test_isolated_margin_note_survives(self):
image = self.scan()
cv2.putText(image, '1', (4, 1110), cv2.FONT_HERSHEY_SIMPLEX,
.65, (20, 90, 190), 2)
self.assertGreater(detect_bounds(image, dpi=150)['bottom_px'], 1110)
def test_long_fraction_bar_on_grid_survives(self):
for colour in ((20,90,190), (20,20,20), (190,20,20)):
with self.subTest(colour=colour):
image = self.scan()
cv2.line(image, (100, 1020), (700, 1020), colour, 3)
self.assertGreater(detect_bounds(image, dpi=150)['bottom_px'], 1020)
def test_black_annotation_survives(self):
image = self.scan()
cv2.putText(image, 'note', (600, 1100), cv2.FONT_HERSHEY_SIMPLEX,
.7, (30,30,30), 2)
self.assertGreater(detect_bounds(image, dpi=150)['bottom_px'], 1100)
def test_blank_and_pencil_only_pages_are_retained(self):
for pencil in (False, True):
image = np.full((1200,850,3),255,np.uint8)
if pencil:
cv2.putText(image, 'pencil', (100,600), cv2.FONT_HERSHEY_SIMPLEX,
1, (195,195,195), 2)
r = detect_bounds(image, dpi=150)
self.assertEqual((r['top_px'],r['bottom_px']), (0,1200))
self.assertEqual(r['status'], 'review-no-ink-seeds')
def test_skewed_colour_scan(self):
matrix = cv2.getRotationMatrix2D((425,600),2,1)
image = cv2.warpAffine(self.scan(),matrix,(850,1200),borderValue=(255,255,255))
r = detect_bounds(image,dpi=150)
self.assertGreater(r['top_px'],250)
self.assertLess(r['top_px'],370)
self.assertGreater(r['bottom_px'],715)
self.assertLess(r['bottom_px'],820)
def test_weak_stroke_attached_to_ink_is_recovered(self):
image = np.full((1200,850,3),255,np.uint8)
cv2.rectangle(image,(400,500),(410,530),(20,90,190),-1)
cv2.rectangle(image,(400,531),(410,540),(130,170,205),-1)
r = detect_bounds(image,dpi=150,padding_mm=0,min_crop_mm=0)
self.assertGreaterEqual(r['bottom_px'],541)
def dark_grid(self):
image = np.full((1200,850,3),255,np.uint8)
for y in range(20,1200,20):
cv2.line(image,(0,y),(849,y),(65,65,65),1)
for x in range(10,850,20):
cv2.line(image,(x,0),(x,1199),(65,65,65),1)
cv2.putText(image,'x + y = 2',(100,400),cv2.FONT_HERSHEY_SIMPLEX,
1,(20,20,20),2)
cv2.putText(image,'answer = 42',(100,700),cv2.FONT_HERSHEY_SIMPLEX,
1,(20,20,20),2)
return image
def test_dark_grid_does_not_seed_entire_page(self):
r = detect_bounds(self.dark_grid(),dpi=150)
self.assertTrue(r['paper_cleanup'])
self.assertGreater(r['top_px'],250)
self.assertLess(r['top_px'],370)
self.assertGreater(r['bottom_px'],700)
self.assertLess(r['bottom_px'],820)
def test_faint_isolated_note_on_dark_grid_survives(self):
image = self.dark_grid()
cv2.putText(image,'pencil',(100,1100),cv2.FONT_HERSHEY_SIMPLEX,
.7,(190,190,190),2)
self.assertGreater(detect_bounds(image,dpi=150)['bottom_px'],1100)
def test_large_unruled_diagram_is_not_paper(self):
image = np.full((1200,850,3),255,np.uint8)
cv2.rectangle(image,(100,100),(750,1100),(20,20,20),3)
r = detect_bounds(image,dpi=150)
self.assertFalse(r['paper_cleanup'])
self.assertLess(r['top_px'],100)
self.assertGreater(r['bottom_px'],1100)
def test_dark_grid_preserves_black_fraction_bar(self):
image = self.dark_grid()
cv2.line(image,(150,1020),(700,1020),(0,0,0),3)
self.assertGreater(detect_bounds(image,dpi=150)['bottom_px'],1020)
def test_disconnected_dark_grid_is_still_recognized(self):
image = self.dark_grid()
for x in range(170,850,170):
image[:,x:x+5] = 255
for y in range(200,1200,200):
image[y:y+5,:] = 255
cv2.putText(image,'x + y = 2',(100,400),cv2.FONT_HERSHEY_SIMPLEX,
1,(20,20,20),2)
r = detect_bounds(image,dpi=150)
self.assertTrue(r['paper_cleanup'])
self.assertGreater(r['top_px'],250)
self.assertLess(r['top_px'],370)
self.assertGreater(r['bottom_px'],700)
self.assertLess(r['bottom_px'],850)
def test_repeated_dark_holes_on_either_side(self):
for right in (False, True):
with self.subTest(right=right):
image = self.dark_grid()
x = 820 if right else 25
for y in (110, 370, 630, 890, 1130):
cv2.ellipse(image, (x,y), (12,20), 0, 0, 300, (35,35,35), 2)
r = detect_bounds(image,dpi=150)
self.assertLess(r['bottom_px'],850)
# The same column can contain handwriting as well as holes.
cv2.putText(image,'7',(x-5,1060),cv2.FONT_HERSHEY_SIMPLEX,
.7,(20,20,20),2)
r = detect_bounds(image,dpi=150)
self.assertGreater(r['bottom_px'],1060)
def test_faded_neutral_grid(self):
image = self.dark_grid()
image[np.all(image == 65,axis=2)] = 145
r = detect_bounds(image,dpi=150)
self.assertTrue(r['paper_cleanup'])
self.assertLess(r['bottom_px'],850)
if __name__ == '__main__':
unittest.main()