204 lines
7.4 KiB
Python
204 lines
7.4 KiB
Python
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_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"
|
|
}
|