Skip to content

Commit

Permalink
feat(voi): add linear exact voi lut function (#1717)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
sedghi authored Dec 20, 2024
1 parent dcb9f77 commit faf3b9d
Show file tree
Hide file tree
Showing 17 changed files with 120 additions and 52 deletions.
7 changes: 5 additions & 2 deletions common/reviews/api/core.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,7 @@ interface CPUFallbackViewport {
voi?: {
windowWidth: number;
windowCenter: number;
voiLUTFunction: VOILUTFunctionType;
};
// (undocumented)
voiLUT?: CPUFallbackLUT;
Expand Down Expand Up @@ -1722,7 +1723,7 @@ interface IImage {
// (undocumented)
voiLUT?: CPUFallbackLUT;
// (undocumented)
voiLUTFunction: string;
voiLUTFunction: VOILUTFunctionType;
// (undocumented)
voxelManager?: IVoxelManager<number> | IVoxelManager<RGB>;
// (undocumented)
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -4609,6 +4610,8 @@ enum VOILUTFunctionType {
// (undocumented)
LINEAR = "LINEAR",
// (undocumented)
LINEAR_EXACT = "LINEAR_EXACT",
// (undocumented)
SAMPLED_SIGMOID = "SIGMOID"
}

Expand Down
2 changes: 0 additions & 2 deletions common/reviews/api/dicom-image-loader.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,6 @@ interface DICOMLoaderIImage extends Types_2.IImage {
totalTimeInMS?: number;
// (undocumented)
transferSyntaxUID?: string;
// (undocumented)
voiLUTFunction: string | undefined;
}

// @public (undocumented)
Expand Down
37 changes: 28 additions & 9 deletions packages/core/src/RenderingEngine/StackViewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -1363,6 +1368,7 @@ class StackViewport extends Viewport {
viewport.voi = {
windowWidth: 0,
windowCenter: 0,
voiLUTFunction: image.voiLUTFunction,
};
}

Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default function computeAutoVoi(
viewport.voi = {
windowWidth: ww,
windowCenter: wc,
voiLUTFunction: image.voiLUTFunction,
};
} else {
viewport.voi.windowWidth = ww;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
CPUFallbackViewportDisplayedArea,
CPUFallbackViewport,
} from '../../../../types';
import { VOILUTFunctionType } from '../../../../enums';

// eslint-disable-next-line valid-jsdoc
/**
Expand Down Expand Up @@ -47,6 +48,7 @@ export default function createViewport(): CPUFallbackViewport {
voi: {
windowWidth: undefined,
windowCenter: undefined,
voiLUTFunction: VOILUTFunctionType.LINEAR,
},
invert: false,
pixelReplication: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 };
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/enums/VOILUTFunctionType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 2 additions & 0 deletions packages/core/src/types/CPUFallbackViewport.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,6 +14,7 @@ interface CPUFallbackViewport {
voi?: {
windowWidth: number;
windowCenter: number;
voiLUTFunction: VOILUTFunctionType;
};
invert?: boolean;
pixelReplication?: boolean;
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/types/IImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 2 additions & 17 deletions packages/core/src/utilities/createSigmoidRGBTransferFunction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 },
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/utilities/logit.ts
Original file line number Diff line number Diff line change
@@ -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);
};
61 changes: 49 additions & 12 deletions packages/core/src/utilities/windowLevel.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 };
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export function metadataForDataset(
modalityLUTOutputPixelRepresentation,
dataSet.elements.x00283010
),
voiLUTFunction: dataSet.string('x00281056'),
};
}

Expand Down
1 change: 0 additions & 1 deletion packages/dicomImageLoader/src/types/DICOMLoaderIImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,5 @@ export interface DICOMLoaderIImage extends Types.IImage {
totalTimeInMS?: number;
data?: DataSet;
imageFrame?: Types.IImageFrame;
voiLUTFunction: string | undefined;
transferSyntaxUID?: string;
}
Loading

0 comments on commit faf3b9d

Please sign in to comment.