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
+42 -12
View File
@@ -10,9 +10,6 @@ from typing import Any
from PIL import Image, ImageDraw
from reportlab.pdfgen import canvas
from copienator.commands import annotating
from copienator.commands import annotating_with_checks
from copienator import utils
from copienator import (
CliError,
EvaluationWorkspace,
@@ -22,9 +19,11 @@ from copienator import (
evaluation_parser,
execute,
read_json,
utils,
workspace_from_args,
)
from copienator.annotation_data import load_annotation_data
from copienator.commands import annotating, annotating_with_checks
from copienator.filesystem import staged_directory
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)
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
if not label_groups.exists():
gemini_groups = _gemini_label_groups(workspace, labels)
@@ -328,20 +329,38 @@ def _generate_groups(
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_directories("Copies", "Par label")
labels = utils.read_all_labels(workspace.root)
groups = _load_label_groups(workspace, labels)
loaded = load_annotation_data(workspace)
refaire_list = annotating_with_checks._load_refaire(workspace) if refaire else None
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:
print(f"Warning: {warning}")
if not loaded.data:
print("Warning: no annotation data was found.")
return ExitCode.PARTIAL
output_root = workspace.annotation_dir("grouped")
if overwrite:
output_root = workspace.annotation_dir("refaire" if refaire else "grouped")
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):
pass
@@ -356,7 +375,9 @@ def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
if generated == 0 or problems or loaded.warnings:
raise 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
else:
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:
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
@@ -384,7 +412,9 @@ def main(argv: Sequence[str] | None = None) -> int:
return execute(
parser,
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 reportlab.pdfgen import canvas
from copienator.commands import annotating
from copienator import utils
from copienator import (
CliError,
EvaluationWorkspace,
@@ -24,9 +22,11 @@ from copienator import (
execute,
read_json,
target_parser,
utils,
workspace_from_target,
)
from copienator.annotation_data import load_annotation_data
from copienator.commands import annotating
from copienator.filesystem import staged_directory
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"):
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)
return [x, y, x + size, y + size]
@@ -150,8 +152,13 @@ def _render_student(
*,
overwrite: bool,
output_mode: str,
output_root: Path | None = None,
) -> 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:
print(f"Skipping {student_id}: output is complete.")
return "skipped"
@@ -178,6 +185,7 @@ def _render_student(
draw_callback=checkbox_renderer.callback,
)
if final_image is None:
problems = True
continue
label_images.append(final_image)
checkbox_groups.append(checkbox_renderer.checkboxes)
@@ -280,6 +288,39 @@ def run(
output_mode = "refaire" if refaire else "checks"
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] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
futures = [
@@ -302,7 +343,9 @@ def run(
def build_parser() -> argparse.ArgumentParser:
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(
"--refaire",
action="store_true",
@@ -328,4 +371,3 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(main())
@@ -9,8 +9,6 @@ from typing import Any
from PIL import Image, ImageDraw
from copienator.commands import annotating
from copienator import utils
from copienator import (
EvaluationWorkspace,
ExitCode,
@@ -18,16 +16,18 @@ from copienator import (
evaluation_parser,
execute,
read_json,
utils,
workspace_from_args,
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
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 (
concatenate,
detect_checks_and_notes,
has_significant_notes,
)
from copienator.filesystem import staged_files
LabelNotes = dict[str, dict[str, Any]]
ScanResult = tuple[dict[str, list[dict[str, Any]]], dict[str, LabelNotes]]
@@ -54,7 +54,9 @@ def get_extra_pdfs_as_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."""
non_empty = [group for group in image_groups if group]
if not non_empty:
@@ -118,6 +120,8 @@ def _scan_annotation_directory(
directory: Path,
only_ids: set[str] | None = None,
default_student_id: str | None = None,
*,
required: bool = False,
) -> ScanResult:
bnote_path = directory / "bnote.json"
if not bnote_path.is_file():
@@ -133,6 +137,8 @@ def _scan_annotation_directory(
actions, notes_image = detect_checks_and_notes(directory)
if notes_image is None:
if required:
raise ValueError(f"Could not read annotations in {directory}")
return {}, {}
actions_by_student: dict[str, list[dict[str, Any]]] = defaultdict(list)
notes_by_student: dict[str, LabelNotes] = defaultdict(dict)
@@ -176,10 +182,12 @@ def apply_actions_and_regenerate_grouped(
all_labels: list[str],
*,
update_score: bool = False,
annotation_dir: str = "BGnot",
selected_labels: set[str] | None = None,
) -> 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}"]
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, {})
dirty_labels = apply_checkbox_actions(labels_data, actions, logs.append)
if update_score:
@@ -187,15 +195,91 @@ def apply_actions_and_regenerate_grouped(
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, "")
dirty_images: dict[str, Image.Image] = {}
concat_images: list[Image.Image] = []
filtered_groups: list[list[Image.Image]] = []
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"]
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))
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"])
if not pdf_path.is_file():
logs.append(f" Missing answer PDF: {pdf_path}")
@@ -221,13 +305,15 @@ def apply_actions_and_regenerate_grouped(
if has_notes:
width, height = sub_note.size
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)
if height > old_header_height:
body = sub_note.crop((0, old_header_height, width, height))
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
logs.append(f" Saved dirty image: {label}.jpg")
concat_images.append(final_image)
@@ -237,33 +323,54 @@ def apply_actions_and_regenerate_grouped(
feedback.get("to_delete", False) for feedback in feedbacks
)
if not perfect or has_notes:
extras = get_extra_pdfs_as_images(
workspace.root, label, annotating, all_labels
extras = (
get_extra_pdfs_as_images(workspace.root, label, annotating, all_labels)
if annotation_dir == "BGnot"
else []
)
filtered_groups.append([*extras, final_image])
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():
image.save(staging / f"{label}.jpg")
atomic_write_json(staging / "score.json", scores)
if concat_image is not None:
concat_image.save(staging / "Concat.jpg")
if filtered_groups:
save_paginated_pdf(filtered_groups, staging / "Concat_F.pdf")
if annotation_dir == "BGnot":
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}")
status = ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
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)
if not isinstance(loaded, list):
raise TypeError("refaire.json must contain a JSON array")
entries: RefaireList = []
by_student: dict[str, list[str]] = {}
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}")
copy_name, labels = entry
student_id = str(copy_name).removeprefix("Copie")
@@ -273,14 +380,78 @@ def _read_refaire(workspace: EvaluationWorkspace) -> tuple[RefaireList, dict[str
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(
workspace: EvaluationWorkspace,
*,
refaire: bool = False,
update_score: bool = False,
annotation_dir: str = "BGnot",
) -> ExitCode:
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_by_student: dict[str, list[str]] = {}
if refaire:
@@ -289,7 +460,12 @@ def run(
refaire_list, refaire_by_student = _read_refaire(workspace)
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:
print(f"Warning: {warning}")
if not loaded.data:
@@ -301,8 +477,10 @@ def run(
only_ids = set(refaire_by_student) or None
group_dirs = [
path
for path in workspace.annotation_dir("grouped").iterdir()
if path.is_dir() and not path.name.startswith("Copie")
for path in (workspace.root / annotation_dir).iterdir()
if annotation_dir == "BGnot"
and path.is_dir()
and not path.name.startswith("Copie")
]
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
futures = [
@@ -312,39 +490,58 @@ def run(
for future in concurrent.futures.as_completed(futures):
_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:
for student_id, requested_labels in refaire_by_student.items():
selected = requested_labels or list(loaded.data.get(student_id, {}))
selected_set = set(selected)
directory = workspace.annotation_dir("refaire") / f"Copie{student_id}"
if not directory.is_dir():
print(f"Warning: missing refaire annotation directory {directory}")
refaire_incomplete = True
expected = {
student_id: set(labels or loaded.data.get(student_id, {}))
for student_id, labels in refaire_by_student.items()
}
redo_actions, redo_notes, skipped_students = _scan_redo_annotations(
workspace.annotation_dir("refaire"), expected
)
for student_id, selected in expected.items():
if student_id in skipped_students:
continue
actions_by_student[student_id] = [
action
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:
notes_by_student[student_id].pop(label, None)
refaire_actions, refaire_notes = _scan_annotation_directory(
directory, default_student_id=student_id
actions_by_student[student_id].extend(
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 = (
ExitCode.PARTIAL
if loaded.warnings or refaire_incomplete
else ExitCode.SUCCESS
ExitCode.PARTIAL if loaded.warnings or skipped_students 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:
futures = {
executor.submit(
@@ -356,9 +553,15 @@ def run(
notes_by_student[student_id],
all_labels,
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
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):
result, output = future.result()
@@ -370,6 +573,12 @@ def run(
def build_parser() -> argparse.ArgumentParser:
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(
"--refaire",
action="store_true",
@@ -387,10 +596,13 @@ def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
if args.annotation_dir != "BGnot" and not args.refaire:
parser.error("--annotation-dir requires --refaire")
return run(
workspace_from_args(args),
refaire=args.refaire,
update_score=args.update_score,
annotation_dir=args.annotation_dir,
)
return execute(parser, argv, handle)
@@ -398,4 +610,3 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(main())