Files
Copies/copienator/commands/add_final_score.py
T

210 lines
8.0 KiB
Python

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,
FINAL_SCORE_ODS_PATH,
FINAL_SCORE_OUTPUT_DIR,
)
# Configuration constants
ODS_PATH = Path(FINAL_SCORE_ODS_PATH).expanduser()
OUTPUT_DIR = Path(FINAL_SCORE_OUTPUT_DIR).expanduser()
HISTOGRAM_PATH = Path(FINAL_SCORE_HISTOGRAM_PATH).expanduser()
def score_font(size):
candidates = [FINAL_SCORE_FONT_PATH, "DejaVuSans-Bold.ttf", "arial.ttf"]
for candidate in candidates:
if not candidate:
continue
try:
return ImageFont.truetype(str(candidate), size)
except OSError:
continue
return ImageFont.load_default()
def get_rounded_score(score):
"""Round score to one decimal place below (floor)."""
try:
val = float(score)
return math.floor(val * 10) / 10
except (ValueError, TypeError):
return None
def copy_return_artifacts(source_dir: Path, destination_dir: Path) -> None:
"""Copy the metadata and optional individual answers for one student."""
for filename in ("score.json", "info.json"):
source = source_dir / filename
if source.is_file():
shutil.copy2(source, destination_dir / filename)
else:
print(f"Warning: Missing '{source}'.")
answers_source = source_dir / "answers"
answers_destination = destination_dir / "answers"
if answers_destination.is_symlink():
answers_destination.unlink()
elif answers_destination.is_dir():
shutil.rmtree(answers_destination)
if answers_source.is_dir():
shutil.copytree(answers_source, answers_destination)
def copy_histogram(output_dir: Path, *, copy_merged_histogramme: bool = False) -> None:
"""Copy the score histogram beside the per-student output folders."""
sources = [(HISTOGRAM_PATH, "histogramme.pdf")]
if copy_merged_histogramme:
sources.append((HISTOGRAM_PATH.with_name("merged_histogramme.pdf"), "merged_histogramme.pdf"))
for source, filename in sources:
if not source.is_file():
print(f"Warning: Missing histogram '{source}'.")
continue
destination = output_dir / filename
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:
# header=None assumes the file starts directly with data.
# If row 0 is a header, change to header=0
df = pd.read_excel(ODS_PATH, engine="odf", header=None)
# Create a lookup dictionary: {Name: Score}
score_db = dict(zip(df[0], df[1]))
except Exception as e:
print(f"CRITICAL ERROR: Could not read ODS file.\n{e}")
sys.exit(1)
# 2. Prepare Output Directory
output_dir.mkdir(parents=True, exist_ok=True)
# 3. Iterate Files
# Structure: Dir/A Rendre/{name}/{name}.jpg
search_path = base_dir / "A Rendre"
if not search_path.exists():
print(f"Error: Directory '{search_path}' not found.")
sys.exit(1)
for student_source in sorted(path for path in search_path.iterdir() if path.is_dir()):
image_paths = sorted(student_source.glob("*.jpg"))
pdf_paths = sorted(student_source.glob("*.pdf"))
media_paths = image_paths or pdf_paths
if not media_paths:
print(f"Error: No JPG or PDF found in '{student_source}'.")
continue
student_name = media_paths[0].stem
student_output = output_dir / student_name
student_output.mkdir(parents=True, exist_ok=True)
# Remove files produced by the former flat output layout when migrating
# an existing export directory.
for suffix in (".jpg", ".pdf"):
legacy_output = output_dir / f"{student_name}{suffix}"
if legacy_output.is_file() or legacy_output.is_symlink():
legacy_output.unlink()
copy_return_artifacts(student_source, student_output)
# 4. Find Score
if student_name not in score_db:
print(f"Error: Student '{student_name}' not found in ODS file.")
else:
raw_score = score_db[student_name]
score = get_rounded_score(raw_score)
if score is None:
print(f"Error: Invalid score '{raw_score}' for '{student_name}'.")
else:
# 5. Process Images
for img_path in image_paths:
try:
with Image.open(img_path) as img:
img = img.convert("RGB")
draw = ImageDraw.Draw(img)
width, _height = img.size
font_size = int(width * 0.08)
font = score_font(font_size)
text = str(score)
bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0]
# 30px padding, top right.
x = width - text_w - 30
y = 30
draw.text((x, y), text, fill=(255, 0, 0), font=font)
img.save(student_output / img_path.name)
print(f"Processed: {student_name} -> {score}")
except Exception as e:
print(f"Error processing image for '{student_name}': {e}")
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)
def main(argv=None):
parser = argparse.ArgumentParser(description="Stamp scores on exam copies.")
parser.add_argument("dir", type=Path, help="Root directory containing 'A Rendre' folder")
parser.add_argument("--copy-merged-histogramme", action="store_true",
help="Also copy merged_histogramme.pdf from the histogram staging directory")
args = parser.parse_args(argv)
base_dir = args.dir.expanduser().resolve()
output_dir = OUTPUT_DIR / base_dir.name
output_dir.mkdir(parents=True, exist_ok=True)
process_images(base_dir, output_dir, copy_merged_histogramme=args.copy_merged_histogramme)
return 0
if __name__ == "__main__":
raise SystemExit(main())