From 84686d7c3a743b2f23422794d17d52c7b0b927bd Mon Sep 17 00:00:00 2001 From: sedghi Date: Sun, 22 Mar 2026 09:01:13 -0400 Subject: [PATCH 1/2] feat(RenderingEngine): enhance StackViewport to preserve properties during actor recreation - Updated _setPropertiesFromCache to retrieve additional properties (colormap, sharpening, smoothing) from cache. - Modified logic in getCameraCPU to ensure stack properties are maintained when actor recreation is triggered by data type mismatches. - Added unit test to verify preservation of stack properties under specific conditions. - Enhanced fakeImageLoader to support multiple data types for testing purposes. --- .../core/src/RenderingEngine/StackViewport.ts | 58 ++++++++++++++----- .../test/stackViewport_gpu_render_test.js | 56 ++++++++++++++++++ utils/test/testUtilsImageLoader.js | 56 +++++++++++++++--- 3 files changed, 150 insertions(+), 20 deletions(-) diff --git a/packages/core/src/RenderingEngine/StackViewport.ts b/packages/core/src/RenderingEngine/StackViewport.ts index c35f7f2b8d..18c1a3e03b 100644 --- a/packages/core/src/RenderingEngine/StackViewport.ts +++ b/packages/core/src/RenderingEngine/StackViewport.ts @@ -969,11 +969,35 @@ class StackViewport extends Viewport { private _setPropertiesFromCache(): void { const voiRange = this._getVOIFromCache(); - const { interpolationType, invert } = this; + const { + colormap, + VOILUTFunction, + interpolationType, + invert, + sharpening, + smoothing, + } = this.getProperties(); + + if (typeof VOILUTFunction !== 'undefined') { + this.setVOILUTFunction(VOILUTFunction, true); + } this.setVOI(voiRange); + + if (typeof colormap !== 'undefined') { + this.setColormap(colormap); + } + this.setInterpolationType(interpolationType); this.setInvertColor(invert); + + if (typeof sharpening !== 'undefined') { + this.setSharpening(sharpening); + } + + if (typeof smoothing !== 'undefined') { + this.setSmoothing(smoothing); + } } private getCameraCPU(): Partial { @@ -2428,6 +2452,7 @@ class StackViewport extends Viewport { image, this._imageData ); + const wasStackInvalidated = this.stackInvalidated; // const activeCamera = this.getRenderer().getActiveCamera(); const viewPresentation = this.getViewPresentation(); @@ -2435,7 +2460,7 @@ class StackViewport extends Viewport { // Cache camera props so we can trigger one camera changed event after // The full transition. // const previousCameraProps = this.getCamera(); - if (sameImageData && !this.stackInvalidated) { + if (sameImageData && !wasStackInvalidated) { // 3a. If we can reuse it, replace the scalar data under the hood this._updateVTKImageDataFromCornerstoneImage(image); @@ -2523,21 +2548,28 @@ class StackViewport extends Viewport { //Todo: i'm not sure if this is needed // activeCamera.setFreezeFocalPoint(true); - const monochrome1 = - imagePixelModule.photometricInterpretation === 'MONOCHROME1'; + if (wasStackInvalidated) { + const monochrome1 = + imagePixelModule.photometricInterpretation === 'MONOCHROME1'; - // invalidate the stack so that we can set the voi range - this.stackInvalidated = true; + // Keep the metadata-driven reset path only for explicit stack invalidation. + this.stackInvalidated = true; - const voiRange = this._getInitialVOIRange(image); - this.setVOI(voiRange, { - forceRecreateLUTFunction: !!monochrome1, - }); + const voiRange = this._getInitialVOIRange(image); + this.setVOI(voiRange, { + forceRecreateLUTFunction: !!monochrome1, + }); - this.initialInvert = !!monochrome1; + this.initialInvert = !!monochrome1; - // should carry over the invert color from the previous image if has been applied - this.setInvertColor(this.invert || this.initialInvert); + // should carry over the invert color from the previous image if has been applied + this.setInvertColor(this.invert || this.initialInvert); + } else { + // Actor recreation can still be needed when imageData cannot be reused + // (for example, a scalar type change), but viewport properties remain stack-based. + this.stackInvalidated = true; + this._setPropertiesFromCache(); + } // Saving position of camera on render, to cache the panning this.stackInvalidated = false; diff --git a/packages/core/test/stackViewport_gpu_render_test.js b/packages/core/test/stackViewport_gpu_render_test.js index 3db4eefb7f..d0dbda759d 100644 --- a/packages/core/test/stackViewport_gpu_render_test.js +++ b/packages/core/test/stackViewport_gpu_render_test.js @@ -860,6 +860,62 @@ describe('renderingCore -- Stack', () => { } }); + it('Should preserve stack properties when actor recreation is only caused by a data type mismatch', function (done) { + testUtils.createViewports(renderingEngine, { + viewportId, + orientation: Enums.OrientationAxis.AXIAL, + }); + + const imageInfo1 = { + loader: 'fakeImageLoader', + name: 'imageURI', + rows: 11, + columns: 11, + barStart: 4, + barWidth: 1, + xSpacing: 1, + ySpacing: 1, + sliceIndex: 0, + dataType: 'Uint8Array', + }; + const imageInfo2 = { + ...imageInfo1, + sliceIndex: 1, + dataType: 'Uint16Array', + }; + + const imageId1 = testUtils.encodeImageIdInfo(imageInfo1); + const imageId2 = testUtils.encodeImageIdInfo(imageInfo2); + + const vp = renderingEngine.getViewport(viewportId); + const expectedVOIRange = { lower: -260, upper: 140 }; + const expectedColormap = { name: 'hsv' }; + + vp.setStack([imageId1, imageId2], 0) + .then(() => { + vp.setProperties({ + colormap: expectedColormap, + interpolationType: InterpolationType.NEAREST, + voiRange: expectedVOIRange, + invert: true, + }); + + return vp.setImageIdIndex(1); + }) + .then(() => { + const props = vp.getProperties(); + + expect(vp.stackActorReInitialized).toBe(true); + expect(props.interpolationType).toBe(InterpolationType.NEAREST); + expect(props.invert).toBe(true); + expect(props.colormap).toEqual(expectedColormap); + expect(props.voiRange).toEqual(expectedVOIRange); + + done(); + }) + .catch(done.fail); + }); + it('Should be able to resetProperties API', function (done) { const element = testUtils.createViewports(renderingEngine, { viewportId, diff --git a/utils/test/testUtilsImageLoader.js b/utils/test/testUtilsImageLoader.js index e2dcc5ac8c..e06f3e9dff 100644 --- a/utils/test/testUtilsImageLoader.js +++ b/utils/test/testUtilsImageLoader.js @@ -5,6 +5,34 @@ import { getVerticalBarRGBImage, } from './testUtilsPixelData'; +const typedArrayConstructors = { + Uint8Array, + Uint16Array, + Float32Array, +}; + +function getTypedArrayConstructor(dataType = 'Uint8Array') { + const TypedArrayConstructor = typedArrayConstructors[dataType]; + + if (!TypedArrayConstructor) { + throw new Error(`Unsupported fake image data type: ${dataType}`); + } + + return TypedArrayConstructor; +} + +function getBitsAllocated(dataType = 'Uint8Array') { + switch (dataType) { + case 'Uint16Array': + return 16; + case 'Float32Array': + return 32; + case 'Uint8Array': + default: + return 8; + } +} + /** * It creates an image based on the imageId name for testing purposes. It splits the imageId * based on "_" and deciphers each field of scheme, rows, columns, barStart, barWidth, x_spacing, y_spacing, rgb, and PT. @@ -26,11 +54,23 @@ import { */ const fakeImageLoader = (imageId) => { const imageInfo = decodeImageIdInfo(imageId); - const { rows, columns, barStart, barWidth, xSpacing, ySpacing, rgb, id } = - imageInfo; + const { + rows, + columns, + barStart, + barWidth, + xSpacing, + ySpacing, + rgb, + id, + dataType, + } = imageInfo; const numberOfComponents = rgb ? 3 : 1; - const pixelData = new Uint8Array(rows * columns * numberOfComponents); + const TypedArrayConstructor = getTypedArrayConstructor(dataType); + const pixelData = new TypedArrayConstructor( + rows * columns * numberOfComponents + ); const imageVoxelManager = utilities.VoxelManager.createImageVoxelManager({ height: rows, @@ -56,7 +96,7 @@ const fakeImageLoader = (imageId) => { rowPixelSpacing: ySpacing, columnPixelSpacing: xSpacing, getPixelData: () => imageVoxelManager.getScalarData(), - sizeInBytes: rows * columns * 1, // 1 byte for now + sizeInBytes: pixelData.byteLength, FrameOfReferenceUID: 'Stack_Frame_Of_Reference', imageFrame: { photometricInterpretation: rgb ? 'RGB' : 'MONOCHROME2', @@ -134,10 +174,12 @@ function fakeMetaDataProvider(type, imageId) { rgb, PT = false, id, + dataType, } = imageInfo; const modality = PT ? 'PT' : 'MR'; const photometricInterpretation = rgb ? 'RGB' : 'MONOCHROME2'; + const bitsAllocated = rgb ? 24 : getBitsAllocated(dataType); if (type === 'imagePixelModule') { const imagePixelModule = { @@ -145,9 +187,9 @@ function fakeMetaDataProvider(type, imageId) { rows, columns, samplesPerPixel: rgb ? 3 : 1, - bitsAllocated: rgb ? 24 : 8, - bitsStored: rgb ? 24 : 8, - highBit: rgb ? 24 : 8, + bitsAllocated, + bitsStored: bitsAllocated, + highBit: bitsAllocated, pixelRepresentation: 0, }; From 39f36248cb2fa93c3a780116747dd132b4865828 Mon Sep 17 00:00:00 2001 From: sedghi Date: Sun, 22 Mar 2026 10:34:42 -0400 Subject: [PATCH 2/2] feat(splitImageIds): enhance frame ID generation for various image formats - Updated `generateFrameImageId` to support DICOMweb and local loader image IDs, allowing for frame number replacement and appending. - Improved error handling by removing the requirement for a specific "/frames/" pattern in the base image ID. - Added unit tests to verify the new functionality, including handling of query parameters and local file image IDs. - Enhanced `retrieveFrameParameterIndex` and `isLoaded` functions to utilize regex for frame parameter detection. --- .../src/utilities/splitImageIdsBy4DTags.ts | 33 +++++++++++-------- .../utilities/splitImageIdsBy4DTags.jest.js | 33 ++++++++++++++++--- .../wadouri/dataSetCacheManager.ts | 2 +- .../wadouri/retrieveMultiframeDataset.ts | 4 ++- 4 files changed, 52 insertions(+), 20 deletions(-) diff --git a/packages/core/src/utilities/splitImageIdsBy4DTags.ts b/packages/core/src/utilities/splitImageIdsBy4DTags.ts index 14bff3bf65..42c17473e3 100644 --- a/packages/core/src/utilities/splitImageIdsBy4DTags.ts +++ b/packages/core/src/utilities/splitImageIdsBy4DTags.ts @@ -31,29 +31,36 @@ interface MultiframeSplitResult { * Generates frame-specific imageIds for a multiframe image. * Replaces the frame number in the imageId with the specified frame number (1-based). * - * @param baseImageId - The base imageId that must contain a "/frames/" pattern followed by a digit. - * Expected format: e.g., "wadouri:http://example.com/image/frames/1" or "wadors:/path/to/image.dcm/frames/1". - * The pattern "/frames/\d+" will be replaced with "/frames/" + frameNumber. + * @param baseImageId - The base imageId. + * Supported formats: + * - DICOMweb: "wadors:/path/to/image.dcm/frames/1" + * - Loader query param: "wadouri:http://example.com/image.dcm?frame=1" + * - Loader base image id without a frame suffix: "dicomfile:0" + * The frame designator is replaced or appended using the loader's convention. * @param frameNumber - The frame number to use (1-based) * @returns The imageId with the frame number replaced - * @throws {Error} If baseImageId does not contain the required "/frames/" pattern, throws an error - * with a clear message indicating the expected format. */ function generateFrameImageId( baseImageId: string, frameNumber: number ): string { const framePattern = /\/frames\/\d+/; + const queryFramePattern = /([?&])frame=\d+/; - if (!framePattern.test(baseImageId)) { - throw new Error( - `generateFrameImageId: baseImageId must contain a "/frames/" pattern followed by a digit. ` + - `Expected format: e.g., "wadouri:http://example.com/image/frames/1" or "wadors:/path/to/image.dcm/frames/1". ` + - `Received: ${baseImageId}` + if (framePattern.test(baseImageId)) { + return baseImageId.replace(framePattern, `/frames/${frameNumber}`); + } + + if (queryFramePattern.test(baseImageId)) { + return baseImageId.replace( + queryFramePattern, + (_, separator) => `${separator}frame=${frameNumber}` ); } - return baseImageId.replace(framePattern, `/frames/${frameNumber}`); + const separator = baseImageId.includes('?') ? '&' : '?'; + + return `${baseImageId}${separator}frame=${frameNumber}`; } /** @@ -61,8 +68,8 @@ function generateFrameImageId( * For NM Multi-frame images where frames are indexed by time slot and slice. * * @param imageIds - Array containing the base imageId (typically just one for multiframe). - * The base imageId must contain a "/frames/" pattern (e.g., "wadouri:http://example.com/image/frames/1"). - * See generateFrameImageId for format requirements. + * The base imageId can be either a DICOMweb frame imageId or a local loader + * imageId such as "dicomfile:0". * @returns Split result if multiframe 4D is detected, null otherwise */ function handleMultiframe4D(imageIds: string[]): MultiframeSplitResult | null { diff --git a/packages/core/test/utilities/splitImageIdsBy4DTags.jest.js b/packages/core/test/utilities/splitImageIdsBy4DTags.jest.js index a618a88786..98aeb71bad 100644 --- a/packages/core/test/utilities/splitImageIdsBy4DTags.jest.js +++ b/packages/core/test/utilities/splitImageIdsBy4DTags.jest.js @@ -35,13 +35,20 @@ describe('splitImageIdsBy4DTags - Multiframe 4D Functions', () => { expect(result).toBe('wadors:/path/to/image.dcm/frames/5'); }); - it('should throw an error when /frames/ pattern is missing', () => { - const baseImageId = 'wadors:/path/to/image.dcm'; + it('should replace the frame number for query-param-based imageIds', () => { + const baseImageId = 'wadouri:/path/to/image.dcm?frame=1'; const frameNumber = 5; + const result = generateFrameImageId(baseImageId, frameNumber); - expect(() => generateFrameImageId(baseImageId, frameNumber)).toThrow( - 'baseImageId must contain a "/frames/" pattern followed by a digit' - ); + expect(result).toBe('wadouri:/path/to/image.dcm?frame=5'); + }); + + it('should append a frame parameter for local file imageIds', () => { + const baseImageId = 'dicomfile:0'; + const frameNumber = 5; + const result = generateFrameImageId(baseImageId, frameNumber); + + expect(result).toBe('dicomfile:0?frame=5'); }); }); @@ -69,6 +76,22 @@ describe('splitImageIdsBy4DTags - Multiframe 4D Functions', () => { ]); }); + it('should successfully split local multiframe imageIds without a /frames/ suffix', () => { + mockMetaDataGet.mockReturnValue({ + NumberOfFrames: 4, + TimeSlotVector: [1, 1, 2, 2], + }); + + const result = handleMultiframe4D(['dicomfile:0']); + + expect(result).not.toBeNull(); + expect(result.splittingTag).toBe('TimeSlotVector'); + expect(result.imageIdGroups).toEqual([ + ['dicomfile:0?frame=1', 'dicomfile:0?frame=2'], + ['dicomfile:0?frame=3', 'dicomfile:0?frame=4'], + ]); + }); + it('should handle SliceVector correctly and sort slices within time slots', () => { mockMetaDataGet.mockReturnValue({ NumberOfFrames: 6, diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/dataSetCacheManager.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/dataSetCacheManager.ts index ad86feee30..cb1f90e070 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/dataSetCacheManager.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/dataSetCacheManager.ts @@ -39,7 +39,7 @@ function isLoaded(uri: string): boolean { function get(uri: string): DataSet { let dataSet; - if (uri.includes('&frame=')) { + if (/[?&]frame=/.test(uri)) { const { frame, dataSet: multiframeDataSet } = multiframeDataset.retrieveMultiframeDataset(uri); diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/retrieveMultiframeDataset.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/retrieveMultiframeDataset.ts index 1b9f198dad..d2583156d0 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/retrieveMultiframeDataset.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/retrieveMultiframeDataset.ts @@ -26,7 +26,9 @@ function _isMultiframeDataset(dataSet) { } function retrieveFrameParameterIndex(uri) { - return uri.indexOf('&frame='); + const match = uri.match(/[?&]frame=/); + + return match ? match.index : -1; } function retrieveMultiframeDataset(uri) {