图解FAST-LIO中的卡尔曼滤波:用Python复现核心算法(附Jupyter Notebook)

如果你正在学习机器人状态估计或SLAM,面对满屏的数学公式和抽象的“流形”、“误差状态”概念感到头疼,那么这篇文章就是为你准备的。我们绕开繁琐的数学推导,直接动手,用Python和Matplotlib将FAST-LIO中那个精巧的迭代卡尔曼滤波(IEKF)核心“画”出来。我们的目标不是复现一个完整的SLAM系统,而是聚焦于理解其紧耦合迭代更新的思想精髓。通过可交互的代码,你将亲眼看到状态预测如何“飘移”,观测(激光点云)又如何像锚点一样将其“拉回”,以及迭代过程如何让估计值一步步收敛到最优。这非常适合作为SLAM或机器人学课程的配套实验,也适合希望深入理解现代LiDAR-IMU融合算法的开发者。

1. 环境准备与数据仿真

为了专注于算法原理的可视化,我们首先需要搭建一个干净的实验环境,并生成一套可控的仿真数据。真实传感器数据噪声复杂,会干扰我们对核心流程的理解。因此,我们自己“制造”一条机器人的运动轨迹和对应的虚拟激光观测。

1.1 安装必要的Python库

我们将使用NumPy进行矩阵运算,Matplotlib进行动态绘图,SciPy处理一些数学工具。建议在Jupyter Notebook或Jupyter Lab环境中运行,以便实时交互和查看图表。

pip install numpy matplotlib scipy jupyter

在Notebook中,我们首先导入所有必需的模块:

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from scipy.spatial.transform import Rotation as R
from scipy.linalg import expm, inv
import matplotlib.patches as patches
%matplotlib widget  # 在Jupyter Lab中启用交互式图表

1.2 生成仿真运动轨迹与IMU数据

我们假设机器人在2D平面上沿一条“8”字形轨迹运动,这样包含了转弯、加速等丰富运动形态。IMU(惯性测量单元)会测量角速度和线加速度(包含重力分量)。

def generate_trajectory(total_time=10.0, dt=0.01):
    """
    生成一条2D‘8’字形轨迹。
    返回:时间戳,真实位置,真实速度,真实朝向(欧拉角yaw),真实角速度,真实加速度(机体坐标系)。
    """
    t = np.arange(0, total_time, dt)
    n = len(t)

    # 1. 生成平滑的“8”字形路径参数方程
    a = 2.0  # 尺度参数
    omega = 0.8  # 角频率
    # 位置
    x = a * np.sin(omega * t)
    y = a * np.sin(omega * t) * np.cos(omega * t)

    # 2. 通过数值微分计算速度(真实世界应更平滑,此处为演示)
    vx = np.gradient(x, dt)
    vy = np.gradient(y, dt)

    # 3. 计算朝向(偏航角):速度方向
    yaw = np.arctan2(vy, vx)  # 注意:这里简化了,实际朝向可能与速度方向有差异

    # 4. 计算角速度(偏航角速度)
    wz = np.gradient(yaw, dt)

    # 5. 计算真实加速度(在惯性系下)
    ax_inertial = np.gradient(vx, dt)
    ay_inertial = np.gradient(vy, dt)

    # 6. 将惯性系加速度转换到机体坐标系
    acc_body = np.zeros((n, 2))
    for i in range(n):
        rot_mat = np.array([[np.cos(yaw[i]), np.sin(yaw[i])],
                            [-np.sin(yaw[i]), np.cos(yaw[i])]])
        acc_body[i] = rot_mat @ np.array([ax_inertial[i], ay_inertial[i]])

    # 7. 添加重力影响(在2D中,我们假设重力沿负Y轴,并在机体坐标系中体现)
    # 机体坐标系下,重力向量为 [0, -g],再经过旋转到惯性系...
    # 为简化,我们在生成IMU测量值时直接添加。

    return t, np.column_stack((x, y)), np.column_stack((vx, vy)), yaw, wz, acc_body

# 生成数据
time, gt_pos, gt_vel, gt_yaw, gt_wz, gt_acc_body = generate_trajectory()
print(f"生成了 {len(time)} 个数据点,时间间隔 {time[1]-time[0]:.3f} 秒")

接下来,我们模拟IMU的测量过程。IMU测量值包含真实信号、缓慢变化的零偏(Bias)和高斯白噪声。

def simulate_imu_measurements(gt_wz, gt_acc_body, dt, gyro_bias=0.05, accel_bias=0.1):
    """
    模拟IMU的陀螺仪和加速度计测量值。
    """
    n = len(gt_wz)
    # 零偏建模为随机游走(缓慢变化)
    bias_gyro = np.cumsum(np.random.randn(n) * 0.001) + gyro_bias
    bias_acc = np.cumsum(np.random.randn(n, 2) * 0.0005, axis=0) + accel_bias

    # 添加高斯白噪声
    noise_gyro = np.random.randn(n) * 0.01  # rad/s
    noise_acc = np.random.randn(n, 2) * 0.05 # m/s^2

    # 生成测量值:真实值 + 零偏 + 噪声
    meas_gyro = gt_wz + bias_gyro + noise_gyro
    # 加速度计测量值:机体坐标系的真实加速度 + 重力分量 + 零偏 + 噪声
    # 在2D中,假设重力为9.81 m/s^2,方向为惯性系负Y轴。需要转到机体系。
    g = 9.81
    gravity_inertial = np.array([0, -g])
    meas_acc = np.zeros_like(gt_acc_body)
    for i in range(n):
        yaw = gt_yaw[i]  # 使用真实朝向进行转换演示
        rot_to_body = np.array([[np.cos(yaw), np.sin(yaw)],
                                 [-np.sin(yaw), np.cos(yaw)]])
        gravity_body = rot_to_body @ gravity_inertial
        meas_acc[i] = gt_acc_body[i] + gravity_body + bias_acc[i] + noise_acc[i]

    return meas_gyro, meas_acc, bias_gyro, bias_acc

1.3 生成仿真激光雷达点云观测

激光雷达观测是修正状态估计的关键。我们模拟一个简单的2D激光雷达,它能够测量到环境中一些固定路标点的距离和角度。

def generate_landmarks(map_size=10.0, num_landmarks=20):
    """在环境中随机生成一些静态路标点。"""
    return np.random.uniform(-map_size/2, map_size/2, (num_landmarks, 2))

def simulate_lidar_measurements(gt_pos, gt_yaw, landmarks, lidar_range=15.0, fov=np.pi*2):
    """
    模拟2D激光雷达扫描。
    对于每个位姿,计算能看到的路标点,并添加噪声。
    """
    n = len(gt_pos)
    all_measurements = []

    for i in range(n):
        measurements_at_i = []
        pos = gt_pos[i]
        yaw = gt_yaw[i]
        rot_mat = np.array([[np.cos(yaw), -np.sin(yaw)],
                            [np.sin(yaw), np.cos(yaw)]])

        for lm in landmarks:
            # 将路标点转换到机器人机体坐标系
            lm_body = rot_mat.T @ (lm - pos)  # 等价于将全局点转换到机体系

            # 计算距离和角度(激光雷达极坐标)
            r = np.linalg.norm(lm_body)
            theta = np.arctan2(lm_body[1], lm_body[0])  # 相对于机器人前方

            # 检查是否在视场角和量程内
            if abs(theta) <= fov/2 and r <= lidar_range:
                # 添加测量噪声
                r_noisy = r + np.random.randn() * 0.1  # 距离噪声 0.1m
                theta_noisy = theta + np.random.randn() * 0.01 # 角度噪声 0.01 rad
                # 记录:测量值,对应的全局路标点ID(索引)
                measurements_at_i.append((r_noisy, theta_noisy, np.where((landmarks == lm).all(axis=1))[0][0]))

        all_measurements.append(measurements_at_i)  # 可能为空列表

    return all_measurements

注意:在实际的FAST-LIO中,处理的是3D点云和面/线特征。这里我们极大地简化了观测模型,用2D路标点代替,以便将注意力集中在滤波器的迭代更新机制上。核心逻辑是相通的:利用观测来修正预测状态。

2. 理解误差状态卡尔曼滤波(ESKF)与迭代扩展卡尔曼滤波(IEKF)

在进入代码实现前,我们需要厘清两个关键概念:为什么FAST-LIO选择误差状态,以及迭代究竟在做什么。这能帮助我们从直觉上把握代码每一步的意义。

2.1 为何使用误差状态?

在传统的扩展卡尔曼滤波(EKF)中,我们直接对机器人的状态(位置、姿态、速度等)进行估计和更新。但姿态(旋转)用欧拉角或四元数表示时,其更新和协方差处理会变得复杂,容易产生奇异性或线性化误差过大的问题。

误差状态卡尔曼滤波(ESKF)采用了一种更优雅的思路:

  • 名义状态 (Nominal State):这是一个“粗略”的状态,通常由IMU的机械积分(即运动方程)直接得到。它包含较大的误差,尤其是漂移。
  • 误差状态 (Error State):这是一个小量的状态,代表了名义状态与真实状态之间的微小差异。我们只对这个误差状态进行卡尔曼滤波的“预测-更新”循环。

这样做的好处显而易见:

  1. 线性化更准:误差状态始终是“小量”,在其零点附近进行线性化(泰勒展开)的精度远高于在变化巨大的名义状态处线性化。
  2. 旋转处理简单:对于3D旋转,误差状态可以用3维的旋转向量(李代数)表示,避免了四元数的约束或欧拉角的奇异性。
  3. 计算高效:误差状态的协方差矩阵维度固定且较小,更新效率高。

在FAST-LIO的论文中,运算符 就是用来连接名义状态 X 和误差状态 δx 的:

  • X_true = X_nominal ⊞ δx (将误差状态加到名义状态上)
  • δx = X_true ⊟ X_nominal (计算误差状态)

在我们的2D简化版中,状态向量定义为:

名义状态 X = [px, py, vx, vy, yaw]
误差状态 δx = [δpx, δpy, δvx, δvy, δyaw]

其中 yaw 是偏航角,δyaw 是偏航角误差。

2.2 迭代扩展卡尔曼滤波(IEKF)的核心思想

标准EKF在更新时,只进行一次线性化:在当前的先验估计点 X_prior 处线性化观测模型 h(X),然后计算卡尔曼增益并更新。如果先验估计离真实值较远,这种一次线性化可能引入较大误差。

IEKF的解决思路是迭代重线性化

  1. 用先验估计 X_prior 作为初始值 X_operating
  2. X_operating 处线性化观测模型 h,计算卡尔曼增益和状态更新量 δx
  3. 将更新量加到操作点上:X_operating = X_operating ⊞ δx
  4. 重复步骤2-3数次(例如3-5次)。
  5. 最后一次迭代得到的 X_operating 作为后验估计 X_posterior

这个过程就像牛顿法求根:我们不断寻找使观测残差 z - h(X) 趋于零的状态 X。每次迭代都在新的、更优的操作点处重新计算观测模型的雅可比矩阵 H,使得线性化更贴近真实非线性函数在当前点的局部形态,从而得到更准确的更新。

下面的表格对比了EKF、ESKF和IEKF的关键区别:

特性 扩展卡尔曼滤波 (EKF) 误差状态卡尔曼滤波 (ESKF) 迭代扩展卡尔曼滤波 (IEKF)
状态估计对象 完整状态向量 误差状态向量 完整或误差状态(FAST-LIO用于误差状态)
线性化点 先验估计值 误差状态零点(名义状态处) 每次迭代更新后的操作点
旋转处理 复杂,易奇异 简单,使用李代数 同ESKF,但迭代优化
计算量 中等 较低 较高(多次计算H矩阵)
精度 线性化误差可能较大 线性化误差小 线性化误差最小,收敛更精确
主要应用场景 早期导航、SLAM 现代IMU融合、视觉惯性里程计 对精度要求高的紧耦合系统,如FAST-LIO

3. 用Python实现紧耦合迭代卡尔曼滤波

现在,我们将上述理论转化为代码。我们将实现一个简化版的“FAST-LIO核心”,包含前向传播(IMU预测)、后向传播(运动畸变校正)、观测模型和迭代更新循环。

3.1 状态定义与运动模型(前向传播)

首先定义我们的状态类,并实现基于IMU测量的状态预测(前向传播)。

class RobotState:
    def __init__(self, pos=np.zeros(2), vel=np.zeros(2), yaw=0.0):
        self.pos = pos.copy()  # 位置 [x, y]
        self.vel = vel.copy()  # 速度 [vx, vy]
        self.yaw = yaw          # 偏航角
        # 误差状态协方差矩阵 (5x5: px, py, vx, vy, yaw)
        self.P = np.eye(5) * 0.01

    def box_plus(self, delta_x):
        """实现 ⊞ 操作:将误差状态加到名义状态上。"""
        new_state = RobotState()
        new_state.pos = self.pos + delta_x[0:2]
        new_state.vel = self.vel + delta_x[2:4]
        new_state.yaw = self.yaw + delta_x[4]
        new_state.P = self.P.copy() # 注意:协方差需要单独更新
        return new_state

    def box_minus(self, other_state):
        """实现 ⊟ 操作:计算两个状态间的误差状态。"""
        delta_x = np.zeros(5)
        delta_x[0:2] = self.pos - other_state.pos
        delta_x[2:4] = self.vel - other_state.vel
        # 角度差需要归一化到 [-pi, pi]
        angle_diff = self.yaw - other_state.yaw
        delta_x[4] = np.arctan2(np.sin(angle_diff), np.cos(angle_diff))
        return delta_x

def imu_prediction(state, gyro_meas, acc_meas, dt):
    """
    基于IMU测量进行状态预测(前向传播)。
    使用简单的欧拉积分。输入acc_meas为机体坐标系测量值。
    """
    # 1. 名义状态预测
    yaw = state.yaw
    # 旋转矩阵:从机体到世界(惯性)系
    R_wb = np.array([[np.cos(yaw), -np.sin(yaw)],
                     [np.sin(yaw), np.cos(yaw)]])
    # 机体坐标系下的加速度(去除重力?在我们的仿真中,测量值已包含重力)
    # 实际上,我们需要用估计的零偏来校正测量值,这里为简化,假设零偏已知或已标定。
    acc_world = R_wb @ acc_meas  # 将测量加速度转换到世界系

    new_state = RobotState()
    # 速度更新:v = v + a * dt
    new_state.vel = state.vel + acc_world * dt
    # 位置更新:p = p + v * dt + 0.5 * a * dt^2
    new_state.pos = state.pos + state.vel * dt + 0.5 * acc_world * dt**2
    # 朝向更新:yaw = yaw + w * dt
    new_state.yaw = state.yaw + gyro_meas * dt
    # 归一化角度
    new_state.yaw = np.arctan2(np.sin(new_state.yaw), np.cos(new_state.yaw))

    # 2. 误差状态协方差预测 (P = F * P * F^T + G * Q * G^T)
    # F是状态转移矩阵关于误差状态的雅可比,这里进行简化
    F = np.eye(5)
    F[0:2, 2:4] = np.eye(2) * dt  # 位置对速度的依赖
    F[0:2, 4] = -R_wb @ np.array([[-np.sin(yaw), -np.cos(yaw)],
                                   [np.cos(yaw), -np.sin(yaw)]]) @ acc_meas * dt  # 位置对yaw的依赖(简化)
    F[2:4, 4] = -R_wb @ np.array([[-np.sin(yaw), -np.cos(yaw)],
                                   [np.cos(yaw), -np.sin(yaw)]]) @ acc_meas  # 速度对yaw的依赖

    # G是噪声驱动矩阵,Q是IMU噪声协方差矩阵
    G = np.zeros((5, 3))  # 假设噪声来自gyro和acc
    G[2:4, 1:3] = R_wb * dt  # 速度受加速度计噪声影响
    G[4, 0] = dt  # 朝向受陀螺仪噪声影响

    Q = np.diag([0.01**2, 0.05**2, 0.05**2])  # 陀螺仪噪声,加速度计噪声(x,y)

    new_state.P = F @ state.P @ F.T + G @ Q @ G.T

    return new_state

3.2 运动畸变校正(后向传播)

在真实的LiDAR扫描中,一帧点云内的每个点是在不同时刻采集的。如果机器人在这段时间内运动,直接把这帧点云当作同一时刻的观测会引入运动畸变。FAST-LIO通过后向传播来校正这一点。

核心思想:我们已经预测到了扫描结束时刻 t_k 的状态 X_k。对于扫描帧内更早时刻 ρ_j 采集的一个点,我们利用IMU数据,从 X_k 反向积分ρ_j 时刻,得到该时刻的状态估计 X_j,然后将这个点从 ρ_j 时刻的激光雷达坐标系,转换到 t_k 时刻的激光雷达坐标系。这样,整帧点云就被“对齐”到了同一个参考时刻 t_k

def backward_propagation(state_at_tk, imu_buffer, measurement_time, lidar_to_imu_extrinsic=np.eye(3)):
    """
    简化的后向传播(运动畸变校正)。
    state_at_tk: t_k时刻的状态估计。
    imu_buffer: 从measurement_time到t_k时刻的IMU数据缓存。
    measurement_time: 某个激光点采集的时刻ρ_j。
    返回:将该激光点从ρ_j时刻校正到t_k时刻所需的变换矩阵(2D齐次坐标形式)。
    """
    # 这里为了简化,我们假设IMU频率足够高,并且使用反向欧拉积分。
    # 实际上,FAST-LIO使用了更精确的推导。
    delta_t = state_at_tk.time - measurement_time  # 时间间隔
    # 简化处理:假设在这段短时间内,角速度和加速度恒定,使用平均IMU测量值
    # 实际应从buffer中插值或积分
    avg_gyro = np.mean([imu.gyro for imu in imu_buffer if measurement_time <= imu.time <= state_at_tk.time])
    avg_acc = np.mean([imu.acc for imu in imu_buffer if measurement_time <= imu.time <= state_at_tk.time], axis=0)

    # 反向积分:从t_k时刻的状态,反向推算ρ_j时刻的状态
    # 注意:这是近似。严格来说需要反向积分运动方程。
    R_wb_tk = np.array([[np.cos(state_at_tk.yaw), -np.sin(state_at_tk.yaw)],
                        [np.sin(state_at_tk.yaw), np.cos(state_at_tk.yaw)]])
    # 我们近似地认为,从ρ_j到t_k的相对运动,与从t_k到ρ_j的IMU正向积分相反。
    # 计算相对旋转
    delta_yaw = -avg_gyro * delta_t
    R_rel = np.array([[np.cos(delta_yaw), -np.sin(delta_yaw)],
                      [np.sin(delta_yaw), np.cos(delta_yaw)]])
    # 计算相对位移(简化)
    # 将平均加速度转换到世界系,并积分两次(注意符号为负,因为是反向)
    acc_world_avg = R_wb_tk @ avg_acc  # 近似使用t_k时刻的旋转
    delta_p = - (state_at_tk.vel * delta_t - 0.5 * acc_world_avg * delta_t**2)

    # 构建从ρ_j时刻机体系到t_k时刻机体系的变换矩阵 (2D 齐次坐标)
    T_rel = np.eye(3)
    T_rel[0:2, 0:2] = R_rel.T  # 注意是反向,所以用转置
    T_rel[0:2, 2] = delta_p

    # 考虑激光雷达到IMU的外参变换 (假设已知,这里用单位阵简化)
    # 完整变换: T_Lk_from_Lj = inv(T_imu_from_lidar) * T_Ik_from_Ij * T_imu_from_lidar
    # 由于我们简化了外参和坐标系,这里直接返回相对变换
    return T_rel

提示:在实际的FAST-LIO代码中,后向传播利用了前向传播过程中保存的IMU状态和协方差,通过公式(10)进行精确计算,并考虑了误差的传播。我们的简化版本旨在展示概念。

3.3 观测模型与迭代更新

这是整个滤波器的核心。我们有了对齐到 t_k 时刻的一帧点云,以及对这些点云对应的地图特征(在我们的仿真中是已知的全局路标点)。观测模型描述了如何从机器人状态预测激光测量值。

def observation_model(state, landmark_global):
    """
    观测模型 h(X): 给定状态X,预测对某个全局路标点的观测(距离和角度)。
    landmark_global: 路标点在全局坐标系下的坐标 [x, y]。
    返回: 预测的观测值 [range, bearing]。
    """
    R_wb = np.array([[np.cos(state.yaw), -np.sin(state.yaw)],
                     [np.sin(state.yaw), np.cos(state.yaw)]])
    # 将全局路标点转换到机器人机体坐标系
    landmark_body = R_wb.T @ (landmark_global - state.pos)
    pred_range = np.linalg.norm(landmark_body)
    pred_bearing = np.arctan2(landmark_body[1], landmark_body[0])
    return np.array([pred_range, pred_bearing])

def compute_observation_jacobian(state, landmark_global):
    """
    计算观测模型 h(X) 在当前状态state处关于误差状态 δx 的雅可比矩阵 H。
    维度: (2, 5),因为观测是2维(距离,角度),误差状态是5维。
    """
    px, py = state.pos
    vx, vy = state.vel
    yaw = state.yaw
    lx, ly = landmark_global

    dx = lx - px
    dy = ly - py
    range_sq = dx**2 + dy**2
    range_ = np.sqrt(range_sq)

    H = np.zeros((2, 5))
    # 距离观测关于位置的导数
    H[0, 0] = -dx / range_  # d(range)/d(px)
    H[0, 1] = -dy / range_  # d(range)/d(py)
    # 距离观测关于速度、yaw的导数为0
    H[0, 2] = 0.0
    H[0, 3] = 0.0
    H[0, 4] = 0.0

    # 角度观测关于位置的导数
    H[1, 0] = dy / range_sq  # d(bearing)/d(px)
    H[1, 1] = -dx / range_sq # d(bearing)/d(py)
    # 角度观测关于速度的导数为0
    H[1, 2] = 0.0
    H[1, 3] = 0.0
    # 角度观测关于yaw的导数: 始终为 -1,因为机体坐标系相对于全局坐标系旋转了-yaw
    H[1, 4] = -1.0

    return H

def iterative_ekf_update(prior_state, landmarks_global, measurements, max_iterations=3):
    """
    执行迭代扩展卡尔曼滤波更新。
    prior_state: 先验状态 (预测状态)。
    landmarks_global: 所有路标点的全局坐标列表。
    measurements: 当前帧的激光测量列表,每个元素为 (range, bearing, landmark_id)。
    """
    x_prior = prior_state
    P_prior = prior_state.P

    # 迭代更新
    x_operating = RobotState(x_prior.pos.copy(), x_prior.vel.copy(), x_prior.yaw) # 深拷贝
    P_operating = P_prior.copy()

    for iteration in range(max_iterations):
        # 初始化本次迭代的更新量
        delta_x = np.zeros(5)
        H_list = []
        residual_list = []
        R_list = []  # 观测噪声协方差

        # 遍历所有观测,构建整体问题
        for (meas_range, meas_bearing, lm_id) in measurements:
            landmark = landmarks_global[lm_id]
            # 预测观测
            z_pred = observation_model(x_operating, landmark)
            # 计算残差 (实际观测 - 预测观测)
            z_meas = np.array([meas_range, meas_bearing])
            # 注意角度残差需要归一化到 [-pi, pi]
            angle_residual = z_meas[1] - z_pred[1]
            angle_residual = np.arctan2(np.sin(angle_residual), np.cos(angle_residual))
            residual = np.array([z_meas[0] - z_pred[0], angle_residual])

            # 计算雅可比矩阵 H
            H = compute_observation_jacobian(x_operating, landmark)

            H_list.append(H)
            residual_list.append(residual)
            # 假设观测噪声独立,每个观测的噪声协方差矩阵
            R = np.diag([0.1**2, 0.02**2])  # 距离噪声0.1m,角度噪声0.02rad
            R_list.append(R)

        if not H_list:
            print("本次更新无有效观测。")
            return x_prior, P_prior  # 没有观测,返回先验

        # 堆叠所有观测的H, residual, R
        H_full = np.vstack(H_list)          # 维度: (2*N_obs, 5)
        residual_full = np.concatenate(residual_list) # 维度: (2*N_obs,)
        R_full = scipy.linalg.block_diag(*R_list) # 维度: (2*N_obs, 2*N_obs)

        # 计算卡尔曼增益 (使用公式 K = P * H^T * (H * P * H^T + R)^{-1})
        # 但直接求逆维度可能很高(2*N_obs)。使用矩阵求逆引理转换,如FAST-LIO论文式(18)。
        # 这里为清晰起见,我们直接计算(在观测不多时可行)。
        S = H_full @ P_operating @ H_full.T + R_full
        K = P_operating @ H_full.T @ inv(S)

        # 计算状态更新量
        delta_x = K @ residual_full

        # 更新操作点 (名义状态)
        x_operating = x_operating.box_plus(delta_x)
        # 更新协方差 (Joseph form 更稳定)
        I = np.eye(P_operating.shape[0])
        P_operating = (I - K @ H_full) @ P_operating @ (I - K @ H_full).T + K @ R_full @ K.T
        # 简单形式: P_operating = (I - K @ H_full) @ P_operating

        print(f"  迭代 {iteration+1}, 残差范数: {np.linalg.norm(residual_full):.4f}, 更新量范数: {np.linalg.norm(delta_x):.6f}")

        # 检查收敛(更新量很小)
        if np.linalg.norm(delta_x) < 1e-4:
            print(f"    迭代在第 {iteration+1} 次收敛。")
            break

    # 迭代结束,后验状态和协方差即为最后一次迭代的结果
    posterior_state = x_operating
    posterior_state.P = P_operating
    return posterior_state, posterior_state.P

4. 动态可视化与结果分析

理论结合代码后,最激动人心的部分就是看到算法如何运行。我们将创建一个动态图表,实时展示机器人的真实轨迹、预测轨迹、滤波后的估计轨迹,以及协方差椭圆的演变。

4.1 创建动态可视化

def run_simulation_and_visualize():
    # 生成仿真数据
    landmarks = generate_landmarks(num_landmarks=15)
    imu_gyro, imu_acc, _, _ = simulate_imu_measurements(gt_wz, gt_acc_body, time[1]-time[0])
    lidar_meas = simulate_lidar_measurements(gt_pos, gt_yaw, landmarks)

    # 初始化滤波器状态
    est_state = RobotState(pos=gt_pos[0] + np.random.randn(2)*0.5, # 给一个初始误差
                           vel=gt_vel[0],
                           yaw=gt_yaw[0] + np.random.randn()*0.1)
    est_state.P = np.eye(5) * 0.1

    # 存储估计结果用于绘图
    estimated_positions = [est_state.pos.copy()]
    estimated_covariances = [est_state.P.copy()]

    # 主滤波循环 (简化:假设IMU和LiDAR同步,实际是异步的)
    for i in range(1, len(time)):
        dt = time[i] - time[i-1]
        # 1. IMU预测 (前向传播)
        est_state = imu_prediction(est_state, imu_gyro[i-1], imu_acc[i-1], dt)

        # 2. LiDAR更新 (假设在时间点i有一次LiDAR观测)
        if lidar_meas[i]:  # 如果有观测
            est_state, _ = iterative_ekf_update(est_state, landmarks, lidar_meas[i], max_iterations=3)

        estimated_positions.append(est_state.pos.copy())
        estimated_covariances.append(est_state.P.copy())

    # 转换为数组以便绘图
    est_pos_arr = np.array(estimated_positions)

    # 创建动态图表
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))

    # 子图1: 轨迹与路标
    ax1.set_xlim(-5, 5)
    ax1.set_ylim(-5, 5)
    ax1.set_aspect('equal')
    ax1.grid(True)
    ax1.set_title('Robot Trajectory & Landmarks')
    ax1.set_xlabel('X [m]')
    ax1.set_ylabel('Y [m]')

    # 绘制静态路标
    ax1.scatter(landmarks[:, 0], landmarks[:, 1], c='green', s=80, marker='s', alpha=0.6, label='Landmarks')
    # 绘制真实轨迹(灰色虚线)
    true_line, = ax1.plot([], [], 'k--', lw=1, alpha=0.7, label='Ground Truth')
    # 绘制估计轨迹(蓝色实线)
    est_line, = ax1.plot([], [], 'b-', lw=2, label='Estimated')
    # 绘制当前真实位置(红色星号)
    true_point, = ax1.plot([], [], 'r*', markersize=12, label='GT Position')
    # 绘制当前估计位置(蓝色圆点)
    est_point, = ax1.plot([], [], 'bo', markersize=8, label='Est Position')
    # 绘制当前估计位置的协方差椭圆
    from matplotlib.patches import Ellipse
    cov_ellipse = Ellipse(xy=(0,0), width=0, height=0, angle=0, alpha=0.3, color='blue')
    ax1.add_patch(cov_ellipse)

    ax1.legend(loc='upper left')

    # 子图2: 位置误差与协方差迹
    ax2.set_xlim(0, len(time))
    ax2.set_ylim(0, 2.0)
    ax2.grid(True)
    ax2.set_title('Position Error & Covariance Trace')
    ax2.set_xlabel('Time Step')
    ax2.set_ylabel('Error [m] / Trace')

    error_line, = ax2.plot([], [], 'r-', lw=2, label='Position Error (RMSE)')
    trace_line, = ax2.plot([], [], 'g-', lw=2, label='Cov Trace (pos)')
    ax2.legend()

    # 初始化函数
    def init():
        true_line.set_data([], [])
        est_line.set_data([], [])
        true_point.set_data([], [])
        est_point.set_data([], [])
        cov_ellipse.set_center((0,0))
        cov_ellipse.set_width(0)
        cov_ellipse.set_height(0)
        error_line.set_data([], [])
        trace_line.set_data([], [])
        return true_line, est_line, true_point, est_point, cov_ellipse, error_line, trace_line

    # 更新函数
    def update(frame):
        # 更新轨迹线
        true_line.set_data(gt_pos[:frame, 0], gt_pos[:frame, 1])
        est_line.set_data(est_pos_arr[:frame, 0], est_pos_arr[:frame, 1])
        # 更新当前位置点
        true_point.set_data([gt_pos[frame, 0]], [gt_pos[frame, 1]])
        est_point.set_data([est_pos_arr[frame, 0]], [est_pos_arr[frame, 1]])

        # 更新协方差椭圆 (只取位置部分的协方差)
        pos_cov = estimated_covariances[frame][0:2, 0:2]
        eigvals, eigvecs = np.linalg.eig(pos_cov)
        angle = np.degrees(np.arctan2(eigvecs[1,0], eigvecs[0,0]))
        width, height = 2 * np.sqrt(eigvals) * 2  # 2-sigma椭圆,放大2倍便于观察
        cov_ellipse.set_center(est_pos_arr[frame])
        cov_ellipse.set_width(width)
        cov_ellipse.set_height(height)
        cov_ellipse.angle = angle

        # 更新误差和协方差迹曲线
        error = np.linalg.norm(est_pos_arr[frame] - gt_pos[frame])
        trace_val = np.trace(pos_cov)
        x_data = list(range(frame+1))
        error_line.set_data(x_data, [np.linalg.norm(est_pos_arr[:i+1] - gt_pos[:i+1], axis=1).mean() for i in range(frame+1)])
        trace_line.set_data(x_data, [np.trace(estimated_covariances[i][0:2, 0:2]) for i in range(frame+1)])

        return true_line, est_line, true_point, est_point, cov_ellipse, error_line, trace_line

    ani = FuncAnimation(fig, update, frames=len(time), init_func=init, blit=True, interval=50, repeat=False)
    plt.tight_layout()
    plt.show()
    return ani

# 运行仿真和可视化
ani = run_simulation_and_visualize()

4.2 结果解读与性能对比

运行上述代码后,你将看到两个动态更新的图表。左侧图表展示了机器人在二维平面上的运动:

  • 绿色方块是固定的环境路标点。
  • 黑色虚线是机器人的真实轨迹(Ground Truth)。
  • 蓝色实线是滤波器估计出的轨迹。
  • 红色星号蓝色圆点分别代表当前时刻的真实位置和估计位置。
  • 蓝色椭圆代表了估计位置的不确定性(协方差椭圆),椭圆越大,不确定性越高。

右侧图表展示了两个关键指标随时间的变化:

  • 红色曲线:位置估计的均方根误差(RMSE)。你可以看到在滤波器初始阶段,由于状态不确定,误差较大。随着观测(激光数据)的不断融入,误差迅速下降并保持在一个较低的水平。如果只有IMU预测(即不开更新),这条线会持续上升,体现IMU的积分漂移。
  • 绿色曲线:位置部分协方差矩阵的迹(Trace)。它反映了滤波器对自身估计的“自信程度”。在每次IMU预测后,不确定性会增长(迹变大);在激光观测更新后,不确定性会减小(迹变小)。这是一个典型的“预测-更新”循环的直观体现。

与传统EKF的对比: 为了体现IEKF的优势,你可以在代码中设置 max_iterations=1 来模拟标准EKF的单次更新。对比两者在快速转弯或观测突然增多时的表现,通常会发现:

  1. 收敛速度:IEKF在状态初始误差较大或观测模型非线性强时,往往能通过迭代更快地收敛到真值附近。
  2. 精度:在相同的观测条件下,IEKF的最终估计误差通常略低于EKF,因为它通过迭代减少了线性化误差。
  3. 计算量:IEKF每次更新需要计算多次观测雅可比矩阵 H 和求解更新方程,计算量是EKF的数倍。但在FAST-LIO的框架下,通过巧妙的公式变换(如式18),将大矩阵求逆转化为小矩阵求逆,极大地缓解了这个问题。

这个简化的演示忽略了实际FAST-LIO中的许多复杂细节,例如:

  • 3D旋转与流形上的运算:我们使用了2D角度,而FAST-LIO在SO(3)流形上使用李群李代数进行运算。
  • 特征提取与关联:我们假设知道每个激光点对应哪个路标点(数据关联已知),而真实系统需要提取面、线特征并进行匹配。
  • 地图管理:FAST-LIO维护了一个增量式ikd-Tree地图,用于高效查询最近邻点云。
  • 异步传感器处理:IMU频率远高于LiDAR,FAST-LIO有复杂的前向传播、后向传播和缓冲机制。

尽管如此,这个Python复现的核心价值在于剥离了工程实现的复杂性,让你能直观地抓住紧耦合迭代卡尔曼滤波的灵魂:利用高频IMU进行状态预测与运动畸变校正,利用低频但精确的LiDAR观测进行迭代式状态修正,两者紧密协作,共同输出一个高频、低漂移、高鲁棒性的里程计估计。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐