From 899b498dd5bc6586cfb6242fb00e062a811c7c7d Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 5 Dec 2024 15:54:34 -0500 Subject: [PATCH] Work on CIFTI conversion workflow (#10) --- .zenodo.json | 5 - REFERENCES.md | 15 --- src/smripost_linc/__main__.py | 9 -- src/smripost_linc/_version.pyi | 4 - src/smripost_linc/interfaces/freesurfer.py | 46 ++++++++++ src/smripost_linc/interfaces/misc.py | 98 +++++++++++++++++++- src/smripost_linc/utils/utils.py | 79 ++++++++++++++++ src/smripost_linc/workflows/base.py | 2 + src/smripost_linc/workflows/freesurfer.py | 102 ++++++++++++++++++++- 9 files changed, 323 insertions(+), 37 deletions(-) delete mode 100644 REFERENCES.md delete mode 100644 src/smripost_linc/__main__.py delete mode 100644 src/smripost_linc/_version.pyi diff --git a/.zenodo.json b/.zenodo.json index c403eea..3ed634a 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -33,11 +33,6 @@ "identifier": "https://smripost_linc.org", "relation": "documents", "scheme": "url" - }, - { - "identifier": "10.1038/s41592-018-0235-4", - "relation": "isPartOf", - "scheme": "doi" } ], "upload_type": "software" diff --git a/REFERENCES.md b/REFERENCES.md deleted file mode 100644 index 0cceb35..0000000 --- a/REFERENCES.md +++ /dev/null @@ -1,15 +0,0 @@ -| Tool (**Package**) | Citation(s) | Link to code or documentation | -|-----|-----|-----| -| **FSL** | | https://doi.org/10.1016/j.neuroimage.2004.07.051 https://doi.org/10.1016/j.neuroimage.2008.10.055 https://doi.org/10.1016/j.neuroimage.2011.09.015 -| SUSAN | https://doi.org/10.1023/A:1007963824710 | https://fsl.fmrib.ox.ac.uk/fsl/fslwiki/SUSAN | -| MELODIC | | https://fsl.fmrib.ox.ac.uk/fsl/fslwiki/MELODIC | -| ICA-AROMA | http://www.sciencedirect.com/science/article/pii/S1053811915001822 | https://github.com/rhr-pruim/ICA-AROMA/ | -| **Other** | | | -| nibabel | https://doi.org/10.5281/zenodo.60808 | https://github.com/nipy/nibabel/ | -| nilearn | https://doi.org/10.3389/fninf.2014.00014 | https://github.com/nilearn/nilearn/ | -| nipype | https://doi.org/10.3389/fninf.2011.00013 https://doi.org/10.5281/zenodo.581704 | https://github.com/nipy/nipype/ | -| **Graphics** | | | -| seaborn | https://doi.org/10.5281/zenodo.883859 | https://github.com/mwaskom/seaborn | -| matplotlib 2.0.0 | https://doi.org/10.5281/zenodo.248351 | https://github.com/matplotlib/matplotlib | -| cwebp | https://developers.google.com/speed/webp/docs/webp_study https://developers.google.com/speed/webp/docs/webp_lossless_alpha_study | https://developers.google.com/speed/webp/ | -| SVGO | | https://github.com/svg/svgo | diff --git a/src/smripost_linc/__main__.py b/src/smripost_linc/__main__.py deleted file mode 100644 index e5c421c..0000000 --- a/src/smripost_linc/__main__.py +++ /dev/null @@ -1,9 +0,0 @@ -# SPDX-FileCopyrightText: 2023-present Chris Markiewicz -# -# SPDX-License-Identifier: Apache-2.0 -import sys - -if __name__ == '__main__': - from .cli import smripost_linc - - sys.exit(smripost_linc()) diff --git a/src/smripost_linc/_version.pyi b/src/smripost_linc/_version.pyi deleted file mode 100644 index f3c1fd3..0000000 --- a/src/smripost_linc/_version.pyi +++ /dev/null @@ -1,4 +0,0 @@ -__version__: str -__version_tuple__: tuple[str, ...] -version: str -version_tuple: tuple[str, ...] diff --git a/src/smripost_linc/interfaces/freesurfer.py b/src/smripost_linc/interfaces/freesurfer.py index 8c80826..6541175 100644 --- a/src/smripost_linc/interfaces/freesurfer.py +++ b/src/smripost_linc/interfaces/freesurfer.py @@ -2,6 +2,7 @@ import os import shutil +from glob import glob from nipype.interfaces.base import ( Directory, @@ -120,3 +121,48 @@ def _run_interface(self, runtime): shutil.copyfile(self.inputs.in_file, out_file) return runtime + + +class _CollectFSAverageSurfacesInputSpec(TraitedSpec): + freesurfer_dir = Directory( + exists=True, + mandatory=True, + desc='FreeSurfer directory', + ) + + +class _CollectFSAverageSurfacesOutputSpec(TraitedSpec): + lh_fsaverage_files = traits.List( + File(exists=True), + desc='Left-hemisphere fsaverage-space surfaces', + ) + rh_fsaverage_files = traits.List( + File(exists=True), + desc='Right-hemisphere fsaverage-space surfaces', + ) + names = traits.List( + traits.Str, + desc='Names of collected surfaces', + ) + + +class CollectFSAverageSurfaces(SimpleInterface): + input_spec = _CollectFSAverageSurfacesInputSpec + output_spec = _CollectFSAverageSurfacesOutputSpec + + def _run_interface(self, runtime): + in_dir = os.path.join( + self.inputs.freesurfer_dir, + 'surf', + ) + lh_mgh_files = sorted(glob(os.path.join(in_dir, 'lh.*.fsaverage.mgh'))) + self._results['lh_fsaverage_files'] = lh_mgh_files + self._results['names'] = [] + self._results['rh_fsaverage_files'] = [] + for lh_file in lh_mgh_files: + name = os.path.basename(lh_file).split('.')[1] + self._results['names'].append(name) + rh_file = os.path.join(in_dir, f'rh.{name}.fsaverage.mgh') + self._results['rh_fsaverage_files'].append(rh_file) + + return runtime diff --git a/src/smripost_linc/interfaces/misc.py b/src/smripost_linc/interfaces/misc.py index 39119e2..c3a67a9 100644 --- a/src/smripost_linc/interfaces/misc.py +++ b/src/smripost_linc/interfaces/misc.py @@ -1,4 +1,6 @@ -"""Miscellaneous interfaces for fmriprep-aroma.""" +"""Miscellaneous interfaces for sMRIPost-LINC.""" + +import os import numpy as np from nipype.interfaces.base import ( @@ -17,6 +19,8 @@ _FixTraitApplyTransformsInputSpec, ) +from smripost_linc.utils.utils import split_filename + class _ApplyTransformsInputSpec(_FixTraitApplyTransformsInputSpec): # Nipype's version doesn't have GenericLabel @@ -156,6 +160,98 @@ class CiftiSeparateMetric(WBCommand): _cmd = 'wb_command -cifti-separate' +class _CiftiCreateDenseScalarInputSpec(_WBCommandInputSpec): + """Input specification for the CiftiSeparateVolumeAll command.""" + + out_file = File( + exists=False, + mandatory=False, + genfile=True, + argstr='%s', + position=0, + desc='The CIFTI output.', + ) + left_metric = File( + exists=True, + mandatory=False, + argstr='-left-metric %s', + position=1, + desc='The input surface data from the left hemisphere.', + ) + right_metric = File( + exists=True, + mandatory=False, + argstr='-right-metric %s', + position=2, + desc='The input surface data from the right hemisphere.', + ) + volume_data = File( + exists=True, + mandatory=False, + argstr='-volume %s', + position=3, + desc='The input volumetric data.', + ) + structure_label_volume = File( + exists=True, + mandatory=False, + argstr='%s', + position=4, + desc='A label file indicating the structure of each voxel in volume_data.', + ) + + +class _CiftiCreateDenseScalarOutputSpec(TraitedSpec): + """Output specification for the CiftiCreateDenseScalar command.""" + + out_file = File(exists=True, desc='output CIFTI file') + + +class CiftiCreateDenseScalar(WBCommand): + """Extract volumetric data from CIFTI file (.dtseries). + + Other structures can also be extracted. + The input cifti file must have a brain models mapping on the chosen + dimension, columns for .dtseries, + + Examples + -------- + >>> cifticreatedensescalar = CiftiCreateDenseScalar() + >>> cifticreatedensescalar.inputs.out_file = 'sub_01_task-rest.dscalar.nii' + >>> cifticreatedensescalar.inputs.left_metric = 'sub_01_task-rest_hemi-L.func.gii' + >>> cifticreatedensescalar.inputs.left_metric = 'sub_01_task-rest_hemi-R.func.gii' + >>> cifticreatedensescalar.inputs.volume_data = 'sub_01_task-rest_subcortical.nii.gz' + >>> cifticreatedensescalar.inputs.structure_label_volume = 'sub_01_task-rest_labels.nii.gz' + >>> cifticreatedensescalar.cmdline + wb_command -cifti-create-dense-scalar 'sub_01_task-rest.dscalar.nii' \ + -left-metric 'sub_01_task-rest_hemi-L.func.gii' \ + -right-metric 'sub_01_task-rest_hemi-R.func.gii' \ + -volume-data 'sub_01_task-rest_subcortical.nii.gz' 'sub_01_task-rest_labels.nii.gz' + """ + + input_spec = _CiftiCreateDenseScalarInputSpec + output_spec = _CiftiCreateDenseScalarOutputSpec + _cmd = 'wb_command -cifti-create-dense-scalar' + + def _gen_filename(self, name): + if name != 'out_file': + return None + + if isdefined(self.inputs.out_file): + return self.inputs.out_file + elif isdefined(self.inputs.volume_data): + _, fname, _ = split_filename(self.inputs.volume_data) + else: + _, fname, _ = split_filename(self.inputs.left_metric) + + return f'{fname}_converted.dscalar.nii' + + def _list_outputs(self): + outputs = self.output_spec().get() + outputs['out_file'] = os.path.abspath(self._gen_filename('out_file')) + return outputs + + class _ParcellationStats2TSVInputSpec(DynamicTraitedSpec): in_file = File(exists=True, mandatory=True, desc='parcellated data') hemisphere = traits.Enum('lh', 'rh', usedefault=True, desc='hemisphere') diff --git a/src/smripost_linc/utils/utils.py b/src/smripost_linc/utils/utils.py index 40085d0..633827c 100644 --- a/src/smripost_linc/utils/utils.py +++ b/src/smripost_linc/utils/utils.py @@ -241,3 +241,82 @@ def list_to_str(lst): return ' and '.join(lst_str) else: return f"{', '.join(lst_str[:-1])}, and {lst_str[-1]}" + + +def split_filename(fname): + """Split a filename into parts: path, base filename and extension. + + Parameters + ---------- + fname : :obj:`str` + file or path name + + Returns + ------- + pth : :obj:`str` + base path from fname + fname : :obj:`str` + filename from fname, without extension + ext : :obj:`str` + file extension from fname + + Examples + -------- + >>> from nipype.utils.filemanip import split_filename + >>> pth, fname, ext = split_filename('/home/data/subject.nii.gz') + >>> pth + '/home/data' + + >>> fname + 'subject' + + >>> ext + '.nii.gz' + """ + # TM 07152022 - edited to add cifti and workbench extensions + special_extensions = [ + '.nii.gz', + '.tar.gz', + '.niml.dset', + '.dconn.nii', + '.dlabel.nii', + '.dpconn.nii', + '.dscalar.nii', + '.dtseries.nii', + '.fiberTEMP.nii', + '.trajTEMP.wbsparse', + '.pconn.nii', + '.pdconn.nii', + '.plabel.nii', + '.pscalar.nii', + '.ptseries.nii', + '.sdseries.nii', + '.label.gii', + '.label.gii', + '.func.gii', + '.shape.gii', + '.rgba.gii', + '.surf.gii', + '.dpconn.nii', + '.dtraj.nii', + '.pconnseries.nii', + '.pconnscalar.nii', + '.dfan.nii', + '.dfibersamp.nii', + '.dfansamp.nii', + ] + + pth = op.dirname(fname) + fname = op.basename(fname) + + ext = None + for special_ext in special_extensions: + ext_len = len(special_ext) + if (len(fname) > ext_len) and (fname[-ext_len:].lower() == special_ext.lower()): + ext = fname[-ext_len:] + fname = fname[:-ext_len] + break + if not ext: + fname, ext = op.splitext(fname) + + return pth, fname, ext diff --git a/src/smripost_linc/workflows/base.py b/src/smripost_linc/workflows/base.py index fb6bcd8..70b58cf 100644 --- a/src/smripost_linc/workflows/base.py +++ b/src/smripost_linc/workflows/base.py @@ -405,6 +405,8 @@ def init_single_run_wf(anat_file, atlases): ]), ]) # fmt:skip + # Calculate myelin map if both T1w and T2w are available + # Fill-in datasinks seen so far for node in workflow.list_node_names(): node_name = node.split('.')[-1] diff --git a/src/smripost_linc/workflows/freesurfer.py b/src/smripost_linc/workflows/freesurfer.py index eb3f3c5..d2a986a 100644 --- a/src/smripost_linc/workflows/freesurfer.py +++ b/src/smripost_linc/workflows/freesurfer.py @@ -2,10 +2,13 @@ # vi: set ft=python sts=4 ts=4 sw=4 et: """Workflows for working with FreeSurfer derivatives.""" +from nipype.interfaces import freesurfer as fs from nipype.interfaces import utility as niu from nipype.pipeline import engine as pe from niworkflows.engine.workflows import LiterateWorkflow as Workflow +from smripost_linc.interfaces.bids import DerivativesDataSink + def init_parcellate_external_wf( name_source, @@ -45,9 +48,6 @@ def init_parcellate_external_wf( parcellated_tsvs Parcellated TSV files. One for each atlas and hemisphere. """ - from nipype.interfaces import freesurfer as fs - - from smripost_linc.interfaces.bids import DerivativesDataSink from smripost_linc.interfaces.freesurfer import CopyAnnots, FreesurferFiles from smripost_linc.interfaces.misc import ParcellationStats2TSV @@ -236,3 +236,99 @@ def symlink_freesurfer_dir(freesurfer_dir, output_dir=None): ) return str(output_dir) + + +def init_convert_metrics_to_cifti_wf(name='convert_metrics_to_cifti_wf'): + """Convert FreeSurfer metrics from MGH format to CIFTI format in fsLR space.""" + from smripost_linc.interfaces.freesurfer import CollectFSAverageSurfaces + from smripost_linc.interfaces.misc import CiftiCreateDenseScalar + + workflow = Workflow(name=name) + + inputnode = pe.Node( + niu.IdentityInterface( + fields=[ + 'subject_id', + 'freesurfer_dir', + ], + ), + name='inputnode', + ) + + # Convert FreeSurfer metrics to CIFTIfy + collect_fsaverage_surfaces = pe.Node( + CollectFSAverageSurfaces(), + name='collect_fsaverage_surfaces', + ) + workflow.connect([ + (inputnode, collect_fsaverage_surfaces, [('freesurfer_dir', 'freesurfer_dir')]), + ]) # fmt:skip + + convert_gifti_to_cifti = pe.MapNode( + CiftiCreateDenseScalar(), + name='convert_gifti_to_cifti', + iterfield=['lh_gifti', 'rh_gifti'], + ) + + for hemi in ['lh', 'rh']: + convert_to_gifti = pe.MapNode( + fs.MRIsConvert(out_datatype='gii'), + name=f'convert_to_gifti_{hemi}', + iterfield=['in_file'], + ) + workflow.connect([ + (collect_fsaverage_surfaces, convert_to_gifti, [ + (f'{hemi}_fsaverage_files', 'in_file'), + ]), + (convert_to_gifti, convert_gifti_to_cifti, [('out_file', f'{hemi}_gifti')]), + ]) # fmt:skip + + warp_fsaverage_to_fslr = pe.MapNode( + niu.Function( + input_names=['in_file', 'hemi'], + output_names=['out_file'], + function=fsaverage_to_fslr, + ), + name='warp_fsaverage_to_fslr', + iterfield=['in_file'], + ) + warp_fsaverage_to_fslr.inputs.hemi = hemi + workflow.connect([ + (convert_to_gifti, warp_fsaverage_to_fslr, [('out_file', 'in_file')]), + (warp_fsaverage_to_fslr, convert_gifti_to_cifti, [('out_file', f'{hemi}_gifti')]), + ]) # fmt:skip + + ds_cifti = pe.MapNode( + DerivativesDataSink( + space='fsLR', + extension='.dscalar.nii', + ), + name='ds_cifti', + iterfield=['in_file', 'suffix'], + ) + workflow.connect([ + (collect_fsaverage_surfaces, ds_cifti, [('names', 'suffix')]), + (convert_gifti_to_cifti, ds_cifti, [('out_file', 'in_file')]), + ]) # fmt:skip + + return workflow + + +def fsaverage_to_fslr(in_file, hemi): + """Convert fsaverage-space GIFTI files to a CIFTI file in fsLR space.""" + import os + + from neuromaps import transforms + + out_file = os.path.abspath('cifti.dscalar.nii') + + fslr_gifti = transforms.fsaverage_to_fslr( + in_file, + target_density='164k', + hemi=hemi[0].upper(), + method='linear', + ) + out_file = os.path.abspath(f'{hemi}.dscalar.nii') + fslr_gifti.to_filename(out_file) + + return out_file