Skip to content

ENH: Add func norm #18653

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
Jan 15, 2021
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 @@ -30,6 +30,7 @@ Classes
PowerNorm
SymLogNorm
TwoSlopeNorm
FuncNorm

Functions
---------
Expand Down
1 change: 0 additions & 1 deletion examples/userdemo/colormap_normalizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@
shading='nearest')
fig.colorbar(pcm, ax=ax[1], extend='both')


###############################################################################
# Custom Norm: An example with a customized normalization. This one
# uses the example above, and normalizes the negative data differently
Expand Down
33 changes: 33 additions & 0 deletions lib/matplotlib/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -1467,6 +1467,39 @@ def inverse(self, value):
return Norm


@_make_norm_from_scale(
scale.FuncScale,
init=lambda functions, vmin=None, vmax=None, clip=False: None)
class FuncNorm(Normalize):
"""
Arbitrary normalization using functions for the forward and inverse.

Parameters
----------
functions : (callable, callable)
two-tuple of the forward and inverse functions for the normalization.
The forward function must be monotonic.

Both functions must have the signature ::

def forward(values: array-like) -> array-like

vmin, vmax : float or None
If *vmin* and/or *vmax* is not given, they are initialized from the
minimum and maximum value, respectively, of the first input
processed; i.e., ``__call__(A)`` calls ``autoscale_None(A)``.

clip : bool, default: False
If ``True`` values falling outside the range ``[vmin, vmax]``,
are mapped to 0 or 1, whichever is closer, and masked values are
set to 1. If ``False`` masked values remain masked.

Clipping silently defeats the purpose of setting the over, under,
and masked colors in a colormap, so it is likely to lead to
surprises; therefore the default is ``clip=False``.
"""


@_make_norm_from_scale(functools.partial(scale.LogScale, nonpositive="mask"))
class LogNorm(Normalize):
"""Normalize a given value to the 0-1 range on a log scale."""
Expand Down
23 changes: 23 additions & 0 deletions lib/matplotlib/tests/test_colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,29 @@ def test_Normalize():
assert 0 < norm(1 + 50 * eps) < 1


def test_FuncNorm():
def forward(x):
return (x**2)
def inverse(x):
return np.sqrt(x)

norm = mcolors.FuncNorm((forward, inverse), vmin=0, vmax=10)
expected = np.array([0, 0.25, 1])
input = np.array([0, 5, 10])
assert_array_almost_equal(norm(input), expected)
assert_array_almost_equal(norm.inverse(expected), input)

def forward(x):
return np.log10(x)
def inverse(x):
return 10**x
norm = mcolors.FuncNorm((forward, inverse), vmin=0.1, vmax=10)
lognorm = mcolors.LogNorm(vmin=0.1, vmax=10)
assert_array_almost_equal(norm([0.2, 5, 10]), lognorm([0.2, 5, 10]))
Copy link
Member

Choose a reason for hiding this comment

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

Did you not want to assert the inverse here as well?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah I wasn't going to but it does drop our test coverage, so added...

Interestingly the _make_norm_from_scale inverse doesn't work on a list of floats (i.e. [1, 2, 3]), but only on a numpy array (np.array([1, 2, 3])) whereas the forward works on a bare list. Not sure if that is a bug. @anntzer ?

Copy link
Member

@QuLogic QuLogic Jan 5, 2021

Choose a reason for hiding this comment

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

Probably because inverse doesn't pass value through process_value like __call__ does. Something like:

diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py
index e417b8178d..b37ec947fa 100644
--- a/lib/matplotlib/colors.py
+++ b/lib/matplotlib/colors.py
@@ -1449,12 +1449,14 @@ def _make_norm_from_scale(scale_cls, base_norm_cls=None, *, init=None):
             t_vmin, t_vmax = self._trf.transform([self.vmin, self.vmax])
             if not np.isfinite([t_vmin, t_vmax]).all():
                 raise ValueError("Invalid vmin or vmax")
+            value, is_scalar = self.process_value(value)
             rescaled = value * (t_vmax - t_vmin)
             rescaled += t_vmin
-            return (self._trf
-                    .inverted()
-                    .transform(rescaled)
-                    .reshape(np.shape(value)))
+            t_value = (self._trf
+                       .inverted()
+                       .transform(rescaled)
+                       .reshape(np.shape(value)))
+            return t_value[0] if is_scalar else t_value
 
     Norm.__name__ = base_norm_cls.__name__
     Norm.__qualname__ = base_norm_cls.__qualname__

Maybe it also needs the masking?

Copy link
Member Author

Choose a reason for hiding this comment

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

Looks right. But maybe orthogonal to this PR. I opened an issue in #19239 just to keep them separate...

Copy link
Member

Choose a reason for hiding this comment

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

Since that is now fixed, do you want to remove the np.array from the inverse check?

assert_array_almost_equal(norm.inverse([0.2, 5, 10]),
lognorm.inverse([0.2, 5, 10]))


def test_TwoSlopeNorm_autoscale():
norm = mcolors.TwoSlopeNorm(vcenter=20)
norm.autoscale([10, 20, 30, 40])
Expand Down
33 changes: 32 additions & 1 deletion tutorials/colors/colormapnorms.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,14 +169,16 @@
X, Y = np.mgrid[0:3:complex(0, N), 0:2:complex(0, N)]
Z1 = (1 + np.sin(Y * 10.)) * X**2

fig, ax = plt.subplots(2, 1)
fig, ax = plt.subplots(2, 1, constrained_layout=True)

pcm = ax[0].pcolormesh(X, Y, Z1, norm=colors.PowerNorm(gamma=0.5),
cmap='PuBu_r', shading='auto')
fig.colorbar(pcm, ax=ax[0], extend='max')
ax[0].set_title('PowerNorm()')

pcm = ax[1].pcolormesh(X, Y, Z1, cmap='PuBu_r', shading='auto')
fig.colorbar(pcm, ax=ax[1], extend='max')
ax[1].set_title('Normalize()')
plt.show()

###############################################################################
Expand Down Expand Up @@ -274,17 +276,45 @@
# Simple geographic plot, set aspect ratio beecause distance between lines of
# longitude depends on latitude.
ax.set_aspect(1 / np.cos(np.deg2rad(49)))
ax.set_title('TwoSlopeNorm(x)')
fig.colorbar(pcm, shrink=0.6)
plt.show()


###############################################################################
# FuncNorm: Arbitrary function normalization
# ------------------------------------------
#
# If the above norms do not provide the normalization you want, you can use
# `~.colors.FuncNorm` to define your own. Note that this example is the same
# as `~.colors.PowerNorm` with a power of 0.5:

def _forward(x):
return np.sqrt(x)


def _inverse(x):
return x**2

N = 100
X, Y = np.mgrid[0:3:complex(0, N), 0:2:complex(0, N)]
Z1 = (1 + np.sin(Y * 10.)) * X**2
fig, ax = plt.subplots()

norm = colors.FuncNorm((_forward, _inverse), vmin=0, vmax=20)
pcm = ax.pcolormesh(X, Y, Z1, norm=norm, cmap='PuBu_r', shading='auto')
ax.set_title('FuncNorm(x)')
fig.colorbar(pcm, shrink=0.6)
plt.show()

###############################################################################
# Custom normalization: Manually implement two linear ranges
# ----------------------------------------------------------
#
# The `.TwoSlopeNorm` 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
Expand All @@ -303,5 +333,6 @@ def __call__(self, value, clip=None):
pcm = ax.pcolormesh(longitude, latitude, topo, rasterized=True, norm=midnorm,
cmap=terrain_map, shading='auto')
ax.set_aspect(1 / np.cos(np.deg2rad(49)))
ax.set_title('Custom norm')
fig.colorbar(pcm, shrink=0.6, extend='both')
plt.show()