Support for post annotation label reassignement.

This commit is contained in:
sebastien committed 2026-10-11 19:03:04 +02:00
1 parent 698ac4240c
commit 370f3252ef
10 files changed
+228 -22

No files matched your search

+19 -5
View File
@@ -9,20 +9,26 @@ import numpy as np
from pdf2image import convert_from_path
from PIL import Image, ImageChops, ImageDraw, ImageFilter
from copienator.commands import annotating
from copienator import configuration, utils
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
configuration,
evaluation_parser,
execute,
read_json,
utils,
workspace_from_args,
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.answer_info import build_answer_info
from copienator.annotation_data import AnnotationData, load_annotation_data
from copienator.annotation_modifications import (
BASELINE,
output_removals,
stage_baseline,
)
from copienator.answer_info import build_answer_info
from copienator.commands import annotating
from copienator.filesystem import staged_files
from copienator.return_answers import save_return_answer_options
@@ -168,6 +174,8 @@ def apply_actions_and_regenerate(
scores = dict.fromkeys(all_labels, "")
answer_labels: list[str] = []
dirty_images: dict[str, Image.Image] = {}
header_heights: dict[str, int] = {}
filtered_labels: list[str] = []
concatenated: list[Image.Image] = []
filtered: list[Image.Image] = []
incomplete = False
@@ -221,15 +229,18 @@ def apply_actions_and_regenerate(
final_image.paste(body, (0, new_header_height), mask=body)
dirty_images[label] = final_image
header_heights[label] = new_header_height
answer_labels.append(label)
concatenated.append(final_image)
if float(scores[label]) != 4.0 or result.get("feedback", []):
filtered.append(final_image)
filtered_labels.append(label)
if incomplete:
return ExitCode.PARTIAL
concat_image = concatenate(concatenated)
filtered_image = concatenate(filtered)
with staged_files(output_dir, remove=("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf",
"touched.json", "answer_labels.json")) as staging:
with staged_files(output_dir, remove=(*output_removals(output_dir), BASELINE)) as staging:
for label, image in dirty_images.items():
image.save(staging / f"{label}.jpg")
if not preserve_score_file:
@@ -241,6 +252,9 @@ def apply_actions_and_regenerate(
concat_image.save(staging / "Concat.jpg")
if filtered_image is not None:
filtered_image.save(staging / "Concat_F.jpg")
stage_baseline(workspace.root, "Bnot", output_dir, staging, dirty_images,
header_heights, build_answer_info(scores, labels_data, answer_labels),
filtered_labels, scores)
if preserve_score_file:
print(f" Preserved existing score.json in {output_dir}")
@@ -9,11 +9,11 @@ from typing import Any
from PIL import Image, ImageDraw
from copienator import configuration
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
configuration,
evaluation_parser,
execute,
read_json,
@@ -21,8 +21,15 @@ from copienator import (
workspace_from_args,
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.answer_info import build_answer_info
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
from copienator.annotation_modifications import (
BASELINE,
baseline,
load_image,
output_removals,
stage_baseline,
)
from copienator.answer_info import build_answer_info
from copienator.commands import annotating
from copienator.commands.reading_annotations import (
concatenate,
@@ -247,6 +254,9 @@ def apply_actions_and_regenerate_grouped(
touched = dict.fromkeys(all_labels, False)
answer_labels: list[str] = []
dirty_images: dict[str, Image.Image] = {}
header_heights: dict[str, int] = {}
filtered_labels: list[str] = []
previous_baseline = baseline(output_dir) if (output_dir / BASELINE).is_file() else None
concat_images: list[Image.Image] = []
filtered_groups: list[list[Image.Image]] = []
incomplete = False
@@ -266,7 +276,17 @@ def apply_actions_and_regenerate_grouped(
if result.get("error") == "empty-answer":
continue
saved_image = output_dir / f"{label}.jpg"
if selected_labels and label not in selected_labels and saved_image.is_file():
baseline_entry = (previous_baseline or {}).get("images", {}).get(label)
if selected_labels and label not in selected_labels and (saved_image.is_file() or baseline_entry):
if annotation_dir in {"BGnot", "Bnot"}:
if previous_baseline is not None and label in previous_baseline["images"]:
entry = previous_baseline["images"][label]
final_image = load_image(output_dir, entry)
header_heights[label] = entry["header_height"]
else:
with Image.open(saved_image) as saved:
final_image = saved.convert("RGB").copy()
else:
with Image.open(saved_image) as saved:
final_image = saved.convert("RGB").copy()
if (
@@ -282,6 +302,7 @@ def apply_actions_and_regenerate_grouped(
scores[label] = str(old_scores.get(label, scores[label]))
concat_images.append(final_image)
answer_labels.append(label)
filtered_labels.append(label)
# Keep previously reviewed content, including handwriting.
if annotation_dir == "BGnot":
extras = get_extra_pdfs_as_images(
@@ -327,6 +348,7 @@ def apply_actions_and_regenerate_grouped(
# Persist every final block, including unchanged answers, for returns.
dirty_images[label] = final_image
header_heights[label] = new_header_height
answer_labels.append(label)
concat_images.append(final_image)
@@ -335,6 +357,7 @@ def apply_actions_and_regenerate_grouped(
feedback.get("to_delete", False) for feedback in feedbacks
)
if not perfect or has_notes:
filtered_labels.append(label)
extras = (
get_extra_pdfs_as_images(workspace.root, label, annotating, all_labels)
if annotation_dir == "BGnot"
@@ -346,8 +369,7 @@ def apply_actions_and_regenerate_grouped(
concat_image = concatenate(concat_images)
if incomplete:
return ExitCode.PARTIAL, "\n".join(logs)
with staged_files(output_dir, remove=("Concat.jpg", "Concat_F.pdf", "Concat_F.jpg",
"touched.json", "answer_labels.json")) as staging:
with staged_files(output_dir, remove=(*output_removals(output_dir), BASELINE)) as staging:
if simple_layout is not None:
simple_layout["replaced"] = sorted(
set(simple_layout["replaced"]) | selected_labels
@@ -370,6 +392,9 @@ def apply_actions_and_regenerate_grouped(
[image for group in filtered_groups for image in group]
)
filtered_image.save(staging / "Concat_F.jpg")
stage_baseline(workspace.root, annotation_dir, output_dir, staging, dirty_images,
header_heights, build_answer_info(scores, labels_data, answer_labels, touched),
filtered_labels, scores)
if preserve_score_file:
logs.append(f" Preserved existing score.json in {output_dir}")
logs.append(f" Saved regenerated files in {output_dir}")
+1
View File
@@ -40,6 +40,7 @@ COMMANDS: dict[str, Command] = {
"import": Command("import_annotations", "Import handwritten annotations"),
"read-annotations": Command("reading_annotations", "Read checkable annotations"),
"read-grouped": Command("reading_grouped_annotations", "Read grouped annotations"),
"modify-annotations": Command("modify_annotations", "Modify reviewed answer bodies"),
"giving-names": Command("giving_names", "Name copies and prepare A Rendre"),
"update-ods": Command("update_ods", "Update the configured score spreadsheet"),
"add-final-score": Command("add_final_score", "Stamp final scores on copies"),
+42
View File
@@ -92,3 +92,45 @@ def staged_files(
if not destination_path.exists():
saved.replace(destination_path)
_remove_path(backup)
def publish_file_sets(file_sets: dict[Path, tuple[Path, Iterable[str]]]) -> None:
"""Commit several staged file sets together, rolling every set back on error.
Staging and destination directories must be on the same filesystem. Unlike
nesting staged_files, failure in a later directory restores earlier ones too.
"""
token = uuid.uuid4().hex
backups: list[tuple[Path, Path]] = []
committed: list[Path] = []
try:
for target, (staging, remove) in file_sets.items():
target.mkdir(parents=True, exist_ok=True)
backup = target / f".publish-{token}.backup"
backup.mkdir()
backups.append((target, backup))
staged = sorted(path for path in staging.iterdir() if path.is_file())
names = {path.name for path in staged} | set(remove)
for name in sorted(names):
if Path(name).name != name:
raise ValueError(f"Invalid output filename: {name}")
destination = target / name
if destination.exists() or destination.is_symlink():
destination.replace(backup / name)
for source in staged:
destination = target / source.name
source.replace(destination)
committed.append(destination)
except Exception:
for destination in reversed(committed):
_remove_path(destination)
for target, backup in reversed(backups):
for saved in backup.iterdir():
saved.replace(target / saved.name)
raise
finally:
for _, backup in backups:
if backup.exists() and not any(backup.iterdir()):
backup.rmdir()
for _, backup in backups:
_remove_path(backup)
+21 -3
View File
@@ -30,6 +30,7 @@ from .scores import ScoreEditorPanel
from .notifications import notify_desktop
from .syncthing import SyncthingPanel
from .manual_resolution import ManualResolutionPanel
from .annotation_modifications import AnnotationModificationsPanel, annotation_mode_for_reader
from .recorrection import prepare_selection_file
from .refaire import SECTION as REFAIRE_SECTION
from .refaire import (
@@ -824,6 +825,7 @@ class CopienatorApp(tk.Tk):
"labels",
"plotting",
"manual_resolution",
"modify_annotations",
"giving_names",
"edit_scores",
"statement_persp",
@@ -888,6 +890,9 @@ class CopienatorApp(tk.Tk):
value = False # Deletion is a one-run choice, never a saved default.
if step.id == 'statement' and spec.name == 'restart':
value = False
if step.id == "modify_annotations" and spec.name == "annotation_dir" and spec.name not in values:
reader = self.state_store.step("read_annotations")
value = annotation_mode_for_reader(reader)
choices = spec.choices
if spec.name == "annotation_dir" and step.id in {"export", "import"}:
choices, value = self._annotation_directory_choices(step.id)
@@ -939,12 +944,19 @@ class CopienatorApp(tk.Tk):
self.edit_recorrection_button.grid(row=row, column=0, columnspan=2, sticky="w", pady=8)
row += 1
if step.id == "manual_resolution":
if step.id in {"manual_resolution", "modify_annotations"}:
def manual_evaluation():
target = self.arg_vars.get("target")
value = target.get().strip() if target else ""
return (self.repository / value).resolve() if value else self.evaluation
if step.id == "modify_annotations":
self.manual_panel = AnnotationModificationsPanel(
self.form, manual_evaluation,
lambda: self.arg_vars["annotation_dir"].get(),
)
self.arg_vars["annotation_dir"].trace_add("write", lambda *_: self.manual_panel.reload())
else:
self.manual_panel = ManualResolutionPanel(self.form, manual_evaluation)
self.manual_panel.grid(row=row, column=0, columnspan=2, sticky="nsew", pady=8)
if "target" in self.arg_vars:
@@ -966,7 +978,7 @@ class CopienatorApp(tk.Tk):
attach_tooltip(extra_entry, step.extra_arguments_help)
row += 1
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.run_button.configure(text="Make modifications" if step.id == "modify_annotations" else "Enregistrer la sélection" if step.id == "refaire_selection" else ("Marquer terminée" if step.is_manual else "Exécuter"))
if step.id == "personal_create":
self.run_button.configure(text="Marquer Réussie")
if step.id == "personal_read":
@@ -1953,7 +1965,7 @@ class CopienatorApp(tk.Tk):
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":
if step_id in {"refaire_merge", "modify_annotations"} 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"}:
@@ -1995,6 +2007,12 @@ class CopienatorApp(tk.Tk):
"manual": False, "reason": reason,
})
self._append_console(f"Remplacer les barèmes par Gemini : ignorée — {reason}\n")
if step_id == "read_annotations" and status == "success":
reader = self.state_store.step(step_id)
mode = annotation_mode_for_reader(reader)
values = dict(self.state_store.step("modify_annotations").get("values", {}))
values["annotation_dir"] = mode
self.state_store.update_step("modify_annotations", values=values)
if step_id == "annotation" and status == "success":
generation_variant = self.state_store.step("annotation").get(
"last_run_variant"
+13 -5
View File
@@ -45,21 +45,29 @@ def cut_operator(percent: float, keep: int, mode: str) -> str:
class CutHelper(tk.Toplevel):
def __init__(self, parent, path: Path, on_accept, initial=None):
# Load the source before creating a window so invalid PDFs leave no dialog.
def __init__(self, parent, path: Path | Image.Image, on_accept, initial=None):
# Body edits pass a raster without headers or supplementary pages.
width = min(850, parent.winfo_screenwidth() - 100)
if isinstance(path, Image.Image):
self.heights = [path.height]
self.scale = min(1.5, width / path.width)
rendered = [path.resize((round(path.width * self.scale),
round(path.height * self.scale)), Image.Resampling.LANCZOS)]
title = "Corps de la réponse annotée"
else:
with pymupdf.open(path) as document:
if not len(document):
raise ValueError("Le PDF est vide.")
self.heights = [page.rect.height for page in document]
width = min(850, parent.winfo_screenwidth() - 100)
self.scale = min(1.5, width / max(page.rect.width for page in document))
rendered = []
for page in document:
pix = page.get_pixmap(matrix=pymupdf.Matrix(self.scale, self.scale), alpha=False,
colorspace=pymupdf.csRGB)
rendered.append(Image.frombytes("RGB", (pix.width, pix.height), pix.samples))
title = path.name
super().__init__(parent)
self.title(f"Cut — {path.name}")
self.title(f"Cut — {title}")
self.geometry(f"{width + 45}x{min(850, parent.winfo_screenheight() - 100)}")
self.transient(parent.winfo_toplevel())
self.on_accept = on_accept
@@ -125,7 +133,7 @@ class CutHelper(tk.Toplevel):
operator = cut_operator(self.percent, self.keep.get(), self.mode.get())
rounded = float(operator.split("{")[1].split("}")[0])
if not 0 < rounded < 100:
messagebox.showerror("Coupe invalide", "Chaque partie doit contenir une portion du PDF.", parent=self)
messagebox.showerror("Coupe invalide", "Chaque partie doit contenir une portion de la réponse.", parent=self)
return
self.destroy()
self.on_accept(operator)
+5 -4
View File
@@ -37,9 +37,10 @@ Enregistrez dans l’éditeur, puis cliquez sur Recharger et enfin sur Exécuter
class ManualResolutionPanel(ttk.Frame):
def __init__(self, parent, get_evaluation):
def __init__(self, parent, get_evaluation, *, help_text=HELP, show_provenance=True):
super().__init__(parent)
self.get_evaluation = get_evaluation
self.show_provenance = show_provenance
self.pdf_buttons = []
self.cut_buttons = []
self.copy_buttons = []
@@ -67,7 +68,7 @@ class ManualResolutionPanel(ttk.Frame):
self.text.configure(yscrollcommand=scroll.set)
scroll.pack(side="right", fill="y")
self.text.pack(fill="both", expand=True)
ttk.Label(self, text=HELP, wraplength=650, justify="left").pack(fill="x", pady=8)
ttk.Label(self, text=help_text, wraplength=650, justify="left").pack(fill="x", pady=8)
self.reload()
def manual_path(self) -> Path | None:
@@ -123,7 +124,7 @@ class ManualResolutionPanel(ttk.Frame):
raise FileNotFoundError("Chargez une évaluation.")
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
self.status.configure(text=f"manual_resolutions.txt indisponible : {exc}")
self.status.configure(text=f"{path.name if path else 'Fichier'} indisponible : {exc}")
else:
self.editor_button.configure(state="normal")
malformed = []
@@ -142,7 +143,7 @@ class ManualResolutionPanel(ttk.Frame):
key = (instruction.copy_id, instruction.old_label)
if key not in provenance:
try:
provenance[key] = populated_labels(path.parent, *key)
provenance[key] = populated_labels(path.parent, *key) if self.show_provenance else []
except (OSError, ValueError) as exc:
provenance[key] = []
provenance_error = f" — Provenance indisponible : {exc}"
+17
View File
@@ -668,6 +668,23 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
),
) + return_answer_arguments,
),
StepDefinition(
"modify_annotations",
"Finalisation",
"Modifier les réponses annotées",
"Coupez ou transférez les corps de réponses après lecture des annotations. "
"Les en-têtes, les scores et les documents complémentaires restent associés à chaque label.",
(python("default", "Modifications", "modify-annotations"),),
arguments=(
arg_target("le dossier de l’évaluation"),
ArgumentSpec("annotation_dir", "Dossier d’annotations", "choice", "--annotation-dir",
default="BGnot", choices=("BGnot", "Bnot"),
help="Choisissez le mode utilisé pour lire les annotations manuscrites ; les coupes portent uniquement sur les corps des réponses."),
),
optional=True,
requires=("labels",),
artifacts=("annotation_modifications.txt",),
),
StepDefinition(
"giving_names",
"Finalisation",
+30
View File
@@ -256,3 +256,33 @@ ones. Weighting and the final overall mark belong to the separate grading flow.
- [ODS export](../copienator/commands/update_ods.py)
- [Final-mark stamping](../copienator/commands/add_final_score.py)
- [Cleanup retention](../copienator/commands/clean.py)
## Manual modifications after annotation reading
`modify-annotations EVALUATION --annotation-dir BGnot` (or `Bnot`) applies
`annotation_modifications.txt`. The GUI exposes it between annotation reading
and return preparation. Its `### Source: BGnot`/`Bnot` line must match the mode.
Instruction operators follow manual resolution syntax but act on reviewed raster
**bodies**, excluding headers and all supplementary pages. Headers and scores stay
with their labels. Missing target bodies are allowed; their new header identifies
the target and its existing score, without inventing feedback or a score.
Both readers record `annotation_baseline.json` and lossless hidden
`.annotation-base-*.png` images in each copy's annotation directory. The JSON
stores the exact header height, baseline answer metadata, and filtered membership.
Older outputs require one full annotation read before body editing. Legacy
selective redo still works, but cannot infer missing header boundaries.
Each application rebuilds from that baseline and publishes all affected copies
and the mode's `annotation_modifications_applied.txt` in one rollback-capable
transaction. Reapplying is idempotent; removing an instruction restores its prior
content and membership. Annotation rereads refresh the baseline and replay the
applied snapshot, not the draft. Selective redo preserves unaffected baseline
layers so previous transfers are never included twice.
The operation rebuilds per-label JPEGs, `info.json`, `Concat.jpg`, and the filtered
BGnot PDF or Bnot JPEG. Every changed non-empty body is included in the filtered
output, with its destination label's supplementary material. `score.json` is not
modified. The GUI marks downstream return preparation stale. Rerun `giving-names`
without `--update` to refresh both full outputs and individual answer exports.
+50
View File
@@ -368,6 +368,56 @@ score.json** sert à mettre à jour les scores sur les images finales à
partir d'une modification manuelle des fichiers `score.json`.
### Modifier les réponses annotées
Cette étape facultative se trouve après la lecture des annotations, pour **Bnot**
et **BGnot**. Pour une ancienne évaluation, relancez d’abord **Lire les annotations
manuscrites** : cette lecture enregistre maintenant la limite exacte de chaque
en-tête et une référence sans perte des images annotées.
Saisissez la **Copie**, le **Label source** et le **Label cible**, puis **Ajouter**.
Les deux labels doivent exister ; la source doit avoir une réponse annotée. Une
cible sans réponse est acceptée. Vous pouvez ajouter plusieurs instructions.
Les boutons **Source**, **Cible** et **Cut** montrent uniquement le **corps de la
réponse** : copie originale, annotations Gemini conservées et annotations
manuscrites situées sur ce corps. Ils excluent le score, les commentaires globaux
de l’en-tête, le contexte, l’énoncé et le corrigé. Les annotations manuscrites de
l’en-tête restent dans leur en-tête. Le pourcentage de coupe porte sur la hauteur
du corps uniquement. Aucun fragment n’est redimensionné lors des transferts.
Les instructions sont enregistrées dans `annotation_modifications.txt` :
```text
### Source: BGnot
Copie43 Ex 1 c{43}1> Ex 2|
```
Cet exemple conserve le début de la source (43 %) et ajoute la fin après le corps
de la cible. `2` conserve la fin, `x` remplace le corps cible au lieu d’ajouter, et
`|Ex 2` place le fragment avant la cible. Les opérateurs sans coupe de la résolution
manuelle sont aussi disponibles et décrits dans le panneau. Une instruction par
source ; une cible ne peut pas être source dans le même fichier. Plusieurs sources
peuvent alimenter une cible, dans l’ordre du fichier.
**Cut** prépare une commande à recopier. Utilisez **Ouvrir dans un éditeur de
texte**, enregistrez puis **Recharger**. **Aperçu du résultat** montre les corps
résultants pour la copie saisie. Cliquez sur **Make modifications** pour appliquer
le fichier enregistré. Chaque label conserve son en-tête ; les scores restent
inchangés. Les images individuelles, les images combinées, le PDF filtré BGnot
(ou l’image filtrée Bnot) et les informations de présence sont reconstruits.
Les réponses modifiées non vides sont incluses dans le résultat filtré, avec les
compléments propres à leur label.
Le fichier reste éditable après application. Réappliquer ne cumule pas les
transferts : le traitement repart de la référence issue de la lecture des
annotations. Retirer une ligne puis appliquer annule son effet. Une nouvelle
lecture des annotations renouvelle la référence et réapplique les dernières
instructions **appliquées**, sans prendre en compte un brouillon non appliqué.
Relancez ensuite **Attribuer les noms et préparer A Rendre**, sans cocher l’option
limitée aux images `answers`, afin d’actualiser tous les livrables.
### Attribuer les noms et préparer A Rendre
**Effet :** rassemble les résultats de chaque élève dans le dossier `A Rendre`.