Keep empty answers in the final individual outputs.
This commit is contained in:
1 parent
b1f1e0bc86
commit
e7094d850e
8 files changed
+164
-37
No files matched your search
@@ -155,7 +155,7 @@ def output_removals(directory: Path) -> tuple[str, ...]:
|
|||||||
info = read_json(directory / "info.json") if (directory / "info.json").exists() else {}
|
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",
|
return ("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json",
|
||||||
*(f"{label}.jpg" for label, entry in info.items()
|
*(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,
|
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)
|
headers, bodies, changed = edited_bodies(directory, meta, instructions)
|
||||||
info = deepcopy(meta["info"])
|
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)
|
all_labels = utils.read_all_labels(root)
|
||||||
images, groups = [], []
|
images, groups = [], []
|
||||||
seen_contexts = set()
|
seen_contexts = set()
|
||||||
@@ -187,15 +195,13 @@ def rebuild(root: Path, mode: str, directory: Path, staging: Path, meta: dict,
|
|||||||
fill="black", font_size=30)
|
fill="black", font_size=30)
|
||||||
image = stack([header, body])
|
image = stack([header, body])
|
||||||
image.save(staging / f"{label}.jpg")
|
image.save(staging / f"{label}.jpg")
|
||||||
images.append(image)
|
if info[label]["not_empty"]:
|
||||||
|
images.append(image)
|
||||||
if label in filtered:
|
if label in filtered:
|
||||||
extras = get_extra_pdfs_as_images(root, label, annotating, all_labels,
|
extras = get_extra_pdfs_as_images(root, label, annotating, all_labels,
|
||||||
seen_contexts=seen_contexts) if mode == "BGnot" else []
|
seen_contexts=seen_contexts) if mode == "BGnot" else []
|
||||||
groups.append([*extras, image])
|
groups.append([*extras, image])
|
||||||
for label, entry in info.items():
|
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["touched"] = mode == "BGnot" and label in bodies and label in filtered
|
||||||
entry["score"] = scores.get(label, entry["score"])
|
entry["score"] = scores.get(label, entry["score"])
|
||||||
atomic_write_json(staging / "info.json", info)
|
atomic_write_json(staging / "info.json", info)
|
||||||
|
|||||||
@@ -550,8 +550,7 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
|
|||||||
canvas.paste(image, (0, current_y))
|
canvas.paste(image, (0, current_y))
|
||||||
current_y += image.height
|
current_y += image.height
|
||||||
canvas.save(staging / "Concat.jpg")
|
canvas.save(staging / "Concat.jpg")
|
||||||
elif labels_data:
|
# An all-empty copy still has valid individual return images.
|
||||||
problems = True
|
|
||||||
return "partial" if problems else "success"
|
return "partial" if problems else "success"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -157,10 +157,14 @@ def apply_actions_and_regenerate(
|
|||||||
"""Apply annotations and atomically merge the regenerated student files."""
|
"""Apply annotations and atomically merge the regenerated student files."""
|
||||||
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
|
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
|
||||||
bnote_path = output_dir / "bnote.json"
|
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}")
|
print(f" Missing {bnote_path}")
|
||||||
return ExitCode.PARTIAL
|
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):
|
if not isinstance(bnote_data, dict):
|
||||||
raise TypeError(f"Expected a JSON object in {bnote_path}")
|
raise TypeError(f"Expected a JSON object in {bnote_path}")
|
||||||
|
|
||||||
@@ -180,7 +184,14 @@ def apply_actions_and_regenerate(
|
|||||||
filtered: list[Image.Image] = []
|
filtered: list[Image.Image] = []
|
||||||
incomplete = False
|
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):
|
if not isinstance(image_info, dict):
|
||||||
incomplete = True
|
incomplete = True
|
||||||
continue
|
continue
|
||||||
@@ -191,11 +202,10 @@ def apply_actions_and_regenerate(
|
|||||||
content = labels_data[label]
|
content = labels_data[label]
|
||||||
result = content["result"]
|
result = content["result"]
|
||||||
scores[label] = str(result.get("score", 0))
|
scores[label] = str(result.get("score", 0))
|
||||||
if result.get("error") == "empty-answer":
|
is_empty = result.get("error") == "empty-answer"
|
||||||
continue
|
|
||||||
|
|
||||||
sub_note = None
|
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))
|
hmin = int(image_info.get("hmin", 0))
|
||||||
hmax = int(image_info.get("hmax", 0))
|
hmax = int(image_info.get("hmax", 0))
|
||||||
sub_note = notes_layer.crop((0, hmin, notes_layer.width, hmax))
|
sub_note = notes_layer.crop((0, hmin, notes_layer.width, hmax))
|
||||||
@@ -213,6 +223,7 @@ def apply_actions_and_regenerate(
|
|||||||
result,
|
result,
|
||||||
content["coordinates"][0],
|
content["coordinates"][0],
|
||||||
with_error=False,
|
with_error=False,
|
||||||
|
with_empty=True,
|
||||||
)
|
)
|
||||||
if final_image is None:
|
if final_image is None:
|
||||||
incomplete = True
|
incomplete = True
|
||||||
@@ -230,6 +241,8 @@ def apply_actions_and_regenerate(
|
|||||||
|
|
||||||
dirty_images[label] = final_image
|
dirty_images[label] = final_image
|
||||||
header_heights[label] = new_header_height
|
header_heights[label] = new_header_height
|
||||||
|
if is_empty:
|
||||||
|
continue
|
||||||
answer_labels.append(label)
|
answer_labels.append(label)
|
||||||
concatenated.append(final_image)
|
concatenated.append(final_image)
|
||||||
if float(scores[label]) != 4.0 or result.get("feedback", []):
|
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
|
status = ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
|
||||||
for student_id in sorted(loaded.data, key=utils.natural_key):
|
for student_id in sorted(loaded.data, key=utils.natural_key):
|
||||||
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
|
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}")
|
print(f"Warning: missing annotation directory {output_dir}")
|
||||||
status = ExitCode.PARTIAL
|
status = ExitCode.PARTIAL
|
||||||
continue
|
continue
|
||||||
print(f"Processing annotations for: {student_id}")
|
print(f"Processing annotations for: {student_id}")
|
||||||
actions, notes = detect_checks_and_notes(output_dir)
|
actions, notes = ([], None) if only_empty else detect_checks_and_notes(output_dir)
|
||||||
if notes is None and not actions and not update_score:
|
if notes is None and not actions and not update_score and not only_empty:
|
||||||
print(" No readable annotation input found.")
|
print(" No readable annotation input found.")
|
||||||
status = ExitCode.PARTIAL
|
status = ExitCode.PARTIAL
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -292,8 +292,7 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
result["score"] = old_scores[label]
|
result["score"] = old_scores[label]
|
||||||
scores[label] = str(result.get("score", 0))
|
scores[label] = str(result.get("score", 0))
|
||||||
touched[label] = False
|
touched[label] = False
|
||||||
if result.get("error") == "empty-answer":
|
is_empty = result.get("error") == "empty-answer"
|
||||||
continue
|
|
||||||
saved_image = output_dir / f"{label}.jpg"
|
saved_image = output_dir / f"{label}.jpg"
|
||||||
baseline_entry = (previous_baseline or {}).get("images", {}).get(label)
|
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):
|
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
|
dirty_images[label] = final_image
|
||||||
scores[label] = str(old_scores.get(label, scores[label]))
|
scores[label] = str(old_scores.get(label, scores[label]))
|
||||||
|
if is_empty:
|
||||||
|
continue
|
||||||
concat_images.append(final_image)
|
concat_images.append(final_image)
|
||||||
answer_labels.append(label)
|
answer_labels.append(label)
|
||||||
filtered_labels.append(label)
|
filtered_labels.append(label)
|
||||||
@@ -344,6 +345,7 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
result,
|
result,
|
||||||
content["coordinates"][0],
|
content["coordinates"][0],
|
||||||
with_error=False,
|
with_error=False,
|
||||||
|
with_empty=True,
|
||||||
)
|
)
|
||||||
if final_image is None:
|
if final_image is None:
|
||||||
incomplete = True
|
incomplete = True
|
||||||
@@ -368,6 +370,8 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
# Persist every final block, including unchanged answers, for returns.
|
# Persist every final block, including unchanged answers, for returns.
|
||||||
dirty_images[label] = final_image
|
dirty_images[label] = final_image
|
||||||
header_heights[label] = new_header_height
|
header_heights[label] = new_header_height
|
||||||
|
if is_empty:
|
||||||
|
continue
|
||||||
answer_labels.append(label)
|
answer_labels.append(label)
|
||||||
concat_images.append(final_image)
|
concat_images.append(final_image)
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ def publish_answer_returns(
|
|||||||
raise ValueError(f"Expected a real answer directory: {answers_dir}")
|
raise ValueError(f"Expected a real answer directory: {answers_dir}")
|
||||||
if configuration.RETURN_ANSWERS_ENABLED:
|
if configuration.RETURN_ANSWERS_ENABLED:
|
||||||
options = load_return_answer_options(root)
|
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.
|
# Import the rendering backend only when individual images are requested.
|
||||||
from copienator.commands.annotating import make_base_image
|
from copienator.commands.annotating import make_base_image
|
||||||
|
|||||||
+12
-9
@@ -39,7 +39,7 @@ answer JPEGs.
|
|||||||
| `Student Name.pdf` | `Concat_F.pdf` | Filtered, paginated correction with context, questions and solutions. |
|
| `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. |
|
| `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. |
|
| `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
|
## Enabling or disabling outputs
|
||||||
|
|
||||||
@@ -69,9 +69,9 @@ and preserves return PDFs when present.
|
|||||||
## Individual answer JPEGs
|
## Individual answer JPEGs
|
||||||
|
|
||||||
With `RETURN_ANSWERS_ENABLED = True`, `giving-names` generates an `answers/`
|
With `RETURN_ANSWERS_ENABLED = True`, `giving-names` generates an `answers/`
|
||||||
subdirectory inside each student's return folder. It includes **every non-empty
|
subdirectory inside each student's return folder. It includes **every present
|
||||||
compiled answer**, even a perfect answer omitted from the filtered PDF. Labels
|
compiled answer**, even a perfect answer omitted from the filtered PDF. Scans
|
||||||
marked `empty-answer` and labels without an answer are excluded. Every JPEG
|
marked `empty-answer` are included; labels without an answer are excluded. Every JPEG
|
||||||
contains the final annotated student answer, including retained feedback and
|
contains the final annotated student answer, including retained feedback and
|
||||||
extracted handwriting.
|
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
|
sanitized label (`001 - Ex 1.jpg`). Numbering prevents filename collisions when
|
||||||
labels differ only by characters forbidden in filenames. JSON keys retain exact
|
labels differ only by characters forbidden in filenames. JSON keys retain exact
|
||||||
labels. The managed `answers/` directory is replaced on successful generation,
|
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`
|
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
|
run. Separate storage keeps these images out of the personal final-mark stamping
|
||||||
step, which reads only JPEGs directly inside the student's folder.
|
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
|
- `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
|
- `not_empty`: the answer was not marked `empty-answer` and was successfully
|
||||||
compiled. Absent answers have `not_empty: false`. When individual export is
|
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
|
enabled, every `present` answer gets a JPEG, including those with `not_empty: false`.
|
||||||
true. These fields describe the answer regardless of export settings.
|
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
|
- `touched`: the answer appears in the compiled filtered `Concat_F.pdf`, using
|
||||||
the actual selection including handwriting and selective redo preservation.
|
the actual selection including handwriting and selective redo preservation.
|
||||||
It is not a flag for human edits. Empty and absent answers have `false`.
|
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
|
all successfully compiled answer blocks, including answers scored 4 with no
|
||||||
remaining feedback. “Full” refers to those answer blocks, not the original scan
|
remaining feedback. “Full” refers to those answer blocks, not the original scan
|
||||||
pages or every question in the statement. Missing/unrenderable answers cannot
|
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 grouped compiler refuses to publish a new set when compilation is incomplete.
|
||||||
|
|
||||||
The JPEG does not prepend the question, context or model solution PDFs.
|
The JPEG does not prepend the question, context or model solution PDFs.
|
||||||
|
|||||||
@@ -115,6 +115,29 @@ class ModificationTests(unittest.TestCase):
|
|||||||
self.assertFalse((self.output / "Empty.jpg").exists())
|
self.assertFalse((self.output / "Empty.jpg").exists())
|
||||||
self.assertFalse(read_json(self.output / "info.json")["Empty"]["not_empty"])
|
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):
|
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()}
|
before = {p.name: p.read_bytes() for p in self.output.iterdir() if p.is_file()}
|
||||||
with self.assertRaises(CliError):
|
with self.assertRaises(CliError):
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ class AnswerReturnTests(unittest.TestCase):
|
|||||||
), patch.object(annotating, "make_base_image", side_effect=self.supplement):
|
), patch.object(annotating, "make_base_image", side_effect=self.supplement):
|
||||||
publish_answer_returns(self.root, self.source, self.destination)
|
publish_answer_returns(self.root, self.source, self.destination)
|
||||||
files = sorted((self.destination / "answers").glob("*.jpg"))
|
files = sorted((self.destination / "answers").glob("*.jpg"))
|
||||||
self.assertEqual([p.name for p in files], ["Ex 1.jpg", "Ex 2.jpg"])
|
self.assertEqual([p.name for p in files], ["Empty.jpg", "Ex 1.jpg", "Ex 2.jpg"])
|
||||||
with Image.open(files[0]) as image:
|
with Image.open(self.destination / "answers" / "Ex 1.jpg") as image:
|
||||||
self.assertEqual(image.size, (100, 30 + 20 * sum((context, question, solution))))
|
self.assertEqual(image.size, (100, 30 + 20 * sum((context, question, solution))))
|
||||||
colors = []
|
colors = []
|
||||||
if context:
|
if context:
|
||||||
@@ -212,7 +212,7 @@ class AnswerReturnTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(path.name for path in answers.iterdir()),
|
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["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["Absent"], {"present": False, "not_empty": False, "touched": False, "score": ""})
|
||||||
self.assertEqual(info["Perfect"], {"present": True, "not_empty": True, "touched": False, "score": "4"})
|
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:
|
for label in rendered:
|
||||||
self.assertTrue((output / f"{label}.jpg").is_file())
|
self.assertTrue((output / f"{label}.jpg").is_file())
|
||||||
# Selective redo keeps unselected saved answers in the PDF,
|
# 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.assertTrue(read_json(output / "info.json")["Perfect"]["touched"])
|
||||||
self.assertFalse(read_json(output / "info.json")["Empty"]["not_empty"])
|
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):
|
def test_all_empty_removes_stale_concat_and_records_false(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
workspace = EvaluationWorkspace(Path(directory))
|
workspace = EvaluationWorkspace(Path(directory))
|
||||||
@@ -325,10 +395,17 @@ class CompiledMembershipTests(unittest.TestCase):
|
|||||||
(output / name).write_bytes(b"stale")
|
(output / name).write_bytes(b"stale")
|
||||||
atomic_write_json(output / "touched.json", {"Empty": True})
|
atomic_write_json(output / "touched.json", {"Empty": True})
|
||||||
atomic_write_json(output / "answer_labels.json", ["Empty"])
|
atomic_write_json(output / "answer_labels.json", ["Empty"])
|
||||||
status, _ = reader.apply_actions_and_regenerate_grouped(
|
answer = workspace.root / "answer.pdf"
|
||||||
workspace, {"01": {"Empty": {"result": {"score": 0, "error": "empty-answer"}}}},
|
answer.touch()
|
||||||
"01", [], {}, ["Empty"]
|
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(status, 0)
|
||||||
self.assertEqual(read_json(output / "info.json"), {
|
self.assertEqual(read_json(output / "info.json"), {
|
||||||
"Empty": {"present": True, "not_empty": False, "touched": False, "score": "0"}
|
"Empty": {"present": True, "not_empty": False, "touched": False, "score": "0"}
|
||||||
|
|||||||
Reference in new issue
Block a user