from __future__ import annotations import importlib.util import json from pathlib import Path import numpy as np import rasterio from rasterio.transform import from_origin ROOT = Path(__file__).resolve().parents[2] SCRIPT = ROOT / "scripts" / "normalize_belgium_building_labels.py" SPEC = importlib.util.spec_from_file_location("normalize_buildings", SCRIPT) assert SPEC and SPEC.loader module = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(module) ASSEMBLER_SPEC = importlib.util.spec_from_file_location( "assemble_building_corpus", ROOT / "scripts" / "assemble_belgium_building_corpus.py" ) assert ASSEMBLER_SPEC and ASSEMBLER_SPEC.loader assembler = importlib.util.module_from_spec(ASSEMBLER_SPEC) ASSEMBLER_SPEC.loader.exec_module(assembler) def test_normalizer_retains_native_identity_and_records_rejections(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")) valid = { "type": "Feature", "id": "native-1", "properties": {"TYPE": "main building"}, "geometry": {"type": "Polygon", "coordinates": [[[4.01, 50.99], [4.02, 50.99], [4.02, 50.98], [4.01, 50.98], [4.01, 50.99]]]}, } duplicate = json.loads(json.dumps(valid)) duplicate["id"] = "native-2" canopy = json.loads(json.dumps(valid)) canopy["id"] = "native-3" canopy["properties"]["TYPE"] = "canopy" tiny = json.loads(json.dumps(valid)) tiny["id"] = "native-4" tiny["geometry"] = {"type": "Polygon", "coordinates": [[[4.03, 50.97], [4.031, 50.97], [4.031, 50.969], [4.03, 50.969], [4.03, 50.97]]]} reference_path = tmp_path / "reference.geojson" reference_path.write_text(json.dumps({"type": "FeatureCollection", "features": [valid, duplicate, canopy, tiny]}), 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="2026-01-01T00:00:00Z", reference_observed_at="2025-12-01T00:00:00Z", ) assert len(normalized["features"]) == 1 properties = normalized["features"][0]["properties"] assert properties["canonical_class"] == "building" assert properties["source_name"] == "urbis" assert properties["source_feature_id"] == "native-1" assert properties["source_class"] == "main building" assert audit["decision_counts"] == { "accepted": 1, "below_resolvable_pixel_size": 1, "duplicate_geometry": 1, "excluded_canopy": 1, } assert audit["temporal_mismatch_days"] == 31 def test_spatial_leakage_audit_fails_cross_split_neighbors() -> None: samples = [ {"sample_slug": "train-a", "split": "train", "bbox_epsg4326": [4.0, 50.0, 4.01, 50.01]}, {"sample_slug": "val-a", "split": "val", "bbox_epsg4326": [4.005, 50.005, 4.02, 50.02]}, {"sample_slug": "test-far", "split": "test", "bbox_epsg4326": [5.0, 51.0, 5.01, 51.01]}, ] audit = assembler.audit_spatial_leakage(samples) assert audit["status"] == "failed" assert audit["findings"][0]["left"] == "train-a" assert audit["findings"][0]["right"] == "val-a" def test_normalizer_rejects_features_created_after_dated_imagery(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")) geometry = { "type": "Polygon", "coordinates": [[[4.01, 50.99], [4.02, 50.99], [4.02, 50.98], [4.01, 50.98], [4.01, 50.99]]], } reference_path = tmp_path / "reference.geojson" reference_path.write_text( json.dumps( { "type": "FeatureCollection", "features": [ {"type": "Feature", "id": "old", "properties": {"BEGINDATUM": "2024-01-01"}, "geometry": geometry}, {"type": "Feature", "id": "new", "properties": {"BEGINDATUM": "2026-01-01"}, "geometry": geometry}, ], } ), encoding="utf-8", ) normalized, audit = module.normalize( reference_path=reference_path, raster_path=raster_path, source_name="grb", min_label_px=3, imagery_observed_at="2025-01-01T00:00:00Z", imagery_valid_to="2025-12-31T23:59:59Z", reference_observed_at="2026-07-01T00:00:00Z", ) assert len(normalized["features"]) == 1 assert audit["decision_counts"] == {"accepted": 1, "created_after_imagery_period": 1} def test_normalizer_rejects_same_year_feature_after_annual_mosaic_start(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")) geometry = { "type": "Polygon", "coordinates": [[[4.01, 50.99], [4.02, 50.99], [4.02, 50.98], [4.01, 50.98], [4.01, 50.99]]], } reference_path = tmp_path / "reference.geojson" reference_path.write_text(json.dumps({ "type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"BEGINDATUM": "2025-08-18"}, "geometry": geometry}], }), encoding="utf-8") normalized, audit = module.normalize( reference_path=reference_path, raster_path=raster_path, source_name="grb", min_label_px=3, imagery_observed_at="2025-01-01T00:00:00Z", imagery_valid_to="2025-12-31T23:59:59Z", reference_observed_at="2026-07-01T00:00:00Z", ) assert normalized["features"] == [] assert audit["decision_counts"] == {"created_after_imagery_period": 1} assert audit["imagery_feature_creation_cutoff"] == "2025-01-01T00:00:00+00:00" def test_normalizer_applies_provider_native_source_class_allowlist(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")) def feature(feature_id: str, source_type: int, left: float) -> dict: return { "type": "Feature", "id": feature_id, "properties": {"TYPE": source_type}, "geometry": {"type": "Polygon", "coordinates": [[[left, 50.99], [left + .01, 50.99], [left + .01, 50.98], [left, 50.98], [left, 50.99]]]}, } reference_path = tmp_path / "reference.geojson" reference_path.write_text(json.dumps({"type": "FeatureCollection", "features": [feature("main", 1, 4.01), feature("annex", 2, 4.03)]}), encoding="utf-8") normalized, audit = module.normalize( reference_path=reference_path, raster_path=raster_path, source_name="grb", min_label_px=3, imagery_observed_at=None, reference_observed_at=None, allowed_source_classes={"1"}, ) assert [item["id"] for item in normalized["features"]] == ["grb:main"] assert audit["allowed_source_classes"] == ["1"] assert audit["decision_counts"] == {"accepted": 1, "source_class_not_allowed": 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 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" }