Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 45 additions & 13 deletions packages/core/src/RenderingEngine/StackViewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -977,11 +977,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<ICamera> {
Expand Down Expand Up @@ -2522,14 +2546,15 @@ class StackViewport extends Viewport {
image,
this._imageData
);
const wasStackInvalidated = this.stackInvalidated;

// const activeCamera = this.getRenderer().getActiveCamera();
const viewPresentation = this.getViewPresentation();

// 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);

Expand Down Expand Up @@ -2617,21 +2642,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;
Expand Down
56 changes: 56 additions & 0 deletions packages/core/test/stackViewport_gpu_render_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 28 additions & 5 deletions packages/core/test/utilities/splitImageIdsBy4DTags.jest.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,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');
});
});

Expand Down Expand Up @@ -67,6 +74,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
33 changes: 20 additions & 13 deletions packages/metadata/src/utilities/splitImageIdsBy4DTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,38 +31,45 @@ 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}`;
}

/**
* Handles multiframe 4D splitting using TimeSlotVector (0054,0070).
* 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 {
Expand Down
56 changes: 49 additions & 7 deletions utils/test/testUtilsImageLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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',
Expand Down Expand Up @@ -134,20 +174,22 @@ 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 = {
photometricInterpretation,
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,
Comment thread
sedghi marked this conversation as resolved.
};

Expand Down
Loading