From f07ff5d52c32860a9f80f639b8ecbc639234b6aa Mon Sep 17 00:00:00 2001 From: Jens Date: Mon, 27 Jul 2026 05:38:16 +0200 Subject: [PATCH] Constrain visible roof grouping to compact clusters --- .../test_building_label_normalization.py | 22 +++++++++++++++++++ docs/BELGIUM_BUILDING_TRAINING_LOOP.md | 7 ++++++ scripts/normalize_belgium_building_labels.py | 14 +++++++++++- 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_building_label_normalization.py b/backend/tests/test_building_label_normalization.py index 7aef475e..c0530296 100644 --- a/backend/tests/test_building_label_normalization.py +++ b/backend/tests/test_building_label_normalization.py @@ -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 audit["accepted_source_feature_count"] == 3 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" + } diff --git a/docs/BELGIUM_BUILDING_TRAINING_LOOP.md b/docs/BELGIUM_BUILDING_TRAINING_LOOP.md index 7dd56e6e..92c10a53 100644 --- a/docs/BELGIUM_BUILDING_TRAINING_LOOP.md +++ b/docs/BELGIUM_BUILDING_TRAINING_LOOP.md @@ -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 whole urban blocks and therefore must pass the same independent gates before 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. diff --git a/scripts/normalize_belgium_building_labels.py b/scripts/normalize_belgium_building_labels.py index 9503ae94..e96a2225 100644 --- a/scripts/normalize_belgium_building_labels.py +++ b/scripts/normalize_belgium_building_labels.py @@ -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]]: - """Dissolve only touching/overlapping footprints into imagery-visible roof instances.""" + """Dissolve only compact touching groups into imagery-visible roof instances.""" if not features: return [] 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) ] 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.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_count": len(source_ids), "source_feature_id": source_ids[0], + "roof_group_fill_ratio": fill_ratio, } ) digest = hashlib.sha256(component.normalize().wkb).hexdigest()[:24]