Repository: nilearn/nilearn

Issue:
Possible discrepancies between the `Cluster ID` and its label in the `label_map` of `get_clusters_tables`
<!--Provide a brief description of the bug.-->
Thanks for adding the label maps to the outputs of `get_clusters_tables`.
I was working with the clusters table and the label maps and noticed a mismatch between the Cluster ID and the label in the `label_map`. While cluster IDs are sorted based on their `peak_vals` and relabeled in the for loop, it seems that the `label_maps` are not relabeled since they are appended to the list before sorting the cluster IDs.

<!--Please fill in the following information, to the best of your ability.-->
Nilearn version: `0.10.1.dev`

### Expected behavior
Cluster `n` corresponds to voxels with value `n` in the label map. Except for `n = 0`, when the peak falls outside of the cluster body.

### Actual behavior
In some cases, cluster `x` corresponds to a cluster with value `y`.

### Steps and code to reproduce bug

<!-- Please ensure the code is a minimal runnable example using Nilearn data to showcase it. -->
<!-- https://stackoverflow.com/help/minimal-reproducible-example -->

```python
import nibabel as nib
import numpy as np
import pandas as pd

from nilearn import reporting
from nilearn import datasets

motor_images = datasets.fetch_neurovault_motor_task()
stat_img = motor_images.images[0]

# Run get_clusters_table on stat_img
clusters_table, label_maps = reporting.get_clusters_table(
            stat_img, 3.6, two_sided=True, return_label_maps=True
        )
# Select negative tail
neg_clusters_table = clusters_table[clusters_table["Peak Stat"] < 0]
neg_label_maps = label_maps[1]

# Get cluster IDs and its respective coordinates from the clusters table
new_clusters_table = neg_clusters_table.apply(pd.to_numeric, errors='coerce').dropna()
cluster_ids = new_clusters_table["Cluster ID"].to_list()
coords = new_clusters_table[["X", "Y", "Z"]].to_numpy()

# Use the coordinates from the clusters table to find the Cluster ID in the label map 
ijk = nib.affines.apply_affine(np.linalg.inv(neg_label_maps.affine), coords).astype(int)
cluster_label_ids = neg_label_maps.get_fdata()[tuple(ijk.T)]

print(cluster_ids, cluster_label_ids)
# > [1.0, 2.0, 3.0, 4.0, 5.0] [1. 3. 5. 2. 4.]
# In this we get that coordinate of cluster 1 has value of 1 in the label map, 
# but cluster 2 has value of 3, 3 value of 5, 4 value of 2 and 5 value of 4. 
# The array [1. 3. 5. 2. 4.] corresponds to the sorted indices that were not apply 
# to the label_maps.
```



Discussion:
Thx for reporting. Would you mind opening a PR ?
Best,

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
@@ -25,6 +25,7 @@
 
 def _local_max(data, affine, min_distance):
     """Find all local maxima of the array, separated by at least min_distance.
+
     Adapted from https://stackoverflow.com/a/22631583/2589328
 
     Parameters
@@ -110,7 +111,7 @@ def _identify_subpeaks(data):
         ijk[subpeaks_outside_cluster] = _cluster_nearest_neighbor(
             ijk[subpeaks_outside_cluster],
             labels_index[subpeaks_outside_cluster],
-            labeled
+            labeled,
         )
     vals = data[ijk[:, 0], ijk[:, 1], ijk[:, 2]]
     return ijk, vals
@@ -205,9 +206,14 @@ def _pare_subpeaks(xyz, ijk, vals, min_distance):
     return ijk, vals
 
 
-def get_clusters_table(stat_img, stat_threshold, cluster_threshold=None,
-                       two_sided=False, min_distance=8.,
-                       return_label_maps=False):
+def get_clusters_table(
+    stat_img,
+    stat_threshold,
+    cluster_threshold=None,
+    two_sided=False,
+    min_distance=8.0,
+    return_label_maps=False,
+):
     """Creates pandas dataframe with img cluster statistics.
 
     This function should work on any statistical maps where more extreme values
@@ -254,7 +260,7 @@ def get_clusters_table(stat_img, stat_threshold, cluster_threshold=None,
     return_label_maps : :obj:`bool`, optional
         Whether or not to additionally output cluster label map images.
         Default=False.
-    
+
         .. versionadded:: 0.10.1.dev
 
     Returns
@@ -274,7 +280,7 @@ def get_clusters_table(stat_img, stat_threshold, cluster_threshold=None,
                            Rows corresponding to subpeaks will not have a value
                            in this column.
         ================== ====================================================
-        
+
     label_maps : :obj:`list`
         Returned if return_label_maps=True
         List of Niimg-like objects of cluster label maps.
@@ -284,14 +290,15 @@ def get_clusters_table(stat_img, stat_threshold, cluster_threshold=None,
         .. versionadded:: 0.10.1.dev
 
     """
-    cols = ['Cluster ID', 'X', 'Y', 'Z', 'Peak Stat', 'Cluster Size (mm3)']
+    cols = ["Cluster ID", "X", "Y", "Z", "Peak Stat", "Cluster Size (mm3)"]
     # Replace None with 0
     cluster_threshold = 0 if cluster_threshold is None else cluster_threshold
 
     # check that stat_img is niimg-like object and 3D
     stat_img = check_niimg_3d(stat_img)
     affine = stat_img.affine
-    
+    shape = stat_img.shape
+
     # Apply threshold(s) to image
     stat_img = threshold_img(
         img=stat_img,
@@ -304,8 +311,11 @@ def get_clusters_table(stat_img, stat_threshold, cluster_threshold=None,
 
     # If cluster threshold is used, there is chance that stat_map will be
     # modified, therefore copy is needed
-    stat_map = _safe_get_data(stat_img, ensure_finite=True,
-                              copy_data=(cluster_threshold is not None))
+    stat_map = _safe_get_data(
+        stat_img,
+        ensure_finite=True,
+        copy_data=(cluster_threshold is not None),
+    )
 
     # Define array for 6-connectivity, aka NN1 or "faces"
     bin_struct = generate_binary_structure(rank=3, connectivity=1)
@@ -327,8 +337,8 @@ def get_clusters_table(stat_img, stat_threshold, cluster_threshold=None,
         # If the stat threshold is too high simply return an empty dataframe
         if np.sum(binarized) == 0:
             warnings.warn(
-                'Attention: No clusters with stat {0} than {1}'.format(
-                    'higher' if sign == 1 else 'lower',
+                "Attention: No clusters with stat {0} than {1}".format(
+                    "higher" if sign == 1 else "lower",
                     stat_threshold * sign,
                 )
             )
@@ -336,14 +346,22 @@ def get_clusters_table(stat_img, stat_threshold, cluster_threshold=None,
 
         # Now re-label and create table
         label_map = label(binarized, bin_struct)[0]
-        # Save label maps as nifti objects
-        label_maps.append(new_img_like(stat_img, label_map, affine=affine))
         clust_ids = sorted(list(np.unique(label_map)[1:]))
         peak_vals = np.array(
-            [np.max(temp_stat_map * (label_map == c)) for c in clust_ids])
+            [np.max(temp_stat_map * (label_map == c)) for c in clust_ids]
+        )
         # Sort by descending max value
         clust_ids = [clust_ids[c] for c in (-peak_vals).argsort()]
 
+        if return_label_maps:
+            # Relabel label_map based on sorted ids
+            relabel_idx = np.insert(clust_ids, 0, 0).argsort().astype(np.int32)
+            relabel_map = relabel_idx[label_map.flatten()].reshape(shape)
+            # Save label maps as nifti objects
+            label_maps.append(
+                new_img_like(stat_img, relabel_map, affine=affine)
+            )
+
         for c_id, c_val in enumerate(clust_ids):
             cluster_mask = label_map == c_val
             masked_data = temp_stat_map * cluster_mask
@@ -382,7 +400,7 @@ def get_clusters_table(stat_img, stat_threshold, cluster_threshold=None,
                 else:
                     # Subpeak naming convention is cluster num+letter:
                     # 1a, 1b, etc
-                    sp_id = '{0}{1}'.format(
+                    sp_id = "{0}{1}".format(
                         c_id + 1,
                         ascii_lowercase[subpeak - 1],
                     )
@@ -392,7 +410,7 @@ def get_clusters_table(stat_img, stat_threshold, cluster_threshold=None,
                         subpeak_xyz[subpeak, 1],
                         subpeak_xyz[subpeak, 2],
                         subpeak_vals[subpeak],
-                        '',
+                        "",
                     ]
                 rows += [row]
 
@@ -404,7 +422,4 @@ def get_clusters_table(stat_img, stat_threshold, cluster_threshold=None,
     else:
         df = pd.DataFrame(columns=cols, data=rows)
 
-    if return_label_maps:
-        return df, label_maps
-    else:
-        return df
+    return (df, label_maps) if return_label_maps else df


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
@@ -5,8 +5,7 @@
 # Set backend to avoid DISPLAY problems
 from nilearn.plotting import _set_mpl_backend
 
-from nilearn.reporting import (get_clusters_table,
-                               )
+from nilearn.reporting import get_clusters_table
 from nilearn.image import get_data
 from nilearn.reporting._get_clusters_table import (
     _local_max,
@@ -17,6 +16,7 @@
 _set_mpl_backend
 try:
     import matplotlib.pyplot
+
     # Avoid making pyflakes unhappy
     matplotlib.pyplot
 except ImportError:
@@ -25,8 +25,9 @@
     have_mpl = True
 
 
-@pytest.mark.skipif(not have_mpl,
-                    reason='Matplotlib not installed; required for this test')
+@pytest.mark.skipif(
+    not have_mpl, reason="Matplotlib not installed; required for this test"
+)
 def test_local_max():
     """Basic test of nilearn.reporting._get_clusters_table._local_max()"""
     shape = (9, 10, 11)
@@ -38,11 +39,11 @@ 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., 10.], [5., 5., 0.]]))
+    assert np.array_equal(ijk, np.array([[5.0, 5.0, 10.0], [5.0, 5.0, 0.0]]))
     assert np.array_equal(vals, np.array([6, 5]))
 
     ijk, vals = _local_max(data, affine, min_distance=11)
-    assert np.array_equal(ijk, np.array([[5., 5., 10.]]))
+    assert np.array_equal(ijk, np.array([[5.0, 5.0, 10.0]]))
     assert np.array_equal(vals, np.array([6]))
 
     # Two global (equal) maxima, 10 voxels apart.
@@ -53,11 +54,11 @@ 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., 0.], [5., 5., 10.]]))
+    assert np.array_equal(ijk, np.array([[5.0, 5.0, 0.0], [5.0, 5.0, 10.0]]))
     assert np.array_equal(vals, np.array([5, 5]))
 
     ijk, vals = _local_max(data, affine, min_distance=11)
-    assert np.array_equal(ijk, np.array([[5., 5., 0.]]))
+    assert np.array_equal(ijk, np.array([[5.0, 5.0, 0.0]]))
     assert np.array_equal(vals, np.array([5]))
 
     # A donut.
@@ -68,7 +69,7 @@ def test_local_max():
     affine = np.eye(4)
 
     ijk, vals = _local_max(data, affine, min_distance=9)
-    assert np.array_equal(ijk, np.array([[4., 5., 5.]]))
+    assert np.array_equal(ijk, np.array([[4.0, 5.0, 5.0]]))
     assert np.array_equal(vals, np.array([1]))
 
 
@@ -83,11 +84,13 @@ def test_cluster_nearest_neighbor():
     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
-    ])
+    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]]))
 
@@ -95,8 +98,8 @@ def test_cluster_nearest_neighbor():
 def test_get_clusters_table(tmp_path):
     shape = (9, 10, 11)
     data = np.zeros(shape)
-    data[2:4, 5:7, 6:8] = 5.
-    data[4:6, 7:9, 8:10] = -5.
+    data[2:4, 5:7, 6:8] = 5.0
+    data[4:6, 7:9, 8:10] = -5.0
     stat_img = nib.Nifti1Image(data, np.eye(4))
 
     # test one cluster extracted
@@ -128,10 +131,15 @@ def test_get_clusters_table(tmp_path):
     stat_img.to_filename(fname)
     cluster_table = get_clusters_table(fname, 4, 0, two_sided=True)
     assert len(cluster_table) == 2
-    
+
     # test with returning label maps
     cluster_table, label_maps = get_clusters_table(
-        stat_img, 4, 0, two_sided=True, return_label_maps=True)
+        stat_img,
+        4,
+        0,
+        two_sided=True,
+        return_label_maps=True,
+    )
     label_map_positive_data = label_maps[0].get_fdata()
     label_map_negative_data = label_maps[1].get_fdata()
     # make sure positive and negative clusters are returned in the label maps
@@ -145,7 +153,7 @@ def test_get_clusters_table(tmp_path):
         stat_img_extra_dim,
         4,
         0,
-        two_sided=True
+        two_sided=True,
     )
     assert len(cluster_table) == 2
 
@@ -155,12 +163,53 @@ def test_get_clusters_table(tmp_path):
     cluster_table = get_clusters_table(stat_img_nans, 1e-2, 0, two_sided=False)
     assert len(cluster_table) == 1
 
+    # Test that subpeaks are handled correctly for len(subpeak_vals) > 1
+    # 1 cluster and two subpeaks, 10 voxels apart.
+    data = np.zeros(shape)
+    data[4, 5, :] = [4, 3, 2, 1, 1, 1, 1, 1, 2, 3, 4]
+    data[5, 5, :] = [5, 4, 3, 2, 1, 1, 1, 2, 3, 4, 6]
+    data[6, 5, :] = [4, 3, 2, 1, 1, 1, 1, 1, 2, 3, 4]
+    stat_img = nib.Nifti1Image(data, np.eye(4))
+
+    cluster_table = get_clusters_table(stat_img, 0, 0, min_distance=9)
+    assert len(cluster_table) == 2
+    assert 1 in cluster_table["Cluster ID"].values
+    assert "1a" in cluster_table["Cluster ID"].values
+
+
+def test_get_clusters_table_relabel_label_maps():
+    """Check that the cluster's labels in label_maps match their corresponding
+    cluster IDs in the clusters table.
+    """
+    shape = (9, 10, 11)
+    data = np.zeros(shape)
+    data[2:4, 5:7, 6:8] = 6.0
+    data[5:7, 7:9, 7:9] = 5.5
+    data[0:3, 0:3, 0:3] = 5.0
+    stat_img = nib.Nifti1Image(data, np.eye(4))
+
+    cluster_table, label_maps = get_clusters_table(
+        stat_img,
+        4,
+        0,
+        return_label_maps=True,
+    )
+
+    # Get cluster ids from clusters table
+    cluster_ids = cluster_table["Cluster ID"].to_numpy()
+
+    # Find the cluster ids in the label map using the coords from the table.
+    coords = cluster_table[["X", "Y", "Z"]].to_numpy().astype(int)
+    lb_cluster_ids = label_maps[0].get_fdata()[tuple(coords.T)]
+
+    assert np.array_equal(cluster_ids, lb_cluster_ids)
+
 
 def test_get_clusters_table_not_modifying_stat_image():
     shape = (9, 10, 11)
     data = np.zeros(shape)
-    data[2:4, 5:7, 6:8] = 5.
-    data[0:3, 0:3, 0:3] = 6.
+    data[2:4, 5:7, 6:8] = 5.0
+    data[0:3, 0:3, 0:3] = 6.0
 
     stat_img = nib.Nifti1Image(data, np.eye(4))
     data_orig = get_data(stat_img).copy()
@@ -170,7 +219,7 @@ def test_get_clusters_table_not_modifying_stat_image():
         stat_img,
         4,
         cluster_threshold=10,
-        two_sided=True
+        two_sided=True,
     )
     assert np.allclose(data_orig, get_data(stat_img))
     assert len(clusters_table) == 1
@@ -181,7 +230,7 @@ def test_get_clusters_table_not_modifying_stat_image():
         stat_img,
         4,
         cluster_threshold=7,
-        two_sided=False
+        two_sided=False,
     )
     assert np.allclose(data_orig, get_data(stat_img))
     assert len(clusters_table) == 2
@@ -192,7 +241,7 @@ def test_get_clusters_table_not_modifying_stat_image():
         stat_img,
         4,
         cluster_threshold=None,
-        two_sided=False
+        two_sided=False,
     )
     assert np.allclose(data_orig, get_data(stat_img))
     assert len(clusters_table) == 2