路径跟踪与闭环飞行

本文介绍空域载具八叉树三维寻路项目中的闭环环节:把前面几节规划出的路径 逐点交给控制器,让载具真的飞过去。

对应总纲第 7 节。


1. 运行节点与话题关系

本节新增一个节点 path_follower,其余节点沿用前几节。 roslaunch octree_uav_3d_pathfinding full_demo.launch 会启动下图除模拟器以外的全部节点:

graph LR SIM["Windows 宿主
AirSim 模拟器
RPC 41451"] subgraph VM["Ubuntu 虚拟机 (ROS Noetic)"] BR["airsim_bridge
桥接节点"] MAP["pointcloud_to_octomap
建图节点"] PLAN["astar_planner
规划与平滑节点"] FOL["path_follower
路径跟踪节点"] RVIZ["rviz"] end SIM -->|"RPC 41451 取点云与位姿"| BR BR -->|"/cloud_in"| MAP BR -->|"TF world→base_link→lidar_link"| PLAN MAP -->|"/octomap_full"| PLAN USER(["用户目标 /uav/goal"]) -->|"只触发规划"| PLAN PLAN -->|"/uav/path_smooth"| FOL BR -->|"/ground_truth/odom 20 Hz"| FOL FOL -->|"/uav/goal_cmd
逐点航点"| BR BR -->|"/uav/goal_cmd 转 moveToPositionAsync"| SIM PLAN -->|"/uav/path
原始折线"| RVIZ PLAN -->|"/uav/path_smooth
平滑路径"| RVIZ FOL -->|"/uav/flown_path
实际轨迹"| RVIZ

1.1 各节点的职责

节点 可执行文件 本节相关的职责
airsim_bridge scripts/airsim_bridge.py 发布 /cloud_in、/ground_truth/odom 与 TF;订阅控制话题,把航点转成 AirSim 的 moveToPositionAsync
pointcloud_to_octomap src/pointcloud_to_octomap.cpp 点云 → 八叉树地图(本节不涉及)
astar_planner src/astar_planner.cpp 订阅 /uav/goal 与地图,输出 /uav/path 与 /uav/path_smooth
path_follower src/path_follower.cpp 订阅路径与位姿,判断"该追哪个航点",向控制话题逐点下发
rviz — 显示两条规划路径与实际飞过的轨迹

1.2 话题

话题 方向 类型 说明
/uav/goal 用户 → 规划器 geometry_msgs/Point 只触发规划,不直接控制飞行
/uav/goal_cmd 跟踪 → 桥接 geometry_msgs/Point 当前要飞的航点
/uav/path_smooth 规划 → 跟踪 nav_msgs/Path 要跟踪的路径(默认;可切 /uav/path)
/ground_truth/odom 桥接 → 跟踪 nav_msgs/Odometry 位姿,20 Hz,位置是 ROS 世界系 ENU
/uav/follow_status 跟踪 → RViz std_msgs/String IDLE / FOLLOWING i/N / DONE
/uav/flown_path 跟踪 → RViz nav_msgs/Path 实际飞过的轨迹,与规划路径同屏对比

2. 需要先解决的一个结构性问题

/uav/goal 被两个节点同时订阅:规划器订阅它来规划,桥接节点订阅它来飞。 如果跟踪节点也往 /uav/goal 逐点发航点,就会出现:

path_follower 发航点 → /uav/goal → astar_planner 重新规划 → 发布新路径
                                → airsim_bridge 飞向该航点
path_follower 收到新路径 → 从第 0 点重新开始 → 又发航点 → …

每次重规划都要跑一遍 A*,路径还会因为起点变化而抖动,载具飞不出去。

解决办法是把两种语义拆成两个话题:

话题 谁发布 谁订阅 语义
/uav/goal 用户 astar_planner "为我规划到这里的路径"
/uav/goal_cmd path_follower airsim_bridge "飞到这个航点"

实现上只改了桥接节点的一处 —— 话题名从硬编码改成参数,默认值保持 /uav/goal:

self.goal_topic = rospy.get_param('~goal_topic', '/uav/goal')
rospy.Subscriber(self.goal_topic, Point, self.goal_cb, queue_size=1)

full_demo.launch 把它设成 /uav/goal_cmd。默认值不变,前三节启动方式与行为都不受影响。


3. 状态机

跟踪逻辑全部放在 include/octree_uav_3d_pathfinding/path_follow_core.h, 是不依赖 ROS 的纯函数,因此可以离线单测;src/path_follower.cpp 只做消息转换与发布。

        ┌──────────────────────────────────────────┐
        │                                          │
   ┌────▼────┐  收到路径(pts>=2)  ┌───────────┐   │
   │  IDLE   ├──────────────────►│ FOLLOWING │   │
   └─────────┘                   └─────┬─────┘   │ 收到新路径:
        ▲                              │         │ 复位到新路径
        │                  到达终点     │         │(不是重规划)
        │                              ▼         │
        └───────────────────────── ┌─────────┐   │
                                   │  DONE   │───┘
                                   └─────────┘
状态 行为
IDLE 等一条至少两个点的路径;不合格的路径(空、单点)被忽略
FOLLOWING 判断当前该追哪个航点并按需下发;到终点转 DONE;单点超时则跳过
DONE 停止下发。之后再收到位姿也不动作

为什么收到新路径要从头开始:规划器每规划一次都会发一条新路径(latched), 旧路径可能已经因为地图更新而失效,继续按旧下标追没有意义。

3.1 判定规则

dist = ‖当前位置 − path[index]‖

若 index 是最后一个点:
    若 dist < finish_tolerance  → 到达,转 DONE
否则若 dist < lookahead         → index++,下发 path[index]
否则                            → 不动

若 距上次前进 > per_point_timeout:
    若 index 是最后一个点      → 直接结束(不跳过,否则永远到不了终点)
    否则                       → index++,计入 skipped,下发新航点

三个阈值的关系是本节的关键:

关系 后果
lookahead > finish_tolerance 载具在飞向前一点的过程中目标已被改写 → 连续飞行
lookahead ≤ finish_tolerance 退化成"每点停稳",动图里一顿一顿(节点启动时会打 WARN 提醒)
终点只认 finish_tolerance 否则会在距终点 lookahead 处就判"到达",最后 1.5 m 不飞

4. 两个取舍

4.1 前瞻跳点,而不是停稳再走

桥接节点只有位置控制(moveToPositionAsync:飞到一个点并减速停住), 没有速度或轨迹跟踪接口。所以"连续飞行"只能靠提前改写目标实现:

做法 结果
等 dist < finish_tolerance 再发下一点 每个点都减速停住,30 个航点就是 30 次启停
dist < lookahead 就发下一点 载具在飞行途中目标被改写,动作连续

代价:实际轨迹会比规划路径切内角(没到点就转向)。 这个代价是量化的 —— 节点统计 max_dev:实际轨迹到规划折线的最大距离。 注意是"到线段"而不是"到航点":航点稀疏,到航点的距离会把正常切内角误判成大幅偏离。

这个做法依赖一个前提:后发的目标覆盖先发的目标,而不是排队执行。 这一点在 AirSim 1.8.1 上实测确认过(2026-10-26):先发 A(60, 0, 8),在载具 仍在飞往 A 的途中改发 B(−60, 0, 8),记录 /ground_truth/odom 得到的距离曲线是:

时刻 到 A 的距离 到 B 的距离
起点(停在 B 附近) 100.23 m 19.78 m
改发 B 的那一刻 41.28 m 77.18 m
记录结束 104.18 m 15.82 m

到 A 的距离在 41.28 m 处由减小转为增大,同期到 B 的距离单调收敛 —— 载具 没有飞到 A 就掉头了。所以 moveToPositionAsync 是覆盖语义,前瞻跳点成立。 若换成排队语义,这套设计就得改成"每个航点停稳后再发下一个",max_dev 也会 退化成接近 0(没有切内角),代价是每段都要启停。

4.2 不做飞行中重规划

飞行中地图持续生长,每张新地图都可能产出不同路径,跟着换会让载具抖动; 真正的重规划还需要"路径变化超过阈值才换"的一致性判据。本节只在收到新路径时复位, 不做主动重规划 —— 见 §7。


5. 参数

config/params.yaml 的 path_follower 组:

参数 默认 说明
path_topic /uav/path_smooth 要跟踪的路径。切 /uav/path 可做"折线 vs 曲线"对照
odom_topic /ground_truth/odom 位姿来源
goal_cmd_topic /uav/goal_cmd 下发给控制器的航点
status_topic /uav/follow_status 状态文本
flown_topic /uav/flown_path 实际轨迹
lookahead 1.5 距当前航点该距离内切换下一点(m)
finish_tolerance 1.0 到达终点的判定距离(m)。不能只按"约 2 倍地图分辨率"取:AirSim 的位置环有稳态误差,本机实测 0.86 m,取 0.5 时末点永远够不着(见 §7.4)
per_point_timeout 15.0 单点超时(s)
publish_flown true 是否发布实际轨迹

airsim_bridge 组新增:

参数 默认 说明
goal_topic /uav/goal 默认与前几节一致;full_demo.launch 里设成 /uav/goal_cmd

飞行速度仍由既有参数 goal_velocity(默认 3.0 m/s)决定。


6. 运行方法

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

# 闭环飞行:完整链路(桥接 + 建图 + 规划 + 跟踪 + 自检 + RViz)
roslaunch octree_uav_3d_pathfinding full_demo.launch

6.1 用 mock 场景离线验证(不起模拟器)

bridge:=false 只是不启动桥接节点,它不提供点云与位姿。所以离线验证要另外起两个节点, 各占一个终端;否则没有 /cloud_in、没有 /ground_truth/odom,规划器和跟踪节点都不会动:

# 终端 1:roscore
roscore

# 终端 2:点云(巡游 40 秒填满地图,之后停在 (-7.5, 7.5))
rosrun octree_uav_3d_pathfinding mock_cloud_publisher.py

# 终端 3:假载具(位姿来源)。起点对齐路径起点,避免多一段"飞向起点"
rosrun octree_uav_3d_pathfinding mock_uav_mover.py \
    _start_x:=-7.5 _start_y:=7.5 _start_z:=0.0

# 终端 4:链路本身
roslaunch octree_uav_3d_pathfinding full_demo.launch \
    bridge:=false check:=false body_frame:=lidar_link

若不传 _start_x/_start_y,假载具从 (0, 0, 0) 起飞,会先飞 22.5 m 到路径起点。 那段"接近段"不计入 flown 与 duration(见下表的 approach 字段),但会拉长总耗时。

6.2 下发目标点

飞行器起飞、扫描一段时间地图建起来之后,下发一次目标点即可,后续由跟踪节点接管:

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

到达终点后节点会打印统计:

follow: DONE | waypoints=73 reached=73 skipped=0 final_timed_out=0 | path=33.52 m flown=31.95 m
        approach=0.00 m/0.00s | duration=10.65s | mean_speed=3.00 m/s | max_dev=0.41 m
  corner cutting: flown is 4.7% shorter than the path
字段 含义
waypoints / reached / skipped 路径点数、到达数、因超时跳过的数量
final_timed_out 终点是否为"超时收尾"(1 = 没进 finish_tolerance 就结束)。此时它不计入 reached
path 要跟踪的路径总长(m)
flown 跟踪阶段实际飞过的路程(m),不含接近段
approach 载具飞向路径起点的那一段:路程(m)/ 耗时(s)
duration 进入跟踪阶段到到达终点(s),与 flown 覆盖同一段轨迹
mean_speed flown / duration,即真实平均速度;上限是 goal_velocity
max_dev 实际轨迹到规划折线的最大距离(m),即 §4.1 里"切内角"的程度
corner cutting path 与 flown 的相对差;斜穿拐角省下的路。仅当 flown < path 时打印

两个口径必须分清:

  • mean_speed 的分子是 flown 而不是 path。前瞻跳点会切内角,实际路程比规划路径短; 用 path / duration 算出来的是"路径推进速率",会超过 goal_velocity (实测出现过 37.22 / 10.90 = 3.41 m/s,而设定速度是 3.0 m/s,物理上不可能)。
  • max_dev 只统计贴上路径之后的轨迹。index = 0 表示载具还在飞向路径起点, 那一段是"接近段",本来就不在路径上;计入的话,一次从上一趟终点飞回新起点的 22.5 m 直线会把 max_dev 拉到 5 m 以上,指标就失去意义。

reached 的口径是 waypoints - skipped - final_timed_out:终点若是超时收尾 (没进 finish_tolerance),它不再算作到达,日志里也会把 final_timed_out 标成 1。


7. 验证

7.1 离线单元测试

tests/test_path_follower.cpp,只依赖 STL —— 不需要 ROS 运行时、不需要 octomap、不需要模拟器。 被测对象是 path_follow_core.h 里的纯函数。实测 59 项检查全部通过。

用例 验证内容
前瞻切换 进 lookahead 就切换并下发;起点在脚下时直接从 0 跳到 1
边界:恰好等于 lookahead 不切换(用 < 而不是 <=)
终点判定 最后一个点距 1.0 m(小于 lookahead)不算到达,只认 finish_tolerance
容差 1.0 m(AirSim 上用的默认值) 距 1.2 m 不算到达、距 0.9 m 算到达,且不置 final_timed_out
超时 中间点超时跳过并计数;前进一次就重新计时
终点超时 直接结束,不计入 skipped,但置 final_timed_out
进容差恰好撞上超时那一拍 先判到达、再判超时:算 Finished,不记成超时收尾
新路径复位 复位后回到 IDLE、下标归零、skipped 与 max_dev 清零
退化输入 空路径 / 单点路径被忽略;重复点组成的退化线段不产生 NaN
偏离度量 到折线而非到航点;折返路径同样正确;历史最大值只增不减

运行方式:

~/uav_ws/devel/lib/octree_uav_3d_pathfinding/test_path_follower
# 退出码 0 = 全部通过

7.2 离线整链路(mock 场景)

场景:scripts/mock_cloud_publisher.py 造的 20×20×6 m 房间 + 5 个障碍(两道错开的墙 + 三根柱子), 传感器巡游 40 秒填满地图后停在 (-7.5, 7.5)。目标点取其对角 (7.5, -7.5, 2.0)。

位姿由 scripts/mock_uav_mover.py 提供 —— 它是只有运动学的桩:匀速直线、到达即停、 不做碰撞判定。它替代的是桥接节点的位姿发布,不是飞行物理,所以这组数据只反映跟踪逻辑, 不反映真实飞行品质。

测量条件:

项 值
假载具起点 (-7.5, 7.5, 0.0)(与路径起点对齐,接近段为 0)
inflation_radius 0.6
smoothing true
smoothing_shortcut / smoothing_spline true / true
smoothing_samples_per_segment 8(默认)
lookahead / finish_tolerance / per_point_timeout 1.5 m / 1.0 m / 15 s
goal_velocity 3.0 m/s
启动命令 full_demo.launch bridge:=false check:=false body_frame:=lidar_link

起点必须对齐:假载具默认起点是 (0, 0, 0),此时它会先飞 22.5 m 到路径起点。 日志里 approach 字段单独给出这段,flown 与 duration 都只算跟踪阶段。

航点数取决于样条是否通过复检

同一套参数跑出来的结果分两种形态,差别不在跟踪逻辑,而在 P5 的平滑结果:

形态 平滑输出 航点数 max_dev
样条被接受 102 -> 73 pts 73(加密) 0.41 m(很贴路径)
样条被复检拒绝 48 -> 11 pts,退回捷径折线 10 ~ 11 1.25 ~ 1.26 m(切角多)

样条是拟合,必须穿过每个航点,拐角处的航点贴在安全边界上时会被复检拒绝 (见 P5 文档 §6.3)。它是否通过取决于地图几何的细节,因此不同次的运行可能落在不同形态。

所以下面把两种形态的数据都列出来,并只用形态内部的数字做比较。

指标 样条被接受(73 点) 样条被拒绝(10~11 点)
到达 / 跳过 73 / 0 10~11 / 0
path 33.52 m 36.3 ~ 37.2 m
flown 31.80 ~ 31.95 m 32.1 ~ 32.3 m
approach 0.00 m / 0.00 s 0.00 ~ 0.30 m
duration 10.59 ~ 10.65 s 10.7 ~ 10.8 s
mean_speed 3.00 m/s 3.00 m/s
max_dev 0.41 m 1.25 ~ 1.26 m
切内角省下的路程 4.7% 13%

稳定的是判定性的量:skipped = 0(全部到达)、mean_speed 正好等于 goal_velocity、 max_dev 小于 lookahead。这三条在两种形态、多次运行里都成立。

浮动的是路径形态相关的量:航点数、path、max_dev。它们由"样条是否通过复检" 和"地图自由叶数"(实测 17945 与 19366)共同决定,复现时不必追求逐位相同 —— 按同形态的量对比即可。

max_dev 与航点密度直接相关:73 点的样条路径几乎逐点走到了,偏离 0.41 m; 10 点的折线只剩拐角,前瞻跳点会斜穿,偏离升到 1.25 m。

跟折线 vs 跟曲线

同一场景、同一目标点,只改 path_topic。下表是另一次运行的对照 (同一张地图、同一个规划结果的两条路径;与该次运行同属"样条被拒绝"的形态, 见上面的两种形态表):

指标 跟原始折线 /uav/path 跟平滑路径 /uav/path_smooth
航点数 48 11
path 37.86 m 36.28 m
flown 33.15 m 32.10 m
duration 11.05 s 10.70 s
mean_speed 3.00 m/s 3.00 m/s
max_dev 0.95 m 1.26 m

方向与预期一致:跟折线时 max_dev 更小(航点密,切角少),路径略长、耗时略久。

但这组对照低估了平滑的收益,原因在测量手段:mock_uav_mover.py 只有运动学 (匀速直线、无加减速、无转向耗时),所以"48 次转向"和"11 次转向"对它几乎没有区别 —— 耗时只差 0.35 s。真实载具有加速度与姿态响应,转向次数直接决定耗时与轨迹抖动, 差距会明显大于这里的读数。这组数据能说明"max_dev 与航点密度的关系", 不能用来量化"平滑在真实飞行中省多少时间"。

7.3 闭环飞行动图

下面这段录自 §7.2 里"样条被复检拒绝、11 航点"的那次运行(不是 73 点那组), 属于离线场景(mock 点云 + 运动学假载具),不是 AirSim 仿真:

闭环飞行:绿线为规划路径,青色为平滑路径,黄色为实际飞过的轨迹

能连续看到:地图建满 → 绿色/青色路径出现 → 黄色轨迹边飞边长 → 到达终点。

黄线与青线的间距就是该次运行的 max_dev(1.26 m)的直观形态: 前瞻跳点斜穿拐角,轨迹因此偏在路径内侧。

AirSim 仿真与离线 mock 场景的差别在于动力学:假载具没有加减速与转向耗时, 所以上面这段只能说明跟踪逻辑跑通,不能说明真实飞行品质。带动力学的仿真见 §7.4。

7.4 AirSim 上的闭环飞行

2026-10-27 在 AirSim(OpenHUTB 发行版,Town10HD 场景)上跑通完整闭环: 桥接 → 建图 → A* → 安全裕度与平滑 → 跟踪 → 飞行。飞机从 (-31.65, -8.55, 7.95) 飞到 (-45.15, 1.35, 7.65),两点直线 16.74 m。

AirSim 上的闭环飞行:绿色为规划的原始路径,青色为平滑路径,黄色为实际飞过的轨迹

这份动图的视角偏远(录制时 RViz 的 Distance 约 120 m),路径只占画面中部的一小块, 点云与展开节点也在同一屏里,读图时需要先找到那条折线。录制时把 SensorCloud 与 AStarExplored 关掉、Distance 收到 40~60 m、路径线宽加到 0.08 m,绕行会更清楚。 这一版保留的是首次录制的原始视角,没有为了好看而重录。

指标 值
规划 expanded 14374、discovered 33220、326.9 ms
原始路径 63 点、27.91 m、kappa_max 2.321 1/m、最小间隙 0.75 m
平滑 63 → 26 点、26.87 m;样条被碰撞复检拒绝,退回捷径折线;最小间隙提到 1.65 m
绕行比 27.91 / 16.74 = 1.67(按平滑后 26.87 m 算为 1.60)
飞行 12.30 s、flown 25.82 m、mean_speed 2.10 m/s、max_dev 1.12 m
终点判定 final_timed_out = 0,即真正进了 finish_tolerance,不是超时收尾

三点需要如实说明:

  1. 12.30 s 短于 §8 第 5 条建议的 15~25 s。 这次两点直线只有 16.74 m,行程长度是靠 绕行(1.67 倍)撑起来的。要更长就得有更大的已扫描范围,而"未知 = 障碍" 把可规划区域限制在雷达量程以内。
  2. 样条又一次被复检拒绝,与 §7.2 里第二种形态同因:仿真里的地图比 mock 场景更稀疏, 平滑曲线更容易擦到障碍。退回折线是复检在起作用的证据,不是平滑失败。
  3. 细枝、细杆进不了八叉树(0.3 m 分辨率 + 稀疏采样),规划器会把它们当成可通行。 这一轮实测里载具撞到过树枝,靠物理引擎自行脱困,但轨迹会短暂偏离。 这是感知分辨率与碰撞模型之间的固有缺口,见 §8 第 6 条。

8. 实现中要注意的几点

# 事项 说明
1 不要在两个节点间共用 /uav/goal 见 §2:会导致"每发一个航点就重新规划"
2 坐标只需要 ROS 世界系 ENU 世界系 z 偏移与 NED↔ENU 转换都在桥接节点内部完成,跟踪节点不要重复计算
3 目标点必须落在已扫描区域 可通行判定把"未知"当障碍,目标点在未扫描区时 A* 直接失败。所以流程是"先扫描、再下发目标"
4 航点被吸附到八叉树叶中心 终点不会精确等于下发值,判定要用容差
5 动图的拍摄要求 飞低、飞远、斜视视角:高度取 2~4 m,让载具处在障碍之间而非其上;路径跨越房间对角;单次飞行 15~25 s。在有树冠的场景里高度要避开树冠带:本项目实测 z 取 7~8 m 会撞树,贴着街道空域飞反而更安全
6 细障碍看不见,碰撞仍会发生 0.3 m 分辨率下,直径小于一个体素的枝条、杆件常常不落进任何体素,激光采样也未必打中;规划器据此判为可通行,而 AirSim 的碰撞由物理引擎判定。实测撞到树枝后载具能自行脱困,但会短暂偏离路径。要缩小这个缺口得提高分辨率或降低飞行速度,两者都有代价

9. 进一步工作

# 项 说明
1 飞行中重规划 需要"路径变化超过阈值才换"的一致性判据,否则载具会跟着地图抖动
2 速度/加速度约束的轨迹跟踪 换用 moveByVelocityAsync 并引入动力学约束,替代"逐点位置控制 + 前瞻"
3 局部避障 全局规划 + 地图更新已能覆盖演示场景;更紧的空间需要局部规划器
4 到达后的自动悬停与降落 当前只在终点停止下发航点,降落仍由操作者发起

10. 参考

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

rosrun rqt_graph rqt_graph        # 节点—话题图
rostopic info /uav/goal           # 确认真实订阅者只有规划器
rostopic info /uav/goal_cmd       # 确认真实订阅者只有桥接节点
rosrun rqt_tf_tree rqt_tf_tree    # TF 树