Repository: nilearn/nilearn

Issue:
[BUG] plot_roi cannot handle cmap with only 1 level
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Operating system

- [ ] Linux
- [ ] Mac
- [ ] Windows

### Operating system version

For example one of the following:
- Linux Ubuntu 22.04
- Mac OS Version 12 "monterey"
- Windows 11


### Python version

- [ ] 3.12
- [ ] 3.11
- [ ] 3.10
- [ ] 3.9
- [ ] 3.8

### nilearn version

0.10.3

### Expected behavior

Plot ROI as usual

### Current behavior & error messages

Given an image with only one level (i.e. 0s and 1s), and a custom cmap, `plot_roi` throws an error constructing the `color_bar`.



```bash
<ipython-input-14-60f0ec2e3bf9> in <module>
----> 1 niplt.plot_roi(img, alpha=0.8, colorbar=True, cmap=cmap)

~/anaconda3/lib/python3.9/site-packages/nilearn/plotting/img_plotting.py in plot_roi(roi_img, bg_img, cut_coords, output_file, display_mode, figure, axes, title, annotate, draw_cross, black_bg, threshold, alpha, cmap, dim, colorbar, cbar_tick_format, vmin, vmax, resampling_interpolation, view_type, linewidths, radiological, **kwargs)
    921     )
    922 
--> 923     display = _plot_img_with_bg(
    924         img=roi_img,
    925         bg_img=bg_img,

~/anaconda3/lib/python3.9/site-packages/nilearn/plotting/img_plotting.py in _plot_img_with_bg(img, bg_img, cut_coords, output_file, display_mode, colorbar, figure, axes, title, threshold, annotate, draw_cross, black_bg, vmin, vmax, bg_vmin, bg_vmax, interpolation, display_factory, cbar_vmin, cbar_vmax, cbar_tick_format, brain_color, decimals, radiological, **kwargs)
    266 
    267     if img is not None and img is not False:
--> 268         display.add_overlay(
    269             new_img_like(img, data, affine),
    270             threshold=threshold,

~/anaconda3/lib/python3.9/site-packages/nilearn/plotting/displays/_slicers.py in add_overlay(self, img, threshold, colorbar, cbar_tick_format, cbar_vmin, cbar_vmax, **kwargs)
    314         # look at test_img_plotting.test_outlier_cut_coords.
    315         if colorbar and ims:
--> 316             self._show_colorbar(
    317                 ims[0].cmap, ims[0].norm, cbar_vmin, cbar_vmax, threshold
    318             )

~/anaconda3/lib/python3.9/site-packages/nilearn/plotting/displays/_slicers.py in _show_colorbar(self, cmap, norm, cbar_vmin, cbar_vmax, threshold)
    551                 "Custom cmap", cmaplist, our_cmap.N
    552             )
--> 553         self._cbar = ColorbarBase(
    554             self._colorbar_ax,
    555             ticks=ticks,

~/anaconda3/lib/python3.9/site-packages/matplotlib/colorbar.py in __init__(self, ax, mappable, cmap, norm, alpha, values, boundaries, orientation, ticklocation, extend, spacing, ticks, format, drawedges, extendfrac, extendrect, label, location)
    409         else:
    410             self._formatter = format  # Assume it is a Formatter or None
--> 411         self._draw_all()
    412 
    413         if isinstance(mappable, contour.ContourSet) and not mappable.filled:

~/anaconda3/lib/python3.9/site-packages/matplotlib/colorbar.py in _draw_all(self)
    535         self.vmin, self.vmax = self._boundaries[self._inside][[0, -1]]
    536         # Compute the X/Y mesh.
--> 537         X, Y = self._mesh()
    538         # draw the extend triangles, and shrink the inner axes to accommodate.
    539         # also adds the outline path to self.outline spine:

~/anaconda3/lib/python3.9/site-packages/matplotlib/colorbar.py in _mesh(self)
   1109         orientation.
   1110         """
-> 1111         y, _ = self._proportional_y()
   1112         # Use the vmin and vmax of the colorbar, which may not be the same
   1113         # as the norm. There are situations where the colormap has a

~/anaconda3/lib/python3.9/site-packages/matplotlib/colorbar.py in _proportional_y(self)
   1249         # make the lower and upper extend lengths proportional to the lengths
   1250         # of the first and last boundary spacing (if extendfrac='auto'):
-> 1251         automin = yscaled[1] - yscaled[0]
   1252         automax = yscaled[-1] - yscaled[-2]
   1253         extendlength = [0, 0]

IndexError: index 1 is out of bounds for axis 0 with size 1
```


### Steps and code to reproduce bug

```python
# Paste your code here

from nilearn import plotting as niplt
import numpy as np
import nibabel as nib
import matplotlib.pyplot as plt

array_data = np.arange(24, dtype=np.int16).reshape((2, 3, 4))
array_data[:] = 0
array_data[0, 1, 1] = 1
affine = np.diag([1, 2, 3, 1])
img = nib.Nifti1Image(array_data, affine)
clust_ids = list(np.unique(img.get_fdata())[1:])
cmap = plt.cm.get_cmap("tab20", len(clust_ids))
cmap = plt.cm.get_cmap("tab20", len(clust_ids))
niplt.plot_roi(img, alpha=0.8, colorbar=True, cmap=cmap)

```



Discussion:
This worked in previous versions.

I suspect this was brought on by `https://github.com/nilearn/nilearn/pull/2887` but I can't say for certain. 

I stared at it for a while, and I couldn't quite spot the change that would bring on the error.

I will say the tick values looked suspect, as they included a negative value. But modifying that line did not fix the issue. 
I haven't gotten that far, but it looks like the `norm` that is returned in the `ims` of `_map_show` differs, such that previously `norm(1)` returned 0, and now it returns 1. 
@adelavega 
thanks for the bug report: will git bisect to confirm where the regression happened

```python
# Paste your code here

from nilearn import plotting as niplt
import numpy as np
import nibabel as nib
import matplotlib.pyplot as plt


def main():
    array_data = np.arange(24, dtype=np.int16).reshape((2, 3, 4))
    array_data[:] = 0
    array_data[0, 1, 1] = 1
    affine = np.diag([1, 2, 3, 1])

    img = nib.Nifti1Image(array_data, affine)

    clust_ids = list(np.unique(img.get_fdata())[1:])

    cmap = plt.cm.get_cmap("tab20", len(clust_ids))
    cmap = plt.cm.get_cmap("tab20", len(clust_ids))

    try:
        niplt.plot_roi(img, alpha=0.8, colorbar=True, cmap=cmap)
        print("good")
        return 0
    except IndexError:
        print("bad")
        return 1
    except Exception:
        print("skipped")
        return 125

if __name__ == "__main__":
    exit(main())
```

```bash
git bisect start
git bisect good 0.10.2
git bisect bad 0.10.3
git bisect run python tmp.py
```

c018480af01c1b03efc88f920b5fe35e8d3ce75e is the first bad commit

[ENH] Allow setting `vmin` in `plot_glass_brain` and `plot_stat_map` (#3993)
OK adding back [this thresholding](https://github.com/nilearn/nilearn/pull/3993/files#r1475962281) fixed the bug



Resolving patch:
diff --git a/examples/01_plotting/plot_demo_glass_brain_extensive.py b/examples/01_plotting/plot_demo_glass_brain_extensive.py
--- a/examples/01_plotting/plot_demo_glass_brain_extensive.py
+++ b/examples/01_plotting/plot_demo_glass_brain_extensive.py
@@ -102,6 +102,7 @@
     threshold=2,
     symmetric_cbar=False,
     cmap="viridis",
+    title="only plot positive values",
 )
 
 # %%
@@ -116,6 +117,7 @@
     threshold=2,
     symmetric_cbar=False,
     cmap="viridis",
+    title="vmin == threshold",
 )
 
 # %%
diff --git a/nilearn/plotting/displays/_slicers.py b/nilearn/plotting/displays/_slicers.py
--- a/nilearn/plotting/displays/_slicers.py
+++ b/nilearn/plotting/displays/_slicers.py
@@ -393,6 +393,16 @@ def _map_show(
         threshold = float(threshold) if threshold is not None else None
 
         affine = img.affine
+        if threshold is not None:
+            data = safe_get_data(img, ensure_finite=True)
+            if threshold == 0:
+                data = np.ma.masked_equal(data, 0, copy=False)
+            else:
+                data = np.ma.masked_inside(
+                    data, -threshold, threshold, copy=False
+                )
+            img = new_img_like(img, data, affine)
+
         data = safe_get_data(img, ensure_finite=True)
         data_bounds = get_bounds(data.shape, affine)
         (xmin, xmax), (ymin, ymax), (zmin, zmax) = data_bounds


Test patch:
diff --git a/nilearn/plotting/tests/test_img_plotting/test_plot_roi.py b/nilearn/plotting/tests/test_img_plotting/test_plot_roi.py
--- a/nilearn/plotting/tests/test_img_plotting/test_plot_roi.py
+++ b/nilearn/plotting/tests/test_img_plotting/test_plot_roi.py
@@ -69,3 +69,25 @@ def test_demo_plot_roi_output_file(tmp_path):
     with open(filename, "wb") as fp:
         out = demo_plot_roi(output_file=fp)
     assert out is None
+
+
+def test_cmap_with_one_level(shape_3d_default, affine_eye):
+    """Test we can handle cmap with only 1 level.
+
+    Regression test for
+    https://github.com/nilearn/nilearn/issues/4255
+    """
+    array_data = np.zeros(shape_3d_default)
+    array_data[0, 1, 1] = 1
+
+    img = Nifti1Image(array_data, affine_eye)
+
+    clust_ids = list(np.unique(img.get_fdata())[1:])
+
+    try:
+        cmap = plt.colormaps["tab20"].resampled(len(clust_ids))
+    except TypeError:
+        # for older versions of matplotlib
+        cmap = plt.cm.get_cmap("tab20", len(clust_ids))
+
+    plot_roi(img, alpha=0.8, colorbar=True, cmap=cmap)