32k pixel as hard group limit, better manual resolution support
This commit is contained in:
1 parent
a9c0703f03
commit
5009831c7c
9 files changed
+322
-58
No files matched your search
@@ -27,7 +27,7 @@ from copienator.commands import annotating, annotating_with_checks
|
||||
from copienator.filesystem import staged_directory
|
||||
from copienator.utils import natural_key
|
||||
|
||||
MAX_HEIGHT_PX = 25000
|
||||
MAX_HEIGHT_PX = 32000
|
||||
|
||||
|
||||
def render_item(item):
|
||||
@@ -63,6 +63,11 @@ 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:
|
||||
raise CliError(
|
||||
f"{prefix} G{group_id} would be {total_height} px high; "
|
||||
f"the maximum is {MAX_HEIGHT_PX} px."
|
||||
)
|
||||
concatenated = Image.new("RGB", (max_width, total_height), "white")
|
||||
draw = ImageDraw.Draw(concatenated)
|
||||
checkbox_map: list[dict[str, Any]] = []
|
||||
@@ -268,32 +273,44 @@ def _existing_group_state(
|
||||
|
||||
|
||||
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
|
||||
batches = []
|
||||
current = []
|
||||
current_height = 0
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal current, current_height
|
||||
if current:
|
||||
batches.append(current)
|
||||
return batches
|
||||
current = []
|
||||
current_height = 0
|
||||
|
||||
strict = split(MAX_HEIGHT_PX)
|
||||
relaxed = split(1.1 * MAX_HEIGHT_PX)
|
||||
return relaxed if len(relaxed) < len(strict) else strict
|
||||
index = 0
|
||||
while index < len(rendered):
|
||||
student_id = rendered[index][0]
|
||||
end = index + 1
|
||||
while end < len(rendered) and rendered[end][0] == student_id:
|
||||
end += 1
|
||||
student_items = rendered[index:end]
|
||||
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."
|
||||
)
|
||||
|
||||
# Consider the student's complete block before adding its first image.
|
||||
# Otherwise later images from that same student could overflow the batch.
|
||||
if current and current_height + student_height > MAX_HEIGHT_PX:
|
||||
flush()
|
||||
current.extend(student_items)
|
||||
current_height += student_height
|
||||
index = end
|
||||
|
||||
flush()
|
||||
return batches
|
||||
|
||||
|
||||
def _generate_groups(
|
||||
|
||||
@@ -1026,7 +1026,9 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
|
||||
manual_path = INPUT_DIR / "manual_resolutions.txt"
|
||||
atomic_write_text(
|
||||
manual_path,
|
||||
"### Use -> x>, -x, ss, sx, xx, xs, c{43}1>, c{43}2x\n"
|
||||
"### Use -> x>, -x, ss, sx, xx, xs, c{43}1>, c{43}2x; "
|
||||
"delete a line to dismiss its conflict and keep its PDFs unchanged, "
|
||||
"then run resolve-manual (even if all lines were deleted).\n"
|
||||
+ "\n".join(unresolved_delayed)
|
||||
+ "\n",
|
||||
)
|
||||
|
||||
@@ -132,6 +132,18 @@ def set_suffix_and_clean_error(
|
||||
result["error"] = error
|
||||
|
||||
|
||||
def pending_manual_conflicts(results: dict[str, Any]) -> set[tuple[str, str, str]]:
|
||||
"""Return the copy/source/target conflicts listed by correction's manual file."""
|
||||
return {
|
||||
(item["id"], label, target)
|
||||
for label, batches in results.items()
|
||||
for batch in batches
|
||||
for item in batch
|
||||
for kind, target in item.get("result", {}).get("delayed", [])
|
||||
if kind in {"wrong-label", "add-label"}
|
||||
}
|
||||
|
||||
|
||||
def get_actual_pdf(copies_dir: Path, copy_id: str, label: str) -> Path:
|
||||
base = copies_dir / f"Copie{copy_id}" / f"{label}.pdf"
|
||||
for candidate in (
|
||||
@@ -157,13 +169,7 @@ def _validate_pdf_inputs(
|
||||
missing: set[Path] = set()
|
||||
for instruction in instructions:
|
||||
source = initial_paths[(instruction.copy_id, instruction.old_label)]
|
||||
destination = initial_paths[(instruction.copy_id, instruction.new_label)]
|
||||
if instruction.should_merge:
|
||||
if not source.exists():
|
||||
missing.add(source)
|
||||
if not destination.exists():
|
||||
missing.add(destination)
|
||||
elif instruction.should_copy and not source.exists():
|
||||
if (instruction.should_merge or instruction.should_copy) and not source.exists():
|
||||
missing.add(source)
|
||||
if missing:
|
||||
rendered = ", ".join(str(path) for path in sorted(missing))
|
||||
@@ -178,6 +184,10 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
raise CliError("correction.json must contain a JSON object")
|
||||
results: dict[str, Any] = loaded
|
||||
instructions = parse_instructions(workspace.manual_resolutions_file)
|
||||
retained_conflicts = {
|
||||
(item.copy_id, item.old_label, item.new_label) for item in instructions
|
||||
}
|
||||
dismissed_conflicts = pending_manual_conflicts(results) - retained_conflicts
|
||||
cut_sources = [(item.copy_id, item.old_label) for item in instructions if item.cut]
|
||||
if len(set(cut_sources)) != len(cut_sources):
|
||||
raise CliError("Une seule coupe par label source est autorisée dans une résolution.")
|
||||
@@ -226,9 +236,11 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
try:
|
||||
if instruction.pipe_first:
|
||||
writer.append(source)
|
||||
writer.append(destination)
|
||||
if destination.exists():
|
||||
writer.append(destination)
|
||||
else:
|
||||
writer.append(destination)
|
||||
if destination.exists():
|
||||
writer.append(destination)
|
||||
writer.append(source)
|
||||
writer.write(temp_output)
|
||||
except Exception:
|
||||
@@ -285,6 +297,9 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
if temporary not in used_temps:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
for copy_id, label, target in dismissed_conflicts:
|
||||
# A removed instruction acknowledges the conflict without touching PDFs.
|
||||
set_suffix_and_clean_error(results, copy_id, label, None, target)
|
||||
atomic_write_json(workspace.correction_file, results)
|
||||
refaire_tasks = [[copy_name, labels] for copy_name, labels in refaire_by_copy.items()]
|
||||
if refaire_tasks:
|
||||
@@ -292,6 +307,8 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
workspace.manual_resolutions_file.unlink()
|
||||
|
||||
print("Manual resolutions successfully applied.")
|
||||
if dismissed_conflicts:
|
||||
print(f"{len(dismissed_conflicts)} removed conflict(s) dismissed; their PDFs were kept unchanged.")
|
||||
if refaire_tasks:
|
||||
print(
|
||||
f"File {workspace.refaire_file.name} generated. Run "
|
||||
|
||||
Reference in new issue
Block a user