八叉树上的 A* 全局寻路

本文介绍空域载具八叉树三维寻路项目中的规划环节:在上一节建好的八叉树占用地图上 做 A* 搜索,规划一条从飞行器当前位置到目标点的无碰撞路径。

对应总纲第 4 节「八叉树上的 A* 全局寻路」。


1. 输入与输出

方向 话题 类型 说明
订阅 /octomap_full octomap_msgs/Octomap 八叉树占用地图,由建图节点以 latched 方式发布
订阅 /uav/goal geometry_msgs/Point 目标点(世界系 ENU)。收到一次规划一次
查询 TF world ← base_link — 取飞行器当前位置作为路径起点
发布 /uav/path nav_msgs/Path 规划出的路径
发布 /uav/explored_markers visualization_msgs/MarkerArray A* 展开过的节点(按尺寸着色)

本节点只做规划、不控制飞行。 把路径喂给控制器实现闭环飞行是后续阶段的事。


2. 核心问题:八叉树不是均匀网格

这是本节全部难度的来源。二维栅格 A* 里每个格子大小相同、邻居关系固定; 八叉树这两点都不成立:

问题 具体表现
节点大小不一 空旷区域被 octomap 塌缩成一个粗节点,障碍物附近才细分到 0.3 m 叶节点。同一棵树里节点边长可能差上百倍
邻居关系复杂 找某个方向上的邻居时,可能碰上同样大、更大(父级)、更小(子级)三种情况
代价不能按"步"算 跨一个大节点和跨一个小节点,实际位移完全不同,必须按几何距离计算

为什么不把八叉树展开成均匀栅格

那样实现会简单很多,但代价不可接受:

  • 内存:0.3 m 分辨率、20×20×6 m 的场景就是 67×67×20 ≈ 9 万个叶节点; 真实场景(70×70×35 m)更是 635 万个体素,每个还要存 g/h/parent
  • 丢掉八叉树的意义:八叉树的价值恰恰在于"空旷处用大节点表示", 展开成栅格等于把建图阶段省下来的内存又还回去

因此走树遍历式:搜索图的节点就是八叉树的叶节点本身。


3. 邻接:26 邻域在八叉树上怎么找

3.1 探测点法

对叶节点 ,记中心为 、边长为 。对 26 个方向 ,沿该方向挪出自身表面:

其中 是八叉树分辨率。

为什么这个点必定落在紧贴的邻居体内:任何合法邻居的边长都 , 所以从自身表面再往里走半个最小体素,既出了自己的范围、又还没跨过邻居。 由于八叉树的格子是轴对齐且互不重叠的,含有该点的叶子必定与本节点相邻。

比手动遍历树去"找兄弟节点"简单得多,而且天然处理了粗细不一的情况。

3.2 三种情况

探测结果 处理
落在未知空间(该分支未分配节点) 不可通行,跳过
邻居边长 同层或更粗 → 就是它,计入 1 个邻居
邻居边长 邻居更细 → 需要展开,见下

3.3 细侧展开

当邻居比本节点更细时,探测点只命中了接触面上许多小子节点中的一个。 接触面上的子节点数量取决于接触类型:

接触类型 方向特点 子节点数
面接触 只有一个分量非零
棱接触 两个分量非零
角接触 三个分量非零

其中 (两个边长都是 2 的幂,所以是整数)。

对面接触,在接触面上均匀取 个采样点各查一次,去重后得到该方向上的全部邻居。

3.4 计算量控制:

空旷处一个被塌缩的空闲节点边长可达几十米,而它旁边紧挨的细碎结构只有 0.3 m —— 此时 可以大到 128,面接触要采 16384 个点,不可接受。

因此设上限 (默认 4):

  • → 完整展开
  • → 放弃该方向

为什么放弃是安全的: 大意味着"我是一个大空闲节点,但那个方向紧挨着一片 细碎结构" —— 这几乎总出现在障碍物附近或传感器噪声区域。放弃它只会让路径绕远, 不会产生穿墙路径。这是一个保守的近似。


4. 代价与启发

4.1 代价:两节点中心的欧氏距离

不能数"步数" —— 跨一个大节点和跨一个小节点的实际位移可能差两个数量级。

对粗邻居,中心距会略微高估实际移动代价 —— 这是保守方向,会让 A* 偏向细分路径, 但不会产生不安全的路径。

4.2 启发:欧氏距离

可采纳(任何路径的实际长度都 直线距离),因此保证最优解。

需要更快收敛时可引入权重 做加权 A*,牺牲最优性换速度。 默认 。


5. 可通行性判定

在 octomap 里三种状态的存储表现并不对称:

状态 在树里的表现 判定方式
占用 有节点,isNodeOccupied() 为 true 障碍
空闲 有节点(可能是塌缩后的粗节点) 可通行
未知 根本没有节点 —— 该分支从未被分配 按点查找时返回"找不到"

未知区域一律当障碍。 这是保守的选择:宁可规划不出路径让用户去多扫一圈, 也不要规划一条穿过从未观测区域的路径。

未知区域在 octomap 里没有节点对象,因此也无法作为图节点参与搜索。 真有"必须在未扫描区域规划"的需求时,正确做法是让建图侧把该区域显式标记, 而不是在规划侧开特例。


6. 实现:astar_planner 节点

核心算法在 include/octree_uav_3d_pathfinding/astar.h,与 ROS 完全解耦 —— 它只依赖 octomap 与 STL,因此可以离线单元测试(tests/test_astar.cpp)。 ROS 节点只是它的一层外壳,负责收发话题、查 TF、把结果转成 ROS 消息。

收到目标点后的流程:

  1. 查 TF:world ← base_link,得到飞行器当前位置作为起点
  2. 吸附起终点:用户给的位置可能落在障碍或未知区域里, 此时以该点为中心按体素为步长逐层向外扫,找最近的可通行叶节点
  3. A*:以自由叶节点为图节点,按第 3 节的邻接规则扩展
  4. 回溯:沿父指针回到起点,把每个节点的中心连成路径
  5. 发布:nav_msgs/Path + 已展开节点的 MarkerArray

关于"已展开节点"的可视化

节点会把 A* 实际展开过的节点发成 MarkerArray,按节点尺寸着色:

  • 偏蓝 = 小节点(障碍物附近,需要细碎地走)
  • 偏红 = 大节点(空旷区域,一步跨过去)

这一路输出是理解 A* 在八叉树上行为最直观的方式 —— 一张图就能看出跨分辨率邻接是否真的生效。


7. 参数说明

参数位于 config/params.yaml 的 astar_planner 组。

参数 默认值 说明
map_topic / goal_topic /octomap_full / /uav/goal 输入
path_topic / explored_topic /uav/path / /uav/explored_markers 输出
world_frame / body_frame world / base_link 坐标系
k_max 4.0 细侧邻居展开上限,见 3.4
max_iterations 200000 A* 最大展开次数,防止跑飞
heuristic_weight 1.0 启发权重,>1 为加权 A*
search_radius 3.0 起终点落在障碍里时的吸附搜索半径(m)
plan_timeout 10.0 单次规划超时(s)
max_expanded_markers 20000 展开节点可视化的上限,防止 RViz 卡顿

调参建议:

  • k_max 是最值得注意的一个。调小会让路径更容易绕远(放弃的邻接更多), 调大会让单次节点扩展变慢。4 是本场景下的平衡点。
  • 规划失败且原因是"找不到可行节点"时,先看地图的覆盖率 —— 多半是目标点落在还没扫描到的区域里了。

8. 运行方法与效果

8.1 准备工作空间

本模块的功能包是 octree_uav_3d_pathfinding,按 catkin 的规则,它需要位于 工作空间的 src/ 之下。工作空间的建立见环境配置与前置准备, 模块代码的准备有两种方式:

# 工作空间
mkdir -p ~/uav_ws/src && cd ~/uav_ws && catkin_make && source devel/setup.bash

# 方式一:克隆本仓库,把包软链接进工作空间
git clone https://github.com/OpenHUTB/ros2.git ~/ros2
ln -sfn ~/ros2/src/air/octree_uav_3d_pathfinding ~/uav_ws/src/octree_uav_3d_pathfinding

若代码已在别处,把 ln 的源路径换成实际路径即可;也可以直接把功能包目录 拷贝到 ~/uav_ws/src/ 下。用软链接的好处是仓库更新后不必重新拷贝。

8.2 编译与启动

# 编译
cd ~/uav_ws && catkin_make
source ~/uav_ws/devel/setup.bash

# 启动(桥接 + 建图 + 规划 + 自检 + RViz)
roslaunch octree_uav_3d_pathfinding main.launch

前置条件:模拟器已启动、params.yaml 里的 host 已改成自己的宿主机地址 (见环境配置与前置准备)。

飞行器起飞并扫描一段时间、地图建起来之后,下发目标点:

# 目标点是 ROS 世界系 ENU
rostopic pub -1 /uav/goal geometry_msgs/Point "{x: 30.0, y: 0.0, z: 15.0}"

规划节点会打印统计信息:

[INFO] plan #1: OK | expanded=2720 discovered=5879 waypoints=30 length=31.36 m in 35.0 ms
[INFO]   start cell center=(-8.40, 8.40, 1.20)
[INFO]   goal  cell center=(8.40, -8.40, 1.20)
[INFO] published 2720 explored cells

RViz 中会显示绿色路径。下图为离线验证场景 —— 20×20×6 m 的封闭房间, 里面立着两道错开的墙(从地面一直顶到天花板)和几根柱子; 起点在左上角、终点在右下角,路径必须穿过两个缺口,形成一个 S 形 (31.36 m,而两点直线距离只有 21.2 m):

RViz 中的规划路径

规划节点每次规划都会输出统计信息:总体一行(展开节点数、航点数、路径长度与耗时), 再加上起终点实际吸附到的叶节点中心,以及可视化的节点个数:

规划节点的统计输出

补充观察:怎么看"跨分辨率"是否真的生效

节点还会把 A* 实际展开过的节点发布到 /uav/explored_markers (MarkerArray,每个节点一个立方体、边长就是节点边长,并按尺寸着色: 偏蓝是细节点、偏红是粗节点)。在 RViz 里单独显示这一路(其余显示项关掉), 就能直观看到算法在空旷区一步跨过大节点、在障碍附近才细碎展开。 展开节点可能上万,可视化会比较密集 —— 按需调小 max_expanded_markers 即可。

不依赖图形界面也能确认:看单元测试的用例 4 —— 它构造了一个边长 16.00 m 的粗空闲节点(分辨率 0.50 m),断言 A* 相邻航点的最大间距 达到 17.32 m,即 34.6 倍分辨率。如果邻接退化成"只在最细分辨率上一点点挪", 这个数字会掉到 1~2 倍,测试立刻失败。

离线验证(不需要模拟器)

上面那组数据就是用它跑出来的。mock_cloud_publisher.py 会发布一个确定的房间场景, 并且让传感器依次驻留房间四个角 —— 这样地图会被从多个视角扫过, 障碍物背后的"阴影"(永远扫不到、因而在地图里是未知的区域)才会被填掉。

# 终端 1:离线点云(不需要模拟器,也不需要 airsim_bridge)
rosrun octree_uav_3d_pathfinding mock_cloud_publisher.py

# 终端 2:建图 + 规划 + RViz(跳过桥接与自检;
#         离线场景只广播 world -> lidar_link,所以体坐标系要指到它)
roslaunch octree_uav_3d_pathfinding main.launch bridge:=false check:=false body_frame:=lidar_link

# 等约 60 秒让传感器巡游一圈把地图扫满,再在终端 3 下发目标点
rostopic pub -1 /uav/goal geometry_msgs/Point "{x: 7.5, y: -7.5, z: 0.0}"

9. 主要话题

话题 类型 方向 说明
/octomap_full octomap_msgs/Octomap 订阅 八叉树占用地图(latched)
/uav/goal geometry_msgs/Point 订阅 目标点(世界系 ENU)
/uav/path nav_msgs/Path 发布 规划出的路径
/uav/explored_markers visualization_msgs/MarkerArray 发布 A* 展开的节点

排查用命令:

# 看规划结果(latched,一连上就能看到上一条)
rostopic echo /uav/path

# 确认 RViz 订阅上了(Subscribers 里应该有 /rviz)
rostopic info /uav/path

10. 开发中踩到的几个坑

这三个问题都很隐蔽,记录下来供参考。

10.1 叶子查找不能用 OcTree::search()

octomap 的 search(point) 会一路下潜到指定深度,中途缺少子节点就返回 NULL:

for (unsigned int k = tree_depth; k > depth; --k) {
  n = n->getChild(computeChildIdx(key, k));
  if (!n) return NULL;          // ← 这里
}

而被剪枝后的粗节点恰恰就是"没有子节点" —— 于是这个有效的空闲大节点 会被误判成"未知"。而 A* 的路径正是要走这些大空闲节点。

解法:自己实现下潜 —— 遇到没有子节点的节点就把它当叶子返回; 只有"子节点槽位存在但为空"才判定为未知。另外 octomap 1.9 把 node->hasChildren() 标记为废弃、node->getChild() 干脆不存在,要改用树上的 tree->nodeHasChildren() / tree->nodeChildExists() / tree->getNodeChild()。

10.2 NodeId 必须折算到叶子自己那一层

同一个叶子,用不同的点去查会得到不同的最大深度键。如果把探测点的键直接当作 节点标识,那么:

  • closed / g_score 去重完全失效,同一节点被反复展开
  • 终点判定永远匹配不上 —— A* 走到终点叶子时的 id 来自某个探测点, 而目标节点的 id 来自用户给的终点坐标

解法:把键右移 (树深 − 叶子深度) 位,折算到叶子自己所在的那一层。

只有"叶子边长恰好等于分辨率"时两者才巧合地相等 —— 这也解释了为什么 细碎场景能跑通、而有大片粗节点的场景全崩。

10.3 子节点编号的位序是 (z<<2)|(y<<1)|x

octomap 的子节点编号约定是 x 在最低位,而不是直觉上的 x 在最高位。 文档里没有明确说明。

用错位序的后果是灾难性的:遍历地图里的前 2000 个自由叶节点、 用它们各自的中心反查一遍,1883 个查不回来。

为什么这么难发现(三个巧合叠加):

  1. 起点 (0,0,0) 的键是 (32768, 32768, 32768) —— 三个分量完全相同, 任何位序都算出同一个索引,所以起点永远查得到;
  2. ±y、±z 方向的探测点,x 与 z 分量仍然相等,两种位序给出相同结果;
  3. 占用体素铺满墙面,RViz 里的房间看起来完全正常 —— 而空闲空间在 RViz 里根本看不见,所以"自由空间读不出来"没有任何视觉迹象。

怎么抓出来的:写了一个自检 —— 遍历地图里的自由叶节点,对每个节点取它的中心 再反查一遍,看能不能找回同一个节点。这个"自己查自己"的自检一次就暴露了 1883/2000 的失配率,然后把 4 种位序各试一遍,直接测出正确位序。

这个自检保留成了节点的常驻诊断。当"一部分能工作、一部分不能"时, 要找正常与不正常样本之间的共同点 —— 这次就是"起点三个键分量相同"这个巧合。

10.4 障碍物表面采样过稀会导致"射线穿缝"

这是感知层的经典问题,在建图环节也踩到过:如果障碍物表面的采样点间距 大于八叉树分辨率,射线会从采样点的缝里钻进去、穿到障碍物另一侧, 把障碍物内部也标成空闲 —— 于是规划器会给出"穿墙"路径。

判断方法:如果路径长度和直线距离几乎一样,那多半不是绕行而是穿过去了。

解法:障碍物表面的采样间距要明显小于地图分辨率。


11. 参考

  • OctoMap 官网
  • Hart, Nilsson, Raphael, A Formal Basis for the Heuristic Determination of Minimum Cost Paths, IEEE Transactions on Systems Science and Cybernetics, 1968
  • nav_msgs/Path 消息定义
  • 模块源码:src/air/octree_uav_3d_pathfinding/

本文档及配套模块代码在编写过程中使用了 AI 大模型辅助(方案讨论、代码与文档起草、问题排查)。 全部内容经过实际运行验证:模块在 Ubuntu 20.04.6 + ROS Noetic + AirSim 1.8.1 环境下 通过 roslaunch octree_uav_3d_pathfinding main.launch 运行,并得到上述规划结果。 作者对提交内容的正确性与完整性负全部责任。