From 4c3d6449249a9e4790865f2a7e825be591581958 Mon Sep 17 00:00:00 2001 From: Eric Firing Date: Mon, 14 Sep 2020 08:21:52 -1000 Subject: [PATCH] Clarify color priorities in collections --- .../collection_color_handling.rst | 14 ++ lib/matplotlib/axes/_axes.py | 2 +- lib/matplotlib/cm.py | 2 +- lib/matplotlib/collections.py | 236 +++++++++++------- lib/matplotlib/patches.py | 3 - lib/matplotlib/tests/test_collections.py | 99 +++++++- lib/mpl_toolkits/mplot3d/art3d.py | 6 +- 7 files changed, 262 insertions(+), 100 deletions(-) create mode 100644 doc/users/next_whats_new/collection_color_handling.rst diff --git a/doc/users/next_whats_new/collection_color_handling.rst b/doc/users/next_whats_new/collection_color_handling.rst new file mode 100644 index 000000000000..d913962b7d52 --- /dev/null +++ b/doc/users/next_whats_new/collection_color_handling.rst @@ -0,0 +1,14 @@ +Collection color specification and mapping +------------------------------------------ + +Reworking the handling of color mapping and the keyword arguments for facecolor +and edgecolor has resulted in three behavior changes: + +1. Color mapping can be turned off by calling ``Collection.set_array(None)``. + Previously, this would have no effect. +2. When a mappable array is set, with ``facecolor='none'`` and + ``edgecolor='face'``, both the faces and the edges are left uncolored. + Previously the edges would be color-mapped. +3. When a mappable array is set, with ``facecolor='none'`` and + ``edgecolor='red'``, the edges are red. This addresses Issue #1302. + Previously the edges would be color-mapped. diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 1b0c881245af..3a170e9e44c5 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -6154,7 +6154,7 @@ def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None, if shading is None: shading = rcParams['pcolor.shading'] shading = shading.lower() - kwargs.setdefault('edgecolors', 'None') + kwargs.setdefault('edgecolors', 'none') X, Y, C, shading = self._pcolorargs('pcolormesh', *args, shading=shading, kwargs=kwargs) diff --git a/lib/matplotlib/cm.py b/lib/matplotlib/cm.py index 2f1443d0f09f..e7d6a7199eec 100644 --- a/lib/matplotlib/cm.py +++ b/lib/matplotlib/cm.py @@ -366,7 +366,7 @@ def set_array(self, A): Parameters ---------- - A : ndarray + A : ndarray or None """ self._A = A self._update_dict['array'] = True diff --git a/lib/matplotlib/collections.py b/lib/matplotlib/collections.py index 7ae6a867ba93..bb270b218e22 100644 --- a/lib/matplotlib/collections.py +++ b/lib/matplotlib/collections.py @@ -20,6 +20,8 @@ import warnings +# "color" is excluded; it is a compound setter, and its docstring differs +# in LineCollection. @cbook._define_aliases({ "antialiased": ["antialiaseds", "aa"], "edgecolor": ["edgecolors", "ec"], @@ -168,8 +170,10 @@ def __init__(self, # list of unbroadcast/scaled linewidths self._us_lw = [0] self._linewidths = [0] - self._is_filled = True # May be modified by set_facecolor(). - + # Flags set by _set_mappable_flags: are colors from mapping an array? + self._face_is_mapped = None + self._edge_is_mapped = None + self._mapped_colors = None # calculated in update_scalarmappable self._hatch_color = mcolors.to_rgba(mpl.rcParams['hatch.color']) self.set_facecolor(facecolors) self.set_edgecolor(edgecolors) @@ -586,6 +590,10 @@ def get_offset_position(self): """ return self._offset_position + def _get_default_linewidth(self): + # This may be overridden in a subclass. + return mpl.rcParams['patch.linewidth'] # validated as float + def set_linewidth(self, lw): """ Set the linewidth(s) for the collection. *lw* can be a scalar @@ -597,9 +605,7 @@ def set_linewidth(self, lw): lw : float or list of floats """ if lw is None: - lw = mpl.rcParams['patch.linewidth'] - if lw is None: - lw = mpl.rcParams['lines.linewidth'] + lw = self._get_default_linewidth() # get the un-scaled/broadcast lw self._us_lw = np.atleast_1d(np.asarray(lw)) @@ -730,10 +736,14 @@ def set_antialiased(self, aa): aa : bool or list of bools """ if aa is None: - aa = mpl.rcParams['patch.antialiased'] + aa = self._get_default_antialiased() self._antialiaseds = np.atleast_1d(np.asarray(aa, bool)) self.stale = True + def _get_default_antialiased(self): + # This may be overridden in a subclass. + return mpl.rcParams['patch.antialiased'] + def set_color(self, c): """ Set both the edgecolor and the facecolor. @@ -750,16 +760,14 @@ def set_color(self, c): self.set_facecolor(c) self.set_edgecolor(c) + def _get_default_facecolor(self): + # This may be overridden in a subclass. + return mpl.rcParams['patch.facecolor'] + def _set_facecolor(self, c): if c is None: - c = mpl.rcParams['patch.facecolor'] + c = self._get_default_facecolor() - self._is_filled = True - try: - if c.lower() == 'none': - self._is_filled = False - except AttributeError: - pass self._facecolors = mcolors.to_rgba_array(c, self._alpha) self.stale = True @@ -775,6 +783,8 @@ def set_facecolor(self, c): ---------- c : color or list of colors """ + if isinstance(c, str) and c.lower() in ("none", "face"): + c = c.lower() self._original_facecolor = c self._set_facecolor(c) @@ -787,29 +797,24 @@ def get_edgecolor(self): else: return self._edgecolors + def _get_default_edgecolor(self): + # This may be overridden in a subclass. + return mpl.rcParams['patch.edgecolor'] + def _set_edgecolor(self, c): set_hatch_color = True if c is None: - if (mpl.rcParams['patch.force_edgecolor'] or - not self._is_filled or self._edge_default): - c = mpl.rcParams['patch.edgecolor'] + if (mpl.rcParams['patch.force_edgecolor'] + or self._edge_default + or cbook._str_equal(self._original_facecolor, 'none')): + c = self._get_default_edgecolor() else: c = 'none' set_hatch_color = False - - self._is_stroked = True - try: - if c.lower() == 'none': - self._is_stroked = False - except AttributeError: - pass - - try: - if c.lower() == 'face': # Special case: lookup in "get" method. - self._edgecolors = 'face' - return - except AttributeError: - pass + if cbook._str_lower_equal(c, 'face'): + self._edgecolors = 'face' + self.stale = True + return self._edgecolors = mcolors.to_rgba_array(c, self._alpha) if set_hatch_color and len(self._edgecolors): self._hatch_color = tuple(self._edgecolors[0]) @@ -825,6 +830,11 @@ def set_edgecolor(self, c): The collection edgecolor(s). If a sequence, the patches cycle through it. If 'face', match the facecolor. """ + # We pass through a default value for use in LineCollection. + # This allows us to maintain None as the default indicator in + # _original_edgecolor. + if isinstance(c, str) and c.lower() in ("none", "face"): + c = c.lower() self._original_edgecolor = c self._set_edgecolor(c) @@ -853,36 +863,81 @@ def get_linewidth(self): def get_linestyle(self): return self._linestyles + def _set_mappable_flags(self): + """ + Determine whether edges and/or faces are color-mapped. + + This is a helper for update_scalarmappable. + It sets Boolean flags '_edge_is_mapped' and '_face_is_mapped'. + + Returns + ------- + mapping_change : bool + True if either flag is True, or if a flag has changed. + """ + # The flags are initialized to None to ensure this returns True + # the first time it is called. + edge0 = self._edge_is_mapped + face0 = self._face_is_mapped + # After returning, the flags must be Booleans, not None. + self._edge_is_mapped = False + self._face_is_mapped = False + if self._A is not None: + if not cbook._str_equal(self._original_facecolor, 'none'): + self._face_is_mapped = True + if cbook._str_equal(self._original_edgecolor, 'face'): + self._edge_is_mapped = True + else: + if self._original_edgecolor is None: + self._edge_is_mapped = True + + mapped = self._face_is_mapped or self._edge_is_mapped + changed = (edge0 is None or face0 is None + or self._edge_is_mapped != edge0 + or self._face_is_mapped != face0) + return mapped or changed + def update_scalarmappable(self): - """Update colors from the scalar mappable array, if it is not None.""" - if self._A is None: - return - # QuadMesh can map 2d arrays (but pcolormesh supplies 1d array) - if self._A.ndim > 1 and not isinstance(self, QuadMesh): - raise ValueError('Collections can only map rank 1 arrays') - if not self._check_update("array"): + """ + Update colors from the scalar mappable array, if any. + + Assign colors to edges and faces based on the array and/or + colors that were directly set, as appropriate. + """ + if not self._set_mappable_flags(): return - if np.iterable(self._alpha): - if self._alpha.size != self._A.size: - raise ValueError(f'Data array shape, {self._A.shape} ' - 'is incompatible with alpha array shape, ' - f'{self._alpha.shape}. ' - 'This can occur with the deprecated ' - 'behavior of the "flat" shading option, ' - 'in which a row and/or column of the data ' - 'array is dropped.') - # pcolormesh, scatter, maybe others flatten their _A - self._alpha = self._alpha.reshape(self._A.shape) - - if self._is_filled: - self._facecolors = self.to_rgba(self._A, self._alpha) - elif self._is_stroked: - self._edgecolors = self.to_rgba(self._A, self._alpha) + # Allow possibility to call 'self.set_array(None)'. + if self._check_update("array") and self._A is not None: + # QuadMesh can map 2d arrays (but pcolormesh supplies 1d array) + if self._A.ndim > 1 and not isinstance(self, QuadMesh): + raise ValueError('Collections can only map rank 1 arrays') + if np.iterable(self._alpha): + if self._alpha.size != self._A.size: + raise ValueError( + f'Data array shape, {self._A.shape} ' + 'is incompatible with alpha array shape, ' + f'{self._alpha.shape}. ' + 'This can occur with the deprecated ' + 'behavior of the "flat" shading option, ' + 'in which a row and/or column of the data ' + 'array is dropped.') + # pcolormesh, scatter, maybe others flatten their _A + self._alpha = self._alpha.reshape(self._A.shape) + self._mapped_colors = self.to_rgba(self._A, self._alpha) + + if self._face_is_mapped: + self._facecolors = self._mapped_colors + else: + self._set_facecolor(self._original_facecolor) + if self._edge_is_mapped: + self._edgecolors = self._mapped_colors + else: + self._set_edgecolor(self._original_edgecolor) self.stale = True def get_fill(self): - """Return whether fill is set.""" - return self._is_filled + """Return whether face is colored.""" + return not cbook._str_lower_equal(self._original_facecolor, "none") def update_from(self, other): """Copy properties from other to self.""" @@ -1350,18 +1405,9 @@ class LineCollection(Collection): _edge_default = True - def __init__(self, segments, # Can be None. - linewidths=None, - colors=None, - antialiaseds=None, - linestyles='solid', - offsets=None, - transOffset=None, - norm=None, - cmap=None, - pickradius=5, - zorder=2, - facecolors='none', + def __init__(self, segments, # Can be None. + *args, # Deprecated. + zorder=2, # Collection.zorder is 1 **kwargs ): """ @@ -1394,29 +1440,25 @@ def __init__(self, segments, # Can be None. `~.path.Path.CLOSEPOLY`. **kwargs - Forwareded to `.Collection`. + Forwarded to `.Collection`. """ - if colors is None: - colors = mpl.rcParams['lines.color'] - if linewidths is None: - linewidths = (mpl.rcParams['lines.linewidth'],) - if antialiaseds is None: - antialiaseds = (mpl.rcParams['lines.antialiased'],) - - colors = mcolors.to_rgba_array(colors) + argnames = ["linewidths", "colors", "antialiaseds", "linestyles", + "offsets", "transOffset", "norm", "cmap", "pickradius", + "zorder", "facecolors"] + if args: + argkw = {name: val for name, val in zip(argnames, args)} + kwargs.update(argkw) + cbook.warn_deprecated( + "3.4", message="Since %(since)s, passing LineCollection " + "arguments other than the first, 'segments', as positional " + "arguments is deprecated, and they will become keyword-only " + "arguments %(removal)s." + ) + # Unfortunately, mplot3d needs this explicit setting of 'facecolors'. + kwargs.setdefault('facecolors', 'none') super().__init__( - edgecolors=colors, - facecolors=facecolors, - linewidths=linewidths, - linestyles=linestyles, - antialiaseds=antialiaseds, - offsets=offsets, - transOffset=transOffset, - norm=norm, - cmap=cmap, zorder=zorder, **kwargs) - self.set_segments(segments) def set_segments(self, segments): @@ -1468,19 +1510,32 @@ def _add_offsets(self, segs): segs[i] = segs[i] + offsets[io:io + 1] return segs + def _get_default_linewidth(self): + return mpl.rcParams['lines.linewidth'] + + def _get_default_antialiased(self): + return mpl.rcParams['lines.antialiased'] + + def _get_default_edgecolor(self): + return mpl.rcParams['lines.color'] + + def _get_default_facecolor(self): + return 'none' + def set_color(self, c): """ - Set the color(s) of the LineCollection. + Set the edgecolor(s) of the LineCollection. Parameters ---------- c : color or list of colors - Single color (all patches have same color), or a - sequence of rgba tuples; if it is a sequence the patches will + Single color (all lines have same color), or a + sequence of rgba tuples; if it is a sequence the lines will cycle through the sequence. """ self.set_edgecolor(c) - self.stale = True + + set_colors = set_color def get_color(self): return self._edgecolors @@ -1851,7 +1906,6 @@ def __init__(self, triangulation, **kwargs): super().__init__(**kwargs) self._triangulation = triangulation self._shading = 'gouraud' - self._is_filled = True self._bbox = transforms.Bbox.unit() diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 2121b100d566..a5bb2019db56 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -4225,9 +4225,6 @@ def get_path_in_displaycoord(self): self.get_linewidth() * dpi_cor, self.get_mutation_aspect()) - # if not fillable: - # self._fill = False - return _path, fillable def draw(self, renderer): diff --git a/lib/matplotlib/tests/test_collections.py b/lib/matplotlib/tests/test_collections.py index 9fe73e3b892a..9b3de1e3b4c8 100644 --- a/lib/matplotlib/tests/test_collections.py +++ b/lib/matplotlib/tests/test_collections.py @@ -8,10 +8,12 @@ import matplotlib as mpl import matplotlib.pyplot as plt import matplotlib.collections as mcollections +import matplotlib.colors as mcolors +import matplotlib.transforms as mtransforms from matplotlib.collections import (Collection, LineCollection, EventCollection, PolyCollection) -import matplotlib.transforms as mtransforms from matplotlib.testing.decorators import check_figures_equal, image_comparison +from matplotlib._api.deprecation import MatplotlibDeprecationWarning def generate_EventCollection_plot(): @@ -771,3 +773,98 @@ def test_legend_inverse_size_label_relationship(): handle_sizes = [5 / x**2 for x in handle_sizes] assert_array_almost_equal(handle_sizes, legend_sizes, decimal=1) + + +@pytest.mark.style('default') +@pytest.mark.parametrize('pcfunc', [plt.pcolor, plt.pcolormesh]) +def test_color_logic(pcfunc): + z = np.arange(12).reshape(3, 4) + # Explicitly set an edgecolor. + pc = pcfunc(z, edgecolors='red', facecolors='none') + pc.update_scalarmappable() # This is called in draw(). + # Define 2 reference "colors" here for multiple use. + face_default = mcolors.to_rgba_array(pc._get_default_facecolor()) + mapped = pc.get_cmap()(pc.norm((z.ravel()))) + # Github issue #1302: + assert mcolors.same_color(pc.get_edgecolor(), 'red') + # Check setting attributes after initialization: + pc = pcfunc(z) + pc.set_facecolor('none') + pc.set_edgecolor('red') + pc.update_scalarmappable() + assert mcolors.same_color(pc.get_facecolor(), 'none') + assert mcolors.same_color(pc.get_edgecolor(), [[1, 0, 0, 1]]) + pc.set_alpha(0.5) + pc.update_scalarmappable() + assert mcolors.same_color(pc.get_edgecolor(), [[1, 0, 0, 0.5]]) + pc.set_alpha(None) # restore default alpha + pc.update_scalarmappable() + assert mcolors.same_color(pc.get_edgecolor(), [[1, 0, 0, 1]]) + # Reset edgecolor to default. + pc.set_edgecolor(None) + pc.update_scalarmappable() + assert mcolors.same_color(pc.get_edgecolor(), mapped) + pc.set_facecolor(None) # restore default for facecolor + pc.update_scalarmappable() + assert mcolors.same_color(pc.get_facecolor(), mapped) + assert mcolors.same_color(pc.get_edgecolor(), 'none') + # Turn off colormapping entirely: + pc.set_array(None) + pc.update_scalarmappable() + assert mcolors.same_color(pc.get_edgecolor(), 'none') + assert mcolors.same_color(pc.get_facecolor(), face_default) # not mapped + # Turn it back on by restoring the array (must be 1D!): + pc.set_array(z.ravel()) + pc.update_scalarmappable() + assert mcolors.same_color(pc.get_facecolor(), mapped) + assert mcolors.same_color(pc.get_edgecolor(), 'none') + # Give color via tuple rather than string. + pc = pcfunc(z, edgecolors=(1, 0, 0), facecolors=(0, 1, 0)) + pc.update_scalarmappable() + assert mcolors.same_color(pc.get_facecolor(), mapped) + assert mcolors.same_color(pc.get_edgecolor(), [[1, 0, 0, 1]]) + # Provide an RGB array; mapping overrides it. + pc = pcfunc(z, edgecolors=(1, 0, 0), facecolors=np.ones((12, 3))) + pc.update_scalarmappable() + assert mcolors.same_color(pc.get_facecolor(), mapped) + assert mcolors.same_color(pc.get_edgecolor(), [[1, 0, 0, 1]]) + # Turn off the mapping. + pc.set_array(None) + pc.update_scalarmappable() + assert mcolors.same_color(pc.get_facecolor(), np.ones((12, 3))) + assert mcolors.same_color(pc.get_edgecolor(), [[1, 0, 0, 1]]) + # And an RGBA array. + pc = pcfunc(z, edgecolors=(1, 0, 0), facecolors=np.ones((12, 4))) + pc.update_scalarmappable() + assert mcolors.same_color(pc.get_facecolor(), mapped) + assert mcolors.same_color(pc.get_edgecolor(), [[1, 0, 0, 1]]) + # Turn off the mapping. + pc.set_array(None) + pc.update_scalarmappable() + assert mcolors.same_color(pc.get_facecolor(), np.ones((12, 4))) + assert mcolors.same_color(pc.get_edgecolor(), [[1, 0, 0, 1]]) + + +def test_LineCollection_args(): + with pytest.warns(MatplotlibDeprecationWarning): + lc = LineCollection(None, 2.2, 'r', zorder=3, facecolors=[0, 1, 0, 1]) + assert lc.get_linewidth()[0] == 2.2 + assert mcolors.same_color(lc.get_edgecolor(), 'r') + assert lc.get_zorder() == 3 + assert mcolors.same_color(lc.get_facecolor(), [[0, 1, 0, 1]]) + # To avoid breaking mplot3d, LineCollection internally sets the facecolor + # kwarg if it has not been specified. Hence we need the following test + # for LineCollection._set_default(). + lc = LineCollection(None, facecolor=None) + assert mcolors.same_color(lc.get_facecolor(), 'none') + + +def test_array_wrong_dimensions(): + z = np.arange(12).reshape(3, 4) + pc = plt.pcolor(z) + with pytest.raises(ValueError, match="^Collections can only map"): + pc.set_array(z) + pc.update_scalarmappable() + pc = plt.pcolormesh(z) + pc.set_array(z) # 2D is OK for Quadmesh + pc.update_scalarmappable() diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 5f5bc58e59e7..822ff79fcc24 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -640,7 +640,7 @@ def _update_scalarmappable(sm): With ScalarMappable objects if the data, colormap, or norm are changed, we need to update the computed colors. This is handled by the base class method update_scalarmappable. This method works - by, detecting if work needs to be done, and if so stashing it on + by detecting if work needs to be done, and if so stashing it on the ``self._facecolors`` attribute. With 3D collections we internally sort the components so that @@ -698,9 +698,9 @@ def _update_scalarmappable(sm): copy_state = sm._update_dict['array'] ret = sm.update_scalarmappable() if copy_state: - if sm._is_filled: + if sm._face_is_mapped: sm._facecolor3d = sm._facecolors - elif sm._is_stroked: + elif sm._edge_is_mapped: # Should this be plain "if"? sm._edgecolor3d = sm._edgecolors