Skip to content

compute mean of a set of rotations #160

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

Merged
merged 16 commits into from
Jan 26, 2025
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 33 additions & 10 deletions spatialmath/pose3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -843,22 +843,22 @@ def Exp(

def UnitQuaternion(self) -> UnitQuaternion:
"""
SO3 as a unit quaternion instance
SO3 as a unit quaternion instance

:return: a unit quaternion representation
:rtype: UnitQuaternion instance
:return: a unit quaternion representation
:rtype: UnitQuaternion instance

``R.UnitQuaternion()`` is an ``UnitQuaternion`` instance representing the same rotation
as the SO3 rotation ``R``.
``R.UnitQuaternion()`` is an ``UnitQuaternion`` instance representing the same rotation
as the SO3 rotation ``R``.

Example:
Example:

.. runblock:: pycon
.. runblock:: pycon

>>> from spatialmath import SO3
>>> SO3.Rz(0.3).UnitQuaternion()
>>> from spatialmath import SO3
>>> SO3.Rz(0.3).UnitQuaternion()

"""
"""
# Function level import to avoid circular dependencies
from spatialmath import UnitQuaternion

Expand Down Expand Up @@ -931,6 +931,29 @@ def angdist(self, other: SO3, metric: int = 6) -> Union[float, ndarray]:
else:
return ad

def mean(self, tol: float = 20) -> SO3:
"""Mean of a set of rotations

:param tol: iteration tolerance in units of eps, defaults to 20
:type tol: float, optional
:return: the mean rotation
:rtype: :class:`SO3` instance.

Computes the Karcher mean of the set of rotations within the SO(3) instance.

:references:
- `**Hartley, Trumpf** - "Rotation Averaging" - IJCV 2011 <https://users.cecs.anu.edu.au/~hartley/Papers/PDF/Hartley-Trumpf:Rotation-averaging:IJCV.pdf>`_
- `Karcher mean <https://en.wikipedia.org/wiki/Karcher_mean`_
"""

eta = tol * np.finfo(float).eps
R_mean = self[0] # initial guess
while True:
r = np.dstack((self.inv() * self).log()).mean(axis=2)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's unclear to me what section of the reference paper outlines this algorithm (admittedly, I did not thoroughly read it) . Would you mind pointing it out to me?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've clarified that.

if np.linalg.norm(r) < eta:
return R_mean
R_mean = R_mean @ self.Exp(r) # update estimate and normalize


# ============================== SE3 =====================================#

Expand Down
83 changes: 69 additions & 14 deletions spatialmath/quaternion.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ def __init__(self, s: Any = None, v=None, check: Optional[bool] = True):
r"""
Construct a new quaternion

:param s: scalar
:type s: float
:param v: vector
:type v: 3-element array_like
:param s: scalar part
:type s: float or ndarray(N)
:param v: vector part
:type v: ndarray(3), ndarray(Nx3)

- ``Quaternion()`` constructs a zero quaternion
- ``Quaternion(s, v)`` construct a new quaternion from the scalar ``s``
Expand Down Expand Up @@ -78,7 +78,7 @@ def __init__(self, s: Any = None, v=None, check: Optional[bool] = True):
super().__init__()

if s is None and smb.isvector(v, 4):
v,s = (s,v)
v, s = (s, v)

if v is None:
# single argument
Expand All @@ -92,6 +92,11 @@ def __init__(self, s: Any = None, v=None, check: Optional[bool] = True):
# Quaternion(s, v)
self.data = [np.r_[s, smb.getvector(v)]]

elif (
smb.isvector(s) and smb.ismatrix(v, (None, 3)) and s.shape[0] == v.shape[0]
):
# Quaternion(s, v) where s and v are arrays
self.data = [np.r_[_s, _v] for _s, _v in zip(s, v)]
else:
raise ValueError("bad argument to Quaternion constructor")

Expand Down Expand Up @@ -395,9 +400,23 @@ def log(self) -> Quaternion:
:seealso: :meth:`Quaternion.exp` :meth:`Quaternion.log` :meth:`UnitQuaternion.angvec`
"""
norm = self.norm()
s = math.log(norm)
v = math.acos(np.clip(self.s / norm, -1, 1)) * smb.unitvec(self.v)
return Quaternion(s=s, v=v)
s = np.log(norm)
if len(self) == 1:
if smb.iszerovec(self._A[1:4]):
v = np.zeros((3,))
else:
v = math.acos(np.clip(self._A[0] / norm, -1, 1)) * smb.unitvec(
self._A[1:4]
)
return Quaternion(s=s, v=v)
else:
v = [
np.zeros((3,))
if smb.iszerovec(A[1:4])
else math.acos(np.clip(A[0] / n, -1, 1)) * smb.unitvec(A[1:4])
for A, n in zip(self._A, norm)
]
return Quaternion(s=s, v=np.array(v))

def exp(self, tol: float = 20) -> Quaternion:
r"""
Expand Down Expand Up @@ -437,7 +456,11 @@ def exp(self, tol: float = 20) -> Quaternion:
exp_s = math.exp(self.s)
norm_v = smb.norm(self.v)
s = exp_s * math.cos(norm_v)
v = exp_s * self.v / norm_v * math.sin(norm_v)
if smb.iszerovec(self.v, tol * _eps):
# result will be a unit quaternion
v = np.zeros((3,))
else:
v = exp_s * self.v / norm_v * math.sin(norm_v)
if abs(self.s) < tol * _eps:
# result will be a unit quaternion
return UnitQuaternion(s=s, v=v)
Expand Down Expand Up @@ -1260,7 +1283,7 @@ def Eul(cls, *angles: List[float], unit: Optional[str] = "rad") -> UnitQuaternio
Construct a new unit quaternion from Euler angles

:param 𝚪: 3-vector of Euler angles
:type 𝚪: array_like
:type 𝚪: 3 floats, array_like(3) or ndarray(N,3)
:param unit: angular units: 'rad' [default], or 'deg'
:type unit: str
:return: unit-quaternion
Expand All @@ -1286,20 +1309,23 @@ def Eul(cls, *angles: List[float], unit: Optional[str] = "rad") -> UnitQuaternio
if len(angles) == 1:
angles = angles[0]

return cls(smb.r2q(smb.eul2r(angles, unit=unit)), check=False)
if smb.isvector(angles, 3):
return cls(smb.r2q(smb.eul2r(angles, unit=unit)), check=False)
else:
return cls([smb.r2q(smb.eul2r(a, unit=unit)) for a in angles], check=False)

@classmethod
def RPY(
cls,
*angles: List[float],
*angles,
order: Optional[str] = "zyx",
unit: Optional[str] = "rad",
) -> UnitQuaternion:
r"""
Construct a new unit quaternion from roll-pitch-yaw angles

:param 𝚪: 3-vector of roll-pitch-yaw angles
:type 𝚪: array_like
:type 𝚪: 3 floats, array_like(3) or ndarray(N,3)
:param unit: angular units: 'rad' [default], or 'deg'
:type unit: str
:param unit: rotation order: 'zyx' [default], 'xyz', or 'yxz'
Expand Down Expand Up @@ -1341,7 +1367,13 @@ def RPY(
if len(angles) == 1:
angles = angles[0]

return cls(smb.r2q(smb.rpy2r(angles, unit=unit, order=order)), check=False)
if smb.isvector(angles, 3):
return cls(smb.r2q(smb.rpy2r(angles, unit=unit, order=order)), check=False)
else:
return cls(
[smb.r2q(smb.rpy2r(a, unit=unit, order=order)) for a in angles],
check=False,
)

@classmethod
def OA(cls, o: ArrayLike3, a: ArrayLike3) -> UnitQuaternion:
Expand Down Expand Up @@ -1569,6 +1601,29 @@ def dotb(self, omega: ArrayLike3) -> R4:
"""
return smb.qdotb(self._A, omega)

def mean(self, tol: float = 20) -> SO3:
"""Mean of a set of rotations

:param tol: iteration tolerance in units of eps, defaults to 20
:type tol: float, optional
:return: the mean rotation
:rtype: :class:`UnitQuaternion` instance.

Computes the Karcher mean of the set of rotations within the unit quaternion instance.

:references:
- `**Hartley, Trumpf** - "Rotation Averaging" - IJCV 2011 <https://users.cecs.anu.edu.au/~hartley/Papers/PDF/Hartley-Trumpf:Rotation-averaging:IJCV.pdf>`_
- `Karcher mean <https://en.wikipedia.org/wiki/Karcher_mean`_
"""

eta = tol * np.finfo(float).eps
q_mean = self[0] # initial guess
while True:
r = (self.inv() * self).log().vec.mean(axis=0)
if np.linalg.norm(r) < eta:
return q_mean
q_mean = q_mean @ self.Exp(r) # update estimate and normalize

def __mul__(
left, right: UnitQuaternion
) -> UnitQuaternion: # lgtm[py/not-named-self] pylint: disable=no-self-argument
Expand Down
23 changes: 23 additions & 0 deletions tests/test_pose3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,29 @@ def test_functions_lie(self):
nt.assert_equal(R, SO3.EulerVec(R.eulervec()))
np.testing.assert_equal((R.inv() * R).eulervec(), np.zeros(3))

R = SO3() # identity matrix case

# Check log and exponential map
nt.assert_equal(R, SO3.Exp(R.log()))
np.testing.assert_equal((R.inv() * R).log(), np.zeros([3, 3]))

# Check euler vector map
nt.assert_equal(R, SO3.EulerVec(R.eulervec()))
np.testing.assert_equal((R.inv() * R).eulervec(), np.zeros(3))

def test_mean(self):
rpy = np.ones((100, 1)) @ np.c_[0.1, 0.2, 0.3]
R = SO3.RPY(rpy)
self.assertEqual(len(R), 100)
m = R.mean()
self.assertIsInstance(m, SO3)
array_compare(m, R[0])

# now add noise
rpy += 0.1 * np.random.rand(100, 3)
m = R.mean()
array_compare(m, SO3.RPY(0.1, 0.2, 0.3))


# ============================== SE3 =====================================#

Expand Down
92 changes: 87 additions & 5 deletions tests/test_quaternion.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,25 +257,79 @@ def test_staticconstructors(self):
UnitQuaternion.Rz(theta, "deg").R, rotz(theta, "deg")
)

def test_constructor_RPY(self):
# 3 angle
q = UnitQuaternion.RPY([0.1, 0.2, 0.3])
self.assertIsInstance(q, UnitQuaternion)
self.assertEqual(len(q), 1)
nt.assert_array_almost_equal(q.R, rpy2r(0.1, 0.2, 0.3))
q = UnitQuaternion.RPY(0.1, 0.2, 0.3)
self.assertIsInstance(q, UnitQuaternion)
self.assertEqual(len(q), 1)
nt.assert_array_almost_equal(q.R, rpy2r(0.1, 0.2, 0.3))
q = UnitQuaternion.RPY(np.r_[0.1, 0.2, 0.3])
self.assertIsInstance(q, UnitQuaternion)
self.assertEqual(len(q), 1)
nt.assert_array_almost_equal(q.R, rpy2r(0.1, 0.2, 0.3))

nt.assert_array_almost_equal(
UnitQuaternion.RPY([0.1, 0.2, 0.3]).R, rpy2r(0.1, 0.2, 0.3)
UnitQuaternion.RPY([10, 20, 30], unit="deg").R,
rpy2r(10, 20, 30, unit="deg"),
)

nt.assert_array_almost_equal(
UnitQuaternion.Eul([0.1, 0.2, 0.3]).R, eul2r(0.1, 0.2, 0.3)
UnitQuaternion.RPY([0.1, 0.2, 0.3], order="xyz").R,
rpy2r(0.1, 0.2, 0.3, order="xyz"),
)

angles = np.array(
[[0.1, 0.2, 0.3], [0.2, 0.3, 0.4], [0.3, 0.4, 0.5], [0.4, 0.5, 0.6]]
)
q = UnitQuaternion.RPY(angles)
self.assertIsInstance(q, UnitQuaternion)
self.assertEqual(len(q), 4)
for i in range(4):
nt.assert_array_almost_equal(q[i].R, rpy2r(angles[i, :]))

q = UnitQuaternion.RPY(angles, order="xyz")
self.assertIsInstance(q, UnitQuaternion)
self.assertEqual(len(q), 4)
for i in range(4):
nt.assert_array_almost_equal(q[i].R, rpy2r(angles[i, :], order="xyz"))

angles *= 10
q = UnitQuaternion.RPY(angles, unit="deg")
self.assertIsInstance(q, UnitQuaternion)
self.assertEqual(len(q), 4)
for i in range(4):
nt.assert_array_almost_equal(q[i].R, rpy2r(angles[i, :], unit="deg"))

def test_constructor_Eul(self):
nt.assert_array_almost_equal(
UnitQuaternion.RPY([10, 20, 30], unit="deg").R,
rpy2r(10, 20, 30, unit="deg"),
UnitQuaternion.Eul([0.1, 0.2, 0.3]).R, eul2r(0.1, 0.2, 0.3)
)

nt.assert_array_almost_equal(
UnitQuaternion.Eul([10, 20, 30], unit="deg").R,
eul2r(10, 20, 30, unit="deg"),
)

angles = np.array(
[[0.1, 0.2, 0.3], [0.2, 0.3, 0.4], [0.3, 0.4, 0.5], [0.4, 0.5, 0.6]]
)
q = UnitQuaternion.Eul(angles)
self.assertIsInstance(q, UnitQuaternion)
self.assertEqual(len(q), 4)
for i in range(4):
nt.assert_array_almost_equal(q[i].R, eul2r(angles[i, :]))

angles *= 10
q = UnitQuaternion.Eul(angles, unit="deg")
self.assertIsInstance(q, UnitQuaternion)
self.assertEqual(len(q), 4)
for i in range(4):
nt.assert_array_almost_equal(q[i].R, eul2r(angles[i, :], unit="deg"))

def test_constructor_AngVec(self):
# (theta, v)
th = 0.2
v = unitvec([1, 2, 3])
Expand All @@ -286,6 +340,7 @@ def test_staticconstructors(self):
)
nt.assert_array_almost_equal(UnitQuaternion.AngVec(th, -v).R, angvec2r(th, -v))

def test_constructor_EulerVec(self):
# (theta, v)
th = 0.2
v = unitvec([1, 2, 3])
Expand Down Expand Up @@ -830,6 +885,20 @@ def test_log(self):
nt.assert_array_almost_equal(q1.log().exp(), q1)
nt.assert_array_almost_equal(q2.log().exp(), q2)

q = Quaternion([q1, q2, q1, q2])
qlog = q.log()
nt.assert_array_almost_equal(qlog[0].exp(), q1)
nt.assert_array_almost_equal(qlog[1].exp(), q2)
nt.assert_array_almost_equal(qlog[2].exp(), q1)
nt.assert_array_almost_equal(qlog[3].exp(), q2)

q = UnitQuaternion() # identity
qlog = q.log()
nt.assert_array_almost_equal(qlog.vec, np.zeros(4))
qq = qlog.exp()
self.assertIsInstance(qq, UnitQuaternion)
nt.assert_array_almost_equal(qq.vec, np.r_[1, 0, 0, 0])

def test_concat(self):
u = Quaternion()
uu = Quaternion([u, u, u, u])
Expand Down Expand Up @@ -1018,6 +1087,19 @@ def test_miscellany(self):
nt.assert_equal(q.inner(q), q.norm() ** 2)
nt.assert_equal(q.inner(u), np.dot(q.vec, u.vec))

def test_mean(self):
rpy = np.ones((100, 1)) @ np.c_[0.1, 0.2, 0.3]
q = UnitQuaternion.RPY(rpy)
self.assertEqual(len(q), 100)
m = q.mean()
self.assertIsInstance(m, UnitQuaternion)
nt.assert_array_almost_equal(m.vec, q[0].vec)

# now add noise
rpy += 0.1 * np.random.rand(100, 3)
m = q.mean()
nt.assert_array_almost_equal(m.vec, q.RPY(0.1, 0.2, 0.3).vec)


# ---------------------------------------------------------------------------------------#
if __name__ == "__main__":
Expand Down
Loading