Bnot : split large copies into pieces.

This commit is contained in:
sebastien committed 2026-10-23 08:32:17 +02:00
1 parent 834ffe32f2
commit 1959c5d3bb
10 files changed
+333 -52

No files matched your search

+8 -10
View File
@@ -23,13 +23,12 @@ from copienator import (
workspace_from_args,
)
from copienator.annotation_data import load_annotation_data
from copienator.annotation_documents import MAX_HEIGHT_PX
from copienator.commands import annotating, annotating_with_checks
from copienator.commands.import_annotations import clear_import_directory
from copienator.filesystem import staged_directory
from copienator.utils import natural_key
MAX_HEIGHT_PX = 32000
def render_item(item):
student_id, label, content = item
@@ -65,10 +64,10 @@ def save_batch(batch, prefix, group_id, output_root: Path) -> None:
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)
if total_height > MAX_HEIGHT_PX:
if total_height > MAX_HEIGHT_PX and len({item[0] for item in batch}) > 1:
raise CliError(
f"{prefix} G{group_id} would be {total_height} px high; "
f"the maximum is {MAX_HEIGHT_PX} px."
f"only a single student's block may exceed {MAX_HEIGHT_PX} px."
)
concatenated = Image.new("RGB", (max_width, total_height), "white")
draw = ImageDraw.Draw(concatenated)
@@ -293,12 +292,11 @@ def split_batches(rendered):
student_height = sum(item[2].height for item in student_items)
if student_height > MAX_HEIGHT_PX:
labels = ", ".join(item[1] for item in student_items)
raise CliError(
f"Copie{student_id} ({labels}) is {student_height} px high; "
f"one student's labels cannot be split across groups and the "
f"maximum group height is {MAX_HEIGHT_PX} px."
)
# Oversized blocks remain intact in a group of their own.
batches.append(list(student_items))
heights.append(student_height)
index = end
continue
# Reuse space in earlier groups, keeping each student's block intact.
for batch_index, height in enumerate(heights):
+64 -22
View File
@@ -26,6 +26,7 @@ from copienator import (
workspace_from_target,
)
from copienator.annotation_data import load_annotation_data
from copienator.annotation_documents import MAX_HEIGHT_PX, copy_documents
from copienator.commands import annotating
from copienator.commands.import_annotations import clear_import_directory
from copienator.filesystem import staged_directory
@@ -143,7 +144,11 @@ class CheckboxRenderer:
def _output_complete(output_dir: Path) -> bool:
return all((output_dir / name).is_file() for name in EXPECTED_OUTPUTS)
return all(
(document / name).is_file()
for document in copy_documents(output_dir)
for name in EXPECTED_OUTPUTS
)
def _render_student(
@@ -197,7 +202,6 @@ def _render_student(
"label": label,
"header_height": header_height,
"layout": final_image.info.get('annotation_layout', annotating.layout_for(None)),
"img_h": final_image.height,
}
)
@@ -205,6 +209,46 @@ def _render_student(
print(f"Warning: no annotations could be rendered for Copie{student_id}")
return "partial"
batches = []
start = 0
height = 0
for index, image in enumerate(label_images):
if image.height > MAX_HEIGHT_PX:
raise CliError(
f"Copie{student_id} ({bnote_entries[index]['label']}) is "
f"{image.height} px high; a single answer cannot be split and "
f"the maximum document height is {MAX_HEIGHT_PX} px."
)
if height + image.height > MAX_HEIGHT_PX:
batches.append((start, index))
start = index
height = 0
height += image.height
batches.append((start, len(label_images)))
with staged_directory(output_dir) as staging:
parts = []
for index, (start, end) in enumerate(batches, start=1):
destination = staging
if len(batches) > 1:
name = f"Part{index}"
parts.append(name)
destination /= name
_save_document(destination, label_images[start:end],
checkbox_groups[start:end], bnote_entries[start:end])
if parts:
atomic_write_json(staging / "bnote.json", {
"parts": parts, "images": bnote_entries,
})
return "partial" if problems else "success"
def _save_document(
output_dir: Path,
label_images: list[Image.Image],
checkbox_groups: list[list[dict[str, Any]]],
bnote_entries: list[dict[str, Any]],
) -> None:
max_width = max(image.width for image in label_images)
total_height = sum(image.height for image in label_images)
concatenated = Image.new("RGB", (max_width, total_height), "white")
@@ -216,7 +260,6 @@ def _render_student(
concatenated.paste(image, (0, current_y))
bnote_entries[index]["hmin"] = current_y
bnote_entries[index]["hmax"] = current_y + image.height
del bnote_entries[index]["img_h"]
for item in checkboxes:
box = item.get("final_box") or item.get("rel_box")
item["global_box"] = [
@@ -228,25 +271,24 @@ def _render_student(
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()
return "partial" if problems else "success"
output_dir.mkdir(parents=True, exist_ok=True)
atomic_write_json(
output_dir / "bnote.json",
{"width": max_width, "height": total_height, "images": bnote_entries},
)
atomic_write_json(output_dir / "checkboxes.json", checkbox_map)
reference = output_dir / "Reference.jpg"
concatenated.save(reference, quality=90)
pdf_path = output_dir / "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 _copy_id_from_target(workspace: EvaluationWorkspace, target: Path) -> str | None:
+5 -2
View File
@@ -11,6 +11,7 @@ from copienator import (
workspace_from_args,
)
from copienator.configuration import EXPORT_DIR
from copienator.annotation_documents import tablet_documents
from copienator.platform import replace_with_link_or_copy
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
@@ -36,7 +37,9 @@ def export_directory(
subdirs = [directory for directory in subdirs if not directory.name.startswith("Copie")]
missing_outputs = 0
for subdir in subdirs:
for name, subdir in tablet_documents(source_dir):
if subdir not in subdirs and subdir.parent not in subdirs:
continue
concat_file = next(
(
candidate
@@ -52,7 +55,7 @@ def export_directory(
)
missing_outputs += 1
continue
destination = sync_dir / f"{prefix}{subdir.name}{concat_file.suffix.lower()}"
destination = sync_dir / f"{prefix}{name}{concat_file.suffix.lower()}"
method = replace_with_link_or_copy(concat_file, destination, prefer="hardlink")
print(f"Exported: {destination} ({method})")
return ExitCode.PARTIAL if missing_outputs else ExitCode.SUCCESS
+6 -3
View File
@@ -13,6 +13,7 @@ from copienator import (
workspace_from_args,
)
from copienator import configuration
from copienator.annotation_documents import tablet_documents
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
IMPORT_DIR = getattr(configuration, "IMPORT_DIR", None)
@@ -58,6 +59,7 @@ def sync_annotated(
return ExitCode.INVALID_WORKSPACE
missing_targets = 0
targets = dict(tablet_documents(annotation_dir))
annotated_files = sorted(
(
path
@@ -70,10 +72,11 @@ def sync_annotated(
if prefix and not annotated_file.stem.startswith(prefix):
print(f"Ignoring return from another pass: {annotated_file.name}")
continue
target_subdir = annotation_dir / annotated_file.stem.removeprefix(prefix)
name = annotated_file.stem.removeprefix(prefix)
target_subdir = targets.get(name)
if not target_subdir.is_dir():
print(f"Warning: directory not found: {target_subdir}", file=sys.stderr)
if target_subdir is None or not target_subdir.is_dir():
print(f"Warning: annotation document not found: {name}", file=sys.stderr)
missing_targets += 1
else:
suffix = annotated_file.suffix.lower()
+28 -3
View File
@@ -22,6 +22,7 @@ from copienator import (
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
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_modifications import (
BASELINE,
@@ -154,6 +155,7 @@ def apply_actions_and_regenerate(
all_labels: list[str],
*,
update_score: bool = False,
label_notes: dict[str, Image.Image] | None = None,
) -> ExitCode:
"""Apply annotations and atomically merge the regenerated student files."""
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
@@ -205,7 +207,7 @@ def apply_actions_and_regenerate(
scores[label] = str(result.get("score", 0))
is_empty = result.get("error") == "empty-answer"
sub_note = None
sub_note = label_notes.get(label) if label_notes is not None else None
if notes_layer is not None and "hmin" in image_info:
hmin = int(image_info.get("hmin", 0))
hmax = int(image_info.get("hmax", 0))
@@ -325,11 +327,33 @@ def run(
status = ExitCode.PARTIAL
continue
print(f"Processing annotations for: {student_id}")
actions, notes = ([], None) if only_empty else detect_checks_and_notes(output_dir)
if notes is None and not actions and not update_score and not only_empty:
documents = copy_documents(output_dir)
label_notes = None
actions, notes = [], None
if not only_empty and documents == [output_dir]:
actions, notes = detect_checks_and_notes(output_dir)
elif not only_empty:
label_notes = {}
incomplete = False
for document in documents:
part_actions, part_notes = detect_checks_and_notes(document)
if part_notes is None:
incomplete = True
continue
actions.extend(part_actions)
for item in read_json(document / "bnote.json")["images"]:
label_notes[item["label"]] = part_notes.crop(
(0, item["hmin"], part_notes.width, item["hmax"])
)
if incomplete:
print(" Missing a readable annotation part; copy was preserved.")
status = ExitCode.PARTIAL
continue
if notes is None and not label_notes and not actions and not update_score and not only_empty:
print(" No readable annotation input found.")
status = ExitCode.PARTIAL
continue
extra = {"label_notes": label_notes} if label_notes is not None else {}
result = apply_actions_and_regenerate(
workspace,
loaded.data,
@@ -338,6 +362,7 @@ def run(
notes,
all_labels,
update_score=update_score,
**extra,
)
if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
@@ -25,6 +25,7 @@ from copienator import (
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
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_modifications import (
BASELINE,
@@ -463,11 +464,10 @@ def _scan_redo_annotations(
incomplete: set[str] = set()
plans = []
required = ("checkboxes.json", "Reference.jpg", "Concat_annotated.pdf")
for path in sorted(directory.iterdir()):
if not path.is_dir():
continue
for _name, path in tablet_documents(directory):
copy_name = path.name if path.name.startswith("Copie") else path.parent.name
default_id = (
path.name.removeprefix("Copie") if path.name.startswith("Copie") else None
copy_name.removeprefix("Copie") if copy_name.startswith("Copie") else None
)
try:
metadata = read_json(path / "bnote.json")
@@ -590,12 +590,12 @@ def run(
if annotation_dir == "Bnot":
for student_id in refaire_by_student:
directory = workspace.root / annotation_dir / f"Copie{student_id}"
if directory.is_dir():
for document in copy_documents(directory) if directory.is_dir() else []:
_merge_scan_result(
actions_by_student,
notes_by_student,
_scan_annotation_directory(
directory, default_student_id=student_id
document, default_student_id=student_id
),
)