Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1959c5d3bb | ||
|
|
834ffe32f2 |
No files matched your search
+11
@@ -235,6 +235,13 @@ OU
|
||||
=python -m copienator annotate-checks Interro/Copies/Copie01.pdf=.
|
||||
Le mode =--refaire= exige un fichier =refaire.json= et écrit dans
|
||||
=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
|
||||
2. =python -m copienator annotate-grouped Interro= dans =BGnot=
|
||||
|
||||
@@ -242,6 +249,10 @@ OU
|
||||
réponses par question.
|
||||
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),
|
||||
qui dit quelles questions regrouper.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -416,6 +416,7 @@ def compose_label_image(base_img, label, result, hmin,
|
||||
# Draw Headers
|
||||
current_y = 0
|
||||
draw = ImageDraw.Draw(final_img, "RGBA")
|
||||
callback_items = []
|
||||
|
||||
for el in header_elements:
|
||||
if el["type"] == "score" and more_right:
|
||||
@@ -426,9 +427,9 @@ def compose_label_image(base_img, label, result, hmin,
|
||||
|
||||
if draw_callback:
|
||||
# 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},
|
||||
el)
|
||||
el))
|
||||
current_y += el["img"].height
|
||||
|
||||
# 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)
|
||||
|
||||
if draw_callback:
|
||||
draw_callback("local_rect", draw,
|
||||
callback_items.append(("local_rect",
|
||||
{"box": [target_xmin, target_ymin, target_xmax, target_ymax]},
|
||||
{"data": fb, "index": idx})
|
||||
{"data": fb, "index": idx}))
|
||||
|
||||
# Render Text
|
||||
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)
|
||||
|
||||
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},
|
||||
{"data": fb, "index": idx})
|
||||
{"data": fb, "index": idx}))
|
||||
|
||||
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
|
||||
return final_img, header_height
|
||||
|
||||
|
||||
@@ -23,13 +23,12 @@ from copienator import (
|
||||
workspace_from_args,
|
||||
)
|
||||
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_annotations import clear_import_directory
|
||||
from copienator.filesystem import staged_directory
|
||||
from copienator.utils import natural_key
|
||||
|
||||
MAX_HEIGHT_PX = 32000
|
||||
|
||||
|
||||
def render_item(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)")
|
||||
max_width = max(item[2].width 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(
|
||||
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")
|
||||
draw = ImageDraw.Draw(concatenated)
|
||||
@@ -293,12 +292,11 @@ def split_batches(rendered):
|
||||
student_height = sum(item[2].height for item in student_items)
|
||||
|
||||
if student_height > MAX_HEIGHT_PX:
|
||||
labels = ", ".join(item[1] for item in student_items)
|
||||
raise CliError(
|
||||
f"Copie{student_id} ({labels}) is {student_height} px high; "
|
||||
f"one student's labels cannot be split across groups and the "
|
||||
f"maximum group height is {MAX_HEIGHT_PX} px."
|
||||
)
|
||||
# Oversized blocks remain intact in a group of their own.
|
||||
batches.append(list(student_items))
|
||||
heights.append(student_height)
|
||||
index = end
|
||||
continue
|
||||
|
||||
# Reuse space in earlier groups, keeping each student's block intact.
|
||||
for batch_index, height in enumerate(heights):
|
||||
|
||||
@@ -26,6 +26,7 @@ from copienator import (
|
||||
workspace_from_target,
|
||||
)
|
||||
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_annotations import clear_import_directory
|
||||
from copienator.filesystem import staged_directory
|
||||
@@ -143,7 +144,11 @@ class CheckboxRenderer:
|
||||
|
||||
|
||||
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(
|
||||
@@ -197,7 +202,6 @@ def _render_student(
|
||||
"label": label,
|
||||
"header_height": header_height,
|
||||
"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}")
|
||||
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)
|
||||
total_height = sum(image.height for image in label_images)
|
||||
concatenated = Image.new("RGB", (max_width, total_height), "white")
|
||||
@@ -216,7 +260,6 @@ def _render_student(
|
||||
concatenated.paste(image, (0, current_y))
|
||||
bnote_entries[index]["hmin"] = current_y
|
||||
bnote_entries[index]["hmax"] = current_y + image.height
|
||||
del bnote_entries[index]["img_h"]
|
||||
for item in checkboxes:
|
||||
box = item.get("final_box") or item.get("rel_box")
|
||||
item["global_box"] = [
|
||||
@@ -228,15 +271,15 @@ def _render_student(
|
||||
checkbox_map.append(item)
|
||||
current_y += image.height
|
||||
|
||||
with staged_directory(output_dir) as staging:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(
|
||||
staging / "bnote.json",
|
||||
output_dir / "bnote.json",
|
||||
{"width": max_width, "height": total_height, "images": bnote_entries},
|
||||
)
|
||||
atomic_write_json(staging / "checkboxes.json", checkbox_map)
|
||||
reference = staging / "Reference.jpg"
|
||||
atomic_write_json(output_dir / "checkboxes.json", checkbox_map)
|
||||
reference = output_dir / "Reference.jpg"
|
||||
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.drawImage(
|
||||
str(reference),
|
||||
@@ -246,7 +289,6 @@ def _render_student(
|
||||
height=total_height,
|
||||
)
|
||||
pdf_canvas.save()
|
||||
return "partial" if problems else "success"
|
||||
|
||||
|
||||
def _copy_id_from_target(workspace: EvaluationWorkspace, target: Path) -> str | None:
|
||||
|
||||
@@ -11,6 +11,7 @@ from copienator import (
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.configuration import EXPORT_DIR
|
||||
from copienator.annotation_documents import tablet_documents
|
||||
from copienator.platform import replace_with_link_or_copy
|
||||
|
||||
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
|
||||
@@ -36,7 +37,9 @@ def export_directory(
|
||||
subdirs = [directory for directory in subdirs if not directory.name.startswith("Copie")]
|
||||
|
||||
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(
|
||||
(
|
||||
candidate
|
||||
@@ -52,7 +55,7 @@ def export_directory(
|
||||
)
|
||||
missing_outputs += 1
|
||||
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")
|
||||
print(f"Exported: {destination} ({method})")
|
||||
return ExitCode.PARTIAL if missing_outputs else ExitCode.SUCCESS
|
||||
|
||||
@@ -13,6 +13,7 @@ from copienator import (
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator import configuration
|
||||
from copienator.annotation_documents import tablet_documents
|
||||
|
||||
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
|
||||
IMPORT_DIR = getattr(configuration, "IMPORT_DIR", None)
|
||||
@@ -58,6 +59,7 @@ def sync_annotated(
|
||||
return ExitCode.INVALID_WORKSPACE
|
||||
|
||||
missing_targets = 0
|
||||
targets = dict(tablet_documents(annotation_dir))
|
||||
annotated_files = sorted(
|
||||
(
|
||||
path
|
||||
@@ -70,10 +72,11 @@ def sync_annotated(
|
||||
if prefix and not annotated_file.stem.startswith(prefix):
|
||||
print(f"Ignoring return from another pass: {annotated_file.name}")
|
||||
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():
|
||||
print(f"Warning: directory not found: {target_subdir}", file=sys.stderr)
|
||||
if target_subdir is None or not target_subdir.is_dir():
|
||||
print(f"Warning: annotation document not found: {name}", file=sys.stderr)
|
||||
missing_targets += 1
|
||||
else:
|
||||
suffix = annotated_file.suffix.lower()
|
||||
|
||||
@@ -22,6 +22,7 @@ from copienator import (
|
||||
)
|
||||
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
|
||||
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_modifications import (
|
||||
BASELINE,
|
||||
@@ -154,6 +155,7 @@ def apply_actions_and_regenerate(
|
||||
all_labels: list[str],
|
||||
*,
|
||||
update_score: bool = False,
|
||||
label_notes: dict[str, Image.Image] | None = None,
|
||||
) -> ExitCode:
|
||||
"""Apply annotations and atomically merge the regenerated student files."""
|
||||
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))
|
||||
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:
|
||||
hmin = int(image_info.get("hmin", 0))
|
||||
hmax = int(image_info.get("hmax", 0))
|
||||
@@ -325,11 +327,33 @@ def run(
|
||||
status = ExitCode.PARTIAL
|
||||
continue
|
||||
print(f"Processing annotations for: {student_id}")
|
||||
actions, notes = ([], None) if only_empty else detect_checks_and_notes(output_dir)
|
||||
if notes is None and not actions and not update_score and not only_empty:
|
||||
documents = copy_documents(output_dir)
|
||||
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.")
|
||||
status = ExitCode.PARTIAL
|
||||
continue
|
||||
extra = {"label_notes": label_notes} if label_notes is not None else {}
|
||||
result = apply_actions_and_regenerate(
|
||||
workspace,
|
||||
loaded.data,
|
||||
@@ -338,6 +362,7 @@ def run(
|
||||
notes,
|
||||
all_labels,
|
||||
update_score=update_score,
|
||||
**extra,
|
||||
)
|
||||
if result != ExitCode.SUCCESS:
|
||||
status = ExitCode.PARTIAL
|
||||
|
||||
@@ -25,6 +25,7 @@ from copienator import (
|
||||
)
|
||||
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
|
||||
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_modifications import (
|
||||
BASELINE,
|
||||
@@ -463,11 +464,10 @@ def _scan_redo_annotations(
|
||||
incomplete: set[str] = set()
|
||||
plans = []
|
||||
required = ("checkboxes.json", "Reference.jpg", "Concat_annotated.pdf")
|
||||
for path in sorted(directory.iterdir()):
|
||||
if not path.is_dir():
|
||||
continue
|
||||
for _name, path in tablet_documents(directory):
|
||||
copy_name = path.name if path.name.startswith("Copie") else path.parent.name
|
||||
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:
|
||||
metadata = read_json(path / "bnote.json")
|
||||
@@ -590,12 +590,12 @@ def run(
|
||||
if annotation_dir == "Bnot":
|
||||
for student_id in refaire_by_student:
|
||||
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(
|
||||
actions_by_student,
|
||||
notes_by_student,
|
||||
_scan_annotation_directory(
|
||||
directory, default_student_id=student_id
|
||||
document, default_student_id=student_id
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -2129,6 +2129,9 @@ class CopienatorApp(tk.Tk):
|
||||
return
|
||||
if step_id == "fetch_batches":
|
||||
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":
|
||||
self.after_idle(self._select_refaire_correction)
|
||||
else:
|
||||
@@ -2241,6 +2244,24 @@ class CopienatorApp(tk.Tk):
|
||||
self.tree.selection_set("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:
|
||||
"""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:
|
||||
|
||||
@@ -80,9 +80,9 @@ class CutHelper(tk.Toplevel):
|
||||
controls = ttk.Frame(self)
|
||||
controls.pack(fill="x", padx=8)
|
||||
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="2 — fin", variable=self.keep, value=2).pack(side="left")
|
||||
ttk.Radiobutton(controls, text="Ajouter à la cible", variable=self.mode, value=">").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", padx=(0, 24))
|
||||
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.Label(self, textvariable=self.caption).pack(anchor="w", padx=8, pady=5)
|
||||
viewport = ttk.Frame(self)
|
||||
|
||||
@@ -193,7 +193,7 @@ class GivingNamesPanel(ttk.LabelFrame):
|
||||
else "A Rendre n’a pas encore été généré."
|
||||
)
|
||||
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
|
||||
|
||||
@@ -203,6 +203,9 @@ class GivingNamesPanel(ttk.LabelFrame):
|
||||
ttk.Label(self.issue_frame, text="Nouveau nom").grid(
|
||||
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):
|
||||
ttk.Label(
|
||||
self.issue_frame,
|
||||
@@ -226,3 +229,6 @@ class GivingNamesPanel(ttk.LabelFrame):
|
||||
item, value
|
||||
),
|
||||
).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
|
||||
)
|
||||
@@ -279,7 +279,9 @@ passage à la suite.
|
||||
Exécutez lorsque tous les résultats sont disponibles. Vérifiez
|
||||
l’absence de récupération manquante ; l’interface revient ensuite à
|
||||
**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
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from copienator.annotation_layout import (
|
||||
move_answer_pdf, save_parts,
|
||||
)
|
||||
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.left_margin import analyse_margins
|
||||
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((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):
|
||||
normal, height, _ = self.render(legacy_layout())
|
||||
low, _, _ = self.render({**legacy_layout(), 'margin_left': 400})
|
||||
|
||||
@@ -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()
|
||||
@@ -372,6 +372,50 @@ class GuiConvenienceTests(unittest.TestCase):
|
||||
self.assertEqual(self.app.state_store.step("correction")["variant"], "integrate")
|
||||
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):
|
||||
for variant, action in product(
|
||||
("integrate", "live", "refaire", "batch", "hybrid", "reset"), ("run", "mark")
|
||||
|
||||
+22
-6
@@ -2338,12 +2338,28 @@ class StandardCliTests(unittest.TestCase):
|
||||
("01", "Ex 2", image, 0, []),
|
||||
("02", "Ex 1", image, 0, []),
|
||||
]
|
||||
with patch.object(module, "MAX_HEIGHT_PX", 100), self.assertRaises(
|
||||
CliError
|
||||
) as raised:
|
||||
module.split_batches(rendered)
|
||||
self.assertIn("Copie01", str(raised.exception))
|
||||
self.assertIn("cannot be split", str(raised.exception))
|
||||
with patch.object(module, "MAX_HEIGHT_PX", 100):
|
||||
batches = module.split_batches(rendered)
|
||||
self.assertEqual(batches, [rendered[:2], rendered[2:]])
|
||||
|
||||
def test_grouped_oversized_answer_is_saved_as_its_own_group(self) -> None:
|
||||
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:
|
||||
module = self.modules["annotating_by_label"]
|
||||
|
||||
Reference in new issue
Block a user