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

Skip to content

Commit 5707b30

Browse files
authored
Merge pull request #19553 from greglucas/norm-updater
ENH: Adding callbacks to Norms for update signals
2 parents c667ea1 + 88ca5c2 commit 5707b30

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)