1. 从示教器上的姿态显示说起:为什么需要指数坐标
1.1 旋转矩阵、欧拉角和四元数各自难受在哪
去年帮朋友调一台六轴机械臂时,他在示教器里看到姿态一栏显示的是欧拉角,随口问了一句:这个角度到底转的是哪个轴?我刚要解释,发现这个问题其实比表面上麻烦。做机器人运动学这些年,姿态表示换来换去,最后真正让我觉得顺手、也让我能安心在纸上推公式的,是指数坐标。它用一根旋转轴和一个转角,把任意旋转压缩成一个三维向量,简洁得不像处理三维旋转的表示方法。这篇文章想把我的理解、推导和踩坑记录整理出来,给正在做机器人运动学、ROS开发或者工业机械臂姿态处理的朋友一个参考。
很多新手第一次接触机械臂,看到示教器上显示 XYZ 坐标和 ABC 三个角度,以为那就是绕三个坐标轴的转角。实际上,ABC 只是控制器按某种欧拉角约定显示出来的姿态。欧拉角本身有三个明显问题:第一,旋转顺序必须严格约定,ZYX、ZYZ、XYX 各不相同,换一套顺序,同样的三个数对应的是完全不同的姿态;第二,存在万向锁问题,当中间旋转角接近 90 度时,另外两个轴的运动空间退化,姿态的求逆和插值会出现奇怪抖动;第三,同一个姿态可以对应多组欧拉角,控制器内部做坐标变换时,很容易因为某个分支选择不同而产生数值跳变。
旋转矩阵 R 很严谨,9 个数没有奇异,也不依赖旋转顺序。但它的问题是冗余,需要满足正交约束 R^T R=I 且 det(R)=1。每次对旋转矩阵做加法和插值时,结果大概率不再是一个合法旋转矩阵,还得重新正交化。如果你在工程里试过对旋转矩阵做线性插值,会发现中间点矩阵的行列式开始偏离 1,这事很头疼。
四元数没有欧拉角的奇异,也只有 4 个数,但带有单位约束。实际使用四元数时,最常犯的错误是忘记归一化,或者忽略 q 和 -q 表示同一个姿态的双覆盖问题。四元数做插值很顺手,但做优化、求导和状态估计时,约束条件始终像个尾巴一样甩不掉。所以到后面你会发现,我们需要一种更贴近“旋转本质”的表示:旋转轴加上旋转角度。指数坐标就是这个思路的严格数学版本。
1.2 指数坐标一句话解释:旋转向量就是指数坐标
指数坐标这个名字听起来有点唬人,但说白了就是一个旋转向量。设一个单位旋转轴为 ω,旋转角度为 θ,那么指数坐标就是:
r = θ ω这里的 r 是三维向量,方向表示旋转轴,模长表示旋转角度。例如绕 z 轴转 90 度,指数坐标就是 [0, 0, π/2]^T。如果绕 x 轴转 30 度,就是 [π/6, 0, 0]^T。你完全可以把它理解成“轴角表示法的省略版本”:传统轴角表示通常是一个单位轴加一个角度,需要 4 个数;指数坐标直接把角度乘到轴向量上,只要 3 个数,清爽很多。
从李群李代数的角度看,三维旋转矩阵的集合构成 SO(3) 群,而所有反对称矩阵构成对应的李代数 so(3)。指数坐标 r 就是 so(3) 里的一个元素,把它做一个矩阵指数运算,就能映射回 SO(3)。换句话说,指数坐标是在“旋转矩阵群”的切空间里给旋转做坐标编码。这个编码没有额外约束,自由度正好是 3,所以它在机器人学、SLAM、计算机视觉里都是姿态优化的默认选择之一。
2. SO(3)上的指数与对数:先看懂公式,再谈使用
2.1 反对称矩阵:旋转向量进入矩阵世界的入口
要把旋转向量变成旋转矩阵,第一步是构造反对称矩阵。对任意三维向量 ω=(ω_x, ω_y, ω_z),定义 hat 算子:
[ω]× = [[0, -ω_z, ω_y], [ω_z, 0, -ω_x], [-ω_y, ω_x, 0]]这个矩阵的作用等价于叉乘,也就是说 [ω]× v = ω × v。在刚体运动里,ω 最典型的身份是角速度向量。一个刚体绕单位轴 ω 以角速度 θ 旋转时,瞬时角速度其实是向量 θω,也就是指数坐标本身。
为什么旋转向量要对应反对称矩阵?这可以从旋转矩阵的导数理解。假设一个旋转从单位矩阵开始,沿固定轴持续旋转,令 R(t) 表示 t 时刻的旋转矩阵,那么 R(0)=I,并且在 t=0 附近,R(t) 的导数就是一个反对称矩阵:
R'(0) = [ω]×反对称矩阵天然对应无穷小旋转,而不是完整旋转。从这个角度说,指数坐标表示的其实是“旋转在单位阵处的切线方向”,只有通过指数映射把它“积”起来,才能得到有限大小的旋转矩阵。
2.2 指数映射:罗德里格斯公式是怎么来的
给定旋转向量 θω,其中 ω 是单位轴,对应的旋转矩阵定义为矩阵指数:
R = exp(θ [ω]×)这里的 exp 不是普通数的指数,而是矩阵指数。直接用矩阵指数的无穷级数计算当然可以,但对三维反对称矩阵有一个著名的闭式解,也就是罗德里格斯公式:
R = I + sin(θ) [ω]× + (1 - cos(θ)) [ω]×^2这个公式怎么推出来的?设 K=[ω]×,注意反对称矩阵的幂次具有循环特性:
K^2 = ωω^T - I K^3 = -K然后展开矩阵指数:
e^{θK} = I + θK + θ^2 K^2 / 2! + θ^3 K^3 / 3! + ...把 K^3=-K、K^4=-K^2 这些关系代进去,奇数项都含有 K,偶数项都含有 K^2,分别凑成 sin(θ) 和 1-cos(θ),就得到了罗德里格斯公式。这个过程其实和复数 z=e^{iθ}=cosθ+i sinθ 的推导思路很相似,只不过这里的“虚数单位”被换成了一个反对称矩阵。
注意一点,公式里的 θ 单位必须是弧度。如果你传入的旋转向量模长是按度算的,最终会得到完全错误的旋转矩阵。这种错误非常隐蔽,因为代码不会报错,只在机械臂实际运动时表现出角度放大了约 57 倍。
2.3 对数映射:从旋转矩阵把轴和角解回来
反过来,给定一个旋转矩阵 R,想求它对应的指数坐标,这个过程叫对数映射。先通过矩阵的迹求转角:
cos(θ) = (tr(R) - 1) / 2 θ = arccos((tr(R) - 1) / 2)由于 arccos 的值域是 [0, π],所以默认取转角在 0 到 π 之间。也就是说,旋转矩阵对应的指数坐标总会返回“最短”的那次旋转。一个绕轴转了 270 度的姿态,用对数映射解出来会是另一根轴上的 90 度旋转,而不是原来的 270 度,这一点在实际调试时特别容易让人迷惑。
求出 θ 之后,在 θ 不接近 0 和 π 的情况下,可以用反对称部分求轴:
[ω]× = (R - R^T) / (2 sin(θ)) ω = vee((R - R^T) / (2 sin(θ)))其中 vee 是 hat 的逆运算,把反对称矩阵的 (2,1)、(0,2)、(1,0) 三个元素取出来,重新拼成一个向量。这个公式的来源是罗德里格斯公式里的反对称项:R - R^T = 2 sin(θ) [ω]×。
但当 θ 接近 0 或 π 时,这个公式会失效。θ=0 时,R=I,旋转轴任意,指数坐标就是零向量。θ=π 时,sin(θ)=0,R 变成对称矩阵,反对称部分直接消失,必须改用对称部分求解。工程里很多 bug 都出在这两个边界上。
3. 手写指数坐标的映射代码:稳定实现与边界处理
3.1 指数映射代码:小角度的坑先堵住
理解了公式,写代码并不难,但要把边界情况处理干净。下面这个 Python 版本可以直接用来做实验。它先求旋转向量模长,再归一化得到单位轴,最后套罗德里格斯公式。
import numpy as np def skew(v): x, y, z = v return np.array([ [0.0, -z, y], [z, 0.0, -x], [-y, x, 0.0] ]) def exp_so3(r): theta = np.linalg.norm(r) if theta < 1e-8: return np.eye(3) axis = r / theta K = skew(axis) return np.eye(3) + np.sin(theta) * K + (1.0 - np.cos(theta)) * (K @ K)当 theta 接近 0 时,sin(θ)/θ 和 (1-cos(θ))/θ^2 在数值上会有一定精度损失。大多数情况下,1e-8 以下直接返回单位矩阵就够了。如果你在做高精度状态估计,可以再加一阶泰勒近似:
if theta < 1e-6: return np.eye(3) + skew(r) + 0.5 * skew(r) @ skew(r)这样在微小旋转下也能保持足够的精度。
3.2 对数映射代码:三种分支必须分开
对数映射代码比指数映射麻烦一点,因为要处理 0、π、中间情况三个分支。下面是我在实际项目里常用的一种写法。
def vee(S): return np.array([S[2, 1], S[0, 2], S[1, 0]]) def log_so3(R): R = np.asarray(R, dtype=float) cos_angle = (np.trace(R) - 1.0) / 2.0 cos_angle = np.clip(cos_angle, -1.0, 1.0) theta = np.arccos(cos_angle) if theta < 1e-8: return np.zeros(3) if np.isclose(theta, np.pi, atol=1e-8): WW = (R + np.eye(3)) / 2.0 diag = np.clip(np.diag(WW), 0.0, 1.0) i = int(np.argmax(diag)) axis = np.zeros(3) axis[i] = np.sqrt(diag[i]) for j in range(3): if j != i: axis[j] = WW[i, j] / axis[i] return np.pi * axis axis = vee((R - R.T) / (2.0 * np.sin(theta))) return theta * axis这段代码里我在 θ=π 分支用了对称部分。原因是 R 在 θ=π 时变成了对称矩阵,反对称部分恒等于零,只能用 (R+I)/2 解出 ωω^T,再通过最大对角元素恢复轴的方向。
几点注意:
- cos_angle 必须 clip 到 [-1,1]。矩阵乘积和浮点误差会让 trace 略微超出范围,直接 acos 会得到 nan。
- 对数映射返回的是模长在 [0,π] 的旋转向量,不会返回超过 π 的角度。若你的机械臂某个关节实际转了 300 度,想用这个函数得到 300 度是不可能的,除非你对每个控制周期做小增量累加。
- θ 在 π 附近但没完全等于 π 时,用反对称部分除 sin(θ) 会有噪声放大风险。如果从传感器数据里拿到一个带误差的旋转矩阵,并且 θ 接近 π,我建议改用四元数中转方式,或者把所有 θ>3π/4 的情况都走对称分支。
3.3 与四元数、欧拉角互转时千万别踩的坑
指数坐标和四元数之间的转换很直接。旋转向量 r=θω 转四元数为:
q = [cos(θ/2), ω sin(θ/2)]这里如果 ω 恰好是零向量,说明旋转角是 0,四元数取单位四元数 [1,0,0,0]。四元数转旋转向量则是先算 θ=2*acos(q_w),再取向量部分归一化乘以 θ。注意 q 和 -q 是同一个姿态,如果你的四元数来自多个传感器融合或者历史缓存,要先统一符号,否则 θ 和 ω 会突然出现翻转。
欧拉角的坑比四元数更多。不同机器人厂家对欧拉角的内部实现可能完全不一样,有的用 ZYX 外旋,有的用 ZYZ 内旋,还有的用 RPY 但绕世界轴定义。我见过一个项目,外部视觉系统给的姿态是欧拉角,机械臂控制器要求的是四元数,中间转了一次,最后姿态差了十几度。排查到最后发现是对“外旋”和“内旋”的理解不一样。
因此我的建议是:在代码内部只使用旋转矩阵、四元数或指数坐标中的一种作为统一表示,欧拉角只用于示教器显示和人工检查。每次转换都是一次出错的机会,能少转就少转。
4. 指数坐标在机器人里的实战场景:从正运动学到姿态插值
4.1 旋量与指数积:正运动学可以不用死记 DH 表
指数坐标在机器人学里最漂亮的应用是旋量理论和指数积公式。一个转动关节的运动,本质上是绕某个固定轴的旋转。把关节轴线的方向记作 ω,轴线上任意一点记作 q,那么关节的螺旋运动可以表示成一个 6 维旋量 ξ。当关节转动 θ 角时,关节末端的齐次变换矩阵就是 e^{ξ̂ θ}。
对于串联机械臂,把每个关节的旋量排成乘积式:
T(θ) = e^{ξ1 θ1} e^{ξ2 θ2} ... e^{ξn θn} M其中 M 是机械臂在零位时末端执行器的齐次变换矩阵。这个公式的旋转部分,核心仍然是 e^{[ω]× θ},也就是我们前面讨论的指数映射。
相比传统 DH 参数建模,指数积的好处是不需要对每个关节定义一个局部坐标系,也不需要记忆 α、a、d、θ 哪个参数表示哪个偏移。你只需要知道每个关节轴线在基坐标系下的位置和方向。做运动学标定时,直接估计旋量参数也更自然,因为旋量的物理意义更贴近机械结构本身。我在对机械臂做正运动学验证时,通常先用 DH 推一遍,再用 POE 推一遍,两边对上了才敢继续做后续控制。
4.2 姿态插值:用 log 求相对量,用 exp 走中间点
给机械臂规划姿态路径时,需要把某段起点姿态 R0 平滑过渡到终点姿态 R1。最容易犯的错误是对 R0 和 R1 的欧拉角直接线性插值。比如从 [0,0,0] 插到 [170,0,0] 度,传统欧拉角会产生中间姿态绕其他轴轻微晃动,严重时甚至触发控制器奇异保护。
正确做法是先求出两个姿态之间的相对旋转:
delta_R = R0.T @ R1 rv = log_so3(delta_R)这里的 rv 表示“在 R0 局部坐标系看来,还需要转多少角度”。然后按比例走指数映射:
for i in range(n + 1): t = i / n R_t = R0 @ exp_so3(t * rv)这样插值出来的姿态始终是合法旋转矩阵,而且如果 rv 的模长不超过 π,走的是一条保持角速度方向不变的测地线,中间点平滑,没有抖动。这个思路等价于四元数插值里的 slerp,但从旋转矩阵的角度理解起来更加直接。
如果你用的是 world 坐标系表达相对旋转,也可以先算delta_R_world = R1 @ R0.T,然后插值时左乘:
R_t = exp_so3(t * rv_world) @ R0两种写法都能用,但一定要分清左乘和右乘。左乘对应相对旋转在 world 系,右乘对应相对旋转在 body 系。混用之后,轨迹看起来可能也是平滑的,但视觉上和实际机械手运动方向会差一个姿态旋转。
4.3 角速度关系与状态估计:为什么优化里总用它
指数坐标和角速度之间有天然联系。对一个连续运动的旋转矩阵 R(t),如果角速度向量在世界系下为 ω_world,则有:
\dot R = [ω_world]× R如果角速度定义在体坐标系下,则换成:
\dot R = R [ω_body]×这两个公式说明,旋转矩阵的导数本质上就是把角速度向量塞进反对称矩阵,再乘上一个旋转矩阵。这也解释了为什么在机器人导航、SLAM 和状态估计里,大家都喜欢用李代数上的增量来表示姿态误差。因为姿态残差通常写成:
r = log_so3(R_est^T R_meas)这个残差是一个三维向量,直接落在向量空间里。优化器可以对它做普通加法和求导,不用反复处理旋转矩阵的正交约束。相比欧拉角,它在三维空间中不引入奇异;相比四元数,它天然只有三个自由度,没有归一化约束。这个优势在做因子图优化、卡尔曼滤波的姿态更新时非常明显。
我在调试机器人导航里的位姿估计时,经常看到有人把全局姿态存成三个欧拉角,然后每次更新直接加一个角度增量。这个做法在平面运动、角度变化不大时勉强能用,一旦机器人开始上下坡或者做 360 度旋转,角度跳变和万向锁会一起出现。后来统一改成“全局四元数 + 局部指数坐标增量”后,整个系统稳定了很多。
5. 调试现场记录与避坑清单
5.1 高频问题速查表
下面是我在带学生和做项目时经常遇到的几个问题,整理成速查表,方便你排查。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| arccos 返回 nan | trace 超出 [-1,1] | 对 cos_angle 做 clip,或者先检查 R^T R 是否接近 I |
| θ 接近 π 时轴的方向乱跳 | 用反对称部分除 sin(θ) 导致噪声放大 | 改用对称部分,或者用四元数中转 |
| 插值轨迹中间出现抖动 | 直接对欧拉角做线性插值 | 改为 R0 @ Exp(t * log(R0^T R1)) |
| 旋转方向反了 | 左乘和右乘约定搞混 | 先确认角速度是在 world 还是 body 系 |
| 旋转角度感觉大了约 57 倍 | 角度和弧度混用 | 统一使用 rad,示教器显示角度只在界面层转换 |
| log 出来的角度总在 0 到 π 之间 | 对数映射取主值 | 如果需要累计角度,用小增量积分而不是对整体求 log |
5.2 工具选型:哪些库可以直接用
如果你不想自己造轮子,下面这些库最好都用过一遍,因为它们在很多机器人项目里已经是事实标准。
- Python 里最方便的是 SciPy:
Rotation.from_rotvec()和Rotation.as_rotvec()直接封装了指数坐标的对数和指数映射,底层也处理了边界情况。 - C++ 里推荐 Sophus,它的
SO3d::exp()和SO3d::log()用法非常清晰。Eigen 的AngleAxis也能做类似事情,但需要手动乘起来。 - 做机械臂运动学、动力学和旋量计算,Pinocchio 库很实用。它提供了
exp3、log3以及 SE(3) 上的整套李群运算,ROS2 环境下也经常直接使用。 - 如果项目里已经在用 tf2 和 ROS,不必为了一个旋转向量引入整套库。用 Eigen 的 Quaterniond 转 AngleAxis 也就几行代码,关键是所有函数都放在一个独立工具模块里,别到处裸转。
5.3 我的几点心得:别把全局姿态塞进一个三维向量
最后说一点个人体会。指数坐标很适合做局部增量、插值、优化和求导,但我不建议把它作为全局姿态的唯一存储形式。原因是旋转向量的模长存在 2π 周期性,累加器从 2π 跳到 0 的那一瞬间,所有依赖它的控制逻辑都会看到一个大跳变。真实机械臂不会在那一瞬间真的转一圈,但你的规划程序会以为自己出了 bug。
比较稳的做法是:全局姿态用旋转矩阵或四元数存储,局部误差和增量一律用指数坐标表达。需要显示或人工观察时,再临时转成欧拉角。这样既避开了欧拉角的奇异,又享受了指数坐标在优化和插值上的便利。
另一个小技巧是调试阶段把转角 θ 的值打出来看。如果程序运行中 θ 突然从接近 0 变成接近 2π,多半不是机械问题,而是你的角度单位或者分支选择出了问题。姿态问题看着复杂,但只要把“本体量”和“增量量”分清楚,把公式按指数映射和对数映射两条路走通,大部分坑都能提前避掉。这也是我在指数坐标上花时间研究之后,最直接的收获。