robomath 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
robomath/__init__.py ADDED
@@ -0,0 +1,221 @@
1
+ """Declare all public usable functions from robomath.
2
+
3
+ Groups: SO(2), SO(3), SE(2), SE(3), Sim(3) (pose with a scale, the scale last in the
4
+ tangent vector) and SE_2(3) (rotation, velocity and position). The Transform class
5
+ wraps SE(3) only.
6
+
7
+ Planned: kinematics (screw axes, product of exponentials, forward
8
+ and inverse kinematics, Jacobians), trajectories on SO(3) and SE(3), and covariance
9
+ propagation. Estimators, simulators and ROS code stay outside this package.
10
+ """
11
+
12
+ from .convert import (
13
+ pq_to_trans,
14
+ quat_wxyz_to_xyzw,
15
+ quat_xyzw_to_wxyz,
16
+ swap_twist,
17
+ trans_to_pq,
18
+ )
19
+ from .euler import euler_zyx_to_matrix, matrix_to_euler_zyx
20
+ from .geodesic import (
21
+ distance_so3,
22
+ interp_se2,
23
+ interp_se3,
24
+ interp_so3,
25
+ mean_se2,
26
+ mean_se3,
27
+ mean_so3,
28
+ )
29
+ from .quaternions import (
30
+ axis_angle_to_quat,
31
+ euler_zyx_to_quat,
32
+ matrix_to_quat,
33
+ quat_conjugate,
34
+ quat_multiply,
35
+ quat_normalize,
36
+ quat_to_axis_angle,
37
+ quat_to_euler_zyx,
38
+ quat_to_matrix,
39
+ slerp,
40
+ )
41
+ from .registration import kabsch, umeyama
42
+ from .rotations import inverse, rotx, roty, rotz
43
+ from .se2 import (
44
+ adjoint_se2,
45
+ exp_vec_se2,
46
+ hat_se2,
47
+ lminus_se2,
48
+ log_vec_se2,
49
+ lplus_se2,
50
+ rminus_se2,
51
+ rplus_se2,
52
+ vee_se2,
53
+ )
54
+ from .se3 import (
55
+ adjoint,
56
+ angular,
57
+ exp_vec_se3,
58
+ hat_se3,
59
+ linear,
60
+ lminus_se3,
61
+ log_vec_se3,
62
+ lplus_se3,
63
+ rminus_se3,
64
+ rp_to_trans,
65
+ rplus_se3,
66
+ trans_inv,
67
+ trans_to_rp,
68
+ twist,
69
+ vee_se3,
70
+ )
71
+ from .se23 import (
72
+ adjoint_se23,
73
+ exp_vec_se23,
74
+ hat_se23,
75
+ lminus_se23,
76
+ log_vec_se23,
77
+ lplus_se23,
78
+ rminus_se23,
79
+ rplus_se23,
80
+ rvp_to_se23,
81
+ se23_inv,
82
+ se23_to_rvp,
83
+ vee_se23,
84
+ )
85
+ from .sim3 import (
86
+ adjoint_sim3,
87
+ exp_vec_sim3,
88
+ hat_sim3,
89
+ lminus_sim3,
90
+ log_vec_sim3,
91
+ lplus_sim3,
92
+ rminus_sim3,
93
+ rplus_sim3,
94
+ sim3_inv,
95
+ sim3_to_srt,
96
+ srt_to_sim3,
97
+ vee_sim3,
98
+ )
99
+ from .so2 import exp_so2, log_so2, wrap_angle
100
+ from .so3 import (
101
+ axis_angle_to_matrix,
102
+ exp_so3,
103
+ exp_vec,
104
+ hat,
105
+ jl,
106
+ jl_inv,
107
+ jr,
108
+ jr_inv,
109
+ lminus,
110
+ log_so3,
111
+ log_vec,
112
+ lplus,
113
+ matrix_to_axis_angle,
114
+ nearest_rotation,
115
+ rminus,
116
+ rplus,
117
+ vee,
118
+ )
119
+ from .transform import Transform
120
+
121
+ __all__ = [
122
+ "axis_angle_to_matrix",
123
+ "axis_angle_to_quat",
124
+ "matrix_to_axis_angle",
125
+ "matrix_to_quat",
126
+ "quat_conjugate",
127
+ "quat_multiply",
128
+ "quat_normalize",
129
+ "quat_to_axis_angle",
130
+ "quat_to_matrix",
131
+ "slerp",
132
+ "euler_zyx_to_matrix",
133
+ "inverse",
134
+ "matrix_to_euler_zyx",
135
+ "rotx",
136
+ "roty",
137
+ "rotz",
138
+ "hat",
139
+ "vee",
140
+ "exp_vec",
141
+ "log_vec",
142
+ "exp_so3",
143
+ "log_so3",
144
+ "rplus",
145
+ "rminus",
146
+ "lplus",
147
+ "lminus",
148
+ "jr",
149
+ "jr_inv",
150
+ "jl",
151
+ "jl_inv",
152
+ "euler_zyx_to_quat",
153
+ "quat_to_euler_zyx",
154
+ "nearest_rotation",
155
+ "Transform",
156
+ "adjoint_sim3",
157
+ "exp_vec_sim3",
158
+ "hat_sim3",
159
+ "lminus_sim3",
160
+ "log_vec_sim3",
161
+ "lplus_sim3",
162
+ "rminus_sim3",
163
+ "rplus_sim3",
164
+ "sim3_inv",
165
+ "sim3_to_srt",
166
+ "srt_to_sim3",
167
+ "vee_sim3",
168
+ "adjoint_se23",
169
+ "exp_vec_se23",
170
+ "hat_se23",
171
+ "lminus_se23",
172
+ "log_vec_se23",
173
+ "lplus_se23",
174
+ "rminus_se23",
175
+ "rplus_se23",
176
+ "rvp_to_se23",
177
+ "se23_inv",
178
+ "se23_to_rvp",
179
+ "vee_se23",
180
+ "pq_to_trans",
181
+ "quat_wxyz_to_xyzw",
182
+ "quat_xyzw_to_wxyz",
183
+ "swap_twist",
184
+ "trans_to_pq",
185
+ "kabsch",
186
+ "umeyama",
187
+ "distance_so3",
188
+ "interp_se2",
189
+ "interp_se3",
190
+ "interp_so3",
191
+ "mean_se2",
192
+ "mean_se3",
193
+ "mean_so3",
194
+ "exp_so2",
195
+ "log_so2",
196
+ "wrap_angle",
197
+ "adjoint_se2",
198
+ "exp_vec_se2",
199
+ "hat_se2",
200
+ "lminus_se2",
201
+ "log_vec_se2",
202
+ "lplus_se2",
203
+ "rminus_se2",
204
+ "rplus_se2",
205
+ "vee_se2",
206
+ "adjoint",
207
+ "angular",
208
+ "exp_vec_se3",
209
+ "hat_se3",
210
+ "linear",
211
+ "lminus_se3",
212
+ "log_vec_se3",
213
+ "lplus_se3",
214
+ "rminus_se3",
215
+ "rp_to_trans",
216
+ "rplus_se3",
217
+ "trans_inv",
218
+ "trans_to_rp",
219
+ "twist",
220
+ "vee_se3",
221
+ ]
robomath/_util.py ADDED
@@ -0,0 +1,16 @@
1
+ """Robomath - small helpers shared by the other modules."""
2
+
3
+ import numpy as np
4
+
5
+
6
+ def norm(v: np.ndarray) -> np.ndarray:
7
+ """Length of the vectors along the last axis, shape (...).
8
+
9
+ Same result as np.linalg.norm(v, axis=-1)
10
+ """
11
+ return np.sqrt(np.einsum("...i,...i->...", v, v))
12
+
13
+
14
+ def apply(a: np.ndarray, v: np.ndarray) -> np.ndarray:
15
+ """Matrix-vector product for stacks: a (..., n, n) times v (..., n)."""
16
+ return (a @ v[..., None])[..., 0]
robomath/convert.py ADDED
@@ -0,0 +1,68 @@
1
+ """Robomath - Convert between robomath's layouts and those of other libraries.
2
+
3
+ Inside robomath a quaternion is (x, y, z, w) and a tangent vector is (translation,
4
+ rotation). Other libraries differ, and a mix-up still runs but gives wrong results.
5
+ Each function here changes only the layout, named by the layout and not the library.
6
+ Every function accepts leading batch dimensions.
7
+
8
+ Quaternion (x, y, z, w), scalar last: scipy, ROS messages, Eigen coeffs(), Pinocchio.
9
+ Quaternion (w, x, y, z), scalar first: Drake, MuJoCo, Ceres.
10
+ Tangent vector (translation, rotation): Sola's paper, Sophus, manif, Pinocchio.
11
+ Tangent vector (rotation, translation): Modern Robotics, Drake, MuJoCo, GTSAM Pose3.
12
+
13
+ Not here yet: poses with the camera convention
14
+ (OpenCV and COLMAP store world-to-camera, so the inverse of ours), other Euler
15
+ sequences, SE(2) layouts such as GTSAM's, and ROS message classes (this module takes
16
+ plain arrays, so it needs no ROS install).
17
+ """
18
+
19
+ import numpy as np
20
+
21
+ from .quaternions import matrix_to_quat, quat_to_matrix
22
+ from .se3 import angular, linear, rp_to_trans, trans_to_rp, twist
23
+
24
+
25
+ def quat_xyzw_to_wxyz(q: np.ndarray) -> np.ndarray:
26
+ """Move the scalar part from last to first: (x, y, z, w) -> (w, x, y, z)."""
27
+ return np.roll(np.asarray(q, dtype=float), 1, axis=-1)
28
+
29
+
30
+ def quat_wxyz_to_xyzw(q: np.ndarray) -> np.ndarray:
31
+ """Move the scalar part from first to last: (w, x, y, z) -> (x, y, z, w)."""
32
+ return np.roll(np.asarray(q, dtype=float), -1, axis=-1)
33
+
34
+
35
+ def swap_twist(tau: np.ndarray) -> np.ndarray:
36
+ """Swap the halves of a tangent vector, (a, b) -> (b, a).
37
+
38
+ Turns (translation, rotation) into (rotation, translation) and back; the swap is
39
+ its own inverse. Works for a tangent vector or a spatial velocity.
40
+ """
41
+ tau = np.asarray(tau, dtype=float)
42
+ return twist(angular(tau), linear(tau))
43
+
44
+
45
+ def pq_to_trans(p: np.ndarray, q: np.ndarray) -> np.ndarray:
46
+ """4x4 pose from a position and an (x, y, z, w) quaternion, as in a ROS Pose.
47
+
48
+ Args:
49
+ p (np.ndarray): Positions, shape (..., 3).
50
+ q (np.ndarray): Unit quaternions, shape (..., 4).
51
+
52
+ Returns:
53
+ np.ndarray: The poses, shape (..., 4, 4).
54
+ """
55
+ return rp_to_trans(quat_to_matrix(q), p)
56
+
57
+
58
+ def trans_to_pq(m: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
59
+ """Position and (x, y, z, w) quaternion of a 4x4 pose, as in a ROS Pose.
60
+
61
+ Args:
62
+ m (np.ndarray): Poses, shape (..., 4, 4).
63
+
64
+ Returns:
65
+ tuple[np.ndarray, np.ndarray]: (p, q), shapes (..., 3) and (..., 4).
66
+ """
67
+ r, t = trans_to_rp(m)
68
+ return t, matrix_to_quat(r)
robomath/euler.py ADDED
@@ -0,0 +1,100 @@
1
+ """Robomath - ZYX Euler angles.
2
+
3
+ Every function accepts leading batch dimensions.
4
+ """
5
+
6
+ import math
7
+
8
+ import numpy as np
9
+
10
+ GIMBAL_TOL = 1e-15
11
+
12
+
13
+ def euler_zyx_to_matrix(alpha: float, beta: float, gamma: float) -> np.ndarray:
14
+ """Rotation matrix for ZYX Euler angles.
15
+
16
+ Args:
17
+ alpha (float): Angle about z, in radians, scalar or shape (...).
18
+ beta (float): Angle about y, in radians; broadcasts with alpha.
19
+ gamma (float): Angle about x, in radians; broadcasts with alpha.
20
+
21
+ Returns:
22
+ np.ndarray: The rotation matrix, shape (..., 3, 3),
23
+ R = Rz(alpha) Ry(beta) Rx(gamma).
24
+ """
25
+ alpha, beta, gamma = (np.asarray(a, dtype=float) for a in (alpha, beta, gamma))
26
+ if alpha.ndim == beta.ndim == gamma.ndim == 0:
27
+ ca, sa = math.cos(alpha), math.sin(alpha)
28
+ cb, sb = math.cos(beta), math.sin(beta)
29
+ cg, sg = math.cos(gamma), math.sin(gamma)
30
+ return np.array(
31
+ [
32
+ [ca * cb, ca * sb * sg - sa * cg, ca * sb * cg + sa * sg],
33
+ [sa * cb, sa * sb * sg + ca * cg, sa * sb * cg - ca * sg],
34
+ [-sb, cb * sg, cb * cg],
35
+ ]
36
+ )
37
+ ca, sa = np.cos(alpha), np.sin(alpha)
38
+ cb, sb = np.cos(beta), np.sin(beta)
39
+ cg, sg = np.cos(gamma), np.sin(gamma)
40
+ r = np.empty(np.broadcast_shapes(alpha.shape, beta.shape, gamma.shape) + (3, 3))
41
+ r[..., 0, 0] = ca * cb
42
+ r[..., 0, 1] = ca * sb * sg - sa * cg
43
+ r[..., 0, 2] = ca * sb * cg + sa * sg
44
+ r[..., 1, 0] = sa * cb
45
+ r[..., 1, 1] = sa * sb * sg + ca * cg
46
+ r[..., 1, 2] = sa * sb * cg - ca * sg
47
+ r[..., 2, 0] = -sb
48
+ r[..., 2, 1] = cb * sg
49
+ r[..., 2, 2] = cb * cg
50
+ return r
51
+
52
+
53
+ def matrix_to_euler_zyx(r: np.ndarray) -> tuple[float, float, float]:
54
+ """ZYX Euler angles of a rotation matrix.
55
+
56
+ At gimbal lock (beta = +-pi/2) only one combination of alpha and gamma is
57
+ fixed by the matrix, so alpha is set to 0 and gamma takes the rest.
58
+
59
+ Args:
60
+ r (np.ndarray): Rotation matrices, shape (..., 3, 3).
61
+
62
+ Returns:
63
+ tuple[float, float, float]: (alpha, beta, gamma) in radians, each of
64
+ shape (...), with alpha and gamma in (-pi, pi] and beta in
65
+ [-pi/2, pi/2].
66
+ """
67
+ r = np.asarray(r, dtype=float)
68
+ if r.ndim == 2:
69
+ (r11, r12, _), (r21, r22, _), (r31, r32, r33) = r.tolist()
70
+ if abs(r31 + 1) < GIMBAL_TOL:
71
+ return 0.0, math.pi / 2, math.atan2(r12, r22)
72
+ if abs(r31 - 1) < GIMBAL_TOL:
73
+ return 0.0, -math.pi / 2, -math.atan2(r12, r22)
74
+ return (
75
+ math.atan2(r21, r11),
76
+ math.atan2(-r31, math.hypot(r11, r21)),
77
+ math.atan2(r32, r33),
78
+ )
79
+ r11, r12 = r[..., 0, 0], r[..., 0, 1]
80
+ r21, r22 = r[..., 1, 0], r[..., 1, 1]
81
+ r31, r32, r33 = r[..., 2, 0], r[..., 2, 1], r[..., 2, 2]
82
+
83
+ # Both gimbal-lock cases and the general one are computed, then chosen by
84
+ # mask, so a batch can mix them.
85
+ lock_up = np.abs(r31 + 1) < GIMBAL_TOL
86
+ lock_down = np.abs(r31 - 1) < GIMBAL_TOL
87
+ locked_gamma = np.arctan2(r12, r22)
88
+
89
+ beta = np.where(
90
+ lock_up,
91
+ np.pi / 2,
92
+ np.where(lock_down, -np.pi / 2, np.arctan2(-r31, np.hypot(r11, r21))),
93
+ )
94
+ alpha = np.where(lock_up | lock_down, 0.0, np.arctan2(r21, r11))
95
+ gamma = np.where(
96
+ lock_up,
97
+ locked_gamma,
98
+ np.where(lock_down, -locked_gamma, np.arctan2(r32, r33)),
99
+ )
100
+ return alpha[()], beta[()], gamma[()]
robomath/geodesic.py ADDED
@@ -0,0 +1,148 @@
1
+ """Robomath - Distance, interpolation and mean on SO(3) and SE(3).
2
+
3
+ All of them follow the shortest path (the geodesic) given by the right plus and
4
+ minus of the group: the step from A to B is minus(B, A), and moving a fraction t
5
+ of it is plus(A, t * minus(B, A)). Every function accepts leading batch dimensions.
6
+ """
7
+
8
+ import warnings
9
+
10
+ import numpy as np
11
+
12
+ from ._util import norm
13
+ from .quaternions import matrix_to_quat, quat_to_matrix
14
+ from .se2 import rminus_se2, rplus_se2
15
+ from .se3 import angular, rminus_se3, rp_to_trans, rplus_se3, trans_to_rp
16
+ from .so2 import exp_so2, log_so2
17
+ from .so3 import rminus, rplus
18
+
19
+ MEAN_TOL = 1e-13
20
+ MEAN_ITERATIONS = 100
21
+ UNIQUE_ANGLE = np.pi / 2
22
+
23
+
24
+ def distance_so3(r1: np.ndarray, r2: np.ndarray) -> np.ndarray:
25
+ """Angle in [0, pi] of the rotation that takes r1 to r2, shape (...)."""
26
+ return norm(rminus(r2, r1))
27
+
28
+
29
+ def interp_so3(r1: np.ndarray, r2: np.ndarray, t: np.ndarray) -> np.ndarray:
30
+ """Rotation a fraction t of the way from r1 (t = 0) to r2 (t = 1)."""
31
+ t = np.asarray(t, dtype=float)
32
+ return rplus(r1, t[..., None] * rminus(r2, r1))
33
+
34
+
35
+ def interp_se2(m1: np.ndarray, m2: np.ndarray, t: np.ndarray) -> np.ndarray:
36
+ """Planar pose a fraction t of the way from m1 (t = 0) to m2 (t = 1)."""
37
+ t = np.asarray(t, dtype=float)
38
+ return rplus_se2(m1, t[..., None] * rminus_se2(m2, m1))
39
+
40
+
41
+ def interp_se3(m1: np.ndarray, m2: np.ndarray, t: np.ndarray) -> np.ndarray:
42
+ """Pose a fraction t of the way from m1 (t = 0) to m2 (t = 1)."""
43
+ t = np.asarray(t, dtype=float)
44
+ return rplus_se3(m1, t[..., None] * rminus_se3(m2, m1))
45
+
46
+
47
+ def _weighted(w, x):
48
+ """Weighted sum over the N items: w (..., N) times x (..., N, k) -> (..., k)."""
49
+ return np.einsum("...n,...nk->...k", w, x)
50
+
51
+
52
+ def _start_so3(rotations, w):
53
+ """Chordal mean: the top eigenvector of the weighted sum of q q^T (Markley).
54
+
55
+ No iteration and no local minima, and it does not depend on the item order.
56
+ Same mean as scipy's Rotation.mean, written here from the method.
57
+ """
58
+ q = matrix_to_quat(rotations)
59
+ k = np.einsum("...n,...ni,...nj->...ij", w, q, q)
60
+ return quat_to_matrix(np.linalg.eigh(k)[1][..., -1])
61
+
62
+
63
+ def _start_se3(poses, w):
64
+ r, t = trans_to_rp(poses)
65
+ return rp_to_trans(_start_so3(r, w), _weighted(w, t))
66
+
67
+
68
+ def _start_se2(poses, w):
69
+ r, t = trans_to_rp(poses)
70
+ theta = log_so2(r)
71
+ mean_angle = np.arctan2(
72
+ np.einsum("...n,...n->...", w, np.sin(theta)),
73
+ np.einsum("...n,...n->...", w, np.cos(theta)),
74
+ )
75
+ return rp_to_trans(exp_so2(mean_angle), _weighted(w, t))
76
+
77
+
78
+ def _angle_se3(tau):
79
+ """Rotation angle of each SE(3) tangent vector."""
80
+ return norm(angular(tau))
81
+
82
+
83
+ def _angle_se2(tau):
84
+ """Rotation angle of each SE(2) tangent vector."""
85
+ return np.abs(tau[..., 2])
86
+
87
+
88
+ def _mean(items, weights, start, plus, minus, angle):
89
+ """Karcher mean: move the guess by the weighted mean of its offsets to the items.
90
+
91
+ items has shape (..., N, a, a); the N items are averaged. The first guess is
92
+ the chordal mean of the rotations, so the answer does not depend on the item
93
+ order. angle maps tangent offsets to the rotation angle of each. The mean is
94
+ unique when all items lie within 90 degrees of it. Beyond that the loop can
95
+ settle on a local answer, so it warns.
96
+ """
97
+ items = np.asarray(items, dtype=float)
98
+ n = items.shape[-3]
99
+ if n == 0:
100
+ raise ValueError("the mean of no items is undefined")
101
+ w = np.full(n, 1.0 / n) if weights is None else np.asarray(weights, dtype=float)
102
+ if np.any(w < 0) or np.any(np.sum(w, axis=-1) <= 0):
103
+ raise ValueError("weights must be non-negative and not all zero")
104
+ w = w / np.sum(w, axis=-1, keepdims=True)
105
+ mean = start(items, w)
106
+ for _ in range(MEAN_ITERATIONS):
107
+ step = np.sum(w[..., None] * minus(items, mean[..., None, :, :]), axis=-2)
108
+ mean = plus(mean, step)
109
+ if np.max(np.abs(step), initial=0.0) < MEAN_TOL:
110
+ offsets = minus(items, mean[..., None, :, :])
111
+ if np.max(angle(offsets), initial=0.0) >= UNIQUE_ANGLE:
112
+ warnings.warn(
113
+ "an item is 90 degrees or more from the mean; "
114
+ "the mean may not be unique",
115
+ stacklevel=3,
116
+ )
117
+ return mean
118
+ raise ValueError("mean did not settle; the items are too spread out")
119
+
120
+
121
+ def mean_so3(rotations: np.ndarray, weights: np.ndarray | None = None) -> np.ndarray:
122
+ """Mean of N rotations, shape (..., N, 3, 3) -> (..., 3, 3).
123
+
124
+ Args:
125
+ rotations (np.ndarray): The rotations to average.
126
+ weights (np.ndarray | None): Weights, shape (..., N). Equal if None.
127
+ """
128
+ return _mean(rotations, weights, _start_so3, rplus, rminus, norm)
129
+
130
+
131
+ def mean_se3(poses: np.ndarray, weights: np.ndarray | None = None) -> np.ndarray:
132
+ """Mean of N poses, shape (..., N, 4, 4) -> (..., 4, 4).
133
+
134
+ Args:
135
+ poses (np.ndarray): The poses to average.
136
+ weights (np.ndarray | None): Weights, shape (..., N). Equal if None.
137
+ """
138
+ return _mean(poses, weights, _start_se3, rplus_se3, rminus_se3, _angle_se3)
139
+
140
+
141
+ def mean_se2(poses: np.ndarray, weights: np.ndarray | None = None) -> np.ndarray:
142
+ """Mean of N planar poses, shape (..., N, 3, 3) -> (..., 3, 3).
143
+
144
+ Args:
145
+ poses (np.ndarray): The poses to average.
146
+ weights (np.ndarray | None): Weights, shape (..., N). Equal if None.
147
+ """
148
+ return _mean(poses, weights, _start_se2, rplus_se2, rminus_se2, _angle_se2)