From 225c9a098e6883423bc1d89db4fae9599e803a2c Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Tue, 9 Jun 2026 13:14:01 -0400 Subject: [PATCH 1/2] Resample lesion mask into in_file's voxel grid. --- niworkflows/interfaces/norm.py | 16 ++++++--- niworkflows/interfaces/tests/test_norm.py | 41 ++++++++++++++++++++++- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/niworkflows/interfaces/norm.py b/niworkflows/interfaces/norm.py index 3f2f3399046..c176e9b26cc 100644 --- a/niworkflows/interfaces/norm.py +++ b/niworkflows/interfaces/norm.py @@ -518,14 +518,16 @@ def create_cfm(in_file, lesion_mask=None, global_mask=True, out_path=None): Notes ----- - in_file and lesion_mask must be in the same - image space and have the same dimensions + The lesion mask is resampled into in_file's voxel grid before being + combined, so the two only need to overlap in world (scanner) space; they + do not need to share the same orientation or dimensions. """ import os import nibabel as nb import numpy as np + from nibabel.processing import resample_from_to from nipype.utils.filemanip import fname_presuffix if out_path is None: @@ -549,11 +551,15 @@ def create_cfm(in_file, lesion_mask=None, global_mask=True, out_path=None): # If a lesion mask was provided, combine it with the secondary mask. if lesion_mask is not None: - # Reorient the lesion mask and get the data. - lm_img = nb.as_closest_canonical(nb.load(lesion_mask)) + # Resample the lesion into in_file's voxel grid so the subtraction is + # spatially correct regardless of the lesion's stored orientation or + # grid. Nearest-neighbor (order=0) keeps the mask binary. + lm_img = resample_from_to( + nb.load(lesion_mask), (data.shape, in_img.affine), order=0 + ) # Subtract lesion mask from secondary mask, set negatives to 0 - data = np.fmax(data - lm_img.dataobj, 0) + data = np.fmax(data - np.asanyarray(lm_img.dataobj), 0) # Cost function mask will be created from subtraction # Otherwise, CFM will be created from global mask diff --git a/niworkflows/interfaces/tests/test_norm.py b/niworkflows/interfaces/tests/test_norm.py index eaeae9b2a8b..b161d581c27 100644 --- a/niworkflows/interfaces/tests/test_norm.py +++ b/niworkflows/interfaces/tests/test_norm.py @@ -20,7 +20,12 @@ # # https://www.nipreps.org/community/licensing/ # -from ..norm import SpatialNormalization +import nibabel as nb +import numpy as np +from nibabel.affines import apply_affine +from nibabel.orientations import axcodes2ornt, io_orientation, ornt_transform + +from ..norm import SpatialNormalization, create_cfm def test_get_settings(): @@ -32,3 +37,37 @@ def test_get_settings(): norm = SpatialNormalization(moving='T1w', flavor='testing') settings = norm._get_settings() assert len(settings) == 3 + + +def test_create_cfm_lesion_orientation(tmp_path): + """A lesion stored in RAS must be excluded at the correct world location + even when in_file is stored in LPS (regression test for the lesion mask + orientation bug).""" + shape = (4, 5, 6) + ras_affine = np.eye(4) + + # in_file: all-ones brain mask, stored in LPS orientation. + ras_img = nb.Nifti1Image(np.ones(shape, dtype=np.uint8), ras_affine) + xfm = ornt_transform(io_orientation(ras_img.affine), axcodes2ornt(('L', 'P', 'S'))) + in_img = ras_img.as_reoriented(xfm) + in_file = str(tmp_path / 'in_lps.nii.gz') + in_img.to_filename(in_file) + + # lesion: single voxel in RAS at world coordinate (1, 2, 3). + lesion_data = np.zeros(shape, dtype=np.uint8) + lesion_data[1, 2, 3] = 1 + lesion_file = str(tmp_path / 'lesion_ras.nii.gz') + nb.Nifti1Image(lesion_data, ras_affine).to_filename(lesion_file) + + out = create_cfm( + in_file, + lesion_mask=lesion_file, + global_mask=True, + out_path=str(tmp_path / 'cfm.nii.gz'), + ) + + cfm = nb.load(out) + zeros = np.argwhere(np.asanyarray(cfm.dataobj) == 0) + # Exactly one voxel excluded, at world coordinate (1, 2, 3). + assert zeros.shape[0] == 1 + assert np.allclose(apply_affine(cfm.affine, zeros[0]), [1, 2, 3]) From 2ed3c22f080f60781055158af826f45b1c5b2a08 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:19:47 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- niworkflows/interfaces/norm.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/niworkflows/interfaces/norm.py b/niworkflows/interfaces/norm.py index c176e9b26cc..48afb97210f 100644 --- a/niworkflows/interfaces/norm.py +++ b/niworkflows/interfaces/norm.py @@ -554,9 +554,7 @@ def create_cfm(in_file, lesion_mask=None, global_mask=True, out_path=None): # Resample the lesion into in_file's voxel grid so the subtraction is # spatially correct regardless of the lesion's stored orientation or # grid. Nearest-neighbor (order=0) keeps the mask binary. - lm_img = resample_from_to( - nb.load(lesion_mask), (data.shape, in_img.affine), order=0 - ) + lm_img = resample_from_to(nb.load(lesion_mask), (data.shape, in_img.affine), order=0) # Subtract lesion mask from secondary mask, set negatives to 0 data = np.fmax(data - np.asanyarray(lm_img.dataobj), 0)