ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从旋转矩阵到欧拉角:原理、推导与工程避坑指南

从旋转矩阵到欧拉角:原理、推导与工程避坑指南

姿态解算这个活儿,干过飞控、机器人或者三维重建的人都不陌生。你手里攥着一个旋转矩阵,九个数,看着挺规整,可一旦要往控制指令、动画关键帧或者人机交互界面上落,就得把它翻译成人能理解的三维角度——这就是从旋转矩阵计算欧拉角这件事的本质。它不是什么高深理论,但绝对是个细节坑密集的环节:同一个矩阵,换一种旋转顺序,出来的角度能差出十万八千里;同一个顺序,内旋和外旋的定义不同,结果又对不上;更别提万向锁附近那一片数值病态区,稍不留神就给你整出一堆跳变的角度,让下游的控制器直接发疯。

我这些年做过几个涉及姿态解算的项目,从机械臂末端位姿到相机外参标定,再到虚拟场景里的物体朝向同步,几乎每次都要和这个转换打交道。踩过的坑包括但不限于:矩阵元素抄错一位导致角度全乱、旋转顺序约定和上游数据源不一致、在奇异点附近没做保护导致角度突变、以及最经典的——把弧度当角度直接喂给界面。这些问题单拎出来都不复杂,但凑在一起,足够让一个调试下午报废。

这篇内容适合谁看?如果你正在写姿态解算相关代码,或者需要把某个库输出的旋转矩阵转成欧拉角去对接另一个系统,再或者你只是好奇这九个数怎么变成三个角,那下面的内容应该能帮到你。我会从旋转矩阵和欧拉角的基本关系讲起,把旋转顺序的约定、公式推导的来龙去脉、奇异点的处理、以及实际编码时的取舍都拆开说清楚,最后给一份可以直接抄作业的实现和一套排查问题的思路。不堆公式吓人,但该有的推导一步不省,因为很多坑恰恰藏在那些被省略的步骤里。

1. 先搞清楚旋转矩阵和欧拉角到底在描述什么

1.1 两种描述方式的本质差异

旋转矩阵和欧拉角,本质上是在用两套语言说同一件事:一个刚体在三维空间里的朝向。旋转矩阵用的是九个元素组成的正交矩阵,满足 $R^T R = I$ 且 $\det(R) = 1$,它描述的是从一个坐标系到另一个坐标系的线性变换。欧拉角则是用三个依次绕特定轴旋转的角度来刻画同一个朝向,比如常见的“绕 Z 转 yaw、绕 Y 转 pitch、绕 X 转 roll”。

这两套语言各有各的脾气。旋转矩阵的好处是唯一、无奇异、可以直接做矩阵乘法复合旋转,数值上也稳定,所以几乎所有姿态解算算法、视觉标定库、物理引擎内部都用它或者四元数来存姿态。欧拉角的优势在于直观,人能看懂,控制律里经常直接拿角度做反馈,动画系统里也习惯用角度插值。但欧拉角有个天生的毛病:它依赖旋转顺序的约定,而且存在万向锁,同一个朝向可以对应多组角度,甚至在奇异点附近角度会剧烈跳变。

所以从旋转矩阵算欧拉角,本质上是一个“反解”过程:已知复合旋转的结果,反推每一步绕轴转了多少。这个反解不是唯一的,你必须先确定旋转顺序,才能写出确定的公式。这就是为什么很多新手直接套网上的代码,结果和自己的数据对不上——大概率是旋转顺序或者内旋外旋的定义不一致。

1.2 旋转顺序为什么是第一个必须敲定的参数

绕三个坐标轴的旋转,顺序不同,结果完全不同。举个直观的例子:你先绕 X 转 90 度再绕 Z 转 90 度,和先绕 Z 转 90 度再绕 X 转 90 度,最终朝向是两码事。三个轴的全排列有六种,再考虑每个角是绕固定轴(外旋,extrinsic)还是绕自身轴(内旋,intrinsic),组合起来约定就更多了。工程上常见的约定有 ZYX、XYZ、ZXY 等,航空航天领域习惯用 ZYX(也就是 yaw-pitch-roll 顺序),机器人学里 XYZ 和 ZYX 都常见,图形学里则经常见到 XYZ 或者 ZXZ 这类。

这里有个特别容易混淆的点:内旋和外旋在数学上是等价的,只是顺序反过来。也就是说,按 ZYX 顺序做外旋,等价于按 XYZ 顺序做内旋。很多库在文档里写“旋转顺序 XYZ”,但没说是内旋还是外旋,这时候你就得去看它的实现或者测试用例。我个人的经验是,拿到任何一个姿态库,第一件事就是构造一个已知的单轴旋转矩阵喂进去,看它输出的角度是否符合预期,这比读文档快得多。

确定顺序之后,复合旋转矩阵就可以写成各单轴旋转矩阵的连乘。以 ZYX 外旋为例,先绕 Z 转 $\alpha$,再绕 Y 转 $\beta$,再绕 X 转 $\gamma$,复合矩阵是 $R = R_z(\alpha) R_y(\beta) R_x(\gamma)$。注意矩阵乘法的顺序是从右往左作用在向量上,所以最右边的 $R_x$ 最先作用。这个连乘展开之后,九个元素都是 $\alpha, \beta, \gamma$ 的三角函数组合,反解就是从这些组合里把三个角抠出来。

2. 从矩阵元素反解角度的推导逻辑

2.1 以 ZYX 顺序为例展开矩阵

把三个单轴旋转矩阵写出来,绕 Z 轴转 $\alpha$ 的矩阵是:

$$ R_z(\alpha) = \begin{bmatrix} \cos\alpha & -\sin\alpha & 0 \ \sin\alpha & \cos\alpha & 0 \ 0 & 0 & 1 \end{bmatrix} $$

绕 Y 轴转 $\beta$ 的矩阵是:

$$ R_y(\beta) = \begin{bmatrix} \cos\beta & 0 & \sin\beta \ 0 & 1 & 0 \ -\sin\beta & 0 & \cos\beta \end{bmatrix} $$

绕 X 轴转 $\gamma$ 的矩阵是:

$$ R_x(\gamma) = \begin{bmatrix} 1 & 0 & 0 \ 0 & \cos\gamma & -\sin\gamma \ 0 & \sin\gamma & \cos\gamma \end{bmatrix} $$

三者按 $R = R_z(\alpha) R_y(\beta) R_x(\gamma)$ 连乘,展开后得到:

$$ R = \begin{bmatrix} c\alpha c\beta & c\alpha s\beta s\gamma - s\alpha c\gamma & c\alpha s\beta c\gamma + s\alpha s\gamma \ s\alpha c\beta & s\alpha s\beta s\gamma + c\alpha c\gamma & s\alpha s\beta c\gamma - c\alpha s\gamma \ -s\beta & c\beta s\gamma & c\beta c\gamma \end{bmatrix} $$

其中 $c$ 和 $s$ 分别是 cos 和 sin 的简写。这个展开式是后面所有反解的基础,建议自己动手推一遍,因为不同顺序的展开式长得不一样,但推导套路是一致的:先找只含一个未知角的元素,再逐步解出其余两个。

2.2 逐元素反解三个角

观察上面的矩阵,第三行第一列是 $-s\beta$,只含 $\beta$ 一个未知量,这是突破口。于是:

$$ \beta = \arcsin(-R_{31}) $$

这里要注意,$\arcsin$ 的值域是 $[-\pi/2, \pi/2]$,所以解出来的 $\beta$ 落在第一或第四象限。这在大多数应用里是可接受的,因为 pitch 角通常就定义在这个范围内。但如果你需要完整的 $[-\pi, \pi]$ 范围,就得结合其他元素判断。

解出 $\beta$ 之后,看第三行第二列 $c\beta s\gamma$ 和第三行第三列 $c\beta c\gamma$。当 $c\beta \neq 0$ 时,可以同时除以 $c\beta$,得到:

$$ \gamma = \arctan2(R_{32}, R_{33}) $$

再看第一行第一列 $c\alpha c\beta$ 和第二行第一列 $s\alpha c\beta$,同样在 $c\beta \neq 0$ 时:

$$ \alpha = \arctan2(R_{21}, R_{11}) $$

这里用 $\arctan2$ 而不是普通 $\arctan$,是因为 $\arctan2$ 能根据两个参数的符号确定象限,返回 $[-\pi, \pi]$ 的完整范围,避免角度丢失。这是反解里一个非常关键的细节,很多手写的实现用 $\arctan(y/x)$ 然后在四个象限里手动判断,既啰嗦又容易出错,直接用 $\arctan2$ 省事得多。

2.3 奇异点到底发生了什么

上面推导里反复出现“当 $c\beta \neq 0$ 时”这个条件,这就是万向锁的数学表现。当 $\beta = \pm \pi/2$ 时,$c\beta = 0$,此时 $R_{32}$ 和 $R_{33}$ 同时为零,$\arctan2(0, 0)$ 无定义,$\alpha$ 和 $\gamma$ 也无法分离。从几何上看,这时候绕 Z 轴和绕 X 轴的旋转轴重合了,两个角的效果退化成只有一个自由度,你没法从矩阵里唯一确定 $\alpha$ 和 $\gamma$ 各自是多少,只能确定它们的和或差。

工程上的处理方式通常是:在奇异点附近设定一个阈值,当 $|c\beta|$ 小于阈值时,强制令其中一个角为零(比如令 $\gamma = 0$),然后解出另一个角。这样虽然丢失了一个自由度,但至少输出是连续且可用的。另一种做法是切换到四元数表示,在奇异点附近用四元数做插值和运算,只在需要显示或控制时再转成欧拉角。我个人的偏好是后者,因为四元数没有奇异点,整个链路的数值稳定性更好,欧拉角只在最终输出层出现。

注意:奇异点阈值不能设得太大,否则正常区域的角度精度会受影响;也不能太小,否则浮点误差会让 $\arctan2$ 的输出剧烈抖动。经验值取 $10^{-6}$ 到 $10^{-4}$ 之间比较稳妥,具体要看你的数据精度和下游对角度跳变的容忍度。

3. 实操:写一份能直接用的转换代码

3.1 参数约定和接口设计

动手写代码之前,先把约定固定下来,这是避免后续扯皮的关键。我习惯在函数签名和注释里明确三件事:旋转顺序、内旋还是外旋、角度单位。比如下面这个 Python 实现,约定为 ZYX 外旋,输入是 $3\times3$ 的旋转矩阵,输出是 $(roll, pitch, yaw)$ 三元组,单位为弧度。

import numpy as np def rotation_matrix_to_euler_zyx(R): """ 将旋转矩阵转换为 ZYX 外旋欧拉角 (roll, pitch, yaw)。 参数: R: 3x3 旋转矩阵 (numpy array) 返回: (roll, pitch, yaw) 单位弧度 约定: R = Rz(yaw) @ Ry(pitch) @ Rx(roll) """ R = np.asarray(R, dtype=float) # 基本合法性检查 if R.shape != (3, 3): raise ValueError("输入必须是 3x3 矩阵") # pitch 由 R[2,0] 反解 sin_pitch = -R[2, 0] # 数值裁剪,防止浮点误差导致 arcsin 定义域越界 sin_pitch = np.clip(sin_pitch, -1.0, 1.0) pitch = np.arcsin(sin_pitch) # 奇异点判断 if abs(sin_pitch) > 1.0 - 1e-6: # 万向锁:令 roll = 0,解 yaw roll = 0.0 yaw = np.arctan2(-R[0, 1], R[1, 1]) else: roll = np.arctan2(R[2, 1], R[2, 2]) yaw = np.arctan2(R[1, 0], R[0, 0]) return roll, pitch, yaw

这段代码不长,但每一行都有讲究。np.clip那一步是必须的,因为浮点运算会让 $R_{31}$ 偶尔变成 $1.0000000002$ 这种值,直接喂给arcsin会返回nan,下游拿到nan基本就是灾难。奇异点判断用1.0 - 1e-6作为阈值,对应 $|c\beta| < \sqrt{2 \times 10^{-6}} \approx 0.0014$,这个范围在实际数据里足够小,不会误伤正常区域。

3.2 验证:用已知角度构造矩阵再反解

写完代码必须验证,最直接的办法是构造已知角度的旋转矩阵,喂进去看输出是否一致。下面这段测试代码覆盖了常规角度和奇异点附近的情况:

def euler_zyx_to_matrix(roll, pitch, yaw): cr, sr = np.cos(roll), np.sin(roll) cp, sp = np.cos(pitch), np.sin(pitch) cy, sy = np.cos(yaw), np.sin(yaw) Rz = np.array([[cy, -sy, 0], [sy, cy, 0], [0, 0, 1]]) Ry = np.array([[cp, 0, sp], [0, 1, 0], [-sp, 0, cp]]) Rx = np.array([[1, 0, 0], [0, cr, -sr], [0, sr, cr]]) return Rz @ Ry @ Rx # 常规角度测试 for angles in [(0.1, 0.2, 0.3), (-1.0, 0.5, 2.0), (3.0, -1.2, -2.5)]: R = euler_zyx_to_matrix(*angles) result = rotation_matrix_to_euler_zyx(R) print(angles, "->", result) # 奇异点附近测试 for pitch in [np.pi/2 - 1e-7, np.pi/2, -np.pi/2 + 1e-7]: R = euler_zyx_to_matrix(0.5, pitch, 0.8) result = rotation_matrix_to_euler_zyx(R) print("singular:", pitch, "->", result)

实测下来,常规角度能精确还原到 $10^{-15}$ 量级,奇异点附近因为强制令 roll 为零,输出会和输入不同,但对应的旋转矩阵是一致的——这一点很重要,验证的时候不能直接比角度,要比重建后的矩阵差异。我一般用 $|R_{rebuilt} - R_{original}|_F$ 作为指标,小于 $10^{-10}$ 就算通过。

3.3 其他旋转顺序怎么改

上面的代码只适用于 ZYX 外旋。如果你需要 XYZ 或者其他顺序,不要试图改几个下标就完事,正确的做法是重新推导展开式,找到那个“只含一个未知角”的元素作为突破口。以 XYZ 外旋为例,$R = R_x(\gamma) R_y(\beta) R_z(\alpha)$,展开后 $R_{13} = s\beta$,于是 $\beta = \arcsin(R_{13})$,其余两个角从第一行和第二列的元素反解。套路完全一样,只是下标换了位置。

为了减少重复劳动,可以写一个通用函数,用查表的方式处理六种顺序。但我的建议是,常用的顺序就那两三种,各自写一个明确的函数,注释写清楚约定,比一个“万能函数”加一堆分支要可靠得多。万能函数看着优雅,但一旦出问题,排查成本高得多。

4. 实际项目里踩过的坑和排查思路

4.1 角度对不上:先查顺序再查内外旋

这是最高频的问题。上游给了一个矩阵,你按 ZYX 解出来,和对方声称的角度差了十万八千里。排查顺序应该是:先确认对方的旋转顺序,再确认内旋外旋,最后确认角度单位。我遇到过一次,对方文档写的是“XYZ 欧拉角”,实际实现是内旋 XYZ,等价于外旋 ZYX,而我按外旋 XYZ 去解,结果自然对不上。后来用单轴旋转矩阵做探针测试才定位到问题。

探针测试的方法很简单:构造一个只绕 X 轴转 30 度的矩阵,喂给你的转换函数,看输出是不是 $(30°, 0, 0)$ 或者对应的顺序。如果输出是 $(0, 0, 30°)$,说明顺序约定不同;如果符号反了,说明旋转方向定义不同。这个方法比读文档快,也比猜靠谱。

4.2 角度跳变:奇异点保护和连续性处理

即使避开了奇异点,$\arctan2$ 的输出在 $-\pi$ 和 $\pi$ 交界处也会跳变,比如从 $179°$ 突然变成 $-179°$。如果下游直接拿这个角度做插值或者微分,就会产生巨大的虚假角速度。处理办法是在输出层做角度解缠(unwrap),保证相邻帧的角度差不超过 $\pi$。具体做法是维护上一帧的角度,当前帧解出来之后,如果和上一帧的差超过 $\pi$,就加减 $2\pi$ 修正。

def unwrap_angle(current, previous): diff = current - previous while diff > np.pi: current -= 2 * np.pi diff = current - previous while diff < -np.pi: current += 2 * np.pi diff = current - previous return current

这个函数对三个角分别调用即可。注意它是有状态的,必须按时间顺序逐帧处理,不能并行。如果你的数据是乱序的,那就没法解缠,只能接受跳变或者在更上游用四元数表示。

4.3 数值精度:float32 和 float64 的差异

如果矩阵来自嵌入式设备或者某些图形库,很可能是 float32 精度。float32 的有效位数大约 7 位十进制,在奇异点附近,$R_{31}$ 接近 $\pm 1$ 时,$c\beta$ 的计算误差会被放大,导致解出的 roll 和 yaw 抖动明显。我的做法是在转换前统一转成 float64,虽然不能恢复已经丢失的精度,但至少不会在转换环节引入额外误差。如果抖动仍然严重,就得考虑在上游用更高精度存储,或者干脆改用四元数传递姿态。

4.4 常见问题速查表

现象可能原因排查方法解决方式
角度完全对不上旋转顺序不一致单轴旋转探针测试统一顺序约定
角度符号相反内旋/外旋定义不同检查复合矩阵连乘顺序调整连乘顺序或取逆
奇异点附近角度乱跳万向锁未处理检查 $|R_{31}|$ 是否接近 1加阈值保护,令一个角为零
相邻帧角度突变$\arctan2$ 值域跳变检查相邻帧角度差做角度解缠
输出 nan浮点误差导致 arcsin 越界检查 $R_{31}$ 是否略超 1加 clip 裁剪
精度不够、抖动大float32 精度不足检查输入数据类型转 float64 或改用四元数

这张表基本覆盖了我遇到过的绝大多数问题,实际排查时按顺序过一遍,大部分情况能快速定位。

5. 什么时候该用欧拉角,什么时候该绕开它

5.1 欧拉角的适用边界

欧拉角适合人机交互和最终展示,比如界面上显示“当前朝向:偏航 30 度、俯仰 5 度、横滚 0 度”,或者控制律里需要直观的角度反馈。它也适合小角度范围内的增量控制,因为小角度下三个轴近似解耦,线性化处理很方便。但只要涉及大角度旋转、连续插值、或者需要复合多次旋转,欧拉角就不太合适了,万向锁和角度跳变会让事情变得很麻烦。

我的经验是,内部姿态表示一律用四元数或旋转矩阵,欧拉角只在输入输出边界出现。输入时把用户给的角度转成矩阵或四元数,输出时再把内部表示转成角度给界面。这样整个链路的数值稳定性最好,也避免了在中间环节反复转换带来的精度损失和约定混乱。

5.2 从矩阵到四元数再到欧拉角

如果项目里已经有四元数的基础设施,从矩阵转欧拉角可以走“矩阵→四元数→欧拉角”的路径。四元数的转换公式是稳定的,没有奇异点,转成欧拉角时再处理万向锁,逻辑上更清晰。而且四元数转欧拉角的公式和矩阵转欧拉角的公式本质一致,只是中间多了一步,计算量增加不多,但代码的可维护性更好。

具体来说,矩阵转四元数用标准的 Shepperd 方法,选四个分量中绝对值最大的作为主分量,避免除以接近零的数。四元数转欧拉角则根据旋转顺序套对应的公式。这条路径我在几个项目里用过,实测在奇异点附近的连续性比直接矩阵转欧拉角要好,因为四元数本身是连续的,只在最后一步转角度时才需要处理奇异点。

5.3 一个容易被忽略的点:旋转矩阵的合法性

从外部拿到的矩阵不一定是严格正交的,可能有数值误差,甚至是转置或者带了缩放。直接拿去反解欧拉角,结果可能完全错误。稳妥的做法是先做正交化,用 SVD 分解把矩阵投影到最近的旋转矩阵上:$R_{ortho} = U V^T$,其中 $R = U \Sigma V^T$,并确保 $\det(R_{ortho}) = 1$。这一步在视觉标定和点云配准的输出上尤其必要,因为那些算法输出的矩阵经常有微小的非正交误差。

def orthonormalize(R): U, _, Vt = np.linalg.svd(R) R_ortho = U @ Vt if np.linalg.det(R_ortho) < 0: Vt[-1, :] *= -1 R_ortho = U @ Vt return R_ortho

这段代码不长,但能挡掉很多莫名其妙的错误。我现在的习惯是,任何外部输入的旋转矩阵,进转换函数之前先过一遍正交化,成本很低,收益很大。

6. 把这件事做扎实的几个习惯

6.1 约定写在代码里,不要只写在文档里

旋转顺序、内外旋、角度单位这三件事,一定要在函数名、参数名或者注释里写死。我见过太多项目,文档里写了一套,代码里实现了另一套,后来的人照着文档调用,结果全错。函数名里带上顺序,比如rotation_matrix_to_euler_zyx,参数名里带上单位,比如roll_rad,比任何文档都可靠。如果团队里有约定俗成的命名规范,就严格遵守;如果没有,就自己定一套并推广开。

6.2 测试用例覆盖奇异点和边界

单元测试不能只测常规角度,必须覆盖 $\beta = \pm \pi/2$ 附近、角度为 $0$ 和 $\pm \pi$ 的边界情况。测试的断言不要直接比角度,因为奇异点附近角度不唯一,要比重建矩阵的差异。另外,随机生成大量旋转矩阵做批量测试,统计最大重建误差,能发现一些手工构造用例覆盖不到的边界问题。我一般跑一万组随机测试,最大误差控制在 $10^{-10}$ 以内才算通过。

6.3 文档里写清楚奇异点的处理策略

如果你的函数在奇异点附近会强制令某个角为零,一定要在文档里写明,并且说明这样做的后果——重建矩阵一致,但角度不唯一。下游如果依赖角度的唯一性做逻辑判断,就会出问题。把策略写清楚,让调用方自己决定是否接受,比藏着掖着强。

6.4 性能敏感场景的取舍

如果转换在热路径上,比如每帧都要调用几千次,那就要考虑性能。np.arctan2和np.arcsin的开销不算大,但np.clip和奇异点判断的分支会带来一些开销。实测下来,单次转换在普通 CPU 上大约几微秒,对大多数应用足够。如果确实需要极致性能,可以预先算好三角函数表做查表插值,但精度会下降,得不偿失。我的建议是先用清晰正确的实现,性能不够再优化,不要一上来就为了省几微秒把代码写得难以维护。

6.5 跨语言移植时的注意事项

Python 的math.atan2和 C 的atan2行为一致,但有些语言或者库的atan2参数顺序是反的(比如某些老式实现是atan2(x, y)而不是atan2(y, x)),移植时一定要核对。另外,角度单位在不同语言的标准库里也不统一,有的用弧度,有的提供角度版本,混用是低级但高频的错误。移植之后跑一遍和原实现相同的测试用例,是最稳妥的验证方式。

7. 最后分享几个实操中的小技巧

调试姿态转换的时候,我习惯先打印矩阵的九个元素,肉眼扫一遍。如果第三行第一列明显超出 $[-1, 1]$,那肯定是矩阵本身有问题,先正交化再说。如果矩阵看起来正常但角度不对,就用单轴旋转探针测试,三十秒内能定位到是顺序问题还是内外旋问题。

还有一个技巧是,把解出来的欧拉角立刻转回矩阵,和原矩阵做差,看差矩阵的范数。如果范数很小但角度看起来“不对”,那多半是角度不唯一导致的,不是代码错了。这个自检步骤能省掉很多自我怀疑的时间。

另外,如果你的应用场景里 pitch 角不会接近 $\pm 90°$,比如地面移动机器人的俯仰通常很小,那奇异点处理可以简化,甚至不做保护也不会出问题。但代码里还是要留着保护逻辑,因为需求会变,今天不会接近奇异点,不代表明天不会。防御性编程在这个环节特别值得。

最后一点,如果你在团队里负责这块,把转换函数和测试用例一起提交,别只提交函数。后来的人改代码的时候,有测试兜底,才敢动。我见过太多因为缺少测试,导致一个“小改动”把整个姿态链路搞崩的案例。测试用例本身就是最好的文档。

返回列表