
做检测模型这行绕不开的一件事就是数据集。你可能算法调得飞起网络结构改得天花乱坠结果一跑训练发现 mAP 死活上不去回头一查——标注框偏了、类别 ID 对不上、格式转换时坐标被悄悄截断。我见过太多人把大把时间花在调参上却在数据这一环反复栽跟头。这篇就把检测数据集从零到一的完整链路捋一遍怎么收集、怎么标注、VOC/COCO/YOLO 三种格式到底差在哪、互转时哪些坑必须提前避开。不管你是刚入门想跑通第一个自定义数据集还是已经训练过几轮但总在格式转换上翻车这篇都能直接拿去对照操作。1. 先搞清楚三种格式到底在描述什么很多人一上来就找转换脚本结果转完发现框全错位了。根本原因是没有理解这三种格式对一个目标的描述方式完全不同。它们不是简单的文件后缀差异而是坐标体系、组织结构、字段语义三个层面都不一样。1.1 VOC以像素绝对坐标为核心的 XML 体系VOC 格式源自 PASCAL VOC 挑战赛它的核心特点是用像素绝对坐标描述边界框。一张图对应一个 XML 文件结构大致是这样annotation folderJPEGImages/folder filename000001.jpg/filename size width640/width height480/height depth3/depth /size object namecat/name bndbox xmin120/xmin ymin85/ymin xmax410/xmax ymax360/ymax /bndbox /object /annotation这里的关键点xmin/ymin/xmax/ymax是左上角和右下角的绝对像素值原点在图片左上角x 向右递增y 向下递增。size字段记录了图片宽高这个信息在格式转换时至关重要——因为 YOLO 需要用它来做归一化。VOC 的目录结构通常是Annotations/放 XML、JPEGImages/放原图、ImageSets/Main/放训练验证划分的 txt。这个结构是历史约定很多老代码库默认按这个路径读数据转换时最好保持。1.2 COCOJSON 单文件 归一化与绝对坐标混用COCO 格式用一个大的 JSON 文件描述整个数据集结构比 VOC 复杂得多但表达力也更强。它天然支持实例分割、关键点、多类别等任务。核心字段{ images: [ {id: 1, file_name: 000001.jpg, width: 640, height: 480} ], annotations: [ { id: 1, image_id: 1, category_id: 3, bbox: [120, 85, 290, 275], area: 79750, iscrowd: 0, segmentation: [[120,85, 410,85, 410,360, 120,360]] } ], categories: [ {id: 3, name: cat, supercategory: animal} ] }注意 COCO 的bbox是[x, y, width, height]不是[xmin, ymin, xmax, ymax]。这是转换时最容易搞错的地方之一。x, y是左上角绝对像素坐标width/height是框的宽高。另外category_id在 COCO 里不要求从 0 开始连续但很多训练框架要求连续转换时要注意重映射。1.3 YOLO归一化中心点坐标 纯文本YOLO 格式最简洁每张图对应一个 txt 文件每行一个目标3 0.414062 0.463542 0.453125 0.572917五个字段分别是class_id center_x center_y width height。全部是相对于图片宽高的归一化值范围 0~1。class_id从 0 开始连续编号这个编号对应一个classes.txt或data.yaml里的类别名列表。三种格式的核心差异我整理成一张表转换时对着看维度VOCCOCOYOLO存储形式每图一个 XML单个 JSON每图一个 txt框表示xmin,ymin,xmax,ymaxx,y,w,hcx,cy,w,h归一化坐标类型绝对像素绝对像素归一化 0~1类别表示字符串 name数字 category_id数字 class_id类别起始无编号不强制从 0必须从 0 连续分割支持无有 segmentation有多边形行提示转换前务必先确认源格式的框表示到底是xyxy还是xywh这一步错了后面全错而且往往训练时才发现白白浪费几小时。2. 数据收集别急着爬先想清楚你要什么数据集制作的第一步不是打开标注工具而是想清楚我要检测什么、在什么场景下检测。这个想不清楚后面标注全是返工。2.1 明确检测目标与场景边界我见过一个做试卷题目自动切割的项目一开始把整张试卷所有文字区域都标了结果模型学得一塌糊涂。后来收敛到只标题干区域和答题区域两类效果立刻上来了。所以第一步是定义清晰的类别体系类别不宜过多初期建议控制在 5~10 类每类至少 200~500 个实例起步。场景边界同样重要。你要检测的是白天还是夜间室内还是室外固定摄像头还是移动拍摄这些决定了你收集数据时要覆盖的分布。比如做电力红外数据集就要考虑不同设备、不同拍摄角度、不同环境温度下的红外图像否则模型换个变电站就废了。2.2 收集渠道与合法性常见渠道有几类公开数据集COCO、Open Images、CrowdHuman、CCPD 等可以直接下载或筛选子集、自有业务数据、网络公开图片。这里必须强调使用任何数据都要确认授权和合规性尤其是涉及人脸、车牌、个人信息的场景务必遵守相关法律法规和平台规则。公开数据集一般有明确 license用之前读一遍。对于自有数据收集时要注意多样性不同光照、不同角度、不同遮挡程度、不同背景。我一般会按场景维度建文件夹比如day/、night/、indoor/、outdoor/方便后续做分层采样和难例分析。2.3 数据清洗去重、去模糊、去损坏收集来的数据不能直接用。我通常跑三步清洗去重用感知哈希pHash或简单的 MD5 找完全重复和近似重复的图。重复图会导致训练集和验证集泄漏指标虚高。去模糊/去损坏用拉普拉斯方差判断模糊度阈值一般设在 100 左右视场景调整太模糊的直接剔除用 PIL 尝试打开每张图打不开的就是损坏文件。尺寸过滤太小的图比如短边小于 200px检测价值低建议剔除或单独处理。import cv2 import os def check_blur(image_path, threshold100): img cv2.imread(image_path) if img is None: return False, 0 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) score cv2.Laplacian(gray, cv2.CV_64F).var() return score threshold, score这段代码我几乎每个项目都会用跑一遍把模糊图挑出来人工过一眼比事后模型学不好再回头找原因高效得多。3. 标注工具选型与质量控制的实战细节标注是数据集质量的分水岭。工具选对了效率翻倍质量控制做好了模型才稳。3.1 标注工具怎么选主流工具就那么几个各有适用场景LabelImg老牌工具直接输出 VOC XML也支持 YOLO txt。轻量、离线、上手快适合小规模矩形框标注。缺点是界面老旧不支持分割和关键点。Labelme支持多边形分割标注输出 COCO 或 Labelme 自己的 JSON。做实例分割或需要精细边界时用它。CVAT功能最全支持视频标注、多人协作、自动标注。适合团队和较大规模项目但部署稍重。Roboflow / 在线平台适合快速迭代但数据要上传涉及隐私或敏感数据时慎用。我的建议纯矩形框检测、数据量在几千张以内LabelImg 足够需要分割或团队协作上 CVAT只是快速验证想法在线工具也行但注意数据合规。3.2 标注规范必须先定后标这是血泪教训。如果几个人同时标没有统一规范标出来的框松紧不一、类别理解不同模型直接学懵。标注前一定要写一份标注规范文档至少包含每个类别的明确定义和正反例图框的松紧标准贴边还是留 2px 余量统一即可遮挡、截断目标怎么处理标可见部分还是补全极小目标小于多少像素是否标注类别边界模糊时的判定规则我一般会先让所有人标 50 张同一批图然后交叉比对把分歧点拿出来讨论定规则再正式开标。这一步花半天能省后面几天返工。3.3 标注质量抽检标完不能直接用。抽检比例建议 10%~20%重点看漏标有没有目标没框错标类别对不对框质量松紧是否一致有没有明显偏移边界情况遮挡、截断、密集目标处理是否一致抽检发现的问题要反馈给标注员并更新规范。如果错误率超过 5%建议整批返工或重新培训。注意标注一致性比标注绝对精度更重要。一个框稍微松一点没关系但如果一半人松一半人紧模型就学不到稳定特征。4. 格式互转坐标变换的数学与代码实现终于到核心部分了。三种格式互转本质是坐标体系的换算。我把每个方向的转换逻辑和代码都写清楚你直接抄。4.1 VOC 转 YOLO归一化是唯一难点VOC 的xyxy绝对坐标转 YOLO 的cxcywh归一化公式cx (xmin xmax) / 2 / W cy (ymin ymax) / 2 / H w (xmax - xmin) / W h (ymax - ymin) / H其中 W、H 是图片宽高从 XML 的size字段读。代码import xml.etree.ElementTree as ET import os def voc_to_yolo(xml_path, classes, output_dir): tree ET.parse(xml_path) root tree.getroot() size root.find(size) W int(size.find(width).text) H int(size.find(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) bnd obj.find(bndbox) xmin float(bnd.find(xmin).text) ymin float(bnd.find(ymin).text) xmax float(bnd.find(xmax).text) ymax float(bnd.find(ymax).text) cx (xmin xmax) / 2.0 / W cy (ymin ymax) / 2.0 / H w (xmax - xmin) / W h (ymax - ymin) / H lines.append(f{cls_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}) base os.path.splitext(os.path.basename(xml_path))[0] with open(os.path.join(output_dir, base .txt), w) as f: f.write(\n.join(lines))这里有个坑坐标可能越界。有些标注框会超出图片边界xmax W归一化后 w 可能大于 1。YOLO 训练时一般会裁剪到 [0,1]但最好在转换时就 clamp 一下避免异常值干扰。4.2 YOLO 转 VOC反归一化 边界裁剪反向转换把归一化值乘回图片宽高def yolo_to_voc(txt_path, img_w, img_h, classes, output_dir): lines open(txt_path).read().strip().split(\n) xml_lines [] for line in lines: if not line.strip(): continue parts line.split() cls_id int(parts[0]) cx, cy, w, h map(float, parts[1:5]) xmin int((cx - w/2) * img_w) ymin int((cy - h/2) * img_h) xmax int((cx w/2) * img_w) ymax int((cy h/2) * img_h) # 裁剪到图片边界 xmin max(0, xmin); ymin max(0, ymin) xmax min(img_w, xmax); ymax min(img_h, ymax) xml_lines.append((classes[cls_id], xmin, ymin, xmax, ymax)) # 后续拼 XML 略关键点YOLO 转 VOC 必须知道图片宽高因为 YOLO 文件里不存这个信息。所以转换时要遍历原图读尺寸或者从已有的元数据里取。很多人转完发现框全错就是因为用了错误的宽高。4.3 VOC 转 COCO注意 bbox 是 xywhVOC 的xyxy转 COCO 的xywhx xmin y ymin w xmax - xmin h ymax - yminCOCO 的 JSON 需要同时维护images、annotations、categories三个数组image_id和annotation_id要唯一。代码框架import json coco {images: [], annotations: [], categories: []} for i, name in enumerate(classes): coco[categories].append({id: i, name: name, supercategory: none}) ann_id 1 for img_id, xml_path in enumerate(xml_list, start1): # 解析 XML 拿 W H 和框 coco[images].append({ id: img_id, file_name: os.path.basename(xml_path).replace(.xml, .jpg), width: W, height: H }) for (name, xmin, ymin, xmax, ymax) in boxes: coco[annotations].append({ id: ann_id, image_id: img_id, category_id: classes.index(name), bbox: [xmin, ymin, xmax-xmin, ymax-ymin], area: (xmax-xmin)*(ymax-ymin), iscrowd: 0 }) ann_id 1 json.dump(coco, open(annotations.json, w))area字段很多框架不强制但 COCO 评估时会用建议填上。iscrowd一般填 0表示非密集群组。4.4 COCO 转 YOLO批量处理与类别重映射COCO 的category_id可能不连续比如 1, 3, 7转 YOLO 时必须重映射成 0, 1, 2cat_id_to_yolo {cat[id]: i for i, cat in enumerate(coco[categories])} img_info {img[id]: img for img in coco[images]} for ann in coco[annotations]: img img_info[ann[image_id]] W, H img[width], img[height] x, y, w, h ann[bbox] cx (x w/2) / W cy (y h/2) / H nw w / W nh h / H cls_id cat_id_to_yolo[ann[category_id]] # 写入对应 txt这一步的坑是图片可能没有对应标注负样本转换时要保证每张图都有 txt 文件哪怕是空的否则 YOLO 训练时可能报错或漏掉负样本。4.5 转换后的验证别跳过这一步转完一定要验证。我一般做三件事可视化抽查随机抽 20~50 张把转换后的框画回原图肉眼看是否对齐。数量核对统计转换前后目标总数是否一致类别分布是否一致。边界检查检查是否有坐标越界、宽高为负、类别 ID 超范围的情况。def visualize_bbox(img_path, boxes, classes, save_path): img cv2.imread(img_path) H, W img.shape[:2] for (cls_id, cx, cy, w, h) in boxes: x1 int((cx - w/2) * W); y1 int((cy - h/2) * H) x2 int((cx w/2) * W); y2 int((cy h/2) * H) cv2.rectangle(img, (x1,y1), (x2,y2), (0,255,0), 2) cv2.putText(img, classes[cls_id], (x1, y1-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,255,0), 1) cv2.imwrite(save_path, img)可视化这步千万别省。我遇到过转换脚本把xywh当成xyxy处理数量核对完全正常但可视化一看框全偏了。数量对不代表坐标对。5. 那些年我踩过的格式转换坑这一节单独拎出来因为坑太集中了每个都值得单独说。5.1 坐标系原点与 y 轴方向绝大多数图像格式原点在左上角y 向下。但如果你从某些数学工具或旧系统导出的数据可能是左下角原点、y 向上。转换前确认清楚否则框会上下翻转。判断方法可视化一张图看框是否镜像。5.2 归一化用了错误的宽高YOLO 归一化必须用当前图片自己的宽高不能用数据集统一尺寸。如果图片尺寸不一用错宽高会导致框比例全乱。我见过有人图省事用 640x640 统一归一化结果非正方形图全废。5.3 类别 ID 不连续或从 1 开始VOC 用字符串类别名COCO 的 category_id 可能从 1 开始或不连续YOLO 要求从 0 连续。转换时一定要做重映射并且保存一份classes.txt记录映射关系。训练时类别对不上模型输出的类别名就全错。5.4 浮点精度与坐标截断VOC 的坐标是整数YOLO 是浮点。VOC 转 YOLO 时如果先取整再归一化会损失精度。建议全程用 float 计算最后写文件时保留 6 位小数。反过来 YOLO 转 VOC 时int()截断会导致框偏小建议用round()。5.5 空标注文件与负样本有些图没有目标VOC 里没有 XMLYOLO 里应该是空 txt。转换时要保证空 txt 存在否则训练时这张图可能被跳过负样本丢失。负样本对降低误检很重要别丢。5.6 图片文件名与标注文件名不一致VOC 的 XML 里filename字段和实际文件名可能不一致改过名没同步。转换时以实际文件名为准别信 XML 里的filename。这个坑很隐蔽训练时找不到图才报错。提示转换脚本写完先在小样本10~20 张上跑通并可视化验证确认无误再全量跑。全量跑完再发现问题返工成本高得多。6. 一套可复用的数据集制作流水线把上面的东西串起来我通常按这个流程走需求定义确定类别体系、场景边界、目标数量。数据收集多渠道收集按场景分文件夹。数据清洗去重、去模糊、去损坏、尺寸过滤。标注规范写规范文档试标校准正式标注。质量抽检抽检 10%~20%问题反馈返工。格式统一确定训练框架需要的格式YOLO 居多从标注工具原生格式转换。转换验证可视化抽查 数量核对 边界检查。划分数据集按 8:1:1 或 7:2:1 划分 train/val/test注意同一场景的图不要跨集泄漏。生成配置文件YOLO 的data.yaml写好路径、类别数、类别名。试训练先跑几轮看 loss 和指标是否正常再全量训练。这套流程我用了很多项目基本能覆盖 90% 的检测数据集制作需求。剩下的 10% 是特殊场景比如多模态数据集、视频抽帧、3D 标注那些另说。6.1 数据集划分的坑划分不是随机分就完事。如果同一段视频抽的帧、同一场景的连拍图被分到 train 和 val会导致验证指标虚高。正确做法是按场景/视频/采集批次划分保证 val 和 test 里的场景在 train 里没出现过这样评估才真实。6.2 配置文件模板YOLO 的data.yaml长这样path: /dataset/root train: images/train val: images/val test: images/test nc: 5 names: [person, car, dog, cat, bicycle]nc是类别数names顺序必须和 class_id 对应。这个文件写错训练直接报错或类别全乱。7. 关于工具与效率的一些个人经验最后聊几个提效的点。格式转换脚本我建议封装成命令行工具支持--src-format和--dst-format参数一次写好反复用。我自己的脚本支持 VOC/COCO/YOLO 六向互转加个--visualize参数直接出验证图省得每次单独写验证代码。标注阶段如果类别多、目标密集可以考虑用预训练模型做预标注人工只做修正。比如先用 COCO 预训练的 YOLO 跑一遍把置信度高的框导成标注文件人工在 LabelImg 里改。这样能省 50% 以上的标注时间但要注意预标注的框质量参差修正时别偷懒。数据版本管理也很重要。数据集改了一版又一版没有版本管理过两周自己都忘了哪版对应哪个实验结果。我一般用dataset_v1、dataset_v2这样命名每次改动记录 changelog训练配置里写清楚用的哪版。这个习惯在复现实验时能救命。还有个小技巧转换脚本里加日志记录每张图转换了多少个框、有没有越界裁剪、有没有类别未匹配。跑完看日志异常一目了然比事后排查高效得多。数据集这活儿说到底是细活。算法可以调结构可以换但数据错了后面全是白费。把收集、标注、转换、验证这几步做扎实模型效果自然就上来了。我自己的体会是花在数据上的时间最终都会以模型指标的提升回报回来。