Skip to content

Commit 29f0def

Browse files
authored
Merge pull request #482 from tsalo/cortex-mask
ENH: Generate cortex mask in anat_fit_wf
2 parents 7679ad3 + 04643a4 commit 29f0def

File tree

6 files changed

+231
-14
lines changed

6 files changed

+231
-14
lines changed

.circleci/ds005_outputs.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ smriprep/sub-01/anat/sub-01_dseg.nii.gz
2222
smriprep/sub-01/anat/sub-01_from-fsnative_to-T1w_mode-image_xfm.txt
2323
smriprep/sub-01/anat/sub-01_from-T1w_to-fsnative_mode-image_xfm.txt
2424
smriprep/sub-01/anat/sub-01_hemi-L_curv.shape.gii
25+
smriprep/sub-01/anat/sub-01_hemi-L_desc-cortex_mask.json
26+
smriprep/sub-01/anat/sub-01_hemi-L_desc-cortex_mask.label.gii
2527
smriprep/sub-01/anat/sub-01_hemi-L_inflated.surf.gii
2628
smriprep/sub-01/anat/sub-01_hemi-L_midthickness.surf.gii
2729
smriprep/sub-01/anat/sub-01_hemi-L_pial.surf.gii
@@ -36,6 +38,8 @@ smriprep/sub-01/anat/sub-01_hemi-L_sulc.shape.gii
3638
smriprep/sub-01/anat/sub-01_hemi-L_thickness.shape.gii
3739
smriprep/sub-01/anat/sub-01_hemi-L_white.surf.gii
3840
smriprep/sub-01/anat/sub-01_hemi-R_curv.shape.gii
41+
smriprep/sub-01/anat/sub-01_hemi-R_desc-cortex_mask.json
42+
smriprep/sub-01/anat/sub-01_hemi-R_desc-cortex_mask.label.gii
3943
smriprep/sub-01/anat/sub-01_hemi-R_inflated.surf.gii
4044
smriprep/sub-01/anat/sub-01_hemi-R_midthickness.surf.gii
4145
smriprep/sub-01/anat/sub-01_hemi-R_pial.surf.gii

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ dependencies = [
2828
"nibabel >= 4.0.1",
2929
"nipype >= 1.8.5",
3030
"nireports >= 25.2.0",
31-
"niworkflows >= 1.13.4",
31+
"niworkflows @ git+https://github.com/nipreps/niworkflows.git@master",
3232
"numpy >= 1.24",
3333
"packaging >= 24",
3434
"pybids >= 0.16",

src/smriprep/data/io_spec.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,14 @@
144144
"desc": "msmsulc",
145145
"suffix": "sphere",
146146
"extension": ".surf.gii"
147+
},
148+
"cortex_mask": {
149+
"datatype": "anat",
150+
"hemi": ["L", "R"],
151+
"space": null,
152+
"desc": "cortex",
153+
"suffix": "mask",
154+
"extension": ".label.gii"
147155
}
148156
},
149157
"masks": {

src/smriprep/workflows/anatomical.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
init_ds_fs_segs_wf,
7070
init_ds_grayord_metrics_wf,
7171
init_ds_mask_wf,
72+
init_ds_surface_masks_wf,
7273
init_ds_surface_metrics_wf,
7374
init_ds_surfaces_wf,
7475
init_ds_template_registration_wf,
@@ -78,6 +79,7 @@
7879
)
7980
from .surfaces import (
8081
init_anat_ribbon_wf,
82+
init_cortex_masks_wf,
8183
init_fsLR_reg_wf,
8284
init_gifti_morphometrics_wf,
8385
init_gifti_surfaces_wf,
@@ -429,12 +431,12 @@ def init_anat_preproc_wf(
429431
f"outputnode.sphere_reg_{'msm' if msm_sulc else 'fsLR'}",
430432
'inputnode.sphere_reg_fsLR',
431433
),
434+
('outputnode.cortex_mask', 'inputnode.roi'),
432435
]),
433436
(hcp_morphometrics_wf, morph_grayords_wf, [
434437
('outputnode.curv', 'inputnode.curv'),
435438
('outputnode.sulc', 'inputnode.sulc'),
436439
('outputnode.thickness', 'inputnode.thickness'),
437-
('outputnode.roi', 'inputnode.roi'),
438440
]),
439441
(resample_surfaces_wf, morph_grayords_wf, [
440442
('outputnode.midthickness_fsLR', 'inputnode.midthickness_fsLR'),
@@ -668,6 +670,7 @@ def init_anat_fit_wf(
668670
'sphere_reg',
669671
'sphere_reg_fsLR',
670672
'sphere_reg_msm',
673+
'cortex_mask',
671674
'anat_ribbon',
672675
# Reverse transform; not computable from forward transform
673676
'std2anat_xfm',
@@ -1344,6 +1347,32 @@ def init_anat_fit_wf(
13441347
else:
13451348
LOGGER.info('ANAT Stage 10: MSM-Sulc disabled')
13461349

1350+
# Stage 11: Cortical surface mask
1351+
if len(precomputed.get('cortex_mask', [])) < 2:
1352+
LOGGER.info('ANAT Stage 11: Creating cortical surface mask')
1353+
1354+
cortex_masks_wf = init_cortex_masks_wf()
1355+
ds_cortex_masks_wf = init_ds_surface_masks_wf(
1356+
output_dir=output_dir,
1357+
mask_type='cortex',
1358+
name='ds_cortex_masks_wf',
1359+
)
1360+
1361+
workflow.connect([
1362+
(surfaces_buffer, cortex_masks_wf, [
1363+
('midthickness', 'inputnode.midthickness'),
1364+
('thickness', 'inputnode.thickness'),
1365+
]),
1366+
(cortex_masks_wf, ds_cortex_masks_wf, [
1367+
('outputnode.cortex_masks', 'inputnode.mask_files'),
1368+
('outputnode.source_files', 'inputnode.source_files'),
1369+
]),
1370+
(ds_cortex_masks_wf, outputnode, [('outputnode.mask_files', 'cortex_mask')]),
1371+
]) # fmt:skip
1372+
else:
1373+
LOGGER.info('ANAT Stage 11: Found pre-computed cortical surface mask')
1374+
outputnode.inputs.cortex_mask = sorted(precomputed['cortex_mask'])
1375+
13471376
return workflow
13481377

13491378

src/smriprep/workflows/outputs.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,6 +1231,101 @@ def init_template_iterator_wf(
12311231
return workflow
12321232

12331233

1234+
def init_ds_surface_masks_wf(
1235+
*,
1236+
output_dir: str,
1237+
mask_type: ty.Literal['cortex', 'roi', 'ribbon', 'brain'],
1238+
entities: dict[str, str] | None = None,
1239+
name='ds_surface_masks_wf',
1240+
) -> Workflow:
1241+
"""Save GIFTI surface masks.
1242+
1243+
Parameters
1244+
----------
1245+
output_dir : :class:`str`
1246+
Directory in which to save derivatives
1247+
mask_type : :class:`str`
1248+
Type of mask to save
1249+
entities : :class:`dict` of :class:`str`
1250+
Entities to include in outputs
1251+
name : :class:`str`
1252+
Workflow name (default: ds_surface_masks_wf)
1253+
1254+
Inputs
1255+
------
1256+
source_files : list of lists of str
1257+
List of lists of source files.
1258+
Left hemisphere sources first, then right hemisphere sources.
1259+
mask_files : list of str
1260+
List of input mask files.
1261+
Left hemisphere mask first, then right hemisphere mask.
1262+
1263+
Outputs
1264+
-------
1265+
mask_files : list of str
1266+
List of output mask files.
1267+
Left hemisphere mask first, then right hemisphere mask.
1268+
"""
1269+
workflow = Workflow(name=name)
1270+
1271+
if entities is None:
1272+
entities = {}
1273+
1274+
inputnode = pe.Node(
1275+
niu.IdentityInterface(fields=['mask_files', 'source_files']),
1276+
name='inputnode',
1277+
)
1278+
outputnode = pe.JoinNode(
1279+
niu.IdentityInterface(fields=['mask_files']), name='outputnode', joinsource='ds_itersource'
1280+
)
1281+
1282+
ds_itersource = pe.Node(
1283+
niu.IdentityInterface(fields=['hemi']),
1284+
name='ds_itersource',
1285+
iterables=[('hemi', ['L', 'R'])],
1286+
)
1287+
1288+
sources = pe.Node(niu.Function(function=_bids_relative), name='sources')
1289+
sources.inputs.bids_root = output_dir
1290+
1291+
select_files = pe.Node(
1292+
KeySelect(fields=['mask_file', 'sources'], keys=['L', 'R']),
1293+
name='select_files',
1294+
run_without_submitting=True,
1295+
)
1296+
1297+
ds_surf_mask = pe.Node(
1298+
DerivativesDataSink(
1299+
base_directory=output_dir,
1300+
suffix='mask',
1301+
desc=mask_type,
1302+
extension='.label.gii',
1303+
Type='Brain' if mask_type == 'brain' else 'ROI',
1304+
**entities,
1305+
),
1306+
name='ds_surf_mask',
1307+
run_without_submitting=True,
1308+
)
1309+
1310+
workflow.connect([
1311+
(inputnode, select_files, [
1312+
('mask_files', 'mask_file'),
1313+
('source_files', 'sources'),
1314+
]),
1315+
(select_files, sources, [('sources', 'in_files')]),
1316+
(ds_itersource, select_files, [('hemi', 'key')]),
1317+
(ds_itersource, ds_surf_mask, [('hemi', 'hemi')]),
1318+
(select_files, ds_surf_mask, [
1319+
('mask_file', 'in_file'),
1320+
(('sources', _pop), 'source_file'),
1321+
]),
1322+
(sources, ds_surf_mask, [('out', 'Sources')]),
1323+
(ds_surf_mask, outputnode, [('out_file', 'mask_files')]),
1324+
]) # fmt: skip
1325+
1326+
return workflow
1327+
1328+
12341329
def _bids_relative(in_files, bids_root):
12351330
from pathlib import Path
12361331

@@ -1335,3 +1430,7 @@ def _read_json(in_file):
13351430
from pathlib import Path
13361431

13371432
return loads(Path(in_file).read_text())
1433+
1434+
1435+
def _pop(in_list):
1436+
return in_list[0]

src/smriprep/workflows/surfaces.py

Lines changed: 89 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,8 +1071,6 @@ def init_hcp_morphometrics_wf(
10711071
HCP-style curvature file in GIFTI format
10721072
sulc
10731073
HCP-style sulcal depth file in GIFTI format
1074-
roi
1075-
HCP-style cortical ROI file in GIFTI format
10761074
"""
10771075
DEFAULT_MEMORY_MIN_GB = 0.01
10781076

@@ -1090,7 +1088,7 @@ def init_hcp_morphometrics_wf(
10901088
)
10911089

10921090
outputnode = pe.JoinNode(
1093-
niu.IdentityInterface(fields=['thickness', 'curv', 'sulc', 'roi']),
1091+
niu.IdentityInterface(fields=['thickness', 'curv', 'sulc']),
10941092
name='outputnode',
10951093
joinsource='itersource',
10961094
)
@@ -1115,11 +1113,6 @@ def init_hcp_morphometrics_wf(
11151113
# Thickness is presumably already positive, but HCP uses abs(-thickness)
11161114
abs_thickness = pe.Node(MetricMath(metric='thickness', operation='abs'), name='abs_thickness')
11171115

1118-
# Native ROI is thickness > 0, with holes and islands filled
1119-
initial_roi = pe.Node(MetricMath(metric='roi', operation='bin'), name='initial_roi')
1120-
fill_holes = pe.Node(MetricFillHoles(), name='fill_holes', mem_gb=DEFAULT_MEMORY_MIN_GB)
1121-
native_roi = pe.Node(MetricRemoveIslands(), name='native_roi', mem_gb=DEFAULT_MEMORY_MIN_GB)
1122-
11231116
# Dilation happens separately from ROI creation
11241117
dilate_curv = pe.Node(
11251118
MetricDilate(distance=10, nearest=True),
@@ -1158,15 +1151,99 @@ def init_hcp_morphometrics_wf(
11581151
(dilate_curv, outputnode, [('out_file', 'curv')]),
11591152
(dilate_thickness, outputnode, [('out_file', 'thickness')]),
11601153
(invert_sulc, outputnode, [('metric_file', 'sulc')]),
1161-
# Native ROI file from thickness
1162-
(inputnode, initial_roi, [('subject_id', 'subject_id')]),
1154+
]) # fmt:skip
1155+
1156+
return workflow
1157+
1158+
1159+
def init_cortex_masks_wf(
1160+
*,
1161+
name: str = 'cortex_masks_wf',
1162+
):
1163+
"""Create cortical surface masks from surface files.
1164+
1165+
Workflow Graph
1166+
.. workflow::
1167+
:graph2use: orig
1168+
:simple_form: yes
1169+
1170+
from smriprep.workflows.surfaces import init_cortex_masks_wf
1171+
wf = init_cortex_masks_wf()
1172+
1173+
Inputs
1174+
------
1175+
midthickness : len-2 list of str
1176+
Each hemisphere's FreeSurfer midthickness surface file in GIFTI format
1177+
thickness : len-2 list of str
1178+
Each hemisphere's FreeSurfer thickness file in GIFTI format
1179+
1180+
Outputs
1181+
-------
1182+
cortex_masks : len-2 list of str
1183+
Cortical surface mask in GIFTI format for each hemisphere
1184+
source_files : len-2 list of lists of str
1185+
Each hemisphere's source files, which are used to create the mask
1186+
"""
1187+
DEFAULT_MEMORY_MIN_GB = 0.01
1188+
1189+
workflow = Workflow(name=name)
1190+
1191+
inputnode = pe.Node(
1192+
niu.IdentityInterface(fields=['midthickness', 'thickness']),
1193+
name='inputnode',
1194+
)
1195+
1196+
itersource = pe.Node(
1197+
niu.IdentityInterface(fields=['hemi']),
1198+
name='itersource',
1199+
iterables=[('hemi', ['L', 'R'])],
1200+
)
1201+
1202+
outputnode = pe.JoinNode(
1203+
niu.IdentityInterface(fields=['cortex_masks', 'source_files']),
1204+
name='outputnode',
1205+
joinsource='itersource',
1206+
)
1207+
1208+
select_surfaces = pe.Node(
1209+
KeySelect(fields=['thickness', 'midthickness'], keys=['L', 'R']),
1210+
name='select_surfaces',
1211+
run_without_submitting=True,
1212+
)
1213+
1214+
combine_sources = pe.Node(niu.Merge(2), name='combine_sources', run_without_submitting=True)
1215+
1216+
abs_thickness = pe.Node(
1217+
MetricMath(metric='thickness', operation='abs'),
1218+
name='abs_thickness',
1219+
mem_gb=DEFAULT_MEMORY_MIN_GB,
1220+
)
1221+
initial_roi = pe.Node(
1222+
MetricMath(metric='roi', operation='bin'), name='initial_roi', mem_gb=DEFAULT_MEMORY_MIN_GB
1223+
)
1224+
fill_holes = pe.Node(MetricFillHoles(), name='fill_holes', mem_gb=DEFAULT_MEMORY_MIN_GB)
1225+
native_roi = pe.Node(MetricRemoveIslands(), name='native_roi', mem_gb=DEFAULT_MEMORY_MIN_GB)
1226+
1227+
workflow.connect([
1228+
(inputnode, select_surfaces, [
1229+
('thickness', 'thickness'),
1230+
('midthickness', 'midthickness'),
1231+
]),
1232+
(itersource, select_surfaces, [('hemi', 'key')]),
1233+
(itersource, abs_thickness, [('hemi', 'hemisphere')]),
11631234
(itersource, initial_roi, [('hemi', 'hemisphere')]),
1164-
(abs_thickness, initial_roi, [('metric_file', 'metric_file')]),
1235+
(select_surfaces, abs_thickness, [('thickness', 'metric_file')]),
11651236
(select_surfaces, fill_holes, [('midthickness', 'surface_file')]),
11661237
(select_surfaces, native_roi, [('midthickness', 'surface_file')]),
1238+
(abs_thickness, initial_roi, [('metric_file', 'metric_file')]),
11671239
(initial_roi, fill_holes, [('metric_file', 'metric_file')]),
11681240
(fill_holes, native_roi, [('out_file', 'metric_file')]),
1169-
(native_roi, outputnode, [('out_file', 'roi')]),
1241+
(native_roi, outputnode, [('out_file', 'cortex_masks')]),
1242+
(select_surfaces, combine_sources, [
1243+
('midthickness', 'in1'),
1244+
('thickness', 'in2'),
1245+
]),
1246+
(combine_sources, outputnode, [('out', 'source_files')]),
11701247
]) # fmt:skip
11711248

11721249
return workflow

0 commit comments

Comments
 (0)