Add operator YOLO label QA contact sheets
This commit is contained in:
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user