X Tutup
Skip to content
Open
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
2 changes: 1 addition & 1 deletion doc/api/colorizer_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
:members:
:undoc-members:
:show-inheritance:
:private-members: _ColorizerInterface, _ScalarMappable
:private-members: _ColorbarMappable, _ScalarMappable
2 changes: 2 additions & 0 deletions lib/matplotlib/colorbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,8 @@ def remove(self):

try:
ax = self.mappable.axes
if ax is None:
return
except AttributeError:
return
try:
Expand Down
170 changes: 91 additions & 79 deletions lib/matplotlib/colorizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ def clip(self, clip):
self.norm.clip = clip


class _ColorizerInterface:
class _ColorbarMappable:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why Colorbar? Isn't this rather a ColorMappable?

The objects uses a colorizer to map data to colors. It's just a corollary that objects that use colorisers can be associated to a colorbar.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

jumping off this comment, I think this object is what would be hooked into for a colormap legend (for example for discrete colormaps) type situation?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you please clarify, what is a "colormap legend"?

Without understanding, I'd say no 😛. As said in the comment on the docstring, this object is basically "Data+Colorizer". And colorizer is per-se continous. One can make it look discrete through the (IMHO quite quirky) BoundaryNorm approach. But I wouldn't think about discrete colormaps right now - this abstraction doesn't change however good or bad they are currently supported.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

what is a "colormap legend"?

A legend where the handler and label information is fed from a colormap. The most commonly requested use case is probably a scatter plot where folks wanna color the dots based on class. Also useful for heatmaps of classes (somewhat common in ml) and my motivating example was coded data on a heatmap.

stas.png

Copy link
Copy Markdown
Member

@story645 story645 Jan 29, 2026

Choose a reason for hiding this comment

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

I gave up on a categorical colormap a very long time ago b/c it kinda broke the cmap/norm abstractions (#6934), so one of my main reasons for being psyched about the colorizer pipeline is it doesn't have those divisions. A CategoricalColorizer that takes in a {category name : color} dict is almost definitely much cleaner to implement than my old hack of listed colormaps and boundary norm approach.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

something like this may get simpler in the future. Though it's still somewhat different in that it would target legends not colormaps. So I think this is largely orthogonal to the discussion here.

Coming back to the original topic on naming. I'm now thinking this should even be a ColorMapper (thinking further @story645 your topic could be a DiscreteColorMapper, which potentially could target legends and colormaps). @trygvrad what do you think on the naming. ColorbarMappable, ColorMappable, ColorMapper?

"""
Base class that contains the interface to `Colorizer` objects from
a `ColorizingArtist` or `.cm.ScalarMappable`.
Comment on lines 318 to 319
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
Base class that contains the interface to `Colorizer` objects from
a `ColorizingArtist` or `.cm.ScalarMappable`.
Base class for objects that maps data to colors via a `Colorizer`.
This is e.g. the base for `ColorizingArtist` or `.cm.ScalarMappable`.

I think the subsequent note needs allow an update. My understanding is that this class is basically "Data+Colorizer" and related functionality like changed.

Expand All @@ -322,9 +322,94 @@ class _ColorizerInterface:
attribute. Other functions that as shared between `.ColorizingArtist`
and `.cm.ScalarMappable` are not included.
"""

def __init__(self, colorizer, **kwargs):
"""
Base class for objects that can connect to a colorbar.

All classes that can act as a mappable for `.Figure.colorbar`
will subclass this class.
"""
super().__init__(**kwargs)
self._colorizer = colorizer
self.colorbar = None
self._id_colorizer = self._colorizer.callbacks.connect('changed', self.changed)
self.callbacks = cbook.CallbackRegistry(signals=["changed"])
self._axes = None
self._A = None

@property
def axes(self):
return self._axes

@axes.setter
def axes(self, axes):
self._axes = axes
Comment on lines +341 to +347
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We need to be a bit careful with scoping. We've intentionally left out any Artist notion. So this is rather an abstract concept or concern or mixin or trait (or whatever you want to call it). That it needs association with an Axes is not a-priory clear.

axes is not used anywhere in the class itself. If we want to nevertheless add this here, we need a good reason for that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a good point. Instead of having this here we can have a hasattr(mappable, 'axes') in colorbar().

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We could make a very nuanced sematnic distinction here: If you keep the original name _ColorbarMappable, we could make the operational definition as "some object that can be passed to a colorbar". That would allow to add whatever is needed, including an optional axes.

OTOH I think I still like the conceptual definition "_ColorMappable = Data + Colorizer" more, even if this means colorbar() has to jump an extra hoop with hasattr(mappable, 'axes') (or possibly isinstance(mappable, Artist) - without having checked I assume that colorbar will do some thing additional for Artists in an Axes, that is not needed/possible for an abstract mapping). I propose to go this direction.

From a quick check, the only usage of axes in Colorbar seems to be

try:
ax = self.mappable.axes
except AttributeError:
return

So the optional axes may already be cared for? Please re-check.

Side-note: I've found

if (isinstance(self.norm, (colors.BoundaryNorm, colors.NoNorm)) or
isinstance(self.mappable, contour.ContourSet)):
self.ax.set_navigate(False)

Would it make sense to encode something like can_navigate() into _ColorMappable so that we move the responsibility for this logic from the colorbar to the mappable? - Possibly still needs a better name.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Including can_navigate() makes perfect sense :)


@timhoffm @story645 there is a thought I want to run by you.
I believe all classes that subclass ColorizingArtist will set .axes, because this is a requirement for fig.colorbar(mappable) to behave as expected. However, there is no mention of an .axes attribute in ColorizingArtist or the classes it inherits from.
To me, it seems like .axes is an important attribute of ColorizingArtist, and it should therefore be converted to a property, such that its existence is included in the documentation for ColorizingArtist and mentioned in colorizer.py. What do you think about this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think every Artist has an axes attribute. Documentation may be currently lacking because it‘s not something a user has to care about and because documenting attributes is a bit more tricky as attributes are just names (not objects like methods) and thus cannot hold a docstring. But it‘s possible - see e.g. #29075.

So documentation alone is not enough justification for a property. It’s rather about API. It‘s worth creating a consistent picture on axes, but that can and should be done as a separate action.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think every Artist has an axes attribute.

This is likely unrelated, but do figure level artists just have axes set to none?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yes, but it's more complicated behind the scenes. Let's move the topic to #31118.


@property
def colorizer(self):
return self._colorizer

@colorizer.setter
def colorizer(self, cl):
_api.check_isinstance(Colorizer, colorizer=cl)
self._colorizer.callbacks.disconnect(self._id_colorizer)
self._colorizer = cl
self._id_colorizer = cl.callbacks.connect('changed', self.changed)

def changed(self):
"""
Call this whenever the mappable is changed to notify all the
callbackSM listeners to the 'changed' signal.
"""
self.callbacks.process('changed')
self.stale = True

def _scale_norm(self, norm, vmin, vmax):
self._colorizer._scale_norm(norm, vmin, vmax, self._A)

def set_array(self, A):
"""
Set the value array from array-like *A*.

Parameters
----------
A : array-like or None
The values that are mapped to colors.

The base class `.ScalarMappable` does not make any assumptions on
the dimensionality and shape of the value array *A*.
"""
if A is None:
self._A = None
return

A = _ensure_multivariate_data(A, self.norm.n_components)

A = cbook.safe_masked_invalid(A, copy=True)
if not np.can_cast(A.dtype, float, "same_kind"):
if A.dtype.fields is None:

raise TypeError(f"Image data of dtype {A.dtype} cannot be "
f"converted to float")
else:
for key in A.dtype.fields:
if not np.can_cast(A[key].dtype, float, "same_kind"):
raise TypeError(f"Image data of dtype {A.dtype} cannot be "
f"converted to a sequence of floats")
self._A = A
if not self.norm.scaled():
self._colorizer.autoscale_None(A)

def get_array(self):
"""
Return the array of values, that are mapped to colors.

The base class `.ScalarMappable` does not make any assumptions on
the dimensionality and shape of the array.
"""
return self._A

def to_rgba(self, x, alpha=None, bytes=False, norm=True):
"""
Return a normalized RGBA array corresponding to *x*.
Expand Down Expand Up @@ -514,7 +599,7 @@ def _sig_digits_from_norm(norm, data, n):
return g_sig_digits


class _ScalarMappable(_ColorizerInterface):
class _ScalarMappable(_ColorbarMappable):
"""
A mixin class to map one or multiple sets of scalar data to RGBA.

Expand All @@ -527,15 +612,8 @@ class _ScalarMappable(_ColorizerInterface):
# and ColorizingArtist classes.

# _ScalarMappable can be depreciated so that ColorizingArtist
# inherits directly from _ColorizerInterface.
# in this case, the following changes should occur:
# __init__() has its functionality moved to ColorizingArtist.
# set_array(), get_array(), _get_colorizer() and
# _check_exclusionary_keywords() are moved to ColorizingArtist.
# changed() can be removed so long as colorbar.Colorbar
# is changed to connect to the colorizer instead of the
# ScalarMappable/ColorizingArtist,
# otherwise changed() can be moved to ColorizingArtist.
# inherits directly from _ColorbarMappable.
# in this case, all functionality should be moved to ColorizingArtist.
def __init__(self, norm=None, cmap=None, *, colorizer=None, **kwargs):
"""
Parameters
Expand All @@ -550,63 +628,8 @@ def __init__(self, norm=None, cmap=None, *, colorizer=None, **kwargs):
cmap : str or `~matplotlib.colors.Colormap`
The colormap used to map normalized data values to RGBA colors.
"""
super().__init__(**kwargs)
self._A = None
self._colorizer = self._get_colorizer(colorizer=colorizer, norm=norm, cmap=cmap)

self.colorbar = None
self._id_colorizer = self._colorizer.callbacks.connect('changed', self.changed)
self.callbacks = cbook.CallbackRegistry(signals=["changed"])

def set_array(self, A):
"""
Set the value array from array-like *A*.

Parameters
----------
A : array-like or None
The values that are mapped to colors.

The base class `.ScalarMappable` does not make any assumptions on
the dimensionality and shape of the value array *A*.
"""
if A is None:
self._A = None
return

A = _ensure_multivariate_data(A, self.norm.n_components)

A = cbook.safe_masked_invalid(A, copy=True)
if not np.can_cast(A.dtype, float, "same_kind"):
if A.dtype.fields is None:

raise TypeError(f"Image data of dtype {A.dtype} cannot be "
f"converted to float")
else:
for key in A.dtype.fields:
if not np.can_cast(A[key].dtype, float, "same_kind"):
raise TypeError(f"Image data of dtype {A.dtype} cannot be "
f"converted to a sequence of floats")
self._A = A
if not self.norm.scaled():
self._colorizer.autoscale_None(A)

def get_array(self):
"""
Return the array of values, that are mapped to colors.

The base class `.ScalarMappable` does not make any assumptions on
the dimensionality and shape of the array.
"""
return self._A

def changed(self):
"""
Call this whenever the mappable is changed to notify all the
callbackSM listeners to the 'changed' signal.
"""
self.callbacks.process('changed', self)
self.stale = True
colorizer = self._get_colorizer(colorizer=colorizer, norm=norm, cmap=cmap)
super().__init__(colorizer, **kwargs)

@staticmethod
def _check_exclusionary_keywords(colorizer, **kwargs):
Expand Down Expand Up @@ -710,17 +733,6 @@ def __init__(self, colorizer, **kwargs):
_api.check_isinstance(Colorizer, colorizer=colorizer)
super().__init__(colorizer=colorizer, **kwargs)

@property
def colorizer(self):
return self._colorizer

@colorizer.setter
def colorizer(self, cl):
_api.check_isinstance(Colorizer, colorizer=cl)
self._colorizer.callbacks.disconnect(self._id_colorizer)
self._colorizer = cl
self._id_colorizer = cl.callbacks.connect('changed', self.changed)

def _set_colorizer_check_keywords(self, colorizer, **kwargs):
"""
Raises a ValueError if any kwarg is not None while colorizer is not None.
Expand Down
28 changes: 21 additions & 7 deletions lib/matplotlib/colorizer.pyi
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from matplotlib import cbook, colorbar, colors, artist
from matplotlib import cbook, colorbar, colors, artist, axes as maxes

import numpy as np
from numpy.typing import ArrayLike
Expand Down Expand Up @@ -46,10 +46,27 @@ class Colorizer:
def clip(self, value: bool) -> None: ...


class _ColorizerInterface:
cmap: colors.Colormap

class _ColorbarMappable:
colorbar: colorbar.Colorbar | None
callbacks: cbook.CallbackRegistry
cmap: colors.Colormap
def __init__(
self,
colorizer: Colorizer | None,
**kwargs
) -> None: ...
@property
def colorizer(self) -> Colorizer: ...
@colorizer.setter
def colorizer(self, cl: Colorizer) -> None: ...
def changed(self) -> None: ...
def set_array(self, A: ArrayLike | None) -> None: ...
def get_array(self) -> np.ndarray | None: ...
@property
def axes(self) -> maxes._base._AxesBase | None: ...
@axes.setter
def axes(self, new_axes: maxes._base._AxesBase | None) -> None: ...
def to_rgba(
self,
x: np.ndarray,
Expand All @@ -71,7 +88,7 @@ class _ColorizerInterface:
def autoscale_None(self) -> None: ...


class _ScalarMappable(_ColorizerInterface):
class _ScalarMappable(_ColorbarMappable):
def __init__(
self,
norm: colors.Norm | None = ...,
Expand All @@ -80,9 +97,6 @@ class _ScalarMappable(_ColorizerInterface):
colorizer: Colorizer | None = ...,
**kwargs
) -> None: ...
def set_array(self, A: ArrayLike | None) -> None: ...
def get_array(self) -> np.ndarray | None: ...
def changed(self) -> None: ...


class ColorizingArtist(_ScalarMappable, artist.Artist):
Expand Down
4 changes: 2 additions & 2 deletions lib/matplotlib/pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
from matplotlib.axes import Subplot # noqa: F401
from matplotlib.backends import BackendFilter, backend_registry
from matplotlib.projections import PolarAxes
from matplotlib.colorizer import _ColorizerInterface, ColorizingArtist, Colorizer
from matplotlib.colorizer import _ColorbarMappable, ColorizingArtist, Colorizer
from matplotlib import mlab # for detrend_none, window_hanning
from matplotlib.scale import get_scale_names # noqa: F401

Expand Down Expand Up @@ -4201,7 +4201,7 @@ def spy(
origin=origin,
**kwargs,
)
if isinstance(__ret, _ColorizerInterface):
if isinstance(__ret, _ColorbarMappable):
sci(__ret)
return __ret

Expand Down
22 changes: 22 additions & 0 deletions lib/matplotlib/tests/test_colorbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
BoundaryNorm, LogNorm, PowerNorm, Normalize, NoNorm
)
from matplotlib.colorbar import Colorbar
from matplotlib.colorizer import Colorizer, _ColorbarMappable
from matplotlib.ticker import FixedLocator, LogFormatter, StrMethodFormatter
from matplotlib.testing.decorators import check_figures_equal

Expand Down Expand Up @@ -1242,3 +1243,24 @@ def test_colorbar_format_string_and_old():
plt.imshow([[0, 1]])
cb = plt.colorbar(format="{x}%")
assert isinstance(cb._formatter, StrMethodFormatter)


def test_ColorbarMappable_as_input():
# check that _ColorbarMappable can function as a
# valid input for colorbar
fig, ax = plt.subplots()
norm = Normalize(vmin=-5, vmax=10)
cl = Colorizer(norm=norm)
cbm = _ColorbarMappable(cl)
# test without an axes on the mappable, no kwarg
with pytest.raises(ValueError, match='Unable to determine Axes to steal'):
cb = fig.colorbar(cbm)
# test without an axes on the mappable, with kwarg
cb = fig.colorbar(cbm, ax=ax)
cb = fig.colorbar(cbm, cax=ax)
# test without an axes on the mappable
cbm.axes = ax
cb = fig.colorbar(cbm)
assert cb.mappable is cbm
assert cb.vmin == -5
assert cb.vmax == 10
2 changes: 1 addition & 1 deletion tools/boilerplate.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ def boilerplate_gen():
'hist2d': 'sci(__ret[-1])',
'imshow': 'sci(__ret)',
'spy': (
'if isinstance(__ret, _ColorizerInterface):\n'
'if isinstance(__ret, _ColorbarMappable):\n'
' sci(__ret)'
),
'quiver': 'sci(__ret)',
Expand Down
Loading
X Tutup