Skip to content

Commit 205e8b9

Browse files
committed
ENH: Add OffsetNorm and tests
Borrows heavily from @Tillsen's solution found on StackOverflow here: http://goo.gl/RPXMYB Used with his permission dicussesd on Github here: https://github.com/matplotlib/matplotlib/pull/3858`
1 parent df3530d commit 205e8b9

File tree

2 files changed

+309
-4
lines changed

2 files changed

+309
-4
lines changed

lib/matplotlib/colors.py

+124
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ def rgb2hex(rgb):
225225
a = '#%02x%02x%02x' % tuple([int(np.round(val * 255)) for val in rgb[:3]])
226226
return a
227227

228+
228229
hexColorPattern = re.compile("\A#[a-fA-F0-9]{6}\Z")
229230

230231

@@ -969,6 +970,129 @@ def scaled(self):
969970
return (self.vmin is not None and self.vmax is not None)
970971

971972

973+
class OffsetNorm(Normalize):
974+
"""
975+
A subclass of matplotlib.colors.Normalize.
976+
977+
Normalizes data into the ``[0.0, 1.0]`` interval.
978+
"""
979+
def __init__(self, vmin=None, vcenter=None, vmax=None, clip=False):
980+
"""Normalize data with an offset midpoint
981+
982+
Useful when mapping data unequally centered around a conceptual
983+
center, e.g., data that range from -2 to 4, with 0 as the midpoint.
984+
985+
Parameters
986+
----------
987+
vmin : optional float
988+
The data value that defines ``0.0`` in the normalized data.
989+
Defaults to the min value of the dataset.
990+
991+
vcenter : optional float
992+
The data value that defines ``0.5`` in the normalized data.
993+
Defaults to halfway between *vmin* and *vmax*.
994+
995+
vmax : option float
996+
The data value that defines ``1.0`` in the normalized data.
997+
Defaults to the the max value of the dataset.
998+
999+
clip : optional bool (default is False)
1000+
If *clip* is True, values beyond *vmin* and *vmax* will be set
1001+
to ``0.0`` or ``1.0``, respectively. Otherwise, values outside
1002+
the ``[0.0, 1.0]`` will be returned.
1003+
1004+
Examples
1005+
--------
1006+
>>> import matplotlib.colors as mcolors
1007+
>>> offset = mcolors.OffsetNorm(vmin=-2., vcenter=0., vmax=4.)
1008+
>>> data = [-2., -1., 0., 1., 2., 3., 4.]
1009+
>>> offset(data)
1010+
array([0., 0.25, 0.5, 0.625, 0.75, 0.875, 1.0])
1011+
1012+
"""
1013+
1014+
self.vmin = vmin
1015+
self.vcenter = vcenter
1016+
self.vmax = vmax
1017+
self.clip = clip
1018+
1019+
def __call__(self, value, clip=False):
1020+
if clip is None:
1021+
clip = self.clip
1022+
1023+
result, is_scalar = self.process_value(value)
1024+
1025+
self.autoscale_None(result)
1026+
vmin, vcenter, vmax = self.vmin, self.vcenter, self.vmax
1027+
if vmin == vmax == vcenter:
1028+
result.fill(0)
1029+
elif not vmin <= vcenter <= vmax:
1030+
raise ValueError("minvalue must be less than or equal to "
1031+
"centervalue which must be less than or "
1032+
"equal to maxvalue")
1033+
else:
1034+
vmin = float(vmin)
1035+
vcenter = float(vcenter)
1036+
vmax = float(vmax)
1037+
if clip:
1038+
mask = ma.getmask(result)
1039+
result = ma.array(np.clip(result.filled(vmax), vmin, vmax),
1040+
mask=mask)
1041+
1042+
# ma division is very slow; we can take a shortcut
1043+
resdat = result.data
1044+
1045+
#First scale to -1 to 1 range, than to from 0 to 1.
1046+
resdat -= vcenter
1047+
resdat[resdat > 0] /= abs(vmax - vcenter)
1048+
resdat[resdat < 0] /= abs(vmin - vcenter)
1049+
1050+
resdat /= 2.
1051+
resdat += 0.5
1052+
result = np.ma.array(resdat, mask=result.mask, copy=False)
1053+
1054+
if is_scalar:
1055+
result = result[0]
1056+
1057+
return result
1058+
1059+
def inverse(self, value):
1060+
if not self.scaled():
1061+
raise ValueError("Not invertible until scaled")
1062+
1063+
vmin, vcenter, vmax = self.vmin, self.vcenter, self.vmax
1064+
vmin = float(self.vmin)
1065+
vcenter = float(self.vcenter)
1066+
vmax = float(self.vmax)
1067+
1068+
if cbook.iterable(value):
1069+
val = ma.asarray(value)
1070+
val = 2 * (val - 0.5)
1071+
val[val > 0] *= abs(vmax - vcenter)
1072+
val[val < 0] *= abs(vmin - vcenter)
1073+
val += vcenter
1074+
return val
1075+
else:
1076+
val = 2 * (val - 0.5)
1077+
if val < 0:
1078+
return val * abs(vmin - vcenter) + vcenter
1079+
else:
1080+
return val * abs(vmax - vcenter) + vcenter
1081+
1082+
def autoscale_None(self, A):
1083+
' autoscale only None-valued vmin or vmax'
1084+
if self.vmin is None and np.size(A) > 0:
1085+
self.vmin = ma.min(A)
1086+
1087+
if self.vmax is None and np.size(A) > 0:
1088+
self.vmax = ma.max(A)
1089+
1090+
if self.vcenter is None:
1091+
self.vcenter = (self.vmax + self.vmin) * 0.5
1092+
1093+
return None
1094+
1095+
9721096
class LogNorm(Normalize):
9731097
"""
9741098
Normalize a given value to the 0-1 range on a log scale

lib/matplotlib/tests/test_colors.py

+185-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import itertools
66
from distutils.version import LooseVersion as V
77

8-
from nose.tools import assert_raises
8+
import nose.tools as nt
99

1010
import numpy as np
1111
from numpy.testing.utils import assert_array_equal, assert_array_almost_equal
@@ -80,6 +80,182 @@ def test_Normalize():
8080
_mask_tester(norm, vals)
8181

8282

83+
class _base_NormMixin(object):
84+
def test_call(self):
85+
normed_vals = self.norm(self.vals)
86+
assert_array_almost_equal(normed_vals, self.expected)
87+
88+
def test_inverse(self):
89+
_inverse_tester(self.norm, self.vals)
90+
91+
def test_scalar(self):
92+
_scalar_tester(self.norm, self.vals)
93+
94+
def test_mask(self):
95+
_mask_tester(self.norm, self.vals)
96+
97+
def test_autoscale(self):
98+
norm = self.normclass()
99+
norm.autoscale([10, 20, 30, 40])
100+
nt.assert_equal(norm.vmin, 10.)
101+
nt.assert_equal(norm.vmax, 40.)
102+
103+
def test_autoscale_None_vmin(self):
104+
norm = self.normclass(vmin=0, vmax=None)
105+
norm.autoscale_None([1, 2, 3, 4, 5])
106+
nt.assert_equal(norm.vmin, 0.)
107+
nt.assert_equal(norm.vmax, 5.)
108+
109+
def test_autoscale_None_vmax(self):
110+
norm = self.normclass(vmin=None, vmax=10)
111+
norm.autoscale_None([1, 2, 3, 4, 5])
112+
nt.assert_equal(norm.vmin, 1.)
113+
nt.assert_equal(norm.vmax, 10.)
114+
115+
def test_scale(self):
116+
norm = self.normclass()
117+
nt.assert_false(norm.scaled())
118+
119+
norm([1, 2, 3, 4])
120+
nt.assert_true(norm.scaled())
121+
122+
def test_process_value_scalar(self):
123+
res, is_scalar = mcolors.Normalize.process_value(5)
124+
nt.assert_true(is_scalar)
125+
assert_array_equal(res, np.array([5.]))
126+
127+
def test_process_value_list(self):
128+
res, is_scalar = mcolors.Normalize.process_value([5, 10])
129+
nt.assert_false(is_scalar)
130+
assert_array_equal(res, np.array([5., 10.]))
131+
132+
def test_process_value_tuple(self):
133+
res, is_scalar = mcolors.Normalize.process_value((5, 10))
134+
nt.assert_false(is_scalar)
135+
assert_array_equal(res, np.array([5., 10.]))
136+
137+
def test_process_value_array(self):
138+
res, is_scalar = mcolors.Normalize.process_value(np.array([5, 10]))
139+
nt.assert_false(is_scalar)
140+
assert_array_equal(res, np.array([5., 10.]))
141+
142+
143+
class test_OffsetNorm_Even(_base_NormMixin):
144+
def setup(self):
145+
self.normclass = mcolors.OffsetNorm
146+
self.norm = self.normclass(vmin=-1, vcenter=0, vmax=4)
147+
self.vals = np.array([-1.0, -0.5, 0.0, 1.0, 2.0, 3.0, 4.0])
148+
self.expected = np.array([0.0, 0.25, 0.5, 0.625, 0.75, 0.875, 1.0])
149+
150+
151+
class test_OffsetNorm_Odd(_base_NormMixin):
152+
def setup(self):
153+
self.normclass = mcolors.OffsetNorm
154+
self.norm = self.normclass(vmin=-2, vcenter=0, vmax=5)
155+
self.vals = np.array([-2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0])
156+
self.expected = np.array([0.0, 0.25, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0])
157+
158+
159+
class test_OffsetNorm_AllNegative(_base_NormMixin):
160+
def setup(self):
161+
self.normclass = mcolors.OffsetNorm
162+
self.norm = self.normclass(vmin=-10, vcenter=-8, vmax=-2)
163+
self.vals = np.array([-10., -9., -8., -6., -4., -2.])
164+
self.expected = np.array([0.0, 0.25, 0.5, 0.666667, 0.833333, 1.0])
165+
166+
167+
class test_OffsetNorm_AllPositive(_base_NormMixin):
168+
def setup(self):
169+
self.normclass = mcolors.OffsetNorm
170+
self.norm = self.normclass(vmin=0, vcenter=3, vmax=9)
171+
self.vals = np.array([0., 1.5, 3., 4.5, 6.0, 7.5, 9.])
172+
self.expected = np.array([0.0, 0.25, 0.5, 0.625, 0.75, 0.875, 1.0])
173+
174+
175+
class test_OffsetNorm_NoVs(_base_NormMixin):
176+
def setup(self):
177+
self.normclass = mcolors.OffsetNorm
178+
self.norm = self.normclass(vmin=None, vcenter=None, vmax=None)
179+
self.vals = np.array([-2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0])
180+
self.expected = np.array([0., 0.16666667, 0.33333333,
181+
0.5, 0.66666667, 0.83333333, 1.0])
182+
self.expected_vmin = -2
183+
self.expected_vcenter = 1
184+
self.expected_vmax = 4
185+
186+
def test_vmin(self):
187+
nt.assert_true(self.norm.vmin is None)
188+
self.norm(self.vals)
189+
nt.assert_equal(self.norm.vmin, self.expected_vmin)
190+
191+
def test_vcenter(self):
192+
nt.assert_true(self.norm.vcenter is None)
193+
self.norm(self.vals)
194+
nt.assert_equal(self.norm.vcenter, self.expected_vcenter)
195+
196+
def test_vmax(self):
197+
nt.assert_true(self.norm.vmax is None)
198+
self.norm(self.vals)
199+
nt.assert_equal(self.norm.vmax, self.expected_vmax)
200+
201+
202+
class test_OffsetNorm_VminEqualsVcenter(_base_NormMixin):
203+
def setup(self):
204+
self.normclass = mcolors.OffsetNorm
205+
self.norm = self.normclass(vmin=-2, vcenter=-2, vmax=2)
206+
self.vals = np.array([-2.0, -1.0, 0.0, 1.0, 2.0])
207+
self.expected = np.array([0.5, 0.625, 0.75, 0.875, 1.0])
208+
209+
210+
class test_OffsetNorm_VmaxEqualsVcenter(_base_NormMixin):
211+
def setup(self):
212+
self.normclass = mcolors.OffsetNorm
213+
self.norm = self.normclass(vmin=-2, vcenter=2, vmax=2)
214+
self.vals = np.array([-2.0, -1.0, 0.0, 1.0, 2.0])
215+
self.expected = np.array([0.0, 0.125, 0.25, 0.375, 0.5])
216+
217+
218+
class test_OffsetNorm_VsAllEqual(_base_NormMixin):
219+
def setup(self):
220+
self.v = 10
221+
self.normclass = mcolors.OffsetNorm
222+
self.norm = self.normclass(vmin=self.v, vcenter=self.v, vmax=self.v)
223+
self.vals = np.array([-2.0, -1.0, 0.0, 1.0, 2.0])
224+
self.expected = np.array([0.0, 0.0, 0.0, 0.0, 0.0])
225+
self.expected_inv = self.expected + self.v
226+
227+
def test_inverse(self):
228+
assert_array_almost_equal(
229+
self.norm.inverse(self.norm(self.vals)),
230+
self.expected_inv
231+
)
232+
233+
234+
class test_OffsetNorm_Errors(object):
235+
def setup(self):
236+
self.vals = np.arange(50)
237+
238+
@nt.raises(ValueError)
239+
def test_VminGTVcenter(self):
240+
norm = mcolors.OffsetNorm(vmin=10, vcenter=0, vmax=20)
241+
norm(self.vals)
242+
243+
@nt.raises(ValueError)
244+
def test_VminGTVmax(self):
245+
norm = mcolors.OffsetNorm(vmin=10, vcenter=0, vmax=5)
246+
norm(self.vals)
247+
248+
@nt.raises(ValueError)
249+
def test_VcenterGTVmax(self):
250+
norm = mcolors.OffsetNorm(vmin=10, vcenter=25, vmax=20)
251+
norm(self.vals)
252+
253+
@nt.raises(ValueError)
254+
def test_premature_scaling(self):
255+
norm = mcolors.OffsetNorm()
256+
norm.inverse(np.array([0.1, 0.5, 0.9]))
257+
258+
83259
def test_SymLogNorm():
84260
"""
85261
Test SymLogNorm behavior
@@ -198,7 +374,12 @@ def test_cmap_and_norm_from_levels_and_colors2():
198374
'Wih extend={0!r} and data '
199375
'value={1!r}'.format(extend, d_val))
200376

201-
assert_raises(ValueError, mcolors.from_levels_and_colors, levels, colors)
377+
nt.assert_raises(
378+
ValueError,
379+
mcolors.from_levels_and_colors,
380+
levels,
381+
colors
382+
)
202383

203384

204385
def test_rgb_hsv_round_trip():
@@ -228,8 +409,8 @@ def gray_from_float_rgb():
228409
def gray_from_float_rgba():
229410
return mcolors.colorConverter.to_rgba(0.4)
230411

231-
assert_raises(ValueError, gray_from_float_rgb)
232-
assert_raises(ValueError, gray_from_float_rgba)
412+
nt.assert_raises(ValueError, gray_from_float_rgb)
413+
nt.assert_raises(ValueError, gray_from_float_rgba)
233414

234415

235416
@image_comparison(baseline_images=['light_source_shading_topo'],

0 commit comments

Comments
 (0)