Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ENH] Colorbar ticks at threshold values #2887

Merged
merged 31 commits into from
Nov 28, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
bce4b22
Set colorbar ticks at threshold values
NicolasGensollen Jun 25, 2021
c7ada75
Add tests
NicolasGensollen Jun 25, 2021
2f2f99c
Fix PEP8 issues
NicolasGensollen Jun 25, 2021
767163d
[circle full] request full build
NicolasGensollen Jun 25, 2021
86eee55
[circle full] threshold non symmetrical colorbars and refactor code
NicolasGensollen Jun 28, 2021
b669d39
[circle full] Fix PEP8
NicolasGensollen Jun 28, 2021
fad61b1
Fixes and refactoring
NicolasGensollen Jun 29, 2021
a43ec87
Add tests
NicolasGensollen Jun 29, 2021
4ea410f
Fix PEP8 issues
NicolasGensollen Jun 30, 2021
3469345
More refactoring
NicolasGensollen Jun 30, 2021
a522edb
[circle full] extend to plot_surf
NicolasGensollen Jun 30, 2021
6dd669a
[circle full] Fix
NicolasGensollen Jun 30, 2021
fcd2637
Add a whatsnew entry
NicolasGensollen Jul 1, 2021
09b15c0
Merge branch 'main' into colorbar-ticks-at-threshold
Remi-Gau Aug 4, 2023
5bcad37
rm colorbar
Remi-Gau Aug 4, 2023
59f8122
Apply suggestions from code review
Remi-Gau Aug 4, 2023
51b0fb8
rm extra code
Remi-Gau Aug 4, 2023
35215c5
rm extra code
Remi-Gau Aug 4, 2023
5f7bf3e
bring back code for assymetric colorbars
Remi-Gau Aug 4, 2023
16a0059
tests pass
Remi-Gau Aug 4, 2023
476ce8c
semantic line break
Remi-Gau Aug 5, 2023
ac1c326
fix tests
Remi-Gau Aug 5, 2023
c202298
Merge branch 'main' into colorbar-ticks-at-threshold
Remi-Gau Aug 28, 2023
42eba50
isort
Remi-Gau Aug 28, 2023
fead0f4
Merge branch 'main' into colorbar-ticks-at-threshold
Remi-Gau Sep 19, 2023
73e7b1b
add threshold
Remi-Gau Sep 20, 2023
ae4a92f
Fix test
Nov 24, 2023
3a18f5c
Allow with plot_img_on_surf
Nov 24, 2023
1b4c8f7
Merge branch 'main' into colorbar-ticks-at-threshold
ymzayek Nov 24, 2023
df7470b
Fix test 2
Nov 27, 2023
91ea227
Merge branch 'main' into colorbar-ticks-at-threshold
ymzayek Nov 27, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions doc/changes/latest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Fixes

- Relax the :func:`~nilearn.interfaces.fmriprep.load_confounds` confounds selection on `cosine` as not all confound files contained the variables (:gh:`3816` by `Hao-Ting Wang`_).


Remi-Gau marked this conversation as resolved.
Show resolved Hide resolved
Enhancements
------------

Expand All @@ -39,6 +40,9 @@ Enhancements

- Make return key names in the description file of destrieux surface consistent with :func:`~datasets.fetch_atlas_surf_destrieux` (:gh:`3774` by `Tarun Samanta`_).

- When plotting thresholded statistical maps with a colorbar, the threshold value(s) will now be displayed as tick labels on the colorbar (:gh:`#2833` by `Nicolas Gensollen`_).


Remi-Gau marked this conversation as resolved.
Show resolved Hide resolved
Changes
-------

Expand Down
35 changes: 30 additions & 5 deletions nilearn/plotting/displays/_slicers.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,11 +529,7 @@
self._colorbar_ax.set_axis_bgcolor("w")

our_cmap = plt.get_cmap(cmap)
# 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 cbar_vmin != cbar_vmax else 1
ticks = np.linspace(cbar_vmin, cbar_vmax, nb_ticks)
ticks = _get_cbar_ticks(norm.vmin, norm.vmax, offset, nb_ticks=5)

Check warning on line 532 in nilearn/plotting/displays/_slicers.py

View check run for this annotation

Codecov / codecov/patch

nilearn/plotting/displays/_slicers.py#L532

Added line #L532 was not covered by tests
bounds = np.linspace(cbar_vmin, cbar_vmax, our_cmap.N)

# some colormap hacking
Expand Down Expand Up @@ -805,6 +801,35 @@
)


def _get_cbar_ticks(vmin, vmax, offset, nb_ticks=5):

Check warning on line 804 in nilearn/plotting/displays/_slicers.py

View check run for this annotation

Codecov / codecov/patch

nilearn/plotting/displays/_slicers.py#L804

Added line #L804 was not covered by tests
"""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)

Check warning on line 809 in nilearn/plotting/displays/_slicers.py

View check run for this annotation

Codecov / codecov/patch

nilearn/plotting/displays/_slicers.py#L809

Added line #L809 was not covered by tests

# If a threshold is specified, we want two of the ticks 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)

Check warning on line 815 in nilearn/plotting/displays/_slicers.py

View check run for this annotation

Codecov / codecov/patch

nilearn/plotting/displays/_slicers.py#L815

Added line #L815 was not covered by tests
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 exactly 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])

Check warning on line 822 in nilearn/plotting/displays/_slicers.py

View check run for this annotation

Codecov / codecov/patch

nilearn/plotting/displays/_slicers.py#L821-L822

Added lines #L821 - L822 were not covered by tests
else:
# Find the closest 2 ticks
idx_closest = np.sort(np.argpartition(diff, 2)[:2])

Check warning on line 825 in nilearn/plotting/displays/_slicers.py

View check run for this annotation

Codecov / codecov/patch

nilearn/plotting/displays/_slicers.py#L825

Added line #L825 was not covered by tests
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]
return ticks

Check warning on line 830 in nilearn/plotting/displays/_slicers.py

View check run for this annotation

Codecov / codecov/patch

nilearn/plotting/displays/_slicers.py#L827-L830

Added lines #L827 - L830 were not covered by tests


class OrthoSlicer(BaseSlicer):
"""Class to create 3 linked axes for plotting orthogonal \
cuts of 3D maps.
Expand Down
36 changes: 36 additions & 0 deletions nilearn/plotting/img_plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,42 @@
return display


def _get_cropped_cbar_ticks(cbar_vmin, cbar_vmax,

Check warning on line 226 in nilearn/plotting/img_plotting.py

View check run for this annotation

Codecov / codecov/patch

nilearn/plotting/img_plotting.py#L226

Added line #L226 was not covered by tests
threshold=None, n_ticks=5):
"""Helper function for _crop_colobar.
Returns ticks for cropped colorbars.

cbar_tick_locs = cbar.locator.locs
new_tick_locs = _get_cropped_cbar_ticks(
cbar_vmin, cbar_vmax, threshold,
n_ticks=len(cbar_tick_locs))
cbar.set_ticks(new_tick_locs, update_ticks=True)
"""
new_tick_locs = np.linspace(cbar_vmin, cbar_vmax, n_ticks)

Check warning on line 237 in nilearn/plotting/img_plotting.py

View check run for this annotation

Codecov / codecov/patch

nilearn/plotting/img_plotting.py#L237

Added line #L237 was not covered by tests
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 tick in new_tick_locs])
new_tick_locs[idx_closest] = threshold

Check warning on line 243 in nilearn/plotting/img_plotting.py

View check run for this annotation

Codecov / codecov/patch

nilearn/plotting/img_plotting.py#L243

Added line #L243 was not covered by tests
else:
# Case where we do a symmetric thresholding within an
# asymmetric cbar and both threshold values are within bounds
if cbar_vmin <= -threshold <= threshold <= cbar_vmax:
from .displays import _get_cbar_ticks
new_tick_locs = _get_cbar_ticks(

Check warning on line 249 in nilearn/plotting/img_plotting.py

View check run for this annotation

Codecov / codecov/patch

nilearn/plotting/img_plotting.py#L248-L249

Added lines #L248 - L249 were not covered by tests
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 tick in new_tick_locs])
new_tick_locs[idx_closest] = (

Check warning on line 256 in nilearn/plotting/img_plotting.py

View check run for this annotation

Codecov / codecov/patch

nilearn/plotting/img_plotting.py#L256

Added line #L256 was not covered by tests
-threshold if threshold > cbar_vmax else threshold)
return new_tick_locs

Check warning on line 258 in nilearn/plotting/img_plotting.py

View check run for this annotation

Codecov / codecov/patch

nilearn/plotting/img_plotting.py#L258

Added line #L258 was not covered by tests



@fill_doc
def plot_img(img, cut_coords=None, output_file=None, display_mode='ortho',
figure=None, axes=None, title=None, threshold=None,
Expand Down
12 changes: 10 additions & 2 deletions nilearn/plotting/surf_plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
import warnings

Check warning on line 10 in nilearn/plotting/surf_plotting.py

View check run for this annotation

Codecov / codecov/patch

nilearn/plotting/surf_plotting.py#L10

Added line #L10 was not covered by tests


from matplotlib import gridspec
from matplotlib.cm import ScalarMappable
from matplotlib.colorbar import make_axes
Expand Down Expand Up @@ -414,8 +417,8 @@
if cbar_tick_format == "%i" and vmax - vmin < n_ticks - 1:
ticks = np.arange(vmin, vmax + 1)
else:
# remove duplicate ticks when vmin == vmax, or almost
ticks = np.unique(np.linspace(vmin, vmax, n_ticks))
from nilearn.plotting.displays import _get_cbar_ticks
ticks = _get_cbar_ticks(vmin, vmax, threshold, nb_ticks)

Check warning on line 421 in nilearn/plotting/surf_plotting.py

View check run for this annotation

Codecov / codecov/patch

nilearn/plotting/surf_plotting.py#L420-L421

Added lines #L420 - L421 were not covered by tests
return ticks


Expand All @@ -428,6 +431,10 @@
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:
warnings.warn("You provided a non integer threshold "

Check warning on line 435 in nilearn/plotting/surf_plotting.py

View check run for this annotation

Codecov / codecov/patch

nilearn/plotting/surf_plotting.py#L435

Added line #L435 was not covered by tests
"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))
Expand Down Expand Up @@ -597,6 +604,7 @@
cbar_tick_format)
our_cmap, norm = _get_cmap_matplotlib(cmap, vmin, vmax, 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)
Expand Down
61 changes: 61 additions & 0 deletions nilearn/plotting/tests/test_img_plotting/test_img_plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,64 @@ def test_plotting_functions_radiological_view(
result = plotting_func(testdata_3d_for_plotting["img"], radiological=True)
assert result.axes.get("y").radiological is True
plt.close()


import nibabel

from nilearn import plotting

functions = [plotting.plot_stat_map, plotting.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 = nibabel.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 = [plotting.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 = nibabel.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()