feat: guide raster building analysis workflow
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 00:19:38 +02:00
parent d528677e03
commit 2f9898bc82
12 changed files with 598 additions and 64 deletions
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import type {
DatasetCreateResponse,
DetectionModelCapability,
@@ -10,7 +10,7 @@ import type {
QualityCheckRead,
YoloPreflightResponse,
} from '../../types'
import type { DetectionCalibrationRunRow } from '../../hooks/useDetectionWorkflow'
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
@@ -59,6 +59,7 @@ interface DetectionLabProps {
runningDetectionCalibration: boolean
detectionCalibrationRows: DetectionCalibrationRunRow[]
detectionCalibrationError: string | null
detectionWorkflowStage: DetectionWorkflowStage
selectedDetectionRunId: string
detectionItems: DetectionRead[]
detectionClassFilter: string
@@ -82,6 +83,9 @@ interface DetectionLabProps {
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
@@ -115,6 +119,7 @@ export function DetectionLab({
runningDetectionCalibration,
detectionCalibrationRows,
detectionCalibrationError,
detectionWorkflowStage,
selectedDetectionRunId,
detectionItems,
detectionClassFilter,
@@ -138,6 +143,9 @@ export function DetectionLab({
onSetConfidenceThreshold,
onSetTileManifestPath,
onRunDetection,
onUploadRaster,
onPrepareAndRunDetection,
onOpenResultsOnMap,
onLoadRuns,
onSelectRun,
onSetClassFilter,
@@ -173,6 +181,8 @@ export function DetectionLab({
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
@@ -192,6 +202,12 @@ export function DetectionLab({
detectionModelUiRunnable &&
detectionHasExplicitModelAsset &&
detectionHasTileManifest
const guidedDetectionReady =
Boolean(selectedProjectId) &&
detectionHasDataset &&
detectionHasModel &&
detectionModelUiRunnable &&
detectionHasExplicitModelAsset
const detectionRunBlockedReason = !selectedProjectId
? 'De regionale werkruimte is nog niet geladen'
: !detectionHasDataset
@@ -208,6 +224,19 @@ export function DetectionLab({
? '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">
@@ -491,16 +520,16 @@ export function DetectionLab({
<div className="ai-lab-run-surface" aria-label="Detection run controls">
<h3>Nieuwe beeldanalyse</h3>
<div
className={detectionRunReady ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}
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>De analyse start zodra een luchtbeeld en de bijbehorende beeldtegels beschikbaar zijn.</p>
<p>Kies een luchtbeeld en model. GeoIntel maakt de beeldtegels en laadt het resultaat daarna automatisch op de kaart.</p>
</div>
<span className={detectionRunReady ? 'status-badge status-badge-ready' : 'status-badge'}>
{detectionRunReady ? 'Klaar om te starten' : 'Nog niet startklaar'}
<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">
@@ -522,7 +551,9 @@ export function DetectionLab({
{detectionRequiresTileManifest
? detectionHasTileManifest
? 'Beschikbaar'
: 'Maak eerst tegels vanuit het luchtbeeld'
: detectionHasDataset
? 'Worden automatisch voorbereid'
: 'Wachten op een luchtbeeld'
: 'Niet vereist'}
</strong>
</div>
@@ -540,20 +571,57 @@ export function DetectionLab({
</div>
</div>
</div>
<div className={detectionRunReady ? 'lab-action-guardrail lab-action-guardrail-ready' : 'lab-action-guardrail'}>
<div className={guidedDetectionReady ? 'lab-action-guardrail lab-action-guardrail-ready' : 'lab-action-guardrail'}>
<span>Analyse</span>
<strong>{detectionRunReady ? 'Klaar om gebouwen te zoeken' : detectionRunBlockedReason}</strong>
<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 onder Bronnen een gegeorefereerde GeoTIFF toe. Daarna kan GeoIntel er beeldtegels en een detectierun van maken.</p>
<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)}>
<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}>
@@ -589,27 +657,46 @@ export function DetectionLab({
) : null}
</label>
</div>
{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="primary-action" type="button" onClick={onRunDetection} disabled={runningDetection || !detectionRunReady}>
Zoek gebouwen
<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>
@@ -761,9 +848,14 @@ export function DetectionLab({
<h3>Gevonden objecten</h3>
<p className="muted">Bekijk eerder bewaarde analyses en filter op type of zekerheid.</p>
</div>
<button className="secondary-action" type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
Analyses vernieuwen
</button>
<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>
@@ -954,9 +1046,12 @@ export function DetectionLab({
</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)}>
@@ -989,36 +1084,34 @@ export function DetectionLab({
<p>Fout negatief: {detectionQaResult.false_negatives}</p>
{detectionQaResult.coverage ? (
<div className="detection-qa-diagnostic">
<span>Inference coverage</span>
<span>Gecontroleerd beeldbereik</span>
<strong>
{detectionQaResult.coverage.applied
? `${detectionQaResult.coverage.reference_evaluated_count} of ${detectionQaResult.coverage.reference_raw_count} reference features evaluated`
: 'No tile manifest 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} outside coverage, ${detectionQaResult.coverage.reference_clipped_boundary_count} clipped at the boundary, ${detectionQaResult.coverage.tile_count} ${detectionQaResult.coverage.tile_count === 1 ? 'tile' : 'tiles'}.`
: 'This run uses the complete selected reference population.'}
? `${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>Box-to-footprint diagnostic only</span>
<span>Aanvullende vormdiagnose</span>
<strong>
{detectionQaResult.box_to_footprint_diagnostics.envelope_matches} envelope matches versus{' '}
{detectionQaResult.box_to_footprint_diagnostics.strict_matches} canonical matches
{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} possible matching artifacts. Canonical precision, recall and F1 above remain footprint-IoU based.
{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>
</div>
</details>
</section>
)
}
@@ -1060,6 +1153,37 @@ function formatModelAssetSize(sizeBytes: number): string {
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