Skip to content

Commit 38fa3d5

Browse files
authored
Merge pull request #1968 from mgxd/fix/compcor
enh: multiple masks for compcor
2 parents 5a88840 + 668cf8a commit 38fa3d5

File tree

4 files changed

+224
-81
lines changed

4 files changed

+224
-81
lines changed

nipype/algorithms/confounds.py

Lines changed: 172 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from ..external.due import BibTeX
2727
from ..interfaces.base import (traits, TraitedSpec, BaseInterface,
2828
BaseInterfaceInputSpec, File, isdefined,
29-
InputMultiPath)
29+
InputMultiPath, OutputMultiPath)
3030
from ..utils import NUMPY_MMAP
3131
from ..utils.misc import normalize_mc_params
3232

@@ -301,23 +301,33 @@ def _list_outputs(self):
301301

302302
class CompCorInputSpec(BaseInterfaceInputSpec):
303303
realigned_file = File(exists=True, mandatory=True,
304-
desc='already realigned brain image (4D)')
305-
mask_file = File(exists=True, desc='mask file that determines ROI (3D)')
306-
components_file = File('components_file.txt', exists=False,
307-
usedefault=True,
308-
desc='filename to store physiological components')
304+
desc='already realigned brain image (4D)')
305+
mask_file = InputMultiPath(File(exists=True, deprecated='0.13',
306+
new_name='mask_files',
307+
desc='One or more mask files that determines ROI (3D)'))
308+
mask_files = InputMultiPath(File(exists=True,
309+
desc='One or more mask files that determines ROI (3D)'))
310+
merge_method = traits.Enum('union', 'intersect', 'none', xor=['mask_index'],
311+
requires=['mask_files'],
312+
desc='Merge method if multiple masks are present - `union` aggregates '
313+
'all masks, `intersect` computes the truth value of all masks, `none` '
314+
'performs CompCor on each mask individually')
315+
mask_index = traits.Range(0, xor=['merge_method'], requires=['mask_files'],
316+
desc='Position of mask in `mask_files` to use - first is the default')
317+
components_file = File('components_file.txt', exists=False, usedefault=True,
318+
desc='filename to store physiological components')
309319
num_components = traits.Int(6, usedefault=True) # 6 for BOLD, 4 for ASL
310320
use_regress_poly = traits.Bool(True, usedefault=True,
311-
desc='use polynomial regression'
312-
'pre-component extraction')
321+
desc='use polynomial regression pre-component extraction')
313322
regress_poly_degree = traits.Range(low=1, default=1, usedefault=True,
314-
desc='the degree polynomial to use')
315-
header = traits.Str(desc='the desired header for the output tsv file (one column).'
316-
'If undefined, will default to "CompCor"')
323+
desc='the degree polynomial to use')
324+
header = traits.Str(
325+
desc='the desired header for the output tsv file (one column).'
326+
'If undefined, will default to "CompCor"')
317327

318328
class CompCorOutputSpec(TraitedSpec):
319329
components_file = File(exists=True,
320-
desc='text file containing the noise components')
330+
desc='text file containing the noise components')
321331

322332
class CompCor(BaseInterface):
323333
'''
@@ -328,7 +338,7 @@ class CompCor(BaseInterface):
328338
329339
>>> ccinterface = CompCor()
330340
>>> ccinterface.inputs.realigned_file = 'functional.nii'
331-
>>> ccinterface.inputs.mask_file = 'mask.nii'
341+
>>> ccinterface.inputs.mask_files = 'mask.nii'
332342
>>> ccinterface.inputs.num_components = 1
333343
>>> ccinterface.inputs.use_regress_poly = True
334344
>>> ccinterface.inputs.regress_poly_degree = 2
@@ -349,15 +359,53 @@ class CompCor(BaseInterface):
349359
'tags': ['method', 'implementation']
350360
}]
351361

352-
def _run_interface(self, runtime):
353-
imgseries = nb.load(self.inputs.realigned_file, mmap=NUMPY_MMAP).get_data()
354-
mask = nb.load(self.inputs.mask_file, mmap=NUMPY_MMAP).get_data()
355-
356-
if imgseries.shape[:3] != mask.shape:
357-
raise ValueError('Inputs for CompCor, func {} and mask {}, do not have matching '
358-
'spatial dimensions ({} and {}, respectively)'
359-
.format(self.inputs.realigned_file, self.inputs.mask_file,
360-
imgseries.shape[:3], mask.shape))
362+
def _run_interface(self, runtime, tCompCor_mask=False):
363+
364+
imgseries = nb.load(self.inputs.realigned_file,
365+
mmap=NUMPY_MMAP).get_data()
366+
components = None
367+
368+
if isdefined(self.inputs.mask_files) or isdefined(self.inputs.mask_file):
369+
if (not isdefined(self.inputs.mask_index) and
370+
not isdefined(self.inputs.merge_method)):
371+
self.inputs.mask_index = 0
372+
373+
if isdefined(self.inputs.mask_index):
374+
if self.inputs.mask_index < len(self.inputs.mask_files):
375+
self.inputs.mask_files = [
376+
self.inputs.mask_files[self.inputs.mask_index]]
377+
else:
378+
self.inputs.mask_files = self.inputs.mask_files[0]
379+
if not tCompCor_mask:
380+
RuntimeWarning('Mask index exceeded number of masks, using '
381+
'mask {} instead'.format(self.inputs.mask_files[0]))
382+
383+
for mask_file in self.inputs.mask_files:
384+
mask = nb.load(mask_file, mmap=NUMPY_MMAP).get_data()
385+
386+
if imgseries.shape[:3] != mask.shape:
387+
raise ValueError('Inputs for CompCor, func {} and mask {}, '
388+
'do not have matching spatial dimensions '
389+
'({} and {}, respectively)'.format(
390+
self.inputs.realigned_file, mask_file,
391+
imgseries.shape[:3], mask.shape))
392+
393+
if (isdefined(self.inputs.merge_method) and
394+
self.inputs.merge_method != 'none' and
395+
len(self.inputs.mask_files) > 1):
396+
if mask_file == self.inputs.mask_files[0]:
397+
new_mask = mask
398+
continue
399+
else:
400+
if self.inputs.merge_method == 'union':
401+
new_mask = np.logical_or(new_mask, mask).astype(int)
402+
elif self.inputs.merge_method == 'intersect':
403+
new_mask = np.logical_and(new_mask, mask).astype(int)
404+
405+
if mask_file != self.inputs.mask_files[-1]:
406+
continue
407+
else: # merge complete
408+
mask = new_mask
361409

362410
voxel_timecourses = imgseries[mask > 0]
363411
# Zero-out any bad values
@@ -366,7 +414,8 @@ def _run_interface(self, runtime):
366414
# from paper:
367415
# "The constant and linear trends of the columns in the matrix M were
368416
# removed [prior to ...]"
369-
degree = self.inputs.regress_poly_degree if self.inputs.use_regress_poly else 0
417+
degree = (self.inputs.regress_poly_degree if
418+
self.inputs.use_regress_poly else 0)
370419
voxel_timecourses = regress_poly(degree, voxel_timecourses)
371420

372421
# "Voxel time series from the noise ROI (either anatomical or tSTD) were
@@ -380,9 +429,13 @@ def _run_interface(self, runtime):
380429
# "The covariance matrix C = MMT was constructed and decomposed into its
381430
# principal components using a singular value decomposition."
382431
u, _, _ = linalg.svd(M, full_matrices=False)
383-
components = u[:, :self.inputs.num_components]
384-
components_file = os.path.join(os.getcwd(), self.inputs.components_file)
432+
if components is None:
433+
components = u[:, :self.inputs.num_components]
434+
else:
435+
components = np.hstack((components,
436+
u[:, :self.inputs.num_components]))
385437

438+
components_file = os.path.join(os.getcwd(), self.inputs.components_file)
386439
self._set_header()
387440
np.savetxt(components_file, components, fmt=b"%.10f", delimiter='\t',
388441
header=self._make_headers(components.shape[1]), comments='')
@@ -401,7 +454,8 @@ def _compute_tSTD(self, M, x, axis=0):
401454
return stdM
402455

403456
def _set_header(self, header='CompCor'):
404-
self.inputs.header = self.inputs.header if isdefined(self.inputs.header) else header
457+
self.inputs.header = (self.inputs.header if isdefined(self.inputs.header)
458+
else header)
405459

406460
def _make_headers(self, num_col):
407461
headers = []
@@ -434,7 +488,8 @@ class TCompCorInputSpec(CompCorInputSpec):
434488

435489
class TCompCorOutputSpec(CompCorInputSpec):
436490
# and all the fields in CompCorInputSpec
437-
high_variance_mask = File(exists=True, desc="voxels excedding the variance threshold")
491+
high_variance_masks = OutputMultiPath(File(exists=True,
492+
desc="voxels excedding the variance threshold"))
438493

439494
class TCompCor(CompCor):
440495
'''
@@ -445,7 +500,7 @@ class TCompCor(CompCor):
445500
446501
>>> ccinterface = TCompCor()
447502
>>> ccinterface.inputs.realigned_file = 'functional.nii'
448-
>>> ccinterface.inputs.mask_file = 'mask.nii'
503+
>>> ccinterface.inputs.mask_files = 'mask.nii'
449504
>>> ccinterface.inputs.num_components = 1
450505
>>> ccinterface.inputs.use_regress_poly = True
451506
>>> ccinterface.inputs.regress_poly_degree = 2
@@ -456,52 +511,105 @@ class TCompCor(CompCor):
456511
output_spec = TCompCorOutputSpec
457512

458513
def _run_interface(self, runtime):
459-
imgseries = nb.load(self.inputs.realigned_file, mmap=NUMPY_MMAP).get_data()
514+
515+
_out_masks = []
516+
img = nb.load(self.inputs.realigned_file, mmap=NUMPY_MMAP)
517+
imgseries = img.get_data()
518+
aff = img.affine
519+
460520

461521
if imgseries.ndim != 4:
462-
raise ValueError('tCompCor expected a 4-D nifti file. Input {} has {} dimensions '
463-
'(shape {})'
464-
.format(self.inputs.realigned_file, imgseries.ndim, imgseries.shape))
465-
466-
if isdefined(self.inputs.mask_file):
467-
in_mask_data = nb.load(self.inputs.mask_file, mmap=NUMPY_MMAP).get_data()
468-
imgseries = imgseries[in_mask_data != 0, :]
469-
470-
# From the paper:
471-
# "For each voxel time series, the temporal standard deviation is
472-
# defined as the standard deviation of the time series after the removal
473-
# of low-frequency nuisance terms (e.g., linear and quadratic drift)."
474-
imgseries = regress_poly(2, imgseries)
475-
476-
# "To construct the tSTD noise ROI, we sorted the voxels by their
477-
# temporal standard deviation ..."
478-
tSTD = self._compute_tSTD(imgseries, 0, axis=-1)
479-
480-
# use percentile_threshold to pick voxels
481-
threshold_std = np.percentile(tSTD, 100. * (1. - self.inputs.percentile_threshold))
482-
mask = tSTD >= threshold_std
483-
484-
if isdefined(self.inputs.mask_file):
485-
mask_data = np.zeros_like(in_mask_data)
486-
mask_data[in_mask_data != 0] = mask
522+
raise ValueError('tCompCor expected a 4-D nifti file. Input {} has '
523+
'{} dimensions (shape {})'.format(
524+
self.inputs.realigned_file, imgseries.ndim,
525+
imgseries.shape))
526+
527+
if isdefined(self.inputs.mask_files):
528+
if (not isdefined(self.inputs.mask_index) and
529+
not isdefined(self.inputs.merge_method)):
530+
self.inputs.mask_index = 0
531+
if isdefined(self.inputs.mask_index):
532+
if self.inputs.mask_index < len(self.inputs.mask_files):
533+
self.inputs.mask_files = [
534+
self.inputs.mask_files[self.inputs.mask_index]]
535+
else:
536+
RuntimeWarning('Mask index exceeded number of masks, using '
537+
'mask {} instead'.format(self.inputs.mask_files[0]))
538+
self.inputs.mask_files = self.inputs.mask_files[0]
539+
540+
for i, mask_file in enumerate(self.inputs.mask_files, 1):
541+
in_mask = nb.load(mask_file, mmap=NUMPY_MMAP).get_data()
542+
if (isdefined(self.inputs.merge_method) and
543+
self.inputs.merge_method != 'none' and
544+
len(self.inputs.mask_files) > 1):
545+
if mask_file == self.inputs.mask_files[0]:
546+
new_mask = in_mask
547+
continue
548+
else:
549+
if self.inputs.merge_method == 'union':
550+
new_mask = np.logical_or(new_mask,
551+
in_mask).astype(int)
552+
elif self.inputs.merge_method == 'intersect':
553+
new_mask = np.logical_and(new_mask,
554+
in_mask).astype(int)
555+
if mask_file != self.inputs.mask_files[-1]:
556+
continue
557+
else: # merge complete
558+
in_mask = new_mask
559+
560+
imgseries = imgseries[in_mask != 0, :]
561+
562+
# From the paper:
563+
# "For each voxel time series, the temporal standard deviation is
564+
# defined as the standard deviation of the time series after the removal
565+
# of low-frequency nuisance terms (e.g., linear and quadratic drift)."
566+
imgseries = regress_poly(2, imgseries)
567+
568+
# "To construct the tSTD noise ROI, we sorted the voxels by their
569+
# temporal standard deviation ..."
570+
tSTD = self._compute_tSTD(imgseries, 0, axis=-1)
571+
572+
# use percentile_threshold to pick voxels
573+
threshold_std = np.percentile(tSTD, 100. *
574+
(1. - self.inputs.percentile_threshold))
575+
mask = tSTD >= threshold_std
576+
577+
mask_data = np.zeros_like(in_mask)
578+
mask_data[in_mask != 0] = mask
579+
# save mask
580+
if self.inputs.merge_method == 'none':
581+
mask_file = os.path.abspath('mask{}.nii'.format(i))
582+
else:
583+
mask_file = os.path.abspath('mask.nii')
584+
nb.Nifti1Image(mask_data, aff).to_filename(mask_file)
585+
IFLOG.debug('tCompcor computed and saved mask of shape {} to '
586+
'mask_file {}'.format(mask.shape, mask_file))
587+
_out_masks.append(mask_file)
588+
self._set_header('tCompCor')
589+
487590
else:
591+
imgseries = regress_poly(2, imgseries)
592+
tSTD = self._compute_tSTD(imgseries, 0, axis=-1)
593+
threshold_std = np.percentile(tSTD, 100. *
594+
(1. - self.inputs.percentile_threshold))
595+
mask = tSTD >= threshold_std
488596
mask_data = mask.astype(int)
489597

490-
# save mask
491-
mask_file = os.path.abspath('mask.nii')
492-
nb.Nifti1Image(mask_data,
493-
nb.load(self.inputs.realigned_file).affine).to_filename(mask_file)
494-
IFLOG.debug('tCompcor computed and saved mask of shape {} to mask_file {}'
495-
.format(mask.shape, mask_file))
496-
self.inputs.mask_file = mask_file
497-
self._set_header('tCompCor')
598+
# save mask
599+
mask_file = os.path.abspath('mask.nii')
600+
nb.Nifti1Image(mask_data, aff).to_filename(mask_file)
601+
IFLOG.debug('tCompcor computed and saved mask of shape {} to '
602+
'mask_file {}'.format(mask.shape, mask_file))
603+
_out_masks.append(mask_file)
604+
self._set_header('tCompCor')
498605

499-
super(TCompCor, self)._run_interface(runtime)
606+
self.inputs.mask_files = _out_masks
607+
super(TCompCor, self)._run_interface(runtime, tCompCor_mask=True)
500608
return runtime
501609

502610
def _list_outputs(self):
503611
outputs = super(TCompCor, self)._list_outputs()
504-
outputs['high_variance_mask'] = self.inputs.mask_file
612+
outputs['high_variance_masks'] = self.inputs.mask_files
505613
return outputs
506614

507615
class TSNRInputSpec(BaseInterfaceInputSpec):

0 commit comments

Comments
 (0)