☰
油茶儿成熟检测数据集:VOC转YOLO格式与训练避坑实战
2026/10/1 9:03:34 网站建设 项目流程

简介:面向油茶儿成熟度检测的YOLO数据集,专为农业视觉与实时目标检测场景设计,适合需要训练成熟度识别模型的算法工程师、研究人员及农业智能化开发者,可有效解决人工检测效率低、高质量标注数据稀缺等问题。资源共220个文件,包含110张不同季节、光照条件下的油茶儿果实图像与110份配套XML标注文件,整体大小约276.03MB。XML标注由专业人员手工完成,以边界框标定果实位置,并记录青涩、转色、成熟等阶段的状态标签,格式规范、与YOLO系列算法兼容性好,可直接用于模型训练、验证与推理测试。已有85人浏览学习,图像覆盖多阶段外观特征和多种拍摄环境,数据划分灵活,可帮助使用者省去大量采集与标注成本,快速搭建油茶儿成熟度检测基线;同时为后续优化模型结构、调整超参数或迁移至其他农作物成熟度识别提供了可靠的数据基础。

1. 油茶儿成熟检测数据集:这份资源到底解决什么问题

油茶儿成熟检测数据集,名字听起来只是个农业课题的附属品,但它在 YOLO 目标检测这条线路上踩的坑,一点不比通用物体检测少。这份资源给的是图片加 xml 标签的 VOC 格式标注,也就是说你拿到手的不是一句"建议你用 YOLO 检测成熟度"的废话,而是一批已经框好边界的真实样本。想自己从头标一版,得爬树、换光照、分成熟度分别框,一周起步。这份数据解决的是果实成熟度检测里"有数据可训"这个前置问题,适合正在做智慧农业、油茶种植监测,或者需要一个密集小目标检测练手集的人。接下来我按自己复现的流程,把从解压、转格式到训练、避坑的过程完整拆开讲,照着走能少走很多弯路。

2. 先把 VOC 的 xml 看清:目录结构、标签字段与一致性体检

很多人在第一步就翻车:压缩包解压出来,看一眼有图片有 xml,直接拿第三方脚本转 YOLO 格式,结果训练出来的模型框偏移、loss 不收敛,回头排查才发现 xml 里的 size 和实际图片尺寸对不上,或者类别名里混进了前缀空格。所以拿到这份数据集,先用半小时做一次"数据体检",比急着训练重要得多。

2.1 解压后先不要急着训练:目录结构长什么样

解压之后第一件事是用命令把结构打出来,而不是双击打开随便翻。常见做法是先用unrar或者你本地的图形化解压工具解压,然后通过命令行统计数量和目录层级。

# 解压(具体文件名以你下载到的为准) unrar x "YOLO目标检测-油茶儿成熟检测数据集(图片+xml标签).rar" # 看目录层级 find . -maxdepth 2 -type d | sort # 统计图片和xml数量,判断是否一一对应 echo "jpg数量: $(find . -name "*.jpg" | wc -l)" echo "xml数量: $(find . -name "*.xml" | wc -l)" # 找出有xml没图片、有图片没xml的文件对,防止数据不齐 for xml in $(find . -name "*.xml"); do img="${xml%.xml}.jpg" [ -f "$img" ] || echo "缺少图片: $img" done

find和wc -l的组合是快速摸清数据集体量的基础操作,建议把统计结果记下来,后续划分 train/val 时能估算出大致比例。.jpg是这份资源里最可能的图片格式,但如果你解压后发现是.png或者.jpeg,把代码里的后缀统一改掉再跑。

2.2 xml 里的每个标签都在说什么:object、bndbox、difficult

这份数据用的是 VOC 风格标注,也就是 LabelImg 默认输出的格式。一个 xml 文件对应一张图片,里面最关键的信息都集中在下表这几个字段里。

字段含义对 YOLO 训练的影响
filename图片文件名与其他文件关联的主键,改文件名必须同步改这里
size/width、height、depth图片原始宽高和通道数转换标签时要拿它做分母,数值错误会导致框偏移
object/name目标类别名类别映射表靠它生成,名字里多个空格都算不同类
object/bndboxxmin、ymin、xmax、ymax(像素整数坐标)转 YOLO 格式的原始输入,必须保证在图片范围内
object/difficult0 或 1,是否难以辨认YOLO 训练会把它当普通目标处理,容易引入噪声
object/truncated目标是否被截断截断样本对遮挡检测有帮助,一般保留

difficult这个字段在 VOC 时代的语义是"这框太模糊我不打算考它",但 YOLO 系的训练脚本默认不去读它。也就是说,如果这份数据里存在difficult=1的框,转换之后它照样参与训练,而这些框往往是角度刁钻或遮挡严重的样本,反而成了模型学习的干扰项。我的建议是转换前先统计一下这类框的数量,占比超过 5% 就考虑要不要过滤。

2.3 用脚本快速体检这组 xml 的完整性和一致性

接下来写一个小脚本遍历所有 xml,检查坐标是否越界、size 是否异常。这一步能提前暴露标注质量问题,省得训练到一半才发现。

import xml.etree.ElementTree as ET from pathlib import Path for xml_path in Path("annotations").glob("*.xml"): root = ET.parse(xml_path).getroot() w = int(root.findtext("size/width")) h = int(root.findtext("size/height")) if w <= 0 or h <= 0: print(f"[异常] {xml_path.name}: size 无效 {w}x{h}") for obj in root.iter("object"): box = obj.find("bndbox") xmin = float(box.findtext("xmin")) ymin = float(box.findtext("ymin")) xmax = float(box.findtext("xmax")) ymax = float(box.findtext("ymax")) if xmin < 0 or ymin < 0 or xmax > w or ymax > h: print(f"[越界] {xml_path.name}: {xmin},{ymin},{xmax},{ymax} 图片 {w}x{h}") if xmax <= xmin or ymax <= ymin: print(f"[无效] {xml_path.name}: 框面积为0或反向坐标")

这段代码的逻辑很直白:先把 size 读出来,再去校验每个 bndbox 是否落在图片范围内。findtext如果找不到节点会返回None,所以当 xml 结构被标注工具改过时,这里会抛异常,你反而能第一时间发现是哪几个文件出了问题。越界框常见原因是标注过程中图片被 resize 过而 xml 未同步更新,这类文件要在转换阶段单独处理。

2.4 统计类别分布与目标密度:决定训练策略的依据

在动手转换之前,还有一件事值得做:统计类别名和目标密度。这决定了后面的训练是单类还是多类,也决定了数据增强里要不要开 copy-paste。

from collections import Counter from pathlib import Path import xml.etree.ElementTree as ET cnt_name = Counter() cnt_per_image = [] for xml_path in Path("annotations").glob("*.xml"): root = ET.parse(xml_path).getroot() boxes = [obj for obj in root.iter("object")] cnt_per_image.append(len(boxes)) for obj in boxes: name = obj.findtext("name") cnt_name[name.strip()] += 1 print("类别分布:", dict(cnt_name)) print("单张图目标数均值:", round(sum(cnt_per_image) / len(cnt_per_image), 2)) print("单张图目标数最大值:", max(cnt_per_image))

name.strip()这一步千万别省,我遇到过类别名写成mature(末尾带空格)的情况,肉眼完全看不出来,但转出来的标签类别 ID 全乱了。目标密度这个数字直接关系到训练配置:如果均值超过 30,说明是密集小目标场景,输入尺寸尽量不低于 640;如果均值只有三五个,说明目标相对稀疏,可以优先考虑 416 或者更轻量模型。

3. 把 xml 转成 YOLO 格式:归一化公式、转换脚本与三个边界坑

VOC 格式的 xml 没法直接喂给 YOLO 的 dataloader,YOLO 需要的是与图片同名的 txt 文件,每行一行规范化的目标信息:类别 ID、中心点坐标和宽高。这一段是整个流程里最容易出错的地方,公式本身简单,但三个边界坑几乎每个人都踩过。

3.1 为什么转?YOLO 不认 xml 的像素坐标框

VOC 用绝对像素坐标描述框的位置,xmin/ymin/xmax/ymax 的单位是像素;YOLO 则要求四个值全部归一化到 0 到 1 之间,且这组值代表的是目标中心坐标和框尺寸,不是左上和右下角。归一化的好处是:图片被缩放到任意分辨率,标签都不用重新算。转换公式是:

center_x = (xmin + xmax) / 2 / image_width center_y = (ymin + ymax) / 2 / image_height box_width = (xmax - xmin) / image_width box_height = (ymax - ymin) / image_height

注意所有除法都用同一组 image_width 和 image_height,也就是 xml 里 size 字段的值,或者实际图片的宽高。哪一步用了不同的尺寸基准,框就会往某个方向偏移。

3.2 转换脚本:从 xml 目录到 labels 目录一次搞定

下面这个是可用版本,直接保存成voc2yolo.py就能跑,注意按实际目录调整路径。

import xml.etree.ElementTree as ET from pathlib import Path IMG_DIR = Path("images") # 图片目录 XML_DIR = Path("annotations") # xml目录 OUT_DIR = Path("labels") # 输出yolo标签目录 OUT_DIR.mkdir(exist_ok=True) # 类别顺序直接决定标签第一位的数字,必须与训练时的data.yaml保持一致 CLASSES = ["mature"] # 按xml里实际出现的name填写,顺序一旦确定不要改 def convert_one(xml_path: Path): tree = ET.parse(xml_path) root = tree.getroot() img_w = int(root.findtext("size/width")) img_h = int(root.findtext("size/height")) lines = [] for obj in root.iter("object"): name = obj.findtext("name") if name not in CLASSES: print(f"[跳过] {xml_path.name}: 未知类别 {name}") continue class_id = CLASSES.index(name) box = obj.find("bndbox") xmin = float(box.findtext("xmin")) ymin = float(box.findtext("ymin")) xmax = float(box.findtext("xmax")) ymax = float(box.findtext("ymax")) # 归一化,保留6位小数足够 cx = (xmin + xmax) / 2.0 / img_w cy = (ymin + ymax) / 2.0 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h lines.append(f"{class_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") return lines for xml_path in sorted(XML_DIR.glob("*.xml")): lines = convert_one(xml_path) if lines: out_path = OUT_DIR / (xml_path.stem + ".txt") out_path.write_text("\n".join(lines) + "\n") print(f"[OK] {xml_path.name} -> {out_path.name}")

代码逻辑分三层:最外层遍历所有 xml;中间层读取图片尺寸并遍历 object;里层做坐标归一化和文本格式化。六个小数位在处理 6000 像素级别的大图时误差小于一个像素,够用;不要用round(..., 2),那会把小目标的框精度毁掉。

CLASSES 列表的写法有个细节值得单独说:它既负责过滤未知类别,又决定了类别 ID。如果你这份数据里有成熟和未成熟两类,就按["mature", "immature"]这样的顺序排列,训练时的 data.yaml 必须保持完全一致,否则模型训练和验证时标签对不上。

3.3 转换后的强制校验:把标签画回图上

转完格式不算完,还要验证转换过程没把框弄丢。最直观的做法是把 txt 里的归一化坐标还原成像素框,画到原始图片上,肉眼抽查一遍。

import cv2 from pathlib import Path for txt_path in sorted(Path("labels").glob("*.txt"))[:10]: # 先抽查前10个 img_path = Path("images") / (txt_path.stem + ".jpg") img = cv2.imread(str(img_path)) if img is None: print(f"[缺图] {txt_path.stem}") continue h, w = img.shape[:2] with open(txt_path) as f: for line in f: parts = line.strip().split() if len(parts) != 5: print(f"[格式错] {txt_path.name}: {line}") continue _, cx, cy, bw, bh = map(float, parts) x1 = int((cx - bw / 2) * w) y1 = int((cy - bh / 2) * h) x2 = int((cx + bw / 2) * w) y2 = int((cy + bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imwrite(f"check/{txt_path.stem}_check.jpg", img)

这里的换算正好是归一化公式的逆运算:中心坐标减半宽得到左上角,加半宽得到右下角。同一个 txt 画出的框如果和原 xml 里的像素坐标对不上,说明转换脚本或数据集有问题,而不是标注错了。

3.4 三个边界坑:踩过的人才懂为什么单独列

第一个坑是取整时机。有人图省事,先把 xmin、xmax 取整再相减,结果小目标的宽直接变成 0,转出来的标签是废的。正确做法是全程用浮点数,最后格式化输出时才限制小数位数。

第二个坑是 size 字段和实际图片不一致。标注工具记录的是标注当时的尺寸,如果后来有人批量压缩过图片,xml 里的宽高就成了过期数据。我一般会在转换脚本里用 OpenCV 读一次真实图片宽高,以此为准做分母,而不是迷信 xml 里的 size。

第三个坑是类别 ID 的稳定性。转换脚本多跑一次不会改变 ID,但如果你中途改了 CLASSES 顺序,或者把过滤未知类别的逻辑写晚一步,整个 labels 目录的语义就变了。建议转换时把 CLASSES 写成常量并加注释,训练前再跑一次类别统计确认标签里的第一个数字没有异常值。

4. 训练参数怎么设:输入尺寸、anchors 与数据增强的取舍

数据格式修好了,接下来面对的是训练配置。油茶儿检测的本质是密集小目标问题:果实挂在树上,叶片遮挡、光照变化、成熟度过渡,这些因素直接决定了你不能照搬 COCO 的默认配置。这一章的参数我按实践中的优先级排开,先说影响最大的输入尺寸。

4.1 输入尺寸选 640 还是 416:从目标占比倒推

很多人纠结输入尺寸是玄学,其实有判断依据。用第 2 章统计出的框宽高中位数,除以图片尺寸,得到"目标占比"。如果中位数占比在 3% 以下,属于典型小目标,用 640 保证缩放后果实还能保留足够像素;如果占比超过 8%,说明果实本身就大,416 可以换来更快的训练速度。

油茶儿这种场景我默认建议 640 起步。理由很直接:果实密集且互相遮挡,缩到 416 后相邻果实的边界在特征图上几乎融为一体,anchor 匹配会变得不稳定。显存不够时,与其降到 416,不如保持 640 但把 batch 减到 8,前者牺牲的是检测能力,后者只是牺牲一点训练速度。

4.2 anchors 先重算:autoanchor 不是玄学,是省时间的捷径

YOLOv5 和 YOLOv8 训练时会默认执行 autoanchor,也就是根据你这份数据集的标签自动聚类出合适的 anchor 尺寸。这个机制平时不用管,但它有两个前提:标签格式必须完全正确,且数据集中在同一类目标上。

油茶儿果实的尺寸分布相对集中,autoanchor 能算出一组贴近真实框的初始值,收敛更快。需要手动介入的情况是:物体尺寸极度不均匀,比如既有小果实又有被无人机拍到的大簇果实,聚类出的 anchors 可能会偏向某一端。我一般会跑一次训练前检查,看日志里 autoanchor 输出的 k-means 结果,如果最大 anchor 和最小 anchor 差距超过 10 倍,再考虑分两阶段训练。其他情况不用手动调,这个环节真的不是玄学。

4.3 我这套跑下来比较顺的超参组合

以下参数是针对油茶儿类似场景的一套起始配置,环境是单卡 8G 显存,YOLOv8s 权重。参数值不是绝对的,但是按重要性排序的,改的时候优先动后面的。

参数取值说明
imgsz640输入尺寸,密集小目标场景不建议低于 640
batch168G 显存跑 YOLOv8s 的稳妥值,显存大可以到 32
epochs150单类检测任务 100 个 epoch 够,想看稳定收敛就 150
lr00.01从预训练权重续训建议降到 0.001,防止破坏已有特征
lrf0.01余弦退火末段学习率,不需要动
optimizerSGDYOLOv8 默认 AdamW,小数据集上 SGD 泛化更稳
seed0固定随机种子,方便复现对比实验
close_mosaic10最后 10 个 epoch 关闭 mosaic,避免增强分布干扰收敛

训练命令用 YOLOv8 的 CLI 启动最省事:

yolo detect train \ data=oiltea.yaml \ model=yolov8s.pt \ imgsz=640 \ batch=16 \ epochs=150 \ lr0=0.01 \ optimizer=SGD \ close_mosaic=10 \ seed=0

data=oiltea.yaml指向你的数据集配置文件,里面至少包含train、val路径、类别数量和类别名称。close_mosaic=10这个参数在 YOLOv8 里是官方推荐的,它在最后 10 轮把 mosaic 增强关掉,让模型在接近真实分布的图片上收尾,能在最后几轮把 mAP 稳住甚至再提一点。model=yolov8s.pt加载的是 COCO 预训练权重,不是随机初始化,对收敛速度影响很大。

4.4 数据增强:密集果实场景下哪些开关要动

数据增强不是全开就好。针对油茶儿数据集中"果实密集、互相遮挡、背景复杂"三个特点,最值得关注的是这几个增强项:

mosaic 是 YOLOv5 以来提升最明显的增强策略,把四张图拼成一张,变相扩大 batch 的上下文多样性。但在密集目标场景里,四张图拼接时会随机裁剪,果实被裁掉一半的情况很常见,所以建议用close_mosaic在训练后期关掉它。hsv 增强调整亮度饱和度和色相,对果实的颜色变化有帮助,但注意饱和度增强幅度别太大,否则会把青色叶子和青色未成熟果实混在一起。翻转向量和角度默认参数在树冠场景足够,不需要额外调。copy-paste 增强如果数据集目标数不够多可以开,它能把一个果实框复制到另一张图上,对密集场景有正向帮助,但前提是你的目标框标注足够准确,否则复制过去的是一堆噪声。

5. 训练与转换中绕不开的坑:从标注到 val 的四条排查记录

这一章写的是我自己在复现这类数据集时真实踩过的坑,按出现频率排序。每一条都按"现象 → 原因 → 解决"的格式讲,你在训练时如果撞上同样的问题,直接对号入座。

5.1 loss 正常下降但 mAP 一直上不去:先从标签找问题

现象:训练日志里 box_loss 和 cls_loss 都在降,但 val 的 mAP50 卡在 0.3 左右不动。换预训练权重、调学习率都不管用。

原因:最常见的是类别映射错位。xml 里的类别名列表和 data.yaml 里的 names 顺序不一致,转出来的标签第一列数字指的类别和训练时 YOLO 理解的类别不是一个东西。另一种可能是 xml 里存在difficult=1的框,这些框本身标注质量差,参与训练后模型的置信度被反复拉低。

解决:先做一次标签验证,把所有 txt 文件里的第一列数字统计出来,和你的类别数对比。如果数字范围超出了类别数减一,说明转换脚本过滤逻辑没生效。然后处理 difficult 框,在转换脚本里加一行过滤条件:

# 过滤difficult框后再写txt if obj.findtext("difficult") == "1": continue

这两步做完重训,一般情况下 mAP 会有肉眼可见的提升。

5.2 训练中途 loss 变成 nan:先查标签和显存

现象:训练跑到第 20 到 40 个 epoch 之间,loss 突然打印出 nan,然后一直回不来。

原因:90% 的情况是标签文件有问题,常见的是空 txt 文件或者某一行坐标出现无穷值。少数情况是学习率设太大,触发梯度爆炸,尤其当 batch 也开得比较大时。

解决:先跑一次全量标签检查,不要手动翻。

# 找出所有空txt find labels -name "*.txt" -size 0 # 找出坐标越界的行(任何字段超出[0,1]) awk '$2<0 || $2>1 || $3<0 || $3>1 || $4<0 || $4>1 || $5<0 || $5>1 {print FILENAME, $0}' labels/*.txt

-size 0会列出所有 0 字节文件,这些文件对应的图片没有目标,训练时读到空标签会计算出无效损失。awk 那行检查五个字段是否都在合法区间,输出里只要出现任何一行,直接定位到对应的 xml 手工修。如果标签没问题,再把lr0降到 0.001、batch减半重试。

5.3 检测框比实际果实大一圈或整体偏移:size 字段不一致

现象:验证集上检测框能框到目标,但整体向右上方偏移,或者框明显比真实果实大一圈。训练集上也有同样问题,说明不是后处理的问题。

原因:转格式时用了 xml 里的 size 作为分母,但 xml 记录的是标注工具的原始尺寸,图片后来被压缩过,实际宽高和 size 不一致。归一化算出的 txt 标签虽然值在 0 到 1 之间,但还原到实际图片上时,真实图片坐标系变了,框就错位了。

解决:转换脚本里不要读 xml 的 size,直接用 OpenCV 读实际图片尺寸。改动很小,但效果天差地别:

import cv2 img = cv2.imread(str(IMG_DIR / (xml_path.stem + ".jpg"))) img_h, img_w = img.shape[:2] # 以实际图片为准,忽略xml里的size

5.4 训练集验证集都高、换到新场景立刻翻车:划分方式有 bug

现象:同源测试集上 mAP 很漂亮,一旦换到另一片果园或另一台设备的视频,检测效果明显下降。

原因:随机划分 train/val 时,同一棵树、同一时段的照片同时出现在两边,数据高度相似。模型学到的是这片果园的特征,而不是"油茶儿"本身的特征,这叫数据泄漏。

解决:划分时按序列或按目录分,不要按单张图片随机分。常见的做法是先把所有图片按拍摄时间或地点做分组,然后用组为单位划分:

# 按目录维度划分:每个子目录里的图片要么全进train,要么全进val python - <<'EOF' import random from pathlib import Path groups = sorted([p for p in Path("images").iterdir() if p.is_dir()]) random.seed(0) random.shuffle(groups) split = int(len(groups) * 0.8) with open("train.txt", "w") as f: for g in groups[:split]: for img in sorted(g.glob("*.jpg")): f.write(str(img.resolve()) + "\n") with open("val.txt", "w") as f: for g in groups[split:]: for img in sorted(g.glob("*.jpg")): f.write(str(img.resolve()) + "\n") EOF

这段脚本会把每个子目录当作一个整体,整个目录的图片要么全进训练集,要么全进验证集,避免同一地块的照片同时出现在两边。如果你的数据没有按目录分好,至少要做到按拍摄时刻分桶,比如把间隔 1 秒内的照片分到同一边。

6. 验证与进阶:读混淆矩阵与调阈值,把模型推到能用

6.1 看混淆矩阵别只看总行数:YOLO 的矩阵计数规则

训练完第一件事不是看 mAP,而是打开 val 输出的 confusion_matrix.png。这张图在主对角线上的值越高越好,但有一个容易误判的细节:矩阵里的数值是归一化比例,不是样本数,而且行总和往往不等于该类的真实样本数,因为验证时 YOLO 会做多尺度推理和 TTA,同一个目标会被多个预测框命中,重复计入。所以正确的读法是看"背景帧被误判成目标的概率"和"类别间的混淆程度",不要纠结对角线上为什么不等于 1。如果背景那一列有超过 10% 的误检,说明模型把树叶纹理当成了果实,优先检查数据增强里 hsv 的饱和度设置。

6.2 conf 与 NMS 阈值:从测试视频里找漏检和重框

验证集指标不直接等于上线效果,把 best.pt 拿到真实视频里跑一遍,边看边调两个参数。

# conf=0.25是默认值,先跑一遍看漏检和误检分布 yolo detect predict model=runs/detect/train/weights/best.pt source=test.mp4 conf=0.25 iou=0.45 save=True

conf 决定什么样的框算检出,调低漏检减少但误检变多,调高则反过来。视频里同一颗果实在连续帧中被反复点亮是正常的,如果同帧内相互重叠的框太多,把 iou 从 0.45 往 0.6 方向调,NMS 会合并掉冗余框。油茶儿这类密集场景,我一般会从 conf=0.25 和 iou=0.45 出发,先降 conf 补漏检,再看误检是哪一类,如果是同类果实重复框就升 iou,如果是树叶被误判就回到数据增强找原因。前阵子我图省事,默认阈值直接部署到果园 demo,结果模型把满地落叶当成了成熟果实,当场翻车。从那以后我每次训练完都会强制走一遍"视频实拍 → 扫 conf/iou → 看误检类型"的流程,再决定要不要回到训练侧调参,养成习惯后踩坑次数明显少了很多。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询