feat(ui): refine governed workbench and dual-screen flows
This commit is contained in:
@@ -46,7 +46,7 @@ export function useChangeDetectionWorkflow({
|
||||
return
|
||||
}
|
||||
if (changeIouThreshold < 0 || changeIouThreshold > 1) {
|
||||
setChangeDetectionError('IoU threshold must be between 0 and 1')
|
||||
setChangeDetectionError('De IoU-drempel moet tussen 0 en 1 liggen.')
|
||||
return
|
||||
}
|
||||
setChangeDetectionError(null)
|
||||
@@ -63,10 +63,10 @@ export function useChangeDetectionWorkflow({
|
||||
...(selection.areaId ? { area_id: selection.areaId } : {}),
|
||||
})
|
||||
if (job.status !== 'success') {
|
||||
throw new Error(job.error_message || 'Change detection job failed')
|
||||
throw new Error(job.error_message || 'De wijzigingsanalyse is mislukt.')
|
||||
}
|
||||
if (!job.result_json) {
|
||||
throw new Error('Change detection completed without result payload')
|
||||
throw new Error('De wijzigingsanalyse is afgerond zonder resultaat.')
|
||||
}
|
||||
setChangeSourceDatasetId(sourceDatasetId)
|
||||
setChangeTargetDatasetId(targetDatasetId)
|
||||
|
||||
@@ -218,7 +218,7 @@ export function useDatasetWorkflow({
|
||||
await loadDatasetJobs(projectId, dataset.id, detailRequestId)
|
||||
} catch (error) {
|
||||
if (detailRequestId === datasetDetailRequestSequence.current) {
|
||||
setDatasetDetailError(formatError(error, 'Unable to load dataset detail'))
|
||||
setDatasetDetailError(formatError(error, 'De datasetdetails konden niet worden geladen.'))
|
||||
}
|
||||
} finally {
|
||||
if (detailRequestId === datasetDetailRequestSequence.current) {
|
||||
@@ -245,11 +245,11 @@ export function useDatasetWorkflow({
|
||||
try {
|
||||
const parsedSourceMetadata = JSON.parse(datasetForm.sourceMetadataJson)
|
||||
if (parsedSourceMetadata === null || typeof parsedSourceMetadata !== 'object') {
|
||||
setErrorMessage('Source metadata must be a JSON object')
|
||||
setErrorMessage('Bronmetadata moet een JSON-object zijn.')
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
setErrorMessage('Source metadata must be valid JSON')
|
||||
setErrorMessage('Bronmetadata moet geldige JSON zijn.')
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -257,11 +257,11 @@ export function useDatasetWorkflow({
|
||||
try {
|
||||
const parsedProvenanceMetadata = JSON.parse(datasetForm.provenanceMetadataJson)
|
||||
if (parsedProvenanceMetadata === null || typeof parsedProvenanceMetadata !== 'object') {
|
||||
setErrorMessage('Provenance metadata must be a JSON object')
|
||||
setErrorMessage('Provenancemetadata moet een JSON-object zijn.')
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
setErrorMessage('Provenance metadata must be valid JSON')
|
||||
setErrorMessage('Provenancemetadata moet geldige JSON zijn.')
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -401,7 +401,7 @@ export function useDatasetWorkflow({
|
||||
}
|
||||
const targetCrs = rasterReprojectCrs.trim()
|
||||
if (!targetCrs) {
|
||||
setDatasetDetailError('Target CRS is required for raster reproject')
|
||||
setDatasetDetailError('Voor rasterherprojectie is een doel-CRS vereist.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
YoloPreflightResponse,
|
||||
} from '../types'
|
||||
import { formatError } from '../lib/formatError'
|
||||
import { rasterTileCount } from '../lib/rasterTiling'
|
||||
import {
|
||||
analysisRunIdFromJob,
|
||||
completedDetectionResponse,
|
||||
@@ -84,16 +85,6 @@ function tileManifestPathFromJob(job: JobRead): string | null {
|
||||
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)
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError'
|
||||
}
|
||||
@@ -507,11 +498,14 @@ export function useDetectionWorkflow({
|
||||
const inspection = await datasetsApi.rasterInspect(projectId, datasetId)
|
||||
assertProjectCurrent()
|
||||
const expectedTileCount = rasterTileCount(inspection.metadata, 512, 64)
|
||||
const maxTiles = yoloPreflight?.max_tiles ?? 256
|
||||
const maxTiles = yoloPreflight?.max_tiles
|
||||
if (expectedTileCount === null) {
|
||||
throw new Error('De afmetingen van het luchtbeeld konden niet veilig worden bepaald')
|
||||
}
|
||||
if (expectedTileCount > maxTiles) {
|
||||
if (!Number.isInteger(maxTiles) || (maxTiles ?? 0) <= 0) {
|
||||
throw new Error('De serverlimiet voor beeldtegels kon niet betrouwbaar worden opgehaald; vernieuw eerst de modelstatus')
|
||||
}
|
||||
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.`,
|
||||
)
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('a thrown error', () => {
|
||||
|
||||
await act(async () => void (await view.result.current.run()))
|
||||
|
||||
expect(view.result.current.error).toBe('Full GIS workflow failed.')
|
||||
expect(view.result.current.error).toBe('De volledige GIS-werkstroom is mislukt.')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export type FullWorkflowMode = 'new' | 'reuse'
|
||||
|
||||
export const IDLE_STATUS = 'Klaar om de volledige GIS-werkstroom uit te voeren.'
|
||||
const STOPPED_STATUS = 'De werkstroom is gestopt.'
|
||||
const GENERIC_FAILURE = 'Full GIS workflow failed.'
|
||||
const GENERIC_FAILURE = 'De volledige GIS-werkstroom is mislukt.'
|
||||
|
||||
interface FullGisWorkflowOptions {
|
||||
selectedDataset: DatasetCreateResponse | null | undefined
|
||||
|
||||
@@ -36,7 +36,7 @@ export function useMapSelectionQa({
|
||||
return null
|
||||
}
|
||||
if (candidateDataset.id === selectedMapQaReferenceDatasetId) {
|
||||
setMapSelectionQaError('Candidate and reference datasets must be different.')
|
||||
setMapSelectionQaError('De kandidaat- en referentiedataset moeten verschillend zijn.')
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -75,11 +75,11 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali
|
||||
return
|
||||
}
|
||||
if (qaCandidateDatasetId === qaReferenceDatasetId) {
|
||||
setQaError('Candidate and reference datasets must be different')
|
||||
setQaError('De kandidaat- en referentiedataset moeten verschillend zijn.')
|
||||
return
|
||||
}
|
||||
if (!Number.isFinite(qaIouThreshold) || qaIouThreshold < 0 || qaIouThreshold > 1) {
|
||||
setQaError('IoU threshold must be between 0 and 1')
|
||||
setQaError('De IoU-drempel moet tussen 0 en 1 liggen.')
|
||||
return
|
||||
}
|
||||
setQaError(null)
|
||||
@@ -94,7 +94,7 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali
|
||||
}
|
||||
const job: JobRead = await qaApi.runQa(selectedProjectId, request)
|
||||
if (job.status === 'failed') {
|
||||
setQaError(job.error_message || 'QA comparison failed')
|
||||
setQaError(job.error_message || 'De kwaliteitsvergelijking is mislukt.')
|
||||
return
|
||||
}
|
||||
const payload = job.result_json
|
||||
@@ -113,7 +113,7 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali
|
||||
await loadProjectData(selectedProjectId)
|
||||
}
|
||||
} catch (error) {
|
||||
setQaError(error instanceof Error ? error.message : 'QA comparison failed')
|
||||
setQaError(error instanceof Error ? error.message : 'De kwaliteitsvergelijking is mislukt.')
|
||||
} finally {
|
||||
setQaRunning(false)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { JobRead, SegmentationRead, SegmentationRunRead } from '../types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
rasterInspect: vi.fn(),
|
||||
rasterTile: vi.fn(),
|
||||
listModels: vi.fn(),
|
||||
runAsync: vi.fn(),
|
||||
listRuns: vi.fn(),
|
||||
@@ -13,6 +15,10 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('../services/api', () => ({
|
||||
datasetsApi: {
|
||||
rasterInspect: mocks.rasterInspect,
|
||||
rasterTile: mocks.rasterTile,
|
||||
},
|
||||
segmentationApi: {
|
||||
listModels: mocks.listModels,
|
||||
runAsync: mocks.runAsync,
|
||||
@@ -60,6 +66,7 @@ function renderWorkflow(selectedProjectId = projectId) {
|
||||
selectedProjectId,
|
||||
rasterDatasets: [],
|
||||
qaIouThreshold: 0.5,
|
||||
maxInferenceTiles: 100,
|
||||
loadProjectData,
|
||||
loadQualityChecks,
|
||||
}))
|
||||
@@ -87,6 +94,20 @@ describe('useSegmentationWorkflow GPU execution', () => {
|
||||
mocks.getRun.mockResolvedValue(persistedRun)
|
||||
mocks.listSegmentations.mockResolvedValue({ items: [], total: 0, truncated: false })
|
||||
mocks.getRunGeoJson.mockResolvedValue({ type: 'FeatureCollection', features: [] })
|
||||
mocks.rasterInspect.mockResolvedValue({
|
||||
dataset_id: datasetId,
|
||||
ready: true,
|
||||
metadata: { width: 512, height: 512 },
|
||||
})
|
||||
mocks.rasterTile.mockResolvedValue({
|
||||
id: 'tile-job-1',
|
||||
job_type: 'raster.tile',
|
||||
status: 'success',
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
parameters_json: {},
|
||||
result_json: { manifest_path: '/tiles/generated-manifest.json' },
|
||||
})
|
||||
})
|
||||
|
||||
it('queues, follows and reconciles a persisted segmentation result', async () => {
|
||||
@@ -118,15 +139,41 @@ describe('useSegmentationWorkflow GPU execution', () => {
|
||||
expect(loadProjectData).toHaveBeenCalledWith(projectId)
|
||||
})
|
||||
|
||||
it('does not queue a configured model without a tile manifest', async () => {
|
||||
it('prepares a server manifest before queueing when no manifest is supplied', async () => {
|
||||
const { result } = renderWorkflow()
|
||||
await act(async () => { await result.current.loadSegmentationModels() })
|
||||
act(() => { result.current.setSelectedSegmentationDatasetId(datasetId) })
|
||||
|
||||
await act(async () => { await result.current.runSegmentation() })
|
||||
|
||||
expect(mocks.rasterInspect).toHaveBeenCalledWith(projectId, datasetId)
|
||||
expect(mocks.rasterTile).toHaveBeenCalledWith(projectId, datasetId, {
|
||||
tile_size: 512,
|
||||
overlap: 64,
|
||||
})
|
||||
expect(mocks.runAsync).toHaveBeenCalledWith(expect.objectContaining({
|
||||
tile_manifest_path: '/tiles/generated-manifest.json',
|
||||
}))
|
||||
expect(result.current.segmentationTileManifestPath).toBe('/tiles/generated-manifest.json')
|
||||
expect(result.current.segmentationRunError).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the backend-reported inference limit and refuses tiling before writes', async () => {
|
||||
mocks.rasterInspect.mockResolvedValueOnce({
|
||||
dataset_id: datasetId,
|
||||
ready: true,
|
||||
metadata: { width: 5376, height: 4480 },
|
||||
})
|
||||
const { result } = renderWorkflow()
|
||||
await act(async () => { await result.current.loadSegmentationModels() })
|
||||
act(() => { result.current.setSelectedSegmentationDatasetId(datasetId) })
|
||||
|
||||
await act(async () => { await result.current.runSegmentation() })
|
||||
|
||||
expect(mocks.rasterTile).not.toHaveBeenCalled()
|
||||
expect(mocks.runAsync).not.toHaveBeenCalled()
|
||||
expect(result.current.segmentationRunError).toContain('beeldtegelmanifest')
|
||||
expect(result.current.segmentationRunError).toContain('120 beeldtegels')
|
||||
expect(result.current.segmentationRunError).toContain('maximum is 100')
|
||||
})
|
||||
|
||||
it('ignores a late run list after the active project changes', async () => {
|
||||
@@ -142,6 +189,7 @@ describe('useSegmentationWorkflow GPU execution', () => {
|
||||
selectedProjectId,
|
||||
rasterDatasets: [],
|
||||
qaIouThreshold: 0.5,
|
||||
maxInferenceTiles: 100,
|
||||
loadProjectData,
|
||||
loadQualityChecks,
|
||||
}),
|
||||
@@ -185,6 +233,7 @@ describe('useSegmentationWorkflow GPU execution', () => {
|
||||
selectedProjectId,
|
||||
rasterDatasets: [],
|
||||
qaIouThreshold: 0.5,
|
||||
maxInferenceTiles: 100,
|
||||
loadProjectData,
|
||||
loadQualityChecks,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { segmentationApi } from '../services/api'
|
||||
import { datasetsApi, segmentationApi } from '../services/api'
|
||||
import type {
|
||||
DatasetCreateResponse,
|
||||
JobRead,
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SegmentationRunResponse,
|
||||
} from '../types'
|
||||
import { formatError } from '../lib/formatError'
|
||||
import { rasterTileCount } from '../lib/rasterTiling'
|
||||
import {
|
||||
analysisRunIdFromSegmentationJob,
|
||||
completedSegmentationResponse,
|
||||
@@ -18,10 +19,19 @@ import {
|
||||
waitForSegmentationJob,
|
||||
} from '../services/segmentationJob'
|
||||
|
||||
const SEGMENTATION_TILE_SIZE = 512
|
||||
const SEGMENTATION_TILE_OVERLAP = 64
|
||||
|
||||
function tileManifestPathFromJob(job: JobRead): string | null {
|
||||
const manifestPath = job.result_json?.manifest_path
|
||||
return typeof manifestPath === 'string' && manifestPath.trim() ? manifestPath.trim() : null
|
||||
}
|
||||
|
||||
interface SegmentationWorkflowOptions {
|
||||
selectedProjectId: string | null
|
||||
rasterDatasets: DatasetCreateResponse[]
|
||||
qaIouThreshold: number
|
||||
maxInferenceTiles: number | null
|
||||
loadProjectData: (projectId: string) => Promise<unknown>
|
||||
loadQualityChecks: (projectId?: string | null) => Promise<QualityCheckRead[] | void>
|
||||
}
|
||||
@@ -40,6 +50,7 @@ export function useSegmentationWorkflow({
|
||||
selectedProjectId,
|
||||
rasterDatasets,
|
||||
qaIouThreshold,
|
||||
maxInferenceTiles,
|
||||
loadProjectData,
|
||||
loadQualityChecks,
|
||||
}: SegmentationWorkflowOptions) {
|
||||
@@ -237,10 +248,6 @@ export function useSegmentationWorkflow({
|
||||
setSegmentationRunError('Het fixturemodel is uitsluitend beschikbaar voor expliciete geautomatiseerde tests')
|
||||
return
|
||||
}
|
||||
if (!segmentationTileManifestPath.trim()) {
|
||||
setSegmentationRunError('Koppel eerst het beeldtegelmanifest van het gekozen rasterbestand')
|
||||
return
|
||||
}
|
||||
if (
|
||||
(activeSegmentationControllerRef.current && !activeSegmentationControllerRef.current.signal.aborted)
|
||||
|| segmentationJob?.status === 'queued'
|
||||
@@ -251,15 +258,6 @@ export function useSegmentationWorkflow({
|
||||
}
|
||||
|
||||
const projectId = selectedProjectId
|
||||
const parameters: Record<string, unknown> = {}
|
||||
const request = {
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
model_id: selectedSegmentationModelId,
|
||||
confidence_threshold: segmentationConfidenceThreshold,
|
||||
tile_manifest_path: segmentationTileManifestPath.trim() || null,
|
||||
parameters_json: parameters,
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const executionSequence = segmentationExecutionSequence.current + 1
|
||||
segmentationExecutionSequence.current = executionSequence
|
||||
@@ -279,6 +277,46 @@ export function useSegmentationWorkflow({
|
||||
setRunningSegmentation(true)
|
||||
setSegmentationJob(null)
|
||||
try {
|
||||
let manifestPath = segmentationTileManifestPath.trim()
|
||||
if (!manifestPath) {
|
||||
if (!Number.isInteger(maxInferenceTiles) || (maxInferenceTiles ?? 0) <= 0) {
|
||||
throw new Error('De serverlimiet voor beeldtegels kon niet betrouwbaar worden opgehaald; vernieuw eerst de modelstatus')
|
||||
}
|
||||
const inspection = await datasetsApi.rasterInspect(projectId, datasetId)
|
||||
assertExecutionCurrent()
|
||||
const expectedTileCount = rasterTileCount(
|
||||
inspection.metadata,
|
||||
SEGMENTATION_TILE_SIZE,
|
||||
SEGMENTATION_TILE_OVERLAP,
|
||||
)
|
||||
if (expectedTileCount === null) {
|
||||
throw new Error('De afmetingen van het rasterbestand konden niet veilig worden bepaald')
|
||||
}
|
||||
if (expectedTileCount > maxInferenceTiles!) {
|
||||
throw new Error(
|
||||
`Dit rasterbestand zou ${expectedTileCount} beeldtegels maken; het door de server gemelde maximum is ${maxInferenceTiles}. Knip het raster eerst tot het gewenste werkgebied.`,
|
||||
)
|
||||
}
|
||||
const tileJob = await datasetsApi.rasterTile(projectId, datasetId, {
|
||||
tile_size: SEGMENTATION_TILE_SIZE,
|
||||
overlap: SEGMENTATION_TILE_OVERLAP,
|
||||
})
|
||||
assertExecutionCurrent()
|
||||
manifestPath = tileManifestPathFromJob(tileJob) ?? ''
|
||||
if (!manifestPath) {
|
||||
throw new Error(tileJob.error_message || 'De tegelvoorbereiding leverde geen geldig manifest op')
|
||||
}
|
||||
setSegmentationTileManifestPath(manifestPath)
|
||||
}
|
||||
const parameters: Record<string, unknown> = {}
|
||||
const request = {
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
model_id: selectedSegmentationModelId,
|
||||
confidence_threshold: segmentationConfidenceThreshold,
|
||||
tile_manifest_path: manifestPath,
|
||||
parameters_json: parameters,
|
||||
}
|
||||
const queuedJob = await segmentationApi.runAsync(request)
|
||||
assertExecutionCurrent()
|
||||
setSegmentationJob(queuedJob)
|
||||
|
||||
Reference in New Issue
Block a user