Skip to content

Commit 31785c2

Browse files
phobsonjklymak
authored andcommitted
ENH: Add PiecewiseLinearNorm and tests
Borrows heavily from @Tillsen's solution found on StackOverflow here: http://goo.gl/RPXMYB Used with his permission dicussesd on Github here: https://github.com/matplotlib/matplotlib/pull/3858` TST: add tests for DivergingNorm DOC: add to colors_api.rst DOC: add tutorial FIX: fix extend=both DOC: add new example
1 parent 2c54229 commit 31785c2

File tree

9 files changed

+298
-54
lines changed

9 files changed

+298
-54
lines changed

doc/api/colors_api.rst

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Classes
2525

2626
BoundaryNorm
2727
Colormap
28+
DivergingNorm
2829
LightSource
2930
LinearSegmentedColormap
3031
ListedColormap
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""
2+
=====================================
3+
DivergingNorm colormap normalization
4+
=====================================
5+
6+
Sometimes we want to have a different colormap on either side of a
7+
conceptual center point, and we want those two colormaps to have
8+
different linear scales. An example is a topographic map where the land
9+
and ocean have a center at zero, but land typically has a greater
10+
elevation range than the water has depth range, and they are often
11+
represented by a different colormap.
12+
"""
13+
14+
import numpy as np
15+
import matplotlib.pyplot as plt
16+
import matplotlib.cbook as cbook
17+
import matplotlib.colors as colors
18+
19+
filename = cbook.get_sample_data('topobathy.npz', asfileobj=False)
20+
with np.load(filename) as dem:
21+
topo = dem['topo']
22+
longitude = dem['longitude']
23+
latitude = dem['latitude']
24+
25+
fig, ax = plt.subplots(constrained_layout=True)
26+
# make a colormap that has land and ocean clearly delineated and of the
27+
# same length (256 + 256)
28+
colors_undersea = plt.cm.terrain(np.linspace(0, 0.17, 256))
29+
colors_land = plt.cm.terrain(np.linspace(0.25, 1, 256))
30+
all_colors = np.vstack((colors_undersea, colors_land))
31+
terrain_map = colors.LinearSegmentedColormap.from_list('terrain_map',
32+
all_colors)
33+
34+
# make the norm: Note the center is offset so that the land has more
35+
# dynamic range:
36+
divnorm = colors.DivergingNorm(vmin=-500, vcenter=0, vmax=4000)
37+
38+
pcm = ax.pcolormesh(longitude, latitude, topo, rasterized=True, norm=divnorm,
39+
cmap=terrain_map,)
40+
ax.set_xlabel('Lon $[^o E]$')
41+
ax.set_ylabel('Lat $[^o N]$')
42+
ax.set_aspect(1 / np.cos(np.deg2rad(49)))
43+
fig.colorbar(pcm, shrink=0.6, extend='both', label='Elevation [m]')
44+
plt.show()

lib/matplotlib/colorbar.py

+15-16
Original file line numberDiff line numberDiff line change
@@ -875,8 +875,8 @@ def _process_values(self, b=None):
875875
+ self._boundaries[1:])
876876
if isinstance(self.norm, colors.NoNorm):
877877
self._values = (self._values + 0.00001).astype(np.int16)
878-
return
879-
self._values = np.array(self.values)
878+
else:
879+
self._values = np.array(self.values)
880880
return
881881
if self.values is not None:
882882
self._values = np.array(self.values)
@@ -1113,20 +1113,19 @@ def _locate(self, x):
11131113
b = self.norm(self._boundaries, clip=False).filled()
11141114
xn = self.norm(x, clip=False).filled()
11151115

1116-
# The rest is linear interpolation with extrapolation at ends.
1117-
ii = np.searchsorted(b, xn)
1118-
i0 = ii - 1
1119-
itop = (ii == len(b))
1120-
ibot = (ii == 0)
1121-
i0[itop] -= 1
1122-
ii[itop] -= 1
1123-
i0[ibot] += 1
1124-
ii[ibot] += 1
1125-
1126-
y = self._y
1127-
db = b[ii] - b[i0]
1128-
dy = y[ii] - y[i0]
1129-
z = y[i0] + (xn - b[i0]) * dy / db
1116+
bunique = b
1117+
yunique = self._y
1118+
# trim extra b values at beginning and end if they are
1119+
# not unique. These are here for extended colorbars, and are not
1120+
# wanted for the interpolation.
1121+
if b[0] == b[1]:
1122+
bunique = bunique[1:]
1123+
yunique = yunique[1:]
1124+
if b[-1] == b[-2]:
1125+
bunique = bunique[:-1]
1126+
yunique = yunique[:-1]
1127+
1128+
z = np.interp(xn, bunique, yunique)
11301129
return z
11311130

11321131
def set_alpha(self, alpha):

lib/matplotlib/colors.py

+70
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,76 @@ def scaled(self):
958958
return self.vmin is not None and self.vmax is not None
959959

960960

961+
class DivergingNorm(Normalize):
962+
def __init__(self, vcenter, vmin=None, vmax=None):
963+
"""
964+
Normalize data with a set center.
965+
966+
Useful when mapping data with an unequal rates of change around a
967+
conceptual center, e.g., data that range from -2 to 4, with 0 as
968+
the midpoint.
969+
970+
Parameters
971+
----------
972+
vcenter : float
973+
The data value that defines ``0.5`` in the normalization.
974+
vmin : float, optional
975+
The data value that defines ``0.0`` in the normalization.
976+
Defaults to the min value of the dataset.
977+
vmax : float, optional
978+
The data value that defines ``1.0`` in the normalization.
979+
Defaults to the the max value of the dataset.
980+
981+
Examples
982+
--------
983+
This maps data value -4000 to 0., 0 to 0.5, and +10000 to 1.0; data
984+
between is linearly interpolated::
985+
986+
>>> import matplotlib.colors as mcolors
987+
>>> offset = mcolors.DivergingNorm(vmin=-4000.,
988+
vcenter=0., vmax=10000)
989+
>>> data = [-4000., -2000., 0., 2500., 5000., 7500., 10000.]
990+
>>> offset(data)
991+
array([0., 0.25, 0.5, 0.625, 0.75, 0.875, 1.0])
992+
"""
993+
994+
self.vcenter = vcenter
995+
self.vmin = vmin
996+
self.vmax = vmax
997+
if vcenter is not None and vmax is not None and vcenter >= vmax:
998+
raise ValueError('vmin, vcenter, and vmax must be in '
999+
'ascending order')
1000+
if vcenter is not None and vmin is not None and vcenter <= vmin:
1001+
raise ValueError('vmin, vcenter, and vmax must be in '
1002+
'ascending order')
1003+
1004+
def autoscale_None(self, A):
1005+
"""
1006+
Get vmin and vmax, and then clip at vcenter
1007+
"""
1008+
super().autoscale_None(A)
1009+
if self.vmin > self.vcenter:
1010+
self.vmin = self.vcenter
1011+
if self.vmax < self.vcenter:
1012+
self.vmax = self.vcenter
1013+
1014+
def __call__(self, value, clip=None):
1015+
"""
1016+
Map value to the interval [0, 1]. The clip argument is unused.
1017+
"""
1018+
result, is_scalar = self.process_value(value)
1019+
self.autoscale_None(result) # sets self.vmin, self.vmax if None
1020+
1021+
if not self.vmin <= self.vcenter <= self.vmax:
1022+
raise ValueError("vmin, vcenter, vmax must increase monotonically")
1023+
result = np.ma.masked_array(
1024+
np.interp(result, [self.vmin, self.vcenter, self.vmax],
1025+
[0, 0.5, 1.]), mask=np.ma.getmask(result))
1026+
if is_scalar:
1027+
result = np.atleast_1d(result)[0]
1028+
return result
1029+
1030+
9611031
class LogNorm(Normalize):
9621032
"""Normalize a given value to the 0-1 range on a log scale."""
9631033

Binary file not shown.

lib/matplotlib/tests/test_colorbar.py

+20-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
from matplotlib import rc_context
55
from matplotlib.testing.decorators import image_comparison
66
import matplotlib.pyplot as plt
7-
from matplotlib.colors import BoundaryNorm, LogNorm, PowerNorm, Normalize
7+
from matplotlib.colors import (BoundaryNorm, LogNorm, PowerNorm, Normalize,
8+
DivergingNorm)
89
from matplotlib.cm import get_cmap
910
from matplotlib.colorbar import ColorbarBase, _ColorbarLogLocator
1011
from matplotlib.ticker import LogLocator, LogFormatter, FixedLocator
@@ -539,3 +540,21 @@ def test_colorbar_inverted_ticks():
539540
cbar.ax.invert_yaxis()
540541
np.testing.assert_allclose(ticks, cbar.get_ticks())
541542
np.testing.assert_allclose(minorticks, cbar.get_ticks(minor=True))
543+
544+
545+
def test_extend_colorbar_customnorm():
546+
# This was a funny error with DivergingNorm, maybe with other norms,
547+
# when extend='both'
548+
N = 100
549+
X, Y = np.mgrid[-3:3:complex(0, N), -2:2:complex(0, N)]
550+
Z1 = np.exp(-X**2 - Y**2)
551+
Z2 = np.exp(-(X - 1)**2 - (Y - 1)**2)
552+
Z = (Z1 - Z2) * 2
553+
554+
fig, ax = plt.subplots(2, 1)
555+
pcm = ax[0].pcolormesh(X, Y, Z,
556+
norm=DivergingNorm(vcenter=0., vmin=-2, vmax=1),
557+
cmap='RdBu_r')
558+
cb = fig.colorbar(pcm, ax=ax[0], extend='both')
559+
np.testing.assert_allclose(cb.ax.get_position().extents,
560+
[0.78375, 0.536364, 0.796147, 0.9], rtol=1e-3)

lib/matplotlib/tests/test_colors.py

+90
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,96 @@ def test_Normalize():
221221
assert 0 < norm(1 + 50 * eps) < 1
222222

223223

224+
def test_DivergingNorm_autoscale():
225+
norm = mcolors.DivergingNorm(vcenter=20)
226+
norm.autoscale([10, 20, 30, 40])
227+
assert norm.vmin == 10.
228+
assert norm.vmax == 40.
229+
230+
231+
def test_DivergingNorm_autoscale_None_vmin():
232+
norm = mcolors.DivergingNorm(2, vmin=0, vmax=None)
233+
norm.autoscale_None([1, 2, 3, 4, 5])
234+
assert norm(5) == 1
235+
assert norm.vmax == 5
236+
237+
238+
def test_DivergingNorm_autoscale_None_vmax():
239+
norm = mcolors.DivergingNorm(2, vmin=None, vmax=10)
240+
norm.autoscale_None([1, 2, 3, 4, 5])
241+
assert norm(1) == 0
242+
assert norm.vmin == 1
243+
244+
245+
def test_DivergingNorm_scale():
246+
norm = mcolors.DivergingNorm(2)
247+
assert norm.scaled() is False
248+
norm([1, 2, 3, 4])
249+
assert norm.scaled() is True
250+
251+
252+
def test_DivergingNorm_scaleout_center():
253+
# test the vmin never goes above vcenter
254+
norm = mcolors.DivergingNorm(vcenter=0)
255+
x = norm([1, 2, 3, 5])
256+
assert norm.vmin == 0
257+
assert norm.vmax == 5
258+
259+
260+
def test_DivergingNorm_scaleout_center_max():
261+
# test the vmax never goes below vcenter
262+
norm = mcolors.DivergingNorm(vcenter=0)
263+
x = norm([-1, -2, -3, -5])
264+
assert norm.vmax == 0
265+
assert norm.vmin == -5
266+
267+
268+
def test_DivergingNorm_Even():
269+
norm = mcolors.DivergingNorm(vmin=-1, vcenter=0, vmax=4)
270+
vals = np.array([-1.0, -0.5, 0.0, 1.0, 2.0, 3.0, 4.0])
271+
expected = np.array([0.0, 0.25, 0.5, 0.625, 0.75, 0.875, 1.0])
272+
assert_array_equal(norm(vals), expected)
273+
274+
275+
def test_DivergingNorm_Odd():
276+
norm = mcolors.DivergingNorm(vmin=-2, vcenter=0, vmax=5)
277+
vals = np.array([-2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0])
278+
expected = np.array([0.0, 0.25, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0])
279+
assert_array_equal(norm(vals), expected)
280+
281+
282+
def test_DivergingNorm_VminEqualsVcenter():
283+
with pytest.raises(ValueError):
284+
norm = mcolors.DivergingNorm(vmin=-2, vcenter=-2, vmax=2)
285+
286+
287+
def test_DivergingNorm_VmaxEqualsVcenter():
288+
with pytest.raises(ValueError):
289+
norm = mcolors.DivergingNorm(vmin=-2, vcenter=2, vmax=2)
290+
291+
292+
def test_DivergingNorm_VminGTVcenter():
293+
with pytest.raises(ValueError):
294+
norm = mcolors.DivergingNorm(vmin=10, vcenter=0, vmax=20)
295+
296+
297+
def test_DivergingNorm_DivergingNorm_VminGTVmax():
298+
with pytest.raises(ValueError):
299+
norm = mcolors.DivergingNorm(vmin=10, vcenter=0, vmax=5)
300+
301+
302+
def test_DivergingNorm_VcenterGTVmax():
303+
vals = np.arange(50)
304+
with pytest.raises(ValueError):
305+
norm = mcolors.DivergingNorm(vmin=10, vcenter=25, vmax=20)
306+
307+
308+
def test_DivergingNorm_premature_scaling():
309+
norm = mcolors.DivergingNorm(vcenter=2)
310+
with pytest.raises(ValueError):
311+
norm.inverse(np.array([0.1, 0.5, 0.9]))
312+
313+
224314
def test_SymLogNorm():
225315
"""
226316
Test SymLogNorm behavior

lib/matplotlib/tests/test_contour.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,10 @@ def test_contourf_log_extension():
404404

405405

406406
@image_comparison(baseline_images=['contour_addlines'],
407-
extensions=['png'], remove_text=True, style='mpl20')
407+
extensions=['png'], remove_text=True, style='mpl20',
408+
tol=0.03)
409+
# tolerance is because image changed minutely when tick finding on
410+
# colorbars was cleaned up...
408411
def test_contour_addlines():
409412
fig, ax = plt.subplots()
410413
np.random.seed(19680812)

0 commit comments

Comments
 (0)