feat: guide raster building analysis workflow
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user