Compare commits

...
3 Commits
Author SHA1 Message Date
sebastien 76cead8561 Prompts improvements (?) 2026-10-12 22:21:43 +02:00
sebastien ad7813ea8f Allow Gemini to report insufficient crop, and deal with it 2026-10-12 19:01:51 +02:00
sebastien 247b124754 Better (?) default left margin 2026-10-12 18:52:05 +02:00
12 changed files with 645 additions and 44 deletions

No files matched your search

+84
View File
@@ -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)
+6 -3
View File
@@ -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.
"""
+70 -7
View File
@@ -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
+151
View File
@@ -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]}
+3
View File
@@ -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`.
+10
View File
@@ -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",
+23 -2
View File
@@ -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 à
+4 -4
View File
@@ -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)
-25
View File
@@ -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:
+107
View File
@@ -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('{}')
+184
View File
@@ -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'])
+3 -3
View File
@@ -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
)