Skip to content

Commit 87e21f6

Browse files
committed
add function to compute (signed) area of path
1 parent d3eaffd commit 87e21f6

File tree

6 files changed

+237
-15
lines changed

6 files changed

+237
-15
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Path area
2+
~~~~~~~~~
3+
4+
A `~.path.Path.signed_area` method was added to compute the signed filled area
5+
of a Path object analytically (i.e. without integration). This should be useful
6+
for constructing Paths of a desired area.

lib/matplotlib/bezier.py

+109-1
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@
1212

1313

1414
# same algorithm as 3.8's math.comb
15-
@np.vectorize
1615
@lru_cache(maxsize=128)
1716
def _comb(n, k):
1817
if k > n:
1918
return 0
2019
k = min(k, n - k)
2120
i = np.arange(1, k + 1)
2221
return np.prod((n + 1 - i)/i).astype(int)
22+
_comb = np.vectorize(_comb, otypes=[np.int])
2323

2424

2525
class NonIntersectingPathException(ValueError):
@@ -220,6 +220,114 @@ def point_at_t(self, t):
220220
"""Evaluate curve at a single point *t*. Returns a Tuple[float*d]."""
221221
return tuple(self(t))
222222

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

lib/matplotlib/path.py

+56
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,62 @@ def intersects_bbox(self, bbox, filled=True):
626626
return _path.path_intersects_rectangle(
627627
self, bbox.x0, bbox.y0, bbox.x1, bbox.y1, filled)
628628

629+
def signed_area(self):
630+
"""
631+
Get signed area of the filled path.
632+
633+
Area of a filled region is treated as positive if the path encloses it
634+
in a counter-clockwise direction, but negative if the path encloses it
635+
moving clockwise.
636+
637+
All sub paths are treated as if they had been closed. That is, if there
638+
is a MOVETO without a preceding CLOSEPOLY, one is added.
639+
640+
If the path is made up of multiple components that overlap, the
641+
overlapping area is multiply counted.
642+
643+
Returns
644+
-------
645+
float
646+
The signed area enclosed by the path.
647+
648+
Notes
649+
-----
650+
If the Path is not self-intersecting and has no overlapping components,
651+
then the absolute value of the signed area is equal to the actual
652+
filled area when the Path is drawn (e.g. as a PathPatch).
653+
654+
Examples
655+
--------
656+
A symmetric figure eight, (where one loop is clockwise and
657+
the other counterclockwise) would have a total *signed_area* of zero,
658+
since the two loops would cancel each other out.
659+
"""
660+
area = 0
661+
prev_point = None
662+
prev_code = None
663+
start_point = None
664+
for B, code in self.iter_bezier():
665+
# MOVETO signals the start of a new connected component of the path
666+
if code == Path.MOVETO:
667+
# if the previous segment exists and it doesn't close the
668+
# previous connected component of the path, do so manually to
669+
# match Agg's convention of filling unclosed path segments
670+
if prev_code not in (None, Path.CLOSEPOLY):
671+
Bclose = BezierSegment(np.array([prev_point, start_point]))
672+
area += Bclose.arc_area
673+
# to allow us to manually close this connected component later
674+
start_point = B.control_points[0]
675+
area += B.arc_area
676+
prev_point = B.control_points[-1]
677+
prev_code = code
678+
# add final implied CLOSEPOLY, if necessary
679+
if start_point is not None \
680+
and not np.all(np.isclose(start_point, prev_point)):
681+
B = BezierSegment(np.array([prev_point, start_point]))
682+
area += B.arc_area
683+
return area
684+
629685
def interpolated(self, steps):
630686
"""
631687
Return a new path resampled to length N x steps.

lib/matplotlib/tests/test_bezier.py

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

lib/matplotlib/tests/test_path.py

+39-14
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import copy
22
import re
3+
from collections import namedtuple
34

45
import numpy as np
5-
66
from numpy.testing import assert_array_equal
77
import pytest
88

@@ -71,25 +71,27 @@ def test_contains_points_negative_radius():
7171
np.testing.assert_equal(result, [True, False, False])
7272

7373

74-
_test_paths = [
74+
_ExampleCurve = namedtuple('ExampleCurve', ['path', 'extents', 'area'])
75+
_test_curves = [
7576
# interior extrema determine extents and degenerate derivative
76-
Path([[0, 0], [1, 0], [1, 1], [0, 1]],
77-
[Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]),
78-
# a quadratic curve
79-
Path([[0, 0], [0, 1], [1, 0]], [Path.MOVETO, Path.CURVE3, Path.CURVE3]),
77+
_ExampleCurve(Path([[0, 0], [1, 0], [1, 1], [0, 1]],
78+
[Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]),
79+
extents=(0., 0., 0.75, 1.), area=0.6),
80+
# a quadratic curve, clockwise
81+
_ExampleCurve(Path([[0, 0], [0, 1], [1, 0]],
82+
[Path.MOVETO, Path.CURVE3, Path.CURVE3]),
83+
extents=(0., 0., 1., 0.5), area=-1/3),
8084
# a linear curve, degenerate vertically
81-
Path([[0, 1], [1, 1]], [Path.MOVETO, Path.LINETO]),
85+
_ExampleCurve(Path([[0, 1], [1, 1]], [Path.MOVETO, Path.LINETO]),
86+
extents=(0., 1., 1., 1.), area=0.),
8287
# a point
83-
Path([[1, 2]], [Path.MOVETO]),
88+
_ExampleCurve(Path([[1, 2]], [Path.MOVETO]), extents=(1., 2., 1., 2.),
89+
area=0.),
8490
]
8591

8692

87-
_test_path_extents = [(0., 0., 0.75, 1.), (0., 0., 1., 0.5), (0., 1., 1., 1.),
88-
(1., 2., 1., 2.)]
89-
90-
91-
@pytest.mark.parametrize('path, extents', zip(_test_paths, _test_path_extents))
92-
def test_exact_extents(path, extents):
93+
@pytest.mark.parametrize('precomputed_curve', _test_curves)
94+
def test_exact_extents(precomputed_curve):
9395
# notice that if we just looked at the control points to get the bounding
9496
# box of each curve, we would get the wrong answers. For example, for
9597
# hard_curve = Path([[0, 0], [1, 0], [1, 1], [0, 1]],
@@ -99,9 +101,32 @@ def test_exact_extents(path, extents):
99101
# the way out to the control points.
100102
# Note that counterintuitively, path.get_extents() returns a Bbox, so we
101103
# have to get that Bbox's `.extents`.
104+
path, extents = precomputed_curve.path, precomputed_curve.extents
102105
assert np.all(path.get_extents().extents == extents)
103106

104107

108+
@pytest.mark.parametrize('precomputed_curve', _test_curves)
109+
def test_signed_area(precomputed_curve):
110+
path, area = precomputed_curve.path, precomputed_curve.area
111+
assert np.isclose(path.signed_area(), area)
112+
# now flip direction, sign of *signed_area* should flip
113+
rcurve = Path(path.vertices[::-1], path.codes)
114+
assert np.isclose(rcurve.signed_area(), -area)
115+
116+
117+
def test_signed_area_unit_rectangle():
118+
rect = Path.unit_rectangle()
119+
assert np.isclose(rect.signed_area(), 1)
120+
121+
122+
def test_signed_area_unit_circle():
123+
circ = Path.unit_circle()
124+
# Not a "real" circle, just an approximation of a circle made out of bezier
125+
# curves. The actual value is 3.1415935732517166, which is close enough to
126+
# pass here.
127+
assert np.isclose(circ.signed_area(), np.pi)
128+
129+
105130
def test_point_in_path_nan():
106131
box = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
107132
p = Path(box)

requirements/testing/travis_all.txt

+1
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ pytest-timeout
99
pytest-xdist
1010
python-dateutil
1111
tornado
12+
scipy

0 commit comments

Comments
 (0)