目标检测数据集制作全流程:从标注规范到VOC/COCO/YOLO格式转换
发布时间:2026/10/1 18:02:28来源:尧图网络
做检测数据集最磨人的不是标得慢而是格式来回倒腾。我接手过好几个项目团队里有人用LabelImg导出的VOC XML有人从CVAT拉下来的COCO JSON训练那边又只认YOLO的txt每次做新一轮数据都花一两天在写转换脚本、排查类别对不上的问题上。这篇就把检测数据集的全流程摊开讲怎么收集、怎么标、VOC/COCO/YOLO三种格式底层是怎么回事、互转到底在转什么给出可以直接复用的代码和规则。无论是刚入门目标检测的学生还是被标注格式折磨的工程团队这篇都能用。1. 返工重灾区不在转换而在收集和标注的地基先说一个我观察了很久的结论格式转换本身不难难的是数据一开始就没收对、没标对导致后面所有流程都在填坑。很多人以为买个好用的标注工具、写个转换脚本就万事大吉结果训练时发现mAP上不去、某些类识别极差、模型对光照特别敏感回头查了一大圈才发现是数据源的问题。1.1 收集阶段就要想清楚的场景覆盖数据收集最容易犯的错是贪多图快。我见过有人用爬虫抓了十万张图看着数量很大实际一分析全是同一类背景、同一种角度、同一个时间段拍的训练出来模型换个环境就废。收集数据前先列一个覆盖矩阵每个维度至少有三五档变化光照变化白天、傍晚、夜间补光、逆光、强反光尺度变化近景大目标、远景小目标、同框大小混杂角度变化俯拍、平视、仰拍、侧倾背景干扰纯色背景、复杂纹理、目标与背景颜色相近遮挡程度完整、半遮挡、密集遮挡运动状态静止、低运动模糊、高运动模糊真正好的检测数据集不看你存了多少G图片而是看这些图片里目标出现的形态跨度有多大。以车辆检测为例白天阳光下拍的车、夜晚车灯照亮一部分的车、雨雾天气模糊的车三者的特征差异比大众和丰田的差异大得多。1.2 视频抽帧不是越快越好自己用视频抽帧做数据时按固定间隔抽是最省事也最容易踩坑的方案。如果视频里一辆车静止停着占了十秒按每秒一帧抽就有十张几乎完全一样的图这些图进训练集会带来严重的过拟合。我常用的抽帧策略是去相似帧先做一遍快速感知哈希过滤掉帧间相似度过高的候选帧保证数据集的有效信息密度。另一个实用技巧是按运动强度动态抽帧。用简单的帧间差分计算画面变化量变化大的区间加密抽帧变化小的区间稀疏抽帧。这样同一个视频能抽出覆盖面更广的样本后期标注的ROI也更高。1.3 公开数据集与自采数据的组合策略完全从零收集太费劲完全依赖公开数据集又和控制场景差距太大。我的习惯是分三步走先找一批高质量公开数据集快速跑通基线再针对自己的场景补充自采数据最后用难例挖掘持续补充。比如做鸟类目标检测公开的CUB-200数据集有图像但很多是特写和你用远摄镜头在树冠里找鸟完全两回事就得靠自采视频抽帧来补齐小目标样本。公开数据集引用时还要注意许可协议和来源标注问题特别是商用项目很多公开数据集只允许科研用途。这块不把关好后面部署上线可能踩法律风险。2. 标注工具和标注规则先立规矩再开工数据收集完就进入标注环节。我给所有团队的建议是换工具之前先立标注规范规范不统一换什么工具都一样出问题。2.1 工具选型对比标注工具折腾过不少说说我用下来的真实感受。单机简单场景用LabelImg最顺它是VOC格式原生的界面朴素但速度不错适合一个人标几百张图的小项目。团队协作、数据量大的项目CVAT是首选它原生支持Pascal VOC、COCO、YOLO三种格式的导入导出在线的任务分配和审核流程对人效提升很大一个任务分给几个人同时标还能做交叉复核。最近两年X-AnyLabeling也值得关注它集成了多种AI辅助标注SAM模型交互分割、自动边框预测敏感度和效率确实高尤其适合目标轮廓复杂、形状不规则的场景。不过辅助标注跑偏的现象也有后面标注质量检查环节必须跟上。2.2 必须先定的四类标注规范动标之前团队必须把下面四件事写成文档并且每个标注员都签字确认第一类名表。类别名统一用英文小写加下划线比如person、traffic_light禁用带空格和大小写混拼的命名。YOLO格式是纯数字ID换格式时ID和类名完全靠映射表类名不一致会导致转换后类别彻底错乱。第二边界框定义。检测框是紧贴目标外轮廓还是包含少量边缘间隙这个必须统一。标准做法是紧贴可见轮廓包括被遮挡但能判断边界的部分。框的大小直接影响YOLO的anchor匹配效果。第三遮挡与截断的标注规则。半遮挡目标遮挡面积不超过50%必须标框框取可见部分的外接矩形完全遮挡不标目标超出图像边界时框要严格截断在图像边缘内不允许让xmax超出图片宽度。第四小目标的下限。一张1920x1080图里小于20x20像素的目标建议在早期标注阶段直接放弃否则大量小目标框会让训练loss剧烈震荡。如果领域本身就看重小目标比如遥感、无人机视角那就需要单独配置切片策略把大幅影像切成有重叠的小图再标。2.3 标注边界的一致性问题CVAT的交互标注意味着标注员可以来回拖动边界框这带来的一个问题是目标边界到底落在语义边界还是视觉边界。举个例子同一辆车一个人把框贴着车身画另一个人把后视镜和保险杠凸出部分也包进去了两个框可能差好几像素。对YOLO这种基于锚框的算法几个像素的抖动对最终回归结果影响不小。我的做法是在规范里写死语义边界策略框边界落在目标语义属于它的最外层像素的外切边缘。也就是只要目标自身的部件后视镜、天线、翘起的保险杠能被观察到就包含进框背景里的物体路牌、灯杆、灌木即便和车产生粘连也不能包含。标完后做一轮全量复核两个人交叉看把边界不一致的框统一。3. 把三种格式当数据结构看坐标体系是分水岭VOC、COCO、YOLO这三种格式很多人只记了个大概就去做转换结果就是转得越多错得越离谱。我自己总结的方法是把它们当成三种数据结构去理解而不是三个文件名后缀。三种格式的差异核心就一句话——坐标体系完全不同。3.1 VOC XML数组思维的绝对坐标VOC的标注文件是一个XML文档里面每个object节点描述一个目标边界框用的是绝对像素坐标四个值是左上角(xmin, ymin)和右下角(xmax, ymax)就是x1y1x2y2格式。图片尺寸、通道数等信息也都写在XML的size节点里。一个典型的XML长这样annotation folderJPEGImages/folder filename001.jpg/filename size width1920/width height1080/height depth3/depth /size object nameperson/name poseUnspecified/pose truncated0/truncated difficult0/difficult bndbox xmin120/xmin ymin300/ymin xmax480/xmax ymax870/ymax /bndbox /object /annotation值得留意的细节是truncated目标是否超出图像边界和difficult目标是否难以识别两个字段训练时需要根据任务决定是否跳过difficult1的样本转换格式时这两个字段经常被粗心丢掉。3.2 COCO JSON索引表思维的规范JSONCOCO是三个格式里结构最规整、也是维数最多的。它本质是三张表images表存每张图的ID、文件名、宽高annotations表存每个矩形框的具体信息bbox是[x, y, width, height]——注意是左上角坐标加宽高不是右下角坐标categories表存类别ID和类别名的映射。{ images: [ {id: 1, file_name: 001.jpg, width: 1920, height: 1080} ], annotations: [ {id: 1, image_id: 1, category_id: 1, bbox: [120, 300, 360, 570], area: 205200, iscrowd: 0} ], categories: [ {id: 1, name: person, supercategory: none} ] }COCO的area字段很多人换算时忽略其实它就是width * height。另外COCO的iscrowd字段代表这个框内是一整群目标还是单目标训练时处理方式不同转换脚本里经常被默认写成0。这些细节不管模型精度可能莫名其妙地差。3.3 YOLO TXT训练友好的归一化相对坐标YOLO格式的每个图片对应一个txt文件每行一个目标格式是class cx cy w h。这里的坐标是归一化相对坐标值的范围在0到1之间。中心点cx, cy是矩形框中心点的坐标除以图片宽高宽w和高h也是用目标的宽高分别除以图片宽高。0 0.156250 0.541667 0.187500 0.263889这种设计的初衷是让训练时的网络输入和标签保持在同一个量纲下避免图片尺寸变化导致标签失效。也正因为是相对坐标YOLO格式的txt不携带图片尺寸信息一旦和图片分离就无法还原绝对像素坐标。3.4 一张表看透三种格式的坐标系格式文件形式框坐标表示坐标基准类别表示是否依赖图片尺寸VOCXMLxmin, ymin, xmax, ymax绝对像素字符串类名是size节点记录COCOJSONx, y, width, height绝对像素数字类别ID是images表记录YOLOTXTcx, cy, w, h归一化相对值数字类别ID是转换时需要知道尺寸所以格式互转的本质就是把同一坐标系下的框描述换成另一种坐标系下的描述。绝对转绝对VOC转COCO最轻松只要拆解和重组字段绝对转归一化VOC/COCO转YOLO需要读出图片宽高做除法归一化转绝对YOLO转VOC/COCO必须知道图片尺寸才能还原这是回转换最易踩坑的点。4. 格式互转的完整代码从VOC到YOLO、从COCO到YOLO说完了原理下面直接给代码。我给的是经过多个项目验证的转换函数不是网上那种只能跑通小数据集的玩具脚本包含了边界值防错和明显脏数据的过滤。4.1 转换前必须做的数据消毒转换脚本执行前先跑一遍数据消毒。我见过大量转换后训练报错、或者loss异常飙升的案例最后定位下来几乎都是源标注文件里有脏数据xmax和xmin相等目标退化成一个点、坐标超出图片边界、一张图里类别名写错了、某个image_id在images表中找不到。这些在转换前过滤掉能省下后面排查的非常多时间。def sanitize_voc_annotation(root, img_w, img_h): 清洗单个VOC对象返回合规的bbox或None xmin float(root.find(bndbox/xmin).text) ymin float(root.find(bndbox/ymin).text) xmax float(root.find(bndbox/xmax).text) ymax float(root.find(bndbox/ymax).text) xmin, xmax sorted([xmin, xmax]) ymin, ymax sorted([ymin, ymax]) if xmax - xmin 1 or ymax - ymin 1: return None xmin max(0, min(xmin, img_w - 1)) xmax max(0, min(xmax, img_w - 1)) ymin max(0, min(ymin, img_h - 1)) ymax max(0, min(ymax, img_h - 1)) return xmin, ymin, xmax, ymax这套消毒策略的核心是先判断目标框是否退化再暴力截断边框到图像边界内。注意这里用img_w - 1而不是img_w因为像素坐标是从0开始数的最右一个像素的坐标是width - 1。4.2 VOC转YOLO的完整实现import os import cv2 import xml.etree.ElementTree as ET def voc_to_yolo(xml_dir, img_dir, out_dir, class_names): os.makedirs(out_dir, exist_okTrue) for xml_file in os.listdir(xml_dir): if not xml_file.endswith(.xml): continue xml_path os.path.join(xml_dir, xml_file) tree ET.parse(xml_path) root tree.getroot() filename root.find(filename).text img_path os.path.join(img_dir, filename) img cv2.imread(img_path) if img is None: print(f[skip] 图片不存在或无法读取: {img_path}) continue img_h, img_w img.shape[:2] txt_name os.path.splitext(xml_file)[0] .txt txt_path os.path.join(out_dir, txt_name) lines [] for obj in root.iter(object): name obj.find(name).text.strip() if name not in class_names: print(f[warn] 未映射的类名 {name} 在 {xml_file} 中) continue cls_id class_names.index(name) box sanitize_voc_annotation(obj, img_w, img_h) if box is None: continue xmin, ymin, xmax, ymax box cx (xmin xmax) / 2.0 / img_w cy (ymin ymax) / 2.0 / img_h w (xmax - xmin) / img_w h (ymax - ymin) / img_h lines.append(f{cls_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}) with open(txt_path, w) as f: f.write(\n.join(lines)) print(f[done] VOC转YOLO完成输出目录: {out_dir})这段代码里有几个决定一次搞对的细节读图片用cv2.imread而不是直接从XML的size节点拿宽高因为XML里的尺寸可能和实际图片不一致resize过、被压缩软件改过都可能导致对不上归一化时用img_w做分母而不是img_w - 1这是归一化坐标和整数像素坐标约定的差异很多人在这里纠结半天统一用img_w就能保持YOLO训练的一致性。4.3 COCO转YOLO的要点与代码COCO转YOLO比VOC转YOLO多一层嵌套结构必须先建立image_id到file_name的映射再遍历annotations去填充每个文件的行。import json import os import cv2 def coco_to_yolo(coco_json_path, img_dir, out_dir, sort_by_nameTrue): os.makedirs(out_dir, exist_okTrue) with open(coco_json_path, r) as f: coco json.load(f) img_id_to_file {} img_id_to_size {} for img in coco[images]: img_id img[id] img_id_to_file[img_id] img[file_name] img_id_to_size[img_id] (img[height], img[width]) cat_id_to_cls {} for cat in coco[categories]: cat_id_to_cls[cat[id]] cat[name] img_anns {} for ann in coco[annotations]: image_id ann[image_id] img_anns.setdefault(image_id, []).append(ann) for img_id, anns in img_anns.items(): file_name img_id_to_file[img_id] img_path os.path.join(img_dir, file_name) img cv2.imread(img_path) if img is None: print(f[skip] 图片不存在: {img_path}) continue actual_h, actual_w img.shape[:2] exp_h, exp_w img_id_to_size[img_id] if actual_w ! exp_w or actual_h ! exp_h: print(f[warn] 尺寸不匹配 {file_name}: JSON记录({exp_w},{exp_h}) 实际({actual_w},{actual_h})) lines [] for ann in anns: if ann.get(iscrowd, 0) 1: continue cat_id ann[category_id] if cat_id not in cat_id_to_cls: continue cls_name cat_id_to_cls[cat_id] # 用类别名做映射中间层 cls_id sorted(cat_id_to_cls.values()).index(cls_name) x, y, w, h ann[bbox] cx (x w / 2) / actual_w cy (y h / 2) / actual_h nw w / actual_w nh h / actual_h lines.append(f{cls_id} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}) txt_name os.path.splitext(file_name)[0] .txt with open(os.path.join(out_dir, txt_name), w) as f: f.write(\n.join(lines)) print(f[done] COCO转YOLO完成输出目录: {out_dir})COCO转YOLO最容易搞错的点是category_id的映射。COCO的类别ID往往不是从0开始的连续整数比如是1、3、5而YOLO要求类别ID必须是从0开始连续索引。上面代码用sorted(cat_id_to_cls.values()).index(cls_name)把类别名排序后取索引保证了无论COCO原始ID多么乱输出YOLO的ID始终是从0开始按字母序排的。4.4 YOLO转VOC / YOLO转COCO的回转换YOLO转VOC或COCO核心难点是YOLO的txt不携带图片尺寸必须先定位到对应的图片文件读取宽高。所以回转换之前必须确保JPEGImages或images目录里面的图片存在否则转出的绝对坐标一定是错的。图片在可以由脚本自动对齐图片丢了只能手动清理数据。import os import cv2 def yolo_to_voc(txt_dir, img_dir, xml_out_dir, class_names): os.makedirs(xml_out_dir, exist_okTrue) for txt_file in os.listdir(txt_dir): if not txt_file.endswith(.txt): continue base os.path.splitext(txt_file)[0] img_path None for ext in [.jpg, .jpeg, .png, .bmp, .webp]: candidate os.path.join(img_dir, base ext) if os.path.exists(candidate): img_path candidate break if img_path is None: print(f[error] 找不到图片: {base}) continue img cv2.imread(img_path) img_h, img_w img.shape[:2] annotation ET.Element(annotation) ET.SubElement(annotation, folder).text JPEGImages ET.SubElement(annotation, filename).text os.path.basename(img_path) size ET.SubElement(annotation, size) ET.SubElement(size, width).text str(img_w) ET.SubElement(size, height).text str(img_h) ET.SubElement(size, depth).text 3 with open(os.path.join(txt_dir, txt_file), r) as f: for line in f: parts line.strip().split() if len(parts) ! 5: print(f[warn] 异常行 {txt_file}: {line.strip()}) continue cls_id int(parts[0]) cx, cy, w, h map(float, parts[1:]) if cls_id len(class_names): print(f[error] 类别ID越界 {txt_file}: {cls_id}) continue xmin int(round((cx - w / 2) * img_w)) ymin int(round((cy - h / 2) * img_h)) xmax int(round((cx w / 2) * img_w)) ymax int(round((cy h / 2) * img_h)) xmin max(0, min(xmin, img_w - 1)) ymin max(0, min(ymin, img_h - 1)) xmax max(0, min(xmax, img_w - 1)) ymax max(0, min(ymax, img_h - 1)) obj ET.SubElement(annotation, object) ET.SubElement(obj, name).text class_names[cls_id] ET.SubElement(obj, pose).text Unspecified ET.SubElement(obj, truncated).text 0 ET.SubElement(obj, difficult).text 0 bbox ET.SubElement(obj, bndbox) ET.SubElement(bbox, xmin).text str(xmin) ET.SubElement(bbox, ymin).text str(ymin) ET.SubElement(bbox, xmax).text str(xmax) ET.SubElement(bbox, ymax).text str(ymax) xml_tree ET.ElementTree(annotation) xml_path os.path.join(xml_out_dir, base .xml) xml_tree.write(xml_path, encodingutf-8, xml_declarationTrue) print(f[done] YOLO转VOC完成输出目录: {xml_out_dir})注意回转换时用int(round(...))不是直接int(...)截断。比如归一化坐标cx0.5、图片宽1920、目标宽0.1时(0.5 0.1/2) * 1920得到1056.0看起来整除了但更多时候结果是1056.000001这种浮点误差值直接截断会少1个像素。系统性少1个像素对训练影响不大但逼死强迫症也不值当round到底。4.5 类别文件管理不管做哪个方向的转换最后都要生成一份和标注文件配套的classes.txt。YOLO训练时这个文件的顺序就等于类别ID顺序改一个顺序就相当于把所有标注的ID全改了模型等于重训。我见过有人直接把网上的VOC 20类classes.txt套到自己数据集上结果训练时类别标签全错mAP直接掉到地平线。我建议把类别顺序管理上升到项目根目录下的一个唯一配置文件里所有转换脚本都从它读顺序。比如person bicycle car motorbike traffic_light这个文件一旦定稿就不要轻易变更顺序。如果非要加类别只能往后追加如果非要删类别就重新生成全量标注文件并且重新验证一轮。5. 转换完不等于能用可视化验证是最后一道防线转换脚本跑完文件都生成了很多人的第一反应就是直接丢进训练脚本。这是最容易中招的时刻。转换过程中可能出现各种隐蔽问题比如类别顺序乱了、归一化坐标错了一位、某张图的目标全被过滤掉变成空txt。不验证直接训练轻则mAP低得很稳定重则训练几轮就loss爆掉。5.1 画框验证是成本最低的手段最直观的验证方式是把转换后的标注画在原图上人眼扫一遍就知道有没有问题。下面这段代码把YOLO的txt叠加到图片上import cv2 import os def draw_yolo_boxes(img_path, txt_path, class_names): img cv2.imread(img_path) img_h, img_w img.shape[:2] with open(txt_path, r) as f: for line in f: parts line.strip().split() if len(parts) ! 5: continue cls_id, cx, cy, w, h map(float, parts) x1 int((cx - w / 2) * img_w) y1 int((cy - h / 2) * img_h) x2 int((cx w / 2) * img_w) y2 int((cy h / 2) * img_h) color (0, 255, 0) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) label class_names[int(cls_id)] cv2.putText(img, label, (x1, max(20, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) return img跑一遍所有图片随机抽几十张出来看重点检查三件事框是否紧贴目标轮廓、框是否越出图像边界、类别标签和框内目标是否一致。肉眼验证虽然土但在数据集质量检查上是无可替代的一环。5.2 反向转换验证法一个我特别推荐的方法正向转换后反向转回去再把反向转换后的坐标和原始标注比一次算IoU。如果平均IoU低于0.98说明某个环节有尺度过不去的误差。这个方法能自动化批量检测适合大型数据集。核心思路是以YOLO为中间桥梁VOC转YOLO再转回VOC理论上坐标应当几乎不变只有归一化和反归一化的舍入误差。下面这段就是比较的核心逻辑def box_iou(box_a, box_b): x1 max(box_a[0], box_b[0]) y1 max(box_a[1], box_b[1]) x2 min(box_a[2], box_b[2]) y2 min(box_a[3], box_b[3]) inter max(0, x2 - x1) * max(0, y2 - y1) area_a (box_a[2] - box_a[0]) * (box_a[3] - box_a[1]) area_b (box_b[2] - box_b[0]) * (box_b[3] - box_b[1]) union area_a area_b - inter return inter / union if union 0 else 0比对时若某个目标的IoU低于0.95就把它连同原图一起输出到排查目录。这个自动化检查能一次性揪出所有转完悄悄变了的问题。5.3 容易漏的三类问题第一类是重复类别名。VOC里同一个目标被标了两次XML里有两个object节点用同一个名字坐标完全一样。YOLO格式里看不出问题但训练时这个目标被当成两个正样本梯度被拉了两次影响虽小却白白浪费显存。转换脚本里可以加一个按(name, xmin, ymin, xmax, ymax)去重的逻辑。第二类是空白txt文件。如果VOC里有目标的图片转出来txt是空的大概率是类名不在映射表里被脚本跳过了。这种情况图片还在标签确实空的训练时这张纯背景图会被当成负样本。少量负样本没问题如果大量图片的txt全空训练集阳性比例失衡模型会倾向于输出空检测。转换脚本跑完统计一下有多少个txt文件是0字节超过图片总数的5%就得查。第三类是标注文件与图片文件名不对齐。VOC里filename节点写的是001.jpg实际文件叫IMG_001.jpg或大小写不一致跨平台迁移时路径全断。上面代码里我直接拿filename去找图就是在处理这类问题——找不到图宁可跳过也不要生成标着错误路径的标注。5.4 数据集划分的一个隐蔽坑转完格式后要划分train/val这时有一个特别隐蔽的坑直接从视频抽帧得到的数据集如果按随机比例划分训练集和验证集同一场景连续帧的相似图片可能同时出现在两边造成验证集信息泄漏验证集mAP虚高。划分前需要先按视频片段ID做分组同一片段的图片分到同一个集合再在组级别做划分。这个思路也适用于有先后时间戳的连续监控数据。我见过一个项目验证集mAP高达0.98上线后实际场景只有0.6查了半天就是这个问题。划分完还要检查每个集合里各类别的分布是否均衡。用一个简单的统计脚本输出train和val各自的类别数量矩阵如果某个类在验证集里样本只有个位数就要考虑按类别补充或调整划分种子。做数据集全流程这活儿七分在前期规划三分在工程执行。把收集覆盖度、标注规范、坐标体系三个地基打牢格式互转就是一道机械的算术题再也不会半夜被一句格式不对拉起来加班。
网站建设高端定制企业官网