Constrain visible roof grouping to compact clusters
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:38:16 +02:00
parent 31c18792d1
commit f07ff5d52c
3 changed files with 42 additions and 1 deletions
@@ -179,3 +179,25 @@ def test_normalizer_merges_only_touching_visible_roof_instances(tmp_path: Path)
assert sorted(item["properties"]["source_feature_count"] for item in normalized["features"]) == [1, 2] assert sorted(item["properties"]["source_feature_count"] for item in normalized["features"]) == [1, 2]
assert audit["accepted_source_feature_count"] == 3 assert audit["accepted_source_feature_count"] == 3
assert audit["accepted_feature_count"] == 2 assert audit["accepted_feature_count"] == 2
def test_visible_roof_merge_retains_large_touching_chains() -> None:
features = []
for index in range(13):
left = float(index)
features.append(
{
"type": "Feature",
"id": str(index),
"properties": {"source_feature_id": str(index)},
"geometry": {
"type": "Polygon",
"coordinates": [[[left, 0], [left + 1, 0], [left + 1, 1], [left, 1], [left, 0]]],
},
}
)
merged = module.merge_touching_roof_instances(features, "grb")
assert len(merged) == 13
assert {item["properties"]["label_semantics"] for item in merged} == {
"native_instance_complex_touch_group"
}
+7
View File
@@ -102,3 +102,10 @@ and visible-instance counts. This mode is not the default: the Belgium v8
experiment showed that unconditional touching-footprint dissolve can merge experiment showed that unconditional touching-footprint dissolve can merge
whole urban blocks and therefore must pass the same independent gates before whole urban blocks and therefore must pass the same independent gates before
it can replace native instances. it can replace native instances.
The compact-roof variant therefore merges a connected group only when it has
at most 12 source footprints and fills at least 55% of its axis-aligned
envelope. Larger or irregular connected groups retain their native instances
and are marked `native_instance_complex_touch_group`. These fixed criteria
prevent administrative row-house chains from becoming one ambiguous detector
box while keeping the experiment deterministic and auditable.
+13 -1
View File
@@ -91,7 +91,7 @@ def _polygonal(geometry: Any) -> Any | None:
def merge_touching_roof_instances(features: list[dict[str, Any]], source_name: str) -> list[dict[str, Any]]: 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.""" """Dissolve only compact touching groups into imagery-visible roof instances."""
if not features: if not features:
return [] return []
source_geometries = [(feature, shape(feature["geometry"])) for feature in features] source_geometries = [(feature, shape(feature["geometry"])) for feature in features]
@@ -105,6 +105,17 @@ def merge_touching_roof_instances(features: list[dict[str, Any]], source_name: s
if geometry.intersects(component) if geometry.intersects(component)
] ]
source_ids = sorted(str(feature["properties"]["source_feature_id"]) for feature in contributors) source_ids = sorted(str(feature["properties"]["source_feature_id"]) for feature in contributors)
envelope_area = component.envelope.area
fill_ratio = component.area / envelope_area if envelope_area else 0.0
# Large connected blocks and irregular chains are administratively
# adjacent but not one reliably box-shaped roof target. Preserve their
# native instances instead of creating a giant ambiguous detector box.
if len(contributors) > 12 or fill_ratio < 0.55:
for feature in contributors:
retained = json.loads(json.dumps(feature))
retained["properties"]["label_semantics"] = "native_instance_complex_touch_group"
merged.append(retained)
continue
properties = dict(contributors[0]["properties"]) properties = dict(contributors[0]["properties"])
properties.update( properties.update(
{ {
@@ -112,6 +123,7 @@ def merge_touching_roof_instances(features: list[dict[str, Any]], source_name: s
"source_feature_ids": source_ids, "source_feature_ids": source_ids,
"source_feature_count": len(source_ids), "source_feature_count": len(source_ids),
"source_feature_id": source_ids[0], "source_feature_id": source_ids[0],
"roof_group_fill_ratio": fill_ratio,
} }
) )
digest = hashlib.sha256(component.normalize().wkb).hexdigest()[:24] digest = hashlib.sha256(component.normalize().wkb).hexdigest()[:24]