小马的世界

源码理解Autoware Freespace Planner

2026-04-12 · 16 min read

在 lane driving 场景里,路径规划大多是沿着车道中心线、参考路线、行为决策结果往下做,问题虽然复杂,但“路”本身是明确的。

停车场景不是这样。你可能需要:

  • 倒车入库
  • 在狭窄区域多把挪车
  • 从一个姿态精确切到另一个姿态
  • 在没有明确 lane 的自由空间里避障

这时问题就变成了:给定当前位姿、目标位姿、占据栅格地图和车辆转向约束,怎样找到一条无碰撞、满足车辆运动学、并且允许换向的可执行机动路径。

当然,Free space planner不止适合于停车领域,而是任何在需要精确地改变车辆姿态的情况下都可以使用。

从代码层面看,这个任务被拆成两层:

  • autoware_freespace_planner 负责节点调度、状态管理和轨迹分段发布。
  • autoware_freespace_planning_algorithms 负责真正的搜索、碰撞检测和路径恢复。

这个模块不是一次性生成一条从起点开到终点的完整停车轨迹,而是先搜索一条允许前进、倒车、再前进的机动路径,再把它拆成若干段单方向轨迹,要求车辆每到一个换向点先停稳,再继续下一段。
使用的主要算法有HybridAHybrid A*RRTRRT*

节点层在做什么

autoware_freespace_planner 的核心是
src/autoware_freespace_planner/freespace_planner_node.cpp

这个节点订阅四类输入:

  • ~/input/route
  • ~/input/occupancy_grid
  • ~/input/odometry
  • ~/input/scenario

然后发布:

  • ~/output/trajectory
  • is_completed
  • 以及几路调试话题

虽然输入里有 route,但当前实现并没有把 route 当作一条要跟踪的参考线去使用。onRoute() 只是把消息里的 goal_pose 取出来,更新内部目标,然后把停车周期重新开始。

再往下看 onTimer(),整个节点的工作方式就清楚了。它的主循环大意如下:

if (!parking_scenario_active) {
  reset();
  return;
}

updateData();
if (!isDataReady()) return;

if (is_completed_) {
  publish_stop_trajectory();
  return;
}

current_pose_ = odom_->pose.pose;

if (!reset_in_progress_ && isPlanRequired()) {
  publish_stop_trajectory_if_needed();
  reset();
  reset_in_progress_ = true;
}

if (reset_in_progress_ && ego_is_stopped()) {
  planTrajectory();
  reset_in_progress_ = false;
}

updateTargetIndex();
publish_current_partial_trajectory();

它先判断场景是否激活,再判断数据是否齐全,再决定要不要重规划。如果要重规划,也不是立刻算,而是先让车停住,等速度真的降下来之后再重新生成轨迹。

何时会触发重新规划

isPlanRequired() 的条件并不多

第一种情况最简单:当前根本没有轨迹,那当然要规划。

第二种情况是当前轨迹上出现了障碍物,而且这个障碍物持续存在了一段时间,超过 th_obstacle_time_sec。这里的实现需要多说一句,因为它检查的不是整条完整机动路径,而是当前正在执行的那一段 partial_trajectory_

代码会先找到车辆在当前段上的最近点,然后只截取“从当前位置到当前分段终点”的这一截,交给算法层的 hasObstacleOnTrajectory() 去做碰撞判断。也就是说,节点只关心眼下这段是否还能走,不会提前担心后面那一段倒车路径会不会遇到新障碍。

第三种情况是车辆明显偏离了当前轨迹,超过 th_course_out_distance_m。这个判断也比较朴素:当前车位姿到轨迹最近点的二维距离。

onTimer() 在发现需要重规划时,首先做的是发布 stop trajectory,然后把 reset_in_progress_ 置为 true。此后节点会持续检查 odom_buffer_,只有当这段时间里的速度都低于 th_stopped_velocity_mps,才真正调用 planTrajectory()

停车轨迹往往会涉及前进和倒车切换。如果车辆还处在运动中,规划器突然给出一条方向、曲率、甚至分段边界都不同的新轨迹,控制器未必能优雅处理。停车场景又是低速场景,时间预算通常没那么紧,于是系统更偏向保守做法:先把状态收敛到“车已停住”,再开始下一次规划。

机动路径先算出来,但输出时要按换向点切开

读这部分源码之前,我对 freespace planning 最大的误解,是以为算法层吐出什么,节点层就原样发什么。实际上完全不是这样。

算法层返回的是 PlannerWaypoints。每个 waypoint 带一个 is_back 标志,表示这一小段是前进还是倒车。节点层在 utils::create_trajectory() 里把它们转成 Trajectory 时,会统一赋一个常速 waypoints_velocity / 3.6,然后根据 is_back 决定速度是正还是负。

于是完整轨迹里自然会出现这样的情况:

  • 前面一段速度为正
  • 中间某个点之后速度变成负
  • 再往后可能又变回正

这条轨迹在“搜索意义”上是完整的,但在“控制意义”上并不适合直接下发。于是 utils::get_reversing_indices() 会扫描整条轨迹,只要相邻两个点的速度符号发生变化,就把这个位置记下来,作为换向边界。

后面节点层真正发布时,只会取 prev_target_index_target_index_ 之间的那一段,也就是当前方向一致的这一截 partial_trajectory_。当车辆停住且靠近当前分段终点之后,updateTargetIndex() 才会把目标切到下一个换向点。

utils::get_partial_trajectory() 里还有两个小处理,很能说明设计意图:

  • 分段首点的速度会被改成第二个点的速度符号
  • 分段尾点的速度会被强制设成 0

首点这样处理,是为了避免切段边界上遗留错误的速度符号;尾点强制为 0,则是为了明确要求车辆在分段结束时停稳,给下一次切段或者下一次规划创造稳定的起点。

读到这里,再回头看节点里的 is_completed_target_index_reversing_indices_ 这些成员变量,整个结构就顺了。

算法层的底座

autoware_freespace_planning_algorithms 里最值得先读的是 abstract_algorithm.cpp

因为无论选 AA* 还是 RRTRRT*,真正把这套东西变成“能在车上跑”的,是这层公共底座。

Unknown 在这里直接算障碍

setMap() 会把 occupancy grid 转成内部的障碍表。逻辑很简单:

  • cost < 0 算障碍
  • cost >= obstacle_threshold 算障碍

这意味着 unknown cell 在这里是不可走的。它不是“暂时未知,先试试”,而是“保守处理,直接封掉”。

对停车场景来说,这个选择是合理的。

所有搜索都先回到 costmap 的局部坐标系

节点层传进来的起点和终点,最终都会先通过 global2local() 转到 costmap 的局部坐标系。后面的离散化、碰撞检测、路径搜索,基本都发生在这个局部平面里。

这样做的好处很实际:你可以把地图、索引、车辆 footprint 这些逻辑都统一在一个平面世界里处理,不必在搜索过程中反复考虑全局坐标变换。

碰撞检测不是每次都“拿车框去扫格子”

computeCollisionIndexes() 会针对每个离散朝向,把车辆矩形 footprint 采样成一组栅格索引,提前缓存下来。运行时如果要判断某个 (x, y, theta) 是否碰撞,只需要把这一组 footprint 索引平移到当前位置去查表。

更妙的是,它前面还加了一层 EDT 剪枝。

computeEDTMap() 会为整张地图算 Euclidean Distance Transform,得到每个栅格到最近障碍物的距离。真正进入 detectCollision() 时,先看这个位置离最近障碍有多远:

  • 如果远到超过车辆最大外接尺度,那肯定安全,直接返回 false
  • 如果近到小于车辆最小内接尺度,那几乎肯定碰撞,直接返回 true
  • 只有落在两者之间的灰区,才真的去遍历 footprint 覆盖的栅格

这一段实现看起来不花哨,但非常有效。它把最贵的逐栅格检测留给了真正有必要的情况。

如果把它压成伪代码,大概就是这样:

if (detectBoundaryExit(base_index)) return true;

const auto obstacle_edt = getObstacleEDT(base_index).distance;
if (obstacle_edt > vehicle.max_dimension) return false;
if (obstacle_edt < vehicle.min_dimension) return true;

for (auto cell : precomputed_footprint(theta)) {
  if (is_obstacle(cell + base_index.xy)) return true;
}
return false;

很多人看 freespace planning 时,注意力都放在 A* 的启发式或者 RRT* 的采样策略上。但如果没有这层足够快、足够稳定的碰撞底座,上层算法很快就会变成纸上谈兵。

车辆尺寸在这里会被保守放大

节点初始化算法对象之前,会先按 vehicle_shape_margin_m 把车长、车宽和后悬做一轮扩张。若你选的是 rrtstar,构造函数里还会再叠一层 rrtstar.margin

也就是说,$RRT* $实际使用的碰撞车模,往往会比 $ A* $更保守一点。

这也是调参时一个很容易忽略的点。有时候你以为是采样参数的问题,结果只是碰撞模型已经被放大得太保守了。

默认的 Hybrid A*

默认参数文件 config/freespace_planner.param.yaml 里,planning_algorithm 设的是 astar。所以大多数情况下,真正跑起来的是 AstarSearch

Autoware 这里的 $A* $不是平面上的普通 AA*,而是典型的 HybridAHybrid A*:搜索状态包含 xy 和离散后的 theta。换句话说,它搜索的是三维状态格,而不是二维占据栅格。

它的扩展不是“朝八邻域走一步”,而是通过 kinematic_bicycle_model::getPose() 用运动学自行车模型推下一状态。给出当前位姿、轴距、转角和前进或倒车的距离,算法会按车模推演出下一个位姿。

这意味着,搜索图上的边从一开始就带着车辆非完整约束,而不是最后再拿一条栅格路径去做平滑。

expandNodes() 的结构如下

for (steering_index in [-N, ..., +N]) {
  next_pose = bicycle_model(current_pose, steering, distance);
  next_index = pose2index(next_pose);

  if (out_of_range || occupied || detectCollision(next_index)) continue;
  if (long_arc_has_intermediate_collision) continue;

  move_cost = current.gc
            + length_cost
            + steering_change_cost
            + obstacle_cost
            + near_goal_lateral_cost
            + direction_switch_cost;

  total_cost = move_cost + estimateCost(next_pose, next_index);
  update_openlist_if_better(...);
}

很多文章会把 HybridAHybrid A* 讲成“多了个朝向维度的 AA*”。从源码中我们可以知道,它远不止如此。这里的节点扩展、碰撞检查、代价设计和启发式都已经非常工程化。

启发式不是一个,而是两个下界叠在一起

AstarSearch::estimateCost() 用到两种启发式信息。

第一种是 col_free_distance_map_setCollisionFreeDistanceMap() 会从目标位置出发,在二维自由栅格上做一轮类似 Dijkstra 的传播。它忽略朝向,只关心“在不穿障碍物的前提下,从这个位置到目标位置至少还要走多远”。这个量比单纯的欧氏距离更贴近障碍物结构。

第二种是 Reeds-Shepp distance。它考虑了车辆最小转弯半径、位置和朝向,也允许前进和倒车,因此是一个很自然的 car-like 终端距离下界。

代码没有在这两个启发式之间二选一,而是取它们的较大值,再乘 distance_heuristic_weight。也就是:

double total_cost = col_free_distance_map_[indexToId(index)];
total_cost = std::max(total_cost, calcReedsSheppDistance(pose, goal_pose_, avg_turning_radius_));
return distance_heuristic_weight * total_cost;

这个写法很聪明。障碍物形状复杂时,二维传播距离更有用;终端姿态要求更严格时,Reeds-Shepp 下界更有用。取两者较大值,相当于把启发式收紧了。

它找的不是最短路径,而是更像“能开的停车机动”

AA* 里的 g 代价不只是长度。你还能在代码里看到这些偏好:

  • 弯得太多要加惩罚
  • 倒车要加惩罚
  • 频繁切换方向要加惩罚
  • 太贴近障碍物要加惩罚
  • 接近目标时横向没对齐也要加惩罚

这会让控制不会只追求几何最短,而会更偏向于一条控制起来舒服、离障碍物别太近、终点姿态也更顺的停车机动。

步长还是自适应的

如果打开 adapt_expansion_distance,扩展距离不是固定值,而会综合考虑离目标还有多远、离障碍物有多近,在 min_expansion_dist_max_expansion_dist_ 之间自适应调整。

这带来的直观效果是:空旷地方步子可以大一点,搜索更快;接近障碍物或目标时步子收小一点,结果更细。

同时代码还会对较长弧段插中间检查点,防止一步跨过去直接漏掉碰撞。

终点判定也不是“必须和 goal_pose 完全重合”

isGoal() 用的是一个带容差的判定方式。只要当前位置落在目标的纵向、横向和朝向容差范围内,就可以认为已经到达。这样做比“必须完全重合”实用得多,也更符合停车问题的实际需求。

RRT* 在这里更像一个备选方案,而不是默认主力

rrtstar.cpprrtstar_core.cpp 也很值得看,但它们在整个模块里的位置,我更愿意理解成“另一条设计路径”。

这套 RRTRRT* 不是教科书里纯二维几何版的$ RRT*$。它的状态空间同样包含 xyyaw,距离度量、插值和连边有效性检查都依赖 ReedsSheppStateSpace。换句话说,它在做的是 car-like configuration space 上的采样规划。

rrtstar_core::RRTStar::extend() 的主干也很干净:

x_rand = informed ? sample_in_ellipse() : sample_uniform();
x_nearest = findNearestNode(x_rand);
x_new = interpolate_child2parent(x_nearest->pose, x_rand, mu_);

if (!isValidPath(x_new, x_nearest)) return;

neighbors = findNeighborNodes(x_new);
best_parent = getBestParentNode(x_new, x_nearest, neighbors);
node_new = addNewNode(x_new, best_parent);

if (node_reconnect = getReconnectTargetNode(node_new, neighbors)) {
  reconnect(node_new, node_reconnect);
}

if (isValidPath(goal, node_new)) {
  reached_nodes_.push_back(node_new);
  update_best_goal_parent();
}

整套流程没有什么隐藏机关,就是采样、延申、找更优父节点、重连、检查能否连到 goal。

有意思的是它做了几处很工程化的取舍。

第一,邻域半径没有按论文里那样随样本数收缩,而是固定使用一个尺度。源码注释里也写得很直白:因为这里用的是 Reeds-Shepp 距离,不再是简单欧氏空间,照搬论文里的收缩半径并不好设计。

第二,neighbor_radius 这个参数在实现里其实一人分饰两角:既影响从最近节点向随机样本推进时迈多大一步,也影响查邻居时的范围。它不只是“邻域大小”,也是树的增长节奏。

第三,如果已经找到一条可行解,并且开了 use_informed_sampling,采样区域会缩到一个椭圆内,也就是 informed RRT* 的经典做法。不过这里的椭圆是基于二维欧氏下界构造的,而不是直接对 Reeds-Shepp 代价做解析采样。这个取舍在 rrtstar.md 里也有解释:欧氏下界虽然没那么紧,但好采样,而且仍然是 admissible 的。

从结果上说,我能理解为什么默认配置还是选择 HybridAHybrid A*。停车场景里,AA* 的结果更稳定、更规整,也更容易通过代价项把“贴障要少一点”“换向别太频繁”这些偏好写进去。RRTRRT* 当然有它的价值,尤其是在更大范围、更开放的空间里,但在这份实现里,它更像一条备用路线。

读完代码之后,我觉得最值得记住的几个事实

第一,freespace_planner 不是单纯的 path solver,而是一个停车机动执行器。它知道什么时候该规划,什么时候该停,什么时候该切下一段。

第二,算法层的输出不是最终给控制器的输出。搜索层先给出一条完整机动路径,节点层再把它切成若干段单方向轨迹逐段发布。

第三,replanning 的默认策略是保守的。不是边开边丝滑换轨,而是先停住,再算下一条。

第四,真正让这套系统能跑起来的,不只是 $A* $或 RRTRRT* 的大框架,而是 AbstractPlanningAlgorithm 那层很扎实的地图处理、坐标转换、EDT 和碰撞检测。

第五,默认主力算法仍然是 HybridAHybrid A*,而且这份实现已经不是“教科书级别的简化版”,而是一套把车模、启发式、惩罚项和执行需求揉在一起的工程版本。

如果你准备继续深挖,建议按这个顺序读源码

我自己的阅读顺序是这样的,体验还不错:

先看 planning/autoware_freespace_planner/src/autoware_freespace_planner/freespace_planner_node.cpp,搞清楚节点层到底在调度什么;再看 utils.cpp,把 reversing index、partial trajectory、stop trajectory 这些细节吃透。接着去看 planning/autoware_freespace_planning_algorithms/src/abstract_algorithm.cpp,理解地图、碰撞和 EDT;最后再进入 astar_search.cpprrtstar_core.cpp