GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
115 lines
3.9 KiB
Python
115 lines
3.9 KiB
Python
"""An extended building is a change, not a deletion plus a new building.
|
|
|
|
With only added/removed/unchanged, a footprint that grew by an annexe drops
|
|
below the IoU threshold and is reported twice: once as removed and once as
|
|
added. That hides exactly the category a change-detection product exists to
|
|
show, and inflates both counts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from shapely.geometry import box
|
|
|
|
from app.services.change_detection_service import ChangeDetectionService
|
|
|
|
|
|
def _feature(feature_id: str, geometry):
|
|
return {"feature_id": feature_id, "properties": {}, "geometry": geometry}
|
|
|
|
|
|
def _classify(source, target, *, iou_threshold=0.8, modified_threshold=0.3):
|
|
return ChangeDetectionService._classify_features(
|
|
source,
|
|
target,
|
|
iou_threshold=iou_threshold,
|
|
modified_threshold=modified_threshold,
|
|
)
|
|
|
|
|
|
def test_an_extended_footprint_is_reported_as_modified() -> None:
|
|
source = [_feature("b1", box(0, 0, 10, 10))]
|
|
target = [_feature("b1-new", box(0, 0, 10, 14))] # IoU 100/140 = 0.71
|
|
|
|
result = _classify(source, target)
|
|
|
|
assert [item["change_type"] for item in result] == ["modified"]
|
|
assert result[0]["source_feature_id"] == "b1"
|
|
assert result[0]["target_feature_id"] == "b1-new"
|
|
assert result[0]["iou"] == pytest.approx(100 / 140)
|
|
|
|
|
|
def test_a_nearly_identical_footprint_is_unchanged() -> None:
|
|
source = [_feature("b1", box(0, 0, 10, 10))]
|
|
target = [_feature("b1", box(0, 0, 10, 10.2))]
|
|
|
|
result = _classify(source, target)
|
|
|
|
assert [item["change_type"] for item in result] == ["unchanged"]
|
|
|
|
|
|
def test_a_genuinely_new_building_stays_added() -> None:
|
|
source = [_feature("b1", box(0, 0, 10, 10))]
|
|
target = [_feature("b1", box(0, 0, 10, 10)), _feature("b2", box(50, 50, 60, 60))]
|
|
|
|
result = _classify(source, target)
|
|
|
|
assert sorted(item["change_type"] for item in result) == ["added", "unchanged"]
|
|
|
|
|
|
def test_a_demolished_building_stays_removed() -> None:
|
|
source = [_feature("b1", box(0, 0, 10, 10)), _feature("b2", box(50, 50, 60, 60))]
|
|
target = [_feature("b1", box(0, 0, 10, 10))]
|
|
|
|
result = _classify(source, target)
|
|
|
|
assert sorted(item["change_type"] for item in result) == ["removed", "unchanged"]
|
|
|
|
|
|
def test_barely_overlapping_footprints_are_not_called_modified() -> None:
|
|
"""Below the modified floor the two are separate objects, not one changed."""
|
|
|
|
source = [_feature("b1", box(0, 0, 10, 10))]
|
|
target = [_feature("b2", box(9, 9, 19, 19))] # IoU ~0.005
|
|
|
|
result = _classify(source, target)
|
|
|
|
assert sorted(item["change_type"] for item in result) == ["added", "removed"]
|
|
|
|
|
|
def test_each_target_is_claimed_at_most_once() -> None:
|
|
source = [_feature("a", box(0, 0, 10, 10)), _feature("b", box(0, 0, 10, 12))]
|
|
target = [_feature("t", box(0, 0, 10, 10))]
|
|
|
|
result = _classify(source, target)
|
|
|
|
claimed = [item for item in result if item["target_feature_id"] == "t"]
|
|
assert len(claimed) == 1
|
|
|
|
|
|
def test_classification_is_independent_of_input_order() -> None:
|
|
source = [_feature("a", box(0, 0, 10, 10)), _feature("b", box(30, 30, 40, 40))]
|
|
target = [_feature("a2", box(0, 0, 10, 14)), _feature("c", box(70, 70, 80, 80))]
|
|
|
|
forward = _classify(source, target)
|
|
reverse = _classify(list(reversed(source)), list(reversed(target)))
|
|
|
|
def signature(items):
|
|
return sorted(
|
|
(item["change_type"], item["source_feature_id"], item["target_feature_id"]) for item in items
|
|
)
|
|
|
|
assert signature(forward) == signature(reverse)
|
|
|
|
|
|
def test_large_populations_do_not_use_a_full_cross_product() -> None:
|
|
"""A spatial index keeps a city-sized comparison tractable."""
|
|
|
|
source = [_feature(f"s{i}", box(i * 10, 0, i * 10 + 8, 8)) for i in range(400)]
|
|
target = [_feature(f"t{i}", box(i * 10, 0, i * 10 + 8, 8)) for i in range(400)]
|
|
|
|
result = _classify(source, target)
|
|
|
|
assert all(item["change_type"] == "unchanged" for item in result)
|
|
assert len(result) == 400
|