Bnot : split large copies into pieces.
This commit is contained in:
1 parent
834ffe32f2
commit
1959c5d3bb
10 files changed
+333
-52
No files matched your search
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user