From a8da6e8a9eec23170c34b3cdcaaaef6abcd73eec Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Wed, 12 Aug 2026 22:18:05 +0200 Subject: [PATCH 01/13] feat(voi): support VOI LUT Function LINEAR_EXACT/SIGMOID & VOI LUT Seq Adds full DICOM VOI LUT Function (0028,1056) support, applies the VOI LUT Sequence (0028,3010) on the GPU path, and fixes the VOI defects around them. - Normalize 0028,1056 across provider shapes (string, single element array, padded/mixed case) instead of indexing the string, which turned "SIGMOID" into "S" - Fall back to LINEAR with a warning on an unknown VOI LUT Function rather than throwing "Invalid VOI LUT function" - Build the transfer function from the VOI LUT Sequence so CR/DX/MG images that rely on their VOI LUT stop rendering as a flat linear window - Separate the coincident nodes of a zero width window, so a WW=1 frame no longer leaves a stuck step-function LUT that survives every later voi change - Keep the prescaled PT 0-5 default when the metadata also carries a window, matching the stack viewport - Take the window from the nearest imageId that has one instead of only the middle one, whose metadata may not be registered - Keep the VOI LUT Function attached to the window in setDefaultVolumeVOI, which dropped it and windowed LINEAR_EXACT and SIGMOID volumes as LINEAR - Implement LINEAR, LINEAR_EXACT (C.11.2.1.3.2) and SIGMOID (C.11.2.1.3.1) on the CPU fallback path, and allow setVOILUTFunction under CPU rendering - Stretch the VOI LUT Sequence curve over the current range so window level reshapes it, as the sampled sigmoid already does; requesting a VOI LUT Function opts out - Normalize the sequence across wadouri, naturalized dcmjs and raw DICOMweb JSON, and expose it from wadors and @cornerstonejs/metadata - Add a voiLutFunction example that loads a local DICOM P10 and shows how its VOI resolves on the GPU and CPU paths - Read the window from the Frame VOI LUT macro of enhanced multiframe SOPs, which wadouri left nested one level below the root so viewports fell back to the image min/max Fixes #938 Fixes #985 Fixes #1141 Fixes #1767 Fixes #1806 Fixes #2104 Fixes #2236 Fixes #2520 Fixes #2716 Fixes #2733 Fixes #2745 Fixes #2844 --- .../Planar/CpuImageSliceRenderPath.ts | 9 +- .../Planar/VtkImageMapperRenderPath.ts | 2 + .../core/src/RenderingEngine/StackViewport.ts | 252 ++++++++++--- .../cpuFallback/rendering/computeAutoVoi.ts | 19 +- .../rendering/doesImageNeedToBeRendered.ts | 2 + .../cpuFallback/rendering/generateColorLUT.ts | 8 +- .../cpuFallback/rendering/generateLut.ts | 7 +- .../rendering/getDefaultViewport.ts | 8 + .../helpers/cpuFallback/rendering/getLut.ts | 5 +- .../cpuFallback/rendering/getVOILut.ts | 123 ++++++- .../cpuFallback/rendering/renderColorImage.ts | 14 +- .../cpuFallback/rendering/saveLastRendered.ts | 1 + .../helpers/planarImageRendering.ts | 91 ++++- .../helpers/setDefaultVolumeVOI.ts | 113 ++++-- packages/core/src/types/CPUFallbackLUT.ts | 4 + .../src/types/CPUFallbackRenderingTools.ts | 2 + .../core/src/types/CPUFallbackViewport.ts | 2 +- packages/core/src/types/IImage.ts | 1 + .../createLinearRGBTransferFunction.ts | 16 + .../createVOILUTSequenceTransferFunction.ts | 203 +++++++++++ .../utilities/getVOIRangeFromWindowLevel.ts | 2 +- packages/core/src/utilities/index.ts | 10 + packages/core/src/utilities/voiLUTFunction.ts | 92 +++++ packages/core/src/utilities/windowLevel.ts | 73 ++-- .../core/test/planarComputedCamera.jest.js | 1 + .../core/test/utilities/getVOILut.jest.js | 231 ++++++++++++ .../utilities/setDefaultVolumeVOI.jest.js | 141 ++++++++ .../test/utilities/voiLUTFunction.jest.js | 114 ++++++ .../__tests__/normalizeVOILUTSequence.spec.ts | 110 ++++++ .../src/__tests__/wadouriDataSetLayer.spec.ts | 142 +++++++- .../src/imageLoader/createImage.ts | 29 +- .../imageLoader/normalizeVOILUTSequence.ts | 174 +++++++++ .../wadors/metaData/metaDataProvider.ts | 9 +- .../imageLoader/wadouri/metaData/getLUTs.ts | 29 +- .../wadouri/metaData/metaDataProvider.ts | 30 +- .../metadata/src/utilities/modules/voiLut.ts | 3 + .../tools/examples/voiLutFunction/index.ts | 342 ++++++++++++++++++ utils/ExampleRunner/example-info.json | 4 + 38 files changed, 2255 insertions(+), 163 deletions(-) create mode 100644 packages/core/src/utilities/createVOILUTSequenceTransferFunction.ts create mode 100644 packages/core/src/utilities/voiLUTFunction.ts create mode 100644 packages/core/test/utilities/getVOILut.jest.js create mode 100644 packages/core/test/utilities/setDefaultVolumeVOI.jest.js create mode 100644 packages/core/test/utilities/voiLUTFunction.jest.js create mode 100644 packages/dicomImageLoader/src/__tests__/normalizeVOILUTSequence.spec.ts create mode 100644 packages/dicomImageLoader/src/imageLoader/normalizeVOILUTSequence.ts create mode 100644 packages/tools/examples/voiLutFunction/index.ts diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath.ts index fcf17ff88a..95d5a1bbad 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath.ts @@ -415,15 +415,20 @@ function applyDataPresentation( viewport.invert = props?.invert ?? false; if (voiRange) { + // The range was produced from the window with the image's VOI LUT Function + // (getDefaultImageVOIRange), and the CPU render path windows it with that + // same function, so converting back with any other one shifts the window + const voiLUTFunction = enabledElement.image?.voiLUTFunction; const { windowCenter, windowWidth } = toWindowLevel( voiRange.lower, - voiRange.upper + voiRange.upper, + voiLUTFunction ); viewport.voi = { windowCenter, windowWidth, - voiLUTFunction: enabledElement.image?.voiLUTFunction, + voiLUTFunction, }; } diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/VtkImageMapperRenderPath.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/VtkImageMapperRenderPath.ts index b7b230e4ea..3c1a501ca5 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/VtkImageMapperRenderPath.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/VtkImageMapperRenderPath.ts @@ -156,6 +156,7 @@ export class VtkImageMapperRenderPath actor: rendering.actor, defaultVOIRange: rendering.defaultVOIRange, defaultVOILUTFunction: rendering.currentImage?.voiLUTFunction, + defaultVOILUT: rendering.currentImage?.voiLUT, props: { interpolationType: InterpolationType.LINEAR, ...dataPresentation, @@ -419,6 +420,7 @@ async function updateRenderedImage(args: { actor, defaultVOIRange: rendering.defaultVOIRange, defaultVOILUTFunction: image.voiLUTFunction, + defaultVOILUT: image.voiLUT, props: { interpolationType: dataPresentation?.interpolationType ?? InterpolationType.LINEAR, diff --git a/packages/core/src/RenderingEngine/StackViewport.ts b/packages/core/src/RenderingEngine/StackViewport.ts index f55a00e1a1..f8da91f75d 100644 --- a/packages/core/src/RenderingEngine/StackViewport.ts +++ b/packages/core/src/RenderingEngine/StackViewport.ts @@ -15,6 +15,7 @@ import type { ActorEntry, CPUFallbackColormapData, CPUFallbackEnabledElement, + CPUFallbackLUT, CPUIImageData, ColormapPublic, EventTypes, @@ -52,6 +53,14 @@ import { import * as windowLevelUtil from '../utilities/windowLevel'; import createLinearRGBTransferFunction from '../utilities/createLinearRGBTransferFunction'; import createSigmoidRGBTransferFunction from '../utilities/createSigmoidRGBTransferFunction'; +import createVOILUTSequenceTransferFunction, { + getVOILUTSequenceRange, + isRenderableVOILUT, +} from '../utilities/createVOILUTSequenceTransferFunction'; +import { + getValidVOILUTFunction, + normalizeVOILUTFunction, +} from '../utilities/voiLUTFunction'; import { updateVTKImageDataWithCornerstoneImage } from '../utilities/updateVTKImageDataWithCornerstoneImage'; import triggerEvent from '../utilities/triggerEvent'; import { isEqual } from '../utilities/isEqual'; @@ -179,6 +188,12 @@ class StackViewport extends Viewport { private sharpening: number = 0; private smoothing: number = 0; private VOILUTFunction: VOILUTFunctionType; + // Whether the transfer function currently on the actor was built from the + // image's VOI LUT Sequence rather than from a window width/center + private voiLUTSequenceApplied = false; + // Set once the application asks for a specific VOI LUT Function, which opts + // out of the image's own VOI LUT Sequence - see _getVOILUTSequenceToApply + private voiLUTFunctionSetByUser = false; // private invert = false; // The initial invert of the image loaded as opposed to the invert status of the viewport itself (see above). @@ -790,6 +805,7 @@ class StackViewport extends Viewport { } if (typeof VOILUTFunction !== 'undefined') { + this.voiLUTFunctionSetByUser = true; this.setVOILUTFunction(VOILUTFunction, suppressEvents); } @@ -872,12 +888,20 @@ class StackViewport extends Viewport { public resetProperties(): void { this.cpuRenderingInvalidated = true; this.voiUpdatedWithSetProperties = false; + // Back to the image's own VOI, which includes its VOI LUT Function and its + // VOI LUT Sequence. Leaving VOILUTFunction on the user's choice made reset + // keep windowing a LINEAR image as SIGMOID/LINEAR_EXACT; normalize rather + // than validate here so an image without one leaves it unset and the + // per image fallbacks apply. + this.voiLUTFunctionSetByUser = false; + this.VOILUTFunction = normalizeVOILUTFunction(this.csImage?.voiLUTFunction); this.viewportStatus = ViewportStatus.PRE_RENDER; this.fillWithBackgroundColor(); if (this.useCPURendering) { this._cpuFallbackEnabledElement.renderingTools = {}; + this._syncCPUVOILUTSequence(); } this._resetProperties(); @@ -904,26 +928,35 @@ class StackViewport extends Viewport { this.setInterpolationType(InterpolationType.LINEAR); - if (!this.useCPURendering) { - const transferFunction = this.getTransferFunction(); - setTransferFunctionNodes( - transferFunction, - this.initialTransferFunctionNodes - ); + if (this.useCPURendering) { + return; + } + + if (this.voiLUTSequenceApplied) { + this.colormap = undefined; + return; + } - const nodes = getTransferFunctionNodes(transferFunction); + const transferFunction = this.getTransferFunction(); + setTransferFunctionNodes( + transferFunction, + this.initialTransferFunctionNodes + ); - const RGBPoints = nodes.reduce((acc, node) => { - acc.push(node[0], node[1], node[2], node[3]); - return acc; - }, []); + const nodes = getTransferFunctionNodes(transferFunction); - const defaultActor = this.getDefaultActor(); - const matchedColormap = colormapUtils.findMatchingColormap( - RGBPoints, - defaultActor.actor - ); + const RGBPoints = nodes.reduce((acc, node) => { + acc.push(node[0], node[1], node[2], node[3]); + return acc; + }, []); + + const defaultActor = this.getDefaultActor(); + const matchedColormap = colormapUtils.findMatchingColormap( + RGBPoints, + defaultActor.actor + ); + if (matchedColormap) { this.setColormap(matchedColormap); } } @@ -1375,13 +1408,25 @@ class StackViewport extends Viewport { voiLUTFunction: VOILUTFunctionType, suppressEvents?: boolean ): void { - if (this.useCPURendering) { - throw new Error('VOI LUT function is not supported in CPU rendering'); - } - // make sure the VOI LUT function is valid in the VOILUTFunctionType which is enum const newVOILUTFunction = this._getValidVOILUTFunction(voiLUTFunction); + if (this.useCPURendering) { + // The CPU path builds its 8 bit display LUT from viewport.voi, so + // switching function means regenerating that LUT on the next render. + this.VOILUTFunction = newVOILUTFunction; + // getVOILut gives the image's VOI LUT Sequence precedence over the + // function, so the sequence has to come off the viewport for an explicit + // request to have any effect. This is the CPU side of the opt out the GPU + // path does in _getVOILUTSequenceToApply; without it setting a VOI LUT + // Function changed the render on GPU and did nothing on CPU. + this._syncCPUVOILUTSequence(); + this.cpuRenderingInvalidated = true; + this.setVOI(this.voiRange, { suppressEvents }); + + return; + } + let forceRecreateLUTFunction = false; if (this.VOILUTFunction !== newVOILUTFunction) { forceRecreateLUTFunction = true; @@ -1509,13 +1554,18 @@ class StackViewport extends Viewport { private setVOICPU(voiRange: VOIRange, options: SetVOIOptions = {}): void { const { suppressEvents = false } = options; - // TODO: Account for VOILUTFunction const { viewport, image } = this._cpuFallbackEnabledElement; if (!viewport || !image) { return; } + // The VOI LUT function decides how a window maps to display values, so the + // same function has to be used for both directions of the conversion below + // and be handed to the CPU render path via viewport.voi. + const voiLUTFunction = + this.VOILUTFunction ?? getValidVOILUTFunction(image.voiLUTFunction); + if (typeof voiRange === 'undefined') { const { windowWidth: ww, windowCenter: wc } = image; @@ -1524,30 +1574,33 @@ class StackViewport extends Viewport { viewport.voi = { windowWidth: wwToUse, windowCenter: wcToUse, - voiLUTFunction: image.voiLUTFunction, + voiLUTFunction, }; const { lower, upper } = getVOIRangeFromWindowLevel( wwToUse, wcToUse, - image.voiLUTFunction + voiLUTFunction ); voiRange = { lower, upper }; } else { const { lower, upper } = voiRange; const { windowCenter, windowWidth } = windowLevelUtil.toWindowLevel( lower, - upper + upper, + voiLUTFunction ); if (!viewport.voi) { viewport.voi = { windowWidth: 0, windowCenter: 0, - voiLUTFunction: image.voiLUTFunction, + voiLUTFunction, }; } + viewport.voi.voiLUTFunction = voiLUTFunction; + viewport.voi.windowWidth = windowWidth; viewport.voi.windowCenter = windowCenter; } @@ -1578,6 +1631,28 @@ class StackViewport extends Viewport { return imageActor.getProperty().getRGBTransferFunction(0); } + /** + * Builds the transfer function for a VOI range: the image's VOI LUT Sequence + * curve stretched over that range when one applies, otherwise the analytic + * VOI LUT Function. + */ + private _createVOITransferFunction( + voiRange: VOIRange, + voiLUTSequence?: CPUFallbackLUT + ): vtkColorTransferFunction | undefined { + if (voiLUTSequence) { + return createVOILUTSequenceTransferFunction(voiLUTSequence, { voiRange }); + } + + if (this.VOILUTFunction === VOILUTFunctionType.SAMPLED_SIGMOID) { + return createSigmoidRGBTransferFunction(voiRange); + } + + return createLinearRGBTransferFunction( + voiRange + ) as vtkColorTransferFunction; + } + private setVOIGPU(voiRange: VOIRange, options: SetVOIOptions = {}): void { const { suppressEvents = false, @@ -1585,11 +1660,15 @@ class StackViewport extends Viewport { voiUpdatedWithSetProperties = false, } = options; + const voiLUTSequence = this._getVOILUTSequenceToApply(); + const useVOILUTSequence = !!voiLUTSequence; + if ( voiRange && this.voiRange && this.voiRange.lower === voiRange.lower && this.voiRange.upper === voiRange.upper && + useVOILUTSequence === this.voiLUTSequenceApplied && !forceRecreateLUTFunction && !this.stackInvalidated ) { @@ -1624,30 +1703,53 @@ class StackViewport extends Viewport { const isSigmoidTFun = this.VOILUTFunction === VOILUTFunctionType.SAMPLED_SIGMOID; - // use the old cfun if it exists for linear case - if (isSigmoidTFun || !transferFunction || forceRecreateLUTFunction) { - const transferFunctionCreator = isSigmoidTFun - ? createSigmoidRGBTransferFunction - : createLinearRGBTransferFunction; + // A VOI LUT Sequence carries its own nonlinear curve, so it must be + // rebuilt as a whole (there is no range to slide) - same as the sigmoid. + // The function also has to be recreated when we transition between a + // sequence and a window, since the two are not interchangeable by range. + const recreateForVOILUTSequence = + useVOILUTSequence !== this.voiLUTSequenceApplied; - transferFunction = transferFunctionCreator( - voiRangeToUse - ) as vtkColorTransferFunction; + if ( + isSigmoidTFun || + useVOILUTSequence || + recreateForVOILUTSequence || + !transferFunction || + forceRecreateLUTFunction + ) { + const nextTransferFunction = this._createVOITransferFunction( + voiRangeToUse, + voiLUTSequence + ); - if (this.invert) { - invertRgbTransferFunction(transferFunction); - } + // _createVOITransferFunction returns undefined for a VOI LUT Sequence it + // cannot use; keep the previous function rather than blanking the image + if (nextTransferFunction) { + transferFunction = nextTransferFunction as vtkColorTransferFunction; + + if (this.invert) { + invertRgbTransferFunction(transferFunction); + } - imageActor.getProperty().setRGBTransferFunction(0, transferFunction); - this.initialTransferFunctionNodes = - getTransferFunctionNodes(transferFunction); + imageActor.getProperty().setRGBTransferFunction(0, transferFunction); + + // Only _resetProperties consumes these, and it rebuilds a VOI LUT + // Sequence curve from the LUT itself rather than replaying nodes - so + // reading back a thousand nodes here on every window level move would be + // pure overhead. + if (!useVOILUTSequence) { + this.initialTransferFunctionNodes = + getTransferFunctionNodes(transferFunction); + } + } } - if (!isSigmoidTFun) { + if (!isSigmoidTFun && !useVOILUTSequence) { // @ts-ignore vtk type error transferFunction.setRange(voiRangeToUse.lower, voiRangeToUse.upper); } + this.voiLUTSequenceApplied = useVOILUTSequence; this.voiRange = voiRangeToUse; // if voiRange is set by setProperties we need to lock it if it is not locked already @@ -2730,10 +2832,64 @@ class StackViewport extends Viewport { } } + /** + * The VOI LUT Sequence (0028,3010) of the displayed image, when it should + * drive the display instead of an analytic VOI LUT Function. + * + * DICOM allows a window and a sequence to both be present and lets the + * application pick (C.11.2.1); like the legacy cornerstone renderer we prefer + * the sequence, since a file that ships an explicit VOI LUT expects that + * curve. Window level interaction does not disable it - the curve is stretched + * over the new range instead, the same way the sampled sigmoid is rebuilt from + * a new window - so the shape the file specified survives interaction. + * + * Asking for a VOI LUT Function explicitly (`setProperties({ VOILUTFunction })`) + * is the way to opt out and get a plain analytic window instead. + */ + /** + * Keeps the CPU fallback viewport's VOI LUT Sequence in step with + * {@link _getVOILUTSequenceToApply}, which is what decides the same question + * on the GPU path: the image's sequence drives the display unless the user + * asked for a VOI LUT Function explicitly, in which case it has to be off the + * viewport - `getVOILut` prefers `viewport.voiLUT` over the function and the + * request would otherwise be silently ignored. + */ + private _syncCPUVOILUTSequence(): void { + const { viewport, image } = this._cpuFallbackEnabledElement ?? {}; + + if (!viewport) { + return; + } + + viewport.voiLUT = this._getVOILUTSequenceToApply(image ?? this.csImage); + } + + private _getVOILUTSequenceToApply(image: IImage = this.csImage) { + if (this.voiLUTFunctionSetByUser) { + return undefined; + } + + const voiLUT = image?.voiLUT; + + return isRenderableVOILUT(voiLUT) ? voiLUT : undefined; + } + private _getInitialVOIRange(image: IImage) { if (this.voiRange && this.voiUpdatedWithSetProperties) { return this.voiRange; } + + // When the VOI LUT Sequence drives the display, its own input domain is the + // range - the curve is defined against modality LUT output, not relative to + // a window, so starting from the file's Window Center/Width (which DICOM + // allows alongside the sequence) would stretch the curve before it has ever + // been shown unmodified. + const voiLUT = this._getVOILUTSequenceToApply(image); + + if (voiLUT) { + return getVOILUTSequenceRange(voiLUT); + } + const { windowCenter, windowWidth, voiLUTFunction } = image; let voiRange = getVOIRangeFromWindowLevel( @@ -3305,6 +3461,13 @@ class StackViewport extends Viewport { } private _getVOIRangeForCurrentImage() { + // a VOI LUT Sequence defines the range it is mapped over + const voiLUT = this._getVOILUTSequenceToApply(); + + if (voiLUT) { + return getVOILUTSequenceRange(voiLUT); + } + const { windowCenter, windowWidth, voiLUTFunction } = this.csImage; return getVOIRangeFromWindowLevel( @@ -3317,14 +3480,7 @@ class StackViewport extends Viewport { private _getValidVOILUTFunction( voiLUTFunction: VOILUTFunctionType | unknown ): VOILUTFunctionType { - if ( - !Object.values(VOILUTFunctionType).includes( - voiLUTFunction as VOILUTFunctionType - ) - ) { - return VOILUTFunctionType.LINEAR; - } - return voiLUTFunction as VOILUTFunctionType; + return getValidVOILUTFunction(voiLUTFunction); } /** diff --git a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/computeAutoVoi.ts b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/computeAutoVoi.ts index 14eb8a8b87..fb7cb4da84 100644 --- a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/computeAutoVoi.ts +++ b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/computeAutoVoi.ts @@ -1,3 +1,4 @@ +import { normalizeVOILUTFunction } from '../../../../utilities/voiLUTFunction'; import type { IImage, CPUFallbackViewport } from '../../../../types'; /** @@ -14,8 +15,13 @@ export default function computeAutoVoi( return; } - const maxVoi = image.maxPixelValue * image.slope + image.intercept; - const minVoi = image.minPixelValue * image.slope + image.intercept; + // A prescaled image already has the modality LUT baked into its pixel values + // (and therefore into min/maxPixelValue), so applying slope/intercept again + // here would compute a window for the wrong value range. + const slope = image.isPreScaled ? 1 : image.slope; + const intercept = image.isPreScaled ? 0 : image.intercept; + const maxVoi = image.maxPixelValue * slope + intercept; + const minVoi = image.minPixelValue * slope + intercept; const ww = maxVoi - minVoi; const wc = (maxVoi + minVoi) / 2; @@ -23,11 +29,14 @@ export default function computeAutoVoi( viewport.voi = { windowWidth: ww, windowCenter: wc, - voiLUTFunction: image.voiLUTFunction, + voiLUTFunction: normalizeVOILUTFunction(image.voiLUTFunction), }; } else { viewport.voi.windowWidth = ww; viewport.voi.windowCenter = wc; + viewport.voi.voiLUTFunction ??= normalizeVOILUTFunction( + image.voiLUTFunction + ); } } @@ -42,7 +51,7 @@ function hasVoi(viewport: CPUFallbackViewport): boolean { return ( hasLut || - (viewport.voi.windowWidth !== undefined && - viewport.voi.windowCenter !== undefined) + (viewport.voi?.windowWidth !== undefined && + viewport.voi?.windowCenter !== undefined) ); } diff --git a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/doesImageNeedToBeRendered.ts b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/doesImageNeedToBeRendered.ts index 53b6be1be6..8bf88327c2 100644 --- a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/doesImageNeedToBeRendered.ts +++ b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/doesImageNeedToBeRendered.ts @@ -25,6 +25,8 @@ export default function doesImageNeedToBeRendered( enabledElement.viewport.voi.windowCenter || lastRenderedViewport.windowWidth !== enabledElement.viewport.voi.windowWidth || + lastRenderedViewport.voiLUTFunction !== + enabledElement.viewport.voi.voiLUTFunction || lastRenderedViewport.invert !== enabledElement.viewport.invert || lastRenderedViewport.rotation !== enabledElement.viewport.rotation || lastRenderedViewport.hflip !== enabledElement.viewport.hflip || diff --git a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/generateColorLUT.ts b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/generateColorLUT.ts index 309f0a76fc..c059174324 100644 --- a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/generateColorLUT.ts +++ b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/generateColorLUT.ts @@ -1,4 +1,5 @@ import getVOILUT from './getVOILut'; +import type VOILUTFunctionType from '../../../../enums/VOILUTFunctionType'; import type { IImage, CPUFallbackLUT } from '../../../../types'; /** @@ -10,6 +11,7 @@ import type { IImage, CPUFallbackLUT } from '../../../../types'; * @param windowCenter - The Window Center * @param invert - A boolean describing whether or not the image has been inverted * @param voiLUT- A Volume of Interest Lookup Table + * @param voiLUTFunction - VOI LUT Function (0028,1056) * * @returns A lookup table to apply to the image */ @@ -18,7 +20,8 @@ export default function generateColorLUT( windowWidth: number | number[], windowCenter: number | number[], invert: boolean, - voiLUT?: CPUFallbackLUT + voiLUT?: CPUFallbackLUT, + voiLUTFunction?: VOILUTFunctionType | string ) { const maxPixelValue = image.maxPixelValue; const minPixelValue = image.minPixelValue; @@ -35,7 +38,8 @@ export default function generateColorLUT( const vlutfn = getVOILUT( Array.isArray(windowWidth) ? windowWidth[0] : windowWidth, Array.isArray(windowCenter) ? windowCenter[0] : windowCenter, - voiLUT + voiLUT, + voiLUTFunction ); if (invert) { diff --git a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/generateLut.ts b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/generateLut.ts index 3a94729fe3..cc2b72625e 100644 --- a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/generateLut.ts +++ b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/generateLut.ts @@ -1,5 +1,6 @@ import getModalityLut from './getModalityLut'; import getVOILUT from './getVOILut'; +import type VOILUTFunctionType from '../../../../enums/VOILUTFunctionType'; import type { IImage, CPUFallbackLUT } from '../../../../types'; /** @@ -12,6 +13,7 @@ import type { IImage, CPUFallbackLUT } from '../../../../types'; * @param invert - A boolean describing whether or not the image has been inverted * @param modalityLUT - A modality Lookup Table * @param voiLUT - A Volume of Interest Lookup Table + * @param voiLUTFunction - VOI LUT Function (0028,1056) * * @returns A lookup table to apply to the image */ @@ -21,7 +23,8 @@ export default function ( windowCenter: number, invert: boolean, modalityLUT: CPUFallbackLUT, - voiLUT: CPUFallbackLUT + voiLUT: CPUFallbackLUT, + voiLUTFunction?: VOILUTFunctionType | string ): Uint8ClampedArray { const maxPixelValue = image.maxPixelValue; const minPixelValue = image.minPixelValue; @@ -37,7 +40,7 @@ export default function ( const lut = image.cachedLut.lutArray; const mlutfn = getModalityLut(image.slope, image.intercept, modalityLUT); - const vlutfn = getVOILUT(windowWidth, windowCenter, voiLUT); + const vlutfn = getVOILUT(windowWidth, windowCenter, voiLUT, voiLUTFunction); if (image.isPreScaled) { // if the image is already preScaled, it means that the slop and the intercept diff --git a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getDefaultViewport.ts b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getDefaultViewport.ts index f3ae88ab88..14749e95ce 100644 --- a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getDefaultViewport.ts +++ b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getDefaultViewport.ts @@ -1,5 +1,6 @@ import createViewport from './createViewport'; import getImageFitScale from './getImageFitScale'; +import { normalizeVOILUTFunction } from '../../../../utilities/voiLUTFunction'; import type { IImage, CPUFallbackColormap, @@ -34,10 +35,16 @@ export default function ( let voi; + // The VOI LUT Function decides how the window is applied (C.11.2.1.3), so it + // has to travel with the window itself - the CPU render path reads it off + // viewport.voi to pick the linear/linear exact/sigmoid mapping. + const voiLUTFunction = normalizeVOILUTFunction(image.voiLUTFunction); + if (modality === 'PT' && image.isPreScaled) { voi = { windowWidth: 5, windowCenter: 2.5, + voiLUTFunction, }; } else if ( image.windowWidth !== undefined && @@ -50,6 +57,7 @@ export default function ( windowCenter: Array.isArray(image.windowCenter) ? image.windowCenter[0] : image.windowCenter, + voiLUTFunction, }; } diff --git a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getLut.ts b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getLut.ts index ce7a659fb9..ad3dbaed91 100644 --- a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getLut.ts +++ b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getLut.ts @@ -23,6 +23,7 @@ export default function ( image.cachedLut !== undefined && image.cachedLut.windowCenter === viewport.voi.windowCenter && image.cachedLut.windowWidth === viewport.voi.windowWidth && + image.cachedLut.voiLUTFunction === viewport.voi.voiLUTFunction && lutMatches(image.cachedLut.modalityLUT, viewport.modalityLUT) && lutMatches(image.cachedLut.voiLUT, viewport.voiLUT) && image.cachedLut.invert === viewport.invert && @@ -40,11 +41,13 @@ export default function ( viewport.voi.windowCenter, viewport.invert, viewport.modalityLUT, - viewport.voiLUT + viewport.voiLUT, + viewport.voi.voiLUTFunction ); image.cachedLut.windowWidth = viewport.voi.windowWidth; image.cachedLut.windowCenter = viewport.voi.windowCenter; + image.cachedLut.voiLUTFunction = viewport.voi.voiLUTFunction; image.cachedLut.invert = viewport.invert; image.cachedLut.voiLUT = viewport.voiLUT; image.cachedLut.modalityLUT = viewport.modalityLUT; diff --git a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getVOILut.ts b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getVOILut.ts index 945322df19..801779e3d5 100644 --- a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getVOILut.ts +++ b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getVOILut.ts @@ -1,4 +1,7 @@ /* eslint no-bitwise: 0 */ +import VOILUTFunctionType from '../../../../enums/VOILUTFunctionType'; +import { getValidVOILUTFunction } from '../../../../utilities/voiLUTFunction'; +import type { CPUFallbackLUT } from '../../../../types'; /** * Volume of Interest Lookup Table Function @@ -14,6 +17,14 @@ * @module: VOILUT */ +// The CPU rendering path maps into 8 bit display values +const Y_MIN = 0; +const Y_MAX = 255; + +function clampToDisplayRange(value: number): number { + return Math.min(Math.max(value, Y_MIN), Y_MAX); +} + /** * Generates the linear VOI LUT function. * From the DICOM standard: @@ -27,14 +38,91 @@ * @memberof VOILUT */ function generateLinearVOILUT(windowWidth: number, windowCenter: number) { - return function (modalityLutValue) { + // C.11.2.1.2.1 defines w === 1 (the smallest width it allows) as a threshold + // rather than a ramp: y = ymin for x <= c - 0.5 and ymax above it. The + // continuous form divides by w - 1, so it cannot express that - it would + // divide by zero, and the value exactly at the threshold would land halfway + // up the display range instead of at ymin. + if (windowWidth <= 1) { + const threshold = windowCenter - 0.5; + + return function (modalityLutValue: number): number { + return modalityLutValue <= threshold ? Y_MIN : Y_MAX; + }; + } + + const width = windowWidth - 1; + + return function (modalityLutValue: number): number { const value = - ((modalityLutValue - (windowCenter - 0.5)) / (windowWidth - 1) + 0.5) * - 255.0; - return Math.min(Math.max(value, 0), 255); + ((modalityLutValue - (windowCenter - 0.5)) / width + 0.5) * Y_MAX; + + return clampToDisplayRange(value); }; } +/** + * Generates the LINEAR_EXACT VOI LUT function. + * From the DICOM standard (C.11.2.1.3.2): + * https://dicom.nema.org/medical/dicom/current/output/html/part03.html#sect_C.11.2.1.3.2 + * ((x - c) / w + 0.5) * (ymax - ymin) + ymin + * clipped to the ymin...ymax range + * + * Unlike LINEAR this has no half-pixel offsets and divides by w rather than + * w - 1, which matters for the narrow windows LINEAR_EXACT is typically used + * with (e.g. parametric maps and floating point pixel data). + * + * @param {Number} windowWidth Window Width + * @param {Number} windowCenter Window Center + * @returns {VOILUTFunction} VOI LUT mapping function + * @memberof VOILUT + */ +function generateLinearExactVOILUT(windowWidth: number, windowCenter: number) { + const width = windowWidth === 0 ? Number.EPSILON : windowWidth; + + return function (modalityLutValue: number): number { + const value = ((modalityLutValue - windowCenter) / width + 0.5) * Y_MAX; + + return clampToDisplayRange(value); + }; +} + +/** + * Generates the SIGMOID VOI LUT function. + * From the DICOM standard (C.11.2.1.3.1): + * https://dicom.nema.org/medical/dicom/current/output/html/part03.html#sect_C.11.2.1.3.1 + * y = (ymax - ymin) / (1 + exp(-4 * (x - c) / w)) + ymin + * + * The sigmoid is asymptotic, so no clipping is needed: it can never leave the + * ymin...ymax range. + * + * @param {Number} windowWidth Window Width + * @param {Number} windowCenter Window Center + * @returns {VOILUTFunction} VOI LUT mapping function + * @memberof VOILUT + */ +function generateSigmoidVOILUT(windowWidth: number, windowCenter: number) { + const width = windowWidth === 0 ? Number.EPSILON : windowWidth; + + return function (modalityLutValue: number): number { + return ( + Y_MAX / (1 + Math.exp((-4 * (modalityLutValue - windowCenter)) / width)) + ); + }; +} + +function maxLUTValue(lut: ArrayLike): number { + let max = -Infinity; + + for (let i = 0; i < lut.length; i++) { + if (lut[i] > max) { + max = lut[i]; + } + } + + return max; +} + /** * Generate a non-linear volume of interest lookup table * @@ -45,7 +133,9 @@ function generateLinearVOILUT(windowWidth: number, windowCenter: number) { */ function generateNonLinearVOILUT(voiLUT) { // We don't trust the voiLUT.numBitsPerEntry, mainly thanks to Agfa! - const bitsPerEntry = Math.max(...voiLUT.lut).toString(2).length; + // Reduced rather than spread into Math.max - VOI LUTs can hold tens of + // thousands of entries, which overflows the argument limit. + const bitsPerEntry = maxLUTValue(voiLUT.lut).toString(2).length; const shift = bitsPerEntry - 8; const minValue = voiLUT.lut[0] >> shift; const maxValue = voiLUT.lut[voiLUT.lut.length - 1] >> shift; @@ -66,17 +156,34 @@ function generateNonLinearVOILUT(voiLUT) { * Retrieve a VOI LUT mapping function given the current windowing settings * and the VOI LUT for the image * + * A VOI LUT Sequence, when present, takes precedence over the window + * width/center and the VOI LUT Function, since the sequence *is* the + * transformation. + * * @param {Number} windowWidth Window Width * @param {Number} windowCenter Window Center * @param {LUT} [voiLUT] Volume of Interest Lookup Table Object + * @param {String} [voiLUTFunction] VOI LUT Function (0028,1056) * * @return {VOILUTFunction} VOI LUT mapping function * @memberof VOILUT */ -export default function (windowWidth: number, windowCenter: number, voiLUT) { - if (voiLUT) { +export default function ( + windowWidth: number, + windowCenter: number, + voiLUT?: CPUFallbackLUT, + voiLUTFunction?: VOILUTFunctionType | string +) { + if (voiLUT?.lut?.length) { return generateNonLinearVOILUT(voiLUT); } - return generateLinearVOILUT(windowWidth, windowCenter); + switch (getValidVOILUTFunction(voiLUTFunction)) { + case VOILUTFunctionType.LINEAR_EXACT: + return generateLinearExactVOILUT(windowWidth, windowCenter); + case VOILUTFunctionType.SAMPLED_SIGMOID: + return generateSigmoidVOILUT(windowWidth, windowCenter); + default: + return generateLinearVOILUT(windowWidth, windowCenter); + } } diff --git a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/renderColorImage.ts b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/renderColorImage.ts index 22196d5170..c23886b013 100644 --- a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/renderColorImage.ts +++ b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/renderColorImage.ts @@ -12,6 +12,8 @@ import type { CPUFallbackEnabledElement, } from '../../../../types'; import { createCanvas } from '../../getOrCreateCanvas'; +import VOILUTFunctionType from '../../../../enums/VOILUTFunctionType'; +import { getValidVOILUTFunction } from '../../../../utilities/voiLUTFunction'; /** * Generates an appropriate Look Up Table to render the given image with the given window width and level (specified in the viewport) @@ -28,6 +30,7 @@ function getLut(image: IImage, viewport: CPUFallbackViewport) { image.cachedLut !== undefined && image.cachedLut.windowCenter === viewport.voi.windowCenter && image.cachedLut.windowWidth === viewport.voi.windowWidth && + image.cachedLut.voiLUTFunction === viewport.voi.voiLUTFunction && image.cachedLut.invert === viewport.invert ) { return image.cachedLut.lutArray; @@ -38,10 +41,13 @@ function getLut(image: IImage, viewport: CPUFallbackViewport) { image, viewport.voi.windowWidth, viewport.voi.windowCenter, - viewport.invert + viewport.invert, + undefined, + viewport.voi.voiLUTFunction ); image.cachedLut.windowWidth = viewport.voi.windowWidth; image.cachedLut.windowCenter = viewport.voi.windowCenter; + image.cachedLut.voiLUTFunction = viewport.voi.voiLUTFunction; image.cachedLut.invert = viewport.invert; return image.cachedLut.lutArray; @@ -77,10 +83,14 @@ function getRenderCanvas( // The ww/wc is identity and not inverted - get a canvas with the image rendered into it for // Fast drawing. Note that this is 256/128, and NOT 255/127, per the DICOM // standard, but allow either. - const { windowWidth, windowCenter } = enabledElement.viewport.voi; + const { windowWidth, windowCenter, voiLUTFunction } = + enabledElement.viewport.voi; if ( (windowWidth === 256 || windowWidth === 255) && (windowCenter === 128 || windowCenter === 127) && + // Only a LINEAR window of those dimensions is the identity. SIGMOID and + // LINEAR_EXACT still reshape the pixels, so they have to go through the LUT + getValidVOILUTFunction(voiLUTFunction) === VOILUTFunctionType.LINEAR && !enabledElement.viewport.invert && image.getCanvas && image.getCanvas() diff --git a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/saveLastRendered.ts b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/saveLastRendered.ts index 8786f26c86..9fc54a4478 100644 --- a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/saveLastRendered.ts +++ b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/saveLastRendered.ts @@ -23,6 +23,7 @@ export default function ( enabledElement.renderingTools.lastRenderedViewport = { windowCenter: viewport.voi.windowCenter, windowWidth: viewport.voi.windowWidth, + voiLUTFunction: viewport.voi.voiLUTFunction, invert: viewport.invert, rotation: viewport.rotation, hflip: viewport.hflip, diff --git a/packages/core/src/RenderingEngine/helpers/planarImageRendering.ts b/packages/core/src/RenderingEngine/helpers/planarImageRendering.ts index f03d50907f..77bd797d57 100644 --- a/packages/core/src/RenderingEngine/helpers/planarImageRendering.ts +++ b/packages/core/src/RenderingEngine/helpers/planarImageRendering.ts @@ -4,9 +4,19 @@ import type vtkImageSlice from '@kitware/vtk.js/Rendering/Core/ImageSlice'; import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; import type vtkRenderer from '@kitware/vtk.js/Rendering/Core/Renderer'; import { InterpolationType, VOILUTFunctionType } from '../../enums'; -import type { ColormapPublic, IImage, Point3, VOIRange } from '../../types'; +import type { + ColormapPublic, + CPUFallbackLUT, + IImage, + Point3, + VOIRange, +} from '../../types'; import createLinearRGBTransferFunction from '../../utilities/createLinearRGBTransferFunction'; import createSigmoidRGBTransferFunction from '../../utilities/createSigmoidRGBTransferFunction'; +import createVOILUTSequenceTransferFunction, { + getVOILUTSequenceRange, + isRenderableVOILUT, +} from '../../utilities/createVOILUTSequenceTransferFunction'; import getVOIRangeFromWindowLevel from '../../utilities/getVOIRangeFromWindowLevel'; import isPTPrescaledWithSUV from '../../utilities/isPTPrescaledWithSUV'; import { getImageDataMetadata } from '../../utilities/getImageDataMetadata'; @@ -134,6 +144,12 @@ export function getDefaultImageVOIRange(image: IImage): VOIRange | undefined { return { lower: 0, upper: 5 }; } + // A VOI LUT Sequence defines the range it is mapped over, and it takes + // precedence over a window the file may also carry + if (isRenderableVOILUT(image.voiLUT)) { + return getVOILUTSequenceRange(image.voiLUT); + } + return getVOIRangeFromWindowLevel( image.windowWidth, image.windowCenter, @@ -157,11 +173,28 @@ export function applyPlanarImagePresentation(args: { actor: vtkImageSlice; defaultVOIRange?: VOIRange; defaultVOILUTFunction?: VOILUTFunctionType; + /** + * VOI LUT Sequence (0028,3010) of the displayed image. Drives the display + * unless the caller asks for a specific VOI LUT Function or a colormap - see + * createPlanarRGBTransferFunction. + */ + defaultVOILUT?: CPUFallbackLUT; props?: PlanarImagePresentation; }): void { - const { actor, defaultVOIRange, defaultVOILUTFunction, props } = args; + const { + actor, + defaultVOIRange, + defaultVOILUTFunction, + defaultVOILUT, + props, + } = args; const property = actor.getProperty(); const voiRange = props?.voiRange ?? defaultVOIRange; + // An explicitly requested VOI LUT Function opts out of the file's VOI LUT + // Sequence; an explicit range does not - the curve is stretched over it, so + // window level keeps the shape the file specified + const voiLUT = + props?.voiLUTFunction === undefined ? defaultVOILUT : undefined; if (props?.visible !== undefined) { actor.setVisibility(props.visible); @@ -188,6 +221,7 @@ export function applyPlanarImagePresentation(args: { invert: props?.invert, voiRange, voiLUTFunction: props?.voiLUTFunction ?? defaultVOILUTFunction, + voiLUT, }); property.setUseLookupTableScalarRange(true); @@ -199,14 +233,21 @@ export function createPlanarRGBTransferFunction(args: { invert?: boolean; voiRange: VOIRange; voiLUTFunction?: VOILUTFunctionType; + /** + * VOI LUT Sequence (0028,3010). When present it defines the whole VOI + * transformation and takes precedence over the window and the VOI LUT + * Function (C.11.2.1) - a colormap still wins, since that is an explicit + * display choice rather than file metadata. + */ + voiLUT?: CPUFallbackLUT; }): vtkColorTransferFunction { - const { colormap, invert, voiRange, voiLUTFunction } = args; - const transferFunction = - colormap?.name !== undefined - ? createColormapTransferFunction(colormap, voiRange) - : voiLUTFunction === VOILUTFunctionType.SAMPLED_SIGMOID - ? createSigmoidRGBTransferFunction(voiRange) - : createLinearRGBTransferFunction(voiRange); + const { colormap, invert, voiRange, voiLUTFunction, voiLUT } = args; + const transferFunction = createVOITransferFunction({ + colormap, + voiRange, + voiLUTFunction, + voiLUT, + }); if (invert) { invertRgbTransferFunction(transferFunction); @@ -215,6 +256,38 @@ export function createPlanarRGBTransferFunction(args: { return transferFunction; } +function createVOITransferFunction(args: { + colormap?: ColormapPublic; + voiRange: VOIRange; + voiLUTFunction?: VOILUTFunctionType; + voiLUT?: CPUFallbackLUT; +}): vtkColorTransferFunction { + const { colormap, voiRange, voiLUTFunction, voiLUT } = args; + + if (colormap?.name !== undefined) { + return createColormapTransferFunction(colormap, voiRange); + } + + if (voiLUT) { + // Stretched over voiRange so window level reshapes the file's curve rather + // than replacing it + const voiLUTSequenceTransferFunction = createVOILUTSequenceTransferFunction( + voiLUT, + { voiRange } + ); + + if (voiLUTSequenceTransferFunction) { + return voiLUTSequenceTransferFunction; + } + } + + if (voiLUTFunction === VOILUTFunctionType.SAMPLED_SIGMOID) { + return createSigmoidRGBTransferFunction(voiRange); + } + + return createLinearRGBTransferFunction(voiRange); +} + function createColormapTransferFunction( colormap: ColormapPublic, voiRange: VOIRange diff --git a/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts b/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts index 823d5a865e..a69e3271eb 100644 --- a/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts +++ b/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts @@ -9,6 +9,7 @@ import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransf import { loadAndCacheImage } from '../../loaders/imageLoader'; import * as metaData from '../../metaData'; import * as windowLevel from '../../utilities/windowLevel'; +import { normalizeVOILUTFunction } from '../../utilities/voiLUTFunction'; import { MetadataModules, RequestType } from '../../enums'; import cache from '../../cache/cache'; @@ -67,9 +68,22 @@ export async function getDefaultVolumeVOIRange( ): Promise { let voi = getVOIFromMetadata(imageVolume); + // A prescaled PT gets the 0-5 default even when the metadata does carry a + // window. PT window width/center is expressed in the unscaled counts, so + // applying it to SUV values produces an enormous range and a black volume. + // This override used to run only on the min/max path below, so a PT series + // that shipped a window skipped it and the volume viewport disagreed with the + // stack viewport, which has always preferred its own PT range. It applies to + // a volume with no imageIds too, whose window came from the volume metadata + // rather than from an instance: scaling is a property of the volume, not of + // how its window was found. + if (voi) { + voi = handlePreScaledVolume(imageVolume, voi); + } + if ( !voi && - imageVolume.imageIds.length && + imageVolume.imageIds?.length && shouldUseImageIdsForVOI(imageVolume) ) { voi = await getVOIFromMiddleSliceMinMax(imageVolume); @@ -99,12 +113,22 @@ function shouldUseImageIdsForVOI(imageVolume: IImageVolume): boolean { } function handlePreScaledVolume(imageVolume: IImageVolume, voi: VOIRange) { - const imageIds = imageVolume.imageIds; + const imageIds = imageVolume.imageIds ?? []; const imageIdIndex = Math.floor(imageIds.length / 2); const imageId = imageIds[imageIdIndex]; + // A volume does not have to carry imageIds - one built from its own metadata + // has none - so the general series module is only worth asking for when there + // is an instance to key it on. The volume metadata fallback below covers the + // rest, and the prescaling check itself reads only volume level fields. const generalSeriesModule = - metaData.get(MetadataModules.GENERAL_SERIES, imageId) || {}; + (imageId ? metaData.get(MetadataModules.GENERAL_SERIES, imageId) : null) || + {}; + // The volume's own metadata is the fallback: the middle instance may not have + // a registered general series module, and missing the modality here would + // quietly skip the PT handling below. + const modality = + generalSeriesModule.modality ?? imageVolume.metadata?.Modality; /** * If the volume is prescaled and the modality is PT Sometimes you get super high @@ -112,7 +136,7 @@ function handlePreScaledVolume(imageVolume: IImageVolume, voi: VOIRange) { * Therefore, we follow the majority of other viewers and we set the min/max * for the scaled PT to be 0, 5 */ - if (_isCurrentImagePTPrescaled(generalSeriesModule.modality, imageVolume)) { + if (_isCurrentImagePTPrescaled(modality, imageVolume)) { return { lower: 0, upper: 5, @@ -122,6 +146,59 @@ function handlePreScaledVolume(imageVolume: IImageVolume, voi: VOIRange) { return voi; } +/** + * Finds a usable Window Center/Width, starting at the middle of the stack and + * walking outwards. + * + * The middle instance is preferred (it is the most representative slice), but it + * is not guaranteed to have registered metadata: which imageId lands in the + * middle depends on how the volume was created and in which order its instances + * were added, and an instance whose metadata has not been registered yet + * silently produced no window at all - the volume then fell back to a min/max + * range from the pixel data (cornerstone3D#1767). Any sibling in the same series + * carries the same window in practice, so the nearest instance that has one is a + * far better answer than giving up. + */ +function getWindowFromNearestImageId(imageIds: string[]) { + const middle = Math.floor(imageIds.length / 2); + + for (let offset = 0; offset < imageIds.length; offset++) { + // middle, middle + 1, middle - 1, middle + 2, ... + const direction = offset % 2 === 0 ? 1 : -1; + const index = middle + direction * Math.ceil(offset / 2); + + if (index < 0 || index >= imageIds.length) { + continue; + } + + const voiLutModule = metaData.get(MetadataModules.VOI_LUT, imageIds[index]); + + const { windowWidth, windowCenter } = voiLutModule ?? {}; + const width = Array.isArray(windowWidth) ? windowWidth[0] : windowWidth; + const center = Array.isArray(windowCenter) ? windowCenter[0] : windowCenter; + + // A center of 0 is a perfectly good window - prescaled PT, parametric maps + // and centered MR all use one - so it has to be tested for existence rather + // than for truthiness, or those series silently fall back to a min/max + // range. A width of 0 or a missing width has no window to show, however. + if (!width || center == null) { + continue; + } + + // The VOI LUT Function has to stay attached to the window - it decides how + // the window converts to a range. It used to be assigned to `voi` first and + // then overwritten by the window object, so a LINEAR_EXACT/SIGMOID volume + // was silently windowed as LINEAR. + return { + windowWidth: width, + windowCenter: center, + voiLUTFunction: normalizeVOILUTFunction(voiLutModule.voiLUTFunction), + }; + } + + return undefined; +} + /** * Get the VOI from the metadata of the middle slice of the image volume or the metadata of the image volume * It checks the metadata for the VOI and if it is not found, it returns null @@ -133,28 +210,12 @@ function getVOIFromMetadata(imageVolume: IImageVolume): VOIRange | undefined { const { imageIds, metadata } = imageVolume; let voi; if (imageIds?.length) { - const imageIdIndex = Math.floor(imageIds.length / 2); - const imageId = imageIds[imageIdIndex]; - const voiLutModule = metaData.get(MetadataModules.VOI_LUT, imageId); - if (voiLutModule && voiLutModule.windowWidth && voiLutModule.windowCenter) { - if (voiLutModule?.voiLUTFunction) { - voi = {}; - voi.voiLUTFunction = voiLutModule?.voiLUTFunction; - } - const { windowWidth, windowCenter } = voiLutModule; - - const width = Array.isArray(windowWidth) ? windowWidth[0] : windowWidth; - const center = Array.isArray(windowCenter) - ? windowCenter[0] - : windowCenter; - - // Skip if width is 0 - if (width !== 0) { - voi = { windowWidth: width, windowCenter: center }; - } - } + voi = getWindowFromNearestImageId(imageIds); } else { - voi = metadata.voiLut[0]; + // A volume without imageIds carries its own window, which it is not + // required to have - indexing it unconditionally threw for every volume + // built without one + voi = metadata?.voiLut?.[0]; } if (voi && (voi.windowWidth !== 0 || voi.windowCenter !== 0)) { @@ -261,7 +322,7 @@ function _isCurrentImagePTPrescaled(modality, imageVolume) { return false; } - if (!imageVolume.scaling?.PT.suvbw) { + if (!imageVolume.scaling?.PT?.suvbw) { return false; } diff --git a/packages/core/src/types/CPUFallbackLUT.ts b/packages/core/src/types/CPUFallbackLUT.ts index 0619709b56..a0043b9a1f 100644 --- a/packages/core/src/types/CPUFallbackLUT.ts +++ b/packages/core/src/types/CPUFallbackLUT.ts @@ -1,6 +1,10 @@ interface CPUFallbackLUT { lut: number[]; id?: string; + /** The stored value the first LUT entry maps, from LUT Descriptor (0028,3002) */ + firstValueMapped?: number; + /** Bits per LUT entry, from LUT Descriptor (0028,3002) */ + numBitsPerEntry?: number; } export type { CPUFallbackLUT as default }; diff --git a/packages/core/src/types/CPUFallbackRenderingTools.ts b/packages/core/src/types/CPUFallbackRenderingTools.ts index 3e26304590..a9f12e3c94 100644 --- a/packages/core/src/types/CPUFallbackRenderingTools.ts +++ b/packages/core/src/types/CPUFallbackRenderingTools.ts @@ -1,5 +1,6 @@ import type CPUFallbackLookupTable from './CPUFallbackLookupTable'; import type CPUFallbackLUT from './CPUFallbackLUT'; +import type VOILUTFunctionType from '../enums/VOILUTFunctionType'; interface CPUFallbackRenderingTools { renderCanvas?: HTMLCanvasElement; @@ -8,6 +9,7 @@ interface CPUFallbackRenderingTools { lastRenderedViewport?: { windowWidth: number | number[]; windowCenter: number | number[]; + voiLUTFunction?: VOILUTFunctionType; invert: boolean; rotation: number; hflip: boolean; diff --git a/packages/core/src/types/CPUFallbackViewport.ts b/packages/core/src/types/CPUFallbackViewport.ts index 4d466e45cb..0d1e2d6632 100644 --- a/packages/core/src/types/CPUFallbackViewport.ts +++ b/packages/core/src/types/CPUFallbackViewport.ts @@ -15,7 +15,7 @@ interface CPUFallbackViewport { voi?: { windowWidth: number; windowCenter: number; - voiLUTFunction: VOILUTFunctionType; + voiLUTFunction?: VOILUTFunctionType; }; invert?: boolean; pixelReplication?: boolean; diff --git a/packages/core/src/types/IImage.ts b/packages/core/src/types/IImage.ts index 2ebde4a9f3..3ef103625b 100644 --- a/packages/core/src/types/IImage.ts +++ b/packages/core/src/types/IImage.ts @@ -126,6 +126,7 @@ interface IImage { cachedLut?: { windowWidth?: number | number[]; windowCenter?: number | number[]; + voiLUTFunction?: VOILUTFunctionType; invert?: boolean; lutArray?: Uint8ClampedArray; modalityLUT?: CPUFallbackLUT; diff --git a/packages/core/src/utilities/createLinearRGBTransferFunction.ts b/packages/core/src/utilities/createLinearRGBTransferFunction.ts index 3f07072159..41138a194d 100644 --- a/packages/core/src/utilities/createLinearRGBTransferFunction.ts +++ b/packages/core/src/utilities/createLinearRGBTransferFunction.ts @@ -1,4 +1,5 @@ import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; +import { EPSILON } from '@kitware/vtk.js/Common/Core/Math/Constants'; import type { VOIRange } from '../types/voi'; export default function createLinearRGBTransferFunction( @@ -11,6 +12,21 @@ export default function createLinearRGBTransferFunction( lower = voiRange.lower; upper = voiRange.upper; } + + // A window width of 1 collapses lower onto upper under the DICOM LINEAR + // formula (C.11.2.1.2.1 subtracts (w - 1) / 2 from both ends), which is what + // the all zero padding frame at the start of many multi frame US cines gives. + // Coincident nodes are unrecoverable rather than merely wrong: VTK.js + // setMappingRange rescales existing nodes by newSpan / currentSpan, so every + // later window change divides by zero and the nodes never move again, leaving + // each following frame bilevel while voiRange still looks correct. + if (upper === lower) { + const halfWidth = Math.max(Math.abs(lower), 1) * EPSILON; + + lower -= halfWidth; + upper += halfWidth; + } + cfun.addRGBPoint(lower, 0.0, 0.0, 0.0); cfun.addRGBPoint(upper, 1.0, 1.0, 1.0); diff --git a/packages/core/src/utilities/createVOILUTSequenceTransferFunction.ts b/packages/core/src/utilities/createVOILUTSequenceTransferFunction.ts new file mode 100644 index 0000000000..a19d099840 --- /dev/null +++ b/packages/core/src/utilities/createVOILUTSequenceTransferFunction.ts @@ -0,0 +1,203 @@ +import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import type { CPUFallbackLUT, VOIRange } from '../types'; + +// VTK.js builds its own sampled table from the nodes we hand it, so there is +// nothing to gain from emitting a node per entry of a 16k or 64k entry LUT. +// Matches the sampled sigmoid's node count: humans perceive no more than ~900 +// shades of gray (doi: 10.1007/s10278-006-1052-3), and every node costs on each +// rebuild, which a window level drag triggers on every mouse move. +const DEFAULT_MAX_NODES = 1024; + +// How far a skipped entry may sit from the line drawn through the entries that +// were kept, as a fraction of the output range, before nodes are added to +// reproduce it. 1/512 is half a step of the 8 bit display path, so anything +// under it cannot show up on screen anyway. +const TOLERANCE = 1 / 512; + +/** + * A VOI LUT Sequence item (0028,3010) that can be rendered, i.e. one that has + * both LUT Data and a usable LUT Descriptor. + */ +type RenderableVOILUT = CPUFallbackLUT & { firstValueMapped: number }; + +/** + * Returns true when the given VOI LUT Sequence item holds enough data to build + * a transfer function from. + */ +export function isRenderableVOILUT( + voiLUT: CPUFallbackLUT | undefined +): voiLUT is RenderableVOILUT { + return ( + !!voiLUT && + !!voiLUT.lut && + voiLUT.lut.length > 1 && + Number.isFinite(voiLUT.firstValueMapped) + ); +} + +/** + * The range of input (modality LUT output) values a VOI LUT Sequence maps. + * Values outside it clamp to the first/last entry, which is what both the DICOM + * standard and VTK.js's clamping do. + */ +export function getVOILUTSequenceRange(voiLUT: RenderableVOILUT): VOIRange { + return { + lower: voiLUT.firstValueMapped, + upper: voiLUT.firstValueMapped + voiLUT.lut.length - 1, + }; +} + +/** + * The input range to lay the curve over: the requested one, or the LUT's own + * domain when no range was requested. A zero width range would collapse every + * node onto one input value, so it falls back to the domain too. + */ +function resolveDomain( + voiLUT: RenderableVOILUT, + voiRange: VOIRange | undefined +): VOIRange { + if (!voiRange || voiRange.upper === voiRange.lower) { + return getVOILUTSequenceRange(voiLUT); + } + + // Allow for a swapped range, which window level tools can produce + return voiRange.upper > voiRange.lower + ? voiRange + : { lower: voiRange.upper, upper: voiRange.lower }; +} + +/** + * Builds a grayscale `vtkColorTransferFunction` from a DICOM VOI LUT Sequence + * (0028,3010). + * + * When a VOI LUT Sequence is present it *is* the VOI transformation, replacing + * the window width/center and the VOI LUT Function entirely (C.11.2.1). Without + * this the GPU path fell back to a linear window over the stored values, so + * images that rely on their VOI LUT (typically CR, DX and MG, whose curves are + * strongly non linear) rendered far too flat. + * + * The output entries are normalized the way the legacy cornerstone + * `getVOILut.js` did: the number of significant bits is taken from the largest + * entry rather than from LUT Descriptor's declared bit depth, because that + * declared depth cannot be trusted in real world data. + * + * `voiRange` stretches the curve over a different input range than the LUT's own + * domain, which is what keeps window level interaction working: the shape the + * file specified is preserved and only the range it spans changes, exactly as + * the sampled sigmoid is rebuilt from a new window. Pass the LUT's own domain + * (see {@link getVOILUTSequenceRange}), or nothing, for an unmodified curve. + * + * @param voiLUT - a VOI LUT Sequence item + * @param options.voiRange - input range to stretch the curve over + * @param options.maxNodes - upper bound on the number of transfer function nodes + * @returns the transfer function, or undefined when the LUT cannot be used + */ +export default function createVOILUTSequenceTransferFunction( + voiLUT: CPUFallbackLUT, + options: { voiRange?: VOIRange; maxNodes?: number } = {} +): vtkColorTransferFunction | undefined { + if (!isRenderableVOILUT(voiLUT)) { + return undefined; + } + + const { voiRange, maxNodes = DEFAULT_MAX_NODES } = options; + const { lut } = voiLUT; + const length = lut.length; + const domain = resolveDomain(voiLUT, voiRange); + // Input value the entry at `index` maps, in the (possibly stretched) domain + const inputAt = (index: number) => + domain.lower + (index / (length - 1)) * (domain.upper - domain.lower); + + let maxEntry = -Infinity; + for (let i = 0; i < length; i++) { + if (lut[i] > maxEntry) { + maxEntry = lut[i]; + } + } + + // An all zero LUT carries no curve to render + if (maxEntry <= 0) { + return undefined; + } + + // Same "don't trust numBitsPerEntry" heuristic as the CPU path: derive the + // bit depth from the data so a LUT whose entries only use part of its + // declared depth still spans the full display range. + const bitsPerEntry = Math.ceil(Math.log2(maxEntry + 1)); + const scale = Math.pow(2, bitsPerEntry) - 1; + + const step = Math.max(1, Math.ceil(length / maxNodes)); + const table: number[] = []; + let lastPushed = -1; + + const pushNode = (index: number) => { + // Entries are visited in order, and a repeated node would give VTK.js two + // outputs for one input + if (index <= lastPushed || index < 0 || index > length - 1) { + return; + } + + // Sharpness 0 gives linear interpolation between the sampled entries + const y = Math.min(Math.max(lut[index] / scale, 0), 1); + table.push(inputAt(index), y, y, y, 0.5, 0.0); + lastPushed = index; + }; + + /** + * Sampling every `step`th entry and interpolating between them assumes the + * skipped entries lie on that line. A VOI LUT that steps rather than ramps + * (a threshold curve, or the flat toe and shoulder of a CR/DX presentation + * curve) breaks that assumption: the transition gets smeared across the whole + * skipped span and lands up to `step` entries away from where the file put + * it. So the span is checked against the line it would be drawn as, and the + * entries on both sides of its sharpest transition are kept when it deviates. + */ + const refineSpan = (start: number, end: number) => { + const spread = lut[end] - lut[start]; + let worstIndex = -1; + let worstDeviation = 0; + + for (let i = start + 1; i < end; i++) { + const interpolated = lut[start] + (spread * (i - start)) / (end - start); + const deviation = Math.abs(lut[i] - interpolated); + + if (deviation > worstDeviation) { + worstDeviation = deviation; + worstIndex = i; + } + } + + if (worstIndex < 0 || worstDeviation / scale <= TOLERANCE) { + return; + } + + // Both sides, so a step stays a step: the ramp between the kept pair is + // then one entry wide, which is as sharp as the LUT itself is + pushNode(worstIndex - 1); + pushNode(worstIndex); + pushNode(worstIndex + 1); + }; + + for (let i = 0; i < length; i += step) { + pushNode(i); + + if (step > 1) { + refineSpan(i, Math.min(i + step, length - 1)); + } + } + + // Always anchor the last entry so the upper end of the LUT domain is exact + pushNode(length - 1); + + const cfun = vtkColorTransferFunction.newInstance(); + + cfun.buildFunctionFromArray( + vtkDataArray.newInstance({ + values: table, + numberOfComponents: 6, + }) + ); + + return cfun; +} diff --git a/packages/core/src/utilities/getVOIRangeFromWindowLevel.ts b/packages/core/src/utilities/getVOIRangeFromWindowLevel.ts index 8c48af4dfa..8c6ff1f3fb 100644 --- a/packages/core/src/utilities/getVOIRangeFromWindowLevel.ts +++ b/packages/core/src/utilities/getVOIRangeFromWindowLevel.ts @@ -5,7 +5,7 @@ import { toLowHighRange } from './windowLevel'; export default function getVOIRangeFromWindowLevel( windowWidth: number | number[] | undefined, windowCenter: number | number[] | undefined, - voiLUTFunction: VOILUTFunctionType = VOILUTFunctionType.LINEAR + voiLUTFunction: VOILUTFunctionType | string = VOILUTFunctionType.LINEAR ): VOIRange | undefined { let center: number | undefined; let width: number | undefined; diff --git a/packages/core/src/utilities/index.ts b/packages/core/src/utilities/index.ts index 3ee7e1af25..26580cb33b 100644 --- a/packages/core/src/utilities/index.ts +++ b/packages/core/src/utilities/index.ts @@ -1,6 +1,7 @@ import * as eventListener from './eventListener'; import csUtils from './invertRgbTransferFunction'; import createSigmoidRGBTransferFunction from './createSigmoidRGBTransferFunction'; +import createVOILUTSequenceTransferFunction from './createVOILUTSequenceTransferFunction'; import getVoiFromSigmoidRGBTransferFunction from './getVoiFromSigmoidRGBTransferFunction'; import createLinearRGBTransferFunction from './createLinearRGBTransferFunction'; import scaleRgbTransferFunction from './scaleRgbTransferFunction'; @@ -121,6 +122,14 @@ import { mapMappedBandToRawRange, } from './viewportVoiIntensityMapping'; export type { ViewportVoiMappingProps } from './viewportVoiIntensityMapping'; +export { + isRenderableVOILUT, + getVOILUTSequenceRange, +} from './createVOILUTSequenceTransferFunction'; +export { + normalizeVOILUTFunction, + getValidVOILUTFunction, +} from './voiLUTFunction'; export * from './getPixelSpacingInformation'; export * from './getPlaneCubeIntersectionDimensions'; export * from './rotateToViewCoordinates'; @@ -153,6 +162,7 @@ export { eventListener, csUtils as invertRgbTransferFunction, createSigmoidRGBTransferFunction, + createVOILUTSequenceTransferFunction, getVoiFromSigmoidRGBTransferFunction, createLinearRGBTransferFunction, scaleRgbTransferFunction, diff --git a/packages/core/src/utilities/voiLUTFunction.ts b/packages/core/src/utilities/voiLUTFunction.ts new file mode 100644 index 0000000000..b42f141699 --- /dev/null +++ b/packages/core/src/utilities/voiLUTFunction.ts @@ -0,0 +1,92 @@ +import VOILUTFunctionType from '../enums/VOILUTFunctionType'; + +/** + * The values of VOI LUT Function (0028,1056) that DICOM defines, mapped to the + * enum members cornerstone uses internally. `SAMPLED_SIGMOID` has the string + * value `SIGMOID`, so both the DICOM defined term and the enum key resolve to + * the same member. + * + * See https://dicom.nema.org/medical/dicom/current/output/html/part03.html#sect_C.11.2.1.3 + */ +const definedTerms = new Map([ + ['LINEAR', VOILUTFunctionType.LINEAR], + ['LINEAR_EXACT', VOILUTFunctionType.LINEAR_EXACT], + ['SIGMOID', VOILUTFunctionType.SAMPLED_SIGMOID], + ['SAMPLED_SIGMOID', VOILUTFunctionType.SAMPLED_SIGMOID], +]); + +const warnedValues = new Set(); + +/** + * Normalizes a raw VOI LUT Function (0028,1056) value into a + * {@link VOILUTFunctionType}. + * + * The value reaches us in a few different shapes depending on which metadata + * provider produced it: dicom-parser returns a `string`, DICOMweb JSON returns + * a single element `Value` array, and some providers hand back the whole + * multi-valued array. It is a CS attribute, so it can also arrive padded with + * whitespace or a trailing null, and in the wrong case. + * + * Note that indexing a `string` (`value[0]`) yields its first *character*, so + * treating the string shape as an array silently produces `'S'` for + * `'SIGMOID'`, which caused the "Invalid VOI LUT function" throw reported in + * cornerstone3D#2844. + * + * @param value - the raw attribute value from a metadata provider + * @returns the matching VOILUTFunctionType, or undefined when the value is + * absent or not a DICOM defined term. + */ +function normalizeVOILUTFunction( + value: unknown +): VOILUTFunctionType | undefined { + const raw = Array.isArray(value) + ? value.find((item) => typeof item === 'string' && item.trim() !== '') + : value; + + if (typeof raw !== 'string') { + return undefined; + } + + // CS values may be padded with spaces or a null byte, and are case + // insensitive in practice even though the standard defines them uppercase. + const cleaned = raw.replace(/\0/g, '').trim().toUpperCase(); + + if (cleaned === '') { + return undefined; + } + + return definedTerms.get(cleaned); +} + +/** + * Same as {@link normalizeVOILUTFunction} but always resolves to a usable + * value, falling back to `LINEAR` (the DICOM default when the attribute is + * absent) for anything unrecognized. An unrecognized non-empty value is warned + * about once so bad metadata is still noticeable, but it never throws: a VOI + * LUT Function we do not know about should degrade to a linear window rather + * than break rendering. + * + * @param value - the raw attribute value from a metadata provider + * @returns a valid VOILUTFunctionType + */ +function getValidVOILUTFunction(value: unknown): VOILUTFunctionType { + const normalized = normalizeVOILUTFunction(value); + + if (normalized) { + return normalized; + } + + if (typeof value === 'string' && value.trim() !== '') { + const key = value.trim(); + if (!warnedValues.has(key)) { + warnedValues.add(key); + console.warn( + `Unsupported VOI LUT Function "${key}", falling back to LINEAR` + ); + } + } + + return VOILUTFunctionType.LINEAR; +} + +export { normalizeVOILUTFunction, getValidVOILUTFunction }; diff --git a/packages/core/src/utilities/windowLevel.ts b/packages/core/src/utilities/windowLevel.ts index 62a1737e1f..6e7d5967f6 100644 --- a/packages/core/src/utilities/windowLevel.ts +++ b/packages/core/src/utilities/windowLevel.ts @@ -1,22 +1,39 @@ import VOILUTFunctionType from '../enums/VOILUTFunctionType'; -import { logit } from './logit'; +import { getValidVOILUTFunction } from './voiLUTFunction'; /** * Given a low and high window level, return the window width and window center * Formulas from note 4 in * https://dicom.nema.org/medical/dicom/current/output/html/part03.html#sect_C.11.2.1.2.1 * extended to allow for low/high swapping + * + * This is the exact inverse of {@link toLowHighRange} for the same VOI LUT + * function, so a range -> window -> range round trip (what the window level + * tools do on every drag) is lossless. + * * @param low - The low window level. * @param high - The high window level. + * @param voiLUTFunction - 'LINEAR' (default) | 'LINEAR_EXACT' | 'SIGMOID' * @returns a JavaScript object with two properties: windowWidth and windowCenter. */ function toWindowLevel( low: number, - high: number + high: number, + voiLUTFunction?: VOILUTFunctionType | string ): { windowWidth: number; windowCenter: number; } { + if ( + getValidVOILUTFunction(voiLUTFunction) === VOILUTFunctionType.LINEAR_EXACT + ) { + // Inverse of C.11.2.1.3.2, which has no halfpixel offsets + return { + windowWidth: Math.abs(high - low), + windowCenter: (low + high) / 2, + }; + } + // Allow for swapping high/low const windowWidth = Math.abs(high - low) + 1; const windowCenter = (low + high + 1) / 2; @@ -39,11 +56,18 @@ function toWindowLevel( * upper = c + w/2 * * SIGMOID (C.11.2.1.3.1): - * - The sigmoid does not define linear "bounds" in the same way. It's asymptotic. - * - We define approximate bounds by choosing output thresholds (e.g., 1% and 99%) - * and solving for input x: - * y = 1/(1 + exp(-4*(x - c)/w)) - * For y=0.01 and y=0.99, solve for x. + * - The sigmoid is asymptotic, so it has no linear bounds to convert to. It is + * deliberately given the same bounds as LINEAR: the range is only a carrier + * for the window here, and the sigmoid transfer function recovers the window + * width/center from it via `toWindowLevel` before evaluating + * `1 / (1 + exp(-4 * (x - c) / w))`. Because the LINEAR mapping is an exact + * inverse of `toWindowLevel`, the window the file specified is the window + * that gets rendered. Picking asymptote-based bounds instead (e.g. the 1% and + * 99% output levels) would break that round trip and silently widen the + * window on every conversion. + * + * An unrecognized VOI LUT Function falls back to LINEAR rather than throwing, + * so malformed metadata cannot break rendering. * * @param windowWidth - The width of the window * @param windowCenter - The center of the window @@ -53,44 +77,27 @@ function toWindowLevel( function toLowHighRange( windowWidth: number, windowCenter: number, - voiLUTFunction: VOILUTFunctionType = VOILUTFunctionType.LINEAR + voiLUTFunction?: VOILUTFunctionType | string ): { lower: number; upper: number; } { - // Note: The SIGMOID function is currently treated the same as LINEAR - // because we don't have a good way to define "bounds" for it. - // Remove or statement when fixed if ( - voiLUTFunction === VOILUTFunctionType.LINEAR || - voiLUTFunction === VOILUTFunctionType.SAMPLED_SIGMOID + getValidVOILUTFunction(voiLUTFunction) === VOILUTFunctionType.LINEAR_EXACT ) { - // From C.11.2.1.2.1 (linear function) - return { - lower: windowCenter - 0.5 - (windowWidth - 1) / 2, - upper: windowCenter - 0.5 + (windowWidth - 1) / 2, - }; - } else if (voiLUTFunction === VOILUTFunctionType.LINEAR_EXACT) { // From C.11.2.1.3.2 (linear exact function) return { lower: windowCenter - windowWidth / 2, upper: windowCenter + windowWidth / 2, }; - // Note: The SIGMOID function is currently treated the same as LINEAR - // because we don't have a good way to define "bounds" for it. - // Uncomment when fixed - // } else if (voiLUTFunction === VOILUTFunctionType.SAMPLED_SIGMOID) { - // // From C.11.2.1.3.1 (sigmoid function) - // // Sigmoid: y = 1 / (1 + exp(-4*(x - c)/w)) - // const xLower = logit(0.01, windowCenter, windowWidth); - // const xUpper = logit(0.99, windowCenter, windowWidth); - // return { - // lower: xLower, - // upper: xUpper, - // }; - } else { - throw new Error('Invalid VOI LUT function'); } + + // From C.11.2.1.2.1 (linear function), also used to carry the window for the + // sampled sigmoid. See the note above. + return { + lower: windowCenter - 0.5 - (windowWidth - 1) / 2, + upper: windowCenter - 0.5 + (windowWidth - 1) / 2, + }; } export { toWindowLevel, toLowHighRange }; diff --git a/packages/core/test/planarComputedCamera.jest.js b/packages/core/test/planarComputedCamera.jest.js index 146f1db941..2544c1adc4 100644 --- a/packages/core/test/planarComputedCamera.jest.js +++ b/packages/core/test/planarComputedCamera.jest.js @@ -632,6 +632,7 @@ describe('Planar CPU image render path', () => { expect(attachment.rendering.enabledElement.viewport.voi).toEqual({ windowCenter: 2.5, windowWidth: 5, + voiLUTFunction: VOILUTFunctionType.LINEAR, }); }); }); diff --git a/packages/core/test/utilities/getVOILut.jest.js b/packages/core/test/utilities/getVOILut.jest.js new file mode 100644 index 0000000000..fe135b5d1b --- /dev/null +++ b/packages/core/test/utilities/getVOILut.jest.js @@ -0,0 +1,231 @@ +import { describe, it, expect } from '@jest/globals'; +import getVOILUT from '../../src/RenderingEngine/helpers/cpuFallback/rendering/getVOILut'; +import createVOILUTSequenceTransferFunction, { + isRenderableVOILUT, + getVOILUTSequenceRange, +} from '../../src/utilities/createVOILUTSequenceTransferFunction'; +import createLinearRGBTransferFunction from '../../src/utilities/createLinearRGBTransferFunction'; +import VOILUTFunctionType from '../../src/enums/VOILUTFunctionType'; + +describe('cpuFallback getVOILut', function () { + it('maps the window center to mid grey for LINEAR', () => { + const fn = getVOILUT(256, 128); + + expect(fn(128)).toBeCloseTo(128, 0); + expect(fn(0)).toBe(0); + expect(fn(255)).toBe(255); + }); + + it('does not divide by zero for a width of 1', () => { + const fn = getVOILUT(1, 128); + + expect(Number.isFinite(fn(127))).toBe(true); + expect(fn(127)).toBe(0); + expect(fn(129)).toBe(255); + }); + + it('treats a width of 1 as the C.11.2.1.2.1 threshold', () => { + // y = ymin for x <= c - 0.5, ymax above it. A fractional center puts a + // stored value exactly on the threshold, which the continuous form mapped + // to the middle of the display range instead of to ymin + const fn = getVOILUT(1, 128.5); + + expect(fn(128)).toBe(0); + expect(fn(129)).toBe(255); + }); + + it('applies the LINEAR_EXACT formula', () => { + const fn = getVOILUT(100, 50, undefined, VOILUTFunctionType.LINEAR_EXACT); + + // ((x - c) / w + 0.5) * 255 + expect(fn(50)).toBeCloseTo(127.5, 5); + expect(fn(0)).toBe(0); + expect(fn(100)).toBe(255); + // LINEAR would put the same input half a pixel off + expect(fn(50)).not.toBe(getVOILUT(100, 50)(50)); + }); + + it('applies the SIGMOID formula', () => { + const fn = getVOILUT( + 100, + 50, + undefined, + VOILUTFunctionType.SAMPLED_SIGMOID + ); + + expect(fn(50)).toBeCloseTo(127.5, 5); + // 255 / (1 + exp(-4 * (100 - 50) / 100)) + expect(fn(100)).toBeCloseTo(255 / (1 + Math.exp(-2)), 5); + // Asymptotic - never clips, never leaves the range + expect(fn(-100000)).toBeGreaterThanOrEqual(0); + expect(fn(100000)).toBeLessThanOrEqual(255); + }); + + it('falls back to LINEAR for an unsupported function', () => { + const fn = getVOILUT(256, 128, undefined, 'S'); + + expect(fn(128)).toBe(getVOILUT(256, 128)(128)); + }); + + it('prefers a VOI LUT Sequence over the window', () => { + const voiLUT = { + firstValueMapped: 0, + numBitsPerEntry: 8, + lut: [0, 64, 128, 255], + }; + const fn = getVOILUT(1, 1, voiLUT, VOILUTFunctionType.SAMPLED_SIGMOID); + + expect(fn(1)).toBe(64); + // Values below/above the mapped range clamp to the first/last entry + expect(fn(-10)).toBe(0); + expect(fn(10)).toBe(255); + }); +}); + +describe('createLinearRGBTransferFunction', function () { + it('places the two nodes at the window ends', () => { + const cfun = createLinearRGBTransferFunction({ lower: 0, upper: 255 }); + + expect(cfun.getRange()).toEqual([0, 255]); + }); + + it('separates the nodes of a zero width window', () => { + // A window width of 1 collapses both ends onto one value. Coincident nodes + // make every later setRange divide by zero, so the function would stay a + // step function forever (cornerstone3D#2733). + const cfun = createLinearRGBTransferFunction({ lower: 128, upper: 128 }); + const [lower, upper] = cfun.getRange(); + + expect(upper).toBeGreaterThan(lower); + // Still a threshold at the requested value + expect((lower + upper) / 2).toBeCloseTo(128, 6); + + // And the range can still be moved afterwards + cfun.setMappingRange(0, 255); + expect(cfun.getRange()).toEqual([0, 255]); + }); +}); + +describe('createVOILUTSequenceTransferFunction', function () { + const voiLUT = { + firstValueMapped: 10, + numBitsPerEntry: 8, + lut: [0, 32, 64, 128, 255], + }; + + it('recognizes a renderable VOI LUT Sequence', () => { + expect(isRenderableVOILUT(voiLUT)).toBe(true); + expect(isRenderableVOILUT(undefined)).toBe(false); + expect(isRenderableVOILUT({ lut: [] })).toBe(false); + // Missing LUT Descriptor first value mapped + expect(isRenderableVOILUT({ lut: [1, 2, 3] })).toBe(false); + }); + + it('reports the LUT input domain', () => { + expect(getVOILUTSequenceRange(voiLUT)).toEqual({ lower: 10, upper: 14 }); + }); + + it('maps stored values through the LUT entries', () => { + const cfun = createVOILUTSequenceTransferFunction(voiLUT); + + expect(cfun).toBeDefined(); + + const rgb = [0, 0, 0]; + + cfun.getColor(10, rgb); + expect(rgb[0]).toBeCloseTo(0, 3); + + cfun.getColor(14, rgb); + expect(rgb[0]).toBeCloseTo(1, 3); + + cfun.getColor(12, rgb); + expect(rgb[0]).toBeCloseTo(64 / 255, 2); + + // Outside the mapped range the ends are held, as DICOM requires + cfun.getColor(-100, rgb); + expect(rgb[0]).toBeCloseTo(0, 3); + cfun.getColor(1000, rgb); + expect(rgb[0]).toBeCloseTo(1, 3); + }); + + it('stretches the curve over a requested range, preserving its shape', () => { + // Window level hands a new range in; the file's curve must be reshaped over + // it rather than replaced by a linear ramp + const cfun = createVOILUTSequenceTransferFunction(voiLUT, { + voiRange: { lower: 100, upper: 200 }, + }); + const rgb = [0, 0, 0]; + + cfun.getColor(100, rgb); + expect(rgb[0]).toBeCloseTo(0, 3); + + cfun.getColor(200, rgb); + expect(rgb[0]).toBeCloseTo(1, 3); + + // Entry 2 of 5 sits at the middle of the stretched domain and keeps its value + cfun.getColor(150, rgb); + expect(rgb[0]).toBeCloseTo(64 / 255, 2); + }); + + it('ignores a degenerate requested range', () => { + const cfun = createVOILUTSequenceTransferFunction(voiLUT, { + voiRange: { lower: 5, upper: 5 }, + }); + const rgb = [0, 0, 0]; + + // Falls back to the LUT's own domain instead of collapsing every node + cfun.getColor(14, rgb); + expect(rgb[0]).toBeCloseTo(1, 3); + }); + + it('decimates very large LUTs to a bounded number of nodes', () => { + const lut = Array.from({ length: 16384 }, (_, i) => i * 4); + const cfun = createVOILUTSequenceTransferFunction( + { firstValueMapped: 0, numBitsPerEntry: 16, lut }, + { maxNodes: 256 } + ); + + expect(cfun.getSize()).toBeLessThanOrEqual(257); + + const rgb = [0, 0, 0]; + cfun.getColor(16383, rgb); + expect(rgb[0]).toBeCloseTo(1, 2); + }); + + it('keeps a step transition that falls between sampled entries', () => { + // A threshold LUT whose step sits well inside a skipped span: with plain + // every-nth-entry sampling the transition was ramped across the whole span + // and landed up to `step` entries away from where the file put it + const length = 4096; + const stepIndex = 1000; + const lut = Array.from({ length }, (_, i) => (i < stepIndex ? 0 : 4095)); + const cfun = createVOILUTSequenceTransferFunction( + { firstValueMapped: 0, numBitsPerEntry: 12, lut }, + { maxNodes: 64 } + ); + const rgb = [0, 0, 0]; + + // Still black one entry below the step and white at it + cfun.getColor(stepIndex - 1, rgb); + expect(rgb[0]).toBeCloseTo(0, 2); + + cfun.getColor(stepIndex, rgb); + expect(rgb[0]).toBeCloseTo(1, 2); + + // The endpoints of the LUT domain stay exact + cfun.getColor(0, rgb); + expect(rgb[0]).toBeCloseTo(0, 3); + cfun.getColor(length - 1, rgb); + expect(rgb[0]).toBeCloseTo(1, 3); + }); + + it('returns undefined for a LUT it cannot use', () => { + expect(createVOILUTSequenceTransferFunction(undefined)).toBeUndefined(); + expect( + createVOILUTSequenceTransferFunction({ + firstValueMapped: 0, + lut: [0, 0, 0], + }) + ).toBeUndefined(); + }); +}); diff --git a/packages/core/test/utilities/setDefaultVolumeVOI.jest.js b/packages/core/test/utilities/setDefaultVolumeVOI.jest.js new file mode 100644 index 0000000000..d28ff090e8 --- /dev/null +++ b/packages/core/test/utilities/setDefaultVolumeVOI.jest.js @@ -0,0 +1,141 @@ +import { describe, it, expect, afterEach } from '@jest/globals'; +import { getDefaultVolumeVOIRange } from '../../src/RenderingEngine/helpers/setDefaultVolumeVOI'; +import * as metaData from '../../src/metaData'; +import { MetadataModules } from '../../src/enums'; + +// imageIds that no loader can resolve, so the min/max fallback cannot run and +// the assertions only see what the metadata produced +const imageIds = ['test:0', 'test:1', 'test:2', 'test:3', 'test:4']; +const volume = { imageIds, metadata: { Modality: 'CT' } }; + +function provideVOI(voiByImageId) { + const provider = (type, imageId) => + type === MetadataModules.VOI_LUT ? voiByImageId[imageId] : undefined; + + metaData.addProvider(provider, 10000); + + return provider; +} + +describe('getDefaultVolumeVOIRange', function () { + let provider; + + afterEach(() => { + if (provider) { + metaData.removeProvider(provider); + provider = undefined; + } + }); + + it('accepts a window center of 0', async () => { + // A center of 0 is a perfectly good window - it used to be tested for + // truthiness, so those series fell through to a min/max range + provider = provideVOI({ + 'test:2': { windowWidth: 400, windowCenter: 0 }, + }); + + await expect(getDefaultVolumeVOIRange(volume)).resolves.toEqual({ + lower: -200, + upper: 199, + }); + }); + + it('walks outwards when the middle instance has no window', async () => { + provider = provideVOI({ + 'test:3': { windowWidth: 400, windowCenter: 0 }, + }); + + await expect(getDefaultVolumeVOIRange(volume)).resolves.toEqual({ + lower: -200, + upper: 199, + }); + }); + + it('skips an instance whose window width is 0', async () => { + provider = provideVOI({ + 'test:2': { windowWidth: 0, windowCenter: 40 }, + 'test:3': { windowWidth: 400, windowCenter: 0 }, + }); + + await expect(getDefaultVolumeVOIRange(volume)).resolves.toEqual({ + lower: -200, + upper: 199, + }); + }); + + it('prefers the PT 0-5 range over a window the metadata carries', async () => { + // cornerstone3D#1806: PT window width/center is expressed in the unscaled + // counts, so applying it to SUV values blacks out the volume. The override + // used to run only when the metadata had no window at all, leaving the + // volume viewport disagreeing with the stack viewport + provider = provideVOI({ + 'test:2': { windowWidth: 30000, windowCenter: 15000 }, + }); + + const ptVolume = { + imageIds, + metadata: { Modality: 'PT' }, + isPreScaled: true, + scaling: { PT: { suvbw: 1 } }, + }; + + await expect(getDefaultVolumeVOIRange(ptVolume)).resolves.toEqual({ + lower: 0, + upper: 5, + }); + }); + + it('prefers the PT 0-5 range for a volume with no imageIds', async () => { + // Such a volume takes its window from its own metadata rather than from an + // instance. Scaling is a property of the volume, not of how its window was + // found, so the override has to apply on that path too + const ptVolume = { + imageIds: [], + metadata: { + Modality: 'PT', + voiLut: [{ windowWidth: 30000, windowCenter: 15000 }], + }, + isPreScaled: true, + scaling: { PT: { suvbw: 1 } }, + }; + + await expect(getDefaultVolumeVOIRange(ptVolume)).resolves.toEqual({ + lower: 0, + upper: 5, + }); + }); + + it('leaves an unscaled PT volume on its metadata window', async () => { + // Only a prescaled PT gets the SUV range - without scaling the counts are + // what the window describes + provider = provideVOI({ + 'test:2': { windowWidth: 400, windowCenter: 0 }, + }); + + await expect( + getDefaultVolumeVOIRange({ imageIds, metadata: { Modality: 'PT' } }) + ).resolves.toEqual({ lower: -200, upper: 199 }); + }); + + it('does not throw for a volume with neither imageIds nor a window', async () => { + await expect( + getDefaultVolumeVOIRange({ imageIds: [], metadata: {} }) + ).resolves.toBeUndefined(); + }); + + it('keeps the VOI LUT Function attached to the window', async () => { + provider = provideVOI({ + 'test:2': { + windowWidth: 400, + windowCenter: 0, + voiLUTFunction: 'LINEAR_EXACT', + }, + }); + + // LINEAR_EXACT has no half pixel offsets: c - w/2 .. c + w/2 + await expect(getDefaultVolumeVOIRange(volume)).resolves.toEqual({ + lower: -200, + upper: 200, + }); + }); +}); diff --git a/packages/core/test/utilities/voiLUTFunction.jest.js b/packages/core/test/utilities/voiLUTFunction.jest.js new file mode 100644 index 0000000000..9f207ceb7f --- /dev/null +++ b/packages/core/test/utilities/voiLUTFunction.jest.js @@ -0,0 +1,114 @@ +import { describe, it, expect } from '@jest/globals'; +import { + normalizeVOILUTFunction, + getValidVOILUTFunction, +} from '../../src/utilities/voiLUTFunction'; +import { toWindowLevel, toLowHighRange } from '../../src/utilities/windowLevel'; +import getVOIRangeFromWindowLevel from '../../src/utilities/getVOIRangeFromWindowLevel'; +import VOILUTFunctionType from '../../src/enums/VOILUTFunctionType'; + +describe('normalizeVOILUTFunction', function () { + it('accepts the DICOM defined terms', () => { + expect(normalizeVOILUTFunction('LINEAR')).toBe(VOILUTFunctionType.LINEAR); + expect(normalizeVOILUTFunction('LINEAR_EXACT')).toBe( + VOILUTFunctionType.LINEAR_EXACT + ); + expect(normalizeVOILUTFunction('SIGMOID')).toBe( + VOILUTFunctionType.SAMPLED_SIGMOID + ); + }); + + it('accepts a single element array, as DICOMweb JSON provides', () => { + expect(normalizeVOILUTFunction(['SIGMOID'])).toBe( + VOILUTFunctionType.SAMPLED_SIGMOID + ); + }); + + it('tolerates CS padding and casing', () => { + expect(normalizeVOILUTFunction(' sigmoid ')).toBe( + VOILUTFunctionType.SAMPLED_SIGMOID + ); + expect(normalizeVOILUTFunction('LINEAR_EXACT\0')).toBe( + VOILUTFunctionType.LINEAR_EXACT + ); + }); + + it('returns undefined for absent or unknown values', () => { + expect(normalizeVOILUTFunction(undefined)).toBeUndefined(); + expect(normalizeVOILUTFunction('')).toBeUndefined(); + expect(normalizeVOILUTFunction([])).toBeUndefined(); + expect(normalizeVOILUTFunction('BOGUS')).toBeUndefined(); + // A truncated value, which is what the createImage bug produced + expect(normalizeVOILUTFunction('S')).toBeUndefined(); + }); + + it('falls back to LINEAR rather than throwing on bad metadata', () => { + expect(getValidVOILUTFunction('S')).toBe(VOILUTFunctionType.LINEAR); + expect(getValidVOILUTFunction(undefined)).toBe(VOILUTFunctionType.LINEAR); + expect(getValidVOILUTFunction(42)).toBe(VOILUTFunctionType.LINEAR); + }); +}); + +describe('windowLevel conversions', function () { + it('uses the C.11.2.1.2.1 formula for LINEAR', () => { + expect(toLowHighRange(100, 50)).toEqual({ lower: 0, upper: 99 }); + }); + + it('uses the C.11.2.1.3.2 formula for LINEAR_EXACT', () => { + expect(toLowHighRange(100, 50, VOILUTFunctionType.LINEAR_EXACT)).toEqual({ + lower: 0, + upper: 100, + }); + }); + + it('carries the window unchanged for SIGMOID', () => { + // The sigmoid transfer function recovers width/center from the range, so + // the round trip has to be lossless + const { lower, upper } = toLowHighRange( + 1500, + -600, + VOILUTFunctionType.SAMPLED_SIGMOID + ); + expect( + toWindowLevel(lower, upper, VOILUTFunctionType.SAMPLED_SIGMOID) + ).toEqual({ windowWidth: 1500, windowCenter: -600 }); + }); + + it('round trips LINEAR_EXACT', () => { + const { lower, upper } = toLowHighRange( + 0.5, + 0.25, + VOILUTFunctionType.LINEAR_EXACT + ); + expect( + toWindowLevel(lower, upper, VOILUTFunctionType.LINEAR_EXACT) + ).toEqual({ windowWidth: 0.5, windowCenter: 0.25 }); + }); + + it('does not throw for an unsupported VOI LUT function', () => { + // cornerstone3D#2844: a malformed 0028,1056 used to throw + // "Invalid VOI LUT function" from here and break rendering entirely + expect(() => toLowHighRange(100, 50, 'S')).not.toThrow(); + expect(toLowHighRange(100, 50, 'S')).toEqual(toLowHighRange(100, 50)); + }); +}); + +describe('getVOIRangeFromWindowLevel', function () { + it('honors the VOI LUT function', () => { + expect(getVOIRangeFromWindowLevel(100, 50, 'LINEAR_EXACT')).toEqual({ + lower: 0, + upper: 100, + }); + }); + + it('accepts multi-valued window width/center', () => { + expect(getVOIRangeFromWindowLevel([100, 200], [50, 60])).toEqual({ + lower: 0, + upper: 99, + }); + }); + + it('returns undefined when there is no window', () => { + expect(getVOIRangeFromWindowLevel(undefined, undefined)).toBeUndefined(); + }); +}); diff --git a/packages/dicomImageLoader/src/__tests__/normalizeVOILUTSequence.spec.ts b/packages/dicomImageLoader/src/__tests__/normalizeVOILUTSequence.spec.ts new file mode 100644 index 0000000000..cc5fe48121 --- /dev/null +++ b/packages/dicomImageLoader/src/__tests__/normalizeVOILUTSequence.spec.ts @@ -0,0 +1,110 @@ +import normalizeVOILUTSequence from '../imageLoader/normalizeVOILUTSequence'; + +function toBase64(bytes: number[]): string { + return Buffer.from(Uint8Array.from(bytes)).toString('base64'); +} + +describe('normalizeVOILUTSequence', () => { + it('passes an already normalized (wadouri) LUT through', () => { + expect( + normalizeVOILUTSequence([ + { firstValueMapped: 10, numBitsPerEntry: 16, lut: [0, 128, 255] }, + ]) + ).toEqual({ + firstValueMapped: 10, + numBitsPerEntry: 16, + lut: [0, 128, 255], + }); + }); + + it('normalizes the naturalized dcmjs shape', () => { + expect( + normalizeVOILUTSequence({ + LUTDescriptor: [3, 10, 16], + LUTData: [0, 128, 255], + }) + ).toEqual({ + firstValueMapped: 10, + numBitsPerEntry: 16, + lut: [0, 128, 255], + }); + }); + + it('decodes 16 bit LUT Data from a buffer', () => { + const lut = Uint16Array.from([0, 4096, 65535]); + + expect( + normalizeVOILUTSequence({ + LUTDescriptor: [3, 10, 16], + LUTData: lut.buffer, + })?.lut + ).toEqual([0, 4096, 65535]); + }); + + it('decodes 8 bit LUT Data as one entry per byte', () => { + // LUT Descriptor value 3 is the width of an entry. Reading an 8 bit LUT as + // 16 bit words gave half a LUT of nonsense + const result = normalizeVOILUTSequence({ + LUTDescriptor: [4, 10, 8], + LUTData: Uint8Array.from([0, 64, 128, 255]).buffer, + }); + + expect(result?.lut).toEqual([0, 64, 128, 255]); + expect(result?.numBitsPerEntry).toBe(8); + }); + + it('decodes InlineBinary with the declared entry width', () => { + expect( + normalizeVOILUTSequence({ + '00283002': { Value: [4, 10, 8] }, + '00283006': { InlineBinary: toBase64([0, 64, 128, 255]) }, + })?.lut + ).toEqual([0, 64, 128, 255]); + + // Little endian 16 bit: 0x0000, 0x1000, 0xFFFF + expect( + normalizeVOILUTSequence({ + '00283002': { Value: [3, 10, 16] }, + '00283006': { InlineBinary: toBase64([0, 0, 0, 0x10, 0xff, 0xff]) }, + })?.lut + ).toEqual([0, 4096, 65535]); + }); + + it('reinterprets a byte view of a 16 bit LUT', () => { + expect( + normalizeVOILUTSequence({ + LUTDescriptor: [3, 10, 16], + LUTData: Uint8Array.from([0, 0, 0, 0x10, 0xff, 0xff]), + })?.lut + ).toEqual([0, 4096, 65535]); + }); + + it('trims to the data actually received', () => { + // Descriptor value 1 of 0 means 65536 entries, which no real item that + // short can hold + expect( + normalizeVOILUTSequence({ + LUTDescriptor: [0, 10, 16], + LUTData: [0, 128, 255], + })?.lut + ).toEqual([0, 128, 255]); + }); + + it('returns undefined for items it cannot use', () => { + expect(normalizeVOILUTSequence(undefined)).toBeUndefined(); + expect(normalizeVOILUTSequence([])).toBeUndefined(); + expect(normalizeVOILUTSequence({ LUTDescriptor: [3, 10] })).toBeUndefined(); + expect( + normalizeVOILUTSequence({ LUTDescriptor: [3, 10, 16], LUTData: [] }) + ).toBeUndefined(); + }); + + it('takes the first usable item of the sequence', () => { + expect( + normalizeVOILUTSequence([ + { LUTDescriptor: [3, 10] }, + { LUTDescriptor: [3, 20, 16], LUTData: [1, 2, 3] }, + ]) + ).toEqual({ firstValueMapped: 20, numBitsPerEntry: 16, lut: [1, 2, 3] }); + }); +}); diff --git a/packages/dicomImageLoader/src/__tests__/wadouriDataSetLayer.spec.ts b/packages/dicomImageLoader/src/__tests__/wadouriDataSetLayer.spec.ts index 4433913aef..b48ae74324 100644 --- a/packages/dicomImageLoader/src/__tests__/wadouriDataSetLayer.spec.ts +++ b/packages/dicomImageLoader/src/__tests__/wadouriDataSetLayer.spec.ts @@ -301,7 +301,7 @@ describe('wadouri dataSet-layer', () => { {}, { x00283002: { length: 6 }, - x00283006: { length: 2 }, + x00283006: { length: 4 }, } ); // uint16/int16 index-based access on the LUT item dataset @@ -351,6 +351,146 @@ describe('wadouri dataSet-layer', () => { ]); }); + it('reads LUT Data as unsigned even for a signed pixel representation', () => { + // Only First Value Mapped is in pixel space; LUT Data holds output + // values and is always unsigned. Reading it as int16 turned every entry + // above 32767 negative, which also broke the "largest entry decides the + // bit depth" heuristic the renderers use. + const voiLutItemDataSet = fakeDataSet( + {}, + { + x00283002: { length: 6 }, + x00283006: { length: 4 }, + } + ); + (voiLutItemDataSet as any).uint16 = (t: string, i = 0) => { + if (t === 'x00283002') { + return [2, 65531, 16][i]; + } + if (t === 'x00283006') { + return [100, 65535][i]; + } + return undefined; + }; + (voiLutItemDataSet as any).int16 = (t: string, i = 0) => { + if (t === 'x00283002') { + return [2, -5, 16][i]; + } + if (t === 'x00283006') { + return [100, -1][i]; + } + return undefined; + }; + + const dataSet = fakeDataSet( + // CT SOP class -> signed modality LUT output pixel representation + { x00080016: '1.2.840.10008.5.1.4.1.1.2' }, + { x00283010: { items: [{ dataSet: voiLutItemDataSet }] } } + ); + + const result = metadataForDataset( + 'voiLutModule', + 'imageId', + dataSet as any + ); + + expect(result.voiLUTSequence[0].firstValueMapped).toBe(-5); + expect(result.voiLUTSequence[0].lut).toEqual([100, 65535]); + }); + + it('reads a zero entry count as 65536 entries', () => { + // Value 1 of LUT Descriptor is a US, which cannot hold 65536, so 0 + // means 65536 (PS3.3 C.11.1.1). Reading it as 65535 dropped the last + // entry of every full size LUT. + const voiLutItemDataSet = fakeDataSet( + {}, + { + x00283002: { length: 6 }, + x00283006: { length: 131072 }, + } + ); + (voiLutItemDataSet as any).uint16 = (t: string, i = 0) => { + if (t === 'x00283002') { + return [0, 0, 16][i]; + } + if (t === 'x00283006') { + return i; + } + return undefined; + }; + + const dataSet = fakeDataSet( + { x00280103: 0 }, + { x00283010: { items: [{ dataSet: voiLutItemDataSet }] } } + ); + + const result = metadataForDataset( + 'voiLutModule', + 'imageId', + dataSet as any + ); + + const { lut } = result.voiLUTSequence[0]; + + expect(lut.length).toBe(65536); + expect(lut[65535]).toBe(65535); + }); + + it('reads the window from the Frame VOI LUT macro of an enhanced multi frame', () => { + // Enhanced SOPs carry the VOI inside FrameVOILUTSequence (0028,9132) in + // the functional groups, and the frame combiner leaves it one level + // below the root (cornerstone3D#2745) + const frameVOIDataSet = fakeDataSet({ + x00281050: '-600', + x00281051: '1500', + x00281056: 'SIGMOID', + }); + + const dataSet = fakeDataSet( + {}, + { + x00289132: { items: [{ dataSet: frameVOIDataSet }] }, + } + ); + + const result = metadataForDataset( + 'voiLutModule', + 'imageId', + dataSet as any + ); + + expect(result.windowCenter).toEqual([-600]); + expect(result.windowWidth).toEqual([1500]); + expect(result.voiLUTFunction).toBe('SIGMOID'); + }); + + it('prefers a window at the root over the Frame VOI LUT macro', () => { + const frameVOIDataSet = fakeDataSet({ + x00281050: '-600', + x00281051: '1500', + }); + + const dataSet = fakeDataSet( + { + x00281050: '40', + x00281051: '400', + }, + { + x00281050: { length: 2 }, + x00289132: { items: [{ dataSet: frameVOIDataSet }] }, + } + ); + + const result = metadataForDataset( + 'voiLutModule', + 'imageId', + dataSet as any + ); + + expect(result.windowCenter).toEqual([40]); + expect(result.windowWidth).toEqual([400]); + }); + it('returns undefined voiLUTSequence when no sequence element is present', () => { const dataSet = fakeDataSet({ x00281050: '40\\50', diff --git a/packages/dicomImageLoader/src/imageLoader/createImage.ts b/packages/dicomImageLoader/src/imageLoader/createImage.ts index 1b33cc4db2..cadbb83762 100644 --- a/packages/dicomImageLoader/src/imageLoader/createImage.ts +++ b/packages/dicomImageLoader/src/imageLoader/createImage.ts @@ -17,6 +17,7 @@ import { getOptions } from './internal/options'; import isColorImageFn from '../shared/isColorImage'; import removeAFromRGBA from './removeAFromRGBA'; import isModalityLUTForDisplay from './isModalityLutForDisplay'; +import normalizeVOILUTSequence from './normalizeVOILUTSequence'; import setPixelDataType from './setPixelDataType'; import { fetchPaletteData } from './colorSpaceConverters/fetchPaletteData'; @@ -411,11 +412,16 @@ async function createImage( windowWidth: voiLutModule.windowWidth ? voiLutModule.windowWidth[0] : undefined, + // VOI LUT Function (0028,1056) reaches us as a string from + // dicom-parser, as a single element array from DICOMweb JSON, and + // occasionally under the older `voiLutFunction` spelling. Indexing a + // string yields its first *character*, which is what turned "SIGMOID" + // into "S" and made rendering throw "Invalid VOI LUT function" + // (cornerstone3D#2844), so normalize all the shapes here. voiLUTFunction: - (voiLutModule.voiLUTFunction?.length && - voiLutModule.voiLUTFunction[0]) || - voiLutModule.voiLutFunction || - undefined, + utilities.normalizeVOILUTFunction( + voiLutModule.voiLUTFunction ?? voiLutModule.voiLutFunction + ) ?? undefined, decodeTimeInMS: imageFrame.decodeTimeInMS, floatPixelData: undefined, imageFrame, @@ -486,12 +492,15 @@ async function createImage( image.modalityLUT = modalityLutModule.modalityLUTSequence[0]; } - // VOI LUT - if ( - voiLutModule.voiLUTSequence && - voiLutModule.voiLUTSequence.length > 0 - ) { - image.voiLUT = voiLutModule.voiLUTSequence[0]; + // VOI LUT Sequence (0028,3010). Providers hand this back in several + // shapes (already parsed, naturalized dcmjs, or raw DICOMweb JSON), so + // normalize before handing it to the renderers. + const voiLUT = normalizeVOILUTSequence( + voiLutModule.voiLUTSequence ?? voiLutModule.VOILUTSequence + ); + + if (voiLUT) { + image.voiLUT = voiLUT; } if (image.color) { diff --git a/packages/dicomImageLoader/src/imageLoader/normalizeVOILUTSequence.ts b/packages/dicomImageLoader/src/imageLoader/normalizeVOILUTSequence.ts new file mode 100644 index 0000000000..030e10db3b --- /dev/null +++ b/packages/dicomImageLoader/src/imageLoader/normalizeVOILUTSequence.ts @@ -0,0 +1,174 @@ +import type { Types } from '@cornerstonejs/core'; + +type LUTLike = Types.CPUFallbackLUT; + +/** + * The shapes a VOI LUT Sequence (0028,3010) item can arrive in, depending on + * which metadata provider produced it: + * + * - the wadouri provider already returns `{ firstValueMapped, numBitsPerEntry, lut }` + * - the naturalized (dcmjs) providers return `{ LUTDescriptor, LUTData }` + * - raw DICOMweb JSON returns `{ '00283002': { Value }, '00283006': { Value | InlineBinary } }` + * + * Rendering only cares about the first form, so everything is converted to it. + */ +type RawLUTItem = Record; + +/** + * Decodes a buffer of LUT Data. Entries are 16 bits (LUT Data is US/OW) unless + * LUT Descriptor declares 8 bits per entry, in which case they are packed one + * per byte and reading them as 16 bit words gives half a LUT of nonsense. + */ +function fromBuffer(buffer: ArrayBufferLike, bitsPerEntry?: number): number[] { + if (bitsPerEntry === 8) { + return Array.from(new Uint8Array(buffer)); + } + + return Array.from( + new Uint16Array(buffer, 0, Math.floor(buffer.byteLength / 2)) + ); +} + +function toNumberArray( + data: unknown, + bitsPerEntry?: number +): number[] | undefined { + if (!data) { + return undefined; + } + + if (Array.isArray(data)) { + // A single element array holding the buffer, as bulkdata sometimes arrives + if (data.length === 1 && isBinary(data[0])) { + return toNumberArray(data[0], bitsPerEntry); + } + + return data.every((value) => typeof value === 'number') + ? (data as number[]) + : undefined; + } + + if (ArrayBuffer.isView(data) && !(data instanceof DataView)) { + const view = data as ArrayBufferView & { BYTES_PER_ELEMENT?: number }; + + // A byte view of a 16 bit LUT is a buffer that has not been reinterpreted + // yet, rather than one entry per element + if (view.BYTES_PER_ELEMENT === 1 && bitsPerEntry > 8) { + return fromBuffer( + view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength), + bitsPerEntry + ); + } + + return Array.from(view as unknown as ArrayLike); + } + + if (data instanceof ArrayBuffer) { + return fromBuffer(data, bitsPerEntry); + } + + return undefined; +} + +function isBinary(value: unknown): boolean { + return ( + value instanceof ArrayBuffer || + (ArrayBuffer.isView(value) && !(value instanceof DataView)) + ); +} + +function fromInlineBinary( + inlineBinary: unknown, + bitsPerEntry?: number +): number[] | undefined { + if (typeof inlineBinary !== 'string' || typeof atob !== 'function') { + return undefined; + } + + const binary = atob(inlineBinary); + const bytes = new Uint8Array(binary.length); + + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + // DICOM JSON InlineBinary is little endian + return fromBuffer(bytes.buffer, bitsPerEntry); +} + +/** + * Normalizes a VOI LUT Sequence into the `{ firstValueMapped, numBitsPerEntry, + * lut }` shape the renderers consume, accepting any of the provider shapes + * described above. + * + * @param voiLUTSequence - the VOI LUT Sequence, or a single item of it + * @returns the first usable LUT of the sequence, or undefined + */ +export default function normalizeVOILUTSequence( + voiLUTSequence: unknown +): LUTLike | undefined { + const items = Array.isArray(voiLUTSequence) + ? voiLUTSequence + : [voiLUTSequence]; + + for (const item of items) { + const lut = normalizeItem(item as RawLUTItem); + + if (lut) { + return lut; + } + } + + return undefined; +} + +function normalizeItem(item: RawLUTItem): LUTLike | undefined { + if (!item || typeof item !== 'object') { + return undefined; + } + + // Already normalized (wadouri) + const existing = toNumberArray(item.lut); + + if (existing?.length) { + return { + lut: existing, + firstValueMapped: Number(item.firstValueMapped) || 0, + numBitsPerEntry: Number(item.numBitsPerEntry) || undefined, + }; + } + + const descriptor = + toNumberArray(item.LUTDescriptor) ?? + toNumberArray((item['00283002'] as RawLUTItem)?.Value); + + if (!descriptor || descriptor.length < 3) { + return undefined; + } + + // Value 3 of LUT Descriptor is how wide an entry is, so it has to be known + // before the LUT Data can be decoded + const bitsPerEntry = descriptor[2]; + const data = + toNumberArray(item.LUTData, bitsPerEntry) ?? + toNumberArray((item['00283006'] as RawLUTItem)?.Value, bitsPerEntry) ?? + fromInlineBinary( + (item['00283006'] as RawLUTItem)?.InlineBinary, + bitsPerEntry + ); + + if (!data?.length) { + return undefined; + } + + // LUT Descriptor value 1 of 0 means 65536 entries (it is a US that cannot + // hold 65536), and cannot be trusted beyond the data we actually received. + const declaredEntries = descriptor[0] === 0 ? 65536 : descriptor[0]; + const numEntries = Math.min(declaredEntries, data.length); + + return { + lut: data.length === numEntries ? data : data.slice(0, numEntries), + firstValueMapped: descriptor[1] ?? 0, + numBitsPerEntry: descriptor[2], + }; +} diff --git a/packages/dicomImageLoader/src/imageLoader/wadors/metaData/metaDataProvider.ts b/packages/dicomImageLoader/src/imageLoader/wadors/metaData/metaDataProvider.ts index 16991b768d..0a1ce0d747 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadors/metaData/metaDataProvider.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadors/metaData/metaDataProvider.ts @@ -10,6 +10,7 @@ import metaDataManager, { retrieveMultiframeMetadataImageId, } from '../metaDataManager'; import getValue from './getValue'; +import getSequenceItems from './getSequenceItems'; import { getMultiframeInformation, getFrameInformation, @@ -257,11 +258,17 @@ function metaDataProvider(type, imageId) { } if (type === MetadataModules.VOI_LUT) { + // Passed through as raw DICOMweb JSON items - createImage normalizes the + // LUT Descriptor/LUT Data into the shape the renderers use. Items whose LUT + // Data is only a BulkDataURI cannot be resolved here (that needs an async + // fetch) and are dropped by the normalizer. + const voiLUTSequence = getSequenceItems(metaData['00283010']); + return { windowCenter: getNumberValues(metaData['00281050'], 1), windowWidth: getNumberValues(metaData['00281051'], 1), voiLUTFunction: getValue(metaData['00281056']), - // TODO VOT LUT Sequence + voiLUTSequence: voiLUTSequence.length ? voiLUTSequence : undefined, }; } diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/getLUTs.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/getLUTs.ts index 465054bf7f..39ef14ba6a 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/getLUTs.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/getLUTs.ts @@ -4,11 +4,16 @@ import type { LutType } from '../../../types'; function getLUT(pixelRepresentation: number, lutDataSet: DataSet): LutType { let numLUTEntries = lutDataSet.uint16('x00283002', 0); + // Value 1 of LUT Descriptor is a US, which cannot hold 65536, so 0 means + // 65536 entries (PS3.3 C.11.1.1). Reading it as 65535 dropped the last entry + // of every full size LUT. if (numLUTEntries === 0) { - numLUTEntries = 65535; + numLUTEntries = 65536; } let firstValueMapped = 0; + // Only value 2, First Value Mapped, is interpreted with the pixel + // representation: it lives in the same space as the pixel data it maps. if (pixelRepresentation === 0) { firstValueMapped = lutDataSet.uint16('x00283002', 1); } else { @@ -23,13 +28,23 @@ function getLUT(pixelRepresentation: number, lutDataSet: DataSet): LutType { lut: [], }; - // console.log("minValue=", minValue, "; maxValue=", maxValue); + // The descriptor cannot be trusted beyond the data actually received - a + // short LUT Data would otherwise fill the tail of the table with undefined + const lutDataElement = lutDataSet.elements.x00283006; + + if (lutDataElement) { + numLUTEntries = Math.min( + numLUTEntries, + Math.floor(lutDataElement.length / 2) + ); + } + + // LUT Data is always unsigned, whatever the pixel representation says: it + // holds output values, not pixel values. Reading it as int16 turned every + // entry above 32767 negative, which also broke the "largest entry decides the + // bit depth" heuristic the renderers use. for (let i = 0; i < numLUTEntries; i++) { - if (pixelRepresentation === 0) { - lut.lut[i] = lutDataSet.uint16('x00283006', i); - } else { - lut.lut[i] = lutDataSet.int16('x00283006', i); - } + lut.lut[i] = lutDataSet.uint16('x00283006', i); } return lut; diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts index 391ae586f0..0dd398b6cd 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts @@ -65,6 +65,20 @@ function metaDataProvider(type, imageId) { return metadataForDataset(type, imageId, dataSet); } +/** + * The dataset the VOI attributes live in: the Frame VOI LUT Sequence + * (0028,9132) item when the root carries no window of its own, otherwise the + * dataset itself. Root tags win, so a legacy window on an enhanced object still + * takes precedence over the macro. + */ +function resolveVOIDataSet(dataSet: dicomParser.DataSet): dicomParser.DataSet { + if (dataSet.elements.x00281050 || dataSet.elements.x00281051) { + return dataSet; + } + + return dataSet.elements.x00289132?.items?.[0]?.dataSet ?? dataSet; +} + export function metadataForDataset( type, imageId, @@ -212,15 +226,23 @@ export function metadataForDataset( if (type === MetadataModules.VOI_LUT) { const modalityLUTOutputPixelRepresentation = getModalityLUTOutputPixelRepresentation(dataSet); + // Enhanced multi frame SOPs (Enhanced CT/MR/PT/US/XA) put the VOI in the + // Frame VOI LUT macro inside the shared or per frame functional groups + // rather than at the dataset root. The frame combiner copies the macro + // itself to the root but not its contents, so the window stays one level + // deeper than the tags read below and the viewport fell back to the image + // min/max (cornerstone3D#2745). wadors reads the same file correctly, hence + // the same image looking different through the two loaders. + const voiDataSet = resolveVOIDataSet(dataSet); return { - windowCenter: getNumberValues(dataSet, 'x00281050', 1), - windowWidth: getNumberValues(dataSet, 'x00281051', 1), + windowCenter: getNumberValues(voiDataSet, 'x00281050', 1), + windowWidth: getNumberValues(voiDataSet, 'x00281051', 1), voiLUTSequence: getLUTs( modalityLUTOutputPixelRepresentation, - dataSet.elements.x00283010 + voiDataSet.elements.x00283010 ), - voiLUTFunction: dataSet.string('x00281056'), + voiLUTFunction: voiDataSet.string('x00281056'), }; } diff --git a/packages/metadata/src/utilities/modules/voiLut.ts b/packages/metadata/src/utilities/modules/voiLut.ts index 23c678d3b3..7de8490f11 100644 --- a/packages/metadata/src/utilities/modules/voiLut.ts +++ b/packages/metadata/src/utilities/modules/voiLut.ts @@ -6,4 +6,7 @@ export const tags: ModuleTagEntry[] = [ 'WindowWidth', 'VOILUTFunction', 'WindowCenterWidthExplanation', + // Naturalized as `voiLUTSequence`; when present it defines the VOI + // transformation instead of the window (C.11.2.1) + 'VOILUTSequence', ]; diff --git a/packages/tools/examples/voiLutFunction/index.ts b/packages/tools/examples/voiLutFunction/index.ts new file mode 100644 index 0000000000..ad57803002 --- /dev/null +++ b/packages/tools/examples/voiLutFunction/index.ts @@ -0,0 +1,342 @@ +import type { Types } from '@cornerstonejs/core'; +import { + Enums, + RenderingEngine, + getShouldUseCPURendering, + metaData, + setUseCPURendering, + utilities, +} from '@cornerstonejs/core'; +import cornerstoneDICOMImageLoader from '@cornerstonejs/dicom-image-loader'; +import * as cornerstoneTools from '@cornerstonejs/tools'; + +import { + initDemo, + setTitleAndDescription, + addButtonToToolbar, + addToggleButtonToToolbar, + addDropdownToToolbar, +} from '../../../../utils/demo/helpers'; +import { prefetchMetadataInformation } from '../../../../utils/demo/helpers/convertMultiframeImageIds'; + +const { + PanTool, + WindowLevelTool, + ZoomTool, + StackScrollTool, + ToolGroupManager, + Enums: csToolsEnums, +} = cornerstoneTools; + +const { MouseBindings } = csToolsEnums; +const { ViewportType, VOILUTFunctionType } = Enums; + +// This is for debugging purposes +console.warn( + 'Click on index.ts to open source code for this example --------->' +); + +// ======== Set up page ======== // +setTitleAndDescription( + 'VOI LUT Function and VOI LUT Sequence (local DICOM)', + 'Drop a local DICOM P10 file (or pick one) to inspect how its VOI is applied. ' + + 'Shows VOI LUT Function (0028,1056) - LINEAR, LINEAR_EXACT and SIGMOID - and, ' + + 'when the file carries a VOI LUT Sequence (0028,3010), lets you compare the ' + + 'sequence against a plain window.' +); + +const renderingEngineId = 'VOI_LUT_RENDERING_ENGINE'; +const viewportId = 'VOI_LUT_STACK'; +const toolGroupId = 'VOI_LUT_TOOL_GROUP'; + +const content = document.getElementById('content'); + +const instructions = document.createElement('p'); +instructions.innerText = + 'Left click drag: window level | Middle drag: pan | Right drag: zoom'; +content.appendChild(instructions); + +const form = document.createElement('form'); +form.style.marginBottom = '10px'; +const fileInput = document.createElement('input'); +fileInput.type = 'file'; +fileInput.accept = '.dcm,application/dicom'; +form.appendChild(fileInput); +content.appendChild(form); + +const layout = document.createElement('div'); +layout.style.display = 'flex'; +layout.style.flexDirection = 'row'; +content.appendChild(layout); + +const element = document.createElement('div'); +element.id = 'cornerstone-element'; +element.style.width = '512px'; +element.style.height = '512px'; +element.style.border = '1px dashed #999'; +element.oncontextmenu = (e) => e.preventDefault(); +layout.appendChild(element); + +const info = document.createElement('div'); +info.style.marginLeft = '20px'; +info.style.fontFamily = 'monospace'; +info.style.whiteSpace = 'pre'; +info.innerText = 'Drop a DICOM file on the viewport, or use the file picker.'; +layout.appendChild(info); + +// ============================= // + +let renderingEngine: RenderingEngine; +let viewport: Types.IStackViewport; +let currentImageId: string; +// Mirrors what the viewport tracks internally: asking for a VOI LUT Function is +// the opt out from the image's own VOI LUT Sequence +let voiLUTFunctionRequested = false; + +/** + * (Re)creates the stack viewport. + */ +function createViewport() { + renderingEngine.enableElement({ + viewportId, + type: ViewportType.STACK, + element, + defaultOptions: { + background: [0.2, 0, 0.2] as Types.Point3, + }, + }); + + viewport = renderingEngine.getViewport(viewportId) as Types.IStackViewport; + + ToolGroupManager.getToolGroup(toolGroupId).addViewport( + viewportId, + renderingEngineId + ); +} + +/** + * Renders everything that decides how the VOI is applied for the loaded image: + * the window from the file, the VOI LUT Function, the VOI LUT Sequence (if any) + * and what the viewport ended up using. + */ +function updateInfo() { + if (!currentImageId) { + return; + } + + const voiLutModule = metaData.get('voiLutModule', currentImageId) || {}; + const properties = viewport.getProperties(); + // image.voiLUT is the normalized VOI LUT Sequence the loader attached + const voiLUT = viewport.getCornerstoneImage()?.voiLUT; + + const lines = [ + `rendering : ${ + getShouldUseCPURendering() ? 'CPU fallback' : 'GPU (vtk.js)' + }`, + `windowCenter (0028,1050): ${voiLutModule.windowCenter ?? '-'}`, + `windowWidth (0028,1051): ${voiLutModule.windowWidth ?? '-'}`, + `VOILUTFunction (0028,1056): ${voiLutModule.voiLUTFunction ?? '(absent)'}`, + `VOI LUT Sequence (0028,3010): ${ + voiLUT + ? `${voiLUT.lut.length} entries, firstValueMapped ${ + voiLUT.firstValueMapped + }, ${voiLUT.numBitsPerEntry ?? '?'} bits/entry` + : '(absent)' + }`, + '', + `viewport VOILUTFunction : ${properties.VOILUTFunction}`, + `viewport voiRange : ${ + properties.voiRange + ? `${properties.voiRange.lower.toFixed( + 2 + )} .. ${properties.voiRange.upper.toFixed(2)}` + : '-' + }`, + `voiRange source : ${ + properties.isComputedVOI ? 'metadata / VOI LUT' : 'set explicitly' + }`, + `applied VOI : ${ + voiLUT && !voiLUTFunctionRequested + ? 'VOI LUT Sequence, stretched over voiRange' + : `${properties.VOILUTFunction} over voiRange` + }`, + ]; + + info.innerText = lines.join('\n'); +} + +async function loadAndViewImage(imageId: string) { + currentImageId = imageId; + voiLUTFunctionRequested = false; + + await prefetchMetadataInformation([imageId]); + await viewport.setStack([imageId]); + + viewport.render(); + updateInfo(); +} + +async function reloadCurrentImage() { + if (!currentImageId) { + return; + } + + await loadAndViewImage(currentImageId); +} + +fileInput.addEventListener('change', (evt: Event) => { + const file = (evt.target as HTMLInputElement).files?.[0]; + + if (!file) { + return; + } + + void loadAndViewImage( + cornerstoneDICOMImageLoader.wadouri.fileManager.add(file) + ); +}); + +element.addEventListener('dragover', (evt: DragEvent) => { + evt.stopPropagation(); + evt.preventDefault(); + evt.dataTransfer.dropEffect = 'copy'; +}); + +element.addEventListener('drop', (evt: DragEvent) => { + evt.stopPropagation(); + evt.preventDefault(); + + const file = evt.dataTransfer.files[0]; + + if (!file) { + return; + } + + void loadAndViewImage( + cornerstoneDICOMImageLoader.wadouri.fileManager.add(file) + ); +}); + +// Keyed by the DICOM defined term of VOI LUT Function (0028,1056) +const voiLUTFunctions = { + LINEAR: VOILUTFunctionType.LINEAR, + LINEAR_EXACT: VOILUTFunctionType.LINEAR_EXACT, + SIGMOID: VOILUTFunctionType.SAMPLED_SIGMOID, +}; + +addDropdownToToolbar({ + labelText: 'VOI LUT Function: ', + options: { + values: Object.keys(voiLUTFunctions), + defaultValue: 'LINEAR', + }, + onSelectedValueChange: (key) => { + voiLUTFunctionRequested = true; + viewport.setProperties({ + VOILUTFunction: voiLUTFunctions[key as keyof typeof voiLUTFunctions], + }); + viewport.render(); + updateInfo(); + }, +}); + +addButtonToToolbar({ + title: 'Back to the VOI LUT Sequence', + onClick: () => { + // resetProperties drops the explicitly requested VOI LUT Function, which is + // what opts back in to the file's own VOI LUT Sequence + voiLUTFunctionRequested = false; + viewport.resetProperties(); + viewport.render(); + updateInfo(); + }, +}); + +addButtonToToolbar({ + title: "Use the file's window instead", + onClick: () => { + const voiLutModule = metaData.get('voiLutModule', currentImageId) || {}; + const windowWidth = Array.isArray(voiLutModule.windowWidth) + ? voiLutModule.windowWidth[0] + : voiLutModule.windowWidth; + const windowCenter = Array.isArray(voiLutModule.windowCenter) + ? voiLutModule.windowCenter[0] + : voiLutModule.windowCenter; + + if (windowWidth === undefined || windowCenter === undefined) { + info.innerText = 'This file has no Window Center/Width to fall back to.'; + + return; + } + + // Requesting a VOI LUT Function is the opt out from the VOI LUT Sequence; + // an explicit range alone would only stretch the sequence's curve + const voiLUTFunction = + viewport.getProperties().VOILUTFunction ?? VOILUTFunctionType.LINEAR; + const voiRange = utilities.windowLevel.toLowHighRange( + Number(windowWidth), + Number(windowCenter), + voiLUTFunction + ); + + voiLUTFunctionRequested = true; + viewport.setProperties({ VOILUTFunction: voiLUTFunction, voiRange }); + viewport.render(); + updateInfo(); + }, +}); + +addToggleButtonToToolbar({ + title: 'Use CPU rendering', + defaultToggle: false, + onClick: (toggle) => { + setUseCPURendering(toggle); + + // Rebuild the element so the engine re-registers the viewport for the new + // pipeline. See createViewport. + renderingEngine.disableElement(viewportId); + createViewport(); + + void reloadCurrentImage(); + }, +}); + +/** + * Runs the demo + */ +async function run() { + await initDemo(); + + cornerstoneTools.addTool(PanTool); + cornerstoneTools.addTool(WindowLevelTool); + cornerstoneTools.addTool(ZoomTool); + cornerstoneTools.addTool(StackScrollTool); + + const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + + toolGroup.addTool(WindowLevelTool.toolName); + toolGroup.addTool(PanTool.toolName); + toolGroup.addTool(ZoomTool.toolName); + toolGroup.addTool(StackScrollTool.toolName); + + toolGroup.setToolActive(WindowLevelTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + toolGroup.setToolActive(PanTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Auxiliary }], + }); + toolGroup.setToolActive(ZoomTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Secondary }], + }); + toolGroup.setToolActive(StackScrollTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Wheel }], + }); + + renderingEngine = new RenderingEngine(renderingEngineId); + + createViewport(); + + element.addEventListener(Enums.Events.VOI_MODIFIED, updateInfo); +} + +run(); diff --git a/utils/ExampleRunner/example-info.json b/utils/ExampleRunner/example-info.json index 7d6159c495..a9be2e100d 100644 --- a/utils/ExampleRunner/example-info.json +++ b/utils/ExampleRunner/example-info.json @@ -129,6 +129,10 @@ "name": "DICOM P10 from the local file system", "description": "Provides an interface to load a DICOM P10 image from your local file system to the Cornerstone3D" }, + "voiLutFunction": { + "name": "VOI LUT Function and VOI LUT Sequence", + "description": "Loads a local DICOM P10 file and shows how its VOI is applied: LINEAR / LINEAR_EXACT / SIGMOID VOI LUT Function (0028,1056), and the VOI LUT Sequence (0028,3010) compared against a plain window, on both the GPU and CPU paths" + }, "advancedLocal": { "name": "DICOM P10 with annotation and CPU choice", "description": "Annotation tools with drag and drop and CPU/GPU choice" From e0279ba9b5dade34a1613eeb47c5128d6383cebd Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Fri, 14 Aug 2026 23:16:19 +0200 Subject: [PATCH 02/13] fix(dicomImageLoader): ignore windowing/VOI tags when decoding color images Windowing and VOI LUT attributes only apply to monochrome images (DICOM PS3.3 C.11.2.1.2.2), but createImage applied them to any image carrying them. A color instance with RescaleSlope/Intercept, a window, a VOILUTFunction or a VOI/Modality LUT Sequence rendered wrong - typically blown out, since the grayscale range is nowhere near the [0, 255] range of the color samples. Color images now get the identity modality transform, no prescaling and no VOI at all, leaving the existing 256/128 identity window as the sole VOI source. This matters more since VOI LUT Sequence support landed: StackViewport now derives its initial VOI range from image.voiLUT and honours non LINEAR voiLUTFunction on the CPU path, neither of which checks image.color. --- .../src/__tests__/createImageColorVOI.spec.ts | 156 ++++++++++++++++++ .../src/imageLoader/createImage.ts | 118 +++++++------ 2 files changed, 226 insertions(+), 48 deletions(-) create mode 100644 packages/dicomImageLoader/src/__tests__/createImageColorVOI.spec.ts diff --git a/packages/dicomImageLoader/src/__tests__/createImageColorVOI.spec.ts b/packages/dicomImageLoader/src/__tests__/createImageColorVOI.spec.ts new file mode 100644 index 0000000000..c374d15c0d --- /dev/null +++ b/packages/dicomImageLoader/src/__tests__/createImageColorVOI.spec.ts @@ -0,0 +1,156 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { metaData } from '@cornerstonejs/core'; + +import createImage from '../imageLoader/createImage'; +import getImageFrame from '../imageLoader/getImageFrame'; +import decodeImageFrame from '../imageLoader/decodeImageFrame'; +import type { DICOMLoaderIImage } from '../types'; + +jest.mock('../imageLoader/getImageFrame'); +jest.mock('../imageLoader/decodeImageFrame'); + +const COLOR_IMAGE_ID = 'test:color'; +const GRAYSCALE_IMAGE_ID = 'test:grayscale'; + +/** + * A VOI LUT Sequence, a VOI LUT Function, a window and a modality LUT all at + * once. Per DICOM PS3.3 C.11.2.1.2.2 none of these apply to a color image, so + * the same metadata has to produce very different images depending only on the + * photometric interpretation. + */ +const VOI_LUT_SEQUENCE = [ + { firstValueMapped: 0, numBitsPerEntry: 16, lut: [0, 128, 255] }, +]; +const MODALITY_LUT_SEQUENCE = [ + { firstValueMapped: 0, numBitsPerEntry: 16, lut: [0, 512, 1024] }, +]; + +const METADATA = { + voiLutModule: { + windowCenter: [40], + windowWidth: [400], + voiLUTFunction: 'SIGMOID', + voiLUTSequence: VOI_LUT_SEQUENCE, + }, + modalityLutModule: { + rescaleIntercept: -1024, + rescaleSlope: 2, + modalityLUTSequence: MODALITY_LUT_SEQUENCE, + }, + generalSeriesModule: { modality: 'CT' }, + // CT Image Storage - makes isModalityLUTForDisplay() true + sopCommonModule: { sopClassUID: '1.2.840.10008.5.1.4.1.1.2' }, + imagePlaneModule: {}, + calibrationModule: {}, + scalingModule: {}, +}; + +function metadataProvider(type: string) { + return METADATA[type]; +} + +function makeImageFrame(imageId: string) { + const isColor = imageId === COLOR_IMAGE_ID; + const rows = 2; + const columns = 2; + const samplesPerPixel = isColor ? 3 : 1; + const pixelDataLength = rows * columns * samplesPerPixel; + + return { + imageId, + rows, + columns, + samplesPerPixel, + photometricInterpretation: isColor ? 'RGB' : 'MONOCHROME2', + planarConfiguration: 0, + bitsAllocated: 8, + bitsStored: 8, + highBit: 7, + pixelRepresentation: 0, + smallestPixelValue: 0, + largestPixelValue: 255, + pixelDataLength, + pixelData: new Uint8Array(pixelDataLength).fill(120), + }; +} + +/** The options object handed to `decodeImageFrame` for the last decode. */ +let lastDecodeOptions; + +describe('createImage - windowing/VOI on color images (PS3.3 C.11.2.1.2.2)', () => { + beforeEach(() => { + lastDecodeOptions = undefined; + + (getImageFrame as jest.Mock).mockImplementation(makeImageFrame); + (decodeImageFrame as jest.Mock).mockImplementation( + (imageFrame, transferSyntax, pixelData, canvas, options) => { + lastDecodeOptions = options; + return Promise.resolve(imageFrame); + } + ); + + metaData.addProvider(metadataProvider); + }); + + afterEach(() => { + metaData.removeProvider(metadataProvider); + jest.resetAllMocks(); + }); + + // The options object is passed through verbatim - the third test below + // depends on createImage seeing the very same object twice. + function load(imageId: string, options: any = { useRGBA: false }) { + return createImage( + imageId, + new Uint8Array(12).fill(120), + '1.2.840.10008.1.2.1', + options + ) as Promise; + } + + it('ignores the modality LUT, VOI and windowing tags for a color image', async () => { + const image = await load(COLOR_IMAGE_ID); + + expect(image.color).toBe(true); + // Identity modality transform instead of the -1024/2 rescale + expect(image.intercept).toBe(0); + expect(image.slope).toBe(1); + expect(image.modalityLUT).toBeUndefined(); + // No VOI at all - neither the sequence, the function, nor the window + expect(image.voiLUT).toBeUndefined(); + expect(image.voiLUTFunction).toBeUndefined(); + // The DICOM identity window for 8 bit color samples + expect(image.windowWidth).toBe(256); + expect(image.windowCenter).toBe(128); + // Pre-scaling would apply the modality LUT to the RGB samples + expect(lastDecodeOptions.preScale.enabled).toBe(false); + }); + + it('still honours all of them for a grayscale image', async () => { + const image = await load(GRAYSCALE_IMAGE_ID); + + expect(image.color).toBe(false); + expect(image.intercept).toBe(-1024); + expect(image.slope).toBe(2); + expect(image.modalityLUT).toEqual(MODALITY_LUT_SEQUENCE[0]); + expect(image.voiLUT).toEqual(VOI_LUT_SEQUENCE[0]); + expect(image.voiLUTFunction).toBe('SIGMOID'); + expect(image.windowWidth).toBe(400); + expect(image.windowCenter).toBe(40); + expect(lastDecodeOptions.preScale.enabled).toBe(true); + }); + + it('does not let a color image disable pre-scaling for a later grayscale one', async () => { + // Callers routinely reuse one options object across an entire stack, so + // createImage must not write its per image decisions back into it. + const sharedOptions = { useRGBA: false }; + + await load(COLOR_IMAGE_ID, sharedOptions); + expect(lastDecodeOptions.preScale.enabled).toBe(false); + + await load(GRAYSCALE_IMAGE_ID, sharedOptions); + expect(lastDecodeOptions.preScale.enabled).toBe(true); + + expect(sharedOptions).toEqual({ useRGBA: false }); + }); +}); diff --git a/packages/dicomImageLoader/src/imageLoader/createImage.ts b/packages/dicomImageLoader/src/imageLoader/createImage.ts index cadbb83762..732df287fc 100644 --- a/packages/dicomImageLoader/src/imageLoader/createImage.ts +++ b/packages/dicomImageLoader/src/imageLoader/createImage.ts @@ -34,13 +34,7 @@ async function createImage( // in cs3d const useRGBA = options.useRGBA; - // always preScale the pixel array unless it is asked not to - options.preScale = { - enabled: - options.preScale && options.preScale.enabled !== undefined - ? options.preScale.enabled - : true, - }; + const imageOptions: DICOMLoaderImageOptions = { ...options }; if (!pixelData?.length) { return Promise.reject(new Error('The pixel data is missing')); @@ -49,9 +43,20 @@ async function createImage( const { MetadataModules } = Enums; const canvas = document.createElement('canvas'); const imageFrame = getImageFrame(imageId); - imageFrame.decodeLevel = options.decodeLevel; + imageFrame.decodeLevel = imageOptions.decodeLevel; + + const isColorImage = isColorImageFn(imageFrame.photometricInterpretation); + + // always preScale the pixel array unless it is asked not to, or unless the + // image is not monochrome: the Modality LUT only applies to grayscale images + // (DICOM PS3.3 C.11.2.1.2.2). + const preScaleRequested = imageOptions.preScale?.enabled ?? true; - options.allowFloatRendering = canRenderFloatTextures(); + imageOptions.preScale = { + enabled: preScaleRequested && !isColorImage, + }; + + imageOptions.allowFloatRendering = canRenderFloatTextures(); let redData, greenData, blueData; // Capture palette descriptors before decode (worker may not return them). @@ -73,17 +78,17 @@ async function createImage( } // Get the scaling parameters from the metadata - if (options.preScale.enabled) { + if (imageOptions.preScale.enabled) { const scalingParameters = getScalingParameters(metaData, imageId); if (scalingParameters) { - options.preScale = { - ...options.preScale, + imageOptions.preScale = { + ...imageOptions.preScale, scalingParameters: scalingParameters as Types.ScalingParameters, }; } else { // Identity transform (slope 1, intercept 0) or no LUT: treat as non-prescaled so worker does not use scalingParameters. - options.preScale.enabled = false; + imageOptions.preScale.enabled = false; } } @@ -112,12 +117,10 @@ async function createImage( transferSyntax, pixelData, canvas, - options, + imageOptions, taskDecodeConfig ); - const isColorImage = isColorImageFn(imageFrame.photometricInterpretation); - return new Promise( (resolve, reject) => { // eslint-disable-next-line complexity @@ -127,8 +130,8 @@ async function createImage( let alreadyTyped = false; // We can safely render color image in 8 bit, so no need to convert if ( - options.targetBuffer && - options.targetBuffer.type && + imageOptions.targetBuffer && + imageOptions.targetBuffer.type && !isColorImage ) { const { @@ -136,7 +139,7 @@ async function createImage( type, offset: rawOffset = 0, length: rawLength, - } = options.targetBuffer; + } = imageOptions.targetBuffer; const imageFrameLength = imageFrame.pixelDataLength; @@ -381,6 +384,35 @@ async function createImage( numberOfComponents: numberOfComponents, }); + // The rescale slope/intercept, the VOI descriptors and the LUT + // sequences only apply to monochrome images (DICOM PS3.3 C.11.2.1.2.2), + // so color images get the identity modality transform and no VOI at + // all. StackViewport derives its initial VOI range from these, and a + // grayscale window would land nowhere near the [0, 255] range of the + // color samples. + let intercept = 0; + let slope = 1; + let windowCenter; + let windowWidth; + let voiLUTFunction; + + if (!isColorImage) { + intercept = modalityLutModule.rescaleIntercept || 0; + slope = modalityLutModule.rescaleSlope || 1; + windowCenter = voiLutModule.windowCenter?.[0]; + windowWidth = voiLutModule.windowWidth?.[0]; + // VOI LUT Function (0028,1056) reaches us as a string from + // dicom-parser, as a single element array from DICOMweb JSON, and + // occasionally under the older `voiLutFunction` spelling. Indexing a + // string yields its first *character*, which is what turned "SIGMOID" + // into "S" and made rendering throw "Invalid VOI LUT function" + // (cornerstone3D#2844), so normalize all the shapes here. + voiLUTFunction = + utilities.normalizeVOILUTFunction( + voiLutModule.voiLUTFunction ?? voiLutModule.voiLutFunction + ) ?? undefined; + } + const image: DICOMLoaderIImage = { imageId, dataType: imageFrame.pixelData.constructor @@ -391,12 +423,8 @@ async function createImage( columns: imageFrame.columns, height: imageFrame.rows, preScale: imageFrame.preScale, - intercept: modalityLutModule.rescaleIntercept - ? modalityLutModule.rescaleIntercept - : 0, - slope: modalityLutModule.rescaleSlope - ? modalityLutModule.rescaleSlope - : 1, + intercept, + slope, invert: imageFrame.photometricInterpretation === 'MONOCHROME1', minPixelValue: imageFrame.smallestPixelValue, maxPixelValue: imageFrame.largestPixelValue, @@ -406,22 +434,9 @@ async function createImage( width: imageFrame.columns, // use the first value for rendering, if other values // are needed later, it can be grabbed again from the voiLUtModule - windowCenter: voiLutModule.windowCenter - ? voiLutModule.windowCenter[0] - : undefined, - windowWidth: voiLutModule.windowWidth - ? voiLutModule.windowWidth[0] - : undefined, - // VOI LUT Function (0028,1056) reaches us as a string from - // dicom-parser, as a single element array from DICOMweb JSON, and - // occasionally under the older `voiLutFunction` spelling. Indexing a - // string yields its first *character*, which is what turned "SIGMOID" - // into "S" and made rendering throw "Invalid VOI LUT function" - // (cornerstone3D#2844), so normalize all the shapes here. - voiLUTFunction: - utilities.normalizeVOILUTFunction( - voiLutModule.voiLUTFunction ?? voiLutModule.voiLutFunction - ) ?? undefined, + windowCenter, + windowWidth, + voiLUTFunction, decodeTimeInMS: imageFrame.decodeTimeInMS, floatPixelData: undefined, imageFrame, @@ -483,8 +498,10 @@ async function createImage( }; } - // Modality LUT + // Modality LUT, skipped for non-monochrome images for the same reason + // the rescale slope/intercept is skipped above. if ( + !isColorImage && modalityLutModule.modalityLUTSequence && modalityLutModule.modalityLUTSequence.length > 0 && isModalityLUTForDisplay(sopCommonModule.sopClassUID) @@ -492,12 +509,17 @@ async function createImage( image.modalityLUT = modalityLutModule.modalityLUTSequence[0]; } - // VOI LUT Sequence (0028,3010). Providers hand this back in several - // shapes (already parsed, naturalized dcmjs, or raw DICOMweb JSON), so - // normalize before handing it to the renderers. - const voiLUT = normalizeVOILUTSequence( - voiLutModule.voiLUTSequence ?? voiLutModule.VOILUTSequence - ); + // VOI LUT Sequence (0028,3010), also skipped for non-monochrome images. + // Providers hand this back in several shapes (already parsed, + // naturalized dcmjs, or raw DICOMweb JSON), so normalize before handing + // it to the renderers. + let voiLUT; + + if (!isColorImage) { + voiLUT = normalizeVOILUTSequence( + voiLutModule.voiLUTSequence ?? voiLutModule.VOILUTSequence + ); + } if (voiLUT) { image.voiLUT = voiLUT; From 58d629b8a70a9e4420fefaf17e599eb282ed1601 Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Wed, 19 Aug 2026 00:25:51 +0200 Subject: [PATCH 03/13] fix(core): keep the VOI LUT Sequence when an app sets VOILUTFunction The tag VOI LUT Function (0028,1056) applies to the window, not to a VOI LUT Sequence, but an absent tag becomes LINEAR and getProperties() gives that value to the application. An app that keeps the properties and sets them again thus sent a LINEAR value that no person selected, and the viewport stopped the use of the curve until a call to resetProperties(). Now the flag is set only if the new function is different from the function of the image. Also, the new property useVOILUTSequence lets an application ignore the curve directly, and getImageDataMetadata() clears the flag for each new image. --- .../core/src/RenderingEngine/StackViewport.ts | 76 ++++++++++++++----- .../helpers/setDefaultVolumeVOI.ts | 67 ++++++++-------- .../core/src/types/StackViewportProperties.ts | 6 ++ .../migration-guides/5x/1-migration-notes.md | 38 ++++++++++ 4 files changed, 132 insertions(+), 55 deletions(-) diff --git a/packages/core/src/RenderingEngine/StackViewport.ts b/packages/core/src/RenderingEngine/StackViewport.ts index f8da91f75d..06bfaa9402 100644 --- a/packages/core/src/RenderingEngine/StackViewport.ts +++ b/packages/core/src/RenderingEngine/StackViewport.ts @@ -191,9 +191,13 @@ class StackViewport extends Viewport { // Whether the transfer function currently on the actor was built from the // image's VOI LUT Sequence rather than from a window width/center private voiLUTSequenceApplied = false; - // Set once the application asks for a specific VOI LUT Function, which opts - // out of the image's own VOI LUT Sequence - see _getVOILUTSequenceToApply + // True when the application asked for a VOI LUT Function that is different + // from the function of the image. Then the image cannot use its VOI LUT + // Sequence. See _getVOILUTSequenceToApply. private voiLUTFunctionSetByUser = false; + // The choice of the application about the VOI LUT Sequence of the image. It + // stays undefined until the application makes a choice. + private useVOILUTSequence: boolean; // private invert = false; // The initial invert of the image loaded as opposed to the invert status of the viewport itself (see above). @@ -759,6 +763,7 @@ class StackViewport extends Viewport { @param properties.colormap - Specifies the colormap for the viewport. @param properties.voiRange - Defines the lower and upper Value of Interest (VOI) to be applied. @param properties.VOILUTFunction - Function to handle the application of a lookup table (LUT) to the VOI. + @param properties.useVOILUTSequence - If false, ignore the VOI LUT Sequence of the image and use the VOI LUT Function. @param properties.invert - A boolean value to toggle color inversion (true: inverted, false: not inverted). @param properties.interpolationType - Determines the interpolation method to be used (1: linear, 0: nearest-neighbor). @param properties.rotation - Specifies the image rotation angle in degrees. @@ -769,6 +774,7 @@ class StackViewport extends Viewport { colormap, voiRange, VOILUTFunction, + useVOILUTSequence, invert, interpolationType, sharpening, @@ -787,6 +793,8 @@ class StackViewport extends Viewport { voiRange: this.globalDefaultProperties.voiRange ?? voiRange, VOILUTFunction: this.globalDefaultProperties.VOILUTFunction ?? VOILUTFunction, + useVOILUTSequence: + this.globalDefaultProperties.useVOILUTSequence ?? useVOILUTSequence, invert: this.globalDefaultProperties.invert ?? invert, interpolationType: this.globalDefaultProperties.interpolationType ?? interpolationType, @@ -804,9 +812,26 @@ class StackViewport extends Viewport { this.setVOI(voiRange, { suppressEvents, voiUpdatedWithSetProperties }); } + if (typeof useVOILUTSequence !== 'undefined') { + this.useVOILUTSequence = useVOILUTSequence; + } + if (typeof VOILUTFunction !== 'undefined') { - this.voiLUTFunctionSetByUser = true; + // Only a different function stops the use of the VOI LUT Sequence. An + // absent tag (0028,1056) becomes LINEAR, and getProperties gives that + // value to the application. Thus an application that keeps the properties + // and sets them again must not stop the sequence with a LINEAR value that + // no person selected. Both values go through getValidVOILUTFunction, and + // an absent tag is equal to LINEAR. + this.voiLUTFunctionSetByUser = + getValidVOILUTFunction(VOILUTFunction) !== + getValidVOILUTFunction(this.csImage?.voiLUTFunction); this.setVOILUTFunction(VOILUTFunction, suppressEvents); + } else if (typeof useVOILUTSequence !== 'undefined') { + // A change of useVOILUTSequence changes the transfer function, but it + // does not change the VOI LUT Function or the range. Thus set the current + // function again to make the new transfer function. + this.setVOILUTFunction(this.VOILUTFunction, suppressEvents); } if (typeof invert !== 'undefined') { @@ -857,6 +882,7 @@ class StackViewport extends Viewport { VOILUTFunction, interpolationType, invert, + useVOILUTSequence, voiUpdatedWithSetProperties, } = this; @@ -864,6 +890,7 @@ class StackViewport extends Viewport { colormap, voiRange, VOILUTFunction, + useVOILUTSequence, interpolationType, invert, isComputedVOI: !voiUpdatedWithSetProperties, @@ -894,6 +921,7 @@ class StackViewport extends Viewport { // than validate here so an image without one leaves it unset and the // per image fallbacks apply. this.voiLUTFunctionSetByUser = false; + this.useVOILUTSequence = undefined; this.VOILUTFunction = normalizeVOILUTFunction(this.csImage?.voiLUTFunction); this.viewportStatus = ViewportStatus.PRE_RENDER; @@ -1833,6 +1861,10 @@ class StackViewport extends Viewport { this.modality = modality; const voiLUTFunctionEnum = this._getValidVOILUTFunction(voiLUTFunction); + // The VOI LUT Function comes from this image. Thus the flag for the + // function of the previous image is not correct, and it must not stop the + // VOI LUT Sequence of this image. + this.voiLUTFunctionSetByUser = false; this.VOILUTFunction = voiLUTFunctionEnum; this.calibration = calibration; @@ -2832,20 +2864,6 @@ class StackViewport extends Viewport { } } - /** - * The VOI LUT Sequence (0028,3010) of the displayed image, when it should - * drive the display instead of an analytic VOI LUT Function. - * - * DICOM allows a window and a sequence to both be present and lets the - * application pick (C.11.2.1); like the legacy cornerstone renderer we prefer - * the sequence, since a file that ships an explicit VOI LUT expects that - * curve. Window level interaction does not disable it - the curve is stretched - * over the new range instead, the same way the sampled sigmoid is rebuilt from - * a new window - so the shape the file specified survives interaction. - * - * Asking for a VOI LUT Function explicitly (`setProperties({ VOILUTFunction })`) - * is the way to opt out and get a plain analytic window instead. - */ /** * Keeps the CPU fallback viewport's VOI LUT Sequence in step with * {@link _getVOILUTSequenceToApply}, which is what decides the same question @@ -2864,8 +2882,30 @@ class StackViewport extends Viewport { viewport.voiLUT = this._getVOILUTSequenceToApply(image ?? this.csImage); } + /** + * The VOI LUT Sequence (0028,3010) of the displayed image, when it should + * drive the display instead of an analytic VOI LUT Function. + * + * DICOM allows a window and a sequence to both be present and lets the + * application pick (C.11.2.1); like the legacy cornerstone renderer we prefer + * the sequence, since a file that ships an explicit VOI LUT expects that + * curve. Window level interaction does not disable it - the curve is stretched + * over the new range instead, the same way the sampled sigmoid is rebuilt from + * a new window - so the shape the file specified survives interaction. + * + * To get a plain analytic window, use + * `setProperties({ useVOILUTSequence: false })`. A VOI LUT Function that is + * different from the function of the image also stops the sequence, because + * only one of the two can drive the display. But a function that is equal to + * the function of the image does not stop the sequence. This includes the + * LINEAR value that an absent (0028,1056) gives. + */ private _getVOILUTSequenceToApply(image: IImage = this.csImage) { - if (this.voiLUTFunctionSetByUser) { + if (this.useVOILUTSequence === false) { + return undefined; + } + + if (this.useVOILUTSequence !== true && this.voiLUTFunctionSetByUser) { return undefined; } diff --git a/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts b/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts index a69e3271eb..80a5f5ddb3 100644 --- a/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts +++ b/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts @@ -68,15 +68,11 @@ export async function getDefaultVolumeVOIRange( ): Promise { let voi = getVOIFromMetadata(imageVolume); - // A prescaled PT gets the 0-5 default even when the metadata does carry a - // window. PT window width/center is expressed in the unscaled counts, so - // applying it to SUV values produces an enormous range and a black volume. - // This override used to run only on the min/max path below, so a PT series - // that shipped a window skipped it and the volume viewport disagreed with the - // stack viewport, which has always preferred its own PT range. It applies to - // a volume with no imageIds too, whose window came from the volume metadata - // rather than from an instance: scaling is a property of the volume, not of - // how its window was found. + // A prescaled PT volume contains SUV values, but the window in the metadata + // is in the unscaled counts. Thus this window gives a very large range and a + // black volume, and a prescaled PT volume must use the default range 0 to 5. + // This is also correct for a volume that has no imageIds, because the + // prescaling is a property of the volume, not of the source of the window. if (voi) { voi = handlePreScaledVolume(imageVolume, voi); } @@ -117,16 +113,15 @@ function handlePreScaledVolume(imageVolume: IImageVolume, voi: VOIRange) { const imageIdIndex = Math.floor(imageIds.length / 2); const imageId = imageIds[imageIdIndex]; - // A volume does not have to carry imageIds - one built from its own metadata - // has none - so the general series module is only worth asking for when there - // is an instance to key it on. The volume metadata fallback below covers the - // rest, and the prescaling check itself reads only volume level fields. + // A volume is not required to have imageIds. A volume that comes from its + // own metadata has none. Thus get the general series module only when there + // is an instance. The test for the prescaling reads only fields of the volume. const generalSeriesModule = (imageId ? metaData.get(MetadataModules.GENERAL_SERIES, imageId) : null) || {}; - // The volume's own metadata is the fallback: the middle instance may not have - // a registered general series module, and missing the modality here would - // quietly skip the PT handling below. + // The metadata of the volume gives the modality when the general series + // module of the instance is absent. Without the modality, the code below does + // not find a prescaled PT volume. const modality = generalSeriesModule.modality ?? imageVolume.metadata?.Modality; @@ -147,17 +142,16 @@ function handlePreScaledVolume(imageVolume: IImageVolume, voi: VOIRange) { } /** - * Finds a usable Window Center/Width, starting at the middle of the stack and - * walking outwards. + * Finds a usable Window Center and Window Width. The search starts at the middle + * of the stack and continues to the two ends. * - * The middle instance is preferred (it is the most representative slice), but it - * is not guaranteed to have registered metadata: which imageId lands in the - * middle depends on how the volume was created and in which order its instances - * were added, and an instance whose metadata has not been registered yet - * silently produced no window at all - the volume then fell back to a min/max - * range from the pixel data (cornerstone3D#1767). Any sibling in the same series - * carries the same window in practice, so the nearest instance that has one is a - * far better answer than giving up. + * The middle instance is the best slice, but its metadata can be absent. The + * position of an imageId in the volume depends on the method that made the + * volume and on the sequence of the instances. When the metadata of the middle + * instance was absent, the volume found no window. Then it used a range from + * the minimum and the maximum of the pixel data. Usually, + * the other instances of the series have the same window. Thus the nearest + * instance that has a window gives a much better result. */ function getWindowFromNearestImageId(imageIds: string[]) { const middle = Math.floor(imageIds.length / 2); @@ -177,18 +171,18 @@ function getWindowFromNearestImageId(imageIds: string[]) { const width = Array.isArray(windowWidth) ? windowWidth[0] : windowWidth; const center = Array.isArray(windowCenter) ? windowCenter[0] : windowCenter; - // A center of 0 is a perfectly good window - prescaled PT, parametric maps - // and centered MR all use one - so it has to be tested for existence rather - // than for truthiness, or those series silently fall back to a min/max - // range. A width of 0 or a missing width has no window to show, however. + // A center of 0 is a correct window. Prescaled PT volumes, parametric maps + // and centered MR volumes use one. Thus make sure that the center exists, + // but do not make sure that the center is not 0. If not, these series use a + // range from the minimum and the maximum. But a width of 0, or an absent + // width, gives no window. if (!width || center == null) { continue; } - // The VOI LUT Function has to stay attached to the window - it decides how - // the window converts to a range. It used to be assigned to `voi` first and - // then overwritten by the window object, so a LINEAR_EXACT/SIGMOID volume - // was silently windowed as LINEAR. + // Keep the VOI LUT Function with the window. The function controls how the + // window becomes a range. If the function is lost, a volume with the + // function LINEAR_EXACT or SIGMOID gets a LINEAR window. return { windowWidth: width, windowCenter: center, @@ -212,9 +206,8 @@ function getVOIFromMetadata(imageVolume: IImageVolume): VOIRange | undefined { if (imageIds?.length) { voi = getWindowFromNearestImageId(imageIds); } else { - // A volume without imageIds carries its own window, which it is not - // required to have - indexing it unconditionally threw for every volume - // built without one + // A volume that has no imageIds contains its own window, but the volume is + // not required to have one. voi = metadata?.voiLut?.[0]; } diff --git a/packages/core/src/types/StackViewportProperties.ts b/packages/core/src/types/StackViewportProperties.ts index 4d8ebd595a..28f2061c37 100644 --- a/packages/core/src/types/StackViewportProperties.ts +++ b/packages/core/src/types/StackViewportProperties.ts @@ -11,6 +11,12 @@ type StackViewportProperties = ViewportProperties & { suppressEvents?: boolean; /** Indicates if the voi is a computed VOI (not user set) */ isComputedVOI?: boolean; + /** + * The use of the VOI LUT Sequence (0028,3010) of the image. If this property + * is undefined, the viewport uses the sequence when the image has one. If it + * is false, the viewport ignores the sequence and uses the VOI LUT Function. + */ + useVOILUTSequence?: boolean; }; export type { StackViewportProperties as default }; diff --git a/packages/docs/docs/migration-guides/5x/1-migration-notes.md b/packages/docs/docs/migration-guides/5x/1-migration-notes.md index 7073b5d5a5..6775c5598a 100644 --- a/packages/docs/docs/migration-guides/5x/1-migration-notes.md +++ b/packages/docs/docs/migration-guides/5x/1-migration-notes.md @@ -242,3 +242,41 @@ Two notes on scope: - **Check your scroll affordances on small screens.** If a page relied on viewport drags to scroll, add padding, a scroll container, or a gutter outside the viewport elements so the page remains scrollable on a phone or tablet. + +## Prescaled PT volumes ignore the window in the metadata + +### What Changed + +A volume viewport that shows a **prescaled PT series** (`isPreScaled` with an +`suvbw` scaling factor) now always starts with a default VOI range of 0 to 5. +This is correct also when the series has a Window Center and a Window Width. + +Before, this default range was applicable only when the viewport found no +window. The viewport used the range 0 to 5 only after it calculated the minimum +and the maximum of the middle slice. Thus a PT series that had a window used +that window. + +### Why This Matters + +A prescaled PT volume contains SUV values, but the Window Center and the Window +Width of the file are in the unscaled counts. The window has the incorrect +units. Applied to SUV values, it gives a very large range, and the volume is +almost black. + +Thus the series that had a window were the series with the incorrect display. +These volumes also did not agree with the stack viewport, which always uses its +own PT range. **After the upgrade, PET volumes look different and correct.** + +Unscaled PT volumes do not change. They continue to use the window in the +metadata. + +### Migration Guidance + +- **Usually, you do not have to do an operation.** The new default range agrees + with the stack viewport and with other viewers. +- **If your application needs the initial behavior**, set the range with + `viewport.setProperties({ voiRange })` after the volume loads, or give your + own VOI when you configure the viewport. The default is applicable only when + the application does not set a range. +- **If your tests compare volume and stack displays**, change the PT values that + you calculated from the metadata window to the range 0 to 5. From 0ceac5d5a813e9035c9105db58a26ed123f9001e Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Fri, 21 Aug 2026 01:28:56 +0200 Subject: [PATCH 04/13] fix(core): use one VOI LUT Sequence rule on all viewport types applyPlanarImagePresentation stopped the VOI LUT Sequence of the file when the presentation had a voiLUTFunction. StackViewport stops it only when the function is different from the function of the image. Thus the two viewport types showed the same file in two ways. Use the test for an equal or a different function on the generic viewports also. The generic viewports have no useVOILUTSequence property. Thus an application there cannot select the other behavior. --- .../core/src/RenderingEngine/StackViewport.ts | 5 ++++ .../helpers/planarImageRendering.ts | 30 ++++++++++++++----- packages/tools/src/tools/WindowLevelTool.ts | 3 +- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/core/src/RenderingEngine/StackViewport.ts b/packages/core/src/RenderingEngine/StackViewport.ts index 06bfaa9402..700d1e8964 100644 --- a/packages/core/src/RenderingEngine/StackViewport.ts +++ b/packages/core/src/RenderingEngine/StackViewport.ts @@ -2899,6 +2899,11 @@ class StackViewport extends Viewport { * only one of the two can drive the display. But a function that is equal to * the function of the image does not stop the sequence. This includes the * LINEAR value that an absent (0028,1056) gives. + * + * The generic viewports use the same rule in + * `applyPlanarImagePresentation`. They do not have a `useVOILUTSequence` + * override. Thus the test for an equal or a different function is the full + * rule there. */ private _getVOILUTSequenceToApply(image: IImage = this.csImage) { if (this.useVOILUTSequence === false) { diff --git a/packages/core/src/RenderingEngine/helpers/planarImageRendering.ts b/packages/core/src/RenderingEngine/helpers/planarImageRendering.ts index 77bd797d57..a7a6772f91 100644 --- a/packages/core/src/RenderingEngine/helpers/planarImageRendering.ts +++ b/packages/core/src/RenderingEngine/helpers/planarImageRendering.ts @@ -18,6 +18,7 @@ import createVOILUTSequenceTransferFunction, { isRenderableVOILUT, } from '../../utilities/createVOILUTSequenceTransferFunction'; import getVOIRangeFromWindowLevel from '../../utilities/getVOIRangeFromWindowLevel'; +import { getValidVOILUTFunction } from '../../utilities/voiLUTFunction'; import isPTPrescaledWithSUV from '../../utilities/isPTPrescaledWithSUV'; import { getImageDataMetadata } from '../../utilities/getImageDataMetadata'; import invertRgbTransferFunction from '../../utilities/invertRgbTransferFunction'; @@ -174,8 +175,9 @@ export function applyPlanarImagePresentation(args: { defaultVOIRange?: VOIRange; defaultVOILUTFunction?: VOILUTFunctionType; /** - * VOI LUT Sequence (0028,3010) of the displayed image. Drives the display - * unless the caller asks for a specific VOI LUT Function or a colormap - see + * VOI LUT Sequence (0028,3010) of the displayed image. This sequence + * controls the display. Two conditions stop it: a VOI LUT Function that is + * different from the function of the image, or a colormap. Refer to * createPlanarRGBTransferFunction. */ defaultVOILUT?: CPUFallbackLUT; @@ -190,11 +192,25 @@ export function applyPlanarImagePresentation(args: { } = args; const property = actor.getProperty(); const voiRange = props?.voiRange ?? defaultVOIRange; - // An explicitly requested VOI LUT Function opts out of the file's VOI LUT - // Sequence; an explicit range does not - the curve is stretched over it, so - // window level keeps the shape the file specified - const voiLUT = - props?.voiLUTFunction === undefined ? defaultVOILUT : undefined; + // This rule is the same as the rule in + // StackViewport._getVOILUTSequenceToApply. Only one of the two can control + // the display. Thus a VOI LUT Function that is different from the function + // of the image stops the VOI LUT Sequence of the file. A function that is + // equal to the function of the image does not stop it. An absent tag + // (0028,1056) gives the LINEAR value, and getProperties gives that value to + // the application. Thus an application that applies the presentation that it + // read keeps the sequence. A range from the caller also keeps the sequence. + // The transfer function stretches the curve over that range. Thus window + // level operations keep the shape that the file specifies. + const canUseVOILUTSequence = + props?.voiLUTFunction === undefined || + getValidVOILUTFunction(props.voiLUTFunction) === + getValidVOILUTFunction(defaultVOILUTFunction); + let voiLUT; + + if (canUseVOILUTSequence) { + voiLUT = defaultVOILUT; + } if (props?.visible !== undefined) { actor.setVisibility(props.visible); diff --git a/packages/tools/src/tools/WindowLevelTool.ts b/packages/tools/src/tools/WindowLevelTool.ts index 455e59b3a6..be2a6025fa 100644 --- a/packages/tools/src/tools/WindowLevelTool.ts +++ b/packages/tools/src/tools/WindowLevelTool.ts @@ -172,7 +172,8 @@ class WindowLevelTool extends BaseTool { let { windowWidth, windowCenter } = utilities.windowLevel.toWindowLevel( lower, - upper + upper, + voiLutFunction ); windowWidth += wwDelta; From 85899e8325e5450d032770399362bafb0a188ce7 Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Fri, 21 Aug 2026 17:33:45 +0200 Subject: [PATCH 05/13] feat(core): apply the VOI LUT Function and the VOI LUT Sequence on volumes A volume viewport read neither tag. setDefaultVolumeVOI used the VOI LUT Function (0028,1056) to calculate the range, but no code gave the function to the viewport. Thus a SIGMOID series got a linear transfer function with a sigmoid range. No volume path read the VOI LUT Sequence (0028,3010). Thus a CR, a DX or an MG file that carries its curve showed the curve on a stack viewport and a flat window on a volume viewport and on an MPR. - Resolve one VOI source for a volume: the range, the VOI LUT Function and the VOI LUT Sequence of the nearest instance that has one. A sequence gives its own input domain as the range and wins over a window of the same instance (C.11.2.1), as on the stack viewport - Take the sequence from the cached image, which the loader normalized, and from the VOI LUT module for a volume whose instances are not loaded - Ignore the window and the curve of a prescaled PT volume. Both are in the unscaled counts, and the volume holds SUV - Make the transfer function that the shape needs in one function, which setDefaultVolumeVOI and BaseVolumeViewport.setVOI both use. Thus window level stretches the curve of the file and does not replace it - Keep the shape for each volume on the viewport, and make a new window transfer function when the reason for a curve goes away. A range on a curve only rescales its nodes, so the shape would stay - Normalize the VOI LUT Function with getValidVOILUTFunction. The test for a member of the enum made LINEAR from a padded value, a lower case value or a single element array, which the providers give - Store the requested function before the transfer function is made. The old order used the previous function, so setProperties({ VOILUTFunction }) had no effect until the next change of the VOI - Give the volume viewports and the generic viewports the property useVOILUTSequence, which only the stack viewport had - Pass the function and the sequence on the volume paths of the generic viewports, on the GPU and on the CPU - Map VOILUTFunction onto the presentation of a compatibility planar viewport, which dropped the property --- .../src/RenderingEngine/BaseVolumeViewport.ts | 217 ++++++++- .../Planar/PlanarCPUVolumeSampler.ts | 22 +- .../Planar/PlanarViewportTypes.ts | 6 + .../Planar/VtkVolumeSliceRenderPath.ts | 11 + .../Planar/planarLegacyCompatibility.ts | 11 + .../Planar/planarRuntimeTypes.ts | 7 +- .../Planar/planarVolumePresentation.ts | 27 +- .../helpers/planarImageRendering.ts | 71 ++- .../helpers/setDefaultVolumeVOI.ts | 410 ++++++++++++++---- .../core/src/types/StackViewportProperties.ts | 6 - packages/core/src/types/ViewportProperties.ts | 8 +- packages/core/src/types/voi.ts | 5 + .../utilities/setDefaultVolumeVOI.jest.js | 68 ++- 13 files changed, 725 insertions(+), 144 deletions(-) diff --git a/packages/core/src/RenderingEngine/BaseVolumeViewport.ts b/packages/core/src/RenderingEngine/BaseVolumeViewport.ts index 4c5924737a..c77485e725 100644 --- a/packages/core/src/RenderingEngine/BaseVolumeViewport.ts +++ b/packages/core/src/RenderingEngine/BaseVolumeViewport.ts @@ -44,7 +44,9 @@ import type { PlaneRestriction, ViewportInput } from '../types/IViewport'; import triggerEvent from '../utilities/triggerEvent'; import * as colormapUtils from '../utilities/colormap'; import invertRgbTransferFunction from '../utilities/invertRgbTransferFunction'; -import createSigmoidRGBTransferFunction from '../utilities/createSigmoidRGBTransferFunction'; +import createLinearRGBTransferFunction from '../utilities/createLinearRGBTransferFunction'; +import { getValidVOILUTFunction } from '../utilities/voiLUTFunction'; +import { isRenderableVOILUT } from '../utilities/createVOILUTSequenceTransferFunction'; import transformWorldToIndex from '../utilities/transformWorldToIndex'; import { findMatchingColormap, @@ -65,6 +67,12 @@ import { createAndCacheVolume } from '../loaders/volumeLoader'; import resolveViewportVolumeId from './helpers/resolveViewportVolumeId'; import { getGenericViewportImageDisplaySet } from './GenericViewport/genericViewportDisplaySetAccess'; import createVolumeActor from './helpers/createVolumeActor'; +import { + createVolumeVOITransferFunction, + getVolumeVOIShape, + volumeVOIIsCurve, +} from './helpers/setDefaultVolumeVOI'; +import type { VolumeVOIShape } from './helpers/setDefaultVolumeVOI'; import volumeNewImageEventDispatcher, { resetVolumeNewImageState, } from './helpers/volumeNewImageEventDispatcher'; @@ -116,6 +124,28 @@ abstract class BaseVolumeViewport extends Viewport { protected initialViewUp: Point3; protected viewportProperties: VolumeViewportProperties = {}; private volumeIds = new Set(); + /** + * The VOI LUT Function and the VOI LUT Sequence that the file of each volume + * specifies, by volumeId. The metadata of a volume does not change, so the + * shape is read once and kept: a window level drag asks for it on every + * mouse move. + */ + private volumeVOIShapes = new Map(); + /** + * Whether the transfer function of a volume is a curve (a VOI LUT Sequence or + * a sigmoid) at the moment. A curve cannot become a window by a change of the + * range, because vtk.js rescales the nodes that it holds. Thus the transition + * needs a new transfer function. + */ + private volumeVOICurveApplied = new Map(); + /** + * True when an application asked for a VOI LUT Function that is different + * from the function of the volume. Only one of the function and the VOI LUT + * Sequence can control the display. + */ + private voiLUTFunctionSetByUser = false; + /** The `useVOILUTSequence` property, when an application set it. */ + private useVOILUTSequence: boolean; constructor(props: ViewportInput) { super(props); @@ -269,13 +299,81 @@ abstract class BaseVolumeViewport extends Viewport { volumeId?: string, suppressEvents?: boolean ): void { - // make sure the VOI LUT function is valid in the VOILUTFunctionType which is enum - if (!Object.values(VOILUTFunctionType).includes(voiLUTFunction)) { - voiLUTFunction = VOILUTFunctionType.LINEAR; - } - const { voiRange } = this.getProperties(); + // Normalize rather than test for a member of the enum: the value reaches us + // as a padded, lower case or single element array attribute from the + // providers as well as from an application, and an unknown value must + // become LINEAR rather than break the render. + const newVOILUTFunction = getValidVOILUTFunction(voiLUTFunction); + const { voiRange } = this.getProperties(volumeId) ?? {}; + + // Only a function that is different from the function of the volume stops + // the use of the VOI LUT Sequence. An absent (0028,1056) becomes LINEAR, + // and getProperties gives that value to the application. Thus an + // application that keeps the properties and sets them again must not stop + // the sequence with a LINEAR value that no person selected. This is the + // same rule as StackViewport and applyPlanarImagePresentation use. + this.voiLUTFunctionSetByUser = + newVOILUTFunction !== + getValidVOILUTFunction(this._getVolumeVOIShape(volumeId).voiLUTFunction); + + // The property has to hold the new function before the transfer function is + // made from it. The old order built the transfer function from the previous + // function, so a request for SIGMOID had no effect until the next change of + // the VOI. + this.viewportProperties.VOILUTFunction = newVOILUTFunction; this.setVOI(voiRange, volumeId, suppressEvents); - this.viewportProperties.VOILUTFunction = voiLUTFunction; + } + + /** + * The VOI LUT Function and the VOI LUT Sequence of the file of a volume. + */ + private _getVolumeVOIShape(volumeId?: string): VolumeVOIShape { + const volumeIdToUse = + volumeId ?? this._getApplicableVolumeActor(volumeId)?.volumeId; + + if (!volumeIdToUse) { + return {}; + } + + let shape = this.volumeVOIShapes.get(volumeIdToUse); + + if (!shape) { + shape = getVolumeVOIShape(cache.getVolume(volumeIdToUse)); + this.volumeVOIShapes.set(volumeIdToUse, shape); + } + + return shape; + } + + /** + * The VOI LUT Sequence (0028,3010) of a volume, when it should control the + * display instead of an analytic VOI LUT Function. Refer to + * resolveVOILUTSequenceToApply for the rule. A colormap also stops it, which + * setVOI does for the sigmoid also. + */ + private _getVOILUTSequenceToApply(volumeId?: string) { + if (this.useVOILUTSequence === false) { + return undefined; + } + + if (this.useVOILUTSequence !== true && this.voiLUTFunctionSetByUser) { + return undefined; + } + + const { voiLUT } = this._getVolumeVOIShape(volumeId); + + return isRenderableVOILUT(voiLUT) ? voiLUT : undefined; + } + + /** + * The VOI LUT Function in effect: the one that an application set, or the one + * that the file of the volume carries. + */ + private _getVOILUTFunctionToApply(volumeId?: string): VOILUTFunctionType { + return getValidVOILUTFunction( + this.viewportProperties.VOILUTFunction ?? + this._getVolumeVOIShape(volumeId).voiLUTFunction + ); } /** @@ -314,6 +412,10 @@ abstract class BaseVolumeViewport extends Viewport { cfun.applyColorMap(colormapObj); cfun.setMappingRange(range[0], range[1]); volumeActor.getProperty().setRGBTransferFunction(0, cfun); + // The colormap replaces the transfer function. Thus a curve of a VOI LUT + // Sequence or of a sigmoid is no longer on the actor, and a later change of + // the VOI keeps the colors of the colormap. + this.volumeVOICurveApplied.set(applicableVolumeActorInfo.volumeId, false); // This configures the viewport to use the most recently applied colormap. // However, this approach is not optimal when dealing with two volumes, as it prevents retrieval of the @@ -466,6 +568,7 @@ abstract class BaseVolumeViewport extends Viewport { }, volumeId: applicableVolumeActorInfo.volumeId, VOILUTFunction: VOILUTFunction, + voiLUTSequenceApplied: !!this._getVOILUTSequenceToApply(volumeId), colormap: matchedColormap, invert, }; @@ -552,22 +655,53 @@ abstract class BaseVolumeViewport extends Viewport { return; } - const { VOILUTFunction } = this.getProperties(volumeIdToUse); - // scaling logic here // https://github.com/Kitware/vtk-js/blob/c6f2e12cddfe5c0386a73f0793eb6d9ab20d573e/Sources/Rendering/OpenGL/VolumeMapper/index.js#L957-L972 - if (VOILUTFunction === VOILUTFunctionType.SAMPLED_SIGMOID) { - const cfun = createSigmoidRGBTransferFunction(voiRangeToUse); - volumeActor.getProperty().setRGBTransferFunction(0, cfun); + // A VOI LUT Sequence of the file and the SIGMOID function are curves. Thus + // they need their own transfer function. The curve of a sequence is + // stretched over the range. Thus window level reshapes the curve and does + // not replace it, as on the stack and the generic viewports. + // + // A colormap stops both curves. A colormap is a choice of a person, and it + // fills the transfer function with its own colors. The generic viewports + // use the same rule (refer to createPlanarRGBTransferFunction). + let curve; + + if (!this.viewportProperties.colormap?.name) { + curve = createVolumeVOITransferFunction({ + voiRange: voiRangeToUse, + voiLUT: this._getVOILUTSequenceToApply(volumeIdToUse), + voiLUTFunction: this._getVOILUTFunctionToApply(volumeIdToUse), + }); + } + + const { lower, upper } = voiRangeToUse; + + if (curve) { + volumeActor.getProperty().setRGBTransferFunction(0, curve); + this.volumeVOICurveApplied.set(volumeIdToUse, true); + } else if (this.volumeVOICurveApplied.get(volumeIdToUse)) { + // A curve holds hundreds of nodes, and setRange only rescales them. Thus + // the shape of the curve stays after the reason for it goes away + // (`useVOILUTSequence: false`, or a move back to LINEAR). Make the window + // again from nothing. + this.volumeVOICurveApplied.set(volumeIdToUse, false); + volumeActor + .getProperty() + .setRGBTransferFunction( + 0, + createLinearRGBTransferFunction(voiRangeToUse) + ); + + if (this.viewportProperties.invert) { + invertRgbTransferFunction( + volumeActor.getProperty().getRGBTransferFunction(0) + ); + } } else { - // TODO: refactor and make it work for PET series (inverted/colormap) - // const cfun = createLinearRGBTransferFunction(voiRangeToUse); - // volumeActor.getProperty().setRGBTransferFunction(0, cfun); - - // Todo: Moving from LINEAR to SIGMOID and back to LINEAR will not - // work until we implement it in a different way because the - // LINEAR transfer function is not recreated. - const { lower, upper } = voiRangeToUse; + // Todo: refactor and make it work for PET series (inverted/colormap) + // A range on the existing transfer function keeps the colormap and the + // inversion that it carries. volumeActor .getProperty() .getRGBTransferFunction(0) @@ -1052,6 +1186,7 @@ abstract class BaseVolumeViewport extends Viewport { { voiRange, VOILUTFunction, + useVOILUTSequence, invert, colormap, preset, @@ -1095,6 +1230,10 @@ abstract class BaseVolumeViewport extends Viewport { this.setThreshold(colormap, volumeId); } + if (useVOILUTSequence !== undefined) { + this.useVOILUTSequence = useVOILUTSequence; + } + if (voiRange !== undefined) { this.setVOI(voiRange, volumeId, suppressEvents); } @@ -1105,6 +1244,15 @@ abstract class BaseVolumeViewport extends Viewport { if (VOILUTFunction !== undefined) { this.setVOILUTFunction(VOILUTFunction, volumeId, suppressEvents); + } else if (useVOILUTSequence !== undefined && voiRange === undefined) { + // A change of useVOILUTSequence changes the transfer function, but it + // does not change the VOI LUT Function or the range. Thus set the current + // function again to make the new transfer function. + this.setVOILUTFunction( + this._getVOILUTFunctionToApply(volumeId), + volumeId, + suppressEvents + ); } if (preset !== undefined) { @@ -1220,6 +1368,10 @@ abstract class BaseVolumeViewport extends Viewport { public resetToDefaultProperties(volumeId: string): void { const properties = this.globalDefaultProperties; + this.voiLUTFunctionSetByUser = false; + this.useVOILUTSequence = properties.useVOILUTSequence; + this.viewportProperties.VOILUTFunction = properties.VOILUTFunction; + if (properties.colormap?.name) { this.setColormap(properties.colormap, volumeId); } @@ -1341,12 +1493,15 @@ abstract class BaseVolumeViewport extends Viewport { const { colormap: latestColormap, - VOILUTFunction, interpolationType, invert, slabThickness, preset, } = this.viewportProperties; + // The function of the file when no application set one, as on the stack + // viewport. An application that reads the properties and sets them again + // then keeps the VOI LUT Sequence of the file. + const VOILUTFunction = this._getVOILUTFunctionToApply(volumeId); volumeId ||= this.getVolumeId(); const volume = cache.getVolume(volumeId); @@ -1366,7 +1521,7 @@ abstract class BaseVolumeViewport extends Viewport { const volumeActor = volumeActorEntry.actor as vtkVolume; const cfun = volumeActor.getProperty().getRGBTransferFunction(0); const [lower, upper] = - this.viewportProperties?.VOILUTFunction === 'SIGMOID' + VOILUTFunction === VOILUTFunctionType.SAMPLED_SIGMOID ? getVoiFromSigmoidRGBTransferFunction(cfun) : cfun.getRange(); @@ -1381,6 +1536,7 @@ abstract class BaseVolumeViewport extends Viewport { colormap: colormap, voiRange: voiRange, VOILUTFunction: VOILUTFunction, + useVOILUTSequence: this.useVOILUTSequence, interpolationType: interpolationType, invert: invert, slabThickness: slabThickness, @@ -1910,6 +2066,23 @@ abstract class BaseVolumeViewport extends Viewport { for (let i = 0; i < volumeActorEntries.length; i++) { this.viewportProperties.invert = false; } + + // New volumes bring their own VOI LUT Function and VOI LUT Sequence. Record + // which actors setDefaultVolumeVOI gave a curve to, so a later change of the + // VOI knows that a range on that transfer function cannot remove the curve. + this.voiLUTFunctionSetByUser = false; + this.volumeVOICurveApplied.clear(); + this.volumeVOIShapes.clear(); + + for (const actorEntry of volumeActorEntries) { + const volumeId = actorEntry.referencedId; + + this.volumeVOICurveApplied.set( + volumeId, + volumeVOIIsCurve(this._getVolumeVOIShape(volumeId)) + ); + } + this.setActors(volumeActorEntries); } diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarCPUVolumeSampler.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarCPUVolumeSampler.ts index c69f3f62c7..380c0630c5 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarCPUVolumeSampler.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarCPUVolumeSampler.ts @@ -14,6 +14,8 @@ import type { VOIRange, } from '../../../types'; import VoxelManager from '../../../utilities/VoxelManager'; +import { resolveVOILUTSequenceToApply } from '../../helpers/planarImageRendering'; +import { getVolumeVOIShape } from '../../helpers/setDefaultVolumeVOI'; import getDefaultViewport from '../../helpers/cpuFallback/rendering/getDefaultViewport'; import getSpacingInNormalDirection from '../../../utilities/getSpacingInNormalDirection'; import type { PlanarDataPresentation } from './PlanarViewportTypes'; @@ -402,11 +404,22 @@ export default class PlanarCPUVolumeSampler { viewport.invert = dataPresentation?.invert ?? false; viewport.pixelReplication = dataPresentation?.interpolationType === InterpolationType.NEAREST; + // The sampled slice carries the VOI LUT Function and the VOI LUT Sequence + // of the volume (refer to createSliceImage). The CPU renderer reads them + // from the viewport, as on the stack. viewport.voi = { windowCenter: (resolvedVOI.lower + resolvedVOI.upper) / 2, windowWidth: Math.max(resolvedVOI.upper - resolvedVOI.lower, 1), - voiLUTFunction: VOILUTFunctionType.LINEAR, + voiLUTFunction: + dataPresentation?.voiLUTFunction ?? + sampledSliceState.image.voiLUTFunction ?? + VOILUTFunctionType.LINEAR, }; + viewport.voiLUT = resolveVOILUTSequenceToApply({ + defaultVOILUT: sampledSliceState.image.voiLUT, + defaultVOILUTFunction: sampledSliceState.image.voiLUTFunction, + props: dataPresentation, + }); } public needsResample(args: { @@ -1192,6 +1205,10 @@ export default class PlanarCPUVolumeSampler { const windowWidth = Math.max(1, resolvedVOI.upper - resolvedVOI.lower); const windowCenter = (resolvedVOI.lower + resolvedVOI.upper) / 2; const imageId = `cpuVolumeSlice:${volume.volumeId}:${++this.sampleSequence}`; + // The slice is a synthetic image, but the VOI transformation is a property + // of the file. Thus the slice keeps the VOI LUT Function and the VOI LUT + // Sequence of the volume. + const { voiLUT, voiLUTFunction } = getVolumeVOIShape(volume); const voxelManager = VoxelManager.createImageVoxelManager({ width, height, @@ -1205,7 +1222,8 @@ export default class PlanarCPUVolumeSampler { intercept: 0, windowCenter, windowWidth, - voiLUTFunction: VOILUTFunctionType.LINEAR, + voiLUTFunction: voiLUTFunction ?? VOILUTFunctionType.LINEAR, + voiLUT, isPreScaled: volume.isPreScaled, scaling: volume.scaling, color: numberOfComponents > 1, diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportTypes.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportTypes.ts index 2915644dc7..83b0013bf6 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportTypes.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportTypes.ts @@ -121,6 +121,12 @@ export interface PlanarPresentationProps extends BasePresentationProps { colormap?: ColormapPublic; voiRange?: VOIRange; voiLUTFunction?: VOILUTFunctionType; + /** + * The use of the VOI LUT Sequence (0028,3010) of the image. If this property + * is undefined, the viewport uses the sequence when the image has one. If it + * is false, the viewport ignores the sequence and uses the VOI LUT Function. + */ + useVOILUTSequence?: boolean; invert?: boolean; } diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/VtkVolumeSliceRenderPath.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/VtkVolumeSliceRenderPath.ts index 548a9629e7..2b17590fc6 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/VtkVolumeSliceRenderPath.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/VtkVolumeSliceRenderPath.ts @@ -6,6 +6,7 @@ import uuidv4 from '../../../utilities/uuidv4'; import { Events, ViewportType } from '../../../enums'; import eventTarget from '../../../eventTarget'; import createVolumeSliceActor from '../../helpers/createVolumeSliceActor'; +import { getVolumeVOIShape } from '../../helpers/setDefaultVolumeVOI'; import { ActorRenderMode } from '../../../types'; import type { IImageData, Point2, Point3 } from '../../../types'; import type { @@ -79,6 +80,10 @@ export class VtkVolumeSliceRenderPath const transferFunction = actor.getProperty().getRGBTransferFunction(0); const defaultRange = transferFunction?.getRange?.(); + // The VOI LUT Function and the VOI LUT Sequence of the file, which the + // range cannot carry. setDefaultVolumeVOI already put them on the actor; + // the presentation needs them again on each change of the VOI. + const volumeVOIShape = getVolumeVOIShape(imageVolume); const rendering: PlanarVolumeSliceRendering = { renderMode: ActorRenderMode.VTK_VOLUME_SLICE, @@ -94,6 +99,8 @@ export class VtkVolumeSliceRenderPath defaultVOIRange: defaultRange ? { lower: defaultRange[0], upper: defaultRange[1] } : undefined, + defaultVOILUTFunction: volumeVOIShape.voiLUTFunction, + defaultVOILUT: volumeVOIShape.voiLUT, dataPresentation: undefined, isSegmentationOverlay, }; @@ -205,6 +212,10 @@ export class VtkVolumeSliceRenderPath defaultVOIRange: rendering.isSegmentationOverlay ? undefined : rendering.defaultVOIRange, + defaultVOILUTFunction: rendering.defaultVOILUTFunction, + defaultVOILUT: rendering.isSegmentationOverlay + ? undefined + : rendering.defaultVOILUT, mapper: rendering.mapper, props: rendering.dataPresentation, }); diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/planarLegacyCompatibility.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/planarLegacyCompatibility.ts index 0f2e8f0c64..52138ce461 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/planarLegacyCompatibility.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/planarLegacyCompatibility.ts @@ -209,6 +209,17 @@ export function toPlanarDataPresentation( presentation.voiRange = cloneVOIRange(properties.voiRange); } + // The legacy name of the VOI LUT Function (0028,1056) is VOILUTFunction. The + // presentation had no path for it, so a request for SIGMOID or for + // LINEAR_EXACT on a compatibility viewport did nothing. + if (properties.VOILUTFunction !== undefined) { + presentation.voiLUTFunction = properties.VOILUTFunction; + } + + if (properties.useVOILUTSequence !== undefined) { + presentation.useVOILUTSequence = properties.useVOILUTSequence; + } + if (properties.invert !== undefined) { presentation.invert = properties.invert; } diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/planarRuntimeTypes.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/planarRuntimeTypes.ts index 5439742fbe..f4ff228eb0 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/planarRuntimeTypes.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/planarRuntimeTypes.ts @@ -21,10 +21,11 @@ import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import type vtkImageMapper from '@kitware/vtk.js/Rendering/Core/ImageMapper'; import type vtkImageResliceMapper from '@kitware/vtk.js/Rendering/Core/ImageResliceMapper'; import type vtkImageSlice from '@kitware/vtk.js/Rendering/Core/ImageSlice'; -import type { InterpolationType } from '../../../enums'; +import type { InterpolationType, VOILUTFunctionType } from '../../../enums'; import type { CPUFallbackEnabledElement, ActorRenderMode, + CPUFallbackLUT, ICanvasActor, IImage, IImageVolume, @@ -148,6 +149,10 @@ export type PlanarVolumeSliceRendering = MountedRendering<{ currentImageIdIndex: number; maxImageIdIndex: number; defaultVOIRange?: VOIRange; + /** VOI LUT Function (0028,1056) of the file of the volume. */ + defaultVOILUTFunction?: VOILUTFunctionType; + /** VOI LUT Sequence (0028,3010) of the file of the volume. */ + defaultVOILUT?: CPUFallbackLUT; dataPresentation?: PlanarDataPresentation; isSegmentationOverlay?: boolean; removeStreamingSubscriptions?: () => void; diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/planarVolumePresentation.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/planarVolumePresentation.ts index a95b575347..e3f6f18276 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/planarVolumePresentation.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/planarVolumePresentation.ts @@ -1,8 +1,12 @@ import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction'; import type vtkImageResliceMapper from '@kitware/vtk.js/Rendering/Core/ImageResliceMapper'; import type vtkImageSlice from '@kitware/vtk.js/Rendering/Core/ImageSlice'; -import type { ColormapPublic, VOIRange } from '../../../types'; -import { createPlanarRGBTransferFunction } from '../../helpers/planarImageRendering'; +import type { VOILUTFunctionType } from '../../../enums'; +import type { ColormapPublic, CPUFallbackLUT, VOIRange } from '../../../types'; +import { + createPlanarRGBTransferFunction, + resolveVOILUTSequenceToApply, +} from '../../helpers/planarImageRendering'; import type { PlanarDataPresentation } from './PlanarViewportTypes'; import { mapBlendModeToSlabType, @@ -13,9 +17,20 @@ export function applyPlanarVolumePresentation(args: { actor: vtkImageSlice; mapper: vtkImageResliceMapper; defaultVOIRange?: VOIRange; + /** VOI LUT Function (0028,1056) of the file of the volume. */ + defaultVOILUTFunction?: VOILUTFunctionType; + /** VOI LUT Sequence (0028,3010) of the file of the volume. */ + defaultVOILUT?: CPUFallbackLUT; props?: PlanarDataPresentation; }): void { - const { actor, defaultVOIRange, mapper, props } = args; + const { + actor, + defaultVOIRange, + defaultVOILUTFunction, + defaultVOILUT, + mapper, + props, + } = args; const property = actor.getProperty(); const voiRange = props?.voiRange ?? defaultVOIRange; @@ -55,6 +70,12 @@ export function applyPlanarVolumePresentation(args: { colormap: props?.colormap, invert: props?.invert, voiRange, + voiLUTFunction: props?.voiLUTFunction ?? defaultVOILUTFunction, + voiLUT: resolveVOILUTSequenceToApply({ + defaultVOILUT, + defaultVOILUTFunction, + props, + }), }); property.setUseLookupTableScalarRange(true); diff --git a/packages/core/src/RenderingEngine/helpers/planarImageRendering.ts b/packages/core/src/RenderingEngine/helpers/planarImageRendering.ts index a7a6772f91..df7a4bf1d4 100644 --- a/packages/core/src/RenderingEngine/helpers/planarImageRendering.ts +++ b/packages/core/src/RenderingEngine/helpers/planarImageRendering.ts @@ -40,6 +40,12 @@ export interface PlanarImagePresentation { colormap?: ColormapPublic; voiRange?: VOIRange; voiLUTFunction?: VOILUTFunctionType; + /** + * The use of the VOI LUT Sequence (0028,3010) of the image. If this property + * is undefined, the viewport uses the sequence when the image has one. If it + * is false, the viewport ignores the sequence and uses the VOI LUT Function. + */ + useVOILUTSequence?: boolean; invert?: boolean; } @@ -192,25 +198,11 @@ export function applyPlanarImagePresentation(args: { } = args; const property = actor.getProperty(); const voiRange = props?.voiRange ?? defaultVOIRange; - // This rule is the same as the rule in - // StackViewport._getVOILUTSequenceToApply. Only one of the two can control - // the display. Thus a VOI LUT Function that is different from the function - // of the image stops the VOI LUT Sequence of the file. A function that is - // equal to the function of the image does not stop it. An absent tag - // (0028,1056) gives the LINEAR value, and getProperties gives that value to - // the application. Thus an application that applies the presentation that it - // read keeps the sequence. A range from the caller also keeps the sequence. - // The transfer function stretches the curve over that range. Thus window - // level operations keep the shape that the file specifies. - const canUseVOILUTSequence = - props?.voiLUTFunction === undefined || - getValidVOILUTFunction(props.voiLUTFunction) === - getValidVOILUTFunction(defaultVOILUTFunction); - let voiLUT; - - if (canUseVOILUTSequence) { - voiLUT = defaultVOILUT; - } + const voiLUT = resolveVOILUTSequenceToApply({ + defaultVOILUT, + defaultVOILUTFunction, + props, + }); if (props?.visible !== undefined) { actor.setVisibility(props.visible); @@ -244,6 +236,47 @@ export function applyPlanarImagePresentation(args: { property.setRGBTransferFunction(0, transferFunction); } +/** + * The VOI LUT Sequence (0028,3010) that a presentation lets the file keep. + * + * This rule is the same as the rule in + * StackViewport._getVOILUTSequenceToApply and in + * BaseVolumeViewport._getVOILUTSequenceToApply. Only one of the sequence and + * the VOI LUT Function can control the display. Thus a function that is + * different from the function of the image stops the sequence. A function that + * is equal to the function of the image does not stop it. An absent tag + * (0028,1056) gives the LINEAR value, and getProperties gives that value to the + * application. Thus an application that applies the presentation that it read + * keeps the sequence. A range from the caller also keeps the sequence. The + * transfer function stretches the curve over that range. Thus window level + * operations keep the shape that the file specifies. + * + * The property `useVOILUTSequence` is the direct control: `false` ignores the + * sequence, and `true` keeps it whatever the function is. + */ +export function resolveVOILUTSequenceToApply(args: { + defaultVOILUT?: CPUFallbackLUT; + defaultVOILUTFunction?: VOILUTFunctionType; + props?: Pick; +}): CPUFallbackLUT | undefined { + const { defaultVOILUT, defaultVOILUTFunction, props } = args; + + if (props?.useVOILUTSequence === false) { + return undefined; + } + + const functionIsDifferent = + props?.voiLUTFunction !== undefined && + getValidVOILUTFunction(props.voiLUTFunction) !== + getValidVOILUTFunction(defaultVOILUTFunction); + + if (props?.useVOILUTSequence !== true && functionIsDifferent) { + return undefined; + } + + return defaultVOILUT; +} + export function createPlanarRGBTransferFunction(args: { colormap?: ColormapPublic; invert?: boolean; diff --git a/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts b/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts index 80a5f5ddb3..75b481fc38 100644 --- a/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts +++ b/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts @@ -4,18 +4,52 @@ import type { IImageVolume, VOIRange, ScalingParameters, + CPUFallbackLUT, } from '../../types'; +import type vtkColorTransferFunctionType from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; import { loadAndCacheImage } from '../../loaders/imageLoader'; import * as metaData from '../../metaData'; import * as windowLevel from '../../utilities/windowLevel'; import { normalizeVOILUTFunction } from '../../utilities/voiLUTFunction'; -import { MetadataModules, RequestType } from '../../enums'; +import createVOILUTSequenceTransferFunction, { + getVOILUTSequenceRange, + isRenderableVOILUT, +} from '../../utilities/createVOILUTSequenceTransferFunction'; +import createSigmoidRGBTransferFunction from '../../utilities/createSigmoidRGBTransferFunction'; +import { MetadataModules, RequestType, VOILUTFunctionType } from '../../enums'; import cache from '../../cache/cache'; const PRIORITY = 0; const REQUEST_TYPE = RequestType.Prefetch; +// A prescaled PT volume holds SUV values, and a SUV of 0 to 5 is the range that +// the majority of the viewers use. See _isCurrentImagePTPrescaled. +const PT_PRESCALED_RANGE: VOIRange = { lower: 0, upper: 5 }; + +/** + * The shape of the VOI transformation that the file of a volume specifies: the + * VOI LUT Function (0028,1056) and the VOI LUT Sequence (0028,3010). The range + * alone cannot carry either one - a sigmoid and a curve of a sequence need + * their own transfer function. + */ +export type VolumeVOIShape = { + voiLUTFunction?: VOILUTFunctionType; + voiLUT?: CPUFallbackLUT; +}; + +/** The full default VOI of a volume: the range plus its shape. */ +export type VolumeVOI = VolumeVOIShape & { voiRange?: VOIRange }; + +/** + * What one instance of a volume says about the VOI. Either a window, or a VOI + * LUT Sequence, together with the VOI LUT Function of that instance. + */ +type VolumeVOISource = VolumeVOIShape & { + windowWidth?: number; + windowCenter?: number; +}; + /** * It sets the default window level of an image volume based on the VOI. * It first look for the VOI in the metadata and if it is not found, it @@ -29,18 +63,111 @@ async function setDefaultVolumeVOI( volumeActor: VolumeActor | ImageActor, imageVolume: IImageVolume ): Promise { - const voi = await getDefaultVolumeVOIRange(imageVolume); + const voi = await getDefaultVolumeVOI(imageVolume); - if ( - !voi || - (voi.lower === 0 && voi.upper === 0) || - voi.lower === undefined || - voi.upper === undefined - ) { + applyVolumeVOI(volumeActor, voi); +} + +/** + * Puts a default VOI on an actor. A VOI LUT Sequence and the SIGMOID function + * need a new transfer function, because a range cannot give a curve. Each other + * function keeps the transfer function of the actor and moves its range. Thus a + * colormap on that transfer function stays. + */ +function applyVolumeVOI( + volumeActor: VolumeActor | ImageActor, + voi: VolumeVOI +): void { + const { voiRange } = voi; + + if (!isUsableVOIRange(voiRange)) { return; } - ensureRGBTransferFunction(volumeActor).setMappingRange(voi.lower, voi.upper); + const curve = createVolumeVOITransferFunction(voi); + + if (curve) { + installTransferFunction(volumeActor, curve); + + return; + } + + ensureRGBTransferFunction(volumeActor).setMappingRange( + voiRange.lower, + voiRange.upper + ); +} + +/** + * The transfer function that the shape of a VOI needs, or undefined when a + * range on the existing transfer function is enough (the LINEAR and the + * LINEAR_EXACT functions, whose difference is already in the range). + * + * A VOI LUT Sequence is the whole VOI transformation. Thus it replaces the + * window and the VOI LUT Function (PS3.3 C.11.2.1). The curve is stretched over + * the range. Thus window level reshapes the curve and does not replace it. The + * stack viewport and the generic viewports use the same rule. + */ +export function createVolumeVOITransferFunction( + voi: VolumeVOI +): vtkColorTransferFunctionType | undefined { + const { voiRange, voiLUT, voiLUTFunction } = voi; + + if (!isUsableVOIRange(voiRange)) { + return undefined; + } + + if (isRenderableVOILUT(voiLUT)) { + return createVOILUTSequenceTransferFunction(voiLUT, { voiRange }); + } + + if (isSigmoid(voiLUTFunction)) { + return createSigmoidRGBTransferFunction(voiRange); + } + + return undefined; +} + +/** + * True when a VOI needs its own transfer function, and false when a range on + * the existing one gives the same display. + */ +export function volumeVOIIsCurve(voi: VolumeVOIShape): boolean { + return isRenderableVOILUT(voi.voiLUT) || isSigmoid(voi.voiLUTFunction); +} + +function isSigmoid(voiLUTFunction?: VOILUTFunctionType): boolean { + return ( + normalizeVOILUTFunction(voiLUTFunction) === + VOILUTFunctionType.SAMPLED_SIGMOID + ); +} + +function isUsableVOIRange(voiRange?: VOIRange): boolean { + if (!voiRange) { + return false; + } + + const { lower, upper } = voiRange; + + if (lower === undefined || upper === undefined) { + return false; + } + + return lower !== 0 || upper !== 0; +} + +function installTransferFunction( + volumeActor: VolumeActor | ImageActor, + transferFunction: vtkColorTransferFunctionType +) { + const property = volumeActor.getProperty(); + + property.setRGBTransferFunction(0, transferFunction); + + if ('setUseLookupTableScalarRange' in property) { + property.setUseLookupTableScalarRange?.(true); + } } function ensureRGBTransferFunction(volumeActor: VolumeActor | ImageActor) { @@ -63,30 +190,66 @@ function ensureRGBTransferFunction(volumeActor: VolumeActor | ImageActor) { return transferFunction; } -export async function getDefaultVolumeVOIRange( +/** + * The default VOI of a volume: the range, the VOI LUT Function and the VOI LUT + * Sequence. The range comes from the file, or from the minimum and the maximum + * of the middle slice when the file gives no VOI. + */ +export async function getDefaultVolumeVOI( imageVolume: IImageVolume -): Promise { - let voi = getVOIFromMetadata(imageVolume); - - // A prescaled PT volume contains SUV values, but the window in the metadata - // is in the unscaled counts. Thus this window gives a very large range and a - // black volume, and a prescaled PT volume must use the default range 0 to 5. - // This is also correct for a volume that has no imageIds, because the - // prescaling is a property of the volume, not of the source of the window. - if (voi) { - voi = handlePreScaledVolume(imageVolume, voi); +): Promise { + // A prescaled PT volume contains SUV values, but the window and the curve in + // the metadata are in the unscaled counts. Thus neither applies to it, and it + // uses the default range 0 to 5. This is also correct for a volume that has + // no imageIds, because the prescaling is a property of the volume, not of the + // source of the window. + if (isPTPrescaledVolume(imageVolume)) { + return { voiRange: PT_PRESCALED_RANGE }; } + const source = getVolumeVOISource(imageVolume); + let voiRange = getRangeFromVOISource(source); + if ( - !voi && + !voiRange && imageVolume.imageIds?.length && shouldUseImageIdsForVOI(imageVolume) ) { - voi = await getVOIFromMiddleSliceMinMax(imageVolume); - voi = handlePreScaledVolume(imageVolume, voi); + voiRange = await getVOIFromMiddleSliceMinMax(imageVolume); + } + + return { + voiRange, + voiLUT: source?.voiLUT, + voiLUTFunction: source?.voiLUTFunction, + }; +} + +export async function getDefaultVolumeVOIRange( + imageVolume: IImageVolume +): Promise { + const { voiRange } = await getDefaultVolumeVOI(imageVolume); + + return voiRange; +} + +/** + * The shape of the VOI of a volume, without the load of an instance that the + * range fallback needs. A viewport calls this on every window level change, so + * it stays synchronous and reads the metadata only. + */ +export function getVolumeVOIShape(imageVolume: IImageVolume): VolumeVOIShape { + if (!imageVolume || isPTPrescaledVolume(imageVolume)) { + return {}; } - return voi; + const source = getVolumeVOISource(imageVolume); + + if (!source) { + return {}; + } + + return { voiLUT: source.voiLUT, voiLUTFunction: source.voiLUTFunction }; } function shouldUseImageIdsForVOI(imageVolume: IImageVolume): boolean { @@ -108,7 +271,7 @@ function shouldUseImageIdsForVOI(imageVolume: IImageVolume): boolean { return imageId.includes(':'); } -function handlePreScaledVolume(imageVolume: IImageVolume, voi: VOIRange) { +function isPTPrescaledVolume(imageVolume: IImageVolume): boolean { const imageIds = imageVolume.imageIds ?? []; const imageIdIndex = Math.floor(imageIds.length / 2); const imageId = imageIds[imageIdIndex]; @@ -125,25 +288,75 @@ function handlePreScaledVolume(imageVolume: IImageVolume, voi: VOIRange) { const modality = generalSeriesModule.modality ?? imageVolume.metadata?.Modality; - /** - * If the volume is prescaled and the modality is PT Sometimes you get super high - * values at the peak and it skews the min/max so nothing useful is displayed - * Therefore, we follow the majority of other viewers and we set the min/max - * for the scaled PT to be 0, 5 - */ - if (_isCurrentImagePTPrescaled(modality, imageVolume)) { - return { - lower: 0, - upper: 5, - }; + return _isCurrentImagePTPrescaled(modality, imageVolume); +} + +/** + * The VOI LUT Sequence of an instance, in the shape that the renderers use. + * + * A cached image is the best source: the loader normalizes the sequence of the + * file there, and each provider (wadouri, naturalized dcmjs, DICOMweb JSON) + * gives another shape. The metadata module is the fallback for a volume whose + * instances were never loaded as images; it holds the already parsed shape on + * the wadouri path. + */ +function getVOILUTSequenceForImageId( + imageId: string, + voiLutModule: Record +): CPUFallbackLUT | undefined { + const imageVOILUT = cache.getImage(imageId)?.voiLUT; + + if (isRenderableVOILUT(imageVOILUT)) { + return imageVOILUT; + } + + const sequence = voiLutModule.voiLUTSequence; + + if (!sequence) { + return undefined; + } + + const items = Array.isArray(sequence) ? sequence : [sequence]; + + return items.find(isRenderableVOILUT); +} + +/** + * What one instance says about the VOI, or undefined when it says nothing. + */ +function getVOISourceFromImageId(imageId: string): VolumeVOISource | undefined { + const voiLutModule = metaData.get(MetadataModules.VOI_LUT, imageId) ?? {}; + const voiLUTFunction = normalizeVOILUTFunction(voiLutModule.voiLUTFunction); + const voiLUT = getVOILUTSequenceForImageId(imageId, voiLutModule); + + // The sequence is the whole VOI transformation. Thus it wins over a window + // that the same instance also carries (C.11.2.1), as on the stack viewport. + if (voiLUT) { + return { voiLUT, voiLUTFunction }; + } + + const { windowWidth, windowCenter } = voiLutModule; + const width = Array.isArray(windowWidth) ? windowWidth[0] : windowWidth; + const center = Array.isArray(windowCenter) ? windowCenter[0] : windowCenter; + + // A center of 0 is a correct window. Prescaled PT volumes, parametric maps + // and centered MR volumes use one. Thus make sure that the center exists, + // but do not make sure that the center is not 0. If not, these series use a + // range from the minimum and the maximum. But a width of 0, or an absent + // width, gives no window. + if (!width || center == null) { + return undefined; } - return voi; + // Keep the VOI LUT Function with the window. The function controls how the + // window becomes a range. If the function is lost, a volume with the + // function LINEAR_EXACT or SIGMOID gets a LINEAR window. + return { windowWidth: width, windowCenter: center, voiLUTFunction }; } /** - * Finds a usable Window Center and Window Width. The search starts at the middle - * of the stack and continues to the two ends. + * Finds a usable VOI. The search starts at the middle of the stack and + * continues to the two ends. * * The middle instance is the best slice, but its metadata can be absent. The * position of an imageId in the volume depends on the method that made the @@ -153,7 +366,9 @@ function handlePreScaledVolume(imageVolume: IImageVolume, voi: VOIRange) { * the other instances of the series have the same window. Thus the nearest * instance that has a window gives a much better result. */ -function getWindowFromNearestImageId(imageIds: string[]) { +function getVOISourceFromImageIds( + imageIds: string[] +): VolumeVOISource | undefined { const middle = Math.floor(imageIds.length / 2); for (let offset = 0; offset < imageIds.length; offset++) { @@ -165,68 +380,85 @@ function getWindowFromNearestImageId(imageIds: string[]) { continue; } - const voiLutModule = metaData.get(MetadataModules.VOI_LUT, imageIds[index]); + const source = getVOISourceFromImageId(imageIds[index]); - const { windowWidth, windowCenter } = voiLutModule ?? {}; - const width = Array.isArray(windowWidth) ? windowWidth[0] : windowWidth; - const center = Array.isArray(windowCenter) ? windowCenter[0] : windowCenter; - - // A center of 0 is a correct window. Prescaled PT volumes, parametric maps - // and centered MR volumes use one. Thus make sure that the center exists, - // but do not make sure that the center is not 0. If not, these series use a - // range from the minimum and the maximum. But a width of 0, or an absent - // width, gives no window. - if (!width || center == null) { - continue; + if (source) { + return source; } - - // Keep the VOI LUT Function with the window. The function controls how the - // window becomes a range. If the function is lost, a volume with the - // function LINEAR_EXACT or SIGMOID gets a LINEAR window. - return { - windowWidth: width, - windowCenter: center, - voiLUTFunction: normalizeVOILUTFunction(voiLutModule.voiLUTFunction), - }; } return undefined; } /** - * Get the VOI from the metadata of the middle slice of the image volume or the metadata of the image volume - * It checks the metadata for the VOI and if it is not found, it returns null - * - * @param imageVolume - The image volume that we want to get the VOI from. - * @returns VOIRange with lower and upper values + * A volume that has no imageIds contains its own window, but the volume is not + * required to have one. */ -function getVOIFromMetadata(imageVolume: IImageVolume): VOIRange | undefined { - const { imageIds, metadata } = imageVolume; - let voi; +function getVOISourceFromVolumeMetadata( + imageVolume: IImageVolume +): VolumeVOISource | undefined { + const voi = imageVolume.metadata?.voiLut?.[0]; + + if (!voi) { + return undefined; + } + + return { + windowWidth: Number(voi.windowWidth), + windowCenter: Number(voi.windowCenter), + voiLUTFunction: normalizeVOILUTFunction(voi.voiLUTFunction), + }; +} + +function getVolumeVOISource( + imageVolume: IImageVolume +): VolumeVOISource | undefined { + const { imageIds } = imageVolume; + if (imageIds?.length) { - voi = getWindowFromNearestImageId(imageIds); - } else { - // A volume that has no imageIds contains its own window, but the volume is - // not required to have one. - voi = metadata?.voiLut?.[0]; - } - - if (voi && (voi.windowWidth !== 0 || voi.windowCenter !== 0)) { - const { lower, upper } = windowLevel.toLowHighRange( - Number(voi.windowWidth), - Number(voi.windowCenter), - voi.voiLUTFunction - ); - - if (isNaN(lower) || isNaN(upper)) { - return; - } + return getVOISourceFromImageIds(imageIds); + } + + return getVOISourceFromVolumeMetadata(imageVolume); +} + +/** + * The range of a VOI source. A VOI LUT Sequence gives its own input domain, + * because the curve is defined against the output of the modality LUT and not + * relative to a window. A window becomes a range through its VOI LUT Function. + */ +function getRangeFromVOISource( + source: VolumeVOISource | undefined +): VOIRange | undefined { + if (!source) { + return undefined; + } - return { lower, upper }; + if (isRenderableVOILUT(source.voiLUT)) { + return getVOILUTSequenceRange(source.voiLUT); } - // Return undefined if no valid VOI was found - return undefined; + const { windowWidth, windowCenter, voiLUTFunction } = source; + + if (windowWidth == null || windowCenter == null) { + return undefined; + } + + if (windowWidth === 0 && windowCenter === 0) { + return undefined; + } + + const { lower, upper } = windowLevel.toLowHighRange( + Number(windowWidth), + Number(windowCenter), + voiLUTFunction + ); + + if (isNaN(lower) || isNaN(upper)) { + return undefined; + } + + return { lower, upper }; } /** diff --git a/packages/core/src/types/StackViewportProperties.ts b/packages/core/src/types/StackViewportProperties.ts index 28f2061c37..4d8ebd595a 100644 --- a/packages/core/src/types/StackViewportProperties.ts +++ b/packages/core/src/types/StackViewportProperties.ts @@ -11,12 +11,6 @@ type StackViewportProperties = ViewportProperties & { suppressEvents?: boolean; /** Indicates if the voi is a computed VOI (not user set) */ isComputedVOI?: boolean; - /** - * The use of the VOI LUT Sequence (0028,3010) of the image. If this property - * is undefined, the viewport uses the sequence when the image has one. If it - * is false, the viewport ignores the sequence and uses the VOI LUT Function. - */ - useVOILUTSequence?: boolean; }; export type { StackViewportProperties as default }; diff --git a/packages/core/src/types/ViewportProperties.ts b/packages/core/src/types/ViewportProperties.ts index 87c9626eed..7ecf09c264 100644 --- a/packages/core/src/types/ViewportProperties.ts +++ b/packages/core/src/types/ViewportProperties.ts @@ -8,8 +8,14 @@ import type { ColormapPublic } from './Colormap'; export interface ViewportProperties { /** voi range (upper, lower) for the viewport */ voiRange?: VOIRange; - /** VOILUTFunction type which is LINEAR or SAMPLED_SIGMOID */ + /** VOILUTFunction type which is LINEAR, LINEAR_EXACT or SAMPLED_SIGMOID */ VOILUTFunction?: VOILUTFunctionType; + /** + * The use of the VOI LUT Sequence (0028,3010) of the image. If this property + * is undefined, the viewport uses the sequence when the image has one. If it + * is false, the viewport ignores the sequence and uses the VOI LUT Function. + */ + useVOILUTSequence?: boolean; /** invert flag - whether the image is inverted */ invert?: boolean; /** Colormap applied to the viewport*/ diff --git a/packages/core/src/types/voi.ts b/packages/core/src/types/voi.ts index 9cebbe4e4d..a009e4a76d 100644 --- a/packages/core/src/types/voi.ts +++ b/packages/core/src/types/voi.ts @@ -3,6 +3,11 @@ interface VOI { windowWidth: number; /** Window Center for display */ windowCenter: number; + /** + * VOI LUT Function (0028,1056) of the window. The function controls how the + * window becomes a range, so it must stay with the window. + */ + voiLUTFunction?: string; } interface VOIRange { diff --git a/packages/core/test/utilities/setDefaultVolumeVOI.jest.js b/packages/core/test/utilities/setDefaultVolumeVOI.jest.js index d28ff090e8..e1c00d8810 100644 --- a/packages/core/test/utilities/setDefaultVolumeVOI.jest.js +++ b/packages/core/test/utilities/setDefaultVolumeVOI.jest.js @@ -1,5 +1,9 @@ import { describe, it, expect, afterEach } from '@jest/globals'; -import { getDefaultVolumeVOIRange } from '../../src/RenderingEngine/helpers/setDefaultVolumeVOI'; +import { + getDefaultVolumeVOI, + getDefaultVolumeVOIRange, + getVolumeVOIShape, +} from '../../src/RenderingEngine/helpers/setDefaultVolumeVOI'; import * as metaData from '../../src/metaData'; import { MetadataModules } from '../../src/enums'; @@ -123,6 +127,68 @@ describe('getDefaultVolumeVOIRange', function () { ).resolves.toBeUndefined(); }); + it('gives the VOI LUT Function of the instance to the viewport', async () => { + // The volume viewport reads the function from the shape to make a sigmoid + // transfer function. Before, it always made a linear one + provider = provideVOI({ + 'test:2': { + windowWidth: 400, + windowCenter: 0, + voiLUTFunction: 'SIGMOID', + }, + }); + + expect(getVolumeVOIShape(volume).voiLUTFunction).toBe('SIGMOID'); + await expect(getDefaultVolumeVOI(volume)).resolves.toEqual({ + voiRange: { lower: -200, upper: 199 }, + voiLUT: undefined, + voiLUTFunction: 'SIGMOID', + }); + }); + + it('takes the range from a VOI LUT Sequence of the instance', async () => { + // The sequence is the whole VOI transformation, so its own input domain is + // the range, and it wins over a window of the same instance (C.11.2.1) + const voiLUT = { + firstValueMapped: 100, + numBitsPerEntry: 16, + lut: [0, 8, 16, 24], + }; + + provider = provideVOI({ + 'test:2': { windowWidth: 400, windowCenter: 0, voiLUTSequence: [voiLUT] }, + }); + + await expect(getDefaultVolumeVOI(volume)).resolves.toEqual({ + voiRange: { lower: 100, upper: 103 }, + voiLUT, + voiLUTFunction: undefined, + }); + }); + + it('ignores the VOI LUT Sequence of a prescaled PT volume', async () => { + // The curve of the file is in the unscaled counts, and the volume holds SUV + const voiLUT = { + firstValueMapped: 0, + numBitsPerEntry: 16, + lut: [0, 8, 16], + }; + + provider = provideVOI({ 'test:2': { voiLUTSequence: [voiLUT] } }); + + const ptVolume = { + imageIds, + metadata: { Modality: 'PT' }, + isPreScaled: true, + scaling: { PT: { suvbw: 1 } }, + }; + + expect(getVolumeVOIShape(ptVolume)).toEqual({}); + await expect(getDefaultVolumeVOI(ptVolume)).resolves.toEqual({ + voiRange: { lower: 0, upper: 5 }, + }); + }); + it('keeps the VOI LUT Function attached to the window', async () => { provider = provideVOI({ 'test:2': { From b5f7d10b19c5346ca9eac1aeb5719dfec5552d1f Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Fri, 21 Aug 2026 17:35:18 +0200 Subject: [PATCH 06/13] fix(core): stretch the VOI LUT Sequence over the window on the CPU path The GPU path lays the curve of a VOI LUT Sequence (0028,3010) over the current range. Thus window level reshapes the curve of the file. The CPU path used the index of the entry directly: it subtracted First Value Mapped from the value and read that entry. Thus window level did nothing on the CPU and worked on the GPU, for the same file. The curve now has one implementation, which the CPU display LUT, the GPU transfer function and the tools that map a display intensity all use. - Add createVOILUTSampler, which lays the curve over a range and gives an output from 0 to 1. The scale of the entries and the domain are calculated one time, because the CPU path maps every stored value of an image - Add sampleVOILUT and invertVOILUTSample for the callers that map one value, and getVOILUTOutputScale for the number of bits of the entries - Use the sampler in the CPU getVOILut and in the GPU builder - Take the number of bits from the largest entry, as the comment always said, but with a division and not a shift. A shift by a negative count shifts by 31 in JavaScript, so a LUT whose largest entry is below 128 gave 0 for each entry and a black image. A shift also cannot map a fractional value, which prescaled float data gives - Test for a renderable LUT with isRenderableVOILUT, so a LUT of one entry and a LUT without First Value Mapped take the window path --- .../cpuFallback/rendering/getVOILut.ts | 74 +++++----- .../createVOILUTSequenceTransferFunction.ts | 138 ++++++++++++++++-- packages/core/src/utilities/index.ts | 5 + .../core/test/utilities/getVOILut.jest.js | 39 ++++- 4 files changed, 208 insertions(+), 48 deletions(-) diff --git a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getVOILut.ts b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getVOILut.ts index 801779e3d5..af5fba6151 100644 --- a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getVOILut.ts +++ b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getVOILut.ts @@ -1,6 +1,12 @@ /* eslint no-bitwise: 0 */ import VOILUTFunctionType from '../../../../enums/VOILUTFunctionType'; import { getValidVOILUTFunction } from '../../../../utilities/voiLUTFunction'; +import { + createVOILUTSampler, + isRenderableVOILUT, +} from '../../../../utilities/createVOILUTSequenceTransferFunction'; +import type { RenderableVOILUT } from '../../../../utilities/createVOILUTSequenceTransferFunction'; +import { toLowHighRange } from '../../../../utilities/windowLevel'; import type { CPUFallbackLUT } from '../../../../types'; /** @@ -111,44 +117,41 @@ function generateSigmoidVOILUT(windowWidth: number, windowCenter: number) { }; } -function maxLUTValue(lut: ArrayLike): number { - let max = -Infinity; - - for (let i = 0; i < lut.length; i++) { - if (lut[i] > max) { - max = lut[i]; - } - } - - return max; -} - /** - * Generate a non-linear volume of interest lookup table + * Generate a non-linear volume of interest lookup table from a VOI LUT + * Sequence (0028,3010). + * + * The curve is stretched over the window, as on the GPU path (refer to + * createVOILUTSequenceTransferFunction). Thus window level reshapes the curve + * of the file and does not replace it. The window of the own domain of the LUT + * gives the curve of the file without a change, and that window is the default + * (refer to getVOILUTSequenceRange). Before this, the CPU path used the index + * of the entry directly. Thus window level did nothing on the CPU and worked on + * the GPU, for the same file. * * @param {LUT} voiLUT Volume of Interest Lookup Table Object + * @param {Number} windowWidth Window Width + * @param {Number} windowCenter Window Center + * @param {String} [voiLUTFunction] VOI LUT Function (0028,1056) * * @returns {VOILUTFunction} VOI LUT mapping function * @memberof VOILUT */ -function generateNonLinearVOILUT(voiLUT) { - // We don't trust the voiLUT.numBitsPerEntry, mainly thanks to Agfa! - // Reduced rather than spread into Math.max - VOI LUTs can hold tens of - // thousands of entries, which overflows the argument limit. - const bitsPerEntry = maxLUTValue(voiLUT.lut).toString(2).length; - const shift = bitsPerEntry - 8; - const minValue = voiLUT.lut[0] >> shift; - const maxValue = voiLUT.lut[voiLUT.lut.length - 1] >> shift; - const maxValueMapped = voiLUT.firstValueMapped + voiLUT.lut.length - 1; - - return function (modalityLutValue) { - if (modalityLutValue < voiLUT.firstValueMapped) { - return minValue; - } else if (modalityLutValue >= maxValueMapped) { - return maxValue; - } - - return voiLUT.lut[modalityLutValue - voiLUT.firstValueMapped] >> shift; +function generateNonLinearVOILUT( + voiLUT: RenderableVOILUT, + windowWidth: number, + windowCenter: number, + voiLUTFunction?: VOILUTFunctionType | string +) { + // createVOILUTSampler holds the shape of the curve for every path, and it + // takes the number of bits from the largest entry. The shift of the entries + // that this function did before gives 0 for every entry of a LUT whose + // largest entry is below 128, and it cannot map a fractional value. + const voiRange = toLowHighRange(windowWidth, windowCenter, voiLUTFunction); + const sample = createVOILUTSampler(voiLUT, voiRange); + + return function (modalityLutValue: number): number { + return sample(modalityLutValue) * Y_MAX; }; } @@ -174,8 +177,13 @@ export default function ( voiLUT?: CPUFallbackLUT, voiLUTFunction?: VOILUTFunctionType | string ) { - if (voiLUT?.lut?.length) { - return generateNonLinearVOILUT(voiLUT); + if (isRenderableVOILUT(voiLUT)) { + return generateNonLinearVOILUT( + voiLUT, + windowWidth, + windowCenter, + voiLUTFunction + ); } switch (getValidVOILUTFunction(voiLUTFunction)) { diff --git a/packages/core/src/utilities/createVOILUTSequenceTransferFunction.ts b/packages/core/src/utilities/createVOILUTSequenceTransferFunction.ts index a19d099840..ccd8aa634b 100644 --- a/packages/core/src/utilities/createVOILUTSequenceTransferFunction.ts +++ b/packages/core/src/utilities/createVOILUTSequenceTransferFunction.ts @@ -19,7 +19,7 @@ const TOLERANCE = 1 / 512; * A VOI LUT Sequence item (0028,3010) that can be rendered, i.e. one that has * both LUT Data and a usable LUT Descriptor. */ -type RenderableVOILUT = CPUFallbackLUT & { firstValueMapped: number }; +export type RenderableVOILUT = CPUFallbackLUT & { firstValueMapped: number }; /** * Returns true when the given VOI LUT Sequence item holds enough data to build @@ -48,6 +48,124 @@ export function getVOILUTSequenceRange(voiLUT: RenderableVOILUT): VOIRange { }; } +/** + * The value that the largest entry of a LUT can hold. + * + * The number of significant bits comes from the largest entry and not from the + * declared depth in LUT Descriptor, because that declared depth cannot be + * trusted in real world data. + */ +export function getVOILUTOutputScale(lut: ArrayLike): number { + let maxEntry = -Infinity; + + for (let i = 0; i < lut.length; i++) { + if (lut[i] > maxEntry) { + maxEntry = lut[i]; + } + } + + if (maxEntry <= 0) { + return 0; + } + + return Math.pow(2, Math.ceil(Math.log2(maxEntry + 1))) - 1; +} + +/** + * The output of a VOI LUT Sequence for one input value, from 0 to 1. + * + * The curve is laid over `voiRange`, or over the own domain of the LUT when no + * range is given. Every path that shows a sequence uses this function, so the + * CPU renderer, the GPU transfer function and the tools that map a display + * intensity all see one curve. + * + * @param voiLUT - a VOI LUT Sequence item + * @param value - a value in the output space of the modality LUT + * @param voiRange - the input range to lay the curve over + */ +export function sampleVOILUT( + voiLUT: RenderableVOILUT, + value: number, + voiRange?: VOIRange +): number { + return createVOILUTSampler(voiLUT, voiRange)(value); +} + +/** + * A function that gives the output of a VOI LUT Sequence, from 0 to 1, for each + * input value. The scale of the entries and the domain are calculated one time. + * A path that maps many values, such as the display LUT of the CPU renderer, + * must use this and not sampleVOILUT: a scan of the entries for each value of a + * LUT of 65536 entries is very slow. + * + * @param voiLUT - a VOI LUT Sequence item + * @param voiRange - the input range to lay the curve over + */ +export function createVOILUTSampler( + voiLUT: RenderableVOILUT, + voiRange?: VOIRange +): (value: number) => number { + const { lut } = voiLUT; + const scale = getVOILUTOutputScale(lut); + + if (!scale) { + return () => 0; + } + + const lastIndex = lut.length - 1; + const domain = resolveDomain(voiLUT, voiRange); + const span = domain.upper - domain.lower; + + // A zero span is a threshold and has no span to lay the curve over. + if (span <= 0) { + return (value: number) => { + if (value < domain.lower) { + return lut[0] / scale; + } + + return lut[lastIndex] / scale; + }; + } + + return (value: number) => { + const index = Math.round(((value - domain.lower) / span) * lastIndex); + + return lut[Math.min(Math.max(index, 0), lastIndex)] / scale; + }; +} + +/** + * The input value that gives an output of `output01`, from 0 to 1. The search + * takes the entry that is nearest to the output, so a curve that is not + * monotonic gives the first of the equal entries. + */ +export function invertVOILUTSample( + voiLUT: RenderableVOILUT, + output01: number, + voiRange?: VOIRange +): number { + const { lut } = voiLUT; + const domain = resolveDomain(voiLUT, voiRange); + const scale = getVOILUTOutputScale(lut); + const target = output01 * scale; + let nearestIndex = 0; + let nearestDistance = Infinity; + + for (let i = 0; i < lut.length; i++) { + const distance = Math.abs(lut[i] - target); + + if (distance < nearestDistance) { + nearestDistance = distance; + nearestIndex = i; + } + } + + return ( + domain.lower + + (nearestIndex / (lut.length - 1)) * (domain.upper - domain.lower) + ); +} + /** * The input range to lay the curve over: the requested one, or the LUT's own * domain when no range was requested. A zero width range would collapse every @@ -109,24 +227,16 @@ export default function createVOILUTSequenceTransferFunction( const inputAt = (index: number) => domain.lower + (index / (length - 1)) * (domain.upper - domain.lower); - let maxEntry = -Infinity; - for (let i = 0; i < length; i++) { - if (lut[i] > maxEntry) { - maxEntry = lut[i]; - } - } + // Same "don't trust numBitsPerEntry" heuristic as the CPU path: derive the + // bit depth from the data so a LUT whose entries only use part of its + // declared depth still spans the full display range. + const scale = getVOILUTOutputScale(lut); // An all zero LUT carries no curve to render - if (maxEntry <= 0) { + if (!scale) { return undefined; } - // Same "don't trust numBitsPerEntry" heuristic as the CPU path: derive the - // bit depth from the data so a LUT whose entries only use part of its - // declared depth still spans the full display range. - const bitsPerEntry = Math.ceil(Math.log2(maxEntry + 1)); - const scale = Math.pow(2, bitsPerEntry) - 1; - const step = Math.max(1, Math.ceil(length / maxNodes)); const table: number[] = []; let lastPushed = -1; diff --git a/packages/core/src/utilities/index.ts b/packages/core/src/utilities/index.ts index 26580cb33b..b10e8a7448 100644 --- a/packages/core/src/utilities/index.ts +++ b/packages/core/src/utilities/index.ts @@ -125,7 +125,12 @@ export type { ViewportVoiMappingProps } from './viewportVoiIntensityMapping'; export { isRenderableVOILUT, getVOILUTSequenceRange, + getVOILUTOutputScale, + sampleVOILUT, + createVOILUTSampler, + invertVOILUTSample, } from './createVOILUTSequenceTransferFunction'; +export type { RenderableVOILUT } from './createVOILUTSequenceTransferFunction'; export { normalizeVOILUTFunction, getValidVOILUTFunction, diff --git a/packages/core/test/utilities/getVOILut.jest.js b/packages/core/test/utilities/getVOILut.jest.js index fe135b5d1b..7509221b99 100644 --- a/packages/core/test/utilities/getVOILut.jest.js +++ b/packages/core/test/utilities/getVOILut.jest.js @@ -73,13 +73,50 @@ describe('cpuFallback getVOILut', function () { numBitsPerEntry: 8, lut: [0, 64, 128, 255], }; - const fn = getVOILUT(1, 1, voiLUT, VOILUTFunctionType.SAMPLED_SIGMOID); + // The window of the own domain of the LUT (0 .. 3) gives the curve of the + // file without a change + const fn = getVOILUT(4, 2, voiLUT, VOILUTFunctionType.SAMPLED_SIGMOID); + expect(fn(0)).toBe(0); expect(fn(1)).toBe(64); + expect(fn(3)).toBe(255); // Values below/above the mapped range clamp to the first/last entry expect(fn(-10)).toBe(0); expect(fn(10)).toBe(255); }); + + it('stretches a VOI LUT Sequence over the window', () => { + // The GPU path stretches the curve over the range, so window level + // reshapes it. The CPU path used the index of the entry directly, so a + // window level drag did nothing for the same file + const voiLUT = { + firstValueMapped: 0, + numBitsPerEntry: 8, + lut: [0, 64, 128, 255], + }; + const fn = getVOILUT(7, 3.5, voiLUT, VOILUTFunctionType.LINEAR); + + // The window 0 .. 6 is two times the domain of the LUT. Thus the middle of + // the window gives the middle of the curve. + expect(fn(0)).toBe(0); + expect(fn(3)).toBe(128); + expect(fn(6)).toBe(255); + }); + + it('uses the full display range for a LUT of small entries', () => { + // The number of bits comes from the largest entry, and a shift of the + // entries gave 0 for every entry of a LUT whose largest entry is below 128 + const voiLUT = { + firstValueMapped: 0, + numBitsPerEntry: 16, + lut: [0, 50, 100], + }; + const fn = getVOILUT(3, 1.5, voiLUT, VOILUTFunctionType.LINEAR); + + // 7 bits hold 100, so the entries are scaled by 127 + expect(fn(0)).toBe(0); + expect(fn(2)).toBeCloseTo((100 / 127) * 255, 5); + }); }); describe('createLinearRGBTransferFunction', function () { From 540bcb2ce9cb64c547de6368db9fa2529f8f515e Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Fri, 21 Aug 2026 17:38:22 +0200 Subject: [PATCH 07/13] fix(core): keep the colormap when a VOI LUT Sequence controls the display createPlanarRGBTransferFunction gives a colormap precedence over the curve of a VOI LUT Sequence and over a sigmoid: a colormap fills the transfer function with its own colors, and a curve of grey would remove them. StackViewport had no such test. Thus setProperties({ colormap }) on an image that carries a sequence, and then one window level drag or one scroll, made the image grey again. A sigmoid did the same, which is older. - Stop the curve of a sequence and the curve of a sigmoid on the stack GPU path when a colormap is applied. The range then moves on the transfer function of the colormap, which keeps its colors - Do not make a new transfer function for the transition away from a sequence when a colormap is applied. setColormapGPU already put the colors of the colormap on the transfer function, and a new one would remove them - Keep the sequence on the CPU path. There the colormap comes after the VOI LUT, so the curve and the colors combine, and _syncCPUVOILUTSequence is correct as it is --- .../core/src/RenderingEngine/StackViewport.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/core/src/RenderingEngine/StackViewport.ts b/packages/core/src/RenderingEngine/StackViewport.ts index 700d1e8964..cfaeba013c 100644 --- a/packages/core/src/RenderingEngine/StackViewport.ts +++ b/packages/core/src/RenderingEngine/StackViewport.ts @@ -1688,7 +1688,16 @@ class StackViewport extends Viewport { voiUpdatedWithSetProperties = false, } = options; - const voiLUTSequence = this._getVOILUTSequenceToApply(); + // A colormap fills the transfer function with its own colors. Thus it stops + // the curve of a VOI LUT Sequence and the curve of a sigmoid, which would + // replace those colors with a grey ramp. The generic viewports use the same + // rule (refer to createPlanarRGBTransferFunction). The CPU path is + // different: there the colormap comes after the VOI LUT, so the two combine + // and _syncCPUVOILUTSequence keeps the sequence. + const colormapApplied = !!(this.colormap as ColormapPublic)?.name; + const voiLUTSequence = colormapApplied + ? undefined + : this._getVOILUTSequenceToApply(); const useVOILUTSequence = !!voiLUTSequence; if ( @@ -1729,14 +1738,17 @@ class StackViewport extends Viewport { let transferFunction = imageActor.getProperty().getRGBTransferFunction(0); const isSigmoidTFun = + !colormapApplied && this.VOILUTFunction === VOILUTFunctionType.SAMPLED_SIGMOID; // A VOI LUT Sequence carries its own nonlinear curve, so it must be // rebuilt as a whole (there is no range to slide) - same as the sigmoid. // The function also has to be recreated when we transition between a // sequence and a window, since the two are not interchangeable by range. + // A colormap already replaced the colors of the transfer function, and a + // new one here would remove the colormap. const recreateForVOILUTSequence = - useVOILUTSequence !== this.voiLUTSequenceApplied; + !colormapApplied && useVOILUTSequence !== this.voiLUTSequenceApplied; if ( isSigmoidTFun || @@ -1793,6 +1805,7 @@ class StackViewport extends Viewport { viewportId: this.id, range: voiRangeToUse, VOILUTFunction: this.VOILUTFunction, + voiLUTSequenceApplied: useVOILUTSequence, }; triggerEvent(this.element, Events.VOI_MODIFIED, eventDetail); From a5c7897d9223c9c93b546e2b3e2b3fef13319aa9 Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Fri, 21 Aug 2026 17:43:35 +0200 Subject: [PATCH 08/13] fix(dicomImageLoader): read a LUT Data of 8 bit entries on the wadouri path getLUT read every entry of LUT Data (0028,3006) as a 16 bit word, and it calculated the number of entries as the length of the element divided by 2. LUT Descriptor value 3 gives the width of an entry, and a LUT that declares 8 bits can hold one entry in each byte. Such a LUT thus gave half a table of nonsense, and normalizeVOILUTSequence could not correct it: the wadouri provider gives an item that is already in the shape of the renderers, so the 8 bit branch of the normalizer does not run. --- .../imageLoader/wadouri/metaData/getLUTs.ts | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/getLUTs.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/getLUTs.ts index 39ef14ba6a..39f81683e6 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/getLUTs.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/getLUTs.ts @@ -28,14 +28,19 @@ function getLUT(pixelRepresentation: number, lutDataSet: DataSet): LutType { lut: [], }; - // The descriptor cannot be trusted beyond the data actually received - a - // short LUT Data would otherwise fill the tail of the table with undefined const lutDataElement = lutDataSet.elements.x00283006; + const bytesPerEntry = getBytesPerEntry( + lutDataElement, + numLUTEntries, + numBitsPerEntry + ); + // The descriptor cannot be trusted beyond the data actually received - a + // short LUT Data would otherwise fill the tail of the table with undefined if (lutDataElement) { numLUTEntries = Math.min( numLUTEntries, - Math.floor(lutDataElement.length / 2) + Math.floor(lutDataElement.length / bytesPerEntry) ); } @@ -44,12 +49,35 @@ function getLUT(pixelRepresentation: number, lutDataSet: DataSet): LutType { // entry above 32767 negative, which also broke the "largest entry decides the // bit depth" heuristic the renderers use. for (let i = 0; i < numLUTEntries; i++) { - lut.lut[i] = lutDataSet.uint16('x00283006', i); + if (bytesPerEntry === 1) { + lut.lut[i] = lutDataSet.byteArray[lutDataElement.dataOffset + i]; + } else { + lut.lut[i] = lutDataSet.uint16('x00283006', i); + } } return lut; } +/** + * The width of one entry of LUT Data. An entry is 16 bits (LUT Data is US or + * OW), but a LUT that declares 8 bits for each entry can hold one entry in each + * byte. The length of the element says which of the two the file uses, because + * the number of entries is known. Reading such a LUT as 16 bit words gave half + * a LUT of nonsense. + */ +function getBytesPerEntry( + lutDataElement: Element, + numLUTEntries: number, + numBitsPerEntry: number +): number { + if (numBitsPerEntry === 8 && lutDataElement?.length === numLUTEntries) { + return 1; + } + + return 2; +} + function getLUTs(pixelRepresentation: number, lutSequence: Element): LutType[] { if (!lutSequence || !lutSequence.items || !lutSequence.items.length) { return; From 57d35e9507c686b590d1d4471f20d74113232d3e Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Fri, 21 Aug 2026 17:47:05 +0200 Subject: [PATCH 09/13] refactor(core): remove the second getValidVOILUTFunction buildMetadata held its own getValidVOILUTFunction, which tested for a member of the VOILUTFunctionType enum. That test gives LINEAR for a padded value, a lower case value or a single element array of (0028,1056), which the providers give, and it does not know the key SAMPLED_SIGMOID. The function in voiLUTFunction.ts normalizes all these shapes, and both are exported from the utilities of the package under the same name. --- packages/core/src/types/EventTypes.ts | 7 + packages/core/src/utilities/buildMetadata.ts | 24 +-- .../utilities/viewportVoiIntensityMapping.ts | 21 +++ .../imageLoader/normalizeVOILUTSequence.ts | 142 +++++++++++------- .../migration-guides/5x/1-migration-notes.md | 46 ++++++ .../growCut/getViewportVoiMappingForVolume.ts | 13 ++ 6 files changed, 181 insertions(+), 72 deletions(-) diff --git a/packages/core/src/types/EventTypes.ts b/packages/core/src/types/EventTypes.ts index 89a2765e74..7a598be4d1 100644 --- a/packages/core/src/types/EventTypes.ts +++ b/packages/core/src/types/EventTypes.ts @@ -60,6 +60,13 @@ interface VoiModifiedEventDetail { volumeId?: string; /** VOILUTFunction */ VOILUTFunction?: VOILUTFunctionType; + /** + * True when a VOI LUT Sequence (0028,3010) of the file controls the display. + * The range is then the input domain of the curve, and the VOI LUT Function + * does not apply. A colorbar that draws a ramp from the range and the + * function shows a curve that the viewport does not use. + */ + voiLUTSequenceApplied?: boolean; /** inverted */ invert?: boolean; /** Indicates if the 'invert' state has changed from the previous state */ diff --git a/packages/core/src/utilities/buildMetadata.ts b/packages/core/src/utilities/buildMetadata.ts index 879948e6a2..23905c4a97 100644 --- a/packages/core/src/utilities/buildMetadata.ts +++ b/packages/core/src/utilities/buildMetadata.ts @@ -1,5 +1,6 @@ import * as metaData from '../metaData'; import { MetadataModules, VOILUTFunctionType } from '../enums'; +import { getValidVOILUTFunction } from './voiLUTFunction'; import type IImage from '../types/IImage'; import type { ImagePlaneModule } from '../types'; import type IImageCalibration from '../types/IImageCalibration'; @@ -24,23 +25,12 @@ export interface BuildMetadataResult { }; } -/** - * Gets a valid VOI LUT function from the provided value, defaulting to LINEAR if invalid - * @param voiLUTFunction - The VOI LUT function to validate - * @returns A valid VOI LUT function - */ -export function getValidVOILUTFunction( - voiLUTFunction: VOILUTFunctionType | unknown -): VOILUTFunctionType { - if ( - !Object.values(VOILUTFunctionType).includes( - voiLUTFunction as VOILUTFunctionType - ) - ) { - return VOILUTFunctionType.LINEAR; - } - return voiLUTFunction as VOILUTFunctionType; -} +// This module held a second getValidVOILUTFunction. That one tested for a +// member of the enum, so it made LINEAR from a padded, a lower case or a single +// element array value of (0028,1056), which the providers give. One +// normalization is enough, and voiLUTFunction.ts holds it. The name stays +// available here for the applications that import it from this path. +export { getValidVOILUTFunction }; /** * Creates default values for imagePlaneModule if values are undefined diff --git a/packages/core/src/utilities/viewportVoiIntensityMapping.ts b/packages/core/src/utilities/viewportVoiIntensityMapping.ts index f5651329c8..146e1e4db4 100644 --- a/packages/core/src/utilities/viewportVoiIntensityMapping.ts +++ b/packages/core/src/utilities/viewportVoiIntensityMapping.ts @@ -1,4 +1,10 @@ import VOILUTFunctionType from '../enums/VOILUTFunctionType'; +import type { CPUFallbackLUT } from '../types'; +import { + invertVOILUTSample, + isRenderableVOILUT, + sampleVOILUT, +} from './createVOILUTSequenceTransferFunction'; import { logit } from './logit'; import * as windowLevelUtil from './windowLevel'; @@ -7,6 +13,13 @@ const Y_EPS = 1e-6; export type ViewportVoiMappingProps = { voiRange: { lower: number; upper: number }; VOILUTFunction?: string | VOILUTFunctionType; + /** + * VOI LUT Sequence (0028,3010) of the image, when it controls the display. + * The sequence is the whole VOI transformation, so it replaces the window and + * the VOI LUT Function (PS3.3 C.11.2.1). Without it a tool that works in + * display intensity sees a linear ramp, and the viewport shows the curve. + */ + voiLUT?: CPUFallbackLUT; /** * When true the viewport renders the VOI inverted (e.g. PET AC), so the * displayed intensity is `1 − mapped`. Both the forward and inverse maps must @@ -28,6 +41,10 @@ export function mapScalarToViewportVoiIntensity( const fn = props.VOILUTFunction as string | undefined; const applyInvert = (y: number) => (props.invert === true ? 1 - y : y); + if (isRenderableVOILUT(props.voiLUT)) { + return applyInvert(sampleVOILUT(props.voiLUT, value, props.voiRange)); + } + if (fn === VOILUTFunctionType.SAMPLED_SIGMOID || fn === 'SIGMOID') { const { windowCenter, windowWidth } = windowLevelUtil.toWindowLevel( lower, @@ -58,6 +75,10 @@ export function mapViewportVoiIntensityToScalar( const y = props.invert === true ? clamp01(1 - clamp01(mapped01)) : clamp01(mapped01); + if (isRenderableVOILUT(props.voiLUT)) { + return invertVOILUTSample(props.voiLUT, y, props.voiRange); + } + if (fn === VOILUTFunctionType.SAMPLED_SIGMOID || fn === 'SIGMOID') { const { windowCenter, windowWidth } = windowLevelUtil.toWindowLevel( lower, diff --git a/packages/dicomImageLoader/src/imageLoader/normalizeVOILUTSequence.ts b/packages/dicomImageLoader/src/imageLoader/normalizeVOILUTSequence.ts index 030e10db3b..f7998df4fd 100644 --- a/packages/dicomImageLoader/src/imageLoader/normalizeVOILUTSequence.ts +++ b/packages/dicomImageLoader/src/imageLoader/normalizeVOILUTSequence.ts @@ -12,88 +12,125 @@ type LUTLike = Types.CPUFallbackLUT; * * Rendering only cares about the first form, so everything is converted to it. */ -type RawLUTItem = Record; +type RawLUTItem = { + lut?: unknown; + firstValueMapped?: number; + numBitsPerEntry?: number; + LUTDescriptor?: unknown; + LUTData?: unknown; + '00283002'?: { Value?: unknown }; + '00283006'?: { Value?: unknown; InlineBinary?: unknown }; +}; /** - * Decodes a buffer of LUT Data. Entries are 16 bits (LUT Data is US/OW) unless - * LUT Descriptor declares 8 bits per entry, in which case they are packed one - * per byte and reading them as 16 bit words gives half a LUT of nonsense. + * Copies the elements of a typed array into a plain array. A LUT holds up to + * 65536 entries, and an indexed loop is faster than `Array.from`, which walks + * the iterator protocol. */ -function fromBuffer(buffer: ArrayBufferLike, bitsPerEntry?: number): number[] { +function copyElements(source: ArrayLike): number[] { + const entries = new Array(source.length); + + for (let i = 0; i < entries.length; i++) { + entries[i] = source[i]; + } + + return entries; +} + +/** + * Decodes the bytes of LUT Data. An entry is 16 bits (LUT Data is US or OW) + * unless LUT Descriptor declares 8 bits for each entry, in which case the + * entries are packed one to a byte and reading them as 16 bit words gives half + * a LUT of nonsense. + * + * The pairs are combined by hand rather than through a `Uint16Array`, because + * that needs an even offset into the buffer, which a view of a larger buffer + * does not promise, and because DICOM writes LUT Data little endian whatever + * the endianness of the host is. + */ +function fromBytes(bytes: Uint8Array, bitsPerEntry?: number): number[] { if (bitsPerEntry === 8) { - return Array.from(new Uint8Array(buffer)); + return copyElements(bytes); } - return Array.from( - new Uint16Array(buffer, 0, Math.floor(buffer.byteLength / 2)) - ); + const numEntries = bytes.length >> 1; + const entries = new Array(numEntries); + + for (let i = 0; i < numEntries; i++) { + entries[i] = bytes[2 * i] | (bytes[2 * i + 1] << 8); + } + + return entries; +} + +function toBytes(value: ArrayBuffer | ArrayBufferView): Uint8Array { + if (value instanceof ArrayBuffer) { + return new Uint8Array(value); + } + + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); +} + +function isBinary(value: unknown): boolean { + return value instanceof ArrayBuffer || ArrayBuffer.isView(value); } function toNumberArray( data: unknown, bitsPerEntry?: number ): number[] | undefined { - if (!data) { - return undefined; - } - if (Array.isArray(data)) { // A single element array holding the buffer, as bulkdata sometimes arrives if (data.length === 1 && isBinary(data[0])) { return toNumberArray(data[0], bitsPerEntry); } - return data.every((value) => typeof value === 'number') - ? (data as number[]) - : undefined; - } - - if (ArrayBuffer.isView(data) && !(data instanceof DataView)) { - const view = data as ArrayBufferView & { BYTES_PER_ELEMENT?: number }; - - // A byte view of a 16 bit LUT is a buffer that has not been reinterpreted - // yet, rather than one entry per element - if (view.BYTES_PER_ELEMENT === 1 && bitsPerEntry > 8) { - return fromBuffer( - view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength), - bitsPerEntry - ); + if (data.every((value) => typeof value === 'number')) { + return data as number[]; } - return Array.from(view as unknown as ArrayLike); + return undefined; } if (data instanceof ArrayBuffer) { - return fromBuffer(data, bitsPerEntry); + return fromBytes(new Uint8Array(data), bitsPerEntry); } - return undefined; -} + if (ArrayBuffer.isView(data)) { + const elementSize = (data as { BYTES_PER_ELEMENT?: number }) + .BYTES_PER_ELEMENT; -function isBinary(value: unknown): boolean { - return ( - value instanceof ArrayBuffer || - (ArrayBuffer.isView(value) && !(value instanceof DataView)) - ); + // A byte view of a 16 bit LUT is a buffer that nobody reinterpreted yet, + // rather than one entry for each element. A DataView has no element size at + // all, so it holds bytes as well. + const holdsBytes = !elementSize || (elementSize === 1 && bitsPerEntry > 8); + + if (holdsBytes) { + return fromBytes(toBytes(data), bitsPerEntry); + } + + return copyElements(data as unknown as ArrayLike); + } + + return undefined; } function fromInlineBinary( inlineBinary: unknown, bitsPerEntry?: number ): number[] | undefined { - if (typeof inlineBinary !== 'string' || typeof atob !== 'function') { + if (typeof inlineBinary !== 'string') { return undefined; } const binary = atob(inlineBinary); const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { + for (let i = 0; i < bytes.length; i++) { bytes[i] = binary.charCodeAt(i); } - // DICOM JSON InlineBinary is little endian - return fromBuffer(bytes.buffer, bitsPerEntry); + return fromBytes(bytes, bitsPerEntry); } /** @@ -133,14 +170,13 @@ function normalizeItem(item: RawLUTItem): LUTLike | undefined { if (existing?.length) { return { lut: existing, - firstValueMapped: Number(item.firstValueMapped) || 0, - numBitsPerEntry: Number(item.numBitsPerEntry) || undefined, + firstValueMapped: item.firstValueMapped ?? 0, + numBitsPerEntry: item.numBitsPerEntry, }; } const descriptor = - toNumberArray(item.LUTDescriptor) ?? - toNumberArray((item['00283002'] as RawLUTItem)?.Value); + toNumberArray(item.LUTDescriptor) ?? toNumberArray(item['00283002']?.Value); if (!descriptor || descriptor.length < 3) { return undefined; @@ -151,11 +187,8 @@ function normalizeItem(item: RawLUTItem): LUTLike | undefined { const bitsPerEntry = descriptor[2]; const data = toNumberArray(item.LUTData, bitsPerEntry) ?? - toNumberArray((item['00283006'] as RawLUTItem)?.Value, bitsPerEntry) ?? - fromInlineBinary( - (item['00283006'] as RawLUTItem)?.InlineBinary, - bitsPerEntry - ); + toNumberArray(item['00283006']?.Value, bitsPerEntry) ?? + fromInlineBinary(item['00283006']?.InlineBinary, bitsPerEntry); if (!data?.length) { return undefined; @@ -163,12 +196,11 @@ function normalizeItem(item: RawLUTItem): LUTLike | undefined { // LUT Descriptor value 1 of 0 means 65536 entries (it is a US that cannot // hold 65536), and cannot be trusted beyond the data we actually received. - const declaredEntries = descriptor[0] === 0 ? 65536 : descriptor[0]; - const numEntries = Math.min(declaredEntries, data.length); + const numEntries = Math.min(descriptor[0] || 65536, data.length); return { - lut: data.length === numEntries ? data : data.slice(0, numEntries), - firstValueMapped: descriptor[1] ?? 0, - numBitsPerEntry: descriptor[2], + lut: numEntries === data.length ? data : data.slice(0, numEntries), + firstValueMapped: descriptor[1], + numBitsPerEntry: bitsPerEntry, }; } diff --git a/packages/docs/docs/migration-guides/5x/1-migration-notes.md b/packages/docs/docs/migration-guides/5x/1-migration-notes.md index 6775c5598a..71b6418a56 100644 --- a/packages/docs/docs/migration-guides/5x/1-migration-notes.md +++ b/packages/docs/docs/migration-guides/5x/1-migration-notes.md @@ -280,3 +280,49 @@ metadata. the application does not set a range. - **If your tests compare volume and stack displays**, change the PT values that you calculated from the metadata window to the range 0 to 5. + +## Volume viewports apply the VOI LUT Function and the VOI LUT Sequence + +### What Changed + +A volume viewport now reads the VOI LUT Function (0028,1056) and the VOI LUT +Sequence (0028,3010) of the file, as the stack viewport does: + +- A series that has the function SIGMOID gets a sigmoid transfer function. Before + this, the viewport calculated the range from the function but always made a + linear transfer function. +- A series that has a VOI LUT Sequence gets the curve of the sequence. The range + is the input domain of the curve, and window level stretches the curve over the + new range. +- The generic viewports (`PLANAR_NEXT`) do the same on their volume paths, on the + GPU and on the CPU. +- `setProperties({ VOILUTFunction })` on a volume viewport now normalizes the + value, thus a padded value, a lower case value or a single element array also + works. The property is also applied before the transfer function is made. Before + this, a request for SIGMOID had no effect until the next change of the VOI. + +Two other changes make all the viewport types agree: + +- A colormap stops the curve of a VOI LUT Sequence and the curve of a sigmoid on + the GPU. A colormap fills the transfer function with its own colors. Before + this, a stack viewport removed the colormap on the next window level operation + of an image that has a sequence. +- The CPU path stretches the curve of a sequence over the window. Before this, + window level did nothing on the CPU and worked on the GPU, for the same file. + +### Why This Matters + +CR, DX and MG images frequently carry a VOI LUT Sequence, and their curves are +strongly non linear. The same file thus had two displays: the correct curve on a +stack viewport, and a flat linear window on a volume viewport or an MPR. + +### Migration Guidance + +- **Usually, you do not have to do an operation.** The display of these series + agrees with the stack viewport and with other viewers. +- **To ignore the curve of the file**, use + `setProperties({ useVOILUTSequence: false })`. This property is now available on + the volume viewports and on the generic viewports also. +- **If your application draws a colorbar**, read the new field + `voiLUTSequenceApplied` of the `VOI_MODIFIED` event. A ramp that you calculate + from the range and the function is not the curve that the viewport shows. diff --git a/packages/tools/src/utilities/segmentation/growCut/getViewportVoiMappingForVolume.ts b/packages/tools/src/utilities/segmentation/growCut/getViewportVoiMappingForVolume.ts index 1e2a58b8d6..a4bec8ed9e 100644 --- a/packages/tools/src/utilities/segmentation/growCut/getViewportVoiMappingForVolume.ts +++ b/packages/tools/src/utilities/segmentation/growCut/getViewportVoiMappingForVolume.ts @@ -3,6 +3,12 @@ import type { Types } from '@cornerstonejs/core'; export type ViewportVoiMappingForTool = { voiRange: { lower: number; upper: number }; VOILUTFunction?: string; + /** + * VOI LUT Sequence (0028,3010) of the image on display. The sequence is the + * whole VOI transformation, so the mapping must use its curve and not a + * linear ramp. + */ + voiLUT?: Types.CPUFallbackLUT; /** Viewport invert flag (e.g. PET AC). Needed so display luma inverse-maps to the right raw end. */ invert?: boolean; }; @@ -13,6 +19,7 @@ type ViewportWithProps = Types.IViewport & { VOILUTFunction?: string; invert?: boolean; } | null; + getCornerstoneImage?: () => Types.IImage | undefined; }; /** @@ -36,9 +43,15 @@ export function getViewportVoiMappingForVolume( if (typeof lower !== 'number' || typeof upper !== 'number') { return null; } + // A stack viewport gives the image on display, and the image carries the + // sequence of the file. A volume has one sequence for each instance, so the + // volume paths keep the window of the viewport. + const image = (viewport as ViewportWithProps).getCornerstoneImage?.(); + return { voiRange: { lower, upper }, VOILUTFunction: props.VOILUTFunction, + voiLUT: image?.voiLUT, invert: props.invert === true, }; } From 8af22eeee1a17ff08935c9eb599c89b7163ddc09 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Sat, 15 Aug 2026 10:51:33 -0400 Subject: [PATCH 10/13] fix(voi): keep the VOI LUT Sequence usable when the transfer function is rebuilt Review follow ups on the VOI LUT Sequence support. StackViewport: - A colormap was dropped back to grayscale whenever the transfer function was rebuilt (a forced recreation, or the first one on a stack) while getProperties() went on reporting it. The colormap now takes precedence over the file's VOI transformation inside _createVOITransferFunction, matching createPlanarRGBTransferFunction, so recreating the function is colormap safe rather than only being avoided while one is set. - A VOI LUT Sequence that could not be turned into a curve was still recorded as applied. Since that flag gates both the setVOIGPU early return and _resetProperties, window level went permanently inert. It now falls back to the analytic window, as the CPU path does, and voiLUTSequenceApplied - both the field and the VOI_MODIFIED detail - reflects what reached the actor. - _getInitialVOIRange checked the sequence before the prescaled PT override, the opposite order from getDefaultImageVOIRange, so a prescaled PT carrying a sequence got a stored value range and rendered black. - getProperties() and the VOI_MODIFIED detail could report VOILUTFunction as undefined, which both declare non-optional. The field stays optional so the per image fallback in setVOICPU still applies; the public surface resolves it. wadouri metadata provider: - The VOI dataset was chosen from the window tags alone and then used for the VOI LUT Sequence and VOI LUT Function too, dropping whichever attributes lived in the other dataset. Each attribute is now resolved independently, and a present but zero length window falls through to the Frame VOI LUT macro instead of ending the search. Co-Authored-By: Claude Opus 5 (1M context) --- .../core/src/RenderingEngine/StackViewport.ts | 115 +++++++++++++++--- .../wadouri/metaData/metaDataProvider.ts | 68 ++++++++--- 2 files changed, 150 insertions(+), 33 deletions(-) diff --git a/packages/core/src/RenderingEngine/StackViewport.ts b/packages/core/src/RenderingEngine/StackViewport.ts index cfaeba013c..ad8707a082 100644 --- a/packages/core/src/RenderingEngine/StackViewport.ts +++ b/packages/core/src/RenderingEngine/StackViewport.ts @@ -187,7 +187,11 @@ class StackViewport extends Viewport { private voiUpdatedWithSetProperties = false; private sharpening: number = 0; private smoothing: number = 0; - private VOILUTFunction: VOILUTFunctionType; + // Left undefined until one is resolved for the displayed image, so the per + // image fallback in setVOICPU still applies. The public surface never sees + // that gap - getProperties and VOI_MODIFIED resolve it through + // _getEffectiveVOILUTFunction. + private VOILUTFunction: VOILUTFunctionType | undefined; // Whether the transfer function currently on the actor was built from the // image's VOI LUT Sequence rather than from a window width/center private voiLUTSequenceApplied = false; @@ -879,7 +883,6 @@ class StackViewport extends Viewport { const { colormap, voiRange, - VOILUTFunction, interpolationType, invert, useVOILUTSequence, @@ -889,7 +892,7 @@ class StackViewport extends Viewport { return { colormap, voiRange, - VOILUTFunction, + VOILUTFunction: this._getEffectiveVOILUTFunction(), useVOILUTSequence, interpolationType, invert, @@ -1668,6 +1671,19 @@ class StackViewport extends Viewport { voiRange: VOIRange, voiLUTSequence?: CPUFallbackLUT ): vtkColorTransferFunction | undefined { + // A colormap is an explicit display choice made by the application, so it + // outranks the file's own VOI transformation - the same order + // createPlanarRGBTransferFunction uses. Without this, rebuilding the + // function (a window level move on a VOI LUT Sequence or a sampled sigmoid) + // dropped the colormap back to grayscale while getProperties() went on + // reporting it. + const colormapTransferFunction = + this._createColormapTransferFunction(voiRange); + + if (colormapTransferFunction) { + return colormapTransferFunction; + } + if (voiLUTSequence) { return createVOILUTSequenceTransferFunction(voiLUTSequence, { voiRange }); } @@ -1681,6 +1697,35 @@ class StackViewport extends Viewport { ) as vtkColorTransferFunction; } + /** + * The current colormap as a transfer function over `voiRange`, or undefined + * when no colormap is set - or when its name is not one we can resolve, in + * which case the caller falls back to the grayscale paths rather than + * leaving the image blank. + */ + private _createColormapTransferFunction( + voiRange: VOIRange + ): vtkColorTransferFunction | undefined { + const colormapName = (this.colormap as ColormapPublic)?.name; + + if (!colormapName) { + return undefined; + } + + const colormapObj = colormapUtils.resolveColormap(colormapName); + + if (!colormapObj) { + return undefined; + } + + const cfun = vtkColorTransferFunction.newInstance(); + + cfun.applyColorMap(colormapObj); + cfun.setMappingRange(voiRange.lower, voiRange.upper); + + return cfun; + } + private setVOIGPU(voiRange: VOIRange, options: SetVOIOptions = {}): void { const { suppressEvents = false, @@ -1750,6 +1795,10 @@ class StackViewport extends Viewport { const recreateForVOILUTSequence = !colormapApplied && useVOILUTSequence !== this.voiLUTSequenceApplied; + // Tracks what actually ended up on the actor, which is not always what was + // asked for - see the fallback below. + let appliedVOILUTSequence = useVOILUTSequence; + if ( isSigmoidTFun || useVOILUTSequence || @@ -1757,13 +1806,22 @@ class StackViewport extends Viewport { !transferFunction || forceRecreateLUTFunction ) { - const nextTransferFunction = this._createVOITransferFunction( + let nextTransferFunction = this._createVOITransferFunction( voiRangeToUse, voiLUTSequence ); - // _createVOITransferFunction returns undefined for a VOI LUT Sequence it - // cannot use; keep the previous function rather than blanking the image + // A VOI LUT Sequence that cannot be turned into a curve (all zero LUT + // data, say) must not be recorded as applied: voiLUTSequenceApplied gates + // the early return above and _resetProperties, so marking it applied here + // left window level permanently inert on that viewport. Fall back to the + // analytic window instead, which is also what the CPU path does. + if (!nextTransferFunction && useVOILUTSequence) { + appliedVOILUTSequence = false; + nextTransferFunction = this._createVOITransferFunction(voiRangeToUse); + } + + // Keep the previous function rather than blanking the image if (nextTransferFunction) { transferFunction = nextTransferFunction as vtkColorTransferFunction; @@ -1777,19 +1835,19 @@ class StackViewport extends Viewport { // Sequence curve from the LUT itself rather than replaying nodes - so // reading back a thousand nodes here on every window level move would be // pure overhead. - if (!useVOILUTSequence) { + if (!appliedVOILUTSequence) { this.initialTransferFunctionNodes = getTransferFunctionNodes(transferFunction); } } } - if (!isSigmoidTFun && !useVOILUTSequence) { + if (!isSigmoidTFun && !appliedVOILUTSequence && transferFunction) { // @ts-ignore vtk type error transferFunction.setRange(voiRangeToUse.lower, voiRangeToUse.upper); } - this.voiLUTSequenceApplied = useVOILUTSequence; + this.voiLUTSequenceApplied = appliedVOILUTSequence; this.voiRange = voiRangeToUse; // if voiRange is set by setProperties we need to lock it if it is not locked already @@ -1804,8 +1862,8 @@ class StackViewport extends Viewport { const eventDetail: VoiModifiedEventDetail = { viewportId: this.id, range: voiRangeToUse, - VOILUTFunction: this.VOILUTFunction, - voiLUTSequenceApplied: useVOILUTSequence, + VOILUTFunction: this._getEffectiveVOILUTFunction(), + voiLUTSequenceApplied: appliedVOILUTSequence, }; triggerEvent(this.element, Events.VOI_MODIFIED, eventDetail); @@ -2937,6 +2995,18 @@ class StackViewport extends Viewport { return this.voiRange; } + // A prescaled PT is displayed in SUV, so the file's own VOI is in the wrong + // domain whichever form it takes - window and sequence alike are defined + // against modality LUT output. Checked first for that reason, and because + // getDefaultImageVOIRange makes the same call in this order; the other way + // round a prescaled PT that also carries a sequence got a stored value + // range and rendered black. + const ptPrescaledRange = this._getPTPreScaledRange(); + + if (ptPrescaledRange) { + return ptPrescaledRange; + } + // When the VOI LUT Sequence drives the display, its own input domain is the // range - the curve is defined against modality LUT output, not relative to // a window, so starting from the file's Window Center/Width (which DICOM @@ -2950,17 +3020,11 @@ class StackViewport extends Viewport { const { windowCenter, windowWidth, voiLUTFunction } = image; - let voiRange = getVOIRangeFromWindowLevel( + return getVOIRangeFromWindowLevel( windowWidth, windowCenter, voiLUTFunction ); - - // Get the range for the PT since if it is prescaled - // we set a default range of 0-5 - voiRange = this._getPTPreScaledRange() || voiRange; - - return voiRange; } private _getPTPreScaledRange() { @@ -3541,6 +3605,21 @@ class StackViewport extends Viewport { return getValidVOILUTFunction(voiLUTFunction); } + /** + * The VOI LUT Function in effect, for consumers outside the viewport. + * + * `this.VOILUTFunction` is deliberately left undefined until one is resolved + * for the displayed image, but `getProperties()` and the VOI_MODIFIED detail + * are part of the public surface and are declared non-optional, so the + * image's own function - or LINEAR, the DICOM default - stands in for the gap. + */ + private _getEffectiveVOILUTFunction(): VOILUTFunctionType { + return ( + this.VOILUTFunction ?? + getValidVOILUTFunction(this.csImage?.voiLUTFunction) + ); + } + /** * Returns the index of the imageId being renderer * diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts index 0dd398b6cd..4958f075db 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts @@ -66,17 +66,43 @@ function metaDataProvider(type, imageId) { } /** - * The dataset the VOI attributes live in: the Frame VOI LUT Sequence - * (0028,9132) item when the root carries no window of its own, otherwise the - * dataset itself. Root tags win, so a legacy window on an enhanced object still - * takes precedence over the macro. + * The datasets a VOI attribute may live in, in precedence order: the dataset + * itself, then the Frame VOI LUT Sequence (0028,9132) item. Root tags win, so a + * legacy window on an enhanced object still takes precedence over the macro. */ -function resolveVOIDataSet(dataSet: dicomParser.DataSet): dicomParser.DataSet { - if (dataSet.elements.x00281050 || dataSet.elements.x00281051) { - return dataSet; +function getVOIDataSets(dataSet: dicomParser.DataSet): dicomParser.DataSet[] { + const frameVOIDataSet = dataSet.elements.x00289132?.items?.[0]?.dataSet; + + return frameVOIDataSet ? [dataSet, frameVOIDataSet] : [dataSet]; +} + +/** + * The first value `read` yields from any of `dataSets`. + * + * Each VOI attribute is resolved on its own rather than picking one dataset for + * all of them: an enhanced object may carry the window in the Frame VOI LUT + * macro and the VOI LUT Sequence at the root, or the reverse, and choosing a + * single dataset from the window tags alone silently dropped whichever + * attributes lived in the other one. + * + * Presence is decided by reading the attribute the same way the caller + * extracts it, so an attribute that is present but empty - DICOM allows a type + * 2 window to be sent zero length, and `getNumberValues` yields nothing for one + * - falls through to the next dataset instead of ending the search. + */ +function findVOIValue( + dataSets: dicomParser.DataSet[], + read: (dataSet: dicomParser.DataSet) => T | undefined +): T | undefined { + for (const dataSet of dataSets) { + const value = read(dataSet); + + if (value !== undefined) { + return value; + } } - return dataSet.elements.x00289132?.items?.[0]?.dataSet ?? dataSet; + return undefined; } export function metadataForDataset( @@ -233,16 +259,28 @@ export function metadataForDataset( // deeper than the tags read below and the viewport fell back to the image // min/max (cornerstone3D#2745). wadors reads the same file correctly, hence // the same image looking different through the two loaders. - const voiDataSet = resolveVOIDataSet(dataSet); + const voiDataSets = getVOIDataSets(dataSet); + // Center and width are resolved as a pair - a window is only meaningful as + // both - while the sequence and the function are looked up on their own. + const windowDataSet = + voiDataSets.find( + (candidate) => + getNumberValues(candidate, 'x00281050', 1) !== undefined && + getNumberValues(candidate, 'x00281051', 1) !== undefined + ) ?? dataSet; return { - windowCenter: getNumberValues(voiDataSet, 'x00281050', 1), - windowWidth: getNumberValues(voiDataSet, 'x00281051', 1), - voiLUTSequence: getLUTs( - modalityLUTOutputPixelRepresentation, - voiDataSet.elements.x00283010 + windowCenter: getNumberValues(windowDataSet, 'x00281050', 1), + windowWidth: getNumberValues(windowDataSet, 'x00281051', 1), + voiLUTSequence: findVOIValue(voiDataSets, (candidate) => + getLUTs( + modalityLUTOutputPixelRepresentation, + candidate.elements.x00283010 + ) + ), + voiLUTFunction: findVOIValue(voiDataSets, (candidate) => + candidate.string('x00281056') ), - voiLUTFunction: voiDataSet.string('x00281056'), }; } From 0a96707c5c72863e5a305de39bfc0579a9b1aeda Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Sat, 15 Aug 2026 21:16:49 -0400 Subject: [PATCH 11/13] test(voi): cover independent resolution of the VOI attributes Both fail against the previous single-dataset resolution: - a VOI LUT Sequence at the root was dropped when the window came from the Frame VOI LUT macro, because the dataset was chosen from the window tags and then used for the sequence too - a present but zero length Window Center counted as a window and defeated the macro fallback, leaving the image with no window at all Co-Authored-By: Claude Opus 5 (1M context) --- .../src/__tests__/wadouriDataSetLayer.spec.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/packages/dicomImageLoader/src/__tests__/wadouriDataSetLayer.spec.ts b/packages/dicomImageLoader/src/__tests__/wadouriDataSetLayer.spec.ts index b48ae74324..f7866aab50 100644 --- a/packages/dicomImageLoader/src/__tests__/wadouriDataSetLayer.spec.ts +++ b/packages/dicomImageLoader/src/__tests__/wadouriDataSetLayer.spec.ts @@ -491,6 +491,87 @@ describe('wadouri dataSet-layer', () => { expect(result.windowWidth).toEqual([400]); }); + it('keeps a root VOI LUT Sequence when the window comes from the Frame VOI LUT macro', () => { + // Each VOI attribute is resolved on its own: picking one dataset from + // the window tags alone dropped a sequence that lived in the other one. + const voiLutItemDataSet = fakeDataSet( + {}, + { + x00283002: { length: 6 }, + x00283006: { length: 4 }, + } + ); + (voiLutItemDataSet as any).uint16 = (t: string, i = 0) => { + if (t === 'x00283002') { + return [10, 0, 16][i]; + } + if (t === 'x00283006') { + return [100, 200][i]; + } + return undefined; + }; + + const frameVOIDataSet = fakeDataSet({ + x00281050: '-600', + x00281051: '1500', + }); + + const dataSet = fakeDataSet( + { + x00280103: 0, + }, + { + x00283010: { items: [{ dataSet: voiLutItemDataSet }] }, + x00289132: { items: [{ dataSet: frameVOIDataSet }] }, + } + ); + + const result = metadataForDataset( + 'voiLutModule', + 'imageId', + dataSet as any + ); + + expect(result.windowCenter).toEqual([-600]); + expect(result.windowWidth).toEqual([1500]); + expect(result.voiLUTSequence).toEqual([ + { + id: '1', + firstValueMapped: 0, + numBitsPerEntry: 16, + lut: [100, 200], + }, + ]); + }); + + it('falls through a present but empty root window to the Frame VOI LUT macro', () => { + // Window Center is type 2, so it may be sent zero length. Treating it + // as present defeated the macro fallback and left the image with no + // window at all. + const frameVOIDataSet = fakeDataSet({ + x00281050: '-600', + x00281051: '1500', + }); + + const dataSet = fakeDataSet( + {}, + { + x00281050: { length: 0 }, + x00281051: { length: 0 }, + x00289132: { items: [{ dataSet: frameVOIDataSet }] }, + } + ); + + const result = metadataForDataset( + 'voiLutModule', + 'imageId', + dataSet as any + ); + + expect(result.windowCenter).toEqual([-600]); + expect(result.windowWidth).toEqual([1500]); + }); + it('returns undefined voiLUTSequence when no sequence element is present', () => { const dataSet = fakeDataSet({ x00281050: '40\\50', From 9ccba0ca6af1c061e6c910729a93d3b151d78e24 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Mon, 17 Aug 2026 08:31:02 -0400 Subject: [PATCH 12/13] fix(voi): keep the per frame VOI LUT Function fallback on stack navigation _setPropertiesFromCache re-asserts the viewport's own properties on every frame navigation, and read VOILUTFunction back through getProperties(). Now that getProperties() resolves an unset function rather than reporting it undefined, that guard stopped skipping: after resetProperties() on an image with no VOI LUT Function (0028,1056), the field was pinned to the current frame's function and the next frame rendered with the previous frame's. Visible on the CPU path only - setVOICPU's fallback is where the per image value was resolved, while on GPU an unset function and LINEAR take the same branch - but the re-assertion has no business resolving anything, so it reads the raw field and leaves the fallbacks to the render paths. Co-Authored-By: Claude Opus 5 (1M context) --- .../core/src/RenderingEngine/StackViewport.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/core/src/RenderingEngine/StackViewport.ts b/packages/core/src/RenderingEngine/StackViewport.ts index ad8707a082..ccad7e9baa 100644 --- a/packages/core/src/RenderingEngine/StackViewport.ts +++ b/packages/core/src/RenderingEngine/StackViewport.ts @@ -1051,17 +1051,16 @@ class StackViewport extends Viewport { private _setPropertiesFromCache(): void { const voiRange = this._getVOIFromCache(); - const { - colormap, - VOILUTFunction, - interpolationType, - invert, - sharpening, - smoothing, - } = this.getProperties(); - - if (typeof VOILUTFunction !== 'undefined') { - this.setVOILUTFunction(VOILUTFunction, true); + const { colormap, interpolationType, invert, sharpening, smoothing } = + this.getProperties(); + + // The raw field, not getProperties()' resolved value: this re-asserts what + // the viewport already had, so an unset function must stay unset and leave + // the per image fallbacks to resolve it. Feeding the resolved value back + // here would pin it to the current image's function, and the next frame + // would then be rendered with the previous frame's. + if (typeof this.VOILUTFunction !== 'undefined') { + this.setVOILUTFunction(this.VOILUTFunction, true); } this.setVOI(voiRange); From 8310756aa56c067e03224a9d3b83273b08d423b0 Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Wed, 26 Aug 2026 00:50:21 +0200 Subject: [PATCH 13/13] fix(voi): prevent stale LUT state across viewport transitions --- .../src/RenderingEngine/BaseVolumeViewport.ts | 6 + .../Planar/CpuImageSliceRenderPath.ts | 20 +++- .../Planar/PlanarCPUVolumeSampler.ts | 42 +++++-- .../Planar/planarLegacyCompatibility.ts | 14 ++- .../core/src/RenderingEngine/StackViewport.ts | 13 +- packages/core/src/types/ViewportProperties.ts | 2 + ...lanarLegacyCompatibilityController.jest.js | 21 ++++ .../core/test/planarComputedCamera.jest.js | 54 +++++++++ .../test/planarCpuVolumeRenderPath.jest.js | 83 ++++++++++++- .../test/stackViewport_gpu_render_test.js | 113 ++++++++++++++++++ 10 files changed, 347 insertions(+), 21 deletions(-) diff --git a/packages/core/src/RenderingEngine/BaseVolumeViewport.ts b/packages/core/src/RenderingEngine/BaseVolumeViewport.ts index c77485e725..d502fae66f 100644 --- a/packages/core/src/RenderingEngine/BaseVolumeViewport.ts +++ b/packages/core/src/RenderingEngine/BaseVolumeViewport.ts @@ -1367,6 +1367,7 @@ abstract class BaseVolumeViewport extends Viewport { */ public resetToDefaultProperties(volumeId: string): void { const properties = this.globalDefaultProperties; + const currentVOIRange = this.getProperties(volumeId)?.voiRange; this.voiLUTFunctionSetByUser = false; this.useVOILUTSequence = properties.useVOILUTSequence; @@ -1385,6 +1386,10 @@ abstract class BaseVolumeViewport extends Viewport { if (properties.VOILUTFunction !== undefined) { this.setVOILUTFunction(properties.VOILUTFunction, volumeId); + } else if (properties.voiRange === undefined && currentVOIRange) { + // The cleared flags can change the required transfer function even when + // no saved default has a VOI value. + this.setVOI(currentVOIRange, volumeId); } if (properties.invert !== undefined) { @@ -1536,6 +1541,7 @@ abstract class BaseVolumeViewport extends Viewport { colormap: colormap, voiRange: voiRange, VOILUTFunction: VOILUTFunction, + voiLUTFunctionSetByUser: this.voiLUTFunctionSetByUser, useVOILUTSequence: this.useVOILUTSequence, interpolationType: interpolationType, invert: invert, diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath.ts index 95d5a1bbad..8b8a60ccee 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath.ts @@ -5,7 +5,10 @@ import calculateTransform from '../../helpers/cpuFallback/rendering/calculateTra import canvasToPixel from '../../helpers/cpuFallback/rendering/canvasToPixel'; import correctShift from '../../helpers/cpuFallback/rendering/correctShift'; import getDefaultViewport from '../../helpers/cpuFallback/rendering/getDefaultViewport'; -import { getDefaultImageVOIRange } from '../../helpers/planarImageRendering'; +import { + getDefaultImageVOIRange, + resolveVOILUTSequenceToApply, +} from '../../helpers/planarImageRendering'; import resizeEnabledElement from '../../helpers/cpuFallback/rendering/resize'; import drawImageSync from '../../helpers/cpuFallback/drawImageSync'; import { resolveCPUFallbackColormap } from '../../helpers/cpuFallback/colors'; @@ -15,6 +18,7 @@ import { ViewportType, Events, ViewportStatus, + VOILUTFunctionType, } from '../../../enums'; import { loadAndCacheImage } from '../../../loaders/imageLoader'; import * as metaData from '../../../metaData'; @@ -413,12 +417,18 @@ function applyDataPresentation( enabledElement.image?.colormap ); viewport.invert = props?.invert ?? false; + const voiLUTFunction = + props?.voiLUTFunction ?? + enabledElement.image?.voiLUTFunction ?? + VOILUTFunctionType.LINEAR; + + viewport.voiLUT = resolveVOILUTSequenceToApply({ + defaultVOILUT: enabledElement.image?.voiLUT, + defaultVOILUTFunction: enabledElement.image?.voiLUTFunction, + props, + }); if (voiRange) { - // The range was produced from the window with the image's VOI LUT Function - // (getDefaultImageVOIRange), and the CPU render path windows it with that - // same function, so converting back with any other one shifts the window - const voiLUTFunction = enabledElement.image?.voiLUTFunction; const { windowCenter, windowWidth } = toWindowLevel( voiRange.lower, voiRange.upper, diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarCPUVolumeSampler.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarCPUVolumeSampler.ts index 380c0630c5..ec1f6231db 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarCPUVolumeSampler.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarCPUVolumeSampler.ts @@ -15,7 +15,10 @@ import type { } from '../../../types'; import VoxelManager from '../../../utilities/VoxelManager'; import { resolveVOILUTSequenceToApply } from '../../helpers/planarImageRendering'; -import { getVolumeVOIShape } from '../../helpers/setDefaultVolumeVOI'; +import { + getVolumeVOIShape, + type VolumeVOIShape, +} from '../../helpers/setDefaultVolumeVOI'; import getDefaultViewport from '../../helpers/cpuFallback/rendering/getDefaultViewport'; import getSpacingInNormalDirection from '../../../utilities/getSpacingInNormalDirection'; import type { PlanarDataPresentation } from './PlanarViewportTypes'; @@ -27,6 +30,7 @@ import { getSpatiallyClampedContinuousIndex, SOURCE_SLICE_INDEX_TOLERANCE, } from './planarCPUVolumeSamplingUtils'; +import { toWindowLevel } from '../../../utilities/windowLevel'; type SliceArray = PixelDataTypedArray; type SliceArrayConstructor = new (length: number) => SliceArray; @@ -171,6 +175,7 @@ function worldVectorToContinuousIndexDelta( export default class PlanarCPUVolumeSampler { private sampleSequence = 0; + private volumeVOIShapes = new WeakMap(); private scalarViewportSampler = new PlanarCPUScalarViewportSampler(); private scalarRangeCache = new WeakMap< NonNullable, @@ -407,14 +412,17 @@ export default class PlanarCPUVolumeSampler { // The sampled slice carries the VOI LUT Function and the VOI LUT Sequence // of the volume (refer to createSliceImage). The CPU renderer reads them // from the viewport, as on the stack. - viewport.voi = { - windowCenter: (resolvedVOI.lower + resolvedVOI.upper) / 2, - windowWidth: Math.max(resolvedVOI.upper - resolvedVOI.lower, 1), - voiLUTFunction: - dataPresentation?.voiLUTFunction ?? - sampledSliceState.image.voiLUTFunction ?? - VOILUTFunctionType.LINEAR, - }; + const voiLUTFunction = + dataPresentation?.voiLUTFunction ?? + sampledSliceState.image.voiLUTFunction ?? + VOILUTFunctionType.LINEAR; + const { windowCenter, windowWidth } = toWindowLevel( + resolvedVOI.lower, + resolvedVOI.upper, + voiLUTFunction + ); + + viewport.voi = { windowCenter, windowWidth, voiLUTFunction }; viewport.voiLUT = resolveVOILUTSequenceToApply({ defaultVOILUT: sampledSliceState.image.voiLUT, defaultVOILUTFunction: sampledSliceState.image.voiLUTFunction, @@ -1202,13 +1210,23 @@ export default class PlanarCPUVolumeSampler { voiRange && voiRange.upper > voiRange.lower ? voiRange : { lower: minPixelValue, upper: maxPixelValue }; - const windowWidth = Math.max(1, resolvedVOI.upper - resolvedVOI.lower); - const windowCenter = (resolvedVOI.lower + resolvedVOI.upper) / 2; const imageId = `cpuVolumeSlice:${volume.volumeId}:${++this.sampleSequence}`; // The slice is a synthetic image, but the VOI transformation is a property // of the file. Thus the slice keeps the VOI LUT Function and the VOI LUT // Sequence of the volume. - const { voiLUT, voiLUTFunction } = getVolumeVOIShape(volume); + let volumeVOIShape = this.volumeVOIShapes.get(volume); + + if (!volumeVOIShape) { + volumeVOIShape = getVolumeVOIShape(volume); + this.volumeVOIShapes.set(volume, volumeVOIShape); + } + + const { voiLUT, voiLUTFunction } = volumeVOIShape; + const { windowCenter, windowWidth } = toWindowLevel( + resolvedVOI.lower, + resolvedVOI.upper, + voiLUTFunction + ); const voxelManager = VoxelManager.createImageVoxelManager({ width, height, diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/planarLegacyCompatibility.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/planarLegacyCompatibility.ts index 52138ce461..d0f757e00c 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/planarLegacyCompatibility.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/planarLegacyCompatibility.ts @@ -189,6 +189,15 @@ export function mergePlanarLegacyProperties( Object.assign(merged, next); + if ( + next.VOILUTFunction !== undefined && + next.voiLUTFunctionSetByUser === undefined + ) { + // A VOILUTFunction passed to setProperties is an application choice. + // getProperties returns false when the value came from image metadata. + merged.voiLUTFunctionSetByUser = true; + } + if (next.colormap) { merged.colormap = mergePlanarColormap(current.colormap, next.colormap); } @@ -212,7 +221,10 @@ export function toPlanarDataPresentation( // The legacy name of the VOI LUT Function (0028,1056) is VOILUTFunction. The // presentation had no path for it, so a request for SIGMOID or for // LINEAR_EXACT on a compatibility viewport did nothing. - if (properties.VOILUTFunction !== undefined) { + if ( + properties.VOILUTFunction !== undefined && + properties.voiLUTFunctionSetByUser !== false + ) { presentation.voiLUTFunction = properties.VOILUTFunction; } diff --git a/packages/core/src/RenderingEngine/StackViewport.ts b/packages/core/src/RenderingEngine/StackViewport.ts index ccad7e9baa..64e0c89e59 100644 --- a/packages/core/src/RenderingEngine/StackViewport.ts +++ b/packages/core/src/RenderingEngine/StackViewport.ts @@ -195,6 +195,10 @@ class StackViewport extends Viewport { // Whether the transfer function currently on the actor was built from the // image's VOI LUT Sequence rather than from a window width/center private voiLUTSequenceApplied = false; + // The VOI LUT Sequence used to build the current transfer function. Two + // frames can have the same input range but different LUT data. The range and + // voiLUTSequenceApplied flag alone cannot show that the function is current. + private processedVOILUTSequence: CPUFallbackLUT | undefined; // True when the application asked for a VOI LUT Function that is different // from the function of the image. Then the image cannot use its VOI LUT // Sequence. See _getVOILUTSequenceToApply. @@ -766,7 +770,7 @@ class StackViewport extends Viewport { @param properties - An object containing the properties to be set. @param properties.colormap - Specifies the colormap for the viewport. @param properties.voiRange - Defines the lower and upper Value of Interest (VOI) to be applied. - @param properties.VOILUTFunction - Function to handle the application of a lookup table (LUT) to the VOI. + @param properties.VOILUTFunction - Function used to apply the lookup table to the VOI. Each image provides this setting again during navigation. An application request does not remain active across frames. @param properties.useVOILUTSequence - If false, ignore the VOI LUT Sequence of the image and use the VOI LUT Function. @param properties.invert - A boolean value to toggle color inversion (true: inverted, false: not inverted). @param properties.interpolationType - Determines the interpolation method to be used (1: linear, 0: nearest-neighbor). @@ -893,6 +897,7 @@ class StackViewport extends Viewport { colormap, voiRange, VOILUTFunction: this._getEffectiveVOILUTFunction(), + voiLUTFunctionSetByUser: this.voiLUTFunctionSetByUser, useVOILUTSequence, interpolationType, invert, @@ -1749,7 +1754,7 @@ class StackViewport extends Viewport { this.voiRange && this.voiRange.lower === voiRange.lower && this.voiRange.upper === voiRange.upper && - useVOILUTSequence === this.voiLUTSequenceApplied && + voiLUTSequence === this.processedVOILUTSequence && !forceRecreateLUTFunction && !this.stackInvalidated ) { @@ -1847,6 +1852,7 @@ class StackViewport extends Viewport { } this.voiLUTSequenceApplied = appliedVOILUTSequence; + this.processedVOILUTSequence = voiLUTSequence; this.voiRange = voiRangeToUse; // if voiRange is set by setProperties we need to lock it if it is not locked already @@ -2191,6 +2197,9 @@ class StackViewport extends Viewport { this.flipVertical = false; this.flipHorizontal = false; this.voiRange = null; + this.useVOILUTSequence = undefined; + this.voiLUTSequenceApplied = false; + this.processedVOILUTSequence = undefined; this.interpolationType = InterpolationType.LINEAR; this.invert = false; this.viewportStatus = ViewportStatus.LOADING; diff --git a/packages/core/src/types/ViewportProperties.ts b/packages/core/src/types/ViewportProperties.ts index 7ecf09c264..34c3ce1d39 100644 --- a/packages/core/src/types/ViewportProperties.ts +++ b/packages/core/src/types/ViewportProperties.ts @@ -10,6 +10,8 @@ export interface ViewportProperties { voiRange?: VOIRange; /** VOILUTFunction type which is LINEAR, LINEAR_EXACT or SAMPLED_SIGMOID */ VOILUTFunction?: VOILUTFunctionType; + /** True when the application selected VOILUTFunction. */ + voiLUTFunctionSetByUser?: boolean; /** * The use of the VOI LUT Sequence (0028,3010) of the image. If this property * is undefined, the viewport uses the sequence when the image has one. If it diff --git a/packages/core/test/PlanarLegacyCompatibilityController.jest.js b/packages/core/test/PlanarLegacyCompatibilityController.jest.js index 97d4b8e0c7..56ae85c1a6 100644 --- a/packages/core/test/PlanarLegacyCompatibilityController.jest.js +++ b/packages/core/test/PlanarLegacyCompatibilityController.jest.js @@ -206,6 +206,27 @@ describe('PlanarLegacyCompatibilityController', () => { ); }); + it('does not pin a resolved image VOI LUT function into the presentation', () => { + const { host } = createHost({ + getActiveDataId: jest.fn(() => 'data-1'), + }); + const controller = new PlanarLegacyCompatibilityController(host); + + controller.setProperties({ + VOILUTFunction: 'SIGMOID', + voiLUTFunctionSetByUser: false, + }); + + expect(host.setDataPresentationState).toHaveBeenCalledWith('data-1', {}); + + controller.setProperties({ + VOILUTFunction: 'SIGMOID', + }); + expect(host.setDataPresentationState).toHaveBeenLastCalledWith('data-1', { + voiLUTFunction: 'SIGMOID', + }); + }); + it('does nothing when no target data id can be resolved', () => { const { host } = createHost({ getActiveDataId: jest.fn(() => undefined), diff --git a/packages/core/test/planarComputedCamera.jest.js b/packages/core/test/planarComputedCamera.jest.js index 2544c1adc4..a248af2ee1 100644 --- a/packages/core/test/planarComputedCamera.jest.js +++ b/packages/core/test/planarComputedCamera.jest.js @@ -635,4 +635,58 @@ describe('Planar CPU image render path', () => { voiLUTFunction: VOILUTFunctionType.LINEAR, }); }); + + it('applies the requested VOI LUT function and sequence choice', async () => { + const canvas = document.createElement('canvas'); + const image = createImage('voi-image'); + const voiLUT = { + firstValueMapped: 0, + numBitsPerEntry: 8, + lut: [0, 64, 255], + }; + + image.voiLUT = voiLUT; + Object.defineProperties(canvas, { + clientHeight: { configurable: true, value: 64 }, + clientWidth: { configurable: true, value: 64 }, + }); + + const renderPath = new CpuImageSliceRenderPath(); + const attachment = await renderPath.addData( + { + viewportId: 'viewport', + renderingEngineId: 'rendering-engine', + viewport: { element: document.createElement('div') }, + display: { activateRenderMode: jest.fn() }, + cpu: { canvas }, + }, + { + id: 'voi-stack', + type: 'image', + image, + imageIds: [image.imageId], + initialImageIdIndex: 0, + }, + {} + ); + + attachment.updateDataPresentation({ + voiRange: { lower: 0, upper: 100 }, + voiLUTFunction: VOILUTFunctionType.SAMPLED_SIGMOID, + useVOILUTSequence: false, + }); + + expect(attachment.rendering.enabledElement.viewport.voi).toEqual({ + windowCenter: 50.5, + windowWidth: 101, + voiLUTFunction: VOILUTFunctionType.SAMPLED_SIGMOID, + }); + expect(attachment.rendering.enabledElement.viewport.voiLUT).toBeUndefined(); + + attachment.updateDataPresentation({ + voiRange: { lower: 0, upper: 100 }, + useVOILUTSequence: true, + }); + expect(attachment.rendering.enabledElement.viewport.voiLUT).toBe(voiLUT); + }); }); diff --git a/packages/core/test/planarCpuVolumeRenderPath.jest.js b/packages/core/test/planarCpuVolumeRenderPath.jest.js index 10faa8919d..3de2e8c4f5 100644 --- a/packages/core/test/planarCpuVolumeRenderPath.jest.js +++ b/packages/core/test/planarCpuVolumeRenderPath.jest.js @@ -3,12 +3,13 @@ jest.mock('../src/RenderingEngine/helpers/cpuFallback/drawImageSync', () => ({ default: jest.fn(), })); -import { Events, InterpolationType } from '../src/enums'; +import { Events, InterpolationType, VOILUTFunctionType } from '../src/enums'; import { ActorRenderMode } from '../src/types'; import drawImageSync from '../src/RenderingEngine/helpers/cpuFallback/drawImageSync'; import { CpuVolumeSliceRenderPath } from '../src/RenderingEngine/GenericViewport/Planar/CpuVolumeSliceRenderPath'; import PlanarCPUVolumeSampler from '../src/RenderingEngine/GenericViewport/Planar/PlanarCPUVolumeSampler'; import eventTarget from '../src/eventTarget'; +import * as metaData from '../src/metaData'; function createCanvas(width = 256, height = 256) { const canvas = document.createElement('canvas'); @@ -344,6 +345,86 @@ describe('CpuVolumeSliceRenderPath', () => { }); describe('PlanarCPUVolumeSampler resampling decisions', () => { + it('reads a volume VOI shape only once across resamples', () => { + const sampler = new PlanarCPUVolumeSampler(); + const volume = { + ...createTestVolume(), + imageIds: ['image-0', 'image-1'], + }; + const getMetadata = jest.spyOn(metaData, 'get').mockReturnValue(undefined); + const sampleArgs = { + volume, + width: 1, + height: 1, + dataPresentation: { + interpolationType: InterpolationType.LINEAR, + }, + }; + + sampler.sampleSliceImage({ + ...sampleArgs, + camera: createCoronalCamera([1.5, 1.5, 1.5]), + }); + sampler.sampleSliceImage({ + ...sampleArgs, + camera: createCoronalCamera([1.5, 2.5, 1.5]), + }); + + expect(getMetadata).toHaveBeenCalledTimes(3); + getMetadata.mockRestore(); + }); + + it('converts the CPU window with the selected VOI LUT function', () => { + const sampler = new PlanarCPUVolumeSampler(); + const sampledSliceState = createSampledSliceState({ + ...createSampledImage(), + voiLUTFunction: VOILUTFunctionType.LINEAR, + }); + const enabledElement = { + canvas: createCanvas(), + image: sampledSliceState.image, + viewport: {}, + }; + + sampler.updateCPUFallbackViewport({ + enabledElement, + sampledSliceState, + camera: { + focalPoint: [0, 0, 0], + parallelScale: 32, + }, + dataPresentation: { + voiRange: { lower: 0, upper: 100 }, + voiLUTFunction: VOILUTFunctionType.LINEAR, + }, + }); + + expect(enabledElement.viewport.voi).toEqual({ + windowCenter: 50.5, + windowWidth: 101, + voiLUTFunction: VOILUTFunctionType.LINEAR, + }); + + sampler.updateCPUFallbackViewport({ + enabledElement, + sampledSliceState, + camera: { + focalPoint: [0, 0, 0], + parallelScale: 32, + }, + dataPresentation: { + voiRange: { lower: 0, upper: 100 }, + voiLUTFunction: VOILUTFunctionType.LINEAR_EXACT, + }, + }); + + expect(enabledElement.viewport.voi).toEqual({ + windowCenter: 50, + windowWidth: 100, + voiLUTFunction: VOILUTFunctionType.LINEAR_EXACT, + }); + }); + it('reuses orthogonal source-slice samples across zoom changes', () => { const sampler = new PlanarCPUVolumeSampler(); const sampledSliceState = { diff --git a/packages/core/test/stackViewport_gpu_render_test.js b/packages/core/test/stackViewport_gpu_render_test.js index 84beec9e26..c785bdfeaf 100644 --- a/packages/core/test/stackViewport_gpu_render_test.js +++ b/packages/core/test/stackViewport_gpu_render_test.js @@ -1041,6 +1041,119 @@ describe('renderingCore -- Stack', () => { }); }); + describe('VOI LUT Sequence state', function () { + it('rebuilds for a different LUT with the same input range', async function () { + testUtils.createViewports(renderingEngine, { + viewportId, + orientation: Enums.OrientationAxis.AXIAL, + }); + const imageInfo = { + loader: 'fakeImageLoader', + name: 'voiLUT', + rows: 16, + columns: 16, + barStart: 4, + barWidth: 4, + xSpacing: 1, + ySpacing: 1, + sliceIndex: 0, + }; + const imageId1 = encodeImageIdInfo(imageInfo); + const imageId2 = encodeImageIdInfo({ ...imageInfo, sliceIndex: 1 }); + const [image1, image2] = await Promise.all([ + imageLoader.loadAndCacheImage(imageId1), + imageLoader.loadAndCacheImage(imageId2), + ]); + + image1.voiLUT = { + firstValueMapped: 0, + numBitsPerEntry: 8, + lut: [0, 64, 255], + }; + image2.voiLUT = { + firstValueMapped: 0, + numBitsPerEntry: 8, + lut: [0, 192, 255], + }; + + const viewport = renderingEngine.getViewport(viewportId); + await viewport.setStack([imageId1, imageId2], 0); + const firstTransferFunction = viewport.getTransferFunction(); + + await viewport.setImageIdIndex(1); + + expect(viewport.getTransferFunction()).not.toBe(firstTransferFunction); + expect(viewport.voiLUTSequenceApplied).toBe(true); + }); + + it('clears the VOI LUT Sequence decision state for a new stack', async function () { + testUtils.createViewports(renderingEngine, { + viewportId, + orientation: Enums.OrientationAxis.AXIAL, + }); + const imageInfo = { + loader: 'fakeImageLoader', + name: 'voiState', + rows: 16, + columns: 16, + barStart: 4, + barWidth: 4, + xSpacing: 1, + ySpacing: 1, + sliceIndex: 0, + }; + const imageId1 = encodeImageIdInfo(imageInfo); + const imageId2 = encodeImageIdInfo({ ...imageInfo, sliceIndex: 1 }); + const viewport = renderingEngine.getViewport(viewportId); + + await viewport.setStack([imageId1], 0); + viewport.setProperties({ useVOILUTSequence: false }); + viewport.voiLUTSequenceApplied = true; + + await viewport.setStack([imageId2], 0); + + expect(viewport.getProperties().useVOILUTSequence).toBeUndefined(); + expect(viewport.voiLUTSequenceApplied).toBe(false); + }); + + it('does not retry an unbuildable LUT for an unchanged range', async function () { + testUtils.createViewports(renderingEngine, { + viewportId, + orientation: Enums.OrientationAxis.AXIAL, + }); + const imageId = encodeImageIdInfo({ + loader: 'fakeImageLoader', + name: 'emptyVOILUT', + rows: 16, + columns: 16, + barStart: 4, + barWidth: 4, + xSpacing: 1, + ySpacing: 1, + }); + const image = await imageLoader.loadAndCacheImage(imageId); + image.voiLUT = { + firstValueMapped: 0, + numBitsPerEntry: 8, + lut: [0, 0, 0], + }; + + const viewport = renderingEngine.getViewport(viewportId); + await viewport.setStack([imageId], 0); + const createTransferFunction = spyOn( + viewport, + '_createVOITransferFunction' + ).and.callThrough(); + const { voiRange } = viewport.getProperties(); + + viewport.setProperties({ voiRange }); + viewport.setProperties({ voiRange }); + + expect(createTransferFunction).not.toHaveBeenCalled(); + expect(viewport.voiLUTSequenceApplied).toBe(false); + }); + }); + describe('Flipping', function () { it('Should be able to flip a stack viewport horizontally', function (done) { const element = testUtils.createViewports(renderingEngine, {