Repository: nilearn/nilearn

Issue:
Colorbar size not scaled with figsize with newest MPL
<!--Provide a brief description of the bug.-->

[This recent NeuroStars post ](https://neurostars.org/t/a-strange-bug-with-a-huge-colorbar/21903) suggests that we are not properly supporting colorbars with the newest matplotlib release (3.5.1). Please see the post for an example rendering.

<!--Please fill in the following information, to the best of your ability.-->
Nilearn version: 0.9.0

### Expected behavior
The colorbar size would be appropriately linked to the figsize.

### Actual behavior
The colorbar size does not scale with the figure.

### Steps and code to reproduce bug

```python
plotting.plot_stat_map(img, cut_coords=[20, -50, -8], colorbar = True, draw_cross = False, cmap = ‘cyan_copper’, vmax = 7)
```



Discussion:
Indeed, I can reproduce the bug. Funny that nobody noticed it before since 3.5.1 is already 3 months old.
Also, I think the link to the neurostars post is not correct. Here is the [right link](https://neurostars.org/t/a-strange-bug-with-a-huge-colorbar/21903).
On my side, I haven't been able to reproduce this issue? 

I tested with python 3.7, matplotlib@3.5.1 and nilearn@0.9.0 / nilearn@main.

Snippet:
```
from nilearn import plotting

from nilearn.datasets import fetch_neurovault_motor_task

images = fetch_neurovault_motor_task()

plotting.plot_stat_map(images.images[0], cut_coords=[20, -50, -8], colorbar = True, draw_cross = False, cmap='cyan_copper', vmax=1)
plotting.show()
```


@RaphaelMeudec I haven't investigated, but indeed, it seems that your code isn't producing the error...
I still get the error when running this (Nilearn main and Matplotlib 3.5.1):

```python
In [1]: from nilearn.datasets import load_mni152_template

In [2]: from nilearn import plotting

In [3]: img = load_mni152_template()
FutureWarning: Default resolution of the MNI template will change from 2mm to 1mm in version 0.10.0

In [4]: plotting.plot_stat_map(img, cut_coords=[20, -50, -8], colorbar = True, draw_cross = False, vmax = 7)
Out[4]: <nilearn.plotting.displays._slicers.OrthoSlicer at 0x7fab9804a880>

In [5]: plotting.show()
```
the problem happens here:

  https://github.com/nilearn/nilearn/blob/183cac7b29e58f25ed8f800892209e713de4d0a7/nilearn/plotting/img_plotting.py#L256

when the colorbar is cropped (showing only the half of the colormap that is used when the image has only ≤ 0 or only ≥ 0 values), the axes containing the colorbar is modified and then so are the spines (the black lines around the axes).

But since
  https://github.com/matplotlib/matplotlib/pull/20054

the spines now use the axes transform (coordinates scaled to be between 0 and 1 rather than data coordinates):

  https://github.com/matplotlib/matplotlib/blob/8c94a2204739a9ce0d32345cacf0f39f3e0bc7b2/lib/matplotlib/colorbar.py#L200

so now if the max image value is 10 we end up with an outline that is 10 times the height of the figure.

We *can* change the outline xy coordinates we pass and use the appropriate transform for newer matplotlib versions.

However, this nilearn crop_colorbar function would then have three branches depending on matplotlib versions (and more are likely to creep in in the future). Moreover it already uses undocumented as well as explicitly private attributes of matplotlib colorbars. This suggests we should probably change the way we crop the colorbar altogether.

Rather than modifying its parts in place, maybe we can remove the existing colorbar and create a new one with the appropriate colormap, or even use the correct colormap (ranging to 0 to vmax when the image has only non-negative values) from the start (ie when plotting the brain image) and then we would not need to crop the colorbar at all.
> Also, I think the link to the neurostars post is not correct. Here is the [right link](https://neurostars.org/t/a-strange-bug-with-a-huge-colorbar/21903).

Ha, too many things on my clipboard. Thanks for correcting, @NicolasGensollen ! I've updated the link in the top comment for clarity.
Just to be explicit, then, it seems that @RaphaelMeudec is not able to reproduce the bug (but @NicolasGensollen was) because of the `vmax` argument. In terms of a solution:

> Rather than modifying its parts in place, maybe we can remove the existing colorbar and create a new one with the appropriate colormap, or even use the correct colormap (ranging to 0 to vmax when the image has only non-negative values) from the start (ie when plotting the brain image) and then we would not need to crop the colorbar at all.

Thanks for such a nice summary, @jeromedockes ! I (personally) think this path forward sounds reasonable.
@emdupre I am not sure if the `vmax` argument is sufficient in itself. I think an important element is that the fetch_motor task gives a symmetric colorbar which does not trigger the `.set_xy` call inside the `_crop_colorbar` method as explained @jeromedockes. Plotting the mask lead to a positive-only colorbar where `_crop_colorbar` has been called.

I agree with simplifying and getting read of the matplotlib version checks, I can look into it!

Resolving patch:
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
@@ -219,7 +219,8 @@ def title(self, text, x=0.01, y=0.99, size=15, color=None, bgcolor=None,
 
     @fill_doc
     def add_overlay(self, img, threshold=1e-6, colorbar=False,
-                    cbar_tick_format="%.2g", **kwargs):
+                    cbar_tick_format="%.2g", cbar_vmin=None, cbar_vmax=None,
+                    **kwargs):
         """ Plot a 3D map in all the views.
 
         Parameters
@@ -249,6 +250,14 @@ def add_overlay(self, img, threshold=1e-6, colorbar=False,
         kwargs : :obj:`dict`
             Extra keyword arguments are passed to function
             :func:`~matplotlib.pyplot.imshow`.
+
+        cbar_vmin : :obj:`float`, optional
+            Minimal value for the colorbar. If None, the minimal value
+            is computed based on the data.
+
+        cbar_vmax : :obj:`float`, optional
+            Maximal value for the colorbar. If None, the maximal value
+            is computed based on the data.
         """
         if colorbar and self._colorbar:
             raise ValueError("This figure already has an overlay with a "
@@ -266,7 +275,8 @@ def add_overlay(self, img, threshold=1e-6, colorbar=False,
 
         # `ims` can be empty in some corner cases, look at test_img_plotting.test_outlier_cut_coords.
         if colorbar and ims:
-            self._show_colorbar(ims[0].cmap, ims[0].norm, threshold)
+            self._show_colorbar(ims[0].cmap, ims[0].norm,
+                                cbar_vmin, cbar_vmax, threshold)
 
         plt.draw_if_interactive()
 
@@ -406,7 +416,8 @@ def _map_show(self, img, type='imshow',
         return ims
 
     @fill_doc
-    def _show_colorbar(self, cmap, norm, threshold=None):
+    def _show_colorbar(self, cmap, norm, cbar_vmin=None,
+                       cbar_vmax=None, threshold=None):
         """Displays the colorbar.
 
         Parameters
@@ -418,6 +429,14 @@ def _show_colorbar(self, cmap, norm, threshold=None):
 
         threshold : :obj:`float` or ``None``, optional
             The absolute value at which the colorbar is thresholded.
+
+        cbar_vmin : :obj:`float`, optional
+            Minimal value for the colorbar. If None, the minimal value
+            is computed based on the data.
+
+        cbar_vmax : :obj:`float`, optional
+            Maximal value for the colorbar. If None, the maximal value
+            is computed based on the data.
         """
         if threshold is None:
             offset = 0
@@ -426,6 +445,9 @@ def _show_colorbar(self, cmap, norm, threshold=None):
         if offset > norm.vmax:
             offset = norm.vmax
 
+        cbar_vmin = cbar_vmin if cbar_vmin is not None else norm.vmin
+        cbar_vmax = cbar_vmax if cbar_vmax is not None else norm.vmax
+
         # create new  axis for the colorbar
         figure = self.frame_axes.figure
         _, y0, x1, y1 = self.rect
@@ -447,9 +469,9 @@ def _show_colorbar(self, cmap, norm, threshold=None):
         # edge case where the data has a single value
         # yields a cryptic matplotlib error message
         # when trying to plot the color bar
-        nb_ticks = 5 if norm.vmin != norm.vmax else 1
-        ticks = np.linspace(norm.vmin, norm.vmax, nb_ticks)
-        bounds = np.linspace(norm.vmin, norm.vmax, our_cmap.N)
+        nb_ticks = 5 if cbar_vmin != cbar_vmax else 1
+        ticks = np.linspace(cbar_vmin, cbar_vmax, nb_ticks)
+        bounds = np.linspace(cbar_vmin, cbar_vmax, our_cmap.N)
 
         # some colormap hacking
         cmaplist = [our_cmap(i) for i in range(our_cmap.N)]
@@ -457,7 +479,7 @@ def _show_colorbar(self, cmap, norm, threshold=None):
         transparent_stop = int(norm(offset, clip=True) * (our_cmap.N - 1))
         for i in range(transparent_start, transparent_stop):
             cmaplist[i] = self._brain_color + (0.,)  # transparent
-        if norm.vmin == norm.vmax:  # len(np.unique(data)) == 1 ?
+        if cbar_vmin == cbar_vmax:  # len(np.unique(data)) == 1 ?
             return
         else:
             our_cmap = LinearSegmentedColormap.from_list(
diff --git a/nilearn/plotting/img_plotting.py b/nilearn/plotting/img_plotting.py
--- a/nilearn/plotting/img_plotting.py
+++ b/nilearn/plotting/img_plotting.py
@@ -190,8 +190,7 @@ def _plot_img_with_bg(img, bg_img=None, cut_coords=None,
         black_bg=black_bg,
         colorbar=colorbar,
         brain_color=brain_color,
-        )
-
+    )
     if bg_img is not None:
         bg_img = _utils.check_niimg_3d(bg_img)
         display.add_overlay(bg_img,
@@ -202,6 +201,7 @@ def _plot_img_with_bg(img, bg_img=None, cut_coords=None,
         display.add_overlay(new_img_like(img, data, affine),
                             threshold=threshold, interpolation=interpolation,
                             colorbar=colorbar, vmin=vmin, vmax=vmax,
+                            cbar_vmin=cbar_vmin, cbar_vmax=cbar_vmax,
                             cbar_tick_format=cbar_tick_format,
                             **kwargs)
 
@@ -211,9 +211,6 @@ def _plot_img_with_bg(img, bg_img=None, cut_coords=None,
         display.draw_cross()
     if title is not None and not title == '':
         display.title(title)
-    if hasattr(display, '_cbar'):
-        cbar = display._cbar
-        _crop_colorbar(cbar, cbar_vmin, cbar_vmax)
     if output_file is not None:
         display.savefig(output_file)
         display.close()
@@ -221,51 +218,6 @@ def _plot_img_with_bg(img, bg_img=None, cut_coords=None,
     return display
 
 
-def _crop_colorbar(cbar, cbar_vmin, cbar_vmax):
-    """Crop a colorbar to show from cbar_vmin to cbar_vmax.
-    Used when symmetric_cbar=False is used.
-
-    """
-    if (cbar_vmin is None) and (cbar_vmax is None):
-        return
-    cbar_tick_locs = cbar.locator.locs
-    if cbar_vmax is None:
-        cbar_vmax = cbar_tick_locs.max()
-    if cbar_vmin is None:
-        cbar_vmin = cbar_tick_locs.min()
-    new_tick_locs = np.linspace(cbar_vmin, cbar_vmax,
-                                len(cbar_tick_locs))
-
-    # matplotlib >= 3.2.0 no longer normalizes axes between 0 and 1
-    # See https://matplotlib.org/3.2.1/api/prev_api_changes/api_changes_3.2.0.html
-    # _outline was removed in
-    # https://github.com/matplotlib/matplotlib/commit/03a542e875eba091a027046d5ec652daa8be6863
-    # so we use the code from there
-    if _compare_version(matplotlib.__version__, '>=', "3.2.0"):
-        cbar.ax.set_ylim(cbar_vmin, cbar_vmax)
-        X = cbar._mesh()[0]
-        X = np.array([X[0], X[-1]])
-        Y = np.array([[cbar_vmin, cbar_vmin], [cbar_vmax, cbar_vmax]])
-        N = X.shape[0]
-        ii = [0, 1, N - 2, N - 1, 2 * N - 1, 2 * N - 2, N + 1, N, 0]
-        x = X.T.reshape(-1)[ii]
-        y = Y.T.reshape(-1)[ii]
-        xy = (np.column_stack([y, x])
-              if cbar.orientation == 'horizontal' else
-              np.column_stack([x, y]))
-        cbar.outline.set_xy(xy)
-    else:
-        cbar.ax.set_ylim(cbar.norm(cbar_vmin), cbar.norm(cbar_vmax))
-        outline = cbar.outline.get_xy()
-        outline[:2, 1] += cbar.norm(cbar_vmin)
-        outline[2:6, 1] -= (1. - cbar.norm(cbar_vmax))
-        outline[6:, 1] += cbar.norm(cbar_vmin)
-        cbar.outline.set_xy(outline)
-
-    cbar.set_ticks(new_tick_locs)
-    cbar.update_ticks()
-
-
 @fill_doc
 def plot_img(img, cut_coords=None, output_file=None, display_mode='ortho',
              figure=None, axes=None, title=None, threshold=None,
diff --git a/nilearn/plotting/surf_plotting.py b/nilearn/plotting/surf_plotting.py
--- a/nilearn/plotting/surf_plotting.py
+++ b/nilearn/plotting/surf_plotting.py
@@ -13,8 +13,7 @@
 from mpl_toolkits.mplot3d import Axes3D  # noqa
 from nilearn import image
 from nilearn.plotting.cm import cold_hot
-from nilearn.plotting.img_plotting import (_get_colorbar_and_data_ranges,
-                                           _crop_colorbar)
+from nilearn.plotting.img_plotting import _get_colorbar_and_data_ranges
 from nilearn.surface import (load_surf_data,
                              load_surf_mesh,
                              vol_to_surf)
@@ -501,19 +500,21 @@ def _plot_surf_matplotlib(coords, faces, surf_map=None, bg_map=None,
             face_colors[kept_indices] = cmap(surf_map_faces[kept_indices])
 
         if colorbar:
-            ticks = _get_ticks_matplotlib(vmin, vmax, cbar_tick_format)
+            cbar_vmin = cbar_vmin if cbar_vmin is not None else vmin
+            cbar_vmax = cbar_vmax if cbar_vmax is not None else vmax
+            ticks = _get_ticks_matplotlib(cbar_vmin, cbar_vmax,
+                                          cbar_tick_format)
             our_cmap, norm = _get_cmap_matplotlib(cmap, vmin, vmax, threshold)
-            bounds = np.linspace(vmin, vmax, our_cmap.N)
+            bounds = np.linspace(cbar_vmin, cbar_vmax, our_cmap.N)
             # we need to create a proxy mappable
             proxy_mappable = ScalarMappable(cmap=our_cmap, norm=norm)
             proxy_mappable.set_array(surf_map_faces)
             cax, kw = make_axes(axes, location='right', fraction=.15,
                                 shrink=.5, pad=.0, aspect=10.)
-            cbar = figure.colorbar(
+            figure.colorbar(
                 proxy_mappable, cax=cax, ticks=ticks,
                 boundaries=bounds, spacing='proportional',
                 format=cbar_tick_format, orientation='vertical')
-            _crop_colorbar(cbar, cbar_vmin, cbar_vmax)
 
         p3dcollec.set_facecolors(face_colors)
 


Test patch: