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)))