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

Skip to content

Commit 88ca5c2

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 ca275dc commit 88ca5c2

File tree

8 files changed

+186
-25
lines changed

8 files changed

+186
-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
@@ -337,7 +337,7 @@ def __init__(self, norm=None, cmap=None):
337337
The colormap used to map normalized data values to RGBA colors.
338338
"""
339339
self._A = None
340-
self.norm = None # So that the setter knows we're initializing.
340+
self._norm = None # So that the setter knows we're initializing.
341341
self.set_norm(norm) # The Normalize instance of this ScalarMappable.
342342
self.cmap = None # So that the setter knows we're initializing.
343343
self.set_cmap(cmap) # The Colormap instance of this ScalarMappable.
@@ -496,6 +496,8 @@ def set_clim(self, vmin=None, vmax=None):
496496
497497
.. ACCEPTS: (vmin: float, vmax: float)
498498
"""
499+
# If the norm's limits are updated self.changed() will be called
500+
# through the callbacks attached to the norm
499501
if vmax is None:
500502
try:
501503
vmin, vmax = vmin
@@ -505,7 +507,6 @@ def set_clim(self, vmin=None, vmax=None):
505507
self.norm.vmin = colors._sanitize_extrema(vmin)
506508
if vmax is not None:
507509
self.norm.vmax = colors._sanitize_extrema(vmax)
508-
self.changed()
509510

510511
def get_alpha(self):
511512
"""
@@ -531,6 +532,30 @@ def set_cmap(self, cmap):
531532
if not in_init:
532533
self.changed() # Things are not set up properly yet.
533534

535+
@property
536+
def norm(self):
537+
return self._norm
538+
539+
@norm.setter
540+
def norm(self, norm):
541+
_api.check_isinstance((colors.Normalize, None), norm=norm)
542+
if norm is None:
543+
norm = colors.Normalize()
544+
545+
if norm is self.norm:
546+
# We aren't updating anything
547+
return
548+
549+
in_init = self.norm is None
550+
# Remove the current callback and connect to the new one
551+
if not in_init:
552+
self.norm.callbacks.disconnect(self._id_norm)
553+
self._norm = norm
554+
self._id_norm = self.norm.callbacks.connect('changed',
555+
self.changed)
556+
if not in_init:
557+
self.changed()
558+
534559
def set_norm(self, norm):
535560
"""
536561
Set the normalization instance.
@@ -545,13 +570,7 @@ def set_norm(self, norm):
545570
the norm of the mappable will reset the norm, locator, and formatters
546571
on the colorbar to default.
547572
"""
548-
_api.check_isinstance((colors.Normalize, None), norm=norm)
549-
in_init = self.norm is None
550-
if norm is None:
551-
norm = colors.Normalize()
552573
self.norm = norm
553-
if not in_init:
554-
self.changed() # Things are not set up properly yet.
555574

556575
def autoscale(self):
557576
"""
@@ -560,8 +579,9 @@ def autoscale(self):
560579
"""
561580
if self._A is None:
562581
raise TypeError('You must first set_array for mappable')
582+
# If the norm's limits are updated self.changed() will be called
583+
# through the callbacks attached to the norm
563584
self.norm.autoscale(self._A)
564-
self.changed()
565585

566586
def autoscale_None(self):
567587
"""
@@ -570,8 +590,9 @@ def autoscale_None(self):
570590
"""
571591
if self._A is None:
572592
raise TypeError('You must first set_array for mappable')
593+
# If the norm's limits are updated self.changed() will be called
594+
# through the callbacks attached to the norm
573595
self.norm.autoscale_None(self._A)
574-
self.changed()
575596

576597
def changed(self):
577598
"""

lib/matplotlib/colorbar.py

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

473473
self.locator = None
474+
self.minorlocator = None
474475
self.formatter = None
475476
self.__scale = None # linear, log10 for now. Hopefully more?
476477

@@ -1096,7 +1097,7 @@ def _mesh(self):
10961097
# vmax of the colorbar, not the norm. This allows the situation
10971098
# where the colormap has a narrower range than the colorbar, to
10981099
# accommodate extra contours:
1099-
norm = copy.copy(self.norm)
1100+
norm = copy.deepcopy(self.norm)
11001101
norm.vmin = self.vmin
11011102
norm.vmax = self.vmax
11021103
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
@@ -537,11 +537,14 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0,
537537
if isinstance(self.norm, mcolors.LogNorm) and s_vmin <= 0:
538538
# Don't give 0 or negative values to LogNorm
539539
s_vmin = np.finfo(scaled_dtype).eps
540-
with cbook._setattr_cm(self.norm,
541-
vmin=s_vmin,
542-
vmax=s_vmax,
543-
):
544-
output = self.norm(resampled_masked)
540+
# Block the norm from sending an update signal during the
541+
# temporary vmin/vmax change
542+
with self.norm.callbacks.blocked():
543+
with cbook._setattr_cm(self.norm,
544+
vmin=s_vmin,
545+
vmax=s_vmax,
546+
):
547+
output = self.norm(resampled_masked)
545548
else:
546549
if A.ndim == 2: # _interpolation_stage == 'rgba'
547550
self.norm.autoscale_None(A)

lib/matplotlib/tests/test_colors.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import copy
22
import itertools
3+
import unittest.mock
34

45
from io import BytesIO
56
import numpy as np
@@ -17,7 +18,7 @@
1718
import matplotlib.cbook as cbook
1819
import matplotlib.pyplot as plt
1920
import matplotlib.scale as mscale
20-
from matplotlib.testing.decorators import image_comparison
21+
from matplotlib.testing.decorators import image_comparison, check_figures_equal
2122

2223

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