8000 add function to compute (signed) area of path · matplotlib/matplotlib@1bb9562 · GitHub
[go: up one dir, main page]

Skip to content

Commit 1bb9562

Browse files
brunobeltrangreglucas
authored andcommitted
add function to compute (signed) area of path
1 parent bda958a commit 1bb9562

File tree

5 files changed

+234
-24
lines changed

5 files changed

+234
-24
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 & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
A module providing some utility functions regarding Bézier path manipulation.
33
"""
44

5-
from functools import lru_cache
65
import math
76
import warnings
87

@@ -11,15 +10,7 @@
1110
from matplotlib import _api
1211

1312

14-
# same algorithm as 3.8's math.comb
15-
@np.vectorize
16-
@lru_cache(maxsize=128)
17-
def _comb(n, k):
18-
if k > n:
19-
return 0
20-
k = min(k, n - k)
21-
i = np.arange(1, k + 1)
22-
return np.prod((n + 1 - i)/i).astype(int)
13+
_comb = np.vectorize(math.comb, otypes=[int])
2314

2415

2516
class NonIntersectingPathException(ValueError):
@@ -229,6 +220,114 @@ def point_at_t(self, t):
229220
"""
230221
return tuple(self(t))
231222

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+
232331
@property
233332
def control_points(self):
234333
"""The control points of the curve."""

lib/matplotlib/path.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,62 @@ def intersects_bbox(self, bbox, filled=True):
666666
return _path.path_intersects_rectangle(
667667
self, bbox.x0, bbox.y0, bbox.x1, bbox.y1, filled)
668668

669+
def signed_area(self):
670+
"""
671+
Get signed area of the filled path.
672+
673+
Area of a filled region is treated as positive if the path encloses it
674+
in a counter-clockwise direction, but negative if the path encloses it
675+
moving clockwise.
676+
677+
All sub paths are treated as if they had been closed. That is, if there
678+
is a MOVETO without a preceding CLOSEPOLY, one is added.
679+
680+
If the path is made up of multiple components that overlap, the
681+
overlapping area is multiply counted.
682+
683+
Returns
684+
-------
685+
float
686+
The signed area enclosed by the path.
687+
688+
Notes
689+
-----
690+
If the Path is not self-intersecting and has no overlapping components,
691+
then the absolute value of the signed area is equal to the actual
692+
filled area when the Path is drawn (e.g. as a PathPatch).
693+
694+
Examples
695+
--------
696+
A symmetric figure eight, (where one loop is clockwise and
697+
the other counterclockwise) would have a total *signed_area* of zero,
698+
since the two loops would cancel each other out.
699+
"""
700+
area = 0
701+
prev_point = None
702+
prev_code = None
703+
start_point = None
704+
for B, code in self.iter_bezier():
705+
# MOVETO signals the start of a new connected component of the path
706+
if code == Path.MOVETO:
707+
# if the previous segment exists and it doesn't close the
708+
# previous connected component of the path, do so manually to
709+
# match Agg's convention of filling unclosed path segments
710+
if prev_code not in (None, Path.CLOSEPOLY):
711+
Bclose = BezierSegment(np.array([prev_point, start_point]))
712+
area += Bclose.arc_area
713+
# to allow us to manually close this connected component later
714+
start_point = B.control_points[0]
715+
area += B.arc_area
716+
prev_point = B.control_points[-1]
717+
prev_code = code
718+
# add final implied CLOSEPOLY, if necessary
719+
if start_point is not None \
720+
and not np.all(np.isclose(start_point, prev_point)):
721+
B = BezierSegment(np.array([prev_point, start_point]))
722+
area += B.arc_area
723+
return area
724+
669725
def interpolated(self, steps):
670726
"""
671727
Return a new path resampled to length N x *steps*.

lib/matplotlib/tests/test_bezier.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
"""
44

55
from matplotlib.bezier import inside_circle, split_bezier_intersecting_with_closedpath
6+
from matplotlib.tests.test_path import _test_curves
7+
8+
import numpy as np
9+
import pytest
610

711

812
def test_split_bezier_with_large_values():
@@ -15,3 +19,23 @@ def test_split_bezier_with_large_values():
1519
# All we are testing is that this completes
1620
# The failure case is an infinite loop resulting from floating point precision
1721
# pytest will timeout if that occurs
22+
23+
24+
# get several curves to test our code on by borrowing the tests cases used in
25+
# `~.tests.test_path`. get last path element ([-1]) and curve, not code ([0])
26+
_test_curves = [list(tc.path.iter_bezier())[-1][0] for tc in _test_curves]
27+
28+
29+
def _integral_arc_area(B):
30+
"""(Signed) area swept out by ray from origin to curve."""
31+
dB = B.differentiate(B)
32+
def integrand(t):
33+
return np.cross(B(t), dB(t))/2
34+
x = np.linspace(0, 1, 1000)
35+
y = integrand(x)
36+
return np.trapz(y, x)
37+
38+
39+
@pytest.mark.parametrize("B", _test_curves)
40+
def test_area_formula(B):
41+
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 platform
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

@@ -88,25 +88,27 @@ def test_contains_points_negative_radius():
8888
np.testing.assert_equal(result, [True, False, False])
8989

9090

91-
_test_paths = [
91+
_ExampleCurve = namedtuple('ExampleCurve', ['path', 'extents', 'area'])
92+
_test_curves = [
9293
# interior extrema determine extents and degenerate derivative
93-
Path([[0, 0], [1, 0], [1, 1], [0, 1]],
94-
[Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]),
95-
# a quadratic curve
96-
Path([[0, 0], [0, 1], [1, 0]], [Path.MOVETO, Path.CURVE3, Path.CURVE3]),
94+
_ExampleCurve(Path([[0, 0], [1, 0], [1, 1], [0, 1]],
95+
[Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]),
96+
extents=(0., 0., 0.75, 1.), area=0.6),
97+
# a quadratic curve, clockwise
98+
_ExampleCurve(Path([[0, 0], [0, 1], [1, 0]],
99+
[Path.MOVETO, Path.CURVE3, Path.CURVE3]),
100+
extents=(0., 0., 1., 0.5), area=-1/3),
97101
# a linear curve, degenerate vertically
98-
Path([[0, 1], [1, 1]], [Path.MOVETO, Path.LINETO]),
102+
_ExampleCurve(Path([[0, 1], [1, 1]], [Path.MOVETO, Path.LINETO]),
103+
extents=(0., 1., 1., 1.), area=0.),
99104
# a point
100-
Path([[1, 2]], [Path.MOVETO]),
105+
_ExampleCurve(Path([[1, 2]], [Path.MOVETO]), extents=(1., 2., 1., 2.),
106+
area=0.),
101107
]
102108

103109

104-
_test_path_extents = [(0., 0., 0.75, 1.), (0., 0., 1., 0.5), (0., 1., 1., 1.),
105-
(1., 2., 1., 2.)]
106-
107-
108-
@pytest.mark.parametrize('path, extents', zip(_test_paths, _test_path_extents))
109-
def test_exact_extents(path, extents):
110+
@pytest.mark.parametrize('precomputed_curve', _test_curves)
111+
def test_exact_extents(precomputed_curve):
110112
# notice that if we just looked at the control points to get the bounding
111113
# box of each curve, we would get the wrong answers. For example, for
112114
# hard_curve = Path([[0, 0], [1, 0], [1, 1], [0, 1]],
@@ -116,6 +118,7 @@ def test_exact_extents(path, extents):
116118
# the way out to the control points.
117119
# Note that counterintuitively, path.get_extents() returns a Bbox, so we
118120
# have to get that Bbox's `.extents`.
121+
path, extents = precomputed_curve.path, precomputed_curve.extents
119122
assert np.all(path.get_extents().extents == extents)
120123

121124

@@ -129,6 +132,28 @@ def test_extents_with_ignored_codes(ignored_code):
129132
assert np.all(path.get_extents().extents == (0., 0., 1., 1.))
130133

131134

135+
@pytest.mark.parametrize('precomputed_curve', _test_curves)
136+
def test_signed_area(precomputed_curve):
137+
path, area = precomputed_curve.path, precomputed_curve.area
138+
assert np.isclose(path.signed_area(), area)
139+
# now flip direction, sign of *signed_area* should flip
140+
rcurve = Path(path.vertices[::-1], path.codes)
141+
assert np.isclose(rcurve.signed_area(), -area)
142+
143+
144+
def test_signed_area_unit_rectangle():
145+
rect = Path.unit_rectangle()
146+
assert np.isclose(rect.signed_area(), 1)
147+
148+
149+
def test_signed_area_unit_circle():
150+
circ = Path.unit_circle()
151+
# Not a "real" circle, just an approximation of a circle made out of bezier
152+
# curves. The actual value is 3.1415935732517166, which is close enough to
153+
# pass here.
154+
assert np.isclose(circ.signed_area(), np.pi)
155+
156+
132157
def test_point_in_path_nan():
133158
box = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
134159
p = Path(box)

0 commit comments

Comments
 (0)
0