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
+12
View File
@@ -85,6 +85,18 @@ Detection Lab and Segmentation Lab now share the same AI workspace hierarchy: mo
AI Lab run controls explicitly explain when no raster dataset is available, instead of only showing disabled detection/segmentation run buttons.
Detection Lab now provides one guided operational path for configured building detection:
1. choose an existing raster or explicitly upload a georeferenced GeoTIFF;
2. create canonical 512 px tiles with 64 px overlap through the existing raster API;
3. run the read-only YOLO preflight for the selected local model asset;
4. execute the existing persisted detection endpoint;
5. load the persisted Detection rows and GeoJSON and open them on the existing MapLibre map.
The browser never manufactures manifest content, detections or QA metrics. Manual manifest paths and direct-manifest execution remain available only under technical tile settings. Detection QA remains the existing persisted reference comparison and is shown as a primary review step.
Before creating tiles, the guided action inspects raster dimensions and estimates the number of 512/64 tiles against the backend-reported `YOLO_MAX_TILES`. Oversized imagery is stopped before tile files are written and must first be clipped to the intended work area. Repeated runs reuse the currently linked manifest.
## Scope implemented
- API client layer (`src/services/api`)
- Project and area list/create flows
+23
View File
@@ -270,11 +270,14 @@ function App(): JSX.Element {
runningDetectionCalibration,
detectionCalibrationRows,
detectionCalibrationError,
detectionWorkflowStage,
loadDetectionModels,
loadYoloPreflight,
loadDetectionRuns,
loadDetectionResults,
runDetection,
uploadDetectionRaster,
prepareAndRunDetection,
runDetectionQa,
runDetectionCalibration,
applyDetectionOperatorProfile,
@@ -578,6 +581,22 @@ function App(): JSX.Element {
setMapLayerVisible(true)
setActiveWorkspace('map')
}
const runGuidedDetection = async () => {
const completed = await prepareAndRunDetection()
if (completed) {
setMapContentMode('analysis')
setMapLayerVisible(true)
setActiveWorkspace('map')
}
}
const openDetectionResultsOnMap = () => {
if (!detectionGeoJson) {
return
}
setMapContentMode('analysis')
setMapLayerVisible(true)
setActiveWorkspace('map')
}
const openDatasetExport = (dataset: DatasetCreateResponse) => {
if (selectedProjectId) {
loadDatasetDetails(selectedProjectId, dataset)
@@ -1048,6 +1067,7 @@ function App(): JSX.Element {
runningDetectionCalibration={runningDetectionCalibration}
detectionCalibrationRows={detectionCalibrationRows}
detectionCalibrationError={detectionCalibrationError}
detectionWorkflowStage={detectionWorkflowStage}
selectedDetectionRunId={selectedDetectionRunId}
detectionItems={detectionItems}
detectionClassFilter={detectionClassFilter}
@@ -1071,6 +1091,9 @@ function App(): JSX.Element {
onSetConfidenceThreshold={setDetectionConfidenceThreshold}
onSetTileManifestPath={setDetectionTileManifestPath}
onRunDetection={runDetection}
onUploadRaster={uploadDetectionRaster}
onPrepareAndRunDetection={runGuidedDetection}
onOpenResultsOnMap={openDetectionResultsOnMap}
onLoadRuns={() => loadDetectionRuns()}
onSelectRun={setSelectedDetectionRunId}
onSetClassFilter={setDetectionClassFilter}
@@ -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
+165 -15
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { detectionApi } from '../services/api'
import { datasetsApi, detectionApi } from '../services/api'
import type {
DatasetCreateResponse,
DetectionModelCapability,
@@ -7,6 +7,7 @@ import type {
DetectionRead,
DetectionRunRead,
DetectionRunResponse,
JobRead,
ModelAssetRead,
QualityCheckRead,
YoloPreflightResponse,
@@ -26,6 +27,17 @@ interface DetectionOperatorProfileSelection {
confidenceThreshold: number
}
export type DetectionWorkflowStage =
| 'idle'
| 'uploading'
| 'ready'
| 'tiling'
| 'validating'
| 'detecting'
| 'loading'
| 'complete'
| 'failed'
export interface DetectionCalibrationRunRow {
threshold: number
status: 'queued' | 'running' | 'success' | 'failed'
@@ -59,6 +71,21 @@ function parseCalibrationThresholds(value: string): number[] {
return thresholds
}
function tileManifestPathFromJob(job: JobRead): string | null {
const manifestPath = job.result_json?.manifest_path
return typeof manifestPath === 'string' && manifestPath.trim().length > 0 ? manifestPath.trim() : null
}
function rasterTileCount(metadata: Record<string, unknown>, tileSize: number, overlap: number): number | null {
const width = metadata.width
const height = metadata.height
if (typeof width !== 'number' || typeof height !== 'number' || width <= 0 || height <= 0) {
return null
}
const step = tileSize - overlap
return Math.ceil(width / step) * Math.ceil(height / step)
}
export function useDetectionWorkflow({
selectedProjectId,
rasterDatasets,
@@ -97,6 +124,7 @@ export function useDetectionWorkflow({
const [runningDetectionCalibration, setRunningDetectionCalibration] = useState(false)
const [detectionCalibrationRows, setDetectionCalibrationRows] = useState<DetectionCalibrationRunRow[]>([])
const [detectionCalibrationError, setDetectionCalibrationError] = useState<string | null>(null)
const [detectionWorkflowStage, setDetectionWorkflowStage] = useState<DetectionWorkflowStage>('idle')
useEffect(() => {
if (!selectedDetectionDatasetId && rasterDatasets.length > 0) {
@@ -201,6 +229,25 @@ export function useDetectionWorkflow({
}
}
const executeDetection = async (projectId: string, datasetId: string, manifestPath: string | null) => {
const result = await detectionApi.run({
project_id: projectId,
dataset_id: datasetId,
model_id: selectedDetectionModelId,
model_asset_id: selectedModelAssetId || null,
confidence_threshold: detectionConfidenceThreshold,
tile_manifest_path: manifestPath,
parameters_json: {},
})
setDetectionRunResult(result)
setSelectedDetectionRunId(result.analysis_run_id)
setDetectionWorkflowStage('loading')
await loadDetectionRuns(projectId)
await loadDetectionResults(result.analysis_run_id)
await loadProjectData(projectId)
return result
}
const runDetection = async () => {
if (!selectedProjectId) {
setDetectionRunError('Select a project first')
@@ -214,23 +261,122 @@ export function useDetectionWorkflow({
setDetectionRunError(null)
setDetectionRunResult(null)
setRunningDetection(true)
setDetectionWorkflowStage('detecting')
try {
const result = await detectionApi.run({
project_id: selectedProjectId,
dataset_id: datasetId,
model_id: selectedDetectionModelId,
model_asset_id: selectedModelAssetId || null,
confidence_threshold: detectionConfidenceThreshold,
tile_manifest_path: detectionTileManifestPath.trim() || null,
parameters_json: {},
})
setDetectionRunResult(result)
setSelectedDetectionRunId(result.analysis_run_id)
await loadDetectionRuns(selectedProjectId)
await loadDetectionResults(result.analysis_run_id)
await loadProjectData(selectedProjectId)
await executeDetection(selectedProjectId, datasetId, detectionTileManifestPath.trim() || null)
setDetectionWorkflowStage('complete')
} catch (error) {
setDetectionRunError(formatError(error, 'Detection run failed'))
setDetectionWorkflowStage('failed')
} finally {
setRunningDetection(false)
}
}
const uploadDetectionRaster = async (file: File): Promise<boolean> => {
if (!selectedProjectId) {
setDetectionRunError('De regionale werkruimte is nog niet geladen')
return false
}
setDetectionRunError(null)
setDetectionWorkflowStage('uploading')
try {
const dataset = await datasetsApi.upload(selectedProjectId, {
file,
datasetType: 'raster',
source: 'user_upload',
datasetRole: 'source',
sourceName: 'manual',
sourceMetadataJson: JSON.stringify({ purpose: 'building_detection' }),
provenanceMetadataJson: JSON.stringify({ original_filename: file.name, acquisition: 'explicit_user_upload' }),
})
setSelectedDetectionDatasetId(dataset.id)
setDetectionTileManifestPath('')
setDetectionRunResult(null)
setDetectionWorkflowStage('ready')
await loadProjectData(selectedProjectId)
return true
} catch (error) {
setDetectionRunError(formatError(error, 'Het luchtbeeld kon niet worden toegevoegd'))
setDetectionWorkflowStage('failed')
return false
}
}
const prepareAndRunDetection = async (): Promise<boolean> => {
if (!selectedProjectId) {
setDetectionRunError('De regionale werkruimte is nog niet geladen')
return false
}
const datasetId = selectedDetectionDatasetId || rasterDatasets[0]?.id
if (!datasetId) {
setDetectionRunError('Kies of voeg eerst een gegeorefereerd luchtbeeld toe')
return false
}
const selectedModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId)
if (!selectedModel?.configured || selectedDetectionModelId === 'manual-fixture-detector') {
setDetectionRunError(selectedModel?.limitation_message ?? 'Het gekozen analysemodel is niet beschikbaar')
return false
}
if (selectedDetectionModelId === 'yolo-configured' && modelAssets.length > 0 && !selectedModelAssetId) {
setDetectionRunError('Kies eerst een lokaal modelbestand')
return false
}
setDetectionRunError(null)
setDetectionRunResult(null)
setRunningDetection(true)
try {
let manifestPath = detectionTileManifestPath.trim()
if (!manifestPath) {
setDetectionWorkflowStage('tiling')
const inspection = await datasetsApi.rasterInspect(selectedProjectId, datasetId)
const expectedTileCount = rasterTileCount(inspection.metadata, 512, 64)
const maxTiles = yoloPreflight?.max_tiles ?? 256
if (expectedTileCount === null) {
throw new Error('De afmetingen van het luchtbeeld konden niet veilig worden bepaald')
}
if (expectedTileCount > maxTiles) {
throw new Error(
`Dit luchtbeeld zou ${expectedTileCount} beeldtegels maken; het veilige maximum is ${maxTiles}. Knip het beeld eerst tot het gewenste werkgebied.`,
)
}
const tileJob = await datasetsApi.rasterTile(selectedProjectId, datasetId, {
tile_size: 512,
overlap: 64,
})
manifestPath = tileManifestPathFromJob(tileJob) ?? ''
if (!manifestPath) {
throw new Error(tileJob.error_message || 'De tegelvoorbereiding leverde geen geldig manifest op')
}
setDetectionTileManifestPath(manifestPath)
}
setDetectionWorkflowStage('validating')
const preflight = await detectionApi.getYoloPreflight({
tile_manifest_path: manifestPath,
model_asset_id: selectedModelAssetId || null,
})
setYoloPreflight(preflight)
setYoloPreflightError(null)
if (
!preflight.checks.manifest_valid ||
!preflight.checks.tile_paths_exist ||
!preflight.checks.tile_limit_ok ||
!preflight.checks.dependencies_available ||
!preflight.checks.model_file_exists
) {
throw new Error(preflight.message || 'De beeldtegels of modelruntime zijn niet startklaar')
}
setDetectionWorkflowStage('detecting')
await executeDetection(selectedProjectId, datasetId, manifestPath)
setDetectionWorkflowStage('complete')
return true
} catch (error) {
setDetectionRunError(formatError(error, 'De beeldanalyse is mislukt'))
setDetectionWorkflowStage('failed')
return false
} finally {
setRunningDetection(false)
}
@@ -371,6 +517,7 @@ export function useDetectionWorkflow({
setDetectionRunResult(null)
setDetectionCalibrationRows([])
setDetectionCalibrationError(null)
setDetectionWorkflowStage('idle')
}
return {
@@ -405,11 +552,14 @@ export function useDetectionWorkflow({
runningDetectionCalibration,
detectionCalibrationRows,
detectionCalibrationError,
detectionWorkflowStage,
loadDetectionModels,
loadYoloPreflight,
loadDetectionRuns,
loadDetectionResults,
runDetection,
uploadDetectionRaster,
prepareAndRunDetection,
runDetectionQa,
runDetectionCalibration,
applyDetectionOperatorProfile,
+119
View File
@@ -3977,6 +3977,125 @@ button.entity-card {
overflow-wrap: anywhere;
}
.guided-raster-input {
display: grid;
grid-template-columns: minmax(14rem, 1fr) minmax(14rem, 0.9fr) auto;
gap: 0.75rem;
align-items: end;
border: 1px solid #d8e3de;
border-radius: 8px;
padding: 0.75rem;
background: #f8fbf9;
}
.guided-raster-input > div,
.file-picker-field {
display: grid;
min-width: 0;
gap: 0.25rem;
}
.guided-raster-input strong,
.file-picker-field span {
color: var(--text);
font-size: 0.86rem;
}
.guided-raster-input p {
margin: 0;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.4;
}
.guided-raster-input input[type="file"] {
min-height: 2.45rem;
padding: 0.42rem;
background: #ffffff;
font-size: 0.78rem;
}
.guided-detection-progress {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.5rem;
}
.guided-detection-step {
display: flex;
min-width: 0;
align-items: center;
gap: 0.45rem;
border: 1px solid #dce4e0;
border-radius: 7px;
padding: 0.52rem 0.6rem;
background: #ffffff;
color: var(--muted);
}
.guided-detection-step span {
display: grid;
width: 1.25rem;
height: 1.25rem;
flex: 0 0 1.25rem;
place-items: center;
border-radius: 50%;
background: #edf2ef;
font-size: 0.72rem;
font-weight: 800;
}
.guided-detection-step strong {
min-width: 0;
font-size: 0.78rem;
line-height: 1.25;
overflow-wrap: anywhere;
}
.guided-detection-step-active {
border-color: #8fb9ad;
background: #f2faf7;
color: var(--accent-strong);
}
.guided-detection-step-complete {
border-color: #b8dcc9;
background: #f8fff9;
color: #235f43;
}
.guided-detection-step-active span,
.guided-detection-step-complete span {
background: #dcefe6;
color: #235f43;
}
.guided-detection-action {
min-height: 2.75rem;
padding-inline: 1.15rem;
}
.technical-manifest-surface {
margin-top: 0.1rem;
}
@media (max-width: 900px) {
.guided-raster-input {
grid-template-columns: 1fr;
align-items: stretch;
}
.guided-detection-progress {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 520px) {
.guided-detection-progress {
grid-template-columns: 1fr;
}
}
.raster-readiness-item span,
.raster-manifest-handoff span {
color: var(--muted);