diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f7b68fb..61f6ca03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index 585499ea..43122e71 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -1 +1 @@ -__all__ = ["areas", "datasets", "health", "projects", "exports", "jobs", "external", "qa"] +__all__ = ["analysis", "areas", "datasets", "health", "projects", "exports", "jobs", "external", "qa"] diff --git a/backend/app/api/routes/analysis.py b/backend/app/api/routes/analysis.py new file mode 100644 index 00000000..76e158f2 --- /dev/null +++ b/backend/app/api/routes/analysis.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 3e5ece17..705b4b18 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 56015829..d327303a 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -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", diff --git a/backend/app/schemas/analysis.py b/backend/app/schemas/analysis.py new file mode 100644 index 00000000..02a054f4 --- /dev/null +++ b/backend/app/schemas/analysis.py @@ -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 diff --git a/backend/app/services/change_detection_service.py b/backend/app/services/change_detection_service.py new file mode 100644 index 00000000..5bc8aac3 --- /dev/null +++ b/backend/app/services/change_detection_service.py @@ -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, + }, + } diff --git a/backend/tests/test_sprint18_change_detection.py b/backend/tests/test_sprint18_change_detection.py new file mode 100644 index 00000000..01b996b0 --- /dev/null +++ b/backend/tests/test_sprint18_change_detection.py @@ -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) diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 3cca46d9..f973ef3e 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -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 diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 0f004939..539f3acb 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -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. diff --git a/docs/TODO.md b/docs/TODO.md index 8406a945..a6d0661a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 32ffd9c0..5026e3bf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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([]) const [loadingCapabilities, setLoadingCapabilities] = useState(false) const [capabilitiesError, setCapabilitiesError] = useState(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(null) + const [changeDetectionError, setChangeDetectionError] = useState(null) const [detectionModels, setDetectionModels] = useState([]) const [loadingDetectionModels, setLoadingDetectionModels] = useState(false) const [detectionModelError, setDetectionModelError] = useState(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} /> + + 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 ( +
+
+
+

Analysis

+

Change Detection

+
+ +
+ +
+ + + + +
+ + {error ?

{error}

: null} + {!error && vectorDatasets.length < 2 ?

Upload at least two vector datasets to compare.

: null} + + {result ? ( +
+
+ {result.added_count} + Added +
+
+ {result.removed_count} + Removed +
+
+ {result.unchanged_count} + Unchanged +
+
+ {result.geojson.features.length} + Map features +
+
+ ) : null} + + {result?.warnings.length ? ( +
    + {result.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+ ) : null} +
+ ) +} diff --git a/frontend/src/services/api/analysis.ts b/frontend/src/services/api/analysis.ts new file mode 100644 index 00000000..383fb8b4 --- /dev/null +++ b/frontend/src/services/api/analysis.ts @@ -0,0 +1,7 @@ +import { apiPost } from './client' +import type { ChangeDetectionRequest, ChangeDetectionSummary, JobRead } from '../../types' + +export const analysisApi = { + runChangeDetection: (payload: ChangeDetectionRequest): Promise => + apiPost('/api/v1/analysis/change-detection', payload), +} diff --git a/frontend/src/services/api/index.ts b/frontend/src/services/api/index.ts index 47fff464..6c311f7d 100644 --- a/frontend/src/services/api/index.ts +++ b/frontend/src/services/api/index.ts @@ -1,4 +1,5 @@ export { areasApi } from './areas' +export { analysisApi } from './analysis' export { datasetsApi } from './datasets' export { demoApi } from './demo' export { detectionApi } from './detection' diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index fc7dfb37..f9ade1ae 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9111632f..682ee379 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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