- 文中“A*算法”、“grid_path算法”注释转自“移动机器人导航技术笔记(1)-global_planner学习”。
- 网上文章在说如何优化,像“ROS:一种简单的基于global_planner的全局路径优化方法”。
- 成本。在不断遍历栅格时,把两栅格之间衡量距离的那个数值称为成本。没用距离,考虑到距离一般表示两点之间的线段长度。也不用代价,这里专门用代价指代价地图中的栅格值。代码中,成本矩阵potential_array中单元值potential就是start到该点需要的成本。
- [a,b]。表示结点a到结点b的实际最短路径成本。设起始结点为S,当前结点为n,目标结点为G,于是n的实际成本应该是f*(n)=g*(n)+h*(n),其中g*(n)=[S,n],h*(n)=[n,G],对于是g*(n)是比较容易得到的,在搜索的过程中我们可以按搜索的顺序对它进行累积计算。
- 判断路径下一个点a,是要把足迹放在该点a上进行判断吗?从a点出发,向前有三个方向,难道它要把足迹转三个方向都判断一次?——计算全局路径时就没用到足迹结构(footprint)。那如何做到考虑足迹?在判断某栅格是否可衍生时,剔除的不仅仅障碍栅格,还包括内切圆(膨胀)栅格。这意味着,只要该栅格被放入OPEN表,它和障碍栅格间至少距离了内切圆半径,也就达到考虑足迹目的。剔除膨胀栅格参考DijkstraExpansion::getCost,当中lethal_cost_是253(INSCRIBED_INFLATED_OBSTACLE),“c < lethal_cost_ - 1”表示只有代价小于“INSCRIBED_INFLATED_OBSTACLE - 1”的栅格才会放入OPEN表。
- 起点(start)和终点(goal)距离非常近,makePlan会因为距离太近而失败吗?——不会。即使近到同一个栅格,也不会因距离近而失败。
- makePlan时,会修改地图中3类栅格的代价。1)start所在栅格,改为FREE_SPACE。2)goal和它周围8个栅格,改为FREE_SPACE。3)边界那一圈栅格,改为LETHAL_OBSTACLE。(2)是rose_ros新加的,这么做的原因:导航时,机器人并不是要准确到达goal指定栅格,到达附近就行。加之goal栅格代价“>=lethal_cost_ - 1”时,calculatePotentials会失败,导致makePlan失败。另外,(2)在makePlan后,相关栅格会恢复回原貌,(1)、(3)则不会。
- 同步全局代价地图。1)makePlan用的代价地图就是move_base中的planner_costmap_ros_。planner_costmap_ros_在全局代价地图的upateMap线程更新,makePlan运行在planThread线程,它们之间须要同步。2)在makePlan,首先会申请掉costmap锁,防止updateMap更新地图。同样,updateMap也要申请掉costmap锁,防止makePlan用这地图去规划路径。3)makePlan规完出一条路径需要点时间,这会不会影响其它线程使用到“最新”地图。local_costmap这个Costmap2DROS不用全局地图,因而全局地图的作用可说就是makePlan,所以只要让每次makePlan使用最新地图,makePlan时间长点不会对其它模块造成太多影响。
- 是否可以用全局规划间接实现探索建图?假设机器人站在一个代价已知的栅格start,然后指定一个未知区域栅格goal,makePlan能算出一条从start到goal的全局路径吗。——不能。makePlan要求start到goal的代价地图绝大多数是已知代价。探索时,栅格代价很多是未知的。
一、nav_core::BaseGlobalPlanner
对全局规划(global planner),ros提供的三种实现:navfn/NavfnROS、global_planner/GlobalPlanner、carrot_planner/CarrotPlanner,本文分析其中一种实现:global_planner/GlobalPlanner。它们都从BaseGlobalPlanner派生,主要操作是两个。
1.1 initialize(name, costmap)
初始化,算法实例p_calc_、planner_、path_maker_的选取。主要是以下三个实例。
- 计算起始点s到点n的成本,即g(n)。p_calc_:PotentialCalculator::calculatePotential()、 QuadraticCalculator::calculatePotential()
- 计算成本矩阵potential_array_,内中要使用p_calc_。planner_:DijkstraExpansion::calculatePotentials()、 AStarExpansion::calculatePotentials()
- 从成本矩阵中提取路径path。path_maker_:GridPath::getPath()、 GradientPath::getPath()

1.2 makePlan(start, goal, plan)
生成全局路径。要生成路径时,调用者只要须要调用一次makePlan,它主要两个步骤。
- 计算成本矩阵potential_array(planner_->calculatePotentials)
- 从成本矩阵中提取路径plan(path_maker_->getPath)
二、GlobalPlanner::makePlan
- [IN]start:此刻机器人中心在/map坐标系下的位姿。
- [IN]goal:此次导航要到达的目标。
- [IN]tolerance:默认0。
- [IN]plan:存储计算出的全局路径。
bool GlobalPlanner::makePlan(const geometry_msgs::PoseStamped& start, const geometry_msgs::PoseStamped& goal, double tolerance, std::vector<geometry_msgs::PoseStamped>& plan) { boost::mutex::scoped_lock lock(mutex_); if (!initialized_) { ROS_ERROR( "This planner has not been initialized yet, but it is being used, please call initialize() before use"); return false; } //clear the plan, just in case plan.clear(); ros::NodeHandle n; std::string global_frame = frame_id_; //until tf can handle transforming things that are way in the past... we'll require the goal to be in our global frame if (goal.header.frame_id != global_frame) { return false; } if (start.header.frame_id != global_frame) { return false; }
goal、start必须是/map坐标系下的位姿。
double wx = start.pose.position.x; double wy = start.pose.position.y; unsigned int start_x_i, start_y_i, goal_x_i, goal_y_i; double start_x, start_y, goal_x, goal_y; if (!costmap_->worldToMap(wx, wy, start_x_i, start_y_i)) {
用worldToMap把world坐标系下的坐标(wx, wy)转换到map坐标系下坐标。如果(wx, wy)不在代价地图内,它将返回false。
return false; } if(old_navfn_behavior_){
对libros,old_navfn_behavior_总是false,不会进这入口。
start_x = start_x_i; start_y = start_y_i; } else{ worldToMap(wx, wy, start_x, start_y); }
此个worldToMap对应的是GlobalPlanner::worldToMap。它和costmap_->worldToMap有什么区别?——它算出的map坐标系x、y坐标类型不是int,而是double。因为map坐标系是栅格中心,所以它要都要减去convert_offset_。假设convert_offset_=0.5,那以下等式成立。
- start_x = double时的start_x_i - convert_offset_。
- start_y = double时的start_x_y - convert_offset_。
wx = goal.pose.position.x; wy = goal.pose.position.y; if (!costmap_->worldToMap(wx, wy, goal_x_i, goal_y_i)) { ROS_WARN_THROTTLE(1.0, "The goal sent to the global planner is off the global costmap. Planning will always fail to this goal."); return false; } if(old_navfn_behavior_){ goal_x = goal_x_i; goal_y = goal_y_i; }else{ worldToMap(wx, wy, goal_x, goal_y); }
类似start.pose.position,算出goal.pose.position在map坐标系下的坐标。
//clear the starting cell within the costmap because we know it can't be an obstacle clearRobotCell(start, start_x_i, start_y_i); // It is not necessary robot to reach the goal-cell, just reach near. std::vector<SDL_Point> locs; locs.push_back(SDL_Point{(int)goal_x_i, (int)goal_y_i}); costmap_->getMax8Cells(goal_x_i, goal_y_i, nullptr, &locs); costmap_2d::tset_cost_restorer FREE_SPACE_cost_restorer(*costmap_, locs, costmap_2d::FREE_SPACE);
makePlan时,会修改地图中3类栅格的代价。
- start所在栅格,改为FREE_SPACE。见上面的clearRobotCell(...)。
- goal和它周围8个栅格,改为FREE_SPACE。见上面的FREE_SPACE_cost_restorer。这点是rose_ros新加的,这么做的原因:导航时,机器人并不是要准确到达goal指定栅格,到达附近就行。加之goal栅格代价“>=lethal_cost_ - 1”时,calculatePotentials会失败,导致makePlan失败。
- 边界那一圈栅格,改为LETHAL_OBSTACLE。见下面的outlineMap(...)。
另外,(2)在makePlan后,相关栅格会恢复回原貌,(1)、(3)则不会。
int nx = costmap_->getSizeInCellsX(), ny = costmap_->getSizeInCellsY();
在全局代价地图,是静态层决定这个代价代图尺寸,所以(nx, ny)就是栅格图尺寸。
//make sure to resize the underlying array that Navfn uses p_calc_->setSize(nx, ny); planner_->setSize(nx, ny); path_maker_->setSize(nx, ny); potential_array_ = new float[nx * ny]; if(outline_map_) outlineMap(costmap_->getCharMap(), nx, ny, costmap_2d::LETHAL_OBSTACLE);
在libros,outline_map_是true,因而总会执行outlineMap。outlineMap作用是把代价格地图中上、下两行,左、右两行栅格置为costmap_2d::LETHAL_OBSTACLE。
bool found_legal = planner_->calculatePotentials(costmap_->getCharMap(), start_x, start_y, goal_x, goal_y, nx * ny * 2, potential_array_); FREE_SPACE_cost_restorer.manual_restore();
计算成本矩阵,结果放在potential_array_。采用的是狄克斯特拉寻路时,对应DijkstraExpansion::calculatePotentials。
用代价地图计算出成本矩阵后,调用FREE_SPACE_cost_restorer.manual_restore(),将(2)修改的相关栅格恢复成原貌。
if(!old_navfn_behavior_) planner_->clearEndpoint(costmap_->getCharMap(), potential_array_, goal_x_i, goal_y_i, 2); if(publish_potential_) publishPotential(potential_array_); if (found_legal) { //extract the plan if (getPlanFromPotential(start_x, start_y, goal_x, goal_y, goal, plan)) { //make sure the goal we push on has the same timestamp as the rest of the plan geometry_msgs::PoseStamped goal_copy = goal; goal_copy.header.stamp = ros::Time::now(); plan.push_back(goal_copy);
found_legal是true,意味着成功计算出成本矩阵。接下是调用getPlanFromPotential,从成本矩阵得到上层须要的、std::vector<geometry_msgs::PoseStamped>类型的plan。在getPlanFromPotential,要根据参数use_grid_path调用不同的提取算法,true时是GridPath::getPath,否则GradientPath::getPath。libros用的是GradientPath::getPath。
} else { ROS_ERROR("Failed to get a plan from potential when a legal potential was found. This shouldn't happen."); } }else{ ROS_ERROR("Failed to get a plan."); } // add orientations if needed orientation_filter_->processPath(start, plan);
processPath用于调整路径中每个点的角度(orientation)。之前生成路径时,每个点已经有个非零角度,这里按规则统一调整角度。对libros,omode_值是FORWARD,在这种模式下,对第i个点的角度等于atan2(y1-y0, x1-x0),(x0, y0)是i点坐标,(x1, y1)是i+1点坐标。即所有点的角度是指向下一个点。图2显示了omode_==FORWARD调整后的各点角度。

//publish the plan for visualization purposes publishPlan(plan); delete[] potential_array_; return !plan.empty(); }
在图1,rose_ros用的是绿色实例,但非绿色的更容易理解。为尽快知道各实例在makePlan中作用,以及makePlan能做什么、不能做什么,下面让深入A*算法、PotentialCalculator、GridPath。
关于DijkstraExpansion算法,参考“global_planner解析(4)”。
三、计算成本矩阵:A*算法
3.1 calculatePotentials
<libros>/global_planner/src/astar.cpp ------ bool AStarExpansion::calculatePotentials(unsigned char* costs, double start_x, double start_y, double end_x, double end_y, int cycles, float* potential) { queue_.clear(); int start_i = toIndex(start_x, start_y); // 1.将起点放入队列 queue_.push_back(Index(start_i, 0)); // 2.将所有点的potential都设为一个极大值 std::fill(potential, potential + ns_, POT_HIGH); // 3.将起点的potential设为0 potential[start_i] = 0; int goal_i = toIndex(end_x, end_y); int cycle = 0; // 4.进入循环,继续循环的判断条件为只要队列大小大于0且循环次数小于所有格子数的2倍 while (queue_.size() > 0 && cycle < cycles) { // 5.得到最小cost的索引,并删除它,如果索引指向goal(目的地)则退出算法,返回true Index top = queue_[0]; std::pop_heap(queue_.begin(), queue_.end(), greater1()); queue_.pop_back(); int i = top.i; if (i == goal_i) return true; // 6.对右、左、上、下四个点执行add函数 add(costs, potential, potential[i], i + 1, end_x, end_y); add(costs, potential, potential[i], i - 1, end_x, end_y); add(costs, potential, potential[i], i + nx_, end_x, end_y); add(costs, potential, potential[i], i - nx_, end_x, end_y); cycle++; } return false; }
3.2 AStarExpansion::add
add函数中,如果是已经添加的的点则忽略,根据costmap的值如果是障碍物的点也忽略。
void AStarExpansion::add(unsigned char* costs, float* potential, float prev_potential, int next_i, int end_x, int end_y) { if (next_i < 0 || next_i >= ns_) return; if (potential[next_i] < POT_HIGH) return; if(costs[next_i]>=lethal_cost_ && !(unknown_ && costs[next_i]==costmap_2d::NO_INFORMATION)) return; // p_calc_->calculatePotential用于计算起点到当前点next_i的potential(成本) potential[next_i] = p_calc_->calculatePotential(potential, costs[next_i] + neutral_cost_, next_i, prev_potential); int x = next_i % nx_, y = next_i / nx_; float distance = abs(end_x - x) + abs(end_y - y); queue_.push_back(Index(next_i, potential[next_i] + distance * neutral_cost_)); // potential[next_i]: 是起点到当前点的cost即g(n) // distance * neutral_cost_: 是当前点到目的点的cost即h(n)。 // f(n)=g(n)+h(n): 计算完这两个cost后,加起来即为f(n),将其存入队列中。 std::push_heap(queue_.begin(), queue_.end(), greater1()); }
A* 算法是策略寻路,不保证一定是最短路径。Dijkstra算法是全局遍历,确保运算结果一定是最短路径。 Dijkstra需要载入全部数据,遍历搜索。
四、计算起点到当前点n的potential(成本):PotentialCalculator
- neutral_cost_:平均代价。不知道那些未知栅格是什么值,但有时须要“认为”它们有值,那就认为就是这个平均值。举个例子,要计算h(n),即栅格n到目标栅格成本,算出n到gobal的线段长distance后,认为distance*neutral_cost_就是h(n)。
calculatePotential用于计算起始点s到点n的成本,即g(n)。
<libros>/include/global_planner/potential_calculator.h ------ class PotentialCalculator { virtual float calculatePotential(float* potential, unsigned char cost, int n, float prev_potential=-1){ if (prev_potential < 0){ // 何时会进这个入口,下面会说。 // get min of neighbors // 分别求出左、右、下、上最小的代价值 float min_h = std::min( potential[n - 1], potential[n + 1] ), min_v = std::min( potential[n - nx_], potential[n + nx_]); // 从最小值中再取最小,也就是四个最小的 prev_potential = std::min(min_h, min_v); } // 成本 = 当前已经累计的成本 + 代价地图中栅格n代价 return prev_potential + cost; } }
g(n) = g(n-1) + (代价地图中栅格n的代价+neutral_cost_)。
prev_potential<0,这值往往就是-1。此时调用者告知calculatePotential,我不知道g(n-1),即prev_potential,你内部算出一个g(n-1)。如何计算g(n-1)?——PotentialCalculator::calculatePotential的方法是取出该栅格前、后、左、右最小成本的栅格,把它的成本作为做为起点到栅格n-1的g(n-1)
代码中何会出现prev_potential<0?——回看makePath代码,它算出成本矩阵后,会调用planner_->clearEndpoint。
planner_->clearEndpoint(costmap_->getCharMap(), potential_array_, goal_x_i, goal_y_i, 2); class Expander { void clearEndpoint(unsigned char* costs, float* potential, int gx, int gy, int s){ int startCell = toIndex(gx, gy); for(int i=-s;i<=s;i++){ for(int j=-s;j<=s;j++){ int n = startCell+i+nx_*j; if(potential[n]<POT_HIGH) continue; float c = costs[n]+neutral_cost_; float pot = p_calc_->calculatePotential(potential, c, n); potential[n] = pot; } } } }
clearEndpoint会调用calculatePotential,调用时不给第4个参数prev_potential,即此时的prev_potential会是-1。
此时(gx, gy)是目标点在map坐标系坐标。可以肯定它所在栅格成本不会是POT_HIGH,clearEndpoint的功能是检查周围8个栅格,如果没有设置过成本的,以“prev_potential=-1”调用calculatePotential。此时如何给这些栅格n设置成本?——上面已说过,PotentialCalculator版本的方法是取出该栅格前、后、左、右最小成本的栅格,把它的成本作为做为起点到栅格n-1的g(n-1)。g(n-1)加上n代价后,就是起点到栅格n的g(n)。因为“8”栅格成本取的是最小g(n-1),这可能就是函数名中“clear”的语义吧。
顺便说下,在QuadraticCalculator::calculatePotential,计算g(n)时就没使用prev_potential参数。
五、从成本矩阵提取出路径:GridPath算法
<libros>/global_planner/src/grid_path.cpp ------ bool GridPath::getPath(float* potential, double start_x, double start_y, double end_x, double end_y, std::vector<std::pair<float, float> >& path) { std::pair<float, float> current; current.first = end_x; current.second = end_y; // 1.将goal(目的地)所在的点的(x,y)作为当前点加入path int start_index = getIndex(start_x, start_y); path.push_back(current); int c = 0; int ns = xs_ * ys_; // 2.进入循环,继续循环的条件为当前点的索引不是起始点 while (getIndex(current.first, current.second) != start_index) { float min_val = 1e10; int min_x = 0, min_y = 0; // 3.搜索当前点的四周的四个临近点, // 选取这四个临近点的potential的值最小的点 for (int xd = -1; xd <= 1; xd++) { for (int yd = -1; yd <= 1; yd++) { if (xd == 0 && yd == 0) continue; int x = current.first + xd, y = current.second + yd; int index = getIndex(x, y); if (potential[index] < min_val) { min_val = potential[index]; min_x = x; min_y = y; } } } if (min_x == 0 && min_y == 0) return false; // 4.将potential值最小的点更改为当前点,并加入path current.first = min_x; current.second = min_y; path.push_back(current); if(c++>ns*4){ return false; } } // 返回2,继续循环 return true; }
这个模块负责使用计算好的potential矩阵生成一条全局规划路径。算法很好理解,首先将goal所在的点的(x, y)塞到path,然后搜索当前的点的四周的八个临近点,选取这八个临近点的potential的值最小的点min,然后把这点塞到path,然后再将当前点更新为min这点,由于start点的potential的值是0,所以这是个梯度下降的方法。