airo-spatial-algebra 2025.4.0__tar.gz
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.
- airo_spatial_algebra-2025.4.0/PKG-INFO +14 -0
- airo_spatial_algebra-2025.4.0/README.md +7 -0
- airo_spatial_algebra-2025.4.0/airo_spatial_algebra/__init__.py +4 -0
- airo_spatial_algebra-2025.4.0/airo_spatial_algebra/operations.py +82 -0
- airo_spatial_algebra-2025.4.0/airo_spatial_algebra/py.typed +0 -0
- airo_spatial_algebra-2025.4.0/airo_spatial_algebra/se3.py +214 -0
- airo_spatial_algebra-2025.4.0/airo_spatial_algebra.egg-info/PKG-INFO +14 -0
- airo_spatial_algebra-2025.4.0/airo_spatial_algebra.egg-info/SOURCES.txt +13 -0
- airo_spatial_algebra-2025.4.0/airo_spatial_algebra.egg-info/dependency_links.txt +1 -0
- airo_spatial_algebra-2025.4.0/airo_spatial_algebra.egg-info/requires.txt +4 -0
- airo_spatial_algebra-2025.4.0/airo_spatial_algebra.egg-info/top_level.txt +1 -0
- airo_spatial_algebra-2025.4.0/setup.cfg +4 -0
- airo_spatial_algebra-2025.4.0/setup.py +17 -0
- airo_spatial_algebra-2025.4.0/test/test_operations.py +39 -0
- airo_spatial_algebra-2025.4.0/test/test_se3_container.py +132 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: airo_spatial_algebra
|
|
3
|
+
Version: 2025.4.0
|
|
4
|
+
Summary: Code for working with SE3 poses, transforms... for robotic manipulation at the Ghent University AI and Robotics Lab
|
|
5
|
+
Author: Thomas Lips
|
|
6
|
+
Author-email: thomas.lips@ugent.be
|
|
7
|
+
Requires-Dist: numpy<2.0
|
|
8
|
+
Requires-Dist: scipy
|
|
9
|
+
Requires-Dist: spatialmath-python
|
|
10
|
+
Requires-Dist: airo-typing==2025.4.0
|
|
11
|
+
Dynamic: author
|
|
12
|
+
Dynamic: author-email
|
|
13
|
+
Dynamic: requires-dist
|
|
14
|
+
Dynamic: summary
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# airo-spatial-algebra
|
|
2
|
+
|
|
3
|
+
This package provides functionality to work with SE3 poses, transforms etc. The heavy lifting is done by Peter Corke's [spatial-math](https://github.com/petercorke/spatialmath-python) package. We simply wrap a subset of its features to make it more verbose and self-explanatory.
|
|
4
|
+
|
|
5
|
+
The package contains an `SE3Container` class for converting between different representations of SE3 poses (and hence SO3 rotations as well).
|
|
6
|
+
Furthermore a few common operations on points and poses (such as changing the frame in which they are represented) are provided for convenience.
|
|
7
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This file contains some operations on points and poses.
|
|
3
|
+
|
|
4
|
+
It also defines a helper class for a collection of points, but this is only used internally
|
|
5
|
+
to allow the user to directly interact with np.arrays to reduce friction.
|
|
6
|
+
|
|
7
|
+
i.e. the user can call <transform>(points) where points is just a numpy array,
|
|
8
|
+
instead of having to first convert the points to a specific format.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from airo_typing import HomogeneousMatrixType, Vector3DArrayType, Vectors3DType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _HomogeneousPoints:
|
|
16
|
+
"""Helper class to facilitate multiplicating 4x4 matrices with one or more 3D points.
|
|
17
|
+
This class internally handles the addition / removal of a dimension to the points.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# TODO: extend to generic dimensions (1D,2D,3D).
|
|
21
|
+
def __init__(self, points: Vectors3DType):
|
|
22
|
+
if not self.is_valid_points_type(points):
|
|
23
|
+
raise ValueError(f"Invalid argument for {_HomogeneousPoints.__name__}.__init__ ")
|
|
24
|
+
|
|
25
|
+
points = _HomogeneousPoints.ensure_array_2d(points)
|
|
26
|
+
self._homogeneous_points = np.concatenate([points, np.ones((points.shape[0], 1), dtype=np.float32)], axis=1)
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def is_valid_points_type(points: Vectors3DType) -> bool:
|
|
30
|
+
if len(points.shape) == 1:
|
|
31
|
+
if len(points) == 3:
|
|
32
|
+
return True
|
|
33
|
+
elif len(points.shape) == 2:
|
|
34
|
+
if points.shape[1] == 3:
|
|
35
|
+
return True
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def ensure_array_2d(points: Vectors3DType) -> Vector3DArrayType:
|
|
40
|
+
"""If points is a single shape (3,) point, then it is reshaped to (1,3)."""
|
|
41
|
+
if len(points.shape) == 1:
|
|
42
|
+
if len(points) != 3:
|
|
43
|
+
raise ValueError("points has only one dimension, but it's length is not 3")
|
|
44
|
+
points = points.reshape((1, 3))
|
|
45
|
+
return points
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def homogeneous_points(self) -> np.ndarray:
|
|
49
|
+
"""Nx4 matrix representing the homogeneous points"""
|
|
50
|
+
return self._homogeneous_points
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def points(self) -> Vectors3DType:
|
|
54
|
+
"""Nx3 matrix representing the points"""
|
|
55
|
+
# normalize points (for safety, should never be necessary with affine transforms)
|
|
56
|
+
# but we've had bugs of this type with projection operations, so better safe than sorry?
|
|
57
|
+
scalars = self._homogeneous_points[:, 3][:, np.newaxis]
|
|
58
|
+
points = self.homogeneous_points[:, :3] / scalars
|
|
59
|
+
# TODO: if the original poitns was (1,3) matrix, then the resulting points would be a (3,) vector.
|
|
60
|
+
# Is this desirable? and if not, how to avoid it?
|
|
61
|
+
if points.shape[0] == 1:
|
|
62
|
+
# single point -> create vector from 1x3 matrix
|
|
63
|
+
return points[0]
|
|
64
|
+
else:
|
|
65
|
+
return points
|
|
66
|
+
|
|
67
|
+
def apply_transform(self, homogeneous_transform_matrix: HomogeneousMatrixType) -> None:
|
|
68
|
+
self._homogeneous_points = (homogeneous_transform_matrix @ self.homogeneous_points.transpose()).transpose()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def transform_points(homogeneous_transform_matrix: HomogeneousMatrixType, points: Vectors3DType) -> Vectors3DType:
|
|
72
|
+
"""Applies a transform to a (set of) point(s).
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
homogeneous_transform_matrix (HomogeneousMatrixType): _description_
|
|
76
|
+
points (PointsType): _description_
|
|
77
|
+
Returns:
|
|
78
|
+
PointsType: (3,) vector or (N,3) matrix.
|
|
79
|
+
"""
|
|
80
|
+
homogeneous_points = _HomogeneousPoints(points)
|
|
81
|
+
homogeneous_points.apply_transform(homogeneous_transform_matrix)
|
|
82
|
+
return homogeneous_points.points
|
|
File without changes
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional # use class as type for class methods
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from airo_typing import (
|
|
7
|
+
AxisAngleType,
|
|
8
|
+
EulerAnglesType,
|
|
9
|
+
HomogeneousMatrixType,
|
|
10
|
+
QuaternionType,
|
|
11
|
+
RotationMatrixType,
|
|
12
|
+
RotationVectorType,
|
|
13
|
+
Vector3DType,
|
|
14
|
+
)
|
|
15
|
+
from scipy.spatial.transform import Rotation
|
|
16
|
+
from spatialmath import SE3, UnitQuaternion
|
|
17
|
+
from spatialmath.base import trnorm
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SE3Container:
|
|
21
|
+
"""A container class for SE3 elements. These elements are used to represent the 3D Pose of an element A in a frame B,
|
|
22
|
+
or stated differently the transform from frame B to frame A.
|
|
23
|
+
|
|
24
|
+
Conventions:
|
|
25
|
+
translations are in meters,rotations in radians.
|
|
26
|
+
quaternions are scalar-last and normalized.
|
|
27
|
+
euler angles are the angles of consecutive rotations around the original X-Y-Z axis (in that order).
|
|
28
|
+
|
|
29
|
+
Note that ther exist many different types of euler angels that differ in the order of axes,
|
|
30
|
+
and in whether they rotate around the original and the new axes. We chose this convention as it is the most common in robotics
|
|
31
|
+
and also easy to reason about. use the Scipy.transform.Rotation class if you need to convert from/to other formats.
|
|
32
|
+
|
|
33
|
+
This is a wrapper around the SE3 class of Peter Corke's Spatial Math Library: https://petercorke.github.io/spatialmath-python/
|
|
34
|
+
The scope if this class is not to perform arbitrary calculations on SE3 elements,
|
|
35
|
+
it is merely a 'simplified and more readable' wrapper
|
|
36
|
+
that facilitates creating/retrieving position and/or orientations in various formats.
|
|
37
|
+
|
|
38
|
+
If you need support for calculations and/or more ways to create SE3 elements, use Peter Corke's Spatial Math Library directly.
|
|
39
|
+
You can decide this on the fly as you can always access the SE3 attribute of this class or instantiate this class from an SE3 object
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, se3: SE3) -> None: # type: ignore
|
|
43
|
+
self.se3 = se3
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def random(cls) -> SE3Container:
|
|
47
|
+
"""A random SE3 element with translations in the [-1,1]^3 cube."""
|
|
48
|
+
return cls(SE3.Rand())
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_translation(cls, translation: Vector3DType) -> SE3Container:
|
|
52
|
+
"""creates a translation-only SE3 element"""
|
|
53
|
+
return cls(SE3.Trans(translation.tolist()))
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_homogeneous_matrix(cls, matrix: HomogeneousMatrixType) -> SE3Container:
|
|
57
|
+
_assert_is_se3_matrix(matrix)
|
|
58
|
+
return cls(SE3(matrix))
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_rotation_matrix_and_translation(
|
|
62
|
+
cls, rotation_matrix: RotationMatrixType, translation: Optional[Vector3DType] = None
|
|
63
|
+
) -> SE3Container:
|
|
64
|
+
_assert_is_so3_matrix(rotation_matrix)
|
|
65
|
+
return cls(SE3.Rt(rotation_matrix, translation))
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_rotation_vector_and_translation(
|
|
69
|
+
cls, rotation_vector: RotationVectorType, translation: Optional[Vector3DType] = None
|
|
70
|
+
) -> SE3Container:
|
|
71
|
+
return cls(SE3.Rt(Rotation.from_rotvec(rotation_vector).as_matrix(), translation))
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_quaternion_and_translation(
|
|
75
|
+
cls, quaternion: QuaternionType, translation: Optional[Vector3DType] = None
|
|
76
|
+
) -> SE3Container:
|
|
77
|
+
q = UnitQuaternion(quaternion[3], quaternion[:3]) # scalar-first in math lib
|
|
78
|
+
return cls(SE3.Rt(q.R, translation))
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_euler_angles_and_translation(
|
|
82
|
+
cls, euler_angels: EulerAnglesType, translation: Optional[Vector3DType] = None
|
|
83
|
+
) -> SE3Container:
|
|
84
|
+
# convert from extrinsic XYZ to rotmatrix
|
|
85
|
+
# bc SE3.Eul does not accept translation
|
|
86
|
+
rot_matrix = Rotation.from_euler("xyz", euler_angels, degrees=False).as_matrix()
|
|
87
|
+
return cls.from_rotation_matrix_and_translation(rot_matrix, translation)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_orthogonal_base_vectors_and_translation(
|
|
91
|
+
cls,
|
|
92
|
+
x_axis: Vector3DType,
|
|
93
|
+
y_axis: Vector3DType,
|
|
94
|
+
z_axis: Vector3DType,
|
|
95
|
+
translation: Optional[Vector3DType] = None,
|
|
96
|
+
) -> SE3Container:
|
|
97
|
+
# create orientation matrix with base vectors as columns
|
|
98
|
+
orientation_matrix = np.zeros((3, 3))
|
|
99
|
+
for i, axis in enumerate([x_axis, y_axis, z_axis]):
|
|
100
|
+
orientation_matrix[:, i] = axis / np.linalg.norm(axis)
|
|
101
|
+
|
|
102
|
+
_assert_is_so3_matrix(orientation_matrix)
|
|
103
|
+
|
|
104
|
+
return cls(SE3.Rt(orientation_matrix, translation))
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def orientation_as_quaternion(self) -> QuaternionType:
|
|
108
|
+
angle, vec = self.se3.angvec()
|
|
109
|
+
scalar_first_quaternion = UnitQuaternion.AngVec(angle, vec).A
|
|
110
|
+
return self.scalar_first_quaternion_to_scalar_last(scalar_first_quaternion)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def orientation_as_euler_angles(self) -> EulerAnglesType:
|
|
114
|
+
zyx_ordered_angles = self.se3.eul()
|
|
115
|
+
# convert from intrinsic ZYZ to extrinsic xyz
|
|
116
|
+
return Rotation.from_euler("ZYZ", zyx_ordered_angles, degrees=False).as_euler("xyz", degrees=False)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def orientation_as_axis_angle(self) -> AxisAngleType:
|
|
120
|
+
angle, axis = self.se3.angvec()
|
|
121
|
+
return axis.astype(np.float64), float(angle)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def orientation_as_rotation_vector(self) -> Vector3DType:
|
|
125
|
+
axis, angle = self.orientation_as_axis_angle
|
|
126
|
+
if axis is None:
|
|
127
|
+
return np.zeros(3)
|
|
128
|
+
return angle * axis
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def rotation_matrix(self) -> RotationMatrixType:
|
|
132
|
+
return self.se3.R
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def homogeneous_matrix(self) -> HomogeneousMatrixType:
|
|
136
|
+
return self.se3.A
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def translation(self) -> Vector3DType:
|
|
140
|
+
# TODO: should this be named position or translation?
|
|
141
|
+
return self.se3.t
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def x_axis(self) -> Vector3DType:
|
|
145
|
+
"""also called normal vector. This is the first column of the rotation matrix"""
|
|
146
|
+
return self.se3.n
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def y_axis(self) -> Vector3DType:
|
|
150
|
+
"""also colled orientation vector. This is the second column of the rotation matrix"""
|
|
151
|
+
return self.se3.o
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def z_axis(self) -> Vector3DType:
|
|
155
|
+
"""also called approach vector. This is the third column of the rotation matrix"""
|
|
156
|
+
return self.se3.a
|
|
157
|
+
|
|
158
|
+
def __str__(self) -> str:
|
|
159
|
+
return str(f"SE3 -> \n {self.homogeneous_matrix}")
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def scalar_first_quaternion_to_scalar_last(scalar_first_quaternion: np.ndarray) -> QuaternionType:
|
|
163
|
+
scalar_last_quaternion = np.roll(scalar_first_quaternion, -1)
|
|
164
|
+
return scalar_last_quaternion
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def scalar_last_quaternion_to_scalar_first(scalar_last_quaternion: QuaternionType) -> np.ndarray:
|
|
168
|
+
scalar_first_quaternion = np.roll(scalar_last_quaternion, 1)
|
|
169
|
+
return scalar_first_quaternion
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def normalize_so3_matrix(matrix: np.ndarray) -> np.ndarray:
|
|
173
|
+
"""normalize an SO3 matrix (i.e. a rotation matrix) to be orthogonal and have determinant 1 (right-handed coordinate system)
|
|
174
|
+
see https://en.wikipedia.org/wiki/3D_rotation_group
|
|
175
|
+
|
|
176
|
+
Can be used to fix numerical issues with rotation matrices
|
|
177
|
+
|
|
178
|
+
will make sure x,y,z are unit vectors, then
|
|
179
|
+
will construct new x vector as y cross z, then construct new y vector as z cross x, so that x,y,z are orthogonal
|
|
180
|
+
|
|
181
|
+
"""
|
|
182
|
+
assert matrix.shape == (3, 3), "matrix is not a 3x3 matrix"
|
|
183
|
+
return trnorm(matrix)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _assert_is_so3_matrix(matrix: np.ndarray) -> None:
|
|
187
|
+
"""check if matrix is a valid SO3 matrix
|
|
188
|
+
this requires the matrix to be orthogonal (base vectors are perpendicular) and have determinant 1 (right-handed coordinate system)
|
|
189
|
+
see https://en.wikipedia.org/wiki/3D_rotation_group
|
|
190
|
+
|
|
191
|
+
This function will raise a ValueError if the matrix is not valid
|
|
192
|
+
|
|
193
|
+
"""
|
|
194
|
+
if matrix.shape != (3, 3):
|
|
195
|
+
raise ValueError("matrix is not a 3x3 matrix")
|
|
196
|
+
if not np.allclose(matrix @ matrix.T, np.eye(3)):
|
|
197
|
+
raise ValueError(
|
|
198
|
+
"matrix is not orthnormal, i.e. its base vectors are not perpendicular. If you are sure this is a numerical issue, use normalize_so3_matrix()"
|
|
199
|
+
)
|
|
200
|
+
if not np.allclose(np.linalg.det(matrix), 1):
|
|
201
|
+
raise ValueError("matrix does not have determinant 1 (not right-handed)")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _assert_is_se3_matrix(matrix: np.ndarray) -> None:
|
|
205
|
+
"""check if matrix is a valid SE3 matrix (i.e. a valid pose)
|
|
206
|
+
this requires the rotation part to be a valid SO3 matrix and the translation part to be a 3D vector
|
|
207
|
+
|
|
208
|
+
This function will raise a ValueError if the matrix is not valid
|
|
209
|
+
"""
|
|
210
|
+
if matrix.shape != (4, 4):
|
|
211
|
+
raise ValueError("matrix is not a 4x4 matrix")
|
|
212
|
+
if not np.allclose(matrix[3, :], np.array([0, 0, 0, 1])):
|
|
213
|
+
raise ValueError("last row of matrix is not [0,0,0,1]")
|
|
214
|
+
_assert_is_so3_matrix(matrix[:3, :3])
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: airo_spatial_algebra
|
|
3
|
+
Version: 2025.4.0
|
|
4
|
+
Summary: Code for working with SE3 poses, transforms... for robotic manipulation at the Ghent University AI and Robotics Lab
|
|
5
|
+
Author: Thomas Lips
|
|
6
|
+
Author-email: thomas.lips@ugent.be
|
|
7
|
+
Requires-Dist: numpy<2.0
|
|
8
|
+
Requires-Dist: scipy
|
|
9
|
+
Requires-Dist: spatialmath-python
|
|
10
|
+
Requires-Dist: airo-typing==2025.4.0
|
|
11
|
+
Dynamic: author
|
|
12
|
+
Dynamic: author-email
|
|
13
|
+
Dynamic: requires-dist
|
|
14
|
+
Dynamic: summary
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
setup.py
|
|
3
|
+
airo_spatial_algebra/__init__.py
|
|
4
|
+
airo_spatial_algebra/operations.py
|
|
5
|
+
airo_spatial_algebra/py.typed
|
|
6
|
+
airo_spatial_algebra/se3.py
|
|
7
|
+
airo_spatial_algebra.egg-info/PKG-INFO
|
|
8
|
+
airo_spatial_algebra.egg-info/SOURCES.txt
|
|
9
|
+
airo_spatial_algebra.egg-info/dependency_links.txt
|
|
10
|
+
airo_spatial_algebra.egg-info/requires.txt
|
|
11
|
+
airo_spatial_algebra.egg-info/top_level.txt
|
|
12
|
+
test/test_operations.py
|
|
13
|
+
test/test_se3_container.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
airo_spatial_algebra
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
|
|
3
|
+
import setuptools
|
|
4
|
+
|
|
5
|
+
root_folder = pathlib.Path(__file__).parents[1]
|
|
6
|
+
setuptools.setup(
|
|
7
|
+
name="airo_spatial_algebra",
|
|
8
|
+
version="2025.4.0",
|
|
9
|
+
description="Code for working with SE3 poses, transforms... for robotic manipulation at the Ghent University AI and Robotics Lab",
|
|
10
|
+
author="Thomas Lips",
|
|
11
|
+
author_email="thomas.lips@ugent.be",
|
|
12
|
+
install_requires=["numpy<2.0", "scipy", "spatialmath-python", "airo-typing==2025.4.0"],
|
|
13
|
+
packages=setuptools.find_packages(exclude=["test"]),
|
|
14
|
+
# include py.typed to declare type information is available, see
|
|
15
|
+
# https://mypy.readthedocs.io/en/stable/installed_packages.html#making-pep-561-compatible-packages
|
|
16
|
+
package_data={"airo_spatial_algebra": ["py.typed"]},
|
|
17
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
from airo_spatial_algebra.operations import _HomogeneousPoints, transform_points
|
|
4
|
+
from airo_spatial_algebra.se3 import SE3Container
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_helper_class_creation():
|
|
8
|
+
point = np.array([1.0, 2, 3])
|
|
9
|
+
hpoints = _HomogeneousPoints(point)
|
|
10
|
+
assert hpoints._homogeneous_points.shape == (1, 4)
|
|
11
|
+
assert hpoints._homogeneous_points[0, -1] == 1.0
|
|
12
|
+
|
|
13
|
+
homogeneous_point = np.array([1, 2, 3, 1])
|
|
14
|
+
with pytest.raises(ValueError):
|
|
15
|
+
_HomogeneousPoints(homogeneous_point)
|
|
16
|
+
|
|
17
|
+
points = np.arange(6).reshape(2, 3)
|
|
18
|
+
hpoints = _HomogeneousPoints(points)
|
|
19
|
+
assert hpoints._homogeneous_points.shape == (2, 4)
|
|
20
|
+
# check that the scale is 1.0
|
|
21
|
+
assert hpoints._homogeneous_points[0, -1] == 1.0
|
|
22
|
+
|
|
23
|
+
wronglyshaped_points = np.arange(6).reshape(3, 2)
|
|
24
|
+
with pytest.raises(ValueError):
|
|
25
|
+
_HomogeneousPoints(wronglyshaped_points)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.mark.parametrize("points", [np.arange(6).astype(np.float32).reshape(2, 3), np.array([1.0, 2, 3])])
|
|
29
|
+
def test_helper_class_properties(points):
|
|
30
|
+
hpoints = _HomogeneousPoints(points)
|
|
31
|
+
assert np.isclose(points, hpoints.points).all()
|
|
32
|
+
assert hpoints.homogeneous_points.shape == (points.size // 3, 4)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_transform_points():
|
|
36
|
+
points = np.arange(6).astype(np.float32).reshape(2, 3)
|
|
37
|
+
transform = SE3Container.random()
|
|
38
|
+
transformed_points = transform_points(transform.homogeneous_matrix, points)
|
|
39
|
+
assert np.isclose(transformed_points[0], transform.rotation_matrix @ points[0] + transform.translation).all()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
from airo_spatial_algebra import SE3Container
|
|
4
|
+
from airo_spatial_algebra.se3 import _assert_is_so3_matrix, normalize_so3_matrix
|
|
5
|
+
from scipy.spatial.transform import Rotation
|
|
6
|
+
from spatialmath import SE3, SO3
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture(autouse=True)
|
|
10
|
+
def seed():
|
|
11
|
+
"""fixture that will run before each test in this module to make them deterministic.
|
|
12
|
+
Random poses etc are generated with spatialmath,
|
|
13
|
+
which usesnp.random"""
|
|
14
|
+
np.random.seed(2022)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_from_translation_and_homogeneous():
|
|
18
|
+
pose = np.eye(4)
|
|
19
|
+
translation = np.array([1, 2, 3.0])
|
|
20
|
+
pose[:3, 3] = translation
|
|
21
|
+
se3 = SE3Container.from_translation(translation)
|
|
22
|
+
assert np.isclose(se3.homogeneous_matrix, pose).all()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_from_hom_matrix():
|
|
26
|
+
pose = SE3.Rand().A
|
|
27
|
+
se3 = SE3Container.from_homogeneous_matrix(pose)
|
|
28
|
+
assert np.isclose(se3.homogeneous_matrix, pose).all()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_from_bad_hom_matrix():
|
|
32
|
+
pose = SE3.Rand().A
|
|
33
|
+
pose[3, 3] += 0.01
|
|
34
|
+
with pytest.raises(ValueError):
|
|
35
|
+
SE3Container.from_homogeneous_matrix(pose)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_from_rot_mat():
|
|
39
|
+
# no translation
|
|
40
|
+
rot_matrix = SO3.Rand().R
|
|
41
|
+
pose = np.eye(4)
|
|
42
|
+
pose[:3, :3] = rot_matrix
|
|
43
|
+
se3 = SE3Container.from_rotation_matrix_and_translation(rot_matrix)
|
|
44
|
+
assert np.isclose(se3.homogeneous_matrix, pose).all()
|
|
45
|
+
|
|
46
|
+
# with translation
|
|
47
|
+
trans = np.array([1, 2, 3.2])
|
|
48
|
+
pose[:3, 3] = trans
|
|
49
|
+
se3 = SE3Container.from_rotation_matrix_and_translation(rot_matrix, trans)
|
|
50
|
+
assert np.isclose(se3.homogeneous_matrix, pose).all()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_from_bad_rot_mat():
|
|
54
|
+
rot_matrix = SO3.Rand().R
|
|
55
|
+
rot_matrix[0, 0] += 0.01
|
|
56
|
+
with pytest.raises(ValueError):
|
|
57
|
+
SE3Container.from_rotation_matrix_and_translation(rot_matrix)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_from_base_vectors():
|
|
61
|
+
rot_matrix = np.eye(3)
|
|
62
|
+
se3 = SE3Container.from_orthogonal_base_vectors_and_translation(
|
|
63
|
+
rot_matrix[:, 0], rot_matrix[:, 1], rot_matrix[:, 2], np.array([1, 2, 3])
|
|
64
|
+
)
|
|
65
|
+
assert np.isclose(se3.rotation_matrix, rot_matrix).all()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_from_rotation_vector():
|
|
69
|
+
angle, axis = SO3.Rand().angvec()
|
|
70
|
+
rot_vec = angle * axis
|
|
71
|
+
se3 = SE3Container.from_rotation_vector_and_translation(rot_vec)
|
|
72
|
+
assert np.isclose(se3.orientation_as_rotation_vector, rot_vec).all()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_quaternions():
|
|
76
|
+
quat = [0, 0, 0, 1.0] # no rotation scalar-last
|
|
77
|
+
se3 = SE3Container.from_quaternion_and_translation(quat)
|
|
78
|
+
assert np.isclose(se3.rotation_matrix, np.eye(3)).all()
|
|
79
|
+
assert np.isclose(se3.translation, np.zeros(3)).all()
|
|
80
|
+
assert np.isclose(quat, se3.orientation_as_quaternion).all()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_euler():
|
|
84
|
+
euler = [np.pi, np.pi / 4, np.pi / 5] # 90 degs of X
|
|
85
|
+
rot_matrix = Rotation.from_euler("xyz", euler).as_matrix()
|
|
86
|
+
se3 = SE3Container.from_euler_angles_and_translation(euler)
|
|
87
|
+
assert np.isclose(se3.rotation_matrix, rot_matrix).all()
|
|
88
|
+
assert np.isclose(se3.orientation_as_euler_angles, euler).all()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_get_axes_functions():
|
|
92
|
+
se3 = SE3Container.random()
|
|
93
|
+
hom_matrix = se3.homogeneous_matrix
|
|
94
|
+
for i, atrr in enumerate([se3.x_axis, se3.y_axis, se3.z_axis]):
|
|
95
|
+
assert np.isclose(atrr, hom_matrix[:3, i]).all()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_repr():
|
|
99
|
+
se3 = SE3Container.random()
|
|
100
|
+
print(se3)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_all_orientation_reprs_are_equivalent():
|
|
104
|
+
se3 = SE3Container.random()
|
|
105
|
+
rot_matrix = se3.rotation_matrix
|
|
106
|
+
assert np.isclose(Rotation.from_rotvec(se3.orientation_as_rotation_vector).as_matrix(), rot_matrix).all()
|
|
107
|
+
assert np.isclose(Rotation.from_euler("xyz", se3.orientation_as_euler_angles).as_matrix(), rot_matrix).all()
|
|
108
|
+
assert np.isclose(Rotation.from_quat(se3.orientation_as_quaternion).as_matrix(), rot_matrix).all()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_quaternion_scalar_conversion():
|
|
112
|
+
se3 = SE3Container.random()
|
|
113
|
+
quat = se3.orientation_as_quaternion
|
|
114
|
+
assert np.isclose(
|
|
115
|
+
quat,
|
|
116
|
+
SE3Container.scalar_first_quaternion_to_scalar_last(SE3Container.scalar_last_quaternion_to_scalar_first(quat)),
|
|
117
|
+
).all()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_axis_angle_dtypes():
|
|
121
|
+
se3 = SE3Container.from_homogeneous_matrix(np.identity(4))
|
|
122
|
+
axis, angle = se3.orientation_as_axis_angle
|
|
123
|
+
assert isinstance(angle, float)
|
|
124
|
+
assert isinstance(axis, np.ndarray)
|
|
125
|
+
assert axis.dtype == np.float64
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_normalize_so3():
|
|
129
|
+
so3 = SO3.Rand().R
|
|
130
|
+
so3[0, 0] += 0.01
|
|
131
|
+
normalized_so3 = normalize_so3_matrix(so3)
|
|
132
|
+
_assert_is_so3_matrix(normalized_so3)
|