From e7094d850e9e05e730de1a806c98e59c39c32c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Miquel?= Date: Tue, 13 Oct 2026 10:15:57 +0200 Subject: [PATCH] Keep empty answers in the final individual outputs. --- copienator/annotation_modifications.py | 18 ++-- copienator/commands/annotating.py | 3 +- copienator/commands/reading_annotations.py | 33 +++++-- .../commands/reading_grouped_annotations.py | 8 +- copienator/return_answers.py | 2 +- docs/final_output.md | 21 +++-- tests/test_annotation_modifications.py | 23 +++++ tests/test_answer_returns.py | 93 +++++++++++++++++-- 8 files changed, 164 insertions(+), 37 deletions(-) diff --git a/copienator/annotation_modifications.py b/copienator/annotation_modifications.py index 9f54d3b..e27227a 100644 --- a/copienator/annotation_modifications.py +++ b/copienator/annotation_modifications.py @@ -155,7 +155,7 @@ def output_removals(directory: Path) -> tuple[str, ...]: info = read_json(directory / "info.json") if (directory / "info.json").exists() else {} return ("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json", *(f"{label}.jpg" for label, entry in info.items() - if entry.get("not_empty") and Path(label).name == label)) + if Path(label).name == label)) def rebuild(root: Path, mode: str, directory: Path, staging: Path, meta: dict, @@ -169,7 +169,15 @@ def rebuild(root: Path, mode: str, directory: Path, staging: Path, meta: dict, headers, bodies, changed = edited_bodies(directory, meta, instructions) info = deepcopy(meta["info"]) - filtered = set(meta["filtered"]) | changed + for label, entry in info.items(): + # Retained empty scans are images, not evidence of a non-empty answer. + entry["not_empty"] = label in bodies and (entry["not_empty"] or label in changed) + if label in changed: + entry["present"] = label in bodies + entry["score"] = scores.get(label, entry["score"]) + filtered = (set(meta["filtered"]) | changed) & { + label for label, entry in info.items() if entry["not_empty"] + } all_labels = utils.read_all_labels(root) images, groups = [], [] seen_contexts = set() @@ -187,15 +195,13 @@ def rebuild(root: Path, mode: str, directory: Path, staging: Path, meta: dict, fill="black", font_size=30) image = stack([header, body]) image.save(staging / f"{label}.jpg") - images.append(image) + if info[label]["not_empty"]: + images.append(image) if label in filtered: extras = get_extra_pdfs_as_images(root, label, annotating, all_labels, seen_contexts=seen_contexts) if mode == "BGnot" else [] groups.append([*extras, image]) for label, entry in info.items(): - entry["not_empty"] = label in bodies - if label in changed and label in bodies: - entry["present"] = True entry["touched"] = mode == "BGnot" and label in bodies and label in filtered entry["score"] = scores.get(label, entry["score"]) atomic_write_json(staging / "info.json", info) diff --git a/copienator/commands/annotating.py b/copienator/commands/annotating.py index fd95c3b..8e8a2ed 100644 --- a/copienator/commands/annotating.py +++ b/copienator/commands/annotating.py @@ -550,8 +550,7 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite): canvas.paste(image, (0, current_y)) current_y += image.height canvas.save(staging / "Concat.jpg") - elif labels_data: - problems = True + # An all-empty copy still has valid individual return images. return "partial" if problems else "success" diff --git a/copienator/commands/reading_annotations.py b/copienator/commands/reading_annotations.py index 3b2cccd..d33ec0d 100644 --- a/copienator/commands/reading_annotations.py +++ b/copienator/commands/reading_annotations.py @@ -157,10 +157,14 @@ def apply_actions_and_regenerate( """Apply annotations and atomically merge the regenerated student files.""" output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}" bnote_path = output_dir / "bnote.json" - if not bnote_path.is_file(): + only_empty = bool(data[student_id]) and all( + content["result"].get("error") == "empty-answer" + for content in data[student_id].values() + ) + if not bnote_path.is_file() and not only_empty: print(f" Missing {bnote_path}") return ExitCode.PARTIAL - bnote_data = read_json(bnote_path) + bnote_data = read_json(bnote_path) if bnote_path.is_file() else {"images": []} if not isinstance(bnote_data, dict): raise TypeError(f"Expected a JSON object in {bnote_path}") @@ -180,7 +184,14 @@ def apply_actions_and_regenerate( filtered: list[Image.Image] = [] incomplete = False - for image_info in bnote_data.get("images", []): + review_images = list(bnote_data.get("images", [])) + reviewed_labels = {str(item.get("label", "")) for item in review_images if isinstance(item, dict)} + # Empty answers are omitted from the review sheet, but still need return images. + review_images.extend( + {"label": label} for label, content in labels_data.items() + if label not in reviewed_labels and content["result"].get("error") == "empty-answer" + ) + for image_info in review_images: if not isinstance(image_info, dict): incomplete = True continue @@ -191,11 +202,10 @@ 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 + is_empty = result.get("error") == "empty-answer" sub_note = None - if notes_layer is not None: + if notes_layer is not None and "hmin" in image_info: hmin = int(image_info.get("hmin", 0)) hmax = int(image_info.get("hmax", 0)) sub_note = notes_layer.crop((0, hmin, notes_layer.width, hmax)) @@ -213,6 +223,7 @@ def apply_actions_and_regenerate( result, content["coordinates"][0], with_error=False, + with_empty=True, ) if final_image is None: incomplete = True @@ -230,6 +241,8 @@ def apply_actions_and_regenerate( dirty_images[label] = final_image header_heights[label] = new_header_height + if is_empty: + continue answer_labels.append(label) concatenated.append(final_image) if float(scores[label]) != 4.0 or result.get("feedback", []): @@ -302,13 +315,15 @@ def run( status = ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS for student_id in sorted(loaded.data, key=utils.natural_key): output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}" - if not output_dir.is_dir(): + only_empty = all(content["result"].get("error") == "empty-answer" + for content in loaded.data[student_id].values()) + if not output_dir.is_dir() and not only_empty: print(f"Warning: missing annotation directory {output_dir}") status = ExitCode.PARTIAL continue print(f"Processing annotations for: {student_id}") - actions, notes = detect_checks_and_notes(output_dir) - if notes is None and not actions and not update_score: + actions, notes = ([], None) if only_empty else detect_checks_and_notes(output_dir) + if notes is None and not actions and not update_score and not only_empty: print(" No readable annotation input found.") status = ExitCode.PARTIAL continue diff --git a/copienator/commands/reading_grouped_annotations.py b/copienator/commands/reading_grouped_annotations.py index 947f3b0..2e03679 100644 --- a/copienator/commands/reading_grouped_annotations.py +++ b/copienator/commands/reading_grouped_annotations.py @@ -292,8 +292,7 @@ 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 + is_empty = result.get("error") == "empty-answer" saved_image = output_dir / f"{label}.jpg" baseline_entry = (previous_baseline or {}).get("images", {}).get(label) if selected_labels and label not in selected_labels and (saved_image.is_file() or baseline_entry): @@ -319,6 +318,8 @@ def apply_actions_and_regenerate_grouped( ) dirty_images[label] = final_image scores[label] = str(old_scores.get(label, scores[label])) + if is_empty: + continue concat_images.append(final_image) answer_labels.append(label) filtered_labels.append(label) @@ -344,6 +345,7 @@ def apply_actions_and_regenerate_grouped( result, content["coordinates"][0], with_error=False, + with_empty=True, ) if final_image is None: incomplete = True @@ -368,6 +370,8 @@ def apply_actions_and_regenerate_grouped( # Persist every final block, including unchanged answers, for returns. dirty_images[label] = final_image header_heights[label] = new_header_height + if is_empty: + continue answer_labels.append(label) concat_images.append(final_image) diff --git a/copienator/return_answers.py b/copienator/return_answers.py index f06bd4f..bebaad5 100644 --- a/copienator/return_answers.py +++ b/copienator/return_answers.py @@ -84,7 +84,7 @@ def publish_answer_returns( 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"]] + labels = [label for label, entry in info.items() if entry["present"]] # Import the rendering backend only when individual images are requested. from copienator.commands.annotating import make_base_image diff --git a/docs/final_output.md b/docs/final_output.md index 3b400af..5b7489c 100644 --- a/docs/final_output.md +++ b/docs/final_output.md @@ -39,7 +39,7 @@ answer JPEGs. | `Student Name.pdf` | `Concat_F.pdf` | Filtered, paginated correction with context, questions and solutions. | | `score.json` | `score.json` | Per-question scores, including questions omitted from the filtered PDF. | | `info.json` | `info.json` | Answer presence, empty-answer classification, PDF membership and score. | -| `answers/*.jpg` | Final per-label JPEGs selected by `info.json` | One annotated non-empty answer, with optional supplementary material. | +| `answers/*.jpg` | Final per-label JPEGs selected by `info.json` | One annotated answer, including empty scans, with optional supplementary material. | ## Enabling or disabling outputs @@ -69,9 +69,9 @@ and preserves return PDFs when present. ## Individual answer JPEGs With `RETURN_ANSWERS_ENABLED = True`, `giving-names` generates an `answers/` -subdirectory inside each student's return folder. It includes **every non-empty -compiled answer**, even a perfect answer omitted from the filtered PDF. Labels -marked `empty-answer` and labels without an answer are excluded. Every JPEG +subdirectory inside each student's return folder. It includes **every present +compiled answer**, even a perfect answer omitted from the filtered PDF. Scans +marked `empty-answer` are included; labels without an answer are excluded. Every JPEG contains the final annotated student answer, including retained feedback and extracted handwriting. @@ -92,7 +92,7 @@ Filenames use natural label order, a three-digit minimum sequence number, and a sanitized label (`001 - Ex 1.jpg`). Numbering prevents filename collisions when labels differ only by characters forbidden in filenames. JSON keys retain exact labels. The managed `answers/` directory is replaced on successful generation, -so removed/empty answers do not leave stale images; failures preserve the previous +so removed answers do not leave stale images; failures preserve the previous directory. Disabling the option clears this directory on the next `giving-names` run. Separate storage keeps these images out of the personal final-mark stamping step, which reads only JPEGs directly inside the student's folder. @@ -116,11 +116,13 @@ Every label in `score.json` has an object containing exactly four fields: ``` - `present`: an answer entry exists for this student and label in the compilation - data. A supplied answer judged empty still has `present: true`. + data. A supplied answer judged empty still has `present: true`. A body removed by + manual annotation edits has `present: false`. - `not_empty`: the answer was not marked `empty-answer` and was successfully compiled. Absent answers have `not_empty: false`. When individual export is - enabled, a JPEG is generated if and only if both `present` and `not_empty` are - true. These fields describe the answer regardless of export settings. + enabled, every `present` answer gets a JPEG, including those with `not_empty: false`. + Image retention alone does not change the empty classification. These fields + describe the answer regardless of export settings. - `touched`: the answer appears in the compiled filtered `Concat_F.pdf`, using the actual selection including handwriting and selective redo preservation. It is not a flag for human edits. Empty and absent answers have `false`. @@ -151,7 +153,8 @@ The result is one RGB image of variable height, with no page breaks. It contains all successfully compiled answer blocks, including answers scored 4 with no remaining feedback. “Full” refers to those answer blocks, not the original scan pages or every question in the statement. Missing/unrenderable answers cannot -be included, and the renderer normally suppresses `empty-answer` results. +be included. Empty-answer scans are retained individually but excluded from +the combined and filtered documents. The grouped compiler refuses to publish a new set when compilation is incomplete. The JPEG does not prepend the question, context or model solution PDFs. diff --git a/tests/test_annotation_modifications.py b/tests/test_annotation_modifications.py index a357a24..0668505 100644 --- a/tests/test_annotation_modifications.py +++ b/tests/test_annotation_modifications.py @@ -115,6 +115,29 @@ class ModificationTests(unittest.TestCase): self.assertFalse((self.output / "Empty.jpg").exists()) self.assertFalse(read_json(self.output / "info.json")["Empty"]["not_empty"]) + def test_retained_empty_scan_stays_empty_after_unrelated_edits_and_can_be_replaced(self): + self.images["Empty"] = Image.new("RGB", (100, 60), "white") + self.heights["Empty"] = 10 + self.info["Empty"]["present"] = True + self.seed() + self.apply("Copie43 Ex 1 -> Ex 2|") + info = read_json(self.output / "info.json") + self.assertTrue((self.output / "Empty.jpg").is_file()) + self.assertEqual(info["Empty"], self.info["Empty"]) + with Image.open(self.output / "Concat.jpg") as image: + self.assertEqual(image.height, 120 + 180 + 40) + self.apply("Copie43 Ex 1 -> Empty") + self.assertTrue(read_json(self.output / "info.json")["Empty"]["not_empty"]) + self.apply() + self.assertEqual(read_json(self.output / "info.json")["Empty"], self.info["Empty"]) + + def test_removed_body_is_absent_and_not_exported_as_an_empty_scan(self): + self.apply("Copie43 Ex 1 x> Ex 2") + info = read_json(self.output / "info.json")["Ex 1"] + self.assertFalse(info["present"]) + self.assertFalse(info["not_empty"]) + self.assertFalse((self.output / "Ex 1.jpg").exists()) + def test_invalid_file_does_not_apply_valid_first_instruction(self): before = {p.name: p.read_bytes() for p in self.output.iterdir() if p.is_file()} with self.assertRaises(CliError): diff --git a/tests/test_answer_returns.py b/tests/test_answer_returns.py index f4c4f52..1c39969 100644 --- a/tests/test_answer_returns.py +++ b/tests/test_answer_returns.py @@ -57,8 +57,8 @@ class AnswerReturnTests(unittest.TestCase): ), patch.object(annotating, "make_base_image", side_effect=self.supplement): publish_answer_returns(self.root, self.source, self.destination) files = sorted((self.destination / "answers").glob("*.jpg")) - self.assertEqual([p.name for p in files], ["Ex 1.jpg", "Ex 2.jpg"]) - with Image.open(files[0]) as image: + self.assertEqual([p.name for p in files], ["Empty.jpg", "Ex 1.jpg", "Ex 2.jpg"]) + with Image.open(self.destination / "answers" / "Ex 1.jpg") as image: self.assertEqual(image.size, (100, 30 + 20 * sum((context, question, solution)))) colors = [] if context: @@ -212,7 +212,7 @@ class AnswerReturnTests(unittest.TestCase): ) self.assertEqual( sorted(path.name for path in answers.iterdir()), - ["Ex 1.jpg", "Ex 2.jpg"], + ["Empty.jpg", "Ex 1.jpg", "Ex 2.jpg"], ) @@ -304,7 +304,7 @@ class CompiledMembershipTests(unittest.TestCase): self.assertEqual(info["Empty"], {"present": True, "not_empty": False, "touched": False, "score": "0"}) self.assertEqual(info["Absent"], {"present": False, "not_empty": False, "touched": False, "score": ""}) self.assertEqual(info["Perfect"], {"present": True, "not_empty": True, "touched": False, "score": "4"}) - self.assertNotIn("Empty", rendered) + self.assertIn("Empty", rendered) for label in rendered: self.assertTrue((output / f"{label}.jpg").is_file()) # Selective redo keeps unselected saved answers in the PDF, @@ -316,6 +316,76 @@ class CompiledMembershipTests(unittest.TestCase): self.assertTrue(read_json(output / "info.json")["Perfect"]["touched"]) self.assertFalse(read_json(output / "info.json")["Empty"]["not_empty"]) + def test_real_empty_pdf_is_published_in_all_annotation_modes(self): + import fitz + from copienator.commands import reading_annotations as standard + for mode in ("Anot", "Bnot", "BGnot"): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + workspace = EvaluationWorkspace(root) + (root / "labels").write_text("Empty\nAbsent\n") + pdf = root / "answer.pdf" + with fitz.open() as document: + page = document.new_page(width=200, height=60) + page.insert_text((10, 30), "Copied question only") + document.save(pdf) + data = {"01": {"Empty": {"pdf_path": pdf, "coordinates": (0, 0), + "result": {"score": 0, "error": "empty-answer", "feedback": []}}}} + if mode == "Anot": + status = annotating.process_student("01", data["01"], root, ["Empty", "Absent"], True) + self.assertEqual(status, "success") + elif mode == "Bnot": + status = standard.apply_actions_and_regenerate( + workspace, data, "01", [], None, ["Empty", "Absent"]) + self.assertEqual(status, 0) + else: + status, _ = reader.apply_actions_and_regenerate_grouped( + workspace, data, "01", [], {}, ["Empty", "Absent"]) + self.assertEqual(status, 0) + output = root / mode / "Copie01" + destination = root / "return" + destination.mkdir() + with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True, + RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=False, + RETURN_ANSWERS_SOLUTION=False): + publish_answer_returns(root, output, destination) + self.assertEqual([p.name for p in (destination / "answers").iterdir()], ["Empty.jpg"]) + with Image.open(destination / "answers" / "Empty.jpg") as image: + self.assertGreater(image.height, 0) + self.assertLess(image.convert("L").getextrema()[0], 100) + self.assertFalse(read_json(destination / "info.json")["Empty"]["not_empty"]) + self.assertFalse((output / "Concat.jpg").exists()) + self.assertFalse((output / "Concat_F.pdf").exists()) + + def test_bnot_recovers_empty_scans_missing_from_review_layout(self): + from copienator.commands import reading_annotations as standard + for only_empty in (False, True): + with self.subTest(only_empty=only_empty), tempfile.TemporaryDirectory() as directory: + workspace = EvaluationWorkspace(Path(directory)) + output = workspace.root / "Bnot" / "Copie01" + output.mkdir(parents=True) + answer = workspace.root / "answer.pdf" + answer.touch() + labels = {"Empty": {"pdf_path": answer, "coordinates": (0, 0), + "result": {"score": 0, "error": "empty-answer"}}} + if not only_empty: + labels["Full"] = {"pdf_path": answer, "coordinates": (0, 0), + "result": {"score": 4}} + atomic_write_json(output / "bnote.json", {"images": [ + {"label": "Full", "hmin": 0, "hmax": 50, "header_height": 0}]}) + with patch.object(annotating, "make_base_image", return_value=(None, 0, 0)), patch.object( + annotating, "compose_label_image", return_value=(Image.new("RGB", (100, 50)), 0) + ) as render: + status = standard.apply_actions_and_regenerate( + workspace, {"01": labels}, "01", [], None, [*labels, "Absent"]) + self.assertEqual(status, 0) + self.assertTrue(all(call.kwargs["with_empty"] for call in render.call_args_list)) + self.assertTrue((output / "Empty.jpg").is_file()) + self.assertFalse((output / "Absent.jpg").exists()) + self.assertFalse(read_json(output / "info.json")["Empty"]["not_empty"]) + self.assertEqual((output / "Concat.jpg").exists(), not only_empty) + self.assertFalse((output / "Concat_F.jpg").exists()) + def test_all_empty_removes_stale_concat_and_records_false(self): with tempfile.TemporaryDirectory() as directory: workspace = EvaluationWorkspace(Path(directory)) @@ -325,10 +395,17 @@ class CompiledMembershipTests(unittest.TestCase): (output / name).write_bytes(b"stale") atomic_write_json(output / "touched.json", {"Empty": True}) atomic_write_json(output / "answer_labels.json", ["Empty"]) - status, _ = reader.apply_actions_and_regenerate_grouped( - workspace, {"01": {"Empty": {"result": {"score": 0, "error": "empty-answer"}}}}, - "01", [], {}, ["Empty"] - ) + answer = workspace.root / "answer.pdf" + answer.touch() + with patch.object(annotating, "make_base_image", return_value=(Image.new("RGB", (100, 50)), 0, 0)), patch.object( + annotating, "compose_label_image", return_value=(Image.new("RGB", (100, 50)), 0) + ): + status, _ = reader.apply_actions_and_regenerate_grouped( + workspace, {"01": {"Empty": {"result": {"score": 0, "error": "empty-answer"}, + "pdf_path": answer, "coordinates": (0, 0)}}}, + "01", [], {}, ["Empty"] + ) + self.assertTrue((output / "Empty.jpg").is_file()) self.assertEqual(status, 0) self.assertEqual(read_json(output / "info.json"), { "Empty": {"present": True, "not_empty": False, "touched": False, "score": "0"}