diff --git a/doc/users/next_whats_new/depthshading_improvement.rst b/doc/users/next_whats_new/depthshading_improvement.rst new file mode 100644 index 000000000000..895441062b30 --- /dev/null +++ b/doc/users/next_whats_new/depthshading_improvement.rst @@ -0,0 +1,53 @@ +Depth-shading fix and more depth-shading options +-------------------------------------------------------------- + +New options have been added which allow users to modify the behavior of +depth-shading while addressing a visual bug. + +Previously, a slightly buggy method of estimating the "depth" of plotted +items could lead to sudden and unexpected changes in transparency as the +plot orientation changed. + +Now, the behavior has been made smooth and predictable, and the user is +provided with three new options: whether to invert the shading, setting the +lowest acceptable alpha value (highest transparency), and whether to use +the old algorithm. + +The default behavior visually matches the old algorithm: items that appear to be +"deeper" into the screen will become increasingly transparent (up to the now +user-defined limit). If the inversion option is used then items will start +at maximum transparency and become gradually opaque with increasing depth. + +Note 1: depth-shading applies to Patch3DCollections and Path3DCollections, +including scatter plots. + +Note 2: "depthshade=True" must still be used to enable depth-shading + +A simple example: + +.. plot:: + :include-source: true + :alt: A simple example showing different behavior of depthshading, which can be modified using the provided kwargs. + + import matplotlib.pyplot as plt + + fig = plt.figure() + ax = fig.add_subplot(projection="3d") + + X = [i for i in range(10)] + Y = [i for i in range(10)] + Z = [i for i in range(10)] + S = [(i + 1) * 400 for i in range(10)] + + ax.scatter( + xs=X, + ys=Y, + zs=Z, + s=S, + depthshade=True, + depthshade_minalpha=0.1, + depthshade_inverted=True, + depthshade_legacy=True, + ) + + plt.show() diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 44585ccd05e7..90586ac76f3a 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -541,7 +541,17 @@ class Patch3DCollection(PatchCollection): A collection of 3D patches. """ - def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs): + def __init__( + self, + *args, + zs=0, + zdir="z", + depthshade=True, + depthshade_inverted=False, + depthshade_minalpha=0.3, + depthshade_legacy=False, + **kwargs + ): """ Create a collection of flat 3D patches with its normal vector pointed in *zdir* direction, and located at *zs* on the *zdir* @@ -552,18 +562,46 @@ def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs): :class:`~matplotlib.collections.PatchCollection`. In addition, keywords *zs=0* and *zdir='z'* are available. - Also, the keyword argument *depthshade* is available to indicate - whether to shade the patches in order to give the appearance of depth - (default is *True*). This is typically desired in scatter plots. + The keyword argument *depthshade* is available to + indicate whether or not to shade the patches in order to + give the appearance of depth (default is *True*). + This is typically desired in scatter plots. + + *depthshade_inverted* sets whether to reverse the order of + depth-shading transparency. + + *depthshade_minalpha* sets the minimum alpha value applied by + depth-shading. + + *depthshade_legacy* sets whether to use the legacy algorithm + for depth-shading. """ self._depthshade = depthshade + self._depthshade_inverted = depthshade_inverted + self._depthshade_minalpha = depthshade_minalpha + self._depthshade_legacy = depthshade_legacy super().__init__(*args, **kwargs) self.set_3d_properties(zs, zdir) def get_depthshade(self): return self._depthshade - def set_depthshade(self, depthshade): + def get_depthshade_inverted(self): + return self._depthshade_inverted + + def get_depthshade_minalpha(self): + return self._depthshade_minalpha + + def get_depthshade_legacy(self): + return self._depthshade_legacy + + def set_depthshade( + self, + depthshade, + depthshade_inverted=False, + depthshade_minalpha=0.3, + depthshade_legacy=False, + ): """ Set whether depth shading is performed on collection members. @@ -572,8 +610,17 @@ def set_depthshade(self, depthshade): depthshade : bool Whether to shade the patches in order to give the appearance of depth. + depthshade_inverted : bool + Whether to reverse order of depth-shading transparency. + depthshade_minalpha : float + Sets the minimum alpha value used by depth-shading. + depthshade_legacy : bool + Whtether to use the legacy algorithm for depth-shading. """ self._depthshade = depthshade + self._depthshade_inverted = depthshade_inverted + self._depthshade_minalpha = depthshade_minalpha + self._depthshade_legacy = depthshade_legacy self.stale = True def set_sort_zpos(self, val): @@ -623,7 +670,13 @@ def do_3d_projection(self): def _maybe_depth_shade_and_sort_colors(self, color_array): color_array = ( - _zalpha(color_array, self._vzs) + _zalpha( + color_array, + self._vzs, + inverted=self._depthshade_inverted, + min_alpha=self._depthshade_minalpha, + legacy=self._depthshade_legacy, + ) if self._vzs is not None and self._depthshade else color_array ) @@ -643,12 +696,34 @@ def get_edgecolor(self): return self._maybe_depth_shade_and_sort_colors(super().get_edgecolor()) +def _get_data_scale(X, Y, Z): + """ + Estimate the scale of the 3D data for use in depth shading + """ + # Account for empty datasets. Assume that X Y and Z have equal lengths + if len(X) == 0: + return 0 + + # Estimate the scale using the RSS of the ranges of the dimensions + return np.sqrt(np.ptp(X) ** 2 + np.ptp(Y) ** 2 + np.ptp(Z) ** 2) + + class Path3DCollection(PathCollection): """ A collection of 3D paths. """ - def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs): + def __init__( + self, + *args, + zs=0, + zdir="z", + depthshade=True, + depthshade_inverted=False, + depthshade_minalpha=0.3, + depthshade_legacy=False, + **kwargs + ): """ Create a collection of flat 3D paths with its normal vector pointed in *zdir* direction, and located at *zs* on the *zdir* @@ -659,11 +734,24 @@ def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs): :class:`~matplotlib.collections.PathCollection`. In addition, keywords *zs=0* and *zdir='z'* are available. - Also, the keyword argument *depthshade* is available to indicate - whether to shade the patches in order to give the appearance of depth - (default is *True*). This is typically desired in scatter plots. + Also, the keyword argument *depthshade* is available to + indicate whether or not to shade the patches in order to + give the appearance of depth (default is *True*). + This is typically desired in scatter plots. + + *depthshade_inverted* sets whether to reverse the order of + depth-shading transparency. + + *depthshade_minalpha* sets the minimum alpha value applied by + depth-shading. + + *depthshade_legacy* sets whether to use the legacy algorithm + for depth-shading. """ self._depthshade = depthshade + self._depthshade_inverted = depthshade_inverted + self._depthshade_minalpha = depthshade_minalpha + self._depthshade_legacy = depthshade_legacy self._in_draw = False super().__init__(*args, **kwargs) self.set_3d_properties(zs, zdir) @@ -737,7 +825,22 @@ def set_linewidth(self, lw): def get_depthshade(self): return self._depthshade - def set_depthshade(self, depthshade): + def get_depthshade_inverted(self): + return self._depthshade_inverted + + def get_depthshade_minalpha(self): + return self._depthshade_minalpha + + def get_depthshade_legacy(self): + return self._depthshade_legacy + + def set_depthshade( + self, + depthshade, + depthshade_inverted=False, + depthshade_minalpha=0.3, + depthshade_legacy=False, + ): """ Set whether depth shading is performed on collection members. @@ -746,14 +849,24 @@ def set_depthshade(self, depthshade): depthshade : bool Whether to shade the patches in order to give the appearance of depth. + depthshade_inverted : bool + Whether to reverse order of depth-shading transparency. + depthshade_minalpha : float + Sets the minimum alpha value used by depth-shading. + depthshade_legacy : bool + Whtether to use the legacy algorithm for depth-shading. """ self._depthshade = depthshade + self._depthshade_inverted = depthshade_inverted + self._depthshade_minalpha = depthshade_minalpha + self._depthshade_legacy = depthshade_legacy self.stale = True def do_3d_projection(self): xs, ys, zs = self._offsets3d vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs, self.axes.M) + self._data_scale = _get_data_scale(vxs, vys, vzs) # Sort the points based on z coordinates # Performance optimization: Create a sorted index array and reorder # points and point properties according to the index array @@ -798,14 +911,24 @@ def _use_zordered_offset(self): self._offsets = old_offset def _maybe_depth_shade_and_sort_colors(self, color_array): - color_array = ( - _zalpha(color_array, self._vzs) - if self._vzs is not None and self._depthshade - else color_array - ) + # Adjust the color_array alpha values if point depths are defined + # and depth shading is active + if self._vzs is not None and self._depthshade: + color_array = _zalpha( + color_array, + self._vzs, + inverted=self._depthshade_inverted, + min_alpha=self._depthshade_minalpha, + legacy=self._depthshade_legacy, + _data_scale=self._data_scale, + ) + + # Adjust the order of the color_array using the _z_markers_idx, + # which has been sorted by z-depth if len(color_array) > 1: color_array = color_array[self._z_markers_idx] - return mcolors.to_rgba_array(color_array, self._alpha) + + return mcolors.to_rgba_array(color_array) def get_facecolor(self): return self._maybe_depth_shade_and_sort_colors(super().get_facecolor()) @@ -819,7 +942,15 @@ def get_edgecolor(self): return self._maybe_depth_shade_and_sort_colors(super().get_edgecolor()) -def patch_collection_2d_to_3d(col, zs=0, zdir='z', depthshade=True): +def patch_collection_2d_to_3d( + col, + zs=0, + zdir="z", + depthshade=True, + depthshade_inverted=False, + depthshade_minalpha=0.3, + depthshade_legacy=False, +): """ Convert a `.PatchCollection` into a `.Patch3DCollection` object (or a `.PathCollection` into a `.Path3DCollection` object). @@ -835,8 +966,14 @@ def patch_collection_2d_to_3d(col, zs=0, zdir='z', depthshade=True): zdir : {'x', 'y', 'z'} The axis in which to place the patches. Default: "z". See `.get_dir_vector` for a description of the values. - depthshade : bool, default: True - Whether to shade the patches to give a sense of depth. + depthshade + Whether to shade the patches to give a sense of depth. Default: *True*. + depthshade_invert + Whether to reverse order of depth-shading transparency. Default: *False*. + depthshade_minalpha + Sets the minimum alpha value used by depth-shading. Default: 0.3. + depthshade_legacy + Whtether to use the legacy algorithm for depth-shading. Default: *False*. """ if isinstance(col, PathCollection): @@ -845,6 +982,9 @@ def patch_collection_2d_to_3d(col, zs=0, zdir='z', depthshade=True): elif isinstance(col, PatchCollection): col.__class__ = Patch3DCollection col._depthshade = depthshade + col._depthshade_inverted = depthshade_inverted + col._depthshade_minalpha = depthshade_minalpha + col._depthshade_legacy = depthshade_legacy col._in_draw = False col.set_3d_properties(zs, zdir) @@ -1167,17 +1307,65 @@ def rotate_axes(xs, ys, zs, zdir): return xs, ys, zs -def _zalpha(colors, zs): +def _zalpha( + colors, + zs, + inverted=False, + min_alpha=0.3, + legacy=False, + _data_scale=None, +): """Modify the alphas of the color list according to depth.""" - # FIXME: This only works well if the points for *zs* are well-spaced - # in all three dimensions. Otherwise, at certain orientations, - # the min and max zs are very close together. - # Should really normalize against the viewing depth. + if len(colors) == 0 or len(zs) == 0: return np.zeros((0, 4)) - norm = Normalize(min(zs), max(zs)) - sats = 1 - norm(zs) * 0.7 + + # Alpha values beyond the range 0-1 inclusive make no sense, so clip them + min_alpha = np.clip(min_alpha, 0, 1) + + if _data_scale is None or legacy: + # Revert to "legacy mode" if the new method of calculating + # _data_scale fails, or if the user asks for it + + # This only works well if the points for *zs* are well-spaced in + # all three dimensions. Otherwise, at certain orientations the + # min and max zs are very close together. + # Should really normalize against the viewing depth. + + # Normalize the z-depths to the range 0 - 1 + norm = Normalize(min(zs), max(zs)) + + # Generate alpha multipliers using the normalized z-depths so that + # closer points are opaque and the furthest points are still visible, + # but transparent + if inverted: + sats = norm(zs) * (1 - min_alpha) + min_alpha + else: + sats = 1 - norm(zs) * (1 - min_alpha) + + else: + # Improved normalization using a scale value derived from the XYZ + # limits of the plot + + if _data_scale == 0: + # Don't scale the alpha values since we have no valid + # data scale for reference + sats = np.ones_like(zs) + + else: + if inverted: + # Deeper points have an increasingly solid appearance + sats = np.clip(1 - (max(zs) - zs) / _data_scale, min_alpha, 1) + else: + # This is the mode that most closely matches the legacy behavior + + # Deeper points have an increasingly transparent appearance + sats = np.clip(1 - (zs - min(zs)) / _data_scale, min_alpha, 1) + rgba = np.broadcast_to(mcolors.to_rgba_array(colors), (len(zs), 4)) + + # Change the alpha values of the colors using the generated alpha + # multipliers return np.column_stack([rgba[:, :3], rgba[:, 3] * sats]) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 1845c701323e..6c2ddf9b30e6 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -2591,6 +2591,9 @@ def add_collection3d(self, col, zs=0, zdir='z'): "edgecolors", "c", "facecolor", "facecolors", "color"]) def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=True, + depthshade_inverted=False, + depthshade_minalpha=0.3, + depthshade_legacy=False, *args, **kwargs): """ Create a scatter plot. @@ -2627,6 +2630,16 @@ def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=True, Whether to shade the scatter markers to give the appearance of depth. Each call to ``scatter()`` will perform its depthshading independently. + + depthshade_inverted : bool, default: False + Whether to reverse the order of depth-shading transparency. + + depthshade_minalpha : float, default: 0.3 + The lowest alpha value applied by depth-shading. + + depthshade_legacy : bool, default: False + Whether to use the legacy algorithm for depth-shading. + data : indexable object, optional DATA_PARAMETER_PLACEHOLDER **kwargs @@ -2654,8 +2667,15 @@ def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=True, zs = zs.copy() patches = super().scatter(xs, ys, s=s, c=c, *args, **kwargs) - art3d.patch_collection_2d_to_3d(patches, zs=zs, zdir=zdir, - depthshade=depthshade) + art3d.patch_collection_2d_to_3d( + patches, + zs=zs, + zdir=zdir, + depthshade=depthshade, + depthshade_inverted=depthshade_inverted, + depthshade_minalpha=depthshade_minalpha, + depthshade_legacy=depthshade_legacy, + ) if self._zmargin < 0.05 and xs.size > 0: self.set_zmargin(0.05)