From 0ffcb7253df571107bf7e1c933849f3d6e9eb46c Mon Sep 17 00:00:00 2001 From: alvarosg Date: Fri, 16 Dec 2016 00:13:17 +0000 Subject: [PATCH 1/4] ENH: Added FuncNorm --- .../color/colormap_normalizations_funcnorm.py | 85 +++++++ lib/matplotlib/colors.py | 223 ++++++++++++++++++ lib/matplotlib/tests/test_colors.py | 41 ++++ 3 files changed, 349 insertions(+) create mode 100644 examples/color/colormap_normalizations_funcnorm.py diff --git a/examples/color/colormap_normalizations_funcnorm.py b/examples/color/colormap_normalizations_funcnorm.py new file mode 100644 index 000000000000..416db31ac204 --- /dev/null +++ b/examples/color/colormap_normalizations_funcnorm.py @@ -0,0 +1,85 @@ +""" +===================================================================== +Examples of normalization using :class:`~matplotlib.colors.FuncNorm` +===================================================================== + +This is an example on how to perform a normalization using an arbitrary +function with :class:`~matplotlib.colors.FuncNorm`. A logarithm normalization +and a square root normalization will be use as examples. + +""" + +import matplotlib.cm as cm +import matplotlib.colors as colors +import matplotlib.pyplot as plt + +import numpy as np + + +def main(): + fig, axes = plt.subplots(3, 2, gridspec_kw={ + 'width_ratios': [1, 3.5]}, figsize=plt.figaspect(0.6)) + + # Example of logarithm normalization using FuncNorm + norm_log = colors.FuncNorm(f='log10', vmin=0.01) + # The same can be achieved with + # norm_log = colors.FuncNorm(f=np.log10, + # finv=lambda x: 10.**(x), vmin=0.01) + + # Example of root normalization using FuncNorm + norm_sqrt = colors.FuncNorm(f='sqrt', vmin=0.0) + # The same can be achieved with + # norm_sqrt = colors.FuncNorm(f='root{2}', vmin=0.) + # or with + # norm_sqrt = colors.FuncNorm(f=lambda x: x**0.5, + # finv=lambda x: x**2, vmin=0.0) + + normalizations = [(None, 'Regular linear scale'), + (norm_log, 'Log normalization'), + (norm_sqrt, 'Root normalization')] + + for i, (norm, title) in enumerate(normalizations): + X, Y, data = get_data() + + # Showing the normalization effect on an image + ax2 = axes[i][1] + cax = ax2.imshow(data, cmap=cm.afmhot, norm=norm) + ticks = cax.norm.ticks(5) if norm else np.linspace(0, 1, 6) + fig.colorbar(cax, format='%.3g', ticks=ticks, ax=ax2) + ax2.set_title(title) + ax2.axes.get_xaxis().set_ticks([]) + ax2.axes.get_yaxis().set_ticks([]) + + # Plotting the behaviour of the normalization + ax1 = axes[i][0] + d_values = np.linspace(cax.norm.vmin, cax.norm.vmax, 100) + cm_values = cax.norm(d_values) + ax1.plot(d_values, cm_values) + ax1.set_xlabel('Data values') + ax1.set_ylabel('Colormap values') + + plt.show() + + +def get_data(_cache=[]): + if len(_cache) > 0: + return _cache[0] + x = np.linspace(0, 1, 300) + y = np.linspace(-1, 1, 90) + X, Y = np.meshgrid(x, y) + + data = np.zeros(X.shape) + + def gauss2d(x, y, a0, x0, y0, wx, wy): + return a0 * np.exp(-(x - x0)**2 / wx**2 - (y - y0)**2 / wy**2) + N = 15 + for x in np.linspace(0., 1, N): + data += gauss2d(X, Y, x, x, 0, 0.25 / N, 0.25) + + data = data - data.min() + data = data / data.max() + _cache.append((X, Y, data)) + + return _cache[0] + +main() diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 4a58161cdc61..d9d8165cc505 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -960,6 +960,229 @@ def scaled(self): return (self.vmin is not None and self.vmax is not None) +class FuncNorm(Normalize): + """ + Creates a normalizer using a custom function + + The normalizer will be a function mapping the data values into colormap + values in the [0,1] range. + """ + + def __init__(self, f, finv=None, **normalize_kw): + """ + Specify the function to be used, and its inverse, as well as other + parameters to be passed to `Normalize`. The normalization will be + calculated as (f(x)-f(vmin))/(f(max)-f(vmin)). + + Parameters + ---------- + f : callable or string + Function to be used for the normalization receiving a single + parameter, compatible with scalar values and ndarrays. + Alternatively a string from the list ['linear', 'quadratic', + 'cubic', 'sqrt', 'cbrt','log', 'log10', 'power{a}', 'root{a}', + 'log(x+{a})', 'log10(x+{a})'] can be used, replacing 'a' by a + number different than 0 when necessary. + finv : callable, optional + Inverse function of `f` that satisfies finv(f(x))==x. It is + optional in cases where `f` is provided as a string. + normalize_kw : dict, optional + Dict with keywords (`vmin`,`vmax`,`clip`) passed + to `matplotlib.colors.Normalize`. + + Examples + -------- + Creating a logarithmic normalization using the predefined strings: + + >>> import matplotlib.colors as colors + >>> norm = colors.FuncNorm(f='log10', vmin=0.01, vmax=2) + + Or doing it manually: + + >>> import matplotlib.colors as colors + >>> norm = colors.FuncNorm(f=lambda x: np.log10(x), + ... finv=lambda x: 10.**(x), + ... vmin=0.01, vmax=2) + + """ + + if isinstance(f, six.string_types): + func_parser = cbook._StringFuncParser(f) + f = func_parser.function + finv = func_parser.inverse + if not callable(f): + raise ValueError("`f` must be a callable or a string.") + + if finv is None: + raise ValueError("Inverse function `finv` not provided.") + elif not callable(finv): + raise ValueError("`finv` must be a callable.") + + self._f = f + self._finv = finv + + super(FuncNorm, self).__init__(**normalize_kw) + + def _update_f(self, vmin, vmax): + # This method is to be used by derived classes in cases where + # the limits vmin and vmax may require changing/updating the + # function depending on vmin/vmax, for example rescaling it + # to accomodate to the new interval. + return + + def __call__(self, value, clip=None): + """ + Normalizes `value` data in the `[vmin, vmax]` interval into + the `[0.0, 1.0]` interval and returns it. + + Parameters + ---------- + value : float or ndarray of floats + Data to be normalized. + clip : boolean, optional + Whether to clip the data outside the `[vmin, vmax]` limits. + Default `self.clip` from `Normalize` (which defaults to `False`). + + Returns + ------- + result : masked array of floats + Normalized data to the `[0.0, 1.0]` interval. If clip == False, + values smaller than `vmin` or greater than `vmax` will be clipped + to -0.1 and 1.1 respectively. + + """ + if clip is None: + clip = self.clip + + result, is_scalar = self.process_value(value) + self.autoscale_None(result) + + vmin = float(self.vmin) + vmax = float(self.vmax) + + self._update_f(vmin, vmax) + + if clip: + result = np.clip(result, vmin, vmax) + resultnorm = (self._f(result) - self._f(vmin)) / \ + (self._f(vmax) - self._f(vmin)) + else: + resultnorm = result.copy() + mask_over = result > vmax + mask_under = result < vmin + mask = (result >= vmin) * (result <= vmax) + # Since the non linear function is arbitrary and may not be + # defined outside the boundaries, we just set obvious under + # and over values + resultnorm[mask_over] = 1.1 + resultnorm[mask_under] = -0.1 + resultnorm[mask] = (self._f(result[mask]) - self._f(vmin)) / \ + (self._f(vmax) - self._f(vmin)) + + return np.ma.array(resultnorm) + + def inverse(self, value): + """ + Performs the inverse normalization from the `[0.0, 1.0]` into the + `[vmin, vmax]` interval and returns it. + + Parameters + ---------- + value : float or ndarray of floats + Data in the `[0.0, 1.0]` interval. + + Returns + ------- + result : float or ndarray of floats + Data before normalization. + + """ + vmin = self.vmin + vmax = self.vmax + self._update_f(vmin, vmax) + value = self._finv( + value * (self._f(vmax) - self._f(vmin)) + self._f(vmin)) + return value + + @staticmethod + def _fun_normalizer(fun): + if fun(0.) == 0. and fun(1.) == 1.: + return fun + elif fun(0.) == 0.: + return (lambda x: fun(x) / fun(1.)) + else: + return (lambda x: (fun(x) - fun(0.)) / (fun(1.) - fun(0.))) + + def autoscale(self, A): + """ + Autoscales the normalization based on the maximum and minimum values + of `A`. + + Parameters + ---------- + A : ndarray or maskedarray + Array used to calculate the maximum and minimum values. + + """ + self.vmin = float(np.ma.min(A)) + self.vmax = float(np.ma.max(A)) + + def autoscale_None(self, A): + """ + Autoscales the normalization based on the maximum and minimum values + of `A`, only if the limits were not already set. + + Parameters + ---------- + A : ndarray or maskedarray + Array used to calculate the maximum and minimum values. + + """ + if self.vmin is None: + self.vmin = float(np.ma.min(A)) + if self.vmax is None: + self.vmax = float(np.ma.max(A)) + self.vmin = float(self.vmin) + self.vmax = float(self.vmax) + if self.vmin > self.vmax: + raise ValueError("vmin must be smaller than vmax") + + def ticks(self, nticks=13): + """ + Returns an automatic list of `nticks` points in the data space + to be used as ticks in the colorbar. + + Parameters + ---------- + nticks : integer, optional + Number of ticks to be returned. Default 13. + + Returns + ------- + ticks : ndarray + 1d array of length `nticks` with the proposed tick locations. + + """ + ticks = self.inverse(np.linspace(0, 1, nticks)) + finalticks = np.zeros(ticks.shape, dtype=np.bool) + finalticks[0] = True + ticks = FuncNorm._round_ticks(ticks, finalticks) + return ticks + + @staticmethod + def _round_ticks(ticks, permanenttick): + ticks = ticks.copy() + for i in range(len(ticks)): + if i == 0 or i == len(ticks) - 1 or permanenttick[i]: + continue + d1 = ticks[i] - ticks[i - 1] + d2 = ticks[i + 1] - ticks[i] + d = min([d1, d2]) + order = -np.floor(np.log10(d)) + ticks[i] = float(np.round(ticks[i] * 10**order)) / 10**order + return ticks + + class LogNorm(Normalize): """ Normalize a given value to the 0-1 range on a log scale diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index ebfc41aa53e3..8ebe1a52c5c5 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -146,6 +146,47 @@ def test_BoundaryNorm(): assert_true(np.all(bn(vals).mask)) +class TestFuncNorm(object): + def test_limits_with_string(self): + norm = mcolors.FuncNorm(f='log10', vmin=0.01, vmax=2.) + assert_array_equal(norm([0.01, 2]), [0, 1.0]) + + def test_limits_with_lambda(self): + norm = mcolors.FuncNorm(f=lambda x: np.log10(x), + finv=lambda x: 10.**(x), + vmin=0.01, vmax=2.) + assert_array_equal(norm([0.01, 2]), [0, 1.0]) + + def test_limits_without_vmin_vmax(self): + norm = mcolors.FuncNorm(f='log10') + assert_array_equal(norm([0.01, 2]), [0, 1.0]) + + def test_limits_without_vmin(self): + norm = mcolors.FuncNorm(f='log10', vmax=2.) + assert_array_equal(norm([0.01, 2]), [0, 1.0]) + + def test_limits_without_vmax(self): + norm = mcolors.FuncNorm(f='log10', vmin=0.01) + assert_array_equal(norm([0.01, 2]), [0, 1.0]) + + def test_intermediate_values(self): + norm = mcolors.FuncNorm(f='log10') + assert_array_almost_equal(norm([0.01, 0.5, 2]), + [0, 0.73835195870437, 1.0]) + + def test_inverse(self): + norm = mcolors.FuncNorm(f='log10', vmin=0.01, vmax=2.) + x = np.linspace(0.01, 2, 10) + assert_array_almost_equal(x, norm.inverse(norm(x))) + + def test_ticks(self): + norm = mcolors.FuncNorm(f='log10', vmin=0.01, vmax=2.) + expected = [0.01, 0.016, 0.024, 0.04, 0.06, + 0.09, 0.14, 0.22, 0.3, 0.5, + 0.8, 1.3, 2.] + assert_array_almost_equal(norm.ticks(), expected) + + def test_LogNorm(): """ LogNorm ignored clip, now it has the same From 44658d4ce621291c74226dd7e082687dc5c4a634 Mon Sep 17 00:00:00 2001 From: alvarosg Date: Sat, 17 Dec 2016 16:42:21 +0000 Subject: [PATCH 2/4] Implemented feedback from @QuLogic and @story645 --- .../color/colormap_normalizations_funcnorm.py | 123 ++++++++---------- lib/matplotlib/colors.py | 109 +++++++--------- lib/matplotlib/tests/test_colors.py | 14 ++ 3 files changed, 114 insertions(+), 132 deletions(-) diff --git a/examples/color/colormap_normalizations_funcnorm.py b/examples/color/colormap_normalizations_funcnorm.py index 416db31ac204..44520c410433 100644 --- a/examples/color/colormap_normalizations_funcnorm.py +++ b/examples/color/colormap_normalizations_funcnorm.py @@ -15,71 +15,60 @@ import numpy as np +norm_log = colors.FuncNorm(f='log10', vmin=0.01) +# The same can be achieved with +# norm_log = colors.FuncNorm(f=np.log10, +# finv=lambda x: 10.**(x), vmin=0.01) -def main(): - fig, axes = plt.subplots(3, 2, gridspec_kw={ - 'width_ratios': [1, 3.5]}, figsize=plt.figaspect(0.6)) - - # Example of logarithm normalization using FuncNorm - norm_log = colors.FuncNorm(f='log10', vmin=0.01) - # The same can be achieved with - # norm_log = colors.FuncNorm(f=np.log10, - # finv=lambda x: 10.**(x), vmin=0.01) - - # Example of root normalization using FuncNorm - norm_sqrt = colors.FuncNorm(f='sqrt', vmin=0.0) - # The same can be achieved with - # norm_sqrt = colors.FuncNorm(f='root{2}', vmin=0.) - # or with - # norm_sqrt = colors.FuncNorm(f=lambda x: x**0.5, - # finv=lambda x: x**2, vmin=0.0) - - normalizations = [(None, 'Regular linear scale'), - (norm_log, 'Log normalization'), - (norm_sqrt, 'Root normalization')] - - for i, (norm, title) in enumerate(normalizations): - X, Y, data = get_data() - - # Showing the normalization effect on an image - ax2 = axes[i][1] - cax = ax2.imshow(data, cmap=cm.afmhot, norm=norm) - ticks = cax.norm.ticks(5) if norm else np.linspace(0, 1, 6) - fig.colorbar(cax, format='%.3g', ticks=ticks, ax=ax2) - ax2.set_title(title) - ax2.axes.get_xaxis().set_ticks([]) - ax2.axes.get_yaxis().set_ticks([]) - - # Plotting the behaviour of the normalization - ax1 = axes[i][0] - d_values = np.linspace(cax.norm.vmin, cax.norm.vmax, 100) - cm_values = cax.norm(d_values) - ax1.plot(d_values, cm_values) - ax1.set_xlabel('Data values') - ax1.set_ylabel('Colormap values') - - plt.show() - - -def get_data(_cache=[]): - if len(_cache) > 0: - return _cache[0] - x = np.linspace(0, 1, 300) - y = np.linspace(-1, 1, 90) - X, Y = np.meshgrid(x, y) - - data = np.zeros(X.shape) - - def gauss2d(x, y, a0, x0, y0, wx, wy): - return a0 * np.exp(-(x - x0)**2 / wx**2 - (y - y0)**2 / wy**2) - N = 15 - for x in np.linspace(0., 1, N): - data += gauss2d(X, Y, x, x, 0, 0.25 / N, 0.25) - - data = data - data.min() - data = data / data.max() - _cache.append((X, Y, data)) - - return _cache[0] - -main() +norm_sqrt = colors.FuncNorm(f='sqrt', vmin=0.0) +# The same can be achieved with +# norm_sqrt = colors.FuncNorm(f='root{2}', vmin=0.) +# or with +# norm_sqrt = colors.FuncNorm(f=lambda x: x**0.5, +# finv=lambda x: x**2, vmin=0.0) + +normalizations = [(None, 'Regular linear scale'), + (norm_log, 'Log normalization'), + (norm_sqrt, 'Root normalization')] + +# Fabricating some data +x = np.linspace(0, 1, 300) +y = np.linspace(-1, 1, 90) +X, Y = np.meshgrid(x, y) + +data = np.zeros(X.shape) + + +def gauss2d(x, y, a0, x0, y0, wx, wy): + return a0 * np.exp(-(x - x0)**2 / wx**2 - (y - y0)**2 / wy**2) + +for x in np.linspace(0., 1, 15): + data += gauss2d(X, Y, x, x, 0, 0.25 / 15, 0.25) + +data -= data.min() +data /= data.max() + +# Using the custom normalizations to plot the data +fig, axes = plt.subplots(3, 2, sharex='col', + gridspec_kw={'width_ratios': [1, 3.5]}, + figsize=plt.figaspect(0.6)) + +for (ax_left, ax_right), (norm, title) in zip(axes, normalizations): + + # Showing the normalization effect on an image + cax = ax_right.imshow(data, cmap=cm.afmhot, norm=norm, aspect='auto') + ticks = cax.norm.ticks(5) if norm else np.linspace(0, 1, 6) + fig.colorbar(cax, format='%.3g', ticks=ticks, ax=ax_right) + ax_right.set_title(title) + ax_right.xaxis.set_ticks([]) + ax_right.yaxis.set_ticks([]) + + # Plotting the behaviour of the normalization + d_values = np.linspace(cax.norm.vmin, cax.norm.vmax, 100) + cm_values = cax.norm(d_values) + ax_left.plot(d_values, cm_values) + ax_left.set_ylabel('Colormap values') + +ax_left.set_xlabel('Data values') + +plt.show() diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index d9d8165cc505..95e89618bd5b 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -965,14 +965,14 @@ class FuncNorm(Normalize): Creates a normalizer using a custom function The normalizer will be a function mapping the data values into colormap - values in the [0,1] range. + values in the ``[0,1]`` range. """ - def __init__(self, f, finv=None, **normalize_kw): + def __init__(self, f, finv=None, vmin=None, vmax=None, clip=False): """ Specify the function to be used, and its inverse, as well as other parameters to be passed to `Normalize`. The normalization will be - calculated as (f(x)-f(vmin))/(f(max)-f(vmin)). + calculated as (f(x)-f(vmin))/(f(vmax)-f(vmin)). Parameters ---------- @@ -980,15 +980,28 @@ def __init__(self, f, finv=None, **normalize_kw): Function to be used for the normalization receiving a single parameter, compatible with scalar values and ndarrays. Alternatively a string from the list ['linear', 'quadratic', - 'cubic', 'sqrt', 'cbrt','log', 'log10', 'power{a}', 'root{a}', - 'log(x+{a})', 'log10(x+{a})'] can be used, replacing 'a' by a - number different than 0 when necessary. + 'cubic', 'x**{p}', 'sqrt', 'cbrt', 'root{p}(x)', 'log', 'log10', + 'log2', 'log{p}(x)', 'log(x+{p}) 'log10(x+{p})', 'log{p}(x+{p})] + can be used, replacing 'p' by the corresponding value of the + parameter, when present. finv : callable, optional - Inverse function of `f` that satisfies finv(f(x))==x. It is - optional in cases where `f` is provided as a string. - normalize_kw : dict, optional - Dict with keywords (`vmin`,`vmax`,`clip`) passed - to `matplotlib.colors.Normalize`. + Inverse function of `f` that satisfies finv(f(x))==x. + Optional/ignored when `f` is a string. + vmin : float or None, optional + Value assigned to the lower limit of the colormap. If None, it + will be assigned to the minimum value of the data provided. + Default None. + vmax : float or None, optional + Value assigned to the upper limit of the colormap. If None, it + will be assigned to the maximum value of the data provided. + Default None. + clip : bool, optional + If True, any value below `vmin` will be clipped to `vmin`, and + any value above `vmax` will be clip to `vmin`. This effectively + defeats the purpose of setting the over and under values of the + color map. If False, values below `vmin` and above `vmax` will + be set to -0.1 and 1.1 respectively, after the normalization. + Default False. Examples -------- @@ -997,7 +1010,7 @@ def __init__(self, f, finv=None, **normalize_kw): >>> import matplotlib.colors as colors >>> norm = colors.FuncNorm(f='log10', vmin=0.01, vmax=2) - Or doing it manually: + Or manually: >>> import matplotlib.colors as colors >>> norm = colors.FuncNorm(f=lambda x: np.log10(x), @@ -1005,6 +1018,7 @@ def __init__(self, f, finv=None, **normalize_kw): ... vmin=0.01, vmax=2) """ + super(FuncNorm, self).__init__(vmin=vmin, vmax=vmax, clip=clip) if isinstance(f, six.string_types): func_parser = cbook._StringFuncParser(f) @@ -1021,32 +1035,30 @@ def __init__(self, f, finv=None, **normalize_kw): self._f = f self._finv = finv - super(FuncNorm, self).__init__(**normalize_kw) - def _update_f(self, vmin, vmax): # This method is to be used by derived classes in cases where # the limits vmin and vmax may require changing/updating the # function depending on vmin/vmax, for example rescaling it - # to accomodate to the new interval. + # to accommodate to the new interval. return def __call__(self, value, clip=None): """ - Normalizes `value` data in the `[vmin, vmax]` interval into - the `[0.0, 1.0]` interval and returns it. + Normalizes `value` data in the ``[vmin, vmax]`` interval into + the ``[0.0, 1.0]`` interval and returns it. Parameters ---------- value : float or ndarray of floats Data to be normalized. clip : boolean, optional - Whether to clip the data outside the `[vmin, vmax]` limits. + Whether to clip the data outside the ``[`vmin`, `vmax`]`` limits. Default `self.clip` from `Normalize` (which defaults to `False`). Returns ------- result : masked array of floats - Normalized data to the `[0.0, 1.0]` interval. If clip == False, + Normalized data to the ``[0.0, 1.0]`` interval. If `clip` == False, values smaller than `vmin` or greater than `vmax` will be clipped to -0.1 and 1.1 respectively. @@ -1057,6 +1069,7 @@ def __call__(self, value, clip=None): result, is_scalar = self.process_value(value) self.autoscale_None(result) + self._check_vmin_vmax() vmin = float(self.vmin) vmax = float(self.vmax) @@ -1070,7 +1083,7 @@ def __call__(self, value, clip=None): resultnorm = result.copy() mask_over = result > vmax mask_under = result < vmin - mask = (result >= vmin) * (result <= vmax) + mask = ~(mask_over | mask_under) # Since the non linear function is arbitrary and may not be # defined outside the boundaries, we just set obvious under # and over values @@ -1079,17 +1092,21 @@ def __call__(self, value, clip=None): resultnorm[mask] = (self._f(result[mask]) - self._f(vmin)) / \ (self._f(vmax) - self._f(vmin)) - return np.ma.array(resultnorm) + resultnorm = np.ma.array(resultnorm) + if is_scalar: + return resultnorm[0] + else: + return resultnorm def inverse(self, value): """ - Performs the inverse normalization from the `[0.0, 1.0]` into the - `[vmin, vmax]` interval and returns it. + Performs the inverse normalization from the ``[0.0, 1.0]`` into the + ``[`vmin`, `vmax`]`` interval and returns it. Parameters ---------- value : float or ndarray of floats - Data in the `[0.0, 1.0]` interval. + Data in the ``[0.0, 1.0]`` interval. Returns ------- @@ -1097,6 +1114,7 @@ def inverse(self, value): Data before normalization. """ + self._check_vmin_vmax() vmin = self.vmin vmax = self.vmax self._update_f(vmin, vmax) @@ -1104,47 +1122,8 @@ def inverse(self, value): value * (self._f(vmax) - self._f(vmin)) + self._f(vmin)) return value - @staticmethod - def _fun_normalizer(fun): - if fun(0.) == 0. and fun(1.) == 1.: - return fun - elif fun(0.) == 0.: - return (lambda x: fun(x) / fun(1.)) - else: - return (lambda x: (fun(x) - fun(0.)) / (fun(1.) - fun(0.))) - - def autoscale(self, A): - """ - Autoscales the normalization based on the maximum and minimum values - of `A`. - - Parameters - ---------- - A : ndarray or maskedarray - Array used to calculate the maximum and minimum values. - - """ - self.vmin = float(np.ma.min(A)) - self.vmax = float(np.ma.max(A)) - - def autoscale_None(self, A): - """ - Autoscales the normalization based on the maximum and minimum values - of `A`, only if the limits were not already set. - - Parameters - ---------- - A : ndarray or maskedarray - Array used to calculate the maximum and minimum values. - - """ - if self.vmin is None: - self.vmin = float(np.ma.min(A)) - if self.vmax is None: - self.vmax = float(np.ma.max(A)) - self.vmin = float(self.vmin) - self.vmax = float(self.vmax) - if self.vmin > self.vmax: + def _check_vmin_vmax(self): + if self.vmin >= self.vmax: raise ValueError("vmin must be smaller than vmax") def ticks(self, nticks=13): diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 8ebe1a52c5c5..3e834778fca1 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -169,6 +169,20 @@ def test_limits_without_vmax(self): norm = mcolors.FuncNorm(f='log10', vmin=0.01) assert_array_equal(norm([0.01, 2]), [0, 1.0]) + def test_clip_true(self): + norm = mcolors.FuncNorm(f='log10', vmin=0.01, vmax=2., + clip=True) + assert_array_equal(norm([0.0, 2.5]), [0.0, 1.0]) + + def test_clip_false(self): + norm = mcolors.FuncNorm(f='log10', vmin=0.01, vmax=2., + clip=False) + assert_array_equal(norm([0.0, 2.5]), [-0.1, 1.1]) + + def test_clip_default_false(self): + norm = mcolors.FuncNorm(f='log10', vmin=0.01, vmax=2.) + assert_array_equal(norm([0.0, 2.5]), [-0.1, 1.1]) + def test_intermediate_values(self): norm = mcolors.FuncNorm(f='log10') assert_array_almost_equal(norm([0.01, 0.5, 2]), From 546c8f3a5500ac4146f6273fd53b0b77682586b4 Mon Sep 17 00:00:00 2001 From: Alvaro Sanchez Date: Sun, 15 Jan 2017 12:50:58 +0000 Subject: [PATCH 3/4] Implemented changes from @efiring and added a test for scalars --- lib/matplotlib/colors.py | 121 +++++++++++++--------------- lib/matplotlib/tests/test_colors.py | 9 +++ 2 files changed, 65 insertions(+), 65 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 95e89618bd5b..6f169f9928d8 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -962,62 +962,58 @@ def scaled(self): class FuncNorm(Normalize): """ - Creates a normalizer using a custom function + A norm based on a monotonic custom function. - The normalizer will be a function mapping the data values into colormap - values in the ``[0,1]`` range. - """ + The norm will use a provided custom function to map the data + values into colormap values in the [0,1] range. It will be calculated + as (f(x)-f(vmin))/(f(vmax)-f(vmin)). - def __init__(self, f, finv=None, vmin=None, vmax=None, clip=False): - """ - Specify the function to be used, and its inverse, as well as other - parameters to be passed to `Normalize`. The normalization will be - calculated as (f(x)-f(vmin))/(f(vmax)-f(vmin)). + Parameters + ---------- + f : callable or string + Function to be used for the normalization receiving a single + parameter, compatible with scalar values and arrays. + Alternatively some predefined functions may be specified + as a string (See Notes). The chosen function must + be strictly increasing in the [`vmin`, `vmax`] interval. + finv : callable, optional + Inverse of `f` satisfying finv(f(x)) == x. Optional and ignored + when `f` is a string; otherwise, required. + vmin, vmax : None or float, optional + Data values to be mapped to 0 and 1. If either is None, it is + assigned the minimum or maximum value of the data supplied to + the first call of the norm. Default None. + clip : bool, optional + If True, clip data values to [`vmin`, `vmax`]. This effectively + defeats the purpose of setting the over and under values of the + color map. If False, values below `vmin` and above `vmax` will + be mapped to -0.1 and 1.1 respectively. Default False. + + Notes + ----- + Valid predefined functions are ['linear', 'quadratic', + 'cubic', 'x**{p}', 'sqrt', 'cbrt', 'root{p}(x)', 'log', 'log10', + 'log2', 'log{p}(x)', 'log(x+{p}) 'log10(x+{p})', 'log{p}(x+{p})] + where 'p' must be replaced by the corresponding value of the + parameter when present. + + Examples + -------- + Creating a logarithmic normalization using the predefined strings: + + >>> import matplotlib.colors as colors + >>> norm = colors.FuncNorm(f='log10', vmin=0.01, vmax=2) + + Or manually: + + >>> import matplotlib.colors as colors + >>> norm = colors.FuncNorm(f=lambda x: np.log10(x), + ... finv=lambda x: 10.**(x), + ... vmin=0.01, vmax=2) - Parameters - ---------- - f : callable or string - Function to be used for the normalization receiving a single - parameter, compatible with scalar values and ndarrays. - Alternatively a string from the list ['linear', 'quadratic', - 'cubic', 'x**{p}', 'sqrt', 'cbrt', 'root{p}(x)', 'log', 'log10', - 'log2', 'log{p}(x)', 'log(x+{p}) 'log10(x+{p})', 'log{p}(x+{p})] - can be used, replacing 'p' by the corresponding value of the - parameter, when present. - finv : callable, optional - Inverse function of `f` that satisfies finv(f(x))==x. - Optional/ignored when `f` is a string. - vmin : float or None, optional - Value assigned to the lower limit of the colormap. If None, it - will be assigned to the minimum value of the data provided. - Default None. - vmax : float or None, optional - Value assigned to the upper limit of the colormap. If None, it - will be assigned to the maximum value of the data provided. - Default None. - clip : bool, optional - If True, any value below `vmin` will be clipped to `vmin`, and - any value above `vmax` will be clip to `vmin`. This effectively - defeats the purpose of setting the over and under values of the - color map. If False, values below `vmin` and above `vmax` will - be set to -0.1 and 1.1 respectively, after the normalization. - Default False. - - Examples - -------- - Creating a logarithmic normalization using the predefined strings: - - >>> import matplotlib.colors as colors - >>> norm = colors.FuncNorm(f='log10', vmin=0.01, vmax=2) - - Or manually: - - >>> import matplotlib.colors as colors - >>> norm = colors.FuncNorm(f=lambda x: np.log10(x), - ... finv=lambda x: 10.**(x), - ... vmin=0.01, vmax=2) + """ - """ + def __init__(self, f, finv=None, vmin=None, vmax=None, clip=False): super(FuncNorm, self).__init__(vmin=vmin, vmax=vmax, clip=clip) if isinstance(f, six.string_types): @@ -1026,10 +1022,9 @@ def __init__(self, f, finv=None, vmin=None, vmax=None, clip=False): finv = func_parser.inverse if not callable(f): raise ValueError("`f` must be a callable or a string.") - if finv is None: raise ValueError("Inverse function `finv` not provided.") - elif not callable(finv): + if not callable(finv): raise ValueError("`finv` must be a callable.") self._f = f @@ -1040,7 +1035,7 @@ def _update_f(self, vmin, vmax): # the limits vmin and vmax may require changing/updating the # function depending on vmin/vmax, for example rescaling it # to accommodate to the new interval. - return + pass def __call__(self, value, clip=None): """ @@ -1049,7 +1044,7 @@ def __call__(self, value, clip=None): Parameters ---------- - value : float or ndarray of floats + value : scalar or array-like Data to be normalized. clip : boolean, optional Whether to clip the data outside the ``[`vmin`, `vmax`]`` limits. @@ -1069,9 +1064,7 @@ def __call__(self, value, clip=None): result, is_scalar = self.process_value(value) self.autoscale_None(result) - self._check_vmin_vmax() - vmin = float(self.vmin) - vmax = float(self.vmax) + vmin, vmax = self._check_vmin_vmax() self._update_f(vmin, vmax) @@ -1089,10 +1082,9 @@ def __call__(self, value, clip=None): # and over values resultnorm[mask_over] = 1.1 resultnorm[mask_under] = -0.1 - resultnorm[mask] = (self._f(result[mask]) - self._f(vmin)) / \ - (self._f(vmax) - self._f(vmin)) + resultnorm[mask] = ((self._f(result[mask]) - self._f(vmin)) / + (self._f(vmax) - self._f(vmin))) - resultnorm = np.ma.array(resultnorm) if is_scalar: return resultnorm[0] else: @@ -1114,9 +1106,7 @@ def inverse(self, value): Data before normalization. """ - self._check_vmin_vmax() - vmin = self.vmin - vmax = self.vmax + vmin, vmax = self._check_vmin_vmax() self._update_f(vmin, vmax) value = self._finv( value * (self._f(vmax) - self._f(vmin)) + self._f(vmin)) @@ -1125,6 +1115,7 @@ def inverse(self, value): def _check_vmin_vmax(self): if self.vmin >= self.vmax: raise ValueError("vmin must be smaller than vmax") + return float(self.vmin), float(self.vmax) def ticks(self, nticks=13): """ diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 3e834778fca1..30fcffb3d17b 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -193,6 +193,15 @@ def test_inverse(self): x = np.linspace(0.01, 2, 10) assert_array_almost_equal(x, norm.inverse(norm(x))) + def test_scalar(self): + norm = mcolors.FuncNorm(f='linear', vmin=1., vmax=2., + clip=True) + assert_equal(norm(1.5), 0.5) + assert_equal(norm(1.), 0.) + assert_equal(norm(0.5), 0.) + assert_equal(norm(2.), 1.) + assert_equal(norm(2.5), 1.) + def test_ticks(self): norm = mcolors.FuncNorm(f='log10', vmin=0.01, vmax=2.) expected = [0.01, 0.016, 0.024, 0.04, 0.06, From e967d699c9072924a1374486930f2fb350393413 Mon Sep 17 00:00:00 2001 From: Alvaro Sanchez Date: Sun, 15 Jan 2017 15:14:35 +0000 Subject: [PATCH 4/4] Added FuncLocator --- .../color/colormap_normalizations_funcnorm.py | 3 +- lib/matplotlib/colorbar.py | 3 + lib/matplotlib/colors.py | 43 +-------- lib/matplotlib/tests/test_colors.py | 7 -- lib/matplotlib/tests/test_ticker.py | 28 +++++- lib/matplotlib/ticker.py | 87 +++++++++++++++++++ 6 files changed, 122 insertions(+), 49 deletions(-) diff --git a/examples/color/colormap_normalizations_funcnorm.py b/examples/color/colormap_normalizations_funcnorm.py index 44520c410433..dacddbb97c2b 100644 --- a/examples/color/colormap_normalizations_funcnorm.py +++ b/examples/color/colormap_normalizations_funcnorm.py @@ -57,8 +57,7 @@ def gauss2d(x, y, a0, x0, y0, wx, wy): # Showing the normalization effect on an image cax = ax_right.imshow(data, cmap=cm.afmhot, norm=norm, aspect='auto') - ticks = cax.norm.ticks(5) if norm else np.linspace(0, 1, 6) - fig.colorbar(cax, format='%.3g', ticks=ticks, ax=ax_right) + fig.colorbar(cax, format='%.3g', ax=ax_right) ax_right.set_title(title) ax_right.xaxis.set_ticks([]) ax_right.yaxis.set_ticks([]) diff --git a/lib/matplotlib/colorbar.py b/lib/matplotlib/colorbar.py index df488bba7811..072243846670 100644 --- a/lib/matplotlib/colorbar.py +++ b/lib/matplotlib/colorbar.py @@ -583,6 +583,9 @@ def _ticker(self): locator = ticker.FixedLocator(b, nbins=10) elif isinstance(self.norm, colors.LogNorm): locator = ticker.LogLocator(subs='all') + elif isinstance(self.norm, colors.FuncNorm): + locator = ticker.FuncLocator(self.norm.__call__, + self.norm.inverse) elif isinstance(self.norm, colors.SymLogNorm): # The subs setting here should be replaced # by logic in the locator. diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 6f169f9928d8..925434c847e7 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -974,8 +974,8 @@ class FuncNorm(Normalize): Function to be used for the normalization receiving a single parameter, compatible with scalar values and arrays. Alternatively some predefined functions may be specified - as a string (See Notes). The chosen function must - be strictly increasing in the [`vmin`, `vmax`] interval. + as a string (See Notes). The chosen function must be strictly + increasing and bounded in the [`vmin`, `vmax`] interval. finv : callable, optional Inverse of `f` satisfying finv(f(x)) == x. Optional and ignored when `f` is a string; otherwise, required. @@ -1070,8 +1070,8 @@ def __call__(self, value, clip=None): if clip: result = np.clip(result, vmin, vmax) - resultnorm = (self._f(result) - self._f(vmin)) / \ - (self._f(vmax) - self._f(vmin)) + resultnorm = ((self._f(result) - self._f(vmin)) / + (self._f(vmax) - self._f(vmin))) else: resultnorm = result.copy() mask_over = result > vmax @@ -1117,41 +1117,6 @@ def _check_vmin_vmax(self): raise ValueError("vmin must be smaller than vmax") return float(self.vmin), float(self.vmax) - def ticks(self, nticks=13): - """ - Returns an automatic list of `nticks` points in the data space - to be used as ticks in the colorbar. - - Parameters - ---------- - nticks : integer, optional - Number of ticks to be returned. Default 13. - - Returns - ------- - ticks : ndarray - 1d array of length `nticks` with the proposed tick locations. - - """ - ticks = self.inverse(np.linspace(0, 1, nticks)) - finalticks = np.zeros(ticks.shape, dtype=np.bool) - finalticks[0] = True - ticks = FuncNorm._round_ticks(ticks, finalticks) - return ticks - - @staticmethod - def _round_ticks(ticks, permanenttick): - ticks = ticks.copy() - for i in range(len(ticks)): - if i == 0 or i == len(ticks) - 1 or permanenttick[i]: - continue - d1 = ticks[i] - ticks[i - 1] - d2 = ticks[i + 1] - ticks[i] - d = min([d1, d2]) - order = -np.floor(np.log10(d)) - ticks[i] = float(np.round(ticks[i] * 10**order)) / 10**order - return ticks - class LogNorm(Normalize): """ diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 30fcffb3d17b..78497ec62751 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -202,13 +202,6 @@ def test_scalar(self): assert_equal(norm(2.), 1.) assert_equal(norm(2.5), 1.) - def test_ticks(self): - norm = mcolors.FuncNorm(f='log10', vmin=0.01, vmax=2.) - expected = [0.01, 0.016, 0.024, 0.04, 0.06, - 0.09, 0.14, 0.22, 0.3, 0.5, - 0.8, 1.3, 2.] - assert_array_almost_equal(norm.ticks(), expected) - def test_LogNorm(): """ diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index e390237d543c..790160091eee 100644 --- a/lib/matplotlib/tests/test_ticker.py +++ b/lib/matplotlib/tests/test_ticker.py @@ -1,7 +1,8 @@ from __future__ import (absolute_import, division, print_function, unicode_literals) -from numpy.testing import assert_almost_equal +from numpy.testing import (assert_almost_equal, + assert_array_almost_equal) import numpy as np import pytest @@ -75,6 +76,31 @@ def test_LogLocator(): assert_almost_equal(loc.tick_values(1, 100), test_value) +class TestFuncLocator(object): + def test_call(self): + loc = mticker.FuncLocator(np.sqrt, lambda x: x**2) + expected = [0., 0.01, 0.04, 0.09, 0.16, 0.25, 0.4, + 0.49, 0.6, 0.8, 1.] + assert_array_almost_equal(loc(), expected) + + def test_tick_values(self): + loc = mticker.FuncLocator(np.sqrt, lambda x: x**2) + expected = [0., 0.01, 0.04, 0.09, 0.16, 0.25, 0.4, + 0.49, 0.6, 0.8, 1.] + assert_array_almost_equal(loc.tick_values(), expected) + + def test_set_params(self): + loc = mticker.FuncLocator(lambda x: x, lambda x: x, 6) + expected = [0., 0.2, 0.4, 0.6, 0.8, 1.] + assert_array_almost_equal(loc.tick_values(), expected) + loc.set_params(function=np.sqrt, + inverse=lambda x: x**2, + numticks=11) + expected = [0., 0.01, 0.04, 0.09, 0.16, 0.25, 0.4, + 0.49, 0.6, 0.8, 1.] + assert_array_almost_equal(loc.tick_values(), expected) + + def test_LinearLocator_set_params(): """ Create linear locator with presets={}, numticks=2 and change it to diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index afea9f748e05..ee6e5e55dbcf 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -1973,6 +1973,93 @@ def is_close_to_int(x): return abs(x - nearest_long(x)) < 1e-10 +class FuncLocator(Locator): + """ + Determines the tick locations for using user provided functions. + + It attempts to provide a fixed number `numticks` of tick locations + relatively uniformly spread across the axis, while rounding the + values as much as possible. + + Parameters + ---------- + function : callable + Transformation of the axis using the ticks. + inverse : callable + Inverse transformation of `function`. + numticks : integer, optional + Number of ticks to include. Default 11. + + """ + def __init__(self, function, inverse, numticks=None): + self._numticks = numticks + self._function = function + self._inverse = inverse + + def tick_values(self, vmin=None, vmax=None): + """ + Returns the tick locations + + Parameters + ---------- + vmin, vmax : integer, optional + Maximum and minimum values. Not used. + + Returns + ------- + ticks : ndarray + 1d array of length `numticks` with the proposed tick locations. + + """ + + if self._numticks is None: + self._set_numticks() + + ticks = self._inverse(np.linspace(0, 1, self._numticks)) + finalticks = np.zeros(ticks.shape, dtype=np.bool) + finalticks[0] = True + finalticks[-1] = True + ticks = FuncLocator._round_ticks(ticks, finalticks) + return ticks + + def _set_numticks(self): + self._numticks = 11 + + def set_params(self, function=None, inverse=None, numticks=None): + """Set parameters within this locator.""" + if inverse is not None: + self._inverse = inverse + if function is not None: + self._function = function + if numticks is not None: + self._numticks = numticks + + def __call__(self): + """ + Returns the tick locations + + Returns + ------- + ticks : ndarray + 1d array of length `numticks` with the proposed tick locations. + + """ + return self.tick_values() + + @staticmethod + def _round_ticks(ticks, permanent_tick): + ticks = ticks.copy() + for i in range(len(ticks)): + if i == 0 or i == len(ticks) - 1 or permanent_tick[i]: + continue + d1 = ticks[i] - ticks[i - 1] + d2 = ticks[i + 1] - ticks[i] + d = min([d1, d2]) + order = -np.floor(np.log10(d)) + ticks[i] = float(np.round(ticks[i] * 10**order)) / 10**order + return ticks + + class LogLocator(Locator): """ Determine the tick locations for log axes