Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 4356b67

Browse files
authored
Merge pull request #17534 from efiring/fmaussion-extended-BoundaryNorm
Fmaussion extended boundary norm
2 parents e53a00c + f7cc402 commit 4356b67

File tree

7 files changed

+258
-36
lines changed

7 files changed

+258
-36
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
New "extend" keyword to colors.BoundaryNorm
2+
-------------------------------------------
3+
4+
`~.colors.BoundaryNorm` now has an ``extend`` keyword argument, analogous to
5+
``extend`` in `~.axes.Axes.contourf`. When set to 'both', 'min', or 'max',
6+
it maps the corresponding out-of-range values to `~.colors.Colormap`
7+
lookup-table indices near the appropriate ends of their range so that the
8+
colors for out-of range values are adjacent to, but distinct from, their
9+
in-range neighbors. The colorbar inherits the ``extend`` argument from the
10+
norm, so with ``extend='both'``, for example, the colorbar will have triangular
11+
extensions for out-of-range values with colors that differ from adjacent in-range
12+
colors.
13+
14+
.. plot::
15+
16+
import matplotlib.pyplot as plt
17+
from matplotlib.colors import BoundaryNorm
18+
import numpy as np
19+
20+
# Make the data
21+
dx, dy = 0.05, 0.05
22+
y, x = np.mgrid[slice(1, 5 + dy, dy),
23+
slice(1, 5 + dx, dx)]
24+
z = np.sin(x) ** 10 + np.cos(10 + y * x) * np.cos(x)
25+
z = z[:-1, :-1]
26+
27+
# Z roughly varies between -1 and +1.
28+
# Color boundary levels range from -0.8 to 0.8, so there are out-of-bounds
29+
# areas.
30+
levels = [-0.8, -0.5, -0.2, 0.2, 0.5, 0.8]
31+
cmap = plt.get_cmap('PiYG')
32+
33+
fig, axs = plt.subplots(nrows=2, constrained_layout=True, sharex=True)
34+
35+
# Before this change:
36+
norm = BoundaryNorm(levels, ncolors=cmap.N)
37+
im = axs[0].pcolormesh(x, y, z, cmap=cmap, norm=norm)
38+
fig.colorbar(im, ax=axs[0], extend='both')
39+
axs[0].axis([x.min(), x.max(), y.min(), y.max()])
40+
axs[0].set_title("Colorbar with extend='both'")
41+
42+
# With the new keyword:
43+
norm = BoundaryNorm(levels, ncolors=cmap.N, extend='both')
44+
im = axs[1].pcolormesh(x, y, z, cmap=cmap, norm=norm)
45+
fig.colorbar(im, ax=axs[1]) # note that the colorbar is updated accordingly
46+
axs[1].axis([x.min(), x.max(), y.min(), y.max()])
47+
axs[1].set_title("BoundaryNorm with extend='both'")
48+
49+
plt.show()

lib/matplotlib/colorbar.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ def __init__(self, ax, cmap=None,
402402
boundaries=None,
403403
orientation='vertical',
404404
ticklocation='auto',
405-
extend='neither',
405+
extend=None,
406406
spacing='uniform', # uniform or proportional
407407
ticks=None,
408408
format=None,
@@ -430,6 +430,11 @@ def __init__(self, ax, cmap=None,
430430
cmap = cm.get_cmap()
431431
if norm is None:
432432
norm = colors.Normalize()
433+
if extend is None:
434+
if hasattr(norm, 'extend'):
435+
extend = norm.extend
436+
else:
437+
extend = 'neither'
433438
self.alpha = alpha
434439
self.cmap = cmap
435440
self.norm = norm

lib/matplotlib/colors.py

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,7 @@ def __call__(self, X, alpha=None, bytes=False):
530530
"""
531531
Parameters
532532
----------
533-
X : float, ndarray
533+
X : float or int, ndarray or scalar
534534
The data value(s) to convert to RGBA.
535535
For floats, X should be in the interval ``[0.0, 1.0]`` to
536536
return the RGBA values ``X*100`` percent along the Colormap line.
@@ -1410,7 +1410,7 @@ class BoundaryNorm(Normalize):
14101410
interpolation, but using integers seems simpler, and reduces the number of
14111411
conversions back and forth between integer and floating point.
14121412
"""
1413-
def __init__(self, boundaries, ncolors, clip=False):
1413+
def __init__(self, boundaries, ncolors, clip=False, *, extend='neither'):
14141414
"""
14151415
Parameters
14161416
----------
@@ -1427,25 +1427,50 @@ def __init__(self, boundaries, ncolors, clip=False):
14271427
they are below ``boundaries[0]`` or mapped to *ncolors* if they are
14281428
above ``boundaries[-1]``. These are then converted to valid indices
14291429
by `Colormap.__call__`.
1430+
extend : {'neither', 'both', 'min', 'max'}, default: 'neither'
1431+
Extend the number of bins to include one or both of the
1432+
regions beyond the boundaries. For example, if ``extend``
1433+
is 'min', then the color to which the region between the first
1434+
pair of boundaries is mapped will be distinct from the first
1435+
color in the colormap, and by default a
1436+
`~matplotlib.colorbar.Colorbar` will be drawn with
1437+
the triangle extension on the left or lower end.
1438+
1439+
Returns
1440+
-------
1441+
int16 scalar or array
14301442
14311443
Notes
14321444
-----
14331445
*boundaries* defines the edges of bins, and data falling within a bin
14341446
is mapped to the color with the same index.
14351447
1436-
If the number of bins doesn't equal *ncolors*, the color is chosen
1437-
by linear interpolation of the bin number onto color numbers.
1448+
If the number of bins, including any extensions, is less than
1449+
*ncolors*, the color index is chosen by linear interpolation, mapping
1450+
the ``[0, nbins - 1]`` range onto the ``[0, ncolors - 1]`` range.
14381451
"""
1452+
if clip and extend != 'neither':
1453+
raise ValueError("'clip=True' is not compatible with 'extend'")
14391454
self.clip = clip
14401455
self.vmin = boundaries[0]
14411456
self.vmax = boundaries[-1]
14421457
self.boundaries = np.asarray(boundaries)
14431458
self.N = len(self.boundaries)
14441459
self.Ncmap = ncolors
1445-
if self.N - 1 == self.Ncmap:
1446-
self._interp = False
1447-
else:
1448-
self._interp = True
1460+
self.extend = extend
1461+
1462+
self._N = self.N - 1 # number of colors needed
1463+
self._offset = 0
1464+
if extend in ('min', 'both'):
1465+
self._N += 1
1466+
self._offset = 1
1467+
if extend in ('max', 'both'):
1468+
self._N += 1
1469+
if self._N > self.Ncmap:
1470+
raise ValueError(f"There are {self._N} color bins including "
1471+
f"extensions, but ncolors = {ncolors}; "
1472+
"ncolors must equal or exceed the number of "
1473+
"bins")
14491474

14501475
def __call__(self, value, clip=None):
14511476
if clip is None:
@@ -1459,11 +1484,9 @@ def __call__(self, value, clip=None):
14591484
max_col = self.Ncmap - 1
14601485
else:
14611486
max_col = self.Ncmap
1462-
iret = np.zeros(xx.shape, dtype=np.int16)
1463-
for i, b in enumerate(self.boundaries):
1464-
iret[xx >= b] = i
1465-
if self._interp:
1466-
scalefac = (self.Ncmap - 1) / (self.N - 2)
1487+
iret = np.digitize(xx, self.boundaries) - 1 + self._offset
1488+
if self.Ncmap > self._N:
1489+
scalefac = (self.Ncmap - 1) / (self._N - 1)
14671490
iret = (iret * scalefac).astype(np.int16)
14681491
iret[xx < self.vmin] = -1
14691492
iret[xx >= self.vmax] = max_col

lib/matplotlib/tests/test_colors.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,84 @@ def test_BoundaryNorm():
265265
vals = np.ma.masked_invalid([np.Inf])
266266
assert np.all(bn(vals).mask)
267267

268+
# Incompatible extend and clip
269+
with pytest.raises(ValueError, match="not compatible"):
270+
mcolors.BoundaryNorm(np.arange(4), 5, extend='both', clip=True)
271+
272+
# Too small ncolors argument
273+
with pytest.raises(ValueError, match="ncolors must equal or exceed"):
274+
mcolors.BoundaryNorm(np.arange(4), 2)
275+
276+
with pytest.raises(ValueError, match="ncolors must equal or exceed"):
277+
mcolors.BoundaryNorm(np.arange(4), 3, extend='min')
278+
279+
with pytest.raises(ValueError, match="ncolors must equal or exceed"):
280+
mcolors.BoundaryNorm(np.arange(4), 4, extend='both')
281+
282+
# Testing extend keyword, with interpolation (large cmap)
283+
bounds = [1, 2, 3]
284+
cmap = cm.get_cmap('viridis')
285+
mynorm = mcolors.BoundaryNorm(bounds, cmap.N, extend='both')
286+
refnorm = mcolors.BoundaryNorm([0] + bounds + [4], cmap.N)
287+
x = np.random.randn(100) * 10 + 2
288+
ref = refnorm(x)
289+
ref[ref == 0] = -1
290+
ref[ref == cmap.N - 1] = cmap.N
291+
assert_array_equal(mynorm(x), ref)
292+
293+
# Without interpolation
294+
cmref = mcolors.ListedColormap(['blue', 'red'])
295+
cmref.set_over('black')
296+
cmref.set_under('white')
297+
cmshould = mcolors.ListedColormap(['white', 'blue', 'red', 'black'])
298+
299+
refnorm = mcolors.BoundaryNorm(bounds, cmref.N)
300+
mynorm = mcolors.BoundaryNorm(bounds, cmshould.N, extend='both')
301+
assert mynorm.vmin == refnorm.vmin
302+
assert mynorm.vmax == refnorm.vmax
303+
304+
assert mynorm(bounds[0] - 0.1) == -1 # under
305+
assert mynorm(bounds[0] + 0.1) == 1 # first bin -> second color
306+
assert mynorm(bounds[-1] - 0.1) == cmshould.N - 2 # next-to-last color
307+
assert mynorm(bounds[-1] + 0.1) == cmshould.N # over
308+
309+
x = [-1, 1.2, 2.3, 9.6]
310+
assert_array_equal(cmshould(mynorm(x)), cmshould([0, 1, 2, 3]))
311+
x = np.random.randn(100) * 10 + 2
312+
assert_array_equal(cmshould(mynorm(x)), cmref(refnorm(x)))
313+
314+
# Just min
315+
cmref = mcolors.ListedColormap(['blue', 'red'])
316+
cmref.set_under('white')
317+
cmshould = mcolors.ListedColormap(['white', 'blue', 'red'])
318+
319+
assert cmref.N == 2
320+
assert cmshould.N == 3
321+
refnorm = mcolors.BoundaryNorm(bounds, cmref.N)
322+
mynorm = mcolors.BoundaryNorm(bounds, cmshould.N, extend='min')
323+
assert mynorm.vmin == refnorm.vmin
324+
assert mynorm.vmax == refnorm.vmax
325+
x = [-1, 1.2, 2.3]
326+
assert_array_equal(cmshould(mynorm(x)), cmshould([0, 1, 2]))
327+
x = np.random.randn(100) * 10 + 2
328+
assert_array_equal(cmshould(mynorm(x)), cmref(refnorm(x)))
329+
330+
# Just max
331+
cmref = mcolors.ListedColormap(['blue', 'red'])
332+
cmref.set_over('black')
333+
cmshould = mcolors.ListedColormap(['blue', 'red', 'black'])
334+
335+
assert cmref.N == 2
336+
assert cmshould.N == 3
337+
refnorm = mcolors.BoundaryNorm(bounds, cmref.N)
338+
mynorm = mcolors.BoundaryNorm(bounds, cmshould.N, extend='max')
339+
assert mynorm.vmin == refnorm.vmin
340+
assert mynorm.vmax == refnorm.vmax
341+
x = [1.2, 2.3, 4]
342+
assert_array_equal(cmshould(mynorm(x)), cmshould([0, 1, 2]))
343+
x = np.random.randn(100) * 10 + 2
344+
assert_array_equal(cmshould(mynorm(x)), cmref(refnorm(x)))
345+
268346

269347
@pytest.mark.parametrize("vmin,vmax", [[-1, 2], [3, 1]])
270348
def test_lognorm_invalid(vmin, vmax):
@@ -537,6 +615,35 @@ def test_cmap_and_norm_from_levels_and_colors():
537615
ax.tick_params(labelleft=False, labelbottom=False)
538616

539617

618+
@image_comparison(baseline_images=['boundarynorm_and_colorbar'],
619+
extensions=['png'])
620+
def test_boundarynorm_and_colorbarbase():
621+
622+
# Make a figure and axes with dimensions as desired.
623+
fig = plt.figure()
624+
ax1 = fig.add_axes([0.05, 0.80, 0.9, 0.15])
625+
ax2 = fig.add_axes([0.05, 0.475, 0.9, 0.15])
626+
ax3 = fig.add_axes([0.05, 0.15, 0.9, 0.15])
627+
628+
# Set the colormap and bounds
629+
bounds = [-1, 2, 5, 7, 12, 15]
630+
cmap = cm.get_cmap('viridis')
631+
632+
# Default behavior
633+
norm = mcolors.BoundaryNorm(bounds, cmap.N)
634+
cb1 = mcolorbar.ColorbarBase(ax1, cmap=cmap, norm=norm, extend='both',
635+
orientation='horizontal')
636+
# New behavior
637+
norm = mcolors.BoundaryNorm(bounds, cmap.N, extend='both')
638+
cb2 = mcolorbar.ColorbarBase(ax2, cmap=cmap, norm=norm,
639+
orientation='horizontal')
640+
641+
# User can still force to any extend='' if really needed
642+
norm = mcolors.BoundaryNorm(bounds, cmap.N, extend='both')
643+
cb3 = mcolorbar.ColorbarBase(ax3, cmap=cmap, norm=norm,
644+
extend='neither', orientation='horizontal')
645+
646+
540647
def test_cmap_and_norm_from_levels_and_colors2():
541648
levels = [-1, 2, 2.5, 3]
542649
colors = ['red', (0, 1, 0), 'blue', (0.5, 0.5, 0.5), (0.0, 0.0, 0.0, 1.0)]

tutorials/colors/colorbar_only.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,31 @@
3838
fig.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap),
3939
cax=ax, orientation='horizontal', label='Some Units')
4040

41+
42+
###############################################################################
43+
# Extended colorbar with continuous colorscale
44+
# --------------------------------------------
45+
#
46+
# The second example shows how to make a discrete colorbar based on a
47+
# continuous cmap. With the "extend" keyword argument the appropriate colors
48+
# are chosen to fill the colorspace, including the extensions:
49+
fig, ax = plt.subplots(figsize=(6, 1))
50+
fig.subplots_adjust(bottom=0.5)
51+
52+
cmap = mpl.cm.viridis
53+
bounds = [-1, 2, 5, 7, 12, 15]
54+
norm = mpl.colors.BoundaryNorm(bounds, cmap.N, extend='both')
55+
cb2 = mpl.colorbar.ColorbarBase(ax, cmap=cmap,
56+
norm=norm,
57+
orientation='horizontal')
58+
cb2.set_label("Discrete intervals with extend='both' keyword")
59+
fig.show()
60+
4161
###############################################################################
4262
# Discrete intervals colorbar
4363
# ---------------------------
4464
#
45-
# The second example illustrates the use of a
65+
# The third example illustrates the use of a
4666
# :class:`~matplotlib.colors.ListedColormap` which generates a colormap from a
4767
# set of listed colors, `.colors.BoundaryNorm` which generates a colormap
4868
# index based on discrete intervals and extended ends to show the "over" and
@@ -54,11 +74,15 @@
5474
# bounds array must be one greater than the length of the color list. The
5575
# bounds must be monotonically increasing.
5676
#
57-
# This time we pass some more arguments in addition to previous arguments to
58-
# `~.Figure.colorbar`. For the out-of-range values to
59-
# display on the colorbar, we have to use the *extend* keyword argument. To use
60-
# *extend*, you must specify two extra boundaries. Finally spacing argument
61-
# ensures that intervals are shown on colorbar proportionally.
77+
# This time we pass additional arguments to
78+
# `~.Figure.colorbar`. For the out-of-range values to display on the colorbar
79+
# without using the *extend* keyword with
80+
# `.colors.BoundaryNorm`, we have to use the *extend* keyword argument directly
81+
# in the colorbar call, and supply an additional boundary on each end of the
82+
# range. Here we also
83+
# use the spacing argument to make
84+
# the length of each colorbar segment proportional to its corresponding
85+
# interval.
6286

6387
fig, ax = plt.subplots(figsize=(6, 1))
6488
fig.subplots_adjust(bottom=0.5)
@@ -72,7 +96,7 @@
7296
fig.colorbar(
7397
mpl.cm.ScalarMappable(cmap=cmap, norm=norm),
7498
cax=ax,
75-
boundaries=[0] + bounds + [13],
99+
boundaries=[0] + bounds + [13], # Adding values for extensions.
76100
extend='both',
77101
ticks=bounds,
78102
spacing='proportional',
@@ -84,7 +108,7 @@
84108
# Colorbar with custom extension lengths
85109
# --------------------------------------
86110
#
87-
# Here we illustrate the use of custom length colorbar extensions, used on a
111+
# Here we illustrate the use of custom length colorbar extensions, on a
88112
# colorbar with discrete intervals. To make the length of each extension the
89113
# same as the length of the interior colors, use ``extendfrac='auto'``.
90114

0 commit comments

Comments
 (0)