简介:针对目标检测算法训练与农业害虫识别应用,该数据集提供YOLOV5标准目录格式的柑橘害虫图像,涵盖苍蝇、木虱两个类别,可直接用于模型训练与精度验证,解决害虫数据标注分散、格式转换繁琐的常见问题。全部图像为1000-4000分辨率的高清RGB图片,训练集240张,验证集60张,每张图片均附有对应的txt标签文件,标签使用YOLO格式归一化坐标,并按训练/验证分目录存放。资源包共603个文件,其中300张jpeg原图、301个txt标签文件,另含1个可视化py脚本(随机传入一张图片即可绘制边界框并保存)与1个类别说明png,压缩包整体约260MB。已有401人学习使用,适合目标检测入门者及农业AI项目开发者。借助该数据集可免去自建数据集的采集标注环节,直接训练YOLOV5系列模型;可视化脚本则便于快速检查标注质量,提高数据使用效率。
1. 把柑橘害虫照片变成YOLOv5目录格式:这份数据集到底解决什么问题
果园里拍回来的柑橘虫害照片堆在硬盘里,标注也标了大半,但你发现模型根本训不起来——报错刷屏、loss 乱跳、验证集 mAP 一直是 0。绝大多数情况下不是网络结构的问题,而是数据集没有按 YOLOv5 目录格式组织好。2 类别柑橘害虫检测(比如最常见的红蜘蛛、木虱)看着简单,真正落到代码里,images/labels 的目录划分、训练集与验证集的边界、txt 里的五列归一化坐标、data.yaml 的路径写法,每一处都可能让训练翻车。这份数据集的真正价值在于:拿到手解压就能放进 YOLOv5 的 train.py 直接开跑,不用再花一个周末去手搓目录结构、写转换脚本、调路径。下面按我做这类数据集的完整流程,从目录格式拆解一路讲到验证集该怎么用。
2. YOLOv5目录格式拆解:images/labels怎么分,验证集该放哪
2.1 标准目录树:四层结构各管什么
YOLOv5 官方对数据集的约定其实只有一条核心逻辑:图像的读取和标签的读取完全解耦,靠文件名同名配对。数据集的根目录下先分 images 和 labels 两大块,再各自往下分 train 和 val,于是得到 images/train、images/val、labels/train、labels/val 四个目录。训练脚本拿到 data.yaml 里的 train 路径指向 images/train 之后,Loader 会枚举里面的 jpg/png 文件,然后把后缀名替换成 .txt,去 labels/train 下找同名文件。目录树长这样:
citrus_pest/ ├── images/ │ ├── train/ │ │ ├── 0001.jpg │ │ ├── 0002.jpg │ │ └── ... │ └── val/ │ ├── 0101.jpg │ ├── 0102.jpg │ └── ... ├── labels/ │ ├── train/ │ │ ├── 0001.txt │ │ ├── 0002.txt │ │ └── ... │ └── val/ │ ├── 0101.txt │ ├── 0102.txt │ └── ... ├── data.yaml └── 说明.txt这个结构里有几个关键点值得展开。第一,labels 不能并进 images 目录,YOLOv5 的 LoadImagesAndLabels 是拿着图像路径去替换 "images" 为 "labels" 的,混在一起就会反复刷 "label not found" 的警告。第二,train 和 val 在 images 和 labels 下都必须同时存在。很多人只建了 images/val,labels 下忘了建 val,验证集就变成全背景图,mAP 自然难看。第三,文件名前缀必须严格一致,扩展名的差异也会导致配对失败,JPG 和 jpg 同时混在目录里是常见事故源。
这个目录树不挑图像格式,jpg、png、bmp 都行,只要同名 txt 在 labels 对应目录里即可。我一般还会在根目录放一份说明.txt,记录类别顺序、标注工具版本、拍摄环境和标注口径。这个文件不参与训练,纯粹是给"半年后回来用数据"的自己留一颗后悔药:类别顺序和标注规范写清楚,能省掉重新翻旧代码猜来猜去的时间。
2.2 标签txt的5列格式:坐标为什么必须归一化
标签文件是纯文本,每行表示一个目标,固定 5 列:class_id 加四个坐标值,中间用空格分隔。class_id 从 0 开始计数,这是 YOLO 系列一贯的约定;四个坐标依次是 x_center、y_center、width、height,全部是相对图像宽高的归一化值,范围 0~1。举个例子,一张 1920x1080 的图像里,一只木虱的像素级边界框是 x1=800、y1=500、x2=1000、y2=600,对应的 txt 内容是这样:
1 0.468750 0.509259 0.104167 0.092593换算过程:x_center=(800+1000)/2/1920=0.46875,y_center=(500+600)/2/1080≈0.50926,width=(1000-800)/1920≈0.10417,height=(600-500)/1080≈0.09259。这套归一化坐标是 YOLO 系列通用的设计,因为它直接服务于训练时的多尺度输入:图像会被 resize 到 640、960 甚至 1280,像素坐标会随尺寸变化,归一化坐标则完全不受影响,同一份标签喂给任何输入尺寸都成立。
这里有两个容易踩坑的点。一是 class_id 从 0 开始,2 类别的任务里就是 0 和 1,通常把样本量大、更难分的那类设为 0,后面调置信度阈值时操作更顺手。二是注意坐标顺序是"中心点加宽高",不是 VOC 的 xmin/ymin/xmax/ymax。转换脚本写错顺序时训练不会崩,但框全画歪,验证集 AP 低得让人怀疑模型而不是怀疑坐标。
2.3 data.yaml的写法:类别顺序一错全错
data.yaml 是训练入口的数据配置文件,内容就三块:train/val 的路径、类别数 nc、类别名 names。路径指向 images 的下一级目录,YOLOv5 会自动完成 images 到 labels 的目录替换。很多人把 train 写成数据集根目录,导致它在根目录里直接找 .jpg,结果自然是零图像加载。一份正确配置长这样:
# citrus_pest/data.yaml train: /home/your_name/datasets/citrus_pest/images/train val: /home/your_name/datasets/citrus_pest/images/val nc: 2 names: 0: red_spider 1: psyllidnames 的索引和顺序必须和标签里的 class_id 一一对应,它不只影响训练日志里显示的名字,还决定验证阶段混淆矩阵、PR 曲线和每类 AP 的统计口径——txt 里 0 是红蜘蛛而 data.yaml 里 0 写成了木虱,训练全程不报错,但所有评估结果都是错的。另外绝对路径里尽量不要出现中文和空格,YOLOv5 的默认数据加载对这类路径处理得不好,报错也很隐晦。
如果数据集要跟随项目代码一起迁移,用相对路径会更稳:把 data.yaml 放在数据集根目录,train 写 images/train、val 写 images/val,这样 YOLOv5 会相对 data.yaml 文件所在位置去解析,而不是相对当前终端目录。这个细节的坑,后面 5.1 节会有一次实战复盘。
3. 从原始标注到YOLOv5可训数据:转换脚本与数据集划分
3.1 VOC标注转YOLO格式:一个脚本解决坐标换算
拿到标注数据后第一步不是开训,而是先把标注统一成 YOLO txt。常见做法是标注阶段用 LabelImg 导出 VOC xml(像素绝对坐标),训练前再写脚本转换。我倾向于用 VOC 作为中间格式而不是直接标成 txt,原因有两个:一是 VOC xml 保留了图像宽高和完整的边界框信息,人眼可读且字段稳定;二是后续想换 YOLOv8 或者 MMDetection,VOC 是通用中转格式,一次转换多处可用。以下是核心转换逻辑:
import os import xml.etree.ElementTree as ET # 类别名到 id 的映射,必须与 data.yaml 的 names 顺序一致 CLASS_MAP = {'red_spider': 0, 'psyllid': 1} def voc_to_yolo(xml_path, out_dir, class_map): tree = ET.parse(xml_path) root = tree.getroot() img_w = int(root.find('size/width').text) img_h = int(root.find('size/height').text) txt_name = os.path.splitext(os.path.basename(xml_path))[0] + '.txt' lines = [] for obj in root.findall('object'): cls = obj.find('name').text if cls not in class_map: 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) # 裁剪到图像范围内,标注工具经常允许框画出画面 x1 = max(0.0, min(x1, img_w)) y1 = max(0.0, min(y1, img_h)) x2 = max(0.0, min(x2, img_w)) y2 = max(0.0, min(y2, img_h)) if x2 - x1 < 1 or y2 - y1 < 1: continue # 宽或高小于1像素的退化框直接丢弃 x_c = (x1 + x2) / 2.0 / img_w y_c = (y1 + y2) / 2.0 / img_h w = (x2 - x1) / img_w h = (y2 - y1) / img_h # 保留6位小数足够训练,文件体积也小 lines.append(f"{class_map[cls]} {x_c:.6f} {y_c:.6f} {w:.6f} {h:.6f}") with open(os.path.join(out_dir, txt_name), 'w') as f: f.write('\n'.join(lines) + '\n' if lines else '')逻辑说明:这段代码是整个数据准备过程中最容易被抄错的一段。先读图像的 w/h,像素坐标换算归一化时必须用它做分母,漏掉这一步意味着所有框都错位;随后对 bndbox 做 0~img_w/img_h 的裁剪,很多标注工具允许框超出画面,超出部分产生的归一化坐标大于 1,训练时轻则警告重则 loss 变 nan;最后丢弃宽或高小于 1 像素的退化框,这类框常见于把极小的害虫强行放大标注,但原图里根本没有足够像素支撑,留着只会让损失计算不稳定,丢掉反而干净。
参数说明:CLASS_MAP 的键是 xml 里 object/name 的文本,值是对应的类别 id,2 类任务就是 0 和 1;out_dir 建议直接指向 labels/train 或 labels/val 对应目录,省掉一次拷贝动作。整套转换跑完后不要急着训练,先执行 4.1 的检查脚本,把问题拦在训练之前。这几行防御性代码是之前项目里用血泪经验换来的:第一版转换脚本没做越界裁剪,训到第 40 个 epoch loss 直接飘成 nan,白跑一整晚。
3.2 训练集/验证集划分:随机种子与分组策略
2 类别的害虫数据集规模一般不会太大,几百到两三千张,常见划分是 8:2 或者 9:1。直接 random shuffle 是最快但不是最稳妥的做法,尤其当数据来自连续拍摄的视频抽帧时,相邻帧极其相似,训练集和验证集之间会互相"泄题"。先给一个可复现的基础版本:
import os import random random.seed(42) # 固定种子,保证每次划分结果一致 VAL_RATIO = 0.2 all_jpgs = [f for f in os.listdir('raw_images') if f.lower().endswith(('.jpg', '.jpeg', '.png'))] random.shuffle(all_jpgs) val_count = int(len(all_jpgs) * VAL_RATIO) val_files = set(all_jpgs[:val_count]) train_files = set(all_jpgs[val_count:]) def dump_split(files, img_src, img_dst, lab_src, lab_dst): os.makedirs(img_dst, exist_ok=True) os.makedirs(lab_dst, exist_ok=True) for f in files: base = os.path.splitext(f)[0] src_img = os.path.join(img_src, f) # 用符号链接代替复制,不占双倍磁盘 os.symlink(src_img, os.path.join(img_dst, f)) src_lab = os.path.join(lab_src, base + '.txt') if os.path.exists(src_lab): os.symlink(src_lab, os.path.join(lab_dst, base + '.txt')) dump_split(train_files, 'raw_images', 'citrus_pest/images/train', 'raw_labels', 'citrus_pest/labels/train') dump_split(val_files, 'raw_images', 'citrus_pest/images/val', 'raw_labels', 'citrus_pest/labels/val')逻辑说明:按设定比例 shuffle 后切片,train 取后 80%、val 取前 20%,用 set 存放避免文件重复。这里用 os.symlink 建符号链接而不是复制文件,原始图像和标注只保留一份,后续想调整划分比例时删掉目录重建即可,磁盘占用也小。等训练流程确认没问题,再考虑把训练集真正复制成独立副本做版本管理。
参数说明:VAL_RATIO 设 0.2,标注在几百张级别时能留出上百张验证;如果总共只有 200 张左右,建议降到 0.15,否则验证集的统计波动会很大。random.seed(42) 保证这次划分和下次重跑完全一致,这点看起来不起眼,但数据迭代时最怕"每次划分都不一样"造成的评测波动,固定种子是基本的可复现要求,能把这类玄学问题直接消除。
如果图像来自不同果园或不同拍摄时段,更稳妥的做法是按 group 分组:把同一地点同一时段拍摄的图像放进同一个组,按组的粒度划分,保证验证集里的场景在训练集中没有近亲照片。实现上就是在文件列表里保留一个 group 字段,先对分组结果做 shuffle 再进 train/val。5.4 节会专门讲,为什么这个细节直接决定你的 mAP 是真实水平还是表演。
3.3 标注工具导出格式对照:从xml、json到txt
目标检测常用标注工具的导出格式各不相同,新接手一批数据时,先确认手里到底是哪一种格式,再决定转换路径。
| 工具 | 常见导出格式 | 坐标基准 | 转YOLO要点 |
|---|---|---|---|
| LabelImg | VOC xml | 像素绝对坐标 | bndbox 四值,按 3.1 的脚本转换 |
| labelme | json | 像素多边形/矩形 | 取 points 的外接矩形,注意是 (x,y) 点列表 |
| CVAT | xml / coco json | 像素绝对坐标 | 按 category_id 映射到 0/1,coco 的 id 不连续 |
| Roboflow | YOLO txt | 归一化坐标 | 可直接用,但必须核对 names 顺序和文件对名 |
labelme 这类多边形标注在害虫场景里很常见,因为虫体轮廓不规则。转换时取所有 points 的 min/max 包一个正矩形,不要直接套 VOC 脚本。coco json 要注意 annotation 里的 category_id 通常从 1 开始而且可能不连续,必须单独维护一张映射表,不能假设它直接等于 class_id。Roboflow 导出虽然已经是 YOLO txt,但它可能会重排图像文件名,导出的 txt 前缀和本地图片不一定对得上,解压后先做一次配对检查。不管用哪种工具,转换完的第一件事永远是抽几张图把框画回去看,这一步能拦住九成格式错位的问题。
4. 训练前自检:三个脚本验证数据能不能直接喂给YOLOv5
4.1 标注合法性批量检查:越界、空标签、类别号
训练前把整个数据集扫一遍,把所有标注问题集中列出来,比训练时对着满屏 WARNING 猜测原因高效得多。这个脚本我在每个新数据集上都会跑,几十毫秒换回几小时调试时间。逻辑分三步:检查目录完整性、检测图像标签配对、逐行验证标签值域。
import os def check_dataset(img_root, lab_root, nc=2): issues = [] for split in ['train', 'val']: img_dir = os.path.join(img_root, split) lab_dir = os.path.join(lab_root, split) if not os.path.isdir(img_dir) or not os.path.isdir(lab_dir): issues.append(f"[{split}] 目录不存在: {img_dir} 或 {lab_dir}") continue img_stems = {os.path.splitext(f)[0] for f in os.listdir(img_dir) if f.lower().endswith(('.jpg', '.jpeg', '.png'))} lab_stems = {os.path.splitext(f)[0] for f in os.listdir(lab_dir) if f.endswith('.txt')} for s in sorted(img_stems - lab_stems): issues.append(f"[{split}] 图像没有对应标签: {s}") for s in sorted(lab_stems - img_stems): issues.append(f"[{split}] 标签没有对应图像: {s}") for s in sorted(lab_stems): p = os.path.join(lab_dir, s + '.txt') with open(p) as f: lines = [ln.strip() for ln in f if ln.strip()] if not lines: issues.append(f"[{split}] 空标签文件: {s}.txt") for ln in lines: parts = ln.split() if len(parts) != 5: issues.append(f"[{split}] {s}.txt 列数不是5: {ln}") continue cls = int(parts[0]) vals = [float(v) for v in parts[1:]] if cls < 0 or cls >= nc: issues.append(f"[{split}] {s}.txt 类别越界: {cls}") if vals[2] <= 0 or vals[3] <= 0: issues.append(f"[{split}] {s}.txt 宽高<=0: {ln}") if any(v < 0 or v > 1 for v in vals): issues.append(f"[{split}] {s}.txt 坐标超出0~1: {ln}") return issues if __name__ == '__main__': for item in check_dataset('citrus_pest/images', 'citrus_pest/labels', nc=2): print(item) print("检查完成")逻辑说明:脚本按三个阶段输出问题。先验证目录对是否存在,拦截路径配置错误;再用 set 差集检测图像和标签的一一配对关系,这一步抓的是文件命名不一致;最后逐行解析标签内容,验证五列格式、类别号和坐标值域。三个检查各自拦截一类问题,跑完没有任何输出就说明静态检查通过。
参数说明:nc=2 传入类别总数,检查时用它判断 class_id 是否越界;img_stems 和 lab_stems 用 set 做差集,输出有序且不会重复刷屏。常见输出模式也值得熟悉:大量"图像没有对应标签"多半是 labels 目录建错了层级;个别"类别越界"多半是某张图的标注类名在 CLASS_MAP 里没匹配上,被手动改过。
4.2 把框画回原图:可视化确认标注没串位
静态检查只能证明格式合法,不能证明框的位置对。位置错乱在转换脚本里太常见了:xmin/xmax 写反、w/h 和 center 顺序混掉、class_id 映射错位,这些错误训练时完全不报错,只有把框画回原图才能看出来。可视化脚本如下:
import cv2 import os NAMES = ['red_spider', 'psyllid'] COLORS = [(0, 0, 255), (0, 255, 0)] # 0类红框,1类绿框 def draw_one(img_path, txt_path, out_path): img = cv2.imread(img_path) h, w = img.shape[:2] with open(txt_path) as f: for ln in f: cls, cx, cy, bw, bh = ln.strip().split() cls = int(cls) cx, cy, bw, bh = float(cx), float(cy), float(bw), float(bh) x1 = int((cx - bw / 2) * w) y1 = int((cy - bh / 2) * h) x2 = int((cx + bw / 2) * w) y2 = int((cy + bh / 2) * h) # 画框和类别名一起输出 cv2.rectangle(img, (x1, y1), (x2, y2), COLORS[cls], 2) cv2.putText(img, f"{cls}:{NAMES[cls]}", (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, COLORS[cls], 2) cv2.imwrite(out_path, img) # 每个split抽查5张 for split in ['train', 'val']: lab_dir = f'citrus_pest/labels/{split}' img_dir = f'citrus_pest/images/{split}' for i, fn in enumerate(os.listdir(lab_dir)[:5]): if not fn.endswith('.txt'): continue base = os.path.splitext(fn)[0] draw_one(os.path.join(img_dir, base + '.jpg'), os.path.join(lab_dir, fn), f'check_{split}_{i}.jpg')逻辑说明:把归一化坐标乘回原始宽高得到像素框,注意宽高必须先通过 cv2.imread 从原图取,不能用训练时的 640 去还原。画框时把类别 id 和类别名一起写在框上方,颜色按类别区分,2 类任务红绿各一,错位一眼就能看出。
这样抽查会发现三类典型问题:框整体偏移说明 x_center 或 y_center 换算有误;框宽高正确但位置整体偏到角落,往往是 w/h 和 center 顺序写反;框的位置没问题但框住的虫子和类别名对不上,则是 class_map 顺序错了。抽查不需要多,每个 split 五张足够发现规律性错误,全量跑完反而浪费时间。确认这批没问题后,可以把脚本的抽查范围改成全量生成缩略图,做一次最终归档。
4.3 类别统计:两张表决定数据策略
2 类别的数据同样要看分布。害虫场景里最常见的问题是一类虫子数量暴多、另一类只有零星几张,直接训练模型会严重偏科。统计脚本按框计数而不是按图计数,因为检测难度和框数量直接相关:
from collections import Counter def class_stats(lab_dir, names): box_counter = Counter() img_with_box = 0 total_img = 0 for fn in os.listdir(lab_dir): if not fn.endswith('.txt'): continue total_img += 1 has = False with open(os.path.join(lab_dir, fn)) as f: for ln in f: if ln.strip(): cls = int(ln.strip().split()[0]) box_counter[cls] += 1 # 按框计数 has = True if has: img_with_box += 1 print(f"有标注图像: {img_with_box} / {total_img}") for cls_id, name in enumerate(names): print(f"类别 {cls_id} {name}: {box_counter[cls_id]} 个框") class_stats('citrus_pest/labels/train', ['red_spider', 'psyllid']) class_stats('citrus_pest/labels/val', ['red_spider', 'psyllid'])逻辑说明:统计的是框数不是图像数,一图多框和一张一框对训练的影响差别很大,按框统计更能反映数据底子。参数说明:names 只用于打印可读性,不影响统计结果;对 train 和 val 分别调用,能顺手确认验证集的类别分布和训练集接近,否则评估结果会有偏差。
看结果时关注两个数。一是两类框数比例,超过 5:1 就算失衡,考虑对少数类做过采样或者调整 cls loss 权重;二是"有标注图像"占总数比例,如果训练集里大量图像没有任何目标,模型会把"无目标"当作默认输出,这类背景图控制在 10%~20% 即可,太多反而拖慢收敛。统计完这两张表,再决定要不要加数据、加哪一类的数据,而不是闷头开训然后看着 AP 瞎猜。
5. YOLOv5数据集避坑指南:5个让训练翻车的典型问题
5.1 训练卡在"0 images found":路径拼错是头号杀手
现象:python train.py 一运行,日志刷出一片 "WARNING: labels not found in...",epoch 0 跑完 mAP 全是 0,严重时直接报 "AssertionError: train: No labels in ... can not train without labels"。
原因:data.yaml 里的 train/val 写成相对路径,而 YOLOv5 内部按当前工作目录去解析,你在项目根目录执行和数据集根目录执行,解析结果完全不同。另一个高频原因是把 train/val 写到了 images 的上一级,比如 train: citrus_pest/,Loader 在 citrus_pest 目录下找不到任何 .jpg。
解决:data.yaml 里写绝对路径最省心,全路径带盘符或 /home 开头。用相对路径时,把 data.yaml 放在数据集根目录,train 写成 images/train 这种与文件位置挂钩的写法。开跑前先验证一行:python -c "from utils.dataloaders import LoadImagesAndLabels; d=LoadImagesAndLabels('data.yaml', batch_size=8); print(len(d.ims if hasattr(d,'ims') else d))",看一眼图像数量是不是预期的,别等训练跑完才发现等于 0。
5.2 class_id从1开始:训练不报错但验证集mAP全零
现象:训练过程 loss 正常下降,但验证阶段 mAP 一直是 0,PR 曲线空荡荡;或者验证集里明明有第二类目标,模型却完全检不出来。
原因:部分标注工具或转换脚本里类别号从 1 开始写,txt 里只有 1 和 2,而 data.yaml 的 nc=2 只接受 0 和 1。此时类别 1 和 2 一个被当作错位类别,一个直接越界被忽略,模型在训练时把真正的目标当背景学习了。
解决:统一约定 class_id 从 0 开始。转换脚本里做一次 class_id = int(parts[0]) - 1 的校正,或者直接在 VOC 转换的 CLASS_MAP 里把值设成 0 和 1。同时养成习惯,训练前必跑 4.1 的检查脚本,类别越界会被直接打印出来,不用等训练完才发现。这个坑在接手别人导出的数据时特别容易踩,标注的人可能用的是 1 和 2 的编号习惯。
5.3 归一化坐标越界:loss变nan的隐形推手
现象:训练到某个 epoch,box_loss 和 obj_loss 突然跳成 nan,之后所有输出都是 nan,之前的训练白跑。
原因:labels 里存在小于 0 或大于 1 的坐标,或者 width/height 为负。这些值通常来自标注框画出图像边缘、转换脚本没做裁剪。mosaic 增强和自动锚框计算一碰到这类值,损失就容易扩散成 nan。
解决:转换脚本里加坐标裁剪和退化框丢弃的防御逻辑,也就是 3.1 代码里那几行 max/min 的处理;训练前跑 4.1 的坐标值域检查。如果已经训到一半发现 nan,别想着调低学习率救回来,先把标签清洗干净再重训,保存一次白跑的时间和电费相比其实更贵。遇到一次就会明白,防御性代码不是多余的。
5.4 验证集和训练集图像同源:mAP虚高骗过自己
现象:train loss 和 val loss 双双下降得又快又平滑,验证集 mAP 轻松上 0.9,但模型拿到果园新拍的图上实测,漏检一片,完全不是训练时的水准。
原因:划分数据集时直接对整个文件列表 random shuffle,而数据来自连拍或视频抽帧,相邻帧几乎同一画面,相似图像被同时分进训练集和验证集,验证集退化成记忆测试,测出来的分数自然虚高。
解决:按拍摄时间、果园地块、相机位姿分组后再划分。具体做法是把同一时段同一机位抽出的帧归进一个 group,划分时以 group 为单位整体进 train 或 val。代码上给文件列表加一列 group 字段,先对 group 去重再 shuffle,最后按 group 分配。这样验证集里每个场景在训练集中都没有近亲,mAP 才接近真实落地水平。我早期做柑橘数据就栽过这一回,mAP 0.93 的模型下地就露馅,后来按果园分块重划分,数字降到 0.87 但每个框都是真本事。
5.5 害虫框太小:缩放到640后边界框只剩几个像素
现象:训练收敛后,体型大的害虫检出率不错,但某一类特别小的害虫(比如红蜘蛛在图上只有三四十像素)几乎检不出,验证集里对应类别的 AP 低得可怜。
原因:YOLOv5 默认把训练图缩放到 640x640,一只 35x35 像素的红蜘蛛缩放后只剩 5 个像素左右,特征经过骨干网络数次下采样直接消失。这是小目标检测的经典困境,不是网络结构不行,是输入分辨率吃掉了目标的几何信息。
解决:训练时把 imgsz 提到 960 或 1280,代价是显存占用和推理速度,但小目标召回率通常会明显上升;或者对含小目标的图像做滑动窗口切块训练,把大图裁成若干瓦片,推理时用同样的切块逻辑再合并结果。另一个务实做法是用类别权重让损失函数更关注小框。按我的经验,2 类任务先试 imgsz=1280,改动最小、见效最快;如果数据本身连一张包含小目标的高清图都没有,那先补数据比调参更急。
6. 把验证集用到位:训练后评估与数据迭代闭环
验证集不只是训练时的刹车板,它是数据迭代的镜子。训练完第一件事是跑一次验证,确认模型真正的强项和短板:
python val.py --data data.yaml --weights runs/train/exp/weights/best.pt --conf 0.25 --iou 0.5跑完去 runs/val/exp 目录里重点看四个产物:混淆矩阵、PR_curve.png、labels.jpg 以及每类 AP 的统计 txt。2 类任务先看混淆矩阵,观察两个类互相误判的比例——如果 0 类大量被预测成 1 类,要么两类外观太像,要么 0 类样本明显不足。
接着做一个 badcase 提取动作:从验证集预测结果里把漏检和误检的图像单独抽出来排到一张联系表里。漏检的多半是遮挡目标、小目标、背光模糊目标;误检的多半是枯叶、水滴、虫壳这类硬负样本。针对失败模式补数据,比随机加图有效得多:漏检多就补对应姿态和光照的样本,误检多就补困难负样本。每类补到几千框的量级,集中补两三轮,AP 的提升会非常直观。迭代节奏可以固定成:训练 -> 看混淆矩阵 -> 抽 badcase -> 补标重训,一轮两三天,两三轮后基本稳定。
| 检查项 | 看什么 | 数据该动哪 |
|---|---|---|
| mAP@0.5 | 两个类别的整体水平 | 低于 0.8 优先补数据 |
| 混淆矩阵 | 0 类和 1 类互相误判比例 | 增加难负样本(枯叶、水滴、虫壳) |
| 每类 AP 差距 | 低的那个类就是瓶颈 | 优先扩充低频类别,拉平分布 |
最后提醒一句边界:验证集是考试卷,不是训练场的陪练。拿验证集反复调置信度阈值、调 NMS 参数,调到数字漂亮,再换一批数据立刻现原形。正确做法是验证集只用来看结果、选模型、抽 badcase,所有超参调整都用训练集内部的预留数据。我早期做柑橘数据时吃过这个亏,验证集分区太随意,mAP 数字虚高得让人乐观,落地实测才认清差距。后来把验证集按果园地块隔离,每个类别框数补到三千以上,模型的分数才经得起现场检验。这套数据集组织、自检、迭代的流程走通之后,再回头处理任何目标检测任务,基本不会在数据上翻车。希望帮到你。
本文还有配套的精品资源,点击获取