diff --git a/packages/core/src/RenderingEngine/StackViewport.ts b/packages/core/src/RenderingEngine/StackViewport.ts index cc0c134758..43a9625322 100644 --- a/packages/core/src/RenderingEngine/StackViewport.ts +++ b/packages/core/src/RenderingEngine/StackViewport.ts @@ -1,7 +1,7 @@ import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; import type { vtkImageData as vtkImageDataType } from '@kitware/vtk.js/Common/DataModel/ImageData'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; -import vtkCamera from '@kitware/vtk.js/Rendering/Core/Camera'; +import extendedVtkCamera from './vtkClasses/extendedVtkCamera'; import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps'; import vtkImageMapper from '@kitware/vtk.js/Rendering/Core/ImageMapper'; @@ -285,7 +285,7 @@ class StackViewport extends Viewport { private _resetGPUViewport() { const renderer = this.getRenderer(); - const camera = vtkCamera.newInstance(); + const camera = extendedVtkCamera.newInstance(); renderer.setActiveCamera(camera); const viewPlaneNormal = [0, 0, -1] as Point3; @@ -350,6 +350,7 @@ class StackViewport extends Viewport { resetZoom?: boolean; resetToCenter?: boolean; suppressEvents?: boolean; + resetAspectRatio?: boolean; }) => boolean; /** @@ -848,6 +849,7 @@ class StackViewport extends Viewport { resetPan: true, resetZoom: true, resetToCenter: true, + resetAspectRatio: true, suppressEvents: true, }); }; @@ -1045,8 +1047,14 @@ class StackViewport extends Viewport { const { viewport, image } = this._cpuFallbackEnabledElement; const previousCamera = this.getCameraCPU(); - const { focalPoint, parallelScale, scale, flipHorizontal, flipVertical } = - cameraInterface; + const { + focalPoint, + parallelScale, + scale, + flipHorizontal, + flipVertical, + aspectRatio, + } = cameraInterface; const { clientHeight } = this.element; @@ -2683,7 +2691,7 @@ class StackViewport extends Viewport { }); } - private resetCameraGPU({ resetPan, resetZoom }): boolean { + private resetCameraGPU({ resetPan, resetZoom, resetAspectRatio }): boolean { // Todo: we need to make the rotation a camera properties so that // we can reset it there, right now it is not possible to reset the rotation // without this @@ -2699,7 +2707,12 @@ class StackViewport extends Viewport { // For stack Viewport we since we have only one slice // it should be enough to reset the camera to the center of the image const resetToCenter = true; - return super.resetCamera({ resetPan, resetZoom, resetToCenter }); + return super.resetCamera({ + resetPan, + resetZoom, + resetToCenter, + resetAspectRatio, + }); } /** @@ -3608,10 +3621,18 @@ class StackViewport extends Viewport { return true; }, gpu: ( - options: { resetPan?: boolean; resetZoom?: boolean } = {} + options: { + resetPan?: boolean; + resetZoom?: boolean; + resetAspectRatio?: boolean; + } = {} ): boolean => { - const { resetPan = true, resetZoom = true } = options; - this.resetCameraGPU({ resetPan, resetZoom }); + const { + resetPan = true, + resetZoom = true, + resetAspectRatio = true, + } = options; + this.resetCameraGPU({ resetPan, resetZoom, resetAspectRatio }); return true; }, }, diff --git a/packages/core/src/RenderingEngine/Viewport.ts b/packages/core/src/RenderingEngine/Viewport.ts index 2a4ee95cf7..f3f69cdcda 100644 --- a/packages/core/src/RenderingEngine/Viewport.ts +++ b/packages/core/src/RenderingEngine/Viewport.ts @@ -49,6 +49,7 @@ import type vtkMapper from '@kitware/vtk.js/Rendering/Core/Mapper'; import { deepClone } from '../utilities/deepClone'; import { updatePlaneRestriction } from '../utilities/updatePlaneRestriction'; import { getConfiguration } from '../init'; +import type { extendedVtkCamera } from './vtkClasses/extendedVtkCamera'; /** * An object representing a single viewport, which is a camera @@ -71,6 +72,7 @@ class Viewport { rotation: true, pan: true, zoom: true, + aspectRatio: true, displayArea: true, }; @@ -1057,12 +1059,14 @@ class Viewport { resetZoom?: boolean; resetToCenter?: boolean; storeAsInitialCamera?: boolean; + resetAspectRatio?: boolean; }): boolean { const { resetPan = true, resetZoom = true, resetToCenter = true, storeAsInitialCamera = true, + resetAspectRatio = true, } = options || {}; const renderer = this.getRenderer(); @@ -1208,6 +1212,7 @@ class Viewport { viewAngle: 90, viewUp: viewUpToSet, clippingRange: clippingRangeToUse, + aspectRatio: resetAspectRatio && [1, 1], }); const modifiedCamera = this.getCamera(); @@ -1391,6 +1396,36 @@ class Viewport { ); } + /** + * Returns the current aspect ratio of the viewport as a 2D point `[widthRatio, heightRatio]`. + * + * @returns The current aspect ratio `[widthRatio, heightRatio]` + * based on the active camera settings. + */ + public getAspectRatio(): Point2 { + const { aspectRatio } = this.getCamera(); + return aspectRatio; + } + + /** + * Sets the aspect ratio of the viewport using the provided 2D point `[widthRatio, heightRatio]`. + * + * @param value - The aspect ratio to set as `[widthRatio, heightRatio]`. + * @param storeAsInitialCamera - Whether to store the updated camera state as the initial camera. + * Defaults to `false`. + */ + public setAspectRatio(value: Point2, storeAsInitialCamera = false): void { + const camera = this.getCamera(); + + this.setCamera( + { + ...camera, + aspectRatio: value, + }, + storeAsInitialCamera + ); + } + /** * Because the focalPoint is always in the centre of the viewport, * we must do planar computations if the frame (image "slice") is to be preserved. @@ -1454,7 +1489,7 @@ class Viewport { } protected getCameraNoRotation(): ICamera { - const vtkCamera = this.getVtkActiveCamera(); + const vtkCamera = this.getVtkActiveCamera() as extendedVtkCamera; // Helper function to replace NaN vectors with defaults const sanitizeVector = (vector: Point3, defaultValue: Point3): Point3 => { @@ -1489,6 +1524,7 @@ class Viewport { viewAngle: vtkCamera.getViewAngle(), flipHorizontal: this.flipHorizontal, flipVertical: this.flipVertical, + aspectRatio: vtkCamera.getAspectRatio(), }; } @@ -1515,7 +1551,7 @@ class Viewport { cameraInterface: ICamera, storeAsInitialCamera = false ): void { - const vtkCamera = this.getVtkActiveCamera(); + const vtkCamera = this.getVtkActiveCamera() as extendedVtkCamera; const previousCamera = this.getCamera(); const updatedCamera = Object.assign({}, previousCamera, cameraInterface); const { @@ -1528,6 +1564,7 @@ class Viewport { flipHorizontal, flipVertical, clippingRange, + aspectRatio, } = cameraInterface; // Note: Flip camera should be two separate calls since @@ -1589,6 +1626,10 @@ class Viewport { vtkCamera.setClippingRange(clippingRange); } + if (aspectRatio) { + vtkCamera.setAspectRatio(aspectRatio); + } + // update clipping range only if focal point changed of a new actor is added const prevFocalPoint = previousCamera.focalPoint; const prevViewUp = previousCamera.viewUp; @@ -1983,6 +2024,7 @@ class Viewport { displayArea: true, zoom: true, pan: true, + aspectRatio: true, flipHorizontal: true, flipVertical: true, } @@ -2002,9 +2044,17 @@ class Viewport { if (zoom) { target.zoom = initZoom; } + const currentAspectRatio = this.getAspectRatio(); + target.aspectRatio = currentAspectRatio; if (pan) { - target.pan = this.getPan(); - vec2.scale(target.pan, target.pan, 1 / initZoom); + const currentPan = this.getPan(); + const [aspectX, aspectY] = currentAspectRatio; + + // Normalize pan to remove effect of zoom and stretch + const normalizedPanX = currentPan[0] / (initZoom * aspectX); + const normalizedPanY = currentPan[1] / (initZoom * aspectY); + + target.pan = [normalizedPanX, normalizedPanY] as Point2; } if (flipHorizontal) { @@ -2036,6 +2086,7 @@ class Viewport { displayArea, zoom = this.getZoom(), pan, + aspectRatio = this.getAspectRatio(), rotation, flipHorizontal = this.flipHorizontal, flipVertical = this.flipVertical, @@ -2048,6 +2099,8 @@ class Viewport { this.setPan(vec2.scale([0, 0], pan, zoom) as Point2); } + this.setAspectRatio(aspectRatio); + // flip operation requires another re-render to take effect, so unfortunately // right now if the view presentation requires a flip, it will flicker. The // correct way to handle this is to wait for camera and flip together and then diff --git a/packages/core/src/RenderingEngine/VolumeViewport.ts b/packages/core/src/RenderingEngine/VolumeViewport.ts index 938cf0de34..53a69fc54b 100644 --- a/packages/core/src/RenderingEngine/VolumeViewport.ts +++ b/packages/core/src/RenderingEngine/VolumeViewport.ts @@ -381,12 +381,13 @@ class VolumeViewport extends BaseVolumeViewport { resetToCenter = true, suppressEvents = false, resetOrientation = true, + resetAspectRatio = true, } = options || {}; const { orientation } = this.viewportProperties; if (orientation && resetOrientation) { this.applyViewOrientation(orientation, false); } - super.resetCamera({ resetPan, resetZoom, resetToCenter }); + super.resetCamera({ resetPan, resetZoom, resetToCenter, resetAspectRatio }); const activeCamera = this.getVtkActiveCamera(); const viewPlaneNormal = activeCamera.getViewPlaneNormal() as Point3; @@ -821,11 +822,13 @@ class VolumeViewport extends BaseVolumeViewport { const resetZoom = true; const resetToCenter = true; const resetCameraRotation = true; + const resetAspectRatio = true; this.resetCamera({ resetPan, resetZoom, resetToCenter, resetCameraRotation, + resetAspectRatio, }); triggerEvent(this.element, Events.VOI_MODIFIED, eventDetails); diff --git a/packages/core/src/RenderingEngine/helpers/getProjectionScaleIndices.ts b/packages/core/src/RenderingEngine/helpers/getProjectionScaleIndices.ts new file mode 100644 index 0000000000..ba8f999e34 --- /dev/null +++ b/packages/core/src/RenderingEngine/helpers/getProjectionScaleIndices.ts @@ -0,0 +1,62 @@ +import { vec3 } from 'gl-matrix'; + +const EPSILON = 1e-6; + +/** + * Determine which projection-matrix indices to multiply for canvas X and Y scaling. + * For each anatomical axis stretch work as follows + * axial => stretch in anterior-posterior + * sagittal => stretch in superior-inferior + * coronal => stretch in superior-inferior + * @param {Array} viewUp - [ux, uy, uz] camera viewUp (patient-space) + * @param {Array} viewPlaneNormal - [dx, dy, dz] camera viewPlaneNormal (patient-space) + * @returns {{ idxX: number, idxY: number}} + */ +export function getProjectionScaleIndices(viewUp, viewPlaneNormal) { + const up = vec3.normalize(vec3.create(), viewUp); + const vpn = vec3.normalize(vec3.create(), viewPlaneNormal); + + // Image axes in patient space + // Right-hand coordinate system: imageX = up × vpn + const imageX = vec3.create(); + vec3.cross(imageX, up, vpn); + if (vec3.length(imageX) < EPSILON) { + // Fallback: if up and vpn are nearly parallel, create an orthogonal axis + const tmp = + Math.abs(up[0]) < 1 / Math.sqrt(2) // Use 45 degree to find the nearest isometric axis + ? vec3.fromValues(1, 0, 0) + : vec3.fromValues(0, 1, 0); + vec3.cross(imageX, tmp, up); + } + vec3.normalize(imageX, imageX); + const imageY = up; + + // Determine anatomical orientation (axial/sagittal/coronal) + const absVpn = [Math.abs(vpn[0]), Math.abs(vpn[1]), Math.abs(vpn[2])]; + let orientation = 'axial'; + if (absVpn[0] === Math.max(...absVpn)) { + orientation = 'sagittal'; + } else if (absVpn[1] === Math.max(...absVpn)) { + orientation = 'coronal'; + } + + // Determine which anatomical axis to stretch + const AY = vec3.fromValues(0, 1, 0); // A-P + const AZ = vec3.fromValues(0, 0, 1); // S-I + const target = orientation === 'axial' ? AY : AZ; + + // Which image axis (X or Y) aligns better with target? + const scoreX = Math.abs(vec3.dot(imageX, target)); + const scoreY = Math.abs(vec3.dot(imageY, target)); + + // Choose projection-matrix diagonal indices + // 0 - scale X (horizontal) + // 5 - scale Y (vertical) + let [idxX, idxY] = [0, 5]; + // If target aligns with horizontal, swap so vertical scaling follows rotation + if (scoreX > scoreY) { + [idxX, idxY] = [5, 0]; + } + + return { idxX, idxY }; +} diff --git a/packages/core/src/RenderingEngine/helpers/index.ts b/packages/core/src/RenderingEngine/helpers/index.ts index 27415bb374..57189c5777 100644 --- a/packages/core/src/RenderingEngine/helpers/index.ts +++ b/packages/core/src/RenderingEngine/helpers/index.ts @@ -5,6 +5,7 @@ import setVolumesForViewports from './setVolumesForViewports'; import addVolumesToViewports from './addVolumesToViewports'; import volumeNewImageEventDispatcher from './volumeNewImageEventDispatcher'; import addImageSlicesToViewports from './addImageSlicesToViewports'; +import { getProjectionScaleIndices } from './getProjectionScaleIndices'; export { createVolumeActor, @@ -13,4 +14,5 @@ export { addVolumesToViewports, addImageSlicesToViewports, volumeNewImageEventDispatcher, + getProjectionScaleIndices, }; diff --git a/packages/core/src/RenderingEngine/vtkClasses/extendedVtkCamera.ts b/packages/core/src/RenderingEngine/vtkClasses/extendedVtkCamera.ts new file mode 100644 index 0000000000..091f295435 --- /dev/null +++ b/packages/core/src/RenderingEngine/vtkClasses/extendedVtkCamera.ts @@ -0,0 +1,115 @@ +import macro from '@kitware/vtk.js/macros'; +import vtkCamera from '@kitware/vtk.js/Rendering/Core/Camera'; +import type { vtkCamera as cameraType } from '@kitware/vtk.js/Rendering/Core/Camera'; +import { getProjectionScaleIndices } from '../helpers/getProjectionScaleIndices'; + +interface ICameraInitialValues { + position?: number[]; + focalPoint?: number[]; + viewUp?: number[]; + directionOfProjection?: number[]; + parallelProjection?: boolean; + useHorizontalViewAngle?: boolean; + viewAngle?: number; + parallelScale?: number; + clippingRange?: number[]; + windowCenter?: number[]; + viewPlaneNormal?: number[]; + useOffAxisProjection?: boolean; + screenBottomLeft?: number[]; + screenBottomRight?: number[]; + screenTopRight?: number[]; + freezeFocalPoint?: boolean; + physicalTranslation?: number[]; + physicalScale?: number; + physicalViewUp?: number[]; + physicalViewNorth?: number[]; + aspectRatio?: number[]; +} + +export interface extendedVtkCamera extends cameraType { + /** + * Get the aspectRatio of the viewport + * @defaultValue [1, 1] + */ + getAspectRatio(): [x: number, y: number]; + + /** + * Set the aspectRatio of the viewport + * @param aspectRatio - aspectRatio of the viewport in x and y axis + */ + setAspectRatio(aspectRatio: [x: number, y: number]): boolean; +} + +/** + * extendedVtkCamera - A derived class of the core vtkCamera class + * + * This customization is necessary because when need to handle stretched viewport + * + * @param {*} publicAPI The public API to extend + * @param {*} model The private model to extend. + */ +function extendedVtkCamera(publicAPI, model) { + model.classHierarchy.push('extendedVtkCamera'); + + // Keep original + const superGetProjectionMatrix = publicAPI.getProjectionMatrix; + + /** + * getProjectionMatrix - A fork of vtkCamera's getProjectionMatrix method. + * This fork performs most of the same actions, but added handling for stretched viewport. + */ + publicAPI.getProjectionMatrix = (aspect, nearZ, farZ) => { + const matrix = superGetProjectionMatrix(aspect, nearZ, farZ); + + const [sx, sy] = model.aspectRatio; + + if (sx !== 1.0 || sy !== 1.0) { + const viewUp = publicAPI.getViewUp(); + const viewPlaneNormal = publicAPI.getViewPlaneNormal(); + const { idxX, idxY } = getProjectionScaleIndices(viewUp, viewPlaneNormal); + matrix[idxX] *= sx; + matrix[idxY] *= sy; + } + + return matrix; + }; + + publicAPI.getAspectRatio = () => { + return model.aspectRatio; + }; + + publicAPI.setAspectRatio = (aspectRatio) => { + model.aspectRatio = aspectRatio; + }; +} + +// ---------------------------------------------------------------------------- +// Object factory +// ---------------------------------------------------------------------------- + +// ---------------------------------------------------------------------------- + +const DEFAULT_VALUES = { + aspectRatio: [1, 1], +}; + +export function extend(publicAPI, model, initialValues = {}) { + Object.assign(model, DEFAULT_VALUES, initialValues); + + vtkCamera.extend(publicAPI, model, initialValues); + + macro.setGet(publicAPI, model, ['aspectRatio']); + + // Object methods + extendedVtkCamera(publicAPI, model); +} + +// ---------------------------------------------------------------------------- + +export const newInstance: ( + initialValues?: ICameraInitialValues +) => extendedVtkCamera = macro.newInstance(extend, 'extendedVtkCamera'); +// ---------------------------------------------------------------------------- + +export default { newInstance, extend }; diff --git a/packages/core/src/RenderingEngine/vtkClasses/vtkSlabCamera.ts b/packages/core/src/RenderingEngine/vtkClasses/vtkSlabCamera.ts index 05b4ce2d70..c70dfa5678 100644 --- a/packages/core/src/RenderingEngine/vtkClasses/vtkSlabCamera.ts +++ b/packages/core/src/RenderingEngine/vtkClasses/vtkSlabCamera.ts @@ -1,9 +1,10 @@ import macro from '@kitware/vtk.js/macros'; -import vtkCamera from '@kitware/vtk.js/Rendering/Core/Camera'; +import extendedVtkCamera from './extendedVtkCamera'; import vtkMath from '@kitware/vtk.js/Common/Core/Math'; import { vec3, mat4 } from 'gl-matrix'; import type { vtkObject } from '@kitware/vtk.js/interfaces'; import type { Range } from '@kitware/vtk.js/types'; +import { getProjectionScaleIndices } from '../helpers/getProjectionScaleIndices'; /** * @@ -768,7 +769,7 @@ function extend( ): void { Object.assign(model, DEFAULT_VALUES, initialValues); - vtkCamera.extend(publicAPI, model, initialValues); + extendedVtkCamera.extend(publicAPI, model, initialValues); macro.setGet(publicAPI, model, ['isPerformingCoordinateTransformation']); @@ -909,6 +910,16 @@ function vtkSlabCamera(publicAPI, model) { tmpMatrix[15] = 0.0; } + const [sx, sy] = model.aspectRatio; + + if (sx !== 1.0 || sy !== 1.0) { + const viewUp = publicAPI.getViewUp(); + const viewPlaneNormal = publicAPI.getViewPlaneNormal(); + const { idxX, idxY } = getProjectionScaleIndices(viewUp, viewPlaneNormal); + tmpMatrix[idxX] *= sx; + tmpMatrix[idxY] *= sy; + } + mat4.copy(result, tmpMatrix); return result; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 140a992875..edd060290d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -86,6 +86,7 @@ import { setVolumesForViewports, addVolumesToViewports, addImageSlicesToViewports, + getProjectionScaleIndices, } from './RenderingEngine/helpers'; // Add new types here so that they can be imported singly as required. @@ -155,6 +156,7 @@ export { setVolumesForViewports, addVolumesToViewports, addImageSlicesToViewports, + getProjectionScaleIndices, // imageLoadPoolManager as requestPoolManager, imageRetrievalPoolManager, diff --git a/packages/core/src/types/ICamera.ts b/packages/core/src/types/ICamera.ts index 1d749f2707..da7e45457c 100644 --- a/packages/core/src/types/ICamera.ts +++ b/packages/core/src/types/ICamera.ts @@ -33,6 +33,8 @@ interface ICamera { flipVertical?: boolean; /** clipping range */ clippingRange?: Point2; + /** Aspect Ratio */ + aspectRatio?: Point2; } export type { ICamera as default }; diff --git a/packages/core/src/types/IImage.ts b/packages/core/src/types/IImage.ts index 89a897e8b9..2ebde4a9f3 100644 --- a/packages/core/src/types/IImage.ts +++ b/packages/core/src/types/IImage.ts @@ -175,6 +175,7 @@ interface CPUFallbackEnabledElement { imagePixelModule?: ImagePixelModule; }; voxelManager?: IVoxelManager | IVoxelManager; + aspectRatio?: Point2; } export type { IImage as default }; diff --git a/packages/core/src/types/IViewport.ts b/packages/core/src/types/IViewport.ts index cf9a4069f3..0502ab0c16 100644 --- a/packages/core/src/types/IViewport.ts +++ b/packages/core/src/types/IViewport.ts @@ -322,6 +322,11 @@ export interface ViewPresentation { * The flip vertical value is true if the view is flipped vertically. */ flipVertical?: boolean; + + /** + * The aspect ratio is how the viewport image is stretched and the default is [1,1]. + */ + aspectRatio?: Point2; } /** @@ -351,6 +356,7 @@ export interface ViewPresentationSelector { displayArea?: boolean; zoom?: boolean; pan?: boolean; + aspectRatio?: boolean; flipHorizontal?: boolean; flipVertical?: boolean; // Transfer function relative parameters diff --git a/packages/core/test/utilities/getProjectionScaleIndices.jest.js b/packages/core/test/utilities/getProjectionScaleIndices.jest.js new file mode 100644 index 0000000000..3bcb84be59 --- /dev/null +++ b/packages/core/test/utilities/getProjectionScaleIndices.jest.js @@ -0,0 +1,53 @@ +import { vec3 } from 'gl-matrix'; +import { getProjectionScaleIndices } from '../../src/RenderingEngine/helpers/getProjectionScaleIndices'; +import { describe, it, expect } from '@jest/globals'; + +describe('getProjectionScaleIndices', () => { + const EPS = 1e-6; + + test('axial view vpn [0, 0, 1] stretch in Anterior-Posterior', () => { + const viewUp = vec3.fromValues(0, -1, 0); + const vpn = vec3.fromValues(0, 0, 1); + + const { idxX, idxY } = getProjectionScaleIndices(viewUp, vpn); + + // Expect vertical scaling to map to Y (matrix[5]) + expect(idxX).toBe(0); + expect(idxY).toBe(5); + }); + + test('sagittal view vpn [1, 0, 0] Superior-Inferior', () => { + const viewUp = vec3.fromValues(0, 0, -1); + const vpn = vec3.fromValues(1, 0, 0); + + const { idxX, idxY } = getProjectionScaleIndices(viewUp, vpn); + + // Stretch in Z means likely vertical scale should flip + expect([0, 5]).toContain(idxX); + expect([0, 5]).toContain(idxY); + expect(idxX).not.toBe(idxY); + }); + + test('coronal view vpn [0, 1, 0] Superior-Inferior', () => { + const viewUp = vec3.fromValues(0, 0, -1); + const vpn = vec3.fromValues(0, 1, 0); + + const { idxX, idxY } = getProjectionScaleIndices(viewUp, vpn); + + expect([0, 5]).toContain(idxX); + expect([0, 5]).toContain(idxY); + expect(idxX).not.toBe(idxY); + }); + + test('oblique vpn halfway between axial and sagittal', () => { + const vpn = vec3.normalize(vec3.create(), vec3.fromValues(0.707, 0, 0.707)); + const viewUp = vec3.fromValues(0, -1, 0); + + const { idxX, idxY } = getProjectionScaleIndices(viewUp, vpn); + + // Should still return valid diagonal indices + expect([0, 5]).toContain(idxX); + expect([0, 5]).toContain(idxY); + expect(idxX).not.toBe(idxY); + }); +}); diff --git a/packages/tools/examples/axialBasedImageStretching/index.ts b/packages/tools/examples/axialBasedImageStretching/index.ts new file mode 100644 index 0000000000..910a36b121 --- /dev/null +++ b/packages/tools/examples/axialBasedImageStretching/index.ts @@ -0,0 +1,494 @@ +import type { Types } from '@cornerstonejs/core'; +import { + RenderingEngine, + Enums, + setVolumesForViewports, + volumeLoader, + ProgressiveRetrieveImages, + utilities, + getRenderingEngine, +} from '@cornerstonejs/core'; +import { + initDemo, + createImageIdsAndCacheMetaData, + setTitleAndDescription, + addDropdownToToolbar, + addSliderToToolbar, + setCtTransferFunctionForVolumeActor, + getLocalUrl, +} from '../../../../utils/demo/helpers'; +import * as cornerstoneTools from '@cornerstonejs/tools'; + +// This is for debugging purposes +console.warn( + 'Click on index.ts to open source code for this example --------->' +); + +const { + ToolGroupManager, + Enums: csToolsEnums, + segmentation, + LengthTool, + RectangleROITool, + EllipticalROITool, + CircleROITool, + BidirectionalTool, + RectangleScissorsTool, + SphereScissorsTool, + CircleScissorsTool, + BrushTool, + PaintFillTool, + PanTool, + ZoomTool, + StackScrollTool, + utilities: cstUtils, +} = cornerstoneTools; + +const { MouseBindings, KeyboardBindings } = csToolsEnums; +const { ViewportType } = Enums; +const { segmentation: segmentationUtils } = cstUtils; + +// Define a unique id for the volume +const volumeName = 'CT_VOLUME_ID'; // Id of the volume less loader prefix +const segmentationId = 'MY_SEGMENTATION_ID'; +const toolGroupId = 'MY_TOOLGROUP_ID'; +const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; // Loader id which defines which volume loader to use +const volumeId = `${volumeLoaderScheme}:${volumeName}`; +const renderingEngineId = 'myRenderingEngine'; +const viewportId1 = 'CT_AXIAL'; +const viewportId2 = 'CT_SAGITTAL'; +const viewportId3 = 'CT_CORONAL'; + +// ======== Set up page ======== // +setTitleAndDescription( + 'Axial-based Image Stretching', + 'Here we demonstrate axial based stretching with annotation and segmentation tools' +); + +const size = '500px'; +const content = document.getElementById('content'); +const viewportGrid = document.createElement('div'); + +viewportGrid.style.display = 'flex'; +viewportGrid.style.display = 'flex'; +viewportGrid.style.flexDirection = 'row'; + +const element1 = document.createElement('div'); +const element2 = document.createElement('div'); +const element3 = document.createElement('div'); +element1.style.width = size; +element1.style.height = size; +element2.style.width = size; +element2.style.height = size; +element3.style.width = size; +element3.style.height = size; + +// Disable right click context menu so we can have right click tools +element1.oncontextmenu = (e) => e.preventDefault(); +element2.oncontextmenu = (e) => e.preventDefault(); +element3.oncontextmenu = (e) => e.preventDefault(); + +viewportGrid.appendChild(element1); +viewportGrid.appendChild(element2); +viewportGrid.appendChild(element3); + +content.appendChild(viewportGrid); + +const instructions = document.createElement('p'); +instructions.innerText = ` + Left Click: Use selected Segmentation Tool. + Middle Click: Pan + Right Click: Zoom + Mouse wheel: Scroll Stack + `; + +content.append(instructions); + +const brushInstanceNames = { + CircularBrush: 'CircularBrush', + CircularEraser: 'CircularEraser', + SphereBrush: 'SphereBrush', + SphereEraser: 'SphereEraser', + ThresholdCircle: 'ThresholdCircle', + ScissorsEraser: 'ScissorsEraser', +}; + +const brushStrategies = { + [brushInstanceNames.CircularBrush]: 'FILL_INSIDE_CIRCLE', + [brushInstanceNames.CircularEraser]: 'ERASE_INSIDE_CIRCLE', + [brushInstanceNames.SphereBrush]: 'FILL_INSIDE_SPHERE', + [brushInstanceNames.SphereEraser]: 'ERASE_INSIDE_SPHERE', + [brushInstanceNames.ThresholdCircle]: 'THRESHOLD_INSIDE_CIRCLE', + [brushInstanceNames.ScissorsEraser]: 'ERASE_INSIDE', +}; + +const brushValues = [ + brushInstanceNames.CircularBrush, + brushInstanceNames.CircularEraser, + brushInstanceNames.SphereBrush, + brushInstanceNames.SphereEraser, + brushInstanceNames.ThresholdCircle, +]; + +const toolsNames = [ + LengthTool.toolName, + RectangleROITool.toolName, + EllipticalROITool.toolName, + CircleROITool.toolName, + BidirectionalTool.toolName, +]; + +const optionsValues = [ + ...brushValues, + RectangleScissorsTool.toolName, + CircleScissorsTool.toolName, + SphereScissorsTool.toolName, + brushInstanceNames.ScissorsEraser, + PaintFillTool.toolName, + ...toolsNames, +]; + +// ============================= // +addDropdownToToolbar({ + options: { values: optionsValues, defaultValue: BrushTool.toolName }, + onSelectedValueChange: (nameAsStringOrNumber) => { + const name = String(nameAsStringOrNumber); + const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); + + // Set the currently active tool disabled + const toolName = toolGroup.getActivePrimaryMouseButtonTool(); + + if (toolName) { + toolGroup.setToolDisabled(toolName); + } + + if (brushValues.includes(name)) { + toolGroup.setToolActive(name, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + } else { + const toolName = name; + + toolGroup.setToolActive(toolName, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + } + }, +}); + +const thresholdOptions = new Map(); +thresholdOptions.set('CT Fat: (-150, -70)', { + threshold: [-150, -70], +}); +thresholdOptions.set('CT Bone: (200, 1000)', { + threshold: [200, 1000], +}); + +addDropdownToToolbar({ + options: { + values: Array.from(thresholdOptions.keys()), + defaultValue: thresholdOptions[0], + }, + onSelectedValueChange: (nameAsStringOrNumber) => { + const name = String(nameAsStringOrNumber); + + const thresholdArgs = thresholdOptions.get(name); + + segmentationUtils.setBrushThresholdForToolGroup(toolGroupId, { + range: thresholdArgs.threshold, + isDynamic: false, + dynamicRadius: null, + }); + }, +}); + +addSliderToToolbar({ + title: 'Brush Size', + range: [5, 50], + defaultValue: 25, + onSelectedValueChange: (valueAsStringOrNumber) => { + const value = Number(valueAsStringOrNumber); + segmentationUtils.setBrushSizeForToolGroup(toolGroupId, value); + }, +}); + +let stretchAxis = ['Stretch X', 'Stretch Y']; +let selectedAxis = stretchAxis[0]; + +const setStretch = (value) => { + const renderingEngine = getRenderingEngine(renderingEngineId); + const viewport = renderingEngine.getViewport(viewportId1); + const { aspectRatio } = viewport.getCamera(); + if (selectedAxis === 'Stretch X') { + viewport.setCamera({ + aspectRatio: [value, aspectRatio[1]], + }); + } else { + viewport.setCamera({ + aspectRatio: [aspectRatio[0], value], + }); + } + viewport.render(); +}; + +addDropdownToToolbar({ + options: { + values: stretchAxis, + defaultValue: selectedAxis, + }, + onSelectedValueChange: (nameAsStringOrNumber) => { + const name = String(nameAsStringOrNumber); + document.getElementById('stretchSlider').value = 1; + setStretch(1); + selectedAxis = name; + }, +}); + +addSliderToToolbar({ + id: 'stretchSlider', + title: 'Stretch Value', + range: [1, 10], + defaultValue: 1, + onSelectedValueChange: (valueAsStringOrNumber) => { + const value = Number(valueAsStringOrNumber); + setStretch(value); + }, +}); + +// ============================= // + +async function addSegmentationsToState() { + // Create a segmentation of the same resolution as the source data + volumeLoader.createAndCacheDerivedLabelmapVolume(volumeId, { + volumeId: segmentationId, + }); + + // Add the segmentations to state + segmentation.addSegmentations([ + { + segmentationId, + representation: { + // The type of segmentation + type: csToolsEnums.SegmentationRepresentations.Labelmap, + // The actual segmentation data, in the case of labelmap this is a + // reference to the source volume of the segmentation. + data: { + volumeId: segmentationId, + }, + }, + }, + ]); +} + +/** + * Runs the demo + */ +async function run() { + // Init Cornerstone and related libraries + await initDemo(); + + // This is not necessary, but makes the images appear faster + utilities.imageRetrieveMetadataProvider.add( + 'volume', + ProgressiveRetrieveImages.interleavedRetrieveStages + ); + + // Add tools to Cornerstone3D + cornerstoneTools.addTool(LengthTool); + cornerstoneTools.addTool(RectangleROITool); + cornerstoneTools.addTool(EllipticalROITool); + cornerstoneTools.addTool(CircleROITool); + cornerstoneTools.addTool(BidirectionalTool); + cornerstoneTools.addTool(PanTool); + cornerstoneTools.addTool(ZoomTool); + cornerstoneTools.addTool(StackScrollTool); + cornerstoneTools.addTool(RectangleScissorsTool); + cornerstoneTools.addTool(CircleScissorsTool); + cornerstoneTools.addTool(SphereScissorsTool); + cornerstoneTools.addTool(PaintFillTool); + cornerstoneTools.addTool(BrushTool); + + // Define tool groups to add the segmentation display tool to + const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + + // Manipulation Tools + toolGroup.addTool(PanTool.toolName); + toolGroup.addTool(ZoomTool.toolName); + + // Annotation Tools + toolGroup.addTool(LengthTool.toolName); + toolGroup.addTool(RectangleROITool.toolName); + toolGroup.addTool(EllipticalROITool.toolName); + toolGroup.addTool(CircleROITool.toolName); + toolGroup.addTool(BidirectionalTool.toolName); + + // Segmentation Tools + toolGroup.addTool(RectangleScissorsTool.toolName); + toolGroup.addTool(CircleScissorsTool.toolName); + toolGroup.addTool(SphereScissorsTool.toolName); + toolGroup.addToolInstance( + brushInstanceNames.ScissorsEraser, + SphereScissorsTool.toolName, + { + activeStrategy: brushStrategies.ScissorsEraser, + } + ); + toolGroup.addTool(PaintFillTool.toolName); + toolGroup.addTool(StackScrollTool.toolName); + toolGroup.addToolInstance( + brushInstanceNames.CircularBrush, + BrushTool.toolName, + { + activeStrategy: brushStrategies.CircularBrush, + } + ); + toolGroup.addToolInstance( + brushInstanceNames.CircularEraser, + BrushTool.toolName, + { + activeStrategy: brushStrategies.CircularEraser, + } + ); + toolGroup.addToolInstance( + brushInstanceNames.SphereBrush, + BrushTool.toolName, + { + activeStrategy: brushStrategies.SphereBrush, + } + ); + toolGroup.addToolInstance( + brushInstanceNames.SphereEraser, + BrushTool.toolName, + { + activeStrategy: brushStrategies.SphereEraser, + } + ); + toolGroup.addToolInstance( + brushInstanceNames.ThresholdCircle, + BrushTool.toolName, + { + activeStrategy: brushStrategies.ThresholdCircle, + } + ); + + toolGroup.setToolActive(brushInstanceNames.CircularBrush, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + + toolGroup.setToolActive(StackScrollTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Wheel }], + }); + toolGroup.setToolActive(ZoomTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Primary, // Shift Left Click + modifierKey: KeyboardBindings.Shift, + }, + ], + }); + + toolGroup.setToolActive(PanTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Auxiliary, // Middle Click + }, + { + mouseButton: MouseBindings.Primary, + modifierKey: KeyboardBindings.Ctrl, + }, + ], + }); + toolGroup.setToolActive(ZoomTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Secondary, // Right Click + }, + ], + }); + + // Get Cornerstone imageIds for the source data and fetch metadata into RAM + const imageIds = await createImageIdsAndCacheMetaData({ + StudyInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', + SeriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', + wadoRsRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', + }); + + // Define a volume in memory + const volume = await volumeLoader.createAndCacheVolume(volumeId, { + imageIds, + }); + + volume.load(); + + // Add some segmentations based on the source data volume + await addSegmentationsToState(); + + // Instantiate a rendering engine + const renderingEngine = new RenderingEngine(renderingEngineId); + + // Create the viewports + const viewportInputArray = [ + { + viewportId: viewportId1, + type: ViewportType.ORTHOGRAPHIC, + element: element1, + defaultOptions: { + orientation: Enums.OrientationAxis.AXIAL, + background: [0, 0, 0], + }, + }, + { + viewportId: viewportId2, + type: ViewportType.ORTHOGRAPHIC, + element: element2, + defaultOptions: { + orientation: Enums.OrientationAxis.SAGITTAL, + background: [0, 0, 0], + }, + }, + { + viewportId: viewportId3, + type: ViewportType.ORTHOGRAPHIC, + element: element3, + defaultOptions: { + orientation: Enums.OrientationAxis.CORONAL, + background: [0, 0, 0], + }, + }, + ]; + + renderingEngine.setViewports(viewportInputArray); + + toolGroup.addViewport(viewportId1, renderingEngineId); + toolGroup.addViewport(viewportId2, renderingEngineId); + toolGroup.addViewport(viewportId3, renderingEngineId); + + // Set the volume to load + // volume.load(); + + // Set volumes on the viewports + await setVolumesForViewports( + renderingEngine, + [{ volumeId, callback: setCtTransferFunctionForVolumeActor }], + [viewportId1, viewportId2, viewportId3] + ); + + // Add the segmentation representation to the viewports + const segmentationRepresentation = { + segmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }; + + await segmentation.addLabelmapRepresentationToViewportMap({ + [viewportId1]: [segmentationRepresentation], + [viewportId2]: [segmentationRepresentation], + [viewportId3]: [segmentationRepresentation], + }); + + // Render the image + renderingEngine.render(); +} + +run(); diff --git a/packages/tools/src/tools/annotation/CircleROITool.ts b/packages/tools/src/tools/annotation/CircleROITool.ts index 8a7ef406ba..145905a925 100644 --- a/packages/tools/src/tools/annotation/CircleROITool.ts +++ b/packages/tools/src/tools/annotation/CircleROITool.ts @@ -29,6 +29,7 @@ import { drawCircle as drawCircleSvg, drawHandles as drawHandlesSvg, drawLinkedTextBox as drawLinkedTextBoxSvg, + drawEllipseByCoordinates, } from '../../drawingSvg'; import { state } from '../../store/state'; import { ChangeTypes, Events } from '../../enums'; @@ -58,10 +59,14 @@ import { getCanvasCircleCorners, getCanvasCircleRadius, } from '../../utilities/math/circle'; -import { pointInEllipse } from '../../utilities/math/ellipse'; +import { + getCanvasEllipseCorners, + pointInEllipse, +} from '../../utilities/math/ellipse'; import { BasicStatsCalculator } from '../../utilities/math/basic'; import { vec2, vec3 } from 'gl-matrix'; import { getStyleProperty } from '../../stateManagement/annotation/config/helpers'; +import getEllipseWorldCoordinates from '../../utilities/getEllipseWorldCoordinates'; const { transformWorldToIndex } = csUtils; @@ -238,14 +243,84 @@ class CircleROITool extends AnnotationTool { const { viewport } = enabledElement; const { points } = annotation.data.handles; - const canvasHandles = points.map((p) => viewport.worldToCanvas(p)); - const canvasCenter = canvasHandles[0]; - const radius = getCanvasCircleRadius([canvasCenter, canvasHandles[1]]); - const radiusPoint = getCanvasCircleRadius([canvasCenter, canvasCoords]); + // Get the radius in world units from the drag distance + const ellipseWorldCoordinates = getEllipseWorldCoordinates( + [points[0], points[1]], + viewport + ); + + const ellipseCanvasCoordinates = ellipseWorldCoordinates.map((p) => + viewport.worldToCanvas(p) + ) as [Types.Point2, Types.Point2, Types.Point2, Types.Point2]; + const canvasCorners = getCanvasEllipseCorners( + ellipseCanvasCoordinates as [ + Types.Point2, + Types.Point2, + Types.Point2, + Types.Point2, + ] + ); - return Math.abs(radiusPoint - radius) < proximity / 2; + const [canvasPoint1, canvasPoint2] = canvasCorners; + + const minorEllipse = { + left: Math.min(canvasPoint1[0], canvasPoint2[0]) + proximity / 2, + top: Math.min(canvasPoint1[1], canvasPoint2[1]) + proximity / 2, + width: Math.abs(canvasPoint1[0] - canvasPoint2[0]) - proximity, + height: Math.abs(canvasPoint1[1] - canvasPoint2[1]) - proximity, + }; + + const majorEllipse = { + left: Math.min(canvasPoint1[0], canvasPoint2[0]) - proximity / 2, + top: Math.min(canvasPoint1[1], canvasPoint2[1]) - proximity / 2, + width: Math.abs(canvasPoint1[0] - canvasPoint2[0]) + proximity, + height: Math.abs(canvasPoint1[1] - canvasPoint2[1]) + proximity, + }; + + const pointInMinorEllipse = this._pointInEllipseCanvas( + minorEllipse, + canvasCoords + ); + const pointInMajorEllipse = this._pointInEllipseCanvas( + majorEllipse, + canvasCoords + ); + + if (pointInMajorEllipse && !pointInMinorEllipse) { + return true; + } + + return false; }; + /** + * This is a temporary function to use the old ellipse's canvas-based + * calculation for isPointNearTool, we should move the the world-based + * calculation to the tool's isPointNearTool function. + * + * @param ellipse - The ellipse object + * @param location - The location to check + * @returns True if the point is inside the ellipse + */ + _pointInEllipseCanvas(ellipse, location: Types.Point2): boolean { + const xRadius = ellipse.width / 2; + const yRadius = ellipse.height / 2; + + if (xRadius <= 0.0 || yRadius <= 0.0) { + return false; + } + + const center = [ellipse.left + xRadius, ellipse.top + yRadius]; + const normalized = [location[0] - center[0], location[1] - center[1]]; + + const inEllipse = + (normalized[0] * normalized[0]) / (xRadius * xRadius) + + (normalized[1] * normalized[1]) / (yRadius * yRadius) <= + 1.0; + + return inEllipse; + } + toolSelectedCallback = ( evt: EventTypes.InteractionEventType, annotation: CircleROIAnnotation @@ -750,12 +825,27 @@ class CircleROITool extends AnnotationTool { const dataId = `${annotationUID}-circle`; const circleUID = '0'; - drawCircleSvg( + + // Get the radius in world units from the drag distance + const ellipseWorldCoordinates = getEllipseWorldCoordinates( + [points[0], points[1]], + viewport + ); + + const ellipseCanvasCoordinates = ellipseWorldCoordinates.map((p) => + viewport.worldToCanvas(p) + ) as [Types.Point2, Types.Point2, Types.Point2, Types.Point2]; + + drawEllipseByCoordinates( svgDrawingHelper, annotationUID, circleUID, - center, - radius, + ellipseCanvasCoordinates as [ + Types.Point2, + Types.Point2, + Types.Point2, + Types.Point2, + ], { color, lineDash, diff --git a/packages/tools/src/tools/segmentation/BrushTool.ts b/packages/tools/src/tools/segmentation/BrushTool.ts index 539295546d..2bc9de56f0 100644 --- a/packages/tools/src/tools/segmentation/BrushTool.ts +++ b/packages/tools/src/tools/segmentation/BrushTool.ts @@ -31,6 +31,7 @@ import { import triggerAnnotationRenderForViewportUIDs from '../../utilities/triggerAnnotationRenderForViewportIds'; import LabelmapBaseTool from './LabelmapBaseTool'; import { getStrategyData } from './strategies/utils/getStrategyData'; +import { getCenterAndRadiusInCanvas } from '../../utilities/getCenterAndRadiusInCanvas'; /** * @public @@ -706,17 +707,7 @@ class BrushTool extends LabelmapBaseTool { const data = brushCursor.data; const { points } = data.handles; - const canvasCoordinates = points.map((p) => viewport.worldToCanvas(p)); - - const bottom = canvasCoordinates[0]; - const top = canvasCoordinates[1]; - - const center = [ - Math.floor((bottom[0] + top[0]) / 2), - Math.floor((bottom[1] + top[1]) / 2), - ]; - - const radius = Math.abs(bottom[1] - Math.floor((bottom[1] + top[1]) / 2)); + const { center, radius } = getCenterAndRadiusInCanvas(points, viewport); const color = `rgb(${toolMetadata.segmentColor?.slice(0, 3) || [0, 0, 0]})`; diff --git a/packages/tools/src/tools/segmentation/SphereScissorsTool.ts b/packages/tools/src/tools/segmentation/SphereScissorsTool.ts index d76e284451..61372105b5 100644 --- a/packages/tools/src/tools/segmentation/SphereScissorsTool.ts +++ b/packages/tools/src/tools/segmentation/SphereScissorsTool.ts @@ -28,6 +28,8 @@ import { import { getSegmentation } from '../../stateManagement/segmentation/segmentationState'; import LabelmapBaseTool from './LabelmapBaseTool'; +import getEllipseWorldCoordinates from '../../utilities/getEllipseWorldCoordinates'; +import { getCenterAndRadiusInCanvas } from '../../utilities/getCenterAndRadiusInCanvas'; /** * Tool for manipulating segmentation data by drawing a sphere in 3d space. It acts on the @@ -216,35 +218,17 @@ class SphereScissorsTool extends LabelmapBaseTool { const { annotation, viewportIdsToRender, centerCanvas } = this.editData; const { data } = annotation; - const dX = Math.abs(currentCanvasPoints[0] - centerCanvas[0]); - const dY = Math.abs(currentCanvasPoints[1] - centerCanvas[1]); - const radius = Math.sqrt(dX * dX + dY * dY); - - const bottomCanvas: Types.Point2 = [ - centerCanvas[0], - centerCanvas[1] + radius, - ]; - const topCanvas: Types.Point2 = [centerCanvas[0], centerCanvas[1] - radius]; - const leftCanvas: Types.Point2 = [ - centerCanvas[0] - radius, - centerCanvas[1], - ]; - const rightCanvas: Types.Point2 = [ - centerCanvas[0] + radius, - centerCanvas[1], - ]; - - data.handles.points = [ - canvasToWorld(bottomCanvas), - canvasToWorld(topCanvas), - canvasToWorld(leftCanvas), - canvasToWorld(rightCanvas), - ]; + // Convert center and current point to world coordinates + const centerWorld = canvasToWorld(centerCanvas as Types.Point2); + const currentWorld = canvasToWorld(currentCanvasPoints as Types.Point2); - annotation.invalidated = true; + data.handles.points = getEllipseWorldCoordinates( + [centerWorld, currentWorld], + viewport + ) as [Types.Point3, Types.Point3, Types.Point3, Types.Point3]; + annotation.invalidated = true; this.editData.hasMoved = true; - triggerAnnotationRenderForViewportIds(viewportIdsToRender); }; @@ -351,17 +335,7 @@ class SphereScissorsTool extends LabelmapBaseTool { const data = annotation.data; const { points } = data.handles; - const canvasCoordinates = points.map((p) => viewport.worldToCanvas(p)); - - const bottom = canvasCoordinates[0]; - const top = canvasCoordinates[1]; - - const center = [ - Math.floor((bottom[0] + top[0]) / 2), - Math.floor((bottom[1] + top[1]) / 2), - ]; - - const radius = Math.abs(bottom[1] - Math.floor((bottom[1] + top[1]) / 2)); + const { center, radius } = getCenterAndRadiusInCanvas(points, viewport); const color = `rgb(${toolMetadata.segmentColor.slice(0, 3)})`; diff --git a/packages/tools/src/tools/segmentation/strategies/__tests__/fillCircle.spec.ts b/packages/tools/src/tools/segmentation/strategies/__tests__/fillCircle.spec.ts index 84d5aeae66..a846af7537 100644 --- a/packages/tools/src/tools/segmentation/strategies/__tests__/fillCircle.spec.ts +++ b/packages/tools/src/tools/segmentation/strategies/__tests__/fillCircle.spec.ts @@ -24,7 +24,8 @@ describe('createPointInEllipse', () => { [-2, 0, 0] as Types.Point3, [2, 0, 0] as Types.Point3, ], - radius: 1, + xRadius: 1, + yRadius: 1, }); expect(predicate([0, 0, 0] as Types.Point3)).toBe(true); diff --git a/packages/tools/src/tools/segmentation/strategies/fillCircle.ts b/packages/tools/src/tools/segmentation/strategies/fillCircle.ts index 613d588290..d0878eba11 100644 --- a/packages/tools/src/tools/segmentation/strategies/fillCircle.ts +++ b/packages/tools/src/tools/segmentation/strategies/fillCircle.ts @@ -36,21 +36,22 @@ function createCircleCornersForCenter( center: Types.Point3, viewUp: ReadonlyVec3, viewRight: ReadonlyVec3, - radius: number + yRadius: number, + xRadius: number ): Types.Point3[] { const centerVec = vec3.fromValues(center[0], center[1], center[2]); const top = vec3.create(); - vec3.scaleAndAdd(top, centerVec, viewUp, radius); + vec3.scaleAndAdd(top, centerVec, viewUp, yRadius); const bottom = vec3.create(); - vec3.scaleAndAdd(bottom, centerVec, viewUp, -radius); + vec3.scaleAndAdd(bottom, centerVec, viewUp, -yRadius); const right = vec3.create(); - vec3.scaleAndAdd(right, centerVec, viewRight, radius); + vec3.scaleAndAdd(right, centerVec, viewRight, xRadius); const left = vec3.create(); - vec3.scaleAndAdd(left, centerVec, viewRight, -radius); + vec3.scaleAndAdd(left, centerVec, viewRight, -xRadius); return [ bottom as Types.Point3, @@ -65,12 +66,17 @@ function createCircleCornersForCenter( // strategy for many intermediate samples, which was unnecessarily expensive // and still missed fast mouse moves. This predicate lets us describe the full // swept volume in constant time per segment when the strategy runs. -function createStrokePredicate(centers: Types.Point3[], radius: number) { - if (!centers.length || radius <= 0) { +function createStrokePredicate( + centers: Types.Point3[], + xRadius: number, + yRadius: number +) { + if (!centers.length || xRadius <= 0 || yRadius <= 0) { return null; } - const radiusSquared = radius * radius; + const xRadiusSquared = xRadius * xRadius; + const yRadiusSquared = yRadius * yRadius; const centerVecs = centers.map( (point) => [point[0], point[1], point[2]] as Types.Point3 ); @@ -100,7 +106,7 @@ function createStrokePredicate(centers: Types.Point3[], radius: number) { const dx = worldPoint[0] - centerVec[0]; const dy = worldPoint[1] - centerVec[1]; const dz = worldPoint[2] - centerVec[2]; - if (dx * dx + dy * dy + dz * dz <= radiusSquared) { + if ((dx * dx) / xRadiusSquared + (dy * dy) / yRadiusSquared <= 1) { return true; } } @@ -110,7 +116,7 @@ function createStrokePredicate(centers: Types.Point3[], radius: number) { const dx = worldPoint[0] - start[0]; const dy = worldPoint[1] - start[1]; const dz = worldPoint[2] - start[2]; - if (dx * dx + dy * dy + dz * dz <= radiusSquared) { + if ((dx * dx) / xRadiusSquared + (dy * dy) / yRadiusSquared <= 1) { return true; } continue; @@ -128,7 +134,10 @@ function createStrokePredicate(centers: Types.Point3[], radius: number) { const distY = worldPoint[1] - projY; const distZ = worldPoint[2] - projZ; - if (distX * distX + distY * distY + distZ * distZ <= radiusSquared) { + if ( + (distX * distX) / xRadiusSquared + (distY * distY) / yRadiusSquared <= + 1 + ) { return true; } } @@ -169,8 +178,18 @@ const initializeCircle = { center as Types.Point3 ); - const brushRadius = - points.length >= 2 ? vec3.distance(points[0], points[1]) / 2 : 0; + // Get your aspect ratio values + const aspectRatio = viewport?.getAspectRatio?.() || [1, 1]; + + const yRadius = + points.length >= 2 + ? vec3.distance(points[0], points[1]) / 2 / aspectRatio[1] + : 0; + + const xRadius = + points.length >= 2 + ? vec3.distance(points[2], points[3]) / 2 / aspectRatio[0] + : 0; const canvasCoordinates = points.map((p) => viewport.worldToCanvas(p) @@ -214,7 +233,8 @@ const initializeCircle = { centerPoint, normalizedViewUp, viewRight, - brushRadius + yRadius, + xRadius ) ); @@ -231,7 +251,9 @@ const initializeCircle = { operationData.isInObject = createPointInEllipse(cornersInWorld, { strokePointsWorld: strokeCenters, segmentationImageData, - radius: brushRadius, + xRadius, + yRadius, + aspectRatio, }); operationData.isInObjectBoundsIJK = boundsIJK; @@ -251,7 +273,9 @@ function createPointInEllipse( options: { strokePointsWorld?: Types.Point3[]; segmentationImageData?: vtkImageData; - radius?: number; + xRadius?: number; + yRadius?: number; + aspectRatio?: [number, number]; } = {} ) { if (!cornersInWorld || cornersInWorld.length !== 4) { @@ -259,33 +283,38 @@ function createPointInEllipse( } const [topLeft, bottomRight, bottomLeft, topRight] = cornersInWorld; + const aspectRatio = options.aspectRatio || [1, 1]; + // Center is the midpoint of the diagonal const center = vec3.create(); vec3.add(center, topLeft, bottomRight); vec3.scale(center, center, 0.5); - // Major axis: from topLeft to topRight + //Calculate a SINGLE original radius to ensure the base shape is a circle. + // We'll use the width (major axis) as the definitive diameter. const majorAxisVec = vec3.create(); vec3.subtract(majorAxisVec, topRight, topLeft); - const xRadius = vec3.length(majorAxisVec) / 2; - vec3.normalize(majorAxisVec, majorAxisVec); + const originalRadius = vec3.length(majorAxisVec) / 2; + vec3.normalize(majorAxisVec, majorAxisVec); // This is the 'X' direction vector - // Minor axis: from topLeft to bottomLeft + // We still need the minor axis for its direction, but not its length. const minorAxisVec = vec3.create(); vec3.subtract(minorAxisVec, bottomLeft, topLeft); - const yRadius = vec3.length(minorAxisVec) / 2; - vec3.normalize(minorAxisVec, minorAxisVec); + vec3.normalize(minorAxisVec, minorAxisVec); // This is the 'Y' direction vector - // Plane normal - const normal = vec3.create(); - vec3.cross(normal, majorAxisVec, minorAxisVec); - vec3.normalize(normal, normal); + //Apply the inverse aspect ratio stretch CORRECTLY and ALWAYS the same way. + // To counteract the viewport's stretching and make the shape appear circular, + // we must "pre-squash" it in world space. + const xRadius = originalRadius / aspectRatio[0]; + const yRadius = originalRadius / aspectRatio[1]; // If radii are equal, treat as sphere - const radiusForStroke = options.radius ?? Math.max(xRadius, yRadius); + const xRadiusForStroke = options.xRadius ?? xRadius; + const yRadiusForStroke = options.yRadius ?? yRadius; const strokePredicate = createStrokePredicate( options.strokePointsWorld || [], - radiusForStroke + xRadiusForStroke, + yRadiusForStroke ); if (isEqual(xRadius, yRadius)) { @@ -344,19 +373,8 @@ function createPointInEllipse( // conversions happened on callers for every interpolated point. const pointVec = vec3.create(); vec3.subtract(pointVec, worldPoint, center); - // Remove component along normal - const distToPlane = vec3.dot(pointVec, normal); - const proj = vec3.create(); - vec3.scaleAndAdd(proj, pointVec, normal, -distToPlane); - - // Express proj in (majorAxis, minorAxis) coordinates - // Project from center, so shift origin to topLeft - const fromTopLeft = vec3.create(); - const centerToTopLeft = vec3.create(); - vec3.subtract(centerToTopLeft, center, topLeft); - vec3.subtract(fromTopLeft, proj, centerToTopLeft); - const x = vec3.dot(fromTopLeft, majorAxisVec); - const y = vec3.dot(fromTopLeft, minorAxisVec); + const x = vec3.dot(pointVec, majorAxisVec); + const y = vec3.dot(pointVec, minorAxisVec); // Ellipse equation: (x/xRadius)^2 + (y/yRadius)^2 <= 1 return (x * x) / (xRadius * xRadius) + (y * y) / (yRadius * yRadius) <= 1; diff --git a/packages/tools/src/tools/segmentation/strategies/fillSphere.ts b/packages/tools/src/tools/segmentation/strategies/fillSphere.ts index 6d70e3ab7a..422389840f 100644 --- a/packages/tools/src/tools/segmentation/strategies/fillSphere.ts +++ b/packages/tools/src/tools/segmentation/strategies/fillSphere.ts @@ -54,8 +54,17 @@ const sphereComposition = { viewport.canvasToWorld(corner) ); - const strokeRadius = - points.length >= 2 ? vec3.distance(points[0], points[1]) / 2 : undefined; + const aspectRatio = viewport?.getAspectRatio?.() || [1, 1]; + + const yRadius = + points.length >= 2 + ? vec3.distance(points[0], points[1]) / 2 / aspectRatio[1] + : 0; + + const xRadius = + points.length >= 2 + ? vec3.distance(points[2], points[3]) / 2 / aspectRatio[0] + : 0; const strokeCenters = operationData.strokePointsWorld && @@ -143,7 +152,9 @@ const sphereComposition = { operationData.isInObject = createEllipseInPoint(cornersInWorld, { strokePointsWorld: operationData.strokePointsWorld, segmentationImageData, - radius: strokeRadius, + xRadius, + yRadius, + aspectRatio, }); // } }, diff --git a/packages/tools/src/utilities/getCenterAndRadiusInCanvas.ts b/packages/tools/src/utilities/getCenterAndRadiusInCanvas.ts new file mode 100644 index 0000000000..1279e604c1 --- /dev/null +++ b/packages/tools/src/utilities/getCenterAndRadiusInCanvas.ts @@ -0,0 +1,57 @@ +import { + getProjectionScaleIndices, + type Types, + type VolumeViewport, +} from '@cornerstonejs/core'; + +/** + * Calculates the center point and radius in canvas coordinate of a circle + * from a set of world coordinates within the given viewport. + * + * The function projects the provided world coordinates into the + * viewport's canvas coordinates and returns both the + * calculated center and radius in canvas coordinates. + * + * @param {Types.Point3[]} points - The list of 3D points defining the circle - center and point on circle. + * @param {Types.IStackViewport | VolumeViewport} viewport - The current viewport. + * @returns {[Types.Point2, Types.Point2]} An array contains: + * - The first element: center in canvas coordinate. + * - The second element: radius in canvas coordinate. + */ + +export function getCenterAndRadiusInCanvas( + points: Types.Point3[], + viewport: Types.IStackViewport | VolumeViewport +): { center: Types.Point2; radius: number } { + const canvasCoordinates = points.map((p) => viewport.worldToCanvas(p)); + const bottom = canvasCoordinates[0]; + const top = canvasCoordinates[1]; + const right = canvasCoordinates[3]; + + const center: Types.Point2 = [ + Math.floor((bottom[0] + top[0]) / 2), + Math.floor((bottom[1] + top[1]) / 2), + ]; + + // Get your aspect ratio values + const [sx, sy] = viewport.getAspectRatio?.() || [1, 1]; + const camera = viewport.getCamera(); + const { viewUp, viewPlaneNormal } = camera; + + const { idxX, idxY } = getProjectionScaleIndices(viewUp, viewPlaneNormal); + + // Determine which stretch corresponds to horizontal vs vertical in current orientation + const stretchH = idxX === 0 ? sx : sy; + const stretchV = idxY === 5 ? sy : sx; + + const verticalRadius = Math.abs(bottom[1] - center[1]); + const horizontalRadius = Math.abs(right[0] - center[0]); + + let radius; + if (sx !== 1.0 || sy !== 1.0) { + radius = Math.min(verticalRadius * stretchV, horizontalRadius * stretchH); + } else { + radius = verticalRadius; + } + return { center, radius }; +} diff --git a/packages/tools/src/utilities/getEllipseWorldCoordinates.ts b/packages/tools/src/utilities/getEllipseWorldCoordinates.ts new file mode 100644 index 0000000000..d552937c16 --- /dev/null +++ b/packages/tools/src/utilities/getEllipseWorldCoordinates.ts @@ -0,0 +1,61 @@ +import type { VolumeViewport, Types } from '@cornerstonejs/core'; +import { vec3 } from 'gl-matrix'; + +/** + * Computes the ellipse boundary points (top, bottom, left, right) based on two + * given world-space points and the viewport's camera orientation. + * + * Depending on the `returnWorldCoordinates` flag, it returns either: + * - The ellipse boundary points in world coordinates or + * - The corresponding points in canvas coordinates (projected using the viewport). + * + * This function: + * - Uses the camera's `viewUp` and `viewPlaneNormal` to derive the orientation. + * - Computes the perpendicular `viewRight` vector via cross product. + * - Calculates top, bottom, left, and right points relative to the ellipse center. + * + * @param points Array containing: + * - `[0]`: The center of the ellipse in world coordinates. + * - `[1]`: A point on the ellipse radius in world coordinates. + * @param viewport The viewport instance + * @returns Returns an array of canvas-space coordinates representing: + * 1. Bottom + * 2. Top + * 3. Left + * 4. Right + */ +export default function getEllipseWorldCoordinates( + points: [Types.Point3, Types.Point3], + viewport: Types.IStackViewport | VolumeViewport +): Types.Point3[] { + const camera = viewport.getCamera(); + const { viewUp, viewPlaneNormal } = camera; + + // Calculate view right vector + const viewRight = vec3.create(); + vec3.cross(viewRight, viewUp, viewPlaneNormal); + + const [centerWorld, endWorld] = points; + const centerToEndDistance = vec3.distance(centerWorld, endWorld); + + // Calculate the four boundary points in world coordinates + const bottomWorld = vec3.create(); + const topWorld = vec3.create(); + const leftWorld = vec3.create(); + const rightWorld = vec3.create(); + + for (let i = 0; i <= 2; i++) { + bottomWorld[i] = centerWorld[i] - viewUp[i] * centerToEndDistance; + topWorld[i] = centerWorld[i] + viewUp[i] * centerToEndDistance; + leftWorld[i] = centerWorld[i] - viewRight[i] * centerToEndDistance; + rightWorld[i] = centerWorld[i] + viewRight[i] * centerToEndDistance; + } + + const ellipseWorldCoordinates = [ + bottomWorld, + topWorld, + leftWorld, + rightWorld, + ] as [Types.Point3, Types.Point3, Types.Point3, Types.Point3]; + return ellipseWorldCoordinates; +} diff --git a/utils/ExampleRunner/example-info.json b/utils/ExampleRunner/example-info.json index 2d77fdfa7f..89759e0b80 100644 --- a/utils/ExampleRunner/example-info.json +++ b/utils/ExampleRunner/example-info.json @@ -176,6 +176,10 @@ "smoothing": { "name": "Image Smoothing For Stack & Volume Viewports", "description": "Demonstrates how to apply image smoothing to stack and volume viewports" + }, + "axialBasedImageStretching": { + "name": "Axial-based Image Stretching", + "description": "Here we demonstrate axial based stretching with annotation and segmentation tools" } }, "tools-basic": {