第五章 SLAM与导航
5.1 SLAM原理
5.1.1 SLAM基本概念
SLAM(Simultaneous Localization and Mapping)即同时定位与建图,解决"在未知环境中,机器人如何通过传感器确定自身位置并构建环境地图"的问题。
SLAM框架:
传感器数据 → 前端(数据关联) → 后端(优化) → 地图
↓ ↓
位置估计 ← 回环检测
核心问题:
- 机器人不知道自己位置
- 地图未知
- 两者相互依赖
5.1.2 前端:特征提取与匹配
激光SLAM前端:
class LaserSLAMFrontend:
def __init__(self):
self.current_scan = None
self.previous_scan = None
def extract_features(self, scan):
"""提取角点特征"""
features = []
for i in range(1, len(scan) - 1):
# 计算曲率
diff = scan[i-1] + scan[i+1] - 2*scan[i]
curvature = abs(diff)
if curvature > threshold:
features.append(i)
return features
def match_scans(self, scan1, scan2):
"""扫描匹配(ICP)"""
# 转换为点云
points1 = self.scan_to_points(scan1)
points2 = self.scan_to_points(scan2)
# ICP配准
R, t = self.icp(points1, points2)
return R, t
def scan_to_points(self, scan):
"""扫描转点云"""
angles = np.linspace(-np.pi, np.pi, len(scan))
x = scan * np.cos(angles)
y = scan * np.sin(angles)
return np.column_stack([x, y])
视觉SLAM前端:
class VisualSLAMFrontend:
def __init__(self):
self.detector = cv2.ORB_create(1000)
self.matcher = cv2.BFMatcher(cv2.NORM_HAMMING)
def extract_features(self, image):
"""提取特征点"""
kp, desc = self.detector.detectAndCompute(image, None)
return kp, desc
def match_frames(self, desc1, desc2):
"""帧间匹配"""
matches = self.matcher.knnMatch(desc1, desc2, k=2)
# Lowe's ratio test
good_matches = []
for m, n in matches:
if m.distance < 0.75 * n.distance:
good_matches.append(m)
return good_matches
def estimate_motion(self, kp1, kp2, matches, K):
"""估计相机运动"""
# 提取匹配点
pts1 = np.float32([kp1[m.queryIdx].pt for m in matches])
pts2 = np.float32([kp2[m.trainIdx].pt for m in matches])
# 本质矩阵
E, mask = cv2.findEssentialMat(pts1, pts2, K)
# 恢复位姿
_, R, t, mask = cv2.recoverPose(E, pts1, pts2, K)
return R, t
5.1.3 后端:图优化
位姿图:
import g2o
class PoseGraphOptimizer:
def __init__(self):
self.optimizer = g2o.SparseOptimizer()
solver = g2o.BlockSolverSE2(g2o.LinearSolverCholmodSE2())
solver = g2o.OptimizationAlgorithmLevenberg(solver)
self.optimizer.set_algorithm(solver)
def add_vertex(self, id, pose):
"""添加位姿节点"""
v = g2o.VertexSE2()
v.set_id(id)
v.set_estimate(pose)
if id == 0:
v.set_fixed(True) # 固定第一个节点
self.optimizer.add_vertex(v)
def add_edge(self, id1, id2, measurement, information):
"""添加约束边"""
edge = g2o.EdgeSE2()
edge.set_vertex(0, self.optimizer.vertex(id1))
edge.set_vertex(1, self.optimizer.vertex(id2))
edge.set_measurement(measurement)
edge.set_information(information)
self.optimizer.add_edge(edge)
def optimize(self, iterations=10):
"""执行优化"""
self.optimizer.initialize_optimization()
self.optimizer.optimize(iterations)
def get_pose(self, id):
"""获取优化后位姿"""
return self.optimizer.vertex(id).estimate()
5.1.4 回环检测
class LoopClosureDetector:
def __init__(self):
self.keyframes = []
self.descriptors = []
def add_keyframe(self, image, pose):
"""添加关键帧"""
# 提取BoW特征
desc = self.extract_bow_features(image)
self.keyframes.append({'image': image, 'pose': pose})
self.descriptors.append(desc)
def detect_loop(self, current_image, current_id):
"""检测回环"""
current_desc = self.extract_bow_features(current_image)
candidates = []
# 搜索历史帧
for i in range(max(0, current_id - 50)): # 跳过最近的帧
similarity = self.compute_similarity(current_desc, self.descriptors[i])
if similarity > threshold:
candidates.append(i)
if candidates:
# 验证几何一致性
best_match = self.verify_geometry(current_image,
[self.keyframes[i]['image'] for i in candidates])
return best_match
return None
5.2 GMapping
5.2.1 GMapping原理
GMapping基于Rao-Blackwellized粒子滤波,将SLAM问题分解为:
- 定位问题(粒子滤波)
- 建图问题(栅格地图)
核心思想:
每个粒子维护:
- 机器人轨迹
- 对应的地图
权重计算:
w = p(z|m,x) * p(x|u,x')
重采样:
根据权重选择优秀粒子
5.2.2 GMapping配置
# gmapping_params.yaml
map_update_interval: 5.0
maxUrange: 6.0 # 最大有效距离
maxRange: 8.0 # 最大探测距离
sigma: 0.05
kernelSize: 1
lstep: 0.05
astep: 0.05
iterations: 5
lsigma: 0.075
ogain: 3.0
lskip: 0 # 跳帧数
# 粒子数
particles: 30
# 分辨率
delta: 0.05 # 地图分辨率(m)
# 更新参数
linearUpdate: 0.5 # 线性移动阈值
angularUpdate: 0.3 # 角度旋转阈值
# 似然度参数
llsamplerange: 0.01
llsamplestep: 0.01
lasamplerange: 0.005
lasamplestep: 0.005
5.2.3 GMapping launch文件
<launch>
<!-- GMapping节点 -->
<node name="slam_gmapping" pkg="gmapping" type="slam_gmapping" output="screen">
<!-- 坐标系 -->
<param name="base_frame" value="base_link"/>
<param name="odom_frame" value="odom"/>
<param name="map_frame" value="map"/>
<!-- 话题 -->
<remap from="scan" to="scan"/>
<!-- 加载参数 -->
<rosparam file="$(find my_robot)/config/gmapping_params.yaml" command="load"/>
</node>
<!-- RVIZ可视化 -->
<node name="rviz" pkg="rviz" type="rviz" args="-d $(find my_robot)/rviz/slam.rviz"/>
</launch>
5.2.4 地图保存与加载
# 保存地图
rosrun map_server map_saver -f my_map
# 生成文件:
# my_map.yaml (元数据)
# my_map.pgm (图像)
# 加载地图
rosrun map_server map_server my_map.yaml
地图元数据(my_map.yaml):
image: my_map.pgm
resolution: 0.050000
origin: [-10.0, -10.0, 0.0]
negate: 0
occupied_thresh: 0.65
free_thresh: 0.196
5.3 Cartographer
5.3.1 Cartographer原理
Cartographer使用子图(Submap)匹配和位姿图优化。
核心概念:
- 局部SLAM:扫描匹配 + 子图构建
- 全局SLAM:回环检测 + 位姿图优化
- 分支定界:快速扫描匹配
5.3.2 配置文件
cartographer.lua:
include "map_builder.lua"
include "trajectory_builder.lua"
options = {
map_builder = MAP_BUILDER,
trajectory_builder = TRAJECTORY_BUILDER,
map_frame = "map",
tracking_frame = "base_link",
published_frame = "odom",
odom_frame = "odom",
provide_odom_frame = false,
publish_frame_projected_to_2d = true,
use_odometry = true,
use_nav_sat = false,
use_landmarks = false,
num_laser_scans = 1,
num_multi_echo_laser_scans = 0,
num_subdivisions_per_laser_scan = 1,
num_point_clouds = 0,
lookup_transform_timeout_sec = 0.2,
submap_publish_period_sec = 0.3,
pose_publish_period_sec = 5e-3,
trajectory_publish_period_sec = 30e-3,
rangefinder_sampling_ratio = 1.,
odometry_sampling_ratio = 1.,
fixed_frame_pose_sampling_ratio = 1.,
imu_sampling_ratio = 1.,
landmarks_sampling_ratio = 1.,
}
TRAJECTORY_BUILDER_2D.use_imu_data = false
TRAJECTORY_BUILDER_2D.min_range = 0.1
TRAJECTORY_BUILDER_2D.max_range = 10.0
TRAJECTORY_BUILDER_2D.missing_data_ray_length = 5.0
MAP_BUILDER.use_trajectory_builder_2d = true
MAP_BUILDER.num_background_threads = 4
POSE_GRAPH.optimization_problem.huber_scale = 1e2
POSE_GRAPH.optimize_every_n_nodes = 90
POSE_GRAPH.constraint_builder.min_score = 0.65
return options
5.3.3 Launch文件
<launch>
<node name="cartographer_node" pkg="cartographer_ros"
type="cartographer_node" args="
-configuration_directory $(find my_robot)/config
-configuration_basename cartographer.lua"
output="screen">
<remap from="scan" to="scan"/>
</node>
<node name="cartographer_occupancy_grid_node" pkg="cartographer_ros"
type="cartographer_occupancy_grid_node" />
<node name="rviz" pkg="rviz" type="rviz"
args="-d $(find my_robot)/rviz/cartographer.rviz" />
</launch>
5.4 路径规划
5.4.1 Costmap配置
global_costmap.yaml:
global_costmap:
global_frame: map
robot_base_frame: base_link
update_frequency: 1.0
publish_frequency: 1.0
static_map: true
transform_tolerance: 0.5
# 障碍物层
obstacle_layer:
enabled: true
observation_sources: scan
scan:
data_type: LaserScan
topic: /scan
marking: true
clearing: true
min_obstacle_height: 0.0
max_obstacle_height: 2.0
# 膨胀层
inflation_layer:
enabled: true
inflation_radius: 0.5
cost_scaling_factor: 5.0
local_costmap.yaml:
local_costmap:
global_frame: odom
robot_base_frame: base_link
update_frequency: 5.0
publish_frequency: 5.0
static_map: false
rolling_window: true
width: 6.0
height: 6.0
resolution: 0.05
transform_tolerance: 0.5
plugins:
- {name: obstacle_layer, type: "costmap_2d::ObstacleLayer"}
- {name: inflation_layer, type: "costmap_2d::InflationLayer"}
5.4.2 全局规划器
Dijkstra/A*配置(base_global_planner):
base_global_planner: "global_planner/GlobalPlanner"
GlobalPlanner:
allow_unknown: true
default_tolerance: 0.0
visualize_potential: false
use_dijkstra: true # true=Dijkstra, false=A*
use_quadratic: true
use_grid_path: false
old_navfn_behavior: false
lethal_cost: 253
neutral_cost: 50
cost_factor: 3.0
publish_potential: true
orientation_mode: 0
orientation_window_size: 1
5.4.3 局部规划器(DWA)
dwa_local_planner.yaml:
DWALocalPlanner:
# 机器人配置
max_vel_x: 0.5
min_vel_x: 0.0
max_vel_y: 0.0
min_vel_y: 0.0
max_vel_trans: 0.5
min_vel_trans: 0.1
trans_stopped_vel: 0.1
max_vel_theta: 1.0
min_vel_theta: 0.4
theta_stopped_vel: 0.4
# 加速度限制
acc_lim_x: 2.5
acc_lim_y: 0.0
acc_lim_theta: 3.2
# 轨迹参数
sim_time: 1.5
sim_granularity: 0.025
vx_samples: 20
vy_samples: 0
vtheta_samples: 40
# 评分参数
path_distance_bias: 32.0
goal_distance_bias: 24.0
occdist_scale: 0.01
forward_point_distance: 0.325
stop_time_buffer: 0.2
scaling_speed: 0.25
max_scaling_factor: 0.2
# 振荡预防
oscillation_reset_dist: 0.05
oscillation_reset_angle: 0.2
5.4.4 TEB局部规划器
teb_local_planner.yaml:
TebLocalPlannerROS:
# 轨迹配置
teb_autosize: True
dt_ref: 0.3
dt_hysteresis: 0.1
max_samples: 500
global_plan_overwrite_orientation: True
allow_init_with_backwards_motion: False
max_global_plan_lookahead_dist: 3.0
global_plan_viapoint_sep: -1
global_plan_prune_distance: 1
exact_arc_length: False
feasibility_check_no_poses: 5
publish_feedback: False
# 机器人配置
max_vel_x: 0.5
max_vel_x_backwards: 0.2
max_vel_y: 0.0
max_vel_theta: 0.5
acc_lim_x: 0.5
acc_lim_theta: 0.5
min_turning_radius: 0.0
footprint_model:
type: "circular"
radius: 0.2
# 目标容忍度
xy_goal_tolerance: 0.2
yaw_goal_tolerance: 0.1
free_goal_vel: False
# 障碍物
min_obstacle_dist: 0.25
inflation_dist: 0.5
include_costmap_obstacles: True
costmap_obstacles_behind_robot_dist: 1.5
obstacle_poses_affected: 15
dynamic_obstacle_inflation_dist: 0.6
include_dynamic_obstacles: True
# 优化
no_inner_iterations: 5
no_outer_iterations: 4
optimization_activate: True
optimization_verbose: False
penalty_epsilon: 0.1
obstacle_cost_exponent: 4
weight_max_vel_x: 2
weight_max_vel_theta: 1
weight_acc_lim_x: 1
weight_acc_lim_theta: 1
weight_kinematics_nh: 1000
weight_kinematics_forward_drive: 1
weight_kinematics_turning_radius: 1
weight_optimaltime: 1
weight_shortest_path: 0
weight_obstacle: 100
weight_inflation: 0.2
weight_dynamic_obstacle: 10
weight_dynamic_obstacle_inflation: 0.2
weight_viapoint: 1
weight_adapt_factor: 2
5.5 自主导航
5.5.1 move_base配置
move_base.launch:
<launch>
<!-- move_base节点 -->
<node pkg="move_base" type="move_base" respawn="false" name="move_base" output="screen">
<!-- 参数文件 -->
<rosparam file="$(find my_robot)/config/costmap_common_params.yaml" command="load" ns="global_costmap" />
<rosparam file="$(find my_robot)/config/costmap_common_params.yaml" command="load" ns="local_costmap" />
<rosparam file="$(find my_robot)/config/global_costmap_params.yaml" command="load" />
<rosparam file="$(find my_robot)/config/local_costmap_params.yaml" command="load" />
<rosparam file="$(find my_robot)/config/base_local_planner_params.yaml" command="load" />
<!-- 全局规划器 -->
<param name="base_global_planner" value="global_planner/GlobalPlanner"/>
<!-- 局部规划器 -->
<param name="base_local_planner" value="dwa_local_planner/DWAPlannerROS"/>
<!-- 话题重映射 -->
<remap from="cmd_vel" to="cmd_vel"/>
<remap from="odom" to="odom"/>
<remap from="scan" to="scan"/>
</node>
</launch>
5.5.2 AMCL定位
amcl.launch:
<launch>
<!-- 地图服务器 -->
<node name="map_server" pkg="map_server" type="map_server" args="$(find my_robot)/maps/my_map.yaml"/>
<!-- AMCL节点 -->
<node pkg="amcl" type="amcl" name="amcl" output="screen">
<!-- 坐标系 -->
<param name="odom_frame_id" value="odom"/>
<param name="odom_model_type" value="diff"/>
<param name="base_frame_id" value="base_link"/>
<param name="global_frame_id" value="map"/>
<!-- 粒子滤波器 -->
<param name="min_particles" value="500"/>
<param name="max_particles" value="5000"/>
<param name="kld_err" value="0.05"/>
<param name="kld_z" value="0.99"/>
<!-- 更新阈值 -->
<param name="update_min_d" value="0.2"/>
<param name="update_min_a" value="0.5"/>
<!-- 重采样 -->
<param name="resample_interval" value="1"/>
<!-- 激光模型 -->
<param name="laser_model_type" value="likelihood_field"/>
<param name="laser_likelihood_max_dist" value="2.0"/>
<param name="laser_max_range" value="10.0"/>
<param name="laser_min_range" value="0.1"/>
<param name="laser_max_beams" value="180"/>
<!-- 运动模型 -->
<param name="odom_alpha1" value="0.2"/>
<param name="odom_alpha2" value="0.2"/>
<param name="odom_alpha3" value="0.2"/>
<param name="odom_alpha4" value="0.2"/>
<!-- 初始位姿 -->
<param name="initial_pose_x" value="0.0"/>
<param name="initial_pose_y" value="0.0"/>
<param name="initial_pose_a" value="0.0"/>
</node>
</launch>
5.5.3 导航Python接口
#!/usr/bin/env python
import rospy
import actionlib
from move_base_msgs.msg import MoveBaseAction, MoveBaseGoal
from geometry_msgs.msg import PoseStamped
class NavigationController:
def __init__(self):
rospy.init_node('navigation_controller')
# Action客户端
self.client = actionlib.SimpleActionClient('move_base', MoveBaseAction)
self.client.wait_for_server()
def goto(self, x, y, theta):
"""导航到目标点"""
goal = MoveBaseGoal()
goal.target_pose.header.frame_id = "map"
goal.target_pose.header.stamp = rospy.Time.now()
goal.target_pose.pose.position.x = x
goal.target_pose.pose.position.y = y
goal.target_pose.pose.orientation.z = np.sin(theta / 2)
goal.target_pose.pose.orientation.w = np.cos(theta / 2)
rospy.loginfo(f"导航到: ({x}, {y}, {theta})")
self.client.send_goal(goal)
# 等待结果
wait = self.client.wait_for_result()
if not wait:
rospy.logerr("Action服务器不可用")
return False
else:
state = self.client.get_state()
if state == actionlib.GoalStatus.SUCCEEDED:
rospy.loginfo("导航成功!")
return True
else:
rospy.logwarn("导航失败")
return False
def cancel(self):
"""取消导航"""
self.client.cancel_goal()
def patrol(self, waypoints):
"""巡逻"""
for i, (x, y, theta) in enumerate(waypoints):
rospy.loginfo(f"前往航点 {i+1}/{len(waypoints)}")
success = self.goto(x, y, theta)
if not success:
rospy.logwarn(f"航点{i+1}导航失败")
break
rospy.sleep(2) # 停留2秒
# 使用示例
if __name__ == '__main__':
nav = NavigationController()
# 单点导航
nav.goto(x=2.0, y=3.0, theta=0.0)
# 巡逻
waypoints = [
(1.0, 1.0, 0.0),
(2.0, 1.0, 1.57),
(2.0, 2.0, 3.14),
(1.0, 2.0, -1.57)
]
nav.patrol(waypoints)
5.6 高频面试题
问题1: SLAM中的前端和后端分别做什么?
答案:
前端(Frontend):
- 功能: 数据关联和初步位姿估计
- 任务:
- 特征提取(激光角点/视觉特征点)
- 数据匹配(帧间匹配/扫描匹配)
- 运动估计(里程计)
- 局部地图构建
后端(Backend):
- 功能: 全局优化和一致性维护
- 任务:
- 位姿图优化(Graph Optimization)
- 回环检测(Loop Closure Detection)
- 全局地图优化
- 误差分配
对比:
前端: 快速、局部、增量式
后端: 慢速、全局、批处理
前端输出 → 后端输入
初步位姿 → 精确位姿
问题2: GMapping和Cartographer有什么区别?
答案:
| 特性 | GMapping | Cartographer |
|---|---|---|
| 算法 | 粒子滤波 | 子图匹配+图优化 |
| 地图类型 | 2D栅格 | 2D/3D |
| 计算量 | 中 | 高 |
| 精度 | 中 | 高 |
| 大场景 | 一般 | 好 |
| 回环检测 | 弱 | 强 |
| 传感器 | 激光+里程计 | 多传感器融合 |
| 实时性 | 好 | 一般 |
选择建议:
- 小场景、实时性要求高: GMapping
- 大场景、精度要求高: Cartographer
- 3D SLAM: Cartographer
问题3: 什么是Costmap?如何配置障碍物膨胀?
答案:
Costmap定义: 代价地图,用数值表示每个栅格的通行代价:
0: 完全自由
1-252: 部分障碍(膨胀区)
253: 内切障碍
254: 致命障碍
255: 未知
膨胀配置:
inflation_layer:
enabled: true
inflation_radius: 0.5 # 膨胀半径
cost_scaling_factor: 10.0 # 代价衰减系数
代价计算:
cost = 253 * exp(-k * distance)
其中 k = cost_scaling_factor
多层Costmap:
最终代价 = max(静态层, 障碍层, 膨胀层)
1. 静态层: 来自地图
2. 障碍层: 传感器实时检测
3. 膨胀层: 安全距离膨胀
参数调优:
inflation_radius大: 安全但路径长cost_scaling_factor大: 代价衰减快,敢于靠近障碍物
问题4: 导航中的恢复行为(Recovery Behavior)是什么?
答案:
当机器人被困时,move_base会执行恢复行为:
默认恢复序列:
1. 清除costmap
2. 原地旋转
3. 再次清除costmap
4. 再次原地旋转
5. 放弃
配置:
recovery_behavior_enabled: true
recovery_behaviors:
- name: 'conservative_reset'
type: 'clear_costmap_recovery/ClearCostmapRecovery'
- name: 'rotate_recovery'
type: 'rotate_recovery/RotateRecovery'
- name: 'aggressive_reset'
type: 'clear_costmap_recovery/ClearCostmapRecovery'
# 参数
conservative_reset:
reset_distance: 3.0
rotate_recovery:
sim_granularity: 0.017
frequency: 20.0
自定义恢复:
class CustomRecovery:
def execute(self):
# 1. 后退
self.move_backward(0.5) # 后退0.5m
# 2. 旋转
self.rotate(np.pi) # 转180度
# 3. 重新规划
self.replan()
问题5: 如何提高导航的成功率?
答案:
1. 硬件优化
- 提高传感器质量(激光雷达精度)
- 增加传感器冗余(前后激光)
- 改善底盘响应性(PID调参)
2. 地图质量
- 建图时速度慢、匀速
- 多次建图取最优
- 手动编辑地图(去除噪点)
3. Costmap调优
# 膨胀半径适中
inflation_radius: 0.3 # 机器人半径+安全余量
# 障碍物检测
obstacle_range: 2.5 # 检测范围
raytrace_range: 3.0 # 清除范围
# 更新频率
update_frequency: 5.0 # 提高响应
4. 规划器参数
# DWA
path_distance_bias: 32.0 # 增加路径跟踪权重
goal_distance_bias: 24.0 # 目标吸引力
occdist_scale: 0.01 # 障碍物代价
# TEB
min_obstacle_dist: 0.25 # 最小障碍物距离
weight_obstacle: 50 # 障碍物权重
5. 容忍度设置
# 目标容忍度
xy_goal_tolerance: 0.1 # 位置容忍
yaw_goal_tolerance: 0.1 # 角度容忍
# 路径容忍度
controller_patience: 5.0 # 控制器超时
planner_patience: 5.0 # 规划器超时
6. 动态调整
def adaptive_navigation(distance_to_goal):
if distance_to_goal > 5.0:
# 远距离: 快速
set_max_vel(0.8)
set_tolerance(0.2)
else:
# 近距离: 精确
set_max_vel(0.3)
set_tolerance(0.05)
7. 多传感器融合
- 激光雷达: 远距离障碍
- 深度相机: 低矮障碍
- 超声波: 盲区补充
- IMU: 姿态补偿
8. 异常处理
class RobustNavigation:
def navigate_with_retry(self, goal, max_retries=3):
for attempt in range(max_retries):
result = self.goto(goal)
if result == SUCCESS:
return True
elif result == BLOCKED:
# 被困,换路径
self.find_alternative_path()
elif result == TIMEOUT:
# 超时,降低速度
self.reduce_speed()
return False # 失败