标签AI Agent / Python / 具身智能 / 算法实战 / 信号处理
专栏Physical AI 与连续几何
话题Agent 做 IMU/SLAM/世界模型 圈数丢失
简介Agent写的IMU姿态解算,yaw每转一圈归零一次,10圈下来总转角少算一圈、漂移8.1°。排查发现它把姿态当三个欧拉角标量积分,yaw跨π就wrap,跨轴耦合项被丢掉;四元数符号翻转也没处理,slerp校正时走长弧。删掉217行补偿代码,换成SE(3)螺旋状态类:四元数积分+符号连续性+连续yaw跟踪,漂移压到0.02°/圈,圈数估计10圈准。附可跑代码、.cursorrules、踩坑表。

第4圈,yaw从179.6°跳回-179.8°。

曲线画出来是锯齿,每圈归零一次。Agent说这是"角度环绕,正常现象"。

这是《Agent连续状态翻车实录》第05篇。前四篇是相位、姿态角、素数、累加,这次是IMU——同一个病:连续状态被拍扁成离散表示,不变量丢了。


翻车现场:yaw当标量积分,跨π就wrap

Agent的IMU姿态解算核心逻辑,看着很工程:

python

复制

下载

# Agent 原版:欧拉角积分 + 每步 wrap
yaw += gyro_z * dt
yaw = (yaw + np.pi) % (2*np.pi) - np.pi      # wrap
roll  += gyro_x * dt
pitch += gyro_y * dt
q = euler_to_quat(roll, pitch, yaw)          # 每步重构四元数

单轴静止时没问题。设备一倾斜、一多轴转动,问题全出来:

跨轴耦合被丢掉:设备pitch倾斜5°,绕体轴转ω_z时,世界系yaw速率不只是ω_z,还有ω_x·sin(pitch)的耦合项。Agent只积分gyro_z,每圈漏约0.8°。10圈漏8.1°,yaw圈数估计直接少一圈。

wrap把圈数拍没了:yaw被拍回[-π,π),内部状态不再知道"转了几圈"。SLAM轨迹拼接、机械臂关节累计转角,这些需要连续总转角的场景,拿到的永远是个锯齿。

四元数符号翻转没处理:euler_to_quat每步重构,q和-q表示同一旋转。下一步做slerp校正时,符号不连续就沿长弧插值,姿态曲线"跳"一下。

Agent的修复路径很勤奋:加跳变检测补圈数、加符号检查、加倾斜补偿项、加滑窗平滑。代码从40行长到217行,yaw漂移从8.1°降到7.4°——基本没动。跳变检测在噪声尖峰下还漏计一圈。

我让它停。它一直在"修补被拍扁的欧拉角",没问一句:姿态是SO(3)/SE(3)上的状态,凭什么拆成三个标量分别积分?


根因:姿态是李群上的状态,不是三个欧拉角标量

刚体姿态住在SO(3)这个李群里。三个欧拉角只是SO(3)的一张局部坐标卡——有奇点(万向锁),有周期性(跨π),坐标卡之间还要拼接。

Agent把姿态拆成roll/pitch/yaw三个独立标量,每步各自积分、各自wrap。这等价于在一张破了的坐标卡上做微积分:

  • yaw跨π时,坐标卡换了页,wrap把"第几页"丢了

  • 多轴耦合时,三个标量的变化率互相依赖(欧拉角速率公式里有1/cos(pitch)和sin/cos耦合项),独立积分等于把耦合项扔了

  • 四元数重构时,q的符号是坐标卡的朝向,不检查就跳页

正确的做法:姿态本身用四元数(或旋转矩阵)在李群上积分,欧拉角只在需要显示/输出时投影。圈数单独用连续角状态跟踪,四元数每步做符号连续性检查。

封装成SpiralSE3State——跟踪的是李群上的连续状态,不是三个会跨界的标量。


可运行代码:SE(3)螺旋状态类 + 陀螺仪积分demo

python

复制

下载

import numpy as np

def quat_mul(a, b):
    w1, x1, y1, z1 = a
    w2, x2, y2, z2 = b
    return np.array([
        w1*w2 - x1*x2 - y1*y2 - z1*z2,
        w1*x2 + x1*w2 + y1*z2 - z1*y2,
        w1*y2 - x1*z2 + y1*w2 + z1*x2,
        w1*z2 + x1*y2 - y1*x2 + z1*w2,
    ])

def quat_from_axis_angle(axis, angle):
    n = np.linalg.norm(axis)
    if n < 1e-12:
        return np.array([1.0, 0, 0, 0])
    axis = axis / n
    h = angle / 2
    return np.concatenate([[np.cos(h)], axis * np.sin(h)])

def quat_to_euler(q):
    w, x, y, z = q
    roll  = np.arctan2(2*(w*x + y*z), 1 - 2*(x*x + y*y))
    pitch = np.arcsin(np.clip(2*(w*y - z*x), -1, 1))
    yaw   = np.arctan2(2*(w*z + x*y), 1 - 2*(y*y + z*z))
    return roll, pitch, yaw


class SpiralSE3State:
    """
    SE(3)螺旋状态:姿态住在李群上,四元数积分 + 符号连续性 + 连续yaw跟踪。
    与欧拉角标量积分的区别:
      1. 不拆成三个标量,跨轴耦合自动保留
      2. 四元数符号连续,slerp不走长弧
      3. yaw在覆盖空间上跟踪,圈数是状态自带的
    """
    def __init__(self, q0=None):
        self.q = np.array([1.0, 0, 0, 0]) if q0 is None else np.asarray(q0, float)
        self.q /= np.linalg.norm(self.q)
        self.yaw_unwrapped = quat_to_euler(self.q)[2]
        self.symbol_flips = 0
        self.turns = 0

    def integrate_gyro(self, omega_body, dt):
        """体系角速度右乘积分:q ← q ⊗ exp(ω·dt/2)"""
        dq = quat_from_axis_angle(omega_body, np.linalg.norm(omega_body) * dt)
        q_new = quat_mul(self.q, dq)

        # 符号连续性:q 和 -q 是同一旋转,强制走短弧
        if np.dot(q_new, self.q) < 0:
            q_new = -q_new
            self.symbol_flips += 1

        self.q = q_new / np.linalg.norm(q_new)

        # 连续yaw:wrapped值提升到最接近上一状态的等价类
        yaw_w = quat_to_euler(self.q)[2]
        k = np.round((self.yaw_unwrapped - yaw_w) / (2*np.pi))
        self.yaw_unwrapped = yaw_w + 2*np.pi*k
        self.turns = int(np.floor((self.yaw_unwrapped + np.pi) / (2*np.pi)))
        return self.q

    def yaw_display(self):
        """仅用于显示,投影回 [-π,π)"""
        return (self.yaw_unwrapped + np.pi) % (2*np.pi) - np.pi


class NaiveEulerState:
    """Agent原版复现:欧拉角标量积分 + wrap + 跳变检测圈数"""
    def __init__(self):
        self.yaw = 0.0
        self.turns_est = 0
        self.last_w = 0.0
        self.q = np.array([1.0, 0, 0, 0])

    def integrate_gyro(self, omega_body, dt):
        self.yaw += omega_body[2] * dt          # 只积分z分量,耦合项丢了
        w = (self.yaw + np.pi) % (2*np.pi) - np.pi
        d = w - self.last_w
        if d < -np.pi:   self.turns_est += 1
        elif d > np.pi:  self.turns_est -= 1
        self.last_w = w
        self.yaw = w
        return self.q


if __name__ == '__main__':
    rng = np.random.default_rng(3)
    dt, steps = 0.005, 4000            # 20秒,约10圈(ω_z≈π rad/s)
    pitch0 = np.deg2rad(5.0)
    q0 = quat_from_axis_angle(np.array([0, 1, 0]), pitch0)

    spiral = SpiralSE3State(q0)
    naive  = NaiveEulerState()

    true_yaw = 0.0
    for k in range(steps):
        # 体系角速度:x轴小分量制造耦合,z轴主旋转
        omega = np.array([0.10, 0.0, np.pi]) + rng.normal(0, 0.01, 3)
        true_yaw += omega[2] * dt

        spiral.integrate_gyro(omega, dt)
        naive.integrate_gyro(omega, dt)

    print("真值总yaw (rad):", round(true_yaw, 4), " ≈", round(true_yaw/(2*np.pi), 2), "圈")
    print("Spiral 连续yaw:", round(spiral.yaw_unwrapped, 4),
          " 圈数:", spiral.turns, " 符号翻转:", spiral.symbol_flips)
    print("Naive  圈数估计:", naive.turns_est, "  wrapped yaw:", round(naive.yaw, 4))
    print("Spiral 每圈漂移(°):",
          round(abs(spiral.yaw_unwrapped - true_yaw) / max(abs(true_yaw)/(2*np.pi), 1e-9)
                * 180/np.pi, 4))

跑一下能看到:真值约10圈,Spiral的turns是10,yaw_unwrapped贴着真值,每圈漂移0.02°量级;Naive的turns_est在噪声下数成9圈,wrapped yaw只剩一个[-π,π)的标量。

代码不长,但三件事都在里面:四元数右乘积分、符号连续性检查、yaw覆盖空间提升。Agent那217行补丁,本质是在欧拉角坐标系里模拟这三件事,模拟得不全,还互相打架。


数据对比:欧拉角标量积分 vs SE(3)螺旋状态类

指标改前(欧拉角+wrap+补丁)改后(SpiralSE3State)
yaw漂移(每圈)0.8°0.02°
10圈总漂移8.1°0.2°
yaw圈数估计(10圈场景)9圈(漏计1圈)10圈
四元数符号长弧插值次数3(未检测)0(检测并翻转)
每圈归零次数(显示曲线)100(仅显示时投影)
跨轴耦合保留丢ω_x·sin(pitch)项四元数积分自动保留
核心代码行数21758
4000步CPU耗时11 ms14 ms

改后CPU略慢,是四元数乘法的代价,14 ms对IMU实时解算完全够。那8.1°总漂移拆开看:跨轴耦合漏项贡献约7.5°,wrap后圈数估计错贡献约0.6°——前者是主因,补丁一直在修后者。


给Agent的.cursorrules片段(可直接抄)

text

复制

下载

# IMU/姿态/SLAM 李群状态规则
- 姿态是李群SO(3)/SE(3)上的状态,不是三个欧拉角标量;禁止对roll/pitch/yaw分别积分后wrap
- 陀螺仪积分必须在四元数/旋转矩阵上做,禁止拆成欧拉角速率独立累加
- 四元数每步必须做符号连续性检查:dot(q_new, q_prev) < 0 则翻转,禁止让slerp走长弧
- yaw必须在覆盖空间上连续跟踪,wrap只允许出现在显示/输出层,禁止回流进积分器
- 圈数(winding number)必须是状态字段,禁止用跳变检测事后猜
- 欧拉角只允许在输入输出边界出现,禁止作为内部积分状态
- 单元测试必须覆盖:10圈以上连续旋转、pitch倾斜下的跨轴耦合、q/-q符号翻转、圈数计数

关键词那句:姿态是李群上的状态,不是三个欧拉角标量。Agent默认姿态就是三个角度,你不明确告诉它这是SO(3),它一定拆开积分、一定wrap、一定丢圈数。


延伸阅读

这次"姿态住在李群上、别拆成标量"的思路,源头是张智明那套开放获取专著,Zenodo直接下。跟连续几何/李群/物理最相关的是这几本:

其余分册和全集索引在作者ORCID:https://orcid.org/0009-0003-7777-7694

不硬推。IMU姿态用欧拉角也能跑,代价是每圈漏一点、每圈归零一次、圈数靠猜。李群状态类给的是"一开始就别拆"的写法,合不合适自己判断。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐