From b4701cde7077f4bc456621b2f99cf078cc7e4c6f Mon Sep 17 00:00:00 2001 From: David Stansby Date: Sat, 6 Jan 2024 10:01:16 +0000 Subject: [PATCH 1/4] Don't clip PowerNorm inputs < vmin --- .../next_api_changes/behavior/27589-DS.rst | 5 +++ lib/matplotlib/colors.py | 32 ++++++++++++------- lib/matplotlib/tests/test_colors.py | 17 ++++++++-- 3 files changed, 40 insertions(+), 14 deletions(-) create mode 100644 doc/api/next_api_changes/behavior/27589-DS.rst diff --git a/doc/api/next_api_changes/behavior/27589-DS.rst b/doc/api/next_api_changes/behavior/27589-DS.rst new file mode 100644 index 000000000000..314df582600b --- /dev/null +++ b/doc/api/next_api_changes/behavior/27589-DS.rst @@ -0,0 +1,5 @@ +PowerNorm no longer clips values below vmin +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +When ``clip=False`` is set (the default) on `~matplotlib.colors.PowerNorm`, +values below ``vmin`` are now linearly normalised. Previously they were clipped +to zero. This fixes issues with the display of colorbars associated with a power norm. diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 2ce27a810a43..5886b2065836 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1953,10 +1953,10 @@ class PowerNorm(Normalize): Determines the behavior for mapping values outside the range ``[vmin, vmax]``. - If clipping is off, values outside the range ``[vmin, vmax]`` are also - transformed by the power function, resulting in values outside ``[0, 1]``. - This behavior is usually desirable, as colormaps can mark these *under* - and *over* values with specific colors. + If clipping is off, values above *vmax* are transformed by the power + function, resulting in values above 1, and values below *vmin* are linearly + transformed resulting in values below 0. This behavior is usually desirable, as + colormaps can mark these *under* and *over* values with specific colors. If clipping is on, values below *vmin* are mapped to 0 and values above *vmax* are mapped to 1. Such values become indistinguishable from @@ -1969,6 +1969,8 @@ class PowerNorm(Normalize): .. math:: \left ( \frac{x - v_{min}}{v_{max} - v_{min}} \right )^{\gamma} + + For input values below *vmin*, gamma is set to zero. """ def __init__(self, gamma, vmin=None, vmax=None, clip=False): super().__init__(vmin, vmax, clip) @@ -1994,9 +1996,8 @@ def __call__(self, value, clip=None): mask=mask) resdat = result.data resdat -= vmin - resdat[resdat < 0] = 0 - np.power(resdat, gamma, resdat) - resdat /= (vmax - vmin) ** gamma + resdat /= (vmax - vmin) + resdat[resdat > 0] = np.power(resdat[resdat > 0], gamma) result = np.ma.array(resdat, mask=result.mask, copy=False) if is_scalar: @@ -2006,14 +2007,21 @@ def __call__(self, value, clip=None): def inverse(self, value): if not self.scaled(): raise ValueError("Not invertible until scaled") + + result, is_scalar = self.process_value(value) + gamma = self.gamma vmin, vmax = self.vmin, self.vmax - if np.iterable(value): - val = np.ma.asarray(value) - return np.ma.power(val, 1. / gamma) * (vmax - vmin) + vmin - else: - return pow(value, 1. / gamma) * (vmax - vmin) + vmin + resdat = result.data + resdat[resdat > 0] = np.power(resdat[resdat > 0], 1 / gamma) + resdat *= (vmax - vmin) + resdat += vmin + + result = np.ma.array(resdat, mask=result.mask, copy=False) + if is_scalar: + result = result[0] + return result class BoundaryNorm(Normalize): diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 139efbe17407..37b993ec8db7 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -555,12 +555,16 @@ def test_PowerNorm(): assert_array_almost_equal(norm(a), pnorm(a)) a = np.array([-0.5, 0, 2, 4, 8], dtype=float) - expected = [0, 0, 1/16, 1/4, 1] + expected = [-1/16, 0, 1/16, 1/4, 1] pnorm = mcolors.PowerNorm(2, vmin=0, vmax=8) assert_array_almost_equal(pnorm(a), expected) assert pnorm(a[0]) == expected[0] assert pnorm(a[2]) == expected[2] - assert_array_almost_equal(a[1:], pnorm.inverse(pnorm(a))[1:]) + # Check inverse + a_roundtrip = pnorm.inverse(pnorm(a)) + assert_array_almost_equal(a, a_roundtrip) + # PowerNorm inverse adds a mask, so check that is correct too + assert_array_equal(a_roundtrip.mask, np.zeros(a.shape, dtype=bool)) # Clip = True a = np.array([-0.5, 0, 1, 8, 16], dtype=float) @@ -591,6 +595,15 @@ def test_PowerNorm_translation_invariance(): assert_array_almost_equal(pnorm(a - 2), expected) +def test_powernorm_cbar_limits(): + fig, ax = plt.subplots() + vmin, vmax = 300, 1000 + data = np.arange(100*100).reshape(100, 100) + vmin + im = ax.imshow(data, norm=mcolors.PowerNorm(gamma=0.2, vmin=vmin, vmax=vmax)) + cbar = fig.colorbar(im) + assert cbar.ax.get_ylim() == (vmin, vmax) + + def test_Normalize(): norm = mcolors.Normalize() vals = np.arange(-10, 10, 1, dtype=float) From aecc2db0ef8fd51d1bedf101c7cb98d73b9542ec Mon Sep 17 00:00:00 2001 From: David Stansby Date: Sat, 6 Jan 2024 11:36:53 +0000 Subject: [PATCH 2/4] Fix colorbar test --- lib/matplotlib/tests/test_colorbar.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/tests/test_colorbar.py b/lib/matplotlib/tests/test_colorbar.py index 4566ce449aac..74742d8c2369 100644 --- a/lib/matplotlib/tests/test_colorbar.py +++ b/lib/matplotlib/tests/test_colorbar.py @@ -552,10 +552,10 @@ def test_colorbar_lognorm_extension(extend): def test_colorbar_powernorm_extension(): # Test that colorbar with powernorm is extended correctly - f, ax = plt.subplots() - cb = Colorbar(ax, norm=PowerNorm(gamma=0.5, vmin=0.0, vmax=1.0), - orientation='vertical', extend='both') - assert cb._values[0] >= 0.0 + # Just a smoke test that adding the colorbar doesn't raise an error or warning + fig, ax = plt.subplots() + Colorbar(ax, norm=PowerNorm(gamma=0.5, vmin=0.0, vmax=1.0), + orientation='vertical', extend='both') def test_colorbar_axes_kw(): From ba1b58965b5d0d5472a772e16035df4548ad4c15 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Tue, 9 Jan 2024 13:21:17 +0000 Subject: [PATCH 3/4] Fix PowerNorm docstring Co-authored-by: Thomas A Caswell --- lib/matplotlib/colors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 5886b2065836..70e275fdaeca 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1970,7 +1970,7 @@ class PowerNorm(Normalize): \left ( \frac{x - v_{min}}{v_{max} - v_{min}} \right )^{\gamma} - For input values below *vmin*, gamma is set to zero. + For input values below *vmin*, gamma is set to one. """ def __init__(self, gamma, vmin=None, vmax=None, clip=False): super().__init__(vmin, vmax, clip) From 8b6748e977d6104f152d820b9c7d2527f004a318 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Tue, 9 Jan 2024 13:22:23 +0000 Subject: [PATCH 4/4] Reduce size of test data --- lib/matplotlib/tests/test_colors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 37b993ec8db7..d77077340e8c 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -598,7 +598,7 @@ def test_PowerNorm_translation_invariance(): def test_powernorm_cbar_limits(): fig, ax = plt.subplots() vmin, vmax = 300, 1000 - data = np.arange(100*100).reshape(100, 100) + vmin + data = np.arange(10*10).reshape(10, 10) + vmin im = ax.imshow(data, norm=mcolors.PowerNorm(gamma=0.2, vmin=vmin, vmax=vmax)) cbar = fig.colorbar(im) assert cbar.ax.get_ylim() == (vmin, vmax)