Allow signaling issues in label verification

This commit is contained in:
sebastien committed 2026-10-11 21:05:27 +02:00
1 parent 3754c0c11a
commit 9925362352
21 files changed
+1347 -36

No files matched your search

+1 -1
View File
@@ -64,7 +64,7 @@ def make_base_image(pdf_path):
current_y = 0
for page in pages:
base_img.paste(page.convert("RGBA"), (0, current_y))
base_img.paste(page.convert("RGBA"), ((max_w - page.width) // 2, current_y))
current_y += page.height
return (base_img, total_h, max_w)
+1 -1
View File
@@ -471,7 +471,7 @@ def run(
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Interactively crop the label margin from PDF copies")
parser.add_argument("--marked", action="store_true", help="Review flagged copies and clear each flag after saving with Enter")
parser.add_argument("--marked", action="store_true", help="Review flagged copies and clear only margin-cropping flags after saving with Enter")
parser.add_argument(
"--fullpage",
action="store_true",
+11 -1
View File
@@ -16,6 +16,7 @@ from pydantic import BaseModel, Field
from copienator import configuration as config
from copienator.gemini_config import thinking_config
from copienator.copy_errors import marked_copy_paths
from copienator import (
CliError,
EvaluationWorkspace,
@@ -530,6 +531,7 @@ def run(
targets: list[Path],
*,
overwrite: bool = False,
marked: bool = False,
client=None,
sleep: Sleep = time.sleep,
max_workers: int = 12,
@@ -538,6 +540,12 @@ def run(
raise CliError("Le nombre de requêtes simultanées doit être positif.",
ExitCode.INVALID_ARGUMENTS)
workspace.require_files("labels")
if marked:
targets = marked_copy_paths(workspace)
if not targets:
print("No flagged copies to detect labels for.")
return ExitCode.SUCCESS
overwrite = True
images, warnings = selected_images(workspace, targets)
for warning in warnings:
print(f"Warning: {warning}")
@@ -626,6 +634,8 @@ def build_parser() -> argparse.ArgumentParser:
type=Path,
help="Additional copy PDFs or Cutleft images from the same evaluation",
)
parser.add_argument("--marked", action="store_true",
help="Regenerate labels only for copies with remaining flags")
parser.add_argument(
"--overwrite",
action="store_true",
@@ -664,7 +674,7 @@ def main(argv: Sequence[str] | None = None) -> int:
ExitCode.INVALID_ARGUMENTS,
)
targets.append(resolved)
return run(workspace, targets, overwrite=args.overwrite, max_workers=args.workers)
return run(workspace, targets, overwrite=args.overwrite, marked=args.marked, max_workers=args.workers)
return execute(parser, argv, handle)
+2 -2
View File
@@ -117,7 +117,7 @@ def group_files(file_list):
return [g['items'] for g in groups]
def stitch_pdf_pages(images_list):
"""Vertically concatenates a list of PIL images with no separator."""
"""Stack PIL images vertically, centering each within the widest image."""
if not images_list:
return None
if len(images_list) == 1:
@@ -130,7 +130,7 @@ def stitch_pdf_pages(images_list):
y_offset = 0
for img in images_list:
combined.paste(img, (0, y_offset))
combined.paste(img, ((max_width - img.width) // 2, y_offset))
y_offset += img.height
return combined
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
from tempfile import TemporaryDirectory
from copienator import (
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
from copienator.annotation_modifications import (
APPLIED,
DRAFT,
MODES,
baseline,
output_removals,
read_instructions,
rebuild,
validate_instructions,
)
from copienator.filesystem import publish_file_sets
def run(workspace: EvaluationWorkspace, *, annotation_dir: str = "BGnot") -> ExitCode:
workspace.require_files("labels", DRAFT)
workspace.require_directories("Copies", annotation_dir)
text = (workspace.root / DRAFT).read_text(encoding="utf-8")
instructions = read_instructions(text, annotation_dir)
validate_instructions(workspace.root, annotation_dir, instructions)
directory = workspace.root / annotation_dir
applied = directory / APPLIED
previous = read_instructions(applied.read_text(encoding="utf-8"), annotation_dir) if applied.exists() else []
copy_ids = sorted({item.copy_id for item in [*previous, *instructions]})
# Prepare every copy before publishing anything, including the applied file.
with TemporaryDirectory(prefix=".annotation-edits-", dir=directory) as temporary:
staging_root = Path(temporary)
file_sets = {}
for copy_id in copy_ids:
output = directory / f"Copie{copy_id}"
meta = baseline(output)
staging = staging_root / output.name
staging.mkdir()
selected = [item for item in instructions if item.copy_id == copy_id]
rebuild(workspace.root, annotation_dir, output, staging, meta, selected,
read_json(output / "score.json"))
file_sets[output] = (staging, output_removals(output))
manifest = staging_root / "manifest"
manifest.mkdir()
(manifest / APPLIED).write_text(text, encoding="utf-8")
file_sets[directory] = (manifest, ())
publish_file_sets(file_sets)
print(f"Modifications appliquées à {len(copy_ids)} copie(s). Les scores sont conservés.")
print("Relancez « Attribuer les noms et préparer A Rendre » pour actualiser tous les livrables.")
return ExitCode.SUCCESS
def build_parser():
parser = evaluation_parser("Modify reviewed answer bodies using annotation_modifications.txt")
parser.add_argument("--annotation-dir", choices=MODES, default="BGnot")
return parser
def main(argv: Sequence[str] | None = None) -> int:
return execute(build_parser(), argv, lambda args: run(
workspace_from_args(args), annotation_dir=args.annotation_dir))
if __name__ == "__main__":
raise SystemExit(main())
+49 -5
View File
@@ -21,6 +21,7 @@ from copienator import (
target_parser,
workspace_from_target,
)
from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths
from copienator.platform import open_path
from copienator.utils import natural_key, read_all_labels
@@ -307,6 +308,7 @@ class ImageViewer:
self.forward_stack = []
self.current_pil_image = None
self.failed = False
self.skipped_copies = set()
from config import PLOTTING_KB
@@ -314,6 +316,7 @@ class ImageViewer:
self.root.bind(PLOTTING_KB["OK"], self.on_enter)
self.root.bind(PLOTTING_KB["previous"], self.on_previous)
self.root.bind("P", self.on_previous)
self.root.bind("!", self.on_skip)
self.root.bind(PLOTTING_KB["edit"], self.on_edit)
self.root.bind(PLOTTING_KB["open pdf"], self.on_open_pdf)
self.root.bind(PLOTTING_KB["open original pdf"], self.on_open_ori_pdf)
@@ -345,6 +348,10 @@ class ImageViewer:
self.root.quit()
return
if metadata["copie"] in self.skipped_copies:
self.root.after(0, self.poll_queue)
return
# Check if we switched to a new "Copie" group
if self.active_copie_name != metadata["copie"]:
self.save_current_batch() # Write previous group to disk
@@ -361,8 +368,21 @@ class ImageViewer:
"""Writes the accumulated data to the main JSON file."""
if self.active_copie_name and self.accumulated_results:
main_json_path = self.base_dir / "Copies" / f"{self.active_copie_name}.json"
accepted = [item[2] for item in self.history
if item[3] == self.active_copie_name]
parts = {item["part"] for item in accepted}
expected = (set(range(1, len(accepted[-1]["schema"]["columns_per_file"]) + 1))
if accepted else set())
if not expected or parts != expected:
mark_copy_error(self.workspace, main_json_path.with_suffix(".pdf"),
"Vérification des labels incomplète", source="plotting")
self.failed = True
self.accumulated_results = None
return
print(f"Writing aggregated result to {main_json_path}")
atomic_write_json(main_json_path, self.accumulated_results)
clear_copy_error(self.workspace, main_json_path.with_suffix(".pdf"),
source="plotting")
self.accumulated_results = None
def close(self):
@@ -467,6 +487,21 @@ class ImageViewer:
self.is_viewing = False
self.label.config(image="", text="Loading next...")
def on_skip(self, _event=None):
if not self.is_viewing:
return
copie = self.current_meta["copie"]
mark_copy_error(self.workspace, self.workspace.copies_dir / f"{copie}.pdf",
"Problème repéré pendant la vérification des labels", source="plotting")
self.failed = True
self.skipped_copies.add(copie)
# Discard accepted parts of this copy without replacing its previous result.
self.accumulated_results = None
self.history = [item for item in self.history if item[3] != copie]
self.forward_stack = [item for item in self.forward_stack if item[2]["copie"] != copie]
self.is_viewing = False
self.label.config(image="", text="Copie signalée. Chargement de la suivante…")
def on_open_pdf(self, event):
if self.is_viewing and self.current_json_path:
a = self.current_json_path.stem.split('_')[0] + ".pdf"
@@ -530,17 +565,24 @@ def _selected_images(workspace: EvaluationWorkspace, target: Path) -> list[Path]
return sorted(workspace.cutleft_dir.glob("*.jpg"), key=natural_key)
def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False) -> ExitCode:
workspace.require_files("labels")
all_labels = read_all_labels(workspace.root)
files_to_process = _selected_images(workspace, target)
if marked:
copies = marked_copy_paths(workspace)
if not copies:
print("No flagged copies to verify.")
return ExitCode.SUCCESS
files_to_process = [image for pdf in copies for image in _selected_images(workspace, pdf)]
else:
files_to_process = _selected_images(workspace, target)
if not files_to_process:
print(f"No Cutleft images found for {target}")
return ExitCode.PARTIAL
print(
"o to open pdf, O original pdf, e to edit part, p to go back, "
"i to open the statement, click for coordinates"
"i to open the statement, ! to flag and skip the copy, click for coordinates"
)
input_queue = queue.Queue(maxsize=5)
worker = threading.Thread(
@@ -556,7 +598,9 @@ def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
def build_parser() -> argparse.ArgumentParser:
return target_parser("Interactively verify detected label coordinates")
parser = target_parser("Interactively verify detected label coordinates")
parser.add_argument("--marked", action="store_true", help="Verify only copies with remaining flags")
return parser
def main(argv: Sequence[str] | None = None) -> int:
@@ -564,7 +608,7 @@ def main(argv: Sequence[str] | None = None) -> int:
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target)
return run(workspace, target, marked=args.marked)
return execute(parser, argv, handle)