Skip to content

Commit affe0d3

Browse files
authored
Merge pull request #480 from nipreps/enh/session-processing
ENH: Integrate per-session processing
2 parents b839bef + 892d054 commit affe0d3

File tree

8 files changed

+400
-46
lines changed

8 files changed

+400
-46
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ dependencies = [
2727
"matplotlib >= 3.5",
2828
"nibabel >= 4.0.1",
2929
"nipype >= 1.8.5",
30-
"niworkflows >= 1.13.1",
30+
"nireports >= 25.2.0",
31+
"niworkflows >= 1.13.4",
3132
"numpy >= 1.24",
3233
"packaging >= 24",
3334
"pybids >= 0.16",

src/smriprep/cli/run.py

Lines changed: 115 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@
2626
def main():
2727
"""Set an entrypoint."""
2828
opts = get_parser().parse_args()
29+
if opts.longitudinal:
30+
opts.subject_anatomical_reference = 'unbiased'
31+
print(
32+
'The "--longitudinal" flag is deprecated. Use '
33+
'"--subject-anatomical-reference unbiased" instead.'
34+
)
35+
36+
if opts.subject_anatomical_reference == 'unbiased':
37+
opts['longitudinal'] = True
2938
return build_opts(opts)
3039

3140

@@ -53,6 +62,9 @@ def get_parser():
5362

5463
import smriprep
5564

65+
def _drop_ses(value):
66+
return value.removeprefix('ses-')
67+
5668
parser = ArgumentParser(
5769
description='sMRIPrep: Structural MRI PREProcessing workflows',
5870
formatter_class=RawTextHelpFormatter,
@@ -97,6 +109,13 @@ def get_parser():
97109
help='a space delimited list of participant identifiers or a single '
98110
'identifier (the sub- prefix can be removed)',
99111
)
112+
g_bids.add_argument(
113+
'--session-label',
114+
nargs='+',
115+
type=_drop_ses,
116+
help='A space delimited list of session identifiers or a single '
117+
'identifier (the ses- prefix can be removed)',
118+
)
100119
g_bids.add_argument(
101120
'-d',
102121
'--derivatives',
@@ -115,6 +134,17 @@ def get_parser():
115134
'{<suffix>:{<entity>:<filter>,...},...} '
116135
'(https://github.com/bids-standard/pybids/blob/master/bids/layout/config/bids.json)',
117136
)
137+
g_bids.add_argument(
138+
'--subject-anatomical-reference',
139+
choices=['first-lex', 'unbiased', 'sessionwise'],
140+
default='first',
141+
help='Method to produce the reference anatomical space:\n'
142+
'\t"first-lex" will use the first image in lexicographical order\n'
143+
'\t"unbiased" will construct an unbiased template from all images '
144+
'(previously "--longitudinal")\n'
145+
'\t"sessionwise" will independently process each session. If multiple runs are '
146+
'found, the behavior will be similar to "first-lex"',
147+
)
118148

119149
g_perfm = parser.add_argument_group('Options to handle performance')
120150
g_perfm.add_argument(
@@ -175,7 +205,7 @@ def get_parser():
175205
g_conf.add_argument(
176206
'--longitudinal',
177207
action='store_true',
178-
help='treat dataset as longitudinal - may increase runtime',
208+
help='DEPRECATED: use "--subject-anatomical-reference unbiased" instead',
179209
)
180210

181211
# ANTs options
@@ -389,7 +419,7 @@ def _warn_redirect(message, category, filename, lineno, file=None, line=None):
389419
plugin_settings = retval['plugin_settings']
390420
bids_dir = retval['bids_dir']
391421
output_dir = retval['output_dir']
392-
subject_list = retval['subject_list']
422+
subject_session_list = retval['subject_session_list']
393423
run_uuid = retval['run_uuid']
394424
retcode = retval['return_code']
395425

@@ -438,15 +468,25 @@ def _warn_redirect(message, category, filename, lineno, file=None, line=None):
438468
_copy_any(dseg_tsv, str(Path(output_dir) / 'smriprep' / 'desc-aparcaseg_dseg.tsv'))
439469
logger.log(25, 'sMRIPrep finished without errors')
440470
finally:
441-
from niworkflows.reports import generate_reports
471+
from nireports.assembler.tools import generate_reports
442472

443-
from ..utils.bids import write_bidsignore, write_derivative_description
473+
from smriprep import data
474+
from smriprep.utils.bids import write_bidsignore, write_derivative_description
444475

445-
logger.log(25, 'Writing reports for participants: %s', ', '.join(subject_list))
476+
logger.log(
477+
25, 'Writing reports for participants: %s', _pprint_subses(subject_session_list)
478+
)
446479
# Generate reports phase
447-
errno += generate_reports(subject_list, output_dir, run_uuid, packagename='smriprep')
448-
write_derivative_description(bids_dir, str(Path(output_dir) / 'smriprep'))
449-
write_bidsignore(Path(output_dir) / 'smriprep')
480+
smriprep_dir = Path(output_dir) / 'smriprep'
481+
bootstrap_file = data.load('reports-spec.yml')
482+
errno += generate_reports(
483+
subject_session_list,
484+
smriprep_dir,
485+
run_uuid,
486+
bootstrap_file=bootstrap_file,
487+
)
488+
write_derivative_description(bids_dir, smriprep_dir)
489+
write_bidsignore(smriprep_dir)
450490
sys.exit(int(errno > 0))
451491

452492

@@ -469,7 +509,7 @@ def build_workflow(opts, retval):
469509
from subprocess import CalledProcessError, TimeoutExpired, check_call
470510
from time import strftime
471511

472-
from bids import BIDSLayout
512+
from bids.layout import BIDSLayout, Query
473513
from nipype import config as ncfg
474514
from nipype import logging
475515
from niworkflows.utils.bids import collect_participants
@@ -482,7 +522,7 @@ def build_workflow(opts, retval):
482522
INIT_MSG = """
483523
Running sMRIPrep version {version}:
484524
* BIDS dataset path: {bids_dir}.
485-
* Participant list: {subject_list}.
525+
* Participants & Sessions: {subject_session_list}.
486526
* Run identifier: {uuid}.
487527
488528
{spaces}
@@ -495,6 +535,31 @@ def build_workflow(opts, retval):
495535
bids_dir = opts.bids_dir.resolve()
496536
layout = BIDSLayout(str(bids_dir), validate=False)
497537
subject_list = collect_participants(layout, participant_label=opts.participant_label)
538+
session_list = opts.session_label or []
539+
540+
subject_session_list = []
541+
for subject in subject_list:
542+
sessions = (
543+
layout.get_sessions(
544+
scope='raw',
545+
subject=subject,
546+
session=session_list or Query.OPTIONAL,
547+
suffix=['T1w', 'T2w'], # TODO: Track supported modalities globally
548+
)
549+
or None
550+
)
551+
552+
if opts.subject_anatomical_reference == 'sessionwise':
553+
if not sessions:
554+
raise RuntimeError(
555+
'--subject-anatomical-reference "sessionwise" was requested, but no sessions '
556+
f'found for subject {subject}.'
557+
)
558+
for session in sessions:
559+
subject_session_list.append((subject, session))
560+
else:
561+
# This will use all sessions either found by layout or passed in via --session-id
562+
subject_session_list.append((subject, sessions))
498563

499564
bids_filters = json.loads(opts.bids_filter_file.read_text()) if opts.bids_filter_file else None
500565

@@ -576,19 +641,29 @@ def build_workflow(opts, retval):
576641
retval['bids_dir'] = str(bids_dir)
577642
retval['output_dir'] = str(output_dir)
578643
retval['work_dir'] = str(work_dir)
579-
retval['subject_list'] = subject_list
644+
retval['subject_session_list'] = subject_session_list
580645
retval['run_uuid'] = run_uuid
581646
retval['workflow'] = None
582647

583648
# Called with reports only
584649
if opts.reports_only:
585-
from niworkflows.reports import generate_reports
650+
from nireports.assembler.tools import generate_reports
586651

587-
logger.log(25, 'Running --reports-only on participants %s', ', '.join(subject_list))
652+
from smriprep import data
653+
654+
logger.log(
655+
25, 'Running --reports-only on participants %s', _pprint_subses(subject_session_list)
656+
)
588657
if opts.run_uuid is not None:
589658
run_uuid = opts.run_uuid
659+
660+
smriprep_dir = output_dir / 'smriprep'
661+
bootstrap_file = data.load('reports-spec.yml')
590662
retval['return_code'] = generate_reports(
591-
subject_list, str(output_dir), run_uuid, packagename='smriprep'
663+
subject_session_list,
664+
smriprep_dir,
665+
run_uuid,
666+
bootstrap_file=bootstrap_file,
592667
)
593668
return retval
594669

@@ -601,7 +676,7 @@ def build_workflow(opts, retval):
601676
INIT_MSG(
602677
version=smriprep.__version__,
603678
bids_dir=bids_dir,
604-
subject_list=subject_list,
679+
subject_session_list=_pprint_subses(subject_session_list),
605680
uuid=run_uuid,
606681
spaces=output_spaces,
607682
),
@@ -612,7 +687,6 @@ def build_workflow(opts, retval):
612687
# XXX Makes strong assumption of legacy layout
613688
smriprep_dir = str(output_dir / 'smriprep')
614689
warnings.warn(
615-
f'Received DEPRECATED --fast-track flag. Adding {smriprep_dir} to --derivatives list.'
616690
f'Received DEPRECATED --fast-track flag. Adding {smriprep_dir} to --derivatives list.',
617691
stacklevel=1,
618692
)
@@ -638,7 +712,7 @@ def build_workflow(opts, retval):
638712
skull_strip_mode=opts.skull_strip_mode,
639713
skull_strip_template=opts.skull_strip_template[0],
640714
spaces=output_spaces,
641-
subject_list=subject_list,
715+
subject_session_list=subject_session_list,
642716
work_dir=str(work_dir),
643717
bids_filters=bids_filters,
644718
cifti_output=opts.cifti_output,
@@ -694,6 +768,30 @@ def build_workflow(opts, retval):
694768
return retval
695769

696770

771+
def _pprint_subses(subses: list):
772+
"""
773+
Pretty print a list of subjects and sessions.
774+
775+
Example
776+
-------
777+
>>> _pprint_subses([('01', 'A'), ('02', ['A', 'B']), ('03', None), ('04', ['A'])])
778+
'sub-01 ses-A, sub-02 (2 sessions), sub-03, sub-04 ses-A'
779+
"""
780+
output = []
781+
for subject, session in subses:
782+
if isinstance(session, list):
783+
if len(session) > 1:
784+
output.append(f'sub-{subject} ({len(session)} sessions)')
785+
continue
786+
session = session[0]
787+
if session is None:
788+
output.append(f'sub-{subject}')
789+
else:
790+
output.append(f'sub-{subject} ses-{session}')
791+
792+
return ', '.join(output)
793+
794+
697795
if __name__ == '__main__':
698796
raise RuntimeError(
699797
'smriprep/cli/run.py should not be run directly;\n'

src/smriprep/cli/tests/__init__.py

Whitespace-only changes.

src/smriprep/cli/tests/test_cli.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import json
2+
3+
import nibabel as nb
4+
import numpy as np
5+
import pytest
6+
from niworkflows.utils.testing import generate_bids_skeleton
7+
8+
from smriprep.cli.run import build_workflow, get_parser
9+
10+
NO_SESSION_LAYOUT = {
11+
'01': [
12+
{
13+
'anat': [
14+
{'run': 1, 'suffix': 'T1w'},
15+
{'run': 2, 'acq': 'test', 'suffix': 'T1w'},
16+
{'suffix': 'T2w'},
17+
],
18+
},
19+
],
20+
}
21+
22+
SESSION_LAYOUT = {
23+
'01': [
24+
{
25+
'session': 'pre',
26+
'anat': [
27+
{'run': 1, 'suffix': 'T1w'},
28+
{'run': 2, 'acq': 'test', 'suffix': 'T1w'},
29+
{'suffix': 'T2w'},
30+
],
31+
},
32+
{
33+
'session': 'post',
34+
'anat': [
35+
{'run': 1, 'suffix': 'T1w'},
36+
{'run': 2, 'acq': 'test', 'suffix': 'T1w'},
37+
{'suffix': 'T2w'},
38+
],
39+
},
40+
],
41+
}
42+
43+
44+
@pytest.fixture(scope='module')
45+
def bids_no_session(tmp_path_factory):
46+
base = tmp_path_factory.mktemp('bids_dirs')
47+
bids_dir = base / 'no_session'
48+
generate_bids_skeleton(bids_dir, NO_SESSION_LAYOUT)
49+
50+
img = nb.Nifti1Image(np.zeros((10, 10, 10, 10)), np.eye(4))
51+
52+
for path in bids_dir.glob('sub-01/**/*.nii.gz'):
53+
img.to_filename(path)
54+
return bids_dir
55+
56+
57+
@pytest.fixture(scope='module')
58+
def bids_session(tmp_path_factory):
59+
base = tmp_path_factory.mktemp('bids_dirs')
60+
bids_dir = base / 'with_session'
61+
generate_bids_skeleton(bids_dir, SESSION_LAYOUT)
62+
63+
img = nb.Nifti1Image(np.zeros((10, 10, 10, 10)), np.eye(4))
64+
65+
for path in bids_dir.glob('sub-01/**/*.nii.gz'):
66+
img.to_filename(path)
67+
return bids_dir
68+
69+
70+
T1_FILTER = {
71+
't1w': {
72+
'acquisition': None,
73+
'session': ['post'],
74+
}
75+
}
76+
77+
78+
@pytest.mark.parametrize(
79+
('session', 'additional_args', 'filters', 'fail'),
80+
[
81+
(False, [], None, False),
82+
(True, [], None, False),
83+
(True, ['--subject-anatomical-reference', 'sessionwise'], None, False),
84+
(True, ['--subject-anatomical-reference', 'sessionwise'], T1_FILTER, True),
85+
(True, ['--session-label', 'pre'], None, False),
86+
(True, ['--session-label', 'pre'], T1_FILTER, True),
87+
(True, ['--session-label', 'post'], None, False),
88+
],
89+
)
90+
def test_build_workflow(
91+
monkeypatch,
92+
tmp_path,
93+
bids_no_session,
94+
bids_session,
95+
session,
96+
additional_args,
97+
filters,
98+
fail,
99+
):
100+
parser = get_parser()
101+
bids_dir = bids_no_session if not session else bids_session
102+
base_args = [
103+
str(bids_dir),
104+
str(bids_dir / 'derivatives' / 'smriprep'),
105+
'participant',
106+
'--participant-label',
107+
'01',
108+
]
109+
if filters:
110+
filter_file = bids_session / '.filter.json'
111+
filter_file.write_text(json.dumps(filters))
112+
additional_args += ['--bids-filter-file', str(filter_file)]
113+
114+
base_args += additional_args
115+
pargs = parser.parse_args(base_args)
116+
117+
fs_dir = tmp_path / 'freesurfer'
118+
fs_dir.mkdir()
119+
monkeypatch.setenv('FREESURFER_HOME', str(fs_dir))
120+
monkeypatch.setenv('SUBJECTS_DIR', str(fs_dir))
121+
122+
if fail:
123+
with pytest.raises(ValueError, match='Conflicting entities'):
124+
build_workflow(pargs, {})
125+
return
126+
127+
ret = build_workflow(pargs, {})
128+
assert ret['workflow']

0 commit comments

Comments
 (0)