Repository: nilearn/nilearn

Issue:
Pass binary mask to `sample_masks` instead of indices
> Just wondering: wouldn't it be more handy to give  a binary vector with length `n_scans` for  `sample_mask` ?

_Originally posted by @bthirion in https://github.com/nilearn/nilearn/pull/3193#discussion_r838897961_

I did some archaeology and found out why I implemented index, rather than binary vector. I think it was for staying consistent to the OG `sample_mask`:

>I had a look at the relevant code of `sample_mask` application for resolving #2777. Currently, the timeseries censoring is done in the `base_masker` module:
https://github.com/nilearn/nilearn/blob/4cdd8fe8aad085d21e5d8367ad5b94d8efbe5151/nilearn/input_data/base_masker.py#L71-L73

_Originally posted by @htwangtw in https://github.com/nilearn/nilearn/issues/1012#issuecomment-847064092_

And the old doc of `sample_mask`:
https://github.com/nilearn/nilearn/blob/4cdd8fe8aad085d21e5d8367ad5b94d8efbe5151/nilearn/input_data/nifti_masker.py#L172-L177

Here's all the back ground info I can find. We can change it to binary vector as we no longer use the `index_img` function, and is using `signal.clean` to handle things. Let me know how you think we should move forward, given these info. 

cc @pbellec as I know you raised the same question before.

Comments:

Patch:
diff --git a/nilearn/_utils/glm.py b/nilearn/_utils/glm.py
index 591bd6f54..79da26cd3 100644
--- a/nilearn/_utils/glm.py
+++ b/nilearn/_utils/glm.py
@@ -341,11 +341,24 @@ def _check_run_sample_masks(n_runs, sample_masks):
         raise TypeError(
             f"sample_mask has an unhandled type: {sample_masks.__class__}"
         )
-    if not isinstance(sample_masks, (list, tuple)):
+
+    if isinstance(sample_masks, np.ndarray):
         sample_masks = (sample_masks, )
-    if len(sample_masks) != n_runs:
+
+    checked_sample_masks = [_convert_bool2index(sm) for sm in sample_masks]
+
+    if len(checked_sample_masks) != n_runs:
         raise ValueError(
-            f"Number of sample_mask ({len(sample_masks)}) not matching "
-            f"number of runs ({n_runs})."
+            f"Number of sample_mask ({len(checked_sample_masks)}) not "
+            f"matching number of runs ({n_runs})."
         )
-    return sample_masks
+    return checked_sample_masks
+
+
+def _convert_bool2index(sample_mask):
+    """Convert boolean to index. """
+    check_boolean = [type(i) is bool or type(i) is np.bool_
+                     for i in sample_mask]
+    if all(check_boolean):
+        sample_mask = np.where(sample_mask)[0]
+    return sample_mask
diff --git a/nilearn/datasets/struct.py b/nilearn/datasets/struct.py
index 05a2602a9..89c3b1a09 100644
--- a/nilearn/datasets/struct.py
+++ b/nilearn/datasets/struct.py
@@ -37,10 +37,6 @@ _LEGACY_FORMAT_MSG = (
     "instead of recarrays."
 )
 
-# workaround for
-# https://github.com/nilearn/nilearn/pull/2738#issuecomment-869018842
-_MNI_RES_WARNING_ALREADY_SHOWN = False
-
 
 @fill_doc
 def fetch_icbm152_2009(data_dir=None, url=None, resume=True, verbose=1):
@@ -172,7 +168,7 @@ def load_mni152_template(resolution=None):
 
     Parameters
     ----------
-    resolution: int, optional, Default = 2
+    resolution: int, optional, Default = 1
         If resolution is different from 1, the template is re-sampled with the
         specified resolution.
 
@@ -200,14 +196,7 @@ def load_mni152_template(resolution=None):
 
     """
 
-    global _MNI_RES_WARNING_ALREADY_SHOWN
-    if resolution is None:
-        if not _MNI_RES_WARNING_ALREADY_SHOWN:
-            warnings.warn("Default resolution of the MNI template will change "
-                          "from 2mm to 1mm in version 0.10.0", FutureWarning,
-                          stacklevel=2)
-            _MNI_RES_WARNING_ALREADY_SHOWN = True
-        resolution = 2
+    resolution = resolution or 1
 
     brain_template = check_niimg(MNI152_FILE_PATH)
 
@@ -237,7 +226,7 @@ def load_mni152_gm_template(resolution=None):
 
     Parameters
     ----------
-    resolution: int, optional, Default = 2
+    resolution: int, optional, Default = 1
         If resolution is different from 1, the template is re-sampled with the
         specified resolution.
 
@@ -256,13 +245,7 @@ def load_mni152_gm_template(resolution=None):
 
     """
 
-    global _MNI_RES_WARNING_ALREADY_SHOWN
-    if resolution is None:
-        if not _MNI_RES_WARNING_ALREADY_SHOWN:
-            warnings.warn("Default resolution of the MNI template will change "
-                          "from 2mm to 1mm in version 0.10.0", FutureWarning)
-            _MNI_RES_WARNING_ALREADY_SHOWN = True
-        resolution = 2
+    resolution = resolution or 1
 
     gm_template = check_niimg(GM_MNI152_FILE_PATH)
 
@@ -292,7 +275,7 @@ def load_mni152_wm_template(resolution=None):
 
     Parameters
     ----------
-    resolution: int, optional, Default = 2
+    resolution: int, optional, Default = 1
         If resolution is different from 1, the template is re-sampled with the
         specified resolution.
 
@@ -311,13 +294,7 @@ def load_mni152_wm_template(resolution=None):
 
     """
 
-    global _MNI_RES_WARNING_ALREADY_SHOWN
-    if resolution is None:
-        if not _MNI_RES_WARNING_ALREADY_SHOWN:
-            warnings.warn("Default resolution of the MNI template will change "
-                          "from 2mm to 1mm in version 0.10.0", FutureWarning)
-            _MNI_RES_WARNING_ALREADY_SHOWN = True
-        resolution = 2
+    resolution = resolution or 1
 
     wm_template = check_niimg(WM_MNI152_FILE_PATH)
 
@@ -346,7 +323,7 @@ def load_mni152_brain_mask(resolution=None, threshold=0.2):
 
     Parameters
     ----------
-    resolution: int, optional, Default = 2
+    resolution: int, optional, Default = 1
         If resolution is different from 1, the template loaded is first
         re-sampled with the specified resolution.
 
@@ -372,13 +349,7 @@ def load_mni152_brain_mask(resolution=None, threshold=0.2):
 
     """
 
-    global _MNI_RES_WARNING_ALREADY_SHOWN
-    if resolution is None:
-        if not _MNI_RES_WARNING_ALREADY_SHOWN:
-            warnings.warn("Default resolution of the MNI template will change "
-                          "from 2mm to 1mm in version 0.10.0", FutureWarning)
-            _MNI_RES_WARNING_ALREADY_SHOWN = True
-        resolution = 2
+    resolution = resolution or 1
 
     # Load MNI template
     target_img = load_mni152_template(resolution=resolution)
@@ -397,7 +368,7 @@ def load_mni152_gm_mask(resolution=None, threshold=0.2, n_iter=2):
 
     Parameters
     ----------
-    resolution: int, optional, Default = 2
+    resolution: int, optional, Default = 1
         If resolution is different from 1, the template loaded is first
         re-sampled with the specified resolution.
 
@@ -425,13 +396,7 @@ def load_mni152_gm_mask(resolution=None, threshold=0.2, n_iter=2):
 
     """
 
-    global _MNI_RES_WARNING_ALREADY_SHOWN
-    if resolution is None:
-        if not _MNI_RES_WARNING_ALREADY_SHOWN:
-            warnings.warn("Default resolution of the MNI template will change "
-                          "from 2mm to 1mm in version 0.10.0", FutureWarning)
-            _MNI_RES_WARNING_ALREADY_SHOWN = True
-        resolution = 2
+    resolution = resolution or 1
 
     # Load MNI template
     gm_target = load_mni152_gm_template(resolution=resolution)
@@ -455,7 +420,7 @@ def load_mni152_wm_mask(resolution=None, threshold=0.2, n_iter=2):
 
     Parameters
     ----------
-    resolution: int, optional, Default = 2
+    resolution: int, optional, Default = 1
         If resolution is different from 1, the template loaded is first
         re-sampled with the specified resolution.
 
@@ -483,13 +448,7 @@ def load_mni152_wm_mask(resolution=None, threshold=0.2, n_iter=2):
 
     """
 
-    global _MNI_RES_WARNING_ALREADY_SHOWN
-    if resolution is None:
-        if not _MNI_RES_WARNING_ALREADY_SHOWN:
-            warnings.warn("Default resolution of the MNI template will change "
-                          "from 2mm to 1mm in version 0.10.0", FutureWarning)
-            _MNI_RES_WARNING_ALREADY_SHOWN = True
-        resolution = 2
+    resolution = resolution or 1
 
     # Load MNI template
     wm_target = load_mni152_wm_template(resolution=resolution)
diff --git a/nilearn/image/image.py b/nilearn/image/image.py
index 0bbcfd0b8..257f16a17 100644
--- a/nilearn/image/image.py
+++ b/nilearn/image/image.py
@@ -631,24 +631,24 @@ def index_img(imgs, index):
      >>> joint_mni_image = concat_imgs([datasets.load_mni152_template(),
      ...                                datasets.load_mni152_template()])
      >>> print(joint_mni_image.shape)
-     (99, 117, 95, 2)
+     (197, 233, 189, 2)
 
     We can now select one slice from the last dimension of this 4D-image::
 
      >>> single_mni_image = index_img(joint_mni_image, 1)
      >>> print(single_mni_image.shape)
-     (99, 117, 95)
+     (197, 233, 189)
 
     We can also select multiple frames using the `slice` constructor::
 
      >>> five_mni_images = concat_imgs([datasets.load_mni152_template()] * 5)
      >>> print(five_mni_images.shape)
-     (99, 117, 95, 5)
+     (197, 233, 189, 5)
 
      >>> first_three_images = index_img(five_mni_images,
      ...                                slice(0, 3))
      >>> print(first_three_images.shape)
-     (99, 117, 95, 3)
+     (197, 233, 189, 3)
 
     """
     imgs = check_niimg_4d(imgs)
diff --git a/nilearn/image/resampling.py b/nilearn/image/resampling.py
index 679a6c11a..d4d77d5ef 100644
--- a/nilearn/image/resampling.py
+++ b/nilearn/image/resampling.py
@@ -131,7 +131,7 @@ def coord_transform(x, y, z, affine):
         >>> niimg = datasets.load_mni152_template()
         >>> # Find the MNI coordinates of the voxel (50, 50, 50)
         >>> image.coord_transform(50, 50, 50, niimg.affine)
-        (2.0, -34.0, 28.0)
+        (-48.0, -84.0, -22.0)
 
     """
     squeeze = (not hasattr(x, '__iter__'))
diff --git a/nilearn/signal.py b/nilearn/signal.py
index 9991b3c30..661725409 100644
--- a/nilearn/signal.py
+++ b/nilearn/signal.py
@@ -528,11 +528,14 @@ def clean(signals, runs=None, detrend=True, standardize='zscore',
         signal, as if all were in the same array.
         Default is None.
 
-    sample_mask : None, :class:`numpy.ndarray`, :obj:`list`,\
-    :obj:`tuple`, or :obj:`list` of
-        shape: (number of scans - number of volumes removed, )
+    sample_mask : None, Any type compatible with numpy-array indexing, \
+        or :obj:`list` of
+        shape: (number of scans - number of volumes removed, ) for explicit \
+            index, or (number of scans, ) for binary mask
         Masks the niimgs along time/fourth dimension to perform scrubbing
         (remove volumes with high motion) and/or non-steady-state volumes.
+        When passing binary mask with boolean values, ``True`` refers to
+        volumes kept, and ``False`` for volumes removed.
         This masking step is applied before signal cleaning. When supplying run
         information, sample_mask must be a list containing sets of indexes for
         each run.


Test patch:
diff --git a/doc/changes/latest.rst b/doc/changes/latest.rst
index 1650fd8a9..42c302d53 100644
--- a/doc/changes/latest.rst
+++ b/doc/changes/latest.rst
@@ -28,6 +28,7 @@ Enhancements
 - :func:`~signal.clean` imputes scrubbed volumes (defined through ``sample_masks``) with cubic spline function before applying butterworth filter (:gh:`3385` by `Hao-Ting Wang`_). 
 - As part of making the User Guide more user-friendly, the introduction was reworked (:gh:`3380` by `Alexis Thual`_)
 - Added instructions for maintainers to make sure LaTeX dependencies are installed before building and deploying the stable docs (:gh:`3426` by `Yasmin Mzayek`_).
+- Parameter ``sample_masks`` in :func:`~signal.clean` and masker functions accept binary mask (:gh:`3439` by `Hao-Ting Wang`_). 
 
 Changes
 -------
@@ -49,3 +50,4 @@ Changes
     * Joblib -- v1.0.0
 
   (:gh:`3440` by `Yasmin Mzayek`_).
+- In release ``0.10.0`` the default resolution for loaded MNI152 templates will be 1mm instead of 2mm (:gh:`3433` by `Yasmin Mzayek`_).
diff --git a/nilearn/datasets/tests/test_struct.py b/nilearn/datasets/tests/test_struct.py
index 7159a4e9d..0afca3853 100644
--- a/nilearn/datasets/tests/test_struct.py
+++ b/nilearn/datasets/tests/test_struct.py
@@ -148,8 +148,8 @@ def test_fetch_oasis_vbm(tmp_path, request_mocker, legacy_format):
 
 def test_load_mni152_template():
     # All subjects
-    template_nii_1mm = struct.load_mni152_template(resolution=1)
-    template_nii_2mm = struct.load_mni152_template()
+    template_nii_1mm = struct.load_mni152_template()
+    template_nii_2mm = struct.load_mni152_template(resolution=2)
     assert template_nii_1mm.shape == (197, 233, 189)
     assert template_nii_2mm.shape == (99, 117, 95)
     assert template_nii_1mm.header.get_zooms() == (1.0, 1.0, 1.0)
@@ -158,8 +158,8 @@ def test_load_mni152_template():
 
 def test_load_mni152_gm_template():
     # All subjects
-    gm_template_nii_1mm = struct.load_mni152_gm_template(resolution=1)
-    gm_template_nii_2mm = struct.load_mni152_gm_template()
+    gm_template_nii_1mm = struct.load_mni152_gm_template()
+    gm_template_nii_2mm = struct.load_mni152_gm_template(resolution=2)
     assert gm_template_nii_1mm.shape == (197, 233, 189)
     assert gm_template_nii_2mm.shape == (99, 117, 95)
     assert gm_template_nii_1mm.header.get_zooms() == (1.0, 1.0, 1.0)
@@ -168,8 +168,8 @@ def test_load_mni152_gm_template():
 
 def test_load_mni152_wm_template():
     # All subjects
-    wm_template_nii_1mm = struct.load_mni152_wm_template(resolution=1)
-    wm_template_nii_2mm = struct.load_mni152_wm_template()
+    wm_template_nii_1mm = struct.load_mni152_wm_template()
+    wm_template_nii_2mm = struct.load_mni152_wm_template(resolution=2)
     assert wm_template_nii_1mm.shape == (197, 233, 189)
     assert wm_template_nii_2mm.shape == (99, 117, 95)
     assert wm_template_nii_1mm.header.get_zooms() == (1.0, 1.0, 1.0)
@@ -177,8 +177,8 @@ def test_load_mni152_wm_template():
 
 
 def test_load_mni152_brain_mask():
-    brain_mask_1mm = struct.load_mni152_brain_mask(resolution=1)
-    brain_mask_2mm = struct.load_mni152_brain_mask()
+    brain_mask_1mm = struct.load_mni152_brain_mask()
+    brain_mask_2mm = struct.load_mni152_brain_mask(resolution=2)
     assert isinstance(brain_mask_1mm, nibabel.Nifti1Image)
     assert isinstance(brain_mask_2mm, nibabel.Nifti1Image)
     # standard MNI template shape
@@ -187,8 +187,8 @@ def test_load_mni152_brain_mask():
 
 
 def test_load_mni152_gm_mask():
-    gm_mask_1mm = struct.load_mni152_gm_mask(resolution=1)
-    gm_mask_2mm = struct.load_mni152_gm_mask()
+    gm_mask_1mm = struct.load_mni152_gm_mask()
+    gm_mask_2mm = struct.load_mni152_gm_mask(resolution=2)
     assert isinstance(gm_mask_1mm, nibabel.Nifti1Image)
     assert isinstance(gm_mask_2mm, nibabel.Nifti1Image)
     # standard MNI template shape
@@ -197,8 +197,8 @@ def test_load_mni152_gm_mask():
 
 
 def test_load_mni152_wm_mask():
-    wm_mask_1mm = struct.load_mni152_wm_mask(resolution=1)
-    wm_mask_2mm = struct.load_mni152_wm_mask()
+    wm_mask_1mm = struct.load_mni152_wm_mask()
+    wm_mask_2mm = struct.load_mni152_wm_mask(resolution=2)
     assert isinstance(wm_mask_1mm, nibabel.Nifti1Image)
     assert isinstance(wm_mask_2mm, nibabel.Nifti1Image)
     # standard MNI template shape
@@ -206,26 +206,6 @@ def test_load_mni152_wm_mask():
     assert wm_mask_2mm.shape == (99, 117, 95)
 
 
-@pytest.mark.parametrize("part", ["_brain", "_gm", "_wm"])
-@pytest.mark.parametrize("kind", ["template", "mask"])
-def test_mni152_resolution_warnings(part, kind):
-    struct._MNI_RES_WARNING_ALREADY_SHOWN = False
-    if kind == "template" and part == "_brain":
-        part = ""
-    loader = getattr(struct, f"load_mni152{part}_{kind}")
-    try:
-        loader.cache_clear()
-    except AttributeError:
-        pass
-    with warnings.catch_warnings(record=True) as w:
-        loader(resolution=1)
-    assert len(w) == 0
-    with warnings.catch_warnings(record=True) as w:
-        loader()
-        loader()
-    assert len(w) == 1
-
-
 def test_fetch_icbm152_brain_gm_mask(tmp_path, request_mocker):
     dataset = struct.fetch_icbm152_2009(data_dir=str(tmp_path), verbose=0)
     struct.load_mni152_template().to_filename(dataset.gm)
diff --git a/nilearn/tests/test_signal.py b/nilearn/tests/test_signal.py
index de0e1fdd6..9dfd365e3 100644
--- a/nilearn/tests/test_signal.py
+++ b/nilearn/tests/test_signal.py
@@ -125,21 +125,6 @@ def test_butterworth():
     # single timeseries
     data = rng.standard_normal(size=n_samples)
     data_original = data.copy()
-    '''
-    May be only on py3.5:
-    Bug in scipy 1.1.0 generates an unavoidable FutureWarning.
-    (More info: https://github.com/scipy/scipy/issues/9086)
-    The number of warnings generated is overwhelming TravisCI's log limit,
-     causing it to fail tests.
-     This hack prevents that and will be removed in future.
-    '''
-    buggy_scipy = (
-        _compare_version(scipy.__version__, '<', '1.2')
-        and _compare_version(scipy.__version__, '>', '1.0')
-    )
-    if buggy_scipy:
-        warnings.simplefilter('ignore')
-    ''' END HACK '''
     out_single = nisignal.butterworth(data, sampling,
                                       low_pass=low_pass, high_pass=high_pass,
                                       copy=True)
@@ -785,13 +770,20 @@ def test_sample_mask():
     sample_mask = np.arange(signals.shape[0])
     scrub_index = [2, 3, 6, 7, 8, 30, 31, 32]
     sample_mask = np.delete(sample_mask, scrub_index)
+    sample_mask_binary = np.full(signals.shape[0], True)
+    sample_mask_binary[scrub_index] = False
 
     scrub_clean = clean(signals, confounds=confounds, sample_mask=sample_mask)
     assert scrub_clean.shape[0] == sample_mask.shape[0]
 
+    # test the binary mask
+    scrub_clean_bin = clean(signals, confounds=confounds,
+                            sample_mask=sample_mask_binary)
+    np.testing.assert_equal(scrub_clean_bin, scrub_clean)
+
     # list of sample_mask for each run
     runs = np.ones(signals.shape[0])
-    runs[0:signals.shape[0] // 2] = 0
+    runs[:signals.shape[0] // 2] = 0
     sample_mask_sep = [np.arange(20), np.arange(20)]
     scrub_index = [[6, 7, 8], [10, 11, 12]]
     sample_mask_sep = [np.delete(sm, si)
@@ -800,6 +792,15 @@ def test_sample_mask():
                            sample_mask=sample_mask_sep, runs=runs)
     assert scrub_sep_mask.shape[0] == signals.shape[0] - 6
 
+    # test for binary mask per run
+    sample_mask_sep_binary = [np.full(signals.shape[0] // 2, True),
+                              np.full(signals.shape[0] // 2, True)]
+    sample_mask_sep_binary[0][scrub_index[0]] = False
+    sample_mask_sep_binary[1][scrub_index[1]] = False
+    scrub_sep_mask = clean(signals, confounds=confounds,
+                           sample_mask=sample_mask_sep_binary, runs=runs)
+    assert scrub_sep_mask.shape[0] == signals.shape[0] - 6
+
     # 1D sample mask with runs labels
     with pytest.raises(ValueError,
                        match=r'Number of sample_mask \(\d\) not matching'):