Skip to content

fix(cs3d): render thumbnails for images with a VOI LUT Sequence - #140

Merged
wayfarer3130 merged 3 commits into
masterfrom
fix/thumbnail-voi-lut-sequence
Oct 14, 2026
Merged

wayfarer3130 merged 3 commits into
masterfrom
fix/thumbnail-voi-lut-sequence

Conversation

@rleisti

@rleisti rleisti commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The server makes no thumbnail for an image that carries a VOI LUT Sequence (0028,3010), and /thumbnail answers 404. createImage handed the naturalized sequence item to the cornerstone CPU renderer as-is. The renderer reads a LUT as { firstValueMapped, lut } and spreads lut, so getRenderedBuffer threw, logged the error, and wrote nothing. A Modality LUT Sequence failed the same way.

This PR converts the sequence items into the renderer's LUT shape, and works around two renderer behaviours that the conversion makes reachable.

Changes

packages/cs3d/src/image/createImage.ts:

  • The LUT Descriptor and LUT Data are read from InlineBinary or from decoded values. LUT Data that is only a BulkDataURI cannot be read here, so that LUT is dropped and the window center/width (or, for a Modality LUT, the rescale) applies.
  • LUT entries are unsigned (US/OW). The first value mapped is signed when the values that index the LUT are signed: the pixel representation for a Modality LUT, and for a VOI LUT the modality output. For the modality output, an applied Modality LUT decides first (the renderer uses it in place of the rescale, and its entries are unsigned); then CT, then a rescale whose lowest output is negative, then the pixel representation, as in dicom-image-loader's getModalityLUTOutputPixelRepresentation. A Modality LUT Sequence that the code does not apply (XA, XRF, or an item it cannot read) does not count.
  • A descriptor entry count of 0 means 65536 entries.
  • 8 bit entries are read one per byte or one per word. The data length tells the two layouts apart.
  • The renderer clamps a Modality LUT at firstValueMapped, but then reads lut[storedValue] without subtracting firstValueMapped. A Modality LUT that starts above 0 is padded at the front with its first entry, and starts at 0. A Modality LUT that starts below 0 is left as is and still renders wrong; such LUTs are rare.
  • The renderer looks the VOI LUT up by the modality output without rounding, so a fractional rescale reads between entries. With no Modality LUT and a slope or intercept that is not an integer, the VOI LUT is skipped and the window renders the image.

Where this differs from dicom-image-loader's getLUTs, it follows PS3.3 C.11.1/C.11.2: getLUTs reads LUT Data as signed for signed pixel data, and reads a count of 0 as 65535 entries.

Behaviour for users

  • An image with a VOI LUT Sequence gets a thumbnail, rendered through its VOI LUT. Before, it got none.
  • An image with a Modality LUT Sequence gets a thumbnail. Before, it got none.
  • An image without LUT sequences renders as before. The new code runs only when a sequence is present.

Tests

packages/cs3d/test/voiLut.jest.js (43 tests):

  • The conversion: descriptor and data as InlineBinary and as decoded values, a single decoded value, unsigned entries on signed pixels, a count of 0 (16 bit and 8 bit), data longer than the count, the three 8 bit layouts, and the fallback for a short descriptor, short data and a BulkDataURI.
  • The signedness of the first value mapped: one row for each part of the rule, including empty Rescale elements, an empty Modality LUT Sequence, a Modality LUT that is not applied, an applied Modality LUT ahead of CT and of a negative rescale, and a decoded negative descriptor value.
  • The Modality LUT padding, and the VOI LUT skip for a fractional slope or intercept.
  • Two render tests through getRenderedBuffer that check the colour of a rendered pixel: one through a VOI LUT, one through a Modality LUT that starts above 0.

Each test fails against the code before this change. A mutation check of 30 single-line changes to the conversion code fails at least one test each.

Manual check: the 1.7.6 server image with the patched createImage.ts, and the two instances of the CR study stored by STOW, answers the series and instance thumbnails with 200. The series thumbnail shows the wrist with the correct MONOCHROME1 polarity. Without the patch, the same setup answers 404.

createImage handed the cornerstone renderer the naturalized Modality/VOI LUT
Sequence item as-is. The renderer reads a LUT as { firstValueMapped, lut } and
spreads `lut`, so every image carrying a VOI LUT Sequence threw inside
getRenderedBuffer, no thumbnail was written, and the server answered
/thumbnail with 404 (seen on a JPEG Lossless MONOCHROME1 CR).

Convert the sequence item into the renderer's LUT shape:

- Read the LUT Descriptor and LUT Data from InlineBinary or decoded values.
  LUT Data that is only a BulkDataURI cannot be read here, so that LUT is
  dropped and the window center/width (or rescale) applies instead.
- Keep LUT entries unsigned (US/OW). The first value mapped is signed when
  the values indexing the LUT are: pixel representation for a Modality LUT,
  and for a VOI LUT the modality output, by the same rule as
  dicom-image-loader's getModalityLUTOutputPixelRepresentation.
- A descriptor entry count of 0 means 65536; 8 bit entries are read one per
  byte or one per word, told apart by the data length.

Two renderer behaviours need working around:

- The renderer clamps a Modality LUT at firstValueMapped but reads
  lut[storedValue] without subtracting it, so a Modality LUT starting above 0
  is padded at the front with its first entry and moved to start at 0.
- The renderer looks the VOI LUT up by the modality output without rounding,
  so with no Modality LUT and a fractional rescale the VOI LUT is skipped and
  the window renders the image.
@rleisti
rleisti requested a review from wayfarer3130 October 13, 2026 16:36

@wayfarer3130 wayfarer3130 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I merged origin/master (v1.7.9) into this branch. The merge had no conflicts, and the 46 tests of @radicalimaging/cs3d pass after the merge.

The conversion is correct. I compared it with getVOILut and getModalityLut of the CPU renderer in @cornerstonejs/core 5.11.5. The padding and the rule for the signed first value mapped agree with the two renderer functions.

Please change: the length of the comments

An inline comment must stay short, with one or two lines. Put a longer explanation in a JSDoc comment of the function. A JSDoc comment is shared with the editor tools and with the generated documentation.

In packages/cs3d/src/image/createImage.ts:

  • Lines 7-9, 31-37, 64-66, 78-80: change each // block above a function to a JSDoc comment (/** ... */). Add @param and @returns. The block above toCornerstoneLUT has 7 lines and holds three different facts. Give each fact one short sentence in the JSDoc.
  • Lines 89-90, in isModalityOutputSigned: make the comment one line, for example // dcmjs gives null or [] for an empty element; treat it as absent. Move the reason to the JSDoc of the function.
  • Lines 268-270, in createImage: make the comment one line, for example // Skip the VOI LUT for a fractional rescale; see modalityOutputIsInteger. Move the reason to a JSDoc comment. One way is a small function isModalityOutputInteger(image, slope, intercept) with that JSDoc.

Minor: a Modality LUT that the code does not apply

isModalityOutputSigned returns false when ModalityLUTSequence has an item. createImage does not always apply that item:

  • imageFrameUtils.is.modalityLut returns false for XA and XRF.
  • toCornerstoneLUT returns undefined for an item that it cannot read, for example a short descriptor or a BulkDataURI.

In these cases, the renderer uses slope 1 and intercept 0. With signed pixels, the modality output is signed, but the VOI LUT reads its first value mapped as unsigned. A fix: give isModalityOutputSigned the result of the conversion (image.modalityLUT), and not the raw sequence. This case is rare, so a follow-up PR is also acceptable.

Accepted as is

  • The VOI LUT has priority over the window center and width. This behaviour is correct for a thumbnail.
  • A Modality LUT that starts below 0 still renders incorrectly. The PR description states this limit.

isModalityOutputSigned read the raw Modality LUT Sequence, but createImage
does not apply every item: XA and XRF skip it, and an item that cannot be read
gives no LUT. The renderer then uses the rescale, so with signed pixels the VOI
LUT was indexed by signed values while its first value mapped was read as
unsigned. The function now takes the Modality LUT that the image applies, and
checks it first, because the renderer uses it in place of the rescale and its
entries are unsigned.

Also turn the long // blocks above the LUT helpers into JSDoc comments, and
move the fractional-rescale check into isModalityOutputInteger.
@rleisti

rleisti commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Both points are done in this PR, in f1ae03d.

Comment length

  • The // blocks above getLUTBytes, toCornerstoneLUT, startModalityLUTAtZero and isModalityOutputSigned are now JSDoc comments with @param and @returns. The JSDoc of toCornerstoneLUT gives each of the three facts one sentence.
  • The empty-element handling in isModalityOutputSigned is now a small function decimal(value), with the reason in its JSDoc.
  • In createImage, the VOI LUT comment is one line: // VOI LUT; skipped for a fractional rescale, see isModalityOutputInteger. The reason is in the JSDoc of the new function isModalityOutputInteger(modalityLUT, slope, intercept).
  • Two comments of 3 lines in createImage remain (the target buffer and the Float32 conversion). They are older than this PR, so I did not change them.

A Modality LUT that the code does not apply

  • isModalityOutputSigned now takes image.modalityLUT, the Modality LUT that the image applies, and not the raw sequence. XA, XRF, and an item that cannot be read now fall through to the rescale and pixel representation rules.
  • The function checks the applied Modality LUT first, before the CT and rescale rules. The renderer uses an applied Modality LUT in place of the rescale (getModalityLut), and the entries of the LUT are unsigned, so the VOI LUT is then indexed by unsigned values. getModalityLUTOutputPixelRepresentation checks the sequence last, but it decides for the loader, not for this renderer.
  • New rows in the signedness table: XA with a Modality LUT Sequence on signed pixels, a Modality LUT that cannot be read on signed pixels, an applied Modality LUT on CT, and an applied Modality LUT with a negative rescale. These 4 rows fail with the code before f1ae03d.

Checks

  • The 50 tests of @radicalimaging/cs3d pass.
  • A mutation check of 30 single-line changes to the conversion code fails at least one test each.
  • Prettier and tsc are clean.
  • The 1.7.6 server image with this createImage.ts, and the CR study of the Monday item, still answers the series and instance thumbnails with 200.

I will also update the PR description for the new rule order and test count.

@rleisti
rleisti requested a review from wayfarer3130 October 13, 2026 18:20
@wayfarer3130
wayfarer3130 merged commit 27f2573 into master Oct 14, 2026
5 checks passed
@wayfarer3130
wayfarer3130 deleted the fix/thumbnail-voi-lut-sequence branch October 14, 2026 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants