Skip to content

Commit d0250b4

Browse files
committed
Merge pull request #1204 from bgamari/power-law-norm
Add power-law normalization
2 parents 9f28318 + d5343c0 commit d0250b4

File tree

6 files changed

+147
-19
lines changed

6 files changed

+147
-19
lines changed

CHANGELOG

+3
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
all pyplot.tri* methods) and mlab.griddata. Deprecated
3232
matplotlib.delaunay module. - IMT
3333

34+
2013-11-05 Add power-law normalization method. This is useful for,
35+
e.g., showing small populations in a "hist2d" histogram.
36+
3437
2013-10-27 Added get_rlabel_position and set_rlabel_position methods to
3538
PolarAxes to control angular position of radial tick labels.
3639

doc/users/whats_new.rst

+8
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ Phil Elson rewrote of the documentation and userguide for both Legend and PathEf
3232
New plotting features
3333
---------------------
3434

35+
Power-law normalization
36+
```````````````````````
37+
Ben Gamari added a power-law normalization method,
38+
:class:`~matplotlib.colors.PowerNorm`. This class maps a range of
39+
values to the interval [0,1] with power-law scaling with the exponent
40+
provided by the constructor's `gamma` argument. Power law normalization
41+
can be useful for, e.g., emphasizing small populations in a histogram.
42+
3543
Fully customizable boxplots
3644
````````````````````````````
3745
Paul Hobson overhauled the :func:`~matplotlib.pyplot.boxplot` method such

examples/api/power_norm_demo.py

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/python
2+
3+
from matplotlib import pyplot as plt
4+
import matplotlib.colors as mcolors
5+
import numpy as np
6+
from numpy.random import multivariate_normal
7+
8+
data = np.vstack([multivariate_normal([10, 10], [[3, 5],[4, 2]], size=100000),
9+
multivariate_normal([30, 20], [[2, 3],[1, 3]], size=1000)
10+
])
11+
12+
gammas = [0.8, 0.5, 0.3]
13+
xgrid = np.floor((len(gammas) + 1.) / 2)
14+
ygrid = np.ceil((len(gammas) + 1.) / 2)
15+
16+
plt.subplot(xgrid, ygrid, 1)
17+
plt.title('Linear normalization')
18+
plt.hist2d(data[:,0], data[:,1], bins=100)
19+
20+
for i, gamma in enumerate(gammas):
21+
plt.subplot(xgrid, ygrid, i + 2)
22+
plt.title('Power law normalization\n$(\gamma=%1.1f)$' % gamma)
23+
plt.hist2d(data[:, 0], data[:, 1],
24+
bins=100, norm=mcolors.PowerNorm(gamma))
25+
26+
plt.subplots_adjust(hspace=0.39)
27+
plt.show()

lib/matplotlib/axes/_axes.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -5839,7 +5839,9 @@ def hist2d(self, x, y, bins=10, range=None, normed=False, weights=None,
58395839
-----
58405840
Rendering the histogram with a logarithmic color scale is
58415841
accomplished by passing a :class:`colors.LogNorm` instance to
5842-
the *norm* keyword argument.
5842+
the *norm* keyword argument. Likewise, power-law normalization
5843+
(similar in effect to gamma correction) can be accomplished with
5844+
:class:`colors.PowerNorm`.
58435845
58445846
Examples
58455847
--------

lib/matplotlib/colors.py

+92-17
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
import six
5353
from six.moves import zip
5454

55+
import warnings
5556
import re
5657
import numpy as np
5758
from numpy import ma
@@ -617,24 +618,24 @@ def __call__(self, X, alpha=None, bytes=False):
617618
return rgba
618619

619620
def set_bad(self, color='k', alpha=None):
620-
'''Set color to be used for masked values.
621-
'''
621+
"""Set color to be used for masked values.
622+
"""
622623
self._rgba_bad = colorConverter.to_rgba(color, alpha)
623624
if self._isinit:
624625
self._set_extremes()
625626

626627
def set_under(self, color='k', alpha=None):
627-
'''Set color to be used for low out-of-range values.
628+
"""Set color to be used for low out-of-range values.
628629
Requires norm.clip = False
629-
'''
630+
"""
630631
self._rgba_under = colorConverter.to_rgba(color, alpha)
631632
if self._isinit:
632633
self._set_extremes()
633634

634635
def set_over(self, color='k', alpha=None):
635-
'''Set color to be used for high out-of-range values.
636+
"""Set color to be used for high out-of-range values.
636637
Requires norm.clip = False
637-
'''
638+
"""
638639
self._rgba_over = colorConverter.to_rgba(color, alpha)
639640
if self._isinit:
640641
self._set_extremes()
@@ -651,7 +652,7 @@ def _set_extremes(self):
651652
self._lut[self._i_bad] = self._rgba_bad
652653

653654
def _init(self):
654-
'''Generate the lookup table, self._lut'''
655+
"""Generate the lookup table, self._lut"""
655656
raise NotImplementedError("Abstract class only")
656657

657658
def is_gray(self):
@@ -937,9 +938,9 @@ def inverse(self, value):
937938
return vmin + value * (vmax - vmin)
938939

939940
def autoscale(self, A):
940-
'''
941+
"""
941942
Set *vmin*, *vmax* to min, max of *A*.
942-
'''
943+
"""
943944
self.vmin = ma.min(A)
944945
self.vmax = ma.max(A)
945946

@@ -1008,9 +1009,9 @@ def inverse(self, value):
10081009
return vmin * pow((vmax / vmin), value)
10091010

10101011
def autoscale(self, A):
1011-
'''
1012+
"""
10121013
Set *vmin*, *vmax* to min, max of *A*.
1013-
'''
1014+
"""
10141015
A = ma.masked_less_equal(A, 0, copy=False)
10151016
self.vmin = ma.min(A)
10161017
self.vmax = ma.max(A)
@@ -1140,8 +1141,82 @@ def autoscale_None(self, A):
11401141
self._transform_vmin_vmax()
11411142

11421143

1144+
class PowerNorm(Normalize):
1145+
"""
1146+
Normalize a given value to the ``[0, 1]`` interval with a power-law
1147+
scaling. This will clip any negative data points to 0.
1148+
"""
1149+
def __init__(self, gamma, vmin=None, vmax=None, clip=False):
1150+
Normalize.__init__(self, vmin, vmax, clip)
1151+
self.gamma = gamma
1152+
1153+
def __call__(self, value, clip=None):
1154+
if clip is None:
1155+
clip = self.clip
1156+
1157+
result, is_scalar = self.process_value(value)
1158+
1159+
self.autoscale_None(result)
1160+
gamma = self.gamma
1161+
vmin, vmax = self.vmin, self.vmax
1162+
if vmin > vmax:
1163+
raise ValueError("minvalue must be less than or equal to maxvalue")
1164+
elif vmin == vmax:
1165+
result.fill(0)
1166+
else:
1167+
if clip:
1168+
mask = ma.getmask(result)
1169+
val = ma.array(np.clip(result.filled(vmax), vmin, vmax),
1170+
mask=mask)
1171+
resdat = result.data
1172+
resdat -= vmin
1173+
np.power(resdat, gamma, resdat)
1174+
resdat /= (vmax - vmin) ** gamma
1175+
result = np.ma.array(resdat, mask=result.mask, copy=False)
1176+
result[value < 0] = 0
1177+
if is_scalar:
1178+
result = result[0]
1179+
return result
1180+
1181+
def inverse(self, value):
1182+
if not self.scaled():
1183+
raise ValueError("Not invertible until scaled")
1184+
gamma = self.gamma
1185+
vmin, vmax = self.vmin, self.vmax
1186+
1187+
if cbook.iterable(value):
1188+
val = ma.asarray(value)
1189+
return ma.power(value, 1. / gamma) * (vmax - vmin) + vmin
1190+
else:
1191+
return pow(value, 1. / gamma) * (vmax - vmin) + vmin
1192+
1193+
def autoscale(self, A):
1194+
"""
1195+
Set *vmin*, *vmax* to min, max of *A*.
1196+
"""
1197+
self.vmin = ma.min(A)
1198+
if self.vmin < 0:
1199+
self.vmin = 0
1200+
warnings.warn("Power-law scaling on negative values is "
1201+
"ill-defined, clamping to 0.")
1202+
1203+
self.vmax = ma.max(A)
1204+
1205+
def autoscale_None(self, A):
1206+
' autoscale only None-valued vmin or vmax'
1207+
if self.vmin is None and np.size(A) > 0:
1208+
self.vmin = ma.min(A)
1209+
if self.vmin < 0:
1210+
self.vmin = 0
1211+
warnings.warn("Power-law scaling on negative values is "
1212+
"ill-defined, clamping to 0.")
1213+
1214+
if self.vmax is None and np.size(A) > 0:
1215+
self.vmax = ma.max(A)
1216+
1217+
11431218
class BoundaryNorm(Normalize):
1144-
'''
1219+
"""
11451220
Generate a colormap index based on discrete intervals.
11461221
11471222
Unlike :class:`Normalize` or :class:`LogNorm`,
@@ -1152,9 +1227,9 @@ class BoundaryNorm(Normalize):
11521227
piece-wise linear interpolation, but using integers seems
11531228
simpler, and reduces the number of conversions back and forth
11541229
between integer and floating point.
1155-
'''
1230+
"""
11561231
def __init__(self, boundaries, ncolors, clip=False):
1157-
'''
1232+
"""
11581233
*boundaries*
11591234
a monotonically increasing sequence
11601235
*ncolors*
@@ -1171,7 +1246,7 @@ def __init__(self, boundaries, ncolors, clip=False):
11711246
Out-of-range values are mapped to -1 if low and ncolors
11721247
if high; these are converted to valid indices by
11731248
:meth:`Colormap.__call__` .
1174-
'''
1249+
"""
11751250
self.clip = clip
11761251
self.vmin = boundaries[0]
11771252
self.vmax = boundaries[-1]
@@ -1209,11 +1284,11 @@ def inverse(self, value):
12091284

12101285

12111286
class NoNorm(Normalize):
1212-
'''
1287+
"""
12131288
Dummy replacement for Normalize, for the case where we
12141289
want to use indices directly in a
12151290
:class:`~matplotlib.cm.ScalarMappable` .
1216-
'''
1291+
"""
12171292
def __call__(self, value, clip=None):
12181293
return value
12191294

lib/matplotlib/tests/test_colors.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,27 @@ def test_BoundaryNorm():
4747

4848
def test_LogNorm():
4949
"""
50-
LogNorm igornoed clip, now it has the same
50+
LogNorm ignored clip, now it has the same
5151
behavior as Normalize, e.g., values > vmax are bigger than 1
5252
without clip, with clip they are 1.
5353
"""
5454
ln = mcolors.LogNorm(clip=True, vmax=5)
5555
assert_array_equal(ln([1, 6]), [0, 1.0])
5656

5757

58+
def test_PowerNorm():
59+
a = np.array([0, 0.5, 1, 1.5], dtype=np.float)
60+
pnorm = mcolors.PowerNorm(1)
61+
norm = mcolors.Normalize()
62+
assert_array_almost_equal(norm(a), pnorm(a))
63+
64+
a = np.array([-0.5, 0, 2, 4, 8], dtype=np.float)
65+
expected = [0, 0, 1./16, 1./4, 1]
66+
pnorm = mcolors.PowerNorm(2, vmin=0, vmax=8)
67+
assert_array_almost_equal(pnorm(a), expected)
68+
assert_array_almost_equal(a[1:], pnorm.inverse(pnorm(a))[1:])
69+
70+
5871
def test_Normalize():
5972
norm = mcolors.Normalize()
6073
vals = np.arange(-10, 10, 1, dtype=np.float)

0 commit comments

Comments
 (0)