Add vector change detection foundation
This commit is contained in:
+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