From c0f9a11242485a0cb56d59ffa660181ed34fd888 Mon Sep 17 00:00:00 2001 From: romleiaj Date: Sat, 4 Jul 2026 07:17:54 -0400 Subject: [PATCH 1/9] Add batch multicam collect-folder scanner Scans a root folder of collect folders (collect -> camera -> images) and produces per-collect beginMultiCamImport arguments for MML seal surveys, without copying imagery. Camera subfolders are matched case-insensitively across collects; the canonical camera set is the union of image-bearing subfolder names, limited to 2-3 alphanumeric cameras to match the existing multicam subfolder import. Missing/empty/imageless camera folders are reported as per-collect blocking problems; frame-count mismatches are non-blocking warnings (seal collects legitimately drop frames). Default display camera follows pickDefaultMulticamCamera (center/middle alias, else the middle camera of display order). Co-Authored-By: Claude Fable 5 --- .../backend/native/multiCollectImport.spec.ts | 263 ++++++++++++++++++ .../backend/native/multiCollectImport.ts | 230 +++++++++++++++ client/platform/desktop/constants.ts | 40 ++- 3 files changed, 532 insertions(+), 1 deletion(-) create mode 100644 client/platform/desktop/backend/native/multiCollectImport.spec.ts create mode 100644 client/platform/desktop/backend/native/multiCollectImport.ts diff --git a/client/platform/desktop/backend/native/multiCollectImport.spec.ts b/client/platform/desktop/backend/native/multiCollectImport.spec.ts new file mode 100644 index 000000000..7ff0dd325 --- /dev/null +++ b/client/platform/desktop/backend/native/multiCollectImport.spec.ts @@ -0,0 +1,263 @@ +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 scanMultiCamBatch from './multiCollectImport'; +// 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.multiCollectImport', () => { + it('produces import args for every valid collect', async () => { + mockfs({ + '/survey': { + fl01: { EO: frames(3), IR: frames(3), UV: frames(3) }, + fl02: { EO: frames(2), IR: frames(2), UV: frames(2) }, + }, + }); + const result = await scanMultiCamBatch('/survey'); + expect(result.problems).toEqual([]); + expect(result.cameraNames).toEqual(['EO', 'IR', 'UV']); + expect(result.collects.map((c) => c.name)).toEqual(['fl01', 'fl02']); + result.collects.forEach((collect) => { + expect(collect.problems).toEqual([]); + expect(collect.warnings).toEqual([]); + expect(collect.importArgs).not.toBeNull(); + expect(collect.importArgs?.datasetName).toBe(collect.name); + expect(collect.importArgs?.cameraOrder).toEqual(['EO', 'IR', 'UV']); + expect(collect.importArgs?.type).toBe('image-sequence'); + // middle camera of the display order (no center/middle alias present) + expect(collect.importArgs?.defaultDisplay).toBe('IR'); + expect(collect.importArgs?.sourceList).toEqual({ + EO: { sourcePath: `/survey/${collect.name}/EO`, trackFile: '' }, + IR: { sourcePath: `/survey/${collect.name}/IR`, trackFile: '' }, + UV: { sourcePath: `/survey/${collect.name}/UV`, trackFile: '' }, + }); + }); + }); + + it('flags a collect missing a camera folder without blocking others', async () => { + mockfs({ + '/survey': { + fl01: { EO: frames(3), IR: frames(3) }, + fl02: { EO: frames(3) }, + }, + }); + const result = await scanMultiCamBatch('/survey'); + expect(result.problems).toEqual([]); + expect(result.cameraNames).toEqual(['EO', 'IR']); + const [fl01, fl02] = result.collects; + expect(fl01.problems).toEqual([]); + expect(fl01.importArgs).not.toBeNull(); + expect(fl02.problems).toEqual(['Missing camera folder "IR"']); + expect(fl02.importArgs).toBeNull(); + }); + + it('distinguishes empty camera folders from folders with no supported images', async () => { + mockfs({ + '/survey': { + fl01: { EO: frames(2), IR: frames(2) }, + fl02: { EO: frames(2), IR: {} }, + fl03: { EO: frames(2), IR: { 'notes.txt': 'x' } }, + }, + }); + const result = await scanMultiCamBatch('/survey'); + const [, fl02, fl03] = result.collects; + expect(fl02.problems).toEqual(['Camera folder "IR" is empty']); + expect(fl03.problems).toEqual(['No supported images in camera folder "IR"']); + expect(fl02.importArgs).toBeNull(); + expect(fl03.importArgs).toBeNull(); + }); + + it('ignores non-camera folders and loose files inside collects', async () => { + mockfs({ + '/survey': { + 'flight_manifest.csv': 'a,b', + fl01: { + EO: frames(2), + IR: frames(2), + logs: { 'run.log': 'ok' }, + 'readme.txt': 'notes', + }, + fl02: { EO: frames(2), IR: frames(2), logs: {} }, + }, + }); + const result = await scanMultiCamBatch('/survey'); + expect(result.problems).toEqual([]); + expect(result.cameraNames).toEqual(['EO', 'IR']); + expect(result.collects.map((c) => c.name)).toEqual(['fl01', 'fl02']); + result.collects.forEach((collect) => { + expect(collect.problems).toEqual([]); + expect(Object.keys(collect.importArgs?.sourceList ?? {})).toEqual(['EO', 'IR']); + }); + }); + + it('warns (without blocking) on frame count mismatch between cameras', async () => { + mockfs({ + '/survey': { + fl01: { EO: frames(3), IR: frames(2) }, + }, + }); + const result = await scanMultiCamBatch('/survey'); + const [fl01] = result.collects; + expect(fl01.problems).toEqual([]); + expect(fl01.warnings).toEqual(['Frame counts differ across cameras (EO: 3, IR: 2)']); + expect(fl01.importArgs).not.toBeNull(); + expect(fl01.cameras.map((c) => c.imageCount)).toEqual([3, 2]); + }); + + it('matches camera folders case-insensitively across collects', async () => { + mockfs({ + '/survey': { + fl01: { EO: frames(2), IR: frames(2) }, + fl02: { eo: frames(2), ir: frames(2) }, + }, + }); + const result = await scanMultiCamBatch('/survey'); + expect(result.cameraNames).toEqual(['EO', 'IR']); + const [, fl02] = result.collects; + expect(fl02.problems).toEqual([]); + expect(fl02.importArgs?.sourceList).toEqual({ + EO: { sourcePath: '/survey/fl02/eo', trackFile: '' }, + IR: { sourcePath: '/survey/fl02/ir', trackFile: '' }, + }); + }); + + it('prefers a center camera for defaultDisplay and the STAR/CENTER/PORT order', async () => { + mockfs({ + '/survey': { + fl01: { CENTER: frames(2), PORT: frames(2), STAR: frames(2) }, + }, + }); + const result = await scanMultiCamBatch('/survey'); + expect(result.cameraNames).toEqual(['STAR', 'CENTER', 'PORT']); + expect(result.collects[0].importArgs?.defaultDisplay).toBe('CENTER'); + }); + + it('reports a root problem when no collect folders exist', async () => { + mockfs({ '/survey': { 'stray.png': '' } }); + const result = await scanMultiCamBatch('/survey'); + expect(result.problems).toEqual(['No collect folders found in /survey']); + expect(result.collects).toEqual([]); + }); + + it('blocks all collects when fewer than two cameras are shared', async () => { + mockfs({ + '/survey': { + fl01: { EO: frames(2) }, + fl02: { EO: frames(2) }, + }, + }); + const result = await scanMultiCamBatch('/survey'); + expect(result.problems).toEqual([ + 'Expected 2 or 3 camera folders shared across collects, found 1 (EO)', + ]); + result.collects.forEach((collect) => { + expect(collect.importArgs).toBeNull(); + }); + }); + + it('blocks all collects when more than three cameras are shared', async () => { + mockfs({ + '/survey': { + fl01: { + A: frames(1), B: frames(1), C: frames(1), D: frames(1), + }, + }, + }); + const result = await scanMultiCamBatch('/survey'); + expect(result.problems).toEqual([ + 'Expected 2 or 3 camera folders shared across collects, found 4 (A, B, C, D)', + ]); + expect(result.collects[0].importArgs).toBeNull(); + }); + + it('rejects camera folder names that are not alphanumeric', async () => { + mockfs({ + '/survey': { + fl01: { 'EO cam': frames(2), IR: frames(2) }, + }, + }); + const result = await scanMultiCamBatch('/survey'); + expect(result.problems).toEqual([ + 'Camera folder names must be letters and numbers only (no spaces): EO cam', + ]); + expect(result.collects[0].importArgs).toBeNull(); + }); + + it('throws when the root path does not exist', async () => { + mockfs({ '/survey': {} }); + await expect(scanMultiCamBatch('/missing')).rejects.toThrow('Directory not found'); + }); + + it('produces args accepted by beginMultiCamImport', async () => { + mockfs({ + '/survey': { + fl01: { EO: frames(2), IR: frames(2) }, + }, + }); + const result = await scanMultiCamBatch('/survey'); + const { importArgs } = result.collects[0]; + expect(importArgs).not.toBeNull(); + if (!importArgs) { + return; + } + const imported = await beginMultiCamImport(importArgs); + expect(imported.jsonMeta.name).toBe('fl01'); + expect(imported.jsonMeta.subType).toBe('multicam'); + expect(imported.jsonMeta.multiCam?.defaultDisplay).toBe('EO'); + expect(Object.keys(imported.jsonMeta.multiCam?.cameras ?? {})).toEqual(['EO', 'IR']); + expect(imported.jsonMeta.multiCam?.cameras.EO.originalBasePath).toBe('/survey/fl01/EO'); + expect(imported.jsonMeta.multiCam?.cameras.EO.originalImageFiles).toHaveLength(2); + }); +}); diff --git a/client/platform/desktop/backend/native/multiCollectImport.ts b/client/platform/desktop/backend/native/multiCollectImport.ts new file mode 100644 index 000000000..4380f1e5e --- /dev/null +++ b/client/platform/desktop/backend/native/multiCollectImport.ts @@ -0,0 +1,230 @@ +/** + * Batch multicam ("multi-collect") import scanner. + * + * Scans a root folder whose immediate children are "collect" folders; each collect + * folder is expected to hold the same set of camera subfolders (e.g. EO/, IR/, UV/), + * each containing that camera's image frames. Produces one ready-to-run + * `beginMultiCamImport` argument set per valid collect, without copying any imagery + * (the existing multicam import records `originalBasePath` in place). + * + * Validation rules: + * - Camera subfolders are matched case-insensitively across collects; the canonical + * camera set is the union of subfolder names that contain at least one supported + * image in at least one collect. Subfolders that never contain images anywhere + * (and loose files at the collect level) are ignored as non-camera content. + * - The canonical camera set must contain 2 or 3 alphanumeric camera names, matching + * the limits of the existing multicam subfolder import (multicamSubfolderLayout). + * - Per collect, a canonical camera must exist and contain at least one supported + * image; otherwise the collect is flagged with a blocking problem. + * - Differing frame counts between cameras within a collect are reported as a + * non-blocking warning (seal collects legitimately drop frames on some cameras). + * - `defaultDisplay` follows the existing convention (`pickDefaultMulticamCamera`): + * a camera named "center"/"middle" when present, otherwise the middle camera of + * the display order (for 2 cameras, the first). + */ +import npath from 'path'; +import fs from 'fs-extra'; +import { MultiCamImportFolderArgs } from 'dive-common/apispec'; +import { + isValidCameraName, + orderSubfolderCameraNames, + pickDefaultMulticamCamera, +} from 'dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout'; +import { + MultiCamBatchCamera, + MultiCamBatchCollect, + MultiCamBatchScanResult, +} from 'platform/desktop/constants'; +import { findImagesInFolder, listImmediateSubfolders } from './common'; + +/** Camera-count limits shared with the single multicam subfolder import. */ +export const MinBatchCameras = 2; +export const MaxBatchCameras = 3; + +interface CollectSubfolderScan { + // subfolder name as found on disk + folderName: string; + // absolute path of the subfolder + path: string; + // total directory entries (0 means completely empty folder) + entryCount: number; + // supported images found by the standard multicam image filter + imageCount: number; +} + +interface CollectRawScan { + name: string; + path: string; + // keyed by lowercased subfolder name + subfolders: Map; +} + +async function scanCollectFolder(collectPath: string): Promise> { + const subfolderNames = await listImmediateSubfolders(collectPath); + const subfolders = new Map(); + // Sequential on purpose: keeps disk access predictable for large surveys. + 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; +} + +/** + * Determine the canonical camera set: subfolder names holding at least one supported + * image in at least one collect. Names are matched case-insensitively; the casing of + * the first collect that has images for a camera wins. + */ +function canonicalCameraNames(collects: CollectRawScan[]): string[] { + const canonical = new Map(); // lowercased -> display casing + collects.forEach((collect) => { + collect.subfolders.forEach((subfolder, lowerName) => { + if (subfolder.imageCount > 0 && !canonical.has(lowerName)) { + canonical.set(lowerName, subfolder.folderName); + } + }); + }); + return orderSubfolderCameraNames([...canonical.values()]); +} + +function validateCameraSet(cameraNames: string[]): string[] { + const problems: string[] = []; + if (cameraNames.length < MinBatchCameras || cameraNames.length > MaxBatchCameras) { + problems.push( + `Expected ${MinBatchCameras} or ${MaxBatchCameras} camera folders shared across collects, ` + + `found ${cameraNames.length}${cameraNames.length ? ` (${cameraNames.join(', ')})` : ''}`, + ); + } + const invalid = cameraNames.filter((name) => !isValidCameraName(name)); + if (invalid.length) { + problems.push( + `Camera folder names must be letters and numbers only (no spaces): ${invalid.join(', ')}`, + ); + } + return problems; +} + +function buildCollectResult( + collect: CollectRawScan, + cameraNames: string[], +): MultiCamBatchCollect { + const problems: string[] = []; + const warnings: string[] = []; + const cameras: MultiCamBatchCamera[] = []; + + cameraNames.forEach((cameraName) => { + const subfolder = collect.subfolders.get(cameraName.toLowerCase()); + if (!subfolder) { + problems.push(`Missing camera folder "${cameraName}"`); + return; + } + if (subfolder.entryCount === 0) { + problems.push(`Camera folder "${cameraName}" is empty`); + return; + } + if (subfolder.imageCount === 0) { + problems.push(`No supported images in camera folder "${cameraName}"`); + return; + } + cameras.push({ + name: cameraName, + sourcePath: subfolder.path, + imageCount: subfolder.imageCount, + }); + }); + + if (!problems.length && cameras.length > 1) { + const counts = cameras.map((camera) => camera.imageCount); + if (new Set(counts).size > 1) { + warnings.push( + `Frame counts differ across cameras (${cameras + .map((camera) => `${camera.name}: ${camera.imageCount}`) + .join(', ')})`, + ); + } + } + + let importArgs: MultiCamImportFolderArgs | null = null; + if (!problems.length) { + const sourceList: MultiCamImportFolderArgs['sourceList'] = {}; + cameras.forEach((camera) => { + sourceList[camera.name] = { sourcePath: camera.sourcePath, trackFile: '' }; + }); + importArgs = { + datasetName: collect.name, + defaultDisplay: pickDefaultMulticamCamera(cameraNames), + cameraOrder: [...cameraNames], + sourceList, + type: 'image-sequence', + }; + } + + return { + name: collect.name, + path: collect.path, + cameras, + problems, + warnings, + importArgs, + }; +} + +/** + * Scan a root folder of collect folders and produce per-collect multicam import + * arguments compatible with `beginMultiCamImport`. + */ +async function scanMultiCamBatch(rootPath: string): Promise { + const collectNames = (await listImmediateSubfolders(rootPath)) + .sort((a, b) => a.localeCompare(b)); + + const rawScans: CollectRawScan[] = []; + for (let i = 0; i < collectNames.length; i += 1) { + const name = collectNames[i]; + const collectPath = npath.join(rootPath, name); + rawScans.push({ + name, + path: collectPath, + // eslint-disable-next-line no-await-in-loop + subfolders: await scanCollectFolder(collectPath), + }); + } + + const problems: string[] = []; + if (!rawScans.length) { + problems.push(`No collect folders found in ${rootPath}`); + } + + const cameraNames = canonicalCameraNames(rawScans); + if (rawScans.length) { + problems.push(...validateCameraSet(cameraNames)); + } + + const cameraSetValid = !problems.length; + const collects = rawScans.map((collect) => { + const result = buildCollectResult(collect, cameraNames); + if (!cameraSetValid) { + // A structurally-invalid camera set blocks every collect. + return { ...result, importArgs: null }; + } + return result; + }); + + return { + rootPath, + cameraNames, + collects, + problems, + }; +} + +export default scanMultiCamBatch; diff --git a/client/platform/desktop/constants.ts b/client/platform/desktop/constants.ts index 5b24868ff..763b0e7c3 100644 --- a/client/platform/desktop/constants.ts +++ b/client/platform/desktop/constants.ts @@ -1,6 +1,6 @@ import type { DatasetMeta, DatasetMetaMutable, DatasetType, - Pipe, SubType, MediaImportResponse, PipelineParams, + Pipe, SubType, MediaImportResponse, MultiCamImportFolderArgs, PipelineParams, } from 'dive-common/apispec'; import { Attribute } from 'vue-media-annotator/use/AttributeTypes'; import { AttributeTrackFilter } from 'vue-media-annotator/AttributeTrackFilterControls'; @@ -257,6 +257,44 @@ export interface DesktopMediaImportResponse extends MediaImportResponse { metaFileAbsPath?: string; } +/** One camera subfolder discovered inside a collect folder during a batch scan. */ +export interface MultiCamBatchCamera { + // camera name (canonical camera-subfolder name) + name: string; + // absolute path of the camera subfolder for this collect + sourcePath: string; + // number of supported images found in the subfolder + imageCount: number; +} + +/** Scan result for a single collect folder (one multicam dataset candidate). */ +export interface MultiCamBatchCollect { + // collect folder name (used as the dataset name) + name: string; + // absolute path of the collect folder + path: string; + // cameras found in this collect, in display order + cameras: MultiCamBatchCamera[]; + // blocking issues; when non-empty, importArgs is null + problems: string[]; + // non-blocking issues (e.g. frame count mismatch between cameras) + warnings: string[]; + // ready-to-run beginMultiCamImport arguments, or null when problems exist + importArgs: MultiCamImportFolderArgs | null; +} + +/** Result of scanning a root folder of collect folders for batch multicam import. */ +export interface MultiCamBatchScanResult { + // absolute path of the scanned root folder + rootPath: string; + // canonical camera-subfolder names shared across collects, in display order + cameraNames: string[]; + // per-collect scan results, sorted by collect name + collects: MultiCamBatchCollect[]; + // structural issues with the batch as a whole (no collects, bad camera set, ...) + problems: string[]; +} + export interface DesktopJobUpdate extends DesktopJob { // body contents of update payload body: string[]; From 6525864d2528819a90409869ac7a5dfb92be952f Mon Sep 17 00:00:00 2001 From: romleiaj Date: Sat, 4 Jul 2026 07:21:44 -0400 Subject: [PATCH 2/9] Add batch multicam import UI and desktop wiring New 'MultiCam Batch' entry in the Open Image Sequence dropdown (desktop only, behind a new batch-multi-cam-import prop on ImportButton) opens ImportMultiCamBatchDialog: pick a root folder, review a per-collect validation summary table (cameras, frame counts, problems/warnings), then import all valid collects sequentially via the existing import-multicam-media / finalize-import machinery, continuing past per-collect failures and reporting a final summary. Adds the scan-multicam-batch IPC handler and scanMultiCamBatch frontend API. Co-Authored-By: Claude Fable 5 --- .../dive-common/components/ImportButton.vue | 16 + client/platform/desktop/backend/ipcService.ts | 6 + client/platform/desktop/frontend/api.ts | 6 + .../components/ImportMultiCamBatchDialog.vue | 336 ++++++++++++++++++ .../desktop/frontend/components/Recent.vue | 18 + 5 files changed, 382 insertions(+) create mode 100644 client/platform/desktop/frontend/components/ImportMultiCamBatchDialog.vue diff --git a/client/dive-common/components/ImportButton.vue b/client/dive-common/components/ImportButton.vue index d2bf90bde..0e7dbcaad 100644 --- a/client/dive-common/components/ImportButton.vue +++ b/client/dive-common/components/ImportButton.vue @@ -27,6 +27,10 @@ export default defineComponent({ type: Boolean, default: false, }, + batchMultiCamImport: { // Desktop-only batch import of collect folders (collect/camera/images) + type: Boolean, + default: false, + }, buttonAttrs: { type: Object, default: () => DefaultButtonAttrs, @@ -171,6 +175,18 @@ export default defineComponent({ MultiCam + + + mdi-folder-multiple-image + + + MultiCam Batch + + diff --git a/client/platform/desktop/backend/ipcService.ts b/client/platform/desktop/backend/ipcService.ts index c8b01ab7b..070d4bf44 100644 --- a/client/platform/desktop/backend/ipcService.ts +++ b/client/platform/desktop/backend/ipcService.ts @@ -23,6 +23,7 @@ import linux from './native/linux'; import win32 from './native/windows'; import * as common from './native/common'; import beginMultiCamImport from './native/multiCamImport'; +import scanMultiCamBatch from './native/multiCollectImport'; import settings from './state/settings'; import { listen } from './server'; import { @@ -228,6 +229,11 @@ export default function register() { return ret; }); + ipcMain.handle('scan-multicam-batch', async (event, { path: rootPath }: { path: string }) => { + const ret = await scanMultiCamBatch(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/frontend/api.ts b/client/platform/desktop/frontend/api.ts index 942f2d7d9..e773257ee 100644 --- a/client/platform/desktop/frontend/api.ts +++ b/client/platform/desktop/frontend/api.ts @@ -21,6 +21,7 @@ import { ExportMulticamEverythingArgs, DesktopMediaImportResponse, ConversionArgs, JobType, DesktopJob, + MultiCamBatchScanResult, } from 'platform/desktop/constants'; import { gpuJobQueue, cpuJobQueue } from './store/jobs'; @@ -220,6 +221,10 @@ function importMultiCam(args: MultiCamImportArgs): return window.diveDesktop.invoke('import-multicam-media', { args }); } +function scanMultiCamBatch(path: string): Promise { + return window.diveDesktop.invoke('scan-multicam-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', { @@ -699,6 +704,7 @@ export { checkDataset, importAnnotationFile, importMultiCam, + scanMultiCamBatch, openLink, nvidiaSmi, cancelJob, diff --git a/client/platform/desktop/frontend/components/ImportMultiCamBatchDialog.vue b/client/platform/desktop/frontend/components/ImportMultiCamBatchDialog.vue new file mode 100644 index 000000000..fdf0b2c16 --- /dev/null +++ b/client/platform/desktop/frontend/components/ImportMultiCamBatchDialog.vue @@ -0,0 +1,336 @@ + + + + diff --git a/client/platform/desktop/frontend/components/Recent.vue b/client/platform/desktop/frontend/components/Recent.vue index ae0fad5a2..d0a076616 100644 --- a/client/platform/desktop/frontend/components/Recent.vue +++ b/client/platform/desktop/frontend/components/Recent.vue @@ -31,6 +31,7 @@ import DatasetSourceInfo from './DatasetSourceInfo.vue'; import NavigationBar from './NavigationBar.vue'; import ImportDialog from './ImportDialog.vue'; import BulkImportDialog from './BulkImportDialog.vue'; +import ImportMultiCamBatchDialog from './ImportMultiCamBatchDialog.vue'; export default defineComponent({ components: { @@ -41,12 +42,14 @@ export default defineComponent({ BulkImportDialog, NavigationBar, ImportMultiCamDialog, + ImportMultiCamBatchDialog, TooltipBtn, }, setup() { const router = useRouter(); const importMultiCamDialog = ref(false); + const importMultiCamBatchDialog = ref(false); const pendingImportPayload: Ref = ref(null); const bulkImport = ref(false); const searchText: Ref = ref(''); @@ -269,6 +272,7 @@ export default defineComponent({ error, importing, importMultiCamDialog, + importMultiCamBatchDialog, headers, upgradedVersion, downgradedVersion, @@ -310,6 +314,18 @@ export default defineComponent({ @abort="importMultiCamDialog = false" /> + + + Date: Wed, 8 Jul 2026 11:19:14 -0400 Subject: [PATCH 3/9] make the dropdown area larger to display full multicam batch import option --- client/dive-common/components/ImportButton.vue | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/client/dive-common/components/ImportButton.vue b/client/dive-common/components/ImportButton.vue index 0e7dbcaad..48e03ec34 100644 --- a/client/dive-common/components/ImportButton.vue +++ b/client/dive-common/components/ImportButton.vue @@ -1,6 +1,6 @@ + + diff --git a/client/dive-common/multiCamBatchScan.spec.ts b/client/dive-common/multiCamBatchScan.spec.ts new file mode 100644 index 000000000..51a7dea06 --- /dev/null +++ b/client/dive-common/multiCamBatchScan.spec.ts @@ -0,0 +1,151 @@ +import { + CollectRawScan, + CollectSubfolderScan, + scanMultiCamBatchFromCollects, +} from 'dive-common/multiCamBatchScan'; + +function collectSubfolder( + collectName: string, + cameraName: string, + imageCount: number, + entryCount = imageCount, +): CollectSubfolderScan { + return { + folderName: cameraName, + path: `/survey/${collectName}/${cameraName}`, + entryCount, + imageCount, + }; +} + +function collectScan( + name: string, + subfolders: Record, +): CollectRawScan { + return { + name, + path: `/survey/${name}`, + subfolders: new Map(Object.entries(subfolders).map(([key, value]) => [key.toLowerCase(), value])), + }; +} + +describe('multiCamBatchScan', () => { + it('produces import args for every valid collect', () => { + const result = scanMultiCamBatchFromCollects('/survey', [ + collectScan('fl01', { + EO: collectSubfolder('fl01', 'EO', 3), + IR: collectSubfolder('fl01', 'IR', 3), + UV: collectSubfolder('fl01', 'UV', 3), + }), + collectScan('fl02', { + EO: collectSubfolder('fl02', 'EO', 2), + IR: collectSubfolder('fl02', 'IR', 2), + UV: collectSubfolder('fl02', 'UV', 2), + }), + ]); + expect(result.problems).toEqual([]); + expect(result.cameraNames).toEqual(['EO', 'IR', 'UV']); + expect(result.collects.map((c) => c.name)).toEqual(['fl01', 'fl02']); + result.collects.forEach((collect) => { + expect(collect.problems).toEqual([]); + expect(collect.warnings).toEqual([]); + expect(collect.importArgs).not.toBeNull(); + expect(collect.importArgs?.datasetName).toBe(collect.name); + expect(collect.importArgs?.cameraOrder).toEqual(['EO', 'IR', 'UV']); + expect(collect.importArgs?.type).toBe('image-sequence'); + expect(collect.importArgs?.defaultDisplay).toBe('EO'); + expect(collect.importArgs?.sourceList).toEqual({ + EO: { sourcePath: `/survey/${collect.name}/EO`, trackFile: '' }, + IR: { sourcePath: `/survey/${collect.name}/IR`, trackFile: '' }, + UV: { sourcePath: `/survey/${collect.name}/UV`, trackFile: '' }, + }); + }); + }); + + it('flags a collect missing a camera folder without blocking others', () => { + const result = scanMultiCamBatchFromCollects('/survey', [ + collectScan('fl01', { + EO: collectSubfolder('fl01', 'EO', 3), + IR: collectSubfolder('fl01', 'IR', 3), + }), + collectScan('fl02', { + EO: collectSubfolder('fl02', 'EO', 3), + }), + ]); + expect(result.problems).toEqual([]); + expect(result.cameraNames).toEqual(['EO', 'IR']); + const [fl01, fl02] = result.collects; + expect(fl01.problems).toEqual([]); + expect(fl01.importArgs).not.toBeNull(); + expect(fl02.problems).toEqual(['Missing camera folder "IR"']); + expect(fl02.importArgs).toBeNull(); + }); + + it('distinguishes empty camera folders from folders with no supported images', () => { + const result = scanMultiCamBatchFromCollects('/survey', [ + collectScan('fl01', { + EO: collectSubfolder('fl01', 'EO', 2), + IR: collectSubfolder('fl01', 'IR', 2), + }), + collectScan('fl02', { + EO: collectSubfolder('fl02', 'EO', 2), + IR: collectSubfolder('fl02', 'IR', 0, 0), + }), + collectScan('fl03', { + EO: collectSubfolder('fl03', 'EO', 2), + IR: collectSubfolder('fl03', 'IR', 0, 1), + }), + ]); + const [, fl02, fl03] = result.collects; + expect(fl02.problems).toEqual(['Camera folder "IR" is empty']); + expect(fl03.problems).toEqual(['No supported images in camera folder "IR"']); + expect(fl02.importArgs).toBeNull(); + expect(fl03.importArgs).toBeNull(); + }); + + it('warns (without blocking) on frame count mismatch between cameras', () => { + const result = scanMultiCamBatchFromCollects('/survey', [ + collectScan('fl01', { + EO: collectSubfolder('fl01', 'EO', 3), + IR: collectSubfolder('fl01', 'IR', 2), + }), + ]); + const [fl01] = result.collects; + expect(fl01.problems).toEqual([]); + expect(fl01.warnings).toEqual(['Frame counts differ across cameras (EO: 3, IR: 2)']); + expect(fl01.importArgs).not.toBeNull(); + expect(fl01.cameras.map((c) => c.imageCount)).toEqual([3, 2]); + }); + + it('reports a root problem when no collect folders exist', () => { + const result = scanMultiCamBatchFromCollects('/survey', []); + expect(result.problems).toEqual(['No collect folders found in /survey']); + expect(result.collects).toEqual([]); + }); + + it('blocks all collects when fewer than two cameras are shared', () => { + const result = scanMultiCamBatchFromCollects('/survey', [ + collectScan('fl01', { EO: collectSubfolder('fl01', 'EO', 2) }), + collectScan('fl02', { EO: collectSubfolder('fl02', 'EO', 2) }), + ]); + expect(result.problems).toEqual([ + 'Expected 2 or 3 camera folders shared across collects, found 1 (EO)', + ]); + result.collects.forEach((collect) => { + expect(collect.importArgs).toBeNull(); + }); + }); + + it('rejects camera folder names that are not alphanumeric', () => { + const result = scanMultiCamBatchFromCollects('/survey', [ + collectScan('fl01', { + 'EO cam': collectSubfolder('fl01', 'EO cam', 2), + IR: collectSubfolder('fl01', 'IR', 2), + }), + ]); + expect(result.problems).toEqual([ + 'Camera folder names must be letters and numbers only (no spaces): EO cam', + ]); + expect(result.collects[0].importArgs).toBeNull(); + }); +}); diff --git a/client/dive-common/multiCamBatchScan.ts b/client/dive-common/multiCamBatchScan.ts new file mode 100644 index 000000000..ead9ecc82 --- /dev/null +++ b/client/dive-common/multiCamBatchScan.ts @@ -0,0 +1,183 @@ +/** + * Batch multicam ("multi-collect") import scan logic shared by desktop and web. + * + * Scans a root folder whose immediate children are "collect" folders; each collect + * folder is expected to hold the same set of camera subfolders (e.g. EO/, IR/, UV/), + * each containing that camera's image frames. + */ +import { MultiCamImportFolderArgs } from 'dive-common/apispec'; +import { + isValidCameraName, + orderSubfolderCameraNames, + pickDefaultMulticamCamera, +} from 'dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout'; + +/** Camera-count limits shared with the single multicam subfolder import. */ +export const MinBatchCameras = 2; +export const MaxBatchCameras = 3; + +/** One camera subfolder discovered inside a collect folder during a batch scan. */ +export interface MultiCamBatchCamera { + name: string; + sourcePath: string; + imageCount: number; +} + +/** Scan result for a single collect folder (one multicam dataset candidate). */ +export interface MultiCamBatchCollect { + name: string; + path: string; + cameras: MultiCamBatchCamera[]; + problems: string[]; + warnings: string[]; + importArgs: MultiCamImportFolderArgs | null; +} + +/** Result of scanning a root folder of collect folders for batch multicam import. */ +export interface MultiCamBatchScanResult { + rootPath: string; + cameraNames: string[]; + collects: MultiCamBatchCollect[]; + problems: string[]; +} + +export interface CollectSubfolderScan { + folderName: string; + path: string; + entryCount: number; + imageCount: number; +} + +export interface CollectRawScan { + name: string; + path: string; + subfolders: Map; +} + +function canonicalCameraNames(collects: CollectRawScan[]): string[] { + const canonical = new Map(); + collects.forEach((collect) => { + collect.subfolders.forEach((subfolder, lowerName) => { + if (subfolder.imageCount > 0 && !canonical.has(lowerName)) { + canonical.set(lowerName, subfolder.folderName); + } + }); + }); + return orderSubfolderCameraNames([...canonical.values()]); +} + +function validateCameraSet(cameraNames: string[]): string[] { + const problems: string[] = []; + if (cameraNames.length < MinBatchCameras || cameraNames.length > MaxBatchCameras) { + problems.push( + `Expected ${MinBatchCameras} or ${MaxBatchCameras} camera folders shared across collects, ` + + `found ${cameraNames.length}${cameraNames.length ? ` (${cameraNames.join(', ')})` : ''}`, + ); + } + const invalid = cameraNames.filter((name) => !isValidCameraName(name)); + if (invalid.length) { + problems.push( + `Camera folder names must be letters and numbers only (no spaces): ${invalid.join(', ')}`, + ); + } + return problems; +} + +function buildCollectResult( + collect: CollectRawScan, + cameraNames: string[], +): MultiCamBatchCollect { + const problems: string[] = []; + const warnings: string[] = []; + const cameras: MultiCamBatchCamera[] = []; + + cameraNames.forEach((cameraName) => { + const subfolder = collect.subfolders.get(cameraName.toLowerCase()); + if (!subfolder) { + problems.push(`Missing camera folder "${cameraName}"`); + return; + } + if (subfolder.entryCount === 0) { + problems.push(`Camera folder "${cameraName}" is empty`); + return; + } + if (subfolder.imageCount === 0) { + problems.push(`No supported images in camera folder "${cameraName}"`); + return; + } + cameras.push({ + name: cameraName, + sourcePath: subfolder.path, + imageCount: subfolder.imageCount, + }); + }); + + if (!problems.length && cameras.length > 1) { + const counts = cameras.map((camera) => camera.imageCount); + if (new Set(counts).size > 1) { + warnings.push( + `Frame counts differ across cameras (${cameras + .map((camera) => `${camera.name}: ${camera.imageCount}`) + .join(', ')})`, + ); + } + } + + let importArgs: MultiCamImportFolderArgs | null = null; + if (!problems.length) { + const sourceList: MultiCamImportFolderArgs['sourceList'] = {}; + cameras.forEach((camera) => { + sourceList[camera.name] = { sourcePath: camera.sourcePath, trackFile: '' }; + }); + importArgs = { + datasetName: collect.name, + defaultDisplay: pickDefaultMulticamCamera(cameraNames), + cameraOrder: [...cameraNames], + sourceList, + type: 'image-sequence', + }; + } + + return { + name: collect.name, + path: collect.path, + cameras, + problems, + warnings, + importArgs, + }; +} + +/** + * Build a batch scan result from pre-scanned collect folders. + */ +export function scanMultiCamBatchFromCollects( + rootPath: string, + rawScans: CollectRawScan[], +): MultiCamBatchScanResult { + const problems: string[] = []; + if (!rawScans.length) { + problems.push(`No collect folders found in ${rootPath}`); + } + + const cameraNames = canonicalCameraNames(rawScans); + if (rawScans.length) { + problems.push(...validateCameraSet(cameraNames)); + } + + const cameraSetValid = !problems.length; + const collects = rawScans.map((collect) => { + const result = buildCollectResult(collect, cameraNames); + if (!cameraSetValid) { + return { ...result, importArgs: null }; + } + return result; + }); + + return { + rootPath, + cameraNames, + collects, + problems, + }; +} diff --git a/client/platform/desktop/backend/native/multiCollectImport.spec.ts b/client/platform/desktop/backend/native/multiCollectImport.spec.ts index 7ff0dd325..65909276b 100644 --- a/client/platform/desktop/backend/native/multiCollectImport.spec.ts +++ b/client/platform/desktop/backend/native/multiCollectImport.spec.ts @@ -76,8 +76,8 @@ describe('native.multiCollectImport', () => { expect(collect.importArgs?.datasetName).toBe(collect.name); expect(collect.importArgs?.cameraOrder).toEqual(['EO', 'IR', 'UV']); expect(collect.importArgs?.type).toBe('image-sequence'); - // middle camera of the display order (no center/middle alias present) - expect(collect.importArgs?.defaultDisplay).toBe('IR'); + // EO is preferred as default display when present + expect(collect.importArgs?.defaultDisplay).toBe('EO'); expect(collect.importArgs?.sourceList).toEqual({ EO: { sourcePath: `/survey/${collect.name}/EO`, trackFile: '' }, IR: { sourcePath: `/survey/${collect.name}/IR`, trackFile: '' }, diff --git a/client/platform/desktop/backend/native/multiCollectImport.ts b/client/platform/desktop/backend/native/multiCollectImport.ts index 4380f1e5e..7a7d74f6d 100644 --- a/client/platform/desktop/backend/native/multiCollectImport.ts +++ b/client/platform/desktop/backend/native/multiCollectImport.ts @@ -1,68 +1,18 @@ /** - * Batch multicam ("multi-collect") import scanner. - * - * Scans a root folder whose immediate children are "collect" folders; each collect - * folder is expected to hold the same set of camera subfolders (e.g. EO/, IR/, UV/), - * each containing that camera's image frames. Produces one ready-to-run - * `beginMultiCamImport` argument set per valid collect, without copying any imagery - * (the existing multicam import records `originalBasePath` in place). - * - * Validation rules: - * - Camera subfolders are matched case-insensitively across collects; the canonical - * camera set is the union of subfolder names that contain at least one supported - * image in at least one collect. Subfolders that never contain images anywhere - * (and loose files at the collect level) are ignored as non-camera content. - * - The canonical camera set must contain 2 or 3 alphanumeric camera names, matching - * the limits of the existing multicam subfolder import (multicamSubfolderLayout). - * - Per collect, a canonical camera must exist and contain at least one supported - * image; otherwise the collect is flagged with a blocking problem. - * - Differing frame counts between cameras within a collect are reported as a - * non-blocking warning (seal collects legitimately drop frames on some cameras). - * - `defaultDisplay` follows the existing convention (`pickDefaultMulticamCamera`): - * a camera named "center"/"middle" when present, otherwise the middle camera of - * the display order (for 2 cameras, the first). + * Batch multicam import scanner (desktop filesystem backend). */ import npath from 'path'; import fs from 'fs-extra'; -import { MultiCamImportFolderArgs } from 'dive-common/apispec'; import { - isValidCameraName, - orderSubfolderCameraNames, - pickDefaultMulticamCamera, -} from 'dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout'; -import { - MultiCamBatchCamera, - MultiCamBatchCollect, - MultiCamBatchScanResult, -} from 'platform/desktop/constants'; + CollectRawScan, + CollectSubfolderScan, + scanMultiCamBatchFromCollects, +} from 'dive-common/multiCamBatchScan'; import { findImagesInFolder, listImmediateSubfolders } from './common'; -/** Camera-count limits shared with the single multicam subfolder import. */ -export const MinBatchCameras = 2; -export const MaxBatchCameras = 3; - -interface CollectSubfolderScan { - // subfolder name as found on disk - folderName: string; - // absolute path of the subfolder - path: string; - // total directory entries (0 means completely empty folder) - entryCount: number; - // supported images found by the standard multicam image filter - imageCount: number; -} - -interface CollectRawScan { - name: string; - path: string; - // keyed by lowercased subfolder name - subfolders: Map; -} - async function scanCollectFolder(collectPath: string): Promise> { const subfolderNames = await listImmediateSubfolders(collectPath); const subfolders = new Map(); - // Sequential on purpose: keeps disk access predictable for large surveys. for (let i = 0; i < subfolderNames.length; i += 1) { const folderName = subfolderNames[i]; const subfolderPath = npath.join(collectPath, folderName); @@ -80,110 +30,7 @@ async function scanCollectFolder(collectPath: string): Promise(); // lowercased -> display casing - collects.forEach((collect) => { - collect.subfolders.forEach((subfolder, lowerName) => { - if (subfolder.imageCount > 0 && !canonical.has(lowerName)) { - canonical.set(lowerName, subfolder.folderName); - } - }); - }); - return orderSubfolderCameraNames([...canonical.values()]); -} - -function validateCameraSet(cameraNames: string[]): string[] { - const problems: string[] = []; - if (cameraNames.length < MinBatchCameras || cameraNames.length > MaxBatchCameras) { - problems.push( - `Expected ${MinBatchCameras} or ${MaxBatchCameras} camera folders shared across collects, ` - + `found ${cameraNames.length}${cameraNames.length ? ` (${cameraNames.join(', ')})` : ''}`, - ); - } - const invalid = cameraNames.filter((name) => !isValidCameraName(name)); - if (invalid.length) { - problems.push( - `Camera folder names must be letters and numbers only (no spaces): ${invalid.join(', ')}`, - ); - } - return problems; -} - -function buildCollectResult( - collect: CollectRawScan, - cameraNames: string[], -): MultiCamBatchCollect { - const problems: string[] = []; - const warnings: string[] = []; - const cameras: MultiCamBatchCamera[] = []; - - cameraNames.forEach((cameraName) => { - const subfolder = collect.subfolders.get(cameraName.toLowerCase()); - if (!subfolder) { - problems.push(`Missing camera folder "${cameraName}"`); - return; - } - if (subfolder.entryCount === 0) { - problems.push(`Camera folder "${cameraName}" is empty`); - return; - } - if (subfolder.imageCount === 0) { - problems.push(`No supported images in camera folder "${cameraName}"`); - return; - } - cameras.push({ - name: cameraName, - sourcePath: subfolder.path, - imageCount: subfolder.imageCount, - }); - }); - - if (!problems.length && cameras.length > 1) { - const counts = cameras.map((camera) => camera.imageCount); - if (new Set(counts).size > 1) { - warnings.push( - `Frame counts differ across cameras (${cameras - .map((camera) => `${camera.name}: ${camera.imageCount}`) - .join(', ')})`, - ); - } - } - - let importArgs: MultiCamImportFolderArgs | null = null; - if (!problems.length) { - const sourceList: MultiCamImportFolderArgs['sourceList'] = {}; - cameras.forEach((camera) => { - sourceList[camera.name] = { sourcePath: camera.sourcePath, trackFile: '' }; - }); - importArgs = { - datasetName: collect.name, - defaultDisplay: pickDefaultMulticamCamera(cameraNames), - cameraOrder: [...cameraNames], - sourceList, - type: 'image-sequence', - }; - } - - return { - name: collect.name, - path: collect.path, - cameras, - problems, - warnings, - importArgs, - }; -} - -/** - * Scan a root folder of collect folders and produce per-collect multicam import - * arguments compatible with `beginMultiCamImport`. - */ -async function scanMultiCamBatch(rootPath: string): Promise { +async function scanMultiCamBatch(rootPath: string) { const collectNames = (await listImmediateSubfolders(rootPath)) .sort((a, b) => a.localeCompare(b)); @@ -199,32 +46,7 @@ async function scanMultiCamBatch(rootPath: string): Promise { - const result = buildCollectResult(collect, cameraNames); - if (!cameraSetValid) { - // A structurally-invalid camera set blocks every collect. - return { ...result, importArgs: null }; - } - return result; - }); - - return { - rootPath, - cameraNames, - collects, - problems, - }; + return scanMultiCamBatchFromCollects(rootPath, rawScans); } export default scanMultiCamBatch; diff --git a/client/platform/desktop/constants.ts b/client/platform/desktop/constants.ts index 763b0e7c3..c9f2d4475 100644 --- a/client/platform/desktop/constants.ts +++ b/client/platform/desktop/constants.ts @@ -257,43 +257,11 @@ export interface DesktopMediaImportResponse extends MediaImportResponse { metaFileAbsPath?: string; } -/** One camera subfolder discovered inside a collect folder during a batch scan. */ -export interface MultiCamBatchCamera { - // camera name (canonical camera-subfolder name) - name: string; - // absolute path of the camera subfolder for this collect - sourcePath: string; - // number of supported images found in the subfolder - imageCount: number; -} - -/** Scan result for a single collect folder (one multicam dataset candidate). */ -export interface MultiCamBatchCollect { - // collect folder name (used as the dataset name) - name: string; - // absolute path of the collect folder - path: string; - // cameras found in this collect, in display order - cameras: MultiCamBatchCamera[]; - // blocking issues; when non-empty, importArgs is null - problems: string[]; - // non-blocking issues (e.g. frame count mismatch between cameras) - warnings: string[]; - // ready-to-run beginMultiCamImport arguments, or null when problems exist - importArgs: MultiCamImportFolderArgs | null; -} - -/** Result of scanning a root folder of collect folders for batch multicam import. */ -export interface MultiCamBatchScanResult { - // absolute path of the scanned root folder - rootPath: string; - // canonical camera-subfolder names shared across collects, in display order - cameraNames: string[]; - // per-collect scan results, sorted by collect name - collects: MultiCamBatchCollect[]; - // structural issues with the batch as a whole (no collects, bad camera set, ...) - problems: string[]; -} +export type { + MultiCamBatchCamera, + MultiCamBatchCollect, + MultiCamBatchScanResult, +} from 'dive-common/multiCamBatchScan'; export interface DesktopJobUpdate extends DesktopJob { // body contents of update payload diff --git a/client/platform/desktop/frontend/components/ImportMultiCamBatchDialog.vue b/client/platform/desktop/frontend/components/ImportMultiCamBatchDialog.vue index 4e0b84bcb..377eb6694 100644 --- a/client/platform/desktop/frontend/components/ImportMultiCamBatchDialog.vue +++ b/client/platform/desktop/frontend/components/ImportMultiCamBatchDialog.vue @@ -1,141 +1,27 @@ - diff --git a/client/platform/web-girder/scanMultiCamBatch.spec.ts b/client/platform/web-girder/scanMultiCamBatch.spec.ts new file mode 100644 index 000000000..cf7099ad1 --- /dev/null +++ b/client/platform/web-girder/scanMultiCamBatch.spec.ts @@ -0,0 +1,47 @@ +import { scanMultiCamBatchFromFiles } from './scanMultiCamBatch'; + +function makeFile(relativePath: string): File { + const name = relativePath.split('/').pop() ?? relativePath; + const file = new File([''], name, { type: 'image/png' }); + Object.defineProperty(file, 'webkitRelativePath', { value: relativePath }); + return file; +} + +function frames(collect: string, camera: string, count: number, root = 'survey'): File[] { + return Array.from({ length: count }, (_, index) => makeFile( + `${root}/${collect}/${camera}/frame_${String(index).padStart(4, '0')}.png`, + )); +} + +describe('scanMultiCamBatchFromFiles', () => { + it('produces import args for every valid collect', () => { + const files = [ + ...frames('fl01', 'EO', 3), + ...frames('fl01', 'IR', 3), + ...frames('fl01', 'UV', 3), + ...frames('fl02', 'EO', 2), + ...frames('fl02', 'IR', 2), + ...frames('fl02', 'UV', 2), + ]; + const result = scanMultiCamBatchFromFiles('survey', files); + expect(result.problems).toEqual([]); + expect(result.cameraNames).toEqual(['EO', 'IR', 'UV']); + expect(result.collects.map((collect) => collect.name)).toEqual(['fl01', 'fl02']); + result.collects.forEach((collect) => { + expect(collect.importArgs).not.toBeNull(); + expect(collect.importArgs?.sourceList.EO.sourcePath).toBe(`survey/${collect.name}/EO`); + }); + }); + + it('flags a collect missing a camera folder without blocking others', () => { + const files = [ + ...frames('fl01', 'EO', 3), + ...frames('fl01', 'IR', 3), + ...frames('fl02', 'EO', 3), + ]; + const result = scanMultiCamBatchFromFiles('survey', files); + expect(result.problems).toEqual([]); + expect(result.collects[0].importArgs).not.toBeNull(); + expect(result.collects[1].problems).toEqual(['Missing camera folder "IR"']); + }); +}); diff --git a/client/platform/web-girder/scanMultiCamBatch.ts b/client/platform/web-girder/scanMultiCamBatch.ts new file mode 100644 index 000000000..a21bf9fd4 --- /dev/null +++ b/client/platform/web-girder/scanMultiCamBatch.ts @@ -0,0 +1,95 @@ +/** + * Batch multicam import scanner for the web (browser File list + webkitRelativePath). + */ +import { + CollectRawScan, + CollectSubfolderScan, + scanMultiCamBatchFromCollects, +} from 'dive-common/multiCamBatchScan'; +import { filterMediaFiles } from 'dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout'; + +function normalizeRootPath(rootPath: string): string { + return rootPath.replace(/\\/g, '/').replace(/\/+$/, ''); +} + +function relativePath(file: File): string { + return (file.webkitRelativePath || file.name).replace(/\\/g, '/'); +} + +function pathWithinPrefix(path: string, prefix: string): boolean { + return path === prefix || path.startsWith(`${prefix}/`); +} + +function directChildNames(prefix: string, paths: string[]): Set { + const childPrefix = `${prefix}/`; + const children = new Set(); + paths.forEach((path) => { + if (!path.startsWith(childPrefix)) { + return; + } + const rest = path.slice(childPrefix.length); + const parts = rest.split('/').filter(Boolean); + if (parts.length >= 1) { + children.add(parts[0]); + } + }); + return children; +} + +function scanCollectFromFiles(collectPath: string, allFiles: File[]): Map { + const normalizedCollectPath = normalizeRootPath(collectPath); + const collectPrefix = `${normalizedCollectPath}/`; + const relPaths = allFiles.map((file) => relativePath(file)); + const subfolderNames = directChildNames(normalizedCollectPath, relPaths); + const subfolders = new Map(); + + subfolderNames.forEach((folderName) => { + const subfolderPath = `${normalizedCollectPath}/${folderName}`; + const subfolderPrefix = `${subfolderPath}/`; + const filesInSubfolder = allFiles.filter((file) => pathWithinPrefix(relativePath(file), subfolderPath)); + const directChildPaths = filesInSubfolder + .map((file) => relativePath(file)) + .filter((path) => path.startsWith(subfolderPrefix)) + .map((path) => path.slice(subfolderPrefix.length)) + .filter((rest) => rest.length > 0); + const directEntries = new Set(); + directChildPaths.forEach((rest) => { + const [first] = rest.split('/'); + if (first) { + directEntries.add(first); + } + }); + const directFiles = filesInSubfolder.filter((file) => { + const rest = relativePath(file).slice(subfolderPrefix.length); + return rest.length > 0 && !rest.includes('/'); + }); + subfolders.set(folderName.toLowerCase(), { + folderName, + path: subfolderPath, + entryCount: directEntries.size, + imageCount: filterMediaFiles(directFiles, 'image-sequence').length, + }); + }); + + return subfolders; +} + +export function scanMultiCamBatchFromFiles( + rootPath: string, + files: File[], +): ReturnType { + const normalizedRoot = normalizeRootPath(rootPath); + const relPaths = files.map((file) => relativePath(file)); + const collectNames = [...directChildNames(normalizedRoot, relPaths)].sort((a, b) => a.localeCompare(b)); + const rawScans: CollectRawScan[] = collectNames.map((name) => ({ + name, + path: `${normalizedRoot}/${name}`, + subfolders: scanCollectFromFiles(`${normalizedRoot}/${name}`, files), + })); + return scanMultiCamBatchFromCollects(normalizedRoot, rawScans); +} + +export function filesForCameraSource(sourcePath: string, allFiles: File[]): File[] { + const normalizedSource = normalizeRootPath(sourcePath); + return allFiles.filter((file) => pathWithinPrefix(relativePath(file), normalizedSource)); +} diff --git a/client/platform/web-girder/views/Upload.vue b/client/platform/web-girder/views/Upload.vue index 5679dc4c3..42a85dc39 100644 --- a/client/platform/web-girder/views/Upload.vue +++ b/client/platform/web-girder/views/Upload.vue @@ -16,6 +16,8 @@ import { import ImportButton from 'dive-common/components/ImportButton.vue'; import ImportMultiCamDialog from 'dive-common/components/ImportMultiCamDialog.vue'; +import ImportMultiCamBatchDialog from 'dive-common/components/ImportMultiCamBatchDialog.vue'; +import { MultiCamBatchCollect } from 'dive-common/multiCamBatchScan'; import { DatasetType, MediaImportResponse, MultiCamImportArgs, MultiCamImportFolderArgs, } from 'dive-common/apispec'; @@ -42,6 +44,8 @@ import { stereoCalibrationAllowedExtensionsLabel, } from 'platform/web-girder/multicamCalibration'; import { openFromDisk } from 'platform/web-girder/utils'; +import { filesForCameraSource, scanMultiCamBatchFromFiles } from 'platform/web-girder/scanMultiCamBatch'; +import eventBus from 'platform/web-girder/eventBus'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { getResponseError } from 'vue-media-annotator/utils'; import { clientSettings } from 'dive-common/store/settings'; @@ -112,7 +116,7 @@ function multicamCameraSlotPercent( } export default defineComponent({ - components: { ImportButton, ImportMultiCamDialog, UploadGirder }, + components: { ImportButton, ImportMultiCamDialog, ImportMultiCamBatchDialog, UploadGirder }, props: { location: { type: Object, @@ -125,6 +129,8 @@ export default defineComponent({ const stereo = ref(false); const multiCamOpenType = ref('image-sequence'); const importMultiCamDialog = ref(false); + const importMultiCamBatchDialog = ref(false); + const batchImportFiles: Ref = ref([]); const multicamImporting = ref(false); const multicamImportProgress = ref(null); const girderUpload: Ref = ref(null); @@ -363,24 +369,36 @@ export default defineComponent({ renameCameraFolderFiles(oldSourcePath, newSourcePath); }; - const multiCamImport = async (args: MultiCamImportArgs) => { - importMultiCamDialog.value = false; - if (!isMultiCamFolderArgs(args)) { - preUploadErrorMessage.value = 'Glob-based multicam import is not supported on web yet.'; - return; - } + interface MultiCamFolderImportOptions { + openViewer?: boolean; + closeUpload?: boolean; + showProgressOverlay?: boolean; + progressLabel?: string; + } + + const runMultiCamFolderImport = async ( + args: MultiCamImportFolderArgs, + options: MultiCamFolderImportOptions = {}, + ): Promise => { + const { + openViewer = true, + closeUpload = true, + showProgressOverlay = true, + progressLabel = '', + } = options; + const labelPrefix = progressLabel ? `${progressLabel} — ` : ''; if (!props.location?._id || props.location._modelType !== 'folder') { - preUploadErrorMessage.value = 'Select a folder to upload into before importing multicam data.'; - return; + throw new Error('Select a folder to upload into before importing multicam data.'); } const uploadComponent = girderUpload.value; if (!uploadComponent?.uploadCameraDataset) { - preUploadErrorMessage.value = 'Upload is not ready. Close and reopen the upload dialog.'; - return; + throw new Error('Upload is not ready. Close and reopen the upload dialog.'); } - multicamImporting.value = true; - multicamImportProgress.value = { percent: 0, message: 'Preparing import…' }; + if (showProgressOverlay) { + multicamImporting.value = true; + multicamImportProgress.value = { percent: 0, message: `${labelPrefix}Preparing import…` }; + } preUploadErrorMessage.value = null; let datasetFolderId: string | null = null; let multicamLinked = false; @@ -392,7 +410,7 @@ export default defineComponent({ const fps = args.type === VideoType ? DefaultVideoFPS : (clientSettings.annotationFPS || 1); - setMulticamImportProgress(MULTICAM_PROGRESS_START, 'Creating dataset folder…'); + setMulticamImportProgress(MULTICAM_PROGRESS_START, `${labelPrefix}Creating dataset folder…`); const { data: datasetFolder } = await createGirderFolder({ folderId: props.location._id, name: datasetName, @@ -443,7 +461,7 @@ export default defineComponent({ clearMulticamUploadProgressTimer(); setMulticamImportProgress( multicamCameraSlotPercent(i, totalCameras, MULTICAM_CAMERA_UPLOAD_WEIGHT), - `Processing ${cameraName} (${i + 1} of ${totalCameras})`, + `${labelPrefix}Processing ${cameraName} (${i + 1} of ${totalCameras})`, ); // eslint-disable-next-line no-await-in-loop -- finalize only after post-process marks folder as a dataset await waitForFolderDatasetReady(folder._id, { @@ -455,7 +473,7 @@ export default defineComponent({ totalCameras, MULTICAM_CAMERA_UPLOAD_WEIGHT + fraction * processShare, ), - `Processing ${cameraName} (${i + 1} of ${totalCameras})`, + `${labelPrefix}Processing ${cameraName} (${i + 1} of ${totalCameras})`, ); }, requireViewableImages: uploadType === ImageSequenceType, @@ -464,16 +482,16 @@ export default defineComponent({ setMulticamImportProgress( multicamCameraSlotPercent(i + 1, totalCameras, 0), totalCameras > 1 && i + 1 < totalCameras - ? `Finished ${cameraName}, starting next camera…` - : `Finished ${cameraName}`, + ? `${labelPrefix}Finished ${cameraName}, starting next camera…` + : `${labelPrefix}Finished ${cameraName}`, ); cameras[cameraName] = { folderId: folder._id, type: uploadType }; } - setMulticamImportProgress(92, 'Finalizing multicam dataset…'); + setMulticamImportProgress(92, `${labelPrefix}Finalizing multicam dataset…`); let calibrationFileId: string | undefined; if (args.calibrationFile) { - setMulticamImportProgress(94, 'Uploading calibration…'); + setMulticamImportProgress(94, `${labelPrefix}Uploading calibration…`); const calFile = getCalibrationFile(args.calibrationFile); if (!calFile) { throw new Error( @@ -489,7 +507,7 @@ export default defineComponent({ } const subType = stereo.value ? 'stereo' : 'multicam'; - setMulticamImportProgress(97, 'Linking cameras…'); + setMulticamImportProgress(97, `${labelPrefix}Linking cameras…`); const { data: parentFolder } = await createMulticamDataset({ parentFolderId: datasetFolder._id, name: datasetName, @@ -503,26 +521,83 @@ export default defineComponent({ }); multicamLinked = true; - setMulticamImportProgress(100, 'Opening viewer…'); - clearMulticamFileRegistry(); - await router.push({ name: 'viewer', params: { id: parentFolder._id } }); - close(); + if (openViewer) { + setMulticamImportProgress(100, `${labelPrefix}Opening viewer…`); + clearMulticamFileRegistry(); + await router.push({ name: 'viewer', params: { id: parentFolder._id } }); + if (closeUpload) { + close(); + } + } + return parentFolder._id; } catch (err) { - preUploadErrorMessage.value = err.response?.data?.message || err.message || String(err); if (datasetFolderId && !multicamLinked) { try { await deleteResources([{ _id: datasetFolderId, _modelType: 'folder' }]); } catch (cleanupErr) { - await errorHandler({ err: cleanupErr, name: 'Multicam import cleanup' }); + if (showProgressOverlay) { + await errorHandler({ err: cleanupErr, name: 'Multicam import cleanup' }); + } } } - await errorHandler({ err, name: 'Multicam import' }); + if (showProgressOverlay) { + preUploadErrorMessage.value = err.response?.data?.message || err.message || String(err); + await errorHandler({ err, name: 'Multicam import' }); + } + throw err; } finally { clearMulticamUploadProgressTimer(); - multicamImporting.value = false; - multicamImportProgress.value = null; + if (showProgressOverlay) { + multicamImporting.value = false; + multicamImportProgress.value = null; + } } }; + + const multiCamImport = async (args: MultiCamImportArgs) => { + importMultiCamDialog.value = false; + if (!isMultiCamFolderArgs(args)) { + preUploadErrorMessage.value = 'Glob-based multicam import is not supported on web yet.'; + return; + } + await runMultiCamFolderImport(args); + }; + + const chooseAndScanBatch = async () => { + const ret = await openFromDisk('image-sequence', true); + if (ret.canceled || !ret.fileList?.length) { + return null; + } + batchImportFiles.value = ret.fileList; + const root = ret.root ?? ret.filePaths[0]?.split('/').filter(Boolean)[0] ?? ''; + if (!root) { + throw new Error('Could not determine the selected root folder.'); + } + return scanMultiCamBatchFromFiles(root, ret.fileList); + }; + + const importBatchCollect = async (collect: MultiCamBatchCollect, datasetName: string) => { + if (!collect.importArgs) { + return; + } + clearMulticamFileRegistry(); + collect.cameras.forEach((camera) => { + stashCameraFolderFiles( + camera.sourcePath, + filesForCameraSource(camera.sourcePath, batchImportFiles.value), + ); + }); + await runMultiCamFolderImport( + { ...collect.importArgs, datasetName }, + { + openViewer: false, + closeUpload: false, + showProgressOverlay: false, + progressLabel: collect.name, + }, + ); + clearMulticamFileRegistry(); + }; // Filter to show how many files are left to upload const filesNotUploaded = (item: PendingUpload) => item.files.filter( (file) => file.status !== 'done' && file.status !== 'error', @@ -569,6 +644,13 @@ export default defineComponent({ function close() { emit('close'); } + const closeMultiCamBatchDialog = (importedCount = 0) => { + importMultiCamBatchDialog.value = false; + batchImportFiles.value = []; + if (importedCount > 0) { + eventBus.$emit('refresh-data-browser'); + } + }; function abort() { if (pendingUploads.value.length === 0) { close(); @@ -607,6 +689,7 @@ export default defineComponent({ stereo, multiCamOpenType, importMultiCamDialog, + importMultiCamBatchDialog, girderUpload, multicamImporting, multicamImportProgress, @@ -614,12 +697,15 @@ export default defineComponent({ clientSettings, //methods close, + closeMultiCamBatchDialog, openImport, processImport, openMultiCamDialog, filterFileUpload, multiCamImportCheck, multiCamImport, + chooseAndScanBatch, + importBatchCollect, registerSubfolderCameras, unregisterSubfolderCamera, renameSubfolderCamera, @@ -678,6 +764,19 @@ export default defineComponent({ @abort="importMultiCamDialog = false; preUploadErrorMessage = null" /> + + + From 3211197f961f15c38e88e3e529ddd861ded9f34e Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 8 Jul 2026 12:05:27 -0400 Subject: [PATCH 6/9] update documentation for batch multi-camera import functionality --- .../components/ImportMultiCamDialog/README.md | 27 +++++++++++ docs/Dive-Desktop.md | 1 + docs/Multicamera-data.md | 46 +++++++++++++++++++ docs/Web-Version.md | 9 ++-- docs/index.md | 1 + 5 files changed, 81 insertions(+), 3 deletions(-) diff --git a/client/dive-common/components/ImportMultiCamDialog/README.md b/client/dive-common/components/ImportMultiCamDialog/README.md index c24082941..7fdf87c0f 100644 --- a/client/dive-common/components/ImportMultiCamDialog/README.md +++ b/client/dive-common/components/ImportMultiCamDialog/README.md @@ -15,6 +15,33 @@ Used by: - `client/platform/desktop/frontend/components/Recent.vue` - `client/platform/web-girder/views/Upload.vue` +## Batch multicam import (`ImportMultiCamBatchDialog`) + +Separate from the single-dataset dialog above. Entry point: + +```ts +import ImportMultiCamBatchDialog from 'dive-common/components/ImportMultiCamBatchDialog.vue'; +``` + +Used by the same platform pages (`Recent.vue`, `Upload.vue`), opened from the ==MultiCam Batch== item on `ImportButton` (`batchMultiCamImport` prop). + +| Piece | Role | +|-------|------| +| `ImportMultiCamBatchDialog.vue` | Scan review UI, editable dataset names, per-collect selection, sequential import | +| `multiCamBatchScan.ts` | Shared scan/validation (`scanMultiCamBatchFromCollects`, `MultiCamBatchCollect`) | +| `multicamSubfolderLayout.ts` | Camera naming rules and preferred order (shared with subfolder import) | +| `platform/desktop/backend/native/multiCollectImport.ts` | Desktop filesystem scan (`scan-multicam-batch` IPC) | +| `platform/web-girder/scanMultiCamBatch.ts` | Web scan from `File[]` + `webkitRelativePath` | + +Platform wiring: + +- **Desktop** — `chooseAndScan` calls `api.scanMultiCamBatch(path)`; `importCollect` runs the existing multicam import path per collect. +- **Web** — `chooseAndScan` picks a folder via `openFromDisk`, then `scanMultiCamBatchFromFiles`; `importCollect` uploads each camera's files and finalizes the multicam parent dataset. + +Tests: `multiCamBatchScan.spec.ts`, `multiCollectImport.spec.ts`, `scanMultiCamBatch.spec.ts`. + +User-facing docs: [Multicamera and Stereo Data](../../../../docs/Multicamera-data.md#batch-multicam-import). + ## Import modes | Mode (`importType`) | UI component | Description | diff --git a/docs/Dive-Desktop.md b/docs/Dive-Desktop.md index a21cd6948..a9547742a 100644 --- a/docs/Dive-Desktop.md +++ b/docs/Dive-Desktop.md @@ -57,6 +57,7 @@ Click either ==Open Image Sequence :material-folder-open:== or ==Open Video :mat * ==:material-view-list-outline: Image List== will prompt you to choose a `.txt` file that contains an image name or full path on each line. * ==:material-binoculars: Stereo== will prompt you to choose 2 videos or 2 image sequences and a calibration file. * ==:material-camera-burst: Multi-Cam== will prompt you to describe the multi-cam configuration by naming several cameras and picking the source media for each. +* ==:material-folder-multiple-image: MultiCam Batch== will prompt you to choose a root folder of **collect** subfolders and import one multicam image-sequence dataset per collect. See [Batch multicam import](Multicamera-data.md#batch-multicam-import) for the expected folder layout. The import routine will look for `.csv` and `.json` files in the same directory as the source media, and you will be prompted to manually select an annotation file and a configuration file. Neither is required. diff --git a/docs/Multicamera-data.md b/docs/Multicamera-data.md index 4de064bb6..2c97197d7 100644 --- a/docs/Multicamera-data.md +++ b/docs/Multicamera-data.md @@ -6,6 +6,7 @@ DIVE supports **multicamera** and **stereo** datasets on both the [web version]( |------------|-----|---------| | Import stereo (2 cameras + calibration) | ✔️ | ✔️ | | Import multicam (2 or 3 cameras) | ✔️ | ✔️ | +| Batch multicam import (collect folders) | ✔️ | ✔️ | | View and annotate across cameras | ✔️ | ✔️ | | MultiCamera Tools (link/unlink tracks) | ✔️ | ✔️ | | Run stereo / multicam VIAME pipelines | ✔️ | ✔️ | @@ -30,6 +31,7 @@ Multicam import is available from the standard upload dialog on [viame.kitware.c 5. Choose one of: * ==:material-binoculars: Stereoscopic== — exactly 2 cameras and a calibration `.npz` file. * ==:material-camera-burst: MultiCam== — 2 or 3 cameras; no calibration file required. + * ==:material-folder-multiple-image: MultiCam Batch== — import many multicam datasets at once from a folder of **collect** subfolders (image sequences only). See [Batch multicam import](#batch-multicam-import). 6. In the import dialog, assign a source folder or video file to each camera. All cameras must use the same media type (all image sequences or all videos) and must have the same number of frames (or matching video duration). 7. Optionally attach a per-camera annotation file during import. 8. Enter a dataset name, choose the default display camera, and click ==Begin Import==. @@ -47,6 +49,7 @@ For general web upload concepts (permissions, transcoding, zip import), see [Upl * ==:material-binoculars: Stereo== — choose 2 videos or 2 image sequences and a calibration file. * ==:material-camera-burst: Multi-Cam== — name each camera and pick its source media. +* ==:material-folder-multiple-image: MultiCam Batch== — import many multicam datasets from a folder of collect subfolders (image sequences only). See [Batch multicam import](#batch-multicam-import). Desktop additionally supports ==:material-view-list-outline: Image List== and glob-based folder filtering for single-camera imports, and glob/keyword layout options for multicam import. @@ -54,6 +57,49 @@ Desktop additionally supports ==:material-view-list-outline: Image List== and gl Stereoscopic data **requires** a calibration file. Generic multicamera data does **not**. +### Batch multicam import + +Use **MultiCam Batch** when you have many synchronized multicam **collects** on disk that share the same camera layout — for example repeated survey passes where each pass is one collect folder with the same camera subfolders (`EO/`, `IR/`, `UV/`, and so on). + +**Folder layout:** + +``` +survey/ ← choose this root folder + collect_001/ ← one multicam dataset + EO/ + frame001.jpg + frame002.jpg + IR/ + frame001.jpg + frame002.jpg + collect_002/ + EO/ + IR/ +``` + +Each immediate child of the root is a **collect** folder. Inside every collect, the same **2 or 3 camera subfolders** must appear, each holding that camera's image frames for that collect. + +**Requirements:** + +* **Image sequences only** — not video or stereo. +* **2 or 3 cameras** shared across all collects (same subfolder names in every collect). +* **Camera folder names** must use letters, numbers, and underscores only (no spaces). +* **Frame counts** should match across cameras within a collect; mismatches are flagged as warnings in the review table. +* Supported image formats match standard DIVE image-sequence import (PNG, JPEG, TIFF, and others). + +**Workflow (Web and Desktop):** + +1. Open ==Add Image Sequence== and choose ==MultiCam Batch== from the ==:material-chevron-down:== dropdown. +2. Select the **root folder** whose subfolders are collects. +3. Review the scan summary: detected cameras, per-collect frame counts, and any blocking issues. +4. Edit the **dataset name** for each collect you plan to import (defaults to the collect folder name). +5. Select which valid collects to import, then start the batch. DIVE creates **one multicam parent dataset per collect**. +6. If one collect fails, the batch **continues** with the remaining selected collects. + +On **Web**, the folder picker uploads all files under the chosen root; scanning uses browser paths to group images by collect and camera. On **Desktop**, scanning reads the folder tree locally before import begins. + +For a single multicam dataset from one parent folder (one collect, camera subfolders only), use ==MultiCam== with the **parent-folder** import mode instead of MultiCam Batch. + ## Data/Track Organization Data is loaded amongst multiple folders to create a multicamera dataset. In these cases trackIds will be linked if they are the same across the cameras. Selection of a trackId that exists across multiple cameras will be linked together in the [Track List](UI-Track-List.md). diff --git a/docs/Web-Version.md b/docs/Web-Version.md index ec784ef2e..7760d3575 100644 --- a/docs/Web-Version.md +++ b/docs/Web-Version.md @@ -53,10 +53,13 @@ DIVE Web supports importing **stereo** (2 cameras + calibration) and **multicam* 1. Navigate to the folder where the new dataset should live (see steps above). 2. Click ==:material-file-upload: Upload==. 3. On ==Add Image Sequence== or ==Add Video==, open the ==:material-chevron-down:== dropdown. -4. Choose ==:material-binoculars: Stereoscopic== or ==:material-camera-burst: MultiCam==. -5. Assign media to each camera, set a dataset name and default display camera, then start the import. +4. Choose one of: + * ==:material-binoculars: Stereoscopic== — 2 cameras plus a calibration `.npz` file. + * ==:material-camera-burst: MultiCam== — assign media to each of 2 or 3 cameras for **one** dataset. + * ==:material-folder-multiple-image: MultiCam Batch== — import **many** multicam image-sequence datasets from a root folder of collect subfolders (see [Batch multicam import](Multicamera-data.md#batch-multicam-import)). +5. For Stereoscopic or MultiCam, assign media to each camera, set a dataset name and default display camera, then start the import. For MultiCam Batch, choose the root folder, review the scan table, edit dataset names, select collects, and start the batch. -All cameras in one import must share the same media type and frame count. Stereoscopic imports require a calibration `.npz` file. +All cameras in one import must share the same media type and frame count. Stereoscopic imports require a calibration `.npz` file. MultiCam Batch is **image-sequence only** (not video or stereo). For camera selection, linked tracks, MultiCamera Tools, and pipeline details, see [Multicamera and Stereo Data](Multicamera-data.md). diff --git a/docs/index.md b/docs/index.md index c52ee2149..1714c3291 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,6 +21,7 @@ Load your own images and videos | ✔️ | ✔️ |     Image and video transcoding | ✔️ | ✔️ |     Import using image lists | ❌ | ✔️ Multicamera and stereo datasets | ✔️ | ✔️ +|     Batch multicam import (collect folders) | ✔️ | ✔️ Load annotations from [supported formats](DataFormats.md) | ✔️ | ✔️ Create new object and track annotation | ✔️ | ✔️ Annotation export | ✔️ | ✔️ From 76842b69819b7579cfee9c55851181da7298152e Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 8 Jul 2026 12:06:50 -0400 Subject: [PATCH 7/9] linting --- client/platform/desktop/constants.ts | 2 +- client/platform/web-girder/scanMultiCamBatch.ts | 1 - client/platform/web-girder/views/Upload.vue | 7 ++++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/client/platform/desktop/constants.ts b/client/platform/desktop/constants.ts index c9f2d4475..c7b0337c4 100644 --- a/client/platform/desktop/constants.ts +++ b/client/platform/desktop/constants.ts @@ -1,6 +1,6 @@ import type { DatasetMeta, DatasetMetaMutable, DatasetType, - Pipe, SubType, MediaImportResponse, MultiCamImportFolderArgs, PipelineParams, + Pipe, SubType, MediaImportResponse, PipelineParams, } from 'dive-common/apispec'; import { Attribute } from 'vue-media-annotator/use/AttributeTypes'; import { AttributeTrackFilter } from 'vue-media-annotator/AttributeTrackFilterControls'; diff --git a/client/platform/web-girder/scanMultiCamBatch.ts b/client/platform/web-girder/scanMultiCamBatch.ts index a21bf9fd4..7af60fc59 100644 --- a/client/platform/web-girder/scanMultiCamBatch.ts +++ b/client/platform/web-girder/scanMultiCamBatch.ts @@ -38,7 +38,6 @@ function directChildNames(prefix: string, paths: string[]): Set { function scanCollectFromFiles(collectPath: string, allFiles: File[]): Map { const normalizedCollectPath = normalizeRootPath(collectPath); - const collectPrefix = `${normalizedCollectPath}/`; const relPaths = allFiles.map((file) => relativePath(file)); const subfolderNames = directChildNames(normalizedCollectPath, relPaths); const subfolders = new Map(); diff --git a/client/platform/web-girder/views/Upload.vue b/client/platform/web-girder/views/Upload.vue index 42a85dc39..01bbe0e15 100644 --- a/client/platform/web-girder/views/Upload.vue +++ b/client/platform/web-girder/views/Upload.vue @@ -116,7 +116,12 @@ function multicamCameraSlotPercent( } export default defineComponent({ - components: { ImportButton, ImportMultiCamDialog, ImportMultiCamBatchDialog, UploadGirder }, + components: { + ImportButton, + ImportMultiCamDialog, + ImportMultiCamBatchDialog, + UploadGirder, + }, props: { location: { type: Object, From 8cf2f1b2dba94ca6a12e898eae828bdfdf3f2c26 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 8 Jul 2026 12:15:20 -0400 Subject: [PATCH 8/9] ordering multicam imports --- .../multicamSubfolderLayout.spec.ts | 23 +++++++++++---- .../multicamSubfolderLayout.ts | 28 +++++++++++++------ client/dive-common/multiCamBatchScan.spec.ts | 4 +-- client/dive-common/multicamDisplay.spec.ts | 7 +++++ client/dive-common/multicamDisplay.ts | 3 +- .../backend/native/multiCollectImport.spec.ts | 4 +-- .../web-girder/scanMultiCamBatch.spec.ts | 2 +- 7 files changed, 52 insertions(+), 19 deletions(-) diff --git a/client/dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout.spec.ts b/client/dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout.spec.ts index 7493eeec1..6ca1674ab 100644 --- a/client/dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout.spec.ts +++ b/client/dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout.spec.ts @@ -16,6 +16,7 @@ import { organizeSubfolderCameras, orderSubfolderCameraNames, parentFolderLabelFromAbsolutePaths, + preferEoIrSubfolderOrder, preferEoSubfolderFirst, preferLeftSubfolderFirst, pickDefaultMulticamCamera, @@ -326,19 +327,27 @@ describe('sortSubfolderCameraNames', () => { }); }); -describe('preferEoSubfolderFirst', () => { - it('moves EO-named folders before IR and others', () => { - expect(preferEoSubfolderFirst(['2021_IR_SHORT', '2021_EO_SHORT'])).toEqual([ +describe('preferEoIrSubfolderOrder', () => { + it('moves EO-named folders left and IR-named folders right', () => { + expect(preferEoIrSubfolderOrder(['2021_IR_SHORT', '2021_EO_SHORT'])).toEqual([ '2021_EO_SHORT', '2021_IR_SHORT', ]); + expect(preferEoIrSubfolderOrder(['IR', 'UV', 'EO'])).toEqual(['EO', 'UV', 'IR']); }); - it('leaves order unchanged when EO is already first', () => { - expect(preferEoSubfolderFirst(['2021_EO_SHORT', '2021_IR_SHORT'])).toEqual([ + it('leaves order unchanged when EO is already first and IR is already last', () => { + expect(preferEoIrSubfolderOrder(['2021_EO_SHORT', '2021_IR_SHORT'])).toEqual([ '2021_EO_SHORT', '2021_IR_SHORT', ]); + expect(preferEoIrSubfolderOrder(['EO', 'UV', 'IR'])).toEqual(['EO', 'UV', 'IR']); + }); +}); + +describe('preferEoSubfolderFirst', () => { + it('delegates to preferEoIrSubfolderOrder', () => { + expect(preferEoSubfolderFirst(['IR', 'UV', 'EO'])).toEqual(['EO', 'UV', 'IR']); }); }); @@ -406,6 +415,10 @@ describe('orderSubfolderCameraNames', () => { it('uses preferred order when STAR, CENTER, and PORT are all present', () => { expect(orderSubfolderCameraNames(['PORT', 'CENTER', 'STAR'])).toEqual(['STAR', 'CENTER', 'PORT']); }); + + it('orders EO left and IR right when those tokens appear in folder names', () => { + expect(orderSubfolderCameraNames(['IR', 'UV', 'EO'])).toEqual(['EO', 'UV', 'IR']); + }); }); describe('organizeSubfolderCameras case', () => { diff --git a/client/dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout.ts b/client/dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout.ts index 015c3bfc3..8b640b9e5 100644 --- a/client/dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout.ts +++ b/client/dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout.ts @@ -60,14 +60,26 @@ export function isIrSubfolderName(name: string): boolean { return /(^|_)IR(_|$)/i.test(name); } -/** Move an EO-named folder to the front when present (before IR and other cameras). */ +/** Move EO-named folders to the front and IR-named folders to the back; preserve middle order. */ +export function preferEoIrSubfolderOrder(names: string[]): string[] { + const eo: string[] = []; + const middle: string[] = []; + const ir: string[] = []; + names.forEach((name) => { + if (isEoSubfolderName(name)) { + eo.push(name); + } else if (isIrSubfolderName(name)) { + ir.push(name); + } else { + middle.push(name); + } + }); + return [...eo, ...middle, ...ir]; +} + +/** @deprecated Use preferEoIrSubfolderOrder */ export function preferEoSubfolderFirst(names: string[]): string[] { - const eoIndex = names.findIndex((name) => isEoSubfolderName(name)); - if (eoIndex <= 0) { - return names; - } - const eo = names[eoIndex]; - return [eo, ...names.filter((_, index) => index !== eoIndex)]; + return preferEoIrSubfolderOrder(names); } /** @@ -178,7 +190,7 @@ export function orderSubfolderCameraNames( if (options?.preferLeftFirst) { ordered = preferLeftSubfolderFirst(ordered); } - return preferEoSubfolderFirst(ordered); + return preferEoIrSubfolderOrder(ordered); } export function isValidCameraName(name: string): boolean { diff --git a/client/dive-common/multiCamBatchScan.spec.ts b/client/dive-common/multiCamBatchScan.spec.ts index 51a7dea06..661183491 100644 --- a/client/dive-common/multiCamBatchScan.spec.ts +++ b/client/dive-common/multiCamBatchScan.spec.ts @@ -44,14 +44,14 @@ describe('multiCamBatchScan', () => { }), ]); expect(result.problems).toEqual([]); - expect(result.cameraNames).toEqual(['EO', 'IR', 'UV']); + expect(result.cameraNames).toEqual(['EO', 'UV', 'IR']); expect(result.collects.map((c) => c.name)).toEqual(['fl01', 'fl02']); result.collects.forEach((collect) => { expect(collect.problems).toEqual([]); expect(collect.warnings).toEqual([]); expect(collect.importArgs).not.toBeNull(); expect(collect.importArgs?.datasetName).toBe(collect.name); - expect(collect.importArgs?.cameraOrder).toEqual(['EO', 'IR', 'UV']); + expect(collect.importArgs?.cameraOrder).toEqual(['EO', 'UV', 'IR']); expect(collect.importArgs?.type).toBe('image-sequence'); expect(collect.importArgs?.defaultDisplay).toBe('EO'); expect(collect.importArgs?.sourceList).toEqual({ diff --git a/client/dive-common/multicamDisplay.spec.ts b/client/dive-common/multicamDisplay.spec.ts index a5b2a274b..922870022 100644 --- a/client/dive-common/multicamDisplay.spec.ts +++ b/client/dive-common/multicamDisplay.spec.ts @@ -36,6 +36,13 @@ describe('multicamDisplay', () => { })).toEqual(['left', 'right']); }); + it('falls back to EO left and IR right when cameraOrder is missing', () => { + expect(orderedMultiCamCameraNames({ + defaultDisplay: 'EO', + cameras: { IR: {}, UV: {}, EO: {} }, + })).toEqual(['EO', 'UV', 'IR']); + }); + it('detects multicam dataset meta for training guards', () => { expect(isMultiCamDatasetMeta({ type: 'multi', subType: 'stereo' })).toBe(true); expect(isMultiCamDatasetMeta({ type: 'video', subType: null })).toBe(false); diff --git a/client/dive-common/multicamDisplay.ts b/client/dive-common/multicamDisplay.ts index 7a30e10b8..fed95655e 100644 --- a/client/dive-common/multicamDisplay.ts +++ b/client/dive-common/multicamDisplay.ts @@ -1,4 +1,5 @@ import type { SubType } from 'dive-common/apispec'; +import { preferEoIrSubfolderOrder } from 'dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout'; export type MultiCamSubType = 'stereo' | 'multicam'; @@ -38,7 +39,7 @@ export function orderedMultiCamCameraNames(multiCamMedia: MultiCamMediaLike | nu if (cameraOrder?.length) { return cameraOrder.filter((name) => name in cameras); } - return Object.keys(cameras); + return preferEoIrSubfolderOrder(Object.keys(cameras)); } export function isMultiCamSubType(subType: SubType | string | null | undefined): subType is MultiCamSubType { diff --git a/client/platform/desktop/backend/native/multiCollectImport.spec.ts b/client/platform/desktop/backend/native/multiCollectImport.spec.ts index 65909276b..cab294d06 100644 --- a/client/platform/desktop/backend/native/multiCollectImport.spec.ts +++ b/client/platform/desktop/backend/native/multiCollectImport.spec.ts @@ -67,14 +67,14 @@ describe('native.multiCollectImport', () => { }); const result = await scanMultiCamBatch('/survey'); expect(result.problems).toEqual([]); - expect(result.cameraNames).toEqual(['EO', 'IR', 'UV']); + expect(result.cameraNames).toEqual(['EO', 'UV', 'IR']); expect(result.collects.map((c) => c.name)).toEqual(['fl01', 'fl02']); result.collects.forEach((collect) => { expect(collect.problems).toEqual([]); expect(collect.warnings).toEqual([]); expect(collect.importArgs).not.toBeNull(); expect(collect.importArgs?.datasetName).toBe(collect.name); - expect(collect.importArgs?.cameraOrder).toEqual(['EO', 'IR', 'UV']); + expect(collect.importArgs?.cameraOrder).toEqual(['EO', 'UV', 'IR']); expect(collect.importArgs?.type).toBe('image-sequence'); // EO is preferred as default display when present expect(collect.importArgs?.defaultDisplay).toBe('EO'); diff --git a/client/platform/web-girder/scanMultiCamBatch.spec.ts b/client/platform/web-girder/scanMultiCamBatch.spec.ts index cf7099ad1..bb493f478 100644 --- a/client/platform/web-girder/scanMultiCamBatch.spec.ts +++ b/client/platform/web-girder/scanMultiCamBatch.spec.ts @@ -25,7 +25,7 @@ describe('scanMultiCamBatchFromFiles', () => { ]; const result = scanMultiCamBatchFromFiles('survey', files); expect(result.problems).toEqual([]); - expect(result.cameraNames).toEqual(['EO', 'IR', 'UV']); + expect(result.cameraNames).toEqual(['EO', 'UV', 'IR']); expect(result.collects.map((collect) => collect.name)).toEqual(['fl01', 'fl02']); result.collects.forEach((collect) => { expect(collect.importArgs).not.toBeNull(); From 5e0203f02f1140c1b90f41389d0ef3f5b999ea14 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 8 Jul 2026 12:21:01 -0400 Subject: [PATCH 9/9] prevent stereoscopic loading and calculations when in multicamera mode --- client/dive-common/components/Viewer.vue | 3 +- client/dive-common/multicamDisplay.spec.ts | 7 ++++ client/dive-common/multicamDisplay.ts | 5 +++ client/dive-common/use/useModeManager.ts | 16 +++++-- .../frontend/components/ViewerLoader.vue | 42 ++++++++++++++++--- 5 files changed, 63 insertions(+), 10 deletions(-) diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index 646b3b1fd..879f0ad9f 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -410,6 +410,7 @@ export default defineComponent({ cameraStore, aggregateController, readonlyState, + isStereoscopicDataset: computed(() => subType.value === 'stereo'), onStereoAnnotationComplete: (params: StereoAnnotationCompleteParams) => { emit('stereo-annotation-complete', params); }, @@ -583,7 +584,7 @@ export default defineComponent({ // measurement (length, midpoint, range, RMS) computed for every frame // where both cameras now have a line. The desktop loader owns the stereo // service, so delegate via an event. - if (isStereoInteractiveModeEnabled()) { + if (isStereoInteractiveModeEnabled() && subType.value === 'stereo') { emit('stereo-track-linked', baseTrack); } } diff --git a/client/dive-common/multicamDisplay.spec.ts b/client/dive-common/multicamDisplay.spec.ts index 922870022..99c4f589b 100644 --- a/client/dive-common/multicamDisplay.spec.ts +++ b/client/dive-common/multicamDisplay.spec.ts @@ -4,6 +4,7 @@ import { getMultiCamTooltip, isMultiCamDatasetMeta, isMultiCamTrainingTarget, + isStereoscopicDatasetMeta, orderedMultiCamCameraNames, } from './multicamDisplay'; @@ -48,6 +49,12 @@ describe('multicamDisplay', () => { expect(isMultiCamDatasetMeta({ type: 'video', subType: null })).toBe(false); }); + it('detects stereoscopic vs plain multicam datasets', () => { + expect(isStereoscopicDatasetMeta({ type: 'multi', subType: 'stereo' })).toBe(true); + expect(isStereoscopicDatasetMeta({ type: 'multi', subType: 'multicam' })).toBe(false); + expect(isStereoscopicDatasetMeta({ type: 'video', subType: 'stereo' })).toBe(false); + }); + it('disables training for multicam parent and child camera selection', () => { const parent = { _id: 'parent-id', diff --git a/client/dive-common/multicamDisplay.ts b/client/dive-common/multicamDisplay.ts index fed95655e..acb0ca846 100644 --- a/client/dive-common/multicamDisplay.ts +++ b/client/dive-common/multicamDisplay.ts @@ -62,6 +62,11 @@ export function isMultiCamDatasetMeta(meta: DatasetMetaLike | null | undefined): return getMultiCamSubType(meta) !== null; } +/** True when folder meta describes a stereoscopic (not plain multicam) parent dataset. */ +export function isStereoscopicDatasetMeta(meta: DatasetMetaLike | null | undefined): boolean { + return getMultiCamSubType(meta) === 'stereo'; +} + /** * Whether training should be disabled for the current data browser selection. * Covers the multicam parent, browsing inside it with no selection, and per-camera child folders. diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index 38f17ea68..ad322c869 100644 --- a/client/dive-common/use/useModeManager.ts +++ b/client/dive-common/use/useModeManager.ts @@ -97,6 +97,7 @@ export default function useModeManager({ aggregateController, readonlyState, recipes, + isStereoscopicDataset, onStereoAnnotationComplete, onStereoAnnotationReset, onStereoSegmentationFinalize, @@ -107,12 +108,19 @@ export default function useModeManager({ aggregateController: Ref; readonlyState: Readonly>; recipes: Recipe[]; + /** When set, interactive stereo only runs on stereoscopic datasets. */ + isStereoscopicDataset?: Ref; onStereoAnnotationComplete?: (params: StereoAnnotationCompleteParams) => void; onStereoAnnotationReset?: (params: StereoAnnotationResetParams) => void; onStereoSegmentationFinalize?: (params?: StereoSegmentationFinalizeParams) => void; }) { let creating = false; const { prompt } = usePrompt(); + + function stereoInteractiveActive(): boolean { + return isStereoInteractiveModeEnabled() + && (isStereoscopicDataset === undefined || isStereoscopicDataset.value); + } const annotationModes = reactive({ visible: ['rectangle', 'Polygon', 'LineString', 'text'] as VisibleAnnotationTypes[], editing: 'rectangle' as EditAnnotationTypes, @@ -557,7 +565,7 @@ export default function useModeManager({ newTrackSettingsAfterLogic(track); // Stereo: emit box annotation complete - if (onStereoAnnotationComplete && isStereoInteractiveModeEnabled()) { + if (onStereoAnnotationComplete && stereoInteractiveActive()) { onStereoAnnotationComplete({ type: 'box', camera: selectedCamera.value, @@ -731,7 +739,7 @@ export default function useModeManager({ newTrackSettingsAfterLogic(track); // Stereo: emit line or polygon annotation complete - if (onStereoAnnotationComplete && isStereoInteractiveModeEnabled() + if (onStereoAnnotationComplete && stereoInteractiveActive() && completedTrackId !== null) { // Check for LineString with exactly 2 points (line annotation) if (data.geometry.type === 'LineString' @@ -1295,7 +1303,7 @@ export default function useModeManager({ // without waiting for the user to finalize the detection. Only fired for // fresh predictions (controlPoints present), so navigating frames or // restoring a pending preview does not re-trigger stereo work. - if (onStereoAnnotationComplete && isStereoInteractiveModeEnabled() + if (onStereoAnnotationComplete && stereoInteractiveActive() && selectedTrackId.value !== null && result.controlPoints) { onStereoAnnotationComplete({ type: 'segmentation', @@ -1466,7 +1474,7 @@ export default function useModeManager({ : []); } - if (onStereoAnnotationReset && isStereoInteractiveModeEnabled()) { + if (onStereoAnnotationReset && stereoInteractiveActive()) { onStereoAnnotationReset({ trackId: selectedTrackId.value as number, frameNum: data.frameNum, diff --git a/client/platform/desktop/frontend/components/ViewerLoader.vue b/client/platform/desktop/frontend/components/ViewerLoader.vue index ab4f0887a..b065e459b 100644 --- a/client/platform/desktop/frontend/components/ViewerLoader.vue +++ b/client/platform/desktop/frontend/components/ViewerLoader.vue @@ -11,6 +11,7 @@ import context from 'dive-common/store/context'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { SegmentationPredictRequest } from 'dive-common/apispec'; import { clientSettings } from 'dive-common/store/settings'; +import { isStereoscopicDatasetMeta } from 'dive-common/multicamDisplay'; import type { StereoAnnotationCompleteParams, StereoAnnotationResetParams, @@ -92,6 +93,7 @@ export default defineComponent({ const { prompt } = usePrompt(); const viewerRef = ref(); const subTypeList = computed(() => [datasets.value[props.id]?.subType || null]); + const isStereoscopicDataset = computed(() => subTypeList.value[0] === 'stereo'); const camNumbers = computed(() => [datasets.value[props.id]?.cameraNumber || 1]); const readonlyMode = computed(() => settings.value?.readonlyMode || false); const selectedCamera = ref(''); @@ -628,9 +630,9 @@ export default defineComponent({ async function loadStereoMetadata(): Promise { try { const meta = await loadMetadata(props.id); - // Single-camera datasets have no stereo pair: report no stereo so the - // caller does not load the stereo service. - if (!meta.multiCamMedia) return false; + // Plain multicam and single-camera datasets have no stereo pair: report + // no stereo so the caller does not load the stereo service. + if (!meta.multiCamMedia || !isStereoscopicDatasetMeta(meta)) return false; // Extract calibration file path from multiCam metadata stereoCalibrationFile = meta.multiCam?.calibration || undefined; @@ -715,8 +717,10 @@ export default defineComponent({ // The backend stereo service is needed whenever either stereo feature is on // (length-on-modify or cross-camera auto-compute). Track the combined state // so toggling one feature while the other is already on does not restart it. - const stereoServiceWanted = () => clientSettings.stereoSettings.updateLengthsOnModify - || clientSettings.stereoSettings.autoComputeOtherCamera; + const stereoServiceWanted = () => isStereoscopicDataset.value && ( + clientSettings.stereoSettings.updateLengthsOnModify + || clientSettings.stereoSettings.autoComputeOtherCamera + ); function disableStereoFeatureToggles() { clientSettings.stereoSettings.updateLengthsOnModify = false; @@ -812,6 +816,16 @@ export default defineComponent({ // in single-camera datasets. let stereoDatasetUnavailable = false; + function resetStereoStateForDatasetChange() { + stereoDatasetUnavailable = false; + stereoImagePathGetters.value = {}; + stereoCameraFps.value = {}; + lastStereoFrame = -1; + stereoCalibrationFile = undefined; + stereoDatasetFps = undefined; + closeStereoLoadingDialog(); + } + function requestStereoServiceState(enabled: boolean, userInitiated: boolean): Promise { const p = applyStereoServiceState(enabled, userInitiated).finally(() => { if (stereoStatePromise === p) stereoStatePromise = null; @@ -820,6 +834,24 @@ export default defineComponent({ return p; } + watch(() => props.id, () => { + resetStereoStateForDatasetChange(); + if (stereoEnabled.value) { + requestStereoServiceState(false, false); + } + }); + + watch(isStereoscopicDataset, (isStereo) => { + if (!isStereo) { + stereoDatasetUnavailable = true; + if (stereoEnabled.value) { + requestStereoServiceState(false, false); + } + } else { + stereoDatasetUnavailable = false; + } + }); + // Wait out any in-flight enable/disable; returns whether the service is up. async function stereoServiceReady(): Promise { // Self-heal: a stereo feature is wanted but no enable is running and