Cache for reading annotation
This commit is contained in:
1 parent
55d5cf16c9
commit
ff7496b113
12 files changed
+466
-13
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user