diff --git a/client/dive-common/components/ImportButton.vue b/client/dive-common/components/ImportButton.vue index d2bf90bde..8020ef548 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/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/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/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/multiCamBatchScan.spec.ts b/client/dive-common/multiCamBatchScan.spec.ts new file mode 100644 index 000000000..661183491 --- /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', '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', 'UV', 'IR']); + 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/dive-common/multicamDisplay.spec.ts b/client/dive-common/multicamDisplay.spec.ts index a5b2a274b..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'; @@ -36,11 +37,24 @@ 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); }); + 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 7a30e10b8..acb0ca846 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 { @@ -61,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/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/backend/native/multiCollectImport.spec.ts b/client/platform/desktop/backend/native/multiCollectImport.spec.ts new file mode 100644 index 000000000..cab294d06 --- /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', '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', 'UV', 'IR']); + expect(collect.importArgs?.type).toBe('image-sequence'); + // 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: '' }, + 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..7a7d74f6d --- /dev/null +++ b/client/platform/desktop/backend/native/multiCollectImport.ts @@ -0,0 +1,52 @@ +/** + * Batch multicam import scanner (desktop filesystem backend). + */ +import npath from 'path'; +import fs from 'fs-extra'; +import { + CollectRawScan, + CollectSubfolderScan, + scanMultiCamBatchFromCollects, +} from 'dive-common/multiCamBatchScan'; +import { findImagesInFolder, listImmediateSubfolders } from './common'; + +async function scanCollectFolder(collectPath: string): Promise> { + const subfolderNames = await listImmediateSubfolders(collectPath); + const subfolders = new Map(); + for (let i = 0; i < subfolderNames.length; i += 1) { + const folderName = subfolderNames[i]; + const subfolderPath = npath.join(collectPath, folderName); + // eslint-disable-next-line no-await-in-loop + const entryCount = (await fs.readdir(subfolderPath)).length; + // eslint-disable-next-line no-await-in-loop + const found = await findImagesInFolder(subfolderPath); + subfolders.set(folderName.toLowerCase(), { + folderName, + path: subfolderPath, + entryCount, + imageCount: found.imagePaths.length, + }); + } + return subfolders; +} + +async function scanMultiCamBatch(rootPath: string) { + 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), + }); + } + + return scanMultiCamBatchFromCollects(rootPath, rawScans); +} + +export default scanMultiCamBatch; diff --git a/client/platform/desktop/constants.ts b/client/platform/desktop/constants.ts index 5b24868ff..c7b0337c4 100644 --- a/client/platform/desktop/constants.ts +++ b/client/platform/desktop/constants.ts @@ -257,6 +257,12 @@ export interface DesktopMediaImportResponse extends MediaImportResponse { metaFileAbsPath?: string; } +export type { + MultiCamBatchCamera, + MultiCamBatchCollect, + MultiCamBatchScanResult, +} from 'dive-common/multiCamBatchScan'; + export interface DesktopJobUpdate extends DesktopJob { // body contents of update payload body: 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..377eb6694 --- /dev/null +++ b/client/platform/desktop/frontend/components/ImportMultiCamBatchDialog.vue @@ -0,0 +1,51 @@ + + + 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" /> + + + [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 diff --git a/client/platform/web-girder/scanMultiCamBatch.spec.ts b/client/platform/web-girder/scanMultiCamBatch.spec.ts new file mode 100644 index 000000000..bb493f478 --- /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', 'UV', 'IR']); + 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..7af60fc59 --- /dev/null +++ b/client/platform/web-girder/scanMultiCamBatch.ts @@ -0,0 +1,94 @@ +/** + * 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 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..01bbe0e15 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,12 @@ function multicamCameraSlotPercent( } export default defineComponent({ - components: { ImportButton, ImportMultiCamDialog, UploadGirder }, + components: { + ImportButton, + ImportMultiCamDialog, + ImportMultiCamBatchDialog, + UploadGirder, + }, props: { location: { type: Object, @@ -125,6 +134,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 +374,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 +415,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 +466,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 +478,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 +487,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 +512,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,25 +526,82 @@ 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( @@ -569,6 +649,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 +694,7 @@ export default defineComponent({ stereo, multiCamOpenType, importMultiCamDialog, + importMultiCamBatchDialog, girderUpload, multicamImporting, multicamImportProgress, @@ -614,12 +702,15 @@ export default defineComponent({ clientSettings, //methods close, + closeMultiCamBatchDialog, openImport, processImport, openMultiCamDialog, filterFileUpload, multiCamImportCheck, multiCamImport, + chooseAndScanBatch, + importBatchCollect, registerSubfolderCameras, unregisterSubfolderCamera, renameSubfolderCamera, @@ -678,6 +769,19 @@ export default defineComponent({ @abort="importMultiCamDialog = false; preUploadErrorMessage = null" /> + + + 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 | ✔️ | ✔️