diff --git a/packages/core/src/RenderingEngine/BaseVolumeViewport.ts b/packages/core/src/RenderingEngine/BaseVolumeViewport.ts index 4c5924737a..d502fae66f 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) { @@ -1219,6 +1367,11 @@ 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; + this.viewportProperties.VOILUTFunction = properties.VOILUTFunction; if (properties.colormap?.name) { this.setColormap(properties.colormap, volumeId); @@ -1233,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) { @@ -1341,12 +1498,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 +1526,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 +1541,8 @@ abstract class BaseVolumeViewport extends Viewport { colormap: colormap, voiRange: voiRange, VOILUTFunction: VOILUTFunction, + voiLUTFunctionSetByUser: this.voiLUTFunctionSetByUser, + useVOILUTSequence: this.useVOILUTSequence, interpolationType: interpolationType, invert: invert, slabThickness: slabThickness, @@ -1910,6 +2072,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/CpuImageSliceRenderPath.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath.ts index fcf17ff88a..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,17 +417,28 @@ 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) { 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/PlanarCPUVolumeSampler.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarCPUVolumeSampler.ts index c69f3f62c7..ec1f6231db 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarCPUVolumeSampler.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarCPUVolumeSampler.ts @@ -14,6 +14,11 @@ import type { VOIRange, } from '../../../types'; import VoxelManager from '../../../utilities/VoxelManager'; +import { resolveVOILUTSequenceToApply } from '../../helpers/planarImageRendering'; +import { + getVolumeVOIShape, + type VolumeVOIShape, +} from '../../helpers/setDefaultVolumeVOI'; import getDefaultViewport from '../../helpers/cpuFallback/rendering/getDefaultViewport'; import getSpacingInNormalDirection from '../../../utilities/getSpacingInNormalDirection'; import type { PlanarDataPresentation } from './PlanarViewportTypes'; @@ -25,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; @@ -169,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, @@ -402,11 +409,25 @@ export default class PlanarCPUVolumeSampler { viewport.invert = dataPresentation?.invert ?? false; viewport.pixelReplication = dataPresentation?.interpolationType === InterpolationType.NEAREST; - viewport.voi = { - windowCenter: (resolvedVOI.lower + resolvedVOI.upper) / 2, - windowWidth: Math.max(resolvedVOI.upper - resolvedVOI.lower, 1), - voiLUTFunction: VOILUTFunctionType.LINEAR, - }; + // 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. + 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, + props: dataPresentation, + }); } public needsResample(args: { @@ -1189,9 +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. + 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, @@ -1205,7 +1240,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/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/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..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); } @@ -209,6 +218,20 @@ 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 && + properties.voiLUTFunctionSetByUser !== false + ) { + 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/StackViewport.ts b/packages/core/src/RenderingEngine/StackViewport.ts index f55a00e1a1..64e0c89e59 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'; @@ -178,7 +187,25 @@ 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; + // 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. + 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). @@ -743,7 +770,8 @@ 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). @param properties.rotation - Specifies the image rotation angle in degrees. @@ -754,6 +782,7 @@ class StackViewport extends Viewport { colormap, voiRange, VOILUTFunction, + useVOILUTSequence, invert, interpolationType, sharpening, @@ -772,6 +801,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, @@ -789,8 +820,26 @@ class StackViewport extends Viewport { this.setVOI(voiRange, { suppressEvents, voiUpdatedWithSetProperties }); } + if (typeof useVOILUTSequence !== 'undefined') { + this.useVOILUTSequence = useVOILUTSequence; + } + if (typeof VOILUTFunction !== 'undefined') { + // 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') { @@ -838,16 +887,18 @@ class StackViewport extends Viewport { const { colormap, voiRange, - VOILUTFunction, interpolationType, invert, + useVOILUTSequence, voiUpdatedWithSetProperties, } = this; return { colormap, voiRange, - VOILUTFunction, + VOILUTFunction: this._getEffectiveVOILUTFunction(), + voiLUTFunctionSetByUser: this.voiLUTFunctionSetByUser, + useVOILUTSequence, interpolationType, invert, isComputedVOI: !voiUpdatedWithSetProperties, @@ -872,12 +923,21 @@ 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.useVOILUTSequence = undefined; + 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 +964,35 @@ class StackViewport extends Viewport { this.setInterpolationType(InterpolationType.LINEAR); - if (!this.useCPURendering) { - const transferFunction = this.getTransferFunction(); - setTransferFunctionNodes( - transferFunction, - this.initialTransferFunctionNodes - ); + if (this.useCPURendering) { + return; + } - const nodes = getTransferFunctionNodes(transferFunction); + if (this.voiLUTSequenceApplied) { + this.colormap = undefined; + return; + } - const RGBPoints = nodes.reduce((acc, node) => { - acc.push(node[0], node[1], node[2], node[3]); - return acc; - }, []); + const transferFunction = this.getTransferFunction(); + setTransferFunctionNodes( + transferFunction, + this.initialTransferFunctionNodes + ); - const defaultActor = this.getDefaultActor(); - const matchedColormap = colormapUtils.findMatchingColormap( - RGBPoints, - defaultActor.actor - ); + const nodes = getTransferFunctionNodes(transferFunction); + + 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); } } @@ -987,17 +1056,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); @@ -1375,13 +1443,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 +1589,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 +1609,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 +1666,70 @@ 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 { + // 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 }); + } + + if (this.VOILUTFunction === VOILUTFunctionType.SAMPLED_SIGMOID) { + return createSigmoidRGBTransferFunction(voiRange); + } + + return createLinearRGBTransferFunction( + voiRange + ) 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, @@ -1585,11 +1737,24 @@ class StackViewport extends Viewport { voiUpdatedWithSetProperties = false, } = options; + // 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 ( voiRange && this.voiRange && this.voiRange.lower === voiRange.lower && this.voiRange.upper === voiRange.upper && + voiLUTSequence === this.processedVOILUTSequence && !forceRecreateLUTFunction && !this.stackInvalidated ) { @@ -1622,32 +1787,72 @@ class StackViewport extends Viewport { let transferFunction = imageActor.getProperty().getRGBTransferFunction(0); const isSigmoidTFun = + !colormapApplied && 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. + // A colormap already replaced the colors of the transfer function, and a + // new one here would remove the colormap. + 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; - transferFunction = transferFunctionCreator( - voiRangeToUse - ) as vtkColorTransferFunction; + if ( + isSigmoidTFun || + useVOILUTSequence || + recreateForVOILUTSequence || + !transferFunction || + forceRecreateLUTFunction + ) { + let nextTransferFunction = this._createVOITransferFunction( + voiRangeToUse, + voiLUTSequence + ); - if (this.invert) { - invertRgbTransferFunction(transferFunction); + // 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); } - imageActor.getProperty().setRGBTransferFunction(0, transferFunction); - this.initialTransferFunctionNodes = - getTransferFunctionNodes(transferFunction); + // 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); + + // 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 (!appliedVOILUTSequence) { + this.initialTransferFunctionNodes = + getTransferFunctionNodes(transferFunction); + } + } } - if (!isSigmoidTFun) { + if (!isSigmoidTFun && !appliedVOILUTSequence && transferFunction) { // @ts-ignore vtk type error transferFunction.setRange(voiRangeToUse.lower, voiRangeToUse.upper); } + 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 @@ -1662,7 +1867,8 @@ class StackViewport extends Viewport { const eventDetail: VoiModifiedEventDetail = { viewportId: this.id, range: voiRangeToUse, - VOILUTFunction: this.VOILUTFunction, + VOILUTFunction: this._getEffectiveVOILUTFunction(), + voiLUTSequenceApplied: appliedVOILUTSequence, }; triggerEvent(this.element, Events.VOI_MODIFIED, eventDetail); @@ -1731,6 +1937,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; @@ -1987,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; @@ -2730,23 +2943,96 @@ class StackViewport extends Viewport { } } + /** + * 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); + } + + /** + * 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. + * + * 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) { + return undefined; + } + + if (this.useVOILUTSequence !== true && 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; } + + // 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 + // 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( + 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() { @@ -3305,6 +3591,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 +3610,22 @@ 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); + } + + /** + * 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) + ); } /** 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..af5fba6151 100644 --- a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getVOILut.ts +++ b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/getVOILut.ts @@ -1,4 +1,13 @@ /* 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'; /** * Volume of Interest Lookup Table Function @@ -14,6 +23,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,38 +44,114 @@ * @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)) + ); }; } /** - * 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! - const bitsPerEntry = Math.max(...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; }; } @@ -66,17 +159,39 @@ 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) { - return generateNonLinearVOILUT(voiLUT); +export default function ( + windowWidth: number, + windowCenter: number, + voiLUT?: CPUFallbackLUT, + voiLUTFunction?: VOILUTFunctionType | string +) { + if (isRenderableVOILUT(voiLUT)) { + return generateNonLinearVOILUT( + voiLUT, + windowWidth, + windowCenter, + voiLUTFunction + ); } - 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..df7a4bf1d4 100644 --- a/packages/core/src/RenderingEngine/helpers/planarImageRendering.ts +++ b/packages/core/src/RenderingEngine/helpers/planarImageRendering.ts @@ -4,10 +4,21 @@ 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 { getValidVOILUTFunction } from '../../utilities/voiLUTFunction'; import isPTPrescaledWithSUV from '../../utilities/isPTPrescaledWithSUV'; import { getImageDataMetadata } from '../../utilities/getImageDataMetadata'; import invertRgbTransferFunction from '../../utilities/invertRgbTransferFunction'; @@ -29,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; } @@ -134,6 +151,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 +180,29 @@ export function applyPlanarImagePresentation(args: { actor: vtkImageSlice; defaultVOIRange?: VOIRange; defaultVOILUTFunction?: VOILUTFunctionType; + /** + * 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; 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; + const voiLUT = resolveVOILUTSequenceToApply({ + defaultVOILUT, + defaultVOILUTFunction, + props, + }); if (props?.visible !== undefined) { actor.setVisibility(props.visible); @@ -188,25 +229,74 @@ export function applyPlanarImagePresentation(args: { invert: props?.invert, voiRange, voiLUTFunction: props?.voiLUTFunction ?? defaultVOILUTFunction, + voiLUT, }); property.setUseLookupTableScalarRange(true); 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; 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 +305,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..75b481fc38 100644 --- a/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts +++ b/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts @@ -4,17 +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 { MetadataModules, RequestType } from '../../enums'; +import { normalizeVOILUTFunction } from '../../utilities/voiLUTFunction'; +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 @@ -28,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) { @@ -62,21 +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); +): 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 && - imageVolume.imageIds.length && + !voiRange && + imageVolume.imageIds?.length && shouldUseImageIdsForVOI(imageVolume) ) { - voi = await getVOIFromMiddleSliceMinMax(imageVolume); - voi = handlePreScaledVolume(imageVolume, voi); + voiRange = await getVOIFromMiddleSliceMinMax(imageVolume); } - return voi; + 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 {}; + } + + const source = getVolumeVOISource(imageVolume); + + if (!source) { + return {}; + } + + return { voiLUT: source.voiLUT, voiLUTFunction: source.voiLUTFunction }; } function shouldUseImageIdsForVOI(imageVolume: IImageVolume): boolean { @@ -98,83 +271,196 @@ function shouldUseImageIdsForVOI(imageVolume: IImageVolume): boolean { return imageId.includes(':'); } -function handlePreScaledVolume(imageVolume: IImageVolume, voi: VOIRange) { - const imageIds = imageVolume.imageIds; +function isPTPrescaledVolume(imageVolume: IImageVolume): boolean { + const imageIds = imageVolume.imageIds ?? []; const imageIdIndex = Math.floor(imageIds.length / 2); const imageId = imageIds[imageIdIndex]; + // 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 = - metaData.get(MetadataModules.GENERAL_SERIES, imageId) || {}; + (imageId ? metaData.get(MetadataModules.GENERAL_SERIES, imageId) : null) || + {}; + // 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; + + 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; + } - /** - * 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(generalSeriesModule.modality, imageVolume)) { - return { - lower: 0, - upper: 5, - }; + const sequence = voiLutModule.voiLUTSequence; + + if (!sequence) { + return undefined; } - return voi; + const items = Array.isArray(sequence) ? sequence : [sequence]; + + return items.find(isRenderableVOILUT); } /** - * 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 + * What one instance says about the VOI, or undefined when it says nothing. */ -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 }; - } - } - } else { - voi = metadata.voiLut[0]; +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 }; } - if (voi && (voi.windowWidth !== 0 || voi.windowCenter !== 0)) { - const { lower, upper } = windowLevel.toLowHighRange( - Number(voi.windowWidth), - Number(voi.windowCenter), - voi.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; + } + + // 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 }; +} - if (isNaN(lower) || isNaN(upper)) { - return; +/** + * 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 + * 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 getVOISourceFromImageIds( + imageIds: string[] +): VolumeVOISource | undefined { + 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; } - return { lower, upper }; + const source = getVOISourceFromImageId(imageIds[index]); + + if (source) { + return source; + } } - // Return undefined if no valid VOI was found return undefined; } +/** + * A volume that has no imageIds contains its own window, but the volume is not + * required to have one. + */ +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) { + 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; + } + + if (isRenderableVOILUT(source.voiLUT)) { + return getVOILUTSequenceRange(source.voiLUT); + } + + 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 }; +} + /** * It loads the middle slice image (middle imageId) and based on its min * and max pixel values, it calculates the VOI. @@ -261,7 +547,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/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/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/types/ViewportProperties.ts b/packages/core/src/types/ViewportProperties.ts index 87c9626eed..34c3ce1d39 100644 --- a/packages/core/src/types/ViewportProperties.ts +++ b/packages/core/src/types/ViewportProperties.ts @@ -8,8 +8,16 @@ 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; + /** 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 + * 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/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/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..ccd8aa634b --- /dev/null +++ b/packages/core/src/utilities/createVOILUTSequenceTransferFunction.ts @@ -0,0 +1,313 @@ +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. + */ +export 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 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 + * 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); + + // 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 (!scale) { + return undefined; + } + + 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..b10e8a7448 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,19 @@ import { mapMappedBandToRawRange, } from './viewportVoiIntensityMapping'; export type { ViewportVoiMappingProps } from './viewportVoiIntensityMapping'; +export { + isRenderableVOILUT, + getVOILUTSequenceRange, + getVOILUTOutputScale, + sampleVOILUT, + createVOILUTSampler, + invertVOILUTSample, +} from './createVOILUTSequenceTransferFunction'; +export type { RenderableVOILUT } from './createVOILUTSequenceTransferFunction'; +export { + normalizeVOILUTFunction, + getValidVOILUTFunction, +} from './voiLUTFunction'; export * from './getPixelSpacingInformation'; export * from './getPlaneCubeIntersectionDimensions'; export * from './rotateToViewCoordinates'; @@ -153,6 +167,7 @@ export { eventListener, csUtils as invertRgbTransferFunction, createSigmoidRGBTransferFunction, + createVOILUTSequenceTransferFunction, getVoiFromSigmoidRGBTransferFunction, createLinearRGBTransferFunction, scaleRgbTransferFunction, 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/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/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 146f1db941..a248af2ee1 100644 --- a/packages/core/test/planarComputedCamera.jest.js +++ b/packages/core/test/planarComputedCamera.jest.js @@ -632,6 +632,61 @@ describe('Planar CPU image render path', () => { expect(attachment.rendering.enabledElement.viewport.voi).toEqual({ windowCenter: 2.5, windowWidth: 5, + 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, { diff --git a/packages/core/test/utilities/getVOILut.jest.js b/packages/core/test/utilities/getVOILut.jest.js new file mode 100644 index 0000000000..7509221b99 --- /dev/null +++ b/packages/core/test/utilities/getVOILut.jest.js @@ -0,0 +1,268 @@ +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], + }; + // 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 () { + 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..e1c00d8810 --- /dev/null +++ b/packages/core/test/utilities/setDefaultVolumeVOI.jest.js @@ -0,0 +1,207 @@ +import { describe, it, expect, afterEach } from '@jest/globals'; +import { + getDefaultVolumeVOI, + getDefaultVolumeVOIRange, + getVolumeVOIShape, +} 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('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': { + 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__/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/__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..f7866aab50 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,227 @@ 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('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', diff --git a/packages/dicomImageLoader/src/imageLoader/createImage.ts b/packages/dicomImageLoader/src/imageLoader/createImage.ts index 1b33cc4db2..732df287fc 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'; @@ -33,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')); @@ -48,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). @@ -72,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; } } @@ -111,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 @@ -126,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 { @@ -135,7 +139,7 @@ async function createImage( type, offset: rawOffset = 0, length: rawLength, - } = options.targetBuffer; + } = imageOptions.targetBuffer; const imageFrameLength = imageFrame.pixelDataLength; @@ -380,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 @@ -390,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, @@ -405,17 +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, - voiLUTFunction: - (voiLutModule.voiLUTFunction?.length && - voiLutModule.voiLUTFunction[0]) || - voiLutModule.voiLutFunction || - undefined, + windowCenter, + windowWidth, + voiLUTFunction, decodeTimeInMS: imageFrame.decodeTimeInMS, floatPixelData: undefined, imageFrame, @@ -477,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) @@ -486,12 +509,20 @@ 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), 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; } 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..f7998df4fd --- /dev/null +++ b/packages/dicomImageLoader/src/imageLoader/normalizeVOILUTSequence.ts @@ -0,0 +1,206 @@ +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 = { + lut?: unknown; + firstValueMapped?: number; + numBitsPerEntry?: number; + LUTDescriptor?: unknown; + LUTData?: unknown; + '00283002'?: { Value?: unknown }; + '00283006'?: { Value?: unknown; InlineBinary?: unknown }; +}; + +/** + * 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 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 copyElements(bytes); + } + + 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 (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); + } + + if (data.every((value) => typeof value === 'number')) { + return data as number[]; + } + + return undefined; + } + + if (data instanceof ArrayBuffer) { + return fromBytes(new Uint8Array(data), bitsPerEntry); + } + + if (ArrayBuffer.isView(data)) { + const elementSize = (data as { BYTES_PER_ELEMENT?: number }) + .BYTES_PER_ELEMENT; + + // 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') { + return undefined; + } + + const binary = atob(inlineBinary); + const bytes = new Uint8Array(binary.length); + + for (let i = 0; i < bytes.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + return fromBytes(bytes, 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: item.firstValueMapped ?? 0, + numBitsPerEntry: item.numBitsPerEntry, + }; + } + + const descriptor = + toNumberArray(item.LUTDescriptor) ?? toNumberArray(item['00283002']?.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']?.Value, bitsPerEntry) ?? + fromInlineBinary(item['00283006']?.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 numEntries = Math.min(descriptor[0] || 65536, data.length); + + return { + lut: numEntries === data.length ? data : data.slice(0, numEntries), + firstValueMapped: descriptor[1], + numBitsPerEntry: bitsPerEntry, + }; +} 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..39f81683e6 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,18 +28,56 @@ function getLUT(pixelRepresentation: number, lutDataSet: DataSet): LutType { lut: [], }; - // console.log("minValue=", minValue, "; maxValue=", maxValue); + 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 / bytesPerEntry) + ); + } + + // 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); + if (bytesPerEntry === 1) { + lut.lut[i] = lutDataSet.byteArray[lutDataElement.dataOffset + i]; } else { - lut.lut[i] = lutDataSet.int16('x00283006', i); + 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; diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts index 391ae586f0..4958f075db 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts @@ -65,6 +65,46 @@ function metaDataProvider(type, imageId) { return metadataForDataset(type, imageId, dataSet); } +/** + * 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 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 undefined; +} + export function metadataForDataset( type, imageId, @@ -212,15 +252,35 @@ 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 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(dataSet, 'x00281050', 1), - windowWidth: getNumberValues(dataSet, 'x00281051', 1), - voiLUTSequence: getLUTs( - modalityLUTOutputPixelRepresentation, - dataSet.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: dataSet.string('x00281056'), }; } 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..71b6418a56 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,87 @@ 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. + +## 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/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/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; 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, }; } 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"