【自动驾驶与机器人控制】几何学和运动学中,四元数q.normalize() 到底干了什么?——修复模长 ≠ 修复角度误差

四元数用来表示旋转时,必须是单位四元数 ,也就是它的模长(范数)必须等于 1。

∣ q ∣ = w 2 + x 2 + y 2 + z 2 = 1 |q|=\sqrt{w^2+x^2+y^2+z^2}=1 ∣q∣=w2+x2+y2+z2 =1

1. normalize () 的作用:只修正「模长」

q.normalize() 的数学操作:把四元数的四个分量,全部除以它自身的模长。

效果:强制让新四元数的模长 = 1。仅此而已。

举个例子:

假设运算之后四元数变成: q = 1.02 , ; 0.11 , ; 0.11 , ; 0.11 q=1.02,;0.11,;0.11,;0.11 q=1.02,;0.11,;0.11,;0.11

它的模长 ∣ q ∣ > 1 |q|>1 ∣q∣>1,不再是单位四元数,已经不能正确代表旋转。

调用normalize(),把四个数整体缩放,让模重新回到 1。

✅它做的:整体缩放四个数字,保证模 = 1

❌它不会修改四个分量之间的相对比例关系!也就是角度信息不会被修正。

2. 为什么会出现模长偏离 1?

代码里这行:

cpp 复制代码
Quatd q = pose.unit_quaternion() * Quatd(1, 0.5*omega[0]*dt,0.5*omega[1]*dt,0.5*omega[2]*dt);

增量四元数我们直接写的是 Quatd(1, 0.5ωdt)

这个增量 1 , 1 2 ω Δ t 1,\\frac12\\omega\\Delta t 1,21ωΔt 本身就不是严格单位四元数!

计算它的模长:

只要角速度不为 0,这个增量四元数模就大于 1。

每次乘法之后,四元数模长会慢慢偏离 1,数值上发生 "膨胀"。

👉如果不做normalize(),模长越来越大,彻底失去旋转的物理含义。

所以必须调用normalize()把模强行拉回 1,解决数值膨胀问题

3. 重点:为什么 normalize 不能消除角度的截断误差?

角度误差来自哪里?

我们用的是一阶近似公式:

q k + 1 ≈ q k ⊗ 1 , 1 2 ω Δ t q_{k+1}\approx q_k \otimes \left1,\\frac12\\boldsymbol\\omega\\Delta t\\right qk+1≈qk⊗1,21ωΔt

真实完整的增量四元数应该是:

Δ q = cos ⁡ θ 2 , ; n sin ⁡ θ 2 \Delta q=\left\\cos\\frac{\\theta}{2},; n\\sin\\frac{\\theta}{2}\\right Δq=cos2θ,;nsin2θ

一阶近似是把 cos ⁡ θ 2 ≈ 1 \cos\frac{\theta}{2}\approx1 cos2θ≈1, sin ⁡ θ 2 ≈ θ 2 \sin\frac{\theta}{2}\approx\frac{\theta}{2} sin2θ≈2θ。

这一步就已经产生角度上的近似偏差,四个分量本身的相对比例就已经错了。

normalize()只是把这一组已经比例错误的四个数字整体缩放

缩放只会全部同乘一个系数,不会改变 w,x,y,z 互相之间的比例

比例错了 → 代表的旋转角度依然是错的!缩放救不了这个错误。

通俗比喻帮助理解

假设真实正确四元数应该是:w=0.98,x=0.14,y=0,z=0(对应某个旋转角度)

一阶近似算出来得到一组错误比例:w=1.00,x=0.15,y=0,z=0

这组数字本身比例就不对,代表的旋转角度已经偏了。

normalize()只会把这四个数统一除以模长,把模变成 1,但w:x的比值依旧是错的,角度依旧不对。

👉normalize 修复的是「大小(模)」,修复不了「内部比例(角度)」,这一点是非常重要的,也是非常容易混淆的。

4. 结合 motion.cpp 实验现象

--use_quaternion=true,角速度很大:

  1. 每一步一阶近似带来角度截断误差;

  2. q.normalize()保证模长 = 1,不会出现数值爆炸;

  3. 但是每一步角度都有一点点错,误差不断累积;

  4. 最后的现象:轨迹螺旋漂移,不能闭合圆环。

即便每一步都 normalize,漂移依旧存在!

5. 最后归纳

  1. 单位四元数表示旋转要求: ∣ q ∣ = 1 |q|=1 ∣q∣=1;normalize()将四元数所有分量同除以模长,强制使模长等于 1,解决数值运算带来的模长漂移。

  2. normalize()只是对四元数整体做缩放,不会改变 w,x,y,z 之间的相对比例

  3. 一阶泰勒近似产生的是分量比例上的角度误差 ,缩放无法修正该误差;所以即使调用normalize(),大增量下姿态依旧会漂移。

6. 工程启示

  • 四元数一阶近似只适合单步旋转角度很小(高频 IMU,dt 很小)的场景;

  • 如果单步旋转角度大,不要用 1 , 1 2 ω Δ t 1,\\frac12\\omega\\Delta t 1,21ωΔt 近似,要用完整指数映射计算正确的增量四元数。

7.为什么表示旋转的时候必须是单位四元数呢?

四元数旋转公式:

p ′ = q p q − 1 p' = qpq^{-1} p′=qpq−1

p p p 是三维点写成的纯虚四元数 0 , x , y , z T 0,x,y,z^T 0,x,y,zT, q q q 用来做旋转。

  1. 回顾四元数的逆

四元数求逆公式:

q − 1 = q ∗ ∣ q ∣ 2 q^{-1}=\frac{q^*}{|q|^2} q−1=∣q∣2q∗

  • q ∗ q^* q∗ 是共轭(虚部取反)

  • ∣ q ∣ |q| ∣q∣ 是四元数模长。

情况 1: q q q 是单位四元数, ∣ q ∣ = 1 |q|=1 ∣q∣=1

此时 ∣ q ∣ 2 = 1 |q|^2=1 ∣q∣2=1,所以:

q − 1 = q ∗ q^{-1}=q^* q−1=q∗

旋转公式简化成大家常见写法:

p ′ = q p q ∗ p'=qpq^* p′=qpq∗

这个运算只会改变向量方向,不改变向量长度 ,正好对应「旋转」的物理含义:旋转不伸缩物体,只改变朝向

情况 2:如果模长 ∣ q ∣ ≠ 1 |q| \neq 1 ∣q∣=1(大于 1 或者小于 1)

把 q q q 拆成:

q = s ⋅ q u q = s \cdot q_u q=s⋅qu

  • s = ∣ q ∣ s=|q| s=∣q∣ 是模长(标量缩放系数)

  • q u q_u qu 是单位四元数( ∣ q u ∣ = 1 |q_u|=1 ∣qu∣=1,代表真正的旋转)

代入旋转公式 p ′ = q p q − 1 p'=q p q^{-1} p′=qpq−1:

👉看上去标量 s 好像抵消了?

但是!上面这个是数学理论;代码里我们不是这么干的!


⚠关键:代码中的坑(Eigen/sad 库现实行为)

在 C++ Eigen 中,Eigen::Quaterniond 并没有自动帮你带上 q p q − 1 q p q^{-1} qpq−1 的完整运算

Eigen 重载的operator*做旋转,内部默认假设输入是单位四元数 ,直接执行:

p ′ = q p q ∗ p' = qpq^* p′=qpq∗

而不是 q p q − 1 q p q^{-1} qpq−1!

当 ∣ q ∣ = s ≠ 1 |q|=s \neq1 ∣q∣=s=1,代码实际执行:

结果多乘了 s 2 s^2 s2!

  • 如果 ∣ q ∣ > 1 |q|>1 ∣q∣>1, s > 1 s>1 s>1, s 2 > 1 s^2>1 s2>1:向量被放大,发生拉伸

  • 如果 ∣ q ∣ < 1 |q|<1 ∣q∣<1, s < 1 s<1 s<1, s 2 < 1 s^2<1 s2<1:向量被缩小,发生压缩

旋转的定义:只改变方向,不改变矢量长度。

一旦模长不等于 1,使用库的旋转接口,会附带缩放效果 ,不再是纯旋转!

这就是工程上,表达旋转必须强制单位四元数的根本原因。

举数字例子

真实旋转:点 p = ( 1 , 0 , 0 ) p=(1,0,0) p=(1,0,0),绕 Z 轴转 90°,正确结果 ( 0 , 1 , 0 ) (0,1,0) (0,1,0),长度还是 1。

现在取一个非单位四元数

q = 1.1 ⋅ q u q=1.1\cdot q_u q=1.1⋅qu,模长 s = 1.1 > 1 s=1.1>1 s=1.1>1。

用 Eigen q * p做旋转,输出结果会是 ( 0 , 1.21 , 0 ) (0, 1.21, 0) (0,1.21,0)。

方向对了,但是长度变成 1.21,被放大了!这已经不是旋转,是「旋转 + 放大」。


  1. 回到 motion.cpp 代码场景
cpp 复制代码
Quatd q = pose.unit_quaternion() * Quatd(1, 0.5*omega[0]*dt,0.5*omega[1]*dt,0.5*omega[2]*dt);
// 这里算出来的q模长>1,如果不normalize!
pose.so3() = SO3(q);

SO3(q)内部会拿这个四元数去构造旋转矩阵。

非单位四元数构造旋转矩阵,得到的矩阵不再是正交矩阵 , R T R ≠ I R^TR\neq I RTR=I。

  • 正交矩阵性质:旋转矩阵必须满足 R T R = I , det ⁡ ( R ) = 1 R^TR=I,\det(R)=1 RTR=I,det(R)=1;

  • 四元数模长偏离 1 → 生成的矩阵丧失正交性;

  • 后续做坐标变换 v w = R w b v b v_w=R_{wb}v_b vw=Rwbvb,向量会被莫名其妙放大 / 缩小,仿真直接崩坏。

👉所以代码必须调用q.normalize(),强制 ∣ q ∣ = 1 |q|=1 ∣q∣=1,保证从四元数生成的矩阵是合法旋转矩阵。

  1. 区分两件完全不同的事情(非常容易混淆)

  2. normalize () 处理的:模长(缩放因子 s)

    解决:四元数乘法带来的数值膨胀,保证 ∣ q ∣ = 1 |q|=1 ∣q∣=1,保证矩阵正交,消除附带缩放。

    ✔能修好。

  3. 一阶近似带来的:角度误差(w,x,y,z 的相对比例错了)

    来源:把 cos ⁡ θ 2 ≈ 1 , sin ⁡ θ 2 ≈ θ 2 \cos\frac{\theta}{2}\approx1,\sin\frac{\theta}{2}\approx\frac{\theta}{2} cos2θ≈1,sin2θ≈2θ,近似导致四个分量之间比例不对。

    normalize()只是整体缩放所有分量,不会改变互相之间比例,修不了角度偏差

比喻:

一张照片,①图片整体放大(模长问题);②图片里面人物五官比例扭曲(角度截断误差)。

normalize()只能把整张图缩放到原始大小,修复不了五官扭曲

  1. 总结

  2. 数学理论上,完整旋转公式 p ′ = q p q − 1 p'=q p q^{-1} p′=qpq−1,模长会被抵消,任意非零四元数都可以表达同一个旋转。

  3. 但是工程库 (Eigen、sad) 的四元数旋转接口默认直接使用 p ′ = q p q ∗ p'=q p q^* p′=qpq∗,省略除以 ∣ q ∣ 2 |q|^2 ∣q∣2**。**

    • 如果 ∣ q ∣ ≠ 1 |q|\neq1 ∣q∣=1,运算结果会额外乘上 ∣ q ∣ 2 |q|^2 ∣q∣2,产生向量缩放,不再是纯旋转。
  4. 用四元数构造旋转矩阵时,只有单位四元数,才能输出满足 R T R = I R^TR=I RTR=I 的正交旋转矩阵;模长大于或者小于 1,生成矩阵丧失正交性,坐标变换会附带拉伸 / 压缩。

  5. normalize()把四元数全部分量同除以自身模长,强制 ∣ q ∣ = 1 |q|=1 ∣q∣=1,消除数值累积带来的缩放问题;但它只做整体缩放,不能修复一阶近似带来的分量比例(角度)误差

  6. 反思

问:为什么表示旋转的四元数要求必须是单位四元数?

答:

理论上完整公式 p ′ = q p q − 1 p'=qpq^{-1} p′=qpq−1 会抵消模长;但工程上 Eigen 等库简化为 p ′ = q p q ∗ p'=qpq^* p′=qpq∗。

如果四元数模长不等于 1,旋转运算会引入额外缩放,不再是纯旋转。同时只有单位四元数,转换得到的旋转矩阵才满足正交矩阵条件 R T R = I R^TR=I RTR=I。

normalize 只能修正模长,不能修正近似带来的角度误差。

8.代码与四元数一阶近似公式一一对应拆解

公式:

q k + 1 ≈ q k ⊗ Δ q , Δ q = 1 1 2 ω Δ t \boldsymbol{q}_{k+1} \approx q_k \otimes \Delta q,\quad \Delta q=\begin{bmatrix} \boldsymbol 1 \\ \dfrac12\boldsymbol\omega\Delta t \end{bmatrix} qk+1≈qk⊗Δq,Δq=121ωΔt

四元数存储格式:Quatd(w, x, y, z)

  • 第 1 个参数:实部 w

  • 第 2、3、4 参数:虚部 x , y , z x,y,z x,y,z,对应向量部分 1 2 ω Δ t = 1 2 ω x Δ t , ; ω y Δ t , ; ω z Δ t \frac12\boldsymbol\omega\Delta t=\frac12\\omega_x\\Delta t,;\\omega_y\\Delta t,;\\omega_z\\Delta t 21ωΔt=21ωxΔt,;ωyΔt,;ωzΔt

代码

cpp 复制代码
Quatd q = pose.unit_quaternion() * Quatd(1, 0.5*omega[0]*dt,0.5*omega[1]*dt,0.5*omega[2]*dt);

把代码拆成两部分:

  1. pose.unit_quaternion() → q k \boldsymbol q_k qk,上一时刻旧的四元数

  2. Quatd(1, 0.5*omega[0]*dt,0.5*omega[1]*dt,0.5*omega[2]*dt) → 增量四元数 Δ q \boldsymbol{\Delta q} Δq

  3. * :Eigen 的Quaterniond重载的*运算符,代表**四元数乘法 ** ⊗ \otimes ⊗


逐项分量对齐

Δ q = ; w = 1 , x = 1 2 ω x Δ t , y = 1 2 ω y Δ t , z = 1 2 ω z Δ t ; \Delta q=\Big;w=1,\\quad x=\\frac12\\omega_x\\Delta t,\\quad y=\\frac12\\omega_y\\Delta t,\\quad z=\\frac12\\omega_z\\Delta t;\\Big Δq=;w=1,x=21ωxΔt,y=21ωyΔt,z=21ωzΔt;

公式符号 代码里面的值 说明
Δ q . w \Delta q.w Δq.w 1 增量四元数实部,一阶近似令 cos ⁡ ( θ / 2 ) ≈ 1 \cos(\theta/2)\approx 1 cos(θ/2)≈1
Δ q . x \Delta q.x Δq.x 0.5 * omega[0] * dt 虚部 x 分量 1 2 ω x Δ t \frac12\omega_x \Delta t 21ωxΔt
Δ q . y \Delta q.y Δq.y 0.5 * omega[1] * dt 虚部 y 分量 1 2 ω y Δ t \frac12\omega_y \Delta t 21ωyΔt
Δ q . z \Delta q.z Δq.z 0.5 * omega[2] * dt 虚部 z 分量 1 2 ω z Δ t \frac12\omega_z \Delta t 21ωzΔt

然后四元数相乘:

q k + 1 ; = ; q k ; ⊗ ; Δ q q_{k+1} ;=; q_k ;\otimes; \Delta q qk+1;=;qk;⊗;Δq

对应代码:

cpp 复制代码
q = 旧四元数  *  增量四元数;

⚠注意:这里的*不是普通标量乘法,是四元数乘法 ,Eigen 库内部已经重载实现四元数的 ⊗ \otimes ⊗运算。

得到相乘结果q之后:

cpp 复制代码
q.normalize();
pose.so3() = SO3(q);
  1. q.normalize():把相乘之后的四元数缩放为单位四元数;

  2. SO3(q):用更新后的四元数构造旋转,赋值给 pose 的旋转部分,得到 q k + 1 q_{k+1} qk+1。

你对不上的最常见 3 个坑

坑 1:分不清四元数构造顺序 Quatd(w, x, y, z)

很多人误以为是Quatd(x,y,z,w),把实部放最后,那就完全错位。

Eigen / 本书 sad 库:第一个参数是实部 w!

Quatd(1, a,b,c) → w = 1 , ; x = a , ; y = b , ; z = c w=1,;x=a,;y=b,;z=c w=1,;x=a,;y=b,;z=c,正好匹配 Δ q = 1 , 1 2 ω Δ t \Delta q=1,\\frac12\\omega\\Delta t Δq=1,21ωΔt

坑 2:把*当成普通数字相乘

cpp 复制代码
A * B

如果 A、B 是普通 double,就是标量乘;

如果 A、B 是Quatd对象,operator*被重载,执行**四元数乘法 ** ⊗ \otimes ⊗,不是简单逐项相乘。

坑 3:数学公式简写 1 , 1 2 ω Δ t \left1,\\frac12\\omega\\Delta t\\right 1,21ωΔt 格式含义

Δ q = 1 , 1 2 ω Δ t \Delta q=\left1,\\frac12\\boldsymbol\\omega\\Delta t\\right Δq=1,21ωΔt

这个简写格式含义:

  • 第一个元素:实部 w = 1 w=1 w=1

  • 后面矢量部分:虚部向量 1 2 ω Δ t \frac12\boldsymbol\omega\Delta t 21ωΔt

    对应 C++ 构造函数入参顺序:Quatd(w, x, y, z),一一对应。

完整展开公式‑代码对照

离散一阶近似(来自四元数微分方程 q ˙ = 1 2 q 0 , ω T \dot q=\frac12 q0,\\omega^T q˙=21q0,ωT)

cpp 复制代码
// q_k:上一时刻姿态四元数
Quatd q_k = pose.unit_quaternion();
// 增量Δq = [1, 0.5ωₓdt ,0.5ωᵧdt ,0.5ω_z dt ]
Quatd delta_q(1,
        0.5*omega[0]*dt,
        0.5*omega[1]*dt,
        0.5*omega[2]*dt);
// 四元数乘法 q_{k+1}= q_k ⊗ Δq
Quatd q_k1 = q_k * delta_q;
q_k1.normalize();          // 强制单位四元数,修复模长漂移
pose.so3() = SO3(q_k1);    // 更新姿态

上面这段等价于原来一行代码,拆开写更容易看懂对应关系。

补充:这个近似到底舍弃了什么?

真实完整增量四元数(精确):

Δ q r e a l = cos ⁡ θ 2 , ; n sin ⁡ θ 2 \Delta q_{real}=\left\\cos\\frac{\\theta}{2},; \\boldsymbol n\\sin\\frac{\\theta}{2}\\right Δqreal=cos2θ,;nsin2θ

一阶近似做两个近似:

当 θ = ∣ ω Δ t ∣ \theta=|\omega\Delta t| θ=∣ωΔt∣ 比较大,这两个等式偏差变大,引入角度截断误差

normalize()只缩放整体,不能修正这部分近似。

相关推荐
xwz小王子1 小时前
机器人的“最后一毫米”: 新加坡南洋理工大学Facet-0如何教会基础模型“感受”自己的动作?
大数据·人工智能·机器人
硅谷秋水2 小时前
JEPA-WAM:基于联合嵌入世界建模的VLA策略学习
人工智能·机器学习·计算机视觉·语言模型·机器人
Zixhy5 小时前
多点巡检机器人项目技术总结
linux·c++·机器人·自动驾驶
小程序设计5 小时前
机械设计光伏电池板清洁机器人
机器人
沫儿笙7 小时前
安川机器人氩弧焊省气装置
机器人·自动化
m0_7393128710 小时前
【自动驾驶和机器人控制】万向锁(Gimbal Lock)原理详解 + 和欧拉角 / 四元数 / SO(3)对比
算法·机器人·自动驾驶
小程序设计11 小时前
机械设计光伏板智能清洁机器人结构设计-
机器人·机械设计
m0_7393128711 小时前
四元数、李群SO(3)/李代数so(3)的作用及应用场景
算法·机器人·自动驾驶
m0_7393128712 小时前
【自动驾驶/机器人控制】之坐标系&运动学仿真代码工程分析(一)
人工智能·机器人·自动驾驶