ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

YOLO西红柿成熟度检测:从1267张图像训练到ONNX部署的全流程指南

YOLO西红柿成熟度检测:从1267张图像训练到ONNX部署的全流程指南 简介一份专门用于YOLO算法训练的西红成熟程度数据集包含1267张带标签图像覆盖半熟、绿色、完全成熟三个类别适用于智慧农业、目标检测算法研究及作物成熟度自动识别等场景。压缩包共2000个文件其中1267个xml格式的标签文件对应图像标注733个txt文件提供类别与坐标信息整体包体约303.56MB结构规整方便直接导入训练框架。标签细致区分成熟阶段并记录形状、大小等特征为模型提供丰富的视觉差异有助于提高目标检测的精度与泛化能力。目前已有233人学习使用适合算法研究者、农业AI开发者和入门学员下载可大幅节省图像采集与人工标注成本快速开展模型训练与验证实验。1. 从 1267 张带标签图像开始YOLO 西红柿成熟度检测要解决的不只是“能检测”温室采摘机器人和分拣线上的视觉系统最怕的不是“看不到果”而是“分不清熟没熟”。西红柿从绿色到半熟再到完全成熟是一个连续渐变过程果肩颜色、光照角度、遮挡阴影都会让类别边界变得模糊。这个标题给出的数据集——1267 张带标签图像覆盖半熟、绿色、完全成熟三个状态——正好是训练一个三分类 YOLO 检测模型的起点。规模不大但对农业场景的细粒度识别来说样本量足够完成原型验证和第一版部署。这篇文章按我实际做目标检测项目时的路径来写拿到 zip 之后先做什么体检标签格式如何归一化成 YOLO 能吃的 txt训练参数怎么设不翻车最后怎么导出 ONNX 放在边缘设备上跑推理。中间会给出可以直接复制运行的 Python 脚本和命令行也会说明每个参数为什么要这么调。新手可以照步骤走完一遍老手可以直接跳到第 4 章看小数据集训练的坑以及第 5 章的部署细节。2. 解压和体检用五分钟脚本确认 1267 张图像与标签是否“可训练”2.1 zip 压缩包解压后的目录结构images/ 与 labels/ 的配对关系这类数据集压缩包最常见的组织方式是顶层两个目录images/放全部图像labels/放与图像同名的标注文件。标注文件的格式可能是 YOLO 的 txt、Pascal VOC 的 XML也可能是 CSV。解压前先看一眼压缩包内目录结构不要直接双击解压到桌面命令行更可靠unzip -l yolo算法-西红柿成熟程度数据集-1267张图像带标签-半熟-绿色-完全成熟.zip | head -50unzip -l列出 zip 内的文件清单而不实际解压head -50截取前 50 行足以判断目录层级和文件命名规律。如果文件数量异常少说明可能嵌套了多层目录继续用grep过滤统计unzip -l 数据集.zip | grep -c \.jpg\|\.jpeg\|\.png这个命令统计图像文件总数用于核对是否与标题声称的 1267 张一致。压缩包若是分卷或者曾通过网盘传输可能损坏解压时报错就先用zip -T测试完整性zip -T 数据集.zip-T遍历压缩包内每个文件做 CRC 校验输出OK才说明文件完整。很多训练报错“图片打不开”的根因其实在压缩包阶段就已经埋下这一步值得做。解压时如果遇到中文文件名乱码考虑在 Linux 下用unzip -O gbk指定编码macOS 的ditto也能保留原始文件名。2.2 用 Python 检查图像完整性、标签格式与类别分布解压完成后不要急着开训练。先用一段脚本对数据集做全面体检覆盖四个方面图像能否被解码、图像与标签是否一一配对、标签内容是否合法、类别分布是否均衡。以下脚本直接保存为inspect_dataset.py运行import os from collections import Counter from PIL import Image IMG_EXTS {.jpg, .jpeg, .png, .bmp} def inspect_dataset(images_dir, labels_dir): images [f for f in os.listdir(images_dir) if os.path.splitext(f)[1].lower() in IMG_EXTS] missing_labels, broken_images, class_counter [], [], Counter() label_records 0 for img_name in images: stem os.path.splitext(img_name)[0] label_path os.path.join(labels_dir, stem .txt) if not os.path.exists(label_path): missing_labels.append(img_name) continue # 检查图像能否被 PIL 正常解码 try: with Image.open(os.path.join(images_dir, img_name)) as im: im.load() except Exception: broken_images.append(img_name) continue # 解析 YOLO 格式标签检查坐标范围和类别 ID with open(label_path, r) as f: for line in f: parts line.strip().split() if len(parts) ! 5: continue class_id int(float(parts[0])) _, xc, yc, w, h map(float, parts) if not (0.0 xc 1.0 and 0.0 yc 1.0 and 0.0 w 1.0 and 0.0 h 1.0): print(f非法坐标: {label_path}: {line.strip()}) class_counter[class_id] 1 label_records 1 print(f图像总数: {len(images)}) print(f缺失标签: {len(missing_labels)} - {missing_labels[:5]}) print(f损坏图像: {len(broken_images)} - {broken_images[:5]}) print(f标签实例总数: {label_records}) print(f类别分布: {dict(sorted(class_counter.items()))}) if __name__ __main__: inspect_dataset(images, labels)脚本逻辑分三层先找出没有对应标签文件的图像这类样本训练时会被 YOLO 忽略或直接报错再做图像解码验证排除半截 jpg 这类问题最后逐行解析标签确认类别 ID 是否越界、中心点坐标和宽高是否在归一化范围内。im.load()是关键它强制把图像数据读入内存解码失败会在这里抛出异常。类别分布打印出来后对照标题里的三个类别——假设 ID 0 是 green、1 是 half-ripe、2 是 ripe——就能知道每类有多少实例。实例数相差超过 3 倍就需要在第 4 章做针对性处理比如调整 class weights 或做数据增强。2.3 类别分布表与“少样本类别”的取舍以一个典型的三分类成熟度数据集为例分布可能长这样类别 ID类别名实例数图像数占比0绿色68351238.4%1半熟47638028.5%2完全成熟51040230.1%图像总数是 1267但实例总数通常大于图像数因为一张图里可能同时有多个果实。这里要区分两个概念图像数是独立文件数实例数是所有标注框的总和。YOLO 训练时 loss 按实例计算所以类别不平衡判断依据的是实例数不是图像数。如果某类实例特别少比如半熟只有几十个那么模型会倾向于把半熟预测成绿色或完全成熟——因为半熟本身就处于两个类别的中间态特征区分度低。处理少样本类别我一般按优先级做三件事先看该类别的标注框质量是否框得过大、包含了过多背景然后做针对性的数据增强例如对半熟样本做 HSV 扰动模拟不同光照下的过渡色最后才考虑用class_weight放大少样本类别的 loss 贡献。第一件事最容易被忽略但标注框边界不干净对细粒度分类的影响比增强更大因为模型学到的是“框内整体特征”背景噪声会直接干扰颜色判断。3. 标签格式归一化把任意标注转成 YOLO 的 txt 格式3.1 YOLO 标签的数学定义cx、cy、w、h 的归一化坐标YOLO 系列训练时读取的标签是纯文本格式每行一个目标格式为class_id cx cy w h其中cx和cy是目标边界框中心点的归一化坐标w和h是边界框宽度和高度的归一化数值。归一化是指除以图像自身的宽度和高度所以这四个浮点数都在 0 到 1 之间。举例一张 1920x1080 的图像里一个边界框左上角在 (480, 270)右下角在 (960, 810)那么中心点为 ((480960)/2, (270810)/2) (720, 540)归一化后 cx 720/1920 0.375cy 540/1080 0.5w (960-480)/1920 0.25h (810-270)/1080 0.5最终标签行就是0 0.375 0.5 0.25 0.5。需要特别注意的是这个坐标系不同于 COCO 的像素坐标也不同于 VOC 的 xmin/ymin/xmax/ymax 形式。如果拿到的是 VOC XML 标注或 COCO JSON 标注必须先转换否则训练时 loss 会爆炸或直接报 label 格式错误。转换公式并不复杂但要处理好一个边界情况当边界框宽度或高度为 0 时转换结果会出现w0YOLO 会将其视为无效目标并跳过这类标注通常需要从数据集中剔除。3.2 从 VOC XML、CSV 或已有 txt 转换的统一脚本很多公开数据集给的是 VOC XML或者一张 CSV 表。下面这段脚本可以同时处理 XML 和 CSV 两种来源并输出 YOLO 格式的 txt 文件。如果拿到的已经是 txt它也能帮你做一次合法性校验并统计类别 IDimport os import csv import xml.etree.ElementTree as ET CLASS_NAMES [green, half-ripe, ripe] # 按实际数据集的类别顺序调整 CLASS_TO_ID {name: i for i, name in enumerate(CLASS_NAMES)} def convert_xml(xml_path, out_dir, img_width, img_height): tree ET.parse(xml_path) root tree.getroot() objects root.findall(object) lines [] for obj in objects: name obj.find(name).text.strip() if name not in CLASS_TO_ID: continue bndbox obj.find(bndbox) xmin float(bndbox.find(xmin).text) ymin float(bndbox.find(ymin).text) xmax float(bndbox.find(xmax).text) ymax float(bndbox.find(ymax).text) cx (xmin xmax) / 2.0 / img_width cy (ymin ymax) / 2.0 / img_height w (xmax - xmin) / img_width h (ymax - ymin) / img_height if w 0 or h 0 or cx 0 or cy 0 or cx 1 or cy 1: continue lines.append(f{CLASS_TO_ID[name]} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}) return lines脚本核心逻辑在坐标换算那四行先求像素中心点再分别除以图像宽高做归一化。CLASS_NAMES列表的顺序必须与训练时的data.yaml里names的顺序完全一致这是整个流程中最容易出错的环节。比如 CSV 里写的类别名可能是中文“半熟”而这里写的是英文 “half-ripe”映射不到就会直接跳过该目标。CSV 形式的标注一般每行是一条记录包含image_name, class_name, xmin, ymin, xmax, ymax字段转换时先按图像名聚合所有行再逐行套用同样的坐标公式即可。转换完成后用回第 2.2 节的检查脚本重新过一遍数据重点看标签文件数量是否与图像一一对应。3.3 数据集划分train/val 按 8:2 分层抽样标签转换完成后下一步是把 1267 张图像切成训练集和验证集。我坚持用分层抽样而不是纯随机划分原因很简单成熟度数据集的图像通常来自不同生长阶段或不同光照时段纯随机划分可能让某一类在训练集里的占比与整体分布偏差很大。import os import random from collections import defaultdict from shutil import copy2 random.seed(42) def split_dataset(images_dir, labels_dir, train_ratio0.8): images [f for f in os.listdir(images_dir) if f.endswith(.jpg)] # 按图像对应的标签类别做分层 class_to_images defaultdict(list) for img in images: stem os.path.splitext(img)[0] label_path os.path.join(labels_dir, stem .txt) with open(label_path) as f: first_cls int(f.readline().split()[0]) class_to_images[first_cls].append(img) train_imgs, val_imgs [], [] for cls_imgs in class_to_images.values(): random.shuffle(cls_imgs) split_idx int(len(cls_imgs) * train_ratio) train_imgs.extend(cls_imgs[:split_idx]) val_imgs.extend(cls_imgs[split_idx:]) os.makedirs(images/train, exist_okTrue) os.makedirs(images/val, exist_okTrue) os.makedirs(labels/train, exist_okTrue) os.makedirs(labels/val, exist_okTrue) for img in train_imgs: stem os.path.splitext(img)[0] copy2(os.path.join(images_dir, img), images/train/) copy2(os.path.join(labels_dir, stem .txt), labels/train/) # val 部分同理分层抽样的逻辑是先把图像按“该图第一个目标的类别”分组在每组内部按比例抽样。这种方式比全局随机更能保证绿色、半熟、完全成熟三个类别的图像在训练集和验证集中都出现。代码里random.seed(42)固定随机种子确保每次划分结果一致方便复现实验对比。只取第一个目标的类别做分层依据是一种折中方案。如果每张图的多个目标跨越多个类别更严格的做法是统计每个类别的实例占比然后按多标签分层抽样但在这个数据集规模下首类别分层已经够用。划分完成后打印两边各自的类别分布偏差超过 5% 就考虑加大train_ratio或换随机种子。4. 训练配置用 YOLOv8 训练自己的西红柿成熟度数据集4.1 data.yaml 与模型选型n/s/m 怎么选YOLOv8 是目前训练自定义数据集最顺手的版本不管是命令行接口还是 Python API 都比 v5 简洁。训练前先写data.yaml这是数据集的“配置文件”告诉模型去哪里找图、有几类、类别叫什么path: /path/to/dataset train: images/train val: images/val names: 0: green 1: half-ripe 2: ripepath是数据集根目录的绝对路径train和val是相对path的路径。这里有个细节names的0: green这种写法是 YOLOv8 的格式旧版 YOLOv5 需要写成{0: green}的字典形式两者混用会导致训练直接报错。模型尺寸的选择我一般按部署目标反推。模型文件大小和推理速度的关系如下模型参数数量权重大小推理速度T4 GPU适合场景yolov8n3.2M6MB约 1ms边缘设备、实时视频流yolov8s11.2M22MB约 1.5ms分拣线实时检测yolov8m25.9M50MB约 3ms离线分析、精度优先只有 1267 张图像的数据集我不建议一上来就选 yolov8m。数据量越小大模型越容易过拟合训练集上 mAP 很高、验证集上一塌糊涂。先从yolov8n跑通流程得到一个可用的基线再尝试yolov8s看精度提升是否值得多出来的推理耗时这是最稳妥的路线。4.2 训练命令与关键参数imgsz、epochs、batch、早停训练命令只需要一行但参数值的选取有讲究yolo detect train \ datadata.yaml \ modelyolov8n.pt \ epochs150 \ imgsz640 \ batch16 \ patience30 \ projectruns \ nametomato_ripe核心参数的作用可以拆开看。imgsz640是输入分辨率YOLOv8 默认就是 640如果原始图像边长差异很大可以改成 512 或 416 来加速训练但如果果实较小、一帧里有很多个小西红柿保持 640 能减少小目标漏检代价是显存占用更高。batch16受限于显存16GB 显存跑yolov8n可以开到 32但 batch 过大在小数据集上会加速过拟合16 是一个相对安全的起点。patience30是早停参数意思是连续 30 个 epoch 验证集 mAP 没有提升就自动停止训练。这是小数据集上最值得依赖的机制——150 个 epoch 在可视化 loss 曲线平稳后往往提前到 60-80 个 epoch 就停了你不需要手动掐时间。project和name指定输出目录训练日志、权重和图表都会保存到runs/tomato_ripe/下。训练显存不够时优先减小batch而不是imgsz。imgsz影响模型能看到的细节batch只影响梯度估计的稳定性对最终精度影响相对小。如果 8GB 显存跑不起来batch8 imgsz640通常能解决。4.3 训练日志里必须盯的三个信号loss、mAP50、混淆矩阵训练过程中终端会实时打印box_loss、cls_loss、dfl_loss和验证集指标。我一般只看三个信号cls_loss是否持续下降直到平稳。成熟度检测本质上是分类问题比重定位问题更难cls_loss不降说明模型没有学到“绿色 vs 半熟”的区别。mAP50最终值。对于三分类成熟度检测mAP50 在 0.85 以上算可用0.9 以上算优秀如果只有 0.7 左右大概率是类别混淆或标注不干净。混淆矩阵在runs/tomato_ripe/confusion_matrix.png。这个图会直接告诉你哪两类互相误判最多。训练结束后跑一次验证确认结果不是早停前的偶然波动yolo detect val \ datadata.yaml \ modelruns/tomato_ripe/weights/best.ptbest.pt是训练过程中验证集表现最好的权重last.pt是最后一个 epoch 的权重两者可能 mAP 接近但best.pt的泛化性通常更好。4.4 小数据集的三个典型坑过拟合、光照、类别混淆1267 张图像对检测任务来说属于典型的小数据集训练中我遇到过三类问题各有应对手段。过拟合的表现是训练 loss 降到极低但验证 mAP 上不去或者验证 loss 先降后升。对策按性价比排序加早停已配置、做在线增强、减小模型尺寸。YOLOv8 默认开了 Mosaic 增强小数据集上可以适当提高hsv_h、hsv_s的增强强度让模型对光照变化更鲁棒。光照问题在温室场景尤其突出。西红柿从绿色到红色的颜色变化在不同色温的光源下呈现的 RGB 值差异很大甚至可能让同一个果实在阴天和晴天被分为两个类别。训练时开启scale0.5和fliplr0.5这类几何增强帮助有限关键是把hsv_s和hsv_v的扰动范围加大模拟不同光照条件。类别混淆主要发生在“绿色”和“半熟”之间。这两类在果实转到粉红色之前边界非常模糊。我的经验是不要只看 mAP去翻val_batch*.jpg里预测错误的样本看模型是把半熟误判成绿色还是把绿色误判成半熟。如果是前者说明半熟类样本不足需要补数据或对该类做更强的颜色增强如果是后者说明标签本身不一致——可能标注时把浅绿色标成了半熟需要回头检查原始标签。5. 从 PyTorch 到 ONNX成熟度检测的轻量部署推理5.1 模型导出与输入输出的形状约定训练完成后下一步是把模型从 PyTorch 格式导出成 ONNX脱离深度学习框架跑推理。导出命令很简单yolo export modelruns/tomato_ripe/weights/best.pt formatonnx opset12 simplifyTrueopset12兼容大多数 ONNX Runtime 版本simplifyTrue会做一些计算图优化减小模型体积。导出后的best.onnx可以用onnxruntime直接加载。这里要明确 ONNX 模型的输入输出约定输入是(1, 3, 640, 640)的 RGB 图像归一化到[0, 1]的浮点张量输出形状是(1, 7, 8400)其中7 4 34 是边界框的cx, cy, w, h3 是三个成熟度类别的置信度8400 是三个检测尺度80x80、40x40、20x20上锚点数量的总和。5.2 ONNX Runtime 推理代码读图、前处理、解析输出下面这段代码可以直接运行完成从读图到输出检测框的全流程import numpy as np import cv2 import onnxruntime as ort session ort.InferenceSession(best.onnx) input_name session.get_inputs()[0].name def preprocess(img, input_size640): h, w img.shape[:2] scale min(input_size / h, input_size / w) new_w, new_h int(w * scale), int(h * scale) resized cv2.resize(img, (new_w, new_h)) canvas np.full((input_size, input_size, 3), 114, dtypenp.uint8) canvas[:new_h, :new_w] resized # BGR - RGB, HWC - CHW, 归一化到 [0,1] blob cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0 blob blob.transpose(2, 0, 1)[None] return blob, scale, new_w, new_h def postprocess(output, scale, new_w, new_h, conf_thres0.25): # output shape: (1, 7, 8400) - (8400, 7) preds output[0].transpose(1, 0) boxes, scores preds[:, :4], preds[:, 4:] class_ids np.argmax(scores, axis1) confs np.max(scores, axis1) mask confs conf_thres boxes, confs, class_ids boxes[mask], confs[mask], class_ids[mask] # 归一化坐标还原到原图尺寸 boxes[:, [0, 2]] / scale # cx, w 在 x 方向还原 boxes[:, [1, 3]] / scale # cy, h 在 y 方向还原 # 转换为 xyxy 格式假设原图左上角对齐画布左上角 boxes[:, 0] - boxes[:, 2] / 2 boxes[:, 1] - boxes[:, 3] / 2 boxes[:, 2] boxes[:, 0] boxes[:, 3] boxes[:, 1] return boxes, confs, class_ids img cv2.imread(test_tomato.jpg) blob, scale, new_w, new_h preprocess(img) outputs session.run(None, {input_name: blob}) boxes, confs, class_ids postprocess(outputs[0], scale, new_w, new_h)前处理里用了letterbox而非直接拉伸这是保持检测精度的关键。scale记录原始图像缩放到 640x640 画布的比例用于后处理时把坐标还原回去。postprocess中先过滤低置信度框再按类别取最大置信度。这段代码没有实现 NMS实际使用时建议用cv2.dnn.NMSBoxes或手动实现一个简单的 NMS否则同一果实会输出多个重叠框。5.3 针对成熟度场景的工程小技巧帧率控制与类别阈值部署到实际分拣线或巡检机器人上时有两个容易被忽略的工程细节。视频流推理时不要每帧都跑完整预处理。如果摄像头是 1080p 30fps而模型推理一次需要 15ms那么瓶颈往往在cv2.resize和cvtColor上。常见的做法是只对检测框内的区域做高分辨率分析而非整帧缩放。对成熟度检测来说先用一个轻量模型或传统视觉方法定位果实区域再对每个果实区域裁剪放大做成熟度分类这样既保证了小果实的识别率又控制了整体计算量。类别阈值不要三个类统一用 0.25。绿色和半熟之间的误判最频繁我会对半熟单独提高阈值到 0.4低于阈值的框直接丢弃宁可不判断也不误判。这个微调可以在postprocess里针对不同class_id设置不同conf_thres来实现。另外如果分拣线需要实时决策还要考虑输出端的逻辑滞后——检测到“半熟”到执行分拣动作之间的时间差可能需要往前补偿几帧这个补偿量需要在现场根据传送带速度实测标定。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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