bound change detection to the operator's selection

Change detection was the one analysis that ignored the selection entirely. It
compared two datasets in full, loaded every feature of both into Python with no
spatial predicate, and — with include_unchanged defaulting to true — returned a
FeatureCollection holding both datasets. For a regional building layer that is
the wrong answer to "what changed here" and a response no browser should be
asked to hold.

It now accepts bbox and area_id, resolved the way every other analysis resolves
them, and loads through an indexed ST_Intersects predicate.

Features are deliberately not clipped to the selection. A change class
describes a whole object: comparing a clipped earlier footprint against an
unclipped later one would report the selection edge itself as a change. Objects
the edge crosses are compared in full and counted in a warning.

The returned geometry is capped by preview_limit, spending that budget on
modified, added and removed before unchanged, while every count still describes
the whole selection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jens
2026-08-22 14:56:15 +02:00
co-authored by Claude Opus 5
parent d29d572e8c
commit 12aaf1bb4d
4 changed files with 295 additions and 7 deletions
+15
View File
@@ -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