Skip to content

Commit 2f20bf7

Browse files
committed
Fix quaternion slerp when the endpoints are the same rotation
The slerp weights sin((1-s)t)/sin(t) and sin(s.t)/sin(t) are singular wherever sin(t) vanishes, ie. at t=0 (coincident endpoints) and t=pi (antipodal endpoints, which are the same rotation under the double cover). qslerp() guarded only t=0; UnitQuaternion.interp() and .interp1() re-derived the weights inline and guarded neither, so they raised on valid input: q = UnitQuaternion.Rx(0.3) q.interp(q, 0.5) # ZeroDivisionError UnitQuaternion().interp1(0.5) # ZeroDivisionError UnitQuaternion.Rx(pi).interp(UnitQuaternion.Rx(-pi), 0.5, shortest=True) # ZeroDivisionError UnitQuaternion.Rx(pi).interp(UnitQuaternion.Rx(-pi), 5) # TypeError, non-unit result qslerp() itself returned non-unit quaternions near t=pi: qslerp(q, -q, 0.5) gave [0 0 0 0], and for endpoints 1e-9 from antipodal the norm reached 5.8e6. Root of that: acos(dotprod) loses the small angle to rounding at both ends, so sin(acos(dotprod)) is a bad denominator. Take sin(t) directly as the length of the component of q1 orthogonal to q0, which keeps full relative precision, and get t from atan2. The only remaining degenerate case is sin(t) == 0, where q0 and q1 are the same rotation and so is every interpolate. Measured against a 50-digit q0*exp(s*log(q0^-1.q1)) reference over the whole range of t and s, worst error for t within 1e-6 of pi drops from 4.4e-5 to 2.7e-10 rad, and the largest deviation from unit norm anywhere from 8.2e6 to 2.8e-4. Ordinary angles are unchanged to within 1 ulp, and the whole surface still agrees with scipy's Slerp to 1.4e-15 rad over 10000 random pairs. The two UnitQuaternion methods now call qslerp(), which their own :seealso: already pointed at, so the formula lives in one place.
1 parent 8b83c1f commit 2f20bf7

4 files changed

Lines changed: 80 additions & 57 deletions

File tree

spatialmath/base/quaternions.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -789,7 +789,7 @@ def qslerp(
789789
:type s: float
790790
:arg shortest: choose shortest distance [default False]
791791
:type shortest: bool
792-
:param tol: Tolerance when checking for identical quaternions, in multiples of eps, defaults to 20
792+
:param tol: Tolerance when checking for coincident quaternions, in multiples of eps, defaults to 20
793793
:type tol: float, optional
794794
:return: interpolated unit-quaternion
795795
:rtype: ndarray(4)
@@ -814,6 +814,9 @@ def qslerp(
814814
>>> qprint(qslerp(q0, q1, 1)) # this is q1
815815
>>> qprint(qslerp(q0, q1, 0.5)) # this is in "half way" between
816816
817+
.. note:: If ``q0`` and ``q1`` are the same rotation, ie. their dot product is
818+
:math:`\\pm 1`, the interpolate is that rotation for all ``s``.
819+
817820
.. warning:: There is no check that the passed values are unit-quaternions.
818821
819822
"""
@@ -838,13 +841,20 @@ def qslerp(
838841
dotprod = -dotprod # pylint: disable=invalid-unary-operand-type
839842

840843
dotprod = np.clip(dotprod, -1, 1) # Clip within domain of acos()
841-
theta = math.acos(dotprod) # theta is the angle between rotation vectors
842-
if abs(theta) > tol * _eps:
844+
845+
# sin(theta) is the length of the component of q1 orthogonal to q0. Computing
846+
# it this way keeps full relative precision as theta approaches 0 or pi, where
847+
# sin(acos(dotprod)) does not: acos loses the small angle to rounding.
848+
sin_theta = float(np.linalg.norm(q1 - dotprod * q0))
849+
theta = math.atan2(sin_theta, dotprod) # theta is the angle between q0 and q1
850+
851+
if sin_theta > tol * _eps:
843852
s0 = math.sin((1 - s) * theta)
844853
s1 = math.sin(s * theta)
845-
return ((q0 * s0) + (q1 * s1)) / math.sin(theta)
854+
return ((q0 * s0) + (q1 * s1)) / sin_theta
846855
else:
847-
# quaternions are identical
856+
# theta is 0 or pi: q0 and q1 are the same rotation, so is every
857+
# interpolate between them
848858
return q0
849859

850860

spatialmath/quaternion.py

Lines changed: 6 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1946,33 +1946,10 @@ def interp(
19461946
# 2 quaternion form
19471947
if not isinstance(end, UnitQuaternion):
19481948
raise TypeError("end argument must be a UnitQuaternion")
1949-
q1 = self.vec
1950-
q2 = end.vec
1951-
dot = smb.qinner(q1, q2)
19521949

1953-
# If the dot product is negative, the quaternions
1954-
# have opposite handed-ness and slerp won't take
1955-
# the shorter path. Fix by reversing one quaternion.
1956-
if shortest:
1957-
if dot < 0:
1958-
q1 = -q1
1959-
dot = -dot
1960-
1961-
# shouldn't be needed by handle numerical errors: -eps, 1+eps cases
1962-
dot = np.clip(dot, -1, 1) # Clip within domain of acos()
1963-
1964-
theta_0 = math.acos(dot) # theta_0 = angle between input vectors
1965-
1966-
qi = []
1967-
for sk in s:
1968-
theta = theta_0 * sk # theta = angle between v0 and result
1969-
1970-
s1 = float(math.cos(theta) - dot * math.sin(theta) / math.sin(theta_0))
1971-
s2 = math.sin(theta) / math.sin(theta_0)
1972-
out = (q1 * s1) + (q2 * s2)
1973-
qi.append(out)
1974-
1975-
return UnitQuaternion(qi)
1950+
return UnitQuaternion(
1951+
[smb.qslerp(self.vec, end.vec, sk, shortest=shortest) for sk in s]
1952+
)
19761953

19771954
def interp1(self, s: float = 0, shortest: Optional[bool] = False) -> UnitQuaternion:
19781955
"""
@@ -2019,32 +1996,9 @@ def interp1(self, s: float = 0, shortest: Optional[bool] = False) -> UnitQuatern
20191996
s = smb.getvector(s)
20201997
s = np.clip(s, 0, 1) # enforce valid values
20211998

2022-
q = self.vec
2023-
dot = q[0] # s
2024-
2025-
# If the dot product is negative, the quaternions
2026-
# have opposite handed-ness and slerp won't take
2027-
# the shorter path. Fix by reversing one quaternion.
2028-
if shortest:
2029-
if dot < 0:
2030-
q = -q
2031-
dot = -dot
2032-
2033-
# shouldn't be needed by handle numerical errors: -eps, 1+eps cases
2034-
dot = np.clip(dot, -1, 1) # Clip within domain of acos()
2035-
2036-
theta_0 = math.acos(dot) # theta_0 = angle between input vectors
2037-
2038-
qi = []
2039-
for sk in s:
2040-
theta = theta_0 * sk # theta = angle between v0 and result
2041-
2042-
s1 = float(math.cos(theta) - dot * math.sin(theta) / math.sin(theta_0))
2043-
s2 = math.sin(theta) / math.sin(theta_0)
2044-
out = np.r_[s1, 0, 0, 0] + (q * s2)
2045-
qi.append(out)
2046-
2047-
return UnitQuaternion(qi)
1999+
return UnitQuaternion(
2000+
[smb.qslerp(smb.qeye(), self.vec, sk, shortest=shortest) for sk in s]
2001+
)
20482002

20492003
def increment(self, w: ArrayLike3, normalize: Optional[bool] = False) -> None:
20502004
"""

tests/base/test_quaternions.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,34 @@ def test_slerp(self):
178178
qslerp(r2q(tr.roty(0.3)), r2q(tr.roty(0.5)), 0.5), r2q(tr.roty(0.4))
179179
)
180180

181+
def test_slerp_same_rotation(self):
182+
# coincident (dot = +1) and antipodal (dot = -1) endpoints are the same
183+
# rotation, so every interpolate is that rotation
184+
q = r2q(tr.rotx(0.3))
185+
for s in (0, 0.25, 0.5, 1):
186+
for shortest in (False, True):
187+
nt.assert_array_almost_equal(qslerp(q, q, s, shortest=shortest), q)
188+
qi = qslerp(q, -q, s, shortest=shortest)
189+
self.assertAlmostEqual(np.linalg.norm(qi), 1)
190+
nt.assert_array_almost_equal(q2r(qi), tr.rotx(0.3))
191+
192+
def test_slerp_near_pi(self):
193+
# the slerp weights are sin(...)/sin(theta), singular at theta = 0 and pi.
194+
# Check against the closed form cos(s.theta) q0 + sin(s.theta) v, where v is
195+
# the unit quaternion orthogonal to q0 in the plane of the great circle.
196+
q0 = r2q(tr.rpy2r(0.2, 0.3, 0.4))
197+
v = np.r_[0, 0, 1, 0] - np.dot(np.r_[0, 0, 1, 0], q0) * q0
198+
v = v / np.linalg.norm(v)
199+
200+
for theta in (1e-6, 1e-3, 0.5, 1.5, math.pi - 1e-3, math.pi - 1e-6):
201+
q1 = math.cos(theta) * q0 + math.sin(theta) * v
202+
for s in (0.25, 0.5, 0.75):
203+
qi = qslerp(q0, q1, s)
204+
nt.assert_array_almost_equal(
205+
qi, math.cos(s * theta) * q0 + math.sin(s * theta) * v
206+
)
207+
self.assertAlmostEqual(np.linalg.norm(qi), 1)
208+
181209
def test_rotx(self):
182210
pass
183211

tests/test_quaternion.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,37 @@ def test_interp(self):
701701
# qcompare( qq(6), UnitQuaternion.Rx(pi) )
702702
# TODO interp
703703

704+
def test_interp_same_rotation(self):
705+
# endpoints that are the same rotation make the slerp weights singular
706+
q = UnitQuaternion.Rx(0.3)
707+
for s in (0, 0.4, 1):
708+
for shortest in (False, True):
709+
qcompare(q.interp(q, s, shortest=shortest), q)
710+
qcompare(
711+
q.interp(UnitQuaternion.Rx(0.3 + 1e-9), s, shortest=shortest), q
712+
)
713+
qq = q.interp(q, 5)
714+
self.assertEqual(len(qq), 5)
715+
qcompare(qq[3], q)
716+
717+
u = UnitQuaternion()
718+
for s in (0, 0.4, 1):
719+
qcompare(u.interp1(s), u)
720+
qcompare(UnitQuaternion.Rx(1e-9).interp1(s), u)
721+
self.assertEqual(len(u.interp1(5)), 5)
722+
723+
# Rx(pi) and Rx(-pi) are the same rotation, with a dot product of -1
724+
p = UnitQuaternion.Rx(pi)
725+
m = UnitQuaternion.Rx(-pi)
726+
self.assertAlmostEqual(np.dot(p.vec, m.vec), -1)
727+
for shortest in (False, True):
728+
for s in (0, 0.4, 1):
729+
qi = p.interp(m, s, shortest=shortest)
730+
self.assertAlmostEqual(np.linalg.norm(qi.vec), 1)
731+
nt.assert_array_almost_equal(qi.R, p.R)
732+
for qi in p.interp(m, 5):
733+
nt.assert_array_almost_equal(qi.R, p.R)
734+
704735
def test_increment(self):
705736
q = UnitQuaternion()
706737

0 commit comments

Comments
 (0)