1838张猕猴桃图训YOLO:VOC与YOLO格式转换及小样本训练避坑指南

发布时间:2026/10/25 19:14:57
1838张猕猴桃图训YOLO:VOC与YOLO格式转换及小样本训练避坑指南

简介:面向计算机视觉目标检测任务,这份猕猴桃检测数据集包含1838张真实场景图像,标注类别为单类“mihoutao”,共19278个目标框,适合算法工程师、科研人员和高校学生用于水果检测、农业自动化分拣等场景的模型训练与算法验证。数据采用Pascal VOC与YOLO双格式,每张图片均对应xml标注文件和txt标签文件,可直接接入YOLO、Faster R-CNN等主流检测框架,省去格式转换步骤。压缩包共2000个文件,主要文件类型为xml标注与txt标签文件,整体约84.07MB,便于下载与存储。标注工具采用labelImg,以矩形框形式对猕猴桃进行框选,标注位置准确。目前已有725人学习下载,配套说明可供参考。对于需要标准化农业数据集开展实验的开发者,这份资源能显著节省数据准备时间,帮助快速开展模型迭代与对比。

1. 用1838张猕猴桃图训YOLO前,得先看清这个数据集值不值得接

做农业视觉检测的同行,多半在某个深夜被“猕猴桃分拣”“果园产量估计”“货架识别”这类需求找上门。到手一份标注好的数据集压缩包,名字写着“猕猴桃检测数据集VOC+YOLO格式1838张1类别.7z”,看着挺规整——1838张图、1个类别、VOC和YOLO两套格式都给了,还有7z压缩减小体积。但真要拿来训模型,问题立刻冒出来:标注准不准?图片有没有重复?类别只有一类,能不能直接上手训YOLOv5或者YOLOv8?

这个标题背后其实是“小样本单类别目标检测”的典型场景。1838张图在目标检测里算小而精的规模,训好了能解决果园计数、采收分拣的初级需求,训不好就是标注错位、模型过拟合、loss曲线乱跳的黑匣子吵架现场。本文不用假设你手里有现成工程,直接把数据集从解压到训练、再到导出推理的完整路径拆开讲,顺手把VOC和YOLO两套标注格式的边界坑也一并填平。

2. 解压数据集前先看这两个目录:VOC布局与YOLO布局的差异

拿到这个.7z,第一件事不是双击启动训练,而是先把数据集的“骨架”看清。Pascal VOC和YOLO系列框架(不管是YOLOv5还是YOLOv8)默认读取的目录结构完全不同,硬套的话大概率训练脚本一启动就报找不到图片的错。

2.1 7z文件解压与文件完整性检查

常见做法是用7-Zip直接解压,命令行是:

# 解压到当前目录下的 kiwifruit_dataset 文件夹 7z x 猕猴桃检测数据集VOC+YOLO格式1838张1类别.7z -okiwifruit_dataset

解压后先列目录:

ls -la kiwifruit_dataset

正常会看到类似VOCdevkit、yolo两个顶层目录,或者Annotations、images、labels这类子目录。7z压缩本身自带CRC校验,若解压中途报“CRC Error”或者“Unexpected end of archive”,先别慌,八成是下载不完整或存储介质有问题,重新下载一次或者让提供方重新打包,不要带着坏文件训练。

解压完成后,逐个验证图片能不能打开,用Python批量检查是最高效的:

import os from PIL import Image img_dir = "kiwifruit_dataset/images" bad_images = [] for fname in os.listdir(img_dir): if fname.lower().endswith((".jpg", ".jpeg", ".png")): try: with Image.open(os.path.join(img_dir, fname)) as img: img.verify() except Exception: bad_images.append(fname) print(f"损坏图片数量: {len(bad_images)}") if bad_images: print(bad_images[:20])

逻辑说明:img.verify()只校验文件头和数据完整性,不加载完整像素,所以跑得很快,适合1838张图这种规模。若某个图是空文件或截断下载,这里能直接揪出来。如果一个损坏图都没有,再进入下一步。

参数说明:如果图片是.bmp或.tiff格式,记得把endswith的元组里加上对应后缀,不然检查会漏掉。另外别只依赖文件后缀,有的数据集里文件叫.jpg实际是PNG编码,Image.open()本身能自动识别,不影响后续训练。

2.2 VOC格式的五个关键目录:JPEGImages、Annotations、ImageSets、SegmentationClass、SegmentationObject

VOC格式的目标检测数据集,目录结构一般长这样:

VOCdevkit/ └── VOC2007/ ├── Annotations/ # 每张图片对应的XML标注文件 ├── JPEGImages/ # 原始图片 ├── ImageSets/ │ └── Main/ # train.txt, val.txt, trainval.txt ├── SegmentationClass/ # 语义分割的png标注(可选) └── SegmentationObject/ # 实例分割的png标注(可选)

Annotations里的XML文件描述每个目标的类别和边界框坐标,核心字段是<object>、<name>、<bndbox>以及<xmin><ymin><xmax><ymax>。这套坐标是绝对值,单位是像素。

xml.etree.ElementTree来解析一个XML样例:

import xml.etree.ElementTree as ET xml_path = "VOCdevkit/VOC2007/Annotations/kiwi_00001.xml" tree = ET.parse(xml_path) root = tree.getroot() for obj in root.findall("object"): name = obj.find("name").text bbox = obj.find("bndbox") xmin = int(bbox.find("xmin").text) ymin = int(bbox.find("ymin").text) xmax = int(bbox.find("xmax").text) ymax = int(bbox.find("ymax").text) print(f"类别: {name}, 坐标: ({xmin}, {ymin}) -> ({xmax}, {ymax})")

逻辑说明:VOC的标注把目标框定义为左上角(xmin, ymin)和右下角(xmax, ymax),注意它和COCO的(x, y, width, height)表示法不同,转换YOLO的时候容易搞混坐标含义。另外<name>字段必须严格统一,比如全大写KIWI和全小写kiwi在训练时会当成两个类别,中等规模项目里这种粗心会浪费整天的排错时间。

2.3 YOLO格式的txt标注与归一化坐标逻辑

YOLO系列的标注不是XML,而是每张图片对应一个同名.txt文件,放在labels目录下。每行内容形如:

0 0.4367 0.5123 0.2345 0.1876

5个数值依次是:类别索引class_id、归一化中心点x坐标、归一化中心点y坐标、归一化宽度、归一化高度。所有值都是0~1之间的小数,用像素坐标除以图片宽高得到。

看一个真实样本:

cat yolo/labels/kiwi_00001.txt

假设输出为:

0 0.4521 0.3817 0.3822 0.2745

这表示第0类(猕猴桃),边界框中心在图片水平45.21%、垂直38.17%的位置,框宽高分别占全图的38.22%和27.45%。

这么说可能还是比较抽象,用Python把它还原回像素坐标:

# 假设图片尺寸是 640x480 img_w, img_h = 640, 480 class_id, cx_norm, cy_norm, w_norm, h_norm = 0, 0.4521, 0.3817, 0.3822, 0.2745 cx, cy = cx_norm * img_w, cy_norm * img_h w, h = w_norm * img_w, h_norm * img_h xmin = int(cx - w / 2) ymin = int(cy - h / 2) xmax = int(cx + w / 2) ymax = int(cy + h / 2) print(f"像素坐标: ({xmin}, {ymin}) -> ({xmax}, {ymax})")

能看到归一化还原为像素的过程,如果出现xmin负数或xmax超过图片宽度的情况,说明标注本身就有问题,后面章节会专门讲这条排查路径。

2.4 同一份数据用两套格式的用意:框架兼容与迁移自由

为什么数据集要同时给VOC和YOLO两套格式?站在产出方的角度,这是为了降低使用门槛——有人用老牌的Faster R-CNN、SSD,框架原生支持VOC;有人图省事直接上YOLOv5/v8,开箱即用txt格式。两边都不冷落,受众面更广。

站在使用方的角度,两套格式同时存在还有个隐藏好处:可以作为标注正确性的交叉验证。把同一张图的VOC坐标转成YOLO坐标,再和YOLO目录里的txt对比,一致就说明原始标注可靠;不一致则说明数据集内部出现版本分裂。常见做法是写个小脚本批量对比,后面章节会给代码。

3. 把VOC转成YOLO格式:一份可复用的转换脚本和四个边界坑

有些情况下你拿到手的VOC目录没配套YOLO格式,那就得自己转。反过来,如果txt格式有误、需要重新从XML生成,也要走坐标系转换。1838张图的规模不大,脚本转换也就是一两秒的事,但转换过程中的坐标边界问题才是真正的大坑。

3.1 用Python写VOC转YOLO:从XML到txt的完整脚本

标准做法是读取XML里的<bndbox>和图片尺寸,做归一化后写入txt。这里给一份可直接复用的脚本:

import os import xml.etree.ElementTree as ET from PIL import Image def voc_to_yolo(xml_path, img_dir, out_dir, classes): """ xml_path: 单个XML文件路径 img_dir: 图片所在目录 out_dir: 输出txt所在目录 classes: 类别列表,索引即class_id """ tree = ET.parse(xml_path) root = tree.getroot() # 从XML里读图片文件名,然后拿真实宽高 img_fname = root.find("filename").text img_path = os.path.join(img_dir, img_fname) with Image.open(img_path) as img: img_w, img_h = img.size out_lines = [] for obj in root.findall("object"): name = obj.find("name").text if name not in classes: continue class_id = classes.index(name) bbox = obj.find("bndbox") xmin = float(bbox.find("xmin").text) ymin = float(bbox.find("ymin").text) xmax = float(bbox.find("xmax").text) ymax = float(bbox.find("ymax").text) # 归一化计算 x_center = ((xmin + xmax) / 2) / img_w y_center = ((ymin + ymax) / 2) / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h # 数值裁剪,防止越界 x_center = min(max(x_center, 0.0), 1.0) y_center = min(max(y_center, 0.0), 1.0) w = min(max(w, 0.0), 1.0) h = min(max(h, 0.0), 1.0) out_lines.append(f"{class_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") # 输出文件名与XML同名,但扩展名改为txt txt_fname = os.path.splitext(os.path.basename(xml_path))[0] + ".txt" with open(os.path.join(out_dir, txt_fname), "w") as f: f.write("\n".join(out_lines)) # 用法示例 classes = ["kiwi"] # 单类别数据集 xml_dir = "VOCdevkit/VOC2007/Annotations" img_dir = "VOCdevkit/VOC2007/JPEGImages" out_dir = "yolo_labels" os.makedirs(out_dir, exist_ok=True) for xml_name in os.listdir(xml_dir): if xml_name.endswith(".xml"): voc_to_yolo(os.path.join(xml_dir, xml_name), img_dir, out_dir, classes) print("转换完成")

逻辑说明:classes列表的索引就是训练时的class_id,如果数据集只有kiwi一个类别,索引固定为0。读图片尺寸用PIL而非直接从XML里拿<width>和<height>,是因为XML里的尺寸字段有时是标注工具写错的,直接用真实图片尺寸更安全。

参数说明:输出保留6位小数,已经足够满足YOLO训练精度需求。min(max(...))裁剪是防止某些标注框超出图像边界导致的负数或大于1的异常值。用这种方式处理越界标注,宁可裁剪到边缘也比留着异常值让loss爆炸好。

3.2 边界坑一:标注框越界——AI如何悄悄丢目标

实际数据集里,标注框坐标超出图片边界并不罕见。产生原因多数是标注工具允许拖拽超出画布、或者批量标注时自动生成的框就没对齐图片。若不做处理,YOLO训练时正样本标注可能出现负数坐标、宽高为负的情况,轻则产生警告,重则导致loss直接变NaN。

处理策略分层:

# 策略1: 完全丢弃越界目标(最严格) if xmin < 0 or ymin < 0 or xmax > img_w or ymax > img_h: continue # 跳过这个object # 策略2: 裁剪到边界(推荐,保目标) xmin = max(0, xmin) ymin = max(0, ymin) xmax = min(img_w, xmax) ymax = min(img_h, ymax) # 策略3: 只过滤面积过小的目标 box_w = xmax - xmin box_h = ymax - ymin if box_w < 10 or box_h < 10: continue

我一般用策略2加策略3组合:先裁剪到边界,再过滤掉宽或高小于10像素的框。太小的框对检测任务几乎没有贡献,反而会让模型去拟合“噪点”。

3.3 边界坑二:图片尺寸不一致——归一化的坐标系被扭曲

1838张数据集的图片如果来自多个来源,宽高比可能从4:3到16:9都有。YOLO的归一化坐标系是以单张图片宽高为基准的,所以图片尺寸不一致本身不是致命问题,训练时会自动resize。但坑在于:如果XML里的<width>和图片真实宽度不一致,归一化坐标就是不准确的。

跑一段校验脚本:

xml_dir = "Annotations" img_dir = "JPEGImages" mismatch = [] for xml_name in os.listdir(xml_dir): if not xml_name.endswith(".xml"): continue tree = ET.parse(os.path.join(xml_dir, xml_name)) root = tree.getroot() xml_w = int(root.find("size/width").text) xml_h = int(root.find("size/height").text) img_fname = root.find("filename").text with Image.open(os.path.join(img_dir, img_fname)) as img: real_w, real_h = img.size if xml_w != real_w or xml_h != real_h: mismatch.append((xml_name, xml_w, xml_h, real_w, real_h)) print(f"尺寸不一致文件数: {len(mismatch)}") for item in mismatch[:10]: print(item)

逻辑说明:这段脚本逐个比对XML声明的尺寸和图像真实尺寸。检测到不一致后,有两条路——要么重新生成XML里的尺寸字段,要么在转换脚本里直接用Image.size的返回值(上一小节的方案已经自动处理了这个问题)。这里建议直接采用后者。

3.4 边界坑三:数据划分与类别数不匹配——先看txt再定classes参数

YOLO训练前的数据划分通常由脚本按比例随机切分训练集和验证集,但前提是每张图都有对应的txt标注。如果labels目录里只有1500个txt,而images目录有1838张图,多出来的338张会落入“无标注样本”状态。训练时它们可能被跳过,也可能被当成背景图参与负样本学习,结果全看框架实现。

检查代码:

import os img_dir = "images" label_dir = "labels" imgs = set(os.path.splitext(f)[0] for f in os.listdir(img_dir) if f.endswith(".jpg")) labels = set(os.path.splitext(f)[0] for f in os.listdir(label_dir) if f.endswith(".txt")) missing_label = imgs - labels missing_img = labels - imgs print(f"缺标注的图片数: {len(missing_label)}") print(f"无图片的标注数: {len(missing_img)}") if missing_label: print("缺标注样例:", list(missing_label)[:10])

处理方式也很直白:把缺标注的图片从训练集里剔除,或者补标注。标注工具这块用LabelImg或者X-AnyLabeling都能很快补完,但补完要重新做一遍格式转换。要是只有零星几张缺标注,剔掉比补标注更划算。

3.5 边界坑四:单类别的特殊情况——正负样本不均衡与类别索引混乱

本数据集只有1个类别,按理说class_id永远是0,最不济也就不会出现类别索引混乱。但恰恰是单类别场景更容易踩坑:有人习惯从1开始数类别,结果类别索引写成1,训练时就会报“class index out of range”或者强行为背景分配了索引0,导致背景也被当作目标参与检测。

验证类别索引是否正确的通用办法:

import os label_dir = "labels" unique_classes = set() for fname in os.listdir(label_dir): if not fname.endswith(".txt"): continue with open(os.path.join(label_dir, fname)) as f: for line in f: parts = line.strip().split() if len(parts) != 5: print(f"异常行: {fname}: {line}") else: unique_classes.add(int(parts[0])) print(f"数据集中出现的类别索引: {sorted(unique_classes)}")

逻辑说明:打印出数据集里实际出现的类别索引集合,再和你预期值比较。若出现索引1而预期只有0,说明某处标注把kiwi标成了其他类别数字,需要逐个核对标注来源。

4. 用YOLOv5训这个数据集:环境搭建、目录配置和三组必调参数

结构看清了、转换也验证过了,接下来就是真正跑通训练。YOLOv5虽然官方更新放缓,但对小数据集和单类任务仍然是最省心的选择;YOLOv8的ultralytics包也能直接吃这套数据,差异主要集中在配置文件和回调接口上。这里以YOLOv5为主,因为它对目录要求最明确、报错信息最直观。

4.1 创建数据集配置文件与目录软链接

YOLOv5要求你把数据放进特定结构,并在data/目录下建一个yaml文件描述数据路径和类别。常见做法是:

# 在YOLOv5项目根目录下 mkdir -p datasets/kiwifruit/images/train mkdir -p datasets/kiwifruit/images/val mkdir -p datasets/kiwifruit/labels/train mkdir -p datasets/kiwifruit/labels/val

然后做软链接或者直接拷贝文件过去:

# 假设你已在根目录解压了数据 ln -s /absolute/path/to/kiwifruit_dataset/images/train datasets/kiwifruit/images/train ln -s /absolute/path/to/kiwifruit_dataset/images/val datasets/kiwifruit/images/val

注意:1234步里的train和val划分要先做,把1838张图按大概8:2或9:1分成两拨,常见比例在8:2,稳妥一点的做法是先按文件名排序,然后抽20%作为验证集,不要让随机划分毁了标注顺序。

4.2 定义data yaml并启动训练

新建data/kiwifruit.yaml:

# 训练和验证集图片路径,同时会按此推label路径 train: datasets/kiwifruit/images/train val: datasets/kiwifruit/images/val # 类别数量与名称 nc: 1 names: ['kiwi']

启动训练:

python train.py --img 640 --batch 16 --epochs 100 --data data/kiwifruit.yaml --weights yolov5s.pt --project runs/train/kiwifruit

参数说明:

  • --img 640:输入图像会被resize到640x640。数据集的原始尺寸如果集中在500x500上下,用640不亏。
  • --batch 16:单卡显存8G以下建议8,12G以上可以16。1838张图不算大,batch大一点收敛更稳。
  • --epochs 100:小数据集100个epoch起步,配合--patience 20做早停。
  • --weights yolov5s.pt:用COCO预训练权重做迁移学习,即使是单类别也有明显提速效果。如果网络受限,可以用--weights ''从头训练,但100个epoch可能不够。

4.3 三个必调参数:image size、anchor与mosaic

很多在小数据集上连跑十几个小时不出效果的案例,问题往往出在默认参数上。

第一个必调参数是--img。对于“单果检测”这种目标不算太密集的任务,416或512的输入分辨率往往效果和640差距不大,但显存占用和训练速度能快不少。原图如果框很小、目标密集,才需要往640以上调。先跑一个短实验对比--img 416和--img 640在验证集上的mAP,再决定正式训练用哪个。

第二个必调参数是--anchor。YOLOv5默认anchor是从COCO数据集聚类出来的,对猕猴桃这种固定形状、尺寸分布相对集中的目标不一定最优。通常做法是先关闭自动anchor:

python train.py --img 640 --batch 16 --epochs 50 --data data/kiwifruit.yaml --weights yolov5s.pt --noautoanchor

跑几个epoch之后,脚本会打印出建议的anchor值。把它写进模型配置里,然后再开自动anchor或者手动设定,mAP通常能涨2到3个点。

第三个必调参数是--mosaic。mosaic数据增强把4张图拼在一起输入,等于天然增加了batch size和场景多样性,但对小数据集有副作用——混淆了真实框的上下文,导致模型在早期收敛缓慢。常见做法是前30个epoch关掉mosaic,后70个epoch再开:

python train.py --img 640 --batch 16 --epochs 100 --data data/kiwifruit.yaml --weights yolov5s.pt --mosaic 0.0

这个参数可以按epoch动态调整,但写成固定值更省心。如果发现收敛太慢,也可以全程开0.5的概率,具体数值以实验为准。

4.4 训练过程怎么看:loss曲线、mAP曲线与学习率曲线

训练时不光看日志,还要盯住验证集指标。YOLOv5会在runs/train/kiwifruit/下生成results.png,包含box_loss、cls_loss、mAP_0.5和mAP_0.5:0.95的曲线。对于单类别目标,重点看box_loss和mAP_0.5,cls_loss因为类别少会很早就降到接近0,不用过于紧张。

启动tensorboard或者直接看图片都行,我一般盯三个信号:

  • box_loss从0.08以上降到了0.03以下,说明框回归在正常收敛。
  • mAP_0.5在训练结束后达到0.9以上,说明检测可用。
  • mAP_0.5:0.95如果只有0.5左右,说明框的定位精度一般,可能需要更高的输入分辨率或更细的anchor。

5. 小数据集训练避坑:标注错位、类别混淆与loss不降的排查记录

1838张单类别数据集的训练过程里,有四类问题出现频率最高。每一条都是能复现的踩坑案例,按“现象-原因-解决”写清楚。

5.1 验证集mAP很高但推理效果差——问题出在数据划分

现象:训练日志里mAP_0.5达到0.95,但拿一张没见过的猕猴桃图片做推理,效果奇差,漏检严重。

原因:数据划分时train和val来自同一个视频帧序列的连续片段,比如视频前800帧归训练、后200帧归验证,画面高度相似,相当于“背题”。验证集的分数虚高,实际泛化能力远差于指标。

解决:按文件名随机洗牌后划分,而不是按顺序切分。更严格的做法是先用--project重新独立划分一次,让同源的图片尽量只出现在训练集或只出现在验证集。

5.2 loss下降但mAP不涨——检测框位置偏移,正样本跟丢了

现象:box_loss稳步下降,但mAP_0.5大概卡在0.7~0.8附近不涨。

原因:数据集里存在一定比例的标注框中心偏移或宽高误差过大,模型把标注噪声当成真实分布的一部分,学习出来的框位置有系统性偏差。多见于标注时框没有紧贴果实轮廓,留了太多无关边缘。

解决:先抽查100张图的标注框可视化,把框画到图片上逐一检查。如果确实是批量偏移,用脚本统一修正。常见做法是把所有框按原中心向内收缩5%到10%,或者用聚类方法把同类框的尺寸统计出来,手动剔除偏离均值超过3倍标准差的异常框。

5.3 验证集loss比训练集低很多——数据增强副作用

现象:训练loss在0.02附近,验证loss却到了0.005,差距整整一个数量级。

原因:训练时开启了mosaic、hsv、flip等增强手段,模型在增强数据上学习难度更大,所以loss数值更高;验证则不启用任何增强,天然数值低。一定程度上的差距是正常的,但如果差距太悬殊,说明模型对原始图过拟合,增强反而没起作用。

解决:优先减少mosaic概率和hsv增强强度。把--hsv 0.0关掉颜色抖动,减弱--fliplr 0.0关闭水平翻转。保持训练和验证的分布差异在合理范围,用得到验证loss不至于过低的标准来调。

5.4 训练时突然出现NaN loss——学习率过大或标注文件损坏

现象:跑到第40个epoch,loss直接变为nan,后续指标全部失效。

原因:学习率初始值设置过大,梯度爆炸;或者某个训练批次里混入了一个损坏的txt标注(比如一行只有4个数字、或坐标值超过1.0)。前者的概率较小,因为YOLOv5默认学习率0.01对迁移学习是稳妥的;后者在数据集整理阶段更容易发生。

解决:先把标注文件完整性再检查一遍,确认每行都是5个数值。包含非法字符的文件全部删除或重新生成。如果数据没问题,定位到触发nan的batch,把那个batch对应的图片和txt单独拉出来检查。

5.5 单类别模型的类别数配置——nc=1但判断为背景

现象:模型训练完成后,推理时对猕猴桃目标经常报background,且置信度只有0.3左右。

原因:nc=1的单类别模型本身就容易把目标判为背景,因为模型只需要区分“猕猴桃”和“背景”,当数据里背景太杂、目标太小的时候,置信度整体会被压低。很多框架的默认置信度阈值在0.25,刚好卡在这种低置信度预测上。

解决:推理时调低置信度阈值,从0.25降到0.1;如果目标是计数而非精细定位,这个方案足够用。想从根上解决就把训练时的--conf-thres和--iou-thres参数按照验证集实际分布重新调一次,或者考虑把易混淆背景图也加入训练集作为负样本。

6. 验证与进阶:把模型导出成ONNX,再借backbone输出做一次可视化体检

模型训好了,离上线还剩最后一公里。传统做法是直接用torch.hub.load加载pt文件做推理,但生产环境常见的是用ONNX。导出和可视化的验证方法可以让模型的真实水平更快暴露出来。

6.1 导出ONNX并跑一次英伟达TensorRT预备检查

YOLOv5自带的导出脚本已经很成熟:

python export.py --weights runs/train/kiwifruit/weights/best.pt --img 640 --batch 1 --include onnx

导出成功后,用ONNX Runtime做一次推理验证:

import onnxruntime as ort import numpy as np from PIL import Image sess = ort.InferenceSession("best.onnx") input_name = sess.get_inputs()[0].name img = Image.open("test_kiwi.jpg").resize((640, 640)) img_array = np.array(img).astype(np.float32) / 255.0 img_array = np.transpose(img_array, (2, 0, 1))[None, ...] outputs = sess.run(None, {input_name: img_array}) print(f"输出shape: {[o.shape for o in outputs]}")

逻辑说明:ONNX输出的shape一般是(1, 25200, 6),25200是640x640下3个尺度特征图的总预测数,6是(cx, cy, w, h, obj_conf, class_conf)。postprocess阶段仍要做NMS,不能直接拿原始输出当结果。

这一步本质上是验证导出后模型结构是否完整、推理结果是否和PyTorch版本一致。小数据集训练的模型最容易在导出时出现数值差异,因为batch norm层的统计量是在训练时累计的,若训练中断过或数据太偏,导出的ONNX可能和pt模型在边缘输入上产生较大偏差。

6.2 backbone特征图可视化:看模型到底学到了什么

当mAP曲线足够好看但实际效果差强人意时,直接看backbone输出的热力图是最快的诊断手段。YOLOv5的backbone在model.model[0]到model.model[9]之间,切出某一层的输出画成特征图:

import torch import matplotlib.pyplot as plt from PIL import Image model = torch.hub.load("ultralytics/yolov5", "custom", path="best.pt") # 截断前向传播,取第5层输出 layer = model.model[5] model.eval() img = Image.open("test_kiwi.jpg").convert("RGB") with torch.no_grad(): x = model.model[0](torch.from_numpy(np.array(img.resize((640, 640)))).permute(2, 0, 1).float() / 255.0)[None, ...] x = model.model[1](x) x = model.model[2](x) x = model.model[3](x) x = model.model[4](x) feat = layer(x) # 把特征图channels平均后可视化 mean_feat = feat.mean(dim=1)[0] plt.imshow(mean_feat.cpu().numpy(), cmap="jet") plt.colorbar() plt.savefig("backbone_heatmap.png")

逻辑说明:这段代码手动逐层前向传播,取第5层的输出特征图。如果热力图的高亮区域集中在猕猴桃所在的位置,说明backbone已经学到了“果实区域响应更强”的模式;如果高亮区域分散在背景纹理或果筐边缘,说明模型学习的特征偏了,常见对策是增加负样本、加强数据增强或换更大的backbone。

6.3 数据收集优先级与下一轮迭代建议

模型能跑通只是开始,1838张单类别数据集的后续提升空间,主要在三个方向。优先扩“困难样本”,比如遮挡严重、光照过暗、多个果实紧贴的图片,这些样本对mAP的贡献远大于再多加几百张常规图。其次是拍摄设备与角度多样化,如果数据集全部来自俯拍固定机位,模型换到手持拍摄场景时退化会很明显。最后才是调参,在数据充分之前,反复调anchor和mosaic的边际收益有限。

我自己的习惯是每次训练完都导出一份bad case图集合,把那些漏检和误检的图片打印成联系表,做成一个不断累积的“错误集”。下一轮数据采集时按错误集里暴露的短板去找图,不盲目堆量。这么做三轮之后,即使仍然只有2000张附近的规模,模型的实用度也会比一次性刷5000张通用图高出不少。

坑踩得多了,越来越觉得小数据集检测的成败不取决于调参玄学,而取决于你对自己数据分布的了解有多深。希望这篇笔记能让你少走几趟弯路,把猕猴桃数据集真正变成能用的检测模型,而不是停留在解压完成后就吃灰的压缩包。

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