audit nested YOLO labels without destructive rewrites
This commit is contained in:
@@ -26,9 +26,17 @@ 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(
|
||||
"--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",
|
||||
@@ -36,7 +44,12 @@ def parse_args() -> argparse.Namespace:
|
||||
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(
|
||||
"--thumb-size",
|
||||
type=int,
|
||||
default=256,
|
||||
help="Rendered tile thumbnail size in pixels",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tiles-per-sheet",
|
||||
type=int,
|
||||
@@ -60,14 +73,18 @@ 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]]:
|
||||
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)}")
|
||||
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]
|
||||
|
||||
|
||||
@@ -95,7 +112,9 @@ def resolve_path(raw_path: str | None, summary_path: Path) -> Path | None:
|
||||
return candidate
|
||||
|
||||
|
||||
def parse_yolo_label_file(path: Path | None) -> tuple[list[dict[str, float]], int, bool]:
|
||||
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
|
||||
|
||||
@@ -118,7 +137,12 @@ def parse_yolo_label_file(path: Path | None) -> tuple[list[dict[str, float]], in
|
||||
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):
|
||||
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(
|
||||
@@ -143,7 +167,9 @@ def tile_sort_key(tile: dict[str, Any]) -> tuple[int, str, str, int, int]:
|
||||
)
|
||||
|
||||
|
||||
def balanced_tiles_by_sample(tiles: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
|
||||
def balanced_tiles_by_sample(
|
||||
tiles: list[dict[str, Any]], limit: int
|
||||
) -> list[dict[str, Any]]:
|
||||
if limit <= 0 or not tiles:
|
||||
return []
|
||||
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||
@@ -174,23 +200,43 @@ def select_tiles(tiles: list[dict[str, Any]], max_tiles: int) -> list[dict[str,
|
||||
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)]
|
||||
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)],
|
||||
[
|
||||
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],
|
||||
[
|
||||
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
|
||||
negative_slots = (
|
||||
min(len(negatives), max(1, max_tiles // 5))
|
||||
if negatives and max_tiles > 1
|
||||
else 0
|
||||
)
|
||||
selected = balanced_tiles_by_sample(positives, max_tiles - negative_slots)
|
||||
selected.extend(balanced_tiles_by_sample(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]
|
||||
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)
|
||||
@@ -206,17 +252,25 @@ def draw_tile_card(
|
||||
low_visual_variance: bool,
|
||||
) -> Image.Image:
|
||||
header_height = 44
|
||||
card = Image.new("RGB", (thumb_size, thumb_size + header_height), color=(245, 247, 250))
|
||||
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)
|
||||
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")]
|
||||
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:
|
||||
@@ -248,7 +302,9 @@ def image_has_low_visual_variance(image_path: Path, blank_range_threshold: int)
|
||||
return (max_value - min_value) <= blank_range_threshold
|
||||
|
||||
|
||||
def build_contact_sheet(cards: list[Image.Image], columns: int, output_path: Path) -> None:
|
||||
def build_contact_sheet(
|
||||
cards: list[Image.Image], columns: int, output_path: Path
|
||||
) -> None:
|
||||
if not cards:
|
||||
return
|
||||
if columns <= 0:
|
||||
@@ -272,7 +328,9 @@ def build_contact_sheet(cards: list[Image.Image], columns: int, output_path: Pat
|
||||
sheet.save(output_path)
|
||||
|
||||
|
||||
def build_report(summary: dict[str, Any], summary_path: Path, args: argparse.Namespace) -> tuple[dict[str, Any], list[Image.Image]]:
|
||||
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")
|
||||
@@ -290,7 +348,9 @@ def build_report(summary: dict[str, Any], summary_path: Path, args: argparse.Nam
|
||||
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)
|
||||
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:
|
||||
@@ -301,7 +361,9 @@ def build_report(summary: dict[str, Any], summary_path: Path, args: argparse.Nam
|
||||
if image_path is None or not image_path.exists():
|
||||
missing_image_count += 1
|
||||
else:
|
||||
low_visual_variance = image_has_low_visual_variance(image_path, args.blank_range_threshold)
|
||||
low_visual_variance = image_has_low_visual_variance(
|
||||
image_path, args.blank_range_threshold
|
||||
)
|
||||
if low_visual_variance:
|
||||
low_visual_variance_tile_count += 1
|
||||
rendered_cards.append(
|
||||
@@ -339,7 +401,9 @@ def build_report(summary: dict[str, Any], summary_path: Path, args: argparse.Nam
|
||||
raise ValueError("tiles_per_sheet must be positive")
|
||||
contact_sheets = [
|
||||
{
|
||||
"path": CONTACT_SHEET_NAME_TEMPLATE.format(index=(start // args.tiles_per_sheet) + 1),
|
||||
"path": CONTACT_SHEET_NAME_TEMPLATE.format(
|
||||
index=(start // args.tiles_per_sheet) + 1
|
||||
),
|
||||
"tile_count": len(rendered_cards[start : start + args.tiles_per_sheet]),
|
||||
"columns": args.columns,
|
||||
"thumb_size": args.thumb_size,
|
||||
@@ -432,12 +496,16 @@ def main() -> int:
|
||||
|
||||
summary = load_json(summary_path)
|
||||
report, cards = build_report(summary, summary_path, args)
|
||||
for page_index, start in enumerate(range(0, len(cards), args.tiles_per_sheet), start=1):
|
||||
for page_index, start in enumerate(
|
||||
range(0, len(cards), args.tiles_per_sheet), start=1
|
||||
):
|
||||
page_cards = cards[start : start + args.tiles_per_sheet]
|
||||
page_name = CONTACT_SHEET_NAME_TEMPLATE.format(index=page_index)
|
||||
build_contact_sheet(page_cards, args.columns, output_dir / page_name)
|
||||
|
||||
(output_dir / JSON_NAME).write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
|
||||
(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")
|
||||
|
||||
Reference in New Issue
Block a user