From 00d5d05766714b5192b257d2a5dcc7f133d833b0 Mon Sep 17 00:00:00 2001 From: Obliman Date: Fri, 20 May 2022 23:26:15 -0500 Subject: [PATCH 01/16] Update art3d.py This update changes the depthscale behavior from the original Normalize method to an improved version that behaves well even when points are closely spaced. --- lib/mpl_toolkits/mplot3d/art3d.py | 43 +++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 4f551fea8a71..6c738b0adfec 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -488,6 +488,13 @@ def get_edgecolor(self): return self._maybe_depth_shade_and_sort_colors(super().get_edgecolor()) +def get_data_scale(X, Y, Z): + def _m(data): + return np.power(max(data) - min(data), 2) + + return np.power(_m(X) + _m(Y) + _m(Z), 0.5) + + class Path3DCollection(PathCollection): """ A collection of 3D paths. @@ -585,6 +592,7 @@ 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.dscl = 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 @@ -613,7 +621,7 @@ 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, self.dscl) if self._vzs is not None and self._depthshade else color_array ) @@ -918,15 +926,34 @@ def rotate_axes(xs, ys, zs, zdir): return xs, ys, zs -def _zalpha(colors, zs): +def _zalpha(colors, zs, dscl=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. + # Return empty if given empty 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 + + if dscl is None: + # 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 + sats = 1 - norm(zs) * 0.7 + + else: + # Improved normalization using a scale value derived from the XYZ limits of the plot + sats = np.clip(1 - (zs - min(zs)) / dscl, 0.3, 1) # Solid near, transparent far, solid default + # sats = np.clip((max(zs)-zs)/dscl, 0.3, 1) # Solid near, transparent far, transparent default + # sats = np.clip(1-(max(zs)-zs)/dscl, 0.3, 1) # Transparent near, solid far, solid default + # sats = np.clip((zs-min(zs))/dscl, 0.3, 1) # Transparent near, solid far, transparent default + + # Restructure colors into a rgba numpy array 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]) From af2e2a28361651d198c6e20e861397f261bd2d12 Mon Sep 17 00:00:00 2001 From: Obliman Date: Sat, 21 May 2022 00:44:50 -0500 Subject: [PATCH 02/16] Update art3d.py Fixed line lengths, added handling of zero-length datasets when computing the data scale. --- lib/mpl_toolkits/mplot3d/art3d.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 6c738b0adfec..124bf80edc23 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -490,6 +490,8 @@ def get_edgecolor(self): def get_data_scale(X, Y, Z): def _m(data): + if len(data) == 0: + return 0 return np.power(max(data) - min(data), 2) return np.power(_m(X) + _m(Y) + _m(Z), 0.5) @@ -941,19 +943,30 @@ def _zalpha(colors, zs, dscl=None): # 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 + # Generate alpha multipliers using the normalized z-depths so that + # closer points # are opaque and the furthest points are still visible, but transparent sats = 1 - norm(zs) * 0.7 else: - # Improved normalization using a scale value derived from the XYZ limits of the plot - sats = np.clip(1 - (zs - min(zs)) / dscl, 0.3, 1) # Solid near, transparent far, solid default - # sats = np.clip((max(zs)-zs)/dscl, 0.3, 1) # Solid near, transparent far, transparent default - # sats = np.clip(1-(max(zs)-zs)/dscl, 0.3, 1) # Transparent near, solid far, solid default - # sats = np.clip((zs-min(zs))/dscl, 0.3, 1) # Transparent near, solid far, transparent default + # Improved normalization using a scale value derived from the XYZ + # limits of the plot + + # Solid near, transparent far, solid default + sats = np.clip(1 - (zs - min(zs)) / dscl, 0.3, 1) + + # Solid near, transparent far, transparent default + # sats = np.clip((max(zs)-zs)/dscl, 0.3, 1) + + # Transparent near, solid far, solid default + # sats = np.clip(1-(max(zs)-zs)/dscl, 0.3, 1) + + # Transparent near, solid far, transparent default + # sats = np.clip((zs-min(zs))/dscl, 0.3, 1) # Restructure colors into a rgba numpy array rgba = np.broadcast_to(mcolors.to_rgba_array(colors), (len(zs), 4)) - # Change the alpha values of the colors using the generated alpha multipliers + # Change the alpha values of the colors using the generated alpha + # multipliers return np.column_stack([rgba[:, :3], rgba[:, 3] * sats]) From 1ed46484a0ba1ca308aa6f11f3baf097c4a06849 Mon Sep 17 00:00:00 2001 From: Obliman Date: Sat, 21 May 2022 01:56:13 -0500 Subject: [PATCH 03/16] Update art3d.py Added a small value to the denominator of the "sats" calculation to avoid division by zero error when plot is empty (scale is zero). Removed trailing whitespace. --- lib/mpl_toolkits/mplot3d/art3d.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 124bf80edc23..5da4916eb9f0 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -949,20 +949,20 @@ def _zalpha(colors, zs, dscl=None): sats = 1 - norm(zs) * 0.7 else: - # Improved normalization using a scale value derived from the XYZ + # Improved normalization using a scale value derived from the XYZ # limits of the plot # Solid near, transparent far, solid default - sats = np.clip(1 - (zs - min(zs)) / dscl, 0.3, 1) + sats = np.clip(1 - (zs - min(zs)) / (dscl+1e-8), 0.3, 1) # Solid near, transparent far, transparent default - # sats = np.clip((max(zs)-zs)/dscl, 0.3, 1) + # sats = np.clip((max(zs)-zs)/(dscl+1e-8), 0.3, 1) # Transparent near, solid far, solid default - # sats = np.clip(1-(max(zs)-zs)/dscl, 0.3, 1) + # sats = np.clip(1-(max(zs)-zs)/(dscl+1e-8), 0.3, 1) # Transparent near, solid far, transparent default - # sats = np.clip((zs-min(zs))/dscl, 0.3, 1) + # sats = np.clip((zs-min(zs))/(dscl+1e-8), 0.3, 1) # Restructure colors into a rgba numpy array rgba = np.broadcast_to(mcolors.to_rgba_array(colors), (len(zs), 4)) From ca24aaee3084b4f2dc37e124ca68cf4a0b61d6ab Mon Sep 17 00:00:00 2001 From: Obliman Date: Sat, 21 May 2022 04:13:00 -0500 Subject: [PATCH 04/16] Updated art3d.py per feedback Renamed get_data_scale and dscl to indicate that they are private, added description of function's purpose, and replaced np.power with np.sqrt. --- lib/mpl_toolkits/mplot3d/art3d.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 5da4916eb9f0..973771038282 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -488,13 +488,16 @@ def get_edgecolor(self): return self._maybe_depth_shade_and_sort_colors(super().get_edgecolor()) -def get_data_scale(X, Y, Z): +def _get_data_scale(X, Y, Z): + """ + Estimate the scale of the 3D data for use in depthshading + """ def _m(data): if len(data) == 0: return 0 return np.power(max(data) - min(data), 2) - return np.power(_m(X) + _m(Y) + _m(Z), 0.5) + return np.sqrt(_m(X) + _m(Y) + _m(Z)) class Path3DCollection(PathCollection): @@ -594,7 +597,7 @@ 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.dscl = get_data_scale(vxs, vys, vzs) + self._dscl = _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 @@ -623,7 +626,7 @@ def do_3d_projection(self): def _maybe_depth_shade_and_sort_colors(self, color_array): color_array = ( - _zalpha(color_array, self._vzs, self.dscl) + _zalpha(color_array, self._vzs, self._dscl) if self._vzs is not None and self._depthshade else color_array ) @@ -928,13 +931,13 @@ def rotate_axes(xs, ys, zs, zdir): return xs, ys, zs -def _zalpha(colors, zs, dscl=None): +def _zalpha(colors, zs, _dscl=None): """Modify the alphas of the color list according to depth.""" # Return empty if given empty if len(colors) == 0 or len(zs) == 0: return np.zeros((0, 4)) - if dscl is None: + if _dscl is None: # 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. @@ -953,16 +956,16 @@ def _zalpha(colors, zs, dscl=None): # limits of the plot # Solid near, transparent far, solid default - sats = np.clip(1 - (zs - min(zs)) / (dscl+1e-8), 0.3, 1) + sats = np.clip(1 - (zs - min(zs)) / (_dscl+1e-8), 0.3, 1) # Solid near, transparent far, transparent default - # sats = np.clip((max(zs)-zs)/(dscl+1e-8), 0.3, 1) + # sats = np.clip((max(zs)-zs)/(_dscl+1e-8), 0.3, 1) # Transparent near, solid far, solid default - # sats = np.clip(1-(max(zs)-zs)/(dscl+1e-8), 0.3, 1) + # sats = np.clip(1-(max(zs)-zs)/(_dscl+1e-8), 0.3, 1) # Transparent near, solid far, transparent default - # sats = np.clip((zs-min(zs))/(dscl+1e-8), 0.3, 1) + # sats = np.clip((zs-min(zs))/(_dscl+1e-8), 0.3, 1) # Restructure colors into a rgba numpy array rgba = np.broadcast_to(mcolors.to_rgba_array(colors), (len(zs), 4)) From 2ef7284cbdfce49bab6664730bf2ba49a6eb05e2 Mon Sep 17 00:00:00 2001 From: Obliman Date: Sat, 21 May 2022 04:23:27 -0500 Subject: [PATCH 05/16] Update art3d.py Forgot this portion of the update earlier. Fixes the alpha draw order flipping as the point depth inverts even though the alpha should not change per point. --- lib/mpl_toolkits/mplot3d/art3d.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 973771038282..878c7d853992 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -625,14 +625,20 @@ def do_3d_projection(self): return np.min(vzs) if vzs.size else np.nan def _maybe_depth_shade_and_sort_colors(self, color_array): - color_array = ( - _zalpha(color_array, self._vzs, self._dscl) - 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, dscl=self.dscl) + + # 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) + + # The correct alpha is already accounted for in the color_array + # Passing the self._alpha value in overwrites the automatically + # adjusted alpha + return mcolors.to_rgba_array(color_array) def get_facecolor(self): return self._maybe_depth_shade_and_sort_colors(super().get_facecolor()) From c085f47f52394ea02e4b3007fe9e22539d0568a9 Mon Sep 17 00:00:00 2001 From: Obliman Date: Sat, 21 May 2022 04:25:43 -0500 Subject: [PATCH 06/16] Update art3d.py Missed the update to _dscl after re-adding code. Updated now. --- lib/mpl_toolkits/mplot3d/art3d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 878c7d853992..9e97f0fea43a 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -628,7 +628,7 @@ def _maybe_depth_shade_and_sort_colors(self, 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, dscl=self.dscl) + color_array = _zalpha(color_array, self._vzs, _dscl=self._dscl) # Adjust the order of the color_array using the _z_markers_idx, # which has been sorted by z-depth From 6bb3cd5c53974b1398a6ad85def91ff3bd6570b3 Mon Sep 17 00:00:00 2001 From: Obliman Date: Wed, 1 Jun 2022 14:24:19 -0500 Subject: [PATCH 07/16] Update art3d.py per reviewer feedback Only one dataset of (X, Y, Z) needs to be checked for zero length assuming all have the same length. Instead of using a small constant in the denominator it's cleaner to just check for '_dscl==0' and return ones instead. This applies for datasets with no datapoints, all the same datapoint such that no scale can be estimated. --- lib/mpl_toolkits/mplot3d/art3d.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 9e97f0fea43a..48f4f93a09c7 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -493,10 +493,12 @@ def _get_data_scale(X, Y, Z): Estimate the scale of the 3D data for use in depthshading """ def _m(data): - if len(data) == 0: - return 0 return np.power(max(data) - min(data), 2) + # Account for empty datasets. Assume that X Y and Z have equal lengths + if len(X) == 0: + return 0 + return np.sqrt(_m(X) + _m(Y) + _m(Z)) @@ -962,7 +964,11 @@ def _zalpha(colors, zs, _dscl=None): # limits of the plot # Solid near, transparent far, solid default - sats = np.clip(1 - (zs - min(zs)) / (_dscl+1e-8), 0.3, 1) + if _dscl == 0: + sats = np.ones_like(zs) + else: + sats = np.clip(1 - (zs - min(zs)) / _dscl, 0.3, 1) + # print(_dscl) # Solid near, transparent far, transparent default # sats = np.clip((max(zs)-zs)/(_dscl+1e-8), 0.3, 1) From 2458402e18fe2579fe565068b113fafc6e79505f Mon Sep 17 00:00:00 2001 From: Obliman Date: Mon, 22 Aug 2022 11:53:57 -0500 Subject: [PATCH 08/16] Update art3d.py per reviewer feedback Fixed some word wrapping, made get_data_scale more compact, replaced _dscl with the more descriptive _data_scale, removed redundant comments, added clearer comments for implementation of shading modes --- lib/mpl_toolkits/mplot3d/art3d.py | 72 +++++++++++++++++++------------ 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 48f4f93a09c7..3635640b11f8 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -490,16 +490,14 @@ def get_edgecolor(self): def _get_data_scale(X, Y, Z): """ - Estimate the scale of the 3D data for use in depthshading + Estimate the scale of the 3D data for use in depth shading """ - def _m(data): - return np.power(max(data) - min(data), 2) - # Account for empty datasets. Assume that X Y and Z have equal lengths if len(X) == 0: return 0 - return np.sqrt(_m(X) + _m(Y) + _m(Z)) + # 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): @@ -599,7 +597,7 @@ 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._dscl = _get_data_scale(vxs, vys, vzs) + 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 @@ -630,16 +628,14 @@ def _maybe_depth_shade_and_sort_colors(self, 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, _dscl=self._dscl) + color_array = _zalpha(color_array, self._vzs, + _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] - # The correct alpha is already accounted for in the color_array - # Passing the self._alpha value in overwrites the automatically - # adjusted alpha return mcolors.to_rgba_array(color_array) def get_facecolor(self): @@ -939,13 +935,13 @@ def rotate_axes(xs, ys, zs, zdir): return xs, ys, zs -def _zalpha(colors, zs, _dscl=None): +def _zalpha(colors, zs, _data_scale=None, shading_mode=0): """Modify the alphas of the color list according to depth.""" - # Return empty if given empty + if len(colors) == 0 or len(zs) == 0: return np.zeros((0, 4)) - if _dscl is None: + if _data_scale is None: # 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. @@ -955,31 +951,51 @@ def _zalpha(colors, zs, _dscl=None): 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 + # closer points are opaque and the furthest points are still visible, + # but transparent sats = 1 - norm(zs) * 0.7 else: # Improved normalization using a scale value derived from the XYZ # limits of the plot - # Solid near, transparent far, solid default - if _dscl == 0: + 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: - sats = np.clip(1 - (zs - min(zs)) / _dscl, 0.3, 1) - # print(_dscl) - # Solid near, transparent far, transparent default - # sats = np.clip((max(zs)-zs)/(_dscl+1e-8), 0.3, 1) - - # Transparent near, solid far, solid default - # sats = np.clip(1-(max(zs)-zs)/(_dscl+1e-8), 0.3, 1) + else: + if shading_mode == 0: + # This is the mode that most closely matches the behavior + # when _data_scale = None + + # Shallower points have an increasingly solid appearance + # Deeper points have an increasingly transparent appearance + # Points with uniform depth have a solid appearance + sats = np.clip(1 - (zs - min(zs)) / _data_scale, 0.3, 1) + + elif shading_mode == 1: + # Shallower points have an increasingly solid appearance + # Deeper points have an increasingly transparent appearance + # Points with uniform depth have a transparent appearance + sats = np.clip((max(zs) - zs) / _data_scale, 0.3, 1) + + elif shading_mode == 2: + # Shallower points have an increasingly transparent appearance + # Deeper points have an increasingly solid appearance + # Points with uniform depth have a solid appearance + sats = np.clip(1 - (max(zs) - zs) / _data_scale, 0.3, 1) + + elif shading_mode == 3: + # Shallower points have an increasingly transparent appearance + # Deeper points have an increasingly solid appearance + # Points with uniform depth have a transparent appearance + sats = np.clip((zs - min(zs)) / _data_scale, 0.3, 1) - # Transparent near, solid far, transparent default - # sats = np.clip((zs-min(zs))/(_dscl+1e-8), 0.3, 1) + else: + raise NotImplementedError( + 'Specified "shading_mode" must be 0 (default), 1, 2, or 3') - # Restructure colors into a rgba numpy array rgba = np.broadcast_to(mcolors.to_rgba_array(colors), (len(zs), 4)) # Change the alpha values of the colors using the generated alpha From 52ad303d910a425193a3c64ce5e6980db60c749b Mon Sep 17 00:00:00 2001 From: Nathan Hansen <9289200+nhansendev@users.noreply.github.com> Date: Wed, 29 Nov 2023 01:49:22 -0600 Subject: [PATCH 09/16] Improvements for depth-shading --- lib/mpl_toolkits/mplot3d/art3d.py | 217 +++++++++++++++++++++++------- 1 file changed, 167 insertions(+), 50 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 364916876069..817540f65ae0 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -536,7 +536,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* @@ -547,18 +557,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. @@ -567,8 +605,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): @@ -618,7 +665,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 ) @@ -647,15 +700,25 @@ def _get_data_scale(X, Y, Z): 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) - + 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* @@ -666,11 +729,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) @@ -744,7 +820,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. @@ -753,8 +844,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 do_3d_projection(self): @@ -809,8 +909,14 @@ def _maybe_depth_shade_and_sort_colors(self, 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, - _data_scale=self._data_scale) + 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 @@ -831,7 +937,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). @@ -846,6 +960,12 @@ def patch_collection_2d_to_3d(col, zs=0, zdir='z', depthshade=True): See `.get_dir_vector` for a description of the values. 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): @@ -854,6 +974,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) @@ -1174,16 +1297,29 @@ def rotate_axes(xs, ys, zs, zdir): return xs, ys, zs -def _zalpha(colors, zs, _data_scale=None, shading_mode=0): +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.""" if len(colors) == 0 or len(zs) == 0: return np.zeros((0, 4)) - if _data_scale is None: - # 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. + # 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 @@ -1192,7 +1328,10 @@ def _zalpha(colors, zs, _data_scale=None, shading_mode=0): # Generate alpha multipliers using the normalized z-depths so that # closer points are opaque and the furthest points are still visible, # but transparent - sats = 1 - norm(zs) * 0.7 + 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 @@ -1204,36 +1343,14 @@ def _zalpha(colors, zs, _data_scale=None, shading_mode=0): sats = np.ones_like(zs) else: - if shading_mode == 0: - # This is the mode that most closely matches the behavior - # when _data_scale = None - - # Shallower points have an increasingly solid appearance - # Deeper points have an increasingly transparent appearance - # Points with uniform depth have a solid appearance - sats = np.clip(1 - (zs - min(zs)) / _data_scale, 0.3, 1) - - elif shading_mode == 1: - # Shallower points have an increasingly solid appearance - # Deeper points have an increasingly transparent appearance - # Points with uniform depth have a transparent appearance - sats = np.clip((max(zs) - zs) / _data_scale, 0.3, 1) - - elif shading_mode == 2: - # Shallower points have an increasingly transparent appearance - # Deeper points have an increasingly solid appearance - # Points with uniform depth have a solid appearance - sats = np.clip(1 - (max(zs) - zs) / _data_scale, 0.3, 1) - - elif shading_mode == 3: - # Shallower points have an increasingly transparent appearance + if inverted: # Deeper points have an increasingly solid appearance - # Points with uniform depth have a transparent appearance - sats = np.clip((zs - min(zs)) / _data_scale, 0.3, 1) - + sats = np.clip(1 - (max(zs) - zs) / _data_scale, min_alpha, 1) else: - raise NotImplementedError( - 'Specified "shading_mode" must be 0 (default), 1, 2, or 3') + # 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)) From 968047b589b96beadb4868f9db5f31f6bc54d0e8 Mon Sep 17 00:00:00 2001 From: Nathan Hansen <9289200+nhansendev@users.noreply.github.com> Date: Wed, 29 Nov 2023 02:12:57 -0600 Subject: [PATCH 10/16] Added kwargs for depth-shading --- lib/mpl_toolkits/mplot3d/axes3d.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 84bbd763f882..b01ffdabbb3e 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -2614,6 +2614,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. @@ -2650,6 +2653,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 @@ -2677,8 +2690,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) From 8657a07f03f9ab24ebe2445299816d988ab5493d Mon Sep 17 00:00:00 2001 From: Nathan Hansen <9289200+nhansendev@users.noreply.github.com> Date: Wed, 29 Nov 2023 02:29:16 -0600 Subject: [PATCH 11/16] Create depthshading_improvement.rst --- .../depthshading_improvement.rst | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 doc/users/next_whats_new/depthshading_improvement.rst 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..e3f79c7579cb --- /dev/null +++ b/doc/users/next_whats_new/depthshading_improvement.rst @@ -0,0 +1,51 @@ +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:: + 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() + From b32d00b72eaaf85e3b34ca4527169be4ada6db4a Mon Sep 17 00:00:00 2001 From: Nathan Hansen <9289200+nhansendev@users.noreply.github.com> Date: Sun, 17 Dec 2023 17:20:22 -0600 Subject: [PATCH 12/16] Update depthshading_improvement.rst --- doc/users/next_whats_new/depthshading_improvement.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/users/next_whats_new/depthshading_improvement.rst b/doc/users/next_whats_new/depthshading_improvement.rst index e3f79c7579cb..d96d6aedae73 100644 --- a/doc/users/next_whats_new/depthshading_improvement.rst +++ b/doc/users/next_whats_new/depthshading_improvement.rst @@ -26,6 +26,9 @@ 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() From 25d0e225c418d55061e828b8a8ab49cee5e3b1bd Mon Sep 17 00:00:00 2001 From: Nathan Hansen <9289200+nhansendev@users.noreply.github.com> Date: Sun, 17 Dec 2023 17:29:34 -0600 Subject: [PATCH 13/16] Update art3d.py formatting --- lib/mpl_toolkits/mplot3d/art3d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 325a0b229817..90586ac76f3a 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -706,7 +706,7 @@ def _get_data_scale(X, Y, Z): # 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): """ From cfc05aa209356d333e3e1fefe1f1b6baeed96b22 Mon Sep 17 00:00:00 2001 From: Nathan Hansen <9289200+nhansendev@users.noreply.github.com> Date: Sun, 17 Dec 2023 17:31:41 -0600 Subject: [PATCH 14/16] Update axes3d.py formatting --- lib/mpl_toolkits/mplot3d/axes3d.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 5a584a36f9e8..6c2ddf9b30e6 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -2591,9 +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, + depthshade_inverted=False, + depthshade_minalpha=0.3, + depthshade_legacy=False, *args, **kwargs): """ Create a scatter plot. @@ -2639,7 +2639,7 @@ def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=True, depthshade_legacy : bool, default: False Whether to use the legacy algorithm for depth-shading. - + data : indexable object, optional DATA_PARAMETER_PLACEHOLDER **kwargs @@ -2675,7 +2675,7 @@ def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=True, 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) From dfb395ccfebd8944eb20469ffc1c71ff159446ca Mon Sep 17 00:00:00 2001 From: Nathan Hansen <9289200+nhansendev@users.noreply.github.com> Date: Sun, 17 Dec 2023 17:48:22 -0600 Subject: [PATCH 15/16] Update depthshading_improvement.rst formatting --- doc/users/next_whats_new/depthshading_improvement.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/users/next_whats_new/depthshading_improvement.rst b/doc/users/next_whats_new/depthshading_improvement.rst index d96d6aedae73..895441062b30 100644 --- a/doc/users/next_whats_new/depthshading_improvement.rst +++ b/doc/users/next_whats_new/depthshading_improvement.rst @@ -51,4 +51,3 @@ A simple example: ) plt.show() - From dc80ea01407c7d4c5e39118dba23efd718560442 Mon Sep 17 00:00:00 2001 From: Nathan Hansen <9289200+nhansendev@users.noreply.github.com> Date: Sun, 17 Dec 2023 17:52:17 -0600 Subject: [PATCH 16/16] Update depthshading_improvement.rst formatting