基于ROS2 Humble的AGV导航仿真:从建模到动态避障
2026/9/8 13:49:06 网站建设 项目流程

简介:自主导航是智能物流与工业AGV的核心技术,其本质是让机器人在未知或已知环境中完成定位、路径规划与安全避障。在机器人开发中,SLAM与代价地图是导航的基础,而ROS2作为新一代机器人框架,结合Gazebo仿真可大幅降低算法验证成本。针对动态避障小车路径规划需求,本文以ROS2 Humble和Nav2为核心,搭建了一台差速驱动AGV的仿真模型,详细介绍URDF建模、传感器配置、slam_toolbox建图、AMCL定位、A*全局规划以及DWA/TEB局部规划的实现流程,并深入剖析膨胀半径、粒子滤波参数等关键调优技巧。同时,文中还讨论了仿真场景如何与agv调度系统衔接,为多车协同控制和后续真实部署提供可复用的技术参考。通过这套方案,开发者能够在仿真环境中快速验证导航算法,降低实车调试风险。 我刚接触ROS2 Navigation时有个很深的感受:网上教程一大把,但大部分Demo要么用TurtleBot在空房间里打转,要么直接加载官方地图跑一圈就完事。真要把这套东西落到AGV场景,比如工厂里的物料搬运、仓库里的货架对接,你会发现一堆在教程里没写过的问题——底盘模型怎么建、传感器装在哪个高度、代价地图膨胀半径设多大、AGV过窄通道时为什么总是“犹豫不决”。这篇文章就围绕一个我实际跑通的AGV仿真演示项目展开,基于ROS2 Humble框架,在Gazebo里搭建了一台差速驱动的小型AGV,完成了自主导航、路径规划和动态避障。适合正在做AGV相关课题、想用仿真快速验证算法、或者刚进ROS2导航坑想找一套完整参考的读者。

1. 先说清楚:AGV导航到底在解决什么问题

1.1 为什么选ROS2 Humble和Gazebo这套组合

做AGV仿真选型时,我先把主流的方案捋了一遍。ROS1 Noetic虽然成熟,但官方已经停止维护,新项目再抱着ROS1不放,后续扩展会越来越难受。ROS2 Humble是长期支持版本(LTS),支持到2027年,而且Navigation2功能包在Humble上的表现已经很稳定,社区里踩坑记录也足够多,出了问题基本都能搜到解决方案。

Gazebo这边,我对比过Gazebo Classic和Gazebo Ignition(现在叫Gazebo Harmonic)。当时考虑到Nav2官方文档和大多数教程都基于Gazebo Classic,虽然它从11.x换到Gazebo 11也可以,但配合ROS2 Humble最省事的还是官方推荐的gazebo_ros_pkgs。Ignition的物理引擎虽然更先进,但驱动接口、传感器插件跟ROS2的适配文档偏少,对新手很不友好。另外一个现实问题是:话题里的热搜词也提到“mujoco和gazebo区别”,Mujoco在强化学习场景确实强,但做AGV导航验证,Gazebo的传感器仿真和ROS2原生集成度明显更合适。

选ROS2 Humble + Gazebo的最大理由,我认为是Nav2这个核心导航栈。它把定位、全局规划、局部规划、行为树、恢复策略全部模块化,你不需要自己从零实现一套SLAM和路径规划,而是可以在理解原理之后,用配置参数去调优整个系统。这种“框架给骨架、我来填血肉”的方式,特别适合做AGV这样需要快速迭代验证的项目。

1.2 硬件选型的仿真化思路:底盘、传感器与控制

真实AGV的底盘五花八门,有差速、有舵轮、还有麦克纳姆轮。这个项目里我用的是差速驱动,主要原因是差速模型在Gazebo里最容易建模,控制律也直观——左右轮速度差决定转向,导航栈里对差速底盘的支持最成熟。如果你以后要换麦轮或者舵轮,Nav2里的Controller插件(比如RPP、TricycleController)也有对应方案,但入门阶段先别给自己加难度。

这里有个关键认知:AGV跟服务机器人最大的区别在于它的工作节奏和场景约束。AGV一般跑在结构化环境里,通道宽度固定、路线相对固定,但负载变化大、对重复定位精度有要求。比如一个用于搬运的AGV,它的激光雷达安装高度不能太低也不能太高——太低扫不到货架腿部,太高又扫不到低矮障碍物。仿真里如果忽略这些细节,导航算法在仿真里跑得再漂亮,搬到真车上也会翻车。

这个项目里我用了“激光雷达为主、RGBD摄像头为辅”的传感方案。激光雷达提供2D代价地图所需的平面信息,RGBD摄像头则可以补充桌面高度以下的障碍物检测。有热词提到“搭配两颗rgbd传感器,实现数能接受与发送”,我的方案是一颗前置深度相机做近距离避障,一颗后置用于倒车时的视觉辅助,通过Ros2的话题节点完成数据收发。深度图和2D激光点云的融合,是后面讲避障策略时的重点。

2. 搭建Gazebo仿真场景的完整过程

2.1 从零组装一个差速AGV模型

我建议用URDF(统一机器人描述格式)来建模AGV,而不是直接用Gazebo自带的GUI拖拽。URDF的好处是纯文本、可版本管理、可参数化,改轮距、改传感器位置只需要编辑一个标签,不用重复打开GUI。

差速AGV的URDF核心结构分几块:底盘(base_link)、两个驱动轮(left_wheel、right_wheel)、一个万向支撑轮(caster)、传感器挂载点。轮子与底盘的连接用continuous类型的关节,底盘与传感器用fixed关节。底盘的物理属性里有几个容易被忽略的坑:

  • 质量不能太小,否则负载加上去惯性矩阵会失真,Gazebo仿真时会漂;
  • 轮子的摩擦系数要单独设置,差速轮如果摩擦不足,导航时会出现原地打滑、转向半径变大;
  • 支撑轮最好用球形或者带球铰的结构,否则小车转弯时支撑轮会卡顿,导致转速反馈抖动。

我给的参考数值:底盘质量15kg,轮子质量1.5kg,轮半径0.1m,轮距0.35m,底盘长0.6m宽0.4m高0.3m。这个尺寸接近一台小型的托盘搬运AGV。跑起来后,Nav2里的差分控制器参数也照着这些数值去匹配。

Gazebo中需要在URDF里加入<gazebo>标签声明每个link的摩擦系数、惯性参数,并且为激光雷达和摄像头添加对应的传感器插件。如果不加插件,模型能在Rviz里显示,但在Gazebo里就是一个“看不见摸不着”的物体,不会输出任何传感器数据。

2.2 传感器配置:激光雷达为主,RGBD为辅

激光雷达我用的是2D激光雷达插件,模拟思岚A1这种低成本雷达,扫描范围360度、最大距离12米、扫描频率10Hz。在Gazebo的sdf配置里,这一步要注意设置<alwaysOn>true</alwaysOn><update_rate>10</update_rate>,否则雷达数据刷新率不稳定,AMCL定位时会出现漂移。

RGBD摄像头用的是libgazebo_ros_camera插件,输出深度图、彩色图和相机内参。深度图的话题类型是sensor_msgs/Image,但后续做点云时通常在Rviz里直接看PointCloud2格式。如果你想把深度图用于避障,可以写一个简单的ROS2节点,接收深度图后生成占据栅格(costmap),这是常见的视觉避障思路。

另一个重点:传感器的坐标系。激光雷达挂在baselink上方0.2m处,深度相机前置在车头前方0.1m处。TF树会把这些坐标串联起来。很多新手报错“No transform from [laser] to [base_link]”,90%的情况是URDF里没有正确声明fixed关节,或者没有启动robot_state_publisher节点。

我用一个自写的Python节点做了数据闭环验证——订阅雷达的/scan话题和深度相机的/depth/image_raw话题,将其打包成自定义消息,再通过另一个话题发送给“控制器”。这么做主要是为了模拟AGV里面常见的数据处理逻辑:传感器数据先经过感知模块,再进入导航模块,中间可能要加滤波、时间同步、坐标系变换等处理。

2.3 场景布置与障碍物设计

Gazebo场景我模拟了一个長方形的仓库:长20m宽10m,四周墙壁,中间有若干货架和立柱,货架间距刚好让AGV通过。这种场景的好处是可以测试几个典型的AGV运行工况:直线巷道高速通行、十字路口转弯、窄通道规避、货架旁的停车对接。

障碍物我统一用Gazebo的spawn_entity服务动态生成,而不是一开世界文件就全部摆死。这样后面做动态障碍物测试的时候,可以直接在一个运行中的仿真环境里添加障碍物,而不需要重启Gazebo。比如测试避障时,我用命令行在AGV前方3m处生成一个0.3m见方的障碍物,看导航系统能不能及时重新规划。

注意静态障碍物和动态障碍物在代价地图里的处理方式不一样。静态障碍物适合直接标记在地图上,动态障碍物则需要依靠传感器实时更新局部代价地图。在建场景时就要想清楚哪些障碍物以后会挪动,把它们和固定场景分开管理。

3. 建图与定位:让AGV知道自己在哪里

3.1 slam_toolbox建图流程

建图是整个导航的“前置工程”。没有一张可靠的地图,路径规划就是空中楼阁。我用的是slam_toolbox,不是Gmapping,也不是Cartographer。原因很简单:slam_toolbox是纯ROS2包,安装省事,对单激光雷达的2D场景效果不错,而且它支持“在线建图+保存地图”一条龙流程。

启动SLAM工具箱的命令大致如下:

ros2 launch slam_toolbox online_async_launch.py use_sim_time:=true

建图时通过键盘控制节点或者手动发cmd_vel让AGV慢慢跑遍整个地图。这里有个很实用的经验:建图速度一定要慢,转弯要稳,不要原地打转。快速移动会导致激光帧间匹配错位,地图出现重影。我建图时控制线速度0.3m/s,角速度0.5rad/s,跑完整个仓库大约5分钟。

地图建好后保存:

ros2 run nav2_map_server map_saver_cli -f map

生成map.pgm和map.yaml。保存之后用map_server加载地图。

3.2 AMCL定位参数与常见问题

定位我用的是AMCL(自适应蒙特卡洛定位),它通过粒子滤波估计机器人在地图上的位姿。AMCL在Nav2里是单独的一个生命周期节点,配置项非常多,但必须关注的核心参数是这些:粒子数(max_particlesmin_particles)、更新阈值、初始位姿。

调试时我建议把max_particles设成3000左右,粒子越多定位越稳,CPU占用越高。真正部署时再降下去。另外一个容易忽略的参数是transform_tolerance——这是AMCL发布坐标变换的最大允许延迟,如果设为0,经常出现“Lookup would require extrapolation into the past”的报错。设个0.1s或者0.2s可以规避大量TF时序问题。

定位是否成功,判断标准不是看Rviz里的机器人模型有没有贴上地图,而是看累计误差会不会收敛。我习惯同时开一个Rqt可视化窗口监视估计位姿的方差。粒子分布发散时,需要给AMCL一个更准确的初始位姿估计(用/initialpose话题发布),或者检查激光雷达的数据帧率是否稳定。

4. 全局路径规划:A*算法的栅格化实现与原理

4.1 图搜索为什么要选A*

AGV导航里的“路径规划”这个热点词,核心基本都是图搜索算法。Nav2默认的全局规划器用的是NavFn插件,底层算法就是A的一种变体。A在栅格地图上的效果很好:它用启发式函数f(n) = g(n) + h(n),g(n)是起点到当前点的实际代价,h(n)是当前点到目标点的估计代价。启发函数控制搜索行为——如果h(n)始终为0,A*退化成Dijkstra;如果h(n)大于实际代价,搜索速度变快但不保证最优。

在AGV场景里,栅格地图上的A搜索有几个特殊需求。首先是要考虑AGV的尺寸,所以算法搜索的其实是“膨胀后的地图”——每个障碍物周围根据AGV半径和间隙阈值进行膨胀处理。其次,A的8邻域搜索会造成对角线移动的高估或低估,需要调整对角移动的代价或者使用更高级的Theta算法。但Nav2里默认的A已经处理了这些问题,所以我没有自己造轮子。

不过在写自己的路径规划演示时,我用Python实现了一个简化版的A*算法,用于展示效果。核心代码思路是:

import heapq def a_star(start, goal, grid): open_set = [(0, start)] came_from = {} g_score = {start: 0} while open_set: current = heapq.heappop(open_set)[1] if current == goal: # 回溯路径 path = [] while current in came_from: path.append(current) current = came_from[current] return path[::-1] for neighbor in get_neighbors(current, grid): tentative_g = g_score[current] + cost(current, neighbor) if tentative_g < g_score.get(neighbor, float('inf')): came_from[neighbor] = current g_score[neighbor] = tentative_g heapq.heappush(open_set, (tentative_g + heuristic(neighbor, goal), neighbor)) return []

这个简化版算法用于教学演示是很直观的,但真正跑导航时还是要用Nav2内置的NavFn,因为它在性能、内存管理和地图裁剪上做了大量优化。自己造轮子可以作为理解工具,上生产还是用成熟实现。

4.2 代价地图里膨胀层的含义

代价地图(costmap)是路径规划成功与否的关键。Nav2里代价地图分全局代价地图和局部代价地图,每层地图都有自己的代价计算方式。障碍物层(obstacle_layer)直接读传感器数据,把障碍物标记为致命代价(254),膨胀层(inflation_layer)则把这些致命代价向外扩散,产生递减的代价梯度。

膨胀半径是AGV导航里最重要的参数之一。膨胀半径太小,路径会贴障碍物太近,容易出现剐蹭;膨胀半径太大,AGV可能直接判定某些窄通道不可通行。我的经验值是:如果AGV半宽0.2m,通道宽0.8m,那么安全的膨胀半径设置应该在0.3m左右,比AGV半径多出0.1m的安全余量。

这里有一个非常经典的坑:如果你把膨胀半径设得比通道宽度的一半还大,导航系统会认为通道不可通行,此时AGV会绕远路或者直接报告“No valid path”。我调试“AGV进不去货架区”的问题时,最后发现就是膨胀半径大了一点点,导致AGV觉得货架间隙不够宽。

4.3 全局路径规划的调参经验

Nav2里全局路径规划有几个最常调整的参数:

  • tolerance:路径终点的容忍误差,如果设为0.2m,AGV接近目标点0.2m内就算到达。
  • use_astar:NavFn默认启用A*,但你可以切换成Dijkstra,Dijkstra在某些动态场景下重新规划出的路径更平稳。
  • max_iterations:搜索迭代次数限制,超出就报失败,仓库场景建议设大一点,避免地图大时搜索超时。

调参时我的方法是先在Rviz的PathDisplay里观察生成的绿色路径。如果路径走“之”字抖动,说明代价地图膨胀不够或者平滑参数没调好。如果是靠墙边的路径贴着障碍物走,那就把inflation_radius调大一点。全局路径调好了,局部避障才有意义——全局路径负责“大方向”,局部规划器负责“小修小补”。

5. 局部避障:动态障碍物下的实时决策

5.1 DWA控制器的工作逻辑

局部避障是导航系统中最接近“驾驶行为”的一层。Nav2默认的局部控制器是DWA(动态窗口法)。DWA的思路是在机器人运动学约束下采样一组速度(线速度+角速度),然后评估每组速度对应的轨迹,选一条得分最高的轨迹执行。评估指标一般包括:目标方向对齐度、障碍物距离、速度大小。

DWA的优点是计算量小、响应快,适合差速AGV。但要注意它的天然缺陷:它只搜索当前时刻的速度窗口,所以容易陷入局部极小值,比如被一个U形障碍物困住反复左右试探。AGV在货架间掉头时我遇到过好几次这种情况,表现为小车在一个地方来回抽搐,不走直线。

解决办法有几个:

  • 调高path_distance_bias,让DWA更倾向于沿着全局路径走;
  • 调低goal_distance_bias,避免AGV为了快速到达目标点而冲进死胡同;
  • 提高max_vel_x的上限不要太大,过高的线速度会让DWA采样窗口变大、规划不稳定。

5.2 换成TEB之后的变化

如果DWA满足不了你的需求,可以考虑用TEB(Timed Elastic Band)替代。TEB的核心思想是把全局路径看作一条“有弹性的带子”,通过优化目标函数来调整这条带子的形状,同时考虑时间最优、路径平滑、障碍物避让等约束。TEB在过通道、转弯时比DWA更平滑,而且天然支持“时间最优”——它会尽量压缩时间开销,AGV效率能高不少。

但TEB不是银弹。它对参数更敏感,调不好容易出现震荡。TEB有个参数dt_ref,控制轨迹离散化的时间步长,一般0.3s左右。太小会让优化变量过多、计算量大,太大则轨迹粗糙、容易撞障碍物。我的实测感受是:直线长通道用DWA稳,弯道多的仓库用TEB更顺。这个跟热搜词里“动态避障小车路径规划”关注的问题高度一致——动态场景下局部规划器直接决定小车是流畅通过还是一惊一乍。

5.3 动态障碍物仿真场景搭建

为了测试避障策略,我在仿真里设了一个场景:AGV沿巷道前进,路面中央突然出现一个移动障碍物(另一个慢速AGV或者人员模型)。这个场景可以通过Gazebo的spawn_entity服务动态生成障碍物,比如:

ros2 run gazebo_ros spawn_entity.py -entity obstacle -file obstacle.sdf -x 5.0 -y 2.0 -z 0.0

然后我通过导航目标点让AGV继续前进。观察到的现象是:局部代价地图检测到障碍物后,局部规划器会重新规划轨迹;如果障碍物挡住了整条巷道,局部规划器会请求全局路径重新规划。

Nav2里面动态避障的核心是有三层协调:局部代价地图的障碍物层实时更新、局部规划器实时避让、全局规划器在局部无路可走时触发重规划。手动验证时,我建议用ros2 topic pub /goal_pose发布导航目标,然后观察Rviz里的路径变化。如果AGV完全停住不动,多半是代价地图的更新频率不够,检查一下footprint_padding和传感器数据是否有空帧。

6. 实测记录与性能数据

6.1 跑通的完整导航链路

整个项目的最终效果是:通过Rviz2界面点击一个目标点,AGV在Gazebo仿真环境中自主规划路径、行驶并准确到达目标点,途中可以对静态障碍物和动态障碍物进行规避。我在仓库地图里设置了5个测试点,包括四个角落和一个中心点,AGV逐点遍历,总行驶路径大约40m,平均线速度0.5m/s,最远点导航耗时约35秒。

数据上,影响最大的因素是局部规划器的选择:

  • DWA模式下,CPU占用约30%,但遇到动态障碍物时减速明显,平均速度降为0.3m/s;
  • TEB模式下,路径平滑度更好,但在通道口会偶尔出现小幅震荡,平均速度0.35m/s。

定位方面,AMCL粒子收敛后,AGV在货架旁的横向误差约5cm,纵向误差约8cm,基本满足演示级的对接要求。如果要做高精度托盘对接,需要加上视觉辅助或者反光板定位。

6.2 踩坑记录:Gazebo卡顿、坐标系错误、代价地图爆炸

把排查过程完整写下来,希望对后来的人有帮助。

第一个坑是Gazebo仿真速度越来越慢。跑一段时间后实时因子从1.0掉到0.4,AGV卡得像幻灯片。原因是场景里的RGDB摄像头和雷达都在以10Hz的频率发布数据,而且我没有关闭无关的可视化模型。解决办法是降低传感器发布频率到5Hz,并关闭Rviz中不必要的话题显示,实时因子恢复到0.8以上。

第二个坑是坐标系错误导致的导航偏移。一开始我把雷达的frame_id写成base_scan,但URDF里没有定义这个坐标系。当Nav2运行时,TF树里找不到base_scanbase_link的变换,于是局部代价地图全变成了未知区域,导航方向完全错误。排查方式是:ros2 run tf2_ros tf2_echo base_link base_scan查看TF是否连通,如果报错,回到URDF检查fixed关节。

第三个坑是代价地图爆炸。障碍物层的传感器数据里偶发NaN值(Gazebo雷达在特定角度可能有无效数据),这些NaN会被当作致命代价写入地图,导致代价地图出现黑洞。启动Nav2之前,最好在传感器数据链路里加一个简单的滤波节点,过滤NaN值。很多实际问题追根溯源都是数据质量问题。

6.3 后续可以扩展的方向

这个仿真项目跑通后,可以顺延扩展的几个方向:

  • 多AGV调度:在Gazebo中启动多台AGV,用中心调度节点给它们分别发布目标点,测试巷道交通管制和避碰逻辑。搜索词里“agv调度系统”就是关注这个方向,可以从小场景做起。
  • 感知和定位融合:把RGBD相机的深度图接入局部代价地图,实现货架下层悬空障碍物的检测,弥补2D雷达的盲区。
  • MoveIt2和Gazebo结合:搜索词里有人问“如何将moveit2和gazebo结合在一起”,如果AGV需要机械臂装卸货物,可以在Gazebo里给AGV加一个机械臂,用MoveIt2控制末端执行器,这样“移动+操作”就闭环了。
  • 真实AGV部署:仿真验证过算法后,把ROS2导航栈迁移到真车,主要变化在于传感器噪声和里程计标定,导航核心算法不用大改。

最后聊点我的实际感受

把这套AGV仿真项目完整跑下来,我最深的体会是:导航算法本身不是最难的,难的是把传感器、模型、坐标变换、代价地图、规划器之间的配合理顺。很多人卡在“代码能跑但车不走”的阶段,其实是某个细节没对上——可能是TF树断了,可能是代价地图膨胀参数不对,也可能是传感器数据频率太低。我在调试过程中把这些问题一个个定位并解决,才真正理解了Nav2的体系结构。如果你也在做类似的项目,建议先拿这个仿真环境把导航全流程跑一遍,再逐步加入自己的算法,后面遇到真车问题就有排查的底子了。最后提醒一句:仿真里调好的参数,移植到真车时必须重新标定,尤其是膨胀半径和速度限制,别指望仿真值能直接沿用。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询