☰
无人机飞行走廊与B样条轨迹优化:原理、实践与避坑指南
2026/10/22 14:59:00 网站建设 项目流程

简介:这是一份面向无人机自主导航方向的飞行走廊规划源码包,基于B样条曲线完成轨迹连接与优化,适合研究路径平滑、避障策略及实时控制的开发者与学习者。压缩包共133个文件,约9.7MB,以hpp与cpp源码为主,辅以头文件、配置文件、launch及rviz可视化文件,另有少量so动态库、msg/srv接口定义和readme文档,整体目录结构清晰,便于按模块阅读和二次开发。当前已有213人浏览学习,属于中等热度的小型算法参考项目。内容涵盖B样条控制点选取与曲线生成、碰撞检测地图构建、轨迹跟踪与状态估计接口,并配有模拟环境配置,可帮助使用者快速搭建走廊规划实验,理解参数调整对飞行路径平滑性及避障效果的影响,适合需要快速上手工程实现的无人机算法爱好者参考借鉴。

1. 飞行走廊 + B样条:为什么这条技术路线值得投时间

无人机在树林、城市峡谷或厂房内部做自主飞行时,最怕的不是"飞不过去",而是"算了半天发现路径穿过了一面看不见的墙"。飞行走廊规划的思路是:先把三维空间里的可通行区域切成一串凸多面体,让无人机只在这些区域里活动,再把轨迹的每一段用B样条曲线连接起来,最后用非线性优化把曲线修到"平滑、可飞、不碰墙"。这套"走廊约束 + B样条参数化 + 轨迹优化"的组合,是当前做无人机航迹规划最实用的工程方案之一,尤其适合需要实时重规划、对轨迹平滑性有硬性要求的场景。本文会把走廊怎么生成、B样条怎么初始化、优化目标怎么设、参数怎么调、坑在哪讲透,直接落到能复现的程度。

2. 飞行走廊生成:从占据栅格到凸多面体的关键一步

2.1 走廊的本质:为什么必须把自由空间"凸化"

飞行走廊规划的第一步不是画轨迹,而是把环境里"能飞的空间"抽出来。在大多数实际工程里,环境地图来自激光雷达或深度相机生成的占据栅格(Occupancy Grid),它的表达形式是"某个格子有障碍物"或"某个格子是空闲的"。但占据栅格有一个很麻烦的问题:它是一堆离散的布尔值,没法直接用来做几何约束。轨迹优化时需要判断"轨迹上这个点是否在允许区域内",如果每次都查栅格,计算量大且不连续,优化器很容易在离散边界上来回震荡。

所以要把自由空间"凸化"。所谓凸化,就是把一大片自由空间拆成若干个凸多面体(Convex Polyhedron),每个多面体内部任意两点的连线都在该区域内。这样轨迹上的任意一点,只要落在某个凸多面体内,就可以用一组线性不等式来描述约束:Ax <= b。这组不等式在优化里是连续可导的约束条件,直接交给求解器处理即可。走廊就是由这些凸多面体首尾相连组成的通道——轨迹不会飞出走廊,也就不会撞上障碍物。

2.2 主路径提取与走廊扩张:常见做法与选型

走廊生成的典型流程分三步。第一步,用A*、JPS或者RRT*在占据栅格上找一条从起点到终点的粗路径,这条路径可以贴着障碍物走,甚至路径上的某些点离障碍物很近,没关系,它只用来指示方向。第二步,沿着粗路径上的每个采样点,朝周围做"膨胀"——不断向外扩张,直到碰到占据栅格里的障碍物。第三步,把膨胀出来的空间用凸多面体近似表达,相邻多面体之间要有重叠区域,保证轨迹连接点能平滑过渡。

工程里最常见的做法是用"立方体膨胀 + 凹坑填充":先沿着路径点初始化一串立方体,然后迭代地拉伸、扩张每个立方体的边长,直到逼近障碍物边界。具体实现时要注意:走廊的宽度不能只取决于地图分辨率,还要考虑无人机尺寸和动力学约束。比如一架轴距 450mm 的穿越机,走廊半径至少要留 0.8 米以上,否则最后优化出的轨迹虽然几何上不碰墙,但实际飞行时桨尖气流会扫到树枝或墙壁。

2.3 走廊生成的可执行流程:一个最小实现

下面给一个走廊生成的伪代码实现思路,基于占据栅格地图,输出一串凸多面体列表。这个实现不依赖特定库,核心逻辑就是"沿主路径扩张并记录边界"。

import numpy as np from dataclasses import dataclass @dataclass class ConvexCorridor: # 每个走廊段用一个中心点和半边长来近似凸多面体 center: np.ndarray # (3, ) 中心坐标 half_size: np.ndarray # (3, ) 各轴半径 # 实际上后续优化会转成 Ax <= b def to_inequality(self): A = np.vstack([np.eye(3), -np.eye(3)]) b = np.concatenate([self.half_size, self.half_size]) return A, b + self.center def generate_corridor(path_points, grid_map, min_radius=0.8): """ path_points: (N, 3) 粗路径点 grid_map: 占据栅格,1表示占据,0表示空闲 min_radius: 走廊最小半径(米) """ corridors = [] for p in path_points: half = np.array([min_radius, min_radius, min_radius]) # 逐步扩张半边长,直到碰到障碍物或达到上限 while True: # 采样当前立方体边界上的点,查询栅格 corners = sample_cube_boundary(p, half) occupied = [grid_map.is_occupied(c) for c in corners] if any(occupied): break # 碰到障碍物,停止扩张 half += np.array([0.1, 0.1, 0.1]) # 步长10cm if np.max(half) > 4.0: # 防止无限扩张 break corridors.append(ConvexCorridor(p, half)) return corridors

这段代码的逻辑是:沿着粗路径点逐个扩张正方体,直到边界采样点碰到占据栅格或达到扩张上限。half_size逐渐增大,走廊就膨胀开了。实际工程里有个细节需要注意,这段代码里sample_cube_boundary的采样密度直接影响走廊质量——采样太稀会漏掉障碍物,导致走廊内表面包含障碍。我一般会在每个面上采样 9 个点(3x3 均匀分布),同时在棱边上额外采样 4 个点来兜底。另外,min_radius一定要显式设置,不要依赖栅格分辨率。

走廊生成完之后,最好把邻近且方向一致的走廊段合并。因为如果每一米就生成一个凸多面体,整条走廊可能有几十个段,每个段在优化时都提供一组约束,会显著增加求解规模。常见的合并策略是:如果相邻两个走廊段的重叠体积占比超过 80%,或者中心点连线方向与两段轴向夹角小于 15 度,就把它们合并成一个更大的凸多面体。这一条在长距离巡检任务里特别管用,能把走廊段数从 50 压缩到 10 个以内。

3. 用均匀B样条把走廊串起来:控制点设计与轨迹参数化

3.1 为什么选B样条而不是多项式或minimum snap

轨迹参数化有几种路线:多项式(比如 minimum snap/jerk)、贝塞尔曲线、B样条。多项式轨迹在无约束环境下很棒,闭式解求起来快,但一旦要加"穿过走廊"这种几何约束,每个多项式段的系数都要跟约束做耦合,非常繁琐。贝塞尔曲线的凸包性质好,但全局支撑性差,拼接点处的高阶连续性需要额外处理。B样条恰好是兼顾两者的方案:它有局部支撑性(移动一个控制点只影响附近几段轨迹),又有天然的连续性(k 次 B 样条自动有 k-1 阶连续),还满足凸包性质(曲线一定落在控制点形成的凸包内)。

"轨迹落在控制点凸包内"这一条,对走廊规划来说简直是量身定做的:如果把控制点全部放进走廊的凸多面体里,那么整条B样条曲线都在走廊内——因为曲线在控制点凸包内,控制点在走廊内,凸多面体的凸包性质保证曲线也在走廊内。这就在数学上保证了"约束控制点 = 约束整条轨迹",优化时只需要约束控制点,不需要在曲线上密集采样验证,计算量直接降了一个数量级。

3.2 控制点放进走廊的初始化方法

拿到走廊之后,下一步是在走廊内初始化一串B样条控制点。最直接的做法是:沿走廊中心线按固定间距采样控制点,然后把每个控制点向内收缩到走廊的安全边界内(比如收缩到走廊中心到边界距离的 80% 处)。这样初始轨迹一定在走廊内,且保留了足够的修正空间给优化器。

这里要讲清楚B样条的两个核心参数:次数 p 和节点向量。工程里我一般用三次 B 样条(p=3),因为三次已经保证了加速度连续(C2 连续),更高次数的收益很小但计算量明显上升。节点向量如果做均匀分布,则轨迹的时间分配就是均匀的,这会导致一个常见问题:走廊中某些地方转弯很急,需要更长的时间来过弯,均匀时间分配可能让速度规划出来的轨迹在过弯处速度过高。所以更讲究一点的做法是后面优化环节里把节点向量也作为变量来处理。初始阶段直接用均匀节点向量即可。

初始化代码可以这样写:

def init_control_points(corridors, spacing=1.2, p=3): """ corridors: ConvexCorridor 列表 spacing: 控制点间距(米) p: B样条阶数 返回: 控制点数组 (M, 3) """ centers = np.array([c.center for c in corridors]) # (N, 3) # 沿中心线按固定弧长插值出初始控制点 dist = np.linalg.norm(np.diff(centers, axis=0), axis=1) # 相邻走廊中心距离 cum_dist = np.concatenate([[0], np.cumsum(dist)]) total_len = cum_dist[-1] num_points = int(np.ceil(total_len / spacing)) + 1 # 插值出 num_points 个均匀分布的点 interp_pts = np.interp( np.linspace(0, total_len, num_points), cum_dist, centers[:, 0] ) # 这里只展示x轴处理,实际要逐轴处理 # 逐轴插值 pts = np.zeros((num_points, 3)) for axis in range(3): pts[:, axis] = np.interp( np.linspace(0, total_len, num_points), cum_dist, centers[:, axis] ) return pts

原因在于插值后的控制点可能落在走廊边界附近,或者落在多面体边缘,给优化器留的余量不足。我一般会再做一步收缩处理:以每段走廊中心为中心,把控制点沿中心方向收缩 10%-20% 的走廊半径。这个操作很便宜,但能明显减少优化迭代次数——优化器不需要花大量迭代先"把点拉回走廊内",可以直接去优化平滑性和时间。

3.3 轨迹碰撞检测与修正初始化:一个代码示例

初始化之后必须检查轨迹是否真的落在走廊内。虽然理论上控制点在走廊内、曲线就在走廊内,但这是有前提的:B样条曲线的凸包是"控制点全体"的凸包,而走廊可能是分段的多个凸多面体,控制点分布在多个走廊段内。曲线在任意一段上只受局部几个控制点影响,所以真正严格的做法是逐段验证。下面是一个快速检测与修正的方法。

def check_and_fix_trajectory(control_points, corridors, p=3): """逐点采样B样条曲线,检查是否飞出走廊,超出则把控制点向走廊中心拉""" # 假设已有B样条求值函数 bspline_eval(control_points, p, u) # 在归一化参数域 [0,1] 内采样 100 个点验证 u_samples = np.linspace(0, 0, 100) # 实际应使用 knot vector 归一化 # 下面是关键检查逻辑 for i in range(100): u = i / 99.0 pt = bspline_eval(control_points, p, u) # 找到距离最近的走廊段中心 centers = np.array([c.center for c in corridors]) dist = np.linalg.norm(centers - pt, axis=1) nearest_id = np.argmin(dist) # 检查点是否在最近的走廊段扩张范围内 A, b = corridors[nearest_id].to_inequality() if np.any(A @ pt - b > 0): # 如果飞出走廊,把影响该点的控制点拉回来 # 定位到影响该曲线段的控制点索引 seg_id = int(np.clip(u * (len(control_points) - p), 0, len(control_points) - p - 1)) # 把控制点朝走廊中心收缩 direction = centers[nearest_id] - control_points[seg_id] control_points[seg_id] += 0.3 * direction return control_points

这段代码演示的是一个"检测-拉回"的修正循环。注意在均匀节点向量下,参数 u 与控制点索引的换算关系是 seg_id = u * (M - p),其中 M 是控制点数量。这个是 B 样条工程实现里最容易写错的地方,因为 De Boor 算法里节点区间和控制点并不是一对一映射,而是错位的,需要仔细看定义。另外,拉回系数 0.3 是经验值,拉得太猛会让轨迹在后续迭代里出现抖动。反复修正 3-5 次之后,轨迹基本就能完全收进走廊了。

常用的半自动办法是把走廊和初始轨迹导出出来,直接在三维可视化里叠加地图叠加层查看。这步建议自动化,因为飞行走廊规划的调试过程中,超过一半的"玄学问题"其实是可视化没做出来——你根本不知道轨迹是哪里飞出去的。我自己的习惯是花半天时间把可视化的脚本写好,后续调试效率能提高一倍以上。

4. 轨迹优化:把"能飞"变成"飞得稳、飞得省"

4.1 优化问题的构成:目标函数与硬约束

走廊只是把"可通行空间"框出来了,B样条只是把轨迹表达出来了,但初始轨迹通常是又绕又抖的。优化环节要做的,就是在"不飞出走廊"的前提下,让轨迹尽量平滑、尽量符合无人机动力学。这个优化问题写成一个带约束的非线性优化:

目标函数= 平滑项 + 动力学项 + 时间正则项,其中平滑项常用的是加速度/加加速度(jerk)的平方积分。物理含义很直观:jerk 越小,轨迹越柔顺,电机和机架的负担越小。硬约束是控制点必须落在走廊的凸多面体内。

不过这个"硬约束"在实际工程里一般不会直接写成不等式约束丢给求解器,因为投影到凸多面体上的操作计算量不小。更常见的做法是惩罚法:目标函数里加一项"控制点穿出走廊的深度平方和",用一个大权重把它压住。这种处理方式虽然没有硬约束那么严格,但工程实现简单、求解稳定。用硬约束的求解器(比如用序列二次规划)在约束数量多时经常遇到不可行问题,而惩罚法几乎不会。

4.2 如何把时间分配落进优化里

B样条轨迹的时间分配藏在节点向量里。均匀节点向量意味着所有段用时相同,这往往不是最优的。比如轨迹要从一个狭窄的门洞穿过,门洞附近必须走慢一点,如果节点向量均匀分布,轨迹在门洞附近的时间被平均分配,速度可能还是偏快。所以优化的一个关键变量是节点向量间隔。

具体做法是:把相邻节点之间的时间间隔 dT 作为优化变量,目标函数里加一个"总时间尽量短"的正则项。但 dT 不能太小,否则轨迹退化成"蠕动",也不符合实际。一般会在目标函数里设置时间项权重,让优化器在"更快完成"和"加速度可控"之间做权衡。

工程实现上有一个非常实用的技巧:先跑一次固定时间分配的优化,拿到平滑轨迹后,根据轨迹上每点的速度幅值重新分配时间——速度高的段(说明这段路程短但控制点多)适当增加时间,速度低的段压缩时间,然后重新用新的节点向量再优化一次。这个"两步法"比直接把时间变量丢给求解器要稳定得多,尤其适合新手快速跑通流程。

4.3 工程中最常改的三个参数

第一个是惩罚权重。把飞行走廊约束作为惩罚项加进目标函数时,权重设太小,轨迹会穿出走廊;设太大,求解器会优先满足走廊约束而牺牲平滑性。我的经验是从 100 开始,逐步调大。观察指标是求解完成后统计"控制点穿出走廊的最大距离",如果大于 5cm,就把权重翻倍。第二个是平滑项的阶数。用 jerk 还是 snap,对最终轨迹形态影响很大。多旋翼一般用 jerk 就够了,snap 会让轨迹在起点和终点处看起来"过于笔直",反而失去自然感。如果做的是穿越机花飞,那才需要 snap。第三个是控制点间距。间距太密,轨迹自由度太高、优化容易抖动;间距太疏,轨迹又无法贴合走廊的弯折。经验值是 0.8-1.5 倍无人机轴距。

下面是一段典型的优化目标函数构建代码,使用惩罚法:

def cost_function(control_points, corridors, p=3): """返回总代价: 平滑代价 + 走廊惩罚代价 + 时间正则""" # 平滑代价: 用控制点的三阶差分近似 jerk 积分 # B样条的导数可以用控制点差分表示(均匀节点向量下) diff1 = np.diff(control_points, n=1, axis=0) diff2 = np.diff(diff1, n=1, axis=0) diff3 = np.diff(diff2, n=1, axis=0) jerk_cost = np.sum(diff3 ** 2) # 走廊惩罚: 统计穿出走廊的控制点 pen_cost = 0.0 weight_pen = 100.0 # 惩罚权重,经验值从100起步 for pt in control_points: for corr in corridors: A, b = corr.to_inequality() violation = A @ pt - b pen_cost += np.sum(np.maximum(violation, 0) ** 2) * weight_pen # 时间正则: 控制点间距的总长,鼓励轨迹不要绕远路 length_cost = np.sum(np.linalg.norm(np.diff(control_points, axis=0), axis=1)) weight_length = 1.0 total = jerk_cost + pen_cost + weight_length * length_cost return total

这段代码的要点:diff3的三阶差分在均匀节点向量下恰好在常数尺度上对应 jerk 的离散近似,虽然严格来说需要乘以与节点间距相关的系数,但在实践中直接用它做相对比较完全可行。走廊惩罚用了np.maximum(violation, 0),只有穿出走廊的轴才产生代价,且是平方惩罚——穿出越深代价增长越快,优化器会被自然地"推回"走廊内。长度代价是防止轨迹为了减少 jerk 而绕远路。

参数调整时要注意各代价的量纲匹配。jerk 代价的量级通常非常小(均匀节点向量下,差分值可能在 0.01 级别),所以它会自动被走廊惩罚压制。如果发现优化结果出现明显穿墙,第一件事不是去调权重,而是去检查控制点初始化是不是离走廊边界太近,或者在走廊生成阶段把min_radius调大一点。权重只是兜底,不能替代合理的初始化。

5. 避坑指南:走廊规划与B样条优化的五个常见问题

5.1 走廊生成成功但轨迹仍然穿墙:碰撞检测的采样密度问题

现象:三维可视化里B样条轨迹明显地穿过障碍物,但检查控制点又都在走廊内。原因:走廊段生成时采样点太稀,立方体扩张过程中绕过了细长障碍物(比如一根电线杆或一根斜拉索),导致走廊表面其实包含了一个"视觉上不可见"的凸出障碍。轨迹在凸包性质的保证下确实没飞出走廊,但走廊本身就不是严格自由的。解决:把走廊扩张时的边界采样密度提高,并在走廊生成后做一次障碍物点云到走廊表面的距离检查——如果任何障碍物点到走廊边界的距离小于无人机安全半径,就把该走廊段收缩或切开重生成。

5.2 B样条轨迹在平滑性和动态可行性之间"打架"

现象:优化后的轨迹从几何角度看极其平滑,但无人机实际飞的时候跟踪误差很大,特别是急转弯处总跟不上指令。原因:目标函数只优化了 jerk 或 snap,没有显式约束速度的上限。B样条优化天然喜欢把轨迹变平滑,但这意味着轨迹会尽量走直线、尽量在拐角处"切弯",导致局部速度非常高。解决:在目标函数里加入"速度约束",即在轨迹上采样点处计算速度幅值,超过阈值则加惩罚。更根本的解决办法是重新考虑时间分配——转弯处的节点间隔应该更小,即留给转弯更多的时间。

5.3 优化后的轨迹大幅偏离可见路径,走廊白做了

现象:优化之前粗路径是直的,优化之后轨迹绕了一个大弯,看起来很蠢。原因:走廊生成得过于宽松。走廊虽然限制住了控制点,但控制点在走廊内的活动空间其实很大——如果走廊宽度达到三四米,优化器会为了降低 jerk 而把轨迹甩到走廊的另一侧,甚至在长走廊里做"S 型"游走。解决:适当地收窄走廊,或者在走廊内加一条"中心线吸引"项:目标函数里加一点控制点到走廊中心线的距离平方和,权重不需要大,0.1 左右就够,轨迹就不会飘逸了。这算是血泪经验,我第一次做走廊规划时,看到优化结果绕了远路还以为代码有 bug,最后发现是走廊太宽了。

5.4 优化迭代不收敛或收敛特别慢

现象:求解器迭代几百次后代价函数还在下降,但轨迹形态已经稳定,继续迭代没有实际意义。原因:把一些本应在初始化阶段解决的问题留给了优化器。典型的就是控制点初始化太差,有的控制点在走廊边界上,惩罚项给出的梯度非常大,求解器不得不"先救人再优化"。解决:初始化阶段就把所有控制点收缩到走廊中心周围,保证每个控制点距边界至少 20% 走廊半径。迭代慢还有一个常见原因是惩罚权重太大,导致目标函数的 Hessian 矩阵病态。如果权重已经开到 10000 以上还没法收敛,不如回头检查初始化而不是继续加权重。

5.5 走廊段之间的过渡处轨迹出现折角

现象:两段走廊交叠区域附近,轨迹有一个明显的"折点",平滑性在那里断掉了。原因:两段走廊的凸多面体交叠区域太窄,控制点被压在交叠区里,没有空间做平滑过渡。凸多面体是凸的,但两段走廊之间形成的"走廊颈部"可能很窄。解决:在走廊生成时保证相邻凸多面体的交叠体积足够大,做法是让每个走廊段的扩张范围多延伸 30%-50% 到相邻段中。如果交叠区域仍然窄,就在该处插入额外的控制点,给优化器更多自由度去过渡。

6. 进阶技巧:把走廊信息融进优化做实时局部重规划

前面讲的都是全局规划。实际飞行中,无人机不可能每飞一段都重新做全局走廊——环境在变,计算时间也不够。更常见的架构是:先跑一遍全局走廊规划,得到一条参考轨迹,然后无人机沿参考轨迹飞行时,用一个滑动窗口做局部重规划。窗口内的控制点跟着无人机走,每飞过一个节点就向前补一个新节点,新节点的走廊约束只取当前位置附近的两三个走廊段。

这个架构下,走廊生成不需要每次全部重跑,只需要在探测到新障碍物时,沿着当前轨迹前方 10-20 米的范围重新生成局部走廊段。B样条的局部支撑性在这里派上大用场:修改局部几个控制点,只会影响附近几段轨迹,远处的轨迹完全不受扰动——这正是实时重规划时最想要的性质。如果当初选了多项式轨迹,这一步就得整条轨迹重新解算。

我个人习惯在最终实现里加一个"走廊安全边距"的经验值,默认设为无人机半径的 1.5 倍。走廊生成时按这个边距缩小走廊边界,优化时留出的余量就能吸收控制误差和风扰。代价是走廊变窄、轨迹灵活性下降,但换来的是实飞时的保险系数明显提升。有一次我在茂密的树丛里试飞,传感器噪声导致地图上有几根"幽灵树枝",安全边距恰好把轨迹从这些假障碍旁边推开,实飞没有蹭到任何东西。这个参数一旦确认,就把它写死在配置里,不要再动。

如果你刚开始做这个方向,我的建议是不要一上来就追求复杂的求解器配置,先用惩罚法把最小闭环跑通:占据栅格地图 → 粗路径 → 走廊 → B样条初始化 → 惩罚优化 → 可视化验证。整个链路跑通之后,再逐步加时间分配优化、局部重规划、安全边距这些进阶手段。这套思路让我自己少走了很多弯路,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询