diff --git a/package-lock.json b/package-lock.json index 5bf59f07..2cc89e7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@aws-sdk/client-s3": "^3.435.0", "@itk-wasm/dicom": "7.2.2", "@itk-wasm/image-io": "^1.3.0", - "@kitware/vtk.js": "^29.0.0", + "@kitware/vtk.js": "^32.6.2", "@netlify/edge-functions": "^2.0.0", "@sentry/vue": "^7.54.0", "@velipso/polybool": "^2.0.11", @@ -3585,9 +3585,10 @@ } }, "node_modules/@kitware/vtk.js": { - "version": "29.11.2", - "resolved": "https://registry.npmjs.org/@kitware/vtk.js/-/vtk.js-29.11.2.tgz", - "integrity": "sha512-SMYz7E5QlnMxg/iuNLYlae993EFG+eHjcQkGwWpGtPS2ANSnJdGiS0tCdc1LJz6gQ5LK01yZzObIHW472BybLQ==", + "version": "32.6.2", + "resolved": "https://registry.npmjs.org/@kitware/vtk.js/-/vtk.js-32.6.2.tgz", + "integrity": "sha512-f2GwNnCq2GTu4PKdjroQ6CZkPCsTwdrt+T3fwpTy3mZ6uL0s9CFWZPS4fYYwuFYqPPqxfmwag0NEI1FIPfRR4g==", + "license": "BSD-3-Clause", "dependencies": { "@babel/runtime": "7.22.11", "@types/webxr": "^0.5.5", @@ -3613,7 +3614,7 @@ "peerDependencies": { "@babel/preset-env": "^7.17.10", "autoprefixer": "^10.4.7", - "wslink": "^1.1.0" + "wslink": ">=1.1.0 || ^2.0.0" } }, "node_modules/@ljharb/through": { @@ -26100,9 +26101,9 @@ } }, "@kitware/vtk.js": { - "version": "29.11.2", - "resolved": "https://registry.npmjs.org/@kitware/vtk.js/-/vtk.js-29.11.2.tgz", - "integrity": "sha512-SMYz7E5QlnMxg/iuNLYlae993EFG+eHjcQkGwWpGtPS2ANSnJdGiS0tCdc1LJz6gQ5LK01yZzObIHW472BybLQ==", + "version": "32.6.2", + "resolved": "https://registry.npmjs.org/@kitware/vtk.js/-/vtk.js-32.6.2.tgz", + "integrity": "sha512-f2GwNnCq2GTu4PKdjroQ6CZkPCsTwdrt+T3fwpTy3mZ6uL0s9CFWZPS4fYYwuFYqPPqxfmwag0NEI1FIPfRR4g==", "requires": { "@babel/runtime": "7.22.11", "@types/webxr": "^0.5.5", diff --git a/package.json b/package.json index 6209ddb2..210cd775 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@aws-sdk/client-s3": "^3.435.0", "@itk-wasm/dicom": "7.2.2", "@itk-wasm/image-io": "^1.3.0", - "@kitware/vtk.js": "^29.0.0", + "@kitware/vtk.js": "^32.6.2", "@netlify/edge-functions": "^2.0.0", "@sentry/vue": "^7.54.0", "@velipso/polybool": "^2.0.11", diff --git a/src/components/SegmentEditor.vue b/src/components/SegmentEditor.vue index 998df3b8..1c3c54ae 100644 --- a/src/components/SegmentEditor.vue +++ b/src/components/SegmentEditor.vue @@ -2,12 +2,20 @@ import LabelEditor from '@/src/components/LabelEditor.vue'; import { computed } from 'vue'; -defineEmits(['done', 'cancel', 'delete', 'update:name', 'update:color']); +defineEmits([ + 'done', + 'cancel', + 'delete', + 'update:name', + 'update:color', + 'update:opacity', +]); const props = defineProps<{ name: string; color: string; invalidNames: Set; + opacity: number; }>(); function isUniqueEditingName(name: string) { @@ -41,6 +49,18 @@ const valid = computed(() => { @keydown.stop.enter="done" :rules="[uniqueNameRule]" /> + diff --git a/src/components/SegmentGroupControls.vue b/src/components/SegmentGroupControls.vue index 1a26299d..91e3149d 100644 --- a/src/components/SegmentGroupControls.vue +++ b/src/components/SegmentGroupControls.vue @@ -208,7 +208,6 @@ function openSaveDialog(id: string) { import { computed, toRefs } from 'vue'; import { useGlobalLayerColorConfig } from '@/src/composables/useGlobalLayerColorConfig'; +import { useGlobalSegmentGroupConfig } from '@/src/store/view-configs/segmentGroups'; const props = defineProps<{ groupId: string; @@ -21,12 +22,33 @@ const setOpacity = (opacity: number) => { }, }); }; + +const { config, updateConfig: updateSegmentGroupConfig } = + useGlobalSegmentGroupConfig(groupId); + +const outlineOpacity = computed({ + get: () => config.value!.config!.outlineOpacity, + set: (opacity: number) => { + updateSegmentGroupConfig({ + outlineOpacity: opacity, + }); + }, +}); + +const outlineThickness = computed({ + get: () => config.value!.config!.outlineThickness, + set: (thickness: number) => { + updateSegmentGroupConfig({ + outlineThickness: thickness, + }); + }, +}); diff --git a/src/components/SegmentList.vue b/src/components/SegmentList.vue index 13e5cca3..05a19073 100644 --- a/src/components/SegmentList.vue +++ b/src/components/SegmentList.vue @@ -11,7 +11,7 @@ import { hexaToRGBA, rgbaToHexa } from '@/src/utils/color'; import { reactive, ref, toRefs, computed, watch } from 'vue'; import { SegmentMask } from '@/src/types/segment'; import { usePaintToolStore } from '@/src/store/tools/paint'; -import { RGBAColor } from '@kitware/vtk.js/types'; +import type { RGBAColor } from '@kitware/vtk.js/types'; const props = defineProps({ groupId: { @@ -58,36 +58,6 @@ watch( { immediate: true } ); -// --- segment opacity --- // - -const selectedSegmentMask = computed(() => { - if (!selectedSegment.value) return null; - return segmentGroupStore.getSegment(groupId.value, selectedSegment.value); -}); - -const segmentOpacity = computed(() => { - if (!selectedSegmentMask.value) return 1; - return selectedSegmentMask.value.color[3] / 255; -}); - -const setSegmentOpacity = (opacity: number) => { - if (!selectedSegmentMask.value) { - return; - } - - const color = selectedSegmentMask.value.color; - segmentGroupStore.updateSegment( - groupId.value, - selectedSegmentMask.value.value, - { - color: [ - ...(color.slice(0, 3) as [number, number, number]), - Math.round(opacity * 255), - ], - } - ); -}; - const toggleVisible = (value: number) => { const segment = segmentGroupStore.getSegment(groupId.value, value); if (!segment) return; @@ -116,6 +86,7 @@ const editingSegmentValue = ref>(null); const editState = reactive({ name: '', color: '', + opacity: 1, }); const editDialog = ref(false); @@ -136,14 +107,20 @@ function startEditing(value: number) { if (!editingSegment.value) return; editState.name = editingSegment.value.name; editState.color = rgbaToHexa(editingSegment.value.color); + editState.opacity = editingSegment.value.color[3] / 255; } function stopEditing(commit: boolean) { - if (editingSegmentValue.value && commit) + if (editingSegmentValue.value && commit) { + const color = [ + ...(hexaToRGBA(editState.color).slice(0, 3) as [number, number, number]), + Math.round(editState.opacity * 255), + ] as RGBAColor; segmentGroupStore.updateSegment(groupId.value, editingSegmentValue.value, { name: editState.name ?? makeDefaultSegmentName(editingSegmentValue.value), - color: hexaToRGBA(editState.color), + color, }); + } editingSegmentValue.value = null; editDialog.value = false; } @@ -170,19 +147,6 @@ function deleteEditingSegment() { - - { if (!config) return; const cfun = sliceRep.property.getRGBTransferFunction(0); - const ofun = sliceRep.property.getScalarOpacity(0); + const ofun = sliceRep.property.getPiecewiseFunction(0); + + if (!cfun || !ofun) throw new Error('Missing transfer functions'); applyColoring({ props: { diff --git a/src/components/vtk/VtkSegmentationSliceRepresentation.vue b/src/components/vtk/VtkSegmentationSliceRepresentation.vue index 0ba1db0f..11a6e673 100644 --- a/src/components/vtk/VtkSegmentationSliceRepresentation.vue +++ b/src/components/vtk/VtkSegmentationSliceRepresentation.vue @@ -1,5 +1,5 @@ diff --git a/src/composables/useSegmentGroupConfigInitializer.ts b/src/composables/useSegmentGroupConfigInitializer.ts index b2674f5a..c53a1e95 100644 --- a/src/composables/useSegmentGroupConfigInitializer.ts +++ b/src/composables/useSegmentGroupConfigInitializer.ts @@ -1,8 +1,9 @@ -import useLayerColoringStore from '@/src/store/view-configs/layers'; import { watchImmediate } from '@vueuse/core'; import { MaybeRef, computed, unref } from 'vue'; +import useLayerColoringStore from '@/src/store/view-configs/layers'; +import { useSegmentGroupConfigStore } from '@/src/store/view-configs/segmentGroups'; -export function useSegmentGroupConfigInitializer( +function useLayerConfigInitializerForSegmentGroups( viewId: MaybeRef, layerId: MaybeRef ) { @@ -16,6 +17,28 @@ export function useSegmentGroupConfigInitializer( const viewIdVal = unref(viewId); const layerIdVal = unref(layerId); - coloringStore.initConfig(viewIdVal, layerIdVal); + coloringStore.initConfig(viewIdVal, layerIdVal); // initConfig instead of resetColorPreset for layers + coloringStore.updateBlendConfig(viewIdVal, layerIdVal, { + opacity: 0.3, + }); + }); +} + +export function useSegmentGroupConfigInitializer( + viewId: MaybeRef, + segmentGroupId: MaybeRef +) { + useLayerConfigInitializerForSegmentGroups(viewId, segmentGroupId); + + const configStore = useSegmentGroupConfigStore(); + const config = computed(() => + configStore.getConfig(unref(viewId), unref(segmentGroupId)) + ); + + watchImmediate(config, (config_) => { + if (config_) return; + const viewIdVal = unref(viewId); + const layerIdVal = unref(segmentGroupId); + configStore.initConfig(viewIdVal, layerIdVal); }); } diff --git a/src/io/state-file/schema.ts b/src/io/state-file/schema.ts index e251f7ac..980990fb 100644 --- a/src/io/state-file/schema.ts +++ b/src/io/state-file/schema.ts @@ -21,6 +21,7 @@ import type { SliceConfig, WindowLevelConfig, LayersConfig, + SegmentGroupConfig, VolumeColorConfig, } from '../../store/view-configs/types'; import type { LPSAxisDir, LPSAxis } from '../../types/lps'; @@ -215,10 +216,16 @@ const LayersConfig = z.object({ blendConfig: BlendConfig, }) satisfies z.ZodType; +const SegmentGroupConfig = z.object({ + outlineOpacity: z.number(), + outlineThickness: z.number(), +}) satisfies z.ZodType; + const ViewConfig = z.object({ window: WindowLevelConfig.optional(), slice: SliceConfig.optional(), layers: LayersConfig.optional(), + segmentGroup: SegmentGroupConfig.optional(), camera: CameraConfig.optional(), volumeColorConfig: VolumeColorConfig.optional(), }); diff --git a/src/store/segmentGroups.ts b/src/store/segmentGroups.ts index 282ad706..57783ce7 100644 --- a/src/store/segmentGroups.ts +++ b/src/store/segmentGroups.ts @@ -2,7 +2,7 @@ import { computed, reactive, ref, toRaw, watch } from 'vue'; import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import vtkBoundingBox from '@kitware/vtk.js/Common/DataModel/BoundingBox'; -import type { RGBAColor } from '@kitware/vtk.js/types'; +import type { RGBAColor, TypedArray } from '@kitware/vtk.js/types'; import { defineStore } from 'pinia'; import { useImageStore } from '@/src/store/datasets-images'; import { join, normalize } from '@/src/utils/path'; @@ -64,20 +64,41 @@ export function createLabelmapFromImage(imageData: vtkImageData) { return labelmap; } +function convertToUint8(array: number[] | TypedArray): Uint8Array { + const uint8Array = new Uint8Array(array.length); + for (let i = 0; i < array.length; i++) { + const value = array[i]; + uint8Array[i] = value < 0 || value > 255 ? 0 : value; + } + return uint8Array; +} + +function getLabelMapScalars(imageData: vtkImageData) { + const scalars = imageData.getPointData().getScalars(); + let values = scalars.getData(); + + if (!(values instanceof LabelmapArrayType)) { + values = convertToUint8(values); + } + + return vtkDataArray.newInstance({ + numberOfComponents: scalars.getNumberOfComponents(), + values, + }); +} + export function toLabelMap(imageData: vtkImageData) { const labelmap = vtkLabelMap.newInstance( - imageData.get( - 'spacing', - 'origin', - 'direction', - 'extent', - 'dataDescription', - 'pointData' - ) + imageData.get('spacing', 'origin', 'direction', 'extent', 'dataDescription') ); + labelmap.setDimensions(imageData.getDimensions()); labelmap.computeTransforms(); + // outline rendering only supports UInt8Array image types + const scalars = getLabelMapScalars(imageData); + labelmap.getPointData().setScalars(scalars); + return labelmap; } diff --git a/src/store/view-configs/common.ts b/src/store/view-configs/common.ts index e02adadc..3142fbfb 100644 --- a/src/store/view-configs/common.ts +++ b/src/store/view-configs/common.ts @@ -3,6 +3,7 @@ import { StateFile, ViewConfig } from '../../io/state-file/schema'; import { CameraConfig, LayersConfig, + SegmentGroupConfig, SliceConfig, VolumeColorConfig, WindowLevelConfig, @@ -14,7 +15,8 @@ type SubViewConfig = | SliceConfig | VolumeColorConfig | WindowLevelConfig - | LayersConfig; + | LayersConfig + | SegmentGroupConfig; type ViewConfigGetter = ( viewID: string, diff --git a/src/store/view-configs/segmentGroups.ts b/src/store/view-configs/segmentGroups.ts new file mode 100644 index 00000000..d20b8210 --- /dev/null +++ b/src/store/view-configs/segmentGroups.ts @@ -0,0 +1,130 @@ +import { reactive, computed, unref, MaybeRef } from 'vue'; +import { defineStore } from 'pinia'; + +import { + DoubleKeyRecord, + deleteSecondKey, + getDoubleKeyRecord, + patchDoubleKeyRecord, +} from '@/src/utils/doubleKeyRecord'; +import { Maybe } from '@/src/types'; + +import { createViewConfigSerializer } from './common'; +import { ViewConfig } from '../../io/state-file/schema'; +import { SegmentGroupConfig } from './types'; + +type Config = SegmentGroupConfig; +const CONFIG_NAME = 'segmentGroup'; + +export const defaultConfig = () => ({ + outlineOpacity: 1.0, + outlineThickness: 2, +}); + +export const useSegmentGroupConfigStore = defineStore( + `${CONFIG_NAME}Config`, + () => { + const configs = reactive>({}); + + const getConfig = (viewID: Maybe, dataID: Maybe) => + getDoubleKeyRecord(configs, viewID, dataID); + + const updateConfig = ( + viewID: string, + dataID: string, + patch: Partial + ) => { + const config = { + ...defaultConfig(), + ...getConfig(viewID, dataID), + ...patch, + }; + + patchDoubleKeyRecord(configs, viewID, dataID, config); + }; + + const initConfig = (viewID: string, dataID: string) => + updateConfig(viewID, dataID, defaultConfig()); + + const removeView = (viewID: string) => { + delete configs[viewID]; + }; + + const removeData = (dataID: string, viewID?: string) => { + if (viewID) { + delete configs[viewID]?.[dataID]; + } else { + deleteSecondKey(configs, dataID); + } + }; + + const serialize = createViewConfigSerializer(configs, CONFIG_NAME); + + const deserialize = ( + viewID: string, + config: Record + ) => { + Object.entries(config).forEach(([dataID, viewConfig]) => { + if (viewConfig.segmentGroup) { + updateConfig(viewID, dataID, viewConfig.segmentGroup); + } + }); + }; + + // For updating all configs together // + + const aConfig = computed(() => { + const viewIDs = Object.keys(configs); + if (viewIDs.length === 0) return null; + const firstViewID = viewIDs[0]; + const dataIDs = Object.keys(configs[firstViewID]); + if (dataIDs.length === 0) return null; + const firstDataID = dataIDs[0]; + return configs[firstViewID][firstDataID]; + }); + + const updateAllConfigs = (dataID: string, patch: Partial) => { + Object.keys(configs).forEach((viewID) => { + updateConfig(viewID, dataID, patch); + }); + }; + + return { + configs, + getConfig, + initConfig, + updateConfig, + removeView, + removeData, + serialize, + deserialize, + aConfig, + updateAllConfigs, + }; + } +); + +export const useGlobalSegmentGroupConfig = (dataId: MaybeRef) => { + const store = useSegmentGroupConfigStore(); + + const views = computed(() => Object.keys(store.configs)); + + const configs = computed(() => + views.value.map((viewID) => ({ + config: store.getConfig(viewID, unref(dataId)), + viewID, + })) + ); + + // get any one + const config = computed(() => configs.value.find(({ config: c }) => c)); + + // update all configs + const updateConfig = (patch: Partial) => { + configs.value.forEach(({ viewID }) => + store.updateConfig(viewID, unref(dataId), patch) + ); + }; + + return { config, updateConfig }; +}; diff --git a/src/store/view-configs/types.ts b/src/store/view-configs/types.ts index 781d6450..b672d44d 100644 --- a/src/store/view-configs/types.ts +++ b/src/store/view-configs/types.ts @@ -57,3 +57,8 @@ export interface LayersConfig { opacityFunction: OpacityFunction; blendConfig: BlendConfig; } + +export interface SegmentGroupConfig { + outlineOpacity: number; + outlineThickness: number; +}