-
-
Notifications
You must be signed in to change notification settings - Fork 7.9k
Compute area of path #16859
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Compute area of path #16859
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
Path area | ||
~~~~~~~~~ | ||
|
||
A `~.path.Path.signed_area` method was added to compute the signed filled area | ||
of a Path object analytically (i.e. without integration). This should be useful | ||
for constructing Paths of a desired area. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,7 +2,6 @@ | |
A module providing some utility functions regarding Bézier path manipulation. | ||
""" | ||
|
||
from functools import lru_cache | ||
import math | ||
import warnings | ||
|
||
|
@@ -11,15 +10,7 @@ | |
from matplotlib import _api | ||
|
||
|
||
# same algorithm as 3.8's math.comb | ||
@np.vectorize | ||
@lru_cache(maxsize=128) | ||
def _comb(n, k): | ||
if k > n: | ||
return 0 | ||
k = min(k, n - k) | ||
i = np.arange(1, k + 1) | ||
return np.prod((n + 1 - i)/i).astype(int) | ||
_comb = np.vectorize(math.comb, otypes=[int]) | ||
|
||
|
||
class NonIntersectingPathException(ValueError): | ||
|
@@ -229,6 +220,121 @@ def point_at_t(self, t): | |
""" | ||
return tuple(self(t)) | ||
|
||
@property | ||
def arc_area(self): | ||
r""" | ||
Signed area swept out by ray from origin to curve. | ||
|
||
Counterclockwise area is counted as positive, and clockwise area as | ||
negative. | ||
|
||
The sum of this function for each Bezier curve in a Path will give the | ||
signed area enclosed by the Path. | ||
|
||
Returns | ||
------- | ||
float | ||
The signed area of the arc swept out by the curve. | ||
|
||
Notes | ||
----- | ||
An analytical formula is possible for arbitrary bezier curves. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there some external resource we can link to that describes the formula? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added a link to a publicly accessible course notes on computer graphics. The terminology is slightly different, but the approach looks the same to me. |
||
The formulas can be found in computer graphics references [1]_ and | ||
an example derivation is given below. | ||
|
||
For a bezier curve :math:`\vec{B}(t)`, to calculate the area of the arc | ||
swept out by the ray from the origin to the curve, we need to compute | ||
:math:`\frac{1}{2}\int_0^1 \vec{B}(t) \cdot \vec{n}(t) dt`, where | ||
:math:`\vec{n}(t) = u^{(1)}(t)\hat{x}^{(0)} - u^{(0)}(t)\hat{x}^{(1)}` | ||
is the normal vector oriented away from the origin and | ||
:math:`u^{(i)}(t) = \frac{d}{dt} B^{(i)}(t)` is the :math:`i`\th | ||
component of the curve's tangent vector. (This formula can be found by | ||
applying the divergence theorem to :math:`F(x,y) = [x, y]/2`, and | ||
calculates the *signed* area for a counter-clockwise curve, by the | ||
right hand rule). | ||
|
||
The control points of the curve are its coefficients in a Bernstein | ||
expansion, so if we let :math:`P_i = [P^{(0)}_i, P^{(1)}_i]` be the | ||
:math:`i`\th control point, then | ||
|
||
.. math:: | ||
|
||
\frac{1}{2}\int_0^1 B(t) \cdot n(t) dt | ||
&= \frac{1}{2}\int_0^1 B^{(0)}(t) \frac{d}{dt} B^{(1)}(t) | ||
- B^{(1)}(t) \frac{d}{dt} B^{(0)}(t) | ||
dt \\ | ||
&= \frac{1}{2}\int_0^1 | ||
\left( \sum_{j=0}^n P_j^{(0)} b_{j,n} \right) | ||
\left( n \sum_{k=0}^{n-1} (P_{k+1}^{(1)} - | ||
P_{k}^{(1)}) b_{j,n} \right) | ||
\\ | ||
&\hspace{1em} - \left( \sum_{j=0}^n P_j^{(1)} b_{j,n} | ||
\right) \left( n \sum_{k=0}^{n-1} (P_{k+1}^{(0)} | ||
- P_{k}^{(0)}) b_{j,n} \right) | ||
dt, | ||
|
||
where :math:`b_{\nu, n}(t) = {n \choose \nu} t^\nu {(1 - t)}^{n-\nu}` | ||
is the :math:`\nu`\th Bernstein polynomial of degree :math:`n`. | ||
|
||
Grouping :math:`t^l(1-t)^m` terms together for each :math:`l`, | ||
:math:`m`, we get that the integrand becomes | ||
|
||
.. math:: | ||
|
||
\sum_{j=0}^n \sum_{k=0}^{n-1} | ||
{n \choose j} {{n - 1} \choose k} | ||
&\left[P_j^{(0)} (P_{k+1}^{(1)} - P_{k}^{(1)}) | ||
- P_j^{(1)} (P_{k+1}^{(0)} - P_{k}^{(0)})\right] \\ | ||
&\hspace{1em}\times{}t^{j + k} {(1 - t)}^{2n - 1 - j - k} | ||
|
||
or more compactly, | ||
|
||
.. math:: | ||
|
||
\sum_{j=0}^n \sum_{k=0}^{n-1} | ||
\frac{{n \choose j} {{n - 1} \choose k}} | ||
{{{2n - 1} \choose {j+k}}} | ||
[P_j^{(0)} (P_{k+1}^{(1)} - P_{k}^{(1)}) | ||
- P_j^{(1)} (P_{k+1}^{(0)} - P_{k}^{(0)})] | ||
b_{j+k,2n-1}(t). | ||
|
||
Interchanging sum and integral, and using the fact that :math:`\int_0^1 | ||
b_{\nu, n}(t) dt = \frac{1}{n + 1}`, we conclude that the original | ||
integral can be written as | ||
|
||
.. math:: | ||
|
||
\frac{1}{2}&\int_0^1 B(t) \cdot n(t) dt | ||
\\ | ||
&= \frac{1}{4}\sum_{j=0}^n \sum_{k=0}^{n-1} | ||
\frac{{n \choose j} {{n - 1} \choose k}} | ||
{{{2n - 1} \choose {j+k}}} | ||
[P_j^{(0)} (P_{k+1}^{(1)} - P_{k}^{(1)}) | ||
- P_j^{(1)} (P_{k+1}^{(0)} - P_{k}^{(0)})] | ||
|
||
References | ||
---------- | ||
.. [1] Sederberg, Thomas W., "Computer Aided Geometric Design" (2012). | ||
Faculty Publications. 1. https://scholarsarchive.byu.edu/facpub/1 | ||
""" | ||
n = self.degree | ||
P = self.control_points | ||
dP = np.diff(P, axis=0) | ||
j = np.arange(n + 1) | ||
k = np.arange(n) | ||
return (1/4)*np.sum( | ||
np.multiply.outer(_comb(n, j), _comb(n - 1, k)) | ||
/ _comb(2*n - 1, np.add.outer(j, k)) | ||
* (np.multiply.outer(P[j, 0], dP[k, 1]) - | ||
np.multiply.outer(P[j, 1], dP[k, 0])) | ||
) | ||
|
||
@classmethod | ||
def differentiate(cls, B): | ||
"""Return the derivative of a BezierSegment, itself a BezierSegment""" | ||
dcontrol_points = B.degree*np.diff(B.control_points, axis=0) | ||
return cls(dcontrol_points) | ||
|
||
@property | ||
def control_points(self): | ||
"""The control points of the curve.""" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -666,6 +666,62 @@ | |
return _path.path_intersects_rectangle( | ||
self, bbox.x0, bbox.y0, bbox.x1, bbox.y1, filled) | ||
|
||
def signed_area(self): | ||
""" | ||
Get signed area of the filled path. | ||
|
||
Area of a filled region is treated as positive if the path encloses it | ||
in a counter-clockwise direction, but negative if the path encloses it | ||
moving clockwise. | ||
|
||
All sub paths are treated as if they had been closed. That is, if there | ||
is a MOVETO without a preceding CLOSEPOLY, one is added. | ||
|
||
If the path is made up of multiple components that overlap, the | ||
overlapping area is multiply counted. | ||
|
||
Returns | ||
------- | ||
float | ||
The signed area enclosed by the path. | ||
|
||
Notes | ||
----- | ||
If the Path is not self-intersecting and has no overlapping components, | ||
then the absolute value of the signed area is equal to the actual | ||
filled area when the Path is drawn (e.g. as a PathPatch). | ||
|
||
Examples | ||
-------- | ||
A symmetric figure eight, (where one loop is clockwise and | ||
the other counterclockwise) would have a total *signed_area* of zero, | ||
since the two loops would cancel each other out. | ||
""" | ||
area = 0 | ||
prev_point = None | ||
prev_code = None | ||
start_point = None | ||
for B, code in self.iter_bezier(): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it be reasonable to calculate the area on a shifted path, which has it's "center" in the origin? If we are far away from the origin, we add and subtract a lot of long-and-small areas. I can imagine that this could significantly reduce numerical precision. |
||
# MOVETO signals the start of a new connected component of the path | ||
if code == Path.MOVETO: | ||
# if the previous segment exists and it doesn't close the | ||
# previous connected component of the path, do so manually to | ||
# match Agg's convention of filling unclosed path segments | ||
if prev_code not in (None, Path.CLOSEPOLY): | ||
Bclose = BezierSegment([prev_point, start_point]) | ||
area += Bclose.arc_area | ||
# to allow us to manually close this connected component later | ||
start_point = B.control_points[0] | ||
area += B.arc_area | ||
prev_point = B.control_points[-1] | ||
prev_code = code | ||
# add final implied CLOSEPOLY, if necessary | ||
if start_point is not None \ | ||
and not np.all(np.isclose(start_point, prev_point)): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you know if There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This logic is handled by |
||
B = BezierSegment([prev_point, start_point]) | ||
area += B.arc_area | ||
return area | ||
|
||
def interpolated(self, steps): | ||
""" | ||
Return a new path resampled to length N x *steps*. | ||
|
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
@@ -1,8 +1,8 @@ | ||||||||
import platform | ||||||||
import re | ||||||||
from collections import namedtuple | ||||||||
|
||||||||
import numpy as np | ||||||||
|
||||||||
from numpy.testing import assert_array_equal | ||||||||
import pytest | ||||||||
|
||||||||
|
@@ -88,25 +88,29 @@ def test_contains_points_negative_radius(): | |||||||
np.testing.assert_equal(result, [True, False, False]) | ||||||||
|
||||||||
|
||||||||
_test_paths = [ | ||||||||
_ExampleCurve = namedtuple('_ExampleCurve', ['path', 'extents', 'area']) | ||||||||
_test_curves = [ | ||||||||
# interior extrema determine extents and degenerate derivative | ||||||||
Path([[0, 0], [1, 0], [1, 1], [0, 1]], | ||||||||
[Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]), | ||||||||
# a quadratic curve | ||||||||
Path([[0, 0], [0, 1], [1, 0]], [Path.MOVETO, Path.CURVE3, Path.CURVE3]), | ||||||||
_ExampleCurve(Path([[0, 0], [1, 0], [1, 1], [0, 1]], | ||||||||
[Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]), | ||||||||
extents=(0., 0., 0.75, 1.), area=0.6), | ||||||||
# a quadratic curve, clockwise | ||||||||
_ExampleCurve(Path([[0, 0], [0, 1], [1, 0]], | ||||||||
[Path.MOVETO, Path.CURVE3, Path.CURVE3]), | ||||||||
extents=(0., 0., 1., 0.5), area=-1/3), | ||||||||
# a linear curve, degenerate vertically | ||||||||
Path([[0, 1], [1, 1]], [Path.MOVETO, Path.LINETO]), | ||||||||
_ExampleCurve(Path([[0, 1], [1, 1]], [Path.MOVETO, Path.LINETO]), | ||||||||
extents=(0., 1., 1., 1.), area=0.), | ||||||||
# a point | ||||||||
Path([[1, 2]], [Path.MOVETO]), | ||||||||
_ExampleCurve(Path([[1, 2]], [Path.MOVETO]), extents=(1., 2., 1., 2.), | ||||||||
area=0.), | ||||||||
# non-curved triangle | ||||||||
_ExampleCurve(Path([(1, 1), (2, 1), (1.5, 2)]), extents=(1, 1, 2, 2), area=0.5), | ||||||||
] | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test a simple non-curved polygon such as a triangle:
Suggested change
Side-remark: Not an expert, but I believe there are more efficient formulas for simple polygons without Bezier segments. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree, but looking through this it is all vectorized and over the degree-1 shapes I'm guessing it isn't that expensive. I think this should also be a good way for someone to later come in and add a fast-path for those cases: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Digression, but this is apparently messing up mypy stubtest which I think is due to the float in here vs int in all the other paths. Changing this to Do we really need to be running mypy over the test suite? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't have a strong opinion on mypy on tests. I assume the added value would be that we check for reasonable typing of our interfaces by exercising calls to these interfaces. |
||||||||
|
||||||||
|
||||||||
_test_path_extents = [(0., 0., 0.75, 1.), (0., 0., 1., 0.5), (0., 1., 1., 1.), | ||||||||
(1., 2., 1., 2.)] | ||||||||
|
||||||||
|
||||||||
@pytest.mark.parametrize('path, extents', zip(_test_paths, _test_path_extents)) | ||||||||
def test_exact_extents(path, extents): | ||||||||
@pytest.mark.parametrize('precomputed_curve', _test_curves) | ||||||||
def test_exact_extents(precomputed_curve): | ||||||||
# notice that if we just looked at the control points to get the bounding | ||||||||
# box of each curve, we would get the wrong answers. For example, for | ||||||||
# hard_curve = Path([[0, 0], [1, 0], [1, 1], [0, 1]], | ||||||||
|
@@ -116,6 +120,7 @@ def test_exact_extents(path, extents): | |||||||
# the way out to the control points. | ||||||||
# Note that counterintuitively, path.get_extents() returns a Bbox, so we | ||||||||
# have to get that Bbox's `.extents`. | ||||||||
path, extents = precomputed_curve.path, precomputed_curve.extents | ||||||||
assert np.all(path.get_extents().extents == extents) | ||||||||
|
||||||||
|
||||||||
|
@@ -129,6 +134,28 @@ def test_extents_with_ignored_codes(ignored_code): | |||||||
assert np.all(path.get_extents().extents == (0., 0., 1., 1.)) | ||||||||
|
||||||||
|
||||||||
@pytest.mark.parametrize('precomputed_curve', _test_curves) | ||||||||
def test_signed_area(precomputed_curve): | ||||||||
path, area = precomputed_curve.path, precomputed_curve.area | ||||||||
np.testing.assert_allclose(path.signed_area(), area) | ||||||||
# now flip direction, sign of *signed_area* should flip | ||||||||
rcurve = Path(path.vertices[::-1], path.codes) | ||||||||
np.testing.assert_allclose(rcurve.signed_area(), -area) | ||||||||
|
||||||||
|
||||||||
def test_signed_area_unit_rectangle(): | ||||||||
rect = Path.unit_rectangle() | ||||||||
assert rect.signed_area() == 1 | ||||||||
|
||||||||
|
||||||||
def test_signed_area_unit_circle(): | ||||||||
circ = Path.unit_circle() | ||||||||
# Not a "real" circle, just an approximation of a circle made out of bezier | ||||||||
# curves. The actual value is 3.1415935732517166, which is close enough to | ||||||||
# pass here. | ||||||||
assert np.isclose(circ.signed_area(), np.pi) | ||||||||
|
||||||||
|
||||||||
def test_point_in_path_nan(): | ||||||||
box = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]) | ||||||||
p = Path(box) | ||||||||
|
Uh oh!
There was an error while loading. Please reload this page.