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
+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