393 lines
13 KiB
Python
393 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import concurrent.futures
|
|
import os
|
|
from collections.abc import Sequence
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from PIL import Image, ImageDraw
|
|
from reportlab.pdfgen import canvas
|
|
|
|
from copienator.commands import annotating
|
|
from copienator.commands import annotating_with_checks
|
|
from copienator import utils
|
|
from copienator import (
|
|
CliError,
|
|
EvaluationWorkspace,
|
|
ExitCode,
|
|
atomic_write_json,
|
|
atomic_write_text,
|
|
evaluation_parser,
|
|
execute,
|
|
read_json,
|
|
workspace_from_args,
|
|
)
|
|
from copienator.annotation_data import load_annotation_data
|
|
from copienator.filesystem import staged_directory
|
|
from copienator.utils import natural_key
|
|
|
|
MAX_HEIGHT_PX = 25000
|
|
|
|
|
|
def render_item(item):
|
|
student_id, label, content = item
|
|
pdf_path = Path(content["pdf_path"])
|
|
if not pdf_path.exists():
|
|
print(f"Warning: answer PDF not found: {pdf_path}")
|
|
return None
|
|
base_image, _, _ = annotating.make_base_image(pdf_path)
|
|
checkbox_renderer = annotating_with_checks.CheckboxRenderer(label)
|
|
final_image, header_height = annotating.compose_label_image(
|
|
base_image,
|
|
label,
|
|
content["result"],
|
|
content["coordinates"][0],
|
|
draw_callback=checkbox_renderer.callback,
|
|
more_right=True,
|
|
with_id=student_id,
|
|
)
|
|
if final_image is None:
|
|
return None
|
|
return (
|
|
student_id,
|
|
label,
|
|
final_image,
|
|
header_height,
|
|
checkbox_renderer.checkboxes,
|
|
)
|
|
|
|
|
|
def save_batch(batch, prefix, group_id, output_root: Path) -> None:
|
|
output_dir = output_root / f"{prefix} G{group_id}"
|
|
print(f"Generating group PDF: {prefix} G{group_id} ({len(batch)} elements)")
|
|
max_width = max(item[2].width for item in batch)
|
|
total_height = sum(item[2].height for item in batch)
|
|
concatenated = Image.new("RGB", (max_width, total_height), "white")
|
|
draw = ImageDraw.Draw(concatenated)
|
|
checkbox_map: list[dict[str, Any]] = []
|
|
bnote_entries: list[dict[str, Any]] = []
|
|
current_y = 0
|
|
previous_student = None
|
|
|
|
for student_id, label, image, header_height, checkboxes in batch:
|
|
concatenated.paste(image, (0, current_y))
|
|
if student_id != previous_student:
|
|
draw.rectangle([0, current_y, max_width, current_y + 4], fill="purple")
|
|
previous_student = student_id
|
|
bnote_entries.append(
|
|
{
|
|
"id": student_id,
|
|
"label": label,
|
|
"header_height": header_height,
|
|
"hmin": current_y,
|
|
"hmax": current_y + image.height,
|
|
}
|
|
)
|
|
for item in checkboxes:
|
|
box = item.get("final_box") or item.get("rel_box")
|
|
item["global_box"] = [
|
|
box[0],
|
|
box[1] + current_y,
|
|
box[2],
|
|
box[3] + current_y,
|
|
]
|
|
item["student_id"] = student_id
|
|
checkbox_map.append(item)
|
|
current_y += image.height
|
|
|
|
with staged_directory(output_dir) as staging:
|
|
atomic_write_json(
|
|
staging / "bnote.json",
|
|
{"width": max_width, "height": total_height, "images": bnote_entries},
|
|
)
|
|
atomic_write_json(staging / "checkboxes.json", checkbox_map)
|
|
reference = staging / "Reference.jpg"
|
|
concatenated.save(reference, quality=90)
|
|
pdf_path = staging / "Concat.pdf"
|
|
pdf_canvas = canvas.Canvas(str(pdf_path), pagesize=(max_width, total_height))
|
|
pdf_canvas.drawImage(
|
|
str(reference),
|
|
0,
|
|
0,
|
|
width=max_width,
|
|
height=total_height,
|
|
)
|
|
pdf_canvas.save()
|
|
|
|
|
|
def _initial_label_groups(labels: list[str]) -> str:
|
|
groups: dict[str, list[str]] = {}
|
|
for label in labels:
|
|
key = label.split(" : ")[0] if " : " in label else label
|
|
groups.setdefault(key, []).append(label)
|
|
return "".join(",".join(items) + "\n" for items in groups.values())
|
|
|
|
|
|
def _gemini_label_groups(
|
|
workspace: EvaluationWorkspace, labels: list[str]
|
|
) -> list[list[str]] | None:
|
|
source = workspace.gemini_exam_items_file
|
|
if not source.is_file():
|
|
return None
|
|
|
|
groups: list[list[str]] = []
|
|
current: list[str] = []
|
|
try:
|
|
source_lines = source.read_text(encoding="utf-8").splitlines()
|
|
except (OSError, UnicodeError) as exc:
|
|
print(f"Warning: could not read Gemini question groups from {source}: {exc}")
|
|
return None
|
|
for raw_line in source_lines:
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if line == "---":
|
|
if current:
|
|
groups.append(current)
|
|
current = []
|
|
continue
|
|
if " ### " not in line:
|
|
continue
|
|
label = line.split(" ### ", 1)[0].strip()
|
|
if label and label != "CONTEXT":
|
|
current.append(label)
|
|
if current:
|
|
groups.append(current)
|
|
|
|
flattened = [label for group in groups for label in group]
|
|
known = set(labels)
|
|
if (
|
|
not flattened
|
|
or len(flattened) != len(set(flattened))
|
|
or set(flattened) != known
|
|
):
|
|
missing = sorted(known.difference(flattened), key=natural_key)
|
|
unknown = sorted(set(flattened).difference(known), key=natural_key)
|
|
details = []
|
|
if missing:
|
|
details.append("missing: " + ", ".join(missing))
|
|
if unknown:
|
|
details.append("unknown: " + ", ".join(unknown))
|
|
if len(flattened) != len(set(flattened)):
|
|
details.append("duplicate labels")
|
|
print(
|
|
f"Warning: ignoring incompatible Gemini question groups in {source}"
|
|
+ (f" ({'; '.join(details)})" if details else "")
|
|
)
|
|
return None
|
|
return groups
|
|
|
|
|
|
def _serialize_label_groups(groups: list[list[str]]) -> str:
|
|
return "".join(",".join(group) + "\n" for group in groups)
|
|
|
|
|
|
def _load_label_groups(workspace: EvaluationWorkspace, labels: list[str]) -> list[list[str]]:
|
|
label_groups = workspace.label_groups_file
|
|
if not label_groups.exists():
|
|
gemini_groups = _gemini_label_groups(workspace, labels)
|
|
if gemini_groups is not None:
|
|
initial_content = _serialize_label_groups(gemini_groups)
|
|
source_description = "the groups selected in gemini_for_enonce.py"
|
|
else:
|
|
initial_content = _initial_label_groups(labels)
|
|
source_description = "the label-prefix fallback"
|
|
atomic_write_text(label_groups, initial_content)
|
|
print(
|
|
f"Created {label_groups} from {source_description}; "
|
|
"review the groups before continuing."
|
|
)
|
|
utils.edit_file_and_enter(label_groups)
|
|
known_labels = set(labels)
|
|
groups: list[list[str]] = []
|
|
unknown: set[str] = set()
|
|
for line in label_groups.read_text(encoding="utf-8").splitlines():
|
|
group = [label.strip() for label in line.split(",") if label.strip()]
|
|
unknown.update(label for label in group if label not in known_labels)
|
|
if group:
|
|
groups.append(group)
|
|
if unknown:
|
|
raise CliError(
|
|
"label_groups contains unknown labels: " + ", ".join(sorted(unknown))
|
|
)
|
|
return groups
|
|
|
|
|
|
def _unique_prefixes(groups: list[list[str]]) -> list[tuple[str, list[str]]]:
|
|
used: set[str] = set()
|
|
result: list[tuple[str, list[str]]] = []
|
|
previous: str | None = None
|
|
for labels in groups:
|
|
safe_labels = [label.replace(":", "").strip() for label in labels]
|
|
base = os.path.commonprefix(safe_labels).strip() or "Group"
|
|
if base and previous is not None and natural_key(base) < natural_key(previous):
|
|
candidate = f"{safe_labels[0]}+"
|
|
if natural_key(candidate) > natural_key(previous):
|
|
base = candidate
|
|
prefix = base.removesuffix("i")
|
|
counter = 2
|
|
while prefix in used:
|
|
prefix = f"{base}-{counter}"
|
|
counter += 1
|
|
if counter == 2 and previous and previous in prefix:
|
|
prefix = f"{previous}-{counter}"
|
|
elif counter == 2:
|
|
previous = prefix
|
|
used.add(prefix)
|
|
result.append((prefix, labels))
|
|
return result
|
|
|
|
|
|
def _existing_group_state(
|
|
output_root: Path,
|
|
prefix: str,
|
|
) -> tuple[set[tuple[str, str]], int]:
|
|
existing_items: set[tuple[str, str]] = set()
|
|
maximum_group = 0
|
|
if not output_root.is_dir():
|
|
return existing_items, maximum_group
|
|
for directory in output_root.iterdir():
|
|
if not directory.is_dir() or not directory.name.startswith(f"{prefix} G"):
|
|
continue
|
|
try:
|
|
maximum_group = max(maximum_group, int(directory.name.split(" G")[-1]))
|
|
except ValueError:
|
|
continue
|
|
metadata = directory / "bnote.json"
|
|
if not metadata.exists():
|
|
continue
|
|
loaded = read_json(metadata)
|
|
if not isinstance(loaded, dict):
|
|
raise TypeError(f"Expected a JSON object in {metadata}")
|
|
for image in loaded.get("images", []):
|
|
existing_items.add((str(image["id"]), str(image["label"])))
|
|
return existing_items, maximum_group
|
|
|
|
|
|
def split_batches(rendered):
|
|
def split(maximum_height: float):
|
|
batches = []
|
|
current = []
|
|
current_height = 0
|
|
previous_student = None
|
|
for item in rendered:
|
|
student_id = item[0]
|
|
image_height = item[2].height
|
|
if (
|
|
current
|
|
and current_height + image_height > maximum_height
|
|
and student_id != previous_student
|
|
):
|
|
batches.append(current)
|
|
current = []
|
|
current_height = 0
|
|
current.append(item)
|
|
current_height += image_height
|
|
previous_student = student_id
|
|
if current:
|
|
batches.append(current)
|
|
return batches
|
|
|
|
strict = split(MAX_HEIGHT_PX)
|
|
relaxed = split(1.1 * MAX_HEIGHT_PX)
|
|
return relaxed if len(relaxed) < len(strict) else strict
|
|
|
|
|
|
def _generate_groups(
|
|
output_root: Path,
|
|
data,
|
|
groups: list[list[str]],
|
|
*,
|
|
resume: bool,
|
|
) -> tuple[int, bool]:
|
|
generated = 0
|
|
problems = False
|
|
for prefix, labels in _unique_prefixes(groups):
|
|
existing_items, maximum_group = (
|
|
_existing_group_state(output_root, prefix) if resume else (set(), 0)
|
|
)
|
|
items = [
|
|
(student_id, label, student_labels[label])
|
|
for student_id, student_labels in data.items()
|
|
for label in labels
|
|
if label in student_labels and (student_id, label) not in existing_items
|
|
]
|
|
if not items:
|
|
continue
|
|
items.sort(key=lambda item: (natural_key(item[0]), natural_key(item[1])))
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
|
rendered = list(executor.map(render_item, items))
|
|
if any(item is None for item in rendered):
|
|
problems = True
|
|
successful = [item for item in rendered if item is not None]
|
|
for index, batch in enumerate(split_batches(successful), start=1):
|
|
save_batch(batch, prefix, maximum_group + index, output_root)
|
|
generated += 1
|
|
return generated, problems
|
|
|
|
|
|
def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
|
|
workspace.require_files("labels", "correction.json")
|
|
workspace.require_directories("Copies", "Par label")
|
|
labels = utils.read_all_labels(workspace.root)
|
|
groups = _load_label_groups(workspace, labels)
|
|
loaded = load_annotation_data(workspace)
|
|
for warning in loaded.warnings:
|
|
print(f"Warning: {warning}")
|
|
if not loaded.data:
|
|
print("Warning: no annotation data was found.")
|
|
return ExitCode.PARTIAL
|
|
|
|
output_root = workspace.annotation_dir("grouped")
|
|
if overwrite:
|
|
class IncompleteGroupedOutput(Exception):
|
|
pass
|
|
|
|
try:
|
|
with staged_directory(output_root) as staging:
|
|
generated, problems = _generate_groups(
|
|
staging,
|
|
loaded.data,
|
|
groups,
|
|
resume=False,
|
|
)
|
|
if generated == 0 or problems or loaded.warnings:
|
|
raise IncompleteGroupedOutput
|
|
except IncompleteGroupedOutput:
|
|
print("Warning: grouped overwrite was incomplete; previous BGnot was preserved.")
|
|
return ExitCode.PARTIAL
|
|
else:
|
|
output_root.mkdir(parents=True, exist_ok=True)
|
|
generated, problems = _generate_groups(
|
|
output_root,
|
|
loaded.data,
|
|
groups,
|
|
resume=True,
|
|
)
|
|
if problems:
|
|
return ExitCode.PARTIAL
|
|
if generated == 0:
|
|
print("No new grouped annotations were required.")
|
|
return ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = evaluation_parser("Generate annotated PDFs grouped by labels.")
|
|
parser.add_argument("--overwrite", action="store_true", help="Replace BGnot safely")
|
|
return parser
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
parser = build_parser()
|
|
return execute(
|
|
parser,
|
|
argv,
|
|
lambda args: run(workspace_from_args(args), overwrite=args.overwrite),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|