Build governed Belgian training corpus pipeline
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-26 22:14:28 +02:00
parent 6323885a8d
commit 68d3fa34e3
5 changed files with 447 additions and 2 deletions
@@ -0,0 +1,71 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import numpy as np
import rasterio
from rasterio.transform import from_origin
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = ROOT / "scripts" / "normalize_belgium_building_labels.py"
SPEC = importlib.util.spec_from_file_location("normalize_buildings", SCRIPT)
assert SPEC and SPEC.loader
module = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(module)
def test_normalizer_retains_native_identity_and_records_rejections(tmp_path: Path) -> None:
raster_path = tmp_path / "image.tif"
with rasterio.open(
raster_path,
"w",
driver="GTiff",
width=100,
height=100,
count=3,
dtype="uint8",
crs="EPSG:4326",
transform=from_origin(4.0, 51.0, 0.001, 0.001),
) as dataset:
dataset.write(np.zeros((3, 100, 100), dtype="uint8"))
valid = {
"type": "Feature",
"id": "native-1",
"properties": {"TYPE": "main building"},
"geometry": {"type": "Polygon", "coordinates": [[[4.01, 50.99], [4.02, 50.99], [4.02, 50.98], [4.01, 50.98], [4.01, 50.99]]]},
}
duplicate = json.loads(json.dumps(valid))
duplicate["id"] = "native-2"
canopy = json.loads(json.dumps(valid))
canopy["id"] = "native-3"
canopy["properties"]["TYPE"] = "canopy"
tiny = json.loads(json.dumps(valid))
tiny["id"] = "native-4"
tiny["geometry"] = {"type": "Polygon", "coordinates": [[[4.03, 50.97], [4.031, 50.97], [4.031, 50.969], [4.03, 50.969], [4.03, 50.97]]]}
reference_path = tmp_path / "reference.geojson"
reference_path.write_text(json.dumps({"type": "FeatureCollection", "features": [valid, duplicate, canopy, tiny]}), encoding="utf-8")
normalized, audit = module.normalize(
reference_path=reference_path,
raster_path=raster_path,
source_name="urbis",
min_label_px=3,
imagery_observed_at="2026-01-01T00:00:00Z",
reference_observed_at="2025-12-01T00:00:00Z",
)
assert len(normalized["features"]) == 1
properties = normalized["features"][0]["properties"]
assert properties["canonical_class"] == "building"
assert properties["source_name"] == "urbis"
assert properties["source_feature_id"] == "native-1"
assert properties["source_class"] == "main building"
assert audit["decision_counts"] == {
"accepted": 1,
"below_resolvable_pixel_size": 1,
"duplicate_geometry": 1,
"excluded_canopy": 1,
}
assert audit["temporal_mismatch_days"] == 31
+2
View File
@@ -120,6 +120,8 @@ COPY scripts/provision_regional_grb_context.py /app/scripts/provision_regional_g
COPY scripts/audit_source_freshness.py /app/scripts/audit_source_freshness.py COPY scripts/audit_source_freshness.py /app/scripts/audit_source_freshness.py
COPY scripts/manage_grb_refresh.py /app/scripts/manage_grb_refresh.py COPY scripts/manage_grb_refresh.py /app/scripts/manage_grb_refresh.py
COPY scripts/export_operator_yolo_tile_dataset.py /app/scripts/export_operator_yolo_tile_dataset.py COPY scripts/export_operator_yolo_tile_dataset.py /app/scripts/export_operator_yolo_tile_dataset.py
COPY scripts/normalize_belgium_building_labels.py /app/scripts/normalize_belgium_building_labels.py
COPY scripts/assemble_belgium_building_corpus.py /app/scripts/assemble_belgium_building_corpus.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
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Assemble immutable detector input pairs from persisted governed Datasets."""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
from pathlib import Path
from typing import Any
from uuid import UUID
from app.db.session import SessionLocal
from app.models import Dataset
from normalize_belgium_building_labels import normalize
REGION_SOURCES = {
"flanders": ("digitaal_vlaanderen_orthophoto", "grb"),
"wallonia": ("spw_orthophoto", "spw_picc"),
"brussels": ("urbis_orthophoto", "urbis"),
}
SPLITS = {"train", "val", "calibration", "test", "background-test"}
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 _dataset_path(dataset: Dataset) -> Path:
if not dataset.storage_path:
raise SystemExit(f"Dataset {dataset.id} has no persisted storage path")
path = Path(dataset.storage_path)
if not path.is_file():
raise SystemExit(f"Dataset {dataset.id} artifact is unreadable: {path}")
return path
def _validate_pair(sample: dict[str, Any], raster: Dataset, reference: Dataset) -> tuple[str, str]:
region = str(sample.get("region") or "").lower()
if region not in REGION_SOURCES:
raise SystemExit(f"Unsupported region for {sample.get('sample_slug')}: {region}")
expected_raster, expected_reference = REGION_SOURCES[region]
if raster.source_name != expected_raster:
raise SystemExit(f"Raster provider mismatch for {sample['sample_slug']}: {raster.source_name}")
if reference.source_name != expected_reference or reference.reference_layer_name != "buildings":
raise SystemExit(f"Reference provider/layer mismatch for {sample['sample_slug']}")
split = str(sample.get("split") or "")
if split not in SPLITS:
raise SystemExit(f"Unsupported split for {sample['sample_slug']}: {split}")
if raster.status != "ready" or reference.status != "ready":
raise SystemExit(f"Dataset pair is not ready for {sample['sample_slug']}")
return region, expected_reference
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--spec", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--version", default="building-be-v1")
parser.add_argument("--min-label-px", type=float, default=3.0)
parser.add_argument("--freeze", action="store_true")
args = parser.parse_args()
spec = json.loads(args.spec.read_text(encoding="utf-8-sig"))
samples = spec.get("samples")
if not isinstance(samples, list) or not samples:
raise SystemExit("Corpus spec must contain at least one sample")
output_dir = args.output_dir.resolve()
if output_dir.exists() and any(output_dir.iterdir()):
raise SystemExit(f"Refusing to overwrite non-empty corpus directory: {output_dir}")
output_dir.mkdir(parents=True, exist_ok=True)
pairs_dir = output_dir / "pairs"
pairs_dir.mkdir()
manifest_samples: list[dict[str, Any]] = []
seen_slugs: set[str] = set()
with SessionLocal() as db:
for sample in samples:
slug = str(sample.get("sample_slug") or "").strip()
if not slug or slug in seen_slugs:
raise SystemExit(f"Missing or duplicate sample_slug: {slug}")
seen_slugs.add(slug)
raster = db.get(Dataset, UUID(str(sample["raster_dataset_id"])))
reference = db.get(Dataset, UUID(str(sample["reference_dataset_id"])))
if raster is None or reference is None:
raise SystemExit(f"Persisted Dataset pair not found for {slug}")
region, reference_source = _validate_pair(sample, raster, reference)
raster_source = _dataset_path(raster)
reference_source_path = _dataset_path(reference)
sample_dir = pairs_dir / slug
sample_dir.mkdir()
raster_target = sample_dir / "image.tif"
normalized_target = sample_dir / "buildings.normalized.geojson"
audit_target = sample_dir / "label-audit.json"
shutil.copyfile(raster_source, raster_target)
normalized, audit = normalize(
reference_path=reference_source_path,
raster_path=raster_target,
source_name=reference_source,
min_label_px=args.min_label_px,
imagery_observed_at=raster.observed_at.isoformat() if raster.observed_at else None,
reference_observed_at=reference.observed_at.isoformat() if reference.observed_at else None,
)
normalized_target.write_text(json.dumps(normalized, ensure_ascii=False), encoding="utf-8")
audit_target.write_text(json.dumps(audit, ensure_ascii=False, indent=2), encoding="utf-8")
manifest_samples.append(
{
"sample_slug": slug,
"sample_role": sample.get("sample_role", "positive"),
"region": region,
"context": sample.get("context"),
"split": sample["split"],
"raster_path": str(raster_target),
"reference_path": str(normalized_target),
"reference_source": reference_source,
"reference_layer": "buildings",
"reference_feature_count": audit["accepted_feature_count"],
"raster_dataset_id": str(raster.id),
"reference_dataset_id": str(reference.id),
"raster_sha256": sha256(raster_target),
"reference_sha256": sha256(normalized_target),
"label_audit_sha256": sha256(audit_target),
"bbox_epsg4326": (raster.source_metadata or {}).get("bbox_epsg4326"),
}
)
manifest = {
"schema_version": 1,
"dataset_version": args.version,
"immutable": bool(args.freeze),
"samples": manifest_samples,
}
manifest_path = output_dir / "operator_samples_manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
freeze = {
"dataset_version": args.version,
"manifest_sha256": sha256(manifest_path),
"sample_count": len(manifest_samples),
"immutable": bool(args.freeze),
}
(output_dir / "corpus-freeze.json").write_text(json.dumps(freeze, indent=2), encoding="utf-8")
print(json.dumps(freeze))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4 -2
View File
@@ -484,6 +484,8 @@ def export_sample_tiles(
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
sample_slug = str(sample["sample_slug"]) sample_slug = str(sample["sample_slug"])
sample_role = str(sample.get("sample_role") or "reference") sample_role = str(sample.get("sample_role") or "reference")
sample_reference_source = str(sample.get("reference_source") or reference_source).strip().lower()
sample_reference_layer = str(sample.get("reference_layer") or reference_layer).strip().lower()
background_category = background_category_for_sample(sample) background_category = background_category_for_sample(sample)
recommended_split = str(sample.get("recommended_split") or "") recommended_split = str(sample.get("recommended_split") or "")
split = "val" if sample_slug.lower() in val_slugs else "train" split = "val" if sample_slug.lower() in val_slugs else "train"
@@ -500,8 +502,8 @@ def export_sample_tiles(
reference_path, reference_path,
dataset, dataset,
min_label_px=min_label_px, min_label_px=min_label_px,
reference_source=reference_source, reference_source=sample_reference_source,
reference_layer=reference_layer, reference_layer=sample_reference_layer,
) )
for tile_index, tile_window in enumerate(iter_tile_windows(dataset.width, dataset.height, tile_size, stride)): for tile_index, tile_window in enumerate(iter_tile_windows(dataset.width, dataset.height, tile_size, stride)):
labels = labels_for_tile( labels = labels_for_tile(
@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""Normalize governed Belgian building references for detector training.
The script never asserts semantic parity between providers. It emits one
canonical training class while retaining provider-native identifiers,
properties and an explicit accept/reject decision for every source feature.
"""
from __future__ import annotations
import argparse
import hashlib
import json
from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import Any
import rasterio
from pyproj import Transformer
from shapely.geometry import mapping, shape
from shapely.ops import transform as shapely_transform
from shapely.validation import make_valid
SUPPORTED_SOURCES = {"grb", "spw_picc", "urbis"}
EXCLUDED_TOKENS = {
"canopy": ("canopy", "afdak", "auvent"),
"ruin": ("ruin", "ruine", "ruïne"),
"underground": ("underground", "ondergronds", "souterrain"),
}
def _feature_id(feature: dict[str, Any], index: int) -> str:
properties = feature.get("properties") or {}
for key in ("source_feature_id", "OBJECTID", "INSPIRE_ID", "id", "gml_id"):
value = properties.get(key)
if value not in (None, ""):
return str(value)
if feature.get("id") not in (None, ""):
return str(feature["id"])
return f"row-{index}"
def _source_class(properties: dict[str, Any]) -> str | None:
for key in ("source_class", "TYPE", "type", "OBJTYPE", "nature", "NATURE", "class"):
value = properties.get(key)
if value not in (None, ""):
return str(value)
return None
def _semantic_exclusion(properties: dict[str, Any]) -> str | None:
haystack = " ".join(str(value).lower() for value in properties.values() if value is not None)
for reason, tokens in EXCLUDED_TOKENS.items():
if any(token in haystack for token in tokens):
return f"excluded_{reason}"
return None
def _polygonal(geometry: Any) -> Any | None:
if geometry.geom_type in {"Polygon", "MultiPolygon"}:
return geometry
if geometry.geom_type == "GeometryCollection":
polygons = [part for part in geometry.geoms if part.geom_type in {"Polygon", "MultiPolygon"}]
if not polygons:
return None
from shapely.ops import unary_union
return unary_union(polygons)
return None
def normalize(
*,
reference_path: Path,
raster_path: Path,
source_name: str,
min_label_px: float,
imagery_observed_at: str | None,
reference_observed_at: str | None,
) -> tuple[dict[str, Any], dict[str, Any]]:
if source_name not in SUPPORTED_SOURCES:
raise SystemExit(f"Unsupported governed building source: {source_name}")
payload = json.loads(reference_path.read_text(encoding="utf-8-sig"))
if payload.get("type") != "FeatureCollection" or not isinstance(payload.get("features"), list):
raise SystemExit("Reference must be a GeoJSON FeatureCollection")
accepted: list[dict[str, Any]] = []
decisions: list[dict[str, Any]] = []
seen: set[str] = set()
counts: Counter[str] = Counter()
with rasterio.open(raster_path) as raster:
if raster.crs is None:
raise SystemExit("Raster CRS is required")
transformer = Transformer.from_crs("EPSG:4326", raster.crs, always_xy=True)
for index, feature in enumerate(payload["features"]):
properties = dict(feature.get("properties") or {})
source_feature_id = _feature_id(feature, index)
decision = {
"source_name": source_name,
"source_feature_id": source_feature_id,
"source_class": _source_class(properties),
"accepted": False,
"reason": None,
"geometry_repaired": False,
}
try:
geometry = shape(feature.get("geometry"))
except Exception:
decision["reason"] = "invalid_geometry_unreadable"
decisions.append(decision)
counts[decision["reason"]] += 1
continue
if not geometry.is_valid:
geometry = make_valid(geometry)
decision["geometry_repaired"] = True
geometry = _polygonal(geometry)
if geometry is None or geometry.is_empty or not geometry.is_valid:
decision["reason"] = "invalid_geometry_unrepairable"
elif (reason := _semantic_exclusion(properties)) is not None:
decision["reason"] = reason
else:
metric = shapely_transform(transformer.transform, geometry)
min_x, min_y, max_x, max_y = metric.bounds
pixel_width = (max_x - min_x) / abs(float(raster.transform.a))
pixel_height = (max_y - min_y) / abs(float(raster.transform.e))
decision["pixel_width"] = pixel_width
decision["pixel_height"] = pixel_height
if pixel_width < min_label_px or pixel_height < min_label_px:
decision["reason"] = "below_resolvable_pixel_size"
else:
digest = hashlib.sha256(geometry.normalize().wkb).hexdigest()
if digest in seen:
decision["reason"] = "duplicate_geometry"
else:
seen.add(digest)
decision["accepted"] = True
decision["reason"] = "accepted"
normalized_properties = dict(properties)
normalized_properties.update(
{
"canonical_class": "building",
"source_name": source_name,
"reference_layer_name": "buildings",
"source_feature_id": source_feature_id,
"source_class": decision["source_class"],
"label_decision": "accepted",
"geometry_repaired": decision["geometry_repaired"],
}
)
accepted.append(
{
"type": "Feature",
"id": f"{source_name}:{source_feature_id}",
"properties": normalized_properties,
"geometry": mapping(geometry),
}
)
decisions.append(decision)
counts[str(decision["reason"])] += 1
temporal_mismatch_days = None
if imagery_observed_at and reference_observed_at:
imagery_date = datetime.fromisoformat(imagery_observed_at.replace("Z", "+00:00"))
reference_date = datetime.fromisoformat(reference_observed_at.replace("Z", "+00:00"))
temporal_mismatch_days = abs((imagery_date - reference_date).days)
normalized = {
"type": "FeatureCollection",
"name": f"canonical-building-{source_name}",
"features": accepted,
}
audit = {
"schema_version": 1,
"canonical_class": "building",
"source_name": source_name,
"reference_path": str(reference_path),
"raster_path": str(raster_path),
"min_label_px": min_label_px,
"imagery_observed_at": imagery_observed_at,
"reference_observed_at": reference_observed_at,
"temporal_mismatch_days": temporal_mismatch_days,
"input_feature_count": len(payload["features"]),
"accepted_feature_count": len(accepted),
"decision_counts": dict(sorted(counts.items())),
"decisions": decisions,
}
return normalized, audit
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--reference", type=Path, required=True)
parser.add_argument("--raster", type=Path, required=True)
parser.add_argument("--source-name", choices=sorted(SUPPORTED_SOURCES), required=True)
parser.add_argument("--output-reference", type=Path, required=True)
parser.add_argument("--output-audit", type=Path, required=True)
parser.add_argument("--min-label-px", type=float, default=3.0)
parser.add_argument("--imagery-observed-at")
parser.add_argument("--reference-observed-at")
args = parser.parse_args()
normalized, audit = normalize(
reference_path=args.reference,
raster_path=args.raster,
source_name=args.source_name,
min_label_px=args.min_label_px,
imagery_observed_at=args.imagery_observed_at,
reference_observed_at=args.reference_observed_at,
)
args.output_reference.parent.mkdir(parents=True, exist_ok=True)
args.output_audit.parent.mkdir(parents=True, exist_ok=True)
args.output_reference.write_text(json.dumps(normalized, ensure_ascii=False), encoding="utf-8")
args.output_audit.write_text(json.dumps(audit, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({key: value for key, value in audit.items() if key != "decisions"}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())