Skip to content

Commit 65daaad

Browse files
authored
fix: preserve small rotations in quaternion logarithms (#237)
`UnitQuaternion.Rx(1e-9).log().v` currently returns `[0, 0, 0]` instead of `[5e-10, 0, 0]`. `acos(s / norm)` loses the angle when the quotient rounds to one; it also loses precision for larger small rotations. Both singleton and sequence logarithms are affected, including non-unit quaternions. Use `atan2(norm(v), s)` in both paths. This is the same principal angle for a nonzero vector part, retains small-angle information, and preserves the branch for negative scalar parts. The existing zero-vector handling and scalar `log(norm(q))` are unchanged. Regression tests cover signed small rotations from `1e-12` to `1e-4` radians, an ordinary rotation, singleton/sequence agreement, non-unit scales, `exp(log(q))`, input preservation, and the branch near the negative real axis. The original implementation fails eight scalar subcases, both sequence subcases, and the negative-real precision check. Validation on Windows / Python 3.12.14: - Complete suite: **350 passed, 3 skipped**, with all 12 new subtests passing, on both NumPy 2.5.3 / SciPy 1.18.1 and NumPy 1.26.4 / SciPy 1.15.3 (`MPLBACKEND=Agg`, the CI timeout options). - Independently checked 100 deterministic rotation vectors against `scipy.spatial.transform.Rotation`, through singleton unit, singleton scaled, and sequence unit quaternion logarithms: all **300 comparisons pass** after the fix; 270 fail a `1e-13` relative-error check before it. Maximum relative error after the fix is about `3.6e-16` in both dependency environments. - Black 23.10.0, flake8's syntax/undefined-name checks (`E9,F63,F7,F82`), and `git diff --check` pass. - Isolated sdist and wheel builds pass. This validates the quaternion math and repository tests; no ROS nodes or robot hardware were run. --------- Co-authored-by: Jinhang Dong <186017830+dddd-jh@users.noreply.github.com>
1 parent 64f4d76 commit 65daaad

2 files changed

Lines changed: 64 additions & 10 deletions

File tree

‎spatialmath/quaternion.py‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,9 @@ def log(self) -> Quaternion:
381381
382382
\ln \| q \|, \langle \frac{\vec{v}}{\| \vec{v} \|} \cos^{-1} \frac{s}{\| q \|} \rangle
383383
384+
The implementation uses the equivalent :math:`\operatorname{atan2}(\|\vec{v}\|, s)`
385+
to avoid the loss of precision in ``acos`` for small rotation angles.
386+
384387
For a ``UnitQuaternion`` the logarithm is a pure quaternion whose vector
385388
part :math:`\vec{v}` and :math:`\vec{v}/2` is a Euler vector: parallel
386389
to the axis of rotation and whose norm is the magnitude of rotation.
@@ -403,20 +406,17 @@ def log(self) -> Quaternion:
403406
norm = self.norm()
404407
s = np.log(norm)
405408
if len(self) == 1:
406-
if smb.iszerovec(self._A[1:4]):
409+
n = math.hypot(*self._A[1:4])
410+
if n == 0:
407411
v = np.zeros((3,))
408412
else:
409-
v = math.acos(np.clip(self._A[0] / norm, -1, 1)) * smb.unitvec(
410-
self._A[1:4]
411-
)
413+
v = math.atan2(n, self._A[0]) * self._A[1:4] / n
412414
return Quaternion(s=s, v=v)
413415
else:
414-
v = [
415-
np.zeros((3,))
416-
if smb.iszerovec(A[1:4])
417-
else math.acos(np.clip(A[0] / n, -1, 1)) * smb.unitvec(A[1:4])
418-
for A, n in zip(self._A, norm)
419-
]
416+
v = []
417+
for A in self._A:
418+
n = math.hypot(*A[1:4])
419+
v.append(np.zeros((3,)) if n == 0 else math.atan2(n, A[0]) * A[1:4] / n)
420420
return Quaternion(s=s, v=np.array(v))
421421

422422
def exp(self, tol: float = 20) -> Quaternion:

‎tests/test_quaternion.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,60 @@ def test_log(self):
943943
self.assertIsInstance(qq, UnitQuaternion)
944944
nt.assert_array_almost_equal(qq.vec, np.r_[1, 0, 0, 0])
945945

946+
def test_log_small_rotation(self):
947+
axis = np.array([1.0, -2.0, 3.0]) / math.sqrt(14)
948+
for angle in (1e-12, -1e-9, 1e-7, 1e-4, 0.3):
949+
for scale in (1.0, 3.0, 1e-6):
950+
with self.subTest(angle=angle, scale=scale):
951+
values = scale * np.r_[cos(angle / 2), sin(angle / 2) * axis]
952+
q = UnitQuaternion(values) if scale == 1 else Quaternion(values)
953+
original = q.vec.copy()
954+
qlog = q.log()
955+
nt.assert_allclose(qlog.v, angle / 2 * axis, rtol=1e-13, atol=0)
956+
self.assertAlmostEqual(qlog.s, math.log(scale))
957+
nt.assert_allclose(qlog.exp().vec, q.vec, rtol=1e-13, atol=0)
958+
nt.assert_array_equal(q.vec, original)
959+
960+
def test_log_small_rotation_sequence(self):
961+
angles = np.array([1e-12, -1e-9, 1e-7, 0.3])
962+
axes = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 0, 0]])
963+
values = np.column_stack(
964+
(np.cos(angles / 2), np.sin(angles[:, None] / 2) * axes)
965+
)
966+
expected = angles[:, None] / 2 * axes
967+
for scales in (
968+
np.ones(4),
969+
np.array([2.0, 3.0, 4.0, 5.0]),
970+
1e-6 * np.ones(4),
971+
):
972+
with self.subTest(scales=scales):
973+
scaled = scales[:, None] * values
974+
q = (
975+
UnitQuaternion(scaled)
976+
if np.all(scales == 1)
977+
else Quaternion(list(scaled))
978+
)
979+
original = q.vec.copy()
980+
qlog = q.log()
981+
self.assertEqual(len(qlog), len(q))
982+
nt.assert_allclose(qlog.v, expected, rtol=1e-13, atol=0)
983+
nt.assert_allclose(qlog.s, np.log(scales), atol=1e-15)
984+
for i in range(len(q)):
985+
nt.assert_allclose(qlog[i].exp().vec, scaled[i], rtol=1e-13, atol=0)
986+
nt.assert_allclose(qlog[i].vec, q[i].log().vec, rtol=1e-13, atol=0)
987+
nt.assert_array_equal(q.vec, original)
988+
989+
def test_log_near_negative_real(self):
990+
# Keep the principal quaternion branch near pi, rather than folding
991+
# a negative scalar part onto the small-angle branch.
992+
angle = 1e-7
993+
q = Quaternion([-cos(angle), sin(angle), 0, 0])
994+
expected = np.r_[0, pi - angle, 0, 0]
995+
nt.assert_allclose(q.log().vec, expected, rtol=0, atol=1e-15)
996+
nt.assert_allclose(
997+
Quaternion([q, q]).log().vec, [expected, expected], rtol=0, atol=1e-15
998+
)
999+
9461000
def test_concat(self):
9471001
u = Quaternion()
9481002
uu = Quaternion([u, u, u, u])

0 commit comments

Comments
 (0)