资讯动态

VOC转YOLO:241张牛检测数据集的格式检查、转换与训练避坑

发布时间:2026/9/28 9:18:07 来源:尧图企业网站定制
简介这是一份面向目标检测练习与模型训练的牛类图像数据集包含241张不同姿态的牛只照片同时提供Pascal VOC和YOLO两种主流标注格式可直接加载至常见检测框架训练也适合初学者对比两种格式的标注差异。资源共725个文件主要有241张JPG原图、241个XML标注文件和243个TXT标注文件压缩包84.43MB标注类别仅Cattle一类共286个矩形框由labelImg绘制边界框标注规范。目前已有102位学习者浏览使用这类小样本单类别数据集非常适合快速验证YOLO系列、SSD等算法或用于课程设计与调试输出。下载后既省去重复标注的时间又能通过同图不同标注文件的对应关系理解目标框从像素坐标到归一化坐标的转换对后续自建数据及迁移学习有直接帮助。1. 一份241张的牛检测数据集为何同时提供VOC格式和YOLO格式你刚下载了一个牛数据集压缩包解压后看到VOC格式和YOLO格式两个文件夹241张图片1个类别。别急着把zip丢给训练脚本这两份格式是不是同一套标注、文件夹结构有没有缺东西往往决定了后续训练是顺利收敛还是反复翻车。这份落地笔记不讨论抽象概念而是直接带你做一遍如何检查压缩包里的VOC格式和YOLO格式如何用脚本把VOC转换成可训练的YOLO格式如何划分只有241张的单类别数据以及在真实工位上看哪些坑最容易踩。适合手里有小样本数据集、想快速试出检测效果的人也适合刚入目标检测、想彻底搞懂两种标注格式差在哪的读者。2. 两种格式摊开看VOC的xml和YOLO的txt分别记了什么2.1 解压一份牛数据集后你先看到的大目录结构以我处理过的牛检测数据集为例解压后通常能看到这样的骨架不同打包者会有细微出入cow_dataset/ ├── voc_format/ │ ├── JPEGImages/ # 241 张图片 │ ├── Annotations/ # 241 个 xml │ └── ImageSets/Main/ # train.txt / val.txt有些包没有 └── yolo_format/ ├── images/ # 与 JPEGImages 内容相同的副本 ├── labels/ # 241 个 txt └── classes.txt # 记录类别名cowJPEGImages里是原始图片Annotations里是每张图片对应的VOC标注文件。ImageSets/Main是可选的训练验证划分列表很多打包者会漏掉。yolo_format/images和labels是YOLO系列的输入形态classes.txt则记录了当前数据集的类别顺序。两份格式同时提供通常是为了兼容不同工具链VOC格式用于LabelImg等标注工具以及Pascal VOC评估脚本YOLO格式直接喂给YOLOv5这类训练框架。但这种“双轨提供”恰恰是隐患的开始因为两份标注不一定来自同一次导出。VOC和YOLO的核心字段可以这样对照维度VOC格式YOLO格式文件后缀.xml.txt坐标体系像素左上角/右下角归一化中心点宽高类别名字符串如cow整数ID如0一张图多目标多个object节点每行一个目标是否含图片尺寸xml里通常有size完全不含这种差异意味着你没法用记事本对着一份txt判断它标注得对不对必须先把两种格式对齐。我见过不少下载者只看yolo_format以为有txt就能训练忽略了voc_format里的xml才是纠错时唯一可靠的信息源。如果你解压后根本没有voc_format只有yolo格式那第一步就不是训练而是先把每张图和对应的txt画框人工审核一遍。好在标题里写明同时提供两种格式所以我们有机会按更稳妥的流程走。2.2 从坐标到归一化一个牛框在两种格式里的数学表达假设一张图片宽1280、高720一头牛被标在像素坐标xmin300, ymin200, xmax700, ymax650。VOC的xml会写成bndbox xmin300/xmin ymin200/ymin xmax700/xmax ymax650/ymax /bndboxYOLO要求的是“类别 中心点x 中心点y 宽w 高h”全部除以图片宽高归一化。换算逻辑是这样的# 一个 VOC - YOLO 的手工换算示例 img_w, img_h 1280, 720 xmin, ymin, xmax, ymax 300, 200, 700, 650 # 中心点坐标 cx ((xmin xmax) / 2) / img_w cy ((ymin ymax) / 2) / img_h # 目标框宽高注意是绝对像素宽高除以图片宽高 w (xmax - xmin) / img_w h (ymax - ymin) / img_h print(f0 {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}) # 输出0 0.390625 0.590278 0.312500 0.625000这段代码里cx和cy都在0到1之间表示牛框中心点在图片中的相对位置。YOLO用归一化坐标的好处是图片无论缩放成640还是1280标注里的相对位置不变训练时省去坐标换算。但这也带来一个常见错误——很多人直接把xmin/xmax除一下当w忘了先算中心点结果画出来的框整体偏移。我在真实项目里见过有人把xmax和width混淆写了个公式 w xmax/img_w结果框宽度变成中心点坐标跑出来的模型每个框都向右偏移。所以这种手工换算虽然简单也不要跳过验证。2.3 为什么我默认把VOC当“母本”而不是直接信任自带的YOLO很多数据包自带的YOLO标注是从别的格式导出的导出工具不同ID编号规则就不同。有的工具从1开始编号有的从0开始有的把类别按字母序排有的按标注先后排。如果直接拿别人的YOLO格式训练classes.txt第一行是cow可能实际ID却是1训练时YOLO会直接报class index超范围。反过来VOC的xml里保留了完整的类别名字符串和坐标绝对值信息量更大。即使自带YOLO格式我也会用VOC重新生成一份YOLO格式再和自带的比对。这就是我处理牛数据集的基本流程先确认VOC结构完整再写脚本批量转换转换后统一做一次对齐校验。下面这章就是完整的转换脚本和校验步骤。另外xml里的size字段虽然写了宽高但我很少直接信它。因为部分标注工具在保存前修改过图像分辨率却没有同步更新size。如果你用size做归一化分母而实际图片已经被压缩所有框都会按错误比例缩放。这是为什么我的转换脚本坚持用PIL读取图片尺寸的根本原因。3. 用脚本把VOC格式批量转成YOLO格式241张转换与对齐校验3.1 转换脚本从xml读取框、读取图片尺寸、写txt这里给一个我在单类别检测中经常用的转换脚本它不依赖框架只用Python标准库加Pillow。它能处理xml解析失败、图片名不匹配、坐标越界等小问题跑完以后你得到的是一个干净的YOLO目录。import xml.etree.ElementTree as ET from pathlib import Path from PIL import Image def convert_voc_to_yolo(voc_root, yolo_root, class_names): voc_root Path(voc_root) yolo_root Path(yolo_root) ann_dir voc_root / Annotations img_dir voc_root / JPEGImages out_img_dir yolo_root / images out_label_dir yolo_root / labels out_img_dir.mkdir(parentsTrue, exist_okTrue) out_label_dir.mkdir(parentsTrue, exist_okTrue) class_map {name: idx for idx, name in enumerate(class_names)} (yolo_root / classes.txt).write_text( \n.join(class_names), encodingutf-8 ) for xml_path in sorted(ann_dir.glob(*.xml)): try: tree ET.parse(xml_path) root tree.getroot() except ET.ParseError as e: print(fXML解析失败: {xml_path} - {e}) continue filename root.findtext(filename) if not filename: filename xml_path.stem .jpg img_path img_dir / filename if not img_path.exists(): print(f图片不存在: {img_path}) continue # 从图片文件读取真实宽高不要只信 xml 里的 size try: with Image.open(img_path) as img: img_w, img_h img.size except Exception as e: print(f读取图片失败: {img_path} - {e}) continue # 复制图片到 yolo 目录保持后续训练目录自包含 (out_img_dir / filename).write_bytes(img_path.read_bytes()) lines [] for obj in root.findall(object): name obj.findtext(name) if name is None: continue name name.strip() if name ! class_names[0]: # 单类别数据集只保留牛多类别时改成 if name not in class_map 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)) # 有些标注工具存的顶点顺序是反的统一处理成 min/max xmin, xmax min(xmin, xmax), max(xmin, xmax) ymin, ymax min(ymin, ymax), max(ymin, ymax) # 归一化到 [0,1]越界的地方截断防止训练早期 loss 爆炸 x_center min(max(((xmin xmax) / 2) / img_w, 0.0), 1.0) y_center min(max(((ymin ymax) / 2) / img_h, 0.0), 1.0) w min(max((xmax - xmin) / img_w, 0.0), 1.0) h min(max((ymax - ymin) / img_h, 0.0), 1.0) lines.append( f{class_map[name]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f} ) label_path out_label_dir / (xml_path.stem .txt) label_path.write_text(\n.join(lines), encodingutf-8) print(f已转换: {xml_path.name} - {label_path.name}, f{len(lines)} 个标注) if __name__ __main__: convert_voc_to_yolo( voc_root./cow_dataset/voc_format, yolo_root./cow_dataset/yolo_generated, class_names[cow] )这段脚本的核心步骤是解析xml找到filename字段用Pillow打开对应图片拿到真实宽高然后遍历每个object节点取出bndbox的四个像素坐标做一次顶点顺序纠正再归一化。参数层面class_names列表的索引就是最终的YOLO类别ID所以顺序不能随便写单类别时class_names[cow]此时ID必定是0。如果你以后扩成多类别要把脚本里的if name ! class_names[0]改成if name not in class_map: continue这样不在类别表里的对象会自动跳过。有两点值得说明第一我没有直接使用xml里的size标签而是强制从图片文件读取宽高因为标注工具在图片旋转、压缩后经常把宽高写错训练时用错尺寸会导致所有框挪位。第二复制图片用write_bytes而不是shutil.copy是为了绕开某些Windows路径下因中文目录名带来的编码问题如果你在Linux下可以换成shutil.copy2。3.2 转换后立即做的对齐检查图片数、标注行数、坐标范围转换脚本跑完第一件要做的事是数一下images和labels目录下的文件数正常应该都是241。这一步能快速暴露xml与jpg名称对不上、部分xml没标注等问题。我用另一个小脚本做检查from pathlib import Path yolo_root Path(./cow_dataset/yolo_generated) images {p.stem: p for p in (yolo_root / images).glob(*)} labels {p.stem: p for p in (yolo_root / labels).glob(*.txt)} print(f图片数: {len(images)} 标注数: {len(labels)}) # 图片有而标注没有说明 xml 可能缺失或文件名不匹配 missing_label [name for name in images if name not in labels] # 标注有而图片没有说明 xml 里 filename 对不上实际文件 missing_image [name for name in labels if name not in images] print(缺标注的图片:, missing_label) print(缺图片的标注:, missing_image) # 逐行检查 YOLO txt 的字段个数和坐标区间 for name, label_file in labels.items(): for i, line in enumerate(label_file.read_text().splitlines(), 1): parts line.split() if len(parts) ! 5: print(f{name}.txt 第{i}行字段数异常: {line}) continue class_id, cx, cy, w, h parts try: cx, cy, w, h float(cx), float(cy), float(w), float(h) except ValueError: print(f{name}.txt 第{i}行无法转float: {line}) continue if not (0 cx 1 and 0 cy 1 and 0 w 1 and 0 h 1): print(f{name}.txt 第{i}行坐标越界: {line}) # 单类别数据所有第一列都应该是 0 bad_cls 0 for label_file in labels.values(): for line in label_file.read_text().splitlines(): if line.split()[0] ! 0: bad_cls 1 print(f类别ID不为0的行数: {bad_cls})如果你跑完以后missing_label不是空列表不要着急往下训练。先回到voc_format/Annotations看那样图片有没有对应的xml如果有多半是脚本没匹配上重点查filename字段是不是带了./前缀。如果坐标越界行比较多说明原始VOC框本身有问题需要回到标注工具里修正靠转换脚本截断只是黑匣子补救不会提升模型精度。我一直建议这类检查脚本要保留到训练前再跑一次因为你不一定每份数据都手动确认过。3.3 与自带YOLO格式做一次差比确认两份标注是否同源很多数据包自带yolo_format我用生成的yolo_generated和它逐文件比对行数。如果行数一致再抽样画几个框看看坐标是否一样。一个简单粗暴的比对方式是用bash的diffdiff (cd yolo_format/labels cat *.txt) \ (cd yolo_generated/labels cat *.txt)如果输出为空说明两份相同之后训练用哪份都行。如果输出了一堆差异就用vimdiff yolo_format/labels/cow_001.txt yolo_generated/labels/cow_001.txt看具体坐标。出现差异很正常尤其是当自带的yolo格式是经过图片缩放生成的而VOC的xml对应原图时两者坐标天然不同。这种时候我选择相信由VOC直接生成的那份因为它对应的图片尺寸是脚本实时读取的不是某个中间步骤遗漏的旧值。到这里干净的数据集已经准备好了。下一章要面对一个更现实的问题241张图1个类别怎么划分、怎么配置训练参数才不会让小数据集的脾气毁掉之前的所有准备。4. 把241张单类别数据交给YOLO之前划分、配置与训练策略4.1 数据集划分为什么不直接全部丢给训练241张图片对于目标检测来说是非常小的规模。常见做法是拿出80%当训练集、20%当验证集也就是193张训练、48张验证然后固定随机种子保证每次复现结果一样。我一般不会把验证集的比例再调高因为训练本身数据就不够但也不会低于10%否则验证指标不可信。代码可以这样写import random from pathlib import Path from sklearn.model_selection import train_test_split yolo_root Path(./cow_dataset/yolo_generated) image_files sorted((yolo_root / images).glob(*)) train_files, val_files train_test_split( image_files, test_size0.2, random_state42, shuffleTrue ) (yolo_root / train.txt).write_text( \n.join(str(p.resolve()) for p in train_files), encodingutf-8 ) (yolo_root / val.txt).write_text( \n.join(str(p.resolve()) for p in val_files), encodingutf-8 ) print(ftrain{len(train_files)} val{len(val_files)})如果你不喜欢依赖sklearn也可以用纯随机方式random.seed(42) random.shuffle(image_files) val_count int(len(image_files) * 0.2) train_files image_files[val_count:] val_files image_files[:val_count]这两段代码按照图片粒度随机抽样而不是按照目标框粒度抽样。这意味着同一头牛如果出现在多张图片里这些图片可能同时落在训练集和验证集。对于常规检测任务这不算数据泄漏因为你关注的是“能否在未见过的图像里找到牛”而不是“能否识别同一头牛的个体身份”。如果牛数据集里包含多张连拍帧比如同一场景间隔几秒拍的那建议把同一情景的图片归到同一个集合这需要额外的人工目录信息241张图的量级可以手动排一下。4.2 data.yaml 该怎么写路径、类别数、类别名YOLO系列训练需要一份data.yaml它描述了数据集在哪里、有几个类别、类别名是什么。结构非常简单# cow.yaml train: ./cow_dataset/yolo_generated/train.txt val: ./cow_dataset/yolo_generated/val.txt nc: 1 names: [cow]这里的train和val指向我们在4.1生成的txt文件每一行是一张图片的绝对路径。YOLO会从与图片同名的.txt文件中读取标签。如果你使用的是纯目录形式让YOLO自己扫描图片那么train和val不能指向同一个目录否则训练集和验证集会重叠需要先把图片按划分放到train_images和val_images两个目录。nc表示类别数量当前只有牛所以是1names列表里的第一个字符串cow必须和转换脚本里的class_names完全一致否则日志里的类别名显示会错位。4.3 训练参数怎么设mosaic、batch、epochs小数据集最怕两件事一是不收敛二是收敛之后过拟合。以YOLOv5官方命令行为例我通常这样起训练python train.py \ --data cow.yaml \ --weights yolov5s.pt \ --img 640 \ --batch-size 16 \ --epochs 100 \ --patience 20 \ --mosaic 0.5 \ --hyp data/hyps/hyp.scratch-low.yaml每个参数都有存在的必要性。--weights yolov5s.pt表示加载官方预训练权重这是小数据的保命底牌能够把COCO上学会的通用目标表示迁移到牛身上。--batch-size 16不需要调太大241张图就算全加起来也填不满一张高端显卡的显存调大了反而让每个epoch的梯度更新次数变少。--mosaic 0.5是我刻意压低的数值mosaic增强会把四张图拼接成一张单类别牛的目标通常是大而完整拼接后框容易被截断产生标注错位如果训练早期loss波动明显我会直接把它设成0。--hyp指定低增强的超参数文件避免过度的色彩抖动让模型记住背景。如果训练中mAP一直上不去先检查训练日志里的img_size是否和标注分辨率差别过大。有些牛图片是高清的缩放到640后小牛可能只剩20像素此时要么把输入分辨率提高到960要么先裁剪成子图再做训练。我见过很多翻车案例都是有人为了省显存强行把--img 320然后抱怨模型什么都检测不到。4.4 评估一个单类别模型只看mAP和PR曲线就够了吗训练完第一波别急着拿出去用。单类别检测的验证输出很简洁通常长这样Class Images Instances P R mAP.5 mAP.5:.95 cow 48 120 0.92 0.88 0.94 0.72这里Instances表示验证集里真值框的数量。你需要重点核对这个数字是否合理如果一张图里有5头牛验证集48张总实例可能上百如果你的Instances只有20说明标注严重漏标。P和R分别体现查准率和查全率单类别牛检测里我更关心R因为漏检一头牛的代价往往比误检一个背景高。mAP.5是IoU阈值0.5下的平均精度对单类别来说就是那一类的APmAP.5:.95是更严苛的指标数值比前者低一大截很正常。如果你想让这241张数据产生更强泛化能力可以在验证时启用测试时增强TTA或者用不同的输入分辨率各验证一次看mAP对尺度的敏感度。不过这些都是后话先把训练跑通、把评估指标看明白再考虑进阶调优。5. 避坑从解压到训练241张牛数据集的5个常见坑5.1 数据规模与格式层面的翻车现场坑1VOC和YOLO两份标注对不上 现象用脚本把VOC转成txt之后和压缩包自带的YOLO格式逐行比对发现同一个文件名下的框数量不一样坐标也差很多。 原因很多数据包里的VOC格式和YOLO格式不是互相导出的而是分别由不同工具或不同标注人员生成。甚至有的打包者把图片缩放后只更新了YOLO没更新VOC导致VOC坐标还是原始分辨率。 解决以VOC为母本重新生成YOLO格式然后把自带的yolo_format目录改名留作备份。抽样画三张图把生成框和自带框同时画上去人工比对确认以后再进入训练。这一步是最花时间的但值得。坑2XML里的filename和实际图片文件名不匹配 现象转换脚本提示图片不存在: ...但打开JPEGImages目录图片明明就在那里。 原因xml的filename字段可能写成了cow_001.JPG而实际文件是cow_001.jpg也可能写成cow_001.jpeg但文件是png。还可能是xml里带了./前缀拼接路径时成了双斜杠。 解决在脚本里加兜底逻辑优先读filename找不到就用xml_path.stem .jpg再找仍然找不到就扫描整个JPEGImages目录按stem匹配。我在3.1的脚本里只做了第一条兜底实际生产环境建议把三种都写上。这种问题通常影响5到10张图不处理的话模型会在可用数据上再减几笔。坑3坐标越界导致训练早期出现NaN 现象训练刚开始loss打印出来是nan或者前几个epoch的mAP全为0甚至直接报错RuntimeError: CUDA error。 原因XML里的xmax或ymax大于图片宽高导致归一化后w或h超过1IoU计算时出现负面积也有可能是xmin比xmax大标注人把两个点选反了。 解决转换脚本做顶点顺序纠正和min(max(...), 1.0)限幅这是防止NaN的兜底操作。但兜底不等于修好训练前把越界行打印出来回到对应的xml人工确认如果越界框只是超出几个像素可以压回边界如果超出三分之一说明这框标得本身就不对直接删掉可能更安全。5.2 训练与数据分布层面的坑坑4自带的YOLO格式类别ID从1开始训练直接报错 现象训练加载数据时报AssertionError: class index 1 is out of range (0 - 0)或者日志里出现大量WARNING: corrupt label。 原因有的标注工具类别ID从1开始编号而YOLO训练要求从0开始。压缩包里的classes.txt写着cow但txt文件第一列是1对nc1的数据集来说这就是越界。 解决写一个快速统计脚本检查所有txt首列数字是否都等于0。如果是1转换脚本里class_map改成从1开始或者对已有txt批量减1。注意不要只改classes.txt因为YOLO加载标签时不会重新映射ID只按txt里的数字索引类别。这个坑几乎每个做单类别数据集的人都会踩一次所以我在第3章的检查脚本里特意加了第一列校验。坑5241张里的牛目标尺度过小训练后查全率上不去 现象验证集的R值明显低于P值比如P0.95R只有0.6。看检测结果图远处的牛没有被框出来近处的大牛都框得很好。 原因数据集中大部分标注框的面积占图片比例很小缩放到640分辨率后小目标可能只剩十几个像素。YOLO在小数据集上对小目标非常敏感尤其当背景复杂时模型更容易把牛当作背景纹理。 解决先统计所有txt里w和h的分布如果大部分w0.2说明目标整体偏小建议训练时把输入分辨率改成960或者将图片切分成四块分别训练。另一个实用做法是关闭mosaic增强因为它把小目标拼得更小还可以用--hsv_h 0.0之类的参数减少颜色扰动确保模型专注学习牛的轮廓。如果你愿意多花时间还可以对包含小牛的图片做过采样也就是让它们出现在每个epoch的概率更高。提示这里说的负样本问题是单类别检测的另一个隐藏坑。如果你想让模型学会区分“没有牛”的背景可以直接往训练集里塞一些不含牛的图片并生成空txt。YOLO训练时会跳过空标签的图片所以还需要配合自定义采样逻辑否则负样本不参与训练。这个进阶操作建议在跑通第一版模型之后再尝试。6. 最后用预训练权重和交叉验证把这241张牛数据榨出更多泛化能力数据集只有241张单次随机划分的验证结果运气成分很大。我建议在第4章的划分之外再做一次5折交叉验证把241张平均分成5份每次留其中1份验证其余4份训练训练5次后取mAP的平均值和方差。这样你能看到模型在数据扰动下的稳定性而不是被某一次糟糕划分骗到。如果5折的mAP0.5都在0.8以上这份数据就值得继续投入。迁移学习也有更细的玩法。第一轮用预训练权重并冻结backbone只训练head部分20轮这时模型会把牛的轮廓和COCO里的大牲畜特征做初步匹配第二轮解冻全部层使用第一轮得到的权重继续训练40轮。解冻时把学习率调低一个数量级避免破坏已经学到的特征。YOLOv5里可以通过--freeze 10冻结前10层再结合--cos-lr做余弦退火。如果你发现验证集mAP稳步上升但训练集mAP已经接近1说明开始过拟合可以提前停止。我自己在这个牛数据集上犯过一个错为了省时间直接用自带的yolo格式训练没做检查和重转结果训练到第40轮时mAP卡在0.4上不去。后来画框检查才发现那批自带标注有三分之一是旧的框偏移了整整一个牛身。从那以后我养成了每个新数据集必须跑转换脚本和检查脚本的习惯宁可在数据准备上多花半小时也不愿在训练上浪费半天。希望这个流程帮你少走一点弯路吧。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑