Skip to content

Commit cb37c8f

Browse files
committed
add function to get center of mass of path
1 parent 0dbef92 commit cb37c8f

File tree

3 files changed

+251
-2
lines changed

3 files changed

+251
-2
lines changed

lib/matplotlib/bezier.py

+76
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,73 @@ def arc_length(self, rtol=None, atol=None):
310310
return curr_est
311311
return curr_est
312312

313+
def arc_center_of_mass(self):
314+
r"""
315+
Center of mass of the (even-odd-rendered) area swept out by the ray
316+
from the origin to the path.
317+
318+
Summing this vector for each segment along a closed path will produce
319+
that area's center of mass.
320+
321+
Returns
322+
-------
323+
r_cm : (2,) np.array<float>
324+
the "arc's center of mass"
325+
326+
Notes
327+
-----
328+
A simple analytical form can be derived for general Bezier curves.
329+
Suppose the curve was closed, so :math:`B(0) = B(1)`. Call the area
330+
enclosed by :math:`B(t)` :math:`B_\text{int}`. The center of mass of
331+
:math:`B_\text{int}` is defined by the expected value of the position
332+
vector :math:`\vec{r}`
333+
334+
.. math::
335+
336+
\vec{R}_\text{cm} = \int_{B_\text{int}} \vec{r} \left( \frac{1}{
337+
\int_{B_\text{int}}} d\vec{r} \right) d\vec{r}
338+
339+
where :math:`(1/\text{Area}(B_\text{int})` can be interpreted as a
340+
probability density.
341+
342+
In order to compute this integral, we choose two functions
343+
:math:`F_0(x,y) = [x^2/2, 0]` and :math:`F_1(x,y) = [0, y^2/2]` such
344+
that :math:`[\div \cdot F_0, \div \cdot F_1] = \vec{r}`. Then, applying
345+
the divergence integral (componentwise), we get that
346+
347+
.. math::
348+
\vec{R}_\text{cm} &= \oint_{B(t)} F \cdot \vec{n} dt \\
349+
&= \int_0^1 \left[ \begin{array}{1}
350+
B^{(0)}(t) \frac{dB^{(1)}(t)}{dt} \\
351+
- B^{(1)}(t) \frac{dB^{(0)}(t)}{dt} \end{array} \right] dt
352+
353+
After expanding in Berstein polynomials and moving the integral inside
354+
all the sums, we get that
355+
356+
.. math::
357+
\vec{R}_\text{cm} = \frac{1}{6} \sum_{i,j=0}^n\sum_{k=0}^{n-1}
358+
\frac{{n \choose i}{n \choose j}{{n-1} \choose k}}
359+
{{3n - 1} \choose {i + j + k}}
360+
\left(\begin{array}{1}
361+
P^{(0)}_i P^{(0)}_j (P^{(1)}_{k+1} - P^{(1)}_k)
362+
- P^{(1)}_i P^{(1)}_j (P^{(0)}_{k+1} - P^{(0)}_k)
363+
\right) \end{array}
364+
365+
where :math:`P_i = [P^{(0)}_i, P^{(1)}_i]` is the :math:`i`'th control
366+
point of the curve and :math:`n` is the degree of the curve.
367+
"""
368+
n = self.degree
369+
r_cm = np.zeros(2)
370+
P = self.control_points
371+
dP = np.diff(P, axis=0)
372+
Pn = np.array([[1, -1]])*dP[:, ::-1] # n = [y, -x]
373+
for i in range(n + 1):
374+
for j in range(n + 1):
375+
for k in range(n):
376+
r_cm += _comb(n, i) * _comb(n, j) * _comb(n - 1, k) \
377+
* P[i]*P[j]*Pn[k] / _comb(3*n - 1, i + j + k)
378+
return r_cm/6
379+
313380
def arc_area(self):
314381
r"""
315382
(Signed) area swept out by ray from origin to curve.
@@ -406,6 +473,15 @@ def arc_area(self):
406473
* (P[j, 0]*dP[k, 1] - P[j, 1]*dP[k, 0])
407474
return (1/4)*area
408475

476+
def center_of_mass(self):
477+
"""Return the center of mass of the curve (not the filled curve!)
478+
479+
Notes
480+
-----
481+
Computed as the mean of the control points.
482+
"""
483+
return np.mean(self._cpoints, axis=0)
484+
409485
@classmethod
410486
def differentiate(cls, B):
411487
"""Return the derivative of a BezierSegment, itself a BezierSegment"""

lib/matplotlib/path.py

+139-2
Original file line numberDiff line numberDiff line change
@@ -700,10 +700,147 @@ def signed_area(self, **kwargs):
700700
# add final implied CLOSEPOLY, if necessary
701701
if start_point is not None \
702702
and not np.all(np.isclose(start_point, prev_point)):
703-
B = BezierSegment(np.array([prev_point, start_point]))
704-
area += B.arc_area()
703+
Bclose = BezierSegment(np.array([prev_point, start_point]))
704+
area += Bclose.arc_area()
705705
return area
706706

707+
def center_of_mass(self, dimension=None, **kwargs):
708+
r"""
709+
Center of mass of the path, assuming constant density.
710+
711+
The center of mass is defined to be the expected value of a vector
712+
located uniformly within either the filled area of the path
713+
(:code:`dimension=2`) or the along path's edge (:code:`dimension=1`) or
714+
along isolated points of the path (:code:`dimension=0`). Notice in
715+
particular that for this definition, if the filled area is used, then
716+
any 0- or 1-dimensional components of the path will not contribute to
717+
the center of mass. Similarly, for if *dimension* is 1, then isolated
718+
points in the path (i.e. "0-dimensional" strokes made up of only
719+
:code:`Path.MOVETO`'s) will not contribute to the center of mass.
720+
721+
For the 2d case, the center of mass is computed using the same
722+
filling strategy as `signed_area`. So, if a path is self-intersecting,
723+
the drawing rule "even-odd" is used and only the filled area is
724+
counted, and all sub paths are treated as if they had been closed. That
725+
is, if there is a MOVETO without a preceding CLOSEPOLY, one is added.
726+
727+
For the 1d measure, the curve is averaged as-is (the implied CLOSEPOLY
728+
is not added).
729+
730+
For the 0d measure, any non-isolated points are ignored.
731+
732+
Parameters
733+
----------
734+
dimension : 2, 1, or 0 (optional)
735+
Whether to compute the center of mass by taking the expected value
736+
of a position uniformly distributed within the filled path
737+
(2D-measure), the path's edge (1D-measure), or between the
738+
discrete, isolated points of the path (0D-measure), respectively.
739+
By default, the intended dimension of the path is inferred by
740+
checking first if `Path.signed_area` is non-zero (implying a
741+
*dimension* of 2), then if the `Path.length` is non-zero (implying
742+
a *dimension* of 1), and finally falling back to the counting
743+
measure (*dimension* of 0).
744+
kwargs : Dict[str, object]
745+
Passed thru to `Path.cleaned` via `Path.iter_bezier`.
746+
747+
Returns
748+
-------
749+
r_cm : (2,) np.array<float>
750+
The center of mass of the path.
751+
752+
Raises
753+
------
754+
ValueError
755+
An empty path has no well-defined center of mass.
756+
757+
In addition, if a specific *dimension* is requested and that
758+
dimension is not well-defined, an error is raised. This can happen
759+
if::
760+
761+
1) 2D expected value was requested but the path has zero area
762+
2) 1D expected value was requested but the path has only
763+
`Path.MOVETO` directives
764+
3) 0D expected value was requested but the path has NO
765+
subsequent `Path.MOVETO` directives.
766+
767+
This error cannot be raised if the function is allowed to infer
768+
what *dimension* to use.
769+
"""
770+
area = None
771+
cleaned = self.cleaned(**kwargs)
772+
move_codes = cleaned.codes == Path.MOVETO
773+
if len(cleaned.codes) == 0:
774+
raise ValueError("An empty path has no center of mass.")
775+
if dimension is None:
776+
dimension = 2
777+
area = cleaned.signed_area()
778+
if not np.isclose(area, 0):
779+
dimension -= 1
780+
if np.all(move_codes):
781+
dimension = 0
782+
if dimension == 2:
783+
# area computation can be expensive, make sure we don't repeat it
784+
if area is None:
785+
area = cleaned.signed_area()
786+
if np.isclose(area, 0):
787+
raise ValueError("2d expected value over empty area is "
788+
"ill-defined.")
789+
return cleaned._2d_center_of_mass(area)
790+
if dimension == 1:
791+
if np.all(move_codes):
792+
raise ValueError("1d expected value over empty arc-length is "
793+
"ill-defined.")
794+
return cleaned._1d_center_of_mass()
795+
if dimension == 0:
796+
adjacent_moves = (move_codes[1:] + move_codes[:-1]) == 2
797+
if len(move_codes) > 1 and not np.any(adjacent_moves):
798+
raise ValueError("0d expected value with no isolated points "
799+
"is ill-defined.")
800+
return cleaned._0d_center_of_mass()
801+
802+
def _2d_center_of_mass(self, normalization=None):
803+
#TODO: refactor this and signed_area (and maybe others, with
804+
# close= parameter)?
805+
if normalization is None:
806+
normalization = self.signed_area()
807+
r_cm = np.zeros(2)
808+
prev_point = None
809+
prev_code = None
810+
start_point = None
811+
for B, code in self.iter_bezier():
812+
if code == Path.MOVETO:
813+
if prev_code is not None and prev_code is not Path.CLOSEPOLY:
814+
Bclose = BezierSegment(np.array([prev_point, start_point]))
815+
r_cm += Bclose.arc_center_of_mass()
816+
start_point = B.control_points[0]
817+
r_cm += B.arc_center_of_mass()
818+
prev_point = B.control_points[-1]
819+
prev_code = code
820+
# add final implied CLOSEPOLY, if necessary
821+
if start_point is not None \
822+
and not np.all(np.isclose(start_point, prev_point)):
823+
Bclose = BezierSegment(np.array([prev_point, start_point]))
824+
r_cm += Bclose.arc_center_of_mass()
825+
return r_cm / normalization
826+
827+
def _1d_center_of_mass(self):
828+
r_cm = np.zeros(2)
829+
Bs = list(self.iter_bezier())
830+
arc_lengths = np.array([B.arc_length() for B in Bs])
831+
r_cms = np.array([B.center_of_mass() for B in Bs])
832+
total_length = np.sum(arc_lengths)
833+
return np.sum(r_cms*arc_lengths)/total_length
834+
835+
def _0d_center_of_mass(self):
836+
move_verts = self.codes
837+
isolated_verts = move_verts.copy()
838+
if len(move_verts) > 1:
839+
isolated_verts[:-1] = (move_verts[:-1] + move_verts[1:]) == 2
840+
isolated_verts[-1] = move_verts[-1]
841+
num_verts = np.sum(isolated_verts)
842+
return np.sum(self.vertices[isolated_verts], axis=0)/num_verts
843+
707844
def interpolated(self, steps):
708845
"""
709846
Return a new path resampled to length N x steps.

lib/matplotlib/tests/test_bezier.py

+36
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
_test_curves = [list(tc.path.iter_bezier())[-1][0] for tc in _test_curves]
1111

1212

13+
def _integral_center_of_mass(B):
14+
return np.array([scipy.integrate.quad(lambda t: B(t)[0], 0, 1)[0],
15+
scipy.integrate.quad(lambda t: B(t)[1], 0, 1)[0]])
16+
17+
1318
def _integral_arc_length(B):
1419
dB = B.differentiate(B)
1520
def integrand(t):
@@ -27,6 +32,27 @@ def integrand(t):
2732
return scipy.integrate.quad(integrand, 0, 1)[0]
2833

2934

35+
def _integral_arc_com(B):
36+
dB = B.differentiate(B)
37+
def integrand(t):
38+
dr = dB(t).T
39+
n = np.array([dr[1], -dr[0]])
40+
return B(t).T**2 * n / 2
41+
def integrand_x(t):
42+
return integrand(t)[0]
43+
def integrand_y(t):
44+
return integrand(t)[1]
45+
return np.array([
46+
scipy.integrate.quad(integrand_x, 0, 1)[0],
47+
scipy.integrate.quad(integrand_y, 0, 1)[0]
48+
])
49+
50+
51+
def _integral_com(B):
52+
return np.array([scipy.integrate.quad(lambda t: B(t)[0], 0, 1)[0],
53+
scipy.integrate.quad(lambda t: B(t)[1], 0, 1)[0]])
54+
55+
3056
@pytest.mark.parametrize("B", _test_curves)
3157
def test_area_formula(B):
3258
assert np.isclose(_integral_arc_area(B), B.arc_area())
@@ -37,3 +63,13 @@ def test_length_iteration(B):
3763
assert np.isclose(_integral_arc_length(B),
3864
B.arc_length(rtol=1e-5, atol=1e-8),
3965
rtol=1e-5, atol=1e-8)
66+
67+
68+
@pytest.mark.parametrize("B", _test_curves)
69+
def test_center_of_mass_1d(B):
70+
assert np.all(np.isclose(B.center_of_mass(), _integral_center_of_mass(B)))
71+
72+
73+
@pytest.mark.parametrize("B", _test_curves)
74+
def test_center_of_mass_2d(B):
75+
assert np.all(np.isclose(B.arc_center_of_mass(), _integral_arc_com(B)))

0 commit comments

Comments
 (0)