跳转至

02 姿态的四种表示

一句话定义:同一个"姿态"(载体相对导航系的三维朝向,数学上是 3 自由度旋转群 SO(3))至少有四种主流参数化方式——欧拉角、旋转矩阵(DCM)、四元数、旋转向量——它们彼此等价、可互相转换,但在奇点、参数个数、计算开销和插值友好度上各有权衡。惯导工程的常识是:内部存四元数、对外给欧拉角、做增量更新用旋转向量


一、先统一问题:姿态到底是什么(直觉)

上一讲(01 坐标系与变换)我们说惯导的本质是"不断在坐标系之间搬家"。但姿态本身是什么?

  • 姿态 = 载体坐标系(body)相对于导航系(nav,本项目用 NED)的朝向
  • 数学上,朝向就是一个三维旋转:把 nav 系的向量旋转到 body 系(或反过来)。所有三维旋转构成一个群,叫 SO(3)(Special Orthogonal group,行列式为 +1 的正交矩阵群)。
  • SO(3) 是 3 自由度的——你只需要 3 个数就能唯一确定任意姿态。

关键矛盾:既然是 3 自由度,为什么需要 4 种表示、最多的要用 9 个数?

类比:地球表面是 2 维流形,用经纬度(2 个数)描述。但经纬度在北极点会"奇点"——所有经线在那汇合,经度失去意义。也就是说,没有一种"全局无奇点、只用 2 个数"的方法能覆盖整个球面。姿态同理:SO(3) 没法用 3 个"全局无奇点"的参数完美覆盖,所以每种表示都牺牲点什么(有的有奇点,有的用冗余参数,有的有歧义)。

这就是为什么我们要认识全部四种——没有哪种是"万能"的,只能让它们各司其职


二、四种表示总览

姿态的四种表示及其关系

表示 参数数 约束 奇点/歧义 串联旋转(复合) 插值 主要优点 主要缺点
欧拉角 (yaw/pitch/roll) 3 万向锁(pitch=±90°) 需先转矩阵再乘 不直(会绕远) 直观、人能读 奇点 / 顺序依赖
旋转矩阵 DCM 9 (3×3) 6 个正交约束 矩阵乘 \(R=R_2R_1\) 需再正交化 无奇点 / 直接作用向量 冗余 / 必须反复正交化
四元数 \([w,x,y,z]\) 4 1 个范数约束 \(\|\boldsymbol q\|=1\) 四元数乘(比矩阵便宜) slerp 平滑 无奇点 / 数值稳 / 省 双覆盖(\(q\)\(-q\) 等价)
旋转向量 \(\boldsymbol\theta=\phi\,\boldsymbol u\) 3 \(\pm 2\pi\) 处歧义(需 wrap) 需先转四元数 小角近线性 最小参数 / 物理直观 / IMU 增量天然 大角需 wrap

一句话记忆:欧拉角给人看、旋转矩阵当桥梁、四元数存内部、旋转向量做增量。下面逐个拆开。


三、欧拉角(Euler Angles)

1. 定义与顺序

欧拉角把任意姿态拆成三次基本旋转的复合,最常用的是 Z-Y-X 顺序(也叫 yaw-pitch-roll / 偏航-俯仰-横滚):

  • yaw(偏航 \(\psi\):绕 nav 的竖直轴(Z)转 —— "车头朝哪"
  • pitch(俯仰 \(\theta\):绕机体横轴(Y)转 —— "抬头/低头"
  • roll(横滚 \(\phi\):绕机体纵轴(X)转 —— "左翼上扬/右翼上扬"

⚠️ 顺序极其重要:Z-Y-X 和 Y-X-Z 给出的数字完全不同,同一个物理姿态用不同约定得到的 (ψ,θ,φ) 数值不一样。本项目统一 body→NED 的 Z-Y-X,与 01 的旋转矩阵、以及代码 quat_to_euler 完全一致。

2. 复合旋转矩阵

Z-Y-X 组合出来的旋转矩阵,本项目统一用被动旋转约定(与 坐标变换与旋转矩阵(推导入门) 第一章一致,也是课本主流写法):

  • nav→body(推导入门已推导):\(C_n^b = R_x(\phi)\,R_y(\theta)\,R_z(\psi)\),矩阵从右往左读,最先发生的旋转(Z)放在最右。
  • body→nav(本篇 §四 DCM 段记为 \(R_{nb}\),与这里的 \(C_b^n\) 是同一物体、两种记法):旋转矩阵正交,直接取转置即可: \(C_b^n = (C_n^b)^T = R_z(-\psi)\,R_y(-\theta)\,R_x(-\phi)\)。即"原路倒着转、角度取负"——相对 \(C_n^b\) 顺序反过来(Z 到了最左),正是 推导入门 第五节的 \(C_b^n\)。两种写法数值完全相等(被动正角 \(R^{pass}(\alpha)\) 与主动形式 \(R^{act}(\alpha)\) 互为转置,也等于 \(R^{pass}(-\alpha)\)),只是约定不同,不要混用。

下面三个被动单轴矩阵(与推导入门第一章完全相同,直接复用,不再重列推导):

\[ R_x(\phi)=\begin{pmatrix}1&0&0\\0&\cos\phi&\sin\phi\\0&-\sin\phi&\cos\phi\end{pmatrix},\; R_y(\theta)=\begin{pmatrix}\cos\theta&0&-\sin\theta\\0&1&0\\\sin\theta&0&\cos\theta\end{pmatrix},\; R_z(\psi)=\begin{pmatrix}\cos\psi&\sin\psi&0\\-\sin\psi&\cos\psi&0\\0&0&1\end{pmatrix} \]

把上面三个被动单轴矩阵按 \(R_z(-\psi)\,R_y(-\theta)\,R_x(-\phi)\) 直接乘开,得到完整姿态矩阵(body→nav,本篇最常用形式):

\[ C_b^n= \begin{bmatrix} c_\theta c_\psi & s_\phi s_\theta c_\psi - c_\phi s_\psi & c_\phi s_\theta c_\psi + s_\phi s_\psi \\ c_\theta s_\psi & s_\phi s_\theta s_\psi + c_\phi c_\psi & c_\phi s_\theta s_\psi - s_\phi c_\psi \\ -s_\theta & s_\phi c_\theta & c_\phi c_\theta \end{bmatrix} \]

此矩阵与 推导入门 第五节逐元素一致,可直接当参考表用。

从矩阵元素反解欧拉角(这 9 个格子不是摆设,直接就能读出角度):把上面 \(C_b^n\) 的元素代进去即可反向解出三个欧拉角——其中 \(C_b^n(i,j)\) 指第 \(i\) 行、第 \(j\) 列(行 = nav 的 N/E/D,列 = body 的 x/y/z)。已数值验证与矩阵在任意姿态下逐元素自洽,前提 \(|\theta|<90^\circ\)

\[ \begin{aligned} \psi &= \operatorname{atan2}\!\big(C_b^n(2,1),\; C_b^n(1,1)\big) \\ \theta &= -\operatorname{asin}\!\big(C_b^n(3,1)\big) \\ \phi &= \operatorname{atan2}\!\big(C_b^n(3,2),\; C_b^n(3,3)\big) \end{aligned} \]

💡 这正是下方折叠块里“四元数->欧拉角”公式的矩阵版:两者是同一件事——折叠块用四元数分量 \(q\) 写,这里用矩阵格子写。当 \(|\theta|\to90^\circ\)(万向锁)时 \(C_b^n(1,1),C_b^n(2,1)\to0\),yaw 的数值解会退化,此时四元数无奇点、更稳。

📐 折叠:从四元数取欧拉角的显式公式(本项目代码正是这个)

若手头是四元数 \(\boldsymbol q=(q_0,q_1,q_2,q_3)=(w,x,y,z)\)(Hamilton 序),也可直接写出姿态矩阵(已数值验证与上表在任意姿态下逐元素相等):

\[ C_b^n(\boldsymbol q)= \begin{bmatrix} 1-2(q_2^2+q_3^2) & 2(q_1q_2-q_0q_3) & 2(q_1q_3+q_0q_2) \\ 2(q_1q_2+q_0q_3) & 1-2(q_1^2+q_3^2) & 2(q_2q_3-q_0q_1) \\ 2(q_1q_3-q_0q_2) & 2(q_2q_3+q_0q_1) & 1-2(q_1^2+q_2^2) \end{bmatrix} \]

⚠️ 此式与本项目固件 q2r / quat_to_rotmat 的输出方向(body→NED)一致;若你用的库是 \([x,y,z,w]\) 序或 ENU 系,符号会整体翻转——务必以代码实际输出为准

本项目 ins_math.hquat_to_eulerins_eskf_15d.ceskf15_get_euler 用的是 body→NED(Z-Y-X) 的解析反解(前提:\(q=[q_0,q_1,q_2,q_3]=[w,x,y,z]\),且已单位化):

\[ \begin{aligned} \text{yaw} &= \operatorname{atan2}\!\big(2(q_1 q_2 + q_0 q_3),\; 1 - 2(q_2^2+q_3^2)\big) \\ \text{pitch} &= -\operatorname{asin}\!\big(2(q_1 q_3 - q_0 q_2)\big) \\ \text{roll} &= \operatorname{atan2}\!\big(2(q_2 q_3 + q_0 q_1),\; 1 - 2(q_1^2+q_2^2)\big) \end{aligned} \]

⚠️ 务必以固件代码为准(此处公式与 ins_math.h::quat_to_euler 逐字一致):注意三个分子的加/减号——yaw 是 \(2(q_1q_2+q_0q_3)\)、pitch 是 \(2(q_1q_3-q_0q_2)\)、roll 是 \(2(q_2q_3+q_0q_1)\)别把 \(q_0\) 交叉项的符号写反(早期版本这里错过,会算出错误的欧拉角)。

注意 pitch 前面的负号:这是 NED(z 向下)约定带来的,ENU 里符号会反过来。所以取欧拉角必须和你的旋转矩阵约定绑死,绝不能拿一个库的"通用公式"硬套。

3. 万向锁(Gimbal Lock)—— 欧拉角的死穴

pitch = ±90°(机头正上/正下)时,俯仰环把"横滚轴(X)"转到了和"偏航轴(Z)"共线的位置——两个本该独立的旋转变成了一个,系统丢失一个自由度。此时你无论怎么调 yaw 还是 roll,效果都一样,姿态输出会跳变/失控。

欧拉角万向锁演示

这不是数学笔误,是欧拉角参数化本身的拓扑缺陷——球面坐标在极点必然奇点,无法避免。所以飞控/惯导绝不会在内部用欧拉角做积分,只在最后一步"翻译"给人看。

🔍 折叠:万向锁进阶 —— 到底「锁死哪个角」?为何算法内部一定用四元数?(根据豆包笔记改写)

① 到底"锁死哪个角"?先看规则,再看角名(这是最容易踩的坑)

万向锁的几何规则与角名无关,只跟"哪个轴是第 2 次(中间)转动"有关:

规则:三个角按顺序做三次转动,当"第 2 次转的那根轴"恰好转过 ±90° 时,第 1、第 3 次的转轴在空间里被搬到同一根直线 → 两个角塌缩成一个自由度。 所以"锁死"总是发生在 中间那次转动 的角度 = ±90° 时。

那为什么网上有的说"Z-Y-X 锁 roll"、我们却写"pitch"?—— 因为不同资料把同一个物理旋转起了不同的角名。角名(yaw/pitch/roll)绑定到 body 哪根轴,取决于坐标系约定,并不统一:

转动次序 绕哪个 body 轴 本项目(body→NED, 见 §一)叫它 某类"航空 XYZ"资料可能叫它
第 1 次 绕导航竖直轴 Z yaw ψ yaw
第 2 次(中间) 绕 body 横轴(Y) pitch θ 有时把绕 Y 叫 "roll"
第 3 次 绕 body 纵轴(X) roll φ 有时把绕 X 叫 "pitch"

⚠️ 网上那张流传很广的"ZYX→锁 roll / ZXY→锁 pitch / XYZ→锁 yaw"表格,是在它自己的角名定义下成立的;它那句"中间轴 Y/X/Y"其实已经点破了本质——被锁的永远是"中间那次转动"。你只要死记"中间转 ±90° 就锁",并核对自家约定里"中间那个角叫什么"即可,不必背那张表。

对本项目:中间转轴 = body 横轴 Y = pitch,所以万向锁就在 pitch = ±90°(机头竖直上/下),与上面正文、图、以及 q2att 完全一致。

② 换一种转动顺序,只是把奇点挪个位置,躲不掉

既然万向锁由"中间轴转 ±90°"引起,那换个顺序(把不容易到 ±90° 的轴放中间)只是把死角挪到别处,并不能消灭它——这是"用 3 个角度描述 3D 旋转"的拓扑必然,任何 3 参数表示都躲不掉(旋转矢量在转角 \(0/\pm2\pi\) 处也有歧义,见 §六)。所以工程上"选顺序"通常只是把最不可能到 ±90° 的那个角放到中间:普通飞机俯仰/横滚很少到 ±90°,Z-Y-X 即可;特技/导弹才需要考虑别的约定。本项目固定 Z-Y-X(body→NED),不做这件事。

③ 欧拉角的姿态微分方程:为何"除以 cos(中间角)"会爆

想用欧拉角做递推(由角速度 \(\boldsymbol\omega\) 求姿态角速度)时,会出现:

\[ \begin{bmatrix}\dot\psi\\\dot\theta\\\dot\phi\end{bmatrix} = \frac{1}{\cos\theta} \begin{bmatrix} \cos\theta & \sin\phi\sin\theta & \cos\phi\sin\theta\\ 0 & \cos\phi\cos\theta & -\sin\phi\cos\theta\\ 0 & \sin\phi & \cos\phi \end{bmatrix} \begin{bmatrix}\omega_x\\\omega_y\\\omega_z\end{bmatrix} \]

注意最左那个 \(\frac{1}{\cos\theta}\)——这里的 θ 指"中间那个角"(本项目就是 pitch)。当 pitch→±90°,\(\cos\theta\to0\),右边矩阵虽不爆、但这个整体分母会让 \(\dot\theta\) 附近的 \(\dot\psi,\dot\phi\) 数值爆炸。这就是万向锁在算法递推层面的直接后果。因为这条,本项目绝不用欧拉角做姿态积分——eskf15_predict 每帧都是 角增量 → rv2q → qmul 右乘(见 §八),全程四元数,完全绕开 \(\cos\theta\) 分母。

④ 行业标准做法(与 §四-4、§八 一致)

  • 算法内部:姿态状态永远存四元数(或旋转矩阵),无奇点、好积分 → 本项目 nom.q[4]
  • 输出给人看:只在需要时用 quat_to_euler 把四元数翻译成 yaw/pitch/roll → 本项目 eskf15_get_euler
  • 控制逻辑:若外围控制真的要用欧拉角,需加奇点保护(接近 ±90° 时切换策略或用四元数直接控制)——本项目 ESKF 误差态用失准角、名义态用四元数,天然无此顾虑。

⑤ 亲手验一次"塌缩"(配 Mini-INS,在你机器上跑)

pitch 越接近 90°,"改 pitch"和"改 yaw"给的姿态差异就越小 → 这就是自由度塌缩的数字证据:

addpath(genpath('docs/惯性导航/assets/miniins'));
% 靠近奇异点 pitch≈90°:pitch +2° 和 yaw +2° 几乎同一姿态
C1 = euler2cnb([(89.9+2)*pi/180; 0; 0]);   % miniins: att=[pitch;roll;yaw]
C2 = euler2cnb([89.9*pi/180; 0; 2*pi/180]);
fprintf('靠近90° 两姿态差 = %.3e\n', norm(C1-C2));   % → 很小(约 1e-3)
% 远离奇异点 pitch≈10°:同样 +2° 差异明显
C3 = euler2cnb([(10+2)*pi/180; 0; 0]);
C4 = euler2cnb([10*pi/180; 0; 2*pi/180]);
fprintf('远离90° 两姿态差 = %.3f\n', norm(C3-C4));   % → 明显大(约 0.05)

跑完你会发现:靠近 ±90° 时两个"不同"的输入给几乎相同的姿态——飞控若在这里用欧拉角做 PID,控制器会困惑"该调 pitch 还是 yaw"。

若还有疑问可以查看:

【武汉大学惯性导航课程合集【2021年秋】】 【精准空降到 08:42】

【无伤理解欧拉角中的“万向死锁”现象】


四、旋转矩阵 / 方向余弦矩阵(DCM)

1. 定义

一个 \(3\times3\) 正交矩阵 \(R\in\mathrm{SO}(3)\),满足:

\(R^TR = I,\qquad \det R = +1\)

它把 body 系向量旋到 nav 系(或反之,看下标约定)。9 个数里只有 3 个是独立的——其余 6 个被正交约束吃掉了。

\(\mathrm{SO}(3)\) 这两个条件分别在说什么?(理解了这个,DCM 就不用背)

  • \(R^TR=I\) ⇒ 三个列向量单位长且互相垂直 ⇒ 这样的矩阵只能做两件事:旋转镜像
  • \(\det R=+1\) ⇒ 把镜像排除掉,所以 \(\mathrm{SO}(3)\) 里的元素只能是旋转。 (\(\det=-1\) 的正交矩阵是镜像 / 反射,手性翻转了,它不是姿态。别只看正交性就下结论。)

那"旋转"又是怎么被编码进 9 个数的?看列就够了:

\(R\) 的三个列向量为 \(c_1,\ c_2,\ c_3\),则

\[R\,v = a\,c_1 + b\,c_2 + c\,c_3\]

\(e_1=[1,0,0]^T\) 喂进去(此时 \(a=1,\ b=c=0\)),套上面那句定义,结果正好是 \(c_1\)第 1 列 = 旧 X 轴在新系下的坐标,第 2、3 列同理。所以"9 个数里只有 3 个独立"的几何含义就是:姿态只有 3 个自由度,而它们其实就是三个旧轴各自在新系里怎么读。

本篇是被动旋转,别用主动画面去读列

本篇(同推导入门第一章)统一用被动旋转:坐标系转、向量不动。所以列的正确读法是 「旧轴在新系里怎么读」,而不是「旧轴被搬到哪」——后者是主动旋转的画面,矩阵差一个转置。 两套说法的并列对比见 坐标变换与旋转矩阵 · 0.4b 岔路口

一句话点破

矩阵是一张照片,不是一个动作 —— 它拍的是"转完之后坐标轴长什么样"。

不是矩阵被"赋予"了旋转含义,而是你用三个落点把它填满之后,它自然就只能干旋转这一件事。这也解释了为什么四元数、旋转向量、欧拉角都能转成同一个 \(R\):它们只是描述同一个"落点"的不同参数化。

👉 完整三步推导见 坐标变换与旋转矩阵 · 第零节;可拖动的演示见 旋转矩阵三步推导 · 交互演示

2. 性质(务必记住)

  • 串联 = 矩阵乘法\(R_{C\leftarrow A} = R_{C\leftarrow B}\,R_{B\leftarrow A}\)。多个姿态复合不用"转回欧拉角再算",直接乘。
  • 逆 = 转置\(R^{-1}=R^T\)(因为正交)。所以 body→nav 是 \(R_{nb}\),nav→body 是 \(R_{bn}=R_{nb}^T\)
  • 直接作用向量\(\boldsymbol v_{\text{nav}} = R_{nb}\,\boldsymbol v_{\text{body}}\),不用任何三角函数。

3. 优点与代价

  • 无奇点,且左乘即可旋转向量,是"底层真理"表达。
  • 9 个数只装 3 自由度:数值积分后 \(R\) 会慢慢偏离正交(浮点漂移),必须周期性正交化/重投影,否则越积越歪。
  • ❌ 插值不自然(两矩阵之间线性插值得不到合法旋转)。

工程里 DCM 主要当换算枢纽:四元数↔欧拉角、雅可比矩阵求导,往往都要先经 \(R\) 中转。本项目 quat_to_rotmat / q2r 就是干这个的。


五、四元数(Quaternion)

1. 定义与几何意义

单位四元数 \(\boldsymbol q = (q_0, q_1, q_2, q_3) = (w, x, y, z)\) 表示"绕单位轴 \(\boldsymbol u\) 转角度 \(\phi\)":

\(\boldsymbol q = \big(\cos\tfrac{\phi}{2},\; \boldsymbol u\sin\tfrac{\phi}{2}\big)\)

也就是说四元数把"旋转轴"和"半角"编码进 4 个数里。本项目约定 Hamilton 乘积、\([w,x,y,z]\) 顺序(注意:ROS/TensorFlow 等部分库用 \([x,y,z,w]\) 的 JPL 序,混用是最常见的移植 bug,见第九节)。

2. 为什么四元数是工程首选

维度 四元数 旋转矩阵
参数 4(仅 1 个范数约束) 9(6 个约束)
奇点
串联 四元数乘(16 次乘+12 次加) 矩阵乘(27 次乘+18 次加)
数值稳定性 只需偶尔归一化 需正交化
插值 slerp 平滑、恒定角速度

结论:四元数比旋转矩阵省一半存储和计算,又无欧拉角的奇点,所以几乎所有实时惯导都在内部用四元数存姿态。

3. 双覆盖(Double Cover)—— 四元数的唯一"坑"

四元数对旋转的映射是 2 对 1\(\boldsymbol q\)\(-\boldsymbol q\) 表示同一个三维旋转(转 \(\phi\)\(\boldsymbol u\),与转 \(2\pi-\phi\)\(-\boldsymbol u\) 等价)。

四元数双覆盖:q 与 -q 表示同一旋转

📐 折叠:四元数乘法(Hamilton) 与 旋转向量→四元数

Hamilton 乘法(本项目 quat_multiply / qmul):

\[ \boldsymbol q_1\otimes\boldsymbol q_2 = \begin{pmatrix} w_1w_2 - \boldsymbol v_1\!\cdot\!\boldsymbol v_2 \\ w_1\boldsymbol v_2 + w_2\boldsymbol v_1 + \boldsymbol v_1\times\boldsymbol v_2 \end{pmatrix} \]

代码实现(核对 ins_math.h):

float w = q1[0]*q2[0] - q1[1]*q2[1] - q1[2]*q2[2] - q1[3]*q2[3];
float x = q1[0]*q2[1] + q1[1]*q2[0] + q1[2]*q2[3] - q1[3]*q2[2];
float y = q1[0]*q2[2] - q1[1]*q2[3] + q1[2]*q2[0] + q1[3]*q2[1];
float z = q1[0]*q2[3] + q1[1]*q2[2] - q1[2]*q2[1] + q1[3]*q2[0];

旋转向量 → 四元数增量(本项目 rotvec_to_quat_delta / rv2q姿态更新的核心):

设旋转向量 \(\boldsymbol\delta\boldsymbol\theta = (\delta\theta_x,\delta\theta_y,\delta\theta_z)\)\(\alpha=\|\boldsymbol\delta\boldsymbol\theta\|\),则

\[ \boldsymbol dq = \Big(\cos\tfrac{\alpha}{2},\; \frac{\sin(\alpha/2)}{\alpha}\,\boldsymbol\delta\boldsymbol\theta\Big) \]

小角度时 \(\boldsymbol dq \approx (1,\; \tfrac12\boldsymbol\delta\boldsymbol\theta)\)——这正是陀螺角增量 \(\boldsymbol\omega\Delta t\) 直接"升级"成四元数增量的公式(见第六、八节)。

🔬 PSINS 演示 ①:旋转向量 → 四元数(rv2q)

本系列「PSINS 演示」统一三件套:① PSINS 参考代码(带署名)→ ② C 语言实现对照 → ③ 结论。 参考来源:严龚敏《PSINS》(西北工业大学),保留版权、要求引用。 本页共 4 个面板:① rv2q(§五/§六 姿态增量)② qmul(§五 四元数乘法)③ q2mat(§四 DCM)④ q2att(§三 欧拉角,⚠️ 差异警示)。

① PSINS 参考实现(MATLAB)

% PSINS: base0/rv2q.m — Gongmin Yan, NWPU (psins260705)
function q = rv2q(rv)
    n2 = rv(1)^2 + rv(2)^2 + rv(3)^2;
    if n2 < 1.0e-8                      % 小角泰勒展开
        q(1) = 1 - n2*(1/8 - n2/384);  s = 1/2 - n2*(1/48 - n2/3840);
    else
        n = sqrt(n2); q(1) = cos(n/2); s = sin(n/2)/n;
    end
    q(2:4) = s * rv;

② C 语言实现(本项目 ins_eskf_15d.c :: rv2q

static void rv2q(const float dt[3], float dq[4])
{
    float a = sqrtf(dt[0]*dt[0] + dt[1]*dt[1] + dt[2]*dt[2]), h = 0.5f * a;
    if (a < 1e-8f) { dq[0]=1; dq[1]=dq[2]=dq[3]=0; return; }
    float s = sinf(h) / a;            /* = sin(n/2)/n */
    dq[0] = cosf(h);
    dq[1] = s*dt[0]; dq[2] = s*dt[1]; dq[3] = s*dt[2];
    qnorm(dq);
}

③ 结论

逐字等价:令 n = a = ‖dt‖,则 h = n/2s = sin(n/2)/n——与 PSINS 主分支完全一致。 小角处理上 PSINS 用泰勒展开提高精度,本项目在 a<1e-8 时直接退化为单位四元数(此时 dt≈0dq≈(1,0),等价且更简单)。 这说明本项目底层姿态数学与经过大量工程验证的 PSINS 完全对齐——我们不是在"自己拍脑袋",而是站在经典参考的肩膀上。


🔬 PSINS 演示 ②:四元数乘法(qmul)

对应正文 §五-2:四元数乘法是姿态串联(\(q \leftarrow q \otimes dq\))的引擎,属于纯代数——它只跟 Hamilton 约定有关,与坐标系(ENU/NED)无关,所以可以直接逐项对照。

① PSINS 参考实现(MATLAB)

% PSINS: base0/qmul.m — Gongmin Yan, NWPU (psins260705)
function q = qmul(q1, q2)      % q = q1 ⊗ q2(Hamilton 乘积,标量在首)
    q = [ q1(1)*q2(1) - q1(2)*q2(2) - q1(3)*q2(3) - q1(4)*q2(4);
          q1(1)*q2(2) + q1(2)*q2(1) + q1(3)*q2(4) - q1(4)*q2(3);
          q1(1)*q2(3) + q1(3)*q2(1) + q1(4)*q2(2) - q1(2)*q2(4);
          q1(1)*q2(4) + q1(4)*q2(1) + q1(2)*q2(3) - q1(3)*q2(2) ];

② C 语言实现(本项目 ins_eskf_15d.c :: qmul

static void qmul(float o[4], const float q1[4], const float q2[4])
{
    float w = q1[0]*q2[0] - q1[1]*q2[1] - q1[2]*q2[2] - q1[3]*q2[3];
    float x = q1[0]*q2[1] + q1[1]*q2[0] + q1[2]*q2[3] - q1[3]*q2[2];
    float y = q1[0]*q2[2] - q1[1]*q2[3] + q1[2]*q2[0] + q1[3]*q2[1];
    float z = q1[0]*q2[3] + q1[1]*q2[2] - q1[2]*q2[1] + q1[3]*q2[0];
    o[0] = w; o[1] = x; o[2] = y; o[3] = z;
}

③ 结论

逐项一致:四项 Hamilton 乘积公式完全对应(qmul.m 与 C 的 qmul 逐字等价)。这也再次确认存储序兼容——PSINS 标量在首 [q0,q1,q2,q3] 与本项目 [w,x,y,z] 同构,直接互通。


🔬 PSINS 演示 ③:四元数 → 旋转矩阵(q2mat / q2r)

对应正文 §四-3:DCM 当"换算枢纽",雅可比矩阵求导常经它中转。⚠️ 注意方向语义:PSINS 的 q2mat 返回 Cnb = nav→body(从导航系到机体系),本项目的 q2r 也是 world→body(= NED→body)——两边方向一致,公式才能逐项对照。

① PSINS 参考实现(MATLAB)

% PSINS: base0/q2mat.m — Gongmin Yan, NWPU (psins260705)
function Cnb = q2mat(qnb)     % Cnb = nav→body 的 DCM(行向量写法)
    q11=qnb(1)*qnb(1); q12=qnb(1)*qnb(2); q13=qnb(1)*qnb(3); q14=qnb(1)*qnb(4);
    q22=qnb(2)*qnb(2); q23=qnb(2)*qnb(3); q24=qnb(2)*qnb(4);
    q33=qnb(3)*qnb(3); q34=qnb(3)*qnb(4); q44=qnb(4)*qnb(4);
    Cnb = [ q11+q22-q33-q44,  2*(q23-q14),     2*(q24+q13);
            2*(q23+q14),      q11-q22+q33-q44, 2*(q34-q12);
            2*(q24-q13),      2*(q34+q12),     q11-q22-q33+q44 ];

② C 语言实现(本项目 ins_eskf_15d.c :: q2r,行主序)

/* world->body (= NED->body = 参考 R.') */
static void q2r(const float q[4], float R[9])
{
    float xx=q[1]*q[1], yy=q[2]*q[2], zz=q[3]*q[3];
    float xy=q[1]*q[2], xz=q[1]*q[3], yz=q[2]*q[3];
    float wx=q[0]*q[1], wy=q[0]*q[2], wz=q[0]*q[3];
    R[0]=1-2*(yy+zz); R[1]=2*(xy+wz); R[2]=2*(xz-wy);
    R[3]=2*(xy-wz);   R[4]=1-2*(xx+zz); R[5]=2*(yz+wx);
    R[6]=2*(xz+wy);   R[7]=2*(yz-wx);  R[8]=1-2*(xx+yy);
}

③ 结论

逐项一致:9 个元素一一对应(MATLAB 行序 vs C 行主序正好对齐)。而且两边的语义都是 nav→bodyCnb / world→body)——公式本身不依赖 ENU/NED 的轴向选择,只要"导航系→机体系"的方向约定一致就能直接对照。


🔬 补齐:旋转矩阵 → 四元数(m2q / Shepperd trace 法)

上面 demo ③ 讲了"四元数→矩阵"(q2r),但反向(DCM→四元数)在固件里同样常用——align_triad 用 TRIAD 求出旋转矩阵后,就要用 m2q 把它落成四元数。本项目的 m2q(q2r(q)) = ±q,二者严格互逆。下面补齐这一方向。

① 标准 Shepperd / trace 法(四分支,数值稳定)

\(R\)(行主序 \(R[i\cdot3+j]\))反解,先算迹 \(\operatorname{tr}=R_{00}+R_{11}+R_{22}\),再按"迹与最大对角元"四选一开方——永远挑最大量开方、避免小分母放大误差

\[ \begin{aligned} \operatorname{tr}>0 &: s=2\sqrt{\operatorname{tr}+1},\; w=\tfrac14 s,\; x=\tfrac{R_{21}-R_{12}}{s},\; y=\tfrac{R_{02}-R_{20}}{s},\; z=\tfrac{R_{10}-R_{01}}{s} \\ R_{00}\text{最大} &: s=2\sqrt{1+R_{00}-R_{11}-R_{22}},\; x=\tfrac14 s,\; y=\tfrac{R_{01}+R_{10}}{s},\; z=\tfrac{R_{02}+R_{20}}{s},\; w=\tfrac{R_{21}-R_{12}}{s} \\ R_{11}\text{最大} &: s=2\sqrt{1+R_{11}-R_{00}-R_{22}},\; y=\tfrac14 s,\; z=\tfrac{R_{12}+R_{21}}{s},\; w=\tfrac{R_{02}-R_{20}}{s},\; x=\tfrac{R_{01}+R_{10}}{s} \\ R_{22}\text{最大} &: s=2\sqrt{1+R_{22}-R_{00}-R_{11}},\; z=\tfrac14 s,\; w=\tfrac{R_{10}-R_{01}}{s},\; x=\tfrac{R_{02}+R_{20}}{s},\; y=\tfrac{R_{12}+R_{21}}{s} \end{aligned} \]

⚠️ 只写 \(\operatorname{tr}>0\) 一支会在接近 180° 旋转时算飞(根号里变负 / 分母塌缩)。四分支就是为数值稳定,别省。

② 本固件的约定扭曲(关键):m2q 必须"先解标准公式、再取共轭"

本项目 q2r 输出的不是教科书标准 \(R\),而是 \(R\) 的转置q2r(q)=R_{\text{std}}(q)^T$,已在 demo ③ 注明 world→body)。为让m2q成为它的严格逆,m2q的做法是:先按"标准公式"对输入 $R$ 解出v(实则是真 $q$ 的**共轭** $(w,-x,-y,-z)$),再对v取一次共轭q=(v_0,-v_1,-v_2,-v_3)翻回固件约定。于是m2q(q2r(q))=±q` 恒成立。

③ C 语言实现(本项目 ins_align.c :: m2q

static void m2q(const float R[9], float q[4])
{
    float v[4];
    float tr = R[0] + R[4] + R[8];
    if (tr > 0.0f) {
        float s = 2.0f * sqrtf(tr + 1.0f);
        v[0] = 0.25f * s; v[1] = (R[7]-R[5])/s; v[2] = (R[2]-R[6])/s; v[3] = (R[3]-R[1])/s;
    } else if (R[0] > R[4] && R[0] > R[8]) {
        float s = 2.0f * sqrtf(1.0f + R[0] - R[4] - R[8]);
        v[0] = (R[7]-R[5])/s; v[1] = 0.25f*s; v[2] = (R[1]+R[3])/s; v[3] = (R[2]+R[6])/s;
    } else if (R[4] > R[8]) {
        float s = 2.0f * sqrtf(1.0f + R[4] - R[0] - R[8]);
        v[0] = (R[2]-R[6])/s; v[1] = (R[1]+R[3])/s; v[2] = 0.25f*s; v[3] = (R[5]+R[7])/s;
    } else {
        float s = 2.0f * sqrtf(1.0f + R[8] - R[0] - R[4]);
        v[0] = (R[3]-R[1])/s; v[1] = (R[2]+R[6])/s; v[2] = (R[5]+R[7])/s; v[3] = 0.25f*s;
    }
    q[0] =  v[0]; q[1] = -v[1]; q[2] = -v[2]; q[3] = -v[3];   /* 取共轭还原固件约定 */
    float n = sqrtf(q[0]*q[0] + q[1]*q[1] + q[2]*q[2] + q[3]*q[3]);
    if (n > 1e-8f) { n = 1.0f/n; q[0]*=n; q[1]*=n; q[2]*=n; q[3]*=n; }
}

④ 结论 / 验证

✅ 本项目 verify/ins_align/test_m2q.c 已跑通双向验证:Demo1 m2q(q2r(q0))==±q0(YES);Demo2 喂"标准 90° 绕 Z"矩阵得共轭(逆旋转)、q2r 还原(YES)。所以 DCM↔四元数在本固件里是严格互逆的一对,只是因 q2r 的转置约定,m2q 内部多取了一次共轭。

💡 呼应 §五-3 / §九:双覆盖意味着"互逆"只保证 ±——比较 m2q 输出时永远比 ±,不能直接 ==;slerp/求差前先判点积符号。


🔬 PSINS 演示 ④:四元数 → 欧拉角(q2att / quat_to_euler)⚠️ 差异警示

对应正文 §三-2 的折叠。这一对不能直接对照——PSINS 用 ENU(x 东、y 北、z 天),本项目用 NED(x 北、y 东、z 地),姿态角顺序与符号约定都不同。差异正是坐标系约定的具象化。

① PSINS 参考实现(MATLAB,ENU 约定)

% PSINS: base0/q2att.m — Gongmin Yan, NWPU (psins260705)
% 输出 att = [pitch; roll; yaw](注意顺序!)—— ENU: x东 y北 z天
function att = q2att(qnb)
    q11=qnb(1)*qnb(1); q12=qnb(1)*qnb(2); q13=qnb(1)*qnb(3); q14=qnb(1)*qnb(4);
    q22=qnb(2)*qnb(2); q23=qnb(2)*qnb(3); q24=qnb(2)*qnb(4);
    q33=qnb(3)*qnb(3); q34=qnb(3)*qnb(4); q44=qnb(4)*qnb(4);
    C12=2*(q23-q14); C22=q11-q22+q33-q44;
    C31=2*(q24-q13); C32=2*(q34+q12); C33=q11-q22-q33+q44;
    if C32>0.999999                     % 万向锁: pitch=±90°
        C11=q11+q22-q33-q44; C13=2*(q24+q13);
        att=[pi/2; atan2(C13,C11); 0];
    elseif C32<-0.999999
        C11=q11+q22-q33-q44; C13=2*(q24+q13);
        att=[-pi/2; atan2(C13,C11); 0];
    else
        att = [ asin(C32);
                atan2(-C31, C33);
                atan2(-C12, C22) ];
    end

② C 语言实现(本项目 ins_math.h :: quat_to_euler,NED 约定)

/* 四元数 -> 欧拉角 (Yaw, Pitch, Roll)。约定 FRD 机体系 -> NED 导航系。 */
static inline void quat_to_euler(const float q[4], float *yaw, float *pitch, float *roll)
{
    float q0=q[0], q1=q[1], q2=q[2], q3=q[3];
    if (yaw)   *yaw   = atan2f(2.0f*(q1*q2 + q0*q3), 1.0f - 2.0f*(q2*q2 + q3*q3));
    if (pitch) *pitch = -asinf(2.0f*(q1*q3 - q0*q2));          /* NED: pitch 带负号 */
    if (roll)  *roll  = atan2f(2.0f*(q2*q3 + q0*q1), 1.0f - 2.0f*(q1*q1 + q2*q2));
}

③ 结论

⚠️ 结构同构、约定不同——公式不能互换: - 输出顺序:PSINS 给 [pitch; roll; yaw],本项目给 (yaw, pitch, roll)。 - 轴向语义:ENU 与 NED 的 z 轴一上一下,pitch 的符号、部分 atan2 的分子差个负号;我们的 pitch 显式带 - 号就是 NED 约定的结果(代码注释也写了)。 - 正确姿势:各用各的取角函数,不要拿 PSINS 的 q2att 公式替换固件的 quat_to_euler——同一物理姿态下两者给出的数字不同(除非恰好对称)。这也解释了 01 坐标系与变换 里为什么坐标系必须先定死。


4. 工程常识:内部永远存四元数,只在输出时转欧拉角

把姿态在滤波器里永远存成四元数nom.q),需要给人/给其他模块看时才用 quat_to_euler 转。原因已经清楚了:四元数无奇点、好积分;欧拉角只适合"最后一步翻译"。本项目 eskf15_get_euler 内部就一行 quat_to_euler(e->nom.q, y, p, r)


六、旋转向量 / 角轴(Axis-Angle)

1. 定义

用一个三维向量表示旋转:方向 = 旋转轴 \(\boldsymbol u\),长度 = 旋转角 \(\phi\)

\(\boldsymbol\theta = \phi\,\boldsymbol u \quad (\|\boldsymbol u\|=1)\)

只有 3 个参数(最小参数化),而且物理直觉极强——"绕某个轴转了多少度"。

2. 为什么它适合"增量更新"

IMU 每帧给出的就是角增量 \(\Delta\boldsymbol\theta \approx \boldsymbol\omega\,\Delta t\)(陀螺输出直接就是旋转向量!)。把它升级成四元数增量 dq = rv2q(Δθ) 再右乘到姿态上,就是姿态积分最自然的做法:

\(\boldsymbol q_{k+1} = \boldsymbol q_k \otimes \boldsymbol{dq}(\Delta\boldsymbol\theta)\)

本项目 ins_eskf_15d.ceskf15_predict 正是这么干的(见第八节代码走读)。

3. 缺点:大角度歧义

  • \(\phi\) 和转 \(\phi+2\pi\) 是同一个姿态,但向量长度差 \(2\pi\)——所以旋转向量在 \(\pm\pi\)(或 \(\pm2\pi\))处不连续,需要 wrap。
  • 不适合做"绝对姿态"存储(超过半圈就乱),但做小增量完美无瑕,所以它是"更新步"的最佳载体,而非"状态存储"的载体。

七、Bortz 方程与"不可交换误差"(coning)

这一段是进阶。想深入姿态更新算法(本系列 07 篇)前,先在这里埋个种子。

一个朴素的想法:既然 \(\Delta\boldsymbol\theta=\boldsymbol\omega\Delta t\),那一直累加 \(\boldsymbol q \leftarrow \boldsymbol q\otimes\text{rv2q}(\boldsymbol\omega_k\Delta t)\) 不就行了?在角速度恒定或变化很慢时是对的。但当载体做圆锥运动(coning,高频小幅振动+转动耦合)时,旋转在三维里不可交换——先转 A 再转 B,和先转 B 再转 A,结果不同。忽略这一项会累积出"不可交换误差"。

Bortz 一阶方程给出旋转向量随时间演化的精确形式:

\(\dot{\boldsymbol\phi} = \boldsymbol\omega + \frac12\boldsymbol\phi\times\boldsymbol\omega + \frac{1}{12}\boldsymbol\phi\times(\boldsymbol\phi\times\boldsymbol\omega) + \cdots\)

  • 第一项 \(\boldsymbol\omega\):就是朴素累加(零阶)。
  • 第二项 \(\tfrac12\boldsymbol\phi\times\boldsymbol\omega\)叉乘修正项,正是 coning 误差的来源——它在高频振动下不可忽略。
📐 折叠:为什么 MEMS 板常用单子样+rv2q 也够用

Bortz 修正项的大小正比于角振动幅值×频率。对本项目这种 1000 Hz 采样 + MEMS 量级的载体,单个采样间隔内 \(\Delta t\) 极小,\(\boldsymbol\phi\times\boldsymbol\omega\) 的二阶项远小于测量噪声,因此每帧用 rv2q(ωΔt) 右乘已是工程上足够精确的"一阶姿态更新"。

真正需要多子样 coning 补偿的,是高动态、低频高幅振动(如导弹、某些战术级 IMU)。那部分会在 07 篇结合 Savage / 毕查德算法展开;现在你只需记住:旋转不可交换 → 简单累加有误差 → 高动态要补偿


八、各司其职:本项目实战里的姿态表示

把上面四种放回我们这块板(AHRS-Board,1000 Hz ESKF):

角色 用什么表示 对应代码 为什么
滤波内部状态 四元数 \(\boldsymbol q\) e->nom.q[4] 无奇点、好积分、省算力
对外输出 / UI / 日志 欧拉角(yaw/pitch/roll) eskf15_get_euler()quat_to_euler() 人能读、调试直观
每帧姿态增量 旋转向量 \(\Delta\boldsymbol\theta\) rv2q(φ, dq) + qmul(q, q, dq) 陀螺角增量天然是旋转向量,右乘即更新
换算枢纽 / 雅可比 旋转矩阵 \(R\) quat_to_rotmat() / q2r() 量测模型(accel/mag)需要 \(R\) 把向量投到 nav 系

代码走读(ins_eskf_15d.c::eskf15_predict

/* 1) 用积分前的姿态算 R(线性化点,量测更新复用) */
q2r(e->nom.q, Rm);          /* world->body = R.' */
m3t(Rm, Rt);                 /* body->NED = R     */

/* 2) 陀螺去零偏 -> 角增量(旋转向量) -> 四元数增量 -> 右乘更新 */
float phi[3] = { ou[0]*dt, ou[1]*dt, ou[2]*dt };  /* Δθ = ω·dt (旋转向量) */
rv2q(phi, dq);                                  /* 旋转向量 -> 四元数增量 */
qmul(e->nom.q, e->nom.q, dq);                   /* q = q ⊗ dq (右乘) */
qnorm(e->nom.q);                                /* 保持单位四元数 */

注意 qmul(e->nom.q, e->nom.q, dq)右乘——因为角增量是在 body 系测得的,要右乘到当前姿态上。这正是第五、六节说的"旋转向量→四元数增量→右乘"。

另一个真实踩过的坑(eskf15_get_euler 注释):取欧拉角必须用 quat_to_euler\(q\) 直接解,绝不能"先用 q2r 得到 \(R\) 再反推欧拉角"——因为本项目里 q2r 输出的是 \(R^T\)(world→body),用它会算成逆旋转的姿态,yaw/pitch/roll 全错。这条坑已经写进代码注释,看到 q2rquat_to_rotmat 的区别要警觉。


九、常见坑(把你未来会栽的提前标出来)

姿态表示经典翻车清单

- 万向锁:pitch 接近 ±90° 时欧拉角输出跳变。对策——内部绝不用欧拉角积分,只在输出时转换;靠近极点时用四元数/slerp。 - 四元数双覆盖符号q-q 等价。slerp / 比较 / 求差前先算点积,若 \(\boldsymbol q\cdot\boldsymbol q'<0\)\(-\boldsymbol q'\),否则插值会"走远路"、姿态瞬跳。 - 存储序 \([w,x,y,z]\) vs \([x,y,z,w]\) 混用:本项目 Hamilton 用前者;ROS(geometry_msgs)、部分神经网络用后者。移植/对接外部库时第一件事就是确认顺序,差一位全错。 - 欧拉角顺序:Z-Y-X 与 Y-X-Z 数值不同。对接 COTS 模块(如 MTi)必须核对其 datasheet 的顺序与基准系。 - q2r 反推欧拉角:本项目 q2r 给的是 \(R^T\),硬推会得到逆旋转——务必走 quat_to_euler。 - 旋转矩阵忘记正交化:长时间积分后 \(R\) 漂移失正,必须周期重投影(如通过四元数重建 \(R\))。 - 旋转向量 \(\pm\pi\) 跳变:当作"绝对姿态"存储时会在半圈处不连续,需要 wrap;它只适合做增量。


十、自测题(合上眼睛能答,才算懂)

  1. 为什么没有一种"只用 3 个数、全局无奇点"的姿态表示?(用经纬度类比回答)
  2. 欧拉角 Z-Y-X 的 pitch=90° 时发生了什么?为什么叫"丢失一个自由度"?
  3. 旋转矩阵串联为什么是乘法 \(R=R_2R_1\) 而不是加法?它和四元数乘法比,贵在哪?
  4. 四元数 \(\boldsymbol q\)\(-\boldsymbol q\) 是什么关系?工程中为什么要"先判符号再 slerp"?
  5. 本项目里姿态在滤波器内部存成什么?对外输出成什么?每帧增量是什么?三者如何衔接(写出那行 qmul 右乘的含义)?
  6. 为什么说"陀螺角增量天然是旋转向量"?旋转向量的主要缺点是什么,为什么它仍适合做增量更新?

答案都藏在上面。第 1、3、5 题答不清,建议回看 01 坐标系与变换数学地基:矩阵、概率与协方差 的旋转矩阵小节。


关联与延伸

外链 Wiki(随时回补)