From 348c8a545ec7693b2401f2ad21371c4f761f4289 Mon Sep 17 00:00:00 2001 From: Laurent Chauvin Date: Wed, 11 Sep 2024 16:35:57 -0400 Subject: [PATCH 1/3] feat(camera): Allow camera and image synchronization --- src/components/ImageDataBrowser.vue | 43 ++++++++++++++++++++ src/composables/usePersistCameraConfig.ts | 10 +++-- src/composables/useSliceConfig.ts | 5 +++ src/io/import/processors/importSingleFile.ts | 17 ++++++++ src/io/state-file/schema.ts | 2 + src/store/datasets-images.ts | 12 ++++++ src/store/view-configs/camera.ts | 40 ++++++++++++++++++ src/store/view-configs/slicing.ts | 43 ++++++++++++++++++++ src/store/view-configs/types.ts | 2 + 9 files changed, 170 insertions(+), 4 deletions(-) diff --git a/src/components/ImageDataBrowser.vue b/src/components/ImageDataBrowser.vue index 14d5ce142..457575fe0 100644 --- a/src/components/ImageDataBrowser.vue +++ b/src/components/ImageDataBrowser.vue @@ -15,6 +15,8 @@ import { useDatasetStore } from '../store/datasets'; import { useMultiSelection } from '../composables/useMultiSelection'; import { useLayersStore } from '../store/datasets-layers'; +import { useViewSliceStore } from '../store/view-configs/slicing'; +import { useViewCameraStore } from '../store/view-configs/camera'; function imageCacheKey(dataID: string) { return `image-${dataID}`; @@ -31,6 +33,8 @@ export default defineComponent({ const dataStore = useDatasetStore(); const layersStore = useLayersStore(); const segmentGroupStore = useSegmentGroupStore(); + const viewSliceStore = useViewSliceStore(); + const viewCameraStore = useViewCameraStore(); const primarySelection = computed(() => dataStore.primarySelection); @@ -116,6 +120,26 @@ export default defineComponent({ { immediate: true, deep: true } ); + // --- sync --- // + const sameSpaceImages = computed(() => { + return imageStore.checkAllImagesSameSpace(); + }); + const isSync = computed(() => { + return viewSliceStore.isSync() && viewCameraStore.isSync(); + }); + function toggleSyncImages() { + viewSliceStore.toggleSyncImages(); + viewCameraStore.toggleSyncCameras(); + viewCameraStore.disableCameraAutoReset = isSync.value; + } + watch( + isSync, + () => { + viewSliceStore.updateSyncConfigs(); + viewCameraStore.updateSyncConfigs(); + } + ); + // --- selection --- // const { selected, selectedAll, selectedSome, toggleSelectAll } = @@ -151,6 +175,9 @@ export default defineComponent({ setPrimarySelection: (sel: DataSelection) => { dataStore.setPrimarySelection(sel); }, + sameSpaceImages, + toggleSyncImages, + isSync, }; }, }); @@ -173,6 +200,22 @@ export default defineComponent({ /> + + mdi-lock + mdi-lock-open-variant + + Sync Images + + ({ @@ -50,6 +49,9 @@ export function usePersistCameraConfig( viewCameraStore.updateConfig(viewIDVal, dataIDVal, { [key]: v, }); + if (viewCameraStore.isSync) { + viewCameraStore.updateSyncConfigs(); + } }, }), }), diff --git a/src/composables/useSliceConfig.ts b/src/composables/useSliceConfig.ts index be82b526a..44d1aa636 100644 --- a/src/composables/useSliceConfig.ts +++ b/src/composables/useSliceConfig.ts @@ -19,6 +19,11 @@ export function useSliceConfig( const imageIdVal = unref(imageID); if (!imageIdVal || val == null) return; store.updateConfig(unref(viewID), imageIdVal, { slice: val }); + + // Update other synchronized views if any + if (config.value?.syncState) { + store.updateSyncConfigs(); + } }, }); const range = computed((): Vector2 => { diff --git a/src/io/import/processors/importSingleFile.ts b/src/io/import/processors/importSingleFile.ts index f64de81c9..e5653b4ad 100644 --- a/src/io/import/processors/importSingleFile.ts +++ b/src/io/import/processors/importSingleFile.ts @@ -8,6 +8,10 @@ import { ImportHandler } from '@/src/io/import/common'; import { DataSourceWithFile } from '@/src/io/import/dataSource'; import { useDatasetStore } from '@/src/store/datasets'; import { useMessageStore } from '@/src/store/messages'; +import { useViewStore } from '@/src/store/views'; +import { useViewSliceStore } from '@/src/store/view-configs/slicing'; +import { getLPSAxisFromDir } from '@/src/utils/lps'; +import { InitViewSpecs } from '@/src/config'; /** * Reads and imports a file DataSource. @@ -36,6 +40,19 @@ const importSingleFile: ImportHandler = async (dataSource, { done }) => { ); fileStore.add(dataID, [dataSource as DataSourceWithFile]); + // Create a default view for each viewID + useViewStore().viewIDs.forEach((viewID: string) => { + const { lpsOrientation, dimensions } = useImageStore().metadata[dataID]; + const axisDir = InitViewSpecs[viewID].props.viewDirection + const lpsFromDir = getLPSAxisFromDir(axisDir); + const lpsOrient = lpsOrientation[lpsFromDir]; + + const dimMax = dimensions[lpsOrient]; + + useViewSliceStore().updateConfig(viewID, dataID, { axisDirection: axisDir, max: dimMax - 1 }); + useViewSliceStore().resetSlice(viewID, dataID); + }); + return done({ dataID, dataSource, diff --git a/src/io/state-file/schema.ts b/src/io/state-file/schema.ts index 65a8cbc90..753191b7e 100644 --- a/src/io/state-file/schema.ts +++ b/src/io/state-file/schema.ts @@ -113,6 +113,7 @@ const SliceConfig = z.object({ min: z.number(), max: z.number(), axisDirection: LPSAxisDir, + syncState: z.boolean(), }) satisfies z.ZodType; const CameraConfig = z.object({ @@ -121,6 +122,7 @@ const CameraConfig = z.object({ focalPoint: Vector3.optional(), directionOfProjection: Vector3.optional(), viewUp: Vector3.optional(), + syncState: z.boolean().optional(), }) satisfies z.ZodType; const ColorBy = z.object({ diff --git a/src/store/datasets-images.ts b/src/store/datasets-images.ts index 817a7c067..460d89218 100644 --- a/src/store/datasets-images.ts +++ b/src/store/datasets-images.ts @@ -10,6 +10,7 @@ import { StateFile, DatasetType } from '../io/state-file/schema'; import { serializeData } from '../io/state-file/utils'; import { useFileStore } from './datasets-files'; import { ImageMetadata } from '../types/image'; +import { compareImageSpaces } from '../utils/imageSpace'; export const defaultImageMetadata = () => ({ name: '(none)', @@ -79,6 +80,17 @@ export const useImageStore = defineStore('images', { } }, + checkAllImagesSameSpace() { + if (this.idList.length < 2) return false; + + const dataFirst = this.dataIndex[this.idList[0]]; + const allEqual = this.idList.slice(1).every((id) => { + return compareImageSpaces(this.dataIndex[id], dataFirst); + }); + + return allEqual; + }, + async serialize(stateFile: StateFile) { const fileStore = useFileStore(); // We want to filter out volume images (which are generated and don't have diff --git a/src/store/view-configs/camera.ts b/src/store/view-configs/camera.ts index 86d3cacf5..846a6f7b5 100644 --- a/src/store/view-configs/camera.ts +++ b/src/store/view-configs/camera.ts @@ -7,11 +7,14 @@ import { } from '@/src/utils/doubleKeyRecord'; import { reactive, ref } from 'vue'; import { Maybe } from '@/src/types'; +import { useCurrentImage } from '@/src/composables/useCurrentImage'; import { createViewConfigSerializer } from './common'; import { ViewConfig } from '../../io/state-file/schema'; import { CameraConfig } from './types'; +import { useImageStore } from '../datasets-images'; export const useViewCameraStore = defineStore('viewCamera', () => { + const imageStore = useImageStore(); const configs = reactive>({}); const getConfig = (viewID: Maybe, dataID: Maybe) => @@ -48,6 +51,40 @@ export const useViewCameraStore = defineStore('viewCamera', () => { } }; + const toggleSyncCameras = () => { + // Synchronize all cameras when toggled + Object.keys(configs).forEach((viewID) => { + imageStore.idList.forEach((imageID) => { + const { syncState } = { + ...getConfig(viewID, imageID) + }; + updateConfig(viewID, imageID, { syncState: !syncState }); + }); + }); + } + + const isSync = () => { + const allSync = Object.keys(configs).every((sc) => Object.keys(configs[sc]).every((c) => configs[sc][c].syncState)); + + return allSync; + } + + const updateSyncConfigs = () => { + Object.keys(configs).forEach((viewID) => { + const { currentImageID } = useCurrentImage(); + const config = getConfig(viewID, currentImageID.value); + imageStore.idList.forEach((imageID) => { + const { syncState } = { + ...getConfig(viewID, imageID) + }; + + if (syncState) { + updateConfig(viewID, imageID, { position: config?.position, focalPoint: config?.focalPoint, parallelScale: config?.parallelScale }); + } + }); + }); + } + const serialize = createViewConfigSerializer(configs, 'camera'); const deserialize = (viewID: string, config: Record) => { @@ -66,6 +103,9 @@ export const useViewCameraStore = defineStore('viewCamera', () => { toggleCameraAutoReset, removeView, removeData, + toggleSyncCameras, + updateSyncConfigs, + isSync, serialize, deserialize, }; diff --git a/src/store/view-configs/slicing.ts b/src/store/view-configs/slicing.ts index c715df7ca..ac53ac078 100644 --- a/src/store/view-configs/slicing.ts +++ b/src/store/view-configs/slicing.ts @@ -8,18 +8,22 @@ import { patchDoubleKeyRecord, } from '@/src/utils/doubleKeyRecord'; import { Maybe } from '@/src/types'; +import { useCurrentImage } from '@/src/composables/useCurrentImage'; import { createViewConfigSerializer } from './common'; import { ViewConfig } from '../../io/state-file/schema'; import { SliceConfig } from './types'; +import { useImageStore } from '../datasets-images'; export const defaultSliceConfig = (): SliceConfig => ({ slice: 0, min: 0, max: 1, axisDirection: 'Inferior', + syncState: false, }); export const useViewSliceStore = defineStore('viewSlice', () => { + const imageStore = useImageStore(); const configs = reactive>({}); const getConfig = (viewID: Maybe, dataID: Maybe) => @@ -64,6 +68,42 @@ export const useViewSliceStore = defineStore('viewSlice', () => { } }; + const toggleSyncImages = () => { + // Synchronize all images when toggled + Object.keys(configs).forEach((viewID) => { + imageStore.idList.forEach((imageID) => { + const { syncState } = { + ...defaultSliceConfig(), + ...getConfig(viewID, imageID) + }; + updateConfig(viewID, imageID, { syncState: !syncState }); + }); + }); + } + + const isSync = () => { + const allSync = Object.keys(configs).every((sc) => Object.keys(configs[sc]).every((c) => configs[sc][c].syncState)); + + return allSync; + } + + const updateSyncConfigs = () => { + Object.keys(configs).forEach((viewID) => { + const { currentImageID } = useCurrentImage(); + const config = getConfig(viewID, currentImageID.value); + imageStore.idList.forEach((imageID) => { + const { syncState } = { + ...defaultSliceConfig(), + ...getConfig(viewID, imageID) + }; + + if (syncState) { + updateConfig(viewID, imageID, { slice: config?.slice }); + } + }); + }); + } + const serialize = createViewConfigSerializer(configs, 'slice'); const deserialize = (viewID: string, config: Record) => { @@ -81,6 +121,9 @@ export const useViewSliceStore = defineStore('viewSlice', () => { resetSlice, removeView, removeData, + toggleSyncImages, + updateSyncConfigs, + isSync, serialize, deserialize, }; diff --git a/src/store/view-configs/types.ts b/src/store/view-configs/types.ts index 7a54766da..781d64502 100644 --- a/src/store/view-configs/types.ts +++ b/src/store/view-configs/types.ts @@ -14,6 +14,7 @@ export interface CameraConfig { focalPoint?: Vector3; directionOfProjection?: Vector3; viewUp?: Vector3; + syncState?: boolean; } export interface SliceConfig { @@ -21,6 +22,7 @@ export interface SliceConfig { min: number; max: number; axisDirection: LPSAxisDir; + syncState: boolean; } export interface VolumeColorConfig { From 7be4abe9b8f478476cb80f671b0dd2c55c7bbfdc Mon Sep 17 00:00:00 2001 From: Laurent Chauvin Date: Thu, 26 Sep 2024 14:44:51 -0400 Subject: [PATCH 2/3] Fix typo --- src/composables/usePersistCameraConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/composables/usePersistCameraConfig.ts b/src/composables/usePersistCameraConfig.ts index c5b97dd96..19ccbf7a1 100644 --- a/src/composables/usePersistCameraConfig.ts +++ b/src/composables/usePersistCameraConfig.ts @@ -49,7 +49,7 @@ export function usePersistCameraConfig( viewCameraStore.updateConfig(viewIDVal, dataIDVal, { [key]: v, }); - if (viewCameraStore.isSync) { + if (viewCameraStore.isSync()) { viewCameraStore.updateSyncConfigs(); } }, From 4f0ebf52fc5711b5b74669f34d6d6bfe0d322353 Mon Sep 17 00:00:00 2001 From: Laurent Chauvin Date: Wed, 2 Oct 2024 21:44:14 -0400 Subject: [PATCH 3/3] Prettify --- src/components/ImageDataBrowser.vue | 17 ++++++-------- src/io/import/processors/importSingleFile.ts | 7 ++++-- src/store/datasets-images.ts | 2 +- src/store/view-configs/camera.ts | 24 ++++++++++++-------- src/store/view-configs/slicing.ts | 14 +++++++----- 5 files changed, 36 insertions(+), 28 deletions(-) diff --git a/src/components/ImageDataBrowser.vue b/src/components/ImageDataBrowser.vue index 457575fe0..2fe67e5a3 100644 --- a/src/components/ImageDataBrowser.vue +++ b/src/components/ImageDataBrowser.vue @@ -120,8 +120,8 @@ export default defineComponent({ { immediate: true, deep: true } ); - // --- sync --- // - const sameSpaceImages = computed(() => { + // --- sync --- // + const sameSpaceImages = computed(() => { return imageStore.checkAllImagesSameSpace(); }); const isSync = computed(() => { @@ -132,13 +132,10 @@ export default defineComponent({ viewCameraStore.toggleSyncCameras(); viewCameraStore.disableCameraAutoReset = isSync.value; } - watch( - isSync, - () => { - viewSliceStore.updateSyncConfigs(); - viewCameraStore.updateSyncConfigs(); - } - ); + watch(isSync, () => { + viewSliceStore.updateSyncConfigs(); + viewCameraStore.updateSyncConfigs(); + }); // --- selection --- // @@ -206,7 +203,7 @@ export default defineComponent({ :disabled="!sameSpaceImages" @click.stop="toggleSyncImages" > - mdi-lock + mdi-lock mdi-lock-open-variant { // Create a default view for each viewID useViewStore().viewIDs.forEach((viewID: string) => { const { lpsOrientation, dimensions } = useImageStore().metadata[dataID]; - const axisDir = InitViewSpecs[viewID].props.viewDirection + const axisDir = InitViewSpecs[viewID].props.viewDirection; const lpsFromDir = getLPSAxisFromDir(axisDir); const lpsOrient = lpsOrientation[lpsFromDir]; const dimMax = dimensions[lpsOrient]; - useViewSliceStore().updateConfig(viewID, dataID, { axisDirection: axisDir, max: dimMax - 1 }); + useViewSliceStore().updateConfig(viewID, dataID, { + axisDirection: axisDir, + max: dimMax - 1, + }); useViewSliceStore().resetSlice(viewID, dataID); }); diff --git a/src/store/datasets-images.ts b/src/store/datasets-images.ts index 460d89218..580017373 100644 --- a/src/store/datasets-images.ts +++ b/src/store/datasets-images.ts @@ -85,7 +85,7 @@ export const useImageStore = defineStore('images', { const dataFirst = this.dataIndex[this.idList[0]]; const allEqual = this.idList.slice(1).every((id) => { - return compareImageSpaces(this.dataIndex[id], dataFirst); + return compareImageSpaces(this.dataIndex[id], dataFirst); }); return allEqual; diff --git a/src/store/view-configs/camera.ts b/src/store/view-configs/camera.ts index 846a6f7b5..0355808f8 100644 --- a/src/store/view-configs/camera.ts +++ b/src/store/view-configs/camera.ts @@ -32,12 +32,12 @@ export const useViewCameraStore = defineStore('viewCamera', () => { patchDoubleKeyRecord(configs, viewID, dataID, config); }; - + const disableCameraAutoReset = ref(false); const toggleCameraAutoReset = () => { disableCameraAutoReset.value = !disableCameraAutoReset.value; - } + }; const removeView = (viewID: string) => { delete configs[viewID]; @@ -56,18 +56,20 @@ export const useViewCameraStore = defineStore('viewCamera', () => { Object.keys(configs).forEach((viewID) => { imageStore.idList.forEach((imageID) => { const { syncState } = { - ...getConfig(viewID, imageID) + ...getConfig(viewID, imageID), }; updateConfig(viewID, imageID, { syncState: !syncState }); }); }); - } + }; const isSync = () => { - const allSync = Object.keys(configs).every((sc) => Object.keys(configs[sc]).every((c) => configs[sc][c].syncState)); + const allSync = Object.keys(configs).every((sc) => + Object.keys(configs[sc]).every((c) => configs[sc][c].syncState) + ); return allSync; - } + }; const updateSyncConfigs = () => { Object.keys(configs).forEach((viewID) => { @@ -75,15 +77,19 @@ export const useViewCameraStore = defineStore('viewCamera', () => { const config = getConfig(viewID, currentImageID.value); imageStore.idList.forEach((imageID) => { const { syncState } = { - ...getConfig(viewID, imageID) + ...getConfig(viewID, imageID), }; if (syncState) { - updateConfig(viewID, imageID, { position: config?.position, focalPoint: config?.focalPoint, parallelScale: config?.parallelScale }); + updateConfig(viewID, imageID, { + position: config?.position, + focalPoint: config?.focalPoint, + parallelScale: config?.parallelScale, + }); } }); }); - } + }; const serialize = createViewConfigSerializer(configs, 'camera'); diff --git a/src/store/view-configs/slicing.ts b/src/store/view-configs/slicing.ts index ac53ac078..e45ad15f1 100644 --- a/src/store/view-configs/slicing.ts +++ b/src/store/view-configs/slicing.ts @@ -74,18 +74,20 @@ export const useViewSliceStore = defineStore('viewSlice', () => { imageStore.idList.forEach((imageID) => { const { syncState } = { ...defaultSliceConfig(), - ...getConfig(viewID, imageID) + ...getConfig(viewID, imageID), }; updateConfig(viewID, imageID, { syncState: !syncState }); }); }); - } + }; const isSync = () => { - const allSync = Object.keys(configs).every((sc) => Object.keys(configs[sc]).every((c) => configs[sc][c].syncState)); + const allSync = Object.keys(configs).every((sc) => + Object.keys(configs[sc]).every((c) => configs[sc][c].syncState) + ); return allSync; - } + }; const updateSyncConfigs = () => { Object.keys(configs).forEach((viewID) => { @@ -94,7 +96,7 @@ export const useViewSliceStore = defineStore('viewSlice', () => { imageStore.idList.forEach((imageID) => { const { syncState } = { ...defaultSliceConfig(), - ...getConfig(viewID, imageID) + ...getConfig(viewID, imageID), }; if (syncState) { @@ -102,7 +104,7 @@ export const useViewSliceStore = defineStore('viewSlice', () => { } }); }); - } + }; const serialize = createViewConfigSerializer(configs, 'slice');