Repository: nilearn/nilearn
Pull request #5317: [REF] refactor mask loading and validation in maskers
Author: login: Remi-Gau
Created: 2025-04-04T08:42:19Z | Merged: 2025-04-16T08:11:24Z

<!---
This is a suggested pull request template for nilearn.
It's designed to capture information we've found to be useful in reviewing pull requests.

If there is other information that would be helpful to include, please don't hesitate to add it!

Please make sure your pull request also follows the
[contribution guidelines](https://nilearn.github.io/stable/development.html#contribution-guidelines) that
will be enforced during the review process.
-->

<!-- Please indicate after the # which issue you're closing with this PR.
This is helpful for the maintainers AND will magically close the issue when this
pull request is merged!
If the PR closes multiple issues, includes "closes" before each one is listed.
https://help.github.com/articles/closing-issues-using-keywords -->
- Closes none

<!-- Please give a brief overview of what has changed in the PR.
If you're not sure what to write, consider it a note to the maintainers to indicate
what they should be looking for when they review the pull request. -->
Changes proposed in this pull request:

- create a _load_mask method in base maskers
- use it to load and validate masks in all children classes
- update estimator checks to check those methods
- merge some checks for nifti masker and surface maskers



Diff:
diff --git a/nilearn/_utils/estimator_checks.py b/nilearn/_utils/estimator_checks.py
index 05b4213ff1a..9974e454361 100644
--- a/nilearn/_utils/estimator_checks.py
+++ b/nilearn/_utils/estimator_checks.py
@@ -19,11 +19,13 @@
 from sklearn.utils.estimator_checks import check_is_fitted
 
 from nilearn._utils import compare_version
-from nilearn._utils.exceptions import DimensionError
+from nilearn._utils.exceptions import DimensionError, MeshDimensionError
 from nilearn._utils.helpers import is_matplotlib_installed
 from nilearn._utils.testing import write_imgs_to_path
 from nilearn.conftest import (
     _affine_eye,
+    _drop_surf_img_part,
+    _flip_surf_img,
     _img_3d_mni,
     _img_3d_ones,
     _img_3d_rand,
@@ -42,7 +44,10 @@
 from nilearn.regions import RegionExtractor
 from nilearn.reporting.tests.test_html_report import _check_html
 from nilearn.surface import SurfaceImage
-from nilearn.surface.utils import assert_surface_image_equal
+from nilearn.surface.utils import (
+    assert_polydata_equal,
+    assert_surface_image_equal,
+)
 
 # TODO simplify when dropping sklearn 1.5,
 if compare_version(sklearn_version, ">", "1.5.2"):
@@ -312,6 +317,14 @@ def nilearn_check_estimator(estimator):
         yield (clone(estimator), check_masker_refit)
         yield (clone(estimator), check_masker_transformer)
         yield (clone(estimator), check_masker_compatibility_mask_image)
+        yield (clone(estimator), check_masker_fit_with_mask_too_many_samples)
+        yield (clone(estimator), check_masker_fit_with_empty_mask)
+        yield (clone(estimator), check_masker_fit_returns_self)
+        yield (
+            clone(estimator),
+            check_masker_fit_with_non_finite_in_mask,
+        )
+        yield (clone(estimator), check_masker_smooth)
 
         if not is_multimasker(estimator):
             yield (clone(estimator), check_masker_detrending)
@@ -329,22 +342,14 @@ def nilearn_check_estimator(estimator):
             yield (clone(estimator), check_nifti_masker_fit_transform)
             yield (clone(estimator), check_nifti_masker_fit_transform_files)
             yield (clone(estimator), check_nifti_masker_fit_with_3d_mask)
-            yield (clone(estimator), check_nifti_masker_fit_with_4d_mask)
-            yield (clone(estimator), check_nifti_masker_fit_with_empty_mask)
             yield (clone(estimator), check_nifti_masker_fit_with_only_mask)
             yield (
                 clone(estimator),
                 check_nifti_masker_generate_report_after_fit_with_only_mask,
             )
-            yield (
-                clone(estimator),
-                check_nifti_masker_fit_with_non_finite_in_mask,
-            )
             yield (clone(estimator), check_nifti_masker_clean_error)
             yield (clone(estimator), check_nifti_masker_clean_warning)
             yield (clone(estimator), check_nifti_masker_dtype)
-            yield (clone(estimator), check_nifti_masker_smooth)
-            yield (clone(estimator), check_nifti_masker_fit_returns_self)
             yield (clone(estimator), check_nifti_masker_fit_transform_5d)
 
             if is_multimasker(estimator):
@@ -359,8 +364,7 @@ def nilearn_check_estimator(estimator):
                 )
 
         if surf_img_input:
-            yield (clone(estimator), check_surface_masker_fit_returns_self)
-            yield (clone(estimator), check_surface_masker_smooth)
+            yield (clone(estimator), check_surface_masker_fit_with_mask)
 
     if is_glm:
         yield (clone(estimator), check_glm_fit_returns_self)
@@ -456,6 +460,16 @@ def check_masker_fitted(estimator):
     check_is_fitted(estimator)
 
 
+def check_masker_fit_returns_self(estimator):
+    """Check maskers return itself after fit."""
+    if accept_niimg_input(estimator):
+        imgs = _img_3d_rand()
+    else:
+        imgs = _make_surface_img(10)
+
+    assert estimator.fit(imgs) is estimator
+
+
 def check_masker_clean_kwargs(estimator):
     """Check attributes for cleaning.
 
@@ -523,6 +537,11 @@ def check_masker_compatibility_mask_image(estimator):
     with pytest.raises(TypeError):
         estimator.transform(image_to_transform)
 
+    if accept_niimg_input(estimator):
+        assert isinstance(estimator.mask_img_, Nifti1Image)
+    else:
+        assert isinstance(estimator.mask_img_, SurfaceImage)
+
 
 def check_masker_clean(estimator):
     """Check that cleaning does something on fit transform.
@@ -599,39 +618,6 @@ def check_masker_transformer_high_variance_confounds(estimator):
     assert_raises(AssertionError, assert_array_equal, signal_1, signal_2)
 
 
-def check_multi_masker_transformer_sample_mask(estimator):
-    """Test multi maskers with a list of "sample_mask".
-
-    "sample_mask" was directly sent as input to the parallel calls of
-    "transform_single_imgs" instead of sending iterations.
-    See https://github.com/nilearn/nilearn/issues/3967 for more details.
-    """
-    length = _img_4d_rand_eye_medium().shape[3]
-
-    n_scrub1 = 3
-    n_scrub2 = 2
-
-    sample_mask1 = np.arange(length - n_scrub1)
-    sample_mask2 = np.arange(length - n_scrub2)
-
-    signals_list = estimator.fit_transform(
-        [_img_4d_rand_eye_medium(), _img_4d_rand_eye_medium()],
-        sample_mask=[sample_mask1, sample_mask2],
-    )
-
-    for ts, n_scrub in zip(signals_list, [n_scrub1, n_scrub2]):
-        assert ts.shape[0] == length - n_scrub
-
-    with pytest.raises(
-        ValueError,
-        match="number of sample_mask .* unequal to number of images",
-    ):
-        estimator.fit_transform(
-            [_img_4d_rand_eye_medium(), _img_4d_rand_eye_medium()],
-            sample_mask=sample_mask1,
-        )
-
-
 def check_masker_transformer_sample_mask(estimator):
     """Check sample_mask use in maskers.
 
@@ -685,38 +671,6 @@ def check_masker_transformer_sample_mask(estimator):
     assert_array_equal(signal_2, signal_5)
 
 
-def check_multi_masker_with_confounds(estimator):
-    """Test multi maskers with a list of confounds.
-
-    Ensure results is different than when not using confounds.
-
-    Check that error is raised if number of confounds
-    does not match number of images
-    """
-    length = _img_4d_rand_eye_medium().shape[3]
-
-    array = _rng().random((length, 3))
-
-    signals_list_1 = estimator.fit_transform(
-        [_img_4d_rand_eye_medium(), _img_4d_rand_eye_medium()],
-    )
-    signals_list_2 = estimator.fit_transform(
-        [_img_4d_rand_eye_medium(), _img_4d_rand_eye_medium()],
-        confounds=[array, array],
-    )
-
-    for signal_1, signal_2 in zip(signals_list_1, signals_list_2):
-        assert_raises(AssertionError, assert_array_equal, signal_1, signal_2)
-
-    with pytest.raises(
-        ValueError, match="number of confounds .* unequal to number of images"
-    ):
-        estimator.fit_transform(
-            [_img_4d_rand_eye_medium(), _img_4d_rand_eye_medium()],
-            confounds=array,
-        )
-
-
 def check_masker_with_confounds(estimator):
     """Test fit_transform with confounds.
 
@@ -818,53 +772,187 @@ def check_masker_refit(estimator):
             assert_surface_image_equal(fitted_mask_1, fitted_mask_2)
 
 
-# ------------------ SURFACE MASKER CHECKS ------------------
+def check_masker_fit_with_mask_too_many_samples(estimator):
+    """Check mask with too many sample cannot be used with maskers.
 
+    - 4D masks not allowed for nifti maskers
+    - 2D masks not allowed for surface maskers
+    """
+    if accept_niimg_input(estimator):
+        mask_img = _img_4d_zeros()
+        imgs = [_img_3d_rand()]
+        msg = "Expected dimension is 3D"
+        error = DimensionError
+    else:
+        mask_img = _make_surface_img(2)
+        imgs = _make_surface_img(1)
+        msg = "Data for each part of img should be 1D."
+        error = ValueError
+
+    estimator.mask_img = mask_img
+    with pytest.raises(error, match=msg):
+        estimator.fit(imgs)
 
-def check_surface_masker_fit_returns_self(estimator):
-    """Check surface maskers return itself after fit."""
-    assert estimator.fit(_make_surface_img(10)) is estimator
 
+def check_masker_fit_with_empty_mask(estimator):
+    """Check mask that excludes all voxels raise an error."""
+    if accept_niimg_input(estimator):
+        mask_img = _img_3d_zeros()
+        imgs = [_img_3d_rand()]
+    else:
+        mask_img = _make_surface_mask()
+        for k, v in mask_img.data.parts.items():
+            mask_img.data.parts[k] = np.zeros(v.shape)
+        imgs = _make_surface_img(1)
 
-def check_surface_masker_smooth(estimator):
-    """Check smoothing on surface maskers raises warning.
+    estimator.mask_img = mask_img
+    with pytest.raises(
+        ValueError,
+        match="The mask is invalid as it is empty: it masks all data",
+    ):
+        estimator.fit(imgs)
 
-    Check that output is the same with and without smoothing.
-    TODO: update once smoothing is implemented.
+
+def check_masker_fit_with_non_finite_in_mask(estimator):
+    """Check mask with non finite values can be used with maskers.
+
+    - Warning is thrown.
+    - Output of transform must contain only finite values.
     """
-    assert hasattr(estimator, "smoothing_fwhm")
+    if accept_niimg_input(estimator):
+        # TODO
+        # (29, 30, 31) is used to match MAP_SHAPE in
+        # nilearn/regions/tests/test_region_extractor.py
+        # this test would fail for RegionExtractor otherwise
+        mask = np.ones((29, 30, 31))
+        mask[:, :, 7] = np.nan
+        mask[:, :, 4] = np.inf
+        mask_img = Nifti1Image(mask, affine=_affine_eye())
+
+        imgs = _img_3d_rand()
 
-    n_sample = 10
+    else:
+        mask_img = _make_surface_mask()
+        for k, v in mask_img.data.parts.items():
+            mask_img.data.parts[k] = np.zeros(v.shape)
+        mask_img.data.parts["left"][0:3, 0] = [np.nan, np.inf, 1]
+        mask_img.data.parts["right"][0:3, 0] = [np.nan, np.inf, 1]
 
-    input_img = _make_surface_img(n_sample)
+        imgs = _make_surface_img(1)
 
-    estimator.fit(input_img)
+    estimator.mask_img = mask_img
+    with pytest.warns(UserWarning, match="Non-finite values detected."):
+        estimator.fit()
+
+    signal = estimator.transform(imgs)
+    assert np.all(np.isfinite(signal))
 
-    signal = estimator.transform(input_img)
+
+def check_masker_smooth(estimator):
+    """Check that masker can smooth data when extracting.
+
+    Check that masker instance has smoothing_fwhm attribute.
+    Check that output is different with and without smoothing.
+
+    For Surface maskers:
+    - Check smoothing on surface maskers raises NotImplemented warning.
+    - Check that output is the same with and without smoothing.
+    TODO: update once smoothing is implemented.
+    """
+    assert hasattr(estimator, "smoothing_fwhm")
+
+    if accept_niimg_input(estimator):
+        n_sample = 1
+        imgs = _img_3d_rand()
+    else:
+        n_sample = 10
+        imgs = _make_surface_img(n_sample)
+
+    signal = estimator.fit_transform(imgs)
 
     assert isinstance(signal, np.ndarray)
     assert signal.shape[0] == n_sample
 
     estimator.smoothing_fwhm = 3
-    estimator.fit(input_img)
+    estimator.fit(imgs)
+
+    if accept_niimg_input(estimator):
+        smoothed_signal = estimator.transform(imgs)
+
+        assert_raises(
+            AssertionError, assert_array_equal, smoothed_signal, signal
+        )
 
-    with pytest.warns(UserWarning, match="not yet supported"):
-        smoothed_signal = estimator.transform(input_img)
+    else:
+        with pytest.warns(UserWarning, match="not yet supported"):
+            smoothed_signal = estimator.transform(imgs)
 
-    assert estimator.smoothing_fwhm is None
+        assert_array_equal(smoothed_signal, signal)
 
     assert isinstance(signal, np.ndarray)
     assert signal.shape[0] == n_sample
 
-    assert_array_equal(smoothed_signal, signal)
 
+# ------------------ SURFACE MASKER CHECKS ------------------
 
-# ------------------ NIFTI MASKER CHECKS ------------------
 
+def check_surface_masker_fit_with_mask(estimator):
+    """Check fit / transform with mask provided at init.
+
+    Check with 2D and 1D images.
+
+    Also check 'shape' errors between images to fit and mask.
+    """
+    mask_img = _make_surface_mask()
+
+    # 1D image
+    imgs = _make_surface_img(1)
+    estimator.mask_img = mask_img
+    estimator.fit(imgs)
+
+    assert_polydata_equal(mask_img.data, estimator.mask_img_.data)
+
+    signal = estimator.transform(imgs)
+
+    assert isinstance(signal, np.ndarray)
+    assert signal.shape[0] == 1
+
+    # 2D image
+    imgs = _make_surface_img(5)
+    estimator = clone(estimator)
+    estimator.mask_img = mask_img
+    estimator.fit(imgs)
+
+    assert_polydata_equal(mask_img.data, estimator.mask_img_.data)
+
+    signal = estimator.transform(imgs)
+
+    assert isinstance(signal, np.ndarray)
+    assert signal.shape[0] == 5
+
+    # errors
+    with pytest.raises(
+        MeshDimensionError,
+        match="Number of vertices do not match for between meshes.",
+    ):
+        estimator.fit(_flip_surf_img(imgs))
+    with pytest.raises(
+        MeshDimensionError,
+        match="Number of vertices do not match for between meshes.",
+    ):
+        estimator.transform(_flip_surf_img(imgs))
 
-def check_nifti_masker_fit_returns_self(estimator):
-    """Check if self is returned when calling fit."""
-    assert estimator.fit(_img_3d_rand()) is estimator
+    with pytest.raises(
+        MeshDimensionError, match="PolyMeshes do not have the same keys."
+    ):
+        estimator.fit(_drop_surf_img_part(imgs))
+    with pytest.raises(
+        MeshDimensionError, match="PolyMeshes do not have the same keys."
+    ):
+        estimator.transform(_drop_surf_img_part(imgs))
+
+
+# ------------------ NIFTI MASKER CHECKS ------------------
 
 
 def check_nifti_masker_fit_transform(estimator):
@@ -1006,30 +1094,6 @@ def check_nifti_masker_fit_transform_files(estimator):
         estimator.fit_transform(filename)
 
 
-def check_nifti_masker_smooth(estimator):
-    """Check that masker can smooth data when extracting.
-
-    Check that masker instance has smoothing_fwhm attribute.
-    Check that output is different with and without smoothing.
-    """
-    assert hasattr(estimator, "smoothing_fwhm")
-
-    signal = estimator.fit(_img_3d_rand())
-    signal = estimator.transform(_img_3d_rand())
-
-    assert isinstance(signal, np.ndarray)
-    assert signal.shape[0] == 1
-
-    estimator.smoothing_fwhm = 3
-    estimator.fit(_img_3d_rand())
-    smoothed_signal = estimator.transform(_img_3d_rand())
-
-    assert isinstance(signal, np.ndarray)
-    assert signal.shape[0] == 1
-
-    assert_raises(AssertionError, assert_array_equal, smoothed_signal, signal)
-
-
 def check_nifti_masker_dtype(estimator):
     """Check dtype of output of maskers."""
     data_32 = _rng().random(_shape_3d_default(), dtype=np.float32)
@@ -1088,44 +1152,72 @@ def check_nifti_masker_fit_with_only_mask(estimator):
     assert estimator.mask_img_ is mask_img
 
 
-def check_nifti_masker_fit_with_non_finite_in_mask(estimator):
-    """Check 3D mask with non finite values can be used with nifti maskers.
+# ------------------ MULTI NIFTI MASKER CHECKS ------------------
 
-    - Warning is thrown.
-    - Output of transform must contain only finite values.
+
+def check_multi_masker_with_confounds(estimator):
+    """Test multi maskers with a list of confounds.
+
+    Ensure results is different than when not using confounds.
+
+    Check that error is raised if number of confounds
+    does not match number of images
     """
-    # TODO
-    # (29, 30, 31) is used to match MAP_SHAPE in
-    # nilearn/regions/tests/test_region_extractor.py
-    # this test would fail for RegionExtractor otherwise
-    mask = np.ones((29, 30, 31))
-    mask[:, :, 7] = np.nan
-    mask[:, :, 4] = np.inf
-    mask_img = Nifti1Image(mask, affine=_affine_eye())
+    length = _img_4d_rand_eye_medium().shape[3]
 
-    estimator.mask_img = mask_img
-    with pytest.warns(UserWarning, match="Non-finite values detected."):
-        estimator.fit()
+    array = _rng().random((length, 3))
 
-    signal = estimator.transform(_img_3d_rand())
-    assert np.all(np.isfinite(signal))
+    signals_list_1 = estimator.fit_transform(
+        [_img_4d_rand_eye_medium(), _img_4d_rand_eye_medium()],
+    )
+    signals_list_2 = estimator.fit_transform(
+        [_img_4d_rand_eye_medium(), _img_4d_rand_eye_medium()],
+        confounds=[array, array],
+    )
 
+    for signal_1, signal_2 in zip(signals_list_1, signals_list_2):
+        assert_raises(AssertionError, assert_array_equal, signal_1, signal_2)
 
-def check_nifti_masker_fit_with_empty_mask(estimator):
-    """Check mask that excludes all voxels raise an error."""
-    estimator.mask_img = _img_3d_zeros()
     with pytest.raises(
-        ValueError,
-        match="The mask is invalid as it is empty: it masks all data",
+        ValueError, match="number of confounds .* unequal to number of images"
     ):
-        estimator.fit([_img_3d_rand()])
+        estimator.fit_transform(
+            [_img_4d_rand_eye_medium(), _img_4d_rand_eye_medium()],
+            confounds=array,
+        )
 
 
-def check_nifti_masker_fit_with_4d_mask(estimator):
-    """Check 4D mask cannot be used with nifti maskers."""
-    with pytest.raises(DimensionError, match="Expected dimension is 3D"):
-        estimator.mask_img = _img_4d_zeros()
-        estimator.fit([_img_3d_rand()])
+def check_multi_masker_transformer_sample_mask(estimator):
+    """Test multi maskers with a list of "sample_mask".
+
+    "sample_mask" was directly sent as input to the parallel calls of
+    "transform_single_imgs" instead of sending iterations.
+    See https://github.com/nilearn/nilearn/issues/3967 for more details.
+    """
+    length = _img_4d_rand_eye_medium().shape[3]
+
+    n_scrub1 = 3
+    n_scrub2 = 2
+
+    sample_mask1 = np.arange(length - n_scrub1)
+    sample_mask2 = np.arange(length - n_scrub2)
+
+    signals_list = estimator.fit_transform(
+        [_img_4d_rand_eye_medium(), _img_4d_rand_eye_medium()],
+        sample_mask=[sample_mask1, sample_mask2],
+    )
+
+    for ts, n_scrub in zip(signals_list, [n_scrub1, n_scrub2]):
+        assert ts.shape[0] == length - n_scrub
+
+    with pytest.raises(
+        ValueError,
+        match="number of sample_mask .* unequal to number of images",
+    ):
+        estimator.fit_transform(
+            [_img_4d_rand_eye_medium(), _img_4d_rand_eye_medium()],
+            sample_mask=sample_mask1,
+        )
 
 
 # ------------------ GLM CHECKS ------------------
diff --git a/nilearn/conftest.py b/nilearn/conftest.py
index d0353fc13f8..ab14f1108e5 100644
--- a/nilearn/conftest.py
+++ b/nilearn/conftest.py
@@ -709,42 +709,45 @@ def surf_maps_img():
     return _surf_maps_img()
 
 
+def _flip_surf_img_parts(poly_obj):
+    """Flip hemispheres of a surface image data or mesh."""
+    keys = list(poly_obj.parts.keys())
+    keys = [keys[-1]] + keys[:-1]
+    return dict(zip(keys, poly_obj.parts.values()))
+
+
 @pytest.fixture
 def flip_surf_img_parts():
     """Flip hemispheres of a surface image data or mesh."""
+    return _flip_surf_img_parts
 
-    def f(poly_obj):
-        keys = list(poly_obj.parts.keys())
-        keys = [keys[-1]] + keys[:-1]
-        return dict(zip(keys, poly_obj.parts.values()))
 
-    return f
+def _flip_surf_img(img):
+    """Flip hemispheres of a surface image."""
+    return SurfaceImage(
+        _flip_surf_img_parts(img.mesh), _flip_surf_img_parts(img.data)
+    )
 
 
 @pytest.fixture
-def flip_surf_img(flip_surf_img_parts):
+def flip_surf_img():
     """Flip hemispheres of a surface image."""
+    return _flip_surf_img
 
-    def f(img):
-        return SurfaceImage(
-            flip_surf_img_parts(img.mesh), flip_surf_img_parts(img.data)
-        )
 
-    return f
+def _drop_surf_img_part(img, part_name="right"):
+    """Remove one hemisphere from a SurfaceImage."""
+    mesh_parts = img.mesh.parts.copy()
+    mesh_parts.pop(part_name)
+    data_parts = img.data.parts.copy()
+    data_parts.pop(part_name)
+    return SurfaceImage(mesh_parts, data_parts)
 
 
 @pytest.fixture
 def drop_surf_img_part():
     """Remove one hemisphere from a SurfaceImage."""
-
-    def f(img, part_name="right"):
-        mesh_parts = img.mesh.parts.copy()
-        mesh_parts.pop(part_name)
-        data_parts = img.data.parts.copy()
-        data_parts.pop(part_name)
-        return SurfaceImage(mesh_parts, data_parts)
-
-    return f
+    return _drop_surf_img_part
 
 
 def _make_surface_img_and_design(n_samples=5):
diff --git a/nilearn/glm/tests/test_first_level.py b/nilearn/glm/tests/test_first_level.py
index a8dd1d1736d..225c8ba377e 100644
--- a/nilearn/glm/tests/test_first_level.py
+++ b/nilearn/glm/tests/test_first_level.py
@@ -2285,10 +2285,7 @@ def test_flm_fit_surface_image_with_mask(
     model.fit(img, design_matrices=des)
 
     assert isinstance(model.masker_.mask_img_, SurfaceImage)
-    if surf_mask_dim == 1:
-        assert model.masker_.mask_img_.shape == (9,)
-    else:
-        assert model.masker_.mask_img_.shape == (9, 1)
+    assert model.masker_.mask_img_.shape == (9,)
     assert isinstance(model.masker_, SurfaceMasker)
 
 
@@ -2358,7 +2355,7 @@ def test_flm_with_surface_masker_with_mask(
     if surf_mask_dim == 1:
         assert model.masker_.mask_img_.shape == (9,)
     else:
-        assert model.masker_.mask_img_.shape == (9, 1)
+        assert model.masker_.mask_img_.shape == (9,)
     assert isinstance(model.masker_, SurfaceMasker)
 
 
diff --git a/nilearn/maskers/base_masker.py b/nilearn/maskers/base_masker.py
index 61c7de78d0e..7f0039f40f0 100644
--- a/nilearn/maskers/base_masker.py
+++ b/nilearn/maskers/base_masker.py
@@ -3,13 +3,14 @@
 import abc
 import contextlib
 import warnings
+from collections.abc import Iterable
 
 import numpy as np
 from joblib import Memory
 from sklearn.base import BaseEstimator, TransformerMixin
 from sklearn.utils.estimator_checks import check_is_fitted
 
-from nilearn._utils import repr_niimgs
+from nilearn._utils import logger
 from nilearn._utils.cache_mixin import CacheMixin, cache
 from nilearn._utils.docs import fill_doc
 from nilearn._utils.helpers import (
@@ -20,7 +21,8 @@
 from nilearn._utils.masker_validation import (
     check_compatibility_mask_and_images,
 )
-from nilearn._utils.niimg_conversions import check_niimg
+from nilearn._utils.niimg import repr_niimgs
+from nilearn._utils.niimg_conversions import check_niimg, check_niimg_3d
 from nilearn._utils.tags import SKLEARN_LT_1_6
 from nilearn.image import (
     concat_imgs,
@@ -28,8 +30,9 @@
     resample_img,
     smooth_img,
 )
-from nilearn.masking import unmask
+from nilearn.masking import load_mask_img, unmask
 from nilearn.signal import clean
+from nilearn.surface.utils import check_polymesh_equal
 
 
 def filter_and_extract(
@@ -248,6 +251,30 @@ def fit(self, imgs=None, y=None):
         """Present only to comply with sklearn estimators checks."""
         ...
 
+    def _load_mask(self, imgs):
+        """Load and validate mask if one passed at init.
+
+        Returns
+        -------
+        mask_img_ : None or 3D nifti
+        """
+        if self.mask_img is None:
+            return None
+
+        repr = repr_niimgs(self.mask_img, shorten=(not self.verbose))
+        msg = f"loading mask from {repr}"
+        log(msg=msg, verbose=self.verbose)
+
+        mask_img_ = check_niimg_3d(self.mask_img)
+
+        # Just check that the mask is valid
+        load_mask_img(mask_img_)
+
+        if imgs is not None:
+            check_compatibility_mask_and_images(self.mask_img, imgs)
+
+        return mask_img_
+
     @fill_doc
     def transform(self, imgs, confounds=None, sample_mask=None):
         """Apply mask, spatial and temporal preprocessing.
@@ -425,6 +452,45 @@ def __sklearn_tags__(self):
         )
         return tags
 
+    def _load_mask(self, imgs):
+        """Load and validate mask if one passed at init.
+
+        Returns
+        -------
+        mask_img_ : None or SurfaceImage
+        """
+        if self.mask_img is None:
+            return None
+
+        mask_img_ = self.mask_img
+
+        logger.log(
+            msg=f"loading mask from {mask_img_.__repr__()}",
+            verbose=self.verbose,
+        )
+
+        # squeeze the mask data if it is 2D and has a single column
+        for part in mask_img_.data.parts:
+            if (
+                mask_img_.data.parts[part].ndim == 2
+                and mask_img_.data.parts[part].shape[1] == 1
+            ):
+                mask_img_.data.parts[part] = np.squeeze(
+                    mask_img_.data.parts[part], axis=1
+                )
+
+        # Just check that the mask is valid
+        load_mask_img(mask_img_)
+
+        if imgs is not None:
+            check_compatibility_mask_and_images(mask_img_, imgs)
+            if not isinstance(imgs, Iterable):
+                imgs = [imgs]
+            for x in imgs:
+                check_polymesh_equal(mask_img_.mesh, x.mesh)
+
+        return mask_img_
+
     @rename_parameters(
         replacement_params={"img": "imgs"}, end_version="0.13.2"
     )
diff --git a/nilearn/maskers/nifti_labels_masker.py b/nilearn/maskers/nifti_labels_masker.py
index 5e23272ac28..1b4a27488b8 100644
--- a/nilearn/maskers/nifti_labels_masker.py
+++ b/nilearn/maskers/nifti_labels_masker.py
@@ -590,16 +590,7 @@ def fit(
                     for i, region_id in enumerate(initial_region_ids)
                 }
 
-        if self.mask_img is not None:
-            repr = _utils.repr_niimgs(
-                self.mask_img, shorten=(not self.verbose)
-            )
-            msg = f"loading data from {repr}"
-            logger.log(msg=msg, verbose=self.verbose)
-            self.mask_img_ = _utils.check_niimg_3d(self.mask_img)
-
-        else:
-            self.mask_img_ = None
+        self.mask_img_ = self._load_mask(imgs)
 
         # Check shapes and affines or resample.
         if self.mask_img_ is not None:
@@ -643,15 +634,15 @@ def fit(
                     force_resample=False,
                 )
 
+                # Just check that the mask is valid
+                load_mask_img(self.mask_img_)
+
             else:
                 raise ValueError(
                     "Invalid value for "
                     f"resampling_target: {self.resampling_target}"
                 )
 
-            # Just check that the mask is valid
-            load_mask_img(self.mask_img_)
-
         if not hasattr(self, "_resampled_labels_img_"):
             # obviates need to run .transform() before .inverse_transform()
             self._resampled_labels_img_ = self.labels_img_
diff --git a/nilearn/maskers/nifti_maps_masker.py b/nilearn/maskers/nifti_maps_masker.py
index f4bc352eee3..9c688e21102 100644
--- a/nilearn/maskers/nifti_maps_masker.py
+++ b/nilearn/maskers/nifti_maps_masker.py
@@ -429,57 +429,49 @@ def fit(
             ensure_finite=True,
         )
 
-        if self.mask_img is not None:
-            repr = _utils.repr_niimgs(
-                self.mask_img, shorten=(not self.verbose)
-            )
-            msg = f"loading mask from {repr}"
-            logger.log(msg=msg, verbose=self.verbose)
-            self.mask_img_ = _utils.check_niimg_3d(self.mask_img)
+        self.mask_img_ = self._load_mask(imgs)
+
+        if self.mask_img_ is not None:
+            # Check shapes and affines or resample.
+            if self.resampling_target is None:
+                _utils.niimg_conversions.check_same_fov(
+                    mask=self.mask_img_,
+                    maps=self.maps_img_,
+                    raise_error=True,
+                )
 
-            # Just check that the mask is valid
-            load_mask_img(self.mask_img_)
+            elif self.resampling_target == "mask":
+                logger.log("Resampling maps", self.verbose)
 
-        else:
-            self.mask_img_ = None
+                # TODO switch to force_resample=True
+                # when bumping to version > 0.13
+                self.maps_img_ = resample_img(
+                    self.maps_img_,
+                    target_affine=self.mask_img_.affine,
+                    target_shape=self.mask_img_.shape,
+                    interpolation="continuous",
+                    copy=True,
+                    copy_header=True,
+                    force_resample=False,
+                )
 
-        # Check shapes and affines or resample.
-        if self.resampling_target is None and self.mask_img_ is not None:
-            _utils.niimg_conversions.check_same_fov(
-                mask=self.mask_img_,
-                maps=self.maps_img_,
-                raise_error=True,
-            )
+            elif self.resampling_target == "maps":
+                logger.log("Resampling mask", self.verbose)
 
-        elif self.resampling_target == "mask" and self.mask_img_ is not None:
-            logger.log("Resampling maps", self.verbose)
-
-            # TODO switch to force_resample=True
-            # when bumping to version > 0.13
-            self.maps_img_ = resample_img(
-                self.maps_img_,
-                target_affine=self.mask_img_.affine,
-                target_shape=self.mask_img_.shape,
-                interpolation="continuous",
-                copy=True,
-                copy_header=True,
-                force_resample=False,
-            )
+                # TODO switch to force_resample=True
+                # when bumping to version > 0.13
+                self.mask_img_ = resample_img(
+                    self.mask_img_,
+                    target_affine=self.maps_img_.affine,
+                    target_shape=self.maps_img_.shape[:3],
+                    interpolation="nearest",
+                    copy=True,
+                    copy_header=True,
+                    force_resample=False,
+                )
 
-        elif self.resampling_target == "maps" and self.mask_img_ is not None:
-            logger.log("Resampling mask", self.verbose)
-
-            # TODO switch to force_resample=True
-            # when bumping to version > 0.13
-            self.mask_img_ = resample_img(
-                self.mask_img_,
-                target_affine=self.maps_img_.affine,
-                target_shape=self.maps_img_.shape[:3],
-                interpolation="nearest",
-                copy=True,
-                copy_header=True,
-                force_resample=False,
-            )
+                # Just check that the mask is valid
+                load_mask_img(self.mask_img_)
 
         if self.reports:
             self._reporting_data = {
diff --git a/nilearn/maskers/nifti_masker.py b/nilearn/maskers/nifti_masker.py
index 34d5f7c3235..98d63a284d7 100644
--- a/nilearn/maskers/nifti_masker.py
+++ b/nilearn/maskers/nifti_masker.py
@@ -458,8 +458,10 @@ def fit(
             verbose=self.verbose,
         )
 
+        self.mask_img_ = self._load_mask(imgs)
+
         # Compute the mask if not given by the user
-        if self.mask_img is None:
+        if self.mask_img_ is None:
             if imgs is None:
                 raise ValueError(
                     "Parameter 'imgs' must be provided to "
@@ -473,11 +475,6 @@ def fit(
             self.mask_img_ = self._cache(compute_mask, ignore=["verbose"])(
                 imgs, verbose=max(0, self.verbose - 1), **mask_args
             )
-        else:
-            self.mask_img_ = _utils.check_niimg_3d(self.mask_img)
-
-            # Just check that the mask is valid
-            load_mask_img(self.mask_img_)
 
         if self.reports:  # save inputs for reporting
             self._reporting_data = {
diff --git a/nilearn/maskers/nifti_spheres_masker.py b/nilearn/maskers/nifti_spheres_masker.py
index ea4f5d317b1..0c247b152d3 100644
--- a/nilearn/maskers/nifti_spheres_masker.py
+++ b/nilearn/maskers/nifti_spheres_masker.py
@@ -560,13 +560,7 @@ def fit(
             "native space.\n"
         )
 
-        if self.mask_img is not None:
-            self.mask_img_ = check_niimg_3d(self.mask_img)
-            # Just check that the mask is valid
-            load_mask_img(self.mask_img_)
-
-        else:
-            self.mask_img_ = None
+        self.mask_img_ = self._load_mask(imgs)
 
         if imgs is not None:
             if self.reports:
diff --git a/nilearn/maskers/surface_labels_masker.py b/nilearn/maskers/surface_labels_masker.py
index bfa2a3d9a7d..fb5fe25da9c 100644
--- a/nilearn/maskers/surface_labels_masker.py
+++ b/nilearn/maskers/surface_labels_masker.py
@@ -302,13 +302,10 @@ def fit(self, imgs=None, y=None):
 
         self.label_names_ = self.lut_.name.to_list()
 
-        if self.mask_img is not None:
-            if imgs is not None:
-                check_compatibility_mask_and_images(self.mask_img, imgs)
+        self.mask_img_ = self._load_mask(imgs)
+        if self.mask_img_ is not None:
             check_polymesh_equal(self.labels_img.mesh, self.mask_img.mesh)
 
-        self.mask_img_ = self.mask_img
-
         self._shelving = False
 
         if not self.reports:
diff --git a/nilearn/maskers/surface_maps_masker.py b/nilearn/maskers/surface_maps_masker.py
index a4a4dd9b685..0fc5a0cc368 100644
--- a/nilearn/maskers/surface_maps_masker.py
+++ b/nilearn/maskers/surface_maps_masker.py
@@ -183,30 +183,9 @@ def fit(self, imgs=None, y=None):
 
         self.n_elements_ = self.maps_img.shape[1]
 
-        if self.mask_img is not None:
-            if imgs is not None:
-                check_compatibility_mask_and_images(self.mask_img, imgs)
-            logger.log(
-                msg=f"loading regions from {self.mask_img.__repr__()}",
-                verbose=self.verbose,
-            )
-            check_polymesh_equal(self.maps_img.mesh, self.mask_img.mesh)
-            self.mask_img_ = self.mask_img
-            # squeeze the mask data if it is 2D and has a single column
-            for part in self.mask_img_.data.parts:
-                if (
-                    self.mask_img_.data.parts[part].ndim == 2
-                    and self.mask_img_.data.parts[part].shape[1] == 1
-                ):
-                    self.mask_img_.data.parts[part] = np.squeeze(
-                        self.mask_img_.data.parts[part], axis=1
-                    )
-            self.mask_img_.data._check_ndims(1, "mask_img")
-        else:
-            # TODO
-            # self.mask_img_ should be a SurfaceImage instance
-            # after fit
-            self.mask_img_ = None
+        self.mask_img_ = self._load_mask(imgs)
+        if self.mask_img_ is not None:
+            check_polymesh_equal(self.maps_img.mesh, self.mask_img_.mesh)
 
         self._shelving = False
 
diff --git a/nilearn/maskers/surface_masker.py b/nilearn/maskers/surface_masker.py
index ca2b66f5056..86ec47c5c7e 100644
--- a/nilearn/maskers/surface_masker.py
+++ b/nilearn/maskers/surface_masker.py
@@ -143,27 +143,22 @@ def _fit_mask_img(self, img):
         ----------
         img : SurfaceImage object or :obj:`list` of SurfaceImage or None
         """
-        if img is None:
-            if self.mask_img is None:
-                raise ValueError(
-                    "Please provide either a mask_img "
-                    "when initializing the masker "
-                    "or an img when calling fit()."
-                )
+        self.mask_img_ = self._load_mask(img)
 
-            self.mask_img_ = self.mask_img
+        if self.mask_img_ is not None:
             return
 
+        if img is None:
+            raise ValueError(
+                "Please provide either a mask_img "
+                "when initializing the masker "
+                "or an img when calling fit()."
+            )
+
         if not isinstance(img, list):
             img = [img]
         img = concat_imgs(img)
 
-        if self.mask_img is not None:
-            check_compatibility_mask_and_images(self.mask_img, img)
-            check_polymesh_equal(self.mask_img.mesh, img.mesh)
-            self.mask_img_ = self.mask_img
-            return
-
         # TODO: don't store a full array of 1 to mean "no masking"; use some
         # sentinel value
         mask_data = {
diff --git a/nilearn/maskers/tests/test_surface_masker.py b/nilearn/maskers/tests/test_surface_masker.py
index 38c4091175f..51ce5726171 100644
--- a/nilearn/maskers/tests/test_surface_masker.py
+++ b/nilearn/maskers/tests/test_surface_masker.py
@@ -46,29 +46,6 @@ def test_fit_list_surf_images_with_mask(surf_mask_1d, surf_img_2d):
     assert masker.mask_img_.shape == (surf_img_2d(1).shape[0],)
 
 
-# test with only one surface image and with 2 surface images (surface time
-# series)
-@pytest.mark.parametrize("shape", [1, 2])
-def test_mask_img_fit_shape_mismatch(
-    flip_surf_img, surf_mask_1d, surf_img_2d, shape
-):
-    """Fit fails when mismatch between mesh of mask \
-        and that of image to fit.
-    """
-    masker = SurfaceMasker(surf_mask_1d)
-    with pytest.raises(ValueError, match="Number of vertices"):
-        masker.fit(flip_surf_img(surf_img_2d(shape)))
-    masker.fit(surf_img_2d(shape))
-    assert_polydata_equal(surf_mask_1d.data, masker.mask_img_.data)
-
-
-def test_mask_img_fit_keys_mismatch(surf_mask_1d, drop_surf_img_part):
-    """Check fit fails if one hemisphere is missing."""
-    masker = SurfaceMasker(surf_mask_1d)
-    with pytest.raises(ValueError, match="key"):
-        masker.fit(drop_surf_img_part(surf_mask_1d))
-
-
 def test_none_mask_img(surf_mask_1d):
     """Check that masker can be instantiated with mask_img=None.
 
@@ -112,43 +89,6 @@ def test_inverse_transform_list_surf_images(
     assert img.shape == (surf_mask.mesh.n_vertices, 7)
 
 
-def test_unfitted_masker(surf_mask_1d):
-    """Check transform requires fit first."""
-    masker = SurfaceMasker(surf_mask_1d)
-    with pytest.raises(ValueError, match="fitted"):
-        masker.transform(surf_mask_1d)
-
-
-def test_check_is_fitted(surf_mask_1d):
-    """Check __sklearn_is_fitted__ method."""
-    masker = SurfaceMasker(surf_mask_1d)
-    assert not masker.__sklearn_is_fitted__()
-
-
-def test_mask_img_transform_shape_mismatch(
-    flip_surf_img, surf_img_1d, surf_mask_1d
-):
-    """Transform fails when mismatch between mesh of fitted mask \
-        and that of image to transform.
-    """
-    masker = SurfaceMasker(surf_mask_1d).fit()
-    with pytest.raises(ValueError, match="Number of vertices"):
-        masker.transform(flip_surf_img(surf_img_1d))
-    # non-flipped is ok
-    masker.transform(surf_img_1d)
-
-
-def test_mask_img_transform_keys_mismatch(
-    surf_mask_1d, surf_img_1d, drop_surf_img_part
-):
-    """Check transform fails if one hemisphere is missing."""
-    masker = SurfaceMasker(surf_mask_1d).fit()
-    with pytest.raises(ValueError, match="key"):
-        masker.transform(drop_surf_img_part(surf_img_1d))
-    # full img is ok
-    masker.transform(surf_img_1d)
-
-
 def test_error_inverse_transform_shape(surf_img_1d, surf_mask_1d, rng):
     """Check input shape of inverse transform."""
     masker = SurfaceMasker(surf_mask_1d).fit()
diff --git a/nilearn/regions/region_extractor.py b/nilearn/regions/region_extractor.py
index bc33dbadd92..bb3ca728f3f 100644
--- a/nilearn/regions/region_extractor.py
+++ b/nilearn/regions/region_extractor.py
@@ -17,9 +17,6 @@
 from nilearn._utils.helpers import (
     rename_parameters,
 )
-from nilearn._utils.masker_validation import (
-    check_compatibility_mask_and_images,
-)
 from nilearn._utils.ndimage import peak_local_max
 from nilearn._utils.niimg import safe_get_data
 from nilearn._utils.niimg_conversions import check_same_fov
@@ -33,7 +30,6 @@
 )
 from nilearn.image.resampling import resample_img
 from nilearn.maskers import NiftiMapsMasker
-from nilearn.masking import load_mask_img
 
 
 def _threshold_maps_ratio(maps_img, threshold):
@@ -457,12 +453,7 @@ def fit(
         check_params(self.__dict__)
         maps_img = check_niimg_4d(self.maps_img)
 
-        # Check mask
-        if self.mask_img is not None:
-            if imgs is not None:
-                check_compatibility_mask_and_images(self.mask_img, imgs)
-            self.mask_img = check_niimg_3d(self.mask_img)
-            load_mask_img(self.mask_img)
+        self._load_mask(imgs)
 
         list_of_strategies = ["ratio_n_voxels", "img_value", "percentile"]
         if self.thresholding_strategy not in list_of_strategies:


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.


<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**nilearn/maskers/tests/test_surface_masker.py:48**
* The removal of several negative test cases (e.g. shape and key mismatches) might reduce test coverage. Please ensure that the scenarios for mask image validation errors are covered elsewhere in the tests.
```
def test_mask_img_fit_shape_mismatch(
```
**nilearn/conftest.py:712**
* [nitpick] Review the renaming and reorganization of the test fixture functions to ensure that their names and usages are consistent throughout the test suite.
```
def _flip_surf_img_parts(poly_obj):
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-04-04T16:24:43Z
user: login: bthirion
body: Looks good overall.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T20:54:24Z
user: login: hndgzkn
body: I agree with Bertrand's comments, otherwise ok.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-07T13:15:18Z
user: login: man-shu
body: Plus some tests are failing as of now...
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T13:35:01Z
user: login: Remi-Gau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T08:26:42Z
user: login: Remi-Gau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T17:03:36Z
user: login: Remi-Gau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T17:04:15Z
user: login: Remi-Gau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-14T12:47:00Z
user: login: Remi-Gau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-14T12:47:12Z
user: login: bthirion
body: LGTM, thx.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-14T20:34:58Z
user: login: Remi-Gau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T13:29:19Z
user: login: Remi-Gau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T13:31:01Z
user: login: Remi-Gau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T13:31:11Z

Review comments:
diff_hunk: @@ -419,6 +446,45 @@ def __sklearn_tags__(self):
         )
         return tags
 
+    def _load_mask(self, imgs):
path: nilearn/maskers/base_masker.py
user: login: Copilot
body: There are two definitions of _load_mask in this file (one starting at line 243 and another at line 449). Consolidate them into a single implementation to avoid maintenance issues and potential conflicts.
created_at: 2025-04-04T16:24:42Z
author_association: NONE
diff_hunk: @@ -483,6 +490,18 @@ def check_masker_detrending(estimator):
     assert_raises(AssertionError, assert_array_equal, detrended_signal, signal)
 
 
+def check_masker_validation_mask(estimator):
+    """Raise error when mask_img is not binary."""
+    if accept_niimg_input(estimator):
+        mask_img = _img_3d_mni()
+    else:
+        mask_img = _make_surface_img()
+    estimator.mask_img = mask_img
+
+    with pytest.raises(ValueError, match="Given mask is not made of 2 values"):
+        estimator.fit()
path: nilearn/_utils/estimator_checks.py
user: login: bthirion
body: Is it obvious that any error would come from the mask not being binary ?
created_at: 2025-04-04T20:49:39Z
author_association: MEMBER
diff_hunk: @@ -419,6 +446,45 @@ def __sklearn_tags__(self):
         )
         return tags
 
+    def _load_mask(self, imgs):
+        """Load and validate mask if one passed at init.
+
+        Returns
+        -------
+        mask_img_ : None or 3D nifti
path: nilearn/maskers/base_masker.py
user: login: bthirion
body: Here it's a SurfaceImage
created_at: 2025-04-04T20:53:01Z
author_association: MEMBER
diff_hunk: @@ -174,30 +174,14 @@ def fit(self, img=None, y=None):
 
         self.n_elements_ = self.maps_img.shape[1]
 
-        if self.mask_img is not None:
-            if img is not None:
-                check_compatibility_mask_and_images(self.mask_img, img)
-            logger.log(
-                msg=f"loading regions from {self.mask_img.__repr__()}",
-                verbose=self.verbose,
-            )
-            check_polymesh_equal(self.maps_img.mesh, self.mask_img.mesh)
-            self.mask_img_ = self.mask_img
-            # squeeze the mask data if it is 2D and has a single column
-            for part in self.mask_img_.data.parts:
-                if (
-                    self.mask_img_.data.parts[part].ndim == 2
-                    and self.mask_img_.data.parts[part].shape[1] == 1
-                ):
-                    self.mask_img_.data.parts[part] = np.squeeze(
-                        self.mask_img_.data.parts[part], axis=1
-                    )
-            self.mask_img_.data._check_ndims(1, "mask_img")
-        else:
-            # TODO
-            # self.mask_img_ should be a SurfaceImage instance
-            # after fit
-            self.mask_img_ = None
+        self.mask_img_ = self._load_mask(img)
+
+        if self.mask_img_ is not None:
+            check_polymesh_equal(self.maps_img.mesh, self.mask_img_.mesh)
+
+        # TODO
+        # self.mask_img_ should be a SurfaceImage instance
+        # after fit
path: nilearn/maskers/surface_maps_masker.py
user: login: bthirion
body: to be removed I guess
created_at: 2025-04-04T20:54:04Z
author_association: MEMBER
diff_hunk: @@ -483,6 +490,18 @@ def check_masker_detrending(estimator):
     assert_raises(AssertionError, assert_array_equal, detrended_signal, signal)
 
 
+def check_masker_validation_mask(estimator):
+    """Raise error when mask_img is not binary."""
+    if accept_niimg_input(estimator):
+        mask_img = _img_3d_mni()
+    else:
+        mask_img = _make_surface_img()
+    estimator.mask_img = mask_img
+
+    with pytest.raises(ValueError, match="Given mask is not made of 2 values"):
+        estimator.fit()
path: nilearn/_utils/estimator_checks.py
user: login: Remi-Gau
body: this may be too strict but I believe that mask images that are passed should be 'binarisable' (as in all values should be evaluable as a truthy or falsy)

so probably wanna check non finite values (nan, inf...)

also having a check that the fitted mask (self.mask_img_) is a binary mask would make sense


created_at: 2025-04-09T08:26:42Z
author_association: COLLABORATOR
diff_hunk: @@ -174,30 +174,14 @@ def fit(self, img=None, y=None):
 
         self.n_elements_ = self.maps_img.shape[1]
 
-        if self.mask_img is not None:
-            if img is not None:
-                check_compatibility_mask_and_images(self.mask_img, img)
-            logger.log(
-                msg=f"loading regions from {self.mask_img.__repr__()}",
-                verbose=self.verbose,
-            )
-            check_polymesh_equal(self.maps_img.mesh, self.mask_img.mesh)
-            self.mask_img_ = self.mask_img
-            # squeeze the mask data if it is 2D and has a single column
-            for part in self.mask_img_.data.parts:
-                if (
-                    self.mask_img_.data.parts[part].ndim == 2
-                    and self.mask_img_.data.parts[part].shape[1] == 1
-                ):
-                    self.mask_img_.data.parts[part] = np.squeeze(
-                        self.mask_img_.data.parts[part], axis=1
-                    )
-            self.mask_img_.data._check_ndims(1, "mask_img")
-        else:
-            # TODO
-            # self.mask_img_ should be a SurfaceImage instance
-            # after fit
-            self.mask_img_ = None
+        self.mask_img_ = self._load_mask(img)
+
+        if self.mask_img_ is not None:
+            check_polymesh_equal(self.maps_img.mesh, self.mask_img_.mesh)
+
+        # TODO
+        # self.mask_img_ should be a SurfaceImage instance
+        # after fit
path: nilearn/maskers/surface_maps_masker.py
user: login: Remi-Gau
body: adding a general check for all maskers on this
created_at: 2025-04-09T17:03:35Z
author_association: COLLABORATOR
diff_hunk: @@ -419,6 +446,45 @@ def __sklearn_tags__(self):
         )
         return tags
 
+    def _load_mask(self, imgs):
+        """Load and validate mask if one passed at init.
+
+        Returns
+        -------
+        mask_img_ : None or 3D nifti
path: nilearn/maskers/base_masker.py
user: login: Remi-Gau
body: yup
created_at: 2025-04-09T17:04:12Z
author_association: COLLABORATOR
diff_hunk: @@ -591,39 +612,6 @@ def check_masker_transformer_high_variance_confounds(estimator):
     assert_raises(AssertionError, assert_array_equal, signal_1, signal_2)
 
 
-def check_multi_masker_transformer_sample_mask(estimator):
path: nilearn/_utils/estimator_checks.py
user: login: Remi-Gau
body: moved down in the file
created_at: 2025-04-14T12:47:00Z
author_association: COLLABORATOR
diff_hunk: @@ -677,38 +665,6 @@ def check_masker_transformer_sample_mask(estimator):
     assert_array_equal(signal_2, signal_5)
 
 
-def check_multi_masker_with_confounds(estimator):
path: nilearn/_utils/estimator_checks.py
user: login: Remi-Gau
body: moved down in the file
created_at: 2025-04-14T12:47:11Z
author_association: COLLABORATOR
diff_hunk: @@ -483,6 +490,18 @@ def check_masker_detrending(estimator):
     assert_raises(AssertionError, assert_array_equal, detrended_signal, signal)
 
 
+def check_masker_validation_mask(estimator):
+    """Raise error when mask_img is not binary."""
+    if accept_niimg_input(estimator):
+        mask_img = _img_3d_mni()
+    else:
+        mask_img = _make_surface_img()
+    estimator.mask_img = mask_img
+
+    with pytest.raises(ValueError, match="Given mask is not made of 2 values"):
+        estimator.fit()
path: nilearn/_utils/estimator_checks.py
user: login: Remi-Gau
body: > this may be too strict but I believe that mask images that are passed should be 'binarisable' (as in all values should be evaluable as a truthy or falsy)
> 
> so probably wanna check non finite values (nan, inf...)
> 
> also having a check that the fitted mask (self.mask_img_) is a binary mask would make sense

Will do this in a follow up PR: this one had grown too big
created_at: 2025-04-15T13:29:19Z
author_association: COLLABORATOR
diff_hunk: @@ -810,53 +764,187 @@ def check_masker_refit(estimator):
             assert_surface_image_equal(fitted_mask_1, fitted_mask_2)
 
 
-# ------------------ SURFACE MASKER CHECKS ------------------
+def check_masker_fit_with_mask_too_many_samples(estimator):
+    """Check mask with too many sample cannot be used with maskers.
 
+    - 4D masks not allowed for nifti maskers
+    - 2D masks not allowed for surface maskers
+    """
+    if accept_niimg_input(estimator):
+        mask_img = _img_4d_zeros()
+        imgs = [_img_3d_rand()]
+        msg = "Expected dimension is 3D"
+        error = DimensionError
+    else:
+        mask_img = _make_surface_img(2)
+        imgs = _make_surface_img(1)
+        msg = "Data for each part of img should be 1D."
+        error = ValueError
+
+    estimator.mask_img = mask_img
+    with pytest.raises(error, match=msg):
+        estimator.fit(imgs)
 
-def check_surface_masker_fit_returns_self(estimator):
-    """Check surface maskers return itself after fit."""
-    assert estimator.fit(_make_surface_img(10)) is estimator
 
+def check_masker_fit_with_empty_mask(estimator):
+    """Check mask that excludes all voxels raise an error."""
+    if accept_niimg_input(estimator):
+        mask_img = _img_3d_zeros()
+        imgs = [_img_3d_rand()]
+    else:
+        mask_img = _make_surface_mask()
+        for k, v in mask_img.data.parts.items():
+            mask_img.data.parts[k] = np.zeros(v.shape)
+        imgs = _make_surface_img(1)
 
-def check_surface_masker_smooth(estimator):
path: nilearn/_utils/estimator_checks.py
user: login: Remi-Gau
body: merged with the equivalent check for nifti maskers 
created_at: 2025-04-15T13:31:01Z
author_association: COLLABORATOR
diff_hunk: @@ -810,53 +764,187 @@ def check_masker_refit(estimator):
             assert_surface_image_equal(fitted_mask_1, fitted_mask_2)
 
 
-# ------------------ SURFACE MASKER CHECKS ------------------
+def check_masker_fit_with_mask_too_many_samples(estimator):
+    """Check mask with too many sample cannot be used with maskers.
 
+    - 4D masks not allowed for nifti maskers
+    - 2D masks not allowed for surface maskers
+    """
+    if accept_niimg_input(estimator):
+        mask_img = _img_4d_zeros()
+        imgs = [_img_3d_rand()]
+        msg = "Expected dimension is 3D"
+        error = DimensionError
+    else:
+        mask_img = _make_surface_img(2)
+        imgs = _make_surface_img(1)
+        msg = "Data for each part of img should be 1D."
+        error = ValueError
+
+    estimator.mask_img = mask_img
+    with pytest.raises(error, match=msg):
+        estimator.fit(imgs)
 
-def check_surface_masker_fit_returns_self(estimator):
path: nilearn/_utils/estimator_checks.py
user: login: Remi-Gau
body: merged with the equivalent check for nifti maskers
created_at: 2025-04-15T13:31:10Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-04T08:42:30Z
body: 👋 @Remi-Gau Thanks for creating a PR!

Until this PR is ready for review, you can include the [WIP] tag in its title, or leave it as a github draft.

Please make sure it is compliant with our [contributing guidelines](https://nilearn.github.io/stable/development.html#contribution-guidelines). In particular, be sure it checks the boxes listed below.
- [ ] PR has an interpretable title.
- [ ] PR links to Github issue with mention `Closes #XXXX` (see our documentation on [PR structure](https://nilearn.github.io/stable/development.html#pr-structure))
- [ ] Code is PEP8-compliant (see our documentation on [coding style](https://nilearn.github.io/stable/development.html#coding-style))
- [ ] Changelog or what's new entry in `doc/changes/latest.rst` (see our documentation on [PR structure](https://nilearn.github.io/stable/development.html#pr-structure))

For new features:
- [ ] There is at least one unit test per new function / class (see our documentation on [testing](https://nilearn.github.io/stable/development.html#tests))
- [ ] The new feature is demoed in at least one relevant example.

For bug fixes:
- [ ] There is at least one test that would fail under the original bug conditions.

We will review it as quick as possible, feel free to ping us with questions if needed.
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-04-09T17:29:30Z
body: ## [Codecov](https://app.codecov.io/gh/nilearn/nilearn/pull/5317?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 96.88%. Comparing base [(`0cfae05`)](https://app.codecov.io/gh/nilearn/nilearn/commit/0cfae05865794fd308789adc3a0b24a890311a7a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) to head [(`5dd2c93`)](https://app.codecov.io/gh/nilearn/nilearn/commit/5dd2c937309604319f675e732254bf454876288b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn).
> Report is 1 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main    #5317   +/-   ##
=======================================
  Coverage   96.88%   96.88%           
=======================================
  Files         280      280           
  Lines       38452    38462   +10     
  Branches     3802     3809    +7     
=======================================
+ Hits        37254    37264   +10     
  Misses        659      659           
  Partials      539      539           
```

| [Flag](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | Coverage Δ | |
|---|---|---|
| [macos-latest_3.10_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.56% <100.00%> (+<0.01%)` | :arrow_up: |
| [macos-latest_3.11_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.56% <100.00%> (+<0.01%)` | :arrow_up: |
| [macos-latest_3.12_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.56% <100.00%> (+<0.01%)` | :arrow_up: |
| [macos-latest_3.13_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.56% <100.00%> (+<0.01%)` | :arrow_up: |
| [macos-latest_3.9_min](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `77.60% <99.48%> (+<0.01%)` | :arrow_up: |
| [macos-latest_3.9_plot_min](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.14% <100.00%> (+<0.01%)` | :arrow_up: |
| [macos-latest_3.9_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.55% <100.00%> (+<0.01%)` | :arrow_up: |
| [ubuntu-latest_3.10_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.58% <100.00%> (+<0.01%)` | :arrow_up: |
| [ubuntu-latest_3.11_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.58% <100.00%> (+<0.01%)` | :arrow_up: |
| [ubuntu-latest_3.12_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.58% <100.00%> (+<0.01%)` | :arrow_up: |
| [ubuntu-latest_3.13_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.58% <100.00%> (+<0.01%)` | :arrow_up: |
| [ubuntu-latest_3.13_pre](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.58% <100.00%> (+<0.01%)` | :arrow_up: |
| [ubuntu-latest_3.9_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.57% <100.00%> (+<0.01%)` | :arrow_up: |
| [windows-latest_3.10_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.56% <100.00%> (+<0.01%)` | :arrow_up: |
| [windows-latest_3.11_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.56% <100.00%> (+<0.01%)` | :arrow_up: |
| [windows-latest_3.12_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.56% <100.00%> (+<0.01%)` | :arrow_up: |
| [windows-latest_3.13_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.56% <100.00%> (+<0.01%)` | :arrow_up: |
| [windows-latest_3.9_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5317/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.55% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/nilearn/nilearn/pull/5317?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE