简介:这份源码面向参加无人机智能感知类竞赛、做期末大作业或毕业设计的学生,基于AirSim仿真平台实现复杂环境下的无人机自主飞行。核心任务是在多个移动圆形障碍物中规划路径并抵达终点,涉及霍夫圆检测识别障碍物、卡尔曼滤波与运动模态识别预测障碍物轨迹、PID控制进行轨迹优化,是一套可直接参考的完整赛题方案。资源包共2个文件,包含1个Python主程序与1个Markdown说明文档,压缩包约7KB,体量轻便,便于快速阅读与二次开发。该方案曾获上海交通大学电子信息与电气工程学院主办的“智在飞翔”无人飞行器智能感知技术竞赛三等奖第一名,目前已有350人学习。读者可从中获取感知、预测与控制三个环节的算法实现思路,理解移动障碍物场景下的避障决策流程,并借鉴其代码组织方式与调参经验,适合作为竞赛备赛或课程项目的起步模板。
1. 复杂环境无人机自主飞行:一份能跑通 AirSim 竞赛级任务的 Python 源码
第一次拿到这份源码时,我盯着Hard_Environment_9_9_my.py这个文件名看了半天——"9_9"大概率是场景编号或者调参版本号,这种命名一看就是竞赛现场反复迭代留下的痕迹。它解决的是一个很具体的问题:在 AirSim 仿真环境里,让无人机穿过多个移动的圆圈障碍物,最终抵达终点。注意,障碍物是动的,不是静态桩子,这就把难度从"路径规划"拉到了"动态避障+预测"的层面。
这份资源适合三类人:正在准备大学生竞赛或毕业设计、需要一份有完整算法链路的无人机项目参考的同学;想学 AirSim 但不知道从哪个场景切入的开发者;以及需要一套"感知-预测-控制"闭环代码来改造成自己课题的从业者。它的技术栈很清晰:霍夫圆检测负责"看见"障碍物,卡尔曼滤波加运动模态识别负责"猜"障碍物往哪走,PID 负责"控"无人机怎么飞。据摘要描述,这套方案拿过"智在飞翔"无人飞行器智能感知技术竞赛三等奖第一名,说明它不是玩具代码,是经过赛场验证的。
我拆完这份源码后最大的感受是:它的价值不在于算法有多前沿,而在于工程链路完整且可复现。很多竞赛代码只留一个结果截图,这份至少把感知、预测、控制三段都落到了 Python 文件里。下面我按"先立住原理、再动手复现、最后避坑"的顺序,把这份源码拆开讲。
2. AirSim 环境搭建与源码结构拆解:从 zip 到能跑起来的第一步
2.1 为什么选 AirSim 而不是 Gazebo 或 Webots
无人机仿真平台的选择直接决定你后面调参的痛苦程度。Gazebo 生态老、插件多,但和 PX4 的耦合配置对新手不太友好;Webots 上手快,可复杂环境下的传感器仿真精度和 AirSim 有差距。AirSim 的核心优势是基于 Unreal Engine 的视觉渲染——这对依赖摄像头做霍夫圆检测的方案来说是刚需,因为你需要真实的图像输入,而不是理想化的几何体。
另一个实际原因是 AirSim 的 Python API 封装得比较干净,airsim.MultirotorClient()一行就能建立连接,拿图像、发控制指令都是同步调用,调试时不用在 C++ 和 Python 之间反复横跳。对于期末大作业或竞赛周期只有几周的情况,这个上手速度很关键。
常见做法是:先装 Unreal Engine(建议 4.27 或 5.x,看 AirSim 版本匹配),再从 GitHub 拉 AirSim 插件编译,最后把官方提供的Blocks或Neighborhood场景作为起点。复杂环境场景通常需要自己用 UE 编辑器搭,或者找现成的竞赛场景包。
2.2 源码文件结构与依赖梳理
解压后的目录很简洁,核心就三个东西:
| 文件/目录 | 作用 | 备注 |
|---|---|---|
README.md | 环境说明与运行入口 | 先读这个,别跳过 |
Hard_Environment_9_9_my.py | 主逻辑:感知+预测+控制 | 单文件,约几百行 |
| 场景资源(需自行准备) | AirSim 复杂环境 | 源码包内通常不含 UE 场景 |
依赖方面,除了airsim本身,还需要opencv-python(霍夫圆检测)、numpy(卡尔曼滤波矩阵运算)、filterpy(可选,卡尔曼滤波封装)。我一般会先建一个干净的虚拟环境,避免和系统里的 OpenCV 版本打架:
# 创建独立环境,Python 3.8~3.10 兼容性最好 conda create -n airsim_uav python=3.9 conda activate airsim_uav # 核心依赖,版本别乱升 pip install airsim==1.8.1 pip install opencv-python==4.8.0.74 pip install numpy==1.24.3 pip install filterpy==1.4.5这里有个血泪经验:airsim包的版本必须和你的 AirSim 插件版本对齐,否则MultirotorClient初始化时会报 API 版本不匹配。我见过有人用 1.8 的 Python 包连 1.3 的插件,卡了一下午以为是网络问题。
2.3 连接仿真环境并验证基础控制
在跑主逻辑之前,先写一个最小验证脚本,确认能连上、能起飞、能拿图像。这一步能帮你排除 80% 的环境问题:
import airsim import cv2 import numpy as np # 连接 AirSim,超时设长一点,UE 首次加载慢 client = airsim.MultirotorClient() client.confirmConnection() client.enableApiControl(True) client.armDisarm(True) # 起飞并悬停,高度 3 米 client.takeoffAsync().join() client.moveToZAsync(-3, 1).join() # 拿一帧前向摄像头的图像 response = client.simGetImage("0", airsim.ImageType.Scene) if response: img_bgr = cv2.imdecode(np.frombuffer(response, np.uint8), cv2.IMREAD_COLOR) cv2.imwrite("test_frame.jpg", img_bgr) print("图像尺寸:", img_bgr.shape) else: print("没拿到图像,检查摄像头名称和场景配置") client.armDisarm(False) client.enableApiControl(False)逻辑说明:confirmConnection()是握手,enableApiControl(True)把控制权从 AirSim 默认控制器拿过来,armDisarm(True)解锁电机。simGetImage的第一个参数"0"是摄像头 ID,不同场景可能叫"front_center"或"0",拿不到图就先client.simListCameras()列一下。参数方面,moveToZAsync(-3, 1)里的-3是 NED 坐标系下的高度(负号表示向上),1是速度 m/s,这两个值按你场景的实际尺度调。
3. 霍夫圆检测识别移动障碍物:从图像到圆心坐标的完整链路
3.1 霍夫圆检测的参数怎么调才不玄学
霍夫圆检测(Hough Circle Transform)本质是在参数空间里投票找圆,cv2.HoughCircles的几个参数直接决定你能不能稳定检出障碍物。这份源码里用的是HOUGH_GRADIENT方法,核心参数就四个:
dp:累加器分辨率与图像分辨率的反比。设 1 表示原分辨率,设 2 表示降采样一半。降采样能提速但会丢小圆,我一般设 1.2。minDist:圆心之间的最小距离。设太小会检出同心圆,设太大会漏掉相邻障碍物。按障碍物实际像素间距的 0.8 倍设。param1:Canny 边缘检测的高阈值。低阈值是它的一半。图像对比度低就往下调。param2:累加器阈值,越小检出越多但误检也越多。这是最需要试的参数。
def detect_circles(frame_bgr): # 转灰度 + 高斯模糊,模糊核必须是奇数 gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY) blurred = cv2.GaussianBlur(gray, (9, 9), 2) # 霍夫圆检测,参数按场景调 circles = cv2.HoughCircles( blurred, cv2.HOUGH_GRADIENT, dp=1.2, minDist=50, # 障碍物间距大就调大 param1=100, # Canny 高阈值 param2=30, # 累加器阈值,误检多就调大 minRadius=20, # 按障碍物在图像里的最小像素半径设 maxRadius=200 ) if circles is not None: circles = np.round(circles[0, :]).astype("int") # 按半径排序,通常最大的那个是最近的障碍物 circles = sorted(circles, key=lambda c: c[2], reverse=True) return circles逻辑说明:先灰度化再高斯模糊是为了压噪声,(9,9)的核适合 640x480 以上的图。minRadius和maxRadius一定要设,不设的话算法会在全尺度搜索,又慢又容易误检。返回的circles每行是(x, y, r),x/y 是圆心像素坐标,r 是半径。
参数怎么改:如果障碍物在画面里忽大忽小(无人机在靠近),minRadius要设小一点覆盖远处的小圆;如果背景纹理复杂导致误检,优先调大param2,每次加 5 试。
3.2 像素坐标转机体坐标系的换算
霍夫圆给你的是像素坐标,但控制无人机需要的是三维空间里的相对位置。这一步的换算依赖摄像头内参和障碍物的实际物理半径。常见做法是用相似三角形:
# 摄像头内参,需要根据你的 AirSim 摄像头配置标定 FOCAL_LENGTH_PX = 320.0 # 焦距(像素),640 宽图约等于宽度一半 OBSTACLE_REAL_RADIUS = 0.5 # 障碍物实际半径,单位米 def pixel_to_body(cx, cy, r, img_w, img_h): # 用半径比例估算深度:实际半径 / 像素半径 = 深度 / 焦距 depth = (OBSTACLE_REAL_RADIUS * FOCAL_LENGTH_PX) / max(r, 1) # 像素偏移转机体坐标偏移 dx = (cx - img_w / 2) * depth / FOCAL_LENGTH_PX dy = (cy - img_h / 2) * depth / FOCAL_LENGTH_PX return dx, dy, depth逻辑说明:depth是通过"实际半径与像素半径之比等于深度与焦距之比"推出来的,这是单目测距最朴素也最常用的方法。dx、dy是障碍物相对画面中心的横向和纵向偏移,单位米。参数方面,FOCAL_LENGTH_PX必须标定,不能拍脑袋填,否则深度估计会系统性偏大或偏小。标定方法是用已知距离和已知大小的物体拍一张,反推焦距。
提示:单目测距对半径估计误差很敏感,
r差 10%,depth就差 10%。如果障碍物是圆环而不是实心圆,霍夫检测到的半径可能偏大,需要乘一个修正系数。
3.3 多障碍物筛选与最近目标锁定
画面里同时出现多个圆时,不能全丢给预测模块,得先筛出"当前最该关注的那个"。源码里的策略是按深度排序取最近的,但实际还要加一个视野约束——太靠画面边缘的障碍物即使近,也可能马上飞出视野,不值得跟。
def select_target(circles, img_w, img_h): candidates = [] for (x, y, r) in circles: dx, dy, depth = pixel_to_body(x, y, r, img_w, img_h) # 只保留画面中心 70% 区域内的目标 if abs(x - img_w/2) < img_w*0.35 and abs(y - img_h/2) < img_h*0.35: candidates.append((depth, dx, dy, x, y, r)) if not candidates: return None # 按深度升序,取最近的 candidates.sort(key=lambda c: c[0]) return candidates[0]逻辑说明:abs(x - img_w/2) < img_w*0.35是视野过滤,把边缘的圆排除掉,避免无人机为了追一个即将出画的目标而大幅偏航。candidates.sort按深度排序,取第一个就是最近的。参数0.35可以调,画面抖动大就缩小到 0.3,障碍物密集就放宽到 0.4。
4. 卡尔曼滤波与运动模态识别:预测移动障碍物的下一步
4.1 卡尔曼滤波的状态向量怎么定义
移动障碍物的预测,本质是估计它的位置和速度。卡尔曼滤波的状态向量我一般设成[x, y, z, vx, vy, vz],六维,匀速模型。观测向量是[x, y, z],因为霍夫圆只能给你当前位置,给不了速度。
from filterpy.kalman import KalmanFilter import numpy as np def build_kf(): kf = KalmanFilter(dim_x=6, dim_z=3) dt = 0.1 # 仿真步长,按你的控制频率设 # 状态转移矩阵:位置 += 速度 * dt kf.F = np.array([ [1, 0, 0, dt, 0, 0], [0, 1, 0, 0, dt, 0], [0, 0, 1, 0, 0, dt], [0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 1, 0], [0, 0, 0, 0, 0, 1] ]) # 观测矩阵:只观测位置 kf.H = np.array([ [1, 0, 0, 0, 0, 0], [0, 1, 0, 0, 0, 0], [0, 0, 1, 0, 0, 0] ]) # 过程噪声和观测噪声,按实际抖动调 kf.Q = np.eye(6) * 0.01 kf.R = np.eye(3) * 0.5 kf.P = np.eye(6) * 1.0 return kf逻辑说明:F矩阵是匀速模型的核心,dt必须和你的控制循环周期一致,否则速度估计会系统性偏差。Q是过程噪声,障碍物运动越不规律就调越大;R是观测噪声,霍夫圆检测抖动大就调大。P是初始协方差,设大一点表示对初始状态不信任,让滤波器快速收敛。
参数怎么改:如果预测轨迹明显滞后于实际障碍物,先把Q调大(比如 0.05),让滤波器更相信新观测;如果预测轨迹抖得厉害,把R调大,让滤波器更平滑。
4.2 运动模态识别:匀速、加速还是转向
光有卡尔曼滤波不够,因为障碍物可能突然加速或转向,匀速模型会失效。运动模态识别的思路是:用滤波后的速度序列判断当前处于哪种模式,然后切换对应的预测模型。
class MotionMode: CONSTANT = "constant" ACCEL = "accel" TURN = "turn" def detect_mode(kf, prev_vel, accel_thresh=0.5, turn_thresh=0.3): # 当前速度估计 vel = kf.x[3:6].flatten() # 速度变化量 dv = np.linalg.norm(vel - prev_vel) # 速度方向变化 if np.linalg.norm(prev_vel) > 0.1: cos_angle = np.dot(vel, prev_vel) / (np.linalg.norm(vel) * np.linalg.norm(prev_vel) + 1e-6) angle_change = np.arccos(np.clip(cos_angle, -1, 1)) else: angle_change = 0 if angle_change > turn_thresh: return MotionMode.TURN elif dv > accel_thresh: return MotionMode.ACCEL return MotionMode.CONSTANT逻辑说明:dv是速度矢量变化量,超过阈值认为在加速;angle_change是速度方向变化角,超过阈值认为在转向。两个阈值accel_thresh和turn_thresh需要按障碍物实际运动剧烈程度调。识别出模式后,加速模式把Q调大,转向模式切换成匀角速度模型。
注意:模态识别本身有延迟,别指望它瞬间响应。我一般会在识别结果上加一个 3 帧的滑动窗口投票,避免单帧误判导致预测模型频繁切换。
4.3 预测结果怎么喂给控制模块
预测出来的障碍物未来位置,最终要转成无人机的避障指令。常见做法是算一个"排斥力"或者"安全走廊",让无人机提前绕开。
def predict_obstacle(kf, steps=5): predictions = [] for _ in range(steps): kf.predict() predictions.append(kf.x[:3].copy()) return predictions def compute_avoidance(predictions, drone_pos, safe_dist=2.0): # 找预测轨迹里离无人机最近的点 min_dist = float('inf') avoid_vec = np.zeros(3) for p in predictions: diff = drone_pos - p dist = np.linalg.norm(diff) if dist < min_dist: min_dist = dist avoid_vec = diff / (dist + 1e-6) # 单位排斥方向 if min_dist < safe_dist: return avoid_vec * (safe_dist - min_dist) # 越近排斥越强 return np.zeros(3)逻辑说明:predict_obstacle连续调用kf.predict()得到未来几步的障碍物位置,注意这里只预测不更新,因为没有新观测。compute_avoidance算的是排斥向量,距离小于safe_dist才生效,强度随距离线性增大。参数safe_dist按无人机尺寸和障碍物半径之和再留余量设,一般 1.5 到 3 米。
5. PID 轨迹优化与避坑排查:让无人机真的穿过去
5.1 三轴 PID 的参数整定顺序
PID 是最后一道关,前面感知和预测再准,PID 调不好照样撞。这份源码用的是位置式 PID,三个轴独立控制。整定顺序我的习惯是:先调高度(Z 轴),再调偏航(Yaw),最后调水平(X/Y)。
class PID: def __init__(self, kp, ki, kd, limit=1.0): self.kp, self.ki, self.kd = kp, ki, kd self.limit = limit self.integral = 0.0 self.prev_error = 0.0 def update(self, error, dt): self.integral += error * dt # 积分限幅,防止 windup self.integral = np.clip(self.integral, -self.limit, self.limit) derivative = (error - self.prev_error) / dt self.prev_error = error output = self.kp * error + self.ki * self.integral + self.kd * derivative return np.clip(output, -self.limit, self.limit) # 三轴 PID 初始化,参数是起点不是终点 pid_x = PID(kp=0.8, ki=0.01, kd=0.15) pid_y = PID(kp=0.8, ki=0.01, kd=0.15) pid_z = PID(kp=1.2, ki=0.02, kd=0.2)逻辑说明:integral限幅是必须的,否则无人机在目标点附近会来回振荡。derivative用差分算,dt要和实际控制周期一致。参数起点:kp先给 0.5 到 1.0,观察响应速度;kd给kp的 0.1 到 0.2 倍抑制超调;ki最后加,从 0.01 开始,加多了会振荡。
5.2 避障与轨迹跟踪的融合逻辑
避障排斥向量和轨迹跟踪 PID 输出要叠加,但叠加权重得动态调。离障碍物远的时候以轨迹跟踪为主,近了以避障为主。
def compute_control(target_pos, drone_pos, drone_vel, avoidance, dt): # 轨迹跟踪 PID error = target_pos - drone_pos u_track = np.array([ pid_x.update(error[0], dt), pid_y.update(error[1], dt), pid_z.update(error[2], dt) ]) # 避障权重:排斥向量越大,避障占比越高 avoid_weight = min(np.linalg.norm(avoidance) / 2.0, 0.8) u_final = (1 - avoid_weight) * u_track + avoid_weight * avoidance # 转成 AirSim 的速度指令 vx, vy, vz = u_final * 3.0 # 最大速度 3 m/s return vx, vy, vz逻辑说明:avoid_weight上限设 0.8,保证任何时候都保留一点轨迹跟踪,否则无人机会被障碍物推着乱飞。u_final * 3.0是把归一化控制量转成实际速度,3 m/s 是竞赛场景常见上限,按你的场景调。
5.3 常见问题排查:五个我踩过的坑
现象一:霍夫圆检测在无人机移动时完全失效。原因是运动模糊导致边缘断裂,Canny 检不出完整圆。解决:降低飞行速度,或者把param1从 100 降到 60,让边缘检测更宽容。
现象二:卡尔曼滤波预测的障碍物位置越跑越偏。原因是dt和实际控制周期不一致,比如代码里写 0.1 但实际循环跑了 0.15。解决:在循环里用time.time()实测dt,动态传给滤波器。
现象三:无人机在障碍物前反复横跳不前进。原因是避障权重和轨迹跟踪权重打架,排斥向量和跟踪向量方向相反。解决:给避障加一个"通过窗口",当障碍物在侧面且距离大于安全距离时,避障权重直接归零。
现象四:PID 输出饱和导致无人机猛冲。原因是积分项累积过多,windup了。解决:积分限幅limit设小一点,或者在误差符号反转时清零积分项。
现象五:AirSim 拿到的图像和实际场景对不上。原因是摄像头 ID 写错,或者场景里有多个摄像头。解决:先client.simListCameras()列出所有摄像头,确认你要的那个名字,再传给simGetImage。
提示:这五个坑里,前两个是感知层,中间两个是控制层,最后一个是环境层。排查时按这个顺序定位,能省不少时间。
6. 从能跑到跑好:参数固化、场景泛化与一个验证习惯
把源码跑通只是起点,真正让它稳定拿分,还得做两件事:参数固化和场景泛化。
参数固化指的是把调好的霍夫圆参数、卡尔曼Q/R、PID 三组系数写进配置文件,而不是散在代码里。我一般用一个config.yaml:
hough: dp: 1.2 minDist: 50 param1: 100 param2: 30 minRadius: 20 maxRadius: 200 kalman: dt: 0.1 q_scale: 0.01 r_scale: 0.5 pid: x: {kp: 0.8, ki: 0.01, kd: 0.15} y: {kp: 0.8, ki: 0.01, kd: 0.15} z: {kp: 1.2, ki: 0.02, kd: 0.2}这样换场景时只改 yaml,不动主逻辑。场景泛化则是把障碍物数量、运动速度、初始位置做成随机化,每次跑之前重新生成,避免过拟合到某一个固定布局。AirSim 支持通过 API 动态设置障碍物位姿,我一般写一个randomize_scene()函数,在每轮测试前调用。
验证方法上,我习惯用"三局两胜":同一个场景连续跑三次,如果两次能通过,说明参数稳了;如果三次结果差异很大,说明某个环节对初始条件太敏感,得回去查卡尔曼的P初始值或者 PID 的积分限幅。
还有一个具体技巧:把每次运行的感知-预测-控制数据录下来,用 matplotlib 画三条曲线——障碍物实际轨迹、卡尔曼预测轨迹、无人机实际轨迹。三条线叠在一起看,哪一段预测偏了、哪一段控制滞后了,一目了然。这比盯着仿真画面猜要高效得多。
从那以后我每次改完参数,都强制走一遍"录数据-画曲线-对比"的流程,再也不敢凭感觉调了。希望这份拆解能帮你少走点弯路,把这份源码真正用起来。
本文还有配套的精品资源,点击获取