拓冰建站拓冰建站
首页 / 资讯中心 / 正文

小番茄检测实战:VOC标注转YOLO格式与训练全流程

简介YOLO小番茄目标检测数据集面向计算机视觉学习者、农业智能化开发者及科研人员聚焦小番茄果实识别这一具体场景解决成熟度判别与自动采摘中的目标定位难题。压缩包内含1790个文件由895张不同角度、光照条件下拍摄的PNG图片和895个同名XML标签组成每个XML均包含边界框坐标与类别信息可直接配套YOLO系列模型进行训练与验证也兼容SSD、Faster R-CNN等主流检测框架。数据按图片与标签一一对应整理便于按需要划分训练集与测试集配合旋转、翻转、缩放等数据增强方式可进一步提升模型在复杂环境下的泛化能力。目前已有85人学习下载适合正在入门目标检测的读者作为标准格式练习数据也适合研究者借助迁移学习或算法调优持续优化小番茄检测精度为农业自动化与智能采摘提供扎实的数据基础。1. 小番茄检测为什么不能拿通用数据集硬扛这份小番茄目标检测数据集图片配xml格式标签走的是经典的Pascal VOC组织方式。它要解决一个很具体的场景问题温室里的小番茄目标小、成串生长、枝叶遮挡严重拿COCO上预训练的YOLO权重直接去测漏检和误检会让你开始怀疑是不是参数没调对。数据集的真正价值是把「小目标密集分布同类遮挡」这个组合单独拎出来让检测模型从零训练或微调时有一份干净、可校验的标注可用。它适合三类人做农业视觉落地的工程师、研究小目标检测方向的学生、想用一份现成数据完整跑一遍YOLO训练流程的从业者。这篇笔记从xml解析讲到转换脚本、训练参数和常见坑目标是让你拿到.rar解压后一个晚上能跑通训练。2. 读懂xml标签VOC格式的目录结构与解析方法2.1 解压后的第一件事先看目录树和xml长什么样别急着训练。先解压用tree命令把目录结构列出来确认图片和标注的对应关系。常见做法是.rar里包含images和annotations两个目录图片是.jpg标注是同名.xml一张图对应一个xml文件。先确认文件名是否一一对应有没有多余的标注文件或孤儿图片这决定了后面转换脚本要不要加存在性判断。tree -L 2 .输出大概是这样的结构. ├── images │ ├── 00001.jpg │ └── 00002.jpg └── annotations ├── 00001.xml └── 00002.xml如果发现annotations里有的xml在images里没有对应图片后面写转换脚本时就要跳过反过来图片多、标注少则要考虑是不是标注还没做完。这种目录结构检查花不了两分钟但能省掉后面训练时一半的报错排查时间。接着随便打开一个xml看内容。用浏览器直接拖进去也行VSCode也行关键是别用系统记事本打开——xml里所有标签会挤成一行根本没法看结构。浏览器会把内容自动排版成树状够用。annotation folderimages/folder filename00001.jpg/filename size width1920/width height1080/height depth3/depth /size object nametomato/name poseUnspecified/pose truncated0/truncated difficult0/difficult bndbox xmin315/xmin ymin240/ymin xmax365/xmax ymax285/ymax /bndbox /object /annotation这个xml里最核心的是两组信息size里的宽高以及每个object下的name和bndbox。bndbox里的xmin/xmax/ymin/ymax是目标框左上角和右下角的像素坐标坐标系原点在图片左上角x向右、y向下。这些坐标是之后转YOLO格式的唯一数据来源任何一步错了训练出来的模型都是在垃圾标注上拟合。2.2 用Python批量读出全部标注框xml解析最小脚本解析xml不需要引入大型库Python标准库xml.etree.ElementTree就够用。用find和findtext逐层取值比一次性把xml转成字典再取字段更直观也更容易在字段缺失时快速定位问题。import xml.etree.ElementTree as ET tree ET.parse(annotations/00001.xml) root tree.getroot() # 图片尺寸 size root.find(size) w int(size.findtext(width)) h int(size.findtext(height)) print(f图片尺寸: {w} x {h}) # 遍历所有目标框 for i, obj in enumerate(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框{i}: 类别{name}, 坐标({xmin}, {ymin}) - ({xmax}, {ymax}))findtext和find(name).text的区别在于字段不存在时findtext返回None而find(name).text会直接抛AttributeError。批量处理几百个xml时一个缺失字段就会让整个脚本中断所以能用findtext就别用find().text。跑通这段脚本后你应该能看到每个xml里的目标框都被读出来了。这时候做一件事统计一下所有xml里的类别名看是不是只有tomato一种。grep -h name annotations/*.xml | sort | uniq -c如果看到tomato、Tomato、tomatos混在一起说明标注工具的自動補全不一致。别小看这个问题类别名不统一是转换阶段最常见的翻车原因之一后面章节会专门讲怎么处理。2.3 编辑xml的工具选型与编码坑读xml用浏览器或编辑器都能干但改xml就得多留个心眼。如果标注框本身有错需要微调我不建议直接手改xml——坐标一多容易眼花。用标注工具打开原图、拖一下框、重新保存比自己改数字靠谱得多。纯看结构的话VSCode装一个XML扩展就能格式化和高亮。Chrome或Firefox直接打开本地xml文件是最省事的方案树状折叠、点击展开视觉上比编辑器还友好。编码是xml最容易踩的隐性坑。VOC标注文件绝大多数是UTF-8但Windows上解压、再用记事本另存过可能被改成带BOM的UTF-8或GBK。带BOM的xml在ET.parse时通常能忍但GBK编码里的中文字段会被解析成乱码。import xml.etree.ElementTree as ET try: tree ET.parse(annotations/00001.xml) except ET.ParseError: print(解析失败尝试指定编码重新读) content open(annotations/00001.xml, encodinggbk).read() root ET.fromstring(content)实际上更稳妥的做法是写一个目录级的编码检查读前几个字节判断有没有UTF-8 BOM再逐个文件尝试解析解析失败的单独拉出来看。这种一次性检查脚本花十分钟写好后面转换阶段会省掉大量排查时间。3. 把xml转成YOLO能吃的txt转换脚本与归一化细节3.1 为什么YOLO训练要txt而不是xmlYOLO系列训练时读的标注是.txt文件不是xml。原因不是YOLO不认VOC格式而是txt标注的存储方式对训练框架更友好每行一个目标格式是类别id 中心点x 中心点y 宽度w 高度h五个数字全部归一化到0到1之间。归一化坐标的好处是训练时不管输入分辨率怎么变标注都不用跟着改。xml里的坐标是像素值、绝对坐标而且一张图有多个目标就是多个object节点训练框架每次读取都要解析xml树、遍历节点、再换算坐标。txt则是一行一个框、纯文本读取解析成本和内存占用都小得多。另外YOLO的数据加载器对txt结构有硬性约定第一列是整数类别id后面四列是浮点坐标顺序错了模型根本训不起来。转换的核心就是把xml里的像素坐标(xmin, ymin, xmax, ymax)换算成归一化的(cx, cy, w, h)cx (xmin xmax) / 2 / 图片宽度 cy (ymin ymax) / 2 / 图片高度 w (xmax - xmin) / 图片宽度 h (ymax - ymin) / 图片高度注意这里全部除以的是图片宽或高而不是同一个值。x相关的坐标除以宽y相关的坐标除以高混用的话目标框会被压扁或拉长。3.2 转换脚本全流程批量处理与越界过滤写脚本直接跑目录一次性把xml全转成txt。下面的脚本是一份能直接沿用的模板包含目录创建、批量处理、越界修正、过小框过滤四部分。import xml.etree.ElementTree as ET from pathlib import Path XML_DIR Path(annotations) # 存放xml的目录 IMG_DIR Path(images) # 存放jpg的目录 LABEL_DIR Path(labels) # 转换后txt的输出目录 LABEL_DIR.mkdir(exist_okTrue) CLASS_MAP { tomato: 0, # 统一的类别名 ripe_tomato: 0, # 如果标注分成熟/未熟可合并为同一类 green_tomato: 0, } def convert_one(xml_file: Path) - None: tree ET.parse(xml_file) root tree.getroot() img_name root.findtext(filename) img_path IMG_DIR / img_name if not img_path.exists(): print(f[跳过] 图片不存在: {img_path}) return size root.find(size) w float(size.findtext(width)) h float(size.findtext(height)) lines [] for obj in root.findall(object): name obj.findtext(name).strip().lower() if name not in CLASS_MAP: print(f[跳过] 未映射类别: {name} in {xml_file.name}) continue box obj.find(bndbox) xmin float(box.findtext(xmin)) ymin float(box.findtext(ymin)) xmax float(box.findtext(xmax)) ymax float(box.findtext(ymax)) # 越界修正把坐标clip到图片内 xmin max(0, min(xmin, w - 1)) ymin max(0, min(ymin, h - 1)) xmax max(0, min(xmax, w - 1)) ymax max(0, min(ymax, h - 1)) # 过小框过滤宽或高小于2像素的框没意义 if xmax - xmin 2 or ymax - ymin 2: print(f[过滤] 过小框: {xml_file.name} {name}) continue # 归一化坐标 cx (xmin xmax) / 2 / w cy (ymin ymax) / 2 / h bw (xmax - xmin) / w bh (ymax - ymin) / h lines.append(f{CLASS_MAP[name]} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) if lines: txt_path LABEL_DIR / (xml_file.stem .txt) txt_path.write_text(\n.join(lines), encodingutf-8) for xml_file in sorted(XML_DIR.glob(*.xml)): convert_one(xml_file) print(f转换完成txt输出目录: {LABEL_DIR.resolve()})这段脚本的逻辑分四层先找图片确认xml和图片能对上再读宽高做归一化的分母然后逐目标处理类别映射、越界修正、过小过滤最后把生成的行写入txt。.strip().lower()是在做类别名归一化Tomato、tomato这类变体全被统一成tomato这个细节能避免一大堆因为空格或大小写导致的类别错乱。参数调整上CLASS_MAP是你唯一必须按数据集实际情况改的地方。如果xml里本来就是tomato一种只留一行映射即可。2像素的最小框阈值针对小番茄场景够用如果你这份数据集中有大量极近景大果图可以调到5把贴图边缘的半个框也滤掉。转换完成后检查一下输出目录里的txt数量和xml数量是否一致。允许少——有的xml可能所有框都被过滤掉了但不允许多——txt比xml多说明中间有脏逻辑。3.3 类别映射表与data.yaml一个字母都不能错转换脚本里的CLASS_MAP决定txt里第一列的整数data.yaml里的names决定这个整数对应什么类别名。两边不一致是训练阶段最常见的低级错误txt里写了0yaml里names的0号却是green_tomato模型训练时损失函数倒是能正常算但推理结果和可视化标签会错位。一份标准的小番茄data.yaml长这样# data.yaml path: /home/yourname/small_tomato # 数据集根目录建议写绝对路径 train: images/train val: images/val names: 0: tomato注意path如果写相对路径YOLO会以当前工作目录去拼训练时工作目录一换就找不到数据集直接报AssertionError: Dataset not found。我一般直接写绝对路径一劳永逸。names的索引必须从0开始连续递增不能跳号不能只有一项时写成names: [tomato]这种列表形式——Ultralytics的版本里列表也能解析但和CLASS_MAP逐一对应时容易数错下标。3.4 转换后的自检标注回显图必须看一遍转换脚本跑完txt里的数字看起来都挺正常但数字正常不代表坐标对。唯一可靠的验证方式是画回显图读原图把txt里的归一化坐标换算回像素坐标画框保存然后肉眼抽查几十张。import cv2 from pathlib import Path IMG_DIR Path(images) LABEL_DIR Path(labels) CHECK_DIR Path(check) CHECK_DIR.mkdir(exist_okTrue) for txt_path in list(LABEL_DIR.glob(*.txt))[:50]: img_path IMG_DIR / (txt_path.stem .jpg) img cv2.imread(str(img_path)) if img is None: continue h, w img.shape[:2] for line in txt_path.read_text().strip().splitlines(): parts line.split() cls_id, 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) color (0, 0, 255) if cls_id 0 else (0, 255, 0) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) cv2.putText(img, fcls{int(cls_id)}, (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) cv2.imwrite(str(CHECK_DIR / (txt_path.stem .jpg)), img) print(f回显图已保存到 {CHECK_DIR.resolve()})这里有个容易忽略的细节回显时cv2.imread读出来的高宽顺序是(h, w)换算像素坐标时分子分母别搞反。抽检时重点看两类图一张图里目标超过20个的密集簇以及目标贴着图片边缘的样本。密集簇检查框之间是否明显重叠偏移边缘样本检查框有没有被clip截断。回显这十分钟花得值——训练三小时后发现标注是歪的那才是真的血泪教训。4. 小番茄数据集落地的常见问题与排查4.1 坐标越界和过小框训练震荡的第一嫌疑现象训练时loss曲线上下剧烈震荡前几十个epoch完全没有下降趋势打开回显图发现有的框画到了图片外面有的框只有几个像素大。原因xml里存在坐标大于图片宽高或小于0的标注框这是标注工具手滑或标注时图片被裁剪过导致的。更隐蔽的情况是图片的EXIF旋转信息没有生效标注软件看到的图片方向和转换脚本读图的方向差了90度坐标自然全偏。过小框则是标注时框只框住了一个像素级的番茄尖这种框对训练只有噪声贡献。解决转换脚本里必须做两层防御。第一层是clip把坐标硬拉到图片边界内第二层是面积过滤宽或高小于2像素的框直接丢弃。clip保证不越界过滤保证不引入噪声。xmin max(0, min(xmin, w - 1)) ymin max(0, min(ymin, h - 1)) xmax max(0, min(xmax, w - 1)) ymax max(0, min(ymax, h - 1)) if xmax - xmin 2 or ymax - ymin 2: continue注意clip和过滤的顺序不能反。先clip再计算宽高否则原本越界的框在过滤阶段可能因为负数差值被误删。4.2 图片解码与EXIF旋转读图翻车的两类情况现象训练时报通道数错误或验证时发现一半图片的检测框都偏转了90度。原因直接拍的小番茄照片可能是手机或相机直出这类图片有两个问题。一是通道顺序或通道数不标准有的jpg实际是RGBA四通道OpenCV的imread默认按BGR三通道读四通道图会被截断成奇怪的色块二是EXIF里带着旋转方向标签标注工具按旋转后的方向标框而训练框架读图时不应用EXIF旋转导致框和图像内容错位。解决数据处理阶段先统一转一遍图片。用Pillow读图应用EXIF旋转统一转成RGB三通道再另存为干净的标准jpg。from PIL import Image, ImageOps from pathlib import Path SRC_DIR Path(images_raw) DST_DIR Path(images) DST_DIR.mkdir(exist_okTrue) for img_file in SRC_DIR.iterdir(): im Image.open(img_file) im ImageOps.exif_transpose(im) # 应用EXIF旋转 im im.convert(RGB) # 统一三通道 im.save(DST_DIR / (img_file.stem .jpg), quality95) print(f已处理: {img_file.name})这步做完务必重新核对xml里的width和height与转出的图片是否一致。EXIF旋转生效后横图可能变竖图xml里的size字段要跟着更新否则坐标换算会整体偏移。4.3 小目标训不出来的参数坑输入分辨率与增强现象训练能收敛验证集mAP0.5看着还行但实际检测时漏掉大量远处的小番茄mAP0.5:0.95明显偏低。原因小番茄在1920x1080的原始图里可能只有20x30像素喂进默认imgsz640的网络后目标被压缩到不到10个像素特征图上的响应几乎消失。这不是模型问题是输入分辨率配不上目标尺寸。Ultralytics YOLOv8没有anchor机制靠特征图网格密度感知目标大小输入分辨率直接决定小目标的特征强度。解决把imgsz提高到768或896同时开启mosaic和copy_paste增强。copy_paste会把一张图里的小目标复制粘贴到另一张图的随机位置对小目标密集场景非常有效因为它制造了大量目标样本且不改变原始目标形状。# 明显提升了小目标召回的一组参数 yolo detect train datadata.yaml modelyolov8s.pt \ imgsz768 epochs150 batch16 \ mosaic1.0 copy_paste0.3 close_mosaic10close_mosaic10的含义是最后10个epoch关闭mosaic增强让模型在接近真实分布的图片上收敛。不要整个训练过程都开着mosaic——最后阶段还混着四张图的拼接模型的BN统计和框回归会被带偏。4.4 划分不固定导致的复现性问题现象同样的数据、同样的参数两次训练出来的mAP能差两三个点。原因数据集划分没有固定。每次跑训练脚本都重新随机划分train/val两次划分的分布不一样结果自然不一样。更隐蔽的是PyTorch和CUDA的随机性但那份影响远小于数据划分变动。解决先划分数据集再训练。划分时固定随机种子或者按文件名哈希划到train/val保证每次跑训练用的都是同一份划分。python -c import random from pathlib import Path random.seed(42) files sorted(Path(images).glob(*.jpg)) random.shuffle(files) val_count int(len(files) * 0.15) from pathlib import Path Path(images/train).mkdir(parentsTrue, exist_okTrue) Path(images/val).mkdir(parentsTrue, exist_okTrue) Path(labels/train).mkdir(parentsTrue, exist_okTrue) Path(labels/val).mkdir(parentsTrue, exist_okTrue) for f in files[:val_count]: f.rename(Path(images/val) / f.name) (Path(labels) / (f.stem .txt)).rename(Path(labels/val) / (f.stem .txt)) for f in files[val_count:]: f.rename(Path(images/train) / f.name) (Path(labels) / (f.stem .txt)).rename(Path(labels/train) / (f.stem .txt)) 划分后检查一下images/train和labels/train的文件数是否一致避免因为孤儿txt或孤儿图片导致训练时数据加载报错。5. 用YOLOv8把小番茄数据集训起来参数与loss怎么看5.1 先定baselineimgsz、模型大小和batch怎么选小番茄数据集的目标特性是「小目标、密集、遮挡多」这直接决定baseline的参数选型。模型大小从yolov8n到yolov8m都可以跑但要明确模型越大小目标特征提取能力越强同时显存占用和推理耗时线性上升。小番茄场景推荐从yolov8s起步。n在小目标上会明显吃力——它的C2f模块通道数太少语义信息不足以支撑密集场景的区分m对小番茄这种单类场景属于性能溢出训练周期拉长收益有限。先用s跑通再按验证集结果决定是否升级。输入分辨率imgsz是小番茄场景最敏感的参数。默认640在小目标上漏检严重建议768起步。显存不够时优先降batch而不是降imgsz8G显存跑76816的batch通常没问题再不够就把batch砍到8。5.2 训练命令与数据增强参数速查基于前面转好的目录结构和data.yaml训练命令如下yolo detect train \ datadata.yaml \ modelyolov8s.pt \ imgsz768 \ epochs150 \ batch16 \ patience30 \ projectruns \ nametomato_v8s \ copy_paste0.3 \ close_mosaic10modelyolov8s.pt是预训练权重Ultralytics会尝试从官方地址自动下载。如果你的环境网络不通需要手动下载后放到当前目录否则训练脚本会卡在下载阶段。放好权重后脚本能识别到本地文件直接加载预训练参数做迁移学习训练收敛速度比从零训练快一个量级。数据增强参数里copy_paste0.3和close_mosaic10是小番茄场景最值得调的两个参数建议值作用与理由imgsz768小目标需要更高输入分辨率640下远处番茄几乎不可见modelyolov8s.pt单类密集场景的性价比选择n太小m浪费batch168G显存的安全值显存更大可加到32epochs150单类检测任务一般100-200收敛150是稳妥区间patience30验证指标连续30轮不提升就早停省时间copy_paste0.3小目标复制增强直接提升密集场景召回close_mosaic10最后10轮关闭mosaic避免拼接图干扰收敛flipud0.0小番茄生长方向有统计意义不做上下翻转flipud0.0是一个容易被忽略的细节。番茄是垂挂生长的上下翻转后图像语义仍然合理但模型会学到「番茄既可以朝上也可以朝下生长」这种错误先验实际部署时对姿态判断产生干扰。单类检测任务里省掉上下翻转的收益往往比多一个数据增强更大。5.3 训练时盯着三条曲线box_loss、cls_loss、dfl_lossYOLOv8的损失函数由三个分支组成box_loss负责预测框和真实框的IoU差异cls_loss负责分类置信度dfl_loss负责框边界的分布拟合。训练日志里会同时输出train和val两组重点盯着val的这三条曲线。box_loss在前20个epoch快速下降之后缓慢趋平属于正常如果一直震荡不下降基本可以确定标注里有脏数据回头查坐标越界或类别错乱。cls_loss单类任务会下降得很快它反应的是模型对「这是不是番茄」的把握一般来说50个epoch后就不再明显下降。dfl_loss和box_loss走势相近但它更敏感——框边界参差不齐时dfl_loss会持续偏高且不平滑。训练结束后用验证集跑一轮预测输出PR曲线和混淆矩阵yolo detect val \ modelruns/tomato_v8s/weights/best.pt \ datadata.yaml \ imgsz768单类检测的混淆矩阵没有多类那么丰富但PR曲线里recall的值很关键。小番茄密集场景下mAP0.5:0.95反映的是框的精细度而recall反映的是有没有漏检。如果recall低于0.7优先怀疑输入分辨率不够或copy_paste没开如果recall高但mAP0.5:0.95低说明框的位置精度差此时再考虑换更大的模型或提高imgsz。6. 训练后的验证回显检查、漏检分析和二次标注训练收尾不等于项目收尾。best.pt拿到手第一件事不是直接部署而是用真实场景图做一轮压力测试。yolo predict \ modelruns/tomato_v8s/weights/best.pt \ sourcetest_images/ \ imgsz768 \ conf0.25 \ saveTrue跑完后挑最挤的一串番茄图把conf降到0.15再跑一遍。置信度阈值一降原本被压制住的漏检框会全涌出来。对比两次结果如果低阈值下多出来的框大多数是对的说明模型本身学到了特征只是部署时阈值设太高如果多出来的框全是错检说明模型的置信度校准有问题回退到0.25更稳妥。真正的漏检用阈值调不出来。挑几张密集到分不清果粒的图逐颗数一下图里有多少番茄再数数模型框出了多少。这个人工数数的过程很枯燥但它是评估数据集质量最直接的方式——如果人工都数不清标注本身也没有唯一标准此时补再多的训练也白搭。二次标注是提升密集场景精度的最后手段。把验证集里漏检最多的几张图用标注工具打开补上模型没学到的边界案例——被叶子挡住一半的番茄、光线发暗的果实、重叠到几乎只剩边缘的果子。补完标注转成txt增量训练yolo detect train datadata.yaml modelruns/tomato_v8s/weights/best.pt \ imgsz768 epochs50 batch16 \ copy_paste0.3 close_mosaic10从已有权重继续训练而不是重新开始通常几十个epoch就能看到漏检明显减少。我自己的习惯是每次换数据集都先跑一遍完整回显再进训练宁可多花十分钟看图也不愿意训练三小时后回头查标注问题。模型的性能上限从标注完成那一刻就已经定了训练只是把它逼近那个上限而已。希望这些经验能帮你少走几趟弯路。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门