import { useEffect, useState } from 'react' import { detectionApi } from '../services/api' import type { DatasetCreateResponse, DetectionModelCapability, DetectionQaResult, DetectionRead, DetectionRunRead, DetectionRunResponse, ModelAssetRead, QualityCheckRead, YoloPreflightResponse, } 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 } interface DetectionOperatorProfileSelection { modelAssetId: string confidenceThreshold: number } export interface DetectionCalibrationRunRow { threshold: number status: 'queued' | 'running' | 'success' | 'failed' analysis_run_id?: string | null job_id?: string | null quality_check_id?: string | null detection_count?: number | null precision?: number | null recall?: number | null f1_score?: number | null false_positives?: number | null false_negatives?: number | null message?: string | null } function parseCalibrationThresholds(value: string): number[] { const tokens = value .split(/[\s,;]+/) .map((token) => token.trim()) .filter(Boolean) const thresholds: number[] = [] for (const token of tokens) { const threshold = Number(token) if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) { return [] } if (!thresholds.includes(threshold)) { thresholds.push(threshold) } } return thresholds } export function useDetectionWorkflow({ selectedProjectId, rasterDatasets, qaIouThreshold, loadProjectData, loadQualityChecks, }: DetectionWorkflowOptions) { const [detectionModels, setDetectionModels] = useState([]) const [modelAssets, setModelAssets] = useState([]) const [loadingDetectionModels, setLoadingDetectionModels] = useState(false) const [detectionModelError, setDetectionModelError] = useState(null) const [modelAssetError, setModelAssetError] = useState(null) const [selectedDetectionDatasetId, setSelectedDetectionDatasetId] = useState('') const [selectedDetectionModelId, setSelectedDetectionModelId] = useState('yolo-configured') const [selectedModelAssetId, setSelectedModelAssetId] = useState('') const [detectionTileManifestPath, setDetectionTileManifestPath] = useState('') const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.15) 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 [yoloPreflight, setYoloPreflight] = useState(null) const [loadingYoloPreflight, setLoadingYoloPreflight] = useState(false) const [yoloPreflightError, setYoloPreflightError] = useState(null) const [calibrationThresholdText, setCalibrationThresholdText] = useState('0.50 0.25 0.15') const [runningDetectionCalibration, setRunningDetectionCalibration] = useState(false) const [detectionCalibrationRows, setDetectionCalibrationRows] = useState([]) const [detectionCalibrationError, setDetectionCalibrationError] = useState(null) useEffect(() => { if (!selectedDetectionDatasetId && rasterDatasets.length > 0) { setSelectedDetectionDatasetId(rasterDatasets[0].id) } }, [rasterDatasets, selectedDetectionDatasetId]) const loadDetectionModels = async () => { setLoadingDetectionModels(true) setDetectionModelError(null) setModelAssetError(null) try { const response = await detectionApi.listModels() setDetectionModels(response.models) const selectedModelStillAvailable = response.models.some((model) => model.model_id === selectedDetectionModelId) if (!selectedModelStillAvailable && response.models.length > 0) { const configuredModel = response.models.find( (model) => model.configured && model.model_id !== 'manual-fixture-detector', ) setSelectedDetectionModelId(configuredModel?.model_id ?? response.models[0].model_id) } } catch (error) { setDetectionModelError(formatError(error, 'Failed to load detection models')) } try { const assetResponse = await detectionApi.listModelAssets() setModelAssets(assetResponse.items) const activeAsset = assetResponse.items.find((asset) => asset.active) ?? null const selectedAssetStillAvailable = assetResponse.items.some((asset) => asset.model_asset_id === selectedModelAssetId) const nextAssetId = selectedAssetStillAvailable ? selectedModelAssetId : activeAsset?.model_asset_id ?? '' setSelectedModelAssetId(nextAssetId) try { const preflight = await detectionApi.getYoloPreflight({ model_asset_id: nextAssetId || null }) setYoloPreflight(preflight) setYoloPreflightError(null) } catch (error) { setYoloPreflightError(formatError(error, 'Failed to load YOLO preflight status')) } } catch (error) { setModelAssets([]) setModelAssetError(formatError(error, 'Failed to load local model assets')) } finally { setLoadingDetectionModels(false) } } const loadYoloPreflight = async (tileManifestPath = detectionTileManifestPath) => { setLoadingYoloPreflight(true) setYoloPreflightError(null) try { const response = await detectionApi.getYoloPreflight({ tile_manifest_path: tileManifestPath.trim() || null, model_asset_id: selectedModelAssetId || null, }) setYoloPreflight(response) } catch (error) { setYoloPreflightError(formatError(error, 'Failed to load YOLO preflight status')) } finally { setLoadingYoloPreflight(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, 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) } 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 runDetectionCalibration = async () => { if (!selectedProjectId) { setDetectionCalibrationError('Select a project before calibration') return } const datasetId = selectedDetectionDatasetId || rasterDatasets[0]?.id if (!datasetId) { setDetectionCalibrationError('Select a raster dataset before calibration') return } if (!detectionReferenceDatasetId) { setDetectionCalibrationError('Select a reference dataset before calibration') return } const selectedModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId) if (!selectedModel?.configured || selectedDetectionModelId === 'manual-fixture-detector') { setDetectionCalibrationError('Select a configured non-fixture detection model before calibration') return } if (selectedDetectionModelId === 'yolo-configured' && !detectionTileManifestPath.trim()) { setDetectionCalibrationError('Configured YOLO calibration requires a tile manifest') return } if (selectedDetectionModelId === 'yolo-configured' && modelAssets.length > 0 && !selectedModelAssetId) { setDetectionCalibrationError('Select a local model asset before calibration') return } const thresholds = parseCalibrationThresholds(calibrationThresholdText) if (thresholds.length === 0) { setDetectionCalibrationError('Provide at least one valid threshold between 0 and 1') return } setDetectionCalibrationError(null) setDetectionCalibrationRows(thresholds.map((threshold) => ({ threshold, status: 'queued' }))) setRunningDetectionCalibration(true) try { for (const threshold of thresholds) { setDetectionCalibrationRows((rows) => rows.map((row) => row.threshold === threshold ? { ...row, status: 'running', message: 'Running detection' } : row), ) try { const result = await detectionApi.run({ project_id: selectedProjectId, dataset_id: datasetId, model_id: selectedDetectionModelId, model_asset_id: selectedModelAssetId || null, confidence_threshold: threshold, tile_manifest_path: detectionTileManifestPath.trim() || null, parameters_json: { calibration: true, calibration_thresholds: thresholds }, }) setSelectedDetectionRunId(result.analysis_run_id) const qa = await detectionApi.compareWithReference(result.analysis_run_id, { reference_dataset_id: detectionReferenceDatasetId, iou_threshold: qaIouThreshold, class_name: detectionClassFilter || null, min_confidence: null, }) setDetectionCalibrationRows((rows) => rows.map((row) => row.threshold === threshold ? { ...row, status: 'success', analysis_run_id: result.analysis_run_id, job_id: result.job_id, quality_check_id: qa.quality_check_id, detection_count: result.detection_count, precision: qa.precision ?? null, recall: qa.recall ?? null, f1_score: qa.f1_score ?? null, false_positives: qa.false_positives, false_negatives: qa.false_negatives, message: result.message, } : row), ) } catch (error) { const message = formatError(error, `Calibration threshold ${threshold} failed`) setDetectionCalibrationRows((rows) => rows.map((row) => row.threshold === threshold ? { ...row, status: 'failed', message } : row), ) setDetectionCalibrationError(message) break } } await loadDetectionRuns(selectedProjectId) await loadQualityChecks(selectedProjectId) await loadProjectData(selectedProjectId) } finally { setRunningDetectionCalibration(false) } } const applyDetectionOperatorProfile = (profile: DetectionOperatorProfileSelection) => { setSelectedDetectionModelId('yolo-configured') setSelectedModelAssetId(profile.modelAssetId) setDetectionConfidenceThreshold(profile.confidenceThreshold) } const resetDetectionForProject = () => { setSelectedDetectionDatasetId('') setDetectionRuns([]) setSelectedDetectionRunId('') setDetectionItems([]) setDetectionGeoJson(null) setDetectionRunResult(null) setDetectionCalibrationRows([]) setDetectionCalibrationError(null) } return { detectionModels, modelAssets, loadingDetectionModels, detectionModelError, modelAssetError, selectedDetectionDatasetId, selectedDetectionModelId, selectedModelAssetId, detectionTileManifestPath, detectionConfidenceThreshold, runningDetection, detectionRunResult, detectionRunError, detectionRuns, selectedDetectionRunId, detectionItems, detectionGeoJson, detectionClassFilter, detectionMinConfidenceFilter, loadingDetectionResults, detectionReferenceDatasetId, detectionQaResult, detectionQaError, runningDetectionQa, yoloPreflight, loadingYoloPreflight, yoloPreflightError, calibrationThresholdText, runningDetectionCalibration, detectionCalibrationRows, detectionCalibrationError, loadDetectionModels, loadYoloPreflight, loadDetectionRuns, loadDetectionResults, runDetection, runDetectionQa, runDetectionCalibration, applyDetectionOperatorProfile, resetDetectionForProject, setSelectedDetectionDatasetId, setSelectedDetectionModelId, setSelectedModelAssetId, setDetectionTileManifestPath, setDetectionConfidenceThreshold, setSelectedDetectionRunId, setDetectionClassFilter, setDetectionMinConfidenceFilter, setDetectionReferenceDatasetId, setCalibrationThresholdText, } }