Skip to content

fix(dicom): match the volume buffer to the decoded pixel values - #934

Open
PaulHax wants to merge 1 commit into
mainfrom
dicom-volume-buffer
Open

fix(dicom): match the volume buffer to the decoded pixel values#934
PaulHax wants to merge 1 commit into
mainfrom
dicom-volume-buffer

Conversation

@PaulHax

@PaulHax PaulHax commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Problem

The volume buffer's element type was chosen once, from the first instance:
allocateImageFromChunks took its stored range, put it through RescaleSlope
and RescaleIntercept, floored and ceiled the endpoints, and picked the
narrowest typed array that holds the result. ITK/GDCM applies the rescale
while decoding, so a chunk arrives already in output units, and ITK picks the
output scalar type from the rescale: an integral slope and intercept keep an
integer type, anything fractional produces float64.

That allocation went wrong three ways, and none of them raised anything:

  • A fractional rescale whose endpoints happened to land on integers got an
    integer buffer, and every fraction was dropped on write. This was reproduced
    with a DSC perfusion series from a public archive whose slope is
    112067.85375182 over 16-bit unsigned storage. ITK returns float64; the
    allocator chose an integer buffer and truncated every voxel.
  • A large integral slope pushed the range past the 32-bit types vtk.js has,
    which threw at allocation and sank the whole series.
  • A later instance could decode to a range outside the first instance's type.
    Every instance decodes to the same number of elements whatever its BitsStored
    or rescale, so TypedArray.prototype.set copied it into its slot and
    converted silently: negative values in an unsigned buffer wrap to huge
    positives, 16-bit values in a Uint8Array keep only their low byte. The
    slice looks plausible enough to miss.

Change

Allocation follows ITK

getVolumeBufferType in allocateImageFromChunks.ts now decides the way ITK
does, for the union of every instance's declared range rather than the first
instance's:

  • A fractional RescaleSlope or RescaleIntercept allocates Float64Array, even
    when both transformed endpoints are integers.
  • An integral range whose endpoints are not safe integers, or that exceeds the
    signed or unsigned 32-bit range, allocates Float64Array instead of
    throwing at allocation.
  • Ordinary integral ranges keep the narrowest integer type as before, so
    homogeneous CT and MR allocation is unchanged.
  • getRescaledValueRange returns the exact transformed endpoints. The floor
    and ceil that hid fractional endpoints are gone, and a negative slope orders
    them.

Allocating for the union means a series that mixes bit depths, or carries a
per-instance rescale, gets a buffer every declared instance fits, so no
well-formed file is rejected for its tags. An instance whose tags give no
finite range (BitsStored absent or unparseable, or a rescale that overflows)
is left out of the union and judged on its decoded values when it decodes; the
allocator throws only when no instance is usable.

The write is guarded

dicomChunkImage.ts checks each chunk after it decodes and immediately before
pixelData.set. The per-component ranges it already computed for the scalar
range update move above the write and are reused, so the chunk's actual min
and max cost nothing extra. Two checks, each with its own diagnostic naming
the file, the values it decoded to, and the buffer it has to fit:

  • valuesFitBuffer rejects a range outside the buffer's element type. The
    bound is read off the buffer itself, getBufferValueRange(pixelData), not
    re-derived from metadata.
  • samplesAreIntegral rejects float decoder output with fractional samples
    bound for an integer buffer. It scans samples only when the decoder returned
    a float or plain array, so ordinary integer data still costs one min/max
    pass.

Because the buffer is allocated for what every instance declares, these
checks can only fail when decoder output disagrees with the tags. They are a
guard against that disagreement, not a rule about which series load.

Supporting changes:

  • getPixelFormat, getRescaledValueRange, and getVolumeBufferType replace
    the private getTypedArrayConstructor.
  • numberOr treats a zero-length or whitespace-only element as absent.
    Previously Number('') is 0, so an empty RescaleSlope read as slope 0 and
    collapsed the range to the intercept.
  • DicomChunkImage gains an optional constructor seam for splitAndSort and
    the ITK read, defaulting to the real collaborators, so unit tests can drive
    the volume path with a chosen decoder.
  • tests/specs/syntheticDicom.ts gains pixel-format knobs (bitsAllocated,
    bitsStored, highBit, pixelRepresentation, rescaleSlope,
    rescaleIntercept) and a pixelValue written to every sample, defaulting to
    0, which is byte for byte what the helper emitted before. It pads 8-bit
    pixel data to an even length, since DICOM element values must be even, and
    rejects a bitsAllocated other than 8 or 16 instead of emitting a 32-bit OW
    element no real writer would produce.

Costs

  • A series mixing a 12-bit instance with intercept -1024 and instances
    declaring BitsStored 16 allocated Int16Array from its first instance and
    now allocates Int32Array for the union, doubling memory even though every
    sample fits 16 bits. Narrowing to the decoded range would need reallocation
    at decode time.
  • A fractional rescale allocates 8 bytes per voxel where main allocated 2.
    vtk.js converts the array to float32 for texture upload.
  • The past-32-bit integral case no longer throws at allocation. Whether GDCM
    can decode such an instance at all is a separate question; if it cannot,
    that chunk fails on its own instead of sinking the volume.

Known gaps

Chunks already marked Errored are not re-evaluated. A second addChunks
reallocates the volume, possibly wider, but a chunk that failed earlier keeps
its Errored status. This is the pre-existing pattern for every chunk error.

The guard runs per chunk at decode time. Flagging an inhomogeneous series
at grouping time would be the better place, and is a larger change.

Tests

  • allocateImageFromChunks.spec.ts: pixel format parsing, rescaled range
    ordering, the DSC slope, slope 0.2 with integral endpoints, an integral
    range past 32 bits, slope -1 ordering, a mixed union, a non-finite range, an
    unusable instance left out of the union, buffer range lookups, and both
    guard predicates.
  • dicomChunkImage.spec.ts: a volume built from the DSC slope and an observed
    stored maximum of 65131 keeps its Float64Array and exact values; a decoded
    chunk outside its buffer's range, and one with fractional samples bound for
    an integer buffer, are each rejected with their own diagnostic while the
    image still settles.
  • dicom-modality-rescale.e2e.ts: three generated 16-bit files with the DSC
    slope load in Chrome with no notification; the cached volume's scalars are
    Float64Array with exact rescaled values. It reads the image cache through
    the helper the DICOM and cine spacing tests already use. The window/level
    range shown in the UI is not an alternative: it is set from the decoded ITK
    values, not read back from the buffer, so it would show the same range over
    a wrapped integer buffer.

The volume buffer's element type was derived from the first instance's
stored range put through its rescale, floored and ceiled to integers. ITK
applies the rescale while decoding and picks the output type from it: an
integral slope and intercept keep an integer type, anything fractional
produces float64. A fractional rescale whose endpoints landed on integers
got an integer buffer, and every fraction was dropped on write. An integral
range past 32 bits threw at allocation. A later instance decoding outside
the first instance's type wrote through TypedArray.set with no error,
wrapping every value the type cannot hold.

Allocate for the union of every instance's declared range, choosing
Float64Array whenever the slope or intercept is fractional or the range is
not a safe 32-bit integer range, and the narrowest integer type otherwise.
Ordinary CT and MR allocation is unchanged. An instance whose tags give no
finite range is left out of the union and judged on its decoded values.

Check each chunk after it decodes and before it is written. Its actual
range must fit the buffer's element type, and float output bound for an
integer buffer must be whole numbers. Both rejections name the file, the
values it decoded to, and the buffer it has to fit. Only decoder output
that disagrees with the tags can fail these checks.

Reproduced with a public DSC perfusion series whose slope is
112067.85375182 over 16-bit unsigned storage.
@netlify

netlify Bot commented Sep 2, 2026

Copy link
Copy Markdown

Deploy Preview for volview-dev ready!

Name Link
🔨 Latest commit 12b54f7
🔍 Latest deploy log https://app.netlify.com/projects/volview-dev/deploys/6a9797ecf7e5b5000829f424
😎 Deploy Preview https://deploy-preview-934--volview-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

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.

1 participant