Skip to content

fix(NrrdReader): handle non-spatial axes in 4-D NRRD files#8895

Open
AlexanderSanin wants to merge 1 commit into
Project-MONAI:devfrom
AlexanderSanin:fix/nrrd-reader-4d-channel-affine
Open

fix(NrrdReader): handle non-spatial axes in 4-D NRRD files#8895
AlexanderSanin wants to merge 1 commit into
Project-MONAI:devfrom
AlexanderSanin:fix/nrrd-reader-4d-channel-affine

Conversation

@AlexanderSanin
Copy link
Copy Markdown
Contributor

Summary

Fixes #8653NrrdReader crashes with a ValueError when loading 4-D NRRD files that carry a non-spatial (channel) axis, e.g. files produced by 3D Slicer with kinds: list domain domain domain.

Root cause: pynrrd represents a (none) space direction (for non-spatial axes) as a row of NaN values in the space directions array. The previous _get_affine implementation assumed every row is spatial, so it received a (4, 3) direction matrix and tried to assign its (3, 4) transpose into a (4, 3) slice — causing a shape mismatch ValueError.

Changes:

  • NrrdReader._get_affine: filter all-NaN rows (non-spatial axes) before constructing the affine matrix so only genuine spatial directions are used.
  • NrrdReader.get_data: use the kinds header field to auto-detect the channel axis (any kind that is not domain or space), set ORIGINAL_CHANNEL_DIM accordingly, and exclude the channel axis from SPATIAL_SHAPE — consistent with NibabelReader behaviour.
  • NrrdReader._convert_f_to_c_order: also reverse kinds (when present) so the detected channel index stays correct after F→C reordering.
  • tests/data/test_nrrd_reader.py: add TEST_CASE_4D_CHANNEL / test_read_4d_channel covering the corrected behaviour (no crash, correct affine, correct channel dim, correct spatial shape).

Test plan

  • All 11 existing TestNrrdReader tests pass.
  • New test_read_4d_channel_0 exercises a (3, 4, 5, 6) NRRD file with kinds: list domain domain domain and verifies:
    • No ValueError from _get_affine
    • affine is a valid (4, 4) matrix encoding only the 3 spatial axes
    • original_channel_dim == 0
    • spatial_shape == (4, 5, 6) (channel excluded)

@AlexanderSanin
Copy link
Copy Markdown
Contributor Author

Hey @ericspod @garciadias. Could you, please, have a look at this?

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 3, 2026

📝 Walkthrough

Walkthrough

NrrdReader now detects channel axes in 4D NRRD volumes by inspecting the header's kinds field for the first non-spatial axis, updating metadata and spatial shape accordingly. Affine computation filters out non-spatial axes (all-NaN rows in space directions) to ensure correct spatial-only transformation. The kinds array is reversed during Fortran-to-C order conversion to maintain header consistency. A new test validates the complete workflow with a 4D channel-first NRRD volume.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly summarizes the main change: fixing NrrdReader to handle non-spatial axes in 4-D NRRD files, directly addressing the core problem.
Description check ✅ Passed Description is comprehensive, covering root cause, all code changes, and test plan. Includes problem statement, solution details, and verification steps. Follows template structure with types of changes marked.
Linked Issues check ✅ Passed PR fully addresses issue #8653: filters NaN rows in affine computation, detects channel axis via kinds field, excludes channel from spatial_shape, reverses kinds in F→C conversion, and includes comprehensive tests.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing NrrdReader's handling of 4-D NRRD files with non-spatial axes. No out-of-scope modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 and usage tips.

4-D NRRD files that carry a non-spatial axis (e.g. kinds: list domain
domain domain, as produced by 3D Slicer) write the channel direction as
'(none)' in the header.  pynrrd represents this as a row of NaN values
in the 'space directions' array.

The previous _get_affine implementation assumed that every row in
'space directions' is a spatial axis, so it received a (4, 3) direction
matrix and tried to assign its (3, 4) transpose into a (4, 3) slice,
causing a ValueError.

Changes:
- NrrdReader._get_affine: filter out all-NaN rows (non-spatial axes)
  before computing the affine matrix.
- NrrdReader.get_data: use the 'kinds' header field to automatically
  detect the channel axis (anything that is not 'domain' or 'space') and
  set ORIGINAL_CHANNEL_DIM accordingly; SPATIAL_SHAPE is updated to
  exclude the channel axis to be consistent with other readers.
- NrrdReader._convert_f_to_c_order: also reverse the 'kinds' list when
  present so that the channel axis index remains correct after F→C
  reordering.
- test_nrrd_reader.py: add TEST_CASE_4D_CHANNEL and test_read_4d_channel
  to cover the corrected behaviour.

Closes Project-MONAI#8653

Signed-off-by: Oleksandr Sanin <alexaaander.sanin@gmail.com>
@AlexanderSanin AlexanderSanin force-pushed the fix/nrrd-reader-4d-channel-affine branch from 1b93a34 to 352f64f Compare June 3, 2026 09:24
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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.

Inline comments:
In `@monai/data/image_reader.py`:
- Around line 1590-1593: The code currently only filters all-NaN rows from the
direction matrix (variable direction) which breaks when _convert_f_to_c_order
flips axes so the non-spatial axis is an all-NaN column; update the logic to
detect and remove all-NaN columns as well as rows: compute row-valid mask and
column-valid mask using np.all(np.isnan(direction.astype(float)), axis=1) and
axis=0 respectively, then select the non-NaN axes from direction (e.g.,
direction = direction[row_valid, :] or direction = direction[:, col_valid] as
appropriate) so both Fortran-to-C reordered data and the index_order="C" 4-D
channel case produce spatial-only axes before the affine assembly that follows
(refer to variable names direction and valid and the conversion path through
_convert_f_to_c_order).

In `@tests/data/test_nrrd_reader.py`:
- Around line 148-172: Add a C-order variant of the existing
test_read_4d_channel that exercises NrrdReader(index_order="C") so the new
_convert_f_to_c_order logic is covered: duplicate the test setup using
NrrdReader(index_order="C"), call reader.read and reader.get_data, then assert
the returned numpy array shape and dtype, verify the channel axis has been
reordered (original_channel_dim now reflects the new position), check
spatial_shape excludes the channel axis accordingly, and assert the affine
matches the expected 4×4 matrix for the C-order case.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1f03d047-d1fa-4f41-8765-5c91fd307a80

📥 Commits

Reviewing files that changed from the base of the PR and between 013bda0 and 352f64f.

📒 Files selected for processing (2)
  • monai/data/image_reader.py
  • tests/data/test_nrrd_reader.py

Comment on lines +1590 to +1593
# pynrrd represents non-spatial axes (e.g. 'list' kind in 4-D NRRD files) as rows
# where every element is NaN. Filter them out so the affine only encodes spatial axes.
valid = ~np.all(np.isnan(direction.astype(float)), axis=1)
direction = direction[valid]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle the C-order NaN axis too.

After _convert_f_to_c_order, a 4x3 space directions array becomes 3x4, so the non-spatial axis is an all-NaN column, not a row. Line 1592 only filters rows, which means index_order="C" still reaches a shape mismatch at Line 1598 for the new 4-D channel case.

Suggested fix
-        valid = ~np.all(np.isnan(direction.astype(float)), axis=1)
-        direction = direction[valid]
+        direction = np.asarray(direction, dtype=float)
+        row_mask = ~np.all(np.isnan(direction), axis=1)
+        col_mask = ~np.all(np.isnan(direction), axis=0)
+        direction = direction[row_mask][:, col_mask]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# pynrrd represents non-spatial axes (e.g. 'list' kind in 4-D NRRD files) as rows
# where every element is NaN. Filter them out so the affine only encodes spatial axes.
valid = ~np.all(np.isnan(direction.astype(float)), axis=1)
direction = direction[valid]
# pynrrd represents non-spatial axes (e.g. 'list' kind in 4-D NRRD files) as rows
# where every element is NaN. Filter them out so the affine only encodes spatial axes.
direction = np.asarray(direction, dtype=float)
row_mask = ~np.all(np.isnan(direction), axis=1)
col_mask = ~np.all(np.isnan(direction), axis=0)
direction = direction[row_mask][:, col_mask]
🤖 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 `@monai/data/image_reader.py` around lines 1590 - 1593, The code currently only
filters all-NaN rows from the direction matrix (variable direction) which breaks
when _convert_f_to_c_order flips axes so the non-spatial axis is an all-NaN
column; update the logic to detect and remove all-NaN columns as well as rows:
compute row-valid mask and column-valid mask using
np.all(np.isnan(direction.astype(float)), axis=1) and axis=0 respectively, then
select the non-NaN axes from direction (e.g., direction = direction[row_valid,
:] or direction = direction[:, col_valid] as appropriate) so both Fortran-to-C
reordered data and the index_order="C" 4-D channel case produce spatial-only
axes before the affine assembly that follows (refer to variable names direction
and valid and the conversion path through _convert_f_to_c_order).

Comment on lines +148 to +172
@parameterized.expand([TEST_CASE_4D_CHANNEL])
def test_read_4d_channel(self, data_shape, filename, dtype, reference_header):
"""4-D NRRD with a 'list' channel axis must not crash in _get_affine and must
set ORIGINAL_CHANNEL_DIM / spatial_shape correctly."""
test_image = np.random.rand(*data_shape).astype(dtype)
with tempfile.TemporaryDirectory() as tempdir:
filepath = os.path.join(tempdir, filename)
nrrd.write(filepath, test_image, header=reference_header)
reader = NrrdReader()
image_array, image_header = reader.get_data(reader.read(filepath))
self.assertIsInstance(image_array, np.ndarray)
self.assertEqual(image_array.dtype, dtype)
self.assertTupleEqual(image_array.shape, data_shape)
# spatial_shape must exclude the channel axis
self.assertTupleEqual(tuple(image_header["spatial_shape"]), data_shape[1:])
# channel dim 0 must be identified
self.assertEqual(image_header["original_channel_dim"], 0)
# affine must be a valid 4×4 matrix (3 spatial dims → 4×4)
self.assertTupleEqual(image_header["affine"].shape, (4, 4))
np.testing.assert_allclose(
image_header["affine"],
np.array(
[[-1.0, 0.0, 0.0, -10.0], [0.0, -2.0, 0.0, -20.0], [0.0, 0.0, 3.0, 30.0], [0.0, 0.0, 0.0, 1.0]]
),
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add the C-order variant of this regression test.

This only covers the default F-order path, but the PR also changes _convert_f_to_c_order. Please add the same fixture with NrrdReader(index_order="C") and assert the reordered channel axis, spatial_shape, and affine. As per coding guidelines, "Ensure new or modified definitions will be covered by existing or new unit tests."

🧰 Tools
🪛 Ruff (0.15.15)

[warning] 165-165: Comment contains ambiguous × (MULTIPLICATION SIGN). Did you mean x (LATIN SMALL LETTER X)?

(RUF003)


[warning] 165-165: Comment contains ambiguous × (MULTIPLICATION SIGN). Did you mean x (LATIN SMALL LETTER X)?

(RUF003)

🤖 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 `@tests/data/test_nrrd_reader.py` around lines 148 - 172, Add a C-order variant
of the existing test_read_4d_channel that exercises NrrdReader(index_order="C")
so the new _convert_f_to_c_order logic is covered: duplicate the test setup
using NrrdReader(index_order="C"), call reader.read and reader.get_data, then
assert the returned numpy array shape and dtype, verify the channel axis has
been reordered (original_channel_dim now reflects the new position), check
spatial_shape excludes the channel axis accordingly, and assert the affine
matches the expected 4×4 matrix for the C-order case.

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.

NRRD Reader for 4D images

2 participants