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({
+ {analysisMode === 'current' && mapSelectionBbox ? (
+
+
+ Beeldanalyse
+ Gebouwen herkennen op luchtbeeld
+ Officieel luchtbeeld, lokaal AI-model en automatische controle met GRB.
+
+
+ {orthophotoAnalysisStatus ?
{orthophotoAnalysisStatus}
: null}
+ {orthophotoAnalysisError ?
{orthophotoAnalysisError}
: null}
+
+ ) : null}
+
{!mapSelectionBbox ? (
Nog geen gebied geselecteerd
diff --git a/frontend/src/hooks/useDetectionWorkflow.ts b/frontend/src/hooks/useDetectionWorkflow.ts
index 5260b635..1fb49af2 100644
--- a/frontend/src/hooks/useDetectionWorkflow.ts
+++ b/frontend/src/hooks/useDetectionWorkflow.ts
@@ -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 => {
+ const prepareAndRunDetection = async (
+ datasetIdOverride?: string,
+ modelIdOverride?: string,
+ ): Promise => {
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 => {
+ 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 =>
+ 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,
diff --git a/frontend/src/hooks/useMapOrthophotoAnalysis.ts b/frontend/src/hooks/useMapOrthophotoAnalysis.ts
new file mode 100644
index 00000000..8da73d01
--- /dev/null
+++ b/frontend/src/hooks/useMapOrthophotoAnalysis.ts
@@ -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
+ prepareAndRunDetection: (datasetId?: string) => Promise
+ compareDetectionRunWithReference: (
+ analysisRunId: string,
+ referenceDatasetId: string,
+ ) => Promise
+ 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('idle')
+ const [status, setStatus] = useState('')
+ const [error, setError] = useState(null)
+ const [lastResult, setLastResult] = useState(null)
+
+ const run = async (bbox: VectorSelectionBBox): Promise => {
+ 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,
+ }
+}
diff --git a/frontend/src/services/api/datasets.ts b/frontend/src/services/api/datasets.ts
index 18f6d685..158edba9 100644
--- a/frontend/src/services/api/datasets.ts
+++ b/frontend/src/services/api/datasets.ts
@@ -16,6 +16,7 @@ import type {
RasterNdviRequest,
RasterNdwiRequest,
RasterNdbiRequest,
+ OrthophotoAcquireRequest,
} from '../../types'
export const datasetsApi = {
@@ -81,6 +82,8 @@ export const datasetsApi = {
}
return apiMultipart(`/api/v1/projects/${projectId}/datasets/upload`, form)
},
+ acquireOrthophoto: (projectId: string, payload: OrthophotoAcquireRequest): Promise =>
+ apiPost(`/api/v1/projects/${projectId}/datasets/orthophoto/acquire`, payload),
refreshMetadata: (projectId: string, datasetId: string): Promise =>
apiPost(`/api/v1/projects/${projectId}/datasets/${datasetId}/metadata/refresh`, {}),
inspectRaster: (projectId: string, datasetId: string): Promise =>
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css
index e5cf79da..61537608 100644
--- a/frontend/src/styles/app.css
+++ b/frontend/src/styles/app.css
@@ -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); }
}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 8bdf1f22..1e7cfbd5 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -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