diff --git a/doc/api/next_api_changes/deprecations.rst b/doc/api/next_api_changes/deprecations.rst index f5005552cb85..8410bfaadc85 100644 --- a/doc/api/next_api_changes/deprecations.rst +++ b/doc/api/next_api_changes/deprecations.rst @@ -374,13 +374,13 @@ also be accessible as ``toolbar.parent()``. Path helpers in :mod:`.bezier` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -``bezier.make_path_regular`` is deprecated. Use ``Path.cleaned()`` (or -``Path.cleaned(curves=True)``, etc.) instead (but note that these methods add a -``STOP`` code at the end of the path). - -``bezier.concatenate_paths`` is deprecated. Use ``Path.make_compound_path()`` -instead. +- ``bezier.make_path_regular`` is deprecated. Use ``Path.cleaned()`` (or + ``Path.cleaned(curves=True)``, etc.) instead (but note that these methods add + a ``STOP`` code at the end of the path). +- ``bezier.concatenate_paths`` is deprecated. Use ``Path.make_compound_path()`` + instead. +- ``bezier.split_path_inout`` (use ``Path.split_path_inout`` instead) +- ``bezier.inside_circle()`` (no replacement) ``animation.html_args`` rcParam ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/api/next_api_changes/removals.rst b/doc/api/next_api_changes/removals.rst index c2dab6f14c5f..8d50ae6e54b8 100644 --- a/doc/api/next_api_changes/removals.rst +++ b/doc/api/next_api_changes/removals.rst @@ -103,7 +103,7 @@ Classes, methods and attributes - ``image.BboxImage.interp_at_native`` property (no replacement) - ``lines.Line2D.verticalOffset`` property (no replacement) -- ``bezier.find_r_to_boundary_of_closedpath()`` (no relacement) +- ``bezier.find_r_to_boundary_of_closedpath()`` (no replacement) - ``quiver.Quiver.color()`` (use ``Quiver.get_facecolor()`` instead) - ``quiver.Quiver.keyvec`` property (no replacement) diff --git a/doc/users/next_whats_new/2020-03-16_markerstyle_normalization.rst b/doc/users/next_whats_new/2020-03-16_markerstyle_normalization.rst new file mode 100644 index 000000000000..dce8f34e67bf --- /dev/null +++ b/doc/users/next_whats_new/2020-03-16_markerstyle_normalization.rst @@ -0,0 +1,12 @@ +Allow for custom marker scaling +------------------------------- +`~.markers.MarkerStyle` gained a keyword argument *normalization*, which may be +set to *"none"* to allow for custom paths to not be scaled.:: + + MarkerStyle(Path(...), normalization="none") + +`~.markers.MarkerStyle` also gained a `~.markers.MarkerStyle.set_transform` +method to set affine transformations to existing markers.:: + + m = MarkerStyle("d") + m.set_transform(m.get_transform() + Affine2D().rotate_deg(30)) diff --git a/examples/lines_bars_and_markers/scatter_piecharts.py b/examples/lines_bars_and_markers/scatter_piecharts.py index 6b2b4aa88824..b24f5fd2af8a 100644 --- a/examples/lines_bars_and_markers/scatter_piecharts.py +++ b/examples/lines_bars_and_markers/scatter_piecharts.py @@ -3,15 +3,19 @@ Scatter plot with pie chart markers =================================== -This example makes custom 'pie charts' as the markers for a scatter plot. - -Thanks to Manuel Metz for the example. +This example shows two methods to make custom 'pie charts' as the markers +for a scatter plot. """ +########################################################################## +# Manually creating marker vertices +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# + import numpy as np import matplotlib.pyplot as plt -# first define the ratios +# first define the cumulative ratios r1 = 0.2 # 20% r2 = r1 + 0.4 # 40% @@ -36,10 +40,55 @@ s3 = np.abs(xy3).max() fig, ax = plt.subplots() -ax.scatter(range(3), range(3), marker=xy1, s=s1**2 * sizes, facecolor='blue') -ax.scatter(range(3), range(3), marker=xy2, s=s2**2 * sizes, facecolor='green') -ax.scatter(range(3), range(3), marker=xy3, s=s3**2 * sizes, facecolor='red') +ax.scatter(range(3), range(3), marker=xy1, s=s1**2 * sizes, facecolor='C0') +ax.scatter(range(3), range(3), marker=xy2, s=s2**2 * sizes, facecolor='C1') +ax.scatter(range(3), range(3), marker=xy3, s=s3**2 * sizes, facecolor='C2') + +plt.show() + + +########################################################################## +# Using wedges as markers +# ~~~~~~~~~~~~~~~~~~~~~~~ +# +# An alternative is to create custom markers from the `~.path.Path` of a +# `~.patches.Wedge`, which might be more versatile. +# + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.patches import Wedge +from matplotlib.markers import MarkerStyle + +# first define the ratios +r1 = 0.2 # 20% +r2 = r1 + 0.3 # 50% +r3 = 1 - r1 - r2 # 30% + + +def markers_from_ratios(ratios, width=1): + markers = [] + angles = 360*np.concatenate(([0], np.cumsum(ratios))) + for i in range(len(angles)-1): + # create a Wedge within the unit square in between the given angles... + w = Wedge((0, 0), 0.5, angles[i], angles[i+1], width=width/2) + # ... and create a custom Marker from its path. + markers.append(MarkerStyle(w.get_path(), normalization="none")) + return markers + +# define some sizes of the scatter marker +sizes = np.array([100, 200, 400, 800]) +# collect the markers and some colors +markers = markers_from_ratios([r1, r2, r3], width=0.6) +colors = plt.cm.tab10.colors[:len(markers)] + +fig, ax = plt.subplots() + +for marker, color in zip(markers, colors): + ax.scatter(range(len(sizes)), range(len(sizes)), marker=marker, s=sizes, + edgecolor="none", facecolor=color) +ax.margins(0.1) plt.show() ############################################################################# @@ -55,3 +104,5 @@ import matplotlib matplotlib.axes.Axes.scatter matplotlib.pyplot.scatter +matplotlib.patches.Wedge +matplotlib.markers.MarkerStyle diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index d82539a7f90c..ea5235db248b 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -3,16 +3,25 @@ """ import math +import warnings +from collections import deque import numpy as np import matplotlib.cbook as cbook -from matplotlib.path import Path + +# same algorithm as 3.8's math.comb +@np.vectorize +def _comb(n, k): + k = min(k, n - k) + i = np.arange(1, k + 1) + return np.prod((n + 1 - i)/i).astype(int) class NonIntersectingPathException(ValueError): pass + # some functions @@ -68,6 +77,15 @@ def get_normal_points(cx, cy, cos_t, sin_t, length): return x1, y1, x2, y2 +@cbook.deprecated("3.3", alternative="Path.split_path_inout()") +def split_path_inout(path, inside, tolerance=0.01, reorder_inout=False): + """ + Divide a path into two segments at the point where ``inside(x, y)`` + becomes False. + """ + return path.split_path_inout(inside, tolerance, reorder_inout) + + # BEZIER routines # subdividing bezier curve @@ -168,26 +186,379 @@ def find_bezier_t_intersecting_with_closedpath( class BezierSegment: """ - A D-dimensional Bezier segment. + A d-dimensional Bezier segment. Parameters ---------- - control_points : (N, D) array + control_points : (N, d) array Location of the *N* control points. """ def __init__(self, control_points): - n = len(control_points) - self._orders = np.arange(n) - coeff = [math.factorial(n - 1) - // (math.factorial(i) * math.factorial(n - 1 - i)) - for i in range(n)] - self._px = np.asarray(control_points).T * coeff + self._cpoints = np.asarray(control_points) + self._N, self._d = self._cpoints.shape + self._orders = np.arange(self._N) + coeff = [math.factorial(self._N - 1) + // (math.factorial(i) * math.factorial(self._N - 1 - i)) + for i in range(self._N)] + self._px = (self._cpoints.T * coeff).T + + def __call__(self, t): + t = np.array(t) + orders_shape = (1,)*t.ndim + self._orders.shape + t_shape = t.shape + (1,) # self._orders.ndim == 1 + orders = np.reshape(self._orders, orders_shape) + rev_orders = np.reshape(self._orders[::-1], orders_shape) + t = np.reshape(t, t_shape) + return ((1 - t)**rev_orders * t**orders) @ self._px def point_at_t(self, t): """Return the point on the Bezier curve for parameter *t*.""" - return tuple( - self._px @ (((1 - t) ** self._orders)[::-1] * t ** self._orders)) + return tuple(self(t)) + + def split_at_t(self, t): + """Split into two Bezier curves using de casteljau's algorithm. + + Parameters + ---------- + t : float + Point in [0,1] at which to split into two curves + + Returns + ------- + B1, B2 : BezierSegment + The two sub-curves. + """ + new_cpoints = split_de_casteljau(self._cpoints, t) + return BezierSegment(new_cpoints[0]), BezierSegment(new_cpoints[1]) + + def control_net_length(self): + """Sum of lengths between control points""" + L = 0 + N, d = self._cpoints.shape + for i in range(N - 1): + L += np.linalg.norm(self._cpoints[i+1] - self._cpoints[i]) + return L + + def arc_length(self, rtol=None, atol=None): + """Estimate the length using iterative refinement. + + Our estimate is just the average between the length of the chord and + the length of the control net. + + Since the chord length and control net give lower and upper bounds + (respectively) on the length, this maximum possible error is tested + against an absolute tolerance threshold at each subdivision. + + However, sometimes this estimator converges much faster than this error + esimate would suggest. Therefore, the relative change in the length + estimate between subdivisions is compared to a relative error tolerance + after each set of subdivisions. + + Parameters + ---------- + rtol : float, default 1e-4 + If :code:`abs(est[i+1] - est[i]) <= rtol * est[i+1]`, we return + :code:`est[i+1]`. + atol : float, default 1e-6 + If the distance between chord length and control length at any + point falls below this number, iteration is terminated. + """ + if rtol is None: + rtol = 1e-4 + if atol is None: + atol = 1e-6 + + chord = np.linalg.norm(self._cpoints[-1] - self._cpoints[0]) + net = self.control_net_length() + max_err = (net - chord)/2 + curr_est = chord + max_err + # early exit so we don't try to "split" paths of zero length + if max_err < atol: + return curr_est + + prev_est = np.inf + curves = deque([self]) + errs = deque([max_err]) + lengths = deque([curr_est]) + while np.abs(curr_est - prev_est) > rtol * curr_est: + # subdivide the *whole* curve before checking relative convergence + # again + prev_est = curr_est + num_curves = len(curves) + for i in range(num_curves): + curve = curves.popleft() + new_curves = curve.split_at_t(0.5) + max_err -= errs.popleft() + curr_est -= lengths.popleft() + for ncurve in new_curves: + chord = np.linalg.norm( + ncurve._cpoints[-1] - ncurve._cpoints[0]) + net = ncurve.control_net_length() + nerr = (net - chord)/2 + nlength = chord + nerr + max_err += nerr + curr_est += nlength + curves.append(ncurve) + errs.append(nerr) + lengths.append(nlength) + if max_err < atol: + return curr_est + return curr_est + + def arc_center_of_mass(self): + r""" + Center of mass of the (even-odd-rendered) area swept out by the ray + from the origin to the path. + + Summing this vector for each segment along a closed path will produce + that area's center of mass. + + Returns + ------- + r_cm : (2,) np.array + the "arc's center of mass" + + Notes + ----- + A simple analytical form can be derived for general Bezier curves. + Suppose the curve was closed, so :math:`B(0) = B(1)`. Call the area + enclosed by :math:`B(t)` :math:`B_\text{int}`. The center of mass of + :math:`B_\text{int}` is defined by the expected value of the position + vector `\vec{r}` + + .. math:: + + \vec{R}_\text{cm} = \int_{B_\text{int}} \vec{r} \left( \frac{1}{ + \int_{B_\text{int}}} d\vec{r} \right) d\vec{r} + + where :math:`(1/\text{Area}(B_\text{int})` can be interpreted as a + probability density. + + In order to compute this integral, we choose two functions + :math:`F_0(x,y) = [x^2/2, 0]` and :math:`F_1(x,y) = [0, y^2/2]` such + that :math:`[\div \cdot F_0, \div \cdot F_1] = \vec{r}`. Then, applying + the divergence integral (componentwise), we get that + + .. math:: + \vec{R}_\text{cm} &= \oint_{B(t)} F \cdot \vec{n} dt \\ + &= \int_0^1 \left[ \begin{array}{1} + B^{(0)}(t) \frac{dB^{(1)}(t)}{dt} \\ + - B^{(1)}(t) \frac{dB^{(0)}(t)}{dt} \end{array} \right] dt + + After expanding in Berstein polynomials and moving the integral inside + all the sums, we get that + + .. math:: + \vec{R}_\text{cm} = \frac{1}{6} \sum_{i,j=0}^n\sum_{k=0}^{n-1} + \frac{{n \choose i}{n \choose j}{{n-1} \choose k}} + {{3n - 1} \choose {i + j + k}} + \left(\begin{array}{1} + P^{(0)}_i P^{(0)}_j (P^{(1)}_{k+1} - P^{(1)}_k) + - P^{(1)}_i P^{(1)}_j (P^{(0)}_{k+1} - P^{(0)}_k) + \right) \end{array} + + where :math:`P_i = [P^{(0)}_i, P^{(1)}_i]` is the :math:`i`'th control + point of the curve and :math:`n` is the degree of the curve. + """ + n = self.degree + r_cm = np.zeros(2) + P = self.control_points + dP = np.diff(P, axis=0) + Pn = np.array([[1, -1]])*dP[:, ::-1] # n = [y, -x] + for i in range(n + 1): + for j in range(n + 1): + for k in range(n): + r_cm += _comb(n, i) * _comb(n, j) * _comb(n - 1, k) \ + * P[i]*P[j]*Pn[k] / _comb(3*n - 1, i + j + k) + return r_cm/6 + + def arc_area(self): + r""" + (Signed) area swept out by ray from origin to curve. + + Notes + ----- + A simple, analytical formula is possible for arbitrary bezier curves. + + Given a bezier curve B(t), in order to calculate the area of the arc + swept out by the ray from the origin to the curve, we simply need to + compute :math:`\frac{1}{2}\int_0^1 B(t) \cdot n(t) dt`, where + :math:`n(t) = u^{(1)}(t) \hat{x}_0 - u{(0)}(t) \hat{x}_1` is the normal + vector oriented away from the origin and :math:`u^{(i)}(t) = + \frac{d}{dt} B^{(i)}(t)` is the :math:`i`th component of the curve's + tangent vector. (This formula can be found by applying the divergence + theorem to :math:`F(x,y) = [x, y]/2`, and calculates the *signed* area + for a counter-clockwise curve, by the right hand rule). + + The control points of the curve are just its coefficients in a + Bernstein expansion, so if we let :math:`P_i = [P^{(0)}_i, P^{(1)}_i]` + be the :math:`i`'th control point, then + + .. math:: + + \frac{1}{2}\int_0^1 B(t) \cdot n(t) dt + &= \frac{1}{2}\int_0^1 B^{(0)}(t) \frac{d}{dt} B^{(1)}(t) + - B^{(1)}(t) \frac{d}{dt} B^{(0)}(t) + dt \\ + &= \frac{1}{2}\int_0^1 + \left( \sum_{j=0}^n P_j^{(0)} b_{j,n} \right) + \left( n \sum_{k=0}^{n-1} (P_{k+1}^{(1)} - + P_{k}^{(1)}) b_{j,n} \right) + \\ + &\hspace{1em} - \left( \sum_{j=0}^n P_j^{(1)} b_{j,n} + \right) \left( n \sum_{k=0}^{n-1} (P_{k+1}^{(0)} + - P_{k}^{(0)}) b_{j,n} \right) + dt, + + where :math:`b_{\nu, n}(t) = {n \choose \nu} t^\nu {(1 - t)}^{n-\nu}` + is the :math:`\nu`'th Bernstein polynomial of degree :math:`n`. + + Grouping :math:`t^l(1-t)^m` terms together for each :math:`l`, + :math:`m`, we get that the integrand becomes + + .. math:: + + \sum_{j=0}^n \sum_{k=0}^{n-1} + {n \choose j} {{n - 1} \choose k} + &\left[P_j^{(0)} (P_{k+1}^{(1)} - P_{k}^{(1)}) + - P_j^{(1)} (P_{k+1}^{(0)} - P_{k}^{(0)})\right] \\ + &\hspace{1em}\times{}t^{j + k} {(1 - t)}^{2n - 1 - j - k} + + or just + + .. math:: + + \sum_{j=0}^n \sum_{k=0}^{n-1} + \frac{{n \choose j} {{n - 1} \choose k}} + {{{2n - 1} \choose {j+k}}} + [P_j^{(0)} (P_{k+1}^{(1)} - P_{k}^{(1)}) + - P_j^{(1)} (P_{k+1}^{(0)} - P_{k}^{(0)})] + b_{j+k,2n-1}(t). + + Interchanging sum and integral, and using the fact that :math:`\int_0^1 + b_{\nu, n}(t) dt = \frac{1}{n + 1}`, we conclude that the + original integral can + simply be written as + + .. math:: + + \frac{1}{2}&\int_0^1 B(t) \cdot n(t) dt + \\ + &= \frac{1}{4}\sum_{j=0}^n \sum_{k=0}^{n-1} + \frac{{n \choose j} {{n - 1} \choose k}} + {{{2n - 1} \choose {j+k}}} + [P_j^{(0)} (P_{k+1}^{(1)} - P_{k}^{(1)}) + - P_j^{(1)} (P_{k+1}^{(0)} - P_{k}^{(0)})] + """ + n = self.degree + area = 0 + P = self.control_points + dP = np.diff(P, axis=0) + for j in range(n + 1): + for k in range(n): + area += _comb(n, j)*_comb(n-1, k)/_comb(2*n - 1, j + k) \ + * (P[j, 0]*dP[k, 1] - P[j, 1]*dP[k, 0]) + return (1/4)*area + + def center_of_mass(self): + """Return the center of mass of the curve (not the filled curve!) + + Notes + ----- + Computed as the mean of the control points. + """ + return np.mean(self._cpoints, axis=0) + + @classmethod + def differentiate(cls, B): + """Return the derivative of a BezierSegment, itself a BezierSegment""" + dcontrol_points = B.degree*np.diff(B.control_points, axis=0) + return cls(dcontrol_points) + + @property + def control_points(self): + """The control points of the curve.""" + return self._cpoints + + @property + def dimension(self): + """The dimension of the curve.""" + return self._d + + @property + def degree(self): + """The number of control points in the curve.""" + return self._N - 1 + + @property + def polynomial_coefficients(self): + r"""The polynomial coefficients of the Bezier curve. + + Returns + ------- + coefs : float, (n+1, d) array_like + Coefficients after expanding in polynomial basis, where :math:`n` + is the degree of the bezier curve and :math:`d` its dimension. + These are the numbers (:math:`C_j`) such that the curve can be + written :math:`\sum_{j=0}^n C_j t^j`. + + Notes + ----- + The coefficients are calculated as + + .. math:: + + {n \choose j} \sum_{i=0}^j (-1)^{i+j} {j \choose i} P_i + + where :math:`P_i` are the control points of the curve. + """ + n = self.degree + # matplotlib uses n <= 4. overflow plausible starting around n = 15. + if n > 10: + warnings.warn("Polynomial coefficients formula unstable for high " + "order Bezier curves!", RuntimeWarning) + d = self.dimension + P = self.control_points + coefs = np.zeros((n+1, d)) + for j in range(n+1): + i = np.arange(j+1) + prefactor = np.power(-1, i + j) * _comb(j, i) + prefactor = np.tile(prefactor, (d, 1)).T + coefs[j] = _comb(n, j) * np.sum(prefactor*P[i], axis=0) + return coefs + + @property + def axis_aligned_extrema(self): + """ + Return the location along the curve's interior where its partial + derivative is zero, along with the dimension along which it is zero for + each such instance. + + Returns + ------- + dims : int, array_like + dimension :math:`i` along which the corresponding zero occurs + dzeros : float, array_like + of same size as dims. the :math:`t` such that :math:`d/dx_i B(t) = + 0` + """ + n = self.degree + Cj = self.polynomial_coefficients + # much faster than .differentiate(self).polynomial_coefficients + dCj = np.atleast_2d(np.arange(1, n+1)).T * Cj[1:] + if len(dCj) == 0: + return np.array([]), np.array([]) + dims = [] + roots = [] + for i, pi in enumerate(dCj.T): + r = np.roots(pi[::-1]) + roots.append(r) + dims.append(i*np.ones_like(r)) + roots = np.concatenate(roots) + dims = np.concatenate(dims) + in_range = np.isreal(roots) & (roots >= 0) & (roots <= 1) + return dims[in_range], np.real(roots)[in_range] def split_bezier_intersecting_with_closedpath( @@ -222,69 +593,7 @@ def split_bezier_intersecting_with_closedpath( return _left, _right -# matplotlib specific - - -def split_path_inout(path, inside, tolerance=0.01, reorder_inout=False): - """ - Divide a path into two segments at the point where ``inside(x, y)`` becomes - False. - """ - path_iter = path.iter_segments() - - ctl_points, command = next(path_iter) - begin_inside = inside(ctl_points[-2:]) # true if begin point is inside - - ctl_points_old = ctl_points - - iold = 0 - i = 1 - - for ctl_points, command in path_iter: - iold = i - i += len(ctl_points) // 2 - if inside(ctl_points[-2:]) != begin_inside: - bezier_path = np.concatenate([ctl_points_old[-2:], ctl_points]) - break - ctl_points_old = ctl_points - else: - raise ValueError("The path does not intersect with the patch") - - bp = bezier_path.reshape((-1, 2)) - left, right = split_bezier_intersecting_with_closedpath( - bp, inside, tolerance) - if len(left) == 2: - codes_left = [Path.LINETO] - codes_right = [Path.MOVETO, Path.LINETO] - elif len(left) == 3: - codes_left = [Path.CURVE3, Path.CURVE3] - codes_right = [Path.MOVETO, Path.CURVE3, Path.CURVE3] - elif len(left) == 4: - codes_left = [Path.CURVE4, Path.CURVE4, Path.CURVE4] - codes_right = [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4] - else: - raise AssertionError("This should never be reached") - - verts_left = left[1:] - verts_right = right[:] - - if path.codes is None: - path_in = Path(np.concatenate([path.vertices[:i], verts_left])) - path_out = Path(np.concatenate([verts_right, path.vertices[i:]])) - - else: - path_in = Path(np.concatenate([path.vertices[:iold], verts_left]), - np.concatenate([path.codes[:iold], codes_left])) - - path_out = Path(np.concatenate([verts_right, path.vertices[i:]]), - np.concatenate([codes_right, path.codes[i:]])) - - if reorder_inout and not begin_inside: - path_in, path_out = path_out, path_in - - return path_in, path_out - - +@cbook.deprecated("3.3") def inside_circle(cx, cy, r): """ Return a function that checks whether a point is in a circle with center @@ -294,16 +603,13 @@ def inside_circle(cx, cy, r): f(xy: Tuple[float, float]) -> bool """ - r2 = r ** 2 - - def _f(xy): - x, y = xy - return (x - cx) ** 2 + (y - cy) ** 2 < r2 - return _f + from .patches import _inside_circle + return _inside_circle(cx, cy, r) # quadratic Bezier lines + def get_cos_sin(x0, y0, x1, y1): dx, dy = x1 - x0, y1 - y0 d = (dx * dx + dy * dy) ** .5 @@ -486,6 +792,7 @@ def make_path_regular(p): with ``codes`` set to (MOVETO, LINETO, LINETO, ..., LINETO); otherwise return *p* itself. """ + from .path import Path c = p.codes if c is None: c = np.full(len(p.vertices), Path.LINETO, dtype=Path.code_type) @@ -498,6 +805,5 @@ def make_path_regular(p): @cbook.deprecated("3.3", alternative="Path.make_compound_path()") def concatenate_paths(paths): """Concatenate a list of paths into a single path.""" - vertices = np.concatenate([p.vertices for p in paths]) - codes = np.concatenate([make_path_regular(p).codes for p in paths]) - return Path(vertices, codes) + from .path import Path + return Path.make_compound_path(*paths) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index ccbdce01116b..447210bc6614 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -143,6 +143,11 @@ _empty_path = Path(np.empty((0, 2))) +normalization_options = ["none", "classic", "bbox", "bbox-width", "bbox-area", + "area"] +centering_options = ["none", "classic", "bbox", "mass"] + + class MarkerStyle: markers = { @@ -201,7 +206,8 @@ class MarkerStyle: # TODO: Is this ever used as a non-constant? _point_size_reduction = 0.5 - def __init__(self, marker=None, fillstyle=None): + def __init__(self, marker=None, fillstyle=None, *, + normalization="classic", centering="classic"): """ Attributes ---------- @@ -213,12 +219,37 @@ def __init__(self, marker=None, fillstyle=None): Parameters ---------- - marker : str or array-like, optional, default: None + marker : str, array-like, `~.path.Path`, or `~.markers.MarkerStyle`, \ + default: None See the descriptions of possible markers in the module docstring. fillstyle : str, optional, default: 'full' 'full', 'left", 'right', 'bottom', 'top', 'none' + + normalization : str, optional, default: "classic" + The normalization of the marker size. Can take several values: + *'classic'*, being the default, makes sure custom marker paths are + normalized to fit within a unit-square by affine scaling (but + leaves built-in markers as-is). + *'bbox-width'*, ensure marker path fits in the unit square. + *'area'*, rescale so the marker path has unit "signed_area". + *'bbox-area'*, rescale so that the marker path's bbox has unit + area. + *'none'*, in which case no scaling is performed on the marker path. + + centering : str, optional, default: "classic" + The centering of the marker. Can take several values: + *'none'*, being the default, does not translate the marker path. + The origin in path coordinates is the marker center in this case. + *'bbox'*, translates the marker path so that its bbox's center is + at the origin. + *'center-of-mass'*, translates the marker path so that its center + of mass it as the origin. See Path.center_of_mass for details. """ + cbook._check_in_list(["classic", "none"], normalization=normalization) + cbook._check_in_list(["centering", "none"], centering=centering) + self._normalize = normalization + self._center = centering self._marker_function = None self.set_fillstyle(fillstyle) self.set_marker(marker) @@ -303,6 +334,20 @@ def get_path(self): def get_transform(self): return self._transform.frozen() + def set_transform(self, transform): + """ + Sets the transform of the marker. This is the transform by which the + marker path is transformed. + + In order to change the marker relative to its current state, make sure + to compose with the current transform. Remember that the transform on + the left of the addition side is applied first. For example: + + >>> spin = mpl.transforms.Affine2D().rotate_deg(90) + >>> marker.set_transform(marker.get_transform() + spin) + """ + self._transform = transform + def get_alt_path(self): return self._alt_path @@ -316,8 +361,9 @@ def _set_nothing(self): self._filled = False def _set_custom_marker(self, path): - rescale = np.max(np.abs(path.vertices)) # max of x's and y's. - self._transform = Affine2D().scale(0.5 / rescale) + if self._normalize == "classic": + rescale = np.max(np.abs(path.vertices)) # max of x's and y's. + self._transform = Affine2D().scale(0.5 / rescale) self._path = path def _set_path_marker(self): @@ -350,8 +396,6 @@ def _set_tuple_marker(self): def _set_mathtext_path(self): """ Draws mathtext markers '$...$' using TextPath object. - - Submitted by tcb """ from matplotlib.text import TextPath diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index dd99e16c0c7e..771111d98081 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -10,12 +10,28 @@ import matplotlib as mpl from . import artist, cbook, colors, docstring, lines as mlines, transforms from .bezier import ( - NonIntersectingPathException, get_cos_sin, get_intersection, - get_parallels, inside_circle, make_wedged_bezier2, - split_bezier_intersecting_with_closedpath, split_path_inout) + NonIntersectingPathException, get_cos_sin, get_intersection, get_parallels, + make_wedged_bezier2, split_bezier_intersecting_with_closedpath) from .path import Path +def _inside_circle(cx, cy, r): + """ + Return a function that checks whether a point is in a circle with center + (*cx*, *cy*) and radius *r*. + + The returned function has the signature:: + + f(xy: Tuple[float, float]) -> bool + """ + r2 = r ** 2 + + def _f(xy): + x, y = xy + return (x - cx) ** 2 + (y - cy) ** 2 < r2 + return _f + + @cbook._define_aliases({ "antialiased": ["aa"], "edgecolor": ["ec"], @@ -2414,7 +2430,7 @@ def insideA(xy_display): return patchA.contains(xy_event)[0] try: - left, right = split_path_inout(path, insideA) + left, right = path.split_path_inout(insideA) except ValueError: right = path @@ -2426,7 +2442,7 @@ def insideB(xy_display): return patchB.contains(xy_event)[0] try: - left, right = split_path_inout(path, insideB) + left, right = path.split_path_inout(insideB) except ValueError: left = path @@ -2439,15 +2455,15 @@ def _shrink(self, path, shrinkA, shrinkB): Shrink the path by fixed size (in points) with shrinkA and shrinkB. """ if shrinkA: - insideA = inside_circle(*path.vertices[0], shrinkA) + insideA = _inside_circle(*path.vertices[0], shrinkA) try: - left, path = split_path_inout(path, insideA) + left, path = path.split_path_inout(insideA) except ValueError: pass if shrinkB: - insideB = inside_circle(*path.vertices[-1], shrinkB) + insideB = _inside_circle(*path.vertices[-1], shrinkB) try: - path, right = split_path_inout(path, insideB) + path, right = path.split_path_inout(insideB) except ValueError: pass return path @@ -2872,7 +2888,6 @@ def __call__(self, path, mutation_size, linewidth, The __call__ method is a thin wrapper around the transmute method and takes care of the aspect ratio. """ - if aspect_ratio is not None: # Squeeze the given height by the aspect_ratio vertices = path.vertices / [1, aspect_ratio] @@ -3337,7 +3352,7 @@ def transmute(self, path, mutation_size, linewidth): # divide the path into a head and a tail head_length = self.head_length * mutation_size - in_f = inside_circle(x2, y2, head_length) + in_f = _inside_circle(x2, y2, head_length) arrow_path = [(x0, y0), (x1, y1), (x2, y2)] try: @@ -3420,7 +3435,7 @@ def transmute(self, path, mutation_size, linewidth): arrow_path = [(x0, y0), (x1, y1), (x2, y2)] # path for head - in_f = inside_circle(x2, y2, head_length) + in_f = _inside_circle(x2, y2, head_length) try: path_out, path_in = split_bezier_intersecting_with_closedpath( arrow_path, in_f, tolerance=0.01) @@ -3435,7 +3450,7 @@ def transmute(self, path, mutation_size, linewidth): path_head = path_in # path for head - in_f = inside_circle(x2, y2, head_length * .8) + in_f = _inside_circle(x2, y2, head_length * .8) path_out, path_in = split_bezier_intersecting_with_closedpath( arrow_path, in_f, tolerance=0.01) path_tail = path_out @@ -3453,7 +3468,7 @@ def transmute(self, path, mutation_size, linewidth): w1=1., wm=0.6, w2=0.3) # path for head - in_f = inside_circle(x0, y0, tail_width * .3) + in_f = _inside_circle(x0, y0, tail_width * .3) path_in, path_out = split_bezier_intersecting_with_closedpath( arrow_path, in_f, tolerance=0.01) tail_start = path_in[-1] diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index 16e77e95b45e..b13bd25bec42 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -17,6 +17,18 @@ import matplotlib as mpl from . import _path, cbook from .cbook import _to_unmasked_float_array, simple_linear_interpolation +from .bezier import BezierSegment, split_bezier_intersecting_with_closedpath + + +def _update_extents(extents, point): + dim = len(point) + for i, xi in enumerate(point): + if xi < extents[i]: + extents[i] = xi + # elif here would fail to correctly update from "null" extents of + # np.array([np.inf, np.inf, -np.inf, -np.inf]) + if extents[i+dim] < xi: + extents[i+dim] = xi class Path: @@ -420,6 +432,53 @@ def iter_segments(self, transform=None, remove_nans=True, clip=None, curr_vertices = np.append(curr_vertices, next(vertices)) yield curr_vertices, code + def iter_bezier(self, **kwargs): + """ + Iterate over each bezier curve (lines included) in a Path. + + Parameters + ---------- + kwargs : Dict[str, object] + Forwareded to iter_segments. + + Yields + ------ + B : matplotlib.bezier.BezierSegment + The bezier curves that make up the current path. Note in particular + that freestanding points are bezier curves of order 0, and lines + are bezier curves of order 1 (with two control points). + code : Path.code_type + The code describing what kind of curve is being returned. + Path.MOVETO, Path.LINETO, Path.CURVE3, Path.CURVE4 correspond to + bezier curves with 1, 2, 3, and 4 control points (respectively). + Path.CLOSEPOLY is a Path.LINETO with the control points correctly + chosen based on the start/end points of the current stroke. + """ + first_vert = None + prev_vert = None + for vertices, code in self.iter_segments(**kwargs): + if first_vert is None: + if code != Path.MOVETO: + raise ValueError("Malformed path, must start with MOVETO.") + if code == Path.MOVETO: # a point is like "CURVE1" + first_vert = vertices + yield BezierSegment(np.array([first_vert])), code + elif code == Path.LINETO: # "CURVE2" + yield BezierSegment(np.array([prev_vert, vertices])), code + elif code == Path.CURVE3: + yield BezierSegment(np.array([prev_vert, vertices[:2], + vertices[2:]])), code + elif code == Path.CURVE4: + yield BezierSegment(np.array([prev_vert, vertices[:2], + vertices[2:4], vertices[4:]])), code + elif code == Path.CLOSEPOLY: + yield BezierSegment(np.array([prev_vert, first_vert])), code + elif code == Path.STOP: + return + else: + raise ValueError("Invalid Path.code_type: " + str(code)) + prev_vert = vertices[-2:] + @cbook._delete_parameter("3.3", "quantize") def cleaned(self, transform=None, remove_nans=False, clip=None, quantize=False, simplify=False, curves=False, @@ -545,6 +604,35 @@ def get_extents(self, transform=None): transform = None return Bbox(_path.get_path_extents(path, transform)) + def get_exact_extents(self, **kwargs): + """Get size of Bbox of curve (instead of Bbox of control points). + + Parameters + ---------- + kwargs : Dict[str, object] + Forwarded to self.iter_bezier. + + Returns + ------- + extents : (4,) float, array_like + The extents of the path (xmin, ymin, xmax, ymax). + """ + maxi = 2 # [xmin, ymin, *xmax, ymax] + # return value for empty paths to match _path.h + extents = np.array([np.inf, np.inf, -np.inf, -np.inf]) + for curve, code in self.iter_bezier(**kwargs): + # start and endpoints can be extrema of the curve + _update_extents(extents, curve(0)) # start point + _update_extents(extents, curve(1)) # end point + # interior extrema where d/ds B(s) == 0 + _, dzeros = curve.axis_aligned_extrema + if len(dzeros) == 0: + continue + for zero in dzeros: + potential_extrema = curve.point_at_t(zero) + _update_extents(extents, potential_extrema) + return extents + def intersects_path(self, other, filled=True): """ Return whether if this path intersects another given path. @@ -566,6 +654,199 @@ def intersects_bbox(self, bbox, filled=True): return _path.path_intersects_rectangle( self, bbox.x0, bbox.y0, bbox.x1, bbox.y1, filled) + def length(self, rtol=None, atol=None, **kwargs): + """Get length of Path. + + Equivalent to (but not computed as) + + .. math:: + + \sum_{j=1}^N \int_0^1 ||B'_j(t)|| dt + + where the sum is over the :math:`N` Bezier curves that comprise the + Path. Notice that this measure of length will assign zero weight to all + isolated points on the Path. + + Returns + ------- + length : float + The path length. + """ + return np.sum([B.arc_length(rtol, atol) + for B, code in self.iter_bezier(**kwargs)]) + + def signed_area(self, **kwargs): + """ + Get signed area filled by path. + + All sub paths are treated as if they had been closed. That is, if there + is a MOVETO without a preceding CLOSEPOLY, one is added. + + Signed area means that if a path is self-intersecting, the drawing rule + "even-odd" is used and only the filled area is counted. + + Returns + ------- + area : float + The (signed) enclosed area of the path. + """ + area = 0 + prev_point = None + prev_code = None + start_point = None + for B, code in self.iter_bezier(**kwargs): + if code == Path.MOVETO: + if prev_code is not None and prev_code is not Path.CLOSEPOLY: + Bclose = BezierSegment(np.array([prev_point, start_point])) + area += Bclose.arc_area() + start_point = B.control_points[0] + area += B.arc_area() + prev_point = B.control_points[-1] + prev_code = code + # add final implied CLOSEPOLY, if necessary + if start_point is not None \ + and not np.all(np.isclose(start_point, prev_point)): + Bclose = BezierSegment(np.array([prev_point, start_point])) + area += Bclose.arc_area() + return area + + def center_of_mass(self, dimension=None, **kwargs): + r""" + Center of mass of the path, assuming constant density. + + The center of mass is defined to be the expected value of a vector + located uniformly within either the filled area of the path + (:code:`dimension=2`) or the along path's edge (:code:`dimension=1`) or + along isolated points of the path (:code:`dimension=0`). Notice in + particular that for this definition, if the filled area is used, then + any 0- or 1-dimensional components of the path will not contribute to + the center of mass. Similarly, for if *dimension* is 1, then isolated + points in the path (i.e. "0-dimensional" strokes made up of only + :code:`Path.MOVETO`'s) will not contribute to the center of mass. + + For the 2d case, the center of mass is computed using the same + filling strategy as `signed_area`. So, if a path is self-intersecting, + the drawing rule "even-odd" is used and only the filled area is + counted, and all sub paths are treated as if they had been closed. That + is, if there is a MOVETO without a preceding CLOSEPOLY, one is added. + + For the 1d measure, the curve is averaged as-is (the implied CLOSEPOLY + is not added). + + For the 0d measure, any non-isolated points are ignored. + + Parameters + ---------- + dimension : 2, 1, or 0 (optional) + Whether to compute the center of mass by taking the expected value + of a position uniformly distributed within the filled path + (2D-measure), the path's edge (1D-measure), or between the + discrete, isolated points of the path (0D-measure), respectively. + By default, the intended dimension of the path is inferred by + checking first if `Path.signed_area` is non-zero (implying a + *dimension* of 2), then if the `Path.arc_length` is non-zero + (implying a *dimension* of 1), and finally falling back to the + counting measure (*dimension* of 0). + kwargs : Dict[str, object] + Passed thru to `Path.cleaned` via `Path.iter_bezier`. + + Returns + ------- + r_cm : (2,) np.array + The center of mass of the path. + + Raises + ------ + ValueError + An empty path has no well-defined center of mass. + + In addition, if a specific *dimension* is requested and that + dimension is not well-defined, an error is raised. This can happen + if:: + + 1) 2D expected value was requested but the path has zero area + 2) 1D expected value was requested but the path has only + `Path.MOVETO` directives + 3) 0D expected value was requested but the path has NO + subsequent `Path.MOVETO` directives. + + This error cannot be raised if the function is allowed to infer + what *dimension* to use. + """ + area = None + cleaned = self.cleaned(**kwargs) + move_codes = cleaned.codes == Path.MOVETO + if len(cleaned.codes) == 0: + raise ValueError("An empty path has no center of mass.") + if dimension is None: + dimension = 2 + area = cleaned.signed_area() + if not np.isclose(area, 0): + dimension -= 1 + if np.all(move_codes): + dimension = 0 + if dimension == 2: + # area computation can be expensive, make sure we don't repeat it + if area is None: + area = cleaned.signed_area() + if np.isclose(area, 0): + raise ValueError("2d expected value over empty area is " + "ill-defined.") + return cleaned._2d_center_of_mass(area) + if dimension == 1: + if np.all(move_codes): + raise ValueError("1d expected value over empty arc-length is " + "ill-defined.") + return cleaned._1d_center_of_mass() + if dimension == 0: + adjacent_moves = (move_codes[1:] + move_codes[:-1]) == 2 + if len(move_codes) > 1 and not np.any(adjacent_moves): + raise ValueError("0d expected value with no isolated points " + "is ill-defined.") + return cleaned._0d_center_of_mass() + + def _2d_center_of_mass(self, normalization=None): + #TODO: refactor this and signed_area (and maybe others, with + # close= parameter)? + if normalization is None: + normalization = self.signed_area() + r_cm = np.zeros(2) + prev_point = None + prev_code = None + start_point = None + for B, code in self.iter_bezier(): + if code == Path.MOVETO: + if prev_code is not None and prev_code is not Path.CLOSEPOLY: + Bclose = BezierSegment(np.array([prev_point, start_point])) + r_cm += Bclose.arc_center_of_mass() + start_point = B.control_points[0] + r_cm += B.arc_center_of_mass() + prev_point = B.control_points[-1] + prev_code = code + # add final implied CLOSEPOLY, if necessary + if start_point is not None \ + and not np.all(np.isclose(start_point, prev_point)): + Bclose = BezierSegment(np.array([prev_point, start_point])) + r_cm += Bclose.arc_center_of_mass() + return r_cm / normalization + + def _1d_center_of_mass(self): + r_cm = np.zeros(2) + Bs = list(self.iter_bezier()) + arc_lengths = np.array([B.arc_length() for B in Bs]) + r_cms = np.array([B.center_of_mass() for B in Bs]) + total_length = np.sum(arc_lengths) + return np.sum(r_cms*arc_lengths)/total_length + + def _0d_center_of_mass(self): + move_verts = self.codes + isolated_verts = move_verts.copy() + if len(move_verts) > 1: + isolated_verts[:-1] = (move_verts[:-1] + move_verts[1:]) == 2 + isolated_verts[-1] = move_verts[-1] + num_verts = np.sum(isolated_verts) + return np.sum(self.vertices[isolated_verts], axis=0)/num_verts + def interpolated(self, steps): """ Return a new path resampled to length N x steps. @@ -585,6 +866,65 @@ def interpolated(self, steps): new_codes = None return Path(vertices, new_codes) + def split_path_inout(self, inside, tolerance=0.01, reorder_inout=False): + """ + Divide a path into two segments at the point where ``inside(x, y)`` + becomes False. + """ + path_iter = self.iter_segments() + + ctl_points, command = next(path_iter) + begin_inside = inside(ctl_points[-2:]) # true if begin point is inside + + ctl_points_old = ctl_points + + iold = 0 + i = 1 + + for ctl_points, command in path_iter: + iold = i + i += len(ctl_points) // 2 + if inside(ctl_points[-2:]) != begin_inside: + bezier_path = np.concatenate([ctl_points_old[-2:], ctl_points]) + break + ctl_points_old = ctl_points + else: + raise ValueError("The path does not intersect with the patch") + + bp = bezier_path.reshape((-1, 2)) + left, right = split_bezier_intersecting_with_closedpath( + bp, inside, tolerance) + if len(left) == 2: + codes_left = [Path.LINETO] + codes_right = [Path.MOVETO, Path.LINETO] + elif len(left) == 3: + codes_left = [Path.CURVE3, Path.CURVE3] + codes_right = [Path.MOVETO, Path.CURVE3, Path.CURVE3] + elif len(left) == 4: + codes_left = [Path.CURVE4, Path.CURVE4, Path.CURVE4] + codes_right = [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4] + else: + raise AssertionError("This should never be reached") + + verts_left = left[1:] + verts_right = right[:] + + if self.codes is None: + path_in = Path(np.concatenate([self.vertices[:i], verts_left])) + path_out = Path(np.concatenate([verts_right, self.vertices[i:]])) + + else: + path_in = Path(np.concatenate([self.vertices[:iold], verts_left]), + np.concatenate([self.codes[:iold], codes_left])) + + path_out = Path(np.concatenate([verts_right, self.vertices[i:]]), + np.concatenate([codes_right, self.codes[i:]])) + + if reorder_inout and not begin_inside: + path_in, path_out = path_out, path_in + + return path_in, path_out + def to_polygons(self, transform=None, width=0, height=0, closed_only=True): """ Convert this path to a list of polygons or polylines. Each @@ -647,7 +987,8 @@ def unit_rectangle(cls): def unit_regular_polygon(cls, numVertices): """ Return a :class:`Path` instance for a unit regular polygon with the - given *numVertices* and radius of 1.0, centered at (0, 0). + given *numVertices* such that the circumscribing circle has radius 1.0, + centered at (0, 0). """ if numVertices <= 16: path = cls._unit_regular_polygons.get(numVertices) diff --git a/lib/matplotlib/tests/test_marker.py b/lib/matplotlib/tests/test_marker.py index e50746165792..f34c27896701 100644 --- a/lib/matplotlib/tests/test_marker.py +++ b/lib/matplotlib/tests/test_marker.py @@ -2,8 +2,9 @@ import matplotlib.pyplot as plt from matplotlib import markers from matplotlib.path import Path -from matplotlib.testing.decorators import check_figures_equal +from matplotlib.transforms import Affine2D +from matplotlib.testing.decorators import check_figures_equal import pytest @@ -133,3 +134,24 @@ def draw_ref_marker(y, style, size): ax_test.set(xlim=(-0.5, 1.5), ylim=(-0.5, 1.5)) ax_ref.set(xlim=(-0.5, 1.5), ylim=(-0.5, 1.5)) + + +@check_figures_equal(extensions=["png"]) +def test_marker_normalization(fig_test, fig_ref): + plt.style.use("mpl20") + + ax = fig_ref.subplots() + ax.margins(0.3) + ax.scatter([0, 1], [0, 0], s=400, marker="s", c="C2") + + ax = fig_test.subplots() + ax.margins(0.3) + # test normalize + p = Path([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], closed=True) + p1 = p.transformed(Affine2D().translate(-.5, -.5).scale(20)) + m1 = markers.MarkerStyle(p1, normalization="none") + ax.scatter([0], [0], s=1, marker=m1, c="C2") + # test transform + m2 = markers.MarkerStyle("s") + m2.set_transform(m2.get_transform() + Affine2D().scale(20)) + ax.scatter([1], [0], s=1, marker=m2, c="C2") diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py index b61a92654dc3..9b561378c013 100644 --- a/lib/matplotlib/tests/test_path.py +++ b/lib/matplotlib/tests/test_path.py @@ -49,6 +49,34 @@ def test_contains_points_negative_radius(): np.testing.assert_equal(result, [True, False, False]) +def test_exact_extents_cubic(): + hard_curve = Path([[0, 0], [1, 0], [1, 1], [0, 1]], + [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]) + np.testing.assert_equal(hard_curve.get_exact_extents(), [0., 0., 0.75, 1.]) + + +def test_signed_area_unit_circle(): + circ = Path.unit_circle() + # not quite pi...since it's not quite a circle! + assert(np.isclose(circ.signed_area(), 3.1415935732517166)) + # now counter-clockwise + rverts = circ.vertices[-2::-1] + rverts = np.append(rverts, np.atleast_2d(circ.vertices[0]), axis=0) + rcirc = Path(rverts, circ.codes) + assert(np.isclose(rcirc.signed_area(), -3.1415935732517166)) + + +def test_length_unit_circl(): + circ = Path.unit_circle() + # not quite 2*pi...since it's not quite a circle! + assert(np.isclose(circ.length(), 6.283186229058933)) + + +def test_signed_area_unit_rectangle(): + rect = Path.unit_rectangle() + assert(np.isclose(rect.signed_area(), 1)) + + def test_point_in_path_nan(): box = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]) p = Path(box) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy