做AGV(自动导引车)的朋友应该都有同感:硬件装好只是第一步,真正让人挠头的往往是软件那一大串——激光SLAM怎么建图、路径规划怎么调、避障代码怎么写。这篇文章我就用Python+ROS这套组合,把从激光SLAM建图到自主导航避障的完整链路拆开讲清楚。内容适合正在做AGV样机、服务机器人或想入门机器人导航的开发者,也适合那些已经跑通demo、但对参数怎么调心里没底的工程师。我会把我实际踩过的坑和常用代码直接放出来,按步骤操作就能跑。
1. 项目整体设计与方案选型
1.1 为什么选ROS+Python这套组合
选ROS的原因很直白:它把传感器驱动、定位、规划、可视化这些模块都做成了标准化的节点和话题,你不用从零造轮子。激光雷达驱动、map_server、amcl、move_base这些都是现成的,拿来就能拼。Python作为主开发语言,主要是图它的开发效率,调参数和改逻辑非常快,尤其在避障代码这种“规则为主”的模块上,Python写起来比C++省一半代码,阅读和维护也轻松得多。ROS Noetic 开始对 Python 3 的支持已经很完善,直接用 Python 3 写节点没有历史包袱。当然,如果你做的是实时性要求极高的底层控制,再用 C++ 写插件也不迟,导航上层用 Python 完全够用。
1.2 硬件方案:雷达、底盘和计算平台怎么搭
激光SLAM导航第一步是选好传感器和底盘。2D激光雷达(单线)是目前最常见的选择,价格从几百到几千都有,室内AGV用单线雷达完全够。底盘结构建议优先选差速驱动,控制简单、原地旋转方便,做路径规划时的运动模型也容易理解;全向轮机器人(麦克纳姆轮)更灵活,但控制代码和参数调试会复杂一些;阿克曼转向底盘做的是汽车运动,导航里要加很多运动约束,新手不建议碰。计算平台方面,Jetson Orin Nano、树莓派4B、x86工控机都可以,关键看你要跑什么算法:只用gmapping建图,树莓派4B也能勉强应付;如果上cartographer,建议还是Jetson或x86,内存和CPU会更从容。
一个比较典型的室内AGV硬件配置大概是这样:
| 部件 | 选型建议 | 备注 |
|---|---|---|
| 激光雷达 | RPLIDAR A1/A2、YDLIDAR X4 | 2D单线,10Hz以上 |
| 底盘 | 差速轮式AGV底盘 | 编码器精度尽量高一点 |
| 主控 | Jetson Orin Nano / 树莓派4B | 跑gmapping够用 |
| IMU | MPU6050及以上 | 可选,但强烈建议加 |
| 电机驱动 | 支持ROS串口/Can通信的驱动板 | 必须有/cmd_vel接口 |
IMU不是必须的,但有了IMU可以让里程计信息更准,建图不容易飘。很多AGV底盘自带IMU,或者可以用轮式里程计加上简单滤波,也能跑出不错的效果。我自己的习惯是:室内小场景先不上IMU,跑通流程再逐步升级硬件,这样定位问题出现时更容易判断是算法问题还是传感器问题。
1.3 软件架构:一台AGV上的节点怎么分工
AGV的导航可以分成三个层次。感知层:激光雷达发scan话题,里程计发odom,IMU发imu。决策层:SLAM负责建图,amcl负责定位,move_base负责路径规划和避障。执行层:底盘控制节点订阅/cmd_vel,把速度指令下发给电机驱动板。节点之间只通过话题通信,互不干扰、可以随时替换,这就是ROS最有价值的地方。
我遇到很多新手一上来就想着自己写路径规划算法,动不动就要从Dijkstra开始实现。其实先搞懂move_base怎么用、参数怎么调,已经能解决80%的AGV导航需求。真正需要自己写代码的部分,绝大多数是避障策略、任务调度、多传感器融合这些细节。所以软件架构上,我建议你把move_base当成黑盒先跑通,再逐步替换里面不满意的模块,这样排查问题会舒服很多。
2. 环境搭建与核心依赖
2.1 ROS发行版选择和安装
如果你是全新的项目,我建议直接用 Ubuntu 20.04 + ROS Noetic。如果是ARM板子(RK3588、树莓派),Noetic也有对应移植版。Ubuntu 22.04 + ROS 2 Humble这条路也可以走,生态在快速成熟,但很多老的gmapping、move_base教程还是ROS 1,对新手来说遇到问题不好搜,所以我先把ROS 1这套讲透。安装ROS桌面完整版,本质就是一条apt命令的事:
sudo apt update sudo apt install ros-noetic-desktop-full装完以后记得把环境变量写进~/.bashrc,否则每次开终端都要手动source:
echo "source /opt/ros/noetic/setup.bash" >> ~/.bashrc source ~/.bashrc国内如果下载太慢,可以换清华源、阿里源。社区里也有一键安装ROS的脚本,适合不想折腾环境的朋友,本质上是把装依赖、配置源、安装ROS的流程自动化了,我用过几次,省去了不少重复劳动。
2.2 激光雷达驱动和数据验证
不同厂家的雷达驱动不一样。以rplidar为例,安装驱动后,roslaunch rplidar_ros rplidar_a1.launch就会发布/scan话题。装完驱动别急着建图,先用rostopic echo /scan查看一下数据格式,再在RViz里添加LaserScan显示,确认雷达正前方在图像上的朝向和车体一致。这一步非常关键,雷达装反或者坐标系弄错,建出来的地图会是镜像的,后面定位全乱。
验证雷达数据最简单的命令组合是:
rostopic hz /scan rostopic echo /scan -n1rostopic hz能看到雷达发布频率是不是10Hz,echo能看到ranges数组长度。如果雷达数据是乱的,优先检查USB供电和串口权限,常见问题就是雷达转起来了但数据全是0,或者频繁断线。
2.3 Python环境和catkin工作空间
Noetic完全使用Python 3,系统自带的Python 3.8就够了。ROS节点里常用numpy、scipy,直接用pip装就行:
pip3 install numpy scipy然后创建工作空间:
mkdir -p ~/agv_ws/src cd ~/agv_ws catkin_make创建自己的功能包:
cd ~/agv_ws/src catkin_create_pkg my_agv rospy roscpp geometry_msgs sensor_msgs nav_msgs tf2之后写的避障节点、URDF模型、launch文件都放到my_agv包里面。一个常见的坑是忘记source工作空间的环境变量,source ~/agv_ws/devel/setup.bash,新写的节点rosrun找不到。建议把这句话也追加到~/.bashrc里,不过要注意必须放在ROS自带的setup之后。
3. 激光SLAM建图实战
3.1 建图原理,别靠背参数
SLAM说白了就是机器人边走边回答两个问题:我在哪?周围环境长什么样?先根据激光帧做匹配(前端),算出雷达移动了多少;再把所有关键帧放到一起做优化(后端),让整体地图一致;最后靠回环检测把走了一圈后重访的位置闭合起来。
打个不太准确的比方:就像你在黑暗房间里拿着手电筒走一圈,每走一步都靠看附近墙壁的位置推断自己走没走偏,最后回到门口时还得让门的位置跟记忆里对上。如果走太快或者某段路特征太少,你就容易觉得哪里都对不上,这就是地图漂移。理解了这套逻辑,你就能明白为什么建图时要慢走、要回绕、要保证两侧都有墙,而不是急急忙忙跑一圈就能出好图。
3.2 常用建图算法怎么选
主流的2D激光SLAM算法我基本都试过,简单说说适用场景。
| 算法 | 场景 | 优点 | 缺点 |
|---|---|---|---|
| gmapping | 小场景、室内房间 | 计算量小、成熟稳定 | 无回环优化,大场景会飘 |
| cartographer | 大场景、工厂车间 | 回环优化,精度高 | 参数多、内存占用大 |
| hector_slam | 无里程计场景 | 只用激光数据 | 对雷达频率和精度要求极高 |
| karto | 2D建图 | 有回环 | 使用人数少,资料少 |
小房间学习用gmapping就够了,两百平以上、有长走廊的场景直接上cartographer,别在gmapping上浪费时间调半天还飘。另外提醒一句,建筑结构越简单、特征越少,SLAM越容易失效。激光SLAM在空旷大平层里往往会“迷路”,这种场景要提前布置一些立柱、货架或者贴反光条来增加特征。
3.3 从零建图的操作流程
标准流程四步。第一步,启动雷达和底盘驱动,确认scan和odom话题正常。第二步,启动SLAM节点。gmapping的launch文件一般长这样:
<launch> <node pkg="gmapping" type="slam_gmapping" name="slam_gmapping" output="screen"> <param name="base_frame" value="base_link"/> <param name="odom_frame" value="odom"/> <param name="map_frame" value="map"/> <param name="particles" value="30"/> <param name="linearUpdate" value="0.1"/> <param name="angularUpdate" value="0.1"/> <param name="map_update_interval" value="5.0"/> </node> </launch>第三步,用键盘遥控小车建图。rosrun teleop_twist_keyboard teleop_twist_keyboard.py,然后以“之”字形慢慢走一圈,拐弯要慢,回到起点附近再绕半圈让回环闭合。第四步,地图满意后执行:
rosrun map_server map_saver -f map_name生成map_name.pgm和map_name.yaml两个文件。这里没有捷径,建图过程走太快、转太急,地图必飘。走到过道时尽量让雷达能扫到两侧墙壁,不要贴着一面墙走一大段,不然另一侧会误差累积。
3.4 建图参数调优
以gmapping为例,重点就几个参数。particles:粒子数,默认30,场景大或噪声大可加到60-80,但明显消耗CPU。linearUpdate和angularUpdate:控制每移动多少米、转多少弧度更新一次地图,过密费CPU,过疏容易丢特征。map_update_interval:地图整体更新频率,默认5秒够用。
cartographer的话,主要调loop_closure相关参数、num_subdivisions_per_laser_scan和数据源类型。调参没有捷径,一次只改一个参数,记录结果,对比地图质量。我习惯用固定路线测试——同一个房间里走同样的路线,修改某个参数后对比地图的墙线是否平直、回环是否重合,这个习惯比任何调参脚本都有用。
4. 路径规划与避障实现
4.1 move_base导航框架的组成
真正跑导航的时候,move_base是核心。它接收一个目标点,调用global_planner在全局地图上算出一条从起点到目标点的路径,同时local_planner根据实时激光数据不断修正轨迹,避开意外出现的障碍物。整个框架还需要三样东西配合:map_server发布静态地图,amcl做蒙特卡洛定位,以及底层发布的odom和tf变换。
很多AGV项目卡住,不是路径规划算法本身的问题,而是tf没配好或者定位抖动导致的。建议先把rqt_tf_tree打开看一眼,map、odom、base_link、laser这些坐标系之间的关系是不是严格按树状连接。tf断了或者跳变,后面所有规划全是白搭。我遇到过一个案例,车来回跑几趟以后定位越来越偏,最后发现是odom坐标系里混了两个节点在发,rqt_graph一看就发现问题了。
4.2 全局路径规划参数配置
全局路径规划器有两个选择:navfn/NavfnROS使用Dijkstra算法,global_planner/GlobalPlanner可以实现A和Dijkstra可选。室内AGV一般用A就够了,走出的路径更直,计算量小。costmap参数里核心是inflation_radius(膨胀半径),它决定了路径离障碍物有多远。我一般设成机器人半径加0.1到0.2米的余量,太大路径绕远,太小容易贴着墙走。
下面是costmap_common_params.yaml里最基础的一段:
robot_radius: 0.2 inflation_radius: 0.35 observation_sources: scan scan: data_type: LaserScan topic: /scan marking: true clearing: truerobot_radius一定不能比真实车体小,这是我从一次AGV卡在门框上的事故里学到的教训。测机器人半径时,要把底盘上凸出来的零部件、外挂传感器都算进去,别只量底盘主体。另外,inflation_radius设置得太小,路径会贴着障碍物走,AGV稍微偏一点就撞上;太大,车会在走廊中间刻意绕远,效率低。实际项目里我一般会先用RViz的“Publish Point”功能,在代价地图上看膨胀区域的效果,再决定具体值。
4.3 局部规划器与实时避障
局部规划器主流两个:DWA和TEB。DWA的原理是在每个控制周期内采样多组线速度、角速度组合,模拟未来一小段时间的运动轨迹,然后从多条轨迹中选一条避开障碍、离目标最近的。通俗说,就是在“往前走多远”“转多大弯”这个参数空间里不断试错,选个最好的。优点是参数少、效果好,适合大多数室内AGV。
TEB则把轨迹当成一条可变形的“弹性带”,约束它尽量短、平滑、无碰撞,优点是可以显式设置速度快慢和转弯半径,适合那种需要在工位间精准定位、轨迹讲究的场合。缺点就是参数多,新手容易被几十个参数搞晕,而且调不好容易出现速度震荡。
我的建议:先跑通DWA,满足不了再上TEB。实际项目里大部分AGV用DWA就够了,TEB更多用在服务机器人绕桌、贴边走的场景。
4.4 一个可以直接跑的Python避障节点
这里我给一个简化版的激光避障节点,核心思路很简单:把激光数据分到前方、左侧、右侧三个区域,然后根据距离判断直行、停车或者转向。注意这是教学用的极简版本,生产环境建议在move_base的局部costmap基础上做更完整的避障策略。
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ laser_avoidance.py 订阅 /scan 激光数据,输出 /cmd_vel 速度指令 分区策略:前方有障碍 -> 停车或转向;前方安全 -> 直行 适合室内AGV教学/原型验证,生产环境请结合 move_base 使用 """ import rospy from sensor_msgs.msg import LaserScan from geometry_msgs.msg import Twist class LaserAvoidance: def __init__(self): rospy.init_node("laser_avoidance") self.pub = rospy.Publisher("/cmd_vel", Twist, queue_size=1) rospy.Subscriber("/scan", LaserScan, self.scan_cb) # 距离阈值,单位米 self.stop_dist = 0.25 # 前方小于该距离直接停车 self.safe_dist = 0.45 # 前方大于该距离就放心前进 self.max_speed = 0.22 # 最大前进线速度 self.max_turn = 0.6 # 最大转向角速度 self.front = float("inf") self.left = float("inf") self.right = float("inf") rospy.loginfo("laser_avoidance node started") def scan_cb(self, msg): n = len(msg.ranges) if n == 0: return # 把无效值(0或inf)处理一下 ranges = [r if r != 0.0 else float("inf") for r in msg.ranges] # 航向划分:正前、左前、右前,具体角度范围按雷达安装方式调整 # 这里假设雷达0度在正前方,360个点均匀分布 front = ranges[:n//12] + ranges[-n//12:] left = ranges[n//12 : n//4] right = ranges[-n//4 : -n//12] self.front = min(front) self.left = min(left) self.right = min(right) self.decide() def decide(self): cmd = Twist() if self.front < self.stop_dist: # 太近,原地停止 cmd.linear.x = 0.0 cmd.angular.z = 0.0 elif self.front > self.safe_dist: # 前方空旷,优先直行 cmd.linear.x = self.max_speed cmd.angular.z = 0.0 else: # 前方进入危险区,停车并转向空旷一侧 cmd.linear.x = 0.0 if self.left > self.right: cmd.angular.z = self.max_turn else: cmd.angular.z = -self.max_turn self.pub.publish(cmd) if __name__ == "__main__": try: LaserAvoidance() rospy.spin() except rospy.ROSInterruptException: pass这段代码可以直接放进~/agv_ws/src/my_agv/scripts/laser_avoidance.py,chmod +x后rosrun my_agv laser_avoidance.py运行。实际项目里建议用dynamic_reconfigure把阈值做成动态参数,方便现场调,别像我第一版那样改个安全距离还要重新编译重启。
4.5 move_base与局部代价地图参数调试
move_base的yaml配置常见的就是下面这些。controller_frequency控制局部规划器执行频率,我一般设10Hz,太高耗CPU,太低避障反应慢。速度上限max_vel_x、max_vel_theta要跟底盘电机能力匹配,设大了底盘容易打滑,设小了AGV显得“迟钝”。acc_lim_x和acc_lim_theta限制加减速度,这组参数直接影响起步和停车是否平稳。
controller_frequency: 10.0 max_vel_x: 0.3 min_vel_x: -0.1 max_vel_theta: 0.8 min_vel_theta: 0.1 acc_lim_x: 0.5 acc_lim_theta: 0.5 xy_goal_tolerance: 0.1 yaw_goal_tolerance: 0.1xy_goal_tolerance和yaw_goal_tolerance是到点判断的容忍度。仓储自动对接场景设严一点,比如0.05米;观光导航设宽一点,0.2米反而体验好。调参思路我从是“慢到快”:先把速度调到很低,确认能安全到达目标点,再逐步提高速度,缩小安全距离,直到出现临界情况再回头微调。只有这样做,你才能分清到底是哪个参数引发了问题。
5. 常见问题与排查技巧实录
5.1 建图时地图漂移甚至叠影
这是新手遇到最多、也最容易劝退的问题。原因无非三种:里程计不准、激光帧率低、建图走太快。先看odom话题有没有跳变,打滑厉害的轮子里程计会抖,AGV如果是差速底盘,建议给轮子加编码器,或补充IMU做融合。再看雷达频率,大多数2D雷达10Hz,建图频率并不高,人推着走每分钟别超过0.3米/秒才稳。最后看回环,你走完一圈回到起点,如果地图在起点位置对不上,就是累积误差太大,只能控制速度再来一遍。
5.2 导航过程中小车停住不动或原地乱转
第一种情况,小车停在原地不动,先看amcl定位协方差。如果定位漂了,全局路径根本规划不出来,用RViz的“2D Pose Estimate”手动修正初始化位置,是定位问题最直接的临时解法。第二种情况,小车原地乱转,多半是局部规划器的旋转速度设置太低,或者costmap膨胀半径太大,导致它认为两侧都过不去。把膨胀半径改小一点看现象,也是一个很重要的定位问题的手段。
还有一个小概率原因:底盘的/cmd_vel被其他节点占用了。可以用rqt_graph看看是不是有多个节点同时在发速度指令。我之前就遇到过键盘遥控节点没关、move_base也同时在发指令的情况,车在导航时自己不受控制地乱动,查了半天才发现是节点冲突。
5.3 避障偶尔失效,撞到矮障碍物
2D激光雷达扫描平面以下的物体是完全看不见的,所以雷达安装高度一般在20到40厘米左右。太低看不清远处,太高会漏掉地面矮物。如果你经常在办公区测试,雷达高度30厘米左右、膨胀半径稍大一点是比较稳的组合。另外,避障策略里“反应时间”也很关键:以0.3米/秒的速度行驶,safe_dist只有0.4米时,留给停车的时间只有一秒左右,雷达每个周期10Hz,意味着只有10次激光数据来反应。所以速度、安全距离、传感器帧率三者必须一起调,单独调任何一个都容易出事故。
5.4 故障排查速查表
| 现象 | 常见原因 | 优先排查方向 |
|---|---|---|
| 地图漂移 | 里程计误差大、回环没闭合 | 检查odom、走慢一点重新建图 |
| 导航目标设不了 | amcl没定位 | 用2D Pose Estimate手动初始化 |
| 路径贴合墙壁 | 膨胀半径太小 | 调大inflation_radius |
| 规划路径绕远 | 膨胀半径太大 | 调小膨胀半径 |
| 小车原地乱转 | 局部规划器速度限制太低 | 提高max_vel_theta |
| 避障失效 | 雷达盲区、速度过快 | 降低速度、增大safe_dist |
| 里程计突然跳变 | 轮子打滑、编码器松动 | 检查底盘机械和驱动 |
5.5 几个我踩过之后记住的习惯
最后分享几个实操习惯。第一,改任何参数前,先把原配置文件复制一份存着,调坏了能秒回滚。第二,每次改动都先看rostopic echo /cmd_vel,确认速度指令确实发出来了再去查机械问题。第三,真机上测试前,先在Gazebo仿真里把move_base调通,再把同一套参数搬到实车,能省掉很多不必要的半夜调试时间。第四,激光雷达的安装角度一定要标定,哪怕差五度,建图和导航的体验都会差很大,用一个量角器或者激光测距仪确认雷达中线与车体前进方向平行,这比任何算法调优都来得可靠。
做AGV项目这几年,我的体会是:激光SLAM导航的核心其实不在于算法有多么高深,而在于你把整个系统的每一环都理解到位,并且愿意一点一点调参、记录、对比。ROS和Python把开发门槛降得很低,但能不能让车稳定地跑起来,靠的还是对建图、定位、规划避障这套链路的熟悉程度。最后再分享一个小建议:把你的调参过程和现场问题写成笔记,我每次项目复盘都会发现,之前踩过的坑,在下一个项目里还会以类似的方式再出现一次。保持记录,这是AGV项目里性价比最高的习惯。