☰
草莓目标检测数据集YOLO/VOC格式转换与训练避坑实战
2026/10/11 10:59:05 网站建设 项目流程

简介:面向农业与计算机视觉开发者,这份草莓目标检测数据集覆盖VOC和YOLO两种主流标注格式,可直接用于目标检测模型训练与算法验证。VOC部分按JPEGImages图片目录和Annotations标签目录组织,每张jpg带对应xml框选信息;YOLO部分提供images与labels目录,并预先划分train.txt、val.txt,省去自行切分数据集的步骤。文件包共478个文件,包含236张jpg图像、122个txt标注、118个xml标签、1个yaml配置及1个cache文件,总大小10.13MB,目录结构清晰,便于快速下载与迭代实验。已有221人学习使用。借助这份数据,可省去从零采集和标注草莓图像的重复劳动,直接训练识别模型;同时可用于探究复杂背景下的草莓定位、不同光照与视角下的鲁棒性,也可用于理解VOC与YOLO两种标注体系的转换关系,适合入门练习和中小型农业视觉项目预研,并能依据train/val划分快速评估模型效果。

1. 草莓目标检测数据集:先解决“能用来干什么”再动手

一个压缩包同时带上YOLO格式和VOC格式标注,听起来只是省了几分钟转换,实际解决的是这个领域的通用问题:做草莓采摘机器人、果实计产或大棚巡检的人,拿到标注数据的第一个下午通常耗在格式不统一上——Pascal VOC的XML在mmdetection里顺手,YOLO的txt在Ultralytics里顺手,两套格式对不上,训练脚本白跑一晚的case并不少见。这份草莓目标检测训练数据集的价值在于把两条线都备齐,让你从解压开始就把注意力放在真正费钱的环节:标注校不准确、划分有没有同源重复、训练后的指标能不能反映真实田间表现。适合两类人:一类是刚上手目标检测,想用一张干净表格把YOLO和VOC两种标签体系对上的新手;另一类是已经跑过一轮模型,但被大棚复杂光照、小目标漏检折磨过的从业者。下面按我的工作习惯,从拆格式开始讲。

2. 拆开这份数据集之前:先看懂YOLO格式和VOC格式的组织差异

解压后不管压缩包内部的目录叫什么,你最终要找到三类东西:图像、YOLO的txt标注、VOC的XML标注。常见做法是images/ 放原图,labels/ 放YOLO的txt,Annotations/ 放VOC的xml;如果你的包不是这个排布,先以标注能对应上图像为准,别急着改目录名。

2.1 YOLO格式的目录与标签结构长什么样

先看YOLO侧。所谓YOLO格式,在Ultralytics生态里已经是“一套txt平铺”的约定:每张图片对应一个同名txt,行数与这张图里的目标框个数一致,每行五个字段:类别id、框中心点x、框中心点y、框宽、框高,全部对图像宽高做了归一化,取值在0~1之间。对草莓检测来说,类别id通常是0代表成熟草莓、1代表未成熟草莓或花;如果你的解压包里已经有yaml或data.yaml,以那个为准。这里有一个新手最容易默认“坐标是像素”的错误,归一化后的值只有除以图像宽高得到,千万别被VOC那套绝对像素坐标带偏。

用命令直接看目录结构和标签内容:

find . -name "*.txt" -path "*labels*" | head -n 5 head -3 labels/20230625_1012_img_01.txt

find用来确认labels目录下到底有多少个txt、文件命名是否和图片一一对应;head看前几行标签内容,如果一行不是5个数字而是逗号分隔的列表,说明这份数据不是标准YOLO格式,后面训练前要额外处理。

我一般拿到数据第一件事不看图,而是用一段小脚本统计标签总数和类别分布,判断类别是否只有一两类、框数是否严重不均衡:

from pathlib import Path from collections import Counter import glob counts = Counter() total_boxes = 0 for txt in glob.glob("labels/*.txt"): with open(txt, "r") as f: for line in f: line = line.strip() if not line: continue parts = line.split() if len(parts) == 5: cid = parts[0] counts[cid] += 1 total_boxes += 1 print("类别编号分布:", dict(counts)) print("标注框总数:", total_boxes) print("标注了图片数:", len(glob.glob("labels/*.txt")))

这段脚本的作用是把“类别id有没有超范围”这个隐患提前暴露出来。counts统计的是每一行第一列,也就是类别编号;如果你预期只有0和1,却出现了2、3,说明转换时类别映射漏了类或标注本身混入了其他目标。不要带着这种问题进训练,后面损失拉满都查不出来。

2.2 VOC格式的XML标注:filename和bndbox里有什么

再看VOC侧。VOC格式来自Pascal VOC竞赛,标注是XML而不是txt;每个XML对应一张图,根节点下要有filename、size(width/height/depth)以及若干个object。每个object里是name和bndbox,bndbox给出xmin、ymin、xmax、ymax四个绝对像素坐标。注意两个和YOLO关键差异:一是坐标体系,VOC用像素绝对值、且是整数居多,YOLO用归一化浮点,转换时要除以宽高;二是类别表达,VOC的name是字符串,YOLO是数值id,映射表一旦错位,训练出来的结果就是类别张冠李戴。

用一段代码读取VOC标注里的size和bndbox做现场检查:

import xml.etree.ElementTree as ET from pathlib import Path def inspect_voc(xml_path): tree = ET.parse(xml_path) root = tree.getroot() filename = root.findtext("filename") size = root.find("size") print("图像:", filename, "尺寸:", size.findtext("width"), "x", size.findtext("height")) for obj in root.findall("object"): name = obj.findtext("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")) print(f" {name}: {xmin:.0f},{ymin:.0f}->{xmax:.0f},{ymax:.0f}") inspect_voc("Annotations/20230625_1012_img_01.xml")

逻辑说明:这段直接读xml,打印filename与尺寸、每个object名称与像素bbox,目的是确认数据集里“草莓”类别的name到底写成什么。常见的坑是name字段混用,比如“strawberry”“草莓”“ripen_strawberry”同时出现,后面转yolo时映射表漏一个就丢一类,所以这里先摸底。

2.3 同一份图像、两套标注:用脚本快速校验对应关系

有些人拿到zip后发现txt和xml内容对不上:图片A的xml里画了两个框,txt里却有三行;或图像被处理过后只更新了一侧标注。为什么会出现这种情况?因为很多公开数据集的两种格式是不同人、不同批次导出的,甚至图像经过resize后只同步更新了其中一侧。这在“目标检测数据集”下载场景里很常见,不校验直接训练会埋雷。

我一般的做法是随机抽20张图,把xml读出的bbox按公式转成yolo归一化表示,再和现有txt逐行对比,误差在0.01以内的算通过:

import xml.etree.ElementTree as ET def voc_to_yolo_record(xml_path): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") w = float(size.findtext("width")) h = float(size.findtext("height")) records = [] for obj in root.findall("object"): name = obj.findtext("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")) xc = (xmin + xmax) / 2 / w yc = (ymin + ymax) / 2 / h bw = (xmax - xmin) / w bh = (ymax - ymin) / h records.append((name, xc, yc, bw, bh)) return records def yolo_records(txt_path): records = [] with open(txt_path, "r") as f: for line in f: parts = line.split() if len(parts) == 5: records.append((parts[0], float(parts[1]), float(parts[2]), float(parts[3]), float(parts[4]))) return records

逻辑说明:我把函数拆成两个,xml那边得到类别名+归一化框,txt那边得到类别id+归一化框。比对时先看行数是否一致,再看每组坐标数值差的绝对值是否小于0.01。如果差0.05以上,基本能锁定是“某一侧导出时对图像做了resize,另一侧没同步”,这时不要急着删标注,优先确认当前图像的真实尺寸再决定信哪侧。

3. 用YOLO训练前,校验数据集的三个必备步骤

格式看懂后不要直接开训。我的经验是至少做三件事:切分、可视化、尺寸统计。这一步占半小时,却能避免后面两三天训练白跑。

3.1 用固定随机种子做train/val切分并记录切分清单

先做切分,不是把文件名打个乱就完事。固定随机种子是关键:同样代码在不同机器上重跑,划分一致,问题可复现;如果seed每次不同,训练结果差异你分不清是数据划分还是模型收敛带来的。

一个常用脚本是:

import random from pathlib import Path import shutil SRC_IMG_DIR = Path("images") SRC_LAB_DIR = Path("labels") OUT_ROOT = Path("dataset_split") VAL_RATIO = 0.2 SEED = 42 imgs = sorted([p for p in SRC_IMG_DIR.iterdir() if p.suffix in (".jpg", ".jpeg", ".png")]) random.Random(SEED).shuffle(imgs) val_count = int(len(imgs) * VAL_RATIO) val_imgs = imgs[:val_count] train_imgs = imgs[val_count:] for split_name, split_list in [("train", train_imgs), ("val", val_imgs)]: (OUT_ROOT / split_name / "images").mkdir(parents=True, exist_ok=True) (OUT_ROOT / split_name / "labels").mkdir(parents=True, exist_ok=True) for img in split_list: shutil.copy(img, OUT_ROOT / split_name / "images" / img.name) lab = SRC_LAB_DIR / (img.stem + ".txt") if lab.exists(): shutil.copy(lab, OUT_ROOT / split_name / "labels" / lab.name)

逻辑说明:这里用了“复制而非移动”,保留下原始完整包,万一切分失误还有后悔药。VAL_RATIO设置0.2是常规经验值,图像数量多(一千张以上)可以放宽到0.15;数量少(两三百张)则建议保持0.2甚至0.25,否则验证集太小,指标抖动大。我一般还会顺手把train_imgs和val_imgs的文件名写进txt清单,后面做错题分析时能查回原始来源。

3.2 可视化检查每张图的标注是否贴在果实上

切分后画框检查。别小看这一步,公开的“目标检测数据集”里总有一两成标注是歪的:框整框包住了叶子、中心点标在果实边缘、或是把背景里的红色塑料袋也框了进来。画框脚本的核心是把归一化坐标转回像素坐标再在图上画矩形。

import cv2 CLASS_NAMES = ["strawberry", "green_strawberry"] # 以实际类别为准 def draw_yolo_box(img_path, txt_path, class_names): img = cv2.imread(str(img_path)) h, w = img.shape[:2] with open(txt_path, "r") as f: for line in f: parts = line.split() if len(parts) != 5: continue cid = int(parts[0]) xc, yc, bw, bh = map(float, parts[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), (0, 0, 255), 2) cv2.putText(img, class_names[cid], (x1, max(0, y1 - 6)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) return img

逻辑说明:cls id在这里当list index用,如果类别映射和class_names顺序不一致,画出来的标签名是错的,但这反而能逼你提前把类别顺序理顺。画完挑10张有明显漏检、遮挡的图存成jpg,逐个看:框的中心是否都在果实中心,框宽高是否贴着果实边缘。这一步适合纯小白照着做,一小时能看完几百张,比盲训划算得多。

3.3 统计目标尺寸分布,判断小目标问题有多严重

最后做一次尺寸统计。草莓目标检测在田间的主要难点是远处果实只占几十像素,这类小目标在YOLO里的表现经常让你怀疑模型“瞎了”。统计目标尺寸占比,就是用归一化w和h相乘得到每个框占图像面积的比例,看这个比例有多少低于0.01。

import glob import numpy as np area_ratios = [] num_box_per_img = [] for txt in glob.glob("labels/*.txt"): count = 0 with open(txt, "r") as f: for line in f: parts = line.split() if len(parts) == 5: _, _, _, bw, bh = map(float, parts) area_ratios.append(bw * bh) count += 1 num_box_per_img.append(count) area_ratios = np.array(area_ratios) print("平均每张图框数:", np.mean(num_box_per_img).round(2)) print("目标面积占比中位数:", np.median(area_ratios).round(4)) print("面积占比<0.01的框占比:", (area_ratios < 0.01).mean().round(4))

逻辑说明:0.01这个阈值对应640分辨率下大约6.4x6.4像素的框;如果面积占比小于0.01的框超过三成,说明数据里小目标很多。参数上,这时的选择是:要么训练时把输入分辨率从640提到960或1280,要么推理阶段用SAHI切片,直接硬练640大概率漏检。这个统计本身不决定怎么做,但能帮你判断要不要在这份数据集上投入额外算力。

4. 把VOC格式转成YOLO格式:转换脚本与四个边界坑

为什么要转?两个原因:一是很多数据集虽然同时给两种格式,但实际训练时你一般只跑一个框架,Ultralytics生态里YOLO格式最顺;二是以后要换PaddleDetection或mmdetection,随时要把转换方向反过来用。下面脚本把VOC的xml批量转成YOLO的txt,并重点讲四个容易翻车的边界。

4.1 XML解析到TXT的转换脚本与类别映射

直接贴完整脚本:

import xml.etree.ElementTree as ET from pathlib import Path CLASS_MAP = { "strawberry": 0, "green_strawberry": 1, "leaf": 2, # 按数据集实际类别改 } def voc2yolo(xml_path, out_dir): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") if size is None: print("跳过缺size的xml:", xml_path.name) return img_w = float(size.findtext("width")) img_h = float(size.findtext("height")) lines = [] for obj in root.findall("object"): name = obj.findtext("name").strip() if name not in CLASS_MAP: print(f"未知类别 {name} from {xml_path.name}") continue cid = CLASS_MAP[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")) xc = ((xmin + xmax) / 2) / img_w yc = ((ymin + ymax) / 2) / img_h bw = (xmax - xmin) / img_w bh = (ymax - ymin) / img_h xc = min(max(xc, 0.0), 1.0) yc = min(max(yc, 0.0), 1.0) bw = min(max(bw, 0.0), 1.0) bh = min(max(bh, 0.0), 1.0) lines.append(f"{cid} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}") if lines: out_path = Path(out_dir) / (Path(xml_path).stem + ".txt") out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")

逻辑说明:脚本做的事是读size、读每个object,把绝对坐标换成归一化中心点加宽高,写进txt。参数说明里最有风险的是CLASS_MAP:如果数据里的name实际是“strawberry_flower”而映射表没写,那一整类会被直接跳过,转出来txt行数明显变少。前文2.2节统计过name,这里直接照抄,别凭记忆写。

4.2 坑1:类别编号从0开始,不要沿用VOC里“1”的错觉

新手最容易犯的错,是看到VOC的类别列表从0计数就以为机器也这么理解。实际VOC的XML里每个object名称是字符串,没有id概念;转成YOLO时,CLASS_MAP第一项传0。如果把“草莓”写成1,而你的模型类别数为2(成熟草莓、未熟草莓),训练时0这个id空出来,草莓被塞到1,匹配错位。解决方法是转换完随机挑几个txt,读第一列数字确认都在[0, len(classes)-1]区间内。这个错位不会报错,但训练出来混淆矩阵全乱。

4.3 坑2:difficult与occluded标注到底留不留

公开VOC数据里有一种标注字段是difficult或truncated/occluded,表示这个目标很难判别或已被遮挡。转yolo时很多人把这类框也转了。我的经验是:如果你做的是成熟草莓检测,被遮挡超过一半的果实框会教模型“用叶子也能框出草莓”,推理时误检率飙升。参数上,我一般会在转换脚本里判断obj.findtext("difficult")=="1"就跳过,或先统计这种框占比,小于15%则直接滤掉。但要注意:如果整份数据集的标注风格是“只要露出部分都标”,那difficult字段可能不存在,这时靠框宽高是否异常来筛。

4.4 坑3:坐标越界和翻转增强后的边界值处理

VOC的bndbox理论上是像素范围内的,但导出工具偶尔会出现xmax比图宽大几像素、xmin为负数的情况,原因多为标注框刚好贴边或经过了裁剪操作。转yolo时如果不处理,中心点或宽高会大于1,ultralytics训练时有的版本报错、有的版本静默截断;后者其实问题更大,因为静默改掉标签不告诉你。上面脚本里我加了四个clamp就是一种防御。另一个坑是数据增强里的左右翻转:如果在线增强做镜像,YOLO的归一化中心点会被正确转换;但如果你自己做了离线镜像,只镜像了图像、没同步镜像txt,宽高不变但中心点x变成1-xc,模型直接学坏。

5. 数据与训练联调的排查清单:损失异常、指标虚高和过拟合

数据集校验完,下一步是把数据喂给模型训练。这一章是踩坑重灾区,按排查清单顺序讲。

5.1 损失不降或开始就NaN,先查数据链路

现象:模型训练不到20个epoch,train_loss变成nan或者震荡不降。原因八成不在模型,而在数据链路:标签类别id超出模型head的类别数、标签txt里混入了空行、图片文件读取失败返回空图、数据加载线程读到损坏的jpg。排查时不要调学习率,先去查标签上限:

import glob max_cid = -1 bad_txt = [] for txt in glob.glob("labels/*.txt"): with open(txt) as f: for line in f: parts = line.split() if len(parts) == 5: cid = int(parts[0]) if cid > max_cid: max_cid = cid elif line.strip(): bad_txt.append(txt) print("最大类别id:", max_cid) print("非标准行文件:", set(bad_txt))

逻辑说明:如果你的yaml里nc=2,那max_cid不能大于1;如果打印出3,说明voc转换时类别映射漏了一个类,某些类别被错误编到后面。非标准行文件则多半是空txt或注释行残留,ultralytics的加载器能跳过空行,但更早的加载版本会读成nan。解决方法是把bad_txt对应的xml单独打开,看它是否真的没有object;没有目标框的图像可以作为负样本图像放进训练集,但不要把这种无框图单独设成验证集,因为验证集里没有正样本的那张图不会计入mAP。

5.2 验证mAP虚高:先怀疑训练集与验证集“同源”

另一个很隐蔽的现象:训练集loss正常下降,验证集mAP到了0.95,但你把模型放到真实大棚视频里一测,大量漏检。这种情况大概率不是模型的问题,是data split出了问题:同源帧污染。很多草莓数据集采集方式是连续拍照或视频抽帧,同一串草莓在多张相邻画面里出现;如果你用随机shuffle划分train/val,训练集和验证集可能各含同一株草莓的不同时刻画面,这等于考试时把答案带进了考场。解决办法:切分前先按图像文件名里的时间戳或采集批次做group划分;更简单的办法是用图像哈希去掉重复帧:

import hashlib from pathlib import Path def quick_hash(img_path, sample_bytes=65536): with open(img_path, "rb") as f: return hashlib.md5(f.read(sample_bytes)).hexdigest() seen = {} for img_path in sorted(Path("images").glob("*.jpg")): h = quick_hash(img_path) if h in seen: print(f"疑似重复: {img_path.name} 与 {seen[h].name}") else: seen[h] = img_path

这个只读前64KB字节做近似哈希,速度比全文件md5快很多。出现大批重复时,我一般每组只保留一张,然后再做随机切分。这招对草莓这类连续拍摄的数据集尤其管用。

5.3 草莓果实的增强参数:别把颜色增强开到伤及红色

训练命令里的增强参数是按普通目标检测调的,但草莓检测很吃颜色。成熟草莓能被检出,红色是核心特征;大棚里背光、阴影、反光都会让红色发生变化,模型稍弱就容易漏检。用ultralytics的yolov8训练自己的数据集时,默认的hsv_h、hsv_s、hsv_v增强如果保持默认,你很可能把部分红果增强成了接近叶子颜色的暗红,反而削弱了区分度。我的一般做法是:hsv_h从0.015降到0.005,hsv_s从默认0.7降到0.2~0.3,hsv_v从0.4降到0.3;mosaic、scale、fliplr可以保留,但flipud在草莓场景要谨慎,因为大棚里的果实不会长在天上。这些参数是我在实际大棚数据上调过的经验值,不同数据集要再观察验证集表现微调,但方向基本一致。

6. 训练后反推数据价值:一组15分钟能跑完的快速验证

模型训练完,别只看验证集数字。我习惯做一套15分钟的快速验证,用来判断这份数据集值不值得继续投入。

6.1 用预测结果分类错漏检,定位数据缺口

跑一遍验证集预测,把结果按“漏检、误检、定位偏差”三类人工抽看。漏检里再分:远距离小果漏、被叶子遮挡漏、背光暗红漏、未成熟青果漏。统计出哪个原因占比最高,数据缺口就清楚了。比如背光漏检最多,说明训练数据里缺少暗光样本,解决办法是采集傍晚或遮荫照片补充;比如青果总是和叶子混淆,说明标注时青果的边界框得太松,把叶子兜进去了。

6.2 看类别混淆矩阵而非只看mAP

mAP是均值,掩盖了类别间差异。我一般看YOLO训练时保存的confusion matrix,重点盯两行:成熟草莓是否被认成未熟草莓,未熟草莓是否被认成叶子。如果混淆矩阵里这两个格子发亮,不是模型傻,是标注标准有问题。这时候回头改标注,比调模型参数有效得多。

6.3 每个类别单独定置信度阈值

最后一个技巧是别用统一的conf_thres。草莓成熟检测对召回要求高,置信度阈值可以降到0.2;误检敏感的场合把阈值提到0.4。用验证集上的PR曲线找每个类别的拐点,比拍脑袋可靠。这一步花10分钟,效果立竿见影。

我之前在一批草莓数据上跑出的教训是:第一次训练mAP看着还行,放到真实大棚就漏检,最后排查发现是切分时同一株草莓的连续帧同时进了训练和验证集,指标虚高害人。后来我每次拿到新数据集,先做同源去重,再做随机切分,这个习惯再没让我翻过车。希望帮到你。

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

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

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

立即咨询