Skip to content

Commit 892b72f

Browse files
committed
Refactor color parsing of Axes.scatter
1 parent 4c3c724 commit 892b72f

File tree

2 files changed

+211
-108
lines changed

2 files changed

+211
-108
lines changed

lib/matplotlib/axes/_axes.py

+151-108
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import itertools
44
import logging
55
import math
6+
import operator
67
from numbers import Number
78
import warnings
89

@@ -4012,6 +4013,150 @@ def dopatch(xs, ys, **kwargs):
40124013
return dict(whiskers=whiskers, caps=caps, boxes=boxes,
40134014
medians=medians, fliers=fliers, means=means)
40144015

4016+
def _parse_scatter_color_args(self, c, edgecolors, kwargs, xshape, yshape):
4017+
"""
4018+
Helper function to process color related arguments of `.Axes.scatter`.
4019+
4020+
Argument precedence for facecolors:
4021+
4022+
- c (if not None)
4023+
- kwargs['facecolors']
4024+
- kwargs['facecolor']
4025+
- kwargs['color'] (==kwcolor)
4026+
- 'b' if in classic mode else next color from color cycle
4027+
4028+
Argument precedence for edgecolors:
4029+
4030+
- edgecolors (is an explicit kw argument in scatter())
4031+
- kwargs['edgecolor']
4032+
- kwargs['color'] (==kwcolor)
4033+
- 'face' if not in classic mode else None
4034+
4035+
Arguments
4036+
---------
4037+
c : color or sequence or sequence of color or None
4038+
See argument description of `.Axes.scatter`.
4039+
edgecolors : color or sequence of color or {'face', 'none'} or None
4040+
See argument description of `.Axes.scatter`.
4041+
kwargs : dict
4042+
Additional kwargs. If these keys exist, we pop and process them:
4043+
'facecolors', 'facecolor', 'edgecolor', 'color'
4044+
Note: The dict is modified by this function.
4045+
xshape, yshape : tuple of int
4046+
The shape of the x and y arrays passed to `.Axes.scatter`.
4047+
4048+
Returns
4049+
-------
4050+
c
4051+
The input *c* if it was not *None*, else some color specification
4052+
derived from the other inputs or defaults.
4053+
colors : array(N, 4) or None
4054+
The facecolors as RGBA values or *None* if a colormap is used.
4055+
edgecolors
4056+
The edgecolor specification.
4057+
4058+
"""
4059+
xsize = functools.reduce(operator.mul, xshape, 1)
4060+
ysize = functools.reduce(operator.mul, yshape, 1)
4061+
4062+
facecolors = kwargs.pop('facecolors', None)
4063+
facecolors = kwargs.pop('facecolor', facecolors)
4064+
edgecolors = kwargs.pop('edgecolor', edgecolors)
4065+
4066+
kwcolor = kwargs.pop('color', None)
4067+
4068+
if kwcolor is not None and c is not None:
4069+
raise ValueError("Supply a 'c' argument or a 'color'"
4070+
" kwarg but not both; they differ but"
4071+
" their functionalities overlap.")
4072+
4073+
if kwcolor is not None:
4074+
try:
4075+
mcolors.to_rgba_array(kwcolor)
4076+
except ValueError:
4077+
raise ValueError("'color' kwarg must be an mpl color"
4078+
" spec or sequence of color specs.\n"
4079+
"For a sequence of values to be color-mapped,"
4080+
" use the 'c' argument instead.")
4081+
if edgecolors is None:
4082+
edgecolors = kwcolor
4083+
if facecolors is None:
4084+
facecolors = kwcolor
4085+
4086+
if edgecolors is None and not rcParams['_internal.classic_mode']:
4087+
edgecolors = 'face'
4088+
4089+
c_was_none = c is None
4090+
if c is None:
4091+
c = (facecolors if facecolors is not None
4092+
else "b" if rcParams['_internal.classic_mode']
4093+
else self._get_patches_for_fill.get_next_color())
4094+
4095+
# After this block, c_array will be None unless
4096+
# c is an array for mapping. The potential ambiguity
4097+
# with a sequence of 3 or 4 numbers is resolved in
4098+
# favor of mapping, not rgb or rgba.
4099+
# Convenience vars to track shape mismatch *and* conversion failures.
4100+
valid_shape = True # will be put to the test!
4101+
n_elem = -1 # used only for (some) exceptions
4102+
4103+
if (c_was_none or
4104+
kwcolor is not None or
4105+
isinstance(c, str) or
4106+
(isinstance(c, collections.abc.Iterable) and
4107+
len(c) > 0 and
4108+
isinstance(cbook.safe_first_element(c), str))):
4109+
c_array = None
4110+
else:
4111+
try: # First, does 'c' look suitable for value-mapping?
4112+
c_array = np.asanyarray(c, dtype=float)
4113+
n_elem = c_array.shape[0]
4114+
if c_array.shape in [xshape, yshape]:
4115+
c = np.ma.ravel(c_array)
4116+
else:
4117+
if c_array.shape in ((3,), (4,)):
4118+
_log.warning(
4119+
"'c' argument looks like a single numeric RGB or "
4120+
"RGBA sequence, which should be avoided as value-"
4121+
"mapping will have precedence in case its length "
4122+
"matches with 'x' & 'y'. Please use a 2-D array "
4123+
"with a single row if you really want to specify "
4124+
"the same RGB or RGBA value for all points.")
4125+
# Wrong size; it must not be intended for mapping.
4126+
valid_shape = False
4127+
c_array = None
4128+
except ValueError:
4129+
# Failed to make a floating-point array; c must be color specs.
4130+
c_array = None
4131+
if c_array is None:
4132+
try: # Then is 'c' acceptable as PathCollection facecolors?
4133+
colors = mcolors.to_rgba_array(c)
4134+
n_elem = colors.shape[0]
4135+
if colors.shape[0] not in (0, 1, xsize, ysize):
4136+
# NB: remember that a single color is also acceptable.
4137+
# Besides *colors* will be an empty array if c == 'none'.
4138+
valid_shape = False
4139+
raise ValueError
4140+
except ValueError:
4141+
if not valid_shape: # but at least one conversion succeeded.
4142+
raise ValueError(
4143+
"'c' argument has {nc} elements, which is not "
4144+
"acceptable for use with 'x' with size {xs}, "
4145+
"'y' with size {ys}."
4146+
.format(nc=n_elem, xs=xsize, ys=ysize)
4147+
)
4148+
# Both the mapping *and* the RGBA conversion failed: pretty
4149+
# severe failure => one may appreciate a verbose feedback.
4150+
raise ValueError(
4151+
"'c' argument must either be valid as mpl color(s) "
4152+
"or as numbers to be mapped to colors. "
4153+
"Here c = {}." # <- beware, could be long depending on c.
4154+
.format(c)
4155+
)
4156+
else:
4157+
colors = None # use cmap, norm after collection is created
4158+
return c, colors, edgecolors
4159+
40154160
@_preprocess_data(replace_names=["x", "y", "s", "linewidths",
40164161
"edgecolors", "c", "facecolor",
40174162
"facecolors", "color"],
@@ -4125,129 +4270,27 @@ def scatter(self, x, y, s=None, c=None, marker=None, cmap=None, norm=None,
41254270
41264271
"""
41274272
# Process **kwargs to handle aliases, conflicts with explicit kwargs:
4128-
facecolors = None
4129-
edgecolors = kwargs.pop('edgecolor', edgecolors)
4130-
fc = kwargs.pop('facecolors', None)
4131-
fc = kwargs.pop('facecolor', fc)
4132-
if fc is not None:
4133-
facecolors = fc
4134-
co = kwargs.pop('color', None)
4135-
if co is not None:
4136-
try:
4137-
mcolors.to_rgba_array(co)
4138-
except ValueError:
4139-
raise ValueError("'color' kwarg must be an mpl color"
4140-
" spec or sequence of color specs.\n"
4141-
"For a sequence of values to be color-mapped,"
4142-
" use the 'c' argument instead.")
4143-
if edgecolors is None:
4144-
edgecolors = co
4145-
if facecolors is None:
4146-
facecolors = co
4147-
if c is not None:
4148-
raise ValueError("Supply a 'c' argument or a 'color'"
4149-
" kwarg but not both; they differ but"
4150-
" their functionalities overlap.")
4151-
if c is None:
4152-
if facecolors is not None:
4153-
c = facecolors
4154-
else:
4155-
if rcParams['_internal.classic_mode']:
4156-
c = 'b' # The original default
4157-
else:
4158-
c = self._get_patches_for_fill.get_next_color()
4159-
c_none = True
4160-
else:
4161-
c_none = False
4162-
4163-
if edgecolors is None and not rcParams['_internal.classic_mode']:
4164-
edgecolors = 'face'
41654273

41664274
self._process_unit_info(xdata=x, ydata=y, kwargs=kwargs)
41674275
x = self.convert_xunits(x)
41684276
y = self.convert_yunits(y)
41694277

41704278
# np.ma.ravel yields an ndarray, not a masked array,
41714279
# unless its argument is a masked array.
4172-
xy_shape = (np.shape(x), np.shape(y))
4280+
xshape, yshape = np.shape(x), np.shape(y)
41734281
x = np.ma.ravel(x)
41744282
y = np.ma.ravel(y)
41754283
if x.size != y.size:
41764284
raise ValueError("x and y must be the same size")
41774285

41784286
if s is None:
4179-
if rcParams['_internal.classic_mode']:
4180-
s = 20
4181-
else:
4182-
s = rcParams['lines.markersize'] ** 2.0
4183-
4287+
s = (20 if rcParams['_internal.classic_mode'] else
4288+
rcParams['lines.markersize'] ** 2.0)
41844289
s = np.ma.ravel(s) # This doesn't have to match x, y in size.
41854290

4186-
# After this block, c_array will be None unless
4187-
# c is an array for mapping. The potential ambiguity
4188-
# with a sequence of 3 or 4 numbers is resolved in
4189-
# favor of mapping, not rgb or rgba.
4190-
4191-
# Convenience vars to track shape mismatch *and* conversion failures.
4192-
valid_shape = True # will be put to the test!
4193-
n_elem = -1 # used only for (some) exceptions
4194-
4195-
if (c_none or
4196-
co is not None or
4197-
isinstance(c, str) or
4198-
(isinstance(c, collections.Iterable) and
4199-
len(c) > 0 and
4200-
isinstance(cbook.safe_first_element(c), str))):
4201-
c_array = None
4202-
else:
4203-
try: # First, does 'c' look suitable for value-mapping?
4204-
c_array = np.asanyarray(c, dtype=float)
4205-
n_elem = c_array.shape[0]
4206-
if c_array.shape in xy_shape:
4207-
c = np.ma.ravel(c_array)
4208-
else:
4209-
if c_array.shape in ((3,), (4,)):
4210-
_log.warning(
4211-
"'c' argument looks like a single numeric RGB or "
4212-
"RGBA sequence, which should be avoided as value-"
4213-
"mapping will have precedence in case its length "
4214-
"matches with 'x' & 'y'. Please use a 2-D array "
4215-
"with a single row if you really want to specify "
4216-
"the same RGB or RGBA value for all points.")
4217-
# Wrong size; it must not be intended for mapping.
4218-
valid_shape = False
4219-
c_array = None
4220-
except ValueError:
4221-
# Failed to make a floating-point array; c must be color specs.
4222-
c_array = None
4223-
4224-
if c_array is None:
4225-
try: # Then is 'c' acceptable as PathCollection facecolors?
4226-
colors = mcolors.to_rgba_array(c)
4227-
n_elem = colors.shape[0]
4228-
if colors.shape[0] not in (0, 1, x.size, y.size):
4229-
# NB: remember that a single color is also acceptable.
4230-
# Besides *colors* will be an empty array if c == 'none'.
4231-
valid_shape = False
4232-
raise ValueError
4233-
except ValueError:
4234-
if not valid_shape: # but at least one conversion succeeded.
4235-
raise ValueError(
4236-
"'c' argument has {nc} elements, which is not "
4237-
"acceptable for use with 'x' with size {xs}, "
4238-
"'y' with size {ys}."
4239-
.format(nc=n_elem, xs=x.size, ys=y.size)
4240-
)
4241-
# Both the mapping *and* the RGBA conversion failed: pretty
4242-
# severe failure => one may appreciate a verbose feedback.
4243-
raise ValueError(
4244-
"'c' argument must either be valid as mpl color(s) "
4245-
"or as numbers to be mapped to colors. "
4246-
"Here c = {}." # <- beware, could be long depending on c.
4247-
.format(c)
4248-
)
4249-
else:
4250-
colors = None # use cmap, norm after collection is created
4291+
c, colors, edgecolors = \
4292+
self._parse_scatter_color_args(c, edgecolors, kwargs,
4293+
xshape, yshape)
42514294

42524295
# `delete_masked_points` only modifies arguments of the same length as
42534296
# `x`.

lib/matplotlib/tests/test_axes.py

+60
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
@@ -1807,6 +1808,65 @@ def test_scatter_c(self, c_case, re_key):
18071808
ax.scatter(x, y, c=c_case, edgecolors="black")
18081809

18091810

1811+
def _params(c=None, xshape=(2,), yshape=(2,), **kwargs):
1812+
edgecolors = kwargs.pop('edgecolors', None)
1813+
return (c, edgecolors, kwargs if kwargs is not None else {},
1814+
xshape, yshape)
1815+
_result = namedtuple('_result', 'c, colors')
1816+
1817+
1818+
@pytest.mark.parametrize('params, expected_result',
1819+
[(_params(),
1820+
_result(c='b', colors=np.array([[0, 0, 1, 1]]))),
1821+
(_params(c='r'),
1822+
_result(c='r', colors=np.array([[1, 0, 0, 1]]))),
1823+
(_params(c='r', colors='b'),
1824+
_result(c='r', colors=np.array([[1, 0, 0, 1]]))),
1825+
# color
1826+
(_params(color='b'),
1827+
_result(c='b', colors=np.array([[0, 0, 1, 1]]))),
1828+
(_params(color=['b', 'g']),
1829+
_result(c=['b', 'g'], colors=np.array([[0, 0, 1, 1], [0, .5, 0, 1]]))),
1830+
])
1831+
def test_parse_scatter_color_args(params, expected_result):
1832+
from matplotlib.axes import Axes
1833+
dummyself = 'UNUSED' # self is only used in one case, which we do not
1834+
# test. Therefore we can get away without costly
1835+
# creating an Axes instance.
1836+
c, colors, _edgecolors = Axes._parse_scatter_color_args(dummyself, *params)
1837+
assert c == expected_result.c
1838+
assert_allclose(colors, expected_result.colors)
1839+
1840+
del _params
1841+
del _result
1842+
1843+
1844+
@pytest.mark.parametrize('kwargs, expected_edgecolors',
1845+
[(dict(), None),
1846+
(dict(c='b'), None),
1847+
(dict(edgecolors='r'), 'r'),
1848+
(dict(edgecolors=['r', 'g']), ['r', 'g']),
1849+
(dict(edgecolor='r'), 'r'),
1850+
(dict(edgecolors='face'), 'face'),
1851+
(dict(edgecolors='none'), 'none'),
1852+
(dict(edgecolor='r', edgecolors='g'), 'r'),
1853+
(dict(c='b', edgecolor='r', edgecolors='g'), 'r'),
1854+
(dict(color='r'), 'r'),
1855+
(dict(color='r', edgecolor='g'), 'g'),
1856+
])
1857+
def test_parse_scatter_color_args_edgecolors(kwargs, expected_edgecolors):
1858+
from matplotlib.axes import Axes
1859+
dummyself = 'UNUSED' # self is only used in one case, which we do not
1860+
# test. Therefore we can get away without costly
1861+
# creating an Axes instance.
1862+
c = kwargs.pop('c', None)
1863+
edgecolors = kwargs.pop('edgecolors', None)
1864+
_, _, result_edgecolors = \
1865+
Axes._parse_scatter_color_args(dummyself, c, edgecolors, kwargs,
1866+
xshape=(2,), yshape=(2,))
1867+
assert result_edgecolors == expected_edgecolors
1868+
1869+
18101870
def test_as_mpl_axes_api():
18111871
# tests the _as_mpl_axes api
18121872
from matplotlib.projections.polar import PolarAxes

0 commit comments

Comments
 (0)