Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
29d8eda
Add a 3D viewer for stereo tracks and detections
bourdaisj Sep 28, 2026
721fe74
Show the 3D viewer from the stereo Display selector
mattdawkins Sep 28, 2026
44798e4
Keep the panels in place and add stereo + 3D display modes
mattdawkins Sep 28, 2026
f316f83
Draw head-to-tail segments and frame the 3D view around the data
mattdawkins Sep 28, 2026
46d4eec
Show the stereo rig and a depth grid in the 3D view
mattdawkins Sep 28, 2026
602d16a
Add labelled axes and highlight the selected track in the 3D view
mattdawkins Sep 29, 2026
0c2806b
Shorten the stereo Display labels
mattdawkins Sep 29, 2026
a43ff78
Select a track by clicking it in the 3D view
mattdawkins Sep 29, 2026
3046984
Deselect on an empty click, fit the Display box, and mark more heights
mattdawkins Sep 29, 2026
e1c7de1
Pick tracks in the 3D view by their ball only
mattdawkins Sep 29, 2026
42fecac
Draw the rig's cameras behind depth 0
mattdawkins Sep 29, 2026
311d79f
Put the 3D view's grid at camera height and simplify its markers
mattdawkins Sep 29, 2026
9c45e53
Rest the camera diagrams on depth 0 and return the grid to the floor
mattdawkins Sep 29, 2026
4f69281
Center the orientation marker's axes on each other
mattdawkins Sep 29, 2026
43d8d9d
Keep the stereo Display box a fixed width
mattdawkins Sep 29, 2026
833795a
Lock the Display box width and add rotate buttons to the 3D view
mattdawkins Sep 29, 2026
2683dc8
Drop an unused lint exemption from the track drawer
mattdawkins Sep 29, 2026
0bd0bc2
Stop the Display box from clipping its label
mattdawkins Sep 29, 2026
f577a7c
Copy .npmrc into the web image build so the client installs
mattdawkins Sep 29, 2026
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
174 changes: 164 additions & 10 deletions client/dive-common/components/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ import type {
} from 'dive-common/use/useModeManager';
import clientSettingsSetup, { clientSettings, isStereoInteractiveModeEnabled } from 'dive-common/store/settings';
import {
useApi, FrameImage, DatasetType, GlobalStyleSettings,
useApi, FrameImage, DatasetType, GlobalStyleSettings, DatasetStereoCalibration,
} from 'dive-common/apispec';
import { parentDatasetId } from 'dive-common/compositeDatasetId';
import { orderedMultiCamCameraNames } from 'dive-common/multicamDisplay';
import {
buildAlignedTimeline, buildInverseAlignedIndex, computeGapSlots, TimelineResult,
Expand All @@ -94,6 +95,12 @@ import {
import { usePrompt } from 'dive-common/vue-utilities/prompt-service';
import context from 'dive-common/store/context';
import { MarkChangesPendingFilter } from 'vue-media-annotator/BaseFilterControls';
import TrackViewer from 'vue-media-annotator/components/track_3d_viewer/TrackViewer.vue';
import TrackViewerSettings from 'vue-media-annotator/components/track_3d_viewer/TrackViewerSettings.vue';
import TrackViewerSettingsStore from 'vue-media-annotator/components/track_3d_viewer/TrackViewerSettingsStore';
import {
displayOptions, displayValue, parseDisplayValue, Viewer3dMode,
} from 'dive-common/stereoDisplay';
import GroupSidebarVue from './GroupSidebar.vue';
import MultiCamToolsVue from './MultiCamTools.vue';
import RegistrationToolsVue from './CameraRegistration/RegistrationTools.vue';
Expand Down Expand Up @@ -133,6 +140,7 @@ export default defineComponent({
UnsavedChangesDialog,
EditorMenu,
MultiCamToolbar,
TrackViewer,
AlignedViewToggle,
PrimaryAttributeTrackFilter,
TrackList,
Expand Down Expand Up @@ -255,7 +263,7 @@ export default defineComponent({
const {
loadDetections, loadConfig, saveConfig, getTiles, getTileURL, getTileHistogram,
loadGlobalStyleSettings, saveGlobalStyleSettings,
getPipelineList, runPipeline, watchPipelineJob,
getPipelineList, runPipeline, watchPipelineJob, getDatasetCalibration,
} = useApi();
const progress = reactive({
// Loaded flag prevents annotator window from populating
Expand Down Expand Up @@ -2105,6 +2113,18 @@ export default defineComponent({
}
}
}
viewer3dRequested.value = 'off';
if (isStereoPair.value) {
context.register({
component: TrackViewerSettings,
description: '3D Viewer Settings',
});
} else {
context.unregister({
component: TrackViewerSettings,
description: '3D Viewer Settings',
});
}
annotationUndo.start();
progress.loaded = true;
fetchSelectedCameraHistogram().catch(() => {});
Expand Down Expand Up @@ -2272,13 +2292,57 @@ export default defineComponent({

};

const trackViewerSettingsStore = new TrackViewerSettingsStore();
const viewer3dRequested = ref('off' as Viewer3dMode);
const isStereoPair = computed(() => subType.value === 'stereo' && multiCamList.value.length === 2);
const viewer3dMode = computed(() => (isStereoPair.value ? viewer3dRequested.value : 'off'));
const viewer3dActive = computed(() => viewer3dMode.value !== 'off');
const displayItems = computed(() => displayOptions(
multiCamList.value,
isStereoPair.value,
defaultCamera.value,
));
const selectedDisplay = computed(() => displayValue(selectedCamera.value, viewer3dMode.value));
function changeDisplay(value: string) {
const { camera, mode } = parseDisplayValue(value);
viewer3dRequested.value = mode;
changeCamera(camera);
}
/** In 'replace' mode the 3D viewer takes the pane of the camera that is not selected. */
function isCameraReplacedBy3dViewer(camera: string) {
return viewer3dMode.value === 'replace' && camera !== selectedCamera.value;
}
const viewer3dPaneOrder = computed(() => (
viewer3dMode.value === 'replace' && multiCamList.value.indexOf(selectedCamera.value) !== 0
? -1 : 1
));
const rigCalibration = ref(null as DatasetStereoCalibration | null);
watch(viewer3dActive, async (active) => {
if (!active) {
return;
}
try {
const result = await getDatasetCalibration(parentDatasetId(datasetId.value));
rigCalibration.value = result?.calibration ?? null;
} catch {
rigCalibration.value = null;
}
});
watch([viewer3dMode, selectedCamera], async () => {
await nextTick();
handleResize();
// The bottom layout has no controls container for handleResize to act on.
onResize();
});

provideAnnotator(
{
annotatorPreferences: toRef(clientSettings, 'annotatorPreferences'),
attributes,
cameraStore,
cameraRegistration,
alignedView,
trackViewerSettingsStore,
datasetId,
editingMode,
groupFilters,
Expand Down Expand Up @@ -2599,6 +2663,14 @@ export default defineComponent({
defaultCamera,
selectedCamera,
changeCamera,
isStereoPair,
viewer3dActive,
viewer3dPaneOrder,
rigCalibration,
displayItems,
selectedDisplay,
changeDisplay,
isCameraReplacedBy3dViewer,
noteRightMouseDown,
// For Navigation Guarding
unsavedChangesDialog,
Expand Down Expand Up @@ -2809,19 +2881,20 @@ export default defineComponent({
</EditorMenu>
<v-select
v-if="showMultiCamToolbar && multiCamList.length > 1"
:value="selectedCamera"
:items="multiCamList"
label="Camera"
:value="selectedDisplay"
:items="displayItems"
:label="isStereoPair ? 'Display' : 'Camera'"
class="mx-1 shrink camera-select"
:menu-props="{ minWidth: 140 }"
:class="{ 'display-select': isStereoPair }"
:menu-props="{ minWidth: isStereoPair ? 200 : 140 }"
outlined
hide-details
dense
variant="default"
@change="changeCamera"
@change="changeDisplay"
>
<template #item="{ item }">
{{ item }} {{ item === defaultCamera ? '(Default)' : '' }}
{{ item.menuText }}
</template>
</v-select>
<aligned-view-toggle v-if="multiCamList.length > 1 && subType !== 'stereo'" />
Expand Down Expand Up @@ -2978,7 +3051,11 @@ export default defineComponent({
<div
v-for="camera in multiCamList"
:key="camera"
:class="displayedCameras.includes(camera) ? 'd-flex flex-column grow' : 'd-none'"
:class="[
displayedCameras.includes(camera) && !isCameraReplacedBy3dViewer(camera)
? 'd-flex flex-column grow' : 'd-none',
{ 'viewer-3d-split': viewer3dActive },
]"
:style="{ height: `calc(100% - ${controlsHeight}px)` }"
@mousedown.left="changeCamera(camera, $event)"
@mousedown.right="noteRightMouseDown"
Expand Down Expand Up @@ -3009,6 +3086,16 @@ export default defineComponent({
<LayerManager :camera="camera" />
</component>
</div>
<div
v-if="viewer3dActive"
class="d-flex flex-column grow viewer-3d-split"
:style="{ height: `calc(100% - ${controlsHeight}px)`, order: viewer3dPaneOrder }"
>
<track-viewer
:controls-height="controlsHeight"
:calibration="rigCalibration"
/>
</div>
</div>
<ControlsContainer
ref="controlsRef"
Expand Down Expand Up @@ -3077,7 +3164,10 @@ export default defineComponent({
<div
v-for="camera in multiCamList"
:key="camera"
class="d-flex flex-column grow"
:class="[
isCameraReplacedBy3dViewer(camera) ? 'd-none' : 'd-flex flex-column grow',
{ 'viewer-3d-split': viewer3dActive },
]"
@mousedown.left="changeCamera(camera, $event)"
@mousedown.right="noteRightMouseDown"
@mouseup.right="changeCamera(camera, $event)"
Expand Down Expand Up @@ -3107,6 +3197,16 @@ export default defineComponent({
<LayerManager :camera="camera" />
</component>
</div>
<div
v-if="viewer3dActive"
class="d-flex flex-column grow viewer-3d-split"
:style="{ order: viewer3dPaneOrder }"
>
<track-viewer
:controls-height="0"
:calibration="rigCalibration"
/>
</div>
</div>
<BottomPanel
:sidebar-mode="sidebarMode"
Expand Down Expand Up @@ -3269,6 +3369,60 @@ html {
font-size: 0.9em;
}

// Fixed, so choosing another display never moves the buttons beside it; a
// label too long for the box is cut short. Without a minimum the toolbar
// squeezes the box to its label when space runs short, which is what let its
// width follow the selection.
.camera-select.display-select {
flex: 0 0 120px !important;
width: 120px !important;
min-width: 120px !important;
max-width: 120px !important;
font-size: 0.8em;

// Let the wrappers shrink to the box. Only the selected text is clipped:
// the label floats above the wrappers and clipping them would cut it off.
.v-input__control,
.v-input__slot,
.v-select__slot {
min-width: 0;
max-width: 100%;
}

.v-select__selections {
min-width: 0;
max-width: 100%;
overflow: hidden;
}

// The left padding stays as Vuetify sets it, so the label lines up with
// the gap left for it in the outline
.v-input__slot {
padding-right: 4px !important;
}

.v-select__selection--comma {
max-width: 100%;
margin-right: 0;
}

.v-select__selections input {
flex: 0 0 0;
width: 0;
padding: 0;
}

.v-input__append-inner {
padding-left: 0;
}
}

.viewer-3d-split {
flex: 1 1 0 !important;
min-width: 0;
min-height: 0;
}

.camera-select .v-select__selections {
flex-wrap: nowrap;
min-width: 0;
Expand Down
25 changes: 25 additions & 0 deletions client/dive-common/stereoDisplay.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { displayOptions, displayValue, parseDisplayValue } from './stereoDisplay';

describe('stereoDisplay', () => {
it('offers each stereo camera selected, beside the 3D viewer, or with it as a third pane', () => {
expect(displayOptions(['left', 'right'], true, 'left').map((option) => option.text)).toEqual([
'Stereo (Left)', 'Stereo (Right)',
'Left + 3D', 'Right + 3D',
'Stereo (Left) + 3D', 'Stereo (Right) + 3D',
]);
});

it('lists plain cameras for other multi-camera datasets', () => {
expect(displayOptions(['eo', 'ir'], false, 'ir')).toEqual([
{ value: 'camera:eo', text: 'eo', menuText: 'eo' },
{ value: 'camera:ir', text: 'ir', menuText: 'ir (Default)' },
]);
});

it('round-trips a display value', () => {
expect(parseDisplayValue(displayValue('right', 'replace'))).toEqual({ camera: 'right', mode: 'replace' });
expect(parseDisplayValue(displayValue('left', 'beside'))).toEqual({ camera: 'left', mode: 'beside' });
expect(parseDisplayValue(displayValue('viewer3d:odd', 'off')))
.toEqual({ camera: 'viewer3d:odd', mode: 'off' });
});
});
58 changes: 58 additions & 0 deletions client/dive-common/stereoDisplay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Entries of the viewer's camera selector. A stereo pair offers, per selected
* camera: both cameras, that camera beside the 3D viewer (which takes the other
* camera's pane), or both cameras with the 3D viewer as a third pane.
*/
export type Viewer3dMode = 'off' | 'replace' | 'beside';

export interface DisplayOption {
value: string;
text: string;
menuText: string;
}

const PREFIXES: Record<Viewer3dMode, string> = {
off: 'camera:',
replace: 'viewer3d:',
beside: 'stereo3d:',
};
const MODES = Object.keys(PREFIXES) as Viewer3dMode[];

export function displayValue(camera: string, mode: Viewer3dMode) {
return `${PREFIXES[mode]}${camera}`;
}

export function parseDisplayValue(value: string): { camera: string; mode: Viewer3dMode } {
const mode = MODES.find((candidate) => value.startsWith(PREFIXES[candidate])) ?? 'off';
return { camera: value.slice(PREFIXES[mode].length), mode };
}

function capitalize(name: string) {
return name.charAt(0).toUpperCase() + name.slice(1);
}

function stereoLabel(camera: string, mode: Viewer3dMode) {
const name = capitalize(camera);
if (mode === 'replace') {
return `${name} + 3D`;
}
return mode === 'beside' ? `Stereo (${name}) + 3D` : `Stereo (${name})`;
}

export function displayOptions(
cameras: readonly string[],
stereo: boolean,
defaultCamera: string,
): DisplayOption[] {
if (!stereo) {
return cameras.map((camera) => ({
value: displayValue(camera, 'off'),
text: camera,
menuText: camera === defaultCamera ? `${camera} (Default)` : camera,
}));
}
return MODES.flatMap((mode) => cameras.map((camera) => {
const text = stereoLabel(camera, mode);
return { value: displayValue(camera, mode), text, menuText: text };
}));
}
5 changes: 5 additions & 0 deletions client/dive-common/store/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import AttributesSideBar from 'dive-common/components/Attributes/AttributesSideB
import MultiCamTools from 'dive-common/components/MultiCamTools.vue';
import RegistrationTools from 'dive-common/components/CameraRegistration/RegistrationTools.vue';
import AttributeTrackFilters from 'vue-media-annotator/components/AttributeTrackFilters.vue';
import TrackViewerSettings from 'vue-media-annotator/components/track_3d_viewer/TrackViewerSettings.vue';
import DatasetInfo from 'dive-common/components/DatasetInfo/DatasetInfo.vue';

interface ContextState {
Expand Down Expand Up @@ -62,6 +63,10 @@ const componentMapEntries: ComponentMapItem[] = [
description: 'Attribute Track Filters',
component: AttributeTrackFilters,
},
{
description: '3D Viewer Settings',
component: TrackViewerSettings,
},
];

const componentMap: Record<string, ComponentMapItem> = Object.fromEntries(
Expand Down
Loading
Loading