Source code for sdcflows.workflows.fit.syn

# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
# vi: set ft=python sts=4 ts=4 sw=4 et:
#
# Copyright 2021 The NiPreps Developers <nipreps@gmail.com>
#
# 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/
#
"""
Estimating the susceptibility distortions without fieldmaps.
"""
from nipype.pipeline import engine as pe
from nipype.interfaces import utility as niu
from niworkflows.engine.workflows import LiterateWorkflow as Workflow

from ... import data

DEFAULT_MEMORY_MIN_GB = 0.01
INPUT_FIELDS = (
    "epi_ref",
    "epi_mask",
    "anat_ref",
    "anat_mask",
    "sd_prior",
)


[docs] def init_syn_sdc_wf( *, atlas_threshold=3, sloppy=False, debug=False, name="syn_sdc_wf", omp_nthreads=1, ): """ Build the *fieldmap-less* susceptibility-distortion estimation workflow. SyN deformation is restricted to the phase-encoding (PE) direction. If no PE direction is specified, anterior-posterior PE is assumed. SyN deformation is also restricted to regions that are expected to have a >3mm (approximately 1 voxel) warp, based on the fieldmap atlas. Workflow Graph .. workflow :: :graph2use: orig :simple_form: yes from sdcflows.workflows.fit.syn import init_syn_sdc_wf wf = init_syn_sdc_wf(omp_nthreads=8) Parameters ---------- atlas_threshold : :obj:`float` Exclude from the registration metric computation areas with average distortions below this threshold (in mm). sloppy : :obj:`bool` Whether a fast (less accurate) configuration of the workflow should be applied. debug : :obj:`bool` Run in debug mode name : :obj:`str` Name for this workflow omp_nthreads : :obj:`int` Parallelize internal tasks across the number of CPUs given by this option. Inputs ------ epi_ref : :obj:`tuple` (:obj:`str`, :obj:`dict`) A tuple, where the first element is the path of the distorted EPI reference map (e.g., an average of *b=0* volumes), and the second element is a dictionary of associated metadata. epi_mask : :obj:`str` A path to a brain mask corresponding to ``epi_ref``. anat_ref : :obj:`str` A preprocessed, skull-stripped anatomical (T1w or T2w) image resampled in EPI space. anat_mask : :obj:`str` Path to the brain mask corresponding to ``anat_ref`` in EPI space. sd_prior : :obj:`str` A template map of areas with strong susceptibility distortions (SD) to regularize the cost function of SyN Outputs ------- fmap : :obj:`str` The path of the estimated fieldmap. fmap_ref : :obj:`str` The path of an unwarped conversion of files in ``epi_ref``. fmap_coeff : :obj:`str` or :obj:`list` of :obj:`str` The path(s) of the B-Spline coefficients supporting the fieldmap. out_warp : :obj:`str` The path of the corresponding displacements field transform to unwarp susceptibility distortions. method: :obj:`str` Short description of the estimation method that was run. """ from packaging.version import parse as parseversion, Version from nipype.interfaces.ants import ImageMath from niworkflows.interfaces.fixes import ( FixHeaderApplyTransforms as ApplyTransforms, FixHeaderRegistration as Registration, ) from niworkflows.interfaces.nibabel import ( Binarize, IntensityClip, RegridToZooms, ) from ...utils.misc import front as _pop, last as _pull from ...interfaces.epi import GetReadoutTime from ...interfaces.fmap import DisplacementsField2Fieldmap from ...interfaces.bspline import ( ApplyCoeffsField, BSplineApprox, DEFAULT_HF_ZOOMS_MM, ) from ...interfaces.brainmask import BinaryDilation, Union ants_version = Registration().version if ants_version and parseversion(ants_version) < Version("2.2.0"): raise RuntimeError( f"Please upgrade ANTs to 2.2 or older ({ants_version} found)." ) workflow = Workflow(name=name) workflow.__desc__ = f"""\ A deformation field to correct for susceptibility distortions was estimated based on *fMRIPrep*'s *fieldmap-less* approach. The deformation field is that resulting from co-registering the EPI reference to the same-subject T1w-reference with its intensity inverted [@fieldmapless1; @fieldmapless2]. Registration is performed with `antsRegistration` (ANTs {ants_version or "-- version unknown"}), and the process regularized by constraining deformation to be nonzero only along the phase-encoding direction, and modulated with an average fieldmap template [@fieldmapless3]. """ inputnode = pe.Node(niu.IdentityInterface(INPUT_FIELDS), name="inputnode") outputnode = pe.Node( niu.IdentityInterface( ["fmap", "fmap_ref", "fmap_coeff", "fmap_mask", "out_warp", "method"] ), name="outputnode", ) outputnode.inputs.method = 'FLB ("fieldmap-less", SyN-based)' readout_time = pe.Node( GetReadoutTime(), name="readout_time", run_without_submitting=True, ) warp_dir = pe.Node( niu.Function(function=_warp_dir), run_without_submitting=True, name="warp_dir", ) warp_dir.inputs.nlevels = 2 atlas_msk = pe.Node(Binarize(thresh_low=atlas_threshold), name="atlas_msk") anat_dilmsk = pe.Node(BinaryDilation(), name="anat_dilmsk") amask2epi = pe.Node( ApplyTransforms(interpolation="MultiLabel", transforms="identity"), name="amask2epi", ) # Calculate laplacian maps lap_anat = pe.Node( ImageMath(operation="Laplacian", op2="1.5 1", copy_header=True), name="lap_anat" ) lap_anat_norm = pe.Node(niu.Function(function=_norm_lap), name="lap_anat_norm") anat_merge = pe.Node( niu.Merge(2), name="anat_merge", run_without_submitting=True, ) clip_epi = pe.Node(IntensityClip(p_min=35.0, p_max=99.9), name="clip_epi") lap_epi = pe.Node( ImageMath(operation="Laplacian", op2="1.5 1", copy_header=True), name="lap_epi" ) lap_epi_norm = pe.Node(niu.Function(function=_norm_lap), name="lap_epi_norm") epi_merge = pe.Node( niu.Merge(2), name="epi_merge", run_without_submitting=True, ) epi_umask = pe.Node(Union(), name="epi_umask") moving_masks = pe.Node( niu.Merge(3), name="moving_masks", run_without_submitting=True, ) fixed_masks = pe.Node( niu.Merge(3), name="fixed_masks", mem_gb=DEFAULT_MEMORY_MIN_GB, run_without_submitting=True, ) # Set a manageable size for the epi reference find_zooms = pe.Node(niu.Function(function=_adjust_zooms), name="find_zooms") zooms_epi = pe.Node(RegridToZooms(), name="zooms_epi") # SyN Registration Core syn = pe.Node( Registration( from_file=data.load(f"sd_syn{'_sloppy' * sloppy}.json") ), name="syn", n_procs=omp_nthreads, ) syn.inputs.output_warped_image = debug syn.inputs.output_inverse_warped_image = debug if debug: syn.inputs.args = "--write-interval-volumes 2" # Extract the corresponding fieldmap in Hz extract_field = pe.Node( DisplacementsField2Fieldmap(), name="extract_field" ) unwarp = pe.Node(ApplyCoeffsField(jacobian=False), name="unwarp") # Check zooms (avoid very expensive B-Splines fitting) zooms_field = pe.Node( ApplyTransforms( interpolation="BSpline", transforms="identity", args="-u float" ), name="zooms_field", ) zooms_bmask = pe.Node( ApplyTransforms( interpolation="MultiLabel", transforms="identity", args="-u uchar" ), name="zooms_bmask", ) # Regularize with B-Splines bs_filter = pe.Node( BSplineApprox(recenter=False, debug=debug, extrapolate=not debug), name="bs_filter", ) bs_filter.interface._always_run = debug bs_filter.inputs.bs_spacing = [DEFAULT_HF_ZOOMS_MM] if sloppy: bs_filter.inputs.zooms_min = 4.0 workflow.connect([ (inputnode, readout_time, [(("epi_ref", _pop), "in_file"), (("epi_ref", _pull), "metadata")]), (inputnode, atlas_msk, [("sd_prior", "in_file")]), (inputnode, clip_epi, [(("epi_ref", _pop), "in_file")]), (inputnode, unwarp, [(("epi_ref", _pop), "in_data")]), (inputnode, amask2epi, [("epi_mask", "reference_image")]), (inputnode, zooms_bmask, [("anat_mask", "input_image")]), (inputnode, fixed_masks, [("anat_mask", "in1"), ("anat_mask", "in2")]), (inputnode, anat_dilmsk, [("anat_mask", "in_file")]), (inputnode, warp_dir, [("anat_ref", "fixed_image")]), (inputnode, anat_merge, [("anat_ref", "in1")]), (inputnode, lap_anat, [("anat_ref", "op1")]), (inputnode, find_zooms, [("anat_ref", "in_anat"), (("epi_ref", _pop), "in_epi")]), (inputnode, zooms_field, [(("epi_ref", _pop), "reference_image")]), (inputnode, epi_umask, [("epi_mask", "in1")]), (lap_anat, lap_anat_norm, [("output_image", "in_file")]), (lap_anat_norm, anat_merge, [("out", "in2")]), (epi_umask, moving_masks, [("out_file", "in1"), ("out_file", "in2"), ("out_file", "in3")]), (clip_epi, epi_merge, [("out_file", "in1")]), (clip_epi, lap_epi, [("out_file", "op1")]), (clip_epi, zooms_epi, [("out_file", "in_file")]), (lap_epi, lap_epi_norm, [("output_image", "in_file")]), (lap_epi_norm, epi_merge, [("out", "in2")]), (find_zooms, zooms_epi, [("out", "zooms")]), (atlas_msk, fixed_masks, [("out_mask", "in3")]), (anat_dilmsk, amask2epi, [("out_file", "input_image")]), (amask2epi, epi_umask, [("output_image", "in2")]), (readout_time, warp_dir, [("pe_direction", "pe_dir")]), (warp_dir, syn, [("out", "restrict_deformation")]), (anat_merge, syn, [("out", "fixed_image")]), (fixed_masks, syn, [("out", "fixed_image_masks")]), (epi_merge, syn, [("out", "moving_image")]), (moving_masks, syn, [("out", "moving_image_masks")]), (syn, extract_field, [(("forward_transforms", _pop), "transform")]), (clip_epi, extract_field, [("out_file", "epi")]), (readout_time, extract_field, [("readout_time", "ro_time"), ("pe_direction", "pe_dir")]), (extract_field, zooms_field, [("out_file", "input_image")]), (zooms_field, zooms_bmask, [("output_image", "reference_image")]), (zooms_field, bs_filter, [("output_image", "in_data")]), # Setting a mask ends up over-fitting the field # - it's better to have all those ~zero around. # (zooms_bmask, bs_filter, [("output_image", "in_mask")]), (bs_filter, unwarp, [("out_coeff", "in_coeff")]), (readout_time, unwarp, [("readout_time", "ro_time"), ("pe_direction", "pe_dir")]), (zooms_bmask, outputnode, [("output_image", "fmap_mask")]), (bs_filter, outputnode, [("out_coeff", "fmap_coeff")]), (unwarp, outputnode, [("out_corrected", "fmap_ref"), ("out_field", "fmap")]), ]) # fmt:skip return workflow
[docs] def init_syn_preprocessing_wf( *, debug=False, name="syn_preprocessing_wf", omp_nthreads=1, auto_bold_nss=False, t1w_inversion=False, ): """ Prepare EPI references and co-registration to anatomical for SyN. Workflow Graph .. workflow :: :graph2use: orig :simple_form: yes from sdcflows.workflows.fit.syn import init_syn_preprocessing_wf wf = init_syn_preprocessing_wf() Parameters ---------- debug : :obj:`bool` Whether a fast (less accurate) configuration of the workflow should be applied. name : :obj:`str` Name for this workflow omp_nthreads : :obj:`int` Parallelize internal tasks across the number of CPUs given by this option. auto_bold_nss : :obj:`bool` Set up the reference workflow to automatically execute nonsteady states detection of BOLD images. t1w_inversion : :obj:`bool` Run T1w intensity inversion so that it looks more like a T2 contrast. Inputs ------ in_epis : :obj:`list` of :obj:`str` Distorted EPI images that will be merged together to create the EPI reference file. t_masks : :obj:`list` of :obj:`bool` (optional) mask of timepoints for calculating an EPI reference. Not used if ``auto_bold_nss=True``. in_meta : :obj:`list` of :obj:`dict` Metadata dictionaries corresponding to the ``in_epis`` input. in_anat : :obj:`str` A preprocessed anatomical (T1w or T2w) image. mask_anat : :obj:`str` A brainmask corresponding to the anatomical (T1w or T2w) image. std2anat_xfm : :obj:`str` inverse registration transform of T1w image to MNI template. Outputs ------- epi_ref : :obj:`tuple` (:obj:`str`, :obj:`dict`) A tuple, where the first element is the path of the distorted EPI reference map (e.g., an average of *b=0* volumes), and the second element is a dictionary of associated metadata. anat_ref : :obj:`str` Path to the anatomical, skull-stripped reference in EPI space. anat_mask : :obj:`str` Path to the brain mask corresponding to ``anat_ref`` in EPI space. sd_prior : :obj:`str` A template map of areas with strong susceptibility distortions (SD) to regularize the cost function of SyN. """ from niworkflows.interfaces.nibabel import ( IntensityClip, ApplyMask, GenerateSamplingReference, ) from niworkflows.interfaces.fixes import ( FixHeaderApplyTransforms as ApplyTransforms, FixHeaderRegistration as Registration, ) from niworkflows.workflows.epi.refmap import init_epi_reference_wf from ...interfaces.utils import Deoblique, DenoiseImage from ...interfaces.brainmask import BrainExtraction, BinaryDilation workflow = Workflow(name=name) inputnode = pe.Node( niu.IdentityInterface( fields=[ "in_epis", "t_masks", "in_meta", "in_anat", "mask_anat", "std2anat_xfm", ] ), name="inputnode", ) outputnode = pe.Node( niu.IdentityInterface( fields=["epi_ref", "epi_mask", "anat_ref", "anat_mask", "sd_prior"] ), name="outputnode", ) deob_epi = pe.Node(Deoblique(), name="deob_epi") # Mapping & preparing prior knowledge # Concatenate transform files: # 1) MNI -> anat; 2) ATLAS -> MNI transform_list = pe.Node( niu.Merge(3), name="transform_list", mem_gb=DEFAULT_MEMORY_MIN_GB, run_without_submitting=True, ) transform_list.inputs.in3 = data.load("fmap_atlas_2_MNI152NLin2009cAsym_affine.mat") prior2epi = pe.Node( ApplyTransforms( invert_transform_flags=[True, False, False], input_image=str(data.load("fmap_atlas.nii.gz")), ), name="prior2epi", n_procs=omp_nthreads, mem_gb=0.3, ) anat2epi = pe.Node( ApplyTransforms(invert_transform_flags=[True]), name="anat2epi", n_procs=omp_nthreads, mem_gb=0.3, ) mask2epi = pe.Node( ApplyTransforms(invert_transform_flags=[True], interpolation="MultiLabel"), name="mask2epi", n_procs=omp_nthreads, mem_gb=0.3, ) mask_dtype = pe.Node( niu.Function(function=_set_dtype, input_names=["in_file", "dtype"]), name="mask_dtype", ) mask_dtype.inputs.dtype = "uint8" epi_reference_wf = init_epi_reference_wf( omp_nthreads=omp_nthreads, auto_bold_nss=auto_bold_nss, ) epi_brain = pe.Node(BrainExtraction(), name="epi_brain") merge_output = pe.Node( niu.Function(function=_merge_meta), name="merge_output", run_without_submitting=True, ) mask_anat = pe.Node(ApplyMask(), name="mask_anat") clip_anat = pe.Node(IntensityClip(p_min=0.0, p_max=99.8), name="clip_anat") ref_anat = pe.Node( DenoiseImage(copy_header=True), name="ref_anat", n_procs=omp_nthreads ) epi2anat = pe.Node( Registration(from_file=data.load("affine.json")), name="epi2anat", n_procs=omp_nthreads, ) epi2anat.inputs.output_warped_image = debug epi2anat.inputs.output_inverse_warped_image = debug if debug: epi2anat.inputs.args = "--write-interval-volumes 5" def _remove_first_mask(in_file): if not isinstance(in_file, list): in_file = [in_file] in_file.insert(0, "NULL") return in_file anat_dilmsk = pe.Node(BinaryDilation(), name="anat_dilmsk") epi_dilmsk = pe.Node(BinaryDilation(), name="epi_dilmsk") sampling_ref = pe.Node(GenerateSamplingReference(), name="sampling_ref") # fmt:off workflow.connect([ (inputnode, transform_list, [("std2anat_xfm", "in2")]), (inputnode, epi_reference_wf, [("in_epis", "inputnode.in_files")]), (inputnode, merge_output, [("in_meta", "meta_list")]), (inputnode, anat_dilmsk, [("mask_anat", "in_file")]), (inputnode, mask_anat, [("in_anat", "in_file"), ("mask_anat", "in_mask")]), (inputnode, mask2epi, [("mask_anat", "input_image")]), (epi_reference_wf, deob_epi, [("outputnode.epi_ref_file", "in_file")]), (deob_epi, merge_output, [("out_file", "epi_ref")]), (mask_anat, clip_anat, [("out_file", "in_file")]), (clip_anat, ref_anat, [("out_file", "input_image")]), (deob_epi, epi_brain, [("out_file", "in_file")]), (epi_brain, epi_dilmsk, [("out_mask", "in_file")]), (ref_anat, epi2anat, [("output_image", "fixed_image")]), (anat_dilmsk, epi2anat, [("out_file", "fixed_image_masks")]), (deob_epi, epi2anat, [("out_file", "moving_image")]), (epi_dilmsk, epi2anat, [ (("out_file", _remove_first_mask), "moving_image_masks")]), (deob_epi, sampling_ref, [("out_file", "fixed_image")]), (epi2anat, transform_list, [("forward_transforms", "in1")]), (transform_list, prior2epi, [("out", "transforms")]), (sampling_ref, prior2epi, [("out_file", "reference_image")]), (ref_anat, anat2epi, [("output_image", "input_image")]), (epi2anat, anat2epi, [("forward_transforms", "transforms")]), (sampling_ref, anat2epi, [("out_file", "reference_image")]), (epi2anat, mask2epi, [("forward_transforms", "transforms")]), (sampling_ref, mask2epi, [("out_file", "reference_image")]), (mask2epi, mask_dtype, [("output_image", "in_file")]), (anat2epi, outputnode, [("output_image", "anat_ref")]), (mask_dtype, outputnode, [("out", "anat_mask")]), (merge_output, outputnode, [("out", "epi_ref")]), (epi_brain, outputnode, [("out_mask", "epi_mask")]), (prior2epi, outputnode, [("output_image", "sd_prior")]), ]) # fmt:on if debug: from niworkflows.interfaces.nibabel import RegridToZooms regrid_anat = pe.Node( RegridToZooms(zooms=(2.0, 2.0, 2.0), smooth=True), name="regrid_anat" ) # fmt:off workflow.connect([ (inputnode, regrid_anat, [("in_anat", "in_file")]), (regrid_anat, sampling_ref, [("out_file", "moving_image")]), ]) # fmt:on else: # fmt:off workflow.connect([ (inputnode, sampling_ref, [("in_anat", "moving_image")]), ]) # fmt:on if not auto_bold_nss: workflow.connect(inputnode, "t_masks", epi_reference_wf, "inputnode.t_masks") return workflow
def _warp_dir(fixed_image, pe_dir, nlevels=3): """Extract the ``restrict_deformation`` argument from metadata.""" import numpy as np import nibabel as nb img = nb.load(fixed_image) if np.any(nb.affines.obliquity(img.affine) > 0.05): from nipype import logging logging.getLogger("nipype.interface").warn( "Running fieldmap-less registration on an oblique dataset" ) vs = nb.affines.voxel_sizes(img.affine) order = np.around(np.abs(img.affine[:3, :3] / vs)) retval = order @ [1 if pe_dir[0] == ax else 0.1 for ax in "ijk"] return nlevels * [retval.tolist()] def _merge_meta(epi_ref, meta_list): """Prepare a tuple of EPI reference and metadata.""" return (epi_ref, meta_list[0]) def _set_dtype(in_file, dtype="int16"): """Change the dtype of an image.""" import numpy as np import nibabel as nb img = nb.load(in_file) if img.header.get_data_dtype() == np.dtype(dtype): return in_file from nipype.utils.filemanip import fname_presuffix out_file = fname_presuffix(in_file, suffix=f"_{dtype}") hdr = img.header.copy() hdr.set_data_dtype(dtype) img.__class__(img.dataobj, img.affine, hdr).to_filename(out_file) return out_file def _adjust_zooms(in_anat, in_epi, z_max=2.2, z_min=1.8): import nibabel as nb anat_res = min(nb.load(in_anat).header.get_zooms()[:3]) epi_res = min(nb.load(in_epi).header.get_zooms()[:3]) zoom_iso = min( round(max(0.5 * (anat_res + epi_res), z_min), 2), z_max, ) return tuple([zoom_iso] * 3)
[docs] def match_histogram(reference, image, ref_mask=None, img_mask=None): """Match the histogram of the T2-like anatomical with the EPI.""" import os import numpy as np import nibabel as nb from nipype.utils.filemanip import fname_presuffix from skimage.exposure import match_histograms nii_img = nb.load(image) img_data = np.asanyarray(nii_img.dataobj) ref_data = np.asanyarray(nb.load(reference).dataobj) ref_mask = ( np.ones_like(ref_data, dtype=bool) if ref_mask is None else np.asanyarray(nb.load(ref_mask).dataobj) > 0 ) img_mask = ( np.ones_like(img_data, dtype=bool) if img_mask is None else np.asanyarray(nb.load(img_mask).dataobj) > 0 ) out_file = fname_presuffix(image, suffix="_matched", newpath=os.getcwd()) img_data[img_mask] = match_histograms( img_data[img_mask], ref_data[ref_mask], ) nii_img.__class__( img_data, nii_img.affine, nii_img.header, ).to_filename(out_file) return out_file
def _norm_lap(in_file): """Brought over from nirodents.""" from pathlib import Path import numpy as np import nibabel as nb from nipype.utils.filemanip import fname_presuffix img = nb.load(in_file) data = img.get_fdata() data -= np.median(data) l_max = np.percentile(data[data > 0], 99.8) l_min = np.percentile(data[data < 0], 0.2) data[data < 0] *= -1.0 / l_min data[data > 0] *= 1.0 / l_max data = np.clip(data, a_min=-1.0, a_max=1.0) out_file = fname_presuffix( Path(in_file).name, suffix="_norm", newpath=str(Path.cwd().absolute()) ) hdr = img.header.copy() hdr.set_data_dtype("float32") img.__class__(data.astype("float32"), img.affine, hdr).to_filename(out_file) return out_file