From 1032112aae4b16ec0b82857eb18b98d83df70f26 Mon Sep 17 00:00:00 2001 From: Forrest Li Date: Tue, 23 Feb 2021 13:16:24 -0500 Subject: [PATCH 1/8] fix: Rename worldOrientation to imageConfig Also fixes up overloaded term "bounds". Bounds now refers to world bounds, and extent is the image-space bounds of the image. --- src/components/VtkThreeView.vue | 16 ++++---- src/components/VtkTwoView.vue | 33 +++++++-------- src/composables/scene.js | 10 ++--- src/composables/view/view2D.js | 6 +-- src/store/visualization.js | 70 ++++++++++++++++++-------------- src/widgets/paint.js | 10 ++--- src/widgets/ruler.js | 4 +- src/widgets/slicingCrosshairs.js | 10 ++--- 8 files changed, 82 insertions(+), 77 deletions(-) diff --git a/src/components/VtkThreeView.vue b/src/components/VtkThreeView.vue index 8e702c4c9..a9775d1af 100644 --- a/src/components/VtkThreeView.vue +++ b/src/components/VtkThreeView.vue @@ -42,9 +42,9 @@ export default { const { sceneSources, - worldOrientation, + imageConfig, colorBy, - boundsWithSpacing, + extentWithSpacing, baseImageColorPreset, baseImage, slices, @@ -56,11 +56,11 @@ export default { .filter((id) => id in pipelines) .map((id) => pipelines[id].last); }, - worldOrientation: (state) => state.visualization.worldOrientation, + imageConfig: (state) => state.visualization.imageConfig, colorBy: (state, getters) => getters.sceneObjectIDs.map((id) => state.visualization.colorBy[id]), - boundsWithSpacing: (_, getters) => - getters['visualization/boundsWithSpacing'], + extentWithSpacing: (_, getters) => + getters['visualization/extentWithSpacing'], baseImageColorPreset: (_, getters) => getters['visualization/baseImageColorPreset'], baseImage(state) { @@ -75,7 +75,7 @@ export default { windowing: (state) => state.visualization.windowing, }); - const spacing = computed(() => worldOrientation.value.spacing); + const spacing = computed(() => imageConfig.value.spacing); const viewRef = useVtkView({ containerRef: vtkContainer, @@ -94,7 +94,7 @@ export default { }); // update scene sources and their colors - watchScene(sceneSources, worldOrientation, viewRef); + watchScene(sceneSources, imageConfig, viewRef); watchColorBy(colorBy, sceneSources, viewRef); // prepare view @@ -121,7 +121,7 @@ export default { }); // reset camera whenever bounds changes - watch(boundsWithSpacing, () => { + watch(extentWithSpacing, () => { const view = unref(viewRef); if (view) { view.resetCamera(); diff --git a/src/components/VtkTwoView.vue b/src/components/VtkTwoView.vue index da26da56f..5bff91e4c 100644 --- a/src/components/VtkTwoView.vue +++ b/src/components/VtkTwoView.vue @@ -57,15 +57,15 @@ import SliceSlider from '@/src/components/SliceSlider.vue'; * This differs from view.resetCamera() in that we reset the view * to the specified bounds. */ -function resetCamera(viewRef, boundsWithSpacing, resizeToFit) { +function resetCamera(viewRef, extentWithSpacing, resizeToFit) { const view = unref(viewRef); if (view) { const renderer = view.getRenderer(); renderer.computeVisiblePropBounds(); - renderer.resetCamera(unref(boundsWithSpacing)); + renderer.resetCamera(unref(extentWithSpacing)); if (unref(resizeToFit)) { - resize2DCameraToFit(view, unref(boundsWithSpacing)); + resize2DCameraToFit(view, unref(extentWithSpacing)); } } } @@ -91,9 +91,9 @@ export default { const { sceneSources, - worldOrientation, + imageConfig, colorBy, - boundsWithSpacing, + extentWithSpacing, baseImage, currentSlice, windowing, @@ -105,11 +105,11 @@ export default { .filter((id) => id in pipelines) .map((id) => pipelines[id].last); }, - worldOrientation: (state) => state.visualization.worldOrientation, + imageConfig: (state) => state.visualization.imageConfig, colorBy: (state, getters) => getters.sceneObjectIDs.map((id) => state.visualization.colorBy[id]), - boundsWithSpacing: (_, getters) => - getters['visualization/boundsWithSpacing'], + extentWithSpacing: (_, getters) => + getters['visualization/extentWithSpacing'], baseImage(state) { const { pipelines } = state.visualization; const { selectedBaseImage } = state; @@ -138,7 +138,7 @@ export default { }); const currentSliceSpacing = computed( - () => worldOrientation.value.spacing[axis.value] + () => imageConfig.value.spacing[axis.value] ); const viewRef = useVtkView({ @@ -157,17 +157,17 @@ export default { } }); - watchScene(sceneSources, worldOrientation, viewRef); + watchScene(sceneSources, imageConfig, viewRef); watchColorBy(colorBy, sceneSources, viewRef); // reset camera conditions watch( - [baseImage, boundsWithSpacing], - () => resetCamera(viewRef, boundsWithSpacing, resizeToFit), + [baseImage, extentWithSpacing], + () => resetCamera(viewRef, extentWithSpacing, resizeToFit), { immediate: true } ); useSubscription(viewRef, (view) => - view.onResize(() => resetCamera(viewRef, boundsWithSpacing, resizeToFit)) + view.onResize(() => resetCamera(viewRef, extentWithSpacing, resizeToFit)) ); // setup view @@ -193,7 +193,7 @@ export default { default: windowing.value.level, })); const sliceRange = computed(() => { - const { bounds } = unref(worldOrientation); + const { bounds } = unref(imageConfig); return { min: bounds[axis.value * 2], max: bounds[axis.value * 2 + 1], @@ -243,10 +243,7 @@ export default { const { pixelProbe } = usePixelProbe(viewRef, baseImage); // orientation labels - const { leftLabel, upLabel } = useOrientationLabels( - viewRef, - worldOrientation - ); + const { leftLabel, upLabel } = useOrientationLabels(viewRef, imageConfig); // pixel probe annotation const pixelAnnotation = computed(() => { diff --git a/src/composables/scene.js b/src/composables/scene.js index 54fce9331..7e9fac804 100644 --- a/src/composables/scene.js +++ b/src/composables/scene.js @@ -5,16 +5,16 @@ import { useProxyManager } from '@/src/composables/proxyManager'; /** * Updates the scene. * @param {Ref} sourcesRef - * @param {Ref} worldOrientationRef + * @param {Ref} imageConfigRef * @param {Ref} viewRef */ -export function watchScene(sourcesRef, worldOrientationRef, viewRef) { +export function watchScene(sourcesRef, imageConfigRef, viewRef) { const pxm = useProxyManager(); function repopulateScene() { const view = unref(viewRef); const sources = unref(sourcesRef); - const worldOrientation = unref(worldOrientationRef); + const imageConfig = unref(imageConfigRef); if (view) { view .getRepresentations() @@ -24,7 +24,7 @@ export function watchScene(sourcesRef, worldOrientationRef, viewRef) { const rep = pxm.getRepresentation(source, view); if (rep) { if (rep.setTransform) { - rep.setTransform(worldOrientation.worldToIndex); + rep.setTransform(imageConfig.worldToIndex); } view.addRepresentation(rep); } @@ -32,7 +32,7 @@ export function watchScene(sourcesRef, worldOrientationRef, viewRef) { } } - watch([sourcesRef, viewRef, worldOrientationRef], repopulateScene); + watch([sourcesRef, viewRef], repopulateScene); // trigger this after repopulateScene watch(sourcesRef, () => { diff --git a/src/composables/view/view2D.js b/src/composables/view/view2D.js index 503a3967c..fbeeae953 100644 --- a/src/composables/view/view2D.js +++ b/src/composables/view/view2D.js @@ -32,9 +32,9 @@ function lpsDirToLabels(dir) { /** * Writes out left and up orientation labels. * @param {Ref} viewRef - * @param {Ref} worldOrientation + * @param {Ref} imageConfig */ -export function useOrientationLabels(viewRef, worldOrientation) { +export function useOrientationLabels(viewRef, imageConfig) { const leftLabel = ref(''); const upLabel = ref(''); @@ -49,7 +49,7 @@ export function useOrientationLabels(viewRef, worldOrientation) { vec3.cross(vright, vdir, vup); // assume direction is orthonormal - const { direction } = unref(worldOrientation); + const { direction } = unref(imageConfig); // since camera is in "image space", transform into // image's world space. diff --git a/src/store/visualization.js b/src/store/visualization.js index bc994225f..f56128851 100644 --- a/src/store/visualization.js +++ b/src/store/visualization.js @@ -15,14 +15,12 @@ export function asInteger(value, defaultValue) { return defaultValue; } -export const defaultWorldOrientation = () => ({ - // ok for images this is actually just extent, since - // that's how we process images in this application. +export const defaultImageConfig = () => ({ bounds: [0, 1, 0, 1, 0, 1], - // world spacing + extent: [0, 1, 0, 1, 0, 1], + dimensions: [1, 1, 1], spacing: [1, 1, 1], direction: [1, 0, 0, 0, 1, 0, 0, 0, 1], - // identity worldToIndex: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], }); @@ -79,7 +77,7 @@ export default (dependencies) => ({ pipelines: {}, slices: defaultSlicing(), resizeToFit: true, - worldOrientation: defaultWorldOrientation(), + imageConfig: defaultImageConfig(), windowing: defaultWindowing(), colorBy: {}, // id -> { array, location } arrayLutPresets: {}, // arrayName -> LUT preset @@ -94,9 +92,18 @@ export default (dependencies) => ({ }; }, - setWorldOrientation(state, { bounds, spacing, direction, worldToIndex }) { - state.worldOrientation = { + setImageConfig( + state, + { bounds, extent, spacing, direction, worldToIndex } + ) { + state.imageConfig = { bounds: [...bounds], + extent: [...extent], + dimensions: [ + extent[1] - extent[0] + 1, + extent[3] - extent[2] + 1, + extent[5] - extent[4] + 1, + ], spacing: [...spacing], direction: [...direction], worldToIndex: [...worldToIndex], @@ -164,9 +171,9 @@ export default (dependencies) => ({ }, getters: { - boundsWithSpacing(state) { - const { spacing, bounds } = state.worldOrientation; - return bounds.map((b, i) => b * spacing[Math.floor(i / 2)]); + extentWithSpacing(state) { + const { spacing, extent } = state.imageConfig; + return extent.map((b, i) => b * spacing[Math.floor(i / 2)]); }, baseImagePipeline(state, getters, rootState) { const { selectedBaseImage } = rootState; @@ -185,7 +192,7 @@ export default (dependencies) => ({ actions: { async updateScene({ dispatch }, { reset = false }) { if (reset) { - await dispatch('updateWorldOrientation'); + await dispatch('updateImageConfig'); await dispatch('resetWindowing'); await dispatch('resetSlicing'); await dispatch('updateColorBy'); @@ -201,7 +208,7 @@ export default (dependencies) => ({ }, /** - * Should run after updateWorldOrientation + * Should run after updateImageConfig */ createPipelinesForScene({ commit, state, rootGetters, rootState }) { const { proxyManager } = dependencies; @@ -219,14 +226,14 @@ export default (dependencies) => ({ } }, - async updateWorldOrientation({ commit, rootState }) { + async updateImageConfig({ commit, rootState }) { const { selectedBaseImage, data } = rootState; if (selectedBaseImage !== NO_SELECTION) { const image = data.vtkCache[selectedBaseImage]; - const spacing = image.getSpacing(); - commit('setWorldOrientation', { - bounds: image.getExtent(), - spacing, + commit('setImageConfig', { + bounds: image.getBounds(), + extent: image.getExtent(), + spacing: image.getSpacing(), direction: image.getDirection(), worldToIndex: [...image.getWorldToIndex()], }); @@ -240,9 +247,10 @@ export default (dependencies) => ({ } bbox.inflate(5); // some extra padding // Without a base image, we assume a spacing of 1. - commit('setWorldOrientation', { - ...defaultWorldOrientation(), + commit('setImageConfig', { + ...defaultImageConfig(), bounds: bbox.getBounds(), + extent: bbox.getBounds(), }); } }, @@ -265,23 +273,23 @@ export default (dependencies) => ({ }, /** - * updateWorldOrientation should be invoked prior to this action. + * updateImageConfig should be invoked prior to this action. */ async resetSlicing({ commit, state, rootState }) { if (rootState.selectedBaseImage !== NO_SELECTION) { - const { bounds } = state.worldOrientation; + const { extent } = state.imageConfig; await commit('setSlices', { - x: bounds[0], - y: bounds[2], - z: bounds[4], + x: extent[0], + y: extent[2], + z: extent[4], }); } else { - // pick middle of bounds - const { bounds } = state.worldOrientation; + // pick middle of extent + const { extent } = state.imageConfig; const center = [ - (bounds[0] + bounds[1]) / 2, - (bounds[2] + bounds[3]) / 2, - (bounds[4] + bounds[5]) / 2, + (extent[0] + extent[1]) / 2, + (extent[2] + extent[3]) / 2, + (extent[4] + extent[5]) / 2, ]; await commit('setSlices', { x: center[0], @@ -389,7 +397,7 @@ export default (dependencies) => ({ const rep = proxyManager.getRepresentation(source, view); if (rep) { if (rep.setTransform) { - rep.setTransform(...state.worldOrientation.worldToIndex); + rep.setTransform(...state.imageConfig.worldToIndex); } rep.getMapper().modified(); } diff --git a/src/widgets/paint.js b/src/widgets/paint.js index ccebdaff4..6a6808c13 100644 --- a/src/widgets/paint.js +++ b/src/widgets/paint.js @@ -45,8 +45,8 @@ export default class PaintWidget extends Widget { updateManipulator(view) { if (view) { const axis = view.getAxis(); - const { slices, worldOrientation } = this.store.state.visualization; - const { spacing } = worldOrientation; + const { slices, imageConfig } = this.store.state.visualization; + const { spacing } = imageConfig; const normal = [0, 0, 0]; normal[axis] = 1; const origin = [0, 0, 0]; @@ -85,13 +85,13 @@ export default class PaintWidget extends Widget { if (id !== NO_SELECTION) { const { vtkCache } = this.store.state.data; - const { worldOrientation } = this.store.state.visualization; + const { imageConfig } = this.store.state.visualization; const { radius, currentLabelFor } = this.store.state.annotations; this.filter = vtkPaintFilter.newInstance(); this.filter.setBackgroundImage(vtkCache[selectedBaseImage]); this.filter.setLabelMap(vtkCache[id]); - this.filter.setMaskWorldToIndex(worldOrientation.worldToIndex); + this.filter.setMaskWorldToIndex(imageConfig.worldToIndex); this.filter.setLabel(currentLabelFor[id]); this.onRadiusChange(radius); } else { @@ -116,7 +116,7 @@ export default class PaintWidget extends Widget { const { spacing, worldToIndex, - } = this.store.state.visualization.worldOrientation; + } = this.store.state.visualization.imageConfig; const inv = mat4.create(); const pt = vec3.create(); mat4.invert(inv, worldToIndex); diff --git a/src/widgets/ruler.js b/src/widgets/ruler.js index fd1ea8c51..f30cafbbb 100644 --- a/src/widgets/ruler.js +++ b/src/widgets/ruler.js @@ -77,8 +77,8 @@ export default class RulerWidget extends Widget { updateManipulator(view) { if (view && this.lockedSlice === null) { const axis = view.getAxis(); - const { slices, worldOrientation } = this.store.state.visualization; - const { spacing } = worldOrientation; + const { slices, imageConfig } = this.store.state.visualization; + const { spacing } = imageConfig; const normal = [0, 0, 0]; normal[axis] = 1; const origin = [0, 0, 0]; diff --git a/src/widgets/slicingCrosshairs.js b/src/widgets/slicingCrosshairs.js index 0a1b84042..2588aa965 100644 --- a/src/widgets/slicingCrosshairs.js +++ b/src/widgets/slicingCrosshairs.js @@ -14,10 +14,10 @@ export default class RulerWidget extends Widget { this.factory = vtkCrosshairsWidget.newInstance(); this.state = this.factory.getWidgetState(); - const { bounds, spacing } = this.store.state.visualization.worldOrientation; + const { extent, spacing } = this.store.state.visualization.imageConfig; this.state .getHandle() - .setBounds(...bounds.map((b, i) => b * spacing[Math.floor(i / 2)])); + .setBounds(...extent.map((b, i) => b * spacing[Math.floor(i / 2)])); // register after setting handle bounds, so our slices don't get // reset to 0,0,0 @@ -31,7 +31,7 @@ export default class RulerWidget extends Widget { } const origin = this.state.getHandle().getOrigin(); - const { spacing } = this.store.state.visualization.worldOrientation; + const { spacing } = this.store.state.visualization.imageConfig; this.store.dispatch('visualization/setSlices', { x: origin[0] / spacing[0], y: origin[1] / spacing[1], @@ -54,8 +54,8 @@ export default class RulerWidget extends Widget { updateManipulator(view) { if (view) { const axis = view.getAxis(); - const { slices, worldOrientation } = this.store.state.visualization; - const { spacing } = worldOrientation; + const { slices, imageConfig } = this.store.state.visualization; + const { spacing } = imageConfig; const normal = [0, 0, 0]; normal[axis] = 1; const origin = [0, 0, 0]; From e94b3d0e84ec360185a50776b0ca69e0cf84e504 Mon Sep 17 00:00:00 2001 From: Forrest Li Date: Tue, 2 Mar 2021 00:23:24 -0500 Subject: [PATCH 2/8] feat(visualization): Add index to world transform This is a useful transform to have in the image config. --- src/store/visualization.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/store/visualization.js b/src/store/visualization.js index f56128851..ea5861fdb 100644 --- a/src/store/visualization.js +++ b/src/store/visualization.js @@ -20,8 +20,10 @@ export const defaultImageConfig = () => ({ extent: [0, 1, 0, 1, 0, 1], dimensions: [1, 1, 1], spacing: [1, 1, 1], + // all matrices are column-major direction: [1, 0, 0, 0, 1, 0, 0, 0, 1], worldToIndex: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + indexToWorld: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], }); export const defaultWindowing = () => ({ @@ -94,7 +96,7 @@ export default (dependencies) => ({ setImageConfig( state, - { bounds, extent, spacing, direction, worldToIndex } + { bounds, extent, spacing, direction, worldToIndex, indexToWorld } ) { state.imageConfig = { bounds: [...bounds], @@ -107,6 +109,7 @@ export default (dependencies) => ({ spacing: [...spacing], direction: [...direction], worldToIndex: [...worldToIndex], + indexToWorld: [...indexToWorld], }; }, @@ -236,6 +239,7 @@ export default (dependencies) => ({ spacing: image.getSpacing(), direction: image.getDirection(), worldToIndex: [...image.getWorldToIndex()], + indexToWorld: [...image.getIndexToWorld()], }); } else { // set dimensions to be the max bounds of all layers From 5d6463c937f07daa862e4dfc21ed8da468e3c852 Mon Sep 17 00:00:00 2001 From: Forrest Li Date: Tue, 2 Mar 2021 00:27:39 -0500 Subject: [PATCH 3/8] refactor: Rename giveViewAnnotations applyViewAnnotations makes much more sense. --- src/components/VtkThreeView.vue | 4 ++-- src/components/VtkTwoView.vue | 4 ++-- src/composables/view/common.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/VtkThreeView.vue b/src/components/VtkThreeView.vue index a9775d1af..7fa55d225 100644 --- a/src/components/VtkThreeView.vue +++ b/src/components/VtkThreeView.vue @@ -24,7 +24,7 @@ import { CommonViewProps, useVtkView, useVtkViewCameraOrientation, - giveViewAnnotations, + applyViewAnnotations, } from '@/src/composables/view/common'; import { useResizeObserver } from '@/src/composables/resizeObserver'; import { watchScene, watchColorBy } from '@/src/composables/scene'; @@ -128,7 +128,7 @@ export default { } }); - giveViewAnnotations( + applyViewAnnotations( viewRef, reactive({ nw: baseImageColorPreset, diff --git a/src/components/VtkTwoView.vue b/src/components/VtkTwoView.vue index 5bff91e4c..55ff333f2 100644 --- a/src/components/VtkTwoView.vue +++ b/src/components/VtkTwoView.vue @@ -34,7 +34,7 @@ import { CommonViewProps, useVtkView, useVtkViewCameraOrientation, - giveViewAnnotations, + applyViewAnnotations, } from '@/src/composables/view/common'; import { useOrientationLabels, @@ -282,7 +282,7 @@ export default { : '' ); - giveViewAnnotations( + applyViewAnnotations( viewRef, reactive({ n: upLabel, diff --git a/src/composables/view/common.js b/src/composables/view/common.js index 2fe8c2324..ded879896 100644 --- a/src/composables/view/common.js +++ b/src/composables/view/common.js @@ -132,7 +132,7 @@ export function useVtkViewCameraOrientation( * @param {Reactive<{ [label: string]: Ref }} labels * @param {Reactive<{ [label: string]: string }} defaults */ -export function giveViewAnnotations(viewRef, labels, defaults = {}) { +export function applyViewAnnotations(viewRef, labels, defaults = {}) { const places = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']; watchEffect(() => { From c073bea963dccbfb9c908d9ffbc7b7a1b0520a6c Mon Sep 17 00:00:00 2001 From: Forrest Li Date: Tue, 2 Mar 2021 00:30:07 -0500 Subject: [PATCH 4/8] feat: View slices in world space Instead of transforming objects into image space, just transform the camera to look at slices correctly. The views no longer look along fixed world axes. Instead, they are labeled as Axial, Sagittal, or Coronal, and the direction in which they look is solely determined by the dataset orientation. In fact, the views look along each dataset's IJK axis, effectively operating as an orthogonal MPR view. This should provide a more consistent view when looking at different datasets. --- src/components/VtkThreeView.vue | 2 +- src/components/VtkTwoView.vue | 49 ++-- src/composables/scene.js | 7 +- src/composables/view/view2D.js | 212 +++++++++++++----- src/io/dicom.js | 7 +- src/store/visualization.js | 13 +- src/utils/common.js | 25 ++- .../index.js | 12 +- src/vtk/View2DProxy/index.js | 12 + src/vtk/proxyUtils.js | 3 +- 10 files changed, 241 insertions(+), 101 deletions(-) diff --git a/src/components/VtkThreeView.vue b/src/components/VtkThreeView.vue index 7fa55d225..8f2284250 100644 --- a/src/components/VtkThreeView.vue +++ b/src/components/VtkThreeView.vue @@ -94,7 +94,7 @@ export default { }); // update scene sources and their colors - watchScene(sceneSources, imageConfig, viewRef); + watchScene(sceneSources, viewRef); watchColorBy(colorBy, sceneSources, viewRef); // prepare view diff --git a/src/components/VtkTwoView.vue b/src/components/VtkTwoView.vue index 55ff333f2..102c60e6c 100644 --- a/src/components/VtkTwoView.vue +++ b/src/components/VtkTwoView.vue @@ -33,13 +33,14 @@ import { import { CommonViewProps, useVtkView, - useVtkViewCameraOrientation, applyViewAnnotations, } from '@/src/composables/view/common'; import { useOrientationLabels, use2DMouseControls, usePixelProbe, + apply2DCameraPlacement, + useIJKAxisCamera, } from '@/src/composables/view/view2D'; import { useResizeObserver } from '@/src/composables/resizeObserver'; import { watchScene, watchColorBy } from '@/src/composables/scene'; @@ -57,15 +58,19 @@ import SliceSlider from '@/src/components/SliceSlider.vue'; * This differs from view.resetCamera() in that we reset the view * to the specified bounds. */ -function resetCamera(viewRef, extentWithSpacing, resizeToFit) { +function resetCamera(viewRef, axis, imageConfig, resizeToFit) { const view = unref(viewRef); if (view) { const renderer = view.getRenderer(); renderer.computeVisiblePropBounds(); - renderer.resetCamera(unref(extentWithSpacing)); + renderer.resetCamera(imageConfig.value.bounds); if (unref(resizeToFit)) { - resize2DCameraToFit(view, unref(extentWithSpacing)); + const { extent, spacing } = imageConfig.value; + const extentWithSpacing = extent.map( + (e, i) => e * spacing[Math.floor(i / 2)] + ); + resize2DCameraToFit(view, unref(axis), unref(extentWithSpacing)); } } } @@ -80,15 +85,19 @@ export default { }, setup(props) { - const { viewName, viewType, viewUp, axis, orientation } = toRefs(props); + const { viewName, viewType } = toRefs(props); const vtkContainer = ref(null); const resizeToFit = ref(true); + + const { axis, orientation, viewUp } = useIJKAxisCamera(viewType); const axisLabel = computed(() => 'xyz'[axis.value]); const store = useStore(); const widgetProvider = useWidgetProvider(); const pxm = useProxyManager(); + // currentSlice: VtkTwoView concerns itself only with IJK coords, so + // currentSlice is expected to be in image coords. const { sceneSources, imageConfig, @@ -137,10 +146,6 @@ export default { }, }); - const currentSliceSpacing = computed( - () => imageConfig.value.spacing[axis.value] - ); - const viewRef = useVtkView({ containerRef: vtkContainer, viewName, @@ -148,7 +153,14 @@ export default { }); // configure camera orientation - useVtkViewCameraOrientation(viewRef, viewUp, axis, orientation); + apply2DCameraPlacement( + viewRef, + imageConfig, + viewUp, + orientation, + axis, + 'image' + ); useResizeObserver(vtkContainer, () => { const view = unref(viewRef); @@ -157,17 +169,17 @@ export default { } }); - watchScene(sceneSources, imageConfig, viewRef); + watchScene(sceneSources, viewRef); watchColorBy(colorBy, sceneSources, viewRef); // reset camera conditions watch( [baseImage, extentWithSpacing], - () => resetCamera(viewRef, extentWithSpacing, resizeToFit), + () => resetCamera(viewRef, axis, imageConfig, resizeToFit), { immediate: true } ); useSubscription(viewRef, (view) => - view.onResize(() => resetCamera(viewRef, extentWithSpacing, resizeToFit)) + view.onResize(() => resetCamera(viewRef, axis, imageConfig, resizeToFit)) ); // setup view @@ -193,10 +205,10 @@ export default { default: windowing.value.level, })); const sliceRange = computed(() => { - const { bounds } = unref(imageConfig); + const { extent } = unref(imageConfig); return { - min: bounds[axis.value * 2], - max: bounds[axis.value * 2 + 1], + min: extent[axis.value * 2], + max: extent[axis.value * 2 + 1], step: 1, default: currentSlice.value, }; @@ -231,8 +243,7 @@ export default { if (viewRef.value && baseImage.value) { const rep = pxm.getRepresentation(baseImage.value, viewRef.value); if (rep) { - if (rep.setSlice) - rep.setSlice(currentSlice.value * currentSliceSpacing.value); + if (rep.setSlice) rep.setSlice(currentSlice.value); if (rep.setWindowWidth) rep.setWindowWidth(windowing.value.width); if (rep.setWindowLevel) rep.setWindowLevel(windowing.value.level); } @@ -243,7 +254,7 @@ export default { const { pixelProbe } = usePixelProbe(viewRef, baseImage); // orientation labels - const { leftLabel, upLabel } = useOrientationLabels(viewRef, imageConfig); + const { left: leftLabel, top: upLabel } = useOrientationLabels(viewRef); // pixel probe annotation const pixelAnnotation = computed(() => { diff --git a/src/composables/scene.js b/src/composables/scene.js index 7e9fac804..b54cf04ba 100644 --- a/src/composables/scene.js +++ b/src/composables/scene.js @@ -5,16 +5,14 @@ import { useProxyManager } from '@/src/composables/proxyManager'; /** * Updates the scene. * @param {Ref} sourcesRef - * @param {Ref} imageConfigRef * @param {Ref} viewRef */ -export function watchScene(sourcesRef, imageConfigRef, viewRef) { +export function watchScene(sourcesRef, viewRef) { const pxm = useProxyManager(); function repopulateScene() { const view = unref(viewRef); const sources = unref(sourcesRef); - const imageConfig = unref(imageConfigRef); if (view) { view .getRepresentations() @@ -23,9 +21,6 @@ export function watchScene(sourcesRef, imageConfigRef, viewRef) { sources.forEach((source) => { const rep = pxm.getRepresentation(source, view); if (rep) { - if (rep.setTransform) { - rep.setTransform(imageConfig.worldToIndex); - } view.addRepresentation(rep); } }); diff --git a/src/composables/view/view2D.js b/src/composables/view/view2D.js index fbeeae953..ed0dec2f4 100644 --- a/src/composables/view/view2D.js +++ b/src/composables/view/view2D.js @@ -1,4 +1,4 @@ -import { mat3, vec3 } from 'gl-matrix'; +import { vec3 } from 'gl-matrix'; import { computed, ref, unref, watch, watchEffect } from '@vue/composition-api'; import vtkPlane from 'vtk.js/Sources/Common/DataModel/Plane'; @@ -10,82 +10,81 @@ import { useSubscription } from '@/src/composables/vtk'; import { useProxyManager } from '@/src/composables/proxyManager'; import { useElementListener } from '@/src/composables/domEvents'; import { useViewContainer } from '@/src/composables/view/common'; -import { zip, worldToIndexRotation } from '@/src/utils/common'; +import { useComputedState } from '@/src/composables/store'; +import { indexToWorldRotation, multiComputed } from '@/src/utils/common'; const EPS = 10e-6; // priority of the pixel mouse probe export const PROBE_PRIORITY = WIDGET_PRIORITY + 10; -function lpsDirToLabels(dir) { - const [x, y, z] = dir; - let label = ''; - if (x > EPS) label += 'L'; - else if (x < -EPS) label += 'R'; - if (y > EPS) label += 'P'; - else if (y < -EPS) label += 'A'; - if (z > EPS) label += 'S'; - else if (z < -EPS) label += 'I'; - return label; +// a signed axis has domain [+-1, +-2, +-3]. +// Adding 3, we shift the domain to [0, 1, 2, 4, 5, 6], +// so the the 3rd index is not used. +const LABELS = 'IAR_LPS'; + +function signedAxesToLabels(axes) { + return axes.map((sa) => LABELS[sa + 3]).join(''); +} + +function sortAndOrientAxes(vec) { + return ( + vec + // track each component's signed axis, + // and shift axes to be 1-indexed so we can differentiate between 0 and -0 + .map((component, axis) => [component, Math.sign(component) * (axis + 1)]) + // remove components close to zero + .filter(([component]) => Math.abs(component) > EPS) + // sort components in decreasing order + .sort(([c1], [c2]) => c2 - c1) + // pick out the axes, now sorted by decreasing magnitude + .map(([, signedAxis]) => signedAxis) + ); } /** - * Writes out left and up orientation labels. + * Computes orientation labels for a given view. + * + * Labels are with respect to the current camera orientation. + * + * vtk.js world axis is implied to be LPS, so orientation labels + * are determined solely from the camera' direction and view-up. + * * @param {Ref} viewRef - * @param {Ref} imageConfig */ -export function useOrientationLabels(viewRef, imageConfig) { - const leftLabel = ref(''); - const upLabel = ref(''); +export function useOrientationLabels(viewRef) { + const rightAxes = ref([]); + const upAxes = ref([]); + + const top = computed(() => signedAxesToLabels(upAxes.value)); + const right = computed(() => signedAxesToLabels(rightAxes.value)); + const bottom = computed(() => + signedAxesToLabels(upAxes.value.map((a) => -a)) + ); + const left = computed(() => + signedAxesToLabels(rightAxes.value.map((a) => -a)) + ); - function updateLabels() { + function updateAxes() { const view = unref(viewRef); if (view) { const camera = view.getCamera(); - // TODO make modifications only if vup and vdir differ const vup = camera.getViewUp(); const vdir = camera.getDirectionOfProjection(); const vright = [0, 0, 0]; + // assumption: vright and vdir are not equal + // (which should be the case for the camera) vec3.cross(vright, vdir, vup); - // assume direction is orthonormal - const { direction } = unref(imageConfig); - - // since camera is in "image space", transform into - // image's world space. - const cameraMat = mat3.fromValues(...vright, ...vup, ...vdir); - const imageMat = mat3.fromValues(...direction); - // `direction` is row-major, and gl-matrix is col-major - mat3.transpose(imageMat, imageMat); - const cameraInImWorld = mat3.create(); - mat3.mul(cameraInImWorld, imageMat, cameraMat); - - // gl-matrix is col-major - const left = cameraInImWorld.slice(0, 3).map((c) => -c); - const up = cameraInImWorld.slice(3, 6); - - const leftLabels = lpsDirToLabels(left); - const upLabels = lpsDirToLabels(up); - - // sort by magnitude - leftLabel.value = zip(left.map(Math.abs), leftLabels) - .sort(([a], [b]) => b - a) - .map(([, label]) => label) - .join(''); - upLabel.value = zip(up.map(Math.abs), upLabels) - .sort(([a], [b]) => b - a) - .map(([, label]) => label) - .join(''); + rightAxes.value = sortAndOrientAxes(vright); + upAxes.value = sortAndOrientAxes(vup); } } - useSubscription(viewRef, (view) => view.getCamera().onModified(updateLabels)); - updateLabels(); + useSubscription(viewRef, (view) => view.getCamera().onModified(updateAxes)); + updateAxes(); - return { - leftLabel, - upLabel, - }; + return { top, right, bottom, left }; } export function use2DMouseControls( @@ -176,7 +175,7 @@ export function computePixelAt(plane, probeVec, imageData) { // this is a hack to work around the first slice sometimes being // very close to zero, but not quite, resulting in being unable to // see pixel values for 0th slice. - Math.abs(c) < 1e-8 ? Math.round(c) : c + Math.abs(c) < 1e-4 ? Math.round(c) : c ); const extent = imageData.getExtent(); if ( @@ -261,8 +260,8 @@ export function usePixelProbe(viewRef, baseImage) { // transform from index to world if required if (axis >= 3) { - origin = image.value.indexToWorld(normal); - normal = worldToIndexRotation(image.value, normal); + origin = image.value.indexToWorld(origin); + normal = indexToWorldRotation(image.value, normal); } if ( @@ -305,3 +304,104 @@ export function usePixelProbe(viewRef, baseImage) { pixelProbe, }; } + +export const ViewTypeAxis = { + ViewX: [1, 0, 0], + ViewY: [0, -1, 0], + ViewZ: [0, 0, -1], +}; + +export const ViewAxisUp = { + 0: [0, 0, 1], + 1: [0, 0, 1], + 2: [0, -1, 0], +}; + +export function useIJKAxisCamera(viewType) { + const { direction } = useComputedState({ + direction: (state) => state.visualization.imageConfig.direction, + }); + + return multiComputed(() => { + const viewDir = ViewTypeAxis[viewType.value]; + let closestIJK = 0; + let closestSign = 1; + let closest = -Infinity; + for (let idx = 0; idx < 3; idx += 1) { + // dir matrix is column major + const indexDir = vec3.fromValues( + direction.value[idx * 3 + 0], + direction.value[idx * 3 + 1], + direction.value[idx * 3 + 2] + ); + const cosine = vec3.dot(indexDir, viewDir); + const sign = Math.sign(cosine); + const howClose = Math.abs(cosine); + if (howClose > closest) { + closest = howClose; + closestIJK = idx; + closestSign = sign; + } + } + return { + axis: closestIJK, // 1=I, 2=J, 3=K + orientation: closestSign, + viewUp: ViewAxisUp[closestIJK], + }; + }); +} + +/** + * Sets the camera based on camera configuration parameters. + * @param {Ref} view + * @param {Ref} imageConfig + * @param {Ref} viewUp + * @param {Ref<-1|1>} orientation + * @param {Ref<0|1|2>} axis + * @param {'image'|'world'} frame + */ +export function apply2DCameraPlacement( + view, + imageConfig, + viewUp, + orientation, + axis, + frame +) { + function updateCamera() { + // get world bounds center + const { bounds } = imageConfig.value; + const center = [ + (bounds[0] + bounds[1]) / 2, + (bounds[2] + bounds[3]) / 2, + (bounds[4] + bounds[5]) / 2, + ]; + + const position = [...center]; + position[axis.value] += orientation.value; + + const dop = [0, 0, 0]; + dop[axis.value] = -orientation.value; + + const vup = [...viewUp.value]; + + if (unref(frame) === 'image') { + const { direction } = imageConfig.value; + vec3.transformMat3(dop, dop, direction); + vec3.transformMat3(vup, vup, direction); + } + + const camera = view.value.getCamera(); + camera.setFocalPoint(...center); + camera.setPosition(...position); + camera.setDirectionOfProjection(...dop); + camera.setViewUp(...vup); + + view.value.getRenderer().resetCamera(); + view.value.set({ axis: axis.value }, true); // set the corresponding axis + } + + watch([imageConfig, viewUp, orientation, axis], updateCamera, { + immediate: true, + }); +} diff --git a/src/io/dicom.js b/src/io/dicom.js index 13bfedc8b..303d7a949 100644 --- a/src/io/dicom.js +++ b/src/io/dicom.js @@ -1,3 +1,4 @@ +import { mat3 } from 'gl-matrix'; import runPipelineBrowser from 'itk/runPipelineBrowser'; import { readFileAsArrayBuffer } from '@/src/io/io'; import IOTypes from 'itk/IOTypes'; @@ -194,7 +195,11 @@ export default class DicomIO { 10 // building volumes is high priority ); - return result.outputs[0].data; + // TEMPORARY tranpose until itk.js consistently outputs col-major + // and ITKHelper is updated. + const image = result.outputs[0].data; + mat3.transpose(image.direction.data, image.direction.data); + return image; } /** diff --git a/src/store/visualization.js b/src/store/visualization.js index ea5861fdb..31e165cd2 100644 --- a/src/store/visualization.js +++ b/src/store/visualization.js @@ -7,6 +7,11 @@ import { DEFAULT_PRESET } from '../vtk/ColorMaps'; const { Mode } = LUTConstants; +export const CoordinateSystem = { + Image: 'image', + World: 'world', +}; + export function asInteger(value, defaultValue) { const rv = Math.round(value); if (Number.isInteger(rv)) { @@ -33,8 +38,10 @@ export const defaultWindowing = () => ({ max: 255, }); -// slicing is done in index space +// For image, XYZ corresponds to image IJK +// For world, XYZ corresponds to LPS in vtk.js export const defaultSlicing = () => ({ + system: CoordinateSystem.Image, x: 0, y: 0, z: 0, @@ -116,6 +123,7 @@ export default (dependencies) => ({ setSlices(state, { x, y, z }) { const { slices: s } = state; state.slices = { + system: s.system, x: asInteger(x, s.x), y: asInteger(y, s.y), z: asInteger(z, s.z), @@ -400,9 +408,6 @@ export default (dependencies) => ({ proxyManager.getViews().forEach((view) => { const rep = proxyManager.getRepresentation(source, view); if (rep) { - if (rep.setTransform) { - rep.setTransform(...state.imageConfig.worldToIndex); - } rep.getMapper().modified(); } }); diff --git a/src/utils/common.js b/src/utils/common.js index 2a493af00..79fb8b252 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -1,3 +1,4 @@ +import { computed } from '@vue/composition-api'; import { mat4, quat, vec3 } from 'gl-matrix'; export function defer() { @@ -69,16 +70,32 @@ export function unsubscribeVtkList(subs) { } /** - * Rotates vector from image world to index. + * Rotates vector from image index space to world dir. * @param {vtkImageData} imageData * @param {vec3} vec */ -export function worldToIndexRotation(imageData, vec) { - const w2iMat = imageData.getWorldToIndex(); +export function indexToWorldRotation(imageData, vec) { + const i2wMat = imageData.getIndexToWorld(); const rotation = quat.create(); - mat4.getRotation(rotation, w2iMat); + mat4.getRotation(rotation, i2wMat); const out = vec3.create(); vec3.transformQuat(out, vec, rotation); return out; } + +/** + * Unpacks an object returned by vue's computed, and makes each + * individual key a separate ref. + */ +export function multiComputed(fn) { + const obj = computed(fn); + const a = Object.keys(obj.value).reduce( + (acc, key) => ({ + ...acc, + [key]: computed(() => obj.value[key]), + }), + {} + ); + return a; +} diff --git a/src/vtk/TransformedSliceRepresentationProxy/index.js b/src/vtk/TransformedSliceRepresentationProxy/index.js index 54d51e0cb..56c9cfdf5 100644 --- a/src/vtk/TransformedSliceRepresentationProxy/index.js +++ b/src/vtk/TransformedSliceRepresentationProxy/index.js @@ -2,9 +2,6 @@ import macro from 'vtk.js/Sources/macro'; import vtkSliceRepresentationProxy from 'vtk.js/Sources/Proxy/Representations/SliceRepresentationProxy'; -import vtkImageTransformFilter from '../ImageTransformFilter'; -import vtkRepresentationProxyTransformMixin from '../transformMixin'; - function vtkTransformedSliceRepresentationProxy(publicAPI, model) { model.classHierarchy.push('vtkTransformedSliceRepresentationProxy'); @@ -20,6 +17,10 @@ function vtkTransformedSliceRepresentationProxy(publicAPI, model) { publicAPI.setSlicingMode(mode); }; + // restrict to IJK slicing + publicAPI.setSlicingMode = (mode) => + superClass.setSlicingMode('IJK'['XYZIJK'.indexOf(mode) % 3]); + // don't set colors on slices publicAPI.setColorBy = () => {}; } @@ -38,11 +39,6 @@ export function extend(publicAPI, model, initialValues = {}) { // Object methods vtkSliceRepresentationProxy.extend(publicAPI, model); - vtkRepresentationProxyTransformMixin(vtkImageTransformFilter)( - publicAPI, - model - ); - // Object specific methods vtkTransformedSliceRepresentationProxy(publicAPI, model); } diff --git a/src/vtk/View2DProxy/index.js b/src/vtk/View2DProxy/index.js index 51560d21a..d5ee88802 100644 --- a/src/vtk/View2DProxy/index.js +++ b/src/vtk/View2DProxy/index.js @@ -9,6 +9,18 @@ function vtkView2DProxy(publicAPI, model) { // we will set the manipulator ourselves publicAPI.bindRepresentationToManipulator = () => {}; + + // allow setting the axis + publicAPI.setAxis = (axis) => { + if (axis !== model.axis) { + model.axis = axis; + model.representations + .filter((rep) => !!rep.setSlicingMode) + .forEach((rep) => rep.setSlicingMode('XYZ'[axis])); + return true; + } + return false; + }; } export function extend(publicAPI, model, initialValues = {}) { diff --git a/src/vtk/proxyUtils.js b/src/vtk/proxyUtils.js index 4f40c033e..63ca36b15 100644 --- a/src/vtk/proxyUtils.js +++ b/src/vtk/proxyUtils.js @@ -23,9 +23,8 @@ export function createFourUpViews(proxyManager) { * Bounds is specified as width/height of orthographic view. * Renders must be triggered manually. */ -export function resize2DCameraToFit(view, bounds) { +export function resize2DCameraToFit(view, axis, bounds) { const camera = view.getCamera(); - const axis = view.getAxis(); const lengths = [ bounds[1] - bounds[0], bounds[3] - bounds[2], From dcc2b6d39ad112e42c3e1f3af0d15b9af93db25c Mon Sep 17 00:00:00 2001 From: Forrest Li Date: Tue, 2 Mar 2021 11:01:26 -0500 Subject: [PATCH 5/8] feat(view2D): Compute correct viewUp --- src/composables/view/view2D.js | 79 +++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/src/composables/view/view2D.js b/src/composables/view/view2D.js index ed0dec2f4..63d7e2ead 100644 --- a/src/composables/view/view2D.js +++ b/src/composables/view/view2D.js @@ -317,6 +317,37 @@ export const ViewAxisUp = { 2: [0, -1, 0], }; +// mat3x3 is taken to be column-major +function findClosestFrameVec(mat3x3, axis) { + let closestIndex = 0; + let closestSign = 1; + let closest = -Infinity; + let vector = []; + for (let idx = 0; idx < 3; idx += 1) { + const indexDir = vec3.fromValues( + mat3x3[idx * 3 + 0], + mat3x3[idx * 3 + 1], + mat3x3[idx * 3 + 2] + ); + const cosine = vec3.dot(indexDir, axis); + const sign = Math.sign(cosine); + const howClose = Math.abs(cosine); + if (howClose > closest) { + closest = howClose; + closestIndex = idx; + closestSign = sign; + vector = indexDir; + } + } + + return { + howClose: closest, + vectorIndex: closestIndex, + sign: closestSign, + vector, + }; +} + export function useIJKAxisCamera(viewType) { const { direction } = useComputedState({ direction: (state) => state.visualization.imageConfig.direction, @@ -324,29 +355,37 @@ export function useIJKAxisCamera(viewType) { return multiComputed(() => { const viewDir = ViewTypeAxis[viewType.value]; - let closestIJK = 0; - let closestSign = 1; - let closest = -Infinity; - for (let idx = 0; idx < 3; idx += 1) { - // dir matrix is column major - const indexDir = vec3.fromValues( - direction.value[idx * 3 + 0], - direction.value[idx * 3 + 1], - direction.value[idx * 3 + 2] - ); - const cosine = vec3.dot(indexDir, viewDir); - const sign = Math.sign(cosine); - const howClose = Math.abs(cosine); - if (howClose > closest) { - closest = howClose; - closestIJK = idx; - closestSign = sign; + const { vectorIndex: axis, sign: orientation } = findClosestFrameVec( + direction.value, + viewDir + ); + + let lpsViewUp = []; + switch (viewType.value) { + case 'ViewX': + case 'ViewY': { + lpsViewUp = [0, 0, 1]; // superior + break; + } + case 'ViewZ': { + lpsViewUp = [0, -1, 0]; // anterior + break; } + default: + // noop; } + + const { vectorIndex: vupIndex, sign: vupSign } = findClosestFrameVec( + direction.value, + lpsViewUp + ); + const viewUp = [0, 0, 0]; + viewUp[vupIndex] = vupSign; + return { - axis: closestIJK, // 1=I, 2=J, 3=K - orientation: closestSign, - viewUp: ViewAxisUp[closestIJK], + axis, // 1=I, 2=J, 3=K + orientation, + viewUp, }; }); } From ec2aa94c2fbf12ade3b323c5dd668335605fc694 Mon Sep 17 00:00:00 2001 From: Forrest Li Date: Tue, 2 Mar 2021 11:19:54 -0500 Subject: [PATCH 6/8] fix(VtkTwoView): resizeToFit needs viewUpAxis Picking the width and height of the resize-to-fit rectangle depends on both the looking axis and the view up axis. --- src/components/VtkTwoView.vue | 72 +++++++++++++++++++++++++++++++--- src/composables/view/view2D.js | 1 + src/vtk/proxyUtils.js | 38 ------------------ 3 files changed, 67 insertions(+), 44 deletions(-) diff --git a/src/components/VtkTwoView.vue b/src/components/VtkTwoView.vue index 102c60e6c..a5e65caaa 100644 --- a/src/components/VtkTwoView.vue +++ b/src/components/VtkTwoView.vue @@ -49,16 +49,67 @@ import { useSubscription } from '@/src/composables/vtk'; import { useWidgetProvider } from '@/src/composables/widgetProvider'; import { useProxyManager } from '@/src/composables/proxyManager'; -import { resize2DCameraToFit } from '@/src/vtk/proxyUtils'; import { DataTypes } from '@/src/constants'; import SliceSlider from '@/src/components/SliceSlider.vue'; +/** + * Sets parallel scale of 2D view camera to fit a given bounds. + * + * Assumes the camera is reset, i.e. focused correctly. + * + * Bounds is specified as width/height of orthographic view. + * Renders must be triggered manually. + */ +function resize2DCameraToFit(view, lookAxis, viewUpAxis, bounds) { + const camera = view.getCamera(); + const lengths = [ + bounds[1] - bounds[0], + bounds[3] - bounds[2], + bounds[5] - bounds[4], + ]; + const [w, h] = view.getOpenglRenderWindow().getSize(); + let bw; + let bh; + /* eslint-disable prefer-destructuring */ + if (lookAxis === 0 && viewUpAxis === 1) { + bw = lengths[2]; + bh = lengths[1]; + } else if (lookAxis === 0 && viewUpAxis === 2) { + bw = lengths[1]; + bh = lengths[2]; + } else if (lookAxis === 1 && viewUpAxis === 0) { + bw = lengths[2]; + bh = lengths[0]; + } else if (lookAxis === 1 && viewUpAxis === 2) { + bw = lengths[0]; + bh = lengths[2]; + } else if (lookAxis === 2 && viewUpAxis === 0) { + bw = lengths[1]; + bh = lengths[0]; + } else if (lookAxis === 2 && viewUpAxis === 1) { + bw = lengths[0]; + bh = lengths[1]; + } + /* eslint-enable prefer-destructuring */ + const viewAspect = w / h; + const boundsAspect = bw / bh; + + let scale = 0; + if (viewAspect >= boundsAspect) { + scale = bh / 2; + } else { + scale = bw / 2 / viewAspect; + } + + camera.setParallelScale(scale); +} + /** * This differs from view.resetCamera() in that we reset the view * to the specified bounds. */ -function resetCamera(viewRef, axis, imageConfig, resizeToFit) { +function resetCamera(viewRef, lookAxis, viewUpAxis, imageConfig, resizeToFit) { const view = unref(viewRef); if (view) { const renderer = view.getRenderer(); @@ -70,7 +121,12 @@ function resetCamera(viewRef, axis, imageConfig, resizeToFit) { const extentWithSpacing = extent.map( (e, i) => e * spacing[Math.floor(i / 2)] ); - resize2DCameraToFit(view, unref(axis), unref(extentWithSpacing)); + resize2DCameraToFit( + view, + unref(lookAxis), + unref(viewUpAxis), + unref(extentWithSpacing) + ); } } } @@ -89,7 +145,9 @@ export default { const vtkContainer = ref(null); const resizeToFit = ref(true); - const { axis, orientation, viewUp } = useIJKAxisCamera(viewType); + const { axis, orientation, viewUp, viewUpAxis } = useIJKAxisCamera( + viewType + ); const axisLabel = computed(() => 'xyz'[axis.value]); const store = useStore(); @@ -175,11 +233,13 @@ export default { // reset camera conditions watch( [baseImage, extentWithSpacing], - () => resetCamera(viewRef, axis, imageConfig, resizeToFit), + () => resetCamera(viewRef, axis, viewUpAxis, imageConfig, resizeToFit), { immediate: true } ); useSubscription(viewRef, (view) => - view.onResize(() => resetCamera(viewRef, axis, imageConfig, resizeToFit)) + view.onResize(() => + resetCamera(viewRef, axis, viewUpAxis, imageConfig, resizeToFit) + ) ); // setup view diff --git a/src/composables/view/view2D.js b/src/composables/view/view2D.js index 63d7e2ead..411f1f30a 100644 --- a/src/composables/view/view2D.js +++ b/src/composables/view/view2D.js @@ -386,6 +386,7 @@ export function useIJKAxisCamera(viewType) { axis, // 1=I, 2=J, 3=K orientation, viewUp, + viewUpAxis: vupIndex, }; }); } diff --git a/src/vtk/proxyUtils.js b/src/vtk/proxyUtils.js index 63ca36b15..f85213115 100644 --- a/src/vtk/proxyUtils.js +++ b/src/vtk/proxyUtils.js @@ -14,41 +14,3 @@ export function createFourUpViews(proxyManager) { createOrGetView(proxyManager, 'ViewZ', 'Z:1'); createOrGetView(proxyManager, 'View3D', '3D:1'); } - -/** - * Sets parallel scale of 2D view camera to fit a given bounds. - * - * Assumes the camera is reset, i.e. focused correctly. - * - * Bounds is specified as width/height of orthographic view. - * Renders must be triggered manually. - */ -export function resize2DCameraToFit(view, axis, bounds) { - const camera = view.getCamera(); - const lengths = [ - bounds[1] - bounds[0], - bounds[3] - bounds[2], - bounds[5] - bounds[4], - ]; - const [w, h] = view.getOpenglRenderWindow().getSize(); - let bw; - let bh; - if (axis === 0 || axis === 2) { - bw = lengths[(axis + 1) % 3]; - bh = lengths[(axis + 2) % 3]; - } else { - bw = lengths[(axis + 2) % 3]; - bh = lengths[(axis + 1) % 3]; - } - const viewAspect = w / h; - const boundsAspect = bw / bh; - - let scale = 0; - if (viewAspect >= boundsAspect) { - scale = bh / 2; - } else { - scale = bw / 2 / viewAspect; - } - - camera.setParallelScale(scale); -} From 7aa9365af4076bdc2180b47297b202cf58c95f87 Mon Sep 17 00:00:00 2001 From: Forrest Li Date: Tue, 2 Mar 2021 11:30:15 -0500 Subject: [PATCH 7/8] refactor(view2D): Remove ViewAxisUp --- src/composables/view/view2D.js | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/composables/view/view2D.js b/src/composables/view/view2D.js index 411f1f30a..bed4eb46b 100644 --- a/src/composables/view/view2D.js +++ b/src/composables/view/view2D.js @@ -305,18 +305,6 @@ export function usePixelProbe(viewRef, baseImage) { }; } -export const ViewTypeAxis = { - ViewX: [1, 0, 0], - ViewY: [0, -1, 0], - ViewZ: [0, 0, -1], -}; - -export const ViewAxisUp = { - 0: [0, 0, 1], - 1: [0, 0, 1], - 2: [0, -1, 0], -}; - // mat3x3 is taken to be column-major function findClosestFrameVec(mat3x3, axis) { let closestIndex = 0; @@ -348,6 +336,12 @@ function findClosestFrameVec(mat3x3, axis) { }; } +const ViewTypeAxis = { + ViewX: [1, 0, 0], + ViewY: [0, -1, 0], + ViewZ: [0, 0, -1], +}; + export function useIJKAxisCamera(viewType) { const { direction } = useComputedState({ direction: (state) => state.visualization.imageConfig.direction, From d09d2d242fe7a7a92ec96e331a9ece5ffa7513c5 Mon Sep 17 00:00:00 2001 From: Forrest Li Date: Mon, 15 Mar 2021 11:54:03 -0400 Subject: [PATCH 8/8] refactor: rename imageConfig -> imageParams --- src/components/VtkThreeView.vue | 6 +++--- src/components/VtkTwoView.vue | 18 +++++++++--------- src/composables/view/view2D.js | 12 ++++++------ src/store/visualization.js | 28 ++++++++++++++-------------- src/widgets/paint.js | 10 +++++----- src/widgets/ruler.js | 4 ++-- src/widgets/slicingCrosshairs.js | 8 ++++---- 7 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/components/VtkThreeView.vue b/src/components/VtkThreeView.vue index 8f2284250..a261c6b6e 100644 --- a/src/components/VtkThreeView.vue +++ b/src/components/VtkThreeView.vue @@ -42,7 +42,7 @@ export default { const { sceneSources, - imageConfig, + imageParams, colorBy, extentWithSpacing, baseImageColorPreset, @@ -56,7 +56,7 @@ export default { .filter((id) => id in pipelines) .map((id) => pipelines[id].last); }, - imageConfig: (state) => state.visualization.imageConfig, + imageParams: (state) => state.visualization.imageParams, colorBy: (state, getters) => getters.sceneObjectIDs.map((id) => state.visualization.colorBy[id]), extentWithSpacing: (_, getters) => @@ -75,7 +75,7 @@ export default { windowing: (state) => state.visualization.windowing, }); - const spacing = computed(() => imageConfig.value.spacing); + const spacing = computed(() => imageParams.value.spacing); const viewRef = useVtkView({ containerRef: vtkContainer, diff --git a/src/components/VtkTwoView.vue b/src/components/VtkTwoView.vue index a5e65caaa..0648fc7c2 100644 --- a/src/components/VtkTwoView.vue +++ b/src/components/VtkTwoView.vue @@ -109,15 +109,15 @@ function resize2DCameraToFit(view, lookAxis, viewUpAxis, bounds) { * This differs from view.resetCamera() in that we reset the view * to the specified bounds. */ -function resetCamera(viewRef, lookAxis, viewUpAxis, imageConfig, resizeToFit) { +function resetCamera(viewRef, lookAxis, viewUpAxis, imageParams, resizeToFit) { const view = unref(viewRef); if (view) { const renderer = view.getRenderer(); renderer.computeVisiblePropBounds(); - renderer.resetCamera(imageConfig.value.bounds); + renderer.resetCamera(imageParams.value.bounds); if (unref(resizeToFit)) { - const { extent, spacing } = imageConfig.value; + const { extent, spacing } = imageParams.value; const extentWithSpacing = extent.map( (e, i) => e * spacing[Math.floor(i / 2)] ); @@ -158,7 +158,7 @@ export default { // currentSlice is expected to be in image coords. const { sceneSources, - imageConfig, + imageParams, colorBy, extentWithSpacing, baseImage, @@ -172,7 +172,7 @@ export default { .filter((id) => id in pipelines) .map((id) => pipelines[id].last); }, - imageConfig: (state) => state.visualization.imageConfig, + imageParams: (state) => state.visualization.imageParams, colorBy: (state, getters) => getters.sceneObjectIDs.map((id) => state.visualization.colorBy[id]), extentWithSpacing: (_, getters) => @@ -213,7 +213,7 @@ export default { // configure camera orientation apply2DCameraPlacement( viewRef, - imageConfig, + imageParams, viewUp, orientation, axis, @@ -233,12 +233,12 @@ export default { // reset camera conditions watch( [baseImage, extentWithSpacing], - () => resetCamera(viewRef, axis, viewUpAxis, imageConfig, resizeToFit), + () => resetCamera(viewRef, axis, viewUpAxis, imageParams, resizeToFit), { immediate: true } ); useSubscription(viewRef, (view) => view.onResize(() => - resetCamera(viewRef, axis, viewUpAxis, imageConfig, resizeToFit) + resetCamera(viewRef, axis, viewUpAxis, imageParams, resizeToFit) ) ); @@ -265,7 +265,7 @@ export default { default: windowing.value.level, })); const sliceRange = computed(() => { - const { extent } = unref(imageConfig); + const { extent } = unref(imageParams); return { min: extent[axis.value * 2], max: extent[axis.value * 2 + 1], diff --git a/src/composables/view/view2D.js b/src/composables/view/view2D.js index bed4eb46b..9b60b948f 100644 --- a/src/composables/view/view2D.js +++ b/src/composables/view/view2D.js @@ -344,7 +344,7 @@ const ViewTypeAxis = { export function useIJKAxisCamera(viewType) { const { direction } = useComputedState({ - direction: (state) => state.visualization.imageConfig.direction, + direction: (state) => state.visualization.imageParams.direction, }); return multiComputed(() => { @@ -388,7 +388,7 @@ export function useIJKAxisCamera(viewType) { /** * Sets the camera based on camera configuration parameters. * @param {Ref} view - * @param {Ref} imageConfig + * @param {Ref} imageParams * @param {Ref} viewUp * @param {Ref<-1|1>} orientation * @param {Ref<0|1|2>} axis @@ -396,7 +396,7 @@ export function useIJKAxisCamera(viewType) { */ export function apply2DCameraPlacement( view, - imageConfig, + imageParams, viewUp, orientation, axis, @@ -404,7 +404,7 @@ export function apply2DCameraPlacement( ) { function updateCamera() { // get world bounds center - const { bounds } = imageConfig.value; + const { bounds } = imageParams.value; const center = [ (bounds[0] + bounds[1]) / 2, (bounds[2] + bounds[3]) / 2, @@ -420,7 +420,7 @@ export function apply2DCameraPlacement( const vup = [...viewUp.value]; if (unref(frame) === 'image') { - const { direction } = imageConfig.value; + const { direction } = imageParams.value; vec3.transformMat3(dop, dop, direction); vec3.transformMat3(vup, vup, direction); } @@ -435,7 +435,7 @@ export function apply2DCameraPlacement( view.value.set({ axis: axis.value }, true); // set the corresponding axis } - watch([imageConfig, viewUp, orientation, axis], updateCamera, { + watch([imageParams, viewUp, orientation, axis], updateCamera, { immediate: true, }); } diff --git a/src/store/visualization.js b/src/store/visualization.js index 31e165cd2..3ebea1271 100644 --- a/src/store/visualization.js +++ b/src/store/visualization.js @@ -20,7 +20,7 @@ export function asInteger(value, defaultValue) { return defaultValue; } -export const defaultImageConfig = () => ({ +export const defaultImageParams = () => ({ bounds: [0, 1, 0, 1, 0, 1], extent: [0, 1, 0, 1, 0, 1], dimensions: [1, 1, 1], @@ -86,7 +86,7 @@ export default (dependencies) => ({ pipelines: {}, slices: defaultSlicing(), resizeToFit: true, - imageConfig: defaultImageConfig(), + imageParams: defaultImageParams(), windowing: defaultWindowing(), colorBy: {}, // id -> { array, location } arrayLutPresets: {}, // arrayName -> LUT preset @@ -101,11 +101,11 @@ export default (dependencies) => ({ }; }, - setImageConfig( + setImageParams( state, { bounds, extent, spacing, direction, worldToIndex, indexToWorld } ) { - state.imageConfig = { + state.imageParams = { bounds: [...bounds], extent: [...extent], dimensions: [ @@ -183,7 +183,7 @@ export default (dependencies) => ({ getters: { extentWithSpacing(state) { - const { spacing, extent } = state.imageConfig; + const { spacing, extent } = state.imageParams; return extent.map((b, i) => b * spacing[Math.floor(i / 2)]); }, baseImagePipeline(state, getters, rootState) { @@ -203,7 +203,7 @@ export default (dependencies) => ({ actions: { async updateScene({ dispatch }, { reset = false }) { if (reset) { - await dispatch('updateImageConfig'); + await dispatch('updateImageParams'); await dispatch('resetWindowing'); await dispatch('resetSlicing'); await dispatch('updateColorBy'); @@ -219,7 +219,7 @@ export default (dependencies) => ({ }, /** - * Should run after updateImageConfig + * Should run after updateImageParams */ createPipelinesForScene({ commit, state, rootGetters, rootState }) { const { proxyManager } = dependencies; @@ -237,11 +237,11 @@ export default (dependencies) => ({ } }, - async updateImageConfig({ commit, rootState }) { + async updateImageParams({ commit, rootState }) { const { selectedBaseImage, data } = rootState; if (selectedBaseImage !== NO_SELECTION) { const image = data.vtkCache[selectedBaseImage]; - commit('setImageConfig', { + commit('setImageParams', { bounds: image.getBounds(), extent: image.getExtent(), spacing: image.getSpacing(), @@ -259,8 +259,8 @@ export default (dependencies) => ({ } bbox.inflate(5); // some extra padding // Without a base image, we assume a spacing of 1. - commit('setImageConfig', { - ...defaultImageConfig(), + commit('setImageParams', { + ...defaultImageParams(), bounds: bbox.getBounds(), extent: bbox.getBounds(), }); @@ -285,11 +285,11 @@ export default (dependencies) => ({ }, /** - * updateImageConfig should be invoked prior to this action. + * updateImageParams should be invoked prior to this action. */ async resetSlicing({ commit, state, rootState }) { if (rootState.selectedBaseImage !== NO_SELECTION) { - const { extent } = state.imageConfig; + const { extent } = state.imageParams; await commit('setSlices', { x: extent[0], y: extent[2], @@ -297,7 +297,7 @@ export default (dependencies) => ({ }); } else { // pick middle of extent - const { extent } = state.imageConfig; + const { extent } = state.imageParams; const center = [ (extent[0] + extent[1]) / 2, (extent[2] + extent[3]) / 2, diff --git a/src/widgets/paint.js b/src/widgets/paint.js index 6a6808c13..40f882edd 100644 --- a/src/widgets/paint.js +++ b/src/widgets/paint.js @@ -45,8 +45,8 @@ export default class PaintWidget extends Widget { updateManipulator(view) { if (view) { const axis = view.getAxis(); - const { slices, imageConfig } = this.store.state.visualization; - const { spacing } = imageConfig; + const { slices, imageParams } = this.store.state.visualization; + const { spacing } = imageParams; const normal = [0, 0, 0]; normal[axis] = 1; const origin = [0, 0, 0]; @@ -85,13 +85,13 @@ export default class PaintWidget extends Widget { if (id !== NO_SELECTION) { const { vtkCache } = this.store.state.data; - const { imageConfig } = this.store.state.visualization; + const { imageParams } = this.store.state.visualization; const { radius, currentLabelFor } = this.store.state.annotations; this.filter = vtkPaintFilter.newInstance(); this.filter.setBackgroundImage(vtkCache[selectedBaseImage]); this.filter.setLabelMap(vtkCache[id]); - this.filter.setMaskWorldToIndex(imageConfig.worldToIndex); + this.filter.setMaskWorldToIndex(imageParams.worldToIndex); this.filter.setLabel(currentLabelFor[id]); this.onRadiusChange(radius); } else { @@ -116,7 +116,7 @@ export default class PaintWidget extends Widget { const { spacing, worldToIndex, - } = this.store.state.visualization.imageConfig; + } = this.store.state.visualization.imageParams; const inv = mat4.create(); const pt = vec3.create(); mat4.invert(inv, worldToIndex); diff --git a/src/widgets/ruler.js b/src/widgets/ruler.js index f30cafbbb..9664b5ab2 100644 --- a/src/widgets/ruler.js +++ b/src/widgets/ruler.js @@ -77,8 +77,8 @@ export default class RulerWidget extends Widget { updateManipulator(view) { if (view && this.lockedSlice === null) { const axis = view.getAxis(); - const { slices, imageConfig } = this.store.state.visualization; - const { spacing } = imageConfig; + const { slices, imageParams } = this.store.state.visualization; + const { spacing } = imageParams; const normal = [0, 0, 0]; normal[axis] = 1; const origin = [0, 0, 0]; diff --git a/src/widgets/slicingCrosshairs.js b/src/widgets/slicingCrosshairs.js index 2588aa965..4682fd9ee 100644 --- a/src/widgets/slicingCrosshairs.js +++ b/src/widgets/slicingCrosshairs.js @@ -14,7 +14,7 @@ export default class RulerWidget extends Widget { this.factory = vtkCrosshairsWidget.newInstance(); this.state = this.factory.getWidgetState(); - const { extent, spacing } = this.store.state.visualization.imageConfig; + const { extent, spacing } = this.store.state.visualization.imageParams; this.state .getHandle() .setBounds(...extent.map((b, i) => b * spacing[Math.floor(i / 2)])); @@ -31,7 +31,7 @@ export default class RulerWidget extends Widget { } const origin = this.state.getHandle().getOrigin(); - const { spacing } = this.store.state.visualization.imageConfig; + const { spacing } = this.store.state.visualization.imageParams; this.store.dispatch('visualization/setSlices', { x: origin[0] / spacing[0], y: origin[1] / spacing[1], @@ -54,8 +54,8 @@ export default class RulerWidget extends Widget { updateManipulator(view) { if (view) { const axis = view.getAxis(); - const { slices, imageConfig } = this.store.state.visualization; - const { spacing } = imageConfig; + const { slices, imageParams } = this.store.state.visualization; + const { spacing } = imageParams; const normal = [0, 0, 0]; normal[axis] = 1; const origin = [0, 0, 0];