Files
geointel/frontend/src/hooks/useDetectionWorkflow.ts
T
Codex d22abe8e7b
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s
feat: add measured detection review loop
2026-07-15 03:00:08 +02:00

611 lines
23 KiB
TypeScript

import { useEffect, useState } from 'react'
import { datasetsApi, detectionApi } from '../services/api'
import type {
DatasetCreateResponse,
DetectionModelCapability,
DetectionQaResult,
DetectionRead,
DetectionRunRead,
DetectionRunResponse,
JobRead,
ModelAssetRead,
QualityCheckRead,
YoloPreflightResponse,
} from '../types'
import { formatError } from '../lib/formatError'
interface DetectionWorkflowOptions {
selectedProjectId: string | null
rasterDatasets: DatasetCreateResponse[]
qaIouThreshold: number
loadProjectData: (projectId: string) => Promise<unknown>
loadQualityChecks: (projectId?: string | null) => Promise<QualityCheckRead[] | void>
}
interface DetectionOperatorProfileSelection {
modelAssetId: string
confidenceThreshold: number
}
export type DetectionWorkflowStage =
| 'idle'
| 'uploading'
| 'ready'
| 'tiling'
| 'validating'
| 'detecting'
| 'loading'
| 'complete'
| 'failed'
export interface DetectionCalibrationRunRow {
threshold: number
status: 'queued' | 'running' | 'success' | 'failed'
analysis_run_id?: string | null
job_id?: string | null
quality_check_id?: string | null
detection_count?: number | null
precision?: number | null
recall?: number | null
f1_score?: number | null
false_positives?: number | null
false_negatives?: number | null
message?: string | null
}
function parseCalibrationThresholds(value: string): number[] {
const tokens = value
.split(/[\s,;]+/)
.map((token) => token.trim())
.filter(Boolean)
const thresholds: number[] = []
for (const token of tokens) {
const threshold = Number(token)
if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) {
return []
}
if (!thresholds.includes(threshold)) {
thresholds.push(threshold)
}
}
return thresholds
}
function tileManifestPathFromJob(job: JobRead): string | null {
const manifestPath = job.result_json?.manifest_path
return typeof manifestPath === 'string' && manifestPath.trim().length > 0 ? manifestPath.trim() : null
}
function rasterTileCount(metadata: Record<string, unknown>, tileSize: number, overlap: number): number | null {
const width = metadata.width
const height = metadata.height
if (typeof width !== 'number' || typeof height !== 'number' || width <= 0 || height <= 0) {
return null
}
const step = tileSize - overlap
return Math.ceil(width / step) * Math.ceil(height / step)
}
export function useDetectionWorkflow({
selectedProjectId,
rasterDatasets,
qaIouThreshold,
loadProjectData,
loadQualityChecks,
}: DetectionWorkflowOptions) {
const [detectionModels, setDetectionModels] = useState<DetectionModelCapability[]>([])
const [modelAssets, setModelAssets] = useState<ModelAssetRead[]>([])
const [loadingDetectionModels, setLoadingDetectionModels] = useState(false)
const [detectionModelError, setDetectionModelError] = useState<string | null>(null)
const [modelAssetError, setModelAssetError] = useState<string | null>(null)
const [selectedDetectionDatasetId, setSelectedDetectionDatasetId] = useState('')
const [selectedDetectionModelId, setSelectedDetectionModelId] = useState('yolo-configured')
const [selectedModelAssetId, setSelectedModelAssetId] = useState('')
const [detectionTileManifestPath, setDetectionTileManifestPath] = useState('')
const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.15)
const [runningDetection, setRunningDetection] = useState(false)
const [detectionRunResult, setDetectionRunResult] = useState<DetectionRunResponse | null>(null)
const [detectionRunError, setDetectionRunError] = useState<string | null>(null)
const [detectionRuns, setDetectionRuns] = useState<DetectionRunRead[]>([])
const [selectedDetectionRunId, setSelectedDetectionRunId] = useState('')
const [detectionItems, setDetectionItems] = useState<DetectionRead[]>([])
const [detectionGeoJson, setDetectionGeoJson] = useState<GeoJSON.FeatureCollection | null>(null)
const [detectionClassFilter, setDetectionClassFilter] = useState('')
const [detectionMinConfidenceFilter, setDetectionMinConfidenceFilter] = useState(0)
const [loadingDetectionResults, setLoadingDetectionResults] = useState(false)
const [detectionReferenceDatasetId, setDetectionReferenceDatasetId] = useState('')
const [detectionQaResult, setDetectionQaResult] = useState<DetectionQaResult | null>(null)
const [detectionQaError, setDetectionQaError] = useState<string | null>(null)
const [runningDetectionQa, setRunningDetectionQa] = useState(false)
const [yoloPreflight, setYoloPreflight] = useState<YoloPreflightResponse | null>(null)
const [loadingYoloPreflight, setLoadingYoloPreflight] = useState(false)
const [yoloPreflightError, setYoloPreflightError] = useState<string | null>(null)
const [calibrationThresholdText, setCalibrationThresholdText] = useState('0.50 0.25 0.15')
const [runningDetectionCalibration, setRunningDetectionCalibration] = useState(false)
const [detectionCalibrationRows, setDetectionCalibrationRows] = useState<DetectionCalibrationRunRow[]>([])
const [detectionCalibrationError, setDetectionCalibrationError] = useState<string | null>(null)
const [detectionWorkflowStage, setDetectionWorkflowStage] = useState<DetectionWorkflowStage>('idle')
useEffect(() => {
if (!selectedDetectionDatasetId && rasterDatasets.length > 0) {
setSelectedDetectionDatasetId(rasterDatasets[0].id)
}
}, [rasterDatasets, selectedDetectionDatasetId])
const loadDetectionModels = async () => {
setLoadingDetectionModels(true)
setDetectionModelError(null)
setModelAssetError(null)
try {
const response = await detectionApi.listModels()
setDetectionModels(response.models)
const selectedModelStillAvailable = response.models.some((model) => model.model_id === selectedDetectionModelId)
if (!selectedModelStillAvailable && response.models.length > 0) {
const configuredModel = response.models.find(
(model) => model.configured && model.model_id !== 'manual-fixture-detector',
)
setSelectedDetectionModelId(configuredModel?.model_id ?? response.models[0].model_id)
}
} catch (error) {
setDetectionModelError(formatError(error, 'Failed to load detection models'))
}
try {
const assetResponse = await detectionApi.listModelAssets()
setModelAssets(assetResponse.items)
const activeAsset = assetResponse.items.find((asset) => asset.active) ?? null
const selectedAssetStillAvailable = assetResponse.items.some((asset) => asset.model_asset_id === selectedModelAssetId)
const nextAssetId = selectedAssetStillAvailable ? selectedModelAssetId : activeAsset?.model_asset_id ?? ''
setSelectedModelAssetId(nextAssetId)
try {
const preflight = await detectionApi.getYoloPreflight({ model_asset_id: nextAssetId || null })
setYoloPreflight(preflight)
setYoloPreflightError(null)
} catch (error) {
setYoloPreflightError(formatError(error, 'Failed to load YOLO preflight status'))
}
} catch (error) {
setModelAssets([])
setModelAssetError(formatError(error, 'Failed to load local model assets'))
} finally {
setLoadingDetectionModels(false)
}
}
const loadYoloPreflight = async (tileManifestPath = detectionTileManifestPath) => {
setLoadingYoloPreflight(true)
setYoloPreflightError(null)
try {
const response = await detectionApi.getYoloPreflight({
tile_manifest_path: tileManifestPath.trim() || null,
model_asset_id: selectedModelAssetId || null,
})
setYoloPreflight(response)
} catch (error) {
setYoloPreflightError(formatError(error, 'Failed to load YOLO preflight status'))
} finally {
setLoadingYoloPreflight(false)
}
}
const loadDetectionRuns = async (projectId = selectedProjectId) => {
if (!projectId) {
setDetectionRuns([])
return
}
try {
const response = await detectionApi.listRuns({ project_id: projectId })
setDetectionRuns(response.items)
if (!selectedDetectionRunId && response.items.length > 0) {
setSelectedDetectionRunId(response.items[0].id)
}
} catch (error) {
setDetectionRunError(formatError(error, 'Failed to load detection runs'))
}
}
const loadDetectionResults = async (analysisRunId = selectedDetectionRunId) => {
if (!analysisRunId) {
setDetectionItems([])
setDetectionGeoJson(null)
return
}
setLoadingDetectionResults(true)
setDetectionRunError(null)
try {
const params = {
class_name: detectionClassFilter || null,
min_confidence: detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
}
const [detectionsResponse, geoJsonResponse] = await Promise.all([
detectionApi.listDetections(analysisRunId, params),
detectionApi.getRunGeoJson(analysisRunId, params),
])
setDetectionItems(detectionsResponse.items)
setDetectionGeoJson(geoJsonResponse)
} catch (error) {
setDetectionRunError(formatError(error, 'Failed to load detection results'))
} finally {
setLoadingDetectionResults(false)
}
}
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: modelId,
model_asset_id: modelAssetId || null,
confidence_threshold: detectionConfidenceThreshold,
tile_manifest_path: manifestPath,
parameters_json: {},
})
setDetectionRunResult(result)
setSelectedDetectionRunId(result.analysis_run_id)
setDetectionWorkflowStage('loading')
await loadDetectionRuns(projectId)
await loadDetectionResults(result.analysis_run_id)
await loadProjectData(projectId)
return result
}
const runDetection = async () => {
if (!selectedProjectId) {
setDetectionRunError('Select a project first')
return
}
const datasetId = selectedDetectionDatasetId || rasterDatasets[0]?.id
if (!datasetId) {
setDetectionRunError('Select a raster dataset')
return
}
setDetectionRunError(null)
setDetectionRunResult(null)
setRunningDetection(true)
setDetectionWorkflowStage('detecting')
try {
await executeDetection(selectedProjectId, datasetId, detectionTileManifestPath.trim() || null)
setDetectionWorkflowStage('complete')
} catch (error) {
setDetectionRunError(formatError(error, 'Detection run failed'))
setDetectionWorkflowStage('failed')
} finally {
setRunningDetection(false)
}
}
const uploadDetectionRaster = async (file: File): Promise<boolean> => {
if (!selectedProjectId) {
setDetectionRunError('De regionale werkruimte is nog niet geladen')
return false
}
setDetectionRunError(null)
setDetectionWorkflowStage('uploading')
try {
const dataset = await datasetsApi.upload(selectedProjectId, {
file,
datasetType: 'raster',
source: 'user_upload',
datasetRole: 'source',
sourceName: 'manual',
sourceMetadataJson: JSON.stringify({ purpose: 'building_detection' }),
provenanceMetadataJson: JSON.stringify({ original_filename: file.name, acquisition: 'explicit_user_upload' }),
})
setSelectedDetectionDatasetId(dataset.id)
setDetectionTileManifestPath('')
setDetectionRunResult(null)
setDetectionWorkflowStage('ready')
await loadProjectData(selectedProjectId)
return true
} catch (error) {
setDetectionRunError(formatError(error, 'Het luchtbeeld kon niet worden toegevoegd'))
setDetectionWorkflowStage('failed')
return false
}
}
const prepareAndRunDetection = async (
datasetIdOverride?: string,
modelIdOverride?: string,
): Promise<DetectionRunResponse | null> => {
if (!selectedProjectId) {
setDetectionRunError('De regionale werkruimte is nog niet geladen')
return null
}
const datasetId = datasetIdOverride || selectedDetectionDatasetId || rasterDatasets[0]?.id
if (!datasetId) {
setDetectionRunError('Kies of voeg eerst een gegeorefereerd luchtbeeld toe')
return null
}
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 null
}
if (effectiveModelId === 'yolo-configured' && modelAssets.length > 0 && !effectiveModelAssetId) {
setDetectionRunError('Kies eerst een lokaal modelbestand')
return null
}
setDetectionRunError(null)
setDetectionRunResult(null)
setRunningDetection(true)
try {
let manifestPath = detectionTileManifestPath.trim()
if (!manifestPath) {
setDetectionWorkflowStage('tiling')
const inspection = await datasetsApi.rasterInspect(selectedProjectId, datasetId)
const expectedTileCount = rasterTileCount(inspection.metadata, 512, 64)
const maxTiles = yoloPreflight?.max_tiles ?? 256
if (expectedTileCount === null) {
throw new Error('De afmetingen van het luchtbeeld konden niet veilig worden bepaald')
}
if (expectedTileCount > maxTiles) {
throw new Error(
`Dit luchtbeeld zou ${expectedTileCount} beeldtegels maken; het veilige maximum is ${maxTiles}. Knip het beeld eerst tot het gewenste werkgebied.`,
)
}
const tileJob = await datasetsApi.rasterTile(selectedProjectId, datasetId, {
tile_size: 512,
overlap: 64,
})
manifestPath = tileManifestPathFromJob(tileJob) ?? ''
if (!manifestPath) {
throw new Error(tileJob.error_message || 'De tegelvoorbereiding leverde geen geldig manifest op')
}
setDetectionTileManifestPath(manifestPath)
}
setDetectionWorkflowStage('validating')
const preflight = await detectionApi.getYoloPreflight({
tile_manifest_path: manifestPath,
model_asset_id: effectiveModelAssetId || null,
})
setYoloPreflight(preflight)
setYoloPreflightError(null)
if (
!preflight.checks.manifest_valid ||
!preflight.checks.tile_paths_exist ||
!preflight.checks.tile_limit_ok ||
!preflight.checks.dependencies_available ||
!preflight.checks.model_file_exists
) {
throw new Error(preflight.message || 'De beeldtegels of modelruntime zijn niet startklaar')
}
setDetectionWorkflowStage('detecting')
const result = await executeDetection(
selectedProjectId,
datasetId,
manifestPath,
effectiveModelId,
effectiveModelAssetId,
)
setDetectionWorkflowStage('complete')
return result
} catch (error) {
setDetectionRunError(formatError(error, 'De beeldanalyse is mislukt'))
setDetectionWorkflowStage('failed')
return null
} finally {
setRunningDetection(false)
}
}
const compareDetectionRunWithReference = async (
analysisRunId: string,
referenceDatasetId: string,
useCurrentFilters = true,
iouThresholdOverride?: number,
): Promise<DetectionQaResult | null> => {
if (!analysisRunId) {
setDetectionQaError('Select a detection run')
return null
}
if (!referenceDatasetId) {
setDetectionQaError('Select a reference dataset')
return null
}
setSelectedDetectionRunId(analysisRunId)
setDetectionReferenceDatasetId(referenceDatasetId)
setDetectionQaError(null)
setDetectionQaResult(null)
setRunningDetectionQa(true)
try {
const result = await detectionApi.compareWithReference(analysisRunId, {
reference_dataset_id: referenceDatasetId,
iou_threshold: iouThresholdOverride ?? qaIouThreshold,
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')
return
}
const datasetId = selectedDetectionDatasetId || rasterDatasets[0]?.id
if (!datasetId) {
setDetectionCalibrationError('Select a raster dataset before calibration')
return
}
if (!detectionReferenceDatasetId) {
setDetectionCalibrationError('Select a reference dataset before calibration')
return
}
const selectedModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId)
if (!selectedModel?.configured || selectedDetectionModelId === 'manual-fixture-detector') {
setDetectionCalibrationError('Select a configured non-fixture detection model before calibration')
return
}
if (selectedDetectionModelId === 'yolo-configured' && !detectionTileManifestPath.trim()) {
setDetectionCalibrationError('Configured YOLO calibration requires a tile manifest')
return
}
if (selectedDetectionModelId === 'yolo-configured' && modelAssets.length > 0 && !selectedModelAssetId) {
setDetectionCalibrationError('Select a local model asset before calibration')
return
}
const thresholds = parseCalibrationThresholds(calibrationThresholdText)
if (thresholds.length === 0) {
setDetectionCalibrationError('Provide at least one valid threshold between 0 and 1')
return
}
setDetectionCalibrationError(null)
setDetectionCalibrationRows(thresholds.map((threshold) => ({ threshold, status: 'queued' })))
setRunningDetectionCalibration(true)
try {
for (const threshold of thresholds) {
setDetectionCalibrationRows((rows) =>
rows.map((row) => row.threshold === threshold ? { ...row, status: 'running', message: 'Running detection' } : row),
)
try {
const result = await detectionApi.run({
project_id: selectedProjectId,
dataset_id: datasetId,
model_id: selectedDetectionModelId,
model_asset_id: selectedModelAssetId || null,
confidence_threshold: threshold,
tile_manifest_path: detectionTileManifestPath.trim() || null,
parameters_json: { calibration: true, calibration_thresholds: thresholds },
})
setSelectedDetectionRunId(result.analysis_run_id)
const qa = await detectionApi.compareWithReference(result.analysis_run_id, {
reference_dataset_id: detectionReferenceDatasetId,
iou_threshold: qaIouThreshold,
class_name: detectionClassFilter || null,
min_confidence: null,
})
setDetectionCalibrationRows((rows) =>
rows.map((row) => row.threshold === threshold
? {
...row,
status: 'success',
analysis_run_id: result.analysis_run_id,
job_id: result.job_id,
quality_check_id: qa.quality_check_id,
detection_count: result.detection_count,
precision: qa.precision ?? null,
recall: qa.recall ?? null,
f1_score: qa.f1_score ?? null,
false_positives: qa.false_positives,
false_negatives: qa.false_negatives,
message: result.message,
}
: row),
)
} catch (error) {
const message = formatError(error, `Calibration threshold ${threshold} failed`)
setDetectionCalibrationRows((rows) =>
rows.map((row) => row.threshold === threshold ? { ...row, status: 'failed', message } : row),
)
setDetectionCalibrationError(message)
break
}
}
await loadDetectionRuns(selectedProjectId)
await loadQualityChecks(selectedProjectId)
await loadProjectData(selectedProjectId)
} finally {
setRunningDetectionCalibration(false)
}
}
const applyDetectionOperatorProfile = (profile: DetectionOperatorProfileSelection) => {
setSelectedDetectionModelId('yolo-configured')
setSelectedModelAssetId(profile.modelAssetId)
setDetectionConfidenceThreshold(profile.confidenceThreshold)
}
const resetDetectionForProject = () => {
setSelectedDetectionDatasetId('')
setDetectionRuns([])
setSelectedDetectionRunId('')
setDetectionItems([])
setDetectionGeoJson(null)
setDetectionRunResult(null)
setDetectionCalibrationRows([])
setDetectionCalibrationError(null)
setDetectionWorkflowStage('idle')
}
return {
detectionModels,
modelAssets,
loadingDetectionModels,
detectionModelError,
modelAssetError,
selectedDetectionDatasetId,
selectedDetectionModelId,
selectedModelAssetId,
detectionTileManifestPath,
detectionConfidenceThreshold,
runningDetection,
detectionRunResult,
detectionRunError,
detectionRuns,
selectedDetectionRunId,
detectionItems,
detectionGeoJson,
detectionClassFilter,
detectionMinConfidenceFilter,
loadingDetectionResults,
detectionReferenceDatasetId,
detectionQaResult,
detectionQaError,
runningDetectionQa,
yoloPreflight,
loadingYoloPreflight,
yoloPreflightError,
calibrationThresholdText,
runningDetectionCalibration,
detectionCalibrationRows,
detectionCalibrationError,
detectionWorkflowStage,
loadDetectionModels,
loadYoloPreflight,
loadDetectionRuns,
loadDetectionResults,
runDetection,
uploadDetectionRaster,
prepareAndRunDetection,
compareDetectionRunWithReference,
runDetectionQa,
runDetectionCalibration,
applyDetectionOperatorProfile,
resetDetectionForProject,
setSelectedDetectionDatasetId,
setSelectedDetectionModelId,
setSelectedModelAssetId,
setDetectionTileManifestPath,
setDetectionConfidenceThreshold,
setSelectedDetectionRunId,
setDetectionClassFilter,
setDetectionMinConfidenceFilter,
setDetectionReferenceDatasetId,
setCalibrationThresholdText,
}
}