简介:这是一份面向目标检测初学者与算法工程师的鸭子识别数据集,采用Pascal VOC与YOLO双格式标注,可直接用于训练和验证单类别检测模型,省去自行采集与标注的成本。压缩包共1892个文件,包含630张jpg原图、630个VOC格式xml标注文件以及630个yolo格式txt标签文件,另附少量说明文本,整体约199.6MB,图片与标注一一对应,方便按需转换或直接投入训练。标注由labelImg完成,类别仅Duck一类,共1149个矩形框,平均每图约1.8个目标,覆盖多种姿态与场景,适合做小样本单类检测、迁移学习或数据增强实验。目前已有217人学习下载,可作为课程设计、毕业项目或算法对比的现成数据基础,帮助读者快速搭建训练流程、验证模型效果并复现检测结果。
1. 鸭子数据集 VOC 格式 + YOLO 格式 630 张 1 类别:小样本检测到底能不能打
拿到一个 630 张、单类别、同时给了 VOC 和 YOLO 两套标注格式的鸭子数据集,第一反应往往不是兴奋,而是犯嘀咕:这点量够不够训一个能用的检测模型?我一开始也这么想,直到在几个实际的小场景里反复验证过——630 张单类别,只要标注干净、划分合理、增强到位,训出一个能在固定机位下稳定框住鸭子的模型,完全可行。它解决的是「我不想从零标几千张,只想快速验证一个单目标检测需求」的问题,适合做农业养殖计数、水域巡检、校园或公园场景的固定摄像头分析,也适合刚入门 YOLO、想拿一个真实小数据集把训练到推理整条链路跑通的人。这篇就把 VOC 和 YOLO 两套格式怎么选、怎么转、怎么训、坑在哪,按我实际踩过的顺序讲清楚。
2. VOC 与 YOLO 标注格式:先搞懂差异再动手
2.1 两套格式到底差在哪
VOC 格式的核心是每张图对应一个 XML 文件,里面用<object>节点描述每个目标,坐标是绝对像素值,记录的是xmin、ymin、xmax、ymax,也就是左上角和右下角。它天然支持一张图多个类别多个目标,可读性好,用文本编辑器打开就能看懂,所以很多标注工具默认导出 VOC。
YOLO 格式则是每张图对应一个.txt文件,每行一个目标,格式是类别索引 中心x 中心y 宽 高,而且这四个值全部是相对整张图宽高的归一化值,范围在 0 到 1 之间。类别不再是字符串名字,而是一个从 0 开始的整数索引,索引和类别名的对应关系单独写在一个classes.txt或data.yaml里。
这个差异带来的直接后果是:VOC 的坐标依赖图片实际尺寸,换一批分辨率不同的图,坐标数值就没法直接复用;YOLO 的归一化坐标则和分辨率解耦,同一张图缩放后标注依然成立。这也是为什么训练时大家更愿意用 YOLO 格式——数据增强里的缩放、裁剪、拼接,处理归一化坐标要省心得多。
单类别的情况下,YOLO 每行的第一个数字永远是 0,看起来有点多余,但别手动去删,格式必须完整,否则解析会错位。
2.2 630 张单类别,先做一次数据体检
在转换格式之前,我一般会先做一次体检,而不是直接开训。630 张听起来不多,但如果里面混了重复图、坏图、漏标图,实际有效样本可能只有五百出头,那训练效果会明显打折。
体检主要看四件事。第一是图片能不能正常打开,有没有下载或解压过程中损坏的零字节文件。第二是标注文件数量和图片数量是否一一对应,VOC 下就是 XML 数是否等于图片数,缺一个都可能是漏标。第三是看标注框有没有越界,xmin小于 0 或者xmax大于图片宽度,这种框在转 YOLO 归一化后会变成负数或大于 1,训练时容易报错或产生异常梯度。第四是看有没有空标注文件,也就是图片在但 XML 里一个<object>都没有,这种图要么补标要么剔除。
下面这段脚本就是做体检用的,跑一遍能快速定位问题文件:
import os import xml.etree.ElementTree as ET from PIL import Image img_dir = "JPEGImages" xml_dir = "Annotations" bad_imgs, bad_xmls, empty_xmls, oob_boxes = [], [], [], [] for name in os.listdir(img_dir): if not name.lower().endswith((".jpg", ".png", ".jpeg")): continue img_path = os.path.join(img_dir, name) # 检查图片是否可读 try: w, h = Image.open(img_path).size except Exception: bad_imgs.append(name) continue stem = os.path.splitext(name)[0] xml_path = os.path.join(xml_dir, stem + ".xml") if not os.path.exists(xml_path): bad_xmls.append(name) continue tree = ET.parse(xml_path) objs = tree.findall("object") if len(objs) == 0: empty_xmls.append(name) continue for obj in objs: bnd = obj.find("bndbox") xmin = int(float(bnd.find("xmin").text)) ymin = int(float(bnd.find("ymin").text)) xmax = int(float(bnd.find("xmax").text)) ymax = int(float(bnd.find("ymax").text)) # 越界判断 if xmin < 0 or ymin < 0 or xmax > w or ymax > h or xmax <= xmin or ymax <= ymin: oob_boxes.append((name, xmin, ymin, xmax, ymax)) print("坏图:", bad_imgs) print("缺XML:", bad_xmls) print("空标注:", empty_xmls) print("越界框:", oob_boxes)逻辑上就是遍历图片目录,逐张尝试打开,再去找同名 XML,解析出每个目标框做边界检查。参数上img_dir和xml_dir换成你解压后的实际目录名即可,VOC 标准结构里图片通常在JPEGImages,标注在Annotations。跑完如果四类列表都是空的,说明数据干净,可以进入转换;如果有内容,先按列表逐个处理,别带着脏数据往下走。
提示:越界框不一定是标注错误,也可能是标注工具导出时四舍五入导致的 1 像素溢出,这种情况把坐标裁剪回
[0, w]和[0, h]即可,不用整张重标。
3. 把 VOC 转成 YOLO:转换脚本与四个边界坑
3.1 转换的核心公式
VOC 转 YOLO 的数学部分其实就四行。设图片宽W、高H,框的xmin、ymin、xmax、ymax,那么:
中心 x =(xmin + xmax) / 2 / W中心 y =(ymin + ymax) / 2 / H宽 =(xmax - xmin) / W高 =(ymax - ymin) / H
四个值都落在 0 到 1 之间。单类别时类别索引固定写 0。理解了这个公式,转换脚本就是把它翻译成代码,再套上目录遍历和文件写入。
3.2 可直接复用的转换脚本
import os import xml.etree.ElementTree as ET from PIL import Image img_dir = "JPEGImages" xml_dir = "Annotations" out_dir = "labels" os.makedirs(out_dir, exist_ok=True) # 单类别,类别名到索引的映射 classes = ["duck"] for name in os.listdir(img_dir): if not name.lower().endswith((".jpg", ".png", ".jpeg")): continue stem = os.path.splitext(name)[0] xml_path = os.path.join(xml_dir, stem + ".xml") if not os.path.exists(xml_path): continue w, h = Image.open(os.path.join(img_dir, name)).size tree = ET.parse(xml_path) lines = [] for obj in tree.findall("object"): cls_name = obj.find("name").text.strip() if cls_name not in classes: continue # 只保留鸭子这一类 cls_id = classes.index(cls_name) bnd = obj.find("bndbox") xmin = float(bnd.find("xmin").text) ymin = float(bnd.find("ymin").text) xmax = float(bnd.find("xmax").text) ymax = float(bnd.find("ymax").text) # 裁剪到图像范围内,防止归一化后越界 xmin = max(0, min(xmin, w)) xmax = max(0, min(xmax, w)) ymin = max(0, min(ymin, h)) ymax = max(0, min(ymax, h)) if xmax <= xmin or ymax <= ymin: continue cx = (xmin + xmax) / 2.0 / w cy = (ymin + ymax) / 2.0 / h bw = (xmax - xmin) / w bh = (ymax - ymin) / h # 保留6位小数,够用且文件不至于太大 lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") with open(os.path.join(out_dir, stem + ".txt"), "w") as f: f.write("\n".join(lines))逻辑说明:脚本遍历图片,找到同名 XML,解析每个目标,过滤出鸭子类别,做坐标裁剪后套公式归一化,最后每个图片写一个同名 txt。参数上classes列表就是类别定义,单类别只放一个"duck",如果你后面要扩类,在这里加名字、索引会自动顺延。:.6f是保留六位小数,YOLO 官方实现读取时对精度不敏感,六位足够,写太多位反而让文件变大。
3.3 四个容易翻车的边界坑
第一个坑是坐标越界。前面体检如果没做,转换时xmax超过图片宽度,归一化后宽高可能大于 1,训练时损失计算会出现异常值。脚本里已经用max/min裁剪兜底,但最好在体检阶段就修掉。
第二个坑是类别名大小写和空格。VOC 里name节点可能是"duck"、"Duck"、" duck ",带空格或大小写不一致,直接比对会漏掉目标,导致某些图变成空标注。稳妥做法是先strip()再统一小写比对。
第三个坑是图片格式混用。目录里同时有.jpg和.JPG,endswith判断如果只写小写就会漏掉大写后缀的图,转换后这些图没有对应 txt,训练时被当成无标注图跳过。判断时统一转小写再比。
第四个坑是文件名里的特殊字符。有些图名带空格或中文,XML 同名但路径拼接时容易出错,建议转换前把文件名统一成纯英文数字下划线,省掉后面一连串编码问题。
3.4 划分训练集和验证集
630 张单类别,我一般按 8:2 划分,训练 504 张、验证 126 张。划分时要注意同一只鸭子、同一段连续视频抽帧的图不能同时出现在训练和验证里,否则验证指标会虚高,看着 mAP 很高,实际换一批图就崩。如果是视频抽帧来的数据,按时间顺序切分,前 80% 做训练,后 20% 做验证,比随机打乱更接近真实泛化表现。
划分脚本很简单,生成两个 txt 列表文件,每行一个图片路径:
import os, random img_dir = "JPEGImages" names = [n for n in os.listdir(img_dir) if n.lower().endswith((".jpg", ".png", ".jpeg"))] random.seed(42) # 固定种子,保证可复现 random.shuffle(names) split = int(len(names) * 0.8) with open("train.txt", "w") as f: f.write("\n".join(os.path.join(img_dir, n) for n in names[:split])) with open("val.txt", "w") as f: f.write("\n".join(os.path.join(img_dir, n) for n in names[split:]))random.seed(42)是为了每次划分结果一致,方便复现和对比实验。如果你是按视频切分,就把shuffle去掉,直接按顺序切片。
4. 用 YOLO 训练 630 张单类别:配置、参数与增强策略
4.1 数据配置文件怎么写
YOLO 训练前要准备一个数据配置文件,通常叫data.yaml,内容如下:
path: ./duck_dataset train: train.txt val: val.txt nc: 1 names: ["duck"]path是数据集根目录,train和val是相对根目录的列表文件路径,nc是类别数,单类别写 1,names是类别名列表,顺序必须和转换时classes里的索引一致,否则模型学到的类别会错位。这个文件是训练入口,路径写错是最常见的启动失败原因,报错通常是找不到图片或标签。
4.2 训练命令与关键参数
以常见的 YOLO 训练入口为例,一条典型命令是这样:
yolo detect train \ data=duck_dataset/data.yaml \ model=yolov8n.pt \ epochs=150 \ imgsz=640 \ batch=16 \ lr0=0.01 \ patience=30 \ project=runs/duck \ name=exp1逐项说参数。model选 nano 版本,630 张单类别用大模型容易过拟合,nano 参数量小、收敛快,是这类小数据集的稳妥起点。epochs=150给足轮次,配合patience=30早停,验证指标 30 轮不提升就停,避免无效训练。imgsz=640是通用输入尺寸,如果你的鸭子在图里占比很小,可以提到 960 或 1280,但显存和速度要重新权衡。batch=16是常见起点,显存不够就降到 8 或 4。lr0=0.01是初始学习率,小数据集不建议太大,否则前期震荡明显。
4.3 小数据集该开哪些增强
630 张的规模,增强是决定成败的关键。默认增强里,我一般会重点调这几个:
| 参数 | 作用 | 小数据集建议 |
|---|---|---|
| mosaic | 四图拼接,增加背景多样性 | 保持开启,但最后 10 轮关闭 |
| fliplr | 水平翻转 | 0.5,鸭子左右对称,安全 |
| flipud | 垂直翻转 | 0,鸭子很少倒挂,开了反而引入噪声 |
| hsv_h / hsv_s / hsv_v | 色调饱和度亮度扰动 | 默认即可,应对不同光照 |
| scale | 随机缩放 | 0.5,模拟远近变化 |
| degrees | 旋转角度 | 0 到 10,别太大,否则框会失真 |
mosaic 在最后若干轮关闭是个实用技巧,因为拼接图里的目标偏小、边界被裁切,训练末期关掉能让模型在真实完整图上收敛得更稳。这个开关在配置里通常通过close_mosaic参数控制,设成 10 就表示最后 10 轮关闭。
4.4 训练过程看什么指标
启动后重点盯三个数:box_loss、cls_loss和验证集的mAP50。单类别时cls_loss会降得很快,因为它只需要区分「是鸭子」和「是背景」,难度低。真正反映定位质量的是box_loss和mAP50-95。如果box_loss一直不降,多半是标注框质量问题,回去查体检结果。如果训练集指标很高但验证集mAP上不去,就是过拟合,减模型容量、加增强、或者补数据。
注意:单类别数据集里
mAP50容易虚高,因为负样本简单。别只看这一个数,mAP50-95和实际推理效果才是判断能不能用的依据。
5. 避坑与排查:小样本鸭子检测的五个真实翻车点
5.1 训练 loss 变 NaN
现象:训练刚开始几轮,box_loss突然变成nan,之后再也不恢复。
原因:最常见的是标注里有越界框或宽高为 0 的框,归一化后出现除零或超大值;其次是学习率设得过高,前期梯度爆炸。
解决:回到第 2 章的体检脚本,把所有越界框和零面积框清掉;同时把lr0从 0.01 降到 0.001 再试。如果还不行,检查图片里有没有尺寸为 0 的损坏文件。
5.2 验证集 mAP 很高但实际推理框不住
现象:训练日志里mAP50到 0.9 以上,但拿新图去推理,鸭子要么漏检要么框歪。
原因:训练验证划分时同一段视频的相邻帧被分到了两边,验证集和训练集高度相似,指标虚高。
解决:按视频或时间顺序切分,确保验证集是模型没见过的场景。如果数据来自多个拍摄地点,按地点划分更可靠。
5.3 模型只认大鸭子,小鸭子全漏
现象:图里近处的大鸭子框得很准,远处的小鸭子一个都检测不到。
原因:630 张里小目标样本占比低,加上imgsz=640下小鸭子缩放后像素太少,特征提取不到。
解决:把imgsz提到 960 或 1280;开启 mosaic 增加小目标出现频率;如果小鸭子是重点,考虑对含小目标的图做上采样,提高其在训练集中的比例。
5.4 背景被误检成鸭子
现象:水面反光、白色漂浮物被框成鸭子。
原因:单类别数据集负样本不足,模型没见过足够多的「像鸭子但不是鸭子」的背景。
解决:收集一批纯背景图,不放任何标注,加入训练集,让模型学会区分。这类图不需要标注文件,YOLO 会把没有对应 txt 的图当作纯背景处理。加个几十张,误检率通常能明显下降。
5.5 换一批图片推理,颜色和光照全不对
现象:训练集里鸭子是白天拍的,换到傍晚或阴天,检测效果断崖式下跌。
原因:数据分布单一,模型对光照变化没有泛化能力。
解决:增强里把hsv_v和hsv_h的扰动范围适当调大,模拟不同光照和色温;有条件的话补拍一些不同时段、不同天气的图,哪怕每类只加几十张,泛化提升也很明显。
6. 从能训到能用:验证方法与一个提点小技巧
训完模型,别急着上线,先做一轮系统验证。我一般会准备三类测试图:第一类是训练集里没出现过的新图,看基本泛化;第二类是不同光照、不同距离的图,看鲁棒性;第三类是纯背景图,看误检率。三类都过了,才算能进实际场景。
验证时不要只看整体mAP,要按目标大小分层看。把测试图按鸭子占图面积分成大、中、小三档,分别统计召回率。如果小目标召回明显低,就回到第 5 章的小目标方案去调。这个分层统计能帮你判断模型到底能不能覆盖你的实际场景,而不是被一个笼统的平均值骗过去。
这里分享一个我常用的提点技巧:在推理阶段对输入做多尺度测试。同一张图分别用 640 和 960 两个尺寸推理,把两组检测框做加权融合,小目标的召回通常能提升几个点,代价是推理耗时增加。实现上就是跑两次推理,然后对框做非极大值抑制合并:
from ultralytics import YOLO model = YOLO("runs/duck/exp1/weights/best.pt") # 两个尺度分别推理 r1 = model.predict("test.jpg", imgsz=640, conf=0.25) r2 = model.predict("test.jpg", imgsz=960, conf=0.25) # 合并两组框,交给NMS统一处理 boxes = [] for r in (r1, r2): for b in r[0].boxes: boxes.append(b) # 实际使用时把两组结果送入同一NMS,或直接取并集后按置信度过滤逻辑上就是让模型在两种感受野下各看一遍,大尺度补小目标,小尺度保速度,融合后取长补短。参数上conf=0.25是置信度阈值,融合时可以适当放低到 0.2,让更多候选框进入合并,再由 NMS 去重。这个技巧在鸭子这种目标尺寸变化大的场景里特别管用,代价是推理时间大约翻倍,实时性要求高的场景要权衡。
最后说个我自己的习惯:每次训完模型,我都会把验证集里预测错的图单独挑出来,一张张看是漏检还是误检,然后反推是数据问题还是模型问题。这个动作花不了多少时间,但比盯着指标曲线有用得多。630 张的数据集,错个二三十张,看一遍就能定位到具体原因。希望帮到你。
本文还有配套的精品资源,点击获取