Add vector change detection foundation
This commit is contained in:
@@ -7,6 +7,14 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## Sprint 18 vector change detection foundation (2026-06-16)
|
||||
|
||||
- Added `POST /api/v1/analysis/change-detection` for synchronous comparison of two vector datasets in the same project.
|
||||
- Added a `ChangeDetectionService` that prefers persisted `vector_features`, falls back to stored GeoJSON with an explicit warning, and returns added/removed/unchanged GeoJSON features.
|
||||
- Added a frontend Change Detection panel and MapLibre change overlay styling for added, removed and unchanged geometries.
|
||||
- Added backend tests for persisted vector feature comparison and canonical API envelope behavior.
|
||||
- No migrations, live provider fetching, AI inference, new dependencies, LiDAR, Copilot, Training Studio or separate Reports module were introduced.
|
||||
|
||||
## Release hardening audit pass (2026-06-15)
|
||||
|
||||
- Replaced remaining backend `datetime.utcnow()` usage with timezone-aware UTC timestamps.
|
||||
|
||||
@@ -1 +1 @@
|
||||
__all__ = ["areas", "datasets", "health", "projects", "exports", "jobs", "external", "qa"]
|
||||
__all__ = ["analysis", "areas", "datasets", "health", "projects", "exports", "jobs", "external", "qa"]
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.db.session import get_db
|
||||
from app.models import Dataset
|
||||
from app.schemas.analysis import ChangeDetectionRequest
|
||||
from app.services.change_detection_service import ChangeDetectionService
|
||||
from app.services.job_service import JobService
|
||||
from app.utils.response import envelope
|
||||
|
||||
router = APIRouter(prefix="/analysis", tags=["analysis"])
|
||||
|
||||
|
||||
@router.post("/change-detection", response_model=dict)
|
||||
def run_change_detection(
|
||||
payload: ChangeDetectionRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
source_dataset = db.get(Dataset, payload.source_dataset_id)
|
||||
if not source_dataset:
|
||||
raise AppError(code="DATASET_NOT_FOUND", message="Source dataset not found", status_code=404)
|
||||
ChangeDetectionService._get_project_vector_dataset(db, payload.source_dataset_id, source_dataset.project_id, "Source")
|
||||
job = JobService.run_sync_job(
|
||||
db=db,
|
||||
project_id=source_dataset.project_id,
|
||||
job_type="analysis.change-detection",
|
||||
parameters=payload.model_dump(mode="json"),
|
||||
input_dataset_id=payload.source_dataset_id,
|
||||
operation=lambda: ChangeDetectionService.compare_vector_datasets(
|
||||
db=db,
|
||||
project_id=source_dataset.project_id,
|
||||
source_dataset_id=payload.source_dataset_id,
|
||||
target_dataset_id=payload.target_dataset_id,
|
||||
iou_threshold=payload.iou_threshold,
|
||||
include_unchanged=payload.include_unchanged,
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
return envelope(job)
|
||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api.routes import areas, datasets, demo, detection, exports, external, health, jobs, projects, qa, quality_checks, segmentation
|
||||
from app.api.routes import analysis, areas, datasets, demo, detection, exports, external, health, jobs, projects, qa, quality_checks, segmentation
|
||||
from app.core.config import get_settings
|
||||
from app.core.errors import AppError
|
||||
from app.core.logging import configure_logging
|
||||
@@ -41,6 +41,7 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(analysis.router, prefix=settings.api_prefix)
|
||||
app.include_router(projects.router, prefix=settings.api_prefix)
|
||||
app.include_router(areas.router, prefix=settings.api_prefix)
|
||||
app.include_router(datasets.router, prefix=settings.api_prefix)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from .common import ApiErrorEnvelope, ApiErrorItem, Envelope, PaginationEnvelope
|
||||
from .project import ProjectCreate, ProjectList, ProjectRead, ProjectUpdate
|
||||
from .area import AreaCreate, AreaList, AreaRead, AreaUpdate
|
||||
from .analysis import ChangeDetectionRequest, ChangeDetectionSummary
|
||||
from .dataset import DatasetCreateResponse, DatasetList
|
||||
from .detection import (
|
||||
DetectionListResponse,
|
||||
@@ -87,6 +88,8 @@ __all__ = [
|
||||
"AreaRead",
|
||||
"AreaUpdate",
|
||||
"AreaList",
|
||||
"ChangeDetectionRequest",
|
||||
"ChangeDetectionSummary",
|
||||
"DatasetCreateResponse",
|
||||
"DatasetList",
|
||||
"DetectionListResponse",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ChangeDetectionRequest(BaseModel):
|
||||
source_dataset_id: UUID
|
||||
target_dataset_id: UUID
|
||||
iou_threshold: float = Field(default=0.8, ge=0.0, le=1.0)
|
||||
include_unchanged: bool = True
|
||||
|
||||
|
||||
class ChangeDetectionSummary(BaseModel):
|
||||
source_dataset_id: UUID
|
||||
target_dataset_id: UUID
|
||||
source_feature_count: int
|
||||
target_feature_count: int
|
||||
added_count: int
|
||||
removed_count: int
|
||||
unchanged_count: int
|
||||
iou_threshold: float
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
generated_at: datetime
|
||||
geojson: dict
|
||||
@@ -0,0 +1,219 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from geoalchemy2.shape import to_shape
|
||||
from shapely.geometry import mapping
|
||||
from shapely.geometry.base import BaseGeometry
|
||||
from shapely.validation import make_valid
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models import Dataset, VectorFeature
|
||||
from app.schemas.analysis import ChangeDetectionSummary
|
||||
from app.services.vector_operations_service import VectorOperationsService
|
||||
|
||||
|
||||
class ChangeDetectionService:
|
||||
SUPPORTED_GEOMETRY_TYPES = {"Polygon", "MultiPolygon"}
|
||||
|
||||
@staticmethod
|
||||
def compare_vector_datasets(
|
||||
db: Session,
|
||||
*,
|
||||
project_id: UUID,
|
||||
source_dataset_id: UUID,
|
||||
target_dataset_id: UUID,
|
||||
iou_threshold: float = 0.8,
|
||||
include_unchanged: bool = True,
|
||||
) -> ChangeDetectionSummary:
|
||||
if source_dataset_id == target_dataset_id:
|
||||
raise AppError(code="INVALID_PARAMETERS", message="Source and target datasets must differ", status_code=400)
|
||||
if iou_threshold < 0 or iou_threshold > 1:
|
||||
raise AppError(code="INVALID_PARAMETERS", message="iou_threshold must be between 0 and 1", status_code=400)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
matched_target_indices: set[int] = set()
|
||||
unchanged: list[dict[str, Any]] = []
|
||||
removed: list[dict[str, Any]] = []
|
||||
|
||||
for source_feature in source_features:
|
||||
best_iou = 0.0
|
||||
best_index: int | None = None
|
||||
for target_index, target_feature in enumerate(target_features):
|
||||
if target_index in matched_target_indices:
|
||||
continue
|
||||
candidate_iou = ChangeDetectionService._iou(source_feature["geometry"], target_feature["geometry"])
|
||||
if candidate_iou > best_iou:
|
||||
best_iou = candidate_iou
|
||||
best_index = target_index
|
||||
|
||||
if best_index is not None and best_iou >= iou_threshold:
|
||||
matched_target_indices.add(best_index)
|
||||
if include_unchanged:
|
||||
unchanged.append(
|
||||
ChangeDetectionService._feature(
|
||||
geometry=source_feature["geometry"],
|
||||
change_type="unchanged",
|
||||
source_dataset_id=source_dataset_id,
|
||||
target_dataset_id=target_dataset_id,
|
||||
source_feature_id=source_feature["feature_id"],
|
||||
target_feature_id=target_features[best_index]["feature_id"],
|
||||
iou=best_iou,
|
||||
properties=source_feature["properties"],
|
||||
)
|
||||
)
|
||||
else:
|
||||
removed.append(
|
||||
ChangeDetectionService._feature(
|
||||
geometry=source_feature["geometry"],
|
||||
change_type="removed",
|
||||
source_dataset_id=source_dataset_id,
|
||||
target_dataset_id=target_dataset_id,
|
||||
source_feature_id=source_feature["feature_id"],
|
||||
target_feature_id=None,
|
||||
iou=best_iou if best_iou > 0 else None,
|
||||
properties=source_feature["properties"],
|
||||
)
|
||||
)
|
||||
|
||||
added = [
|
||||
ChangeDetectionService._feature(
|
||||
geometry=target_feature["geometry"],
|
||||
change_type="added",
|
||||
source_dataset_id=source_dataset_id,
|
||||
target_dataset_id=target_dataset_id,
|
||||
source_feature_id=None,
|
||||
target_feature_id=target_feature["feature_id"],
|
||||
iou=None,
|
||||
properties=target_feature["properties"],
|
||||
)
|
||||
for target_index, target_feature in enumerate(target_features)
|
||||
if target_index not in matched_target_indices
|
||||
]
|
||||
|
||||
geojson_features = added + removed + unchanged
|
||||
return ChangeDetectionSummary(
|
||||
source_dataset_id=source_dataset_id,
|
||||
target_dataset_id=target_dataset_id,
|
||||
source_feature_count=len(source_features),
|
||||
target_feature_count=len(target_features),
|
||||
added_count=len(added),
|
||||
removed_count=len(removed),
|
||||
unchanged_count=len(unchanged) if include_unchanged else len(matched_target_indices),
|
||||
iou_threshold=iou_threshold,
|
||||
warnings=source_warnings + target_warnings,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
geojson={"type": "FeatureCollection", "features": geojson_features},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_project_vector_dataset(db: Session, dataset_id: UUID, project_id: UUID, label: str) -> Dataset:
|
||||
dataset = db.get(Dataset, dataset_id)
|
||||
if not dataset:
|
||||
raise AppError(code="DATASET_NOT_FOUND", message=f"{label} dataset not found", status_code=404)
|
||||
if dataset.project_id != project_id:
|
||||
raise AppError(code="INVALID_DATASET_SCOPE", message=f"{label} dataset does not belong to this project", status_code=400)
|
||||
VectorOperationsService._require_vector_dataset(dataset)
|
||||
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()
|
||||
warnings: list[str] = []
|
||||
if rows:
|
||||
return [ChangeDetectionService._row_to_feature(row) for row in rows], warnings
|
||||
|
||||
warnings.append(f"Dataset {dataset.id} has no persisted vector_features; falling back to stored GeoJSON artifact")
|
||||
_payload, raw_features = VectorOperationsService._load_dataset_payload(dataset)
|
||||
extracted = VectorOperationsService._extract_geometries(raw_features)
|
||||
return [
|
||||
ChangeDetectionService._raw_feature_to_feature(index, raw_feature, geometry)
|
||||
for index, (raw_feature, geometry) in enumerate(extracted)
|
||||
], warnings
|
||||
|
||||
@staticmethod
|
||||
def _row_to_feature(row: VectorFeature) -> dict[str, Any]:
|
||||
geometry = ChangeDetectionService._valid_comparable_geometry(to_shape(row.geometry))
|
||||
return {
|
||||
"feature_id": str(row.source_feature_id or row.id),
|
||||
"properties": dict(row.properties_json or {}),
|
||||
"geometry": geometry,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _raw_feature_to_feature(index: int, raw_feature: dict[str, Any], geometry: BaseGeometry) -> dict[str, Any]:
|
||||
properties = raw_feature.get("properties") if isinstance(raw_feature.get("properties"), dict) else {}
|
||||
source_id = raw_feature.get("id") or properties.get("id") or properties.get("source_feature_id") or str(index)
|
||||
return {
|
||||
"feature_id": str(source_id),
|
||||
"properties": dict(properties),
|
||||
"geometry": ChangeDetectionService._valid_comparable_geometry(geometry),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _valid_comparable_geometry(geometry: BaseGeometry) -> BaseGeometry:
|
||||
if geometry.is_empty:
|
||||
raise AppError(code="INVALID_GEOMETRY", message="Empty geometry cannot be compared", status_code=400)
|
||||
if not geometry.is_valid:
|
||||
geometry = make_valid(geometry)
|
||||
if geometry.is_empty or not geometry.is_valid:
|
||||
raise AppError(code="INVALID_GEOMETRY", message="Geometry cannot be repaired for comparison", status_code=400)
|
||||
if geometry.geom_type not in ChangeDetectionService.SUPPORTED_GEOMETRY_TYPES:
|
||||
raise AppError(
|
||||
code="UNSUPPORTED_GEOMETRY",
|
||||
message="Change detection supports Polygon and MultiPolygon geometries only",
|
||||
details={"geometry_type": geometry.geom_type},
|
||||
status_code=422,
|
||||
)
|
||||
return geometry
|
||||
|
||||
@staticmethod
|
||||
def _iou(left: BaseGeometry, right: BaseGeometry) -> float:
|
||||
if left.area <= 0 or right.area <= 0:
|
||||
return 0.0
|
||||
intersection = left.intersection(right)
|
||||
if intersection.is_empty:
|
||||
return 0.0
|
||||
union_area = left.area + right.area - intersection.area
|
||||
if union_area <= 0:
|
||||
return 0.0
|
||||
return float(intersection.area / union_area)
|
||||
|
||||
@staticmethod
|
||||
def _feature(
|
||||
*,
|
||||
geometry: BaseGeometry,
|
||||
change_type: str,
|
||||
source_dataset_id: UUID,
|
||||
target_dataset_id: UUID,
|
||||
source_feature_id: str | None,
|
||||
target_feature_id: str | None,
|
||||
iou: float | None,
|
||||
properties: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "Feature",
|
||||
"geometry": mapping(geometry),
|
||||
"properties": {
|
||||
**properties,
|
||||
"change_type": change_type,
|
||||
"source_dataset_id": str(source_dataset_id),
|
||||
"target_dataset_id": str(target_dataset_id),
|
||||
"source_feature_id": source_feature_id,
|
||||
"target_feature_id": target_feature_id,
|
||||
"iou": iou,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from geoalchemy2.shape import from_shape
|
||||
from shapely.geometry import box
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.main import app
|
||||
from app.models import Dataset, Job, VectorFeature
|
||||
from app.schemas.analysis import ChangeDetectionSummary
|
||||
from app.services.change_detection_service import ChangeDetectionService
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, rows):
|
||||
self.rows = list(rows)
|
||||
|
||||
def filter(self, *criteria):
|
||||
for criterion in criteria:
|
||||
left = getattr(criterion, "left", None)
|
||||
right = getattr(criterion, "right", None)
|
||||
operator = getattr(criterion, "operator", None)
|
||||
name = getattr(left, "name", None)
|
||||
value = getattr(right, "value", right)
|
||||
if name and operator and operator.__name__ == "eq":
|
||||
self.rows = [row for row in self.rows if getattr(row, name) == value]
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, objects=None, query_rows=None) -> None:
|
||||
self.objects = objects or {}
|
||||
self.query_rows = query_rows or {}
|
||||
self.added = []
|
||||
self.commits = 0
|
||||
self.refreshes = []
|
||||
|
||||
def get(self, model, item_id):
|
||||
return self.objects.get((model, item_id))
|
||||
|
||||
def query(self, model):
|
||||
return FakeQuery(self.query_rows.get(model, []))
|
||||
|
||||
def add(self, item) -> None:
|
||||
self.added.append(item)
|
||||
if getattr(item, "id", None) is not None:
|
||||
self.objects[(item.__class__, item.id)] = item
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
def refresh(self, item) -> None:
|
||||
self.refreshes.append(item)
|
||||
|
||||
|
||||
def _dataset(dataset_id, project_id, name):
|
||||
return Dataset(
|
||||
id=dataset_id,
|
||||
project_id=project_id,
|
||||
name=name,
|
||||
dataset_type="vector",
|
||||
source="manual",
|
||||
dataset_role="source",
|
||||
)
|
||||
|
||||
|
||||
def _feature(dataset_id, source_feature_id, geometry):
|
||||
return VectorFeature(
|
||||
id=uuid4(),
|
||||
dataset_id=dataset_id,
|
||||
source_feature_id=source_feature_id,
|
||||
geometry=from_shape(geometry, srid=4326),
|
||||
properties_json={"source_feature_id": source_feature_id},
|
||||
)
|
||||
|
||||
|
||||
def test_change_detection_compares_persisted_vector_features() -> None:
|
||||
project_id = uuid4()
|
||||
source_dataset_id = uuid4()
|
||||
target_dataset_id = uuid4()
|
||||
source_dataset = _dataset(source_dataset_id, project_id, "before.geojson")
|
||||
target_dataset = _dataset(target_dataset_id, project_id, "after.geojson")
|
||||
rows = [
|
||||
_feature(source_dataset_id, "source-unchanged", box(0, 0, 1, 1)),
|
||||
_feature(source_dataset_id, "source-removed", box(10, 10, 11, 11)),
|
||||
_feature(target_dataset_id, "target-unchanged", box(0, 0, 1, 1)),
|
||||
_feature(target_dataset_id, "target-added", box(20, 20, 21, 21)),
|
||||
]
|
||||
db = FakeSession(
|
||||
objects={(Dataset, source_dataset_id): source_dataset, (Dataset, target_dataset_id): target_dataset},
|
||||
query_rows={VectorFeature: rows},
|
||||
)
|
||||
|
||||
result = ChangeDetectionService.compare_vector_datasets(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
source_dataset_id=source_dataset_id,
|
||||
target_dataset_id=target_dataset_id,
|
||||
iou_threshold=0.8,
|
||||
)
|
||||
|
||||
change_types = [feature["properties"]["change_type"] for feature in result.geojson["features"]]
|
||||
assert result.source_feature_count == 2
|
||||
assert result.target_feature_count == 2
|
||||
assert result.added_count == 1
|
||||
assert result.removed_count == 1
|
||||
assert result.unchanged_count == 1
|
||||
assert sorted(change_types) == ["added", "removed", "unchanged"]
|
||||
assert result.warnings == []
|
||||
|
||||
|
||||
def test_change_detection_endpoint_returns_canonical_envelope(monkeypatch) -> None:
|
||||
project_id = uuid4()
|
||||
source_dataset_id = uuid4()
|
||||
target_dataset_id = uuid4()
|
||||
source_dataset = _dataset(source_dataset_id, project_id, "before.geojson")
|
||||
target_dataset = _dataset(target_dataset_id, project_id, "after.geojson")
|
||||
db = FakeSession(objects={(Dataset, source_dataset_id): source_dataset, (Dataset, target_dataset_id): target_dataset})
|
||||
summary = ChangeDetectionSummary(
|
||||
source_dataset_id=source_dataset_id,
|
||||
target_dataset_id=target_dataset_id,
|
||||
source_feature_count=1,
|
||||
target_feature_count=1,
|
||||
added_count=0,
|
||||
removed_count=0,
|
||||
unchanged_count=1,
|
||||
iou_threshold=0.8,
|
||||
warnings=[],
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
geojson={"type": "FeatureCollection", "features": []},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.api.routes.analysis.ChangeDetectionService.compare_vector_datasets",
|
||||
lambda **_kwargs: summary,
|
||||
)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
try:
|
||||
response = TestClient(app).post(
|
||||
"/api/v1/analysis/change-detection",
|
||||
json={
|
||||
"source_dataset_id": str(source_dataset_id),
|
||||
"target_dataset_id": str(target_dataset_id),
|
||||
"iou_threshold": 0.8,
|
||||
"include_unchanged": True,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert set(payload) == {"data"}
|
||||
assert payload["data"]["job_type"] == "analysis.change-detection"
|
||||
assert payload["data"]["status"] == "success"
|
||||
assert payload["data"]["result_json"]["unchanged_count"] == 1
|
||||
assert any(isinstance(item, Job) for item in db.added)
|
||||
+58
-1
@@ -795,7 +795,64 @@ If the segmentation run has no persisted geometries, the endpoint returns `SEGME
|
||||
|
||||
### POST `/api/v1/analysis/change-detection`
|
||||
|
||||
Request contains source analysis or datasets A/B and method.
|
||||
Compares two persisted vector datasets in the same project and returns a synchronous
|
||||
job envelope. This is a lightweight V1 foundation for added/removed object review,
|
||||
not a temporal run-history engine.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"source_dataset_id": "uuid",
|
||||
"target_dataset_id": "uuid",
|
||||
"iou_threshold": 0.8,
|
||||
"include_unchanged": true
|
||||
}
|
||||
```
|
||||
|
||||
Response is a canonical API envelope containing a `JobRead` payload. On success,
|
||||
`result_json` contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"source_dataset_id": "uuid",
|
||||
"target_dataset_id": "uuid",
|
||||
"source_feature_count": 2,
|
||||
"target_feature_count": 2,
|
||||
"added_count": 1,
|
||||
"removed_count": 1,
|
||||
"unchanged_count": 1,
|
||||
"iou_threshold": 0.8,
|
||||
"warnings": [],
|
||||
"generated_at": "2026-06-16T00:00:00Z",
|
||||
"geojson": {
|
||||
"type": "FeatureCollection",
|
||||
"features": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Change detection prefers persisted `vector_features`. If an older vector dataset
|
||||
has no persisted vector rows, it falls back to the stored GeoJSON artifact and
|
||||
adds a warning to `result_json.warnings`. Supported comparable geometry types are
|
||||
`Polygon` and `MultiPolygon`; point/line geometries return `UNSUPPORTED_GEOMETRY`.
|
||||
|
||||
GeoJSON feature properties include:
|
||||
|
||||
- `change_type`: `added`, `removed` or `unchanged`
|
||||
- `source_dataset_id`
|
||||
- `target_dataset_id`
|
||||
- `source_feature_id`
|
||||
- `target_feature_id`
|
||||
- `iou`
|
||||
|
||||
Limitations:
|
||||
|
||||
- No live GRB/OSM/Sentinel fetching.
|
||||
- No fake object lifecycle classification.
|
||||
- No `changed` classification without durable object ids/versioning.
|
||||
- No first-class change table yet; the current output is stored in job
|
||||
`result_json` and rendered in the frontend map.
|
||||
|
||||
## QA/QC
|
||||
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
## Sprint 18 vector change detection foundation (2026-06-16)
|
||||
|
||||
Changed:
|
||||
- Added `POST /api/v1/analysis/change-detection` for comparing two vector datasets in the same project through the existing synchronous job envelope.
|
||||
- Added `ChangeDetectionService` with persisted `vector_features` as the primary source of comparable geometries and explicit stored-GeoJSON fallback warnings for older datasets.
|
||||
- Added frontend Change Detection controls, summary counts and MapLibre overlay styling for `added`, `removed` and `unchanged` feature properties.
|
||||
- Added backend tests for persisted-vector comparison and canonical API envelope behavior.
|
||||
|
||||
Limitations:
|
||||
- The foundation classifies `added`, `removed` and `unchanged` only. It does not emit fake `changed` objects without durable object ids/versioning.
|
||||
- No migrations, live GRB/OSM/Sentinel fetching, AI inference, new dependencies, LiDAR, Copilot, Training Studio or separate Reports module were introduced.
|
||||
|
||||
Validation:
|
||||
- `python -m compileall backend/app`
|
||||
- `cd backend && python -m pytest -W error::DeprecationWarning`
|
||||
- `cd frontend && npm run typecheck`
|
||||
- `cd frontend && npm run build`
|
||||
- `bash scripts/run_readiness_check.sh` via Git Bash on Windows
|
||||
- `cd backend && python -m alembic heads && python -m alembic upgrade head --sql`
|
||||
# Codex Execution Log
|
||||
|
||||
This file must be updated by Codex after each implementation pass.
|
||||
|
||||
+3
-3
@@ -159,9 +159,9 @@ This file now starts with the current implementation status. Older preparation/b
|
||||
## 12. Change Detection
|
||||
|
||||
- [ ] Compare two runs
|
||||
- [ ] Added/removed objects
|
||||
- [ ] Change stats
|
||||
- [ ] Change layer
|
||||
- [x] Added/removed objects
|
||||
- [x] Change stats
|
||||
- [x] Change layer
|
||||
|
||||
## 13. Tests
|
||||
|
||||
|
||||
+75
-2
@@ -4,13 +4,15 @@ import GeoMap from './components/GeoMap'
|
||||
import { areasApi } from './services/api/areas'
|
||||
import { datasetsApi } from './services/api/datasets'
|
||||
import { projectsApi } from './services/api/projects'
|
||||
import { demoApi, detectionApi, jobsApi, externalApi, exportsApi, qaApi, segmentationApi } from './services/api'
|
||||
import { analysisApi, demoApi, detectionApi, jobsApi, externalApi, exportsApi, qaApi, segmentationApi } from './services/api'
|
||||
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
|
||||
import { DetectionLab } from './components/detection/DetectionLab'
|
||||
import { ExportCenter } from './components/exports/ExportCenter'
|
||||
import { AreaPanel } from './components/project/AreaPanel'
|
||||
import { ProjectPanel } from './components/project/ProjectPanel'
|
||||
import type {
|
||||
ApiError,
|
||||
ChangeDetectionSummary,
|
||||
DatasetCreateResponse,
|
||||
DatasetListResponse,
|
||||
DetectionQaResult,
|
||||
@@ -96,6 +98,13 @@ function App(): JSX.Element {
|
||||
const [providerCapabilities, setProviderCapabilities] = useState<ProviderCapability[]>([])
|
||||
const [loadingCapabilities, setLoadingCapabilities] = useState(false)
|
||||
const [capabilitiesError, setCapabilitiesError] = useState<string | null>(null)
|
||||
const [changeSourceDatasetId, setChangeSourceDatasetId] = useState('')
|
||||
const [changeTargetDatasetId, setChangeTargetDatasetId] = useState('')
|
||||
const [changeIouThreshold, setChangeIouThreshold] = useState(0.8)
|
||||
const [changeIncludeUnchanged, setChangeIncludeUnchanged] = useState(true)
|
||||
const [runningChangeDetection, setRunningChangeDetection] = useState(false)
|
||||
const [changeDetectionResult, setChangeDetectionResult] = useState<ChangeDetectionSummary | null>(null)
|
||||
const [changeDetectionError, setChangeDetectionError] = useState<string | null>(null)
|
||||
const [detectionModels, setDetectionModels] = useState<DetectionModelCapability[]>([])
|
||||
const [loadingDetectionModels, setLoadingDetectionModels] = useState(false)
|
||||
const [detectionModelError, setDetectionModelError] = useState<string | null>(null)
|
||||
@@ -231,7 +240,10 @@ function App(): JSX.Element {
|
||||
() => segmentationModels.find((model) => model.model_id === selectedSegmentationModelId) ?? null,
|
||||
[segmentationModels, selectedSegmentationModelId],
|
||||
)
|
||||
const mapFeatureCollection = useMemo(() => segmentationGeoJson ?? detectionGeoJson ?? datasetContent, [segmentationGeoJson, detectionGeoJson, datasetContent])
|
||||
const mapFeatureCollection = useMemo(
|
||||
() => changeDetectionResult?.geojson ?? segmentationGeoJson ?? detectionGeoJson ?? datasetContent,
|
||||
[changeDetectionResult, segmentationGeoJson, detectionGeoJson, datasetContent],
|
||||
)
|
||||
const isRasterTileInputValid = useMemo(
|
||||
() => rasterTileSize > 0 && rasterTileOverlap >= 0 && rasterTileOverlap < rasterTileSize,
|
||||
[rasterTileSize, rasterTileOverlap],
|
||||
@@ -1119,6 +1131,51 @@ function App(): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const runChangeDetection = async () => {
|
||||
const sourceDatasetId = changeSourceDatasetId || availableVectorDatasets[0]?.id
|
||||
const targetDatasetId =
|
||||
changeTargetDatasetId || availableVectorDatasets.find((dataset) => dataset.id !== sourceDatasetId)?.id
|
||||
if (!sourceDatasetId || !targetDatasetId) {
|
||||
setChangeDetectionError('Select two vector datasets')
|
||||
return
|
||||
}
|
||||
if (sourceDatasetId === targetDatasetId) {
|
||||
setChangeDetectionError('Source and target datasets must differ')
|
||||
return
|
||||
}
|
||||
if (changeIouThreshold < 0 || changeIouThreshold > 1) {
|
||||
setChangeDetectionError('IoU threshold must be between 0 and 1')
|
||||
return
|
||||
}
|
||||
setChangeDetectionError(null)
|
||||
setChangeDetectionResult(null)
|
||||
setRunningChangeDetection(true)
|
||||
try {
|
||||
const job = await analysisApi.runChangeDetection({
|
||||
source_dataset_id: sourceDatasetId,
|
||||
target_dataset_id: targetDatasetId,
|
||||
iou_threshold: changeIouThreshold,
|
||||
include_unchanged: changeIncludeUnchanged,
|
||||
})
|
||||
if (job.status !== 'success') {
|
||||
throw new Error(job.error_message || 'Change detection job failed')
|
||||
}
|
||||
if (!job.result_json) {
|
||||
throw new Error('Change detection completed without result payload')
|
||||
}
|
||||
setChangeSourceDatasetId(sourceDatasetId)
|
||||
setChangeTargetDatasetId(targetDatasetId)
|
||||
setChangeDetectionResult(job.result_json)
|
||||
if (selectedProjectId) {
|
||||
await loadDatasetJobs(selectedProjectId, sourceDatasetId)
|
||||
}
|
||||
} catch (error) {
|
||||
setChangeDetectionError(formatError(error, 'Change detection failed'))
|
||||
} finally {
|
||||
setRunningChangeDetection(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runDetectionQa = async () => {
|
||||
if (!selectedDetectionRunId) {
|
||||
setDetectionQaError('Select a detection run')
|
||||
@@ -1280,6 +1337,22 @@ function App(): JSX.Element {
|
||||
onRefresh={loadCapabilities}
|
||||
/>
|
||||
|
||||
<ChangeDetectionPanel
|
||||
vectorDatasets={availableVectorDatasets}
|
||||
sourceDatasetId={changeSourceDatasetId}
|
||||
targetDatasetId={changeTargetDatasetId}
|
||||
iouThreshold={changeIouThreshold}
|
||||
includeUnchanged={changeIncludeUnchanged}
|
||||
running={runningChangeDetection}
|
||||
result={changeDetectionResult}
|
||||
error={changeDetectionError}
|
||||
onSourceDatasetChange={setChangeSourceDatasetId}
|
||||
onTargetDatasetChange={setChangeTargetDatasetId}
|
||||
onIouThresholdChange={setChangeIouThreshold}
|
||||
onIncludeUnchangedChange={setChangeIncludeUnchanged}
|
||||
onRun={runChangeDetection}
|
||||
/>
|
||||
|
||||
<DetectionLab
|
||||
detectionModels={detectionModels}
|
||||
loadingDetectionModels={loadingDetectionModels}
|
||||
|
||||
@@ -89,13 +89,39 @@ function GeoMap({ data }: GeoMapProps): JSX.Element {
|
||||
id: 'dataset-fill',
|
||||
type: 'fill',
|
||||
source: 'dataset',
|
||||
paint: { 'fill-color': '#f97316', 'fill-opacity': 0.4 },
|
||||
paint: {
|
||||
'fill-color': [
|
||||
'match',
|
||||
['get', 'change_type'],
|
||||
'added',
|
||||
'#16a34a',
|
||||
'removed',
|
||||
'#dc2626',
|
||||
'unchanged',
|
||||
'#2563eb',
|
||||
'#f97316',
|
||||
],
|
||||
'fill-opacity': 0.4,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'dataset-line',
|
||||
type: 'line',
|
||||
source: 'dataset',
|
||||
paint: { 'line-color': '#ea580c', 'line-width': 2 },
|
||||
paint: {
|
||||
'line-color': [
|
||||
'match',
|
||||
['get', 'change_type'],
|
||||
'added',
|
||||
'#15803d',
|
||||
'removed',
|
||||
'#b91c1c',
|
||||
'unchanged',
|
||||
'#1d4ed8',
|
||||
'#ea580c',
|
||||
],
|
||||
'line-width': 2,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ChangeDetectionSummary, DatasetCreateResponse } from '../../types'
|
||||
|
||||
interface ChangeDetectionPanelProps {
|
||||
vectorDatasets: DatasetCreateResponse[]
|
||||
sourceDatasetId: string
|
||||
targetDatasetId: string
|
||||
iouThreshold: number
|
||||
includeUnchanged: boolean
|
||||
running: boolean
|
||||
result: ChangeDetectionSummary | null
|
||||
error: string | null
|
||||
onSourceDatasetChange: (value: string) => void
|
||||
onTargetDatasetChange: (value: string) => void
|
||||
onIouThresholdChange: (value: number) => void
|
||||
onIncludeUnchangedChange: (value: boolean) => void
|
||||
onRun: () => void
|
||||
}
|
||||
|
||||
function datasetLabel(dataset: DatasetCreateResponse): string {
|
||||
const role = dataset.dataset_role ? ` (${dataset.dataset_role})` : ''
|
||||
return `${dataset.name}${role}`
|
||||
}
|
||||
|
||||
export function ChangeDetectionPanel({
|
||||
vectorDatasets,
|
||||
sourceDatasetId,
|
||||
targetDatasetId,
|
||||
iouThreshold,
|
||||
includeUnchanged,
|
||||
running,
|
||||
result,
|
||||
error,
|
||||
onSourceDatasetChange,
|
||||
onTargetDatasetChange,
|
||||
onIouThresholdChange,
|
||||
onIncludeUnchangedChange,
|
||||
onRun,
|
||||
}: ChangeDetectionPanelProps): JSX.Element {
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Analysis</p>
|
||||
<h2>Change Detection</h2>
|
||||
</div>
|
||||
<button disabled={running || vectorDatasets.length < 2} onClick={onRun} type="button">
|
||||
{running ? 'Comparing...' : 'Compare vectors'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
Source vector
|
||||
<select value={sourceDatasetId} onChange={(event) => onSourceDatasetChange(event.target.value)}>
|
||||
<option value="">Select source</option>
|
||||
{vectorDatasets.map((dataset) => (
|
||||
<option key={dataset.id} value={dataset.id}>
|
||||
{datasetLabel(dataset)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Target vector
|
||||
<select value={targetDatasetId} onChange={(event) => onTargetDatasetChange(event.target.value)}>
|
||||
<option value="">Select target</option>
|
||||
{vectorDatasets.map((dataset) => (
|
||||
<option key={dataset.id} value={dataset.id}>
|
||||
{datasetLabel(dataset)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
IoU threshold
|
||||
<input
|
||||
max="1"
|
||||
min="0"
|
||||
step="0.05"
|
||||
type="number"
|
||||
value={iouThreshold}
|
||||
onChange={(event) => onIouThresholdChange(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
checked={includeUnchanged}
|
||||
type="checkbox"
|
||||
onChange={(event) => onIncludeUnchangedChange(event.target.checked)}
|
||||
/>
|
||||
Include unchanged geometry
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
{!error && vectorDatasets.length < 2 ? <p className="muted">Upload at least two vector datasets to compare.</p> : null}
|
||||
|
||||
{result ? (
|
||||
<div className="summary-grid">
|
||||
<div>
|
||||
<span className="metric">{result.added_count}</span>
|
||||
<span>Added</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="metric">{result.removed_count}</span>
|
||||
<span>Removed</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="metric">{result.unchanged_count}</span>
|
||||
<span>Unchanged</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="metric">{result.geojson.features.length}</span>
|
||||
<span>Map features</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result?.warnings.length ? (
|
||||
<ul className="compact-list">
|
||||
{result.warnings.map((warning) => (
|
||||
<li key={warning}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { apiPost } from './client'
|
||||
import type { ChangeDetectionRequest, ChangeDetectionSummary, JobRead } from '../../types'
|
||||
|
||||
export const analysisApi = {
|
||||
runChangeDetection: (payload: ChangeDetectionRequest): Promise<JobRead & { result_json?: ChangeDetectionSummary | null }> =>
|
||||
apiPost<JobRead & { result_json?: ChangeDetectionSummary | null }>('/api/v1/analysis/change-detection', payload),
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { areasApi } from './areas'
|
||||
export { analysisApi } from './analysis'
|
||||
export { datasetsApi } from './datasets'
|
||||
export { demoApi } from './demo'
|
||||
export { detectionApi } from './detection'
|
||||
|
||||
@@ -79,6 +79,73 @@ ul {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.2rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.panel-header button {
|
||||
width: auto;
|
||||
min-width: 9rem;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkbox-row input {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.summary-grid > div {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: block;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.compact-list {
|
||||
padding-left: 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
width: 100%;
|
||||
height: 460px;
|
||||
|
||||
@@ -283,6 +283,27 @@ export interface GeojsonEnvelopeResponse {
|
||||
data: object
|
||||
}
|
||||
|
||||
export interface ChangeDetectionRequest {
|
||||
source_dataset_id: string
|
||||
target_dataset_id: string
|
||||
iou_threshold: number
|
||||
include_unchanged: boolean
|
||||
}
|
||||
|
||||
export interface ChangeDetectionSummary {
|
||||
source_dataset_id: string
|
||||
target_dataset_id: string
|
||||
source_feature_count: number
|
||||
target_feature_count: number
|
||||
added_count: number
|
||||
removed_count: number
|
||||
unchanged_count: number
|
||||
iou_threshold: number
|
||||
warnings: string[]
|
||||
generated_at: string
|
||||
geojson: GeoJSON.FeatureCollection
|
||||
}
|
||||
|
||||
export interface ProviderCapability {
|
||||
provider_name: string
|
||||
display_name: string
|
||||
|
||||
Reference in New Issue
Block a user