Files
geointel/frontend/src/components/detection/DetectionLab.tsx
T
Codex 2f9898bc82
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s
feat: guide raster building analysis workflow
2026-07-15 00:19:38 +02:00

1336 lines
59 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import type {
DatasetCreateResponse,
DetectionModelCapability,
DetectionQaResult,
DetectionRead,
DetectionRunRead,
DetectionRunResponse,
ModelAssetRead,
QualityCheckRead,
YoloPreflightResponse,
} from '../../types'
import type { DetectionCalibrationRunRow, DetectionWorkflowStage } from '../../hooks/useDetectionWorkflow'
import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles'
const DETECTION_PAGE_SIZE_OPTIONS = [25, 50, 100] as const
const DEFAULT_DETECTION_PAGE_SIZE = 50
function detectionModelLabel(model: DetectionModelCapability): string {
if (model.model_id === 'yolo-configured') return 'Lokaal gebouwmodel'
if (model.model_id === 'manual-fixture-detector') return 'Testmodel (alleen voor demo)'
if (model.model_id === 'yolo-placeholder') return 'Gebouwmodel nog niet geconfigureerd'
return model.display_name
}
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 {
detectionModels: DetectionModelCapability[]
modelAssets: ModelAssetRead[]
loadingDetectionModels: boolean
detectionModelError: string | null
modelAssetError: string | null
selectedDetectionDatasetId: string
selectedDetectionModelId: string
selectedModelAssetId: string
detectionTileManifestPath: string
detectionConfidenceThreshold: number
runningDetection: boolean
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[]
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({
detectionModels,
modelAssets,
loadingDetectionModels,
detectionModelError,
modelAssetError,
selectedDetectionDatasetId,
selectedDetectionModelId,
selectedModelAssetId,
detectionTileManifestPath,
detectionConfidenceThreshold,
runningDetection,
detectionRunResult,
detectionRunError,
detectionRuns,
qualityChecks,
calibrationThresholdText,
runningDetectionCalibration,
detectionCalibrationRows,
detectionCalibrationError,
detectionWorkflowStage,
selectedDetectionRunId,
detectionItems,
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.model_file_exists,
)
const detectionRequiresTileManifest = selectedDetectionModelId === 'yolo-configured'
const detectionHasDataset = selectedDetectionDatasetId.length > 0
const detectionHasModel = selectedDetectionModel !== null
const detectionModelReady = Boolean(selectedDetectionModel?.configured)
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
? selectedDetectionModel?.limitation_message ?? 'Het gekozen model is niet geconfigureerd'
: !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
? selectedDetectionModel?.limitation_message ?? 'Het gekozen model is niet geconfigureerd'
: !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>
<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>PyTorch-runtime</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>Gevalideerde kwaliteit</span>
<strong>{selectedOperatorProfile ? `F1 ${selectedOperatorProfile.f1.toFixed(3)}` : 'Nog niet gekoppeld'}</strong>
<p>{selectedOperatorProfile ? `${selectedOperatorProfile.positiveSampleCount} testgebieden · resultaten blijven controleplichtig` : 'Kies het goedgekeurde lokale profiel.'}</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>
<details className="ai-lab-model-surface" aria-label="Detection model capabilities">
<summary>
<span>Technische modelinformatie</span>
<strong>{detectionModels.length} registraties</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-state-stack">
{loadingDetectionModels ? (
<div className="result-state result-state-loading">
<strong>Loading detection models.</strong>
<p>Checking backend model registry availability.</p>
</div>
) : null}
{detectionModelError ? (
<div className="result-state result-state-error">
<strong>Detection model registry unavailable.</strong>
<p>{detectionModelError}</p>
</div>
) : null}
{modelAssetError ? (
<div className="result-state result-state-error">
<strong>Local model assets unavailable.</strong>
<p>{modelAssetError}</p>
</div>
) : null}
{detectionModels.length === 0 && !loadingDetectionModels ? (
<div className="result-state result-state-empty">
<strong>No detection models reported by backend.</strong>
<p>Refresh models after the backend is reachable.</p>
</div>
) : null}
</div>
<ul className="model-list">
{detectionModels.map((model) => (
<li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
<strong>{detectionModelLabel(model)}</strong>
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.status}</span>
<div className="entity-meta">
<span>{model.model_id}</span>
<span>{model.framework}</span>
<span>{model.task_type}</span>
</div>
<p className="muted">classes: {model.supported_classes.join(', ')}</p>
<p className="muted">{model.limitation_message}</p>
</li>
))}
</ul>
</div>
</details>
{selectedDetectionModelId === 'yolo-configured' ? (
<details className="ai-lab-model-surface" aria-label="Local model asset selection">
<summary>
<span>Modelkeuze voor beheerders</span>
<strong>{selectedOperatorProfile?.displayName ?? selectedModelAsset?.display_name ?? 'Geen lokaal model'}</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-section-header">
<div>
<h3>Lokaal modelbestand</h3>
<p>GeoIntel kiest automatisch het actieve lokale model. Een beheerder kan hier bewust een ander reeds aanwezig, alleen-lezen modelbestand kiezen.</p>
</div>
<span className={selectedModelAsset ? 'status-badge status-badge-ready' : 'status-badge'}>
{selectedModelAsset ? 'model gekozen' : 'geen model gekozen'}
</span>
</div>
<div className="model-asset-guidance">
<strong>Gevalideerde profielen</strong>
<p>
Een profiel koppelt een lokaal model aan een gemeten zekerheidsdrempel. Een andere keuze geldt alleen voor de huidige analyse en wijzigt de serverconfiguratie niet.
</p>
</div>
<div className="operator-profile-grid" aria-label="Configured YOLO operator profiles">
{DETECTION_OPERATOR_PROFILES.map((profile) => {
const profileAsset = modelAssets.find((asset) => asset.model_asset_id === profile.modelAssetId)
const profileSelected =
selectedModelAssetId === profile.modelAssetId &&
Math.abs(detectionConfidenceThreshold - profile.confidenceThreshold) < 0.0001
return (
<div
className={profileSelected ? 'operator-profile-card operator-profile-card-selected' : 'operator-profile-card'}
key={profile.id}
>
<div className="operator-profile-card-header">
<strong>{profile.displayName}</strong>
<span className={profile.defaultApproved ? 'status-badge status-badge-ready' : 'status-badge'}>
{profile.defaultApproved ? 'standaardprofiel' : 'kandidaat · extra controle vereist'}
</span>
</div>
<p>{profile.description}</p>
<div className="operator-profile-metrics">
<span>drempel {profile.confidenceThreshold.toFixed(2)}</span>
<span>precision {profile.precision.toFixed(3)}</span>
<span>recall {profile.recall.toFixed(3)}</span>
<span>F1 {profile.f1.toFixed(3)}</span>
<span>testgebieden {profile.positiveSampleCount}</span>
<span>max. achtergrondfouten {profile.maxBackgroundDetections}</span>
</div>
<div className="entity-meta">
<span>modelbestand: {profile.modelAssetId}</span>
<span>beoordeling: {profile.promotionRecommendation}</span>
<span>beschikbaar: {profileAsset ? 'ja' : 'niet gekoppeld'}</span>
</div>
<p className="field-guidance">{profile.limitationMessage}</p>
<button
className="secondary-action"
type="button"
onClick={() => onApplyOperatorProfile(profile)}
disabled={!profileAsset}
>
Profiel gebruiken
</button>
</div>
)
})}
</div>
{selectedModelAsset ? (
<div className="model-asset-guidance">
<strong>Status gekozen model</strong>
<p>
{selectedModelAsset.display_name} wordt voor deze analyse gebruikt. De standaard serverconfiguratie blijft ongewijzigd.
</p>
</div>
) : null}
<label>
Lokaal modelbestand
<select value={selectedModelAssetId} onChange={(event) => onSelectModelAsset(event.target.value)}>
<option value="">Kies een lokaal modelbestand</option>
{modelAssets.map((asset) => (
<option key={asset.model_asset_id} value={asset.model_asset_id}>
{asset.display_name} {asset.active ? '(active)' : ''}
</option>
))}
</select>
</label>
{modelAssets.length === 0 && !loadingDetectionModels ? (
<div className="result-state result-state-empty">
<strong>Geen lokaal modelbestand gevonden.</strong>
<p>Plaats een gecontroleerd model in de modelmap of configureer het bestaande YOLO-modelpad.</p>
</div>
) : null}
{selectedModelAsset ? (
<div className="result-summary-card">
<p>File: {selectedModelAsset.filename}</p>
<p>Status: {selectedModelAsset.status}</p>
<p>Active runtime env model: {selectedModelAsset.active ? 'yes' : 'no'}</p>
<p>will_download_models: {selectedModelAsset.will_download_models ? 'yes' : 'no'}</p>
<p>Size: {formatModelAssetSize(selectedModelAsset.size_bytes)}</p>
<p>SHA-256: {selectedModelAsset.sha256.slice(0, 12)}</p>
<p>Path: {selectedModelAsset.model_path}</p>
<p>{selectedModelAsset.limitation_message}</p>
</div>
) : null}
</div>
</details>
) : null}
<details className="ai-lab-model-surface" aria-label="YOLO runtime preflight">
<summary>
<span>Technische runtimecontrole</span>
<strong>{yoloRuntimeReady ? 'gereed' : yoloPreflight?.status ?? 'niet geladen'}</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-section-header">
<div>
<h3>YOLO runtime preflight</h3>
<p>Read-only runtime status. This does not load a model, run inference or download weights.</p>
</div>
<button className="secondary-action" type="button" onClick={onRefreshYoloPreflight} disabled={loadingYoloPreflight}>
Refresh preflight
</button>
</div>
<div className="ai-lab-state-stack">
{loadingYoloPreflight ? (
<div className="result-state result-state-loading">
<strong>Loading YOLO preflight.</strong>
<p>Checking backend runtime configuration and optional dependency visibility.</p>
</div>
) : null}
{yoloPreflightError ? (
<div className="result-state result-state-error">
<strong>YOLO preflight unavailable.</strong>
<p>{yoloPreflightError}</p>
</div>
) : null}
{!yoloPreflight && !loadingYoloPreflight && !yoloPreflightError ? (
<div className="result-state result-state-empty">
<strong>No YOLO preflight loaded.</strong>
<p>Refresh preflight to inspect the live backend AI runtime before running configured YOLO.</p>
</div>
) : null}
</div>
{yoloPreflight ? (
<div className={yoloPreflight.status === 'ready' ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}>
<div className="ai-lab-section-header">
<div>
<h3>Status: {yoloPreflight.status}</h3>
<p>{yoloPreflight.message}</p>
</div>
<span className={yoloPreflight.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>
{yoloPreflight.checks.dependencies_available ? 'dependencies visible' : 'not ready'}
</span>
</div>
<div className="lab-readiness-grid">
<div className={yoloPreflight.checks.enabled ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>YOLO enabled</span>
<strong>{yoloPreflight.checks.enabled ? 'true' : 'false'}</strong>
</div>
<div className={yoloPreflight.checks.dependencies_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Dependencies</span>
<strong>{yoloPreflight.checks.dependencies_available === true ? 'available' : yoloPreflight.checks.dependencies_available === false ? 'unavailable' : 'not checked'}</strong>
</div>
<div className={yoloPreflight.checks.model_file_exists ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Local model file</span>
<strong>{yoloPreflight.checks.model_file_exists === true ? 'found' : yoloPreflight.checks.model_path_set ? 'missing' : 'not configured'}</strong>
</div>
<div className={yoloPreflight.runtime.cuda_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>CUDA</span>
<strong>{yoloPreflight.runtime.cuda_available === true ? 'available' : yoloPreflight.runtime.cuda_available === false ? 'not available' : 'not checked'}</strong>
</div>
<div className={yoloPreflight.checks.manifest_valid ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Tile manifest validation</span>
<strong>{yoloPreflight.checks.manifest_valid === true ? 'valid' : yoloPreflight.checks.manifest_path_set ? 'not valid' : 'not provided'}</strong>
</div>
<div className={yoloPreflight.tile_count > 0 ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Tile count</span>
<strong>{yoloPreflight.tile_count} / {yoloPreflight.max_tiles}</strong>
</div>
</div>
<div className="entity-meta">
<span>torch_version: {yoloPreflight.runtime.torch_version ?? 'n/a'}</span>
<span>ultralytics_version: {yoloPreflight.runtime.ultralytics_version ?? 'n/a'}</span>
<span>cuda_available: {String(yoloPreflight.runtime.cuda_available ?? 'unknown')}</span>
<span>will_run_inference: {String(yoloPreflight.will_run_inference)}</span>
<span>YOLO_CONFIG_DIR: {yoloPreflight.runtime.yolo_config_dir ?? 'n/a'}</span>
<span>model directory: {yoloPreflight.runtime.model_directory ?? 'n/a'}</span>
<span>model_asset_id: {yoloPreflight.model_asset_id ?? 'n/a'}</span>
</div>
</div>
) : null}
</div>
</details>
<div className="lab-block">
<div className="ai-lab-run-surface" aria-label="Detection run controls">
<h3>Nieuwe beeldanalyse</h3>
<div
className={guidedDetectionReady ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}
aria-label="Detection run readiness"
>
<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}
<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>
<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>
<label>
Analysemodel
<select value={selectedDetectionModelId} onChange={(event) => onSelectModel(event.target.value)}>
{detectionModels.map((model) => (
<option key={model.model_id} value={model.model_id}>
{detectionModelLabel(model)}
</option>
))}
</select>
</label>
<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 || !guidedDetectionReady}>
{detectionWorkflowActionLabel(detectionWorkflowStage)}
</button>
<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 || !detectionRunReady}>
Bestaande beeldtegels analyseren
</button>
</div>
</details>
</div>
</div>
<div className="ai-lab-state-stack">
{detectionRunError ? (
<div className="result-state result-state-error">
<strong>De beeldanalyse is mislukt.</strong>
<p>{detectionRunError}</p>
</div>
) : null}
{detectionRunResult ? (
<div className="result-summary-card">
<p>Status: {detectionRunResult.status}</p>
<p>Uitleg: {detectionRunResult.message}</p>
<p>Analyse: {detectionRunResult.analysis_run_id}</p>
<p>Verwerking: {detectionRunResult.job_id}</p>
<p>Gevonden objecten: {detectionRunResult.detection_count}</p>
{detectionRunResult.error_code ? <p className="error">Code: {detectionRunResult.error_code}</p> : null}
</div>
) : null}
</div>
<details className="ai-lab-model-surface guided-calibration-surface" aria-label="Guided calibration runner">
<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>Guided calibration runner</h3>
<p>This runs real configured YOLO jobs and QA comparisons for each threshold. It does not promote or mutate model files.</p>
</div>
<span className={calibrationRunReady ? 'status-badge status-badge-ready' : 'status-badge'}>
{calibrationRunReady ? 'ready' : 'needs dataset, model, manifest and reference'}
</span>
</div>
<div className="lab-form-grid">
<label>
Threshold set
<input
type="text"
value={calibrationThresholdText}
onChange={(event) => onSetCalibrationThresholdText(event.target.value)}
placeholder="0.50 0.25 0.15"
/>
<span className="field-guidance">Use spaces, commas or semicolons. Values must be between 0 and 1.</span>
</label>
<label>
Reference dataset
<select value={detectionReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
<option value="">Select reference dataset</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 || !calibrationRunReady}
>
Run calibration sweep
</button>
{detectionCalibrationError ? (
<div className="result-state result-state-error">
<strong>Calibration sweep failed.</strong>
<p>{detectionCalibrationError}</p>
</div>
) : null}
{detectionCalibrationRows.length > 0 ? (
<div className="calibration-progress-panel" aria-label="Calibration run progress">
<div className="panel-title-row">
<div>
<h3>Calibration run progress</h3>
<p className="muted">Each row is backed by a persisted detection run and QA check when successful.</p>
</div>
<div className="panel-action-row">
<span className="count-pill">{detectionCalibrationRows.length} thresholds</span>
<button
className="secondary-action"
type="button"
onClick={() => downloadCalibrationSummary(selectedProjectId, detectionCalibrationRows)}
disabled={detectionCalibrationRows.length === 0 || !selectedProjectId}
>
Download calibration summary
</button>
</div>
</div>
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Threshold</th>
<th>Status</th>
<th>Detections</th>
<th>Precision</th>
<th>Recall</th>
<th>F1</th>
<th>False positives</th>
<th>False negatives</th>
<th>Evidence</th>
</tr>
</thead>
<tbody>
{detectionCalibrationRows.map((row) => (
<tr key={row.threshold}>
<td>{row.threshold.toFixed(2)}</td>
<td>{row.status}</td>
<td>{row.detection_count ?? 'n/a'}</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/a'}</td>
<td>{row.false_negatives ?? 'n/a'}</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 evidence map for threshold ${row.threshold.toFixed(2)}`}
>
Open evidence map
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className="result-state result-state-empty">
<strong>No calibration sweep has been run in this session.</strong>
<p>Choose a reference dataset and threshold set, then start the explicit sweep.</p>
</div>
)}
</div>
</details>
<div className="ai-lab-results-surface" aria-label="Detection results">
<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}>
{run.model_name || 'Gebouwdetectie'} · {run.status} · {run.id}
</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="Detection result pagination">
<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>Beeldtegel</th>
</tr>
</thead>
<tbody>
{visibleDetectionItems.map((detection) => (
<tr key={detection.id}>
<td>{detection.class_name}</td>
<td>{detection.confidence.toFixed(2)}</td>
<td>{detection.model_name}</td>
<td className="source-tile-cell" title={detection.source_tile_path ?? undefined}>
{formatSourceTilePath(detection.source_tile_path)}
</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="Calibration comparison winners">
<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>Recall</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/a'}</td>
<td>{formatNullableNumber(row.precision, 3)}</td>
<td>{formatNullableNumber(row.recall, 3)}</td>
<td>{formatNullableNumber(row.f1, 3)}</td>
<td>{row.falsePositives ?? 'n/a'}</td>
<td>{row.falseNegatives ?? 'n/a'}</td>
<td>{row.qualityCheckId}</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: {detectionQaResult.status}</p>
<p>Kwaliteitscontrole: {detectionQaResult.quality_check_id}</p>
<p>Precisie: {detectionQaResult.precision?.toFixed(3) ?? 'n.v.t.'}</p>
<p>Recall: {detectionQaResult.recall?.toFixed(3) ?? 'n/a'}</p>
<p>F1: {detectionQaResult.f1_score?.toFixed(3) ?? 'n/a'}</p>
<p>Gemiddelde overlap: {detectionQaResult.mean_iou?.toFixed(3) ?? 'n.v.t.'}</p>
<p>Fout positief: {detectionQaResult.false_positives}</p>
<p>Fout negatief: {detectionQaResult.false_negatives}</p>
{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, recall en F1 hierboven blijven gebaseerd op de strikte geometrische overlap.
</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/a' : formatNullableNumber(row[metric], 3)}</strong>
<p>Threshold {formatNullableNumber(row.threshold, 2)} · {row.modelName}</p>
</>
) : (
<>
<strong>n/a</strong>
<p>Persisted QA metrics are required.</p>
</>
)}
</div>
)
}
function formatModelAssetSize(sizeBytes: number): string {
if (sizeBytes >= 1024 * 1024) {
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`
}
if (sizeBytes >= 1024) {
return `${(sizeBytes / 1024).toFixed(1)} KB`
}
return `${sizeBytes} B`
}
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): string {
if (stage === 'tiling') return 'Beeldtegels voorbereiden...'
if (stage === 'validating') return 'Model en beeld controleren...'
if (stage === 'detecting') return 'Gebouwen zoeken...'
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 ?? 'configured detection',
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/a'
}
function formatSourceTilePath(path: string | null | undefined): string {
if (!path) {
return 'n/a'
}
const parts = path.replace(/\\/g, '/').split('/').filter(Boolean)
return parts[parts.length - 1] ?? path
}