Skip to content

Commit bcdb49d

Browse files
committed
Refactor color parsing of Axes.scatter
1 parent 677a3b2 commit bcdb49d

File tree

2 files changed

+205
-103
lines changed

2 files changed

+205
-103
lines changed

lib/matplotlib/axes/_axes.py

+147-103
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import itertools
33
import logging
44
import math
5+
import operator
56
from numbers import Number
67
import warnings
78

@@ -3760,6 +3761,146 @@ def dopatch(xs, ys, **kwargs):
37603761
return dict(whiskers=whiskers, caps=caps, boxes=boxes,
37613762
medians=medians, fliers=fliers, means=means)
37623763

3764+
def _parse_scatter_color_args(self, c, edgecolors, kwargs, xshape, yshape):
3765+
"""
3766+
Helper function to process color related arguments of `.Axes.scatter`.
3767+
3768+
Argument precedence for facecolors:
3769+
3770+
- c (if not None)
3771+
- kwargs['facecolors']
3772+
- kwargs['facecolor']
3773+
- kwargs['color'] (==kwcolor)
3774+
- 'b' if in classic mode else next color from color cycle
3775+
3776+
Argument precedence for edgecolors:
3777+
3778+
- edgecolors (is an explicit kw argument in scatter())
3779+
- kwargs['edgecolor']
3780+
- kwargs['color'] (==kwcolor)
3781+
- 'face' if not in classic mode else None
3782+
3783+
Arguments
3784+
---------
3785+
c : color or sequence or sequence of color or None
3786+
See argument description of `.Axes.scatter`.
3787+
edgecolors : color or sequence of color or {'face', 'none'} or None
3788+
See argument description of `.Axes.scatter`.
3789+
kwargs : dict
3790+
Additional kwargs. If these keys exist, we pop and process them:
3791+
'facecolors', 'facecolor', 'edgecolor', 'color'
3792+
Note: The dict is modified by this function.
3793+
xshape, yshape : tuple of int
3794+
The shape of the x and y arrays passed to `.Axes.scatter`.
3795+
3796+
Returns
3797+
-------
3798+
c
3799+
The input *c* if it was not *None*, else some color specification
3800+
derived from the other inputs or defaults.
3801+
colors : array(N, 4) or None
3802+
The facecolors as RGBA values or *None* if a colormap is used.
3803+
edgecolors
3804+
The edgecolor specification.
3805+
3806+
"""
3807+
xsize = functools.reduce(operator.mul, xshape)
3808+
ysize = functools.reduce(operator.mul, yshape)
3809+
3810+
facecolors = kwargs.pop('facecolors', None)
3811+
facecolors = kwargs.pop('facecolor', facecolors)
3812+
edgecolors = kwargs.pop('edgecolor', edgecolors)
3813+
3814+
kwcolor = kwargs.pop('color', None)
3815+
3816+
if kwcolor is not None and c is not None:
3817+
raise ValueError("Supply a 'c' argument or a 'color'"
3818+
" kwarg but not both; they differ but"
3819+
" their functionalities overlap.")
3820+
3821+
if kwcolor is not None:
3822+
try:
3823+
mcolors.to_rgba_array(kwcolor)
3824+
except ValueError:
3825+
raise ValueError("'color' kwarg must be an mpl color"
3826+
" spec or sequence of color specs.\n"
3827+
"For a sequence of values to be color-mapped,"
3828+
" use the 'c' argument instead.")
3829+
if edgecolors is None:
3830+
edgecolors = kwcolor
3831+
if facecolors is None:
3832+
facecolors = kwcolor
3833+
3834+
if edgecolors is None and not rcParams['_internal.classic_mode']:
3835+
edgecolors = 'face'
3836+
3837+
c_is_none = c is None
3838+
if c is None:
3839+
if facecolors is not None:
3840+
c = facecolors
3841+
else:
3842+
c = ('b' if rcParams['_internal.classic_mode'] else
3843+
self._get_patches_for_fill.get_next_color())
3844+
3845+
# After this block, c_array will be None unless
3846+
# c is an array for mapping. The potential ambiguity
3847+
# with a sequence of 3 or 4 numbers is resolved in
3848+
# favor of mapping, not rgb or rgba.
3849+
# Convenience vars to track shape mismatch *and* conversion failures.
3850+
valid_shape = True # will be put to the test!
3851+
n_elem = -1 # used only for (some) exceptions
3852+
if c_is_none or kwcolor is not None:
3853+
c_array = None
3854+
else:
3855+
try: # First, does 'c' look suitable for value-mapping?
3856+
c_array = np.asanyarray(c, dtype=float)
3857+
n_elem = c_array.shape[0]
3858+
if c_array.shape in [xshape, yshape]:
3859+
c = np.ma.ravel(c_array)
3860+
else:
3861+
if c_array.shape in ((3,), (4,)):
3862+
_log.warning(
3863+
"'c' argument looks like a single numeric RGB or "
3864+
"RGBA sequence, which should be avoided as value-"
3865+
"mapping will have precedence in case its length "
3866+
"matches with 'x' & 'y'. Please use a 2-D array "
3867+
"with a single row if you really want to specify "
3868+
"the same RGB or RGBA value for all points.")
3869+
# Wrong size; it must not be intended for mapping.
3870+
valid_shape = False
3871+
c_array = None
3872+
except ValueError:
3873+
# Failed to make a floating-point array; c must be color specs.
3874+
c_array = None
3875+
if c_array is None:
3876+
try: # Then is 'c' acceptable as PathCollection facecolors?
3877+
colors = mcolors.to_rgba_array(c)
3878+
n_elem = colors.shape[0]
3879+
if colors.shape[0] not in (0, 1, xsize, ysize):
3880+
# NB: remember that a single color is also acceptable.
3881+
# Besides *colors* will be an empty array if c == 'none'.
3882+
valid_shape = False
3883+
raise ValueError
3884+
except ValueError:
3885+
if not valid_shape: # but at least one conversion succeeded.
3886+
raise ValueError(
3887+
"'c' argument has {nc} elements, which is not "
3888+
"acceptable for use with 'x' with size {xs}, "
3889+
"'y' with size {ys}."
3890+
.format(nc=n_elem, xs=xsize, ys=ysize)
3891+
)
3892+
# Both the mapping *and* the RGBA conversion failed: pretty
3893+
# severe failure => one may appreciate a verbose feedback.
3894+
raise ValueError(
3895+
"'c' argument must either be valid as mpl color(s) "
3896+
"or as numbers to be mapped to colors. "
3897+
"Here c = {}." # <- beware, could be long depending on c.
3898+
.format(c)
3899+
)
3900+
else:
3901+
colors = None # use cmap, norm after collection is created
3902+
return c, colors, edgecolors
3903+
37633904
@_preprocess_data(replace_names=["x", "y", "s", "linewidths",
37643905
"edgecolors", "c", "facecolor",
37653906
"facecolors", "color"],
@@ -3865,124 +4006,27 @@ def scatter(self, x, y, s=None, c=None, marker=None, cmap=None, norm=None,
38654006
38664007
"""
38674008
# Process **kwargs to handle aliases, conflicts with explicit kwargs:
3868-
facecolors = None
3869-
edgecolors = kwargs.pop('edgecolor', edgecolors)
3870-
fc = kwargs.pop('facecolors', None)
3871-
fc = kwargs.pop('facecolor', fc)
3872-
if fc is not None:
3873-
facecolors = fc
3874-
co = kwargs.pop('color', None)
3875-
if co is not None:
3876-
try:
3877-
mcolors.to_rgba_array(co)
3878-
except ValueError:
3879-
raise ValueError("'color' kwarg must be an mpl color"
3880-
" spec or sequence of color specs.\n"
3881-
"For a sequence of values to be color-mapped,"
3882-
" use the 'c' argument instead.")
3883-
if edgecolors is None:
3884-
edgecolors = co
3885-
if facecolors is None:
3886-
facecolors = co
3887-
if c is not None:
3888-
raise ValueError("Supply a 'c' argument or a 'color'"
3889-
" kwarg but not both; they differ but"
3890-
" their functionalities overlap.")
3891-
if c is None:
3892-
if facecolors is not None:
3893-
c = facecolors
3894-
else:
3895-
if rcParams['_internal.classic_mode']:
3896-
c = 'b' # The original default
3897-
else:
3898-
c = self._get_patches_for_fill.get_next_color()
3899-
c_none = True
3900-
else:
3901-
c_none = False
3902-
3903-
if edgecolors is None and not rcParams['_internal.classic_mode']:
3904-
edgecolors = 'face'
39054009

39064010
self._process_unit_info(xdata=x, ydata=y, kwargs=kwargs)
39074011
x = self.convert_xunits(x)
39084012
y = self.convert_yunits(y)
39094013

39104014
# np.ma.ravel yields an ndarray, not a masked array,
39114015
# unless its argument is a masked array.
3912-
xy_shape = (np.shape(x), np.shape(y))
4016+
xshape, yshape = np.shape(x), np.shape(y)
39134017
x = np.ma.ravel(x)
39144018
y = np.ma.ravel(y)
39154019
if x.size != y.size:
39164020
raise ValueError("x and y must be the same size")
39174021

39184022
if s is None:
3919-
if rcParams['_internal.classic_mode']:
3920-
s = 20
3921-
else:
3922-
s = rcParams['lines.markersize'] ** 2.0
3923-
4023+
s = (20 if rcParams['_internal.classic_mode'] else
4024+
rcParams['lines.markersize'] ** 2.0)
39244025
s = np.ma.ravel(s) # This doesn't have to match x, y in size.
39254026

3926-
# After this block, c_array will be None unless
3927-
# c is an array for mapping. The potential ambiguity
3928-
# with a sequence of 3 or 4 numbers is resolved in
3929-
# favor of mapping, not rgb or rgba.
3930-
3931-
# Convenience vars to track shape mismatch *and* conversion failures.
3932-
valid_shape = True # will be put to the test!
3933-
n_elem = -1 # used only for (some) exceptions
3934-
3935-
if c_none or co is not None:
3936-
c_array = None
3937-
else:
3938-
try: # First, does 'c' look suitable for value-mapping?
3939-
c_array = np.asanyarray(c, dtype=float)
3940-
n_elem = c_array.shape[0]
3941-
if c_array.shape in xy_shape:
3942-
c = np.ma.ravel(c_array)
3943-
else:
3944-
if c_array.shape in ((3,), (4,)):
3945-
_log.warning(
3946-
"'c' argument looks like a single numeric RGB or "
3947-
"RGBA sequence, which should be avoided as value-"
3948-
"mapping will have precedence in case its length "
3949-
"matches with 'x' & 'y'. Please use a 2-D array "
3950-
"with a single row if you really want to specify "
3951-
"the same RGB or RGBA value for all points.")
3952-
# Wrong size; it must not be intended for mapping.
3953-
valid_shape = False
3954-
c_array = None
3955-
except ValueError:
3956-
# Failed to make a floating-point array; c must be color specs.
3957-
c_array = None
3958-
3959-
if c_array is None:
3960-
try: # Then is 'c' acceptable as PathCollection facecolors?
3961-
colors = mcolors.to_rgba_array(c)
3962-
n_elem = colors.shape[0]
3963-
if colors.shape[0] not in (0, 1, x.size, y.size):
3964-
# NB: remember that a single color is also acceptable.
3965-
# Besides *colors* will be an empty array if c == 'none'.
3966-
valid_shape = False
3967-
raise ValueError
3968-
except ValueError:
3969-
if not valid_shape: # but at least one conversion succeeded.
3970-
raise ValueError(
3971-
"'c' argument has {nc} elements, which is not "
3972-
"acceptable for use with 'x' with size {xs}, "
3973-
"'y' with size {ys}."
3974-
.format(nc=n_elem, xs=x.size, ys=y.size)
3975-
)
3976-
# Both the mapping *and* the RGBA conversion failed: pretty
3977-
# severe failure => one may appreciate a verbose feedback.
3978-
raise ValueError(
3979-
"'c' argument must either be valid as mpl color(s) "
3980-
"or as numbers to be mapped to colors. "
3981-
"Here c = {}." # <- beware, could be long depending on c.
3982-
.format(c)
3983-
)
3984-
else:
3985-
colors = None # use cmap, norm after collection is created
4027+
c, colors, edgecolors = \
4028+
self._parse_scatter_color_args(c, edgecolors, kwargs,
4029+
xshape, yshape)
39864030

39874031
# `delete_masked_points` only modifies arguments of the same length as
39884032
# `x`.

lib/matplotlib/tests/test_axes.py

+58
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections import namedtuple
12
from itertools import product
23
from distutils.version import LooseVersion
34
import io
@@ -1792,6 +1793,63 @@ def test_scatter_c(self, c_case, re_key):
17921793
ax.scatter(x, y, c=c_case, edgecolors="black")
17931794

17941795

1796+
def _params(c=None, xshape=(2,), yshape=(2,), **kwargs):
1797+
edgecolors = kwargs.pop('edgecolors', None)
1798+
return (c, edgecolors, kwargs if kwargs is not None else {},
1799+
xshape, yshape)
1800+
_result = namedtuple('_result', 'c, colors')
1801+
@pytest.mark.parametrize('params, expected_result',
1802+
[(_params(),
1803+
_result(c='b', colors=np.array([[0, 0, 1, 1]]))),
1804+
(_params(c='r'),
1805+
_result(c='r', colors=np.array([[1, 0, 0, 1]]))),
1806+
(_params(c='r', colors='b'),
1807+
_result(c='r', colors=np.array([[1, 0, 0, 1]]))),
1808+
# color
1809+
(_params(color='b'),
1810+
_result(c='b', colors=np.array([[0, 0, 1, 1]]))),
1811+
(_params(color=['b', 'g']),
1812+
_result(c=['b', 'g'], colors=np.array([[0, 0, 1, 1], [0, .5, 0, 1]]))),
1813+
])
1814+
def test_parse_scatter_color_args(params, expected_result):
1815+
from matplotlib.axes import Axes
1816+
dummyself = 'UNUSED' # self is only used in one case, which we do not
1817+
# test. Therefore we can get away without costly
1818+
# creating an Axes instance.
1819+
c, colors, _edgecolors = Axes._parse_scatter_color_args(dummyself, *params)
1820+
assert c == expected_result.c
1821+
assert_allclose(colors, expected_result.colors)
1822+
1823+
del _params
1824+
del _result
1825+
1826+
1827+
@pytest.mark.parametrize('kwargs, expected_edgecolors',
1828+
[(dict(), None),
1829+
(dict(c='b'), None),
1830+
(dict(edgecolors='r'), 'r'),
1831+
(dict(edgecolors=['r', 'g']), ['r', 'g']),
1832+
(dict(edgecolor='r'), 'r'),
1833+
(dict(edgecolors='face'), 'face'),
1834+
(dict(edgecolors='none'), 'none'),
1835+
(dict(edgecolor='r', edgecolors='g'), 'r'),
1836+
(dict(c='b', edgecolor='r', edgecolors='g'), 'r'),
1837+
(dict(color='r'), 'r'),
1838+
(dict(color='r', edgecolor='g'), 'g'),
1839+
])
1840+
def test_parse_scatter_color_args_edgecolors(kwargs, expected_edgecolors):
1841+
from matplotlib.axes import Axes
1842+
dummyself = 'UNUSED' # self is only used in one case, which we do not
1843+
# test. Therefore we can get away without costly
1844+
# creating an Axes instance.
1845+
c = kwargs.pop('c', None)
1846+
edgecolors = kwargs.pop('edgecolors', None)
1847+
_, _, result_edgecolors = \
1848+
Axes._parse_scatter_color_args(dummyself, c, edgecolors, kwargs,
1849+
xshape=(2,), yshape=(2,))
1850+
assert result_edgecolors == expected_edgecolors
1851+
1852+
17951853
def test_as_mpl_axes_api():
17961854
# tests the _as_mpl_axes api
17971855
from matplotlib.projections.polar import PolarAxes

0 commit comments

Comments
 (0)