Compare commits

...
2 Commits
Author SHA1 Message Date
sebastien 1959c5d3bb Bnot : split large copies into pieces. 2026-10-23 08:32:17 +02:00
sebastien 834ffe32f2 miscs 2026-10-23 08:24:10 +02:00
17 changed files with 449 additions and 66 deletions

No files matched your search

+11
View File
@@ -235,6 +235,13 @@ OU
=python -m copienator annotate-checks Interro/Copies/Copie01.pdf=. =python -m copienator annotate-checks Interro/Copies/Copie01.pdf=.
Le mode =--refaire= exige un fichier =refaire.json= et écrit dans Le mode =--refaire= exige un fichier =refaire.json= et écrit dans
=BRnot=. =BRnot=.
Les documents sont limités à 32 000 pixels de haut, comme pour =BGnot=.
Les grandes copies sont découpées entre les labels en =Part1=, =Part2=,
etc., dans leur dossier =CopieXX=. L'export les nomme =CopieXX Part1.pdf=,
etc. Importer toutes les parties avant de lancer =read-annotations= :
elles sont réunies dans les résultats habituels de la copie. Une réponse
individuelle dépassant la limite produit une erreur explicite.
OU OU
2. =python -m copienator annotate-grouped Interro= dans =BGnot= 2. =python -m copienator annotate-grouped Interro= dans =BGnot=
@@ -242,6 +249,10 @@ OU
réponses par question. réponses par question.
Enregistrées dans =BGnot= Enregistrées dans =BGnot=
Les groupes visent une hauteur maximale de 32 000 pixels. Si le bloc
d'un seul élève dépasse cette limite, il reste entier dans un groupe
dédié, même lorsqu'il ne contient qu'une seule réponse.
_Needs_ : label_groups file (made automatically by this function), _Needs_ : label_groups file (made automatically by this function),
qui dit quelles questions regrouper. qui dit quelles questions regrouper.
+27
View File
@@ -0,0 +1,27 @@
"""Locate tablet documents, including the parts of a large checked copy."""
from pathlib import Path
from copienator.json_io import read_json
MAX_HEIGHT_PX = 32000
def copy_documents(directory: Path) -> list[Path]:
try:
metadata = read_json(directory / "bnote.json", default={})
except (OSError, ValueError):
# Ordinary documents need no manifest. Let the reader report bad metadata.
return [directory]
parts = metadata.get("parts", []) if isinstance(metadata, dict) else []
return [directory / name for name in parts] if parts else [directory]
def tablet_documents(root: Path) -> list[tuple[str, Path]]:
documents = []
for directory in sorted(root.iterdir()):
if not directory.is_dir():
continue
for part in copy_documents(directory):
name = directory.name if part == directory else f"{directory.name} {part.name}"
documents.append((name, part))
return documents
+17 -9
View File
@@ -416,6 +416,7 @@ def compose_label_image(base_img, label, result, hmin,
# Draw Headers # Draw Headers
current_y = 0 current_y = 0
draw = ImageDraw.Draw(final_img, "RGBA") draw = ImageDraw.Draw(final_img, "RGBA")
callback_items = []
for el in header_elements: for el in header_elements:
if el["type"] == "score" and more_right: if el["type"] == "score" and more_right:
@@ -426,9 +427,9 @@ def compose_label_image(base_img, label, result, hmin,
if draw_callback: if draw_callback:
# Hook for checkboxes # Hook for checkboxes
draw_callback("header_item", draw, callback_items.append(("header_item",
{"x": 0, "y": current_y, "w": el["img"].width, "h": el["img"].height}, {"x": 0, "y": current_y, "w": el["img"].width, "h": el["img"].height},
el) el))
current_y += el["img"].height current_y += el["img"].height
# Paste Base Image # Paste Base Image
@@ -453,9 +454,9 @@ def compose_label_image(base_img, label, result, hmin,
draw.rectangle([target_xmin, target_ymin, target_xmax, target_ymax], outline="red", width=3) draw.rectangle([target_xmin, target_ymin, target_xmax, target_ymax], outline="red", width=3)
if draw_callback: if draw_callback:
draw_callback("local_rect", draw, callback_items.append(("local_rect",
{"box": [target_xmin, target_ymin, target_xmax, target_ymax]}, {"box": [target_xmin, target_ymin, target_xmax, target_ymax]},
{"data": fb, "index": idx}) {"data": fb, "index": idx}))
# Render Text # Render Text
txt_img = render_fn(fb['text'], width_px=ANNOT_WIDTH, txt_img = render_fn(fb['text'], width_px=ANNOT_WIDTH,
@@ -481,12 +482,19 @@ def compose_label_image(base_img, label, result, hmin,
final_img.paste(txt_img, (10, int(paste_y)), mask=txt_img) final_img.paste(txt_img, (10, int(paste_y)), mask=txt_img)
if draw_callback: if draw_callback:
draw_callback("local_text", draw, callback_items.append(("local_text",
{"x": 10, "y": int(paste_y), "w": txt_img.width, "h": txt_img.height}, {"x": 10, "y": int(paste_y), "w": txt_img.width, "h": txt_img.height},
{"data": fb, "index": idx}) {"data": fb, "index": idx}))
last_text_bottom = paste_y + txt_img.height last_text_bottom = paste_y + txt_img.height
# Keep checkbox interiors clear of later rectangles and comment images.
# Use the final canvas, which may have grown while placing comments.
if draw_callback:
draw = ImageDraw.Draw(final_img, "RGBA")
for kind, pos, meta in callback_items:
draw_callback(kind, draw, pos, meta)
final_img.info['annotation_layout'] = layout final_img.info['annotation_layout'] = layout
return final_img, header_height return final_img, header_height
+8 -10
View File
@@ -23,13 +23,12 @@ from copienator import (
workspace_from_args, workspace_from_args,
) )
from copienator.annotation_data import load_annotation_data from copienator.annotation_data import load_annotation_data
from copienator.annotation_documents import MAX_HEIGHT_PX
from copienator.commands import annotating, annotating_with_checks from copienator.commands import annotating, annotating_with_checks
from copienator.commands.import_annotations import clear_import_directory from copienator.commands.import_annotations import clear_import_directory
from copienator.filesystem import staged_directory from copienator.filesystem import staged_directory
from copienator.utils import natural_key from copienator.utils import natural_key
MAX_HEIGHT_PX = 32000
def render_item(item): def render_item(item):
student_id, label, content = item student_id, label, content = item
@@ -65,10 +64,10 @@ def save_batch(batch, prefix, group_id, output_root: Path) -> None:
print(f"Generating group PDF: {prefix} G{group_id} ({len(batch)} elements)") print(f"Generating group PDF: {prefix} G{group_id} ({len(batch)} elements)")
max_width = max(item[2].width for item in batch) max_width = max(item[2].width for item in batch)
total_height = sum(item[2].height for item in batch) total_height = sum(item[2].height for item in batch)
if total_height > MAX_HEIGHT_PX: if total_height > MAX_HEIGHT_PX and len({item[0] for item in batch}) > 1:
raise CliError( raise CliError(
f"{prefix} G{group_id} would be {total_height} px high; " f"{prefix} G{group_id} would be {total_height} px high; "
f"the maximum is {MAX_HEIGHT_PX} px." f"only a single student's block may exceed {MAX_HEIGHT_PX} px."
) )
concatenated = Image.new("RGB", (max_width, total_height), "white") concatenated = Image.new("RGB", (max_width, total_height), "white")
draw = ImageDraw.Draw(concatenated) draw = ImageDraw.Draw(concatenated)
@@ -293,12 +292,11 @@ def split_batches(rendered):
student_height = sum(item[2].height for item in student_items) student_height = sum(item[2].height for item in student_items)
if student_height > MAX_HEIGHT_PX: if student_height > MAX_HEIGHT_PX:
labels = ", ".join(item[1] for item in student_items) # Oversized blocks remain intact in a group of their own.
raise CliError( batches.append(list(student_items))
f"Copie{student_id} ({labels}) is {student_height} px high; " heights.append(student_height)
f"one student's labels cannot be split across groups and the " index = end
f"maximum group height is {MAX_HEIGHT_PX} px." continue
)
# Reuse space in earlier groups, keeping each student's block intact. # Reuse space in earlier groups, keeping each student's block intact.
for batch_index, height in enumerate(heights): for batch_index, height in enumerate(heights):
+64 -22
View File
@@ -26,6 +26,7 @@ from copienator import (
workspace_from_target, workspace_from_target,
) )
from copienator.annotation_data import load_annotation_data from copienator.annotation_data import load_annotation_data
from copienator.annotation_documents import MAX_HEIGHT_PX, copy_documents
from copienator.commands import annotating from copienator.commands import annotating
from copienator.commands.import_annotations import clear_import_directory from copienator.commands.import_annotations import clear_import_directory
from copienator.filesystem import staged_directory from copienator.filesystem import staged_directory
@@ -143,7 +144,11 @@ class CheckboxRenderer:
def _output_complete(output_dir: Path) -> bool: def _output_complete(output_dir: Path) -> bool:
return all((output_dir / name).is_file() for name in EXPECTED_OUTPUTS) return all(
(document / name).is_file()
for document in copy_documents(output_dir)
for name in EXPECTED_OUTPUTS
)
def _render_student( def _render_student(
@@ -197,7 +202,6 @@ def _render_student(
"label": label, "label": label,
"header_height": header_height, "header_height": header_height,
"layout": final_image.info.get('annotation_layout', annotating.layout_for(None)), "layout": final_image.info.get('annotation_layout', annotating.layout_for(None)),
"img_h": final_image.height,
} }
) )
@@ -205,6 +209,46 @@ def _render_student(
print(f"Warning: no annotations could be rendered for Copie{student_id}") print(f"Warning: no annotations could be rendered for Copie{student_id}")
return "partial" return "partial"
batches = []
start = 0
height = 0
for index, image in enumerate(label_images):
if image.height > MAX_HEIGHT_PX:
raise CliError(
f"Copie{student_id} ({bnote_entries[index]['label']}) is "
f"{image.height} px high; a single answer cannot be split and "
f"the maximum document height is {MAX_HEIGHT_PX} px."
)
if height + image.height > MAX_HEIGHT_PX:
batches.append((start, index))
start = index
height = 0
height += image.height
batches.append((start, len(label_images)))
with staged_directory(output_dir) as staging:
parts = []
for index, (start, end) in enumerate(batches, start=1):
destination = staging
if len(batches) > 1:
name = f"Part{index}"
parts.append(name)
destination /= name
_save_document(destination, label_images[start:end],
checkbox_groups[start:end], bnote_entries[start:end])
if parts:
atomic_write_json(staging / "bnote.json", {
"parts": parts, "images": bnote_entries,
})
return "partial" if problems else "success"
def _save_document(
output_dir: Path,
label_images: list[Image.Image],
checkbox_groups: list[list[dict[str, Any]]],
bnote_entries: list[dict[str, Any]],
) -> None:
max_width = max(image.width for image in label_images) max_width = max(image.width for image in label_images)
total_height = sum(image.height for image in label_images) total_height = sum(image.height for image in label_images)
concatenated = Image.new("RGB", (max_width, total_height), "white") concatenated = Image.new("RGB", (max_width, total_height), "white")
@@ -216,7 +260,6 @@ def _render_student(
concatenated.paste(image, (0, current_y)) concatenated.paste(image, (0, current_y))
bnote_entries[index]["hmin"] = current_y bnote_entries[index]["hmin"] = current_y
bnote_entries[index]["hmax"] = current_y + image.height bnote_entries[index]["hmax"] = current_y + image.height
del bnote_entries[index]["img_h"]
for item in checkboxes: for item in checkboxes:
box = item.get("final_box") or item.get("rel_box") box = item.get("final_box") or item.get("rel_box")
item["global_box"] = [ item["global_box"] = [
@@ -228,25 +271,24 @@ def _render_student(
checkbox_map.append(item) checkbox_map.append(item)
current_y += image.height current_y += image.height
with staged_directory(output_dir) as staging: output_dir.mkdir(parents=True, exist_ok=True)
atomic_write_json( atomic_write_json(
staging / "bnote.json", output_dir / "bnote.json",
{"width": max_width, "height": total_height, "images": bnote_entries}, {"width": max_width, "height": total_height, "images": bnote_entries},
) )
atomic_write_json(staging / "checkboxes.json", checkbox_map) atomic_write_json(output_dir / "checkboxes.json", checkbox_map)
reference = staging / "Reference.jpg" reference = output_dir / "Reference.jpg"
concatenated.save(reference, quality=90) concatenated.save(reference, quality=90)
pdf_path = staging / "Concat.pdf" pdf_path = output_dir / "Concat.pdf"
pdf_canvas = canvas.Canvas(str(pdf_path), pagesize=(max_width, total_height)) pdf_canvas = canvas.Canvas(str(pdf_path), pagesize=(max_width, total_height))
pdf_canvas.drawImage( pdf_canvas.drawImage(
str(reference), str(reference),
0, 0,
0, 0,
width=max_width, width=max_width,
height=total_height, height=total_height,
) )
pdf_canvas.save() pdf_canvas.save()
return "partial" if problems else "success"
def _copy_id_from_target(workspace: EvaluationWorkspace, target: Path) -> str | None: def _copy_id_from_target(workspace: EvaluationWorkspace, target: Path) -> str | None:
+5 -2
View File
@@ -11,6 +11,7 @@ from copienator import (
workspace_from_args, workspace_from_args,
) )
from copienator.configuration import EXPORT_DIR from copienator.configuration import EXPORT_DIR
from copienator.annotation_documents import tablet_documents
from copienator.platform import replace_with_link_or_copy from copienator.platform import replace_with_link_or_copy
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot") ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
@@ -36,7 +37,9 @@ def export_directory(
subdirs = [directory for directory in subdirs if not directory.name.startswith("Copie")] subdirs = [directory for directory in subdirs if not directory.name.startswith("Copie")]
missing_outputs = 0 missing_outputs = 0
for subdir in subdirs: for name, subdir in tablet_documents(source_dir):
if subdir not in subdirs and subdir.parent not in subdirs:
continue
concat_file = next( concat_file = next(
( (
candidate candidate
@@ -52,7 +55,7 @@ def export_directory(
) )
missing_outputs += 1 missing_outputs += 1
continue continue
destination = sync_dir / f"{prefix}{subdir.name}{concat_file.suffix.lower()}" destination = sync_dir / f"{prefix}{name}{concat_file.suffix.lower()}"
method = replace_with_link_or_copy(concat_file, destination, prefer="hardlink") method = replace_with_link_or_copy(concat_file, destination, prefer="hardlink")
print(f"Exported: {destination} ({method})") print(f"Exported: {destination} ({method})")
return ExitCode.PARTIAL if missing_outputs else ExitCode.SUCCESS return ExitCode.PARTIAL if missing_outputs else ExitCode.SUCCESS
+6 -3
View File
@@ -13,6 +13,7 @@ from copienator import (
workspace_from_args, workspace_from_args,
) )
from copienator import configuration from copienator import configuration
from copienator.annotation_documents import tablet_documents
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot") ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
IMPORT_DIR = getattr(configuration, "IMPORT_DIR", None) IMPORT_DIR = getattr(configuration, "IMPORT_DIR", None)
@@ -58,6 +59,7 @@ def sync_annotated(
return ExitCode.INVALID_WORKSPACE return ExitCode.INVALID_WORKSPACE
missing_targets = 0 missing_targets = 0
targets = dict(tablet_documents(annotation_dir))
annotated_files = sorted( annotated_files = sorted(
( (
path path
@@ -70,10 +72,11 @@ def sync_annotated(
if prefix and not annotated_file.stem.startswith(prefix): if prefix and not annotated_file.stem.startswith(prefix):
print(f"Ignoring return from another pass: {annotated_file.name}") print(f"Ignoring return from another pass: {annotated_file.name}")
continue continue
target_subdir = annotation_dir / annotated_file.stem.removeprefix(prefix) name = annotated_file.stem.removeprefix(prefix)
target_subdir = targets.get(name)
if not target_subdir.is_dir(): if target_subdir is None or not target_subdir.is_dir():
print(f"Warning: directory not found: {target_subdir}", file=sys.stderr) print(f"Warning: annotation document not found: {name}", file=sys.stderr)
missing_targets += 1 missing_targets += 1
else: else:
suffix = annotated_file.suffix.lower() suffix = annotated_file.suffix.lower()
+28 -3
View File
@@ -22,6 +22,7 @@ from copienator import (
) )
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.annotation_data import AnnotationData, load_annotation_data from copienator.annotation_data import AnnotationData, load_annotation_data
from copienator.annotation_documents import copy_documents
from copienator.annotation_layout import legacy_layout from copienator.annotation_layout import legacy_layout
from copienator.annotation_modifications import ( from copienator.annotation_modifications import (
BASELINE, BASELINE,
@@ -154,6 +155,7 @@ def apply_actions_and_regenerate(
all_labels: list[str], all_labels: list[str],
*, *,
update_score: bool = False, update_score: bool = False,
label_notes: dict[str, Image.Image] | None = None,
) -> ExitCode: ) -> ExitCode:
"""Apply annotations and atomically merge the regenerated student files.""" """Apply annotations and atomically merge the regenerated student files."""
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}" output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
@@ -205,7 +207,7 @@ def apply_actions_and_regenerate(
scores[label] = str(result.get("score", 0)) scores[label] = str(result.get("score", 0))
is_empty = result.get("error") == "empty-answer" is_empty = result.get("error") == "empty-answer"
sub_note = None sub_note = label_notes.get(label) if label_notes is not None else None
if notes_layer is not None and "hmin" in image_info: if notes_layer is not None and "hmin" in image_info:
hmin = int(image_info.get("hmin", 0)) hmin = int(image_info.get("hmin", 0))
hmax = int(image_info.get("hmax", 0)) hmax = int(image_info.get("hmax", 0))
@@ -325,11 +327,33 @@ def run(
status = ExitCode.PARTIAL status = ExitCode.PARTIAL
continue continue
print(f"Processing annotations for: {student_id}") print(f"Processing annotations for: {student_id}")
actions, notes = ([], None) if only_empty else detect_checks_and_notes(output_dir) documents = copy_documents(output_dir)
if notes is None and not actions and not update_score and not only_empty: label_notes = None
actions, notes = [], None
if not only_empty and documents == [output_dir]:
actions, notes = detect_checks_and_notes(output_dir)
elif not only_empty:
label_notes = {}
incomplete = False
for document in documents:
part_actions, part_notes = detect_checks_and_notes(document)
if part_notes is None:
incomplete = True
continue
actions.extend(part_actions)
for item in read_json(document / "bnote.json")["images"]:
label_notes[item["label"]] = part_notes.crop(
(0, item["hmin"], part_notes.width, item["hmax"])
)
if incomplete:
print(" Missing a readable annotation part; copy was preserved.")
status = ExitCode.PARTIAL
continue
if notes is None and not label_notes and not actions and not update_score and not only_empty:
print(" No readable annotation input found.") print(" No readable annotation input found.")
status = ExitCode.PARTIAL status = ExitCode.PARTIAL
continue continue
extra = {"label_notes": label_notes} if label_notes is not None else {}
result = apply_actions_and_regenerate( result = apply_actions_and_regenerate(
workspace, workspace,
loaded.data, loaded.data,
@@ -338,6 +362,7 @@ def run(
notes, notes,
all_labels, all_labels,
update_score=update_score, update_score=update_score,
**extra,
) )
if result != ExitCode.SUCCESS: if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL status = ExitCode.PARTIAL
@@ -25,6 +25,7 @@ from copienator import (
) )
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
from copienator.annotation_documents import copy_documents, tablet_documents
from copienator.annotation_layout import legacy_layout from copienator.annotation_layout import legacy_layout
from copienator.annotation_modifications import ( from copienator.annotation_modifications import (
BASELINE, BASELINE,
@@ -463,11 +464,10 @@ def _scan_redo_annotations(
incomplete: set[str] = set() incomplete: set[str] = set()
plans = [] plans = []
required = ("checkboxes.json", "Reference.jpg", "Concat_annotated.pdf") required = ("checkboxes.json", "Reference.jpg", "Concat_annotated.pdf")
for path in sorted(directory.iterdir()): for _name, path in tablet_documents(directory):
if not path.is_dir(): copy_name = path.name if path.name.startswith("Copie") else path.parent.name
continue
default_id = ( default_id = (
path.name.removeprefix("Copie") if path.name.startswith("Copie") else None copy_name.removeprefix("Copie") if copy_name.startswith("Copie") else None
) )
try: try:
metadata = read_json(path / "bnote.json") metadata = read_json(path / "bnote.json")
@@ -590,12 +590,12 @@ def run(
if annotation_dir == "Bnot": if annotation_dir == "Bnot":
for student_id in refaire_by_student: for student_id in refaire_by_student:
directory = workspace.root / annotation_dir / f"Copie{student_id}" directory = workspace.root / annotation_dir / f"Copie{student_id}"
if directory.is_dir(): for document in copy_documents(directory) if directory.is_dir() else []:
_merge_scan_result( _merge_scan_result(
actions_by_student, actions_by_student,
notes_by_student, notes_by_student,
_scan_annotation_directory( _scan_annotation_directory(
directory, default_student_id=student_id document, default_student_id=student_id
), ),
) )
+21
View File
@@ -2129,6 +2129,9 @@ class CopienatorApp(tk.Tk):
return return
if step_id == "fetch_batches": if step_id == "fetch_batches":
self.after_idle(self._select_batch_integration) self.after_idle(self._select_batch_integration)
elif (step_id == "correction"
and self.state_store.step(step_id).get("last_run_variant") == "integrate"):
self.after_idle(self._select_after_batch_integration)
elif step_id == "manual_resolution": elif step_id == "manual_resolution":
self.after_idle(self._select_refaire_correction) self.after_idle(self._select_refaire_correction)
else: else:
@@ -2241,6 +2244,24 @@ class CopienatorApp(tk.Tk):
self.tree.selection_set("correction") self.tree.selection_set("correction")
self.tree.see("correction") self.tree.see("correction")
def _select_after_batch_integration(self) -> None:
"""Continue integration with conflict resolution or correction cleanup."""
evaluation = self.state_store.evaluation
if self.runner.running or self.active_step_id or evaluation is None:
return
if has_manual_conflicts(evaluation / "manual_resolutions.txt"):
target = "manual_resolution"
else:
self.state_store.update_step("manual_resolution", status="success")
self.state_store.add_history({
"step": "manual_resolution", "status": "success",
"manual": False, "reason": "Aucun conflit manuel après l’intégration batch.",
})
self._populate_tree()
target = "post_correction"
self.tree.selection_set(target)
self.tree.see(target)
def _select_refaire_correction(self) -> None: def _select_refaire_correction(self) -> None:
"""Return from manual conflict resolution to the main redo correction.""" """Return from manual conflict resolution to the main redo correction."""
if self.runner.running or self.active_step_id or not self.state_store.evaluation: if self.runner.running or self.active_step_id or not self.state_store.evaluation:
+3 -3
View File
@@ -80,9 +80,9 @@ class CutHelper(tk.Toplevel):
controls = ttk.Frame(self) controls = ttk.Frame(self)
controls.pack(fill="x", padx=8) controls.pack(fill="x", padx=8)
ttk.Label(controls, text="Conserver à la source :").pack(side="left") ttk.Label(controls, text="Conserver à la source :").pack(side="left")
ttk.Radiobutton(controls, text="1 — début", variable=self.keep, value=1).pack(side="left") ttk.Radiobutton(controls, text="1 — début", variable=self.keep, value=1).pack(side="left", padx=(8, 12))
ttk.Radiobutton(controls, text="2 — fin", variable=self.keep, value=2).pack(side="left") ttk.Radiobutton(controls, text="2 — fin", variable=self.keep, value=2).pack(side="left", padx=(0, 24))
ttk.Radiobutton(controls, text="Ajouter à la cible", variable=self.mode, value=">").pack(side="left") ttk.Radiobutton(controls, text="Ajouter à la cible", variable=self.mode, value=">").pack(side="left", padx=(0, 12))
ttk.Radiobutton(controls, text="Remplacer", variable=self.mode, value="x").pack(side="left") ttk.Radiobutton(controls, text="Remplacer", variable=self.mode, value="x").pack(side="left")
ttk.Label(self, textvariable=self.caption).pack(anchor="w", padx=8, pady=5) ttk.Label(self, textvariable=self.caption).pack(anchor="w", padx=8, pady=5)
viewport = ttk.Frame(self) viewport = ttk.Frame(self)
+7 -1
View File
@@ -193,7 +193,7 @@ class GivingNamesPanel(ttk.LabelFrame):
else "A Rendre n’a pas encore été généré." else "A Rendre n’a pas encore été généré."
) )
ttk.Label(self.issue_frame, text=text).grid( ttk.Label(self.issue_frame, text=text).grid(
row=0, column=0, columnspan=4, sticky="w" row=0, column=0, columnspan=5, sticky="w"
) )
return return
@@ -203,6 +203,9 @@ class GivingNamesPanel(ttk.LabelFrame):
ttk.Label(self.issue_frame, text="Nouveau nom").grid( ttk.Label(self.issue_frame, text="Nouveau nom").grid(
row=0, column=1, sticky="w" row=0, column=1, sticky="w"
) )
ttk.Label(self.issue_frame, text="Nom attribué").grid(
row=0, column=4, sticky="w", padx=(12, 0)
)
for row, issue in enumerate(issues, start=1): for row, issue in enumerate(issues, start=1):
ttk.Label( ttk.Label(
self.issue_frame, self.issue_frame,
@@ -226,3 +229,6 @@ class GivingNamesPanel(ttk.LabelFrame):
item, value item, value
), ),
).grid(row=row, column=3, pady=2) ).grid(row=row, column=3, pady=2)
ttk.Label(self.issue_frame, text=issue.name).grid(
row=row, column=4, sticky="w", padx=(12, 0), pady=2
)
+3 -1
View File
@@ -279,7 +279,9 @@ passage à la suite.
Exécutez lorsque tous les résultats sont disponibles. Vérifiez Exécutez lorsque tous les résultats sont disponibles. Vérifiez
l’absence de récupération manquante ; l’interface revient ensuite à l’absence de récupération manquante ; l’interface revient ensuite à
**Lancer ou intégrer la correction**, en mode **Intégrer les résultats **Lancer ou intégrer la correction**, en mode **Intégrer les résultats
batch**. Exécutez cette intégration. batch**. Exécutez cette intégration. Après succès, l’interface passe à
**Résoudre les conflits manuels** s’il en reste, ou directement à
**Nettoyer la correction** sinon.
### Résoudre les conflits manuels — si des conflits sont signalés ### Résoudre les conflits manuels — si des conflits sont signalés
+21
View File
@@ -12,6 +12,7 @@ from copienator.annotation_layout import (
move_answer_pdf, save_parts, move_answer_pdf, save_parts,
) )
from copienator.commands import annotating, annotating_by_label, splitting_int from copienator.commands import annotating, annotating_by_label, splitting_int
from copienator.commands.annotating_with_checks import CheckboxRenderer
from copienator.commands import reading_grouped_annotations as reader from copienator.commands import reading_grouped_annotations as reader
from copienator.left_margin import analyse_margins from copienator.left_margin import analyse_margins
from copienator.pdf_cut import split_pdf from copienator.pdf_cut import split_pdf
@@ -113,6 +114,26 @@ class AnnotationLayoutTests(unittest.TestCase):
self.assertEqual(low.getpixel((400, height + 80)), (0, 0, 255)) self.assertEqual(low.getpixel((400, height + 80)), (0, 0, 255))
self.assertEqual(low.getpixel((399, height + 80)), (255, 255, 255)) self.assertEqual(low.getpixel((399, height + 80)), (255, 255, 255))
def test_later_rectangle_cannot_draw_inside_comment_checkbox(self):
renderer = CheckboxRenderer('Ex 1')
result = {'score': 2, 'feedback': [
{'text': 'First comment', 'box_2d': [10, 50, 50, 200]},
{'text': 'Later rectangle', 'box_2d': [10, 300, 80, 400]},
]}
with patch.object(annotating, 'render_score_text', return_value=Image.new('RGB', (800, 80), 'white')):
image, _ = annotating.compose_label_image(
Image.new('RGB', (1654, 100), 'white'), 'Ex 1', result, 0,
render_fn=lambda *args, **kwargs: Image.new('RGBA', (620, 50), (255, 200, 200, 180)),
draw_callback=renderer.callback, layout=legacy_layout())
# The later rectangle's left edge crosses the first comment's checkbox.
checkbox = next(item for item in renderer.checkboxes
if item['type'] == 'del_local' and item['index'] == 0)
x1, y1, x2, y2 = checkbox['final_box']
interior = image.crop((x1 + 5, y1 + 5, x2 - 5, y2 - 5))
self.assertEqual(interior.getextrema(), ((255, 255),) * 3)
# The rectangle still exists outside the checkbox.
self.assertEqual(image.getpixel((650, 90)), (255, 0, 0))
def test_mixed_bgnot_widths_and_saved_geometry_survive_config_change(self): def test_mixed_bgnot_widths_and_saved_geometry_survive_config_change(self):
normal, height, _ = self.render(legacy_layout()) normal, height, _ = self.render(legacy_layout())
low, _, _ = self.render({**legacy_layout(), 'margin_left': 400}) low, _, _ = self.render({**legacy_layout(), 'margin_left': 400})
+156
View File
@@ -0,0 +1,156 @@
"""Large checked copies survive the tablet round trip as separate documents."""
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image, ImageDraw
from copienator import CliError, EvaluationWorkspace, ExitCode, read_json
from copienator.annotation_data import AnnotationLoadResult
from copienator.annotation_documents import MAX_HEIGHT_PX, copy_documents
from copienator.commands import annotating_with_checks as checks
from copienator.commands import export, import_annotations, reading_annotations as reader
from copienator.commands import reading_grouped_annotations as grouped_reader
class CheckedDocumentTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.workspace = EvaluationWorkspace(self.root / 'Exam')
for name in ('Copies', 'Par label'):
(self.workspace.root / name).mkdir(parents=True)
(self.workspace.root / 'labels').write_text('Ex 1\nEx 2\nEx 3\n')
(self.workspace.root / 'correction.json').write_text('{}')
self.heights = {'Ex 1': 60, 'Ex 2': 40, 'Ex 3': 60}
self.labels = {}
for label in self.heights:
pdf = self.workspace.root / 'Copies' / f'{label}.pdf'
pdf.touch()
self.labels[label] = {'pdf_path': pdf, 'result': {'score': 2, 'feedback': []},
'coordinates': (0, 0)}
self.output = self.workspace.root / 'Bnot' / 'Copie01'
def compose(self, base, label, result, coordinates, **kwargs):
image = Image.new('RGB', (100, self.heights[label]), 'white')
callback = kwargs.get('draw_callback')
if callback:
callback.__self__.checkboxes.append({
'type': 'score', 'label': label, 'value': 3,
'final_box': [10, 5, 30, 25],
})
ImageDraw.Draw(image).rectangle((10, 5, 30, 25), outline='black', width=2)
return image, 0
def generate(self, overwrite=False):
with (
patch.object(checks, 'MAX_HEIGHT_PX', 100),
patch.object(checks.annotating, 'make_base_image', return_value=(None, 0, 0)),
patch.object(checks.annotating, 'compose_label_image', side_effect=self.compose),
):
return checks._render_student(self.workspace, '01', self.labels,
overwrite=overwrite, output_mode='checks')
def test_splits_on_labels_and_resets_geometry_at_each_part(self):
self.assertEqual(MAX_HEIGHT_PX, 32000)
self.assertEqual(self.generate(), 'success')
parts = copy_documents(self.output)
self.assertEqual([part.name for part in parts], ['Part1', 'Part2'])
self.assertEqual([read_json(p / 'bnote.json')['height'] for p in parts], [100, 60])
self.assertEqual([item['hmin'] for item in read_json(self.output / 'bnote.json')['images']],
[0, 60, 0])
self.assertEqual(read_json(parts[1] / 'checkboxes.json')[0]['global_box'], [10, 5, 30, 25])
self.assertTrue(checks._output_complete(self.output))
self.assertEqual(self.generate(), 'skipped')
(parts[1] / 'Reference.jpg').unlink()
self.assertFalse(checks._output_complete(self.output))
def test_overwrite_removes_stale_parts_and_keeps_small_copy_layout(self):
self.generate()
self.labels = {'Ex 1': self.labels['Ex 1']}
self.generate(overwrite=True)
self.assertEqual(copy_documents(self.output), [self.output])
self.assertTrue((self.output / 'Concat.pdf').is_file())
self.assertFalse((self.output / 'Part1').exists())
def test_single_oversized_answer_and_save_failure_preserve_previous_copy(self):
self.generate()
sentinel = self.output / 'sentinel'
sentinel.write_text('previous')
self.heights['Ex 3'] = 101
with self.assertRaisesRegex(CliError, 'Copie01.*Ex 3'):
self.generate(overwrite=True)
self.assertEqual(sentinel.read_text(), 'previous')
self.heights['Ex 3'] = 60
original = checks._save_document
def fail_second(directory, *args):
if directory.name == 'Part2':
raise OSError('disk full')
original(directory, *args)
with patch.object(checks, '_save_document', side_effect=fail_second):
with self.assertRaisesRegex(OSError, 'disk full'):
self.generate(overwrite=True)
self.assertEqual(sentinel.read_text(), 'previous')
def returned_parts(self):
self.generate()
export_root = self.root / 'Export'
with patch.object(export, 'EXPORT_DIR', export_root):
self.assertEqual(export.run(self.workspace, annotation_dir='Bnot'), ExitCode.SUCCESS)
self.assertEqual(sorted(p.name for p in (export_root / 'Exam').iterdir()),
['Copie01 Part1.pdf', 'Copie01 Part2.pdf'])
import_root = self.root / 'Import'
import_root.mkdir()
for part in copy_documents(self.output):
with Image.open(part / 'Reference.jpg') as reference:
image = reference.convert('RGB')
draw = ImageDraw.Draw(image)
# Change Ex 3's score and add handwriting in its body.
if part.name == 'Part2':
draw.rectangle((14, 9, 26, 21), fill='black')
draw.rectangle((70, 40, 85, 50), fill='black')
image.save(import_root / f'Copie01 {part.name}.pdf', 'PDF', resolution=72)
self.assertEqual(import_annotations.sync_annotated(
self.workspace, annotation_dir_name='Bnot', import_dir=import_root), ExitCode.SUCCESS)
def test_export_import_and_reader_merge_parts_without_moving_notes(self):
self.returned_parts()
with (
patch.object(reader, 'load_annotation_data', return_value=AnnotationLoadResult({'01': self.labels}, [])),
patch.object(reader.annotating, 'make_base_image', return_value=(None, 0, 0)),
patch.object(reader.annotating, 'compose_label_image', side_effect=self.compose),
):
self.assertEqual(reader.run(self.workspace), ExitCode.SUCCESS)
self.assertEqual(read_json(self.output / 'score.json'), {'Ex 1': '2', 'Ex 2': '2', 'Ex 3': '3'})
with Image.open(self.output / 'Ex 3.jpg') as image:
self.assertLess(max(image.getpixel((77, 45))), 80)
with Image.open(self.output / 'Ex 1.jpg') as image:
self.assertGreater(min(image.getpixel((77, 45))), 230)
def test_missing_return_part_preserves_previous_regenerated_files(self):
self.returned_parts()
(self.output / 'Part2' / 'Concat_annotated.pdf').unlink()
(self.output / 'score.json').write_text('{"previous": true}')
with patch.object(reader, 'load_annotation_data', return_value=AnnotationLoadResult({'01': self.labels}, [])):
self.assertEqual(reader.run(self.workspace), ExitCode.PARTIAL)
self.assertEqual(read_json(self.output / 'score.json'), {'previous': True})
def test_redo_reader_accepts_split_copy_documents(self):
self.returned_parts()
# Redo uses the same document layout under BRnot.
self.output.parent.rename(self.workspace.root / 'BRnot')
actions, notes, incomplete = grouped_reader._scan_redo_annotations(
self.workspace.root / 'BRnot', {'01': set(self.labels)})
self.assertFalse(incomplete)
self.assertEqual(set(notes['01']), set(self.labels))
self.assertEqual(actions['01'][0]['label'], 'Ex 3')
(self.workspace.root / 'BRnot/Copie01/Part2/Concat_annotated.pdf').unlink()
_, _, incomplete = grouped_reader._scan_redo_annotations(
self.workspace.root / 'BRnot', {'01': set(self.labels)})
self.assertEqual(incomplete, {'01'})
if __name__ == '__main__':
unittest.main()
+44
View File
@@ -372,6 +372,50 @@ class GuiConvenienceTests(unittest.TestCase):
self.assertEqual(self.app.state_store.step("correction")["variant"], "integrate") self.assertEqual(self.app.state_store.step("correction")["variant"], "integrate")
self.assertIn("--deal-with-batched", self.app._make_command()) self.assertIn("--deal-with-batched", self.app._make_command())
def test_batch_integration_success_selects_conflicts_or_cleanup(self):
for conflicts, visited in product((None, "### Aucun conflit\n", "Copie01 A -> B\n"), (False, True)):
with self.subTest(conflicts=conflicts, visited=visited):
manual = self.evaluation / "manual_resolutions.txt"
if conflicts is None:
manual.unlink(missing_ok=True)
else:
manual.write_text(conflicts, encoding="utf-8")
(self.evaluation / "correction.json").write_text("{}", encoding="utf-8")
self.app.state_store.update_step(
"correction", variant="integrate", last_run_variant="integrate"
)
self.app.state_store.update_step("manual_resolution", visited=visited, status="stale")
self.app._populate_tree()
self.app.tree.selection_set("correction")
self.app.update()
self.app.active_step_id = "correction"
with patch.object(self.app.runner, "start") as start:
self.app._finish_process(0, False)
self.app.update()
start.assert_not_called()
expected = "manual_resolution" if conflicts == "Copie01 A -> B\n" else "post_correction"
self.assertEqual(self.app.current_step.id, expected)
expected_status = "stale" if expected == "manual_resolution" else "success"
self.assertEqual(self.app.state_store.step("manual_resolution")["status"], expected_status)
if expected_status == "success":
self.assertEqual(self.app.tree.item("manual_resolution", "values"), ("Réussie",))
for step_id in ("submit_batches", "batch_status", "fetch_batches", "prepare_recorrection"):
self.assertFalse(self.app.state_store.step(step_id).get("visited"))
def test_unsuccessful_batch_integration_stays_on_correction(self):
self.app.state_store.update_step(
"correction", variant="integrate", last_run_variant="integrate"
)
self.app._populate_tree()
self.app.tree.selection_set("correction")
self.app.update()
for code, interrupted in ((4, False), (1, False), (0, True)):
with self.subTest(code=code, interrupted=interrupted):
self.app.active_step_id = "correction"
self.app._finish_process(code, interrupted)
self.app.update()
self.assertEqual(self.app.current_step.id, "correction")
def test_correction_invalidates_batches_only_when_regenerating_or_resetting(self): def test_correction_invalidates_batches_only_when_regenerating_or_resetting(self):
for variant, action in product( for variant, action in product(
("integrate", "live", "refaire", "batch", "hybrid", "reset"), ("run", "mark") ("integrate", "live", "refaire", "batch", "hybrid", "reset"), ("run", "mark")
+22 -6
View File
@@ -2338,12 +2338,28 @@ class StandardCliTests(unittest.TestCase):
("01", "Ex 2", image, 0, []), ("01", "Ex 2", image, 0, []),
("02", "Ex 1", image, 0, []), ("02", "Ex 1", image, 0, []),
] ]
with patch.object(module, "MAX_HEIGHT_PX", 100), self.assertRaises( with patch.object(module, "MAX_HEIGHT_PX", 100):
CliError batches = module.split_batches(rendered)
) as raised: self.assertEqual(batches, [rendered[:2], rendered[2:]])
module.split_batches(rendered)
self.assertIn("Copie01", str(raised.exception)) def test_grouped_oversized_answer_is_saved_as_its_own_group(self) -> None:
self.assertIn("cannot be split", str(raised.exception)) module = self.modules["annotating_by_label"]
rendered = [
("01", "Ex 1", Image.new("RGB", (10, 40)), 0, []),
("02", "Ex 1", Image.new("RGB", (10, 120)), 0, []),
("03", "Ex 1", Image.new("RGB", (10, 50)), 0, []),
]
with tempfile.TemporaryDirectory() as directory, patch.object(module, "MAX_HEIGHT_PX", 100):
batches = module.split_batches(rendered)
self.assertEqual(batches, [[rendered[0], rendered[2]], [rendered[1]]])
for index, batch in enumerate(batches, start=1):
module.save_batch(batch, "Ex 1", index, Path(directory))
metadata = read_json(Path(directory) / "Ex 1 G2/bnote.json")
self.assertEqual(metadata["height"], 120)
self.assertEqual([item["id"] for item in metadata["images"]], ["02"])
self.assertTrue((Path(directory) / "Ex 1 G2/Concat.pdf").is_file())
with self.assertRaises(CliError):
module.save_batch(rendered[:2], "Ex 1", 3, Path(directory))
def test_grouped_batching_reuses_space_in_earlier_groups(self) -> None: def test_grouped_batching_reuses_space_in_earlier_groups(self) -> None:
module = self.modules["annotating_by_label"] module = self.modules["annotating_by_label"]