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
Lines changed: 6 additions & 0 deletions
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

Lines changed: 109 additions & 1 deletion
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

Lines changed: 56 additions & 0 deletions
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

Lines changed: 26 additions & 0 deletions
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

Lines changed: 39 additions & 14 deletions
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

Lines changed: 1 addition & 0 deletions
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)
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