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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion client/dive-common/apispec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ interface Api {
saveAttributeTrackFilters(datasetId: string,
args: SaveAttributeTrackFilterArgs): Promise<unknown>;
// Non-Endpoint shared functions
openFromDisk(datasetType: DatasetType | 'bulk' | 'calibration' | 'annotation' | 'text' | 'zip' | 'transform', directory?: boolean):
openFromDisk(datasetType: DatasetType | 'bulk' | 'calibration' | 'annotation' | 'frameMetadata' | 'text' | 'zip' | 'transform', directory?: boolean):
Promise<{canceled?: boolean; filePaths: string[]; fileList?: File[]; root?: string}>;
/** Desktop: immediate child directory names under a parent folder (multicam subfolder import). */
listImmediateSubfolders?(parentPath: string): Promise<string[]>;
Expand Down Expand Up @@ -363,6 +363,8 @@ interface Api {
}): Promise<unknown>;
importAnnotationFile(id: string, path: string, file?: File,
additive?: boolean, additivePrepend?: string, set?: string): Promise<boolean | string[]>;
/** Store an arbitrary-named frame metadata sidecar with the dataset; resolves true on success. */
importFrameMetadataFile?(id: string, path: string, file?: File): Promise<boolean>;
// Desktop-only calibration persistence functions
getLastCalibration?(): Promise<string | null>;
saveCalibration?(path: string): Promise<{ savedPath: string; updatedDatasetIds: string[] }>;
Expand Down
85 changes: 85 additions & 0 deletions client/dive-common/components/ImportAnnotations.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
import { useApi } from 'dive-common/apispec';
import { usePrompt } from 'dive-common/vue-utilities/prompt-service';
import { clientSettings } from 'dive-common/store/settings';
import { invalidateFrameMetadata } from 'dive-common/use/useFrameMetadata';
import isFrameMetadataSourceName from 'dive-common/frameMetadata/naming';
import clearLengthAttributes from 'dive-common/utils/clearLengthAttributes';
import warpAnnotationsAcrossCameras from 'dive-common/utils/warpAnnotationsAcrossCameras';
import { cloneDeep } from 'lodash';
Expand All @@ -26,6 +28,10 @@ export default defineComponent({
type: String as PropType<string | null>,
default: null,
},
mediaType: {
type: String as PropType<string | null>,
default: null,
},
calibrationFile: {
type: String as PropType<string | null>,
default: null,
Expand Down Expand Up @@ -191,6 +197,12 @@ export default defineComponent({

if (importFile) {
await reloadAnnotations();
// Importing a reserved-name frame-metadata.csv/.txt through this path creates an
// in-viewer sidecar; drop the session cache so the Frame Metadata panel shows it
// without a viewer reload, matching the explicit Import Frame Metadata button.
if (isFrameMetadataSourceName(ret.fileList?.[0]?.name ?? path)) {
invalidateFrameMetadata();
}
if (
warpToAllCameras.value
&& canWarpToAllCameras.value
Expand Down Expand Up @@ -219,6 +231,46 @@ export default defineComponent({
processing.value = false;
}
};
// Frame metadata is only meaningful for image-sequence datasets (single-camera or a
// multicam rig of image sequences); mirror the sibling sections and gate on media type
// so it never renders on video/large-image datasets, where an import can only fail.
const frameMetadataSupported = computed(
() => !!api.importFrameMetadataFile
&& (props.mediaType === 'image-sequence' || isMulticamDataset.value),
);
const openFrameMetadataUpload = async () => {
if (!api.importFrameMetadataFile) return;
try {
const ret = await openFromDisk('frameMetadata');
if (ret.canceled || !ret.filePaths.length) return;
menuOpen.value = false;
processing.value = true;
const result = await api.importFrameMetadataFile(
props.datasetId,
ret.filePaths[0],
ret.fileList?.[0],
);
processing.value = false;
if (result === false) {
await prompt({
title: 'Frame Metadata Import Failed',
text: ['Could not import the frame metadata file.'],
positiveButton: 'OK',
});
return;
}
// The Frame Metadata panel reads through a session cache; drop it so the new
// sidecar shows up without reloading the viewer.
invalidateFrameMetadata();
} catch (error) {
processing.value = false;
prompt({
title: 'Frame Metadata Import Failed',
text: [getResponseError(error)],
positiveButton: 'OK',
});
}
};
const openCalibrationUpload = async () => {
if (!api.importCalibrationFile) return;
try {
Expand Down Expand Up @@ -357,6 +409,8 @@ export default defineComponent({
};
return {
openUpload,
frameMetadataSupported,
openFrameMetadataUpload,
openCalibrationUpload,
openRegistrationUpload,
applyLastCalibration,
Expand Down Expand Up @@ -533,6 +587,37 @@ export default defineComponent({
</div>
</v-col>
</v-container>
<template v-if="frameMetadataSupported">
<v-divider />
<v-card-title class="pt-3">
Import Frame Metadata
</v-card-title>
<v-card-text class="pb-0">
Attach a per-frame metadata file (CSV or delimited text) to this dataset.
The file can have any name; rows are matched to frames by image filename.
<span v-if="isMulticamDataset">
Frame metadata is stored with the dataset and shared across cameras.
</span>
<a
href="https://kitware.github.io/dive/Frame-Metadata/"
target="_blank"
>Frame Metadata Documentation</a>
</v-card-text>
<v-container>
<v-col>
<v-row>
<v-btn
depressed
block
:disabled="!datasetId || processing"
@click="openFrameMetadataUpload"
>
Import
</v-btn>
</v-row>
</v-col>
</v-container>
</template>
<template v-if="registrationSupported">
<v-divider />
<v-card-title class="pt-3">
Expand Down
12 changes: 12 additions & 0 deletions client/dive-common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,16 @@ const inputAnnotationFileTypes = [
'csv',
];

// Accepted extensions for explicit frame metadata import (any basename).
const frameMetadataFileTypes = [
'csv',
'txt',
];

// Girder item meta key marking a declared frame-metadata sidecar. Keep in sync with the
// server constant FrameMetadataMarker (dive_utils/constants.py).
const frameMetadataItemMarker = 'frameMetadata';

const listFileTypes = [
'txt',
];
Expand Down Expand Up @@ -230,6 +240,8 @@ export {
getLargeImageFileAccept,
getLargeImageAllowedExtensions,
inputAnnotationFileTypes,
frameMetadataFileTypes,
frameMetadataItemMarker,
listFileTypes,
transformFileTypes,
zipFileTypes,
Expand Down
80 changes: 79 additions & 1 deletion client/dive-common/use/useFrameMetadata.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
} from 'vitest';

import type { FrameMetadataSourcesResponse } from '../apispec';
import { __resetFrameMetadataSessionCache, useFrameMetadata } from './useFrameMetadata';
import {
__resetFrameMetadataSessionCache, invalidateFrameMetadata, useFrameMetadata,
} from './useFrameMetadata';

// Drain Vue's watcher scheduler and the promise/microtask + macrotask queues so a dataset switch,
// an async source load/resolve, and any lazy per-camera pass all settle before we assert.
Expand Down Expand Up @@ -76,6 +78,82 @@ describe('useFrameMetadata', () => {
expect(metadata.error.value).toBeNull();
});

it('refetches the current dataset when invalidateFrameMetadata fires (explicit import)', async () => {
const before: FrameMetadataSourcesResponse = { cameras: {} };
const after: FrameMetadataSourcesResponse = {
cameras: {
singleCam: [{
name: 'nav_2024.csv',
text: 'filename,depth\nimg001.png,42\n',
}],
},
};
const loadFrameMetadata = vi.fn()
.mockResolvedValueOnce(before)
.mockResolvedValueOnce(after);
const getCameraMediaNames = vi.fn((camera: string) => (
camera === 'singleCam' ? ['img001.png'] : undefined
));

const datasetId = ref('dataset-a');
const frame = ref(0);
const selectedCamera = ref('singleCam');
const metadata = useFrameMetadata({
datasetId, frame, selectedCamera, loadFrameMetadata, getCameraMediaNames,
});

await settle();
expect(loadFrameMetadata).toHaveBeenCalledTimes(1);
expect(metadata.hasMetadataSource.value).toBe(false);

// An explicit sidecar import bumps the invalidation signal: the same dataset refetches
// (negative cache dropped) and the new source resolves without a dataset switch.
invalidateFrameMetadata();
await settle();
expect(loadFrameMetadata).toHaveBeenCalledTimes(2);
expect(metadata.hasMetadataSource.value).toBe(true);
expect(metadata.currentEntries.value).toEqual([['filename', 'img001.png'], ['depth', '42']]);
});

it('recovers after a failed load instead of stranding the panel on error (FIX 4)', async () => {
const good: FrameMetadataSourcesResponse = {
cameras: {
port: [{ name: 'nav.csv', text: 'filename,depth\nport001.png,10\n' }],
starboard: [{ name: 'nav.csv', text: 'filename,depth\nstar001.png,20\n' }],
},
};
// The first load rejects (transient network blip); the next succeeds.
const loadFrameMetadata = vi.fn()
.mockRejectedValueOnce(new Error('network blip'))
.mockResolvedValueOnce(good);
const getCameraMediaNames = vi.fn((camera: string) => ({
port: ['port001.png'],
starboard: ['star001.png'],
}[camera]));

const datasetId = ref('dataset-a');
const frame = ref(0);
const selectedCamera = ref('port');
const metadata = useFrameMetadata({
datasetId, frame, selectedCamera, loadFrameMetadata, getCameraMediaNames,
});

await settle();
// The initial load rejected: the panel is in an error state, not silently "loaded".
expect(loadFrameMetadata).toHaveBeenCalledTimes(1);
expect(metadata.error.value).not.toBeNull();
expect(metadata.hasMetadataSource.value).toBe(false);

// A camera change re-runs ensure(). A failed load must not have committed the dataset as
// loaded, or this would short-circuit and leave the panel stuck forever; instead it retries.
selectedCamera.value = 'starboard';
await settle();
expect(loadFrameMetadata).toHaveBeenCalledTimes(2);
expect(metadata.error.value).toBeNull();
expect(metadata.hasMetadataSource.value).toBe(true);
expect(metadata.currentEntries.value).toEqual([['filename', 'star001.png'], ['depth', '20']]);
});

it('negative-caches an empty source listing and never refetches until a dataset switch', async () => {
const loadFrameMetadata = vi.fn(async () => ({ cameras: {} }));
const getCameraMediaNames = vi.fn(() => [] as string[]);
Expand Down
22 changes: 22 additions & 0 deletions client/dive-common/use/useFrameMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ export function __resetFrameMetadataSessionCache() {
sessionCache = null;
}

// Bumped when a sidecar is added or removed (e.g. explicit frame metadata import) so live
// composable instances drop their state and refetch instead of serving stale sources.
const invalidationCounter = ref(0);

export function invalidateFrameMetadata() {
sessionCache = null;
invalidationCounter.value += 1;
}

export function useFrameMetadata({
datasetId,
frame,
Expand Down Expand Up @@ -183,6 +192,11 @@ export function useFrameMetadata({
} catch (err) {
if (requestToken === token) {
error.value = getResponseError(err);
// A failed load must not leave loadedDatasetId committed: ensure()'s same-dataset
// short-circuit would then treat the panel as loaded and never retry, stranding it
// on the error. Clearing it lets the next ensure() (camera/frame change, remount, or
// invalidation) re-attempt the fetch.
loadedDatasetId = null;
}
} finally {
endWork(requestToken);
Expand Down Expand Up @@ -232,6 +246,14 @@ export function useFrameMetadata({
{ immediate: true },
);

watch(invalidationCounter, () => {
// A sidecar was added or removed. Forget the loaded dataset so ensure() re-runs its
// dataset-switch branch (bump token, reset, refetch) rather than duplicating it here;
// invalidateFrameMetadata() already cleared the session cache, so ensure() refetches.
loadedDatasetId = null;
ensure();
});

// The viewer can expose an empty media list before filenames finish loading.
const activeCameraMediaCount = computed(() => (
getCameraMediaNames ? (getCameraMediaNames(selectedCamera.value)?.length ?? 0) : 0
Expand Down
4 changes: 4 additions & 0 deletions client/platform/desktop/backend/ipcService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ export default function register() {
return ret;
});

ipcMain.handle('import-frame-metadata', async (event, {
id, path,
}: { id: string; path: string }) => common.importFrameMetadataFile(settings.get(), id, path));

ipcMain.handle('get-last-calibration', async () => common.getLastCalibrationPath(settings.get()));

ipcMain.handle('save-calibration', async (_, { path: sourcePath }: { path: string }) => {
Expand Down
Loading
Loading