- Multicam Tools for creating tracks, linking and unlinking tracks
+ Multi Camera Tools for creating tracks, linking and unlinking tracks
diff --git a/client/dive-common/components/TrackSettingsPanel.vue b/client/dive-common/components/TrackSettingsPanel.vue
index fa00103ee..e0aeb6a68 100644
--- a/client/dive-common/components/TrackSettingsPanel.vue
+++ b/client/dive-common/components/TrackSettingsPanel.vue
@@ -333,7 +333,7 @@ export default defineComponent({
- Multi-Camera Settings
+ Multi Camera Settings
{
it('returns icons and tooltips', () => {
expect(getMultiCamIcon('stereo')).toBe('mdi-binoculars');
expect(getMultiCamIcon('multicam')).toBe('mdi-camera-burst');
- expect(getMultiCamTooltip('stereo')).toBe('Stereoscopic dataset');
- expect(getMultiCamTooltip('multicam')).toBe('Multicamera dataset');
+ expect(getMultiCamTooltip('stereo')).toBe('Stereo dataset');
+ expect(getMultiCamTooltip('multicam')).toBe('Multi Camera dataset');
});
it('orders cameras using cameraOrder when present', () => {
diff --git a/client/dive-common/multicamDisplay.ts b/client/dive-common/multicamDisplay.ts
index 10771eb33..ca7b03189 100644
--- a/client/dive-common/multicamDisplay.ts
+++ b/client/dive-common/multicamDisplay.ts
@@ -27,7 +27,7 @@ export function getMultiCamIcon(subType: MultiCamSubType): string {
}
export function getMultiCamTooltip(subType: MultiCamSubType): string {
- return subType === 'stereo' ? 'Stereoscopic dataset' : 'Multicamera dataset';
+ return subType === 'stereo' ? 'Stereo dataset' : 'Multi Camera dataset';
}
/** Camera names in display order (import / storage order). */
diff --git a/client/dive-common/stereoBatchScan.spec.ts b/client/dive-common/stereoBatchScan.spec.ts
new file mode 100644
index 000000000..76df28672
--- /dev/null
+++ b/client/dive-common/stereoBatchScan.spec.ts
@@ -0,0 +1,203 @@
+import { CollectSubfolderScan } from './multiCamBatchScan';
+import {
+ StereoBatchRawScan,
+ StereoCollectRawScan,
+ scanStereoBatchFromScan,
+} from './stereoBatchScan';
+
+function subfolders(
+ root: string,
+ entries: [string, number][],
+): Map {
+ const map = new Map();
+ entries.forEach(([folderName, imageCount]) => {
+ map.set(folderName.toLowerCase(), {
+ folderName,
+ path: `${root}/${folderName}`,
+ entryCount: imageCount,
+ imageCount,
+ });
+ });
+ return map;
+}
+
+function collect(
+ name: string,
+ entries: [string, number][],
+ extra: Partial = {},
+): StereoCollectRawScan {
+ const path = `/survey/${name}`;
+ return {
+ name,
+ path,
+ subfolders: subfolders(path, entries),
+ ...extra,
+ };
+}
+
+function scan(raw: Partial) {
+ return scanStereoBatchFromScan({ rootPath: '/survey', collects: [], ...raw });
+}
+
+describe('scanStereoBatchFromScan', () => {
+ it('resolves left/right camera folders per collect', () => {
+ const result = scan({
+ collects: [
+ collect('collect1', [['left', 10], ['right', 10]]),
+ collect('collect2', [['left', 8], ['right', 8]]),
+ ],
+ });
+ expect(result.problems).toEqual([]);
+ expect(result.cameraNames).toEqual(['left', 'right']);
+ expect(result.collects).toHaveLength(2);
+ const [first] = result.collects;
+ expect(first.importArgs?.sourceList.left.sourcePath).toBe('/survey/collect1/left');
+ expect(first.importArgs?.sourceList.right.sourcePath).toBe('/survey/collect1/right');
+ expect(first.importArgs?.defaultDisplay).toBe('left');
+ expect(first.importArgs?.type).toBe('image-sequence');
+ });
+
+ it('resolves marker suffixed camera folders', () => {
+ const result = scan({
+ collects: [collect('collect1', [['cam_L', 5], ['cam_R', 5]])],
+ });
+ expect(result.collects[0].importArgs?.sourceList.left.sourcePath)
+ .toBe('/survey/collect1/cam_L');
+ expect(result.collects[0].importArgs?.sourceList.right.sourcePath)
+ .toBe('/survey/collect1/cam_R');
+ });
+
+ it('falls back to listing order for exactly two unnamed camera folders', () => {
+ const result = scan({
+ collects: [collect('collect1', [['camA', 5], ['camB', 5]])],
+ });
+ const [first] = result.collects;
+ expect(first.importArgs?.sourceList.left.sourcePath).toBe('/survey/collect1/camA');
+ expect(first.importArgs?.sourceList.right.sourcePath).toBe('/survey/collect1/camB');
+ expect(first.warnings.join(' ')).toContain('do not identify sides');
+ });
+
+ it('blocks a collect with three unnamed camera folders', () => {
+ const result = scan({
+ collects: [collect('collect1', [['eo', 5], ['ir', 5], ['uv', 5]])],
+ });
+ expect(result.collects[0].importArgs).toBeNull();
+ expect(result.collects[0].problems.join(' ')).toContain('No left and right camera folders');
+ expect(result.problems.join(' ')).toContain('No stereo datasets found');
+ });
+
+ it('pairs videos inside a collect folder', () => {
+ const result = scan({
+ collects: [collect('collect1', [], {
+ videoFiles: [
+ { name: 'a_left.mp4', path: '/survey/collect1/a_left.mp4' },
+ { name: 'a_right.mp4', path: '/survey/collect1/a_right.mp4' },
+ ],
+ })],
+ });
+ const [first] = result.collects;
+ expect(first.importArgs?.type).toBe('video');
+ expect(first.importArgs?.sourceList.left.sourcePath).toBe('/survey/collect1/a_left.mp4');
+ });
+
+ it('pairs sibling videos in the root into one dataset each', () => {
+ const result = scan({
+ rootVideoFiles: [
+ { name: 'dive01_left.mp4', path: '/survey/dive01_left.mp4' },
+ { name: 'dive01_right.mp4', path: '/survey/dive01_right.mp4' },
+ { name: 'dive02_L.mp4', path: '/survey/dive02_L.mp4' },
+ { name: 'dive02_R.mp4', path: '/survey/dive02_R.mp4' },
+ ],
+ });
+ expect(result.problems).toEqual([]);
+ expect(result.collects.map((c) => c.name)).toEqual(['dive01', 'dive02']);
+ expect(result.collects[1].importArgs?.sourceList.right.sourcePath)
+ .toBe('/survey/dive02_R.mp4');
+ });
+
+ it('pairs sibling image folders in the root', () => {
+ const result = scan({
+ collects: [
+ collect('run_L', [], { imageCount: 12 }),
+ collect('run_R', [], { imageCount: 12 }),
+ ],
+ });
+ expect(result.collects).toHaveLength(1);
+ expect(result.collects[0].name).toBe('run');
+ expect(result.collects[0].importArgs?.sourceList.left.sourcePath).toBe('/survey/run_L');
+ });
+
+ it('descends into L/R named folders that hold no images of their own', () => {
+ const result = scan({
+ collects: [
+ collect('run_L', [['left', 4], ['right', 4]]),
+ collect('run_R', [['left', 4], ['right', 4]]),
+ ],
+ });
+ expect(result.collects.map((c) => c.name)).toEqual(['run_L', 'run_R']);
+ expect(result.collects[0].importArgs?.sourceList.left.sourcePath)
+ .toBe('/survey/run_L/left');
+ });
+
+ it('attaches a collect calibration file and falls back to the root one', () => {
+ const result = scan({
+ collects: [
+ collect('collect1', [['left', 4], ['right', 4]], {
+ calibrationFile: '/survey/collect1/calibration.npz',
+ }),
+ collect('collect2', [['left', 4], ['right', 4]]),
+ ],
+ rootCalibrationFile: '/survey/shared_calibration.npz',
+ });
+ expect(result.collects[0].importArgs?.calibrationFile)
+ .toBe('/survey/collect1/calibration.npz');
+ expect(result.collects[1].importArgs?.calibrationFile)
+ .toBe('/survey/shared_calibration.npz');
+ });
+
+ it('warns when no calibration file is found', () => {
+ const result = scan({
+ collects: [collect('collect1', [['left', 4], ['right', 4]])],
+ });
+ expect(result.collects[0].warnings.join(' ')).toContain('No stereo calibration file');
+ });
+
+ it('warns when frame counts differ', () => {
+ const result = scan({
+ collects: [collect('collect1', [['left', 10], ['right', 8]])],
+ });
+ expect(result.collects[0].warnings.join(' ')).toContain('Frame counts differ');
+ });
+
+ it('reports an empty root', () => {
+ const result = scan({});
+ expect(result.problems.join(' ')).toContain('No folders or videos found');
+ });
+
+ it('keeps importable collects alongside blocked ones', () => {
+ const result = scan({
+ collects: [
+ collect('good', [['left', 4], ['right', 4]]),
+ collect('bad', [['eo', 4], ['ir', 4], ['uv', 4]]),
+ ],
+ });
+ expect(result.problems).toEqual([]);
+ expect(result.collects.find((c) => c.name === 'good')?.importArgs).not.toBeNull();
+ expect(result.collects.find((c) => c.name === 'bad')?.importArgs).toBeNull();
+ });
+
+ it('suffixes collect name and datasetName when a root pair already took the stem', () => {
+ const result = scan({
+ rootVideoFiles: [
+ { name: 'dive01_left.mp4', path: '/survey/dive01_left.mp4' },
+ { name: 'dive01_right.mp4', path: '/survey/dive01_right.mp4' },
+ ],
+ collects: [collect('dive01', [['left', 4], ['right', 4]])],
+ });
+ const names = result.collects.map((c) => c.name);
+ expect(names).toContain('dive01');
+ expect(names).toContain('dive01_2');
+ const suffixed = result.collects.find((c) => c.name === 'dive01_2');
+ expect(suffixed?.importArgs?.datasetName).toBe('dive01_2');
+ });
+});
diff --git a/client/dive-common/stereoBatchScan.ts b/client/dive-common/stereoBatchScan.ts
new file mode 100644
index 000000000..d63f9133b
--- /dev/null
+++ b/client/dive-common/stereoBatchScan.ts
@@ -0,0 +1,358 @@
+/**
+ * Batch stereo import scan logic shared by desktop and web.
+ *
+ * Unlike the multi camera batch scan this one requires every dataset to resolve
+ * to a left and a right camera; anything that does not is reported and skipped.
+ * Three layouts are recognized:
+ *
+ * 1. A collect folder holding camera subfolders (or videos) whose names carry
+ * l / left / r / right markers, including the plain `left` + `right` case.
+ * 2. A collect folder holding exactly two camera subfolders (or two videos)
+ * with names that say nothing about sides; listing order decides.
+ * 3. Sibling folders or videos in the root whose names differ only by an L/R
+ * marker (`dive01_left.mp4` + `dive01_right.mp4`); each pair is a dataset.
+ */
+import { MultiCamImportFolderArgs } from 'dive-common/apispec';
+import {
+ CollectSubfolderScan,
+ MultiCamBatchCamera,
+ MultiCamBatchCollect,
+ MultiCamBatchScanResult,
+} from 'dive-common/multiCamBatchScan';
+import { pairStereoNames } from 'dive-common/stereoPairing';
+
+/** Camera names a stereo dataset must use for the importer to mark it stereo. */
+export const StereoCameraNames = ['left', 'right'] as const;
+
+/** A video file discovered during a stereo batch scan. */
+export interface StereoVideoScan {
+ name: string;
+ path: string;
+}
+
+/** One immediate child folder of the scan root. */
+export interface StereoCollectRawScan {
+ name: string;
+ path: string;
+ /** Immediate camera subfolders, keyed by lower-cased folder name. */
+ subfolders: Map;
+ /** Video files sitting directly in this folder. */
+ videoFiles?: StereoVideoScan[];
+ /** Images sitting directly in this folder (makes it a camera, not a collect). */
+ imageCount?: number;
+ /** Stereo calibration file found in this folder. */
+ calibrationFile?: string | null;
+}
+
+export interface StereoBatchRawScan {
+ rootPath: string;
+ collects: StereoCollectRawScan[];
+ /** Video files sitting directly in the root, for root level L/R pairing. */
+ rootVideoFiles?: StereoVideoScan[];
+ /** Calibration file in the root, used when a collect has none of its own. */
+ rootCalibrationFile?: string | null;
+}
+
+interface StereoCameraSource {
+ sourcePath: string;
+ /** Frame count for image cameras; videos report no count. */
+ imageCount?: number;
+}
+
+function lastPathSegment(path: string): string {
+ return path.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? '';
+}
+
+function buildStereoCollect(options: {
+ name: string;
+ path: string;
+ datasetName: string;
+ left: StereoCameraSource;
+ right: StereoCameraSource;
+ type: 'image-sequence' | 'video';
+ calibrationFile?: string | null;
+ warnings: string[];
+}): MultiCamBatchCollect {
+ const {
+ name, path, datasetName, left, right, type, calibrationFile, warnings,
+ } = options;
+
+ const cameras: MultiCamBatchCamera[] = [
+ { name: 'left', sourcePath: left.sourcePath, imageCount: left.imageCount ?? 0 },
+ { name: 'right', sourcePath: right.sourcePath, imageCount: right.imageCount ?? 0 },
+ ];
+
+ const allWarnings = [...warnings];
+ if (type === 'image-sequence'
+ && left.imageCount !== undefined && right.imageCount !== undefined
+ && left.imageCount !== right.imageCount) {
+ allWarnings.push(
+ `Frame counts differ across cameras (left: ${left.imageCount}, right: ${right.imageCount})`,
+ );
+ }
+ if (!calibrationFile) {
+ allWarnings.push(
+ 'No stereo calibration file found; import it later to enable measurement',
+ );
+ }
+
+ const sourceList: MultiCamImportFolderArgs['sourceList'] = {
+ left: { sourcePath: left.sourcePath, trackFile: '' },
+ right: { sourcePath: right.sourcePath, trackFile: '' },
+ };
+
+ return {
+ name,
+ path,
+ cameras,
+ transformFiles: [],
+ problems: [],
+ warnings: allWarnings,
+ importArgs: {
+ datasetName,
+ defaultDisplay: 'left',
+ cameraOrder: ['left', 'right'],
+ sourceList,
+ type,
+ ...(calibrationFile ? { calibrationFile } : {}),
+ },
+ };
+}
+
+function blockedCollect(
+ name: string,
+ path: string,
+ problems: string[],
+): MultiCamBatchCollect {
+ return {
+ name,
+ path,
+ cameras: [],
+ transformFiles: [],
+ problems,
+ warnings: [],
+ importArgs: null,
+ };
+}
+
+/** Resolve one collect folder to a left/right pair using layouts 1 and 2. */
+function resolveCollect(
+ collect: StereoCollectRawScan,
+ rootCalibrationFile: string | null,
+): MultiCamBatchCollect {
+ const calibrationFile = collect.calibrationFile ?? rootCalibrationFile;
+ const imageSubfolders = [...collect.subfolders.values()]
+ .filter((subfolder) => subfolder.imageCount > 0)
+ .sort((a, b) => a.folderName.localeCompare(b.folderName));
+ const videoFiles = [...(collect.videoFiles ?? [])]
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ const toCamera = (subfolder: CollectSubfolderScan): StereoCameraSource => ({
+ sourcePath: subfolder.path,
+ imageCount: subfolder.imageCount,
+ });
+ const toVideoCamera = (video: StereoVideoScan): StereoCameraSource => ({
+ sourcePath: video.path,
+ });
+
+ if (imageSubfolders.length) {
+ const { pairs } = pairStereoNames(imageSubfolders, (subfolder) => subfolder.folderName);
+ if (pairs.length === 1) {
+ return buildStereoCollect({
+ name: collect.name,
+ path: collect.path,
+ datasetName: collect.name,
+ left: toCamera(pairs[0].left),
+ right: toCamera(pairs[0].right),
+ type: 'image-sequence',
+ calibrationFile,
+ warnings: [],
+ });
+ }
+ if (pairs.length > 1) {
+ return blockedCollect(collect.name, collect.path, [
+ `Found ${pairs.length} left/right camera folder pairs; expected one stereo pair`,
+ ]);
+ }
+ if (imageSubfolders.length === 2) {
+ return buildStereoCollect({
+ name: collect.name,
+ path: collect.path,
+ datasetName: collect.name,
+ left: toCamera(imageSubfolders[0]),
+ right: toCamera(imageSubfolders[1]),
+ type: 'image-sequence',
+ calibrationFile,
+ warnings: [
+ `Camera folder names do not identify sides; "${imageSubfolders[0].folderName}" `
+ + `was taken as left and "${imageSubfolders[1].folderName}" as right`,
+ ],
+ });
+ }
+ return blockedCollect(collect.name, collect.path, [
+ 'No left and right camera folders found (looked for l/left and r/right names, '
+ + `or exactly two camera folders; found ${imageSubfolders.length}: `
+ + `${imageSubfolders.map((subfolder) => subfolder.folderName).join(', ')})`,
+ ]);
+ }
+
+ if (videoFiles.length) {
+ const { pairs } = pairStereoNames(videoFiles, (video) => video.name);
+ if (pairs.length === 1) {
+ return buildStereoCollect({
+ name: collect.name,
+ path: collect.path,
+ datasetName: collect.name,
+ left: toVideoCamera(pairs[0].left),
+ right: toVideoCamera(pairs[0].right),
+ type: 'video',
+ calibrationFile,
+ warnings: [],
+ });
+ }
+ if (pairs.length > 1) {
+ return blockedCollect(collect.name, collect.path, [
+ `Found ${pairs.length} left/right video pairs; expected one stereo pair`,
+ ]);
+ }
+ if (videoFiles.length === 2) {
+ return buildStereoCollect({
+ name: collect.name,
+ path: collect.path,
+ datasetName: collect.name,
+ left: toVideoCamera(videoFiles[0]),
+ right: toVideoCamera(videoFiles[1]),
+ type: 'video',
+ calibrationFile,
+ warnings: [
+ `Video names do not identify sides; "${videoFiles[0].name}" was taken as left `
+ + `and "${videoFiles[1].name}" as right`,
+ ],
+ });
+ }
+ return blockedCollect(collect.name, collect.path, [
+ 'No left and right videos found (looked for l/left and r/right names, or exactly '
+ + `two videos; found ${videoFiles.length}: ${videoFiles.map((v) => v.name).join(', ')})`,
+ ]);
+ }
+
+ return blockedCollect(collect.name, collect.path, [
+ 'No camera folders with images and no videos found',
+ ]);
+}
+
+function uniqueName(name: string, taken: Set): string {
+ if (!taken.has(name)) {
+ taken.add(name);
+ return name;
+ }
+ let suffix = 2;
+ while (taken.has(`${name}_${suffix}`)) {
+ suffix += 1;
+ }
+ const unique = `${name}_${suffix}`;
+ taken.add(unique);
+ return unique;
+}
+
+/** Suffix the collect display name and keep importArgs.datasetName in sync. */
+function withUniqueName(
+ collect: MultiCamBatchCollect,
+ taken: Set,
+): MultiCamBatchCollect {
+ const name = uniqueName(collect.name, taken);
+ if (name === collect.name) {
+ return collect;
+ }
+ return {
+ ...collect,
+ name,
+ importArgs: collect.importArgs
+ ? { ...collect.importArgs, datasetName: name }
+ : null,
+ };
+}
+
+/**
+ * Build a stereo batch scan result from a pre-scanned root folder.
+ */
+export function scanStereoBatchFromScan(raw: StereoBatchRawScan): MultiCamBatchScanResult {
+ const { rootPath } = raw;
+ const rootCalibrationFile = raw.rootCalibrationFile ?? null;
+ const rootLabel = lastPathSegment(rootPath);
+ const problems: string[] = [];
+ const collects: MultiCamBatchCollect[] = [];
+ const takenNames = new Set();
+
+ // Layout 3, videos: root level video files whose names pair by L/R marker.
+ const rootVideos = [...(raw.rootVideoFiles ?? [])].sort((a, b) => a.name.localeCompare(b.name));
+ const videoPairing = pairStereoNames(rootVideos, (video) => video.name);
+ videoPairing.pairs.forEach((pair) => {
+ const stem = pair.stem || rootLabel;
+ const name = uniqueName(stem, takenNames);
+ collects.push(buildStereoCollect({
+ name,
+ path: rootPath,
+ datasetName: name,
+ left: { sourcePath: pair.left.path },
+ right: { sourcePath: pair.right.path },
+ type: 'video',
+ calibrationFile: rootCalibrationFile,
+ warnings: [],
+ }));
+ });
+
+ // Layout 3, folders: root level folders that hold images directly and whose
+ // names pair by L/R marker. Folders without their own images are collects to
+ // descend into instead, even when their names happen to pair.
+ const cameraFolders = raw.collects.filter((collect) => (collect.imageCount ?? 0) > 0);
+ const folderPairing = pairStereoNames(cameraFolders, (collect) => collect.name);
+ const pairedFolders = new Set();
+ folderPairing.pairs.forEach((pair) => {
+ pairedFolders.add(pair.left);
+ pairedFolders.add(pair.right);
+ const stem = pair.stem || rootLabel;
+ const name = uniqueName(stem, takenNames);
+ collects.push(buildStereoCollect({
+ name,
+ path: rootPath,
+ datasetName: name,
+ left: {
+ sourcePath: pair.left.path,
+ imageCount: pair.left.imageCount,
+ },
+ right: {
+ sourcePath: pair.right.path,
+ imageCount: pair.right.imageCount,
+ },
+ type: 'image-sequence',
+ calibrationFile: pair.left.calibrationFile ?? rootCalibrationFile,
+ warnings: [],
+ }));
+ });
+
+ // Layouts 1 and 2: everything else is a collect folder holding both cameras.
+ raw.collects
+ .filter((collect) => !pairedFolders.has(collect))
+ .forEach((collect) => {
+ const resolved = resolveCollect(collect, rootCalibrationFile);
+ collects.push(withUniqueName(resolved, takenNames));
+ });
+
+ if (!raw.collects.length && !rootVideos.length) {
+ problems.push(`No folders or videos found in ${rootPath}`);
+ } else if (!collects.some((collect) => collect.importArgs)) {
+ problems.push(
+ 'No stereo datasets found. Each dataset needs a left and a right camera: name '
+ + 'folders or videos with l/left and r/right markers, or leave exactly two '
+ + 'camera folders or videos per collect folder.',
+ );
+ }
+
+ return {
+ rootPath,
+ cameraNames: [...StereoCameraNames],
+ collects,
+ problems,
+ };
+}
diff --git a/client/dive-common/stereoPairing.spec.ts b/client/dive-common/stereoPairing.spec.ts
new file mode 100644
index 000000000..a9a206692
--- /dev/null
+++ b/client/dive-common/stereoPairing.spec.ts
@@ -0,0 +1,94 @@
+import {
+ detectStereoSideByCharDiff,
+ detectStereoSideToken,
+ pairStereoNames,
+} from './stereoPairing';
+
+const names = (list: string[]) => pairStereoNames(list, (name) => name);
+
+describe('detectStereoSideToken', () => {
+ it('matches bare left and right', () => {
+ expect(detectStereoSideToken('left')).toEqual({ stem: '', side: 'left' });
+ expect(detectStereoSideToken('RIGHT')).toEqual({ stem: '', side: 'right' });
+ });
+
+ it('matches delimited markers of any case', () => {
+ expect(detectStereoSideToken('dive01_L.mp4')).toEqual({ stem: 'dive01', side: 'left' });
+ expect(detectStereoSideToken('dive01-Right.mp4')).toEqual({ stem: 'dive01', side: 'right' });
+ expect(detectStereoSideToken('cam l 02')).toEqual({ stem: 'cam_02', side: 'left' });
+ });
+
+ it('does not match substrings inside other tokens', () => {
+ expect(detectStereoSideToken('cam_ir')).toBeNull();
+ expect(detectStereoSideToken('rgb')).toBeNull();
+ expect(detectStereoSideToken('lateral')).toBeNull();
+ });
+
+ it('rejects names carrying both markers', () => {
+ expect(detectStereoSideToken('left_right')).toBeNull();
+ });
+});
+
+describe('detectStereoSideByCharDiff', () => {
+ it('matches a glued single character marker', () => {
+ expect(detectStereoSideByCharDiff('camL.mp4', 'camR.mp4'))
+ .toEqual({ stem: 'cam', leftFirst: true });
+ expect(detectStereoSideByCharDiff('camR.mp4', 'camL.mp4'))
+ .toEqual({ stem: 'cam', leftFirst: false });
+ });
+
+ it('ignores differences that are not L/R', () => {
+ expect(detectStereoSideByCharDiff('camA.mp4', 'camB.mp4')).toBeNull();
+ });
+
+ it('ignores names differing in more than one character', () => {
+ expect(detectStereoSideByCharDiff('camLL.mp4', 'camRR.mp4')).toBeNull();
+ });
+});
+
+describe('pairStereoNames', () => {
+ it('pairs left and right folders', () => {
+ const result = names(['left', 'right']);
+ expect(result.pairs).toEqual([{ stem: '', left: 'left', right: 'right' }]);
+ expect(result.unpaired).toEqual([]);
+ });
+
+ it('pairs multiple sibling video pairs by stem', () => {
+ const result = names([
+ 'dive01_left.mp4', 'dive01_right.mp4', 'dive02_left.mp4', 'dive02_right.mp4',
+ ]);
+ expect(result.pairs).toEqual([
+ { stem: 'dive01', left: 'dive01_left.mp4', right: 'dive01_right.mp4' },
+ { stem: 'dive02', left: 'dive02_left.mp4', right: 'dive02_right.mp4' },
+ ]);
+ expect(result.unpaired).toEqual([]);
+ });
+
+ it('pairs across differing extensions and marker spellings', () => {
+ const result = names(['run_L.mp4', 'run_r.avi']);
+ expect(result.pairs).toEqual([{ stem: 'run', left: 'run_L.mp4', right: 'run_r.avi' }]);
+ });
+
+ it('falls back to single character difference', () => {
+ const result = names(['camL.mp4', 'camR.mp4']);
+ expect(result.pairs).toEqual([{ stem: 'cam', left: 'camL.mp4', right: 'camR.mp4' }]);
+ });
+
+ it('leaves a lone side unpaired', () => {
+ const result = names(['dive01_left.mp4', 'dive02_right.mp4']);
+ expect(result.pairs).toEqual([]);
+ expect(result.unpaired).toEqual(['dive01_left.mp4', 'dive02_right.mp4']);
+ });
+
+ it('leaves duplicate sides for one stem unpaired', () => {
+ const result = names(['a_left.mp4', 'a_l.mp4', 'a_right.mp4']);
+ expect(result.pairs).toEqual([]);
+ expect(result.unpaired).toHaveLength(3);
+ });
+
+ it('does not pair EO/IR style folders', () => {
+ const result = names(['eo', 'ir']);
+ expect(result.pairs).toEqual([]);
+ expect(result.unpaired).toEqual(['eo', 'ir']);
+ });
+});
diff --git a/client/dive-common/stereoPairing.ts b/client/dive-common/stereoPairing.ts
new file mode 100644
index 000000000..4e82bfb4d
--- /dev/null
+++ b/client/dive-common/stereoPairing.ts
@@ -0,0 +1,175 @@
+/**
+ * Left/right pairing for stereo batch import.
+ *
+ * Recognizes l / left / r / right markers in folder and video file names and
+ * pairs names that are otherwise identical, so `dive01_L.mp4` + `dive01_R.mp4`
+ * or `left/` + `right/` resolve to one stereo dataset.
+ */
+
+export type StereoSide = 'left' | 'right';
+
+const LEFT_TOKENS = new Set(['l', 'left']);
+const RIGHT_TOKENS = new Set(['r', 'right']);
+
+/** Name separators that delimit a side marker token. */
+const DELIMITERS = /[_\-.\s]+/;
+
+/** Drop a trailing file extension; names without one pass through. */
+export function stripExtension(name: string): string {
+ const index = name.lastIndexOf('.');
+ return index > 0 ? name.slice(0, index) : name;
+}
+
+export interface StereoSideMatch {
+ /** Name with the side marker removed; the pairing key. */
+ stem: string;
+ side: StereoSide;
+}
+
+/**
+ * Detect a delimited l/left/r/right token. Requires exactly one such token, so
+ * `cam_ir` (ends in "r" but tokenizes to cam + ir) and `left_right` are misses.
+ */
+export function detectStereoSideToken(name: string): StereoSideMatch | null {
+ const parts = stripExtension(name).split(DELIMITERS).filter(Boolean);
+ const matched: number[] = [];
+ parts.forEach((part, index) => {
+ const lower = part.toLowerCase();
+ if (LEFT_TOKENS.has(lower) || RIGHT_TOKENS.has(lower)) {
+ matched.push(index);
+ }
+ });
+ if (matched.length !== 1) {
+ return null;
+ }
+ const index = matched[0];
+ return {
+ stem: parts.filter((_, i) => i !== index).join('_'),
+ side: LEFT_TOKENS.has(parts[index].toLowerCase()) ? 'left' : 'right',
+ };
+}
+
+export interface StereoCharDiff {
+ /** Shared name with the differing character removed. */
+ stem: string;
+ /** Which of the two inputs is the left camera. */
+ leftFirst: boolean;
+}
+
+/**
+ * Pair two names that differ at exactly one character where that character is
+ * L/R, covering markers glued to the name (`camL.mp4` / `camR.mp4`).
+ */
+export function detectStereoSideByCharDiff(a: string, b: string): StereoCharDiff | null {
+ const baseA = stripExtension(a);
+ const baseB = stripExtension(b);
+ if (baseA.length !== baseB.length || baseA === baseB) {
+ return null;
+ }
+ let diffIndex = -1;
+ for (let i = 0; i < baseA.length; i += 1) {
+ if (baseA[i] !== baseB[i]) {
+ if (diffIndex !== -1) {
+ return null;
+ }
+ diffIndex = i;
+ }
+ }
+ if (diffIndex === -1) {
+ return null;
+ }
+ const charA = baseA[diffIndex].toLowerCase();
+ const charB = baseB[diffIndex].toLowerCase();
+ const stem = baseA.slice(0, diffIndex) + baseA.slice(diffIndex + 1);
+ if (charA === 'l' && charB === 'r') {
+ return { stem, leftFirst: true };
+ }
+ if (charA === 'r' && charB === 'l') {
+ return { stem, leftFirst: false };
+ }
+ return null;
+}
+
+export interface StereoPair {
+ /** Shared name with the side marker removed; empty when the names are bare left/right. */
+ stem: string;
+ left: T;
+ right: T;
+}
+
+export interface StereoPairingResult {
+ pairs: StereoPair[];
+ unpaired: T[];
+}
+
+/**
+ * Pair a list of folder or video names into stereo pairs. Delimited markers are
+ * matched first; whatever is left over is matched by single-character L/R
+ * difference. A name that could join more than one pair is left unpaired.
+ */
+export function pairStereoNames(
+ items: T[],
+ getName: (item: T) => string,
+): StereoPairingResult {
+ const pairs: StereoPair[] = [];
+ const consumed = new Set();
+
+ const byStem = new Map();
+ items.forEach((item) => {
+ const match = detectStereoSideToken(getName(item));
+ if (!match) {
+ return;
+ }
+ const group = byStem.get(match.stem) ?? { left: [], right: [] };
+ group[match.side].push(item);
+ byStem.set(match.stem, group);
+ });
+
+ // Sorted for deterministic output regardless of directory listing order.
+ [...byStem.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .forEach(([stem, group]) => {
+ if (group.left.length !== 1 || group.right.length !== 1) {
+ return;
+ }
+ pairs.push({ stem, left: group.left[0], right: group.right[0] });
+ consumed.add(group.left[0]);
+ consumed.add(group.right[0]);
+ });
+
+ const remaining = items.filter((item) => !consumed.has(item));
+ const charPairs: { stem: string; left: T; right: T }[] = [];
+ const ambiguous = new Set();
+ for (let i = 0; i < remaining.length; i += 1) {
+ for (let j = i + 1; j < remaining.length; j += 1) {
+ const diff = detectStereoSideByCharDiff(getName(remaining[i]), getName(remaining[j]));
+ if (diff) {
+ const [left, right] = diff.leftFirst
+ ? [remaining[i], remaining[j]]
+ : [remaining[j], remaining[i]];
+ // A name matching more than one partner is too ambiguous to guess at.
+ if (charPairs.some((pair) => pair.left === left || pair.right === left
+ || pair.left === right || pair.right === right)) {
+ ambiguous.add(left);
+ ambiguous.add(right);
+ } else {
+ charPairs.push({ stem: diff.stem, left, right });
+ }
+ }
+ }
+ }
+
+ charPairs.forEach((pair) => {
+ if (ambiguous.has(pair.left) || ambiguous.has(pair.right)) {
+ return;
+ }
+ pairs.push(pair);
+ consumed.add(pair.left);
+ consumed.add(pair.right);
+ });
+
+ return {
+ pairs,
+ unpaired: items.filter((item) => !consumed.has(item)),
+ };
+}
diff --git a/client/platform/desktop/backend/ipcService.ts b/client/platform/desktop/backend/ipcService.ts
index 9c4dcc3e5..bbc8d8cf4 100644
--- a/client/platform/desktop/backend/ipcService.ts
+++ b/client/platform/desktop/backend/ipcService.ts
@@ -24,6 +24,7 @@ import win32 from './native/windows';
import * as common from './native/common';
import beginMultiCamImport from './native/multiCamImport';
import scanMultiCamBatch from './native/multiCollectImport';
+import scanStereoBatch from './native/stereoCollectImport';
import settings from './state/settings';
import { listen } from './server';
import {
@@ -244,6 +245,11 @@ export default function register() {
return ret;
});
+ ipcMain.handle('scan-stereo-batch', async (event, { path: rootPath }: { path: string }) => {
+ const ret = await scanStereoBatch(rootPath);
+ return ret;
+ });
+
ipcMain.handle('import-annotation', async (event, {
id, path, additive, additivePrepend,
}: { id: string; path: string; additive: boolean; additivePrepend: string }) => {
diff --git a/client/platform/desktop/backend/native/common.ts b/client/platform/desktop/backend/native/common.ts
index 9cd4f887c..ebbd3b623 100644
--- a/client/platform/desktop/backend/native/common.ts
+++ b/client/platform/desktop/backend/native/common.ts
@@ -2445,6 +2445,7 @@ export {
saveAttributes,
saveAttributeTrackFilters,
findImagesInFolder,
+ isVideoFilePath,
listImmediateSubfolders,
listParentFolderCameras,
resolveMulticamCameraSourcePath,
diff --git a/client/platform/desktop/backend/native/stereoCollectImport.spec.ts b/client/platform/desktop/backend/native/stereoCollectImport.spec.ts
new file mode 100644
index 000000000..62f4fefa5
--- /dev/null
+++ b/client/platform/desktop/backend/native/stereoCollectImport.spec.ts
@@ -0,0 +1,190 @@
+import mockfs from 'mock-fs';
+import { Console } from 'console';
+
+// https://github.com/tschaub/mock-fs/issues/234
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+const console = new Console(process.stdout, process.stderr);
+
+vi.mock('fs-extra', async () => {
+ const actual = await vi.importActual('fs-extra');
+ const fsNode = await import('node:fs');
+ const existsByStat = (targetPath: import('node:fs').PathLike) => {
+ try {
+ fsNode.statSync(targetPath);
+ return true;
+ } catch {
+ return false;
+ }
+ };
+
+ const patchedDefault = {
+ ...actual.default,
+ existsSync: existsByStat,
+ pathExistsSync: existsByStat,
+ };
+
+ return {
+ ...actual,
+ default: patchedDefault,
+ existsSync: existsByStat,
+ pathExistsSync: existsByStat,
+ };
+});
+
+vi.mock('./mediaJobs', () => ({
+ checkMedia: vi.fn(() => Promise.resolve({
+ websafe: true,
+ originalFpsString: '30/1',
+ originalFps: 30,
+ videoDimensions: { width: 1920, height: 1080 },
+ })),
+}));
+
+// eslint-disable-next-line import/first
+import scanStereoBatch from './stereoCollectImport';
+// eslint-disable-next-line import/first
+import beginMultiCamImport from './multiCamImport';
+
+const frames = (count: number, prefix = 'frame') => {
+ const files: Record = {};
+ for (let i = 0; i < count; i += 1) {
+ files[`${prefix}_${String(i).padStart(4, '0')}.png`] = '';
+ }
+ return files;
+};
+
+afterEach(() => {
+ mockfs.restore();
+});
+
+describe('native.stereoCollectImport', () => {
+ it('resolves left/right camera folders per collect', async () => {
+ mockfs({
+ '/survey': {
+ fl01: { left: frames(3), right: frames(3) },
+ fl02: { left: frames(2), right: frames(2) },
+ },
+ });
+ const result = await scanStereoBatch('/survey');
+ expect(result.problems).toEqual([]);
+ expect(result.cameraNames).toEqual(['left', 'right']);
+ expect(result.collects.map((collect) => collect.name)).toEqual(['fl01', 'fl02']);
+ expect(result.collects[0].importArgs?.sourceList.left.sourcePath)
+ .toBe('/survey/fl01/left');
+ });
+
+ it('resolves marker suffixed camera folders', async () => {
+ mockfs({
+ '/survey': {
+ fl01: { cam_L: frames(2), cam_R: frames(2) },
+ },
+ });
+ const result = await scanStereoBatch('/survey');
+ expect(result.collects[0].importArgs?.sourceList.left.sourcePath)
+ .toBe('/survey/fl01/cam_L');
+ });
+
+ it('falls back to listing order for two unnamed camera folders', async () => {
+ mockfs({
+ '/survey': {
+ fl01: { camA: frames(2), camB: frames(2) },
+ },
+ });
+ const result = await scanStereoBatch('/survey');
+ expect(result.collects[0].importArgs?.sourceList.left.sourcePath)
+ .toBe('/survey/fl01/camA');
+ expect(result.collects[0].warnings.join(' ')).toContain('do not identify sides');
+ });
+
+ it('errors when no left/right pair can be found', async () => {
+ mockfs({
+ '/survey': {
+ fl01: { EO: frames(2), IR: frames(2), UV: frames(2) },
+ },
+ });
+ const result = await scanStereoBatch('/survey');
+ expect(result.collects[0].importArgs).toBeNull();
+ expect(result.problems.join(' ')).toContain('No stereo datasets found');
+ });
+
+ it('pairs sibling videos in the root', async () => {
+ mockfs({
+ '/survey': {
+ 'dive01_left.mp4': '',
+ 'dive01_right.mp4': '',
+ 'dive02_L.mp4': '',
+ 'dive02_R.mp4': '',
+ },
+ });
+ const result = await scanStereoBatch('/survey');
+ expect(result.problems).toEqual([]);
+ expect(result.collects.map((collect) => collect.name)).toEqual(['dive01', 'dive02']);
+ expect(result.collects[0].importArgs?.type).toBe('video');
+ });
+
+ it('pairs sibling image folders in the root', async () => {
+ mockfs({
+ '/survey': {
+ run_L: frames(4),
+ run_R: frames(4),
+ },
+ });
+ const result = await scanStereoBatch('/survey');
+ expect(result.collects).toHaveLength(1);
+ expect(result.collects[0].name).toBe('run');
+ expect(result.collects[0].importArgs?.sourceList.right.sourcePath).toBe('/survey/run_R');
+ });
+
+ it('attaches a calibration file found next to the cameras', async () => {
+ mockfs({
+ '/survey': {
+ 'calibration.npz': '',
+ fl01: { left: frames(2), right: frames(2) },
+ },
+ });
+ const result = await scanStereoBatch('/survey');
+ expect(result.collects[0].importArgs?.calibrationFile).toBe('/survey/calibration.npz');
+ });
+
+ it('produces args that import as a stereo dataset', async () => {
+ mockfs({
+ '/survey': {
+ fl01: { left: frames(2), right: frames(2) },
+ },
+ });
+ const result = await scanStereoBatch('/survey');
+ const { importArgs } = result.collects[0];
+ expect(importArgs).not.toBeNull();
+ if (!importArgs) {
+ return;
+ }
+ const imported = await beginMultiCamImport(importArgs);
+ expect(imported.jsonConfig.name).toBe('fl01');
+ expect(imported.jsonConfig.subType).toBe('stereo');
+ expect(Object.keys(imported.jsonConfig.multiCam?.cameras ?? {})).toEqual(['left', 'right']);
+ expect(imported.jsonConfig.multiCam?.defaultDisplay).toBe('left');
+ expect(imported.jsonConfig.multiCam?.cameras.left.originalBasePath)
+ .toBe('/survey/fl01/left');
+ });
+
+ it('imports a root level video pair as a stereo dataset', async () => {
+ mockfs({
+ '/survey': {
+ 'dive01_left.mp4': '',
+ 'dive01_right.mp4': '',
+ },
+ });
+ const result = await scanStereoBatch('/survey');
+ const { importArgs } = result.collects[0];
+ expect(importArgs).not.toBeNull();
+ if (!importArgs) {
+ return;
+ }
+ const imported = await beginMultiCamImport(importArgs);
+ expect(imported.jsonConfig.subType).toBe('stereo');
+ expect(imported.jsonConfig.multiCam?.cameras.left.originalVideoFile)
+ .toBe('dive01_left.mp4');
+ expect(imported.jsonConfig.multiCam?.cameras.right.originalVideoFile)
+ .toBe('dive01_right.mp4');
+ });
+});
diff --git a/client/platform/desktop/backend/native/stereoCollectImport.ts b/client/platform/desktop/backend/native/stereoCollectImport.ts
new file mode 100644
index 000000000..9142f696d
--- /dev/null
+++ b/client/platform/desktop/backend/native/stereoCollectImport.ts
@@ -0,0 +1,82 @@
+/**
+ * Batch stereo import scanner (desktop filesystem backend).
+ */
+import npath from 'path';
+import fs from 'fs-extra';
+import { CollectSubfolderScan } from 'dive-common/multiCamBatchScan';
+import {
+ StereoBatchRawScan,
+ StereoCollectRawScan,
+ StereoVideoScan,
+ scanStereoBatchFromScan,
+} from 'dive-common/stereoBatchScan';
+import {
+ findImagesInFolder,
+ isVideoFilePath,
+ listImmediateSubfolders,
+} from './common';
+import { findParentFolderCalibrationFile } from './datasetCalibration';
+
+async function listVideoFiles(folderPath: string): Promise {
+ const entries = await fs.readdir(folderPath, { withFileTypes: true });
+ return entries
+ .filter((entry) => entry.isFile() && !entry.name.startsWith('.'))
+ .filter((entry) => isVideoFilePath(entry.name))
+ .map((entry) => ({ name: entry.name, path: npath.join(folderPath, entry.name) }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+}
+
+async function scanCollectSubfolders(collectPath: string) {
+ const subfolderNames = await listImmediateSubfolders(collectPath);
+ const subfolders = new Map();
+ for (let i = 0; i < subfolderNames.length; i += 1) {
+ const folderName = subfolderNames[i];
+ const subfolderPath = npath.join(collectPath, folderName);
+ // eslint-disable-next-line no-await-in-loop
+ const entryCount = (await fs.readdir(subfolderPath)).length;
+ // eslint-disable-next-line no-await-in-loop
+ const found = await findImagesInFolder(subfolderPath);
+ subfolders.set(folderName.toLowerCase(), {
+ folderName,
+ path: subfolderPath,
+ entryCount,
+ imageCount: found.imagePaths.length,
+ });
+ }
+ return subfolders;
+}
+
+async function scanStereoBatch(rootPath: string) {
+ const collectNames = (await listImmediateSubfolders(rootPath))
+ .sort((a, b) => a.localeCompare(b));
+
+ const collects: StereoCollectRawScan[] = [];
+ for (let i = 0; i < collectNames.length; i += 1) {
+ const name = collectNames[i];
+ const collectPath = npath.join(rootPath, name);
+ collects.push({
+ name,
+ path: collectPath,
+ // eslint-disable-next-line no-await-in-loop
+ subfolders: await scanCollectSubfolders(collectPath),
+ // eslint-disable-next-line no-await-in-loop
+ videoFiles: await listVideoFiles(collectPath),
+ // Images directly here make this folder one camera of a root level pair.
+ // eslint-disable-next-line no-await-in-loop
+ imageCount: (await findImagesInFolder(collectPath)).imagePaths.length,
+ // eslint-disable-next-line no-await-in-loop
+ calibrationFile: await findParentFolderCalibrationFile(collectPath),
+ });
+ }
+
+ const raw: StereoBatchRawScan = {
+ rootPath,
+ collects,
+ rootVideoFiles: await listVideoFiles(rootPath),
+ rootCalibrationFile: await findParentFolderCalibrationFile(rootPath),
+ };
+
+ return scanStereoBatchFromScan(raw);
+}
+
+export default scanStereoBatch;
diff --git a/client/platform/desktop/frontend/api.ts b/client/platform/desktop/frontend/api.ts
index fc3c48b85..29022dc11 100644
--- a/client/platform/desktop/frontend/api.ts
+++ b/client/platform/desktop/frontend/api.ts
@@ -270,6 +270,10 @@ function scanMultiCamBatch(path: string): Promise {
return window.diveDesktop.invoke('scan-multicam-batch', { path });
}
+function scanStereoBatch(path: string): Promise {
+ return window.diveDesktop.invoke('scan-stereo-batch', { path });
+}
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function importAnnotationFile(id: string, path: string, _htmlFile = undefined, additive = false, additivePrepend = ''): Promise {
return window.diveDesktop.invoke('import-annotation', {
@@ -328,7 +332,7 @@ async function exportMulticamEverything(
): Promise {
const parentId = id.split('/')[0];
const location = await window.diveDesktop.showSaveDialog({
- title: 'Export Multicamera Dataset',
+ title: 'Export Multi Camera Dataset',
defaultPath: joinPath(
await window.diveDesktop.getAppPath('home'),
`${parentId}.zip`,
@@ -778,6 +782,7 @@ export {
importAnnotationFile,
importMultiCam,
scanMultiCamBatch,
+ scanStereoBatch,
openLink,
nvidiaSmi,
cancelJob,
diff --git a/client/platform/desktop/frontend/components/ImportStereoBatchDialog.vue b/client/platform/desktop/frontend/components/ImportStereoBatchDialog.vue
new file mode 100644
index 000000000..6b8da1dc0
--- /dev/null
+++ b/client/platform/desktop/frontend/components/ImportStereoBatchDialog.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
diff --git a/client/platform/desktop/frontend/components/Recent.vue b/client/platform/desktop/frontend/components/Recent.vue
index 63639f167..91ff6c06a 100644
--- a/client/platform/desktop/frontend/components/Recent.vue
+++ b/client/platform/desktop/frontend/components/Recent.vue
@@ -33,6 +33,7 @@ import NavigationBar from './NavigationBar.vue';
import ImportDialog from './ImportDialog.vue';
import BulkImportDialog from './BulkImportDialog.vue';
import ImportMultiCamBatchDialog from './ImportMultiCamBatchDialog.vue';
+import ImportStereoBatchDialog from './ImportStereoBatchDialog.vue';
export default defineComponent({
components: {
@@ -44,6 +45,7 @@ export default defineComponent({
NavigationBar,
ImportMultiCamDialog,
ImportMultiCamBatchDialog,
+ ImportStereoBatchDialog,
TooltipBtn,
},
@@ -51,6 +53,7 @@ export default defineComponent({
const router = useRouter();
const importMultiCamDialog = ref(false);
const importMultiCamBatchDialog = ref(false);
+ const importStereoBatchDialog = ref(false);
const pendingImportPayload: Ref = ref(null);
const bulkImport = ref(false);
const searchText: Ref = ref('');
@@ -354,6 +357,7 @@ export default defineComponent({
importing,
importMultiCamDialog,
importMultiCamBatchDialog,
+ importStereoBatchDialog,
headers,
upgradedVersion,
downgradedVersion,
@@ -408,6 +412,18 @@ export default defineComponent({
@abort="importMultiCamBatchDialog = false"
/>
+
+
+
-
diff --git a/client/platform/web-girder/views/Upload.vue b/client/platform/web-girder/views/Upload.vue
index dcc271f3d..204c6e395 100644
--- a/client/platform/web-girder/views/Upload.vue
+++ b/client/platform/web-girder/views/Upload.vue
@@ -489,7 +489,7 @@ export default defineComponent({
const { data: datasetFolder } = await createGirderFolder({
folderId: props.location._id,
name: datasetName,
- description: 'Multicamera dataset',
+ description: 'Multi Camera dataset',
});
datasetFolderId = datasetFolder._id;
const cameras: Record = {};
@@ -613,7 +613,7 @@ export default defineComponent({
}
if (stereo.value && !isAllowedStereoCalibrationFilename(calFile.name)) {
throw new Error(
- `Stereoscopic calibration must be ${stereoCalibrationAllowedExtensionsLabel()}.`,
+ `Stereo calibration must be ${stereoCalibrationAllowedExtensionsLabel()}.`,
);
}
calibrationFileId = await uploadCalibrationItem(datasetFolder._id, calFile);
diff --git a/client/src/components/Tracks/sidebar/SideBarTrackItemView.vue b/client/src/components/Tracks/sidebar/SideBarTrackItemView.vue
index 67b03b003..ff5e946d3 100644
--- a/client/src/components/Tracks/sidebar/SideBarTrackItemView.vue
+++ b/client/src/components/Tracks/sidebar/SideBarTrackItemView.vue
@@ -255,7 +255,7 @@ export default defineComponent({