Skip to content

Commit 7d5b6d7

Browse files
authored
Merge pull request #237 from nipreps/fix/field-generation
FIX: Generation of RAS displacements fields from VSMs
2 parents 6dbf688 + d9c142b commit 7d5b6d7

File tree

2 files changed

+165
-31
lines changed

2 files changed

+165
-31
lines changed

sdcflows/tests/test_transform.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
2+
# vi: set ft=python sts=4 ts=4 sw=4 et:
3+
#
4+
# Copyright 2021 The NiPreps Developers <nipreps@gmail.com>
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
# We support and encourage derived works from this project, please read
19+
# about our expectations at
20+
#
21+
# https://www.nipreps.org/community/licensing/
22+
#
23+
"""Unit tests of the transform object."""
24+
from subprocess import check_call
25+
from itertools import product
26+
import pytest
27+
import nibabel as nb
28+
import numpy as np
29+
from skimage.morphology import ball
30+
import scipy.ndimage as nd
31+
32+
from sdcflows import transform as tf
33+
34+
35+
def generate_oracle(
36+
coeff_file,
37+
rotation=(None, None, None),
38+
zooms=(2.0, 2.2, 1.5),
39+
flip=(False, False, False),
40+
):
41+
"""Generate an in-silico phantom, and a corresponding (aligned) B-Spline field."""
42+
data = ball(20)
43+
data[19:22, ...] = 0
44+
data = np.pad(data + nd.binary_erosion(data, ball(3)), 8)
45+
46+
zooms = [z if not f else -z for z, f in zip(zooms, flip)]
47+
affine = np.diag(zooms + [1])
48+
affine[:3, 3] = -affine[:3, :3] @ ((np.array(data.shape) - 1) * 0.5)
49+
50+
coeff_nii = nb.load(coeff_file)
51+
coeff_aff = np.diag([5.0 if not f else -5.0 for f in flip] + [1])
52+
53+
if any(rotation):
54+
R = nb.affines.from_matvec(
55+
nb.eulerangles.euler2mat(
56+
x=rotation[0],
57+
y=rotation[1],
58+
z=rotation[2],
59+
)
60+
)
61+
affine = R @ affine
62+
coeff_aff = R @ coeff_aff
63+
64+
phantom_nii = nb.Nifti1Image(
65+
data.astype(np.uint8),
66+
affine,
67+
None,
68+
)
69+
coeff_nii = nb.Nifti1Image(
70+
coeff_nii.dataobj,
71+
coeff_aff,
72+
coeff_nii.header,
73+
)
74+
return phantom_nii, coeff_nii
75+
76+
77+
@pytest.mark.parametrize("pe_dir", ["j", "j-", "i", "i-", "k", "k-"])
78+
@pytest.mark.parametrize("rotation", [(None, None, None), (0.2, None, None)])
79+
@pytest.mark.parametrize("flip", list(product(*[(False, True)] * 3)))
80+
def test_displacements_field(tmpdir, testdata_dir, outdir, pe_dir, rotation, flip):
81+
"""Check the generated displacements fields."""
82+
tmpdir.chdir()
83+
84+
# Generate test oracle
85+
phantom_nii, coeff_nii = generate_oracle(
86+
testdata_dir / "topup-coeff-fixed.nii.gz",
87+
rotation=rotation,
88+
)
89+
90+
b0 = tf.B0FieldTransform(coeffs=coeff_nii)
91+
assert b0.fit(phantom_nii) is True
92+
assert b0.fit(phantom_nii) is False
93+
94+
b0.apply(
95+
phantom_nii,
96+
pe_dir=pe_dir,
97+
ro_time=0.2,
98+
output_dtype="float32",
99+
).to_filename("warped-sdcflows.nii.gz")
100+
b0.to_displacements(
101+
ro_time=0.2,
102+
pe_dir=pe_dir,
103+
).to_filename("itk-displacements.nii.gz")
104+
105+
phantom_nii.to_filename("phantom.nii.gz")
106+
# Run antsApplyTransform
107+
exit_code = check_call(
108+
[
109+
"antsApplyTransforms -d 3 -r phantom.nii.gz -i phantom.nii.gz "
110+
"-o warped-ants.nii.gz -n BSpline -t itk-displacements.nii.gz"
111+
],
112+
shell=True,
113+
)
114+
assert exit_code == 0
115+
116+
ours = np.asanyarray(nb.load("warped-sdcflows.nii.gz").dataobj)
117+
theirs = np.asanyarray(nb.load("warped-ants.nii.gz").dataobj)
118+
assert np.all((np.sqrt(((ours - theirs) ** 2).sum()) / ours.size) < 1e-1)
119+
120+
if outdir:
121+
from niworkflows.interfaces.reportlets.registration import (
122+
SimpleBeforeAfterRPT as SimpleBeforeAfter,
123+
)
124+
125+
orientation = "".join([ax[bool(f)] for ax, f in zip(("RL", "AP", "SI"), flip)])
126+
127+
SimpleBeforeAfter(
128+
after_label="Theirs (ANTs)",
129+
before_label="Ours (SDCFlows)",
130+
after="warped-ants.nii.gz",
131+
before="warped-sdcflows.nii.gz",
132+
out_report=str(
133+
outdir / f"xfm_pe-{pe_dir}_flip-{orientation}_x-{rotation[0] or 0}"
134+
f"_y-{rotation[1] or 0}_z-{rotation[2] or 0}.svg"
135+
),
136+
).run()

sdcflows/transform.py

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ def apply(
194194
moved.header.set_data_dtype(output_dtype)
195195
return moved
196196

197-
def to_displacements(self, ro_time, pe_dir):
197+
def to_displacements(self, ro_time, pe_dir, itk_format=True):
198198
"""
199199
Generate a NIfTI file containing a displacements field transform compatible with ITK/ANTs.
200200
@@ -215,38 +215,36 @@ def to_displacements(self, ro_time, pe_dir):
215215
A NIfTI 1.0 object containing the distortion.
216216
217217
"""
218-
from math import pi
219-
from nibabel.affines import voxel_sizes, obliquity
220-
from nibabel.orientations import io_orientation
221-
222-
# Generate warp field
223-
data = self.shifts.get_fdata(dtype="float32").copy()
218+
# Set polarity & scale VSM (voxel-shift-map) by readout time
219+
vsm = self.shifts.get_fdata().copy()
224220
pe_axis = "ijk".index(pe_dir[0])
225-
pe_sign = -1.0 if pe_dir.endswith("-") else 1.0
226-
pe_size = self.shifts.header.get_zooms()[pe_axis]
227-
data *= pe_sign * ro_time * pe_size
228-
229-
fieldshape = tuple(list(data.shape[:3]) + [3])
230-
231-
# Compose a vector field
232-
field = np.zeros((data.size, 3), dtype="float32")
233-
field[..., pe_axis] = data.reshape(-1)
234-
235-
# If coordinate system is oblique, project displacements through directions matrix
236-
aff = self.shifts.affine
237-
if obliquity(aff).max() * 180 / pi > 0.01:
238-
dirmat = np.eye(4)
239-
dirmat[:3, :3] = aff[:3, :3] / (
240-
voxel_sizes(aff) * io_orientation(aff)[:, 1]
241-
)
242-
field = nb.affines.apply_affine(dirmat, field)
243-
244-
warpnii = nb.Nifti1Image(
245-
field.reshape(fieldshape)[:, :, :, np.newaxis, :], aff, None
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,
246244
)
247-
warpnii.header.set_intent("vector", (), "")
248-
warpnii.header.set_xyzt_units("mm")
249-
return warpnii
245+
xyz_nii.header.set_intent("vector", (), "")
246+
xyz_nii.header.set_xyzt_units("mm")
247+
return xyz_nii
250248

251249

252250
def _cubic_bspline(d):

0 commit comments

Comments
 (0)