429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
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<unknown>
|
|
loadQualityChecks: (projectId?: string | null) => Promise<QualityCheckRead[] | void>
|
|
}
|
|
|
|
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<DetectionModelCapability[]>([])
|
|
const [modelAssets, setModelAssets] = useState<ModelAssetRead[]>([])
|
|
const [loadingDetectionModels, setLoadingDetectionModels] = useState(false)
|
|
const [detectionModelError, setDetectionModelError] = useState<string | null>(null)
|
|
const [modelAssetError, setModelAssetError] = useState<string | null>(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<DetectionRunResponse | null>(null)
|
|
const [detectionRunError, setDetectionRunError] = useState<string | null>(null)
|
|
const [detectionRuns, setDetectionRuns] = useState<DetectionRunRead[]>([])
|
|
const [selectedDetectionRunId, setSelectedDetectionRunId] = useState('')
|
|
const [detectionItems, setDetectionItems] = useState<DetectionRead[]>([])
|
|
const [detectionGeoJson, setDetectionGeoJson] = useState<GeoJSON.FeatureCollection | null>(null)
|
|
const [detectionClassFilter, setDetectionClassFilter] = useState('')
|
|
const [detectionMinConfidenceFilter, setDetectionMinConfidenceFilter] = useState(0)
|
|
const [loadingDetectionResults, setLoadingDetectionResults] = useState(false)
|
|
const [detectionReferenceDatasetId, setDetectionReferenceDatasetId] = useState('')
|
|
const [detectionQaResult, setDetectionQaResult] = useState<DetectionQaResult | null>(null)
|
|
const [detectionQaError, setDetectionQaError] = useState<string | null>(null)
|
|
const [runningDetectionQa, setRunningDetectionQa] = useState(false)
|
|
const [yoloPreflight, setYoloPreflight] = useState<YoloPreflightResponse | null>(null)
|
|
const [loadingYoloPreflight, setLoadingYoloPreflight] = useState(false)
|
|
const [yoloPreflightError, setYoloPreflightError] = useState<string | null>(null)
|
|
const [calibrationThresholdText, setCalibrationThresholdText] = useState('0.50 0.25 0.15')
|
|
const [runningDetectionCalibration, setRunningDetectionCalibration] = useState(false)
|
|
const [detectionCalibrationRows, setDetectionCalibrationRows] = useState<DetectionCalibrationRunRow[]>([])
|
|
const [detectionCalibrationError, setDetectionCalibrationError] = useState<string | null>(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,
|
|
}
|
|
}
|