Skip to content

Clarify color priorities in collections #18480

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
Feb 17, 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
14 changes: 14 additions & 0 deletions doc/users/next_whats_new/collection_color_handling.rst
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion lib/matplotlib/axes/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion lib/matplotlib/cm.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ def set_array(self, A):
Parameters
----------
A : ndarray
A : ndarray or None
"""
self._A = A
self._update_dict['array'] = True
Expand Down
236 changes: 145 additions & 91 deletions lib/matplotlib/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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))

Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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)

Expand All @@ -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])
Expand All @@ -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"):
Copy link
Member

Choose a reason for hiding this comment

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

Do we want to extend cbook._str_lower_equal to support multiple values

cbook._str_lower_equal(c, ("none", "face"))?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not motivated to do so. I'm not a fan of cbook._str_lower_equal as a replacement for a simple one-liner.

c = c.lower()
self._original_edgecolor = c
self._set_edgecolor(c)

Expand Down Expand Up @@ -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)'.
Copy link
Member

Choose a reason for hiding this comment

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

Do we specify to allow set_array(None)? What should the effect be? AFAICS this is not documented anywhere.

Copy link
Member Author

Choose a reason for hiding this comment

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

Self._A starts out as None, in which case there is no color mapping; it is optional for a ScalarMappable. Mapping is always initiated via a call to set_array, as far as I know. By allowing the None argument, we can turn mapping on and off again. Increasing this reversibility, the ability to use None to get back to a default state, is part of what I am trying to do in this PR.

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)
Copy link
Member

Choose a reason for hiding this comment

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

Can it happen that we don't update the array but need to update the facecolors or edgecolors? Otherwise mapped_colors could be a local variable.

Copy link
Member Author

Choose a reason for hiding this comment

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

Saving it means we don't have to recalculate it when switching from mapping face to mapping edges, for example.


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."""
Expand Down Expand Up @@ -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
):
"""
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
Loading