做目标检测这事,绕不开的就是数据。随便翻一个检测项目,最耗时间的永远不是调模型,而是做数据集。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 "fps=1" 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> <folder>switch</folder> <filename>img_001.jpg</filename> <size> <width>1280</width> <height>720</height> <depth>3</depth> </size> <object> <name>switch_on</name> <bndbox> <xmin>256</xmin> <ymin>180</ymin> <xmax>520</xmax> <ymax>420</ymax> </bndbox> <difficult>0</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转YOLO
COCO转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_voc,XML全部放这里。最终训练用的图片和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_ok=True) os.makedirs(OUT_IMG_VAL, exist_ok=True) os.makedirs(OUT_LBL_TRAIN, exist_ok=True) os.makedirs(OUT_LBL_VAL, exist_ok=True) 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(f'missing 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(f'no 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%的图片连同标注框一起可视化出来,做一次"人工冒烟测试"。这一步虽然需要几分钟时间,但能切实减少"训了半天才发现数据集有问题"的悲剧。做数据集这件事,前期多花点时间规范流程,后面模型训练时能省下的时间多得多。格式互转不是终点,它只是整个检测工程里最基础也最不能出错的一环。