Skip to content

Commit c215fc8

Browse files
authored
Merge pull request nipreps#511 from oesteban/enh/resample-interface
ENH: Add a pure-Python interface to resample to specific resolutions
2 parents 2f9edaa + 68959a1 commit c215fc8

File tree

3 files changed

+157
-1
lines changed

3 files changed

+157
-1
lines changed

niworkflows/interfaces/images.py

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

2727

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

niworkflows/utils/images.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,85 @@ 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+
# Prepare output x-forms
141+
sform, scode = in_file.get_sform(coded=True)
142+
qform, qcode = in_file.get_qform(coded=True)
143+
144+
hdr = in_file.header.copy()
145+
dtype = hdr.get_data_dtype()
146+
data = np.asanyarray(in_file.dataobj)
147+
zooms = np.array(zooms)
148+
149+
# Calculate the factors to normalize voxel size to the specific zooms
150+
pre_zooms = np.array(in_file.header.get_zooms()[:3])
151+
152+
# Calculate an affine aligned with cardinal axes, for simplicity
153+
card = nb.affines.from_matvec(np.diag(pre_zooms))
154+
extent = card[:3, :3].dot(np.array(in_file.shape[:3]))
155+
card[:3, 3] = -0.5 * extent
156+
157+
# Cover the FoV with the new grid
158+
new_size = np.ceil(extent / zooms).astype(int)
159+
offset = (extent - np.diag(zooms).dot(new_size)) * 0.5
160+
new_card = nb.affines.from_matvec(np.diag(zooms), card[:3, 3] + offset)
161+
162+
# Calculate the new indexes
163+
new_grid = np.array(
164+
np.meshgrid(
165+
np.arange(new_size[0]),
166+
np.arange(new_size[1]),
167+
np.arange(new_size[2]),
168+
indexing="ij",
169+
)
170+
).reshape((3, -1))
171+
172+
# Calculate the locations of the new samples, w.r.t. the original grid
173+
ijk = np.linalg.inv(card).dot(
174+
new_card.dot(np.vstack((new_grid, np.ones((1, new_grid.shape[1])))))
175+
)
176+
177+
# Resample data in the new grid
178+
resampled = map_coordinates(
179+
data,
180+
ijk[:3, :],
181+
output=dtype,
182+
order=order,
183+
mode="constant",
184+
cval=0,
185+
prefilter=True,
186+
).reshape(new_size)
187+
if clip:
188+
resampled = np.clip(resampled, a_min=data.min(), a_max=data.max())
189+
190+
# Set new zooms
191+
hdr.set_zooms(zooms)
192+
193+
# Get the original image's affine
194+
affine = in_file.affine.copy()
195+
# Determine rotations w.r.t. cardinal axis and eccentricity
196+
rot = affine.dot(np.linalg.inv(card))
197+
# Apply to the new cardinal, so that the resampling is consistent
198+
new_affine = rot.dot(new_card)
199+
200+
if qcode != 0:
201+
hdr.set_qform(new_affine.dot(np.linalg.inv(affine).dot(qform)), code=int(qcode))
202+
if scode != 0:
203+
hdr.set_sform(new_affine.dot(np.linalg.inv(affine).dot(sform)), code=int(scode))
204+
if (scode, qcode) == (0, 0):
205+
hdr.set_qform(new_affine, code=1)
206+
hdr.set_sform(new_affine, code=1)
207+
208+
# Create a new x-form affine, aligned with cardinal axes, 1mm3 and centered.
209+
return nb.Nifti1Image(resampled, new_affine, hdr)

niworkflows/utils/tests/test_images.py

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

44
import pytest
5-
from ..images import update_header_fields, overwrite_header, dseg_label
5+
from ..images import (
6+
update_header_fields,
7+
overwrite_header,
8+
dseg_label,
9+
resample_by_spacing,
10+
)
611

712

813
def random_image():
@@ -82,3 +87,28 @@ def test_dseg_label(tmp_path):
8287

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

0 commit comments

Comments
 (0)