From de0406130c54597519e4bbca9160e5331a391b5c Mon Sep 17 00:00:00 2001 From: Jens Date: Mon, 27 Jul 2026 05:06:20 +0200 Subject: [PATCH] Normalize touching footprints as visible roof instances --- .../test_building_label_normalization.py | 47 +++++++++++++++++ docs/BELGIUM_BUILDING_TRAINING_LOOP.md | 8 +++ scripts/assemble_belgium_building_corpus.py | 1 + .../evaluate_belgium_building_candidate.py | 10 +++- scripts/normalize_belgium_building_labels.py | 50 +++++++++++++++++-- 5 files changed, 112 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_building_label_normalization.py b/backend/tests/test_building_label_normalization.py index ff8181c4..7aef475e 100644 --- a/backend/tests/test_building_label_normalization.py +++ b/backend/tests/test_building_label_normalization.py @@ -132,3 +132,50 @@ def test_normalizer_rejects_features_created_after_dated_imagery(tmp_path: Path) ) assert len(normalized["features"]) == 1 assert audit["decision_counts"] == {"accepted": 1, "created_after_imagery_period": 1} + + +def test_normalizer_merges_only_touching_visible_roof_instances(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")) + polygons = [ + [[[4.01, 50.99], [4.02, 50.99], [4.02, 50.98], [4.01, 50.98], [4.01, 50.99]]], + [[[4.02, 50.99], [4.03, 50.99], [4.03, 50.98], [4.02, 50.98], [4.02, 50.99]]], + [[[4.04, 50.99], [4.05, 50.99], [4.05, 50.98], [4.04, 50.98], [4.04, 50.99]]], + ] + reference_path = tmp_path / "reference.geojson" + reference_path.write_text( + json.dumps( + { + "type": "FeatureCollection", + "features": [ + {"type": "Feature", "id": str(index), "properties": {}, "geometry": {"type": "Polygon", "coordinates": coordinates}} + for index, coordinates in enumerate(polygons) + ], + } + ), + 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=None, + reference_observed_at=None, + merge_touching_roofs=True, + ) + assert len(normalized["features"]) == 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_feature_count"] == 2 diff --git a/docs/BELGIUM_BUILDING_TRAINING_LOOP.md b/docs/BELGIUM_BUILDING_TRAINING_LOOP.md index 46ace8a2..0fdf60c8 100644 --- a/docs/BELGIUM_BUILDING_TRAINING_LOOP.md +++ b/docs/BELGIUM_BUILDING_TRAINING_LOOP.md @@ -94,3 +94,11 @@ end of the imagery period. A feature created afterward is retained in the audit but excluded from training as `created_after_imagery_period`. UrbIS does not expose an equivalent feature creation field in this acquisition contract, so its remaining temporal relation stays an explicit sample-level limitation. + +The detector target is an imagery-visible roof instance, not an administrative +address or cadastral unit. Source footprints that truly touch or overlap are +dissolved into one roof instance before tiling; separated footprints are never +bridged. The audit retains every contributing native feature identifier and +reports both the accepted source-feature count and resulting visible-instance +count. This prevents dense row-house subdivisions that cannot be distinguished +from the orthophoto from becoming contradictory image labels. diff --git a/scripts/assemble_belgium_building_corpus.py b/scripts/assemble_belgium_building_corpus.py index ad40462d..cdd4e316 100644 --- a/scripts/assemble_belgium_building_corpus.py +++ b/scripts/assemble_belgium_building_corpus.py @@ -150,6 +150,7 @@ def main() -> int: ), reference_observed_at=reference.observed_at.isoformat() if reference.observed_at else None, imagery_valid_to=raster.valid_to.isoformat() if raster.valid_to else None, + merge_touching_roofs=True, ) 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") diff --git a/scripts/evaluate_belgium_building_candidate.py b/scripts/evaluate_belgium_building_candidate.py index 5be74a84..d619d5a5 100644 --- a/scripts/evaluate_belgium_building_candidate.py +++ b/scripts/evaluate_belgium_building_candidate.py @@ -80,6 +80,7 @@ def main() -> int: parser.add_argument("--match-iou", type=float, default=0.25) parser.add_argument("--device", default="cuda:0") parser.add_argument("--split", default="val") + parser.add_argument("--augment", action="store_true", help="Enable deterministic YOLO test-time augmentation.") args = parser.parse_args() from ultralytics import YOLO @@ -98,7 +99,13 @@ def main() -> int: } tiles = [item for item in summary["tiles"] if item.get("kept", True) and item["split"] == args.split] image_paths = [item["image_path"] for item in tiles] - results = YOLO(str(args.model)).predict(image_paths, conf=min(args.thresholds), device=args.device, verbose=False) + results = YOLO(str(args.model)).predict( + image_paths, + conf=min(args.thresholds), + device=args.device, + augment=args.augment, + verbose=False, + ) observations: list[dict[str, Any]] = [] for tile, result in zip(tiles, results, strict=True): height, width = result.orig_shape @@ -144,6 +151,7 @@ def main() -> int: "summary": str(args.summary), "split": args.split, "match_iou": args.match_iou, + "test_time_augmentation": args.augment, "tile_count": len(tiles), "sweeps": sweeps, } diff --git a/scripts/normalize_belgium_building_labels.py b/scripts/normalize_belgium_building_labels.py index 6a8cd966..9503ae94 100644 --- a/scripts/normalize_belgium_building_labels.py +++ b/scripts/normalize_belgium_building_labels.py @@ -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)