-
-
Notifications
You must be signed in to change notification settings - Fork 7.9k
Add DivergingNorm (again, again, again) #12419
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -25,6 +25,7 @@ Classes | |
|
||
BoundaryNorm | ||
Colormap | ||
DivergingNorm | ||
LightSource | ||
LinearSegmentedColormap | ||
ListedColormap | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
""" | ||
===================================== | ||
DivergingNorm colormap normalization | ||
===================================== | ||
Sometimes we want to have a different colormap on either side of a | ||
conceptual center point, and we want those two colormaps to have | ||
different linear scales. An example is a topographic map where the land | ||
and ocean have a center at zero, but land typically has a greater | ||
elevation range than the water has depth range, and they are often | ||
represented by a different colormap. | ||
""" | ||
|
||
import numpy as np | ||
import matplotlib.pyplot as plt | ||
import matplotlib.cbook as cbook | ||
import matplotlib.colors as colors | ||
|
||
filename = cbook.get_sample_data('topobathy.npz', asfileobj=False) | ||
with np.load(filename) as dem: | ||
topo = dem['topo'] | ||
longitude = dem['longitude'] | ||
latitude = dem['latitude'] | ||
|
||
fig, ax = plt.subplots(constrained_layout=True) | ||
# make a colormap that has land and ocean clearly delineated and of the | ||
# same length (256 + 256) | ||
colors_undersea = plt.cm.terrain(np.linspace(0, 0.17, 256)) | ||
colors_land = plt.cm.terrain(np.linspace(0.25, 1, 256)) | ||
all_colors = np.vstack((colors_undersea, colors_land)) | ||
terrain_map = colors.LinearSegmentedColormap.from_list('terrain_map', | ||
all_colors) | ||
|
||
# make the norm: Note the center is offset so that the land has more | ||
# dynamic range: | ||
divnorm = colors.DivergingNorm(vmin=-500, vcenter=0, vmax=4000) | ||
|
||
pcm = ax.pcolormesh(longitude, latitude, topo, rasterized=True, norm=divnorm, | ||
cmap=terrain_map,) | ||
ax.set_xlabel('Lon $[^o E]$') | ||
ax.set_ylabel('Lat $[^o N]$') | ||
ax.set_aspect(1 / np.cos(np.deg2rad(49))) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess that comes from Mercator? kind of obscure if that's not your field, but probably not a big deal. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added explanation: This is just a local "projection" centered at 49 degrees taking into account how much closer the longitude lines are at that lattitude than at the equator and then assuming the lines of longitude are parallel. Not very sophisticated 😉 |
||
fig.colorbar(pcm, shrink=0.6, extend='both', label='Elevation [m]') | ||
plt.show() |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -875,8 +875,8 @@ def _process_values(self, b=None): | |
+ self._boundaries[1:]) | ||
if isinstance(self.norm, colors.NoNorm): | ||
self._values = (self._values + 0.00001).astype(np.int16) | ||
return | ||
self._values = np.array(self.values) | ||
else: | ||
self._values = np.array(self.values) | ||
return | ||
if self.values is not None: | ||
self._values = np.array(self.values) | ||
|
@@ -1113,20 +1113,19 @@ def _locate(self, x): | |
b = self.norm(self._boundaries, clip=False).filled() | ||
xn = self.norm(x, clip=False).filled() | ||
|
||
# The rest is linear interpolation with extrapolation at ends. | ||
ii = np.searchsorted(b, xn) | ||
i0 = ii - 1 | ||
itop = (ii == len(b)) | ||
ibot = (ii == 0) | ||
i0[itop] -= 1 | ||
ii[itop] -= 1 | ||
i0[ibot] += 1 | ||
ii[ibot] += 1 | ||
|
||
y = self._y | ||
db = b[ii] - b[i0] | ||
dy = y[ii] - y[i0] | ||
z = y[i0] + (xn - b[i0]) * dy / db | ||
bunique = b | ||
yunique = self._y | ||
# trim extra b values at beginning and end if they are | ||
# not unique. These are here for extended colorbars, and are not | ||
# wanted for the interpolation. | ||
if b[0] == b[1]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see how the previous code was doing linear interpolation by hand, but where do these "equal first and second point / equal last and nexttolast point" cases come from? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed comment:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't quite understand this. Souldn't you trim all but one duplicate values? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That should never happen - if it does, just as well that an error trips. |
||
bunique = bunique[1:] | ||
yunique = yunique[1:] | ||
if b[-1] == b[-2]: | ||
bunique = bunique[:-1] | ||
yunique = yunique[:-1] | ||
|
||
z = np.interp(xn, bunique, yunique) | ||
return z | ||
|
||
def set_alpha(self, alpha): | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -958,6 +958,76 @@ def scaled(self): | |
return self.vmin is not None and self.vmax is not None | ||
|
||
|
||
class DivergingNorm(Normalize): | ||
def __init__(self, vcenter, vmin=None, vmax=None): | ||
""" | ||
Normalize data with a set center. | ||
|
||
Useful when mapping data with an unequal rates of change around a | ||
anntzer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
conceptual center, e.g., data that range from -2 to 4, with 0 as | ||
the midpoint. | ||
|
||
Parameters | ||
---------- | ||
vcenter : float | ||
The data value that defines ``0.5`` in the normalization. | ||
vmin : float, optional | ||
The data value that defines ``0.0`` in the normalization. | ||
Defaults to the min value of the dataset. | ||
vmax : float, optional | ||
The data value that defines ``1.0`` in the normalization. | ||
Defaults to the the max value of the dataset. | ||
|
||
Examples | ||
-------- | ||
This maps data value -4000 to 0., 0 to 0.5, and +10000 to 1.0; data | ||
between is linearly interpolated:: | ||
|
||
>>> import matplotlib.colors as mcolors | ||
>>> offset = mcolors.DivergingNorm(vmin=-4000., | ||
vcenter=0., vmax=10000) | ||
>>> data = [-4000., -2000., 0., 2500., 5000., 7500., 10000.] | ||
>>> offset(data) | ||
array([0., 0.25, 0.5, 0.625, 0.75, 0.875, 1.0]) | ||
""" | ||
|
||
self.vcenter = vcenter | ||
self.vmin = vmin | ||
self.vmax = vmax | ||
if vcenter is not None and vmax is not None and vcenter >= vmax: | ||
raise ValueError('vmin, vcenter, and vmax must be in ' | ||
'ascending order') | ||
if vcenter is not None and vmin is not None and vcenter <= vmin: | ||
raise ValueError('vmin, vcenter, and vmax must be in ' | ||
'ascending order') | ||
|
||
def autoscale_None(self, A): | ||
""" | ||
Get vmin and vmax, and then clip at vcenter | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. final dot |
||
""" | ||
super().autoscale_None(A) | ||
if self.vmin > self.vcenter: | ||
self.vmin = self.vcenter | ||
if self.vmax < self.vcenter: | ||
self.vmax = self.vcenter | ||
|
||
def __call__(self, value, clip=None): | ||
""" | ||
Map value to the interval [0, 1]. The clip argument is unused. | ||
""" | ||
result, is_scalar = self.process_value(value) | ||
self.autoscale_None(result) # sets self.vmin, self.vmax if None | ||
anntzer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
if not self.vmin <= self.vcenter <= self.vmax: | ||
raise ValueError("vmin, vcenter, vmax must increase monotonically") | ||
result = np.ma.masked_array( | ||
np.interp(result, [self.vmin, self.vcenter, self.vmax], | ||
[0, 0.5, 1.]), mask=np.ma.getmask(result)) | ||
if is_scalar: | ||
result = np.atleast_1d(result)[0] | ||
return result | ||
|
||
|
||
class LogNorm(Normalize): | ||
"""Normalize a given value to the 0-1 range on a log scale.""" | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -221,6 +221,96 @@ def test_Normalize(): | |
assert 0 < norm(1 + 50 * eps) < 1 | ||
|
||
|
||
def test_DivergingNorm_autoscale(): | ||
norm = mcolors.DivergingNorm(vcenter=20) | ||
norm.autoscale([10, 20, 30, 40]) | ||
assert norm.vmin == 10. | ||
assert norm.vmax == 40. | ||
|
||
|
||
def test_DivergingNorm_autoscale_None_vmin(): | ||
norm = mcolors.DivergingNorm(2, vmin=0, vmax=None) | ||
norm.autoscale_None([1, 2, 3, 4, 5]) | ||
assert norm(5) == 1 | ||
assert norm.vmax == 5 | ||
|
||
|
||
def test_DivergingNorm_autoscale_None_vmax(): | ||
norm = mcolors.DivergingNorm(2, vmin=None, vmax=10) | ||
norm.autoscale_None([1, 2, 3, 4, 5]) | ||
assert norm(1) == 0 | ||
assert norm.vmin == 1 | ||
|
||
|
||
def test_DivergingNorm_scale(): | ||
norm = mcolors.DivergingNorm(2) | ||
assert norm.scaled() is False | ||
norm([1, 2, 3, 4]) | ||
assert norm.scaled() is True | ||
|
||
|
||
def test_DivergingNorm_scaleout_center(): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this and the one below could use pytest.mark.parametrize |
||
# test the vmin never goes above vcenter | ||
norm = mcolors.DivergingNorm(vcenter=0) | ||
x = norm([1, 2, 3, 5]) | ||
assert norm.vmin == 0 | ||
assert norm.vmax == 5 | ||
|
||
|
||
def test_DivergingNorm_scaleout_center_max(): | ||
# test the vmax never goes below vcenter | ||
norm = mcolors.DivergingNorm(vcenter=0) | ||
x = norm([-1, -2, -3, -5]) | ||
assert norm.vmax == 0 | ||
assert norm.vmin == -5 | ||
|
||
|
||
def test_DivergingNorm_Even(): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. and these two as well |
||
norm = mcolors.DivergingNorm(vmin=-1, vcenter=0, vmax=4) | ||
vals = np.array([-1.0, -0.5, 0.0, 1.0, 2.0, 3.0, 4.0]) | ||
expected = np.array([0.0, 0.25, 0.5, 0.625, 0.75, 0.875, 1.0]) | ||
assert_array_equal(norm(vals), expected) | ||
|
||
|
||
def test_DivergingNorm_Odd(): | ||
norm = mcolors.DivergingNorm(vmin=-2, vcenter=0, vmax=5) | ||
vals = np.array([-2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) | ||
expected = np.array([0.0, 0.25, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]) | ||
assert_array_equal(norm(vals), expected) | ||
|
||
|
||
def test_DivergingNorm_VminEqualsVcenter(): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. and these fives There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If someone wants to parametrize these post-facto they can. I am not a fan of parameterized tests.... |
||
with pytest.raises(ValueError): | ||
norm = mcolors.DivergingNorm(vmin=-2, vcenter=-2, vmax=2) | ||
|
||
|
||
def test_DivergingNorm_VmaxEqualsVcenter(): | ||
with pytest.raises(ValueError): | ||
norm = mcolors.DivergingNorm(vmin=-2, vcenter=2, vmax=2) | ||
|
||
|
||
def test_DivergingNorm_VminGTVcenter(): | ||
with pytest.raises(ValueError): | ||
norm = mcolors.DivergingNorm(vmin=10, vcenter=0, vmax=20) | ||
|
||
|
||
def test_DivergingNorm_DivergingNorm_VminGTVmax(): | ||
with pytest.raises(ValueError): | ||
norm = mcolors.DivergingNorm(vmin=10, vcenter=0, vmax=5) | ||
|
||
|
||
def test_DivergingNorm_VcenterGTVmax(): | ||
vals = np.arange(50) | ||
with pytest.raises(ValueError): | ||
norm = mcolors.DivergingNorm(vmin=10, vcenter=25, vmax=20) | ||
|
||
|
||
def test_DivergingNorm_premature_scaling(): | ||
norm = mcolors.DivergingNorm(vcenter=2) | ||
with pytest.raises(ValueError): | ||
norm.inverse(np.array([0.1, 0.5, 0.9])) | ||
|
||
|
||
def test_SymLogNorm(): | ||
""" | ||
Test SymLogNorm behavior | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
perhaps add a whatsnew note too?