1113 lines
48 KiB
TypeScript
1113 lines
48 KiB
TypeScript
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 (
|
|
<section className="workspace-panel ai-lab-shell detection-lab-shell">
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<p className="eyebrow">Object detection</p>
|
|
<h2>Detection Lab</h2>
|
|
</div>
|
|
<button className="secondary-action" type="button" onClick={onLoadModels} disabled={loadingDetectionModels}>
|
|
Refresh models
|
|
</button>
|
|
</div>
|
|
|
|
<div className="ai-lab-model-surface" aria-label="Detection model capabilities">
|
|
<div className="ai-lab-section-header">
|
|
<div>
|
|
<h3>Model registry</h3>
|
|
<p>Backend-reported detector states and limitations.</p>
|
|
</div>
|
|
</div>
|
|
<div className="ai-lab-state-stack">
|
|
{loadingDetectionModels ? (
|
|
<div className="result-state result-state-loading">
|
|
<strong>Loading detection models.</strong>
|
|
<p>Checking backend model registry availability.</p>
|
|
</div>
|
|
) : null}
|
|
{detectionModelError ? (
|
|
<div className="result-state result-state-error">
|
|
<strong>Detection model registry unavailable.</strong>
|
|
<p>{detectionModelError}</p>
|
|
</div>
|
|
) : null}
|
|
{modelAssetError ? (
|
|
<div className="result-state result-state-error">
|
|
<strong>Local model assets unavailable.</strong>
|
|
<p>{modelAssetError}</p>
|
|
</div>
|
|
) : null}
|
|
{detectionModels.length === 0 && !loadingDetectionModels ? (
|
|
<div className="result-state result-state-empty">
|
|
<strong>No detection models reported by backend.</strong>
|
|
<p>Refresh models after the backend is reachable.</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<ul className="model-list">
|
|
{detectionModels.map((model) => (
|
|
<li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
|
|
<strong>{model.display_name}</strong>
|
|
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.status}</span>
|
|
<div className="entity-meta">
|
|
<span>{model.model_id}</span>
|
|
<span>{model.framework}</span>
|
|
<span>{model.task_type}</span>
|
|
</div>
|
|
<p className="muted">classes: {model.supported_classes.join(', ')}</p>
|
|
<p className="muted">{model.limitation_message}</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
{selectedDetectionModelId === 'yolo-configured' ? (
|
|
<div className="ai-lab-model-surface" aria-label="Local model asset selection">
|
|
<div className="ai-lab-section-header">
|
|
<div>
|
|
<h3>Explicit model asset</h3>
|
|
<p>Local model assets are read-only. No model file is selected automatically. Choose an existing local model asset before submitting configured YOLO.</p>
|
|
</div>
|
|
<span className={selectedModelAsset ? 'status-badge status-badge-ready' : 'status-badge'}>
|
|
{selectedModelAsset ? 'asset selected' : 'no explicit asset'}
|
|
</span>
|
|
</div>
|
|
<div className="model-asset-guidance">
|
|
<strong>Operator profiles</strong>
|
|
<p>
|
|
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.
|
|
</p>
|
|
</div>
|
|
<div className="operator-profile-grid" aria-label="Configured YOLO operator profiles">
|
|
{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 (
|
|
<div
|
|
className={profileSelected ? 'operator-profile-card operator-profile-card-selected' : 'operator-profile-card'}
|
|
key={profile.id}
|
|
>
|
|
<div className="operator-profile-card-header">
|
|
<strong>{profile.displayName}</strong>
|
|
<span className={profile.defaultApproved ? 'status-badge status-badge-ready' : 'status-badge'}>
|
|
{profile.defaultApproved ? 'default-approved' : 'Candidate only - not default-approved'}
|
|
</span>
|
|
</div>
|
|
<p>{profile.description}</p>
|
|
<div className="operator-profile-metrics">
|
|
<span>threshold {profile.confidenceThreshold.toFixed(2)}</span>
|
|
<span>precision {profile.precision.toFixed(3)}</span>
|
|
<span>recall {profile.recall.toFixed(3)}</span>
|
|
<span>F1 {profile.f1.toFixed(3)}</span>
|
|
<span>positive AOIs {profile.positiveSampleCount}</span>
|
|
<span>max background FP {profile.maxBackgroundDetections}</span>
|
|
</div>
|
|
<div className="entity-meta">
|
|
<span>asset: {profile.modelAssetId}</span>
|
|
<span>promotionRecommendation: {profile.promotionRecommendation}</span>
|
|
<span>available: {profileAsset ? 'yes' : 'not mounted'}</span>
|
|
</div>
|
|
<p className="field-guidance">{profile.limitationMessage}</p>
|
|
<button
|
|
className="secondary-action"
|
|
type="button"
|
|
onClick={() => onApplyOperatorProfile(profile)}
|
|
disabled={!profileAsset}
|
|
>
|
|
Apply profile
|
|
</button>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
{selectedModelAsset ? (
|
|
<div className="model-asset-guidance">
|
|
<strong>Selected model asset status</strong>
|
|
<p>
|
|
{selectedModelAsset.display_name} is selected for this browser-run request. Runtime default activation
|
|
remains a separate guarded operator action backed by a promotion report.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
<label>
|
|
Local model file
|
|
<select value={selectedModelAssetId} onChange={(event) => onSelectModelAsset(event.target.value)}>
|
|
<option value="">Select explicit local model asset</option>
|
|
{modelAssets.map((asset) => (
|
|
<option key={asset.model_asset_id} value={asset.model_asset_id}>
|
|
{asset.display_name} {asset.active ? '(active)' : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{modelAssets.length === 0 && !loadingDetectionModels ? (
|
|
<div className="result-state result-state-empty">
|
|
<strong>No local model assets found.</strong>
|
|
<p>Mount model files into the backend model directory or continue with the configured YOLO_MODEL_PATH.</p>
|
|
</div>
|
|
) : null}
|
|
{selectedModelAsset ? (
|
|
<div className="result-summary-card">
|
|
<p>File: {selectedModelAsset.filename}</p>
|
|
<p>Status: {selectedModelAsset.status}</p>
|
|
<p>Active runtime env model: {selectedModelAsset.active ? 'yes' : 'no'}</p>
|
|
<p>will_download_models: {selectedModelAsset.will_download_models ? 'yes' : 'no'}</p>
|
|
<p>Size: {formatModelAssetSize(selectedModelAsset.size_bytes)}</p>
|
|
<p>SHA-256: {selectedModelAsset.sha256.slice(0, 12)}</p>
|
|
<p>Path: {selectedModelAsset.model_path}</p>
|
|
<p>{selectedModelAsset.limitation_message}</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="ai-lab-model-surface" aria-label="YOLO runtime preflight">
|
|
<div className="ai-lab-section-header">
|
|
<div>
|
|
<h3>YOLO runtime preflight</h3>
|
|
<p>Read-only runtime status. This does not load a model, run inference or download weights.</p>
|
|
</div>
|
|
<button className="secondary-action" type="button" onClick={onRefreshYoloPreflight} disabled={loadingYoloPreflight}>
|
|
Refresh preflight
|
|
</button>
|
|
</div>
|
|
<div className="ai-lab-state-stack">
|
|
{loadingYoloPreflight ? (
|
|
<div className="result-state result-state-loading">
|
|
<strong>Loading YOLO preflight.</strong>
|
|
<p>Checking backend runtime configuration and optional dependency visibility.</p>
|
|
</div>
|
|
) : null}
|
|
{yoloPreflightError ? (
|
|
<div className="result-state result-state-error">
|
|
<strong>YOLO preflight unavailable.</strong>
|
|
<p>{yoloPreflightError}</p>
|
|
</div>
|
|
) : null}
|
|
{!yoloPreflight && !loadingYoloPreflight && !yoloPreflightError ? (
|
|
<div className="result-state result-state-empty">
|
|
<strong>No YOLO preflight loaded.</strong>
|
|
<p>Refresh preflight to inspect the live backend AI runtime before running configured YOLO.</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{yoloPreflight ? (
|
|
<div className={yoloPreflight.status === 'ready' ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}>
|
|
<div className="ai-lab-section-header">
|
|
<div>
|
|
<h3>Status: {yoloPreflight.status}</h3>
|
|
<p>{yoloPreflight.message}</p>
|
|
</div>
|
|
<span className={yoloPreflight.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>
|
|
{yoloPreflight.checks.dependencies_available ? 'dependencies visible' : 'not ready'}
|
|
</span>
|
|
</div>
|
|
<div className="lab-readiness-grid">
|
|
<div className={yoloPreflight.checks.enabled ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
|
<span>YOLO enabled</span>
|
|
<strong>{yoloPreflight.checks.enabled ? 'true' : 'false'}</strong>
|
|
</div>
|
|
<div className={yoloPreflight.checks.dependencies_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
|
<span>Dependencies</span>
|
|
<strong>{yoloPreflight.checks.dependencies_available === true ? 'available' : yoloPreflight.checks.dependencies_available === false ? 'unavailable' : 'not checked'}</strong>
|
|
</div>
|
|
<div className={yoloPreflight.checks.model_file_exists ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
|
<span>Local model file</span>
|
|
<strong>{yoloPreflight.checks.model_file_exists === true ? 'found' : yoloPreflight.checks.model_path_set ? 'missing' : 'not configured'}</strong>
|
|
</div>
|
|
<div className={yoloPreflight.runtime.cuda_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
|
<span>CUDA</span>
|
|
<strong>{yoloPreflight.runtime.cuda_available === true ? 'available' : yoloPreflight.runtime.cuda_available === false ? 'not available' : 'not checked'}</strong>
|
|
</div>
|
|
<div className={yoloPreflight.checks.manifest_valid ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
|
<span>Tile manifest validation</span>
|
|
<strong>{yoloPreflight.checks.manifest_valid === true ? 'valid' : yoloPreflight.checks.manifest_path_set ? 'not valid' : 'not provided'}</strong>
|
|
</div>
|
|
<div className={yoloPreflight.tile_count > 0 ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
|
<span>Tile count</span>
|
|
<strong>{yoloPreflight.tile_count} / {yoloPreflight.max_tiles}</strong>
|
|
</div>
|
|
</div>
|
|
<div className="entity-meta">
|
|
<span>torch_version: {yoloPreflight.runtime.torch_version ?? 'n/a'}</span>
|
|
<span>ultralytics_version: {yoloPreflight.runtime.ultralytics_version ?? 'n/a'}</span>
|
|
<span>cuda_available: {String(yoloPreflight.runtime.cuda_available ?? 'unknown')}</span>
|
|
<span>will_run_inference: {String(yoloPreflight.will_run_inference)}</span>
|
|
<span>YOLO_CONFIG_DIR: {yoloPreflight.runtime.yolo_config_dir ?? 'n/a'}</span>
|
|
<span>model directory: {yoloPreflight.runtime.model_directory ?? 'n/a'}</span>
|
|
<span>model_asset_id: {yoloPreflight.model_asset_id ?? 'n/a'}</span>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="lab-block">
|
|
<div className="ai-lab-run-surface" aria-label="Detection run controls">
|
|
<h3>Run detection</h3>
|
|
<div
|
|
className={detectionRunReady ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}
|
|
aria-label="Detection run readiness"
|
|
>
|
|
<div className="ai-lab-section-header">
|
|
<div>
|
|
<h3>Run readiness</h3>
|
|
<p>Checks the selected dataset, model and tile manifest before submitting a detection job.</p>
|
|
</div>
|
|
<span className={detectionRunReady ? 'status-badge status-badge-ready' : 'status-badge'}>
|
|
{detectionRunReady ? 'Ready to submit' : 'Blocked'}
|
|
</span>
|
|
</div>
|
|
<div className="lab-readiness-grid">
|
|
<div className={detectionHasDataset ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
|
<span>Raster dataset</span>
|
|
<strong>{detectionHasDataset ? 'Selected' : 'Select a raster dataset'}</strong>
|
|
</div>
|
|
<div className={detectionModelReady ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
|
<span>Model availability</span>
|
|
<strong>
|
|
{detectionModelReady
|
|
? `${selectedDetectionModel?.display_name ?? selectedDetectionModelId} is configured`
|
|
: selectedDetectionModel?.limitation_message ?? 'Select a configured model'}
|
|
</strong>
|
|
</div>
|
|
<div className={detectionHasTileManifest ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
|
<span>Tile manifest</span>
|
|
<strong>
|
|
{detectionRequiresTileManifest
|
|
? detectionHasTileManifest
|
|
? 'Provided for configured YOLO'
|
|
: 'Required for configured YOLO'
|
|
: 'Not required for this model'}
|
|
</strong>
|
|
</div>
|
|
<div className={detectionHasExplicitModelAsset ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
|
<span>Local model asset</span>
|
|
<strong>
|
|
{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'}
|
|
</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className={detectionRunReady ? 'lab-action-guardrail lab-action-guardrail-ready' : 'lab-action-guardrail'}>
|
|
<span>Run action</span>
|
|
<strong>{detectionRunReady ? 'Ready to submit a detection job' : detectionRunBlockedReason}</strong>
|
|
</div>
|
|
{rasterDatasets.length === 0 ? (
|
|
<div className="result-state result-state-empty">
|
|
<strong>No raster datasets available for detection.</strong>
|
|
<p>Upload or select a raster dataset in Data before running object detection.</p>
|
|
</div>
|
|
) : null}
|
|
<div className="lab-form-grid">
|
|
<label>
|
|
Raster dataset
|
|
<select value={selectedDetectionDatasetId} onChange={(event) => onSelectDataset(event.target.value)}>
|
|
<option value="">Select raster dataset</option>
|
|
{rasterDatasets.map((dataset) => (
|
|
<option key={dataset.id} value={dataset.id}>
|
|
{dataset.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Model
|
|
<select value={selectedDetectionModelId} onChange={(event) => onSelectModel(event.target.value)}>
|
|
{detectionModels.map((model) => (
|
|
<option key={model.model_id} value={model.model_id}>
|
|
{model.display_name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Min confidence
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={detectionConfidenceThreshold}
|
|
onChange={(event) => onSetConfidenceThreshold(Number(event.target.value))}
|
|
/>
|
|
{selectedDetectionModelId === 'yolo-configured' ? (
|
|
<span className="field-guidance">
|
|
Use a validated operator profile, or enter a threshold manually for calibration.
|
|
</span>
|
|
) : null}
|
|
</label>
|
|
</div>
|
|
{selectedDetectionModelId === 'yolo-configured' ? (
|
|
<label>
|
|
Tile manifest
|
|
<input
|
|
type="text"
|
|
placeholder="Raster tile manifest path"
|
|
value={detectionTileManifestPath}
|
|
onChange={(event) => onSetTileManifestPath(event.target.value)}
|
|
/>
|
|
</label>
|
|
) : null}
|
|
{selectedDetectionModelId === 'yolo-configured' && detectionTileManifestPath.trim() ? (
|
|
<div className="linked-manifest-card">
|
|
<strong>Linked tile manifest</strong>
|
|
<p>{detectionTileManifestPath}</p>
|
|
<span>Refresh preflight after changing model asset or manifest path.</span>
|
|
</div>
|
|
) : null}
|
|
<button className="primary-action" type="button" onClick={onRunDetection} disabled={runningDetection || !detectionRunReady}>
|
|
Run detection
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ai-lab-state-stack">
|
|
{detectionRunError ? (
|
|
<div className="result-state result-state-error">
|
|
<strong>Detection run failed.</strong>
|
|
<p>{detectionRunError}</p>
|
|
</div>
|
|
) : null}
|
|
{detectionRunResult ? (
|
|
<div className="result-summary-card">
|
|
<p>Status: {detectionRunResult.status}</p>
|
|
<p>Message: {detectionRunResult.message}</p>
|
|
<p>Analysis run: {detectionRunResult.analysis_run_id}</p>
|
|
<p>Job: {detectionRunResult.job_id}</p>
|
|
<p>Detections: {detectionRunResult.detection_count}</p>
|
|
{detectionRunResult.error_code ? <p className="error">Code: {detectionRunResult.error_code}</p> : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="ai-lab-run-surface guided-calibration-surface" aria-label="Guided calibration runner">
|
|
<div className="ai-lab-section-header">
|
|
<div>
|
|
<h3>Guided calibration runner</h3>
|
|
<p>This runs real configured YOLO jobs and QA comparisons for each threshold. It does not promote or mutate model files.</p>
|
|
</div>
|
|
<span className={calibrationRunReady ? 'status-badge status-badge-ready' : 'status-badge'}>
|
|
{calibrationRunReady ? 'ready' : 'needs dataset, model, manifest and reference'}
|
|
</span>
|
|
</div>
|
|
<div className="lab-form-grid">
|
|
<label>
|
|
Threshold set
|
|
<input
|
|
type="text"
|
|
value={calibrationThresholdText}
|
|
onChange={(event) => onSetCalibrationThresholdText(event.target.value)}
|
|
placeholder="0.50 0.25 0.15"
|
|
/>
|
|
<span className="field-guidance">Use spaces, commas or semicolons. Values must be between 0 and 1.</span>
|
|
</label>
|
|
<label>
|
|
Reference dataset
|
|
<select value={detectionReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
|
|
<option value="">Select reference dataset</option>
|
|
{referenceDatasets.map((dataset) => (
|
|
<option key={dataset.id} value={dataset.id}>
|
|
{dataset.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<button
|
|
className="primary-action"
|
|
type="button"
|
|
onClick={onRunCalibration}
|
|
disabled={runningDetectionCalibration || !calibrationRunReady}
|
|
>
|
|
Run calibration sweep
|
|
</button>
|
|
{detectionCalibrationError ? (
|
|
<div className="result-state result-state-error">
|
|
<strong>Calibration sweep failed.</strong>
|
|
<p>{detectionCalibrationError}</p>
|
|
</div>
|
|
) : null}
|
|
{detectionCalibrationRows.length > 0 ? (
|
|
<div className="calibration-progress-panel" aria-label="Calibration run progress">
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<h3>Calibration run progress</h3>
|
|
<p className="muted">Each row is backed by a persisted detection run and QA check when successful.</p>
|
|
</div>
|
|
<div className="panel-action-row">
|
|
<span className="count-pill">{detectionCalibrationRows.length} thresholds</span>
|
|
<button
|
|
className="secondary-action"
|
|
type="button"
|
|
onClick={() => downloadCalibrationSummary(selectedProjectId, detectionCalibrationRows)}
|
|
disabled={detectionCalibrationRows.length === 0 || !selectedProjectId}
|
|
>
|
|
Download calibration summary
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="table-scroll">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Threshold</th>
|
|
<th>Status</th>
|
|
<th>Detections</th>
|
|
<th>Precision</th>
|
|
<th>Recall</th>
|
|
<th>F1</th>
|
|
<th>False positives</th>
|
|
<th>False negatives</th>
|
|
<th>Evidence</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{detectionCalibrationRows.map((row) => (
|
|
<tr key={row.threshold}>
|
|
<td>{row.threshold.toFixed(2)}</td>
|
|
<td>{row.status}</td>
|
|
<td>{row.detection_count ?? 'n/a'}</td>
|
|
<td>{formatNullableNumber(row.precision ?? null, 3)}</td>
|
|
<td>{formatNullableNumber(row.recall ?? null, 3)}</td>
|
|
<td>{formatNullableNumber(row.f1_score ?? null, 3)}</td>
|
|
<td>{row.false_positives ?? 'n/a'}</td>
|
|
<td>{row.false_negatives ?? 'n/a'}</td>
|
|
<td>
|
|
<button
|
|
className="secondary-action table-action"
|
|
type="button"
|
|
onClick={() => row.quality_check_id ? onOpenCalibrationEvidence(row.quality_check_id) : undefined}
|
|
disabled={!row.quality_check_id || row.status !== 'success'}
|
|
aria-label={`Open evidence map for threshold ${row.threshold.toFixed(2)}`}
|
|
>
|
|
Open evidence map
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="result-state result-state-empty">
|
|
<strong>No calibration sweep has been run in this session.</strong>
|
|
<p>Choose a reference dataset and threshold set, then start the explicit sweep.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="ai-lab-results-surface" aria-label="Detection results">
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<h3>Detection results</h3>
|
|
<p className="muted">Load persisted detections and filter by class or confidence.</p>
|
|
</div>
|
|
<button className="secondary-action" type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
|
|
Refresh runs
|
|
</button>
|
|
</div>
|
|
<div className="lab-form-grid">
|
|
<label>
|
|
Run
|
|
<select value={selectedDetectionRunId} onChange={(event) => onSelectRun(event.target.value)}>
|
|
<option value="">Select detection run</option>
|
|
{detectionRuns.map((run) => (
|
|
<option key={run.id} value={run.id}>
|
|
{run.model_name || 'detection'} - {run.status} - {run.id}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Class
|
|
<input
|
|
type="text"
|
|
placeholder="Class filter"
|
|
value={detectionClassFilter}
|
|
onChange={(event) => onSetClassFilter(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Min confidence
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={detectionMinConfidenceFilter}
|
|
onChange={(event) => onSetMinConfidenceFilter(Number(event.target.value))}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<button className="primary-action" type="button" onClick={onLoadResults} disabled={!selectedDetectionRunId || loadingDetectionResults}>
|
|
Load detections
|
|
</button>
|
|
{loadingDetectionResults ? (
|
|
<div className="result-state result-state-loading">
|
|
<strong>Loading detection results.</strong>
|
|
<p>Retrieving persisted detections for the selected run.</p>
|
|
</div>
|
|
) : null}
|
|
<div className="ai-lab-state-stack">
|
|
<div className="result-state result-state-ready">
|
|
<strong>Detections loaded: {detectionItems.length}</strong>
|
|
<p>{selectedDetectionRunId ? 'Loaded from persisted detection records.' : 'Select a detection run before loading results.'}</p>
|
|
</div>
|
|
</div>
|
|
{detectionItems.length > 0 ? (
|
|
<>
|
|
<div className="pagination-toolbar" aria-label="Detection result pagination">
|
|
<p className="pagination-summary" aria-live="polite">
|
|
<strong>{detectionPageStart + 1}-{detectionPageEnd}</strong>
|
|
<span>of {detectionItems.length}</span>
|
|
</p>
|
|
<label className="pagination-page-size">
|
|
Rows
|
|
<select
|
|
value={detectionPageSize}
|
|
onChange={(event) => {
|
|
setDetectionPageSize(Number(event.target.value))
|
|
setDetectionResultPage(1)
|
|
}}
|
|
>
|
|
{DETECTION_PAGE_SIZE_OPTIONS.map((pageSize) => (
|
|
<option key={pageSize} value={pageSize}>{pageSize}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div className="pagination-actions">
|
|
<button
|
|
className="secondary-action pagination-button"
|
|
type="button"
|
|
aria-label="Previous detection results page"
|
|
title="Previous page"
|
|
disabled={currentDetectionPage <= 1}
|
|
onClick={() => setDetectionResultPage(currentDetectionPage - 1)}
|
|
>
|
|
{'<'}
|
|
</button>
|
|
<span>Page {currentDetectionPage} of {detectionPageCount}</span>
|
|
<button
|
|
className="secondary-action pagination-button"
|
|
type="button"
|
|
aria-label="Next detection results page"
|
|
title="Next page"
|
|
disabled={currentDetectionPage >= detectionPageCount}
|
|
onClick={() => setDetectionResultPage(currentDetectionPage + 1)}
|
|
>
|
|
{'>'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="table-scroll">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Class</th>
|
|
<th>Confidence</th>
|
|
<th>Model</th>
|
|
<th>Source tile</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visibleDetectionItems.map((detection) => (
|
|
<tr key={detection.id}>
|
|
<td>{detection.class_name}</td>
|
|
<td>{detection.confidence.toFixed(2)}</td>
|
|
<td>{detection.model_name}</td>
|
|
<td>{detection.source_tile_path || 'n/a'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="ai-lab-results-surface calibration-comparison-surface" aria-label="Detection calibration comparison">
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<h3>Calibration comparison</h3>
|
|
<p className="muted">Compare persisted detection runs by confidence threshold before promoting a model setting.</p>
|
|
</div>
|
|
<span className="count-pill">{calibrationRows.length} rows</span>
|
|
</div>
|
|
{calibrationRows.length > 0 ? (
|
|
<>
|
|
<div className="calibration-summary-grid" aria-label="Calibration comparison winners">
|
|
<CalibrationSummaryCard title="Best F1 candidate" row={bestF1Candidate} metric="f1" />
|
|
<CalibrationSummaryCard title="Best precision candidate" row={bestPrecisionCandidate} metric="precision" />
|
|
<CalibrationSummaryCard title="Lowest false-positive pressure" row={lowestFalsePositivePressureCandidate} metric="falsePositives" />
|
|
</div>
|
|
<div className="table-scroll">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Threshold</th>
|
|
<th>Model</th>
|
|
<th>Detections</th>
|
|
<th>Precision</th>
|
|
<th>Recall</th>
|
|
<th>F1</th>
|
|
<th>False positives</th>
|
|
<th>False negatives</th>
|
|
<th>Quality check</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{calibrationRows.map((row) => (
|
|
<tr key={`${row.analysisRunId}-${row.qualityCheckId}`}>
|
|
<td>{formatNullableNumber(row.threshold, 2)}</td>
|
|
<td>
|
|
<strong>{row.modelName}</strong>
|
|
<span className="table-subtle">{row.modelAssetId ?? 'runtime configured path'}</span>
|
|
</td>
|
|
<td>{row.detectionCount ?? 'n/a'}</td>
|
|
<td>{formatNullableNumber(row.precision, 3)}</td>
|
|
<td>{formatNullableNumber(row.recall, 3)}</td>
|
|
<td>{formatNullableNumber(row.f1, 3)}</td>
|
|
<td>{row.falsePositives ?? 'n/a'}</td>
|
|
<td>{row.falseNegatives ?? 'n/a'}</td>
|
|
<td>{row.qualityCheckId}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="lab-action-guardrail">
|
|
<span>Promotion guardrail</span>
|
|
<strong>Promote only after checking evidence across AOIs, false positives and false negatives.</strong>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="result-state result-state-empty">
|
|
<strong>No calibration comparison available yet.</strong>
|
|
<p>Run configured YOLO at multiple confidence thresholds, then compare each persisted detection run against the same reference dataset.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="ai-lab-qa-surface" aria-label="Detection QA controls and results">
|
|
<h3>Detection QA</h3>
|
|
<label>
|
|
Reference dataset
|
|
<select value={detectionReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
|
|
<option value="">Select reference dataset</option>
|
|
{referenceDatasets.map((dataset) => (
|
|
<option key={dataset.id} value={dataset.id}>
|
|
{dataset.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button className="primary-action" type="button" onClick={onRunQa} disabled={runningDetectionQa || !selectedDetectionRunId || !detectionReferenceDatasetId}>
|
|
Compare detections to reference
|
|
</button>
|
|
{detectionQaError ? (
|
|
<div className="result-state result-state-error">
|
|
<strong>Detection QA failed.</strong>
|
|
<p>{detectionQaError}</p>
|
|
</div>
|
|
) : null}
|
|
{detectionQaResult ? (
|
|
<div className="result-summary-card">
|
|
<p>Status: {detectionQaResult.status}</p>
|
|
<p>Quality check: {detectionQaResult.quality_check_id}</p>
|
|
<p>Precision: {detectionQaResult.precision?.toFixed(3) ?? 'n/a'}</p>
|
|
<p>Recall: {detectionQaResult.recall?.toFixed(3) ?? 'n/a'}</p>
|
|
<p>F1: {detectionQaResult.f1_score?.toFixed(3) ?? 'n/a'}</p>
|
|
<p>Mean IoU: {detectionQaResult.mean_iou?.toFixed(3) ?? 'n/a'}</p>
|
|
<p>False positives: {detectionQaResult.false_positives}</p>
|
|
<p>False negatives: {detectionQaResult.false_negatives}</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function CalibrationSummaryCard({
|
|
title,
|
|
row,
|
|
metric,
|
|
}: {
|
|
title: string
|
|
row: CalibrationRow | null
|
|
metric: 'f1' | 'precision' | 'falsePositives'
|
|
}): JSX.Element {
|
|
return (
|
|
<div className={row ? 'calibration-summary-card calibration-summary-card-ready' : 'calibration-summary-card'}>
|
|
<span>{title}</span>
|
|
{row ? (
|
|
<>
|
|
<strong>{metric === 'falsePositives' ? row.falsePositives ?? 'n/a' : formatNullableNumber(row[metric], 3)}</strong>
|
|
<p>Threshold {formatNullableNumber(row.threshold, 2)} · {row.modelName}</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<strong>n/a</strong>
|
|
<p>Persisted QA metrics are required.</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<CalibrationRow | null>((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<CalibrationRow | null>((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<string, unknown> | null | undefined, key: string): number | null {
|
|
const value = record?.[key]
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
}
|
|
|
|
function stringFromRecord(record: Record<string, unknown> | 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<string, unknown> {
|
|
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'
|
|
}
|