Skip to content

Commit 9c82e3d

Browse files
committed
ENH: Adding callbacks to Norms for update signals
This adds a callback registry to Norm instances that can be connected to by other objects to be notified when the Norm is updated. This is particularly relevant for ScalarMappables to be notified when the vmin/vmax are changed on the Norm. Quadcontourset overrides ScalarMappable's `changed()` function, which meant that autoscaling would get called with the wrong data too early. Therefore, we force an autoscale with the proper data earlier in the `Quadcontourset.changed()` function. The Quadcontourset.changed() method assumes some attributes to be there. If we called changed from a parent class, the object may not have been initialized with those attributes yet, so skip that portion of the update. Remove the ScalarMappable callback from axes_grid as the state isn't fully cleared when updating the axes.
1 parent 08f4629 commit 9c82e3d

File tree

9 files changed

+186
-25
lines changed

9 files changed

+186
-25
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
A callback registry has been added to Normalize objects
2+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3+
4+
`.colors.Normalize` objects now have a callback registry, ``callbacksNorm``,
5+
that can be connected to by other objects to be notified when the norm is
6+
updated. `.cm.ScalarMappable` is now a listener and will register a change
7+
when the norm's vmin, vmax or other attributes are changed.

lib/matplotlib/cm.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ def __init__(self, norm=None, cmap=None):
257257
The colormap used to map normalized data values to RGBA colors.
258258
"""
259259
self._A = None
260-
self.norm = None # So that the setter knows we're initializing.
260+
self._norm = None # So that the setter knows we're initializing.
261261
self.set_norm(norm) # The Normalize instance of this ScalarMappable.
262262
self.cmap = None # So that the setter knows we're initializing.
263263
self.set_cmap(cmap) # The Colormap instance of this ScalarMappable.
@@ -415,6 +415,8 @@ def set_clim(self, vmin=None, vmax=None):
415415
416416
.. ACCEPTS: (vmin: float, vmax: float)
417417
"""
418+
# If the norm's limits are updated self.changed() will be called
419+
# through the callbacks attached to the norm
418420
if vmax is None:
419421
try:
420422
vmin, vmax = vmin
@@ -424,7 +426,6 @@ def set_clim(self, vmin=None, vmax=None):
424426
self.norm.vmin = colors._sanitize_extrema(vmin)
425427
if vmax is not None:
426428
self.norm.vmax = colors._sanitize_extrema(vmax)
427-
self.changed()
428429

429430
def get_alpha(self):
430431
"""
@@ -450,6 +451,30 @@ def set_cmap(self, cmap):
450451
if not in_init:
451452
self.changed() # Things are not set up properly yet.
452453

454+
@property
455+
def norm(self):
456+
return self._norm
457+
458+
@norm.setter
459+
def norm(self, norm):
460+
_api.check_isinstance((colors.Normalize, None), norm=norm)
461+
if norm is None:
462+
norm = colors.Normalize()
463+
464+
if norm is self.norm:
465+
# We aren't updating anything
466+
return
467+
468+
in_init = self.norm is None
469+
# Remove the current callback and connect to the new one
470+
if not in_init:
471+
self.norm.callbacksNorm.disconnect(self._id_norm)
472+
self._norm = norm
473+
self._id_norm = self.norm.callbacksNorm.connect('changed',
474+
self.changed)
475+
if not in_init:
476+
self.changed()
477+
453478
def set_norm(self, norm):
454479
"""
455480
Set the normalization instance.
@@ -464,13 +489,7 @@ def set_norm(self, norm):
464489
the norm of the mappable will reset the norm, locator, and formatters
465490
on the colorbar to default.
466491
"""
467-
_api.check_isinstance((colors.Normalize, None), norm=norm)
468-
in_init = self.norm is None
469-
if norm is None:
470-
norm = colors.Normalize()
471492
self.norm = norm
472-
if not in_init:
473-
self.changed() # Things are not set up properly yet.
474493

475494
def autoscale(self):
476495
"""
@@ -479,8 +498,9 @@ def autoscale(self):
479498
"""
480499
if self._A is None:
481500
raise TypeError('You must first set_array for mappable')
501+
# If the norm's limits are updated self.changed() will be called
502+
# through the callbacks attached to the norm
482503
self.norm.autoscale(self._A)
483-
self.changed()
484504

485505
def autoscale_None(self):
486506
"""
@@ -489,8 +509,9 @@ def autoscale_None(self):
489509
"""
490510
if self._A is None:
491511
raise TypeError('You must first set_array for mappable')
512+
# If the norm's limits are updated self.changed() will be called
513+
# through the callbacks attached to the norm
492514
self.norm.autoscale_None(self._A)
493-
self.changed()
494515

495516
def changed(self):
496517
"""

lib/matplotlib/colorbar.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -961,7 +961,7 @@ def _mesh(self):
961961
# vmax of the colorbar, not the norm. This allows the situation
962962
# where the colormap has a narrower range than the colorbar, to
963963
# accommodate extra contours:
964-
norm = copy.copy(self.norm)
964+
norm = copy.deepcopy(self.norm)
965965
norm.vmin = self.vmin
966966
norm.vmax = self.vmax
967967
x = np.array([0.0, 1.0])

lib/matplotlib/colors.py

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,10 +1149,50 @@ def __init__(self, vmin=None, vmax=None, clip=False):
11491149
-----
11501150
Returns 0 if ``vmin == vmax``.
11511151
"""
1152-
self.vmin = _sanitize_extrema(vmin)
1153-
self.vmax = _sanitize_extrema(vmax)
1154-
self.clip = clip
1152+
self._vmin = _sanitize_extrema(vmin)
1153+
self._vmax = _sanitize_extrema(vmax)
1154+
self._clip = clip
11551155
self._scale = scale.LinearScale(axis=None)
1156+
self.callbacksNorm = cbook.CallbackRegistry()
1157+
1158+
@property
1159+
def vmin(self):
1160+
return self._vmin
1161+
1162+
@vmin.setter
1163+
def vmin(self, value):
1164+
value = _sanitize_extrema(value)
1165+
if value != self._vmin:
1166+
self._vmin = value
1167+
self._changed()
1168+
1169+
@property
1170+
def vmax(self):
1171+
return self._vmax
1172+
1173+
@vmax.setter
1174+
def vmax(self, value):
1175+
value = _sanitize_extrema(value)
1176+
if value != self._vmax:
1177+
self._vmax = value
1178+
self._changed()
1179+
1180+
@property
1181+
def clip(self):
1182+
return self._clip
1183+
1184+
@clip.setter
1185+
def clip(self, value):
1186+
if value != self._clip:
1187+
self._clip = value
1188+
self._changed()
1189+
1190+
def _changed(self):
1191+
"""
1192+
Call this whenever the norm is changed to notify all the
1193+
callback listeners to the 'changed' signal.
1194+
"""
1195+
self.callbacksNorm.process('changed')
11561196

11571197
@staticmethod
11581198
def process_value(value):
@@ -1298,16 +1338,25 @@ def __init__(self, vcenter, vmin=None, vmax=None):
12981338
array([0., 0.25, 0.5, 0.625, 0.75, 0.875, 1.0])
12991339
"""
13001340

1301-
self.vcenter = vcenter
1302-
self.vmin = vmin
1303-
self.vmax = vmax
1341+
super().__init__(vmin=vmin, vmax=vmax)
1342+
self._vcenter = vcenter
13041343
if vcenter is not None and vmax is not None and vcenter >= vmax:
13051344
raise ValueError('vmin, vcenter, and vmax must be in '
13061345
'ascending order')
13071346
if vcenter is not None and vmin is not None and vcenter <= vmin:
13081347
raise ValueError('vmin, vcenter, and vmax must be in '
13091348
'ascending order')
13101349

1350+
@property
1351+
def vcenter(self):
1352+
return self._vcenter
1353+
1354+
@vcenter.setter
1355+
def vcenter(self, value):
1356+
if value != self._vcenter:
1357+
self._vcenter = value
1358+
self._changed()
1359+
13111360
def autoscale_None(self, A):
13121361
"""
13131362
Get vmin and vmax, and then clip at vcenter
@@ -1369,6 +1418,7 @@ def __init__(self, vcenter=0, halfrange=None, clip=False):
13691418
>>> norm(data)
13701419
array([0.25, 0.5 , 1. ])
13711420
"""
1421+
super().__init__()
13721422
self._vcenter = vcenter
13731423
self.vmin = None
13741424
self.vmax = None
@@ -1404,7 +1454,9 @@ def vcenter(self):
14041454

14051455
@vcenter.setter
14061456
def vcenter(self, vcenter):
1407-
self._vcenter = vcenter
1457+
if vcenter != self._vcenter:
1458+
self._vcenter = vcenter
1459+
self._changed()
14081460
if self.vmax is not None:
14091461
# recompute halfrange assuming vmin and vmax represent
14101462
# min and max of data
@@ -1712,9 +1764,7 @@ def __init__(self, boundaries, ncolors, clip=False, *, extend='neither'):
17121764
"""
17131765
if clip and extend != 'neither':
17141766
raise ValueError("'clip=True' is not compatible with 'extend'")
1715-
self.clip = clip
1716-
self.vmin = boundaries[0]
1717-
self.vmax = boundaries[-1]
1767+
super().__init__(vmin=boundaries[0], vmax=boundaries[-1], clip=clip)
17181768
self.boundaries = np.asarray(boundaries)
17191769
self.N = len(self.boundaries)
17201770
if self.N < 2:

lib/matplotlib/contour.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,6 +1092,15 @@ def _make_paths(self, segs, kinds):
10921092
in zip(segs, kinds)]
10931093

10941094
def changed(self):
1095+
if not hasattr(self, "cvalues"):
1096+
# Just return after calling the super() changed function
1097+
cm.ScalarMappable.changed(self)
1098+
return
1099+
# Force an autoscale immediately because self.to_rgba() calls
1100+
# autoscale_None() internally with the data passed to it,
1101+
# so if vmin/vmax are not set yet, this would override them with
1102+
# content from *cvalues* rather than levels like we want
1103+
self.norm.autoscale_None(self.levels)
10951104
tcolors = [(tuple(rgba),)
10961105
for rgba in self.to_rgba(self.cvalues, alpha=self.alpha)]
10971106
self.tcolors = tcolors

lib/matplotlib/tests/test_colors.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import matplotlib.cbook as cbook
1818
import matplotlib.pyplot as plt
1919
import matplotlib.scale as mscale
20-
from matplotlib.testing.decorators import image_comparison
20+
from matplotlib.testing.decorators import image_comparison, check_figures_equal
2121

2222

2323
@pytest.mark.parametrize('N, result', [
@@ -1409,3 +1409,73 @@ def test_norm_deepcopy():
14091409
assert isinstance(norm2._scale, mscale.LinearScale)
14101410
assert norm2.vmin == norm.vmin
14111411
assert norm2._scale is not norm._scale
1412+
1413+
1414+
def test_norm_callback():
1415+
norm = mcolors.Normalize()
1416+
x = 0
1417+
1418+
def increment():
1419+
nonlocal x
1420+
x += 1
1421+
1422+
norm.callbacksNorm.connect('changed', increment)
1423+
# Haven't updated anything, so x should be 0
1424+
assert x == 0
1425+
1426+
# Now change vmin and vmax to test callbacks
1427+
norm.vmin = 1
1428+
assert x == 1
1429+
norm.vmax = 5
1430+
assert x == 2
1431+
# callback shouldn't be called if setting to the same value
1432+
norm.vmin = 1
1433+
assert x == 2
1434+
norm.vmax = 5
1435+
assert x == 2
1436+
1437+
1438+
def test_scalarmappable_norm_update():
1439+
norm = mcolors.Normalize()
1440+
sm = matplotlib.cm.ScalarMappable(norm=norm, cmap='plasma')
1441+
# sm doesn't have a stale attribute at first, set it to False
1442+
sm.stale = False
1443+
# The mappable should be stale after updating vmin/vmax
1444+
norm.vmin = 5
1445+
assert sm.stale
1446+
sm.stale = False
1447+
norm.vmax = 5
1448+
assert sm.stale
1449+
sm.stale = False
1450+
norm.clip = True
1451+
assert sm.stale
1452+
# change to the CenteredNorm and TwoSlopeNorm to test those
1453+
# Also make sure that updating the norm directly and with
1454+
# set_norm both update the Norm callback
1455+
norm = mcolors.CenteredNorm()
1456+
sm.norm = norm
1457+
sm.stale = False
1458+
norm.vcenter = 1
1459+
assert sm.stale
1460+
norm = mcolors.TwoSlopeNorm(vcenter=0, vmin=-1, vmax=1)
1461+
sm.set_norm(norm)
1462+
sm.stale = False
1463+
norm.vcenter = 1
1464+
assert sm.stale
1465+
1466+
1467+
@check_figures_equal()
1468+
def test_norm_update_figs(fig_test, fig_ref):
1469+
ax_ref = fig_ref.add_subplot()
1470+
ax_test = fig_test.add_subplot()
1471+
1472+
z = np.arange(100).reshape((10, 10))
1473+
ax_ref.imshow(z, norm=mcolors.Normalize(10, 90))
1474+
1475+
# Create the norm beforehand with different limits and then update
1476+
# after adding to the plot
1477+
norm = mcolors.Normalize(0, 1)
1478+
ax_test.imshow(z, norm=norm)
1479+
# Force initial draw to make sure it isn't already stale
1480+
fig_test.canvas.draw()
1481+
norm.vmin, norm.vmax = 10, 90

lib/matplotlib/tests/test_image.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,8 +1017,8 @@ def test_imshow_bool():
10171017
def test_full_invalid():
10181018
fig, ax = plt.subplots()
10191019
ax.imshow(np.full((10, 10), np.nan))
1020-
with pytest.warns(UserWarning):
1021-
fig.canvas.draw()
1020+
1021+
fig.canvas.draw()
10221022

10231023

10241024
@pytest.mark.parametrize("fmt,counted",

lib/mpl_toolkits/axes_grid1/axes_grid.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ def colorbar(self, mappable, *, ticks=None, **kwargs):
3232
kwargs['userax'] = False
3333
cb = mpl.colorbar.Colorbar(
3434
self, mappable, orientation=orientation, ticks=ticks, **kwargs)
35+
# If this gets called multiple times on the same axes, some state can
36+
# be left on the axes between calls, so for now we will just remove
37+
# the callback for all axes_grid colorbars
38+
mappable.callbacksSM.disconnect(mappable.colorbar_cid)
3539
self._config_axes()
3640
return cb
3741

lib/mpl_toolkits/tests/test_axes_grid.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def test_imagegrid_cbar_mode_edge():
4444
# "second" ones. To achieve this, clear out the axes first.
4545
for ax in grid:
4646
ax.cax.cla()
47-
cb = ax.cax.colorbar(ax.images[0])
47+
ax.cax.colorbar(ax.images[0])
4848

4949

5050
def test_imagegrid():

0 commit comments

Comments
 (0)