From e3969e89b152b3e53b9259c504af1946f0c66419 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 9 Sep 2026 20:39:57 -0400 Subject: [PATCH 1/5] Handle cross-camera IDs after single-camera pipeline runs --- client/dive-common/apispec.ts | 2 + .../components/RunPipelineMenu.vue | 74 ++++++++-- .../SingleCameraAssociationDialog.vue | 32 +++++ .../dive-common/singleCameraPipeline.spec.ts | 45 ++++++ client/dive-common/singleCameraPipeline.ts | 48 +++++++ .../dive-common/stereoAssociationPipeline.ts | 66 +++++++++ .../native/singleCameraPipeline.spec.ts | 106 ++++++++++++++ .../backend/native/singleCameraPipeline.ts | 103 ++++++++++++++ .../platform/desktop/backend/native/viame.ts | 42 +++++- .../frontend/components/ViewerLoader.vue | 1 + .../web-girder/views/ViewerLoader.vue | 1 + server/dive_server/crud_rpc.py | 40 ++++++ server/dive_tasks/run_pipeline.py | 22 ++- server/dive_tasks/single_camera_pipeline.py | 134 ++++++++++++++++++ server/dive_utils/types.py | 2 + server/tests/test_single_camera_pipeline.py | 106 ++++++++++++++ 16 files changed, 801 insertions(+), 23 deletions(-) create mode 100644 client/dive-common/components/SingleCameraAssociationDialog.vue create mode 100644 client/dive-common/singleCameraPipeline.spec.ts create mode 100644 client/dive-common/singleCameraPipeline.ts create mode 100644 client/dive-common/stereoAssociationPipeline.ts create mode 100644 client/platform/desktop/backend/native/singleCameraPipeline.spec.ts create mode 100644 client/platform/desktop/backend/native/singleCameraPipeline.ts create mode 100644 server/dive_tasks/single_camera_pipeline.py create mode 100644 server/tests/test_single_camera_pipeline.py diff --git a/client/dive-common/apispec.ts b/client/dive-common/apispec.ts index 83589a83e..16d42c657 100644 --- a/client/dive-common/apispec.ts +++ b/client/dive-common/apispec.ts @@ -121,6 +121,8 @@ interface PipelineRuntimeParams { } interface PipelineParams { + /** Postprocess a single-camera run against the rig's existing annotations. */ + singleCameraMode?: 'associate' | 'separate'; kwiverParams?: Record; runtimeParams?: PipelineRuntimeParams; /** diff --git a/client/dive-common/components/RunPipelineMenu.vue b/client/dive-common/components/RunPipelineMenu.vue index 4b2bfeb54..071df8b91 100644 --- a/client/dive-common/components/RunPipelineMenu.vue +++ b/client/dive-common/components/RunPipelineMenu.vue @@ -19,6 +19,11 @@ import { NewDatasetJobConfig, } from 'dive-common/apispec'; import JobLaunchDialog from 'dive-common/components/JobLaunchDialog.vue'; +import SingleCameraAssociationDialog from 'dive-common/components/SingleCameraAssociationDialog.vue'; +import { + singleCameraContext, SingleCameraMode, validateAssociation, +} from 'dive-common/singleCameraPipeline'; +import { isStereoInteractiveModeEnabled } from 'dive-common/store/settings'; import JobConfigFilterTranscodeDialog from 'dive-common/components/JobConfigFilterTranscodeDialog.vue'; import RunPipelineToast from 'dive-common/components/RunPipelineToast.vue'; import { @@ -53,6 +58,7 @@ export default defineComponent({ components: { JobLaunchDialog, + SingleCameraAssociationDialog, JobConfigFilterTranscodeDialog, PipelineParamsDialog, PipelineCameraAssignDialog, @@ -61,6 +67,11 @@ export default defineComponent({ }, props: { + /** Persist viewer annotations before inspecting or consuming the other cameras. */ + beforeRun: { + type: Function as PropType<() => Promise>, + default: undefined, + }, selectedDatasetIds: { type: Array as PropType, default: () => [], @@ -120,7 +131,7 @@ export default defineComponent({ setup(props) { const { prompt } = usePrompt(); const { - runPipeline, getPipelineList, hasCalibrationFile, loadConfig, saveConfig, + runPipeline, getPipelineList, hasCalibrationFile, loadConfig, saveConfig, loadDetections, } = useApi(); const unsortedPipelines = ref({} as Pipelines); const { @@ -132,6 +143,18 @@ export default defineComponent({ const menuState: Ref = ref('idle'); const configuring = computed(() => menuState.value === 'configuring'); const selectedPipeline: Ref = ref(null); + const associationCamera = ref(''); + let resolveAssociation: (mode: SingleCameraMode | null) => void = () => {}; + function answerAssociation(mode: SingleCameraMode | null) { + associationCamera.value = ''; + resolveAssociation(mode); + } + function askAssociation(camera: string) { + return new Promise((resolve) => { + resolveAssociation = resolve; + associationCamera.value = camera; + }); + } const selectedPipelineName = computed(() => (selectedPipeline.value ? selectedPipeline.value.name : '')); function cancelConfig() { menuState.value = 'idle'; @@ -357,18 +380,39 @@ export default defineComponent({ throw new Error('No selected datasets to run on'); } let datasetIds = props.selectedDatasetIds; - if (props.cameraNumbers.length === 1 && props.cameraNumbers[0] > 1 - && (!multiCamPipelineMarkers.includes(pipeline.type) - && stereoPipelineMarker !== pipeline.type)) { - const cameraNames = props.selectedDatasetIds.map((item) => parentDatasetId(item)); - const result = await prompt({ - title: `Running Single Camera Pipeline on ${cameraNames[0]}`, - text: ['Running a single pipeline on multi-camera data can produce conflicting track Ids', - 'Suggest Cancelling and deleting all existing tracks to ensure proper display of the output', - ], - confirm: true, - }); - if (!result) { + const singleCameraModes: Record = {}; + if (!pipelineCreatesNewDataset(pipeline) + && !multiCamPipelineMarkers.includes(pipeline.type) + && stereoPipelineMarker !== pipeline.type) { + datasetIds = [...datasetIds]; + try { + if (props.cameraNumbers.some((count) => count > 1)) await props.beforeRun?.(); + const parents = new Set(); + for (let i = 0; i < datasetIds.length; i += 1) { + // eslint-disable-next-line no-await-in-loop + const context = await singleCameraContext({ loadConfig, loadDetections }, datasetIds[i]); + if (context) { + if (parents.has(context.parentId)) { + throw new Error('Select only one camera per dataset for a single-camera pipeline run.'); + } + parents.add(context.parentId); + datasetIds[i] = context.datasetId; + let mode: SingleCameraMode | null = 'separate'; + if (context.hasOtherTracks) { + // eslint-disable-next-line no-await-in-loop + mode = await askAssociation(context.datasetId); + if (!mode) return; + } + if (mode === 'associate') { + // eslint-disable-next-line no-await-in-loop + const calibration = context.stereo && !!await hasCalibrationFile?.(context.parentId); + validateAssociation(context.stereo, calibration, isStereoInteractiveModeEnabled()); + } + singleCameraModes[context.datasetId] = mode; + } + } + } catch (error) { + await prompt({ title: 'Cannot run pipeline', text: (error as Error).message }); return; } } @@ -393,6 +437,7 @@ export default defineComponent({ outputParentFolderId, kwiverParams: kwiverParamsById?.[id], cameraOrder: cameraOrderById[id], + singleCameraMode: singleCameraModes[id], })), )); } @@ -446,6 +491,8 @@ export default defineComponent({ } return { + associationCamera, + answerAssociation, jobState, pipelines, pipelinesNotRunnable, @@ -481,6 +528,7 @@ export default defineComponent({