3. 数学基础回顾(二):旋转矩阵、欧拉角、四元数、相互转换与插值

好,我们继续。上一章聊完了位置和速度,这一章咱们得把「姿态」这块硬骨头啃下来。

说实话,我刚入行那会儿,觉得旋转不就是转个角度嘛,有啥难的?结果第一次做机械臂的轨迹规划,手臂在奇异点附近直接飞了——嗯,从那以后我再也不敢小看姿态表示了。

这一章,我会把旋转矩阵、欧拉角、四元数这三个东西讲透。它们各自有什么脾气,怎么互相转换,以及做插值时该选谁。你想想看,如果连姿态都描述不清楚,轨迹规划就是空中楼阁。

3.1 旋转矩阵:最直观,但也最啰嗦

旋转矩阵,说白了就是一个 3x3 的矩阵。它把一个向量从坐标系 A 映射到坐标系 B。

它的好处是啥?直观。你一眼就能看出三个轴分别指向哪。而且组合多个旋转时,直接矩阵乘法就行,非常规整。

核心性质:

  • 正交矩阵:R^T * R = I,逆矩阵就是转置。
  • 行列式为 +1(纯旋转,不含反射)。
  • 9 个元素,但只有 3 个自由度——说白了,冗余信息太多。

我在项目中遇到过一个问题:用旋转矩阵做插值。比如从姿态 A 平滑过渡到姿态 B,直接对矩阵的 9 个元素做线性插值?结果得到的矩阵根本不是正交矩阵,姿态全变形了。所以,旋转矩阵适合做「计算」,不适合做「插值」。

3.2 欧拉角:人类友好,但小心万向锁

欧拉角用三个角度来描述旋转:绕 X 轴转(滚转 Roll)、绕 Y 轴转(俯仰 Pitch)、绕 Z 轴转(偏航 Yaw)。

听起来很符合直觉,对吧?我刚开始做无人机飞控时,用的就是欧拉角。调试时看着角度数值变化,心里特别踏实。

避坑指南:万向锁(Gimbal Lock)

我曾经在一次机械臂的路径规划中,遇到了俯仰角接近 ±90° 的情况。结果滚转和偏航突然「耦合」了,丢失了一个自由度。飞机在空中乱转,机械臂在奇异点附近抖动——这就是万向锁。

为什么会这样?因为欧拉角的旋转顺序是固定的(比如 ZYX),当第二个旋转角达到 ±90° 时,第一个和第三个旋转轴会重合,自由度从 3 降为 2。

欧拉角的另一个问题是:同样的姿态,可以用多组不同的欧拉角表示。这就导致插值时路径不唯一,甚至会出现「绕远路」的情况。所以,我个人习惯:只在人机交互界面用欧拉角,算法内部尽量不用。

3.3 四元数:计算界的瑞士军刀

四元数,听起来很玄乎,其实就是一个四维向量:q = w + xi + yj + zk,其中 w 是实部,(x, y, z) 是虚部。

它没有万向锁,插值平滑,计算效率高。现代机器人、游戏引擎、航天器姿态控制,几乎都在用四元数。

我的经验:

刚开始学四元数时,我也觉得抽象。后来我把它理解为「绕一个单位轴 u 旋转 θ 角度」:

q = [cos(θ/2), u*sin(θ/2)]

这么一想,是不是就清晰多了?

四元数的乘法不满足交换律,这点要注意。两个四元数 q1 和 q2 的乘积,表示先应用 q2 再应用 q1 的复合旋转。

3.4 三种表示的对比

特性 旋转矩阵 欧拉角 四元数
自由度 9(冗余) 3 4(单位约束)
万向锁 无 有 无
插值平滑性 差 差 好(球面插值)
计算效率 中等 高 高
人类可读性 差 好 差
组合旋转 矩阵乘法 角度加减(小心) 四元数乘法

3.5 相互转换:代码实现

下面给出最常用的转换函数。我建议你直接收藏这段代码,项目里经常要用。

import numpy as np

def rotation_matrix_to_euler(R):
    """旋转矩阵 -> 欧拉角 (ZYX顺序)"""
    sy = np.sqrt(R[0,0]**2 + R[1,0]**2)
    singular = sy < 1e-6
    if not singular:
        x = np.arctan2(R[2,1], R[2,2])
        y = np.arctan2(-R[2,0], sy)
        z = np.arctan2(R[1,0], R[0,0])
    else:
        x = np.arctan2(-R[1,2], R[1,1])
        y = np.arctan2(-R[2,0], sy)
        z = 0
    return np.array([x, y, z])

def euler_to_quaternion(roll, pitch, yaw):
    """欧拉角 -> 四元数 (ZYX顺序)"""
    cr = np.cos(roll * 0.5)
    sr = np.sin(roll * 0.5)
    cp = np.cos(pitch * 0.5)
    sp = np.sin(pitch * 0.5)
    cy = np.cos(yaw * 0.5)
    sy = np.sin(yaw * 0.5)
    w = cr * cp * cy + sr * sp * sy
    x = sr * cp * cy - cr * sp * sy
    y = cr * sp * cy + sr * cp * sy
    z = cr * cp * sy - sr * sp * cy
    return np.array([w, x, y, z])

def quaternion_to_rotation_matrix(q):
    """四元数 -> 旋转矩阵"""
    w, x, y, z = q
    R = np.array([
        [1 - 2*y*y - 2*z*z, 2*x*y - 2*w*z, 2*x*z + 2*w*y],
        [2*x*y + 2*w*z, 1 - 2*x*x - 2*z*z, 2*y*z - 2*w*x],
        [2*x*z - 2*w*y, 2*y*z + 2*w*x, 1 - 2*x*x - 2*y*y]
    ])
    return R

注意:欧拉角的转换结果依赖于旋转顺序。上面代码用的是 ZYX 顺序(先绕 Z,再绕 Y,最后绕 X)。如果你的系统用的是其他顺序(比如 XYZ),公式需要相应调整。我曾经因为没注意这个细节,调试了整整两天。

3.6 姿态插值:为什么四元数是王者

做轨迹规划时,经常需要让机器人从姿态 A 平滑运动到姿态 B。这时候,插值方法就很重要了。

  • 对欧拉角做线性插值:简单,但路径可能不自然,而且会触发万向锁。
  • 对旋转矩阵做线性插值:结果矩阵不正交,姿态变形。
  • 对四元数做球面线性插值(Slerp):路径最短、最平滑,而且保证单位四元数。

Slerp 的公式如下:

def slerp(q1, q2, t):
    """四元数球面线性插值, t in [0, 1]"""
    dot = np.dot(q1, q2)
    # 如果点积为负,取反以保证最短路径
    if dot < 0:
        q2 = -q2
        dot = -dot
    # 防止除零
    dot = np.clip(dot, -1, 1)
    theta = np.arccos(dot)
    if theta < 1e-6:
        return q1  # 角度太小,直接返回
    sin_theta = np.sin(theta)
    return (np.sin((1-t)*theta) / sin_theta) * q1 + (np.sin(t*theta) / sin_theta) * q2

小技巧:插值前检查两个四元数的点积。如果点积为负,说明它们在四维球面上走的是「远路」。把其中一个取反,就能保证走最短路径。这个坑我踩过,现在每次写 Slerp 都会加上这个判断。

3.7 知识体系总览

下面这张图,把三种姿态表示的关系、转换路径和应用场景串起来了。你可以把它当作本章的「导航图」。

姿态表示知识体系 旋转矩阵 9元素,正交约束 欧拉角 3角度,有万向锁 四元数 4元素,无奇点 组合旋转 / 坐标变换 人机交互 / 调试显示 姿态插值 / 滤波 / 优化 核心建议 算法内部用四元数,人机交互用欧拉角, 需要组合旋转时用旋转矩阵,转换时注意顺序和奇点。 Slerp 插值

嗯,这一章的内容就到这里。旋转矩阵、欧拉角、四元数,这三兄弟各有各的脾气。你只要记住:计算用四元数,显示用欧拉角,组合用旋转矩阵,就能避开大部分坑。