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

Skip to content

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

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