Configuration de l'output final
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
from collections.abc import Collection, Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_answer_info(
|
||||
scores: Mapping[str, str],
|
||||
present_labels: Collection[str],
|
||||
rendered_labels: Collection[str],
|
||||
touched: Mapping[str, bool] | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Describe every question using the answers actually compiled for a copy."""
|
||||
present = set(present_labels)
|
||||
rendered = set(rendered_labels)
|
||||
return {
|
||||
label: {
|
||||
"present": label in present,
|
||||
"not_empty": label in rendered,
|
||||
"touched": (touched or {}).get(label, False),
|
||||
"score": score,
|
||||
}
|
||||
for label, score in scores.items()
|
||||
}
|
||||
@@ -30,6 +30,7 @@ from copienator import (
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.annotation_data import load_annotation_data
|
||||
from copienator.answer_info import build_answer_info
|
||||
from copienator.filesystem import staged_directory
|
||||
from copienator.utils import natural_key
|
||||
|
||||
@@ -446,6 +447,7 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
|
||||
with staged_directory(output_dir) as staging:
|
||||
d_notes = dict.fromkeys(all_labels, "")
|
||||
label_images = []
|
||||
answer_labels = []
|
||||
sorted_labels = sorted(labels_data.items(), key=lambda item: natural_key(item[0]))
|
||||
|
||||
for label, content in sorted_labels:
|
||||
@@ -475,8 +477,12 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
|
||||
final_img.save(staging / f"{label}.jpg")
|
||||
if result.get('error', "") != "empty-answer":
|
||||
label_images.append(final_img)
|
||||
answer_labels.append(label)
|
||||
|
||||
atomic_write_json(staging / "score.json", d_notes)
|
||||
atomic_write_json(staging / "info.json", build_answer_info(
|
||||
d_notes, labels_data, answer_labels
|
||||
))
|
||||
if label_images:
|
||||
max_w = max(image.width for image in label_images)
|
||||
total_h = sum(image.height for image in label_images)
|
||||
|
||||
@@ -13,6 +13,7 @@ from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
configuration,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
workspace_from_args,
|
||||
@@ -83,15 +84,18 @@ def _return_artifacts(workspace: EvaluationWorkspace) -> list[Path]:
|
||||
path for path in files if path.suffix.casefold() in IMAGE_SUFFIXES
|
||||
]
|
||||
scores = [path for path in files if path.name.casefold() == "score.json"]
|
||||
if not images or not scores:
|
||||
pdfs = [path for path in files if path.suffix.casefold() == ".pdf"]
|
||||
if (configuration.RETURN_JPEG_ENABLED and not images) or not scores:
|
||||
missing = []
|
||||
if not images:
|
||||
if configuration.RETURN_JPEG_ENABLED and not images:
|
||||
missing.append("image")
|
||||
if not scores:
|
||||
missing.append("score.json")
|
||||
incomplete.append(f"{directory.name} ({', '.join(missing)})")
|
||||
artifacts.extend(images)
|
||||
artifacts.extend(scores)
|
||||
artifacts.extend(pdfs)
|
||||
artifacts.extend(path for path in files if path.name.casefold() == "info.json")
|
||||
|
||||
if incomplete:
|
||||
details = "\n".join(f" - {item}" for item in incomplete)
|
||||
@@ -219,7 +223,7 @@ def print_plan(workspace: EvaluationWorkspace, plan: CleanupPlan, *, verbose: bo
|
||||
print(f" - {statement_count} textual statement files")
|
||||
print(" - correction.json")
|
||||
print(f" - {log_count} log files")
|
||||
print(f" - {return_count} image/score artifacts in A Rendre")
|
||||
print(f" - {return_count} image/PDF/score artifacts in A Rendre")
|
||||
print(
|
||||
f"Will delete {len(plan.deleted_files)} files and "
|
||||
f"{len(plan.deleted_directories)} directories "
|
||||
|
||||
@@ -10,12 +10,14 @@ from pathlib import Path
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
configuration,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
read_json,
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.platform import replace_with_link_or_copy, safe_filename
|
||||
from copienator.return_answers import publish_answer_returns
|
||||
|
||||
ANNOTATION_CHOICES = ("BGnot", "Bnot", "Anot")
|
||||
|
||||
@@ -94,9 +96,10 @@ def prepare_named_returns(
|
||||
fallback = fallback_annotations / f"Copie{copy_id}"
|
||||
source_folder = None
|
||||
for candidate in (selected, fallback):
|
||||
if (candidate / "Concat.jpg").exists() and (
|
||||
candidate / "score.json"
|
||||
).exists():
|
||||
if (candidate / "score.json").is_file() and (
|
||||
(candidate / "Concat.jpg").is_file()
|
||||
or (candidate / "info.json").is_file()
|
||||
):
|
||||
source_folder = candidate
|
||||
break
|
||||
if source_folder is None:
|
||||
@@ -105,19 +108,31 @@ def prepare_named_returns(
|
||||
assigned_names.add(name)
|
||||
destination = workspace.return_dir / f"{safe_name} ({copy_id})"
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
publish_answer_returns(workspace.root, source_folder, destination)
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
print(f"Error preparing answers for {destination.name}: {exc}", file=sys.stderr)
|
||||
had_errors = True
|
||||
continue
|
||||
links = (
|
||||
("Concat.jpg", f"{safe_name}.jpg"),
|
||||
("Concat_F.pdf", f"{safe_name}.pdf"),
|
||||
("score.json", "score.json"),
|
||||
("Concat.jpg", f"{safe_name}.jpg", configuration.RETURN_JPEG_ENABLED),
|
||||
("Concat_F.pdf", f"{safe_name}.pdf", configuration.RETURN_PDF_ENABLED),
|
||||
("score.json", "score.json", True),
|
||||
)
|
||||
for source_name, destination_name in links:
|
||||
for source_name, destination_name, enabled in links:
|
||||
source = source_folder / source_name
|
||||
if not source.exists():
|
||||
continue
|
||||
target = destination / destination_name
|
||||
try:
|
||||
if not enabled:
|
||||
# Remove only the named return entry, never its link target.
|
||||
target.unlink(missing_ok=True)
|
||||
continue
|
||||
if not source.exists():
|
||||
target.unlink(missing_ok=True)
|
||||
continue
|
||||
method = replace_with_link_or_copy(
|
||||
source,
|
||||
destination / destination_name,
|
||||
target,
|
||||
prefer="symlink",
|
||||
)
|
||||
if method == "copy":
|
||||
@@ -163,4 +178,3 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from copienator import (
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
|
||||
from copienator.answer_info import build_answer_info
|
||||
from copienator.annotation_data import AnnotationData, load_annotation_data
|
||||
from copienator.filesystem import staged_files
|
||||
|
||||
@@ -148,11 +149,12 @@ def apply_actions_and_regenerate(
|
||||
raise TypeError(f"Expected a JSON object in {bnote_path}")
|
||||
|
||||
labels_data = data[student_id]
|
||||
dirty_labels = apply_checkbox_actions(labels_data, actions, print)
|
||||
apply_checkbox_actions(labels_data, actions, print)
|
||||
if update_score:
|
||||
dirty_labels |= apply_score_overrides(labels_data, output_dir / "score.json", print)
|
||||
apply_score_overrides(labels_data, output_dir / "score.json", print)
|
||||
|
||||
scores = dict.fromkeys(all_labels, "")
|
||||
answer_labels: list[str] = []
|
||||
dirty_images: dict[str, Image.Image] = {}
|
||||
concatenated: list[Image.Image] = []
|
||||
filtered: list[Image.Image] = []
|
||||
@@ -169,6 +171,8 @@ def apply_actions_and_regenerate(
|
||||
content = labels_data[label]
|
||||
result = content["result"]
|
||||
scores[label] = str(result.get("score", 0))
|
||||
if result.get("error") == "empty-answer":
|
||||
continue
|
||||
|
||||
sub_note = None
|
||||
if notes_layer is not None:
|
||||
@@ -204,18 +208,22 @@ def apply_actions_and_regenerate(
|
||||
body = sub_note.crop((0, old_header_height, width, height))
|
||||
final_image.paste(body, (0, new_header_height), mask=body)
|
||||
|
||||
if label in dirty_labels or has_notes:
|
||||
dirty_images[label] = final_image
|
||||
dirty_images[label] = final_image
|
||||
answer_labels.append(label)
|
||||
concatenated.append(final_image)
|
||||
if float(scores[label]) != 4.0 or result.get("feedback", []):
|
||||
filtered.append(final_image)
|
||||
|
||||
concat_image = concatenate(concatenated)
|
||||
filtered_image = concatenate(filtered)
|
||||
with staged_files(output_dir) as staging:
|
||||
with staged_files(output_dir, remove=("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf",
|
||||
"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)
|
||||
atomic_write_json(staging / "info.json", build_answer_info(
|
||||
scores, labels_data, answer_labels
|
||||
))
|
||||
if concat_image is not None:
|
||||
concat_image.save(staging / "Concat.jpg")
|
||||
if filtered_image is not None:
|
||||
@@ -284,4 +292,3 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from copienator import (
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
|
||||
from copienator.answer_info import build_answer_info
|
||||
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
|
||||
from copienator.commands import annotating
|
||||
from copienator.commands.reading_annotations import (
|
||||
@@ -189,14 +190,13 @@ def apply_actions_and_regenerate_grouped(
|
||||
logs = [f"\nProcessing compilation for: Copie{student_id}"]
|
||||
output_dir = workspace.root / annotation_dir / f"Copie{student_id}"
|
||||
labels_data = data.get(student_id, {})
|
||||
dirty_labels = apply_checkbox_actions(labels_data, actions, logs.append)
|
||||
apply_checkbox_actions(labels_data, actions, logs.append)
|
||||
if update_score:
|
||||
dirty_labels |= apply_score_overrides(
|
||||
apply_score_overrides(
|
||||
labels_data, output_dir / "score.json", logs.append
|
||||
)
|
||||
|
||||
selected_labels = selected_labels if selected_labels is not None else set()
|
||||
dirty_labels |= selected_labels
|
||||
simple_layout = None
|
||||
simple_annotated = None
|
||||
if selected_labels and annotation_dir == "Anot":
|
||||
@@ -239,6 +239,8 @@ def apply_actions_and_regenerate_grouped(
|
||||
else {}
|
||||
)
|
||||
scores = dict.fromkeys(all_labels, "")
|
||||
touched = dict.fromkeys(all_labels, False)
|
||||
answer_labels: list[str] = []
|
||||
dirty_images: dict[str, Image.Image] = {}
|
||||
concat_images: list[Image.Image] = []
|
||||
filtered_groups: list[list[Image.Image]] = []
|
||||
@@ -255,6 +257,9 @@ def apply_actions_and_regenerate_grouped(
|
||||
):
|
||||
result["score"] = old_scores[label]
|
||||
scores[label] = str(result.get("score", 0))
|
||||
touched[label] = False
|
||||
if result.get("error") == "empty-answer":
|
||||
continue
|
||||
saved_image = output_dir / f"{label}.jpg"
|
||||
if selected_labels and label not in selected_labels and saved_image.is_file():
|
||||
with Image.open(saved_image) as saved:
|
||||
@@ -271,12 +276,14 @@ def apply_actions_and_regenerate_grouped(
|
||||
dirty_images[label] = final_image
|
||||
scores[label] = str(old_scores.get(label, scores[label]))
|
||||
concat_images.append(final_image)
|
||||
answer_labels.append(label)
|
||||
# Keep previously reviewed content, including handwriting.
|
||||
if annotation_dir == "BGnot":
|
||||
extras = get_extra_pdfs_as_images(
|
||||
workspace.root, label, annotating, all_labels
|
||||
)
|
||||
filtered_groups.append([*extras, final_image])
|
||||
touched[label] = True
|
||||
else:
|
||||
filtered_groups.append([final_image])
|
||||
continue
|
||||
@@ -313,9 +320,9 @@ def apply_actions_and_regenerate_grouped(
|
||||
body = sub_note.crop((0, old_header_height, width, height))
|
||||
final_image.paste(body, (0, new_header_height), mask=body)
|
||||
|
||||
if label in dirty_labels or has_notes or selected_labels:
|
||||
dirty_images[label] = final_image
|
||||
logs.append(f" Saved dirty image: {label}.jpg")
|
||||
# Persist every final block, including unchanged answers, for returns.
|
||||
dirty_images[label] = final_image
|
||||
answer_labels.append(label)
|
||||
concat_images.append(final_image)
|
||||
|
||||
feedbacks = result.get("feedback", [])
|
||||
@@ -329,11 +336,13 @@ def apply_actions_and_regenerate_grouped(
|
||||
else []
|
||||
)
|
||||
filtered_groups.append([*extras, final_image])
|
||||
touched[label] = annotation_dir == "BGnot"
|
||||
|
||||
concat_image = concatenate(concat_images)
|
||||
if incomplete:
|
||||
return ExitCode.PARTIAL, "\n".join(logs)
|
||||
with staged_files(output_dir, remove=("Concat_F.pdf", "Concat_F.jpg")) as staging:
|
||||
with staged_files(output_dir, remove=("Concat.jpg", "Concat_F.pdf", "Concat_F.jpg",
|
||||
"touched.json", "answer_labels.json")) as staging:
|
||||
if simple_layout is not None:
|
||||
simple_layout["replaced"] = sorted(
|
||||
set(simple_layout["replaced"]) | selected_labels
|
||||
@@ -342,6 +351,9 @@ def apply_actions_and_regenerate_grouped(
|
||||
for label, image in dirty_images.items():
|
||||
image.save(staging / f"{label}.jpg")
|
||||
atomic_write_json(staging / "score.json", scores)
|
||||
atomic_write_json(staging / "info.json", build_answer_info(
|
||||
scores, labels_data, answer_labels, touched
|
||||
))
|
||||
if concat_image is not None:
|
||||
concat_image.save(staging / "Concat.jpg")
|
||||
if filtered_groups:
|
||||
|
||||
@@ -33,6 +33,12 @@ else:
|
||||
|
||||
# Keep new optional settings compatible with older personal configuration files.
|
||||
ALWAYS_CROP = False
|
||||
RETURN_JPEG_ENABLED = True
|
||||
RETURN_PDF_ENABLED = True
|
||||
RETURN_ANSWERS_ENABLED = False
|
||||
RETURN_ANSWERS_CONTEXT = False
|
||||
RETURN_ANSWERS_QUESTION = True
|
||||
RETURN_ANSWERS_SOLUTION = False
|
||||
for _name in dir(_configuration):
|
||||
if not _name.startswith("_"):
|
||||
globals()[_name] = getattr(_configuration, _name)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import atomic_write_json, configuration, read_json, utils
|
||||
from copienator.filesystem import staged_directory
|
||||
from copienator.platform import safe_filename
|
||||
|
||||
|
||||
def publish_answer_returns(root: Path, source: Path, destination: Path) -> None:
|
||||
"""Publish individual reviewed answers and per-question information."""
|
||||
scores = read_json(source / "score.json")
|
||||
if not isinstance(scores, dict):
|
||||
raise ValueError(f"Expected a score object in {source}")
|
||||
info_path = source / "info.json"
|
||||
if not info_path.is_file():
|
||||
raise ValueError(f"Missing {info_path}; recompile annotations before giving-names")
|
||||
info = read_json(info_path)
|
||||
if not isinstance(info, dict) or set(info) != set(scores):
|
||||
raise ValueError(f"Invalid question information in {info_path}; recompile annotations")
|
||||
for label, entry in info.items():
|
||||
if (
|
||||
not isinstance(entry, dict)
|
||||
or set(entry) != {"present", "not_empty", "touched", "score"}
|
||||
or any(type(entry[key]) is not bool for key in ("present", "not_empty", "touched"))
|
||||
or (entry["not_empty"] and not entry["present"])
|
||||
or (entry["touched"] and not entry["not_empty"])
|
||||
):
|
||||
raise ValueError(f"Invalid question information in {info_path}; recompile annotations")
|
||||
# Match score.json, including manual score edits awaiting recompilation.
|
||||
entry["score"] = scores[label]
|
||||
|
||||
answers_dir = destination / "answers"
|
||||
if answers_dir.is_symlink():
|
||||
raise ValueError(f"Expected a real answer directory: {answers_dir}")
|
||||
if configuration.RETURN_ANSWERS_ENABLED:
|
||||
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.
|
||||
from copienator.commands.annotating import make_base_image
|
||||
from copienator.commands.reading_annotations import concatenate
|
||||
|
||||
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):
|
||||
paths = []
|
||||
if configuration.RETURN_ANSWERS_CONTEXT:
|
||||
paths.extend(utils.pdf_images_of_contexts(root, label, all_labels))
|
||||
if configuration.RETURN_ANSWERS_QUESTION:
|
||||
paths.append(utils.pdf_image_of_enonce(root, label))
|
||||
if configuration.RETURN_ANSWERS_SOLUTION:
|
||||
paths.append(utils.pdf_image_of_solution(root, label))
|
||||
images = []
|
||||
for path in paths:
|
||||
if path:
|
||||
supplement, _, _ = make_base_image(path)
|
||||
if supplement is None:
|
||||
raise ValueError(f"Could not render {path}")
|
||||
images.append(supplement)
|
||||
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")
|
||||
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)
|
||||
Reference in New Issue
Block a user