From 6c32f2924552cd43ecb94ff3296ac06c70b4411f Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 17 Jun 2026 02:13:57 +0200 Subject: [PATCH] Extract detection and segmentation workflow hooks --- CHANGELOG.md | 8 + .../test_sprint26_frontend_workflow_hooks.py | 48 ++ docs/CODEX_EXECUTION_LOG.md | 20 + docs/TODO.md | 3 +- frontend/README.md | 7 + frontend/src/App.tsx | 419 ++++-------------- frontend/src/hooks/useDetectionWorkflow.ts | 217 +++++++++ frontend/src/hooks/useSegmentationWorkflow.ts | 227 ++++++++++ frontend/src/lib/formatError.ts | 7 + 9 files changed, 630 insertions(+), 326 deletions(-) create mode 100644 backend/tests/test_sprint26_frontend_workflow_hooks.py create mode 100644 frontend/src/hooks/useDetectionWorkflow.ts create mode 100644 frontend/src/hooks/useSegmentationWorkflow.ts create mode 100644 frontend/src/lib/formatError.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e70b2f73..0346f8a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ # Changelog +## Sprint 26 frontend workflow hook hardening (2026-06-17) + +- Moved Detection Lab orchestration state and API calls from `App.tsx` into `useDetectionWorkflow`. +- Moved Segmentation Lab orchestration state and API calls from `App.tsx` into `useSegmentationWorkflow`. +- Added shared frontend `formatError` helper. +- Added regression coverage to verify `App.tsx` wires the workflow hooks and panels without direct detection/segmentation API ownership. +- No API contracts, backend behavior, migrations, product features, provider fetching, AI behavior or UI redesign were introduced. + ## Sprint 25 YOLO compatibility smoke hardening (2026-06-17) - Added an explicit `--check-model-load` mode to `scripts/yolo_preflight.py`. diff --git a/backend/tests/test_sprint26_frontend_workflow_hooks.py b/backend/tests/test_sprint26_frontend_workflow_hooks.py new file mode 100644 index 00000000..731846e6 --- /dev/null +++ b/backend/tests/test_sprint26_frontend_workflow_hooks.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_app_uses_detection_and_segmentation_workflow_hooks() -> None: + app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + + assert "useDetectionWorkflow" in app + assert "useSegmentationWorkflow" in app + assert "from './hooks/useDetectionWorkflow'" in app + assert "from './hooks/useSegmentationWorkflow'" in app + assert "detectionApi" not in app + assert "segmentationApi" not in app + + +def test_detection_workflow_hook_owns_detection_api_calls() -> None: + hook = (ROOT / "frontend" / "src" / "hooks" / "useDetectionWorkflow.ts").read_text(encoding="utf-8") + + assert "detectionApi.listModels" in hook + assert "detectionApi.listRuns" in hook + assert "detectionApi.run" in hook + assert "detectionApi.compareWithReference" in hook + assert "resetDetectionForProject" in hook + + +def test_segmentation_workflow_hook_owns_segmentation_api_calls() -> None: + hook = (ROOT / "frontend" / "src" / "hooks" / "useSegmentationWorkflow.ts").read_text(encoding="utf-8") + + assert "segmentationApi.listModels" in hook + assert "segmentationApi.listRuns" in hook + assert "segmentationApi.run" in hook + assert "segmentationApi.compareWithReference" in hook + assert "resetSegmentationForProject" in hook + + +def test_app_still_wires_detection_and_segmentation_panels() -> None: + app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + + assert " | null | undefined): string return `${bounds.min_x?.toFixed(4)}, ${bounds.min_y?.toFixed(4)} -> ${bounds.max_x?.toFixed(4)}, ${bounds.max_y?.toFixed(4)}` } -function formatError(error: unknown, fallback: string): string { - if (error instanceof Error) { - const code = (error as { code?: string }).code - return code ? `${error.message} (${code})` : error.message - } - return fallback -} - function App(): JSX.Element { const [projects, setProjects] = useState([]) const [selectedProjectId, setSelectedProjectId] = useState(null) @@ -112,47 +97,6 @@ function App(): JSX.Element { const [runningChangeDetection, setRunningChangeDetection] = useState(false) const [changeDetectionResult, setChangeDetectionResult] = useState(null) const [changeDetectionError, setChangeDetectionError] = useState(null) - const [detectionModels, setDetectionModels] = useState([]) - const [loadingDetectionModels, setLoadingDetectionModels] = useState(false) - const [detectionModelError, setDetectionModelError] = useState(null) - const [selectedDetectionDatasetId, setSelectedDetectionDatasetId] = useState('') - const [selectedDetectionModelId, setSelectedDetectionModelId] = useState('yolo-placeholder') - const [detectionTileManifestPath, setDetectionTileManifestPath] = useState('') - const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.5) - const [runningDetection, setRunningDetection] = useState(false) - const [detectionRunResult, setDetectionRunResult] = useState(null) - const [detectionRunError, setDetectionRunError] = useState(null) - const [detectionRuns, setDetectionRuns] = useState([]) - const [selectedDetectionRunId, setSelectedDetectionRunId] = useState('') - const [detectionItems, setDetectionItems] = useState([]) - const [detectionGeoJson, setDetectionGeoJson] = useState(null) - const [detectionClassFilter, setDetectionClassFilter] = useState('') - const [detectionMinConfidenceFilter, setDetectionMinConfidenceFilter] = useState(0) - const [loadingDetectionResults, setLoadingDetectionResults] = useState(false) - const [detectionReferenceDatasetId, setDetectionReferenceDatasetId] = useState('') - const [detectionQaResult, setDetectionQaResult] = useState(null) - const [detectionQaError, setDetectionQaError] = useState(null) - const [runningDetectionQa, setRunningDetectionQa] = useState(false) - const [segmentationModels, setSegmentationModels] = useState([]) - const [loadingSegmentationModels, setLoadingSegmentationModels] = useState(false) - const [segmentationModelError, setSegmentationModelError] = useState(null) - const [selectedSegmentationDatasetId, setSelectedSegmentationDatasetId] = useState('') - const [selectedSegmentationModelId, setSelectedSegmentationModelId] = useState('segmentation-placeholder') - const [segmentationConfidenceThreshold, setSegmentationConfidenceThreshold] = useState(0.5) - const [runningSegmentation, setRunningSegmentation] = useState(false) - const [segmentationRunResult, setSegmentationRunResult] = useState(null) - const [segmentationRunError, setSegmentationRunError] = useState(null) - const [segmentationRuns, setSegmentationRuns] = useState([]) - const [selectedSegmentationRunId, setSelectedSegmentationRunId] = useState('') - const [segmentationItems, setSegmentationItems] = useState([]) - const [segmentationGeoJson, setSegmentationGeoJson] = useState(null) - const [segmentationClassFilter, setSegmentationClassFilter] = useState('') - const [segmentationMinConfidenceFilter, setSegmentationMinConfidenceFilter] = useState(0) - const [loadingSegmentationResults, setLoadingSegmentationResults] = useState(false) - const [segmentationReferenceDatasetId, setSegmentationReferenceDatasetId] = useState('') - const [segmentationQaResult, setSegmentationQaResult] = useState(null) - const [segmentationQaError, setSegmentationQaError] = useState(null) - const [runningSegmentationQa, setRunningSegmentationQa] = useState(false) const [rasterPreview, setRasterPreview] = useState(null) const [selectedIntersectTargetId, setSelectedIntersectTargetId] = useState('') const [selectedClipAreaId, setSelectedClipAreaId] = useState('') @@ -243,10 +187,91 @@ function App(): JSX.Element { const candidateDatasets = availableVectorDatasets const providers = useMemo(() => providerCapabilities, [providerCapabilities]) const rasterDatasets = useMemo(() => datasets.filter((item) => item.dataset_type === 'raster'), [datasets]) - const selectedSegmentationModel = useMemo( - () => segmentationModels.find((model) => model.model_id === selectedSegmentationModelId) ?? null, - [segmentationModels, selectedSegmentationModelId], - ) + const { + detectionModels, + loadingDetectionModels, + detectionModelError, + selectedDetectionDatasetId, + selectedDetectionModelId, + detectionTileManifestPath, + detectionConfidenceThreshold, + runningDetection, + detectionRunResult, + detectionRunError, + detectionRuns, + selectedDetectionRunId, + detectionItems, + detectionGeoJson, + detectionClassFilter, + detectionMinConfidenceFilter, + loadingDetectionResults, + detectionReferenceDatasetId, + detectionQaResult, + detectionQaError, + runningDetectionQa, + loadDetectionModels, + loadDetectionRuns, + loadDetectionResults, + runDetection, + runDetectionQa, + resetDetectionForProject, + setSelectedDetectionDatasetId, + setSelectedDetectionModelId, + setDetectionTileManifestPath, + setDetectionConfidenceThreshold, + setSelectedDetectionRunId, + setDetectionClassFilter, + setDetectionMinConfidenceFilter, + setDetectionReferenceDatasetId, + } = useDetectionWorkflow({ + selectedProjectId, + rasterDatasets, + qaIouThreshold, + loadProjectData, + loadQualityChecks, + }) + const { + segmentationModels, + loadingSegmentationModels, + segmentationModelError, + selectedSegmentationDatasetId, + selectedSegmentationModelId, + selectedSegmentationModel, + segmentationConfidenceThreshold, + runningSegmentation, + segmentationRunResult, + segmentationRunError, + segmentationRuns, + selectedSegmentationRunId, + segmentationItems, + segmentationGeoJson, + segmentationClassFilter, + segmentationMinConfidenceFilter, + loadingSegmentationResults, + segmentationReferenceDatasetId, + segmentationQaResult, + segmentationQaError, + runningSegmentationQa, + loadSegmentationModels, + loadSegmentationRuns, + loadSegmentationResults, + runSegmentation, + runSegmentationQa, + resetSegmentationForProject, + setSelectedSegmentationDatasetId, + setSelectedSegmentationModelId, + setSegmentationConfidenceThreshold, + setSelectedSegmentationRunId, + setSegmentationClassFilter, + setSegmentationMinConfidenceFilter, + setSegmentationReferenceDatasetId, + } = useSegmentationWorkflow({ + selectedProjectId, + rasterDatasets, + qaIouThreshold, + loadProjectData, + loadQualityChecks, + }) const selectedMapArea = useMemo( () => areas.find((area) => area.id === selectedMapAreaId) ?? null, [areas, selectedMapAreaId], @@ -329,7 +354,7 @@ function App(): JSX.Element { } } - const loadProjectData = async (projectId: string) => { + async function loadProjectData(projectId: string) { setLoadingAreas(true) setLoadingDatasets(true) setErrorMessage(null) @@ -373,123 +398,7 @@ function App(): JSX.Element { } } - const loadDetectionModels = async () => { - setLoadingDetectionModels(true) - setDetectionModelError(null) - try { - const response = await detectionApi.listModels() - setDetectionModels(response.models) - if (!response.models.some((model) => model.model_id === selectedDetectionModelId) && response.models.length > 0) { - setSelectedDetectionModelId(response.models[0].model_id) - } - } catch (error) { - setDetectionModelError(formatError(error, 'Failed to load detection models')) - } finally { - setLoadingDetectionModels(false) - } - } - - const loadDetectionRuns = async (projectId = selectedProjectId) => { - if (!projectId) { - setDetectionRuns([]) - return - } - try { - const response = await detectionApi.listRuns({ project_id: projectId }) - setDetectionRuns(response.items) - if (!selectedDetectionRunId && response.items.length > 0) { - setSelectedDetectionRunId(response.items[0].id) - } - } catch (error) { - setDetectionRunError(formatError(error, 'Failed to load detection runs')) - } - } - - const loadDetectionResults = async (analysisRunId = selectedDetectionRunId) => { - if (!analysisRunId) { - setDetectionItems([]) - setDetectionGeoJson(null) - return - } - setLoadingDetectionResults(true) - setDetectionRunError(null) - try { - const params = { - class_name: detectionClassFilter || null, - min_confidence: detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null, - } - const [detectionsResponse, geoJsonResponse] = await Promise.all([ - detectionApi.listDetections(analysisRunId, params), - detectionApi.getRunGeoJson(analysisRunId, params), - ]) - setDetectionItems(detectionsResponse.items) - setDetectionGeoJson(geoJsonResponse) - } catch (error) { - setDetectionRunError(formatError(error, 'Failed to load detection results')) - } finally { - setLoadingDetectionResults(false) - } - } - - const loadSegmentationModels = async () => { - setLoadingSegmentationModels(true) - setSegmentationModelError(null) - try { - const response = await segmentationApi.listModels() - setSegmentationModels(response.models) - if (!response.models.some((model) => model.model_id === selectedSegmentationModelId) && response.models.length > 0) { - setSelectedSegmentationModelId(response.models[0].model_id) - } - } catch (error) { - setSegmentationModelError(formatError(error, 'Failed to load segmentation models')) - } finally { - setLoadingSegmentationModels(false) - } - } - - const loadSegmentationRuns = async (projectId = selectedProjectId) => { - if (!projectId) { - setSegmentationRuns([]) - return - } - try { - const response = await segmentationApi.listRuns({ project_id: projectId }) - setSegmentationRuns(response.items) - if (!selectedSegmentationRunId && response.items.length > 0) { - setSelectedSegmentationRunId(response.items[0].id) - } - } catch (error) { - setSegmentationRunError(formatError(error, 'Failed to load segmentation runs')) - } - } - - const loadSegmentationResults = async (analysisRunId = selectedSegmentationRunId) => { - if (!analysisRunId) { - setSegmentationItems([]) - setSegmentationGeoJson(null) - return - } - setLoadingSegmentationResults(true) - setSegmentationRunError(null) - try { - const params = { - class_name: segmentationClassFilter || null, - min_confidence: segmentationMinConfidenceFilter > 0 ? segmentationMinConfidenceFilter : null, - } - const [segmentationsResponse, geoJsonResponse] = await Promise.all([ - segmentationApi.listSegmentations(analysisRunId, params), - segmentationApi.getRunGeoJson(analysisRunId, params), - ]) - setSegmentationItems(segmentationsResponse.items) - setSegmentationGeoJson(geoJsonResponse) - } catch (error) { - setSegmentationRunError(formatError(error, 'Failed to load segmentation results')) - } finally { - setLoadingSegmentationResults(false) - } - } - - const loadQualityChecks = async (projectId = selectedProjectId) => { + async function loadQualityChecks(projectId = selectedProjectId) { if (!projectId) { setQualityChecks([]) return @@ -498,6 +407,7 @@ function App(): JSX.Element { try { const response = await qaApi.listQualityChecks(projectId) setQualityChecks(response.items) + return response.items } catch (error) { setQualityChecksError(formatError(error, 'Failed to load QA/QC results')) } @@ -697,18 +607,8 @@ function App(): JSX.Element { setSelectedRasterMetadata(null) setSelectedRasterStats(null) setJobs([]) - setSelectedDetectionDatasetId('') - setDetectionRuns([]) - setSelectedDetectionRunId('') - setDetectionItems([]) - setDetectionGeoJson(null) - setDetectionRunResult(null) - setSelectedSegmentationDatasetId('') - setSegmentationRuns([]) - setSelectedSegmentationRunId('') - setSegmentationItems([]) - setSegmentationGeoJson(null) - setSegmentationRunResult(null) + resetDetectionForProject() + resetSegmentationForProject() setExports([]) setLatestExport(null) setExportPreview(null) @@ -1166,40 +1066,6 @@ function App(): JSX.Element { } } - const runDetection = async () => { - if (!selectedProjectId) { - setDetectionRunError('Select a project first') - return - } - const datasetId = selectedDetectionDatasetId || rasterDatasets[0]?.id - if (!datasetId) { - setDetectionRunError('Select a raster dataset') - return - } - setDetectionRunError(null) - setDetectionRunResult(null) - setRunningDetection(true) - try { - const result = await detectionApi.run({ - project_id: selectedProjectId, - dataset_id: datasetId, - model_id: selectedDetectionModelId, - 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) - } catch (error) { - setDetectionRunError(formatError(error, 'Detection run failed')) - } finally { - setRunningDetection(false) - } - } - const runChangeDetection = async () => { const sourceDatasetId = changeSourceDatasetId || availableVectorDatasets[0]?.id const targetDatasetId = @@ -1245,103 +1111,6 @@ function App(): JSX.Element { } } - const runDetectionQa = async () => { - if (!selectedDetectionRunId) { - setDetectionQaError('Select a detection run') - return - } - if (!detectionReferenceDatasetId) { - setDetectionQaError('Select a reference dataset') - return - } - setDetectionQaError(null) - setDetectionQaResult(null) - setRunningDetectionQa(true) - try { - const result = await detectionApi.compareWithReference(selectedDetectionRunId, { - reference_dataset_id: detectionReferenceDatasetId, - iou_threshold: qaIouThreshold, - class_name: detectionClassFilter || null, - min_confidence: detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null, - }) - setDetectionQaResult(result) - await loadQualityChecks(selectedProjectId) - } catch (error) { - setDetectionQaError(formatError(error, 'Detection QA failed')) - } finally { - setRunningDetectionQa(false) - } - } - - const runSegmentation = async () => { - if (!selectedProjectId) { - setSegmentationRunError('Select a project first') - return - } - const datasetId = selectedSegmentationDatasetId || rasterDatasets[0]?.id - if (!datasetId) { - setSegmentationRunError('Select a raster dataset') - return - } - if (!selectedSegmentationModel?.configured) { - setSegmentationRunError('Selected segmentation model is not configured') - return - } - setSegmentationRunError(null) - setSegmentationRunResult(null) - setRunningSegmentation(true) - try { - const parameters = - selectedSegmentationModelId === 'fixture-segmenter' - ? { fixture_mode: true, fixture_segmentations: [] } - : {} - const result = await segmentationApi.run({ - project_id: selectedProjectId, - dataset_id: datasetId, - model_id: selectedSegmentationModelId, - confidence_threshold: segmentationConfidenceThreshold, - parameters_json: parameters, - }) - setSegmentationRunResult(result) - setSelectedSegmentationRunId(result.analysis_run_id) - await loadSegmentationRuns(selectedProjectId) - await loadSegmentationResults(result.analysis_run_id) - await loadProjectData(selectedProjectId) - } catch (error) { - setSegmentationRunError(formatError(error, 'Segmentation run failed')) - } finally { - setRunningSegmentation(false) - } - } - - const runSegmentationQa = async () => { - if (!selectedSegmentationRunId) { - setSegmentationQaError('Select a segmentation run') - return - } - if (!segmentationReferenceDatasetId) { - setSegmentationQaError('Select a reference dataset') - return - } - setSegmentationQaError(null) - setSegmentationQaResult(null) - setRunningSegmentationQa(true) - try { - const result = await segmentationApi.compareWithReference(selectedSegmentationRunId, { - reference_dataset_id: segmentationReferenceDatasetId, - iou_threshold: qaIouThreshold, - class_name: segmentationClassFilter || null, - min_confidence: segmentationMinConfidenceFilter > 0 ? segmentationMinConfidenceFilter : null, - }) - setSegmentationQaResult(result) - await loadQualityChecks(selectedProjectId) - } catch (error) { - setSegmentationQaError(formatError(error, 'Segmentation QA failed')) - } finally { - setRunningSegmentationQa(false) - } - } - const pickDerivedDataset = async (datasetId: string) => { if (!selectedProjectId) { return diff --git a/frontend/src/hooks/useDetectionWorkflow.ts b/frontend/src/hooks/useDetectionWorkflow.ts new file mode 100644 index 00000000..c040db4b --- /dev/null +++ b/frontend/src/hooks/useDetectionWorkflow.ts @@ -0,0 +1,217 @@ +import { useState } from 'react' +import { detectionApi } from '../services/api' +import type { + DatasetCreateResponse, + DetectionModelCapability, + DetectionQaResult, + DetectionRead, + DetectionRunRead, + DetectionRunResponse, + QualityCheckRead, +} from '../types' +import { formatError } from '../lib/formatError' + +interface DetectionWorkflowOptions { + selectedProjectId: string | null + rasterDatasets: DatasetCreateResponse[] + qaIouThreshold: number + loadProjectData: (projectId: string) => Promise + loadQualityChecks: (projectId?: string | null) => Promise +} + +export function useDetectionWorkflow({ + selectedProjectId, + rasterDatasets, + qaIouThreshold, + loadProjectData, + loadQualityChecks, +}: DetectionWorkflowOptions) { + const [detectionModels, setDetectionModels] = useState([]) + const [loadingDetectionModels, setLoadingDetectionModels] = useState(false) + const [detectionModelError, setDetectionModelError] = useState(null) + const [selectedDetectionDatasetId, setSelectedDetectionDatasetId] = useState('') + const [selectedDetectionModelId, setSelectedDetectionModelId] = useState('yolo-placeholder') + const [detectionTileManifestPath, setDetectionTileManifestPath] = useState('') + const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.5) + const [runningDetection, setRunningDetection] = useState(false) + const [detectionRunResult, setDetectionRunResult] = useState(null) + const [detectionRunError, setDetectionRunError] = useState(null) + const [detectionRuns, setDetectionRuns] = useState([]) + const [selectedDetectionRunId, setSelectedDetectionRunId] = useState('') + const [detectionItems, setDetectionItems] = useState([]) + const [detectionGeoJson, setDetectionGeoJson] = useState(null) + const [detectionClassFilter, setDetectionClassFilter] = useState('') + const [detectionMinConfidenceFilter, setDetectionMinConfidenceFilter] = useState(0) + const [loadingDetectionResults, setLoadingDetectionResults] = useState(false) + const [detectionReferenceDatasetId, setDetectionReferenceDatasetId] = useState('') + const [detectionQaResult, setDetectionQaResult] = useState(null) + const [detectionQaError, setDetectionQaError] = useState(null) + const [runningDetectionQa, setRunningDetectionQa] = useState(false) + + const loadDetectionModels = async () => { + setLoadingDetectionModels(true) + setDetectionModelError(null) + try { + const response = await detectionApi.listModels() + setDetectionModels(response.models) + if (!response.models.some((model) => model.model_id === selectedDetectionModelId) && response.models.length > 0) { + setSelectedDetectionModelId(response.models[0].model_id) + } + } catch (error) { + setDetectionModelError(formatError(error, 'Failed to load detection models')) + } finally { + setLoadingDetectionModels(false) + } + } + + const loadDetectionRuns = async (projectId = selectedProjectId) => { + if (!projectId) { + setDetectionRuns([]) + return + } + try { + const response = await detectionApi.listRuns({ project_id: projectId }) + setDetectionRuns(response.items) + if (!selectedDetectionRunId && response.items.length > 0) { + setSelectedDetectionRunId(response.items[0].id) + } + } catch (error) { + setDetectionRunError(formatError(error, 'Failed to load detection runs')) + } + } + + const loadDetectionResults = async (analysisRunId = selectedDetectionRunId) => { + if (!analysisRunId) { + setDetectionItems([]) + setDetectionGeoJson(null) + return + } + setLoadingDetectionResults(true) + setDetectionRunError(null) + try { + const params = { + class_name: detectionClassFilter || null, + min_confidence: detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null, + } + const [detectionsResponse, geoJsonResponse] = await Promise.all([ + detectionApi.listDetections(analysisRunId, params), + detectionApi.getRunGeoJson(analysisRunId, params), + ]) + setDetectionItems(detectionsResponse.items) + setDetectionGeoJson(geoJsonResponse) + } catch (error) { + setDetectionRunError(formatError(error, 'Failed to load detection results')) + } finally { + setLoadingDetectionResults(false) + } + } + + const runDetection = async () => { + if (!selectedProjectId) { + setDetectionRunError('Select a project first') + return + } + const datasetId = selectedDetectionDatasetId || rasterDatasets[0]?.id + if (!datasetId) { + setDetectionRunError('Select a raster dataset') + return + } + setDetectionRunError(null) + setDetectionRunResult(null) + setRunningDetection(true) + try { + const result = await detectionApi.run({ + project_id: selectedProjectId, + dataset_id: datasetId, + model_id: selectedDetectionModelId, + 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) + } catch (error) { + setDetectionRunError(formatError(error, 'Detection run failed')) + } finally { + setRunningDetection(false) + } + } + + const runDetectionQa = async () => { + if (!selectedDetectionRunId) { + setDetectionQaError('Select a detection run') + return + } + if (!detectionReferenceDatasetId) { + setDetectionQaError('Select a reference dataset') + return + } + setDetectionQaError(null) + setDetectionQaResult(null) + setRunningDetectionQa(true) + try { + const result = await detectionApi.compareWithReference(selectedDetectionRunId, { + reference_dataset_id: detectionReferenceDatasetId, + iou_threshold: qaIouThreshold, + class_name: detectionClassFilter || null, + min_confidence: detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null, + }) + setDetectionQaResult(result) + await loadQualityChecks(selectedProjectId) + } catch (error) { + setDetectionQaError(formatError(error, 'Detection QA failed')) + } finally { + setRunningDetectionQa(false) + } + } + + const resetDetectionForProject = () => { + setSelectedDetectionDatasetId('') + setDetectionRuns([]) + setSelectedDetectionRunId('') + setDetectionItems([]) + setDetectionGeoJson(null) + setDetectionRunResult(null) + } + + return { + detectionModels, + loadingDetectionModels, + detectionModelError, + selectedDetectionDatasetId, + selectedDetectionModelId, + detectionTileManifestPath, + detectionConfidenceThreshold, + runningDetection, + detectionRunResult, + detectionRunError, + detectionRuns, + selectedDetectionRunId, + detectionItems, + detectionGeoJson, + detectionClassFilter, + detectionMinConfidenceFilter, + loadingDetectionResults, + detectionReferenceDatasetId, + detectionQaResult, + detectionQaError, + runningDetectionQa, + loadDetectionModels, + loadDetectionRuns, + loadDetectionResults, + runDetection, + runDetectionQa, + resetDetectionForProject, + setSelectedDetectionDatasetId, + setSelectedDetectionModelId, + setDetectionTileManifestPath, + setDetectionConfidenceThreshold, + setSelectedDetectionRunId, + setDetectionClassFilter, + setDetectionMinConfidenceFilter, + setDetectionReferenceDatasetId, + } +} diff --git a/frontend/src/hooks/useSegmentationWorkflow.ts b/frontend/src/hooks/useSegmentationWorkflow.ts new file mode 100644 index 00000000..08d568b5 --- /dev/null +++ b/frontend/src/hooks/useSegmentationWorkflow.ts @@ -0,0 +1,227 @@ +import { useMemo, useState } from 'react' +import { segmentationApi } from '../services/api' +import type { + DatasetCreateResponse, + QualityCheckRead, + SegmentationModelCapability, + SegmentationQaResult, + SegmentationRead, + SegmentationRunRead, + SegmentationRunResponse, +} from '../types' +import { formatError } from '../lib/formatError' + +interface SegmentationWorkflowOptions { + selectedProjectId: string | null + rasterDatasets: DatasetCreateResponse[] + qaIouThreshold: number + loadProjectData: (projectId: string) => Promise + loadQualityChecks: (projectId?: string | null) => Promise +} + +export function useSegmentationWorkflow({ + selectedProjectId, + rasterDatasets, + qaIouThreshold, + loadProjectData, + loadQualityChecks, +}: SegmentationWorkflowOptions) { + const [segmentationModels, setSegmentationModels] = useState([]) + const [loadingSegmentationModels, setLoadingSegmentationModels] = useState(false) + const [segmentationModelError, setSegmentationModelError] = useState(null) + const [selectedSegmentationDatasetId, setSelectedSegmentationDatasetId] = useState('') + const [selectedSegmentationModelId, setSelectedSegmentationModelId] = useState('segmentation-placeholder') + const [segmentationConfidenceThreshold, setSegmentationConfidenceThreshold] = useState(0.5) + const [runningSegmentation, setRunningSegmentation] = useState(false) + const [segmentationRunResult, setSegmentationRunResult] = useState(null) + const [segmentationRunError, setSegmentationRunError] = useState(null) + const [segmentationRuns, setSegmentationRuns] = useState([]) + const [selectedSegmentationRunId, setSelectedSegmentationRunId] = useState('') + const [segmentationItems, setSegmentationItems] = useState([]) + const [segmentationGeoJson, setSegmentationGeoJson] = useState(null) + const [segmentationClassFilter, setSegmentationClassFilter] = useState('') + const [segmentationMinConfidenceFilter, setSegmentationMinConfidenceFilter] = useState(0) + const [loadingSegmentationResults, setLoadingSegmentationResults] = useState(false) + const [segmentationReferenceDatasetId, setSegmentationReferenceDatasetId] = useState('') + const [segmentationQaResult, setSegmentationQaResult] = useState(null) + const [segmentationQaError, setSegmentationQaError] = useState(null) + const [runningSegmentationQa, setRunningSegmentationQa] = useState(false) + + const selectedSegmentationModel = useMemo( + () => segmentationModels.find((model) => model.model_id === selectedSegmentationModelId) ?? null, + [segmentationModels, selectedSegmentationModelId], + ) + + const loadSegmentationModels = async () => { + setLoadingSegmentationModels(true) + setSegmentationModelError(null) + try { + const response = await segmentationApi.listModels() + setSegmentationModels(response.models) + if (!response.models.some((model) => model.model_id === selectedSegmentationModelId) && response.models.length > 0) { + setSelectedSegmentationModelId(response.models[0].model_id) + } + } catch (error) { + setSegmentationModelError(formatError(error, 'Failed to load segmentation models')) + } finally { + setLoadingSegmentationModels(false) + } + } + + const loadSegmentationRuns = async (projectId = selectedProjectId) => { + if (!projectId) { + setSegmentationRuns([]) + return + } + try { + const response = await segmentationApi.listRuns({ project_id: projectId }) + setSegmentationRuns(response.items) + if (!selectedSegmentationRunId && response.items.length > 0) { + setSelectedSegmentationRunId(response.items[0].id) + } + } catch (error) { + setSegmentationRunError(formatError(error, 'Failed to load segmentation runs')) + } + } + + const loadSegmentationResults = async (analysisRunId = selectedSegmentationRunId) => { + if (!analysisRunId) { + setSegmentationItems([]) + setSegmentationGeoJson(null) + return + } + setLoadingSegmentationResults(true) + setSegmentationRunError(null) + try { + const params = { + class_name: segmentationClassFilter || null, + min_confidence: segmentationMinConfidenceFilter > 0 ? segmentationMinConfidenceFilter : null, + } + const [segmentationsResponse, geoJsonResponse] = await Promise.all([ + segmentationApi.listSegmentations(analysisRunId, params), + segmentationApi.getRunGeoJson(analysisRunId, params), + ]) + setSegmentationItems(segmentationsResponse.items) + setSegmentationGeoJson(geoJsonResponse) + } catch (error) { + setSegmentationRunError(formatError(error, 'Failed to load segmentation results')) + } finally { + setLoadingSegmentationResults(false) + } + } + + const runSegmentation = async () => { + if (!selectedProjectId) { + setSegmentationRunError('Select a project first') + return + } + const datasetId = selectedSegmentationDatasetId || rasterDatasets[0]?.id + if (!datasetId) { + setSegmentationRunError('Select a raster dataset') + return + } + if (!selectedSegmentationModel?.configured) { + setSegmentationRunError('Selected segmentation model is not configured') + return + } + setSegmentationRunError(null) + setSegmentationRunResult(null) + setRunningSegmentation(true) + try { + const parameters = + selectedSegmentationModelId === 'fixture-segmenter' + ? { fixture_mode: true, fixture_segmentations: [] } + : {} + const result = await segmentationApi.run({ + project_id: selectedProjectId, + dataset_id: datasetId, + model_id: selectedSegmentationModelId, + confidence_threshold: segmentationConfidenceThreshold, + parameters_json: parameters, + }) + setSegmentationRunResult(result) + setSelectedSegmentationRunId(result.analysis_run_id) + await loadSegmentationRuns(selectedProjectId) + await loadSegmentationResults(result.analysis_run_id) + await loadProjectData(selectedProjectId) + } catch (error) { + setSegmentationRunError(formatError(error, 'Segmentation run failed')) + } finally { + setRunningSegmentation(false) + } + } + + const runSegmentationQa = async () => { + if (!selectedSegmentationRunId) { + setSegmentationQaError('Select a segmentation run') + return + } + if (!segmentationReferenceDatasetId) { + setSegmentationQaError('Select a reference dataset') + return + } + setSegmentationQaError(null) + setSegmentationQaResult(null) + setRunningSegmentationQa(true) + try { + const result = await segmentationApi.compareWithReference(selectedSegmentationRunId, { + reference_dataset_id: segmentationReferenceDatasetId, + iou_threshold: qaIouThreshold, + class_name: segmentationClassFilter || null, + min_confidence: segmentationMinConfidenceFilter > 0 ? segmentationMinConfidenceFilter : null, + }) + setSegmentationQaResult(result) + await loadQualityChecks(selectedProjectId) + } catch (error) { + setSegmentationQaError(formatError(error, 'Segmentation QA failed')) + } finally { + setRunningSegmentationQa(false) + } + } + + const resetSegmentationForProject = () => { + setSelectedSegmentationDatasetId('') + setSegmentationRuns([]) + setSelectedSegmentationRunId('') + setSegmentationItems([]) + setSegmentationGeoJson(null) + setSegmentationRunResult(null) + } + + return { + segmentationModels, + loadingSegmentationModels, + segmentationModelError, + selectedSegmentationDatasetId, + selectedSegmentationModelId, + selectedSegmentationModel, + segmentationConfidenceThreshold, + runningSegmentation, + segmentationRunResult, + segmentationRunError, + segmentationRuns, + selectedSegmentationRunId, + segmentationItems, + segmentationGeoJson, + segmentationClassFilter, + segmentationMinConfidenceFilter, + loadingSegmentationResults, + segmentationReferenceDatasetId, + segmentationQaResult, + segmentationQaError, + runningSegmentationQa, + loadSegmentationModels, + loadSegmentationRuns, + loadSegmentationResults, + runSegmentation, + runSegmentationQa, + resetSegmentationForProject, + setSelectedSegmentationDatasetId, + setSelectedSegmentationModelId, + setSegmentationConfidenceThreshold, + setSelectedSegmentationRunId, + setSegmentationClassFilter, + setSegmentationMinConfidenceFilter, + setSegmentationReferenceDatasetId, + } +} diff --git a/frontend/src/lib/formatError.ts b/frontend/src/lib/formatError.ts new file mode 100644 index 00000000..e051a4c7 --- /dev/null +++ b/frontend/src/lib/formatError.ts @@ -0,0 +1,7 @@ +export function formatError(error: unknown, fallback: string): string { + if (error instanceof Error) { + const code = (error as { code?: string }).code + return code ? `${error.message} (${code})` : error.message + } + return fallback +}