Add operator YOLO label QA contact sheets
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-07-11 21:34:22 +02:00
parent 8cb435361d
commit fbccf8322e
7 changed files with 682 additions and 0 deletions
@@ -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/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/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/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 assert "COPY scripts/train_operator_yolo_detector.sh /app/scripts/train_operator_yolo_detector.sh" in dockerfile
@@ -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
+1
View File
@@ -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/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/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/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 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/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 COPY deploy/unraid/all-in-one-start.sh /usr/local/bin/geointel-all-in-one-start
@@ -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.
@@ -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.
+19
View File
@@ -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 next action is usually more positive AOIs, better validation coverage or more
unique hard negatives rather than simply extending epochs. 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: Current Tower audit status:
- `yolo-building-tile-expanded160`: clean baseline; no missing/invalid labels. - `yolo-building-tile-expanded160`: clean baseline; no missing/invalid labels.
@@ -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())