Geometry & 3D Vision
Rotation Representations
The ways a 3D rotation can be written down (rotation matrices, Euler angles, rotation vectors, unit quaternions, 6D vectors and the Lie algebra), how they convert into each other, and where each one fails.
intermediate
A 3D rotation has three degrees of freedom, yet the common ways of writing one down use three, four, six or nine numbers, each with its own failure mode. The extrinsic rotation of the camera parameters can be stored in any of them; optimizers, interpolators and neural networks each prefer a different one, and mixed-up conventions cause silent errors.
Definition
A rotation is a linear map of 3D space that preserves lengths, angles and handedness. A rotation representation is a set of parameters together with a rule that turns them into that map. The main ones are:
- the rotation matrix, a orthogonal matrix with determinant ;
- Euler angles, three successive rotations about coordinate axes;
- the axis–angle pair and the rotation vector (axis times angle);
- the unit quaternion, four numbers on the unit sphere in ;
- the 6D representation, two columns of the rotation matrix, used for learning;
- the Lie algebra , the space of small rotations used for optimization.
This article uses right-handed frames and writes quaternions as , scalar first, except where a library’s own convention is described.
Intuition
A camera’s orientation can be described by the directions its image axes point in (the rows of ), by “turn left 30°, tilt down 20°, roll 5°” (Euler angles), or by “turn 40° about this axis” (axis–angle); every 3D rotation is a turn about some axis.
The difficulty is topological. The rotations form a closed, curved three-dimensional manifold, and Zhou et al. (2019) show that no representation in four or fewer dimensions is continuous in the sense they define. Every compact representation has a weak spot: gimbal lock for Euler angles, the 180° boundary for rotation vectors, the sign ambiguity for quaternions.
Formal Definition
Rotation matrices and SO(3)
The rotation matrices form the special orthogonal group
Orthogonality preserves lengths and angles; excludes reflections. The nine entries obey six independent constraints, leaving three degrees of freedom (Solà, 2017, arXiv v1, Table 1). Rotations compose by matrix multiplication, applies first, and the inverse is the transpose. Composition is not commutative.
Euler angles
Euler angles write as a product of three rotations about coordinate axes. For the yaw–pitch–roll sequence that REP 103 describes,
Read as intrinsic rotations, this turns about , then about the new , then about the newest ; read as extrinsic rotations about fixed axes, the same product applies , then , then . Twelve axis sequences are possible (no axis may follow itself), each read intrinsically or extrinsically; REP 103 counts 24 conventions.
Gimbal lock. At the first and third rotation axes coincide, so yaw and roll turn the camera about the same axis. For this sequence at the matrix depends only on : one degree of freedom is lost, and the angles cannot be recovered uniquely. It follows that near the lock, small changes of orientation need large changes of angles, which makes Euler angles a poor choice of parameters for optimization.
Axis–angle and the Rodrigues formula
A rotation by angle about a unit axis is stored compactly as the rotation vector . The Rodrigues formula converts it to a matrix; OpenCV’s documentation of Rodrigues (4.14.0 header source) states it as
where is the cross-product matrix, . The inverse uses and . OpenCV’s cv::Rodrigues converts in both directions and optionally returns the Jacobian. In the 4.14.0 source, the matrix-to-vector direction first replaces the input by from its singular value decomposition, and it handles angles near 180° by a separate branch, because vanishes there and no longer determines the axis.
Unit quaternions
A unit quaternion with represents the rotation by about as
Writing , the corresponding matrix is
Composition is the Hamilton product (Solà, 2017, arXiv v1, §1.2.2 and Table 1),
Because of the half angle, and give the same matrix: the unit quaternions form a double cover of (Solà, 2017, arXiv v1, §2.4.6). Solà (2017, arXiv v1, §3) explains that quaternion conventions also differ in the algebra’s handedness and in the direction of the operator, not only in the component order. The formulas here are the Hamilton convention, which he reports Eigen, ROS and Ceres follow; the JPL convention, common in aerospace, is left-handed.
Slerp. Spherical linear interpolation moves along the great arc between and at constant angular speed:
Solà (2017, arXiv v1, §2.7) gives this form, which he credits to Glenn Davis citing Shoemake (1985), and notes the double-cover condition: if , replace by first, or the interpolation takes the long way round.
6D representation for learning
Zhou et al. (2019) proposed a continuous representation for networks that regress rotations: the first two columns of , six numbers. A network outputs any six numbers , and a Gram–Schmidt step maps them to a rotation:
Their argument: is homeomorphic to the real projective space , which does not embed in for , so Euler angles, rotation vectors and quaternions all have discontinuities. In their autoencoder sanity test (Figure 5, table c, at 500k iterations), the 6D representation had a mean geodesic error of 0.49° and a maximum of 1.98°, against means of 3.32° for quaternions and 6.98° for Euler angles, each with maxima near 180°. In their point-cloud pose test (Figure 5, table f, at 2,600k iterations), the 6D mean was 2.85°, below quaternions (9.03°) and a directly regressed matrix orthogonalized after prediction (4.21°), though its maximum error was still near 180° there.
Lie group and Lie algebra
is a Lie group. Its tangent space at the identity, the Lie algebra , consists of the skew-symmetric matrices , identified with vectors . The exponential map sends a tangent vector to a rotation, and for its closed form is the Rodrigues formula; the logarithm inverts it (Solà et al., 2018, arXiv v9, Example 4 and Appendix B):
where extracts the vector from a skew-symmetric matrix. The rotation vector is therefore the Lie-algebra coordinate of .
On-manifold optimization. Bundle adjustment, SLAM back ends and perspective-n-point refinement keep each rotation in a global form and solve for a small increment , applied on the right as (the “right-plus” of Solà et al., 2018, arXiv v9, eq. 25) or on the left as . Each nonlinear least-squares step then has three unconstrained parameters per rotation and, near the current estimate, no singularity.
Properties
Distances between rotations
The natural distance between and is the geodesic angle, the angle of the relative rotation :
Zhou et al. (2019) report errors in this metric. For quaternions the same angle is , with the absolute value absorbing the sign. The chordal distance is cheaper; expanding the norm gives , so it ranks pairs the same way as the geodesic angle.
Conversions and numerical pitfalls
- Drift. Products of many rotations accumulate rounding error and leave . The fix is to project back to the nearest rotation in the Frobenius norm, from the SVD ; the determinant term prevents a reflection. Quaternions are renormalized instead.
- Averaging. An element-wise mean of rotation matrices is not a rotation. Projecting it by SVD gives the chordal mean, which SciPy’s
Rotation.meancomputes. - Quaternion sign. Align signs before averaging, interpolating or comparing quaternions (for example so that ).
- Angles from the trace. is ill-conditioned near 0 and 180°: for tiny rotations it returns exactly zero. Using avoids this.
Library Conventions
As of October 2026, from each project’s official documentation or source:
| Library | Quaternion order | Other conventions |
|---|---|---|
SciPy Rotation 1.18.0 |
; with scalar_first=True, added in 1.14.0 |
Euler sequences in upper case are intrinsic, lower case extrinsic; as_euler warns at gimbal lock and sets the third angle to zero; from_matrix orthogonalizes its input; mean is the chordal mean |
| Eigen 3.4.1 (5.0.1 development docs agree) | constructor Quaternion(w, x, y, z); stored and returned by coeffs() as |
rotation operations are undefined for non-unit quaternions; angularDistance, slerp |
| ROS 2 | geometry_msgs/Quaternion fields x, y, z, w, identity |
REP 103: right-handed frames, quaternions preferred, Euler angles discouraged; _optical camera frames use forward, right, down |
| OpenCV 4.14.0 | cv::Quat is |
calibration and pose functions exchange world-to-camera rotations as rotation vectors (rvec), converted by Rodrigues |
The traps are in the mixtures: ROS messages and SciPy’s default put last, and the ROS 2 quaternion tutorial warns that Eigen puts it first, yet Eigen stores it last.
Examples
The examples orient a camera toward a synthetic cube and probe each pitfall. They are deterministic and were run with NumPy 2.3.5 and SciPy 1.16.3, whose Rotation calls used here match the 1.18.0 documentation.
Round trips
A look-at construction gives the world-to-camera rotation for OpenCV’s axes ( right, down, forward); its rows are the camera axes in world coordinates. Hand-written Rodrigues and quaternion formulas are checked against SciPy.
import numpy as np
from scipy.spatial.transform import Rotation
np.set_printoptions(precision=4, suppress=True)
def look_at(C, target, up=(0.0, 0.0, 1.0)):
"""World-to-camera rotation for an OpenCV-style camera (x right, y down, z forward)."""
z = (target - C) / np.linalg.norm(target - C)
x = np.cross(z, up); x /= np.linalg.norm(x)
y = np.cross(z, x)
return np.stack([x, y, z]) # rows = camera axes in world coordinates
# A synthetic object: the 8 corners of a cube centred at the world origin.
cube = np.array([[i, j, k] for i in (-1, 1) for j in (-1, 1) for k in (-1, 1)], float) * 0.5
C = np.array([4.0, -3.0, 2.0]) # camera centre
R = look_at(C, cube.mean(0))
t = -R @ C # X_c = R X_w + t
K = np.array([[800, 0, 320], [0, 800, 240], [0, 0, 1.0]])
print("R^T R = I:", np.allclose(R.T @ R, np.eye(3)), " det R = %.6f" % np.linalg.det(R))
x = K @ (R @ cube.mean(0) + t)
print("object centre projects to pixel", x[:2] / x[2])
def skew(v):
return np.array([[0, -v[2], v[1]], [v[2], 0, -v[0]], [-v[1], v[0], 0]])
def rodrigues(r):
"""Rotation vector -> matrix: R = cos(th) I + (1 - cos(th)) u u^T + sin(th) [u]x."""
th = np.linalg.norm(r)
if th < 1e-12:
return np.eye(3) + skew(r)
u = r / th
return np.cos(th) * np.eye(3) + (1 - np.cos(th)) * np.outer(u, u) + np.sin(th) * skew(u)
def quat_to_matrix(q):
"""Unit quaternion (w, x, y, z), Hamilton convention -> rotation matrix."""
w, x, y, z = q / np.linalg.norm(q)
return np.array([[1 - 2*(y*y + z*z), 2*(x*y - w*z), 2*(x*z + w*y)],
[2*(x*y + w*z), 1 - 2*(x*x + z*z), 2*(y*z - w*x)],
[2*(x*z - w*y), 2*(y*z + w*x), 1 - 2*(x*x + y*y)]])
rot = Rotation.from_matrix(R)
q = rot.as_quat(scalar_first=True) # (w, x, y, z); SciPy's default is (x, y, z, w)
rv = rot.as_rotvec()
eul = rot.as_euler("ZYX", degrees=True) # intrinsic yaw, pitch, roll
print("quaternion (w, x, y, z):", q)
print("rotation vector:", rv, " angle %.3f deg" % np.degrees(np.linalg.norm(rv)))
print("Euler ZYX (deg):", eul)
err = lambda A: np.abs(A - R).max()
print("round trips, max |R' - R|:")
print(" quaternion (own formula) %.1e" % err(quat_to_matrix(q)))
print(" rotation vector (own) %.1e" % err(rodrigues(rv)))
print(" Euler ZYX (SciPy) %.1e" % err(Rotation.from_euler("ZYX", eul, degrees=True).as_matrix()))
print(" scalar-last read as (w,x,y,z) by mistake: %.2f" % err(quat_to_matrix(rot.as_quat())))
Output:
R^T R = I: True det R = 1.000000
object centre projects to pixel [320. 240.]
quaternion (w, x, y, z): [ 0.5014 0.7406 0.3703 -0.2507]
rotation vector: [ 1.7901 0.895 -0.606 ] angle 119.809 deg
Euler ZYX (deg): [ 26.344 47.9689 123.6901]
round trips, max |R' - R|:
quaternion (own formula) 3.3e-16
rotation vector (own) 2.2e-16
Euler ZYX (SciPy) 2.2e-16
scalar-last read as (w,x,y,z) by mistake: 1.73
Every round trip returns the matrix to rounding error. The last line is the classic bug: reading SciPy’s default scalar-last quaternion as yields a valid but wrong rotation, with entries off by up to 1.73 and no error raised.
Gimbal lock and the double cover
import warnings
import numpy as np
from scipy.spatial.transform import Rotation
def euler_zyx(yaw, pitch, roll):
return Rotation.from_euler("ZYX", [yaw, pitch, roll], degrees=True).as_matrix()
def geodesic_deg(A, B):
"""Angle of the rotation A^T B, in degrees (atan2 form, see the last example)."""
M = A.T @ B
s = np.linalg.norm([M[2, 1] - M[1, 2], M[0, 2] - M[2, 0], M[1, 0] - M[0, 1]]) / 2
return np.degrees(np.arctan2(s, (np.trace(M) - 1) / 2))
# Gimbal lock: at pitch = 90 deg, yaw and roll turn about the same axis.
A = euler_zyx(30, 90, 10)
B = euler_zyx(50, 90, 30) # yaw and roll both +20 deg
print("pitch 90: (30, 90, 10) vs (50, 90, 30), angle between them: %.1e deg" % geodesic_deg(A, B))
def jacobian_svals(angles, h=1e-6):
"""Singular values of d vec(R) / d(yaw, pitch, roll) per radian, by central differences."""
J = np.column_stack([(euler_zyx(*(angles + h * e)) - euler_zyx(*(angles - h * e))).ravel() / (2 * h)
for e in np.eye(3)]) * 180 / np.pi
return np.linalg.svd(J, compute_uv=False)
for pitch in (20.0, 89.0, 90.0):
print(f"pitch {pitch:4.0f}: Jacobian singular values", np.array2string(jacobian_svals(np.array([30.0, pitch, 10.0])), precision=3))
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
back = Rotation.from_matrix(A).as_euler("ZYX", degrees=True)
print("SciPy as_euler at the lock:", np.round(back, 4), "| warning:", w[0].message if w else None)
# Double cover: q and -q are the same rotation.
q = Rotation.from_euler("ZYX", [30, 40, 10], degrees=True).as_quat(scalar_first=True)
Rq = Rotation.from_quat(q, scalar_first=True).as_matrix()
Rmq = Rotation.from_quat(-q, scalar_first=True).as_matrix()
print("q =", np.round(q, 4), " |R(q) - R(-q)| max: %.1e" % np.abs(Rq - Rmq).max())
print("Euclidean distance |q - (-q)| = %.1f, rotation angle between them = %.1e deg"
% (np.linalg.norm(2 * q), geodesic_deg(Rq, Rmq)))
Output:
pitch 90: (30, 90, 10) vs (50, 90, 30), angle between them: 2.3e-14 deg
pitch 20: Jacobian singular values [1.638 1.414 1.147]
pitch 89: Jacobian singular values [2. 1.414 0.017]
pitch 90: Jacobian singular values [2.000e+00 1.414e+00 1.034e-08]
SciPy as_euler at the lock: [20. 90. 0.] | warning: Gimbal lock detected. Setting third angle to zero since it is not possible to uniquely determine all angles.
q = [ 0.9119 -0.0091 0.3503 0.2135] |R(q) - R(-q)| max: 0.0e+00
Euclidean distance |q - (-q)| = 2.0, rotation angle between them = 0.0e+00 deg
At 90° pitch, two angle triples with the same give the same orientation, and the smallest singular value of the Jacobian of with respect to the angles falls to finite-difference noise: a lost degree of freedom. Negating a quaternion moves it a Euclidean distance of 2 but leaves the rotation unchanged.
Interpolation and continuity
Naive Euler-angle interpolation and SciPy’s Slerp are compared at 101 points with the geodesic , computed with the matrix exponential and logarithm. Then two rotations 0.2° apart, either side of 180°, are compared in each representation.
import numpy as np
from scipy.linalg import expm, logm
from scipy.spatial.transform import Rotation, Slerp
def geodesic_deg(A, B):
"""Angle of A^T B in degrees, via atan2 (accurate near 0 and 180 degrees)."""
M = A.T @ B
s = np.linalg.norm([M[2, 1] - M[1, 2], M[0, 2] - M[2, 0], M[1, 0] - M[0, 1]]) / 2
return np.degrees(np.arctan2(s, (np.trace(M) - 1) / 2))
def path_length(Rs):
return sum(geodesic_deg(a, b) for a, b in zip(Rs[:-1], Rs[1:]))
ts = np.linspace(0, 1, 101)
# Pairs of camera orientations as intrinsic ZYX (yaw, pitch, roll) in degrees.
for e0, e1 in [((10, 20, 0), (80, 70, 40)), # no angle wraps around
((170, 50, 0), (-170, 60, 30))]: # yaw crosses +-180
e0, e1 = np.array(e0, float), np.array(e1, float)
R0 = Rotation.from_euler("ZYX", e0, degrees=True).as_matrix()
R1 = Rotation.from_euler("ZYX", e1, degrees=True).as_matrix()
W = np.real(logm(R0.T @ R1)) # reference geodesic R0 exp(t log(R0^T R1))
geo = [R0 @ expm(t * W) for t in ts]
slerp = Slerp([0, 1], Rotation.from_matrix([R0, R1]))(ts).as_matrix()
euler = Rotation.from_euler("ZYX", (1 - ts)[:, None] * e0 + ts[:, None] * e1,
degrees=True).as_matrix()
err_e = max(geodesic_deg(a, g) for a, g in zip(euler, geo))
err_s = max(geodesic_deg(a, g) for a, g in zip(slerp, geo))
print(f"{e0} -> {e1}: endpoints {geodesic_deg(R0, R1):.2f} deg apart")
print(f" max error from geodesic: Euler-lerp {err_e:.2f} deg, slerp {err_s:.1e} deg")
print(f" path length: Euler-lerp {path_length(euler):.2f} deg, slerp {path_length(slerp):.2f} deg")
# Continuity: two rotations 0.2 deg apart, either side of 180 deg about the same axis.
axis = np.array([1.0, 2.0, 2.0]) / 3
Ra = Rotation.from_rotvec(np.radians(179.9) * axis)
Rb = Rotation.from_rotvec(np.radians(180.1) * axis)
print("angle between Ra and Rb: %.2f deg" % geodesic_deg(Ra.as_matrix(), Rb.as_matrix()))
d = lambda u, v: np.linalg.norm(u - v)
print("distance between representations:")
print(" rotation vector %.3f" % d(Ra.as_rotvec(), Rb.as_rotvec()))
print(" quaternion (w >= 0) %.3f" % d(Ra.as_quat(canonical=True), Rb.as_quat(canonical=True)))
six = lambda r: r.as_matrix()[:, :2].T.ravel() # first two columns
print(" 6D (two columns) %.3f" % d(six(Ra), six(Rb)))
def from_6d(a):
"""Zhou et al.'s Gram-Schmidt map from 6 numbers to a rotation matrix."""
b1 = a[:3] / np.linalg.norm(a[:3])
b2 = a[3:] - (b1 @ a[3:]) * b1
b2 /= np.linalg.norm(b2)
return np.column_stack([b1, b2, np.cross(b1, b2)])
noisy = six(Ra) + np.array([0.05, -0.02, 0.03, 0.01, 0.04, -0.03]) # an unconstrained "network output"
Rn = from_6d(noisy)
print("6D round trip error %.1e; noisy 6D -> R: orthogonality %.1e, det %.6f"
% (np.abs(from_6d(six(Ra)) - Ra.as_matrix()).max(), np.abs(Rn.T @ Rn - np.eye(3)).max(), np.linalg.det(Rn)))
Output:
[10. 20. 0.] -> [80. 70. 40.]: endpoints 66.49 deg apart
max error from geodesic: Euler-lerp 11.95 deg, slerp 2.5e-14 deg
path length: Euler-lerp 71.54 deg, slerp 66.49 deg
[170. 50. 0.] -> [-170. 60. 30.]: endpoints 20.24 deg apart
max error from geodesic: Euler-lerp 179.63 deg, slerp 2.8e-14 deg
path length: Euler-lerp 365.09 deg, slerp 20.24 deg
angle between Ra and Rb: 0.20 deg
distance between representations:
rotation vector 6.280
quaternion (w >= 0) 2.000
6D (two columns) 0.004
6D round trip error 2.2e-16; noisy 6D -> R: orthogonality 3.0e-17, det 1.000000
Slerp follows the geodesic to rounding error. Euler-angle interpolation strays by 12° even without wrap-around, and when yaw crosses ±180° it turns the camera through 365° to cover a 20° change. Near 180°, the rotation vectors are nearly apart and the canonical quaternions 2 apart, while the 6D vectors differ by 0.004: the discontinuity Zhou et al. (2019) identify. The Gram–Schmidt map turns a perturbed 6D vector into an exact rotation.
Averaging and re-orthonormalization
import numpy as np
from scipy.spatial.transform import Rotation
def geodesic_deg(A, B):
M = A.T @ B
s = np.linalg.norm([M[2, 1] - M[1, 2], M[0, 2] - M[2, 0], M[1, 0] - M[0, 1]]) / 2
return np.degrees(np.arctan2(s, (np.trace(M) - 1) / 2))
def project_to_so3(M):
"""Nearest rotation in the Frobenius norm: U diag(1, 1, det(U V^T)) V^T."""
U, _, Vt = np.linalg.svd(M)
D = np.diag([1.0, 1.0, np.sign(np.linalg.det(U @ Vt))])
return U @ D @ Vt
# Five estimates of one camera's orientation, each off by 10 to 30 deg about some axis.
R_true = Rotation.from_euler("ZYX", [40, -30, 15], degrees=True)
axes = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 1, 0], [0, -1, 1]], float)
angles = np.radians([30, -20, 25, 10, -15])
offsets = Rotation.from_rotvec(angles[:, None] * axes / np.linalg.norm(axes, axis=1)[:, None])
Rs = (R_true * offsets).as_matrix()
print("estimates' errors (deg):", np.round([geodesic_deg(R, R_true.as_matrix()) for R in Rs], 1))
M = Rs.mean(axis=0) # element-wise average
print("element-wise mean: max |M^T M - I| = %.3f, det = %.3f, singular values %s"
% (np.abs(M.T @ M - np.eye(3)).max(), np.linalg.det(M), np.round(np.linalg.svd(M, compute_uv=False), 3)))
P = project_to_so3(M)
print("after SVD projection: max |P^T P - I| = %.1e, det = %.6f, error %.2f deg"
% (np.abs(P.T @ P - np.eye(3)).max(), np.linalg.det(P), geodesic_deg(P, R_true.as_matrix())))
print("SciPy Rotation.mean agrees: %.1e deg" % geodesic_deg(P, Rotation.from_matrix(Rs).mean().as_matrix()))
# Quaternion sign: averaging q's without aligning their signs.
q = Rotation.from_matrix(Rs).as_quat(scalar_first=True)
q[::2] *= -1 # same rotations, three of five sign-flipped
naive = q.mean(axis=0)
aligned = (q * np.sign(q @ q[0])[:, None]).mean(axis=0)
for name, m in [("naive", naive), ("sign-aligned", aligned)]:
R_m = Rotation.from_quat(m, scalar_first=True).as_matrix()
print(f"quaternion mean, {name:12s}: |mean| = {np.linalg.norm(m):.3f}, "
f"error {geodesic_deg(R_m, R_true.as_matrix()):.2f} deg")
# Drift: compose 100,000 small camera increments in float32.
step = Rotation.from_rotvec([1e-3, 2e-3, -1e-3]).as_matrix().astype(np.float32)
R = np.eye(3, dtype=np.float32)
for k in range(1, 100_001):
R = R @ step
if k in (1_000, 10_000, 100_000):
print(f"after {k:6d} products: max |R^T R - I| = {np.abs(R.T @ R - np.eye(3)).max():.1e}")
R = project_to_so3(R.astype(np.float64))
print("re-orthonormalized: max |R^T R - I| = %.1e" % np.abs(R.T @ R - np.eye(3)).max())
# Angle from the trace: arccos loses small angles; atan2 does not.
small = Rotation.from_rotvec([1e-8, 0, 0]).as_matrix()
print("1e-8 rad rotation: arccos form %.2e rad, atan2 form %.2e rad"
% (np.arccos(np.clip((np.trace(small) - 1) / 2, -1, 1)), np.radians(geodesic_deg(np.eye(3), small))))
Output:
estimates' errors (deg): [30. 20. 25. 10. 15.]
element-wise mean: max |M^T M - I| = 0.082, det = 0.888, singular values [0.967 0.959 0.957]
after SVD projection: max |P^T P - I| = 3.9e-16, det = 1.000000, error 7.99 deg
SciPy Rotation.mean agrees: 1.5e-13 deg
quaternion mean, naive : |mean| = 0.200, error 36.04 deg
quaternion mean, sign-aligned: |mean| = 0.985, error 8.01 deg
after 1000 products: max |R^T R - I| = 1.9e-05
after 10000 products: max |R^T R - I| = 1.6e-04
after 100000 products: max |R^T R - I| = 1.7e-03
re-orthonormalized: max |R^T R - I| = 8.9e-16
1e-8 rad rotation: arccos form 0.00e+00 rad, atan2 form 1.00e-08 rad
The element-wise mean of five estimates is not orthogonal (determinant 0.888); its SVD projection is a rotation and matches SciPy’s Rotation.mean (its 8° error comes from offsets that do not cancel). Averaging quaternions with mixed signs gives a 36° error; aligning the signs first gives 8.01°, close to the chordal mean. After 100,000 single-precision products, is off the identity by until one SVD projection restores it. For a rad rotation, the form returns zero.
Common Misconceptions
- “A quaternion is four numbers, so is unambiguous.” The scalar may come first or last, and Hamilton and JPL quaternions differ in handedness.
- “Euler angles are fine away from 90° pitch.” Their wrap-around at ±180° still breaks interpolation, distances and losses.
- “ and are different orientations.” They are the same rotation; distances and losses must take the absolute value of .
- “A rotation vector is a vector, so it can be added.” Adding rotation vectors is not composition except for rotations about the same axis; composition goes through the matrices or quaternions.
Where It Is Used
Every estimate of a camera’s orientation needs a representation: perspective-n-point, the essential matrix decomposition, iterative closest point (whose closed-form solutions use either the SVD or unit quaternions), bundle adjustment, visual odometry, SLAM and the error-state extended Kalman filter, which estimates orientation errors as rotation vectors. Networks that regress object or camera pose can output the 6D form of Zhou et al. (2019). Camera pose combines a rotation with a translation and a transform direction.
Related
- Bundle Adjustment
The joint nonlinear least-squares refinement of all camera parameters and 3D points in a reconstruction, made practical by the sparse structure of the problem and the Schur complement.
- Perspective-n-Point (PnP)
Estimating the position and orientation of a calibrated camera from known 3D points and their 2D image projections, the absolute pose problem behind visual localization, augmented reality, and camera tracking.
- Essential Matrix
The 3 × 3 matrix E = [t]×R that encodes the relative rotation and translation direction between two calibrated views, how it is estimated from point matches, and how the camera pose is recovered from it.
- Simultaneous Localization and Mapping (SLAM)
Building a map of an unknown environment while estimating the trajectory of the sensor moving through it, and using recognized revisits to keep both globally consistent.
- Iterative Closest Point (ICP)
An algorithm that rigidly aligns two point clouds without known correspondences by alternating closest-point matching with a least-squares estimate of the rotation and translation.
- Nonlinear Least Squares
Fitting a model that is nonlinear in its parameters by iteratively minimizing a sum of squared residuals, the optimization behind calibration, triangulation, homography refinement, and bundle adjustment.
References
- Solà, J. (2017). Quaternion Kinematics for the Error-State Kalman Filter. arXiv:1711.02508 (version 1).
- Solà, J., Deray, J. & Atchuthan, D. (2018). A Micro Lie Theory for State Estimation in Robotics. arXiv:1812.01537 (version 9, 2021).
- Zhou, Y., Barnes, C., Lu, J., Yang, J. & Li, H. (2019). On the Continuity of Rotation Representations in Neural Networks. IEEE/CVF Conference on Computer Vision and Pattern Recognition (CVPR), 5738–5746.
- Shoemake, K. (1985). Animating Rotation with Quaternion Curves. ACM SIGGRAPH Computer Graphics, 19(3), 245–254.
- OpenCV 4.14.0 source code: Rodrigues documentation in modules/calib3d/include/opencv2/calib3d.hpp (2026).
- OpenCV 4.14.0 source code: cv::Rodrigues implementation in modules/calib3d/src/calibration_base.cpp (2026).
- OpenCV 4.14.0 source code: cv::Quat documentation in modules/core/include/opencv2/core/quaternion.hpp (2026).
- SciPy documentation (version 1.18.0): scipy.spatial.transform.Rotation.
- Eigen documentation (version 3.4.1): Eigen::Quaternion class template reference.
- Foote, T. & Purvis, M. (2010). REP 103: Standard Units of Measure and Coordinate Conventions. ROS Enhancement Proposals.
- ROS 2 Documentation (Jazzy): Quaternion fundamentals.