From faf3b9ded5dc42ef9fdd6d2f36aecb1e56cefeb7 Mon Sep 17 00:00:00 2001 From: Alireza Date: Fri, 20 Dec 2024 08:40:05 -0500 Subject: [PATCH] feat(voi): add linear exact voi lut function (#1717) * fix: fallback for small window levels * feat: enhance VOI handling by integrating voiLUTFunction across rendering and metadata processes - Added voiLUTFunction to viewport VOI settings in StackViewport and related utilities. - Updated window level calculations to consider voiLUTFunction for accurate range determination. - Enhanced metadata extraction to include voiLUTFunction from DICOM data. - Introduced new logit utility for improved sigmoid function handling in window level transformations. - Updated relevant types and interfaces to accommodate voiLUTFunction. This change improves the flexibility and accuracy of volume rendering based on user-defined LUT functions. * update api * fix tests --- common/reviews/api/core.api.md | 7 ++- common/reviews/api/dicom-image-loader.api.md | 2 - .../core/src/RenderingEngine/StackViewport.ts | 37 ++++++++--- .../cpuFallback/rendering/computeAutoVoi.ts | 1 + .../cpuFallback/rendering/createViewport.ts | 2 + .../helpers/setDefaultVolumeVOI.ts | 8 ++- packages/core/src/enums/VOILUTFunctionType.ts | 2 +- .../core/src/types/CPUFallbackViewport.ts | 2 + packages/core/src/types/IImage.ts | 4 +- .../createSigmoidRGBTransferFunction.ts | 19 +----- packages/core/src/utilities/logit.ts | 9 +++ packages/core/src/utilities/windowLevel.ts | 61 +++++++++++++++---- .../wadors/metaData/metaDataProvider.ts | 3 +- .../wadouri/metaData/metaDataProvider.ts | 1 + .../src/types/DICOMLoaderIImage.ts | 1 - .../tools/src/tools/WindowLevelRegionTool.ts | 5 +- packages/tools/src/tools/WindowLevelTool.ts | 8 ++- 17 files changed, 120 insertions(+), 52 deletions(-) create mode 100644 packages/core/src/utilities/logit.ts diff --git a/common/reviews/api/core.api.md b/common/reviews/api/core.api.md index e1d24f7c4f..1f318988ff 100644 --- a/common/reviews/api/core.api.md +++ b/common/reviews/api/core.api.md @@ -721,6 +721,7 @@ interface CPUFallbackViewport { voi?: { windowWidth: number; windowCenter: number; + voiLUTFunction: VOILUTFunctionType; }; // (undocumented) voiLUT?: CPUFallbackLUT; @@ -1722,7 +1723,7 @@ interface IImage { // (undocumented) voiLUT?: CPUFallbackLUT; // (undocumented) - voiLUTFunction: string; + voiLUTFunction: VOILUTFunctionType; // (undocumented) voxelManager?: IVoxelManager | IVoxelManager; // (undocumented) @@ -3717,7 +3718,7 @@ class TargetEventListeners { function threePlaneIntersection(firstPlane: Plane, secondPlane: Plane, thirdPlane: Plane): Point3; // @public (undocumented) -function toLowHighRange(windowWidth: number, windowCenter: number): { +function toLowHighRange(windowWidth: number, windowCenter: number, voiLUTFunction?: VOILUTFunctionType): { lower: number; upper: number; }; @@ -4609,6 +4610,8 @@ enum VOILUTFunctionType { // (undocumented) LINEAR = "LINEAR", // (undocumented) + LINEAR_EXACT = "LINEAR_EXACT", + // (undocumented) SAMPLED_SIGMOID = "SIGMOID" } diff --git a/common/reviews/api/dicom-image-loader.api.md b/common/reviews/api/dicom-image-loader.api.md index 8fa0a1d1b1..23c601c79b 100644 --- a/common/reviews/api/dicom-image-loader.api.md +++ b/common/reviews/api/dicom-image-loader.api.md @@ -164,8 +164,6 @@ interface DICOMLoaderIImage extends Types_2.IImage { totalTimeInMS?: number; // (undocumented) transferSyntaxUID?: string; - // (undocumented) - voiLUTFunction: string | undefined; } // @public (undocumented) diff --git a/packages/core/src/RenderingEngine/StackViewport.ts b/packages/core/src/RenderingEngine/StackViewport.ts index 341e102e55..7f67af4d09 100644 --- a/packages/core/src/RenderingEngine/StackViewport.ts +++ b/packages/core/src/RenderingEngine/StackViewport.ts @@ -1348,9 +1348,14 @@ class StackViewport extends Viewport { viewport.voi = { windowWidth: wwToUse, windowCenter: wcToUse, + voiLUTFunction: image.voiLUTFunction, }; - const { lower, upper } = windowLevelUtil.toLowHighRange(wwToUse, wcToUse); + const { lower, upper } = windowLevelUtil.toLowHighRange( + wwToUse, + wcToUse, + image.voiLUTFunction + ); voiRange = { lower, upper }; } else { const { lower, upper } = voiRange; @@ -1363,6 +1368,7 @@ class StackViewport extends Viewport { viewport.voi = { windowWidth: 0, windowCenter: 0, + voiLUTFunction: image.voiLUTFunction, }; } @@ -2288,8 +2294,12 @@ class StackViewport extends Viewport { this._cpuFallbackEnabledElement.viewport.colormap ); - const { windowCenter, windowWidth } = viewport.voi; - this.voiRange = windowLevelUtil.toLowHighRange(windowWidth, windowCenter); + const { windowCenter, windowWidth, voiLUTFunction } = viewport.voi; + this.voiRange = windowLevelUtil.toLowHighRange( + windowWidth, + windowCenter, + voiLUTFunction + ); this._cpuFallbackEnabledElement.image = image; this._cpuFallbackEnabledElement.metadata = { @@ -2521,9 +2531,13 @@ class StackViewport extends Viewport { if (this.voiRange && this.voiUpdatedWithSetProperties) { return this.globalDefaultProperties.voiRange; } - const { windowCenter, windowWidth } = image; + const { windowCenter, windowWidth, voiLUTFunction } = image; - let voiRange = this._getVOIRangeFromWindowLevel(windowWidth, windowCenter); + let voiRange = this._getVOIRangeFromWindowLevel( + windowWidth, + windowCenter, + voiLUTFunction + ); // Get the range for the PT since if it is prescaled // we set a default range of 0-5 @@ -2558,7 +2572,8 @@ class StackViewport extends Viewport { private _getVOIRangeFromWindowLevel( windowWidth: number | number[], - windowCenter: number | number[] + windowCenter: number | number[], + voiLUTFunction: VOILUTFunctionType = VOILUTFunctionType.LINEAR ): { lower: number; upper: number } | undefined { let center, width; @@ -2572,7 +2587,7 @@ class StackViewport extends Viewport { // If center and width are defined, convert them to low-high range if (center !== undefined && width !== undefined) { - return windowLevelUtil.toLowHighRange(width, center); + return windowLevelUtil.toLowHighRange(width, center, voiLUTFunction); } } @@ -2949,9 +2964,13 @@ class StackViewport extends Viewport { }; private _getVOIRangeForCurrentImage() { - const { windowCenter, windowWidth } = this.csImage; + const { windowCenter, windowWidth, voiLUTFunction } = this.csImage; - return this._getVOIRangeFromWindowLevel(windowWidth, windowCenter); + return this._getVOIRangeFromWindowLevel( + windowWidth, + windowCenter, + voiLUTFunction + ); } private _getValidVOILUTFunction( diff --git a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/computeAutoVoi.ts b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/computeAutoVoi.ts index 167faa52b9..14eb8a8b87 100644 --- a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/computeAutoVoi.ts +++ b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/computeAutoVoi.ts @@ -23,6 +23,7 @@ export default function computeAutoVoi( viewport.voi = { windowWidth: ww, windowCenter: wc, + voiLUTFunction: image.voiLUTFunction, }; } else { viewport.voi.windowWidth = ww; diff --git a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/createViewport.ts b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/createViewport.ts index 0613ae411f..d455588d9a 100644 --- a/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/createViewport.ts +++ b/packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/createViewport.ts @@ -3,6 +3,7 @@ import type { CPUFallbackViewportDisplayedArea, CPUFallbackViewport, } from '../../../../types'; +import { VOILUTFunctionType } from '../../../../enums'; // eslint-disable-next-line valid-jsdoc /** @@ -47,6 +48,7 @@ export default function createViewport(): CPUFallbackViewport { voi: { windowWidth: undefined, windowCenter: undefined, + voiLUTFunction: VOILUTFunctionType.LINEAR, }, invert: false, pixelReplication: false, diff --git a/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts b/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts index 3dac7d4057..d3aa9572b3 100644 --- a/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts +++ b/packages/core/src/RenderingEngine/helpers/setDefaultVolumeVOI.ts @@ -81,11 +81,12 @@ function handlePreScaledVolume(imageVolume: IImageVolume, voi: VOIRange) { function getVOIFromMetadata(imageVolume: IImageVolume): VOIRange | undefined { const { imageIds, metadata } = imageVolume; let voi; - if (imageIds.length) { + if (imageIds?.length) { const imageIdIndex = Math.floor(imageIds.length / 2); const imageId = imageIds[imageIdIndex]; const voiLutModule = metaData.get('voiLutModule', imageId); - if (voiLutModule?.windowWidth && voiLutModule.windowCenter) { + if (voiLutModule && voiLutModule.windowWidth && voiLutModule.windowCenter) { + voi.voiLUTFunction = voiLutModule.voiLUTFunction; const { windowWidth, windowCenter } = voiLutModule; const width = Array.isArray(windowWidth) ? windowWidth[0] : windowWidth; const center = Array.isArray(windowCenter) @@ -104,7 +105,8 @@ function getVOIFromMetadata(imageVolume: IImageVolume): VOIRange | undefined { if (voi && (voi.windowWidth !== 0 || voi.windowCenter !== 0)) { const { lower, upper } = windowLevel.toLowHighRange( Number(voi.windowWidth), - Number(voi.windowCenter) + Number(voi.windowCenter), + voi.voiLUTFunction ); return { lower, upper }; } diff --git a/packages/core/src/enums/VOILUTFunctionType.ts b/packages/core/src/enums/VOILUTFunctionType.ts index f354f4b353..ce6fb15078 100644 --- a/packages/core/src/enums/VOILUTFunctionType.ts +++ b/packages/core/src/enums/VOILUTFunctionType.ts @@ -4,7 +4,7 @@ enum VOILUTFunctionType { LINEAR = 'LINEAR', SAMPLED_SIGMOID = 'SIGMOID', // SIGMOID is sampled in 1024 even steps so we call it SAMPLED_SIGMOID - // EXACT_LINEAR = 'EXACT_LINEAR', TODO: Add EXACT_LINEAR option from DICOM NEMA + LINEAR_EXACT = 'LINEAR_EXACT', } export default VOILUTFunctionType; diff --git a/packages/core/src/types/CPUFallbackViewport.ts b/packages/core/src/types/CPUFallbackViewport.ts index fa173cfe41..5569edee15 100644 --- a/packages/core/src/types/CPUFallbackViewport.ts +++ b/packages/core/src/types/CPUFallbackViewport.ts @@ -1,6 +1,7 @@ import type CPUFallbackViewportDisplayedArea from './CPUFallbackViewportDisplayedArea'; import type CPUFallbackColormap from './CPUFallbackColormap'; import type CPUFallbackLUT from './CPUFallbackLUT'; +import type VOILUTFunctionType from '../enums/VOILUTFunctionType'; interface CPUFallbackViewport { scale?: number; @@ -13,6 +14,7 @@ interface CPUFallbackViewport { voi?: { windowWidth: number; windowCenter: number; + voiLUTFunction: VOILUTFunctionType; }; invert?: boolean; pixelReplication?: boolean; diff --git a/packages/core/src/types/IImage.ts b/packages/core/src/types/IImage.ts index 28bf42dfc4..89a897e8b9 100644 --- a/packages/core/src/types/IImage.ts +++ b/packages/core/src/types/IImage.ts @@ -3,7 +3,7 @@ import type { PixelDataTypedArray, PixelDataTypedArrayString, } from './PixelDataTypedArray'; -import type { ImageQualityStatus } from '../enums'; +import type { ImageQualityStatus, VOILUTFunctionType } from '../enums'; import type IImageCalibration from './IImageCalibration'; import type RGB from './RGB'; import type IImageFrame from './IImageFrame'; @@ -59,7 +59,7 @@ interface IImage { /** windowWidth from metadata */ windowWidth: number[] | number; /** voiLUTFunction from metadata */ - voiLUTFunction: string; + voiLUTFunction: VOILUTFunctionType; /** function that returns the pixelData as an array */ getPixelData: () => PixelDataTypedArray; getCanvas: () => HTMLCanvasElement; diff --git a/packages/core/src/utilities/createSigmoidRGBTransferFunction.ts b/packages/core/src/utilities/createSigmoidRGBTransferFunction.ts index 72012e9569..d4b3517805 100644 --- a/packages/core/src/utilities/createSigmoidRGBTransferFunction.ts +++ b/packages/core/src/utilities/createSigmoidRGBTransferFunction.ts @@ -2,6 +2,7 @@ import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransf import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; import type { VOIRange } from '../types/voi'; import * as windowLevelUtil from './windowLevel'; +import { logit } from './logit'; /** * A utility that can be used to generate an Sigmoid RgbTransferFunction. @@ -24,29 +25,13 @@ import * as windowLevelUtil from './windowLevel'; */ export default function createSigmoidRGBTransferFunction( voiRange: VOIRange, - approximationNodes: number = 1024 // humans can precieve no more than 900 shades of gray doi: 10.1007/s10278-006-1052-3 + approximationNodes: number = 1024 // humans can perceive no more than 900 shades of gray doi: 10.1007/s10278-006-1052-3 ): vtkColorTransferFunction { const { windowWidth, windowCenter } = windowLevelUtil.toWindowLevel( voiRange.lower, voiRange.upper ); - // Function is defined by dicom spec - // https://dicom.nema.org/medical/dicom/2018b/output/chtml/part03/sect_C.11.2.html - const sigmoid = (x: number, wc: number, ww: number): number => { - return 1 / (1 + Math.exp((-4 * (x - wc)) / ww)); - }; - - // This function is the analytical inverse of the dicom spec sigmoid function - // for values y = [0, 1] exclusive. We use this to perform better sampling of - // points for the LUT as some images can have 2^16 unique values. This method - // can be deprecated if vtk supports LUTFunctions rather than look up tables - // or if vtk supports logistic scale. It currently only supports linear and - // log10 scaling which can be set on the vtkColorTransferFunction - const logit = (y: number, wc: number, ww: number): number => { - return wc - (ww / 4) * Math.log((1 - y) / y); - }; - // we slice out the first and last value to avoid 0 and 1 Infinity values const range: number[] = Array.from( { length: approximationNodes }, diff --git a/packages/core/src/utilities/logit.ts b/packages/core/src/utilities/logit.ts new file mode 100644 index 0000000000..b59b3310e1 --- /dev/null +++ b/packages/core/src/utilities/logit.ts @@ -0,0 +1,9 @@ +// This function is the analytical inverse of the dicom spec sigmoid function +// for values y = [0, 1] exclusive. We use this to perform better sampling of +// points for the LUT as some images can have 2^16 unique values. This method +// can be deprecated if vtk supports LUTFunctions rather than look up tables +// or if vtk supports logistic scale. It currently only supports linear and +// log10 scaling which can be set on the vtkColorTransferFunction +export const logit = (y: number, wc: number, ww: number): number => { + return wc - (ww / 4) * Math.log((1 - y) / y); +}; diff --git a/packages/core/src/utilities/windowLevel.ts b/packages/core/src/utilities/windowLevel.ts index 740807f6be..70b12a8b01 100644 --- a/packages/core/src/utilities/windowLevel.ts +++ b/packages/core/src/utilities/windowLevel.ts @@ -1,3 +1,6 @@ +import VOILUTFunctionType from '../enums/VOILUTFunctionType'; +import { logit } from './logit'; + /** * Given a low and high window level, return the window width and window center * Formulas from note 4 in @@ -20,31 +23,65 @@ function toWindowLevel( return { windowWidth, windowCenter }; } + /** * Given a window width and center, return the lower and upper bounds of the window. - * The formulas for the calculation are specified in the DICOM standard: - * {@link https://dicom.nema.org/medical/dicom/current/output/html/part03.html#sect_C.11.2.1.2.1} + * The calculation depends on the VOI LUT Function: + * + * LINEAR (default): + * - Uses the DICOM standard formula from C.11.2.1.2.1: + * if x <= c - 0.5 - (w-1)/2 => lower bound + * if x > c - 0.5 + (w-1)/2 => upper bound * - * The window transformation is defined by: - * - if `x <= c - 0.5 - (w-1)/2`, then `y = ymin` - * - if `x > c - 0.5 + (w-1)/2`, then `y = ymax` - * - else `y = ((x - (c - 0.5))/(w-1) + 0.5) * (ymax - ymin) + ymin` + * LINEAR_EXACT (C.11.2.1.3.2): + * - Uses: + * lower = c - w/2 + * upper = c + w/2 * - * @param windowWidth - The width of the window in HU + * 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. + * + * @param windowWidth - The width of the window * @param windowCenter - The center of the window + * @param voiLUTFunction - 'LINEAR' | 'LINEAR_EXACT' | 'SIGMOID' * @returns An object containing the lower and upper bounds of the window */ function toLowHighRange( windowWidth: number, - windowCenter: number + windowCenter: number, + voiLUTFunction: VOILUTFunctionType = VOILUTFunctionType.LINEAR ): { lower: number; upper: number; } { - const lower = windowCenter - 0.5 - (windowWidth - 1) / 2; - const upper = windowCenter - 0.5 + (windowWidth - 1) / 2; - - return { lower, upper }; + if (voiLUTFunction === VOILUTFunctionType.LINEAR) { + // 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, + }; + } 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'); + } } export { toWindowLevel, toLowHighRange }; diff --git a/packages/dicomImageLoader/src/imageLoader/wadors/metaData/metaDataProvider.ts b/packages/dicomImageLoader/src/imageLoader/wadors/metaData/metaDataProvider.ts index 0f470c877c..d75cb0b4c8 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadors/metaData/metaDataProvider.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadors/metaData/metaDataProvider.ts @@ -262,9 +262,10 @@ function metaDataProvider(type, imageId) { if (type === MetadataModules.VOI_LUT) { return { - // TODO VOT LUT Sequence windowCenter: getNumberValues(metaData['00281050'], 1), windowWidth: getNumberValues(metaData['00281051'], 1), + voiLUTFunction: getValue(metaData['00281056']), + // TODO VOT LUT Sequence }; } diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts index a0b6a3dfa8..cfe89eb097 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts @@ -213,6 +213,7 @@ export function metadataForDataset( modalityLUTOutputPixelRepresentation, dataSet.elements.x00283010 ), + voiLUTFunction: dataSet.string('x00281056'), }; } diff --git a/packages/dicomImageLoader/src/types/DICOMLoaderIImage.ts b/packages/dicomImageLoader/src/types/DICOMLoaderIImage.ts index 608190177f..fd45a6205c 100644 --- a/packages/dicomImageLoader/src/types/DICOMLoaderIImage.ts +++ b/packages/dicomImageLoader/src/types/DICOMLoaderIImage.ts @@ -7,6 +7,5 @@ export interface DICOMLoaderIImage extends Types.IImage { totalTimeInMS?: number; data?: DataSet; imageFrame?: Types.IImageFrame; - voiLUTFunction: string | undefined; transferSyntaxUID?: string; } diff --git a/packages/tools/src/tools/WindowLevelRegionTool.ts b/packages/tools/src/tools/WindowLevelRegionTool.ts index c56227078d..d9e31c5568 100644 --- a/packages/tools/src/tools/WindowLevelRegionTool.ts +++ b/packages/tools/src/tools/WindowLevelRegionTool.ts @@ -355,9 +355,12 @@ class WindowLevelRegionTool extends AnnotationTool { ); const windowCenter = minMaxMean.mean; + const voiLutFunction = viewport.getProperties().VOILUTFunction; + const voiRange = utilities.windowLevel.toLowHighRange( windowWidth, - windowCenter + windowCenter, + voiLutFunction ); viewport.setProperties({ voiRange }); diff --git a/packages/tools/src/tools/WindowLevelTool.ts b/packages/tools/src/tools/WindowLevelTool.ts index dbe1027777..664919f120 100644 --- a/packages/tools/src/tools/WindowLevelTool.ts +++ b/packages/tools/src/tools/WindowLevelTool.ts @@ -161,8 +161,14 @@ class WindowLevelTool extends BaseTool { windowWidth = Math.max(windowWidth, 1); + const voiLutFunction = viewport.getProperties().VOILUTFunction; + // Convert back to range - return utilities.windowLevel.toLowHighRange(windowWidth, windowCenter); + return utilities.windowLevel.toLowHighRange( + windowWidth, + windowCenter, + voiLutFunction + ); } _getMultiplierFromDynamicRange(viewport, volumeId) {