Refaire fixes and GUI support

This commit is contained in:
2026-09-06 19:06:42 +02:00
parent a9897606ac
commit 2ff1a9b7b9
11 changed files with 1664 additions and 116 deletions
+93 -29
View File
@@ -346,39 +346,103 @@ le plan sans rien supprimer, et =--yes= pour omettre la confirmation
interactive. interactive.
* Autres * Autres
** Recorrection d'une seule copie (peu testé) ** Recorrection d'une copie ou de quelques questions
!! Attention, refaire ne marchera pas si tu fais une annotation non Dans le GUI, ouvrir la section =Refaire des copies (facultatif)=,
groupée into refaire !! repliée par défaut. Ajouter les copies et leurs questions depuis les
listes déroulantes, ou choisir =Toute la copie=, puis enregistrer la
sélection. Le dossier du passage principal est présélectionné d'après
le dernier mode utilisé et les dossiers présents ; vérifier ce choix.
Pour reprendre une même question dans toute la classe, choisir
=Toutes les copies=, sélectionner la question, puis =+ Ajouter=.
Seules les copies ayant un PDF de réponse pour cette question (normal
ou =_new=) sont ajoutées ; le nombre de copies sans réponse est affiché.
Une copie déjà sélectionnée entièrement reste sélectionnée entièrement.
1. Redécoupage Les boutons =Corrigés (Sol)= et =Consignes de notation (Persp)= ouvrent
+ =python -m copienator review-labels InterroTest/Copie01.pdf= les dossiers des textes utilisés par la correction. Modifier et enregistrer
+ =python -m copienator split-answers InterroTest/Copie20.pdf= les fichiers des questions concernées avant de relancer =Refaire la correction=.
2. Créer =refaire.json=, avec un contenu comme Ces boutons sont aussi disponibles dans le parcours principal.
Le choix =PDF à vérifier= propose =Automatique=, =Par question (groupé)=
ou =Par copie=. En automatique, une question présente dans plusieurs
copies déclenche le regroupement ; sinon les PDF sont produits par copie.
Les groupes sont limités en hauteur : une question pour toute la classe
peut donc produire quelques PDF plutôt qu'un fichier par élève.
Le GUI écrit =refaire.json= et guide ensuite le parcours ci-dessous.
La vérification du découpage, le redécoupage et la recorrection peuvent
être ignorés. Pour plusieurs copies, les vérifications et découpages
s'exécutent successivement ; un échec ou une interruption arrête la suite.
Ce parcours a sa propre progression : les boutons de navigation du
passage principal ne l'ouvrent pas automatiquement. Après la fusion,
les étapes de restitution déjà terminées sont marquées à revalider.
Ce flux fonctionne après =annotate-grouped= (=BGnot=),
=annotate-checks= (=Bnot=) ou =annotate-simple= (=Anot=).
Terminer d'abord la lecture des annotations du passage principal
(=read-grouped= ou =read-annotations= pour les modes à cases).
Conserver les dossiers d'annotation et leurs fichiers de référence.
1. Si nécessaire, reprendre le découpage :
+ =python -m copienator review-labels Interro/Copies/Copie01.pdf=
+ =python -m copienator split-answers Interro/Copies/Copie01.pdf=
Vérifier les réponses découpées avant de relancer la correction,
notamment les fichiers =_new= et =_old= issus de résolutions manuelles.
2. Créer =Interro/refaire.json= :
: [["Copie02", []], : [["Copie02", []],
: ["Copie01", ["Ex 1 : 1)"]]] : ["Copie01", ["Ex 1 : 1)"]]]
3. Appeler =correction= avec --refaire. Il doit créer des groupes Une liste vide sélectionne toute la copie ; sinon donner les labels
individuels, faire des requêtes, et remplacer les corrections exacts des questions (pas seulement le nom de l'exercice).
précédentes (à sauver ailleurs). 3. =python -m copienator correct Interro --refaire=
Crée des groupes individuels et remplace les corrections sélectionnées.
Les anciennes corrections sont conservées dans =overwritten_correction.json=.
Cette étape peut être omise si les corrections sont modifiées à la main.
4. Générer les PDF de vérification, selon la présentation souhaitée :
+ Par question : =python -m copienator annotate-grouped Interro --refaire --overwrite=
+ Par copie : =python -m copienator annotate-checks Interro --refaire --overwrite=
Les deux commandes produisent uniquement les réponses sélectionnées
dans =BRnot=, avec des cases à cocher, quel que soit le mode du passage
principal. Le mode groupé garde les identifiants des élèves et regroupe
les réponses par label sans demander de modifier =label_groups=.
Cela ne nécessite pas d'avoir généré =Bnot= ou =BGnot= auparavant.
Attention : =--overwrite= remplace tout le précédent passage dans
=BRnot=, y compris ses annotations manuscrites. Une génération incomplète
conserve l'ancien =BRnot=. Sans =--overwrite=, un =BRnot= existant est refusé.
5. Vider les dossiers personnels d'export/import des anciens fichiers,
puis =python -m copienator export Interro --refaire=.
Annoter les PDF sur la tablette, puis placer les PDF retournés dans
=IMPORT_DIR= en conservant leur nom exporté (nom de groupe ou =Copie01.pdf=).
Même sans modification manuscrite, retourner le PDF pour valider ce passage.
6. =python -m copienator import Interro --refaire=
7. Fusionner dans le dossier du passage principal :
+ Groupé : =python -m copienator read-grouped Interro --refaire=
+ Cases : =python -m copienator read-grouped Interro --refaire --annotation-dir Bnot=
+ Simple : =python -m copienator read-grouped Interro --refaire --annotation-dir Anot=
Ou non, si tu veux le faire à la main. Le lecteur reconnaît les retours par question comme les retours par
4. ?? Si je fais refaire, avant d'avoir créer les annotating with copie grâce aux métadonnées et réattribue les cases et notes à chaque
checks, que se passe-t-il ??? élève. Un groupe manquant laisse intactes les copies qui en dépendent.
5. Appeler =python -m copienator annotate-checks --refaire --overwrite= Il reconstruit la copie complète, conserve les réponses non
6. =python -m copienator export --refaire Interro24= sélectionnées et leurs scores, et remplace les réponses sélectionnées
6. =python -m copienator import --refaire Interro24= par celles de =BRnot=. Dans la compilation filtrée, les images déjà
7. =python -m copienator read-grouped --refaire Interro24= enregistrées des questions non sélectionnées sont conservées par
prudence, même si leur score est parfait, pour ne pas perdre de notes.
Les anciennes cases et notes manuscrites des
questions refaites sont remplacées. Les autres copies restent intactes.
En mode simple, une image =Concat_annotated.jpg= ou =.jpeg= importée
doit conserver les dimensions de l'image exportée ; les parties non
sélectionnées sont conservées. Le fichier =refaire_simple_layout.json=
mémorise le découpage de cette image pour les passages suivants.
Avec =--refaire=, =refaire.json= et le dossier =BRnot= sont des Les sorties finales (=Concat.jpg=, images par question, =score.json=
prérequis obligatoires ; leur absence produit le code de sortie 3. et compilation filtrée) sont mises à jour dans =BGnot=, =Bnot= ou
=Anot= ; les PDF et références du passage principal restent ceux de
ce passage. Pour une nouvelle retouche, reprendre ce flux =--refaire=,
sans relire ensuite les anciennes annotations avec le lecteur normal.
Relancer ensuite les étapes habituelles de calcul des notes et de diffusion.
** Exemple de replotting, refaire d'une copie =refaire.json=, =BRnot= et le dossier du passage principal sont
obligatoires (code de sortie 3 s'ils manquent). Une copie dont les
1. replot it. fichiers de retour sont incomplets est laissée intacte (code 4).
2. `python -m copienator split-answers DS09VA/Copies/Copie25.pdf` Ne pas ajouter =--update-score= sauf pour imposer volontairement les
this will get rid of old/new. anciens scores, y compris ceux des questions refaites.
!! Attention, et si ça dégage un new : bad bad bad.
3. Make `refaire.json`, avec la copie, et les labels à refaire.
4. `python -m copienator correct DS09VA --refaire`
5. `python -m copienator annotate-checks DS09VA --refaire`
6. `python -m copienator import Interro24 --refaire`
+12 -3
View File
@@ -36,7 +36,12 @@ def _coordinate_index(
if not workspace.groups_dir.is_dir(): if not workspace.groups_dir.is_dir():
return index, [f"Group directory not found: {workspace.groups_dir}"] return index, [f"Group directory not found: {workspace.groups_dir}"]
for metadata_path in sorted(workspace.groups_dir.glob("*/Group_*.json")): # A redo appends a new numbered group; its coordinates supersede the old group.
for metadata_path in sorted(
workspace.groups_dir.glob("*/Group_*.json"),
key=lambda path: int(path.stem.removeprefix("Group_")),
reverse=True,
):
image_path = metadata_path.with_suffix(".jpg") image_path = metadata_path.with_suffix(".jpg")
try: try:
entries = read_json(metadata_path) entries = read_json(metadata_path)
@@ -160,12 +165,16 @@ def load_annotation_data(
warnings.append(f"Ignoring malformed correction batch for {label!r}") warnings.append(f"Ignoring malformed correction batch for {label!r}")
continue continue
for item in raw_batch: for item in raw_batch:
if not isinstance(item, dict) or not isinstance(item.get("result"), dict): if not isinstance(item, dict) or not isinstance(
item.get("result"), dict
):
warnings.append(f"Ignoring malformed correction item for {label!r}") warnings.append(f"Ignoring malformed correction item for {label!r}")
continue continue
student_id = str(item.get("id", "")) student_id = str(item.get("id", ""))
if not student_id: if not student_id:
warnings.append(f"Ignoring correction item without an id for {label!r}") warnings.append(
f"Ignoring correction item without an id for {label!r}"
)
continue continue
result = item["result"] result = item["result"]
suffix = str(result.get("suffix", "")) suffix = str(result.get("suffix", ""))
+42 -12
View File
@@ -10,9 +10,6 @@ from typing import Any
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
from copienator.commands import annotating
from copienator.commands import annotating_with_checks
from copienator import utils
from copienator import ( from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
@@ -22,9 +19,11 @@ from copienator import (
evaluation_parser, evaluation_parser,
execute, execute,
read_json, read_json,
utils,
workspace_from_args, workspace_from_args,
) )
from copienator.annotation_data import load_annotation_data from copienator.annotation_data import load_annotation_data
from copienator.commands import annotating, annotating_with_checks
from copienator.filesystem import staged_directory from copienator.filesystem import staged_directory
from copienator.utils import natural_key from copienator.utils import natural_key
@@ -184,7 +183,9 @@ def _serialize_label_groups(groups: list[list[str]]) -> str:
return "".join(",".join(group) + "\n" for group in groups) return "".join(",".join(group) + "\n" for group in groups)
def _load_label_groups(workspace: EvaluationWorkspace, labels: list[str]) -> list[list[str]]: def _load_label_groups(
workspace: EvaluationWorkspace, labels: list[str]
) -> list[list[str]]:
label_groups = workspace.label_groups_file label_groups = workspace.label_groups_file
if not label_groups.exists(): if not label_groups.exists():
gemini_groups = _gemini_label_groups(workspace, labels) gemini_groups = _gemini_label_groups(workspace, labels)
@@ -328,20 +329,38 @@ def _generate_groups(
return generated, problems return generated, problems
def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode: def run(
workspace: EvaluationWorkspace, *, overwrite: bool = False, refaire: bool = False
) -> ExitCode:
workspace.require_files("labels", "correction.json") workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label") workspace.require_directories("Copies", "Par label")
labels = utils.read_all_labels(workspace.root) labels = utils.read_all_labels(workspace.root)
groups = _load_label_groups(workspace, labels) refaire_list = annotating_with_checks._load_refaire(workspace) if refaire else None
loaded = load_annotation_data(workspace) loaded = load_annotation_data(workspace, refaire_list=refaire_list)
groups = (
[
[label]
for label in sorted(
{label for answers in loaded.data.values() for label in answers},
key=natural_key,
)
]
if refaire
else _load_label_groups(workspace, labels)
)
for warning in loaded.warnings: for warning in loaded.warnings:
print(f"Warning: {warning}") print(f"Warning: {warning}")
if not loaded.data: if not loaded.data:
print("Warning: no annotation data was found.") print("Warning: no annotation data was found.")
return ExitCode.PARTIAL return ExitCode.PARTIAL
output_root = workspace.annotation_dir("grouped") output_root = workspace.annotation_dir("refaire" if refaire else "grouped")
if overwrite: if refaire and output_root.exists() and not overwrite:
raise CliError(
"BRnot already exists; use --overwrite to replace the previous redo."
)
if overwrite or refaire:
class IncompleteGroupedOutput(Exception): class IncompleteGroupedOutput(Exception):
pass pass
@@ -356,7 +375,9 @@ def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
if generated == 0 or problems or loaded.warnings: if generated == 0 or problems or loaded.warnings:
raise IncompleteGroupedOutput raise IncompleteGroupedOutput
except IncompleteGroupedOutput: except IncompleteGroupedOutput:
print("Warning: grouped overwrite was incomplete; previous BGnot was preserved.") print(
f"Warning: grouped overwrite was incomplete; previous {output_root.name} was preserved."
)
return ExitCode.PARTIAL return ExitCode.PARTIAL
else: else:
output_root.mkdir(parents=True, exist_ok=True) output_root.mkdir(parents=True, exist_ok=True)
@@ -375,7 +396,14 @@ def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Generate annotated PDFs grouped by labels.") parser = evaluation_parser("Generate annotated PDFs grouped by labels.")
parser.add_argument("--overwrite", action="store_true", help="Replace BGnot safely") parser.add_argument(
"--overwrite", action="store_true", help="Replace annotation outputs safely"
)
parser.add_argument(
"--refaire",
action="store_true",
help="Group only the answers in refaire.json, writing to BRnot",
)
return parser return parser
@@ -384,7 +412,9 @@ def main(argv: Sequence[str] | None = None) -> int:
return execute( return execute(
parser, parser,
argv, argv,
lambda args: run(workspace_from_args(args), overwrite=args.overwrite), lambda args: run(
workspace_from_args(args), overwrite=args.overwrite, refaire=args.refaire
),
) )
+48 -6
View File
@@ -14,8 +14,6 @@ matplotlib.use("Agg")
from PIL import Image, ImageFont from PIL import Image, ImageFont
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
from copienator.commands import annotating
from copienator import utils
from copienator import ( from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
@@ -24,9 +22,11 @@ from copienator import (
execute, execute,
read_json, read_json,
target_parser, target_parser,
utils,
workspace_from_target, workspace_from_target,
) )
from copienator.annotation_data import load_annotation_data from copienator.annotation_data import load_annotation_data
from copienator.commands import annotating
from copienator.filesystem import staged_directory from copienator.filesystem import staged_directory
from copienator.utils import natural_key from copienator.utils import natural_key
@@ -46,7 +46,9 @@ except OSError:
def draw_checkbox(draw, x, y, size=BOX_SIZE, label=None, fill="white"): def draw_checkbox(draw, x, y, size=BOX_SIZE, label=None, fill="white"):
if label: if label:
draw.text((x - BOX_SIZE - 5, y + 2), str(label), fill="black", font=CHECKBOX_FONT) draw.text(
(x - BOX_SIZE - 5, y + 2), str(label), fill="black", font=CHECKBOX_FONT
)
draw.rectangle([x, y, x + size, y + size], fill=fill, outline="black", width=2) draw.rectangle([x, y, x + size, y + size], fill=fill, outline="black", width=2)
return [x, y, x + size, y + size] return [x, y, x + size, y + size]
@@ -150,8 +152,13 @@ def _render_student(
*, *,
overwrite: bool, overwrite: bool,
output_mode: str, output_mode: str,
output_root: Path | None = None,
) -> str: ) -> str:
output_dir = workspace.annotation_dir(output_mode) / f"Copie{student_id}" output_dir = (
output_root
if output_root is not None
else workspace.annotation_dir(output_mode)
) / f"Copie{student_id}"
if _output_complete(output_dir) and not overwrite: if _output_complete(output_dir) and not overwrite:
print(f"Skipping {student_id}: output is complete.") print(f"Skipping {student_id}: output is complete.")
return "skipped" return "skipped"
@@ -178,6 +185,7 @@ def _render_student(
draw_callback=checkbox_renderer.callback, draw_callback=checkbox_renderer.callback,
) )
if final_image is None: if final_image is None:
problems = True
continue continue
label_images.append(final_image) label_images.append(final_image)
checkbox_groups.append(checkbox_renderer.checkboxes) checkbox_groups.append(checkbox_renderer.checkboxes)
@@ -280,6 +288,39 @@ def run(
output_mode = "refaire" if refaire else "checks" output_mode = "refaire" if refaire else "checks"
tasks = sorted(loaded.data.items(), key=lambda item: natural_key(item[0])) tasks = sorted(loaded.data.items(), key=lambda item: natural_key(item[0]))
if refaire:
output_root = workspace.annotation_dir("refaire")
if output_root.exists() and not overwrite:
raise CliError(
"BRnot already exists; use --overwrite to replace the previous redo."
)
class IncompleteRedo(Exception):
pass
try:
with staged_directory(output_root) as staging:
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(
_render_student,
workspace,
student_id,
labels,
overwrite=True,
output_mode="refaire",
output_root=staging,
)
for student_id, labels in tasks
]
statuses = [future.result() for future in futures]
if loaded.warnings or any(status != "success" for status in statuses):
raise IncompleteRedo
except IncompleteRedo:
print("Warning: incomplete redo generation; previous BRnot was preserved.")
return ExitCode.PARTIAL
return ExitCode.SUCCESS
statuses: list[str] = [] statuses: list[str] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
futures = [ futures = [
@@ -302,7 +343,9 @@ def run(
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Generate annotated PDFs with checkboxes.") parser = target_parser("Generate annotated PDFs with checkboxes.")
parser.add_argument("--overwrite", action="store_true", help="Replace existing outputs") parser.add_argument(
"--overwrite", action="store_true", help="Replace existing outputs"
)
parser.add_argument( parser.add_argument(
"--refaire", "--refaire",
action="store_true", action="store_true",
@@ -328,4 +371,3 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
@@ -9,8 +9,6 @@ from typing import Any
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
from copienator.commands import annotating
from copienator import utils
from copienator import ( from copienator import (
EvaluationWorkspace, EvaluationWorkspace,
ExitCode, ExitCode,
@@ -18,16 +16,18 @@ from copienator import (
evaluation_parser, evaluation_parser,
execute, execute,
read_json, read_json,
utils,
workspace_from_args, workspace_from_args,
) )
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.filesystem import staged_files from copienator.commands import annotating
from copienator.commands.reading_annotations import ( from copienator.commands.reading_annotations import (
concatenate, concatenate,
detect_checks_and_notes, detect_checks_and_notes,
has_significant_notes, has_significant_notes,
) )
from copienator.filesystem import staged_files
LabelNotes = dict[str, dict[str, Any]] LabelNotes = dict[str, dict[str, Any]]
ScanResult = tuple[dict[str, list[dict[str, Any]]], dict[str, LabelNotes]] ScanResult = tuple[dict[str, list[dict[str, Any]]], dict[str, LabelNotes]]
@@ -54,7 +54,9 @@ def get_extra_pdfs_as_images(
return images return images
def save_paginated_pdf(image_groups: list[list[Image.Image]], output_path: Path) -> None: def save_paginated_pdf(
image_groups: list[list[Image.Image]], output_path: Path
) -> None:
"""Paginate vertically concatenated image groups and save them as a PDF.""" """Paginate vertically concatenated image groups and save them as a PDF."""
non_empty = [group for group in image_groups if group] non_empty = [group for group in image_groups if group]
if not non_empty: if not non_empty:
@@ -118,6 +120,8 @@ def _scan_annotation_directory(
directory: Path, directory: Path,
only_ids: set[str] | None = None, only_ids: set[str] | None = None,
default_student_id: str | None = None, default_student_id: str | None = None,
*,
required: bool = False,
) -> ScanResult: ) -> ScanResult:
bnote_path = directory / "bnote.json" bnote_path = directory / "bnote.json"
if not bnote_path.is_file(): if not bnote_path.is_file():
@@ -133,6 +137,8 @@ def _scan_annotation_directory(
actions, notes_image = detect_checks_and_notes(directory) actions, notes_image = detect_checks_and_notes(directory)
if notes_image is None: if notes_image is None:
if required:
raise ValueError(f"Could not read annotations in {directory}")
return {}, {} return {}, {}
actions_by_student: dict[str, list[dict[str, Any]]] = defaultdict(list) actions_by_student: dict[str, list[dict[str, Any]]] = defaultdict(list)
notes_by_student: dict[str, LabelNotes] = defaultdict(dict) notes_by_student: dict[str, LabelNotes] = defaultdict(dict)
@@ -176,10 +182,12 @@ def apply_actions_and_regenerate_grouped(
all_labels: list[str], all_labels: list[str],
*, *,
update_score: bool = False, update_score: bool = False,
annotation_dir: str = "BGnot",
selected_labels: set[str] | None = None,
) -> tuple[ExitCode, str]: ) -> tuple[ExitCode, str]:
"""Apply grouped annotations and atomically merge regenerated student files.""" """Regenerate a copy, preserving reviewed images outside the redo selection."""
logs = [f"\nProcessing compilation for: Copie{student_id}"] logs = [f"\nProcessing compilation for: Copie{student_id}"]
output_dir = workspace.annotation_dir("grouped") / f"Copie{student_id}" output_dir = workspace.root / annotation_dir / f"Copie{student_id}"
labels_data = data.get(student_id, {}) labels_data = data.get(student_id, {})
dirty_labels = apply_checkbox_actions(labels_data, actions, logs.append) dirty_labels = apply_checkbox_actions(labels_data, actions, logs.append)
if update_score: if update_score:
@@ -187,15 +195,91 @@ def apply_actions_and_regenerate_grouped(
labels_data, output_dir / "score.json", logs.append labels_data, output_dir / "score.json", logs.append
) )
selected_labels = selected_labels if selected_labels is not None else set()
dirty_labels |= selected_labels
simple_layout = None
simple_annotated = None
if selected_labels and annotation_dir == "Anot":
imported = next(
(
output_dir / name
for name in ("Concat_annotated.jpg", "Concat_annotated.jpeg")
if (output_dir / name).is_file()
),
None,
)
if imported is not None:
layout_path = output_dir / "refaire_simple_layout.json"
if layout_path.is_file():
simple_layout = read_json(layout_path)
else:
simple_layout = {"images": {}, "replaced": []}
y = 0
for label in sorted(labels_data, key=utils.natural_key):
path = output_dir / f"{label}.jpg"
if (
path.is_file()
and labels_data[label]["result"].get("error") != "empty-answer"
):
with Image.open(path) as saved:
simple_layout["images"][label] = [y, y + saved.height]
y += saved.height
with Image.open(imported) as saved:
simple_annotated = saved.convert("RGB").copy()
expected_height = max(
(bounds[1] for bounds in simple_layout["images"].values()), default=0
)
if simple_annotated.height != expected_height:
raise ValueError(
"Imported simple image height does not match the original copy layout"
)
old_scores = (
read_json(output_dir / "score.json")
if selected_labels and (output_dir / "score.json").is_file()
else {}
)
scores = dict.fromkeys(all_labels, "") scores = dict.fromkeys(all_labels, "")
dirty_images: dict[str, Image.Image] = {} dirty_images: dict[str, Image.Image] = {}
concat_images: list[Image.Image] = [] concat_images: list[Image.Image] = []
filtered_groups: list[list[Image.Image]] = [] filtered_groups: list[list[Image.Image]] = []
incomplete = False incomplete = False
for label, content in sorted(labels_data.items(), key=lambda item: utils.natural_key(item[0])): for label, content in sorted(
labels_data.items(), key=lambda item: utils.natural_key(item[0])
):
result = content["result"] result = content["result"]
if (
selected_labels
and label not in selected_labels
and old_scores.get(label, "") != ""
):
result["score"] = old_scores[label]
scores[label] = str(result.get("score", 0)) scores[label] = str(result.get("score", 0))
saved_image = output_dir / f"{label}.jpg"
if selected_labels and label not in selected_labels and saved_image.is_file():
with Image.open(saved_image) as saved:
final_image = saved.convert("RGB").copy()
if (
simple_annotated is not None
and label in simple_layout["images"]
and label not in simple_layout["replaced"]
):
hmin, hmax = simple_layout["images"][label]
final_image = simple_annotated.crop(
(0, hmin, simple_annotated.width, hmax)
)
dirty_images[label] = final_image
scores[label] = str(old_scores.get(label, scores[label]))
concat_images.append(final_image)
# Keep previously reviewed content, including handwriting.
if annotation_dir == "BGnot":
extras = get_extra_pdfs_as_images(
workspace.root, label, annotating, all_labels
)
filtered_groups.append([*extras, final_image])
else:
filtered_groups.append([final_image])
continue
pdf_path = Path(content["pdf_path"]) pdf_path = Path(content["pdf_path"])
if not pdf_path.is_file(): if not pdf_path.is_file():
logs.append(f" Missing answer PDF: {pdf_path}") logs.append(f" Missing answer PDF: {pdf_path}")
@@ -221,13 +305,15 @@ def apply_actions_and_regenerate_grouped(
if has_notes: if has_notes:
width, height = sub_note.size width, height = sub_note.size
if old_header_height > 0: if old_header_height > 0:
header = sub_note.crop((0, 0, width, min(height, old_header_height))) header = sub_note.crop(
(0, 0, width, min(height, old_header_height))
)
final_image.paste(header, (0, 0), mask=header) final_image.paste(header, (0, 0), mask=header)
if height > old_header_height: if height > old_header_height:
body = sub_note.crop((0, old_header_height, width, height)) body = sub_note.crop((0, old_header_height, width, height))
final_image.paste(body, (0, new_header_height), mask=body) final_image.paste(body, (0, new_header_height), mask=body)
if label in dirty_labels or has_notes: if label in dirty_labels or has_notes or selected_labels:
dirty_images[label] = final_image dirty_images[label] = final_image
logs.append(f" Saved dirty image: {label}.jpg") logs.append(f" Saved dirty image: {label}.jpg")
concat_images.append(final_image) concat_images.append(final_image)
@@ -237,33 +323,54 @@ def apply_actions_and_regenerate_grouped(
feedback.get("to_delete", False) for feedback in feedbacks feedback.get("to_delete", False) for feedback in feedbacks
) )
if not perfect or has_notes: if not perfect or has_notes:
extras = get_extra_pdfs_as_images( extras = (
workspace.root, label, annotating, all_labels get_extra_pdfs_as_images(workspace.root, label, annotating, all_labels)
if annotation_dir == "BGnot"
else []
) )
filtered_groups.append([*extras, final_image]) filtered_groups.append([*extras, final_image])
concat_image = concatenate(concat_images) concat_image = concatenate(concat_images)
with staged_files(output_dir) as staging: if incomplete:
return ExitCode.PARTIAL, "\n".join(logs)
with staged_files(output_dir, remove=("Concat_F.pdf", "Concat_F.jpg")) as staging:
if simple_layout is not None:
simple_layout["replaced"] = sorted(
set(simple_layout["replaced"]) | selected_labels
)
atomic_write_json(staging / "refaire_simple_layout.json", simple_layout)
for label, image in dirty_images.items(): for label, image in dirty_images.items():
image.save(staging / f"{label}.jpg") image.save(staging / f"{label}.jpg")
atomic_write_json(staging / "score.json", scores) atomic_write_json(staging / "score.json", scores)
if concat_image is not None: if concat_image is not None:
concat_image.save(staging / "Concat.jpg") concat_image.save(staging / "Concat.jpg")
if filtered_groups: if filtered_groups:
if annotation_dir == "BGnot":
save_paginated_pdf(filtered_groups, staging / "Concat_F.pdf") save_paginated_pdf(filtered_groups, staging / "Concat_F.pdf")
else:
filtered_image = concatenate(
[image for group in filtered_groups for image in group]
)
filtered_image.save(staging / "Concat_F.jpg")
logs.append(f" Saved regenerated files in {output_dir}") logs.append(f" Saved regenerated files in {output_dir}")
status = ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS status = ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
return status, "\n".join(logs) return status, "\n".join(logs)
def _read_refaire(workspace: EvaluationWorkspace) -> tuple[RefaireList, dict[str, list[str]]]: def _read_refaire(
workspace: EvaluationWorkspace,
) -> tuple[RefaireList, dict[str, list[str]]]:
loaded = read_json(workspace.refaire_file) loaded = read_json(workspace.refaire_file)
if not isinstance(loaded, list): if not isinstance(loaded, list):
raise TypeError("refaire.json must contain a JSON array") raise TypeError("refaire.json must contain a JSON array")
entries: RefaireList = [] entries: RefaireList = []
by_student: dict[str, list[str]] = {} by_student: dict[str, list[str]] = {}
for entry in loaded: for entry in loaded:
if not isinstance(entry, list) or len(entry) != 2 or not isinstance(entry[1], list): if (
not isinstance(entry, list)
or len(entry) != 2
or not isinstance(entry[1], list)
):
raise TypeError(f"Malformed refaire entry: {entry!r}") raise TypeError(f"Malformed refaire entry: {entry!r}")
copy_name, labels = entry copy_name, labels = entry
student_id = str(copy_name).removeprefix("Copie") student_id = str(copy_name).removeprefix("Copie")
@@ -273,14 +380,78 @@ def _read_refaire(workspace: EvaluationWorkspace) -> tuple[RefaireList, dict[str
return entries, by_student return entries, by_student
def _scan_redo_annotations(
directory: Path,
expected: dict[str, set[str]],
) -> tuple[dict[str, list[dict[str, Any]]], dict[str, LabelNotes], set[str]]:
"""Read either grouped or per-copy redo PDFs using their student/label metadata."""
actions: dict[str, list[dict[str, Any]]] = defaultdict(list)
notes: dict[str, LabelNotes] = defaultdict(dict)
seen: dict[str, set[str]] = defaultdict(set)
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
default_id = (
path.name.removeprefix("Copie") if path.name.startswith("Copie") else None
)
try:
metadata = read_json(path / "bnote.json")
pairs = [
(str(item.get("id", default_id)), str(item["label"]))
for item in metadata["images"]
]
except (OSError, ValueError, TypeError, KeyError) as exc:
print(f"Warning: unreadable redo metadata in {path}: {exc}")
incomplete.update(expected)
continue
students = {
student_id for student_id, _label in pairs if student_id in expected
}
if not students:
continue
for student_id, label in pairs:
if student_id not in expected:
continue
if label in seen[student_id]:
incomplete.add(student_id)
seen[student_id].add(label)
if any(not (path / name).is_file() for name in required):
print(f"Warning: missing returned redo inputs in {path}")
incomplete.update(students)
else:
plans.append((path, default_id, students))
for student_id, labels in expected.items():
if seen[student_id] != labels:
print(
f"Warning: redo labels do not match refaire.json for Copie{student_id}; regenerate BRnot"
)
incomplete.add(student_id)
for path, default_id, students in plans:
if students <= incomplete:
continue
try:
result = _scan_annotation_directory(
path, default_student_id=default_id, required=True
)
_merge_scan_result(actions, notes, result)
except (OSError, ValueError, TypeError) as exc:
print(f"Warning: could not read redo annotations in {path}: {exc}")
incomplete.update(students)
return dict(actions), dict(notes), incomplete
def run( def run(
workspace: EvaluationWorkspace, workspace: EvaluationWorkspace,
*, *,
refaire: bool = False, refaire: bool = False,
update_score: bool = False, update_score: bool = False,
annotation_dir: str = "BGnot",
) -> ExitCode: ) -> ExitCode:
workspace.require_files("labels", "correction.json") workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label", "BGnot") workspace.require_directories("Copies", "Par label", annotation_dir)
refaire_list: RefaireList | None = None refaire_list: RefaireList | None = None
refaire_by_student: dict[str, list[str]] = {} refaire_by_student: dict[str, list[str]] = {}
if refaire: if refaire:
@@ -289,7 +460,12 @@ def run(
refaire_list, refaire_by_student = _read_refaire(workspace) refaire_list, refaire_by_student = _read_refaire(workspace)
all_labels = utils.read_all_labels(workspace.root) all_labels = utils.read_all_labels(workspace.root)
loaded = load_annotation_data(workspace, refaire_list=refaire_list) loaded = load_annotation_data(workspace)
if refaire_list:
# Add explicitly requested answers without filtering out the rest of a copy.
selected_data = load_annotation_data(workspace, refaire_list=refaire_list)
for student_id, labels in selected_data.data.items():
loaded.data.setdefault(student_id, {}).update(labels)
for warning in loaded.warnings: for warning in loaded.warnings:
print(f"Warning: {warning}") print(f"Warning: {warning}")
if not loaded.data: if not loaded.data:
@@ -301,8 +477,10 @@ def run(
only_ids = set(refaire_by_student) or None only_ids = set(refaire_by_student) or None
group_dirs = [ group_dirs = [
path path
for path in workspace.annotation_dir("grouped").iterdir() for path in (workspace.root / annotation_dir).iterdir()
if path.is_dir() and not path.name.startswith("Copie") if annotation_dir == "BGnot"
and path.is_dir()
and not path.name.startswith("Copie")
] ]
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
futures = [ futures = [
@@ -312,39 +490,58 @@ def run(
for future in concurrent.futures.as_completed(futures): for future in concurrent.futures.as_completed(futures):
_merge_scan_result(actions_by_student, notes_by_student, future.result()) _merge_scan_result(actions_by_student, notes_by_student, future.result())
refaire_incomplete = False if annotation_dir == "Bnot":
for student_id in refaire_by_student:
directory = workspace.root / annotation_dir / f"Copie{student_id}"
if directory.is_dir():
_merge_scan_result(
actions_by_student,
notes_by_student,
_scan_annotation_directory(
directory, default_student_id=student_id
),
)
skipped_students: set[str] = set()
if refaire: if refaire:
for student_id, requested_labels in refaire_by_student.items(): expected = {
selected = requested_labels or list(loaded.data.get(student_id, {})) student_id: set(labels or loaded.data.get(student_id, {}))
selected_set = set(selected) for student_id, labels in refaire_by_student.items()
directory = workspace.annotation_dir("refaire") / f"Copie{student_id}" }
if not directory.is_dir(): redo_actions, redo_notes, skipped_students = _scan_redo_annotations(
print(f"Warning: missing refaire annotation directory {directory}") workspace.annotation_dir("refaire"), expected
refaire_incomplete = True )
for student_id, selected in expected.items():
if student_id in skipped_students:
continue continue
actions_by_student[student_id] = [ actions_by_student[student_id] = [
action action
for action in actions_by_student[student_id] for action in actions_by_student[student_id]
if str(action.get("label")) not in selected_set if str(action.get("label")) not in selected
] ]
for label in selected: for label in selected:
notes_by_student[student_id].pop(label, None) notes_by_student[student_id].pop(label, None)
refaire_actions, refaire_notes = _scan_annotation_directory( actions_by_student[student_id].extend(
directory, default_student_id=student_id action
for action in redo_actions.get(student_id, [])
if str(action.get("label")) in selected
)
notes_by_student[student_id].update(
{
label: note
for label, note in redo_notes.get(student_id, {}).items()
if label in selected
}
) )
for action in refaire_actions.get(student_id, []):
if str(action.get("label")) in selected_set:
actions_by_student[student_id].append(action)
for label, note in refaire_notes.get(student_id, {}).items():
if label in selected_set:
notes_by_student[student_id][label] = note
status = ( status = (
ExitCode.PARTIAL ExitCode.PARTIAL if loaded.warnings or skipped_students else ExitCode.SUCCESS
if loaded.warnings or refaire_incomplete )
else ExitCode.SUCCESS student_ids = (
list(refaire_by_student)
if refaire
else sorted(loaded.data, key=utils.natural_key)
) )
student_ids = list(refaire_by_student) if refaire else sorted(loaded.data, key=utils.natural_key)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
futures = { futures = {
executor.submit( executor.submit(
@@ -356,9 +553,15 @@ def run(
notes_by_student[student_id], notes_by_student[student_id],
all_labels, all_labels,
update_score=update_score, update_score=update_score,
annotation_dir=annotation_dir,
selected_labels=(
set(refaire_by_student[student_id] or loaded.data[student_id])
if refaire
else None
),
): student_id ): student_id
for student_id in student_ids for student_id in student_ids
if student_id in loaded.data if student_id in loaded.data and student_id not in skipped_students
} }
for future in concurrent.futures.as_completed(futures): for future in concurrent.futures.as_completed(futures):
result, output = future.result() result, output = future.result()
@@ -370,6 +573,12 @@ def run(
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Read grouped annotations and regenerate copies") parser = evaluation_parser("Read grouped annotations and regenerate copies")
parser.add_argument(
"--annotation-dir",
choices=("BGnot", "Bnot", "Anot"),
default="BGnot",
help="Original annotation directory for --refaire (default: BGnot)",
)
parser.add_argument( parser.add_argument(
"--refaire", "--refaire",
action="store_true", action="store_true",
@@ -387,10 +596,13 @@ def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser() parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode: def handle(args: argparse.Namespace) -> ExitCode:
if args.annotation_dir != "BGnot" and not args.refaire:
parser.error("--annotation-dir requires --refaire")
return run( return run(
workspace_from_args(args), workspace_from_args(args),
refaire=args.refaire, refaire=args.refaire,
update_score=args.update_score, update_score=args.update_score,
annotation_dir=args.annotation_dir,
) )
return execute(parser, argv, handle) return execute(parser, argv, handle)
@@ -398,4 +610,3 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
+184 -24
View File
@@ -3,11 +3,12 @@ from __future__ import annotations
import os import os
import queue import queue
import tkinter as tk import tkinter as tk
from dataclasses import replace
from pathlib import Path from pathlib import Path
from tkinter import filedialog, messagebox, ttk from tkinter import filedialog, messagebox, ttk
from typing import Any from typing import Any
from copienator import ExitCode from copienator import EvaluationWorkspace, ExitCode, atomic_write_json
from copienator.platform import ( from copienator.platform import (
WindowsLabelError, WindowsLabelError,
add_platform_executable_paths, add_platform_executable_paths,
@@ -16,6 +17,14 @@ from copienator.platform import (
) )
from .diagnostics import collect_diagnostics from .diagnostics import collect_diagnostics
from .refaire import SECTION as REFAIRE_SECTION
from .refaire import (
RefaireSelection,
available_copies,
load_selection,
resolve_layout,
validate_selection,
)
from .runner import ProcessRunner from .runner import ProcessRunner
from .state import StateStore from .state import StateStore
from .workflow import ( from .workflow import (
@@ -154,6 +163,9 @@ class CopienatorApp(tk.Tk):
self.arg_vars: dict[str, tk.Variable] = {} self.arg_vars: dict[str, tk.Variable] = {}
self.copy_paths: dict[str, Path] = {} self.copy_paths: dict[str, Path] = {}
self._rendering = False self._rendering = False
self.refaire_panel: RefaireSelection | None = None
self.pending_refaire_commands: list[list[str]] = []
self.refaire_command_index = 0
self.title("Copienator — assistant de correction") self.title("Copienator — assistant de correction")
self.geometry("1180x820") self.geometry("1180x820")
@@ -245,8 +257,19 @@ class CopienatorApp(tk.Tk):
self.title_label.grid(row=0, column=0, sticky="w") self.title_label.grid(row=0, column=0, sticky="w")
self.description_label = ttk.Label(self.detail, text="", wraplength=680, justify="left") self.description_label = ttk.Label(self.detail, text="", wraplength=680, justify="left")
self.description_label.grid(row=1, column=0, sticky="ew", pady=(5, 8)) self.description_label.grid(row=1, column=0, sticky="ew", pady=(5, 8))
self.form = ttk.Frame(self.detail) form_container = ttk.Frame(self.detail)
self.form.grid(row=2, column=0, sticky="nsew") form_container.grid(row=2, column=0, sticky="nsew")
form_container.columnconfigure(0, weight=1)
form_container.rowconfigure(0, weight=1)
self.form_canvas = tk.Canvas(form_container, highlightthickness=0, width=1, height=1)
self.form_canvas.grid(row=0, column=0, sticky="nsew")
form_scroll = ttk.Scrollbar(form_container, orient="vertical", command=self.form_canvas.yview)
form_scroll.grid(row=0, column=1, sticky="ns")
self.form_canvas.configure(yscrollcommand=form_scroll.set)
self.form = ttk.Frame(self.form_canvas)
form_window = self.form_canvas.create_window((0, 0), window=self.form, anchor="nw")
self.form.bind("<Configure>", lambda _event: self.form_canvas.configure(scrollregion=self.form_canvas.bbox("all")))
self.form_canvas.bind("<Configure>", lambda event: self.form_canvas.itemconfigure(form_window, width=event.width))
self.form.columnconfigure(1, weight=1) self.form.columnconfigure(1, weight=1)
command_box = ttk.LabelFrame(self.detail, text="Commande", padding=6) command_box = ttk.LabelFrame(self.detail, text="Commande", padding=6)
@@ -362,6 +385,9 @@ class CopienatorApp(tk.Tk):
self.proxy_entry.configure(state="normal" if self.use_proxy_var.get() else "disabled") self.proxy_entry.configure(state="normal" if self.use_proxy_var.get() else "disabled")
def _load_evaluation(self) -> None: def _load_evaluation(self) -> None:
if self.active_step_id or self.runner.running:
messagebox.showinfo("Traitement en cours", "Attendez la fin du traitement avant de changer d’évaluation.")
return
evaluation = self.evaluation evaluation = self.evaluation
if not evaluation or not evaluation.is_dir(): if not evaluation or not evaluation.is_dir():
messagebox.showerror("Dossier invalide", "Choisissez un dossier d’évaluation existant.") messagebox.showerror("Dossier invalide", "Choisissez un dossier d’évaluation existant.")
@@ -392,13 +418,14 @@ class CopienatorApp(tk.Tk):
def _populate_tree(self) -> None: def _populate_tree(self) -> None:
selected = self.current_step.id if self.current_step else None selected = self.current_step.id if self.current_step else None
opened = {self.tree.item(item, "text"): self.tree.item(item, "open") for item in self.tree.get_children()}
self.tree.delete(*self.tree.get_children()) self.tree.delete(*self.tree.get_children())
section_items: dict[str, str] = {} section_items: dict[str, str] = {}
for step in self.steps: for step in self.steps:
if step.section not in section_items: if step.section not in section_items:
section_id = f"section:{len(section_items)}" section_id = f"section:{len(section_items)}"
section_items[step.section] = section_id section_items[step.section] = section_id
self.tree.insert("", "end", iid=section_id, text=step.section, values=("",), open=True) self.tree.insert("", "end", iid=section_id, text=step.section, values=("",), open=opened.get(step.section, step.section != REFAIRE_SECTION))
status = self._step_status(step) status = self._step_status(step)
suffix = " (facultative)" if step.optional else "" suffix = " (facultative)" if step.optional else ""
self.tree.insert( self.tree.insert(
@@ -513,9 +540,16 @@ class CopienatorApp(tk.Tk):
if not step: if not step:
return return
self._rendering = True self._rendering = True
redo = step.section == REFAIRE_SECTION
self.console.configure(height=8 if redo else 14)
self.rowconfigure(1, weight=4 if redo else 3)
self.rowconfigure(2, weight=1 if redo else 2)
self.form_canvas.configure(height=(340 if step.id == "refaire_selection" else 260) if redo else 200)
self.form_canvas.yview_moveto(0)
for child in self.form.winfo_children(): for child in self.form.winfo_children():
child.destroy() child.destroy()
self.arg_vars.clear() self.arg_vars.clear()
self.refaire_panel = None
entry = self.state_store.step(step.id) if self.state_store.evaluation else {} entry = self.state_store.step(step.id) if self.state_store.evaluation else {}
saved_variant = entry.get("variant", step.variants[0].id) saved_variant = entry.get("variant", step.variants[0].id)
if saved_variant not in {variant.id for variant in step.variants}: if saved_variant not in {variant.id for variant in step.variants}:
@@ -544,6 +578,8 @@ class CopienatorApp(tk.Tk):
variant = self._current_variant() variant = self._current_variant()
evaluation_arg = self._evaluation_arg() evaluation_arg = self._evaluation_arg()
for spec in step.arguments: for spec in step.arguments:
if step.section == REFAIRE_SECTION:
continue
if spec.variants and variant.id not in spec.variants: if spec.variants and variant.id not in spec.variants:
continue continue
value = values.get(spec.name, value_for_default(spec.default, evaluation_arg)) value = values.get(spec.name, value_for_default(spec.default, evaluation_arg))
@@ -582,27 +618,44 @@ class CopienatorApp(tk.Tk):
row += 1 row += 1
row += 1 row += 1
if not step.is_manual: if not step.is_manual and step.section != REFAIRE_SECTION:
ttk.Label(self.form, text="Arguments supplémentaires").grid( ttk.Label(self.form, text="Arguments supplémentaires").grid(
row=row, column=0, sticky="w", pady=(10, 4), padx=(0, 8) row=row, column=0, sticky="w", pady=(10, 4), padx=(0, 8)
) )
ttk.Entry(self.form, textvariable=self.extra_var).grid(row=row, column=1, sticky="ew", pady=(10, 4)) ttk.Entry(self.form, textvariable=self.extra_var).grid(row=row, column=1, sticky="ew", pady=(10, 4))
self.run_button.configure(text="Marquer terminée" if step.is_manual else "Exécuter") self.run_button.configure(text="Enregistrer la sélection" if step.id == "refaire_selection" else ("Marquer terminée" if step.is_manual else "Exécuter"))
self.skip_button.configure(state="normal" if step.optional else "disabled") self.skip_button.configure(state="normal" if step.optional else "disabled")
self._rendering = False self._rendering = False
self._update_command_preview() self._update_command_preview()
self._update_controls() self._update_controls()
def _render_context_controls(self, step: StepDefinition, row: int) -> int: def _render_context_controls(self, step: StepDefinition, row: int) -> int:
if step.id == "review_persp": if step.section == REFAIRE_SECTION:
ttk.Button( evaluation = self.evaluation
self.form, if step.id in {"refaire_selection", "refaire_correct"}:
text="Ouvrir le dossier Persp", row = self._correction_folder_buttons(row)
command=self._open_persp, if step.id == "refaire_selection" and evaluation:
).grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 8)) choices, preferred = self._annotation_directory_choices("export")
row += 1 draft = self.state_store.step(step.id).get("values", {})
self.refaire_panel = RefaireSelection(self.form, evaluation, draft, choices, preferred)
self.refaire_panel.grid(row=row, column=0, columnspan=2, sticky="nsew")
else:
try:
entries = self._refaire_selection()
summary = f"{len(entries)} copie(s) sélectionnée(s) :\n" + " ; ".join(f"{name} : {', '.join(labels) or 'toute la copie'}" for name, labels in entries[:5])
if len(entries) > 5:
summary += f" ; et {len(entries) - 5} autres (voir la sélection)."
source = self.state_store.step("refaire_selection").get("values", {}).get("annotation_dir", "")
choice = self.state_store.step("refaire_selection").get("values", {}).get("layout", "auto")
layout = resolve_layout(entries, EvaluationWorkspace(evaluation).read_labels(), choice)
summary += f"\nPassage principal : {source} — PDF à vérifier : {'par question' if layout == 'grouped' else 'par copie'}"
except (OSError, ValueError, TypeError) as exc:
summary = str(exc)
ttk.Label(self.form, text=summary, wraplength=600, justify="left").grid(row=row, column=0, columnspan=2, sticky="w")
return row + 1
if step.id in {"review_persp", "correction"}:
row = self._correction_folder_buttons(row)
if step.section == "Prétraitement des copies": if step.section == "Prétraitement des copies":
evaluation = self.evaluation evaluation = self.evaluation
paths = copy_pdf_paths(evaluation) if evaluation else [] paths = copy_pdf_paths(evaluation) if evaluation else []
@@ -646,6 +699,14 @@ class CopienatorApp(tk.Tk):
row += 1 row += 1
return row return row
def _correction_folder_buttons(self, row: int) -> int:
buttons = ttk.Frame(self.form)
buttons.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 6))
for directory, label in (("Sol", "Corrigés (Sol)"), ("Persp", "Consignes de notation (Persp)")):
ttk.Button(buttons, text=label,
command=lambda name=directory: self._open_desktop_path(self.evaluation / name if self.evaluation else None, f"dossier {name}")).pack(side="left", padx=(0, 6))
return row + 1
def _annotation_directory_choices(self, step_id: str) -> tuple[tuple[str, ...], str]: def _annotation_directory_choices(self, step_id: str) -> tuple[tuple[str, ...], str]:
evaluation = self.evaluation evaluation = self.evaluation
detected = detected_annotation_directories(evaluation) if evaluation else () detected = detected_annotation_directories(evaluation) if evaluation else ()
@@ -696,6 +757,10 @@ class CopienatorApp(tk.Tk):
return next((variant for variant in self.current_step.variants if variant.id == selected), self.current_step.variants[0]) return next((variant for variant in self.current_step.variants if variant.id == selected), self.current_step.variants[0])
def _values(self) -> dict[str, object]: def _values(self) -> dict[str, object]:
if self.current_step and self.current_step.section == REFAIRE_SECTION:
if self.current_step.id == "refaire_selection" and self.refaire_panel:
return self.refaire_panel.values()
return {"target": self._evaluation_arg(), "annotation_dir": self.state_store.step("refaire_selection").get("values", {}).get("annotation_dir", "BGnot")}
return {name: variable.get() for name, variable in self.arg_vars.items()} return {name: variable.get() for name, variable in self.arg_vars.items()}
def _save_current_form(self) -> None: def _save_current_form(self) -> None:
@@ -715,9 +780,62 @@ class CopienatorApp(tk.Tk):
evaluation = self.evaluation evaluation = self.evaluation
return evaluation_argument(self.repository, evaluation) if evaluation else "<évaluation>" return evaluation_argument(self.repository, evaluation) if evaluation else "<évaluation>"
def _progression_ids(self, step_id: str) -> list[str]:
redo = step_id.startswith("refaire_")
return [step.id for step in self.steps if step.id.startswith("refaire_") == redo]
def _refaire_selection(self) -> list[list]:
evaluation = self.evaluation
if not evaluation or not self.state_store.evaluation:
raise ValueError("Chargez dabord une évaluation.")
entry = self.state_store.step("refaire_selection")
if entry.get("status") != "success":
raise ValueError("Enregistrez dabord les copies et les questions à refaire.")
values = entry.get("values", {})
selection = load_selection(evaluation)
if selection != validate_selection(values.get("selection"), available_copies(evaluation), EvaluationWorkspace(evaluation).read_labels()):
raise ValueError("La sélection a changé. Enregistrez-la avant de continuer.")
source = values.get("annotation_dir")
if source not in ANNOTATION_DIRECTORIES or not (evaluation / source).is_dir():
raise ValueError("Choisissez un dossier existant pour le passage principal.")
return selection
def _save_refaire_selection(self) -> None:
if not self.refaire_panel or not self.evaluation:
return
try:
values = self.refaire_panel.values()
entries = validate_selection(values["selection"], available_copies(self.evaluation), EvaluationWorkspace(self.evaluation).read_labels())
source = values["annotation_dir"]
if source not in detected_annotation_directories(self.evaluation):
raise ValueError("Choisissez un dossier existant pour le passage principal.")
atomic_write_json(self.evaluation / "refaire.json", entries)
self._mark_step("success")
self._move_selection_from("refaire_selection", 1)
except (OSError, ValueError, TypeError) as exc:
messagebox.showerror("Sélection à vérifier", str(exc))
def _refaire_commands(self) -> list[list[str]]:
assert self.current_step is not None
selection = self._refaire_selection()
values = self._values()
targets = [self._evaluation_arg()]
if self.current_step.id in {"refaire_review", "refaire_split"}:
copies = available_copies(self.evaluation)
targets = [str(copies[name]) for name, _labels in selection]
variant = self._current_variant()
if self.current_step.id == "refaire_annotate":
choice = self.state_store.step("refaire_selection").get("values", {}).get("layout", "auto")
layout = resolve_layout(selection, EvaluationWorkspace(self.evaluation).read_labels(), choice)
variant = replace(variant, program="annotate-grouped" if layout == "grouped" else "annotate-checks")
return [build_command(self.repository, self.current_step, variant,
{**values, "target": target}, self._evaluation_arg()) for target in targets]
def _make_command(self) -> list[str]: def _make_command(self) -> list[str]:
if not self.current_step: if not self.current_step:
return [] return []
if self.current_step.section == REFAIRE_SECTION:
return self._refaire_commands()[0]
return build_command( return build_command(
self.repository, self.repository,
self.current_step, self.current_step,
@@ -731,11 +849,15 @@ class CopienatorApp(tk.Tk):
if self._rendering or not self.current_step: if self._rendering or not self.current_step:
return return
if self.current_step.is_manual: if self.current_step.is_manual:
self.command_var.set("Étape manuelle — aucune commande ne sera exécutée.") self.command_var.set("La sélection sera utilisée pour toutes les étapes de ce parcours." if self.current_step.id == "refaire_selection" else "Étape manuelle — aucune commande ne sera exécutée.")
return return
try: try:
self.command_var.set(command_display(self._make_command())) commands = self._refaire_commands() if self.current_step.section == REFAIRE_SECTION else [self._make_command()]
except ValueError as exc: preview = "\n".join(command_display(command) for command in commands[:2])
if len(commands) > 2:
preview += f"\nPuis {len(commands) - 2} autres copies, successivement."
self.command_var.set(preview)
except (OSError, ValueError, TypeError) as exc:
self.command_var.set(f"Arguments invalides : {exc}") self.command_var.set(f"Arguments invalides : {exc}")
def _browse_target_file(self, variable: tk.Variable) -> None: def _browse_target_file(self, variable: tk.Variable) -> None:
@@ -764,7 +886,7 @@ class CopienatorApp(tk.Tk):
return False return False
try: try:
self._make_command() self._make_command()
except ValueError as exc: except (OSError, ValueError, TypeError) as exc:
messagebox.showerror("Arguments invalides", str(exc)) messagebox.showerror("Arguments invalides", str(exc))
return False return False
return True return True
@@ -775,11 +897,22 @@ class CopienatorApp(tk.Tk):
if not step or not evaluation or not self.state_store.evaluation: if not step or not evaluation or not self.state_store.evaluation:
messagebox.showerror("Évaluation absente", "Chargez dabord un dossier d’évaluation.") messagebox.showerror("Évaluation absente", "Chargez dabord un dossier d’évaluation.")
return return
if self.runner.running: if self.active_step_id or self.runner.running:
messagebox.showwarning("Traitement en cours", "Interrompez le traitement actuel avant den lancer un autre.") messagebox.showwarning("Traitement en cours", "Interrompez le traitement actuel avant den lancer un autre.")
return return
if step.id == "refaire_selection":
self._save_refaire_selection()
return
if step.section == REFAIRE_SECTION:
try:
self._refaire_selection()
except (OSError, ValueError, TypeError) as exc:
messagebox.showerror("Sélection à vérifier", str(exc))
return
if step.is_manual: if step.is_manual:
self._mark_step("success") self._mark_step("success")
if step.section == REFAIRE_SECTION:
self._move_selection_from(step.id, 1)
return return
if os.name == "nt" and step.id != "statement": if os.name == "nt" and step.id != "statement":
labels_path = evaluation / "labels" labels_path = evaluation / "labels"
@@ -816,7 +949,7 @@ class CopienatorApp(tk.Tk):
self._save_current_form() self._save_current_form()
run_values = self._values() run_values = self._values()
ordered_ids = [item.id for item in self.steps] ordered_ids = self._progression_ids(step.id)
self.state_store.invalidate_after(ordered_ids, step.id) self.state_store.invalidate_after(ordered_ids, step.id)
self.state_store.update_step( self.state_store.update_step(
step.id, step.id,
@@ -837,6 +970,8 @@ class CopienatorApp(tk.Tk):
self.use_proxy_var.get(), self.use_proxy_var.get(),
) )
self.pending_refaire_commands = self._refaire_commands()[1:] if step.section == REFAIRE_SECTION else []
self.refaire_command_index = 1
self._append_console(f"\n$ {command_display(command)}\n") self._append_console(f"\n$ {command_display(command)}\n")
try: try:
self.runner.start(command, self.repository, environment, log_path) self.runner.start(command, self.repository, environment, log_path)
@@ -846,6 +981,7 @@ class CopienatorApp(tk.Tk):
{"step": step.id, "command": command_display(command), "status": "failed", "error": str(exc)} {"step": step.id, "command": command_display(command), "status": "failed", "error": str(exc)}
) )
self.active_step_id = None self.active_step_id = None
self.pending_refaire_commands = []
self._append_console(f"Impossible de lancer la commande : {exc}\n") self._append_console(f"Impossible de lancer la commande : {exc}\n")
messagebox.showerror("Échec du lancement", str(exc)) messagebox.showerror("Échec du lancement", str(exc))
self._populate_tree() self._populate_tree()
@@ -854,10 +990,10 @@ class CopienatorApp(tk.Tk):
def _mark_step( def _mark_step(
self, status: str, *, automatic: bool = False, reason: str | None = None self, status: str, *, automatic: bool = False, reason: str | None = None
) -> None: ) -> None:
if not self.current_step or not self.state_store.evaluation: if not self.current_step or not self.state_store.evaluation or self.active_step_id:
return return
self._save_current_form() self._save_current_form()
self.state_store.invalidate_after([item.id for item in self.steps], self.current_step.id) self.state_store.invalidate_after(self._progression_ids(self.current_step.id), self.current_step.id)
self.state_store.update_step(self.current_step.id, status=status) self.state_store.update_step(self.current_step.id, status=status)
history: dict[str, object] = { history: dict[str, object] = {
"step": self.current_step.id, "step": self.current_step.id,
@@ -875,7 +1011,10 @@ class CopienatorApp(tk.Tk):
def _skip_step(self) -> None: def _skip_step(self) -> None:
if self.current_step and self.current_step.optional: if self.current_step and self.current_step.optional:
step_id = self.current_step.id
self._mark_step("skipped") self._mark_step("skipped")
if step_id.startswith("refaire_"):
self._move_selection_from(step_id, 1)
def _poll_runner(self) -> None: def _poll_runner(self) -> None:
while True: while True:
@@ -899,6 +1038,26 @@ class CopienatorApp(tk.Tk):
if not step_id: if not step_id:
return return
status = process_status(return_code, interrupted) status = process_status(return_code, interrupted)
if status == "success" and self.pending_refaire_commands:
command = self.pending_refaire_commands.pop(0)
self.refaire_command_index += 1
self._append_console(f"\n$ {command_display(command)}\n")
self.state_store.update_step(step_id, command=command_display(command))
try:
self.runner.start(command, self.repository,
build_runner_environment(os.environ, self.api_key_var.get(), self.proxy_var.get(), self.use_proxy_var.get()),
self.state_store.workspace.log_path(f"{step_id}_{self.refaire_command_index}"))
self._update_controls()
return
except (OSError, RuntimeError) as exc:
self._append_console(f"Impossible de lancer la copie suivante : {exc}\n")
status, return_code = "failed", 1
self.pending_refaire_commands = []
if step_id == "refaire_merge" and status == "success":
for downstream in ("giving_names", "update_ods", "final_score", "personal_deploy", "personal_sent"):
entry = self.state_store.step(downstream)
if entry.get("status") in {"success", "detected", "skipped"}:
self.state_store.update_step(downstream, status="stale")
if step_id == "clean" and status == "success": if step_id == "clean" and status == "success":
self._append_console( self._append_console(
f"\n[Terminé — code {return_code}{STATUS_LABELS[status]}]\n" f"\n[Terminé — code {return_code}{STATUS_LABELS[status]}]\n"
@@ -977,8 +1136,9 @@ class CopienatorApp(tk.Tk):
self.console.configure(state="disabled") self.console.configure(state="disabled")
def _update_controls(self) -> None: def _update_controls(self) -> None:
running = self.runner.running running = bool(self.active_step_id) or self.runner.running
self.run_button.configure(state="disabled" if running or not self.current_step else "normal") self.run_button.configure(state="disabled" if running or not self.current_step else "normal")
self.skip_button.configure(state="normal" if not running and self.current_step and self.current_step.optional else "disabled")
self.interrupt_button.configure(state="normal" if running else "disabled") self.interrupt_button.configure(state="normal" if running else "disabled")
self.force_button.configure(state="normal" if running else "disabled") self.force_button.configure(state="normal" if running else "disabled")
self.send_button.configure(state="normal" if running else "disabled") self.send_button.configure(state="normal" if running else "disabled")
@@ -990,7 +1150,7 @@ class CopienatorApp(tk.Tk):
self._move_selection_from(self.current_step.id, delta) self._move_selection_from(self.current_step.id, delta)
def _move_selection_from(self, step_id: str, delta: int) -> None: def _move_selection_from(self, step_id: str, delta: int) -> None:
ids = [step.id for step in self.steps] ids = self._progression_ids(step_id)
try: try:
index = ids.index(step_id) index = ids.index(step_id)
except ValueError: except ValueError:
+244
View File
@@ -0,0 +1,244 @@
"""Selection and command planning for the optional redo workflow."""
from __future__ import annotations
import re
import tkinter as tk
from pathlib import Path
from tkinter import ttk
from copienator import EvaluationWorkspace, read_json
from copienator.utils import natural_key
SECTION = "Refaire des copies (facultatif)"
ALL_LABELS = "Toute la copie"
ALL_COPIES = "Toutes les copies"
LAYOUTS = {
"Automatique": "auto",
"Par question (groupé)": "grouped",
"Par copie": "copies",
}
def resolve_layout(
selection: list[list], labels: list[str], choice: str = "auto"
) -> str:
if choice in {"grouped", "copies"}:
return choice
seen = set()
for _name, selected in selection:
current = set(selected or labels)
if seen & current:
return "grouped"
seen.update(current)
return "copies"
def copies_with_answer(copies: dict[str, Path], label: str) -> list[str]:
return [
name
for name, path in copies.items()
if any(
(path.with_suffix("") / f"{label}{suffix}.pdf").is_file()
for suffix in ("", "_new")
)
]
def available_copies(evaluation: Path) -> dict[str, Path]:
return {
path.stem: path
for path in sorted((evaluation / "Copies").glob("Copie*.pdf"), key=natural_key)
if re.fullmatch(r"Copie\d+", path.stem)
}
def validate_selection(
entries: object, copies: dict[str, Path], labels: list[str]
) -> list[list]:
if not isinstance(entries, list) or not entries:
raise ValueError("Ajoutez au moins une copie à refaire.")
result = {}
for entry in entries:
if not isinstance(entry, list) or len(entry) != 2:
raise ValueError("Sélection de copies invalide.")
name, selected = entry
if not isinstance(name, str) or name not in copies:
raise ValueError(f"Copie introuvable : {name}")
if not isinstance(selected, list) or any(
not isinstance(label, str) or label not in labels for label in selected
):
raise ValueError(f"Question inconnue pour {name}. Reprenez la sélection.")
if name in result:
raise ValueError(f"Copie sélectionnée plusieurs fois : {name}")
result[name] = sorted(set(selected), key=natural_key)
return [[name, result[name]] for name in sorted(result, key=natural_key)]
def load_selection(evaluation: Path) -> list[list]:
return validate_selection(
read_json(evaluation / "refaire.json"),
available_copies(evaluation),
EvaluationWorkspace(evaluation).read_labels(),
)
class RefaireSelection(ttk.Frame):
def __init__(
self,
parent,
evaluation: Path,
draft: dict,
directories: tuple[str, ...],
preferred: str,
):
super().__init__(parent)
self.columnconfigure(1, weight=1)
self.copies = available_copies(evaluation)
self.labels = (
EvaluationWorkspace(evaluation).read_labels()
if (evaluation / "labels").is_file()
else []
)
self.entries = {}
self.error = ""
try:
entries = draft.get("selection")
if entries is None:
entries = read_json(evaluation / "refaire.json", default=[])
if entries:
self.entries = dict(
validate_selection(entries, self.copies, self.labels)
)
except (OSError, ValueError, TypeError) as exc:
self.error = str(exc)
self.copy_var = tk.StringVar(value=next(iter(self.copies), ""))
self.label_var = tk.StringVar(value=ALL_LABELS)
source = draft.get("annotation_dir", preferred)
self.source_var = tk.StringVar(
value=source if source in directories else preferred
)
ttk.Label(self, text="Copie").grid(row=0, column=0, sticky="w", padx=(0, 8))
ttk.Combobox(
self,
textvariable=self.copy_var,
values=(ALL_COPIES, *self.copies),
state="readonly",
width=12,
).grid(row=0, column=1, sticky="ew")
ttk.Label(self, text="Question").grid(row=1, column=0, sticky="w", pady=5)
ttk.Combobox(
self,
textvariable=self.label_var,
values=(ALL_LABELS, *self.labels),
state="readonly",
width=12,
).grid(row=1, column=1, sticky="ew", pady=5)
ttk.Button(self, text="+ Ajouter", command=self.add).grid(
row=1, column=2, padx=(8, 0)
)
self.table = ttk.Treeview(
self, columns=("labels",), height=4, selectmode="browse"
)
self.table.heading("#0", text="Copie")
self.table.heading("labels", text="Questions à refaire")
self.table.column("#0", width=100, stretch=False)
self.table.column("labels", width=330)
self.table.grid(row=4, column=0, columnspan=3, sticky="nsew")
scrollbar = ttk.Scrollbar(self, orient="vertical", command=self.table.yview)
scrollbar.grid(row=4, column=3, sticky="ns")
self.table.configure(yscrollcommand=scrollbar.set)
ttk.Button(
self, text="Retirer la copie sélectionnée", command=self.remove
).grid(row=5, column=0, columnspan=3, sticky="w", pady=5)
ttk.Label(self, text="Passage principal").grid(
row=3, column=0, sticky="w", padx=(0, 8)
)
ttk.Combobox(
self,
textvariable=self.source_var,
values=directories,
state="readonly",
width=12,
).grid(row=3, column=1, sticky="ew")
layout = draft.get("layout", "auto")
self.layout_var = tk.StringVar(
value=next(
(name for name, code in LAYOUTS.items() if code == layout),
"Automatique",
)
)
ttk.Label(self, text="PDF à vérifier").grid(row=2, column=0, sticky="w")
ttk.Combobox(
self,
textvariable=self.layout_var,
values=tuple(LAYOUTS),
state="readonly",
width=12,
).grid(row=2, column=1, sticky="ew")
self.message_var = tk.StringVar(
value=self.error
or "Choisissez « Toutes les copies » pour refaire une question dans toute la classe."
)
help_label = ttk.Label(
self,
textvariable=self.message_var,
wraplength=500,
)
help_label.grid(row=6, column=0, columnspan=3, sticky="w", pady=5)
self.bind(
"<Configure>",
lambda event: help_label.configure(wraplength=max(200, event.width - 10)),
)
self.refresh()
def refresh(self):
self.table.delete(*self.table.get_children())
for name in sorted(self.entries, key=natural_key):
self.table.insert(
"",
"end",
iid=name,
text=name,
values=(", ".join(self.entries[name]) or ALL_LABELS,),
)
def add(self):
name, label = self.copy_var.get(), self.label_var.get()
if name not in (ALL_COPIES, *self.copies) or label not in (
ALL_LABELS,
*self.labels,
):
return
names = (
[name]
if name != ALL_COPIES
else list(self.copies)
if label == ALL_LABELS
else copies_with_answer(self.copies, label)
)
for copy_name in names:
# Adding a question must not narrow a copy already selected in full.
if label == ALL_LABELS:
self.entries[copy_name] = []
elif copy_name not in self.entries or self.entries[copy_name]:
self.entries[copy_name] = sorted(
set(self.entries.get(copy_name, [])) | {label}, key=natural_key
)
message = f"{len(names)} copie(s) ajoutée(s)."
if name == ALL_COPIES and len(names) < len(self.copies):
message += f" {len(self.copies) - len(names)} sans réponse découpée pour cette question."
self.message_var.set(message)
self.refresh()
def remove(self):
for name in self.table.selection():
self.entries.pop(name, None)
self.refresh()
def values(self):
return {
"selection": [[name, labels] for name, labels in self.entries.items()],
"annotation_dir": self.source_var.get(),
"layout": LAYOUTS[self.layout_var.get()],
}
+37
View File
@@ -505,9 +505,46 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
requires=("Copies", "correction.json", "A Rendre"), requires=("Copies", "correction.json", "A Rendre"),
), ),
] ]
steps[-1:-1] = build_refaire_workflow()
return [step for step in steps if show_personal_steps or not step.personal] return [step for step in steps if show_personal_steps or not step.personal]
def build_refaire_workflow() -> list[StepDefinition]:
from .refaire import SECTION
selection = StepDefinition(
"refaire_selection", SECTION, "Choisir les copies et les questions",
"Sélectionnez les copies et les questions à refaire, puis enregistrez la sélection. "
"Le passage principal doit être terminé ; conservez ses annotations.",
(CommandVariant("default", "Sélection", None, "manual"),),
requires=("Copies", "labels", "correction.json"),
)
definitions = [
("review", "Reprendre le découpage", "Vérifiez et ajustez les labels des copies sélectionnées. Chaque copie souvre à son tour. Fermez la fenêtre pour passer à la suivante.", "review-labels", (), True),
("split", "Redécouper les réponses", "À exécuter après une modification du découpage. Traite toutes les copies sélectionnées ; vérifiez les fichiers _new et _old en cas de résolution manuelle.", "split-answers", (), True),
("correct", "Refaire la correction", "Relance la correction des seules questions sélectionnées. Peut être ignorée pour corriger manuellement les résultats.", "correct", ("--refaire",), True),
("annotate", "Préparer les copies à vérifier", "Génère les questions sélectionnées avec des cases dans BRnot, par question ou par copie selon la sélection. Remplace le précédent passage dans BRnot.", "annotate-checks", ("--refaire", "--overwrite"), False),
("export", "Exporter vers la tablette", "Exporte BRnot vers EXPORT_DIR. Retirez les anciens fichiers dexport avant le transfert.", "export", ("--refaire",), True),
("tablet", "Vérifier sur la tablette", "Annotez les PDF exportés, puis placez les retours dans IMPORT_DIR sans changer leur nom (nom de groupe ou Copie01.pdf…). Retournez aussi les PDF sans modification. Retirez les anciens fichiers dimport.", None, (), False),
("import", "Importer les copies vérifiées", "Importe les PDF retournés dans BRnot. Vous pouvez ignorer cette étape si les fichiers Concat_annotated.pdf y sont déjà en place.", "import", ("--refaire",), True),
("merge", "Mettre à jour les copies finales", "Fusionne les questions refaites avec le reste de chaque copie dans le dossier du passage principal. Relancez ensuite la préparation de A Rendre, le calcul des notes et la diffusion.", "read-grouped", ("--refaire",), False),
]
steps = [selection]
for suffix, title, description, program, flags, optional in definitions:
arguments = (arg_target(),) if program else ()
if suffix == "merge":
arguments += (ArgumentSpec("annotation_dir", "Passage principal", "choice", "--annotation-dir", default="BGnot", choices=("BGnot", "Bnot", "Anot")),)
requirements = ("refaire.json", "Copies", "labels", "correction.json")
if suffix in {"export", "tablet", "import", "merge"}:
requirements += ("BRnot",)
steps.append(StepDefinition(
f"refaire_{suffix}", SECTION, title, description,
(CommandVariant("default", title, program, "python" if program else "manual", flags),),
arguments=arguments, optional=optional, requires=requirements,
))
return steps
def value_for_default(value: object, evaluation_arg: str) -> object: def value_for_default(value: object, evaluation_arg: str) -> object:
return evaluation_arg if value == EVALUATION else value return evaluation_arg if value == EVALUATION else value
+206
View File
@@ -0,0 +1,206 @@
from __future__ import annotations
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image, ImageDraw
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, read_json
from copienator.annotation_data import AnnotationLoadResult
from copienator.commands import annotating_by_label as grouped
from copienator.commands import annotating_with_checks as checks
from copienator.commands import export, import_annotations
from copienator.commands import reading_grouped_annotations as reader
class GroupedRedoTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name) / "Exam"
for directory in ("Copies", "Par label", "BGnot", "BRnot"):
(self.root / directory).mkdir(parents=True)
(self.root / "labels").write_text("Ex 1\nEx 2\n")
atomic_write_json(self.root / "correction.json", {})
atomic_write_json(
self.root / "refaire.json", [["Copie01", ["Ex 1"]], ["Copie02", ["Ex 1"]]]
)
(self.root / "BRnot/previous.txt").write_text("previous redo")
(self.root / "BGnot/main.txt").write_text("main run")
self.workspace = EvaluationWorkspace(self.root)
self.data = {"01": {"Ex 1": {}}, "02": {"Ex 1": {}}}
def tearDown(self):
self.temp.cleanup()
@staticmethod
def render(item):
student_id, label, _content = item
image = Image.new("RGB", (100, 100), "white")
ImageDraw.Draw(image).rectangle((10, 10, 50, 50), outline="black", width=2)
return (
student_id,
label,
image,
0,
[
{
"type": "score",
"label": label,
"value": 3,
"final_box": [10, 10, 50, 50],
}
],
)
def generate(self):
with (
patch.object(
grouped,
"load_annotation_data",
return_value=AnnotationLoadResult(self.data, []),
) as load,
patch.object(grouped, "render_item", side_effect=self.render),
patch.object(grouped, "_load_label_groups") as label_groups,
):
self.assertEqual(
grouped.run(self.workspace, refaire=True, overwrite=True),
ExitCode.SUCCESS,
)
self.assertEqual(
load.call_args.kwargs["refaire_list"],
read_json(self.root / "refaire.json"),
)
label_groups.assert_not_called()
directories = list((self.root / "BRnot").iterdir())
self.assertEqual(len(directories), 1)
self.assertTrue(directories[0].is_dir())
self.assertEqual((self.root / "BGnot/main.txt").read_text(), "main run")
return directories[0]
def test_grouped_redo_export_import_and_actual_annotation_detection(self):
directory = self.generate()
metadata = read_json(directory / "bnote.json")["images"]
self.assertEqual(
[(item["id"], item["label"]) for item in metadata],
[("01", "Ex 1"), ("02", "Ex 1")],
)
export_root = Path(self.temp.name) / "Export"
with patch.object(export, "EXPORT_DIR", export_root):
self.assertEqual(export.run(self.workspace, refaire=True), ExitCode.SUCCESS)
self.assertEqual(len(list((export_root / "Exam").glob("*.pdf"))), 1)
imported_root = Path(self.temp.name) / "Import"
imported_root.mkdir()
with Image.open(directory / "Reference.jpg") as reference:
annotated = reference.convert("RGB")
draw = ImageDraw.Draw(annotated)
draw.rectangle((17, 17, 43, 43), fill="black")
draw.rectangle((70, 170, 90, 190), fill="black")
annotated.save(imported_root / f"{directory.name}.pdf", "PDF", resolution=72)
with patch.object(import_annotations, "IMPORT_DIR", imported_root):
self.assertEqual(
import_annotations.run(self.workspace, refaire=True), ExitCode.SUCCESS
)
actions, notes, incomplete = reader._scan_redo_annotations(
self.root / "BRnot", {"01": {"Ex 1"}, "02": {"Ex 1"}}
)
self.assertFalse(incomplete)
self.assertEqual(actions["01"][0]["value"], 3)
self.assertFalse(actions.get("02"))
self.assertIn("Ex 1", notes["02"])
full_data = {student: {"Ex 1": {}, "Ex 2": {}} for student in ("01", "02")}
for mode in ("BGnot", "Bnot", "Anot"):
(self.root / mode).mkdir(exist_ok=True)
with (
self.subTest(mode=mode),
patch.object(
reader,
"load_annotation_data",
return_value=AnnotationLoadResult(full_data, []),
),
patch.object(
reader,
"apply_actions_and_regenerate_grouped",
return_value=(ExitCode.SUCCESS, ""),
) as regenerate,
):
self.assertEqual(
reader.run(self.workspace, refaire=True, annotation_dir=mode),
ExitCode.SUCCESS,
)
calls = {call.args[2]: call for call in regenerate.call_args_list}
self.assertEqual(set(calls), {"01", "02"})
self.assertEqual(set(calls["01"].args[1]["01"]), {"Ex 1", "Ex 2"})
self.assertEqual(calls["01"].args[3][0]["value"], 3)
self.assertIn("Ex 1", calls["02"].args[4])
def test_missing_group_leaves_affected_copy_incomplete(self):
directory = self.generate()
shutil.copy2(directory / "Concat.pdf", directory / "Concat_annotated.pdf")
extra = self.root / "BRnot/Ex 2 G1"
extra.mkdir()
atomic_write_json(
extra / "bnote.json", {"images": [{"id": "01", "label": "Ex 2"}]}
)
_actions, _notes, incomplete = reader._scan_redo_annotations(
self.root / "BRnot", {"01": {"Ex 1", "Ex 2"}, "02": {"Ex 1"}}
)
self.assertEqual(incomplete, {"01"})
def test_failed_generation_preserves_previous_redo_for_both_layouts(self):
for module, worker in ((grouped, "render_item"), (checks, "_render_student")):
with (
self.subTest(module=module),
patch.object(
module,
"load_annotation_data",
return_value=AnnotationLoadResult(self.data, []),
),
patch.object(
module,
worker,
return_value=None if module is grouped else "partial",
),
):
if module is grouped:
status = module.run(self.workspace, refaire=True, overwrite=True)
else:
status = module.run(
self.workspace, self.root, refaire=True, overwrite=True
)
self.assertEqual(status, ExitCode.PARTIAL)
self.assertEqual(
(self.root / "BRnot/previous.txt").read_text(), "previous redo"
)
def test_switching_to_per_copy_removes_previous_group_layout(self):
self.generate()
def render(_workspace, student_id, _labels, **kwargs):
output = kwargs["output_root"] / f"Copie{student_id}"
output.mkdir()
(output / "Concat.pdf").touch()
return "success"
with (
patch.object(
checks,
"load_annotation_data",
return_value=AnnotationLoadResult(self.data, []),
),
patch.object(checks, "_render_student", side_effect=render),
):
self.assertEqual(
checks.run(self.workspace, self.root, refaire=True, overwrite=True),
ExitCode.SUCCESS,
)
self.assertEqual(
{path.name for path in (self.root / "BRnot").iterdir()},
{"Copie01", "Copie02"},
)
if __name__ == "__main__":
unittest.main()
+265
View File
@@ -0,0 +1,265 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from copienator import atomic_write_json, read_json
from copienator_gui.app import CopienatorApp
from copienator_gui.refaire import (
ALL_COPIES,
SECTION,
available_copies,
resolve_layout,
validate_selection,
)
from copienator_gui.workflow import build_command, build_workflow
class SelectionTests(unittest.TestCase):
def test_selection_validates_and_deduplicates_labels(self):
copies = {"Copie01": Path("/tmp/Copie01.pdf")}
self.assertEqual(
validate_selection(
[["Copie01", ["Ex 2", "Ex 1", "Ex 2"]]], copies, ["Ex 1", "Ex 2"]
),
[["Copie01", ["Ex 1", "Ex 2"]]],
)
for invalid in (
[],
[["missing", []]],
[["Copie01", ["unknown"]]],
[["../Copie01", []]],
):
with self.subTest(invalid=invalid), self.assertRaises(ValueError):
validate_selection(invalid, copies, ["Ex 1"])
def test_automatic_layout_groups_shared_labels_and_honors_explicit_choice(self):
selection = [["Copie01", ["Ex 1"]], ["Copie02", ["Ex 1"]]]
self.assertEqual(resolve_layout(selection, ["Ex 1", "Ex 2"]), "grouped")
self.assertEqual(resolve_layout(selection, ["Ex 1"], "copies"), "copies")
self.assertEqual(resolve_layout([["Copie01", ["Ex 1"]]], ["Ex 1"]), "copies")
self.assertEqual(
resolve_layout([["Copie01", ["Ex 1"]], ["Copie02", []]], ["Ex 1"]),
"grouped",
)
def test_redo_steps_are_in_both_profiles_and_never_autostart(self):
for personal in (False, True):
steps = [
step for step in build_workflow(personal) if step.section == SECTION
]
self.assertEqual(len(steps), 9)
self.assertEqual(steps[0].id, "refaire_selection")
self.assertTrue(all(not step.auto_start_first_visit for step in steps))
merge = steps[-1]
for mode in ("BGnot", "Bnot", "Anot"):
command = build_command(
Path.cwd(),
merge,
merge.variants[0],
{"target": "/tmp/Exam", "annotation_dir": mode},
"/tmp/Exam",
)
self.assertEqual(
command[4:],
[
"read-grouped",
"/tmp/Exam",
"--refaire",
"--annotation-dir",
mode,
],
)
@unittest.skipUnless(
os.environ.get("DISPLAY"), "Tk tests require a display (use xvfb-run)"
)
class RefaireGuiTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
(self.root / "Copies").mkdir()
(self.root / "Anot").mkdir()
(self.root / "Par label").mkdir()
(self.root / "BRnot").mkdir()
for name in ("Copie01", "Copie02"):
(self.root / "Copies" / f"{name}.pdf").touch()
(self.root / "labels").write_text("Ex 1\nEx 2\n", encoding="utf-8")
atomic_write_json(self.root / "correction.json", {})
self.app = CopienatorApp(Path.cwd(), False, self.root)
self.app.update()
def tearDown(self):
for callback in self.app.tk.splitlist(self.app.tk.call("after", "info")):
self.app.after_cancel(callback)
self.app.destroy()
self.temp.cleanup()
def select(self, ident):
self.app.tree.selection_set(ident)
self.app.tree.see(ident)
self.app.update()
def save_selection(self):
self.select("refaire_selection")
panel = self.app.refaire_panel
panel.label_var.set("Ex 1")
panel.add()
panel.label_var.set("Ex 2")
panel.add()
panel.copy_var.set("Copie02")
panel.label_var.set("Toute la copie")
panel.add()
self.app._run_current_step()
self.app.update()
def test_collapsed_branch_is_separate_and_stays_open_when_refreshed(self):
section = self.app.tree.parent("refaire_selection")
self.assertFalse(self.app.tree.item(section, "open"))
self.assertNotIn("refaire_selection", self.app._progression_ids("giving_names"))
self.assertNotIn("clean", self.app._progression_ids("refaire_merge"))
self.select("refaire_selection")
self.app._populate_tree()
self.assertTrue(self.app.tree.item(section, "open"))
def test_dropdown_selection_saves_json_and_drives_all_commands(self):
self.save_selection()
self.assertEqual(
read_json(self.root / "refaire.json"),
[["Copie01", ["Ex 1", "Ex 2"]], ["Copie02", []]],
)
self.assertEqual(self.app.current_step.id, "refaire_review")
commands = self.app._refaire_commands()
self.assertEqual(
[command[5] for command in commands],
[str(path) for path in available_copies(self.root).values()],
)
self.select("refaire_annotate")
self.assertEqual(
self.app._make_command()[4:],
["annotate-grouped", str(self.root), "--refaire", "--overwrite"],
)
self.select("refaire_merge")
self.assertEqual(
self.app._make_command()[4:],
["read-grouped", str(self.root), "--refaire", "--annotation-dir", "Anot"],
)
self.assertEqual(self.app.state_store.step("annotation").get("status"), None)
def test_one_label_for_all_copies_and_layout_override(self):
for name in ("Copie01", "Copie02"):
directory = self.root / "Copies" / name
directory.mkdir()
(directory / "Ex 1.pdf").touch()
self.select("refaire_selection")
panel = self.app.refaire_panel
panel.copy_var.set(ALL_COPIES)
panel.label_var.set("Ex 1")
panel.add()
self.assertEqual(panel.entries, {"Copie01": ["Ex 1"], "Copie02": ["Ex 1"]})
panel.layout_var.set("Par copie")
self.app._run_current_step()
self.app.update()
self.select("refaire_annotate")
self.assertEqual(self.app._make_command()[4], "annotate-checks")
def test_bulk_add_skips_absent_answers_and_preserves_whole_copy_selection(self):
directory = self.root / "Copies/Copie01"
directory.mkdir()
(directory / "Ex 1_new.pdf").touch()
self.select("refaire_selection")
panel = self.app.refaire_panel
panel.copy_var.set("Copie01")
panel.add()
panel.copy_var.set(ALL_COPIES)
panel.label_var.set("Ex 1")
panel.add()
self.assertEqual(panel.entries, {"Copie01": []})
self.assertIn("1 sans réponse", panel.message_var.get())
def test_correction_folder_buttons_open_expected_folders(self):
for name in ("Sol", "Persp"):
(self.root / name).mkdir()
self.select("refaire_selection")
buttons = self.app.form.winfo_children()[0].winfo_children()
with patch("copienator_gui.app.open_path") as opened:
for button in buttons:
button.invoke()
self.assertEqual(
[call.args[0] for call in opened.call_args_list],
[self.root / "Sol", self.root / "Persp"],
)
def test_small_window_keeps_selection_accessible_by_scrolling(self):
self.select("refaire_selection")
self.app.geometry("900x640")
self.app.update()
self.app.form_canvas.yview_moveto(1)
self.app.update()
self.assertAlmostEqual(self.app.form_canvas.yview()[1], 1.0)
self.assertLess(
self.app.run_button.winfo_rooty() + self.app.run_button.winfo_height(),
self.app.winfo_rooty() + self.app.winfo_height(),
)
def test_queue_runs_copies_in_order_and_stops_on_failure(self):
self.save_selection()
self.select("refaire_split")
with patch.object(self.app.runner, "start") as start:
self.app._run_current_step()
self.assertEqual(start.call_count, 1)
self.assertEqual(len(self.app.pending_refaire_commands), 1)
self.app._finish_process(0, False)
self.assertEqual(start.call_count, 2)
self.assertEqual(self.app.active_step_id, "refaire_split")
self.app._finish_process(1, False)
self.assertEqual(
self.app.state_store.step("refaire_split")["status"], "failed"
)
self.assertIsNone(self.app.active_step_id)
self.assertFalse(self.app.pending_refaire_commands)
def test_interruption_does_not_launch_next_copy(self):
self.save_selection()
self.select("refaire_split")
with patch.object(self.app.runner, "start") as start:
self.app._run_current_step()
self.app._finish_process(130, True)
self.assertEqual(start.call_count, 1)
self.assertFalse(self.app.pending_refaire_commands)
self.assertEqual(
self.app.state_store.step("refaire_split")["status"], "interrupted"
)
def test_unsaved_or_external_changes_block_commands(self):
self.save_selection()
atomic_write_json(self.root / "refaire.json", [["Copie02", []]])
with self.assertRaisesRegex(ValueError, "sélection a changé"):
self.app._refaire_commands()
def test_selection_reload_and_finalization_invalidation(self):
self.save_selection()
self.app.state_store.update_step("giving_names", status="success")
self.app.state_store.update_step(
"annotation", status="success", last_run_variant="simple"
)
self.select("refaire_merge")
with patch.object(self.app.runner, "start"):
self.app._run_current_step()
self.app._finish_process(0, False)
self.app.update()
self.assertEqual(self.app.state_store.step("giving_names")["status"], "stale")
self.assertEqual(self.app.state_store.step("annotation")["status"], "success")
self.assertEqual(self.app.current_step.id, "refaire_merge")
self.select("refaire_selection")
self.assertEqual(
self.app.refaire_panel.entries, {"Copie01": ["Ex 1", "Ex 2"], "Copie02": []}
)
if __name__ == "__main__":
unittest.main()
+280
View File
@@ -0,0 +1,280 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, read_json
from copienator.annotation_data import AnnotationLoadResult, _coordinate_index
from copienator.commands import reading_grouped_annotations as reader
class RefaireTests(unittest.TestCase):
def workspace(self, root, mode):
for name in ("Copies", "Par label", mode, "BRnot/Copie01"):
(root / name).mkdir(parents=True)
(root / "labels").write_text("Ex 1\nEx 2\n")
atomic_write_json(root / "correction.json", {})
atomic_write_json(root / "refaire.json", [["Copie01", ["Ex 2"]]])
for name in (
"bnote.json",
"checkboxes.json",
"Reference.jpg",
"Concat_annotated.pdf",
):
(root / "BRnot/Copie01" / name).touch()
atomic_write_json(
root / "BRnot/Copie01/bnote.json", {"images": [{"label": "Ex 2"}]}
)
return EvaluationWorkspace(root)
def test_reader_merges_full_copy_for_every_original_mode(self):
for mode in ("BGnot", "Bnot", "Anot"):
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp:
workspace = self.workspace(Path(tmp), mode)
data = {"01": {"Ex 1": {}, "Ex 2": {}}, "02": {"Ex 1": {}}}
def load(_workspace, data=data, **kwargs):
return AnnotationLoadResult(
{"01": {"Ex 2": {}}} if kwargs else data, []
)
with (
patch.object(reader, "load_annotation_data", side_effect=load),
patch.object(
reader,
"_scan_annotation_directory",
return_value=(
{"01": [{"label": "Ex 2", "type": "score", "value": 3}]},
{},
),
),
patch.object(
reader,
"apply_actions_and_regenerate_grouped",
return_value=(ExitCode.SUCCESS, ""),
) as render,
):
self.assertEqual(
reader.run(workspace, refaire=True, annotation_dir=mode),
ExitCode.SUCCESS,
)
render.assert_called_once()
self.assertEqual(set(render.call_args.args[1]["01"]), {"Ex 1", "Ex 2"})
self.assertEqual(render.call_args.args[2], "01")
self.assertEqual(render.call_args.kwargs["selected_labels"], {"Ex 2"})
self.assertEqual(render.call_args.kwargs["annotation_dir"], mode)
def test_whole_copy_selection_replaces_all_old_actions(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
workspace = self.workspace(root, "BGnot")
(root / "BGnot/Ex 1").mkdir()
atomic_write_json(root / "refaire.json", [["Copie01", []]])
atomic_write_json(
root / "BRnot/Copie01/bnote.json",
{"images": [{"label": label} for label in ("Ex 1", "Ex 2")]},
)
loaded = AnnotationLoadResult({"01": {"Ex 1": {}, "Ex 2": {}}}, [])
def scan(directory, *args, **kwargs):
return (
({"01": [{"label": "Ex 1", "type": "score", "value": 1}]}, {})
if directory.parent.name == "BGnot"
else ({"01": [{"label": "Ex 2", "type": "score", "value": 4}]}, {})
)
with (
patch.object(reader, "load_annotation_data", return_value=loaded),
patch.object(reader, "_scan_annotation_directory", side_effect=scan),
patch.object(
reader,
"apply_actions_and_regenerate_grouped",
return_value=(ExitCode.SUCCESS, ""),
) as render,
):
self.assertEqual(reader.run(workspace, refaire=True), ExitCode.SUCCESS)
self.assertEqual(
render.call_args.kwargs["selected_labels"], {"Ex 1", "Ex 2"}
)
self.assertEqual(
render.call_args.args[3],
[{"label": "Ex 2", "type": "score", "value": 4}],
)
def test_stale_redo_selection_is_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
workspace = self.workspace(root, "Anot")
atomic_write_json(
root / "BRnot/Copie01/bnote.json", {"images": [{"label": "Ex 1"}]}
)
with (
patch.object(
reader,
"load_annotation_data",
return_value=AnnotationLoadResult({"01": {"Ex 2": {}}}, []),
),
patch.object(reader, "apply_actions_and_regenerate_grouped") as render,
):
self.assertEqual(
reader.run(workspace, refaire=True, annotation_dir="Anot"),
ExitCode.PARTIAL,
)
render.assert_not_called()
def test_missing_redo_return_leaves_copy_untouched(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
workspace = self.workspace(root, "BGnot")
(root / "BRnot/Copie01/Concat_annotated.pdf").unlink()
with (
patch.object(
reader,
"load_annotation_data",
return_value=AnnotationLoadResult({"01": {"Ex 2": {}}}, []),
),
patch.object(reader, "apply_actions_and_regenerate_grouped") as render,
):
self.assertEqual(reader.run(workspace, refaire=True), ExitCode.PARTIAL)
render.assert_not_called()
def test_regeneration_preserves_untouched_image_and_score_and_saves_redo(self):
for mode in ("BGnot", "Bnot", "Anot"):
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
workspace = self.workspace(root, mode)
output = root / mode / "Copie01"
output.mkdir()
Image.new("RGB", (40, 30), "red").save(output / "Ex 1.jpg")
before = (output / "Ex 1.jpg").read_bytes()
atomic_write_json(output / "score.json", {"Ex 1": "3.5", "Ex 2": "0"})
answer = root / "answer.pdf"
answer.touch()
data = {
"01": {
label: {
"result": {"score": 2, "feedback": []},
"pdf_path": answer,
"coordinates": (0, 0),
}
for label in ("Ex 1", "Ex 2")
}
}
with (
patch.object(
reader.annotating,
"make_base_image",
return_value=(Image.new("RGB", (40, 20)), 0, 0),
),
patch.object(
reader.annotating,
"compose_label_image",
return_value=(Image.new("RGB", (40, 20), "blue"), 0),
),
patch.object(reader, "get_extra_pdfs_as_images", return_value=[]),
):
status, _ = reader.apply_actions_and_regenerate_grouped(
workspace,
data,
"01",
[],
{},
["Ex 1", "Ex 2"],
annotation_dir=mode,
selected_labels={"Ex 2"},
)
self.assertEqual(status, ExitCode.SUCCESS)
self.assertEqual(
read_json(output / "score.json"), {"Ex 1": "3.5", "Ex 2": "2"}
)
self.assertEqual((output / "Ex 1.jpg").read_bytes(), before)
self.assertTrue((output / "Ex 2.jpg").is_file())
with Image.open(output / "Concat.jpg") as concat:
self.assertEqual(concat.size, (40, 50))
def test_simple_import_survives_successive_redos(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
workspace = self.workspace(root, "Anot")
output = root / "Anot/Copie01"
output.mkdir()
for label in ("Ex 1", "Ex 2"):
Image.new("RGB", (40, 30), "white").save(output / f"{label}.jpg")
Image.new("RGB", (40, 60), "red").save(output / "Concat_annotated.jpg")
atomic_write_json(output / "score.json", {"Ex 1": "1", "Ex 2": "1"})
answer = root / "answer.pdf"
answer.touch()
data = {
"01": {
label: {
"result": {"score": 2, "feedback": []},
"pdf_path": answer,
"coordinates": (0, 0),
}
for label in ("Ex 1", "Ex 2")
}
}
with (
patch.object(
reader.annotating,
"make_base_image",
return_value=(Image.new("RGB", (40, 20)), 0, 0),
),
patch.object(
reader.annotating,
"compose_label_image",
return_value=(Image.new("RGB", (40, 20), "blue"), 0),
),
):
for selected in ({"Ex 2"}, {"Ex 1"}):
status, _ = reader.apply_actions_and_regenerate_grouped(
workspace,
data,
"01",
[],
{},
["Ex 1", "Ex 2"],
annotation_dir="Anot",
selected_labels=selected,
)
self.assertEqual(status, ExitCode.SUCCESS)
if selected == {"Ex 2"}:
with Image.open(output / "Ex 1.jpg") as untouched:
self.assertGreater(untouched.getpixel((10, 10))[0], 240)
with Image.open(output / "Concat.jpg") as concat:
self.assertEqual(concat.size, (40, 40))
self.assertGreater(concat.getpixel((10, 30))[2], 240)
def test_latest_group_coordinates_win_numerically(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
group = root / "Par label" / "Ex 1"
group.mkdir(parents=True)
for number, height in ((2, 100), (10, 40)):
atomic_write_json(
group / f"Group_{number}.json", [["01", 0, height, "", "Ex 1"]]
)
Image.new("RGB", (20, height)).save(group / f"Group_{number}.jpg")
index, warnings = _coordinate_index(EvaluationWorkspace(root))
self.assertFalse(warnings)
self.assertEqual(index[("Ex 1", "01")].height, 40)
def test_unreadable_redo_is_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
atomic_write_json(root / "bnote.json", {"images": []})
with (
patch.object(
reader, "detect_checks_and_notes", return_value=([], None)
),
self.assertRaises(ValueError),
):
reader._scan_annotation_directory(root, required=True)
if __name__ == "__main__":
unittest.main()