feat: add map-driven orthophoto analysis
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 02:01:03 +02:00
parent 845c4696e7
commit daccd3869a
29 changed files with 1300 additions and 26 deletions
+7
View File
@@ -27,6 +27,13 @@ choose-theme, draw-area, read-result flow.
The primary workflow is deliberately short: choose a municipality or the complete region, choose a data theme, drag a rectangle on the MapLibre map and read the resulting PostGIS evidence. Releasing the drag runs the active theme query and every other available theme query for the same EPSG:4326 bbox. The result panel shows selection area, exact intersection totals, active-theme density, source identity and bounded feature properties. Map rendering remains capped at 1,000 features while `total_feature_count` reports the exact database count.
For a rectangle in `Laatste toestand`, `Herken gebouwen` runs the complete
operational image path without opening the technical AI screen: bounded
official orthophoto acquisition, raster persistence, safe tiling, the active
local YOLO model, Detection persistence and automatic QA against ready GRB
buildings. The panel shows all stages and errors; successful detections open as
an explicit AI-result overlay. Rectangles must be 128-1,024 m per side.
The theme catalog currently recognizes buildings, population, forest/green, water, roads and parcels from dataset names and canonical `reference_layer_name` metadata. A theme is enabled only when a ready persisted vector dataset exists; otherwise it states `Bron nog niet ingeladen`. This prevents missing population or land-cover sources from appearing as zero-valued observations. The previous technical Map workspace remains available through `Geavanceerde werkbank` for derived datasets, QA/QC evidence and export operations.
The workbench uses a task-based shell instead of a single long panel stack. `App.tsx` still owns shared orchestration state, but Map is the default product entry and Overview, Data, QA/QC, AI Labs, Exports and System remain secondary workspaces with a persistent top context bar and an optional selection-detail drawer.
+21
View File
@@ -24,6 +24,7 @@ import { useMapSelectionDataset } from './hooks/useMapSelectionDataset'
import { useMapSelectionQa } from './hooks/useMapSelectionQa'
import { useMapWorkspaceState } from './hooks/useMapWorkspaceState'
import { useMapSelectionExtract } from './hooks/useMapSelectionExtract'
import { useMapOrthophotoAnalysis } from './hooks/useMapOrthophotoAnalysis'
import { useProviderCapabilities } from './hooks/useProviderCapabilities'
import { useProjectWorkspace } from './hooks/useProjectWorkspace'
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
@@ -278,6 +279,7 @@ function App(): JSX.Element {
runDetection,
uploadDetectionRaster,
prepareAndRunDetection,
compareDetectionRunWithReference,
runDetectionQa,
runDetectionCalibration,
applyDetectionOperatorProfile,
@@ -468,6 +470,20 @@ function App(): JSX.Element {
loadQualityChecks,
loadProjectData,
})
const mapOrthophotoAnalysis = useMapOrthophotoAnalysis({
selectedProjectId,
selectedAreaId: selectedMapAreaId,
datasets,
loadProjectData,
prepareAndRunDetection: (datasetId) => prepareAndRunDetection(datasetId, 'yolo-configured'),
compareDetectionRunWithReference: (analysisRunId, referenceDatasetId) =>
compareDetectionRunWithReference(analysisRunId, referenceDatasetId, false),
onAnalysisReady: () => {
setMapContentMode('analysis')
setMapLayerVisible(true)
setActiveWorkspace('map')
},
})
const openMapSelectionQualityEvidence = () => {
setActiveWorkspace('analysis')
}
@@ -990,6 +1006,10 @@ function App(): JSX.Element {
mapSelectionQaError={mapSelectionQaError}
mapSelectionQaResult={mapSelectionQaResult}
latestMapSelectionQualityCheckId={latestMapSelectionQualityCheckId}
orthophotoAnalysisStage={mapOrthophotoAnalysis.stage}
orthophotoAnalysisStatus={mapOrthophotoAnalysis.status}
orthophotoAnalysisError={mapOrthophotoAnalysis.error}
orthophotoAnalysisRunning={mapOrthophotoAnalysis.running}
availableMapDatasets={availableMapDatasets}
selectedMapDatasetId={selectedDataset && isVectorDatasetType(selectedDataset.dataset_type) ? selectedDataset.id : ''}
selectedFeature={selectedMapFeature}
@@ -1010,6 +1030,7 @@ function App(): JSX.Element {
onSelectMapQaReferenceDataset={setSelectedMapQaReferenceDatasetId}
onRunMapSelectionQa={runMapSelectionQa}
onOpenMapSelectionQualityEvidence={openMapSelectionQualityEvidence}
onRunOrthophotoAnalysis={mapOrthophotoAnalysis.run}
onClearQualityEvidence={clearQualityEvidenceGeoJson}
/>
) : null}
@@ -441,6 +441,10 @@ interface MapWorkspaceProps {
mapSelectionQaError: string | null
mapSelectionQaResult: QaComparisonResult | null
latestMapSelectionQualityCheckId: string | null
orthophotoAnalysisStage: 'idle' | 'acquiring' | 'detecting' | 'validating' | 'complete' | 'failed'
orthophotoAnalysisStatus: string
orthophotoAnalysisError: string | null
orthophotoAnalysisRunning: boolean
availableMapDatasets: DatasetCreateResponse[]
selectedMapDatasetId: string
onSelectMapArea: (areaId: string) => void
@@ -460,6 +464,7 @@ interface MapWorkspaceProps {
onSelectMapQaReferenceDataset: (datasetId: string) => void
onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise<QaComparisonResult | null>
onOpenMapSelectionQualityEvidence: () => void
onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise<boolean>
onClearQualityEvidence?: () => void
}
@@ -509,6 +514,10 @@ export function MapWorkspace({
mapSelectionQaError,
mapSelectionQaResult,
latestMapSelectionQualityCheckId,
orthophotoAnalysisStage,
orthophotoAnalysisStatus,
orthophotoAnalysisError,
orthophotoAnalysisRunning,
availableMapDatasets,
selectedMapDatasetId,
onSelectMapArea,
@@ -528,6 +537,7 @@ export function MapWorkspace({
onSelectMapQaReferenceDataset,
onRunMapSelectionQa,
onOpenMapSelectionQualityEvidence,
onRunOrthophotoAnalysis,
onClearQualityEvidence,
}: MapWorkspaceProps): JSX.Element {
const [advancedMode, setAdvancedMode] = useState(false)
@@ -1159,6 +1169,34 @@ export function MapWorkspace({
</div>
</div>
{analysisMode === 'current' && mapSelectionBbox ? (
<div className={`geo-image-analysis geo-image-analysis-${orthophotoAnalysisStage}`}>
<div>
<span>Beeldanalyse</span>
<strong>Gebouwen herkennen op luchtbeeld</strong>
<small>Officieel luchtbeeld, lokaal AI-model en automatische controle met GRB.</small>
</div>
<button
className="primary-action"
disabled={orthophotoAnalysisRunning}
type="button"
onClick={() => void onRunOrthophotoAnalysis(mapSelectionBbox)}
>
{orthophotoAnalysisStage === 'acquiring'
? 'Luchtbeeld ophalen...'
: orthophotoAnalysisStage === 'detecting'
? 'Gebouwen herkennen...'
: orthophotoAnalysisStage === 'validating'
? 'Controleren...'
: orthophotoAnalysisStage === 'complete'
? 'Opnieuw analyseren'
: 'Herken gebouwen'}
</button>
{orthophotoAnalysisStatus ? <p role="status">{orthophotoAnalysisStatus}</p> : null}
{orthophotoAnalysisError ? <p className="error" role="alert">{orthophotoAnalysisError}</p> : null}
</div>
) : null}
{!mapSelectionBbox ? (
<div className="geo-results-empty">
<strong>Nog geen gebied geselecteerd</strong>
+56 -25
View File
@@ -229,12 +229,18 @@ export function useDetectionWorkflow({
}
}
const executeDetection = async (projectId: string, datasetId: string, manifestPath: string | null) => {
const executeDetection = async (
projectId: string,
datasetId: string,
manifestPath: string | null,
modelId = selectedDetectionModelId,
modelAssetId = selectedModelAssetId,
) => {
const result = await detectionApi.run({
project_id: projectId,
dataset_id: datasetId,
model_id: selectedDetectionModelId,
model_asset_id: selectedModelAssetId || null,
model_id: modelId,
model_asset_id: modelAssetId || null,
confidence_threshold: detectionConfidenceThreshold,
tile_manifest_path: manifestPath,
parameters_json: {},
@@ -303,24 +309,31 @@ export function useDetectionWorkflow({
}
}
const prepareAndRunDetection = async (): Promise<boolean> => {
const prepareAndRunDetection = async (
datasetIdOverride?: string,
modelIdOverride?: string,
): Promise<DetectionRunResponse | null> => {
if (!selectedProjectId) {
setDetectionRunError('De regionale werkruimte is nog niet geladen')
return false
return null
}
const datasetId = selectedDetectionDatasetId || rasterDatasets[0]?.id
const datasetId = datasetIdOverride || selectedDetectionDatasetId || rasterDatasets[0]?.id
if (!datasetId) {
setDetectionRunError('Kies of voeg eerst een gegeorefereerd luchtbeeld toe')
return false
return null
}
const selectedModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId)
if (!selectedModel?.configured || selectedDetectionModelId === 'manual-fixture-detector') {
const effectiveModelId = modelIdOverride || selectedDetectionModelId
const effectiveModelAssetId = effectiveModelId === 'yolo-configured'
? modelAssets.find((asset) => asset.active)?.model_asset_id ?? selectedModelAssetId
: selectedModelAssetId
const selectedModel = detectionModels.find((model) => model.model_id === effectiveModelId)
if (!selectedModel?.configured || effectiveModelId === 'manual-fixture-detector') {
setDetectionRunError(selectedModel?.limitation_message ?? 'Het gekozen analysemodel is niet beschikbaar')
return false
return null
}
if (selectedDetectionModelId === 'yolo-configured' && modelAssets.length > 0 && !selectedModelAssetId) {
if (effectiveModelId === 'yolo-configured' && modelAssets.length > 0 && !effectiveModelAssetId) {
setDetectionRunError('Kies eerst een lokaal modelbestand')
return false
return null
}
setDetectionRunError(null)
@@ -355,7 +368,7 @@ export function useDetectionWorkflow({
setDetectionWorkflowStage('validating')
const preflight = await detectionApi.getYoloPreflight({
tile_manifest_path: manifestPath,
model_asset_id: selectedModelAssetId || null,
model_asset_id: effectiveModelAssetId || null,
})
setYoloPreflight(preflight)
setYoloPreflightError(null)
@@ -370,46 +383,63 @@ export function useDetectionWorkflow({
}
setDetectionWorkflowStage('detecting')
await executeDetection(selectedProjectId, datasetId, manifestPath)
const result = await executeDetection(
selectedProjectId,
datasetId,
manifestPath,
effectiveModelId,
effectiveModelAssetId,
)
setDetectionWorkflowStage('complete')
return true
return result
} catch (error) {
setDetectionRunError(formatError(error, 'De beeldanalyse is mislukt'))
setDetectionWorkflowStage('failed')
return false
return null
} finally {
setRunningDetection(false)
}
}
const runDetectionQa = async () => {
if (!selectedDetectionRunId) {
const compareDetectionRunWithReference = async (
analysisRunId: string,
referenceDatasetId: string,
useCurrentFilters = true,
): Promise<DetectionQaResult | null> => {
if (!analysisRunId) {
setDetectionQaError('Select a detection run')
return
return null
}
if (!detectionReferenceDatasetId) {
if (!referenceDatasetId) {
setDetectionQaError('Select a reference dataset')
return
return null
}
setSelectedDetectionRunId(analysisRunId)
setDetectionReferenceDatasetId(referenceDatasetId)
setDetectionQaError(null)
setDetectionQaResult(null)
setRunningDetectionQa(true)
try {
const result = await detectionApi.compareWithReference(selectedDetectionRunId, {
reference_dataset_id: detectionReferenceDatasetId,
const result = await detectionApi.compareWithReference(analysisRunId, {
reference_dataset_id: referenceDatasetId,
iou_threshold: qaIouThreshold,
class_name: detectionClassFilter || null,
min_confidence: detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
class_name: useCurrentFilters ? detectionClassFilter || null : null,
min_confidence: useCurrentFilters && detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
})
setDetectionQaResult(result)
await loadQualityChecks(selectedProjectId)
return result
} catch (error) {
setDetectionQaError(formatError(error, 'Detection QA failed'))
return null
} finally {
setRunningDetectionQa(false)
}
}
const runDetectionQa = async (): Promise<DetectionQaResult | null> =>
compareDetectionRunWithReference(selectedDetectionRunId, detectionReferenceDatasetId)
const runDetectionCalibration = async () => {
if (!selectedProjectId) {
setDetectionCalibrationError('Select a project before calibration')
@@ -560,6 +590,7 @@ export function useDetectionWorkflow({
runDetection,
uploadDetectionRaster,
prepareAndRunDetection,
compareDetectionRunWithReference,
runDetectionQa,
runDetectionCalibration,
applyDetectionOperatorProfile,
@@ -0,0 +1,121 @@
import { useState } from 'react'
import { datasetsApi } from '../services/api'
import type {
DatasetCreateResponse,
DetectionQaResult,
DetectionRunResponse,
OrthophotoAcquisitionResult,
VectorSelectionBBox,
} from '../types'
import { formatError } from '../lib/formatError'
export type MapOrthophotoAnalysisStage =
| 'idle'
| 'acquiring'
| 'detecting'
| 'validating'
| 'complete'
| 'failed'
interface MapOrthophotoAnalysisOptions {
selectedProjectId: string | null
selectedAreaId: string
datasets: DatasetCreateResponse[]
loadProjectData: (projectId: string) => Promise<unknown>
prepareAndRunDetection: (datasetId?: string) => Promise<DetectionRunResponse | null>
compareDetectionRunWithReference: (
analysisRunId: string,
referenceDatasetId: string,
) => Promise<DetectionQaResult | null>
onAnalysisReady: () => void
}
function findBuildingReference(datasets: DatasetCreateResponse[]): DatasetCreateResponse | null {
return datasets.find(
(dataset) =>
dataset.status === 'ready' &&
dataset.dataset_role === 'reference' &&
dataset.source_name === 'grb' &&
dataset.reference_layer_name === 'buildings',
) ?? null
}
export function useMapOrthophotoAnalysis({
selectedProjectId,
selectedAreaId,
datasets,
loadProjectData,
prepareAndRunDetection,
compareDetectionRunWithReference,
onAnalysisReady,
}: MapOrthophotoAnalysisOptions) {
const [stage, setStage] = useState<MapOrthophotoAnalysisStage>('idle')
const [status, setStatus] = useState('')
const [error, setError] = useState<string | null>(null)
const [lastResult, setLastResult] = useState<OrthophotoAcquisitionResult | null>(null)
const run = async (bbox: VectorSelectionBBox): Promise<boolean> => {
if (!selectedProjectId) {
setError('De regionale werkruimte is nog niet geladen.')
setStage('failed')
return false
}
setError(null)
setLastResult(null)
setStage('acquiring')
setStatus('1/3 Officieel luchtbeeld voor de rechthoek ophalen...')
try {
const job = await datasetsApi.acquireOrthophoto(selectedProjectId, {
bbox,
area_id: selectedAreaId || undefined,
})
const acquisition = job.result_json as unknown as OrthophotoAcquisitionResult | null
const datasetId = job.output_dataset_id || acquisition?.output_dataset_id
if (job.status !== 'success' || !datasetId || !acquisition) {
throw new Error(job.error_message || 'Het officiële luchtbeeld werd niet als dataset bewaard.')
}
setLastResult(acquisition)
await loadProjectData(selectedProjectId)
setStage('detecting')
setStatus('2/3 Lokaal AI-model herkent gebouwen...')
const detection = await prepareAndRunDetection(datasetId)
if (!detection) {
throw new Error('De beeldanalyse stopte. Open Beeldanalyse voor de technische oorzaak.')
}
const reference = findBuildingReference(datasets)
if (reference) {
setStage('validating')
setStatus('3/3 Resultaat vergelijken met officiële GRB-gebouwen...')
const quality = await compareDetectionRunWithReference(detection.analysis_run_id, reference.id)
setStatus(
quality
? `Analyse klaar: ${detection.detection_count.toLocaleString('nl-BE')} gebouwen herkend en gecontroleerd.`
: `Analyse klaar: ${detection.detection_count.toLocaleString('nl-BE')} gebouwen herkend; kwaliteitscontrole kon niet afronden.`,
)
} else {
setStatus(
`Analyse klaar: ${detection.detection_count.toLocaleString('nl-BE')} gebouwen herkend. De GRB-referentielaag ontbreekt voor automatische controle.`,
)
}
setStage('complete')
onAnalysisReady()
return true
} catch (caught) {
setError(formatError(caught, 'De kaartgestuurde beeldanalyse is mislukt'))
setStatus('Analyse gestopt.')
setStage('failed')
return false
}
}
return {
stage,
status,
error,
lastResult,
running: stage === 'acquiring' || stage === 'detecting' || stage === 'validating',
run,
}
}
+3
View File
@@ -16,6 +16,7 @@ import type {
RasterNdviRequest,
RasterNdwiRequest,
RasterNdbiRequest,
OrthophotoAcquireRequest,
} from '../../types'
export const datasetsApi = {
@@ -81,6 +82,8 @@ export const datasetsApi = {
}
return apiMultipart<DatasetCreateResponse>(`/api/v1/projects/${projectId}/datasets/upload`, form)
},
acquireOrthophoto: (projectId: string, payload: OrthophotoAcquireRequest): Promise<JobRead> =>
apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/orthophoto/acquire`, payload),
refreshMetadata: (projectId: string, datasetId: string): Promise<DatasetCreateResponse> =>
apiPost<DatasetCreateResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/metadata/refresh`, {}),
inspectRaster: (projectId: string, datasetId: string): Promise<RasterInspectResponse> =>
+62
View File
@@ -6045,6 +6045,68 @@ section {
animation: geo-spin 0.8s linear infinite;
}
.geo-image-analysis {
display: grid;
gap: 0.55rem;
border: 1px solid #b9cec7;
border-left: 3px solid #176a5c;
border-radius: 6px;
padding: 0.65rem;
background: #f4faf8;
}
.geo-image-analysis > div {
display: grid;
gap: 0.14rem;
}
.geo-image-analysis span {
color: #4f6a62;
font-size: 0.61rem;
font-weight: 850;
text-transform: uppercase;
}
.geo-image-analysis strong {
color: #173e38;
font-size: 0.78rem;
}
.geo-image-analysis small,
.geo-image-analysis p {
margin: 0;
color: #64736d;
font-size: 0.65rem;
line-height: 1.4;
}
.geo-image-analysis > button {
width: 100%;
min-height: 2.35rem;
}
.geo-image-analysis-acquiring,
.geo-image-analysis-detecting,
.geo-image-analysis-validating {
border-left-color: #b17a31;
background: #fffbeb;
}
.geo-image-analysis-complete {
border-left-color: #277749;
background: #f1faf4;
}
.geo-image-analysis-failed {
border-color: #e2b9b5;
border-left-color: #aa3f37;
background: #fff7f6;
}
.geo-image-analysis .error {
color: #8b2d2d;
}
@keyframes geo-spin {
to { transform: rotate(360deg); }
}
+20
View File
@@ -298,6 +298,26 @@ export interface VectorSelectionBBox {
crs?: 'EPSG:4326'
}
export interface OrthophotoAcquireRequest {
bbox: VectorSelectionBBox
area_id?: string
force_refresh?: boolean
}
export interface OrthophotoAcquisitionResult {
output_dataset_id: string
reused: boolean
provider: string
layer: string
width: number
height: number
resolution_m: number
bbox_epsg4326: number[]
bbox_epsg31370: number[]
attribution: string
limitation_message: string
}
export interface MapViewportState {
bbox: VectorSelectionBBox
zoom: number