From fbccf8322ee3f16b7658a7fefdf80600fb54a732 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 21:34:22 +0200 Subject: [PATCH] Add operator YOLO label QA contact sheets --- backend/tests/test_docker_runtime_config.py | 4 + ...7_operator_yolo_label_qa_contact_sheets.py | 162 ++++++++ deploy/unraid/Dockerfile.all-in-one | 1 + ...2026-07-11-yolo-label-qa-contact-sheets.md | 63 +++ ...-11-yolo-label-qa-contact-sheets-design.md | 73 ++++ scripts/README.md | 19 + ...r_operator_yolo_label_qa_contact_sheets.py | 360 ++++++++++++++++++ 7 files changed, 682 insertions(+) create mode 100644 backend/tests/test_sprint167_operator_yolo_label_qa_contact_sheets.py create mode 100644 docs/superpowers/plans/2026-07-11-yolo-label-qa-contact-sheets.md create mode 100644 docs/superpowers/specs/2026-07-11-yolo-label-qa-contact-sheets-design.md create mode 100644 scripts/render_operator_yolo_label_qa_contact_sheets.py diff --git a/backend/tests/test_docker_runtime_config.py b/backend/tests/test_docker_runtime_config.py index acadf4d3..9d893605 100644 --- a/backend/tests/test_docker_runtime_config.py +++ b/backend/tests/test_docker_runtime_config.py @@ -61,6 +61,10 @@ def test_all_in_one_dockerfile_copies_operator_scripts_for_runtime_use() -> None assert "COPY scripts/prepare_operator_real_data_samples.py /app/scripts/prepare_operator_real_data_samples.py" in dockerfile assert "COPY scripts/export_operator_yolo_tile_dataset.py /app/scripts/export_operator_yolo_tile_dataset.py" in dockerfile assert "COPY scripts/audit_operator_yolo_dataset_quality.py /app/scripts/audit_operator_yolo_dataset_quality.py" in dockerfile + assert ( + "COPY scripts/render_operator_yolo_label_qa_contact_sheets.py " + "/app/scripts/render_operator_yolo_label_qa_contact_sheets.py" + ) in dockerfile assert "COPY scripts/train_operator_yolo_detector.sh /app/scripts/train_operator_yolo_detector.sh" in dockerfile diff --git a/backend/tests/test_sprint167_operator_yolo_label_qa_contact_sheets.py b/backend/tests/test_sprint167_operator_yolo_label_qa_contact_sheets.py new file mode 100644 index 00000000..f76fd7bf --- /dev/null +++ b/backend/tests/test_sprint167_operator_yolo_label_qa_contact_sheets.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +from PIL import Image + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_operator_yolo_label_qa_contact_sheets_render_visual_artifacts(tmp_path: Path) -> None: + script_path = ROOT / "scripts" / "render_operator_yolo_label_qa_contact_sheets.py" + assert script_path.exists() + + dataset_dir = tmp_path / "yolo-dataset" + image_train = dataset_dir / "images" / "train" + image_val = dataset_dir / "images" / "val" + labels_train = dataset_dir / "labels" / "train" + labels_val = dataset_dir / "labels" / "val" + image_train.mkdir(parents=True) + image_val.mkdir(parents=True) + labels_train.mkdir(parents=True) + labels_val.mkdir(parents=True) + + for path, color in ( + (image_train / "dense_000.png", (120, 130, 140)), + (image_train / "invalid_000.png", (80, 100, 120)), + (image_val / "missing_000.png", (90, 120, 90)), + (image_val / "negative_000.png", (50, 50, 55)), + ): + Image.new("RGB", (64, 64), color=color).save(path) + + (labels_train / "dense_000.txt").write_text( + "0 0.500000 0.500000 0.500000 0.500000\n" + "0 0.250000 0.250000 0.250000 0.250000\n", + encoding="utf-8", + ) + (labels_train / "invalid_000.txt").write_text( + "0 0.500000 0.500000 0.300000 0.300000\n" + "not-a-valid-yolo-row\n", + encoding="utf-8", + ) + (labels_val / "negative_000.txt").write_text("", encoding="utf-8") + + missing_label_path = labels_val / "missing_000.txt" + summary_path = dataset_dir / "yolo_tile_dataset_summary.json" + summary_path.write_text( + json.dumps( + { + "status": "ok", + "dataset_yaml": str(dataset_dir / "dataset.yaml"), + "output_dir": str(dataset_dir), + "class_names": ["building"], + "tile_size": 64, + "stride": 64, + "tiles": [ + { + "sample_slug": "dense", + "sample_role": "reference", + "background_category": "reference_aoi", + "split": "train", + "tile_index": 0, + "kept": True, + "image_path": str(image_train / "dense_000.png"), + "label_path": str(labels_train / "dense_000.txt"), + "label_count": 2, + "is_negative": False, + }, + { + "sample_slug": "invalid", + "sample_role": "reference", + "background_category": "reference_aoi", + "split": "train", + "tile_index": 1, + "kept": True, + "image_path": str(image_train / "invalid_000.png"), + "label_path": str(labels_train / "invalid_000.txt"), + "label_count": 1, + "is_negative": False, + }, + { + "sample_slug": "missing", + "sample_role": "background_candidate", + "background_category": "sparse_building_context", + "split": "val", + "tile_index": 2, + "kept": True, + "image_path": str(image_val / "missing_000.png"), + "label_path": str(missing_label_path), + "label_count": 1, + "is_negative": False, + }, + { + "sample_slug": "negative", + "sample_role": "background_candidate", + "background_category": "pure_empty_negative", + "split": "val", + "tile_index": 3, + "kept": True, + "image_path": str(image_val / "negative_000.png"), + "label_path": str(labels_val / "negative_000.txt"), + "label_count": 0, + "is_negative": True, + }, + ], + } + ), + encoding="utf-8", + ) + + output_dir = tmp_path / "label-qa" + result = subprocess.run( + [ + sys.executable, + str(script_path), + "--summary-path", + str(summary_path), + "--output-dir", + str(output_dir), + "--max-tiles", + "4", + "--columns", + "2", + "--thumb-size", + "128", + ], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + + assert "Operator YOLO label QA contact sheets rendered" in result.stdout + + report = json.loads((output_dir / "operator_yolo_label_qa_summary.json").read_text(encoding="utf-8")) + assert report["status"] == "ok" + assert report["selected_tile_count"] == 4 + assert report["rendered_tile_count"] == 4 + assert report["missing_label_file_count"] == 1 + assert report["invalid_label_count"] == 1 + assert [tile["sample_slug"] for tile in report["selected_tiles"]] == [ + "dense", + "invalid", + "missing", + "negative", + ] + + sheet_path = output_dir / report["contact_sheets"][0]["path"] + assert sheet_path.exists() + sheet = Image.open(sheet_path).convert("RGB") + assert sheet.size[0] >= 256 + assert sheet.size[1] >= 256 + assert len(sheet.getcolors(maxcolors=1000000) or []) > 4 + + markdown = (output_dir / "operator_yolo_label_qa_contact_sheet.md").read_text(encoding="utf-8") + assert "Operator YOLO Label QA Contact Sheets" in markdown + assert "missing label files: 1" in markdown + assert "invalid label rows: 1" in markdown + assert "contact_sheet_001.png" in markdown diff --git a/deploy/unraid/Dockerfile.all-in-one b/deploy/unraid/Dockerfile.all-in-one index b2ffacff..0d954ee3 100644 --- a/deploy/unraid/Dockerfile.all-in-one +++ b/deploy/unraid/Dockerfile.all-in-one @@ -49,6 +49,7 @@ COPY fixtures/ /app/fixtures/ COPY scripts/prepare_operator_real_data_samples.py /app/scripts/prepare_operator_real_data_samples.py COPY scripts/export_operator_yolo_tile_dataset.py /app/scripts/export_operator_yolo_tile_dataset.py COPY scripts/audit_operator_yolo_dataset_quality.py /app/scripts/audit_operator_yolo_dataset_quality.py +COPY scripts/render_operator_yolo_label_qa_contact_sheets.py /app/scripts/render_operator_yolo_label_qa_contact_sheets.py COPY scripts/train_operator_yolo_detector.sh /app/scripts/train_operator_yolo_detector.sh COPY deploy/unraid/nginx-all-in-one.conf /etc/nginx/conf.d/default.conf COPY deploy/unraid/all-in-one-start.sh /usr/local/bin/geointel-all-in-one-start diff --git a/docs/superpowers/plans/2026-07-11-yolo-label-qa-contact-sheets.md b/docs/superpowers/plans/2026-07-11-yolo-label-qa-contact-sheets.md new file mode 100644 index 00000000..a28f4bd6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-yolo-label-qa-contact-sheets.md @@ -0,0 +1,63 @@ +# YOLO Label QA Contact Sheets Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an operator-only visual QA script that creates deterministic contact-sheet PNG artifacts from existing YOLO tile datasets. + +**Architecture:** Add a standalone script under `scripts/` with no backend/API/database changes. The script reads `yolo_tile_dataset_summary.json`, resolves image and label paths, selects a bounded deterministic tile subset, draws normalized YOLO labels using Pillow, and writes JSON/Markdown/PNG artifacts. + +**Tech Stack:** Python standard library, Pillow, pytest subprocess-based script tests. + +--- + +### Task 1: Regression Test + +**Files:** +- Create: `backend/tests/test_sprint167_operator_yolo_label_qa_contact_sheets.py` + +- [ ] Write a failing test that creates a tiny YOLO dataset with train/val images, valid labels, an invalid label row and a missing label path. +- [ ] Run `python -m pytest backend/tests/test_sprint167_operator_yolo_label_qa_contact_sheets.py -q`. +- [ ] Expected result: failure because `scripts/render_operator_yolo_label_qa_contact_sheets.py` does not exist. + +### Task 2: Script Implementation + +**Files:** +- Create: `scripts/render_operator_yolo_label_qa_contact_sheets.py` + +- [ ] Implement CLI arguments: + - `--summary-path` + - `--output-dir` + - `--max-tiles` + - `--columns` + - `--thumb-size` +- [ ] Implement summary loading and `/app/...` path resolution consistent with existing operator scripts. +- [ ] Implement YOLO label parsing with invalid/missing counts. +- [ ] Implement deterministic tile selection. +- [ ] Implement Pillow rendering to PNG contact sheets. +- [ ] Implement JSON and Markdown reports. +- [ ] Run the targeted test and keep the implementation minimal until it passes. + +### Task 3: Documentation + +**Files:** +- Modify: `scripts/README.md` +- Modify: `docs/TODO.md` +- Modify: `docs/CODEX_EXECUTION_LOG.md` + +- [ ] Document the command and intended usage. +- [ ] Mark visual contact sheets as implemented in TODO. +- [ ] Record local and Tower validation evidence. + +### Task 4: Verification And Deploy + +**Commands:** +- `python -m pytest backend/tests/test_sprint167_operator_yolo_label_qa_contact_sheets.py -q` +- `python -m pytest backend/tests/test_sprint146_operator_yolo_dataset_quality_audit.py backend/tests/test_sprint167_operator_yolo_label_qa_contact_sheets.py -q` +- `bash scripts/run_readiness_check.sh` +- `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/deploy_tower.ps1` +- Tower script run against `/app/storage/operator-data/yolo-building-aoi1024-cleanpx12vis035/yolo_tile_dataset_summary.json` + +- [ ] Commit and push after local readiness. +- [ ] Redeploy Tower. +- [ ] Generate Tower contact sheets. +- [ ] Commit and push evidence docs. diff --git a/docs/superpowers/specs/2026-07-11-yolo-label-qa-contact-sheets-design.md b/docs/superpowers/specs/2026-07-11-yolo-label-qa-contact-sheets-design.md new file mode 100644 index 00000000..0292d39a --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-yolo-label-qa-contact-sheets-design.md @@ -0,0 +1,73 @@ +# YOLO Label QA Contact Sheets Design + +## Goal + +Add an operator-only visual QA helper that renders existing YOLO tile images with +their YOLO bbox labels overlaid into deterministic contact-sheet PNG artifacts. + +## Scope + +This is not a product feature and does not change API contracts, database +schema, model activation, detection inference, provider fetching or training. +It only reads an already exported YOLO tile dataset and writes visual evidence +artifacts for human inspection before another training run. + +## Inputs + +- `yolo_tile_dataset_summary.json` from `scripts/export_operator_yolo_tile_dataset.py`. +- Existing tile image files referenced by the summary. +- Existing YOLO label files referenced by the summary. + +## Outputs + +- `operator_yolo_label_qa_summary.json` +- `operator_yolo_label_qa_contact_sheet.md` +- One or more PNG contact sheets under the chosen output directory. + +Each selected tile preview shows the image, label boxes and compact metadata: +sample slug, split, label count and background category when present. + +## Selection Strategy + +The first implementation should be deterministic and small: + +- include tiles with the highest label counts; +- include tiles from low-label positive/context samples; +- include a small number of negative tiles; +- limit total rendered tiles with `--max-tiles`. + +This is enough to catch common issues such as shifted imagery, clipped labels, +wrong class files, empty positives and mislabeled background tiles. + +## Rendering Strategy + +Use Pillow, already available in the project runtime. Draw boxes from normalized +YOLO labels directly onto the tile image. Invalid or missing label files are +reported in JSON/Markdown and skipped for box drawing, not silently ignored. + +## Error Handling + +- Missing summary file: fail with a clear process error. +- Missing image files: record skipped image count and continue if other selected + images can be rendered. +- Missing label files: record missing label count and render the image without + boxes. +- Invalid label rows: record invalid row count and render only valid boxes. + +## Tests + +Add focused tests that create tiny fixture images and YOLO labels in a temporary +dataset directory, run the script and assert: + +- JSON and Markdown reports are created; +- contact-sheet PNG exists; +- selected tile count is deterministic; +- invalid labels are counted; +- missing label files are counted; +- rendered output is not blank. + +## Acceptance + +The helper is acceptable when local targeted tests pass, full readiness passes, +the all-in-one Tower runtime is redeployed, and the clean AOI1024 dataset emits +contact sheets on Tower. diff --git a/scripts/README.md b/scripts/README.md index 87c37a40..596e6bb5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -420,6 +420,25 @@ median box area, small-box share and sample-specific quality warnings. Treat next action is usually more positive AOIs, better validation coverage or more unique hard negatives rather than simply extending epochs. +Render visual label QA contact sheets before spending CPU on another training +run: + +```bash +docker exec -it geointel python3 /app/scripts/render_operator_yolo_label_qa_contact_sheets.py \ + --summary-path /app/storage/operator-data/yolo-building-aoi1024-cleanpx12vis035/yolo_tile_dataset_summary.json \ + --output-dir /app/artifacts/operator-yolo-label-qa/aoi1024-cleanpx12vis035 \ + --max-tiles 32 \ + --columns 4 \ + --thumb-size 256 +``` + +The renderer writes `operator_yolo_label_qa_summary.json`, +`operator_yolo_label_qa_contact_sheet.md` and `contact_sheet_001.png`. It draws +existing YOLO labels on existing tile images only; it does not run inference, +train a model, fetch providers or create fake detections. Missing image files, +missing label files and invalid YOLO rows are reported in the JSON/Markdown +artifacts. + Current Tower audit status: - `yolo-building-tile-expanded160`: clean baseline; no missing/invalid labels. diff --git a/scripts/render_operator_yolo_label_qa_contact_sheets.py b/scripts/render_operator_yolo_label_qa_contact_sheets.py new file mode 100644 index 00000000..5c6136db --- /dev/null +++ b/scripts/render_operator_yolo_label_qa_contact_sheets.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +"""Render visual QA contact sheets for exported operator YOLO tile datasets. + +This helper is operator tooling only. It reads existing tile images and YOLO +label files, then writes visual evidence artifacts. It does not train, infer, +fetch provider data or mutate application persistence. +""" + +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +from typing import Any + +from PIL import Image, ImageDraw, ImageFont + + +JSON_NAME = "operator_yolo_label_qa_summary.json" +MARKDOWN_NAME = "operator_yolo_label_qa_contact_sheet.md" +CONTACT_SHEET_NAME = "contact_sheet_001.png" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Render YOLO tile label overlays into deterministic operator QA contact sheets.", + ) + 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("--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") + return parser.parse_args() + + +def load_json(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + if not isinstance(data, dict): + raise ValueError(f"Expected JSON object in {path}") + return data + + +def resolve_path(raw_path: str | None, summary_path: Path) -> Path | None: + if not raw_path: + return None + + candidate = Path(raw_path) + if candidate.exists(): + return candidate + + if candidate.is_absolute() and raw_path.startswith("/app/"): + local_candidate = Path.cwd() / raw_path.removeprefix("/app/") + if local_candidate.exists(): + return local_candidate + + summary_parent_candidate = summary_path.parent / raw_path.removeprefix("/app/") + if summary_parent_candidate.exists(): + return summary_parent_candidate + + relative_candidate = summary_path.parent / raw_path + if relative_candidate.exists(): + return relative_candidate + + return candidate + + +def parse_yolo_label_file(path: Path | None) -> tuple[list[dict[str, float]], int, bool]: + if path is None or not path.exists(): + return [], 0, True + + boxes: list[dict[str, float]] = [] + invalid_count = 0 + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped: + continue + parts = stripped.split() + if len(parts) != 5: + invalid_count += 1 + continue + try: + class_id = int(float(parts[0])) + center_x = float(parts[1]) + center_y = float(parts[2]) + width = float(parts[3]) + height = float(parts[4]) + except ValueError: + invalid_count += 1 + continue + if not (0 <= center_x <= 1 and 0 <= center_y <= 1 and 0 < width <= 1 and 0 < height <= 1): + invalid_count += 1 + continue + boxes.append( + { + "class_id": float(class_id), + "center_x": center_x, + "center_y": center_y, + "width": width, + "height": height, + } + ) + return boxes, invalid_count, False + + +def tile_sort_key(tile: dict[str, Any]) -> tuple[int, str, str, int, int]: + return ( + -int(tile.get("label_count") or 0), + str(tile.get("sample_slug") or ""), + str(tile.get("split") or ""), + int(tile.get("tile_index") or 0), + int(tile.get("repeat_index") or 0), + ) + + +def select_tiles(tiles: list[dict[str, Any]], max_tiles: int) -> list[dict[str, Any]]: + if max_tiles <= 0: + raise ValueError("max_tiles must be positive") + + kept_tiles = [tile for tile in tiles if isinstance(tile, dict) and tile.get("kept", True)] + positives = sorted( + [tile for tile in kept_tiles if not (bool(tile.get("is_negative")) or int(tile.get("label_count") or 0) == 0)], + key=tile_sort_key, + ) + negatives = sorted( + [tile for tile in kept_tiles if bool(tile.get("is_negative")) or int(tile.get("label_count") or 0) == 0], + key=tile_sort_key, + ) + + negative_slots = min(len(negatives), max(1, max_tiles // 5)) if negatives and max_tiles > 1 else 0 + selected = positives[: max_tiles - negative_slots] + negatives[:negative_slots] + + if len(selected) < max_tiles: + selected_ids = {id(tile) for tile in selected} + remainder = [tile for tile in sorted(kept_tiles, key=tile_sort_key) if id(tile) not in selected_ids] + selected.extend(remainder[: max_tiles - len(selected)]) + + return sorted(selected[:max_tiles], key=tile_sort_key) + + +def draw_tile_card( + image_path: Path, + boxes: list[dict[str, float]], + tile: dict[str, Any], + thumb_size: int, + invalid_label_count: int, + missing_label_file: bool, +) -> Image.Image: + header_height = 44 + card = Image.new("RGB", (thumb_size, thumb_size + header_height), color=(245, 247, 250)) + image = Image.open(image_path).convert("RGB").resize((thumb_size, thumb_size)) + card.paste(image, (0, header_height)) + + draw = ImageDraw.Draw(card) + draw.rectangle((0, 0, thumb_size - 1, header_height - 1), fill=(20, 31, 44)) + draw.rectangle((0, header_height, thumb_size - 1, thumb_size + header_height - 1), outline=(20, 31, 44), width=1) + + font = ImageFont.load_default() + title = f"{tile.get('sample_slug', 'unknown')} / {tile.get('split', 'unknown')} / labels {tile.get('label_count', 0)}" + subtitle_parts = [str(tile.get("background_category") or tile.get("sample_role") or "unknown")] + if missing_label_file: + subtitle_parts.append("missing-label-file") + if invalid_label_count: + subtitle_parts.append(f"invalid:{invalid_label_count}") + draw.text((6, 6), title[:44], fill=(255, 255, 255), font=font) + draw.text((6, 24), " | ".join(subtitle_parts)[:52], fill=(191, 219, 254), font=font) + + for box in boxes: + x_center = box["center_x"] * thumb_size + y_center = box["center_y"] * thumb_size + header_height + width = box["width"] * thumb_size + height = box["height"] * thumb_size + left = max(0, x_center - width / 2) + top = max(header_height, y_center - height / 2) + right = min(thumb_size - 1, x_center + width / 2) + bottom = min(thumb_size + header_height - 1, y_center + height / 2) + draw.rectangle((left, top, right, bottom), outline=(255, 214, 10), width=3) + + return card + + +def build_contact_sheet(cards: list[Image.Image], columns: int, output_path: Path) -> None: + if not cards: + return + if columns <= 0: + raise ValueError("columns must be positive") + + gap = 12 + cell_width = max(card.width for card in cards) + cell_height = max(card.height for card in cards) + rows = math.ceil(len(cards) / columns) + sheet_width = columns * cell_width + (columns + 1) * gap + sheet_height = rows * cell_height + (rows + 1) * gap + sheet = Image.new("RGB", (sheet_width, sheet_height), color=(226, 232, 240)) + + for index, card in enumerate(cards): + row = index // columns + column = index % columns + x = gap + column * (cell_width + gap) + y = gap + row * (cell_height + gap) + sheet.paste(card, (x, y)) + + sheet.save(output_path) + + +def build_report(summary: dict[str, Any], summary_path: Path, args: argparse.Namespace) -> tuple[dict[str, Any], list[Image.Image]]: + tiles = summary.get("tiles") or [] + if not isinstance(tiles, list): + raise ValueError("Expected summary tiles to be a list") + + selected_tiles = select_tiles(tiles, args.max_tiles) + rendered_cards: list[Image.Image] = [] + selected_report_tiles: list[dict[str, Any]] = [] + missing_image_count = 0 + missing_label_file_count = 0 + invalid_label_count = 0 + valid_label_count = 0 + + for tile in selected_tiles: + image_path = resolve_path(tile.get("image_path"), summary_path) + label_path = resolve_path(tile.get("label_path"), summary_path) + boxes, tile_invalid_count, missing_label_file = parse_yolo_label_file(label_path) + invalid_label_count += tile_invalid_count + valid_label_count += len(boxes) + if missing_label_file: + missing_label_file_count += 1 + + rendered = False + if image_path is None or not image_path.exists(): + missing_image_count += 1 + else: + rendered_cards.append( + draw_tile_card( + image_path=image_path, + boxes=boxes, + tile=tile, + thumb_size=args.thumb_size, + invalid_label_count=tile_invalid_count, + missing_label_file=missing_label_file, + ) + ) + rendered = True + + selected_report_tiles.append( + { + "sample_slug": str(tile.get("sample_slug") or "unknown"), + "sample_role": str(tile.get("sample_role") or "unknown"), + "background_category": str(tile.get("background_category") or ""), + "split": str(tile.get("split") or "unknown"), + "tile_index": int(tile.get("tile_index") or 0), + "image_path": str(image_path) if image_path is not None else None, + "label_path": str(label_path) if label_path is not None else None, + "label_count": int(tile.get("label_count") or 0), + "valid_label_count": len(boxes), + "invalid_label_count": tile_invalid_count, + "missing_label_file": missing_label_file, + "rendered": rendered, + } + ) + + contact_sheets = [] + if rendered_cards: + contact_sheets.append( + { + "path": CONTACT_SHEET_NAME, + "tile_count": len(rendered_cards), + "columns": args.columns, + "thumb_size": args.thumb_size, + } + ) + + return ( + { + "status": "ok" if rendered_cards else "no_renderable_tiles", + "summary_path": str(summary_path), + "dataset_output_dir": summary.get("output_dir"), + "class_names": summary.get("class_names", []), + "max_tiles": args.max_tiles, + "columns": args.columns, + "thumb_size": args.thumb_size, + "selected_tile_count": len(selected_tiles), + "rendered_tile_count": len(rendered_cards), + "missing_image_count": missing_image_count, + "missing_label_file_count": missing_label_file_count, + "invalid_label_count": invalid_label_count, + "valid_label_count": valid_label_count, + "contact_sheets": contact_sheets, + "selected_tiles": selected_report_tiles, + }, + rendered_cards, + ) + + +def write_markdown(report: dict[str, Any], output_dir: Path) -> None: + lines = [ + "# Operator YOLO Label QA Contact Sheets", + "", + f"- status: `{report['status']}`", + f"- selected tiles: {report['selected_tile_count']}", + f"- rendered tiles: {report['rendered_tile_count']}", + f"- missing images: {report['missing_image_count']}", + f"- missing label files: {report['missing_label_file_count']}", + f"- invalid label rows: {report['invalid_label_count']}", + f"- valid labels rendered: {report['valid_label_count']}", + "", + "## Contact Sheets", + "", + ] + if report["contact_sheets"]: + for sheet in report["contact_sheets"]: + lines.append(f"- `{sheet['path']}` ({sheet['tile_count']} tiles)") + lines.append(f"") + lines.append(f"![{sheet['path']}]({sheet['path']})") + lines.append("") + else: + lines.append("- None") + + lines.extend(["", "## Selected Tiles", ""]) + for tile in report["selected_tiles"]: + flags = [] + if tile["missing_label_file"]: + flags.append("missing-label-file") + if tile["invalid_label_count"]: + flags.append(f"invalid:{tile['invalid_label_count']}") + flag_text = ", ".join(flags) if flags else "ok" + lines.append( + "- " + f"{tile['sample_slug']} ({tile['split']}, {tile['background_category'] or tile['sample_role']}): " + f"{tile['label_count']} expected labels, {tile['valid_label_count']} rendered labels, {flag_text}" + ) + + (output_dir / MARKDOWN_NAME).write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> int: + args = parse_args() + summary_path = Path(args.summary_path).resolve() + output_dir = Path(args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + summary = load_json(summary_path) + report, cards = build_report(summary, summary_path, args) + if cards: + build_contact_sheet(cards, args.columns, output_dir / CONTACT_SHEET_NAME) + + (output_dir / JSON_NAME).write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8") + write_markdown(report, output_dir) + + print("Operator YOLO label QA contact sheets rendered") + print(f"Status: {report['status']}") + print(f"JSON: {output_dir / JSON_NAME}") + print(f"Markdown: {output_dir / MARKDOWN_NAME}") + for sheet in report["contact_sheets"]: + print(f"Contact sheet: {output_dir / sheet['path']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())