简介:一套面向空中无人机检测任务的旋翼无人机UAV数据集,包含7000多张已标注图片,覆盖多种旋翼无人机形态,统一采用“drone”单类别标注,可直接用于YOLO、SSD、Faster R-CNN等目标检测算法的训练与评估,也适合高校计算机视觉课程、无人机巡检项目作为实验数据。压缩包共22675个文件,其中7558张jpg原图对应7558个xml标签与7559个txt标签,两种标注格式可无缝切换以适配不同检测框架,整体体积876.78MB,文件按图片序号整齐命名,便于快速筛选与批量处理。目前已有1111人学习下载,从标注解析、数据划分到模型训练均可据此展开,能显著节省数据采集与格式转换时间,使研究者集中精力进行检测精度调优与算法对比;同时对无人机避障、低空目标识别等应用也有直接参考价值。
1. 空中无人机检测数据集:7000张旋翼UAV图片能解决什么问题
做空中无人机检测的人,最容易翻车的地方不是模型选型,而是数据集的坑。这份无人机检测数据集一共7000多张标注好的旋翼无人机UAV图片,类别只有drone一类,视觉目标非常明确:凡是画面里出现的旋翼无人机,都要用一个框圈出来。和很多开源数据集不一样的是,它同时给了txt和xml两种标签格式,意味着YOLO系、mmdet系、甚至自己写数据加载器的方案都能直接吃进去,不用再从零做格式搬移。适合正在跑目标检测算法、做无人机视觉感知、或者准备用无人机巡航数据做训练的从业者。
2. 数据集结构与标注格式:txt和xml并存,先搞懂再动手
拿到压缩包先别急着解压就开训。这个数据集的图片命名像是drone_2_595.jpg,同一张图会对应一个.txt和一个.xml。txt是YOLO系训练直接读的归一化坐标,xml是Pascal VOC风格,给需要bbox标签树的框架用。两个文件存在时间久了容易有一方缺失、坐标越界、类别名大小写不一致的问题,我建议先把两套格式的读取逻辑都写出来,做一次全量校验,再进训练流程。
2.1 两份标注各自的读取方式:YOLO txt和Pascal VOC xml
YOLO的txt每一行是一组标注,格式固定为class x_center y_center width height,坐标全部相对于图片宽高做了归一化,范围0到1。读取时务必把类别索引和四维浮点分开解析,不要在按空格切分时踩到尾部换行符。
import os from PIL import Image def read_yolo_txt(txt_path, img_path): with open(txt_path, 'r') as f: lines = f.readlines() w, h = Image.open(img_path).size boxes = [] for line in lines: parts = line.strip().split() cls = int(parts[0]) cx, cy, bw, bh = map(float, parts[1:]) # 归一化坐标转像素坐标,方便画框和评估 x1 = int((cx - bw / 2) * w) y1 = int((cy - bh / 2) * h) x2 = int((cx + bw / 2) * w) y2 = int((cy + bh / 2) * h) boxes.append((cls, x1, y1, x2, y2)) return boxes这里cls直接取整是因为类别索引从0开始。这个数据集只有drone一个类,训练时这个值恒为0,但如果后续你把其他飞行物并入数据,类别索引就不能丢了。Pixel坐标换算后可能超出图片边界,比如标注框边缘落在图片外,读取时最好顺手做一次clamp,否则后面画框或计算IoU容易报错。
XML这边用Python自带的xml.etree就行,不需要引入额外依赖。VOC XML的关键字段就三个:filename、size里的宽高、object里的bndbox。
import xml.etree.ElementTree as ET def read_voc_xml(xml_path): root = ET.parse(xml_path).getroot() boxes = [] for obj in root.iter('object'): name = obj.find('name').text bndbox = obj.find('bndbox') x1 = int(bndbox.find('xmin').text) y1 = int(bndbox.find('ymin').text) x2 = int(bndbox.find('xmax').text) y2 = int(bndbox.find('ymax').text) boxes.append((name, x1, y1, x2, y2)) return boxes注意root.iter('object')是遍历所有object节点,避免极少数嵌套结构导致漏读。我一般会额外检查difficult字段,因为VOC里difficult=1的框表示目标极小或者遮挡严重,很多框架训练时会自动忽略。不同数据集对这个字段的处理不一致,建议先统计一下有多少difficult框,再决定是保留还是当噪声剔除。
2.2 用脚本把标注信息可视化:验证框位置和类别名
类别名对不对、框跟不跟得上机体,光看数字无法判断。把标注画回原图是第一步,也是后面所有调参的基础。
import cv2 import os def draw_boxes(img_path, boxes, class_names=None): img = cv2.imread(img_path) for box in boxes: cls, x1, y1, x2, y2 = box[:5] label = class_names[cls] if class_names else str(cls) color = (0, 255, 0) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) cv2.putText(img, label, (x1, y1 - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2) cv2.imwrite('check_' + os.path.basename(img_path), img)class_names参数传['drone']即可显示可读类名。实际做可视化时别一张张看图,我习惯把同一批抽样的结果拼成一张网格图,一眼扫过去就能发现某个框偏移、漏标、或者类别名写成Drone导致大小写不一致。这个阶段发现的问题,比训练完再回头查快得多。
2.3 文件命名规律与标注完整性核对
drone_2_595.jpg这种命名,drone是场景前缀,2可能是拍摄批次,595是帧序号。即使这个数据集里文件本身是完整的,你后续自己扩充数据时也建议沿用类似命名,至少保留一个能区分拍摄批次的字段,否则后面做按批次划分训练集会很难受。
标签完整性问题不能只用眼睛扫。写一个脚本遍历所有图片,检查同名txt和xml是否存在,同时检查txt是否为空文件。空标注文件不算错误,但混入训练集后会额外产生无目标的负样本,影响训练稳定性。
| 文件 | 内容 | 典型用途 |
|---|---|---|
.jpg | 原始图像 | 模型输入 |
.txt | YOLO归一化坐标,每行一个框 | YOLO系列直接读取 |
.xml | VOC格式的filename、size、object | mmdet、PaddleDetection等框架 |
import os img_dir = 'images' missing = [] for f in os.listdir(img_dir): if f.endswith('.jpg'): stem = os.path.splitext(f)[0] txt_path = os.path.join('labels', stem + '.txt') xml_path = os.path.join('annotations', stem + '.xml') if not os.path.exists(txt_path) or not os.path.exists(xml_path): missing.append(f) print('missing annotation:', len(missing))这段脚本把缺失情况打印出来,方便你确认文件数量。7000多张图如果缺失比例超过1%,建议先联系数据来源补全,不要自行删图硬凑,否则类别分布会悄然改变。
3. 把数据集跑进目标检测训练流程:格式统一与训练集划分
这一章解决的是从原始数据集到可直接训练之间的最后一公里。很多人的做法是解压后直接改yaml路径就开训,结果训练中途报坐标越界,或者验证集mAP虚高得离谱。这里的关键在于格式统一和划分策略。
3.1 为什么先统一标签格式:YOLO txt与XML标注的转换逻辑
同一份标注给两套格式本意是兼容不同框架,但两套格式各自维护容易出偏差。比如有人手动改过xml里的框坐标,txt没有同步更新。所以最稳妥的做法是选一个格式作为基准,再统一导出另一份。
常见做法是以txt为基准,因为YOLO和大部分现代检测框架最终都吃txt。如果要从VOC XML生成txt,核心是坐标映射:VOC的xmin、ymin、xmax、ymax是像素坐标,YOLO需要的是归一化中心点坐标和宽高。
import xml.etree.ElementTree as ET import os def voc_to_yolo(xml_path, out_dir, class_map): root = ET.parse(xml_path).getroot() filename = root.find('filename').text size = root.find('size') img_w = int(size.find('width').text) img_h = int(size.find('height').text) lines = [] for obj in root.iter('object'): name = obj.find('name').text if name not in class_map: print(f'[warn] unknown class: {name}') continue box = obj.find('bndbox') x1 = float(box.find('xmin').text) y1 = float(box.find('ymin').text) x2 = float(box.find('xmax').text) y2 = float(box.find('ymax').text) # 像素坐标转归一化中心坐标 cx = (x1 + x2) / 2 / img_w cy = (y1 + y2) / 2 / img_h bw = (x2 - x1) / img_w bh = (y2 - y1) / img_h lines.append(f'{class_map[name]} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}') stem = os.path.splitext(os.path.basename(xml_path))[0] with open(os.path.join(out_dir, stem + '.txt'), 'w') as f: f.write('\n'.join(lines))class_map传入{'drone': 0}即可,写6位浮点足够,训练时对精度不敏感。转换过程中如果发现x2 - x1为负,说明标注框坐标乱序,比如xmin大于xmax,这种标注要单独拉出来人工核对,不能让它静默进入训练集。还有一类坑是XML里size的宽高和图片实际尺寸不一致,通常是裁剪或缩放过的图没有同步更新XML,转换出来所有框都会偏移。
3.2 训练集/验证集划分:随机切分不够,按批次切分更稳
目标检测训练一般把数据按8:2或9:1划分train和val。很多人的第一反应是随机打乱。随机划分对普通图像数据集基本够用,但无人机影像有个特殊性——同一批航拍视频抽帧出来的图之间高度相似。如果同一段飞行的相邻帧被同时分进train和val,验证指标会虚高,真正部署时碰到新场景反而掉点。
import os import random import shutil def split_dataset(src_img, src_label, dst_root, train_ratio=0.8, seed=0): names = [f[:-4] for f in os.listdir(src_img) if f.endswith('.jpg')] random.seed(seed) random.shuffle(names) train_n = int(len(names) * train_ratio) for split_name, subset in (('train', names[:train_n]), ('val', names[train_n:])): img_out = os.path.join(dst_root, split_name, 'images') label_out = os.path.join(dst_root, split_name, 'labels') os.makedirs(img_out, exist_ok=True) os.makedirs(label_out, exist_ok=True) for name in subset: shutil.copy(os.path.join(src_img, name + '.jpg'), os.path.join(img_out, name + '.jpg')) shutil.copy(os.path.join(src_label, name + '.txt'), os.path.join(label_out, name + '.txt'))seed参数固定随机序列,方便复现。随机划分适合先跑通流程,验证代码和训练链路没问题。但模型调参阶段,我一般会再用按批次划分的方式对比一次:先按文件名里的批次字段分组,按组切分,保证同一批次的图全部落在train或val。这样得到的mAP更接近真实场景的泛化能力。
如果要做更严格的时间序列验证,还可以按帧间隔抽验证集——每连续100帧取5帧做val,其余训练。这种切法对巡检类无人机数据尤其重要,因为连续帧之间背景重叠度太高,随机划分很容易让模型"记住"背景而非无人机本身的特征。
4. 避坑:标好"无人机"的图为什么训出来还是翻车
这个数据集类别单一、标注齐全,看起来非常简单,但恰恰是这种"干净"的数据集最容易让人放松警惕。我在实际跑训过程中遇到过几类高频问题,每一条都是真实翻过车的,按现象、原因、解决的顺序写出来。
4.1 误检:模型把树枝、远处建筑、甚至鸟当成drone
现象:训练完在测试视频里跑,画面上标出一堆绿框,不少框落在树冠边缘、电线杆顶、飞鸟身上。验证集mAP明明不低,实际使用却完全不能用。
原因:这个数据集里无人机目标并不总是占据画面主体,很多图里机体很小,背景占比超过90%。模型学到的不一定是旋翼机体特征,而是"天空/背景交界处有一团深色纹理"。特别是广角镜头下,远处的鸟和无人机在视觉上几乎无法区分。
解决:先做数据统计,看目标框占图片面积的比例分布。如果大量目标框只有几十个像素,优先考虑提高输入分辨率或开启mosaic增强。更实际的做法是收集一批负样本——纯天空、纯地面、有飞鸟但无无人机的图——加入训练集。负样本加入后模型会把"不像无人机"的区域置信度压下去,误检会明显减少。
4.2 小目标漏检:远程无人机的框只有十几个像素,默认锚框吃不下
现象:近距离无人机检测一切正常,距离稍远直接漏检。单张图片里目标明明存在,标签也画出来了,但推理时置信度极低。
原因:YOLO系列默认锚框针对COCO数据集的常见目标尺寸设计,对微小目标不友好。十几像素的框经过多次下采样后,到检测头阶段只剩一两个特征点,特征信息已经丢得差不多了。
解决:把训练输入尺寸从640提高到1280,小目标特征能保留更多。代价是训练时间明显变长,显存占用翻倍。如果显存紧张,选择带P2小目标检测层的模型结构,比单纯拉高分辨率更划算。另外,mosaic增强可以让模型经常看到"被缩小到很小"的目标,这类增强对无人机检测几乎必开。
4.3 验证集指标虚高:同一段飞行轨迹被同时分进train和val
现象:验证mAP达到0.97,模型看似完美。换一段新拍摄的无人机视频来测试,mAP直接掉到0.5以下。
原因:这个问题在随机划分时尤其隐蔽。同一批次拍摄的连续帧,背景几乎一样,无人机飞行姿态也接近,模型在train里见过几乎同款画面,再到val里去测,相当于开卷考试。
解决:不做随机划分,按文件名前缀或拍摄批次划分。训练前先查看有哪些批次前缀,把同一前缀下的帧全部放进同一个集合。用这种划分方式重新评估,得到的mAP才是真实水平。
4.4 标签格式混用:txt和xml坐标单位不一致,转换完框全部偏移
现象:用脚本把xml统一转txt后,训练loss异常偏高,可视化发现很多框贴在目标边缘,甚至一半在画面外。
原因:txt和xml两套标注不是同步更新的,某些图xml里是像素坐标直接填进去的,YOLO解析时当成归一化坐标强制除以图片宽高,框自然错位。
解决:转换前先写一个合法性校验,检查坐标范围是否在合理区间。归一化坐标的宽高必须小于1,像素坐标的xmax不能超过图片宽度。暴露出不一致的标注后,以与图片实际尺寸匹配的那份为准重新生成另一份格式。
def validate_txt_boxes(txt_path, img_w, img_h): with open(txt_path) as f: for line in f: parts = line.strip().split() if len(parts) != 5: print(f'invalid line: {txt_path}: {line}') continue _, cx, cy, bw, bh = map(float, parts) # 归一化坐标合法范围是0到1,越界就是格式错误 if cx < 0 or cx > 1 or cy < 0 or cy > 1: print(f'center out of range: {txt_path}') if bw > 1 or bh > 1: print(f'size out of range: {txt_path}')这个脚本只花几秒钟,能避免后面整轮训练白跑。我在处理多来源数据集时,会把txt和xml先分别统计一遍坐标分布,两者差异超过阈值的图直接隔离出来人工检查。
5. 进阶:从样本统计到NMS阈值:一个更稳的无人机检测调优路径
模型训练完成只是第一步,真正决定无人机检测能不能落地的是两个数字:验证集上目标框的尺寸分布,和推理时NMS阈值的选择。
先跑一个统计脚本,看看标注框的宽度和高度分布情况。
import cv2 import numpy as np from glob import glob stats = [] for txt in glob('val/labels/*.txt'): img_path = txt.replace('labels', 'images')[:-4] + '.jpg' h, w = cv2.imread(img_path).shape[:2] with open(txt) as f: for line in f: _, cx, cy, bw, bh = line.split() stats.append((float(bw) * w, float(bh) * h)) arr = np.array(stats) print('box width p50/p90:', np.percentile(arr[:, 0], [50, 90])) print('box height p50/p90:', np.percentile(arr[:, 1], [50, 90]))p50和p90这两个数字决定了很多训练参数。如果p50框宽在40像素左右,640输入下小目标特征已经很弱,建议上1280分辨率或换带P2层的模型。如果p90框不到200像素,说明几乎没有大目标,不需要为锚框尺寸留太多余量,反而应该把注意力放在低置信度区间的召回上。
NMS阈值方面,我习惯按场景分两套配置。高精度巡检场景,IoU阈值设0.5,置信度阈值0.35以上,优先保证框的位置准确;实时跟踪场景,IoU阈值降到0.45,置信度阈值提高到0.5,宁可漏检也不要误检,因为跟踪器可以靠连续帧互补,但误检会直接带偏轨迹。
真正让我彻底改掉"拿过来就训"习惯的,是有一次数据集中混入了一批夜间红外图。模型训练时loss降得很漂亮,实测时白天场景正常、夜间场景全瞎。从那时起,我每次拿到新数据集都会先跑一遍统计脚本,看一眼尺寸分布、批次划分、坐标合法性这三项,再决定训练参数。这个习惯帮我省掉的重复训练时间,比花在调模型上的时间还多。希望帮到你。
本文还有配套的精品资源,点击获取