Add vector change detection foundation
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-16 23:55:52 +02:00
parent 97017c6512
commit 01f063b921
18 changed files with 871 additions and 10 deletions
+8
View File
@@ -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
View File
@@ -1 +1 @@
__all__ = ["areas", "datasets", "health", "projects", "exports", "jobs", "external", "qa"]
__all__ = ["analysis", "areas", "datasets", "health", "projects", "exports", "jobs", "external", "qa"]
+41
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+27
View File
@@ -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
View File
@@ -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
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+28 -2
View File
@@ -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>
)
}
+7
View File
@@ -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
View File
@@ -1,4 +1,5 @@
export { areasApi } from './areas'
export { analysisApi } from './analysis'
export { datasetsApi } from './datasets'
export { demoApi } from './demo'
export { detectionApi } from './detection'
+67
View File
@@ -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;
+21
View File
@@ -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