Skip to content

Commit fc49cdb

Browse files
authored
Fix order argument for quat_to_euler, quat_from_euler & mat_from_eulet (#94)
* add failing tests * fix quat_to_euler and quat_from_euler * self review * bump version because its a breaking change * also align mat_from_euler
1 parent 2ec7a46 commit fc49cdb

File tree

6 files changed

+242
-62
lines changed

6 files changed

+242
-62
lines changed

pylinalg/matrix.py

+36-26
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
from math import cos, sin
2-
31
import numpy as np
42
from numpy.lib.stride_tricks import as_strided
53

@@ -129,8 +127,9 @@ def mat_from_euler(angles, /, *, order="xyz", out=None, dtype=None):
129127
angles : ndarray, [3]
130128
The euler angles.
131129
order : string, optional
132-
The order in which the rotations should be applied. Default
133-
is "xyz".
130+
The rotation order as a string. Can include 'X', 'Y', 'Z' for intrinsic
131+
rotation (uppercase) or 'x', 'y', 'z' for extrinsic rotation (lowercase).
132+
Default is "xyz".
134133
out : ndarray, optional
135134
A location into which the result is stored. If provided, it
136135
must have a shape that the inputs broadcast to. If not provided or
@@ -155,31 +154,42 @@ def mat_from_euler(angles, /, *, order="xyz", out=None, dtype=None):
155154
ccw around the y-axis, and finally 0° around the x axis.
156155
157156
"""
158-
angles = np.asarray(angles, dtype=float)
159-
order = order.lower()
160-
161-
if angles.ndim == 0:
162-
# add dimension to allow zip
163-
angles = angles[None]
164-
165-
matrices = []
157+
fill_out_first = out is not None
158+
angles = np.atleast_1d(np.asarray(angles))
159+
extrinsic = order.islower()
160+
order = order.upper()
161+
axis_lookup = {"X": 0, "Y": 1, "Z": 2}
162+
if extrinsic:
163+
angles = reversed(angles)
164+
order = reversed(order)
166165
for angle, axis in zip(angles, order):
167-
axis_idx = {"x": 0, "y": 1, "z": 2}[axis]
168-
169-
matrix = np.array([[cos(angle), -sin(angle)], [sin(angle), cos(angle)]])
170-
if axis_idx == 1:
171-
matrix = matrix.T
172-
matrix = np.insert(matrix, axis_idx, 0, axis=0)
173-
matrix = np.insert(matrix, axis_idx, 0, axis=1)
174-
matrix[axis_idx, axis_idx] = 1
175-
166+
axis_idx = axis_lookup[axis]
176167
affine_matrix = np.identity(4, dtype=dtype)
177-
affine_matrix[:3, :3] = matrix
178-
179-
matrices.append(affine_matrix)
180168

181-
# note: combining in the loop would save time and memory usage
182-
return mat_combine([x for x in reversed(matrices)], out=out, dtype=dtype)
169+
if axis_idx == 0:
170+
affine_matrix[1, 1] = np.cos(angle)
171+
affine_matrix[1, 2] = -np.sin(angle)
172+
affine_matrix[2, 1] = np.sin(angle)
173+
affine_matrix[2, 2] = np.cos(angle)
174+
elif axis_idx == 1:
175+
affine_matrix[0, 0] = np.cos(angle)
176+
affine_matrix[0, 2] = np.sin(angle)
177+
affine_matrix[2, 0] = -np.sin(angle)
178+
affine_matrix[2, 2] = np.cos(angle)
179+
elif axis_idx == 2:
180+
affine_matrix[0, 0] = np.cos(angle)
181+
affine_matrix[0, 1] = -np.sin(angle)
182+
affine_matrix[1, 0] = np.sin(angle)
183+
affine_matrix[1, 1] = np.cos(angle)
184+
185+
if fill_out_first:
186+
out[:] = affine_matrix
187+
fill_out_first = False
188+
elif out is None:
189+
out = affine_matrix
190+
else:
191+
out @= affine_matrix
192+
return out
183193

184194

185195
def mat_from_axis_angle(axis, angle, /, *, out=None, dtype=None):

pylinalg/quaternion.py

+9-7
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ def quat_from_axis_angle(axis, angle, /, *, out=None, dtype=None):
260260
return out
261261

262262

263-
def quat_from_euler(angles, /, *, order="XYZ", out=None, dtype=None):
263+
def quat_from_euler(angles, /, *, order="xyz", out=None, dtype=None):
264264
"""
265265
Create a quaternion from euler angles.
266266
@@ -269,8 +269,9 @@ def quat_from_euler(angles, /, *, order="XYZ", out=None, dtype=None):
269269
angles : ndarray, [3]
270270
A set of XYZ euler angles.
271271
order : string, optional
272-
The order in which the rotations should be applied. Default
273-
is "xyz".
272+
The rotation order as a string. Can include 'X', 'Y', 'Z' for intrinsic
273+
rotation (uppercase) or 'x', 'y', 'z' for extrinsic rotation (lowercase).
274+
Default is "xyz".
274275
out : ndarray, optional
275276
A location into which the result is stored. If provided, it
276277
must have a shape that the inputs broadcast to. If not provided or
@@ -293,8 +294,9 @@ def quat_from_euler(angles, /, *, order="XYZ", out=None, dtype=None):
293294
out = np.empty((*batch_shape, 4), dtype=dtype)
294295

295296
# work out the sequence in which to apply the rotations
296-
is_extrinsic = [x.isupper() for x in order]
297-
order = [{"X": 0, "Y": 1, "Z": 2}[x.upper()] for x in order]
297+
is_extrinsic = [x.islower() for x in order]
298+
basis_index = {"x": 0, "y": 1, "z": 2}
299+
order = [basis_index[x] for x in order.lower()]
298300

299301
# convert each euler matrix into a quaternion
300302
quaternions = np.zeros((len(order), *batch_shape, 4), dtype=float)
@@ -305,9 +307,9 @@ def quat_from_euler(angles, /, *, order="XYZ", out=None, dtype=None):
305307
out[:] = quaternions[0]
306308
for idx in range(1, len(quaternions)):
307309
if is_extrinsic[idx]:
308-
quat_mul(out, quaternions[idx], out=out)
309-
else:
310310
quat_mul(quaternions[idx], out, out=out)
311+
else:
312+
quat_mul(out, quaternions[idx], out=out)
311313

312314
return out
313315

pylinalg/vector.py

+124-25
Original file line numberDiff line numberDiff line change
@@ -480,13 +480,17 @@ def vec_spherical_safe(vector, /, *, out=None, dtype=None):
480480
return out
481481

482482

483-
def quat_to_euler(quaternion, /, *, out=None, dtype=None):
484-
"""Convert quaternions to XYZ Euler angles.
483+
def quat_to_euler(quaternion, /, *, order="xyz", out=None, dtype=None):
484+
"""Convert quaternions to Euler angles with specified rotation order.
485485
486486
Parameters
487487
----------
488488
quaternion : ndarray, [4]
489489
The quaternion to convert (in xyzw format).
490+
order : str, optional
491+
The rotation order as a string. Can include 'X', 'Y', 'Z' for intrinsic
492+
rotation (uppercase) or 'x', 'y', 'z' for extrinsic rotation (lowercase).
493+
Default is "xyz".
490494
out : ndarray, optional
491495
A location into which the result is stored. If provided, it
492496
must have a shape that the inputs broadcast to. If not provided or
@@ -498,35 +502,130 @@ def quat_to_euler(quaternion, /, *, out=None, dtype=None):
498502
Returns
499503
-------
500504
out : ndarray, [3]
501-
The XYZ Euler angles.
505+
The Euler angles in the specified order.
506+
507+
References
508+
----------
509+
This implementation is based on the method described in the following paper:
510+
Bernardes E, Viollet S (2022) Quaternion to Euler angles conversion: A direct, general and computationally efficient method.
511+
PLoS ONE 17(11): e0276302. https://doi.org/10.1371/journal.pone.0276302
502512
"""
503513
quaternion = np.asarray(quaternion, dtype=float)
514+
quat = np.atleast_2d(quaternion)
515+
num_rotations = quat.shape[0]
504516

505517
if out is None:
506518
out = np.empty((*quaternion.shape[:-1], 3), dtype=dtype)
507519

508-
ysqr = quaternion[..., 1] ** 2
509-
510-
t0 = 2 * (
511-
quaternion[..., 3] * quaternion[..., 0]
512-
+ quaternion[..., 1] * quaternion[..., 2]
513-
)
514-
t1 = 1 - 2 * (quaternion[..., 0] ** 2 + ysqr)
515-
out[..., 0] = np.arctan2(t0, t1)
516-
517-
t2 = 2 * (
518-
quaternion[..., 3] * quaternion[..., 1]
519-
- quaternion[..., 2] * quaternion[..., 0]
520-
)
521-
t2 = np.clip(t2, a_min=-1, a_max=1)
522-
out[..., 1] = np.arcsin(t2)
523-
524-
t3 = 2 * (
525-
quaternion[..., 3] * quaternion[..., 2]
526-
+ quaternion[..., 0] * quaternion[..., 1]
527-
)
528-
t4 = 1 - 2 * (ysqr + quaternion[..., 2] ** 2)
529-
out[..., 2] = np.arctan2(t3, t4)
520+
extrinsic = order.islower()
521+
order = order.lower()
522+
if not extrinsic:
523+
order = order[::-1]
524+
525+
basis_index = {"x": 0, "y": 1, "z": 2}
526+
i = basis_index[order[0]]
527+
j = basis_index[order[1]]
528+
k = basis_index[order[2]]
529+
530+
is_proper = i == k
531+
532+
if is_proper:
533+
k = 3 - i - j # get third axis
534+
535+
# Step 0
536+
# Check if permutation is even (+1) or odd (-1)
537+
sign = int((i - j) * (j - k) * (k - i) / 2)
538+
539+
eps = 1e-7
540+
for ind in range(num_rotations):
541+
if num_rotations == 1 and out.ndim == 1:
542+
_angles = out
543+
else:
544+
_angles = out[ind, :]
545+
546+
if is_proper:
547+
a = quat[ind, 3]
548+
b = quat[ind, i]
549+
c = quat[ind, j]
550+
d = quat[ind, k] * sign
551+
else:
552+
a = quat[ind, 3] - quat[ind, j]
553+
b = quat[ind, i] + quat[ind, k] * sign
554+
c = quat[ind, j] + quat[ind, 3]
555+
d = quat[ind, k] * sign - quat[ind, i]
556+
557+
n2 = a**2 + b**2 + c**2 + d**2
558+
559+
# Step 3
560+
# Compute second angle...
561+
# _angles[1] = 2*np.arccos(np.sqrt((a**2 + b**2) / n2))
562+
_angles[1] = np.arccos(2 * (a**2 + b**2) / n2 - 1)
563+
564+
# ... and check if it is equal to 0 or pi, causing a singularity
565+
safe1 = np.abs(_angles[1]) >= eps
566+
safe2 = np.abs(_angles[1] - np.pi) >= eps
567+
safe = safe1 and safe2
568+
569+
# Step 4
570+
# compute first and third angles, according to case
571+
if safe:
572+
half_sum = np.arctan2(b, a) # == (alpha+gamma)/2
573+
half_diff = np.arctan2(-d, c) # == (alpha-gamma)/2
574+
575+
_angles[0] = half_sum + half_diff
576+
_angles[2] = half_sum - half_diff
577+
578+
else:
579+
# _angles[0] = 0
580+
581+
if not extrinsic:
582+
# For intrinsic, set first angle to zero so that after reversal we
583+
# ensure that third angle is zero
584+
# 6a
585+
if not safe:
586+
_angles[0] = 0
587+
if not safe1:
588+
half_sum = np.arctan2(b, a)
589+
_angles[2] = 2 * half_sum
590+
# 6c
591+
if not safe2:
592+
half_diff = np.arctan2(-d, c)
593+
_angles[2] = -2 * half_diff
594+
else:
595+
# For extrinsic, set third angle to zero
596+
# 6b
597+
if not safe:
598+
_angles[2] = 0
599+
if not safe1:
600+
half_sum = np.arctan2(b, a)
601+
_angles[0] = 2 * half_sum
602+
# 6c
603+
if not safe2:
604+
half_diff = np.arctan2(-d, c)
605+
_angles[0] = 2 * half_diff
606+
607+
for i_ in range(3):
608+
if _angles[i_] < -np.pi:
609+
_angles[i_] += 2 * np.pi
610+
elif _angles[i_] > np.pi:
611+
_angles[i_] -= 2 * np.pi
612+
613+
# for Tait-Bryan angles
614+
if not is_proper:
615+
_angles[2] *= sign
616+
_angles[1] -= np.pi / 2
617+
618+
if not extrinsic:
619+
# reversal
620+
_angles[0], _angles[2] = _angles[2], _angles[0]
621+
622+
# Step 8
623+
if not safe:
624+
logger.warning(
625+
"Gimbal lock detected. Setting third angle to zero "
626+
"since it is not possible to uniquely determine "
627+
"all angles."
628+
)
530629

531630
return out
532631

pyproject.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[project]
44
name = "pylinalg"
5-
version = "0.5.1"
5+
version = "0.6.0"
66
description = "Linear algebra utilities for Python"
77
readme = "README.md"
88
license = { file = "LICENSE" }
@@ -30,6 +30,7 @@ tests = [
3030
"hypothesis[numpy]~=6.61.0",
3131
"packaging",
3232
"twine",
33+
"scipy",
3334
]
3435
dev = ["pylinalg[lint,tests,docs]"]
3536

tests/test_matrix.py

+24
Original file line numberDiff line numberDiff line change
@@ -356,3 +356,27 @@ def test_mat_look_at(eye, target, up_reference):
356356
# (map up_reference from target to source space and check if it's in the YZ-plane)
357357
new_reference = rotation.T @ la.vec_homogeneous(up_reference)
358358
assert np.abs(new_reference[0]) < 1e-10
359+
360+
361+
def test_mat_euler_vs_scipy():
362+
"""Compare our implementation with scipy's."""
363+
from scipy.spatial.transform import Rotation as R # noqa: N817
364+
365+
cases = [
366+
("XYZ", [np.pi / 2, np.pi / 180, 0]),
367+
("xyz", [np.pi / 2, np.pi / 180, 0]),
368+
("ZXY", [np.pi, np.pi / 180, -np.pi / 180]),
369+
("zxy", [np.pi, np.pi / 180, -np.pi / 180]),
370+
("ZYX", [0, np.pi / 2, np.pi / 2]),
371+
("zyx", [0, np.pi / 2, np.pi / 2]),
372+
]
373+
374+
for order, angles in cases:
375+
scipy_mat = np.identity(4)
376+
scipy_mat[:3, :3] = R.from_euler(order, angles).as_matrix()
377+
378+
npt.assert_allclose(
379+
la.mat_from_euler(angles, order=order),
380+
scipy_mat,
381+
atol=1e-15,
382+
)

0 commit comments

Comments
 (0)