安全裕度与路径平滑

本文介绍空域载具八叉树三维寻路项目中的后处理环节:给上一节 A* 规划出的路径 加上安全裕度,并把它平滑成控制器可以跟踪的光滑曲线。

对应总纲第 5 节「安全裕度与路径平滑」。


1. 运行节点与话题关系

本节的功能由 astar_planner 一个节点承担,它叠加在上一节的规划流程之上, 因此先交代整条链路。roslaunch octree_uav_3d_pathfinding main.launch 会启动下图除模拟器以外的全部节点:

graph LR SIM["Windows 宿主
AirSim 模拟器
RPC 41451"] subgraph VM["Ubuntu 虚拟机 (ROS Noetic)"] BR["airsim_bridge
桥接节点"] MAP["pointcloud_to_octomap
建图节点"] PLAN["astar_planner
规划与平滑节点"] RVIZ["rviz"] end SIM -->|"RPC 41451 取点云与位姿"| BR BR -->|"/uav/goal 转 moveToPositionAsync"| SIM BR -->|"/cloud_in
PointCloud2 10 Hz"| MAP BR -->|"TF world→base_link→lidar_link"| PLAN MAP -->|"/octomap_full
Octomap latched"| PLAN PLAN -->|"/uav/path
原始折线"| RVIZ PLAN -->|"/uav/path_smooth
平滑后路径"| RVIZ GOAL(["目标点
/uav/goal"]) --> PLAN GOAL --> BR

整条链路上只有 astar_planner 一个节点是本项目新增的 C++ 节点, 其余为桥接(Python)、建图(C++)与 RViz。各节点与话题的完整说明见 上一节《八叉树上的 A* 全局寻路》。

1.1 各节点的职责

节点 可执行文件 职责
airsim_bridge scripts/airsim_bridge.py 通过 RPC 从宿主机 AirSim 取点云与位姿,发布 /cloud_in、/ground_truth/odom、/uav/status 与 TF;同时订阅 /uav/goal,把它转成 AirSim 的目标点调用
pointcloud_to_octomap src/pointcloud_to_octomap.cpp 订阅 /cloud_in,用 TF 变换后用 insertPointCloud() 做射线投射,发布 /octomap_full(latched)与 /occupied_cells_vis_array
astar_planner src/astar_planner.cpp 订阅 /octomap_full 与 /uav/goal,查 TF 取起点,做 A* 规划;开启开关后叠加安全裕度与路径平滑,发布 /uav/path、/uav/path_smooth、/uav/explored_markers
rviz — 显示点云、占用体素、两条路径与展开节点

1.2 本节的插入点

本节的两项功能都插在 astar_planner 内部,不新增节点、不新增输入话题:

graph TD CLOUD["/cloud_in
PointCloud2 10 Hz"] --> BR["airsim_bridge"] BR -->|"TF world→base_link"| PLAN MAP["pointcloud_to_octomap"] -->|"/octomap_full
Octomap latched"| PLAN GOAL(["目标点 /uav/goal"]) --> PLAN subgraph PLANNER["astar_planner 节点内部"] A1["A* 规划
(上一节:树遍历 + 跨分辨率邻接)"] A2["安全裕度
膨胀 = 硬约束 / 间隙惩罚 = 软偏好"] A3["路径平滑
捷径化 + 三次样条 + 复检"] A1 --> A2 A2 -->|"原始折线"| RAW(["发布 /uav/path"]) A2 --> A3 A3 -->|"平滑曲线"| SM(["发布 /uav/path_smooth"]) end MAP --> BR RAW --> RVIZ["rviz
两条路径同屏对比"] SM --> RVIZ

安全裕度作用于规划本身(把"贴障碍"变成"不可通行"), 平滑作用于规划输出(把折线变成可跟踪的曲线)。 两者都受同一套可通行判定约束,所以平滑不会把路径拉回障碍旁。

两条路径并存发布,是为了在 RViz 里同屏对比平滑前后的差别:绿色是原始折线, 青色是平滑后的曲线。

1.3 话题与数据流

环节 话题 消息类型 说明
点云 /cloud_in sensor_msgs/PointCloud2 桥接节点发布,约 10 Hz
地图 /octomap_full octomap_msgs/Octomap 建图节点以 latched 方式发布,晚连上的订阅者也能立刻收到
目标点 /uav/goal geometry_msgs/Point 世界系 ENU。规划器与控制器都订阅它
路径 /uav/path nav_msgs/Path 原始折线(A* 输出),latched
平滑路径 /uav/path_smooth nav_msgs/Path 平滑后的路径,latched;与上面并存便于对比
展开节点 /uav/explored_markers visualization_msgs/MarkerArray 按节点尺寸着色,用于观察跨分辨率行为

坐标系:TF 链是 world → base_link → lidar_link;world 为 ENU,桥接节点负责 NED↔ENU 转换。

1.4 发布一次目标点

rostopic pub -1 /uav/goal geometry_msgs/Point "{x: 8.0, y: 8.0, z: 2.0}"

该话题被两个节点订阅:astar_planner 收到后规划一条路径,airsim_bridge 收到后让载具飞过去。 若只想看规划结果而不希望载具移动,用 bridge:=false 启动:

roslaunch octree_uav_3d_pathfinding main.launch bridge:=false

2. 为什么需要这一节

A* 输出的路径是八叉树叶节点中心的序列,直接拿它去飞有两个问题:

问题 具体表现
没有安全裕度 路径航点落在自由叶的中心上,代价是"两节点中心的距离",所以路径可以贴近障碍物。它只是没有撞上,没有留余量
是折线,且航点疏密不均 空旷处一步跨过十几米,障碍附近一步 0.3 m。折线拐角处的曲率为无穷大,控制器无法跟踪;同时有大量共线的冗余航点

本节做两件事来对应这两个问题:安全裕度(膨胀 + 代价惩罚)与路径平滑(捷径化 + 三次样条)。


3. 关键认识:两种安全裕度不是平级的

一个容易想当然的做法是把"地图膨胀"和"代价惩罚"当作两个并列的手段,任选其一。 实际它们是"硬约束"与"软偏好"的关系,本节的设计都建立在这个区分上。

回到 A* 的代价函数:

路径航点是自由叶的中心,于是:

只做代价惩罚 只做地图膨胀
不产生硬保证。惩罚只让贴近障碍的边变贵,A* 在别无选择时仍会贴过去——惩罚大只是"不划算",不是"不允许" 让膨胀占用成为不可通行,路径中心到真障碍的距离有了下界 。这是硬保证

结论:

惩罚要消费膨胀算出的间隙,两者共用同一份信息,而不是各算一套距离。实现上对应 MapInflation(产出)+ clearance penalty(消费)两个组件,而不是两个平级开关。


4. 安全裕度(一):地图膨胀 —— 硬约束

4.1 影子树

把原树里每个占用叶节点沿轴向外扩 ,把外扩盒内的体素全部标进一棵 影子树:

for 每个占用叶(中心 c、半边长 h):
    盒子 = [c − h − r, c + h + r]
    把盒内的体素逐个标进影子树

于是"是否可通行"多了一道硬判定:

bool isBlocked(const point3d& p);   // true = 落在安全裕度内,不可通行

为什么用影子树而不是直接改原树:

  • 原树仍用于发 /octomap_full 和 RViz 可视化,前后占用体素一致
  • 膨胀的信息是新增的,不改建图结果
  • 时影子树为空、isBlocked() 恒为 false,整套代码退回上一节的行为 (这是回归测试的锚点,见 §7)

4.2 膨胀必须"按键枚举",不能自己算坐标

这里有一处容易出错的地方。octomap 的体素中心落在

这组格点上。如果按"盒下界 + 半个体素"生成中心:

// 错误做法:与键格错开半个体素
const point3d q(lo.x() + (i + 0.5) * res, ...);

得到的格点与键格错开半个体素,"写入影子树的体素"与"判定时命中的体素" 不是同一批。症状是被膨胀的体素中心自己判不出阻塞(实测间隙 0.45 m,期望 0.6 m)。

正确做法是按整数键枚举:

OcTreeKey lo_key, hi_key;
tree.coordToKeyChecked(lo, lo_key);      // 先把盒的坐标范围落到键上
tree.coordToKeyChecked(hi, hi_key);
for (long long i = lo_key[0]; i <= hi_key[0]; ++i)      // 按整数键枚举
  for (long long j = lo_key[1]; j <= hi_key[1]; ++j)
    for (long long k = lo_key[2]; k <= hi_key[2]; ++k)
      tree.updateNode(OcTreeKey(i, j, k), true);        // 插入与判定走同一条路径

这样"插入的位置"与"判定时命中的位置"逐格一致。

4.3 取多大:安全性与可通过性的取舍

膨胀会不可逆地损失可通过空间:原来能走的窄缝,膨胀后可能消失。 所以 的选择是一个取舍:

效果
关闭膨胀,退回上一节行为(回归基线)
推荐值(2 个体素):路径中心到真障碍至少留 0.6 m
更保守,窄通道可能被堵死,表现为"基线有解、加膨胀后无解"

"加膨胀后无解"是预期行为,不是缺陷,它是安全裕度的代价。 实测的膨胀规模见 §7。


5. 安全裕度(二):间隙惩罚 —— 软偏好

在膨胀给出硬下界之后,再用代价函数把路径从障碍旁边推开一点:

其中 是节点 到膨胀边界的间隙(m),由膨胀层提供。

  • :代价等于上一节 —— 又一个回归锚点
  • :离障碍越近的边越贵,A* 倾向于走有余量的通道

关于最优性:惩罚只让 变大,所以启发函数仍可采纳 ( 时保证最优)。但 时目标函数被重新定义, 得到的是"新代价下的最优",不是原目标下的次优解。

间隙 的算法是近似:从节点中心沿 6 个轴向按步长探测,数到第一次 命中膨胀占用为止,并按 max_range 截断。它代价低,对软偏好足够, 但不是精确的欧氏距离(真实最近点可能在对角方向)。


6. 路径平滑

两步,顺序不能反。

6.1 第一步:捷径化(shortcut)

贪心地把折线取直:

for i in 0..n-1:
    for j from n-1 down to i+2:        # 从最远的候选试起,取直收益最大
        if 线段 p[i]→p[j] 无碰撞:
            保留 p[j],丢掉 p[i+1..j-1]; break

三个要点:

  1. 碰撞检测用的是规划器自己的可通行谓词,它包含两层判定: 先查原树的占用与未知(未知一律当障碍),再叠加膨胀阻塞。 所以平滑也受安全裕度约束,不会把路径拉回障碍旁;规划与平滑共用同一套安全语义。 这两层都必须有 —— 少了原树那一层,inflation_radius = 0 时谓词会退化成 "任何位置都可行",捷径化就会把路径直接取直、穿过障碍(§7 有用例覆盖)。
  2. 采样步长必须小于分辨率(默认 )。 否则线段会从两个占用体素的对角缝里穿过去。这是安全关键项, 不是可以调大的性能参数(§7 有一条反例断言证明这一点)。 因此 smoothPath 强制 : 显式传入的值只要 ≥ 分辨率,就被限幅到 , 并在 SmoothStats 里标记、由节点打 WARN —— 不静默改参数,也不放行。
  3. 首尾两点保留:它们是任务给定的起点与终点。

6.2 第二步:三次样条

在捷径化后的折线上做自然三次样条插值(曲线过航点):

  • 按累积弦长参数化
  • 对三个坐标分量分别解一次三对角方程组(Thomas 算法),自然边界条件
  • 逐段采样输出

不需要引入 Eigen 或第三方样条库。

6.3 拟合与逼近的取舍

路线 做法 优点 缺点
拟合 曲线过航点 贴合原折线 在拐角内侧向外偏离,可能侵入障碍
逼近 航点当控制点 曲线在控制点凸包内,更平滑 不过航点,会切角

本实现选拟合 + 复检 + 不过就退回折线。第一优先级是不引入碰撞, 平滑度在其次。平滑是增强,不能变成引入碰撞。

复检:样条按 重采样,逐点且逐段检查;只要有一点越界, 就整条丢掉、退回捷径化折线,并在日志里说明"被复检拒绝"。

6.4 曲率计算与除零守卫

平滑后的路径要能衡量"有多光滑",用三点外接圆公式:

分母是三条边长的乘积,共线、重复点、零长时都会逼近 0。守卫策略:

  • 任一邻边长度 →
  • 叉积模长 → (三点共线)
  • 命中守卫时计数上报,不返回 NaN/inf

7. 验证

7.1 离线单元测试

tests/test_smoothing.cpp,只依赖 octomap 与 STL,不需要 ROS 与模拟器。 实测 72 项检查全部通过。

用例 验证内容
回归: isBlocked() 恒 false、间隙饱和、出界点行为与基线一致
可通行谓词( + 平滑) 占用点不可通行、已知空闲点可通行、未知点当障碍;两端点均为已知空闲时,捷径化不得跨过中间的障碍
捷径化:穿墙 给一条绕过墙的折线,取直会穿墙,必须拒绝
捷径化:采样步长 反例断言:步长 1.0 m(> 墙厚 0.2 m)会钻过墙,证明步长是安全关键项
采样步长:限幅 显式传入 1.0 m(≥ 分辨率 0.3)被压到 0.15;默认路径不触发;恰好等于分辨率也拦
曲率守卫 共线 / 重复点 → 且不产生 NaN/inf;单位圆三点 →
样条 无障碍时被采纳、加密路径、过两端点;发布路径的点与线段全部无碰撞
膨胀: 不建影子树、isBlocked 恒 false
膨胀: 体素中心自己被阻塞、0.5 m 内阻塞、1.5 m 外空闲
膨胀:规模 见下表
膨胀:只增不改 原树的占用叶数与节点数不变

7.2 膨胀规模实测(风险项:膨胀后树规模)

一面 的薄墙(面状分布,接近真实地图的形态):

影子树叶数 写入体素数 影子树内存 相对原树内存 构建耗时
0.3 m 1667 17424 0.0748 MB ×3.54 7.3 ms
0.6 m 2278 57624 0.0846 MB ×4.00 16.7 ms
1.0 m 1955 186624 0.0793 MB ×3.75 42.6 ms

体素数随 单调增长,但叶数不单调( 的叶数少于 )。 原因是影子树按八叉树规则剪枝: 的外扩盒边界与 0.3 m 分辨率的层级对得更齐, 更容易合并成大节点。所以测试断言"体素数单调",而不是"叶数单调"。

7.3 路径指标对比(离线端到端实测)

实验平台:scripts/mock_cloud_publisher.py 造的离线场景 —— 房间 , 内有 5 个从地面顶到天花板的障碍(两道错开的墙把空间切成走廊,外加三根柱子)。 传感器在房间四角依次驻留 10 秒填满地图,之后停在最后一个驻留点。 规划起点为传感器最终位置,目标点取其对角 ,三组使用同一条目标点。

三组参数对照(每组重启规划节点 —— 参数只在节点启动时读取):

配置 膨胀半径 平滑
基线 0(关闭) 关闭
+膨胀 0.6 m 关闭
+膨胀+平滑 0.6 m 开启(捷径化 + 样条)

结果:

指标 基线 +膨胀 +膨胀+平滑
扩展节点数 4915 775 721
路径航点数 40 23 10
路径长度 29.71 m 27.53 m 36.46 m
最大曲率 2.1082 1/m 1.6667 1/m 0.7018 1/m
到障碍的最小间隙 0.15 m 0.75 m 1.20 m
规划耗时 82.3 ms 14.1 ms 13.1 ms

膨胀的作用(基线 → +膨胀):

  • 最小间隙 ,抬到安全半径 之上 —— 硬约束的直接效果
  • 扩展节点数 (−84%):大量自由叶落进膨胀带后不再可通行,搜索空间变小
  • 规划耗时 ,与搜索空间缩小一致
  • 膨胀本身的开销是一次性的:inflated voxels=3212130、shadow leaves=20023、 nodes=261154、内存 0.77 MB、建树 613 ms(建图后做一次,不随规划次数增长)

平滑的作用(+膨胀 → +膨胀+平滑):

  • 航点数 ,最大曲率 (−67%)
  • 最小间隙 ,没有变差

该场景下样条被复检拒绝:

P5 smooth pipeline: 43 -> 10 pts | shortcut applied (0.5 ms)
                                 | spline not applied [REJECTED by recheck]
note: spline rejected by collision recheck; fell back to shortcut polyline

上表第三列因此是捷径化折线的指标,不含样条。原因与 §6.3 写的一致:样条是拟合 (必须穿过每个航点),捷径化后只剩 10 个航点,而拐角航点恰好贴在安全边界上 (最小间隙 0.75 m 即来自该点);样条在拐角内侧会向外偏,于是侵入膨胀区。 复检此时按设计整条丢弃样条、退回折线 —— 安全性优先于平滑度。

也就是说:平坦通道上的冗余航点被捷径化去掉了,而剩下的拐角样条过不去 —— 后者是拟合路线的固有代价,不是实现缺陷。改进方向见 §7.4。

关于两组数据的可比性:传感器是巡游的,发目标点的时刻不同,起点会落在不同的自由叶里 (基线为 (-7.50, 7.50),后两组为 (-8.40, 8.40))。所以长度一列不宜横向比较; 其余指标(最小间隙、曲率、航点数、扩展数)反映各自路径的形态,不受影响。

三组在同一场景、同一目标点下的 RViz 对比(离线验证场景,与上一节的图同源):

基线(r=0,无平滑)的路径

基线组的路径贴着障碍与未知区走,最小间隙 0.15 m。

+膨胀(r=0.6 m)的路径

膨胀后路径离障碍明显更远,最小间隙 0.75 m。

+膨胀+平滑的路径

绿色是原始路径(43 点),青色是平滑后路径(10 点捷径折线)。该场景下样条被复检拒绝。

7.4 进一步工作

本节实测暴露的两点,按性价比排序:

# 项 说明
1 拐角处保留航点再拟合 让样条不要在拐角被迫大幅弯曲,从而通过复检。这是让"拟合"路线真正可用的关键
2 改用逼近(B 样条) 航点当控制点,曲线落在控制点凸包内,不穿过拐角 —— 从根上避免"拐角外偏"(见 §6.3)
3 最小捕捉的平滑 把平滑写成一个带约束的优化(目标是最小化曲率或加加速度,约束是全程离障碍 ≥ 安全半径),比"先折线再拟合"更彻底。代价是需要引入二次规划求解器
4 指标的精确化 目前的最小间隙沿 6 个轴向步进探测(偏乐观)。精确做法是对每个路径点做 26 邻域欧氏距离变换,代价高得多

8. 参数

config/params.yaml 的 astar_planner 组(默认值都设成"等价于上一节"):

参数 默认 说明
inflation_radius 0.0 安全裕度半径(m)。0 = 关闭膨胀,行为等于上一节
inflation_resolution 0.0 影子树分辨率;0 = 与原树一致(推荐)
clearance_penalty_weight 0.0 间隙惩罚权重 。0 = 关闭惩罚
clearance_penalty_scale 0.0 惩罚衰减尺度 ;0 = 取 inflation_radius
smoothing false 路径平滑总开关(默认关)
smoothing_shortcut true 是否做捷径化
smoothing_spline true 是否做三次样条
smoothing_sample_step 0.0 碰撞检测步长;0 = resolution/2。≥ 分辨率会被强制限幅到 resolution/2 并打 WARN(见 §6.1)
smoothing_samples_per_segment 8 样条每段采样点数
smooth_path_topic /uav/path_smooth 平滑后路径话题,与 /uav/path 并存便于对比
metrics_log true 打印路径长度 / 最大曲率 / 最小间隙 / 三段耗时
clearance_probe_range 2.0 最小间隙指标的探测上限(m)

默认值全部"等价于上一节"的原因:

  1. 上一节的验收场景可以原样重跑做回归;
  2. 打开开关即可对比;
  3. 避免合入后改变原有行为。

输出话题:

话题 类型 说明
/uav/path nav_msgs/Path 原始折线(上一节的口径,不变)
/uav/path_smooth nav_msgs/Path 平滑后的路径,与上面并存便于 RViz 对比

9. 实现中踩到的坑(供参考)

9.1 判定"是否被占用"不能用"子节点槽位是否为空"

这个判据造成过两个看起来无关的现象。

octomap 在合并/剪枝时会把连片的空闲兄弟删掉,把占用状态留在父节点上。 所以下面这种自己写的下潜判据是错的:

if (!nodeHasChildren(n)) return false;        // 错:当作"到底了,未被标占用"
if (!nodeChildExists(n, idx)) return false;   // 错:当作"该分支未分配"

它不能推出"该处未被标占用"。症状有两种:

  • 膨胀层:被膨胀的体素中心自己判不出阻塞,测出的间隙偏小
  • 上一节的常驻自检:findLeaf self-check: 2000 个自由叶里 172 个查不回来

解法:在影子树里直接用 octomap 自己的 search()。 影子树是逐个底层键插进去的,没有粗自由节点, 所以不存在"search() 遇到剪枝节点返回 NULL"的问题。

这不能照搬到主地图:上一节绕开 search() 的理由是成立的—— 剪枝后的粗空闲节点会让 search() 返回 NULL,而 A* 的路径要走这些大节点。 主地图的叶子查找需要两者结合。

9.2 八叉树遍历器的 key 与"叶子所在层的 key"不是同一个东西

selfCheckLookup 长期误报 172/2000,一度被当成"叶子查找有 bug"。 实际是自检的比较基准错了:它把遍历器报告的原生 key 直接和 "折算到叶子所在层的 key"相比,而遍历器的原生 key 把"该叶在父节点中的位置"留在低位。

实测的对应关系(树深 16):

叶子深度 遍历器原生 key 折算后 右移位数
13 32772 4096 3 = 16 − 13
15 32777 16388 1 = 16 − 15

修法:比较前把遍历器 key 也右移 (树深 − 叶子深度) 位。 改完后,两种测试地图共 1519 个自由叶全部命中(KEY-DIFF = 0)。

结论:自检报"大量失配、但功能看起来正常"时,要先怀疑自检本身。

9.3 手写 octomap 的编译命令必须同时链两个库

liboctomath 是独立的库。只写 -loctomap 会报:

undefined reference to `octomath::Pose6D::transform(octomath::Vector3 const&)'
/opt/ros/noetic/lib/liboctomath.so.1.9: error adding symbols: DSO missing from command line

catkin 的 OCTOMAP_LIBRARIES 里两个都带,所以工程内不会暴露, 只有脱离 catkin 手写 g++ 时才会踩到。正确写法:

g++ -std=c++11 -I/opt/ros/noetic/include foo.cpp \
    -L/opt/ros/noetic/lib -loctomap -loctomath -o foo

10. 参考

  • OctoMap 官网
  • de Boor, A Practical Guide to Splines, Springer, 1978(B 样条与三对角求解)
  • Mellinger & Kumar, Minimum Snap Trajectory Generation and Control for Quadrotors, ICRA 2011(更高级的平滑路线,本文只讲解、未实现)
  • nav_msgs/Path 消息定义
  • 模块源码:src/air/octree_uav_3d_pathfinding/

查看实际运行的节点与话题关系(第 1 节的图是静态版本):

rosrun rqt_graph rqt_graph        # 节点—话题图
rosrun rqt_tf_tree rqt_tf_tree    # TF 树
rostopic list                     # 所有话题

本文档及配套模块代码在编写过程中使用了 AI 大模型辅助(方案讨论、代码与文档起草、问题排查)。 全部内容经过实际运行验证:模块在 Ubuntu 20.04.6 + ROS Noetic 环境下编译通过, 离线单元测试(test_smoothing,59 项检查)全部通过。 作者对提交内容的正确性与完整性负全部责任。