From 5f8493bc00810af2d968f6da075cc4868e13d18e Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 17 Mar 2020 15:08:05 -0700 Subject: [PATCH 1/4] correctly compute bounding box for path --- .../2020-03-31-path-size-methods.rst | 27 ++++ lib/matplotlib/bezier.py | 135 ++++++++++++++++-- lib/matplotlib/path.py | 80 +++++++++-- lib/matplotlib/tests/test_path.py | 31 ++++ lib/matplotlib/transforms.py | 4 +- 5 files changed, 253 insertions(+), 24 deletions(-) create mode 100644 doc/users/next_whats_new/2020-03-31-path-size-methods.rst diff --git a/doc/users/next_whats_new/2020-03-31-path-size-methods.rst b/doc/users/next_whats_new/2020-03-31-path-size-methods.rst new file mode 100644 index 000000000000..d2347fb3b9e5 --- /dev/null +++ b/doc/users/next_whats_new/2020-03-31-path-size-methods.rst @@ -0,0 +1,27 @@ + +Functions to compute a Path's size +---------------------------------- + +Various functions were added to `~.bezier.BezierSegment` and `~.path.Path` to +allow computation of the shape/size of a `~.path.Path` and its composite Bezier +curves. + +In addition to the fixes below, `~.bezier.BezierSegment` has gained more +documentation and usability improvements, including properties that contain its +dimension, degree, control_points, and more. + +Better interface for Path segment iteration +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +`~.path.Path.iter_bezier` iterates through the `~.bezier.BezierSegment`'s that +make up the Path. This is much more useful typically than the existing +`~.path.Path.iter_segments` function, which returns the absolute minimum amount +of information possible to reconstruct the Path. + +Fixed bug that computed a Path's Bbox incorrectly +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Historically, `~.path.Path.get_extents` has always simply returned the Bbox of +a curve's control points, instead of the Bbox of the curve itself. While this is +a correct upper bound for the path's extents, it can differ dramatically from +the Path's actual extents for non-linear Bezier curves. diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index b6e5edfeb2f1..e2ee90b59d96 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -2,12 +2,24 @@ A module providing some utility functions regarding Bezier path manipulation. """ +from functools import lru_cache import math +import warnings import numpy as np import matplotlib.cbook as cbook +# same algorithm as 3.8's math.comb +@np.vectorize +@lru_cache(maxsize=128) +def _comb(n, k): + if k > n: + return 0 + k = min(k, n - k) + i = np.arange(1, k + 1) + return np.prod((n + 1 - i)/i).astype(int) + class NonIntersectingPathException(ValueError): pass @@ -168,26 +180,127 @@ 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): + """ + Evaluate the Bezier curve at point(s) t in [0, 1]. + + Parameters + ---------- + t : float (k,), array_like + Points at which to evaluate the curve. + + Returns + ------- + float (k, d), array_like + Value of the curve for each point in *t*. + """ + t = np.asarray(t) + return (np.power.outer(1 - t, self._orders[::-1]) + * np.power.outer(t, self._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)) + """Evaluate curve at a single point *t*. Returns a Tuple[float*d].""" + return tuple(self(t)) + + @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. + + .. warning:: Follows opposite convention from `numpy.polyval`. + + Returns + ------- + 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) + P = self.control_points + j = np.arange(n+1)[:, None] + i = np.arange(n+1)[None, :] # _comb is non-zero for i <= j + prefactor = (-1)**(i + j) * _comb(j, i) # j on axis 0, i on axis 1 + return _comb(n, j) * prefactor @ P # j on axis 0, self.dimension on 1 + + def axis_aligned_extrema(self): + """ + Return the dimension and location of the curve's interior extrema. + + The extrema are the points along the curve where one of its partial + derivatives is zero. + + Returns + ------- + dims : int, array_like + Index :math:`i` of the partial derivative which is zero at each + interior extrema. + 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 + dCj = np.arange(1, n+1)[:, None] * 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(np.full_like(r, i)) + 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( diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index 9725db239960..500ab6e49477 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -17,6 +17,7 @@ import matplotlib as mpl from . import _path, cbook from .cbook import _to_unmasked_float_array, simple_linear_interpolation +from .bezier import BezierSegment class Path: @@ -421,6 +422,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 + Forwarded 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 verts, 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 = verts + yield BezierSegment(np.array([first_vert])), code + elif code == Path.LINETO: # "CURVE2" + yield BezierSegment(np.array([prev_vert, verts])), code + elif code == Path.CURVE3: + yield BezierSegment(np.array([prev_vert, verts[:2], + verts[2:]])), code + elif code == Path.CURVE4: + yield BezierSegment(np.array([prev_vert, verts[:2], + verts[2:4], verts[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 = verts[-2:] + @cbook._delete_parameter("3.3", "quantize") def cleaned(self, transform=None, remove_nans=False, clip=None, quantize=False, simplify=False, curves=False, @@ -529,22 +577,32 @@ def contains_path(self, path, transform=None): transform = transform.frozen() return _path.path_in_path(self, None, path, transform) - def get_extents(self, transform=None): + def get_extents(self, transform=None, **kwargs): """ - Return the extents (*xmin*, *ymin*, *xmax*, *ymax*) of the path. + Get Bbox of the path. - Unlike computing the extents on the *vertices* alone, this - algorithm will take into account the curves and deal with - control points appropriately. + Parameters + ---------- + transform : matplotlib.transforms.Transform, optional + Transform to apply to path before computing extents, if any. + **kwargs + Forwarded to `.iter_bezier`. + + Returns + ------- + matplotlib.transforms.Bbox + The extents of the path Bbox([[xmin, ymin], [xmax, ymax]]) """ from .transforms import Bbox - path = self if transform is not None: - transform = transform.frozen() - if not transform.is_affine: - path = self.transformed(transform) - transform = None - return Bbox(_path.get_path_extents(path, transform)) + self = transform.transform_path(self) + bbox = Bbox.null() + for curve, code in self.iter_bezier(**kwargs): + # places where the derivative is zero can be extrema + _, dzeros = curve.axis_aligned_extrema() + # as can the ends of the curve + bbox.update_from_data_xy(curve([0, *dzeros, 1]), ignore=False) + return bbox def intersects_path(self, other, filled=True): """ diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py index b61a92654dc3..2a9ccb4662b0 100644 --- a/lib/matplotlib/tests/test_path.py +++ b/lib/matplotlib/tests/test_path.py @@ -49,6 +49,37 @@ def test_contains_points_negative_radius(): np.testing.assert_equal(result, [True, False, False]) +_test_paths = [ + # interior extrema determine extents and degenerate derivative + Path([[0, 0], [1, 0], [1, 1], [0, 1]], + [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]), + # a quadratic curve + Path([[0, 0], [0, 1], [1, 0]], [Path.MOVETO, Path.CURVE3, Path.CURVE3]), + # a linear curve, degenerate vertically + Path([[0, 1], [1, 1]], [Path.MOVETO, Path.LINETO]), + # a point + Path([[1, 2]], [Path.MOVETO]), +] + + +_test_path_extents = [(0., 0., 0.75, 1.), (0., 0., 1., 0.5), (0., 1., 1., 1.), + (1., 2., 1., 2.)] + + +@pytest.mark.parametrize('path, extents', zip(_test_paths, _test_path_extents)) +def test_exact_extents(path, extents): + # notice that if we just looked at the control points to get the bounding + # box of each curve, we would get the wrong answers. For example, for + # hard_curve = Path([[0, 0], [1, 0], [1, 1], [0, 1]], + # [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]) + # we would get that the extents area (0, 0, 1, 1). This code takes into + # account the curved part of the path, which does not typically extend all + # the way out to the control points. + # Note that counterintuitively, path.get_extents() returns a Bbox, so we + # have to get that Bbox's `.extents`. + assert np.all(path.get_extents().extents == extents) + + def test_point_in_path_nan(): box = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]) p = Path(box) diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index 2a8fc834f3ff..4ea0358e15ca 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -847,8 +847,8 @@ def ignore(self, value): def update_from_path(self, path, ignore=None, updatex=True, updatey=True): """ - Update the bounds of the `Bbox` based on the passed in - data. After updating, the bounds will have positive *width* + Update the bounds of the `Bbox` to contain the vertices of the + provided path. After updating, the bounds will have positive *width* and *height*; *x0* and *y0* will be the minimal values. Parameters From 5be375ff529d57ffc07c0322df36e0dcaa1b509d Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Fri, 20 Mar 2020 01:59:28 -0700 Subject: [PATCH 2/4] add function to compute (signed) area of path --- .../2020-03-31-path-size-methods.rst | 11 +- lib/matplotlib/bezier.py | 111 +++++++++++++++++- lib/matplotlib/path.py | 51 ++++++++ lib/matplotlib/tests/test_bezier.py | 26 ++++ lib/matplotlib/tests/test_path.py | 53 ++++++--- requirements/testing/travis_all.txt | 1 + 6 files changed, 236 insertions(+), 17 deletions(-) create mode 100644 lib/matplotlib/tests/test_bezier.py diff --git a/doc/users/next_whats_new/2020-03-31-path-size-methods.rst b/doc/users/next_whats_new/2020-03-31-path-size-methods.rst index d2347fb3b9e5..a873a7e54e77 100644 --- a/doc/users/next_whats_new/2020-03-31-path-size-methods.rst +++ b/doc/users/next_whats_new/2020-03-31-path-size-methods.rst @@ -3,8 +3,8 @@ Functions to compute a Path's size ---------------------------------- Various functions were added to `~.bezier.BezierSegment` and `~.path.Path` to -allow computation of the shape/size of a `~.path.Path` and its composite Bezier -curves. +allow computation of the shape, size and area of a `~.path.Path` and its +composite Bezier curves. In addition to the fixes below, `~.bezier.BezierSegment` has gained more documentation and usability improvements, including properties that contain its @@ -25,3 +25,10 @@ Historically, `~.path.Path.get_extents` has always simply returned the Bbox of a curve's control points, instead of the Bbox of the curve itself. While this is a correct upper bound for the path's extents, it can differ dramatically from the Path's actual extents for non-linear Bezier curves. + +Path area +~~~~~~~~~ + +A `~.path.Path.signed_area` method was added to compute the signed filled area +of a Path object analytically (i.e. without integration). This should be useful +for constructing Paths of a desired area. diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index e2ee90b59d96..cd07a7312d55 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -10,8 +10,8 @@ import matplotlib.cbook as cbook + # same algorithm as 3.8's math.comb -@np.vectorize @lru_cache(maxsize=128) def _comb(n, k): if k > n: @@ -19,6 +19,7 @@ def _comb(n, k): k = min(k, n - k) i = np.arange(1, k + 1) return np.prod((n + 1 - i)/i).astype(int) +_comb = np.vectorize(_comb, otypes=[np.int]) class NonIntersectingPathException(ValueError): @@ -219,6 +220,114 @@ def point_at_t(self, t): """Evaluate curve at a single point *t*. Returns a Tuple[float*d].""" return tuple(self(t)) + @property + def arc_area(self): + r""" + Signed area swept out by ray from origin to curve. + + Counterclockwise area is counted as positive, and clockwise area as + negative. + + The sum of this function for each Bezier curve in a Path will give the + signed area enclosed by the Path. + + Returns + ------- + float + The signed area of the arc swept out by the 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 + P = self.control_points + dP = np.diff(P, axis=0) + j = np.arange(n + 1) + k = np.arange(n) + return (1/4)*np.sum( + np.multiply.outer(_comb(n, j), _comb(n - 1, k)) + / _comb(2*n - 1, np.add.outer(j, k)) + * (np.multiply.outer(P[j, 0], dP[k, 1]) - + np.multiply.outer(P[j, 1], dP[k, 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.""" diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index 500ab6e49477..2749806bdea9 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -625,6 +625,57 @@ def intersects_bbox(self, bbox, filled=True): return _path.path_intersects_rectangle( self, bbox.x0, bbox.y0, bbox.x1, bbox.y1, filled) + def signed_area(self): + """ + Get signed area of the filled path. + + Area of a filled region is treated as positive if the path encloses it + in a counter-clockwise direction, but negative if the path encloses it + moving clockwise. + + 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. + + If the path is made up of multiple components that overlap, the + overlapping area is multiply counted. + + Returns + ------- + float + The signed area enclosed by the path. + + Examples + -------- + A symmetric figure eight, (where one loop is clockwise and + the other counterclockwise) would have a total *signed_area* of zero, + since the two loops would cancel each other out. + + Notes + ----- + If the Path is not self-intersecting and has no overlapping components, + then the absolute value of the signed area is equal to the actual + filled area when the Path is drawn (e.g. as a PathPatch). + """ + area = 0 + 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])) + 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)): + B = BezierSegment(np.array([prev_point, start_point])) + area += B.arc_area + return area + def interpolated(self, steps): """ Return a new path resampled to length N x steps. diff --git a/lib/matplotlib/tests/test_bezier.py b/lib/matplotlib/tests/test_bezier.py new file mode 100644 index 000000000000..ced1362b0bec --- /dev/null +++ b/lib/matplotlib/tests/test_bezier.py @@ -0,0 +1,26 @@ +from matplotlib.tests.test_path import _test_curves + +import numpy as np +import pytest + + +# all tests here are currently comparing against integrals +integrate = pytest.importorskip('scipy.integrate') + + +# get several curves to test our code on by borrowing the tests cases used in +# `~.tests.test_path`. get last path element ([-1]) and curve, not code ([0]) +_test_curves = [list(tc.path.iter_bezier())[-1][0] for tc in _test_curves] + + +def _integral_arc_area(B): + """(Signed) area swept out by ray from origin to curve.""" + dB = B.differentiate(B) + def integrand(t): + return np.cross(B(t), dB(t))/2 + return integrate.quad(integrand, 0, 1)[0] + + +@pytest.mark.parametrize("B", _test_curves) +def test_area_formula(B): + assert np.isclose(_integral_arc_area(B), B.arc_area) diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py index 2a9ccb4662b0..b962c098a672 100644 --- a/lib/matplotlib/tests/test_path.py +++ b/lib/matplotlib/tests/test_path.py @@ -1,7 +1,7 @@ import copy +from collections import namedtuple import numpy as np - from numpy.testing import assert_array_equal import pytest @@ -49,25 +49,26 @@ def test_contains_points_negative_radius(): np.testing.assert_equal(result, [True, False, False]) -_test_paths = [ +_ExampleCurve = namedtuple('ExampleCurve', ['path', 'extents', 'area']) +_test_curves = [ # interior extrema determine extents and degenerate derivative - Path([[0, 0], [1, 0], [1, 1], [0, 1]], - [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]), - # a quadratic curve - Path([[0, 0], [0, 1], [1, 0]], [Path.MOVETO, Path.CURVE3, Path.CURVE3]), + _ExampleCurve(Path([[0, 0], [1, 0], [1, 1], [0, 1]], + [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]), + extents=(0., 0., 0.75, 1.), area=0.6), + # a quadratic curve, clockwise + _ExampleCurve(Path([[0, 0], [0, 1], [1, 0]], [Path.MOVETO, Path.CURVE3, + Path.CURVE3]), extents=(0., 0., 1., 0.5), area=-1/3), # a linear curve, degenerate vertically - Path([[0, 1], [1, 1]], [Path.MOVETO, Path.LINETO]), + _ExampleCurve(Path([[0, 1], [1, 1]], [Path.MOVETO, Path.LINETO]), + extents=(0., 1., 1., 1.), area=0.), # a point - Path([[1, 2]], [Path.MOVETO]), + _ExampleCurve(Path([[1, 2]], [Path.MOVETO]), extents=(1., 2., 1., 2.), + area=0.), ] -_test_path_extents = [(0., 0., 0.75, 1.), (0., 0., 1., 0.5), (0., 1., 1., 1.), - (1., 2., 1., 2.)] - - -@pytest.mark.parametrize('path, extents', zip(_test_paths, _test_path_extents)) -def test_exact_extents(path, extents): +@pytest.mark.parametrize('precomputed_curve', _test_curves) +def test_exact_extents(precomputed_curve): # notice that if we just looked at the control points to get the bounding # box of each curve, we would get the wrong answers. For example, for # hard_curve = Path([[0, 0], [1, 0], [1, 1], [0, 1]], @@ -77,9 +78,33 @@ def test_exact_extents(path, extents): # the way out to the control points. # Note that counterintuitively, path.get_extents() returns a Bbox, so we # have to get that Bbox's `.extents`. + path, extents = precomputed_curve.path, precomputed_curve.extents assert np.all(path.get_extents().extents == extents) +@pytest.mark.parametrize('precomputed_curve', _test_curves) +def test_signed_area(precomputed_curve): + path, area = precomputed_curve.path, precomputed_curve.area + assert np.isclose(path.signed_area, area) + # now flip direction, sign of *signed_area* should flip + rverts = path.vertices[:0:-1] + rverts = np.append(rverts, np.atleast_2d(path.vertices[0]), axis=0) + rcurve = Path(rverts, path.codes) + assert np.isclose(rcurve.signed_area, -area) + + +def test_signed_area_unit_rectangle(): + rect = Path.unit_rectangle() + assert np.isclose(rect.signed_area, 1) + + +def test_signed_area_unit_circle(): + circ = Path.unit_circle() + # not quite pi, since it's not a "real" circle, just an approximation of a + # circle made out of bezier curves + assert np.isclose(circ.signed_area, 3.1415935732517166) + + def test_point_in_path_nan(): box = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]) p = Path(box) diff --git a/requirements/testing/travis_all.txt b/requirements/testing/travis_all.txt index 3f42a603f6b7..1be98a699b1b 100644 --- a/requirements/testing/travis_all.txt +++ b/requirements/testing/travis_all.txt @@ -8,3 +8,4 @@ pytest-timeout pytest-xdist python-dateutil tornado +scipy From caf032489dd876244106fc7297ae57b0c959f916 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Mon, 23 Mar 2020 11:45:53 -0700 Subject: [PATCH 3/4] code to compute bezier segment / path lengths --- lib/matplotlib/bezier.py | 91 +++++++++++++++++++++++++++++ lib/matplotlib/path.py | 21 +++++++ lib/matplotlib/tests/test_bezier.py | 14 +++++ lib/matplotlib/tests/test_path.py | 20 +++++-- 4 files changed, 141 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index cd07a7312d55..45137cc08446 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -5,6 +5,7 @@ from functools import lru_cache import math import warnings +from collections import deque import numpy as np @@ -220,6 +221,96 @@ def point_at_t(self, t): """Evaluate curve at a single point *t*. Returns a Tuple[float*d].""" 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 + @property def arc_area(self): r""" diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index 2749806bdea9..0646970492e8 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -625,6 +625,27 @@ 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): + r"""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): """ Get signed area of the filled path. diff --git a/lib/matplotlib/tests/test_bezier.py b/lib/matplotlib/tests/test_bezier.py index ced1362b0bec..52764840bb0a 100644 --- a/lib/matplotlib/tests/test_bezier.py +++ b/lib/matplotlib/tests/test_bezier.py @@ -13,6 +13,13 @@ _test_curves = [list(tc.path.iter_bezier())[-1][0] for tc in _test_curves] +def _integral_arc_length(B): + dB = B.differentiate(B) + def integrand(t): + return np.linalg.norm(dB(t)) + return integrate.quad(integrand, 0, 1)[0] + + def _integral_arc_area(B): """(Signed) area swept out by ray from origin to curve.""" dB = B.differentiate(B) @@ -24,3 +31,10 @@ def integrand(t): @pytest.mark.parametrize("B", _test_curves) def test_area_formula(B): assert np.isclose(_integral_arc_area(B), B.arc_area) + + +@pytest.mark.parametrize("B", _test_curves) +def test_length_iteration(B): + assert np.isclose(_integral_arc_length(B), + B.arc_length(rtol=1e-5, atol=1e-8), + rtol=1e-5, atol=1e-8) diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py index b962c098a672..10c6dcfb23ff 100644 --- a/lib/matplotlib/tests/test_path.py +++ b/lib/matplotlib/tests/test_path.py @@ -49,21 +49,24 @@ def test_contains_points_negative_radius(): np.testing.assert_equal(result, [True, False, False]) -_ExampleCurve = namedtuple('ExampleCurve', ['path', 'extents', 'area']) +_ExampleCurve = namedtuple('ExampleCurve', + ['path', 'extents', 'area', 'length']) _test_curves = [ # interior extrema determine extents and degenerate derivative _ExampleCurve(Path([[0, 0], [1, 0], [1, 1], [0, 1]], [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]), - extents=(0., 0., 0.75, 1.), area=0.6), + extents=(0., 0., 0.75, 1.), area=0.6, length=2.0), # a quadratic curve, clockwise _ExampleCurve(Path([[0, 0], [0, 1], [1, 0]], [Path.MOVETO, Path.CURVE3, - Path.CURVE3]), extents=(0., 0., 1., 0.5), area=-1/3), + Path.CURVE3]), extents=(0., 0., 1., 0.5), area=-1/3, + length=(1/25)*(10 + 15*np.sqrt(2) + np.sqrt(5) \ + * (np.arcsinh(2) + np.arcsinh(3)))), # a linear curve, degenerate vertically _ExampleCurve(Path([[0, 1], [1, 1]], [Path.MOVETO, Path.LINETO]), - extents=(0., 1., 1., 1.), area=0.), + extents=(0., 1., 1., 1.), area=0., length=1.0), # a point _ExampleCurve(Path([[1, 2]], [Path.MOVETO]), extents=(1., 2., 1., 2.), - area=0.), + area=0., length=0.0), ] @@ -105,6 +108,13 @@ def test_signed_area_unit_circle(): assert np.isclose(circ.signed_area, 3.1415935732517166) +@pytest.mark.parametrize('precomputed_curve', _test_curves) +def test_length_curve(precomputed_curve): + path, length = precomputed_curve.path, precomputed_curve.length + assert np.isclose(path.length(rtol=1e-5, atol=1e-8), length, rtol=1e-5, + atol=1e-8) + + def test_point_in_path_nan(): box = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]) p = Path(box) From 23ae477549893e9ea0ac3a209cf17d72b344b310 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Sat, 21 Mar 2020 14:37:48 -0700 Subject: [PATCH 4/4] add function to get center of mass of path --- lib/matplotlib/bezier.py | 78 ++++++++++++++++ lib/matplotlib/path.py | 137 ++++++++++++++++++++++++++++ lib/matplotlib/tests/test_bezier.py | 36 ++++++++ 3 files changed, 251 insertions(+) diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index 45137cc08446..0606db9170f2 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -311,6 +311,74 @@ def arc_length(self, rtol=None, atol=None): return curr_est return curr_est + @property + 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 :math:`\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 + @property def arc_area(self): r""" @@ -413,6 +481,16 @@ def arc_area(self): np.multiply.outer(P[j, 1], dP[k, 0])) ) + @property + 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""" diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index 0646970492e8..ae58a7b92d92 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -697,6 +697,143 @@ def signed_area(self): area += B.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.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. diff --git a/lib/matplotlib/tests/test_bezier.py b/lib/matplotlib/tests/test_bezier.py index 52764840bb0a..ca2120d39aa8 100644 --- a/lib/matplotlib/tests/test_bezier.py +++ b/lib/matplotlib/tests/test_bezier.py @@ -13,6 +13,11 @@ _test_curves = [list(tc.path.iter_bezier())[-1][0] for tc in _test_curves] +def _integral_center_of_mass(B): + return np.array([integrate.quad(lambda t: B(t)[0], 0, 1)[0], + integrate.quad(lambda t: B(t)[1], 0, 1)[0]]) + + def _integral_arc_length(B): dB = B.differentiate(B) def integrand(t): @@ -28,6 +33,27 @@ def integrand(t): return integrate.quad(integrand, 0, 1)[0] +def _integral_arc_com(B): + dB = B.differentiate(B) + def integrand(t): + dr = dB(t).T + n = np.array([dr[1], -dr[0]]) + return B(t).T**2 * n / 2 + def integrand_x(t): + return integrand(t)[0] + def integrand_y(t): + return integrand(t)[1] + return np.array([ + integrate.quad(integrand_x, 0, 1)[0], + integrate.quad(integrand_y, 0, 1)[0] + ]) + + +def _integral_com(B): + return np.array([integrate.quad(lambda t: B(t)[0], 0, 1)[0], + integrate.quad(lambda t: B(t)[1], 0, 1)[0]]) + + @pytest.mark.parametrize("B", _test_curves) def test_area_formula(B): assert np.isclose(_integral_arc_area(B), B.arc_area) @@ -38,3 +64,13 @@ def test_length_iteration(B): assert np.isclose(_integral_arc_length(B), B.arc_length(rtol=1e-5, atol=1e-8), rtol=1e-5, atol=1e-8) + + +@pytest.mark.parametrize("B", _test_curves) +def test_center_of_mass_1d(B): + assert np.all(np.isclose(B.center_of_mass, _integral_center_of_mass(B))) + + +@pytest.mark.parametrize("B", _test_curves) +def test_center_of_mass_2d(B): + assert np.all(np.isclose(B.arc_center_of_mass, _integral_arc_com(B))) 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