Standardisation 3
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user