图解FAST-LIO中的卡尔曼滤波:用Python复现核心算法(附Jupyter Notebook)
图解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):这是一个小量的状态,代表了名义状态与真实状态之间的微小差异。我们只对这个误差状态进行卡尔曼滤波的“预测-更新”循环。
这样做的好处显而易见:
- 线性化更准:误差状态始终是“小量”,在其零点附近进行线性化(泰勒展开)的精度远高于在变化巨大的名义状态处线性化。
- 旋转处理简单:对于3D旋转,误差状态可以用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的解决思路是迭代重线性化:
- 用先验估计
X_prior作为初始值X_operating。 - 在
X_operating处线性化观测模型h,计算卡尔曼增益和状态更新量δx。 - 将更新量加到操作点上:
X_operating = X_operating ⊞ δx。 - 重复步骤2-3数次(例如3-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的单次更新。对比两者在快速转弯或观测突然增多时的表现,通常会发现:
- 收敛速度:IEKF在状态初始误差较大或观测模型非线性强时,往往能通过迭代更快地收敛到真值附近。
- 精度:在相同的观测条件下,IEKF的最终估计误差通常略低于EKF,因为它通过迭代减少了线性化误差。
- 计算量: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观测进行迭代式状态修正,两者紧密协作,共同输出一个高频、低漂移、高鲁棒性的里程计估计。
更多推荐


所有评论(0)