From ea8b9448aec19fd7ff3dae14158993f80c4417fb Mon Sep 17 00:00:00 2001 From: Yujin Lee Date: Tue, 21 Apr 2026 21:25:08 +0900 Subject: [PATCH 1/3] fix(dicom-image-loader): use Float32Array for non-integer rescale slopes to prevent pixel data corruption DICOM images with non-integer RescaleSlope (e.g. DTI FA maps with RescaleSlope=0.001) have their pixel data destroyed during preScale because getPixelDataTypeFromMinMax incorrectly selects Uint8Array. This happens because Number.isInteger(1.0) === true in JavaScript, so scaled min/max of 0.0 and 1.0 are treated as integers, leading to Uint8Array selection. All fractional values (0.013, 0.5, 0.999) are then truncated to 0. Fix: 1. In _handlePreScaleSetup: detect non-integer scaling parameters and force Float32Array before scaling is applied. 2. In setPixelDataType: skip re-typing if pixelData is already Float32Array, preventing the main thread from downgrading it back to Uint8Array after web worker transfer. Fixes: https://github.com/cornerstonejs/cornerstone3D/issues/2706 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/decodeImageFrameWorker.js | 17 +++++++++++++++++ .../src/imageLoader/setPixelDataType.ts | 8 ++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/dicomImageLoader/src/decodeImageFrameWorker.js b/packages/dicomImageLoader/src/decodeImageFrameWorker.js index ea41912fff..6345138582 100644 --- a/packages/dicomImageLoader/src/decodeImageFrameWorker.js +++ b/packages/dicomImageLoader/src/decodeImageFrameWorker.js @@ -244,6 +244,23 @@ function _handlePreScaleSetup( const scalingParameters = options.preScale.scalingParameters; _validateScalingParameters(scalingParameters); + // When scaling parameters contain non-integer values (e.g. rescaleSlope=0.001 + // for DTI FA maps), the scaled pixel values will be fractional (e.g. 0.013, + // 0.5, 0.999). Force Float32Array to prevent truncation that would occur if + // getPixelDataTypeFromMinMax selects an integer array type — which happens + // when scaled min/max are "integer-like" (e.g. 0.0 and 1.0) because + // Number.isInteger(1.0) === true in JavaScript. + // See: https://github.com/cornerstonejs/cornerstone3D/issues/2706 + const hasFloatRescale = Object.values(scalingParameters).some( + (v) => typeof v === 'number' && !Number.isInteger(v) + ); + + if (hasFloatRescale) { + const typedArray = new Float32Array(imageFrame.pixelData.length); + typedArray.set(imageFrame.pixelData, 0); + return typedArray; + } + const scaledValues = _calculateScaledMinMax( minBeforeScale, maxBeforeScale, diff --git a/packages/dicomImageLoader/src/imageLoader/setPixelDataType.ts b/packages/dicomImageLoader/src/imageLoader/setPixelDataType.ts index 397c102b4d..00636722ed 100644 --- a/packages/dicomImageLoader/src/imageLoader/setPixelDataType.ts +++ b/packages/dicomImageLoader/src/imageLoader/setPixelDataType.ts @@ -8,6 +8,14 @@ import getPixelDataTypeFromMinMax from '../shared/getPixelDataTypeFromMinMax'; * min and max values */ function setPixelDataType(imageFrame) { + // If the pixel data is already Float32Array (e.g. from _handlePreScaleSetup + // forcing Float32 for non-integer rescale slopes), skip re-typing to prevent + // getPixelDataTypeFromMinMax from downgrading it to Uint8Array. + // See: https://github.com/cornerstonejs/cornerstone3D/issues/2706 + if (imageFrame.pixelData instanceof Float32Array) { + return; + } + const minValue = imageFrame.smallestPixelValue; const maxValue = imageFrame.largestPixelValue; From cc21e23f97359b1ddc3372f51dd015a4787171ab Mon Sep 17 00:00:00 2001 From: Yujin Lee Date: Tue, 21 Apr 2026 21:40:09 +0900 Subject: [PATCH 2/3] fix(core): fall back to LINEAR_EXACT when WindowWidth <= 1 to prevent division by zero The DICOM LINEAR VOI formula uses (WW-1) which becomes 0 when WW=1, producing lower === upper (degenerate range). This causes division by zero in the VOI LUT shader, resulting in a binary black/white image. Fall back to LINEAR_EXACT (lower = WC - WW/2, upper = WC + WW/2) which correctly handles WW=1 (e.g. DTI FA with WC=0.5, WW=1 gives lower=0, upper=1). Fixes: https://github.com/cornerstonejs/cornerstone3D/issues/2706 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/utilities/windowLevel.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/core/src/utilities/windowLevel.ts b/packages/core/src/utilities/windowLevel.ts index 62a1737e1f..36156fe823 100644 --- a/packages/core/src/utilities/windowLevel.ts +++ b/packages/core/src/utilities/windowLevel.ts @@ -58,6 +58,15 @@ function toLowHighRange( lower: number; upper: number; } { + // When WindowWidth <= 1, the LINEAR formula produces lower === upper + // (degenerate range) because (WW-1) becomes 0 or negative. This causes + // division by zero in the VOI LUT shader. Fall back to LINEAR_EXACT + // which handles all positive WW values correctly. + // See: https://github.com/cornerstonejs/cornerstone3D/issues/2706 + if (windowWidth <= 1 && voiLUTFunction === VOILUTFunctionType.LINEAR) { + voiLUTFunction = VOILUTFunctionType.LINEAR_EXACT; + } + // 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 From 29ec9f1e0b74fef221ac6466012ab03bef0f8527 Mon Sep 17 00:00:00 2001 From: Yujin Lee Date: Tue, 21 Apr 2026 21:47:21 +0900 Subject: [PATCH 3/3] style: shorten comments to match codebase style Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/utilities/windowLevel.ts | 7 ++----- .../dicomImageLoader/src/decodeImageFrameWorker.js | 11 ++++------- .../src/imageLoader/setPixelDataType.ts | 6 ++---- 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/core/src/utilities/windowLevel.ts b/packages/core/src/utilities/windowLevel.ts index 36156fe823..2d54fad386 100644 --- a/packages/core/src/utilities/windowLevel.ts +++ b/packages/core/src/utilities/windowLevel.ts @@ -58,11 +58,8 @@ function toLowHighRange( lower: number; upper: number; } { - // When WindowWidth <= 1, the LINEAR formula produces lower === upper - // (degenerate range) because (WW-1) becomes 0 or negative. This causes - // division by zero in the VOI LUT shader. Fall back to LINEAR_EXACT - // which handles all positive WW values correctly. - // See: https://github.com/cornerstonejs/cornerstone3D/issues/2706 + // LINEAR formula produces lower === upper when WW <= 1 because (WW-1) + // becomes 0. Fall back to LINEAR_EXACT to avoid division by zero. See #2706. if (windowWidth <= 1 && voiLUTFunction === VOILUTFunctionType.LINEAR) { voiLUTFunction = VOILUTFunctionType.LINEAR_EXACT; } diff --git a/packages/dicomImageLoader/src/decodeImageFrameWorker.js b/packages/dicomImageLoader/src/decodeImageFrameWorker.js index 6345138582..cb525fbb89 100644 --- a/packages/dicomImageLoader/src/decodeImageFrameWorker.js +++ b/packages/dicomImageLoader/src/decodeImageFrameWorker.js @@ -244,13 +244,10 @@ function _handlePreScaleSetup( const scalingParameters = options.preScale.scalingParameters; _validateScalingParameters(scalingParameters); - // When scaling parameters contain non-integer values (e.g. rescaleSlope=0.001 - // for DTI FA maps), the scaled pixel values will be fractional (e.g. 0.013, - // 0.5, 0.999). Force Float32Array to prevent truncation that would occur if - // getPixelDataTypeFromMinMax selects an integer array type — which happens - // when scaled min/max are "integer-like" (e.g. 0.0 and 1.0) because - // Number.isInteger(1.0) === true in JavaScript. - // See: https://github.com/cornerstonejs/cornerstone3D/issues/2706 + // Force Float32Array when scaling parameters are non-integer to avoid + // getPixelDataTypeFromMinMax selecting Uint8Array (Number.isInteger(1.0) + // is true in JS, so scaled min/max can appear integer even when values + // between them are fractional). See #2706. const hasFloatRescale = Object.values(scalingParameters).some( (v) => typeof v === 'number' && !Number.isInteger(v) ); diff --git a/packages/dicomImageLoader/src/imageLoader/setPixelDataType.ts b/packages/dicomImageLoader/src/imageLoader/setPixelDataType.ts index 00636722ed..b3d55a64d6 100644 --- a/packages/dicomImageLoader/src/imageLoader/setPixelDataType.ts +++ b/packages/dicomImageLoader/src/imageLoader/setPixelDataType.ts @@ -8,10 +8,8 @@ import getPixelDataTypeFromMinMax from '../shared/getPixelDataTypeFromMinMax'; * min and max values */ function setPixelDataType(imageFrame) { - // If the pixel data is already Float32Array (e.g. from _handlePreScaleSetup - // forcing Float32 for non-integer rescale slopes), skip re-typing to prevent - // getPixelDataTypeFromMinMax from downgrading it to Uint8Array. - // See: https://github.com/cornerstonejs/cornerstone3D/issues/2706 + // Skip re-typing if already Float32Array to prevent downgrading to + // Uint8Array via getPixelDataTypeFromMinMax. See #2706. if (imageFrame.pixelData instanceof Float32Array) { return; }