Skip to content

Commit 32b1b57

Browse files
committed
ENH: Add simplistic EPI masking algorithm
To support the PEPOLAR workflow.
1 parent a36d91d commit 32b1b57

File tree

11 files changed

+138
-47
lines changed

11 files changed

+138
-47
lines changed

sdcflows/conftest.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
if p.is_dir()
1717
}
1818

19-
data_dir = Path(__file__).parent / "tests" / "data" / "dsA"
19+
data_dir = Path(__file__).parent / "tests" / "data"
2020

21-
layouts["dsA"] = BIDSLayout(data_dir, validate=False, derivatives=False)
21+
layouts["dsA"] = BIDSLayout(data_dir / "dsA", validate=False, derivatives=False)
2222

2323

2424
def pytest_report_header(config):
@@ -40,7 +40,7 @@ def add_np(doctest_namespace):
4040
for key, val in list(layouts.items()):
4141
doctest_namespace[key] = Path(val.root)
4242

43-
doctest_namespace["testdata_dir"] = data_dir
43+
doctest_namespace["dsA_dir"] = data_dir / "dsA"
4444

4545

4646
@pytest.fixture
@@ -68,3 +68,8 @@ def datadir():
6868
@pytest.fixture
6969
def testdata_dir():
7070
return data_dir
71+
72+
73+
@pytest.fixture
74+
def dsA_dir():
75+
return data_dir / "dsA"

sdcflows/fieldmaps.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,65 +82,65 @@ class FieldmapFile:
8282
8383
Examples
8484
--------
85-
>>> f = FieldmapFile(testdata_dir / "sub-01" / "anat" / "sub-01_T1w.nii.gz")
85+
>>> f = FieldmapFile(dsA_dir / "sub-01" / "anat" / "sub-01_T1w.nii.gz")
8686
>>> f.suffix
8787
'T1w'
8888
8989
>>> FieldmapFile(
90-
... testdata_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
90+
... dsA_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
9191
... find_meta=False
9292
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
9393
Traceback (most recent call last):
9494
MetadataError:
9595
9696
>>> f = FieldmapFile(
97-
... testdata_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
97+
... dsA_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
9898
... )
9999
>>> f.metadata['TotalReadoutTime']
100100
0.005
101101
102102
>>> f = FieldmapFile(
103-
... testdata_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
103+
... dsA_dir / "sub-01" / "fmap" / "sub-01_dir-LR_epi.nii.gz",
104104
... metadata={'TotalReadoutTime': 0.006}
105105
... )
106106
>>> f.metadata['TotalReadoutTime']
107107
0.006
108108
109109
>>> FieldmapFile(
110-
... testdata_dir / "sub-01" / "fmap" / "sub-01_phasediff.nii.gz",
110+
... dsA_dir / "sub-01" / "fmap" / "sub-01_phasediff.nii.gz",
111111
... find_meta=False
112112
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
113113
Traceback (most recent call last):
114114
MetadataError:
115115
116116
>>> f = FieldmapFile(
117-
... testdata_dir / "sub-01" / "fmap" / "sub-01_phasediff.nii.gz"
117+
... dsA_dir / "sub-01" / "fmap" / "sub-01_phasediff.nii.gz"
118118
... )
119119
>>> f.metadata['EchoTime2']
120120
0.00746
121121
122122
>>> FieldmapFile(
123-
... testdata_dir / "sub-01" / "fmap" / "sub-01_phase2.nii.gz",
123+
... dsA_dir / "sub-01" / "fmap" / "sub-01_phase2.nii.gz",
124124
... find_meta=False
125125
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
126126
Traceback (most recent call last):
127127
MetadataError:
128128
129129
>>> f = FieldmapFile(
130-
... testdata_dir / "sub-01" / "fmap" / "sub-01_phase2.nii.gz"
130+
... dsA_dir / "sub-01" / "fmap" / "sub-01_phase2.nii.gz"
131131
... )
132132
>>> f.metadata['EchoTime']
133133
0.00746
134134
135135
>>> FieldmapFile(
136-
... testdata_dir / "sub-01" / "fmap" / "sub-01_fieldmap.nii.gz",
136+
... dsA_dir / "sub-01" / "fmap" / "sub-01_fieldmap.nii.gz",
137137
... find_meta=False
138138
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
139139
Traceback (most recent call last):
140140
MetadataError:
141141
142142
>>> f = FieldmapFile(
143-
... testdata_dir / "sub-01" / "fmap" / "sub-01_fieldmap.nii.gz"
143+
... dsA_dir / "sub-01" / "fmap" / "sub-01_fieldmap.nii.gz"
144144
... )
145145
>>> f.metadata['Units']
146146
'rad/s'

sdcflows/interfaces/epi.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
traits,
99
SimpleInterface,
1010
)
11+
from nipype.utils.filemanip import fname_presuffix
1112

1213

1314
class _GetReadoutTimeInputSpec(BaseInterfaceInputSpec):
@@ -35,3 +36,27 @@ def _run_interface(self, runtime):
3536
)
3637
self._results["pe_direction"] = self.inputs.metadata["PhaseEncodingDirection"]
3738
return runtime
39+
40+
41+
class _EPIMaskInputSpec(BaseInterfaceInputSpec):
42+
in_file = File(exists=True, desc="EPI image")
43+
44+
45+
class _EPIMaskOutputSpec(TraitedSpec):
46+
out_file = File(exists=True)
47+
48+
49+
class EPIMask(SimpleInterface):
50+
"""Calculate the readout time from available metadata."""
51+
52+
input_spec = _EPIMaskInputSpec
53+
output_spec = _EPIMaskOutputSpec
54+
55+
def _run_interface(self, runtime):
56+
from ..utils.epimanip import epi_mask
57+
58+
self._results["out_file"] = epi_mask(
59+
self.inputs.in_file,
60+
fname_presuffix(self.inputs.in_file, suffix="_mask", newpath=runtime.cwd),
61+
)
62+
return runtime

sdcflows/tests/data/epi.nii.gz

1.86 MB
Binary file not shown.

sdcflows/tests/test_fieldmaps.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
from .. import fieldmaps as fm
44

55

6-
def test_FieldmapFile(testdata_dir):
6+
def test_FieldmapFile(dsA_dir):
77
"""Test one existing file."""
8-
fm.FieldmapFile(testdata_dir / "sub-01" / "anat" / "sub-01_T1w.nii.gz")
8+
fm.FieldmapFile(dsA_dir / "sub-01" / "anat" / "sub-01_T1w.nii.gz")
99

1010

1111
@pytest.mark.parametrize(
@@ -56,9 +56,9 @@ def test_FieldmapFile(testdata_dir):
5656
),
5757
],
5858
)
59-
def test_FieldmapEstimation(testdata_dir, inputfiles, method, nsources, raises):
59+
def test_FieldmapEstimation(dsA_dir, inputfiles, method, nsources, raises):
6060
"""Test errors."""
61-
sub_dir = testdata_dir / "sub-01"
61+
sub_dir = dsA_dir / "sub-01"
6262

6363
sources = [sub_dir / f for f in inputfiles]
6464

@@ -105,9 +105,9 @@ def test_FieldmapEstimation(testdata_dir, inputfiles, method, nsources, raises):
105105
(("anat/sub-01_T1w.nii.gz", "fmap/sub-01_phase2.nii.gz"), TypeError),
106106
],
107107
)
108-
def test_FieldmapEstimationError(testdata_dir, inputfiles, errortype):
108+
def test_FieldmapEstimationError(dsA_dir, inputfiles, errortype):
109109
"""Test errors."""
110-
sub_dir = testdata_dir / "sub-01"
110+
sub_dir = dsA_dir / "sub-01"
111111

112112
fm.clear_registry()
113113

@@ -117,19 +117,19 @@ def test_FieldmapEstimationError(testdata_dir, inputfiles, errortype):
117117
fm.clear_registry()
118118

119119

120-
def test_FieldmapEstimationIdentifier(monkeypatch, testdata_dir):
120+
def test_FieldmapEstimationIdentifier(monkeypatch, dsA_dir):
121121
"""Check some use cases of B0FieldIdentifier."""
122122
fm.clear_registry()
123123

124124
with pytest.raises(ValueError):
125125
fm.FieldmapEstimation(
126126
[
127127
fm.FieldmapFile(
128-
testdata_dir / "sub-01" / "fmap/sub-01_fieldmap.nii.gz",
128+
dsA_dir / "sub-01" / "fmap/sub-01_fieldmap.nii.gz",
129129
metadata={"Units": "Hz", "B0FieldIdentifier": "fmap_0"},
130130
),
131131
fm.FieldmapFile(
132-
testdata_dir / "sub-01" / "fmap/sub-01_magnitude.nii.gz",
132+
dsA_dir / "sub-01" / "fmap/sub-01_magnitude.nii.gz",
133133
metadata={"Units": "Hz", "B0FieldIdentifier": "fmap_1"},
134134
),
135135
]
@@ -138,15 +138,15 @@ def test_FieldmapEstimationIdentifier(monkeypatch, testdata_dir):
138138
fe = fm.FieldmapEstimation(
139139
[
140140
fm.FieldmapFile(
141-
testdata_dir / "sub-01" / "fmap/sub-01_fieldmap.nii.gz",
141+
dsA_dir / "sub-01" / "fmap/sub-01_fieldmap.nii.gz",
142142
metadata={
143143
"Units": "Hz",
144144
"B0FieldIdentifier": "fmap_0",
145145
"IntendedFor": "file1.nii.gz",
146146
},
147147
),
148148
fm.FieldmapFile(
149-
testdata_dir / "sub-01" / "fmap/sub-01_magnitude.nii.gz",
149+
dsA_dir / "sub-01" / "fmap/sub-01_magnitude.nii.gz",
150150
metadata={"Units": "Hz", "B0FieldIdentifier": "fmap_0"},
151151
),
152152
]
@@ -159,11 +159,11 @@ def test_FieldmapEstimationIdentifier(monkeypatch, testdata_dir):
159159
fm.FieldmapEstimation(
160160
[
161161
fm.FieldmapFile(
162-
testdata_dir / "sub-01" / "fmap/sub-01_fieldmap.nii.gz",
162+
dsA_dir / "sub-01" / "fmap/sub-01_fieldmap.nii.gz",
163163
metadata={"Units": "Hz", "B0FieldIdentifier": "fmap_0"},
164164
),
165165
fm.FieldmapFile(
166-
testdata_dir / "sub-01" / "fmap/sub-01_magnitude.nii.gz",
166+
dsA_dir / "sub-01" / "fmap/sub-01_magnitude.nii.gz",
167167
metadata={"Units": "Hz", "B0FieldIdentifier": "fmap_0"},
168168
),
169169
]
@@ -174,15 +174,15 @@ def test_FieldmapEstimationIdentifier(monkeypatch, testdata_dir):
174174
fe = fm.FieldmapEstimation(
175175
[
176176
fm.FieldmapFile(
177-
testdata_dir / "sub-01" / "fmap/sub-01_fieldmap.nii.gz",
177+
dsA_dir / "sub-01" / "fmap/sub-01_fieldmap.nii.gz",
178178
metadata={
179179
"Units": "Hz",
180180
"B0FieldIdentifier": "fmap_1",
181181
"IntendedFor": ["file1.nii.gz", "file2.nii.gz"],
182182
},
183183
),
184184
fm.FieldmapFile(
185-
testdata_dir / "sub-01" / "fmap/sub-01_magnitude.nii.gz",
185+
dsA_dir / "sub-01" / "fmap/sub-01_magnitude.nii.gz",
186186
metadata={"Units": "Hz", "B0FieldIdentifier": "fmap_1"},
187187
),
188188
]
@@ -192,7 +192,7 @@ def test_FieldmapEstimationIdentifier(monkeypatch, testdata_dir):
192192
assert fm.get_identifier("file2.nii.gz") == ("fmap_1",)
193193
assert not fm.get_identifier("file3.nii.gz")
194194
assert fm.get_identifier(
195-
str(testdata_dir / "sub-01" / "fmap/sub-01_magnitude.nii.gz"), by="sources"
195+
str(dsA_dir / "sub-01" / "fmap/sub-01_magnitude.nii.gz"), by="sources"
196196
) == ("fmap_1",)
197197

198198
with monkeypatch.context() as m:

sdcflows/utils/epimanip.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,48 @@ def get_trt(in_meta, in_file=None):
200200
return ees * (npe - 1)
201201

202202
raise ValueError("Unknown total-readout time specification")
203+
204+
205+
def epi_mask(in_file, out_file=None):
206+
"""Use grayscale morphological operations to obtain a quick mask of EPI data."""
207+
from pathlib import Path
208+
import nibabel as nb
209+
import numpy as np
210+
from scipy import ndimage
211+
from skimage.morphology import ball
212+
213+
if out_file is None:
214+
out_file = Path("mask.nii.gz").absolute()
215+
216+
img = nb.load(in_file)
217+
data = img.get_fdata(dtype="float32")
218+
# First open to blur out the skull around the brain
219+
opened = ndimage.grey_opening(data, structure=ball(3))
220+
# Second, close large vessels and the ventricles
221+
closed = ndimage.grey_closing(opened, structure=ball(2))
222+
223+
# Window filter on percentile 30
224+
closed -= np.percentile(closed, 30)
225+
# Window filter on percentile 90 of data
226+
maxnorm = np.percentile(closed[closed > 0], 90)
227+
closed = np.clip(closed, a_min=0.0, a_max=maxnorm)
228+
# Calculate index of center of masses
229+
cm = tuple(np.round(ndimage.measurements.center_of_mass(closed)).astype(int))
230+
# Erode the picture of the brain by a lot
231+
eroded = ndimage.grey_erosion(closed, structure=ball(5))
232+
# Calculate the residual
233+
wshed = opened - eroded
234+
wshed -= wshed.min()
235+
wshed = np.round(1e3 * wshed / wshed.max()).astype(np.uint16)
236+
markers = np.zeros_like(wshed, dtype=int)
237+
markers[cm] = 2
238+
markers[0, 0, -1] = -1
239+
# Run watershed
240+
labels = ndimage.watershed_ift(wshed, markers)
241+
242+
hdr = img.header.copy()
243+
hdr.set_data_dtype("uint8")
244+
nb.Nifti1Image(
245+
ndimage.binary_dilation(labels == 2, ball(2)).astype("uint8"), img.affine, hdr
246+
).to_filename(out_file)
247+
return out_file
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Test EPI manipulation routines."""
2+
import numpy as np
3+
import nibabel as nb
4+
from ..epimanip import epi_mask
5+
6+
7+
def test_epi_mask(tmpdir, testdata_dir):
8+
"""Check mask algorithm."""
9+
tmpdir.chdir()
10+
mask = epi_mask(testdata_dir / "epi.nii.gz")
11+
assert np.asanyarray(nb.load(mask).dataobj).sum() == 189052

sdcflows/workflows/base.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,16 +123,6 @@ def init_fmap_preproc_wf(
123123
(inputnode, est_wf, [(f, f"inputnode.{f}") for f in fields])
124124
])
125125
# fmt:on
126-
else:
127-
# PEPOLAR and ANAT do not produce masks
128-
# fmt:off
129-
workflow.connect([
130-
(est_wf, fmap_reports_wf, [
131-
("outputnode.fmap_mask", "inputnode.fmap_mask"),
132-
]),
133-
(est_wf, out_map, [("outputnode.fmap_mask", "fmap_mask")]),
134-
])
135-
# fmt:on
136126

137127
# fmt:off
138128
workflow.connect([
@@ -144,11 +134,13 @@ def init_fmap_preproc_wf(
144134
(est_wf, fmap_reports_wf, [
145135
("outputnode.fmap", "inputnode.fieldmap"),
146136
("outputnode.fmap_ref", "inputnode.fmap_ref"),
137+
("outputnode.fmap_mask", "inputnode.fmap_mask"),
147138
]),
148139
(est_wf, out_map, [
149140
("outputnode.fmap", "fmap"),
150141
("outputnode.fmap_ref", "fmap_ref"),
151142
("outputnode.fmap_coeff", "fmap_coeff"),
143+
("outputnode.fmap_mask", "fmap_mask"),
152144
]),
153145
])
154146
# fmt:on

0 commit comments

Comments
 (0)