ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

草莓目标检测数据集YOLO/VOC格式转换与训练避坑实战

草莓目标检测数据集YOLO/VOC格式转换与训练避坑实战 简介面向农业与计算机视觉开发者这份草莓目标检测数据集覆盖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的txtAnnotations/ 放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.txtfind用来确认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、sizewidth/height/depth以及若干个object。每个object里是name和bndboxbndbox给出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(parentsTrue, exist_okTrue) (OUT_ROOT / split_name / labels).mkdir(parentsTrue, exist_okTrue) 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, encodingutf-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 坑2difficult与occluded标注到底留不留公开VOC数据里有一种标注字段是difficult或truncated/occluded表示这个目标很难判别或已被遮挡。转yolo时很多人把这类框也转了。我的经验是如果你做的是成熟草莓检测被遮挡超过一半的果实框会教模型“用叶子也能框出草莓”推理时误检率飙升。参数上我一般会在转换脚本里判断obj.findtext(difficult)1就跳过或先统计这种框占比小于15%则直接滤掉。但要注意如果整份数据集的标注风格是“只要露出部分都标”那difficult字段可能不存在这时靠框宽高是否异常来筛。4.4 坑3坐标越界和翻转增强后的边界值处理VOC的bndbox理论上是像素范围内的但导出工具偶尔会出现xmax比图宽大几像素、xmin为负数的情况原因多为标注框刚好贴边或经过了裁剪操作。转yolo时如果不处理中心点或宽高会大于1ultralytics训练时有的版本报错、有的版本静默截断后者其实问题更大因为静默改掉标签不告诉你。上面脚本里我加了四个clamp就是一种防御。另一个坑是数据增强里的左右翻转如果在线增强做镜像YOLO的归一化中心点会被正确转换但如果你自己做了离线镜像只镜像了图像、没同步镜像txt宽高不变但中心点x变成1-xc模型直接学坏。5. 数据与训练联调的排查清单损失异常、指标虚高和过拟合数据集校验完下一步是把数据喂给模型训练。这一章是踩坑重灾区按排查清单顺序讲。5.1 损失不降或开始就NaN先查数据链路现象模型训练不到20个epochtrain_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里nc2那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_bytes65536): 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.005hsv_s从默认0.7降到0.2~0.3hsv_v从0.4降到0.3mosaic、scale、fliplr可以保留但flipud在草莓场景要谨慎因为大棚里的果实不会长在天上。这些参数是我在实际大棚数据上调过的经验值不同数据集要再观察验证集表现微调但方向基本一致。6. 训练后反推数据价值一组15分钟能跑完的快速验证模型训练完别只看验证集数字。我习惯做一套15分钟的快速验证用来判断这份数据集值不值得继续投入。6.1 用预测结果分类错漏检定位数据缺口跑一遍验证集预测把结果按“漏检、误检、定位偏差”三类人工抽看。漏检里再分远距离小果漏、被叶子遮挡漏、背光暗红漏、未成熟青果漏。统计出哪个原因占比最高数据缺口就清楚了。比如背光漏检最多说明训练数据里缺少暗光样本解决办法是采集傍晚或遮荫照片补充比如青果总是和叶子混淆说明标注时青果的边界框得太松把叶子兜进去了。6.2 看类别混淆矩阵而非只看mAPmAP是均值掩盖了类别间差异。我一般看YOLO训练时保存的confusion matrix重点盯两行成熟草莓是否被认成未熟草莓未熟草莓是否被认成叶子。如果混淆矩阵里这两个格子发亮不是模型傻是标注标准有问题。这时候回头改标注比调模型参数有效得多。6.3 每个类别单独定置信度阈值最后一个技巧是别用统一的conf_thres。草莓成熟检测对召回要求高置信度阈值可以降到0.2误检敏感的场合把阈值提到0.4。用验证集上的PR曲线找每个类别的拐点比拍脑袋可靠。这一步花10分钟效果立竿见影。我之前在一批草莓数据上跑出的教训是第一次训练mAP看着还行放到真实大棚就漏检最后排查发现是切分时同一株草莓的连续帧同时进了训练和验证集指标虚高害人。后来我每次拿到新数据集先做同源去重再做随机切分这个习惯再没让我翻过车。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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