☰
VOC/COCO标注转YOLO格式:Python脚本助你搞定数据集划分与训练准备
2026/10/7 1:45:19 网站建设 项目流程

简介:这套Python脚本专为目标检测与YOLO系列模型的数据准备阶段设计,核心功能包括自动划分训练集和测试集,以及将COCO、VOC等常见标注格式批量转换为YOLO系列所需的标签格式。脚本全部采用Python编写,压缩包内共2个文件,整体体积仅2KB,精简且易于修改,适合具备一定编程基础的学生、工作一至三年的研发人员、科研工作者以及想要入门人工智能的爱好者直接复用。资源中包含数据划分和格式转换两类脚本,经过大量真实项目实践验证,无Bug且稳定性好,能够快速生成结构规范的数据集目录,并直接衔接YOLOv5等主流训练流程,有效避免因标注格式不匹配带来的调试麻烦,大幅提升数据准备效率。目前已有3604人学习下载,对于需要快速出效果、节省时间的计算机视觉项目,这套轻量脚本是一个相当实用的辅助工具。

1. 一个python脚本搞定格式转换与数据集划分:做YOLO训练前最该先解决的问题

你从网上下载了一个带标注的车辆检测数据集,解压开发现是满满一文件夹的XML;另一批同事交接的标注是COCO格式的json;而你手头要跑的是yolov5或yolov8训练自己的数据集,它只认txt标签文件。三种格式互相不认,你又不打算把几千个框重新标一遍——这正是标题里那个python脚本要解决的场景:先把VOC、COCO的标注翻译成YOLO系列数据,再按固定比例把图片连同标签一起划分成训练集、验证集和测试集。这篇笔记就是把我自己用的这套流程拆开讲清楚,从坐标怎么换算、脚本怎么写,到划分时随机种子和目录结构怎么设,最后附上验证脚本和踩坑记录。

2. 三种标注格式的坐标语义:看懂XML、JSON和txt再动手写转换

很多人拿到数据先急着写脚本,结果转换出来的标签要么全在图片左上角,要么类别全部错位。问题不是出在代码,而是没搞明白三种格式描述“一个框”的方式根本不一样。先把坐标语义对齐,脚本只是机械翻译。

2.1 YOLO的txt为什么用归一化中心坐标

YOLO系列的标签文件是txt,每一行对应一个目标框,格式固定为五个数字:

<class_id> <x_center> <y_center> <width> <height>

class_id是整数,从0开始编号;后面四个都是浮点数,并且全部除以图片宽高做了归一化。例如0 0.501953 0.437500 0.064453 0.109375,意思就是类别0的框,中心点位于图片的50.2%宽、43.8%高处,框宽占图片的6.4%,框高占10.9%。

归一化不是YOLO拍脑袋定的。训练时图片会被resize或letterbox到统一尺寸,比如640×640,如果标签存的是绝对像素,缩放后所有框的位置就全错了;归一化坐标只描述相对位置,图片怎么缩放都能还原出正确的框。这也是为什么转换脚本里除以的是XML或JSON里记录的图片宽高,而不是你随便拿一张图另算的尺寸。

下面这段代码演示了换算公式,也是后面两个转换脚本的核心:

# 从 VOC / COCO 的像素坐标换算到 YOLO 归一化坐标 img_w, img_h = 1280, 720 # VOC 给的是左上角和右下角 x_min, y_min, x_max, y_max = 432, 180, 640, 320 # COCO 给的是左上角 + 宽高 x, y, w, h = 432, 180, 208, 140 # VOC 换算 cx_voc = (x_min + x_max) / 2.0 / img_w cy_voc = (y_min + y_max) / 2.0 / img_h bw_voc = (x_max - x_min) / img_w bh_voc = (y_max - y_min) / img_h # COCO 换算,注意宽高已经直接给出 cx_coco = (x + w / 2.0) / img_w cy_coco = (y + h / 2.0) / img_h bw_coco = w / img_w bh_coco = h / img_h

这两段代码算出来的cx、cy、bw、bh应该完全一致。VOC的(x_max - x_min)就是COCO的w,VOC的(x_min + x_max)/2和COCO的(x + w/2)是同一个中心点。转换的本质就是这一个公式,脚本再长也逃不出这个范围。

2.2 VOC和COCO的框描述方式:从绝对像素到相对坐标的换算表

VOC格式最常见的载体是Pascal VOC,每个图片对应一个XML文件。XML里<size>节点记录图片宽高,每个<object>节点下有一个<name>和一个<bndbox>,框是左上角xmin/ymin和右下角xmax/ymax的绝对像素值,比如:

<annotation> <filename>000001.jpg</filename> <size> <width>1280</width> <height>720</height> </size> <object> <name>car</name> <bndbox> <xmin>432</xmin> <ymin>180</ymin> <xmax>640</xmax> <ymax>320</ymax> </bndbox> </object> </annotation>

COCO格式则不同,它把所有标注放在一个json文件里,由images、annotations、categories三个数组组成。images数组里是图片id、文件名、宽高;annotations数组里每条标注通过image_id关联到图片,bbox字段给的是[x, y, width, height],也就是左上角坐标加宽高;categories数组负责把类别id映射到类别名。关键差异在于,COCO的category_id不保证连续,常见的是1、3、7这样跳着的,转YOLO时必须重映射成0、1、2,否则训练时类别数量对不上。一个典型的COCO json如下:

{ "images": [ {"id": 1, "file_name": "000001.jpg", "width": 1280, "height": 720} ], "annotations": [ {"id": 1, "image_id": 1, "category_id": 3, "bbox": [432, 180, 208, 140]} ], "categories": [ {"id": 1, "name": "person"}, {"id": 3, "name": "car"} ] }

把三种格式放在一起对比,差异就非常清楚了:

格式框的表示坐标含义类别表示是否归一化
VOC XMLbndboxxmin, ymin, xmax, ymax(绝对像素)name字符串否
COCO JSONbbox数组x, y, width, height(绝对像素)category_id整数(会跳号)否
YOLO txt行内5个数cx, cy, bw, bh(相对图片宽高)class_id整数(从0连续)是

还有一个细节容易被忽略:VOC的<filename>字段有时只有文件名没有路径,而COCO的file_name可能带子目录前缀。转换脚本输出的txt应该用不含路径的纯文件名,后面划分数据集时靠文件名一一对应,路径越简单越省事。

3. VOC和COCO转YOLO系列数据:两个脚本一次讲透

搞懂了坐标语义,转换脚本其实就是套公式。但实际项目里遇到的XML和json没有教科书那么规整:有的XML里带difficult=1的难例,有的COCO标注框宽高是0,有的类别名不在你的名单里。这一章给出我自己在用的两个脚本,把边界情况一并处理掉。

3.1 VOC转YOLO:解析XML并生成归一化txt

VOC转YOLO的常见做法是用Python标准库xml.etree.ElementTree逐文件解析。脚本对所有XML做四件事:读尺寸、遍历<object>、换算坐标、写txt。需要特别说明的是,<size>里记录的宽高才是换算分母,不是好人像PIL另读图片尺寸——标注时图片可能被压缩过,XML才是标注时的真实参照。

import os import xml.etree.ElementTree as ET def voc2yolo(xml_dir: str, save_dir: str, class_names: list) -> int: """ 把 VOC XML 批量转成 YOLO txt。 xml_dir : 存放 .xml 的目录 save_dir : 输出 .txt 的目录,不存在会自动创建 class_names : 类别名列表,顺序就是 YOLO 的 class_id 返回值 : 成功转换的 xml 文件数 """ os.makedirs(save_dir, exist_ok=True) count = 0 for xml_name in os.listdir(xml_dir): if not xml_name.lower().endswith(".xml"): continue xml_path = os.path.join(xml_dir, xml_name) tree = ET.parse(xml_path) root = tree.getroot() # 图片宽高优先读 <size> 节点 size = root.find("size") img_w = int(size.find("width").text) img_h = int(size.find("height").text) lines = [] for obj in root.findall("object"): # VOC 里 difficult=1 的样本是难例,默认跳过 difficult = obj.find("difficult") if difficult is not None and difficult.text.strip() == "1": continue name = obj.find("name").text if name not in class_names: print(f"[warn] {xml_name} 里出现未登记的类别 {name},已跳过") continue box = obj.find("bndbox") x_min = float(box.find("xmin").text) y_min = float(box.find("ymin").text) x_max = float(box.find("xmax").text) y_max = float(box.find("ymax").text) # 绝对像素坐标换算成归一化中心坐标 cx = (x_min + x_max) / 2.0 / img_w cy = (y_min + y_max) / 2.0 / img_h bw = (x_max - x_min) / img_w bh = (y_max - y_min) / img_h class_id = class_names.index(name) # 保留 6 位小数,避免小数位过长导致文件膨胀 lines.append(f"{class_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") txt_path = os.path.join(save_dir, os.path.splitext(xml_name)[0] + ".txt") with open(txt_path, "w", encoding="utf-8") as f: f.write("\n".join(lines)) count += 1 return count

这段代码里值得注意的参数和逻辑有三处。第一,class_names的顺序直接决定类别id,class_names[0]在YOLO里就是class_id 0,一定要和后续训练时data.yaml里的names顺序保持一致,否则会出现类别错位。第二,difficult判断不是VOC标准强制字段,但很多制作精良的数据集都带,不跳过的话会把难例和普通样本混在一起训练,精度反而下降。第三,即使某个XML里没有目标,也会写一个空的同名txt,空标签对YOLO训练是合法的,框架会把这批图当背景忽略,但如果少生成txt,训练时就会报“No labels found”警告,所以文件对齐比内容对齐更重要。

3.2 COCO转YOLO:category_id重映射与bbox坐标变换

COCO转YOLO的核心工作是把一个大json拆成每图一个txt。流程上先读入json,建立categories的id映射表,再按image_id把annotations聚合到每张图片上,最后把bbox的[x, y, w, h]换算成归一化中心坐标。转换时对越界框做clip、对无效框做过滤,这两步直接影响后续训练能不能跑通。下面是完整脚本:

import json import os from collections import defaultdict def coco2yolo(json_path: str, save_dir: str, class_names: list) -> tuple: """ 把 COCO 格式的 json 转成 YOLO txt。 json_path : COCO 的 annotation json 文件路径 save_dir : 输出 .txt 的目录 class_names : 你希望保留的类别顺序,比如 ["dog", "cat", "bird"] 返回 (转换的图片数, 写入的目标框总数) """ os.makedirs(save_dir, exist_ok=True) with open(json_path, "r", encoding="utf-8") as f: coco = json.load(f) # COCO 的 category id 不保证连续,必须重映射到 0, 1, 2... id_map = {} for cat in coco["categories"]: if cat["name"] in class_names: id_map[cat["id"]] = class_names.index(cat["name"]) # 建立 image_id -> image_info 的索引 img_map = {img["id"]: img for img in coco["images"]} # 按 image_id 聚合 annotations ann_by_img = defaultdict(list) for ann in coco["annotations"]: ann_by_img[ann["image_id"]].append(ann) converted_imgs = 0 total_boxes = 0 for img_id, anns in ann_by_img.items(): img = img_map[img_id] img_w = img["width"] img_h = img["height"] lines = [] for ann in anns: # 不在 class_names 里的类别直接丢掉 if ann["category_id"] not in id_map: continue x, y, w, h = ann["bbox"] # COCO bbox 是 [左, 上, 宽, 高] if w <= 0 or h <= 0: continue # 异常框直接跳过 # 归一化:中心点 = 左上角 + 半宽高 cx = (x + w / 2.0) / img_w cy = (y + h / 2.0) / img_h nw = w / img_w nh = h / img_h # 对越界框做裁剪,避免出现 cx > 1 的非法标签 cx = min(max(cx, 0.0), 1.0) cy = min(max(cy, 0.0), 1.0) nw = min(max(nw, 0.0), 1.0) nh = min(max(nh, 0.0), 1.0) lines.append(f"{id_map[ann['category_id']]} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}") total_boxes += 1 # 输出文件名跟图片名一致 out_name = os.path.splitext(img["file_name"])[0] + ".txt" with open(os.path.join(save_dir, out_name), "w", encoding="utf-8") as f: f.write("\n".join(lines)) converted_imgs += 1 return converted_imgs, total_boxes

这个脚本有两个地方容易踩坑。一是id_map的处理,如果COCO数据集有80个类别而你只想保留其中3个,脚本会自动丢掉其余类别的框,这很实用;但你要确保class_names里的名字和json里的cat["name"]完全一致,大小写、空格都算,"car"和"Car"是两个名字。二是ann["bbox"]里的坐标可能有小数,float类型天然兼容,不需要额外int,转int反而会损失精度。至于clip操作,它把越界值强行限制到[0,1]区间,看起来简单,但实际数据里COCO原始标注经常超出图像边界几个像素,不裁剪的话训练时yolo框架会直接报“All labels are out of bounds”然后跳过这张图,裁剪能保住边缘目标。

4. 训练集与测试集划分:固定随机种子的切分脚本与目录组织

格式转换完成后,接下来就是划分。很多人在这一步图省事,手动复制图片到train文件夹和test文件夹,结果训练第二天才发现验证集里混进了训练集图片,模型精度虚高得离谱。数据划分这事看起来是随机,实际上要保证两点:可复现性和严格不交集。

4.1 切分脚本:按比例分配并同步拷贝图片和标签

我一般不用现成的train_test_split那种工具,因为它在划分时不管标签文件的同步。自己写脚本反而更可控,一次搞定图片和txt的同步拷贝,以及固定随机种子。脚本支持train/val/test三段划分,默认比例8:1:1,你可以按需调整。

import os import random import shutil def split_dataset(image_dir: str, label_dir: str, out_root: str, train_ratio: float = 0.8, val_ratio: float = 0.1, seed: int = 42) -> None: """ 把图片和对应 txt 按比例切成 train/val/test。 image_dir : 图片目录 label_dir : 转换好的 txt 标签目录 out_root : 输出根目录,比如 /mnt/data/datasets train_ratio : 训练集比例,默认 0.8 val_ratio : 验证集比例,默认 0.1,剩余 0.1 作为测试集 seed : 随机种子,固定后切分可复现 """ random.seed(seed) exts = (".jpg", ".jpeg", ".png", ".bmp", ".webp") images = [f for f in sorted(os.listdir(image_dir)) if f.lower().endswith(exts)] random.shuffle(images) n_train = int(len(images) * train_ratio) n_val = int(len(images) * val_ratio) train_files = images[:n_train] val_files = images[n_train:n_train + n_val] test_files = images[n_train + n_val:] for split_name, split_files in [ ("train", train_files), ("val", val_files), ("test", test_files) ]: split_img_dir = os.path.join(out_root, split_name, "images") split_lbl_dir = os.path.join(out_root, split_name, "labels") os.makedirs(split_img_dir, exist_ok=True) os.makedirs(split_lbl_dir, exist_ok=True) for img_name in split_files: shutil.copy(os.path.join(image_dir, img_name), os.path.join(split_img_dir, img_name)) label_name = os.path.splitext(img_name)[0] + ".txt" src_label = os.path.join(label_dir, label_name) # 标签缺失时只警告,不中断;但该图会被框架当背景忽略 if os.path.exists(src_label): shutil.copy(src_label, os.path.join(split_lbl_dir, label_name)) else: print(f"[warn] {img_name} 缺少标签 {label_name}") # 打印划分数量,方便核对 print(f"train: {len(train_files)}, val: {len(val_files)}, test: {len(test_files)}")

这里最关键的是random.seed(seed)。不固定种子的话,每次跑脚本分出来的图片集合都不一样,你昨天训练的结果今天没法复现,对比实验也没法做。固定种子后,只要图片目录内容不变,每次划分结果完全相同。另一个容易被忽略的点是test集合:很多人只有train和val,拿val既调超参又当最终成绩,这样测试成绩乐观得没法看。我习惯留出10%的test,全程不碰它,最后才验证一次。

4.2 目录结构与data.yaml:让yolov5或yolov8直接开训

划分完的目录结构要按YOLO框架的习惯来组织,yolov5和yolov8这两个版本都认这套结构:images和labels平级,train/val/test分别放在下面。最终目录长这样:

datasets/ ├── train/ │ ├── images/ │ │ ├── 000001.jpg │ │ └── 000002.jpg │ └── labels/ │ ├── 000001.txt │ └── 000002.txt ├── val/ │ ├── images/ │ └── labels/ └── test/ ├── images/ └── labels/

配套的data.yaml文件这么写,注意nc和names的顺序必须和转换脚本里的class_names完全一致:

# data.yaml train: /mnt/data/datasets/train/images val: /mnt/data/datasets/val/images # test 路径可以留空,训练时用不到 nc: 3 names: ['dog', 'cat', 'bird']

当yolov8训练时,框架会根据data.yaml里的train路径自动推导出../labels目录找标签,不需要你额外写绝对路径。一个常见的坑是train和val写成了相对路径但数据集目录不在工作区,导致“No labels found”警告;我一般全部写绝对路径,省心。另外names的顺序必须和转换脚本一致,假如转换时class_names=['dog','cat','bird'],data.yaml里也必须是这个顺序,乱排会让所有框对应错类别。这个数据准备阶段的顺序错位,在yolo损失函数计算里看不出来,模型照样收敛,但最后预测结果会张冠李戴。

5. 避坑与常见问题:5个训练翻车的真实场景和解决路径

转换和划分脚本都写好、训练也能跑起来,不代表数据没问题。下面这5个坑是我在yolov5训练自己数据集的过程中真实踩过的,每个都按现象、原因、解决三步写,你遇到类似症状可以直接往这边查。

5.1 坐标与类别的3个坑:mAP为0、越界标签、类别错位

坑一:训练loss正常下降,但验证mAP一直是0,所有预测框都堆在图片左上角。

这个现象几乎可以锁定是标签坐标换算错了。最常见的原因是把x_center直接写成了x_min / img_w,没有加半宽;或者反过来,把width除成了(x_max - x_min) / 2。这种错误产生的标签虽然也在[0,1]区间,但所有框都被压缩到了图像左上角,模型训练时学到的是一堆集中在角落的小框,自然mAP为0。解决方法是先用可视化脚本抽查几张图,看标签框是否贴合目标;再用一段断言检查每个txt里的坐标范围,正常的归一化框应该是0 <= cx <= 1且0 <= bw <= 1。

坑二:训练时报“All labels are out of bounds”,直接跳过所有样本。

原因百分之百是标签里有cx或cy大于1的值。COCO数据集的原始bbox经常越出图像边界几个像素,比如框的右边超出了图片宽度,换算后nw或cx就超过1.0。yolov5和yolov8对越界标签的处理都很严格,只要有一个值不在[0,1]就整图丢弃。解决方法是像我在COCO脚本里做的那样,转换时对cx、cy、nw、nh逐个clip到[0,1];如果标签是负数,更要clip或直接丢弃。这个操作不会丢掉有效目标,最多损失半截在图像外的框。

坑三:检测结果里类别全是乱的,比如“狗”的框标成“猫”。

原因通常是class_names列表和data.yaml里的names顺序不一致。转换时你写['dog','cat','bird'],data.yaml里却按拼音或字母排成了['bird','cat','dog'],于是class_id=0从dog变成了bird,所有标签整体错位。更隐蔽的版本是:你在转换时用了一组类别列表,后来又往列表里加了新类别,老数据集没重新转换,新旧标签的id对不上。解决方法是把转换脚本里的class_names和data.yaml的names统一维护在同一个配置文件里,要么直接用一份json,要么至少保证这两处是同一个变量的输出。

5.2 文件与划分的2个坑:标签缺失和随机种子失效

坑四:训练日志刷屏“No labels found in xxx.jpg”,该图被静默忽略。

现象上loss显示没问题,但你仔细看训练集和验证集的图片数对不上,少了不少样本。原因多半是划分脚本没同步拷贝txt,或者源图片的扩展名是.jpeg而标签用了.jpg的同名文件,os.path.splitext替换后对不上。解决方法是加一段配对检查脚本,列出所有缺少标签的图片,马上就能定位问题:

import os img_dir = "path/to/images" lbl_dir = "path/to/labels" imgs = {os.path.splitext(f)[0] for f in os.listdir(img_dir)} lbls = {os.path.splitext(f)[0] for f in os.listdir(lbl_dir)} print("缺标签的图片:", imgs - lbls) print("多出来的标签:", lbls - imgs)

我把这个检查脚本固定放在转换和划分之间运行,输出为空才继续往下走,一点侥幸心理都不留。

坑五:固定了random.seed,第二次跑划分脚本结果还是变了。

原因在于random.shuffle只保证对同一个列表长度、同一个初始顺序产生相同结果。如果你往图片目录里新增了几张图,sorted(os.listdir())拿到的新列表顺序变化,shuffle后的划分全部重排,之前的验证集图片可能混进了训练集。解决方法是把划分结果持久化:每次划分后把train/val/test的文件名分别存成train.txt、val.txt、test.txt,下次直接按名单拷贝;增删数据时先把旧名单合并再重新划分,而不是直接重跑脚本。这是数据管理里最像“玄学”的部分,但其实只是种子和列表长度的游戏规则。

6. 转换完先别急着训练:一个可视化脚本验证标注对不对

脚本跑完,数据也切好了,我强烈建议做最后一道自检:把标签画回图片上看一眼。这一步能省下至少一个晚上的训练时间,因为模型训练完才发现数据错了,返工成本远高于训练本身。

import cv2 def check_labels(img_path: str, txt_path: str, class_names: list) -> None: """ 把 txt 标签画到图片上,肉眼确认框的位置和类别。 img_path : 一张图片的完整路径 txt_path : 同名 txt 标签的完整路径 class_names : 类别名列表,用于显示 """ img = cv2.imread(img_path) h, w = img.shape[:2] with open(txt_path, "r", encoding="utf-8") as f: lines = f.readlines() for line in lines: line = line.strip() if not line: continue cid, cx, cy, bw, bh = map(float, line.split()) # 越界检查,任何一个值不在 [0,1] 都要报警 assert 0 <= cx <= 1 and 0 <= cy <= 1, line assert 0 <= bw <= 1 and 0 <= bh <= 1, line x1 = int((cx - bw / 2) * w) y1 = int((cy - bh / 2) * h) x2 = int((cx + bw / 2) * w) y2 = int((cy + bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) name = class_names[int(cid)] if int(cid) < len(class_names) else str(int(cid)) cv2.putText(img, name, (x1, max(0, y1 - 6)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.imshow("label-check", img) cv2.waitKey(0) cv2.destroyAllWindows()

挑10到20张覆盖不同场景的图片跑一遍,重点看三件事:框是否贴合目标边缘、是否有框画到了图片外、类别标签和框内物体是否一致。顺手还可以做一次数据统计,用Python的collections.Counter数一下所有txt里每类框的个数,如果某个类别只有几十个框,训练时基本学不出来,要提前补数据。

6.1 训练前的最后一轮自检

在正式开训前,我会把这段检查脚本固化成一个固定流程,和转换脚本放在同一个目录下。先跑配对检查保证每张图都有标签,再跑可视化抽查一批框,最后看一眼类别的框数分布。这三步走完,数据才算是真正能喂给yolov5或yolov8去训练自己的数据集了。这套流程我用了很久,最大的价值不是省下了多少手工标注时间,而是让数据准备阶段变的稳定可复现——你换一批数据、换一个项目,走同一个流程,出来的数据质量就不会忽上忽下。碰见COCO转出来的标签不对,先怀疑脚本的坐标公式;碰见VOC转出来类别错乱,先核对类别顺序;这两个方向排查完,九成问题都在这。希望这套流程能帮你在数据准备阶段少翻几次车,也省下几个通宵。

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

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

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

立即咨询