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
+8
View File
@@ -7,6 +7,14 @@
# Changelog
## Sprint 195 Guided raster-to-detection workflow (2026-07-14)
- Replaced the Detection Lab's manual manifest-path prerequisite with one guided action that creates canonical 512 px raster tiles with 64 px overlap, reuses an existing manifest, validates raster size and the local YOLO runtime, runs persisted detection and loads the persisted GeoJSON result on the existing MapLibre map.
- Added direct, explicit georeferenced GeoTIFF upload in Detection Lab through the existing dataset upload boundary; no browser-side provider fetch, model download or alternate persistence path was introduced.
- Kept manual manifest execution, model assets, preflight and calibration available under technical/management disclosures while making persisted detection QA a primary user step.
- Added clear preparation progress, understandable Dutch QA diagnostics, result-to-map navigation and focused regression coverage.
- Did not change API contracts, database migrations, model dependencies or backend inference behavior.
## Sprint 194 Regional official time-series synchronization (2026-07-14)
- Generalized the proven Statbel population operator from a hardcoded Mol import to an approved geographic scope while keeping Mol as the backwards-compatible default.
@@ -28,9 +28,10 @@ def test_cancelled_selection_requests_cannot_restore_stale_results() -> None:
assert "if (requestSequence.current !== sequence)" in themes_hook
def test_viewport_status_names_the_active_reference_layer() -> None:
def test_viewport_status_uses_end_user_map_language() -> None:
hook = read("frontend/src/hooks/useViewportVectorLayer.ts")
assert "load buildings from PostGIS" not in hook
assert "selectedDataset?.reference_layer_name?.trim() || 'features'" in hook
assert "to load ${layerLabel} from PostGIS" in hook
assert "Zichtbare kaartobjecten laden..." in hook
assert "kaartobjecten getoond" in hook
assert "PostGIS" not in hook
@@ -0,0 +1,63 @@
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def read(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def test_guided_detection_reuses_canonical_raster_and_detection_apis() -> None:
hook = read("frontend/src/hooks/useDetectionWorkflow.ts")
assert "prepareAndRunDetection" in hook
assert "datasetsApi.rasterTile" in hook
assert "datasetsApi.rasterInspect" in hook
assert "rasterTileCount" in hook
assert "expectedTileCount > maxTiles" in hook
assert "tile_size: 512" in hook
assert "overlap: 64" in hook
assert "detectionApi.getYoloPreflight" in hook
assert "await executeDetection(selectedProjectId, datasetId, manifestPath)" in hook
assert "await loadDetectionResults(result.analysis_run_id)" in hook
assert "model_id: selectedDetectionModelId" in hook
assert "model_asset_id: selectedModelAssetId || null" in hook
def test_guided_detection_upload_uses_existing_dataset_persistence_boundary() -> None:
hook = read("frontend/src/hooks/useDetectionWorkflow.ts")
assert "uploadDetectionRaster" in hook
assert "datasetsApi.upload" in hook
assert "datasetType: 'raster'" in hook
assert "datasetRole: 'source'" in hook
assert "sourceName: 'manual'" in hook
assert "explicit_user_upload" in hook
def test_detection_lab_hides_manifest_plumbing_and_exposes_map_first_result_flow() -> None:
lab = read("frontend/src/components/detection/DetectionLab.tsx")
app = read("frontend/src/App.tsx")
assert "Gebouwen zoeken en op kaart tonen" in lab
assert 'aria-label="Luchtbeeld toevoegen"' in lab
assert 'aria-label="Technische tegelinstellingen"' in lab
assert "Worden automatisch voorbereid" in lab
assert "Toon op kaart" in lab
assert "onPrepareAndRunDetection={runGuidedDetection}" in app
assert "setMapContentMode('analysis')" in app
assert "setMapLayerVisible(true)" in app
assert "setActiveWorkspace('map')" in app
def test_detection_qa_remains_persisted_and_primary_not_parallel() -> None:
lab = read("frontend/src/components/detection/DetectionLab.tsx")
hook = read("frontend/src/hooks/useDetectionWorkflow.ts")
assert 'aria-label="Kwaliteitscontrole gebouwdetectie"' in lab
assert "als kwaliteitscontrole in de database bewaard" in lab
assert "detectionApi.compareWithReference" in hook
assert "await loadQualityChecks(selectedProjectId)" in hook
@@ -23,7 +23,9 @@ def test_ai_labs_explain_missing_raster_input_before_disabled_runs() -> None:
)
assert "Geen luchtbeeld beschikbaar in deze werkruimte." in detection_lab
assert "Voeg onder Bronnen een gegeorefereerde GeoTIFF toe." in detection_lab
assert "Voeg hieronder een gegeorefereerde GeoTIFF toe." in detection_lab
assert 'aria-label="Luchtbeeld toevoegen"' in detection_lab
assert "onUploadRaster" in detection_lab
assert "rasterDatasets.length === 0" in detection_lab
assert "No raster datasets available for segmentation." in segmentation_lab
assert "Upload or select a raster dataset in Data before running segmentation." in segmentation_lab
+2
View File
@@ -49,6 +49,8 @@ Sprint 8B adds an import-safe real YOLO adapter path:
- YOLO class labels are normalized to lowercase for persisted detection records and filtering, while the original model label remains available in detection provenance.
- Detection runs remain synchronous behind the existing job and analysis-run persistence boundary for Sprint 8B.
The guided Detection Lab action does not introduce another inference pipeline. It creates a tile manifest through the existing raster service, validates that manifest and the selected local asset through YOLO preflight, then invokes the same configured detection service. Persisted `Detection` geometry remains the authoritative map output; QA continues to compare those rows against persisted reference `vector_features` and stores `QualityCheck`/`Metric` records.
### Sprint 13 YOLO operational preflight
Sprint 13 adds a local preflight command for configured YOLO operation:
+13
View File
@@ -610,6 +610,19 @@ Response:
Sprint 8 implements Detection Lab foundation only. YOLO/PyTorch real inference is not enabled, no model is downloaded, and fixture detections require explicit fixture mode.
### Guided browser orchestration
The current frontend offers one guided building-analysis action, but does not add a parallel backend workflow endpoint. It deliberately composes the canonical contracts in this order:
1. optional explicit `POST /api/v1/projects/{project_id}/datasets/upload` for a georeferenced GeoTIFF;
2. `POST /api/v1/projects/{project_id}/datasets/{dataset_id}/raster/tile` with 512 px tiles and 64 px overlap;
3. `GET /api/v1/detection/yolo/preflight` with the returned manifest and selected local model asset;
4. `POST /api/v1/detection/run` only after successful preflight;
5. persisted run, Detection list and Detection GeoJSON reads;
6. optional persisted reference QA through the existing detection QA endpoint.
The strict `POST /api/v1/detection/run` contract still requires `tile_manifest_path` for configured YOLO. The frontend does not create fake tiles, bypass tile limits, fetch external imagery or download model weights.
### GET `/api/v1/detection/models`
Returns object-detection model capability descriptors.
+17
View File
@@ -1,3 +1,20 @@
## Sprint 195 Guided raster-to-detection workflow (2026-07-14)
Changed:
- Connected the existing Dataset upload, `raster/tile`, YOLO preflight, configured detection, persisted result listing and MapLibre GeoJSON overlay into one guided frontend action.
- Added explicit GeoTIFF input inside Detection Lab so users no longer need to navigate to dataset management before starting image analysis.
- Uses fixed safe tiling defaults of 512 px with 64 px overlap, reuses the current manifest and estimates the tile count from canonical raster inspection before writing tiles; the backend remains authoritative for tile-limit, manifest, dependency and local-model validation.
- Moved manifest paths, direct-manifest execution and calibration under management disclosures while returning persisted reference QA to the main result flow.
- Kept every run on the existing `Dataset -> Job -> AnalysisRun -> Detection -> QualityCheck/Metric` chain. No migration, API route, external fetch, model download or synthetic inference was added.
Validated locally:
- `bash scripts/run_readiness_check.sh` passed with `575 passed`, backend compile, one Alembic head (`202607140001`), frontend typecheck/build and live-smoke syntax validation.
- `python -m alembic upgrade head --sql` generated the complete 26,972-byte PostgreSQL/PostGIS migration plan successfully.
- Focused guided-flow, stale-map-state and direct-upload contracts passed. The Windows workstation has no Docker CLI; live container/PostGIS validation therefore remains part of the Tower deployment pass.
Next:
- Provision one existing real Mol operator orthophoto and matching official GRB reference into the regional workbench through the canonical upload API, then execute the guided action and verify the persisted map/QA result in the browser.
## Sprint 194 Regional official time series and full-Area performance (2026-07-14)
Changed:
+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,6 +657,23 @@ export function DetectionLab({
) : 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
@@ -607,10 +692,12 @@ export function DetectionLab({
<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
<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">
@@ -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>
<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);