SciPy is a fundamental library of the scientific Python ecosystem. I always wondered why it does not provide proper rigid transformations although they are needed in many scientific disciplines such as robotics, computer vision, and computer graphics. This is one reason why pytransform3d exists.
With the issue ENH: spatial.transform: cover proper rigid transformations with a RigidTransform class (rotations plus translations) #19254 opened in September 2023, Scott Shambaugh took the initiative to change this. It took more than a year to agree on an interface and finally implement the suggested changes with the pull request ENH: spatial.transform: baseline implementation of RigidTransform #22267, which will be part of SciPy 1.16.0.
I contributed to some parts of the new feature:
- discussion of the interface
- the initial draft of the conversions from and to exponential coordinates
- the conversion from and to dual quaternions
- dual quaternion normalization
This is a summary of the new feature.
The Rotation
Class¶
With the interface, we tried to be as close as possible to the already existing Rotation
class from the module scipy.spatial.transform
, which looks like this:
import numpy as np
from scipy.spatial.transform import Rotation
# quaternions are use the scalar-last convention, but there is an option
# to use the scalar-first convention
r_BA = Rotation.from_quat([0, 0, np.sin(np.pi / 4), np.cos(np.pi / 4)])
r_BA.as_quat()
r_CB = Rotation.from_matrix(
[[0, -1, 0],
[1, 0, 0],
[0, 0, 1]])
r_CB.as_matrix()
r_DC = Rotation.from_rotvec([0.3, 0.5, -0.2])
r_DC.as_rotvec()
r_ED = Rotation.from_euler(
"zyx", [[90, 0, 0], [0, 45, 0], [45, 60, 30]], degrees=True)
r_FE = Rotation.identity()
r_ED[0].as_euler('zyx', degrees=True)
r_AB = r_BA.inv() # inverse rotation
r_BA.apply([1, 2, 3]) # rotate a vector
r_CA = r_CB * r_BA # compose rotations
# compose fractions of the same rotation
r_BA ** 2
r_BA ** 0.5
In addition, all operations and conversions can also be applied to multiple rotations.
RigidTransform
¶
Many names for the new class were considered, for example, TransformSe3, ProperRigidTransformation, RigidTransformation, and finally RigidTransform as a compromise between technically correct, readable, and short.
from scipy.spatial.transform import RigidTransform
t_BA = [1, 2, 3]
tf_BA = RigidTransform.from_translation(t_BA)
tf_BA
tf_BA.translation
tf_CB = RigidTransform.from_rotation(r_CB)
tf_CB
tf_CB.rotation
... or from both of these components.
tf_DC = RigidTransform.from_components(t_BA, r_BA)
tf_DC.as_components()
You can create the identity transformation via
RigidTransform.identity()
Variable Naming Conventions¶
We use the prefix t
for translations and these are represented by numpy arrays of shape (3,)
. We use the prefix r
for objects of the Rotation
class and tf
for objects of the RigidTransform
class.
Conversions between Representations of Proper Rigid Transformations¶
Just like pytransform3d, the RigidTransform
class supports conversion from and to transformation matrices, exponential coordinates of transformation, and dual quaternions.
Homogenous Transformation Matrix¶
In the initial version of the feature, transformation matrices are used as an internal representation. The constructor of the class will normalize transformation matrices automatically. However, the internal representation is hidden and you should only access the transformation matrix with the following functions.
tf_ED = RigidTransform.from_matrix(
[[0, -1, 0, 1],
[1, 0, 0, 2],
[0, 0, 1, 3],
[0, 0, 0, 1]]
)
tf_ED.as_matrix()
Exponential Coordinates of Transformation¶
Exponential coordinates are a minimal representation $\boldsymbol{\tau} \in \mathbb{R}^6$. They are particularly useful to compute fractions of a transformation, for example, for interpolation (screw linear interpolation, ScLERP) or computing derivatives.
import pytransform3d.transformations as pt
rng = np.random.default_rng(42)
exp_coords = pt.random_exponential_coordinates(rng)
exp_coords
There is a so-called exponential map
$$Exp: \boldsymbol{\tau} \in \mathbb{R}^6 \rightarrow \boldsymbol{T} \in SE(3)$$tf_FE = RigidTransform.from_exp_coords(exp_coords)
tf_FE
The inverse operation is the logarithmic map
$$Log: \boldsymbol{T} \in SE(3) \rightarrow \boldsymbol{\tau} \in \mathbb{R}^6$$tf_FE.as_exp_coords()
Dual Quaternions¶
Similarly to unit quaternions for rotations, unit dual quaternions are an alternative to represent transformations. They support similar operations as transformation matrices. A dual quaternion consists of a real quaternion and a dual quaternion: $\boldsymbol{\sigma} = \boldsymbol{p} + \epsilon \boldsymbol{q}$, where $\epsilon^2 = 0$ and $\epsilon \neq 0$.
We use an array of shape (8,)
to represent dual quaternions.
dq = pt.dual_quaternion_from_transform(pt.random_transform(rng))
dq
The tricky part when using dual quaternions is that SciPy uses the scalar-last convention by default for unit dual quaternions because the the Rotation
class uses this convention by default for unit quaternions. pytransform3d uses the scalar-first convention. However, we can configure SciPy to use scalar-first.
tf_GF = RigidTransform.from_dual_quat(dq, scalar_first=True)
tf_GF
tf_GF.as_dual_quat(scalar_first=True)
Normalization of Dual Quaternions¶
A thing that I learned when implementing the conversion from dual quaternion to RigidTransform
is that it is not sufficient to divide the dual quaternion by the Euclidean norm of the real quaternion to normalize it. Dual quaternions have a second constraint: the real and the dual quaternion must be orthogonal, that is, their scalar product must be 0. When applying normal dual quaternion operations, this constraint is usually not violated easily, but it has to be considered when we accept any arbitrary user input. Hence, we implemented a normalization procedure that enforces both constraints in RigidTransform.from_dual_quat()
.
dq_unnormalized = rng.normal(size=8)
dq_unnormalized
np.round(dq_unnormalized[:4] @ dq_unnormalized[:4], 12)
np.round(dq_unnormalized[:4] @ dq_unnormalized[4:], 12)
dq_normalized = RigidTransform.from_dual_quat(dq_unnormalized).as_dual_quat()
dq_normalized
np.round(dq_normalized[:4] @ dq_normalized[:4], 12)
np.round(dq_normalized[:4] @ dq_normalized[4:], 12)
tf_BA.apply([2, 3, 4])
Inversion¶
tf_BA.inv()
Composition¶
tf_CB * tf_BA
Exponentiation¶
This can be done with integers or floats if you want to compute fractions of the transformation (for example, for screw linear interpolation):
tf_BA ** 2
tf_BA ** 0.5
The operation is implemented as
RigidTransform.from_exp_coords(tf_BA.as_exp_coords() * 0.5)
Vectorization¶
The best feature of the RigidTransform
class is that it provides a vectorized implementation of everything like the Rotation
class. Here is a (maybe not so useful) example:
import pytransform3d.trajectories as ptr
exp_coords = ptr.exponential_coordinates_from_transforms(
ptr.random_trajectories(rng, n_trajectories=1, n_steps=5))[0]
exp_coords
We can use indices to access individual transformations:
tfs = RigidTransform.from_exp_coords(exp_coords)
tfs[0]
We can use, for instance, composition, exponentiation, inversion, and conversions with this batch of transformations.
((tfs * tfs) ** -0.75).inv().as_dual_quat(scalar_first=True)
What's Next?¶
SciPy¶
For SciPy, there is a roadmap issue for this new feature that contains several related tasks: RFC: Feature roadmap for 3D spatial transformations #22370.
pytransform3d¶
As you have seen, pytransform3d is fully compatible with this class. However, I envision it to be a more complete and transparent set of implementations. While SciPy supports the most useful representations of rigid transformations, it hides its internal representation so that it can be replaced in the future. It also does not need a direct translation from dual quaternions to transformation matrices. pytransform3d does not support it yet either, but it would be in the scope of the library. Furthermore, in the scope of pytransform3d are tools to work effectively with every representation, such as normalization in different ways, detecting numerical issues, and similarity checks for representations with double cover. In addition, the TransformManager and the plotting and visualization tools are most likely out of scope for SciPy.
JAX¶
I really like the idea of JAX, a differentiable and GPU-compatible NumPy. It supports the Rotation class of SciPy already. A next step would be to adopt the RigidTransform interface.