ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

茶叶病害检测数据集详解:VOC转YOLO格式与YOLOv8训练实战

茶叶病害检测数据集详解:VOC转YOLO格式与YOLOv8训练实战 简介面向茶叶病害识别与农业视觉检测任务这份数据集整合了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_okTrue) os.makedirs(os.path.join(out_label_dir, split), exist_okTrue) 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前先做编码探测用errorsignore的方式读取文件内容再交给ET解析而不是直接parse文件路径。第二个坑是图像格式不统一。数据集虽然以jpg为主但个别图像可能是png或bmp扩展名不同会导致复制阶段找不到文件。处理方式是在扫描XML时顺便记录每个文件名对应的实际图像扩展名用os.path.exists逐个探测而不是假定全部是.jpg。这个问题在第三方数据集里出现概率极高我用过类似公开数据几乎每次都有几个图像扩展名不对齐的情况。第三个坑是类别名的别名问题。同一类病害在不同拍摄批次里可能被标成“茶饼病”和“Tea blister blight”或“炭疽病”和“anthracnose”。如果直接按字符串建映射这两个名字会被当成两个类别导致类别数突破8个模型训练时会莫名其妙多出一个分类头。解决方法是建立一份同义词归一表预处理阶段统一替换。第四个坑是VOC数据集里difficult标记的处理。difficult1的目标表示这个目标很难辨认VOC时代的评估协议本来就允许忽略它们。但YOLO格式没有difficult字段如果直接把这类目标转成普通目标反而会给训练数据引入噪声。常见的做法是转换时跳过difficult1的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和trainYOLOv8也能识别但不利于后面换机器迁移。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 \ modelyolov8s.pt \ datatea_disease.yaml \ imgsz960 \ batch16 \ epochs150 \ patience25 \ optimizerAdamW \ lr00.005 \ lrf0.01 \ cacheTrue \ device0参数含义拆开说imgsz960把输入分辨率从默认的640提到960网络会花更多显存但对小病斑的召回有明显改善代价是训练时间大约变为原来的2倍batch16是基于单张V100或3090级别显卡的内存占用设定的如果你的显卡只有11G显存可以降到8甚至4但要同步降低学习率patience25表示连续25个epoch验证指标没有提升就早停这个参数避免了在小数据集上跑满150个epoch浪费时间。pitfall是不要直接在训练命令里加ampFalse关闭混合精度。V100以上的显卡对amp支持得很好关闭只会让训练变慢且没有任何精度收益。真正值得关的是cache如果数据集分布在机械硬盘上cacheTrue把图像缓存到内存虽然启动时会等一会儿但能大幅减少每个epoch的图像读取时间。4.3 数据增强对茶叶病害检测的敏感度YOLO训练默认开启mosaic、random_flip等增强但茶叶病害检测场景和常规检测不同有两个增强需要单独考虑。第一是垂直翻转茶叶病斑在叶片上的分布没有“上下”语义垂直翻转是安全的但水平翻转需要看标注时是否遵循了统一拍摄方向如果所有图像都是叶尖朝上、叶柄朝下拍摄水平翻转会制造训练集和实际拍摄场景之间的分布偏移。我会保留水平翻转而关闭垂直翻转原因其实很简单茶园里采样的图像大多没有旋转偏好但减少无谓的翻转能降低模型对方向的一致性负担。第二是HSV增强的饱和度范围。茶叶病害初期症状本身就表现为叶片局部颜色变化饱和度抖动过大可能把健康叶片的颜色扰动到接近病斑的颜色让模型学到伪特征。默认的hsv_s0.7对通用数据集没问题但做茶叶病害检测我会把它下调到0.3左右。yolo detect train \ modelyolov8s.pt \ datatea_disease.yaml \ imgsz960 \ batch16 \ epochs150 \ hsv_h0.015 \ hsv_s0.3 \ hsv_v0.3 \ degrees10 \ translation0.1 \ scale0.5 \ fliplr0.5 \ flipud0.0 \ mosaic1.0 \ device0degrees10允许图像在正负10度内随机旋转对叶片拍摄角度变化有一定鲁棒性提升但不要设到45度以上超过这个范围目标框的标注面积与实际目标区域会发生明显错位干预训练的收敛。scale0.5控制随机缩放强度它和imgsz960配合时相当于在模型中引入了一种多尺度训练效果。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设得太低。我一般先尝试imgsz1280做一轮对比实验如果显存不够就切yolov8n加高分辨率而不是直接用yolov8l。另一个有效手段是开启SAHI切片推理推理时把大图切成有重叠的小块分别检测再合并结果。这个方案对茶叶病害这种背景简单、单个病斑小的场景效果显著。5.4 训练过程中loss出现NaNloss变成NaN常见诱因有三个学习率过大、标注数据里有空标签文件、以及gt框里有宽度或高度为0的异常框。第一个和第三个问题比较容易定位打印训练日志里第一批迭代的loss值如果最初几步就有NaN大概率是lr0设置太高YOLOv8默认lr00.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_lrTrue而不是线性衰减让学习率后段下降更平滑减少在最优解附近的振荡。保存最佳权重而不是最后一轮权重这样即使后续几个epoch精度波动也保留了历史最佳状态。6. 用混淆矩阵验证8个类别到底学没学对训练结束后不要只看mAP要看每一对类别之间的混淆情况。茶叶病害之间经常会呈现出相似的颜色和纹理特征比如茶饼病和炭疽病初期都是圆形褐色斑点如果这两个类在混淆矩阵上互相错认的比例超过10%说明模型学到的是表面颜色特征而不是病害的结构特征。YOLO训练完成后会自动生成confusion_matrix.png和混淆矩阵归一化版本保存在runs/detect/train/目录下。查看方法很简单打开归一化混淆矩阵重点看对角线以外的非零格子假设茶饼病类别0的真实样本有300个其中40个被预测成了炭疽病类别1归一化后就是0.13的混淆率这个数字超过5%就要引起重视。排除模型本身的问题后混淆率高往往要回到数据层面找原因。我遇到过一类困惑是同一个茶饼病样本在VOC标注里被标成不同类别名导致模型实际学了两个“伪类别”。排查方式是随机抽取混淆严重的图像各20张人工核对原图和标注框如果发现标注本身就模糊修复这些标注重新训练比盲目调模型结构有效得多。对已经训练好的模型做一轮测试集验证时还需要注意测试集和训练集的拍摄条件差异。如果两者来自同一批茶园同一时段mAP会虚高部署到其他茶园时容易掉点。更稳健的做法是拿训练集以外的茶园照片做外部验证哪怕只有一两百张也能暴露模型过拟合的程度。以我的习惯拿到这类数据集后第一轮只用默认参数快速跑通第二轮才针对茶叶病害的小目标特性去调imgsz和数据增强第三轮才动手改模型结构。做农业检测最忌讳一上来就改backbone数据层面的问题不解决换什么网络结构都救不回来。希望这些从数据检查到训练配置再到结果验证的流程对你跑通这个数据集有所帮助。本文还有配套的精品资源点击获取
返回列表