Skip to content

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

Merged
merged 1 commit into from
Mar 18, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/api/colors_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Classes

BoundaryNorm
Colormap
DivergingNorm
Copy link
Contributor

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?

LightSource
LinearSegmentedColormap
ListedColormap
Expand Down
44 changes: 44 additions & 0 deletions examples/userdemo/colormap_normalizations_diverging.py
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)))
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Member Author

Choose a reason for hiding this comment

The 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()
31 changes: 15 additions & 16 deletions lib/matplotlib/colorbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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]:
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed comment:

        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.  

Copy link
Member

Choose a reason for hiding this comment

The 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?
The current code does b = [0, 0, 0, 1, 2] --> bunique = [0, 0, 1, 2]. Is that intended?

Copy link
Member Author

Choose a reason for hiding this comment

The 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. b are the boundaries, passed through the norm should be monotonic, except for, possibly, the first and last values if there are an "extend" either at max or min.

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):
Expand Down
70 changes: 70 additions & 0 deletions lib/matplotlib/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
Copy link
Contributor

Choose a reason for hiding this comment

The 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

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."""

Expand Down
Binary file added lib/matplotlib/mpl-data/sample_data/topobathy.npz
Binary file not shown.
21 changes: 20 additions & 1 deletion lib/matplotlib/tests/test_colorbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from matplotlib import rc_context
from matplotlib.testing.decorators import image_comparison
import matplotlib.pyplot as plt
from matplotlib.colors import BoundaryNorm, LogNorm, PowerNorm, Normalize
from matplotlib.colors import (BoundaryNorm, LogNorm, PowerNorm, Normalize,
DivergingNorm)
from matplotlib.cm import get_cmap
from matplotlib.colorbar import ColorbarBase, _ColorbarLogLocator
from matplotlib.ticker import LogLocator, LogFormatter, FixedLocator
Expand Down Expand Up @@ -539,3 +540,21 @@ def test_colorbar_inverted_ticks():
cbar.ax.invert_yaxis()
np.testing.assert_allclose(ticks, cbar.get_ticks())
np.testing.assert_allclose(minorticks, cbar.get_ticks(minor=True))


def test_extend_colorbar_customnorm():
# This was a funny error with DivergingNorm, maybe with other norms,
# when extend='both'
N = 100
X, Y = np.mgrid[-3:3:complex(0, N), -2:2:complex(0, N)]
Z1 = np.exp(-X**2 - Y**2)
Z2 = np.exp(-(X - 1)**2 - (Y - 1)**2)
Z = (Z1 - Z2) * 2

fig, ax = plt.subplots(2, 1)
pcm = ax[0].pcolormesh(X, Y, Z,
norm=DivergingNorm(vcenter=0., vmin=-2, vmax=1),
cmap='RdBu_r')
cb = fig.colorbar(pcm, ax=ax[0], extend='both')
np.testing.assert_allclose(cb.ax.get_position().extents,
[0.78375, 0.536364, 0.796147, 0.9], rtol=1e-3)
90 changes: 90 additions & 0 deletions lib/matplotlib/tests/test_colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Copy link
Contributor

Choose a reason for hiding this comment

The 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():
Copy link
Contributor

Choose a reason for hiding this comment

The 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():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and these fives

Copy link
Member Author

Choose a reason for hiding this comment

The 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
Expand Down
5 changes: 4 additions & 1 deletion lib/matplotlib/tests/test_contour.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,10 @@ def test_contourf_log_extension():


@image_comparison(baseline_images=['contour_addlines'],
extensions=['png'], remove_text=True, style='mpl20')
extensions=['png'], remove_text=True, style='mpl20',
tol=0.03)
# tolerance is because image changed minutely when tick finding on
# colorbars was cleaned up...
def test_contour_addlines():
fig, ax = plt.subplots()
np.random.seed(19680812)
Expand Down
Loading