Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 52231d7

Browse files
committed
add function to get center of mass of path
1 parent bdd79d8 commit 52231d7

File tree

2 files changed

+211
-2
lines changed

2 files changed

+211
-2
lines changed

lib/matplotlib/bezier.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,73 @@ def point_at_t(self, t):
218218
def arc_length(self, tol=1e-6):
219219
pass
220220

221+
def arc_center_of_mass(self):
222+
r"""
223+
Center of mass of the (even-odd-rendered) area swept out by the ray
224+
from the origin to the path.
225+
226+
Summing this vector for each segment along a closed path will produce
227+
that area's center of mass.
228+
229+
Returns
230+
-------
231+
r_cm : (2,) np.array<float>
232+
the "arc's center of mass"
233+
234+
Notes
235+
-----
236+
A simple analytical form can be derived for general Bezier curves.
237+
Suppose the curve was closed, so :math:`B(0) = B(1)`. Call the area
238+
enclosed by :math:`B(t)` :math:`B_\text{int}`. The center of mass of
239+
:math:`B_\text{int}` is defined by the expected value of the position
240+
vector `\vec{r}`
241+
242+
.. math::
243+
244+
\vec{R}_\text{cm} = \int_{B_\text{int}} \vec{r} \left( \frac{1}{
245+
\int_{B_\text{int}}} d\vec{r} \right) d\vec{r}
246+
247+
where :math:`(1/\text{Area}(B_\text{int})` can be interpreted as a
248+
probability density.
249+
250+
In order to compute this integral, we choose two functions
251+
:math:`F_0(x,y) = [x^2/2, 0]` and :math:`F_1(x,y) = [0, y^2/2]` such
252+
that :math:`[\div \cdot F_0, \div \cdot F_1] = \vec{r}`. Then, applying
253+
the divergence integral (componentwise), we get that
254+
255+
.. math::
256+
\vec{R}_\text{cm} &= \oint_{B(t)} F \cdot \vec{n} dt \\
257+
&= \int_0^1 \left[ \begin{array}{1}
258+
B^{(0)}(t) \frac{dB^{(1)}(t)}{dt} \\
259+
- B^{(1)}(t) \frac{dB^{(0)}(t)}{dt} \end{array} \right] dt
260+
261+
After expanding in Berstein polynomials and moving the integral inside
262+
all the sums, we get that
263+
264+
.. math::
265+
\vec{R}_\text{cm} = \frac{1}{6} \sum_{i,j=0}^n\sum_{k=0}^{n-1}
266+
\frac{{n \choose i}{n \choose j}{{n-1} \choose k}}
267+
{{3n - 1} \choose {i + j + k}}
268+
\left(\begin{array}{1}
269+
P^{(0)}_i P^{(0)}_j (P^{(1)}_{k+1} - P^{(1)}_k)
270+
- P^{(1)}_i P^{(1)}_j (P^{(0)}_{k+1} - P^{(0)}_k)
271+
\right) \end{array}
272+
273+
where :math:`P_i = [P^{(0)}_i, P^{(1)}_i]` is the :math:`i`'th control
274+
point of the curve and :math:`n` is the degree of the curve.
275+
"""
276+
n = self.degree
277+
r_cm = np.zeros(2)
278+
P = self.control_points
279+
dP = np.diff(P, axis=0)
280+
Pn = np.array([[1, -1]])*dP[:, ::-1] # n = [y, -x]
281+
for i in range(n + 1):
282+
for j in range(n + 1):
283+
for k in range(n):
284+
r_cm += _comb(n, i) * _comb(n, j) * _comb(n - 1, k) \
285+
* P[i]*P[j]*Pn[k] / _comb(3*n - 1, i + j + k)
286+
return r_cm/6
287+
221288
def arc_area(self):
222289
r"""
223290
(Signed) area swept out by ray from origin to curve.
@@ -306,6 +373,11 @@ def arc_area(self):
306373
* (P[j, 0]*dP[k, 1] - P[j, 1]*dP[k, 0])
307374
return (1/4)*area
308375

376+
def center_of_mass(self):
377+
# return np.array([scipy.integrate.quad(lambda t: B(t)[0], 0, 1)[0],
378+
# scipy.integrate.quad(lambda t: B(t)[1], 0, 1)[0]])
379+
pass
380+
309381
@classmethod
310382
def differentiate(cls, B):
311383
"""Return the derivative of a BezierSegment, itself a BezierSegment"""

lib/matplotlib/path.py

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -688,10 +688,147 @@ def signed_area(self, **kwargs):
688688
# add final implied CLOSEPOLY, if necessary
689689
if start_point is not None \
690690
and not np.all(np.isclose(start_point, prev_point)):
691-
B = BezierSegment(np.array([prev_point, start_point]))
692-
area += B.arc_area()
691+
Bclose = BezierSegment(np.array([prev_point, start_point]))
692+
area += Bclose.arc_area()
693693
return area
694694

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

0 commit comments

Comments
 (0)