From 5ac3238c2750d9bb0f481a6f6146294818507f6b Mon Sep 17 00:00:00 2001 From: Jens Date: Thu, 30 Jul 2026 03:35:15 +0200 Subject: [PATCH] Reject same-year post-mosaic building labels --- .../test_building_label_normalization.py | 32 +++++++++++++++++++ scripts/normalize_belgium_building_labels.py | 15 +++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/backend/tests/test_building_label_normalization.py b/backend/tests/test_building_label_normalization.py index c0530296..1f22799b 100644 --- a/backend/tests/test_building_label_normalization.py +++ b/backend/tests/test_building_label_normalization.py @@ -134,6 +134,38 @@ def test_normalizer_rejects_features_created_after_dated_imagery(tmp_path: Path) 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_merges_only_touching_visible_roof_instances(tmp_path: Path) -> None: raster_path = tmp_path / "image.tif" with rasterio.open( diff --git a/scripts/normalize_belgium_building_labels.py b/scripts/normalize_belgium_building_labels.py index e96a2225..3a4e983e 100644 --- a/scripts/normalize_belgium_building_labels.py +++ b/scripts/normalize_belgium_building_labels.py @@ -159,9 +159,16 @@ def normalize( decisions: list[dict[str, Any]] = [] seen: set[str] = set() counts: Counter[str] = Counter() - imagery_cutoff = ( - datetime.fromisoformat(imagery_valid_to.replace("Z", "+00:00")) if imagery_valid_to else None - ) + imagery_dates = [ + datetime.fromisoformat(value.replace("Z", "+00:00")) + for value in (imagery_observed_at, imagery_valid_to) + if value + ] + # Annual mosaics expose a validity interval but not the flight date for + # each pixel. Using the interval end admits buildings created later in the + # same year that are visibly absent from the mosaic. The earliest governed + # date is therefore the only conservative training-label cutoff. + imagery_cutoff = min(imagery_dates) if imagery_dates else None with rasterio.open(raster_path) as raster: if raster.crs is None: raise SystemExit("Raster CRS is required") @@ -260,6 +267,8 @@ def normalize( "min_label_px": min_label_px, "imagery_observed_at": imagery_observed_at, "imagery_valid_to": imagery_valid_to, + "imagery_feature_creation_cutoff": imagery_cutoff.isoformat() if imagery_cutoff else None, + "imagery_feature_creation_cutoff_policy": "earliest_governed_imagery_date", "reference_observed_at": reference_observed_at, "temporal_mismatch_days": temporal_mismatch_days, "temporal_alignment_status": temporal_alignment_status,