Add per-sample YOLO dataset audit diagnostics
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 20:51:57 +02:00
parent 2f60a41679
commit 7ccabf5fed
3 changed files with 79 additions and 20 deletions
@@ -148,6 +148,20 @@ def test_operator_yolo_dataset_quality_audit_reports_dataset_risks(tmp_path: Pat
assert report["label_stats"]["parsed_label_count"] == 3 assert report["label_stats"]["parsed_label_count"] == 3
assert report["label_stats"]["invalid_label_count"] == 0 assert report["label_stats"]["invalid_label_count"] == 0
assert report["min_label_visible_ratio"] == 0.25 assert report["min_label_visible_ratio"] == 0.25
sample_by_slug = {sample["sample_slug"]: sample for sample in report["sample_summaries"]}
assert sample_by_slug["geel"]["parsed_label_count"] == 2
assert sample_by_slug["geel"]["invalid_label_count"] == 0
assert sample_by_slug["geel"]["small_box_share"] == 0.5
assert sample_by_slug["geel"]["median_box_area"] == 0.00505
assert sample_by_slug["geel"]["quality_warnings"] == [
"median_box_area_below_gate",
"small_box_share_above_gate",
]
assert sample_by_slug["turnhout"]["parsed_label_count"] == 1
assert sample_by_slug["turnhout"]["small_box_share"] == 0.0
assert sample_by_slug["turnhout"]["quality_warnings"] == ["median_box_area_below_gate"]
assert sample_by_slug["postel_bos"]["parsed_label_count"] == 0
assert sample_by_slug["postel_bos"]["quality_warnings"] == []
warning_codes = {warning["code"] for warning in report["warnings"]} warning_codes = {warning["code"] for warning in report["warnings"]}
assert "positive_sample_count_below_gate" in warning_codes assert "positive_sample_count_below_gate" in warning_codes
+3 -2
View File
@@ -413,8 +413,9 @@ The audit reads the tile summary and YOLO label files, then writes
`operator_yolo_dataset_quality_audit.json` and `operator_yolo_dataset_quality_audit.json` and
`operator_yolo_dataset_quality_audit.md`. It reports positive/background sample `operator_yolo_dataset_quality_audit.md`. It reports positive/background sample
coverage, train/validation split coverage, repeated hard-negative pressure, coverage, train/validation split coverage, repeated hard-negative pressure,
minimum visible label ratio, missing or invalid label rows and normalized minimum visible label ratio, missing or invalid label rows, normalized
box-area signals. Treat box-area signals and per-sample label diagnostics such as parsed label count,
median box area, small-box share and sample-specific quality warnings. Treat
`needs_attention` as a dataset-design warning, not as a runtime failure: the `needs_attention` as a dataset-design warning, not as a runtime failure: the
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.
+62 -18
View File
@@ -105,17 +105,18 @@ def add_warning(warnings: list[dict[str, str]], code: str, message: str) -> None
warnings.append({"code": code, "message": message}) warnings.append({"code": code, "message": message})
def summarize_labels( def safe_mean(values: list[float]) -> float | None:
tiles: list[dict[str, Any]], return round(statistics.fmean(values), 12) if values else None
summary_path: Path,
def safe_median(values: list[float]) -> float | None:
return round(float(statistics.median(values)), 12) if values else None
def summarize_label_files(
label_file_paths: set[Path],
small_box_area_threshold: float, small_box_area_threshold: float,
) -> dict[str, Any]: ) -> dict[str, Any]:
label_file_paths: set[Path] = set()
for tile in tiles:
resolved = resolve_path(tile.get("label_path"), summary_path)
if resolved is not None:
label_file_paths.add(resolved)
boxes: list[dict[str, float]] = [] boxes: list[dict[str, float]] = []
invalid_count = 0 invalid_count = 0
missing_file_count = 0 missing_file_count = 0
@@ -132,12 +133,6 @@ def summarize_labels(
heights = [box["height"] for box in boxes] heights = [box["height"] for box in boxes]
small_box_count = sum(1 for area in areas if area < small_box_area_threshold) small_box_count = sum(1 for area in areas if area < small_box_area_threshold)
def safe_mean(values: list[float]) -> float | None:
return statistics.fmean(values) if values else None
def safe_median(values: list[float]) -> float | None:
return statistics.median(values) if values else None
return { return {
"label_file_count": len(label_file_paths), "label_file_count": len(label_file_paths),
"missing_label_file_count": missing_file_count, "missing_label_file_count": missing_file_count,
@@ -154,7 +149,43 @@ def summarize_labels(
} }
def build_sample_summaries(tiles: list[dict[str, Any]]) -> list[dict[str, Any]]: def build_label_quality_warning_codes(
label_stats: dict[str, Any],
args: argparse.Namespace,
) -> list[str]:
warning_codes: list[str] = []
if label_stats["missing_label_file_count"]:
warning_codes.append("label_files_missing")
if label_stats["invalid_label_count"]:
warning_codes.append("invalid_label_rows")
median_box_area = label_stats["median_box_area"]
if median_box_area is not None and median_box_area < args.min_median_box_area:
warning_codes.append("median_box_area_below_gate")
if label_stats["parsed_label_count"] and label_stats["small_box_share"] > args.max_small_box_share:
warning_codes.append("small_box_share_above_gate")
return warning_codes
def summarize_labels(
tiles: list[dict[str, Any]],
summary_path: Path,
small_box_area_threshold: float,
) -> dict[str, Any]:
label_file_paths: set[Path] = set()
for tile in tiles:
resolved = resolve_path(tile.get("label_path"), summary_path)
if resolved is not None:
label_file_paths.add(resolved)
return summarize_label_files(label_file_paths, small_box_area_threshold)
def build_sample_summaries(
tiles: list[dict[str, Any]],
summary_path: Path,
args: argparse.Namespace,
) -> list[dict[str, Any]]:
samples: dict[str, dict[str, Any]] = {} samples: dict[str, dict[str, Any]] = {}
for tile in tiles: for tile in tiles:
slug = str(tile.get("sample_slug") or "unknown") slug = str(tile.get("sample_slug") or "unknown")
@@ -169,6 +200,7 @@ def build_sample_summaries(tiles: list[dict[str, Any]]) -> list[dict[str, Any]]:
"negative_tile_count": 0, "negative_tile_count": 0,
"repeated_background_negative_tile_count": 0, "repeated_background_negative_tile_count": 0,
"label_count": 0, "label_count": 0,
"_label_file_paths": set(),
}, },
) )
sample["splits"].add(tile.get("split") or "unknown") sample["splits"].add(tile.get("split") or "unknown")
@@ -182,13 +214,20 @@ def build_sample_summaries(tiles: list[dict[str, Any]]) -> list[dict[str, Any]]:
sample["positive_tile_count"] += 1 sample["positive_tile_count"] += 1
if tile.get("is_repeated_background_negative"): if tile.get("is_repeated_background_negative"):
sample["repeated_background_negative_tile_count"] += 1 sample["repeated_background_negative_tile_count"] += 1
resolved_label_path = resolve_path(tile.get("label_path"), summary_path)
if resolved_label_path is not None:
sample["_label_file_paths"].add(resolved_label_path)
result: list[dict[str, Any]] = [] result: list[dict[str, Any]] = []
for sample in samples.values(): for sample in samples.values():
label_stats = summarize_label_files(sample.pop("_label_file_paths"), args.small_box_area_threshold)
quality_warnings = build_label_quality_warning_codes(label_stats, args)
result.append( result.append(
{ {
**sample, **sample,
"splits": sorted(sample["splits"]), "splits": sorted(sample["splits"]),
**label_stats,
"quality_warnings": quality_warnings,
} }
) )
return sorted(result, key=lambda item: str(item["sample_slug"])) return sorted(result, key=lambda item: str(item["sample_slug"]))
@@ -196,7 +235,7 @@ def build_sample_summaries(tiles: list[dict[str, Any]]) -> list[dict[str, Any]]:
def build_audit(summary: dict[str, Any], summary_path: Path, args: argparse.Namespace) -> dict[str, Any]: def build_audit(summary: dict[str, Any], summary_path: Path, args: argparse.Namespace) -> dict[str, Any]:
tiles = [tile for tile in summary.get("tiles", []) if isinstance(tile, dict) and tile.get("kept", True)] tiles = [tile for tile in summary.get("tiles", []) if isinstance(tile, dict) and tile.get("kept", True)]
sample_summaries = build_sample_summaries(tiles) sample_summaries = build_sample_summaries(tiles, summary_path, args)
split_counts = Counter(str(tile.get("split") or "unknown") for tile in tiles) split_counts = Counter(str(tile.get("split") or "unknown") for tile in tiles)
role_counts = Counter(str(tile.get("sample_role") or "unknown") for tile in tiles) role_counts = Counter(str(tile.get("sample_role") or "unknown") for tile in tiles)
@@ -374,11 +413,16 @@ def write_markdown(report: dict[str, Any], path: Path) -> None:
lines.extend(["", "## Sample Summary", ""]) lines.extend(["", "## Sample Summary", ""])
for sample in report["sample_summaries"]: for sample in report["sample_summaries"]:
quality_warnings = sample.get("quality_warnings") or []
warning_text = ",".join(quality_warnings) if quality_warnings else "none"
lines.append( lines.append(
"- " "- "
f"{sample['sample_slug']} ({sample['sample_role']}, {','.join(sample['splits'])}): " f"{sample['sample_slug']} ({sample['sample_role']}, {','.join(sample['splits'])}): "
f"{sample['tile_count']} tiles, {sample['positive_tile_count']} positive, " f"{sample['tile_count']} tiles, {sample['positive_tile_count']} positive, "
f"{sample['negative_tile_count']} negative, {sample['label_count']} labels" f"{sample['negative_tile_count']} negative, {sample['label_count']} labels, "
f"parsed labels {sample['parsed_label_count']}, "
f"median box area {format_optional_float(sample['median_box_area'])}, "
f"small-box share {sample['small_box_share']:.3f}, warnings {warning_text}"
) )
path.write_text("\n".join(lines) + "\n", encoding="utf-8") path.write_text("\n".join(lines) + "\n", encoding="utf-8")