Skip to content

Commit 2b24f31

Browse files
committed
enh: multiple masks and options to compcor
1 parent c442383 commit 2b24f31

File tree

2 files changed

+154
-71
lines changed

2 files changed

+154
-71
lines changed

nipype/algorithms/confounds.py

Lines changed: 147 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,29 @@ 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 = InputMultiPath(File(exists=True, desc='mask file(s) 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_files = InputMultiPath(File(exists=True,
306+
desc='One or more mask files that determines ROI (3D)'))
307+
merge_method = traits.Enum('union', 'intersect', 'none', xor=['mask_index'],
308+
desc='Merge method if multiple masks are present - `union` aggregates '
309+
'all masks, `intersect` computes the truth value of all masks, `none` '
310+
'performs CompCor on each mask individually')
311+
mask_index = traits.Range(0, value=0, xor=['merge_method'], usedefault=True,
312+
desc='Position of mask in `mask_files` to use - first is the default')
313+
components_file = File('components_file.txt', exists=False, usedefault=True,
314+
desc='filename to store physiological components')
309315
num_components = traits.Int(6, usedefault=True) # 6 for BOLD, 4 for ASL
310316
use_regress_poly = traits.Bool(True, usedefault=True,
311-
desc='use polynomial regression'
312-
'pre-component extraction')
317+
desc='use polynomial regression pre-component extraction')
313318
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"')
319+
desc='the degree polynomial to use')
320+
header = traits.Str(
321+
desc='the desired header for the output tsv file (one column).'
322+
'If undefined, will default to "CompCor"')
317323

318324
class CompCorOutputSpec(TraitedSpec):
319325
components_file = File(exists=True,
320-
desc='text file containing the noise components')
326+
desc='text file containing the noise components')
321327

322328
class CompCor(BaseInterface):
323329
'''
@@ -328,7 +334,7 @@ class CompCor(BaseInterface):
328334
329335
>>> ccinterface = CompCor()
330336
>>> ccinterface.inputs.realigned_file = 'functional.nii'
331-
>>> ccinterface.inputs.mask_file = 'mask.nii'
337+
>>> ccinterface.inputs.mask_files = 'mask.nii'
332338
>>> ccinterface.inputs.num_components = 1
333339
>>> ccinterface.inputs.use_regress_poly = True
334340
>>> ccinterface.inputs.regress_poly_degree = 2
@@ -350,17 +356,46 @@ class CompCor(BaseInterface):
350356
}]
351357

352358
def _run_interface(self, runtime):
353-
imgseries = nb.load(self.inputs.realigned_file, mmap=NUMPY_MMAP).get_data()
359+
imgseries = nb.load(self.inputs.realigned_file,
360+
mmap=NUMPY_MMAP).get_data()
354361
components = None
355362

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

365400
voxel_timecourses = imgseries[mask > 0]
366401
# Zero-out any bad values
@@ -369,7 +404,8 @@ def _run_interface(self, runtime):
369404
# from paper:
370405
# "The constant and linear trends of the columns in the matrix M were
371406
# removed [prior to ...]"
372-
degree = self.inputs.regress_poly_degree if self.inputs.use_regress_poly else 0
407+
degree = (self.inputs.regress_poly_degree if
408+
self.inputs.use_regress_poly else 0)
373409
voxel_timecourses = regress_poly(degree, voxel_timecourses)
374410

375411
# "Voxel time series from the noise ROI (either anatomical or tSTD) were
@@ -386,18 +422,18 @@ def _run_interface(self, runtime):
386422
if components is None:
387423
components = u[:, :self.inputs.num_components]
388424
else:
389-
components = np.hstack((components, u[:, :self.inputs.num_components]))
425+
components = np.hstack((components,
426+
u[:, :self.inputs.num_components]))
390427

391428
components_file = os.path.join(os.getcwd(), self.inputs.components_file)
392-
393429
self._set_header()
394430
np.savetxt(components_file, components, fmt=b"%.10f", delimiter='\t',
395431
header=self._make_headers(components.shape[1]), comments='')
396432
return runtime
397433

398434
def _list_outputs(self):
399435
outputs = self._outputs().get()
400-
outputs['components_file'] = os.path.abspath(self.inputs.components_file)
436+
outputs['components_file'] = self.inputs.components_file
401437
return outputs
402438

403439
def _compute_tSTD(self, M, x, axis=0):
@@ -408,7 +444,8 @@ def _compute_tSTD(self, M, x, axis=0):
408444
return stdM
409445

410446
def _set_header(self, header='CompCor'):
411-
self.inputs.header = self.inputs.header if isdefined(self.inputs.header) else header
447+
self.inputs.header = (self.inputs.header if isdefined(self.inputs.header)
448+
else header)
412449

413450
def _make_headers(self, num_col):
414451
headers = []
@@ -441,7 +478,8 @@ class TCompCorInputSpec(CompCorInputSpec):
441478

442479
class TCompCorOutputSpec(CompCorInputSpec):
443480
# and all the fields in CompCorInputSpec
444-
high_variance_mask = InputMultiPath(File(exists=True, desc="voxels excedding the variance threshold"))
481+
high_variance_masks = OutputMultiPath(File(exists=True,
482+
desc="voxels excedding the variance threshold"))
445483

446484
class TCompCor(CompCor):
447485
'''
@@ -452,7 +490,7 @@ class TCompCor(CompCor):
452490
453491
>>> ccinterface = TCompCor()
454492
>>> ccinterface.inputs.realigned_file = 'functional.nii'
455-
>>> ccinterface.inputs.mask_file = 'mask.nii'
493+
>>> ccinterface.inputs.mask_files = 'mask.nii'
456494
>>> ccinterface.inputs.num_components = 1
457495
>>> ccinterface.inputs.use_regress_poly = True
458496
>>> ccinterface.inputs.regress_poly_degree = 2
@@ -461,54 +499,99 @@ class TCompCor(CompCor):
461499

462500
input_spec = TCompCorInputSpec
463501
output_spec = TCompCorOutputSpec
502+
_out_masks = []
464503

465504
def _run_interface(self, runtime):
466-
imgseries = nb.load(self.inputs.realigned_file, mmap=NUMPY_MMAP).get_data()
505+
imgseries = nb.load(self.inputs.realigned_file,
506+
mmap=NUMPY_MMAP).get_data()
467507

468508
if imgseries.ndim != 4:
469-
raise ValueError('tCompCor expected a 4-D nifti file. Input {} has {} dimensions '
470-
'(shape {})'
471-
.format(self.inputs.realigned_file, imgseries.ndim, imgseries.shape))
472-
473-
if isdefined(self.inputs.mask_file):
474-
in_mask_data = nb.load(self.inputs.mask_file[0], mmap=NUMPY_MMAP).get_data()
475-
imgseries = imgseries[in_mask_data != 0, :]
476-
477-
# From the paper:
478-
# "For each voxel time series, the temporal standard deviation is
479-
# defined as the standard deviation of the time series after the removal
480-
# of low-frequency nuisance terms (e.g., linear and quadratic drift)."
481-
imgseries = regress_poly(2, imgseries)
482-
483-
# "To construct the tSTD noise ROI, we sorted the voxels by their
484-
# temporal standard deviation ..."
485-
tSTD = self._compute_tSTD(imgseries, 0, axis=-1)
486-
487-
# use percentile_threshold to pick voxels
488-
threshold_std = np.percentile(tSTD, 100. * (1. - self.inputs.percentile_threshold))
489-
mask = tSTD >= threshold_std
490-
491-
if isdefined(self.inputs.mask_file):
492-
mask_data = np.zeros_like(in_mask_data)
493-
mask_data[in_mask_data != 0] = mask
509+
raise ValueError('tCompCor expected a 4-D nifti file. Input {} has '
510+
'{} dimensions (shape {})'.format(
511+
self.inputs.realigned_file, imgseries.ndim,
512+
imgseries.shape))
513+
514+
if isdefined(self.inputs.mask_files):
515+
if isdefined(self.inputs.mask_index) and self.inputs.mask_index:
516+
if self.inputs.mask_index < len(self.inputs.mask_files):
517+
self.inputs.mask_files = [
518+
self.inputs.mask_files[self.inputs.mask_index]]
519+
else:
520+
RuntimeWarning('Mask index exceeded number of masks, using '
521+
'mask {} instead').format(self.inputs.mask_files[0])
522+
self.inputs.mask_files = self.inputs.mask_files[0]
523+
524+
for i, mask_file in enumerate(self.inputs.mask_files, 1):
525+
in_mask = nb.load(mask_file, mmap=NUMPY_MMAP).get_data()
526+
if (isdefined(self.inputs.merge_method) and
527+
self.merge_method != 'none' and
528+
len(self.inputs.mask_files > 1)):
529+
if mask_file == self.inputs.mask_files[0]:
530+
new_mask = in_mask
531+
continue
532+
else:
533+
if self.inputs.merge_method == 'union':
534+
new_mask = np.logical_or(new_mask, in_mask).astype(int)
535+
elif self.inputs.merge_method == 'intersect':
536+
new_mask = np.logical_and(new_mask, in_mask).astype(int)
537+
538+
if mask_file != self.inputs.mask_files[-1]:
539+
continue
540+
else: # merge complete
541+
in_mask = new_mask
542+
543+
imgseries = imgseries[in_mask != 0, :]
544+
545+
# From the paper:
546+
# "For each voxel time series, the temporal standard deviation is
547+
# defined as the standard deviation of the time series after the removal
548+
# of low-frequency nuisance terms (e.g., linear and quadratic drift)."
549+
imgseries = regress_poly(2, imgseries)
550+
551+
# "To construct the tSTD noise ROI, we sorted the voxels by their
552+
# temporal standard deviation ..."
553+
tSTD = self._compute_tSTD(imgseries, 0, axis=-1)
554+
555+
# use percentile_threshold to pick voxels
556+
threshold_std = np.percentile(tSTD, 100. *
557+
(1. - self.inputs.percentile_threshold))
558+
mask = tSTD >= threshold_std
559+
560+
mask_data = np.zeros_like(in_mask)
561+
mask_data[in_mask != 0] = mask
562+
# save mask
563+
mask_file = os.path.abspath('mask{}.nii'.format(i))
564+
nb.Nifti1Image(mask_data, nb.load(
565+
self.inputs.realigned_file).affine).to_filename(mask_file)
566+
IFLOG.debug('tCompcor computed and saved mask of shape {} to '
567+
'mask_file {}'.format(mask.shape, mask_file))
568+
self._out_masks.append(mask_file)
569+
self._set_header('tCompCor')
570+
494571
else:
572+
imgseries = regress_poly(2, imgseries)
573+
tSTD = self._compute_tSTD(imgseries, 0, axis=-1)
574+
threshold_std = np.percentile(tSTD, 100. *
575+
(1. - self.inputs.percentile_threshold))
576+
mask = tSTD >= threshold_std
495577
mask_data = mask.astype(int)
496578

497-
# save mask
498-
mask_file = os.path.abspath('mask.nii')
499-
nb.Nifti1Image(mask_data,
500-
nb.load(self.inputs.realigned_file).affine).to_filename(mask_file)
501-
IFLOG.debug('tCompcor computed and saved mask of shape {} to mask_file {}'
502-
.format(mask.shape, mask_file))
503-
self.inputs.mask_file = mask_file
504-
self._set_header('tCompCor')
579+
# save mask
580+
mask_file = os.path.abspath('mask.nii')
581+
nb.Nifti1Image(mask_data,
582+
nb.load(self.inputs.realigned_file).affine).to_filename(mask_file)
583+
IFLOG.debug('tCompcor computed and saved mask of shape {} to mask_file '
584+
'{}'.format(mask.shape, mask_file))
585+
self._out_masks.append(mask_file)
586+
self._set_header('tCompCor')
505587

588+
self.inputs.mask_files = self._out_masks
506589
super(TCompCor, self)._run_interface(runtime)
507590
return runtime
508591

509592
def _list_outputs(self):
510593
outputs = super(TCompCor, self)._list_outputs()
511-
outputs['high_variance_mask'] = self.inputs.mask_file
594+
outputs['high_variance_masks'] = self.inputs.mask_files
512595
return outputs
513596

514597
class TSNRInputSpec(BaseInterfaceInputSpec):

nipype/algorithms/tests/test_compcor.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def setup_class(self, tmpdir):
2828
mask = np.ones(self.fake_data.shape[:3])
2929
mask[0, 0, 0] = 0
3030
mask[0, 0, 1] = 0
31-
self.mask_file = utils.save_toy_nii(mask, self.filenames['masknii'])
31+
self.mask_files = utils.save_toy_nii(mask, self.filenames['masknii'])
3232

3333
def test_compcor(self):
3434
expected_components = [['-0.1989607212', '-0.5753813646'],
@@ -37,10 +37,10 @@ def test_compcor(self):
3737
['0.4206466244', '-0.3361270124'],
3838
['-0.1246655485', '-0.1235705610']]
3939

40-
self.run_cc(CompCor(realigned_file=self.realigned_file, mask_file=self.mask_file),
40+
self.run_cc(CompCor(realigned_file=self.realigned_file, mask_files=self.mask_files),
4141
expected_components)
4242

43-
self.run_cc(ACompCor(realigned_file=self.realigned_file, mask_file=self.mask_file,
43+
self.run_cc(ACompCor(realigned_file=self.realigned_file, mask_files=self.mask_files,
4444
components_file='acc_components_file'),
4545
expected_components, 'aCompCor')
4646

@@ -62,7 +62,7 @@ def test_tcompcor_no_percentile(self):
6262
assert num_nonmasked_voxels == 1
6363

6464
def test_compcor_no_regress_poly(self):
65-
self.run_cc(CompCor(realigned_file=self.realigned_file, mask_file=self.mask_file,
65+
self.run_cc(CompCor(realigned_file=self.realigned_file, mask_files=self.mask_files,
6666
use_regress_poly=False), [['0.4451946442', '-0.7683311482'],
6767
['-0.4285129505', '-0.0926034137'],
6868
['0.5721540256', '0.5608764842'],
@@ -77,12 +77,12 @@ def test_tcompcor_asymmetric_dim(self):
7777
assert nb.load('mask.nii').get_data().shape == asymmetric_shape[:3]
7878

7979
def test_compcor_bad_input_shapes(self):
80-
shape_less_than = (1, 2, 2, 5) # dim 0 is < dim 0 of self.mask_file (2)
81-
shape_more_than = (3, 3, 3, 5) # dim 0 is > dim 0 of self.mask_file (2)
80+
shape_less_than = (1, 2, 2, 5) # dim 0 is < dim 0 of self.mask_files (2)
81+
shape_more_than = (3, 3, 3, 5) # dim 0 is > dim 0 of self.mask_files (2)
8282

8383
for data_shape in (shape_less_than, shape_more_than):
8484
data_file = utils.save_toy_nii(np.zeros(data_shape), 'temp.nii')
85-
interface = CompCor(realigned_file=data_file, mask_file=self.mask_file)
85+
interface = CompCor(realigned_file=data_file, mask_files=self.mask_files)
8686
with pytest.raises(ValueError, message="Dimension mismatch"): interface.run()
8787

8888
def test_tcompcor_bad_input_dim(self):

0 commit comments

Comments
 (0)