Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/dive-common/apispec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ interface PipelineRuntimeParams {
}

interface PipelineParams {
/** Postprocess a single-camera run against the rig's existing annotations. */
singleCameraMode?: 'associate' | 'separate';
kwiverParams?: Record<string, string>;
runtimeParams?: PipelineRuntimeParams;
/**
Expand Down
84 changes: 71 additions & 13 deletions client/dive-common/components/RunPipelineMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import {
NewDatasetJobConfig,
} from 'dive-common/apispec';
import JobLaunchDialog from 'dive-common/components/JobLaunchDialog.vue';
import SingleCameraAssociationDialog from 'dive-common/singleCamera/AssociationDialog.vue';
import {
associationUnavailableReason, singleCameraContext, SingleCameraMode,
} from 'dive-common/singleCamera';
import { isStereoInteractiveModeEnabled } from 'dive-common/store/settings';
import JobConfigFilterTranscodeDialog from 'dive-common/components/JobConfigFilterTranscodeDialog.vue';
import RunPipelineToast from 'dive-common/components/RunPipelineToast.vue';
import {
Expand Down Expand Up @@ -53,6 +58,7 @@ export default defineComponent({

components: {
JobLaunchDialog,
SingleCameraAssociationDialog,
JobConfigFilterTranscodeDialog,
PipelineParamsDialog,
PipelineCameraAssignDialog,
Expand All @@ -61,6 +67,11 @@ export default defineComponent({
},

props: {
/** Persist viewer annotations before inspecting or consuming the other cameras. */
beforeRun: {
type: Function as PropType<() => Promise<void>>,
default: undefined,
},
selectedDatasetIds: {
type: Array as PropType<string[]>,
default: () => [],
Expand Down Expand Up @@ -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 {
Expand All @@ -132,6 +143,21 @@ export default defineComponent({
const menuState: Ref<MenuState> = ref('idle');
const configuring = computed(() => menuState.value === 'configuring');
const selectedPipeline: Ref<Pipe | null> = ref(null);
const associationCamera = ref('');
const associationUnavailable = ref('');
let resolveAssociation: (mode: SingleCameraMode | null) => void = () => {};
function answerAssociation(mode: SingleCameraMode | null) {
associationCamera.value = '';
associationUnavailable.value = '';
resolveAssociation(mode);
}
function askAssociation(camera: string, unavailableReason: string) {
return new Promise<SingleCameraMode | null>((resolve) => {
resolveAssociation = resolve;
associationUnavailable.value = unavailableReason;
associationCamera.value = camera;
});
}
const selectedPipelineName = computed(() => (selectedPipeline.value ? selectedPipeline.value.name : ''));
function cancelConfig() {
menuState.value = 'idle';
Expand Down Expand Up @@ -357,18 +383,41 @@ 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<string, SingleCameraMode> = {};
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<string>();
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
const calibration = context.stereo && !!await hasCalibrationFile?.(context.parentId);
const unavailable = associationUnavailableReason(
context.stereo,
calibration,
isStereoInteractiveModeEnabled(),
) || '';
// eslint-disable-next-line no-await-in-loop
mode = await askAssociation(context.datasetId, unavailable);
if (!mode) return;
}
singleCameraModes[context.datasetId] = mode;
}
}
} catch (error) {
await prompt({ title: 'Cannot run pipeline', text: (error as Error).message });
return;
}
}
Expand All @@ -393,6 +442,7 @@ export default defineComponent({
outputParentFolderId,
kwiverParams: kwiverParamsById?.[id],
cameraOrder: cameraOrderById[id],
singleCameraMode: singleCameraModes[id],
})),
));
}
Expand Down Expand Up @@ -446,6 +496,9 @@ export default defineComponent({
}

return {
associationCamera,
associationUnavailable,
answerAssociation,
jobState,
pipelines,
pipelinesNotRunnable,
Expand Down Expand Up @@ -481,6 +534,11 @@ export default defineComponent({

<template>
<div>
<SingleCameraAssociationDialog
:camera="associationCamera"
:unavailable-reason="associationUnavailable"
@answer="answerAssociation"
/>
<v-menu
max-width="230"
max-height="none"
Expand Down
55 changes: 55 additions & 0 deletions client/dive-common/singleCamera/AssociationDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<script lang="ts">
import { defineComponent } from 'vue';

export default defineComponent({
props: {
camera: { type: String, default: '' },
/** Non-empty when Yes must be hidden; shown so the user knows why. */
unavailableReason: { type: String, default: '' },
},
emits: ['answer'],
});
</script>

<template>
<v-dialog :value="!!camera" persistent max-width="560">
<v-card>
<v-card-title>
{{ unavailableReason ? 'Keep detections on separate cameras?' : 'Associate detections across cameras?' }}
</v-card-title>
<v-card-text>
<template v-if="unavailableReason">
Another camera already has detections. After running the pipeline on {{ camera }},
DIVE will assign new IDs above all IDs on the other cameras so they do not collide.
Association is unavailable: {{ unavailableReason }}
</template>
<template v-else>
Another camera already has detections. After running the pipeline on {{ camera }},
should DIVE associate its detections with those on the other camera?
Choosing Yes replaces annotations on both cameras with the paired association result.
Choosing No keeps cameras separate and assigns new IDs above all IDs on the other cameras.
</template>
</v-card-text>
<v-card-actions>
<v-btn text @click="$emit('answer', null)">
Cancel
</v-btn>
<v-spacer />
<v-btn
:text="!unavailableReason"
:color="unavailableReason ? 'primary' : undefined"
@click="$emit('answer', 'separate')"
>
{{ unavailableReason ? 'Continue' : 'No' }}
</v-btn>
<v-btn
v-if="!unavailableReason"
color="primary"
@click="$emit('answer', 'associate')"
>
Yes
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
42 changes: 42 additions & 0 deletions client/dive-common/singleCamera/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Single-camera pipelines on multicam / stereo

When a pipeline runs on **one camera** of a multicamera or stereo dataset, DIVE must
avoid TrackId collisions with annotations already on the other cameras, and optionally
pair detections across a calibrated stereo pair.

## Modes

| Mode | When | Result |
|------|------|--------|
| `separate` | Default, or user declines association | Remap new track IDs above every ID on the other cameras |
| `associate` | Stereo + calibration + interactive stereo enabled + user accepts | Run VIAME association on both cameras' CSVs, then ingest paired outputs |

## Layout

| Piece | Role |
|-------|------|
| `decisions.ts` | UI/preflight: resolve camera scope, validate association, shared errors/types, ID remapping |
| `association.ts` | Shared VIAME `.pipe` text and CSV frame helper used when finishing a run |
| `AssociationDialog.vue` | Prompt: associate (when allowed; rewrites both cameras), keep separate IDs, or cancel |
| `index.ts` | Public re-exports for `dive-common/singleCamera` |

Platform execution (filesystem / Girder) stays outside this folder:

- **Desktop** — `platform/desktop/backend/native/singleCameraPipeline.ts` (`prepare` / `finish`), wired from `viame.ts`
- **Web** — `server/dive_tasks/single_camera_pipeline.py`, called from `run_pipeline.py`

The Vue run menu (`components/RunPipelineMenu.vue`) uses `decisions` + `AssociationDialog` before launch; backends apply the same rules after the pipeline writes CSV.

## Import

```ts
import {
singleCameraContext,
validateAssociation,
remapCsvIds,
stereoAssociationPipeline,
lastCsvFrame,
type SingleCameraMode,
} from 'dive-common/singleCamera';
import AssociationDialog from 'dive-common/singleCamera/AssociationDialog.vue';
```
66 changes: 66 additions & 0 deletions client/dive-common/singleCamera/association.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/** Geometry-only pairing needs timestamps, but does not need to decode camera media.
* A one-pixel image repeated through the last annotated frame supplies that clock.
* frame_list is one-based; downsample supplies DIVE's zero-based frame numbers.
*/
export const stereoAssociationPipeline = `config _scheduler
:type thread_per_process

process clock
:: frame_list_input
:image_list_file frames.txt
:image_reader:type ocv

process timestamps
:: downsample
:renumber_frames true

connect from clock.timestamp
to timestamps.timestamp

process pairing
:: compute_measurements
:matching_methods input_pairs_only
:detection_pairing_method calibration
:detection_pairing_threshold 10
:calibration_file calibration.json
:accumulate_track_pairings true
:pairing_resolution_method most_likely
:detection_split_threshold 1
:output_unmatched true
:average_stereo_classes false

connect from timestamps.timestamp
to pairing.timestamp
${[1, 2].map((side) => `
process reader${side}
:: read_object_track
:file_name input${side}.csv
:reader:type viame_csv

connect from clock.image_file_name
to reader${side}.image_file_name
connect from reader${side}.object_track_set
to pairing.object_track_set${side}

process writer${side}
:: write_object_track
:file_name associated${side}.csv
:writer:type viame_csv

connect from pairing.object_track_set${side}
to writer${side}.object_track_set
`).join('')}`;

export function lastCsvFrame(csv: string): number {
let maximum = -1;
csv.split(/\r?\n/).forEach((row) => {
if (/^\s*\d+,/.test(row)) {
// A quoted image identifier may itself contain commas.
const fields = row.match(/^\s*\d+,(?:"(?:[^"]|"")*"|[^,]*),([^,]*),/);
const frame = fields ? Number(fields[1]) : NaN;
if (!Number.isSafeInteger(frame) || frame < 0) throw new Error('Invalid detection frame number.');
maximum = Math.max(maximum, frame);
}
});
return maximum;
}
49 changes: 49 additions & 0 deletions client/dive-common/singleCamera/decisions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Api, DatasetConfig } from '../apispec';
import {
associationUnavailableReason, remapCsvIds, singleCameraContext, validateAssociation,
} from './decisions';

describe('single camera pipeline decisions', () => {
const config = {
subType: 'stereo',
multiCamMedia: { cameras: { left: {}, right: {}, third: {} }, defaultDisplay: 'left' },
} as unknown as DatasetConfig;
function apiFor(ids: Record<string, number[]>) {
return {
loadConfig: vi.fn(async () => config),
loadDetections: vi.fn(async (id: string) => ({ tracks: (ids[id] || []).map((value) => ({ id: value })) })),
} as unknown as Pick<Api, 'loadConfig' | 'loadDetections'>;
}

it('checks every other camera and resolves parent-only runs to defaultDisplay', async () => {
const api = apiFor({ 'rig/third': [500] });
const result = await singleCameraContext(api, 'rig');
expect(result).toMatchObject({ datasetId: 'rig/left', hasOtherTracks: true });
expect(api.loadDetections).toHaveBeenCalledWith('rig/right');
expect(api.loadDetections).toHaveBeenCalledWith('rig/third');
expect(api.loadDetections).not.toHaveBeenCalledWith('rig/left');
});

it('does not prompt just because the selected camera has tracks', async () => {
const result = await singleCameraContext(apiFor({ 'rig/right': [22] }), 'rig/right');
expect(result).toMatchObject({ datasetId: 'rig/right', hasOtherTracks: false });
});

it('rejects unsupported multicam, missing calibration, and disabled stereo', () => {
expect(associationUnavailableReason(false, true, true)).toMatch(/not implemented/);
expect(associationUnavailableReason(true, false, true)).toMatch(/calibration file/);
expect(associationUnavailableReason(true, true, false)).toMatch(/Enable stereo/);
expect(associationUnavailableReason(true, true, true)).toBeNull();
expect(() => validateAssociation(false, true, true)).toThrow('not implemented');
expect(() => validateAssociation(true, true, true)).not.toThrow();
});

it('moves every ID above the other cameras and preserves repeated track states', () => {
const csv = '# comment\n2,frame,0,1,2,3,4,.8,-1\n2,frame,3,1,2,3,4,.9,-1\n0,frame,5,1,2,3,4,.7,-1\n';
expect(remapCsvIds(csv, 999)).toBe(csv.replace(/^2,/gm, '1000,').replace(/^0,/gm, '1001,'));
});

it('refuses unsafe numeric IDs', () => {
expect(() => remapCsvIds('1,frame,0,1,2,3,4,1,-1\n', Number.MAX_SAFE_INTEGER)).toThrow('integer range');
});
});
Loading
Loading