Repository: nilearn/nilearn

Issue:
Colorbar for stats plots should show a ticklabel at the threshold
<!--Provide a brief description what you would like changes and why.-->
Colorbar for plot_stat_map should show a ticklabel at the threshold. 

currently ticklabels are placed evenly:
![image](https://user-images.githubusercontent.com/3617325/117792782-8773d380-b243-11eb-91c5-e4bec20d856e.png)

<!--Please fill in the following information, to the best of your ability.-->
### Benefits to the change
Clearly indicate the threshold for the image without needing to specify in the title or caption. 




Discussion:
+1

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
@@ -535,7 +535,7 @@ def _show_colorbar(
         # yields a cryptic matplotlib error message
         # when trying to plot the color bar
         nb_ticks = 5 if cbar_vmin != cbar_vmax else 1
-        ticks = np.linspace(cbar_vmin, cbar_vmax, nb_ticks)
+        ticks = _get_cbar_ticks(cbar_vmin, cbar_vmax, offset, nb_ticks)
         bounds = np.linspace(cbar_vmin, cbar_vmax, our_cmap.N)
 
         # some colormap hacking
@@ -808,6 +808,38 @@ def savefig(self, filename, dpi=None):
         )
 
 
+def _get_cbar_ticks(vmin, vmax, offset, nb_ticks=5):
+    """Help for BaseSlicer."""
+    # edge case where the data has a single value yields
+    # a cryptic matplotlib error message when trying to plot the color bar
+    if vmin == vmax:
+        return np.linspace(vmin, vmax, 1)
+
+    # If a threshold is specified, we want two of the tick
+    # to correspond to -thresold and +threshold on the colorbar.
+    # If the threshold is very small compared to vmax,
+    # we use a simple linspace as the result would be very difficult to see.
+    ticks = np.linspace(vmin, vmax, nb_ticks)
+    if offset is not None and offset / vmax > 0.12:
+        diff = [abs(abs(tick) - offset) for tick in ticks]
+        # Edge case where the thresholds are exactl
+        # at the same distance to 4 ticks
+        if diff.count(min(diff)) == 4:
+            idx_closest = np.sort(np.argpartition(diff, 4)[:4])
+            idx_closest = np.in1d(ticks, np.sort(ticks[idx_closest])[1:3])
+        else:
+            # Find the closest 2 ticks
+            idx_closest = np.sort(np.argpartition(diff, 2)[:2])
+            if 0 in ticks[idx_closest]:
+                idx_closest = np.sort(np.argpartition(diff, 3)[:3])
+                idx_closest = idx_closest[[0, 2]]
+        ticks[idx_closest] = [-offset, offset]
+    if len(ticks) > 0 and ticks[0] < vmin:
+        ticks[0] = vmin
+
+    return ticks
+
+
 @fill_doc
 class OrthoSlicer(BaseSlicer):
     """Class to create 3 linked axes for plotting orthogonal \
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
@@ -30,6 +30,7 @@
 from nilearn.image.resampling import reorder_img
 from nilearn.maskers import NiftiMasker
 from nilearn.plotting.displays import get_projector, get_slicer
+from nilearn.plotting.displays._slicers import _get_cbar_ticks
 
 from .. import _utils
 from .._utils import compare_version, fill_doc
@@ -247,8 +248,15 @@ def _plot_img_with_bg(img, bg_img=None, cut_coords=None,
         display.annotate(decimals=decimals)
     if draw_cross:
         display.draw_cross()
-    if title is not None and not title == '':
+    if title is not None and title != '':
         display.title(title)
+    if hasattr(display, '_cbar'):
+        cbar = display._cbar
+        new_tick_locs = _get_cropped_cbar_ticks(cbar.vmin,
+                                                cbar.vmax,
+                                                threshold,
+                                                n_ticks=len(cbar.locator.locs))
+        cbar.set_ticks(new_tick_locs)
     if output_file is not None:
         display.savefig(output_file)
         display.close()
@@ -256,6 +264,32 @@ def _plot_img_with_bg(img, bg_img=None, cut_coords=None,
     return display
 
 
+def _get_cropped_cbar_ticks(cbar_vmin, cbar_vmax, threshold=None, n_ticks=5):
+    """Return ticks for cropped colorbars."""
+    new_tick_locs = np.linspace(cbar_vmin, cbar_vmax, n_ticks)
+    if threshold is not None:
+        # Case where cbar is either all positive or all negative
+        if 0 <= cbar_vmin <= cbar_vmax or cbar_vmin <= cbar_vmax <= 0:
+            idx_closest = np.argmin([abs(abs(new_tick_locs) - threshold)
+                                     for _ in new_tick_locs])
+            new_tick_locs[idx_closest] = threshold
+        # Case where we do a symmetric thresholding
+        # within an asymmetric cbar
+        # and both threshold values are within bounds
+        elif cbar_vmin <= -threshold <= threshold <= cbar_vmax:
+            new_tick_locs = _get_cbar_ticks(cbar_vmin,
+                                            cbar_vmax,
+                                            threshold,
+                                            nb_ticks=len(new_tick_locs))
+        # Case where one of the threshold values is out of bounds
+        else:
+            idx_closest = np.argmin([abs(new_tick_locs - threshold)
+                                     for _ in new_tick_locs])
+            new_tick_locs[idx_closest] = (
+                -threshold if threshold > cbar_vmax else threshold)
+    return new_tick_locs
+
+
 @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
@@ -18,6 +18,7 @@
 from nilearn._utils import check_niimg_3d, compare_version, fill_doc
 from nilearn._utils.helpers import is_kaleido_installed, is_plotly_installed
 from nilearn.plotting.cm import _mix_colormaps, cold_hot
+from nilearn.plotting.displays._slicers import _get_cbar_ticks
 from nilearn.plotting.html_surface import _get_vertexcolor
 from nilearn.plotting.img_plotting import _get_colorbar_and_data_ranges
 from nilearn.plotting.js_plotting_utils import colorscale
@@ -401,7 +402,7 @@ def _compute_surf_map_faces_matplotlib(surf_map, faces, avg_method,
     return surf_map_faces
 
 
-def _get_ticks_matplotlib(vmin, vmax, cbar_tick_format):
+def _get_ticks_matplotlib(vmin, vmax, cbar_tick_format, threshold):
     """Help for plot_surf with matplotlib engine.
 
     This function computes the tick values for the colorbar.
@@ -411,14 +412,12 @@ def _get_ticks_matplotlib(vmin, vmax, cbar_tick_format):
     # ...unless we are dealing with integers with a small range
     # in this case, we reduce the number of ticks
     if cbar_tick_format == "%i" and vmax - vmin < n_ticks - 1:
-        ticks = np.arange(vmin, vmax + 1)
+        return np.arange(vmin, vmax + 1)
     else:
-        # remove duplicate ticks when vmin == vmax, or almost
-        ticks = np.unique(np.linspace(vmin, vmax, n_ticks))
-    return ticks
+        return _get_cbar_ticks(vmin, vmax, threshold, n_ticks)
 
 
-def _get_cmap_matplotlib(cmap, vmin, vmax, threshold=None):
+def _get_cmap_matplotlib(cmap, vmin, vmax, cbar_tick_format, threshold=None):
     """Help for plot_surf with matplotlib engine.
 
     This function returns the colormap.
@@ -427,6 +426,9 @@ def _get_cmap_matplotlib(cmap, vmin, vmax, threshold=None):
     norm = Normalize(vmin=vmin, vmax=vmax)
     cmaplist = [our_cmap(i) for i in range(our_cmap.N)]
     if threshold is not None:
+        if cbar_tick_format == "%i" and int(threshold) != threshold:
+            warn("You provided a non integer threshold "
+                 "but configured the colorbar to use integer formatting.")
         # set colors to grey for absolute values < threshold
         istart = int(norm(-threshold, clip=True) * (our_cmap.N - 1))
         istop = int(norm(threshold, clip=True) * (our_cmap.N - 1))
@@ -600,9 +602,14 @@ def _plot_surf_matplotlib(coords, faces, surf_map=None, bg_map=None,
             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)
+                                          cbar_tick_format, threshold)
+            our_cmap, norm = _get_cmap_matplotlib(cmap,
+                                                  vmin,
+                                                  vmax,
+                                                  cbar_tick_format,
+                                                  threshold)
             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)
@@ -1281,7 +1288,8 @@ def plot_img_on_surf(stat_map, surf_mesh='fsaverage5', mask_img=None,
                      inflate=False, views=['lateral', 'medial'],
                      output_file=None, title=None, colorbar=True,
                      vmin=None, vmax=None, threshold=None,
-                     symmetric_cbar='auto', cmap='cold_hot', **kwargs):
+                     symmetric_cbar='auto', cmap='cold_hot',
+                     cbar_tick_format='%i', **kwargs):
     """Plot multiple views of plot_surf_stat_map \
     in a single figure.
 
@@ -1338,6 +1346,7 @@ def plot_img_on_surf(stat_map, surf_mesh='fsaverage5', mask_img=None,
     %(symmetric_cbar)s
     %(cmap)s
         Default='cold_hot'.
+    %(cbar_tick_format)s
     kwargs : dict, optional
         keyword arguments passed to plot_surf_stat_map.
 
@@ -1446,7 +1455,15 @@ def plot_img_on_surf(stat_map, surf_mesh='fsaverage5', mask_img=None,
         cbar_grid = gridspec.GridSpecFromSubplotSpec(3, 3, grid[-1, :])
         cbar_ax = fig.add_subplot(cbar_grid[1])
         axes.append(cbar_ax)
-        fig.colorbar(sm, cax=cbar_ax, orientation='horizontal')
+        # Get custom ticks to set in colorbar
+        ticks = _get_ticks_matplotlib(vmin, vmax, cbar_tick_format, threshold)
+        fig.colorbar(
+            sm,
+            cax=cbar_ax,
+            orientation='horizontal',
+            ticks=ticks,
+            format=cbar_tick_format,
+        )
 
     if title is not None:
         fig.suptitle(title, y=1. - title_h / sum(height_ratios), va="bottom")


Test patch:
diff --git a/nilearn/plotting/tests/test_img_plotting/test_img_plotting.py b/nilearn/plotting/tests/test_img_plotting/test_img_plotting.py
--- a/nilearn/plotting/tests/test_img_plotting/test_img_plotting.py
+++ b/nilearn/plotting/tests/test_img_plotting/test_img_plotting.py
@@ -232,3 +232,60 @@ def test_plotting_functions_radiological_view(img_3d_mni, plotting_func):
     result = plotting_func(img_3d_mni, radiological=True)
     assert result.axes.get("y").radiological is True
     plt.close()
+
+
+functions = [plot_stat_map, plot_img]
+EXPECTED = [(i, ["-10", "-5", "0", "5", "10"]) for i in [0, 0.1, 0.9, 1]]
+EXPECTED += [
+    (i, ["-10", f"-{i}", "0", f"{i}", "10"]) for i in [1.3, 2.5, 3, 4.9, 7.5]
+]
+EXPECTED += [(i, [f"-{i}", "-5", "0", "5", f"{i}"]) for i in [7.6, 8, 9.9]]
+
+
+@pytest.mark.parametrize(
+    "plot_func, threshold, expected_ticks",
+    [(f, e[0], e[1]) for e in EXPECTED for f in functions],
+)
+def test_plot_symmetric_colorbar_threshold(
+    tmp_path, plot_func, threshold, expected_ticks
+):
+    img_data = np.zeros((10, 10, 10))
+    img_data[4:6, 2:4, 4:6] = -10
+    img_data[5:7, 3:7, 3:6] = 10
+    img = Nifti1Image(img_data, affine=np.eye(4))
+    display = plot_func(img, threshold=threshold, colorbar=True)
+    plt.savefig(tmp_path / "test.png")
+    assert [
+        tick.get_text() for tick in display._cbar.ax.get_yticklabels()
+    ] == expected_ticks
+    plt.close()
+
+
+functions = [plot_stat_map]
+EXPECTED2 = [(0, ["0", "2.5", "5", "7.5", "10"])]
+EXPECTED2 += [(i, [f"{i}", "2.5", "5", "7.5", "10"]) for i in [0.1, 0.3, 1.2]]
+EXPECTED2 += [
+    (i, ["0", f"{i}", "5", "7.5", "10"]) for i in [1.3, 1.9, 2.5, 3, 3.7]
+]
+EXPECTED2 += [(i, ["0", "2.5", f"{i}", "7.5", "10"]) for i in [3.8, 4, 5, 6.2]]
+EXPECTED2 += [(i, ["0", "2.5", "5", f"{i}", "10"]) for i in [6.3, 7.5, 8, 8.7]]
+EXPECTED2 += [(i, ["0", "2.5", "5", "7.5", f"{i}"]) for i in [8.8, 9, 9.9]]
+
+
+@pytest.mark.parametrize(
+    "plot_func, threshold, expected_ticks",
+    [(f, e[0], e[1]) for e in EXPECTED2 for f in functions],
+)
+def test_plot_asymmetric_colorbar_threshold(
+    tmp_path, plot_func, threshold, expected_ticks
+):
+    img_data = np.zeros((10, 10, 10))
+    img_data[4:6, 2:4, 4:6] = 5
+    img_data[5:7, 3:7, 3:6] = 10
+    img = Nifti1Image(img_data, affine=np.eye(4))
+    display = plot_func(img, threshold=threshold, colorbar=True)
+    plt.savefig(tmp_path / "test.png")
+    assert [
+        tick.get_text() for tick in display._cbar.ax.get_yticklabels()
+    ] == expected_ticks
+    plt.close()
diff --git a/nilearn/plotting/tests/test_surf_plotting.py b/nilearn/plotting/tests/test_surf_plotting.py
--- a/nilearn/plotting/tests/test_surf_plotting.py
+++ b/nilearn/plotting/tests/test_surf_plotting.py
@@ -809,8 +809,7 @@ def test_plot_surf_roi_colorbar_vmin_equal_across_engines(kwargs):
         mesh, roi_map=roi_map, colorbar=True, engine="plotly", **kwargs
     )
     assert (
-        int(mpl_plot.axes[-1].get_yticklabels()[0].get_text())
-        == plotly_plot.figure.data[1]["cmin"]
+        mpl_plot.axes[-1].get_ylim()[0] == plotly_plot.figure.data[1]["cmin"]
     )
 
 
@@ -1046,10 +1045,13 @@ def test_plot_surf_contours_error(rng):
     (0, np.nextafter(0, 1), "%.1f", [0.e+000, 5.e-324]),
 ])
 def test_get_ticks_matplotlib(vmin, vmax, cbar_tick_format, expected):
-    ticks = _get_ticks_matplotlib(vmin, vmax, cbar_tick_format)
+    ticks = _get_ticks_matplotlib(vmin, vmax, cbar_tick_format, threshold=None)
     assert 1 <= len(ticks) <= 5
     assert ticks[0] == vmin and ticks[-1] == vmax
-    assert len(ticks) == len(expected) and (ticks == expected).all()
+    assert (
+        len(np.unique(ticks)) == len(expected)
+        and (np.unique(ticks) == expected).all()
+    )
 
 
 def test_compute_facecolors_matplotlib():