Skip to content

Commit b7e7bee

Browse files
Fix slerp at coincident/antipodal endpoints, and SO2/SE2.interp1() (#193)
Two crashes in the interpolation methods, found sweeping the whole slerp surface (angle between the endpoints from 0 to pi across both singular ends, `s` over [0,1], `shortest` either way). ### slerp singularities The slerp weights `sin((1-s)t)/sin(t)` and `sin(s.t)/sin(t)` are singular wherever `sin(t)` vanishes: at `t = 0` (coincident endpoints) and at `t = pi` (antipodal endpoints, which are the same rotation under the double cover). `qslerp()` guards only `t = 0`; `UnitQuaternion.interp()` and `.interp1()` re-derive the weights inline and guard neither. ```python 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 qslerp(q.vec, -q.vec, 0.5) # [0 0 0 0], not a unit quaternion ``` `acos(dotprod)` loses the small angle to rounding at both ends, so `sin(acos(dotprod))` is a poor denominator. This takes `sin(t)` directly as the length of the component of `q1` orthogonal to `q0`, which keeps full relative precision, and gets `t` from `atan2`. The only degenerate case left is `sin(t) == 0`, where the endpoints are the same rotation and so is every interpolate. The two `UnitQuaternion` methods now call `qslerp()`, which their own `:seealso:` already pointed at, so the formula lives in one place. Checked against a 50-digit `q0 exp(s log(q0^-1 q1))` reference — Lie-group form, so it shares no algebra with the sin-weight formula — over the full range of `t` and `s`: - endpoints within 1e-6 of antipodal: worst error 4.4e-5 -> 2.7e-10 rad - largest deviation from unit norm anywhere: 8.2e6 -> 2.8e-4 (just short of antipodal, `qslerp` was returning quaternions with a norm in the millions) - ordinary angles move by at most 1 ulp, and the patched surface agrees with `scipy.spatial.transform.Slerp` to 1.4e-15 rad over 10000 random pairs Exactly antipodal with `shortest=False` has no unique great circle, so there is no correct answer there; it now returns a unit quaternion for the rotation both endpoints share instead of a norm-1e6 vector or `[0 0 0 0]`. ### SO2/SE2.interp1() The fix for #33 dropped the `start` local but replaced its uses only in the `N == 3` branch, so `SE2(1, 2, 0.3).interp1(0.5)` has raised `NameError: name 'start' is not defined` ever since — #33 did report it for both SE2 and SE3. `interp1()` had no test coverage in either dimension. --- 4 new test cases, all red on master. The existing `test_slerp` and `test_interp` values were already correct and pass unchanged. Full suite green, `black` 23.10.0 clean. --------- Co-authored-by: Peter Corke <peter.i.corke@gmail.com>
1 parent 292d6ba commit b7e7bee

6 files changed

Lines changed: 166 additions & 66 deletions

File tree

spatialmath/base/quaternions.py

Lines changed: 51 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,47 @@ def r2q(
771771
# return np.r_[qs, (math.sqrt(1.0 - qs**2) / nm) * kv]
772772

773773

774+
def _qslerp_prepare(
775+
q0: ArrayLike4,
776+
q1: ArrayLike4,
777+
shortest: Optional[bool] = False,
778+
) -> tuple[
779+
UnitQuaternionArray,
780+
UnitQuaternionArray,
781+
UnitQuaternionArray,
782+
float,
783+
float,
784+
]:
785+
"""Compute the loop-invariant slerp terms for two unit quaternions.
786+
787+
The original ``q0`` endpoint is returned separately from the sign-adjusted
788+
value used by shortest-path interpolation, preserving the exact value at
789+
``s=0``.
790+
"""
791+
q0 = smb.getvector(q0, 4)
792+
q1 = smb.getvector(q1, 4)
793+
q0_endpoint = q0
794+
795+
dotprod = np.dot(q0, q1)
796+
797+
# If the dot product is negative, the quaternions
798+
# have opposite handed-ness and slerp won't take
799+
# the shorter path. Fix by reversing one quaternion.
800+
if shortest:
801+
if dotprod < 0:
802+
q0 = -q0 # pylint: disable=invalid-unary-operand-type
803+
dotprod = -dotprod # pylint: disable=invalid-unary-operand-type
804+
805+
dotprod = np.clip(dotprod, -1, 1)
806+
807+
# sin(theta) is the length of the component of q1 orthogonal to q0. Computing
808+
# it this way keeps full relative precision as theta approaches 0 or pi, where
809+
# sin(acos(dotprod)) does not: acos loses the small angle to rounding.
810+
sin_theta = float(np.linalg.norm(q1 - dotprod * q0))
811+
theta = math.atan2(sin_theta, dotprod) # theta is the angle between q0 and q1
812+
return q0_endpoint, q0, q1, sin_theta, theta
813+
814+
774815
def qslerp(
775816
q0: ArrayLike4,
776817
q1: ArrayLike4,
@@ -789,7 +830,7 @@ def qslerp(
789830
:type s: float
790831
:arg shortest: choose shortest distance [default False]
791832
:type shortest: bool
792-
:param tol: Tolerance when checking for identical quaternions, in multiples of eps, defaults to 20
833+
:param tol: Tolerance when checking for coincident quaternions, in multiples of eps, defaults to 20
793834
:type tol: float, optional
794835
:return: interpolated unit-quaternion
795836
:rtype: ndarray(4)
@@ -814,37 +855,27 @@ def qslerp(
814855
>>> qprint(qslerp(q0, q1, 1)) # this is q1
815856
>>> qprint(qslerp(q0, q1, 0.5)) # this is in "half way" between
816857
858+
.. note:: If ``q0`` and ``q1`` are the same rotation, ie. their dot product is
859+
:math:`\\pm 1`, the interpolate is that rotation for all ``s``.
860+
817861
.. warning:: There is no check that the passed values are unit-quaternions.
818862
819863
"""
820864
if not 0 <= s <= 1:
821865
raise ValueError("s must be in the interval [0,1]")
822-
q0 = smb.getvector(q0, 4)
823-
q1 = smb.getvector(q1, 4)
824-
866+
q0_endpoint, q0, q1, sin_theta, theta = _qslerp_prepare(q0, q1, shortest=shortest)
825867
if s == 0:
826-
return q0
868+
return q0_endpoint
827869
elif s == 1:
828870
return q1
829871

830-
dotprod = np.dot(q0, q1)
831-
832-
# If the dot product is negative, the quaternions
833-
# have opposite handed-ness and slerp won't take
834-
# the shorter path. Fix by reversing one quaternion.
835-
if shortest:
836-
if dotprod < 0:
837-
q0 = -q0 # pylint: disable=invalid-unary-operand-type
838-
dotprod = -dotprod # pylint: disable=invalid-unary-operand-type
839-
840-
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:
872+
if sin_theta > tol * _eps:
843873
s0 = math.sin((1 - s) * theta)
844874
s1 = math.sin(s * theta)
845-
return ((q0 * s0) + (q1 * s1)) / math.sin(theta)
875+
return ((q0 * s0) + (q1 * s1)) / sin_theta
846876
else:
847-
# quaternions are identical
877+
# theta is 0 or pi: q0 and q1 are the same rotation, so is every
878+
# interpolate between them
848879
return q0
849880

850881

spatialmath/baseposematrix.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -522,10 +522,10 @@ def interp1(self, s: float = None) -> Self:
522522
# SO(2) or SE(2)
523523
if len(s) > 1:
524524
assert len(self) == 1, "if len(s) > 1, len(X) must == 1"
525-
return self.__class__([smb.trinterp2(start, self.A, s=_s) for _s in s])
525+
return self.__class__([smb.trinterp2(None, self.A, s=_s) for _s in s])
526526
else:
527527
return self.__class__(
528-
[smb.trinterp2(start, x, s=s[0]) for x in self.data]
528+
[smb.trinterp2(None, x, s=s[0]) for x in self.data]
529529
)
530530
elif self.N == 3:
531531
# SO(3) or SE(3)

spatialmath/quaternion.py

Lines changed: 27 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import numpy as np
2020
from typing import Any
2121
import spatialmath.base as smb
22+
from spatialmath.base.quaternions import _qslerp_prepare
2223
from spatialmath.pose3d import SO3, SE3
2324
from spatialmath.baseposelist import BasePoseList
2425
from spatialmath.base.types import *
@@ -1949,32 +1950,23 @@ def interp(
19491950
# 2 quaternion form
19501951
if not isinstance(end, UnitQuaternion):
19511952
raise TypeError("end argument must be a UnitQuaternion")
1952-
q1 = self.vec
1953-
q2 = end.vec
1954-
dot = smb.qinner(q1, q2)
1955-
1956-
# If the dot product is negative, the quaternions
1957-
# have opposite handed-ness and slerp won't take
1958-
# the shorter path. Fix by reversing one quaternion.
1959-
if shortest:
1960-
if dot < 0:
1961-
q1 = -q1
1962-
dot = -dot
1963-
1964-
# shouldn't be needed by handle numerical errors: -eps, 1+eps cases
1965-
dot = np.clip(dot, -1, 1) # Clip within domain of acos()
1966-
1967-
theta_0 = math.acos(dot) # theta_0 = angle between input vectors
19681953

1954+
q0_endpoint, q0, q1, sin_theta, theta = _qslerp_prepare(
1955+
self.vec, end.vec, shortest=shortest
1956+
)
19691957
qi = []
19701958
for sk in s:
1971-
theta = theta_0 * sk # theta = angle between v0 and result
1972-
1973-
s1 = float(math.cos(theta) - dot * math.sin(theta) / math.sin(theta_0))
1974-
s2 = math.sin(theta) / math.sin(theta_0)
1975-
out = (q1 * s1) + (q2 * s2)
1959+
if sk == 0:
1960+
out = q0_endpoint
1961+
elif sk == 1:
1962+
out = q1
1963+
elif sin_theta > 20 * _eps:
1964+
s0 = math.sin((1 - sk) * theta)
1965+
s1 = math.sin(sk * theta)
1966+
out = ((q0 * s0) + (q1 * s1)) / sin_theta
1967+
else:
1968+
out = q0
19761969
qi.append(out)
1977-
19781970
return UnitQuaternion(qi)
19791971

19801972
def interp1(self, s: float = 0, shortest: Optional[bool] = False) -> UnitQuaternion:
@@ -2022,31 +2014,22 @@ def interp1(self, s: float = 0, shortest: Optional[bool] = False) -> UnitQuatern
20222014
s = smb.getvector(s)
20232015
s = np.clip(s, 0, 1) # enforce valid values
20242016

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

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

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_pose2d.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,23 @@ def test_interp(self):
466466
array_compare(T1.interp(T2, s=0.5, shortest=False), SE2(0, 0, 0.05))
467467
array_compare(T1.interp(T2, s=0.5, shortest=True), SE2(0, 0, -math.pi + 0.05))
468468

469+
def test_interp1(self):
470+
# interpolate from the identity pose
471+
TT = SE2(2, -4, 0.6)
472+
array_compare(TT.interp1(0), SE2())
473+
array_compare(TT.interp1(1), TT)
474+
array_compare(TT.interp1(0.5), SE2(1, -2, 0.3))
475+
476+
z = TT.interp1([0, 0.5, 1])
477+
self.assertEqual(len(z), 3)
478+
array_compare(z[2], TT)
479+
480+
R = SO2(0.6)
481+
array_compare(R.interp1(0), SO2())
482+
array_compare(R.interp1(1), R)
483+
array_compare(R.interp1(0.5), SO2(0.3))
484+
self.assertEqual(len(SE2([TT, TT]).interp1(0.5)), 2)
485+
469486
def test_miscellany(self):
470487
TT = SE2(1, 2, 0.3)
471488

tests/test_quaternion.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from math import pi
33
import numpy.testing as nt
44
import unittest
5+
from unittest.mock import patch
56

67
from spatialmath import *
78
from spatialmath.base import *
@@ -701,6 +702,46 @@ def test_interp(self):
701702
# qcompare( qq(6), UnitQuaternion.Rx(pi) )
702703
# TODO interp
703704

705+
def test_interp_same_rotation(self):
706+
# endpoints that are the same rotation make the slerp weights singular
707+
q = UnitQuaternion.Rx(0.3)
708+
for s in (0, 0.4, 1):
709+
for shortest in (False, True):
710+
qcompare(q.interp(q, s, shortest=shortest), q)
711+
qcompare(
712+
q.interp(UnitQuaternion.Rx(0.3 + 1e-9), s, shortest=shortest), q
713+
)
714+
qq = q.interp(q, 5)
715+
self.assertEqual(len(qq), 5)
716+
qcompare(qq[3], q)
717+
718+
u = UnitQuaternion()
719+
for s in (0, 0.4, 1):
720+
qcompare(u.interp1(s), u)
721+
qcompare(UnitQuaternion.Rx(1e-9).interp1(s), u)
722+
self.assertEqual(len(u.interp1(5)), 5)
723+
724+
# Rx(pi) and Rx(-pi) are the same rotation, with a dot product of -1
725+
p = UnitQuaternion.Rx(pi)
726+
m = UnitQuaternion.Rx(-pi)
727+
self.assertAlmostEqual(np.dot(p.vec, m.vec), -1)
728+
for shortest in (False, True):
729+
for s in (0, 0.4, 1):
730+
qi = p.interp(m, s, shortest=shortest)
731+
self.assertAlmostEqual(np.linalg.norm(qi.vec), 1)
732+
nt.assert_array_almost_equal(qi.R, p.R)
733+
for qi in p.interp(m, 5):
734+
nt.assert_array_almost_equal(qi.R, p.R)
735+
736+
def test_interp_prepares_slerp_once(self):
737+
q0 = UnitQuaternion.RPY([0.2, 0.3, 0.4])
738+
q1 = UnitQuaternion.RPY([-0.3, 0.1, 0.2])
739+
740+
for interpolate in (lambda: q0.interp1(5), lambda: q0.interp(q1, 5)):
741+
with patch("spatialmath.base.quaternions.np.dot", wraps=np.dot) as dot:
742+
self.assertEqual(len(interpolate()), 5)
743+
self.assertEqual(dot.call_count, 1)
744+
704745
def test_increment(self):
705746
q = UnitQuaternion()
706747

0 commit comments

Comments
 (0)