Compare commits
3
Commits
b6c2a634ca
...
76cead8561
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76cead8561 | ||
|
|
ad7813ea8f | ||
|
|
247b124754 |
No files matched your search
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
@@ -19,6 +20,7 @@ from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
read_json,
|
||||
execute,
|
||||
target_parser,
|
||||
workspace_from_target,
|
||||
@@ -26,6 +28,7 @@ from copienator import (
|
||||
from copienator.filesystem import staged_files
|
||||
from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths
|
||||
from copienator.configuration import SHOW_PERSONAL_STEPS
|
||||
from copienator.left_margin import analyse_margins
|
||||
|
||||
DELIMITER_WIDTH = 5
|
||||
DELIMITER_COLOR = (0, 0, 0)
|
||||
@@ -34,6 +37,7 @@ CROP_SHIFT_STEP = 50
|
||||
CROP_WIDTH_STEP = 50
|
||||
RENDER_DPI = 200
|
||||
NARROW_PAGE_RATIO = 0.8
|
||||
AUTO_STRIP_WIDTH_FACTOR = 1.10
|
||||
pdf_cache_lock = threading.Lock()
|
||||
|
||||
# The requested spelling is kept as the public switch for this alternate layout.
|
||||
@@ -116,6 +120,9 @@ def process_single_pdf(
|
||||
shift_offset: int = 0,
|
||||
max_per_file: int = 5,
|
||||
width_offset: int = 0,
|
||||
*,
|
||||
automatic_margin: bool = True,
|
||||
margin_analysis: dict | None = None,
|
||||
) -> tuple[Image.Image, list[Image.Image], dict[str, object]] | None:
|
||||
"""Convert one PDF into a preview, full-resolution splits and metadata."""
|
||||
try:
|
||||
@@ -124,7 +131,10 @@ def process_single_pdf(
|
||||
if not images:
|
||||
return None
|
||||
reference_width = normal_page_width(pdf_path, images)
|
||||
if automatic_margin and max_per_file != 1 and margin_analysis is None:
|
||||
margin_analysis = analyse_margins(images, RENDER_DPI, reference_width)
|
||||
full_width_pages = []
|
||||
page_crops = []
|
||||
for page_index, image in enumerate(images):
|
||||
width, height = image.size
|
||||
if max_per_file == 1:
|
||||
@@ -140,8 +150,17 @@ def process_single_pdf(
|
||||
width,
|
||||
width // 3 + 100 + shift_offset + max(0, width_offset),
|
||||
)
|
||||
if automatic_margin and max_per_file != 1:
|
||||
left = min(width - 1, max(0, margin_analysis['starts_px'][page_index] + shift_offset))
|
||||
strip_width = round(
|
||||
((reference_width if width < NARROW_PAGE_RATIO * reference_width else width) // 3)
|
||||
* AUTO_STRIP_WIDTH_FACTOR
|
||||
)
|
||||
right = min(width, left + strip_width + max(0, width_offset))
|
||||
if right > left:
|
||||
cropped_images.append(image.crop((left, 0, right, height)))
|
||||
page_crops.append({'page': page_index + 1, 'left_px': left, 'right_px': right,
|
||||
'width_px': width, 'height_px': height})
|
||||
if left == 0 and right == width:
|
||||
full_width_pages.append(page_index + 1)
|
||||
if not cropped_images:
|
||||
@@ -165,6 +184,8 @@ def process_single_pdf(
|
||||
"number_of_files": len(split_images),
|
||||
"columns_per_file": distribution,
|
||||
"full_width_pages": full_width_pages,
|
||||
"page_crops": page_crops,
|
||||
"auto_margin": margin_analysis if automatic_margin and max_per_file != 1 else None,
|
||||
}
|
||||
return preview, split_images, schema
|
||||
except Exception as exc: # noqa: BLE001 - interactive item failure
|
||||
@@ -202,12 +223,49 @@ def save_results(
|
||||
print(f"Saved schema: {base_name}_schema.json")
|
||||
|
||||
|
||||
def regenerate_full_page_image(workspace: EvaluationWorkspace, image_path: Path) -> None:
|
||||
"""Replace one strip montage with the same pages at their full width."""
|
||||
match = re.fullmatch(r"(.+)_(\d+)", image_path.stem)
|
||||
if not match or image_path.parent != workspace.cutleft_dir:
|
||||
raise ValueError(f"Cannot map Cutleft image to PDF pages: {image_path}")
|
||||
stem, part = match.group(1), int(match.group(2)) - 1
|
||||
schema_path = workspace.cutleft_dir / f"{stem}_schema.json"
|
||||
schema = read_json(schema_path)
|
||||
counts = schema["columns_per_file"]
|
||||
if (not isinstance(counts, list) or not counts
|
||||
or any(type(count) is not int or count < 1 for count in counts)
|
||||
or not 0 <= part < len(counts)):
|
||||
raise ValueError("Invalid Cutleft page distribution")
|
||||
first = sum(counts[:part])
|
||||
last = first + counts[part]
|
||||
page_numbers = set(range(first + 1, last + 1))
|
||||
if page_numbers <= set(schema.get("full_width_pages", [])):
|
||||
raise ValueError("Cutleft image already contains full pages")
|
||||
pages = get_pdf_pages(workspace.copies_dir / f"{stem}.pdf")
|
||||
if len(pages) != sum(counts):
|
||||
raise ValueError("Cutleft schema does not match the PDF page count")
|
||||
combined = stitch_images(pages[first:last])
|
||||
if combined is None:
|
||||
raise ValueError("No pages available for full-page fallback")
|
||||
crops = {crop["page"]: crop for crop in schema.get("page_crops", [])}
|
||||
for index in range(first, last):
|
||||
width, height = pages[index].size
|
||||
crops[index + 1] = {"page": index + 1, "left_px": 0, "right_px": width,
|
||||
"width_px": width, "height_px": height}
|
||||
schema["page_crops"] = [crops[key] for key in sorted(crops)]
|
||||
schema["full_width_pages"] = sorted(set(schema.get("full_width_pages", [])) | page_numbers)
|
||||
with staged_files(workspace.cutleft_dir) as staging:
|
||||
combined.save(staging / image_path.name, "JPEG", quality=95)
|
||||
atomic_write_json(staging / schema_path.name, schema)
|
||||
|
||||
|
||||
class ImageReviewer:
|
||||
def __init__(
|
||||
self,
|
||||
files: list[Path],
|
||||
output_dir: Path,
|
||||
default_max_per_file: int = 5,
|
||||
automatic_margin: bool = True,
|
||||
) -> None:
|
||||
self.files = files
|
||||
self.output_dir = output_dir
|
||||
@@ -216,6 +274,8 @@ class ImageReviewer:
|
||||
self.had_errors = False
|
||||
self.stop_prefetch = threading.Event()
|
||||
self.current_result = None
|
||||
self.automatic_margin = automatic_margin
|
||||
self.margin_analysis = None
|
||||
self.index = 0
|
||||
self.current_shift = 0
|
||||
self.current_width_offset = 0
|
||||
@@ -283,6 +343,7 @@ class ImageReviewer:
|
||||
self.current_shift = 0
|
||||
self.current_width_offset = 0
|
||||
self.current_result = None
|
||||
self.margin_analysis = None
|
||||
self.trigger_processing(self.files[self.index], self.current_shift)
|
||||
|
||||
def trigger_processing(self, pdf_path: Path, shift: int) -> None:
|
||||
@@ -299,6 +360,8 @@ class ImageReviewer:
|
||||
shift,
|
||||
self.current_max_per_file,
|
||||
self.current_width_offset,
|
||||
automatic_margin=self.automatic_margin,
|
||||
margin_analysis=self.margin_analysis,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -325,6 +388,8 @@ class ImageReviewer:
|
||||
) -> None:
|
||||
self.current_preview = result[0]
|
||||
self.current_result = result
|
||||
if result[2].get('auto_margin') is not None:
|
||||
self.margin_analysis = result[2]['auto_margin']
|
||||
self.update_display(pdf_path.name, result[2])
|
||||
|
||||
def update_display(self, filename: str, schema: dict[str, object]) -> None:
|
||||
@@ -338,6 +403,17 @@ class ImageReviewer:
|
||||
if personnal_binding
|
||||
else ""
|
||||
)
|
||||
analysis = schema.get('auto_margin')
|
||||
margin_info = ''
|
||||
if analysis:
|
||||
offsets = sorted({round(crop['left_px'] * 25.4 / RENDER_DPI, 1)
|
||||
for crop in schema['page_crops']})
|
||||
needs_review = any(group['status'] in {'inconsistent', 'uncertain', 'insufficient-ink'}
|
||||
for group in analysis['groups'])
|
||||
margin_info = (f"Auto margin: {offsets} mm from left"
|
||||
+ (' | Check uncertain pages' if needs_review else '') + '\n')
|
||||
if analysis.get('no_margin_from_page') is not None:
|
||||
margin_info += f"No margin from page {analysis['no_margin_from_page']} onwards\n"
|
||||
self.label_info.configure(
|
||||
text=(
|
||||
f"[{self.index + 1}/{len(self.files)}] {filename} | "
|
||||
@@ -345,6 +421,7 @@ class ImageReviewer:
|
||||
f"Extra width: {self.current_width_offset}px\n"
|
||||
f"Files: {schema['number_of_files']} | "
|
||||
f"Cols: {schema['columns_per_file']}\n"
|
||||
f"{margin_info}"
|
||||
"Arrows: left/right move by 50px | up/down widen/narrow by 50px\n"
|
||||
"Enter: Save and next | !: flag error and skip | 1: use full pages"
|
||||
f"{personal_instructions}"
|
||||
@@ -428,6 +505,7 @@ def run(
|
||||
fullpage: bool = False,
|
||||
no_visual_check: bool = False,
|
||||
marked: bool = False,
|
||||
automatic_margin: bool = True,
|
||||
) -> ExitCode:
|
||||
files = marked_copy_paths(workspace) if marked else _selected_files(workspace, target)
|
||||
if not files:
|
||||
@@ -463,6 +541,7 @@ def run(
|
||||
files,
|
||||
workspace.cutleft_dir,
|
||||
default_max_per_file=1 if fullpage else 5,
|
||||
automatic_margin=automatic_margin,
|
||||
)
|
||||
if not reviewer.completed:
|
||||
return ExitCode.INTERRUPTED
|
||||
@@ -472,6 +551,10 @@ def run(
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = target_parser("Interactively crop the label margin from PDF copies")
|
||||
parser.add_argument("--marked", action="store_true", help="Review flagged copies and clear only margin-cropping flags after saving with Enter")
|
||||
parser.add_argument(
|
||||
'--manual-margin', action='store_true',
|
||||
help='Use the previous fixed margin instead of automatically estimating body-text positions',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fullpage",
|
||||
action="store_true",
|
||||
@@ -496,6 +579,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
fullpage=args.fullpage,
|
||||
no_visual_check=args.no_visual_check,
|
||||
marked=args.marked,
|
||||
automatic_margin=not args.manual_margin,
|
||||
)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
@@ -79,7 +79,7 @@ class ExamSolutions(BaseModel):
|
||||
class ExtractedContext(StatementContent):
|
||||
target_question_label: str = Field(description="Label exact de la PREMIÈRE question située immédiatement APRÈS cette information dans l’énoncé.")
|
||||
last_question_label: str = Field(description="Label exact de la DERNIÈRE question qui utilise cette information.")
|
||||
context_content: str = Field(description="Texte source des définitions, notations ou hypothèses, extrait de l’énoncé.")
|
||||
context_content: str = Field(description="Définitions, notations ou hypothèses extraites de l’énoncé, éventuellement raccourcies en conservant toutes les informations nécessaires aux questions concernées.")
|
||||
|
||||
class ExamContext(BaseModel):
|
||||
contexts: list[ExtractedContext]
|
||||
@@ -222,8 +222,11 @@ Pour chaque information extraite, identifie :
|
||||
information dans l'énoncé (`target_question_label`).
|
||||
2. Le label de la DERNIÈRE question qui utilise cette information
|
||||
(`last_question_label`).
|
||||
Conserve le texte source dans `context_content`, sans le reformuler ni le
|
||||
traduire, et conserve le LaTeX ainsi que les labels exacts.
|
||||
Dans `context_content`, tu peux raccourcir ou résumer le contexte pour ne
|
||||
garder que les informations nécessaires à la compréhension et à la
|
||||
correction des questions concernées. Conserve toutes les définitions,
|
||||
notations et hypothèses utiles, sans en modifier le sens ni en ajouter.
|
||||
Ne traduis pas le texte et conserve le LaTeX ainsi que les labels exacts.
|
||||
Renvoie le résultat dans la liste `contexts` de l'objet JSON attendu.
|
||||
"""
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from copienator import configuration as config
|
||||
from copienator.gemini_config import fresh_seed, thinking_config
|
||||
@@ -149,10 +149,20 @@ class BoxItem(BaseModel):
|
||||
label: str = Field(description="The label associated with the specific box")
|
||||
|
||||
class AnnotationData(BaseModel):
|
||||
name: str = Field(description="The name identifier")
|
||||
list: typing.List[BoxItem] = Field( # noqa: UP006 - field name shadows list
|
||||
description="List of bounding box items"
|
||||
error: typing.Literal["insufficient_crop"] | None = Field(
|
||||
default=None, description="Report insufficient_crop when the crop hides information needed to identify labels"
|
||||
)
|
||||
error_reason: str | None = Field(default=None, description="Explanation of the crop error")
|
||||
name: str = Field(default="Unknown", description="The name identifier")
|
||||
list: typing.List[BoxItem] = Field( # noqa: UP006 - field name shadows list
|
||||
default_factory=list, description="List of bounding box items"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_success_payload(self):
|
||||
if self.error is None and not {"name", "list"} <= self.model_fields_set:
|
||||
raise ValueError("Successful detection must include name and list")
|
||||
return self
|
||||
|
||||
|
||||
TEXT_CONTEXT_MAX_CHARS = 4000
|
||||
@@ -216,6 +226,7 @@ def generate_request(
|
||||
seed: int | None = None,
|
||||
*,
|
||||
retry: bool = False,
|
||||
full_pages: bool = False,
|
||||
):
|
||||
"""Generates request for Gemini with context."""
|
||||
|
||||
@@ -263,6 +274,18 @@ def generate_request(
|
||||
text = text.replace("##wrong_label_text_context##", "")
|
||||
|
||||
|
||||
text += """
|
||||
|
||||
If the cropped margins hide labels or exercise headings needed to identify
|
||||
questions reliably, return error="insufficient_crop", a short error_reason,
|
||||
and an empty list. Do not guess labels from incomplete cropped information.
|
||||
Missing answers or an empty page alone are not evidence of insufficient crop:
|
||||
students may skip questions. On success, return error=null.
|
||||
"""
|
||||
if full_pages:
|
||||
text = text.replace("image of the left columns", "image of the full pages")
|
||||
text += "\nThis image now contains full pages, in the same reading order. Identify the labels using this expanded view."
|
||||
|
||||
contents = [
|
||||
types.Content(
|
||||
role="user",
|
||||
@@ -399,6 +422,23 @@ def _existing_context(output_json: Path) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def image_contains_full_pages(workspace: EvaluationWorkspace, image_file: Path) -> bool:
|
||||
"""Recognize expanded images on subsequent detection runs."""
|
||||
match = re.fullmatch(r"(.+)_(\d+)", image_file.stem)
|
||||
if not match:
|
||||
return False
|
||||
try:
|
||||
schema = read_json(workspace.cutleft_dir / f"{match.group(1)}_schema.json")
|
||||
counts = schema["columns_per_file"]
|
||||
part = int(match.group(2)) - 1
|
||||
if not 0 <= part < len(counts) or any(type(n) is not int or n < 1 for n in counts):
|
||||
return False
|
||||
first = sum(counts[:part]) + 1
|
||||
return set(range(first, first + counts[part])) <= set(schema.get("full_width_pages", []))
|
||||
except (OSError, KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def process_copy_group(
|
||||
workspace: EvaluationWorkspace,
|
||||
group_key: str,
|
||||
@@ -433,13 +473,14 @@ def process_copy_group(
|
||||
label_retry_count = 0
|
||||
wrong_labels: list[str] = []
|
||||
request_seed = None
|
||||
full_page_fallback = image_contains_full_pages(workspace, image_file)
|
||||
while attempt < MAX_ATTEMPTS:
|
||||
if progress:
|
||||
progress(group_key, f"{image_file.name}, attempt {attempt + 1}/{MAX_ATTEMPTS}", started)
|
||||
if attempt > 0:
|
||||
print(f"[{group_key}] Retrying {image_file.name} in {10 * attempt}s...")
|
||||
sleep(10 * attempt)
|
||||
level = "low" if attempt > 0 else config.THINKING_LEVEL_FOR_LABEL
|
||||
level = "low" if attempt > 0 or full_page_fallback else config.THINKING_LEVEL_FOR_LABEL
|
||||
request_seed = fresh_seed(request_seed)
|
||||
print(
|
||||
f"[{group_key}] {image_file.name}: attempt {attempt + 1}/{MAX_ATTEMPTS} "
|
||||
@@ -464,7 +505,8 @@ def process_copy_group(
|
||||
text_context,
|
||||
text_context_file,
|
||||
seed=request_seed,
|
||||
retry=attempt > 0,
|
||||
retry=attempt > 0 or full_page_fallback,
|
||||
full_pages=full_page_fallback,
|
||||
)
|
||||
response = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
@@ -472,6 +514,25 @@ def process_copy_group(
|
||||
config=request_config,
|
||||
)
|
||||
annotation = AnnotationData.model_validate_json(response.text)
|
||||
if annotation.error == "insufficient_crop":
|
||||
print(f"[{group_key}] {image_file.name}: insufficient crop: "
|
||||
f"{annotation.error_reason or 'labels require a wider view'}")
|
||||
if full_page_fallback:
|
||||
raise IncompleteCopyError(files[image_index:])
|
||||
# Keep numbering and page grouping stable for downstream consumers.
|
||||
from copienator.commands.cutleft import regenerate_full_page_image
|
||||
try:
|
||||
regenerate_full_page_image(workspace, image_file)
|
||||
except Exception as exc:
|
||||
print(f"[{group_key}] Full-page regeneration failed: {exc}")
|
||||
raise IncompleteCopyError(files[image_index:]) from exc
|
||||
output_json.unlink(missing_ok=True)
|
||||
full_page_fallback = True
|
||||
attempt = 0
|
||||
label_retry_count = 0
|
||||
wrong_labels = []
|
||||
print(f"[{group_key}] Regenerated {image_file.name} with full pages; resending to Gemini.")
|
||||
continue
|
||||
unknown = [
|
||||
item.label
|
||||
for item in annotation.list
|
||||
@@ -512,11 +573,13 @@ def process_copy_group(
|
||||
annotation.list = sort_boxes_for_image(
|
||||
workspace, image_file, annotation.list
|
||||
)
|
||||
atomic_write_json(output_json, annotation.model_dump())
|
||||
atomic_write_json(output_json, annotation.model_dump(exclude={"error", "error_reason"}))
|
||||
accumulated_labels.extend(box.label for box in annotation.list)
|
||||
generated += 1
|
||||
print(f"[{group_key}] Saved {image_file.name} after {time.monotonic() - started:.0f}s.")
|
||||
break
|
||||
except IncompleteCopyError:
|
||||
raise
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - remote API retry boundary
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Conservative body-text margin suggestions for the cutleft reviewer."""
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from copienator.ink_detection import detect_bounds
|
||||
|
||||
ANALYSIS_DPI = 100
|
||||
WIDTH_TOLERANCE = .08
|
||||
# Copie01's name/class block reaches about 27% of the full page height.
|
||||
# Start below it with clearance, including on later pages with repeated names.
|
||||
BODY_TOP = .30
|
||||
BODY_BOTTOM = .82
|
||||
DISCARD_LEFT_FRACTION = .10
|
||||
STANDARD_MARGIN_MM = 40
|
||||
MIN_MARGIN_MM = STANDARD_MARGIN_MM / 2
|
||||
|
||||
|
||||
def trimmed_left_edge(starts: list[float]) -> float | None:
|
||||
"""Discard the leftmost 10% of occupied bands; retain the next minimum."""
|
||||
if not starts:
|
||||
return None
|
||||
ordered = sorted(starts)
|
||||
return round(ordered[int(len(ordered) * DISCARD_LEFT_FRACTION)], 1)
|
||||
|
||||
|
||||
def handwriting_mask(rgb: np.ndarray) -> np.ndarray:
|
||||
"""Intersect rognage's foreground with a directional ruling filter."""
|
||||
px = ANALYSIS_DPI / 25.4
|
||||
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
|
||||
darkness = 255 - rgb.min(axis=2)
|
||||
length = 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))
|
||||
binary = ((residual > 32) & (gray < 200)).astype(np.uint8)
|
||||
count, labels, stats, _ = cv2.connectedComponentsWithStats(binary, 8)
|
||||
keep = np.zeros(count, bool)
|
||||
for index, (_x, _y, width, height, area) in enumerate(stats[1:], 1):
|
||||
keep[index] = (area >= max(5, .35 * px * px) and height >= .5 * px
|
||||
and width >= .25 * px and height <= 6 * width and width <= 15 * height)
|
||||
diagnostics = {}
|
||||
detect_bounds(rgb, ANALYSIS_DPI, diagnostics=diagnostics)
|
||||
return keep[labels] & diagnostics['masks']['foreground']
|
||||
|
||||
|
||||
def body_edge(mask: np.ndarray) -> dict:
|
||||
"""Estimate a left edge from occupied 4 mm bands within 30–82% height."""
|
||||
height = mask.shape[0]
|
||||
band_height = max(1, round(4 * ANALYSIS_DPI / 25.4))
|
||||
bottom = round(BODY_BOTTOM * height)
|
||||
starts = []
|
||||
for y in range(round(BODY_TOP * height), bottom, band_height):
|
||||
band = mask[y:min(y + band_height, bottom)]
|
||||
xs = np.flatnonzero(band.sum(axis=0) >= 2)
|
||||
if band.sum() >= 40 and len(xs) >= 12:
|
||||
starts.append(float(xs[0]) * 25.4 / ANALYSIS_DPI)
|
||||
return {
|
||||
'edge_mm': trimmed_left_edge(starts) if len(starts) >= 6 else None,
|
||||
'bands': len(starts),
|
||||
'band_starts_mm': starts,
|
||||
}
|
||||
|
||||
|
||||
def estimate_page(image: Image.Image, render_dpi: int) -> dict:
|
||||
scale = ANALYSIS_DPI / render_dpi
|
||||
small = image.convert('RGB').resize(
|
||||
(max(1, round(image.width * scale)), max(1, round(image.height * scale))),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
return body_edge(handwriting_mask(np.asarray(small)))
|
||||
|
||||
|
||||
def format_groups(images: list[Image.Image], reference_width: float) -> list[list[int]]:
|
||||
"""Group similar widths; keep secondary narrow subpages in separate groups."""
|
||||
groups: list[list[int]] = []
|
||||
for index, image in enumerate(images):
|
||||
narrow = image.width < .8 * reference_width
|
||||
for group in groups:
|
||||
width = float(np.median([images[i].width for i in group]))
|
||||
if (images[group[0]].width < .8 * reference_width) == narrow and abs(image.width - width) <= width * WIDTH_TOLERANCE:
|
||||
group.append(index)
|
||||
break
|
||||
else:
|
||||
groups.append([index])
|
||||
return groups
|
||||
|
||||
|
||||
def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width: float) -> dict:
|
||||
"""Pool occupied bands per format, trim outliers, and crop at half the edge."""
|
||||
starts = [0] * len(images)
|
||||
groups = []
|
||||
page_estimates = {}
|
||||
|
||||
def page_estimate(index):
|
||||
if index not in page_estimates:
|
||||
page_estimates[index] = estimate_page(images[index], render_dpi)
|
||||
return page_estimates[index]
|
||||
|
||||
for indices in format_groups(images, reference_width):
|
||||
# Spread samples through the copy rather than relying on the header page.
|
||||
sample = sorted({indices[0], indices[min(1, len(indices) - 1)],
|
||||
indices[len(indices) // 2], indices[-1]})
|
||||
estimates = {index: page_estimate(index) for index in sample}
|
||||
values = [item['edge_mm'] for item in estimates.values() if item['edge_mm'] is not None]
|
||||
# Trim across the copy's format group, not separately on each page:
|
||||
# one marginal comment must not veto the other pages' shared margin.
|
||||
band_starts = [start for item in estimates.values() for start in item['band_starts_mm']]
|
||||
status = 'insufficient-ink'
|
||||
edge = trimmed_left_edge(band_starts)
|
||||
left_mm = 0.0
|
||||
if len(values) >= min(2, len(indices)):
|
||||
status = 'near-edge' if edge < MIN_MARGIN_MM else 'margin'
|
||||
left_mm = 0.0 if edge < MIN_MARGIN_MM else edge / 2
|
||||
# Secondary subpages can start in the middle of an answer. Preserve
|
||||
# their left edge even when the only visible writing is indented.
|
||||
if images[indices[0]].width < .8 * reference_width:
|
||||
status = 'narrow-page'
|
||||
left_mm = 0.0
|
||||
start = round(left_mm * render_dpi / 25.4)
|
||||
for index in indices:
|
||||
starts[index] = start
|
||||
groups.append({
|
||||
'pages': [index + 1 for index in indices],
|
||||
'status': status,
|
||||
'edge_mm': edge,
|
||||
'left_mm': round(left_mm, 1),
|
||||
'bands': len(band_starts),
|
||||
'discarded_bands': int(len(band_starts) * DISCARD_LEFT_FRACTION),
|
||||
'samples': [{'page': index + 1, **item} for index, item in estimates.items()],
|
||||
})
|
||||
# Inspect actual neighbours, including pages outside the format samples.
|
||||
# Blank/uncertain pages do not establish or extend a no-margin run.
|
||||
no_margin_run = 0
|
||||
no_margin_from_page = None
|
||||
for index in range(len(images)):
|
||||
edge = page_estimate(index)['edge_mm']
|
||||
no_margin_run = no_margin_run + 1 if edge is not None and edge < MIN_MARGIN_MM else 0
|
||||
if no_margin_run >= 2:
|
||||
run_start = index - 1
|
||||
no_margin_from_page = run_start + 1
|
||||
starts[run_start:] = [0] * (len(images) - run_start)
|
||||
break
|
||||
|
||||
return {'version': 5, 'starts_px': starts, 'groups': groups,
|
||||
'no_margin_from_page': no_margin_from_page,
|
||||
'discard_left_fraction': DISCARD_LEFT_FRACTION, 'cut_fraction_of_margin': .5,
|
||||
'standard_margin_mm': STANDARD_MARGIN_MM, 'minimum_margin_mm': MIN_MARGIN_MM,
|
||||
'body_height_fraction': [BODY_TOP, BODY_BOTTOM]}
|
||||
@@ -54,6 +54,9 @@ Certains cas nécessitent une valeur particulière du champ `error` :
|
||||
- La réponse contient aussi une réponse à une autre question de
|
||||
l'exercice, sur plus de quelques lignes : note la question demandée,
|
||||
mais indique `additional-answer`.
|
||||
Examine systématiquement le contenu situé à droite de la réponse à
|
||||
noter : tout contenu supplémentaire dans cette zone doit être signalé
|
||||
par `additional-answer`, même s'il ne comporte que quelques lignes.
|
||||
- La réponse est vide, ou l'élève a seulement recopié l'énoncé : indique
|
||||
`empty-answer` et ne fournis aucun commentaire.
|
||||
S'il n'y a aucune de ces erreurs, attribue la chaîne vide `""` à `error`.
|
||||
|
||||
@@ -289,6 +289,12 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Prétraitement des copies",
|
||||
"Découper une partie à gauche pour détection des labels",
|
||||
"Produit les images de la partie gauche des copies. "
|
||||
"La position est estimée automatiquement sur plusieurs pages de même largeur ; "
|
||||
"Les 10 % de bandes d’écriture les plus à gauche sont ignorées ; "
|
||||
"la découpe commence à la moitié de la marge restante. "
|
||||
"Si la marge est inférieure à 20 mm, elle commence au bord gauche. "
|
||||
"Deux pages consécutives sans marge imposent le bord gauche pour la suite. "
|
||||
"La bande est élargie de 10 %. "
|
||||
"Pour les sous-pages étroites, la marge commence au bord gauche et est limitée à la largeur disponible. "
|
||||
"Dans la fenêtre de découpe, utilisez les flèches : ←/→ déplacent la zone "
|
||||
"de 50 px vers la gauche/droite, ↑ l’élargit de 50 px et ↓ la réduit de 50 px. "
|
||||
@@ -299,6 +305,10 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
(python("default", "Découpe", "crop-labels"),),
|
||||
arguments=(
|
||||
arg_target(),
|
||||
ArgumentSpec(
|
||||
"manual_margin", "Utiliser la marge fixe", "bool", "--manual-margin",
|
||||
help="Désactive l’estimation automatique et reprend la position fixe habituelle, ajustable avec les flèches.",
|
||||
),
|
||||
ArgumentSpec(
|
||||
"fullpage",
|
||||
"Toujours utiliser la page entière",
|
||||
|
||||
@@ -94,11 +94,32 @@ cette étape non interactive, cocher aussi `et ne pas vérifier visuellement`.
|
||||
Ce mode est plus cher : sans avoir vérifié, Astra estime que le coût
|
||||
passerait de 0,50€ à 2€.
|
||||
|
||||
Sans quoi, pour chaque copie, il faut cadrer la bande pour qu'elle
|
||||
contienne bien tous les labels :
|
||||
La position de la bande est proposée automatiquement à partir de plusieurs
|
||||
pages de largeur comparable. L’estimation cherche le début de l’écriture
|
||||
dans la partie centrale des pages (30–82 % de la hauteur), en ignorant le haut
|
||||
et le bas. Les positions de début d’écriture des bandes sont regroupées pour
|
||||
les pages de même format. Les 10 % de bandes les plus à gauche sont ignorées ;
|
||||
le début le plus à gauche restant définit la marge. La découpe commence à la
|
||||
moitié de cette distance. Une marge inférieure à 20 mm (la moitié d’une marge
|
||||
de référence de 40 mm) commence au bord gauche. Les pages étroites et les groupes sans assez
|
||||
d’écriture restent au bord gauche. La même règle s’applique à toutes les pages,
|
||||
y compris la première.
|
||||
|
||||
Deux pages consécutives dont la marge détectée est inférieure à 20 mm imposent
|
||||
le bord gauche pour ces deux pages et toutes les suivantes, même si les pages
|
||||
suivantes semblent plus indentées. Les pages sans assez d’écriture ne comptent
|
||||
pas comme des pages sans marge. La largeur automatique de la bande est augmentée
|
||||
de 10 %, puis les ajustements manuels de largeur sont ajoutés.
|
||||
|
||||
La fenêtre indique les positions proposées et les cas à vérifier. Vérifiez
|
||||
pour chaque copie que la bande contient bien tous les labels :
|
||||
|
||||
**←/→** la déplacent, **↑/↓** l’élargissent ou la réduisent ; **Entrée** valide.
|
||||
|
||||
Ces réglages ajustent la proposition automatique sans relancer l’analyse.
|
||||
Pour reprendre l’ancienne position fixe, cochez **Utiliser la marge fixe**
|
||||
(option `--manual-margin` en ligne de commande).
|
||||
|
||||
Si les labels sont dispersés, utilisez **1** pour utiliser les pages entières pour cette copie.
|
||||
|
||||
Cette vérification visuelle permet toutefois de repérer des copies à
|
||||
|
||||
@@ -17,7 +17,7 @@ class NarrowPageCropTests(unittest.TestCase):
|
||||
narrow.paste('blue', (0, 0, 300, 600))
|
||||
thin = Image.new('RGB', (200, 600), 'yellow')
|
||||
with patch.object(cutleft, 'get_pdf_pages', return_value=[normal, narrow, thin]):
|
||||
result = cutleft.process_single_pdf(Path('/tmp/no-original/Copy.pdf'))
|
||||
result = cutleft.process_single_pdf(Path('/tmp/no-original/Copy.pdf'), automatic_margin=False)
|
||||
_, images, schema = result
|
||||
image = images[0]
|
||||
self.assertEqual(schema['full_width_pages'], [3])
|
||||
@@ -39,7 +39,7 @@ class NarrowPageCropTests(unittest.TestCase):
|
||||
narrow.paste('blue', (50, 0, 450, 600))
|
||||
with patch.object(cutleft, 'get_pdf_pages', return_value=[normal, narrow]):
|
||||
result = cutleft.process_single_pdf(
|
||||
Path('/tmp/no-original/Copy.pdf'), shift_offset=50, width_offset=100)
|
||||
Path('/tmp/no-original/Copy.pdf'), shift_offset=50, width_offset=100, automatic_margin=False)
|
||||
self.assertEqual(result[1][0].size, (805, 600))
|
||||
self.assertEqual(result[1][0].getpixel((405, 0)), (0, 0, 255))
|
||||
self.assertEqual(result[1][0].getpixel((804, 0)), (0, 0, 255))
|
||||
@@ -54,14 +54,14 @@ class NarrowPageCropTests(unittest.TestCase):
|
||||
document.save(originals / 'Copy.pdf')
|
||||
images = [Image.new('RGB', (900, 2339)), Image.new('RGB', (750, 2339))]
|
||||
with patch.object(cutleft, 'get_pdf_pages', return_value=images):
|
||||
result = cutleft.process_single_pdf(root / 'Copies' / 'Copy.pdf')
|
||||
result = cutleft.process_single_pdf(root / 'Copies' / 'Copy.pdf', automatic_margin=False)
|
||||
self.assertEqual(result[2]['full_width_pages'], [])
|
||||
self.assertEqual(result[1][0].width, 2 * int((1191 / 2 * 200 / 72) // 3) + 5)
|
||||
|
||||
def test_small_main_split_adjustments_keep_margin_cropping(self):
|
||||
images = [Image.new('RGB', (1600, 2339)), Image.new('RGB', (1694, 2339))]
|
||||
with patch.object(cutleft, 'get_pdf_pages', return_value=images):
|
||||
result = cutleft.process_single_pdf(Path('/tmp/no-original/Copy.pdf'))
|
||||
result = cutleft.process_single_pdf(Path('/tmp/no-original/Copy.pdf'), automatic_margin=False)
|
||||
self.assertEqual(result[2]['full_width_pages'], [])
|
||||
self.assertEqual(result[1][0].width, 2 * (1694 // 3) + 5)
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import os
|
||||
import tempfile
|
||||
import tkinter as tk
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
@@ -159,29 +157,6 @@ class LabelCopyFlagTests(unittest.TestCase):
|
||||
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'plotting'})
|
||||
self.assertFalse(self.pdf.with_suffix('.json').exists())
|
||||
|
||||
@unittest.skipUnless(os.environ.get('DISPLAY'), 'Tk requires a display')
|
||||
def test_exclamation_key_flags_current_copy_in_real_viewer(self):
|
||||
root = tk.Tk()
|
||||
try:
|
||||
image_queue = Queue()
|
||||
image_queue.put((Image.new('RGB', (100, 100), 'white'),
|
||||
self.pdf.with_name('Copie01_01.json'),
|
||||
{'copie': 'Copie01', 'part': 1, 'name': 'Student',
|
||||
'schema': {'columns_per_file': [1, 1]}}))
|
||||
viewer = plotting.ImageViewer(root, self.workspace, set(), image_queue)
|
||||
root.update()
|
||||
root.focus_force()
|
||||
root.update()
|
||||
root.event_generate('<KeyPress>', keysym='exclam')
|
||||
root.update()
|
||||
self.assertEqual(viewer.skipped_copies, {'Copie01'})
|
||||
self.assertFalse(viewer.is_viewing)
|
||||
self.assertIn(self.pdf.name, copy_errors(self.workspace))
|
||||
finally:
|
||||
for callback in root.tk.splitlist(root.tk.call('after', 'info')):
|
||||
root.after_cancel(callback)
|
||||
root.destroy()
|
||||
|
||||
def test_empty_marked_selection_does_not_process_whole_evaluation(self):
|
||||
with patch.object(self.workspace.__class__, 'require_files'), \
|
||||
patch.object(gemini_for_labels, 'selected_images') as select:
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import EvaluationWorkspace, read_json
|
||||
from copienator.commands import cutleft, gemini_for_labels as labels
|
||||
|
||||
|
||||
class FullPageFallbackTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.workspace = EvaluationWorkspace(Path(temporary.name))
|
||||
self.workspace.cutleft_dir.mkdir()
|
||||
self.workspace.copies_dir.mkdir()
|
||||
self.images = [self.workspace.cutleft_dir / f'Copie01_{i:02d}.jpg' for i in (1, 2, 3)]
|
||||
for image in self.images:
|
||||
image.write_bytes(b'original crop')
|
||||
self.schema_path = self.workspace.cutleft_dir / 'Copie01_schema.json'
|
||||
self.schema_path.write_text(json.dumps({'columns_per_file': [1, 2, 1],
|
||||
'full_width_pages': [], 'total_pages': 4}))
|
||||
self.pages = [Image.new('RGB', (100, 200), color) for color in ('red', 'green', 'blue', 'white')]
|
||||
self.client = Mock()
|
||||
self.error = Mock(text='{"error":"insufficient_crop","error_reason":"Exercise heading clipped"}')
|
||||
self.success = Mock(text='{"name":"Student","list":[{"box_2d":[1,2,3,4],"label":"Ex 1"}]}')
|
||||
|
||||
def process(self):
|
||||
return labels.process_copy_group(
|
||||
self.workspace, 'Copie01', self.images, client=self.client,
|
||||
labels_text='Ex 1', names_text='Student', valid_labels={'Ex 1'},
|
||||
valid_names={'Student'}, overwrite=False, sleep=lambda _: None, target_interval=0,
|
||||
)
|
||||
|
||||
def test_regenerates_only_affected_pages_and_updates_metadata(self):
|
||||
with patch.object(cutleft, 'get_pdf_pages', return_value=self.pages):
|
||||
cutleft.regenerate_full_page_image(self.workspace, self.images[1])
|
||||
self.assertEqual(self.images[0].read_bytes(), b'original crop')
|
||||
self.assertEqual(self.images[2].read_bytes(), b'original crop')
|
||||
with Image.open(self.images[1]) as result:
|
||||
self.assertEqual(result.size, (205, 200))
|
||||
self.assertGreater(result.getpixel((40, 50))[1], 100)
|
||||
self.assertGreater(result.getpixel((150, 50))[2], 200)
|
||||
schema = read_json(self.schema_path)
|
||||
self.assertEqual(schema['columns_per_file'], [1, 2, 1])
|
||||
self.assertEqual(schema['full_width_pages'], [2, 3])
|
||||
self.assertEqual([crop['right_px'] for crop in schema['page_crops']], [100, 100])
|
||||
with self.assertRaisesRegex(ValueError, 'already contains full pages'):
|
||||
cutleft.regenerate_full_page_image(self.workspace, self.images[1])
|
||||
|
||||
def test_error_regenerates_and_resends_with_context(self):
|
||||
self.client.models.generate_content.side_effect = [self.success, self.error, self.success, self.success]
|
||||
with patch.object(cutleft, 'get_pdf_pages', return_value=self.pages):
|
||||
self.assertEqual(self.process(), 3)
|
||||
calls = self.client.models.generate_content.call_args_list
|
||||
retry_parts = calls[2].kwargs['contents'][0].parts
|
||||
self.assertEqual(retry_parts[0].inline_data.data, self.images[1].read_bytes())
|
||||
self.assertIn('now contains full pages', retry_parts[1].text)
|
||||
self.assertIn('"Ex 1"', retry_parts[1].text)
|
||||
result = read_json(self.workspace.copies_dir / 'Copie01_02.json')
|
||||
self.assertEqual(set(result), {'name', 'list'})
|
||||
self.assertEqual(result['list'][0]['label'], 'Ex 1')
|
||||
|
||||
def test_second_crop_error_stops_and_defers_following_images(self):
|
||||
self.client.models.generate_content.side_effect = [self.error, self.error]
|
||||
with patch.object(cutleft, 'get_pdf_pages', return_value=self.pages), \
|
||||
self.assertRaises(labels.IncompleteCopyError) as caught:
|
||||
self.process()
|
||||
self.assertEqual(caught.exception.images, self.images)
|
||||
self.assertEqual(self.client.models.generate_content.call_count, 2)
|
||||
self.assertFalse(list(self.workspace.copies_dir.glob('*.json')))
|
||||
|
||||
def test_bad_mapping_leaves_original_image_and_schema_intact(self):
|
||||
original_schema = self.schema_path.read_bytes()
|
||||
self.client.models.generate_content.return_value = self.error
|
||||
with patch.object(cutleft, 'get_pdf_pages', return_value=self.pages[:1]), \
|
||||
self.assertRaises(labels.IncompleteCopyError):
|
||||
self.process()
|
||||
self.assertEqual(self.client.models.generate_content.call_count, 1)
|
||||
self.assertEqual(self.schema_path.read_bytes(), original_schema)
|
||||
self.assertEqual(self.images[0].read_bytes(), b'original crop')
|
||||
|
||||
def test_full_page_retry_gets_fresh_bounded_attempt_budget(self):
|
||||
self.client.models.generate_content.side_effect = [TimeoutError(), TimeoutError(), self.error,
|
||||
TimeoutError(), TimeoutError(), self.success,
|
||||
self.success, self.success]
|
||||
with patch.object(cutleft, 'get_pdf_pages', return_value=self.pages):
|
||||
self.assertEqual(self.process(), 3)
|
||||
self.assertEqual(self.client.models.generate_content.call_count, 8)
|
||||
|
||||
def test_resumed_full_pages_use_correct_prompt_without_regenerating(self):
|
||||
schema = read_json(self.schema_path)
|
||||
schema['full_width_pages'] = [1]
|
||||
self.schema_path.write_text(json.dumps(schema))
|
||||
self.client.models.generate_content.return_value = self.error
|
||||
with patch.object(cutleft, 'get_pdf_pages') as render, self.assertRaises(labels.IncompleteCopyError):
|
||||
self.process()
|
||||
render.assert_not_called()
|
||||
prompt = self.client.models.generate_content.call_args.kwargs['contents'][0].parts[1].text
|
||||
self.assertIn('now contains full pages', prompt)
|
||||
|
||||
def test_missing_success_payload_is_not_accepted(self):
|
||||
with self.assertRaises(ValueError):
|
||||
labels.AnnotationData.model_validate_json('{}')
|
||||
@@ -0,0 +1,184 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from copienator import left_margin
|
||||
from copienator.commands import cutleft
|
||||
|
||||
|
||||
class LeftMarginTests(unittest.TestCase):
|
||||
def test_trim_discards_exactly_ten_percent_without_interpolation(self):
|
||||
self.assertEqual(left_margin.trimmed_left_edge([100, 1, 9, 8, 7, 6, 5, 4, 3, 2]), 2)
|
||||
self.assertEqual(left_margin.trimmed_left_edge([5] * 9), 5)
|
||||
self.assertIsNone(left_margin.trimmed_left_edge([]))
|
||||
|
||||
def test_one_margin_comment_does_not_veto_other_pages(self):
|
||||
images = [Image.new('RGB', (1654, 200)) for _ in range(4)]
|
||||
bands = [[42] * 40, [42] * 40, [42] * 40, [4] * 8 + [42] * 12]
|
||||
estimates = [{'edge_mm': left_margin.trimmed_left_edge(values),
|
||||
'bands': len(values), 'band_starts_mm': values} for values in bands]
|
||||
with patch.object(left_margin, 'estimate_page', side_effect=estimates):
|
||||
result = left_margin.analyse_margins(images, 200, 1654)
|
||||
group = result['groups'][0]
|
||||
self.assertEqual(group['edge_mm'], 42)
|
||||
self.assertEqual(group['left_mm'], 21)
|
||||
self.assertEqual(group['discarded_bands'], 14)
|
||||
self.assertEqual(result['starts_px'], [round(21 * 200 / 25.4)] * 4)
|
||||
|
||||
def test_repeated_left_writing_beyond_ten_percent_is_retained(self):
|
||||
self.assertEqual(left_margin.trimmed_left_edge([10] * 20 + [42] * 80), 10)
|
||||
|
||||
def test_disagreeing_pages_use_half_the_trimmed_margin(self):
|
||||
result = self.analyse([25, 31, 68, 49])
|
||||
self.assertEqual(result['groups'][0]['left_mm'], 12.5)
|
||||
self.assertEqual(result['starts_px'][1:], [round(12.5 * 200 / 25.4)] * 3)
|
||||
|
||||
def test_small_margin_starts_at_left(self):
|
||||
result = self.analyse([10, 15, 8, 13])
|
||||
self.assertEqual(result['groups'][0]['left_mm'], 0)
|
||||
self.assertEqual(result['starts_px'], [0] * 4)
|
||||
|
||||
def test_two_consecutive_no_margin_pages_enforce_left_edge_afterwards(self):
|
||||
images = [Image.new('RGB', (1654, 200)) for _ in range(8)]
|
||||
# Low pages have fewer occupied bands, so pooling still proposes a margin.
|
||||
edges = [42, 42, 10, 12, 42, 42, 42, 42]
|
||||
by_id = {id(image): edge for image, edge in zip(images, edges)}
|
||||
def estimate(image, _dpi):
|
||||
edge = by_id[id(image)]
|
||||
bands = 6 if edge < 20 else 100
|
||||
return {'edge_mm': edge, 'bands': bands, 'band_starts_mm': [edge] * bands}
|
||||
with patch.object(left_margin, 'estimate_page', side_effect=estimate):
|
||||
result = left_margin.analyse_margins(images, 200, 1654)
|
||||
self.assertEqual(result['groups'][0]['left_mm'], 21)
|
||||
self.assertEqual(result['no_margin_from_page'], 3)
|
||||
self.assertEqual(result['starts_px'], [round(21 * 200 / 25.4)] * 2 + [0] * 6)
|
||||
|
||||
def test_isolated_low_or_blank_pages_do_not_trigger_no_margin_rule(self):
|
||||
for edges in ([42, 10, 42, 12], [42, 10, None, 12]):
|
||||
with self.subTest(edges=edges):
|
||||
result = self.analyse(edges)
|
||||
self.assertIsNone(result['no_margin_from_page'])
|
||||
|
||||
def test_two_no_margin_pages_enforce_rule_across_later_width_changes(self):
|
||||
result = self.analyse([42, 42, 10, 12, 42, 42],
|
||||
widths=[1654, 1654, 1654, 1654, 2000, 2000],
|
||||
band_counts=[100, 100, 6, 6, 100, 100])
|
||||
self.assertEqual(result['no_margin_from_page'], 3)
|
||||
self.assertGreater(result['starts_px'][0], 0)
|
||||
self.assertEqual(result['starts_px'][2:], [0] * 4)
|
||||
|
||||
def test_half_standard_margin_threshold(self):
|
||||
for edge, expected in ((19.9, 0), (20, 10), (20.1, 10.05), (40, 20)):
|
||||
with self.subTest(edge=edge):
|
||||
result = self.analyse([edge] * 4)
|
||||
self.assertEqual(result['starts_px'][1:], [round(expected * 200 / 25.4)] * 3)
|
||||
|
||||
def test_body_bands_ignore_header_footer_and_abstain_on_sparse_ink(self):
|
||||
mask = np.zeros((1169, 827), bool)
|
||||
mask[60:340, 2:120] = True # Name/class block extends past 25% height.
|
||||
mask[1000:1050, 2:50] = True # Pagination.
|
||||
for y in range(330, 940, 35):
|
||||
mask[y:y + 5, 170:270] = True
|
||||
result = left_margin.body_edge(mask)
|
||||
self.assertAlmostEqual(result['edge_mm'], 43.2, delta=.3)
|
||||
self.assertGreaterEqual(result['bands'], 6)
|
||||
mask[300:960] = False
|
||||
self.assertIsNone(left_margin.body_edge(mask)['edge_mm'])
|
||||
|
||||
def test_real_mask_rejects_ruling_and_preserves_handwriting(self):
|
||||
image = Image.new('RGB', (827, 1169), 'white')
|
||||
draw = ImageDraw.Draw(image)
|
||||
for x in range(12, 827, 20):
|
||||
draw.line((x, 0, x, 1169), fill=(170, 190, 210))
|
||||
for y in range(10, 1169, 20):
|
||||
draw.line((0, y, 827, y), fill=(170, 190, 210))
|
||||
font = ImageFont.truetype('DejaVuSans.ttf', 16)
|
||||
for y in range(330, 920, 35):
|
||||
draw.text((170, y), '1) Pour tout x, f(x) = x + 1', font=font, fill=(20, 20, 100))
|
||||
result = left_margin.estimate_page(image, 100)
|
||||
self.assertAlmostEqual(result['edge_mm'], 43.2, delta=3)
|
||||
|
||||
def test_width_groups_tolerate_split_adjustments_and_separate_narrow_pages(self):
|
||||
pages = [Image.new('RGB', (w, 200)) for w in (1600, 1694, 1654, 800, 810, 2000)]
|
||||
self.assertEqual(left_margin.format_groups(pages, 1654), [[0, 1, 2], [3, 4], [5]])
|
||||
|
||||
def analyse(self, edges, widths=None, band_counts=None):
|
||||
images = [Image.new('RGB', (w, 200)) for w in (widths or [1654] * len(edges))]
|
||||
by_id = {id(image): edge for image, edge in zip(images, edges)}
|
||||
counts = {id(image): count for image, count in zip(images, band_counts or [12] * len(edges))}
|
||||
def estimate(image, _dpi):
|
||||
edge = by_id[id(image)]
|
||||
return {'edge_mm': edge, 'bands': counts[id(image)] if edge is not None else 0,
|
||||
'band_starts_mm': [edge] * counts[id(image)] if edge is not None else []}
|
||||
with patch.object(left_margin, 'estimate_page', side_effect=estimate):
|
||||
return left_margin.analyse_margins(images, 200, max(im.width for im in images))
|
||||
|
||||
def test_clear_margin_uses_same_conservative_edge_on_every_page(self):
|
||||
result = self.analyse([43, 41, 46, 44])
|
||||
self.assertEqual(result['groups'][0]['status'], 'margin')
|
||||
self.assertEqual(result['starts_px'], [round(20.5 * 200 / 25.4)] * 4)
|
||||
self.assertNotIn('first_page_body_start_px', result)
|
||||
|
||||
def test_sparse_groups_stay_at_left(self):
|
||||
for edges in ([None] * 4, [40, None, None, None]):
|
||||
with self.subTest(edges=edges):
|
||||
self.assertEqual(self.analyse(edges)['starts_px'], [0] * 4)
|
||||
|
||||
def test_narrow_subpages_stay_at_left_even_with_indented_text(self):
|
||||
result = self.analyse([45, 45, 70, 72], widths=[1654, 1654, 800, 800])
|
||||
self.assertEqual(result['starts_px'][2:], [0, 0])
|
||||
self.assertEqual(result['groups'][1]['status'], 'narrow-page')
|
||||
|
||||
def test_auto_crop_applies_manual_offsets_to_cached_estimate(self):
|
||||
images = [Image.new('RGB', (900, 600), 'white') for _ in range(2)]
|
||||
images[0].paste('blue', (150, 0, 190, 100))
|
||||
analysis = {'starts_px': [150, 150], 'groups': []}
|
||||
with patch.object(cutleft, 'get_pdf_pages', return_value=images), \
|
||||
patch.object(cutleft, 'analyse_margins', return_value=analysis) as analyse:
|
||||
result = cutleft.process_single_pdf(Path('/tmp/left-margin/Copy.pdf'))
|
||||
shifted = cutleft.process_single_pdf(Path('/tmp/left-margin/Copy.pdf'),
|
||||
shift_offset=-50, width_offset=50,
|
||||
margin_analysis=analysis)
|
||||
analyse.assert_called_once()
|
||||
self.assertEqual(result[1][0].getpixel((0, 0)), (0, 0, 255))
|
||||
self.assertEqual([p['left_px'] for p in result[2]['page_crops']], [150, 150])
|
||||
self.assertEqual([p['left_px'] for p in shifted[2]['page_crops']], [100, 100])
|
||||
self.assertEqual(result[2]['columns_per_file'], [2])
|
||||
self.assertEqual(result[1][0].size, (665, 600))
|
||||
self.assertEqual(shifted[1][0].size, (765, 600))
|
||||
|
||||
def test_fullpage_bypasses_analysis(self):
|
||||
with patch.object(cutleft, 'get_pdf_pages', return_value=[Image.new('RGB', (900, 600))]), \
|
||||
patch.object(cutleft, 'analyse_margins') as analyse:
|
||||
result = cutleft.process_single_pdf(Path('/tmp/left-margin/Copy.pdf'), max_per_file=1)
|
||||
analyse.assert_not_called()
|
||||
self.assertIsNone(result[2]['auto_margin'])
|
||||
|
||||
def test_wider_auto_strip_is_capped_at_page_edge(self):
|
||||
images = [Image.new('RGB', (900, 600)), Image.new('RGB', (200, 600))]
|
||||
with patch.object(cutleft, 'get_pdf_pages', return_value=images):
|
||||
result = cutleft.process_single_pdf(Path('/tmp/left-margin/Copy.pdf'),
|
||||
margin_analysis={'starts_px': [750, 0], 'groups': []})
|
||||
self.assertEqual([crop['right_px'] for crop in result[2]['page_crops']], [900, 200])
|
||||
self.assertEqual(result[2]['full_width_pages'], [2])
|
||||
|
||||
def test_first_page_uses_same_crop_width_as_later_pages(self):
|
||||
images = [Image.new('RGB', (900, 600), 'white') for _ in range(2)]
|
||||
images[0].paste('blue', (420, 100, 450, 200))
|
||||
analysis = {'starts_px': [150, 150], 'groups': []}
|
||||
with patch.object(cutleft, 'get_pdf_pages', return_value=images):
|
||||
result = cutleft.process_single_pdf(Path('/tmp/left-margin/Copy.pdf'),
|
||||
margin_analysis=analysis)
|
||||
self.assertEqual(result[2]['page_crops'][0]['right_px'], 480)
|
||||
self.assertEqual(result[1][0].getpixel((290, 120)), (0, 0, 255))
|
||||
self.assertEqual(result[1][0].size, (665, 600))
|
||||
self.assertEqual([crop['left_px'] for crop in result[2]['page_crops']], [150, 150])
|
||||
|
||||
def test_manual_margin_cli_disables_automatic_positioning(self):
|
||||
with patch.object(cutleft, 'run', return_value=0) as run, \
|
||||
patch.object(cutleft, 'workspace_from_target', return_value=(None, Path('/tmp'))):
|
||||
self.assertEqual(cutleft.main(['/tmp', '--manual-margin']), 0)
|
||||
self.assertFalse(run.call_args.kwargs['automatic_margin'])
|
||||
@@ -115,9 +115,9 @@ class MarkedCopiesTests(unittest.TestCase):
|
||||
def test_process_single_pdf_enlarges_crop_and_caps_it_at_page_edge(self):
|
||||
page = Image.new("RGB", (900, 300), "white")
|
||||
with patch.object(cutleft, "get_pdf_pages", return_value=[page]):
|
||||
regular = cutleft.process_single_pdf(self.files[0])
|
||||
enlarged = cutleft.process_single_pdf(self.files[0], width_offset=200)
|
||||
capped = cutleft.process_single_pdf(self.files[0], width_offset=1000)
|
||||
regular = cutleft.process_single_pdf(self.files[0], automatic_margin=False)
|
||||
enlarged = cutleft.process_single_pdf(self.files[0], width_offset=200, automatic_margin=False)
|
||||
capped = cutleft.process_single_pdf(self.files[0], width_offset=1000, automatic_margin=False)
|
||||
fullpage = cutleft.process_single_pdf(
|
||||
self.files[0], max_per_file=1, width_offset=200
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user