做视觉的朋友应该都有过这种经历:想让无人机模型在复杂场景里也能稳定检出目标,结果光凑数据就快把腿跑断了。扛着机器出去飞一天,回来还得一帧一帧标注,路面、树冠、车辆、行人都得画框,画到后半夜才几百张。后来我把主要数据源切到AirSim,这套微软开源的无人机/汽车仿真平台,利用一张显卡就能在一天内批量产出RGB图、深度图和分割图,并且三路图像天然对齐。这篇文章分享我从环境配置、相机参数、采集脚本到数据格式转换的完整流程,最后会专门聊聊我踩过的那些坑,包括最常见的no frames received问题。不管你是要训练YOLOv8还是准备做mmrotate的DOTA数据集,这套流程都能直接接上。
1. 为什么选择AirSim生成无人机训练集
1.1 真实航拍数据采集的困境
地面视角的数据集满地都是,无人机视角却一直很难凑。真实航拍要解决场地、空域、电池、天气、光照等一系列问题,飞一次只能拿到一个时间段、一个固定航线的数据。今天多云,明天大太阳,后天起雾,模型在训练集里没见过,推理时就容易翻车。
更麻烦的是标注。检测任务至少要标普通框,分割任务还要沿着物体边缘抠轮廓。无人机视角下目标往往又小又密集,一棵树、一辆车、一排屋顶,标起来费时费力,一天标几百张已经算快手。数据稍微不够,再回头补标一轮,成本直接翻倍。还有一些特殊场景,比如火灾、洪水、夜间救援,你不可能为了攒数据集真的等一场灾害。
所以我在几个项目里逐渐把数据策略改成“仿真数据打底,真实数据微调”。用仿真环境把数量、多样性、标注成本这三个问题同时解决,再拿少量真实数据做迁移修正。实践下来,模型在检测、分割这类视觉任务上的表现完全够用,尤其适合做预训练和长尾场景补充。
1.2 AirSim能拍出哪些数据
AirSim是微软开源的仿真平台,底层跑在虚幻引擎上,里面内置了多旋翼、固定翼和汽车模型。它最吸引我的不是飞控多真实,而是视觉传感器输出非常规整:RGB图、深度图、分割图、红外图、表面法线图,全都支持。
每个相机能用同一套外参同时拍多种图像。也就是说,你在同一帧、同一个视角下能拿到一张真实感RGB图、一张每像素带距离值的深度图、一张每个物体都带独立ID的分割图。这三者像素级对齐,做多模态融合、半自动标注、仿真到真实迁移都非常方便。
AirSim里的深度图还分成两种:平面深度(DepthPlannar)和透视深度(DepthPerspective)。对无人机斜视视角来说,一般用DepthPerspective,因为拿到的是每个像素沿光轴方向到相机的距离,符合常规单目深度定义,也方便后处理时直接参与点云反投影。
1.3 仿真数据能直接训练吗
这是很多人第一反应。答案是能,但要有技巧。AirSim基于虚幻引擎渲染,纹理、光照、材质已经接近真实,但和真实照片仍有差距。直接用纯仿真数据训练完再拿到真实场景推理,效果会打折,这个差距叫domain gap。
想缩小domain gap,最常用的办法是域随机化。训练时改变天气、时间、光照、物体材质、贴图,让模型学到“目标的基本几何结构和上下文信息”,而不是死记某一种纹理。我在实践中发现,检测任务用仿真数据预训练的效果很明显,尤其对无人机这类视角变化大的任务,模型对高度、角度、尺度的泛化能力会显著提升。
所以我的建议是:把AirSim当成“标注员”,它负责快速生成海量对齐好的数据;真实数据负责“校准”,用来验证和微调。两者结合,既能控制成本,又能保障模型在真实场景的可用性。
2. 环境搭建与相机参数配置
2.1 引擎与AirSim版本选择
环境搭建是很多人卡住的第一关。AirSim有源码编译和预编译两种获取方式。如果只是做数据采集,没必要从源码编译,直接去AirSim的GitHub Release页面下载预编译的Blocks工程就行。这个环境是一个城市街区,做无人机视角的车辆、道路、建筑检测完全够用。
如果你需要自己的场景,比如园区、港口、农田,那就得自己建一个Unreal Engine项目再接入AirSim插件。UE4.27和UE5都有对应版本,我推荐新手先用UE4.27,资料多、稳定,UE5的光照系统更强但新手容易在编译阶段就劝退。
硬件方面,采集数据主要吃显卡。AirSim的光照和渲染全部实时跑,至少要RTX 3060这个级别才能流畅输出1080p图像。显存越小,越要降低分辨率和关闭不必要的特效。渲染性能如果跟不上,采集脚本会出现“no frames received”,这个后面专门说。
2.2 settings.json里最关键的几个配置
AirSim读取的是当前文件夹下的settings.json,很多图像问题都出在这份配置上。下面是我常用的一个无人机视觉配置:
{ "SettingsVersion": 1.2, "SimMode": "Multirotor", "ClockSpeed": 1.0, "CameraDefaults": { "CaptureSettings": [ { "ImageType": 0, "Width": 1280, "Height": 720, "FOV_Degrees": 90 }, { "ImageType": 2, "Width": 1280, "Height": 720, "FOV_Degrees": 90 }, { "ImageType": 3, "Width": 1280, "Height": 720, "FOV_Degrees": 90 } ] }, "Vehicles": { "SimpleFlight": { "VehicleType": "SimpleFlight", "Cameras": { "front_center": { "CaptureSettings": [ { "ImageType": 0, "Width": 1280, "Height": 720, "FOV_Degrees": 90 }, { "ImageType": 2, "Width": 1280, "Height": 720, "FOV_Degrees": 90 }, { "ImageType": 3, "Width": 1280, "Height": 720, "FOV_Degrees": 90 } ] } } } } }这里ImageType 0是RGB图,2是透视深度图,3是分割图。三者的分辨率和FOV必须完全一致,否则后面做像素对齐会非常痛苦。FOV一旦定了,后面深度图转点云、算相机内参时都要用同一个值,别中途随意改。
ClockSpeed可以理解成仿真时钟倍速。我采集时会把它调到1.0,保证画面渲染和物理计算稳定。如果追求速度,可以调到1.5或2.0,但渲染偶尔会掉帧,无人机飞行动作也会变快,反而影响数据质量。
2.3 连接Python API并验证图像输出
AirSim提供了Python API,安装异常简单:
pip install airsim连接之前先把仿真环境跑起来,再执行下面这段代码:
import airsim client = airsim.MultirotorClient() client.confirmConnection() print("连接成功") cam_info = client.simGetCameraInfo("front_center") print("相机位置:", cam_info.pose.position)如果打印“连接成功”但拿不到图像,优先检查settings.json里相机名是否叫front_center,以及分辨率是否真的生效了。AirSim允许自定义相机名,但名字一旦和代码里不一致,就会出现no frames received。
想快速验证图像,可以执行一次simGetImages,把RGB图保存到本地看看尺寸和内容:
import airsim import numpy as np import cv2 client = airsim.MultirotorClient() client.confirmConnection() response = client.simGetImages([ airsim.ImageRequest("front_center", airsim.ImageType.Scene, False, True) ])[0] img_bytes = np.frombuffer(response.image_data_uint8, dtype=np.uint8) img = cv2.imdecode(img_bytes, cv2.IMREAD_COLOR) cv2.imwrite("test_rgb.png", img)如果你的Python版本返回的image_data_uint8是base64字符串,先用base64.b64decode包一层再转numpy,不同版本的AirSim这里略有差异,但都能解决。
2.4 分割图类别ID的正确配法
分割图的原理是给每个网格对象分配一个ID,AirSim再把ID渲染成颜色。UE场景里的物体名千奇百怪,最好在Python里通过simSetSegmentationObjectID主动指定:
# 匹配所有名字带Ground的对象,设置成类别1 client.simSetSegmentationObjectID("Ground.*", 1) client.simSetSegmentationObjectID("Building.*", 2) client.simSetSegmentationObjectID("Vehicle.*", 3)这个接口支持正则表达式,非常方便。注意ID尽量不要超过255,底层stencil buffer是有限精度的,超过之后容易出现类别串色、ID混乱的问题。
我踩过的坑是:设置ID必须在场景完全加载后再执行。如果你在无人机还没初始化完就调用,有些对象还没创建,设置会静默失败。另外,不同对象共享同一个ID是可以的,分割图会把它们渲染成同一类颜色,这样你就能很方便地合并成“道路”“建筑”“车辆”这类语义类别。
3. 数据采集脚本到底怎么写
3.1 三路图像同步获取的原理与写法
AirSim采集最核心的一点是:RGB、深度、分割图必须在同一次simGetImages调用里取,因为仿真引擎是按帧渲染的,一次调用会返回同一帧的多张图。如果你分开三次调用,无人机可能在移动,三路图像就不对齐了。
我的标准写法是:
import airsim import numpy as np import cv2 client = airsim.MultirotorClient() client.confirmConnection() def capture_all(): requests = [ airsim.ImageRequest("front_center", airsim.ImageType.Scene, False, True), airsim.ImageRequest("front_center", airsim.ImageType.DepthPerspective, True, False), airsim.ImageRequest("front_center", airsim.ImageType.Segmentation, False, True), ] responses = client.simGetImages(requests) # RGB rgb = cv2.imdecode(np.frombuffer(responses[0].image_data_uint8, dtype=np.uint8), cv2.IMREAD_COLOR) # 深度 depth = np.array(responses[1].image_data_float, dtype=np.float32) depth = depth.reshape(responses[1].height, responses[1].width) # 分割 seg = cv2.imdecode(np.frombuffer(responses[2].image_data_uint8, dtype=np.uint8), cv2.IMREAD_COLOR) return rgb, depth, seg深度图请求时pixels_as_float一定要设为True,这样返回的是float数组,而不是PNG压缩图。拿到float数组后根据height和width重新reshape,就能得到单通道深度矩阵。
有人习惯每次采集前再单独飞一点,然后悬停采集,这样确实最简单,但效率低。更高效的方式是设置好航点,边飞边采,飞行速度控制在2到3米每秒,不会产生明显运动模糊。
3.2 无人机飞行控制与航点采集
AirSim的飞控API足够做自动化采集。先起飞,然后按航点移动:
client.enableApiControl(True) client.armDisarm(True) client.takeoffAsync().join() # 飞到10米高度 client.moveToPositionAsync(0, 0, -10, 5).join() # 按路线飞行 waypoints = [ (10, 0, -10), (10, 10, -10), (0, 10, -10), ] for wp in waypoints: client.moveToPositionAsync(wp[0], wp[1], wp[2], 4).join()注意AirSim的坐标系是NED,北东地,朝下的Z是负值。所以想飞10米高,目标Z应该写-10。速度单位是米/秒,最后一个参数是速度,不是持续时间。
我自己更推荐“航点悬停采集”模式:无人机飞到目标航点后先悬停1秒,等画面稳定再连拍几张,然后飞下一个点。这样采集的RGB图清晰度最高,深度图和分割图也最干净。如果你的目标数据集要训练运动模糊鲁棒性,再另跑一组非悬停的快扫数据。
3.3 图像保存和深度值换算
保存要分三套逻辑。RGB和分割图都是PNG,直接写文件就行;深度图不是普通图片,必须保留原始float数值。
def save_sample(idx, rgb, depth, seg, out_dir="output"): import os os.makedirs(out_dir, exist_ok=True) cv2.imwrite(f"{out_dir}/{idx:06d}_rgb.png", rgb) cv2.imwrite(f"{out_dir}/{idx:06d}_seg.png", seg) np.save(f"{out_dir}/{idx:06d}_depth.npy", depth)这里千万不要把深度图直接转成8位PNG保存,因为深度值范围很大,一转uint8就只剩视觉参考,数值精度全丢了。训练时如果需要真值深度,必须用npy、npy的float32保存,或者转成pfm格式再压缩。
AirSim的深度值默认单位是厘米,我一般会除以100转成米:
depth_in_meters = depth / 100.0转成米之后,后续做点云、做BEV投影都会方便很多。至于要不要把深度图可视化,可以单独做一个8位HUD预览,但只是用来人工检查,绝不影响训练数据。
3.4 批量任务设计:时间、天气、场地乱序
批量生成数据集时,最忌讳的是所有图片都长得像同一时刻拍的。AirSim支持修改时间、天气、环境,这些都要在采集脚本里随机化:
# 随机设置时间 hour = random.randint(6, 20) client.simSetTimeOfDay(True, hour, 0, 0) # 随机设置天气 client.simSetWeatherParameter(airsim.WeatherParameter.Rain, random.uniform(0, 0.6)) client.simSetWeatherParameter(airsim.WeatherParameter.Snow, 0) client.simSetWeatherParameter(airsim.WeatherParameter.Fog, random.uniform(0, 0.4))我通常会写一个外层循环:先随机选场景和天气,再随机生成一组航点,无人机飞完这条航线后切换条件,重复采集。同时把每次采集对应的相机位姿、高度、偏航角、天气参数写进一个CSV或JSON文件。
这些元数据非常值钱。后续做深度图转点云、做BEV俯视图、做多视角融合都用得到。采集时多存一条记录,比之后再翻工重新补数据省太多时间。
4. 训练集清洗与格式转换
4.1 像素级对齐验证与脏数据过滤
采集完成后不能直接拿去训练,先做一轮噪音检查。虽然三路图像是同一次simGetImages拿到的,理论上是严格对齐的,但如果仿真渲染偶尔卡帧或设置错误,也会出现尺寸不一致、全黑图、深度值全是0的情况。
我的清洗流程有三个动作:
- 检查RGB、深度、分割图的分辨率和尺寸是否一致,不一致的直接删除。
- 统计深度图的有效像素比例,如果全是0或NaN,说明这一帧深度没有正常渲染,丢弃。
- 人工抽查RGB和分割图的边缘轮廓是否重合,尤其道路、建筑的边界,确认没有错位。
还有一个很小的坑:OpenCV读图默认是BGR,如果你要对分割图和RGB按颜色做匹配,记得先统一色彩通道。用cv2.cvtColor把BGR转成RGB,不然你会发现分割图里某类物体的颜色永远“对不上”。
4.2 深度图存成npy/pfm,顺带转点云
深度图真正的价值不只是多一个通道,而是可以反投影成三维点云。假设相机内参是已知的,深度图像素坐标、相机焦距fx、fy和主点cx、cy满足:
X = (u - cx) * Z / fx Y = (v - cy) * Z / fy Z = depth_in_meters(u, v)fx = W / (2 * tan(FOV / 2)),主点近似取图像中心。配合AirSim返回的相机位姿,把每个像素投影到世界坐标系,就能得到一帧W×H个三维点。
我经常把深度图转成点云,再用Open3D保存成PLY或PCD,用来做点云检测、多模态融合的预实验。如果你用Halcon做深度图转点云,原理也一样,核心变量就是相机内参和深度单位,只要深度是米、FOV没错,投影结果基本不会偏。
import numpy as np def depth_to_points(depth_m, fov_deg=90): H, W = depth_m.shape fx = W / (2 * np.tan(np.radians(fov_deg / 2))) fy = fx cx, cy = W / 2, H / 2 u, v = np.meshgrid(np.arange(W), np.arange(H)) x = (u - cx) * depth_m / fx y = (v - cy) * depth_m / fy z = depth_m return np.stack([x, y, z], axis=-1).reshape(-1, 3)4.3 用分割图自动生成YOLO检测标签
分割图天然就是像素级标注。我们要做的,是把每个类别的像素区域转换成目标检测框。这部分代码不复杂,却非常实用:
import cv2 import numpy as np # class_colors: dict, 例如 {(1,1,1): "car", (2,2,2): "building"} def seg_to_yolo_labels(seg_bgr, class_colors, img_w, img_h, out_txt): labels = [] # 每个类别单独生成mask for color, name in class_colors.items(): mask = np.all(seg_bgr == np.array(color), axis=2).astype(np.uint8) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) if w < 16 or h < 16: # 过滤太小的噪点 continue cx = (x + w / 2) / img_w cy = (y + h / 2) / img_h bw = w / img_w bh = h / img_h labels.append(f"{class_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") with open(out_txt, "w") as f: f.write("\n".join(labels))生成的txt就是YOLO格式。YOLOv8、YOLOv5、YOLO11这些主流检测框架都能直接用,把图像和txt按对应的目录结构放好,改一下data.yaml的类别列表就能开训。
这里最需要花心思的是维护好颜色到类别的映射表。建议在第一次设置分割ID时,就把ID、颜色、类别名一起记录下来,后面转标签才不会乱。
4.4 从分割图扩展到实例分割与旋转框
如果要做实例分割,分割图可以直接当Mask用。每个物体的连通域提取出来,RLE编码或polygon格式都能转。像YOLACT这类实例分割框架需要的标签本来就是一个二值Mask表,AirSim分割图是最直接的素材。
做旋转目标检测时,就把boundingRect换成minAreaRect,得到带角度的最小外接矩形:
rect = cv2.minAreaRect(cnt) box = cv2.boxPoints(rect) # 4个角点把4个角点按顺序写出来,再加类别名和difficult标记,就是DOTA数据集的格式。之后可以直接交给mmrotate训练,省去手工标注旋转框的巨大工作量。
我在一个遥感视角项目里,就用AirSim分割图自动生成了DOTA格式的旋转框,主要检测车辆和屋顶,训练出来的模型在仿真测试集上mAP相当理想,说明这条链路完全走得通。
5. 高频问题排查与避坑记录
5.1 no frames received:采集不到图的常见原因
这个问题我在许多交流群里看到过,自己也遇到过。现象是API连接正常,无人机也能控制,但simGetImages拿不到任何有效图像。
排查顺序我建议这样来:
- 确认相机名字和settings.json里的完全一致,多一个空格都会失败。
- 确认settings.json里确实配了对应ImageType的CaptureSettings,没有配置就不会渲染对应图。
- 确认显卡显存是否充足。采集分辨率过高、同时请求多路图像时,显存不够会导致渲染失败。
- 确认是否在打开仿真环境之后才运行Python脚本,脚本启动过早,环境还在加载,图像管线没就绪。
- 确认Unreal场景有没有出现卡死、崩溃。AirSim对电脑配置要求不低,渲染和物理同时跑时,机器太老就可能一帧都出不来。
如果这些都检查完还不行,把settings.json里分辨率降到640×480再试一次。很多渲染问题在低分辨率下会自动消失,先跑通再提分辨率是最高效的做法。
5.2 深度图全黑全白:单位、范围与显示问题
深度图全黑是最容易误导人的现象。很多人以为深度没采到,但其实深度值非常正常,只是数值范围太大,而预览用的图像是8位的,一显示就成了全黑或全白。
不要用直接保存PNG的方法检查深度图。正确做法是打印深度数组的min、max和均值,确认有效值是否在合理范围。如果你是直接np.save存的float32,训练时按米为单位用,完全没有问题。如果非要可视化,用归一化:
depth_img_uint8 = cv2.normalize(depth_in_meters, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)这样可以用来快速检查深度边缘是否清晰,但千万不能把归一化后的图当训练数据。
还有一个容易翻车的点:AirSim的深度单位是厘米,直接拿原始值当米去做点云,整个场景会缩小100倍。最开始我吃过这个亏,点云叠到RGB上怎么都对不齐,后来检查单位才意识到。
5.3 分割图ID错乱:命名、覆盖和未渲染问题
分割图最常见的错是类别全混在一起,或者某个目标始终没有被分割出来。原因基本有三个方向:
- 对象名没有匹配上。UE场景里物体名带前缀后缀,正则表达式写窄了就漏。先用打印的方式把所有对象名扫一遍:
names = client.simListSceneObjects(".*") print(names[:50])- ID被重复设置覆盖了。后设置的ID会覆盖之前的设置,如果两段代码同时操作同一个对象,结果以最后执行为准。
- 分割图没有参与渲染。CaptureSettings里没配置ImageType为3,自然拿不到分割图。
我建议在第一次采集前,先固定几个对象,专门打印一帧分割图看看颜色是否符合预期。确认分割图没问题之后,再跑全量采集,否则后面浪费几个小时跑出来的数据都可能是废的。
5.4 飞控漂移、API超时与物理仿真漂移
无人机飞着飞着偏离航线,在AirSim里也比较常见。大部分情况是速度设置得太快,无人机转弯时甩出去了。把航点之间的移动速度降到3到4米/秒,转向时增加偏航控制,轨迹会稳定很多。
API超时通常发生在调用频繁、画面渲染卡顿的时候。AirSim的Python客户端默认超时时间较短,可以适当调大:
client = airsim.MultirotorClient(timeout=30) client.confirmConnection()如果无人机撞了墙或者姿态异常,不要硬往下飞,用AirSim自带的重置接口恢复:
client.reset() client.enableApiControl(True) client.armDisarm(True)重置后建议等两秒再起飞,不然物理引擎刚恢复,无人机会有抖动。
5.5 问题速查表
| 现象 | 常见原因 | 解决思路 |
|---|---|---|
| no frames received | 相机名错误、显存不足、CaptureSettings缺失 | 对照settings检查配置,降低分辨率 |
| 深度图全黑/全白 | 深度值未归一化、单位厘米、显示误解 | 用npy保留float,检查min/max |
| 分割图全一类颜色 | ID设置没生效、对象名没匹配 | 打印对象列表,检查正则表达式 |
| 三路图像尺寸不一致 | settings配置不统一 | 保证所有ImageType的分辨率一致 |
| 无人机飞行漂移 | 速度过快、碰撞 | 降低速度,reset后重新起飞 |
| API超时 | 渲染卡顿、调用频繁 | 增加timeout,减少单次请求数量 |
6. 一些实操心得和后续扩展
6.1 数据规模怎么定才够用
仿真数据因为不用人工标注,很多人容易陷入“堆量”陷阱,一晚上跑出几万张图,结果训练效果并没有线性提升。我个人的经验是,检测任务先保证3千到1万张高质量图,覆盖不同高度、角度、天气和光照,比盲目堆10万张重复视角有效得多。
关键是控制数据冗余。同一航点连续采集的图片,前后差异只有几个像素,对训练几乎没有贡献,只会让训练变慢、模型过拟合到固定视角。所以我限定同一组天气和航线最多采集几百张就切换条件,让每一张都有新的信息量。
6.2 别忘了做色彩增强和域随机化
即便AirSim做了很多随机化,仿真图像和真实图像在色彩上还是有色差。我习惯在训练前再做一遍色彩增强,用HSV空间调整亮度、饱和度和色调,或者轻微加一些模糊和噪声。
OpenCV里一条cv2.cvtColor(img, cv2.COLOR_BGR2HSV)就能把图像转到HSV空间,再对S、V通道做随机缩放。这虽然不能完全消除domain gap,但能把模型对特定色彩的依赖降下来,让模型更关注目标结构和上下文。
6.3 从单目视觉扩展到多传感器对齐
AirSim的相机系统支持给同一台无人机挂多个相机,你可以同时装一个RGB相机、一个深度相机、一个分割相机,甚至模拟多光谱设备。只要把多个相机的内外参都记录下来,后续就能做多模态融合。
我在做多光谱对齐需求时,就是在AirSim里挂两组相机,一组可见光,一组模拟近红外响应,通过同一份相机外参做投影对齐。这种思路和真实设备上的RGB与多光谱对齐本质一致,只是把真实传感器换成了仿真渲染。训练时先学仿真里的多模态对齐关系,再迁移到真实设备,前期方案验证效率会高很多。
6.4 最后一招:悬停连拍与随机扰动
最后分享一个我一直在用的采集小技巧:把采集拆成“悬停连拍”和“随机扰动”两段。悬停连拍时无人机动量小,画面最干净,适合做分割和深度任务;随机扰动时给相机位置和偏航角加一点微小随机偏移,能模拟飞行抖动,适合做检测任务的泛化。
每次悬停后连拍10到20帧,然后小幅调整相机姿态再拍一组。这样生成的数据既保持了稳定性,又保留了姿态变化,训练出来模型不容易见过拟合。这个方法目前是我最顺手的采集策略,做检测、分割、深度估计都能套用。