植物叶片病害检测数据集:29类VOC标注与YOLO转换实战
简介这份资源面向从事植物病害识别、农业智能检测方向的目标检测开发者与研究者提供一套可直接投入训练的大型叶片病害缺陷数据集覆盖葡萄叶黑腐病、番茄叶菌斑、苹果锈叶病、马铃薯晚疫病等29个类别采用标准VOC标注格式省去自行标注与清洗的繁琐环节。压缩包约934.99MB共2000个文件其中1999个xml标注文件与1个可视化py脚本data目录下按训练集与验证集分文件夹存放训练集含2345张图片及对应xml验证集含239张图片及对应xml并附带类别标签json字典文件。可视化脚本无需修改即可运行随机传入一张图片便能绘制边界框并保存到当前目录便于快速核验标注质量。目前已有256人学习下载适合需要快速搭建病害检测基线、验证模型效果或开展迁移学习实验的读者参考使用。1. 植物叶片病害检测数据集29 类 VOC 标注资源到底能跑出什么结果植物叶片病害识别这件事很多人第一反应是拿分类网络做图像分类但实际田间场景里一片叶子可能同时出现多种病斑分类标签根本描述不清楚。这份 29 类植物叶片病害缺陷检测数据集走的是目标检测路线用 VOC 格式把每一处病斑的位置和类别都框出来。训练集 2345 张图配 2345 个 XML验证集 239 张图配 239 个 XML类别字典单独放在 json 文件里还附带一个可视化脚本 show.py随机传一张图就能把边界框画出来存到当前目录。适合谁用做农业 AI 落地、想快速验证 YOLO 系列模型在细粒度病害检测上表现的工程师以及需要一份开箱即用 VOC 数据集来跑通训练流程的从业者。不用自己从零标注省掉最耗时的环节。2. VOC 标注格式拆解XML 里到底存了什么、怎么读2.1 VOC XML 的字段结构与 29 类标签映射VOC 格式的 XML 文件结构不复杂但字段含义得搞清楚不然后面转 YOLO 格式或者做数据增强时容易翻车。每个 XML 对应一张图根节点是annotation下面几个关键子节点folder和filename图片所在目录和文件名这个字段在移动数据后经常对不上后面避坑章节会细说。size包含width、height、depth记录原图尺寸转 YOLO 归一化坐标时全靠它。object每个标注目标一个里面name是类别名bndbox是xmin/ymin/xmax/ymax四个坐标。类别名直接写在name里比如grape_black_rot、tomato_leaf_mold、apple_rust这种。29 类具体名称在 json 字典文件里建议先读一遍确认类别名和 XML 里的name完全一致大小写、下划线、连字符都不能差。常见做法是用 Python 的xml.etree.ElementTree批量解析把所有类别名收集成集合和 json 里的 key 做差集差集为空才说明标注和字典对得上。import xml.etree.ElementTree as ET import json import os # 读取类别字典 with open(classes.json, r, encodingutf-8) as f: class_dict json.load(f) json_classes set(class_dict.values()) # 假设 json 是 {id: name} 结构 # 遍历训练集 XML收集实际出现的类别名 xml_dir data/train xml_classes set() 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() for obj in root.findall(object): name obj.find(name).text.strip() xml_classes.add(name) # 对比差异 only_in_xml xml_classes - json_classes only_in_json json_classes - xml_classes print(XML 有但 json 没有:, only_in_xml) print(json 有但 XML 没有:, only_in_json)这段脚本的逻辑很直接先把 json 字典里的类别名读成集合再遍历所有 XML 把name文本收集成另一个集合最后做差集。参数上唯一要注意的是 json 文件的结构如果它是{0: grape_black_rot, 1: tomato_leaf_mold}这种 id 到名称的映射取values()就对了如果 json 是列表形式直接set(class_dict)即可。跑完如果两个差集都为空说明标注和字典完全对齐可以放心进入下一步。2.2 用 show.py 做标注可视化验证数据集自带的 show.py 是个轻量验证工具随机传一张图就能把边界框画出来存到当前目录。这个脚本不用改直接运行就行但得知道它内部怎么读 XML、怎么画框出问题时才能定位。import cv2 import xml.etree.ElementTree as ET import random import os def visualize_annotation(img_path, xml_path, save_pathvis_result.jpg): # 读图 img cv2.imread(img_path) if img is None: raise FileNotFoundError(f图片读取失败: {img_path}) # 解析 XML tree ET.parse(xml_path) root tree.getroot() for obj in root.findall(object): name obj.find(name).text bndbox obj.find(bndbox) xmin int(bndbox.find(xmin).text) ymin int(bndbox.find(ymin).text) xmax int(bndbox.find(xmax).text) ymax int(bndbox.find(ymax).text) # 画框和类别名 cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 255, 0), 2) cv2.putText(img, name, (xmin, ymin - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) cv2.imwrite(save_path, img) print(f可视化结果已保存: {save_path}) # 随机选一张训练集图片 train_dir data/train img_files [f for f in os.listdir(train_dir) if f.endswith((.jpg, .png, .jpeg))] img_file random.choice(img_files) base_name os.path.splitext(img_file)[0] xml_file base_name .xml visualize_annotation( os.path.join(train_dir, img_file), os.path.join(train_dir, xml_file) )逻辑说明先读图再解析对应 XML遍历每个object取类别名和四个坐标用 OpenCV 画矩形和文字。参数上注意cv2.rectangle的坐标必须是整数XML 里存的是字符串得int()转换。如果图片路径和 XML 路径对不上脚本会报FileNotFoundError这时候检查文件名是否一致——数据集里图片和 XML 同名不同后缀这是 VOC 格式的惯例。跑完在当前目录看到vis_result.jpg框的位置和类别名都对说明标注质量没问题。3. 从 VOC 转 YOLO 格式转换脚本与四个边界坑3.1 坐标归一化与类别 id 映射YOLO 格式要求每张图对应一个 txt 文件每行是class_id x_center y_center width height全部归一化到 0 到 1 之间。VOC 的xmin/ymin/xmax/ymax是绝对像素坐标转换公式不复杂但有几个边界情况容易出错。import xml.etree.ElementTree as ET import os import json def voc_to_yolo(xml_path, classes, output_dir): tree ET.parse(xml_path) root tree.getroot() size root.find(size) img_w int(size.find(width).text) img_h int(size.find(height).text) lines [] for obj in root.findall(object): name obj.find(name).text.strip() if name not in classes: continue # 跳过字典里没有的类别 class_id classes.index(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 # 过滤无效框 if w 0 or h 0: continue lines.append(f{class_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}) # 写 txt base_name os.path.splitext(os.path.basename(xml_path))[0] txt_path os.path.join(output_dir, base_name .txt) with open(txt_path, w) as f: f.write(\n.join(lines)) # 批量转换 classes list(json.load(open(classes.json)).values()) os.makedirs(labels/train, exist_okTrue) for xml_file in os.listdir(data/train): if xml_file.endswith(.xml): voc_to_yolo(os.path.join(data/train, xml_file), classes, labels/train)逻辑说明先读 XML 拿到图片宽高再遍历每个 object 做坐标裁剪和归一化。参数上classes列表的顺序决定了 class_id必须和后续训练时的data.yaml里names顺序一致否则类别全乱。x_center和y_center是框中心点除以图片宽高w和h是框宽高除以图片宽高保留 6 位小数足够。过滤w 0 or h 0是为了防止标注时框画反了导致宽高为负。3.2 训练集与验证集的目录组织转换完标签后目录结构得按 YOLO 的要求组织。常见做法是建images/train、images/val、labels/train、labels/val四个目录图片和 txt 文件名一一对应只是后缀不同。# 创建目录结构 mkdir -p images/train images/val labels/train labels/val # 复制图片假设原图在 data/train 和 data/val cp data/train/*.jpg images/train/ cp data/val/*.jpg images/val/ # 标签已经在 labels/train 和 labels/val 里了然后写data.yamlpath: ./dataset train: images/train val: images/val nc: 29 names: [grape_black_rot, tomato_leaf_mold, apple_rust, ...] # 按 json 顺序填全 29 类参数说明nc是类别数必须和 json 里类别总数一致names列表顺序必须和转换脚本里的classes顺序完全一致。如果训练时发现类别预测全错九成是这里顺序对不上。验证集 239 张图虽然不多但足够做一轮快速验证看模型有没有学到东西。4. 避坑与排查XML 解析、坐标越界、类别不匹配的常见翻车4.1 现象show.py 画出来的框位置偏移或尺寸不对原因XML 里的size字段和实际图片尺寸不一致。数据集在整理时可能做过缩放但 XML 没同步更新导致归一化时用的宽高是错的。另一种可能是图片被旋转过EXIF 方向信息没处理OpenCV 读进来是旋转后的尺寸和 XML 记录的对不上。解决先写个脚本批量检查所有 XML 的size和实际图片尺寸是否一致。from PIL import Image import xml.etree.ElementTree as ET import os for xml_file in os.listdir(data/train): if not xml_file.endswith(.xml): continue tree ET.parse(os.path.join(data/train, xml_file)) root tree.getroot() size root.find(size) xml_w int(size.find(width).text) xml_h int(size.find(height).text) img_file os.path.splitext(xml_file)[0] .jpg img_path os.path.join(data/train, img_file) if not os.path.exists(img_path): print(f图片缺失: {img_file}) continue with Image.open(img_path) as im: real_w, real_h im.size if xml_w ! real_w or xml_h ! real_h: print(f尺寸不匹配: {xml_file}, XML: {xml_w}x{xml_h}, 实际: {real_w}x{real_h})跑完如果发现不匹配的要么重新标注要么用实际尺寸覆盖 XML 里的size。我一般会直接批量修正 XML省得后面训练时框全飘。4.2 现象转换后的 YOLO 标签里出现负坐标或大于 1 的值原因VOC 标注时框画到了图片外面xmin或ymin是负数xmax或ymax超过了图片宽高。转换脚本里虽然做了裁剪但如果裁剪逻辑写错比如先归一化再裁剪就会出问题。解决在转换脚本里裁剪必须在归一化之前做而且要用图片实际宽高做边界。上面 3.1 的脚本已经处理了关键是max(0, min(xmin, img_w))这个顺序不能反。转换完再写个校验脚本读所有 txt检查每行第二个到第五个值是否都在 0 到 1 之间。import os for txt_file in os.listdir(labels/train): if not txt_file.endswith(.txt): continue with open(os.path.join(labels/train, txt_file)) as f: for line_num, line in enumerate(f, 1): parts line.strip().split() if len(parts) ! 5: print(f{txt_file} 第 {line_num} 行字段数不对: {line}) continue vals [float(x) for x in parts[1:]] if any(v 0 or v 1 for v in vals): print(f{txt_file} 第 {line_num} 行坐标越界: {line})4.3 现象训练时 loss 不下降或者 mAP 一直是 0原因类别 id 映射错了。json 字典里类别顺序和data.yaml里names顺序不一致导致模型学的是错位的标签。另一种可能是 XML 里的name有空格或大小写不一致比如Tomato_Leaf_Mold和tomato_leaf_mold被当成两个类。解决先跑 2.1 的对比脚本确认 XML 类别名和 json 完全一致。然后检查data.yaml的names顺序必须和转换脚本里classes列表顺序一模一样。建议把classes列表直接存成 json训练时读同一个文件避免手写顺序出错。4.4 现象验证集评估时提示图片和标签数量不匹配原因验证集 239 张图但转换后 txt 文件少了几个。可能是某些 XML 里没有object转换脚本写了个空 txt或者图片格式不是 jpg 导致复制时漏了。解决统计images/val和labels/val的文件数必须相等。如果某个 XML 没有 object要么删掉对应图片要么在 txt 里写个空文件但训练时会报错最好直接排除。常见做法是过滤掉没有标注的图片保证每张图至少有一个框。import os img_files set(os.path.splitext(f)[0] for f in os.listdir(images/val)) label_files set(os.path.splitext(f)[0] for f in os.listdir(labels/val)) only_img img_files - label_files only_label label_files - img_files print(有图无标签:, only_img) print(有标签无图:, only_label)5. 进阶技巧用 29 类数据做类别平衡与难例挖掘5.1 统计类别分布并做重采样29 类病害数据类别不平衡是常态。葡萄叶黑腐病可能几百个框某些罕见病只有几十个。直接训练会导致模型偏向多数类少数类 mAP 很低。先统计每个类别的框数量再决定要不要重采样。import os from collections import Counter class_counts Counter() for txt_file in os.listdir(labels/train): if not txt_file.endswith(.txt): continue with open(os.path.join(labels/train, txt_file)) as f: for line in f: class_id int(line.strip().split()[0]) class_counts[class_id] 1 # 按类别 id 排序输出 for class_id in sorted(class_counts.keys()): print(f类别 {class_id}: {class_counts[class_id]} 个框)如果发现某些类别框数少于 100可以考虑两种策略一是对包含这些类别的图片做过采样在data.yaml里用train指向一个重复采样的文件列表二是用 YOLO 的copy_paste增强把少数类实例复制到其他图上。我一般会先跑一轮基线看少数类的 mAP 再决定要不要动。5.2 用验证集做难例挖掘验证集 239 张图虽然少但可以用来找模型翻车的样本。训练完一轮后用模型推理验证集把漏检和误检的图挑出来人工看一遍确认是标注问题还是模型问题。如果是标注问题比如框画得太松或太紧修正后重新训练如果是模型问题比如病斑太小可以调小 anchor 或提高输入分辨率。from ultralytics import YOLO import os model YOLO(runs/detect/train/weights/best.pt) results model.predict(images/val, saveTrue, conf0.25) # 统计每张图的检测框数量 for result in results: img_name os.path.basename(result.path) boxes result.boxes print(f{img_name}: 检测到 {len(boxes)} 个框)参数说明conf0.25是置信度阈值调低会检出更多框但误检也更多调高则漏检增加。难例挖掘时可以先设 0.1 看模型到底学到了什么再逐步调高。saveTrue会把画框后的图存到runs/detect/predict目录直接看哪些图漏了。5.3 输入分辨率与 anchor 调整植物病害的斑块通常比较小YOLO 默认 640 输入可能不够。如果显存允许把imgsz调到 1280小目标召回率会明显提升。anchor 方面YOLOv5 默认 anchor 是针对 COCO 的对病斑这种小目标偏大可以用kmeans重新聚类生成 anchor。# YOLOv5 聚类 anchor python utils/autanchor.py --data data.yaml --img-size 1280 --thr 4.0 --n 9跑完把输出的 anchor 替换到模型配置文件里。注意--img-size要和训练时的imgsz一致否则 anchor 尺度对不上。我一般会在基线跑完后对比默认 anchor 和聚类 anchor 的 mAP如果提升不到 1 个点就不折腾了毕竟重新聚类和调参也费时间。从那以后我每次拿到 VOC 数据集都强制先跑一遍类别对比和尺寸校验再开始转换。这两个脚本花不了几分钟但能省掉后面几小时的排查。希望帮到你。本文还有配套的精品资源点击获取