Skip to content

Commit 291907b

Browse files
committed
add more runnable examples, polish doco and math notation, added polygon method, fixed bug with Polynomial
1 parent d521aca commit 291907b

2 files changed

Lines changed: 159 additions & 43 deletions

File tree

spatialmath/geom2d.py

Lines changed: 158 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,9 @@ def __str__(self) -> str:
278278
"""
279279
return f"Polygon2 with {len(self.path)} vertices"
280280

281+
def __repr__(self) -> str:
282+
return str(self)
283+
281284
def __len__(self) -> int:
282285
"""
283286
Number of vertices in polygon
@@ -671,7 +674,7 @@ def __init__(
671674
radii: Optional[ArrayLike2] = None,
672675
E: Optional[NDArray] = None,
673676
centre: ArrayLike2 = (0, 0),
674-
theta: float = None,
677+
theta: Optional[float] = None,
675678
):
676679
r"""
677680
Create an ellipse
@@ -686,22 +689,23 @@ def __init__(
686689
:type theta: float, optional
687690
:raises ValueError: bad parameters
688691
689-
The ellipse shape can be specified by ``radii`` and ``theta`` or by a 2x2
690-
matrix ``E``.
692+
The ellipse shape can be specified by ``radii`` and ``theta`` or by a
693+
symmetric 2x2 matrix ``E``.
691694
692-
Internally the ellipse is represented by a 2x2 matrix and the centre coordinate
693-
such that
695+
Internally the ellipse is represented by a symmetric matrix :math:`\mat{E} \in \mathbb{R}^{2\times 2}`
696+
and its centre coordinate :math:`\vec{x}_0 \in \mathbb{R}^2` such that
694697
695698
.. math::
696699
697-
(\vec{x} - \vec{x}_0)^T \mat{E} (\vec{x} - \vec{x}_0) = 1
700+
(\vec{x} - \vec{x}_0)^{\top} \mat{E} \, (\vec{x} - \vec{x}_0) = 1
698701
699702
Example:
700703
701704
.. runblock:: pycon
702705
703706
>>> from spatialmath import Ellipse
704-
>>> Ellipse(radii=(1,2), theta=0
707+
>>> import numpy as np
708+
>>> Ellipse(radii=(1,2), theta=0)
705709
>>> Ellipse(E=np.array([[1, 1], [1, 2]]))
706710
707711
"""
@@ -724,30 +728,43 @@ def __init__(
724728
self._centre = centre
725729

726730
@classmethod
727-
def Polynomial(cls, e: ArrayLike) -> Self:
731+
def Polynomial(cls, e: ArrayLike, p: Optional[ArrayLike2] = None) -> Self:
728732
r"""
729733
Create an ellipse from polynomial
730734
731735
:param e: polynomial coeffients :math:`e` or :math:`\eta`
732736
:type e: arraylike(4) or arraylike(5)
737+
:param p: point to set scale
738+
:type p: array_like(2), optional
733739
:return: an ellipse instance
734740
:rtype: Ellipse
735741
736-
An ellipse can be specified by a polynomial
742+
An ellipse can be specified by a polynomial :math:`\vec{e} \in \mathbb{R}^6`
737743
738744
.. math::
739745
740746
e_0 x^2 + e_1 y^2 + e_2 xy + e_3 x + e_4 y + e_5 = 0
741747
742-
or
748+
or :math:`\vec{\epsilon} \in \mathbb{R}^5` where the leading coefficient is
749+
implicitly one
743750
744751
.. math::
745752
746-
x^2 + \eta_1 y^2 + \eta_2 xy + \eta_3 x + \eta_4 y + \eta_5 = 0
753+
x^2 + \epsilon_1 y^2 + \epsilon_2 xy + \epsilon_3 x + \epsilon_4 y + \epsilon_5 = 0
754+
755+
In this latter case, position, orientation and aspect ratio of the
756+
ellipse will be correct, but the overall scale of the ellipse is not
757+
determined. To correct this, we can pass in a single point ``p`` that
758+
we know lies on the perimeter of the ellipse.
747759
748-
The ellipse matrix and centre coordinate are determined from the polynomial
749-
coefficients.
760+
Example:
761+
762+
.. runblock:: pycon
750763
764+
>>> from spatialmath import Ellipse
765+
>>> Ellipse.Polynomial([0.625, 0.625, 0.75, -6.75, -7.25, 24.625])
766+
767+
:seealso: :meth:`polynomial`
751768
"""
752769
e = np.array(e)
753770
if len(e) == 5:
@@ -765,15 +782,13 @@ def Polynomial(cls, e: ArrayLike) -> Self:
765782
# fmt: on
766783

767784
# solve for the centre
768-
# fmt: off
769-
M = -2 * np.array([
770-
[a, c],
771-
[c, b],
772-
])
773-
# fmt: on
774-
centre = np.linalg.lstsq(M, e[3:5], rcond=None)[0]
785+
centre = np.linalg.lstsq(-2 * E, e[3:5], rcond=None)[0]
775786

776-
z = e[5] - a * centre[0] ** 2 - b * centre[1] ** 2 - 2 * c * np.prod(centre)
787+
if p is not None:
788+
# point was passed in, use this to set the scale
789+
p = smb.getvector(p, 2) - centre
790+
s = p @ E @ p
791+
E /= s
777792

778793
return cls(E=E, centre=centre)
779794

@@ -819,6 +834,19 @@ def FromPerimeter(cls, p: Points2) -> Self:
819834
:type p: ndarray(2,N)
820835
:return: an ellipse instance
821836
:rtype: Ellipse
837+
838+
Example:
839+
840+
.. runblock:: pycon
841+
842+
>>> from spatialmath import Ellipse
843+
>>> import numpy as np
844+
>>> eref = Ellipse(radii=(1, 2), theta=np.pi / 4, centre=[3, 4])
845+
>>> perim = eref.points()
846+
>>> print(perim.shape)
847+
>>> Ellipse.FromPerimeter(perim)
848+
849+
:seealso: :meth:`points`
822850
"""
823851
A = []
824852
b = []
@@ -829,8 +857,8 @@ def FromPerimeter(cls, p: Points2) -> Self:
829857
# x^2 + eta[0] y^2 + eta[1] xy + eta[2] x + eta[3] y + eta[4] = 0
830858
e = np.linalg.lstsq(A, b, rcond=None)[0]
831859

832-
# solve for the quadratic term
833-
return cls.Polynomial(e)
860+
# create ellipse from the polynomial, using one point to set scale
861+
return cls.Polynomial(e, p[:, 0])
834862

835863
def __str__(self) -> str:
836864
return f"Ellipse(radii={self.radii}, centre={self.centre}, theta={self.theta})"
@@ -846,13 +874,22 @@ def E(self):
846874
:return: ellipse matrix
847875
:rtype: ndarray(2,2)
848876
849-
The matrix ``E`` describes the shape of the ellipse
877+
The symmetric matrix :math:`\mat{E} \in \mathbb{R}^{2\times 2}` determines the radii and
878+
the orientation of the ellipse
850879
851880
.. math::
852881
853-
(\vec{x} - \vec{x}_0)^T \mat{E} (\vec{x} - \vec{x}_0) = 1
882+
(\vec{x} - \vec{x}_0)^{\top} \mat{E} \, (\vec{x} - \vec{x}_0) = 1
854883
855884
:seealso: :meth:`centre` :meth:`theta` :meth:`radii`
885+
886+
Example:
887+
888+
.. runblock:: pycon
889+
890+
>>> from spatialmath import Ellipse
891+
>>> e = Ellipse(radii=(1,2), centre=(3,4), theta=0.5)
892+
>>> e.E
856893
"""
857894
# return 2x2 ellipse matrix
858895
return self._E
@@ -865,6 +902,14 @@ def centre(self) -> R2:
865902
:return: centre of the ellipse
866903
:rtype: ndarray(2)
867904
905+
Example:
906+
907+
.. runblock:: pycon
908+
909+
>>> from spatialmath import Ellipse
910+
>>> e = Ellipse(radii=(1,2), centre=(3,4), theta=0.5)
911+
>>> e.centre
912+
868913
:seealso: :meth:`radii` :meth:`theta` :meth:`E`
869914
"""
870915
# return centre
@@ -878,6 +923,14 @@ def radii(self) -> R2:
878923
:return: radii of the ellipse
879924
:rtype: ndarray(2)
880925
926+
Example:
927+
928+
.. runblock:: pycon
929+
930+
>>> from spatialmath import Ellipse
931+
>>> e = Ellipse(radii=(1,2), centre=(3,4), theta=0.5)
932+
>>> e.radii
933+
881934
:seealso: :meth:`centre` :meth:`theta` :meth:`E`
882935
"""
883936
return np.linalg.eigvals(self.E) ** (-0.5)
@@ -890,6 +943,14 @@ def theta(self) -> float:
890943
:return: orientation in radians, in the interval [-pi, pi)
891944
:rtype: float
892945
946+
Example:
947+
948+
.. runblock:: pycon
949+
950+
>>> from spatialmath import Ellipse
951+
>>> e = Ellipse(radii=(1,2), centre=(3,4), theta=0.5)
952+
>>> e.theta
953+
893954
:seealso: :meth:`centre` :meth:`radii` :meth:`E`
894955
"""
895956
e, x = np.linalg.eigh(self.E)
@@ -903,20 +964,41 @@ def area(self) -> float:
903964
904965
:return: area
905966
:rtype: float
967+
968+
Example:
969+
970+
.. runblock:: pycon
971+
972+
>>> from spatialmath import Ellipse
973+
>>> e = Ellipse(radii=(1,2), centre=(3,4), theta=0.5)
974+
>>> e.area
906975
"""
907976
return np.pi / np.sqrt(np.linalg.det(self.E))
908977

909978
@property
910979
def polynomial(self):
911-
"""
980+
r"""
912981
Return ellipse as a polynomial
913982
914983
:return: polynomial
915984
:rtype: ndarray(6)
916985
986+
An ellipse can be described by :math:`\vec{e} \in \mathbb{R}^6` which are the
987+
coefficents of a quadratic in :math:`x` and :math:`y`
988+
917989
.. math::
918990
919991
e_0 x^2 + e_1 y^2 + e_2 xy + e_3 x + e_4 y + e_5 = 0
992+
993+
Example:
994+
995+
.. runblock:: pycon
996+
997+
>>> from spatialmath import Ellipse
998+
>>> e = Ellipse(radii=(1,2), centre=(3,4), theta=0.5)
999+
>>> e.polynomial
1000+
1001+
:seealso: :meth:`Polynomial`
9201002
"""
9211003
a = self._E[0, 0]
9221004
b = self._E[1, 1]
@@ -930,15 +1012,15 @@ def polynomial(self):
9301012
2 * c,
9311013
-2 * a * x_0 - 2 * c * y_0,
9321014
-2 * b * y_0 - 2 * c * x_0,
933-
a * x_0**2 + b * y_0**2,
1015+
a * x_0**2 + b * y_0**2 + 2 * c * x_0 * y_0,
9341016
]
9351017
)
9361018

9371019
def plot(self, **kwargs) -> None:
9381020
"""
9391021
Plot ellipse
9401022
941-
:param kwargs: arguments passed to :func:`~spatialmath.base.graphics.plot_ellipse
1023+
:param kwargs: arguments passed to :func:`~spatialmath.base.graphics.plot_ellipse`
9421024
:return: list of artists
9431025
:rtype: _type_
9441026
@@ -956,17 +1038,19 @@ def plot(self, **kwargs) -> None:
9561038
9571039
from spatialmath import Ellipse
9581040
from spatialmath.base import plotvol2
959-
plotvol2(5)
1041+
ax = plotvol2(5)
9601042
e = Ellipse(E=np.array([[1, 1], [1, 2]]))
9611043
e.plot()
1044+
ax.grid()
9621045
9631046
.. plot::
9641047
9651048
from spatialmath import Ellipse
9661049
from spatialmath.base import plotvol2
967-
plotvol2(5)
1050+
ax = plotvol2(5)
9681051
e = Ellipse(E=np.array([[1, 1], [1, 2]]))
9691052
e.plot(filled=True, color='r')
1053+
ax.grid()
9701054
9711055
:seealso: :func:`~spatialmath.base.graphics.plot_ellipse`
9721056
"""
@@ -980,6 +1064,16 @@ def contains(self, p):
9801064
:type p: arraylike(2), ndarray(2,N)
9811065
:return: true if point is contained within ellipse
9821066
:rtype: bool or list(bool)
1067+
1068+
Example:
1069+
1070+
.. runblock:: pycon
1071+
1072+
>>> from spatialmath import Ellipse
1073+
>>> e = Ellipse(radii=(1,2), centre=(3,4), theta=0.5)
1074+
>>> e.contains((3,4))
1075+
>>> e.contains((0,0))
1076+
9831077
"""
9841078
inside = []
9851079
p = smb.getmatrix(p, (2, None))
@@ -1001,24 +1095,46 @@ def points(self, resolution=20) -> Points2:
10011095
:return: set of perimeter points
10021096
:rtype: Points2
10031097
1004-
Return a set of `resolution` points on the perimeter of the ellipse. The perimeter
1098+
Return a set of ``resolution`` points on the perimeter of the ellipse. The perimeter
10051099
set is not closed, that is, last point != first point.
10061100
1007-
:seealso: :func:`~spatialmath.base.graphics.ellipse`
1101+
1102+
Example:
1103+
1104+
.. runblock:: pycon
1105+
1106+
>>> from spatialmath import Ellipse
1107+
>>> e = Ellipse(radii=(1,2), centre=(3,4), theta=0.5)
1108+
>>> e.points()[:,:5] # first 5 points
1109+
1110+
:seealso: :meth:`polygon` :func:`~spatialmath.base.graphics.ellipse`
10081111
"""
10091112
return smb.ellipse(self.E, self.centre, resolution=resolution)
10101113

1114+
def polygon(self, resolution=10) -> Polygon2:
1115+
"""
1116+
Approximate with a polygon
1117+
1118+
:param resolution: number of polygon vertices, defaults to 10
1119+
:type resolution: int, optional
1120+
:return: a polygon approximating the ellipse
1121+
:rtype: :class:`Polygon2` instance
1122+
1123+
Return a polygon instance with ``resolution`` vertices. A :class:`Polygon2`` can be
1124+
used for intersection testing with lines or other polygons.
1125+
1126+
Example:
1127+
1128+
.. runblock:: pycon
1129+
1130+
>>> from spatialmath import Ellipse
1131+
>>> e = Ellipse(radii=(1,2), centre=(3,4), theta=0.5)
1132+
>>> e.polygon()
1133+
1134+
:seealso: :meth:`points`
1135+
"""
1136+
return Polygon2(smb.ellipse(self.E, self.centre, resolution=resolution - 1))
10111137

1012-
# alpha, beta, gamma, xc, yc, e0, e1, e2, e3, e4, e5 = symbols("alpha, beta, gamma, xc, yc, e0, e1, e2, e3, e4, e5")
1013-
# solve(eq, [alpha, beta, gamma, xc, yc])
1014-
# eq = [
1015-
# alpha - e0,
1016-
# beta- e1,
1017-
# 2 * gamma - e2,
1018-
# -2 * (alpha * xc + gamma * yc) - e3,
1019-
# -2 * (beta * yc + gamma * xc) - e4,
1020-
# alpha * xc**2 + beta * yc**2 + 2 * gamma * xc * yc - 1 - e5
1021-
# ]
10221138

10231139
if __name__ == "__main__":
10241140
pass

tests/test_geom2d.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ def test_FromPerimeter(self):
195195
p = eref.points()
196196

197197
e = Ellipse.FromPerimeter(p)
198-
# nt.assert_almost_equal(e.radii, eref.radii) # HACK
198+
nt.assert_almost_equal(e.radii, eref.radii)
199199
nt.assert_almost_equal(e.centre, eref.centre)
200200
nt.assert_almost_equal(e.theta, eref.theta)
201201

0 commit comments

Comments
 (0)