Standardisation 3
This commit is contained in:
+13
@@ -171,6 +171,8 @@ scripts migrés vers cette convention sont actuellement :
|
||||
|
||||
- =copies_tools.py=, =grouping.py= et =verify_groups.py= ;
|
||||
- =post-correction.py= et =resolve_manual.py= ;
|
||||
- =annotating.py=, =annotating_with_checks.py= et
|
||||
=annotating_by_label.py= ;
|
||||
- =export.py=, =import.py= et =giving_names.py=.
|
||||
|
||||
** Correction d'un paquet de copies
|
||||
@@ -355,6 +357,11 @@ OU
|
||||
Ajoute les annotations Gemini, et des checkboxes à cocher.
|
||||
Enregistrées dans le dossier =Bnot=,
|
||||
=--overwrite=
|
||||
|
||||
Une seule copie peut être ciblée avec, par exemple,
|
||||
=python annotating_with_checks.py Interro/Copies/Copie01.pdf=.
|
||||
Le mode =--refaire= exige un fichier =refaire.json= et écrit dans
|
||||
=BRnot=.
|
||||
OU
|
||||
2. =python annotating_by_label.py Interro= dans =BGnot=
|
||||
|
||||
@@ -364,6 +371,12 @@ OU
|
||||
|
||||
_Needs_ : label_groups file (made automatically by this function),
|
||||
qui dit quelles questions regrouper.
|
||||
|
||||
Dans ces trois modes, les métadonnées JSON sont écrites atomiquement.
|
||||
Lors d'une régénération, les nouvelles sorties sont préparées dans un
|
||||
dossier temporaire voisin. Une erreur de rendu conserve donc la sortie
|
||||
précédente ; pour =BGnot --overwrite=, le dossier complet n'est remplacé
|
||||
que si tous les groupes ont été produits.
|
||||
3. =python export.py Interro= (gestion perso)
|
||||
Cela déplace les groupes dans le dossier configuré par =EXPORT_DIR=
|
||||
(par défaut =Export=).
|
||||
|
||||
+123
-225
@@ -1,140 +1,52 @@
|
||||
import sys
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import os
|
||||
import json
|
||||
import glob
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
from PIL import Image
|
||||
import tempfile
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib
|
||||
import matplotlib.colors as mcolors
|
||||
import PIL.ImageOps
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
from matplotlib import pyplot as plt
|
||||
from pdf2image import convert_from_path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
import utils
|
||||
from config import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.annotation_data import load_annotation_data
|
||||
from copienator.filesystem import staged_directory
|
||||
from utils import natural_key
|
||||
|
||||
MARGIN_LEFT = 300
|
||||
ANNOT_WIDTH = 600
|
||||
|
||||
# Results is : Copie id -> label -> {pdf_path, gemini_result, coordinates}
|
||||
# Coordinates are the real coordinates (hmin, hmax) of the image in the Group
|
||||
# The gemini_result coordinates should be un-normalized !
|
||||
def make_dictionary(root_dir, refaire=False, refaire_list=[]):
|
||||
correction_path = os.path.join(root_dir, "correction.json")
|
||||
|
||||
# Load correction data
|
||||
try:
|
||||
with open(correction_path, 'r', encoding='utf-8') as f:
|
||||
corrections = json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"Error: {correction_path} not found.")
|
||||
sys.exit(1)
|
||||
|
||||
# Dictionary: keys are IDs
|
||||
result_data = {}
|
||||
def make_dictionary(root_dir, refaire=False, refaire_list=None):
|
||||
"""Compatibility wrapper used by the annotation-reading scripts."""
|
||||
workspace = EvaluationWorkspace(Path(root_dir))
|
||||
loaded = load_annotation_data(
|
||||
workspace,
|
||||
refaire_list=(refaire_list or []) if refaire else None,
|
||||
)
|
||||
return loaded.data
|
||||
|
||||
# Iterate through labels and items in correction.json
|
||||
for label, items in corrections.items():
|
||||
items = sum(items, []) # Flatten
|
||||
for item in items:
|
||||
# print(item)
|
||||
student_id = item['id']
|
||||
result_obj = item['result']
|
||||
|
||||
if result_obj.get("suffix") == "_old":
|
||||
continue
|
||||
|
||||
# Find coordinates
|
||||
coordinates = None
|
||||
height,width= None, None
|
||||
label_dir = Path(root_dir) / "Par label" / label
|
||||
|
||||
# Search all json files in Dir/label
|
||||
json_files = glob.glob(os.path.join(label_dir, "*.json"))
|
||||
for jf in json_files:
|
||||
try:
|
||||
with open(jf, 'r', encoding='utf-8') as f:
|
||||
coord_list = json.load(f)
|
||||
# Format: [["id", x, y, width_r, "label"], ...]
|
||||
for entry in coord_list:
|
||||
if entry[0] == student_id:
|
||||
coordinates = (entry[1], entry[2])
|
||||
img_path = os.path.splitext(jf)[0] + ".jpg"
|
||||
with Image.open(img_path) as img:
|
||||
width, height = img.size
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if coordinates:
|
||||
break
|
||||
|
||||
suffix = result_obj.get("suffix", "")
|
||||
if suffix == "_new":
|
||||
pdf_path = Path(root_dir) / "Copies" / f"Copie{student_id}" / f"{label}_new.pdf"
|
||||
else:
|
||||
pdf_path = Path(root_dir) / "Copies" / f"Copie{student_id}" / f"{label}.pdf"
|
||||
# Initialize dictionary structure for this ID if missing
|
||||
if student_id not in result_data:
|
||||
result_data[student_id] = {}
|
||||
|
||||
fb = result_obj.get("feedback", [])
|
||||
for i in range(len(fb)):
|
||||
el = fb[i]
|
||||
if height == None or width == None:
|
||||
print("?? height or width is None, for ", student_id, label)
|
||||
if "box_2d" in el and el["box_2d"]:
|
||||
el["box_2d"][0] = (el["box_2d"][0] * height)//1000
|
||||
el["box_2d"][2] = (el["box_2d"][2] * height)//1000
|
||||
el["box_2d"][1] = (el["box_2d"][1] * width)//1000
|
||||
el["box_2d"][3] = (el["box_2d"][3] * width)//1000
|
||||
|
||||
# Populate the object
|
||||
result_data[student_id][label] = {
|
||||
"pdf_path": pdf_path,
|
||||
"result": result_obj,
|
||||
"coordinates": coordinates
|
||||
}
|
||||
|
||||
if refaire:
|
||||
for copie_name, labels_to_redo in refaire_list:
|
||||
sid = copie_name.replace("Copie", "") # Extract "01" from "Copie01"
|
||||
if sid in result_data:
|
||||
# Si des labels à refaire ne sont pas présent dans la correction
|
||||
# On ajoute des dummies
|
||||
if labels_to_redo: # Si la liste est non vide
|
||||
for lbl in labels_to_redo:
|
||||
pdf_path = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}.pdf"
|
||||
if not Path(pdf_path).exists():
|
||||
pdf_path_new = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}_new.pdf"
|
||||
if pdf_path_new.exists():
|
||||
pdf_path = pdf_path_new
|
||||
else:
|
||||
print("Debug : asked to refaire", sid, lbl, "but pdf absent")
|
||||
continue
|
||||
# result_data[sid][lbl] = {
|
||||
# "pdf_path": pdf_path,
|
||||
# "result": {
|
||||
# "score": 0.0,
|
||||
# "feedback": [],
|
||||
# "error": "non traité"
|
||||
# },
|
||||
# "coordinates": (0,0)
|
||||
# }
|
||||
else: # Ce student id n'a jamais été corrigé
|
||||
result_data[sid] = {}
|
||||
for lbl in labels_to_redo:
|
||||
pdf_path = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}.pdf"
|
||||
if not pdf_path.exists():
|
||||
pdf_path_new = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}_new.pdf"
|
||||
if pdf_path_new.exists():
|
||||
pdf_path = pdf_path_new
|
||||
else:
|
||||
print("Debug : asked to refaire", sid, lbl, "but pdf absent")
|
||||
continue
|
||||
result_data[sid][lbl] = {
|
||||
"pdf_path": pdf_path,
|
||||
"result": {
|
||||
"score": 0.0,
|
||||
"feedback": [],
|
||||
"error": "non traité"
|
||||
},
|
||||
"coordinates": (0,0)
|
||||
}
|
||||
|
||||
return result_data
|
||||
|
||||
def make_base_image(pdf_path):
|
||||
pages = convert_from_path(pdf_path)
|
||||
@@ -152,20 +64,6 @@ def make_base_image(pdf_path):
|
||||
current_y += page.height
|
||||
return (base_img, total_h, max_w)
|
||||
|
||||
import io
|
||||
import shutil
|
||||
from pdf2image import convert_from_path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import matplotlib
|
||||
matplotlib.use('Agg') # Force headless rendering
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# plt.rcParams.update({ "text.usetex": True,
|
||||
# "text.latex.preamble": r"\usepackage{bbold}"})
|
||||
|
||||
import re
|
||||
import textwrap
|
||||
|
||||
def normalize_mathtext(text):
|
||||
"""
|
||||
Replaces LaTeX shortcuts not supported by Matplotlib's mathtext parser.
|
||||
@@ -274,16 +172,6 @@ def render_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=N
|
||||
final_img.alpha_composite(img)
|
||||
return final_img
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import subprocess
|
||||
import PIL.ImageOps
|
||||
|
||||
|
||||
from config import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE
|
||||
from string import Template
|
||||
|
||||
|
||||
def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=None, fontsize=19):
|
||||
dpi = 100
|
||||
width_in = width_px / dpi
|
||||
@@ -303,11 +191,12 @@ def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_li
|
||||
f.write(latex_template)
|
||||
|
||||
# Compile to PDF
|
||||
result = subprocess.run(
|
||||
subprocess.run(
|
||||
['pdflatex', '-interaction=nonstopmode', 'text.tex'],
|
||||
cwd=temp_dir,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if not os.path.exists(pdf_path):
|
||||
@@ -336,12 +225,6 @@ def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_li
|
||||
|
||||
return final_img
|
||||
|
||||
import io
|
||||
from PIL import Image
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.colors as mcolors
|
||||
from highlight_text import ax_text
|
||||
|
||||
def color(score):
|
||||
t = max(0.0, min(1.0, float(score) / 4.0))
|
||||
t = t*1.5 - 0.25
|
||||
@@ -350,8 +233,6 @@ def color(score):
|
||||
green = 150 * t
|
||||
return mcolors.to_hex((red/255, green/255, 0))
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
def render_score_text(label, score, error, width_px, fontsize=30,
|
||||
bg_color=(255, 255, 255, 255),
|
||||
with_error=True, id=None):
|
||||
@@ -380,7 +261,7 @@ def render_score_text(label, score, error, width_px, fontsize=30,
|
||||
try:
|
||||
font_regular = ImageFont.truetype("DejaVuSans.ttf", fontsize)
|
||||
font_bold = ImageFont.truetype("DejaVuSans-Bold.ttf", fontsize)
|
||||
except IOError:
|
||||
except OSError:
|
||||
# Fallback for systems without specific TTFs readily available
|
||||
print("here")
|
||||
try:
|
||||
@@ -429,7 +310,6 @@ def compose_label_image(base_img, label, result, hmin,
|
||||
if base_img.width < TARGET_MIN_WIDTH:
|
||||
total_missing = TARGET_MIN_WIDTH - base_img.width
|
||||
left_pad = min(total_missing, MARGIN_LEFT)
|
||||
right_pad = total_missing - left_pad
|
||||
|
||||
new_base = Image.new("RGB", (TARGET_MIN_WIDTH, base_img.height), "white")
|
||||
new_base.paste(base_img, (left_pad, 0))
|
||||
@@ -549,100 +429,118 @@ def compose_label_image(base_img, label, result, hmin,
|
||||
|
||||
return final_img, header_height
|
||||
|
||||
from utils import natural_key
|
||||
import concurrent.futures
|
||||
|
||||
def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
|
||||
"""Helper function to process a single student."""
|
||||
|
||||
# Prepare output directory: Dir/Anot_CopieID
|
||||
output_dir = os.path.join(root_dir, "Anot", f"Copie{student_id}")
|
||||
output_dir = Path(root_dir) / "Anot" / f"Copie{student_id}"
|
||||
|
||||
# Check if already processed (Concat.jpg exists)
|
||||
concat_path = os.path.join(output_dir, "Concat.jpg")
|
||||
if os.path.exists(concat_path) and not overwrite:
|
||||
concat_path = output_dir / "Concat.jpg"
|
||||
if concat_path.exists() and not overwrite:
|
||||
print(f"Skipping Copie {student_id} (Concat.jpg exists)")
|
||||
return
|
||||
return "skipped"
|
||||
|
||||
print("Processing :", student_id)
|
||||
|
||||
# Clean folder if re-processing
|
||||
if os.path.exists(output_dir):
|
||||
shutil.rmtree(output_dir)
|
||||
os.makedirs(output_dir)
|
||||
|
||||
problems = False
|
||||
with staged_directory(output_dir) as staging:
|
||||
d_notes = dict.fromkeys(all_labels, "")
|
||||
label_images = []
|
||||
|
||||
# !! Trier par l'ordre des labels plutôt
|
||||
sorted_labels = sorted(list(labels_data.items()), key=natural_key)
|
||||
sorted_labels = sorted(labels_data.items(), key=lambda item: natural_key(item[0]))
|
||||
|
||||
for label, content in sorted_labels:
|
||||
# 1. Find PDF path
|
||||
copie_folder = f"Copie{student_id}"
|
||||
pdf_full_path = content.get('pdf_path')
|
||||
|
||||
if not pdf_full_path or not os.path.exists(pdf_full_path):
|
||||
if not pdf_full_path or not Path(pdf_full_path).exists():
|
||||
print(f"File not found: {pdf_full_path}")
|
||||
problems = True
|
||||
continue
|
||||
|
||||
# 2. Convert PDF to Image
|
||||
try:
|
||||
(base_img, _, _) = make_base_image(pdf_full_path)
|
||||
except Exception as e:
|
||||
print(f"Error converting {pdf_full_path}: {e}")
|
||||
base_img, _, _ = make_base_image(pdf_full_path)
|
||||
except Exception as exc: # noqa: BLE001 - PDF/LaTeX backends vary
|
||||
print(f"Error converting {pdf_full_path}: {exc}")
|
||||
problems = True
|
||||
continue
|
||||
|
||||
result = content.get('result', {})
|
||||
coordinates = content.get('coordinates', (0, 0)) # (hmin, hmax)
|
||||
score = result.get('score', 0)
|
||||
d_notes[label] = str(score)
|
||||
|
||||
final_img, _ = compose_label_image(base_img, label, result, coordinates[0],
|
||||
coordinates = content.get('coordinates', (0, 0))
|
||||
d_notes[label] = str(result.get('score', 0))
|
||||
final_img, _ = compose_label_image(
|
||||
base_img,
|
||||
label,
|
||||
result,
|
||||
coordinates[0],
|
||||
with_empty=True,
|
||||
render_fn=render_real_latex_text)
|
||||
# 7. Save Image
|
||||
save_path = os.path.join(output_dir, f"{label}.jpg")
|
||||
final_img.save(save_path)
|
||||
render_fn=render_real_latex_text,
|
||||
)
|
||||
final_img.save(staging / f"{label}.jpg")
|
||||
if result.get('error', "") != "empty-answer":
|
||||
label_images.append(final_img)
|
||||
|
||||
# Save scores
|
||||
with open(os.path.join(output_dir, "score.json"), "w") as f:
|
||||
json.dump(d_notes, f, indent=4)
|
||||
|
||||
# Concatenate
|
||||
atomic_write_json(staging / "score.json", d_notes)
|
||||
if label_images:
|
||||
max_w = max(i.width for i in label_images)
|
||||
total_h = sum(i.height for i in label_images)
|
||||
max_w = max(image.width for image in label_images)
|
||||
total_h = sum(image.height for image in label_images)
|
||||
canvas = Image.new('RGB', (max_w, total_h))
|
||||
cy = 0
|
||||
for img in label_images:
|
||||
canvas.paste(img, (0, cy))
|
||||
cy += img.height
|
||||
canvas.save(concat_path)
|
||||
current_y = 0
|
||||
for image in label_images:
|
||||
canvas.paste(image, (0, current_y))
|
||||
current_y += image.height
|
||||
canvas.save(staging / "Concat.jpg")
|
||||
elif labels_data:
|
||||
problems = True
|
||||
return "partial" if problems else "success"
|
||||
|
||||
|
||||
def process_correction(root_dir, data, all_labels, overwrite=False):
|
||||
# Ne pas thread cette application
|
||||
# 1. Il faut protéger les appels à matplotlib
|
||||
# 2. tu vas perdre les erreurs
|
||||
for student_id, labels in sorted(data.items()):
|
||||
statuses = [
|
||||
process_student(student_id, labels, root_dir, all_labels, overwrite)
|
||||
for student_id, labels in sorted(data.items())
|
||||
]
|
||||
return ExitCode.PARTIAL if "partial" in statuses else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = evaluation_parser("Generate simple annotated copies.")
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="Replace existing student output directories",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
|
||||
workspace.require_files("labels", "correction.json")
|
||||
workspace.require_directories("Copies", "Par label")
|
||||
labels = utils.read_all_labels(workspace.root)
|
||||
loaded = load_annotation_data(workspace)
|
||||
for warning in loaded.warnings:
|
||||
print(f"Warning: {warning}")
|
||||
if not loaded.data:
|
||||
print("Warning: no annotation data was found.")
|
||||
return ExitCode.PARTIAL
|
||||
result = process_correction(
|
||||
workspace.root,
|
||||
loaded.data,
|
||||
labels,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
if loaded.warnings and result == ExitCode.SUCCESS:
|
||||
return ExitCode.PARTIAL
|
||||
return result
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(
|
||||
parser,
|
||||
argv,
|
||||
lambda args: run(workspace_from_args(args), overwrite=args.overwrite),
|
||||
)
|
||||
|
||||
import argparse
|
||||
import utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Annotate copies")
|
||||
parser.add_argument("root_dir", help="Directory containing the copies")
|
||||
parser.add_argument("--overwrite", action="store_true", help="Reprocess even if Concat.jpg exists")
|
||||
|
||||
args = parser.parse_args()
|
||||
root_dir = args.root_dir
|
||||
labels = utils.read_all_labels(root_dir)
|
||||
results = make_dictionary(root_dir)
|
||||
# Results is : Copie id -> label -> {pdf_path, gemini_result, coordinates}
|
||||
# Coordinates are the real coordinates (hmin, hmax) of the image in the Group
|
||||
# print(results,"\n\n\n")
|
||||
process_correction(root_dir, results, labels,overwrite=args.overwrite)
|
||||
raise SystemExit(main())
|
||||
|
||||
+277
-210
@@ -1,256 +1,323 @@
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import utils
|
||||
import shutil
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
import annotating
|
||||
import annotating_with_checks
|
||||
|
||||
import utils
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
atomic_write_text,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
read_json,
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.annotation_data import load_annotation_data
|
||||
from copienator.filesystem import staged_directory
|
||||
from utils import natural_key
|
||||
|
||||
MAX_HEIGHT_PX = 25000 # Can be increased by 10%.
|
||||
MAX_HEIGHT_PX = 25000
|
||||
|
||||
|
||||
def render_item(item):
|
||||
student_id, label, content = item
|
||||
pdf_path = content['pdf_path']
|
||||
if not os.path.exists(pdf_path):
|
||||
print("no pdf path for ", pdf_path)
|
||||
pdf_path = Path(content["pdf_path"])
|
||||
if not pdf_path.exists():
|
||||
print(f"Warning: answer PDF not found: {pdf_path}")
|
||||
return None
|
||||
|
||||
base_img, _, _ = annotating.make_base_image(pdf_path)
|
||||
cb_renderer = annotating_with_checks.CheckboxRenderer(label)
|
||||
|
||||
final_img, header_h = annotating.compose_label_image(
|
||||
base_img, label, content['result'], content['coordinates'][0],
|
||||
draw_callback=cb_renderer.callback,
|
||||
base_image, _, _ = annotating.make_base_image(pdf_path)
|
||||
checkbox_renderer = annotating_with_checks.CheckboxRenderer(label)
|
||||
final_image, header_height = annotating.compose_label_image(
|
||||
base_image,
|
||||
label,
|
||||
content["result"],
|
||||
content["coordinates"][0],
|
||||
draw_callback=checkbox_renderer.callback,
|
||||
more_right=True,
|
||||
with_id=student_id
|
||||
with_id=student_id,
|
||||
)
|
||||
if final_img is None:
|
||||
if final_image is None:
|
||||
return None
|
||||
return (
|
||||
student_id,
|
||||
label,
|
||||
final_image,
|
||||
header_height,
|
||||
checkbox_renderer.checkboxes,
|
||||
)
|
||||
|
||||
return (student_id, label, final_img, header_h, cb_renderer.checkboxes)
|
||||
|
||||
def save_batch(batch, prefix, group_id, root_dir, overwrite):
|
||||
output_dir = os.path.join(root_dir, "BGnot", f"{prefix} G{group_id}")
|
||||
|
||||
if os.path.exists(output_dir):
|
||||
if not overwrite:
|
||||
print(f"Skipping {output_dir}: Output already exists.")
|
||||
return
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
print(f"Generating Group PDF: {prefix} G{group_id} ({len(batch)} elements)")
|
||||
os.makedirs(output_dir)
|
||||
|
||||
max_w = max(item[2].width for item in batch)
|
||||
total_h = sum(item[2].height for item in batch)
|
||||
concat_img = Image.new("RGB", (max_w, total_h), "white")
|
||||
draw = ImageDraw.Draw(concat_img)
|
||||
|
||||
final_json_map = []
|
||||
bnote_entries = []
|
||||
def save_batch(batch, prefix, group_id, output_root: Path) -> None:
|
||||
output_dir = output_root / f"{prefix} G{group_id}"
|
||||
print(f"Generating group PDF: {prefix} G{group_id} ({len(batch)} elements)")
|
||||
max_width = max(item[2].width for item in batch)
|
||||
total_height = sum(item[2].height for item in batch)
|
||||
concatenated = Image.new("RGB", (max_width, total_height), "white")
|
||||
draw = ImageDraw.Draw(concatenated)
|
||||
checkbox_map: list[dict[str, Any]] = []
|
||||
bnote_entries: list[dict[str, Any]] = []
|
||||
current_y = 0
|
||||
last_sid = None
|
||||
previous_student = None
|
||||
|
||||
for sid, label, img, header_h, boxes in batch:
|
||||
concat_img.paste(img, (0, current_y))
|
||||
|
||||
if sid != last_sid:
|
||||
draw.rectangle([0, current_y, max_w, current_y + 4], fill="purple")
|
||||
last_sid = sid
|
||||
|
||||
bnote_entries.append({
|
||||
"id": sid,
|
||||
for student_id, label, image, header_height, checkboxes in batch:
|
||||
concatenated.paste(image, (0, current_y))
|
||||
if student_id != previous_student:
|
||||
draw.rectangle([0, current_y, max_width, current_y + 4], fill="purple")
|
||||
previous_student = student_id
|
||||
bnote_entries.append(
|
||||
{
|
||||
"id": student_id,
|
||||
"label": label,
|
||||
"header_height": header_h,
|
||||
"header_height": header_height,
|
||||
"hmin": current_y,
|
||||
"hmax": current_y + img.height
|
||||
})
|
||||
"hmax": current_y + image.height,
|
||||
}
|
||||
)
|
||||
for item in checkboxes:
|
||||
box = item.get("final_box") or item.get("rel_box")
|
||||
item["global_box"] = [
|
||||
box[0],
|
||||
box[1] + current_y,
|
||||
box[2],
|
||||
box[3] + current_y,
|
||||
]
|
||||
item["student_id"] = student_id
|
||||
checkbox_map.append(item)
|
||||
current_y += image.height
|
||||
|
||||
for item in boxes:
|
||||
b = item.get('final_box') or item.get('rel_box')
|
||||
item['global_box'] = [b[0], b[1] + current_y, b[2], b[3] + current_y]
|
||||
item['student_id'] = sid # Required to map checkbox to the correct student
|
||||
final_json_map.append(item)
|
||||
with staged_directory(output_dir) as staging:
|
||||
atomic_write_json(
|
||||
staging / "bnote.json",
|
||||
{"width": max_width, "height": total_height, "images": bnote_entries},
|
||||
)
|
||||
atomic_write_json(staging / "checkboxes.json", checkbox_map)
|
||||
reference = staging / "Reference.jpg"
|
||||
concatenated.save(reference, quality=90)
|
||||
pdf_path = staging / "Concat.pdf"
|
||||
pdf_canvas = canvas.Canvas(str(pdf_path), pagesize=(max_width, total_height))
|
||||
pdf_canvas.drawImage(
|
||||
str(reference),
|
||||
0,
|
||||
0,
|
||||
width=max_width,
|
||||
height=total_height,
|
||||
)
|
||||
pdf_canvas.save()
|
||||
|
||||
current_y += img.height
|
||||
|
||||
with open(os.path.join(output_dir, "bnote.json"), "w") as f:
|
||||
json.dump({"width": max_w, "height": total_h, "images": bnote_entries}, f, indent=2)
|
||||
def _initial_label_groups(labels: list[str]) -> str:
|
||||
groups: dict[str, list[str]] = {}
|
||||
for label in labels:
|
||||
key = label.split(" : ")[0] if " : " in label else label
|
||||
groups.setdefault(key, []).append(label)
|
||||
return "".join(",".join(items) + "\n" for items in groups.values())
|
||||
|
||||
with open(os.path.join(output_dir, "checkboxes.json"), "w") as f:
|
||||
json.dump(final_json_map, f, indent=2)
|
||||
|
||||
temp_img_path = os.path.join(output_dir, "Reference.jpg")
|
||||
concat_img.save(temp_img_path, quality=90)
|
||||
|
||||
pdf_path = os.path.join(output_dir, "Concat.pdf")
|
||||
w, h = concat_img.size
|
||||
c = canvas.Canvas(pdf_path, pagesize=(w, h))
|
||||
c.drawImage(temp_img_path, 0, 0, width=w, height=h)
|
||||
c.save()
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate annotated PDFs grouped by labels.")
|
||||
parser.add_argument("input_path", help="Directory containing Bnot structure")
|
||||
parser.add_argument("--overwrite", action="store_true", help="Overwrite existing output files")
|
||||
args = parser.parse_args()
|
||||
|
||||
root_dir = args.input_path
|
||||
validated_labels = utils.read_all_labels(root_dir)
|
||||
results = annotating.make_dictionary(root_dir)
|
||||
label_groups = os.path.join(root_dir, "label_groups")
|
||||
all_labels = os.path.join(root_dir, "labels")
|
||||
|
||||
if not os.path.exists(label_groups):
|
||||
print(f"Warning: Labels file '{label_groups}' not found, making it out of '{all_labels}'")
|
||||
if not os.path.exists(all_labels):
|
||||
print(f"Error: {all_labels} not found.")
|
||||
sys.exit(1)
|
||||
|
||||
lines = validated_labels
|
||||
|
||||
groups = {}
|
||||
for line in lines:
|
||||
# Key is the part before the colon, or the whole line if no colon
|
||||
key = line.split(' : ')[0] if ' : ' in line else line
|
||||
groups.setdefault(key, []).append(line)
|
||||
|
||||
with open(label_groups, 'w') as f:
|
||||
for items in groups.values():
|
||||
f.write(",".join(items) + "\n")
|
||||
def _load_label_groups(workspace: EvaluationWorkspace, labels: list[str]) -> list[list[str]]:
|
||||
label_groups = workspace.root / "label_groups"
|
||||
if not label_groups.exists():
|
||||
atomic_write_text(label_groups, _initial_label_groups(labels))
|
||||
print(f"Created {label_groups}; review the groups before continuing.")
|
||||
utils.edit_file_and_enter(label_groups)
|
||||
known_labels = set(labels)
|
||||
groups: list[list[str]] = []
|
||||
unknown: set[str] = set()
|
||||
for line in label_groups.read_text(encoding="utf-8").splitlines():
|
||||
group = [label.strip() for label in line.split(",") if label.strip()]
|
||||
unknown.update(label for label in group if label not in known_labels)
|
||||
if group:
|
||||
groups.append(group)
|
||||
if unknown:
|
||||
raise CliError(
|
||||
"label_groups contains unknown labels: " + ", ".join(sorted(unknown))
|
||||
)
|
||||
return groups
|
||||
|
||||
with open(label_groups, "r") as f:
|
||||
lines = [line.strip() for line in f if line.strip()]
|
||||
|
||||
bgnot_dir = os.path.join(root_dir, "BGnot")
|
||||
if args.overwrite and os.path.exists(bgnot_dir):
|
||||
shutil.rmtree(bgnot_dir)
|
||||
os.makedirs(bgnot_dir, exist_ok=True)
|
||||
|
||||
used_prefixes = set()
|
||||
|
||||
previous_prefix = None
|
||||
for line in lines:
|
||||
labels = [l.strip() for l in line.split(',') if l.strip()]
|
||||
safe_labels = [l.replace(":", "").strip() for l in line.split(',') if l.strip()]
|
||||
if not labels:
|
||||
continue
|
||||
|
||||
base_prefix = os.path.commonprefix(safe_labels).strip()
|
||||
|
||||
if base_prefix and previous_prefix is not None:
|
||||
if natural_key(base_prefix) < natural_key(previous_prefix):
|
||||
base_prefix_maybe = f"{safe_labels[0]}+"
|
||||
if natural_key(base_prefix_maybe) > natural_key(previous_prefix):
|
||||
base_prefix = base_prefix_maybe
|
||||
|
||||
if not base_prefix:
|
||||
base_prefix = "Group"
|
||||
|
||||
unique_prefix = base_prefix
|
||||
if unique_prefix[-1] == "i":
|
||||
unique_prefix = unique_prefix[:-1]
|
||||
def _unique_prefixes(groups: list[list[str]]) -> list[tuple[str, list[str]]]:
|
||||
used: set[str] = set()
|
||||
result: list[tuple[str, list[str]]] = []
|
||||
previous: str | None = None
|
||||
for labels in groups:
|
||||
safe_labels = [label.replace(":", "").strip() for label in labels]
|
||||
base = os.path.commonprefix(safe_labels).strip() or "Group"
|
||||
if base and previous is not None and natural_key(base) < natural_key(previous):
|
||||
candidate = f"{safe_labels[0]}+"
|
||||
if natural_key(candidate) > natural_key(previous):
|
||||
base = candidate
|
||||
prefix = base.removesuffix("i")
|
||||
counter = 2
|
||||
while unique_prefix in used_prefixes:
|
||||
unique_prefix = f"{base_prefix}-{counter}"
|
||||
while prefix in used:
|
||||
prefix = f"{base}-{counter}"
|
||||
counter += 1
|
||||
if counter == 2 and previous_prefix and previous_prefix in unique_prefix:
|
||||
unique_prefix = f"{previous_prefix}-{counter}"
|
||||
if counter == 2 and previous and previous in prefix:
|
||||
prefix = f"{previous}-{counter}"
|
||||
elif counter == 2:
|
||||
previous_prefix = unique_prefix
|
||||
|
||||
used_prefixes.add(unique_prefix)
|
||||
|
||||
existing_items = set()
|
||||
max_existing_group = 0
|
||||
previous = prefix
|
||||
used.add(prefix)
|
||||
result.append((prefix, labels))
|
||||
return result
|
||||
|
||||
|
||||
if not args.overwrite and os.path.exists(bgnot_dir):
|
||||
for d in os.listdir(bgnot_dir):
|
||||
if d.startswith(f"{unique_prefix} G"):
|
||||
def _existing_group_state(
|
||||
output_root: Path,
|
||||
prefix: str,
|
||||
) -> tuple[set[tuple[str, str]], int]:
|
||||
existing_items: set[tuple[str, str]] = set()
|
||||
maximum_group = 0
|
||||
if not output_root.is_dir():
|
||||
return existing_items, maximum_group
|
||||
for directory in output_root.iterdir():
|
||||
if not directory.is_dir() or not directory.name.startswith(f"{prefix} G"):
|
||||
continue
|
||||
try:
|
||||
g_id = int(d.split(' G')[-1])
|
||||
max_existing_group = max(max_existing_group, g_id)
|
||||
maximum_group = max(maximum_group, int(directory.name.split(" G")[-1]))
|
||||
except ValueError:
|
||||
continue
|
||||
metadata = directory / "bnote.json"
|
||||
if not metadata.exists():
|
||||
continue
|
||||
loaded = read_json(metadata)
|
||||
if not isinstance(loaded, dict):
|
||||
raise TypeError(f"Expected a JSON object in {metadata}")
|
||||
for image in loaded.get("images", []):
|
||||
existing_items.add((str(image["id"]), str(image["label"])))
|
||||
return existing_items, maximum_group
|
||||
|
||||
|
||||
def split_batches(rendered):
|
||||
def split(maximum_height: float):
|
||||
batches = []
|
||||
current = []
|
||||
current_height = 0
|
||||
previous_student = None
|
||||
for item in rendered:
|
||||
student_id = item[0]
|
||||
image_height = item[2].height
|
||||
if (
|
||||
current
|
||||
and current_height + image_height > maximum_height
|
||||
and student_id != previous_student
|
||||
):
|
||||
batches.append(current)
|
||||
current = []
|
||||
current_height = 0
|
||||
current.append(item)
|
||||
current_height += image_height
|
||||
previous_student = student_id
|
||||
if current:
|
||||
batches.append(current)
|
||||
return batches
|
||||
|
||||
strict = split(MAX_HEIGHT_PX)
|
||||
relaxed = split(1.1 * MAX_HEIGHT_PX)
|
||||
return relaxed if len(relaxed) < len(strict) else strict
|
||||
|
||||
|
||||
def _generate_groups(
|
||||
output_root: Path,
|
||||
data,
|
||||
groups: list[list[str]],
|
||||
*,
|
||||
resume: bool,
|
||||
) -> tuple[int, bool]:
|
||||
generated = 0
|
||||
problems = False
|
||||
for prefix, labels in _unique_prefixes(groups):
|
||||
existing_items, maximum_group = (
|
||||
_existing_group_state(output_root, prefix) if resume else (set(), 0)
|
||||
)
|
||||
items = [
|
||||
(student_id, label, student_labels[label])
|
||||
for student_id, student_labels in data.items()
|
||||
for label in labels
|
||||
if label in student_labels and (student_id, label) not in existing_items
|
||||
]
|
||||
if not items:
|
||||
continue
|
||||
items.sort(key=lambda item: (natural_key(item[0]), natural_key(item[1])))
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
||||
rendered = list(executor.map(render_item, items))
|
||||
if any(item is None for item in rendered):
|
||||
problems = True
|
||||
successful = [item for item in rendered if item is not None]
|
||||
for index, batch in enumerate(split_batches(successful), start=1):
|
||||
save_batch(batch, prefix, maximum_group + index, output_root)
|
||||
generated += 1
|
||||
return generated, problems
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, *, overwrite: 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)
|
||||
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:
|
||||
class IncompleteGroupedOutput(Exception):
|
||||
pass
|
||||
|
||||
bnote_path = os.path.join(bgnot_dir, d, "bnote.json")
|
||||
if os.path.exists(bnote_path):
|
||||
with open(bnote_path, "r") as bf:
|
||||
bdata = json.load(bf)
|
||||
for img in bdata.get("images", []):
|
||||
existing_items.add((img["id"], img["label"]))
|
||||
try:
|
||||
with staged_directory(output_root) as staging:
|
||||
generated, problems = _generate_groups(
|
||||
staging,
|
||||
loaded.data,
|
||||
groups,
|
||||
resume=False,
|
||||
)
|
||||
if generated == 0 or problems or loaded.warnings:
|
||||
raise IncompleteGroupedOutput
|
||||
except IncompleteGroupedOutput:
|
||||
print("Warning: grouped overwrite was incomplete; previous BGnot was preserved.")
|
||||
return ExitCode.PARTIAL
|
||||
else:
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
generated, problems = _generate_groups(
|
||||
output_root,
|
||||
loaded.data,
|
||||
groups,
|
||||
resume=True,
|
||||
)
|
||||
if problems:
|
||||
return ExitCode.PARTIAL
|
||||
if generated == 0:
|
||||
print("No new grouped annotations were required.")
|
||||
return ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
|
||||
|
||||
items_to_render = []
|
||||
for sid, lbls in results.items():
|
||||
for lbl in labels:
|
||||
if lbl in lbls:
|
||||
# Only add if it hasn't been generated yet
|
||||
if (sid, lbl) not in existing_items:
|
||||
items_to_render.append((sid, lbl, lbls[lbl]))
|
||||
if not items_to_render:
|
||||
continue
|
||||
|
||||
# Sort structurally: by student id and label
|
||||
items_to_render.sort(key=lambda x: (natural_key(x[0]), natural_key(x[1])))
|
||||
# Render images in parallel using the pre-existing lock & render function
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
||||
rendered = list(executor.map(render_item, items_to_render))
|
||||
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")
|
||||
return parser
|
||||
|
||||
rendered = [r for r in rendered if r is not None]
|
||||
if not rendered:
|
||||
continue
|
||||
|
||||
# Split into constrained height batches
|
||||
batches = []
|
||||
current_batch = []
|
||||
current_h = 0
|
||||
for r in rendered:
|
||||
sid = r[0]
|
||||
img_h = r[2].height
|
||||
# Split if we exceed max height AND we are on a new student
|
||||
if current_batch and current_h + img_h > MAX_HEIGHT_PX and sid != last_sid:
|
||||
batches.append(current_batch)
|
||||
current_batch = []
|
||||
current_h = 0
|
||||
current_batch.append(r)
|
||||
current_h += img_h
|
||||
last_sid = sid
|
||||
if current_batch:
|
||||
batches.append(current_batch)
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(
|
||||
parser,
|
||||
argv,
|
||||
lambda args: run(workspace_from_args(args), overwrite=args.overwrite),
|
||||
)
|
||||
|
||||
batches2 = []
|
||||
current_batch2 = []
|
||||
current_h2 = 0
|
||||
last_sid2 = None
|
||||
for r in rendered:
|
||||
sid = r[0]
|
||||
img_h = r[2].height
|
||||
# Split if we exceed max height AND we are on a new student
|
||||
if current_batch2 and current_h2 + img_h > 1.1 *MAX_HEIGHT_PX \
|
||||
and sid != last_sid2:
|
||||
batches2.append(current_batch2)
|
||||
current_batch2 = []
|
||||
current_h2 = 0
|
||||
current_batch2.append(r)
|
||||
current_h2 += img_h
|
||||
last_sid2 = sid
|
||||
if current_batch2:
|
||||
batches2.append(current_batch2)
|
||||
|
||||
if len(batches2) < len(batches):
|
||||
batches = batches2
|
||||
|
||||
for i, batch in enumerate(batches, 1):
|
||||
save_batch(batch, unique_prefix, max_existing_group + i, root_dir, args.overwrite)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
raise SystemExit(main())
|
||||
|
||||
+291
-244
@@ -1,283 +1,330 @@
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import threading
|
||||
import img2pdf
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
from PIL import Image, ImageFont
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
# Fix for Matplotlib in threads: Set backend to non-interactive 'Agg'
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import annotating
|
||||
import utils
|
||||
from annotating import MARGIN_LEFT, ANNOT_WIDTH
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
execute,
|
||||
read_json,
|
||||
target_parser,
|
||||
workspace_from_target,
|
||||
)
|
||||
from copienator.annotation_data import load_annotation_data
|
||||
from copienator.filesystem import staged_directory
|
||||
from utils import natural_key
|
||||
|
||||
# Global lock for Matplotlib/Latex rendering to prevent race conditions
|
||||
LATEX_LOCK = threading.Lock()
|
||||
DPI = 100
|
||||
BOX_SIZE = 30
|
||||
SCORE_BOX_SIZE = 40
|
||||
SCORES = [x * 0.5 for x in range(10)] # 0.0 to 4.5
|
||||
SCORES = [value * 0.5 for value in range(10)]
|
||||
EXPECTED_OUTPUTS = ("bnote.json", "checkboxes.json", "Reference.jpg", "Concat.pdf")
|
||||
|
||||
try:
|
||||
CHECKBOX_FONT = ImageFont.truetype("DejaVuSans.ttf", 20)
|
||||
except IOError:
|
||||
except OSError:
|
||||
try:
|
||||
CHECKBOX_FONT = ImageFont.truetype("arial.ttf", 20)
|
||||
except IOError:
|
||||
except OSError:
|
||||
CHECKBOX_FONT = ImageFont.load_default()
|
||||
|
||||
|
||||
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]
|
||||
|
||||
def safe_render_latex(*args, **kwargs):
|
||||
"""Thread-safe wrapper for latex rendering."""
|
||||
# with LATEX_LOCK:
|
||||
# return annotating.render_latex_text(*args, **kwargs)
|
||||
return annotating.render_real_latex_text(*args, **kwargs)
|
||||
|
||||
class CheckboxRenderer:
|
||||
def __init__(self, label_name):
|
||||
self.label = label_name
|
||||
self.checkboxes = [] # List of {type, box, etc.}
|
||||
self.checkboxes = []
|
||||
|
||||
def callback(self, kind, draw, pos, meta):
|
||||
"""
|
||||
Called by compose_label_image during rendering.
|
||||
pos contains {x, y, w, h} or {box}.
|
||||
meta contains {data, index, etc.}
|
||||
"""
|
||||
if kind == "header_item":
|
||||
# meta['data'] is either result object (for score) or feedback object
|
||||
if meta.get("type") == "score":
|
||||
# Draw score boxes
|
||||
start_x = pos['w'] + 20
|
||||
for val in SCORES:
|
||||
box = draw_checkbox(draw, start_x, pos['y'] + 25,
|
||||
SCORE_BOX_SIZE, str(val))
|
||||
self.checkboxes.append({
|
||||
"type": "score", "label": self.label, "value": val,
|
||||
"rel_box": box # Will be adjusted for global Y later
|
||||
})
|
||||
start_x += SCORE_BOX_SIZE + 45
|
||||
|
||||
start_x += SCORE_BOX_SIZE + 60
|
||||
box = draw_checkbox(draw, start_x, pos['y'] + 25, SCORE_BOX_SIZE, "clr")
|
||||
self.checkboxes.append({
|
||||
"type": "clear_all", "label": self.label,
|
||||
"rel_box": box
|
||||
})
|
||||
|
||||
elif meta.get("type") == "global_fb":
|
||||
# Draw delete box for global feedback
|
||||
bx = pos['w'] - BOX_SIZE - 5
|
||||
by = pos['y'] + 5
|
||||
box = draw_checkbox(draw, bx, by, BOX_SIZE)
|
||||
self.checkboxes.append({
|
||||
"type": "del_global", "label": self.label, "index": meta["index"],
|
||||
"rel_box": box, "text_preview": meta["data"]["text"][:20]
|
||||
})
|
||||
|
||||
elif kind == "local_rect":
|
||||
# Delete rect checkbox
|
||||
b = pos['box'] # [xmin, ymin, xmax, ymax]
|
||||
box = draw_checkbox(draw, b[2] - BOX_SIZE, b[1], BOX_SIZE)
|
||||
self.checkboxes.append({
|
||||
"type": "del_local_rect", "label": self.label, "index": meta["index"],
|
||||
"final_box": box, "text_preview": meta["data"]["text"][:20]
|
||||
})
|
||||
|
||||
elif kind == "local_text":
|
||||
# Delete whole local feedback checkbox
|
||||
bx = pos['x'] + pos['w'] - BOX_SIZE
|
||||
by = pos['y']
|
||||
box = draw_checkbox(draw, bx, by, BOX_SIZE)
|
||||
self.checkboxes.append({
|
||||
"type": "del_local", "label": self.label, "index": meta["index"],
|
||||
"final_box": box, "text_preview": meta["data"]["text"][:20]
|
||||
})
|
||||
|
||||
from utils import natural_key
|
||||
|
||||
def process_student(args):
|
||||
"""Thread worker: Processes one student."""
|
||||
root_dir, student_id, labels, overwrite, sub_folder = args
|
||||
|
||||
output_dir = os.path.join(root_dir, sub_folder, f"Copie{student_id}")
|
||||
|
||||
if os.path.exists(output_dir):
|
||||
if not overwrite:
|
||||
print(f"Skipping {student_id}: Output already exists.")
|
||||
return
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
print(f"Generating Checkable PDF for: {student_id}")
|
||||
os.makedirs(output_dir)
|
||||
|
||||
label_images = []
|
||||
# ... (rest of the function remains exactly the same)
|
||||
all_checkboxes = []
|
||||
bnote_entries = [] # For bnote.json
|
||||
|
||||
sorted_labels = sorted(labels.items(), key=lambda x: natural_key(x[0]))
|
||||
|
||||
for label, content in sorted_labels:
|
||||
pdf_path = content['pdf_path']
|
||||
if not os.path.exists(pdf_path): continue
|
||||
|
||||
base_img, _, _ = annotating.make_base_image(pdf_path)
|
||||
|
||||
# Initialize the hook
|
||||
cb_renderer = CheckboxRenderer(label)
|
||||
|
||||
# Render using the shared engine
|
||||
final_img, header_h = annotating.compose_label_image(
|
||||
base_img, label, content['result'], content['coordinates'][0],
|
||||
draw_callback=cb_renderer.callback
|
||||
start_x = pos["w"] + 20
|
||||
for value in SCORES:
|
||||
box = draw_checkbox(
|
||||
draw,
|
||||
start_x,
|
||||
pos["y"] + 25,
|
||||
SCORE_BOX_SIZE,
|
||||
str(value),
|
||||
)
|
||||
self.checkboxes.append(
|
||||
{
|
||||
"type": "score",
|
||||
"label": self.label,
|
||||
"value": value,
|
||||
"rel_box": box,
|
||||
}
|
||||
)
|
||||
start_x += SCORE_BOX_SIZE + 45
|
||||
start_x += SCORE_BOX_SIZE + 60
|
||||
box = draw_checkbox(
|
||||
draw,
|
||||
start_x,
|
||||
pos["y"] + 25,
|
||||
SCORE_BOX_SIZE,
|
||||
"clr",
|
||||
)
|
||||
self.checkboxes.append(
|
||||
{"type": "clear_all", "label": self.label, "rel_box": box}
|
||||
)
|
||||
elif meta.get("type") == "global_fb":
|
||||
box = draw_checkbox(
|
||||
draw,
|
||||
pos["w"] - BOX_SIZE - 5,
|
||||
pos["y"] + 5,
|
||||
BOX_SIZE,
|
||||
)
|
||||
self.checkboxes.append(
|
||||
{
|
||||
"type": "del_global",
|
||||
"label": self.label,
|
||||
"index": meta["index"],
|
||||
"rel_box": box,
|
||||
"text_preview": meta["data"]["text"][:20],
|
||||
}
|
||||
)
|
||||
elif kind == "local_rect":
|
||||
rectangle = pos["box"]
|
||||
box = draw_checkbox(
|
||||
draw,
|
||||
rectangle[2] - BOX_SIZE,
|
||||
rectangle[1],
|
||||
BOX_SIZE,
|
||||
)
|
||||
self.checkboxes.append(
|
||||
{
|
||||
"type": "del_local_rect",
|
||||
"label": self.label,
|
||||
"index": meta["index"],
|
||||
"final_box": box,
|
||||
"text_preview": meta["data"]["text"][:20],
|
||||
}
|
||||
)
|
||||
elif kind == "local_text":
|
||||
box = draw_checkbox(
|
||||
draw,
|
||||
pos["x"] + pos["w"] - BOX_SIZE,
|
||||
pos["y"],
|
||||
BOX_SIZE,
|
||||
)
|
||||
self.checkboxes.append(
|
||||
{
|
||||
"type": "del_local",
|
||||
"label": self.label,
|
||||
"index": meta["index"],
|
||||
"final_box": box,
|
||||
"text_preview": meta["data"]["text"][:20],
|
||||
}
|
||||
)
|
||||
if final_img == None:
|
||||
continue
|
||||
|
||||
label_images.append(final_img)
|
||||
all_checkboxes.append(cb_renderer.checkboxes)
|
||||
bnote_entries.append({
|
||||
|
||||
def _output_complete(output_dir: Path) -> bool:
|
||||
return all((output_dir / name).is_file() for name in EXPECTED_OUTPUTS)
|
||||
|
||||
|
||||
def _render_student(
|
||||
workspace: EvaluationWorkspace,
|
||||
student_id: str,
|
||||
labels: dict[str, dict[str, Any]],
|
||||
*,
|
||||
overwrite: bool,
|
||||
output_mode: str,
|
||||
) -> str:
|
||||
output_dir = 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"
|
||||
|
||||
print(f"Generating checkable PDF for: {student_id}")
|
||||
label_images: list[Image.Image] = []
|
||||
checkbox_groups: list[list[dict[str, Any]]] = []
|
||||
bnote_entries: list[dict[str, Any]] = []
|
||||
problems = False
|
||||
|
||||
for label, content in sorted(labels.items(), key=lambda item: natural_key(item[0])):
|
||||
pdf_path = Path(content["pdf_path"])
|
||||
if not pdf_path.exists():
|
||||
print(f"Warning: answer PDF not found: {pdf_path}")
|
||||
problems = True
|
||||
continue
|
||||
base_image, _, _ = annotating.make_base_image(pdf_path)
|
||||
checkbox_renderer = CheckboxRenderer(label)
|
||||
final_image, header_height = annotating.compose_label_image(
|
||||
base_image,
|
||||
label,
|
||||
content["result"],
|
||||
content["coordinates"][0],
|
||||
draw_callback=checkbox_renderer.callback,
|
||||
)
|
||||
if final_image is None:
|
||||
continue
|
||||
label_images.append(final_image)
|
||||
checkbox_groups.append(checkbox_renderer.checkboxes)
|
||||
bnote_entries.append(
|
||||
{
|
||||
"id": student_id,
|
||||
"label": label,
|
||||
"header_height": header_h,
|
||||
# hmin/hmax will be filled during concatenation
|
||||
"img_h": final_img.height
|
||||
})
|
||||
|
||||
if not label_images: return
|
||||
|
||||
# Concatenate
|
||||
max_w = max(i.width for i in label_images)
|
||||
total_h = sum(i.height for i in label_images)
|
||||
concat_img = Image.new("RGB", (max_w, total_h), "white")
|
||||
|
||||
final_json_map = []
|
||||
current_y = 0
|
||||
|
||||
for idx, (img, boxes) in enumerate(zip(label_images, all_checkboxes)):
|
||||
concat_img.paste(img, (0, current_y))
|
||||
|
||||
bnote_entries[idx]["hmin"] = current_y
|
||||
bnote_entries[idx]["hmax"] = current_y + img.height
|
||||
del bnote_entries[idx]["img_h"] # Clean up temp data
|
||||
|
||||
# Adjust coordinates for concatenated image
|
||||
for item in boxes:
|
||||
# item might have 'rel_box' (header) or 'final_box' (local)
|
||||
# Both were relative to the label image. We just add current_y.
|
||||
b = item.get('final_box') or item.get('rel_box')
|
||||
item['global_box'] = [b[0], b[1] + current_y, b[2], b[3] + current_y]
|
||||
final_json_map.append(item)
|
||||
|
||||
current_y += img.height
|
||||
|
||||
bnote_data = {
|
||||
"width": max_w,
|
||||
"height": total_h,
|
||||
"images": bnote_entries
|
||||
"header_height": header_height,
|
||||
"img_h": final_image.height,
|
||||
}
|
||||
with open(os.path.join(output_dir, "bnote.json"), "w") as f:
|
||||
json.dump(bnote_data, f, indent=2)
|
||||
)
|
||||
|
||||
with open(os.path.join(output_dir, "checkboxes.json"), "w") as f:
|
||||
json.dump(final_json_map, f, indent=2)
|
||||
if not label_images:
|
||||
print(f"Warning: no annotations could be rendered for Copie{student_id}")
|
||||
return "partial"
|
||||
|
||||
temp_img_path = os.path.join(output_dir, "Reference.jpg") # Can't use png here
|
||||
concat_img.save(temp_img_path, quality=90)
|
||||
max_width = max(image.width for image in label_images)
|
||||
total_height = sum(image.height for image in label_images)
|
||||
concatenated = Image.new("RGB", (max_width, total_height), "white")
|
||||
checkbox_map: list[dict[str, Any]] = []
|
||||
current_y = 0
|
||||
for index, (image, checkboxes) in enumerate(
|
||||
zip(label_images, checkbox_groups, strict=True)
|
||||
):
|
||||
concatenated.paste(image, (0, current_y))
|
||||
bnote_entries[index]["hmin"] = current_y
|
||||
bnote_entries[index]["hmax"] = current_y + image.height
|
||||
del bnote_entries[index]["img_h"]
|
||||
for item in checkboxes:
|
||||
box = item.get("final_box") or item.get("rel_box")
|
||||
item["global_box"] = [
|
||||
box[0],
|
||||
box[1] + current_y,
|
||||
box[2],
|
||||
box[3] + current_y,
|
||||
]
|
||||
checkbox_map.append(item)
|
||||
current_y += image.height
|
||||
|
||||
pdf_path = os.path.join(output_dir, "Concat.pdf")
|
||||
w, h = concat_img.size
|
||||
c = canvas.Canvas(pdf_path, pagesize=(w, h))
|
||||
c.drawImage(temp_img_path, 0, 0, width=w, height=h)
|
||||
c.save()
|
||||
with staged_directory(output_dir) as staging:
|
||||
atomic_write_json(
|
||||
staging / "bnote.json",
|
||||
{"width": max_width, "height": total_height, "images": bnote_entries},
|
||||
)
|
||||
atomic_write_json(staging / "checkboxes.json", checkbox_map)
|
||||
reference = staging / "Reference.jpg"
|
||||
concatenated.save(reference, quality=90)
|
||||
pdf_path = staging / "Concat.pdf"
|
||||
pdf_canvas = canvas.Canvas(str(pdf_path), pagesize=(max_width, total_height))
|
||||
pdf_canvas.drawImage(
|
||||
str(reference),
|
||||
0,
|
||||
0,
|
||||
width=max_width,
|
||||
height=total_height,
|
||||
)
|
||||
pdf_canvas.save()
|
||||
return "partial" if problems else "success"
|
||||
|
||||
|
||||
def _copy_id_from_target(workspace: EvaluationWorkspace, target: Path) -> str | None:
|
||||
if target == workspace.root:
|
||||
return None
|
||||
match = re.search(r"Copie(\d+)", target.name)
|
||||
if match is None:
|
||||
raise CliError(f"Could not extract a copy id from target: {target}")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _load_refaire(workspace: EvaluationWorkspace):
|
||||
workspace.require_files("refaire.json")
|
||||
loaded = read_json(workspace.refaire_file)
|
||||
if not isinstance(loaded, list):
|
||||
raise CliError("refaire.json must contain a JSON array")
|
||||
return loaded
|
||||
|
||||
|
||||
def run(
|
||||
workspace: EvaluationWorkspace,
|
||||
target: Path,
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
refaire: bool = False,
|
||||
) -> ExitCode:
|
||||
workspace.require_files("labels", "correction.json")
|
||||
workspace.require_directories("Copies", "Par label")
|
||||
utils.read_all_labels(workspace.root)
|
||||
copy_id = _copy_id_from_target(workspace, target)
|
||||
refaire_list = _load_refaire(workspace) if refaire else None
|
||||
loaded = load_annotation_data(
|
||||
workspace,
|
||||
refaire_list=refaire_list,
|
||||
copy_id=None if refaire else copy_id,
|
||||
)
|
||||
for warning in loaded.warnings:
|
||||
print(f"Warning: {warning}")
|
||||
if not loaded.data:
|
||||
print("Warning: no annotation data was found.")
|
||||
return ExitCode.PARTIAL
|
||||
|
||||
output_mode = "refaire" if refaire else "checks"
|
||||
tasks = sorted(loaded.data.items(), key=lambda item: natural_key(item[0]))
|
||||
statuses: list[str] = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [
|
||||
executor.submit(
|
||||
_render_student,
|
||||
workspace,
|
||||
student_id,
|
||||
labels,
|
||||
overwrite=overwrite,
|
||||
output_mode=output_mode,
|
||||
)
|
||||
for student_id, labels in tasks
|
||||
]
|
||||
for future in futures:
|
||||
statuses.append(future.result())
|
||||
if loaded.warnings or "partial" in statuses:
|
||||
return ExitCode.PARTIAL
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
|
||||
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(
|
||||
"--refaire",
|
||||
action="store_true",
|
||||
help="Process only entries from refaire.json",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
|
||||
def handler(args: argparse.Namespace) -> ExitCode:
|
||||
workspace, target = workspace_from_target(args)
|
||||
return run(
|
||||
workspace,
|
||||
target,
|
||||
overwrite=args.overwrite,
|
||||
refaire=args.refaire,
|
||||
)
|
||||
|
||||
return execute(parser, argv, handler)
|
||||
|
||||
import argparse # Added
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Generate annotated PDFs.")
|
||||
parser.add_argument("input_path", help="Directory or specific file path")
|
||||
parser.add_argument("--overwrite", action="store_true", help="Overwrite existing output files")
|
||||
parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json") # ADD THIS LINE
|
||||
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
input_path = args.input_path
|
||||
overwrite = args.overwrite # Capture flag
|
||||
target_id = None
|
||||
|
||||
# Detect if input is a specific file
|
||||
if os.path.isfile(input_path):
|
||||
root_dir = os.path.dirname(input_path) or "."
|
||||
# Extract ID from filename (e.g., Copie40.pdf -> 40)
|
||||
match = re.search(r'Copie(\d+)', os.path.basename(input_path))
|
||||
if match:
|
||||
target_id = match.group(1)
|
||||
else:
|
||||
print("Error: Could not extract student ID from filename.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
root_dir = input_path
|
||||
|
||||
if os.path.exists(os.path.join(root_dir, "labels")):
|
||||
utils.read_all_labels(root_dir)
|
||||
|
||||
if not args.refaire:
|
||||
results = annotating.make_dictionary(root_dir)
|
||||
|
||||
if args.refaire:
|
||||
refaire_path = os.path.join(root_dir, "refaire.json")
|
||||
if os.path.exists(refaire_path):
|
||||
with open(refaire_path, "r", encoding="utf-8") as f:
|
||||
refaire_list = json.load(f)
|
||||
results = annotating.make_dictionary(root_dir,
|
||||
refaire=True,refaire_list=refaire_list)
|
||||
|
||||
filtered_results = {}
|
||||
for copie_name, labels_to_redo in refaire_list:
|
||||
sid = copie_name.replace("Copie", "") # Extract "01" from "Copie01"
|
||||
if sid in results:
|
||||
if not labels_to_redo:
|
||||
# Empty list: keep all labels for this Copie
|
||||
filtered_results[sid] = results[sid]
|
||||
else:
|
||||
# Keep only the requested labels
|
||||
filtered_results[sid] = {
|
||||
lbl: data for lbl, data in results[sid].items()
|
||||
if lbl in labels_to_redo
|
||||
}
|
||||
results = filtered_results
|
||||
else:
|
||||
print(f"Warning: --refaire flag used, but {refaire_path} not found.")
|
||||
elif target_id:
|
||||
if target_id in results:
|
||||
results = {target_id: results[target_id]}
|
||||
else:
|
||||
print(f"Student ID {target_id} not found in directory scan.")
|
||||
results = {}
|
||||
|
||||
sub_folder = "BRnot" if args.refaire else "Bnot"
|
||||
|
||||
tasks = sorted([(root_dir, sid, lbls, overwrite, sub_folder)
|
||||
for sid, lbls in results.items()])
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = executor.map(process_student, tasks)
|
||||
try:
|
||||
for _ in results:
|
||||
pass
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -7,7 +7,9 @@ from .cli import (
|
||||
evaluation_workspace,
|
||||
execute,
|
||||
standard_parser,
|
||||
target_parser,
|
||||
workspace_from_args,
|
||||
workspace_from_target,
|
||||
)
|
||||
from .json_io import (
|
||||
JsonLockTimeout,
|
||||
@@ -37,5 +39,7 @@ __all__ = [
|
||||
"execute",
|
||||
"read_json",
|
||||
"standard_parser",
|
||||
"target_parser",
|
||||
"workspace_from_args",
|
||||
"workspace_from_target",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from .json_io import read_json
|
||||
from .workspace import EvaluationWorkspace
|
||||
|
||||
AnnotationData = dict[str, dict[str, dict[str, Any]]]
|
||||
RefaireList = list[list[Any]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GroupCoordinates:
|
||||
minimum: int
|
||||
maximum: int
|
||||
width: int
|
||||
height: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AnnotationLoadResult:
|
||||
data: AnnotationData
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
def _coordinate_index(
|
||||
workspace: EvaluationWorkspace,
|
||||
) -> tuple[dict[tuple[str, str], GroupCoordinates], list[str]]:
|
||||
index: dict[tuple[str, str], GroupCoordinates] = {}
|
||||
warnings: list[str] = []
|
||||
if not workspace.groups_dir.is_dir():
|
||||
return index, [f"Group directory not found: {workspace.groups_dir}"]
|
||||
|
||||
for metadata_path in sorted(workspace.groups_dir.glob("*/Group_*.json")):
|
||||
image_path = metadata_path.with_suffix(".jpg")
|
||||
try:
|
||||
entries = read_json(metadata_path)
|
||||
if not isinstance(entries, list):
|
||||
raise TypeError("expected a JSON array")
|
||||
with Image.open(image_path) as image:
|
||||
width, height = image.size
|
||||
for entry in entries:
|
||||
copy_id = str(entry[0])
|
||||
minimum = int(entry[1])
|
||||
maximum = int(entry[2])
|
||||
label = str(entry[4])
|
||||
index.setdefault(
|
||||
(label, copy_id),
|
||||
GroupCoordinates(minimum, maximum, width, height),
|
||||
)
|
||||
except (IndexError, OSError, TypeError, ValueError) as exc:
|
||||
warnings.append(f"Could not read group metadata {metadata_path}: {exc}")
|
||||
return index, warnings
|
||||
|
||||
|
||||
def _scaled_result(result: dict[str, Any], coordinates: GroupCoordinates | None):
|
||||
scaled = copy.deepcopy(result)
|
||||
if coordinates is None:
|
||||
return scaled
|
||||
for feedback in scaled.get("feedback", []):
|
||||
box = feedback.get("box_2d")
|
||||
if not box or len(box) != 4:
|
||||
continue
|
||||
box[0] = int(box[0] * coordinates.height) // 1000
|
||||
box[2] = int(box[2] * coordinates.height) // 1000
|
||||
box[1] = int(box[1] * coordinates.width) // 1000
|
||||
box[3] = int(box[3] * coordinates.width) // 1000
|
||||
return scaled
|
||||
|
||||
|
||||
def _answer_pdf(
|
||||
workspace: EvaluationWorkspace,
|
||||
copy_id: str,
|
||||
label: str,
|
||||
suffix: str = "",
|
||||
) -> Path:
|
||||
copy_dir = workspace.copies_dir / f"Copie{copy_id}"
|
||||
preferred = copy_dir / f"{label}{suffix}.pdf"
|
||||
if preferred.exists():
|
||||
return preferred
|
||||
for candidate in (
|
||||
copy_dir / f"{label}.pdf",
|
||||
copy_dir / f"{label}_new.pdf",
|
||||
):
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return preferred
|
||||
|
||||
|
||||
def _dummy_entry(
|
||||
workspace: EvaluationWorkspace,
|
||||
copy_id: str,
|
||||
label: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"pdf_path": _answer_pdf(workspace, copy_id, label),
|
||||
"result": {
|
||||
"score": 0.0,
|
||||
"feedback": [],
|
||||
"error": "non traité",
|
||||
},
|
||||
"coordinates": (0, 0),
|
||||
"issues": ["No correction result was available for this answer."],
|
||||
}
|
||||
|
||||
|
||||
def _apply_refaire_filter(
|
||||
workspace: EvaluationWorkspace,
|
||||
data: AnnotationData,
|
||||
refaire_list: RefaireList,
|
||||
warnings: list[str],
|
||||
) -> AnnotationData:
|
||||
filtered: AnnotationData = {}
|
||||
for raw_entry in refaire_list:
|
||||
if not isinstance(raw_entry, list) or len(raw_entry) != 2:
|
||||
warnings.append(f"Ignoring malformed refaire entry: {raw_entry!r}")
|
||||
continue
|
||||
copy_name, requested_labels = raw_entry
|
||||
copy_id = str(copy_name).removeprefix("Copie")
|
||||
available = data.get(copy_id, {})
|
||||
if not requested_labels:
|
||||
filtered[copy_id] = dict(available)
|
||||
continue
|
||||
selected: dict[str, dict[str, Any]] = {}
|
||||
for raw_label in requested_labels:
|
||||
label = str(raw_label)
|
||||
selected[label] = (
|
||||
available[label]
|
||||
if label in available
|
||||
else _dummy_entry(workspace, copy_id, label)
|
||||
)
|
||||
filtered[copy_id] = selected
|
||||
return filtered
|
||||
|
||||
|
||||
def load_annotation_data(
|
||||
workspace: EvaluationWorkspace,
|
||||
*,
|
||||
refaire_list: RefaireList | None = None,
|
||||
copy_id: str | None = None,
|
||||
) -> AnnotationLoadResult:
|
||||
workspace.require_files("correction.json")
|
||||
corrections = read_json(workspace.correction_file)
|
||||
if not isinstance(corrections, dict):
|
||||
raise TypeError("correction.json must contain a JSON object")
|
||||
|
||||
coordinate_index, warnings = _coordinate_index(workspace)
|
||||
data: AnnotationData = {}
|
||||
for label, raw_batches in corrections.items():
|
||||
if not isinstance(raw_batches, list):
|
||||
warnings.append(f"Ignoring malformed correction batches for {label!r}")
|
||||
continue
|
||||
for raw_batch in raw_batches:
|
||||
if not isinstance(raw_batch, list):
|
||||
warnings.append(f"Ignoring malformed correction batch for {label!r}")
|
||||
continue
|
||||
for item in raw_batch:
|
||||
if not isinstance(item, dict) or not isinstance(item.get("result"), dict):
|
||||
warnings.append(f"Ignoring malformed correction item for {label!r}")
|
||||
continue
|
||||
student_id = str(item.get("id", ""))
|
||||
if not student_id:
|
||||
warnings.append(f"Ignoring correction item without an id for {label!r}")
|
||||
continue
|
||||
result = item["result"]
|
||||
suffix = str(result.get("suffix", ""))
|
||||
if suffix == "_old":
|
||||
continue
|
||||
coordinates = coordinate_index.get((str(label), student_id))
|
||||
issues: list[str] = []
|
||||
if coordinates is None:
|
||||
issues.append("Group coordinates were not found.")
|
||||
pdf_path = _answer_pdf(workspace, student_id, str(label), suffix)
|
||||
if not pdf_path.exists():
|
||||
issues.append(f"Answer PDF not found: {pdf_path}")
|
||||
data.setdefault(student_id, {})[str(label)] = {
|
||||
"pdf_path": pdf_path,
|
||||
"result": _scaled_result(result, coordinates),
|
||||
"coordinates": (
|
||||
(coordinates.minimum, coordinates.maximum)
|
||||
if coordinates is not None
|
||||
else (0, 0)
|
||||
),
|
||||
"issues": issues,
|
||||
}
|
||||
warnings.extend(
|
||||
f"Copie{student_id} {label}: {issue}" for issue in issues
|
||||
)
|
||||
|
||||
if refaire_list is not None:
|
||||
data = _apply_refaire_filter(workspace, data, refaire_list, warnings)
|
||||
if copy_id is not None:
|
||||
data = {copy_id: data[copy_id]} if copy_id in data else {}
|
||||
if not data:
|
||||
warnings.append(f"Copy id {copy_id} was not found in correction.json")
|
||||
return AnnotationLoadResult(data, warnings)
|
||||
@@ -50,6 +50,16 @@ def evaluation_parser(description: str) -> argparse.ArgumentParser:
|
||||
return parser
|
||||
|
||||
|
||||
def target_parser(description: str) -> argparse.ArgumentParser:
|
||||
parser = standard_parser(description)
|
||||
parser.add_argument(
|
||||
"target",
|
||||
type=Path,
|
||||
help="Evaluation directory or nested file to process",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def evaluation_workspace(
|
||||
path: str | Path,
|
||||
*,
|
||||
@@ -77,6 +87,21 @@ def workspace_from_args(
|
||||
return evaluation_workspace(args.evaluation, repository=repository)
|
||||
|
||||
|
||||
def workspace_from_target(
|
||||
args: argparse.Namespace,
|
||||
*,
|
||||
repository: str | Path | None = None,
|
||||
) -> tuple[EvaluationWorkspace, Path]:
|
||||
target = Path(args.target).expanduser().resolve()
|
||||
if not target.exists():
|
||||
raise CliError(f"Target does not exist: {target}", ExitCode.INVALID_WORKSPACE)
|
||||
repository_path = Path(repository) if repository is not None else None
|
||||
if target.is_dir() and EvaluationWorkspace.looks_like_evaluation(target):
|
||||
return EvaluationWorkspace(target, repository_path), target
|
||||
workspace = EvaluationWorkspace.discover(target, repository=repository_path)
|
||||
return workspace, target
|
||||
|
||||
|
||||
def execute(
|
||||
parser: argparse.ArgumentParser,
|
||||
argv: Sequence[str] | None,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _remove_path(path: Path) -> None:
|
||||
if path.is_symlink() or path.is_file():
|
||||
path.unlink()
|
||||
elif path.exists():
|
||||
shutil.rmtree(path)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def staged_directory(destination: str | Path):
|
||||
"""Build a directory beside its destination and replace on success."""
|
||||
target = Path(destination)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
token = uuid.uuid4().hex
|
||||
staging = target.with_name(f".{target.name}.{token}.tmp")
|
||||
backup = target.with_name(f".{target.name}.{token}.backup")
|
||||
staging.mkdir()
|
||||
committed = False
|
||||
try:
|
||||
yield staging
|
||||
if target.exists():
|
||||
target.replace(backup)
|
||||
try:
|
||||
staging.replace(target)
|
||||
committed = True
|
||||
except Exception:
|
||||
if backup.exists() and not target.exists():
|
||||
backup.replace(target)
|
||||
raise
|
||||
if backup.exists():
|
||||
_remove_path(backup)
|
||||
finally:
|
||||
if staging.exists():
|
||||
_remove_path(staging)
|
||||
if not committed and backup.exists() and not target.exists():
|
||||
backup.replace(target)
|
||||
+207
-1
@@ -13,6 +13,7 @@ from contextlib import redirect_stderr
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
from copienator import (
|
||||
@@ -22,7 +23,10 @@ from copienator import (
|
||||
atomic_update_json,
|
||||
atomic_write_json,
|
||||
read_json,
|
||||
workspace_from_target,
|
||||
)
|
||||
from copienator.annotation_data import AnnotationLoadResult, load_annotation_data
|
||||
from copienator.filesystem import staged_directory
|
||||
from copienator_gui.app import process_status
|
||||
from copienator_gui.diagnostics import collect_diagnostics
|
||||
from copienator_gui.runner import ProcessRunner
|
||||
@@ -156,10 +160,95 @@ class AtomicJsonTests(unittest.TestCase):
|
||||
self.assertEqual(persisted["steps"]["labels"]["status"], "success")
|
||||
|
||||
|
||||
class AnnotationDataTests(unittest.TestCase):
|
||||
def test_loader_indexes_coordinates_without_mutating_correction(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
evaluation = Path(directory) / "Exam"
|
||||
copy_dir = evaluation / "Copies" / "Copie01"
|
||||
group_dir = evaluation / "Par label" / "Ex 1"
|
||||
copy_dir.mkdir(parents=True)
|
||||
group_dir.mkdir(parents=True)
|
||||
(copy_dir / "Ex 1_new.pdf").write_bytes(b"pdf")
|
||||
correction = {
|
||||
"Ex 1": [
|
||||
[
|
||||
{
|
||||
"id": "01",
|
||||
"result": {
|
||||
"suffix": "_new",
|
||||
"feedback": [{"text": "x", "box_2d": [100, 200, 300, 400]}],
|
||||
},
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
atomic_write_json(evaluation / "correction.json", correction)
|
||||
atomic_write_json(
|
||||
group_dir / "Group_1.json",
|
||||
[["01", 10, 90, 1.0, "Ex 1"]],
|
||||
)
|
||||
Image.new("RGB", (100, 200), "white").save(group_dir / "Group_1.jpg")
|
||||
|
||||
loaded = load_annotation_data(EvaluationWorkspace(evaluation))
|
||||
item = loaded.data["01"]["Ex 1"]
|
||||
self.assertEqual(item["pdf_path"], copy_dir / "Ex 1_new.pdf")
|
||||
self.assertEqual(item["coordinates"], (10, 90))
|
||||
self.assertEqual(item["result"]["feedback"][0]["box_2d"], [20, 20, 60, 40])
|
||||
self.assertEqual(read_json(evaluation / "correction.json"), correction)
|
||||
self.assertEqual(loaded.warnings, [])
|
||||
|
||||
def test_refaire_filter_adds_a_missing_correction_entry(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
evaluation = Path(directory) / "Exam"
|
||||
copy_dir = evaluation / "Copies" / "Copie01"
|
||||
copy_dir.mkdir(parents=True)
|
||||
(evaluation / "Par label").mkdir()
|
||||
(copy_dir / "Ex 2.pdf").write_bytes(b"pdf")
|
||||
atomic_write_json(evaluation / "correction.json", {})
|
||||
|
||||
loaded = load_annotation_data(
|
||||
EvaluationWorkspace(evaluation),
|
||||
refaire_list=[["Copie01", ["Ex 2"]]],
|
||||
)
|
||||
item = loaded.data["01"]["Ex 2"]
|
||||
self.assertEqual(item["pdf_path"], copy_dir / "Ex 2.pdf")
|
||||
self.assertEqual(item["result"]["error"], "non traité")
|
||||
|
||||
def test_staged_directory_preserves_then_replaces_destination(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
destination = Path(directory) / "output"
|
||||
destination.mkdir()
|
||||
(destination / "state.txt").write_text("old", encoding="utf-8")
|
||||
|
||||
with self.assertRaises(RuntimeError), staged_directory(
|
||||
destination
|
||||
) as staging:
|
||||
(staging / "state.txt").write_text("broken", encoding="utf-8")
|
||||
raise RuntimeError("rendering failed")
|
||||
self.assertEqual(
|
||||
(destination / "state.txt").read_text(encoding="utf-8"), "old"
|
||||
)
|
||||
|
||||
with staged_directory(destination) as staging:
|
||||
(staging / "state.txt").write_text("new", encoding="utf-8")
|
||||
self.assertEqual(
|
||||
(destination / "state.txt").read_text(encoding="utf-8"), "new"
|
||||
)
|
||||
leftovers = [path for path in destination.parent.iterdir() if path.name.startswith(".output.")]
|
||||
self.assertEqual(leftovers, [])
|
||||
|
||||
|
||||
class StandardCliTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.modules = {
|
||||
"annotating": load_script_module("annotating.py", "annotating"),
|
||||
"annotating_with_checks": load_script_module(
|
||||
"annotating_with_checks.py", "annotating_with_checks"
|
||||
),
|
||||
"annotating_by_label": load_script_module(
|
||||
"annotating_by_label.py", "annotating_by_label"
|
||||
),
|
||||
"copies_tools": load_script_module(
|
||||
"copies_tools.py", "copienator_copies_tools_test"
|
||||
),
|
||||
@@ -192,6 +281,9 @@ class StandardCliTests(unittest.TestCase):
|
||||
"resolve_manual": [missing],
|
||||
"verify_groups": [missing],
|
||||
"copies_tools": ["rotate", missing],
|
||||
"annotating": [missing],
|
||||
"annotating_with_checks": [missing],
|
||||
"annotating_by_label": [missing],
|
||||
}
|
||||
for name, arguments in invocations.items():
|
||||
with self.subTest(script=name), redirect_stderr(io.StringIO()):
|
||||
@@ -259,6 +351,21 @@ class StandardCliTests(unittest.TestCase):
|
||||
"default",
|
||||
{"target": evaluation},
|
||||
),
|
||||
"annotating": (
|
||||
"annotation",
|
||||
"simple",
|
||||
{"target": evaluation, "overwrite": True},
|
||||
),
|
||||
"annotating_with_checks": (
|
||||
"annotation",
|
||||
"checks",
|
||||
{"target": evaluation, "overwrite": True, "refaire": True},
|
||||
),
|
||||
"annotating_by_label": (
|
||||
"annotation",
|
||||
"grouped",
|
||||
{"target": evaluation, "overwrite": True},
|
||||
),
|
||||
}
|
||||
for module_name, (step_id, variant_id, values) in cases.items():
|
||||
step = steps[step_id]
|
||||
@@ -266,7 +373,8 @@ class StandardCliTests(unittest.TestCase):
|
||||
command = build_command(REPOSITORY, step, variant, values, evaluation)
|
||||
with self.subTest(script=module_name):
|
||||
parsed = self.modules[module_name].build_parser().parse_args(command[3:])
|
||||
self.assertEqual(str(parsed.evaluation), evaluation)
|
||||
parsed_path = getattr(parsed, "evaluation", None) or parsed.target
|
||||
self.assertEqual(str(parsed_path), evaluation)
|
||||
|
||||
for step_id in ("rotate", "rename"):
|
||||
step = steps[step_id]
|
||||
@@ -458,6 +566,104 @@ class StandardCliTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(module.main([str(evaluation)]), 0)
|
||||
|
||||
def test_checked_annotation_discovers_workspace_from_copy_pdf(self) -> None:
|
||||
module = self.modules["annotating_with_checks"]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
evaluation = Path(directory) / "Exam"
|
||||
copy_pdf = evaluation / "Copies" / "Copie40.pdf"
|
||||
copy_pdf.parent.mkdir(parents=True)
|
||||
copy_pdf.write_bytes(b"pdf")
|
||||
args = module.build_parser().parse_args([str(copy_pdf)])
|
||||
workspace, target = workspace_from_target(args)
|
||||
self.assertEqual(workspace.root, evaluation)
|
||||
self.assertEqual(target, copy_pdf)
|
||||
self.assertEqual(module._copy_id_from_target(workspace, target), "40")
|
||||
|
||||
def test_checked_refaire_requires_refaire_file(self) -> None:
|
||||
module = self.modules["annotating_with_checks"]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
evaluation = Path(directory) / "Exam"
|
||||
(evaluation / "Copies").mkdir(parents=True)
|
||||
(evaluation / "Par label").mkdir()
|
||||
(evaluation / "labels").write_text("Ex 1\n", encoding="utf-8")
|
||||
atomic_write_json(evaluation / "correction.json", {})
|
||||
with redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(module.main([str(evaluation), "--refaire"]), 3)
|
||||
|
||||
def test_checked_render_failure_preserves_previous_student_output(self) -> None:
|
||||
module = self.modules["annotating_with_checks"]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
evaluation = Path(directory) / "Exam"
|
||||
copy_dir = evaluation / "Copies" / "Copie01"
|
||||
copy_dir.mkdir(parents=True)
|
||||
(evaluation / "Par label").mkdir()
|
||||
(evaluation / "labels").write_text("Ex 1\n", encoding="utf-8")
|
||||
atomic_write_json(evaluation / "correction.json", {})
|
||||
answer = copy_dir / "Ex 1.pdf"
|
||||
answer.write_bytes(b"pdf")
|
||||
previous = evaluation / "Bnot" / "Copie01"
|
||||
previous.mkdir(parents=True)
|
||||
(previous / "sentinel.txt").write_text("old", encoding="utf-8")
|
||||
loaded = AnnotationLoadResult(
|
||||
{
|
||||
"01": {
|
||||
"Ex 1": {
|
||||
"pdf_path": answer,
|
||||
"result": {"feedback": [], "score": 1},
|
||||
"coordinates": (0, 0),
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
with patch.object(
|
||||
module, "load_annotation_data", return_value=loaded
|
||||
), patch.object(
|
||||
self.modules["annotating"],
|
||||
"make_base_image",
|
||||
side_effect=RuntimeError("render failed"),
|
||||
), redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(module.main([str(evaluation), "--overwrite"]), 1)
|
||||
self.assertEqual(
|
||||
(previous / "sentinel.txt").read_text(encoding="utf-8"), "old"
|
||||
)
|
||||
|
||||
def test_grouped_overwrite_preserves_previous_output_when_incomplete(self) -> None:
|
||||
module = self.modules["annotating_by_label"]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
evaluation = Path(directory) / "Exam"
|
||||
(evaluation / "Copies").mkdir(parents=True)
|
||||
(evaluation / "Par label").mkdir()
|
||||
(evaluation / "labels").write_text("Ex 1\n", encoding="utf-8")
|
||||
(evaluation / "label_groups").write_text("Ex 1\n", encoding="utf-8")
|
||||
atomic_write_json(evaluation / "correction.json", {})
|
||||
previous = evaluation / "BGnot"
|
||||
previous.mkdir()
|
||||
(previous / "sentinel.txt").write_text("old", encoding="utf-8")
|
||||
loaded = AnnotationLoadResult(
|
||||
{"01": {"Ex 1": {"pdf_path": Path("missing")}}},
|
||||
[],
|
||||
)
|
||||
with patch.object(
|
||||
module, "load_annotation_data", return_value=loaded
|
||||
), patch.object(module, "_generate_groups", return_value=(1, True)):
|
||||
self.assertEqual(module.main([str(evaluation), "--overwrite"]), 4)
|
||||
self.assertEqual(
|
||||
(previous / "sentinel.txt").read_text(encoding="utf-8"), "old"
|
||||
)
|
||||
|
||||
def test_grouped_batching_does_not_split_one_student(self) -> None:
|
||||
module = self.modules["annotating_by_label"]
|
||||
image = Image.new("RGB", (10, 60), "white")
|
||||
rendered = [
|
||||
("01", "Ex 1", image, 0, []),
|
||||
("01", "Ex 2", image, 0, []),
|
||||
("02", "Ex 1", image, 0, []),
|
||||
]
|
||||
with patch.object(module, "MAX_HEIGHT_PX", 100):
|
||||
batches = module.split_batches(rendered)
|
||||
self.assertEqual([len(batch) for batch in batches], [2, 1])
|
||||
|
||||
|
||||
class WorkflowTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user