☰
从VOC格式到YOLOv8:西瓜数据集目标检测训练全流程踩坑记录
2026/10/4 4:08:51 网站建设 项目流程

简介:这套西瓜目标检测数据集采用Pascal VOC格式组织,共包含一千七百零二张西瓜图片和对应的一千七百零二个XML标注文件,标注类别只有watermelon一种,累计矩形框两千八百一十二个,均由labelImg人工绘制,边界框定位准确。资源包内另有1个txt文件,文件总数三千四百零五个,压缩后大小约167.78MB,整体结构清晰,可直接用于目标检测模型的训练与验证。已有488人学习下载。该数据集面向计算机视觉初学者、算法工程师以及需要西瓜检测样本的科研人员,可作为YOLO、SSD、Faster R-CNN等模型的数据输入,适合课程设计、毕业设计或农业视觉应用原型验证。由于每张图片都配有对应的XML标注信息,无需额外清洗即可使用,可显著节省数据采集和人工标注的时间;同时数据集只提供准确合理的标注,不附带训练权重,便于使用者独立搭建和评估完整的检测流程。

1. 为什么是西瓜数据集、VOC格式,这是目标检测入门最值得先跑通的一条线

很多人的目标检测入门停在装了环境之后:教程用官方示例能跑通,换成自己的数据集就哪哪不对。VOC格式目标检测数据集西瓜数据集-1702张这类数据,正好用来逼着你走完“图片→标注→划分→转格式→训练→评估”的全流程。1702张图,规模中等偏小,训练一轮不至于等到天荒地老,VOC结构又足够暴露出路径、坐标、划分这些常见坑,适合刚跑通官方示例、想换成自己数据集的从业者。本文按我自己的落地习惯,把这个数据集从VOC格式拆开,写出转换脚本、训练配置,再交代我实际踩过的坑。

2. 把VOC格式先拆清楚:目录结构、XML标注和1702张图对应的规模

2.1 目录结构与XML标注字段的含义

标准Pascal VOC的数据集目录长成这样:

VOCdevkit/ └── VOC2007/ ├── JPEGImages/ # 原图,jpg或png ├── Annotations/ # 每张图对应一个xml标注文件 └── ImageSets/ └── Main/ # train.txt / val.txt / test.txt 划分文件

为什么VOC要拆成三个目录,而不是一个目录加一个info文件?因为Pascal VOC把“原图、标注、划分”三个生命周期彻底分开。新增图片时不用动已有XML;切换训练集和验证集时,只改ImageSets/Main里的txt内容,不复制图片;做数据清洗时也只改标注文件。这套设计到今天看依然顺手,所以LabelImg这类标注工具默认导出的就是VOC格式,后续要转成COCO、YOLO或其它格式都有成熟脚本。

Annoations里的XML是VOC格式的核心,字段长这样:

<annotation> <folder>JPEGImages</folder> <filename>watermelon_0001.jpg</filename> <size> <width>1920</width> <height>1080</height> <depth>3</depth> </size> <object> <name>watermelon</name> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>348</xmin> <ymin>220</ymin> <xmax>762</xmax> <ymax>590</ymax> </bndbox> </object> </annotation>

每个字段都有用途:filename决定它和哪张原图对应,实际解析时建议优先用XML文件名而不是filename字段,因为有人改过图名却忘了同步XML;size里的width和height,既是训练时resize的参考,也是后续VOC转YOLO做坐标归一化的分母,这里写错整张图都会出问题;object节点下的name是类别名,bndbox里是目标左上角和右下角的绝对像素坐标。还有一个容易被忽略的difficult字段,difficult=1表示难例,计算mAP时默认会被跳过,清洗数据时最好先处理这类样本,否则转换脚本里容易把难例也带进训练,干扰损失曲线。

XML相比txt的优点是可读性强、自解释,但也带来一个明显问题:单个文件解析慢、文件数量多。1702张图对应1702个XML,手动翻不现实,所以下一步先花两分钟写脚本把整体质量检查一遍,而不是直接转格式去训练。

2.2 标注质量检查:坐标、类别与图片对应关系

我处理任何VOC数据集的第一件事,不是转YOLO,而是统计XML数量和类别分布。脚本如下:

import os import xml.etree.ElementTree as ET from collections import Counter ANN_DIR = 'Annotations' IMG_DIR = 'JPEGImages' xml_names = [f for f in os.listdir(ANN_DIR) if f.endswith('.xml')] img_names = set(os.listdir(IMG_DIR)) print('XML总数:', len(xml_names)) missing_img = [] bad_box = [] cls_counter = Counter() for xml_name in xml_names: tree = ET.parse(os.path.join(ANN_DIR, xml_name)) root = tree.getroot() filename = root.findtext('filename') if filename not in img_names: missing_img.append(xml_name) width = int(root.findtext('size/width')) height = int(root.findtext('size/height')) if width <= 0 or height <= 0: print('尺寸非法:', xml_name) continue for obj in root.findall('object'): name = obj.findtext('name') cls_counter[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 >= xmax or ymin >= ymax: bad_box.append(xml_name) if xmax > width or ymax > height: bad_box.append(xml_name) print('缺失图片的XML:', missing_img) print('非法坐标的XML:', bad_box) print('类别分布:', dict(cls_counter))

逻辑说明:脚本分三条线检查。第一,XML里filename指向的图是否真的在JPEGImages里,缺了就直接列出来。第二,XML里size是否合法,避免后面的归一化除数为零。第三,逐框检查坐标是否自洽、是否超出图片边界。超出边界的情况在VOC里不算少见,尤其用某些标注工具时,框拖着拖着就越界了。

参数说明要按实际调整:如果JPEGImages里既有jpg也有png,脚本里的img_names检查不会出问题,但后面转YOLO时就要同时兼容两种后缀。类别分布这一步尤其重要,如果只想做单一watermelon类别,但XML里出现了Watermelon、water-melon这类变体,就会变成多类任务。我一般建议先统计一遍,确认类别后把名字统一好再往下走。这一步翻车是最不值当的,因为它明明可以在一分钟内发现。

3. 拆训练、验证、测试集:1702张图怎么分最稳

3.1 划分脚本与固定随机种子

目标检测数据集的常规划分比例是8:1:1。1702张图,我预估的规模是训练约1361张、验证约170张、测试约171张。保留独立的test集而不是只留train和val,是因为最终要验证模型在完全没参与训练和调参的数据上的表现,只看val容易过度自信。

划分脚本要固定随机种子,否则每次运行结果都不一样,后续复现和排查都会变成玄学:

import os import random random.seed(42) ids = [os.path.splitext(f)[0] for f in os.listdir('Annotations') if f.endswith('.xml')] random.shuffle(ids) n = len(ids) n_train = int(n * 0.8) n_val = int(n * 0.1) train_ids = ids[:n_train] val_ids = ids[n_train:n_train + n_val] test_ids = ids[n_train + n_val:] os.makedirs('ImageSets/Main', exist_ok=True) def write_list(path, id_list): with open(path, 'w') as fp: for idx in id_list: fp.write(idx + '\n') write_list('ImageSets/Main/train.txt', train_ids) write_list('ImageSets/Main/val.txt', val_ids) write_list('ImageSets/Main/test.txt', test_ids) print('train:', len(train_ids), 'val:', len(val_ids), 'test:', len(test_ids))

逻辑说明:先从Annotations目录拿到所有XML文件名,取不含后缀的id作为划分单位。random.shuffle打乱顺序后按比例切片,写进ImageSets/Main下的三个txt文件。这样做的价值在于,后续YOLO训练时可以用这三个txt快速重组图片和标签,而不用改任何标注文件。

参数说明:random.seed固定为42,只是为了让划分结果可复现,换成任何整数都行,但必须固定。0.8和0.1的分配不是死的,如果1702张图里某一类目标特别少,可以考虑提高train比例到0.85,同时把val压缩到0.1,test保持0.05。但8:1:1在绝大多数中小型数据集上够用,先按这个跑通,不要一上来就搞复杂。

3.2 划分后的核验与类别统计

划分文件写完之后,还要做一个容易偷懒但必须做的核验:确认train.txt里每个id都能找到图片和XML,否则训练时YOLO会疯狂报错或静默跳过样本。核验脚本如下:

import os for split in ['train', 'val', 'test']: with open(f'ImageSets/Main/{split}.txt') as fp: ids = [line.strip() for line in fp if line.strip()] missing = [] for idx in ids: xml_path = f'Annotations/{idx}.xml' img_candidates = [f'JPEGImages/{idx}.jpg', f'JPEGImages/{idx}.png'] if not os.path.exists(xml_path): missing.append(xml_path) if not any(os.path.exists(p) for p in img_candidates): missing.append(idx) print(split, '总数:', len(ids), '缺失项:', missing)

逻辑说明:脚本逐行读划分txt,去掉空行后检查对应的XML和图片是否存在。缺失项为空才可以继续。这里的空行坑特别常见,Windows下编辑txt容易留空行或换行符异常,YOLO读划分文件时会把空行当成一个id去查找,报错信息又长又难懂。

类别统计在划分后也要再做一次,因为我们要确认train、val、test三个集合里的类别分布相近。如果西瓜图片里,训练集几乎全是完整西瓜,验证集却出现大量切开挖瓤的西瓜,模型评估结果会严重失真。判断方法很简单:对三个集合分别跑一遍2.2节的类别统计代码,对比比例。差值超过10个百分点,就要考虑按类别分层抽样,而不是纯随机划分。这一步做完,数据集才算是真的可用,而不是“看起来能用”。

4. VOC转YOLO:转换脚本、目录重组与训练参数

4.1 VOC转YOLO的转换脚本与归一化逻辑

Ultralytics YOLO用的标注格式和VOC完全不一样:每张图对应一个同名txt文件,每一行是一个目标,格式为“类别id x_center y_center width height”,并且这四个坐标值都必须归一化到0到1之间。转换脚本的核心步骤如下:

import os import xml.etree.ElementTree as ET IMG_DIR = 'JPEGImages' ANN_DIR = 'Annotations' LABEL_DIR = 'labels' os.makedirs(LABEL_DIR, exist_ok=True) # 第一遍:扫描所有XML,建立类别名到id的映射 class2id = {} for xml_name in sorted(os.listdir(ANN_DIR)): if not xml_name.endswith('.xml'): continue root = ET.parse(os.path.join(ANN_DIR, xml_name)).getroot() for obj in root.findall('object'): name = obj.findtext('name') if name not in class2id: class2id[name] = len(class2id) print('类别映射:', class2id) # 第二遍:逐XML生成YOLO格式的label文件 for xml_name in sorted(os.listdir(ANN_DIR)): if not xml_name.endswith('.xml'): continue xml_path = os.path.join(ANN_DIR, xml_name) root = ET.parse(xml_path).getroot() filename = root.findtext('filename') img_path = os.path.join(IMG_DIR, filename) if not os.path.exists(img_path): print('缺少图片,跳过:', img_path) continue img_id = os.path.splitext(xml_name)[0] width = int(root.findtext('size/width')) height = int(root.findtext('size/height')) lines = [] for obj in root.findall('object'): if int(obj.findtext('difficult', '0')) == 1: continue name = obj.findtext('name') if name not in class2id: continue 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 >= xmax or ymin >= ymax: print('非法框,跳过:', xml_path) continue x_center = (xmin + xmax) / 2 / width y_center = (ymin + ymax) / 2 / height w = (xmax - xmin) / width h = (ymax - ymin) / height lines.append(f'{class2id[name]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}') out_path = os.path.join(LABEL_DIR, img_id + '.txt') with open(out_path, 'w') as fp: fp.write('\n'.join(lines))

逻辑说明:脚本分两遍处理。第一遍建立类别字典,目的是保证类别id的分配顺序和后续配置文件一致;如果第一遍不扫,直接在转换时边遇到边分配id,两个XML里同一个类名顺序错乱,整个训练就废了。第二遍才是真正的坐标转换,把VOC的绝对像素坐标转成中心点加宽高的归一化坐标。

参数说明有四个容易踩的点。第一,尺寸优先取XML里的size字段,而不是去读图片。读图片更准,但1702张图要全部解码一遍,慢且没必要,只要XML标注本身没问题,size字段就可靠。第二,difficult=1的样本我默认过滤掉,避免难例干扰早期训练,等模型基础能力上来后再考虑要不要加回来。第三,xmin、xmax、width这些值全部转成float再运算,避免整数除法带来的精度丢失。第四,输出格式里保留6位小数,归一化坐标损失精度虽然不大,但在小目标检测场景下会影响边界框回归,6位是实践里比较稳的折中。

4.2 目录重组、data.yaml与Ultralytics训练命令

Ultralytics YOLO的数据集目录要求按images和labels分开放,而且train和val要分开目录。前面VOC结构里的JPEGImages是散装图片,annotations是散装XML,现在要按3.1生成的划分txt重新组织:

mkdir -p datasets/watermelon/{images,labels}/{train,val,test} while read id; do cp "JPEGImages/${id}.jpg" "datasets/watermelon/images/train/${id}.jpg" 2>/dev/null || \ cp "JPEGImages/${id}.png" "datasets/watermelon/images/train/${id}.png" cp "labels/${id}.txt" "datasets/watermelon/labels/train/${id}.txt" done < ImageSets/Main/train.txt

对val.txt和test.txt重复上面命令,把目录名替换成val和test即可。逻辑说明:这个循环会遍历划分文件里的每个id,把对应的图片和label复制到新的数据集目录。cp先尝试jpg存在与否,失败再用png兜底,也就是那条2>/dev/null或语法的作用。1702张图的量级用cp完全没有问题,不需要写多线程。

然后写data.yaml配置:

path: /你的绝对路径/datasets/watermelon train: images/train val: images/val test: images/test nc: 1 names: 0: watermelon

参数说明:path一定要写绝对路径,相对路径在切换工作目录后经常找不到数据,这是Ultralytics系列最常见的报错来源之一。train和val填相对于path的路径。nc是类别数量,names的0号必须和前面转换脚本输出的class2id字典对应,顺序错一个,训练就全乱了。

训练命令我用的是Ultralytics统一接口:

yolo detect train model=yolov8n.pt data=watermelon.yaml epochs=120 imgsz=640 batch=16 workers=4 device=0

参数说明:model用yolov8n.pt起步,n是nano,参数最少、训练最快,适合先验证代码流程;确认没问题后再换yolov8s或更大模型。epochs设为120对1702张图偏多,但配合早停策略问题不大,稳妥做法是先跑50轮看损失曲线是否收敛。imgsz=640是YOLO常见默认输入尺寸,如果西瓜在画面里占比较大,可以尝试512提速;如果目标偏小,可以上768或1024。batch=16需要看显存,显存紧张就降到8。workers=4在Windows上比较稳,Linux可以提到8。device=0表示用第一张GPU,没有GPU就写cpu。

网上搜“yolov8训练自己的数据集”出来的流程,绝大多数就是这三步:整理目录、写data.yaml、跑train命令。如果你用的是更新的Ultralytics版本,训练命令结构基本不变,只是模型权重名换一下,核心流程仍然适用。

5. 避坑记录:VOC转YOLO训练时最容易翻车的四个地方

5.1 图片找不到:训练一启动就报FileNotFoundError或大量样本被静默跳过

现象:训练启动后日志里出现大量image not found之类的警告,训练能跑,但实际参与训练的图片数量远小于预期,整个模型训练完才发现数据不完整。

原因:最常见的两个,第一个是划分txt里写了带后缀的文件名,而VOC的划分习惯是不带后缀;第二个是JPEGImages里图片是JPG大写后缀、划分文件里写的是jpg小写后缀,Linux对大小写敏感,直接找不到。还有一种隐蔽情况是文件名里有空格或中文,Ultralytics的dataloader处理这类路径容易出问题。

解决:把图片文件名统一改成小写、去掉空格,划分txt只写不带后缀的id。改完后重新跑一遍3.2的核验脚本,确保缺失项为空再开始训练。

5.2 坐标与图片尺寸不一致,导致训练出来的框偏大、中心点跑偏

现象:损失值正常下降,验证集mAP看起来也不差,但把模型拿到真实图片上推理,检测框总是比目标大一圈,或者中心点明显偏离目标中心。

原因:XML里的size字段和实际图片分辨率不一致。例如标注工具把图片缩放后标注,但XML里的size仍写着原图尺寸;或者某张图被旋转、裁切过,XML坐标没有同步更新。转换脚本以size为准做归一化,size错了,归一化坐标就和真实像素位置错位。

解决:在检查脚本中增加越界检测,坐标超出图片范围就打印XML路径,人工修正。这里有一个值得注意的细节:允许5像素的容差,因为浮点转换和resize过程有少量误差;超过5像素就必须改XML,不能只过滤跳过,否则模型学到的目标位置是偏移的。

5.3 val集mAP虚高,但实际新场景识别率崩了:图片划分“串数据”

现象:验证集mAP能到0.9以上,放几张完全没见过的现场照片,漏检严重。

原因:1702张图如果来自连拍视频帧或同一场景的多角度拍摄,纯随机划分会把同一个西瓜、同一个光照环境的近似画面同时分到训练集和验证集。模型在训练阶段已经“背过”这些画面的特征,验证集分数自然虚高,一到真实场景就打回原形。

解决:按场景分组划分,而不是按单张图片划分。判断场景分组的常见做法是看文件名前缀,比如日期、相机编号、拍摄批次。

import os import random groups = {} for f in os.listdir('Annotations'): if f.endswith('.xml'): prefix = f.split('_')[0] groups.setdefault(prefix, []).append(os.path.splitext(f)[0]) random.seed(42) group_ids = list(groups.keys()) random.shuffle(group_ids) n_groups = len(group_ids) n_train = int(n_groups * 0.8) n_val = int(n_groups * 0.1) train_ids = [idx for g in group_ids[:n_train] for idx in groups[g]] val_ids = [idx for g in group_ids[n_train:n_train + n_val] for idx in groups[g]] test_ids = [idx for g in group_ids[n_train + n_val:] for idx in groups[g]]

逻辑说明:把同一前缀的图片当成一个组,按组划分,保证同一个场景不会同时出现在训练集和验证集。这样val的mAP才代表真实泛化能力。参数说明:前缀取几位要根据实际文件名定,可以先打印文件名分布再决定,如果你发现前缀粒度太粗或太细,就调整split('_')[0]的取值。

5.4 batch、workers和resume使用不当,造成显存溢出或训练震荡

现象:一训练就报CUDA out of memory;或者中断后续训,loss却比中断前更高。

原因:batch设得过大,超出显存;workers设得过大,在Windows小数据集上频繁卡顿甚至崩溃;中断后续训不传resume=True,优化器状态没恢复,学习率也从初始值重新开始,导致损失曲线震荡。

解决:batch从8开始试,显存占用稳定后再往上加;workers在Windows下用4,Linux小数据集用4或8。中断后一定要用yolo detect train resume=True续训。这个坑看上去很基础,但连做过多轮数据集训练的人也会偶尔犯。

6. 训练完先别急着部署:用混淆矩阵、置信度和resize验证模型真实水平

6.1 用官方验证接口看mAP之外的指标

训练结束后,Ultralytics会在runs/detect/train目录下生成混淆矩阵、P-R曲线、验证集预测图等文件。但很多人只看mAP数字就结束了。我的习惯是再跑一遍验证接口,手动打印更多指标:

from ultralytics import YOLO model = YOLO('runs/detect/train/weights/best.pt') metrics = model.val(data='watermelon.yaml', split='val') print('mAP50:', metrics.box.map50) print('mAP50-95:', metrics.box.map)

逻辑说明:这段代码会重新对val集做一次完整评估。对单类别数据集来说,混淆矩阵会退化成二分类问题,mAP的参考价值反而有限,更关键的是看precision和recall分别掉在哪一侧。如果precision低,说明误检多,推理时把conf调高;如果recall低,说明漏检多,要么降低conf,要么把imgsz调大以增强对小目标的感知。

6.2 对我的意义:第一遍永远跑小模型小轮次

我处理这类中小型VOC数据集有个固定习惯:拿到手先不直接上大模型,而是用yolov8n、只跑50轮,把从数据到评估的整条流水线完整跑一遍。确认没有路径问题、没有标注错位、没有划分串数据之后,再加大模型、加轮次、调增强参数。这个方法帮我挡住了至少一半的翻车事故。批量调参前,先手动跑几张现场图,改一次conf只看一次效果,才能知道模型真正的边界在哪里。

希望这些经验对你有帮助。

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

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

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

立即咨询