Skip to content

fix(dicom-image-loader): use Float32Array for non-integer rescale slopes to prevent pixel data corruption - #2707

Open
eugenest2557 wants to merge 6 commits into
cornerstonejs:mainfrom
eugenest2557:fix/float-rescale-slope-pixel-data-corruption
Open

fix(dicom-image-loader): use Float32Array for non-integer rescale slopes to prevent pixel data corruption#2707
eugenest2557 wants to merge 6 commits into
cornerstonejs:mainfrom
eugenest2557:fix/float-rescale-slope-pixel-data-corruption

Conversation

@eugenest2557

@eugenest2557 eugenest2557 commented Apr 21, 2026

Copy link
Copy Markdown

Context

Fixes #2706

DICOM images with non-integer RescaleSlope (e.g. DTI FA maps with RescaleSlope=0.001) render as completely black in StackViewport. These images display correctly in other viewers such as RadiAnt and OsiriX.

The legacy Cornerstone had a similar issue (cornerstonejs/cornerstone#302, fixed in PR #303).

Two independent bugs are involved:

  1. getPixelDataTypeFromMinMax(0.0, 1.0) selects Uint8Array because Number.isInteger(1.0) === true in JavaScript. All fractional scaled values (0.013, 0.5, 0.999) are truncated to 0. This function is called twice (web worker + main thread setPixelDataType), destroying the data at both stages.

  2. toLowHighRange with WindowWidth=1 produces lower === upper === 0 via the LINEAR formula (WW-1) = 0, causing division by zero in the VOI shader and a binary black/white image.

Changes & Results

  1. _handlePreScaleSetup in decodeImageFrameWorker.js: Detect non-integer scaling parameters and force Float32Array before getPixelDataTypeFromMinMax is called.

  2. setPixelDataType in setPixelDataType.ts: Skip re-typing if pixelData is already Float32Array, preventing the main thread from downgrading it to Uint8Array.

  3. toLowHighRange in windowLevel.ts: When WindowWidth <= 1 with LINEAR function, fall back to LINEAR_EXACT (lower = WC - WW/2, upper = WC + WW/2) to produce a valid range.

All three fixes are needed — removing any one results in either a black screen or a binary black/white image.

Testing

  • Load a DTI FA DICOM with RescaleSlope=0.001 in StackViewport — should display grayscale instead of black
  • Load a standard CT DICOM (integer RescaleSlope=1) — should be unaffected
  • Load a DICOM with WindowWidth > 1toLowHighRange should use existing LINEAR formula unchanged

Checklist

PR

  • My Pull Request title is descriptive, accurate and follows the
    semantic-release format and guidelines.

Code

  • My code has been well-documented (function documentation, inline comments,
    etc.)

Public Documentation Updates

  • The documentation page has been updated as necessary for any public API
    additions or removals.

Tested Environment

  • OS: macOS
  • Node version: 20
  • Browser: Chrome

I may be wrong about some of the details — please correct me if I've misunderstood anything. Any feedback would be greatly appreciated. Thank you!

Summary by CodeRabbit

  • New Features

    • Added support for an additional dose-based scaling option for RTDOSE images.
    • Improved scaling detection so image data is handled more accurately across PET, RTDOSE, and standard rescale cases.
  • Bug Fixes

    • Fixed pixel type handling for cases where scaling produces non-integer values, reducing unexpected truncation.
    • Improved rendering of very small window widths for certain display settings.
    • Prevented already floating-point pixel data from being downgraded during processing.

…pes 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: cornerstonejs#2706

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
eugene25507 and others added 2 commits April 21, 2026 21:40
… 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: cornerstonejs#2706

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@eugenest2557 eugenest2557 reopened this Apr 21, 2026

@sedghi sedghi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The three-part fix correctly addresses the reported StackViewport corruption for non-integer RescaleSlope by forcing a Float32Array in the pre-scale path, preventing the main thread from downgrading it, and avoiding a division by zero when WW=1 where lower equals upper. This core scenario works because comlink structured-clone preserves the Float32Array across the worker boundary, meaning the instanceof guard in setPixelDataType works as expected. The remaining issues are about incomplete coverage in adjacent paths rather than the main flow.

minor packages/dicomImageLoader/src/decodeImageFrameWorker.js:93
The Float32 fix only runs in _handlePreScaleSetup. When options.targetBuffer.type is provided for volume loading, postProcessDecodedPixels takes the _handleTargetBuffer branch instead. Here, validatePixelDataType(scaledMin, scaledMax, Uint8Array) returns true for a scaled range like [0,1] because both 0 and 1 are integers. This leaves invalidType as false, so the data is copied into a Uint8Array target and truncates the fractional values. As a result, loading the same DTI FA map into a volume viewport still causes corruption.

minor packages/dicomImageLoader/src/decodeImageFrameWorker.js:249
hasFloatRescale is recomputed inside _handlePreScaleSetup using the exact same Object.values(...).some(...) expression already computed at lines 79-83 in the caller, which was not passed down. This causes a redundant scan of the scaling parameters.

minor packages/core/src/utilities/windowLevel.ts:61
The WW<=1 guard only rewrites voiLUTFunction when it is LINEAR. However, SAMPLED_SIGMOID goes through the identical LINEAR-formula branch at lines 64-67 and will still produce a division by zero where lower equals upper when WW<=1. The guard should also cover SAMPLED_SIGMOID to match the branch it protects.

nit packages/dicomImageLoader/src/decodeImageFrameWorker.js:250
hasFloatRescale triggers on any non-integer numeric scaling parameter, including ones not actually used by _calculateScaledMinMax for the current modality, such as a stray suvbw on a non-PT image. This can unnecessarily force a Float32Array and double the memory use compared to Uint16 for 16-bit data that did not need it.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR fixes float-scaled DICOM pixel data (e.g., non-integer RescaleSlope, DTI FA maps) rendering incorrectly as truncated integers. It adds doseGridScaling to ScalingParameters, introduces shared getScalingMode utilities in core and dicomImageLoader, updates float-detection and data-type selection logic accordingly, forces float intermediate buffers during pre-scaling when needed, prevents re-typing of Float32Array pixel data, and adjusts a VOI LUT window-width edge case.

Changes

Float scaling correctness across core and dicomImageLoader

Layer / File(s) Summary
ScalingParameters contract
packages/core/src/types/ScalingParameters.ts
Adds optional doseGridScaling field for RTDOSE modality.
Core getScalingMode and float detection
packages/core/src/utilities/getScalingMode.ts, packages/core/src/utilities/hasFloatScalingParameters.ts, packages/core/src/utilities/index.ts
New getScalingMode/ScalingMode export classifies PT_SUV/RTDOSE/RESCALE/NONE; hasFloatScalingParameters now checks only mode-relevant parameters instead of scanning all values.
Core volume data type selection
packages/core/src/utilities/generateVolumePropsFromImageIds.ts
_determineDataType returns Float32Array for BitsAllocated===8 when float rendering is supported and scaling yields non-integer values.
dicomImageLoader getScalingMode and scaleArray refactor
packages/dicomImageLoader/src/shared/scaling/getScalingMode.ts, packages/dicomImageLoader/src/shared/scaling/scaleArray.ts
Adds parallel getScalingMode/hasNonIntegerScaling utilities; scaleArray switches on scaling mode instead of manual modality/type checks.
Worker pre-scale float buffer handling
packages/dicomImageLoader/src/decodeImageFrameWorker.js
Uses hasNonIntegerScaling to force a Float32Array intermediate buffer during pre-scale setup, refactors _calculateScaledMinMax to switch on scaling mode, and updates target-type validity checks for non-integer rescale into float/integer targets.
setPixelDataType float short-circuit
packages/dicomImageLoader/src/imageLoader/setPixelDataType.ts
Skips re-typing when pixelData is already Float32Array.
VOI LUT window width edge case
packages/core/src/utilities/windowLevel.ts
Forces LINEAR_EXACT when windowWidth <= 1 for LINEAR/SAMPLED_SIGMOID VOI LUT functions.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant decodeImageFrameWorker
  participant getScalingMode
  participant hasNonIntegerScaling
  participant PreScaleSetup as _handlePreScaleSetup
  participant setPixelDataType

  decodeImageFrameWorker->>getScalingMode: scalingParameters
  getScalingMode-->>decodeImageFrameWorker: ScalingMode
  decodeImageFrameWorker->>hasNonIntegerScaling: scalingParameters
  hasNonIntegerScaling-->>decodeImageFrameWorker: hasFloatRescale
  decodeImageFrameWorker->>PreScaleSetup: pixelData, hasFloatRescale
  alt hasFloatRescale is true
    PreScaleSetup->>PreScaleSetup: allocate Float32Array intermediate buffer
  else integer scaling
    PreScaleSetup->>PreScaleSetup: keep original typed array
  end
  PreScaleSetup-->>decodeImageFrameWorker: scaled pixelData
  decodeImageFrameWorker->>setPixelDataType: pixelData
  alt pixelData is Float32Array
    setPixelDataType-->>decodeImageFrameWorker: skip re-typing
  else other type
    setPixelDataType->>setPixelDataType: getPixelDataTypeFromMinMax
    setPixelDataType-->>decodeImageFrameWorker: selected typed array
  end
Loading

Possibly related PRs

  • cornerstonejs/cornerstone3D#2767: Both PRs modify handling of modality-specific scaling parameters (PT suvbw, RTDOSE doseGridScaling) in scaling-related detection logic.

Suggested reviewers: sedghi

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is specific, matches the main fix, and follows the semantic-release style.
Description check ✅ Passed The PR description includes Context, Changes & Results, Testing, and a complete checklist.
Linked Issues check ✅ Passed The code addresses the black-rendering bug by preserving float scaling and fixing the WindowWidth=1 VOI path.
Out of Scope Changes check ✅ Passed The added scaling utilities and type changes support the reported fix and do not appear unrelated.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/core/src/utilities/windowLevel.ts (1)

61-69: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Guard logic is correct; comment is slightly imprecise.

The fix correctly falls back to LINEAR_EXACT when windowWidth <= 1, avoiding both the degenerate range (WW=1 → lower===upper) and the inverted range (WW<1 → upper<lower) that the LINEAR formula produces. The downstream shader receives these bounds, so preventing a zero-width range here eliminates the division-by-zero.

One minor note: the comment says "WW <= 1 makes (WW-1) = 0," but (WW-1) is only zero when WW is exactly 1. For WW < 1, the problem is an inverted range (upper < lower), not a zero-width one. Consider refining the comment for accuracy:

📝 Suggested comment refinement
-  // WW <= 1 makes (WW-1) = 0, so LINEAR/SAMPLED_SIGMOID produce lower === upper
-  // (division by zero). Fall back to LINEAR_EXACT. See `#2706`.
+  // WW <= 1 makes LINEAR/SAMPLED_SIGMOID produce a degenerate (WW=1: lower === upper)
+  // or inverted (WW<1: upper < lower) range, causing division by zero downstream.
+  // Fall back to LINEAR_EXACT. See `#2706`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/utilities/windowLevel.ts` around lines 61 - 69, Refine the
explanatory comment in windowLevel logic so it matches the actual behavior: in
the window-level fallback branch in windowLevel.ts, keep the existing
`voiLUTFunction` guard and `LINEAR_EXACT` fallback, but update the note to
distinguish WW = 1 (zero-width range from WW-1 = 0) from WW < 1 (inverted
lower/upper bounds). Refer to `VOILUTFunctionType.LINEAR`,
`VOILUTFunctionType.SAMPLED_SIGMOID`, and `VOILUTFunctionType.LINEAR_EXACT` when
revising the comment so the rationale stays accurate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/core/src/utilities/windowLevel.ts`:
- Around line 61-69: Refine the explanatory comment in windowLevel logic so it
matches the actual behavior: in the window-level fallback branch in
windowLevel.ts, keep the existing `voiLUTFunction` guard and `LINEAR_EXACT`
fallback, but update the note to distinguish WW = 1 (zero-width range from WW-1
= 0) from WW < 1 (inverted lower/upper bounds). Refer to
`VOILUTFunctionType.LINEAR`, `VOILUTFunctionType.SAMPLED_SIGMOID`, and
`VOILUTFunctionType.LINEAR_EXACT` when revising the comment so the rationale
stays accurate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2dcb4df7-d657-46ab-87e6-9fef982a2275

📥 Commits

Reviewing files that changed from the base of the PR and between e382644 and b54318d.

📒 Files selected for processing (10)
  • packages/core/src/types/ScalingParameters.ts
  • packages/core/src/utilities/generateVolumePropsFromImageIds.ts
  • packages/core/src/utilities/getScalingMode.ts
  • packages/core/src/utilities/hasFloatScalingParameters.ts
  • packages/core/src/utilities/index.ts
  • packages/core/src/utilities/windowLevel.ts
  • packages/dicomImageLoader/src/decodeImageFrameWorker.js
  • packages/dicomImageLoader/src/imageLoader/setPixelDataType.ts
  • packages/dicomImageLoader/src/shared/scaling/getScalingMode.ts
  • packages/dicomImageLoader/src/shared/scaling/scaleArray.ts

@eugenest2557

eugenest2557 commented Jul 8, 2026

Copy link
Copy Markdown
Author

Thank you very much for reviewing this so carefully, and for laying out each point so clearly — it was really helpful. I've addressed all four:

  • 1 (volume truncation): You're right that the volume path was still affected. The target-buffer branch in the worker now also forces Float32 for non-integer scaling (hasFloatRescale && !isFloatTarget in the invalidType check), and generateVolumePropsFromImageIds now returns Float32Array for the 8-bit case when floatAfterScale is true (the 16-bit case already did), so the DTI FA map no longer truncates in a volume viewport.
  • 2 (redundant scan): Fixed — hasFloatRescale is now computed once in postProcessDecodedPixels and passed into _handlePreScaleSetup, so the duplicate scan is gone.
  • 3 (SAMPLED_SIGMOID): You're right, thank you — the WW<=1 guard now also rewrites SAMPLED_SIGMOID to LINEAR_EXACT, matching the branch it protects.
  • 4 (over-triggering): Thank you for pointing this out. Float detection is now modality-aware and only inspects the parameters actually applied for the current modality, so a stray non-integer suvbw on a non-PT image (or doseGridScaling on a non-RTDOSE image) no longer forces Float32.

While addressing these I also consolidated the modality branch selection into a single getScalingMode helper shared by scaleArray and _calculateScaledMinMax, and reused it in hasFloatScalingParameters so the volume path gets the same modality-aware behavior.

Please let me know if anything still looks off — happy to make further changes. Thank you again for the thorough review!

@eugenest2557
eugenest2557 requested a review from sedghi July 8, 2026 17:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] DICOM images with float RescaleSlope (e.g. DTI FA maps) render as all black in StackViewport

3 participants