Skip to content

Commit 748d17e

Browse files
committed
fix(compcor): refactor of CompCor masks
This commit revises the implementation of CompCor masks in an attempt to make it closer to the original proposal and at the same time address some recurring problems of *fMRIPrep*'s tCompCor implementation. Finally, with the more careful resampling of prior knowledge from the anatomical scan, this refactor should also make the aCompCor components more run-to-run repeatable. aCompCor -------- The massaging of CompCor masks is now done in anatomical space where it is more precise, and a careful resampling to BOLD space follows. The implementation deviates from Behzadi et al. Their original implementation thresholded the CSF and the WM partial-volume masks at 0.99 (i.e., 99% of the voxel volume is filled with a particular tissue), and then binary eroded that 2 voxels: > Anatomical data were segmented into gray matter, white matter, > and CSF partial volume maps using the FAST algorithm available > in the FSL software package (Smith et al., 2004). Tissue partial > volume maps were linearly interpolated to the resolution of the > functional data series using AFNI (Cox, 1996). In order to form > white matter ROIs, the white matter partial volume maps were > thresholded at a partial volume fraction of 0.99 and then eroded by > two voxels in each direction to further minimize partial voluming > with gray matter. CSF voxels were determined by first thresholding > the CSF partial volume maps at 0.99 and then applying a threedimensional > nearest neighbor criteria to minimize multiple tissue > partial voluming. Since CSF regions are typically small compared > to white matter regions mask, erosion was not applied. This particular procedure is not generalizable to BOLD data with different voxel zooms as the mathematical morphology operations will be scaled by those. Also, from reading the excerpt above and the tCompCor description, I (@oesteban) believe that they always operated slice-wise given the large slice-thickness of their functional data. Instead, *fMRIPrep*'s implementation deviates from Behzadi's implementation on two aspects: * the masks are prepared in high-resolution, anatomical space and then projected into BOLD space; and, * instead of using binary erosion, a dilated GM map is generated -- thresholding the corresponding PV map at 0.05 (i.e., pixels containing at least 5% of GM tissue) and then subtracting that map from the CSF, WM and CSF+WM (combined) masks. This should be equivalent to eroding the masks, except that the erosion only happens at direct interfaces with GM. When the probseg maps provene from FreeSurfer's ``recon-all`` (i.e., they are discrete), binary maps are *transformed* into some sort of partial volume maps by means of a Gaussian smoothing filter with sigma adjusted by the size of the BOLD data. tCompCor -------- In the case of *tCompCor*, this commit removes the heavy erosion of the brain mask because 1) that wasn't part of the original proposal by Behzadi et al., and 2) the erosion was the potential source of errors from numpy complaining that it can't take from an empty axis of an array. > Based on these results, we chose a 2% threshold > (∼20–30 voxels per slice) as a reasonable empirical > threshold that effectively identified voxels with > the highest fractional variance of physiological noise. Although they do the calculation slice-wise, this commit rolls tCompCor back to calculate the 2% threshold on the whole brain mask. Resolves: #2129. References: #2052.
1 parent 8480eab commit 748d17e

File tree

4 files changed

+241
-118
lines changed

4 files changed

+241
-118
lines changed

fmriprep/interfaces/confounds.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,41 @@
1818
from nipype.utils.filemanip import fname_presuffix
1919
from nipype.interfaces.base import (
2020
traits, TraitedSpec, BaseInterfaceInputSpec, File, Directory, isdefined,
21-
SimpleInterface
21+
SimpleInterface, InputMultiObject, OutputMultiObject
2222
)
2323

2424
LOGGER = logging.getLogger('nipype.interface')
2525

2626

27+
class _aCompCorMasksInputSpec(BaseInterfaceInputSpec):
28+
in_vfs = InputMultiObject(File(exists=True), desc="Input volume fractions.")
29+
is_aseg = traits.Bool(False, usedefault=True,
30+
desc="Whether the input volume fractions come from FS' aseg.")
31+
bold_zooms = traits.Tuple(traits.Float, traits.Float, traits.Float, mandatory=True,
32+
desc="BOLD series zooms")
33+
34+
35+
class _aCompCorMasksOutputSpec(TraitedSpec):
36+
out_masks = OutputMultiObject(File(exists=True),
37+
desc="CSF, WM and combined masks, respectively")
38+
39+
40+
class aCompCorMasks(SimpleInterface):
41+
"""Generate masks in T1w space for aCompCor."""
42+
43+
input_spec = _aCompCorMasksInputSpec
44+
output_spec = _aCompCorMasksOutputSpec
45+
46+
def _run_interface(self, runtime):
47+
from ..utils.confounds import acompcor_masks
48+
self._results["out_masks"] = acompcor_masks(
49+
self.inputs.in_vfs,
50+
self.inputs.is_aseg,
51+
self.inputs.bold_zooms,
52+
)
53+
return runtime
54+
55+
2756
class GatherConfoundsInputSpec(BaseInterfaceInputSpec):
2857
signals = File(exists=True, desc='input signals')
2958
dvars = File(exists=True, desc='file containing DVARS')

fmriprep/utils/confounds.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Utilities for confounds manipulation."""
2+
3+
4+
def mask2vf(in_file, zooms=None, out_file=None):
5+
"""
6+
Convert a binary mask on a volume fraction map.
7+
8+
The algorithm simply applies a Gaussian filter with the kernel size scaled
9+
by the zooms given as argument.
10+
11+
"""
12+
import numpy as np
13+
import nibabel as nb
14+
from scipy.ndimage import gaussian_filter
15+
16+
img = nb.load(in_file)
17+
imgzooms = np.array(img.header.get_zooms()[:3], dtype=float)
18+
if zooms is None:
19+
zooms = imgzooms
20+
21+
zooms = np.array(zooms, dtype=float)
22+
sigma = 0.5 * (zooms / imgzooms)
23+
if len(sigma) > 1:
24+
sigma = tuple(sigma)
25+
26+
data = gaussian_filter(img.get_fdata(dtype=np.float32), sigma=sigma)
27+
28+
max_data = np.percentile(data[data > 0], 99)
29+
data = np.clip(data / max_data, a_min=0, a_max=1)
30+
31+
if out_file is None:
32+
return data
33+
34+
hdr = img.header.copy()
35+
hdr.set_data_dtype(np.float32)
36+
nb.Nifti1Image(data.astype(np.float32), img.affine, hdr).to_filename(out_file)
37+
return out_file
38+
39+
40+
def acompcor_masks(in_files, is_aseg=False, zooms=None):
41+
"""
42+
Generate aCompCor masks.
43+
44+
This function selects the CSF partial volume map from the input,
45+
and generates the WM and combined CSF+WM masks for aCompCor.
46+
47+
The implementation deviates from Behzadi et al.
48+
Their original implementation thresholded the CSF and the WM partial-volume
49+
masks at 0.99 (i.e., 99% of the voxel volume is filled with a particular tissue),
50+
and then binary eroded that 2 voxels:
51+
52+
> Anatomical data were segmented into gray matter, white matter,
53+
> and CSF partial volume maps using the FAST algorithm available
54+
> in the FSL software package (Smith et al., 2004). Tissue partial
55+
> volume maps were linearly interpolated to the resolution of the
56+
> functional data series using AFNI (Cox, 1996). In order to form
57+
> white matter ROIs, the white matter partial volume maps were
58+
> thresholded at a partial volume fraction of 0.99 and then eroded by
59+
> two voxels in each direction to further minimize partial voluming
60+
> with gray matter. CSF voxels were determined by first thresholding
61+
> the CSF partial volume maps at 0.99 and then applying a threedimensional
62+
> nearest neighbor criteria to minimize multiple tissue
63+
> partial voluming. Since CSF regions are typically small compared
64+
> to white matter regions mask, erosion was not applied.
65+
66+
This particular procedure is not generalizable to BOLD data with different voxel zooms
67+
as the mathematical morphology operations will be scaled by those.
68+
Also, from reading the excerpt above and the tCompCor description, I (@oesteban)
69+
believe that they always operated slice-wise given the large slice-thickness of
70+
their functional data.
71+
72+
Instead, *fMRIPrep*'s implementation deviates from Behzadi's implementation on two
73+
aspects:
74+
75+
* the masks are prepared in high-resolution, anatomical space and then
76+
projected into BOLD space; and,
77+
* instead of using binary erosion, a dilated GM map is generated -- thresholding
78+
the corresponding PV map at 0.05 (i.e., pixels containing at least 5% of GM tissue)
79+
and then subtracting that map from the CSF, WM and CSF+WM (combined) masks.
80+
This should be equivalent to eroding the masks, except that the erosion
81+
only happens at direct interfaces with GM.
82+
83+
When the probseg maps provene from FreeSurfer's ``recon-all`` (i.e., they are
84+
discrete), binary maps are *transformed* into some sort of partial volume maps
85+
by means of a Gaussian smoothing filter with sigma adjusted by the size of the
86+
BOLD data.
87+
88+
"""
89+
from pathlib import Path
90+
import numpy as np
91+
import nibabel as nb
92+
from scipy.ndimage import binary_dilation
93+
from skimage.morphology import ball
94+
95+
csf_file = in_files[2] # BIDS labeling (CSF=2; last of list)
96+
# Load PV maps (fast) or segments (recon-all)
97+
gm_vf = nb.load(in_files[0])
98+
wm_vf = nb.load(in_files[1])
99+
csf_vf = nb.load(csf_file)
100+
101+
# Prepare target zooms
102+
imgzooms = np.array(gm_vf.header.get_zooms()[:3], dtype=float)
103+
if zooms is None:
104+
zooms = imgzooms
105+
zooms = np.array(zooms, dtype=float)
106+
107+
if not is_aseg:
108+
gm_data = gm_vf.get_fdata() > 0.05
109+
wm_data = wm_vf.get_fdata()
110+
csf_data = csf_vf.get_fdata()
111+
else:
112+
csf_file = mask2vf(
113+
csf_file,
114+
zooms=zooms,
115+
out_file=str(Path("acompcor_csf.nii.gz").absolute()),
116+
)
117+
csf_data = nb.load(csf_file).get_fdata()
118+
wm_data = mask2vf(in_files[1], zooms=zooms)
119+
120+
# We do not have partial volume maps (recon-all route)
121+
gm_data = np.asanyarray(gm_vf.dataobj, np.uint8) > 0
122+
123+
# Dilate the GM mask
124+
gm_data = binary_dilation(gm_data, structure=ball(3))
125+
126+
# Output filenames
127+
wm_file = str(Path("acompcor_wm.nii.gz").absolute())
128+
combined_file = str(Path("acompcor_wmcsf.nii.gz").absolute())
129+
130+
# Prepare WM mask
131+
wm_data[gm_data] = 0 # Make sure voxel does not contain GM
132+
nb.Nifti1Image(wm_data, gm_vf.affine, gm_vf.header).to_filename(wm_file)
133+
134+
# Prepare combined CSF+WM mask
135+
comb_data = csf_data + wm_data
136+
comb_data[gm_data] = 0 # Make sure voxel does not contain GM
137+
nb.Nifti1Image(comb_data, gm_vf.affine, gm_vf.header).to_filename(combined_file)
138+
return [csf_file, wm_file, combined_file]

fmriprep/workflows/bold/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ def init_func_preproc_wf(bold_file):
347347
bold_confounds_wf = init_bold_confs_wf(
348348
mem_gb=mem_gb['largemem'],
349349
metadata=metadata,
350+
freesurfer=freesurfer,
350351
regressors_all_comps=config.workflow.regressors_all_comps,
351352
regressors_fd_th=config.workflow.regressors_fd_th,
352353
regressors_dvars_th=config.workflow.regressors_dvars_th,

0 commit comments

Comments
 (0)