水果识别VOC数据集构建与格式转换实战指南
2026/9/13 12:13:53 网站建设 项目流程

简介:一份面向深度学习和计算机视觉入门人群的水果识别数据集,专注于苹果、香蕉、橙子三个类别,包含三百张图片与对应标注文件,可同时用于图像分类和目标检测模型训练。数据集沿用了帕斯卡视觉目标类别竞赛(VOC)的标准组织方式,项目包中既有图片也有对应的标签文件,每张标签都记录了水果的类别和位置坐标,方便直接接入常见的检测框架进一步调试。压缩包体积轻巧,仅二十三点八兆,全部文件共六百零九个,其中三百个图像、三百个标签文件,另含六个说明文本、两个派森脚本和一个配置文件,脚本可辅助完成数据划分或格式转换,配置项便于调节模型参数。当前已有超过一千三百人学习使用,对于希望快速上手数据标注读取、验证模型抗干扰能力或进行小样本训练的初学者,这套资源提供了低门槛的实践入口。

1. 水果识别的VOC数据集:把标注效率花在刀刃上的第一步

帮朋友排查过好几个检测项目精度上不去的问题,最后的根子大多不在模型参数,而在数据集组织得太随意——图片放一个目录,XML标注放另一个目录,ImageSets/Main 里还混着旧样本没清理。如果一开始就按照 Pascal VOC 格式来组织,这些问题在训练前就会被结构本身挡掉。VOC 数据集规范的核心不是花哨的标注界面,而是用一套统一的目录骨架、统一的 XML 标签字段、统一的索引文件,把“图片-标注-样本划分”三件事绑定成检测框架可以直接读取的样本集。对水果识别来说,这套约定的价值尤其明显:苹果、香蕉、橙子这类目标边界清晰,遮挡情况有限,VOC 的 bndbox 标注模型对刚体加少量遮挡的任务兼容性最好;而且从 Fast R-CNN 到 YOLOv8 的官方训练流程原生支持 VOC 格式,只需要把路径指过去、类别名配置好就能训练。下面按从零组装的路子,把采集、标注、增强、格式转换到交付验证都写清楚。

2. 搭好VOC骨架再谈AI精度:水果图片如何变成能被模型读取的标准样本

2.1 VOC数据集的目录不过三件套:JPEGImages、Annotations、ImageSets

VOC 数据集的组织结构比很多人想象中简单,核心就三部分,下面是以 VOC2007 为蓝本的目录规划:

VOC2007/ ├── Annotations/ │ ├── apple_001.xml │ ├── apple_002.xml │ └── banana_001.xml ├── ImageSets/ │ └── Main/ │ ├── train.txt │ ├── val.txt │ └── trainval.txt └── JPEGImages/ ├── apple_001.jpg ├── apple_002.jpg └── banana_001.jpg

这里最容易被忽略的一条规则是:Annotations 下的 XML 文件名必须与 JPEGImages 下的图片文件名完全同名,只差扩展名。实际训练时,很多检测框架的 Dataset 实现会遍历 JPEGImages 找出所有图片,再拿图片名去 Annotations 里找同名 XML;如果 XML 缺失,有的库直接报错,有的则静默跳过,导致你对着训练日志里“找不到匹配标注”的消息查半天。ImageSets/Main 下存放的 txt 文件则只写图片名(不含 .jpg 后缀),一行一个,框架会用它来过滤当前训练或验证集。

另外,不少人会把 SegmentationClass 和 SegmentationObject 目录也建出来,其实纯检测用不到。我一般建议保持目录最小化,能少一层混淆就少一层,特别是多人协作时目录越少越不容易放错东西。

提示:VOC 布局下的 JPEGImages 不要放缩略图。水果识别场景经常直接从手机或相机导图,原图往往三四千像素宽,训练前缩放是框架内部做的事,你只需要保证源图信息完整。

2.2 水果图片采集与清洗:排除“果蔬混卖”式脏样本

水果图片采集的第一个原则是场景覆盖优先于总量。同样是苹果,厨房案板上的、超市货架上的、手里握着的、切开一半的,对模型的泛化影响完全不同。我一般会按“日常摆拍、逆光、遮挡、密集堆放”至少四个情境拉图,每个类别每个情境不少于 100 张,单类总数再低也要压到 300 张以上,否则后面拿 VOC 格式做迁移学习,类别特征很容易被背景主导。

采集后要过一遍清洗规则,常见的脏样本有这么几类:

  • 带包装纸或标签的水果,如果目标是识别水果本体,这种框会把塑料包装学进去;
  • 果盘里混了五六种水果,框选时没有盖到完整边缘,模型学到的局部纹理容易跟其他类混淆;
  • 加了明显滤镜或美颜的图片,色彩通道被改得厉害,和 VOC 预训练权重见过的自然图差异太大;
  • 水印、贴纸、日期戳,这些在推理时不会出现的文字会干扰特征提取。

还有一个细节:图片命名建议全部用小写英文字母加下划线,不要带空格、括号或中文。VOC 规范本身不限制命名,但不少框架会用文件名切分出图片 ID,遇到空格和中文字符时可能在字符编码和路径解析上翻车。文件名统一成 apple_001.jpg、banana_002.jpg 这种格式,后面写脚本也能省很多麻烦。

2.3 用labelImg打标签并生成正确的VOC XML

标注工具最常用的是 labelImg,它默认支持 PascalVOC 和 YOLO 两种标注格式。打开后先在菜单里把标注格式切到 PascalVOC,再设置图片目录和保存目录,保存目录指到 VOC2007/Annotations。快捷键是效率的关键,熟悉下面这几个就能把标注节奏提起来:

快捷键作用
W创建矩形框,按左键拖拽
D / A下一张 / 上一张图片
Ctrl+S保存当前图片对应的 XML
Del删除当前选中的框
Ctrl+Z撤销上一步

画框时注意,目标框要紧贴水果外边缘,不要留出大块背景,也尽量不把果柄切出去。对于密集堆放的水果,每个可见的完整水果都要单独画框;如果两个水果重叠超过一半,只画露出来的那个,另一个可以不画或者把 difficult 标记为 1,避免模型学到大量重叠框互相干扰。

2.3.1 检查XML最容易被忽略的字段

标注工具保存出来的 XML 结构是标准的 VOC 格式,关键字段有这些:

字段名含义
<folder>图片所在目录名称,不影响实际路径解析
<filename>图片文件名,必须与磁盘文件完全一致
<source>图片来源,通常写数据库名或标注工具
<size>图片宽度、高度、通道数
<object><name>类别名,多个目标会出现多个<object>
<truncated>目标是否被图像边界截断
<difficult>难例标记,0 表示普通,1 表示训练时可忽略
<bndbox>真实框坐标,xmin/ymin/xmax/ymax,单位像素

最容易出问题的字段是<difficult>。很多工具默认生成 0,但如果你在 labelImg 里误触了困难标记,训练框架读取后可能直接忽略这个样本,导致验证集少了一批数据而不自知。另一个坑是<size>和实际图片尺寸不一致,多半发生在标注后对图片做过压缩或裁剪,XML 却还是旧尺寸,这样读取时坐标虽然不越界,但归一化后位置偏移。

2.3.2 批量解析XML统计类别分布

标注完成后,不要急着训练,先用脚本解析一遍 XML,看每个类别到底有多少目标、有没有空标注、有没有越界框。下面这段脚本可以直接复用:

import xml.etree.ElementTree as ET from pathlib import Path from collections import Counter def parse_all_xml(anno_dir): categories = Counter() total_boxes = 0 errors = [] for xml_file in Path(anno_dir).glob("*.xml"): root = ET.parse(xml_file).getroot() img_w = int(root.findtext("size/width")) img_h = int(root.findtext("size/height")) for obj in root.findall("object"): name = obj.findtext("name").strip() if not name: errors.append((xml_file.name, "empty object name")) continue categories[name] += 1 bnd = obj.find("bndbox") xmin = float(bnd.findtext("xmin")) ymin = float(bnd.findtext("ymin")) xmax = float(bnd.findtext("xmax")) ymax = float(bnd.findtext("ymax")) if xmin < 0 or ymin < 0 or xmax > img_w or ymax > img_h: errors.append((xml_file.name, f"{name} box out of bounds")) total_boxes += 1 return categories, total_boxes, errors cats, total, errs = parse_all_xml("VOC2007/Annotations") print("类别统计:", dict(cats)) print("总框数:", total) print("错误样本:", errs[:10])

这段脚本用findtext("size/width")直接从 XML 里抓取值,比先 find 再取 text 少写两行代码;遍历所有<object>块时同时校验坐标是否超出图片边界。打印出来的类别统计如果发现某类目标数量只有几十个,后面训练就会出现严重的类别不平衡,可以提前回去补采集而不是等 loss 崩了再排查。

3. 数据增强与格式转换:水果VOC数据集从“能跑”到“训得好”

3.1 用Albumentations做增强,让VOC XML和图片同步变换

数据增强对水果识别特别关键,因为水果在不同角度、光照和摆放位置下的外观差异很大。很多人直接在 PyTorch 的 transform 里写ToTensorNormalize,只对图片做增强,完全没有同步处理 XML 里的 bndbox,训练时框和图片不同步,损失函数算出来的 IoU 全乱。更可靠的做法是用 albumentations 这类增强库,它原生支持 bbox 转换。

import cv2 import xml.etree.ElementTree as ET import albumentations as A transform = A.Compose([ A.HorizontalFlip(p=0.5), A.RandomBrightnessContrast( brightness_limit=0.2, contrast_limit=0.2, p=0.8 ), A.HueSaturationValue( hue_shift_limit=10, sat_shift_limit=20, val_shift_limit=10, p=0.7 ), A.RandomSizedBBoxSafeCrop( width=512, height=512, erosion_rate=0.2, p=0.5 ), ], bbox_params=A.BboxParams( format="pascal_voc", min_visibility=0.3 ))

这段配置里bbox_paramsformat="pascal_voc"表示传入的框坐标顺序就是 VOC 的 xmin/ymin/xmax/ymax,这和标注 XML 里的字段顺序完全一致,不需要手动换算。min_visibility=0.3表示增强过后框与目标的重叠面积不足 30% 时直接丢弃这个框,避免裁出半个苹果还硬让模型学。RandomSizedBBoxSafeCrop会把图片裁剪并缩放到 512×512,同时保证每个可见框不被切没。

使用时要先把 XML 中的每个框和类别读成列表,传给 transform 后拿返回值里的boxeslabels回写新图片和 XML。切片增强后<size>字段也要同步更新成裁剪后的宽高,否则后续脚本做越界校验时会把本来就正常的数据判成越界。

3.2 类别不均衡:先看统计再定采样策略

水果识别最常见的类别不均衡是“苹果特别多,火龙果特别少”。如果标注完跑了上面的脚本,发现最少的类不足最多的类的五分之一,光靠数据增强解决不了问题。可以先做一轮过采样,把少数类的 XML 和图片复制几份放到训练集里,这里有几个方案可以选:

方案适用场景注意事项
复制少数类样本类间数量差距小于 5 倍时过度复制会导致过拟合,控制在 2 倍内
在线增强样本量超过 500 张主要方式,cfg 里打开 mosaic/flip
Focal Loss类间差距超过 10 倍或难例较多需要调 alpha/gamma,模型收敛较慢
添加负样本经常误检果盘、叶子等背景干扰物单独建一个 background 类

需要特别说明的是,很多框架读 VOC 时默认每个批次是随机抽图,类别不均衡会直接影响 loss 的分母权重。你可以在训练前按 2.3.2 的统计结果,把多数类随机抽掉一部分放到 val.txt 里,让训练集比例更健康;同时保证 val.txt 中各类别的数量分布和真实场景接近,否则验证指标会失真。对于“误把果盘当水果”这类问题,负样本策略比单纯提高类别权重更有效。

3.3 VOC转YOLO格式:归一化坐标别把图片宽高除反了

训练 YOLOv8 或 YOLOv5 时,为了省掉 VOC 解析的复杂度,很多人会先把 VOC 数据转成 YOLO 的 txt 格式。转换时有三个极易出错的地方:类别 ID 必须从 0 开始、坐标必须归一到 0 到 1 之间、输出文件必须和图片同名且扩展名为 txt。下面的函数把单个 VOC XML 转换为 YOLO label 字符串:

def voc_to_yolo(xml_path, class_names): root = ET.parse(xml_path).getroot() img_w = int(root.findtext("size/width")) img_h = int(root.findtext("size/height")) labels = [] for obj in root.findall("object"): name = obj.findtext("name") cls_id = class_names.index(name) bnd = obj.find("bndbox") x1 = float(bnd.findtext("xmin")) y1 = float(bnd.findtext("ymin")) x2 = float(bnd.findtext("xmax")) y2 = float(bnd.findtext("ymax")) cx = ((x1 + x2) / 2.0) / img_w cy = ((y1 + y2) / 2.0) / img_h bw = (x2 - x1) / img_w bh = (y2 - y1) / img_h labels.append(f"{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") return "\n".join(labels)

核心在归一化那四行:中心点坐标除以图片宽度高度,框宽高也分别除以对应边长。很多人写成cx = (x1+x2)/2/(img_h if img_w > img_h else img_w)这种“防短边归一化”,对 YOLO 来说没必要,YOLO 训练时会按模型输入做 letterbox,预先按边长归一化反而可能导致框位置偏移。class_names必须按训练配置文件里的类别顺序传入,顺序错误会导致所有框整体错位。

转换后的 label 文件建议集中放在数据集的labels/目录下,不要和 Annotations 里的 XML 混放。我一般会在转完后写一个反向校验脚本,随机挑几张图,把 txt 里的归一化坐标乘以图片宽高后画框保存成预览图,肉眼确认框的位置和水果边缘是否匹配,这比直接开训省时间得多。

4. ImageSets划分、越界检测与训练前验证的最后一道关卡

4.1 用命名管道和shuf生成train.txt与val.txt

VOC 格式下 train.txt、val.txt、trainval.txt 的写法很简单,但不要把标注顺序原样抄进去。训练集必须打乱,否则同一张篮子里的多张相似图会连续喂给模型,导致先验分布不稳定。用下面的命令可以根据 JPEGImages 目录自动生成训练验证划分:

cd VOC2007 # 随机打乱全部图片名,取前 80% 做训练 ls JPEGImages/ | sed 's/\.jpg$//' | sort -R > all_shuffled.txt total=$(wc -l < all_shuffled.txt) train_count=$(( total * 80 / 100 )) head -n $train_count all_shuffled.txt > ImageSets/Main/train.txt tail -n +$((train_count + 1)) all_shuffled.txt > ImageSets/Main/val.txt # trainval 即全部样本,用于模型导出时的最终训练 cat train.txt val.txt > ImageSets/Main/trainval.txt # 清理临时文件 rm all_shuffled.txt

这里sort -R是 GNU 工具里随机排序的命令,不带参数时每次运行结果不同,所以你第二次跑这个脚本会得到一份新的随机划分。如果希望结果可复现,可以用sort -R --random-source=/dev/urandom,或者用 Python 的random.seed(42)做一套固定随机划分。val.txt 的数量不要太小,水果类别多的时候我建议至少保留 20% 作为验证集,避免验证集只有几十张图导致评估结果方差过大。

4.2 标框越界检查:一个不留神模型训练直接崩溃

VOC 格式里最常见的硬伤是越界框,尤其是用脚本批量增强后没有回写<size>导致标注坐标超过图片尺寸,或者是画框时老手一抖把框拖到图片边缘外。这种数据训练时会直接在 loss 计算里出现 NaN,然后整个 batch 梯度爆炸。训练前跑一次全局坐标检查,几秒钟就能把问题全暴露出来:

from pathlib import Path import xml.etree.ElementTree as ET def validate_all_boxes(anno_dir): bad_boxes = [] for xml_file in Path(anno_dir).glob("*.xml"): root = ET.parse(xml_file).getroot() w = int(root.findtext("size/width")) h = int(root.findtext("size/height")) for obj in root.findall("object"): bnd = obj.find("bndbox") x1 = float(bnd.findtext("xmin")) y1 = float(bnd.findtext("ymin")) x2 = float(bnd.findtext("xmax")) y2 = float(bnd.findtext("ymax")) if x1 < 0 or y1 < 0 or x2 > w or y2 > h: bad_boxes.append((xml_file.name, (x1, y1, x2, y2), w, h)) elif x2 <= x1 or y2 <= y1: bad_boxes.append((xml_file.name, "zero area")) return bad_boxes bad = validate_all_boxes("VOC2007/Annotations") print(f"发现 {len(bad)} 个异常框") for item in bad[:15]: print(item)

这里面x2 <= x1的判断容易被漏掉,很多增强工具箱在处理异常框时会把它转成负数面积,训练时 IoU 计算立刻报错。检查脚本要在每次增强、格式转换之后重跑一遍,不能只在标注完跑一次。基本可以当做一个品控流程用。

4.3 训练准备里的三个小技巧,帮你避开常见坑

正式训练前,有几个细节会影响训练结果但不至于直接报错,提前处理好能省去后面反复试错的时间。

第一个技巧是类别名统一用小写英文字母。VOC XML 里的<name>字段可以写中文,但很多检测框架在读取类别时会把它作为类名映射字典的 key,编码处理不当就会出现类别错乱,甚至在输出预测时无法解码。苹果写成apple,不要写Apple,也别写苹果

第二个技巧是训练前先跑一个 5~10 epoch 的 smoke test。把训练 epoch 数临时调小,batch size 调大,模型用随机初始化而不是加载预训练权重,确认 loss 在下降、验证指标不是 0。如果 smoke test 都是在第一个 epoch 结束前崩,大概率是数据路径和类别映射问题,不是模型问题。

第三个技巧是使用预训练模型时注意力保留。YOLO 系列加载 COCO 预训练权重时,最后一层的类别数需要改成本任务类别数加一个背景类,未更改的输出头维度小于标注类别数,训练会在 category 分配时报错;这时需要对模型头部做重新初始化,把类别维度匹配上。代码上不管是 ultralytics 还是 Detectron2 都有现成的 num_classes 参数,只要传对就行。

上面这些检查全部做完后,再用脚本确认一遍 train.txt 和 val.txt 里的每个名字在 Annotations 和 JPEGImages 都存在,就可以开始训练了。这一步比调任何学习率参数都划算,数据集干净了,模型收敛速度才会有可解释性。

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

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

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

立即咨询