ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

目标检测数据集制作全流程:从标注到VOC/COCO/YOLO格式转换避坑指南

目标检测数据集制作全流程:从标注到VOC/COCO/YOLO格式转换避坑指南 上周帮人排查一个检测训练发散的问题模型结构是照抄的 SOTA预处理也按论文来了可训练到第 20 轮 loss 突然放飞自我一开始怀疑学习率后来怀疑 BN 崩溃折腾一晚上才发现是 COCO 转 YOLO 的时候中心点坐标忘了归一化训练脚本读进来的框全部偏出图像。这种问题在检测数据集制作全流程里非常典型收集、标注、VOC、COCO、YOLO 格式互转每一步单独拿出来都有现成工具但连起来跑的时候各种隐性约定才是真正的坑。这篇就按我实际走通的流程聊一遍适合正在自己攒数据集、或者需要给团队搭数据标注管线的朋友目标是让你从零做完一套检测数据集并且在格式转换这件事上不再返工。1. 为什么数据集制作决定了检测项目的生死1.1 模型再强也喂不饱脏数据很多朋友一开始做检测项目最关心的是换哪个 backbone、加哪个注意力模块、抄哪篇论文的训练 trick。但以我接触过的项目体感模型结构能拉开的那几个点在脏数据面前不值一提。数据标注错 10%mAP 掉的不只是 10%而是可能让某个类别直接废掉因为错框会同时污染正样本和负样本该框的地方没框模型学到的是这里没目标不该框的地方框了模型学到的是这里有目标。这种矛盾样本积累多了loss 会一直在高位震荡怎么看都像模型不收敛。真正的检测项目落地流程大概 70%~80% 的精力都花在数据上。这里说的数据不只是图片数量而是清洗过、标注过、格式正确的样本集合。我见过太多人拿公开数据集直接训练loss 曲线很漂亮一到自己场景就拉胯原因是公开数据和你实际部署环境的光照、角度、目标尺度分布完全不一样。反过来说一份标注准确、场景覆盖合理、格式统一的数据集哪怕用三四年前的 YOLOv5也能在垂直场景里跑出可用的效果。这就是为什么我坚持认为数据集的制作水准直接决定检测项目的生死。1.2 三种格式本质上是在用不同方言说同一句话先给新手一个定心丸VOC、COCO、YOLO 这三种格式描述的是同一件事——哪张图里有哪些类别各自在什么位置。区别只是存储方式、坐标单位和组织结构的差异。你可以把它们理解成用三种方言说同一句话意思一样但发音和语法不一样。VOC 格式一个图片对应一个 XML 文件框的坐标用xmin, ymin, xmax, ymax表示单位是像素且是绝对坐标。COCO 格式所有图片和标注统一存在一个 JSON 文件里框的坐标用x, y, w, h表示单位是像素但 (x,y) 是左上角。YOLO 格式一张图片对应一个 txt 文件框的坐标用x_center, y_center, w, h表示且全部做了归一化值在 0~1 之间。既然描述的是同一件事为什么还要互转因为不同训练框架、不同预训练模型、不同评测工具要求的输入格式不一样。你拿 COCO 预训练权重微调自己的数据可能数据得是 COCO 风格上了 YOLO 训练脚本又要 txt有些线上标注平台导出的是 VOC你没得选。所以格式互转不是可选项而是每个做检测的人都要过的关。1.3 格式错误的爆发点都在训练中后期这是最坑的地方格式错误很少在启动训练时报错。你转完格式训练能跑起来loss 也在降模型看起来一切正常。然后训到一半或者训完验证的时候发现某些类别完全没有预测框或者框的位置全是偏的这时候你再回头查数据之前几十个小时的训练时间已经搭进去了。我个人的经验是格式转换里的坐标系错误、类别索引错位、尺寸信息丢失这三类问题全是潜伏型问题。它们不像语法错误那样在解析阶段就炸给你看而是进入训练流程后才通过损失曲线、验证曲线、可视化结果逐步暴露。所以这篇文章后面专门会讲验证环节那一步做好了能帮你省掉大量返工时间。2. 数据收集先定义问题再找图2.1 任务边界决定收集倾向数据收集的第一步不是打开爬虫或者找公开数据集而是把任务边界写清楚。同样是目标检测类别数量、目标尺度、场景复杂度不同数据收集的策略完全不一样。举个例子你在热搜里能看到各种各样的检测需求鸟类目标检测、开关闭合状态检测、传送带异物检测、遥感图像目标检测、车辆检测比如 BDD100 这类自动驾驶数据集。这几个任务看起来都是找目标画框但实际要求天差地别鸟类细分检测类别之间长得像需要重点收集不同姿态、不同光照、不同背景下的同种鸟分类难度远大于定位难度。开关闭合检测一个开关只有合和断两个状态目标小、位置固定难点在于小目标特征区别微弱对图像分辨率和标注精度要求极高。传送带异物检测异物种类不确定属于开放集问题收集阶段就得刻意涵盖正常物料和各种异物两类并且异物样本通常极少需要靠产线采集慢慢积累。遥感图像检测目标密集、尺度变化巨大、大量小目标收集时要特别注意卫星或无人机成像的分辨率、拍摄角度以及不同地域的样本均衡。自动驾驶车辆检测要求在复杂道路场景下不漏检遮挡、截断、夜间、雨雾都是必须覆盖的维度还要区分轿车、卡车、公交车、行人等类别。所以说数据收集不只是找图而是在用图片定义你的问题。我习惯先写一份数据需求说明把每个类别至少需要多少张、每张图期望包含多少个目标、必须覆盖哪些环境变化列清楚然后再开始找数据效率会高很多。2.2 公开数据集和自有采集怎么搭公开数据集是起步最快的燃料。COCO、Pascal VOC、Open Images 这类通用数据集适合做预训练和通用场景验证如果你做的是垂直领域优先搜有没有现成的领域数据集比如车辆场景有 BDD100K医疗影像有一些公开的细胞或病灶数据集电力设备红外检测也有相关的开源数据。用公开数据起步的好处是标注相对规范格式统一多为 COCO 或 VOC可以直接拿来练手和跑通流程。但垂直场景的检测项目自制数据永远是不可替代的。公开数据集覆盖不了你产线上那个光影环境也覆盖不了你无人机飞的那个高度和角度。我自己的做法是公开数据打底、自有数据调优先用公开数据把模型结构跑通然后花主要精力采集和标注自有场景数据尤其是那些公开数据集里覆盖不到的长尾情况。采集时至少要做到同一个目标从多个角度、多个距离、多种光照条件下拍摄目标之间要有遮挡、靠近、截断的场景不要全是完美摆拍背景要多样化否则模型会学到背景记忆而不是目标记忆记录真实部署时相机的型号和安装位置尽量用同款设备收集。另外数据增强不能解决数据量不足的问题它只能解决不变性问题——比如平移、缩放、亮度变化这些模型应该具备的抗干扰能力。如果某个场景本身就缺样本靠增强是补不回来的增强出来的还是同一个场景的变换不是新场景。2.3 别忽略清洗与合规收集阶段的清洗比很多人想象得重要。公开数据集里经常有 label 错的图、模糊到人眼都无法判断的图、以及同一张图在不同数据集里重复出现的情况。我见过有人把两个公开数据集合并直接用结果训练集和验证集之间出现重复图片验证指标虚高得一塌糊涂换到真实场景立刻原形毕露。清洗手段很朴素算感知哈希或直接用文件名去重抽样人工看图把明显模糊、过暗、过曝的图删掉。合规这块也要提一句虽然不展开但采集图像素材时要注意来源合法、避免侵犯隐私和个人信息尤其涉及人脸、车牌等敏感信息时要么脱敏要么避开。这不是小题大做而是工程长期化必须考虑的事。3. 标注阶段框怎么画比框画得多重要3.1 工具选型要看流程而不只看手感标注工具的选择别只看画框顺不顺手要看它对你的整个数据管线友不友好。我自己用过的几类工具感受如下工具适合场景特点注意点LabelImg单机小规模标注轻量、直接导出 VOC XML 和 YOLO txt项目大了管理困难多人协作基本靠手动拷贝Label Studio团队协作、多模态标注支持分类、检测、分割、文本等多种标注可配置模板需要部署标注界面稍重但格式导出能力强CVAT团队 视频/密集标注有半自动标注插件适合大量重复场景需要 Docker 部署上手曲线高一些X-AnyLabeling个人 半自动辅助集成推理模型辅助标注节省人工依赖模型效果脏标注需要人工确认我的建议是如果你只是几百张图验证想法LabelImg 完全够用如果你要批量做几千上万张直接上 Label Studio 或 CVAT因为它们自带多人任务分配、标注进度管理和格式导出后面省事得多。另外无论用哪个工具标注人员的操作习惯要和工具导出格式匹配否则导出结果经常出现不是我预期结构的坑。3.2 标注规范那些容易吵起来的边界情况标注规范如果只写一句把目标框出来后面一定会出问题。最容易引发分歧的边界情况有这么几类目标被遮挡框应该紧贴可见部分还是按完整目标的外形框行业惯例是框可见部分但如果遮挡比例超过一定阈值比如 50%建议加 flag 或直接不标取决于你的应用目标。目标截断在图像边缘框超出图像边界时YOLO 训练一般要求坐标在 [0,1] 内所以要么把越界部分截断到边界要么干脆删除这种样本看你的训练逻辑。极小目标低于某个像素阈值比如小于 16x16的目标人眼都容易标歪。要么专门定义小目标处理策略要么在下采样标注前做好筛选。两个目标重叠严重要明确是标两个实例还是一个否则模型学到的框会产生歧义。类别边界模糊比如鸟和飞鸟是不是一类轿车和跑车要不要分开这种问题要在标注规范里提前写死否则不同人标出来的类别语义不一致。这些规范落实到文档里再配几张正确框 / 错误框的示例图给标注人员看比反复口头解释强得多。3.3 质检抽检之外的一致性校验很多团队的质检方式是按比例抽检比如标完 1000 张抽 100 张人工看。抽检有用但不够它抓不住系统性的坐标偏移和类别混淆。如果条件允许我建议让两个人标注同一批图然后计算一致性框级别可以用 IoU 判断类别级别可以直接对比标签。如果两个人的 IoU 均值低于 0.8说明标注规范执行有问题得回去重新对齐。更实用的做法是转成可视化检查标注完成后随机抽图把框画回原图并显示类别和置信度如果工具支持人眼扫一遍。这一步能发现绝大多数框大了一圈类别标串了的问题。质检发现问题后要能定位到具体标注员并反馈形成闭环否则问题只会重复出现。4. 三种格式的精讲从目录结构到坐标约定4.1 VOC 是目录 XML不是随便一个文件夹Pascal VOC 的经典目录结构长这样VOCdevkit/ VOC2007/ JPEGImages/ # 原始图片 Annotations/ # 每个图片对应的 XML ImageSets/ Main/ train.txt # 训练图片文件名列表不带扩展名 val.txt trainval.txt每个 XML 文件里记录了图片文件名、尺寸、以及每个目标的类别和边界框annotation filename000001.jpg/filename size width500/width height375/height depth3/depth /size object namebird/name truncated0/truncated difficult0/difficult bndbox xmin100/xmin ymin150/ymin xmax200/xmax ymax250/ymax /bndbox /object /annotation这里最关键的是size里的宽高因为后续转 YOLO 要做归一化必须靠它做分母。另外difficult表示这个目标是否属于难例很多训练框架会忽略 difficult1 的样本转格式时要注意筛选逻辑。注意图片文件名要和 XML 文件名一一对应而且 VOC 的 train.txt 里只写文件名不含扩展名比如000001而不是000001.jpg。这是新手最容易忽略的点。4.2 COCO 是一个大 JSON结构错了连解析都过不去COCO 格式把整个数据集的元信息和标注塞进一个 JSON 文件里核心是五个字段info、licenses、images、annotations、categories。真正干活的是后三个。images是一个数组每个元素描述一张图{ id: 1, file_name: 000001.jpg, width: 500, height: 375 }annotations是标注数组每个元素描述一个目标实例{ id: 1, image_id: 1, category_id: 3, bbox: [100, 150, 100, 100], area: 10000, iscrowd: 0 }注意bbox是[x, y, w, h]单位像素(x,y) 是框左上角。category_id必须对应categories数组里的 id而categories里每个类别又有自己的 id 和 name{id: 3, name: bird, supercategory: animal}COCO 的每一张图都要有唯一的 id每个标注实例也要有唯一的 id并且通过image_id关联到图。新手写转换脚本时最容易犯的错就是 id 不连续、或者category_id和images里的 id 混用。4.3 YOLO 是每张图一个小 txt类别藏在文件名里YOLO 格式最简单也最松散每张图片对应一个 txt 文件名字和图片同名但扩展名是.txt。txt 里每一行是一个目标2 0.500000 0.600000 0.400000 0.266667五个数字依次是class_id, x_center, y_center, width, height。其中坐标全部相对原图宽高做了归一化所以值都在 0~1 之间。class_id 是从 0 开始的整数对应一个名为classes.txt不同框架叫法略有不同的文件按行排列类别名第一行是 id 0第二行是 id 1顺序绝对不能乱。YOLO 格式有个隐性约定它自己不带任何图片尺寸信息。如果你只知道 txt 不知道原图尺寸你连绝对坐标都恢复不出来。这也是为什么做反向转换YOLO→VOC/COCO时必须额外保留一份图片尺寸信息的原因后面实操部分会再强调。4.4 坐标系的换算关系把三个格式放在一起对比核心换算关系就一目了然格式存储位置坐标语义单位归一化VOCXMLxmin, ymin, xmax, ymax像素否COCOJSONx, y, w, h左上角宽高像素否YOLOTXTx_center, y_center, w, h归一化 0~1是转换公式不复杂VOC → YOLOx_center (xmin xmax) / 2 / widthy_center (ymin ymax) / 2 / heightw (xmax - xmin) / widthh (ymax - ymin) / heightCOCO → YOLOx_center x / width w / width / 2y_center y / height h / height / 2YOLO → COCOx (x_center - w / 2) * widthy (y_center - h / 2) * heightbbox_w w * widthbbox_h h * height这里有个容易绕晕的点COCO 的 (x,y) 是左上角而 YOLO 的 (x_center, y_center) 是中心点。换算时千万别直接把 COCO 的 x 当中心点用。我见过好几次类似错误结果就是所有框整体向右下角偏移半个框的距离训练出来框全偏。5. 互转实战先做中间结构再谈转格式5.1 为什么不要写 N×N 个转换函数新手一听要三种格式互转第一反应是写 6 个函数VOC→COCO、VOC→YOLO、COCO→VOC……。但更稳的做法是定义一个统一的中间结构所有格式先解析成这个结构再从这个结构导出成任意目标格式。这样只需要写 3 个解析函数 3 个导出函数而且逻辑清晰、好调试。我用 Python 的话中间结构就是一个普通 dict{ file_name: 000001.jpg, width: 500, height: 375, objects: [ {category: bird, bbox: [100, 150, 200, 250]}, # [xmin, ymin, xmax, ymax] ... ] }注意我这里统一把 bbox 存成绝对像素的[xmin, ymin, xmax, ymax]再做任何导出都基于这个表示能极大减少坐标系混乱带来的 bug。这个中间结构除了内存中用我还会以 JSON 格式落一份到磁盘相当于把整个数据集固化成一份标准答案后续不管要转成什么格式都从这份文件出发。5.2 VOC 转 YOLO 的代码与细节先解析 XMLimport xml.etree.ElementTree as ET def parse_voc(xml_path): tree ET.parse(xml_path) root tree.getroot() sample { file_name: root.find(filename).text, width: int(root.find(size/width).text), height: int(root.find(size/height).text), objects: [] } for obj in root.findall(object): name obj.find(name).text difficult int(obj.find(difficult).text) if obj.find(difficult) is not None else 0 if difficult 1: continue # 按需过滤难例 bndbox obj.find(bndbox) xmin float(bndbox.find(xmin).text) ymin float(bndbox.find(ymin).text) xmax float(bndbox.find(xmax).text) ymax float(bndbox.find(ymax).text) sample[objects].append({category: name, bbox: [xmin, ymin, xmax, ymax]}) return sample解析完成后导出 YOLO txtdef voc_to_yolo(sample, class_names): lines [] w sample[width] h sample[height] for obj in sample[objects]: cls_id class_names.index(obj[category]) xmin, ymin, xmax, ymax obj[bbox] x_center (xmin xmax) / 2 / w y_center (ymin ymax) / 2 / h box_w (xmax - xmin) / w box_h (ymax - ymin) / h # 越界保护避免出现 1 或 0 的值 x_center min(max(x_center, 0), 1) y_center min(max(y_center, 0), 1) box_w min(max(box_w, 0), 1) box_h min(max(box_h, 0), 1) lines.append(f{cls_id} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}) return \n.join(lines)这里有个细节VOC 的filename字段有时带路径比如JPEGImages/000001.jpg有时只有000001.jpg有时甚至不存在。所以解析端要做一个兜底如果filename缺失就用 XML 的文件名作为图片名。再有很多情况下 VOC 的 XML 宽高和真实图片宽高不一致最稳的办法是解析时直接用cv2.imread或PIL读一遍真实尺寸别完全信 XML 里的size。读图确实慢一点但换来的是坐标归一化的准确性值得。5.3 COCO 转 YOLO 的代码与细节COCO 转 YOLO 有两条索引要理清COCO 的category_id比如 3和 YOLO 的class_id比如 0、1、2。中间要做一个category_id → class_id的映射怎么映射取决于你class_names列表的定义。如果你希望 YOLO 的类别顺序按 COCO JSON 里categories数组的顺序来那就先遍历 categories 建一个字典import json def load_coco(coco_json_path): with open(coco_json_path, r, encodingutf-8) as f: coco json.load(f) img_info {img[id]: img for img in coco[images]} cat_id_to_name {cat[id]: cat[name] for cat in coco[categories]} samples {} for ann in coco[annotations]: if ann[iscrowd] 1: continue # 群体目标通常跳过 img_id ann[image_id] img img_info[img_id] if img_id not in samples: samples[img_id] { file_name: img[file_name], width: img[width], height: img[height], objects: [] } x, y, w, h ann[bbox] samples[img_id][objects].append({ category: cat_id_to_name[ann[category_id]], bbox: [x, y, x w, y h] # 转成 xmin, ymin, xmax, ymax }) return list(samples.values())导出成 YOLO 时class_id 的映射要和训练用的classes.txt严格一致。一个常见错误是COCO JSON 里 categories 顺序是[1: bird, 2: cat]然后你导出 YOLO 时按这个顺序生成了classes.txt看起来合情合理。但如果你换了别人的脚本那个脚本默认从 train.txt 里提取类别或者按字母序排就全乱了。所以每次导出 YOLO 时同时生成classes.txt并且提前确认训练 YAML 里的names和这个文件逐行一致。还有一个坑COCO 里一张图可能没有任何 annotationJSON 里images有它但annotations里找不到对应记录。这种图在 YOLO 里就会生成一个空 txt。空 txt 不是错误但训练时如果你用了mosaic增强空图可能会被拼进去当负样本具体好坏看你的策略。我习惯在导出时把空图单独列一个清单由你决定是删掉还是保留。5.4 反向转换为什么必须保留原图宽高YOLO→COCO 和 YOLO→VOC 的难点在于 txt 只有归一化坐标没有原图尺寸。如果你手头有原图文件可以直接读图拿宽高如果没有就只能依赖一份额外的元数据。我来说个真实教训有一次我从标注平台导出一批 YOLO 格式的数据回头要转 COCO 给另一个模型训练但原始图片在另一台机器上我只能先拿到文件列表没图片。想着 txt 里 0.5 0.5 这种坐标看着也够用就写死一个统一尺寸去转。结果某些图片实际是 1920x1080某些是 1280x720转出来 COCO 的像素框全错位白白返工了两天。所以我的强烈建议是在你最初做任何格式转换时就同步生成一份dataset_meta.json记录每张图的file_name / width / height。有了它反向转换就只是算术问题def yolo_to_coco_line(yolo_line, img_w, img_h): cls_id, x_center, y_center, w_norm, h_norm map(float, yolo_line.split()) x (x_center - w_norm / 2) * img_w y (y_center - h_norm / 2) * img_h w w_norm * img_w h h_norm * img_h return int(cls_id), [x, y, w, h]反向转换还要注意YOLO 的坐标在归一化时可能被裁剪到 [0,1]所以反向算出来的x w理论上等于img_w但因为有浮点误差和越界裁剪建议用min(max(...))做一次钳制保证坐标不出界。6. 一次搞对的验证清单和那些踩过的坑6.1 转换完先做三件事每次转换完不管你是从哪种格式转到哪种格式我建议立刻做三件事再进入训练第一可视化抽查。随机抽 20~50 张图把转换后的框画回原图按类别着不同颜色人眼扫一遍。这个动作能拦截掉 80% 以上的坐标系错误和类别错位。脚本不复杂cv2.rectangle cv2.putText输出到一个visualize/目录翻一遍也就几分钟。第二统计自检。写个脚本统计每个类别的标注框数量、框的宽高分布、以及是否存在异常值比如宽高为 0、坐标超出 [-0.05, 1.05] 范围。类别分布能帮你发现某个类别可能在转换时被整体漏掉框的尺寸分布能帮你发现归一化的分母是不是错了——如果某一类框的宽高全部小于 0.01大概率是宽度和高度单位搞错了。第三划分一致性检查。训练集、验证集、测试集的图片名单必须和转换后的标签文件一一对应。我习惯用 Python 断言每张图片文件都存在且对应的标签文件也存在YOLO 场景或者 XML/JSON 里能找到对应项。宁可跑一次挺慢的完整校验也不要等训练中途再发现缺文件。6.2 常见错误 Top 5 和根因错误现象根因规避方法训练 loss 炸到 NaN坐标未归一化或归一化时除以了 0解析时校验宽高 0统一在中间结构存绝对坐标导出时再算归一化所有框整体偏移把 COCO 左上角坐标当成了 YOLO 中心点严格按照公式换算可视化抽查某个类别的框全部消失classes.txt 的类别顺序和训练 YAML 不一致或 category_id→class_id 映射写错每次导出时同时导出 classes.txt并在训练配置里逐行对照图片能读但标签对不上文件名大小写、扩展名不一致.jpg / .JPG / .jpeg或 Windows 路径分隔符问题统一用小写扩展名路径统一为相对路径脚本里做字符串规范化转回 COCO 后框偏移YOLO→COCO 时缺少原图宽高用了错误的统一尺寸项目初始化时就生成 dataset_meta.json 记录宽高其中第一条我要多说一句很多新手在归一化时直接把(xmax - xmin)当w这没错但如果你的xmax和xmin是字符串或者不小心被整型截断成 0除出来就是无穷大loss 立刻爆炸。所以解析 XML 和 JSON 时坐标字段一定要转成float并且对宽高做一次if box_w 0: continue的过滤。这种 0 宽 0 高的脏标注在公开数据里其实并不少见。6.3 训练前的最后一公里格式转换完成不等于数据集就绪。训练前的最后一公里通常还有三件事数据集划分。不要随机划分按场景或视频序列划分避免同一场景的相似帧同时出现在训练集和验证集导致验证结果虚高。比如车载视频连续帧按时间切段前 80% 段做训练后 20% 段做验证。类别重算与 anchors。YOLO 自带聚类 anchors 的脚本换数据集后建议重新聚类不要沿用 COCO 的默认 anchors。目标尺度差异大的数据集默认 anchors 会导致小目标漏检严重。预训练模型类别数对齐。如果要用 COCO 预训练权重微调注意你的类别数如果和 COCO 不一致最后一层 head 的输出维度要改。改完先冻结 backbone 训几个 epoch再解冻全模型这是比较稳的路径。把这些都跑通了再启动训练。此时如果 loss 还不正常至少你可以底气十足地说数据这边我确认过了然后专注排查模型和超参。我自己的习惯是每次转换完数据都跑一个 5~10 轮的冒烟训练不只是看 loss更主要的是用tensorboard或者日志里的图片预览看预测框大概落在哪。只要框大致贴住目标说明数据管线没问题如果框满天飞赶紧停下来查数据别硬训。最后分享一个我踩过多次坑之后养成的习惯所有转换脚本的输入输出都做成可重复执行的函数并在每次转换完成后自动打印一行摘要比如从 VOC 解析 1200 张图共 4500 个目标其中过滤 difficult 80 个导出 YOLO 1200 个 txt。这行摘要看着不起眼但它能让你在三天后回顾时一眼看出当次转换有没有异常也能在数据集版本迭代时快速定位是哪一步出了问题。检测数据集的制作就是这样一个耐心活前面每多花一分钟做校验后面训练和调优阶段就能少熬一个通宵。
返回列表