Close full operational audit findings
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-17 14:19:02 +02:00
parent cc36dabcbe
commit 78a5f0b0b2
81 changed files with 2248 additions and 1467 deletions
@@ -12,17 +12,11 @@ import type {
} from '../../types'
import type { DetectionCalibrationRunRow, DetectionWorkflowStage } from '../../hooks/useDetectionWorkflow'
import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles'
import { DetectionModelManagement, detectionModelLabel } from './DetectionModelManagement'
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
}
function detectionQualityInterpretation(f1: number | null | undefined): string {
if (typeof f1 !== 'number' || !Number.isFinite(f1)) return 'Nog geen gevalideerde kwaliteitsmeting.'
if (f1 >= 0.85) return 'Sterk resultaat; steekproefcontrole blijft vereist.'
@@ -31,6 +25,18 @@ function detectionQualityInterpretation(f1: number | null | undefined): string {
return 'Onvoldoende betrouwbaar voor operationeel gebruik.'
}
function formatDetectionRunLabel(run: DetectionRunRead): string {
const status = run.status === 'completed' ? 'afgerond' : run.status
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 `${run.model_name || 'Gebouwdetectie'} · ${status} · ${dateLabel}`
}
interface CalibrationRow {
analysisRunId: string
qualityCheckId: string
@@ -284,255 +290,29 @@ export function DetectionLab({
{detectionQualityInterpretation(selectedOperatorProfile?.f1)}
</p>
<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>
<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}
/>
<div className="lab-block">
<div className="ai-lab-run-surface" aria-label="Detection run controls">
<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="Detection run readiness"
aria-label="Startklaar voor gebouwdetectie"
>
<div className="ai-lab-section-header">
<div>
@@ -720,17 +500,22 @@ export function DetectionLab({
) : 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>Status: {detectionRunResult.status === 'completed' ? 'afgerond' : 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>
<details className="ai-lab-model-surface guided-calibration-surface" aria-label="Guided calibration runner">
<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>
@@ -738,28 +523,28 @@ export function DetectionLab({
<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>
<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 ? 'ready' : 'needs dataset, model, manifest and reference'}
{calibrationRunReady ? 'startklaar' : 'luchtbeeld, model, beeldtegels en referentie vereist'}
</span>
</div>
<div className="lab-form-grid">
<label>
Threshold set
Zekerheidsdrempels
<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>
<span className="field-guidance">Scheid waarden met spaties, komma's of puntkomma's. Iedere waarde ligt tussen 0 en 1.</span>
</label>
<label>
Reference dataset
Referentielaag
<select value={detectionReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
<option value="">Select reference dataset</option>
<option value="">Kies een referentielaag</option>
{referenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
@@ -774,30 +559,30 @@ export function DetectionLab({
onClick={onRunCalibration}
disabled={runningDetectionCalibration || !calibrationRunReady}
>
Run calibration sweep
Drempels vergelijken
</button>
{detectionCalibrationError ? (
<div className="result-state result-state-error">
<strong>Calibration sweep failed.</strong>
<strong>De kalibratievergelijking is mislukt.</strong>
<p>{detectionCalibrationError}</p>
</div>
) : null}
{detectionCalibrationRows.length > 0 ? (
<div className="calibration-progress-panel" aria-label="Calibration run progress">
<div className="calibration-progress-panel" aria-label="Voortgang modelkalibratie">
<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>
<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} thresholds</span>
<span className="count-pill">{detectionCalibrationRows.length} drempels</span>
<button
className="secondary-action"
type="button"
onClick={() => downloadCalibrationSummary(selectedProjectId, detectionCalibrationRows)}
disabled={detectionCalibrationRows.length === 0 || !selectedProjectId}
>
Download calibration summary
Samenvatting downloaden
</button>
</div>
</div>
@@ -805,15 +590,15 @@ export function DetectionLab({
<table>
<thead>
<tr>
<th>Threshold</th>
<th>Drempel</th>
<th>Status</th>
<th>Detections</th>
<th>Precision</th>
<th>Recall</th>
<th>Objecten</th>
<th>Precisie</th>
<th>Herkenningsgraad</th>
<th>F1</th>
<th>False positives</th>
<th>False negatives</th>
<th>Evidence</th>
<th>Onterecht gevonden</th>
<th>Gemist</th>
<th>Kaartbewijs</th>
</tr>
</thead>
<tbody>
@@ -821,21 +606,21 @@ export function DetectionLab({
<tr key={row.threshold}>
<td>{row.threshold.toFixed(2)}</td>
<td>{row.status}</td>
<td>{row.detection_count ?? 'n/a'}</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/a'}</td>
<td>{row.false_negatives ?? 'n/a'}</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 evidence map for threshold ${row.threshold.toFixed(2)}`}
aria-label={`Open kaartbewijs voor drempel ${row.threshold.toFixed(2)}`}
>
Open evidence map
Toon op kaart
</button>
</td>
</tr>
@@ -846,14 +631,14 @@ export function DetectionLab({
</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>
<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>
<div className="ai-lab-results-surface" aria-label="Detection results">
<div className="ai-lab-results-surface" aria-label="Resultaten van de beeldanalyse">
<div className="panel-title-row">
<div>
<h3>Gevonden objecten</h3>
@@ -875,7 +660,7 @@ export function DetectionLab({
<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}
{formatDetectionRunLabel(run)}
</option>
))}
</select>
@@ -918,7 +703,7 @@ export function DetectionLab({
</div>
{detectionItems.length > 0 ? (
<>
<div className="pagination-toolbar" aria-label="Detection result pagination">
<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>
@@ -1005,7 +790,7 @@ export function DetectionLab({
</div>
{calibrationRows.length > 0 ? (
<>
<div className="calibration-summary-grid" aria-label="Calibration comparison winners">
<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" />
@@ -1018,7 +803,7 @@ export function DetectionLab({
<th>Model</th>
<th>Objecten</th>
<th>Precisie</th>
<th>Recall</th>
<th>Herkenningsgraad</th>
<th>F1</th>
<th>Fout positief</th>
<th>Fout negatief</th>
@@ -1033,13 +818,17 @@ export function DetectionLab({
<strong>{row.modelName}</strong>
<span className="table-subtle">{row.modelAssetId ?? 'geconfigureerd lokaal model'}</span>
</td>
<td>{row.detectionCount ?? 'n/a'}</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/a'}</td>
<td>{row.falseNegatives ?? 'n/a'}</td>
<td>{row.qualityCheckId}</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>
@@ -1085,15 +874,18 @@ export function DetectionLab({
) : null}
{detectionQaResult ? (
<div className="result-summary-card">
<p>Status: {detectionQaResult.status}</p>
<p>Kwaliteitscontrole: {detectionQaResult.quality_check_id}</p>
<p>Status: {detectionQaResult.status === 'completed' ? 'afgerond' : detectionQaResult.status}</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>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>
@@ -1117,7 +909,7 @@ export function DetectionLab({
{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.
{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>
</div>
) : null}
@@ -1142,12 +934,12 @@ function CalibrationSummaryCard({
<span>{title}</span>
{row ? (
<>
<strong>{metric === 'falsePositives' ? row.falsePositives ?? 'n/a' : formatNullableNumber(row[metric], 3)}</strong>
<strong>{metric === 'falsePositives' ? row.falsePositives ?? 'n.v.t.' : formatNullableNumber(row[metric], 3)}</strong>
<p>Threshold {formatNullableNumber(row.threshold, 2)} · {row.modelName}</p>
</>
) : (
<>
<strong>n/a</strong>
<strong>n.v.t.</strong>
<p>Persisted QA metrics are required.</p>
</>
)}
@@ -1155,16 +947,6 @@ function CalibrationSummaryCard({
)
}
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,
@@ -1209,7 +991,7 @@ function buildCalibrationRows(detectionRuns: DetectionRunRead[], qualityChecks:
analysisRunId: run.id,
qualityCheckId: check.id,
threshold,
modelName: run.model_name ?? 'configured detection',
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'),
@@ -1335,12 +1117,12 @@ function downloadJsonFile(filename: string, payload: unknown): void {
}
function formatNullableNumber(value: number | null, digits: number): string {
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(digits) : 'n/a'
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(digits) : 'n.v.t.'
}
function formatSourceTilePath(path: string | null | undefined): string {
if (!path) {
return 'n/a'
return 'n.v.t.'
}
const parts = path.replace(/\\/g, '/').split('/').filter(Boolean)
return parts[parts.length - 1] ?? path
@@ -0,0 +1,297 @@
import type {
DetectionModelCapability,
ModelAssetRead,
YoloPreflightResponse,
} from '../../types'
import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles'
interface DetectionModelManagementProps {
detectionModels: DetectionModelCapability[]
modelAssets: ModelAssetRead[]
loadingDetectionModels: boolean
detectionModelError: string | null
modelAssetError: string | null
selectedDetectionModelId: string
selectedModelAssetId: string
detectionConfidenceThreshold: number
yoloPreflight: YoloPreflightResponse | null
loadingYoloPreflight: boolean
yoloPreflightError: string | null
onRefreshYoloPreflight: () => void
onSelectModelAsset: (modelAssetId: string) => void
onApplyOperatorProfile: (profile: DetectionOperatorProfile) => void
}
export 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
}
function formatModelAssetSize(sizeBytes: number): string {
if (!Number.isFinite(sizeBytes) || sizeBytes <= 0) return 'n.v.t.'
const megabytes = sizeBytes / (1024 * 1024)
return `${megabytes.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} MB`
}
function statusLabel(value: string): string {
if (value === 'configured' || value === 'ready') return 'gereed'
if (value === 'not_configured') return 'niet geconfigureerd'
if (value === 'dependency_unavailable') return 'software ontbreekt'
return value.replace(/_/g, ' ')
}
export function DetectionModelManagement({
detectionModels,
modelAssets,
loadingDetectionModels,
detectionModelError,
modelAssetError,
selectedDetectionModelId,
selectedModelAssetId,
detectionConfidenceThreshold,
yoloPreflight,
loadingYoloPreflight,
yoloPreflightError,
onRefreshYoloPreflight,
onSelectModelAsset,
onApplyOperatorProfile,
}: DetectionModelManagementProps): JSX.Element {
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,
)
return (
<div className="detection-model-management">
<details className="ai-lab-model-surface" aria-label="Technische modelmogelijkheden">
<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>Analysemodellen worden gecontroleerd.</strong>
<p>GeoIntel leest de modelregistratie en lokale bestanden.</p>
</div>
) : null}
{detectionModelError ? (
<div className="result-state result-state-error">
<strong>De modelregistratie is niet bereikbaar.</strong>
<p>{detectionModelError}</p>
</div>
) : null}
{modelAssetError ? (
<div className="result-state result-state-error">
<strong>De lokale modelbestanden konden niet worden gelezen.</strong>
<p>{modelAssetError}</p>
</div>
) : null}
{detectionModels.length === 0 && !loadingDetectionModels ? (
<div className="result-state result-state-empty">
<strong>De backend meldt geen analysemodellen.</strong>
<p>Vernieuw de status zodra de backend opnieuw bereikbaar is.</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'}>
{statusLabel(model.status)}
</span>
<p className="muted">Ondersteunde klassen: {model.supported_classes.join(', ') || 'niet opgegeven'}</p>
<p className="muted">{model.limitation_message}</p>
<details className="technical-inline-details">
<summary>Technische identificatie</summary>
<div className="entity-meta">
<span>Model-ID: {model.model_id}</span>
<span>Framework: {model.framework}</span>
<span>Taaktype: {model.task_type}</span>
</div>
</details>
</li>
))}
</ul>
</div>
</details>
{selectedDetectionModelId === 'yolo-configured' ? (
<details className="ai-lab-model-surface" aria-label="Lokale modelkeuze">
<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 andere keuze geldt alleen voor deze analyse.</p>
</div>
<span className={selectedModelAsset ? 'status-badge status-badge-ready' : 'status-badge'}>
{selectedModelAsset ? 'model gekozen' : 'geen model gekozen'}
</span>
</div>
<div className="operator-profile-grid" aria-label="Gevalideerde YOLO-profielen">
{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>precisie {profile.precision.toFixed(3)}</span>
<span>herkenningsgraad {profile.recall.toFixed(3)}</span>
<span>F1 {profile.f1.toFixed(3)}</span>
<span>testgebieden {profile.positiveSampleCount}</span>
<span>max. achtergrondfouten {profile.maxBackgroundDetections}</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>
<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 ? '(actief)' : ''}
</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 ? (
<details className="technical-inline-details">
<summary>Bestands- en integriteitsgegevens</summary>
<div className="result-summary-card">
<p>Bestand: {selectedModelAsset.filename}</p>
<p>Status: {statusLabel(selectedModelAsset.status)}</p>
<p>Actief servermodel: {selectedModelAsset.active ? 'ja' : 'nee'}</p>
<p>Automatisch downloaden: {selectedModelAsset.will_download_models ? 'ja' : 'nee'}</p>
<p>Grootte: {formatModelAssetSize(selectedModelAsset.size_bytes)}</p>
<p>SHA-256: {selectedModelAsset.sha256.slice(0, 12)}</p>
<p>Pad: {selectedModelAsset.model_path}</p>
</div>
</details>
) : null}
</div>
</details>
) : null}
<details className="ai-lab-model-surface" aria-label="Technische YOLO-runtimecontrole">
<summary>
<span>Technische runtimecontrole</span>
<strong>{yoloRuntimeReady ? 'gereed' : statusLabel(yoloPreflight?.status ?? 'niet geladen')}</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-section-header">
<div>
<h3>YOLO-runtimecontrole</h3>
<p>Deze alleen-lezen controle start geen analyse en downloadt geen modelbestanden.</p>
</div>
<button className="secondary-action" type="button" onClick={onRefreshYoloPreflight} disabled={loadingYoloPreflight}>
Controle vernieuwen
</button>
</div>
<div className="ai-lab-state-stack">
{loadingYoloPreflight ? (
<div className="result-state result-state-loading">
<strong>De YOLO-runtime wordt gecontroleerd.</strong>
<p>GeoIntel controleert configuratie, optionele software en lokale bestanden.</p>
</div>
) : null}
{yoloPreflightError ? (
<div className="result-state result-state-error">
<strong>De YOLO-runtimecontrole is niet beschikbaar.</strong>
<p>{yoloPreflightError}</p>
</div>
) : null}
{!yoloPreflight && !loadingYoloPreflight && !yoloPreflightError ? (
<div className="result-state result-state-empty">
<strong>Nog geen runtimecontrole geladen.</strong>
<p>Vernieuw de controle voordat je een lokaal model gebruikt.</p>
</div>
) : null}
</div>
{yoloPreflight ? (
<div className={yoloPreflight.status === 'ready' ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}>
<div className="lab-readiness-grid">
<div className={yoloPreflight.checks.enabled ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>YOLO ingeschakeld</span>
<strong>{yoloPreflight.checks.enabled ? 'ja' : 'nee'}</strong>
</div>
<div className={yoloPreflight.checks.dependencies_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Benodigde software</span>
<strong>{yoloPreflight.checks.dependencies_available === true ? 'beschikbaar' : yoloPreflight.checks.dependencies_available === false ? 'ontbreekt' : 'niet gecontroleerd'}</strong>
</div>
<div className={yoloPreflight.checks.model_file_exists ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Lokaal modelbestand</span>
<strong>{yoloPreflight.checks.model_file_exists === true ? 'gevonden' : yoloPreflight.checks.model_path_set ? 'ontbreekt' : 'niet geconfigureerd'}</strong>
</div>
<div className={yoloPreflight.runtime.cuda_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>GPU-versnelling</span>
<strong>{yoloPreflight.runtime.cuda_available === true ? 'beschikbaar' : yoloPreflight.runtime.cuda_available === false ? 'niet beschikbaar' : 'niet gecontroleerd'}</strong>
</div>
<div className={yoloPreflight.checks.manifest_valid ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Beeldtegels</span>
<strong>{yoloPreflight.checks.manifest_valid === true ? 'geldig' : yoloPreflight.checks.manifest_path_set ? 'ongeldig' : 'niet opgegeven'}</strong>
</div>
<div className={yoloPreflight.tile_count > 0 ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Aantal beeldtegels</span>
<strong>{yoloPreflight.tile_count} / {yoloPreflight.max_tiles}</strong>
</div>
</div>
<details className="technical-inline-details">
<summary>Versies en serverpaden</summary>
<div className="entity-meta">
<span>PyTorch: {yoloPreflight.runtime.torch_version ?? 'n.v.t.'}</span>
<span>Ultralytics: {yoloPreflight.runtime.ultralytics_version ?? 'n.v.t.'}</span>
<span>YOLO-configuratiemap: {yoloPreflight.runtime.yolo_config_dir ?? 'n.v.t.'}</span>
<span>Modelmap: {yoloPreflight.runtime.model_directory ?? 'n.v.t.'}</span>
<span>Model-ID: {yoloPreflight.model_asset_id ?? 'n.v.t.'}</span>
</div>
</details>
</div>
) : null}
</div>
</details>
</div>
)
}