8908张土豆病害检测数据集:VOC/YOLO双格式校验与分层划分 简介面向农业计算机视觉、土豆病害识别与目标检测研究者这份文档围绕一套8908张、5类别的VOCYOLO双格式数据展开覆盖损伤土豆、缺陷土豆、真菌性病害土豆、正常土豆与萌芽土豆等常见状态可用于病害早期筛查、分类识别与检测模型训练。包内共1个docx文件约3.71MB以数据集说明和预览信息为主帮助读者快速掌握目录构成、标注规范与使用边界。已有68人学习。文档交代了jpg图片与对应xml、txt标注的配套关系xml便于传统检测流程读取txt可直接服务YOLO类模型同时列出各类别框数及总框数30854说明采用labelImg绘制矩形框并提示数据经过增强、需查看图片预览且不对训练模型精度作保证。读者可据此评估数据适用性、规划标注解析与格式转换流程减少前期调研和试错成本。1. 8908张土豆病害图先别急着训练五个类别和双格式的边界拿到一个标着 8908 张、5 类别的土豆病害检测数据集第一反应不是直接丢进 YOLO 开训而是先看它到底能解决哪类检测问题。这个土豆病害检测数据集同时给了 Pascal VOC 的 xml 和 YOLO 的 txtjpg 图片 8908 张标注框合计 30854 个类别覆盖正常土豆、损伤、缺陷、真菌性病害和发芽五类并且混有增强图。它适合做检测模型基线、类别不均衡实验、标注清洗流程验证也适合拿来练 VOC 与 YOLO 双格式转换和分层划分。但它不是分割数据集也不保证训练精度更不适合直接当产线验收标准。五个类别里“Potato”和“Diseased-fungal potato”框数最多“Defected potato”最少增强图还会把同源样本带进不同集合。先摸清这些边界再谈模型。2. 拆开VOC与YOLO双格式字段对照、类别映射和校验脚本2.1 目录树和文件命名先对齐jpg、xml、txt三件套这个数据集的结构不复杂但最容易在“看起来都有”上翻车。常见目录是图片放一个文件夹VOC 的 xml 放一个文件夹YOLO 的 txt 放一个文件夹也可能三者混在同一层。不管哪种先按 basename 对齐0001.jpg对应0001.xml和0001.txt。图片数量 8908xml 数量 8908txt 数量 8908这三个数必须一致少一个都别往下走。先跑一遍文件盘点别靠肉眼数# 统计 jpg、xml、txt 数量路径按实际目录替换 find data/potato -type f -name *.jpg | wc -l find data/potato -type f -name *.xml | wc -l find data/potato -type f -name *.txt | wc -l # 找出只有图片、没有 xml 的 basename comm -23 \ (find data/potato -type f -name *.jpg -printf %f\n | sed s/\.jpg$// | sort) \ (find data/potato -type f -name *.xml -printf %f\n | sed s/\.xml$// | sort)comm -23取的是“只在第一个列表出现”的 basename也就是缺 xml 的图片。把xml换成txt再跑一次能查出缺 YOLO 标签的图。参数上注意find的-printf是 GNU find 的用法macOS 自带 find 不支持时换成basename循环或 Python 脚本不要硬套。类别映射必须先定死。YOLO 的 txt 里第一列是class_id从 0 开始顺序由训练时的names列表决定。这个数据集的五类名称和框数如下类别名称YOLO 索引建议标注框数Damaged potato04850Defected potato13953Diseased-fungal potato27674Potato39868Sprouted potato44509合计-30854索引不是天然固定但一旦训练脚本里的names顺序变了txt 里的 0 到 4 就会整体错位。常见做法是把这个表写进项目 README转换脚本、data.yaml、评估脚本共用同一份映射。别在三个地方各写一遍。2.2 从VOC XML转到YOLO txt坐标归一化和clampVOC 的 xml 里存的是绝对像素坐标xmin, ymin, xmax, ymaxYOLO 要的是归一化中心点和宽高x_center, y_center, width, height全部除以图片宽高落到 0 到 1。转换本身不复杂坑在边界标注框可能贴边、可能超出图片、可能宽高为 0直接除会得到负数或大于 1 的值。下面这段脚本按类别映射生成 txt同时把越界框 clamp 回图内并把异常记录到日志import os import xml.etree.ElementTree as ET CLASS_MAP { Damaged potato: 0, Defected potato: 1, Diseased-fungal potato: 2, Potato: 3, Sprouted potato: 4, } def convert_one(xml_path, txt_path, log_path): 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 CLASS_MAP: with open(log_path, a, encodingutf-8) as f: f.write(fUNKNOWN_CLASS\t{xml_path}\t{name}\n) continue 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) # 先裁到图片范围内避免负坐标和越界 xmin max(0.0, min(xmin, img_w)) xmax max(0.0, min(xmax, img_w)) ymin max(0.0, min(ymin, img_h)) ymax max(0.0, min(ymax, img_h)) bw xmax - xmin bh ymax - ymin if bw 1 or bh 1: with open(log_path, a, encodingutf-8) as f: f.write(fBAD_BOX\t{xml_path}\t{name}\t{bw}\t{bh}\n) continue xc (xmin xmax) / 2.0 / img_w yc (ymin ymax) / 2.0 / img_h nw bw / img_w nh bh / img_h lines.append(f{CLASS_MAP[name]} {xc:.6f} {yc:.6f} {nw:.6f} {nh:.6f}) with open(txt_path, w, encodingutf-8) as f: f.write(\n.join(lines)) if __name__ __main__: xml_dir data/potato/voc_xml txt_dir data/potato/yolo_txt os.makedirs(txt_dir, exist_okTrue) log_path data/potato/convert_bad.log for fn in os.listdir(xml_dir): if fn.endswith(.xml): stem fn[:-4] convert_one( os.path.join(xml_dir, fn), os.path.join(txt_dir, stem .txt), log_path, )代码里CLASS_MAP必须和训练时的names完全一致顺序变了索引就变了。bw 1 or bh 1是过滤几乎退化的框阈值 1 像素是经验值如果你做的是极小目标检测可以降到 0.5但要同步看日志量。clamp放在计算宽高之前避免xmax小于xmin时出现负宽。每行保留 6 位小数够用YOLO 读取时不会再做精度截断。转换完再抽 20 张图做回标把 txt 画回原图看框是否还压在目标上。只跑数量校验不够坐标错位在数量上完全看不出来。2.3 跑一遍完整性校验空标注、越界、类别拼写转换脚本跑完接着做一次全量校验。重点看四件事txt 是否为空、class_id 是否落在 0 到 4、归一化坐标是否都在 0 到 1、每个类别的框数是否接近 4850/3953/7674/9868/4509。空 txt 不一定是错有些图可能只有背景但这个数据集标注框总数 30854空图比例异常高就要查。import os from collections import Counter def check_yolo(txt_dir, num_classes5): cnt Counter() empty, bad_range, bad_line [], [], [] for fn in os.listdir(txt_dir): if not fn.endswith(.txt): continue path os.path.join(txt_dir, fn) with open(path, encodingutf-8) as f: rows [r.strip() for r in f if r.strip()] if not rows: empty.append(fn) continue for i, row in enumerate(rows): parts row.split() if len(parts) ! 5: bad_line.append((fn, i, row)) continue cid int(float(parts[0])) vals [float(v) for v in parts[1:]] cnt[cid] 1 if cid 0 or cid num_classes or any(v 0 or v 1 for v in vals): bad_range.append((fn, i, row)) print(类别计数:, dict(sorted(cnt.items()))) print(空标注文件数:, len(empty)) print(字段数异常:, len(bad_line)) print(类别或坐标越界:, len(bad_range)) return cnt, empty, bad_range check_yolo(data/potato/yolo_txt)len(parts) ! 5能抓住多空格、少字段、把分割格式混进来的行。int(float(parts[0]))是为了兼容0.0这种写法但正常 YOLO 检测标签第一列就是整数出现小数说明上游脚本有问题。类别计数打印出来后和表格对一遍差几十个框可能是过滤了退化框差几千个就是类别映射或文件缺失。提示类别名里的连字符和空格是拼写重灾区Diseased-fungal potato少一个连字符就会变成未知类转换脚本会把它写进日志而不是静默丢弃。日志文件不要删后面排查类别错位要靠它。3. 分层划分与增强图处理8908张图怎么切才不泄漏3.1 为什么按图片随机切会泄漏增强图与重复图这个数据集明确写了“有增强”意味着同一张原图可能对应多张旋转、亮度变化、裁剪后的增强图。如果按图片随机切 train/val同一原图的增强版本很可能一部分进训练、一部分进验证验证集上的 mAP 会虚高看起来模型学会了病害实际是记住了同一张图的近邻样本。上线换一批新图指标立刻掉下来这种翻车在现场很常见。另一个问题是类别不均衡。五个类别框数从 3953 到 9868按图片随机切不保证每个集合里稀有类都有足够样本。更稳的做法是“先按原图分组再按组分层”。分组键可以从文件名里找如果增强图命名带_aug、_rotate、_bright之类的后缀去掉后缀归到同一组如果命名看不出来就用文件哈希和感知哈希做近似去重至少把完全重复的图压到同一组。划分比例用 8:1:1 还是 7:2:1取决于你手里的验证需求。做基线复现我一般用 8:1:1验证集和测试集各留 10%测试集只在最后跑一次。随机种子固定比如 42保证别人能复现同一份切分。别每次训练重新随机切否则不同轮次的指标没法比。3.2 分组分层划分脚本8:1:1和随机种子下面脚本先按文件名前缀归组再用每组里框数最多的类别作为分层键。这个做法不是完美的多标签分层但比纯随机稳尤其是增强图同源的情况。依赖scikit-learn没有就装。import os import re from collections import defaultdict from sklearn.model_selection import train_test_split def group_key(filename): stem os.path.splitext(os.path.basename(filename))[0] # 去掉常见的增强后缀_aug1、_rotate_2、_bright3 等 stem re.sub(r(_aug|_rotate|_bright|_flip|_crop)[-_]?\d*$, , stem, flagsre.I) return stem def load_records(txt_dir, jpg_dir): records [] for fn in os.listdir(txt_dir): if not fn.endswith(.txt): continue stem fn[:-4] jpg os.path.join(jpg_dir, stem .jpg) if not os.path.exists(jpg): continue classes [] with open(os.path.join(txt_dir, fn), encodingutf-8) as f: for row in f: row row.strip() if row: classes.append(int(float(row.split()[0]))) major max(set(classes), keyclasses.count) if classes else -1 records.append({stem: stem, group: group_key(stem), major: major}) return records def split_by_group(records, seed42): groups defaultdict(list) for r in records: groups[r[group]].append(r) group_items [] for g, items in groups.items(): major max(set(i[major] for i in items), key[i[major] for i in items].count) group_items.append((g, major)) g_list [g for g, _ in group_items] y [m for _, m in group_items] train_g, temp_g, _, temp_y train_test_split( g_list, y, test_size0.2, random_stateseed, stratifyy ) val_g, test_g train_test_split( temp_g, test_size0.5, random_stateseed, stratifytemp_y ) train [r for r in records if r[group] in set(train_g)] val [r for r in records if r[group] in set(val_g)] test [r for r in records if r[group] in set(test_g)] return train, val, test records load_records(data/potato/yolo_txt, data/potato/images) train, val, test split_by_group(records, seed42) print(len(train), len(val), len(test))关键参数test_size0.2先切出 20% 做临时集再对半分成验证和测试最终比例约 8:1:1。stratifyy按主类别分层保证稀有类在三个集合里都有。group_key的正则需要按实际文件名改如果增强图命名是xxx_aug_001就把正则里的\d*改成\d。如果文件名完全看不出分组group_key会退化成每张图一组这时候必须先做 3.3 的去重否则泄漏照样存在。划分完把三个集合的类别框数打印出来和总表对一遍。某个集合里Defected potato只有几十个框说明分层没生效或稀有类被切碎需要换分层键或调整比例。3.3 哈希去重与增强图归组文件名看不出来怎么办文件名没有增强标记时先做文件级 MD5 去重完全相同的图直接合并。再做平均哈希aHash找近似重复把汉明距离小于阈值的图归到同一组。阈值别拍脑袋8x8 的 aHash 用 5 到 8 比较常见太小漏掉近似图太大把不同图误合并。下面脚本只做完全重复和 aHash 聚类依赖 Pillow。import hashlib from PIL import Image def md5(path): h hashlib.md5() with open(path, rb) as f: h.update(f.read()) return h.hexdigest() def ahash(path, size8): img Image.open(path).convert(L).resize((size, size), Image.Resampling.LANCZOS) pixels list(img.getdata()) avg sum(pixels) / len(pixels) bits .join(1 if p avg else 0 for p in pixels) return int(bits, 2) def hamming(a, b): return (a ^ b).bit_count() # 先按 md5 分组 hash_groups {} for fn in os.listdir(data/potato/images): if fn.endswith(.jpg): path os.path.join(data/potato/images, fn) hash_groups.setdefault(md5(path), []).append(fn) # 再对每组代表图做 aHash 近似合并阈值 6 reps [(k, v[0]) for k, v in hash_groups.items()] for i in range(len(reps)): for j in range(i 1, len(reps)): if hamming(ahash(os.path.join(data/potato/images, reps[i][1])), ahash(os.path.join(data/potato/images, reps[j][1]))) 6: print(近似重复:, reps[i][1], reps[j][1])MD5 分组能抓住原图复制aHash 能抓住亮度、压缩变化后的近似图。阈值 6 不是万能值跑完抽 20 对看是不是真重复误合并比漏合并更伤分层。分组结果写回划分脚本的group_key让同一组只进一个集合。注意增强图不是不能进训练集而是不能让同源增强图跨训练和验证。训练集放增强图能提升泛化验证集和测试集尽量用未增强或固定增强版本指标才有参考意义。4. 训练参数落点nc5、输入尺寸和30854个框的采样对齐4.1 data.yaml与类别索引nc5不是随便写训练前的第一份配置是data.yaml。nc必须等于 5names的顺序必须和 txt 里的class_id一一对应。路径建议写绝对路径或相对项目根目录的路径别写当前终端目录的相对路径换一台机器就找不到图。path: /data/potato_yolo train: images/train val: images/val test: images/test nc: 5 names: 0: Damaged potato 1: Defected potato 2: Diseased-fungal potato 3: Potato 4: Sprouted potatopath是数据集根目录train/val/test是相对path的图片目录。YOLO 默认会去找同名 txt把 txt 放在labels/train下时图片路径和标签路径的对应关系要按框架规则来如果框架要求images和labels平行就别把 txt 混在图片目录里。配置写完先跑一次小样本训练看日志里的类别数是不是 5、每类实例数是不是接近 4850/3953/7674/9868/4509。日志里出现nc80或类别名不对说明 yaml 没被读到或者被默认配置覆盖。还有一个细节names里的字符串带空格和连字符yaml 里用引号包起来更稳避免解析器把空格当分隔。类别名保持和 VOC 原始名称一致别为了好看改成中文或缩写后面回标和混淆矩阵对不上。4.2 输入尺寸与采样小目标、30854框、类别不均衡土豆病害的检测框很多是表面局部区域相对整图偏小。输入尺寸 640 是基线但小目标多的时候 640 会把小框压到几个像素召回上不去。常见做法是先跑 640 看基线再试 960 或 1280显存不够就调小 batch。imgsz提高后推理速度下降产线场景要一起权衡。类别不均衡方面Diseased-fungal potato和Potato框数多Defected potato少。YOLO 检测器不像分类任务那样直接给类别权重常见做法是过采样稀有类图片、在增强里多给稀有类做 mosaic/copy-paste或者先跑一遍看混淆矩阵确认稀有类是被背景吞了还是被相似类吃了。不要一上来就猛加过采样否则稀有类过拟合验证集指标好看但新图崩。训练命令以常见 YOLO 检测器为例参数按显存改yolo detect train \ data/data/potato_yolo/data.yaml \ modelyolo11n.pt \ imgsz960 \ epochs100 \ batch8 \ workers8 \ cacheTrue \ close_mosaic10 \ patience30 \ seed42 \ projectruns/potato \ namebaseline_960imgsz960比 640 更吃显存batch8是 8GB 显存附近的保守值24GB 可以上 16 或 24。cacheTrue把图片缓存到内存或磁盘8908 张图不算大内存够就开能减少 IO 抖动。close_mosaic10表示最后 10 轮关闭 mosaic让模型在真实布局上收尾小目标任务里这个参数经常能拉回一点精度。patience30是早停耐心值指标 30 轮不涨就停别让模型在过拟合区空跑。如果显存不够又不想降imgsz可以先用 640 跑一轮确认流程通再切 960。别在数据校验没做完的时候直接上大分辨率训练崩了分不清是显存问题还是标签问题。4.3 小批过拟合验证20张图先看loss能不能压下去全量训练之前抽 20 到 50 张图做一次过拟合测试。做法是把训练集和验证集都指向同一小批图训练 50 到 100 轮看分类 loss 和框回归 loss 能不能降到很低mAP 能不能接近 1。如果小批都学不动说明标签格式、类别索引或图片路径有问题别浪费全量训练时间。# 构造小批 data_small.yamltrain/val 都指向同一个 20 张图目录 yolo detect train \ data/data/potato_yolo/data_small.yaml \ modelyolo11n.pt \ imgsz640 \ epochs80 \ batch4 \ workers2 \ cacheFalse \ seed42 \ projectruns/potato \ nameoverfit_20小批过拟合时mosaic可以关掉避免增强把 20 张图搅得太散。看日志重点不是最终 mAP而是 loss 有没有持续下降、验证集预测框有没有和标注框重合。如果 loss 不降先回 2.3 查 txt 的class_id和坐标范围如果 loss 降但框全偏查 VOC 转换时的宽高顺序和归一化分母。提示小批过拟合通过只能证明“这批标签能被模型学”不能证明全量数据质量。全量训练前仍然要跑 2.3 的全量校验和 3.2 的分层统计。5. 避坑排查标注框、类别索引和增强重复图的翻车现场5.1 路径与文件对应类图片在、txt不在、xml类别拼错现象训练日志里train图片数比预期少几百张或者某类实例数明显低于表格。原因jpg 和 txt 的 basename 不一致常见是图片名带_aug而 txt 名没有或者大小写扩展名混用.JPG与.jpg。另一种是转换脚本只处理了 xml 目录里的一部分文件日志没看。解决用 2.1 的comm命令分别查缺 xml、缺 txt 的 basename把缺失清单导出来。扩展名统一转小写转换脚本按stem匹配而不是按完整文件名。训练前打印每个集合的图片数和标签数两个数不相等就停。现象转换日志里出现大量UNKNOWN_CLASS但类别名看起来没错。原因VOC xml 里的name带了前后空格、不可见字符或者连字符被写成了中文标点。Diseased-fungal potato这种带连字符的类别最容易中招。解决转换时用strip()并在CLASS_MAP查不到时把原始名称写进日志。对日志里的名称做一次repr()打印能看出隐藏字符。类别映射表只维护一份转换、校验、训练共用别手抄。5.2 标注几何类框越界、宽高为0、归一化错位现象回标时框整体偏移或者框的宽高反了。原因VOC 的xmin/ymin/xmax/ymax顺序读错或者把width/height和xmax-xmin/ymax-ymin搞混。YOLO 要的是中心点加宽高不是左上角加宽高。解决按 2.2 的公式逐行核对xc(xminxmax)/2/wnw(xmax-xmin)/w。转换后抽 20 张图回标偏移超过几个像素就查图片实际尺寸和 xml 里的size是否一致。有些增强图被裁剪过但 xml 还写原尺寸这种必须用实际 jpg 的尺寸覆盖 xml 尺寸或者直接丢弃。现象校验脚本报大量坐标越界但原图看起来框没出界。原因xml 里的坐标是 1-based 或带了 padding转换时没 clamp或者图片被 resize 后 xml 没同步更新。解决先 clamp 到[0, w]和[0, h]再算宽高。clamp 后宽高小于 1 像素的框写日志并过滤。不要用负数坐标直接训练YOLO 读取时可能静默截断导致框缩成一团。5.3 训练表现类mAP虚高、单类召回塌陷、增强图重复现象验证集 mAP 很高换一批新图掉得很厉害。原因增强图同源跨集合验证集里混进了训练集的近邻样本。也可能是测试集被反复用来调参指标被乐观估计。解决按 3.2 分组划分按 3.3 做哈希去重。验证集和测试集只用未增强或固定增强版本测试集只在最后跑一次。看指标时同时看训练集和验证集的 loss 曲线验证 loss 先降后升就是过拟合信号。现象Defected potato的召回明显低于其他类混淆矩阵里被分到Damaged potato。原因两类外观接近稀有类样本少模型偏向多数类。也可能是类别索引错位把缺陷类标成了损伤类。解决先查 txt 里的class_id分布和names顺序是否一致排除索引错位。确认没错后再对稀有类做过采样或增强看混淆矩阵里错分方向有没有变化。别只看总体 mAP按类别看召回和误报稀有类的问题经常被平均值盖住。6. 验收技巧50张抽查图回标与混淆矩阵怎么看全量训练前我会固定抽 50 张图做回标验收覆盖五个类别和不同增强类型。做法很简单读一张 jpg 和对应 txt把归一化框还原成像素坐标画上去颜色按类别分。下面这段代码够用依赖 OpenCV。import cv2 COLORS [(0,0,255),(0,255,0),(255,0,0),(0,255,255),(255,0,255)] NAMES [Damaged,Defected,Fungal,Potato,Sprouted] def draw_one(img_path, txt_path, out_path): img cv2.imread(img_path) h, w img.shape[:2] with open(txt_path, encodingutf-8) as f: for row in f: p row.split() if len(p) ! 5: continue cid int(float(p[0])) xc, yc, bw, bh map(float, p[1:]) x1 int((xc - bw / 2) * w) y1 int((yc - bh / 2) * h) x2 int((xc bw / 2) * w) y2 int((yc bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), COLORS[cid], 2) cv2.putText(img, NAMES[cid], (x1, max(y1 - 4, 0)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, COLORS[cid], 1) cv2.imwrite(out_path, img)COLORS和NAMES的顺序必须跟data.yaml的names一致否则颜色和文字会错位。int截断可能让框差一像素验收够用要精确就换成round。50 张图里重点看三类框是否贴边被截断、同一张图里有没有漏标、增强图的目标有没有被裁掉一半。漏标在指标上表现为召回上不去但只看 loss 很难发现。混淆矩阵放在回标之后看。先看对角线再看错分方向Defected大量错到Damaged说明两类边界模糊要么补稀有类样本要么在标注规范里把“缺陷”和“损伤”的判定条件写死。Potato被大量误检成病害先查背景图里有没有类似病斑的纹理再查验证集有没有重复图。指标异常时先回数据不要先调模型。从那以后我每次拿到新数据集都先跑文件对应、全量校验和 50 张回标再开训练。这套流程多花半小时能省掉后面几天的无效调参。希望帮到你。本文还有配套的精品资源点击获取