目标检测数据集制作全流程:VOC/COCO/YOLO格式转换与标注实践
发布时间:2026/10/1 23:25:43来源:尧图网络
做目标检测这事绕不开的就是数据。随便翻一个检测项目最耗时间的永远不是调模型而是做数据集。VOC那种xml格式、COCO那种大json、YOLO那种一行五个数字的txt光说清楚都费半天劲更别提互相转换了。我见过太多人标注完才意识到完蛋工具只能存YOLO格式但我要用COCO训练然后满世界找脚本。今天就把我这几年做检测数据集的完整流程摊开讲从收集图片、标注规范、三大格式的底层结构到互转代码的真实写法一次性说明白。目标只有一个让看完的人做数据集时不用再走第二遍弯路。1. 数据集制作的整体思路与方案选型1.1 为什么非要自己动手做数据集很多人第一反应是网上不是有现成的COCO、VOC数据集吗直接下载不就行了。这话对纯学习来说没毛病但拿到真实项目里基本不成立。原因有三条。第一场景对不上。通用数据集里的人和车和你厂里监控画面里的目标完全是两回事。就拿最近挺多人问的电力红外数据集来说红外图像里设备发热区域的特征在自然光数据集里根本找不到对应样本。想用通用权重直接去检测红外场景效果可想而知。第二类别定义得自己定。比如开关闭合检测你需要区分的是开关闭合和开关断开这两个状态有时候还要区分正常和故障。这种细粒度语义是公开数据集覆盖不了的唯一的办法就是自己采集、自己标。第三数据量要求不大但要求精。实际项目里每个类别有几百到一两千个实例往往就够模型在特定场景下用得很好。这个量级靠自己整理完全可行关键是要把流程理顺。所以结论很明确自己制作检测数据集不是因为没有公开数据集而是因为公开数据集解决不了你的实际问题。1.2 VOC、COCO、YOLO三种格式到底选哪个做数据集之前得先搞明白三种格式的定位否则后面全是坑。VOC格式源自PASCAL VOC竞赛本质是一张图片配一个XML文件。XML里记录了图片文件名、路径、尺寸以及每个目标的类别和边界框坐标。它的特点是可读性极强VSCode打开就能看懂出了问题最容易排查。COCO格式源自Microsoft COCO数据集它把所有信息塞进一个巨大的JSON文件里。这个JSON里有images列表、annotations列表、categories列表各自用id互相引用。看起来复杂但它统一了目标检测、实例分割、关键点检测等多种任务的标注格式科研圈和开源模型生态都很认它。YOLO格式是Ultralytics等框架带火的一个txt文件每一行代表一个目标五个数字分别是类别id 中心点x 中心点y 宽度 高度全部除以图片宽高做了归一化。它极简读起来快但人眼基本等于看不懂。我个人的选型建议是这样的标注阶段统一用VOC格式因为XML可读、可视化工具多、出问题好排查。等标注全做完了再按需求决定是转COCO还是YOLO。如果最终训练框架是YOLOv5/YOLOv8系列直接转YOLO格式如果要做多任务训练或者和开源模型对比就转COCO。千万不要标到一半换格式那才是真正的时间黑洞。1.3 一次搞对的核心思想我踩过几次坑之后总结出一条铁律格式是流程的末端产物不是流程的起点。很多新手拿到图片就开始标标到几张才想起来没定义类别标到几十张又觉得目录结构不对最后不得不写脚本把文件挪来挪去。正确顺序应该是先确定任务和类别清单写成classes.txt规划目录结构把图片按原始/待标注/已标注分开确定训练集验证集比例但先不急着切分统一用VOC格式标注脚本批量转换到目标格式程序化校验可视化抽查最后再切分文件夹这套流程看着步骤多但每一步都可以脚本化真正动手时间反而最短。2. 图片收集与初始筛选2.1 数据来源与采集方法检测数据集的图片来源常见的就三个渠道。第一个是现场采集。拿手机、相机、工业相机对着目标场景拍拍的时候要注意变化角度、距离和光照条件。做电力红外数据集的项目往往就是带着热像仪去变电站现场一圈一圈拍做传送带异物检测的多是把工业相机架在产线上连续录视频再抽帧。第二个是公开数据集二次筛选。比如车辆检测可以基于BDD100K筛选合适片段目标检测通用场景可以先从COCO里挑出你需要的类别。注意这里用的是筛选不是下载完直接拿来用。公开数据集的图像尺寸、风格、标注风格都不统一筛选完基本都要重新转换和部分重新标注。第三个是视频抽帧这个我特别提一下。监控场景或者产线场景很多时候拿不到原始静态图只能拿视频。用ffmpeg拉取RTSP流或者直接处理视频文件按一定帧率抽帧就行。ffmpeg -i input.mp4 -vf fps1 frame_%04d.jpg上面这行命令就是每秒抽一帧。抽完记得做一次快速浏览把模糊的、重复的、没有目标的帧都删掉不然纯靠标注时发现浪费的时间够你做好几遍了。2.2 图片数量与质量怎么把握数量这东西没法给一个死数字不同任务差异太大了。但有一条经验可以参考单类别实例数在500到1000个区间内模型通常能拿到不错的基础效果少于200个基本只能靠预训练权重硬撑超过2000个后继续加数据带来的收益会明显递减。比数量更重要的是质量。我一直强调多样性优先也就是同一个类别的样本要覆盖不同角度、不同光照、不同背景。光伏板俯拍图里如果全是同一时刻的顺光图片模型一到阴天或者逆光就拉胯加再多图也没用。拿到一批图片之后我习惯先做个初筛规则就三条分辨率低于320x320的图直接不要除非目标特别大画面里有明显运动模糊、失焦的小图直接删目标占比极小的图可以先留后面用小目标增强去处理不要急着删2.3 批量去重是省时间利器数据一多重复图片就很麻烦。手动翻图找重复几百上千张图能翻到你怀疑人生。有经验的都会用感知哈希去重。感知哈希的原理不复杂把图片缩小成8x8的灰度图计算每个像素和平均灰度的大小关系得到一个64位的哈希值。相似的图片哈希值也相近汉明距离小于某个阈值的就判定为近似重复。这种脚本我写过很多次核心就几步。读取图片计算哈希值两两比对汉明距离距离超过阈值就放进待删除列表。批量处理下来速度很快误杀率也能接受。做完这一步数据集里的脏数据能少一大半。3. 标注环节的实操规范3.1 标注工具选型与使用场景标注工具我用过不少按场景给大家排个序。LabelImg是最经典的安装简单界面朴素支持PascalVOC和YOLO两种格式导出。几百张图的小项目用这个完全够用但它只支持矩形框标注也不支持团队协作。X-AnyLabeling是近两年用得越来越多的工具支持自动标注辅助、支持SAM模型接入能半自动地分割目标。对工业场景来说背景变化不大的前提下半自动标注能节省一半以上的时间。CVAT和Label Studio属于团队级工具支持多人协同标注、任务分配、在线管理。数据量上到几千上万张的时候一定要用这种带账号管理、审核流程的工具不然没法保证标注质量。我的建议很简单自己一个人做小项目用LabelImg或者X-AnyLabeling团队协作直接上CVAT。别在这上面花太多时间纠结工具只是手段稳定的标注规范才是核心。3.2 边界框标注的细节规范标注规范决定了数据集的可用性上限。技术上的东西都能补规范乱了数据就废了。我给自己定了几条硬标准。框要紧贴目标外缘。默认不额外留白边也不故意往里收。目标如果形状不规则按下限兜住外轮廓就行不需要精确到每个凹角。遮挡目标的处理。目标被遮挡了一部分只标可见区域不要脑补被遮住的部分。这条在COCO这种强标准数据集里也有明确要求。如果你标了遮挡物体被遮挡部分的边界不准确训练时会给模型传递错误信号。小目标的标注。我定义了最小可标注面积边框边长至少是图片短边的1%至2%低于这个就不标。这么做不是为了省事而是因为过小的框在训练时会变成噪声反而拉低精度。当然如果项目专门做小目标检测你要单独做小目标增强那就是另一套策略了。类别定义必须在动手前写死。比如做开关检测switch_on和switch_off到底以什么状态划分必须写清楚标到一半再去翻图片对比效率极低。3.3 标注质量的二次检查标完之后不能直接拿去训练一定要做质检。我一般分两层。第一层是程序自动检查统计几个指标每个类别的目标数量分布、目标框面积分布、坐标是否越界、是否有重复框。这些用脚本遍历XML或者JSON就能查出来不需要人肉看。第二层是人工抽检。随机抽10%左右的数据把标注框画回图片上看重点检查类别是否标错、框是否完全偏了。这一层靠的是经验看多了之后一眼就能判断这个人的标注习惯和不规范的地方。质检出问题就要打回去返工别怕麻烦。不合格的数据进了训练集模型的表现会像吃了苍蝇一样难受而且你根本找不到是哪个环节出了问题。4. VOC、COCO、YOLO格式深度解析与互转4.1 VOC格式XML标注详解VOC格式的标注文件是一个XML根节点是annotation里面存放整体信息。一个典型的标注文件长这样我拆开说。annotation folderswitch/folder filenameimg_001.jpg/filename size width1280/width height720/height depth3/depth /size object nameswitch_on/name bndbox xmin256/xmin ymin180/ymin xmax520/xmax ymax420/ymax /bndbox difficult0/difficult /object /annotation关键信息就三块图片路径与尺寸、目标的类别名、目标的bndbox坐标。coordinates是左上角(xmin,ymin)和右下角(xmax,ymax)单位是像素绝对值没做任何归一化。difficult是VOC时代遗留的属性用来标记难以识别的目标现代训练基本都用不到保持0就行。需要注意的一点是XML里的filename只是文件名不包含路径。图片文件通常放在同级目录或特定数据根目录下转换或训练时要自己做路径拼接。4.2 COCO格式JSON嵌套结构COCO标注是一个大JSON核心字段有三个images、annotations、categories。images里面每个元素描述一张图片字段包含id、file_name、width、height。annotations里每个元素描述一个目标字段包含id、image_id、category_id、bbox、area、iscrowd。categories里每个元素描述一个类别字段包含id、name。最容易踩坑的是bbox的坐标定义。COCO的bbox是[x, y, width, height]表示左上角坐标宽高而VOC的bndbox是[xmin, ymin, xmax, ymax]是左上角右下角。这两个定义完全不一样转换时不能只改个标签名必须做坐标换算。还有area字段。VOC格式里没有这个字段转COCO的时候要自己算area width * height。iscrowd表示这个目标是否是一组密集目标初始全部置0就行。COCO格式的优点是完全脱离了文件路径的干扰所有关联都用id连接很适合大规模型数据集的管理。但对小数据集来说它也确实笨重——改一个类别名要动JSON结构远不如改XML直观。4.3 YOLO格式一行五个数YOLO格式的txt标注极简单一个txt文件对应一张同名的图片文件。每一行代表一个目标五个值分别对应类别id从0开始与类别清单的顺序严格对应归一化后的中心点x坐标归一化后的中心点y坐标归一化后的宽度归一化后的高度归一化就一步把所有像素坐标除以图片的宽度或高度。YOLO格式的优点是对训练框架友好读起来一行一个目标无需解析XML缺点是难以直接用眼睛检查坐标一错往往要可视化才能发现。4.4 核心转换代码写法转换脚本网上能找到一堆但很多都写得花里胡哨实际用起来反而要调半天。我的经验是核心逻辑就几行别整复杂了。VOC转YOLO先读XML提取图片宽高和每个目标的像素坐标然后做归一化。import xml.etree.ElementTree as ET def voc_to_yolo(xml_path, class_list, out_txt_path): tree ET.parse(xml_path) root tree.getroot() img_w int(root.find(size/width).text) img_h int(root.find(size/height).text) lines [] for obj in root.findall(object): name obj.find(name).text if name not in class_list: continue cls_id class_list.index(name) xmin float(obj.find(bndbox/xmin).text) ymin float(obj.find(bndbox/ymin).text) xmax float(obj.find(bndbox/xmax).text) ymax float(obj.find(bndbox/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 lines.append(f{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}) with open(out_txt_path, w) as f: f.write(\n.join(lines))这个函数有两点值得注意。第一class_list的顺序必须和最终训练时的classes.txt保持一致不然类别就错位了第二归一化后的坐标保留6位小数就够不用写满默认浮点数位。这个代码可以直接用适配所有VOC格式的XML。COCO转YOLOCOCO转YOLO核心在JSON解析和坐标换算。import json def coco_to_yolo(json_path, out_dir): with open(json_path, r) as f: data json.load(f) # 建立id到类别名的映射 cat_id_to_name {c[id]: c[name] for c in data[categories]} # 建立图片id到文件名的映射 img_id_to_file {im[id]: im[file_name] for im in data[images]} img_id_to_size {im[id]: (im[width], im[height]) for im in data[images]} # 目标id重新映射到0开始的连续编号 new_cat_ids {old_id: idx for idx, old_id in enumerate(cat_id_to_name)} from collections import defaultdict anns_by_img defaultdict(list) for ann in data[annotations]: image_id ann[image_id] bbox ann[bbox] # [x, y, w, h] cls_id new_cat_ids[ann[category_id]] x, y, w, h bbox img_w, img_h img_id_to_size[image_id] x_center (x w / 2) / img_w y_center (y h / 2) / img_h w_norm w / img_w h_norm h / img_h anns_by_img[image_id].append( f{cls_id} {x_center:.6f} {y_center:.6f} {w_norm:.6f} {h_norm:.6f} ) for image_id, lines in anns_by_img.items(): file_name img_id_to_file[image_id] txt_name file_name.rsplit(., 1)[0] .txt with open(f{out_dir}/{txt_name}, w) as f: f.write(\n.join(lines))这里有个细节要特别强调COCO的category_id可能是不连续的比如1、3、5这样的编号。直接拿这个编号当YOLO的类别id训练时类别数就错了。必须重新映射成从0开始连续编号。上面代码里的new_cat_ids就是干这个的。YOLO转COCO反向转换逻辑也不难从txt里读归一化的中心点坐标和宽高还原成像素坐标系下的[x, y, w, h]即可。def yolo_line_to_coco(line, img_w, img_h): cls_id, x_c, y_c, w_n, h_n map(float, line.split()) w w_n * img_w h h_n * img_h x x_c * img_w - w / 2 y y_c * img_h - h / 2 return int(cls_id), [x, y, w, h]这段代码短但容易出错的是取整精度。YOLO存的是归一化小数乘回宽高之后因为浮点误差实际数值可能和你原始标注差不到1像素。这属于正常误差不用管直接四舍五入就行。4.5 手工转换还是工具转换现在网上有不少现成的格式转换工具比如labelme2coco、json2yolo一类用起来确实方便。但我的建议是小数据集自己写脚本大数据集再考虑着用现成工具。原因很简单格式转换这件事的核心风险不在转换逻辑本身而在格式理解是否透彻。自己写一遍脚本你就彻底掌握了三种格式的坐标定义差异后面遇到别的问题也能自己排查。用现成工具一旦出现类别编号映射错误、并行丢失标注这类坑反而更难排查。5. 实操演示从图片到YOLO训练集一次跑通5.1 场景设定开关闭合检测说一个我实际做过的例子大家直接照着套流程。假设任务是做配电箱里的开关闭合状态检测类别定义两个switch_on表示开关合上switch_off表示开关断开。这个任务看起来简单实际做数据的时候也有不少讲究。开关在画面里经常偏小而且不同品牌的开关外观差异很大标注时容易出现只标分闸状态不标合闸状态这种漏标。因为合闸的开关视觉上更像是开关本来就该有的样子反而容易忽略。5.2 目录结构规划开工之前先建目录这是数据集地基。我的目录结构是这样的datasets/switch/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ ├── annotations_voc/ ├── annotations_coco/ ├── classes.txt标注中间产物是annotations_vocXML全部放这里。最终训练用的图片和txt分开放images和labels目录名对应这样YOLO框架直接能认。5.3 标注与格式转换实操我用LabelImg以VOC格式把开关图片全部标注完成得到了几十上百个XML文件。然后写一个脚本一次性做三件事解析VOC XML、生成YOLO txt、按比例切分train/val。import os import random import shutil import xml.etree.ElementTree as ET CLASSES [switch_on, switch_off] VOC_DIR annotations_voc IMG_DIR images_all OUT_IMG_TRAIN images/train OUT_IMG_VAL images/val OUT_LBL_TRAIN labels/train OUT_LBL_VAL labels/val VAL_RATIO 0.2 os.makedirs(OUT_IMG_TRAIN, exist_okTrue) os.makedirs(OUT_IMG_VAL, exist_okTrue) os.makedirs(OUT_LBL_TRAIN, exist_okTrue) os.makedirs(OUT_LBL_VAL, exist_okTrue) with open(classes.txt, w) as f: f.write(\n.join(CLASSES)) xml_files [f for f in os.listdir(VOC_DIR) if f.endswith(.xml)] random.shuffle(xml_files) val_count max(1, int(len(xml_files) * VAL_RATIO)) val_set set(xml_files[:val_count]) for xml_file in xml_files: is_val xml_file in val_set out_img_dir OUT_IMG_VAL if is_val else OUT_IMG_TRAIN out_lbl_dir OUT_LBL_VAL if is_val else OUT_LBL_TRAIN base xml_file[:-4] img_file f{base}.jpg if not os.path.exists(f{IMG_DIR}/{img_file}): print(fmissing image: {img_file}) continue # VOC转YOLO tree ET.parse(f{VOC_DIR}/{xml_file}) root tree.getroot() img_w int(root.find(size/width).text) img_h int(root.find(size/height).text) lines [] for obj in root.findall(object): name obj.find(name).text if name not in CLASSES: continue cls_id CLASSES.index(name) xmin float(obj.find(bndbox/xmin).text) ymin float(obj.find(bndbox/ymin).text) xmax float(obj.find(bndbox/xmax).text) ymax float(obj.find(bndbox/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 lines.append(f{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}) if lines: with open(f{out_lbl_dir}/{base}.txt, w) as f: f.write(\n.join(lines)) shutil.copy(f{IMG_DIR}/{img_file}, f{out_img_dir}/{img_file}) else: print(fno objects: {xml_file}) print(done)这里有个小设计要说明只有标注文件里有内容才复制图片没有目标的xml直接跳过。空txt留在训练里会报错还不如一开始就过滤掉。5.4 转换后的可视化验证这一步很多人会省略但我强烈建议做完。转换逻辑再正确也得靠视觉确认。写个可视化脚本读YOLO txt里的归一化坐标乘回图片真实宽高用OpenCV画框保存。肉眼扫一遍转换后的结果框的位置有没有整体偏移、类别对不对一目了然。import cv2 def draw_yolo(image_path, txt_path, class_list): img cv2.imread(image_path) h_img, w_img img.shape[:2] with open(txt_path, r) as f: lines f.readlines() for line in lines: parts line.strip().split() cls_id int(parts[0]) x_c, y_c, w_n, h_n map(float, parts[1:]) x1 int((x_c - w_n / 2) * w_img) y1 int((y_c - h_n / 2) * h_img) x2 int((x_c w_n / 2) * w_img) y2 int((y_c h_n / 2) * h_img) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, class_list[cls_id], (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) return img框画完之后我习惯挑10张和原始XML标注做对比坐标偏差超过几个像素的要回去查。这步只花十分钟但能让后面训练时省下大把调试时间。6. 避坑指南与常见问题实录6.1 类别编号错位是最隐蔽的坑这个问题几乎每个人都遇到过。标注工具生成的类别顺序和你最终训练用的classes.txt顺序不一致导致模型把猫当成狗还在那训练得不亦乐乎。解决之道就是让所有环节读同一个classes.txt。标注工具里定义的类别顺序转换脚本里引用的类别顺序训练配置里的类别顺序三者必须完全一致。我的习惯是先把classes.txt写好放在项目根目录所有脚本都从这个文件读而不是代码里二次定义。6.2 坐标与图片尺寸不对应这个坑出现得也很多。标注完XML之后有人会对图片做缩放或者裁剪但XML里的size字段没同步更新。转换到YOLO格式时程序按XML里的旧尺寸做归一化得到的坐标就全偏了。所以我在做格式转换前加了程序化校验遍历每一个XML读取size字段再读图片的实际宽高不一致的直接报错。这个检查写起来就几行却能避免一批看起来正常实际全偏的标注。6.3 空标注文件带来的隐蔽报错有时候一张图里确实没有要检测的目标但工具还是生成了一个空的txt文件。YOLO训练时遇到这种空标注文件有些框架会直接报错有些则静默跳过一整个batch训练损失出现莫名跳变。我最后用的策略是转换脚本里直接把空txt文件删除同时把对应的图片也从数据目录里移出去。这比单独处理要彻底。6.4 中文路径和特殊字符问题这个坑常见于Windows环境。数据集根目录带中文或者图片文件名带空格、括号YOLO训练时会因为路径解析问题莫名报错。我个人现在无论什么环境数据集一律用英文目录图片文件名一律纯字母数字加下划线不搞特殊字符。省得后面排查到怀疑人生。另外要注意的一点是标注工具自动保存的XML里写的路径往往带绝对路径训练框架不一定认。建议转换脚本里只保留相对路径或文件名路径拼接交给数据配置去处理。6.5 训练集验证集划分时机的问题有人喜欢先划分图片再转换有人先转换再划分两种都行但有一个前提必须保证图片和txt同名文件必须一一对应不能出现图片train里放着txt却跑到val里的情况。我习惯的做法是做一次全量转换转换成功之后再统一划分。这样逻辑链路更简单不会出现转换了一部分发现类别清单错了又要重新来的场面。最后再分享一个小技巧。整套数据做好之后别急着开训练。先把train里面大概20%的图片连同标注框一起可视化出来做一次人工冒烟测试。这一步虽然需要几分钟时间但能切实减少训了半天才发现数据集有问题的悲剧。做数据集这件事前期多花点时间规范流程后面模型训练时能省下的时间多得多。格式互转不是终点它只是整个检测工程里最基础也最不能出错的一环。
网站建设高端定制企业官网