简介:面向ROS机器人导航开发者的路径规划算法项目,将人工势场法与A搜索相结合,利用A的全局搜索能力弥补单一势场法易陷入局部极小值的缺陷,适用于移动机器人全局与局部混合规划场景。压缩包共54个文件,以C++源码与头文件为核心,辅以YAML参数文件、launch启动项、PGM栅格地图及RVIZ可视化配置,便于直接接入ROS环境完成仿真与调参,整体体积仅78KB。已有6331人参与学习下载。内容覆盖势场模型构建、启发式代价评估、节点扩展与混合A搜索流程,以及ROS插件封装等关键模块,代码结构与注释便于梳理算法主流程和各子模块接口。实际应用中,开发者可学习如何让A全局路径引导势场法走出局部最优,并通过调整吸引/排斥权重和影响范围来平衡路径平滑度与安全性,适合有一定ROS基础、正在研究路径规划或准备在实物机器人上部署导航算法的中高级学习者。
1. ROS实现人工势场法结合A算法_路径规划算法:A当队长、势场当保安,路径才跑得稳
ROS实现人工势场法结合A算法_路径规划算法,拆开看其实是两件事:让A算法在栅格地图上先找出一条可行的全局主通道,再让人工势场法沿这条通道处理动态障碍、贴边平滑和突发避让。很多做ROS小车导航的朋友一开始只依赖move_base自带的全局规划器,遇到障碍物就频繁重规划,重规划期间机器人原地打转;转成纯人工势场法又会被U形障碍困住,在局部极小值里来回抖动。把两种算法串成同一条路径规划链,是工程实战里比较稳的一种解法,也适合做毕业设计、比赛改装和实际产品原型。这篇文章按我自己的落地顺序来写:先说清楚结合的原理和选型,再给一套能在Gazebo里跑通的最小ROS实现,最后列参数坑和验证手段。
2. A*与人工势场法各自的边界,以及ROS里融合方案的选型判断
2.1 A*算法当全局规划器:不是找单条线,而是留出一条安全走廊
A*算法在路径规划里的地位不用多讲。它维护两个代价:从起点到当前节点的实际代价g(n),以及从当前节点到目标点的启发式估计h(n)。每次从优先队列里取出f(n)=g(n)+h(n)最小的节点,然后向邻域扩展。只要启发式不高估真实代价,第一次找到目标时就是最优路径。
但真正用过A*的人都知道,地图分辨率、邻域类型、启发式权重这三个参数对结果影响极大。分辨率太高,搜索速度肉眼可见地变慢;邻域用4邻域,路径拐直角弯多,机器人走起来一顿一顿;8邻域配合对角线距离则自然很多。启发式权重取1.0是最优性保证的底线,取1.2到1.5则会让搜索更快,但路径可能稍微绕一点。
很多初学者把这三种参数当成“魔法值”去抄,抄完发现路径贴着墙。原因很简单:A只在静态栅格地图上搜索,它根本不知道机器人是有半径的。所以在做全局路径时,必须先对栅格地图做膨胀,把机器人半径和传感器误差都算进障碍物距离里。常见做法是直接用costmap_2d的inflation层,或者在建图时把地图边缘向内腐蚀一圈。否则A给出的所谓最优路径,在真车上就是一道贴墙题。
2.2 人工势场法当局部控制器:力的计算比搜索更适合动态环境
人工势场法把机器人放在一个人造势场里:目标点产生引力场,障碍物产生斥力场,机器人沿负梯度方向走。经典的势场函数是这样的:
引力势:U_att = 0.5 * ξ * d_goal²,对位置求导得到引力F_att = ξ * (goal - robot)。斥力势:当障碍物距离ρ小于作用半径ρ0时,U_rep = 0.5 * η * (1/ρ - 1/ρ0)²,相应的斥力大小会随着ρ变小急剧增大。两个力向量相加,就是机器人下一步的运动趋势。
这个机制的优势是反应快。激光雷达一帧到,斥力就能立刻更新,不需要像A*那样重新搜索全图。但它最大的毛病也众所周知:局部极小值。引力斥力在某一点恰好平衡,机器人就觉得“这地方已经到头了”,开始绕圈或抖动。所以纯粹挥舞人工势场法做长距离导航几乎都会翻车,除非有人给它指一条路。
这里我给一个最简力合成代码,后续ROS节点就是在这个基础上的。参数只有三个:att_gain控制吸引强度,rep_gain控制斥力强度,rho0控制斥力有效半径。
#!/usr/bin/env python3 # 人工势场法的力合成,保留最核心逻辑 import numpy as np def potential_fields(robot_pos, goal_pos, obstacles, att_gain=1.0, rep_gain=4.0, rho0=0.5): # robot_pos: [x, y],goal_pos: [x, y] # obstacles: 障碍物点列表 [[ox, oy], ...] # rho0: 斥力作用半径,单位米 F_att = att_gain * (np.array(goal_pos) - np.array(robot_pos)) F_total = np.array(F_att, dtype=float) for o in obstacles: delta = np.array(robot_pos) - np.array(o) rho = np.linalg.norm(delta) if 0 < rho < rho0: # 经典斥力公式:越近越大,且距离倒数平方梯度 F_rep = rep_gain * (1.0 / rho - 1.0 / rho0) / rho**2 * delta F_total += F_rep # 限制总力模长,避免速度指令出现尖峰 norm = np.linalg.norm(F_total) if norm > 1.0: return F_total / norm return F_total逻辑说明:斥力方向沿机器人指向障碍物的反方向,也就是远离障碍物。作用半径rho0之外完全无视,保证远处凹凸不影响路径。最后把总力归一化到单位向量,这样底层控制接收到的只是方向和强度等级,不会因为某个障碍物贴太近就输出一个爆炸速度。实际用的时候,我一般会把总力放大后再乘以最大线速度,让机器人有稳定加速过程。
2.3 两种结合方式:串行引导和并行叠加,我选串行
把A*和人工势场法放在一起,常见的工程方案有两种。
第一种是串行引导。A先规划一条全局路径,局部控制层沿路径取一个“前瞻点”作为临时目标,再用人工势场法对障碍物做局部斥力。前瞻点距离通常在0.3到0.8米,离得太近机器人容易贴着全局路径追点,走得很机械;离得太远又等于无视局部环境。这种方案结构清晰,A只管宏观道路,势场只管局部避让,排查问题也容易。
第二种是并行叠加。把A*路径本身当作一个“路径引力场”,与障碍物斥力场叠加,再做一次全局势场寻优。这样融合看似更优雅,但实现起来两个势场会打架。路径引力会把机器人拉向路径,障碍物斥力又推开,参数稍有偏差就会在路径两侧震荡。调参一个晚上,问题也不一定收敛。
所以我推荐串行。ROS里最常见的落地方式也很简单:写两个节点,一个输出全局路径,一个订阅路径和激光数据,发布速度指令。如果你不想自己造轮子,也可以把A*用nav2或move_base的global planner承担,把势场局部控制器做成自定义local planner。但为了讲清楚原理,下面从零写两个节点。
3. 在ROS里搭出最小闭环:从栅格地图到速度指令的四层结构
3.1 先创建ROS包,把地图和坐标前提准备好
路径规划在ROS里并不是孤立节点的事,它要依赖代价地图和TF。为了最小可复现,我在ROS1 Noetic环境里建一个hybrid_planner包,后续代码都放在里面。如果你用的是ROS2,整体思路一样,只是话题名、包名和TF API要换一下。
mkdir -p ~/apf_ws/src cd ~/apf_ws/src catkin_create_pkg hybrid_planner rospy nav_msgs geometry_msgs sensor_msgs tf2_ros cd ~/apf_ws catkin_make source devel/setup.bashcatkin_create_pkg后面的依赖,rospy负责节点框架,nav_msgs里拿OccupancyGrid和Path,geometry_msgs负责Pose和Twist,sensor_msgs读激光雷达,tf2_ros做坐标变换。这一条命令把后续的接口都定下来了。地图方面我建议直接用map_server发布occupancy grid,或者自己写一个Python脚本把PGM地图读成numpy数组,再发布成nav_msgs/OccupancyGrid。重点说一句:地图分辨率必须和A*的栅格搜索分辨率一致,否则路径坐标和真机坐标对不上,这是最常见的黑匣子问题。
3.2 A*全局路径节点:先在地图上找出主通道
这个节点订阅一个目标点话题,收到目标后在内存里的栅格地图上执行A*搜索,把结果发布成nav_msgs/Path。实现只保留核心搜索逻辑,正常情况下一次几米范围的规划耗时应小于50毫秒。
#!/usr/bin/env python3 import rospy import heapq import numpy as np from nav_msgs.msg import OccupancyGrid, Path from geometry_msgs.msg import PoseStamped class AstarGlobalPlanner: def __init__(self): rospy.init_node("astar_global_planner") self.map = None self.path_pub = rospy.Publisher("/global_path", Path, queue_size=1) rospy.Subscriber("/map", OccupancyGrid, self.map_cb) rospy.Subscriber("/goal", PoseStamped, self.goal_cb) self.weight = rospy.get_param("~weight", 1.05) # 启发式权重 self.frame_id = "map" def map_cb(self, msg): # 占用栅格阈值设为50,大于50视为不可通过 self.map = np.array(msg.data).reshape(msg.info.height, msg.info.width) self.resolution = msg.info.resolution self.origin = msg.info.origin def goal_cb(self, goal): if self.map is None: rospy.logwarn("地图还没收到,先等map话题") return start = self.world_to_grid(goal.pose.position.x, goal.pose.position.y) goal_idx = self.world_to_grid( rospy.get_param("/goal_x", 2.0), rospy.get_param("/goal_y", 2.0)) path = self.plan(start, goal_idx) if not path: rospy.logwarn("A*没有找到可行路径") return self.publish_path(path) def world_to_grid(self, x, y): # 原点在左下的地图,转换为行列索引 col = int((x - self.origin.position.x) / self.resolution) row = int((y - self.origin.position.y) / self.resolution) return (row, col) def plan(self, start, goal, grid): if grid[start] > 50 or grid[goal] > 50: return [] open_set = [] came_from = {} g_score = {start: 0.0} f_score = {start: self.heuristic(start, goal)} heapq.heappush(open_set, (f_score[start], start)) while open_set: _, current = heapq.heappop(open_set) if current == goal: return self.reconstruct(came_from, current) for nb in self.neighbors(current, grid): tentative = g_score[current] + self.cost(current, nb) if tentative < g_score.get(nb, float("inf")): came_from[nb] = current g_score[nb] = tentative heapq.heappush(open_set, (tentative + self.weight * self.heuristic(nb, goal), nb)) return [] def heuristic(self, a, b): # 8邻域用对角距离 return max(abs(a[0] - b[0]), abs(a[1] - b[1])) def neighbors(self, cell, grid): for di, dj in ((-1,0),(1,0),(0,-1),(0,1),(-1,-1),(-1,1),(1,-1),(1,1)): n = (cell[0]+di, cell[1]+dj) if 0 <= n[0] < grid.shape[0] and 0 <= n[1] < grid.shape[1] and grid[n] <= 50: yield n def cost(self, a, b): return 1.0 if a[0] == b[0] or a[1] == b[1] else 1.4 def reconstruct(self, came_from, current): path = [current] while current in came_from: current = came_from[current] path.append(current) return path[::-1] def publish_path(self, grid_path): path_msg = Path() path_msg.header.frame_id = self.frame_id for row, col in grid_path: p = PoseStamped() p.pose.position.x = self.origin.position.x + (col + 0.5) * self.resolution p.pose.position.y = self.origin.position.y + (row + 0.5) * self.resolution path_msg.poses.append(p) self.path_pub.publish(path_msg) if __name__ == "__main__": AstarGlobalPlanner() rospy.spin()这段代码的逻辑不复杂:收到地图后维持一个原始占用栅格,收到目标点就从目标点反查栅格坐标。A*搜索用heapq实现,弹出f值最小的节点,扩展8邻域,跳过占用值大于50的格子。启发式用对角距离是因为邻域包含斜向移动,相比曼哈顿距离更贴合实际代价。参数里最重要是rosparam的weight,取1.05到1.3之间能明显提速,同时又不会让路径严重绕远。还有一个小点是world_to_grid里的0.5偏移,它保证栅格坐标对应的是格子中心,而不是格子左下角。很多人漏了这个,导致路径箭头全都偏半格,视觉上不明显,机器人压线却很严重。
3.3 人工势场局部控制器:把全局路径变成避障速度指令
局部控制器订阅全局路径和激光雷达,取路径前瞻点作为引力源,激光点作为障碍物斥力源,合成速度指令发布到/cmd_vel。
#!/usr/bin/env python3 import rospy import numpy as np from sensor_msgs.msg import LaserScan from nav_msgs.msg import Path from geometry_msgs.msg import Twist, PoseStamped class ApfLocalPlanner: def __init__(self): rospy.init_node("apf_local_planner") self.path = [] self.path_index = 0 self.scan = None self.twist_pub = rospy.Publisher("/cmd_vel", Twist, queue_size=1) self.att_gain = rospy.get_param("~att_gain", 1.5) self.rep_gain = rospy.get_param("~rep_gain", 8.0) self.rho0 = rospy.get_param("~rho0", 0.45) self.lookahead = rospy.get_param("~lookahead", 0.5) self.max_speed = rospy.get_param("~max_speed", 0.25) rospy.Subscriber("/scan", LaserScan, self.scan_cb) rospy.Subscriber("/global_path", Path, self.path_cb) def path_cb(self, msg): self.path = [(p.pose.position.x, p.pose.position.y) for p in msg.poses] self.path_index = 0 def scan_cb(self, msg): if self.path is None or len(self.path) == 0: return # 简化为利用最近障碍物生成斥力,实际可迭代全部扫描点 robot_pose = self.get_pose() # 此处由odom/TF得到 if robot_pose is None: return goal = self.lookahead_point(robot_pose) F = self.attractive(robot_pose, goal) F += self.repulsive(msg) ftwist = self.force_to_twist(F, target_yaw=None) self.twist_pub.publish(ftwist) def get_pose(self): # 实际代码建议从odom话题读位姿,这里只保留接口 return (0.0, 0.0, 0.0) def lookahead_point(self, robot_pose): # 从当前路径索引向后找距离超过lookahead的点 while self.path_index < len(self.path) - 1: x, y = self.path[self.path_index] d = np.hypot(x - robot_pose[0], y - robot_pose[1]) if d < self.lookahead: self.path_index += 1 else: break return self.path[min(self.path_index, len(self.path) - 1)] def attractive(self, robot_pose, goal): dx, dy = goal[0] - robot_pose[0], goal[1] - robot_pose[1] return self.att_gain * np.array([dx, dy]) def repulsive(self, scan): F = np.zeros(2) for r, theta in zip(scan.ranges, self.angle_list(scan)): if r == 0 or np.isnan(r) or r > self.rho0: continue ox = scan.ranges[0] * 0 # 占位,真实代码用r*cos(theta)在机器人系下 oy = 0 # 斥力方向应当用障碍物在机器人系下的坐标计算 rho = max(r, 1e-3) strength = self.rep_gain * (1.0 / rho - 1.0 / self.rho0) / rho**2 F += strength * np.array([-np.cos(theta), -np.sin(theta)]) return F @staticmethod def angle_list(scan): return [scan.angle_min + i * scan.angle_increment for i in range(len(scan.ranges))]这段代码是一家可跑起来的骨架,但里面有两个地方必须自己补齐。第一个是get_pose,需要用tf2把机器人坐标系变换到map坐标系,或者直接用可用的odom位置。第二个是repulsive里的障碍物坐标,我写成了占位,实际应该根据激光距离和角度得到障碍物点在机器人系下的坐标,然后再投影到map系,才和全局路径引力在同一坐标系里相加。这是势场法里最容易黑匣子的坐标问题:本质上,两个力必须在同一个坐标系,不然合成方向完全错乱。从参数上看,lookahead是全局路径和势场法的结合点,我一般设成0.4到0.6米;rho0必须大于机器人半径,避免激光直接把机器人本身当成障碍物。
3.4 用launch文件把两个节点串成最小闭环
单节点实现还不够,还要让A*全局规划、势场局部规划、仿真器三者之间话题对齐。我习惯用一个launch把节点全带起来。
<launch> <param name="robot_description" command="$(find xacro)/xacro --inorder $(find turtlebot3_description)/urdf/turtlebot3_burger.urdf.xacro"/> <node pkg="map_server" type="map_server" name="map_server" args="$(find my_nav)/maps/room.yaml"/> <node pkg="hybrid_planner" type="astar_global.py" name="astar_global"> <param name="weight" value="1.1"/> <param name="~frame_id" value="map"/> </node> <node pkg="hybrid_planner" type="apf_local.py" name="apf_local"> <param name="~att_gain" value="1.5"/> <param name="~rep_gain" value="8.0"/> <param name="~rho0" value="0.45"/> <param name="~lookahead" value="0.5"/> <param name="~max_speed" value="0.25"/> </node> <node pkg="rviz" type="rviz" name="rviz" args="-d $(find my_nav)/rviz/nav.rviz"/> </launch>launch里最值得注意的参数是rho0和max_speed。rho0决定了机器人“多近才算危险”,太小会直接撞上,太大则会绕远路。max_speed和势场力没有直接关系,但它决定上层给出的虚拟力最终映射成多大线速度和角速度,仿真器里调太快容易振荡,0.2到0.3米每秒比较靠谱。如果跑起来发现机器人不动,先检查map坐标系和odom坐标系是否都发布,再检查/global_path是否有数据,最后再检查/cmd_vel是否在被别的节点抢占。ROS节点地图收音机式的黑匣子问题,九成都出在这三步上。
4. 融合路上的常见问题与避坑记录:五个真实翻车现场
4.1 现象:机器人陷入局部极小值,在障碍物前反复抖动
表现是机器人在一个U形或凹形障碍物前面来回蹭,明明目标就在墙后,它就是不绕过。原因非常经典:人工势场法在局部极小点附近引力与斥力相互抵消,机器人以为已经到达终点。只用势场法必然遇到,Noetic、Foxy都一样,跟ROS版本无关。
解决的思路是让势场法承认全局规划员的权威。如果全局路径能够提供一条绕行路线,局部势场就不应该再对墙背后的目标直接引,而是对路径上的前瞻点引。这就要回到第3章里的lookahead_point,确保前瞻点始终落在A路径上,而不是直接指向终点。我一般还会加一层超时保护:检测到连续2秒速度模长小于0.05,且与目标距离没明显减少,就强制发送一个抢占型全局重规划请求,用A重新绕路。这一招能救回八成卡死现场。
4.2 现象:目标点附近有障碍物时,机器人一直停在原地不动
这是“目标不可达”问题。经典斥力场在目标点附近非零,因为目标点和障碍物距离可能很小,斥力比引力还大,于是机器人无法贴进目标。现象是明明终点在面前,却怎么推都推不进去。
原因在于斥力函数没有考虑机器人到目标的距离。改进方法是给斥力场加一个距离影响系数,让机器人在靠近目标时,斥力随到目标的距离减小而衰减。常见的做法是把斥力势场乘上因子(1 - e^(-α * d_goal²)),当接近目标时因子趋近于零,斥力不再压制引力。另外,还可以在到达判断中设置目标点范围,比如距离小于0.15米时直接切换到PID速度伺服,不再用势场力驱动。这样既保留障碍物附近的避障性能,又不会在终点前僵持。
4.3 现象:A*选的路不算错,但机器人走起来贴墙太近,姿态很别扭
现象在rviz里看,路径刚好从墙根穿过,拓扑上可行,但真车或者Gazebo里机器人半径比占用栅格大,薄薄一层墙皮就蹭到了。根因是全局规划用的地图没有膨胀,或者膨胀半径小于机器人本体半径。
解决分两层。一是地图层,直接使用costmap_2d的inflation层,让代价地图在障碍物周围长出“危险区”,A*搜索时把这些膨胀区视作不可通行。二是算法层,如果不想引入costmap,可以在拿到栅格数据后自己写一步形态学膨胀,把占用格周围半米内全部标成占用。实际项目中必须保证膨胀半径≥机器人外接圆半径+车速停不下来产生的滑行距离,Berger常用的值是0.3到0.5米,具体看底盘刹车性能。
4.4 现象:局部势场和全局路径方向打架,机器人走“之”字
现象是机器人明明沿着走廊走,却总在左右摆动,轨迹像锯齿。原因有两个:一是力合成时全局引力用的是当前目标点,而不是路径前瞻点,导致机器人每到一个路径点就像换了个目标,方向跳变;二是势场更新频率太低,或者速度指令没有低通滤波,激光帧率30Hz,速度指令却10Hz,力之间的跳变直接被底盘放大。
解决方法是给速度指令做平滑,我习惯在局部控制器里维护一个一阶低通:v_out = α * v_target + (1 - α) * v_last,α取0.3到0.5。同时把前瞻点设置得比控制周期远一些,保证方向变化是渐进的。还有一个经验是,角速度不要直接跟随势场合力方向,而是先由线速度方向决定转向误差,再用PID输出角速度。这样机器人走起来更像车,而不是像全向漂移。
4.5 现象:动态障碍物出现时,全局A*反复重新规划导致卡顿
表现是激光雷达里出现一个新障碍物,A节点瞬间触发重规划,路径频繁跳变,机器人速度指令一会向前一会刹车。原因很直接:全局规划被无穷次重算,而A在几千个栅格节点上反复搜索,耗时波动大,局部控制器等不到稳定路径,自然像得了帕金森。
解决方法是给全局规划加节流保护。我通常在goal_cb里记录上一次重规划时间,至少间隔1.5秒才允许再次重规划;并且只有当机器人当前位置与目标之间的路径点被新障碍物阻断时,才真正触发重规划,否则只是局部势场临时绕一下。这样可以合理地把A*当成低频战略层,人工势场当成高频战术层,两者的时间尺度拉开,系统才不会互相怼。
5. 把融合结果的验证闭环跑完:记录数据、看指标、再调参数
很多人在rviz里看到一条绿色路径就以为工期完成,实际上路径规划算法最需要的是量化验证。我会在Gazebo里放一个小房子地图,让机器人从起点到终点跑十次,记录/scan、/global_path、/cmd_vel和tf。
先把数据录下来:
mkdir -p ~/bag_logs rosbag record -O ~/bag_logs/apf_astar_test.bag \ /scan /global_path /goal /cmd_vel /odom /tf然后回放,用Python脚本从/global_path里数出路径点数,从/odom里算实际行驶距离和到达时间,从/scan里统计整个过程中离障碍物的最小距离。如果最小距离小于膨胀半径,立刻检查是不是rho0太小,或者车速太快刹不住。如果行驶距离明显大于A*路径长度,大概率是局部势场绕了太多远路,此时把lookahead调大,让机器人更信任全局路径。
最后一个习惯是我的血泪经验:每次调完参数,不要只改一个值就重跑全地图,那等于在碰运气。正确的做法是做一个简单的参数表格,把att_gain、rep_gain、rho0、lookahead、weight五组参数组合,每个组合跑两遍,手动记录“到达成功率”“路径长度”“最小到场距离”三个指标,两轮取平均后再对比。势场法和A*融合本来就是参数敏感型算法,单次数跑出来的随机性很大,必须用平均数据说话。我现在换任何一个地图,都是先把这套验证流程跑两遍,再决定要不要继续调。这样至少能保证不是靠玄学在工作。希望帮到你。
本文还有配套的精品资源,点击获取