Miscs improvements (Interro02)

This commit is contained in:
2026-09-15 14:18:22 +02:00
parent 0a86403ca6
commit 9b22a8a137
15 changed files with 893 additions and 118 deletions
+83 -44
View File
@@ -7,11 +7,17 @@ from pathlib import Path
import pandas as pd
from PIL import Image, ImageDraw, ImageFont
from copienator.configuration import FINAL_SCORE_FONT_PATH, FINAL_SCORE_ODS_PATH, FINAL_SCORE_OUTPUT_DIR
from copienator.configuration import (
FINAL_SCORE_FONT_PATH,
FINAL_SCORE_HISTOGRAM_PATH,
FINAL_SCORE_ODS_PATH,
FINAL_SCORE_OUTPUT_DIR,
)
# Configuration constants
ODS_PATH = Path(FINAL_SCORE_ODS_PATH).expanduser()
OUTPUT_DIR = Path(FINAL_SCORE_OUTPUT_DIR).expanduser()
HISTOGRAM_PATH = Path(FINAL_SCORE_HISTOGRAM_PATH).expanduser()
def score_font(size):
@@ -33,6 +39,35 @@ def get_rounded_score(score):
except (ValueError, TypeError):
return None
def copy_return_artifacts(source_dir: Path, destination_dir: Path) -> None:
"""Copy the metadata and optional individual answers for one student."""
for filename in ("score.json", "info.json"):
source = source_dir / filename
if source.is_file():
shutil.copy2(source, destination_dir / filename)
else:
print(f"Warning: Missing '{source}'.")
answers_source = source_dir / "answers"
answers_destination = destination_dir / "answers"
if answers_destination.is_symlink():
answers_destination.unlink()
elif answers_destination.is_dir():
shutil.rmtree(answers_destination)
if answers_source.is_dir():
shutil.copytree(answers_source, answers_destination)
def copy_histogram(output_dir: Path) -> None:
"""Copy the score histogram beside the per-student output folders."""
if not HISTOGRAM_PATH.is_file():
print(f"Warning: Missing histogram '{HISTOGRAM_PATH}'.")
return
destination = output_dir / "histogramme.pdf"
shutil.copy2(HISTOGRAM_PATH, destination)
print(f"Copied histogram: {destination}")
def process_images(base_dir, output_dir):
# 1. Load Data
try:
@@ -56,60 +91,64 @@ def process_images(base_dir, output_dir):
print(f"Error: Directory '{search_path}' not found.")
sys.exit(1)
for img_path in sorted(search_path.glob("*/*.jpg")):
student_name = img_path.stem # Filename without extension
for student_source in sorted(path for path in search_path.iterdir() if path.is_dir()):
image_paths = sorted(student_source.glob("*.jpg"))
pdf_paths = sorted(student_source.glob("*.pdf"))
media_paths = image_paths or pdf_paths
if not media_paths:
print(f"Error: No JPG or PDF found in '{student_source}'.")
continue
student_name = media_paths[0].stem
student_output = output_dir / student_name
student_output.mkdir(parents=True, exist_ok=True)
# Remove files produced by the former flat output layout when migrating
# an existing export directory.
for suffix in (".jpg", ".pdf"):
legacy_output = output_dir / f"{student_name}{suffix}"
if legacy_output.is_file() or legacy_output.is_symlink():
legacy_output.unlink()
copy_return_artifacts(student_source, student_output)
# 4. Find Score
if student_name not in score_db:
print(f"Error: Student '{student_name}' not found in ODS file.")
continue
else:
raw_score = score_db[student_name]
score = get_rounded_score(raw_score)
raw_score = score_db[student_name]
score = get_rounded_score(raw_score)
if score is None:
print(f"Error: Invalid score '{raw_score}' for '{student_name}'.")
else:
# 5. Process Images
for img_path in image_paths:
try:
with Image.open(img_path) as img:
img = img.convert("RGB")
draw = ImageDraw.Draw(img)
width, _height = img.size
if score is None:
print(f"Error: Invalid score '{raw_score}' for '{student_name}'.")
continue
font_size = int(width * 0.08)
font = score_font(font_size)
text = str(score)
# 5. Process Image
try:
with Image.open(img_path) as img:
img = img.convert("RGB")
draw = ImageDraw.Draw(img)
width, height = img.size
bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0]
# Dynamic font size (15% of image height)
font_size = int(width * 0.08)
# 30px padding, top right.
x = width - text_w - 30
y = 30
draw.text((x, y), text, fill=(255, 0, 0), font=font)
font = score_font(font_size)
img.save(student_output / img_path.name)
print(f"Processed: {student_name} -> {score}")
except Exception as e:
print(f"Error processing image for '{student_name}': {e}")
text = str(score)
for pdf_path in pdf_paths:
shutil.copy2(pdf_path, student_output / pdf_path.name)
# Calculate text size and position (Top Right)
bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
# 30px padding
x = width - text_w - 30
y = 30
# Draw Text (Red)
draw.text((x, y), text, fill=(255, 0, 0), font=font)
# Save
save_path = output_dir / f"{student_name}.jpg"
img.save(save_path)
print(f"Processed: {student_name} -> {score}")
except Exception as e:
print(f"Error processing image for '{student_name}': {e}")
for pdf_path in sorted(search_path.glob("*/*.pdf")):
student_name = pdf_path.stem # Filename without extension
save_path = output_dir / f"{student_name}.pdf"
shutil.copy(str(pdf_path), str(save_path))
copy_histogram(output_dir)
def main(argv=None):
+18 -5
View File
@@ -85,6 +85,8 @@ def flush_thread_log(tid=None):
# --- Lock for thread-safe file writing ---
io_lock = threading.Lock()
pro_lock = threading.Lock()
group_index_lock = threading.Lock()
reserved_group_indices: dict[str, int] = {}
pro_count = 0
flash_count = 0
pro_quota_exhausted = False
@@ -155,6 +157,7 @@ def configure_runtime(
completed_tasks = []
results = {label: [] for _file, label in tasks}
thread_logs.clear()
reserved_group_indices.clear()
pro_count = 0
flash_count = 0
pro_quota_exhausted = False
@@ -294,6 +297,16 @@ def get_next_group_idx(label):
if not existing: return 0
return max([int(f.stem.split("_")[1]) for f in existing])
def reserve_next_group_idx(label: str) -> int:
"""Reserve a unique zero-based group index for this correction run."""
with group_index_lock:
if label not in reserved_group_indices:
reserved_group_indices[label] = get_next_group_idx(label)
idx = reserved_group_indices[label]
reserved_group_indices[label] = idx + 1
return idx
def handle_label_errors(pid, label, res, pdf_path):
"""Handles Gemini labeling errors, moves/copies files, and returns new tasks."""
new_tasks = []
@@ -334,7 +347,7 @@ def handle_label_errors(pid, label, res, pdf_path):
if pdf_path != old_pdf_path:
shutil.move(str(pdf_path), str(old_pdf_path))
idx = get_next_group_idx(new_label)
idx = reserve_next_group_idx(new_label)
height = grouping.get_pdf_height(str(new_pdf_path))
grouping.create_jpg(new_label, idx, [(pid, str(new_pdf_path), height)], GROUPS_DIR)
tprint(f"\t\tMaking {new_label} group {idx+1}")
@@ -366,7 +379,7 @@ def handle_label_errors(pid, label, res, pdf_path):
if not base_add_pdf_path.exists() and not add_pdf_path.exists():
shutil.copy(str(pdf_path), str(add_pdf_path))
tprint(f"\t\tCopying Copie{pid} : {label} -> {add_label}")
idx = get_next_group_idx(add_label)
idx = reserve_next_group_idx(add_label)
tprint(f"\t\tMaking {add_label} group {idx+1}")
height = grouping.get_pdf_height(str(add_pdf_path))
grouping.create_jpg(add_label, idx, [(pid, str(add_pdf_path), height)], GROUPS_DIR)
@@ -584,7 +597,7 @@ def resolve_delayed_moves():
if pdf_path != old_pdf_path:
shutil.move(str(pdf_path), str(old_pdf_path))
idx = get_next_group_idx(target_label)
idx = reserve_next_group_idx(target_label)
height = grouping.get_pdf_height(str(new_pdf_path))
grouping.create_jpg(target_label, idx, [(pid, str(new_pdf_path), height)], GROUPS_DIR)
new_tasks.append((str(GROUPS_DIR / target_label / f"Group_{idx+1}.jpg"), target_label, False))
@@ -602,7 +615,7 @@ def resolve_delayed_moves():
resolved_any = True
shutil.copy(str(pdf_path), str(add_pdf_path))
idx = get_next_group_idx(target_label)
idx = reserve_next_group_idx(target_label)
height = grouping.get_pdf_height(str(add_pdf_path))
grouping.create_jpg(target_label, idx, [(pid, str(add_pdf_path), height)], GROUPS_DIR)
new_tasks.append((str(GROUPS_DIR / target_label / f"Group_{idx+1}.jpg"), target_label, False))
@@ -688,7 +701,7 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
# pdf_path = copie_dir / f"{label}_old.pdf"
if pdf_path.exists():
idx = get_next_group_idx(label)
idx = reserve_next_group_idx(label)
height = grouping.get_pdf_height(str(pdf_path))
grouping.create_jpg(label, idx, [(pid, str(pdf_path), height)], GROUPS_DIR)
new_group_path = str(GROUPS_DIR / label / f"Group_{idx+1}.jpg")
+82 -13
View File
@@ -29,9 +29,20 @@ def build_parser() -> argparse.ArgumentParser:
choices=ANNOTATION_CHOICES,
help="Annotation directory to use",
)
parser.add_argument(
"--update",
action="store_true",
help=(
"Update only the individual images in existing A Rendre/answers "
"directories, matching folders by their trailing copy ID"
),
)
return parser
RETURN_COPY_ID = re.compile(r"\((\d+)\)$")
def _read_expected_names(workspace: EvaluationWorkspace) -> set[str]:
names_path = workspace.names_file()
if not names_path.exists():
@@ -47,6 +58,70 @@ def _read_expected_names(workspace: EvaluationWorkspace) -> set[str]:
}
def _annotation_source(
workspace: EvaluationWorkspace,
annotation_dir_name: str,
copy_id: str,
) -> Path | None:
selected = workspace.root / annotation_dir_name / f"Copie{copy_id}"
fallback = workspace.annotation_dir("simple") / f"Copie{copy_id}"
for candidate in (selected, fallback):
if (candidate / "score.json").is_file() and (
(candidate / "Concat.jpg").is_file()
or (candidate / "info.json").is_file()
):
return candidate
return None
def update_named_return_answers(
workspace: EvaluationWorkspace,
annotation_dir_name: str,
) -> ExitCode:
"""Refresh only answers/ in existing returns, preserving manual names."""
workspace.require_directories(annotation_dir_name, "A Rendre")
had_errors = False
found = False
for destination in sorted(workspace.return_dir.iterdir()):
if not destination.is_dir():
continue
match = RETURN_COPY_ID.search(destination.name)
if match is None:
print(
f"Warning: cannot identify a copy ID in {destination.name!r}; skipped",
file=sys.stderr,
)
had_errors = True
continue
found = True
copy_id = match.group(1)
source_folder = _annotation_source(
workspace, annotation_dir_name, copy_id
)
if source_folder is None:
print(
f"Warning: no annotation source found for Copie{copy_id}; skipped",
file=sys.stderr,
)
had_errors = True
continue
try:
publish_answer_returns(
workspace.root,
source_folder,
destination,
answers_only=True,
)
print(f"Updated answers for {destination.name} from Copie{copy_id}")
except (OSError, TypeError, ValueError) as exc:
print(f"Error updating answers for {destination.name}: {exc}", file=sys.stderr)
had_errors = True
if not found:
print("Warning: no identifiable student folders found in A Rendre", file=sys.stderr)
had_errors = True
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
def prepare_named_returns(
workspace: EvaluationWorkspace,
annotation_dir_name: str,
@@ -74,9 +149,6 @@ def prepare_named_returns(
had_errors = True
assigned_names: set[str] = set()
selected_annotations = workspace.root / annotation_dir_name
fallback_annotations = workspace.annotation_dir("simple")
for name, copy_ids in copies_map.items():
if name == "Unknown":
print(
@@ -92,16 +164,9 @@ def prepare_named_returns(
safe_name = safe_filename(name)
for copy_id in copy_ids:
selected = selected_annotations / f"Copie{copy_id}"
fallback = fallback_annotations / f"Copie{copy_id}"
source_folder = None
for candidate in (selected, fallback):
if (candidate / "score.json").is_file() and (
(candidate / "Concat.jpg").is_file()
or (candidate / "info.json").is_file()
):
source_folder = candidate
break
source_folder = _annotation_source(
workspace, annotation_dir_name, copy_id
)
if source_folder is None:
continue
@@ -160,7 +225,10 @@ def run(
workspace: EvaluationWorkspace,
*,
annotation_dir: str,
update: bool = False,
) -> ExitCode:
if update:
return update_named_return_answers(workspace, annotation_dir)
return prepare_named_returns(workspace, annotation_dir)
@@ -172,6 +240,7 @@ def main(argv: Sequence[str] | None = None) -> int:
lambda args: run(
workspace_from_args(args, repository=Path.cwd()),
annotation_dir=args.annotation_dir,
update=args.update,
),
)
+47 -11
View File
@@ -19,6 +19,10 @@ from copienator import (
WORD_LIST_FILE = Path(__file__).resolve().parents[1] / "data" / "liste_francais.txt"
ACCENT_PATTERN = re.compile(r"[éèêëàâäîïôöùûüçœÉÈÊËÀÂÄÎÏÔÖÙÛÜÇŒ]")
MATH_PATTERN = re.compile(
r"(\$\$.*?\$\$|\$.*?\$|\\\(.*?\\\)|\\\[.*?\\\])",
re.DOTALL,
)
def build_parser() -> argparse.ArgumentParser:
@@ -26,22 +30,37 @@ def build_parser() -> argparse.ArgumentParser:
def escape_latex_underscores(text: str) -> str:
r"""Escape underscores outside LaTeX math environments."""
math_pattern = re.compile(
r"(\$\$.*?\$\$|\$.*?\$|\\\(.*?\\\)|\\\[.*?\\\])",
re.DOTALL,
)
r"""Escape underscores outside math without double-escaping existing ones."""
def escape_plain(value: str) -> str:
# Collapse any existing escape run as well, making cleanup idempotent.
return re.sub(r"\\*_", lambda _match: r"\_", value)
parts: list[str] = []
last_end = 0
for match in math_pattern.finditer(text):
for match in MATH_PATTERN.finditer(text):
start, end = match.span()
parts.append(text[last_end:start].replace("_", r"\_"))
parts.append(escape_plain(text[last_end:start]))
parts.append(match.group(0))
last_end = end
parts.append(text[last_end:].replace("_", r"\_"))
parts.append(escape_plain(text[last_end:]))
return "".join(parts)
def normalize_overescaped_latex_commands(text: str) -> str:
r"""Collapse doubled command escapes inside LaTeX math environments.
Model responses occasionally contain ``\\mathbb`` after JSON decoding where
LaTeX requires ``\mathbb``. A doubled backslash followed by whitespace is a
legitimate row break (for example in ``cases``), so it must be preserved.
"""
def normalize_math(match: re.Match[str]) -> str:
return re.sub(r"\\\\(?=[A-Za-z{}])", r"\\", match.group(0))
return MATH_PATTERN.sub(normalize_math, text)
def build_lookup_map(word_list_path: Path = WORD_LIST_FILE) -> dict[str, str]:
words = word_list_path.read_text(encoding="utf-8").splitlines()
lookup: dict[str, str] = {}
@@ -68,8 +87,23 @@ def fix_hex_corruption_safe(text: str) -> str:
)
def some_other_replacements(text: str) -> str:
return text.replace("\neq", "\\neq").replace("\not", "\\not")
def repair_json_escape_corruption(text: str) -> str:
r"""Restore observed LaTeX commands consumed as JSON control escapes."""
replacements = (
("\x0crac", r"\frac"),
("\x0ceuille", r"\equiv"),
("\theta", r"\theta"),
("\times", r"\times"),
("\textbackslash ", "\\"),
("\negthinspace", r"\negthinspace"),
("\neq", r"\neq"),
("\not", r"\not"),
("", r"\ensuremath{\in}"),
("", r"\ensuremath{\subset}"),
)
for broken, repaired in replacements:
text = text.replace(broken, repaired)
return text
def clean_string(text: str, lookup: dict[str, str]) -> str:
@@ -80,7 +114,9 @@ def clean_string(text: str, lookup: dict[str, str]) -> str:
text = re.sub(r" \x00{1,2} ", " à ", text)
if "\x00" in text:
text = fast_fix(text, lookup).replace("\x00", "")
return escape_latex_underscores(some_other_replacements(text))
text = repair_json_escape_corruption(text)
text = normalize_overescaped_latex_commands(text)
return escape_latex_underscores(text)
def clean_obj(value: Any, lookup: dict[str, str]) -> Any:
+83 -15
View File
@@ -10,7 +10,7 @@ from pdf2image import convert_from_path
from PIL import Image, ImageChops, ImageDraw, ImageFilter
from copienator.commands import annotating
from copienator import utils
from copienator import configuration, utils
from copienator import (
EvaluationWorkspace,
ExitCode,
@@ -24,6 +24,7 @@ from copienator.annotation_actions import apply_checkbox_actions, apply_score_ov
from copienator.answer_info import build_answer_info
from copienator.annotation_data import AnnotationData, load_annotation_data
from copienator.filesystem import staged_files
from copienator.return_answers import save_return_answer_options
Image.MAX_IMAGE_PIXELS = None
@@ -69,10 +70,11 @@ def detect_checks_and_notes(
print(f" Resizing annotated PDF from {user_image.size} to {reference.size}")
user_image = user_image.resize(reference.size, Image.Resampling.LANCZOS)
difference = np.abs(
np.array(reference).astype(int) - np.array(user_image).astype(int)
).astype(np.uint8)
difference_gray = np.mean(difference, axis=2)
# Keep the full-size difference in uint8. Converting both tall group images
# to the platform ``int`` dtype used several gigabytes per scan worker.
difference = np.asarray(
ImageChops.difference(reference, user_image), dtype=np.uint8
)
keep_mask = Image.new("L", reference.size, 255)
mask_draw = ImageDraw.Draw(keep_mask)
actions: list[dict[str, Any]] = []
@@ -83,10 +85,14 @@ def detect_checks_and_notes(
x1, y1, x2, y2 = map(int, raw_box["global_box"])
x1, y1 = max(0, x1), max(0, y1)
x2, y2 = min(reference.width, x2), min(reference.height, y2)
region = difference_gray[y1 + 5 : y2 - 5, x1 + 5 : x2 - 5]
region = difference[y1 + 5 : y2 - 5, x1 + 5 : x2 - 5]
if region.size == 0:
continue
density = np.sum(region > 30) / region.size
# Preserve the previous mean-across-RGB threshold, but allocate its
# temporary float array only for the small checkbox region.
density = np.count_nonzero(np.mean(region, axis=2) > 30) / (
region.shape[0] * region.shape[1]
)
if density > 0.05:
actions.append(raw_box)
mask_draw.rectangle([x1 - 15, y1 - 15, x2 + 15, y2 + 15], fill=0)
@@ -95,13 +101,17 @@ def detect_checks_and_notes(
if raw_box.get("type") == "score" and raw_box.get("value") == 0.0:
mask_draw.rectangle([0, y1 - 10, reference.width, y2 + 10], fill=0)
del difference
reference_blur = reference.filter(ImageFilter.GaussianBlur(2))
user_blur = user_image.filter(ImageFilter.GaussianBlur(2))
diff_image = ImageChops.difference(reference_blur, user_blur).convert("L")
alpha = np.where(np.array(diff_image) > 50, 255, 0).astype(np.uint8)
final_alpha = np.minimum(alpha, np.array(keep_mask))
del reference_blur, user_blur
alpha = np.asarray(diff_image, dtype=np.uint8).copy()
np.greater(alpha, 50, out=alpha)
alpha *= np.uint8(255)
np.minimum(alpha, np.asarray(keep_mask, dtype=np.uint8), out=alpha)
notes = user_image.convert("RGBA")
notes.putalpha(Image.fromarray(final_alpha))
notes.putalpha(Image.fromarray(alpha))
return actions, notes
@@ -150,8 +160,10 @@ def apply_actions_and_regenerate(
labels_data = data[student_id]
apply_checkbox_actions(labels_data, actions, print)
score_path = output_dir / "score.json"
preserve_score_file = update_score and score_path.is_file()
if update_score:
apply_score_overrides(labels_data, output_dir / "score.json", print)
apply_score_overrides(labels_data, score_path, print)
scores = dict.fromkeys(all_labels, "")
answer_labels: list[str] = []
@@ -220,7 +232,8 @@ def apply_actions_and_regenerate(
"touched.json", "answer_labels.json")) as staging:
for label, image in dirty_images.items():
image.save(staging / f"{label}.jpg")
atomic_write_json(staging / "score.json", scores)
if not preserve_score_file:
atomic_write_json(staging / "score.json", scores)
atomic_write_json(staging / "info.json", build_answer_info(
scores, labels_data, answer_labels
))
@@ -229,13 +242,41 @@ def apply_actions_and_regenerate(
if filtered_image is not None:
filtered_image.save(staging / "Concat_F.jpg")
if preserve_score_file:
print(f" Preserved existing score.json in {output_dir}")
print(f" Saved regenerated files in {output_dir}")
return ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
def run(workspace: EvaluationWorkspace, *, update_score: bool = False) -> ExitCode:
def run(
workspace: EvaluationWorkspace,
*,
update_score: bool = False,
return_answers_context: bool | None = None,
return_answers_question: bool | None = None,
return_answers_solution: bool | None = None,
) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label", "Bnot")
if configuration.RETURN_ANSWERS_ENABLED:
save_return_answer_options(
workspace.root,
context=(
configuration.RETURN_ANSWERS_CONTEXT
if return_answers_context is None
else return_answers_context
),
question=(
configuration.RETURN_ANSWERS_QUESTION
if return_answers_question is None
else return_answers_question
),
solution=(
configuration.RETURN_ANSWERS_SOLUTION
if return_answers_solution is None
else return_answers_solution
),
)
all_labels = utils.read_all_labels(workspace.root)
loaded = load_annotation_data(workspace)
for warning in loaded.warnings:
@@ -276,7 +317,28 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--update-score",
action="store_true",
help="Override generated scores with values from existing score.json files",
help=(
"Regenerate images with current statement/solution PDFs while "
"preserving and applying existing score.json values"
),
)
parser.add_argument(
"--return-answers-context",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_CONTEXT,
help="Include applicable context pages in individual answer exports",
)
parser.add_argument(
"--return-answers-question",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_QUESTION,
help="Include the current question PDF in individual answer exports",
)
parser.add_argument(
"--return-answers-solution",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_SOLUTION,
help="Include the current solution PDF in individual answer exports",
)
return parser
@@ -285,7 +347,13 @@ def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
return run(workspace_from_args(args), update_score=args.update_score)
return run(
workspace_from_args(args),
update_score=args.update_score,
return_answers_context=args.return_answers_context,
return_answers_question=args.return_answers_question,
return_answers_solution=args.return_answers_solution,
)
return execute(parser, argv, handle)
@@ -9,6 +9,7 @@ from typing import Any
from PIL import Image, ImageDraw
from copienator import configuration
from copienator import (
EvaluationWorkspace,
ExitCode,
@@ -29,9 +30,11 @@ from copienator.commands.reading_annotations import (
has_significant_notes,
)
from copienator.filesystem import staged_files
from copienator.return_answers import save_return_answer_options
LabelNotes = dict[str, dict[str, Any]]
ScanResult = tuple[dict[str, list[dict[str, Any]]], dict[str, LabelNotes]]
SCAN_WORKERS = 2
def get_extra_pdfs_as_images(
@@ -191,9 +194,11 @@ def apply_actions_and_regenerate_grouped(
output_dir = workspace.root / annotation_dir / f"Copie{student_id}"
labels_data = data.get(student_id, {})
apply_checkbox_actions(labels_data, actions, logs.append)
score_path = output_dir / "score.json"
preserve_score_file = update_score and score_path.is_file()
if update_score:
apply_score_overrides(
labels_data, output_dir / "score.json", logs.append
labels_data, score_path, logs.append
)
selected_labels = selected_labels if selected_labels is not None else set()
@@ -350,7 +355,8 @@ def apply_actions_and_regenerate_grouped(
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 not preserve_score_file:
atomic_write_json(staging / "score.json", scores)
atomic_write_json(staging / "info.json", build_answer_info(
scores, labels_data, answer_labels, touched
))
@@ -364,6 +370,8 @@ def apply_actions_and_regenerate_grouped(
[image for group in filtered_groups for image in group]
)
filtered_image.save(staging / "Concat_F.jpg")
if preserve_score_file:
logs.append(f" Preserved existing score.json in {output_dir}")
logs.append(f" Saved regenerated files in {output_dir}")
status = ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
return status, "\n".join(logs)
@@ -461,6 +469,9 @@ def run(
refaire: bool = False,
update_score: bool = False,
annotation_dir: str = "BGnot",
return_answers_context: bool | None = None,
return_answers_question: bool | None = None,
return_answers_solution: bool | None = None,
) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label", annotation_dir)
@@ -470,6 +481,25 @@ def run(
workspace.require_files("refaire.json")
workspace.require_directories("BRnot")
refaire_list, refaire_by_student = _read_refaire(workspace)
if configuration.RETURN_ANSWERS_ENABLED:
save_return_answer_options(
workspace.root,
context=(
configuration.RETURN_ANSWERS_CONTEXT
if return_answers_context is None
else return_answers_context
),
question=(
configuration.RETURN_ANSWERS_QUESTION
if return_answers_question is None
else return_answers_question
),
solution=(
configuration.RETURN_ANSWERS_SOLUTION
if return_answers_solution is None
else return_answers_solution
),
)
all_labels = utils.read_all_labels(workspace.root)
loaded = load_annotation_data(workspace)
@@ -494,7 +524,10 @@ def run(
and path.is_dir()
and not path.name.startswith("Copie")
]
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
# Each worker decodes a full-height returned group and its reference image.
# Keep this stage deliberately narrow; answer regeneration below has its own
# parallel executor and a much smaller per-task memory footprint.
with concurrent.futures.ThreadPoolExecutor(max_workers=SCAN_WORKERS) as executor:
futures = [
executor.submit(_scan_annotation_directory, path, only_ids)
for path in group_dirs
@@ -599,7 +632,28 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--update-score",
action="store_true",
help="Override generated scores with values from existing score.json files",
help=(
"Regenerate images with current statement/solution PDFs while "
"preserving and applying existing score.json values"
),
)
parser.add_argument(
"--return-answers-context",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_CONTEXT,
help="Include applicable context pages in individual answer exports",
)
parser.add_argument(
"--return-answers-question",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_QUESTION,
help="Include the current question PDF in individual answer exports",
)
parser.add_argument(
"--return-answers-solution",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_SOLUTION,
help="Include the current solution PDF in individual answer exports",
)
return parser
@@ -615,6 +669,9 @@ def main(argv: Sequence[str] | None = None) -> int:
refaire=args.refaire,
update_score=args.update_score,
annotation_dir=args.annotation_dir,
return_answers_context=args.return_answers_context,
return_answers_question=args.return_answers_question,
return_answers_solution=args.return_answers_solution,
)
return execute(parser, argv, handle)
+1
View File
@@ -39,6 +39,7 @@ RETURN_ANSWERS_ENABLED = False
RETURN_ANSWERS_CONTEXT = False
RETURN_ANSWERS_QUESTION = True
RETURN_ANSWERS_SOLUTION = False
FINAL_SCORE_HISTOGRAM_PATH = Path("histogramme.pdf")
for _name in dir(_configuration):
if not _name.startswith("_"):
globals()[_name] = getattr(_configuration, _name)
+63 -10
View File
@@ -8,9 +8,56 @@ from copienator import atomic_write_json, configuration, read_json, utils
from copienator.filesystem import staged_directory
from copienator.platform import safe_filename
RETURN_ANSWER_OPTIONS_FILE = Path(".copienator") / "return_answers.json"
def publish_answer_returns(root: Path, source: Path, destination: Path) -> None:
"""Publish individual reviewed answers and per-question information."""
def configured_return_answer_options() -> dict[str, bool]:
return {
"context": bool(configuration.RETURN_ANSWERS_CONTEXT),
"question": bool(configuration.RETURN_ANSWERS_QUESTION),
"solution": bool(configuration.RETURN_ANSWERS_SOLUTION),
}
def save_return_answer_options(
root: Path,
*,
context: bool,
question: bool,
solution: bool,
) -> None:
path = Path(root) / RETURN_ANSWER_OPTIONS_FILE
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(
path,
{"context": context, "question": question, "solution": solution},
)
def load_return_answer_options(root: Path) -> dict[str, bool]:
options = configured_return_answer_options()
path = Path(root) / RETURN_ANSWER_OPTIONS_FILE
if not path.is_file():
return options
loaded = read_json(path)
if not isinstance(loaded, dict):
raise ValueError(f"Expected a return-answer options object in {path}")
for name in options:
if name in loaded:
if type(loaded[name]) is not bool:
raise ValueError(f"Expected a boolean for {name!r} in {path}")
options[name] = loaded[name]
return options
def publish_answer_returns(
root: Path,
source: Path,
destination: Path,
*,
answers_only: bool = False,
) -> None:
"""Publish reviewed answers, optionally without touching return metadata."""
scores = read_json(source / "score.json")
if not isinstance(scores, dict):
raise ValueError(f"Expected a score object in {source}")
@@ -36,6 +83,7 @@ def publish_answer_returns(root: Path, source: Path, destination: Path) -> None:
if answers_dir.is_symlink():
raise ValueError(f"Expected a real answer directory: {answers_dir}")
if configuration.RETURN_ANSWERS_ENABLED:
options = load_return_answer_options(root)
labels = [label for label, entry in info.items() if entry["present"] and entry["not_empty"]]
# Import the rendering backend only when individual images are requested.
@@ -44,13 +92,13 @@ def publish_answer_returns(root: Path, source: Path, destination: Path) -> None:
all_labels = utils.read_all_labels(root)
with staged_directory(answers_dir) as staging:
for index, label in enumerate(sorted(labels, key=utils.natural_key), 1):
for label in sorted(labels, key=utils.natural_key):
paths = []
if configuration.RETURN_ANSWERS_CONTEXT:
if options["context"]:
paths.extend(utils.pdf_images_of_contexts(root, label, all_labels))
if configuration.RETURN_ANSWERS_QUESTION:
if options["question"]:
paths.append(utils.pdf_image_of_enonce(root, label))
if configuration.RETURN_ANSWERS_SOLUTION:
if options["solution"]:
paths.append(utils.pdf_image_of_solution(root, label))
images = []
for path in paths:
@@ -62,11 +110,16 @@ def publish_answer_returns(root: Path, source: Path, destination: Path) -> None:
with Image.open(source / f"{label}.jpg") as answer:
images.append(answer.convert("RGB"))
image = concatenate(images)
# Numbering avoids collisions between sanitized label filenames.
image.save(staging / f"{index:03d} - {safe_filename(label)}.jpg")
output = staging / f"{safe_filename(label)}.jpg"
if output.exists():
raise ValueError(
f"Answer labels produce the same filename in {answers_dir}: {label}"
)
image.save(output)
elif answers_dir.exists():
# Replace the managed directory with an empty one to remove stale exports.
with staged_directory(answers_dir):
pass
atomic_write_json(destination / "info.json", info)
(destination / "touched.json").unlink(missing_ok=True)
if not answers_only:
atomic_write_json(destination / "info.json", info)
(destination / "touched.json").unlink(missing_ok=True)
+21 -6
View File
@@ -160,23 +160,38 @@ def compile_to_pdf(text, output_pdf_path):
# env['TEXINPUTS'] = f".:{current_dir}:"
try:
subprocess.run(
result = subprocess.run(
['pdflatex', '-interaction=nonstopmode', tex_filename],
cwd=temp_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=False
)
if "minted" in text:
subprocess.run(
result = subprocess.run(
['pdflatex', '-interaction=nonstopmode', tex_filename],
cwd=temp_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=False)
if result.returncode != 0:
error_lines = [
line.strip() for line in result.stdout.splitlines()
if line.lstrip().startswith("!")
]
detail = f": {error_lines[0]}" if error_lines else ""
print(
f"Warning: LaTeX compilation failed for {output_pdf_path} "
f"(exit code {result.returncode}){detail}"
)
generated_pdf = os.path.join(temp_dir, pdf_filename)
if os.path.exists(generated_pdf):
shutil.move(generated_pdf, output_pdf_path)
else:
print(f"Warning: LaTeX compilation produced no PDF for {output_pdf_path}")
except Exception as e:
print(f"Compilation error for {output_pdf_path}: {e}")