ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ROS移动机器人路径规划:A*与人工势场法融合实战

ROS移动机器人路径规划:A*与人工势场法融合实战 简介这份资源面向机器人路径规划方向的研究者与开发者聚焦ROS环境下人工势场法与A算法的融合实现用于解决单一人工势场法易陷入局部极小值、难以稳定抵达目标的问题。压缩包共54个文件约78KB以cpp与h源码为主体配合yaml参数、pgm栅格地图、launch启动文件、rviz可视化配置及xml插件描述构成可编译运行的完整工程。内容围绕势场模型定义、A搜索中引入势场代价评估、ROS节点编写与插件封装展开核心规划器源码便于读者深入理解吸引与排斥势场的计算方式、启发式搜索流程以及消息发布订阅机制。目前已有6331人学习下载适合希望掌握混合路径规划思路、对照源码调试参数并提升机器人自主导航能力的中高级开发者参考。1. 从一次局部极小值翻车说起ROS 里为什么要把人工势场法和 A* 绑在一起在 ROS 里做移动机器人路径规划很多人第一次跑人工势场法Artificial Potential FieldAPF都会遇到同一个场景机器人在 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 到 2540 是自由空间253 是内切障碍254 是致命障碍。A* 搜索时应该把栅格代价值乘进 $g(n)$这样规划出的路径会自然远离障碍物而不是贴着障碍边缘走。一个容易忽略的点costmap_2d的膨胀层会给障碍物周围栅格赋一个递减的代价值A* 如果直接用二值地图障碍/自由就会忽略这个梯度信息路径会紧贴障碍。正确做法是把costmap的代价值归一化后作为额外代价加到 $g(n)$ 上。2.3 两种算法在 ROS 导航栈中的接口关系ROS 的move_base把全局规划和局部规划分开global_planner订阅全局costmap发布nav_msgs/Pathlocal_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::vectorgeometry_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指针并获取底层Costmap2DmakePlan里做三件事世界坐标转栅格坐标、A* 搜索、栅格路径转PoseStamped序列。neutral_cost_一般设 1.0lethal_cost_设 253cost_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::vectorgeometry_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::vectorstd::vectordouble g_score(height, std::vectordouble(width, INFINITY)); std::vectorstd::vectorbool closed(height, std::vectorbool(width, false)); std::vectorstd::vectorstd::pairint,int parent(height, std::vectorstd::pairint,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_queueNode, std::vectorNode, std::greaterNode 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.xmllibrary pathlib/libapf_astar_planner class nameapf_astar_planner/AStarGlobalPlanner typeapf_astar_planner::AStarGlobalPlanner base_class_typenav_core::BaseGlobalPlanner descriptionA* global planner with costmap-aware cost/description /class /libraryCMakeLists.txt里要加add_library和pluginlib_export_plugin_description_filepackage.xml里要导出nav_core和pluginlib依赖。然后在move_base的 launch 文件里指定param namebase_global_planner valueapf_astar_planner/AStarGlobalPlanner/ param nameAStarGlobalPlanner/cost_factor value1.0/ param nameAStarGlobalPlanner/neutral_cost value1.0/cost_factor是暴露给 ROS 参数服务器的方便在 launch 里调不用重新编译。neutral_cost目前没在搜索里用到但保留作为扩展接口。配置完用rospack plugins --attribplugin nav_core检查插件是否注册成功如果列表里没有你的规划器多半是plugin.xml路径或export标签写错了。4. 人工势场法局部规划器从合力计算到 cmd_vel 输出4.1 以全局路径前瞻点为引力的改进势场纯 APF 的引力目标就是全局目标点这在 A* 给出路径后反而会出问题机器人可能被局部障碍推离路径然后引力又把它拉向全局目标结果切过障碍。改进做法是把引力目标设为全局路径上距离机器人最近点往前一定前瞻距离的点。前瞻距离一般取 0.5 到 1.0 米太小机器人会频繁转向太大则可能切内弯撞障碍。// 从全局路径中找最近点再往前取前瞻点 geometry_msgs::PoseStamped getLookaheadPoint( const std::vectorgeometry_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[i1].pose.position.x - global_plan[i].pose.position.x; double dy global_plan[i1].pose.position.y - global_plan[i].pose.position.y; accum std::hypot(dx, dy); if (accum lookahead_dist) return global_plan[i1]; } 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.0cost_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_oldalpha取 0.3 到 0.5。5.4 现象A* 搜索在大地图上耗时超过 move_base 的规划周期原因开放列表用了std::priority_queue每次 push 都复制 Node 结构体而且没有做重复节点去重同一个栅格可能被多次 push。地图越大冗余节点越多。解决用std::unordered_map记录每个栅格的最佳g_scorepush 前检查新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 --attribplugin 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(mx1, my); double c_down costmap-getCost(mx, my-1); double c_up costmap-getCost(mx, my1); 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 局部规划看速度平滑度最后合在一起跑闭环。希望帮到你。本文还有配套的精品资源点击获取
返回列表