Skip to content

Commit 8cfd877

Browse files
authored
Merge pull request #239 from nipreps/fix/syn-revision
FIX: SDC-SyN ("fieldmap-less") overhaul
2 parents 74d89bf + 25f61ad commit 8cfd877

File tree

9 files changed

+444
-222
lines changed

9 files changed

+444
-222
lines changed

sdcflows/data/affine.json

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
{
22
"collapse_output_transforms": true,
3-
"convergence_threshold": [ 1e-06, 1e-06 ],
4-
"convergence_window_size": [ 10, 10 ],
3+
"convergence_threshold": [ 1e-06, 1e-07 ],
4+
"convergence_window_size": [ 10, 5 ],
55
"dimension": 3,
66
"initial_moving_transform_com": 1,
77
"interpolation": "Linear",
88
"metric": [ "Mattes", "Mattes" ],
99
"metric_weight": [ 1.0, 1.0 ],
10-
"number_of_iterations": [ [ 1000, 500, 250, 100 ], [ 1000, 500, 250, 100 ] ],
10+
"number_of_iterations": [ [ 1000, 0 ], [ 250, 100 ] ],
1111
"radius_or_number_of_bins": [ 32, 32 ],
12-
"sampling_percentage": [ 0.25, 0.25 ],
12+
"sampling_percentage": [ 1.0, 0.4 ],
1313
"sampling_strategy": [ "Regular", "Random" ],
14-
"shrink_factors": [[ 8, 4, 2, 1] , [ 8, 4, 2, 1 ]],
15-
"sigma_units": [ "vox", "vox" ],
16-
"smoothing_sigmas": [ [ 4, 2, 1, 0 ], [ 4, 2, 1, 0 ] ],
17-
"transform_parameters": [ [ 1.0 ], [ 0.6 ] ],
14+
"shrink_factors": [[ 4, 1] , [ 2, 1 ]],
15+
"sigma_units": [ "mm", "vox" ],
16+
"smoothing_sigmas": [ [ 6, 0 ], [ 2, 1 ] ],
17+
"transform_parameters": [ [ 2.0 ], [ 1.0 ] ],
1818
"transforms": [ "Rigid", "Affine" ],
1919
"use_estimate_learning_rate_once": [ false, false ],
2020
"use_histogram_matching": [ true, true ],
21+
"winsorize_lower_quantile": 0.001,
22+
"winsorize_upper_quantile": 0.998,
2123
"write_composite_transform": false
2224
}

sdcflows/data/sd_syn.json

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,24 @@
11
{
22
"collapse_output_transforms": true,
3-
"convergence_threshold": [ 1e-06, 1e-06, 1e-08 ],
4-
"convergence_window_size": [ 5, 3, 2 ],
3+
"convergence_threshold": [ 1e-06, 1e-08 ],
4+
"convergence_window_size": [ 5, 2 ],
55
"dimension": 3,
66
"interpolation": "Linear",
7-
"metric": [ "Mattes", "CC", "CC" ],
8-
"metric_weight": [ 1, 1, 1 ],
9-
"number_of_iterations": [ [ 100, 0 ], [ 20, 10, 0 ], [ 10 ] ],
7+
"metric": [ ["Mattes", "Mattes"], ["Mattes", "Mattes"] ],
8+
"metric_weight": [ [0.5, 0.5], [0.5, 0.5] ],
9+
"number_of_iterations": [ [ 200, 100 ], [ 10 ] ],
1010
"output_transform_prefix": "fmap_syn",
11-
"radius_or_number_of_bins": [ 56, 8, 5 ],
12-
"sampling_percentage": [ 0.80, 0.80, 1.0 ],
13-
"sampling_strategy": [ "Random", "Random", "Random" ],
14-
"shrink_factors": [ [ 1, 1 ], [ 1, 1, 1 ], [ 1 ] ],
15-
"sigma_units": [ "vox", "vox", "vox" ],
16-
"smoothing_sigmas": [ [ 2, 0 ], [ 2, 1, 0 ], [ 0 ] ],
17-
"transform_parameters": [ [ 0.2, 2.8, 2.8 ], [ 0.2, 2.8, 1.8 ], [ 0.8, 2.0, 1.0 ] ],
18-
"transforms": [ "SyN", "SyN", "SyN" ],
19-
"use_estimate_learning_rate_once": [ false, true, true ],
20-
"use_histogram_matching": [ true, false, false ],
21-
"verbose": true,
22-
"winsorize_lower_quantile": 0.02,
23-
"winsorize_upper_quantile": 1.0,
11+
"radius_or_number_of_bins": [ [48, 48], [48, 48] ],
12+
"sampling_percentage": [ [0.8, 0.8], [1.0, 1.0] ],
13+
"sampling_strategy": [["Random", "Random"], [null, null] ],
14+
"shrink_factors": [ [ 1, 1 ], [ 1 ] ],
15+
"sigma_units": [ "vox", "vox" ],
16+
"smoothing_sigmas": [ [ 2, 0 ], [ 0 ] ],
17+
"transform_parameters": [ [ 0.8, 6.0, 3.0 ], [ 0.8, 2.0, 1.0 ] ],
18+
"transforms": [ "SyN", "SyN" ],
19+
"use_estimate_learning_rate_once": [ true, true ],
20+
"use_histogram_matching": [ true, true ],
21+
"winsorize_lower_quantile": 0.001,
22+
"winsorize_upper_quantile": 0.998,
2423
"write_composite_transform": false
2524
}

sdcflows/data/sd_syn_sloppy.json

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
11
{
22
"collapse_output_transforms": true,
3-
"convergence_threshold": [ 1e-06, 1e-06, 1e-08 ],
4-
"convergence_window_size": [ 5, 3, 2 ],
3+
"convergence_threshold": [ 1e-06, 1e-08 ],
4+
"convergence_window_size": [ 5, 2 ],
55
"dimension": 3,
66
"interpolation": "Linear",
7-
"metric": [ "Mattes", "CC", "CC" ],
8-
"metric_weight": [ 1, 1, 1 ],
9-
"number_of_iterations": [ [ 10, 0 ], [ 10, 5, 0 ], [ 2 ] ],
7+
"metric": [ ["Mattes", "Mattes"], ["Mattes", "Mattes"] ],
8+
"metric_weight": [ [0.5, 0.5], [0.5, 0.5] ],
9+
"number_of_iterations": [ [ 20, 10 ], [ 2 ] ],
1010
"output_transform_prefix": "fmap_syn",
11-
"radius_or_number_of_bins": [ 56, 8, 5 ],
12-
"sampling_percentage": [ 0.80, 0.80, 1.0 ],
13-
"sampling_strategy": [ "Random", "Random", "Random" ],
14-
"shrink_factors": [ [ 1, 1 ], [ 1, 1, 1 ], [ 1 ] ],
15-
"sigma_units": [ "vox", "vox", "vox" ],
16-
"smoothing_sigmas": [ [ 2, 0 ], [ 2, 1, 0 ], [ 0 ] ],
17-
"transform_parameters": [ [ 0.2, 2.8, 2.8 ], [ 0.2, 2.8, 1.8 ], [ 0.8, 2.0, 1.0 ] ],
18-
"transforms": [ "SyN", "SyN", "SyN" ],
19-
"use_estimate_learning_rate_once": [ false, true, true ],
20-
"use_histogram_matching": [ true, false, false ],
11+
"radius_or_number_of_bins": [ [48, 48], [48, 48] ],
12+
"sampling_percentage": [ [0.8, 0.8], [1.0, 1.0] ],
13+
"sampling_strategy": [["Random", "Random"], [null, null] ],
14+
"shrink_factors": [ [ 1, 1 ], [ 1 ] ],
15+
"sigma_units": [ "vox", "vox" ],
16+
"smoothing_sigmas": [ [ 2, 0 ], [ 0 ] ],
17+
"transform_parameters": [ [ 0.8, 6.0, 3.0 ], [ 0.8, 2.0, 1.0 ] ],
18+
"transforms": [ "SyN", "SyN" ],
19+
"use_estimate_learning_rate_once": [ true, true ],
20+
"use_histogram_matching": [ true, true ],
2121
"verbose": true,
22-
"winsorize_lower_quantile": 0.02,
23-
"winsorize_upper_quantile": 1.0,
22+
"winsorize_lower_quantile": 0.001,
23+
"winsorize_upper_quantile": 0.998,
2424
"write_composite_transform": false
2525
}

sdcflows/interfaces/bspline.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252

5353
class _BSplineApproxInputSpec(BaseInterfaceInputSpec):
5454
in_data = File(exists=True, mandatory=True, desc="path to a fieldmap")
55-
in_mask = File(exists=True, mandatory=True, desc="path to a brain mask")
55+
in_mask = File(exists=True, desc="path to a brain mask")
5656
bs_spacing = InputMultiObject(
5757
[DEFAULT_ZOOMS_MM],
5858
traits.Tuple(traits.Float, traits.Float, traits.Float),
@@ -127,7 +127,11 @@ def _run_interface(self, runtime):
127127
# Load in the fieldmap
128128
fmapnii = nb.load(self.inputs.in_data)
129129
data = fmapnii.get_fdata(dtype="float32")
130-
mask = nb.load(self.inputs.in_mask).get_fdata() > 0
130+
mask = (
131+
nb.load(self.inputs.in_mask).get_fdata() > 0
132+
if isdefined(self.inputs.in_mask)
133+
else np.ones_like(data, dtype=bool)
134+
)
131135
bs_spacing = [np.array(sp, dtype="float32") for sp in self.inputs.bs_spacing]
132136

133137
# Recenter the fieldmap

sdcflows/interfaces/fmap.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,52 @@ def _run_interface(self, runtime):
152152
self._results["out_file"]
153153
)
154154
return runtime
155+
156+
157+
class _DisplacementsField2FieldmapInputSpec(BaseInterfaceInputSpec):
158+
transform = File(exists=True, mandatory=True, desc="input displacements field")
159+
ro_time = traits.Float(mandatory=True, desc="total readout time")
160+
pe_dir = traits.Enum(
161+
"j-", "j", "i", "i-", "k", "k-", mandatory=True, desc="phase encoding direction"
162+
)
163+
demean = traits.Bool(False, usedefault=True, desc="regress field to the mean")
164+
itk_transform = traits.Bool(
165+
True, usedefault=True, desc="whether this is an ITK/ANTs transform"
166+
)
167+
168+
169+
class _DisplacementsField2FieldmapOutputSpec(TraitedSpec):
170+
out_file = File(exists=True, desc="output fieldmap in Hz")
171+
172+
173+
class DisplacementsField2Fieldmap(SimpleInterface):
174+
"""Convert from a transform to a B0 fieldmap in Hz."""
175+
176+
input_spec = _DisplacementsField2FieldmapInputSpec
177+
output_spec = _DisplacementsField2FieldmapOutputSpec
178+
179+
def _run_interface(self, runtime):
180+
from sdcflows.transform import disp_to_fmap
181+
182+
self._results["out_file"] = fname_presuffix(
183+
self.inputs.transform, suffix="_Hz", newpath=runtime.cwd
184+
)
185+
fmapnii = disp_to_fmap(
186+
nb.load(self.inputs.transform),
187+
ro_time=self.inputs.ro_time,
188+
pe_dir=self.inputs.pe_dir,
189+
itk_format=self.inputs.itk_transform,
190+
)
191+
192+
if self.inputs.demean:
193+
data = np.asanyarray(fmapnii.dataobj)
194+
data -= np.median(data)
195+
196+
fmapnii = fmapnii.__class__(
197+
data.astype("float32"),
198+
fmapnii.affine,
199+
fmapnii.header,
200+
)
201+
202+
fmapnii.to_filename(self._results["out_file"])
203+
return runtime

sdcflows/tests/test_transform.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,26 @@ def test_displacements_field(tmpdir, testdata_dir, outdir, pe_dir, rotation, fli
134134
f"_y-{rotation[1] or 0}_z-{rotation[2] or 0}.svg"
135135
),
136136
).run()
137+
138+
139+
@pytest.mark.parametrize("pe_dir", ["j", "j-", "i", "i-", "k", "k-"])
140+
def test_conversions(tmpdir, testdata_dir, pe_dir):
141+
"""Check inverse functions."""
142+
tmpdir.chdir()
143+
144+
fmap_nii = nb.load(testdata_dir / "topup-field.nii.gz")
145+
new_nii = tf.disp_to_fmap(
146+
tf.fmap_to_disp(
147+
fmap_nii,
148+
ro_time=0.2,
149+
pe_dir=pe_dir,
150+
),
151+
ro_time=0.2,
152+
pe_dir=pe_dir,
153+
)
154+
155+
new_nii.to_filename("test.nii.gz")
156+
assert np.allclose(
157+
fmap_nii.get_fdata(dtype="float32"),
158+
new_nii.get_fdata(dtype="float32"),
159+
)

sdcflows/transform.py

Lines changed: 96 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,14 @@ def fit(self, spatialimage):
8989

9090
# Interpolate the VSM (voxel-shift map)
9191
vsm = np.zeros(spatialimage.shape[:3], dtype="float32")
92-
vsm = (np.squeeze(np.vstack(coeffs).T) @ sparse_vstack(weights)).reshape(
92+
vsm = (np.squeeze(np.hstack(coeffs).T) @ sparse_vstack(weights)).reshape(
9393
vsm.shape
9494
)
9595

9696
# Cache
9797
self.shifts = nb.Nifti1Image(vsm, spatialimage.affine, None)
98+
self.shifts.header.set_intent("estimate", name="Voxel shift")
99+
self.shifts.header.set_xyzt_units("mm")
98100
return True
99101

100102
def apply(
@@ -215,36 +217,99 @@ def to_displacements(self, ro_time, pe_dir, itk_format=True):
215217
A NIfTI 1.0 object containing the distortion.
216218
217219
"""
218-
# Set polarity & scale VSM (voxel-shift-map) by readout time
219-
vsm = self.shifts.get_fdata().copy()
220-
pe_axis = "ijk".index(pe_dir[0])
221-
vsm *= -1.0 if pe_dir.endswith("-") else 1.0
222-
vsm *= ro_time
223-
224-
# Shape of displacements field
225-
# Note that ITK NIfTI fields are 5D (have an empty 4th dimension)
226-
fieldshape = tuple(list(vsm.shape[:3]) + [1, 3])
227-
228-
# Convert VSM to voxel displacements
229-
ijk_deltas = np.zeros((vsm.size, 3), dtype="float32")
230-
ijk_deltas[:, pe_axis] = vsm.reshape(-1)
231-
232-
# To convert from VSM to RAS field we just apply the affine
233-
aff = self.shifts.affine.copy()
234-
aff[:3, 3] = 0 # Translations MUST NOT be applied, though.
235-
xyz_deltas = nb.affines.apply_affine(aff, ijk_deltas)
236-
if itk_format:
237-
# ITK displacement vectors are in LPS orientation
238-
xyz_deltas[..., (0, 1)] *= -1.0
239-
240-
xyz_nii = nb.Nifti1Image(
241-
xyz_deltas.reshape(fieldshape),
242-
self.shifts.affine,
243-
None,
244-
)
245-
xyz_nii.header.set_intent("vector", (), "")
246-
xyz_nii.header.set_xyzt_units("mm")
247-
return xyz_nii
220+
return fmap_to_disp(self.shifts, ro_time, pe_dir, itk_format=itk_format)
221+
222+
223+
def fmap_to_disp(fmap_nii, ro_time, pe_dir, itk_format=True):
224+
"""
225+
Convert a fieldmap in Hz into an ITK/ANTs-compatible displacements field.
226+
227+
The displacements field can be calculated following
228+
`Eq. (2) in the fieldmap fitting section
229+
<sdcflows.workflows.fit.fieldmap.html#mjx-eqn-eq%3Afieldmap-2>`__.
230+
231+
Parameters
232+
----------
233+
fmap_nii : :obj:`os.pathlike`
234+
Path to a voxel-shift-map (VSM) in NIfTI format
235+
ro_time : :obj:`float`
236+
The total readout time in seconds
237+
pe_dir : :obj:`str`
238+
The ``PhaseEncodingDirection`` metadata value
239+
240+
Returns
241+
-------
242+
spatialimage : :obj:`nibabel.nifti.Nifti1Image`
243+
A NIfTI 1.0 object containing the distortion.
244+
245+
"""
246+
# Set polarity & scale VSM (voxel-shift-map) by readout time
247+
vsm = fmap_nii.get_fdata().copy() * (-ro_time if pe_dir.endswith("-") else ro_time)
248+
249+
# Shape of displacements field
250+
# Note that ITK NIfTI fields are 5D (have an empty 4th dimension)
251+
fieldshape = vsm.shape[:3] + (1, 3)
252+
253+
# Convert VSM to voxel displacements
254+
pe_axis = "ijk".index(pe_dir[0])
255+
ijk_deltas = np.zeros((vsm.size, 3), dtype="float32")
256+
ijk_deltas[:, pe_axis] = vsm.reshape(-1)
257+
258+
# To convert from VSM to RAS field we just apply the affine
259+
aff = fmap_nii.affine.copy()
260+
aff[:3, 3] = 0 # Translations MUST NOT be applied, though.
261+
xyz_deltas = nb.affines.apply_affine(aff, ijk_deltas)
262+
if itk_format:
263+
# ITK displacement vectors are in LPS orientation
264+
xyz_deltas[..., (0, 1)] *= -1.0
265+
266+
xyz_nii = nb.Nifti1Image(xyz_deltas.reshape(fieldshape), fmap_nii.affine)
267+
xyz_nii.header.set_intent("vector", name="SDC")
268+
xyz_nii.header.set_xyzt_units("mm")
269+
return xyz_nii
270+
271+
272+
def disp_to_fmap(xyz_nii, ro_time, pe_dir, itk_format=True):
273+
"""
274+
Convert a displacements field into a fieldmap in Hz.
275+
276+
This is the inverse operation to the previous function.
277+
278+
Parameters
279+
----------
280+
xyz_nii : :obj:`os.pathlike`
281+
Path to a displacements field in NIfTI format.
282+
ro_time : :obj:`float`
283+
The total readout time in seconds.
284+
pe_dir : :obj:`str`
285+
The ``PhaseEncodingDirection`` metadata value.
286+
287+
Returns
288+
-------
289+
spatialimage : :obj:`nibabel.nifti.Nifti1Image`
290+
A NIfTI 1.0 object containing the field in Hz.
291+
292+
"""
293+
xyz_deltas = np.squeeze(xyz_nii.get_fdata(dtype="float32")).reshape((-1, 3))
294+
295+
if itk_format:
296+
# ITK displacement vectors are in LPS orientation
297+
xyz_deltas[:, (0, 1)] *= -1
298+
299+
inv_aff = np.linalg.inv(xyz_nii.affine)
300+
inv_aff[:3, 3] = 0 # Translations MUST NOT be applied.
301+
302+
# Convert displacements from mm to voxel units
303+
# Using the inverse affine accounts for reordering of axes, etc.
304+
ijk_deltas = nb.affines.apply_affine(inv_aff, xyz_deltas).astype("float32")
305+
pe_axis = "ijk".index(pe_dir[0])
306+
vsm = ijk_deltas[:, pe_axis].reshape(xyz_nii.shape[:3])
307+
scale_factor = -ro_time if pe_dir.endswith("-") else ro_time
308+
309+
fmap_nii = nb.Nifti1Image(vsm / scale_factor, xyz_nii.affine)
310+
fmap_nii.header.set_intent("estimate", name="Delta_B0 [Hz]")
311+
fmap_nii.header.set_xyzt_units("mm")
312+
return fmap_nii
248313

249314

250315
def _cubic_bspline(d):

0 commit comments

Comments
 (0)