Standardisation 3

This commit is contained in:
2026-08-20 14:35:04 +02:00
parent 63f690b353
commit 8b087bb3e4
9 changed files with 1187 additions and 683 deletions
+4
View File
@@ -7,7 +7,9 @@ from .cli import (
evaluation_workspace,
execute,
standard_parser,
target_parser,
workspace_from_args,
workspace_from_target,
)
from .json_io import (
JsonLockTimeout,
@@ -37,5 +39,7 @@ __all__ = [
"execute",
"read_json",
"standard_parser",
"target_parser",
"workspace_from_args",
"workspace_from_target",
]
+201
View File
@@ -0,0 +1,201 @@
from __future__ import annotations
import copy
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from PIL import Image
from .json_io import read_json
from .workspace import EvaluationWorkspace
AnnotationData = dict[str, dict[str, dict[str, Any]]]
RefaireList = list[list[Any]]
@dataclass(frozen=True, slots=True)
class GroupCoordinates:
minimum: int
maximum: int
width: int
height: int
@dataclass(slots=True)
class AnnotationLoadResult:
data: AnnotationData
warnings: list[str]
def _coordinate_index(
workspace: EvaluationWorkspace,
) -> tuple[dict[tuple[str, str], GroupCoordinates], list[str]]:
index: dict[tuple[str, str], GroupCoordinates] = {}
warnings: list[str] = []
if not workspace.groups_dir.is_dir():
return index, [f"Group directory not found: {workspace.groups_dir}"]
for metadata_path in sorted(workspace.groups_dir.glob("*/Group_*.json")):
image_path = metadata_path.with_suffix(".jpg")
try:
entries = read_json(metadata_path)
if not isinstance(entries, list):
raise TypeError("expected a JSON array")
with Image.open(image_path) as image:
width, height = image.size
for entry in entries:
copy_id = str(entry[0])
minimum = int(entry[1])
maximum = int(entry[2])
label = str(entry[4])
index.setdefault(
(label, copy_id),
GroupCoordinates(minimum, maximum, width, height),
)
except (IndexError, OSError, TypeError, ValueError) as exc:
warnings.append(f"Could not read group metadata {metadata_path}: {exc}")
return index, warnings
def _scaled_result(result: dict[str, Any], coordinates: GroupCoordinates | None):
scaled = copy.deepcopy(result)
if coordinates is None:
return scaled
for feedback in scaled.get("feedback", []):
box = feedback.get("box_2d")
if not box or len(box) != 4:
continue
box[0] = int(box[0] * coordinates.height) // 1000
box[2] = int(box[2] * coordinates.height) // 1000
box[1] = int(box[1] * coordinates.width) // 1000
box[3] = int(box[3] * coordinates.width) // 1000
return scaled
def _answer_pdf(
workspace: EvaluationWorkspace,
copy_id: str,
label: str,
suffix: str = "",
) -> Path:
copy_dir = workspace.copies_dir / f"Copie{copy_id}"
preferred = copy_dir / f"{label}{suffix}.pdf"
if preferred.exists():
return preferred
for candidate in (
copy_dir / f"{label}.pdf",
copy_dir / f"{label}_new.pdf",
):
if candidate.exists():
return candidate
return preferred
def _dummy_entry(
workspace: EvaluationWorkspace,
copy_id: str,
label: str,
) -> dict[str, Any]:
return {
"pdf_path": _answer_pdf(workspace, copy_id, label),
"result": {
"score": 0.0,
"feedback": [],
"error": "non traité",
},
"coordinates": (0, 0),
"issues": ["No correction result was available for this answer."],
}
def _apply_refaire_filter(
workspace: EvaluationWorkspace,
data: AnnotationData,
refaire_list: RefaireList,
warnings: list[str],
) -> AnnotationData:
filtered: AnnotationData = {}
for raw_entry in refaire_list:
if not isinstance(raw_entry, list) or len(raw_entry) != 2:
warnings.append(f"Ignoring malformed refaire entry: {raw_entry!r}")
continue
copy_name, requested_labels = raw_entry
copy_id = str(copy_name).removeprefix("Copie")
available = data.get(copy_id, {})
if not requested_labels:
filtered[copy_id] = dict(available)
continue
selected: dict[str, dict[str, Any]] = {}
for raw_label in requested_labels:
label = str(raw_label)
selected[label] = (
available[label]
if label in available
else _dummy_entry(workspace, copy_id, label)
)
filtered[copy_id] = selected
return filtered
def load_annotation_data(
workspace: EvaluationWorkspace,
*,
refaire_list: RefaireList | None = None,
copy_id: str | None = None,
) -> AnnotationLoadResult:
workspace.require_files("correction.json")
corrections = read_json(workspace.correction_file)
if not isinstance(corrections, dict):
raise TypeError("correction.json must contain a JSON object")
coordinate_index, warnings = _coordinate_index(workspace)
data: AnnotationData = {}
for label, raw_batches in corrections.items():
if not isinstance(raw_batches, list):
warnings.append(f"Ignoring malformed correction batches for {label!r}")
continue
for raw_batch in raw_batches:
if not isinstance(raw_batch, list):
warnings.append(f"Ignoring malformed correction batch for {label!r}")
continue
for item in raw_batch:
if not isinstance(item, dict) or not isinstance(item.get("result"), dict):
warnings.append(f"Ignoring malformed correction item for {label!r}")
continue
student_id = str(item.get("id", ""))
if not student_id:
warnings.append(f"Ignoring correction item without an id for {label!r}")
continue
result = item["result"]
suffix = str(result.get("suffix", ""))
if suffix == "_old":
continue
coordinates = coordinate_index.get((str(label), student_id))
issues: list[str] = []
if coordinates is None:
issues.append("Group coordinates were not found.")
pdf_path = _answer_pdf(workspace, student_id, str(label), suffix)
if not pdf_path.exists():
issues.append(f"Answer PDF not found: {pdf_path}")
data.setdefault(student_id, {})[str(label)] = {
"pdf_path": pdf_path,
"result": _scaled_result(result, coordinates),
"coordinates": (
(coordinates.minimum, coordinates.maximum)
if coordinates is not None
else (0, 0)
),
"issues": issues,
}
warnings.extend(
f"Copie{student_id} {label}: {issue}" for issue in issues
)
if refaire_list is not None:
data = _apply_refaire_filter(workspace, data, refaire_list, warnings)
if copy_id is not None:
data = {copy_id: data[copy_id]} if copy_id in data else {}
if not data:
warnings.append(f"Copy id {copy_id} was not found in correction.json")
return AnnotationLoadResult(data, warnings)
+25
View File
@@ -50,6 +50,16 @@ def evaluation_parser(description: str) -> argparse.ArgumentParser:
return parser
def target_parser(description: str) -> argparse.ArgumentParser:
parser = standard_parser(description)
parser.add_argument(
"target",
type=Path,
help="Evaluation directory or nested file to process",
)
return parser
def evaluation_workspace(
path: str | Path,
*,
@@ -77,6 +87,21 @@ def workspace_from_args(
return evaluation_workspace(args.evaluation, repository=repository)
def workspace_from_target(
args: argparse.Namespace,
*,
repository: str | Path | None = None,
) -> tuple[EvaluationWorkspace, Path]:
target = Path(args.target).expanduser().resolve()
if not target.exists():
raise CliError(f"Target does not exist: {target}", ExitCode.INVALID_WORKSPACE)
repository_path = Path(repository) if repository is not None else None
if target.is_dir() and EvaluationWorkspace.looks_like_evaluation(target):
return EvaluationWorkspace(target, repository_path), target
workspace = EvaluationWorkspace.discover(target, repository=repository_path)
return workspace, target
def execute(
parser: argparse.ArgumentParser,
argv: Sequence[str] | None,
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import shutil
import uuid
from contextlib import contextmanager
from pathlib import Path
def _remove_path(path: Path) -> None:
if path.is_symlink() or path.is_file():
path.unlink()
elif path.exists():
shutil.rmtree(path)
@contextmanager
def staged_directory(destination: str | Path):
"""Build a directory beside its destination and replace on success."""
target = Path(destination)
target.parent.mkdir(parents=True, exist_ok=True)
token = uuid.uuid4().hex
staging = target.with_name(f".{target.name}.{token}.tmp")
backup = target.with_name(f".{target.name}.{token}.backup")
staging.mkdir()
committed = False
try:
yield staging
if target.exists():
target.replace(backup)
try:
staging.replace(target)
committed = True
except Exception:
if backup.exists() and not target.exists():
backup.replace(target)
raise
if backup.exists():
_remove_path(backup)
finally:
if staging.exists():
_remove_path(staging)
if not committed and backup.exists() and not target.exists():
backup.replace(target)