Skip to content

Commit 73fa73b

Browse files
committed
ENH: Enable resampling morphometrics to fsLR CIFTI-2 files
1 parent e271d75 commit 73fa73b

File tree

5 files changed

+450
-1
lines changed

5 files changed

+450
-1
lines changed

smriprep/interfaces/cifti.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import json
2+
from pathlib import Path
3+
import typing as ty
4+
5+
import nibabel as nb
6+
import numpy as np
7+
8+
from nibabel import cifti2 as ci
9+
from nipype.interfaces.base import TraitedSpec, traits, File, SimpleInterface
10+
from nipype.utils.filemanip import split_filename
11+
from templateflow import api as tf
12+
13+
14+
class _GenerateDScalarInputSpec(TraitedSpec):
15+
surface_target = traits.Enum(
16+
"fsLR",
17+
usedefault=True,
18+
desc="CIFTI surface target space",
19+
)
20+
grayordinates = traits.Enum(
21+
"91k", "170k", usedefault=True, desc="Final CIFTI grayordinates"
22+
)
23+
scalar_surfs = traits.List(
24+
File(exists=True),
25+
mandatory=True,
26+
desc="list of surface BOLD GIFTI files (length 2 with order [L,R])",
27+
)
28+
scalar_name = traits.Str(mandatory=True, desc="Name of scalar")
29+
30+
31+
class _GenerateDScalarOutputSpec(TraitedSpec):
32+
out_file = File(desc="generated CIFTI file")
33+
out_metadata = File(desc="CIFTI metadata JSON")
34+
35+
36+
class GenerateDScalar(SimpleInterface):
37+
"""
38+
Generate a HCP-style CIFTI-2 image from scalar surface files.
39+
"""
40+
input_spec = _GenerateDScalarInputSpec
41+
output_spec = _GenerateDScalarOutputSpec
42+
43+
def _run_interface(self, runtime):
44+
45+
surface_labels, metadata = _prepare_cifti(self.inputs.grayordinates)
46+
self._results["out_file"] = _create_cifti_image(
47+
self.inputs.scalar_surfs,
48+
surface_labels,
49+
self.inputs.scalar_name,
50+
metadata,
51+
)
52+
metadata_file = Path("dscalar.json").absolute()
53+
metadata_file.write_text(json.dumps(metadata, indent=2))
54+
self._results["out_metadata"] = str(metadata_file)
55+
return runtime
56+
57+
58+
def _prepare_cifti(grayordinates: str) -> ty.Tuple[list, dict]:
59+
"""
60+
Fetch the required templates needed for CIFTI-2 generation, based on input surface density.
61+
62+
Parameters
63+
----------
64+
grayordinates :
65+
Total CIFTI grayordinates (91k, 170k)
66+
67+
Returns
68+
-------
69+
surface_labels
70+
Surface label files for vertex inclusion/exclusion.
71+
metadata
72+
Dictionary with BIDS metadata.
73+
74+
Examples
75+
--------
76+
>>> surface_labels, metadata = _prepare_cifti('91k')
77+
>>> surface_labels # doctest: +ELLIPSIS
78+
['.../tpl-fsLR_hemi-L_den-32k_desc-nomedialwall_dparc.label.gii', \
79+
'.../tpl-fsLR_hemi-R_den-32k_desc-nomedialwall_dparc.label.gii']
80+
>>> metadata # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE
81+
{'Density': '91,282 grayordinates corresponding to all of the grey matter sampled at a \
82+
2mm average vertex spacing...', 'SpatialReference': {'CIFTI_STRUCTURE_CORTEX_LEFT': ...}}
83+
84+
"""
85+
86+
grayord_key = {
87+
"91k": {
88+
"surface-den": "32k",
89+
"tf-res": "02",
90+
"grayords": "91,282",
91+
"res-mm": "2mm"
92+
},
93+
"170k": {
94+
"surface-den": "59k",
95+
"tf-res": "06",
96+
"grayords": "170,494",
97+
"res-mm": "1.6mm"
98+
}
99+
}
100+
if grayordinates not in grayord_key:
101+
raise NotImplementedError("Grayordinates {grayordinates} is not supported.")
102+
103+
total_grayords = grayord_key[grayordinates]['grayords']
104+
res_mm = grayord_key[grayordinates]['res-mm']
105+
surface_density = grayord_key[grayordinates]['surface-den']
106+
# Fetch templates
107+
surface_labels = [
108+
str(
109+
tf.get(
110+
"fsLR",
111+
density=surface_density,
112+
hemi=hemi,
113+
desc="nomedialwall",
114+
suffix="dparc",
115+
raise_empty=True,
116+
)
117+
)
118+
for hemi in ("L", "R")
119+
]
120+
121+
tf_url = "https://templateflow.s3.amazonaws.com"
122+
surfaces_url = ( # midthickness is the default, but varying levels of inflation are all valid
123+
f"{tf_url}/tpl-fsLR/tpl-fsLR_den-{surface_density}_hemi-%s_midthickness.surf.gii"
124+
)
125+
metadata = {
126+
"Density": (
127+
f"{total_grayords} grayordinates corresponding to all of the grey matter sampled at a "
128+
f"{res_mm} average vertex spacing on the surface"
129+
),
130+
"SpatialReference": {
131+
"CIFTI_STRUCTURE_CORTEX_LEFT": surfaces_url % "L",
132+
"CIFTI_STRUCTURE_CORTEX_RIGHT": surfaces_url % "R",
133+
}
134+
}
135+
return surface_labels, metadata
136+
137+
138+
def _create_cifti_image(
139+
scalar_surfs: ty.Tuple[str, str],
140+
surface_labels: ty.Tuple[str, str],
141+
scalar_name: str,
142+
metadata: ty.Optional[dict] = None,
143+
):
144+
"""
145+
Generate CIFTI image in target space.
146+
147+
Parameters
148+
----------
149+
scalar_surfs
150+
Surface scalar files (L,R)
151+
surface_labels
152+
Surface label files used to remove medial wall (L,R)
153+
metadata
154+
Metadata to include in CIFTI header
155+
scalar_name
156+
Name to apply to scalar map
157+
158+
Returns
159+
-------
160+
out :
161+
BOLD data saved as CIFTI dtseries
162+
"""
163+
brainmodels = []
164+
arrays = []
165+
166+
for idx, hemi in enumerate(('left', 'right')):
167+
labels = nb.load(surface_labels[idx])
168+
mask = np.bool_[labels.darrays[0].data]
169+
170+
struct = f'cortex_{hemi}'
171+
brainmodels.append(
172+
ci.BrainModelAxis(struct, vertex=np.nonzero(mask)[0], nvertices={struct: len(mask)})
173+
)
174+
175+
morph_scalar = nb.load(scalar_surfs[idx])
176+
arrays.append(morph_scalar.darrays[0].data[mask].astype("float32"))
177+
178+
# provide some metadata to CIFTI matrix
179+
if not metadata:
180+
metadata = {
181+
"surface": "fsLR",
182+
}
183+
184+
# generate and save CIFTI image
185+
hdr = ci.Cifti2Header.from_axes(
186+
(ci.ScalarAxis([scalar_name]), brainmodels[0] + brainmodels[1])
187+
)
188+
hdr.matrix.metadata.update(metadata)
189+
190+
img = ci.Cifti2Image(dataobj=np.atleast_2d(np.concatenate(arrays)), header=hdr)
191+
img.nifti_header.set_intent("NIFTI_INTENT_CONNECTIVITY_DENSE_SCALARS")
192+
193+
out_file = "{}.dscalar.nii".format(split_filename(scalar_surfs[0])[1])
194+
img.to_filename(out_file)
195+
return Path.cwd() / out_file

smriprep/workflows/anatomical.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
from ..utils.misc import apply_lut as _apply_bids_lut, fs_isRunning as _fs_isRunning
5252
from .norm import init_anat_norm_wf
5353
from .outputs import init_anat_reports_wf, init_anat_derivatives_wf
54-
from .surfaces import init_anat_ribbon_wf, init_surface_recon_wf
54+
from .surfaces import init_anat_ribbon_wf, init_surface_recon_wf, init_morph_grayords_wf
5555

5656
LOGGER = logging.getLogger("nipype.workflow")
5757

@@ -69,6 +69,7 @@ def init_anat_preproc_wf(
6969
skull_strip_mode,
7070
skull_strip_template,
7171
spaces,
72+
cifti_output=False,
7273
debug=False,
7374
existing_derivatives=None,
7475
name="anat_preproc_wf",
@@ -464,6 +465,7 @@ def _check_img(img):
464465
t2w=t2w,
465466
output_dir=output_dir,
466467
spaces=spaces,
468+
cifti_output=cifti_output,
467469
)
468470

469471
# fmt:off
@@ -639,6 +641,22 @@ def _check_img(img):
639641
])
640642
# fmt:on
641643

644+
if cifti_output:
645+
morph_grayords_wf = init_morph_grayords_wf(grayord_density=cifti_output)
646+
anat_derivatives_wf.get_node('inputnode').inputs.cifti_density = cifti_output
647+
# fmt:off
648+
workflow.connect([
649+
(surface_recon_wf, morph_grayords_wf, [
650+
('outputnode.subject_id', 'inputnode.subject_id'),
651+
('outputnode.subjects_dir', 'inputnode.subjects_dir'),
652+
]),
653+
(morph_grayords_wf, anat_derivatives_wf, [
654+
("outputnode.cifti_morph", "inputnode.cifti_morph"),
655+
("outputnode.cifti_metadata", "inputnode.cifti_metadata"),
656+
]),
657+
])
658+
# fmt:on
659+
642660
return workflow
643661

644662

smriprep/workflows/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,7 @@ def init_single_subject_wf(
414414
skull_strip_mode=skull_strip_mode,
415415
skull_strip_template=skull_strip_template,
416416
spaces=spaces,
417+
cifti_output=False, # Enabling this needs a CLI flag
417418
)
418419

419420
# fmt:off

smriprep/workflows/outputs.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ def init_anat_derivatives_wf(
204204
t2w,
205205
output_dir,
206206
spaces,
207+
cifti_output,
207208
name="anat_derivatives_wf",
208209
tpm_labels=BIDS_TISSUE_ORDER,
209210
):
@@ -224,6 +225,8 @@ def init_anat_derivatives_wf(
224225
Workflow name (default: anat_derivatives_wf)
225226
tpm_labels : :obj:`tuple`
226227
Tissue probability maps in order
228+
cifti_output : :obj:`bool`
229+
Whether the ``--cifti-output`` flag was set.
227230
228231
Inputs
229232
------
@@ -273,6 +276,12 @@ def init_anat_derivatives_wf(
273276
FreeSurfer's aseg segmentation, in native T1w space
274277
t1w_fs_aparc
275278
FreeSurfer's aparc+aseg segmentation, in native T1w space
279+
cifti_morph
280+
Morphometric CIFTI-2 dscalar files
281+
cifti_density
282+
Grayordinate density
283+
cifti_metadata
284+
JSON files containing metadata dictionaries
276285
277286
"""
278287
from niworkflows.interfaces.utility import KeySelect
@@ -299,6 +308,9 @@ def init_anat_derivatives_wf(
299308
"anat_ribbon",
300309
"t1w_fs_aseg",
301310
"t1w_fs_aparc",
311+
'cifti_metadata',
312+
'cifti_density',
313+
'cifti_morph',
302314
]
303315
),
304316
name="inputnode",
@@ -696,6 +708,27 @@ def init_anat_derivatives_wf(
696708

697709
])
698710
# fmt:on
711+
712+
if cifti_output:
713+
ds_cifti_morph = pe.MapNode(
714+
DerivativesDataSink(
715+
base_directory=output_dir,
716+
suffix=['curv', 'sulc', 'thickness'],
717+
compress=False,
718+
space='fsLR',
719+
),
720+
name='ds_cifti_morph',
721+
run_without_submitting=True,
722+
iterfield=["in_file", "meta_dict", "suffix"],
723+
)
724+
# fmt:off
725+
workflow.connect([
726+
(inputnode, ds_cifti_morph, [('cifti_morph', 'in_file'),
727+
('source_file', 'source_file'),
728+
('cifti_density', 'density'),
729+
(('cifti_metadata', _read_json), 'meta_dict')])
730+
])
731+
# fmt:on
699732
return workflow
700733

701734

@@ -796,3 +829,10 @@ def _combine_cohort(in_template):
796829
return template
797830
return f"{template}+{in_template.split('cohort-')[-1].split(':')[0]}"
798831
return [_combine_cohort(v) for v in in_template]
832+
833+
834+
def _read_json(in_file):
835+
from json import loads
836+
from pathlib import Path
837+
838+
return loads(Path(in_file).read_text())

0 commit comments

Comments
 (0)