简介:这份资源面向机器人路径规划方向的研究者与开发者,聚焦ROS环境下人工势场法与A算法的融合实现,用于解决单一人工势场法易陷入局部极小值、难以稳定抵达目标的问题。压缩包共54个文件,约78KB,以cpp与h源码为主体,配合yaml参数、pgm栅格地图、launch启动文件、rviz可视化配置及xml插件描述,构成可编译运行的完整工程。内容围绕势场模型定义、A搜索中引入势场代价评估、ROS节点编写与插件封装展开,核心规划器源码便于读者深入理解吸引与排斥势场的计算方式、启发式搜索流程以及消息发布订阅机制。目前已有6331人学习下载,适合希望掌握混合路径规划思路、对照源码调试参数并提升机器人自主导航能力的中高级开发者参考。
1. 从一次局部极小值翻车说起:ROS 里为什么要把人工势场法和 A* 绑在一起
在 ROS 里做移动机器人路径规划,很多人第一次跑人工势场法(Artificial Potential Field,APF)都会遇到同一个场景:机器人在 Gazebo 里朝着目标点走得好好的,突然前面出现一个 U 型障碍,它一头扎进去,在凹槽里来回抖动,速度指令反复正负跳变,最后卡死在离目标点不到一米的地方。这不是参数没调好,而是人工势场法的固有缺陷——局部极小值。引力场和斥力场在某一点恰好抵消,合力为零,机器人就失去了方向。
A* 算法正好补上这块短板。它是基于栅格图的全局搜索算法,只要地图连通,就一定能找到从起点到目标点的最短路径,不存在局部极小值问题。但 A* 的短板也很明显:它输出的是离散的折线路径,机器人跟踪时拐角处需要急停转向,而且栅格分辨率越高,搜索耗时越长。把两者结合,常见的做法是:A* 负责全局粗规划,给出一条避开所有已知障碍的折线;人工势场法负责局部细调和动态避障,沿着 A* 的路径点走,遇到临时障碍或需要平滑过渡时用势场力修正。
这套组合在 ROS 的move_base框架里对应得很自然:global_planner插件换成 A*,local_planner插件用人工势场法,中间通过nav_msgs/Path传递路径。适合谁?适合已经能在 ROS 里跑通基本导航、但被局部极小值和路径抖动折磨过的朋友。如果你还在纠结ros在ubuntu哪个版本好,建议直接上 Ubuntu 20.04 + ROS Noetic,生态最全,鱼香ros一键安装也能省掉不少配环境的麻烦。下面从原理到代码,把这条路走一遍。
2. 人工势场法与 A* 的数学底子:合力怎么算、代价怎么定
2.1 引力场与斥力场的函数形式与参数含义
人工势场法的核心思想是把机器人当成一个在虚拟力场中运动的质点。目标点产生引力,障碍物产生斥力,合力决定机器人的运动方向。常用的引力场函数是二次型:
$$U_{att}(q) = \frac{1}{2} k_{att} \cdot d_{goal}^2(q)$$
其中 $k_{att}$ 是引力增益系数,$d_{goal}(q)$ 是机器人当前位置到目标点的欧氏距离。对 $U_{att}$ 求负梯度得到引力:
$$F_{att}(q) = - abla U_{att}(q) = k_{att} \cdot (q_{goal} - q)$$
斥力场函数通常用分段形式,只在障碍物影响范围内生效:
$$U_{rep}(q) = \begin{cases} \frac{1}{2} k_{rep} \left( \frac{1}{d_{obs}(q)} - \frac{1}{d_0} \right)^2 & d_{obs}(q) \leq d_0 \ 0 & d_{obs}(q) > d_0 \end{cases}$$
$k_{rep}$ 是斥力增益,$d_0$ 是障碍物影响半径,$d_{obs}(q)$ 是到最近障碍物的距离。斥力是斥力场的负梯度,方向从障碍物指向机器人。
参数怎么设?$k_{att}$ 一般取 1.0 到 2.0,太大机器人会冲过头,太小则靠近目标时速度太慢。$k_{rep}$ 通常比 $k_{att}$ 大一个量级,取 10 到 50,否则斥力推不动引力。$d_0$ 根据机器人尺寸和激光雷达量程来定,常见 0.5 到 1.5 米。这三个参数没有万能值,需要在 Gazebo 里反复试。
2.2 A* 的启发函数与栅格代价设计
A* 的评估函数是 $f(n) = g(n) + h(n)$,$g(n)$ 是从起点到节点 $n$ 的实际代价,$h(n)$ 是从 $n$ 到目标的启发式估计。在栅格地图上,如果只允许上下左右移动,$h(n)$ 用曼哈顿距离;如果允许八方向移动,用对角距离(Octile distance)更合适:
$$h_{octile}(n) = D \cdot (dx + dy) + (D\sqrt{2} - 2D) \cdot \min(dx, dy)$$
其中 $dx = |x_n - x_{goal}|$,$dy = |y_n - y_{goal}|$,$D$ 是单位移动代价。ROS 的costmap_2d里每个栅格有一个代价值,范围 0 到 254,0 是自由空间,253 是内切障碍,254 是致命障碍。A* 搜索时应该把栅格代价值乘进 $g(n)$,这样规划出的路径会自然远离障碍物,而不是贴着障碍边缘走。
一个容易忽略的点:costmap_2d的膨胀层会给障碍物周围栅格赋一个递减的代价值,A* 如果直接用二值地图(障碍/自由),就会忽略这个梯度信息,路径会紧贴障碍。正确做法是把costmap的代价值归一化后作为额外代价加到 $g(n)$ 上。
2.3 两种算法在 ROS 导航栈中的接口关系
ROS 的move_base把全局规划和局部规划分开:global_planner订阅全局costmap,发布nav_msgs/Path;local_planner订阅局部costmap和全局路径,发布geometry_msgs/Twist。A* 作为全局规划器,输出一条从起点到目标点的路径;人工势场法作为局部规划器,接收这条路径,把路径上的前瞻点当作临时目标点,同时叠加局部costmap中障碍物的斥力,计算出最终的速度指令。
接口的关键在于:局部规划器不能完全无视全局路径,否则就退化成纯 APF,还是会卡局部极小值。常见做法是取全局路径上距离机器人一定前瞻距离的点作为 APF 的引力目标,而不是直接用地全局目标点。这样机器人会沿着 A* 的路径走,同时 APF 负责微调避障。
3. 在 ROS 里把 A* 全局规划器接进 move_base:插件写法与配置
3.1 继承 nav_core::BaseGlobalPlanner 的最小实现
ROS 的全局规划器插件需要继承nav_core::BaseGlobalPlanner,实现initialize和makePlan两个纯虚函数。下面是一个最小可编译的 A* 全局规划器头文件和实现骨架:
// include/apf_astar_planner/astar_global_planner.h #ifndef APF_ASTAR_PLANNER_ASTAR_GLOBAL_PLANNER_H #define APF_ASTAR_PLANNER_ASTAR_GLOBAL_PLANNER_H #include <nav_core/base_global_planner.h> #include <costmap_2d/costmap_2d_ros.h> #include <geometry_msgs/PoseStamped.h> #include <nav_msgs/Path.h> #include <vector> #include <queue> namespace apf_astar_planner { struct Node { int x, y; double g, h; Node* parent; bool operator>(const Node& other) const { return (g + h) > (other.g + other.h); } }; class AStarGlobalPlanner : public nav_core::BaseGlobalPlanner { public: AStarGlobalPlanner(); AStarGlobalPlanner(std::string name, costmap_2d::Costmap2DROS* costmap_ros); void initialize(std::string name, costmap_2d::Costmap2DROS* costmap_ros) override; bool makePlan(const geometry_msgs::PoseStamped& start, const geometry_msgs::PoseStamped& goal, std::vector<geometry_msgs::PoseStamped>& plan) override; private: costmap_2d::Costmap2DROS* costmap_ros_; costmap_2d::Costmap2D* costmap_; bool initialized_; double neutral_cost_; // 自由栅格基础代价 double lethal_cost_; // 致命障碍代价阈值 double cost_factor_; // 代价值缩放因子 }; } // namespace apf_astar_planner #endifinitialize里保存costmap_ros指针并获取底层Costmap2D,makePlan里做三件事:世界坐标转栅格坐标、A* 搜索、栅格路径转PoseStamped序列。neutral_cost_一般设 1.0,lethal_cost_设 253,cost_factor_取 0.5 到 2.0 之间,用来调节路径远离障碍的程度。
3.2 A* 搜索核心:开放列表、代价更新与路径回溯
A* 搜索的实现细节决定了规划器的实用性和效率。下面这段代码是makePlan里的核心搜索逻辑:
// src/astar_global_planner.cpp 片段 bool AStarGlobalPlanner::makePlan( const geometry_msgs::PoseStamped& start, const geometry_msgs::PoseStamped& goal, std::vector<geometry_msgs::PoseStamped>& plan) { unsigned int mx_start, my_start, mx_goal, my_goal; if (!costmap_->worldToMap(start.pose.position.x, start.pose.position.y, mx_start, my_start)) return false; if (!costmap_->worldToMap(goal.pose.position.x, goal.pose.position.y, mx_goal, my_goal)) return false; int width = costmap_->getSizeInCellsX(); int height = costmap_->getSizeInCellsY(); std::vector<std::vector<double>> g_score(height, std::vector<double>(width, INFINITY)); std::vector<std::vector<bool>> closed(height, std::vector<bool>(width, false)); std::vector<std::vector<std::pair<int,int>>> parent(height, std::vector<std::pair<int,int>>(width, {-1, -1})); // 八方向移动,对角代价 sqrt(2) const int dx[8] = {1, -1, 0, 0, 1, 1, -1, -1}; const int dy[8] = {0, 0, 1, -1, 1, -1, 1, -1}; const double move_cost[8] = {1, 1, 1, 1, 1.414, 1.414, 1.414, 1.414}; auto heuristic = [&](int x, int y) { double ddx = std::abs(x - (int)mx_goal); double ddy = std::abs(y - (int)my_goal); return (ddx + ddy) + (1.414 - 2.0) * std::min(ddx, ddy); }; std::priority_queue<Node, std::vector<Node>, std::greater<Node>> open; g_score[my_start][mx_start] = 0.0; open.push({(int)mx_start, (int)my_start, 0.0, heuristic(mx_start, my_start), nullptr}); while (!open.empty()) { Node current = open.top(); open.pop(); if (closed[current.y][current.x]) continue; closed[current.y][current.x] = true; if (current.x == (int)mx_goal && current.y == (int)my_goal) { // 回溯路径 int cx = current.x, cy = current.y; while (cx != -1 && cy != -1) { geometry_msgs::PoseStamped pose; double wx, wy; costmap_->mapToWorld(cx, cy, wx, wy); pose.pose.position.x = wx; pose.pose.position.y = wy; pose.pose.orientation.w = 1.0; plan.push_back(pose); auto p = parent[cy][cx]; cx = p.first; cy = p.second; } std::reverse(plan.begin(), plan.end()); return true; } for (int i = 0; i < 8; ++i) { int nx = current.x + dx[i]; int ny = current.y + dy[i]; if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; unsigned char cost = costmap_->getCost(nx, ny); if (cost >= lethal_cost_) continue; // 致命障碍跳过 if (closed[ny][nx]) continue; // 把 costmap 代价值折算进移动代价 double step = move_cost[i] * (1.0 + cost_factor_ * cost / 255.0); double tentative_g = g_score[current.y][current.x] + step; if (tentative_g < g_score[ny][nx]) { g_score[ny][nx] = tentative_g; parent[ny][nx] = {current.x, current.y}; open.push({nx, ny, tentative_g, heuristic(nx, ny), nullptr}); } } } return false; // 开放列表耗尽,无路径 }这段代码有几个关键点。第一,closed数组防止重复扩展,但注意在代价可更新的情况下,标准 A* 的closed标记需要配合g_score判断,这里简化处理,因为栅格代价是静态的。第二,step的计算把costmap代价值乘进移动代价,cost_factor_越大路径越远离障碍。第三,启发函数用 Octile 距离,保证八方向移动下的可采纳性。第四,路径回溯后要reverse,因为是从目标往回找的。
3.3 plugin.xml 与 costmap_common_params 的配置写法
写好的规划器要注册成插件才能被move_base加载。在包根目录建planner_plugin.xml:
<library path="lib/libapf_astar_planner"> <class name="apf_astar_planner/AStarGlobalPlanner" type="apf_astar_planner::AStarGlobalPlanner" base_class_type="nav_core::BaseGlobalPlanner"> <description>A* global planner with costmap-aware cost</description> </class> </library>CMakeLists.txt里要加add_library和pluginlib_export_plugin_description_file,package.xml里要导出nav_core和pluginlib依赖。然后在move_base的 launch 文件里指定:
<param name="base_global_planner" value="apf_astar_planner/AStarGlobalPlanner"/> <param name="AStarGlobalPlanner/cost_factor" value="1.0"/> <param name="AStarGlobalPlanner/neutral_cost" value="1.0"/>cost_factor是暴露给 ROS 参数服务器的,方便在 launch 里调,不用重新编译。neutral_cost目前没在搜索里用到,但保留作为扩展接口。配置完用rospack plugins --attrib=plugin nav_core检查插件是否注册成功,如果列表里没有你的规划器,多半是plugin.xml路径或export标签写错了。
4. 人工势场法局部规划器:从合力计算到 cmd_vel 输出
4.1 以全局路径前瞻点为引力的改进势场
纯 APF 的引力目标就是全局目标点,这在 A* 给出路径后反而会出问题:机器人可能被局部障碍推离路径,然后引力又把它拉向全局目标,结果切过障碍。改进做法是把引力目标设为全局路径上距离机器人最近点往前一定前瞻距离的点。前瞻距离一般取 0.5 到 1.0 米,太小机器人会频繁转向,太大则可能切内弯撞障碍。
// 从全局路径中找最近点,再往前取前瞻点 geometry_msgs::PoseStamped getLookaheadPoint( const std::vector<geometry_msgs::PoseStamped>& global_plan, const geometry_msgs::PoseStamped& robot_pose, double lookahead_dist) { double min_dist = INFINITY; size_t nearest_idx = 0; for (size_t i = 0; i < global_plan.size(); ++i) { double dx = global_plan[i].pose.position.x - robot_pose.pose.position.x; double dy = global_plan[i].pose.position.y - robot_pose.pose.position.y; double d = std::hypot(dx, dy); if (d < min_dist) { min_dist = d; nearest_idx = i; } } double accum = 0.0; for (size_t i = nearest_idx; i + 1 < global_plan.size(); ++i) { double dx = global_plan[i+1].pose.position.x - global_plan[i].pose.position.x; double dy = global_plan[i+1].pose.position.y - global_plan[i].pose.position.y; accum += std::hypot(dx, dy); if (accum >= lookahead_dist) return global_plan[i+1]; } return global_plan.back(); }这个函数先找全局路径上离机器人最近的路径点,然后沿路径累积距离,返回第一个累积距离超过lookahead_dist的点。这样机器人始终盯着前方一段距离的路径点,而不是全局终点,路径跟踪更平滑。
4.2 斥力计算:激光雷达最近障碍与影响半径
斥力来自局部costmap或激光雷达扫描。用costmap的好处是已经做了障碍膨胀,不用自己处理传感器噪声。遍历局部costmap中机器人周围一定半径内的栅格,找到代价值最高的栅格作为最近障碍,计算斥力:
// 基于局部 costmap 计算斥力 geometry_msgs::Vector3 computeRepulsiveForce( costmap_2d::Costmap2D* local_costmap, double robot_x, double robot_y, double influence_radius, double k_rep) { geometry_msgs::Vector3 force; force.x = 0; force.y = 0; force.z = 0; unsigned int mx, my; if (!local_costmap->worldToMap(robot_x, robot_y, mx, my)) return force; int radius_cells = influence_radius / local_costmap->getResolution(); double max_cost = 0; int ox = -1, oy = -1; for (int dy = -radius_cells; dy <= radius_cells; ++dy) { for (int dx = -radius_cells; dx <= radius_cells; ++dx) { int nx = mx + dx, ny = my + dy; if (nx < 0 || ny < 0 || nx >= (int)local_costmap->getSizeInCellsX() || ny >= (int)local_costmap->getSizeInCellsY()) continue; unsigned char c = local_costmap->getCost(nx, ny); if (c > max_cost) { max_cost = c; ox = nx; oy = ny; } } } if (ox < 0 || max_cost < 128) return force; // 无有效障碍 double obs_x, obs_y; local_costmap->mapToWorld(ox, oy, obs_x, obs_y); double dx = robot_x - obs_x, dy = robot_y - obs_y; double dist = std::hypot(dx, dy); if (dist < 1e-6 || dist > influence_radius) return force; double mag = k_rep * (1.0/dist - 1.0/influence_radius) / (dist*dist); force.x = mag * dx / dist; force.y = mag * dy / dist; return force; }influence_radius和 APF 的 $d_0$ 对应,k_rep是斥力增益。max_cost < 128的判断是为了忽略膨胀层边缘的低代价值栅格,只对真正有威胁的障碍产生斥力。如果局部costmap里没有代价值超过 128 的栅格,说明周围安全,斥力为零。
4.3 合力限幅、速度映射与局部极小值逃逸策略
引力加斥力得到合力后,不能直接当速度用。合力方向作为期望航向,合力大小映射为线速度,同时限制最大速度和角速度。常见映射:
// 合力转 cmd_vel geometry_msgs::Twist forceToTwist( const geometry_msgs::Vector3& force, double robot_yaw, double max_linear, double max_angular) { geometry_msgs::Twist cmd; double mag = std::hypot(force.x, force.y); if (mag < 1e-3) { cmd.linear.x = 0; cmd.angular.z = 0; return cmd; } double desired_yaw = std::atan2(force.y, force.x); double yaw_error = desired_yaw - robot_yaw; while (yaw_error > M_PI) yaw_error -= 2*M_PI; while (yaw_error < -M_PI) yaw_error += 2*M_PI; cmd.angular.z = std::max(-max_angular, std::min(max_angular, 2.0 * yaw_error)); // 航向误差大时减速 double speed_scale = std::max(0.0, 1.0 - std::abs(yaw_error) / M_PI); cmd.linear.x = std::min(max_linear, mag) * speed_scale; return cmd; }局部极小值逃逸:即使有 A* 路径引导,如果前瞻点恰好被障碍挡住,合力仍可能为零。一个简单有效的策略是检测连续多帧合力幅值低于阈值且机器人速度接近零,就进入“逃逸模式”——随机选一个垂直于当前航向的方向,给一个固定角速度转一段时间,直到合力恢复或超时。这个逻辑不需要复杂的状态机,用一个计数器就能实现。
5. 避坑与排查:A* 加人工势场法在 ROS 里最容易翻车的 5 个点
5.1 现象:A* 规划出的路径贴着障碍物边缘走
原因:A* 搜索时只判断了栅格是否致命障碍,没有把costmap膨胀层的代价值计入移动代价,导致算法认为贴边和走中间代价一样,而贴边路径更短。
解决:在step计算里加入cost_factor_ * cost / 255.0,cost_factor_从 0.5 开始试,逐步加大到 2.0。同时确认costmap_common_params.yaml里inflation_radius设得合理,一般取机器人半径加 0.1 到 0.2 米。
5.2 现象:机器人沿全局路径走,但遇到动态障碍后偏离路径回不来
原因:APF 的引力目标用了全局终点而不是路径前瞻点,动态障碍的斥力把机器人推离路径后,引力直接指向终点,机器人试图切过障碍回到终点方向,而不是回到路径上。
解决:改用getLookaheadPoint取路径前瞻点作为引力目标。前瞻距离lookahead_dist取 0.6 到 0.8 米,太小会导致机器人频繁修正航向,太大则切内弯。
5.3 现象:局部规划器输出的 cmd_vel 抖动剧烈,机器人原地打转
原因:斥力计算时用了单个最近障碍栅格,障碍栅格在相邻帧之间跳变,导致斥力方向突变。另外合力没有做低通滤波,噪声直接传到了速度指令。
解决:斥力计算改为对影响半径内所有高代价栅格的斥力做矢量和,而不是只取最近一个。同时对最终的cmd.vel做一阶低通滤波:cmd_filtered = alpha * cmd_new + (1-alpha) * cmd_old,alpha取 0.3 到 0.5。
5.4 现象:A* 搜索在大地图上耗时超过 move_base 的规划周期
原因:开放列表用了std::priority_queue,每次 push 都复制 Node 结构体,而且没有做重复节点去重,同一个栅格可能被多次 push。地图越大,冗余节点越多。
解决:用std::unordered_map记录每个栅格的最佳g_score,push 前检查新g_score是否更优;或者用索引堆(indexed priority queue)减少内存分配。另外把costmap分辨率从 0.05 米降到 0.1 米,搜索节点数直接少四分之三,对路径精度影响在可接受范围内。
5.5 现象:插件编译通过但 move_base 启动时报 “Failed to create global planner”
原因:plugin.xml里的type字符串和 C++ 命名空间不匹配,或者CMakeLists.txt里没有把plugin.xml安装到正确路径,或者package.xml缺少pluginlib的export。
解决:用rospack plugins --attrib=plugin nav_core确认插件是否被识别。检查plugin.xml中type是否和头文件里的类全名一致(包括命名空间)。检查CMakeLists.txt里pluginlib_export_plugin_description_file(nav_core planner_plugin.xml)是否在catkin_package()之后。检查package.xml里是否有<nav_core plugin="${prefix}/planner_plugin.xml"/>。
6. 进阶技巧:用代价地图梯度替代离散斥力,让路径更顺
前面第 4 章里的斥力计算是基于离散栅格的,即使做了矢量和,斥力方向仍然会在栅格边界跳变。一个更顺滑的做法是直接用costmap的代价梯度作为斥力方向。costmap_2d::Costmap2D提供了getCost接口,我们可以用中心差分算梯度:
// 用 costmap 代价梯度替代离散斥力 geometry_msgs::Vector3 computeGradientRepulsion( costmap_2d::Costmap2D* costmap, double robot_x, double robot_y, double k_rep, double influence_radius) { geometry_msgs::Vector3 force; force.x = 0; force.y = 0; force.z = 0; unsigned int mx, my; if (!costmap->worldToMap(robot_x, robot_y, mx, my)) return force; // 中心差分算梯度 double c_left = costmap->getCost(mx-1, my); double c_right = costmap->getCost(mx+1, my); double c_down = costmap->getCost(mx, my-1); double c_up = costmap->getCost(mx, my+1); double grad_x = (c_right - c_left) / 2.0; double grad_y = (c_up - c_down) / 2.0; double grad_mag = std::hypot(grad_x, grad_y); if (grad_mag < 1e-3) return force; // 梯度方向指向代价增大方向,斥力应反向 double scale = k_rep * std::max(0.0, 1.0 - grad_mag / 255.0); force.x = -scale * grad_x / grad_mag; force.y = -scale * grad_y / grad_mag; return force; }这个方法的优势在于:梯度是连续的,斥力方向不会在栅格边界跳变;而且梯度天然指向代价增大方向,斥力反向就是远离障碍的方向,不需要单独找最近障碍。k_rep在这里的含义和离散版本略有不同,它控制的是斥力对梯度幅值的缩放,一般取 0.5 到 2.0 之间。
验证方法:在 Gazebo 里放一个 U 型障碍,分别用离散斥力和梯度斥力跑同一组目标点,用rostopic echo /cmd_vel录下角速度序列,算角速度的方差。梯度版本的角速度方差通常能降到离散版本的三分之一以下,机器人过弯明显更顺。
还有一个细节:costmap的梯度在膨胀层边缘最大,在致命障碍中心反而为零(因为周围都是 254,差分为零)。所以梯度斥力适合在膨胀层范围内使用,如果机器人已经贴到致命障碍,梯度法会失效,这时候需要回退到离散斥力或者直接触发紧急停止。我一般会在局部规划器里加一个判断:如果costmap当前栅格代价值超过 200,直接输出零速度并让恢复行为接管。
这套 A* 加人工势场法的组合,我从最早被局部极小值卡到怀疑人生,到后来把cost_factor和lookahead_dist两个参数摸清楚,前后调了大概两周。血泪经验是:不要指望一套参数跑所有场景,Gazebo 里调好的参数换到实车大概率要重调,因为实车的costmap噪声和定位漂移跟仿真完全不是一个量级。我现在的习惯是每个新场景先跑 A* 单独规划看路径质量,再开 APF 局部规划看速度平滑度,最后合在一起跑闭环。希望帮到你。
本文还有配套的精品资源,点击获取