diff --git a/backend/app/api/routes/analysis.py b/backend/app/api/routes/analysis.py index fea2d265..6eb771dc 100644 --- a/backend/app/api/routes/analysis.py +++ b/backend/app/api/routes/analysis.py @@ -36,7 +36,11 @@ def run_change_detection( source_dataset_id=payload.source_dataset_id, target_dataset_id=payload.target_dataset_id, iou_threshold=payload.iou_threshold, + modified_threshold=payload.modified_threshold, include_unchanged=payload.include_unchanged, + bbox=payload.bbox.model_dump() if payload.bbox is not None else None, + area_id=payload.area_id, + preview_limit=payload.preview_limit, ).model_dump(mode="json"), ) return envelope(job) diff --git a/backend/app/schemas/analysis.py b/backend/app/schemas/analysis.py index 4c73c831..b1ca6419 100644 --- a/backend/app/schemas/analysis.py +++ b/backend/app/schemas/analysis.py @@ -5,12 +5,22 @@ from uuid import UUID from pydantic import BaseModel, Field +from app.schemas.operations import VectorSelectionBBox + class ChangeDetectionRequest(BaseModel): source_dataset_id: UUID target_dataset_id: UUID iou_threshold: float = Field(default=0.8, ge=0.0, le=1.0) + # Below this the two footprints are separate objects rather than one that + # was redrawn; between the two thresholds the change class is "modified". + modified_threshold: float = Field(default=0.3, ge=0.0, le=1.0) include_unchanged: bool = True + # Without a selection the comparison covers both datasets in full, which is + # rarely the question and never a response a map can draw. + bbox: VectorSelectionBBox | None = None + area_id: UUID | None = None + preview_limit: int = Field(default=2_000, ge=1, le=20_000) class ChangeDetectionSummary(BaseModel): @@ -26,6 +36,11 @@ class ChangeDetectionSummary(BaseModel): unchanged_count: int iou_threshold: float modified_iou_threshold: float | None = None + selection_area_id: UUID | None = None + # Counts describe the whole selection; the GeoJSON is capped so a regional + # comparison does not return both datasets in one response. + preview_limit: int | None = None + preview_truncated: bool = False warnings: list[str] = Field(default_factory=list) generated_at: datetime geojson: dict diff --git a/backend/app/services/change_detection_service.py b/backend/app/services/change_detection_service.py index b1844fdb..952342d6 100644 --- a/backend/app/services/change_detection_service.py +++ b/backend/app/services/change_detection_service.py @@ -4,11 +4,12 @@ from datetime import datetime, timezone from typing import Any from uuid import UUID -from geoalchemy2.shape import to_shape +from geoalchemy2.shape import from_shape, to_shape from shapely.geometry import mapping from shapely.geometry.base import BaseGeometry from shapely.strtree import STRtree from shapely.validation import make_valid +from sqlalchemy import func from sqlalchemy.orm import Session from app.core.errors import AppError @@ -30,6 +31,9 @@ class ChangeDetectionService: iou_threshold: float = 0.8, include_unchanged: bool = True, modified_threshold: float = 0.3, + bbox: dict[str, Any] | None = None, + area_id: UUID | None = None, + preview_limit: int = 2_000, ) -> ChangeDetectionSummary: if source_dataset_id == target_dataset_id: raise AppError(code="INVALID_PARAMETERS", message="Source and target datasets must differ", status_code=400) @@ -45,14 +49,19 @@ class ChangeDetectionService: source_dataset = ChangeDetectionService._get_project_vector_dataset(db, source_dataset_id, project_id, "Source") target_dataset = ChangeDetectionService._get_project_vector_dataset(db, target_dataset_id, project_id, "Target") - source_features, source_warnings = ChangeDetectionService._load_features(db, source_dataset) - target_features, target_warnings = ChangeDetectionService._load_features(db, target_dataset) + selection_geometry = ChangeDetectionService._selection_geometry(db, project_id, bbox=bbox, area_id=area_id) + + source_features, source_warnings = ChangeDetectionService._load_features(db, source_dataset, selection_geometry) + target_features, target_warnings = ChangeDetectionService._load_features(db, target_dataset, selection_geometry) if not source_features: raise AppError(code="EMPTY_VECTOR_DATASET", message="Source dataset has no comparable vector features", status_code=422) if not target_features: raise AppError(code="EMPTY_VECTOR_DATASET", message="Target dataset has no comparable vector features", status_code=422) + source_features = ChangeDetectionService.restrict_to_selection(source_features, selection_geometry, label="Source") + target_features = ChangeDetectionService.restrict_to_selection(target_features, selection_geometry, label="Target") + classified = ChangeDetectionService._classify_features( source_features, target_features, @@ -79,7 +88,24 @@ class ChangeDetectionService: if not include_unchanged: buckets["unchanged"] = [] - geojson_features = buckets["added"] + buckets["removed"] + buckets["modified"] + buckets["unchanged"] + geojson_features, preview_truncated = ChangeDetectionService.limit_preview( + buckets["added"] + buckets["removed"] + buckets["modified"] + buckets["unchanged"], + limit=preview_limit, + ) + warnings = source_warnings + target_warnings + edge_count = sum( + 1 for feature in source_features + target_features if feature.get("partially_covered") + ) + if edge_count: + warnings.append( + f"{edge_count} objecten liggen deels buiten de selectie. Ze zijn volledig vergeleken, zodat de " + "selectierand zelf geen wijziging veroorzaakt." + ) + if preview_truncated: + warnings.append( + f"De tellingen gelden voor de volledige selectie; de kaart toont maximaal {preview_limit} objecten, " + "wijzigingen eerst." + ) return ChangeDetectionSummary( source_dataset_id=source_dataset_id, target_dataset_id=target_dataset_id, @@ -91,11 +117,109 @@ class ChangeDetectionService: unchanged_count=unchanged_count, iou_threshold=iou_threshold, modified_iou_threshold=modified_threshold, - warnings=source_warnings + target_warnings, + selection_area_id=area_id, + preview_limit=preview_limit, + preview_truncated=preview_truncated, + warnings=warnings, generated_at=datetime.now(timezone.utc), geojson={"type": "FeatureCollection", "features": geojson_features}, ) + @staticmethod + def _selection_geometry( + db: Session, + project_id: UUID, + *, + bbox: dict[str, Any] | None, + area_id: UUID | None, + ) -> BaseGeometry | None: + """Resolve the drawn rectangle against the named work area, if any.""" + + from app.models import Area + from shapely.geometry import box as shapely_box + + selection = None + if bbox: + selection = shapely_box( + float(bbox["min_x"]), float(bbox["min_y"]), float(bbox["max_x"]), float(bbox["max_y"]) + ) + if area_id is None: + return selection + + area = db.get(Area, area_id) + if area is None or area.project_id != project_id: + raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404) + area_geometry = to_shape(area.geometry) + if selection is None: + return area_geometry + intersection = selection.intersection(area_geometry) + if intersection.is_empty or intersection.area <= 0: + raise AppError( + code="CHANGE_DETECTION_SELECTION_OUTSIDE_AREA", + message="Selection does not overlap the selected work area", + status_code=422, + ) + return intersection + + # Order the preview spends its budget in. An operator asking what changed + # is not helped by a cap filled with unchanged footprints. + PREVIEW_PRIORITY = {"modified": 0, "added": 1, "removed": 2, "unchanged": 3} + + @staticmethod + def restrict_to_selection( + features: list[dict[str, Any]], + selection_geometry: BaseGeometry | None, + *, + label: str = "Dataset", + ) -> list[dict[str, Any]]: + """Keep the features a drawn selection reaches, and say which it cuts. + + Geometry is deliberately *not* clipped. A change class describes a whole + object: comparing a clipped 2020 footprint against an unclipped 2024 one + would manufacture "modified" along the selection edge. Clipping is right + for an area metric and wrong for an identity comparison. + """ + + if selection_geometry is None: + return features + + kept: list[dict[str, Any]] = [] + for feature in features: + geometry = feature["geometry"] + if not geometry.intersects(selection_geometry): + continue + kept.append({**feature, "partially_covered": not selection_geometry.covers(geometry)}) + + if not kept: + raise AppError( + code="CHANGE_DETECTION_SELECTION_EMPTY", + message=f"{label} dataset has no features inside this selection", + status_code=422, + ) + return kept + + @staticmethod + def limit_preview( + features: list[dict[str, Any]], + *, + limit: int, + ) -> tuple[list[dict[str, Any]], bool]: + """Cap the returned geometry without capping the counts. + + ``include_unchanged`` defaulted to true and nothing bounded the result, + so a regional comparison returned a FeatureCollection holding both + datasets in full. The counts describe the whole selection; the preview + describes what a map can usefully draw. + """ + + if limit <= 0 or len(features) <= limit: + return features, False + ordered = sorted( + features, + key=lambda item: ChangeDetectionService.PREVIEW_PRIORITY.get(item["change_type"], 9), + ) + return ordered[:limit], True + @staticmethod def _classify_features( source_features: list[dict[str, Any]], @@ -182,8 +306,25 @@ class ChangeDetectionService: return dataset @staticmethod - def _load_features(db: Session, dataset: Dataset) -> tuple[list[dict[str, Any]], list[str]]: - rows = db.query(VectorFeature).filter(VectorFeature.dataset_id == dataset.id).all() + def _load_features( + db: Session, + dataset: Dataset, + selection_geometry: BaseGeometry | None = None, + ) -> tuple[list[dict[str, Any]], list[str]]: + query = db.query(VectorFeature).filter(VectorFeature.dataset_id == dataset.id) + if selection_geometry is not None and hasattr(query, "filter"): + # Bound the load in the database. Pulling a regional building layer + # into Python to then discard most of it costs memory and time for + # nothing, and the fallback below has no such option. + try: + query = query.filter( + func.ST_Intersects(VectorFeature.geometry, from_shape(selection_geometry, srid=4326)) + ) + except Exception: + # Lightweight unit-test sessions do not implement every spatial + # predicate; restrict_to_selection still bounds the population. + pass + rows = query.all() warnings: list[str] = [] if rows: return [ChangeDetectionService._row_to_feature(row) for row in rows], warnings diff --git a/backend/tests/test_change_detection_area_selection.py b/backend/tests/test_change_detection_area_selection.py new file mode 100644 index 00000000..3c2d7a20 --- /dev/null +++ b/backend/tests/test_change_detection_area_selection.py @@ -0,0 +1,128 @@ +"""Change detection must answer the question the operator actually asked. + +Every other analysis in the workbench is bounded by the drawn selection. +Change detection was not: it compared two datasets in full, loaded every +feature of both into Python, and — with ``include_unchanged`` defaulting to +true — returned a FeatureCollection containing both datasets entire. For a +regional building layer that is the wrong answer to "what changed here" and a +response no browser should be asked to hold. +""" + +from __future__ import annotations + +import pytest +from shapely.geometry import box + +from app.core.errors import AppError +from app.services.change_detection_service import ChangeDetectionService + + +def _feature(feature_id: str, geometry): + return {"feature_id": feature_id, "properties": {}, "geometry": geometry} + + +INSIDE = box(0.0, 0.0, 1.0, 1.0) +OUTSIDE = box(50.0, 50.0, 51.0, 51.0) +SELECTION = box(-1.0, -1.0, 2.0, 2.0) + + +def test_features_outside_the_selection_are_not_compared() -> None: + kept = ChangeDetectionService.restrict_to_selection( + [_feature("in", INSIDE), _feature("out", OUTSIDE)], + SELECTION, + ) + + assert [item["feature_id"] for item in kept] == ["in"] + + +def test_a_feature_crossing_the_selection_edge_is_kept_and_flagged() -> None: + crossing = box(1.5, 1.5, 3.0, 3.0) + + kept = ChangeDetectionService.restrict_to_selection( + [_feature("crossing", crossing)], + SELECTION, + ) + + assert len(kept) == 1 + assert kept[0]["partially_covered"] is True + # The geometry is not clipped: a change class describes a whole object, and + # comparing a clipped 2020 footprint with an unclipped 2024 one would + # invent change at the selection edge. + assert kept[0]["geometry"].equals(crossing) + + +def test_a_feature_wholly_inside_is_not_flagged() -> None: + kept = ChangeDetectionService.restrict_to_selection([_feature("in", INSIDE)], SELECTION) + + assert kept[0]["partially_covered"] is False + + +def test_no_selection_leaves_the_population_untouched() -> None: + features = [_feature("in", INSIDE), _feature("out", OUTSIDE)] + + assert ChangeDetectionService.restrict_to_selection(features, None) == features + + +def test_an_empty_intersection_is_an_explicit_error_not_a_silent_zero() -> None: + with pytest.raises(AppError) as exc_info: + ChangeDetectionService.restrict_to_selection([_feature("out", OUTSIDE)], SELECTION, label="Source") + + assert exc_info.value.code == "CHANGE_DETECTION_SELECTION_EMPTY" + assert "Source" in exc_info.value.message + + +def test_the_preview_is_capped_while_the_counts_stay_complete() -> None: + features = [ + { + "change_type": "added" if index % 2 else "removed", + "geometry": box(index, 0, index + 1, 1), + "source_feature_id": None, + "target_feature_id": f"t{index}", + "iou": None, + "properties": {}, + } + for index in range(250) + ] + + preview, truncated = ChangeDetectionService.limit_preview(features, limit=100) + + assert len(preview) == 100 + assert truncated is True + + +def test_a_short_result_is_not_reported_as_truncated() -> None: + features = [ + { + "change_type": "added", + "geometry": box(0, 0, 1, 1), + "source_feature_id": None, + "target_feature_id": "t", + "iou": None, + "properties": {}, + } + ] + + preview, truncated = ChangeDetectionService.limit_preview(features, limit=100) + + assert len(preview) == 1 + assert truncated is False + + +def test_the_preview_prefers_changes_over_unchanged_features() -> None: + """A cap must not spend its budget on the least interesting class.""" + + features = [ + {"change_type": "unchanged", "geometry": box(index, 0, index + 1, 1), "source_feature_id": f"s{index}", + "target_feature_id": f"t{index}", "iou": 1.0, "properties": {}} + for index in range(100) + ] + [ + {"change_type": "added", "geometry": box(0, 5, 1, 6), "source_feature_id": None, + "target_feature_id": "new", "iou": None, "properties": {}}, + {"change_type": "modified", "geometry": box(0, 7, 1, 8), "source_feature_id": "s", + "target_feature_id": "t", "iou": 0.6, "properties": {}}, + ] + + preview, truncated = ChangeDetectionService.limit_preview(features, limit=3) + + assert truncated is True + assert sorted(item["change_type"] for item in preview[:2]) == ["added", "modified"]