Skip to content

Commit ecbf103

Browse files
mgxdoestebaneffigies
authored
ENH: Add CIFTI surface plot (#663)
* ENH: Add CIFTI surface plot * DEP: Add surfplot to installation requirements * DEP: Bump matplotlib minimum and remove compatibility pin * ENH: Plot average BOLD signal * ENH: Add helper function / pytest fixture for data creation * TST: Add test for CIFTI plot * STY: Placate flake8 * FIX: Operate within tmp directory * MAINT: Bump templateflow requirement to reflect new templates * TST: Allow pytest to invoke xvfb * MAINT: Add `tests` as installation extra * DOC: Add intersphinx source for surfplot * ENH: Add parameter to control data clipping * Apply suggestions from code review Co-authored-by: Oscar Esteban <[email protected]> * FIX: Leverage existing numpy function; set default to clip at 0 * Update niworkflows/viz/plots.py Co-authored-by: Chris Markiewicz <[email protected]> * Update niworkflows/viz/plots.py Co-authored-by: Chris Markiewicz <[email protected]> Co-authored-by: Oscar Esteban <[email protected]> Co-authored-by: Chris Markiewicz <[email protected]>
1 parent ab3bcab commit ecbf103

File tree

5 files changed

+215
-4
lines changed

5 files changed

+215
-4
lines changed

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@
242242
"python": ("https://docs.python.org/3/", None),
243243
"scipy": ("https://docs.scipy.org/doc/scipy/reference", None),
244244
"smriprep": ("https://www.nipreps.org/smriprep/", None),
245+
"surfplot": ("https://surfplot.readthedocs.io/en/latest/", None),
245246
"templateflow": ("https://www.templateflow.org/python-client", None),
246247
}
247248

niworkflows/tests/generate_data.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from pathlib import Path
2+
3+
import numpy as np
4+
5+
6+
def _create_dtseries_cifti(timepoints, models):
7+
"""Create a dense timeseries CIFTI-2 file"""
8+
import nibabel.cifti2 as ci
9+
10+
def create_series_map():
11+
return ci.Cifti2MatrixIndicesMap(
12+
(0,),
13+
'CIFTI_INDEX_TYPE_SERIES',
14+
number_of_series_points=timepoints,
15+
series_exponent=0,
16+
series_start=0,
17+
series_step=1,
18+
series_unit='SECOND'
19+
)
20+
21+
def create_geometry_map():
22+
index_offset = 0
23+
brain_models = []
24+
timeseries = np.zeros((timepoints, 0))
25+
26+
for name, data in models:
27+
if "CORTEX" in name:
28+
model_type = "CIFTI_MODEL_TYPE_SURFACE"
29+
attr = "vertex_indices"
30+
indices = ci.Cifti2VertexIndices(np.arange(len(data)))
31+
else:
32+
model_type = "CIFTI_MODEL_TYPE_VOXELS"
33+
attr = "voxel_indices_ijk"
34+
indices = ci.Cifti2VoxelIndicesIJK(np.arange(len(data)))
35+
bm = ci.Cifti2BrainModel(
36+
index_offset=index_offset,
37+
index_count=len(data),
38+
model_type=model_type,
39+
brain_structure=name,
40+
)
41+
setattr(bm, attr, indices)
42+
index_offset += len(data)
43+
brain_models.append(bm)
44+
timeseries = np.column_stack((timeseries, data.T))
45+
46+
brain_models.append(
47+
ci.Cifti2Volume(
48+
(4, 4, 4),
49+
ci.Cifti2TransformationMatrixVoxelIndicesIJKtoXYZ(-3, np.eye(4)),
50+
)
51+
)
52+
53+
return ci.Cifti2MatrixIndicesMap(
54+
(1,),
55+
"CIFTI_INDEX_TYPE_BRAIN_MODELS",
56+
maps=brain_models,
57+
), timeseries
58+
59+
matrix = ci.Cifti2Matrix()
60+
series_map = create_series_map()
61+
geometry_map, ts = create_geometry_map()
62+
matrix.append(series_map)
63+
matrix.append(geometry_map)
64+
hdr = ci.Cifti2Header(matrix)
65+
img = ci.Cifti2Image(dataobj=ts, header=hdr)
66+
img.nifti_header.set_intent("NIFTI_INTENT_CONNECTIVITY_DENSE_SERIES")
67+
68+
out_file = Path("test.dtseries.nii").absolute()
69+
ci.save(img, out_file)
70+
return out_file

niworkflows/tests/test_viz.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,15 @@
2222
#
2323
"""Test viz module"""
2424
import os
25+
from pathlib import Path
26+
2527
import numpy as np
2628
import nibabel as nb
27-
from .. import viz
29+
import pytest
30+
2831
from .conftest import datadir
29-
from pathlib import Path
32+
from .generate_data import _create_dtseries_cifti
33+
from .. import viz
3034

3135

3236
def test_carpetplot():
@@ -149,3 +153,25 @@ def test_compcor_variance_plot(tmp_path):
149153
out_file = str(out_dir / "variance_plot_short.svg")
150154
metadata_file = os.path.join(datadir, "confounds_metadata_short_test.tsv")
151155
viz.plots.compcor_variance_plot([metadata_file], output_file=out_file)
156+
157+
158+
@pytest.fixture
159+
def create_surface_dtseries():
160+
"""Create a dense timeseries CIFTI-2 file with only cortex structures"""
161+
out_file = _create_dtseries_cifti(
162+
timepoints=10,
163+
models=[
164+
('CIFTI_STRUCTURE_CORTEX_LEFT', np.random.rand(29696, 10)),
165+
('CIFTI_STRUCTURE_CORTEX_RIGHT', np.random.rand(29716, 10)),
166+
],
167+
)
168+
yield str(out_file)
169+
out_file.unlink()
170+
171+
172+
def test_cifti_surfaces_plot(tmp_path, create_surface_dtseries):
173+
"""Test plotting CIFTI-2 surfaces"""
174+
os.chdir(tmp_path)
175+
out_dir = Path(os.getenv("SAVE_CIRCLE_ARTIFACTS", str(tmp_path)))
176+
out_file = str(out_dir / "cifti_surfaces_plot.svg")
177+
viz.plots.cifti_surfaces_plot(create_surface_dtseries, output_file=out_file)

niworkflows/viz/plots.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,102 @@ def confounds_correlation_plot(
10511051
return [ax0, ax1], gs
10521052

10531053

1054+
def cifti_surfaces_plot(
1055+
in_cifti,
1056+
density="32k",
1057+
surface_type="inflated",
1058+
clip_range=(0, None),
1059+
output_file=None,
1060+
**splt_kwargs
1061+
):
1062+
"""
1063+
Plots a CIFTI-2 dense timeseries onto left/right mesh surfaces.
1064+
1065+
Parameters
1066+
----------
1067+
in_cifti : str
1068+
CIFTI-2 dense timeseries (.dtseries.nii)
1069+
density : str
1070+
Surface density
1071+
surface_type : str
1072+
Inflation level of mesh surfaces. Supported: midthickness, inflated, veryinflated
1073+
clip_range : tuple or None
1074+
Range to clip `in_cifti` data prior to plotting.
1075+
If not None, two values must be provided as lower and upper bounds.
1076+
If values are None, no clipping is performed for that bound.
1077+
output_file: :obj:`str` or :obj:`None`
1078+
Path where the output figure should be saved. If this is not defined,
1079+
then the figure will be returned.
1080+
splt_kwargs : dict
1081+
Keyword arguments for :obj:`surfplot.Plot`
1082+
1083+
Outputs
1084+
-------
1085+
figure : matplotlib.pyplot.figure
1086+
Surface plot figure. Returned only if ``output_file`` is ``None``.
1087+
output_file: :obj:`str`
1088+
The file where the figure is saved.
1089+
"""
1090+
import surfplot as splt
1091+
from surfplot.utils import add_fslr_medial_wall
1092+
1093+
def get_surface_meshes(density, surface_type):
1094+
import templateflow.api as tf
1095+
lh, rh = tf.get("fsLR", density=density, suffix=surface_type, extension=[".surf.gii"])
1096+
return str(lh), str(rh)
1097+
1098+
if density != "32k":
1099+
raise NotImplementedError("Only 32k density is currently supported.")
1100+
1101+
img = nb.cifti2.load(in_cifti)
1102+
if img.nifti_header.get_intent()[0] != "ConnDenseSeries":
1103+
raise TypeError(f"{in_cifti} is not a dense timeseries CIFTI file")
1104+
1105+
geo = img.header.get_index_map(1)
1106+
left_cortex, right_cortex = None, None
1107+
for bm in geo.brain_models:
1108+
if bm.brain_structure == "CIFTI_STRUCTURE_CORTEX_LEFT":
1109+
left_cortex = bm
1110+
elif bm.brain_structure == "CIFTI_STRUCTURE_CORTEX_RIGHT":
1111+
right_cortex = bm
1112+
1113+
if left_cortex is None or right_cortex is None:
1114+
raise RuntimeError("CIFTI is missing cortex information")
1115+
1116+
# calculate an average of the BOLD data, excluding the first 5 volumes
1117+
# as potential nonsteady states
1118+
data = img.dataobj[5:20].mean(axis=0)
1119+
1120+
cortex_data = _concat_brain_struct_data((left_cortex, right_cortex), data)
1121+
if density == "32k" and len(cortex_data) != 59412:
1122+
raise ValueError("Cortex data is not in fsLR space")
1123+
# medial wall needs to be added back in
1124+
cortex_data = add_fslr_medial_wall(cortex_data)
1125+
if clip_range:
1126+
cortex_data = np.clip(cortex_data, clip_range[0], clip_range[1], out=cortex_data)
1127+
1128+
lh_data, rh_data = np.array_split(cortex_data, 2)
1129+
1130+
# Build the figure
1131+
lh_mesh, rh_mesh = get_surface_meshes(density, surface_type)
1132+
p = splt.Plot(
1133+
surf_lh=lh_mesh,
1134+
surf_rh=rh_mesh,
1135+
layout=splt_kwargs.pop("layout", "row"),
1136+
**splt_kwargs
1137+
)
1138+
p.add_layer({'left': lh_data, 'right': rh_data}, cmap='YlOrRd_r')
1139+
figure = p.build() # figsize - leave default?
1140+
1141+
if output_file is not None:
1142+
figure.savefig(output_file, bbox_inches="tight")
1143+
plt.close(figure)
1144+
figure = None
1145+
return output_file
1146+
1147+
return figure
1148+
1149+
10541150
def _get_tr(img):
10551151
"""
10561152
Attempt to extract repetition time from NIfTI/CIFTI header
@@ -1093,3 +1189,12 @@ def _decimate_data(data, seg, size):
10931189
if t_dec:
10941190
data = data[:, ::t_dec]
10951191
return data, seg
1192+
1193+
1194+
def _concat_brain_struct_data(structs, data):
1195+
concat_data = np.array([], dtype=data.dtype)
1196+
for struct in structs:
1197+
struct_upper_bound = struct.index_offset + struct.index_count
1198+
struct_data = data[struct.index_offset:struct_upper_bound]
1199+
concat_data = np.concatenate((concat_data, struct_data))
1200+
return concat_data

setup.cfg

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ python_requires = >= 3.7
2727
install_requires =
2828
attrs
2929
jinja2
30-
matplotlib ~= 3.3, >= 3.3.1
30+
matplotlib >= 3.4.2
3131
nibabel ~= 3.0
3232
nilearn >= 0.5.2
3333
nipype ~= 1.5
@@ -40,14 +40,16 @@ install_requires =
4040
scikit-image
4141
scipy
4242
seaborn
43+
surfplot
4344
svgutils >= 0.3.4
4445
transforms3d
45-
templateflow >= 0.6
46+
templateflow >= 0.7.2
4647
test_requires =
4748
coverage >=5.2.1
4849
pytest >= 4.4
4950
pytest-cov
5051
pytest-xdist >= 1.28
52+
pytest-xvfb
5153
packages = find:
5254
zip_safe = true
5355

@@ -81,10 +83,17 @@ pointclouds =
8183
pyntcloud
8284
style =
8385
flake8 >= 3.7.0
86+
tests =
87+
coverage >=5.2.1
88+
pytest >= 4.4
89+
pytest-cov
90+
pytest-xdist >= 1.28
91+
pytest-xvfb
8492
all =
8593
%(doc)s
8694
%(pointclouds)s
8795
%(style)s
96+
%(tests)s
8897

8998
[versioneer]
9099
VCS = git

0 commit comments

Comments
 (0)