📖 第01课:SLAM概述
课程阶段:阶段1 - SLAM基础(1-6)
学习目标:理解SLAM问题的数学本质、概率框架、核心模块,掌握SLAM分类体系与代表性算法
前置知识:线性代数基础、概率论基础、Python/C++编程
一、什么是SLAM?
SLAM(Simultaneous Localization And Mapping,同时定位与建图)是机器人领域的核心问题:机器人在未知环境中,同时估计自身位姿和构建环境地图。
这是一个典型的"鸡与蛋"问题——定位需要地图,建图需要位姿,两者必须同时求解。SLAM的数学本质是一个高维状态估计问题,需要从不完整、带噪声的传感器数据中恢复环境结构和机器人运动。
┌──────────────────────────────────────────────────┐
│ SLAM 问题 │
│ │
│ 传感器观测 z ──→ ┌──────────┐ ──→ 位姿 x │
│ │ │ │
│ 控制输入 u ──→ │ SLAM │ ──→ 地图 m │
│ │ 求解器 │ │
│ 先验知识 ──→ │ │ ──→ 不确定性 Σ │
│ └──────────┘ │
│ │
│ 输出: p(x₀:T, m | z₁:T, u₁:T) │
└──────────────────────────────────────────────────┘
二、SLAM问题的数学形式化
2.1 状态空间定义
定义以下随机变量:
- xt ∈ SE(3):时刻 t 机器人的6DoF位姿(3平移+3旋转)
- m = {m₁, m₂, ..., mM}:环境地图,M个路标
- ut:时刻 t 的控制输入(里程计/IMU读数)
- zt:时刻 t 的传感器观测
- x0:T = {x₀, x₁, ..., xT}:机器人完整轨迹
SLAM的完整概率形式:
p(x0:T, m | z1:T, u1:T)
即:给定所有观测 z1:T 和控制输入 u1:T,
求机器人轨迹 x0:T 和地图 m 的联合后验分布。
这是一个高维联合分布——假设100个路标,每个路标3维,轨迹100步,
则状态空间维度 = 100×6 + 100×3 = 900维。
2.2 贝叶斯递归分解
利用贝叶斯法则和条件独立性,SLAM后验可以递归分解:
第一步:轨迹与地图分离
p(x0:T, m | z1:T, u1:T) = p(x0:T | z1:T, u1:T) · p(m | x0:T, z1:T)
第二步:轨迹的马尔可夫分解
p(x0:T | z1:T, u1:T) = η · p(x₀) · ∏t=1T p(xt | xt-1, ut) · p(zt | xt, m)
其中 η = 1/p(z1:T | u1:T) 是归一化常数。
第三步:地图的条件分布
给定轨迹,地图的后验可分解为各路标独立:
p(m | x0:T, z1:T) = ∏j=1M p(mj | x0:T, z1:T)
这是EKF-SLAM的关键:地图路标在给定轨迹后条件独立。
2.3 运动模型与观测模型
运动模型(状态转移):
xt = f(xt-1, ut) + wt, wt ~ N(0, Rt)
其中 f(·) 是运动函数,wt 是过程噪声。
对于差速驱动:f 返回 (x+Δx, y+Δy, θ+Δθ)。
观测模型:
zt = h(xt, m) + vt, vt ~ N(0, Qt)
其中 h(·) 是观测函数,vt 是观测噪声。
对于相机观测路标:h 将3D路标投影到2D像素坐标。
2.4 马尔可夫假设
SLAM求解依赖两个关键独立性假设:
运动马尔可夫性:
p(xt | x0:t-1, u1:t) = p(xt | xt-1, ut)
当前状态只依赖上一状态和当前控制。
观测条件独立性:
p(zt | x0:t, m, z1:t-1) = p(zt | xt, m)
当前观测只依赖当前位姿和地图。
⚠ 这些假设在实际中可能不成立!
• 运动马尔可夫性忽略打滑、惯性等时序相关性
• 观测独立性忽略动态物体、光照变化等
• 鲁棒SLAM需要处理这些违例
三、SLAM系统的四大模块
传感器数据 ──→ [前端 Front-end] ──→ [后端 Back-end] ──→ 地图输出
│ ↑
└──→ [回环检测] ────┘
│
[地图表示] ←──────┘
前端:特征提取 → 数据关联 → 帧间运动估计
后端:状态估计 → 图优化 → 一致性维护
回环:场景识别 → 几何验证 → 约束添加
地图:稀疏/稠密/语义 → 多层次表示
3.1 前端(Front-end)
负责数据关联和帧间运动估计,是SLAM的"感知"模块:
- 特征提取:从图像中提取ORB/SIFT特征点,从点云中提取边缘/平面
- 数据关联:将当前观测与地图中的路标匹配,建立对应关系
- 运动估计:通过ICP/PnP/光流估计帧间相对运动
- 关键帧选择:决定何时插入关键帧,平衡精度与效率
前端的错误(错误数据关联)会传播到后端,导致地图不可用。因此鲁棒的数据关联是SLAM的基石。
3.2 后端(Back-end)
负责状态估计与全局优化,是SLAM的"推理"模块:
- 滤波方法:EKF-SLAM(单次线性化)、UKF-SLAM(采样线性化)、粒子滤波SLAM(FastSLAM)
- 优化方法:图优化(g2o/GTSAM)、Bundle Adjustment(Ceres Solver)
- 核心目标:最小化残差 ∑||zt - h(xt, m)||²Σ
3.3 回环检测(Loop Closure Detection)
检测机器人是否回到曾经到过的位置,消除累积漂移:
- 词袋模型:DBoW2/DBoW3,将图像转为视觉单词向量
- 深度学习:NetVLAD、SuperGlue,学习场景描述子
- 几何验证:RANSAC + 基本矩阵检验,防止误匹配
- 回环修正:添加回环约束后触发全局位姿图优化
3.4 地图表示
| 类型 | 数学描述 | 适用场景 | 代表算法 |
| 路标地图 | m = {m₁,...,mM} ∈ ℝ³M | 视觉SLAM定位 | ORB-SLAM2 |
| 占据栅格 | p(occ | z1:t) ∈ [0,1] 网格 | 导航避障 | GMapping |
| 点云地图 | {pᵢ} ⊂ ℝ³,稠密3D点集 | 重建与可视化 | LOAM |
| TSDF | D(x) 截断符号距离场 | 面重建 | KinectFusion |
| 语义地图 | {(pᵢ, cᵢ)} 带标签的地图 | 高级任务规划 | Kimera |
| OctoMap | 八叉树概率占据 | 大规模3D导航 | OctoMap |
四、SLAM方法分类
4.1 按后端方法分类
滤波方法 vs 优化方法
| 维度 | 滤波(EKF/PF) | 优化(图优化/BA) |
| 状态表示 | 当前时刻分布 p(xt, m) | 全轨迹 p(x0:T, m) |
| 线性化 | 一阶(EKF)或采样(PF) | 迭代重线性化 |
| 计算量 | O(M²) 每步(EKF) | O(N³) 但稀疏可加速 |
| 一致性 | 线性化误差累积 | 全局最优(局部极值内) |
| 回环处理 | 困难 | 自然支持 |
| 代表算法 | EKF-SLAM, FastSLAM | ORB-SLAM, Cartographer |
4.2 按传感器分类
| 类型 | 传感器 | 优点 | 缺点 | 代表 |
| 视觉SLAM | 单目/双目/RGB-D相机 | 成本低、信息丰富 | 受光照影响、尺度问题 | ORB-SLAM3 |
| 激光SLAM | 2D/3D LiDAR | 精度高、不受光照影响 | 成本高、稀疏 | LOAM, Cartographer |
| 多传感器融合 | 相机+LiDAR+IMU | 鲁棒、互补 | 标定复杂、计算量大 | LVI-SAM, R3LIVE |
4.3 按地图表示分类
根据地图的稠密程度:
- 稀疏SLAM:仅估计路标点位置(ORB-SLAM),适合定位
- 半稠密SLAM:估计边缘/梯度区域的深度(LSD-SLAM)
- 稠密SLAM:重建完整3D结构(ElasticFusion,KinectFusion)
五、SLAM的概率图模型
SLAM问题可以用概率图模型优雅地表示。理解图模型是理解现代SLAM算法的基础。
5.1 动态贝叶斯网络
时刻: 0 1 2 3 ... T
控制: u₁ ──→ u₂ ──→ u₃ ──→ u₄ ──→ ... ──→ uT
位姿: x₀ ──→ x₁ ──→ x₂ ──→ x₃ ──→ ... ──→ xT
│ │ │ │ │
↓ ↓ ↓ ↓ ↓
观测: z₁ z₂ z₃ z₄ ... zT
│ │ │ │ │
↓ ↓ ↓ ↓ ↓
地图: m₁ m₂ m₁ m₂ m₁ m₂ m₁ m₂ ... m₁ m₂
↕ ↕ ↕ ↕ ↕
关联: [c₁] [c₂] [c₃] [c₄] ... [cT]
这个动态贝叶斯网络展示了SLAM的核心结构:
- 水平方向:运动模型 p(xt | xt-1, ut)
- 垂直方向:观测模型 p(zt | xt, m)
- 路标关联 ct:数据关联变量,决定观测对应哪个路标
5.2 因子图表示
在现代SLAM中,更常用因子图来表示优化问题:
SLAM的最大后验估计等价于最小二乘:
x*0:T, m* = argmin { ∑t=1T ||xt - f(xt-1, ut)||²Rt
+ ∑t=1T ∑j∈Jt ||ztj - h(xt, mj)||²Qt }
每个 ||·||²Σ = (·)T Σ-1 (·) 是马氏距离。
这个目标函数对应一个因子图:
• 先验因子:锚定 x₀
• 运动因子:连接 xt-1 → xt,信息矩阵 ΣRt
• 观测因子:连接 xt → mj,信息矩阵 ΣQt
六、SLAM的可观测性与一致性
6.1 可观测性
SLAM问题是否可解,取决于系统是否可观测:
可观测性定义:
系统在轨迹 x0:T 上可观测,当且仅当:
对于任意两个不同状态 (x0:T, m) ≠ (x'0:T, m'),
存在至少一个观测 zt 使得 p(zt | xt, m) ≠ p(zt | x't, m')。
单目SLAM的不可观测自由度:
• 7-DoF:6-DoF位姿 + 1-DoF全局尺度
• 绝对尺度无法从单目观测中恢复
• 需要初始化或先验来固定尺度
6.2 一致性
SLAM估计的一致性指估计的不确定性是否真实反映了误差大小:
一致性定义:
估计 (x̂, Σ̂) 是一致的,当且仅当:
(x - x̂)T Σ̂-1 (x - x̂) 服从 χ²(n) 分布
其中 n 是状态维度。
EKF-SLAM的不一致性问题:
• 线性化误差导致协方差低估
• 长时间运行后变得过度自信
• 解决方案:UKF、迭代EKF、或切换到优化方法
七、代码实践:简单EKF-SLAM仿真
下面实现一个2D EKF-SLAM仿真,包含完整的预测和更新步骤,验证SLAM的基本原理。
import numpy as np
import matplotlib.pyplot as plt
# ============================================================
# 2D EKF-SLAM Simulation
# State: [x, y, θ, m_x1, m_y1, ..., m_xM, m_yM]
# ============================================================
class EKFSlam2D:
def __init__(self, n_landmarks=10):
# State: [x, y, θ, lx1, ly1, ..., lxM, lyM]
self.n_lm = n_landmarks
self.dim = 3 + 2 * n_landmarks
# State vector and covariance
self.x = np.zeros(self.dim)
self.P = np.zeros((self.dim, self.dim))
# Initial pose uncertainty
self.P[0:3, 0:3] = np.diag([0.01, 0.01, 0.001])
# Landmark uncertainty: large initially
self.P[3:, 3:] = np.eye(2 * n_landmarks) * 1000.0
# Motion noise
self.R = np.diag([0.05, 0.05, 0.01]) # [Δx, Δy, Δθ]
# Observation noise
self.Q = np.diag([0.1, 0.05]) # [range, bearing]
# Track which landmarks have been observed
self.observed = [False] * n_landmarks
def predict(self, u):
"""EKF predict step with motion model u=[Δx, Δy, Δθ]
x_new = x + R(θ) * [Δx, Δy]^T
θ_new = θ + Δθ
"""
dx, dy, dtheta = u
theta = self.x[2]
# Compute Jacobian of motion model w.r.t. pose
# x' = x + cos(θ)*Δx - sin(θ)*Δy
# y' = y + sin(θ)*Δx + cos(θ)*Δy
# θ' = θ + Δθ
Fx = np.zeros((3, self.dim))
Fx[0:3, 0:3] = np.eye(3)
# Jacobian of motion w.r.t. [x, y, θ]
Gx = np.array([
[1, 0, -np.sin(theta)*dx - np.cos(theta)*dy],
[0, 1, np.cos(theta)*dx - np.sin(theta)*dy],
[0, 0, 1]
])
# Jacobian of motion w.r.t. control noise
V = np.array([
[np.cos(theta), -np.sin(theta), 0],
[np.sin(theta), np.cos(theta), 0],
[0, 0, 1]
])
# Update state mean
self.x[0] += np.cos(theta) * dx - np.sin(theta) * dy
self.x[1] += np.sin(theta) * dx + np.cos(theta) * dy
self.x[2] += dtheta
# Update covariance: P' = G*P*G^T + V*R*V^T
G = np.eye(self.dim)
G[0:3, 0:3] = Gx
self.P = G @ self.P @ G.T + Fx.T @ V @ self.R @ V.T @ Fx
def update(self, z_list):
"""EKF update with landmark observations.
z_list: [(landmark_id, range, bearing), ...]
"""
for lm_id, r_obs, b_obs in z_list:
lx_idx = 3 + 2 * lm_id
ly_idx = 4 + 2 * lm_id
# Predicted landmark position
lx = self.x[lx_idx]
ly = self.x[ly_idx]
dx = lx - self.x[0]
dy = ly - self.x[1]
q = dx**2 + dy**2
sqrt_q = np.sqrt(q)
# Predicted observation: [range, bearing]
r_pred = sqrt_q
b_pred = np.arctan2(dy, dx) - self.x[2]
# Innovation
innov = np.array([r_obs - r_pred,
np.arctan2(np.sin(b_obs - b_pred),
np.cos(b_obs - b_pred))])
# Jacobian H = dh/dstate
H = np.zeros((2, self.dim))
# dh/d[x, y, θ]
H[0, 0] = -dx / sqrt_q
H[0, 1] = -dy / sqrt_q
H[1, 0] = dy / q
H[1, 1] = -dx / q
H[1, 2] = -1
# dh/d[lx, ly]
H[0, lx_idx] = dx / sqrt_q
H[0, ly_idx] = dy / sqrt_q
H[1, lx_idx] = -dy / q
H[1, ly_idx] = dx / q
# Kalman gain
S = H @ self.P @ H.T + self.Q
K = self.P @ H.T @ np.linalg.inv(S)
# State update
self.x += K @ innov
# Covariance update: P' = (I - K*H)*P
I_KH = np.eye(self.dim) - K @ H
self.P = I_KH @ self.P
self.observed[lm_id] = True
# ============================================================
# Simulation
# ============================================================
np.random.seed(42)
# Generate landmarks in a room
n_landmarks = 8
landmarks = np.array([
[5, 0], [5, 5], [0, 5], [-5, 5],
[-5, 0], [-5, -5], [0, -5], [5, -5]
], dtype=float)
# Generate robot trajectory (circular path)
T = 100
dt = 0.1
true_traj = []
for t in range(T):
theta = 2 * np.pi * t / T
x = 3 * np.cos(theta)
y = 3 * np.sin(theta)
true_traj.append([x, y, theta + np.pi/2])
true_traj = np.array(true_traj)
# Initialize EKF-SLAM
slam = EKFSlam2D(n_landmarks)
slam.x[0:3] = true_traj[0]
# Run SLAM
est_traj = []
for t in range(1, T):
# True motion
prev = true_traj[t-1]
curr = true_traj[t]
dx = (curr[0] - prev[0])*np.cos(prev[2]) + (curr[1] - prev[1])*np.sin(prev[2])
dy = -(curr[0] - prev[0])*np.sin(prev[2]) + (curr[1] - prev[1])*np.cos(prev[2])
dtheta = curr[2] - prev[2]
u = np.array([dx, dy, dtheta])
# Add motion noise
u_noisy = u + np.random.multivariate_normal(np.zeros(3), slam.R * 0.5)
# Predict
slam.predict(u_noisy)
# Observe landmarks within range
z_list = []
for j in range(n_landmarks):
dx_lm = landmarks[j, 0] - curr[0]
dy_lm = landmarks[j, 1] - curr[1]
r = np.sqrt(dx_lm**2 + dy_lm**2)
if r < 8.0: # max observation range
b = np.arctan2(dy_lm, dx_lm) - curr[2]
r_noisy = r + np.random.randn() * 0.1
b_noisy = b + np.random.randn() * 0.05
z_list.append((j, r_noisy, b_noisy))
# Update
if z_list:
slam.update(z_list)
est_traj.append(slam.x[0:3].copy())
est_traj = np.array(est_traj)
# Plot results
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# Trajectory comparison
axes[0].plot(true_traj[:,0], true_traj[:,1], 'b-', label='Ground Truth', linewidth=2)
axes[0].plot(est_traj[:,0], est_traj[:,1], 'r--', label='EKF-SLAM Estimate', linewidth=2)
axes[0].scatter(landmarks[:,0], landmarks[:,1], c='green', s=100, marker='*', label='True Landmarks', zorder=5)
est_lm = slam.x[3:].reshape(-1, 2)
axes[0].scatter(est_lm[:,0], est_lm[:,1], c='red', s=50, marker='o', label='Estimated Landmarks', alpha=0.7)
axes[0].set_xlabel('X (m)')
axes[0].set_ylabel('Y (m)')
axes[0].set_title('EKF-SLAM: Trajectory & Landmarks')
axes[0].legend()
axes[0].grid(True, alpha=0.3)
axes[0].set_aspect('equal')
# Position error over time
pos_error = np.sqrt((true_traj[1:,0] - est_traj[:,0])**2 +
(true_traj[1:,1] - est_traj[:,1])**2)
axes[1].plot(pos_error, 'b-', linewidth=1)
axes[1].set_xlabel('Time Step')
axes[1].set_ylabel('Position Error (m)')
axes[1].set_title('EKF-SLAM Localization Error')
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('ekf_slam_result.png', dpi=150)
plt.show()
# Print landmark estimation error
for j in range(n_landmarks):
err = np.linalg.norm(est_lm[j] - landmarks[j])
print(f"Landmark {j}: error = {err:.3f} m")
八、ROS2集成:SLAM仿真框架
下面创建一个ROS2节点来模拟简单的2D SLAM系统,展示传感器数据发布、TF变换和地图服务。
# slam_sim_node.py - ROS2 2D SLAM Simulation Node
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import PoseStamped, Twist
from nav_msgs.msg import OccupancyGrid, Odometry
from sensor_msgs.msg import LaserScan
from tf2_ros import TransformBroadcaster
from geometry_msgs.msg import TransformStamped
import numpy as np
class SlamSimNode(Node):
def __init__(self):
super().__init__('slam_sim_node')
# Robot state
self.robot_pose = np.array([0.0, 0.0, 0.0]) # x, y, theta
self.dt = 0.1 # 10 Hz update rate
# Subscribers
self.cmd_vel_sub = self.create_subscription(
Twist, '/cmd_vel', self.cmd_vel_callback, 10)
# Publishers
self.odom_pub = self.create_publisher(Odometry, '/odom', 10)
self.scan_pub = self.create_publisher(LaserScan, '/scan', 10)
self.map_pub = self.create_publisher(OccupancyGrid, '/map', 10)
self.pose_pub = self.create_publisher(PoseStamped, '/slam_pose', 10)
# TF broadcaster
self.tf_broadcaster = TransformBroadcaster(self)
# Timer for simulation loop
self.timer = self.create_timer(self.dt, self.simulation_step)
# Simple map (walls at boundaries)
self.map_resolution = 0.05 # m/pixel
self.map_width = 200
self.map_height = 200
self.grid = np.zeros((self.map_height, self.map_width), dtype=np.int8)
# Add boundary walls
self.grid[0, :] = 100
self.grid[-1, :] = 100
self.grid[:, 0] = 100
self.grid[:, -1] = 100
self.get_logger().info('SLAM simulation node started')
def cmd_vel_callback(self, msg):
# Apply velocity to update robot pose
v = msg.linear.x
omega = msg.angular.z
theta = self.robot_pose[2]
# Differential drive model
self.robot_pose[0] += v * np.cos(theta) * self.dt
self.robot_pose[1] += v * np.sin(theta) * self.dt
self.robot_pose[2] += omega * self.dt
def simulation_step(self):
# Publish odometry
self.publish_odometry()
# Publish simulated laser scan
self.publish_scan()
# Publish map
self.publish_map()
# Broadcast TF
self.broadcast_tf()
def publish_odometry(self):
odom = Odometry()
odom.header.stamp = self.get_clock().now().to_msg()
odom.header.frame_id = 'odom'
odom.child_frame_id = 'base_link'
odom.pose.pose.position.x = float(self.robot_pose[0])
odom.pose.pose.position.y = float(self.robot_pose[1])
# Orientation from yaw
from tf_transformations import quaternion_from_euler
q = quaternion_from_euler(0, 0, self.robot_pose[2])
odom.pose.pose.orientation.x = q[0]
odom.pose.pose.orientation.y = q[1]
odom.pose.pose.orientation.z = q[2]
odom.pose.pose.orientation.w = q[3]
self.odom_pub.publish(odom)
def publish_scan(self):
scan = LaserScan()
scan.header.stamp = self.get_clock().now().to_msg()
scan.header.frame_id = 'laser'
scan.angle_min = -np.pi
scan.angle_max = np.pi
scan.angle_increment = np.pi / 180 # 1 degree resolution
scan.range_min = 0.1
scan.range_max = 10.0
n_beams = 360
scan.ranges = [10.0] * n_beams # default: max range
# Simulate wall detections (simplified)
for i in range(n_beams):
angle = self.robot_pose[2] + scan.angle_min + i * scan.angle_increment
# Ray-cast to boundary walls at ±5m
for wall_dist in [5.0]:
dx = np.cos(angle) * wall_dist
dy = np.sin(angle) * wall_dist
wx = self.robot_pose[0] + dx
wy = self.robot_pose[1] + dy
if abs(wx) >= 5.0 or abs(wy) >= 5.0:
r = min(5.0/max(abs(np.cos(angle)), 1e-6),
5.0/max(abs(np.sin(angle)), 1e-6))
scan.ranges[i] = min(r, 10.0) + np.random.randn()*0.02
self.scan_pub.publish(scan)
def publish_map(self):
map_msg = OccupancyGrid()
map_msg.header.stamp = self.get_clock().now().to_msg()
map_msg.header.frame_id = 'map'
map_msg.info.resolution = self.map_resolution
map_msg.info.width = self.map_width
map_msg.info.height = self.map_height
map_msg.info.origin.position.x = -5.0
map_msg.info.origin.position.y = -5.0
map_msg.info.origin.orientation.w = 1.0
map_msg.data = self.grid.flatten().tolist()
self.map_pub.publish(map_msg)
def broadcast_tf(self):
t = TransformStamped()
t.header.stamp = self.get_clock().now().to_msg()
t.header.frame_id = 'odom'
t.child_frame_id = 'base_link'
t.transform.translation.x = float(self.robot_pose[0])
t.transform.translation.y = float(self.robot_pose[1])
from tf_transformations import quaternion_from_euler
q = quaternion_from_euler(0, 0, self.robot_pose[2])
t.transform.rotation.x = q[0]
t.transform.rotation.y = q[1]
t.transform.rotation.z = q[2]
t.transform.rotation.w = q[3]
self.tf_broadcaster.sendTransform(t)
def main(args=None):
rclpy.init(args=args)
node = SlamSimNode()
rclpy.spin(node)
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
8.1 Launch文件
<!-- slam_sim.launch.py -->
<launch>
<node pkg="slam_course" exec="slam_sim_node" name="slam_sim" output="screen">
<param name="map_resolution" value="0.05"/>
<param name="max_range" value="10.0"/>
</node>
<node pkg="rviz2" exec="rviz2" name="rviz2"
args="-d $(find-pkg-share slam_course)/config/slam.rviz"/>
</launch>
九、Gazebo仿真实验
配置Gazebo仿真环境来测试SLAM算法:
<!-- gazebo_slam.world - Simple room for SLAM testing -->
<?xml version="1.0"?>
<sdf version="1.6">
<world name="slam_test_room">
<include>
<uri>model://ground_plane</uri>
</include>
<include>
<uri>model://sun</uri>
</include>
<!-- Room walls (10m x 10m) -->
<model name="room">
<static>true</static>
<!-- North wall -->
<link name="north_wall">
<pose>0 5 1 0 0 0</pose>
<collision name="collision">
<geometry><box><size>10 0.2 2</size></box></geometry>
</collision>
<visual name="visual">
<geometry><box><size>10 0.2 2</size></box></geometry>
</visual>
</link>
<!-- Add South, East, West walls similarly -->
</model>
<!-- Obstacles -->
<model name="obstacle_1">
<static>true</static>
<link name="link">
<pose>2 2 0.5 0 0 0</pose>
<collision name="collision">
<geometry><cylinder><radius>0.3</radius><height>1</height></cylinder></geometry>
</collision>
<visual name="visual">
<geometry><cylinder><radius>0.3</radius><height>1</height></cylinder></geometry>
</visual>
</link>
</model>
</world>
</sdf>
十、SLAM发展历史与趋势
| 年代 | 里程碑 | 方法 |
| 1986 | Smith, Self, Cheeseman | 首次提出随机地图估计 |
| 1991 | Leonard, Durrant-Whyte | 将SLAM形式化为同时定位与建图 |
| 2002 | Montemerlo et al. | FastSLAM: 粒子滤波+Rao-Blackwellized |
| 2006 | Grisetti et al. | GMapping: 自适应粒子滤波2D SLAM |
| 2008 | Kuemmerle et al. | g2o: 通用图优化框架 |
| 2011 | Engel et al. | LSD-SLAM: 半稠密直接法 |
| 2015 | Mur-Artal et al. | ORB-SLAM: 特征法视觉SLAM标杆 |
| 2016 | Zhang & Singh | LOAM: 激光里程计与建图 |
| 2018 | Shan et al. | LeGO-LOAM: 轻量级地面优化LOAM |
| 2020 | Campos et al. | ORB-SLAM3: 多地图多传感器统一框架 |
| 2021 | Shan et al. | LVI-SAM: 激光视觉惯性融合 |
| 2023 | Various | Gaussian Splatting SLAM: 3DGS表示 |
十一、练习
练习1:EKF-SLAM参数敏感性分析
修改上述EKF-SLAM仿真代码,分析以下参数对SLAM性能的影响:
- 将运动噪声 R 增大5倍,观察轨迹估计误差如何变化
- 将观测噪声 Q 增大5倍,观察路标估计误差如何变化
- 将观测范围从8m减小到4m,观察SLAM何时"失效"
- 绘制不同参数下的NEES(Normalized Estimation Error Squared)曲线,验证一致性
提示:NEES = (x - x̂)ᵀ Σ⁻¹ (x - x̂),一致时NEES应接近状态维度n
练习2:数据关联挑战
在仿真中加入数据关联错误:
- 以5%的概率将观测关联到错误的路标ID
- 观察EKF-SLAM的估计如何发散
- 实现一个基于马氏距离的数据关联门限(χ²检验),拒绝离群关联
- 比较有无门限的SLAM性能差异
练习3:ROS2 SLAM仿真
基于上面的ROS2节点:
- 使用ros2 run运行节点,用rviz2可视化
- 编写一个控制节点,让机器人沿正方形路径运动
- 记录/odom和/scan话题数据到rosbag
- 回放rosbag数据,观察SLAM输出
练习4:SLAM问题维度分析
计算以下SLAM场景的状态空间维度,并分析EKF-SLAM的计算复杂度:
- 100步轨迹,20个2D路标
- 1000步轨迹,200个2D路标
- 100步轨迹,50个3D路标(每个路标3维)
EKF-SLAM每步的复杂度为O(M²),其中M为路标数。分析在什么规模下EKF-SLAM不再可行。