Repository: nilearn/nilearn

Issue:
Shift centers of mass outside of clusters to closest in-cluster voxel in get_clusters_table
<!--Provide a brief description what you would like changes and why.-->
When presented with statistical maps with cluster-level statistics (i.e., one statistic across each cluster), `get_clusters_table` will identify the center of mass of the cluster. Sometimes, this center of mass will end up outside of the actual cluster, which currently leads to a warning and a NaN in the "Peak Stat" column. While the identified coordinate is the actual center of mass, it seems like it would be more useful to users if the function automatically found the closest voxel _in_ the cluster and reported that instead.

<!--Please fill in the following information, to the best of your ability.-->
### Benefits to the change
Better support for cluster-level corrected statistical maps.

### Pseudocode for the new behavior, if applicable

The enhancement would sometimes change the values in the table, but the function signature would remain the same.


Discussion:
No strong opinion on this.
My impression is that it makes the output more meaningful, but the behaviror of the function is slighlty more complex to explain.
There may be more elegant ways to identify centers of mass of irregular shapes like clusters too, but I wasn't aware of any.
Hi there, I'm a fan of nilearn and I'm interested in contributing!

Maybe you could consider a "constrained" center of mass? Whereas the typical center of mass minimizes the sum of squared distances to the cluster points over the full space, the "constrained" center of mass would minimize just over the cluster points.

This is similar but not quite the same as the "projected" center of mass that @tsalo suggested. Not totally clear to me whether this is more or less intuitive. But could be worth considering.

```python
def constrained_center_of_mass(points, weight=None):
    """
    Find the center of mass of a set of points, constrained to the set of points.
    
    Args:
        points: array of points, shape (n, d)
        weight: optional array of point weights, shape (n,)
    
    Returns:
        The constrained center of mass, shape (d,)
    """
    if weight is None:
        weight = np.ones(len(points))

    loss = np.sum(weight * cdist(points, points, metric="seuclidean"), axis=1)
    ccom = points[np.argmin(loss)]
    return ccom
```
@clane9 that sounds great to me!
Cool! Probably worth noting though that the projected center is more efficient to compute. (O(n) vs O(n^2), at least for this naive implementation of the constrained center.)
The problem is that  if there is a large number of voxels (10^5) you form a 10^5 * 10^5 matrix.
I would compute the standard barycenter, and then take the point that is closer to it. This is actually equivalent if I'm not mistaken.
Best,
Ah, I think you are right they are equivalent. All the better.

I thought they would not be, since projecting an unconstrained solution does not give you a constrained solution in general. But because the center of mass "objective" is a very simple quadratic, the level sets are spheres and you get that the closest point to the barycenter is also the constrained barycenter.

If you think it's worth implementing, I'd be happy to open a PR. But I'd defer to you @tsalo if you want to work on it since it's your issue.
@clane9 I don't really have time to work on this at the moment, so it would be great if you are able to open a PR. I'll be happy to review it though.

Resolving patch:
diff --git a/nilearn/reporting/_get_clusters_table.py b/nilearn/reporting/_get_clusters_table.py
--- a/nilearn/reporting/_get_clusters_table.py
+++ b/nilearn/reporting/_get_clusters_table.py
@@ -73,40 +73,76 @@ def _identify_subpeaks(data):
     -----
     When a cluster's local maximum corresponds to contiguous voxels with the
     same values (as in a binary cluster), this function determines the center
-    of mass for those voxels.
+    of mass for those voxels. If the center of mass falls outside the cluster,
+    we instead report the nearest cluster voxel.
     """
-    # Initial identification of subpeaks with minimal minimum distance
     data_max = maximum_filter(data, 3)
     maxima = data == data_max
-    data_min = minimum_filter(data, 3)
-    diff = (data_max - data_min) > 0
-    maxima[diff == 0] = 0
+    zero_mask = data == 0
+    maxima[zero_mask] = 0
+
+    # Don't treat constant patches as maxima unless the entire cluster is
+    # constant (as in a binary cluster).
+    is_constant = np.isclose(data[~zero_mask].max(), data[~zero_mask].min())
+    if not is_constant:
+        data_min = minimum_filter(data, 3)
+        diff = (data_max - data_min) > 0
+        maxima[diff == 0] = 0
 
     labeled, n_subpeaks = label(maxima)
-    labels_index = range(1, n_subpeaks + 1)
+    labels_index = np.arange(1, n_subpeaks + 1)
     ijk = np.array(center_of_mass(data, labeled, labels_index))
     ijk = np.round(ijk).astype(int)
-    vals = np.apply_along_axis(
-        arr=ijk, axis=1, func1d=_get_val, input_arr=data
-    )
     # Determine if all subpeaks are within the cluster
     # They may not be if the cluster is binary and has a shape where the COM is
     # outside the cluster, like a donut.
-    cluster_idx = np.vstack(np.where(labeled)).T.tolist()
-    subpeaks_outside_cluster = [
-        i
-        for i, peak_idx in enumerate(ijk.tolist())
-        if peak_idx not in cluster_idx
-    ]
-    vals[subpeaks_outside_cluster] = np.nan
-    if subpeaks_outside_cluster:
+    subpeaks_outside_cluster = (
+        labeled[ijk[:, 0], ijk[:, 1], ijk[:, 2]] != labels_index
+    )
+    if np.any(subpeaks_outside_cluster):
         warnings.warn(
             "Attention: At least one of the (sub)peaks falls outside of the "
-            "cluster body."
+            "cluster body. Identifying the nearest in-cluster voxel."
         )
+        # Replace centers of mass with their nearest neighbor points in the
+        # corresponding clusters. Note this is also equivalent to computing the
+        # centers of mass constrained to points within the cluster.
+        ijk[subpeaks_outside_cluster] = _cluster_nearest_neighbor(
+            ijk[subpeaks_outside_cluster],
+            labels_index[subpeaks_outside_cluster],
+            labeled
+        )
+    vals = data[ijk[:, 0], ijk[:, 1], ijk[:, 2]]
     return ijk, vals
 
 
+def _cluster_nearest_neighbor(ijk, labels_index, labeled):
+    """Find the nearest neighbor for given points in the corresponding cluster.
+
+    Parameters
+    ----------
+    ijk : :obj:`numpy.ndarray`
+        (n_pts, 3) array of query points.
+    labels_index : :obj:`numpy.ndarray`
+        (n_pts,) array of corresponding cluster indices.
+    labeled : :obj:`numpy.ndarray`
+        3D array with voxels labeled according to cluster index.
+
+    Returns
+    -------
+    nbrs : :obj:`numpy.ndarray`
+        (n_pts, 3) nearest neighbor points.
+    """
+    labels = labeled[labeled > 0]
+    clusters_ijk = np.array(labeled.nonzero()).T
+    nbrs = np.zeros_like(ijk)
+    for ii, (lab, point) in enumerate(zip(labels_index, ijk)):
+        lab_ijk = clusters_ijk[labels == lab]
+        dist = np.linalg.norm(lab_ijk - point, axis=1)
+        nbrs[ii] = lab_ijk[np.argmin(dist)]
+    return nbrs
+
+
 def _sort_subpeaks(ijk, vals, affine):
     """Sort subpeaks in cluster in descending order of stat value.
 
@@ -169,25 +205,6 @@ def _pare_subpeaks(xyz, ijk, vals, min_distance):
     return ijk, vals
 
 
-def _get_val(row, input_arr):
-    """Extract values from array based on index.
-
-    Parameters
-    ----------
-    row : :obj:`tuple` of length 3
-        3-length index into ``input_arr``.
-    input_arr : 3D :obj:`numpy.ndarray`
-        Array from which to extract value.
-
-    Returns
-    -------
-    :obj:`float` or :obj:`int`
-        The value from ``input_arr`` at the row index.
-    """
-    i, j, k = row
-    return input_arr[i, j, k]
-
-
 def get_clusters_table(stat_img, stat_threshold, cluster_threshold=None,
                        two_sided=False, min_distance=8.):
     """Creates pandas dataframe with img cluster statistics.
@@ -205,6 +222,10 @@ def get_clusters_table(stat_img, stat_threshold, cluster_threshold=None,
 
         This center of mass may, in some cases, appear outside of the cluster.
 
+        .. versionchanged:: 0.9.2dev
+            In this case, the cluster voxel nearest to the center of mass is
+            reported.
+
     Parameters
     ----------
     stat_img : Niimg-like object


Test patch:
diff --git a/nilearn/reporting/tests/test_reporting.py b/nilearn/reporting/tests/test_reporting.py
--- a/nilearn/reporting/tests/test_reporting.py
+++ b/nilearn/reporting/tests/test_reporting.py
@@ -8,7 +8,10 @@
 from nilearn.reporting import (get_clusters_table,
                                )
 from nilearn.image import get_data
-from nilearn.reporting._get_clusters_table import _local_max
+from nilearn.reporting._get_clusters_table import (
+    _local_max,
+    _cluster_nearest_neighbor,
+)
 
 # Avoid making pyflakes unhappy
 _set_mpl_backend
@@ -65,8 +68,28 @@ def test_local_max():
     affine = np.eye(4)
 
     ijk, vals = _local_max(data, affine, min_distance=9)
-    assert np.array_equal(ijk, np.array([[5., 5., 5.]]))
-    assert np.all(np.isnan(vals))
+    assert np.array_equal(ijk, np.array([[4., 5., 5.]]))
+    assert np.array_equal(vals, np.array([1]))
+
+
+def test_cluster_nearest_neighbor():
+    """Check that _cluster_nearest_neighbor preserves within-cluster voxels,
+    projects voxels to the correct cluster, and handles singleton clusters.
+    """
+    shape = (9, 10, 11)
+    labeled = np.zeros(shape)
+    # cluster 1 is half the volume, cluster 2 is a single voxel
+    labeled[:, 5:, :] = 1
+    labeled[4, 2, 6] = 2
+
+    labels_index = np.array([1, 1, 2])
+    ijk = np.array([
+        [4, 7, 5],  # inside cluster 1
+        [4, 2, 5],  # outside, close to 2
+        [4, 3, 6],  # outside, close to 2
+    ])
+    nbrs = _cluster_nearest_neighbor(ijk, labels_index, labeled)
+    assert np.array_equal(nbrs, np.array([[4, 7, 5], [4, 5, 5], [4, 2, 6]]))
 
 
 def test_get_clusters_table(tmp_path):