Reject blank positive imagery in training loop
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-27 02:57:36 +02:00
parent c717201cb2
commit 70897a3265
9 changed files with 287 additions and 3 deletions
@@ -117,6 +117,27 @@ class OrthophotoAcquisitionService:
valid_from=datetime(2024, 4, 6, tzinfo=UTC), valid_from=datetime(2024, 4, 6, tzinfo=UTC),
valid_to=datetime(2024, 9, 21, 23, 59, 59, tzinfo=UTC), valid_to=datetime(2024, 9, 21, 23, 59, 59, tzinfo=UTC),
), ),
OrthophotoProduct(
key="wallonia_2023",
display_name="Zomerorthofoto Wallonië 2023",
observation_label="27 mei tot 25 juni 2023",
temporal_granularity="period",
native_resolution_m=0.25,
wms_url="https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_2023_ETE/MapServer/WMSServer",
layer="0",
catalog_url="https://geoportail.wallonie.be/catalogue/ad55c2ce-62ad-4c3c-b3cf-8fbc270a6b6e.html",
limitation_message="Officiële gebiedsdekkende SPW-zomercampagne 2023; exacte vliegdata zijn beschikbaar in het afzonderlijke maillage- en tuilageproduct.",
provider="spw_orthophoto",
source_label="SPW ORTHO_2023_ETE WMS",
attribution="Bron: Service public de Wallonie (SPW), Orthophotos 2023 Été",
license_note="CC BY 4.0; citeer SPW en vermeld wijzigingen.",
series_namespace="spw",
coverage_zone="wallonia",
supports_detection=True,
observed_at=datetime(2023, 5, 27, tzinfo=UTC),
valid_from=datetime(2023, 5, 27, tzinfo=UTC),
valid_to=datetime(2023, 6, 25, 23, 59, 59, tzinfo=UTC),
),
OrthophotoProduct( OrthophotoProduct(
key="brussels_latest", key="brussels_latest",
display_name="Meest recente orthofoto Brussel", display_name="Meest recente orthofoto Brussel",
@@ -0,0 +1,31 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
SCRIPT = Path(__file__).parents[2] / "scripts" / "run_belgium_building_training_loop.py"
SPEC = importlib.util.spec_from_file_location("training_loop", SCRIPT)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
def test_training_command_is_cuda_deterministic_and_bound_to_frozen_inputs(tmp_path: Path) -> None:
command = MODULE.training_command(
"yolo",
model=tmp_path / "base.pt",
data=tmp_path / "dataset.yaml",
project=tmp_path / "runs",
name="iteration-001",
epochs=160,
seed=42,
batch=2,
workers=4,
)
assert command[:2] == ["yolo", "train"]
assert "device=0" in command
assert "deterministic=True" in command
assert "seed=42" in command
assert "epochs=160" in command
assert f"data={tmp_path / 'dataset.yaml'}" in command
@@ -65,6 +65,7 @@ def test_operator_yolo_dataset_quality_audit_reports_dataset_risks(tmp_path: Pat
"label_count": 2, "label_count": 2,
"is_negative": False, "is_negative": False,
"is_repeated_background_negative": False, "is_repeated_background_negative": False,
"low_visual_variance": True,
}, },
{ {
"sample_slug": "postel_bos", "sample_slug": "postel_bos",
@@ -144,6 +145,7 @@ def test_operator_yolo_dataset_quality_audit_reports_dataset_risks(tmp_path: Pat
assert report["positive_sample_count"] == 2 assert report["positive_sample_count"] == 2
assert report["background_sample_count"] == 1 assert report["background_sample_count"] == 1
assert report["train_negative_tile_count"] == 2 assert report["train_negative_tile_count"] == 2
assert report["low_variance_positive_tile_count"] == 1
assert report["repeated_background_negative_tile_count"] == 1 assert report["repeated_background_negative_tile_count"] == 1
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
@@ -169,6 +171,7 @@ def test_operator_yolo_dataset_quality_audit_reports_dataset_risks(tmp_path: Pat
assert "repeated_background_negative_share_above_gate" in warning_codes assert "repeated_background_negative_share_above_gate" in warning_codes
assert "median_box_area_below_gate" in warning_codes assert "median_box_area_below_gate" in warning_codes
assert "small_box_share_above_gate" in warning_codes assert "small_box_share_above_gate" in warning_codes
assert "positive_tiles_have_low_visual_variance" in warning_codes
markdown = (output_dir / "operator_yolo_dataset_quality_audit.md").read_text(encoding="utf-8") markdown = (output_dir / "operator_yolo_dataset_quality_audit.md").read_text(encoding="utf-8")
assert "Operator YOLO Dataset Quality Audit" in markdown assert "Operator YOLO Dataset Quality Audit" in markdown
@@ -169,7 +169,7 @@ def test_orthophoto_product_registry_exposes_only_governed_official_layers() ->
assert {"2025", "2012", "2008_2011", "2000_2003", "1979_1990", "1971"}.issubset(keys) assert {"2025", "2012", "2008_2011", "2000_2003", "1979_1990", "1971"}.issubset(keys)
assert next(item for item in products if item["key"] == "most_recent")["supports_detection"] is True assert next(item for item in products if item["key"] == "most_recent")["supports_detection"] is True
detection_keys = {item["key"] for item in products if item["supports_detection"]} detection_keys = {item["key"] for item in products if item["supports_detection"]}
assert {"most_recent", "wallonia_latest", "wallonia_2024", "brussels_latest", "brussels_2025", "2025"} <= detection_keys assert {"most_recent", "wallonia_latest", "wallonia_2024", "wallonia_2023", "brussels_latest", "brussels_2025", "2025"} <= detection_keys
by_key = {item["key"]: item for item in products} by_key = {item["key"]: item for item in products}
assert by_key["wallonia_latest"]["provider"] == "spw_orthophoto" assert by_key["wallonia_latest"]["provider"] == "spw_orthophoto"
assert by_key["wallonia_latest"]["coverage_zone"] == "wallonia" assert by_key["wallonia_latest"]["coverage_zone"] == "wallonia"
+1
View File
@@ -126,6 +126,7 @@ COPY scripts/provision_belgium_building_training_portfolio.py /app/scripts/provi
COPY scripts/audit_belgium_building_corpus.py /app/scripts/audit_belgium_building_corpus.py COPY scripts/audit_belgium_building_corpus.py /app/scripts/audit_belgium_building_corpus.py
COPY scripts/evaluate_belgium_building_candidate.py /app/scripts/evaluate_belgium_building_candidate.py COPY scripts/evaluate_belgium_building_candidate.py /app/scripts/evaluate_belgium_building_candidate.py
COPY scripts/assess_belgium_building_training_iteration.py /app/scripts/assess_belgium_building_training_iteration.py COPY scripts/assess_belgium_building_training_iteration.py /app/scripts/assess_belgium_building_training_iteration.py
COPY scripts/run_belgium_building_training_loop.py /app/scripts/run_belgium_building_training_loop.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/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
+2
View File
@@ -74,6 +74,8 @@ The active production model remains unchanged while any gate fails.
- per-AOI evaluator: `scripts/evaluate_belgium_building_candidate.py`; - per-AOI evaluator: `scripts/evaluate_belgium_building_candidate.py`;
- calibration-only selection and release gates: - calibration-only selection and release gates:
`scripts/assess_belgium_building_training_iteration.py`. `scripts/assess_belgium_building_training_iteration.py`.
- checkpointed CUDA orchestration:
`scripts/run_belgium_building_training_loop.py`.
Every failed assessment returns `continue_training_loop`. Only a report with Every failed assessment returns `continue_training_loop`. Only a report with
`training_complete` may proceed to final human review and guarded activation. `training_complete` may proceed to final human review and guarded activation.
+17 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import statistics import statistics
from collections import Counter, defaultdict from collections import Counter
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -246,6 +246,7 @@ def build_audit(summary: dict[str, Any], summary_path: Path, args: argparse.Name
] ]
positive_tiles = [tile for tile in tiles if tile not in negative_tiles] positive_tiles = [tile for tile in tiles if tile not in negative_tiles]
train_negative_tiles = [tile for tile in negative_tiles if tile.get("split") == "train"] train_negative_tiles = [tile for tile in negative_tiles if tile.get("split") == "train"]
low_variance_positive_tiles = [tile for tile in positive_tiles if tile.get("low_visual_variance")]
val_positive_samples = { val_positive_samples = {
str(tile.get("sample_slug")) str(tile.get("sample_slug"))
for tile in positive_tiles for tile in positive_tiles
@@ -288,6 +289,15 @@ def build_audit(summary: dict[str, Any], summary_path: Path, args: argparse.Name
f"gate is {args.max_repeated_negative_share:.3f}." f"gate is {args.max_repeated_negative_share:.3f}."
), ),
) )
if low_variance_positive_tiles:
add_warning(
warnings,
"positive_tiles_have_low_visual_variance",
(
f"{len(low_variance_positive_tiles)} positive tiles are visually blank/low-variance; "
"the imagery source does not support their labels."
),
)
if label_stats["missing_label_file_count"]: if label_stats["missing_label_file_count"]:
add_warning( add_warning(
warnings, warnings,
@@ -337,6 +347,7 @@ def build_audit(summary: dict[str, Any], summary_path: Path, args: argparse.Name
"train_tile_count": split_counts.get("train", 0), "train_tile_count": split_counts.get("train", 0),
"val_tile_count": split_counts.get("val", 0), "val_tile_count": split_counts.get("val", 0),
"train_negative_tile_count": len(train_negative_tiles), "train_negative_tile_count": len(train_negative_tiles),
"low_variance_positive_tile_count": len(low_variance_positive_tiles),
"repeated_background_negative_tile_count": repeated_negative_count, "repeated_background_negative_tile_count": repeated_negative_count,
"repeated_background_negative_share_of_negatives": repeated_negative_share, "repeated_background_negative_share_of_negatives": repeated_negative_share,
"positive_tile_share": len(positive_tiles) / len(tiles) if tiles else 0.0, "positive_tile_share": len(positive_tiles) / len(tiles) if tiles else 0.0,
@@ -371,6 +382,10 @@ def build_recommendations(warnings: list[dict[str, str]]) -> list[str]:
) )
if "label_files_missing" in codes or "invalid_label_rows" in codes: if "label_files_missing" in codes or "invalid_label_rows" in codes:
recommendations.append("Regenerate the YOLO tile dataset and review exporter path/label integrity.") recommendations.append("Regenerate the YOLO tile dataset and review exporter path/label integrity.")
if "positive_tiles_have_low_visual_variance" in codes:
recommendations.append(
"Reject the raster product for affected AOIs or replace it with an officially complete imagery edition before training."
)
if not recommendations: if not recommendations:
recommendations.append("Dataset audit passed the configured gates; continue with benchmarked training.") recommendations.append("Dataset audit passed the configured gates; continue with benchmarked training.")
return recommendations return recommendations
@@ -389,6 +404,7 @@ def write_markdown(report: dict[str, Any], path: Path) -> None:
f"- Samples: {report['sample_count']} ({report['positive_sample_count']} positive, {report['background_sample_count']} background)", f"- Samples: {report['sample_count']} ({report['positive_sample_count']} positive, {report['background_sample_count']} background)",
f"- Repeated background negative share: {report['repeated_background_negative_share_of_negatives']:.3f}", f"- Repeated background negative share: {report['repeated_background_negative_share_of_negatives']:.3f}",
f"- Minimum visible label ratio: {format_optional_float(report.get('min_label_visible_ratio'))}", f"- Minimum visible label ratio: {format_optional_float(report.get('min_label_visible_ratio'))}",
f"- Blank/low-variance positive tiles: {report['low_variance_positive_tile_count']}",
"", "",
"## Label Quality", "## Label Quality",
"", "",
@@ -115,7 +115,7 @@ REGION_CONTRACT = {
}, },
"wallonia": { "wallonia": {
"area_id": "e5fd742a-ca18-4520-abe0-d28416aa2ece", "area_id": "e5fd742a-ca18-4520-abe0-d28416aa2ece",
"orthophoto_product": "wallonia_2024", "orthophoto_product": "wallonia_2023",
"reference_path": "datasets/official-vector/acquire", "reference_path": "datasets/official-vector/acquire",
"reference_product": "spw_picc_buildings", "reference_product": "spw_picc_buildings",
}, },
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Run checkpointed CUDA train/evaluate iterations until gates pass or a batch yields."""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import subprocess
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_json(path: Path, value: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(value, indent=2), encoding="utf-8")
temporary.replace(path)
def training_command(
yolo: str,
*,
model: Path,
data: Path,
project: Path,
name: str,
epochs: int,
seed: int,
batch: int,
workers: int,
) -> list[str]:
return [
yolo,
"train",
f"model={model}",
f"data={data}",
f"epochs={epochs}",
"imgsz=640",
f"batch={batch}",
"device=0",
f"workers={workers}",
"patience=35",
"cache=disk",
"close_mosaic=20",
f"seed={seed}",
"deterministic=True",
f"project={project}",
f"name={name}",
"exist_ok=True",
]
def run(command: list[str], log_path: Path | None = None, *, allowed: set[int] = {0}) -> int:
if log_path:
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open("a", encoding="utf-8") as log:
completed = subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, check=False)
else:
completed = subprocess.run(command, check=False)
if completed.returncode not in allowed:
raise RuntimeError(f"Command failed ({completed.returncode}): {' '.join(command)}")
return completed.returncode
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--initial-model", type=Path, required=True)
parser.add_argument("--train-yaml", type=Path, required=True)
parser.add_argument("--calibration-summary", type=Path, required=True)
parser.add_argument("--test-summary", type=Path, required=True)
parser.add_argument("--background-summary", type=Path, required=True)
parser.add_argument("--corpus-manifest", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--iterations", type=int, default=1)
parser.add_argument("--epochs", type=int, default=160)
parser.add_argument("--batch", type=int, default=2)
parser.add_argument("--workers", type=int, default=4)
parser.add_argument("--seed", type=int, default=20260731)
parser.add_argument("--yolo", default="yolo")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
if args.iterations < 1:
raise SystemExit("--iterations must be positive")
state_path = args.output_dir / "training-loop-state.json"
state: dict[str, Any] = {
"schema_version": 1,
"status": "running",
"started_at": datetime.now(UTC).isoformat(),
"initial_model": str(args.initial_model),
"train_yaml": str(args.train_yaml),
"corpus_manifest": str(args.corpus_manifest),
"iterations": [],
}
if state_path.is_file():
state = json.loads(state_path.read_text(encoding="utf-8"))
state["status"] = "running"
model = Path(state.get("next_model") or args.initial_model)
first_index = len(state["iterations"]) + 1
scripts_dir = Path(__file__).resolve().parent
for offset in range(args.iterations):
index = first_index + offset
name = f"iteration-{index:03d}"
iteration_dir = args.output_dir / name
train_run = args.output_dir / "runs" / name
command = training_command(
args.yolo,
model=model,
data=args.train_yaml,
project=args.output_dir / "runs",
name=name,
epochs=args.epochs,
seed=args.seed + index,
batch=args.batch,
workers=args.workers,
)
if args.dry_run:
print(json.dumps({"training_command": command}, indent=2))
return 0
run(command, iteration_dir / "training.log")
best = train_run / "weights" / "best.pt"
if not best.is_file():
raise RuntimeError(f"Training produced no best checkpoint: {best}")
candidate = iteration_dir / "candidate.pt"
shutil.copy2(best, candidate)
reports: dict[str, Path] = {}
for role, summary in (
("calibration", args.calibration_summary),
("test", args.test_summary),
("background", args.background_summary),
):
report = iteration_dir / f"{role}.json"
reports[role] = report
run(
[
sys.executable,
str(scripts_dir / "evaluate_belgium_building_candidate.py"),
"--model",
str(candidate),
"--summary",
str(summary),
"--corpus-manifest",
str(args.corpus_manifest),
"--output",
str(report),
"--device",
"cuda:0",
],
iteration_dir / f"{role}.log",
)
assessment = iteration_dir / "assessment.json"
run(
[
sys.executable,
str(scripts_dir / "assess_belgium_building_training_iteration.py"),
"--calibration",
str(reports["calibration"]),
"--test",
str(reports["test"]),
"--background",
str(reports["background"]),
"--output",
str(assessment),
],
iteration_dir / "assessment.log",
allowed={0, 2},
)
decision = json.loads(assessment.read_text(encoding="utf-8"))
record = {
"iteration": index,
"candidate": str(candidate),
"candidate_sha256": sha256(candidate),
"assessment": str(assessment),
"status": decision["status"],
"failures": decision["failures"],
}
state["iterations"].append(record)
state["next_model"] = str(candidate)
if decision["status"] == "training_complete":
state["status"] = "training_complete"
state["completed_at"] = datetime.now(UTC).isoformat()
write_json(state_path, state)
print(json.dumps(state, indent=2))
return 0
model = candidate
write_json(state_path, state)
state["status"] = "continue_training_loop"
state["yielded_at"] = datetime.now(UTC).isoformat()
write_json(state_path, state)
print(json.dumps(state, indent=2))
return 2
if __name__ == "__main__":
raise SystemExit(main())