ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

柑橘病害检测数据集实战:VOC+YOLO双格式2814张训练YOLOv8

柑橘病害检测数据集实战:VOC+YOLO双格式2814张训练YOLOv8 简介本数据集面向从事水果病害识别、农业视觉检测与深度学习模型训练的研究者与开发者聚焦橙子、橘子、桔子果实表面的四类病害识别任务可用于目标检测模型的训练、验证与算法对比实验。资源采用Pascal VOC与YOLO双格式标注包含2814张jpg图片及一一对应的xml与txt标注文件标注类别为blackspot、canker、fresh、grenning四类需注意标注对象为果实病害而非叶片病害。压缩包共约2000个文件以1999个xml标注文件和1个说明txt为主整体大小82.45MB目录结构清晰便于直接接入主流检测框架。目前已有504人学习下载适合需要快速构建果实病害检测基线、验证数据增强策略或开展多类别病害分类研究的读者参考使用。1. 橙子橘子桔子病害检测数据集2814 张 VOCYOLO 双格式到底能干什么果园里最怕的不是虫是叶子上一块不起眼的黄斑。橙子、橘子、桔子这三兄弟的病害症状高度相似黄龙病、溃疡病、炭疽病、健康叶肉眼在早期几乎分不出来。我去年帮一个柑橘合作社做巡园方案最初想用现成的公开数据集结果发现大部分是番茄、葡萄的柑橘类少得可怜而且类别定义混乱。后来拿到一份 2814 张、4 类别的 VOCYOLO 双格式数据集才算把训练链路跑通。这个数据集的核心价值在于它同时给了 VOC 的 XML 标注和 YOLO 的 TXT 标注意味着你不需要自己写转换脚本就能直接喂给 YOLOv5/v8。4 个类别对应柑橘最常见的病害与健康状态2814 张的规模不算大但足够做迁移学习后的微调。适合谁适合手上有果园巡检需求、想快速验证检测方案可行性的工程师也适合刚学 YOLO 训练、需要一个真实农业场景练手的人。它解决的不是“从零训练一个通用模型”的问题而是“用最小成本跑通柑橘病害检测闭环”的问题。2. 拆开压缩包先看什么VOC 与 YOLO 双格式的目录结构和类别映射拿到一个数据集压缩包最忌讳直接unzip然后train.py一把梭。我一般会先花十分钟把目录结构和标注格式摸清楚否则后面训练 loss 不降、mAP 为 0 的时候你连是数据问题还是代码问题都分不清。2.1 VOC 格式的 Annotations 与 JPEGImages 对应关系VOC 格式的标准结构是Annotations/放 XMLJPEGImages/放原图ImageSets/Main/放训练验证划分文件。这个数据集既然是 VOCYOLO 双格式大概率是两套目录并列或者一套目录下同时有 xml 和 txt。你需要确认的第一件事是XML 里的filename字段和 JPEGImages 里的实际文件名是否完全一致包括大小写和后缀。# 先看目录层级不要急着解压到当前目录 unzip -l 橙子橘子桔子病害检测数据集VOCYOLO格式2814张4类别.zip | head -50 # 解压到独立目录避免污染工作区 mkdir -p ~/datasets/citrus_disease unzip 橙子橘子桔子病害检测数据集VOCYOLO格式2814张4类别.zip -d ~/datasets/citrus_disease # 统计图片数量和标注数量是否对齐 find ~/datasets/citrus_disease -name *.jpg | wc -l find ~/datasets/citrus_disease -name *.xml | wc -l find ~/datasets/citrus_disease -name *.txt | grep -v classes | wc -l上面三条find命令分别统计 jpg、xml、txt 的数量。如果 jpg 是 2814xml 也是 2814txt 略少或相等说明标注基本完整。如果 txt 数量明显少于 jpg可能是部分图片没有 YOLO 标注需要检查是不是空标注文件被过滤了。注意grep -v classes是为了排除classes.txt这个类别名文件它本身不是标注。2.2 四个类别在 YOLO 的 classes.txt 里怎么排YOLO 格式的类别索引是 0 起始的整数classes.txt里每一行对应一个类别名行号就是类别 ID。这个数据集是 4 类别你需要打开classes.txt确认顺序。常见柑橘病害数据集的类别可能是0 黄龙病、1 溃疡病、2 炭疽病、3 健康。但不同标注者可能把健康放在 0或者把桔子单独作为一类。# 查看 classes.txt 内容确认类别顺序 with open(~/datasets/citrus_disease/classes.txt, r, encodingutf-8) as f: classes [line.strip() for line in f.readlines() if line.strip()] for idx, name in enumerate(classes): print(f类别 ID {idx}: {name}) # 统计每个类别的标注框数量判断类别是否均衡 import os from collections import Counter label_dir ~/datasets/citrus_disease/labels counter Counter() for txt_file in os.listdir(label_dir): if not txt_file.endswith(.txt) or txt_file classes.txt: continue with open(os.path.join(label_dir, txt_file), r) as f: for line in f: cls_id int(line.split()[0]) counter[cls_id] 1 print(各类别标注框数量:, dict(counter))这段代码先打印类别映射再遍历所有 YOLO 标注文件统计每个类别的框数。如果某个类别只有几十个框而其他类别有上千个训练时就会出现严重的类别不平衡模型会倾向于预测多数类。我一般会要求每个类别至少占总框数的 10%否则需要做重采样或加类别权重。参数说明label_dir要换成你实际的 labels 目录路径cls_id是每行第一个数字代表类别索引。2.3 用一条命令检查标注框是否越界YOLO 格式的标注是class_id x_center y_center width height全部归一化到 0-1。如果标注时坐标算错会出现负数或大于 1 的值训练时直接报错或者产生玄学 loss。用下面这段脚本可以快速扫一遍。import os def check_yolo_labels(label_dir): bad_files [] for txt_file in os.listdir(label_dir): if not txt_file.endswith(.txt) or txt_file classes.txt: continue path os.path.join(label_dir, txt_file) with open(path, r) as f: for line_num, line in enumerate(f, 1): parts line.strip().split() if len(parts) ! 5: bad_files.append((txt_file, line_num, 字段数不对)) continue cls_id, x, y, w, h parts vals [float(x), float(y), float(w), float(h)] if any(v 0 or v 1 for v in vals): bad_files.append((txt_file, line_num, f坐标越界: {vals})) return bad_files bad check_yolo_labels(~/datasets/citrus_disease/labels) print(f发现 {len(bad)} 处异常) for item in bad[:10]: print(item)逻辑很直接逐行读取检查字段数是否为 5检查四个坐标值是否在 0 到 1 之间。发现越界就记录下来。我遇到过标注工具导出时把像素坐标直接写进去的情况x_center 变成 640 这种值训练时模型完全学不动。这一步花两分钟能省后面两小时排查。3. 从 VOC 到 YOLO 的转换脚本、参数和四个容易翻车的边界虽然这个数据集号称双格式但实际拿到的 VOC 和 YOLO 可能不是同一批图或者 YOLO 标注有缺失。更常见的情况是你手头只有 VOC需要自己转 YOLO。所以这一章把转换脚本写清楚顺便把四个边界坑填了。3.1 VOC XML 解析与 YOLO TXT 生成的完整脚本VOC 的 XML 里bndbox存的是xmin, ymin, xmax, ymax的像素绝对值。YOLO 需要的是归一化的中心点和宽高。转换公式x_center (xmin xmax) / 2 / img_widthy_center (ymin ymax) / 2 / img_heightwidth (xmax - xmin) / img_widthheight (ymax - ymin) / img_height。import os import xml.etree.ElementTree as ET from PIL import Image def voc_to_yolo(xml_dir, img_dir, out_dir, classes): os.makedirs(out_dir, exist_okTrue) class_to_id {name: idx for idx, name in enumerate(classes)} skipped 0 for xml_file in os.listdir(xml_dir): if not xml_file.endswith(.xml): continue tree ET.parse(os.path.join(xml_dir, xml_file)) root tree.getroot() # 获取图片尺寸优先从 XML 读读不到就打开图片 size root.find(size) if size is not None: img_w int(size.find(width).text) img_h int(size.find(height).text) else: img_name root.find(filename).text with Image.open(os.path.join(img_dir, img_name)) as im: img_w, img_h im.size lines [] for obj in root.findall(object): cls_name obj.find(name).text.strip() if cls_name not in class_to_id: skipped 1 continue cls_id class_to_id[cls_name] bndbox obj.find(bndbox) xmin float(bndbox.find(xmin).text) ymin float(bndbox.find(ymin).text) xmax float(bndbox.find(xmax).text) ymax float(bndbox.find(ymax).text) # 裁剪到图片边界内防止越界 xmin max(0, min(xmin, img_w)) xmax max(0, min(xmax, img_w)) ymin max(0, min(ymin, img_h)) ymax max(0, min(ymax, img_h)) x_center (xmin xmax) / 2.0 / img_w y_center (ymin ymax) / 2.0 / img_h w (xmax - xmin) / img_w h (ymax - ymin) / img_h lines.append(f{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}) out_name os.path.splitext(xml_file)[0] .txt with open(os.path.join(out_dir, out_name), w) as f: f.write(\n.join(lines)) print(f转换完成跳过 {skipped} 个未知类别) classes [huanglongbing, ulcer, anthracnose, healthy] voc_to_yolo( xml_dir~/datasets/citrus_disease/Annotations, img_dir~/datasets/citrus_disease/JPEGImages, out_dir~/datasets/citrus_disease/labels_converted, classesclasses )参数说明classes列表的顺序必须和你想用的类别 ID 一致不能随便改。skipped统计的是 XML 里出现了但不在 classes 列表里的类别名如果这个数很大说明你的类别名写错了或者数据集有额外类别。坐标裁剪那四行是后悔药防止标注框超出图片边界导致归一化后出现大于 1 的值。3.2 图片和标注文件名不一致时怎么对齐VOC 的 XML 里filename字段有时和实际 jpg 文件名不一致比如 XML 写的是IMG_001.jpg实际文件是img_001.JPG。YOLO 训练时是按文件名找标注的对不上就直接跳过你会在日志里看到大量ignoring或者No labels found。# 批量检查 XML 里的 filename 和实际文件名的差异 python3 -c import os, xml.etree.ElementTree as ET xml_dir ~/datasets/citrus_disease/Annotations img_dir ~/datasets/citrus_disease/JPEGImages img_files set(os.listdir(img_dir)) mismatch [] for x in os.listdir(xml_dir): if not x.endswith(.xml): continue root ET.parse(os.path.join(xml_dir, x)).getroot() fn root.find(filename).text if fn not in img_files: mismatch.append((x, fn)) print(f不一致数量: {len(mismatch)}) for m in mismatch[:5]: print(m) 如果发现不一致最简单的做法是以 XML 的 filename 为准把 jpg 重命名。但重命名前先备份避免改错。另一种做法是修改 XML 里的 filename 字段但 XML 多了改起来慢。我一般写个映射字典在转换脚本里做一次替换。3.3 空标注文件和纯背景图的处理策略有些图片里没有任何病害目标XML 里object数量为 0转换后生成的 txt 是空文件。YOLO 训练时空 txt 会被当作负样本背景图这本身没问题但如果空文件太多模型会学到“什么都别检测”的偷懒策略。你需要统计空标注比例。import os empty_count 0 total 0 for txt in os.listdir(~/datasets/citrus_disease/labels_converted): if not txt.endswith(.txt): continue total 1 if os.path.getsize(os.path.join(~/datasets/citrus_disease/labels_converted, txt)) 0: empty_count 1 print(f空标注文件: {empty_count}/{total} {empty_count/total:.2%})如果空标注超过 20%建议把一部分空标注图从训练集里剔除或者用--rect等策略控制。我一般保留 5%-10% 的背景图作为负样本太多反而拖累召回率。3.4 训练集验证集划分别用随机划分坑自己同一棵树的叶子可能被拍了好几张如果随机划分训练集和验证集里会出现同一棵树的相似图片验证集精度虚高上线后翻车。正确做法是按拍摄批次或按树划分。import os, random, shutil all_imgs [f for f in os.listdir(~/datasets/citrus_disease/JPEGImages) if f.endswith(.jpg)] # 假设文件名前缀代表拍摄批次按前缀分组 groups {} for img in all_imgs: prefix img.split(_)[0] groups.setdefault(prefix, []).append(img) group_keys list(groups.keys()) random.seed(42) random.shuffle(group_keys) split int(len(group_keys) * 0.8) train_groups group_keys[:split] val_groups group_keys[split:] for phase, gs in [(train, train_groups), (val, val_groups)]: os.makedirs(f~/datasets/citrus_disease/images/{phase}, exist_okTrue) os.makedirs(f~/datasets/citrus_disease/labels/{phase}, exist_okTrue) for g in gs: for img in groups[g]: shutil.copy(f~/datasets/citrus_disease/JPEGImages/{img}, f~/datasets/citrus_disease/images/{phase}/{img}) txt os.path.splitext(img)[0] .txt src_txt f~/datasets/citrus_disease/labels_converted/{txt} if os.path.exists(src_txt): shutil.copy(src_txt, f~/datasets/citrus_disease/labels/{phase}/{txt})这段脚本按文件名前缀分组保证同一批次的图片要么全在训练集要么全在验证集。random.seed(42)保证可复现。如果你的文件名没有批次信息至少要用hash或md5做分组别直接random.shuffle整个列表。4. YOLOv8 训练柑橘病害检测环境、参数和显存不够的解法数据集准备好了接下来是训练。YOLOv8 是目前最顺手的选择ultralytics包把训练、验证、导出都封装好了。但环境配置和参数设置有几个地方容易卡住。4.1 Anaconda 环境配置与 ultralytics 安装我一般用 conda 建独立环境避免和系统 Python 打架。Python 版本选 3.9 或 3.10太新了有些依赖还没跟上。conda create -n citrus_yolo python3.10 -y conda activate citrus_yolo # 安装 PyTorch根据你的 CUDA 版本选命令 # CUDA 11.8 用下面这条 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 # 安装 ultralytics pip install ultralytics # 验证安装 yolo checksyolo checks会打印环境信息包括 PyTorch 版本、CUDA 是否可用、GPU 型号。如果 CUDA 显示不可用检查驱动版本和 PyTorch 的 CUDA 版本是否匹配。这一步不通过后面训练会回退到 CPU2814 张图能跑到天荒地老。4.2 data.yaml 的五个必填字段和路径写法YOLOv8 用data.yaml描述数据集。这个文件写错训练直接报Dataset not found。path: /home/user/datasets/citrus_disease train: images/train val: images/val nc: 4 names: 0: huanglongbing 1: ulcer 2: anthracnose 3: healthypath是数据集根目录train和val是相对于path的图片目录。注意train写的是图片目录YOLOv8 会自动去找同级的labels目录。nc是类别数必须和names的长度一致。names的键是类别 ID值是类别名顺序要和classes.txt一致。我见过有人把names写成列表[a,b,c,d]YOLOv8 也支持但字典写法更不容易错。4.3 训练命令与 batch size 显存不够的降级方案yolo detect train \ data/home/user/datasets/citrus_disease/data.yaml \ modelyolov8n.pt \ epochs100 \ imgsz640 \ batch16 \ lr00.01 \ patience20 \ projectcitrus_runs \ nameexp1参数说明modelyolov8n.pt用预训练权重2814 张图从零训练不够必须迁移学习。imgsz640是输入尺寸柑橘病害的斑点有时很小640 是底线再小可能漏检。batch16在 8GB 显存上跑 640 尺寸大概刚好如果 OOM 就降到 8 或 4。patience20表示 20 个 epoch 验证指标不提升就早停省时间。lr00.01是初始学习率微调任务可以降到 0.001。如果显存不够除了降 batch还可以用imgsz512或者开启ampTrue自动混合精度。但imgsz降了之后小目标检测会变差需要权衡。4.4 训练日志里该盯哪三个指标训练开始后控制台会打印每个 epoch 的box_loss、cls_loss、dfl_loss和mAP50、mAP50-95。我一般盯三个cls_loss是否稳定下降、mAP50是否在 50 个 epoch 内超过 0.5、验证集的box_loss和训练集是否差距过大。如果cls_loss震荡不降检查学习率是不是太大。如果mAP50一直低于 0.3大概率是标注有问题或者类别不平衡。如果训练集 loss 降但验证集 loss 升过拟合了加数据增强或者减模型复杂度。5. 柑橘病害检测的避坑与排查标注、类别和过拟合的五个血泪教训这一章记录我在柑橘病害数据集上踩过的五个坑每个都按现象、原因、解决来写。你如果训练时遇到类似情况可以直接对号入座。5.1 现象mAP 始终为 0模型什么都不检测原因最常见的是data.yaml里的names顺序和标注文件里的类别 ID 对不上。比如标注里 0 是健康叶但names里 0 写成了黄龙病模型学出来的类别全错。另一种可能是图片路径写错YOLOv8 根本没读到图训练的是空数据集。解决先用yolo detect train加--verbose看数据加载日志确认读到了多少张图、多少个标注框。然后写个脚本随机抽 10 张图把 YOLO 标注画到图上肉眼确认框的位置和类别是否正确。import cv2 import os def draw_yolo_label(img_path, label_path, classes, save_path): img cv2.imread(img_path) h, w img.shape[:2] with open(label_path) as f: for line in f: cls_id, x, y, bw, bh map(float, line.split()) x1 int((x - bw/2) * w) y1 int((y - bh/2) * h) x2 int((x bw/2) * w) y2 int((y bh/2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, classes[int(cls_id)], (x1, y1-5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imwrite(save_path, img) classes [huanglongbing, ulcer, anthracnose, healthy] draw_yolo_label(sample.jpg, sample.txt, classes, check.jpg)5.2 现象验证集 mAP 很高但拿果园实拍图测试全错原因训练集和验证集划分时没有按树或按批次分组同一棵树的相似图片同时出现在两边验证集精度虚高。实拍图的光照、角度、背景和数据集差异大模型泛化不了。解决重新按拍摄批次划分确保验证集里的树在训练集里没出现过。另外在训练时加颜色抖动、随机裁剪、马赛克增强提升对光照和角度的鲁棒性。YOLOv8 默认开启 mosaic但颜色增强需要手动调hsv_h、hsv_s、hsv_v参数。5.3 现象某个类别永远检测不到其他类别正常原因类别不平衡。如果健康叶有 2000 个框炭疽病只有 80 个框模型会直接放弃少数类全部预测多数类也能拿到不错的整体 mAP。解决先统计各类别框数如果少数类占比低于 10%做三件事一是对少数类图片过采样在data.yaml里用train指向一个包含重复文件的目录二是训练时加cls权重YOLOv8 支持class_weights参数三是降低置信度阈值看少数类是否有输出如果阈值降到 0.1 还是没框说明模型完全没学到需要检查标注质量。5.4 现象训练到一半 loss 突然变成 NaN原因学习率太大或者标注里有非法值坐标越界、宽高为 0。YOLOv8 的dfl_loss对非法标注很敏感一个宽高为 0 的框就能让 loss 爆炸。解决训练前跑一遍第 2.3 节的越界检查脚本把宽高小于 0.001 的框删掉。学习率从 0.01 降到 0.001 再试。如果还 NaN加梯度裁剪--clip_grad 10。5.5 现象推理时同一张图置信度阈值调高调低结果差异巨大原因模型对某些样本的输出概率集中在 0.3-0.5 之间阈值 0.5 时漏检0.25 时误检。这说明模型没有充分收敛或者训练数据里正负样本边界模糊。解决不要只用一个阈值。先跑yolo detect val看不同置信度下的 P-R 曲线选 F1 最高的点作为阈值。如果曲线整体偏低回到训练阶段增加 epoch 或加更多难例。我一般会在验证集上扫 0.1 到 0.9 的阈值选 mAP50 最高的那个而不是拍脑袋定 0.5。6. 让 2814 张图发挥更大价值难例挖掘与推理阈值调优的实操技巧2814 张图在农业检测里不算多但用好了足够上线一个巡园辅助工具。这一章讲两个进阶技巧怎么从误检里挖难例以及怎么用验证集调出最适合你果园的置信度阈值。6.1 用验证集误检结果做一轮难例挖掘模型训练完后跑一次验证把误检和漏检的图挑出来人工复核标注然后把这些图加进训练集再训一轮。这是提升 mAP 最有效的方法没有之一。# 跑验证并保存误检图 yolo detect val \ modelcitrus_runs/exp1/weights/best.pt \ data/home/user/datasets/citrus_disease/data.yaml \ conf0.25 \ save_jsonTrue \ plotsTruesave_jsonTrue会生成 COCO 格式的预测结果plotsTrue会画混淆矩阵和 P-R 曲线。然后写脚本对比预测 JSON 和真实标注找出 FP 和 FN 对应的图片。import json with open(citrus_runs/exp1/predictions.json) as f: preds json.load(f) # 这里需要和 ground truth 对比伪代码示意逻辑 # 实际可以用 pycocotools 的 COCOeval 拿到每个类别的 FP/FN 图片 ID # 把对应图片复制到 hard_examples 目录人工复核后加入训练集我一般会做两到三轮难例挖掘每轮加 100-200 张误检图mAP 能涨 3-5 个点。注意加进去的图要重新标注不能直接复制原标注因为误检往往是因为标注漏了或者框不准。6.2 置信度阈值和 NMS IoU 的联合调参YOLOv8 推理时有两个关键参数conf置信度阈值和iouNMS 的 IoU 阈值。conf控制哪些框保留iou控制重叠框合并。柑橘病害的斑点经常密集出现iou设太大比如 0.7会导致相邻病斑被合并成一个设太小比如 0.3会导致同一个病斑出多个框。参数作用建议范围调整方向conf置信度阈值0.2-0.5漏检多就降误检多就升iouNMS IoU 阈值0.4-0.6病斑密集就降稀疏就升imgsz推理尺寸640-1280小病斑多就升速度慢就降max_det每图最大检测数100-300病斑密集就升调参方法固定iou0.5在验证集上扫conf从 0.1 到 0.9步长 0.05记录每个阈值的 precision、recall、F1。选 F1 最高的conf。然后固定这个conf扫iou从 0.3 到 0.7选 mAP50 最高的。最后用实拍图验证如果实拍图漏检多把conf再降 0.05。# 扫描 conf 阈值 for conf in 0.1 0.15 0.2 0.25 0.3 0.35 0.4 0.45 0.5; do yolo detect val \ modelcitrus_runs/exp1/weights/best.pt \ data/home/user/datasets/citrus_disease/data.yaml \ conf$conf \ iou0.5 \ nameconf_$conf done跑完后看每个conf_*目录下的results.csv找 F1 最高的那个。我去年在柑橘数据集上最终选的是conf0.3、iou0.45实拍图漏检率从 18% 降到了 7%。6.3 导出 ONNX 做端侧部署的注意点如果要把模型部署到巡检设备上导出 ONNX 是常见做法。YOLOv8 导出命令很简单但有几个参数影响推理结果。yolo export \ modelcitrus_runs/exp1/weights/best.pt \ formatonnx \ imgsz640 \ opset12 \ simplifyTrue \ dynamicFalseopset12兼容性最好simplifyTrue会优化计算图dynamicFalse固定输入尺寸推理速度更快。导出后一定要用onnxruntime跑一张图和 PyTorch 的输出对比确保数值误差在 1e-3 以内。我遇到过simplify后某些算子被错误合并导致输出框偏移的情况对比验证不能省。6.4 我自己的习惯先跑通再优化别一上来就调模型结构做了几年检测我最大的教训是数据集质量决定上限模型结构只决定逼近上限的速度。2814 张图4 个类别YOLOv8n 足够。与其花时间改注意力模块不如把标注再检查一遍把误检图再加一轮。我现在的习惯是拿到新数据集先用yolov8n跑 50 个 epoch 看 baseline然后花 80% 时间在数据清洗和难例挖掘上最后才考虑换模型。这个顺序让我少走了很多弯路。希望帮到你。本文还有配套的精品资源点击获取
返回列表