Repository: nilearn/nilearn
Pull request #5301: [ENH] compute mean of means for list of surface images
Author: login: Remi-Gau
Created: 2025-04-01T11:44:28Z | Merged: 2025-04-02T08:33:54Z

<!---
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 #5288

<!-- 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:

- ensure mean_img can work on iterable of surface images 



Linked issue #5288: make sure nilearn.image.mean_image can handle list of 2D of surface images
should do mean of each 2D surface image and then do the mean of means

Diff:
diff --git a/doc/changes/latest.rst b/doc/changes/latest.rst
index acc8b3701c7..b1e3f463dd0 100644
--- a/doc/changes/latest.rst
+++ b/doc/changes/latest.rst
@@ -34,6 +34,10 @@ Fixes
 Enhancements
 ------------
 
+
+
+- :bdg-dark:`Code` Add surface support for :func:`~nilearn.image.new_img_like`, :func:`~nilearn.image.index_img`, :func:`~nilearn.image.mean_img`, :func:`~nilearn.image.concat_imgs`, :func:`~nilearn.image.iter_imgs` (:gh:`5224`, :gh:`5301` by `Rémi Gau`_).
+
 - :bdg-dark:`Code` :func:`~nilearn.interfaces.bids.save_glm_to_bids` can infer the naming scheme for output files from the input files passed at fit time to :class:`~nilearn.glm.first_level.FirstLevelModel`. It will also now save the GLM mask to disk (:gh:`5256`, :gh:`5279` by `Rémi Gau`_).
 
 - :bdg-dark:`Code` Add a ``timeout`` parameter to the neurovault fetching functions (:gh:`5268` by `Rémi Gau`_).
diff --git a/nilearn/image/image.py b/nilearn/image/image.py
index 4e38cacabfb..cf37aad8ba2 100644
--- a/nilearn/image/image.py
+++ b/nilearn/image/image.py
@@ -499,6 +499,18 @@ def compute_mean(imgs, target_affine=None, target_shape=None, smooth=False):
     return mean_data, affine
 
 
+def _compute_surface_mean(imgs: SurfaceImage) -> SurfaceImage:
+    """Compute mean of a single surface image over its 2nd dimension."""
+    if len(imgs.shape) < 2 or imgs.shape[1] < 2:
+        data = imgs.data
+    else:
+        data = {
+            part: np.mean(value, axis=1).astype(float)
+            for part, value in imgs.data.parts.items()
+        }
+    return new_img_like(imgs, data=data)
+
+
 @fill_doc
 def mean_img(
     imgs,
@@ -520,9 +532,10 @@ def mean_img(
 
     Parameters
     ----------
-    imgs : Niimg-like object or iterable of Niimg-like objects, \
-           or :obj:`~nilearn.surface.SurfaceImage`.
-        Images to be averaged over time (see :ref:`extracting_data`
+    imgs : Niimg-like or or :obj:`~nilearn.surface.SurfaceImage` object, or \
+           iterable of Niimg-like or :obj:`~nilearn.surface.SurfaceImage`.
+        Images to be averaged over 'time'
+        (see :ref:`extracting_data`
         for a detailed description of the valid input types).
 
     %(target_affine)s
@@ -556,15 +569,15 @@ def mean_img(
     nilearn.image.math_img : For more general operations on images.
 
     """
-    if isinstance(imgs, SurfaceImage):
-        if len(imgs.shape) < 2 or imgs.shape[1] < 2:
-            data = imgs.data
-        else:
-            data = {
-                part: np.mean(value, axis=1).astype(float)
-                for part, value in imgs.data.parts.items()
-            }
-        return new_img_like(imgs, data=data)
+    is_iterable = isinstance(imgs, collections.abc.Iterable)
+    is_surface_img = isinstance(imgs, SurfaceImage) or (
+        is_iterable and all(isinstance(x, SurfaceImage) for x in imgs)
+    )
+    if is_surface_img:
+        if not is_iterable:
+            imgs = [imgs]
+        all_means = concat_imgs([_compute_surface_mean(x) for x in imgs])
+        return _compute_surface_mean(all_means)
 
     # TODO: remove this warning in 0.13.0
     check_copy_header(copy_header)
@@ -573,9 +586,7 @@ def mean_img(
     is_str = isinstance(imgs, str)
     is_iterable = isinstance(imgs, collections.abc.Iterable)
     if is_str or not is_iterable:
-        imgs = [
-            imgs,
-        ]
+        imgs = [imgs]
 
     imgs_iter = iter(imgs)
     first_img = check_niimg(next(imgs_iter))
diff --git a/nilearn/image/tests/test_image.py b/nilearn/image/tests/test_image.py
index 043ea9d457b..b9d48758fa8 100644
--- a/nilearn/image/tests/test_image.py
+++ b/nilearn/image/tests/test_image.py
@@ -567,6 +567,21 @@ def test_mean_img_surface(surf_img_1d, surf_img_2d):
     assert img.shape == (img.mesh.n_vertices,)
 
 
+def test_mean_img_surface_list(surf_img_2d):
+    """Check that mean_img computes mean of mean."""
+    surf_img_1 = surf_img_2d(2)
+    surf_img_2 = surf_img_2d(3)
+
+    mean_surf_img_1 = mean_img(surf_img_1)
+    mean_surf_img_2 = mean_img(surf_img_2)
+
+    direct_mean = mean_img([surf_img_1, surf_img_2])
+
+    assert_surface_image_equal(
+        direct_mean, mean_img([mean_surf_img_1, mean_surf_img_2])
+    )
+
+
 def test_swap_img_hemispheres(affine_eye, shape_3d_default, rng):
     # make sure input image data is not overwritten inside function
     data = rng.standard_normal(size=shape_3d_default)


Reviews:
user: login: Remi-Gau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T11:49:56Z
user: login: bthirion
body: Maybe add a whatsnew entry ?
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-01T16:23:20Z
user: login: hndgzkn
body: A part from one suggestion, seems ok to me.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-02T08:10:54Z
user: login: Remi-Gau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T08:30:36Z
user: login: hndgzkn
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T08:46:40Z
user: login: man-shu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T10:22:14Z
user: login: hndgzkn
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T12:48:01Z

Review comments:
diff_hunk: @@ -556,15 +569,15 @@ def mean_img(
     nilearn.image.math_img : For more general operations on images.
 
     """
-    if isinstance(imgs, SurfaceImage):
-        if len(imgs.shape) < 2 or imgs.shape[1] < 2:
-            data = imgs.data
-        else:
-            data = {
-                part: np.mean(value, axis=1).astype(float)
-                for part, value in imgs.data.parts.items()
-            }
-        return new_img_like(imgs, data=data)
+    is_iterable = isinstance(imgs, collections.abc.Iterable)
+    is_surface_img = isinstance(imgs, SurfaceImage) or (
+        is_iterable and all(isinstance(x, SurfaceImage) for x in imgs)
+    )
+    if is_surface_img:
+        if not is_iterable:
+            imgs = [imgs]
+        all_means = concat_imgs([compute_surface_mean(x) for x in imgs])
+        return compute_surface_mean(all_means)
path: nilearn/image/image.py
user: login: Remi-Gau
body: note that this does not parallelize things like it is done for volumes with joblib

but I'd say let's make it work first and then let's make it fast
created_at: 2025-04-01T11:49:56Z
author_association: COLLABORATOR
diff_hunk: @@ -499,6 +499,18 @@ def compute_mean(imgs, target_affine=None, target_shape=None, smooth=False):
     return mean_data, affine
 
 
+def _compute_surface_mean(imgs: SurfaceImage) -> SurfaceImage:
+    """Compute mean of a single surface image over its 2nd dimension."""
+    if len(imgs.shape) < 2 or imgs.shape[1] < 2:
+        data = imgs.data
+    else:
+        data = {
+            part: np.mean(value, axis=1).astype(float)
+            for part, value in imgs.data.parts.items()
+        }
+    return new_img_like(imgs, data=data)
+
+
path: nilearn/image/image.py
user: login: hndgzkn
body: As this one only computes mean of a single SurfaceImage, isn't it possible to implement it as a method `get_mean` or `mean` in SurfaceImage, ie call it like `imgs.get_mean()` or `imgs.mean()`?
created_at: 2025-04-02T08:03:22Z
author_association: COLLABORATOR
diff_hunk: @@ -499,6 +499,18 @@ def compute_mean(imgs, target_affine=None, target_shape=None, smooth=False):
     return mean_data, affine
 
 
+def _compute_surface_mean(imgs: SurfaceImage) -> SurfaceImage:
+    """Compute mean of a single surface image over its 2nd dimension."""
+    if len(imgs.shape) < 2 or imgs.shape[1] < 2:
+        data = imgs.data
+    else:
+        data = {
+            part: np.mean(value, axis=1).astype(float)
+            for part, value in imgs.data.parts.items()
+        }
+    return new_img_like(imgs, data=data)
+
+
path: nilearn/image/image.py
user: login: Remi-Gau
body: I think we have tried to avoid making our classes 'too smart' and similar to what the  NiftiImage does at the moment.
created_at: 2025-04-02T08:30:36Z
author_association: COLLABORATOR
diff_hunk: @@ -499,6 +499,18 @@ def compute_mean(imgs, target_affine=None, target_shape=None, smooth=False):
     return mean_data, affine
 
 
+def _compute_surface_mean(imgs: SurfaceImage) -> SurfaceImage:
+    """Compute mean of a single surface image over its 2nd dimension."""
+    if len(imgs.shape) < 2 or imgs.shape[1] < 2:
+        data = imgs.data
+    else:
+        data = {
+            part: np.mean(value, axis=1).astype(float)
+            for part, value in imgs.data.parts.items()
+        }
+    return new_img_like(imgs, data=data)
+
+
path: nilearn/image/image.py
user: login: hndgzkn
body: I understand, but it seems very natural to me that an image calculates its own mean when it has all necessary info. 
I don't know if we might need this mean calculation elsewhere, but putting this function in `nilearn.image.image` feels misplaced.
created_at: 2025-04-02T08:46:40Z
author_association: COLLABORATOR
diff_hunk: @@ -499,6 +499,18 @@ def compute_mean(imgs, target_affine=None, target_shape=None, smooth=False):
     return mean_data, affine
 
 
+def _compute_surface_mean(imgs: SurfaceImage) -> SurfaceImage:
+    """Compute mean of a single surface image over its 2nd dimension."""
+    if len(imgs.shape) < 2 or imgs.shape[1] < 2:
+        data = imgs.data
+    else:
+        data = {
+            part: np.mean(value, axis=1).astype(float)
+            for part, value in imgs.data.parts.items()
+        }
+    return new_img_like(imgs, data=data)
+
+
path: nilearn/image/image.py
user: login: man-shu
body: Another relevant point might be that we have depended on nibabel for our NiftiImage needs. So we just work with NiftiImage as it comes from nibabel and simply provide manipulation operations under nilearn.image.

For SurfaceImage we have had to create our own data structure/object.

So, the question is whether we want to align SurfacImage more closely to the NiftiImage model or optimize our SurfaceImage objects independently.

I think I would go with the former (but just because it's more consistent).
created_at: 2025-04-02T10:22:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -499,6 +499,18 @@ def compute_mean(imgs, target_affine=None, target_shape=None, smooth=False):
     return mean_data, affine
 
 
+def _compute_surface_mean(imgs: SurfaceImage) -> SurfaceImage:
+    """Compute mean of a single surface image over its 2nd dimension."""
+    if len(imgs.shape) < 2 or imgs.shape[1] < 2:
+        data = imgs.data
+    else:
+        data = {
+            part: np.mean(value, axis=1).astype(float)
+            for part, value in imgs.data.parts.items()
+        }
+    return new_img_like(imgs, data=data)
+
+
path: nilearn/image/image.py
user: login: hndgzkn
body: I see your point. I've checked the code for `Nifti1Image` and `Nifti2Image`. `Nifti1Image` extends all the following in several explicitly or implicitly:

-  `Nifti1Pair`
-  `analyze.AnalyzeImage`
- `SpatialImage`
- `DataobjImage`
- `FileBasedImage`
- `SerializableImage`

Each of them provides many additional methods. For example `get_data`, although deprecated, is one of them. And it is replaced with `get_fdata`. 

I think it is a good idea to keep consistent. However, whenever by nature of the object, we might need additional functionality, we might rethink if we should really stick to consistency.

The current solution creates many functions detached from its object, and creates cyclic import problems as well. 

It seems reasonable to define functions that could be used outside of any instance of a class, independent of the class data. But if a function only uses the data of a class, and meaningful only in that context, we might think of other solutions.

created_at: 2025-04-02T12:48:01Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-01T11:51:23Z
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-01T13:48:25Z
body: ## [Codecov](https://app.codecov.io/gh/nilearn/nilearn/pull/5301?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 [(`3fec6f2`)](https://app.codecov.io/gh/nilearn/nilearn/commit/3fec6f29031c97122943a61846d3e8ebabf24dfc?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) to head [(`ec23842`)](https://app.codecov.io/gh/nilearn/nilearn/commit/ec238426b3d49cdd3303f50f5f56d698ec80b8a2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn).
> Report is 3 commits behind head on main.

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


```diff
@@            Coverage Diff             @@
##             main    #5301      +/-   ##
==========================================
- Coverage   96.89%   96.88%   -0.01%     
==========================================
  Files         276      276              
  Lines       38221    38268      +47     
  Branches     3789     3794       +5     
==========================================
+ Hits        37034    37076      +42     
- Misses        644      648       +4     
- Partials      543      544       +1     
```

| [Flag](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/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/5301/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_down: |
| [macos-latest_3.11_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/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_down: |
| [macos-latest_3.12_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/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_down: |
| [macos-latest_3.13_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/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_down: |
| [macos-latest_3.9_min](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `77.65% <100.00%> (+0.01%)` | :arrow_up: |
| [macos-latest_3.9_plot_min](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.36% <100.00%> (-0.01%)` | :arrow_down: |
| [macos-latest_3.9_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/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_down: |
| [ubuntu-latest_3.10_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.60% <100.00%> (-0.01%)` | :arrow_down: |
| [ubuntu-latest_3.11_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.60% <100.00%> (-0.01%)` | :arrow_down: |
| [ubuntu-latest_3.12_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.60% <100.00%> (-0.01%)` | :arrow_down: |
| [ubuntu-latest_3.13_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.60% <100.00%> (-0.01%)` | :arrow_down: |
| [ubuntu-latest_3.13_pre](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.60% <100.00%> (-0.01%)` | :arrow_down: |
| [ubuntu-latest_3.9_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | `95.59% <100.00%> (-0.01%)` | :arrow_down: |
| [windows-latest_3.10_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/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_down: |
| [windows-latest_3.11_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/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_down: |
| [windows-latest_3.12_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/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_down: |
| [windows-latest_3.13_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/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_down: |
| [windows-latest_3.9_plotting](https://app.codecov.io/gh/nilearn/nilearn/pull/5301/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_down: |

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/5301?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
user: login: Remi-Gau
created_at: 2025-04-02T07:48:26Z
body: > Maybe add a whatsnew entry ?

yup
- also because there was no entry for https://github.com/nilearn/nilearn/pull/5224/files1
author_association: COLLABORATOR