import { useEffect, useState } from 'react' import type { DatasetCreateResponse, DetectionModelCapability, DetectionQaResult, DetectionRead, DetectionRunRead, DetectionRunResponse, ModelAssetRead, QualityCheckRead, YoloPreflightResponse, } from '../../types' import type { DetectionCalibrationRunRow } from '../../hooks/useDetectionWorkflow' import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles' const DETECTION_PAGE_SIZE_OPTIONS = [25, 50, 100] as const const DEFAULT_DETECTION_PAGE_SIZE = 50 interface CalibrationRow { analysisRunId: string qualityCheckId: string threshold: number | null modelName: string modelAssetId: string | null detectionCount: number | null precision: number | null recall: number | null f1: number | null falsePositives: number | null falseNegatives: number | null score: number | null createdAt: string | null } interface DetectionLabProps { detectionModels: DetectionModelCapability[] modelAssets: ModelAssetRead[] loadingDetectionModels: boolean detectionModelError: string | null modelAssetError: string | null selectedDetectionDatasetId: string selectedDetectionModelId: string selectedModelAssetId: string detectionTileManifestPath: string detectionConfidenceThreshold: number runningDetection: boolean detectionRunResult: DetectionRunResponse | null detectionRunError: string | null detectionRuns: DetectionRunRead[] qualityChecks: QualityCheckRead[] calibrationThresholdText: string runningDetectionCalibration: boolean detectionCalibrationRows: DetectionCalibrationRunRow[] detectionCalibrationError: string | null selectedDetectionRunId: string detectionItems: DetectionRead[] detectionClassFilter: string detectionMinConfidenceFilter: number loadingDetectionResults: boolean detectionReferenceDatasetId: string detectionQaResult: DetectionQaResult | null detectionQaError: string | null runningDetectionQa: boolean yoloPreflight: YoloPreflightResponse | null loadingYoloPreflight: boolean yoloPreflightError: string | null selectedProjectId: string | null rasterDatasets: DatasetCreateResponse[] referenceDatasets: DatasetCreateResponse[] onLoadModels: () => void onRefreshYoloPreflight: () => void onSelectDataset: (datasetId: string) => void onSelectModel: (modelId: string) => void onSelectModelAsset: (modelAssetId: string) => void onSetConfidenceThreshold: (value: number) => void onSetTileManifestPath: (value: string) => void onRunDetection: () => void onLoadRuns: () => void onSelectRun: (runId: string) => void onSetClassFilter: (value: string) => void onSetMinConfidenceFilter: (value: number) => void onLoadResults: () => void onSelectReferenceDataset: (datasetId: string) => void onRunQa: () => void onSetCalibrationThresholdText: (value: string) => void onRunCalibration: () => void onOpenCalibrationEvidence: (qualityCheckId: string) => void onApplyOperatorProfile: (profile: DetectionOperatorProfile) => void } export function DetectionLab({ detectionModels, modelAssets, loadingDetectionModels, detectionModelError, modelAssetError, selectedDetectionDatasetId, selectedDetectionModelId, selectedModelAssetId, detectionTileManifestPath, detectionConfidenceThreshold, runningDetection, detectionRunResult, detectionRunError, detectionRuns, qualityChecks, calibrationThresholdText, runningDetectionCalibration, detectionCalibrationRows, detectionCalibrationError, selectedDetectionRunId, detectionItems, detectionClassFilter, detectionMinConfidenceFilter, loadingDetectionResults, detectionReferenceDatasetId, detectionQaResult, detectionQaError, runningDetectionQa, yoloPreflight, loadingYoloPreflight, yoloPreflightError, selectedProjectId, rasterDatasets, referenceDatasets, onLoadModels, onRefreshYoloPreflight, onSelectDataset, onSelectModel, onSelectModelAsset, onSetConfidenceThreshold, onSetTileManifestPath, onRunDetection, onLoadRuns, onSelectRun, onSetClassFilter, onSetMinConfidenceFilter, onLoadResults, onSelectReferenceDataset, onRunQa, onSetCalibrationThresholdText, onRunCalibration, onOpenCalibrationEvidence, onApplyOperatorProfile, }: DetectionLabProps): JSX.Element { const selectedDetectionModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId) ?? null const selectedModelAsset = modelAssets.find((asset) => asset.model_asset_id === selectedModelAssetId) ?? null const detectionRequiresTileManifest = selectedDetectionModelId === 'yolo-configured' const detectionHasDataset = selectedDetectionDatasetId.length > 0 const detectionHasModel = selectedDetectionModel !== null const detectionModelReady = Boolean(selectedDetectionModel?.configured) const detectionModelUiRunnable = detectionModelReady && selectedDetectionModelId !== 'manual-fixture-detector' const detectionHasExplicitModelAsset = selectedDetectionModelId !== 'yolo-configured' || modelAssets.length === 0 || selectedModelAssetId.length > 0 const calibrationRows = buildCalibrationRows(detectionRuns, qualityChecks) const bestF1Candidate = bestCalibrationRow(calibrationRows, 'f1') const bestPrecisionCandidate = bestCalibrationRow(calibrationRows, 'precision') const lowestFalsePositivePressureCandidate = bestLowestCalibrationRow(calibrationRows, 'falsePositives') const [detectionResultPage, setDetectionResultPage] = useState(1) const [detectionPageSize, setDetectionPageSize] = useState(DEFAULT_DETECTION_PAGE_SIZE) const detectionPageCount = Math.max(1, Math.ceil(detectionItems.length / detectionPageSize)) const currentDetectionPage = Math.min(detectionResultPage, detectionPageCount) const detectionPageStart = (currentDetectionPage - 1) * detectionPageSize const detectionPageEnd = Math.min(detectionPageStart + detectionPageSize, detectionItems.length) const visibleDetectionItems = detectionItems.slice(detectionPageStart, detectionPageEnd) useEffect(() => { setDetectionResultPage(1) }, [selectedDetectionRunId, detectionClassFilter, detectionMinConfidenceFilter, detectionItems]) const detectionHasTileManifest = !detectionRequiresTileManifest || detectionTileManifestPath.trim().length > 0 const detectionRunReady = Boolean(selectedProjectId) && detectionHasDataset && detectionHasModel && detectionModelUiRunnable && detectionHasExplicitModelAsset && detectionHasTileManifest const detectionRunBlockedReason = !selectedProjectId ? 'Select or create a project first' : !detectionHasDataset ? 'Select a raster dataset' : !detectionHasModel ? 'Select a detection model' : selectedDetectionModelId === 'manual-fixture-detector' ? 'Fixture model is explicit test/demo-only' : !detectionModelReady ? selectedDetectionModel?.limitation_message ?? 'Selected model is not configured' : !detectionHasExplicitModelAsset ? 'Select a local model asset deliberately' : !detectionHasTileManifest ? 'Provide a raster tile manifest for configured YOLO' : null const calibrationRunReady = detectionRunReady && detectionReferenceDatasetId.length > 0 && calibrationThresholdText.trim().length > 0 return (

Object detection

Detection Lab

Model registry

Backend-reported detector states and limitations.

{loadingDetectionModels ? (
Loading detection models.

Checking backend model registry availability.

) : null} {detectionModelError ? (
Detection model registry unavailable.

{detectionModelError}

) : null} {modelAssetError ? (
Local model assets unavailable.

{modelAssetError}

) : null} {detectionModels.length === 0 && !loadingDetectionModels ? (
No detection models reported by backend.

Refresh models after the backend is reachable.

) : null}
{selectedDetectionModelId === 'yolo-configured' ? (

Explicit model asset

Local model assets are read-only. No model file is selected automatically. Choose an existing local model asset before submitting configured YOLO.

{selectedModelAsset ? 'asset selected' : 'no explicit asset'}
Operator profiles

Candidate profiles apply a local model asset and confidence threshold only after an explicit click. Promoted profiles still require explicit operator action and do not mutate the runtime environment.

{DETECTION_OPERATOR_PROFILES.map((profile) => { const profileAsset = modelAssets.find((asset) => asset.model_asset_id === profile.modelAssetId) const profileSelected = selectedModelAssetId === profile.modelAssetId && Math.abs(detectionConfidenceThreshold - profile.confidenceThreshold) < 0.0001 return (
{profile.displayName} {profile.defaultApproved ? 'default-approved' : 'Candidate only - not default-approved'}

{profile.description}

threshold {profile.confidenceThreshold.toFixed(2)} precision {profile.precision.toFixed(3)} recall {profile.recall.toFixed(3)} F1 {profile.f1.toFixed(3)} positive AOIs {profile.positiveSampleCount} max background FP {profile.maxBackgroundDetections}
asset: {profile.modelAssetId} promotionRecommendation: {profile.promotionRecommendation} available: {profileAsset ? 'yes' : 'not mounted'}

{profile.limitationMessage}

) })}
{selectedModelAsset ? (
Selected model asset status

{selectedModelAsset.display_name} is selected for this browser-run request. Runtime default activation remains a separate guarded operator action backed by a promotion report.

) : null} {modelAssets.length === 0 && !loadingDetectionModels ? (
No local model assets found.

Mount model files into the backend model directory or continue with the configured YOLO_MODEL_PATH.

) : null} {selectedModelAsset ? (

File: {selectedModelAsset.filename}

Status: {selectedModelAsset.status}

Active runtime env model: {selectedModelAsset.active ? 'yes' : 'no'}

will_download_models: {selectedModelAsset.will_download_models ? 'yes' : 'no'}

Size: {formatModelAssetSize(selectedModelAsset.size_bytes)}

SHA-256: {selectedModelAsset.sha256.slice(0, 12)}

Path: {selectedModelAsset.model_path}

{selectedModelAsset.limitation_message}

) : null}
) : null}

YOLO runtime preflight

Read-only runtime status. This does not load a model, run inference or download weights.

{loadingYoloPreflight ? (
Loading YOLO preflight.

Checking backend runtime configuration and optional dependency visibility.

) : null} {yoloPreflightError ? (
YOLO preflight unavailable.

{yoloPreflightError}

) : null} {!yoloPreflight && !loadingYoloPreflight && !yoloPreflightError ? (
No YOLO preflight loaded.

Refresh preflight to inspect the live backend AI runtime before running configured YOLO.

) : null}
{yoloPreflight ? (

Status: {yoloPreflight.status}

{yoloPreflight.message}

{yoloPreflight.checks.dependencies_available ? 'dependencies visible' : 'not ready'}
YOLO enabled {yoloPreflight.checks.enabled ? 'true' : 'false'}
Dependencies {yoloPreflight.checks.dependencies_available === true ? 'available' : yoloPreflight.checks.dependencies_available === false ? 'unavailable' : 'not checked'}
Local model file {yoloPreflight.checks.model_file_exists === true ? 'found' : yoloPreflight.checks.model_path_set ? 'missing' : 'not configured'}
CUDA {yoloPreflight.runtime.cuda_available === true ? 'available' : yoloPreflight.runtime.cuda_available === false ? 'not available' : 'not checked'}
Tile manifest validation {yoloPreflight.checks.manifest_valid === true ? 'valid' : yoloPreflight.checks.manifest_path_set ? 'not valid' : 'not provided'}
0 ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}> Tile count {yoloPreflight.tile_count} / {yoloPreflight.max_tiles}
torch_version: {yoloPreflight.runtime.torch_version ?? 'n/a'} ultralytics_version: {yoloPreflight.runtime.ultralytics_version ?? 'n/a'} cuda_available: {String(yoloPreflight.runtime.cuda_available ?? 'unknown')} will_run_inference: {String(yoloPreflight.will_run_inference)} YOLO_CONFIG_DIR: {yoloPreflight.runtime.yolo_config_dir ?? 'n/a'} model directory: {yoloPreflight.runtime.model_directory ?? 'n/a'} model_asset_id: {yoloPreflight.model_asset_id ?? 'n/a'}
) : null}

Run detection

Run readiness

Checks the selected dataset, model and tile manifest before submitting a detection job.

{detectionRunReady ? 'Ready to submit' : 'Blocked'}
Raster dataset {detectionHasDataset ? 'Selected' : 'Select a raster dataset'}
Model availability {detectionModelReady ? `${selectedDetectionModel?.display_name ?? selectedDetectionModelId} is configured` : selectedDetectionModel?.limitation_message ?? 'Select a configured model'}
Tile manifest {detectionRequiresTileManifest ? detectionHasTileManifest ? 'Provided for configured YOLO' : 'Required for configured YOLO' : 'Not required for this model'}
Local model asset {selectedDetectionModelId === 'yolo-configured' ? selectedModelAsset ? selectedModelAsset.display_name : modelAssets.length > 0 ? 'Select a local model asset deliberately' : 'No local assets reported; backend configured path only' : 'Not required for this model'}
Run action {detectionRunReady ? 'Ready to submit a detection job' : detectionRunBlockedReason}
{rasterDatasets.length === 0 ? (
No raster datasets available for detection.

Upload or select a raster dataset in Data before running object detection.

) : null}
{selectedDetectionModelId === 'yolo-configured' ? ( ) : null} {selectedDetectionModelId === 'yolo-configured' && detectionTileManifestPath.trim() ? (
Linked tile manifest

{detectionTileManifestPath}

Refresh preflight after changing model asset or manifest path.
) : null}
{detectionRunError ? (
Detection run failed.

{detectionRunError}

) : null} {detectionRunResult ? (

Status: {detectionRunResult.status}

Message: {detectionRunResult.message}

Analysis run: {detectionRunResult.analysis_run_id}

Job: {detectionRunResult.job_id}

Detections: {detectionRunResult.detection_count}

{detectionRunResult.error_code ?

Code: {detectionRunResult.error_code}

: null}
) : null}

Guided calibration runner

This runs real configured YOLO jobs and QA comparisons for each threshold. It does not promote or mutate model files.

{calibrationRunReady ? 'ready' : 'needs dataset, model, manifest and reference'}
{detectionCalibrationError ? (
Calibration sweep failed.

{detectionCalibrationError}

) : null} {detectionCalibrationRows.length > 0 ? (

Calibration run progress

Each row is backed by a persisted detection run and QA check when successful.

{detectionCalibrationRows.length} thresholds
{detectionCalibrationRows.map((row) => ( ))}
Threshold Status Detections Precision Recall F1 False positives False negatives Evidence
{row.threshold.toFixed(2)} {row.status} {row.detection_count ?? 'n/a'} {formatNullableNumber(row.precision ?? null, 3)} {formatNullableNumber(row.recall ?? null, 3)} {formatNullableNumber(row.f1_score ?? null, 3)} {row.false_positives ?? 'n/a'} {row.false_negatives ?? 'n/a'}
) : (
No calibration sweep has been run in this session.

Choose a reference dataset and threshold set, then start the explicit sweep.

)}

Detection results

Load persisted detections and filter by class or confidence.

{loadingDetectionResults ? (
Loading detection results.

Retrieving persisted detections for the selected run.

) : null}
Detections loaded: {detectionItems.length}

{selectedDetectionRunId ? 'Loaded from persisted detection records.' : 'Select a detection run before loading results.'}

{detectionItems.length > 0 ? ( <>

{detectionPageStart + 1}-{detectionPageEnd} of {detectionItems.length}

Page {currentDetectionPage} of {detectionPageCount}
{visibleDetectionItems.map((detection) => ( ))}
Class Confidence Model Source tile
{detection.class_name} {detection.confidence.toFixed(2)} {detection.model_name} {detection.source_tile_path || 'n/a'}
) : null}

Calibration comparison

Compare persisted detection runs by confidence threshold before promoting a model setting.

{calibrationRows.length} rows
{calibrationRows.length > 0 ? ( <>
{calibrationRows.map((row) => ( ))}
Threshold Model Detections Precision Recall F1 False positives False negatives Quality check
{formatNullableNumber(row.threshold, 2)} {row.modelName} {row.modelAssetId ?? 'runtime configured path'} {row.detectionCount ?? 'n/a'} {formatNullableNumber(row.precision, 3)} {formatNullableNumber(row.recall, 3)} {formatNullableNumber(row.f1, 3)} {row.falsePositives ?? 'n/a'} {row.falseNegatives ?? 'n/a'} {row.qualityCheckId}
Promotion guardrail Promote only after checking evidence across AOIs, false positives and false negatives.
) : (
No calibration comparison available yet.

Run configured YOLO at multiple confidence thresholds, then compare each persisted detection run against the same reference dataset.

)}

Detection QA

{detectionQaError ? (
Detection QA failed.

{detectionQaError}

) : null} {detectionQaResult ? (

Status: {detectionQaResult.status}

Quality check: {detectionQaResult.quality_check_id}

Precision: {detectionQaResult.precision?.toFixed(3) ?? 'n/a'}

Recall: {detectionQaResult.recall?.toFixed(3) ?? 'n/a'}

F1: {detectionQaResult.f1_score?.toFixed(3) ?? 'n/a'}

Mean IoU: {detectionQaResult.mean_iou?.toFixed(3) ?? 'n/a'}

False positives: {detectionQaResult.false_positives}

False negatives: {detectionQaResult.false_negatives}

) : null}
) } function CalibrationSummaryCard({ title, row, metric, }: { title: string row: CalibrationRow | null metric: 'f1' | 'precision' | 'falsePositives' }): JSX.Element { return (
{title} {row ? ( <> {metric === 'falsePositives' ? row.falsePositives ?? 'n/a' : formatNullableNumber(row[metric], 3)}

Threshold {formatNullableNumber(row.threshold, 2)} ยท {row.modelName}

) : ( <> n/a

Persisted QA metrics are required.

)}
) } function formatModelAssetSize(sizeBytes: number): string { if (sizeBytes >= 1024 * 1024) { return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB` } if (sizeBytes >= 1024) { return `${(sizeBytes / 1024).toFixed(1)} KB` } return `${sizeBytes} B` } function buildCalibrationRows(detectionRuns: DetectionRunRead[], qualityChecks: QualityCheckRead[]): CalibrationRow[] { const runById = new Map(detectionRuns.map((run) => [run.id, run])) return qualityChecks .flatMap((check) => { const run = check.analysis_run_id ? runById.get(check.analysis_run_id) : null if (!run || run.analysis_type !== 'detection') { return [] } const threshold = confidenceThresholdForRun(run) return [{ analysisRunId: run.id, qualityCheckId: check.id, threshold, modelName: run.model_name ?? 'configured detection', modelAssetId: stringFromRecord(run.parameters_json, 'model_asset_id'), detectionCount: numberFromRecord(run.result_json, 'detection_count'), precision: metricValue(check, 'precision'), recall: metricValue(check, 'recall'), f1: metricValue(check, 'f1'), falsePositives: metricValue(check, 'false_positives'), falseNegatives: metricValue(check, 'false_negatives'), score: check.score ?? null, createdAt: check.completed_at ?? check.created_at ?? run.finished_at ?? run.created_at ?? null, }] }) .sort((left, right) => { const createdDiff = Date.parse(right.createdAt ?? '') - Date.parse(left.createdAt ?? '') if (Number.isFinite(createdDiff) && createdDiff !== 0) { return createdDiff } return (right.threshold ?? -1) - (left.threshold ?? -1) }) } function confidenceThresholdForRun(run: DetectionRunRead): number | null { return ( numberFromRecord(run.parameters_json, 'confidence_threshold') ?? numberFromRecord(run.result_json, 'confidence_threshold') ?? null ) } function metricValue(check: QualityCheckRead, key: string): number | null { const aliases = key === 'f1' ? ['f1', 'f1_score'] : [key] for (const alias of aliases) { const metric = check.metrics.find((item) => item.metric_key === alias) if (typeof metric?.metric_value === 'number' && Number.isFinite(metric.metric_value)) { return metric.metric_value } const findingValue = numberFromRecord(check.findings_json, alias) if (findingValue !== null) { return findingValue } } return key === 'f1' && typeof check.score === 'number' ? check.score : null } function bestCalibrationRow(rows: CalibrationRow[], metric: 'f1' | 'precision'): CalibrationRow | null { return rows.reduce((best, row) => { const value = row[metric] if (value === null) { return best } if (!best || value > (best[metric] ?? Number.NEGATIVE_INFINITY)) { return row } return best }, null) } function bestLowestCalibrationRow(rows: CalibrationRow[], metric: 'falsePositives'): CalibrationRow | null { return rows.reduce((best, row) => { const value = row[metric] if (value === null) { return best } if (!best || value < (best[metric] ?? Number.POSITIVE_INFINITY)) { return row } return best }, null) } function numberFromRecord(record: Record | null | undefined, key: string): number | null { const value = record?.[key] return typeof value === 'number' && Number.isFinite(value) ? value : null } function stringFromRecord(record: Record | null | undefined, key: string): string | null { const value = record?.[key] return typeof value === 'string' && value.trim().length > 0 ? value : null } function buildCalibrationSummaryExport(projectId: string, rows: DetectionCalibrationRunRow[]): Record { const successfulRows = rows.filter((row) => row.status === 'success') return { export_type: 'detection_calibration_summary', project_id: projectId, created_at: new Date().toISOString(), calibration_thresholds: rows.map((row) => row.threshold), quality_check_ids: successfulRows.map((row) => row.quality_check_id).filter(Boolean), rows: rows.map((row) => ({ threshold: row.threshold, status: row.status, analysis_run_id: row.analysis_run_id ?? null, job_id: row.job_id ?? null, quality_check_id: row.quality_check_id ?? null, evidence_geojson_url: row.quality_check_id ? `/api/v1/projects/${projectId}/quality-checks/${row.quality_check_id}/evidence/geojson` : null, detection_count: row.detection_count ?? null, precision: row.precision ?? null, recall: row.recall ?? null, f1_score: row.f1_score ?? null, false_positives: row.false_positives ?? null, false_negatives: row.false_negatives ?? null, message: row.message ?? null, })), } } function downloadCalibrationSummary(projectId: string | null, rows: DetectionCalibrationRunRow[]): void { if (!projectId || rows.length === 0) { return } downloadJsonFile('detection-calibration-summary.json', buildCalibrationSummaryExport(projectId, rows)) } function downloadJsonFile(filename: string, payload: unknown): void { const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = filename link.click() URL.revokeObjectURL(url) } function formatNullableNumber(value: number | null, digits: number): string { return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(digits) : 'n/a' }