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,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())