From 9d012045f0ca5edf9a32112343ee1bf7643e4aa7 Mon Sep 17 00:00:00 2001 From: ImportanceOfBeingErnest Date: Tue, 24 Sep 2019 01:51:51 +0200 Subject: [PATCH] DivergingNorm Fair --- .../2019-09-24_divergingnorm_fair.rst | 27 ++++++ .../colormap_normalizations_diverging.py | 55 +++++++++++-- lib/matplotlib/colors.py | 67 +++++++++------ lib/matplotlib/tests/test_colors.py | 82 ++++++++----------- tutorials/colors/colormapnorms.py | 58 ++++++++++--- 5 files changed, 196 insertions(+), 93 deletions(-) create mode 100644 doc/users/next_whats_new/2019-09-24_divergingnorm_fair.rst diff --git a/doc/users/next_whats_new/2019-09-24_divergingnorm_fair.rst b/doc/users/next_whats_new/2019-09-24_divergingnorm_fair.rst new file mode 100644 index 000000000000..407e9675c60b --- /dev/null +++ b/doc/users/next_whats_new/2019-09-24_divergingnorm_fair.rst @@ -0,0 +1,27 @@ +Fair DivergingNorm +------------------ +`~.DivergingNorm` now has an argument ``fair``, which can be set to ``True`` +in order to create an off-centered normalization with equally spaced colors. + +..plot:: + + import numpy as np + import matplotlib.pyplot as plt + from matplotlib.colors import DivergingNorm + + np.random.seed(19680801) + data = np.random.rand(4, 11) + + fig, (ax1, ax2) = plt.subplots(ncols=2, figsize=(7, 2)) + + norm1 = DivergingNorm(0.25, vmin=0, vmax=1, fair=False) + im = ax1.imshow(data, cmap='RdBu', norm=norm1) + cbar = fig.colorbar(im, ax=ax1, orientation="horizontal", aspect=15) + + norm2 = DivergingNorm(0.25, vmin=0, vmax=1, fair=True) + im = ax2.imshow(data, cmap='RdBu', norm=norm2) + cbar = fig.colorbar(im, ax=ax2, orientation="horizontal", aspect=15) + + ax1.set_title("DivergingNorm(.., fair=False)") + ax2.set_title("DivergingNorm(.., fair=True)") + plt.show() \ No newline at end of file diff --git a/examples/userdemo/colormap_normalizations_diverging.py b/examples/userdemo/colormap_normalizations_diverging.py index 7a5a68c29b73..f863186b2c1d 100644 --- a/examples/userdemo/colormap_normalizations_diverging.py +++ b/examples/userdemo/colormap_normalizations_diverging.py @@ -2,15 +2,23 @@ ===================================== 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. """ +############################################################################## +# .. _divergingnorm-diffmap: +# +# Different mapping on either side of a center +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# +# 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. +# This achieved with a `~.DivergingNorm` and by setting its ``vcenter`` +# argument to zero. + import numpy as np import matplotlib.pyplot as plt import matplotlib.cbook as cbook @@ -29,16 +37,45 @@ 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) + 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,) + cmap=terrain_map) ax.set_xlabel('Lon $[^o E]$') ax.set_ylabel('Lat $[^o N]$') ax.set_aspect(1 / np.cos(np.deg2rad(49))) fig.colorbar(pcm, shrink=0.6, extend='both', label='Elevation [m]') plt.show() + + +############################################################################## +# .. _divergingnorm-fairmap: +# +# Fair mapping on either side of a center +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# +# On other occasions it may be useful to preserve the linear mapping to colors, +# but still define a center point, such that the colormap extends to both sides +# of the center equally. This can be achieved by using the ``fair=True`` +# argument of the `~.DivergingNorm`. + +np.random.seed(19680801) +data = np.random.rand(11, 11) + +fig, (ax1, ax2) = plt.subplots(ncols=2, figsize=(8, 3.5)) + +norm1 = colors.DivergingNorm(0.25, vmin=0, vmax=1, fair=False) +im = ax1.imshow(data, cmap='RdBu', norm=norm1) +cbar = fig.colorbar(im, ax=ax1, ticks=[0, 0.25, 0.5, 0.75, 1]) + +norm2 = colors.DivergingNorm(0.25, vmin=0, vmax=1, fair=True) +im = ax2.imshow(data, cmap='RdBu', norm=norm2) +cbar = fig.colorbar(im, ax=ax2, ticks=[0, 0.25, 0.5, 0.75, 1]) + +ax1.set_title("DivergingNorm(.., fair=False)") +ax2.set_title("DivergingNorm(.., fair=True)") +plt.show() diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 1aa4ca9cd40d..d782590e2157 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1061,11 +1061,11 @@ def scaled(self): class DivergingNorm(Normalize): - def __init__(self, vcenter, vmin=None, vmax=None): + def __init__(self, vcenter, vmin=None, vmax=None, fair=False): """ Normalize data with a set center. - Useful when mapping data with an unequal rates of change around a + Useful when mapping data around a conceptual center, e.g., data that range from -2 to 4, with 0 as the midpoint. @@ -1079,6 +1079,12 @@ def __init__(self, vcenter, vmin=None, vmax=None): vmax : float, optional The data value that defines ``1.0`` in the normalization. Defaults to the the max value of the dataset. + fair : bool, optional + If *False* (default), the range between vmin and vmax will be + mapped to the normalized range ``[0,1]``. If *True*, the range + ``[vcenter-d, vcenter+d]`` with + ``d=max(abs(vcenter-vmin), abs(vmax-vcenter))`` is mapped to + ``[0,1]``. This is useful to ensure colors are equally distributed. Examples -------- @@ -1091,40 +1097,49 @@ def __init__(self, vcenter, vmin=None, vmax=None): >>> data = [-4000., -2000., 0., 2500., 5000., 7500., 10000.] >>> offset(data) array([0., 0.25, 0.5, 0.625, 0.75, 0.875, 1.0]) + + A more detailed example is found in + :doc:`/gallery/userdemo/colormap_normalizations_diverging` """ 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 - """ - super().autoscale_None(A) - if self.vmin > self.vcenter: - self.vmin = self.vcenter - if self.vmax < self.vcenter: - self.vmax = self.vcenter + self.fair = fair + super().__init__(vmin=vmin, vmax=vmax) def __call__(self, value, clip=None): """ - Map value to the interval [0, 1]. The clip argument is unused. + Map value to (a subset of) 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 self.vmin > self.vmax: + raise ValueError("vmin must be less or equal vmax") + elif self.vmin == self.vmax: + interp_x = [self.vmin, self.vmax] + interp_y = [0.0, 0.0] + elif self.vcenter >= self.vmax: + interp_x = [self.vmin, self.vcenter] + interp_y = [0.0, 0.5] + elif self.vcenter <= self.vmin: + interp_x = [self.vcenter, self.vmax] + interp_y = [0.5, 1.0] + elif self.fair: + maxrange = max(np.abs(self.vcenter - self.vmin), + np.abs(self.vmax - self.vcenter)) + interp_x = [self.vcenter - maxrange, self.vcenter + maxrange] + interp_y = [0, 1.] + else: + interp_x = [self.vmin, self.vcenter, self.vmax] + interp_y = [0, 0.5, 1.] + + under = result < self.vmin + over = result > self.vmax + interp = np.interp(result, interp_x, interp_y, left=-1., right=2.) + result = np.ma.masked_array(interp, mask=np.ma.getmask(result)) + result[under] = -1. + result[over] = 2. if is_scalar: result = np.atleast_1d(result)[0] return result diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 96e34edfbd74..051b6cdb2ec5 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -324,58 +324,44 @@ def test_DivergingNorm_scale(): def test_DivergingNorm_scaleout_center(): - # test the vmin never goes above vcenter + # test vcenter outside [vmin, vmax] norm = mcolors.DivergingNorm(vcenter=0) - norm([1, 2, 3, 5]) - assert norm.vmin == 0 - assert norm.vmax == 5 - + a = norm([1., 2., 3., 5.]) + assert norm.vmin == 1. + assert norm.vmax == 5. + assert_array_almost_equal(a, np.array([0.6, 0.7, 0.8, 1.0])) -def test_DivergingNorm_scaleout_center_max(): - # test the vmax never goes below vcenter norm = mcolors.DivergingNorm(vcenter=0) - norm([-1, -2, -3, -5]) - assert norm.vmax == 0 - assert norm.vmin == -5 - - -def test_DivergingNorm_Even(): - 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(): - with pytest.raises(ValueError): - mcolors.DivergingNorm(vmin=-2, vcenter=-2, vmax=2) - - -def test_DivergingNorm_VmaxEqualsVcenter(): - with pytest.raises(ValueError): - mcolors.DivergingNorm(vmin=-2, vcenter=2, vmax=2) - - -def test_DivergingNorm_VminGTVcenter(): - with pytest.raises(ValueError): - mcolors.DivergingNorm(vmin=10, vcenter=0, vmax=20) - - -def test_DivergingNorm_DivergingNorm_VminGTVmax(): - with pytest.raises(ValueError): - mcolors.DivergingNorm(vmin=10, vcenter=0, vmax=5) - - -def test_DivergingNorm_VcenterGTVmax(): + a = norm([-1., -2., -3., -5.]) + assert norm.vmax == -1. + assert norm.vmin == -5. + assert_array_almost_equal(a, np.array([0.4, 0.3, 0.2, 0.0])) + + +@pytest.mark.parametrize("vmin,vc,vmax,fair,vals,expect", + [[-1, 0, 4, False, [-1.0, -0.5, 0.0, 1.0, 2.0, 3.0, 4.0], + [0.0, 0.25, 0.5, 0.625, 0.75, 0.875, 1.0]], + [-2, 0, 5, False, [-2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0], + [0.0, 0.25, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]], + [-2, -2, 2, False, [-3, -2, -1, 0, 1, 2, 3], + [-1, 0.5, 0.625, 0.75, 0.875, 1., 2.]], + [-2, 2, 2, False, [-3, -2, -1, 0, 1, 2, 3], + [-1., 0., 0.125, 0.25, 0.375, 0.5, 2.0]], + [10, 0, 20, False, [0, 5, 10, 15, 20], [-1, -1, 0.75, 0.875, 1.]], + [10, 30, 20, False, [10, 15, 20, 25, 30], [0., 0.125, 0.25, 2, 2]], + [-4, -4, -4, False, [-8, -6, -4, -2, 0], [-1., -1., 0., 2., 2.]], + [-6, 0, 12, True, [-12, -6, -3, 0, 6, 12], + [-1, 0.25, 0.375, 0.5, 0.75, 1.]]]) +def test_DivergingNorm_Misc(vmin, vc, vmax, fair, vals, expect): + norm = mcolors.DivergingNorm(vmin=vmin, vcenter=vc, vmax=vmax, fair=fair) + assert_array_equal(norm(vals), np.array(expect)) + + +def test_DivergingNorm_rasing(): + # test for vmin > vmax -> not allowed with pytest.raises(ValueError): - mcolors.DivergingNorm(vmin=10, vcenter=25, vmax=20) + norm = mcolors.DivergingNorm(vmin=10, vcenter=0, vmax=5) + norm(np.array([-3, -2, -1, 0, 1, 2, 3])) def test_DivergingNorm_premature_scaling(): diff --git a/tutorials/colors/colormapnorms.py b/tutorials/colors/colormapnorms.py index dd28fe07f230..d5d6da29f787 100644 --- a/tutorials/colors/colormapnorms.py +++ b/tutorials/colors/colormapnorms.py @@ -186,16 +186,23 @@ plt.show() -############################################################################### -# DivergingNorm: Different mapping on either side of a center -# ----------------------------------------------------------- +############################################################################## +# .. _colormapnorms-diffmap: +# +# DivergingNorm +# ------------- +# +# Different mapping on either side of a center +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # # 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. +# represented by a different colormap. This achieved with a `~.DivergingNorm` +# and by setting its ``vcenter`` argument to zero. + filename = cbook.get_sample_data('topobathy.npz', asfileobj=False) with np.load(filename) as dem: @@ -203,7 +210,7 @@ longitude = dem['longitude'] latitude = dem['latitude'] -fig, ax = plt.subplots() +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)) @@ -214,14 +221,44 @@ # 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) +divnorm = colors.DivergingNorm(vmin=-500, vcenter=0, vmax=4000) pcm = ax.pcolormesh(longitude, latitude, topo, rasterized=True, norm=divnorm, cmap=terrain_map,) -# Simple geographic plot, set aspect ratio beecause distance between lines of -# longitude depends on latitude. +ax.set_xlabel('Lon $[^o E]$') +ax.set_ylabel('Lat $[^o N]$') ax.set_aspect(1 / np.cos(np.deg2rad(49))) -fig.colorbar(pcm, shrink=0.6) +fig.colorbar(pcm, shrink=0.6, extend='both', label='Elevation [m]') +plt.show() + + +############################################################################## +# .. _colormapnorms-fairmap: +# +# Fair mapping on either side of a center +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# +# On other occasions it may be useful to preserve the linear mapping to colors, +# but still define a center point, such that the colormap extends to both sides +# of the center equally. This can be achieved by using the ``fair=True`` +# argument of the `~.DivergingNorm`. + + +np.random.seed(19680801) +data = np.random.rand(11, 11) + +fig, (ax1, ax2) = plt.subplots(ncols=2, figsize=(8, 3.5)) + +norm1 = colors.DivergingNorm(0.25, vmin=0, vmax=1, fair=False) +im = ax1.imshow(data, cmap='RdBu', norm=norm1) +cbar = fig.colorbar(im, ax=ax1, ticks=[0, 0.25, 0.5, 0.75, 1]) + +norm2 = colors.DivergingNorm(0.25, vmin=0, vmax=1, fair=True) +im = ax2.imshow(data, cmap='RdBu', norm=norm2) +cbar = fig.colorbar(im, ax=ax2, ticks=[0, 0.25, 0.5, 0.75, 1]) + +ax1.set_title("DivergingNorm(.., fair=False)") +ax2.set_title("DivergingNorm(.., fair=True)") plt.show() @@ -232,6 +269,7 @@ # The `.DivergingNorm` described above makes a useful example for # defining your own norm. + class MidpointNormalize(colors.Normalize): def __init__(self, vmin=None, vmax=None, vcenter=None, clip=False): self.vcenter = vcenter @@ -248,7 +286,7 @@ def __call__(self, value, clip=None): midnorm = MidpointNormalize(vmin=-500., vcenter=0, vmax=4000) pcm = ax.pcolormesh(longitude, latitude, topo, rasterized=True, norm=midnorm, - cmap=terrain_map) + cmap=terrain_map) ax.set_aspect(1 / np.cos(np.deg2rad(49))) fig.colorbar(pcm, shrink=0.6, extend='both') plt.show()