Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit af3b419

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 495a985 commit af3b419

File tree

8 files changed

+189
-25
lines changed

8 files changed

+189
-25
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
A callback registry has been added to Normalize objects
2+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3+
4+
`.colors.Normalize` objects now have a callback registry, ``callbacks``,
5+
that can be connected to by other objects to be notified when the norm is
6+
updated. The callback emits the key ``changed`` when the norm is modified.
7+
`.cm.ScalarMappable` is now a listener and will register a change
8+
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.
@@ -416,6 +416,8 @@ def set_clim(self, vmin=None, vmax=None):
416416
417417
.. ACCEPTS: (vmin: float, vmax: float)
418418
"""
419+
# If the norm's limits are updated self.changed() will be called
420+
# through the callbacks attached to the norm
419421
if vmax is None:
420422
try:
421423
vmin, vmax = vmin
@@ -425,7 +427,6 @@ def set_clim(self, vmin=None, vmax=None):
425427
self.norm.vmin = colors._sanitize_extrema(vmin)
426428
if vmax is not None:
427429
self.norm.vmax = colors._sanitize_extrema(vmax)
428-
self.changed()
429430

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

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

476495
def autoscale(self):
477496
"""
@@ -480,8 +499,9 @@ def autoscale(self):
480499
"""
481500
if self._A is None:
482501
raise TypeError('You must first set_array for mappable')
502+
# If the norm's limits are updated self.changed() will be called
503+
# through the callbacks attached to the norm
483504
self.norm.autoscale(self._A)
484-
self.changed()
485505

486506
def autoscale_None(self):
487507
"""
@@ -490,8 +510,9 @@ def autoscale_None(self):
490510
"""
491511
if self._A is None:
492512
raise TypeError('You must first set_array for mappable')
513+
# If the norm's limits are updated self.changed() will be called
514+
# through the callbacks attached to the norm
493515
self.norm.autoscale_None(self._A)
494-
self.changed()
495516

496517
def changed(self):
497518
"""

lib/matplotlib/colorbar.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ def __init__(self, ax, mappable=None, *, cmap=None,
478478
self.ax.add_collection(self.dividers)
479479

480480
self.locator = None
481+
self.minorlocator = None
481482
self.formatter = None
482483
self.__scale = None # linear, log10 for now. Hopefully more?
483484

@@ -1103,7 +1104,7 @@ def _mesh(self):
11031104
# vmax of the colorbar, not the norm. This allows the situation
11041105
# where the colormap has a narrower range than the colorbar, to
11051106
# accommodate extra contours:
1106-
norm = copy.copy(self.norm)
1107+
norm = copy.deepcopy(self.norm)
11071108
norm.vmin = self.vmin
11081109
norm.vmax = self.vmax
11091110
x = np.array([0.0, 1.0])

lib/matplotlib/colors.py

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1123,10 +1123,50 @@ def __init__(self, vmin=None, vmax=None, clip=False):
11231123
-----
11241124
Returns 0 if ``vmin == vmax``.
11251125
"""
1126-
self.vmin = _sanitize_extrema(vmin)
1127-
self.vmax = _sanitize_extrema(vmax)
1128-
self.clip = clip
1129-
self._scale = None # will default to LinearScale for colorbar
1126+
self._vmin = _sanitize_extrema(vmin)
1127+
self._vmax = _sanitize_extrema(vmax)
1128+
self._clip = clip
1129+
self._scale = None
1130+
self.callbacks = cbook.CallbackRegistry()
1131+
1132+
@property
1133+
def vmin(self):
1134+
return self._vmin
1135+
1136+
@vmin.setter
1137+
def vmin(self, value):
1138+
value = _sanitize_extrema(value)
1139+
if value != self._vmin:
1140+
self._vmin = value
1141+
self._changed()
1142+
1143+
@property
1144+
def vmax(self):
1145+
return self._vmax
1146+
1147+
@vmax.setter
1148+
def vmax(self, value):
1149+
value = _sanitize_extrema(value)
1150+
if value != self._vmax:
1151+
self._vmax = value
1152+
self._changed()
1153+
1154+
@property
1155+
def clip(self):
1156+
return self._clip
1157+
1158+
@clip.setter
1159+
def clip(self, value):
1160+
if value != self._clip:
1161+
self._clip = value
1162+
self._changed()
1163+
1164+
def _changed(self):
1165+
"""
1166+
Call this whenever the norm is changed to notify all the
1167+
callback listeners to the 'changed' signal.
1168+
"""
1169+
self.callbacks.process('changed')
11301170

11311171
@staticmethod
11321172
def process_value(value):
@@ -1273,14 +1313,24 @@ def __init__(self, vcenter, vmin=None, vmax=None):
12731313
"""
12741314

12751315
super().__init__(vmin=vmin, vmax=vmax)
1276-
self.vcenter = vcenter
1316+
self._vcenter = vcenter
12771317
if vcenter is not None and vmax is not None and vcenter >= vmax:
12781318
raise ValueError('vmin, vcenter, and vmax must be in '
12791319
'ascending order')
12801320
if vcenter is not None and vmin is not None and vcenter <= vmin:
12811321
raise ValueError('vmin, vcenter, and vmax must be in '
12821322
'ascending order')
12831323

1324+
@property
1325+
def vcenter(self):
1326+
return self._vcenter
1327+
1328+
@vcenter.setter
1329+
def vcenter(self, value):
1330+
if value != self._vcenter:
1331+
self._vcenter = value
1332+
self._changed()
1333+
12841334
def autoscale_None(self, A):
12851335
"""
12861336
Get vmin and vmax, and then clip at vcenter
@@ -1387,7 +1437,9 @@ def vcenter(self):
13871437

13881438
@vcenter.setter
13891439
def vcenter(self, vcenter):
1390-
self._vcenter = vcenter
1440+
if vcenter != self._vcenter:
1441+
self._vcenter = vcenter
1442+
self._changed()
13911443
if self.vmax is not None:
13921444
# recompute halfrange assuming vmin and vmax represent
13931445
# min and max of data

lib/matplotlib/contour.py

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

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

lib/matplotlib/image.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -535,11 +535,14 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0,
535535
if isinstance(self.norm, mcolors.LogNorm) and s_vmin <= 0:
536536
# Don't give 0 or negative values to LogNorm
537537
s_vmin = np.finfo(scaled_dtype).eps
538-
with cbook._setattr_cm(self.norm,
539-
vmin=s_vmin,
540-
vmax=s_vmax,
541-
):
542-
output = self.norm(resampled_masked)
538+
# Block the norm from sending an update signal during the
539+
# temporary vmin/vmax change
540+
with self.norm.callbacks.blocked():
541+
with cbook._setattr_cm(self.norm,
542+
vmin=s_vmin,
543+
vmax=s_vmax,
544+
):
545+
output = self.norm(resampled_masked)
543546
else:
544547
if A.shape[2] == 3:
545548
A = _rgb_to_rgba(A)

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

0 commit comments

Comments
 (0)