From 17984820e15d104995d8a7bae0b06c717006980f Mon Sep 17 00:00:00 2001 From: Jens Date: Thu, 30 Jul 2026 02:49:57 +0200 Subject: [PATCH] Add fail-closed sample filters to label QA --- ...r_operator_yolo_label_qa_contact_sheets.py | 21 ++++++++++++++- ...r_operator_yolo_label_qa_contact_sheets.py | 26 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tests/test_render_operator_yolo_label_qa_contact_sheets.py diff --git a/scripts/render_operator_yolo_label_qa_contact_sheets.py b/scripts/render_operator_yolo_label_qa_contact_sheets.py index 32ba5d29..b2ba00e2 100644 --- a/scripts/render_operator_yolo_label_qa_contact_sheets.py +++ b/scripts/render_operator_yolo_label_qa_contact_sheets.py @@ -29,6 +29,12 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--summary-path", required=True, help="Path to yolo_tile_dataset_summary.json") parser.add_argument("--output-dir", required=True, help="Directory for JSON, Markdown and PNG artifacts") parser.add_argument("--max-tiles", type=int, default=24, help="Maximum selected tiles to render") + parser.add_argument( + "--sample-slug", + action="append", + default=[], + help="Render only this sample slug; repeat to select multiple samples.", + ) parser.add_argument("--columns", type=int, default=4, help="Contact-sheet columns") parser.add_argument("--thumb-size", type=int, default=256, help="Rendered tile thumbnail size in pixels") parser.add_argument( @@ -48,6 +54,17 @@ def load_json(path: Path) -> dict[str, Any]: return data +def filter_tiles_by_samples(tiles: list[dict[str, Any]], sample_slugs: list[str]) -> list[dict[str, Any]]: + requested = {slug.strip() for slug in sample_slugs if slug.strip()} + if not requested: + return tiles + available = {str(tile.get("sample_slug") or "") for tile in tiles} + missing = sorted(requested - available) + if missing: + raise ValueError(f"Requested sample slugs absent from summary: {', '.join(missing)}") + return [tile for tile in tiles if str(tile.get("sample_slug") or "") in requested] + + def resolve_path(raw_path: str | None, summary_path: Path) -> Path | None: if not raw_path: return None @@ -254,7 +271,8 @@ def build_report(summary: dict[str, Any], summary_path: Path, args: argparse.Nam if not isinstance(tiles, list): raise ValueError("Expected summary tiles to be a list") - selected_tiles = select_tiles(tiles, args.max_tiles) + filtered_tiles = filter_tiles_by_samples(tiles, args.sample_slug) + selected_tiles = select_tiles(filtered_tiles, args.max_tiles) rendered_cards: list[Image.Image] = [] selected_report_tiles: list[dict[str, Any]] = [] missing_image_count = 0 @@ -329,6 +347,7 @@ def build_report(summary: dict[str, Any], summary_path: Path, args: argparse.Nam "dataset_output_dir": summary.get("output_dir"), "class_names": summary.get("class_names", []), "max_tiles": args.max_tiles, + "requested_sample_slugs": sorted(set(args.sample_slug)), "columns": args.columns, "thumb_size": args.thumb_size, "selected_tile_count": len(selected_tiles), diff --git a/tests/test_render_operator_yolo_label_qa_contact_sheets.py b/tests/test_render_operator_yolo_label_qa_contact_sheets.py new file mode 100644 index 00000000..010f108f --- /dev/null +++ b/tests/test_render_operator_yolo_label_qa_contact_sheets.py @@ -0,0 +1,26 @@ +import pytest + +from scripts.render_operator_yolo_label_qa_contact_sheets import filter_tiles_by_samples + + +TILES = [ + {"sample_slug": "genk-industry-train"}, + {"sample_slug": "lokeren-ribbon-train"}, + {"sample_slug": "ostend-coastal-train"}, +] + + +def test_filter_tiles_by_samples_keeps_only_explicit_samples() -> None: + assert filter_tiles_by_samples(TILES, ["genk-industry-train", "ostend-coastal-train"]) == [ + TILES[0], + TILES[2], + ] + + +def test_filter_tiles_by_samples_rejects_missing_sample() -> None: + with pytest.raises(ValueError, match="absent from summary"): + filter_tiles_by_samples(TILES, ["missing"]) + + +def test_filter_tiles_by_samples_without_filter_preserves_tiles() -> None: + assert filter_tiles_by_samples(TILES, []) is TILES