
简介面向计算机视觉目标检测任务的风筝kite专用数据集共包含2260张真实场景图片所有目标均以矩形框标注为单一类别标注框总数达8790个。资源同时提供Pascal VOC和YOLO两种标准格式VOC格式的XML文件便于使用XML解析的检测框架读取YOLO格式的TXT文件可直接用于YOLO系列模型训练省去手动转换环节。压缩包内共2000个文件以XML标注文件为主体1998个另附使用说明等TXT文档整体约268MB结构简洁配合标注工具labelImg的规范说明方便核对标注细节。当前已有76人学习下载适合作为高校实验、算法竞赛或风筝识别项目的训练语料也可用作验证不同尺度目标检测效果的基准数据。数据集自带的标注质量信息完整便于使用者快速投入模型迭代。1. 这是一份带双格式标注的风筝检测数据集先看数据再谈训练拿到手的时候我以为它只是普通的Pascal VOC格式目标检测数据集结果解压完发现每个jpg旁边都跟着同名的xml和txt——VOC和YOLO两种格式一次性给齐了。整理下来一共2260张图片、1个类别kite、8790个标注框全部用labelImg手工画的矩形框。做无人机巡检、天空目标识别、或者单纯想入门YOLO训练流程的这份数据都能直接用。它的价值不在数据量大而在格式干净VOC转YOLO最耗时的转换环节它用双格式直接绕过了。但也别高兴太早它没有给train/val/test的划分文件目录结构需要自己重建这个后面我会说清楚。这篇笔记会把解压核对、格式转换、目录划分、YOLOv8训练、以及我踩过的几个坑完整过一遍。2. 解压与核对看懂VOC和YOLO两套文件的三件套结构2.1 解压后先看这两个文件版权声明与使用前必读压缩包解压后第一眼看到的是两个txt一个是版权声明一个是使用前必读。我建议别跳过先花两分钟看完再碰数据。里面通常会写明数据用途限制、标注工具版本、以及一个比较容易忽略的点这套数据集的标注只负责类别和框位置不保证训练出来的模型精度。这也是我拿到任何数据集的第一习惯——先看说明文档再看数据结构。直接跳过去训练后面出问题你会浪费更多时间。数据集根目录下每条样本是三个文件同名的组合命名规律一眼就能看明白例如firc_kite_1776.jpg、firc_kite_1776.xml、firc_kite_1776.txt编号前缀方便回滚溯源大批量数据集用这种命名法很常见。2.2 jpg/xml/txt三件套的对应关系与数量核对脚本先说一个重要区别摘要里写的“不包含分割路径的txt文件”是指没有train.txt、val.txt这种记录图片路径的清单文件而不是说每张图片没有标签txt。恰恰相反每个jpg旁边都配了一个YOLO格式的txt标签文件。所以这个数据集的实际构成是jpg图片、VOC格式xml、YOLO格式txt三件套一一对应。动手前先跑一段统计脚本确认三者数量对得上。import os from collections import defaultdict data_dir firc-dataset files os.listdir(data_dir) jpg [f for f in files if f.lower().endswith(.jpg)] xml [f for f in files if f.lower().endswith(.xml)] txt [f for f in files if f.lower().endswith(.txt)] jpg_base {os.path.splitext(f)[0] for f in jpg} xml_base {os.path.splitext(f)[0] for f in xml} txt_base {os.path.splitext(f)[0] for f in txt} print(fjpg 数量: {len(jpg)}) print(fxml 数量: {len(xml)}) print(ftxt 数量: {len(txt)}) print(f三者同名交集: {len(jpg_base xml_base txt_base)}) print(fjpg 有而 xml 缺失: {len(jpg_base - xml_base)}) print(fjpg 有而 txt 缺失: {len(jpg_base - txt_base)})这段脚本逻辑很简单但很实用。endswith后缀过滤把三类文件分开os.path.splitext取文件名主干的集合做交集。正常结果应该是三个2260交集也是2260缺失数都是0。如果集合差非空说明这份数据本身有残缺先别训练找源头补文件。2.3 读一条xml看看kite框到底怎么记的下载下来的xml是labelImg的标准VOC输出格式。打开任意一条结构类似这样。annotation folderfirc-dataset/folder filenamefirc_kite_1776.jpg/filename size width1920/width height1080/height depth3/depth /size object namekite/name poseUnspecified/pose truncated0/truncated difficult0/difficult bndbox xmin482/xmin ymin215/ymin xmax628/xmax ymax402/ymax /bndbox /object /annotation这是标准的Pascal VOC标注结构。size里的宽高对应图片原尺寸bndbox里的四个值是矩形框的绝对像素坐标左上角和右下角。一个xml里可能有多个object块每个代表一个kite框2260张图一共8790个框平均每张约3.9个框。对应的YOLO格式txt文件长这样0 0.289062 0.285648 0.076042 0.173148这行的含义是类别ID为0然后依次是归一化后的中心点x、中心点y、框宽、框高公式是框中心/图宽和框宽/图宽所有值都在0到1之间。这也是YOLO系列训练时真正读取的标签格式。注意xml里的size字段在转换时能直接用但前提是xml记录的分辨率和实际jpg一致。如果不一致后面转换就会出错这个坑我在第5章专门说。3. 从VOC到YOLO的坐标转换归一化与train/val划分脚本3.1 为什么VOC坐标不能直接喂给YOLO很多刚接触YOLO的人会问一个问题数据集里已经有txt了为什么还要看xml如果你的训练目标是把这份数据跑通那确实可以直接用txt。但如果你手里只有VOC格式数据或者想验证txt标注是否正确就必须理解VOC和YOLO两套坐标体系的差异。VOC的bndbox是绝对像素值xmin可能是482ymin可能是215这依赖具体图片的分辨率。而YOLO的标签需要相对坐标归一化到0到1之间。YOLO在训练时会把输入图片缩放到固定的imgsz尺寸比如640×640如果标签还是绝对像素值缩放后框的位置就全错了。还有个概念要区分这是水平矩形框检测不涉及旋转框。如果你接触过mmrotate、DOTA这类遥感数据集那些还需要多一个angle参数。这份kite数据是普通的axis-aligned框YOLO系模型直接能接。3.2 一个可直接用的VOC转YOLO转换脚本转换公式其实就四行中心点x等于(xmin xmax) / 2 / 图宽中心点y等于(ymin ymax) / 2 / 图高宽度等于(xmax - xmin) / 图宽高度等于(ymax - ymin) / 图高。注意这里有个关键细节分母必须是图片真实尺寸最好别盲信xml里的size而是从jpg本身读取。import os import xml.etree.ElementTree as ET from PIL import Image def get_image_size(img_path): with Image.open(img_path) as im: return im.size # 返回 (width, height) def voc_to_yolo(xml_path, img_path): tree ET.parse(xml_path) root tree.getroot() img_w, img_h get_image_size(img_path) lines [] for obj in root.findall(object): name obj.find(name).text box obj.find(bndbox) xmin float(box.find(xmin).text) ymin float(box.find(ymin).text) xmax float(box.find(xmax).text) ymax float(box.find(ymax).text) cx (xmin xmax) / 2.0 / img_w cy (ymin ymax) / 2.0 / img_h bw (xmax - xmin) / img_w bh (ymax - ymin) / img_h class_id 0 # 本数据集只有 kite 一个类别 lines.append(f{class_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) return lines data_dir firc-dataset out_dir yolo_labels os.makedirs(out_dir, exist_okTrue) bad [] for xml_name in os.listdir(data_dir): if not xml_name.endswith(.xml): continue base os.path.splitext(xml_name)[0] xml_path os.path.join(data_dir, xml_name) img_path os.path.join(data_dir, base .jpg) if not os.path.exists(img_path): bad.append(base .jpg missing) continue lines voc_to_yolo(xml_path, img_path) with open(os.path.join(out_dir, base .txt), w, encodingutf-8) as f: f.write(\n.join(lines)) print(f{base} - {len(lines)} boxes) print(缺图片的文件:, bad if bad else 无)这段脚本有几个值得注意的参数行为。get_image_size用的是PIL读真实分辨率而不是直接取xml的size因为图片可能在标注后被重采样过。class_id硬编码为0因为这份数据只有一个kite类别如果以后加入其他类别这里要改成name到ID的映射字典。输出格式用了:.6f保留6位小数YOLO训练对这个精度完全够用。3.3 train/val/test划分同步复制三件套的脚本原数据把所有文件都平铺在根目录里这是训练前必须处理的一步。Ultralytics YOLO的默认目录约定是images/train、labels/train这种结构图片和标签分开放。划分的原则是随机、分层、同步复制。三条线要同时走图片进了train同名的xml和txt也必须进train一旦标签和图片错位后面出来的mAP就是假的这种问题最阴间因为训练不报错只有结果异常。import os import random import shutil data_dir firc-dataset target firc-split random.seed(7) # 固定随机种子保证划分可复现 imgs [ f for f in os.listdir(data_dir) if f.lower().endswith(.jpg) and os.path.exists(os.path.join(data_dir, os.path.splitext(f)[0] .txt)) ] random.shuffle(imgs) n len(imgs) n_train int(n * 0.8) n_val int(n * 0.1) split { train: imgs[:n_train], val: imgs[n_train:n_train n_val], test: imgs[n_train n_val:], } for part, items in split.items(): img_dir os.path.join(target, part, images) lab_dir os.path.join(target, part, labels) os.makedirs(img_dir, exist_okTrue) os.makedirs(lab_dir, exist_okTrue) for img_name in items: base os.path.splitext(img_name)[0] shutil.copy2(os.path.join(data_dir, img_name), os.path.join(img_dir, img_name)) shutil.copy2(os.path.join(data_dir, base .txt), os.path.join(lab_dir, base .txt)) print(part, len(items)) # 校验每个子集的 images 和 labels 数量必须一致 for part in split: img_count len(os.listdir(os.path.join(target, part, images))) lab_count len(os.listdir(os.path.join(target, part, labels))) status OK if img_count lab_count else MISMATCH print(f{part}: images{img_count}, labels{lab_count} - {status})比例我通常用80/10/10train、val、test分别是1808、226、226张。random.seed(7)固定住这样换台机器重跑划分结果一致排查问题时有后悔药吃。注意脚本里只复制jpg和txt到目标目录xml没有复制过去——训练阶段Ultralytics只需要txtxml留着是喂给转换脚本用的。如果你还想保留VOC原件顺手把xml也复制到labels之外的备份目录。4. 用YOLOv8训练自己的数据集配置yaml与关键参数调优4.1 目录结构重建与data.yaml配置第3章的划分脚本跑完后目录结构应该是这个样子的这是Ultralytics YOLO的标准布局。确认无误后写data.yaml来告诉训练器数据在哪、类别叫什么。path: /绝对路径/firc-split # 改成你自己的实际路径 train: train/images val: val/images test: test/images nc: 1 names: 0: kitepath字段是整个配置的根。train和val填的是相对path的子目录写的是train/images而不是train/labels因为训练器会自动在同级目录下找labels。nc是类别数这里只有kite一个names列表和txt里的类别ID一一对应。注意path尽量不要用相对路径尤其是你在多个目录下切换终端时相对路径容易飘。4.2 训练参数选择模型复杂度、imgsz、batch、epochs配置写好后就可以开训了。我一般第一个版本不会直接上大模型先用小模型把流程跑通确认数据没有问题再根据结果往上加复杂度。yolo detect train modelyolov8n.pt datakite.yaml epochs100 imgsz640 batch16 device0参数推荐值说明modelyolov8n.pt / yolov8s.ptn最轻适合先验证流程s精度更高显存够用就上epochs100起步配合早停机制连续几十轮val不涨会自动停imgsz640 / 960 / 1280风筝是小目标imgsz太低会漏检代价是显存和速度batch8~32取决于显存11G显存跑s模型建议8或16device0指定GPU没有GPU就填cpu但训练会非常慢训练日志里重点关注三列lossbox_loss、cls_loss、dfl_loss。这就是YOLO损失函数的三个组成部分——框回归用CIoU相关损失分类用BCE分布聚焦损失负责边界质量。它们的整体趋势应该是稳步下降如果某条loss在一百多个epoch后仍然不降大概率是标签有问题回头去查第3章的转换结果。imgsz这个参数在风筝检测场景里很关键。小风筝在远景天空背景下往往只有几十个像素640分辨率训练时框可能就落在1~2个网格里特征很弱。我一般会在s模型上用960起步如果发现漏检多再上1280。4.3 单类别标注的三个特殊点这份数据只有一个kite类别看起来最简单但单类别场景有三件事容易翻车这里单独说。第一类别ID必须从0开始。txt标签里那个第一列数字0对应names[0]也就是kite。如果你从labelImg手工补过标签不小心写成1而yaml里nc: 1类别索引直接越界训练时损失会异常val的mAP大概率是0。检查方法很简单awk {print $1} firc-split/train/labels/*.txt | sort -u看输出是不是只有0。第二names顺序要和训练时一致。如果以后你把数据扩成多类别kite是第0类还是第1类这个顺序定义了整个训练任务yaml里怎么写预测结果就怎么解释错位后你拿到一个类别ID根本不知道对应什么。第三xml里的difficult字段要不要过滤。这取决于标注规则。如果原始voc里把严重遮挡、极小目标标成了difficult转换时通常可以选择跳过它们减少训练时的噪声。这份数据的说明里没提difficult的特殊处理我默认全部保留。如果你的场景对误检很敏感可以加一句判断if int(obj.find(difficult).text) 1: continue把这些难例排除掉。5. 避坑排查数据与训练阶段的五条复现记录5.1 训练正常但mAP一直为0先查标签坐标是否归一化现象loss在下降train阶段看起来一切正常但val的mAP50在几十个epoch里都是0预测时所有框都不对。原因txt标签的坐标没有做归一化直接写成了VOC的绝对像素值。YOLO在计算IoU时预测框是0到1的相对坐标标签框可能是0到1920的像素值两者根本不在一个数量级IoU永远接近0。解决用第3章的转换脚本重新生成txt然后随便打开一个txt文件检查五个数值必须全部在0到1之间。这一步不要用肉眼抽查三五个就完事写个循环统计一下全局最大值。awk {for(i2;i5;i) if($i0 || $i1) print FILENAME: $0} firc-split/train/labels/*.txt | head -20输出为空说明坐标范围正常可以继续训练。这个坑在手工转换或网上随便找的转换脚本里概率极高100张图里哪怕有1张没转换对负样本噪声就够模型喝一壶的。5.2 报错No labels found缓存文件与目录路径的坑现象训练一启动Ultralytics直接报No labels found in ...或者提示找不到图片。原因目录结构不符合约定比如图片在images/train但标签被放到了train/labels之外的位置。另外Ultralytics第一次扫描数据会生成.cache缓存文件如果你移动了标签文件但缓存还在它会继续用旧的索引信息。解决先核对目录树train/images下的jpg必须和train/labels下的txt同名。确认无误后找到firc-split目录下的train.cache或val.cache直接删掉重新跑训练。删除缓存这个动作很多人不知道属于那种“卡住半天发现是玄学”的坑实际只是缓存没刷新的问题。5.3 xml里的size和真实图片尺寸不一致现象转换脚本跑完训练loss曲线特别奇怪box_loss高得离谱或者mAP很低但偶尔能中几个框。原因数据集经过压缩、重采样或平台二次处理xml里记录的width是1920但实际jpg已经被压缩成1280。用xml的size做分母算出来的归一化坐标整体偏移。解决这也是我在第3章的脚本里特意用PIL读真实图片尺寸的原因。Image.open拿到的是当前jpg的实际宽高永远和图片内容一致。如果你的转换脚本用xml size做分母赶紧换成读真实尺寸。5.4 小目标漏检风筝占比太小imgsz不够现象val上检测不到远处的小风筝或者只能检测近处的大风筝远处的无论怎么调置信度阈值都没反应。原因天空背景下风筝本身很小尤其在俯瞰视角下可能就占画面的1%~3%。imgsz设成640每个风筝对应的特征区域只有十几个像素特征图上的响应很弱小目标层根本没有有效激活。解决在显存允许的前提下把imgsz提升到960或1280同时训练时开启mosaic1.0数据增强让小目标有机会和背景切块组合。如果模型已经训练完了推理时可以换用更大分辨率的输入或者按滑窗切图推理把大图切成多块分别检测再合并结果。对于无人机巡检场景这个方法比重新训练省事得多。5.5 图片和标签数量对不上错位后mAP虚高现象训练集上跑出来的mAP非常高0.9以上但换到另一批真实图片上完全不能用检测框乱飘。原因划分数据时只复制了图片漏复制了部分txt或者shuffle后图片顺序和标签顺序没有同步。训练器按文件名索引但如果存在重名或者手动复制漏了它可能用上一张图片的标签造成“错位监督”模型学到的映射完全是乱的。解决用划分脚本末尾的校验逻辑逐个子集比对images和labels的文件数一定要完全相等。我一般还会额外跑一个更强校验对比两边的主干集合是否完全相同。import os for part in [train, val, test]: img_set {os.path.splitext(f)[0] for f in os.listdir(ffirc-split/{part}/images)} lab_set {os.path.splitext(f)[0] for f in os.listdir(ffirc-split/{part}/labels)} print(part, img-only:, img_set - lab_set, lab-only:, lab_set - img_set)两个差集都为空这个子集才算合格。数据集里的黑匣子往往就藏在这种细节里训练跑得越顺越要回头检查数据管道。6. 验证与部署前检查用混淆矩阵、置信度曲线和ONNX收口6.1 训练完先看三张图混淆矩阵、PR曲线、F1曲线训练完成后第一步不是直接拿图片测试而是跑一次完整的val评估。yolo val modelruns/detect/train/weights/best.pt datakite.yaml跑完后在runs/detect/val目录下会生成一堆图表重点看三张confusion_matrix.png、PR_curve.png、F1_curve.png。混淆矩阵看的是误检结构。单类别场景主要看kite这一行的对角线值以及背景那一行的漏检率。如果背景行数值偏高说明模型把云、楼房、飞鸟误判成了风筝这时候需要补负样本或者调置信度阈值。F1曲线实际上比PR曲线更实用它直接告诉你置信度阈值设多少收益最高。找到曲线最高点对应的阈值推理时就用这个值而不是默认的0.25。这个动作很多人忽略但风筝检测这种场景误报一次就是一次无效告警阈值调优的收益非常直观。6.2 导出ONNX做工程验证确认val指标能看之后下一步就是把模型导出成ONNX脱离PyTorch环境验证。yolo export modelruns/detect/train/weights/best.pt formatonnx imgsz640 opset12 simplifyTrue导出完成后用onnxruntime快速验证推理输出import cv2 import numpy as np import onnxruntime as ort session ort.InferenceSession(best.onnx, providers[CUDAExecutionProvider, CPUExecutionProvider]) input_name session.get_inputs()[0].name print(input:, session.get_inputs()[0].shape, output:, [o.shape for o in session.get_outputs()]) img cv2.imread(test_kite.jpg) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (640, 640)) img img.astype(np.float32) / 255.0 img img.transpose(2, 0, 1)[None] outputs session.run(None, {input_name: img}) print(outputs[0].shape, outputs[1].shape)这段代码的关键是预处理要和训练时保持一致包括分辨率、归一化方式、通道顺序。ONNX推理时常用的输出形式有两种一种是(1, 84, 8400)这种解耦输出另一种是(1, 300, 84)经过NMS后的结果不同版本导出格式不同打印输出shape就能确认。我自己的习惯是每次训练前强制跑一遍第3章的校验脚本确认train和val的jpg数量与txt数量完全一致再开训这个动作已经成了肌肉记忆。上一次因为复制漏了一个txt训练两天出来的模型在实地上完全不能用从那以后我再也不敢跳过数据校验。这份VOC和YOLO双格式的风筝数据集格式干净上手成本低如果正好卡在数据准备这一步直接拿它把流程跑通后面换数据只是换yaml的问题希望帮到你。本文还有配套的精品资源点击获取