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

Skip to content

ENH: Adding callbacks to Norms for update signals #19553

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 1, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions doc/users/next_whats_new/callbacks_on_norms.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
A callback registry has been added to Normalize objects
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

`.colors.Normalize` objects now have a callback registry, ``callbacks``,
that can be connected to by other objects to be notified when the norm is
updated. The callback emits the key ``changed`` when the norm is modified.
`.cm.ScalarMappable` is now a listener and will register a change
when the norm's vmin, vmax or other attributes are changed.
41 changes: 31 additions & 10 deletions lib/matplotlib/cm.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ def __init__(self, norm=None, cmap=None):
The colormap used to map normalized data values to RGBA colors.
"""
self._A = None
self.norm = None # So that the setter knows we're initializing.
self._norm = None # So that the setter knows we're initializing.
self.set_norm(norm) # The Normalize instance of this ScalarMappable.
self.cmap = None # So that the setter knows we're initializing.
self.set_cmap(cmap) # The Colormap instance of this ScalarMappable.
Expand Down Expand Up @@ -496,6 +496,8 @@ def set_clim(self, vmin=None, vmax=None):

.. ACCEPTS: (vmin: float, vmax: float)
"""
# If the norm's limits are updated self.changed() will be called
# through the callbacks attached to the norm
if vmax is None:
try:
vmin, vmax = vmin
Expand All @@ -505,7 +507,6 @@ def set_clim(self, vmin=None, vmax=None):
self.norm.vmin = colors._sanitize_extrema(vmin)
if vmax is not None:
self.norm.vmax = colors._sanitize_extrema(vmax)
self.changed()

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

@property
def norm(self):
return self._norm

@norm.setter
def norm(self, norm):
_api.check_isinstance((colors.Normalize, None), norm=norm)
if norm is None:
norm = colors.Normalize()

if norm is self.norm:
# We aren't updating anything
return

in_init = self.norm is None
# Remove the current callback and connect to the new one
if not in_init:
self.norm.callbacks.disconnect(self._id_norm)
self._norm = norm
self._id_norm = self.norm.callbacks.connect('changed',
self.changed)
if not in_init:
self.changed()

def set_norm(self, norm):
"""
Set the normalization instance.
Expand All @@ -545,13 +570,7 @@ def set_norm(self, norm):
the norm of the mappable will reset the norm, locator, and formatters
on the colorbar to default.
"""
_api.check_isinstance((colors.Normalize, None), norm=norm)
in_init = self.norm is None
if norm is None:
norm = colors.Normalize()
self.norm = norm
if not in_init:
self.changed() # Things are not set up properly yet.

def autoscale(self):
"""
Expand All @@ -560,8 +579,9 @@ def autoscale(self):
"""
if self._A is None:
raise TypeError('You must first set_array for mappable')
# If the norm's limits are updated self.changed() will be called
# through the callbacks attached to the norm
self.norm.autoscale(self._A)
self.changed()

def autoscale_None(self):
"""
Expand All @@ -570,8 +590,9 @@ def autoscale_None(self):
"""
if self._A is None:
raise TypeError('You must first set_array for mappable')
# If the norm's limits are updated self.changed() will be called
# through the callbacks attached to the norm
self.norm.autoscale_None(self._A)
self.changed()

def changed(self):
"""
Expand Down
3 changes: 2 additions & 1 deletion lib/matplotlib/colorbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,7 @@ def __init__(self, ax, mappable=None, *, cmap=None,
self.ax.add_collection(self.dividers)

self.locator = None
self.minorlocator = None
self.formatter = None
self.__scale = None # linear, log10 for now. Hopefully more?

Expand Down Expand Up @@ -1096,7 +1097,7 @@ def _mesh(self):
# vmax of the colorbar, not the norm. This allows the situation
# where the colormap has a narrower range than the colorbar, to
# accommodate extra contours:
norm = copy.copy(self.norm)
norm = copy.deepcopy(self.norm)
norm.vmin = self.vmin
norm.vmax = self.vmax
x = np.array([0.0, 1.0])
Expand Down
64 changes: 58 additions & 6 deletions lib/matplotlib/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -1123,10 +1123,50 @@ def __init__(self, vmin=None, vmax=None, clip=False):
-----
Returns 0 if ``vmin == vmax``.
"""
self.vmin = _sanitize_extrema(vmin)
self.vmax = _sanitize_extrema(vmax)
self.clip = clip
self._scale = None # will default to LinearScale for colorbar
self._vmin = _sanitize_extrema(vmin)
self._vmax = _sanitize_extrema(vmax)
self._clip = clip
self._scale = None
self.callbacks = cbook.CallbackRegistry()

@property
def vmin(self):
return self._vmin

@vmin.setter
def vmin(self, value):
value = _sanitize_extrema(value)
if value != self._vmin:
self._vmin = value
self._changed()

@property
def vmax(self):
return self._vmax

@vmax.setter
def vmax(self, value):
value = _sanitize_extrema(value)
if value != self._vmax:
self._vmax = value
self._changed()

@property
def clip(self):
return self._clip

@clip.setter
def clip(self, value):
if value != self._clip:
self._clip = value
self._changed()

def _changed(self):
"""
Call this whenever the norm is changed to notify all the
callback listeners to the 'changed' signal.
"""
self.callbacks.process('changed')

@staticmethod
def process_value(value):
Expand Down Expand Up @@ -1273,14 +1313,24 @@ def __init__(self, vcenter, vmin=None, vmax=None):
"""

super().__init__(vmin=vmin, vmax=vmax)
self.vcenter = vcenter
self._vcenter = vcenter
if vcenter is not None and vmax is not None and vcenter >= vmax:
raise ValueError('vmin, vcenter, and vmax must be in '
'ascending order')
if vcenter is not None and vmin is not None and vcenter <= vmin:
raise ValueError('vmin, vcenter, and vmax must be in '
'ascending order')

@property
def vcenter(self):
return self._vcenter

@vcenter.setter
def vcenter(self, value):
if value != self._vcenter:
self._vcenter = value
self._changed()

def autoscale_None(self, A):
"""
Get vmin and vmax, and then clip at vcenter
Expand Down Expand Up @@ -1387,7 +1437,9 @@ def vcenter(self):

@vcenter.setter
def vcenter(self, vcenter):
self._vcenter = vcenter
if vcenter != self._vcenter:
self._vcenter = vcenter
self._changed()
if self.vmax is not None:
# recompute halfrange assuming vmin and vmax represent
# min and max of data
Expand Down
9 changes: 9 additions & 0 deletions lib/matplotlib/contour.py
Original file line number Diff line number Diff line change
Expand Up @@ -1090,6 +1090,15 @@ def _make_paths(self, segs, kinds):
in zip(segs, kinds)]

def changed(self):
if not hasattr(self, "cvalues"):
# Just return after calling the super() changed function
cm.ScalarMappable.changed(self)
return
# Force an autoscale immediately because self.to_rgba() calls
# autoscale_None() internally with the data passed to it,
# so if vmin/vmax are not set yet, this would override them with
# content from *cvalues* rather than levels like we want
self.norm.autoscale_None(self.levels)
tcolors = [(tuple(rgba),)
for rgba in self.to_rgba(self.cvalues, alpha=self.alpha)]
self.tcolors = tcolors
Expand Down
13 changes: 8 additions & 5 deletions lib/matplotlib/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,11 +537,14 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0,
if isinstance(self.norm, mcolors.LogNorm) and s_vmin <= 0:
# Don't give 0 or negative values to LogNorm
s_vmin = np.finfo(scaled_dtype).eps
with cbook._setattr_cm(self.norm,
vmin=s_vmin,
vmax=s_vmax,
):
output = self.norm(resampled_masked)
# Block the norm from sending an update signal during the
# temporary vmin/vmax change
with self.norm.callbacks.blocked():
with cbook._setattr_cm(self.norm,
vmin=s_vmin,
vmax=s_vmax,
):
output = self.norm(resampled_masked)
else:
if A.ndim == 2: # _interpolation_stage == 'rgba'
self.norm.autoscale_None(A)
Expand Down
69 changes: 68 additions & 1 deletion lib/matplotlib/tests/test_colors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import copy
import itertools
import unittest.mock

from io import BytesIO
import numpy as np
Expand All @@ -17,7 +18,7 @@
import matplotlib.cbook as cbook
import matplotlib.pyplot as plt
import matplotlib.scale as mscale
from matplotlib.testing.decorators import image_comparison
from matplotlib.testing.decorators import image_comparison, check_figures_equal


@pytest.mark.parametrize('N, result', [
Expand Down Expand Up @@ -1408,3 +1409,69 @@ def test_norm_deepcopy():
norm2 = copy.deepcopy(norm)
assert norm2._scale is None
assert norm2.vmin == norm.vmin


def test_norm_callback():
increment = unittest.mock.Mock(return_value=None)

norm = mcolors.Normalize()
norm.callbacks.connect('changed', increment)
# Haven't updated anything, so call count should be 0
assert increment.call_count == 0

# Now change vmin and vmax to test callbacks
norm.vmin = 1
assert increment.call_count == 1
norm.vmax = 5
assert increment.call_count == 2
# callback shouldn't be called if setting to the same value
norm.vmin = 1
assert increment.call_count == 2
norm.vmax = 5
assert increment.call_count == 2


def test_scalarmappable_norm_update():
norm = mcolors.Normalize()
sm = matplotlib.cm.ScalarMappable(norm=norm, cmap='plasma')
# sm doesn't have a stale attribute at first, set it to False
sm.stale = False
# The mappable should be stale after updating vmin/vmax
norm.vmin = 5
assert sm.stale
sm.stale = False
norm.vmax = 5
assert sm.stale
sm.stale = False
norm.clip = True
assert sm.stale
# change to the CenteredNorm and TwoSlopeNorm to test those
# Also make sure that updating the norm directly and with
# set_norm both update the Norm callback
norm = mcolors.CenteredNorm()
sm.norm = norm
sm.stale = False
norm.vcenter = 1
assert sm.stale
norm = mcolors.TwoSlopeNorm(vcenter=0, vmin=-1, vmax=1)
sm.set_norm(norm)
sm.stale = False
norm.vcenter = 1
assert sm.stale


@check_figures_equal()
def test_norm_update_figs(fig_test, fig_ref):
ax_ref = fig_ref.add_subplot()
ax_test = fig_test.add_subplot()

z = np.arange(100).reshape((10, 10))
ax_ref.imshow(z, norm=mcolors.Normalize(10, 90))

# Create the norm beforehand with different limits and then update
# after adding to the plot
norm = mcolors.Normalize(0, 1)
ax_test.imshow(z, norm=norm)
# Force initial draw to make sure it isn't already stale
fig_test.canvas.draw()
norm.vmin, norm.vmax = 10, 90
4 changes: 2 additions & 2 deletions lib/matplotlib/tests/test_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -1017,8 +1017,8 @@ def test_imshow_bool():
def test_full_invalid():
fig, ax = plt.subplots()
ax.imshow(np.full((10, 10), np.nan))
with pytest.warns(UserWarning):
fig.canvas.draw()

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this not warning anymore?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous warning was:

matplotlib/lib/matplotlib/image.py:442: UserWarning: Warning: converting a masked element to nan.
  vmid = np.float64(self.norm.vmin) + dv / 2

If I print out the previous im.norm.vmin, im.norm.vmax, they were both np.ma.masked, which stems from passing in a fully masked array (np.max(np.ma.array([1, 2], mask=[True, True])) returns masked), so here we are calling np.float64(np.ma.masked). I don't think we want masked as vmin/vmax, so this perhaps even helps here... The vmin/vmax turn into (0, 0) now.

Note that the image doesn't change at all, just no warning about norm vmin/vmax anymore.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fun second-order behavior change!

Previously if you did

im = plt.imshow(np.full((10, 10), np.nan), vmin=np.ma.masked, vmax=np.ma.masked)
assert im.norm.vmin == 0
assert im.norm.vmax == 0

but if you did

im = plt.imshow(np.full((10, 10), np.nan)
assert im.norm.vmin is np.ma.masked
assert im.norm.vmax is np.ma.masked

(== is nan-like with np.ma.masked).

I would argue that this is a bug, in the sense that explicitly passing the np.max(data) and letting us auto-computer the max gave different results.

We also say that if vmin == vmax the result of __call__ will be 0.

This will lead to a subtle behavior change in the case where:

  • the user passed us all nan
  • let us infer the vmin/vmax
  • later updates the array with non-nan values

Previously the user would get all "bad" color, now they will get the "0" color. Given the other difference in behavior of explicitly passing vmin/vmax, the agreement with "if vmin/vmax match -> go to 0", this feeling like a very cornery corner case, and the results already being useless from a communications point of view, I think we can leave this as-is and do not need a further API change note.

fig.canvas.draw()


@pytest.mark.parametrize("fmt,counted",
Expand Down