1215 lines
54 KiB
TypeScript
1215 lines
54 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import type {
|
|
DatasetCreateResponse,
|
|
DetectionModelCapability,
|
|
DetectionQaResult,
|
|
DetectionRead,
|
|
DetectionRunRead,
|
|
DetectionRunResponse,
|
|
JobRead,
|
|
ModelAssetRead,
|
|
QualityCheckRead,
|
|
YoloPreflightResponse,
|
|
} from '../../types'
|
|
import type { DetectionCalibrationRunRow, DetectionWorkflowStage } from '../../hooks/useDetectionWorkflow'
|
|
import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles'
|
|
import { DetectionModelManagement, detectionModelLabel } from './DetectionModelManagement'
|
|
import { AiPipelineIllustration } from './AiPipelineIllustration'
|
|
import { ModelSelector } from '../models/ModelSelector'
|
|
import { analysisModelAvailabilityMessage, toAnalysisModelOption } from '../models/modelOptions'
|
|
|
|
const DETECTION_PAGE_SIZE_OPTIONS = [25, 50, 100] as const
|
|
const DEFAULT_DETECTION_PAGE_SIZE = 50
|
|
|
|
function detectionQualityInterpretation(f1: number | null | undefined): string {
|
|
if (typeof f1 !== 'number' || !Number.isFinite(f1)) return 'Nog geen onafhankelijke kwaliteitsmeting beschikbaar.'
|
|
return `Historische F1 ${f1.toFixed(3)} is alleen kalibratiecontext. Een actuele, ruimtelijk onafhankelijke QA-run bepaalt of dit resultaat lokaal bruikbaar is.`
|
|
}
|
|
|
|
function detectionStatusLabel(status: string): string {
|
|
if (status === 'completed') return 'afgerond'
|
|
if (status === 'success') return 'geslaagd'
|
|
if (status === 'failed') return 'mislukt'
|
|
if (status === 'running') return 'bezig'
|
|
if (status === 'queued') return 'in wachtrij'
|
|
return status.replace(/_/g, ' ')
|
|
}
|
|
|
|
function persistedDetectionModelLabel(modelName: string | null | undefined): string {
|
|
if (!modelName) return 'Gebouwdetectie'
|
|
if (modelName === 'yolo-configured') return 'Lokaal gebouwmodel'
|
|
if (modelName === 'manual-fixture-detector') return 'Testmodel'
|
|
if (modelName === 'yolo-placeholder') return 'Niet-geconfigureerd gebouwmodel'
|
|
return modelName
|
|
}
|
|
|
|
function detectionClassLabel(className: string): string {
|
|
if (className.toLowerCase() === 'building') return 'Gebouw'
|
|
return className
|
|
}
|
|
|
|
function formatDetectionRunLabel(run: DetectionRunRead): string {
|
|
const timestamp = run.finished_at ?? run.created_at
|
|
const dateLabel = timestamp
|
|
? new Intl.DateTimeFormat('nl-BE', {
|
|
dateStyle: 'short',
|
|
timeStyle: 'short',
|
|
}).format(new Date(timestamp))
|
|
: 'datum onbekend'
|
|
return `${persistedDetectionModelLabel(run.model_name)} · ${detectionStatusLabel(run.status)} · ${dateLabel}`
|
|
}
|
|
|
|
interface CalibrationRow {
|
|
analysisRunId: string
|
|
qualityCheckId: string
|
|
threshold: number | null
|
|
modelName: string
|
|
modelAssetId: string | null
|
|
detectionCount: number | null
|
|
precision: number | null
|
|
recall: number | null
|
|
f1: number | null
|
|
falsePositives: number | null
|
|
falseNegatives: number | null
|
|
score: number | null
|
|
createdAt: string | null
|
|
}
|
|
|
|
interface DetectionLabProps {
|
|
managementLocked?: boolean
|
|
detectionModels: DetectionModelCapability[]
|
|
modelAssets: ModelAssetRead[]
|
|
loadingDetectionModels: boolean
|
|
detectionModelError: string | null
|
|
modelAssetError: string | null
|
|
selectedDetectionDatasetId: string
|
|
selectedDetectionModelId: string
|
|
selectedModelAssetId: string
|
|
detectionTileManifestPath: string
|
|
detectionConfidenceThreshold: number
|
|
runningDetection: boolean
|
|
detectionJob: JobRead | null
|
|
detectionRunResult: DetectionRunResponse | null
|
|
detectionRunError: string | null
|
|
detectionRuns: DetectionRunRead[]
|
|
qualityChecks: QualityCheckRead[]
|
|
calibrationThresholdText: string
|
|
runningDetectionCalibration: boolean
|
|
detectionCalibrationRows: DetectionCalibrationRunRow[]
|
|
detectionCalibrationError: string | null
|
|
detectionWorkflowStage: DetectionWorkflowStage
|
|
selectedDetectionRunId: string
|
|
detectionItems: DetectionRead[]
|
|
/** Complete population behind the returned page. */
|
|
detectionTotal?: number
|
|
detectionTruncated?: boolean
|
|
detectionClassFilter: string
|
|
detectionMinConfidenceFilter: number
|
|
loadingDetectionResults: boolean
|
|
detectionReferenceDatasetId: string
|
|
detectionQaResult: DetectionQaResult | null
|
|
detectionQaError: string | null
|
|
runningDetectionQa: boolean
|
|
yoloPreflight: YoloPreflightResponse | null
|
|
loadingYoloPreflight: boolean
|
|
yoloPreflightError: string | null
|
|
selectedProjectId: string | null
|
|
rasterDatasets: DatasetCreateResponse[]
|
|
referenceDatasets: DatasetCreateResponse[]
|
|
onLoadModels: () => void
|
|
onRefreshYoloPreflight: () => void
|
|
onSelectDataset: (datasetId: string) => void
|
|
onSelectModel: (modelId: string) => void
|
|
onSelectModelAsset: (modelAssetId: string) => void
|
|
onSetConfidenceThreshold: (value: number) => void
|
|
onSetTileManifestPath: (value: string) => void
|
|
onRunDetection: () => void
|
|
onUploadRaster: (file: File) => Promise<boolean>
|
|
onPrepareAndRunDetection: () => Promise<void>
|
|
onOpenResultsOnMap: () => void
|
|
onLoadRuns: () => void
|
|
onSelectRun: (runId: string) => void
|
|
onSetClassFilter: (value: string) => void
|
|
onSetMinConfidenceFilter: (value: number) => void
|
|
onLoadResults: () => void
|
|
onSelectReferenceDataset: (datasetId: string) => void
|
|
onRunQa: () => void
|
|
onSetCalibrationThresholdText: (value: string) => void
|
|
onRunCalibration: () => void
|
|
onOpenCalibrationEvidence: (qualityCheckId: string) => void
|
|
onApplyOperatorProfile: (profile: DetectionOperatorProfile) => void
|
|
}
|
|
|
|
export function DetectionLab({
|
|
managementLocked = false,
|
|
detectionModels,
|
|
modelAssets,
|
|
loadingDetectionModels,
|
|
detectionModelError,
|
|
modelAssetError,
|
|
selectedDetectionDatasetId,
|
|
selectedDetectionModelId,
|
|
selectedModelAssetId,
|
|
detectionTileManifestPath,
|
|
detectionConfidenceThreshold,
|
|
runningDetection,
|
|
detectionJob,
|
|
detectionRunResult,
|
|
detectionRunError,
|
|
detectionRuns,
|
|
qualityChecks,
|
|
calibrationThresholdText,
|
|
runningDetectionCalibration,
|
|
detectionCalibrationRows,
|
|
detectionCalibrationError,
|
|
detectionWorkflowStage,
|
|
selectedDetectionRunId,
|
|
detectionItems,
|
|
detectionTotal,
|
|
detectionTruncated = false,
|
|
detectionClassFilter,
|
|
detectionMinConfidenceFilter,
|
|
loadingDetectionResults,
|
|
detectionReferenceDatasetId,
|
|
detectionQaResult,
|
|
detectionQaError,
|
|
runningDetectionQa,
|
|
yoloPreflight,
|
|
loadingYoloPreflight,
|
|
yoloPreflightError,
|
|
selectedProjectId,
|
|
rasterDatasets,
|
|
referenceDatasets,
|
|
onLoadModels,
|
|
onRefreshYoloPreflight,
|
|
onSelectDataset,
|
|
onSelectModel,
|
|
onSelectModelAsset,
|
|
onSetConfidenceThreshold,
|
|
onSetTileManifestPath,
|
|
onRunDetection,
|
|
onUploadRaster,
|
|
onPrepareAndRunDetection,
|
|
onOpenResultsOnMap,
|
|
onLoadRuns,
|
|
onSelectRun,
|
|
onSetClassFilter,
|
|
onSetMinConfidenceFilter,
|
|
onLoadResults,
|
|
onSelectReferenceDataset,
|
|
onRunQa,
|
|
onSetCalibrationThresholdText,
|
|
onRunCalibration,
|
|
onOpenCalibrationEvidence,
|
|
onApplyOperatorProfile,
|
|
}: DetectionLabProps): JSX.Element {
|
|
const selectedDetectionModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId) ?? null
|
|
const selectedModelAsset = modelAssets.find((asset) => asset.model_asset_id === selectedModelAssetId) ?? null
|
|
const selectedOperatorProfile = DETECTION_OPERATOR_PROFILES.find(
|
|
(profile) => profile.modelAssetId === selectedModelAssetId,
|
|
) ?? null
|
|
const yoloRuntimeReady = Boolean(
|
|
yoloPreflight?.checks?.enabled &&
|
|
yoloPreflight.checks?.dependencies_available &&
|
|
yoloPreflight.checks?.accelerator_ready === true &&
|
|
yoloPreflight.checks?.model_file_exists,
|
|
)
|
|
const detectionRequiresTileManifest = selectedDetectionModelId === 'yolo-configured'
|
|
const detectionJobActive = detectionJob?.status === 'queued' || detectionJob?.status === 'running'
|
|
const detectionHasDataset = selectedDetectionDatasetId.length > 0
|
|
const detectionHasModel = selectedDetectionModel !== null
|
|
const detectionModelReady = Boolean(selectedDetectionModel?.configured)
|
|
const selectedDetectionModelAvailability = selectedDetectionModel
|
|
? analysisModelAvailabilityMessage(selectedDetectionModel)
|
|
: 'Het gekozen model is niet geconfigureerd'
|
|
const detectionModelUiRunnable = detectionModelReady && selectedDetectionModelId !== 'manual-fixture-detector'
|
|
const detectionHasExplicitModelAsset =
|
|
selectedDetectionModelId !== 'yolo-configured' || modelAssets.length === 0 || selectedModelAssetId.length > 0
|
|
const calibrationRows = buildCalibrationRows(detectionRuns, qualityChecks)
|
|
const bestF1Candidate = bestCalibrationRow(calibrationRows, 'f1')
|
|
const bestPrecisionCandidate = bestCalibrationRow(calibrationRows, 'precision')
|
|
const lowestFalsePositivePressureCandidate = bestLowestCalibrationRow(calibrationRows, 'falsePositives')
|
|
const [detectionResultPage, setDetectionResultPage] = useState(1)
|
|
const [detectionPageSize, setDetectionPageSize] = useState(DEFAULT_DETECTION_PAGE_SIZE)
|
|
const [pendingRasterFile, setPendingRasterFile] = useState<File | null>(null)
|
|
const rasterFileInputRef = useRef<HTMLInputElement>(null)
|
|
const detectionPageCount = Math.max(1, Math.ceil(detectionItems.length / detectionPageSize))
|
|
const currentDetectionPage = Math.min(detectionResultPage, detectionPageCount)
|
|
const detectionPageStart = (currentDetectionPage - 1) * detectionPageSize
|
|
const detectionPageEnd = Math.min(detectionPageStart + detectionPageSize, detectionItems.length)
|
|
const visibleDetectionItems = detectionItems.slice(detectionPageStart, detectionPageEnd)
|
|
|
|
useEffect(() => {
|
|
setDetectionResultPage(1)
|
|
}, [selectedDetectionRunId, detectionClassFilter, detectionMinConfidenceFilter, detectionItems])
|
|
|
|
const detectionHasTileManifest =
|
|
!detectionRequiresTileManifest || detectionTileManifestPath.trim().length > 0
|
|
const detectionRunReady =
|
|
Boolean(selectedProjectId) &&
|
|
detectionHasDataset &&
|
|
detectionHasModel &&
|
|
detectionModelUiRunnable &&
|
|
detectionHasExplicitModelAsset &&
|
|
detectionHasTileManifest
|
|
const guidedDetectionReady =
|
|
Boolean(selectedProjectId) &&
|
|
detectionHasDataset &&
|
|
detectionHasModel &&
|
|
detectionModelUiRunnable &&
|
|
detectionHasExplicitModelAsset
|
|
const detectionRunBlockedReason = !selectedProjectId
|
|
? 'De regionale werkruimte is nog niet geladen'
|
|
: !detectionHasDataset
|
|
? 'Kies een luchtbeeld'
|
|
: !detectionHasModel
|
|
? 'Kies een analysemodel'
|
|
: selectedDetectionModelId === 'manual-fixture-detector'
|
|
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo\'s'
|
|
: !detectionModelReady
|
|
? selectedDetectionModelAvailability
|
|
: !detectionHasExplicitModelAsset
|
|
? 'Kies een lokaal modelbestand onder beheer'
|
|
: !detectionHasTileManifest
|
|
? 'Maak eerst beeldtegels voor het gekozen luchtbeeld'
|
|
: null
|
|
const calibrationRunReady = detectionRunReady && detectionReferenceDatasetId.length > 0 && calibrationThresholdText.trim().length > 0
|
|
const guidedDetectionBlockedReason = !selectedProjectId
|
|
? 'De regionale werkruimte is nog niet geladen'
|
|
: !detectionHasDataset
|
|
? 'Kies of voeg een gegeorefereerd luchtbeeld toe'
|
|
: !detectionHasModel
|
|
? 'Kies een analysemodel'
|
|
: selectedDetectionModelId === 'manual-fixture-detector'
|
|
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo\'s'
|
|
: !detectionModelReady
|
|
? selectedDetectionModelAvailability
|
|
: !detectionHasExplicitModelAsset
|
|
? 'Kies een lokaal modelbestand onder beheer'
|
|
: null
|
|
|
|
return (
|
|
<section className="workspace-panel ai-lab-shell detection-lab-shell">
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<p className="eyebrow">Analyse van luchtbeelden</p>
|
|
<h2>Gebouwen herkennen</h2>
|
|
</div>
|
|
<button className="secondary-action" type="button" onClick={onLoadModels} disabled={loadingDetectionModels}>
|
|
Status vernieuwen
|
|
</button>
|
|
</div>
|
|
|
|
<AiPipelineIllustration
|
|
hasImagery={detectionHasDataset}
|
|
hasTiles={detectionHasTileManifest}
|
|
gpuReady={yoloRuntimeReady && Boolean(yoloPreflight?.runtime.cuda_available)}
|
|
hasDetections={detectionItems.length > 0 || detectionRuns.some((run) => run.status === 'completed')}
|
|
hasQualityEvidence={Boolean(detectionQaResult) || qualityChecks.length > 0}
|
|
running={runningDetection || runningDetectionQa || runningDetectionCalibration}
|
|
/>
|
|
|
|
<div className="ai-user-summary" aria-label="Status gebouwdetectie">
|
|
<div className="ai-user-summary-card ai-user-summary-card-primary">
|
|
<span>Actieve analyse</span>
|
|
<strong>Gebouwdetectie</strong>
|
|
<p>{selectedOperatorProfile?.displayName ?? selectedModelAsset?.display_name ?? 'Lokaal YOLO-model'}</p>
|
|
</div>
|
|
<div className={yoloRuntimeReady ? 'ai-user-summary-card ai-user-summary-card-ready' : 'ai-user-summary-card'}>
|
|
<span>Rekenomgeving</span>
|
|
<strong>{yoloRuntimeReady ? 'Gereed' : loadingDetectionModels ? 'Controleren...' : 'Niet gereed'}</strong>
|
|
<p>{yoloRuntimeReady ? `${yoloPreflight?.runtime.cuda_available ? 'GPU' : 'CPU'} · lokaal model gevonden` : 'Controleer de modelconfiguratie onder beheer.'}</p>
|
|
</div>
|
|
<div className="ai-user-summary-card">
|
|
<span>Bewijsstatus</span>
|
|
<strong>{selectedOperatorProfile ? `Historische F1 ${selectedOperatorProfile.f1.toFixed(3)}` : 'Nog niet gekoppeld'}</strong>
|
|
<p>{selectedOperatorProfile ? selectedOperatorProfile.validationScope : 'Kies een modelprofiel met gedocumenteerd evaluatiebewijs.'}</p>
|
|
</div>
|
|
<div className={rasterDatasets.length > 0 ? 'ai-user-summary-card ai-user-summary-card-ready' : 'ai-user-summary-card'}>
|
|
<span>Beschikbare luchtbeelden</span>
|
|
<strong>{rasterDatasets.length}</strong>
|
|
<p>{rasterDatasets.length > 0 ? 'Klaar om een beeld te kiezen.' : 'Laad eerst een gegeorefereerd luchtbeeld in.'}</p>
|
|
</div>
|
|
</div>
|
|
<p className="ai-quality-guidance">
|
|
{detectionQualityInterpretation(selectedOperatorProfile?.f1)}
|
|
</p>
|
|
{selectedOperatorProfile && selectedDetectionModel?.nationally_validated !== true ? (
|
|
<div className="result-state result-state-warning" role="status">
|
|
<strong>Nog niet nationaal gevalideerd</strong>
|
|
<p>
|
|
Dit model is operationeel voor gecontroleerde beeldanalyse, maar de gemeten kwaliteit geldt alleen voor {selectedDetectionModel?.validation_scope ?? selectedOperatorProfile.validationScope}.
|
|
Resultaten elders in Belgie of op zee vereisen lokale referentiedata en QA voordat ze als betrouwbaar kunnen worden vrijgegeven.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{!managementLocked ? <DetectionModelManagement
|
|
detectionModels={detectionModels}
|
|
modelAssets={modelAssets}
|
|
loadingDetectionModels={loadingDetectionModels}
|
|
detectionModelError={detectionModelError}
|
|
modelAssetError={modelAssetError}
|
|
selectedDetectionModelId={selectedDetectionModelId}
|
|
selectedModelAssetId={selectedModelAssetId}
|
|
detectionConfidenceThreshold={detectionConfidenceThreshold}
|
|
yoloPreflight={yoloPreflight}
|
|
loadingYoloPreflight={loadingYoloPreflight}
|
|
yoloPreflightError={yoloPreflightError}
|
|
onRefreshYoloPreflight={onRefreshYoloPreflight}
|
|
onSelectModelAsset={onSelectModelAsset}
|
|
onApplyOperatorProfile={onApplyOperatorProfile}
|
|
/> : null}
|
|
|
|
<div className="lab-block">
|
|
<div className="ai-lab-run-surface" aria-label="Gebouwdetectie starten">
|
|
<h3>Nieuwe beeldanalyse</h3>
|
|
<div
|
|
className={guidedDetectionReady ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}
|
|
aria-label="Startklaar voor gebouwdetectie"
|
|
>
|
|
<div className="ai-lab-section-header">
|
|
<div>
|
|
<h3>Wat is nog nodig?</h3>
|
|
<p>Kies een luchtbeeld en model. GeoIntel maakt de beeldtegels en laadt het resultaat daarna automatisch op de kaart.</p>
|
|
</div>
|
|
<span className={guidedDetectionReady ? 'status-badge status-badge-ready' : 'status-badge'}>
|
|
{guidedDetectionReady ? 'Klaar om te starten' : 'Nog niet startklaar'}
|
|
</span>
|
|
</div>
|
|
<div className="lab-readiness-grid">
|
|
<div className={detectionHasDataset ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
|
<span>Luchtbeeld</span>
|
|
<strong>{detectionHasDataset ? 'Geselecteerd' : 'Nog geen luchtbeeld'}</strong>
|
|
</div>
|
|
<div className={detectionModelReady ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
|
<span>Analysemodel</span>
|
|
<strong>
|
|
{detectionModelReady
|
|
? 'Lokaal gebouwmodel beschikbaar'
|
|
: 'Model niet beschikbaar'}
|
|
</strong>
|
|
</div>
|
|
<div className={detectionHasTileManifest ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
|
<span>Beeldtegels</span>
|
|
<strong>
|
|
{detectionRequiresTileManifest
|
|
? detectionHasTileManifest
|
|
? 'Beschikbaar'
|
|
: detectionHasDataset
|
|
? 'Worden automatisch voorbereid'
|
|
: 'Wachten op een luchtbeeld'
|
|
: 'Niet vereist'}
|
|
</strong>
|
|
</div>
|
|
<div className={detectionHasExplicitModelAsset ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
|
<span>Lokale modelkeuze</span>
|
|
<strong>
|
|
{selectedDetectionModelId === 'yolo-configured'
|
|
? selectedModelAsset
|
|
? selectedOperatorProfile?.displayName ?? selectedModelAsset.display_name
|
|
: modelAssets.length > 0
|
|
? 'Kies een lokaal model onder beheer'
|
|
: 'Geen lokaal model gevonden'
|
|
: 'Niet vereist'}
|
|
</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className={guidedDetectionReady ? 'lab-action-guardrail lab-action-guardrail-ready' : 'lab-action-guardrail'}>
|
|
<span>Analyse</span>
|
|
<strong>{guidedDetectionReady ? 'Klaar om gebouwen te zoeken' : guidedDetectionBlockedReason}</strong>
|
|
</div>
|
|
{rasterDatasets.length === 0 ? (
|
|
<div className="result-state result-state-empty">
|
|
<strong>Geen luchtbeeld beschikbaar in deze werkruimte.</strong>
|
|
<p>Voeg hieronder een gegeorefereerde GeoTIFF toe. GeoIntel controleert de projectie en bewaart het bronbestand als dataset.</p>
|
|
</div>
|
|
) : null}
|
|
{!managementLocked ? <div className="guided-raster-input" aria-label="Luchtbeeld toevoegen">
|
|
<div>
|
|
<strong>Eigen luchtbeeld toevoegen</strong>
|
|
<p>Gebruik een GeoTIFF met geldige CRS en georeferentie. Een bestaand luchtbeeld kan meteen in de keuzelijst worden gebruikt.</p>
|
|
</div>
|
|
<label className="file-picker-field">
|
|
<span>GeoTIFF-bestand</span>
|
|
<input
|
|
ref={rasterFileInputRef}
|
|
type="file"
|
|
accept=".tif,.tiff,image/tiff,application/geotiff"
|
|
onChange={(event) => setPendingRasterFile(event.target.files?.[0] ?? null)}
|
|
disabled={runningDetection}
|
|
/>
|
|
</label>
|
|
<button
|
|
className="secondary-action"
|
|
type="button"
|
|
disabled={!pendingRasterFile || runningDetection}
|
|
onClick={async () => {
|
|
if (pendingRasterFile && await onUploadRaster(pendingRasterFile)) {
|
|
setPendingRasterFile(null)
|
|
if (rasterFileInputRef.current) {
|
|
rasterFileInputRef.current.value = ''
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
{detectionWorkflowStage === 'uploading' ? 'Luchtbeeld toevoegen...' : 'Luchtbeeld toevoegen'}
|
|
</button>
|
|
</div> : null}
|
|
<div className="lab-form-grid">
|
|
<label>
|
|
Luchtbeeld
|
|
<select
|
|
value={selectedDetectionDatasetId}
|
|
onChange={(event) => {
|
|
onSelectDataset(event.target.value)
|
|
onSetTileManifestPath('')
|
|
}}
|
|
>
|
|
<option value="">Kies een luchtbeeld</option>
|
|
{rasterDatasets.map((dataset) => (
|
|
<option key={dataset.id} value={dataset.id}>
|
|
{dataset.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<ModelSelector
|
|
label="Analysemodel"
|
|
value={selectedDetectionModelId}
|
|
options={detectionModels.map(toAnalysisModelOption)}
|
|
onChange={onSelectModel}
|
|
loading={loadingDetectionModels}
|
|
error={detectionModelError}
|
|
onRefresh={onLoadModels}
|
|
/>
|
|
<label>
|
|
Minimale zekerheid
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={detectionConfidenceThreshold}
|
|
onChange={(event) => onSetConfidenceThreshold(Number(event.target.value))}
|
|
/>
|
|
{selectedDetectionModelId === 'yolo-configured' ? (
|
|
<span className="field-guidance">
|
|
Het actieve gevalideerde profiel gebruikt standaard 0,15 voor een evenwichtige gebouwcontrole.
|
|
</span>
|
|
) : null}
|
|
</label>
|
|
</div>
|
|
<div className="guided-detection-progress" aria-live="polite">
|
|
<DetectionWorkflowStep label="1. Luchtbeeld" complete={detectionHasDataset} active={detectionWorkflowStage === 'uploading'} />
|
|
<DetectionWorkflowStep label="2. Beeldtegels" complete={detectionHasTileManifest || detectionWorkflowStage === 'complete'} active={detectionWorkflowStage === 'tiling'} />
|
|
<DetectionWorkflowStep label="3. Modelcontrole" complete={detectionWorkflowStage === 'detecting' || detectionWorkflowStage === 'loading' || detectionWorkflowStage === 'complete'} active={detectionWorkflowStage === 'validating'} />
|
|
<DetectionWorkflowStep label="4. Resultaat" complete={detectionWorkflowStage === 'complete'} active={detectionWorkflowStage === 'detecting' || detectionWorkflowStage === 'loading'} />
|
|
</div>
|
|
<button className="primary-action guided-detection-action" type="button" onClick={onPrepareAndRunDetection} disabled={runningDetection || runningDetectionCalibration || detectionJobActive || !guidedDetectionReady}>
|
|
{detectionWorkflowActionLabel(detectionWorkflowStage, detectionJob?.status)}
|
|
</button>
|
|
|
|
{!managementLocked ? <details className="ai-lab-model-surface technical-manifest-surface" aria-label="Technische tegelinstellingen">
|
|
<summary>
|
|
<span>Technische tegelinstellingen</span>
|
|
<strong>{detectionHasTileManifest ? 'manifest beschikbaar' : 'automatisch'}</strong>
|
|
</summary>
|
|
<div className="ai-lab-disclosure-body">
|
|
<p className="muted">De normale actie gebruikt automatisch 512 px-tegels met 64 px overlap. Alleen beheerders hoeven hier een bestaand manifest te koppelen.</p>
|
|
{selectedDetectionModelId === 'yolo-configured' ? (
|
|
<label>
|
|
Beeldtegelbestand
|
|
<input
|
|
type="text"
|
|
placeholder="Pad naar de aangemaakte beeldtegels"
|
|
value={detectionTileManifestPath}
|
|
onChange={(event) => onSetTileManifestPath(event.target.value)}
|
|
/>
|
|
</label>
|
|
) : null}
|
|
{selectedDetectionModelId === 'yolo-configured' && detectionTileManifestPath.trim() ? (
|
|
<div className="linked-manifest-card">
|
|
<strong>Gekoppelde beeldtegels</strong>
|
|
<p>{detectionTileManifestPath}</p>
|
|
<span>De technische controle wordt vernieuwd wanneer het model of tegelbestand wijzigt.</span>
|
|
</div>
|
|
) : null}
|
|
<button className="secondary-action" type="button" onClick={onRunDetection} disabled={runningDetection || runningDetectionCalibration || detectionJobActive || !detectionRunReady}>
|
|
Bestaande beeldtegels analyseren
|
|
</button>
|
|
</div>
|
|
</details> : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ai-lab-state-stack">
|
|
{detectionJob && (detectionJob.status === 'queued' || detectionJob.status === 'running') ? (
|
|
<div className="result-state" role="status" aria-live="polite">
|
|
<strong>{detectionJob.status === 'queued' ? 'GPU-taak staat in de wachtrij.' : 'GPU-analyse wordt uitgevoerd.'}</strong>
|
|
<p>
|
|
{detectionJob.status === 'queued'
|
|
? 'De server heeft de aanvraag veilig bewaard en start ze zodra de NVIDIA-worker beschikbaar is.'
|
|
: 'Het model verwerkt de beeldtegels op de server. Dit scherm volgt de bewaarde taak automatisch.'}
|
|
</p>
|
|
<span className="muted">Taak-ID: {detectionJob.id}</span>
|
|
</div>
|
|
) : null}
|
|
{detectionRunError ? (
|
|
<div className="result-state result-state-error" role="alert">
|
|
<strong>{detectionJobActive ? 'Het volgen van de servertaak is onderbroken.' : 'De beeldanalyse is mislukt.'}</strong>
|
|
<p>{detectionRunError}</p>
|
|
</div>
|
|
) : null}
|
|
{detectionRunResult ? (
|
|
<div className={detectionRunResult.detection_count === 0 ? 'result-state result-state-warning' : 'result-summary-card'} role="status">
|
|
<p>Status: {detectionStatusLabel(detectionRunResult.status)}</p>
|
|
<p>{detectionRunResult.message}</p>
|
|
<p>Gevonden objecten: {detectionRunResult.detection_count}</p>
|
|
{detectionRunResult.error_code ? <p className="error">Code: {detectionRunResult.error_code}</p> : null}
|
|
<details className="technical-inline-details">
|
|
<summary>Technische verwerking</summary>
|
|
<div className="entity-meta">
|
|
<span>Analyserun-ID: {detectionRunResult.analysis_run_id}</span>
|
|
<span>Taak-ID: {detectionRunResult.job_id}</span>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{!managementLocked ? <details className="ai-lab-model-surface guided-calibration-surface" aria-label="Modelkalibratie voor beheerders">
|
|
<summary>
|
|
<span>Modelkalibratie voor beheerders</span>
|
|
<strong>{detectionCalibrationRows.length > 0 ? `${detectionCalibrationRows.length} drempels getest` : 'gesloten'}</strong>
|
|
</summary>
|
|
<div className="ai-lab-disclosure-body">
|
|
<div className="ai-lab-section-header">
|
|
<div>
|
|
<h3>Zekerheidsdrempels vergelijken</h3>
|
|
<p>Voert het lokale model en een kwaliteitscontrole uit voor iedere drempel. Modelbestanden worden niet gewijzigd.</p>
|
|
</div>
|
|
<span className={calibrationRunReady ? 'status-badge status-badge-ready' : 'status-badge'}>
|
|
{calibrationRunReady ? 'startklaar' : 'luchtbeeld, model, beeldtegels en referentie vereist'}
|
|
</span>
|
|
</div>
|
|
<div className="lab-form-grid">
|
|
<label>
|
|
Zekerheidsdrempels
|
|
<input
|
|
type="text"
|
|
value={calibrationThresholdText}
|
|
onChange={(event) => onSetCalibrationThresholdText(event.target.value)}
|
|
placeholder="0.50 0.25 0.15"
|
|
/>
|
|
<span className="field-guidance">Scheid waarden met spaties, komma's of puntkomma's. Iedere waarde ligt tussen 0 en 1.</span>
|
|
</label>
|
|
<label>
|
|
Referentielaag
|
|
<select value={detectionReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
|
|
<option value="">Kies een referentielaag</option>
|
|
{referenceDatasets.map((dataset) => (
|
|
<option key={dataset.id} value={dataset.id}>
|
|
{dataset.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<button
|
|
className="primary-action"
|
|
type="button"
|
|
onClick={onRunCalibration}
|
|
disabled={runningDetectionCalibration || runningDetection || detectionJobActive || !calibrationRunReady}
|
|
>
|
|
Drempels vergelijken
|
|
</button>
|
|
{detectionCalibrationError ? (
|
|
<div className="result-state result-state-error">
|
|
<strong>De kalibratievergelijking is mislukt.</strong>
|
|
<p>{detectionCalibrationError}</p>
|
|
</div>
|
|
) : null}
|
|
{detectionCalibrationRows.length > 0 ? (
|
|
<div className="calibration-progress-panel" aria-label="Voortgang modelkalibratie">
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<h3>Voortgang modelkalibratie</h3>
|
|
<p className="muted">Iedere geslaagde rij is gekoppeld aan een bewaarde beeldanalyse en kwaliteitscontrole.</p>
|
|
</div>
|
|
<div className="panel-action-row">
|
|
<span className="count-pill">{detectionCalibrationRows.length} drempels</span>
|
|
<button
|
|
className="secondary-action"
|
|
type="button"
|
|
onClick={() => downloadCalibrationSummary(selectedProjectId, detectionCalibrationRows)}
|
|
disabled={detectionCalibrationRows.length === 0 || !selectedProjectId}
|
|
>
|
|
Samenvatting downloaden
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="table-scroll">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Drempel</th>
|
|
<th>Status</th>
|
|
<th>Objecten</th>
|
|
<th>Precisie</th>
|
|
<th>Herkenningsgraad</th>
|
|
<th>F1</th>
|
|
<th>Onterecht gevonden</th>
|
|
<th>Gemist</th>
|
|
<th>Kaartbewijs</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{detectionCalibrationRows.map((row) => (
|
|
<tr key={row.threshold} className={row.best_f1 ? 'calibration-row-best' : undefined}>
|
|
<td>
|
|
{row.threshold.toFixed(2)}
|
|
{row.best_f1 ? <span className="status-badge"> beste F1</span> : null}
|
|
</td>
|
|
<td>{row.status}</td>
|
|
<td>{row.detection_count ?? 'n.v.t.'}</td>
|
|
<td>{formatNullableNumber(row.precision ?? null, 3)}</td>
|
|
<td>{formatNullableNumber(row.recall ?? null, 3)}</td>
|
|
<td>{formatNullableNumber(row.f1_score ?? null, 3)}</td>
|
|
<td>{row.false_positives ?? 'n.v.t.'}</td>
|
|
<td>{row.false_negatives ?? 'n.v.t.'}</td>
|
|
<td>
|
|
<button
|
|
className="secondary-action table-action"
|
|
type="button"
|
|
onClick={() => row.quality_check_id ? onOpenCalibrationEvidence(row.quality_check_id) : undefined}
|
|
disabled={!row.quality_check_id || row.status !== 'success'}
|
|
aria-label={`Open kaartbewijs voor drempel ${row.threshold.toFixed(2)}`}
|
|
>
|
|
Toon op kaart
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="result-state result-state-empty">
|
|
<strong>In deze sessie zijn nog geen drempels vergeleken.</strong>
|
|
<p>Kies een referentielaag en drempelreeks en start daarna de vergelijking.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</details> : null}
|
|
|
|
<div className="ai-lab-results-surface" aria-label="Resultaten van de beeldanalyse">
|
|
{detectionTruncated ? (
|
|
<p className="geo-data-notice">
|
|
Deze run leverde {(detectionTotal ?? detectionItems.length).toLocaleString('nl-BE')} detecties op;
|
|
hieronder en op de kaart staan de {detectionItems.length.toLocaleString('nl-BE')} met de hoogste
|
|
zekerheid. De tellingen in de kwaliteitscontrole gebruiken de volledige run.
|
|
</p>
|
|
) : null}
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<h3>Gevonden objecten</h3>
|
|
<p className="muted">Bekijk eerder bewaarde analyses en filter op type of zekerheid.</p>
|
|
</div>
|
|
<div className="panel-action-row">
|
|
<button className="secondary-action" type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
|
|
Analyses vernieuwen
|
|
</button>
|
|
<button className="primary-action" type="button" onClick={onOpenResultsOnMap} disabled={detectionItems.length === 0}>
|
|
Toon op kaart
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="lab-form-grid">
|
|
<label>
|
|
Analyse
|
|
<select value={selectedDetectionRunId} onChange={(event) => onSelectRun(event.target.value)}>
|
|
<option value="">Kies een bewaarde analyse</option>
|
|
{detectionRuns.map((run) => (
|
|
<option key={run.id} value={run.id}>
|
|
{formatDetectionRunLabel(run)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Type object
|
|
<input
|
|
type="text"
|
|
placeholder="bijvoorbeeld gebouw"
|
|
value={detectionClassFilter}
|
|
onChange={(event) => onSetClassFilter(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Minimale zekerheid
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={detectionMinConfidenceFilter}
|
|
onChange={(event) => onSetMinConfidenceFilter(Number(event.target.value))}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<button className="primary-action" type="button" onClick={onLoadResults} disabled={!selectedDetectionRunId || loadingDetectionResults}>
|
|
Resultaten laden
|
|
</button>
|
|
{loadingDetectionResults ? (
|
|
<div className="result-state result-state-loading">
|
|
<strong>Resultaten laden.</strong>
|
|
<p>De bewaarde objecten van deze analyse worden opgehaald.</p>
|
|
</div>
|
|
) : null}
|
|
<div className="ai-lab-state-stack">
|
|
<div className="result-state result-state-ready">
|
|
<strong>{detectionItems.length} objecten geladen</strong>
|
|
<p>{selectedDetectionRunId ? 'Deze resultaten zijn bewaard in de database.' : 'Kies eerst een bewaarde analyse.'}</p>
|
|
</div>
|
|
</div>
|
|
{detectionItems.length > 0 ? (
|
|
<>
|
|
<div className="pagination-toolbar" aria-label="Paginering van gevonden objecten">
|
|
<p className="pagination-summary" aria-live="polite">
|
|
<strong>{detectionPageStart + 1}-{detectionPageEnd}</strong>
|
|
<span>van {detectionItems.length}</span>
|
|
</p>
|
|
<label className="pagination-page-size">
|
|
Rijen
|
|
<select
|
|
value={detectionPageSize}
|
|
onChange={(event) => {
|
|
setDetectionPageSize(Number(event.target.value))
|
|
setDetectionResultPage(1)
|
|
}}
|
|
>
|
|
{DETECTION_PAGE_SIZE_OPTIONS.map((pageSize) => (
|
|
<option key={pageSize} value={pageSize}>{pageSize}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div className="pagination-actions">
|
|
<button
|
|
className="secondary-action pagination-button"
|
|
type="button"
|
|
aria-label="Vorige resultatenpagina"
|
|
title="Vorige pagina"
|
|
disabled={currentDetectionPage <= 1}
|
|
onClick={() => setDetectionResultPage(currentDetectionPage - 1)}
|
|
>
|
|
{'<'}
|
|
</button>
|
|
<span>Pagina {currentDetectionPage} van {detectionPageCount}</span>
|
|
<button
|
|
className="secondary-action pagination-button"
|
|
type="button"
|
|
aria-label="Volgende resultatenpagina"
|
|
title="Volgende pagina"
|
|
disabled={currentDetectionPage >= detectionPageCount}
|
|
onClick={() => setDetectionResultPage(currentDetectionPage + 1)}
|
|
>
|
|
{'>'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="table-scroll">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Type</th>
|
|
<th>Zekerheid</th>
|
|
<th>Model</th>
|
|
<th>Herkomst</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visibleDetectionItems.map((detection) => (
|
|
<tr key={detection.id}>
|
|
<td>{detectionClassLabel(detection.class_name)}</td>
|
|
<td>{detection.confidence.toFixed(2)}</td>
|
|
<td>{persistedDetectionModelLabel(detection.model_name)}</td>
|
|
<td className="source-tile-cell">Luchtbeeldtegel</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
|
|
<details className="secondary-analysis-disclosure detection-technical-evaluation">
|
|
<summary>
|
|
<span>Technische modelevaluatie</span>
|
|
<strong>{calibrationRows.length > 0 || detectionQaResult ? 'resultaten beschikbaar' : 'optioneel'}</strong>
|
|
</summary>
|
|
<div className="ai-lab-disclosure-body">
|
|
<div className="ai-lab-results-surface calibration-comparison-surface" aria-label="Vergelijking modelkalibratie">
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<h3>Kalibraties vergelijken</h3>
|
|
<p className="muted">Vergelijk bewaarde analyseruns per zekerheidsdrempel voordat een modelinstelling wordt goedgekeurd.</p>
|
|
</div>
|
|
<span className="count-pill">{calibrationRows.length} resultaten</span>
|
|
</div>
|
|
{calibrationRows.length > 0 ? (
|
|
<>
|
|
<div className="calibration-summary-grid" aria-label="Beste kalibratieresultaten">
|
|
<CalibrationSummaryCard title="Beste F1-score" row={bestF1Candidate} metric="f1" />
|
|
<CalibrationSummaryCard title="Beste precisie" row={bestPrecisionCandidate} metric="precision" />
|
|
<CalibrationSummaryCard title="Minste foutieve meldingen" row={lowestFalsePositivePressureCandidate} metric="falsePositives" />
|
|
</div>
|
|
<div className="table-scroll">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Drempel</th>
|
|
<th>Model</th>
|
|
<th>Objecten</th>
|
|
<th>Precisie</th>
|
|
<th>Herkenningsgraad</th>
|
|
<th>F1</th>
|
|
<th>Fout positief</th>
|
|
<th>Fout negatief</th>
|
|
<th>Kwaliteitscontrole</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{calibrationRows.map((row) => (
|
|
<tr key={`${row.analysisRunId}-${row.qualityCheckId}`}>
|
|
<td>{formatNullableNumber(row.threshold, 2)}</td>
|
|
<td>
|
|
<strong>{row.modelName}</strong>
|
|
<span className="table-subtle">{row.modelAssetId ?? 'geconfigureerd lokaal model'}</span>
|
|
</td>
|
|
<td>{row.detectionCount ?? 'n.v.t.'}</td>
|
|
<td>{formatNullableNumber(row.precision, 3)}</td>
|
|
<td>{formatNullableNumber(row.recall, 3)}</td>
|
|
<td>{formatNullableNumber(row.f1, 3)}</td>
|
|
<td>{row.falsePositives ?? 'n.v.t.'}</td>
|
|
<td>{row.falseNegatives ?? 'n.v.t.'}</td>
|
|
<td>
|
|
<button className="secondary-action table-action" type="button" onClick={() => onOpenCalibrationEvidence(row.qualityCheckId)}>
|
|
Toon kaartbewijs
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="lab-action-guardrail">
|
|
<span>Voorwaarde voor goedkeuring</span>
|
|
<strong>Keur pas goed nadat meerdere gebieden en de foutieve positieve en negatieve resultaten zijn gecontroleerd.</strong>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="result-state result-state-empty">
|
|
<strong>Nog geen kalibratievergelijking beschikbaar.</strong>
|
|
<p>Voer het lokale model met meerdere zekerheidsdrempels uit en vergelijk de resultaten met dezelfde referentielaag.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<div className="ai-lab-qa-surface" aria-label="Kwaliteitscontrole gebouwdetectie">
|
|
<h3>Kwaliteitscontrole gebouwdetectie</h3>
|
|
<p className="muted">Vergelijk de gevonden gebouwen met een bewaarde officiële referentielaag. De uitkomst wordt als kwaliteitscontrole in de database bewaard.</p>
|
|
<label>
|
|
Referentielaag
|
|
<select value={detectionReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
|
|
<option value="">Kies een referentielaag</option>
|
|
{referenceDatasets.map((dataset) => (
|
|
<option key={dataset.id} value={dataset.id}>
|
|
{dataset.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button className="primary-action" type="button" onClick={onRunQa} disabled={runningDetectionQa || !selectedDetectionRunId || !detectionReferenceDatasetId}>
|
|
Vergelijk met referentielaag
|
|
</button>
|
|
{detectionQaError ? (
|
|
<div className="result-state result-state-error">
|
|
<strong>De kwaliteitscontrole is mislukt.</strong>
|
|
<p>{detectionQaError}</p>
|
|
</div>
|
|
) : null}
|
|
{detectionQaResult ? (
|
|
<div className="result-summary-card">
|
|
<p>Status: {detectionStatusLabel(detectionQaResult.status)}</p>
|
|
<p>Precisie: {detectionQaResult.precision?.toFixed(3) ?? 'n.v.t.'}</p>
|
|
<p>Herkenningsgraad: {detectionQaResult.recall?.toFixed(3) ?? 'n.v.t.'}</p>
|
|
<p>F1: {detectionQaResult.f1_score?.toFixed(3) ?? 'n.v.t.'}</p>
|
|
<p>Gemiddelde overlap: {detectionQaResult.mean_iou?.toFixed(3) ?? 'n.v.t.'}</p>
|
|
<p>Minimale IoU voor een match: {detectionQaResult.iou_threshold.toFixed(2)}</p>
|
|
<p>Fout positief: {detectionQaResult.false_positives}</p>
|
|
<p>Fout negatief: {detectionQaResult.false_negatives}</p>
|
|
<details className="technical-inline-details">
|
|
<summary>Technische referentie</summary>
|
|
<span>Kwaliteitscontrole-ID: {detectionQaResult.quality_check_id}</span>
|
|
</details>
|
|
{detectionQaResult.coverage ? (
|
|
<div className="detection-qa-diagnostic">
|
|
<span>Gecontroleerd beeldbereik</span>
|
|
<strong>
|
|
{detectionQaResult.coverage.applied
|
|
? `${detectionQaResult.coverage.reference_evaluated_count} van ${detectionQaResult.coverage.reference_raw_count} referentieobjecten gecontroleerd`
|
|
: 'De volledige referentielaag is gecontroleerd'}
|
|
</strong>
|
|
<p>
|
|
{detectionQaResult.coverage.applied
|
|
? `${detectionQaResult.coverage.reference_excluded_outside_count} buiten beeldbereik, ${detectionQaResult.coverage.reference_clipped_boundary_count} aan de rand begrensd, ${detectionQaResult.coverage.tile_count} beeldtegels.`
|
|
: 'Deze controle gebruikt alle objecten uit de gekozen referentielaag.'}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
{detectionQaResult.box_to_footprint_diagnostics ? (
|
|
<div className="detection-qa-diagnostic detection-qa-diagnostic-caution">
|
|
<span>Aanvullende vormdiagnose</span>
|
|
<strong>
|
|
{detectionQaResult.box_to_footprint_diagnostics.envelope_matches} rechthoekmatches tegenover{' '}
|
|
{detectionQaResult.box_to_footprint_diagnostics.strict_matches} strikte vormmatches
|
|
</strong>
|
|
<p>
|
|
{detectionQaResult.box_to_footprint_diagnostics.possible_box_to_footprint_mismatch_count} mogelijke vormafwijkingen. Precisie, herkenningsgraad en F1 hierboven blijven gebaseerd op de strikte geometrische overlap.
|
|
</p>
|
|
{detectionQaResult.box_to_footprint_diagnostics.interpretation ? (
|
|
<p>{detectionQaResult.box_to_footprint_diagnostics.interpretation}</p>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{detectionQaResult.precision_recall_curve ? (
|
|
<div className="detection-qa-diagnostic">
|
|
<span>Drempelonafhankelijke kwaliteit</span>
|
|
<strong>
|
|
AP {formatNullableNumber(detectionQaResult.precision_recall_curve.average_precision, 3)} · beste F1{' '}
|
|
{formatNullableNumber(detectionQaResult.precision_recall_curve.best_f1, 3)}
|
|
</strong>
|
|
<p>
|
|
{detectionQaResult.precision_recall_curve.best_f1_threshold === null
|
|
? 'Geen kandidaten om een werkpunt uit af te leiden.'
|
|
: `De beste F1 ligt bij drempel ${formatNullableNumber(
|
|
detectionQaResult.precision_recall_curve.best_f1_threshold,
|
|
2,
|
|
)}. Precisie en herkenningsgraad hierboven gelden alleen voor de gekozen drempel; AP vat de volledige curve samen en maakt vergelijking tussen modellen mogelijk.`}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function CalibrationSummaryCard({
|
|
title,
|
|
row,
|
|
metric,
|
|
}: {
|
|
title: string
|
|
row: CalibrationRow | null
|
|
metric: 'f1' | 'precision' | 'falsePositives'
|
|
}): JSX.Element {
|
|
return (
|
|
<div className={row ? 'calibration-summary-card calibration-summary-card-ready' : 'calibration-summary-card'}>
|
|
<span>{title}</span>
|
|
{row ? (
|
|
<>
|
|
<strong>{metric === 'falsePositives' ? row.falsePositives ?? 'n.v.t.' : formatNullableNumber(row[metric], 3)}</strong>
|
|
<p>Threshold {formatNullableNumber(row.threshold, 2)} · {row.modelName}</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<strong>n.v.t.</strong>
|
|
<p>Persisted QA metrics are required.</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DetectionWorkflowStep({
|
|
label,
|
|
complete,
|
|
active,
|
|
}: {
|
|
label: string
|
|
complete: boolean
|
|
active: boolean
|
|
}): JSX.Element {
|
|
const className = active
|
|
? 'guided-detection-step guided-detection-step-active'
|
|
: complete
|
|
? 'guided-detection-step guided-detection-step-complete'
|
|
: 'guided-detection-step'
|
|
return (
|
|
<div className={className}>
|
|
<span aria-hidden="true">{complete ? 'OK' : active ? '...' : '-'}</span>
|
|
<strong>{label}</strong>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function detectionWorkflowActionLabel(stage: DetectionWorkflowStage, jobStatus?: string): string {
|
|
if (stage === 'tiling') return 'Beeldtegels voorbereiden...'
|
|
if (stage === 'validating') return 'Model en beeld controleren...'
|
|
if (stage === 'detecting' && jobStatus === 'queued') return 'Wachten op NVIDIA GPU...'
|
|
if (stage === 'detecting') return 'Gebouwen zoeken op NVIDIA GPU...'
|
|
if (stage === 'loading') return 'Resultaat op kaart laden...'
|
|
if (stage === 'complete') return 'Analyse opnieuw uitvoeren'
|
|
return 'Gebouwen zoeken en op kaart tonen'
|
|
}
|
|
|
|
function buildCalibrationRows(detectionRuns: DetectionRunRead[], qualityChecks: QualityCheckRead[]): CalibrationRow[] {
|
|
const runById = new Map(detectionRuns.map((run) => [run.id, run]))
|
|
return qualityChecks
|
|
.flatMap((check) => {
|
|
const run = check.analysis_run_id ? runById.get(check.analysis_run_id) : null
|
|
if (!run || run.analysis_type !== 'detection') {
|
|
return []
|
|
}
|
|
const threshold = confidenceThresholdForRun(run)
|
|
return [{
|
|
analysisRunId: run.id,
|
|
qualityCheckId: check.id,
|
|
threshold,
|
|
modelName: run.model_name ?? 'geconfigureerde detectie',
|
|
modelAssetId: stringFromRecord(run.parameters_json, 'model_asset_id'),
|
|
detectionCount: numberFromRecord(run.result_json, 'detection_count'),
|
|
precision: metricValue(check, 'precision'),
|
|
recall: metricValue(check, 'recall'),
|
|
f1: metricValue(check, 'f1'),
|
|
falsePositives: metricValue(check, 'false_positives'),
|
|
falseNegatives: metricValue(check, 'false_negatives'),
|
|
score: check.score ?? null,
|
|
createdAt: check.completed_at ?? check.created_at ?? run.finished_at ?? run.created_at ?? null,
|
|
}]
|
|
})
|
|
.sort((left, right) => {
|
|
const createdDiff = Date.parse(right.createdAt ?? '') - Date.parse(left.createdAt ?? '')
|
|
if (Number.isFinite(createdDiff) && createdDiff !== 0) {
|
|
return createdDiff
|
|
}
|
|
return (right.threshold ?? -1) - (left.threshold ?? -1)
|
|
})
|
|
}
|
|
|
|
function confidenceThresholdForRun(run: DetectionRunRead): number | null {
|
|
return (
|
|
numberFromRecord(run.parameters_json, 'confidence_threshold') ??
|
|
numberFromRecord(run.result_json, 'confidence_threshold') ??
|
|
null
|
|
)
|
|
}
|
|
|
|
function metricValue(check: QualityCheckRead, key: string): number | null {
|
|
const aliases = key === 'f1' ? ['f1', 'f1_score'] : [key]
|
|
for (const alias of aliases) {
|
|
const metric = check.metrics.find((item) => item.metric_key === alias)
|
|
if (typeof metric?.metric_value === 'number' && Number.isFinite(metric.metric_value)) {
|
|
return metric.metric_value
|
|
}
|
|
const findingValue = numberFromRecord(check.findings_json, alias)
|
|
if (findingValue !== null) {
|
|
return findingValue
|
|
}
|
|
}
|
|
return key === 'f1' && typeof check.score === 'number' ? check.score : null
|
|
}
|
|
|
|
function bestCalibrationRow(rows: CalibrationRow[], metric: 'f1' | 'precision'): CalibrationRow | null {
|
|
return rows.reduce<CalibrationRow | null>((best, row) => {
|
|
const value = row[metric]
|
|
if (value === null) {
|
|
return best
|
|
}
|
|
if (!best || value > (best[metric] ?? Number.NEGATIVE_INFINITY)) {
|
|
return row
|
|
}
|
|
return best
|
|
}, null)
|
|
}
|
|
|
|
function bestLowestCalibrationRow(rows: CalibrationRow[], metric: 'falsePositives'): CalibrationRow | null {
|
|
return rows.reduce<CalibrationRow | null>((best, row) => {
|
|
const value = row[metric]
|
|
if (value === null) {
|
|
return best
|
|
}
|
|
if (!best || value < (best[metric] ?? Number.POSITIVE_INFINITY)) {
|
|
return row
|
|
}
|
|
return best
|
|
}, null)
|
|
}
|
|
|
|
function numberFromRecord(record: Record<string, unknown> | null | undefined, key: string): number | null {
|
|
const value = record?.[key]
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
}
|
|
|
|
function stringFromRecord(record: Record<string, unknown> | null | undefined, key: string): string | null {
|
|
const value = record?.[key]
|
|
return typeof value === 'string' && value.trim().length > 0 ? value : null
|
|
}
|
|
|
|
function buildCalibrationSummaryExport(projectId: string, rows: DetectionCalibrationRunRow[]): Record<string, unknown> {
|
|
const successfulRows = rows.filter((row) => row.status === 'success')
|
|
return {
|
|
export_type: 'detection_calibration_summary',
|
|
project_id: projectId,
|
|
created_at: new Date().toISOString(),
|
|
calibration_thresholds: rows.map((row) => row.threshold),
|
|
quality_check_ids: successfulRows.map((row) => row.quality_check_id).filter(Boolean),
|
|
rows: rows.map((row) => ({
|
|
threshold: row.threshold,
|
|
status: row.status,
|
|
analysis_run_id: row.analysis_run_id ?? null,
|
|
job_id: row.job_id ?? null,
|
|
quality_check_id: row.quality_check_id ?? null,
|
|
evidence_geojson_url: row.quality_check_id
|
|
? `/api/v1/projects/${projectId}/quality-checks/${row.quality_check_id}/evidence/geojson`
|
|
: null,
|
|
detection_count: row.detection_count ?? null,
|
|
precision: row.precision ?? null,
|
|
recall: row.recall ?? null,
|
|
f1_score: row.f1_score ?? null,
|
|
false_positives: row.false_positives ?? null,
|
|
false_negatives: row.false_negatives ?? null,
|
|
message: row.message ?? null,
|
|
})),
|
|
}
|
|
}
|
|
|
|
function downloadCalibrationSummary(projectId: string | null, rows: DetectionCalibrationRunRow[]): void {
|
|
if (!projectId || rows.length === 0) {
|
|
return
|
|
}
|
|
downloadJsonFile('detection-calibration-summary.json', buildCalibrationSummaryExport(projectId, rows))
|
|
}
|
|
|
|
function downloadJsonFile(filename: string, payload: unknown): void {
|
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
|
|
const url = URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = filename
|
|
link.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
function formatNullableNumber(value: number | null, digits: number): string {
|
|
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(digits) : 'n.v.t.'
|
|
}
|