ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

8908张土豆病害检测数据集:VOC/YOLO转换与训练验收避坑

8908张土豆病害检测数据集:VOC/YOLO转换与训练验收避坑

简介:面向农业计算机视觉研究与土豆病害识别开发者的数据集说明文档,聚焦生长、储存环节中损伤、缺陷、真菌病害、萌芽与正常土豆的识别需求,帮助读者在选型、标注理解与模型训练准备阶段快速判断数据可用性。压缩包共1个docx文件,约3.71MB,以文档形式集中整理数据集格式、五类名称、框数分布、标注工具、重要说明与图片预览,便于按模块查阅。文档对应数据集含8908张图像,五类为损伤土豆、缺陷土豆、真菌性病害土豆、正常土豆和萌芽土豆,提供Pascal VOC的xml与YOLO的txt双格式标注,共30854个框,其中正常土豆9868框、真菌性病害7674框,另含数据增强提示。已有68人学习,适合需要复核类别覆盖、标注规模、VOC/YOLO衔接细节以及精度声明与使用边界的研究者与开发者参考。

1. 土豆病害检测数据集:8908 张双格式标注,先搞清楚它能干什么

农业巡检场景里,最耗时间的环节通常出在标注,模型训练本身反而没那么费时间。这份土豆病害检测数据集给的是 8908 张 jpg、8908 个 VOC xml、8908 个 YOLO txt,五个类别合计 30854 个矩形框,标注工具是 labelImg,图片做过增强。类别覆盖正常土豆、损伤、缺陷、真菌性病害、萌芽五种状态,基本对应分选线下料口能拍到的那几类情况。它适合两类人:一是要快速搭一个土豆分选或仓储巡检原型的开发者,二是需要现成标注做迁移学习预训练的算法同学。注意,提供方明确写了不对训练精度作保证,这意味着后处理、阈值、场景适配仍然要自己扛。下面按“拆包核对 → 类别分析 → 转换训练 → 排错 → 验收”走一遍。

2. 拆包先看结构:VOC 与 YOLO 三件套的对齐校验和坐标系差异

拿到压缩包,别急着写训练脚本。这种双格式数据集最常见的崩法不是模型结构选错,而是三件套对不齐——图片有、xml 少一个、txt 多一个,训练时框架不报错,只是悄悄少读几百张图,最后指标怎么调都上不去。先把目录理清楚,再用脚本把 8908 组文件挨个点名。

2.1 压缩包解压后应该看到什么

按正文描述,这份数据集只包含 jpg 图片、VOC 格式 xml、YOLO 格式 txt,不包含分割路径的 txt 文件。也就是说没有train.txt、val.txt这类索引清单,划分要自己做。我一般会先把目录整理成下面这样,再动手:

potato/ ├── images/ # 8908 张 jpg ├── annotations/ # 8908 个 VOC xml ├── labels/ # 8908 个 YOLO txt └── preview/ # 图片预览与标注示例

如果原始包是 jpg 和 xml 混在一个文件夹里,先写个脚本把扩展名分流,别手工拖拽。文件名主干(stem)是后面所有对齐操作的唯一键,任何重命名都要保证 jpg、xml、txt 三者同步,否则校正起来很麻烦。

有个细节容易被忽略:交付方提到“数据集有增强,请认真查看图片预览”。增强图通常会以新文件名混进同一目录,文件名可能带_aug、_copy之类的后缀,也可能完全随机。先翻十几张预览图,确认增强方式是大致旋转、翻转、亮度扰动,还是把目标贴到不同背景上。这一步决定后面 3.2 节怎么处理重复场景。

2.2 用一段脚本核对 8908 组三件套

下面这段不依赖任何第三方库,纯标准库跑完。它的作用是输出三个集合的差集,差集为空才算对齐。

import os from pathlib import Path # 按实际解压路径改这三行 IMG_DIR = Path("potato/images") XML_DIR = Path("potato/annotations") TXT_DIR = Path("potato/labels") # 用文件名主干作为唯一键,忽略扩展名大小写 imgs = {p.stem for p in IMG_DIR.iterdir() if p.suffix.lower() in (".jpg", ".jpeg")} xmls = {p.stem for p in XML_DIR.iterdir() if p.suffix.lower() == ".xml"} txts = {p.stem for p in TXT_DIR.iterdir() if p.suffix.lower() == ".txt"} print("jpg:", len(imgs), "xml:", len(xmls), "txt:", len(txts)) # 三个方向都查一遍,避免只看单向缺失 print("有图无xml:", len(imgs - xmls), sorted(imgs - xmls)[:10]) print("有图无txt:", len(imgs - txts), sorted(imgs - txts)[:10]) print("有xml无图:", len(xmls - imgs), sorted(xmls - imgs)[:10]) print("有txt无图:", len(txts - imgs), sorted(txts - imgs)[:10])

脚本逻辑很简单:分别扫描三个目录,用Path.stem取主干。参数上唯一需要改的是三个路径常量。跑完如果jpg、xml、txt都打印 8908,且四个差集都是 0,说明三件套一一对应。

如果出现“有图无 txt”,优先怀疑两件事:一是这张图在 VOC 里本来就是负样本(不含任何目标,xml 里没有 object 节点),转 YOLO 时被跳过;二是转换脚本中途崩过。负样本要不要保留,取决于你的业务——分选线上空图很多,留一部分当背景样本能压误检;但如果验证集里空图占比过高,mAP 会被拉低,这点在第 6 章会再提。

2.3 VOC 的绝对坐标和 YOLO 的归一化坐标差在哪

两套格式描述的是同一个框,但写法完全不同。VOC 的 xml 里存的是绝对像素坐标,xmin/ymin/xmax/ymax,左上角到右下角,单位是像素;YOLO 的 txt 每行五个数,class cx cy w h,其中后四个全部除以了图宽图高,落在 0 到 1 之间,cx cy是框中心点。

这两个体系的差异会带来三类实际故障。第一类是边界框贴边:VOC 允许xmax等于图宽,转成归一化后cx + w/2刚好等于 1.0,某些框架会因浮点误差算出 1.0000001 而把框裁掉一小截;稳妥做法是转换后统一 clip 到[0, 1]。第二类是 1 像素差:VOC 的框是闭区间,宽高按xmax - xmin算出来会比实际少 1 像素,几千个框累加后对小目标有感知,但一般不影响训练,知道有这回事即可。第三类是坐标系原点:labelImg 默认左上角原点,如果你后续接了某个要求右下角原点的工具链,转换时要额外做一次翻转,不要想当然。

验证两者是否一致,最省事的办法是抽一张图,用 PIL 把 YOLO 的归一化坐标还原成像素框,和 xml 里的数值对一遍,误差在 2 像素以内就算通过。这一步花五分钟,能省掉后面一整天的怀疑。

3. 五类 30854 个框:类别长尾、增强痕迹与数据划分的取舍

3.1 先把 30854 个框按类别数出来

正文已经给了每类框数:Damaged potato 4850、Defected potato 3953、Diseased-fungal potato 7674、Potato 9868、Sprouted potato 4509,合计 30854。平均每张图约 3.46 个框,说明大部分图片是多目标场景,不是单果摆拍。

从分布看,Potato(正常)占 32%,Defected potato只占 12.8%,最大类与最小类相差约 2.5 倍。这个比例在农业数据集里算温和的,不需要上来就做重采样,但有两个后果要提前知道:一是模型会天然偏向把模糊目标判成Potato,因为它的先验概率最高;二是Defected potato的召回率通常最难做上去,验收时要单独看这一类的 AP,不能只看总体 mAP。

统计脚本可以直接从 xml 里数,顺带把每类框的宽高分布也打出来:

import xml.etree.ElementTree as ET from collections import Counter, defaultdict from pathlib import Path XML_DIR = Path("potato/annotations") cnt = Counter() sizes = defaultdict(list) for xml_path in XML_DIR.glob("*.xml"): root = ET.parse(xml_path).getroot() size = root.find("size") W = float(size.find("width").text) H = float(size.find("height").text) for obj in root.findall("object"): name = obj.find("name").text.strip() box = obj.find("bndbox") w = float(box.find("xmax").text) - float(box.find("xmin").text) h = float(box.find("ymax").text) - float(box.find("ymin").text) cnt[name] += 1 sizes[name].append((w / W, h / H)) # 归一化后便于跨分辨率比较 for name, n in cnt.most_common(): ws = [s[0] for s in sizes[name]] hs = [s[1] for s in sizes[name]] print(f"{name:28s} 框数={n:5d} 平均宽={sum(ws)/len(ws):.3f} 平均高={sum(hs)/len(hs):.3f}")

代码里的W、H从每个 xml 的size节点读,不要用全局常量,因为数据集可能混了不同分辨率的图。归一化宽高能直观看出目标尺度:如果多数框的归一化宽高都在 0.5 以上,说明是大目标特写,imgsz=640够用;如果大量框小于 0.1,就得考虑提高输入分辨率或者切图训练。

3.2 增强图的痕迹:同一场景重复出现会带来什么

原始数据做过增强,这是好事,也是隐患。好处是样本多样性上去了,模型对光照、角度的鲁棒性会好一些。隐患在于,如果增强是离线做好的、和原图放在同一个目录,那么同一颗土豆、同一个背景可能出现四五次,只是旋转角度或亮度不同。

这会直接影响数据划分。你要是纯随机按 8:2 切分,同一场景的“原图”进了训练集、“旋转版”进了验证集,验证指标会明显虚高,部署到新产线上立刻掉点。这种虚高特别有迷惑性,因为 loss 曲线很漂亮,你会以为模型学到了病害特征,其实它记住的是背景纹理。

识别办法有两个。肉眼法:随机抽 30 张缩略图拼成一张九宫格看,如果发现背景高度雷同、只是角度不同,基本可以确认。脚本法是算感知哈希分组,用 PIL 自己实现一个 dHash,不引入额外依赖:

from PIL import Image from pathlib import Path import itertools def dhash(path, size=8): img = Image.open(path).convert("L").resize((size + 1, size), Image.LANCZOS) px = list(img.getdata()) bits = [] for row in range(size): for col in range(size): left = px[row * (size + 1) + col] right = px[row * (size + 1) + col + 1] bits.append("1" if left > right else "0") return int("".join(bits), 2) hashes = {p.stem: dhash(p) for p in Path("potato/images").glob("*.jpg")} def hamming(a, b): return bin(a ^ b).count("1") # 阈值 5 是经验值,越小越严格;先小样本试,别一上来全量两两比 groups = [] stems = list(hashes) seen = set() for s in stems: if s in seen: continue group = [s] seen.add(s) for t in stems: if t not in seen and hamming(hashes[s], hashes[t]) <= 5: group.append(t) seen.add(t) groups.append(group) print("总图数", len(stems), "近似分组数", len(groups)) print("最大组大小", max(len(g) for g in groups))

hamming距离阈值 5 是偏宽松的起点,8x8 的 dHash 一共 64 位,阈值越小越严格。全量两两比较是 O(n²),8908 张跑起来会慢,实际用的时候先按文件名前缀或目录粗分组,再在组内算哈希。分好组之后,划分数据集时以“组”为单位整组进训练或验证,不要拆开。

3.3 划分训练验证集:按图分还是按场景分

承接上一节,结论是:有增强的数据集,划分单位必须是场景组,不是单张图。做法是把 3.2 得到的groups打乱,按 8:1:1 切,保证同一组的图只出现在一个 split 里。

如果嫌哈希麻烦,还有个降级方案:看文件名。很多增强流程会给增强图加统一后缀,比如原图IMG_0001.jpg、增强图IMG_0001_aug1.jpg,那就按主干前缀分组,简单可靠。前提是你要先翻预览图确认命名规律,别凭猜测写正则。

划分比例上,验证集不要低于 8%。五个类别里有长尾,验证集太小会导致Defected potato只有几十个框,AP 波动非常大,今天 0.62 明天 0.48,你会误以为是训练不稳定。我的习惯是训练 80%、验证 12%、测试 8%,测试集切完就锁起来,调参阶段一次都不碰,留到最后验收用。

4. 从 xml 到 txt:转换脚本、data.yaml 与训练参数怎么落

4.1 写一个可复现的 VOC→YOLO 转换脚本

虽然数据集自带 txt,但只要你改过类别名、筛过图、或者要重新划分,就得自己转一遍。下面这个脚本把 VOC 转 YOLO,同时做三件事:过滤不在类别表里的脏标注、对越界坐标做 clip、跳过无目标的空图(可选保留)。

import xml.etree.ElementTree as ET from pathlib import Path from PIL import Image # 顺序必须和 data.yaml 的 names 完全一致,改顺序等于改标签含义 CLASSES = [ "Damaged potato", "Defected potato", "Diseased-fungal potato", "Potato", "Sprouted potato", ] CLS2ID = {c: i for i, c in enumerate(CLASSES)} XML_DIR = Path("potato/annotations") IMG_DIR = Path("potato/images") OUT_DIR = Path("potato/labels_v2") OUT_DIR.mkdir(parents=True, exist_ok=True) KEEP_EMPTY = True # 空图是否生成空 txt,当背景样本用 for xml_path in sorted(XML_DIR.glob("*.xml")): root = ET.parse(xml_path).getroot() size = root.find("size") W, H = float(size.find("width").text), float(size.find("height").text) if W <= 0 or H <= 0: print("异常尺寸,跳过", xml_path.name) continue lines = [] for obj in root.findall("object"): name = obj.find("name").text.strip() if name not in CLS2ID: print("未知类别,跳过", name, xml_path.name) continue b = obj.find("bndbox") x1 = float(b.find("xmin").text) y1 = float(b.find("ymin").text) x2 = float(b.find("xmax").text) y2 = float(b.find("ymax").text) # 先 clip 再归一化,避免浮点误差把坐标推出 [0,1] x1, x2 = max(0.0, min(x1, W)), max(0.0, min(x2, W)) y1, y2 = max(0.0, min(y1, H)), max(0.0, min(y2, H)) if x2 - x1 < 1 or y2 - y1 < 1: continue # 宽高为 0 的退化框直接丢 cx = (x1 + x2) / 2 / W cy = (y1 + y2) / 2 / H w = (x2 - x1) / W h = (y2 - y1) / H lines.append(f"{CLS2ID[name]} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") if not lines and not KEEP_EMPTY: continue (OUT_DIR / f"{xml_path.stem}.txt").write_text("\n".join(lines), encoding="utf-8")

逻辑说明:脚本以 xml 为主驱动,一张图对应一个 txt。CLASSES的顺序是硬约束,CLS2ID由它派生,任何一行都不允许单独改动。坐标先 clip 到图像范围再归一化,是为了处理标注时手抖画到图外的框;退化框(宽或高小于 1 像素)直接丢,因为归一化后会变成 0,训练时可能触发除零或无效 anchor。

参数上需要你改的只有四个路径常量和KEEP_EMPTY。KEEP_EMPTY=True会把无目标的图写成空 txt,这在 YOLO 里是合法的背景样本;但要注意,如果一个 batch 里全是空 txt,loss 会异常偏低,训练日志看起来像卡住了,这时候检查一下空图比例,超过 15% 就考虑降采样。

转换完成后再跑一遍 2.2 的对齐脚本,把TXT_DIR指向labels_v2,确认输出数量与图片一致。这个动作重复一次不亏,转换脚本写错一个缩进就能悄悄少生成几百个文件。

4.2 data.yaml 的 names 顺序必须和 txt 索引锁定

YOLO 的 txt 只存类别索引,不存类别名,所以data.yaml里的names列表顺序就是标签含义的唯一来源。索引 0 在 txt 里是 0,在 yaml 里必须还是Damaged potato。这个对应关系一旦错位,训练照样能跑、loss 照样下降,但模型学到的语义全乱,这是最典型的黑匣子故障。

# potato.yaml path: /data/potato # 数据集根目录,绝对路径最稳 train: images/train val: images/val test: images/test names: 0: Damaged potato 1: Defected potato 2: Diseased-fungal potato 3: Potato 4: Sprouted potato

写 yaml 时有三个注意点。第一,path建议写绝对路径,相对路径是相对于运行命令时的当前目录,不是相对于 yaml 文件,很多人在这里翻车。第二,names里的类别名带空格和连字符,yaml 里用引号包起来更保险,虽然这里不加引号也能解析。第三,train、val指向的是图片目录,框架会自动把路径里的images替换成labels去找标注,所以目录命名最好遵循这个约定,省得再配labels字段。

4.3 训练命令与一组保守可用的超参

环境里装好检测框架后,一条命令就能起训。下面这组参数是我在 8k 量级、五类、单卡场景下比较常用的起点,不是最优解,但复现性好不容易出问题:

yolo detect train \ data=potato.yaml \ model=yolov8s.pt \ imgsz=640 \ epochs=100 \ batch=16 \ workers=8 \ patience=30 \ lr0=0.01 \ lrf=0.01 \ close_mosaic=10 \ cache=ram \ project=runs/potato \ name=baseline

逐项说清楚。model换成你环境里已有的权重文件,小模型先跑通再换大模型,别一上来就上大 backbone,8k 张图容易过拟合。imgsz=640对应大多数产线相机的下采样尺寸,如果 3.1 节统计出目标普遍偏小,可以提到 960 再试。batch=16是 8G 显存下的安全值,显存够就往上加,batch 太小 BN 统计不稳。patience=30表示 30 轮验证指标没提升就早停,配合 100 epoch 上限,实际往往 60 到 80 轮就停了。close_mosaic=10在最后 10 轮关掉马赛克增强,让小目标回归真实分布,这一步对最终 mAP 通常有零点几个点的正向影响。cache=ram把图片缓存进内存,8908 张 jpg 大概几个 G,内存够就开,能明显减少数据加载瓶颈;内存紧张就改cache=disk或去掉。

训练启动后先盯前 10 轮。看三件事:train/box_loss是否稳定下降、val/box_loss有没有发散、每类的metrics/mAP50是不是从第二轮开始就有非零值。如果某一类从头到尾 AP 都是 0,八成是 4.2 的 names 错位或者这一类在验证集里样本太少,回到 2.2 和 3.1 复查。

5. 避坑排查:这份数据集最容易翻车的五个地方

5.1 训练 loss 正常下降,但推理结果几乎全是 Potato

现象:训练日志一切正常,验证集 mAP50 能到 0.7 上下,但拿真实图片推理,框出来的几乎都标成Potato,病害类极少出现。

原因:Potato这一类占了 9868 个框,是Defected potato的 2.5 倍。交叉熵在类别不平衡时会偏向高频类,加上真菌性病害和缺陷在低分辨率下纹理接近,模型走了捷径。另一个可能是验证集里Potato的图片占比过高,导致指标看起来还行。

解决:先按 3.1 的脚本确认验证集每类框数,如果最小类少于 200 个框,先补验证集而不是改模型。训练侧可以开类别权重或者用 copy-paste 增强补长尾类;推理侧按类别分别调置信度阈值,病害类阈值压低到 0.25,正常类提到 0.5,用混淆矩阵验证调整效果。

5.2 mAP 虚高,换一批图就崩

现象:验证集 mAP50 到 0.85,换一个批次的实拍图,漏检率直接过半。

原因:3.2 说的增强泄漏。同一场景的原图和增强图被随机分到了训练集和验证集,验证集对模型来说是“见过的背景”,指标虚高。也可能验证集和训练集来自同一次采集,光照、背景完全一致。

解决:按 dHash 分组重划数据集,保证同组不跨 split;再额外留一批不同时间段采集的图做测试集,哪怕只有两三百张。验收时以这个独立测试集为准,验证集指标只用于调参。

5.3 标注框贴边被裁,小目标训练时消失

现象:训练时提示某些标签越界,或者可视化时发现图像边缘的目标框少了一截。

原因:VOC 的xmax允许等于图宽,转 YOLO 归一化后cx + w/2在浮点下可能略大于 1,部分框架的数据增强(比如随机裁剪、仿射变换)会把这个框判为非法直接丢弃。

解决:用 4.1 脚本里的 clip 逻辑,先把坐标夹到[0, W]、[0, H]再归一化,并在写出前检查w > 0 and h > 0。增强阶段把随机裁剪的边界留出 5% 余量,别贴着边裁。

5.4 同一张图同时出现在训练集和验证集

现象:对齐脚本显示文件数都对,但训练到后期验证 loss 突然掉到很低,mAP 跳变。

原因:增强图文件名不同、内容近似,随机划分时被拆散;或者你复制过目录,同一个 stem 在两个 split 里各存了一份。

解决:划分前先做一次全局 stem 去重,跨 split 取交集,非空就报错退出。把这段检查写成训练前的固定步骤,比事后排查省事得多。

5.5 中文路径或空格路径导致读图失败

现象:训练启动即报错,提示找不到图片,或者读到 0 张图。

原因:数据集解压到了带中文或空格的目录,部分底层库对路径编码处理不一致;data.yaml里的相对路径又叠了一层当前目录问题。

解决:数据根目录统一用纯英文、无空格、层级浅的路径,比如/data/potato。yaml 里的path写绝对路径。如果必须用中文路径,先小批量跑yolo detect val验证读图链路,别等训练到第 10 轮才发现。

6. 验收与进阶:用单类 AP 和分层小样本判断要不要继续投入

6.1 别只看 mAP50,先看每类召回和混淆矩阵

农业检测场景的代价不对称:漏掉一颗病害土豆,可能污染一整箱;把正常土豆误判成病害,最多多切一刀。所以验收时优先级是每类召回 > 每类 AP > 总体 mAP50。

跑一次验证,把逐类结果拉出来:

yolo detect val \ model=runs/potato/baseline/weights/best.pt \ data=potato.yaml \ split=test \ conf=0.001 \ iou=0.6 \ plots=True

conf=0.001是为了让 PR 曲线覆盖全阈值区间,方便后面手动挑工作点;iou=0.6是常规匹配阈值,如果目标密集、重叠多,可以降到 0.5 再看一遍。输出目录里的混淆矩阵要重点看两处:Diseased-fungal potato有多少被误判成Potato,Defected potato有多少被判成Damaged potato。这两组混淆如果占比超过 20%,说明类别定义在视觉上不够可分,要么合并类别,要么补更多区分性样本,光调参解决不了。

指标关注点我的参考线
每类 Recall病害漏检病害三类均不低于 0.80
每类 AP50长尾类是否学动最小类不低于 0.65
背景误检率空图误报不高于 10%
推理耗时产线节拍单图 640 输入低于 30ms

这张表不是标准,是我自己的止损线。低于参考线时,先回去查数据划分和类别错位,再考虑换模型。

6.2 一个省时间的做法:分层抽样 800 张先跑通

全量 8908 张跑 100 轮,单卡动辄几小时。我的习惯是先按类别框数比例分层抽 800 张,跑 15 轮,专门用来验证三件事:标签索引有没有错位、数据管道读图是否正常、增强有没有把目标搞没。这 800 张里保证每个类别至少 60 个框,Defected potato这种小类按比例上浮。

抽样脚本按 3.1 统计出的每类框数做比例分配,抽完单独生成一份potato_mini.yaml,path指向 mini 目录。15 轮跑完看验证集的可视化结果,如果框的位置和类别都对得上,再切回全量。这一步大概二十分钟,能挡掉八成低级错误。

我自己有过一次教训:直接上全量训了四个小时,最后发现data.yaml的names顺序和转换脚本的CLASSES不一致,模型把萌芽当成了正常,loss 曲线却漂亮得很。从那以后我每次起训前都强制走一遍“对齐脚本 + 800 张分层抽样 + 可视化抽检”,三关过了才开全量。这套流程看着笨,但比事后复盘省时间。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进