Allow signaling issues in label verification
This commit is contained in:
1 parent
3754c0c11a
commit
9925362352
21 files changed
+1345
-34
No files matched your search
@@ -0,0 +1,237 @@
|
||||
"""Reproducible edits to reviewed answer bodies, never to headers or supplements."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from copienator import CliError, atomic_write_json, read_json, utils
|
||||
from copienator.commands.resolve_manual import ManualInstruction, parse_instruction_text
|
||||
|
||||
DRAFT = "annotation_modifications.txt"
|
||||
APPLIED = "annotation_modifications_applied.txt"
|
||||
BASELINE = "annotation_baseline.json"
|
||||
MODES = ("BGnot", "Bnot")
|
||||
|
||||
|
||||
def read_instructions(text: str, mode: str) -> list[ManualInstruction]:
|
||||
declared = re.findall(r"^### Source:\s*(\S+)\s*$", text, re.MULTILINE)
|
||||
if declared != [mode] or mode not in MODES:
|
||||
raise CliError(f"Le fichier doit contenir exactement une ligne '### Source: {mode}'.")
|
||||
return parse_instruction_text(text)
|
||||
|
||||
|
||||
def normalize_copy(root: Path, value: str) -> str:
|
||||
number = value.strip().removeprefix("Copie")
|
||||
if not number.isdecimal():
|
||||
raise CliError("Numéro de copie invalide.")
|
||||
matches = [p.name.removeprefix("Copie") for p in (root / "Copies").glob("Copie*")
|
||||
if p.is_dir() and p.name.removeprefix("Copie").isdecimal()
|
||||
and int(p.name.removeprefix("Copie")) == int(number)]
|
||||
if len(matches) != 1:
|
||||
raise CliError(f"Copie {value} introuvable ou ambiguë.")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def baseline(root: Path) -> dict:
|
||||
path = root / BASELINE
|
||||
if not path.is_file():
|
||||
raise CliError(f"Relancez « Lire les annotations manuscrites » pour {root.name} : "
|
||||
"la séparation exacte entre en-tête et réponse est indisponible.")
|
||||
return read_json(path)
|
||||
|
||||
|
||||
def load_image(directory: Path, entry: dict) -> Image.Image:
|
||||
filename = entry["file"]
|
||||
if Path(filename).name != filename:
|
||||
raise CliError("Chemin d’image de référence invalide.")
|
||||
with Image.open(directory / filename) as image:
|
||||
result = image.convert("RGB").copy()
|
||||
height = entry["header_height"]
|
||||
if not isinstance(height, int) or not 0 <= height < result.height:
|
||||
raise CliError("Séparation en-tête/réponse invalide ; relancez la lecture des annotations.")
|
||||
return result
|
||||
|
||||
|
||||
def body_image(directory: Path, label: str) -> Image.Image:
|
||||
entry = baseline(directory)["images"].get(label)
|
||||
if entry is None:
|
||||
raise CliError(f"Pas de réponse annotée pour {label} dans {directory.name}.")
|
||||
image = load_image(directory, entry)
|
||||
return image.crop((0, entry["header_height"], image.width, image.height))
|
||||
|
||||
|
||||
def validate_instructions(root: Path, mode: str, instructions: list[ManualInstruction]) -> None:
|
||||
labels = set(utils.read_all_labels(root))
|
||||
sources = set()
|
||||
targets = set()
|
||||
for item in instructions:
|
||||
for label in (item.old_label, item.new_label):
|
||||
if label not in labels or Path(label).name != label or label in {".", ".."}:
|
||||
raise CliError(f"Label inconnu ou invalide : {label}")
|
||||
if item.old_label == item.new_label:
|
||||
raise CliError("La source et la cible doivent être différentes.")
|
||||
if normalize_copy(root, item.copy_id) != item.copy_id:
|
||||
raise CliError(f"Utilisez Copie{normalize_copy(root, item.copy_id)} dans le fichier.")
|
||||
key = (item.copy_id, item.old_label)
|
||||
if key in sources:
|
||||
raise CliError("Une seule instruction par label source et copie est autorisée.")
|
||||
sources.add(key)
|
||||
targets.add((item.copy_id, item.new_label))
|
||||
directory = root / mode / f"Copie{item.copy_id}"
|
||||
source = body_image(directory, item.old_label)
|
||||
if item.cut:
|
||||
y = round(source.height * item.cut[0] / 100)
|
||||
if not 0 < y < source.height:
|
||||
raise CliError("Chaque partie de la coupe doit contenir au moins un pixel.")
|
||||
target = baseline(directory)["images"].get(item.new_label)
|
||||
if target:
|
||||
load_image(directory, target)
|
||||
# Also report damaged current outputs instead of silently replacing them.
|
||||
for label in (item.old_label, item.new_label):
|
||||
path = directory / f"{label}.jpg"
|
||||
if path.exists():
|
||||
with Image.open(path) as image:
|
||||
image.load()
|
||||
if sources & targets:
|
||||
raise CliError("Une cible ne peut pas également être source dans le même fichier.")
|
||||
|
||||
|
||||
def stack(images: list[Image.Image]) -> Image.Image | None:
|
||||
images = [image for image in images if image.height]
|
||||
if not images:
|
||||
return None
|
||||
result = Image.new("RGB", (max(i.width for i in images), sum(i.height for i in images)), "white")
|
||||
y = 0
|
||||
for image in images:
|
||||
result.paste(image, (0, y))
|
||||
y += image.height
|
||||
return result
|
||||
|
||||
|
||||
def edited_bodies(directory: Path, meta: dict, instructions: list[ManualInstruction]):
|
||||
headers, bodies = {}, {}
|
||||
for label, entry in meta["images"].items():
|
||||
image = load_image(directory, entry)
|
||||
h = entry["header_height"]
|
||||
headers[label] = image.crop((0, 0, image.width, h))
|
||||
bodies[label] = image.crop((0, h, image.width, image.height))
|
||||
originals = bodies.copy()
|
||||
changed = set()
|
||||
for item in instructions:
|
||||
source, target = item.old_label, item.new_label
|
||||
if source not in originals:
|
||||
raise CliError(f"Source annotée absente : {source}. Les modifications n’ont pas été appliquées.")
|
||||
piece = originals[source]
|
||||
if item.cut:
|
||||
percent, keep = item.cut
|
||||
y = round(piece.height * percent / 100)
|
||||
if not 0 < y < piece.height:
|
||||
raise CliError(f"Coupe invalide pour {source}.")
|
||||
top, bottom = piece.crop((0, 0, piece.width, y)), piece.crop((0, y, piece.width, piece.height))
|
||||
bodies[source], piece = (top, bottom) if keep == 1 else (bottom, top)
|
||||
changed.add(source)
|
||||
elif item.operator.startswith("x"):
|
||||
bodies.pop(source, None)
|
||||
changed.add(source)
|
||||
if item.should_merge:
|
||||
parts = ([bodies[target]] if target in bodies else [])
|
||||
parts.insert(0 if item.pipe_first else len(parts), piece)
|
||||
bodies[target] = stack(parts)
|
||||
changed.add(target)
|
||||
elif item.should_copy:
|
||||
bodies[target] = piece
|
||||
changed.add(target)
|
||||
elif item.operator.endswith("x"):
|
||||
bodies.pop(target, None)
|
||||
changed.add(target)
|
||||
return headers, bodies, changed
|
||||
|
||||
|
||||
def output_removals(directory: Path) -> tuple[str, ...]:
|
||||
info = read_json(directory / "info.json") if (directory / "info.json").exists() else {}
|
||||
return ("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json",
|
||||
*(f"{label}.jpg" for label, entry in info.items()
|
||||
if entry.get("not_empty") and Path(label).name == label))
|
||||
|
||||
|
||||
def rebuild(root: Path, mode: str, directory: Path, staging: Path, meta: dict,
|
||||
instructions: list[ManualInstruction], scores: dict) -> None:
|
||||
# Imported lazily: both readers use this module while saving their baseline.
|
||||
from copienator.commands import annotating
|
||||
from copienator.commands.reading_grouped_annotations import (
|
||||
get_extra_pdfs_as_images,
|
||||
save_paginated_pdf,
|
||||
)
|
||||
|
||||
headers, bodies, changed = edited_bodies(directory, meta, instructions)
|
||||
info = deepcopy(meta["info"])
|
||||
filtered = set(meta["filtered"]) | changed
|
||||
all_labels = utils.read_all_labels(root)
|
||||
images, groups = [], []
|
||||
seen_contexts = set()
|
||||
order = sorted(bodies, key=utils.natural_key)
|
||||
for label in order:
|
||||
if label not in bodies:
|
||||
continue
|
||||
body = bodies[label]
|
||||
header = headers.get(label)
|
||||
if header is None:
|
||||
# No prior answer means no prior header. Do not invent a score or
|
||||
# resurrect Gemini feedback from correction.json.
|
||||
header = Image.new("RGB", (max(body.width, 800), 80), "white")
|
||||
ImageDraw.Draw(header).text((20, 20), f"{label} Note : {scores.get(label, '')}",
|
||||
fill="black", font_size=30)
|
||||
image = stack([header, body])
|
||||
image.save(staging / f"{label}.jpg")
|
||||
images.append(image)
|
||||
if label in filtered:
|
||||
extras = get_extra_pdfs_as_images(root, label, annotating, all_labels,
|
||||
seen_contexts=seen_contexts) if mode == "BGnot" else []
|
||||
groups.append([*extras, image])
|
||||
for label, entry in info.items():
|
||||
entry["not_empty"] = label in bodies
|
||||
if label in changed and label in bodies:
|
||||
entry["present"] = True
|
||||
entry["touched"] = mode == "BGnot" and label in bodies and label in filtered
|
||||
entry["score"] = scores.get(label, entry["score"])
|
||||
atomic_write_json(staging / "info.json", info)
|
||||
if images:
|
||||
stack(images).save(staging / "Concat.jpg")
|
||||
if groups:
|
||||
if mode == "BGnot":
|
||||
save_paginated_pdf(groups, staging / "Concat_F.pdf")
|
||||
else:
|
||||
stack([image for group in groups for image in group]).save(staging / "Concat_F.jpg")
|
||||
|
||||
|
||||
def stage_baseline(root: Path, mode: str, output: Path, staging: Path,
|
||||
images: dict[str, Image.Image], heights: dict[str, int],
|
||||
info: dict, filtered: list[str], scores: dict) -> None:
|
||||
"""Save exact lossless reviewed layers, then replay only applied edits."""
|
||||
if mode not in MODES:
|
||||
return
|
||||
applied = output.parent / APPLIED
|
||||
instructions = read_instructions(applied.read_text(encoding="utf-8"), mode) if applied.is_file() else []
|
||||
selected = [i for i in instructions if f"Copie{i.copy_id}" == output.name]
|
||||
if not images.keys() <= heights.keys():
|
||||
if selected:
|
||||
raise CliError("Relancez la lecture principale des annotations avant de réappliquer les modifications.")
|
||||
# Legacy selective redo can still preserve its old images. A full read is
|
||||
# required before body editing; never guess the old header boundaries.
|
||||
return
|
||||
meta = {"version": 1, "images": {}, "info": info, "filtered": filtered}
|
||||
for label, image in images.items():
|
||||
filename = ".annotation-base-" + sha256(label.encode()).hexdigest() + ".png"
|
||||
image.save(staging / filename)
|
||||
meta["images"][label] = {"file": filename, "header_height": heights[label]}
|
||||
atomic_write_json(staging / BASELINE, meta)
|
||||
if selected:
|
||||
# Remove freshly staged aggregates before rebuilding; empty filtered
|
||||
# output must not retain the reader's previous version.
|
||||
for name in ("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", *(f"{label}.jpg" for label in images)):
|
||||
(staging / name).unlink(missing_ok=True)
|
||||
rebuild(root, mode, staging, staging, meta, selected, scores)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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,9 +565,16 @@ 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)
|
||||
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}")
|
||||
@@ -540,7 +582,7 @@ def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
|
||||
|
||||
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)
|
||||
|
||||
|
||||
+40
-14
@@ -1,4 +1,4 @@
|
||||
"""Persistent copy flags shared by page splitting, margin review and the GUI."""
|
||||
"""Persistent per-step copy flags shared by repair commands and the GUI."""
|
||||
from pathlib import Path
|
||||
|
||||
from copienator import CliError, EvaluationWorkspace, atomic_update_json, read_json
|
||||
@@ -8,31 +8,57 @@ def _path(workspace: EvaluationWorkspace) -> Path:
|
||||
return workspace.metadata_dir / "copy_errors.json"
|
||||
|
||||
|
||||
def _validate(value) -> dict[str, str]:
|
||||
if not isinstance(value, dict) or any(
|
||||
not isinstance(name, str) or not isinstance(reason, str)
|
||||
or "/" in name or "\\" in name or Path(name).suffix.casefold() != ".pdf"
|
||||
for name, reason in value.items()
|
||||
):
|
||||
raise CliError("Invalid copy_errors.json: expected PDF filenames and error descriptions")
|
||||
return value
|
||||
def _validate(value) -> dict[str, dict[str, str]]:
|
||||
if not isinstance(value, dict):
|
||||
raise CliError("Invalid copy_errors.json: expected PDF filenames and step flags")
|
||||
normalized = {}
|
||||
for name, flags in value.items():
|
||||
if (not isinstance(name, str) or "/" in name or "\\" in name
|
||||
or Path(name).suffix.casefold() != ".pdf"):
|
||||
raise CliError("Invalid copy_errors.json: invalid PDF filename")
|
||||
# Older versions only reported errors from margin cropping.
|
||||
if isinstance(flags, str):
|
||||
flags = {"cutleft": flags}
|
||||
if (not isinstance(flags, dict) or not flags
|
||||
or any(step not in {"cutleft", "plotting"} or not isinstance(reason, str)
|
||||
for step, reason in flags.items())):
|
||||
raise CliError("Invalid copy_errors.json: invalid step flags")
|
||||
normalized[name] = dict(flags)
|
||||
return normalized
|
||||
|
||||
|
||||
def copy_errors(workspace: EvaluationWorkspace) -> dict[str, str]:
|
||||
return _validate(read_json(_path(workspace), default={}))
|
||||
"""Return a summary per copy, preserving the existing GUI-facing API."""
|
||||
flags = _validate(read_json(_path(workspace), default={}))
|
||||
return {name: "; ".join(reasons.values()) for name, reasons in flags.items()}
|
||||
|
||||
|
||||
def mark_copy_error(workspace: EvaluationWorkspace, pdf: Path, reason: str) -> None:
|
||||
def mark_copy_error(workspace: EvaluationWorkspace, pdf: Path, reason: str,
|
||||
*, source: str = "cutleft") -> None:
|
||||
if source not in {"cutleft", "plotting"}:
|
||||
raise ValueError(f"Unknown copy flag source: {source}")
|
||||
def update(errors):
|
||||
_validate(errors)[pdf.name] = reason
|
||||
normalized = _validate(errors)
|
||||
normalized.setdefault(pdf.name, {})[source] = reason
|
||||
errors.clear()
|
||||
errors.update(normalized)
|
||||
atomic_update_json(_path(workspace), update, default_factory=dict)
|
||||
|
||||
|
||||
def clear_copy_error(workspace: EvaluationWorkspace, pdf: Path) -> None:
|
||||
def clear_copy_error(workspace: EvaluationWorkspace, pdf: Path,
|
||||
*, source: str = "cutleft") -> None:
|
||||
if source not in {"cutleft", "plotting"}:
|
||||
raise ValueError(f"Unknown copy flag source: {source}")
|
||||
if not _path(workspace).exists():
|
||||
return
|
||||
def update(errors):
|
||||
_validate(errors).pop(pdf.name, None)
|
||||
normalized = _validate(errors)
|
||||
flags = normalized.get(pdf.name, {})
|
||||
flags.pop(source, None)
|
||||
if not flags:
|
||||
normalized.pop(pdf.name, None)
|
||||
errors.clear()
|
||||
errors.update(normalized)
|
||||
atomic_update_json(_path(workspace), update, default_factory=dict)
|
||||
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ import json
|
||||
|
||||
|
||||
def get_single_image_bytes(pdf_path):
|
||||
"""Converts a multi-page PDF into a single stitched JPEG in memory."""
|
||||
"""Convert PDF pages into a single JPEG, centering pages within the widest."""
|
||||
imgs = convert_from_path(pdf_path, dpi=200) # Same DPI as grouping.py
|
||||
if not imgs:
|
||||
raise ValueError(f"No pages in {pdf_path}")
|
||||
@@ -250,7 +250,7 @@ def get_single_image_bytes(pdf_path):
|
||||
combined = Image.new('RGB', (max_width, total_height), 'white')
|
||||
y_offset = 0
|
||||
for img in imgs:
|
||||
combined.paste(img, (0, y_offset))
|
||||
combined.paste(img, ((max_width - img.width) // 2, y_offset))
|
||||
y_offset += img.height
|
||||
|
||||
img_byte_arr = io.BytesIO()
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from copienator import CliError, utils
|
||||
from copienator.annotation_modifications import (
|
||||
DRAFT,
|
||||
baseline,
|
||||
body_image,
|
||||
edited_bodies,
|
||||
normalize_copy,
|
||||
read_instructions,
|
||||
validate_instructions,
|
||||
)
|
||||
from copienator.commands.resolve_manual import ManualInstruction
|
||||
from copienator.platform import open_path
|
||||
|
||||
from .cut_helper import CutHelper
|
||||
from .manual_resolution import ManualResolutionPanel
|
||||
|
||||
HELP = """Ajoutez une copie, un label source et un label cible. Source, Cible et Cut montrent uniquement le corps de la réponse avec ses annotations conservées, sans note, commentaire global, contexte, énoncé ou corrigé. Les en-têtes restent associés à leur label ; les scores ne changent pas.
|
||||
|
||||
Format : Copie43 Label source OP Label cible|
|
||||
-> ajoute la source à la cible ; x> la déplace ; -x copie en remplaçant la cible ; xx déplace en remplaçant. ss conserve les deux ; sx vide la cible ; xs vide la source.
|
||||
c{43}1> conserve les premiers 43 % de la source et ajoute le reste à la cible ; c{43}2x conserve la fin et remplace la cible par le début. Le pourcentage porte uniquement sur le corps de la réponse. |Cible place le fragment avant la cible ; Cible| le place après.
|
||||
|
||||
Une instruction par source. Une cible ne peut pas également être source. Plusieurs sources peuvent alimenter une cible dans l’ordre du fichier. Une cible sans image est vide.
|
||||
Cut affiche une commande à recopier dans l’éditeur. Enregistrez puis Recharger. Aperçu du résultat montre les corps après toutes les instructions enregistrées.
|
||||
|
||||
Make modifications applique uniquement le fichier enregistré. Chaque application repart des images originales relues : modifier ou retirer une ligne puis appliquer remplace ou annule son effet. Les lignes ### sont des commentaires ; conservez la ligne ### Source: BGnot ou Bnot. Le fichier est conservé. Une nouvelle lecture des annotations réapplique les dernières instructions appliquées, jamais un brouillon. Relancez ensuite la préparation de A Rendre (sans l’option limitée à answers) pour actualiser tous les livrables."""
|
||||
|
||||
|
||||
def annotation_mode_for_reader(reader):
|
||||
if reader.get("last_run_variant", reader.get("variant")) == "standard":
|
||||
return "Bnot"
|
||||
values = reader.get("last_run_values", reader.get("values", {}))
|
||||
if values.get("refaire") and values.get("annotation_dir") == "Bnot":
|
||||
return "Bnot"
|
||||
return "BGnot"
|
||||
|
||||
|
||||
class AnnotationModificationsPanel(ManualResolutionPanel):
|
||||
def __init__(self, parent, get_evaluation, get_mode):
|
||||
self.get_mode = get_mode
|
||||
self.previews = TemporaryDirectory(prefix="copienator-body-preview-")
|
||||
super().__init__(parent, get_evaluation, help_text=HELP, show_provenance=False)
|
||||
self.bind("<Destroy>", self._cleanup, add="+")
|
||||
form = ttk.Frame(self)
|
||||
form.pack(fill="x", before=self.editor_button.master, pady=5)
|
||||
self.copy_var, self.source_var, self.target_var = (tk.StringVar() for _ in range(3))
|
||||
ttk.Label(form, text="Copie").grid(row=0, column=0)
|
||||
ttk.Entry(form, textvariable=self.copy_var, width=9).grid(row=1, column=0, padx=3)
|
||||
self.label_choices = []
|
||||
for column, title, variable in ((1, "Label source", self.source_var), (2, "Label cible", self.target_var)):
|
||||
ttk.Label(form, text=title).grid(row=0, column=column)
|
||||
combo = ttk.Combobox(form, textvariable=variable, width=22)
|
||||
combo.grid(row=1, column=column, padx=3)
|
||||
self.label_choices.append(combo)
|
||||
ttk.Button(form, text="Ajouter", command=self.add_instruction).grid(row=1, column=3, padx=5)
|
||||
ttk.Button(self.editor_button.master, text="Aperçu du résultat", command=self.preview_result).pack(side="left")
|
||||
self.reload()
|
||||
|
||||
def _cleanup(self, event):
|
||||
if event.widget is self:
|
||||
self.previews.cleanup()
|
||||
|
||||
def manual_path(self):
|
||||
evaluation = self.get_evaluation()
|
||||
return evaluation / DRAFT if evaluation else None
|
||||
|
||||
def _read(self):
|
||||
path = self.manual_path()
|
||||
if path is None:
|
||||
raise CliError("Chargez une évaluation.")
|
||||
text = path.read_text(encoding="utf-8") if path.exists() else f"### Source: {self.get_mode()}\n"
|
||||
return path, text, read_instructions(text, self.get_mode())
|
||||
|
||||
def reload(self):
|
||||
super().reload()
|
||||
self.editor_button.configure(state="normal" if self.manual_path() else "disabled")
|
||||
try:
|
||||
path, _, instructions = self._read()
|
||||
validate_instructions(path.parent, self.get_mode(), instructions)
|
||||
for combo in getattr(self, "label_choices", []):
|
||||
combo.configure(values=utils.read_all_labels(path.parent))
|
||||
except (CliError, OSError, ValueError, KeyError) as exc:
|
||||
self.status.configure(text=str(exc))
|
||||
|
||||
def open_editor(self):
|
||||
try:
|
||||
path = self.manual_path()
|
||||
if path is None:
|
||||
raise CliError("Chargez une évaluation.")
|
||||
if not path.exists():
|
||||
path.write_text(f"### Source: {self.get_mode()}\n", encoding="utf-8")
|
||||
open_path(path)
|
||||
except (CliError, OSError, ValueError, RuntimeError) as exc:
|
||||
messagebox.showerror("Ouverture du fichier", str(exc))
|
||||
|
||||
def add_instruction(self):
|
||||
try:
|
||||
path, text, instructions = self._read()
|
||||
copy_id = normalize_copy(path.parent, self.copy_var.get())
|
||||
source, target = self.source_var.get().strip(), self.target_var.get().strip()
|
||||
item = ManualInstruction(copy_id, source, "->", target, False)
|
||||
validate_instructions(path.parent, self.get_mode(), [*instructions, item])
|
||||
text = text.rstrip() + f"\nCopie{copy_id} {source} -> {target}|\n"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
self.reload()
|
||||
except (CliError, OSError, ValueError, KeyError) as exc:
|
||||
messagebox.showerror("Instruction invalide", str(exc))
|
||||
|
||||
def _show(self, image, name):
|
||||
path = Path(self.previews.name) / f"{name}.png"
|
||||
image.save(path)
|
||||
open_path(path)
|
||||
|
||||
def open_pdf(self, evaluation, copy_id, label):
|
||||
try:
|
||||
self._show(body_image(evaluation / self.get_mode() / f"Copie{copy_id}", label),
|
||||
f"Copie{copy_id} - {label} - corps")
|
||||
except (CliError, OSError, ValueError, RuntimeError, KeyError) as exc:
|
||||
messagebox.showinfo("Aperçu du corps annoté", str(exc))
|
||||
|
||||
def open_cut(self, evaluation, instruction):
|
||||
def accepted(operator):
|
||||
target = "|" + instruction.new_label if instruction.pipe_first else instruction.new_label + "|"
|
||||
self.cut_result.set(f"Copie{instruction.copy_id} {instruction.old_label} {operator} {target}")
|
||||
try:
|
||||
source = body_image(evaluation / self.get_mode() / f"Copie{instruction.copy_id}", instruction.old_label)
|
||||
initial = (*instruction.cut, instruction.operator[-1]) if instruction.cut else None
|
||||
CutHelper(self, source, accepted, initial)
|
||||
except (CliError, OSError, ValueError, RuntimeError, KeyError) as exc:
|
||||
messagebox.showerror("Coupe du corps annoté", str(exc))
|
||||
|
||||
def preview_result(self):
|
||||
try:
|
||||
path, _, instructions = self._read()
|
||||
validate_instructions(path.parent, self.get_mode(), instructions)
|
||||
# Preview just the requested copy, avoiding a window for every copy.
|
||||
copy_id = normalize_copy(path.parent, self.copy_var.get())
|
||||
selected = [i for i in instructions if i.copy_id == copy_id]
|
||||
if not selected:
|
||||
raise CliError("Aucune instruction pour cette copie. Saisissez son numéro dans Copie.")
|
||||
directory = path.parent / self.get_mode() / f"Copie{copy_id}"
|
||||
_, bodies, changed = edited_bodies(directory, baseline(directory), selected)
|
||||
for label in sorted(changed):
|
||||
if label in bodies:
|
||||
self._show(bodies[label], f"Copie{copy_id} - {label} - résultat")
|
||||
emptied = sorted(changed - bodies.keys())
|
||||
if emptied:
|
||||
messagebox.showinfo("Réponses vidées", ", ".join(emptied))
|
||||
except (CliError, OSError, ValueError, RuntimeError, KeyError) as exc:
|
||||
messagebox.showerror("Aperçu du résultat", str(exc))
|
||||
@@ -212,6 +212,7 @@ def plotting_shortcut_lines() -> list[str]:
|
||||
f"{display_names.get(PLOTTING_KB[action], PLOTTING_KB[action])} : {description}"
|
||||
for action, description in labels
|
||||
]
|
||||
lines.append("! : signaler une erreur et passer à la copie suivante")
|
||||
lines.append("Échap : fermer la fenêtre")
|
||||
return lines
|
||||
|
||||
@@ -1189,7 +1190,7 @@ class CopienatorApp(tk.Tk):
|
||||
)
|
||||
ttk.Label(copies, text=help_text + "Vérifiez la commande puis cliquez sur Exécuter.",
|
||||
wraplength=570).grid(row=3, column=0, columnspan=2, sticky="w", pady=(5, 0))
|
||||
if step.id in {"page_splitter", "cutleft"}:
|
||||
if step.id in {"page_splitter", "cutleft", "labels", "plotting"}:
|
||||
try:
|
||||
marked = copy_errors(EvaluationWorkspace(evaluation)) if evaluation else {}
|
||||
marked_summary = ", ".join(marked) or "Aucune copie signalée."
|
||||
@@ -1316,7 +1317,7 @@ class CopienatorApp(tk.Tk):
|
||||
self.arg_vars["target"].set(self._evaluation_arg())
|
||||
|
||||
def _run_marked_copies(self) -> None:
|
||||
if (not self.current_step or self.current_step.id not in {"page_splitter", "cutleft"}
|
||||
if (not self.current_step or self.current_step.id not in {"page_splitter", "cutleft", "labels", "plotting"}
|
||||
or self.active_step_id or self.runner.running or not self.state_store.evaluation):
|
||||
return
|
||||
self.arg_vars["target"].set(self._evaluation_arg())
|
||||
@@ -2146,7 +2147,7 @@ class CopienatorApp(tk.Tk):
|
||||
if self.current_step and self.current_step.id == "batch_status":
|
||||
self.watch_batches_button.configure(state="disabled" if running or self.batch_monitor.active or not self.state_store.evaluation else "normal")
|
||||
self.stop_watch_batches_button.configure(state="normal" if self.batch_monitor.active else "disabled")
|
||||
if running and self.current_step and self.current_step.id in {"page_splitter", "cutleft"}:
|
||||
if running and self.current_step and self.current_step.id in {"page_splitter", "cutleft", "labels", "plotting"}:
|
||||
self.marked_copies_button.configure(state="disabled")
|
||||
if self.current_step and self.current_step.id == "rename":
|
||||
self.validate_rename_button.configure(
|
||||
|
||||
@@ -293,6 +293,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Dans la fenêtre de découpe, utilisez les flèches : ←/→ déplacent la zone "
|
||||
"de 50 px vers la gauche/droite, ↑ l’élargit de 50 px et ↓ la réduit de 50 px. "
|
||||
"1 utilise les pages entières, Entrée valide et ! signale une erreur. "
|
||||
"La validation efface uniquement le signalement de découpe, sans effacer celui des labels."
|
||||
f"{personnal_binding} "
|
||||
"Une copie précise peut être ciblée.",
|
||||
(python("default", "Découpe", "crop-labels"),),
|
||||
@@ -331,6 +332,14 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
(python("default", "Détection des labels", "labels"),),
|
||||
arguments=(
|
||||
arg_target(),
|
||||
ArgumentSpec(
|
||||
"marked",
|
||||
"Copies signalées uniquement",
|
||||
"bool",
|
||||
"--marked",
|
||||
help="Traite uniquement les copies ayant un signalement encore actif. "
|
||||
"En détection, régénère aussi les résultats existants.",
|
||||
),
|
||||
ArgumentSpec(
|
||||
"workers",
|
||||
"Requêtes simultanées",
|
||||
@@ -361,9 +370,20 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Labels et regroupement",
|
||||
"Vérifier visuellement les labels",
|
||||
"Ouvre la fenêtre de vérification. La cible peut être toute l’évaluation ou une copie. "
|
||||
"Le raccourci p revient à l’image précédente, y compris dans la copie précédente.",
|
||||
"Le raccourci p revient à l’image précédente, y compris dans la copie précédente. "
|
||||
"! signale une erreur et passe à la copie suivante. Entrée valide chaque image ; "
|
||||
"le signalement de vérification est effacé après validation complète de la copie.",
|
||||
(python("default", "Vérification", "review-labels"),),
|
||||
arguments=(arg_target(),),
|
||||
arguments=(
|
||||
arg_target(),
|
||||
ArgumentSpec(
|
||||
"marked",
|
||||
"Copies signalées uniquement",
|
||||
"bool",
|
||||
"--marked",
|
||||
help="Vérifie uniquement les copies ayant un signalement encore actif.",
|
||||
),
|
||||
),
|
||||
requires=("labels", "Cutleft"),
|
||||
artifacts=("Copies/Copie*.json",),
|
||||
),
|
||||
|
||||
@@ -106,7 +106,8 @@ l'envers, ou dont les pages sont dans le mauvais ordre. Utiliser **!**
|
||||
pour signaler une copie problématique.
|
||||
|
||||
Vous pourrez alors relancer l'étape de réordonnement des copies avec
|
||||
les copies signalées.
|
||||
les copies signalées. Une découpe gauche enregistrée avec succès efface uniquement
|
||||
son propre signalement ; un signalement de vérification des labels reste actif.
|
||||
|
||||
## Labels et regroupement
|
||||
|
||||
@@ -114,6 +115,10 @@ les copies signalées.
|
||||
|
||||
**Effet :** reconnaît les identifiants de questions et leur position dans les copies.
|
||||
|
||||
**Copies signalées uniquement** limite la détection aux copies ayant au moins
|
||||
un signalement actif et régénère leurs résultats existants. La détection
|
||||
n’efface aucun signalement.
|
||||
|
||||
### Vérifier visuellement les labels
|
||||
|
||||
**Effet :** valide ou corrige les repères qui serviront à découper les réponses.
|
||||
@@ -131,6 +136,14 @@ Pour faire des modifications : **e** ouvre le fichier .json à modifier à la ma
|
||||
**Entrée** pour valider, **i** pour ouvrir l'énoncer, **o** pour
|
||||
ouvrir la copie d'origine, **p** pour revenir en arrière.
|
||||
|
||||
**!** signale une erreur et passe à la copie suivante sans remplacer le résultat
|
||||
complet précédent par une vérification partielle. Les copies signalées peuvent
|
||||
être reprises dans **Découper à gauche**, **Détecter les labels**, puis ici, avec
|
||||
**Copies signalées uniquement**. Le signalement de vérification est effacé
|
||||
uniquement lorsque toutes les images de la copie sont validées et le résultat
|
||||
complet enregistré. Fermer la fenêtre ou interrompre la reprise conserve le
|
||||
signalement. Une copie peut porter les deux signalements indépendamment.
|
||||
|
||||
### Découper les réponses par question
|
||||
|
||||
**Effet :** crée un document de réponse pour chaque question repérée
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import CliError, EvaluationWorkspace, atomic_write_json, read_json
|
||||
from copienator.annotation_modifications import (
|
||||
APPLIED,
|
||||
BASELINE,
|
||||
DRAFT,
|
||||
body_image,
|
||||
read_instructions,
|
||||
stack,
|
||||
stage_baseline,
|
||||
)
|
||||
from copienator.commands import modify_annotations
|
||||
from copienator.commands import reading_annotations as standard
|
||||
from copienator.commands import reading_grouped_annotations as grouped
|
||||
from copienator.filesystem import staged_files
|
||||
from copienator.return_answers import publish_answer_returns
|
||||
|
||||
|
||||
class ModificationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
(self.root / "Copies" / "Copie43").mkdir(parents=True)
|
||||
(self.root / "labels").write_text("Ex 1\nEx 2\nEx 3\nEmpty\n")
|
||||
self.workspace = EvaluationWorkspace(self.root)
|
||||
self.mode = "Bnot"
|
||||
self.images = {
|
||||
"Ex 1": stack([Image.new("RGB", (100, 20), "red"),
|
||||
Image.new("RGB", (100, 40), "blue"),
|
||||
Image.new("RGB", (100, 60), "green")]),
|
||||
"Ex 2": stack([Image.new("RGB", (120, 30), "yellow"),
|
||||
Image.new("RGB", (120, 50), "purple")]),
|
||||
"Ex 3": stack([Image.new("RGB", (100, 10), "orange"),
|
||||
Image.new("RGB", (100, 30), "cyan")]),
|
||||
}
|
||||
self.heights = {"Ex 1": 20, "Ex 2": 30, "Ex 3": 10}
|
||||
self.scores = {"Ex 1": "3", "Ex 2": "4", "Ex 3": "4", "Empty": ""}
|
||||
self.info = {label: {"present": label != "Empty", "not_empty": label != "Empty",
|
||||
"touched": False, "score": score}
|
||||
for label, score in self.scores.items()}
|
||||
self.seed()
|
||||
|
||||
@property
|
||||
def output(self):
|
||||
return self.root / self.mode / "Copie43"
|
||||
|
||||
def seed(self):
|
||||
self.output.mkdir(parents=True, exist_ok=True)
|
||||
with staged_files(self.output) as staging:
|
||||
for label, image in self.images.items():
|
||||
image.save(staging / f"{label}.jpg")
|
||||
atomic_write_json(staging / "score.json", self.scores)
|
||||
atomic_write_json(staging / "info.json", self.info)
|
||||
stage_baseline(self.root, self.mode, self.output, staging, self.images,
|
||||
self.heights, self.info, ["Ex 1"], self.scores)
|
||||
|
||||
def apply(self, *lines):
|
||||
(self.root / DRAFT).write_text(f"### Source: {self.mode}\n" + "\n".join(lines))
|
||||
return modify_annotations.run(self.workspace, annotation_dir=self.mode)
|
||||
|
||||
def image(self, label):
|
||||
with Image.open(self.output / f"{label}.jpg") as image:
|
||||
return image.convert("RGB").copy()
|
||||
|
||||
def assert_color(self, image, xy, expected):
|
||||
expected = Image.new("RGB", (1, 1), expected).getpixel((0, 0))
|
||||
self.assertTrue(all(abs(a-b) < 12 for a, b in zip(image.getpixel(xy), expected)),
|
||||
(image.getpixel(xy), expected))
|
||||
|
||||
def test_cut_uses_body_height_preserves_own_headers_and_scores(self):
|
||||
self.apply("Copie43 Ex 1 c{40}2> |Ex 2")
|
||||
source, target = self.image("Ex 1"), self.image("Ex 2")
|
||||
self.assertEqual(source.size, (100, 80))
|
||||
self.assertEqual(target.size, (120, 120))
|
||||
self.assert_color(source, (50, 5), "red")
|
||||
self.assert_color(source, (50, 45), "green")
|
||||
self.assert_color(target, (50, 10), "yellow")
|
||||
self.assert_color(target, (50, 45), "blue")
|
||||
self.assert_color(target, (50, 90), "purple")
|
||||
self.assertEqual(read_json(self.output / "score.json"), self.scores)
|
||||
self.assertEqual(body_image(self.output, "Ex 1").size, (100, 100))
|
||||
|
||||
def test_repeated_application_edit_and_removal_rebuild_from_baseline(self):
|
||||
line = "Copie43 Ex 1 c{40}1> Ex 2|"
|
||||
self.apply(line)
|
||||
first = {p.name: p.read_bytes() for p in self.output.glob("*.jpg")}
|
||||
self.apply(line)
|
||||
self.assertEqual(first, {p.name: p.read_bytes() for p in self.output.glob("*.jpg")})
|
||||
self.apply("Copie43 Ex 1 c{60}1> Ex 2|")
|
||||
self.assertEqual(self.image("Ex 2").height, 120)
|
||||
self.apply()
|
||||
self.assertEqual(self.image("Ex 1").size, self.images["Ex 1"].size)
|
||||
self.assertEqual(self.image("Ex 2").size, self.images["Ex 2"].size)
|
||||
self.assertEqual(read_json(self.output / "info.json"), self.info)
|
||||
|
||||
def test_empty_target_order_multiple_sources_and_header_creation(self):
|
||||
self.apply("Copie43 Ex 1 c{40}1> Empty|", "Copie43 Ex 3 -> |Empty")
|
||||
image = self.image("Empty")
|
||||
self.assertEqual(image.height, 80 + 60 + 30)
|
||||
self.assert_color(image, (50, 90), "cyan")
|
||||
self.assert_color(image, (50, 140), "green")
|
||||
info = read_json(self.output / "info.json")["Empty"]
|
||||
self.assertTrue(info["present"] and info["not_empty"])
|
||||
self.assertEqual(info["score"], "")
|
||||
self.apply()
|
||||
self.assertFalse((self.output / "Empty.jpg").exists())
|
||||
self.assertFalse(read_json(self.output / "info.json")["Empty"]["not_empty"])
|
||||
|
||||
def test_invalid_file_does_not_apply_valid_first_instruction(self):
|
||||
before = {p.name: p.read_bytes() for p in self.output.iterdir() if p.is_file()}
|
||||
with self.assertRaises(CliError):
|
||||
self.apply("Copie43 Ex 1 -> Ex 2|", "Copie43 Ex 3 -> Unknown|")
|
||||
self.assertEqual(before, {p.name: p.read_bytes() for p in self.output.iterdir() if p.is_file()})
|
||||
self.assertFalse((self.output.parent / APPLIED).exists())
|
||||
with self.assertRaises(CliError):
|
||||
self.apply("Copie43 Ex 1 -> Ex 2|", "Copie43 Ex 2 -> Ex 3|")
|
||||
with self.assertRaises(CliError):
|
||||
self.apply("Copie43 Ex 1 -> Ex 2|", "Copie43 Ex 1 -> Ex 3|")
|
||||
with self.assertRaises(CliError):
|
||||
read_instructions("### Source: BGnot\n", "Bnot")
|
||||
|
||||
def test_missing_baseline_requires_rereading_not_guessing_header(self):
|
||||
(self.output / BASELINE).unlink()
|
||||
with self.assertRaisesRegex(CliError, "Relancez"):
|
||||
self.apply("Copie43 Ex 1 -> Ex 2|")
|
||||
|
||||
def test_grouped_supplements_use_destination_label_and_force_inclusion(self):
|
||||
self.mode = "BGnot"
|
||||
self.seed()
|
||||
extras = Image.new("RGB", (120, 12), "black")
|
||||
with patch.object(grouped, "get_extra_pdfs_as_images", return_value=[extras]) as get_extra, \
|
||||
patch.object(grouped, "save_paginated_pdf") as save:
|
||||
self.apply("Copie43 Ex 1 c{40}2> Ex 2|")
|
||||
self.assertEqual([c.args[1] for c in get_extra.call_args_list], ["Ex 1", "Ex 2"])
|
||||
self.assertIs(get_extra.call_args_list[0].kwargs["seen_contexts"],
|
||||
get_extra.call_args_list[1].kwargs["seen_contexts"])
|
||||
groups = save.call_args.args[0]
|
||||
self.assertEqual([group[0].height for group in groups], [12, 12])
|
||||
self.assertEqual(groups[1][1].height, 120)
|
||||
self.assertEqual(self.image("Ex 2").height, 120) # supplement never in answer
|
||||
self.assertTrue(read_json(self.output / "info.json")["Ex 2"]["touched"])
|
||||
|
||||
def test_rereading_replays_applied_not_draft_and_refreshes_baseline(self):
|
||||
self.apply("Copie43 Ex 1 c{40}1> Ex 2|")
|
||||
(self.root / DRAFT).write_text("broken unapplied draft")
|
||||
self.images["Ex 1"] = stack([Image.new("RGB", (100, 20), "red"),
|
||||
Image.new("RGB", (100, 100), "cyan")])
|
||||
self.seed()
|
||||
self.assertEqual(self.image("Ex 2").height, 140)
|
||||
self.assert_color(self.image("Ex 2"), (50, 110), "cyan")
|
||||
self.assertEqual(body_image(self.output, "Ex 1").getpixel((20, 20)), (0, 255, 255))
|
||||
|
||||
def test_transaction_rolls_back_all_copies_and_applied_file(self):
|
||||
import shutil
|
||||
(self.root / "Copies/Copie44").mkdir()
|
||||
second = self.output.parent / "Copie44"
|
||||
shutil.copytree(self.output, second)
|
||||
self.apply("Copie43 Ex 1 -> Ex 2|")
|
||||
before = {str(p.relative_to(self.output.parent)): p.read_bytes()
|
||||
for p in self.output.parent.rglob("*") if p.is_file()}
|
||||
original = Path.replace
|
||||
def fail_once(path, target):
|
||||
if path.parent.name == "Copie44" and path.name == "Concat.jpg" and ".annotation-edits-" in str(path):
|
||||
raise OSError("simulated publish failure")
|
||||
return original(path, target)
|
||||
with patch.object(Path, "replace", fail_once), self.assertRaises(OSError):
|
||||
self.apply("Copie43 Ex 1 c{40}1> Ex 2|", "Copie44 Ex 1 -> Ex 2|")
|
||||
after = {str(p.relative_to(self.output.parent)): p.read_bytes()
|
||||
for p in self.output.parent.rglob("*") if p.is_file()}
|
||||
self.assertEqual(before, after)
|
||||
|
||||
def test_individual_returns_contain_modified_body_with_original_target_header(self):
|
||||
from copienator import configuration
|
||||
self.apply("Copie43 Ex 1 c{40}2> |Ex 2")
|
||||
destination = self.root / "A Rendre/Student (43)"
|
||||
with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True,
|
||||
RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=False,
|
||||
RETURN_ANSWERS_SOLUTION=False):
|
||||
publish_answer_returns(self.root, self.output, destination)
|
||||
with Image.open(destination / "answers/Ex 2.jpg") as image:
|
||||
self.assertEqual(image.size, (120, 120))
|
||||
self.assert_color(image, (50, 10), "yellow")
|
||||
self.assert_color(image, (50, 45), "blue")
|
||||
|
||||
def test_empty_everything_clears_old_combined_outputs(self):
|
||||
self.apply("Copie43 Ex 1 xs Empty|", "Copie43 Ex 2 xs Empty|", "Copie43 Ex 3 xs Empty|")
|
||||
self.assertFalse((self.output / "Concat.jpg").exists())
|
||||
self.assertFalse((self.output / "Concat_F.jpg").exists())
|
||||
self.assertFalse(any(entry["not_empty"] for entry in read_json(self.output / "info.json").values()))
|
||||
|
||||
def test_both_readers_capture_reviewed_header_and_handwriting_separately(self):
|
||||
from copy import deepcopy
|
||||
|
||||
from copienator.commands import annotating
|
||||
for mode in ("BGnot", "Bnot"):
|
||||
with self.subTest(mode=mode):
|
||||
self.mode = mode
|
||||
self.seed()
|
||||
answer = self.root / "answer.pdf"
|
||||
answer.touch()
|
||||
data = {"43": {"Ex 1": {"pdf_path": answer, "coordinates": (0, 0),
|
||||
"result": {"score": 3, "feedback": [{"text": "delete me"}, {"text": "keep me"}]}}}}
|
||||
notes = Image.new("RGBA", (100, 110), (0, 0, 0, 0))
|
||||
notes.paste((255, 0, 255, 255), (20, 40, 40, 60)) # body ink, old header = 10
|
||||
notes.paste((0, 0, 0, 255), (20, 2, 40, 7)) # header ink stays there
|
||||
actions = [{"label": "Ex 1", "type": "del_global", "index": 0}]
|
||||
atomic_write_json(self.output / "bnote.json", {"images": [
|
||||
{"label": "Ex 1", "hmin": 0, "hmax": 110, "header_height": 10}]})
|
||||
def compose(_base, _label, result, *_args, **_kwargs):
|
||||
self.assertTrue(result["feedback"][0]["to_delete"])
|
||||
self.assertNotIn("to_delete", result["feedback"][1])
|
||||
return self.images["Ex 1"].copy(), 20
|
||||
with patch.object(annotating, "make_base_image", return_value=(None, 0, 0)), \
|
||||
patch.object(annotating, "compose_label_image", side_effect=compose), \
|
||||
patch.object(grouped, "get_extra_pdfs_as_images", return_value=[]):
|
||||
if mode == "Bnot":
|
||||
status = standard.apply_actions_and_regenerate(
|
||||
self.workspace, deepcopy(data), "43", actions, notes, list(self.scores))
|
||||
else:
|
||||
status, _ = grouped.apply_actions_and_regenerate_grouped(
|
||||
self.workspace, deepcopy(data), "43", actions,
|
||||
{"Ex 1": {"img": notes, "old_header_h": 10}}, list(self.scores))
|
||||
self.assertEqual(status, 0)
|
||||
body = body_image(self.output, "Ex 1")
|
||||
self.assertEqual(body.size, (100, 100))
|
||||
self.assertEqual(body.getpixel((25, 35)), (255, 0, 255))
|
||||
self.assertEqual(body.getpixel((25, 5)), (0, 0, 255))
|
||||
self.apply("Copie43 Ex 1 c{40}2> Empty|")
|
||||
self.assert_color(self.image("Empty"), (25, 115), "magenta")
|
||||
self.assert_color(self.image("Ex 1"), (25, 4), "black")
|
||||
|
||||
def test_selective_redo_does_not_bake_in_or_duplicate_previous_transfers(self):
|
||||
from copy import deepcopy
|
||||
|
||||
from copienator.commands import annotating
|
||||
self.mode = "BGnot"
|
||||
self.seed()
|
||||
self.apply("Copie43 Ex 1 x> Ex 2|")
|
||||
self.assertFalse((self.output / "Ex 1.jpg").exists())
|
||||
before = self.image("Ex 2").size
|
||||
answer = self.root / "answer.pdf"
|
||||
answer.touch()
|
||||
data = {"43": {label: {"result": {"score": self.scores[label], "feedback": []},
|
||||
"pdf_path": answer, "coordinates": (0, 0)}
|
||||
for label in self.images}}
|
||||
with patch.object(annotating, "make_base_image", return_value=(None, 0, 0)), \
|
||||
patch.object(annotating, "compose_label_image", return_value=(self.images["Ex 3"], 10)), \
|
||||
patch.object(grouped, "get_extra_pdfs_as_images", return_value=[]):
|
||||
for _ in range(2):
|
||||
status, _ = grouped.apply_actions_and_regenerate_grouped(
|
||||
self.workspace, deepcopy(data), "43", [], {}, list(self.scores),
|
||||
annotation_dir="BGnot", selected_labels={"Ex 3"})
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(self.image("Ex 2").size, before)
|
||||
self.assertFalse((self.output / "Ex 1.jpg").exists())
|
||||
self.assertEqual(body_image(self.output, "Ex 1").size, (100, 100))
|
||||
@@ -0,0 +1,79 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import atomic_write_json, read_json
|
||||
from copienator.commands import annotating_by_label as grouped
|
||||
from copienator.utils import natural_key
|
||||
|
||||
|
||||
class GroupedNamingTests(unittest.TestCase):
|
||||
def test_dsi_question_groups_have_short_unique_ordered_prefixes(self):
|
||||
groups = [
|
||||
["Ex 1", "Ex 2", "Ex 3"],
|
||||
["Ex 4 : 1)", "Ex 4 : 2)"],
|
||||
["I)1)", "I)2)"],
|
||||
["I)3)", "I)4)"],
|
||||
["I)5)", "I)6)", "I)7)"],
|
||||
["I)8)", "I)9)"],
|
||||
["I)10)", "I)11)", "I)12)"],
|
||||
["I)13)"],
|
||||
["I)14)", "I)15)", "I)16)"],
|
||||
["II)1)", "II)2)"],
|
||||
["II)3)a)", "II)3)b)"],
|
||||
["II)4)", "II)5)", "II)6)", "II)7)"],
|
||||
["II)8)", "II)9)"],
|
||||
["II)10)"],
|
||||
]
|
||||
named = grouped._unique_prefixes(groups)
|
||||
prefixes = [prefix for prefix, _labels in named]
|
||||
self.assertEqual(len(prefixes), len(set(prefixes)))
|
||||
self.assertEqual(prefixes, sorted(prefixes, key=natural_key))
|
||||
self.assertEqual(prefixes, [
|
||||
"Ex", "Ex-2", "I)", "I)-2", "I)-3", "I)-4", "I)-5",
|
||||
"I)-6", "I)-7", "II)", "II)-2", "II)-3", "II)-4", "II)-5",
|
||||
])
|
||||
self.assertEqual([labels for _prefix, labels in named], groups)
|
||||
|
||||
def test_resume_preserves_legacy_names_and_appends_only_missing_answers(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary)
|
||||
# The old naming bug put distinct question groups under I)-2.
|
||||
for number, label in enumerate(["I)3)", "I)10)", "II)1)"], start=1):
|
||||
folder = output / f"I)-2 G{number}"
|
||||
folder.mkdir()
|
||||
atomic_write_json(folder / "bnote.json", {
|
||||
"images": [{"id": "01", "label": label}],
|
||||
})
|
||||
(folder / "Concat.pdf").write_bytes(b"reviewed PDF")
|
||||
before = {p: p.read_bytes() for p in output.rglob("*") if p.is_file()}
|
||||
groups = [["I)1)", "I)2)"], ["I)3)", "I)4)"],
|
||||
["I)10)", "I)11)"], ["II)1)", "II)2)"]]
|
||||
data = {"01": {label: {} for label in ["I)3)", "I)10)", "II)1)"]}}
|
||||
with patch.object(grouped, "render_item") as render:
|
||||
self.assertEqual(grouped._generate_groups(
|
||||
output, data, groups, resume=True,
|
||||
), (0, False))
|
||||
render.assert_not_called()
|
||||
self.assertEqual(before, {
|
||||
p: p.read_bytes() for p in output.rglob("*") if p.is_file()
|
||||
})
|
||||
|
||||
data["02"] = {"I)3)": {}}
|
||||
with patch.object(grouped, "render_item", return_value=(
|
||||
"02", "I)3)", Image.new("RGB", (100, 100), "white"), 0, [],
|
||||
)) as render:
|
||||
self.assertEqual(grouped._generate_groups(
|
||||
output, data, groups, resume=True,
|
||||
), (1, False))
|
||||
render.assert_called_once_with(("02", "I)3)", {}))
|
||||
self.assertEqual(read_json(output / "I)-2 G4/bnote.json")["images"][0]["id"], "02")
|
||||
for path, content in before.items():
|
||||
self.assertEqual(path.read_bytes(), content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import atomic_write_json
|
||||
from copienator.annotation_modifications import DRAFT, stage_baseline
|
||||
from copienator.filesystem import staged_files
|
||||
from copienator_gui.app import CopienatorApp
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk tests require a display")
|
||||
class AnnotationModificationGuiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
(self.root / "Copies/Copie43").mkdir(parents=True)
|
||||
(self.root / "labels").write_text("Ex 1\nEx 2\n")
|
||||
self.output = self.root / "Bnot/Copie43"
|
||||
images = {"Ex 1": Image.new("RGB", (100, 120), "blue")}
|
||||
scores = {"Ex 1": "3", "Ex 2": ""}
|
||||
info = {label: {"present": label == "Ex 1", "not_empty": label == "Ex 1",
|
||||
"touched": False, "score": score} for label, score in scores.items()}
|
||||
with staged_files(self.output) as staging:
|
||||
images["Ex 1"].save(staging / "Ex 1.jpg")
|
||||
atomic_write_json(staging / "score.json", scores)
|
||||
atomic_write_json(staging / "info.json", info)
|
||||
stage_baseline(self.root, "Bnot", self.output, staging, images, {"Ex 1": 20}, info, ["Ex 1"], scores)
|
||||
self.app = CopienatorApp(Path.cwd(), False, self.root)
|
||||
self.app.state_store.update_step("read_annotations", last_run_variant="standard")
|
||||
self.app.tree.selection_set("modify_annotations")
|
||||
self.app.update()
|
||||
self.panel = self.app.manual_panel
|
||||
|
||||
def tearDown(self):
|
||||
for callback in self.app.tk.splitlist(self.app.tk.call("after", "info")):
|
||||
self.app.after_cancel(callback)
|
||||
self.app.destroy()
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_add_validate_edit_reload_and_cut_only_body(self):
|
||||
from copienator.annotation_modifications import read_instructions
|
||||
self.assertEqual(self.app.arg_vars["annotation_dir"].get(), "Bnot")
|
||||
self.assertEqual(self.app.run_button.cget("text"), "Make modifications")
|
||||
self.panel.copy_var.set("43")
|
||||
self.panel.source_var.set("Ex 1")
|
||||
self.panel.target_var.set("Ex 2")
|
||||
self.panel.add_instruction()
|
||||
path = self.root / DRAFT
|
||||
self.assertIn("Copie43 Ex 1 -> Ex 2|", path.read_text())
|
||||
item = read_instructions(path.read_text(), "Bnot")[0]
|
||||
with patch("copienator_gui.annotation_modifications.CutHelper") as helper:
|
||||
self.panel.open_cut(self.root, item)
|
||||
self.assertEqual(helper.call_args.args[1].size, (100, 100))
|
||||
helper.call_args.args[2]("c{40}2>")
|
||||
self.assertEqual(self.panel.cut_result.get(), "Copie43 Ex 1 c{40}2> Ex 2|")
|
||||
with patch("copienator_gui.annotation_modifications.messagebox.showerror") as error:
|
||||
self.panel.add_instruction() # duplicate source
|
||||
error.assert_called_once()
|
||||
path.write_text("bad instruction")
|
||||
self.panel.reload()
|
||||
with patch("copienator_gui.annotation_modifications.open_path") as editor:
|
||||
self.panel.open_editor() # malformed files must remain editable
|
||||
editor.assert_called_once_with(path)
|
||||
|
||||
def test_workflow_is_between_reader_and_publisher_and_builds_command(self):
|
||||
steps = list(self.app.step_by_id)
|
||||
self.assertLess(steps.index("read_annotations"), steps.index("modify_annotations"))
|
||||
self.assertLess(steps.index("modify_annotations"), steps.index("giving_names"))
|
||||
command = self.app._make_command()
|
||||
self.assertIn("modify-annotations", command)
|
||||
self.assertEqual(command[-2:], ["--annotation-dir", "Bnot"])
|
||||
@@ -976,6 +976,9 @@ class StandardCliTests(unittest.TestCase):
|
||||
(evaluation / "Copies").mkdir(parents=True)
|
||||
viewer = module.ImageViewer.__new__(module.ImageViewer)
|
||||
viewer.base_dir = evaluation
|
||||
viewer.workspace = EvaluationWorkspace(evaluation)
|
||||
viewer.history = [(None, None, {"part": 1, "schema": {"columns_per_file": [1]}},
|
||||
"Copie01", None)]
|
||||
viewer.active_copie_name = "Copie01"
|
||||
viewer.accumulated_results = {
|
||||
"name": "Test",
|
||||
@@ -1121,6 +1124,7 @@ class StandardCliTests(unittest.TestCase):
|
||||
|
||||
root.bind.assert_any_call("p", viewer.on_previous)
|
||||
root.bind.assert_any_call("P", viewer.on_previous)
|
||||
root.bind.assert_any_call("!", viewer.on_skip)
|
||||
|
||||
def test_plotting_validates_plain_and_directional_labels(self) -> None:
|
||||
module = self.modules["plotting"]
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import os
|
||||
import tempfile
|
||||
import tkinter as tk
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, read_json
|
||||
from copienator.commands import cutleft, gemini_for_labels, plotting
|
||||
from copienator.copy_errors import clear_copy_error, copy_errors, mark_copy_error, marked_copy_paths
|
||||
|
||||
|
||||
class LabelCopyFlagTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.workspace = EvaluationWorkspace(Path(self.temp.name))
|
||||
self.workspace.copies_dir.mkdir()
|
||||
self.workspace.cutleft_dir.mkdir()
|
||||
self.pdf = self.workspace.copies_dir / 'Copie01.pdf'
|
||||
self.pdf.touch()
|
||||
|
||||
def flag(self, source):
|
||||
mark_copy_error(self.workspace, self.pdf, source, source=source)
|
||||
|
||||
def viewer(self, parts=2):
|
||||
viewer = plotting.ImageViewer.__new__(plotting.ImageViewer)
|
||||
viewer.workspace = self.workspace
|
||||
viewer.base_dir = self.workspace.root
|
||||
viewer.active_copie_name = 'Copie01'
|
||||
viewer.accumulated_results = {'name': 'Student', 'list': []}
|
||||
viewer.current_meta = {'copie': 'Copie01', 'part': 1,
|
||||
'schema': {'columns_per_file': [1] * parts}}
|
||||
viewer.current_json_path = self.pdf.with_name('Copie01_01.json')
|
||||
atomic_write_json(viewer.current_json_path, {'name': 'Student', 'list': []})
|
||||
viewer.valid_labels = set()
|
||||
viewer.history = []
|
||||
viewer.forward_stack = []
|
||||
viewer.skipped_copies = set()
|
||||
viewer.current_pil_image = Image.new('RGB', (20, 20), 'white')
|
||||
viewer.is_viewing = True
|
||||
viewer.failed = False
|
||||
viewer.label = Mock()
|
||||
viewer.root = Mock()
|
||||
viewer.image_queue = Queue()
|
||||
return viewer
|
||||
|
||||
def test_legacy_flags_migrate_and_steps_clear_independently(self):
|
||||
atomic_write_json(self.workspace.metadata_dir / 'copy_errors.json',
|
||||
{self.pdf.name: 'Legacy cropping problem'})
|
||||
self.flag('plotting')
|
||||
clear_copy_error(self.workspace, self.pdf)
|
||||
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'plotting'})
|
||||
self.flag('cutleft')
|
||||
clear_copy_error(self.workspace, self.pdf, source='plotting')
|
||||
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'cutleft'})
|
||||
clear_copy_error(self.workspace, self.pdf)
|
||||
self.assertEqual(marked_copy_paths(self.workspace), [])
|
||||
|
||||
def test_successful_crop_preserves_review_flag_and_selection(self):
|
||||
self.flag('cutleft')
|
||||
self.flag('plotting')
|
||||
image = Image.new('RGB', (20, 20), 'white')
|
||||
with patch.object(cutleft, 'process_single_pdf', return_value=(image, [image], {})), \
|
||||
patch.object(cutleft, 'save_results'):
|
||||
self.assertEqual(cutleft.run(self.workspace, self.workspace.root, marked=True,
|
||||
fullpage=True, no_visual_check=True), ExitCode.SUCCESS)
|
||||
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'plotting'})
|
||||
self.assertEqual(marked_copy_paths(self.workspace), [self.pdf])
|
||||
|
||||
def test_full_review_clears_only_review_flag_after_save(self):
|
||||
self.flag('cutleft')
|
||||
self.flag('plotting')
|
||||
viewer = self.viewer()
|
||||
viewer.on_enter(None)
|
||||
viewer.current_meta = dict(viewer.current_meta, part=2)
|
||||
viewer.is_viewing = True
|
||||
viewer.on_enter(None)
|
||||
with patch.object(plotting, 'atomic_write_json', side_effect=OSError('disk full')):
|
||||
with self.assertRaises(OSError):
|
||||
viewer.save_current_batch()
|
||||
self.assertIn('plotting', copy_errors(self.workspace)[self.pdf.name])
|
||||
viewer.save_current_batch()
|
||||
self.assertEqual(read_json(self.pdf.with_suffix('.json')), {'name': 'Student', 'list': []})
|
||||
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'cutleft'})
|
||||
|
||||
def test_incomplete_review_preserves_previous_aggregate_and_flag(self):
|
||||
self.flag('plotting')
|
||||
old = {'name': 'Old', 'list': [{'label': 'Ex 1'}]}
|
||||
atomic_write_json(self.pdf.with_suffix('.json'), old)
|
||||
viewer = self.viewer()
|
||||
viewer.on_enter(None)
|
||||
viewer.save_current_batch()
|
||||
self.assertEqual(read_json(self.pdf.with_suffix('.json')), old)
|
||||
self.assertIn(self.pdf.name, copy_errors(self.workspace))
|
||||
self.assertTrue(viewer.failed)
|
||||
|
||||
def test_flag_skips_remaining_copy_parts_without_saving_partial_data(self):
|
||||
self.flag('cutleft')
|
||||
old = {'name': 'Old', 'list': []}
|
||||
atomic_write_json(self.pdf.with_suffix('.json'), old)
|
||||
viewer = self.viewer(parts=3)
|
||||
viewer.on_enter(None)
|
||||
viewer.is_viewing = True
|
||||
viewer.current_meta = dict(viewer.current_meta, part=2)
|
||||
viewer.on_skip()
|
||||
viewer.save_current_batch()
|
||||
self.assertEqual(read_json(self.pdf.with_suffix('.json')), old)
|
||||
self.assertEqual(set(read_json(self.workspace.metadata_dir / 'copy_errors.json')[self.pdf.name]),
|
||||
{'cutleft', 'plotting'})
|
||||
viewer.image_queue.put((viewer.current_pil_image, viewer.current_json_path,
|
||||
dict(viewer.current_meta, part=3)))
|
||||
viewer.display_image = Mock()
|
||||
viewer.poll_queue()
|
||||
viewer.display_image.assert_not_called()
|
||||
self.assertFalse(viewer.is_viewing)
|
||||
|
||||
def test_detection_regenerates_only_flagged_copies_and_preserves_flags(self):
|
||||
self.flag('plotting')
|
||||
(self.workspace.copies_dir / 'Copie02.pdf').touch()
|
||||
for name in ('Copie01_01', 'Copie02_01'):
|
||||
(self.workspace.cutleft_dir / f'{name}.jpg').touch()
|
||||
atomic_write_json(self.workspace.copies_dir / f'{name}.json', {'name': 'Old', 'list': []})
|
||||
with patch.object(self.workspace.__class__, 'require_files'), \
|
||||
patch.object(gemini_for_labels, 'read_all_labels', return_value=[]), \
|
||||
patch.object(self.workspace.__class__, 'names_file', return_value=self.pdf), \
|
||||
patch.object(gemini_for_labels, 'process_copy_group', return_value=1) as process:
|
||||
status = gemini_for_labels.run(self.workspace, [self.workspace.root], marked=True, client=Mock())
|
||||
self.assertEqual(status, ExitCode.SUCCESS)
|
||||
process.assert_called_once()
|
||||
self.assertEqual(process.call_args.args[1], 'Copie01')
|
||||
self.assertTrue(process.call_args.kwargs['overwrite'])
|
||||
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'plotting'})
|
||||
|
||||
def test_visual_review_selects_only_flagged_copy_images(self):
|
||||
self.flag('plotting')
|
||||
for name in ('Copie01_01', 'Copie01_02', 'Copie02_01'):
|
||||
(self.workspace.cutleft_dir / f'{name}.jpg').touch()
|
||||
with patch.object(self.workspace.__class__, 'require_files'), \
|
||||
patch.object(plotting, 'read_all_labels', return_value=[]), \
|
||||
patch.object(plotting.threading, 'Thread') as worker, \
|
||||
patch.object(plotting.tk, 'Tk'), \
|
||||
patch.object(plotting, 'ImageViewer') as viewer:
|
||||
viewer.return_value.failed = False
|
||||
self.assertEqual(plotting.run(self.workspace, self.workspace.root, marked=True),
|
||||
ExitCode.SUCCESS)
|
||||
selected = worker.call_args.kwargs['args'][1]
|
||||
self.assertEqual([image.name for image in selected], ['Copie01_01.jpg', 'Copie01_02.jpg'])
|
||||
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'plotting'})
|
||||
|
||||
def test_closing_review_preserves_flags_and_does_not_save_partial_result(self):
|
||||
self.flag('plotting')
|
||||
viewer = self.viewer()
|
||||
viewer.on_enter(None)
|
||||
viewer.close()
|
||||
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'plotting'})
|
||||
self.assertFalse(self.pdf.with_suffix('.json').exists())
|
||||
|
||||
@unittest.skipUnless(os.environ.get('DISPLAY'), 'Tk requires a display')
|
||||
def test_exclamation_key_flags_current_copy_in_real_viewer(self):
|
||||
root = tk.Tk()
|
||||
try:
|
||||
image_queue = Queue()
|
||||
image_queue.put((Image.new('RGB', (100, 100), 'white'),
|
||||
self.pdf.with_name('Copie01_01.json'),
|
||||
{'copie': 'Copie01', 'part': 1, 'name': 'Student',
|
||||
'schema': {'columns_per_file': [1, 1]}}))
|
||||
viewer = plotting.ImageViewer(root, self.workspace, set(), image_queue)
|
||||
root.update()
|
||||
root.focus_force()
|
||||
root.update()
|
||||
root.event_generate('<KeyPress>', keysym='exclam')
|
||||
root.update()
|
||||
self.assertEqual(viewer.skipped_copies, {'Copie01'})
|
||||
self.assertFalse(viewer.is_viewing)
|
||||
self.assertIn(self.pdf.name, copy_errors(self.workspace))
|
||||
finally:
|
||||
for callback in root.tk.splitlist(root.tk.call('after', 'info')):
|
||||
root.after_cancel(callback)
|
||||
root.destroy()
|
||||
|
||||
def test_empty_marked_selection_does_not_process_whole_evaluation(self):
|
||||
with patch.object(self.workspace.__class__, 'require_files'), \
|
||||
patch.object(gemini_for_labels, 'selected_images') as select:
|
||||
self.assertEqual(gemini_for_labels.run(self.workspace, [self.workspace.root], marked=True),
|
||||
ExitCode.SUCCESS)
|
||||
select.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -43,7 +43,7 @@ class LabelTimeoutTests(unittest.TestCase):
|
||||
self.assertEqual(labels.main([str(self.workspace.root), *arguments]), 0)
|
||||
run.assert_called_once_with(
|
||||
unittest.mock.ANY, [self.workspace.root],
|
||||
overwrite=False, max_workers=expected,
|
||||
overwrite=False, marked=False, max_workers=expected,
|
||||
)
|
||||
|
||||
def test_selected_concurrency_controls_executor(self):
|
||||
|
||||
@@ -233,12 +233,13 @@ class MarkedCopiesGuiTests(unittest.TestCase):
|
||||
self.assertEqual(copy_errors(self.workspace), {})
|
||||
self.assertTrue((self.workspace.cutleft_dir / "Copie01_01.jpg").exists())
|
||||
|
||||
def test_both_buttons_run_marked_and_single_copy_target_clears_filter(self):
|
||||
def test_all_marked_buttons_and_single_copy_target_clears_filter(self):
|
||||
mark_copy_error(self.workspace, self.files[1], "Review needed")
|
||||
app = CopienatorApp(Path.cwd(), False, self.workspace.root)
|
||||
try:
|
||||
app.update()
|
||||
for ident, command in (("page_splitter", "page-split"), ("cutleft", "crop-labels")):
|
||||
for ident, command in (("page_splitter", "page-split"), ("cutleft", "crop-labels"),
|
||||
("labels", "labels"), ("plotting", "review-labels")):
|
||||
app.tree.selection_set(ident)
|
||||
app.update()
|
||||
self.assertIn("(1)", app.marked_copies_button.cget("text"))
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from copienator.commands.reading_grouped_annotations import (
|
||||
context_content_key, get_extra_pdfs_as_images, save_paginated_pdf,
|
||||
)
|
||||
|
||||
|
||||
class PdfSupplementTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
(self.root / "Text2").mkdir()
|
||||
(self.root / "Sol2").mkdir()
|
||||
self.labels = ["Q1", "Q2", "Q3"]
|
||||
self.rendered = []
|
||||
|
||||
def render(path):
|
||||
self.rendered.append(Path(path).name)
|
||||
return Image.new("RGB", (100, 30), "white"), 30, 100
|
||||
|
||||
self.renderer = SimpleNamespace(make_base_image=render)
|
||||
|
||||
def pdf(self, folder, name, text):
|
||||
path = self.root / folder / name
|
||||
document = canvas.Canvas(str(path))
|
||||
document.drawString(20, 800, text)
|
||||
document.save()
|
||||
return path
|
||||
|
||||
def extras(self, label, seen):
|
||||
return get_extra_pdfs_as_images(
|
||||
self.root, label, self.renderer, self.labels, seen_contexts=seen,
|
||||
)
|
||||
|
||||
def test_content_deduplication_across_ranges_and_copy_reset(self):
|
||||
self.pdf("Text2", "CTXT Q1 -> Q2.pdf", "Bob collects coupons: N >= 1")
|
||||
self.pdf("Text2", "CTXT Q2 -> Q3.pdf", "Bob collects coupons: N >= 1")
|
||||
self.pdf("Text2", "CTXT Q2 -> Q2.pdf", "Bob collects coupons: N >= 2")
|
||||
seen = set()
|
||||
self.assertEqual(len(self.extras("Q1", seen)), 1)
|
||||
self.assertEqual(len(self.extras("Q2", seen)), 1)
|
||||
self.assertEqual(len(self.extras("Q3", seen)), 0)
|
||||
self.assertEqual(len(seen), 2)
|
||||
self.assertEqual(len(self.extras("Q3", set())), 1)
|
||||
|
||||
def test_first_retained_question_gets_context_and_semantic_borders(self):
|
||||
self.pdf("Text2", "CTXT Q1 -> Q3.pdf", "Context")
|
||||
self.pdf("Text2", "CTXT Q2 -> Q3.pdf", "Other context")
|
||||
self.pdf("Text2", "Q2.pdf", "Question")
|
||||
self.pdf("Sol2", "Q2.pdf", "Solution")
|
||||
# Q1 was filtered out; it never consumes the shared context.
|
||||
images = self.extras("Q2", set())
|
||||
self.assertEqual([i.getpixel((0, 0)) for i in images],
|
||||
[(255, 255, 255), (255, 255, 255), (0, 0, 0), (0, 0, 255)])
|
||||
self.assertTrue(all(i.getpixel((50, 15)) == (255, 255, 255) for i in images))
|
||||
|
||||
def test_missing_question_does_not_shift_solution_border(self):
|
||||
self.pdf("Text2", "CTXT Q1 -> Q3.pdf", "Context")
|
||||
self.pdf("Sol2", "Q2.pdf", "Solution")
|
||||
images = self.extras("Q2", set())
|
||||
self.assertEqual([i.getpixel((0, 0)) for i in images],
|
||||
[(255, 255, 255), (0, 0, 255)])
|
||||
|
||||
def test_unrendered_context_is_not_consumed(self):
|
||||
self.pdf("Text2", "CTXT Q1 -> Q3.pdf", "Context")
|
||||
seen = set()
|
||||
with patch.object(self.renderer, "make_base_image", return_value=(None, 0, 0)):
|
||||
self.assertEqual(self.extras("Q1", seen), [])
|
||||
self.assertFalse(seen)
|
||||
self.assertEqual(len(self.extras("Q2", seen)), 1)
|
||||
|
||||
def test_missing_solution_does_not_shift_border_to_student_answer(self):
|
||||
self.pdf("Text2", "Q2.pdf", "Question")
|
||||
images = self.extras("Q2", set())
|
||||
answer = Image.new("RGB", (100, 200), "white")
|
||||
captured = []
|
||||
|
||||
def save(page, *args, **kwargs):
|
||||
captured.extend([page.copy(), *kwargs['append_images']])
|
||||
|
||||
with patch.object(Image.Image, "save", save):
|
||||
save_paginated_pdf([images + [answer], [answer]], self.root / "out.pdf")
|
||||
self.assertEqual(len(captured), 2)
|
||||
self.assertEqual(captured[0].getpixel((11, 7)), (0, 0, 0))
|
||||
self.assertEqual(captured[0].getpixel((11, 37)), (255, 255, 255))
|
||||
self.assertEqual(captured[1].getpixel((11, 7)), (255, 255, 255))
|
||||
self.assertEqual(answer.getpixel((0, 0)), (255, 255, 255))
|
||||
|
||||
def test_empty_extracted_text_does_not_merge_distinct_contexts(self):
|
||||
paths = []
|
||||
for name, color in [("a.pdf", "red"), ("b.pdf", "blue")]:
|
||||
path = self.root / name
|
||||
document = canvas.Canvas(str(path))
|
||||
document.setFillColor(color)
|
||||
document.rect(20, 20, 100, 100, fill=1)
|
||||
document.save()
|
||||
paths.append(path)
|
||||
self.assertNotEqual(context_content_key(paths[0]), context_content_key(paths[1]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user