diff --git a/src/smriprep/utils/misc.py b/src/smriprep/utils/misc.py index 3c473b49a2..c40abf2a4a 100644 --- a/src/smriprep/utils/misc.py +++ b/src/smriprep/utils/misc.py @@ -22,6 +22,8 @@ # """Self-contained utilities to be used within Function nodes.""" +import typing as ty + def apply_lut(in_dseg, lut, newpath=None): """Map the input discrete segmentation to a new label set (lookup table, LUT).""" @@ -95,3 +97,48 @@ def fs_isRunning(subjects_dir, subject_id, mtime_tol=86400, logger=None): if logger: logger.warn(f'Removed "IsRunning*" files found under {subj_dir}') return subjects_dir + + +def collect_anat( + subject_data: dict, precomputed: dict, reference_anat: ty.Literal['T1w', 'T2w'] = 'T1w' +): + """ + Collects the anatomical inputs for a given subject and organises the + files and associated information into ``reference`` and ``aux`` keys + to pass to :py:func:`init_anat_fit_wf` + + Parameters + ---------- + subject_data: :obj:`dict` + lists of input data + precomputed: :obj:`dict` + cache of derivative files + reference_anat: :obj:`str` + MR image type (T1w, T2w, etc.) of primary anatomical scan + + Returns + ------- + anat_inputs: :obj:`dict` + """ + ref_anat = reference_anat.lower() + if ref_anat not in subject_data.keys(): + raise FileNotFoundError + + anat_inputs = { + modality: { + 'data': subject_data[modality], + 'n': len(subject_data[modality]), + 'precomputed': f'{modality}_preproc' in precomputed, + 'role': 'reference' if modality.capitalize() == reference_anat else 'aux', + } + for modality in ['t1w', 't2w', 'flair'] + if modality in subject_data.keys() + } + anat_inputs[reference_anat.lower()].update( + { + f'have_{preproc}': f'{reference_anat.lower()}_{preproc}' in precomputed + for preproc in ['mask', 'tpms', 'dseg'] + } + ) + + return anat_inputs diff --git a/src/smriprep/workflows/anatomical.py b/src/smriprep/workflows/anatomical.py index 0a9c3c7311..22d4a74d82 100644 --- a/src/smriprep/workflows/anatomical.py +++ b/src/smriprep/workflows/anatomical.py @@ -101,14 +101,12 @@ def init_anat_preproc_wf( hires: bool, longitudinal: bool, msm_sulc: bool, - t1w: list, - t2w: list, + anat: dict, skull_strip_mode: str, skull_strip_template: Reference, spaces: SpatialReferences, precomputed: dict, omp_nthreads: int, - flair: list = (), # Remove default after callers start passing it debug: bool = False, sloppy: bool = False, cifti_output: ty.Literal['91k', '170k', False] = False, @@ -162,8 +160,6 @@ def init_anat_preproc_wf( longitudinal : :obj:`bool` Create unbiased structural template, regardless of number of inputs (may increase runtime) - t1w : :obj:`list` - List of T1-weighted structural images. skull_strip_mode : :obj:`str` Determiner for T1-weighted skull stripping (`force` ensures skull stripping, `skip` ignores skull stripping, and `auto` automatically ignores skull stripping @@ -209,16 +205,16 @@ def init_anat_preproc_wf( Outputs ------- - t1w_preproc - The T1w reference map, which is calculated as the average of bias-corrected - and preprocessed T1w images, defining the anatomical space. - t1w_mask + anat_preproc + The anatomical reference map, which is calculated as the average of bias-corrected + and preprocessed anatomical images, defining the anatomical space. + anat_mask Brain (binary) mask estimated by brain extraction. - t1w_dseg + anat_dseg Brain tissue segmentation of the preprocessed structural image, including gray-matter (GM), white-matter (WM) and cerebrospinal fluid (CSF). - t1w_tpms - List of tissue probability maps corresponding to ``t1w_dseg``. + anat_tpms + List of tissue probability maps corresponding to ``anat_dseg``. template List of template names to which the structural image has been registered anat2std_xfm @@ -233,8 +229,8 @@ def init_anat_preproc_wf( subject_id FreeSurfer subject ID; use as input to a node to ensure that it is run after FreeSurfer reconstruction is completed. - fsnative2t1w_xfm - ITK-style affine matrix translating from FreeSurfer-conformed subject space to T1w + fsnative2anat_xfm + ITK-style affine matrix translating from FreeSurfer-conformed subject space to anatomical """ workflow = Workflow(name=name) @@ -249,15 +245,15 @@ def init_anat_preproc_wf( 'template', 'subjects_dir', 'subject_id', - 't1w_preproc', - 't1w_mask', - 't1w_dseg', - 't1w_tpms', + 'anat_preproc', + 'anat_mask', + 'anat_dseg', + 'anat_tpms', 'anat2std_xfm', 'std2anat_xfm', - 'fsnative2t1w_xfm', - 't1w_aparc', - 't1w_aseg', + 'fsnative2anat_xfm', + 'anat_aparc', + 'anat_aseg', 'sphere_reg', 'sphere_reg_fsLR', ] @@ -275,9 +271,7 @@ def init_anat_preproc_wf( skull_strip_mode=skull_strip_mode, skull_strip_template=skull_strip_template, spaces=spaces, - t1w=t1w, - t2w=t2w, - flair=flair, + anat=anat, precomputed=precomputed, debug=debug, sloppy=sloppy, @@ -304,13 +298,13 @@ def init_anat_preproc_wf( ('outputnode.template', 'template'), ('outputnode.subjects_dir', 'subjects_dir'), ('outputnode.subject_id', 'subject_id'), - ('outputnode.t1w_preproc', 't1w_preproc'), - ('outputnode.t1w_mask', 't1w_mask'), - ('outputnode.t1w_dseg', 't1w_dseg'), - ('outputnode.t1w_tpms', 't1w_tpms'), + ('outputnode.anat_preproc', 'anat_preproc'), + ('outputnode.anat_mask', 'anat_mask'), + ('outputnode.anat_dseg', 'anat_dseg'), + ('outputnode.anat_tpms', 'anat_tpms'), ('outputnode.anat2std_xfm', 'anat2std_xfm'), ('outputnode.std2anat_xfm', 'std2anat_xfm'), - ('outputnode.fsnative2t1w_xfm', 'fsnative2t1w_xfm'), + ('outputnode.fsnative2anat_xfm', 'fsnative2anat_xfm'), ('outputnode.sphere_reg', 'sphere_reg'), (f"outputnode.sphere_reg_{'msm' if msm_sulc else 'fsLR'}", 'sphere_reg_fsLR'), ('outputnode.anat_ribbon', 'anat_ribbon'), @@ -320,11 +314,11 @@ def init_anat_preproc_wf( ('outputnode.anat2std_xfm', 'inputnode.anat2std_xfm'), ]), (anat_fit_wf, ds_std_volumes_wf, [ - ('outputnode.t1w_valid_list', 'inputnode.source_files'), - ('outputnode.t1w_preproc', 'inputnode.anat_preproc'), - ('outputnode.t1w_mask', 'inputnode.anat_mask'), - ('outputnode.t1w_dseg', 'inputnode.anat_dseg'), - ('outputnode.t1w_tpms', 'inputnode.anat_tpms'), + ('outputnode.anat_valid_list', 'inputnode.source_files'), + ('outputnode.anat_preproc', 'inputnode.anat_preproc'), + ('outputnode.anat_mask', 'inputnode.anat_mask'), + ('outputnode.anat_dseg', 'inputnode.anat_dseg'), + ('outputnode.anat_tpms', 'inputnode.anat_tpms'), ]), (template_iterator_wf, ds_std_volumes_wf, [ ('outputnode.std_t1w', 'inputnode.ref_file'), @@ -348,33 +342,33 @@ def init_anat_preproc_wf( workflow.connect([ (anat_fit_wf, surface_derivatives_wf, [ - ('outputnode.t1w_preproc', 'inputnode.reference'), + ('outputnode.anat_preproc', 'inputnode.reference'), ('outputnode.subjects_dir', 'inputnode.subjects_dir'), ('outputnode.subject_id', 'inputnode.subject_id'), - ('outputnode.fsnative2t1w_xfm', 'inputnode.fsnative2anat_xfm'), + ('outputnode.fsnative2anat_xfm', 'inputnode.fsnative2anat_xfm'), ]), (anat_fit_wf, ds_surfaces_wf, [ - ('outputnode.t1w_valid_list', 'inputnode.source_files'), + ('outputnode.anat_valid_list', 'inputnode.source_files'), ]), (surface_derivatives_wf, ds_surfaces_wf, [ ('outputnode.inflated', 'inputnode.inflated'), ]), (anat_fit_wf, ds_curv_wf, [ - ('outputnode.t1w_valid_list', 'inputnode.source_files'), + ('outputnode.anat_valid_list', 'inputnode.source_files'), ]), (surface_derivatives_wf, ds_curv_wf, [ ('outputnode.curv', 'inputnode.curv'), ]), (anat_fit_wf, ds_fs_segs_wf, [ - ('outputnode.t1w_valid_list', 'inputnode.source_files'), + ('outputnode.anat_valid_list', 'inputnode.source_files'), ]), (surface_derivatives_wf, ds_fs_segs_wf, [ ('outputnode.out_aseg', 'inputnode.anat_fs_aseg'), ('outputnode.out_aparc', 'inputnode.anat_fs_aparc'), ]), (surface_derivatives_wf, outputnode, [ - ('outputnode.out_aseg', 't1w_aseg'), - ('outputnode.out_aparc', 't1w_aparc'), + ('outputnode.out_aseg', 'anat_aseg'), + ('outputnode.out_aparc', 'anat_aparc'), ]), ]) # fmt:skip @@ -440,10 +434,10 @@ def init_anat_preproc_wf( ('outputnode.midthickness_fsLR', 'inputnode.midthickness_fsLR'), ]), (anat_fit_wf, ds_fsLR_surfaces_wf, [ - ('outputnode.t1w_valid_list', 'inputnode.source_files'), + ('outputnode.anat_valid_list', 'inputnode.source_files'), ]), (anat_fit_wf, ds_grayord_metrics_wf, [ - ('outputnode.t1w_valid_list', 'inputnode.source_files'), + ('outputnode.anat_valid_list', 'inputnode.source_files'), ]), (resample_surfaces_wf, ds_fsLR_surfaces_wf, [ ('outputnode.white_fsLR', 'inputnode.white'), @@ -471,14 +465,12 @@ def init_anat_fit_wf( hires: bool, longitudinal: bool, msm_sulc: bool, - t1w: list, - t2w: list, + anat: dict, skull_strip_mode: str, skull_strip_template: Reference, spaces: SpatialReferences, precomputed: dict, omp_nthreads: int, - flair: list = (), # Remove default after callers start passing it debug: bool = False, sloppy: bool = False, name='anat_fit_wf', @@ -490,7 +482,7 @@ def init_anat_fit_wf( This includes: - - T1w reference: realigning and then averaging T1w images. + - anatomical reference: realigning and then averaging reference anatomical images. - Brain extraction and INU (bias field) correction. - Brain tissue segmentation. - Spatial normalization to standard spaces. @@ -582,18 +574,18 @@ def init_anat_fit_wf( Outputs ------- - t1w_preproc - The T1w reference map, which is calculated as the average of bias-corrected - and preprocessed T1w images, defining the anatomical space. - t1w_mask + anat_preproc + The anatomical reference map, which is calculated as the average of bias-corrected + and preprocessed anatomical images, defining the anatomical space. + anat_mask Brain (binary) mask estimated by brain extraction. - t1w_dseg + anat_dseg Brain tissue segmentation of the preprocessed structural image, including gray-matter (GM), white-matter (WM) and cerebrospinal fluid (CSF). - t1w_tpms - List of tissue probability maps corresponding to ``t1w_dseg``. - t1w_valid_list - List of input T1w images accepted for preprocessing. If t1w_preproc is + anat_tpms + List of tissue probability maps corresponding to ``anat_dseg``. + anat_valid_list + List of input anatomical images accepted for preprocessing. If anat_preproc is precomputed, this is always a list containing that image. template List of template names to which the structural image has been registered @@ -609,8 +601,8 @@ def init_anat_fit_wf( subject_id FreeSurfer subject ID; use as input to a node to ensure that it is run after FreeSurfer reconstruction is completed. - fsnative2t1w_xfm - ITK-style affine matrix translating from FreeSurfer-conformed subject space to T1w + fsnative2anat_xfm + ITK-style affine matrix translating from FreeSurfer-conformed subject space to anatomical See Also -------- @@ -619,18 +611,22 @@ def init_anat_fit_wf( """ workflow = Workflow(name=name) - num_t1w = len(t1w) + ref_anat = [modality for modality in anat.keys() if anat[modality]['role'] == 'reference'][0] + aux_anat = [modality for modality in anat.keys() if anat[modality]['role'] == 'aux'] + aux_weighted = [x for x in ['t1w', 't2w'] if x in aux_anat] + num_ref = anat[ref_anat]['n'] + ref_string = ref_anat.capitalize() desc = f""" Anatomical data preprocessing -: A total of {num_t1w} T1-weighted (T1w) images were found within the input -BIDS dataset.""" +: A total of {num_ref} {ref_string.strip('w')}-weighted \ +({ref_string}) images were found within the input BIDS dataset.""" - have_t1w = 't1w_preproc' in precomputed - have_t2w = 't2w_preproc' in precomputed - have_mask = 't1w_mask' in precomputed - have_dseg = 't1w_dseg' in precomputed - have_tpms = 't1w_tpms' in precomputed + have_ref = anat[ref_anat]['precomputed'] + have_aux = any(anat[modality]['precomputed'] for modality in aux_anat) + have_mask = anat[ref_anat]['have_mask'] + have_dseg = anat[ref_anat]['have_dseg'] + have_tpms = anat[ref_anat]['have_tpms'] # Organization # ------------ @@ -650,13 +646,13 @@ def init_anat_fit_wf( niu.IdentityInterface( fields=[ # Primary derivatives - 't1w_preproc', - 't2w_preproc', - 't1w_mask', - 't1w_dseg', - 't1w_tpms', + 'anat_preproc', + 'aux_preproc', + 'anat_mask', + 'anat_dseg', + 'anat_tpms', 'anat2std_xfm', - 'fsnative2t1w_xfm', + 'fsnative2anat_xfm', # Surface and metric derivatives for fsLR resampling 'white', 'pial', @@ -674,7 +670,7 @@ def init_anat_fit_wf( 'template', 'subjects_dir', 'subject_id', - 't1w_valid_list', + 'anat_valid_list', ] ), name='outputnode', @@ -689,13 +685,13 @@ def init_anat_fit_wf( ) # Stage 2 results - t1w_buffer = pe.Node( - niu.IdentityInterface(fields=['t1w_preproc', 't1w_mask', 't1w_brain', 'ants_seg']), - name='t1w_buffer', + anat_buffer = pe.Node( + niu.IdentityInterface(fields=['anat_preproc', 'anat_mask', 'anat_brain', 'ants_seg']), + name='anat_buffer', ) # Stage 3 results seg_buffer = pe.Node( - niu.IdentityInterface(fields=['t1w_dseg', 't1w_tpms']), + niu.IdentityInterface(fields=['anat_dseg', 'anat_tpms']), name='seg_buffer', ) # Stage 4 results: collated template names, forward and reverse transforms @@ -705,7 +701,7 @@ def init_anat_fit_wf( # Stage 6 results: Refined stage 2 results; may be direct copy if no refinement refined_buffer = pe.Node( - niu.IdentityInterface(fields=['t1w_mask', 't1w_brain']), + niu.IdentityInterface(fields=['anat_mask', 'anat_brain']), name='refined_buffer', ) @@ -724,13 +720,13 @@ def init_anat_fit_wf( # fmt:off workflow.connect([ (seg_buffer, outputnode, [ - ('t1w_dseg', 't1w_dseg'), - ('t1w_tpms', 't1w_tpms'), + ('anat_dseg', 'anat_dseg'), + ('anat_tpms', 'anat_tpms'), ]), (anat2std_buffer, outputnode, [('out', 'anat2std_xfm')]), (std2anat_buffer, outputnode, [('out', 'std2anat_xfm')]), (template_buffer, outputnode, [('out', 'template')]), - (sourcefile_buffer, outputnode, [('source_files', 't1w_valid_list')]), + (sourcefile_buffer, outputnode, [('source_files', 'anat_valid_list')]), (surfaces_buffer, outputnode, [ ('white', 'white'), ('pial', 'pial'), @@ -755,10 +751,10 @@ def init_anat_fit_wf( # fmt:off workflow.connect([ (outputnode, anat_reports_wf, [ - ('t1w_valid_list', 'inputnode.source_file'), - ('t1w_preproc', 'inputnode.t1w_preproc'), - ('t1w_mask', 'inputnode.t1w_mask'), - ('t1w_dseg', 'inputnode.t1w_dseg'), + ('anat_valid_list', 'inputnode.source_file'), + ('anat_preproc', 'inputnode.t1w_preproc'), + ('anat_mask', 'inputnode.t1w_mask'), + ('anat_dseg', 'inputnode.t1w_dseg'), ('template', 'inputnode.template'), ('anat2std_xfm', 'inputnode.anat2std_xfm'), ('subjects_dir', 'inputnode.subjects_dir'), @@ -770,29 +766,33 @@ def init_anat_fit_wf( # Stage 1: Conform images and validate # If desc-preproc_T1w.nii.gz is provided, just validate it anat_validate = pe.Node(ValidateImage(), name='anat_validate', run_without_submitting=True) - if not have_t1w: + if not have_ref: LOGGER.info('ANAT Stage 1: Adding template workflow') ants_ver = ANTsInfo.version() or '(version unknown)' desc += f"""\ - {'Each' if num_t1w > 1 else 'The'} T1w image was corrected for intensity + {'Each' if num_ref > 1 else 'The'} {ref_string} image was corrected for intensity non-uniformity (INU) with `N4BiasFieldCorrection` [@n4], distributed with ANTs {ants_ver} [@ants, RRID:SCR_004757]""" - desc += '.\n' if num_t1w > 1 else ', and used as T1w-reference throughout the workflow.\n' + desc += ( + '.\n' + if num_ref > 1 + else (f', and used as {ref_string}-reference throughout the workflow.\n') + ) anat_template_wf = init_anat_template_wf( longitudinal=longitudinal, omp_nthreads=omp_nthreads, - num_files=num_t1w, - image_type='T1w', + num_files=num_ref, + image_type=ref_string, name='anat_template_wf', ) ds_template_wf = init_ds_template_wf( - output_dir=output_dir, num_anat=num_t1w, image_type='T1w' + output_dir=output_dir, num_anat=num_ref, image_type=ref_string ) # fmt:off workflow.connect([ - (inputnode, anat_template_wf, [('t1w', 'inputnode.anat_files')]), + (inputnode, anat_template_wf, [(ref_anat, 'inputnode.anat_files')]), (anat_template_wf, anat_validate, [('outputnode.anat_ref', 'in_file')]), (anat_template_wf, sourcefile_buffer, [ ('outputnode.anat_valid_list', 'source_files'), @@ -804,40 +804,40 @@ def init_anat_fit_wf( ('outputnode.anat_realign_xfm', 'inputnode.anat_ref_xfms'), ]), (sourcefile_buffer, ds_template_wf, [('source_files', 'inputnode.source_files')]), - (t1w_buffer, ds_template_wf, [('t1w_preproc', 'inputnode.anat_preproc')]), - (ds_template_wf, outputnode, [('outputnode.anat_preproc', 't1w_preproc')]), + (anat_buffer, ds_template_wf, [('anat_preproc', 'inputnode.anat_preproc')]), + (ds_template_wf, outputnode, [('outputnode.anat_preproc', 'anat_preproc')]), ]) # fmt:on else: - LOGGER.info('ANAT Found preprocessed T1w - skipping Stage 1') - desc += """ A preprocessed T1w image was provided as a precomputed input -and used as T1w-reference throughout the workflow. + LOGGER.info(f'ANAT Found preprocessed {ref_string} - skipping Stage 1') + desc += f""" A preprocessed {ref_string} image was provided as a precomputed input +and used as {ref_string}-reference throughout the workflow. """ - anat_validate.inputs.in_file = precomputed['t1w_preproc'] - sourcefile_buffer.inputs.source_files = [precomputed['t1w_preproc']] + anat_validate.inputs.in_file = precomputed[f'{ref_anat}_preproc'] + sourcefile_buffer.inputs.source_files = [precomputed[f'{ref_anat}_preproc']] # fmt:off workflow.connect([ - (anat_validate, t1w_buffer, [('out_file', 't1w_preproc')]), - (t1w_buffer, outputnode, [('t1w_preproc', 't1w_preproc')]), + (anat_validate, anat_buffer, [('out_file', 'anat_preproc')]), + (anat_buffer, outputnode, [('anat_preproc', 'anat_preproc')]), ]) # fmt:on # Stage 2: INU correction and masking - # We always need to generate t1w_brain; how to do that depends on whether we have - # a pre-corrected T1w or precomputed mask, or are given an already masked image + # We always need to generate anat_brain; how to do that depends on whether we have + # a pre-corrected anatomical or precomputed mask, or are given an already masked image if not have_mask: LOGGER.info('ANAT Stage 2: Preparing brain extraction workflow') if skull_strip_mode == 'auto': - run_skull_strip = not all(_is_skull_stripped(img) for img in t1w) + run_skull_strip = not all(_is_skull_stripped(img) for img in anat[ref_anat]['data']) else: run_skull_strip = {'force': True, 'skip': False}[skull_strip_mode] # Brain extraction if run_skull_strip: desc += f"""\ -The T1w-reference was then skull-stripped with a *Nipype* implementation of +The {ref_string}-reference was then skull-stripped with a *Nipype* implementation of the `antsBrainExtraction.sh` workflow (from ANTs), using {skull_strip_template.fullname} as target template. """ @@ -851,24 +851,24 @@ def init_anat_fit_wf( # fmt:off workflow.connect([ (anat_validate, brain_extraction_wf, [('out_file', 'inputnode.in_files')]), - (brain_extraction_wf, t1w_buffer, [ - ('outputnode.out_mask', 't1w_mask'), - (('outputnode.out_file', _pop), 't1w_brain'), + (brain_extraction_wf, anat_buffer, [ + ('outputnode.out_mask', 'anat_mask'), + (('outputnode.out_file', _pop), 'anat_brain'), ('outputnode.out_segm', 'ants_seg'), ]), ]) - if not have_t1w: + if not have_ref: workflow.connect([ - (brain_extraction_wf, t1w_buffer, [ - (('outputnode.bias_corrected', _pop), 't1w_preproc'), + (brain_extraction_wf, anat_buffer, [ + (('outputnode.bias_corrected', _pop), 'anat_preproc'), ]), ]) # fmt:on - # Determine mask from T1w and uniformize - elif not have_t1w: + # Determine mask from anatomical and uniformize + elif not have_ref: LOGGER.info('ANAT Stage 2: Skipping skull-strip, INU-correction only') - desc += """\ -The provided T1w image was previously skull-stripped; a brain mask was + desc += f"""\ +The provided {ref_string} image was previously skull-stripped; a brain mask was derived from the input image. """ n4_only_wf = init_n4_only_wf( @@ -878,10 +878,10 @@ def init_anat_fit_wf( # fmt:off workflow.connect([ (anat_validate, n4_only_wf, [('out_file', 'inputnode.in_files')]), - (n4_only_wf, t1w_buffer, [ - (('outputnode.bias_corrected', _pop), 't1w_preproc'), - ('outputnode.out_mask', 't1w_mask'), - (('outputnode.out_file', _pop), 't1w_brain'), + (n4_only_wf, anat_buffer, [ + (('outputnode.bias_corrected', _pop), 'anat_preproc'), + ('outputnode.out_mask', 'anat_mask'), + (('outputnode.out_file', _pop), 'anat_brain'), ('outputnode.out_segm', 'ants_seg'), ]), ]) @@ -889,30 +889,30 @@ def init_anat_fit_wf( # Binarize the already uniformized image else: LOGGER.info('ANAT Stage 2: Skipping skull-strip, generating mask from input') - desc += """\ -The provided T1w image was previously skull-stripped; a brain mask was + desc += f"""\ +The provided {ref_string} image was previously skull-stripped; a brain mask was derived from the input image. """ binarize = pe.Node(Binarize(thresh_low=2), name='binarize') # fmt:off workflow.connect([ (anat_validate, binarize, [('out_file', 'in_file')]), - (anat_validate, t1w_buffer, [('out_file', 't1w_brain')]), - (binarize, t1w_buffer, [('out_file', 't1w_mask')]), + (anat_validate, anat_buffer, [('out_file', 'anat_brain')]), + (binarize, anat_buffer, [('out_file', 'anat_mask')]), ]) # fmt:on - ds_t1w_mask_wf = init_ds_mask_wf( + ds_anat_mask_wf = init_ds_mask_wf( bids_root=bids_root, output_dir=output_dir, mask_type='brain', - name='ds_t1w_mask_wf', + name='ds_anat_mask_wf', ) # fmt:off workflow.connect([ - (sourcefile_buffer, ds_t1w_mask_wf, [('source_files', 'inputnode.source_files')]), - (refined_buffer, ds_t1w_mask_wf, [('t1w_mask', 'inputnode.mask_file')]), - (ds_t1w_mask_wf, outputnode, [('outputnode.mask_file', 't1w_mask')]), + (sourcefile_buffer, ds_anat_mask_wf, [('source_files', 'inputnode.source_files')]), + (refined_buffer, ds_anat_mask_wf, [('anat_mask', 'inputnode.mask_file')]), + (ds_anat_mask_wf, outputnode, [('outputnode.mask_file', 'anat_mask')]), ]) # fmt:on else: @@ -920,12 +920,12 @@ def init_anat_fit_wf( desc += """\ A pre-computed brain mask was provided as input and used throughout the workflow. """ - t1w_buffer.inputs.t1w_mask = precomputed['t1w_mask'] + anat_buffer.inputs.anat_mask = precomputed[f'{ref_anat}_mask'] # If we have a mask, always apply it - apply_mask = pe.Node(ApplyMask(in_mask=precomputed['t1w_mask']), name='apply_mask') + apply_mask = pe.Node(ApplyMask(in_mask=precomputed[f'{ref_anat}_mask']), name='apply_mask') workflow.connect([(anat_validate, apply_mask, [('out_file', 'in_file')])]) # Run N4 if it hasn't been pre-run - if not have_t1w: + if not have_ref: LOGGER.info('ANAT Skipping skull-strip, INU-correction only') n4_only_wf = init_n4_only_wf( omp_nthreads=omp_nthreads, @@ -934,16 +934,16 @@ def init_anat_fit_wf( # fmt:off workflow.connect([ (apply_mask, n4_only_wf, [('out_file', 'inputnode.in_files')]), - (n4_only_wf, t1w_buffer, [ - (('outputnode.bias_corrected', _pop), 't1w_preproc'), - (('outputnode.out_file', _pop), 't1w_brain'), + (n4_only_wf, anat_buffer, [ + (('outputnode.bias_corrected', _pop), 'anat_preproc'), + (('outputnode.out_file', _pop), 'anat_brain'), ]), ]) # fmt:on else: LOGGER.info('ANAT Skipping Stage 2') - workflow.connect([(apply_mask, t1w_buffer, [('out_file', 't1w_brain')])]) - workflow.connect([(refined_buffer, outputnode, [('t1w_mask', 't1w_mask')])]) + workflow.connect([(apply_mask, anat_buffer, [('out_file', 'anat_brain')])]) + workflow.connect([(refined_buffer, outputnode, [('anat_mask', 'anat_mask')])]) # Stage 3: Segmentation if not (have_dseg and have_tpms): @@ -952,30 +952,30 @@ def init_anat_fit_wf( desc += f"""\ Brain tissue segmentation of cerebrospinal fluid (CSF), white-matter (WM) and gray-matter (GM) was performed on -the brain-extracted T1w using `fast` [FSL {fsl_ver}, RRID:SCR_002823, @fsl_fast]. +the brain-extracted {ref_string} using `fast` [FSL {fsl_ver}, RRID:SCR_002823, @fsl_fast]. """ fast = pe.Node( FAST(segments=True, no_bias=True, probability_maps=True, bias_iters=0), name='fast', mem_gb=3, ) - lut_t1w_dseg = pe.Node(niu.Function(function=_apply_bids_lut), name='lut_t1w_dseg') - lut_t1w_dseg.inputs.lut = (0, 3, 1, 2) # Maps: 0 -> 0, 3 -> 1, 1 -> 2, 2 -> 3. + lut_anat_dseg = pe.Node(niu.Function(function=_apply_bids_lut), name='lut_anat_dseg') + lut_anat_dseg.inputs.lut = (0, 3, 1, 2) # Maps: 0 -> 0, 3 -> 1, 1 -> 2, 2 -> 3. fast2bids = pe.Node( niu.Function(function=_probseg_fast2bids), name='fast2bids', run_without_submitting=True, ) - workflow.connect([(refined_buffer, fast, [('t1w_brain', 'in_files')])]) + workflow.connect([(refined_buffer, fast, [('anat_brain', 'in_files')])]) # fmt:off if not have_dseg: ds_dseg_wf = init_ds_dseg_wf(output_dir=output_dir) workflow.connect([ - (fast, lut_t1w_dseg, [('partial_volume_map', 'in_dseg')]), + (fast, lut_anat_dseg, [('partial_volume_map', 'in_dseg')]), (sourcefile_buffer, ds_dseg_wf, [('source_files', 'inputnode.source_files')]), - (lut_t1w_dseg, ds_dseg_wf, [('out', 'inputnode.anat_dseg')]), - (ds_dseg_wf, seg_buffer, [('outputnode.anat_dseg', 't1w_dseg')]), + (lut_anat_dseg, ds_dseg_wf, [('out', 'inputnode.anat_dseg')]), + (ds_dseg_wf, seg_buffer, [('outputnode.anat_dseg', 'anat_dseg')]), ]) if not have_tpms: ds_tpms_wf = init_ds_tpms_wf(output_dir=output_dir) @@ -983,7 +983,7 @@ def init_anat_fit_wf( (fast, fast2bids, [('partial_volume_files', 'inlist')]), (sourcefile_buffer, ds_tpms_wf, [('source_files', 'inputnode.source_files')]), (fast2bids, ds_tpms_wf, [('out', 'inputnode.anat_tpms')]), - (ds_tpms_wf, seg_buffer, [('outputnode.anat_tpms', 't1w_tpms')]), + (ds_tpms_wf, seg_buffer, [('outputnode.anat_tpms', 'anat_tpms')]), ]) # fmt:on else: @@ -991,11 +991,11 @@ def init_anat_fit_wf( if have_dseg: LOGGER.info('ANAT Found discrete segmentation') desc += 'Precomputed discrete tissue segmentations were provided as inputs.\n' - seg_buffer.inputs.t1w_dseg = precomputed['t1w_dseg'] + seg_buffer.inputs.anat_dseg = precomputed[f'{ref_anat}_dseg'] if have_tpms: LOGGER.info('ANAT Found tissue probability maps') desc += 'Precomputed tissue probabiilty maps were provided as inputs.\n' - seg_buffer.inputs.t1w_tpms = precomputed['t1w_tpms'] + seg_buffer.inputs.anat_tpms = precomputed[f'{ref_anat}_tpms'] # Stage 4: Normalization templates = [] @@ -1019,14 +1019,14 @@ def init_anat_fit_wf( templates=templates, ) ds_template_registration_wf = init_ds_template_registration_wf( - output_dir=output_dir, image_type='T1w' + output_dir=output_dir, image_type=ref_string ) # fmt:off workflow.connect([ (inputnode, register_template_wf, [('roi', 'inputnode.lesion_mask')]), - (t1w_buffer, register_template_wf, [('t1w_preproc', 'inputnode.moving_image')]), - (refined_buffer, register_template_wf, [('t1w_mask', 'inputnode.moving_mask')]), + (anat_buffer, register_template_wf, [('anat_preproc', 'inputnode.moving_image')]), + (refined_buffer, register_template_wf, [('anat_mask', 'inputnode.moving_mask')]), (sourcefile_buffer, ds_template_registration_wf, [ ('source_files', 'inputnode.source_files') ]), @@ -1047,9 +1047,9 @@ def init_anat_fit_wf( if have_mask or not freesurfer: # fmt:off workflow.connect([ - (t1w_buffer, refined_buffer, [ - ('t1w_mask', 't1w_mask'), - ('t1w_brain', 't1w_brain'), + (anat_buffer, refined_buffer, [ + ('anat_mask', 'anat_mask'), + ('anat_brain', 'anat_brain'), ]), ]) # fmt:on @@ -1074,10 +1074,12 @@ def init_anat_fit_wf( fs_no_resume=fs_no_resume, precomputed=precomputed, ) - if t2w or flair: - t2w_or_flair = 'T2-weighted' if t2w else 'FLAIR' + if have_aux: + weighted_or_flair = ( + aux_weighted[0].capitalize().replace('w', '-weighted') if aux_weighted else 'FLAIR' + ) surface_recon_wf.__desc__ += f"""\ -A {t2w_or_flair} image was used to improve pial surface refinement. +A {weighted_or_flair} image was used to improve pial surface refinement. """ # fmt:off @@ -1092,8 +1094,8 @@ def init_anat_fit_wf( ('subject_id', 'inputnode.subject_id'), ]), (fs_isrunning, surface_recon_wf, [('out', 'inputnode.subjects_dir')]), - (anat_validate, surface_recon_wf, [('out_file', 'inputnode.t1w')]), - (t1w_buffer, surface_recon_wf, [('t1w_brain', 'inputnode.skullstripped_t1')]), + (anat_validate, surface_recon_wf, [('out_file', f'inputnode.{ref_anat}')]), + (anat_buffer, surface_recon_wf, [('anat_brain', 'inputnode.skullstripped_t1')]), (surface_recon_wf, outputnode, [ ('outputnode.subjects_dir', 'subjects_dir'), ('outputnode.subject_id', 'subject_id'), @@ -1103,7 +1105,9 @@ def init_anat_fit_wf( fsnative_xfms = precomputed.get('transforms', {}).get('fsnative') if not fsnative_xfms: - ds_fs_registration_wf = init_ds_fs_registration_wf(output_dir=output_dir, image_type='T1w') + ds_fs_registration_wf = init_ds_fs_registration_wf( + output_dir=output_dir, image_type=ref_string + ) # fmt:off workflow.connect([ (sourcefile_buffer, ds_fs_registration_wf, [ @@ -1113,16 +1117,16 @@ def init_anat_fit_wf( ('outputnode.fsnative2t1w_xfm', 'inputnode.fsnative2anat_xfm'), ]), (ds_fs_registration_wf, outputnode, [ - ('outputnode.fsnative2anat_xfm', 'fsnative2t1w_xfm'), + ('outputnode.fsnative2anat_xfm', 'fsnative2anat_xfm'), ]), ]) # fmt:on elif 'reverse' in fsnative_xfms: - LOGGER.info('ANAT Found fsnative-T1w transform - skipping registration') - outputnode.inputs.fsnative2t1w_xfm = fsnative_xfms['reverse'] + LOGGER.info(f'ANAT Found fsnative-{ref_string} transform - skipping registration') + outputnode.inputs.fsnative2anat_xfm = fsnative_xfms['reverse'] else: raise RuntimeError( - 'Found a T1w-to-fsnative transform without the reverse. Time to handle this.' + f'Found a {ref_string}-to-fsnative transform without the reverse. Time to handle this.' ) if not have_mask: @@ -1138,31 +1142,32 @@ def init_anat_fit_wf( ('outputnode.subject_id', 'inputnode.subject_id'), ('outputnode.fsnative2t1w_xfm', 'inputnode.fsnative2anat_xfm'), ]), - (t1w_buffer, refinement_wf, [ - ('t1w_preproc', 'inputnode.reference_image'), + (anat_buffer, refinement_wf, [ + ('anat_preproc', 'inputnode.reference_image'), ('ants_seg', 'inputnode.ants_segs'), ]), - (t1w_buffer, applyrefined, [('t1w_preproc', 'in_file')]), + (anat_buffer, applyrefined, [('anat_preproc', 'in_file')]), (refinement_wf, applyrefined, [('outputnode.out_brainmask', 'mask_file')]), - (refinement_wf, refined_buffer, [('outputnode.out_brainmask', 't1w_mask')]), - (applyrefined, refined_buffer, [('out_file', 't1w_brain')]), + (refinement_wf, refined_buffer, [('outputnode.out_brainmask', 'anat_mask')]), + (applyrefined, refined_buffer, [('out_file', 'anat_brain')]), ]) # fmt:on else: LOGGER.info('ANAT Found brain mask - skipping Stage 6') - if t2w and not have_t2w: - LOGGER.info('ANAT Stage 7: Creating T2w template') - t2w_template_wf = init_anat_template_wf( + if aux_weighted and not have_aux: + aux_str = aux_weighted[0] + LOGGER.info('ANAT Stage 7: Creating aux template') + aux_template_wf = init_anat_template_wf( longitudinal=longitudinal, omp_nthreads=omp_nthreads, - num_files=len(t2w), - image_type='T2w', - name='t2w_template_wf', + num_files=anat[aux_str]['n'], + image_type=aux_str.capitalize(), + name='aux_template_wf', ) bbreg = pe.Node( fs.BBRegister( - contrast_type='t2', + contrast_type=aux_str.strip('w'), init='coreg', dof=6, out_lta_file=True, @@ -1171,45 +1176,47 @@ def init_anat_fit_wf( name='bbreg', ) coreg_xfms = pe.Node(niu.Merge(2), name='merge_xfms', run_without_submitting=True) - t2wtot1w_xfm = pe.Node(ConcatenateXFMs(), name='t2wtot1w_xfm', run_without_submitting=True) - t2w_resample = pe.Node( + auxtoanat_xfm = pe.Node( + ConcatenateXFMs(), name='auxtoanat_xfm', run_without_submitting=True + ) + aux_resample = pe.Node( ApplyTransforms( dimension=3, default_value=0, float=True, interpolation='LanczosWindowedSinc', ), - name='t2w_resample', + name='aux_resample', ) - ds_t2w_preproc = pe.Node( + ds_aux_preproc = pe.Node( DerivativesDataSink(base_directory=output_dir, desc='preproc', compress=True), - name='ds_t2w_preproc', + name='ds_aux_preproc', run_without_submitting=True, ) - ds_t2w_preproc.inputs.SkullStripped = False + ds_aux_preproc.inputs.SkullStripped = False workflow.connect([ - (inputnode, t2w_template_wf, [('t2w', 'inputnode.anat_files')]), - (t2w_template_wf, bbreg, [('outputnode.anat_ref', 'source_file')]), + (inputnode, aux_template_wf, [(aux_str, 'inputnode.anat_files')]), + (aux_template_wf, bbreg, [('outputnode.anat_ref', 'source_file')]), (surface_recon_wf, bbreg, [ ('outputnode.subject_id', 'subject_id'), ('outputnode.subjects_dir', 'subjects_dir'), ]), (bbreg, coreg_xfms, [('out_lta_file', 'in1')]), (surface_recon_wf, coreg_xfms, [('outputnode.fsnative2t1w_xfm', 'in2')]), - (coreg_xfms, t2wtot1w_xfm, [('out', 'in_xfms')]), - (t2w_template_wf, t2w_resample, [('outputnode.anat_ref', 'input_image')]), - (t1w_buffer, t2w_resample, [('t1w_preproc', 'reference_image')]), - (t2wtot1w_xfm, t2w_resample, [('out_xfm', 'transforms')]), - (inputnode, ds_t2w_preproc, [('t2w', 'source_file')]), - (t2w_resample, ds_t2w_preproc, [('output_image', 'in_file')]), - (ds_t2w_preproc, outputnode, [('out_file', 't2w_preproc')]), + (coreg_xfms, auxtoanat_xfm, [('out', 'in_xfms')]), + (aux_template_wf, aux_resample, [('outputnode.anat_ref', 'input_image')]), + (anat_buffer, aux_resample, [('anat_preproc', 'reference_image')]), + (auxtoanat_xfm, aux_resample, [('out_xfm', 'transforms')]), + (inputnode, ds_aux_preproc, [(aux_str, 'source_file')]), + (aux_resample, ds_aux_preproc, [('output_image', 'in_file')]), + (ds_aux_preproc, outputnode, [('out_file', 'aux_preproc')]), ]) # fmt:skip - elif not t2w: - LOGGER.info('ANAT No T2w images provided - skipping Stage 7') + elif not aux_weighted: + LOGGER.info('ANAT No auxiliary images provided - skipping Stage 7') else: - LOGGER.info('ANAT Found preprocessed T2w - skipping Stage 7') + LOGGER.info('ANAT Found preprocessed auxiliary - skipping Stage 7') # Stages 8-10: Surface conversion and registration # sphere_reg is needed to generate sphere_reg_fsLR @@ -1313,8 +1320,8 @@ def init_anat_fit_wf( ) # fmt:off workflow.connect([ - (t1w_buffer, anat_ribbon_wf, [ - ('t1w_preproc', 'inputnode.ref_file'), + (anat_buffer, anat_ribbon_wf, [ + ('anat_preproc', 'inputnode.ref_file'), ]), (surfaces_buffer, anat_ribbon_wf, [ ('white', 'inputnode.white'), @@ -1458,7 +1465,7 @@ def init_anat_template_wf( name='outputnode', ) - # 0. Denoise and reorient T1w image(s) to RAS and resample to common voxel space + # 0. Denoise and reorient anat image(s) to RAS and resample to common voxel space anat_ref_dimensions = pe.Node(TemplateDimensions(), name='anat_ref_dimensions') denoise = pe.MapNode( DenoiseImage(noise_model='Rician', num_threads=omp_nthreads), @@ -1501,11 +1508,11 @@ def init_anat_template_wf( name='anat_conform_xfm', ) - # 1. Template (only if several T1w images) + # 1. Template (only if several anat images) # 1a. Correct for bias field: the bias field is an additive factor # in log-transformed intensity units. Therefore, it is not a linear # combination of fields and N4 fails with merged images. - # 1b. Align and merge if several T1w images are provided + # 1b. Align and merge if several anat images are provided n4_correct = pe.MapNode( N4BiasFieldCorrection(dimension=3, copy_header=True), iterfield='input_image', @@ -1645,7 +1652,7 @@ def _probseg_fast2bids(inlist): def _is_skull_stripped(img): - """Check if T1w images are skull-stripped.""" + """Check if anat images are skull-stripped.""" import nibabel as nb import numpy as np diff --git a/src/smriprep/workflows/base.py b/src/smriprep/workflows/base.py index 730fe1f656..cc22ae21e8 100644 --- a/src/smriprep/workflows/base.py +++ b/src/smriprep/workflows/base.py @@ -36,6 +36,7 @@ from ..__about__ import __version__ from ..interfaces import DerivativesDataSink +from ..utils.misc import collect_anat from .anatomical import init_anat_preproc_wf @@ -430,10 +431,8 @@ def init_single_subject_wf( fs_no_resume=fs_no_resume, longitudinal=longitudinal, msm_sulc=msm_sulc, + anat=collect_anat(subject_data, deriv_cache, 'T1w'), name='anat_preproc_wf', - t1w=subject_data['t1w'], - t2w=subject_data['t2w'], - flair=subject_data['flair'], omp_nthreads=omp_nthreads, output_dir=output_dir, skull_strip_fixed_seed=skull_strip_fixed_seed, diff --git a/src/smriprep/workflows/tests/test_anatomical.py b/src/smriprep/workflows/tests/test_anatomical.py index d34cd5de7e..24ab82b6a1 100644 --- a/src/smriprep/workflows/tests/test_anatomical.py +++ b/src/smriprep/workflows/tests/test_anatomical.py @@ -4,9 +4,11 @@ import numpy as np import pytest from nipype.pipeline.engine.utils import generate_expanded_graph +from niworkflows.utils.bids import collect_data from niworkflows.utils.spaces import Reference, SpatialReferences from niworkflows.utils.testing import generate_bids_skeleton +from ...utils.misc import collect_anat from ..anatomical import init_anat_fit_wf, init_anat_preproc_wf BASE_LAYOUT = { @@ -80,8 +82,7 @@ def test_init_anat_preproc_wf( hires=False, longitudinal=False, msm_sulc=False, - t1w=[str(bids_root / 'sub-01' / 'anat' / 'sub-01_T1w.nii.gz')], - t2w=[str(bids_root / 'sub-01' / 'anat' / 'sub-01_T2w.nii.gz')], + anat=collect_anat(collect_data(bids_root, '01')[0], {}, 'T1w'), skull_strip_mode='force', skull_strip_template=Reference('OASIS30ANTs'), spaces=SpatialReferences( @@ -112,8 +113,7 @@ def test_anat_fit_wf( hires=False, longitudinal=False, msm_sulc=msm_sulc, - t1w=[str(bids_root / 'sub-01' / 'anat' / 'sub-01_T1w.nii.gz')], - t2w=[str(bids_root / 'sub-01' / 'anat' / 'sub-01_T2w.nii.gz')], + anat=collect_anat(collect_data(bids_root, '01')[0], {}, 'T1w'), skull_strip_mode=skull_strip_mode, skull_strip_template=Reference('OASIS30ANTs'), spaces=SpatialReferences( @@ -154,13 +154,6 @@ def test_anat_fit_precomputes( output_dir = tmp_path / 'output' output_dir.mkdir() - # Construct inputs - t1w_list = [ - str(bids_root / 'sub-01' / 'anat' / 'sub-01_run-1_T1w.nii.gz'), - str(bids_root / 'sub-01' / 'anat' / 'sub-01_run-2_T1w.nii.gz'), - ][:t1w] - t2w_list = [str(bids_root / 'sub-01' / 'anat' / 'sub-01_T2w.nii.gz')][:t2w] - # Construct precomputed files empty_img = nb.Nifti1Image(np.zeros((1, 1, 1)), np.eye(4)) precomputed = {} @@ -200,6 +193,7 @@ def test_anat_fit_precomputes( for path in xfm.values(): Path(path).touch() + anat = collect_anat(collect_data(bids_root, '01')[0], precomputed, 'T1w') # Create workflow wf = init_anat_fit_wf( bids_root=str(bids_root), @@ -208,8 +202,7 @@ def test_anat_fit_precomputes( hires=False, longitudinal=False, msm_sulc=True, - t1w=t1w_list, - t2w=t2w_list, + anat=anat, skull_strip_mode=skull_strip_mode, skull_strip_template=Reference('OASIS30ANTs'), spaces=SpatialReferences(