Expand YOLO training AOIs safely
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-07-12 23:42:29 +02:00
parent 53cd38a5b2
commit 0f49c980ba
11 changed files with 310 additions and 7 deletions
+37
View File
@@ -405,6 +405,43 @@ This refreshed cleanpx dataset is the minimum pre-training baseline after the
visual contact-sheet pass found six blank-looking `arendonk_heide` validation
negatives in the older export.
The persisted false-negative audit subsequently showed that the cleanpx12
candidate still misses about 79-92% of the comparable reference population and
misses every reference building below 25 m2 in the seven-AOI review. Do not
train another candidate from the same four positive training AOIs. Refresh the
existing AOI1024 sample directory after pulling Sprint 171; existing files are
reused and only the four new explicit positive AOIs need to be fetched:
```bash
docker exec -it geointel python3 /app/scripts/prepare_operator_real_data_samples.py \
--output-dir /app/storage/operator-data/operator-samples-1024 \
--width 1024 \
--height 1024 \
--half-size-scale 2
```
The expansion adds Olen, Lille, Oud-Turnhout and Kasterlee center as training
samples. Turnhout, Retie, Westerlo and Arendonk-heide remain explicit validation
holdouts in generated manifest provenance. The tile exporter defaults to those
four holdouts and rejects a manifest-aware split that leaks one into training.
Use the lower `min-label-px=4` profile first to measure small-building retention;
it remains subject to dataset audit and visual contact-sheet review before any
training:
```bash
docker exec -it geointel python3 /app/scripts/export_operator_yolo_tile_dataset.py \
--manifest-path /app/storage/operator-data/operator-samples-1024/operator_samples_manifest.json \
--output-dir /app/storage/operator-data/yolo-building-aoi1024-expanded-minpx4vis035 \
--tile-size 512 \
--stride 256 \
--negative-keep-ratio 1.0 \
--min-label-px 4 \
--min-label-visible-ratio 0.35 \
--drop-low-variance-negatives \
--blank-range-threshold 3 \
--force
```
Then audit with stricter small-box gates:
```bash
@@ -116,6 +116,7 @@ def audit_feature_collection(payload: dict[str, Any], geod: Any, shape: Any) ->
raise SystemExit("Evidence GeoJSON must be a FeatureCollection")
features = payload.get("features") or []
false_negative_ids: set[str] = set()
reference_ids: set[str] = set()
false_negative_areas: list[float] = []
matched_reference_areas: list[float] = []
bucket_counts = {
@@ -141,11 +142,13 @@ def audit_feature_collection(payload: dict[str, Any], geod: Any, shape: Any) ->
)
area_m2 = abs(float(geod.geometry_area_perimeter(geometry)[0]))
bucket = area_bucket(area_m2)
reference_id = stable_reference_id(feature, geometry)
reference_ids.add(reference_id)
bucket_role = "false_negative" if role == "false_negative" else "matched_reference"
bucket_counts[bucket][bucket_role] += 1
bucket_counts[bucket]["total_reference"] += 1
if role == "false_negative":
false_negative_ids.add(stable_reference_id(feature, geometry))
false_negative_ids.add(reference_id)
false_negative_areas.append(area_m2)
else:
matched_reference_areas.append(area_m2)
@@ -157,6 +160,7 @@ def audit_feature_collection(payload: dict[str, Any], geod: Any, shape: Any) ->
total_reference = len(false_negative_areas) + len(matched_reference_areas)
return {
"false_negative_ids": false_negative_ids,
"reference_ids": reference_ids,
"false_negative_count": len(false_negative_areas),
"matched_reference_count": len(matched_reference_areas),
"total_reference_count": total_reference,
@@ -284,16 +288,31 @@ def run_audit(portfolio_args: list[str], output_dir: Path) -> tuple[Path, Path]:
for sample_slug in sorted(expected_slugs):
portfolio_rows = []
false_negative_sets = []
reference_sets = []
for label, _ in parsed_portfolios:
raw = portfolio_samples[label][sample_slug]
false_negative_sets.append(raw["false_negative_ids"])
reference_sets.append(raw["reference_ids"])
portfolio_rows.append(
{key: value for key, value in raw.items() if key != "false_negative_ids"}
{
key: value
for key, value in raw.items()
if key not in {"false_negative_ids", "reference_ids"}
}
)
if any(reference_ids != reference_sets[0] for reference_ids in reference_sets[1:]):
counts = ", ".join(
f"{label}={len(reference_ids)}"
for (label, _), reference_ids in zip(parsed_portfolios, reference_sets, strict=True)
)
raise SystemExit(
f"Sample {sample_slug} has different reference populations across portfolios ({counts})"
)
persistent_ids = sorted(set.intersection(*false_negative_sets))
sample_reports.append(
{
"sample_slug": sample_slug,
"reference_population_count": len(reference_sets[0]),
"persistent_false_negative_count": len(persistent_ids),
"persistent_reference_ids": persistent_ids,
"portfolios": portfolio_rows,
+46 -3
View File
@@ -25,6 +25,10 @@ REFERENCE_AOI_CATEGORY = "reference_aoi"
PURE_EMPTY_BACKGROUND_CATEGORY = "pure_empty_negative"
SPARSE_BACKGROUND_CATEGORY = "sparse_building_context"
LOW_VARIANCE_NEGATIVE_SKIP_REASON = "low_visual_variance_negative"
DEFAULT_VALIDATION_SAMPLE_SLUGS = frozenset(
{"turnhout", "retie", "westerlo", "arendonk_heide"}
)
DEFAULT_VALIDATION_SAMPLES = ",".join(sorted(DEFAULT_VALIDATION_SAMPLE_SLUGS))
rasterio: Any = None
Window: Any = None
Transformer: Any = None
@@ -67,8 +71,11 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--stride", type=int, default=int(os.environ.get("OPERATOR_YOLO_TILE_STRIDE", "128")))
parser.add_argument(
"--val-samples",
default=os.environ.get("OPERATOR_YOLO_VAL_SAMPLES", "turnhout"),
help="Comma/space separated sample slugs assigned to validation. Defaults to turnhout.",
default=os.environ.get("OPERATOR_YOLO_VAL_SAMPLES", DEFAULT_VALIDATION_SAMPLES),
help=(
"Comma/space separated sample slugs assigned to validation. "
"Defaults to the documented Turnhout, Retie, Westerlo and Arendonk-heide holdouts."
),
)
parser.add_argument(
"--negative-keep-ratio",
@@ -145,6 +152,35 @@ def split_slugs(raw: str) -> set[str]:
return {value.strip().lower() for value in raw.replace(",", " ").split() if value.strip()}
def validate_validation_split(samples: list[dict[str, Any]], val_slugs: set[str]) -> set[str]:
sample_slugs = {
str(sample.get("sample_slug") or "").strip().lower()
for sample in samples
if str(sample.get("sample_slug") or "").strip()
}
if not val_slugs:
raise SystemExit("YOLO validation split must include at least one sample")
unknown = val_slugs - sample_slugs
if unknown:
raise SystemExit(
"YOLO validation split references unknown samples: " + ", ".join(sorted(unknown))
)
recommended_holdouts = {
str(sample.get("sample_slug") or "").strip().lower()
for sample in samples
if str(sample.get("recommended_split") or "").strip().lower() == "val"
}
missing_holdouts = recommended_holdouts - val_slugs
if missing_holdouts:
raise SystemExit(
"YOLO validation split omits recommended validation holdouts: "
+ ", ".join(sorted(missing_holdouts))
)
if not sample_slugs - val_slugs:
raise SystemExit("YOLO validation split leaves no training samples")
return val_slugs
def edge_starts(length: int, tile_size: int, stride: int) -> list[int]:
if tile_size <= 0:
raise ValueError("tile_size must be positive")
@@ -367,6 +403,7 @@ def export_sample_tiles(
sample_slug = str(sample["sample_slug"])
sample_role = str(sample.get("sample_role") or "reference")
background_category = background_category_for_sample(sample)
recommended_split = str(sample.get("recommended_split") or "")
split = "val" if sample_slug.lower() in val_slugs else "train"
raster_path = resolve_manifest_path(str(sample["raster_path"]), manifest_path)
reference_path = resolve_manifest_path(str(sample["reference_path"]), manifest_path)
@@ -390,6 +427,9 @@ def export_sample_tiles(
exported.append(
{
"sample_slug": sample_slug,
"sample_role": sample_role,
"background_category": background_category,
"recommended_split": recommended_split,
"split": split,
"tile_index": tile_index,
"kept": False,
@@ -410,6 +450,7 @@ def export_sample_tiles(
"sample_slug": sample_slug,
"sample_role": sample_role,
"background_category": background_category,
"recommended_split": recommended_split,
"split": split,
"tile_index": tile_index,
"kept": False,
@@ -446,6 +487,7 @@ def export_sample_tiles(
"sample_slug": sample_slug,
"sample_role": sample_role,
"background_category": background_category,
"recommended_split": recommended_split,
"split": split,
"tile_index": tile_index,
"repeat_index": repeat_index,
@@ -479,7 +521,7 @@ def main() -> int:
samples = manifest.get("samples") or []
if not samples:
raise SystemExit("Operator sample manifest contains no samples")
val_slugs = split_slugs(args.val_samples)
val_slugs = validate_validation_split(samples, split_slugs(args.val_samples))
exported_tiles: list[dict[str, Any]] = []
for sample in samples:
exported_tiles.extend(
@@ -527,6 +569,7 @@ def main() -> int:
"drop_low_variance_negatives": args.drop_low_variance_negatives,
"blank_range_threshold": args.blank_range_threshold,
"source_sample_count": len(samples),
"validation_sample_slugs": sorted(val_slugs),
"tile_count": len(kept_tiles),
"positive_tile_count": len(positive_tiles),
"negative_tile_count": len(negative_tiles),
+42 -2
View File
@@ -25,6 +25,12 @@ DEFAULT_GRB_MAX_FEATURES = 100000
REFERENCE_AOI_CATEGORY = "reference_aoi"
PURE_EMPTY_BACKGROUND_CATEGORY = "pure_empty_negative"
SPARSE_BACKGROUND_CATEGORY = "sparse_building_context"
TRAINING_EXPANSION_SAMPLE_SLUGS = frozenset(
{"olen_center", "lille_center", "oud_turnhout_center", "kasterlee_center"}
)
DEFAULT_VALIDATION_SAMPLE_SLUGS = frozenset(
{"turnhout", "retie", "westerlo", "arendonk_heide"}
)
requests: Any = None
rasterio: Any = None
Transformer: Any = None
@@ -92,6 +98,30 @@ SAMPLES: dict[str, OperatorSample] = {
center_lat=51.0909,
half_size_m=220.0,
),
"olen_center": OperatorSample(
slug="olen_center",
display_name="Olen center training expansion",
center_lon=4.8597257,
center_lat=51.1438611,
),
"lille_center": OperatorSample(
slug="lille_center",
display_name="Lille center training expansion",
center_lon=4.8242404,
center_lat=51.2382180,
),
"oud_turnhout_center": OperatorSample(
slug="oud_turnhout_center",
display_name="Oud-Turnhout center training expansion",
center_lon=4.9817086,
center_lat=51.3178319,
),
"kasterlee_center": OperatorSample(
slug="kasterlee_center",
display_name="Kasterlee center training expansion",
center_lon=4.9678120,
center_lat=51.2407915,
),
"postel_bos": OperatorSample(
slug="postel_bos",
display_name="Postel forest background candidate",
@@ -270,6 +300,10 @@ def background_category_for_sample(sample: OperatorSample, reference_feature_cou
return PURE_EMPTY_BACKGROUND_CATEGORY if reference_feature_count <= 0 else SPARSE_BACKGROUND_CATEGORY
def recommended_split_for_sample(sample: OperatorSample) -> str:
return "val" if sample.slug in DEFAULT_VALIDATION_SAMPLE_SLUGS else "train"
def apply_sample_overrides(
sample: OperatorSample,
*,
@@ -481,6 +515,7 @@ def fetch_reference(
reference["sample_role"] = sample.sample_role
reference["allow_empty_reference"] = sample.allow_empty_reference
reference["background_category"] = background_category_for_sample(sample, len(features))
reference["recommended_split"] = recommended_split_for_sample(sample)
reference["reference_page_limit"] = page_limit
reference["reference_max_features"] = max_features
reference["reference_pages_fetched"] = len(pages)
@@ -495,6 +530,7 @@ def fetch_reference(
props.setdefault("sample_slug", sample.slug)
props.setdefault("sample_role", sample.sample_role)
props.setdefault("background_category", background_category_for_sample(sample, len(features)))
props.setdefault("recommended_split", recommended_split_for_sample(sample))
reference_path.write_text(json.dumps(reference, ensure_ascii=False), encoding="utf-8")
return prepared_url(GRB_GBG_URL, ogc_params), len(features)
@@ -537,6 +573,7 @@ def prepare_sample(
"sample_role": sample.sample_role,
"allow_empty_reference": sample.allow_empty_reference,
"background_category": background_category,
"recommended_split": recommended_split_for_sample(sample),
"raster_path": str(ortho_path),
"reference_path": str(reference_path),
"reference_feature_count": reference_feature_count,
@@ -572,7 +609,8 @@ def write_readme(output_dir: Path, samples: list[dict[str, Any]]) -> None:
f"- `{sample['sample_slug']}`: `{Path(sample['raster_path']).name}` and "
f"`{Path(sample['reference_path']).name}`, "
f"{sample['reference_feature_count']} reference features, role `{sample['sample_role']}`, "
f"background category `{sample['background_category']}`."
f"background category `{sample['background_category']}`, "
f"recommended split `{sample['recommended_split']}`."
)
lines.append("")
lines.append("Purpose: configured-YOLO detection + persisted QA/QC validation with operator-provided files.")
@@ -602,7 +640,7 @@ def main() -> int:
write_readme(output_dir, samples)
manifest = {
"schema_version": 1,
"schema_version": 2,
"description": "GeoIntel operator real-data samples for configured-YOLO QA validation.",
"output_dir": str(output_dir),
"sample_width": args.width,
@@ -610,6 +648,8 @@ def main() -> int:
"half_size_scale": args.half_size_scale,
"reference_page_limit": args.reference_page_limit,
"reference_max_features": args.reference_max_features,
"default_validation_sample_slugs": sorted(DEFAULT_VALIDATION_SAMPLE_SLUGS),
"training_expansion_sample_slugs": sorted(TRAINING_EXPANSION_SAMPLE_SLUGS),
"samples": samples,
}
manifest_path = output_dir / args.manifest_name