Skip to content

Commit 07c1c62

Browse files
committed
Refactor color parsing of Axes.scatter
1 parent cb0135c commit 07c1c62

File tree

2 files changed

+212
-107
lines changed

2 files changed

+212
-107
lines changed

lib/matplotlib/axes/_axes.py

Lines changed: 152 additions & 107 deletions
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

@@ -4004,6 +4005,151 @@ def dopatch(xs, ys, **kwargs):
40044005
return dict(whiskers=whiskers, caps=caps, boxes=boxes,
40054006
medians=medians, fliers=fliers, means=means)
40064007

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

41584267
self._process_unit_info(xdata=x, ydata=y, kwargs=kwargs)
41594268
x = self.convert_xunits(x)
41604269
y = self.convert_yunits(y)
41614270

41624271
# np.ma.ravel yields an ndarray, not a masked array,
41634272
# unless its argument is a masked array.
4164-
xy_shape = (np.shape(x), np.shape(y))
4273+
xshape, yshape = np.shape(x), np.shape(y)
41654274
x = np.ma.ravel(x)
41664275
y = np.ma.ravel(y)
41674276
if x.size != y.size:
41684277
raise ValueError("x and y must be the same size")
41694278

41704279
if s is None:
4171-
if rcParams['_internal.classic_mode']:
4172-
s = 20
4173-
else:
4174-
s = rcParams['lines.markersize'] ** 2.0
4175-
4280+
s = (20 if rcParams['_internal.classic_mode'] else
4281+
rcParams['lines.markersize'] ** 2.0)
41764282
s = np.ma.ravel(s) # This doesn't have to match x, y in size.
41774283

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

42434288
# `delete_masked_points` only modifies arguments of the same length as
42444289
# `x`.

lib/matplotlib/tests/test_axes.py

Lines changed: 60 additions & 0 deletions
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)