diff --git a/qsiprep/cli/run.py b/qsiprep/cli/run.py index eddd5ec3..43807a16 100644 --- a/qsiprep/cli/run.py +++ b/qsiprep/cli/run.py @@ -284,9 +284,13 @@ def get_parser(): action="store", nargs="+", default=[], - choices=["fieldmaps"], - help="ignore selected aspects of the input dataset to disable " - "corresponding parts of the workflow (a space delimited list)", + choices=["fieldmaps", "phase"], + help=( + "ignore selected aspects of the input dataset to disable " + "corresponding parts of the workflow (a space delimited list). " + 'Ignoring "phase" will disable complex-valued denoising, ' + "when phase DWI data are available." + ), ) g_conf.add_argument( "--longitudinal", diff --git a/qsiprep/interfaces/dwi_merge.py b/qsiprep/interfaces/dwi_merge.py index b4cb08c7..2f89d5d6 100644 --- a/qsiprep/interfaces/dwi_merge.py +++ b/qsiprep/interfaces/dwi_merge.py @@ -3,6 +3,7 @@ import json import os.path as op +import nibabel as nb import numpy as np import pandas as pd from nilearn.image import concat_imgs, index_img, iter_img, load_img, math_img @@ -714,3 +715,154 @@ def create_provenance_dataframe( image_df = pd.concat(series_confounds, axis=0, ignore_index=True) image_df["original_file"] = bids_sources return image_df + + +class _PhaseToRadInputSpec(BaseInterfaceInputSpec): + """Output spec for PhaseToRad interface. + + STATEMENT OF CHANGES: This class is derived from sources licensed under the Apache-2.0 terms, + and the code has been changed. + + Notes + ----- + The code is derived from + https://github.com/nipreps/sdcflows/blob/c6cd42944f4b6d638716ce020ffe51010e9eb58a/\ + sdcflows/utils/phasemanip.py#L26. + + License + ------- + ORIGINAL WORK'S ATTRIBUTION NOTICE: + + Copyright 2021 The NiPreps Developers + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + We support and encourage derived works from this project, please read + about our expectations at + + https://www.nipreps.org/community/licensing/ + + """ + + phase_file = File(exists=True, mandatory=True) + + +class _PhaseToRadOutputSpec(TraitedSpec): + """Output spec for PhaseToRad interface. + + STATEMENT OF CHANGES: This class is derived from sources licensed under the Apache-2.0 terms, + and the code has been changed. + + Notes + ----- + The code is derived from + https://github.com/nipreps/sdcflows/blob/c6cd42944f4b6d638716ce020ffe51010e9eb58a/\ + sdcflows/utils/phasemanip.py#L26. + + License + ------- + ORIGINAL WORK'S ATTRIBUTION NOTICE: + + Copyright 2021 The NiPreps Developers + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + We support and encourage derived works from this project, please read + about our expectations at + + https://www.nipreps.org/community/licensing/ + + """ + + phase_file = File(exists=True) + + +class PhaseToRad(SimpleInterface): + """Convert phase image from arbitrary units (au) to radians. + + This method assumes that the phase image's minimum and maximum values correspond to + -pi and pi, respectively, and scales the image to be between 0 and 2*pi. + + STATEMENT OF CHANGES: This class is derived from sources licensed under the Apache-2.0 terms, + and the code has not been changed. + + Notes + ----- + The code is derived from + https://github.com/nipreps/sdcflows/blob/c6cd42944f4b6d638716ce020ffe51010e9eb58a/\ + sdcflows/utils/phasemanip.py#L26. + + License + ------- + ORIGINAL WORK'S ATTRIBUTION NOTICE: + + Copyright 2021 The NiPreps Developers + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + We support and encourage derived works from this project, please read + about our expectations at + + https://www.nipreps.org/community/licensing/ + + """ + + input_spec = _PhaseToRadInputSpec + output_spec = _PhaseToRadOutputSpec + + def _run_interface(self, runtime): + im = nb.load(self.inputs.phase_file) + data = im.get_fdata(caching="unchanged") # Read as float64 for safety + hdr = im.header.copy() + + # Rescale to [0, 2*pi] + data = (data - data.min()) * (2 * np.pi / (data.max() - data.min())) + + # Round to float32 and clip + data = np.clip(np.float32(data), 0.0, 2 * np.pi) + + hdr.set_data_dtype(np.float32) + hdr.set_xyzt_units("mm") + + # Set the output file name + self._results["phase_file"] = fname_presuffix( + self.inputs.phase_file, + suffix="_rad.nii.gz", + newpath=runtime.cwd, + use_ext=False, + ) + + # Save the output image + nb.Nifti1Image(data, None, hdr).to_filename(self._results["phase_file"]) + + return runtime diff --git a/qsiprep/interfaces/mrtrix.py b/qsiprep/interfaces/mrtrix.py index a11ecfaa..4682e669 100644 --- a/qsiprep/interfaces/mrtrix.py +++ b/qsiprep/interfaces/mrtrix.py @@ -1369,3 +1369,54 @@ class TransformHeader(CommandLine): input_spec = _TransformHeaderInputSpec output_spec = _TransformHeaderOutputSpec _cmd = "mrtransform -strides -1,-2,3" + + +class _PolarToComplexInputSpec(CommandLineInputSpec): + mag_file = traits.File(exists=True, mandatory=True, position=0, argstr="%s") + phase_file = traits.File(exists=True, mandatory=True, position=1, argstr="%s") + out_file = traits.File( + exists=False, + name_source="mag_file", + name_template="%s_complex.nii.gz", + keep_extension=False, + position=-1, + argstr="-polar %s", + ) + + +class _PolarToComplexOutputSpec(TraitedSpec): + out_file = File(exists=True) + + +class PolarToComplex(CommandLine): + """Convert a magnitude and phase image pair to a single complex image using mrcalc.""" + + input_spec = _PolarToComplexInputSpec + output_spec = _PolarToComplexOutputSpec + + _cmd = "mrcalc" + + +class _ComplexToMagnitudeInputSpec(CommandLineInputSpec): + complex_file = traits.File(exists=True, mandatory=True, position=0, argstr="%s") + out_file = traits.File( + exists=False, + name_source="complex_file", + name_template="%s_mag.nii.gz", + keep_extension=False, + position=-1, + argstr="-abs %s", + ) + + +class _ComplexToMagnitudeOutputSpec(TraitedSpec): + out_file = File(exists=True) + + +class ComplexToMagnitude(CommandLine): + """Extract the magnitude portion of a complex image using mrcalc.""" + + input_spec = _ComplexToMagnitudeInputSpec + output_spec = _ComplexToMagnitudeOutputSpec + + _cmd = "mrcalc" diff --git a/qsiprep/utils/bids.py b/qsiprep/utils/bids.py index 29bd5281..adf059ea 100644 --- a/qsiprep/utils/bids.py +++ b/qsiprep/utils/bids.py @@ -157,10 +157,7 @@ def collect_participants(bids_dir, participant_label=None, strict=False, bids_va def collect_data(bids_dir, participant_label, filters=None, bids_validate=True): - """ - Uses pybids to retrieve the input data for a given participant - - """ + """Use pybids to retrieve the input data for a given participant.""" if isinstance(bids_dir, BIDSLayout): layout = bids_dir else: @@ -173,7 +170,7 @@ def collect_data(bids_dir, participant_label, filters=None, bids_validate=True): "t2w": {"datatype": "anat", "suffix": "T2w"}, "t1w": {"datatype": "anat", "suffix": "T1w"}, "roi": {"datatype": "anat", "suffix": "roi"}, - "dwi": {"datatype": "dwi", "suffix": "dwi"}, + "dwi": {"datatype": "dwi", "part": ["mag", None], "suffix": "dwi"}, } bids_filters = filters or {} for acq, entities in bids_filters.items(): diff --git a/qsiprep/utils/grouping.py b/qsiprep/utils/grouping.py index 0a4b0273..d9b11648 100644 --- a/qsiprep/utils/grouping.py +++ b/qsiprep/utils/grouping.py @@ -51,7 +51,6 @@ def group_dwi_scans( A dict where the keys are the BIDS derivatives name of the output file after concatenation. The values are lists of dwi files in that group. """ - # Handle the grouping of multiple dwi files within a session dwi_session_groups = get_session_groups(bids_layout, subject_data, combine_scans) diff --git a/qsiprep/workflows/base.py b/qsiprep/workflows/base.py index 88288b5f..e1806a1a 100644 --- a/qsiprep/workflows/base.py +++ b/qsiprep/workflows/base.py @@ -158,8 +158,7 @@ def init_qsiprep_wf( anatomical_contrst : str Which contrast to use for the anatomical reference ignore : list - Preprocessing steps to skip (may include "slicetiming", - "fieldmaps") + Preprocessing steps to skip (may include "slicetiming", "fieldmaps", "phase"). low_mem : bool Write uncompressed .nii files in some cases to reduce memory usage anat_only : bool @@ -401,7 +400,7 @@ def init_single_subject_wf( name : str Name of workflow ignore : list - Preprocessing steps to skip (may include "sbref", "fieldmaps") + Preprocessing steps to skip (may include "sbref", "fieldmaps", "phase") debug : bool Do inaccurate but fast normalization low_mem : bool @@ -496,7 +495,10 @@ def init_single_subject_wf( LOGGER.warning("Building a test workflow") else: subject_data, layout = collect_data( - bids_dir, subject_id, filters=bids_filters, bids_validate=False + bids_dir, + subject_id, + filters=bids_filters, + bids_validate=False, ) # Warn about --dwi-only and non-none --anat-modality diff --git a/qsiprep/workflows/dwi/base.py b/qsiprep/workflows/dwi/base.py index ae3470f0..e08461b4 100644 --- a/qsiprep/workflows/dwi/base.py +++ b/qsiprep/workflows/dwi/base.py @@ -373,6 +373,8 @@ def init_dwi_preproc_wf( source_file=source_file, low_mem=low_mem, denoise_before_combining=denoise_before_combining, + layout=layout, + ignore=ignore, omp_nthreads=omp_nthreads, ) test_pre_hmc_connect = pe.Node(TestInput(), name="test_pre_hmc_connect") diff --git a/qsiprep/workflows/dwi/merge.py b/qsiprep/workflows/dwi/merge.py index 796b6eac..4285e607 100644 --- a/qsiprep/workflows/dwi/merge.py +++ b/qsiprep/workflows/dwi/merge.py @@ -10,6 +10,7 @@ """ import pandas as pd +from bids.layout import Query from nipype import logging from nipype.interfaces import utility as niu from nipype.pipeline import engine as pe @@ -17,11 +18,16 @@ from ...engine import Workflow from ...interfaces import ConformDwi, DerivativesDataSink -from ...interfaces.bids import get_metadata_for_nifti from ...interfaces.dipy import Patch2Self -from ...interfaces.dwi_merge import MergeDWIs, StackConfounds +from ...interfaces.dwi_merge import MergeDWIs, PhaseToRad, StackConfounds from ...interfaces.gradients import ExtractB0s -from ...interfaces.mrtrix import DWIBiasCorrect, DWIDenoise, MRDeGibbs +from ...interfaces.mrtrix import ( + ComplexToMagnitude, + DWIBiasCorrect, + DWIDenoise, + MRDeGibbs, + PolarToComplex, +) from ...interfaces.nilearn import MaskEPI, Merge from ...interfaces.tortoise import Gibbs from ...utils.bids import IMPORTANT_DWI_FIELDS, update_metadata_from_nifti_header @@ -43,6 +49,8 @@ def init_merge_and_denoise_wf( orientation, b0_threshold, source_file, + layout=None, + ignore=[], mem_gb=1, omp_nthreads=1, calculate_qc=False, @@ -56,16 +64,18 @@ def init_merge_and_denoise_wf( :simple_form: yes from qsiprep.workflows.dwi import init_merge_and_denoise_wf - wf = init_merge_and_dwnoise_wf(['/path/to/dwi/sub-1_dwi.nii.gz'], - source_file='/data/sub-1/dwi/sub-1_dwi.nii.gz', - dwi_denoise_window=7, - denoise_method='patch2self', - b0_threshold=100, - unringing_method='mrdegibbs', - dwi_no_biascorr=False, - no_b0_harmonization=False, - denoise_before_combining=True, - combine_all_dwis=True) + wf = init_merge_and_dwnoise_wf( + ['/path/to/dwi/sub-1_dwi.nii.gz'], + source_file='/data/sub-1/dwi/sub-1_dwi.nii.gz', + dwi_denoise_window=7, + denoise_method='patch2self', + b0_threshold=100, + unringing_method='mrdegibbs', + dwi_no_biascorr=False, + no_b0_harmonization=False, + denoise_before_combining=True, + combine_all_dwis=True, + ) **Parameters** @@ -90,6 +100,12 @@ def init_merge_and_denoise_wf( is run on the merged dwi series. calculate_qc : bool Should DSI Studio's QC be calculated on the merged raw data? + layout : None or :obj:`bids.layout.BIDSLayout` + Used to try to find phase files. + Only used if ``denoise_before_combining`` is ``True``. + ignore : list + List of elements to ignore in processing. + The only relevant value for this workflow is "phase". **Outputs** @@ -109,7 +125,6 @@ def init_merge_and_denoise_wf( DSI Studio QC text file """ - workflow = Workflow(name=name) outputnode = pe.Node( niu.IdentityInterface( @@ -167,14 +182,14 @@ def init_merge_and_denoise_wf( denoising_wfs = [] # Get a data frame of the raw_dwi_files and their imaging parameters: - dwi_df = get_acq_parameters_df(raw_dwi_files) + dwi_df = get_acq_parameters_df(raw_dwi_files, layout=layout) for dwi_num, row in dwi_df.iterrows(): dwi_num += 1 # start at 1 dwi_file = row.BIDSFile # Conform each image to the requested orientation conformers.append( - pe.Node(ConformDwi(orientation=orientation), name="conform_dwis%02d" % dwi_num) + pe.Node(ConformDwi(orientation=orientation), name="conform_dwis%02d" % dwi_num), ) conformers[-1].inputs.dwi_file = dwi_file @@ -182,6 +197,30 @@ def init_merge_and_denoise_wf( # Build the denoising workflow _, fname, _ = split_filename(dwi_file) wf_name = _get_wf_name(fname).replace("preproc", "denoise") + + # Set up a strict query for a phase file based on the magnitude file. + all_entities = layout.get_entities(metadata=False) + # No other non-matching entities allowed + query = {k: Query.NONE for k in all_entities.keys()} + query.update(layout.get_file(dwi_file).get_entities()) + query["part"] = "phase" + phase_files = layout.get(**query) + phase_available = False + if len(phase_files) == 1: + phase_available = True + LOGGER.info("Phase file found for %s", dwi_file) + phase_file = phase_files[0].path + + use_phase = phase_available and "phase" not in ignore + if use_phase: + conform_phase = pe.Node( + ConformDwi( + orientation=orientation, + dwi_file=phase_file, + ), + name=f"conform_phase{dwi_num}", + ) + denoising_wfs.append( init_dwi_denoising_wf( dwi_denoise_window=dwi_denoise_window, @@ -195,20 +234,28 @@ def init_merge_and_denoise_wf( phase_encoding_direction=row.PhaseEncodingAxis, omp_nthreads=omp_nthreads, source_file=dwi_file, + use_phase=use_phase, name=wf_name, - ) + ), ) workflow.connect([ (conformers[-1], denoising_wfs[-1], [ ('bval_file', 'inputnode.bval_file'), ('bvec_file', 'inputnode.bvec_file'), - ('dwi_file', 'inputnode.dwi_file')]), + ('dwi_file', 'inputnode.dwi_file'), + ]), (denoising_wfs[-1], denoising_confounds, [ - ('outputnode.confounds', 'in%d' % dwi_num)]), - (denoising_wfs[-1], noise_images, [ - ('outputnode.noise_image', 'in%d' % dwi_num)]), - (denoising_wfs[-1], bias_images, [ - ('outputnode.bias_image', 'in%d' % dwi_num)])]) # fmt:skip + ('outputnode.confounds', f'in{dwi_num}'), + ]), + (denoising_wfs[-1], noise_images, [('outputnode.noise_image', f'in{dwi_num}')]), + (denoising_wfs[-1], bias_images, [('outputnode.bias_image', f'in{dwi_num}')]), + ]) # fmt:skip + + if use_phase: + workflow.connect([ + (conform_phase, denoising_wfs[-1], [('dwi_file', 'inputnode.dwi_phase_file')]), + ]) # fmt:skip + dwi_source = denoising_wfs[-1] edge_prefix = "outputnode." else: @@ -216,11 +263,11 @@ def init_merge_and_denoise_wf( edge_prefix = "" workflow.connect([ - (dwi_source, conformed_images, [(edge_prefix + 'dwi_file', 'in%d' % dwi_num)]), - (conformers[-1], conformed_raw_images, [('dwi_file', 'in%d' % dwi_num)]), - (dwi_source, conformed_bvals, [(edge_prefix + 'bval_file', 'in%d' % dwi_num)]), - (dwi_source, conformed_bvecs, [(edge_prefix + 'bvec_file', 'in%d' % dwi_num)]), - (conformers[-1], conformation_reports, [('out_report', 'in%d' % dwi_num)]) + (dwi_source, conformed_images, [(f'{edge_prefix}dwi_file', f'in{dwi_num}')]), + (conformers[-1], conformed_raw_images, [('dwi_file', f'in{dwi_num}')]), + (dwi_source, conformed_bvals, [(f'{edge_prefix}bval_file', f'in{dwi_num}')]), + (dwi_source, conformed_bvecs, [(f'{edge_prefix}bvec_file', f'in{dwi_num}')]), + (conformers[-1], conformation_reports, [('out_report', f'in{dwi_num}')]), ]) # fmt:skip # Get an orientation-conformed version of the raw inputs and their gradients @@ -237,18 +284,24 @@ def init_merge_and_denoise_wf( (merge_dwis, outputnode, [ ('original_images', 'original_files'), ('out_bval', 'merged_bval'), - ('out_bvec', 'merged_bvec')])]) # fmt:skip + ('out_bvec', 'merged_bvec'), + ]), + ]) # fmt:skip # Get a QC score for the raw data if calculate_qc: qc_wf = init_modelfree_qc_wf( - omp_nthreads=omp_nthreads, bvec_convention="DIPY" if orientation == "LPS" else "FSL" + omp_nthreads=omp_nthreads, + bvec_convention="DIPY" if orientation == "LPS" else "FSL", ) workflow.connect([ (qc_wf, outputnode, [('outputnode.qc_summary', 'qc_summary')]), (raw_merge, qc_wf, [('out_file', 'inputnode.dwi_file')]), - (merge_dwis, qc_wf, [('out_bval', 'inputnode.bval_file'), - ('out_bvec', 'inputnode.bvec_file')])]) # fmt:skip + (merge_dwis, qc_wf, [ + ('out_bval', 'inputnode.bval_file'), + ('out_bvec', 'inputnode.bvec_file'), + ]), + ]) # fmt:skip # We have denoised and combined, therefore we are done if denoise_before_combining: @@ -256,10 +309,12 @@ def init_merge_and_denoise_wf( (denoising_confounds, merge_dwis, [('out', 'denoising_confounds')]), (merge_dwis, outputnode, [ ('out_dwi', 'merged_image'), - ('merged_denoising_confounds', 'denoising_confounds')]), + ('merged_denoising_confounds', 'denoising_confounds'), + ]), (noise_images, outputnode, [('out', 'noise_images')]), (bias_images, outputnode, [('out', 'bias_images')]) ]) # fmt:skip + return workflow # Send the merged series for denoising @@ -277,6 +332,7 @@ def init_merge_and_denoise_wf( mem_gb=mem_gb, omp_nthreads=omp_nthreads, source_file=source_file, + use_phase=False, # can't use phase with concatenated data name="merged_denoise", ) @@ -284,7 +340,9 @@ def init_merge_and_denoise_wf( (merge_dwis, denoising_wf, [ ('out_bval', 'inputnode.bval_file'), ('out_dwi', 'inputnode.dwi_file'), - ('out_bvec', 'inputnode.bvec_file')]), + ('out_bvec', 'inputnode.bvec_file'), + ('out_dwi_phase', 'inputnode.dwi_phase_file'), + ]), (merge_dwis, merge_confounds, [('merged_denoising_confounds', 'in1')]), (denoising_wf, merge_confounds, [('outputnode.confounds', 'in2')]), (merge_confounds, hstack_confounds, [('out', 'in_files')]), @@ -292,7 +350,8 @@ def init_merge_and_denoise_wf( (denoising_wf, outputnode, [ ('outputnode.dwi_file', 'merged_image'), (('outputnode.noise_image', _as_list), 'noise_images'), - (('outputnode.bias_image', _as_list), 'bias_images')]) + (('outputnode.bias_image', _as_list), 'bias_images'), + ]) ]) # fmt:skip return workflow @@ -308,17 +367,86 @@ def init_dwi_denoising_wf( source_file, partial_fourier, phase_encoding_direction, + use_phase, mem_gb=1, omp_nthreads=1, name="denoise_wf", ): + """Build a workflow to denoise a DWI series. + + Parameters + ---------- + dwi_denoise_window : int + window size in voxels for image-based denoising. Must be odd. If 0, ' + 'denoising will not be run' + denoise_method : str + Either 'dwidenoise', 'patch2self' or 'none' + unringing_method : str + algorithm to use for removing Gibbs ringing. Options: none, mrdegibbs + dwi_no_biascorr : bool + run spatial bias correction (N4) on dwi series + no_b0_harmonization : bool + skip rescaling dwi scans to have matching b=0 intensities across scans + b0_threshold : int + Maximum b value for an image to be considered a b=0 + source_file : str + path to the original dwi file + partial_fourier : float + fraction of k-space acquired + phase_encoding_direction : str + direction of phase encoding + phase_available : bool + True if phase data are available for the DWI scan. + If True, and ``denoise_method`` is ``dwidenoise``, then ``dwidenoise`` + will be run on the complex-valued data. + mem_gb : float + memory in GB to allocate to the workflow + omp_nthreads : int + number of threads to use + name : str + name of the workflow + + Inputs + ------ + dwi_file + path to the dwi file + bval_file + path to the bval file + bvec_file + path to the bvec file + dwi_phase_file + path to the dwi phase file (optional) + + Outputs + ------- + dwi_file + path to the denoised dwi file + bval_file + path to the denoised bval file + bvec_file + path to the denoised bvec file + noise_image + path to the noise image + bias_image + path to the bias image + confounds + path to the confounds file + """ inputnode = pe.Node( - niu.IdentityInterface(fields=["dwi_file", "bval_file", "bvec_file"]), name="inputnode" + niu.IdentityInterface(fields=["dwi_file", "bval_file", "bvec_file", "dwi_phase_file"]), + name="inputnode", ) outputnode = pe.Node( niu.IdentityInterface( - fields=["dwi_file", "bval_file", "bvec_file", "noise_image", "bias_image", "confounds"] + fields=[ + "dwi_file", + "bval_file", + "bvec_file", + "noise_image", + "bias_image", + "confounds", + ], ), name="outputnode", ) @@ -329,14 +457,22 @@ def init_dwi_denoising_wf( def get_buffernode(): num_buffers = len(buffernodes) - return pe.Node(niu.IdentityInterface(fields=["dwi_file"]), name="buffer%02d" % num_buffers) + return pe.Node( + niu.IdentityInterface(fields=["dwi_file"]), + name=f"buffer{num_buffers:02}", + ) buffernodes.append(get_buffernode()) + workflow.connect([ - (inputnode, buffernodes[-1], [('dwi_file', 'dwi_file')]), + # The first buffernode is the raw file + (inputnode, buffernodes[0], [('dwi_file', 'dwi_file')]), + # XXX: Why pass the bval and bvec files through unmodified? (inputnode, outputnode, [ ('bval_file', 'bval_file'), - ('bvec_file', 'bvec_file')])]) # fmt:skip + ('bvec_file', 'bvec_file'), + ]), + ]) # fmt:skip # Which steps to apply? do_denoise = denoise_method in ("patch2self", "dwidenoise") @@ -350,7 +486,66 @@ def get_buffernode(): # Add the steps step_num = 1 # Merge inputs start at 1 if do_denoise: - if denoise_method == "dwidenoise": + # Add buffernode for denoised DWI + buffernodes.append(get_buffernode()) + + ds_report_denoising = pe.Node( + DerivativesDataSink( + suffix=f"{name}_denoising", + source_file=source_file, + ), + name=f"ds_report_{name}_denoising", + run_without_submitting=True, + mem_gb=DEFAULT_MEMORY_MIN_GB, + ) + + if (denoise_method == "dwidenoise") and use_phase: + # If there are phase files available, then we can use dwidenoise + # on the complex-valued data. + phase_to_radians = pe.Node( + PhaseToRad(), + name="phase_to_radians", + n_procs=omp_nthreads, + ) + workflow.connect([(inputnode, phase_to_radians, [("dwi_phase_file", "phase_file")])]) + + combine_complex = pe.Node( + PolarToComplex(), + name="combine_complex", + n_procs=omp_nthreads, + ) + workflow.connect([ + (buffernodes[-2], combine_complex, [('dwi_file', 'mag_file')]), + (phase_to_radians, combine_complex, [('phase_file', 'phase_file')]), + ]) # fmt:skip + + denoiser = pe.Node( + DWIDenoise( + extent=(dwi_denoise_window, dwi_denoise_window, dwi_denoise_window), + nthreads=omp_nthreads, + ), + name="denoiser", + n_procs=omp_nthreads, + ) + + workflow.connect([ + (combine_complex, denoiser, [('out_file', 'in_file')]), + (denoiser, ds_report_denoising, [('out_report', 'in_file')]), + (denoiser, merge_confounds, [('nmse_text', f'in{step_num}')]), + ]) # fmt:skip + + split_complex = pe.Node( + ComplexToMagnitude(), + name="split_complex", + n_procs=omp_nthreads, + ) + + workflow.connect([ + (denoiser, split_complex, [('out_file', 'complex_file')]), + (split_complex, buffernodes[-1], [('out_file', 'dwi_file')]), + ]) # fmt:skip + + elif denoise_method == "dwidenoise": denoiser = pe.Node( DWIDenoise( extent=(dwi_denoise_window, dwi_denoise_window, dwi_denoise_window), @@ -361,66 +556,86 @@ def get_buffernode(): ) else: denoiser = pe.Node( - Patch2Self(patch_radius=dwi_denoise_window), name="denoiser", n_procs=omp_nthreads + Patch2Self(patch_radius=dwi_denoise_window), + name="denoiser", + n_procs=omp_nthreads, ) + workflow.connect([(inputnode, denoiser, [("bval_file", "bval_file")])]) + + if (denoise_method in ("dwidenoise", "patch2self")) and not use_phase: workflow.connect([ - (inputnode, denoiser, [('bval_file', 'bval_file')])]) # fmt:skip - ds_report_denoising = pe.Node( - DerivativesDataSink(suffix=name + "_denoising", source_file=source_file), - name="ds_report_" + name + "_denoising", - run_without_submitting=True, - mem_gb=DEFAULT_MEMORY_MIN_GB, - ) - buffernodes.append(get_buffernode()) - workflow.connect([ - (buffernodes[-2], denoiser, [('dwi_file', 'in_file')]), - (denoiser, ds_report_denoising, [('out_report', 'in_file')]), - (denoiser, buffernodes[-1], [('out_file', 'dwi_file')]), - (denoiser, merge_confounds, [('nmse_text', 'in%d' % step_num)]) - ]) # fmt:skip + (buffernodes[-2], denoiser, [('dwi_file', 'in_file')]), + (denoiser, ds_report_denoising, [('out_report', 'in_file')]), + (denoiser, buffernodes[-1], [('out_file', 'dwi_file')]), + (denoiser, merge_confounds, [('nmse_text', f'in{step_num}')]), + ]) # fmt:skip + step_num += 1 if do_unringing: if unringing_method == "mrdegibbs": degibbser = pe.Node( - MRDeGibbs(nthreads=omp_nthreads), name="degibbser", n_procs=omp_nthreads + MRDeGibbs(nthreads=omp_nthreads), + name="degibbser", + n_procs=omp_nthreads, ) elif unringing_method == "rpg": - pe_code = {"i": 0, "i-": 0, "j": 1, "j-": 1, "k": 2}.get(phase_encoding_direction) + pe_code = { + "i": 0, + "i-": 0, + "j": 1, + "j-": 1, + "k": 2, + }.get(phase_encoding_direction) if pe_code is None: raise Exception("rpg requires an i[-],j[-] or k[-] PhaseEncodingDirection") + degibbser = pe.Node( - Gibbs(kspace_coverage=partial_fourier, phase_encoding_dir=pe_code), + Gibbs( + kspace_coverage=partial_fourier, + phase_encoding_dir=pe_code, + ), name="degibbser", n_procs=omp_nthreads, ) ds_report_unringing = pe.Node( - DerivativesDataSink(suffix=name + "_unringing", source_file=source_file), - name="ds_report_" + name + "_unringing", + DerivativesDataSink( + suffix=f"{name}_unringing", + source_file=source_file, + ), + name=f"ds_report_{name}_unringing", run_without_submitting=True, mem_gb=DEFAULT_MEMORY_MIN_GB, ) + # Add buffernode for unringed DWI buffernodes.append(get_buffernode()) + workflow.connect([ (buffernodes[-2], degibbser, [('dwi_file', 'in_file')]), (degibbser, ds_report_unringing, [('out_report', 'in_file')]), (degibbser, buffernodes[-1], [('out_file', 'dwi_file')]), - (degibbser, merge_confounds, [('nmse_text', 'in%d' % step_num)]) + (degibbser, merge_confounds, [('nmse_text', f'in{step_num}')]), ]) # fmt:skip step_num += 1 if do_biascorr: biascorr = pe.Node(DWIBiasCorrect(method="ants"), name="biascorr", n_procs=omp_nthreads) ds_report_biascorr = pe.Node( - DerivativesDataSink(suffix=name + "_biascorr", source_file=source_file), - name="ds_report_" + name + "_biascorr", + DerivativesDataSink( + suffix=f"{name}_biascorr", + source_file=source_file, + ), + name=f"ds_report_{name}_biascorr", run_without_submitting=True, mem_gb=DEFAULT_MEMORY_MIN_GB, ) get_b0s = pe.Node(ExtractB0s(b0_threshold=b0_threshold), name="get_b0s") quick_mask = pe.Node(MaskEPI(lower_cutoff=0.02), name="quick_mask") + + # Add buffernode for bias-corrected DWI buffernodes.append(get_buffernode()) + workflow.connect([ (buffernodes[-2], biascorr, [('dwi_file', 'in_file')]), (buffernodes[-2], get_b0s, [('dwi_file', 'dwi_series')]), @@ -429,20 +644,23 @@ def get_buffernode(): (quick_mask, biascorr, [('out_mask', 'mask')]), (biascorr, buffernodes[-1], [('out_file', 'dwi_file')]), (biascorr, ds_report_biascorr, [('out_report', 'in_file')]), - (biascorr, merge_confounds, [('nmse_text', 'in%d' % step_num)]), - (inputnode, biascorr, [('bval_file', 'in_bval'), ('bvec_file', 'in_bvec')]) + (biascorr, merge_confounds, [('nmse_text', f'in{step_num}')]), + (inputnode, biascorr, [ + ('bval_file', 'in_bval'), + ('bvec_file', 'in_bvec'), + ]), ]) # fmt:skip step_num += 1 - workflow.connect([ - (buffernodes[-1], outputnode, [('dwi_file', 'dwi_file')])]) # fmt:skip + # Connect the final buffernode (the most recent output) to the outputnode + workflow.connect([(buffernodes[-1], outputnode, [("dwi_file", "dwi_file")])]) # If any denoising operations were run, collect their confounds if step_num > 1: hstack_confounds = pe.Node(StackConfounds(axis=1), name="hstack_confounds") workflow.connect([ (merge_confounds, hstack_confounds, [('out', 'in_files')]), - (hstack_confounds, outputnode, [('confounds_file', 'confounds')]) + (hstack_confounds, outputnode, [('confounds_file', 'confounds')]), ]) # fmt:skip return workflow @@ -453,17 +671,19 @@ def _as_list(item): def gen_denoising_boilerplate( + *, denoise_method, dwi_denoise_window, unringing_method, b1_biascorrect_stage, no_b0_harmonization, b0_threshold, + use_phase, ): - """Generates methods boilerplate for the denoising workflow.""" + """Generate a methods boilerplate for the denoising workflow.""" desc = [ - "Any images with a b-value less than %d s/mm^2 were treated as a " - "*b*=0 image." % b0_threshold + f"Any images with a b-value less than {b0_threshold} s/mm^2 were treated as a " + "*b*=0 image." ] do_denoise = denoise_method in ("dwidenoise", "patch2self") do_unringing = unringing_method in ("rpg", "mrdegibbs") @@ -473,17 +693,22 @@ def gen_denoising_boilerplate( if denoise_method == "dwidenoise": desc.append( "MP-PCA denoising as implemented in MRtrix3's `dwidenoise`" - "[@dwidenoise1] was applied with " - "a %d-voxel window." % dwi_denoise_window + f"[@dwidenoise1] was applied with a {dwi_denoise_window}-voxel window." ) last_step = "After MP-PCA, " - if denoise_method == "patch2self": - desc.append("Denoising using `patch2self` " "[@patch2self] was applied") + if use_phase: + desc.append( + "When phase data were available, this was done on complex-valued data." + ) + + elif denoise_method == "patch2self": + desc.append("Denoising using `patch2self` [@patch2self] was applied") if dwi_denoise_window == "auto": desc.append("with settings based on developer recommendations.") else: - desc.append("with a %d-voxel window." % dwi_denoise_window) + desc.append(f"with a {dwi_denoise_window}-voxel window.") last_step = "After `patch2self`, " + if do_unringing: unringing_txt = { "mrdegibbs": "MRtrix3's `mrdegibbs` [@mrdegibbs].", @@ -491,20 +716,19 @@ def gen_denoising_boilerplate( "rpg": "TORTOISE's `Gibbs` [@pfgibbs].", }[unringing_method] - desc.append(last_step + "Gibbs unringing was performed using " + unringing_txt) + desc.append(f"{last_step}Gibbs unringing was performed using {unringing_txt}") last_step = "Following unringing, " if b1_biascorrect_stage == "legacy": desc.append( - last_step + "B1 field inhomogeneity was corrected using " - "`dwibiascorrect` from MRtrix3 with the N4 algorithm " - "[@n4]." + f"{last_step}B1 field inhomogeneity was corrected using " + "`dwibiascorrect` from MRtrix3 with the N4 algorithm [@n4]." ) last_step = "After B1 bias correction, " if harmonize_b0s: desc.append( - last_step + "the mean intensity of the DWI series was adjusted " + f"{last_step}the mean intensity of the DWI series was adjusted " "so all the mean intensity of the b=0 images matched across each" "separate DWI scanning sequence." ) @@ -524,16 +748,19 @@ def gen_denoising_boilerplate( return " ".join(desc) -def get_acq_parameters_df(dwi_file_list): +def get_acq_parameters_df(dwi_file_list, layout): """Figure out what the""" file_rows = [] for dwi_file in dwi_file_list: - meta = get_metadata_for_nifti(dwi_file) - update_metadata_from_nifti_header(meta, dwi_file) - meta["BIDSFile"] = dwi_file - file_rows.append(meta) - - merged_acq_params = pd.DataFrame(file_rows, columns=["BIDSFile"] + IMPORTANT_DWI_FIELDS) + metadata = layout.get_metadata(dwi_file) + update_metadata_from_nifti_header(metadata, dwi_file) + metadata["BIDSFile"] = dwi_file + file_rows.append(metadata) + + merged_acq_params = pd.DataFrame( + file_rows, + columns=["BIDSFile"] + IMPORTANT_DWI_FIELDS, + ) merged_acq_params["PhaseEncodingAxis"] = merged_acq_params[ "PhaseEncodingDirection" ].str.replace("-", "") @@ -542,18 +769,24 @@ def get_acq_parameters_df(dwi_file_list): def get_merged_parameter(parameter_df, parameter_name, selection_mode="all"): """Return a single parameter from a parameter dataframe.""" - col = parameter_df[parameter_name] unique_values = col.unique() if len(unique_values) > 1: - LOGGER.warn("Found %d unique values for %s", parameter_name, len(unique_values)) + LOGGER.warn( + "Found %d unique values for %s", + parameter_name, + len(unique_values), + ) + # Require that all the values are the same if selection_mode == "all": if len(unique_values) > 1: raise Exception( - "More than one value for %s was found: %s exiting!" - % (parameter_name, str(unique_values)) + "More than one value for %s was found (%s): exiting!", + parameter_name, + str(unique_values), ) + return unique_values[0] if selection_mode == "mode": diff --git a/qsiprep/workflows/dwi/pre_hmc.py b/qsiprep/workflows/dwi/pre_hmc.py index 45f17e27..074fa439 100644 --- a/qsiprep/workflows/dwi/pre_hmc.py +++ b/qsiprep/workflows/dwi/pre_hmc.py @@ -38,6 +38,8 @@ def init_dwi_pre_hmc_wf( source_file, low_mem, calculate_qc=True, + layout=None, + ignore=[], name="pre_hmc_wf", ): """ @@ -64,6 +66,8 @@ def init_dwi_pre_hmc_wf( no_b0_harmonization=False, denoise_before_combining=True, omp_nthreads=1, + layout=None, + ignore=["phase"], low_mem=False) **Parameters** @@ -85,6 +89,12 @@ def init_dwi_pre_hmc_wf( 'LPS' or 'LAS' low_mem : bool Write uncompressed .nii files in some cases to reduce memory usage + layout : None or :obj:`bids.layout.BIDSLayout` + Used to try to find phase files. + Only used if ``denoise_before_combining`` is ``True``. + ignore : list + List of elements to ignore in processing. + The only relevant value for this workflow is "phase". **Outputs** dwi_file @@ -126,21 +136,24 @@ def init_dwi_pre_hmc_wf( dwi_series = scan_groups["dwi_series"] # Configure the denoising window - if denoise_method == "dwidenoise" and dwi_denoise_window == "auto": + if (denoise_method == "dwidenoise") and dwi_denoise_window == "auto": dwi_denoise_window = 5 LOGGER.info("Automatically using 5, 5, 5 window for dwidenoise") + if dwi_denoise_window != "auto": try: dwi_denoise_window = int(dwi_denoise_window) except ValueError: raise Exception("dwi denoise window must be an integer or 'auto'") + workflow.__postdesc__ = gen_denoising_boilerplate( - denoise_method, - dwi_denoise_window, - unringing_method, - b1_biascorrect_stage, - no_b0_harmonization, - b0_threshold, + denoise_method=denoise_method, + dwi_denoise_window=dwi_denoise_window, + unringing_method=unringing_method, + b1_biascorrect_stage=b1_biascorrect_stage, + no_b0_harmonization=no_b0_harmonization, + b0_threshold=b0_threshold, + use_phase="phase" not in ignore, ) # Doing biascorr here is the old way. @@ -173,8 +186,10 @@ def init_dwi_pre_hmc_wf( orientation=orientation, omp_nthreads=omp_nthreads, source_file=plus_source_file, - phase_id=pe_axis + "+ phase-encoding direction", + phase_id=f"{pe_axis}+ phase-encoding direction", calculate_qc=False, + ignore=ignore, + layout=layout, name="merge_plus", ) @@ -192,8 +207,10 @@ def init_dwi_pre_hmc_wf( orientation=orientation, omp_nthreads=omp_nthreads, source_file=minus_source_file, - phase_id=pe_axis + "- phase-encoding direction", + phase_id=f"{pe_axis}- phase-encoding direction", calculate_qc=False, + ignore=ignore, + layout=layout, name="merge_minus", ) @@ -292,6 +309,7 @@ def init_dwi_pre_hmc_wf( "single file, as required for the FSL workflows.\n\n" ) return workflow + workflow.__postdesc__ += "\n\n" merge_dwis = init_merge_and_denoise_wf( raw_dwi_files=dwi_series, @@ -306,6 +324,8 @@ def init_dwi_pre_hmc_wf( calculate_qc=True, phase_id=dwi_series_pedir, source_file=source_file, + ignore=ignore, + layout=layout, ) workflow.connect([