Skip to content

Add power-law normalization #1204

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 5 commits into from
Apr 4, 2014
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
3 changes: 3 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
all pyplot.tri* methods) and mlab.griddata. Deprecated
matplotlib.delaunay module. - IMT

2013-11-05 Add power-law normalization method. This is useful for,
e.g., showing small populations in a "hist2d" histogram.

2013-10-27 Added get_rlabel_position and set_rlabel_position methods to
PolarAxes to control angular position of radial tick labels.

Expand Down
8 changes: 8 additions & 0 deletions doc/users/whats_new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ Phil Elson rewrote of the documentation and userguide for both Legend and PathEf
New plotting features
---------------------

Power-law normalization
```````````````````````
Ben Gamari added a power-law normalization method,
:class:`~matplotlib.colors.PowerNorm`. This class maps a range of
values to the interval [0,1] with power-law scaling with the exponent
provided by the constructor's `gamma` argument. Power law normalization
can be useful for, e.g., emphasizing small populations in a histogram.

Fully customizable boxplots
````````````````````````````
Paul Hobson overhauled the :func:`~matplotlib.pyplot.boxplot` method such
Expand Down
27 changes: 27 additions & 0 deletions examples/api/power_norm_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/python

from matplotlib import pyplot as plt
import matplotlib.colors as mcolors
import numpy as np
from numpy.random import multivariate_normal

data = np.vstack([multivariate_normal([10, 10], [[3, 5],[4, 2]], size=100000),
multivariate_normal([30, 20], [[2, 3],[1, 3]], size=1000)
])

gammas = [0.8, 0.5, 0.3]
xgrid = np.floor((len(gammas) + 1.) / 2)
ygrid = np.ceil((len(gammas) + 1.) / 2)

plt.subplot(xgrid, ygrid, 1)
plt.title('Linear normalization')
plt.hist2d(data[:,0], data[:,1], bins=100)

for i, gamma in enumerate(gammas):
plt.subplot(xgrid, ygrid, i + 2)
plt.title('Power law normalization\n$(\gamma=%1.1f)$' % gamma)
plt.hist2d(data[:, 0], data[:, 1],
bins=100, norm=mcolors.PowerNorm(gamma))

plt.subplots_adjust(hspace=0.39)
plt.show()
4 changes: 3 additions & 1 deletion lib/matplotlib/axes/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5807,7 +5807,9 @@ def hist2d(self, x, y, bins=10, range=None, normed=False, weights=None,
-----
Rendering the histogram with a logarithmic color scale is
accomplished by passing a :class:`colors.LogNorm` instance to
the *norm* keyword argument.
the *norm* keyword argument. Likewise, power-law normalization
(similar in effect to gamma correction) can be accomplished with
:class:`colors.PowerNorm`.

Examples
--------
Expand Down
109 changes: 92 additions & 17 deletions lib/matplotlib/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import six
from six.moves import map, zip

import warnings
import re
import numpy as np
from numpy import ma
Expand Down Expand Up @@ -625,24 +626,24 @@ def __call__(self, X, alpha=None, bytes=False):
return rgba

def set_bad(self, color='k', alpha=None):
'''Set color to be used for masked values.
'''
"""Set color to be used for masked values.
"""
self._rgba_bad = colorConverter.to_rgba(color, alpha)
if self._isinit:
self._set_extremes()

def set_under(self, color='k', alpha=None):
'''Set color to be used for low out-of-range values.
"""Set color to be used for low out-of-range values.
Requires norm.clip = False
'''
"""
self._rgba_under = colorConverter.to_rgba(color, alpha)
if self._isinit:
self._set_extremes()

def set_over(self, color='k', alpha=None):
'''Set color to be used for high out-of-range values.
"""Set color to be used for high out-of-range values.
Requires norm.clip = False
'''
"""
self._rgba_over = colorConverter.to_rgba(color, alpha)
if self._isinit:
self._set_extremes()
Expand All @@ -659,7 +660,7 @@ def _set_extremes(self):
self._lut[self._i_bad] = self._rgba_bad

def _init(self):
'''Generate the lookup table, self._lut'''
"""Generate the lookup table, self._lut"""
raise NotImplementedError("Abstract class only")

def is_gray(self):
Expand Down Expand Up @@ -945,9 +946,9 @@ def inverse(self, value):
return vmin + value * (vmax - vmin)

def autoscale(self, A):
'''
"""
Set *vmin*, *vmax* to min, max of *A*.
'''
"""
self.vmin = ma.min(A)
self.vmax = ma.max(A)

Expand Down Expand Up @@ -1016,9 +1017,9 @@ def inverse(self, value):
return vmin * pow((vmax / vmin), value)

def autoscale(self, A):
'''
"""
Set *vmin*, *vmax* to min, max of *A*.
'''
"""
A = ma.masked_less_equal(A, 0, copy=False)
self.vmin = ma.min(A)
self.vmax = ma.max(A)
Expand Down Expand Up @@ -1148,8 +1149,82 @@ def autoscale_None(self, A):
self._transform_vmin_vmax()


class PowerNorm(Normalize):
"""
Normalize a given value to the ``[0, 1]`` interval with a power-law
scaling. This will clip any negative data points to 0.
"""
def __init__(self, gamma, vmin=None, vmax=None, clip=False):
Normalize.__init__(self, vmin, vmax, clip)
self.gamma = gamma
Copy link
Member

Choose a reason for hiding this comment

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

What happens if gamma <= 0 || gamma > 1?

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 see the problem with gamma > 1

Copy link
Member

Choose a reason for hiding this comment

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

@tacaswell I agree. My main concerns are with gamma <= 0 and non-positive data.


def __call__(self, value, clip=None):
if clip is None:
clip = self.clip

result, is_scalar = self.process_value(value)

self.autoscale_None(result)
gamma = self.gamma
vmin, vmax = self.vmin, self.vmax
if vmin > vmax:
raise ValueError("minvalue must be less than or equal to maxvalue")
elif vmin == vmax:
result.fill(0)
else:
if clip:
mask = ma.getmask(result)
val = ma.array(np.clip(result.filled(vmax), vmin, vmax),
mask=mask)
resdat = result.data
resdat -= vmin
np.power(resdat, gamma, resdat)
resdat /= (vmax - vmin) ** gamma
result = np.ma.array(resdat, mask=result.mask, copy=False)
result[value < 0] = 0
if is_scalar:
result = result[0]
return result

def inverse(self, value):
if not self.scaled():
raise ValueError("Not invertible until scaled")
gamma = self.gamma
vmin, vmax = self.vmin, self.vmax

if cbook.iterable(value):
val = ma.asarray(value)
return ma.power(value, 1. / gamma) * (vmax - vmin) + vmin
else:
return pow(value, 1. / gamma) * (vmax - vmin) + vmin

def autoscale(self, A):
"""
Set *vmin*, *vmax* to min, max of *A*.
"""
self.vmin = ma.min(A)
if self.vmin < 0:
self.vmin = 0
warnings.warn("Power-law scaling on negative values is "
"ill-defined, clamping to 0.")

self.vmax = ma.max(A)

def autoscale_None(self, A):
Copy link
Member

Choose a reason for hiding this comment

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

Why not just inherit autoscale_None and autoscale from Normalize? It doesn't look like you are doing anything differently here in either of them.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@efiring fair point. I'll fix this shortly.

' autoscale only None-valued vmin or vmax'
if self.vmin is None and np.size(A) > 0:
self.vmin = ma.min(A)
if self.vmin < 0:
self.vmin = 0
warnings.warn("Power-law scaling on negative values is "
"ill-defined, clamping to 0.")

if self.vmax is None and np.size(A) > 0:
self.vmax = ma.max(A)


class BoundaryNorm(Normalize):
'''
"""
Generate a colormap index based on discrete intervals.

Unlike :class:`Normalize` or :class:`LogNorm`,
Expand All @@ -1160,9 +1235,9 @@ class BoundaryNorm(Normalize):
piece-wise linear interpolation, but using integers seems
simpler, and reduces the number of conversions back and forth
between integer and floating point.
'''
"""
def __init__(self, boundaries, ncolors, clip=False):
'''
"""
*boundaries*
a monotonically increasing sequence
*ncolors*
Expand All @@ -1179,7 +1254,7 @@ def __init__(self, boundaries, ncolors, clip=False):
Out-of-range values are mapped to -1 if low and ncolors
if high; these are converted to valid indices by
:meth:`Colormap.__call__` .
'''
"""
self.clip = clip
self.vmin = boundaries[0]
self.vmax = boundaries[-1]
Expand Down Expand Up @@ -1217,11 +1292,11 @@ def inverse(self, value):


class NoNorm(Normalize):
'''
"""
Dummy replacement for Normalize, for the case where we
want to use indices directly in a
:class:`~matplotlib.cm.ScalarMappable` .
'''
"""
def __call__(self, value, clip=None):
return value

Expand Down
15 changes: 14 additions & 1 deletion lib/matplotlib/tests/test_colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,27 @@ def test_BoundaryNorm():

def test_LogNorm():
"""
LogNorm igornoed clip, now it has the same
LogNorm ignored clip, now it has the same
behavior as Normalize, e.g., values > vmax are bigger than 1
without clip, with clip they are 1.
"""
ln = mcolors.LogNorm(clip=True, vmax=5)
assert_array_equal(ln([1, 6]), [0, 1.0])

Copy link
Member

Choose a reason for hiding this comment

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

PEP8: Add another blank line before the test definition.


def test_PowerNorm():
a = np.array([0, 0.5, 1, 1.5], dtype=np.float)
pnorm = mcolors.PowerNorm(1)
norm = mcolors.Normalize()
assert_array_almost_equal(norm(a), pnorm(a))
Copy link
Member

Choose a reason for hiding this comment

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

this is the assertion that is now failing


a = np.array([-0.5, 0, 2, 4, 8], dtype=np.float)
expected = [0, 0, 1./16, 1./4, 1]
pnorm = mcolors.PowerNorm(2, vmin=0, vmax=8)
assert_array_almost_equal(pnorm(a), expected)
assert_array_almost_equal(a[1:], pnorm.inverse(pnorm(a))[1:])


Copy link
Member

Choose a reason for hiding this comment

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

PEP8: And add another blank line after the test definition.

def test_Normalize():
norm = mcolors.Normalize()
vals = np.arange(-10, 10, 1, dtype=np.float)
Expand Down