Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(voi): add linear exact voi lut function #1717

Merged
merged 4 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not always defined, it's crashing tests in OHIF

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
Loading