Skip to content

Commit bfee73b

Browse files
authored
RF: Replace Convert3d with nitransforms in MCFLIRT2ITK (#835)
* TEST: Add regression test for MCFLIRT2ITK * TEST: Update test to account for minor differences with nitransforms * RF: Replace Convert3d with nitransforms in MCFLIRT2ITK * MNT: Remove convert3d from the Dockerfile
1 parent 6955e22 commit bfee73b

File tree

6 files changed

+119
-91
lines changed

6 files changed

+119
-91
lines changed

Dockerfile

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -152,17 +152,6 @@ ENV FSLDIR="/opt/fsl" \
152152
ENV MKL_NUM_THREADS=1 \
153153
OMP_NUM_THREADS=1
154154

155-
# Convert3D (neurodocker build)
156-
RUN echo "Downloading Convert3D ..." \
157-
&& mkdir -p /opt/convert3d-1.0.0 \
158-
&& curl -fsSL --retry 5 https://sourceforge.net/projects/c3d/files/c3d/1.0.0/c3d-1.0.0-Linux-x86_64.tar.gz/download \
159-
| tar -xz -C /opt/convert3d-1.0.0 --strip-components 1 \
160-
--exclude "c3d-1.0.0-Linux-x86_64/lib" \
161-
--exclude "c3d-1.0.0-Linux-x86_64/share" \
162-
--exclude "c3d-1.0.0-Linux-x86_64/bin/c3d_gui"
163-
ENV C3DPATH="/opt/convert3d-1.0.0" \
164-
PATH="/opt/convert3d-1.0.0/bin:$PATH"
165-
166155
# Create a shared $HOME directory
167156
RUN useradd -m -s /bin/bash -G users niworkflows
168157
WORKDIR /home/niworkflows

niworkflows/interfaces/itk.py

Lines changed: 23 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,22 @@
2525
from mimetypes import guess_type
2626
from tempfile import TemporaryDirectory
2727

28+
import nibabel as nb
29+
import nitransforms as nt
30+
import numpy as np
2831
from nipype import logging
29-
from nipype.utils.filemanip import fname_presuffix
3032
from nipype.interfaces.base import (
31-
traits,
32-
TraitedSpec,
3333
BaseInterfaceInputSpec,
3434
File,
3535
InputMultiObject,
3636
OutputMultiObject,
3737
SimpleInterface,
38+
TraitedSpec,
39+
isdefined,
40+
traits,
3841
)
42+
from nipype.utils.filemanip import fname_presuffix
43+
3944
from .fixes import _FixTraitApplyTransformsInputSpec
4045

4146
LOGGER = logging.getLogger("nipype.interface")
@@ -45,13 +50,9 @@ class _MCFLIRT2ITKInputSpec(BaseInterfaceInputSpec):
4550
in_files = InputMultiObject(
4651
File(exists=True), mandatory=True, desc="list of MAT files from MCFLIRT"
4752
)
48-
in_reference = File(
49-
exists=True, mandatory=True, desc="input image for spatial reference"
50-
)
53+
in_reference = File(exists=True, mandatory=True, desc="input image for spatial reference")
5154
in_source = File(exists=True, mandatory=True, desc="input image for spatial source")
52-
num_threads = traits.Int(
53-
1, usedefault=True, nohash=True, desc="number of parallel processes"
54-
)
55+
num_threads = traits.Int(nohash=True, desc="number of parallel processes")
5556

5657

5758
class _MCFLIRT2ITKOutputSpec(TraitedSpec):
@@ -65,53 +66,22 @@ class MCFLIRT2ITK(SimpleInterface):
6566
output_spec = _MCFLIRT2ITKOutputSpec
6667

6768
def _run_interface(self, runtime):
68-
num_threads = self.inputs.num_threads
69-
if num_threads < 1:
70-
num_threads = None
71-
72-
with TemporaryDirectory(prefix="tmp-", dir=runtime.cwd) as tmp_folder:
73-
# Inputs are ready to run in parallel
74-
if num_threads is None or num_threads > 1:
75-
from concurrent.futures import ThreadPoolExecutor
76-
77-
with ThreadPoolExecutor(max_workers=num_threads) as pool:
78-
itk_outs = list(
79-
pool.map(
80-
_mat2itk,
81-
[
82-
(
83-
in_mat,
84-
self.inputs.in_reference,
85-
self.inputs.in_source,
86-
i,
87-
tmp_folder,
88-
)
89-
for i, in_mat in enumerate(self.inputs.in_files)
90-
],
91-
)
92-
)
93-
else:
94-
itk_outs = [
95-
_mat2itk(
96-
(
97-
in_mat,
98-
self.inputs.in_reference,
99-
self.inputs.in_source,
100-
i,
101-
tmp_folder,
102-
)
103-
)
104-
for i, in_mat in enumerate(self.inputs.in_files)
105-
]
106-
107-
# Compose the collated ITK transform file and write
108-
tfms = "#Insight Transform File V1.0\n" + "".join(
109-
[el[1] for el in sorted(itk_outs)]
69+
if isdefined(self.inputs.num_threads):
70+
LOGGER.warning("Multithreading is deprecated. Remove the num_threads input.")
71+
72+
source = nb.load(self.inputs.in_source)
73+
reference = nb.load(self.inputs.in_reference)
74+
affines = [
75+
nt.linear.load(mat, fmt='fsl', reference=reference, moving=source)
76+
for mat in self.inputs.in_files
77+
]
78+
79+
affarray = nt.io.itk.ITKLinearTransformArray.from_ras(
80+
np.stack([a.matrix for a in affines], axis=0),
11081
)
11182

11283
self._results["out_file"] = os.path.join(runtime.cwd, "mat2itk.txt")
113-
with open(self._results["out_file"], "w") as f:
114-
f.write(tfms)
84+
affarray.to_filename(self._results["out_file"])
11585

11686
return runtime
11787

@@ -206,30 +176,6 @@ def _run_interface(self, runtime):
206176
return runtime
207177

208178

209-
def _mat2itk(args):
210-
from nipype.interfaces.c3 import C3dAffineTool
211-
from nipype.utils.filemanip import fname_presuffix
212-
213-
in_file, in_ref, in_src, index, newpath = args
214-
# Generate a temporal file name
215-
out_file = fname_presuffix(in_file, suffix="_itk-%05d.txt" % index, newpath=newpath)
216-
217-
# Run c3d_affine_tool
218-
C3dAffineTool(
219-
transform_file=in_file,
220-
reference_file=in_ref,
221-
source_file=in_src,
222-
fsl2ras=True,
223-
itk_transform=out_file,
224-
resource_monitor=False,
225-
).run()
226-
transform = "#Transform %d\n" % index
227-
with open(out_file) as itkfh:
228-
transform += "".join(itkfh.readlines()[2:])
229-
230-
return (index, transform)
231-
232-
233179
def _applytfms(args):
234180
"""
235181
Applies ANTs' antsApplyTransforms to the input image.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
0.999999 -0.000272 0.001561 -0.071358
2+
0.000272 1.000000 -0.000210 0.053746
3+
-0.001561 0.000210 0.999999 0.153994
4+
0.000000 0.000000 0.000000 1.000000
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
0.999999 -0.000320 0.001330 -0.045489
2+
0.000320 1.000000 -0.000390 0.064510
3+
-0.001329 0.000390 0.999999 0.111615
4+
0.000000 0.000000 0.000000 1.000000
348 Bytes
Binary file not shown.

niworkflows/interfaces/tests/test_itk.py

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,26 @@
2020
#
2121
# https://www.nipreps.org/community/licensing/
2222
#
23+
import re
24+
from pathlib import Path
25+
26+
import numpy as np
2327
import pytest
24-
from ..itk import _applytfms
25-
from ... import data
2628
from nipype.interfaces.ants.base import Info
29+
from nipype.pipeline import engine as pe
30+
31+
from ... import data
32+
from ..itk import MCFLIRT2ITK, _applytfms
33+
from .data import load_test_data
2734

2835

2936
@pytest.mark.skipif(Info.version() is None, reason="Missing ANTs")
3037
@pytest.mark.parametrize("ext", (".nii", ".nii.gz"))
3138
@pytest.mark.parametrize("copy_dtype", (True, False))
3239
@pytest.mark.parametrize("in_dtype", ("i2", "f4"))
3340
def test_applytfms(tmpdir, ext, copy_dtype, in_dtype):
34-
import numpy as np
3541
import nibabel as nb
42+
import numpy as np
3643

3744
in_file = str(tmpdir / ("src" + ext))
3845
nii = nb.Nifti1Image(np.zeros((5, 5, 5), dtype=np.float32), np.eye(4))
@@ -52,3 +59,81 @@ def test_applytfms(tmpdir, ext, copy_dtype, in_dtype):
5259
assert np.allclose(nii.get_fdata(), out_nii.get_fdata())
5360
if copy_dtype:
5461
assert nii.get_data_dtype() == out_nii.get_data_dtype()
62+
63+
64+
def test_MCFLIRT2ITK(tmp_path):
65+
# Test that MCFLIRT2ITK produces output that is consistent with convert3d
66+
test_data = load_test_data()
67+
68+
fsl2itk = pe.Node(
69+
MCFLIRT2ITK(
70+
in_files=[str(test_data / "MAT_0098"), str(test_data / "MAT_0099")],
71+
in_reference=str(test_data / "boldref.nii"),
72+
in_source=str(test_data / "boldref.nii"),
73+
),
74+
name="fsl2itk",
75+
base_dir=str(tmp_path),
76+
)
77+
78+
res = fsl2itk.run()
79+
out_file = Path(res.outputs.out_file)
80+
81+
assert out_file.exists()
82+
lines = out_file.read_text().splitlines()
83+
84+
assert lines[:2] == [
85+
"#Insight Transform File V1.0",
86+
"#Transform 0",
87+
]
88+
assert re.match(
89+
r"Transform: (MatrixOffsetTransformBase|AffineTransform)_(float|double)_3_3",
90+
lines[2],
91+
)
92+
assert lines[3].startswith("Parameters: ")
93+
assert lines[4] == "FixedParameters: 0 0 0"
94+
offset = 1 if lines[5] == "" else 0
95+
assert lines[5 + offset] == "#Transform 1"
96+
assert lines[6 + offset] == lines[2]
97+
assert lines[7 + offset].startswith("Parameters: ")
98+
99+
params0 = np.array([float(p) for p in lines[3].split(" ")[1:]])
100+
params1 = np.array([float(p) for p in lines[7 + offset].split(" ")[1:]])
101+
# Empirically determined
102+
assert np.allclose(
103+
params0,
104+
np.array(
105+
[
106+
9.99998489e-01,
107+
-4.36657508e-04,
108+
-1.52316526e-03,
109+
4.36017740e-04,
110+
9.99999777e-01,
111+
-2.10558666e-04,
112+
1.52334852e-03,
113+
2.09440681e-04,
114+
9.99998624e-01,
115+
-1.28961869e-03,
116+
6.93155516e-02,
117+
-1.12375673e-02,
118+
]
119+
),
120+
)
121+
assert np.allclose(
122+
params1,
123+
np.array(
124+
[
125+
9.99999130e-01,
126+
-4.60021530e-04,
127+
-1.28828576e-03,
128+
4.58910652e-04,
129+
9.99999648e-01,
130+
-3.90485877e-04,
131+
1.28764980e-03,
132+
3.89513646e-04,
133+
9.99999178e-01,
134+
-9.19541650e-03,
135+
7.45419094e-02,
136+
-8.95843238e-03,
137+
]
138+
),
139+
)

0 commit comments

Comments
 (0)