简介:面向茶叶病害识别与农业视觉检测任务,这份数据集整合了9591张茶叶图像的VOC与YOLO双重标注,覆盖茶叶黑腐病、褐枯病、锈病、红蜘蛛为害、茶蚊虫为害、白星病等8个类别,可服务于目标检测模型的训练与验证,适合农业AI研发人员、计算机视觉学习者及植物病理研究者使用。压缩包共2000个文件,以1999个XML标注文件为主,另附1个TXT使用说明,标注与图片一一对应,包体约101.93MB,整体便于下载与复用;XML与TXT组合既适合VOC流程,也能灵活转换到YOLO场景,目录结构清晰,方便后续扩展。当前已有1223人学习浏览,资源热度较好,是茶叶病害检测方向值得参考的数据资源。通过这套数据可获得标准化的双格式标注,省去手动标注和格式转换环节,同时明确各类别命名规则,便于扩展数据集与对比模型效果,可帮助快速进入茶叶病害检测实验或项目部署。
1. 茶叶病害检测为什么值得用这个数据集展开一次实验
做农业视觉落地的工程师基本都绕不开一个尴尬:模型结构有的是,公开的病害数据集却少得可怜,尤其是茶叶这种经济作物。茶园病害直接连到收购价,检测做得好不好不是发论文的事,是能不能在真实叶片上少漏检一簇病斑的事。这个茶叶病害检测数据集包含9591张真实场景图像和8个类别标注,同时提供VOC和YOLO两种格式,属于拿来就能训的开箱型资源。对刚接触目标检测的人来说,它解决的是“有数据可练手、有格式能跑通”的问题;对已经在做农业检测的工程师来说,它则是一个可以直接做迁移学习、跑基线甚至上线预研的起点。下面按我实际处理的顺序,把数据集结构、格式转换、训练配置和踩过的坑完整过一遍。
2. VOC与YOLO两种标注格式:同一个数据集,两种完全不同的打开方式
2.1 VOC格式的目录结构与annotation读取逻辑
VOC格式最早来自PASCAL VOC竞赛,它的目录组织方式几乎是目标检测领域的通用语言。这个数据集既然标了VOC格式,结构上应该遵循同样的约定:JPEGImages放原图,Annotations放XML标注文件,ImageSets/Main放训练集、验证集、测试集的划分txt。标注的最小单位是object节点,每个object包含name(类别名)、difficult(难例标记)和bndbox(真实框坐标,xmin、ymin、xmax、ymax)。
我在拿到这类数据后不会急着直接丢进训练脚本,而是先写一段代码把标注读一遍,确认三件事:类别名和实际病害是否一致、bndbox坐标有没有超出图像边界、以及有没有空的XML文件。很多数据集发布时是程序自动导出的,容易出现某个类别只出现在训练集但不出现在验证集的情况,这种问题等到训练完看mAP时才发现就晚了。
import xml.etree.ElementTree as ET import os ann_dir = "Annotations" img_dir = "JPEGImages" def check_voc_annotation(xml_path): tree = ET.parse(xml_path) root = tree.getroot() objects = root.findall("object") if len(objects) == 0: print(f"[WARN] 空标注: {xml_path}") return None size = root.find("size") img_w = int(size.find("width").text) img_h = int(size.find("height").text) for obj in objects: name = obj.find("name").text bbox = obj.find("bndbox") xmin = int(float(bbox.find("xmin").text)) ymin = int(float(bbox.find("ymin").text)) xmax = int(float(bbox.find("xmax").text)) ymax = int(float(bbox.find("ymax").text)) if xmin < 0 or ymin < 0 or xmax > img_w or ymax > img_h: print(f"[WARN] 越界框: {xml_path}, {name}, ({xmin},{ymin},{xmax},{ymax})") return len(objects) xml_files = [f for f in os.listdir(ann_dir) if f.endswith(".xml")] for f in xml_files[:50]: check_voc_annotation(os.path.join(ann_dir, f))这段代码做的事情很简单:读取XML里的size节点和object节点,逐个检查坐标合法性。核心在于bbox读取时一定要用int()包一层,因为XML里存的是字符串,直接拿去算IoU会得到错误结果甚至直接报TypeError。越界框在后续归一化和训练中会导致loss计算出现异常值,尽早排查比训练出来再怀疑人生要好得多。
2.2 YOLO格式的核心差异:归一化坐标与txt文件组织
YOLO格式和VOC格式看起来是同一个目标检测任务,但存储逻辑完全不同。每个图像对应一个同名txt文件,每一行是一个目标:类别id、归一化后的中心点x、中心点y、归一化后的宽w、归一化后的高h。所有数值都是0到1之间的小数,类别id从0开始连续编号。
这里有一个非常容易被新手忽略的细节:VOC的bndbox是左上角和右下角的像素坐标,YOLO是中心点和宽高的相对坐标,两者之间的转换不只是一个单位换算,还涉及坐标系的切换。正确做法是先计算box的像素宽度和高度,再除以图像宽高得到归一化值,而不是直接把xmin、ymin、xmax、ymax丢进txt。
def voc_to_yolo(xml_path, img_w, img_h, class_map): tree = ET.parse(xml_path) root = tree.getroot() lines = [] for obj in root.findall("object"): name = obj.find("name").text if name not in class_map: class_id = len(class_map) class_map[name] = class_id bbox = obj.find("bndbox") xmin = float(bbox.find("xmin").text) ymin = float(bbox.find("ymin").text) xmax = float(bbox.find("xmax").text) ymax = float(bbox.find("ymax").text) x_center = (xmin + xmax) / 2.0 / img_w y_center = (ymin + ymax) / 2.0 / img_h width = (xmax - xmin) / img_w height = (ymax - ymin) / img_h width = min(width, 1.0) height = min(height, 1.0) lines.append(f"{class_map[name]} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}") return lines参数说明:class_map是类别名字到id的映射字典,建议提前定义好而不是在转换过程中动态生成,否则同一个类别在不同文件里可能得到不同的id。坐标取到6位小数足够,YOLO训练时浮点精度不会成为瓶颈,反而保留过多位数会让txt文件无谓地变大。宽度和高度做min裁剪是为了防止标注本身越界导致归一化值大于1,但裁剪不是修复问题,只是不让它在训练时立即爆炸,根本解法还是回到2.1节的校验脚本里定位越界源。
2.3 数据集划分:VOC的Main目录与YOLO的train/val目录
VOC格式的数据集划分写在ImageSets/Main下的train.txt、val.txt和test.txt里,每个文件一行一个文件名(不带扩展名)。而YOLO训练时通常按目录划分,比如images/train放训练图,images/val放验证图,labels/train放对应的标注。同一个数据集要在两套格式之间切换,最稳妥的做法是维护一份统一的划分名单,然后同时生成两套目录结构,而不是各自划分一次。
我在处理这个数据集时习惯按大约8:1:1的比例切分,而且要保证每个类别在训练集、验证集中都出现过。如果某个类别只出现在训练集而验证集没有,验证过程的对应AP会直接显示为0或者干脆不计算,这会对模型真实效果形成误导。切分时需要对每个类别的样本数做一次统计,然后按类别比例做分层采样。
# 建目录结构 mkdir -p images/train images/val images/test mkdir -p labels/train labels/val labels/test # 按划分名单复制图像 while read line; do cp JPEGImages/${line}.jpg images/train/ cp Annotations/${line}.xml labels/train_tmp/ # 注意这里是占位,实际应转换后存放 done < train.txt这个脚本里的标签复制是示意,实际操作中要先把XML转换txt,再复制到labels/train。不要直接在原目录上做in-place转换,因为VOC和YOLO的标签文件扩展名不同(xml对txt),但万一转换脚本只处理了部分文件,原始标注就被覆盖了,没有后悔药可吃。
3. 从VOC到YOLO的完整转换:脚本实现与四个边界坑
3.1 完整转换脚本:从目录扫描到标签落盘
写转换脚本时不要只盯着单个XML文件,要从整个数据集的维度考虑。正确的流程是:扫描全部XML、收集类别清单、建立类别映射、逐个转换、按划分名单分发到目标目录。这样可以保证类别id在全局保持一致,不会出现训练集里茶饼病是0、验证集里茶饼病变成1的情况。
import os import xml.etree.ElementTree as ET from collections import defaultdict import shutil ann_dir = "Annotations" img_dir = "JPEGImages" out_img_dir = "images" out_label_dir = "labels" split_dir = "ImageSets/Main" # 第一步:扫描所有类别 class_counts = defaultdict(int) xml_files = [f for f in os.listdir(ann_dir) if f.endswith(".xml")] for f in xml_files: tree = ET.parse(os.path.join(ann_dir, f)) for obj in tree.getroot().findall("object"): class_counts[obj.find("name").text] += 1 print("类别分布:", dict(class_counts)) class_map = {name: idx for idx, name in enumerate(sorted(class_counts.keys()))} print("类别映射:", class_map) # 第二步:读取划分名单 for split in ["train", "val", "test"]: split_file = os.path.join(split_dir, f"{split}.txt") if not os.path.exists(split_file): print(f"[WARN] 缺少划分文件: {split_file}") continue os.makedirs(os.path.join(out_img_dir, split), exist_ok=True) os.makedirs(os.path.join(out_label_dir, split), exist_ok=True) with open(split_file, "r") as f: names = [line.strip() for line in f if line.strip()] for name in names: # 复制图像 src_img = os.path.join(img_dir, f"{name}.jpg") dst_img = os.path.join(out_img_dir, split, f"{name}.jpg") if os.path.exists(src_img): shutil.copy(src_img, dst_img) else: print(f"[WARN] 图像缺失: {src_img}") # 转换并写入标签 xml_path = os.path.join(ann_dir, f"{name}.xml") if not os.path.exists(xml_path): print(f"[WARN] 标注缺失: {xml_path}") continue 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) label_lines = [] for obj in root.findall("object"): cls_name = obj.find("name").text bbox = obj.find("bndbox") xmin = float(bbox.find("xmin").text) ymin = float(bbox.find("ymin").text) xmax = float(bbox.find("xmax").text) ymax = float(bbox.find("ymax").text) 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 # 边界安全裁剪 x_center = max(0.0, min(1.0, x_center)) y_center = max(0.0, min(1.0, y_center)) w = max(0.0, min(1.0, w)) h = max(0.0, min(1.0, h)) label_lines.append(f"{class_map[cls_name]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") with open(os.path.join(out_label_dir, split, f"{name}.txt"), "w") as f: f.write("\n".join(label_lines) + "\n" if label_lines else "") print("转换完成")这个脚本把整个流程串成了两步:先统计类别再按划分名单分发。sorted()对类别名排序可以保证多次运行得到相同映射,不是必须但推荐。标签文件末尾加换行是YOLO训练代码的约定,很多解析器按行读取,少了最后一行换行不会报错,但某些严格实现会漏读最后一条目标。
3.2 四个边界坑:从XML解析失败到image_id不匹配
第一个坑是XML里存在非ASCII字符或损坏的标签。有些标注工具会在name字段里写带乱码的文本,ET.parse遇到这类文件会直接抛出ParseError。解决方案是在扫描XML前先做编码探测,用errors="ignore"的方式读取文件内容再交给ET解析,而不是直接parse文件路径。
第二个坑是图像格式不统一。数据集虽然以jpg为主,但个别图像可能是png或bmp,扩展名不同会导致复制阶段找不到文件。处理方式是在扫描XML时顺便记录每个文件名对应的实际图像扩展名,用os.path.exists逐个探测,而不是假定全部是.jpg。这个问题在第三方数据集里出现概率极高,我用过类似公开数据,几乎每次都有几个图像扩展名不对齐的情况。
第三个坑是类别名的别名问题。同一类病害在不同拍摄批次里可能被标成“茶饼病”和“Tea blister blight”,或“炭疽病”和“anthracnose”。如果直接按字符串建映射,这两个名字会被当成两个类别,导致类别数突破8个,模型训练时会莫名其妙多出一个分类头。解决方法是建立一份同义词归一表,预处理阶段统一替换。
第四个坑是VOC数据集里difficult标记的处理。difficult=1的目标表示这个目标很难辨认,VOC时代的评估协议本来就允许忽略它们。但YOLO格式没有difficult字段,如果直接把这类目标转成普通目标,反而会给训练数据引入噪声。常见的做法是转换时跳过difficult=1的object节点,保留干净的目标集。这个操作没有标准答案,但跳过之后模型的收敛稳定性通常会更好。
3.3 训练前验证:标签文件与图像文件的对应检查
转换完成不代表数据可用,我见过太多转换脚本把训练集和验证集的标签全混在一起的翻车案例。写一个快速核对脚本来确认:images/val下的每个jpg都有对应的labels/val下的txt,且txt里的类别id都在0到7之间。
# 快速核对:检查标签缺失和类别越界 for img in images/val/*.jpg; do base=$(basename "$img" .jpg) if [ ! -f "labels/val/${base}.txt" ]; then echo "缺失标签: ${base}" fi done # 统计类别id分布 awk '{print $1}' labels/val/*.txt | sort | uniq -c第一个命令检查一一对应关系,第二个命令检查类别id分布。如果发现类别id出现8以上的数字,说明转换脚本的class_map出了问题。这个awk统计在训练前值得对train、val都跑一遍,成本只有几秒,但能避免训练到一半发现类别数不匹配,然后从头再来。
4. 基于YOLO格式训练茶叶病害检测模型:配置文件与训练命令
4.1 数据yaml与模型yaml的编写方式
YOLO系列训练框架(无论是YOLOv5还是YOLOv8)都要求用一个yaml文件描述数据集的路径、训练/验证目录和类别名称。这个yaml是训练过程的入口配置,写错一处就会直接拒绝启动训练或者训练出来的模型类别数量始终不对。针对这个8类别茶叶病害数据集,数据yaml结构如下:
# tea_disease.yaml path: /home/user/tea_disease_dataset # 数据集根目录 train: images/train # 相对根目录的训练图像目录 val: images/val # 相对根目录的验证图像目录 test: images/test # 可选,不参与训练 nc: 8 # 类别数必须与数据集一致 names: 0: tea_blight 1: anthracnose 2: tea_leaf_spot 3: tea_rust 4: algal_leaf_spot 5: tea_mite_damage 6: tea_scab 7: tea_mosquito_bug注意path字段填的是数据集根目录的绝对路径,train和val是相对路径。如果只写绝对路径而不拆path和train,YOLOv8也能识别,但不利于后面换机器迁移。names字段的id顺序必须与转换脚本里class_map一致,这是最容易出错也最要命的地方——YOLO训练读取的txt第一列是数字id,模型输出也是按id排列,一旦顺序不一致,训练过程不会报错,但预测结果是完全错位的。
模型yaml一般直接用默认配置。对这个9591张图像、8个类别的规模,我会选yolov8s或yolov5s作为起点,而不是直接上l或x版本。数据集规模不算大,深层模型很容易过拟合到训练集的拍摄条件上,s版本在这个规模上能在mAP和推理速度之间取得更好的平衡。
4.2 训练命令与核心超参数设置
配置好yaml之后,训练命令本身并不复杂,但参数的选取需要结合数据特点。茶叶病害检测和通用目标检测有一个明显的差异:病斑往往面积小、分布密集,一张叶子图像里可能出现几十个直径只有几十像素的病斑。针对这种小目标密集场景,输入分辨率不能太低,默认的640x640可能不够,我会把imgsz调到960甚至1280来做训练。
yolo detect train \ model=yolov8s.pt \ data=tea_disease.yaml \ imgsz=960 \ batch=16 \ epochs=150 \ patience=25 \ optimizer=AdamW \ lr0=0.005 \ lrf=0.01 \ cache=True \ device=0参数含义拆开说:imgsz=960把输入分辨率从默认的640提到960,网络会花更多显存,但对小病斑的召回有明显改善,代价是训练时间大约变为原来的2倍;batch=16是基于单张V100或3090级别显卡的内存占用设定的,如果你的显卡只有11G显存,可以降到8甚至4,但要同步降低学习率;patience=25表示连续25个epoch验证指标没有提升就早停,这个参数避免了在小数据集上跑满150个epoch浪费时间。
pitfall是不要直接在训练命令里加amp=False关闭混合精度。V100以上的显卡对amp支持得很好,关闭只会让训练变慢且没有任何精度收益。真正值得关的是cache,如果数据集分布在机械硬盘上,cache=True把图像缓存到内存虽然启动时会等一会儿,但能大幅减少每个epoch的图像读取时间。
4.3 数据增强对茶叶病害检测的敏感度
YOLO训练默认开启mosaic、random_flip等增强,但茶叶病害检测场景和常规检测不同,有两个增强需要单独考虑。第一是垂直翻转,茶叶病斑在叶片上的分布没有“上下”语义,垂直翻转是安全的;但水平翻转需要看标注时是否遵循了统一拍摄方向,如果所有图像都是叶尖朝上、叶柄朝下拍摄,水平翻转会制造训练集和实际拍摄场景之间的分布偏移。我会保留水平翻转而关闭垂直翻转,原因其实很简单:茶园里采样的图像大多没有旋转偏好,但减少无谓的翻转能降低模型对方向的一致性负担。
第二是HSV增强的饱和度范围。茶叶病害初期症状本身就表现为叶片局部颜色变化,饱和度抖动过大可能把健康叶片的颜色扰动到接近病斑的颜色,让模型学到伪特征。默认的hsv_s=0.7对通用数据集没问题,但做茶叶病害检测我会把它下调到0.3左右。
yolo detect train \ model=yolov8s.pt \ data=tea_disease.yaml \ imgsz=960 \ batch=16 \ epochs=150 \ hsv_h=0.015 \ hsv_s=0.3 \ hsv_v=0.3 \ degrees=10 \ translation=0.1 \ scale=0.5 \ fliplr=0.5 \ flipud=0.0 \ mosaic=1.0 \ device=0degrees=10允许图像在正负10度内随机旋转,对叶片拍摄角度变化有一定鲁棒性提升,但不要设到45度以上,超过这个范围目标框的标注面积与实际目标区域会发生明显错位,干预训练的收敛。scale=0.5控制随机缩放强度,它和imgsz=960配合时相当于在模型中引入了一种多尺度训练效果。
5. 茶叶病害检测训练中常见的5个坑:现象、原因、解决
5.1 训练loss正常下降但验证AP全部为0
这个现象相当常见:训练过程的box_loss和cls_loss都在平滑下降,日志看起来一切正常,但每个epoch结束后验证集的mAP50显示0.000,所有类别的AP都是0。多数情况下原因指向验证集标签的类别id和模型输出顺序不匹配,或者验证集图像路径配置错误,导致验证时根本没有读到标签。
排查方法是先手动验证一张图:把验证集里某张图和它的txt标签调出来,用训练完成的模型做一次推理并画框,看预测框是不是落在病斑附近。如果验证阶段完全没有标签参与运算,那么混淆矩阵会显示一个“background”类别占了100%的比例。解决路径是重新检查tea_disease.yaml里的val路径,再用3.3节的快速核对脚本检查labels/val里的txt是否完整,特别关注空txt文件——空标签会让验证指标异常。
5.2 类别id错位:训练集和验证集名称映射不一致
这个坑我踩过不止一次。转换脚本里用sorted(class_counts.keys())动态生成类别映射,理论上同一个数据集两次结果应该一致,但如果某次运行到一半数据集被人为增删过,或者XML里出现了之前不存在的类别名,class_map的索引就会发生变化,原来0号类别变成1号、2号类别变成3号,所有已经生成的txt标签全部错位。
解决思路是不要让程序自动构建类别映射,而是在数据集根目录放一份固定的class_names.txt,转换脚本按这份文件来建映射。这样无论XML里出现什么意外类别名,映射始终对外保持一致。如果已经发生了错位,修复方式不是重新转换所有标签,而是写脚本把txt第一列按旧映射到新映射的关系重写一遍。
5.3 小目标病斑漏检严重且召回率上不去
茶叶病害检测的特殊之处在于目标尺度跨度极大:整片病斑可能占据图像的三分之一,而初期针尖大小的病斑只有十几个像素。YOLO默认的anchor设计和特征金字塔对多尺度目标是有自适应能力的,但小目标在深层特征图上退化严重。如果训练完发现小病斑召回率明显偏低,首先看是不是imgsz设得太低。
我一般先尝试imgsz=1280做一轮对比实验,如果显存不够就切yolov8n加高分辨率,而不是直接用yolov8l。另一个有效手段是开启SAHI(切片推理),推理时把大图切成有重叠的小块分别检测再合并结果。这个方案对茶叶病害这种背景简单、单个病斑小的场景效果显著。
5.4 训练过程中loss出现NaN
loss变成NaN常见诱因有三个:学习率过大、标注数据里有空标签文件、以及gt框里有宽度或高度为0的异常框。第一个和第三个问题比较容易定位,打印训练日志里第一批迭代的loss值,如果最初几步就有NaN,大概率是lr0设置太高,YOLOv8默认lr0=0.01搭配AdamW在某些数据集上确实会不稳定,建议先降到0.002试跑50个epoch看看。
空标签文件导致的NaN隐蔽得多:某个txt文件存在但内容为空,模型无法从空标注计算正样本,如果恰好这个batch没有其他有效目标,loss回传就会出现异常。检查方式是扫描labels目录下所有txt,看是否有0字节文件,有则删掉并同步从训练列表里移除对应图像。
5.5 训练epoch很长但精度不再提升
数据集规模9591张、8个类别,如果前期收敛正常但后段精度停滞,多半是学习率调度没有适配数据集规模。YOLO默认在训练的后半段把学习率余弦降到初始值的1/100,但数据量少时模型在20个epoch左右就已经收敛到接近极限,剩下几十个epoch主要在做无意义的精细拟合。
推荐的调整不是增大epoch数,而是把patience降到15,让早停机制在更早的时间点终止训练。同时用cos_lr=True而不是线性衰减,让学习率后段下降更平滑,减少在最优解附近的振荡。保存最佳权重而不是最后一轮权重,这样即使后续几个epoch精度波动,也保留了历史最佳状态。
6. 用混淆矩阵验证8个类别到底学没学对
训练结束后不要只看mAP,要看每一对类别之间的混淆情况。茶叶病害之间经常会呈现出相似的颜色和纹理特征,比如茶饼病和炭疽病初期都是圆形褐色斑点,如果这两个类在混淆矩阵上互相错认的比例超过10%,说明模型学到的是表面颜色特征而不是病害的结构特征。
YOLO训练完成后会自动生成confusion_matrix.png和混淆矩阵归一化版本,保存在runs/detect/train/目录下。查看方法很简单,打开归一化混淆矩阵,重点看对角线以外的非零格子:假设茶饼病(类别0)的真实样本有300个,其中40个被预测成了炭疽病(类别1),归一化后就是0.13的混淆率,这个数字超过5%就要引起重视。
排除模型本身的问题后,混淆率高往往要回到数据层面找原因。我遇到过一类困惑是同一个茶饼病样本在VOC标注里被标成不同类别名,导致模型实际学了两个“伪类别”。排查方式是随机抽取混淆严重的图像各20张,人工核对原图和标注框,如果发现标注本身就模糊,修复这些标注重新训练,比盲目调模型结构有效得多。
对已经训练好的模型做一轮测试集验证时,还需要注意测试集和训练集的拍摄条件差异。如果两者来自同一批茶园同一时段,mAP会虚高,部署到其他茶园时容易掉点。更稳健的做法是拿训练集以外的茶园照片做外部验证,哪怕只有一两百张,也能暴露模型过拟合的程度。
以我的习惯,拿到这类数据集后第一轮只用默认参数快速跑通,第二轮才针对茶叶病害的小目标特性去调imgsz和数据增强,第三轮才动手改模型结构。做农业检测最忌讳一上来就改backbone,数据层面的问题不解决,换什么网络结构都救不回来。希望这些从数据检查到训练配置再到结果验证的流程对你跑通这个数据集有所帮助。
本文还有配套的精品资源,点击获取