From 13a08f6cbd5337c65afdbd6f194071a5bb8f9594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Miquel?= Date: Tue, 8 Sep 2026 17:00:18 +0200 Subject: [PATCH] fix sorting --- copienator/commands/gemini_for_labels.py | 31 ++++++++++ copienator/commands/plotting.py | 18 +----- tests/test_gui_core.py | 77 ++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 15 deletions(-) diff --git a/copienator/commands/gemini_for_labels.py b/copienator/commands/gemini_for_labels.py index f0d6117..28c44ff 100644 --- a/copienator/commands/gemini_for_labels.py +++ b/copienator/commands/gemini_for_labels.py @@ -340,6 +340,34 @@ def group_images(image_files: list[Path]) -> dict[str, list[Path]]: return dict(groups) +def sort_boxes_for_image( + workspace: EvaluationWorkspace, + image_file: Path, + boxes: list[BoxItem], +) -> list[BoxItem]: + """Sort boxes in the image's page-column reading order when schema exists.""" + match = re.match(r"(.+)_(\d+)$", image_file.stem) + if not match: + return boxes + schema_path = workspace.cutleft_dir / f"{match.group(1)}_schema.json" + try: + schema = read_json(schema_path) + columns_per_file = schema["columns_per_file"] + column_count = int(columns_per_file[int(match.group(2)) - 1]) + if column_count < 1: + return boxes + except (OSError, KeyError, IndexError, TypeError, ValueError): + return boxes + + def position(item: BoxItem) -> tuple[int, int, int]: + ymin, xmin, _ymax, xmax = item.box_2d + center_x = (xmin + xmax) // 2 + column = min(column_count - 1, max(0, center_x * column_count // 1000)) + return column, ymin, xmin + + return sorted(boxes, key=position) + + def _existing_context(output_json: Path) -> list[str]: try: loaded = read_json(output_json) @@ -452,6 +480,9 @@ def process_copy_group( continue annotation.name = "Unknown" + annotation.list = sort_boxes_for_image( + workspace, image_file, annotation.list + ) atomic_write_json(output_json, annotation.model_dump()) accumulated_labels.extend(box.label for box in annotation.list) generated += 1 diff --git a/copienator/commands/plotting.py b/copienator/commands/plotting.py index 129fdf4..1696173 100644 --- a/copienator/commands/plotting.py +++ b/copienator/commands/plotting.py @@ -333,28 +333,16 @@ class ImageViewer: try: current_data = read_json(self.current_json_path) - nb_pages = self.current_meta["schema"]["columns_per_file"][ - self.current_meta["part"] - 1 - ] - original_items = current_data["list"] - ordered_items = sort_bounding_boxes(original_items, nb_pages) - - if ordered_items != original_items: - current_data["list"] = ordered_items - atomic_write_json(self.current_json_path, current_data) - print( - f"Reordered labels by column in " - f"{self.current_json_path.name}." - ) + items = current_data["list"] # Perform the conversion now, post-edit converted_items = convert_list( - ordered_items, + items, self.current_meta["part"], self.current_meta["schema"] ) - labels = normalized_labels(ordered_items) + labels = normalized_labels(items) false_labels = [ label for label in labels if label not in self.valid_labels ] diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index 9cb8861..26fc099 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -910,6 +910,39 @@ class StandardCliTests(unittest.TestCase): ) self.assertIsNone(viewer.accumulated_results) + def test_plotting_validation_preserves_manually_edited_order(self) -> None: + module = self.modules["plotting"] + with tempfile.TemporaryDirectory() as directory: + json_path = Path(directory) / "Copie01_01.json" + manually_ordered = [ + {"box_2d": [100, 600, 130, 700], "label": "Ex 2"}, + {"box_2d": [500, 100, 530, 200], "label": "Ex 1"}, + ] + atomic_write_json( + json_path, + {"name": "Student", "list": manually_ordered}, + ) + viewer = module.ImageViewer.__new__(module.ImageViewer) + viewer.is_viewing = True + viewer.current_json_path = json_path + viewer.current_meta = { + "schema": {"columns_per_file": [2]}, + "part": 1, + } + viewer.valid_labels = {"Ex 1", "Ex 2"} + viewer.accumulated_results = {"name": "Student", "list": []} + viewer.history = [] + viewer.current_pil_image = Mock() + viewer.label = Mock() + + viewer.on_enter(None) + + self.assertEqual(read_json(json_path)["list"], manually_ordered) + self.assertEqual( + [item["label"] for item in viewer.accumulated_results["list"]], + ["Ex 2", "Ex 1"], + ) + def test_plotting_validates_plain_and_directional_labels(self) -> None: module = self.modules["plotting"] self.assertEqual( @@ -1008,6 +1041,50 @@ class StandardCliTests(unittest.TestCase): }, ) + def test_label_detection_writes_boxes_in_column_reading_order(self) -> None: + module = self.modules["gemini_for_labels"] + with tempfile.TemporaryDirectory() as directory: + evaluation = Path(directory) / "Exam" + copies = evaluation / "Copies" + cutleft = evaluation / "Cutleft" + copies.mkdir(parents=True) + cutleft.mkdir() + image = cutleft / "Copie03_02.jpg" + image.write_bytes(b"image") + atomic_write_json( + cutleft / "Copie03_schema.json", + {"columns_per_file": [2, 2]}, + ) + client = Mock() + client.models.generate_content.return_value = Mock( + text=( + '{"name":"Continued","list":[' + '{"box_2d":[100,600,130,700],"label":"Ex 2"},' + '{"box_2d":[500,100,530,200],"label":"Ex 1"},' + '{"box_2d":[300,620,330,720],"label":"Ex 3"}]}' + ) + ) + + module.process_copy_group( + EvaluationWorkspace(evaluation), + "Copie03", + [image], + client=client, + labels_text="Ex 1\nEx 2\nEx 3\n", + names_text="Student\n", + valid_labels={"Ex 1", "Ex 2", "Ex 3"}, + valid_names={"Student", "Unknown", "Continued"}, + overwrite=True, + sleep=lambda _seconds: None, + target_interval=0, + ) + + result = read_json(copies / "Copie03_02.json") + self.assertEqual( + [item["label"] for item in result["list"]], + ["Ex 1", "Ex 2", "Ex 3"], + ) + def test_label_detection_retries_unknown_labels(self) -> None: module = self.modules["gemini_for_labels"] with tempfile.TemporaryDirectory() as directory: