Normalize touching footprints as visible roof instances
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 05:06:20 +02:00
parent 7744803461
commit de0406130c
5 changed files with 112 additions and 4 deletions
+47 -3
View File
@@ -19,7 +19,7 @@ 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.ops import transform as shapely_transform, unary_union
from shapely.validation import make_valid
SUPPORTED_SOURCES = {"grb", "spw_picc", "urbis"}
@@ -90,6 +90,42 @@ def _polygonal(geometry: Any) -> Any | None:
return None
def merge_touching_roof_instances(features: list[dict[str, Any]], source_name: str) -> list[dict[str, Any]]:
"""Dissolve only touching/overlapping footprints into imagery-visible roof instances."""
if not features:
return []
source_geometries = [(feature, shape(feature["geometry"])) for feature in features]
dissolved = unary_union([geometry for _feature, geometry in source_geometries])
components = list(dissolved.geoms) if dissolved.geom_type == "MultiPolygon" else [dissolved]
merged: list[dict[str, Any]] = []
for component in components:
contributors = [
feature
for feature, geometry in source_geometries
if geometry.intersects(component)
]
source_ids = sorted(str(feature["properties"]["source_feature_id"]) for feature in contributors)
properties = dict(contributors[0]["properties"])
properties.update(
{
"label_semantics": "visible_touching_roof_instance",
"source_feature_ids": source_ids,
"source_feature_count": len(source_ids),
"source_feature_id": source_ids[0],
}
)
digest = hashlib.sha256(component.normalize().wkb).hexdigest()[:24]
merged.append(
{
"type": "Feature",
"id": f"{source_name}:roof-instance:{digest}",
"properties": properties,
"geometry": mapping(component),
}
)
return merged
def normalize(
*,
reference_path: Path,
@@ -99,6 +135,7 @@ def normalize(
imagery_observed_at: str | None,
reference_observed_at: str | None,
imagery_valid_to: str | None = None,
merge_touching_roofs: bool = False,
) -> tuple[dict[str, Any], dict[str, Any]]:
if source_name not in SUPPORTED_SOURCES:
raise SystemExit(f"Unsupported governed building source: {source_name}")
@@ -194,10 +231,13 @@ def normalize(
reference_date = datetime.fromisoformat(reference_observed_at.replace("Z", "+00:00"))
temporal_mismatch_days = abs((imagery_date - reference_date).days)
temporal_alignment_status = "measured"
normalized_features = (
merge_touching_roof_instances(accepted, source_name) if merge_touching_roofs else accepted
)
normalized = {
"type": "FeatureCollection",
"name": f"canonical-building-{source_name}",
"features": accepted,
"features": normalized_features,
}
audit = {
"schema_version": 1,
@@ -212,7 +252,9 @@ def normalize(
"temporal_mismatch_days": temporal_mismatch_days,
"temporal_alignment_status": temporal_alignment_status,
"input_feature_count": len(payload["features"]),
"accepted_feature_count": len(accepted),
"accepted_source_feature_count": len(accepted),
"accepted_feature_count": len(normalized_features),
"merge_touching_roofs": merge_touching_roofs,
"decision_counts": dict(sorted(counts.items())),
"decisions": decisions,
}
@@ -230,6 +272,7 @@ def main() -> int:
parser.add_argument("--imagery-observed-at")
parser.add_argument("--reference-observed-at")
parser.add_argument("--imagery-valid-to")
parser.add_argument("--merge-touching-roofs", action="store_true")
args = parser.parse_args()
normalized, audit = normalize(
reference_path=args.reference,
@@ -239,6 +282,7 @@ def main() -> int:
imagery_observed_at=args.imagery_observed_at,
reference_observed_at=args.reference_observed_at,
imagery_valid_to=args.imagery_valid_to,
merge_touching_roofs=args.merge_touching_roofs,
)
args.output_reference.parent.mkdir(parents=True, exist_ok=True)
args.output_audit.parent.mkdir(parents=True, exist_ok=True)