Cache for reading annotation

This commit is contained in:
sebastien committed 2026-10-24 10:22:41 +02:00
1 parent 55d5cf16c9
commit ff7496b113
12 files changed
+466 -13

No files matched your search

+2 -1
View File
@@ -9,6 +9,7 @@ from pathlib import Path
from PIL import Image, ImageDraw
from copienator import CliError, atomic_write_json, read_json, utils
from copienator.annotation_read_cache import STATE_FILE
from copienator.commands.resolve_manual import ManualInstruction, parse_instruction_text
DRAFT = "annotation_modifications.txt"
@@ -153,7 +154,7 @@ def edited_bodies(directory: Path, meta: dict, instructions: list[ManualInstruct
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",
return (STATE_FILE, "Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json",
*(f"{label}.jpg" for label, entry in info.items()
if Path(label).name == label))
+77
View File
@@ -0,0 +1,77 @@
"""Small content fingerprints for incremental --update-score reads."""
from hashlib import file_digest, sha256
import json
from pathlib import Path
from copienator import atomic_write_json, configuration, read_json
from copienator.annotation_documents import copy_documents
from copienator.return_answers import load_return_answer_options
STATE_FILE = ".annotation-read-state.json"
DOCUMENT_INPUTS = ("bnote.json", "checkboxes.json", "Reference.jpg", "Concat_annotated.pdf")
def _files_digest(paths) -> str:
digest = sha256()
for path in sorted(set(paths)):
digest.update(str(path).encode())
if path.is_file():
with path.open("rb") as stream:
digest.update(file_digest(stream, "sha256").digest())
else:
digest.update(b"missing")
return digest.hexdigest()
class AnnotationReadCache:
def __init__(self, workspace, mode: str, data: dict, group_dirs=()):
self.workspace = workspace
self.mode = mode
self.signatures = {}
root = workspace.root
shared = [root / "labels", root / mode / "annotation_modifications_applied.txt"]
for folder in ("Text2", "Sol2"):
shared.extend((root / folder).glob("*.pdf"))
for directory in group_dirs:
shared.extend(directory / name for name in DOCUMENT_INPUTS)
common = (_files_digest(shared), load_return_answer_options(root),
configuration.RETURN_ANSWERS_ENABLED,
configuration.LATEX_ANOT_BEFORE, configuration.LATEX_ANOT_AFTER)
for copy_id, labels in data.items():
directory = root / mode / f"Copie{copy_id}"
paths = [directory / "score.json", directory / "bnote.json"]
for document in copy_documents(directory):
paths.extend(document / name for name in DOCUMENT_INPUTS)
for content in labels.values():
pdf = Path(content["pdf_path"])
paths.extend((pdf, pdf.with_suffix(".layout.json")))
payload = json.dumps((1, common, labels, _files_digest(paths)),
sort_keys=True, default=str).encode()
self.signatures[copy_id] = sha256(payload).hexdigest()
def unchanged(self, copy_id: str) -> bool:
directory = self.workspace.root / self.mode / f"Copie{copy_id}"
try:
state = read_json(directory / STATE_FILE)
return (state["signature"] == self.signatures[copy_id]
and bool(state["outputs"])
and all(Path(path).is_file() for path in state["outputs"]))
except (OSError, ValueError, TypeError, KeyError):
return False
def remember(self, copy_ids) -> None:
for copy_id in copy_ids:
directory = self.workspace.root / self.mode / f"Copie{copy_id}"
outputs = [directory / "info.json"]
info = read_json(directory / "info.json")
outputs.extend(directory / f"{label}.jpg" for label, entry in info.items()
if entry["present"])
outputs.extend(directory.glob("Concat*.*"))
outputs.extend(directory.glob(".annotation-base-*.png"))
outputs.extend(directory.glob("annotation_baseline.json"))
for destination in self.workspace.return_dir.glob(f"* ({copy_id})"):
outputs.extend((destination / "answers").glob("*.jpg"))
atomic_write_json(directory / STATE_FILE, {
"signature": self.signatures[copy_id],
"outputs": [str(path) for path in outputs],
})
+36
View File
@@ -2,11 +2,13 @@ import argparse
import math
import shutil
import sys
from decimal import Decimal, InvalidOperation, ROUND_CEILING
from pathlib import Path
import pandas as pd
from PIL import Image, ImageDraw, ImageFont
from copienator import atomic_write_json, read_json
from copienator.configuration import (
FINAL_SCORE_FONT_PATH,
FINAL_SCORE_HISTOGRAM_PATH,
@@ -72,6 +74,39 @@ def copy_histogram(output_dir: Path, *, copy_merged_histogramme: bool = False) -
shutil.copy2(source, destination)
print(f"Copied histogram: {destination}")
def write_score_statistics(search_path: Path, output_dir: Path) -> None:
"""Average available per-question scores, rounding upward to one decimal."""
scores_by_label = {}
for path in sorted(search_path.glob("*/score.json")):
try:
scores = read_json(path)
if not isinstance(scores, dict):
raise ValueError("expected an object of per-label scores")
except (OSError, ValueError) as exc:
print(f"Warning: Could not read scores from '{path}': {exc}")
continue
for label, value in scores.items():
values = scores_by_label.setdefault(label, [])
if value is None or (isinstance(value, str) and not value.strip()):
continue
try:
number = Decimal(str(value))
if not number.is_finite():
raise InvalidOperation
except InvalidOperation:
print(f"Warning: Invalid score '{value}' for '{label}' in '{path}'.")
continue
values.append(number)
statistics = {
label: str((sum(values) / len(values)).quantize(Decimal("0.1"), rounding=ROUND_CEILING))
if values else ""
for label, values in scores_by_label.items()
}
atomic_write_json(output_dir / "stat.json", statistics)
print(f"Wrote per-label averages: {output_dir / 'stat.json'}")
def process_images(base_dir, output_dir, *, copy_merged_histogramme=False):
# 1. Load Data
try:
@@ -152,6 +187,7 @@ def process_images(base_dir, output_dir, *, copy_merged_histogramme=False):
for pdf_path in pdf_paths:
shutil.copy2(pdf_path, student_output / pdf_path.name)
write_score_statistics(search_path, output_dir)
copy_histogram(output_dir, copy_merged_histogramme=copy_merged_histogramme)
+9 -3
View File
@@ -4,7 +4,7 @@ import argparse
import re
import sys
from collections import defaultdict
from collections.abc import Sequence
from collections.abc import Collection, Sequence
from pathlib import Path
from copienator import (
@@ -77,8 +77,12 @@ def _annotation_source(
def update_named_return_answers(
workspace: EvaluationWorkspace,
annotation_dir_name: str,
*,
copy_ids: Collection[str] | None = None,
) -> ExitCode:
"""Refresh only answers/ in existing returns, preserving manual names."""
"""Refresh answers/ in existing returns, optionally only successful rereads."""
if copy_ids is not None and (not copy_ids or not workspace.return_dir.is_dir()):
return ExitCode.SUCCESS
workspace.require_directories(annotation_dir_name, "A Rendre")
had_errors = False
found = False
@@ -86,6 +90,8 @@ def update_named_return_answers(
if not destination.is_dir():
continue
match = RETURN_COPY_ID.search(destination.name)
if copy_ids is not None and (match is None or match.group(1) not in copy_ids):
continue
if match is None:
print(
f"Warning: cannot identify a copy ID in {destination.name!r}; skipped",
@@ -116,7 +122,7 @@ def update_named_return_answers(
except (OSError, TypeError, ValueError) as exc:
print(f"Error updating answers for {destination.name}: {exc}", file=sys.stderr)
had_errors = True
if not found:
if not found and copy_ids is None:
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
@@ -24,6 +24,7 @@ from copienator.annotation_actions import apply_checkbox_actions, apply_score_ov
from copienator.annotation_data import AnnotationData, load_annotation_data
from copienator.annotation_documents import copy_documents
from copienator.annotation_layout import legacy_layout
from copienator.annotation_read_cache import AnnotationReadCache
from copienator.annotation_modifications import (
BASELINE,
output_removals,
@@ -31,6 +32,7 @@ from copienator.annotation_modifications import (
)
from copienator.answer_info import build_answer_info
from copienator.commands import annotating
from copienator.commands.giving_names import update_named_return_answers
from copienator.filesystem import staged_files
from copienator.return_answers import save_return_answer_options
@@ -324,7 +326,12 @@ def run(
return ExitCode.PARTIAL
status = ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
cache = AnnotationReadCache(workspace, "Bnot", loaded.data) if update_score else None
regenerated: set[str] = set()
for student_id in sorted(loaded.data, key=utils.natural_key):
if cache is not None and cache.unchanged(student_id):
print(f"Skipping unchanged Copie{student_id}")
continue
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
only_empty = all(content["result"].get("error") == "empty-answer"
for content in loaded.data[student_id].values())
@@ -372,6 +379,12 @@ def run(
)
if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
else:
regenerated.add(student_id)
if update_named_return_answers(workspace, "Bnot", copy_ids=regenerated) != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
elif cache is not None:
cache.remember(regenerated)
return status
@@ -27,6 +27,7 @@ from copienator.annotation_actions import apply_checkbox_actions, apply_score_ov
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
from copienator.annotation_documents import copy_documents, tablet_documents
from copienator.annotation_layout import legacy_layout
from copienator.annotation_read_cache import AnnotationReadCache
from copienator.annotation_modifications import (
BASELINE,
baseline,
@@ -36,6 +37,7 @@ from copienator.annotation_modifications import (
)
from copienator.answer_info import build_answer_info
from copienator.commands import annotating
from copienator.commands.giving_names import update_named_return_answers
from copienator.commands.reading_annotations import (
concatenate,
detect_checks_and_notes,
@@ -594,6 +596,18 @@ def run(
and path.is_dir()
and not path.name.startswith("Copie")
]
# Redo reads deliberately remain full reads: their selected labels and
# preserved baseline images have different dependencies.
cache = (AnnotationReadCache(workspace, annotation_dir, loaded.data, group_dirs)
if update_score and not refaire else None)
pending_ids = set(loaded.data)
if cache is not None:
pending_ids = {copy_id for copy_id in loaded.data if not cache.unchanged(copy_id)}
for copy_id in sorted(set(loaded.data) - pending_ids, key=utils.natural_key):
print(f"Skipping unchanged Copie{copy_id}")
if not pending_ids:
return ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
only_ids = pending_ids
# 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.
@@ -657,6 +671,7 @@ def run(
if refaire
else sorted(loaded.data, key=utils.natural_key)
)
regenerated: set[str] = set()
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
futures = {
executor.submit(
@@ -676,13 +691,19 @@ def run(
),
): student_id
for student_id in student_ids
if student_id in loaded.data and student_id not in skipped_students
if student_id in pending_ids and student_id not in skipped_students
}
for future in concurrent.futures.as_completed(futures):
result, output = future.result()
print(output)
if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
else:
regenerated.add(futures[future])
if update_named_return_answers(workspace, annotation_dir, copy_ids=regenerated) != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
elif cache is not None:
cache.remember(regenerated)
return status