Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit a568413

Browse files
committed
add function to compute (signed) area of path
1 parent b29f403 commit a568413

File tree

6 files changed

+268
-29
lines changed

6 files changed

+268
-29
lines changed

doc/users/next_whats_new/2020-03-31-path-size-methods.rst

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,36 @@
11

2-
Path extents now computed correctly
3-
-----------------------------------
2+
Functions to compute a Path's size
3+
----------------------------------
4+
5+
Various functions were added to `~.bezier.BezierSegment` and `~.path.Path` to
6+
allow computation of the shape, size and area of a `~.path.Path` and its
7+
composite Bezier curves.
8+
9+
In addition, to the fixes below, `~.bezier.BezierSegment` has gained more
10+
documentation and usability improvements, including properties that contain its
11+
dimension, degree, control_points, and more.
12+
13+
Better interface for Path segment iteration
14+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
15+
16+
`~.path.Path.iter_bezier` iterates through the `~.bezier.BezierSegment`'s that
17+
make up the Path. This is much more useful typically than the existing
18+
`~.path.Path.iter_segments` function, which returns the absolute minimum amount
19+
of information possible to reconstruct the Path.
20+
21+
Fixed bug that computed a Path's Bbox incorrectly
22+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
423

524
Historically, `~.path.Path.get_extents` has always simply returned the Bbox of
6-
its control points. While this is a correct upper bound for the path's extents,
7-
it can differ dramatically from the Path's actual extents for non-linear Bezier
8-
curves.
25+
a curve's control points, instead of the Bbox of the curve itself. While this is
26+
a correct upper bound for the path's extents, it can differ dramatically from
27+
the Path's actual extents for non-linear Bezier curves.
28+
29+
Path area
30+
~~~~~~~~~
31+
32+
A `~.path.Path.signed_area` method was added to compute the (signed) filled area
33+
of a Path object efficiently (i.e. without integration). This should be useful
34+
for constructing Path's of a desired area.
35+
936

10-
In addition, `~.bezier.BezierSegment` has gained more documentation and
11-
usability improvements, including properties that contain its dimension, degree,
12-
control_points, and more.

lib/matplotlib/bezier.py

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,12 +191,135 @@ def __init__(self, control_points):
191191
coeff = [math.factorial(self._N - 1)
192192
// (math.factorial(i) * math.factorial(self._N - 1 - i))
193193
for i in range(self._N)]
194-
self._px = self._cpoints.T * coeff
194+
self._px = (self._cpoints.T * coeff).T
195+
196+
def __call__(self, t):
197+
"""
198+
Evaluate the Bezier curve at point(s) t in [0, 1].
199+
200+
Parameters
201+
----------
202+
t : float (k,), array_like
203+
Points at which to evaluate the curve.
204+
205+
Returns
206+
-------
207+
float (k, d), array_like
208+
Value of the curve for each point in *t*.
209+
"""
210+
t = np.array(t)
211+
orders_shape = (1,)*t.ndim + self._orders.shape
212+
t_shape = t.shape + (1,) # self._orders.ndim == 1
213+
orders = np.reshape(self._orders, orders_shape)
214+
rev_orders = np.reshape(self._orders[::-1], orders_shape)
215+
t = np.reshape(t, t_shape)
216+
return ((1 - t)**rev_orders * t**orders) @ self._px
195217

196218
def point_at_t(self, t):
197-
"""Return the point on the Bezier curve for parameter *t*."""
198-
return tuple(
199-
self._px @ (((1 - t) ** self._orders)[::-1] * t ** self._orders))
219+
"""Evaluate curve at a single point *t*. Returns a Tuple[float*d]."""
220+
return tuple(self(t))
221+
222+
def arc_area(self):
223+
r"""
224+
(Signed) area swept out by ray from origin to curve.
225+
226+
The sum of this function for each Bezier curve in a Path will give the
227+
area enclosed by the Path.
228+
229+
Returns
230+
-------
231+
float
232+
The signed area of the arc swept out by the curve.
233+
234+
Notes
235+
-----
236+
A simple, analytical formula is possible for arbitrary bezier curves.
237+
238+
Given a bezier curve B(t), in order to calculate the area of the arc
239+
swept out by the ray from the origin to the curve, we simply need to
240+
compute :math:`\frac{1}{2}\int_0^1 B(t) \cdot n(t) dt`, where
241+
:math:`n(t) = u^{(1)}(t) \hat{x}_0 - u{(0)}(t) \hat{x}_1` is the normal
242+
vector oriented away from the origin and :math:`u^{(i)}(t) =
243+
\frac{d}{dt} B^{(i)}(t)` is the :math:`i`th component of the curve's
244+
tangent vector. (This formula can be found by applying the divergence
245+
theorem to :math:`F(x,y) = [x, y]/2`, and calculates the *signed* area
246+
for a counter-clockwise curve, by the right hand rule).
247+
248+
The control points of the curve are just its coefficients in a
249+
Bernstein expansion, so if we let :math:`P_i = [P^{(0)}_i, P^{(1)}_i]`
250+
be the :math:`i`'th control point, then
251+
252+
.. math::
253+
254+
\frac{1}{2}\int_0^1 B(t) \cdot n(t) dt
255+
&= \frac{1}{2}\int_0^1 B^{(0)}(t) \frac{d}{dt} B^{(1)}(t)
256+
- B^{(1)}(t) \frac{d}{dt} B^{(0)}(t)
257+
dt \\
258+
&= \frac{1}{2}\int_0^1
259+
\left( \sum_{j=0}^n P_j^{(0)} b_{j,n} \right)
260+
\left( n \sum_{k=0}^{n-1} (P_{k+1}^{(1)} -
261+
P_{k}^{(1)}) b_{j,n} \right)
262+
\\
263+
&\hspace{1em} - \left( \sum_{j=0}^n P_j^{(1)} b_{j,n}
264+
\right) \left( n \sum_{k=0}^{n-1} (P_{k+1}^{(0)}
265+
- P_{k}^{(0)}) b_{j,n} \right)
266+
dt,
267+
268+
where :math:`b_{\nu, n}(t) = {n \choose \nu} t^\nu {(1 - t)}^{n-\nu}`
269+
is the :math:`\nu`'th Bernstein polynomial of degree :math:`n`.
270+
271+
Grouping :math:`t^l(1-t)^m` terms together for each :math:`l`,
272+
:math:`m`, we get that the integrand becomes
273+
274+
.. math::
275+
276+
\sum_{j=0}^n \sum_{k=0}^{n-1}
277+
{n \choose j} {{n - 1} \choose k}
278+
&\left[P_j^{(0)} (P_{k+1}^{(1)} - P_{k}^{(1)})
279+
- P_j^{(1)} (P_{k+1}^{(0)} - P_{k}^{(0)})\right] \\
280+
&\hspace{1em}\times{}t^{j + k} {(1 - t)}^{2n - 1 - j - k}
281+
282+
or just
283+
284+
.. math::
285+
286+
\sum_{j=0}^n \sum_{k=0}^{n-1}
287+
\frac{{n \choose j} {{n - 1} \choose k}}
288+
{{{2n - 1} \choose {j+k}}}
289+
[P_j^{(0)} (P_{k+1}^{(1)} - P_{k}^{(1)})
290+
- P_j^{(1)} (P_{k+1}^{(0)} - P_{k}^{(0)})]
291+
b_{j+k,2n-1}(t).
292+
293+
Interchanging sum and integral, and using the fact that :math:`\int_0^1
294+
b_{\nu, n}(t) dt = \frac{1}{n + 1}`, we conclude that the
295+
original integral can
296+
simply be written as
297+
298+
.. math::
299+
300+
\frac{1}{2}&\int_0^1 B(t) \cdot n(t) dt
301+
\\
302+
&= \frac{1}{4}\sum_{j=0}^n \sum_{k=0}^{n-1}
303+
\frac{{n \choose j} {{n - 1} \choose k}}
304+
{{{2n - 1} \choose {j+k}}}
305+
[P_j^{(0)} (P_{k+1}^{(1)} - P_{k}^{(1)})
306+
- P_j^{(1)} (P_{k+1}^{(0)} - P_{k}^{(0)})]
307+
"""
308+
n = self.degree
309+
area = 0
310+
P = self.control_points
311+
dP = np.diff(P, axis=0)
312+
for j in range(n + 1):
313+
for k in range(n):
314+
area += _comb(n, j)*_comb(n-1, k)/_comb(2*n - 1, j + k) \
315+
* (P[j, 0]*dP[k, 1] - P[j, 1]*dP[k, 0])
316+
return (1/4)*area
317+
318+
@classmethod
319+
def differentiate(cls, B):
320+
"""Return the derivative of a BezierSegment, itself a BezierSegment"""
321+
dcontrol_points = B.degree*np.diff(B.control_points, axis=0)
322+
return cls(dcontrol_points)
200323

201324
@property
202325
def control_points(self):

lib/matplotlib/path.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -610,14 +610,14 @@ def get_extents(self, transform=None, **kwargs):
610610
extents = np.array([np.inf, np.inf, -np.inf, -np.inf])
611611
for curve, code in self.iter_bezier(**kwargs):
612612
# start and endpoints can be extrema of the curve
613-
_update_extents(extents, curve.point_at_t(0)) # start point
614-
_update_extents(extents, curve.point_at_t(1)) # end point
613+
_update_extents(extents, curve(0)) # start point
614+
_update_extents(extents, curve(1)) # end point
615615
# interior extrema where d/ds B(s) == 0
616616
_, dzeros = curve.axis_aligned_extrema
617617
if len(dzeros) == 0:
618618
continue
619619
for ti in dzeros:
620-
potential_extrema = curve.point_at_t(ti)
620+
potential_extrema = curve(ti)
621621
_update_extents(extents, potential_extrema)
622622
return Bbox.from_extents(extents)
623623

@@ -642,6 +642,47 @@ def intersects_bbox(self, bbox, filled=True):
642642
return _path.path_intersects_rectangle(
643643
self, bbox.x0, bbox.y0, bbox.x1, bbox.y1, filled)
644644

645+
def signed_area(self, **kwargs):
646+
"""
647+
Get signed area of the filled path.
648+
649+
All sub paths are treated as if they had been closed. That is, if there
650+
is a MOVETO without a preceding CLOSEPOLY, one is added.
651+
652+
If the path is made up of multiple components that overlap, the
653+
overlapping area is multiply counted.
654+
655+
Returns
656+
-------
657+
float
658+
The (signed) enclosed area of the path.
659+
660+
Notes
661+
-----
662+
If the Path is not self-intersecting and has no overlapping components,
663+
then the absolute value of the signed area is equal to the actual
664+
filled area when the Path is drawn (e.g. as a PathPatch).
665+
"""
666+
area = 0
667+
prev_point = None
668+
prev_code = None
669+
start_point = None
670+
for B, code in self.iter_bezier(**kwargs):
671+
if code == Path.MOVETO:
672+
if prev_code is not None and prev_code is not Path.CLOSEPOLY:
673+
Bclose = BezierSegment(np.array([prev_point, start_point]))
674+
area += Bclose.arc_area()
675+
start_point = B.control_points[0]
676+
area += B.arc_area()
677+
prev_point = B.control_points[-1]
678+
prev_code = code
679+
# add final implied CLOSEPOLY, if necessary
680+
if start_point is not None \
681+
and not np.all(np.isclose(start_point, prev_point)):
682+
B = BezierSegment(np.array([prev_point, start_point]))
683+
area += B.arc_area()
684+
return area
685+
645686
def interpolated(self, steps):
646687
"""
647688
Return a new path resampled to length N x steps.

lib/matplotlib/tests/test_bezier.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from matplotlib.tests.test_path import _test_curves
2+
3+
import numpy as np
4+
import pytest
5+
import scipy.integrate
6+
7+
8+
# get several curves to test our code on by borrowing the tests cases used in
9+
# `~.tests.test_path`. get last path element ([-1]) and curve, not code ([0])
10+
_test_curves = [list(tc.path.iter_bezier())[-1][0] for tc in _test_curves]
11+
12+
13+
def _integral_arc_area(B):
14+
"""(Signed) area swept out by ray from origin to curve."""
15+
dB = B.differentiate(B)
16+
def integrand(t):
17+
dr = dB(t).T
18+
n = np.array([dr[1], -dr[0]])
19+
return (B(t).T @ n) / 2
20+
return scipy.integrate.quad(integrand, 0, 1)[0]
21+
22+
23+
@pytest.mark.parametrize("B", _test_curves)
24+
def test_area_formula(B):
25+
assert np.isclose(_integral_arc_area(B), B.arc_area())

lib/matplotlib/tests/test_path.py

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import copy
2+
from collections import namedtuple
23

34
import numpy as np
4-
55
from numpy.testing import assert_array_equal
66
import pytest
77

@@ -49,25 +49,26 @@ def test_contains_points_negative_radius():
4949
np.testing.assert_equal(result, [True, False, False])
5050

5151

52-
_test_paths = [
52+
_ExampleCurve = namedtuple('ExampleCurve', ['path', 'extents', 'area'])
53+
_test_curves = [
5354
# interior extrema determine extents and degenerate derivative
54-
Path([[0, 0], [1, 0], [1, 1], [0, 1]],
55-
[Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]),
56-
# a quadratic curve
57-
Path([[0, 0], [0, 1], [1, 0]], [Path.MOVETO, Path.CURVE3, Path.CURVE3]),
55+
_ExampleCurve(Path([[0, 0], [1, 0], [1, 1], [0, 1]],
56+
[Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]),
57+
extents=(0., 0., 0.75, 1.), area=0.6),
58+
# a quadratic curve, clockwise
59+
_ExampleCurve(Path([[0, 0], [0, 1], [1, 0]], [Path.MOVETO, Path.CURVE3,
60+
Path.CURVE3]), extents=(0., 0., 1., 0.5), area=-1/3),
5861
# a linear curve, degenerate vertically
59-
Path([[0, 1], [1, 1]], [Path.MOVETO, Path.LINETO]),
62+
_ExampleCurve(Path([[0, 1], [1, 1]], [Path.MOVETO, Path.LINETO]),
63+
extents=(0., 1., 1., 1.), area=0.),
6064
# a point
61-
Path([[1, 2]], [Path.MOVETO]),
65+
_ExampleCurve(Path([[1, 2]], [Path.MOVETO]), extents=(1., 2., 1., 2.),
66+
area=0.),
6267
]
6368

6469

65-
_test_path_extents = [(0., 0., 0.75, 1.), (0., 0., 1., 0.5), (0., 1., 1., 1.),
66-
(1., 2., 1., 2.)]
67-
68-
69-
@pytest.mark.parametrize('path, extents', zip(_test_paths, _test_path_extents))
70-
def test_exact_extents(path, extents):
70+
@pytest.mark.parametrize('precomputed_curve', _test_curves)
71+
def test_exact_extents(precomputed_curve):
7172
# notice that if we just looked at the control points to get the bounding
7273
# box of each curve, we would get the wrong answers. For example, for
7374
# hard_curve = Path([[0, 0], [1, 0], [1, 1], [0, 1]],
@@ -77,9 +78,33 @@ def test_exact_extents(path, extents):
7778
# the way out to the control points.
7879
# Note that counterintuitively, path.get_extents() returns a Bbox, so we
7980
# have to get that Bbox's `.extents`.
81+
path, extents = precomputed_curve.path, precomputed_curve.extents
8082
assert np.all(path.get_extents().extents == extents)
8183

8284

85+
@pytest.mark.parametrize('precomputed_curve', _test_curves)
86+
def test_signed_area(precomputed_curve):
87+
path, area = precomputed_curve.path, precomputed_curve.area
88+
assert np.isclose(path.signed_area(), area)
89+
# now flip direction, sign of *signed_area* should flip
90+
rverts = path.vertices[:0:-1]
91+
rverts = np.append(rverts, np.atleast_2d(path.vertices[0]), axis=0)
92+
rcurve = Path(rverts, path.codes)
93+
assert np.isclose(rcurve.signed_area(), -area)
94+
95+
96+
def test_signed_area_unit_rectangle():
97+
rect = Path.unit_rectangle()
98+
assert np.isclose(rect.signed_area(), 1)
99+
100+
101+
def test_signed_area_unit_circle():
102+
circ = Path.unit_circle()
103+
# not quite pi, since it's not a "real" circle, just an approximation of a
104+
# circle made out of bezier curves
105+
assert np.isclose(circ.signed_area(), 3.1415935732517166)
106+
107+
83108
def test_point_in_path_nan():
84109
box = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
85110
p = Path(box)

requirements/testing/travis_all.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ pytest-timeout
88
pytest-xdist
99
python-dateutil
1010
tornado
11+
scipy

0 commit comments

Comments
 (0)