Skip to content

Commit 1b1afe1

Browse files
authored
Merge pull request #6268 from efiring/alpha_array
ENH: support alpha arrays in collections
2 parents 2e82a38 + c9d0741 commit 1b1afe1

File tree

9 files changed

+276
-36
lines changed

9 files changed

+276
-36
lines changed
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
Transparency (alpha) can be set as an array in collections
2+
----------------------------------------------------------
3+
Previously, the alpha value controlling tranparency in collections could be
4+
specified only as a scalar applied to all elements in the collection.
5+
For example, all the markers in a `~.Axes.scatter` plot, or all the
6+
quadrilaterals in a `~.Axes.pcolormesh` plot, would have the same alpha value.
7+
8+
Now it is possible to supply alpha as an array with one value for each element
9+
(marker, quadrilateral, etc.) in a collection.
10+
11+
.. plot::
12+
13+
x = np.arange(5, dtype=float)
14+
y = np.arange(5, dtype=float)
15+
# z and zalpha for demo pcolormesh
16+
z = x[1:, np.newaxis] + y[np.newaxis, 1:]
17+
zalpha = np.ones_like(z)
18+
zalpha[::2, ::2] = 0.3 # alternate patches are partly transparent
19+
# s and salpha for demo scatter
20+
s = x
21+
salpha = np.linspace(0.1, 0.9, len(x)) # just a ramp
22+
23+
fig, axs = plt.subplots(2, 2, constrained_layout=True)
24+
axs[0, 0].pcolormesh(x, y, z, alpha=zalpha)
25+
axs[0, 0].set_title("pcolormesh")
26+
axs[0, 1].scatter(x, y, c=s, alpha=salpha)
27+
axs[0, 1].set_title("color-mapped")
28+
axs[1, 0].scatter(x, y, c='k', alpha=salpha)
29+
axs[1, 0].set_title("c='k'")
30+
axs[1, 1].scatter(x, y, c=['r', 'g', 'b', 'c', 'm'], alpha=salpha)
31+
axs[1, 1].set_title("c=['r', 'g', 'b', 'c', 'm']")

lib/matplotlib/artist.py

+29-2
Original file line numberDiff line numberDiff line change
@@ -954,10 +954,37 @@ def set_alpha(self, alpha):
954954
955955
Parameters
956956
----------
957-
alpha : float or None
957+
alpha : scalar or None
958+
*alpha* must be within the 0-1 range, inclusive.
958959
"""
959960
if alpha is not None and not isinstance(alpha, Number):
960-
raise TypeError('alpha must be a float or None')
961+
raise TypeError(
962+
f'alpha must be numeric or None, not {type(alpha)}')
963+
if alpha is not None and not (0 <= alpha <= 1):
964+
raise ValueError(f'alpha ({alpha}) is outside 0-1 range')
965+
self._alpha = alpha
966+
self.pchanged()
967+
self.stale = True
968+
969+
def _set_alpha_for_array(self, alpha):
970+
"""
971+
Set the alpha value used for blending - not supported on all backends.
972+
973+
Parameters
974+
----------
975+
alpha : array-like or scalar or None
976+
All values must be within the 0-1 range, inclusive.
977+
Masked values and nans are not supported.
978+
"""
979+
if isinstance(alpha, str):
980+
raise TypeError("alpha must be numeric or None, not a string")
981+
if not np.iterable(alpha):
982+
Artist.set_alpha(self, alpha)
983+
return
984+
alpha = np.asarray(alpha)
985+
if not (0 <= alpha.min() and alpha.max() <= 1):
986+
raise ValueError('alpha must be between 0 and 1, inclusive, '
987+
f'but min is {alpha.min()}, max is {alpha.max()}')
961988
self._alpha = alpha
962989
self.pchanged()
963990
self.stale = True

lib/matplotlib/collections.py

+27-3
Original file line numberDiff line numberDiff line change
@@ -832,12 +832,24 @@ def set_edgecolor(self, c):
832832
self._set_edgecolor(c)
833833

834834
def set_alpha(self, alpha):
835-
# docstring inherited
836-
super().set_alpha(alpha)
835+
"""
836+
Set the transparency of the collection.
837+
838+
Parameters
839+
----------
840+
alpha: float or array of float or None
841+
If not None, *alpha* values must be between 0 and 1, inclusive.
842+
If an array is provided, its length must match the number of
843+
elements in the collection. Masked values and nans are not
844+
supported.
845+
"""
846+
artist.Artist._set_alpha_for_array(self, alpha)
837847
self._update_dict['array'] = True
838848
self._set_facecolor(self._original_facecolor)
839849
self._set_edgecolor(self._original_edgecolor)
840850

851+
set_alpha.__doc__ = artist.Artist._set_alpha_for_array.__doc__
852+
841853
def get_linewidth(self):
842854
return self._linewidths
843855

@@ -848,11 +860,23 @@ def update_scalarmappable(self):
848860
"""Update colors from the scalar mappable array, if it is not None."""
849861
if self._A is None:
850862
return
851-
# QuadMesh can map 2d arrays
863+
# QuadMesh can map 2d arrays (but pcolormesh supplies 1d array)
852864
if self._A.ndim > 1 and not isinstance(self, QuadMesh):
853865
raise ValueError('Collections can only map rank 1 arrays')
854866
if not self._check_update("array"):
855867
return
868+
if np.iterable(self._alpha):
869+
if self._alpha.size != self._A.size:
870+
raise ValueError(f'Data array shape, {self._A.shape} '
871+
'is incompatible with alpha array shape, '
872+
f'{self._alpha.shape}. '
873+
'This can occur with the deprecated '
874+
'behavior of the "flat" shading option, '
875+
'in which a row and/or column of the data '
876+
'array is dropped.')
877+
# pcolormesh, scatter, maybe others flatten their _A
878+
self._alpha = self._alpha.reshape(self._A.shape)
879+
856880
if self._is_filled:
857881
self._facecolors = self.to_rgba(self._A, self._alpha)
858882
elif self._is_stroked:

lib/matplotlib/colors.py

+55-20
Original file line numberDiff line numberDiff line change
@@ -289,18 +289,40 @@ def to_rgba_array(c, alpha=None):
289289
"""
290290
Convert *c* to a (n, 4) array of RGBA colors.
291291
292-
If *alpha* is not ``None``, it forces the alpha value. If *c* is
293-
``"none"`` (case-insensitive) or an empty list, an empty array is returned.
294-
If *c* is a masked array, an ndarray is returned with a (0, 0, 0, 0)
295-
row for each masked value or row in *c*.
292+
Parameters
293+
----------
294+
c : Matplotlib color or array of colors
295+
If *c* is a masked array, an ndarray is returned with a (0, 0, 0, 0)
296+
row for each masked value or row in *c*.
297+
298+
alpha : float or sequence of floats, optional
299+
If *alpha* is not ``None``, it forces the alpha value, except if *c* is
300+
``"none"`` (case-insensitive), which always maps to ``(0, 0, 0, 0)``.
301+
If *alpha* is a sequence and *c* is a single color, *c* will be
302+
repeated to match the length of *alpha*.
303+
304+
Returns
305+
-------
306+
array
307+
(n, 4) array of RGBA colors.
308+
296309
"""
297310
# Special-case inputs that are already arrays, for performance. (If the
298311
# array has the wrong kind or shape, raise the error during one-at-a-time
299312
# conversion.)
313+
if np.iterable(alpha):
314+
alpha = np.asarray(alpha).ravel()
300315
if (isinstance(c, np.ndarray) and c.dtype.kind in "if"
301316
and c.ndim == 2 and c.shape[1] in [3, 4]):
302317
mask = c.mask.any(axis=1) if np.ma.is_masked(c) else None
303318
c = np.ma.getdata(c)
319+
if np.iterable(alpha):
320+
if c.shape[0] == 1 and alpha.shape[0] > 1:
321+
c = np.tile(c, (alpha.shape[0], 1))
322+
elif c.shape[0] != alpha.shape[0]:
323+
raise ValueError("The number of colors must match the number"
324+
" of alpha values if there are more than one"
325+
" of each.")
304326
if c.shape[1] == 3:
305327
result = np.column_stack([c, np.zeros(len(c))])
306328
result[:, -1] = alpha if alpha is not None else 1.
@@ -320,7 +342,10 @@ def to_rgba_array(c, alpha=None):
320342
if cbook._str_lower_equal(c, "none"):
321343
return np.zeros((0, 4), float)
322344
try:
323-
return np.array([to_rgba(c, alpha)], float)
345+
if np.iterable(alpha):
346+
return np.array([to_rgba(c, a) for a in alpha], float)
347+
else:
348+
return np.array([to_rgba(c, alpha)], float)
324349
except (ValueError, TypeError):
325350
pass
326351

@@ -332,7 +357,10 @@ def to_rgba_array(c, alpha=None):
332357
if len(c) == 0:
333358
return np.zeros((0, 4), float)
334359
else:
335-
return np.array([to_rgba(cc, alpha) for cc in c])
360+
if np.iterable(alpha):
361+
return np.array([to_rgba(cc, aa) for cc, aa in zip(c, alpha)])
362+
else:
363+
return np.array([to_rgba(cc, alpha) for cc in c])
336364

337365

338366
def to_rgb(c):
@@ -539,8 +567,9 @@ def __call__(self, X, alpha=None, bytes=False):
539567
return the RGBA values ``X*100`` percent along the Colormap line.
540568
For integers, X should be in the interval ``[0, Colormap.N)`` to
541569
return RGBA values *indexed* from the Colormap with index ``X``.
542-
alpha : float, None
543-
Alpha must be a scalar between 0 and 1, or None.
570+
alpha : float, array-like, None
571+
Alpha must be a scalar between 0 and 1, a sequence of such
572+
floats with shape matching X, or None.
544573
bytes : bool
545574
If False (default), the returned RGBA values will be floats in the
546575
interval ``[0, 1]`` otherwise they will be uint8s in the interval
@@ -580,23 +609,29 @@ def __call__(self, X, alpha=None, bytes=False):
580609
else:
581610
lut = self._lut.copy() # Don't let alpha modify original _lut.
582611

612+
rgba = np.empty(shape=xa.shape + (4,), dtype=lut.dtype)
613+
lut.take(xa, axis=0, mode='clip', out=rgba)
614+
583615
if alpha is not None:
616+
if np.iterable(alpha):
617+
alpha = np.asarray(alpha)
618+
if alpha.shape != xa.shape:
619+
raise ValueError("alpha is array-like but its shape"
620+
" %s doesn't match that of X %s" %
621+
(alpha.shape, xa.shape))
584622
alpha = np.clip(alpha, 0, 1)
585623
if bytes:
586-
alpha = int(alpha * 255)
587-
if (lut[-1] == 0).all():
588-
lut[:-1, -1] = alpha
589-
# All zeros is taken as a flag for the default bad
590-
# color, which is no color--fully transparent. We
591-
# don't want to override this.
592-
else:
593-
lut[:, -1] = alpha
594-
# If the bad value is set to have a color, then we
595-
# override its alpha just as for any other value.
624+
alpha = (alpha * 255).astype(np.uint8)
625+
rgba[..., -1] = alpha
626+
627+
# If the "bad" color is all zeros, then ignore alpha input.
628+
if (lut[-1] == 0).all() and np.any(mask_bad):
629+
if np.iterable(mask_bad) and mask_bad.shape == xa.shape:
630+
rgba[mask_bad] = (0, 0, 0, 0)
631+
else:
632+
rgba[..., :] = (0, 0, 0, 0)
596633

597-
rgba = lut[xa]
598634
if not np.iterable(X):
599-
# Return a tuple if the input was a scalar
600635
rgba = tuple(rgba)
601636
return rgba
602637

lib/matplotlib/image.py

+5-9
Original file line numberDiff line numberDiff line change
@@ -274,16 +274,12 @@ def set_alpha(self, alpha):
274274
275275
Parameters
276276
----------
277-
alpha : float
277+
alpha : float or 2D array-like or None
278278
"""
279-
if alpha is not None and not isinstance(alpha, Number):
280-
alpha = np.asarray(alpha)
281-
if alpha.ndim != 2:
282-
raise TypeError('alpha must be a float, two-dimensional '
283-
'array, or None')
284-
self._alpha = alpha
285-
self.pchanged()
286-
self.stale = True
279+
martist.Artist._set_alpha_for_array(self, alpha)
280+
if np.ndim(alpha) not in (0, 2):
281+
raise TypeError('alpha must be a float, two-dimensional '
282+
'array, or None')
287283
self._imcache = None
288284

289285
def _get_scalar_alpha(self):

lib/matplotlib/tests/test_artist.py

+26
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,29 @@ def test_artist_inspector_get_aliases():
277277
ai = martist.ArtistInspector(mlines.Line2D)
278278
aliases = ai.get_aliases()
279279
assert aliases["linewidth"] == {"lw"}
280+
281+
282+
def test_set_alpha():
283+
art = martist.Artist()
284+
with pytest.raises(TypeError, match='^alpha must be numeric or None'):
285+
art.set_alpha('string')
286+
with pytest.raises(TypeError, match='^alpha must be numeric or None'):
287+
art.set_alpha([1, 2, 3])
288+
with pytest.raises(ValueError, match="outside 0-1 range"):
289+
art.set_alpha(1.1)
290+
with pytest.raises(ValueError, match="outside 0-1 range"):
291+
art.set_alpha(np.nan)
292+
293+
294+
def test_set_alpha_for_array():
295+
art = martist.Artist()
296+
with pytest.raises(TypeError, match='^alpha must be numeric or None'):
297+
art._set_alpha_for_array('string')
298+
with pytest.raises(ValueError, match="outside 0-1 range"):
299+
art._set_alpha_for_array(1.1)
300+
with pytest.raises(ValueError, match="outside 0-1 range"):
301+
art._set_alpha_for_array(np.nan)
302+
with pytest.raises(ValueError, match="alpha must be between 0 and 1"):
303+
art._set_alpha_for_array([0.5, 1.1])
304+
with pytest.raises(ValueError, match="alpha must be between 0 and 1"):
305+
art._set_alpha_for_array([0.5, np.nan])

lib/matplotlib/tests/test_collections.py

+62-2
Original file line numberDiff line numberDiff line change
@@ -543,11 +543,38 @@ def test_cap_and_joinstyle_image():
543543
def test_scatter_post_alpha():
544544
fig, ax = plt.subplots()
545545
sc = ax.scatter(range(5), range(5), c=range(5))
546-
# this needs to be here to update internal state
547-
fig.canvas.draw()
548546
sc.set_alpha(.1)
549547

550548

549+
def test_scatter_alpha_array():
550+
x = np.arange(5)
551+
alpha = x / 5
552+
# With color mapping.
553+
fig, (ax0, ax1) = plt.subplots(2)
554+
sc0 = ax0.scatter(x, x, c=x, alpha=alpha)
555+
sc1 = ax1.scatter(x, x, c=x)
556+
sc1.set_alpha(alpha)
557+
plt.draw()
558+
assert_array_equal(sc0.get_facecolors()[:, -1], alpha)
559+
assert_array_equal(sc1.get_facecolors()[:, -1], alpha)
560+
# Without color mapping.
561+
fig, (ax0, ax1) = plt.subplots(2)
562+
sc0 = ax0.scatter(x, x, color=['r', 'g', 'b', 'c', 'm'], alpha=alpha)
563+
sc1 = ax1.scatter(x, x, color='r', alpha=alpha)
564+
plt.draw()
565+
assert_array_equal(sc0.get_facecolors()[:, -1], alpha)
566+
assert_array_equal(sc1.get_facecolors()[:, -1], alpha)
567+
# Without color mapping, and set alpha afterward.
568+
fig, (ax0, ax1) = plt.subplots(2)
569+
sc0 = ax0.scatter(x, x, color=['r', 'g', 'b', 'c', 'm'])
570+
sc0.set_alpha(alpha)
571+
sc1 = ax1.scatter(x, x, color='r')
572+
sc1.set_alpha(alpha)
573+
plt.draw()
574+
assert_array_equal(sc0.get_facecolors()[:, -1], alpha)
575+
assert_array_equal(sc1.get_facecolors()[:, -1], alpha)
576+
577+
551578
def test_pathcollection_legend_elements():
552579
np.random.seed(19680801)
553580
x, y = np.random.rand(2, 10)
@@ -662,6 +689,39 @@ def test_quadmesh_set_array():
662689
assert np.array_equal(coll.get_array(), np.ones(9))
663690

664691

692+
def test_quadmesh_alpha_array():
693+
x = np.arange(4)
694+
y = np.arange(4)
695+
z = np.arange(9).reshape((3, 3))
696+
alpha = z / z.max()
697+
alpha_flat = alpha.ravel()
698+
# Provide 2-D alpha:
699+
fig, (ax0, ax1) = plt.subplots(2)
700+
coll1 = ax0.pcolormesh(x, y, z, alpha=alpha)
701+
coll2 = ax1.pcolormesh(x, y, z)
702+
coll2.set_alpha(alpha)
703+
plt.draw()
704+
assert_array_equal(coll1.get_facecolors()[:, -1], alpha_flat)
705+
assert_array_equal(coll2.get_facecolors()[:, -1], alpha_flat)
706+
# Or provide 1-D alpha:
707+
fig, (ax0, ax1) = plt.subplots(2)
708+
coll1 = ax0.pcolormesh(x, y, z, alpha=alpha_flat)
709+
coll2 = ax1.pcolormesh(x, y, z)
710+
coll2.set_alpha(alpha_flat)
711+
plt.draw()
712+
assert_array_equal(coll1.get_facecolors()[:, -1], alpha_flat)
713+
assert_array_equal(coll2.get_facecolors()[:, -1], alpha_flat)
714+
715+
716+
def test_alpha_validation():
717+
# Most of the relevant testing is in test_artist and test_colors.
718+
fig, ax = plt.subplots()
719+
pc = ax.pcolormesh(np.arange(12).reshape((3, 4)))
720+
with pytest.raises(ValueError, match="^Data array shape"):
721+
pc.set_alpha([0.5, 0.6])
722+
pc.update_scalarmappable()
723+
724+
665725
def test_legend_inverse_size_label_relationship():
666726
"""
667727
Ensure legend markers scale appropriately when label and size are

0 commit comments

Comments
 (0)