ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Airsim无人机无碰撞巡航源码解析:感知规划控制全流程

Airsim无人机无碰撞巡航源码解析:感知规划控制全流程 我最早接触 Airsim 仿真那会儿还在为一台不知道什么时候会撞上树枝的无人机调参。你要真架着机子去试飞电池没两块经得起撞桨叶更经不起折腾。后来我把整套避障逻辑整个搬进 Airsim 里跑才算是把“无碰撞巡航”这个目标真正落地了。Airsim 跑起来不挑显卡、有现成 API、环境随便换源码改起来也不用担心把真机摔了。这一篇我就把整套自动 UAV 巡航无碰撞源码的思路、模块拆解、部署流程和实际踩过的坑一次讲清楚想用 Airsim 做自主避障航迹的人直接照着我这份复盘来就行。1. 整体思路与方案拆解1.1 为什么是 Airsim 而不是其他仿真器先回答一个最基础的问题做无人机避障仿真市面上有 Gazebo、有 Webots、也有 Airsim为什么最终选 Airsim。我之前用 Gazebo 配合 PX4 做过一段时间模型加载慢、传感器噪声要自己调、写一个简单的深度图测试节点都要绕好几个包而且物理引擎和渲染引擎是拆开的视觉传感器的仿真效果和真实世界差距明显。Airsim 直接建在虚幻引擎上把渲染和物理绑定在一起深度相机、激光雷达的输出更接近真实传感器的行为尤其在做视觉避障的时候深度图的连续性、遮挡关系、光照影响都能真实模拟出来。我的经验是Airsim 适合做“感知 决策 控制”闭环的验证Gazebo 更适合做底层飞控和硬件在环的验证。如果你主要目标是验证避障算法本身Airsim 能省下大量前期配置时间。另外还有一点Airsim 的 Python API 设计得相当友好。很多仿真器提供的接口要么太底层要么需要自己拼装消息。Airsim 里一个client.getDepthImage()就能拿到深度图client.moveToPositionAsync()就能下达位置指令代码写得再糙也能很快把主循环搭起来。对于我个人这种“先把效果跑出来再慢慢优化细节”的调试风格来说这种体验非常关键。1.2 无碰撞巡航系统的层次划分无碰撞巡航听起来好像只是一句“不要撞到东西”但要落到工程实现上得拆成三个层次。第一层叫感知层负责回答“周围有什么”。这一层需要从传感器拿到原始数据转换成本体坐标系下的障碍物位置。第二层叫规划层负责回答“接下来飞去哪”。这一层根据感知结果决定是继续沿着预设航线走还是调整航向绕过障碍物。第三层叫控制层负责回答“怎么飞过去”。这一层把规划层给出的目标点转换成速度指令或者位置指令发送给 Airsim 里的无人机。这三个层次缺一不可。很多人做避障只做第二层写一个“检测到障碍就绕开”的逻辑结果放到真机上发现传感器数据抖动得根本没法用这就是第一层没做好。我的源码里把感知层的计算放在独立线程里保证深度图的获取和转换不会拖慢决策主循环。第三个层次则要关注控制指令的平滑性避免无人机在绕障时出现剧烈抖动甚至翻转。1.3 避障算法选型人工势场法与动态窗口法避障算法我实测过两种人工势场法和动态窗口法。人工势场法的核心思想是给目标点一个吸引力、给障碍物一个排斥力无人机在合力场中运动逻辑简单、参数一眼能看明白。动态窗口法则是从当前的线速度和角速度出发采样一组可行的速度组合用代价函数筛选出最安全、最接近目标的一组更接近实际控制终端的行为模式。两条路线各有自己的适用范围。人工势场法的问题在于容易陷入局部极小值——两边各有一个障碍物无人机会在中间反复震荡出不去。动态窗口法计算量稍大但效果更稳定因为它的目标是连续的速度空间搜索不容易出现死锁。我在源码里默认使用人工势场法同时保留了一个可切换的动态窗口法开关。想快速跑通看效果的先别动脑子直接用默认参数跑跑通后研究避障策略想做到稳定绕障的切到动态窗口法。1.4 地图与坐标系脑子里要有全局和局部两张图做主程序之前坐标系的问题必须先搞清楚不然代码跑起来全是玄学Bug。Airsim 里无人机的位姿用的是 NED 坐标系North-East-Down即 X 轴朝北、Y 轴朝东、Z 轴朝下而虚幻引擎的坐标系是 Z 轴朝上。我第一次写的时候没做转换直接把getPose()的返回值拿去做障碍物坐标推算结果无人机一个劲往地下钻。Airsim API 里提供位置和姿态的分别是世界坐标系和机体坐标系下的数据深度图像素坐标转换为相机坐标系下的三维点时必须乘上相机的内参矩阵再把相机坐标转到机体坐标最后转到世界坐标。全局地图我建得比较简单就是一张二维占据网格把无人机当前高度平面附近的障碍物按 0.1 米分辨率投射进去。算路的时候不是直接用原始深度图而是查这个网格图。这样做的优势是传感信息做了时间和空间上的缓存就算某帧深度图有抖动也不会立刻引发规划层的错误决策。局部地图则直接用当前帧的深度数据用于短期避障决策。2. 源码核心模块解析2.1 感知模块深度图与世界坐标转换感知是整个闭环的源头也是最容易写错的地方。源码里感知模块的函数比较关键def depth_to_world(self, depth_img, pose, fov90.0): h, w depth_img.shape fx w / (2.0 * np.tan(np.radians(fov / 2.0))) fy fx cx w / 2.0 cy h / 2.0 # 将像素坐标转换为相机坐标 v, u np.where(depth_img 0) z depth_img[v, u] x (u - cx) * z / fx y (v - cy) * z / fy cam_pts np.stack([x, y, -z], axis1) # 相机前向为Z向下为-Y这里做转换 # 获取相机到机体的旋转矩阵通常为常量 R_cam_to_body np.array([[0, 0, 1], [1, 0, 0], [0, 1, 0]], dtypenp.float64) body_pts (R_cam_to_body cam_pts.T).T # 获取机体到世界的旋转与平移 q pose.orientation R_body_to_world quat_to_rotation_matrix(q.w, q.x, q.y, q.z) t np.array([pose.position.x_val, pose.position.y_val, pose.position.z_val]) world_pts (R_body_to_world body_pts.T).T t return world_pts这套转换看着复杂其实原理就是三个坐标系的连续变换像素坐标系 - 相机坐标系 - 机体坐标系 - 世界坐标系。这里有个小细节Airsim 深度图的单位是米30 米内的深度值还算准超出这个范围会噪点明显。我把超过 25 米的深度直接置为 0让感知模块只关心近处障碍物这样既能减少计算量也避免了远距离传感器噪声干扰规划。2.2 碰撞检测逻辑与安全距离判定碰撞检测不是用一个简单的“障碍物是否有像素”来判断的那样阈值太低无人机在树丛里根本寸步难行。我的判断逻辑分成两层。第一层是几何碰撞层基于无人机机体半径加上安全冗余比如 0.5 米判断障碍物是否进入这个膨胀区域。第二层是路径前瞻层从当前速度方向往前推 1.5 秒计算这 1.5 秒里会不会撞上障碍物。如果几何层未触发但路径前瞻层触发了说明障碍物在前面但不是贴脸状态这时候不需要急停只需要平滑减速并转向即可。源码里用一个二进制向量表示视野扇区self.sector_threat np.zeros(8) # 8个扇区 for pt in world_pts: dx pt[0] - pose.position.x_val dy pt[1] - pose.position.y_val dist np.hypot(dx, dy) if dist 2.0 and abs(pt[2] - pose.position.z_val) 1.2: angle math.degrees(math.atan2(dy, dx)) sector int((angle 180) / 45) self.sector_threat[sector] 1这个扇区的划分逻辑要配合航向角使用。因为我做的是水平巡航高度方向的变化不大所以 Z 轴只做了阈值判断没有做细格划分。如果要做三维避障可以把扇区改成球面格网那又是另一个复杂度的工程了。2.3 路径规划与避障策略实现避障策略我分成三种模式正常巡航、优先转向、就地徘徊。正常巡航模式下无人机没有检测到任何威胁它匀速沿着预设航点飞行。这时候控制模块给一个前向速度指令和期望偏航角。优先转向模式下检测到前向扇区或左/右某个扇区有障碍物无人机不会停而是以当前位置为中心把期望航向向威胁最小的方向偏转 45 度同时将前向速度降到原来的 40%。原文里有人工势场法的说法实际做的时候我把它进一步简化成“扇区威胁消除”策略因为在二维巡航场景里人工势场法多余且参数敏感。def potential_field_guidance(self, target_wp, pose, threat_sectors): k_att 0.8 k_rep 1.2 d_rep 2.5 # 吸引力方向 dx target_wp[0] - pose.position.x_val dy target_wp[1] - pose.position.y_val att_vec np.array([k_att * dx, k_att * dy]) # 排斥力方向对每个威胁扇区取反向 rep_vec np.zeros(2) for i in range(8): if threat_sectors[i] 0: angle_rad np.radians(i * 45) dir_rep np.array([-np.cos(angle_rad), -np.sin(angle_rad)]) rep_vec k_rep * dir_rep / (d_rep ** 2) force att_vec rep_vec return np.degrees(np.arctan2(force[1], force[0]))这套逻辑的精髓是吸引力和排斥力的权重其实不是固定常数而是随着距离动态调整。离目标近时吸引力降为 0避免在终点附近来回抖动离障碍近时排斥力增强牺牲路径平滑性换取安全性。2.4 控制指令解耦与发送规划的最终输出是一个期望航向角和一个速度标量。很多新手在这里犯错误直接把期望航向角传给 Airsim 的moveToPositionAsync结果无人机会先转向再直线飞过去转向过程中如果遇到新障碍物逻辑就乱了。我的做法是把航向角拆解为偏航角指令和机体坐标系下的速度指令让控制层独立处理def send_velocity_command(self, desired_yaw_deg, speed_ms, duration0.2): vx speed_ms * np.cos(np.radians(desired_yaw_deg)) vy speed_ms * np.sin(np.radians(desired_yaw_deg)) yaw_cmd desired_yaw_deg self.client.moveByVelocityAsync(vx, vy, 0, duration, drivetrainDrivetrainType.ForwardOnly, yaw_modeYawMode(False, yaw_cmd))这个ForwardOnly模式很关键意思是无人机机头始终朝向飞行方向所以偏航角和速度方向是被绑定在一起的。如果用MaxDegree模式限制转向速度物理效果更接近真实四旋翼但代码调试的时候我建议先关掉限制跑通流程再打开物理限制。drivetrain参数控制的就是底盘车或旋翼无人机行为Airsim 里无人机类型多用 ForwardOnly 来模拟四旋翼偏航跟随。2.5 主循环与状态机代码能跑通的关键整个程序的主循环是一个 while 循环加一个状态机。状态机的状态有TAKEOFF、CRUISE、AVOID、LANDING四个状态。起飞机之后切到 CRUISE感知模块检测到威胁就切到 AVOIDAVOID 状态里如果连续 3 帧检测不到威胁说明绕过了障碍物切回 CRUISE 并重新计算到下一个航点的路径。加状态机的好处是逻辑分支清晰不会出现“绕过障碍后又想回头撞上去”的诡异行为。while True: pose client.getMultirotorState().kinematics_estimated depth client.getDepthImage() world_pts sensor.depth_to_world(depth, pose) if state State.CRUISE: threat detector.check_sectors(world_pts) if threat.any(): state State.AVOID else: yaw planner.guidance(current_wp, pose, threat) ctrl.send_velocity_command(yaw, config.cruise_speed) elif state State.AVOID: # 只在威胁存在时执行避障指令否则回到巡航 threat detector.check_sectors(world_pts) if not threat.any() and avoidance_counter 3: state State.CRUISE avoidance_counter 0 else: yaw planner.avoidance_yaw(threat) ctrl.send_velocity_command(yaw, config.avoid_speed) avoidance_counter 1 if distance_to_current_wp config.wp_reach_threshold: next_wp()这个主循环的问题在于 Python 的 GIL 和多线程调度不确定性如果感知模块计算量太大主循环的帧率会下降。我的解决办法是感知模块的计算不放在主线程里而是用一个异步任务定时 0.1 秒刷新一次全局的威胁扇区向量主循环只做决策和控制。实测下来主循环能稳定在 10Hz 以上避障响应延迟不超过 150ms这个数据在仿真里已经算可以的了。3. 实操部署与运行配置3.1 环境准备Airsim 版本与 Python 封装跑这套源码需要准备的环境是 Airsim 1.8.0 以上版本我本地用的是 1.8.0 的 Windows 版加虚幻引擎 4.27 项目。有的人喜欢用 Linux 配 Airsim Unreal Engine 模拟环境也行但驱动配置麻烦一点。先把 Airsim 仓库克隆下来编译或直接下载 release 包然后用 Unreal Engine 打开一个空工程生成场景。如果你不想手搓场景Airsim 源码仓库自带的Blocks环境足够测试避障了里面有大楼模型、地面和若干障碍物。Python 环境建议装airsim包官方把 Python API 和 ROS 接口分开发布直接在 PyPI 里安装最简单pip install airsim要注意版本匹配问题我用的一些 API如moveByVelocityAsync的yaw_mode参数在老版本里不存在。热词搜索结果里也有提到 Airsim 版本兼容性的坑我的建议是如果代码突然报参数错误先查一下当前 Airsim 版本和 Python API 版本是否一致这比四处搜资料快得多。3.2 编写并运行主程序逐步跑通流程代码的结构我用模块化来写方便替换和调试。目录结构大概是这样的uav_cruise/ ├── config.py # 全局配置参数 ├── sensor.py # 深度图获取与世界坐标转换 ├── detector.py # 威胁检测与扇区划分 ├── planner.py # 路径规划与避障引导 ├── controller.py # 速度指令发送 └── main.py # 主循环与状态机先跑一个最小化的流程只做起飞、上升、悬停确认 Airsim 环境和 Python API 接通。然后单独跑深度图采集把深度图保存成本地图片人工检查一下深度值是否符合预期。最后再接上避障逻辑整体联调。这个由简到繁的过程对新手来说是最稳的能明显降低调试难度。我个人的习惯是每加一个模块先打印这个模块的输入输出中间变量比如转换后的世界坐标点云数量、威胁扇区向量、期望航向角看看是不是和自己预判的一致。如果你直接完整跑整套源码一旦出问题跟踪起来会像大海捞针。3.3 关键参数调整经验值表源码里的参数需要根据你的仿真场景微调我给一个参考范围表参数名含义参考范围备注cruse_speed巡航速度3–8 m/s场景空旷可调大狭窄环境调小avoid_speed避障速度1–3 m/s太大容易进入“抖动绕行”状态safety_radius安全半径0.5–1.5 m越小越贴近障碍物越大越保守detect_range检测距离8–25 m越大提前量越大但噪点也越多lookahead_time路径前瞻时间1.0–2.5 s速度越快这个值要越大wp_reach_threshold航点到达阈值1.0–2.0 m太小会导致航点无法触发参数整定上有个顺序先固定巡航速度再调安全半径最后再调前瞻时间。不要齐头并进地调否则你根本分不清是哪个参数导致的避障失败。3.4 性能调优需要关注的几个瓶颈Airsim 跑仿真吃的是显卡渲染能力。我最初用集显跑深度图延迟明显避障反应特别迟钝跟 FPS 太低直接相关。后来换了独显把 Unreal Engine 的画面质量从“高”调成“中”关闭阴影和动态光影Airsim 后端能跑到 60 FPS整个避障就顺滑多了。如果你不需要渲染细节甚至可以把天空盒、植被这些浪费性能的元素直接去掉。Python 端的性能问题主要出在两点一是深度图转世界坐标的向量化处理不够二是频繁调用 API 导致通信开销。前者我建议尽量用numpy的矩阵运算不要写 Python 循环去遍历像素一个 320x240 的深度图如果用 for 循环遍历一帧要 200 毫秒主循环直接被卡死。后者可以适当降低指令发送频率从 10Hz 减到 5Hz配合前瞻逻辑实际避障效果并不差太多。4. 常见问题与排查技巧实录4.1 深度图全黑或者全是噪声深度图全黑十有八九是相机没对准目标区域或者碰撞通道没设置。Airsim 的深度相机默认 Render 类型要选 DepthPlanar如果你在 Settings.json 里没配置相机类型拿到的深度图可能全为 0。还有种情况是渲染材质问题有些纯色物体反射率太低深度值不稳定出噪声。我的解决方法是先固定无人机让它原地悬停转一圈实时打印深度图的 min 和 max 值看看哪些方向有有效读数。如果某个方向长期全 0说明那个方向没有可用深度数据可能是相机视野被禁用或遮挡了。4.2 无人机绕障时震荡甚至原地打转这个问题十有八九是参数不匹配安全半径太小或者避障速度太高导致感知和规划频繁切换状态。我实测过一次把avoid_speed从 3 m/s 降到 1.5 m/s 后震荡明显缓解。震荡还有一个常见来源是扇区划分的航向变化太快规避方向每一帧都在跳。我的做法是给航向指令加了一个低通滤波让期望航向平滑变化smoothed_yaw previous_yaw * 0.3 desired_yaw * 0.7这种方式牺牲一点点反应速度但换来了稳定的飞行姿态尤其在狭窄通道里效果非常明显。4.3 坐标转换后障碍物位置漂移严重坐标漂移基本都出在姿态转换上。quat_to_rotation_matrix的实现如果顺序错了旋转矩阵就是错的而且这个错误很难直接看出来。我的一个强烈建议是先在仿真里放置一个已知坐标的方块用无人机飞到它附近暂停打印转换后的障碍物世界坐标观察是否和 Airsim 编辑器里显示的物体坐标一致。如果就偏几厘米那是深度图分辨率问题如果偏几米那就是旋转矩阵错了。排查过程要冷静别猜用数据验证。4.4 避障逻辑一直触发或者一直不触发一直触发说明安全半径相对于地图尺寸太大导致稍有障碍物就进入绕行状态。一直不触发则相反通常是detect_range设得太小或者深度图过滤条件把有效点全滤掉了。我的源码里有个阈值设置——只把距离小于detect_range的障碍物算入威胁如果场景中障碍物正好在边界附近就会造成“时好时坏”的假象。建议日志里打印每个扇区的障碍物数量确认检测是不是稳定。4.5 避障完成后无法继续沿原航线到达目标点这个和规划层的航点推进逻辑相关。常见场景是无人机为了绕障已经偏离原航线 5 米以上绕障结束后回到巡航状态但航点判定阈值还是 1 米所以它永远也到不了下一个航点。四个解决办法一是航点到达阈值放宽到 2–3 米二是在绕障结束后把“当前航点”重新投影到全局航线上最近的点三是给航点队列增加重规划逻辑如果绕障偏移过大就从当前位置重新搜索到下一航点的路径四是手动插入一个“回到航线”的虚拟航点让无人机先返回原航线再飞向下一目标。我源码里默认用的是第二种方案效果好、改动少。4.6 多线程与主循环的实时性感知模块在新线程里运行主循环做控制和决策合理的设计。但 Python 多线程有 GIL 限制如果传感器计算里有大循环GIL 还是会拖慢主循环。我后来改用多进程模式把感知模块单独放在一个进程里通过队列把威胁扇区传给主进程效果立竿见影。如果你只是想先跑通测试进程通信的复杂度可能不划算但如果你的框架是响应式避障加视觉感知多进程这是必经之路。5. 个人体会与调试心得整套源码改到现在我最深的体会是仿真的价值不在“酷”而在让你把认知盲区全部暴露出来。真机调试的时候时间、空间、安全成本都不允许你反复试错很多问题是靠着运气才没出事的。Airsim 里则可以放心大胆地让无人机撞树看它会怎么反应观察哪个参数影响最大然后再回退到正确的轨道上。调试这个避障系统的顺序我建议一定按“先静止后运动、先低速后高速、先简单场景后复杂场景”来。先在只有一个方块障碍物的场景里跑通避障再加第二个、第三个障碍物逐渐增加地图复杂度。直接在一个迷宫式的环境里跑出了问题你都不知道是规划算法的问题还是感知的问题。后面我准备继续把这个工程往两个方向扩展第一是给深度图叠加更多的噪声模型模拟真实室内光照变化对深度感知的影响第二是引入简单的强化学习胜任意让无人机在长距离巡航里学会更聪明的绕障策略而不是每次遇到障碍都固定偏航 45 度。等第二版代码验证得差不多了我再来继续更新这个系列。最后分享一个小技巧调试时先把避障的主要参数写死在配置文件的顶部不要散落在各个函数里。我早期把安全半径拆成了四个不同阈值分散在三个文件里改一次要翻遍所有代码后来统一收拢到一个 config 文件里调试效率高了一倍。这个习惯现在保留下来了对后续扩展和维护特别有用。
返回列表