工具运动平滑,日志里的四元数却突然全部变号;欧拉角从 179° 变成 −179°,有人以为发生了 358° 的跳跃。旋转的表示可能不连续,而实际姿态并没有跳变。把表示空间里的数值差直接当成物理运动,是位姿处理中的常见错误。
**阅读范围:**本文是三维视觉与医学影像软件工程系列的独立技术教程。数据和数值实验均为合成示例,不读取患者数据,不代表实际设备性能或临床精度。文中长度按示例明确的单位解释。资料核对日期:2026 年 10 月 16 日。

图 1:四元数数值变化,不一定意味着物理姿态跳变(原创教学配图)
一、先约定分量顺序与主动旋转
单位四元数可以写成 q=(x,y,z,w),也有系统使用 (w,x,y,z)。SciPy 默认采用标量在后的顺序。本篇所有四元数均为 xyzw,角度输入显式注明 degrees,旋转向量使用弧度。外部数据必须先核对协议,不能因为数组长度为四就直接传入。
主动旋转表示把向量旋转到新方向;被动变换描述坐标基改变。两者可能使用互逆矩阵。四元数能表示旋转,并不能替我们决定源坐标系、目标坐标系或变换方向;接口文档应同时写清这些约定。
二、q 与 −q 为什么是同一旋转?
四元数对三维旋转具有双覆盖性质,q 与 −q 对应相同旋转矩阵。这意味着相邻帧四元数分量的欧氏差可能很大,而真实角差接近零。代码将 q 和 −q 分别转为矩阵并检查相等。
如果 q 和 −q 直接做算术平均,结果是零向量,不能作为单位旋转。对时间序列可在相邻点积为负时把当前四元数反号,以选择局部连续表示。这个处理不负责去除真实的大跳变,也不能从稀疏数据推断多周旋转历史。
三、SLERP 与矩阵平均有什么不同?
旋转矩阵必须正交且行列式为一。直接对两个矩阵逐元素平均,一般会走出旋转集合;再用于向量会产生缩放或剪切。代码用 170° 与 −170° 的绕 z 旋转演示矩阵平均的正交性误差。
SLERP 在相邻旋转间沿较短旋转路径插值。对这两个端点,中间姿态接近 180°,不是把欧拉角当标量得到的 0°。较短路径是一个明确假设;若真实物体在两次采样间多转了几圈,单靠两个姿态无法恢复圈数。
四、平移和旋转分开插值的边界
常用位姿插值是平移线性插值、旋转 SLERP,适合许多相邻时刻对齐任务。它不保证物体沿固定螺旋运动,也不等价于完整 SE(3) 指数插值。需要哪种轨迹模型,应由运动假设和精度需求决定。
旋转复合不交换。SciPy 中 (b*a).apply(v) 表示先 a 后 b,本篇用实际向量验证。计算角误差应使用相对旋转的转角,而不是逐元素相减四元数或欧拉角。接近 π 时还要注意轴符号和表示分支。
五、可运行的完整合成实验
代码检查四元数反号不改变旋转;人为翻转时间序列中的符号,再恢复相邻点积为正的连续表示;对 170° 与 −170° 做 SLERP;最后验证旋转复合顺序。使用显式 N×1 的单轴角数组,避免不同 SciPy 版本对批量形状的差异。
环境说明:代码在本机 Python 3.14 环境中实际执行。为便于复现,请保存为 demo.py,在支持这些库的独立环境中安装依赖;若目标解释器没有兼容 wheel,应先选择受支持的解释器。环境差异可能影响最后几位数值,不影响本文所检查的几何或统计关系。
python -m pip install numpy scipy
python demo.py
import numpy as np
from scipy.spatial.transform import Rotation,Slerp
r=Rotation.from_euler('z',179,degrees=True)
q=r.as_quat() # x,y,z,w
np.testing.assert_allclose(Rotation.from_quat(-q).as_matrix(),r.as_matrix())
assert np.linalg.norm((q+(-q))/2)==0 # opposite quaternion average is zero
sequence=Rotation.from_euler('z',np.array([170,175,179,181,185,190])[:,None],degrees=True).as_quat()
sequence[1::2]*=-1
continuous=sequence.copy()
for i in range(1,len(continuous)):
if np.dot(continuous[i-1],continuous[i])<0:continuous[i]*=-1
assert (np.sum(continuous[:-1]*continuous[1:],axis=1)>0).all()
key=Rotation.from_euler('z',np.array([170,-170])[:,None],degrees=True)
middle=Slerp([0,1],key)([.5]).as_matrix()[0]
np.testing.assert_allclose(middle,Rotation.from_euler('z',180,degrees=True).as_matrix(),atol=1e-12)
linear=(key.as_matrix()[0]+key.as_matrix()[1])/2
orthogonality=np.linalg.norm(linear.T@linear-np.eye(3))
assert orthogonality>.01
a=Rotation.from_euler('x',30,degrees=True)
b=Rotation.from_euler('z',40,degrees=True)
v=np.array([1.,2.,3.])
np.testing.assert_allclose((b*a).apply(v),b.apply(a.apply(v)))
print('linear_matrix_orthogonality_error',orthogonality)
print('SLERP_midpoint_angle_deg',Rotation.from_matrix(middle).magnitude()*180/np.pi)
print('PASS: double cover, sign continuity, SLERP and composition')
六、运行结果与应该怎样解读
矩阵逐元素平均的正交性误差范数约 0.04264,说明它不是合法旋转;SLERP 中点转角为 180°。所有断言通过。示例验证表示与插值性质,没有模拟跟踪噪声、时钟误差或真实运动速度约束。

图 2:跨角度分支的插值路径(原创教学配图)
代码中的断言验证了本篇明确描述的性质。配图中标为原理示意的部分用于解释模型,不是实测截图;由公式生成的曲线只在图注与正文列出的假设下成立。不要把合成实验输出当成真实产品的性能报告。
七、常见现象与排查方向
| 现象 | 优先检查 | 处理方向 |
|---|---|---|
| 显示姿态突然翻转 | xyzw 与 wxyz 混淆 | 检查单位旋转和已知 90° 测试 |
| 四元数日志全体变号 | 双覆盖表示改变 | 比较相对旋转,必要时做符号连续处理 |
| 插值时物体绕远路 | 直接平均欧拉角 | 用符合运动假设的旋转插值 |
| 混合矩阵导致模型变形 | 插值破坏正交性 | 使用旋转类并验证 RᵀR 与 det |
八、独立验证与适用边界
用单位旋转、绕单轴 90°、逆旋转和不交换的复合旋转测试接口。时间插值还要检查查询时刻在范围内,拒绝无提示外推。保存坐标与分量约定,而不是只保存四个数字。本篇不实现姿态滤波、四元数统计均值或速度预测,这些是不同问题。
建议把原始输入、参数、软件版本、输出结果和失败状态一起保留。若把同一份检查数据反复用于选择参数,它就不再是完全独立的最终验证集。将算法演示接入真实系统时,应另行验证输入质量、参考不确定性和完整处理链路。
参考资料
上述资料用于核对相关接口与基本约定;本文的合成数据、推导示例与代码不声称复刻库内全部实现。
写在最后
"学而时习之,不亦说乎?"
------ 孔子,《论语·学而》
尊重旋转的几何结构,比在四个数字上做直觉运算更可靠。