
简介无人机检测数据集VOC格式是一套面向目标检测任务的标准数据集专为无人机与鸟类识别场景设计适合计算机视觉初学者、算法工程师及无人机相关课题研究者使用。数据严格遵循PASCAL VOC标注规范每一张JPG图像都配有同名XML文件内含边界框坐标与类别标签可直接接入YOLO、SSD、Faster R-CNN等主流目标检测框架进行训练与评估。压缩包整体共862个文件其中包含427张无人机/鸟类实拍图像、427个XML标注文件以及7个TXT格式的图像列表和1份MD说明文档整体大小29.49MB目录结构清晰Annotations与ImageSets分工明确便于按不同训练阶段快速划分数据。目前已有59人学习下载适合需要获取规范标注数据、开展目标检测实验或验证无人机识别算法的读者。使用该数据集可以省去自行采集与标注的繁琐过程直接聚焦模型构建、参数调优与性能验证有效提升算法研发与项目落地效率。1. 无人机检测数据集VOC格式先认清这份数据到底长什么样无人机检测数据集VOC格式是很多做无人机视角目标检测的人拿到的第一份训练数据。VOC格式把图像和标注拆成 JPEGImages 与 Annotations 两套文件夹再用 XML 记录每张图里的目标类别和边框坐标。它不像 COCO 要解析 JSON也不像 YOLO 那样每张图配一个 txt本质上是「目录约定 XML 字段约定」的打包方式。这篇笔记写给三类人刚下载完数据集不知道怎么下手的初学者准备把 VOC 转成 YOLO 格式训练的老手以及想在训练前避开脏数据坑的一线工程师。我会按格式拆解、数据体检、格式转换、踩坑排查、可视化验证这条线把一套能直接复用的处理流程逐段讲清楚。2. 拆开 VOC 数据集的目录结构XML 标注与无人机视角的两个特点2.1 目录结构和文件命名JPEGImages、Annotations、ImageSets 各管什么VOC 格式的目录约定源于 Pascal VOC 竞赛到今天仍然被大量数据集沿用。拿到一份 VOC 格式的无人机检测数据集第一件事不是打开图片而是先看目录骨架。标准结构长这样dataset/ ├── JPEGImages/ │ ├── 000001.jpg │ ├── 000002.jpg │ └── ... ├── Annotations/ │ ├── 000001.xml │ ├── 000002.xml │ └── ... └── ImageSets/ └── Main/ ├── train.txt ├── val.txt ├── trainval.txt └── test.txtJPEGImages 放原始图像Annotations 放与图像同名的 XML 标注文件ImageSets/Main 下的 txt 文件则保存参与训练、验证、测试的样本文件名不带扩展名。这个划分里最容易忽略的是 ImageSets很多从网络爬取后自制的无人机数据集根本没有这个目录只有图片和 XML划分要靠自己写脚本。而有 ImageSets 的数据集训练时可以直接按 txt 列表加载省得自己随机切分也更容易复现别人的实验。文件命名同样有讲究。正规数据集会用六位数字编号比如 000001.jpg但也有不少数据集的文件名带拍摄时间戳或地理位置信息比如DJI_20240512_143022.jpg。文件名里带空格或中文时后续转格式、写清单、跨平台拷贝都很容易出问题我一般会在体检阶段就把这类文件统一重命名。2.2 XML 标注字段逐行解读bndbox 是核心但不是全部VOC 的 XML 文件是标注信息的唯一载体用文本编辑器打开一份典型的标注文件结构大致如下annotation folderJPEGImages/folder filename000001.jpg/filename source databaseUAV Detection Dataset/database /source size width1920/width height1080/height depth3/depth /size segmented0/segmented object namecar/name poseUnspecified/pose truncated0/truncated difficult0/difficult bndbox xmin256/xmin ymin378/ymin xmax310/xmax ymax420/ymax /bndbox /object /annotationfilename与图像文件名一一对应size里的宽高是坐标校验的基准object下的name是类别名bndbox是目标边框的左上角和右下角坐标。无人机检测数据集里truncated和difficult两个字段往往被标注人员忽略默认填 0但这两个字段在评估时很关键——Pascal VOC 的 mAP 计算会排除difficult1的目标如果你准备拿这类数据集复现论文指标就得先确认评估代码是否遵守了这个约定。需要注意的是不少无人机数据集的 XML 里还会出现自定义字段比如occluded、ignore、source。这些字段不影响 YOLO 转换但会影响你后续的数据清洗策略。比如ignore1的目标标注框可能本身就是错的或者目标太小无法辨认这类样本在训练时要么排除要么单独处理。2.3 无人机视角与自然图像的本质差异小目标、大图、俯拍把 VOC 格式的无人机数据集和普通路测数据集放在一起对比你会发现三个显著差异。第一是图像尺寸普遍偏大常见 1920×1080很多数据源直接给到 4K 以上第二是目标绝对像素小同一辆卡车在路测数据集里可能占 300×200 像素在无人机俯拍图里往往只有 40×30 甚至更小第三是视角单一几乎全是俯视或大角度斜视目标姿态变化剧烈。这三个差异直接决定了后续每一步的技术选型。大图意味着训练时不能整图缩放否则小目标会缩到几个像素检测器根本学不到特征目标像素小意味着 anchor 设计、图像切片、损失函数权重都要重新考虑俯拍视角则意味着背景和目标的纹理特征与自然图像差异很大用 COCO 预训练权重直接 fine-tune 效果往往不理想。后面第 4 章、第 6 章的内容本质上都是围绕这三个特点展开的。2.4 用 Python 批量读取 XML三分钟摸清数据集的成色不了解数据集的类别分布和标注质量之前不建议直接开始训练。我一般会用一段短脚本把所有 XML 解析一遍先把「数据集里到底有哪些类别、每类多少个框、图片尺寸分布怎样」这几件事搞清楚。import xml.etree.ElementTree as ET from collections import Counter from pathlib import Path xml_dir Path(Annotations) cat_counter Counter() img_sizes [] for xml_path in sorted(xml_dir.glob(*.xml)): root ET.parse(xml_path).getroot() w int(root.findtext(size/width)) h int(root.findtext(size/height)) img_sizes.append((w, h)) for obj in root.iter(object): name obj.findtext(name).strip() cat_counter[name] 1 print(类别数量:, len(cat_counter)) print(各类目标数:, cat_counter.most_common()) print(图片尺寸分布:, Counter(img_sizes).most_common(5))这段脚本用了 Python 自带的xml.etree.ElementTree不需要安装额外依赖。findtext(size/width)是直接按路径取子节点文本比find再text少写两行iter(object)会遍历所有嵌套的 object 节点即使某个 XML 里存在多层结构也不会漏掉。运行后如果发现类别列表里混着Car和car两种写法或者类别有十几个而你的任务只需要其中三个那就得在转换前做一次类别归一化。3. 数据体检训练前先把脏数据筛出来3.1 为什么体检是必须的标注文件也是人写的很多无人机数据集不是人工逐张标注的而是通过半自动标注工具、跟踪算法或者爬虫脚本生成的。这类数据的标注错误率比纯人工标注高不少常见问题包括边框画到图像边界外、目标类别标错、同一张图重复标注、图像旋转后标注没跟着旋转。这些问题不会直接报错而是让模型在训练时学到错误特征表现为验证集 loss 不降、mAP 忽高忽低、个别类别完全无法收敛。如果你拿到的数据集是别人整理好的 VOC 格式里面的标注已经被清洗过一轮体检仍然值得做——至少能确认类别列表和你预想的一致确认训练集和验证集没有交叉。把体检当作训练前的固定步骤能省掉后面大量排查时间。3.2 一份完整的体检脚本文件对应、坐标越界、类别分布、目标面积我把体检脚本拆成两段。第一段检查文件对应关系也就是 JPEGImages 和 Annotations 两个目录是否一一匹配from pathlib import Path img_dir Path(JPEGImages) xml_dir Path(Annotations) imgs {p.stem: p for p in img_dir.iterdir() if p.suffix.lower() in (.jpg, .jpeg, .png)} xmls {p.stem: p for p in xml_dir.glob(*.xml)} missing_xml sorted(set(imgs) - set(xmls)) missing_img sorted(set(xmls) - set(imgs)) print(f图片数量: {len(imgs)}) print(f标注数量: {len(xmls)}) print(f有图无标注: {len(missing_xml)}, 示例: {missing_xml[:3]}) print(f有标注无图: {len(missing_img)}, 示例: {missing_img[:3]})这段逻辑的核心是把图片和 XML 的文件主名提取成集合再做差集判断。p.stem取的是不含扩展名的文件名能同时处理.jpg和.jpeg的混用情况。如果差集不为空说明数据本身不完整这时候直接进入转换会非常危险要先把缺失的文件补齐或排除。第二段检查每个 XML 里的坐标合法性和类别分布import xml.etree.ElementTree as ET from collections import Counter, defaultdict xmls {p.stem: p for p in Path(Annotations).glob(*.xml)} bad_boxes [] # 记录越界或非法坐标 empty_imgs [] # 记录没有目标的图 cat_counter Counter() # 类别目标数 area_buckets Counter() # 目标面积分桶 def bucket(area): if area 16 * 16: return 16px if area 32 * 32: return 16-32px if area 96 * 96: return 32-96px return 96px for stem, xml_path in xmls.items(): root ET.parse(xml_path).getroot() img_w int(root.findtext(size/width)) img_h int(root.findtext(size/height)) objs list(root.iter(object)) if not objs: empty_imgs.append(stem) continue for obj in objs: name obj.findtext(name).strip() cat_counter[name] 1 box obj.find(bndbox) xmin float(box.findtext(xmin)) ymin float(box.findtext(ymin)) xmax float(box.findtext(xmax)) ymax float(box.findtext(ymax)) if xmin 0 or ymin 0 or xmax img_w or ymax img_h \ or xmin xmax or ymin ymax: bad_boxes.append((stem, name, (xmin, ymin, xmax, ymax))) area (xmax - xmin) * (ymax - ymin) area_buckets[bucket(area)] 1 print(越界/非法框数量:, len(bad_boxes)) print(空标注图数量:, len(empty_imgs), 示例:, empty_imgs[:5]) print(类别统计:, cat_counter.most_common()) print(面积分桶:, dict(area_buckets))关于面积分桶的阈值我是按无人机检测的实际经验设的小于 16×16 像素的目标属于极难样本检测器基本只能靠上下文线索猜测16~32 像素是一般小目标32~96 是中等目标大于 96 像素在无人机俯拍图里已经算大目标了。如果32px的占比超过一半后面训练就必须考虑图像切片或者专门调 anchor。3.3 体检报告怎么看三个需要当场决定的问题体检脚本跑完输出一堆数字关键是怎么解读。第一missing_xml或missing_img大于零处理办法不是把它们删掉而是先看能不能补如果无法补齐就在生成清单时排除这些文件避免训练时 dataloader 报错。第二bad_boxes超过总数 1%说明标注坐标系统有问题常见原因是图像经过缩放后标注没同步更新这时候不要硬着头皮转格式先找到出问题的数据源。第三empty_imgs不为零时这些图基本是背景图或纯遮挡图在目标检测里可以作为负样本但前提是你的训练框架支持负样本采样否则要单独处理。面积分桶是最容易被忽略的体检项。如果小目标占比极高直接套用默认 anchor 和默认输入尺寸mAP 会非常难看。此时要么在预处理阶段做切片要么对损失函数做类别和尺度权重这些决定应该在拿到体检报告时就定下来而不是等训练完才补救。4. 从 VOC 转成 YOLO 训练格式转换脚本与四个关键点4.1 为什么训练前多数时候要转换格式VOC 格式本身是可以直接训练检测器的MMDetection 和 Detectron2 都内置了 VOC 数据集的加载器。但现实中做无人机检测更多人用的是 YOLO 系列的训练流程尤其是 YOLOv5、YOLOv8 这种生态。YOLO 训练需要每张图片对应一个同名 txt 标注文件内容每一行是一个目标字段依次是「类别序号 归一化中心x 归一化中心y 归一化宽 归一化高」。从 VOC 转 YOLO 的收益不只是格式匹配。YOLO 的增强管线、anchor 计算、mAP 评估都是围绕 txt 标签设计的很多无人机检测的开源复现项目也直接把 VOC 标签转成 YOLO 格式共享。转换本身不难难的是在转换过程中保持坐标正确、类别序号不错位、划分边界不串场。4.2 转换脚本逐段拆解XML 坐标如何变成归一化坐标下面这段是我常用的转换脚本可以一次转换整个数据集import xml.etree.ElementTree as ET from pathlib import Path # 类别顺序即 classes.txt 的顺序务必与训练配置的 data.yaml 一致 CLASSES [car, truck, bus, person] def voc2yolo(xml_path: Path, label_dir: Path, classes: list): root ET.parse(xml_path).getroot() img_w int(root.findtext(size/width)) img_h int(root.findtext(size/height)) lines [] for obj in root.iter(object): name obj.findtext(name).strip() if name not in classes: print(f[skip] {xml_path.name}: 未定义类别 {name}) continue cls_id classes.index(name) box obj.find(bndbox) xmin float(box.findtext(xmin)) ymin float(box.findtext(ymin)) xmax float(box.findtext(xmax)) ymax float(box.findtext(ymax)) # 坐标裁剪到图像边界防止训练时越界框触发异常 xmin max(0, min(xmin, img_w - 1)) xmax max(0, min(xmax, img_w - 1)) ymin max(0, min(ymin, img_h - 1)) ymax max(0, min(ymax, img_h - 1)) if xmin xmax or ymin ymax: continue x_center (xmin xmax) / 2 / img_w y_center (ymin ymax) / 2 / img_h w_norm (xmax - xmin) / img_w h_norm (ymax - ymin) / img_h lines.append(f{cls_id} {x_center:.6f} {y_center:.6f} {w_norm:.6f} {h_norm:.6f}) if lines: label_dir.mkdir(parentsTrue, exist_okTrue) (label_dir / (xml_path.stem .txt)).write_text(\n.join(lines), encodingutf-8) xml_dir Path(Annotations) label_dir Path(labels) for xml_path in sorted(xml_dir.glob(*.xml)): voc2yolo(xml_path, label_dir, CLASSES) print(转换完成)核心逻辑就三行算中心点坐标除以图像宽高完成归一化拼字符串写文件。需要注意x_center (xmin xmax) / 2 / img_w的运算顺序先算像素中心再归一化顺序不能反过来。我加了坐标裁剪逻辑把越界框压回图像边界内这比直接抛异常更稳但裁剪比例过高的数据集要回溯检查标注质量。CLASSES列表顺序直接决定 txt 里类别序号的编号这是整个转换过程最需要小心的地方。如果训练配置里的 data.yaml 类别顺序和这个列表不一致模型训练时不会报错但推理输出的类别和你的预期完全对不上。我的习惯是从第 3 章体检脚本的输出里直接复制cat_counter.most_common()的类别列表再人工确认一遍顺序而不是手敲。4.3 划分清单生成训练/验证/测试如何对齐 ImageSetsVOC 数据集自带 ImageSets/Main 下的划分文件时转换脚本要严格按照划分生成清单。我的做法是把划分文件和转换结果合并处理# 以 val.txt 为例为每个文件名生成对应的图片和标签路径 sed s|^|JPEGImages/|; s|$|.jpg| ImageSets/Main/val.txt val_images.txt sed s|^|labels/|; s|$|.txt| ImageSets/Main/val.txt val_labels.txt如果数据集没有 ImageSets那就要在转换后自己划分。划分的原则是不要按文件名顺序机械地取前 80%因为无人机数据集的图像常常按航迹排序相邻帧高度相关直接切分会导致训练集和验证集出现数据泄漏。常见做法是先按场景或视频片段分组再对组做随机划分确保同一个场景的连续帧不会同时出现在训练和验证里。划分完成后建议把 train.txt 和 val.txt 里的文件名做一次交集检查交集为空是最低要求。更进一步检查同一目标是否在相邻帧反复出现并跨越了划分边界这类问题无人机数据集里很常见属于数据泄漏的隐蔽形态。最稳妥的做法是找轨迹 ID 或者时间戳字段但这取决于数据集的原始组织方式如果没有这些信息至少做文件名层面的去重。5. VOC 格式无人机数据集的五个坑从现象到排查方案5.1 类别穷举不全转换后目标全部变成 background现象转换脚本跑完没有报错训练也能正常启动但训练几个 epoch 后 loss 居高不下可视化预测结果时发现模型把所有目标都预测成背景。原因转换脚本里的CLASSES列表没穷举数据集里的全部类别。比如数据里有truck和van但你的列表只有car和person转换函数里if name not in classes会把这些目标静默跳过相当于标注凭空消失。更隐蔽的情况是类别名带着前后空格或大小写不一致比如Car、car、car被当成三种类别。解决转换前先跑第 3 章体检脚本把cat_counter.most_common()的输出与CLASSES逐项对比。对比时先做.strip()和.lower()归一化再决定是否合并同义词。转换脚本里遇到未知类别时不要只打印一行就继续建议收集未知类别并统计数量转换结束后统一检查。5.2 bndbox 坐标越界硬裁剪还是回查数据现象体检脚本报了少量xmax img_w或ymax img_h转换脚本做了坐标裁剪训练也没有报错但验证阶段有个别类别的 Precision 和 Recall 波动剧烈。原因越界框通常来自标注工具的边界吸附误差或者图像 resize 后标注未同步。裁剪看似解决了问题但当一个框的大部分面积在图像外时裁剪后剩余区域已经不含完整的语义信息模型学到的是残缺的目标特征。解决越界框数量少比如 1% 以内且超出范围不超过 10 像素时裁剪即可。越界比例超过 1%或者单个框超出图像边界超过 50 像素需要回到源头检查图像和标注是否来自同一版本。常见做法是检查这些异常图片的 acquisition time 或文件名编号如果是批量生成的数据很可能整批都需要重新处理。5.3 图像与标注文件数量对不上黑匣子里的脏文件名现象数据加载时报FileNotFoundError报错信息指向某张图片或某个 txt 文件找不到但打开目录看文件明明存在。原因Linux 下使用训练框架时常见的有两类问题。一类是文件名大小写不一致Windows 上生成的AbC.jpg在 Linux 下写成了abc.jpg另一类是图片扩展名混用比如同一个数据集里既有.jpg又有.jpeg但划分清单里只写了.jpg。还有更隐蔽的情况——文件名里带了不可见字符或全角空格肉眼根本看不出来。解决在划分清单生成后用脚本做一次批量校验逐一检查清单里的文件路径是否存在。我一般会把train.txt和val.txt里的每一行喂给pathlib.Path调用.exists()做断言任何一行失败就打印文件名和序号。这个步骤看起来多余但能拦截掉相当一部分「训练到一半才炸」的问题。5.4 小目标扎堆导致 anchor 失效现象训练时 loss 曲线下降正常但评估时 mAP0.5 能到 0.6mAP0.5:0.95 只有 0.15小目标类别基本不召回。原因无人机俯拍图里大量目标只有 20~40 像素YOLO 的默认 anchor 是根据 COCO 数据集聚类出来的主要覆盖 40 像素以上的目标。当训练图被缩放到 640×640 输入尺寸后原始 30×30 像素的目标可能缩到不足 20 像素正好落在 anchor 的覆盖盲区。检测器不是不学而是学到的特征根本无法通过默认 anchor 回归到正确尺度。解决按第 3 章面积分桶的统计结果决定策略。如果小于 32 像素的目标超过三成先做图像切片把大图切成 1024×1024 的 patch再在 patch 级别做训练这比调 anchor 更直接。如果不做切片至少要关掉 autoanchor 的默认聚类改用本数据集 GT 框的聚类结果并把最小 anchor 尺寸调小到 4×4 或 8×8 级别。请记得验证集评估时要使用相同切片策略否则评估结果没有参考意义。5.5 把 difficult 和 truncated 目标当普通框转进 YOLO现象实验报告里 mAP 比别人的结果低 5 个点而且差的点集中在遮挡严重的类别上。原因VOC 的评估协议里difficult1的目标不参与 mAP 计算很多数据集的难样本、极模糊样本都被标成difficult1。如果你在转换时不管这个字段把这些框全部当成普通目标训练和评估模型被迫拟合一堆本身无法辨识的标注mAP 自然被拉低。truncated1的目标则情况相反它表示目标超出画面边界框的形状本身不完整。解决转换前先明确自己的评估目标。如果追求的是和 VOC 官方协议对齐的指标就在转换脚本里加一行判断把difficult1的 object 直接跳过。如果想提升模型在真实无人机巡检中的表现建议把difficult1的目标保留在训练集中但评估时按 VOC 协议排除它们。注意这个决定要在训练前做好并写进实验记录否则拿到结果后临时改标签整个实验就得重跑。提示第 5.1 和第 5.5 这两类问题恰恰最难排查因为训练过程完全正常只有到了评估阶段才发现指标不对。建议在转换脚本里加一个统计输出每次转换都打印「跳过多少 difficult 目标、跳过多少未知类别」留痕可比对着查。6. 进阶验证把 GT 框画回原图做小目标筛查6.1 随机抽 100 张把标注框叠到原图上转换完成后我第一次做的不是训练而是可视化抽查。用 PIL 把 XML 里的 bndbox 画回原图生成一张带框的 JPEG随机抽 100 张人工过目。这个步骤能发现体检脚本发现不了的问题——比如框和目标的语义错位、同一目标重复标注、标注了阴影或倒影等。from PIL import Image, ImageDraw import xml.etree.ElementTree as ET import random xmls list(Path(Annotations).glob(*.xml)) random.seed(42) for xml_path in random.sample(xmls, min(100, len(xmls))): root ET.parse(xml_path).getroot() img Image.open(fJPEGImages/{root.findtext(filename)}).convert(RGB) draw ImageDraw.Draw(img) for obj in root.iter(object): name obj.findtext(name) box obj.find(bndbox) xy (float(box.findtext(xmin)), float(box.findtext(ymin)), float(box.findtext(xmax)), float(box.findtext(ymax))) draw.rectangle(xy, outline(255, 0, 0), width3) draw.text((xy[0], xy[1] - 10), name, fill(255, 0, 0)) img.save(fcheck/{xml_path.stem}_gt.jpg)这段脚本每张图保存一个带 GT 框的副本没有标注的图也会保存原图方便确认哪些是空图。抽查的重点不是看框画得准不准而是看类别语义对不对——无人机俯拍图里人、车、树影在俯视角度下很难区分如果标注人员把树影也标成了 person模型会学到一大堆错误特征。人工抽查很枯燥但它比任何自动化指标都早发现问题。6.2 目标尺寸散点图小目标分布一眼看清体检脚本里的面积分桶是聚合统计我还习惯再画一张目标宽高散点图把每个目标的宽和高画成二维点横纵轴用对数刻度。这样能直观看出目标尺度是单峰还是双峰分布以及小目标是否集中在某个固定宽度区间。import matplotlib.pyplot as plt ws, hs [], [] for xml_path in Path(Annotations).glob(*.xml): root ET.parse(xml_path).getroot() for obj in root.iter(object): box obj.find(bndbox) ws.append(float(box.findtext(xmax)) - float(box.findtext(xmin))) hs.append(float(box.findtext(ymax)) - float(box.findtext(ymin))) plt.figure(figsize(8, 6)) plt.scatter(ws, hs, s1, alpha0.3) plt.xscale(log) plt.yscale(log) plt.xlabel(target width (px)) plt.ylabel(target height (px)) plt.grid(True, whichboth, linestyle--, linewidth0.5) plt.savefig(target_size_dist.png, dpi150)散点图在 log 坐标下呈线性分布时说明目标尺度跨度大需要多尺度训练策略如果点密集在左下角说明小目标问题严重图像切片优先级最高。我见过一份数据集在 16~24 像素区间聚集了 40% 以上的目标按常规流程训练出来的模型在真实场景里基本无法部署。这时候如果时间紧另一个常用技巧是修改数据增强里的随机缩放范围把最小缩放比例提高到 0.5相当于把原始小目标在训练时放大一倍。6.3 切片训练前的必要准备如果决定对大图做切片先确认三件事。第一切片尺寸要匹配目标尺度1024×1024 的 patch 比 512×512 保留更多上下文无人机图像里的目标通常需要周围背景辅助识别patch 太小反而降低精度。第二切片重叠率建议 10%~20%否则目标正好落在切片边缘时会被截断。第三切片后要把标注坐标换算到 patch 坐标系并且过滤掉面积小于原始目标 30% 的截断目标这些目标在 patch 里已经不可辨识。这几个细节做完我才觉得一套 VOC 格式的无人机检测数据真正到了可以训练的状态。从目录拆解到体检、转换、可视化每一步都在为后面的训练省时间。VOC 格式本身不难难的是数据质量和格式转换里那些不会报错的隐性错误。希望这些流程和坑能让你少走一次弯路。本文还有配套的精品资源点击获取