Add leak-free regional YOLO dataset builder
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-30 01:24:37 +02:00
parent c2859e5919
commit 410959cacd
2 changed files with 152 additions and 32 deletions
+105 -32
View File
@@ -1,15 +1,19 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Create a checksummed regional YOLO view without copying protected data.""" """Build a leak-free regional YOLO view over an immutable tiled corpus."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import hashlib import hashlib
import json import json
from collections import Counter
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
PROTECTED_SPLITS = {"calibration", "test", "background-test"}
def sha256(path: Path) -> str: def sha256(path: Path) -> str:
digest = hashlib.sha256() digest = hashlib.sha256()
with path.open("rb") as stream: with path.open("rb") as stream:
@@ -18,21 +22,80 @@ def sha256(path: Path) -> str:
return digest.hexdigest() return digest.hexdigest()
def select_paths(summary: dict[str, Any], manifest: dict[str, Any], region: str) -> tuple[list[str], list[str]]: def write_lines(path: Path, values: list[str]) -> None:
path.write_text("".join(f"{value}\n" for value in values), encoding="utf-8")
def build(
*,
summary: dict[str, Any],
manifest: dict[str, Any],
region: str,
priority_contexts: set[str],
priority_repeat: int,
negative_repeat: int,
) -> tuple[list[str], list[str], dict[str, Any]]:
samples = {item["sample_slug"]: item for item in manifest["samples"]} samples = {item["sample_slug"]: item for item in manifest["samples"]}
selected: list[tuple[dict[str, Any], dict[str, Any]]] = []
protected: list[str] = []
unknown: list[str] = []
for tile in summary["tiles"]:
if not tile.get("kept", True):
continue
sample = samples.get(tile["sample_slug"])
if sample is None:
unknown.append(tile["sample_slug"])
continue
split = str(tile.get("split") or sample.get("split") or "")
if split in PROTECTED_SPLITS:
protected.append(tile["sample_slug"])
continue
if sample.get("region") == region and split in {"train", "val"}:
selected.append((tile, sample))
if unknown:
raise ValueError(f"summary references unknown samples: {sorted(set(unknown))}")
if protected:
raise ValueError(f"summary contains protected tiles: {sorted(set(protected))}")
train: list[str] = [] train: list[str] = []
val: list[str] = [] val: list[str] = []
for tile in summary["tiles"]: sample_counts: Counter[str] = Counter()
sample = samples[tile["sample_slug"]] context_counts: Counter[str] = Counter()
if sample["region"] != region or not tile.get("kept", True): negative_count = 0
for tile, sample in selected:
image_path = str(tile["image_path"])
split = str(tile.get("split") or sample.get("split"))
if split == "val":
val.append(image_path)
continue continue
if sample["split"] == "train" and tile["split"] == "train": context = str(sample.get("context") or "unknown")
train.append(tile["image_path"]) is_negative = bool(tile.get("is_negative"))
elif sample["split"] == "val" and tile["split"] == "val": repeat = negative_repeat if is_negative else priority_repeat if context in priority_contexts else 1
val.append(tile["image_path"]) train.extend([image_path] * repeat)
sample_counts[sample["sample_slug"]] += repeat
context_counts[context] += repeat
negative_count += repeat if is_negative else 0
if not train or not val: if not train or not val:
raise ValueError(f"Region {region!r} must contain train and validation images") raise ValueError(f"regional dataset requires non-empty train and val lists: {region}")
return sorted(train), sorted(val) if set(train) & set(val):
raise ValueError("regional train/validation image leakage")
evidence = {
"schema_version": 1,
"status": "ok",
"region": region,
"priority_contexts": sorted(priority_contexts),
"priority_repeat": priority_repeat,
"negative_repeat": negative_repeat,
"train_entry_count": len(train),
"train_unique_image_count": len(set(train)),
"validation_image_count": len(val),
"negative_train_entry_count": negative_count,
"sample_entry_counts": dict(sorted(sample_counts.items())),
"context_entry_counts": dict(sorted(context_counts.items())),
"protected_samples_in_training": [],
"train_validation_overlap": [],
}
return train, val, evidence
def main() -> int: def main() -> int:
@@ -41,34 +104,44 @@ def main() -> int:
parser.add_argument("--corpus-manifest", type=Path, required=True) parser.add_argument("--corpus-manifest", type=Path, required=True)
parser.add_argument("--region", required=True) parser.add_argument("--region", required=True)
parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--priority-context", action="append", default=[])
parser.add_argument("--priority-repeat", type=int, default=2)
parser.add_argument("--negative-repeat", type=int, default=2)
args = parser.parse_args() args = parser.parse_args()
if args.priority_repeat < 1 or args.negative_repeat < 1:
parser.error("repeat counts must be positive")
summary = json.loads(args.summary.read_text(encoding="utf-8")) summary = json.loads(args.summary.read_text(encoding="utf-8"))
manifest = json.loads(args.corpus_manifest.read_text(encoding="utf-8")) manifest = json.loads(args.corpus_manifest.read_text(encoding="utf-8"))
train, val = select_paths(summary, manifest, args.region) train, val, evidence = build(
args.output_dir.mkdir(parents=True, exist_ok=True) summary=summary,
train_list = args.output_dir / "train.txt" manifest=manifest,
val_list = args.output_dir / "val.txt" region=args.region,
train_list.write_text("\n".join(train) + "\n", encoding="utf-8") priority_contexts=set(args.priority_context),
val_list.write_text("\n".join(val) + "\n", encoding="utf-8") priority_repeat=args.priority_repeat,
dataset_yaml = args.output_dir / "dataset.yaml" negative_repeat=args.negative_repeat,
dataset_yaml.write_text( )
f"path: {args.output_dir}\ntrain: {train_list}\nval: {val_list}\nnames:\n 0: building\n", args.output_dir.mkdir(parents=True, exist_ok=False)
train_path = args.output_dir / "train.txt"
val_path = args.output_dir / "val.txt"
write_lines(train_path, train)
write_lines(val_path, val)
yaml_path = args.output_dir / "dataset.yaml"
yaml_path.write_text(
f"path: /\ntrain: {train_path}\nval: {val_path}\nnames:\n 0: building\n",
encoding="utf-8", encoding="utf-8",
) )
evidence = { evidence.update({
"schema_version": 1, "source_summary": str(args.summary),
"status": "ok", "source_summary_sha256": sha256(args.summary),
"region": args.region,
"train_image_count": len(train),
"validation_image_count": len(val),
"summary": str(args.summary),
"summary_sha256": sha256(args.summary),
"corpus_manifest": str(args.corpus_manifest), "corpus_manifest": str(args.corpus_manifest),
"corpus_manifest_sha256": sha256(args.corpus_manifest), "corpus_manifest_sha256": sha256(args.corpus_manifest),
"dataset_yaml": str(dataset_yaml), "train_sha256": sha256(train_path),
"protected_splits_in_training": [], "validation_sha256": sha256(val_path),
} "dataset_yaml": str(yaml_path),
(args.output_dir / "regional-dataset.json").write_text(json.dumps(evidence, indent=2), encoding="utf-8") })
(args.output_dir / "regional-dataset-evidence.json").write_text(
json.dumps(evidence, indent=2), encoding="utf-8"
)
print(json.dumps(evidence, indent=2)) print(json.dumps(evidence, indent=2))
return 0 return 0
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "build_regional_yolo_dataset.py"
SPEC = importlib.util.spec_from_file_location("build_regional_yolo_dataset", SCRIPT)
assert SPEC and SPEC.loader
module = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(module)
def test_regional_dataset_balances_contexts_and_keeps_validation_closed() -> None:
manifest = {"samples": [
{"sample_slug": "f-train", "region": "flanders", "split": "train", "context": "ribbon"},
{"sample_slug": "f-val", "region": "flanders", "split": "val", "context": "mixed"},
{"sample_slug": "w-train", "region": "wallonia", "split": "train", "context": "ribbon"},
]}
summary = {"tiles": [
{"sample_slug": "f-train", "split": "train", "image_path": "/f-pos.png", "kept": True},
{"sample_slug": "f-train", "split": "train", "image_path": "/f-neg.png", "is_negative": True},
{"sample_slug": "f-val", "split": "val", "image_path": "/f-val.png"},
{"sample_slug": "w-train", "split": "train", "image_path": "/w.png"},
]}
train, val, evidence = module.build(
summary=summary, manifest=manifest, region="flanders",
priority_contexts={"ribbon"}, priority_repeat=3, negative_repeat=2,
)
assert train == ["/f-pos.png"] * 3 + ["/f-neg.png"] * 2
assert val == ["/f-val.png"]
assert evidence["negative_train_entry_count"] == 2
assert evidence["protected_samples_in_training"] == []
def test_regional_dataset_rejects_protected_tiles() -> None:
manifest = {"samples": [
{"sample_slug": "f-cal", "region": "flanders", "split": "calibration", "context": "ribbon"},
]}
summary = {"tiles": [{"sample_slug": "f-cal", "split": "calibration", "image_path": "/cal.png"}]}
with pytest.raises(ValueError, match="protected"):
module.build(
summary=summary, manifest=manifest, region="flanders",
priority_contexts=set(), priority_repeat=1, negative_repeat=1,
)