Skip to content

Commit 3ef058c

Browse files
committed
enh: add a pure-Python interface to resample to specific resolutions
1 parent a6a328f commit 3ef058c

File tree

3 files changed

+133
-1
lines changed

3 files changed

+133
-1
lines changed

niworkflows/interfaces/images.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,39 @@
2525
LOGGER = logging.getLogger("nipype.interface")
2626

2727

28+
class _RegridToZoomsInputSpec(BaseInterfaceInputSpec):
29+
in_file = File(exists=True, mandatory=True,
30+
desc="a file whose resolution is to change")
31+
zooms = traits.Tuple(traits.Float, traits.Float, traits.Float,
32+
mandatory=True, desc="the new resolution")
33+
order = traits.Int(3, usedefault=True, desc="order of interpolator")
34+
clip = traits.Bool(True, usedefault=True,
35+
desc="clip the data array within the original image's range")
36+
37+
38+
class _RegridToZoomsOutputSpec(TraitedSpec):
39+
out_file = File(exists=True, dec="the regridded file")
40+
41+
42+
class RegridToZooms(SimpleInterface):
43+
"""Change the resolution of an image (regrid)."""
44+
45+
input_spec = _RegridToZoomsInputSpec
46+
output_spec = _RegridToZoomsOutputSpec
47+
48+
def _run_interface(self, runtime):
49+
from ..utils.images import resample_by_spacing
50+
self._results["out_file"] = fname_presuffix(
51+
self.inputs.in_file, suffix="_regrid", newpath=runtime.cwd)
52+
resample_by_spacing(
53+
self.inputs.in_file,
54+
self.inputs.zooms,
55+
order=self.inputs.order,
56+
clip=self.inputs.clip,
57+
).to_filename(self._results["out_file"])
58+
return runtime
59+
60+
2861
class _IntraModalMergeInputSpec(BaseInterfaceInputSpec):
2962
in_files = InputMultiPath(File(exists=True), mandatory=True, desc="input files")
3063
in_mask = File(exists=True, desc="input mask for grand mean scaling")

niworkflows/utils/images.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,77 @@ def dseg_label(in_seg, label, newpath=None):
125125
new.set_data_dtype(np.uint8)
126126
new.to_filename(out_file)
127127
return out_file
128+
129+
130+
def resample_by_spacing(in_file, zooms, order=3, clip=True):
131+
"""Regrid the input image to match the new zooms."""
132+
from pathlib import Path
133+
import numpy as np
134+
import nibabel as nb
135+
from scipy.ndimage import map_coordinates
136+
137+
if isinstance(in_file, (str, Path)):
138+
in_file = nb.load(in_file)
139+
140+
hdr = in_file.header.copy()
141+
dtype = hdr.get_data_dtype()
142+
data = np.asanyarray(in_file.dataobj)
143+
zooms = np.array(zooms)
144+
145+
# Calculate the factors to normalize voxel size to the specific zooms
146+
pre_zooms = np.array(in_file.header.get_zooms()[:3])
147+
148+
# Calculate an affine aligned with cardinal axes, for simplicity
149+
card = nb.affines.from_matvec(np.diag(pre_zooms))
150+
extent = card[:3, :3].dot(np.array(in_file.shape[:3]))
151+
card[:3, 3] = -0.5 * extent
152+
153+
# Cover the FoV with the new grid
154+
new_size = np.ceil(extent / zooms).astype(int)
155+
offset = (extent - np.diag(zooms).dot(new_size)) * 0.5
156+
new_card = nb.affines.from_matvec(np.diag(zooms), card[:3, 3] + offset)
157+
158+
# Calculate the new indexes
159+
new_grid = np.array(np.meshgrid(
160+
np.arange(new_size[0]),
161+
np.arange(new_size[1]),
162+
np.arange(new_size[2]),
163+
indexing="ij")
164+
).reshape((3, -1))
165+
166+
# Calculate the locations of the new samples, w.r.t. the original grid
167+
ijk = np.linalg.inv(card).dot(new_card.dot(
168+
np.vstack((new_grid, np.ones((1, new_grid.shape[1]))))
169+
))
170+
171+
# Resample data in the new grid
172+
resampled = map_coordinates(
173+
data,
174+
ijk[:3, :],
175+
output=dtype,
176+
order=order,
177+
mode="constant",
178+
cval=0,
179+
prefilter=True,
180+
).reshape(new_size)
181+
if clip:
182+
resampled = np.clip(resampled, min=data.min(), max=data.max())
183+
184+
# Prepare output x-forms
185+
sform, scode = hdr.get_sform(coded=True)
186+
qform, qcode = hdr.get_qform(coded=True)
187+
188+
# Get the original image's affine
189+
affine = in_file.affine
190+
# Determine rotations w.r.t. cardinal axis and eccentricity
191+
rot = affine.dot(np.linalg.inv(card))
192+
# Apply to the new cardinal, so that the resampling is consistent
193+
new_affine = rot.dot(new_card)
194+
195+
if scode != 0:
196+
hdr.set_sform(new_affine.dot(np.linalg.inv(affine).dot(sform)), code=int(scode))
197+
if qcode != 0:
198+
hdr.set_qform(new_affine.dot(np.linalg.inv(affine).dot(qform)), code=int(qcode))
199+
200+
# Create a new x-form affine, aligned with cardinal axes, 1mm3 and centered.
201+
return nb.Nifti1Image(resampled, new_affine, hdr)

niworkflows/utils/tests/test_images.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import numpy as np
33

44
import pytest
5-
from ..images import update_header_fields, overwrite_header, dseg_label
5+
from ..images import update_header_fields, overwrite_header, dseg_label, resample_by_spacing
66

77

88
def random_image():
@@ -82,3 +82,28 @@ def test_dseg_label(tmp_path):
8282

8383
new_im = nb.load(dseg_label(fname, label=2, newpath=tmp_path))
8484
assert np.all((data == 2).astype("int16") == np.int16(new_im.dataobj))
85+
86+
87+
def test_resample_by_spacing():
88+
"""Check the output zooms and data."""
89+
img_shape = (193, 229, 193) # Size from MNI152NLin2009cAsym, res=1
90+
img_affine = nb.affines.from_matvec(np.eye(3), (-96, -132, -78))
91+
92+
data = np.random.normal(size=img_shape).astype(float)
93+
new_affine = nb.affines.from_matvec(2.0 * np.eye(3), (-96.5, -132.5, -78.5))
94+
95+
nii = nb.Nifti1Image(data, img_affine, None)
96+
nii.set_qform(img_affine, code=4)
97+
resampled = resample_by_spacing(nii, (2.0, 2.0, 2.0), order=1, clip=False)
98+
assert resampled.header.get_zooms()[:3] == (2.0, 2.0, 2.0)
99+
assert np.all(resampled.affine == new_affine)
100+
101+
# Create a rotation matrix
102+
rot = nb.affines.from_matvec(nb.eulerangles.euler2mat(0.01, 0.02, 0.004))
103+
# Check this works with oblique images
104+
nii = nb.Nifti1Image(data, rot.dot(img_affine), None)
105+
nii.set_qform(img_affine, code=4)
106+
nii.set_sform(rot.dot(img_affine), code=4)
107+
resampled = resample_by_spacing(nii, (2.0, 2.0, 2.0), order=1, clip=False)
108+
assert resampled.header.get_zooms()[:3] == (2.0, 2.0, 2.0)
109+
assert np.allclose(resampled.affine, rot.dot(new_affine))

0 commit comments

Comments
 (0)