检测数据集制作全流程:收集、标注、VOC、COCO、YOLO 格式互转一次搞对
检测数据集制作全流程:收集、标注、VOC、COCO、YOLO 格式互转一次搞对
目标检测项目里,最容易被低估、又最容易致命的一步是数据集。
模型效果不好,第一件该查的永远是数据。而数据出问题,往往不是"图不够",是格式不对、坐标越界、划分泄漏这些看不见的坑——它们不会报错,只会让你的指标悄悄失真。
这篇按做事的顺序走一遍:想清楚要什么 → 收集清洗 → 标注 → 格式互转 → 划分 → 体检。每一步给出可直接用的脚本,最后附一份体检清单。
系列导航:本篇是数据准备。往下看标注质量看《数据增强怎么配》,看指标看《检测评估指标怎么看》,看上线看《检测模型部署成 Web 服务》。

一、先想清楚:一份能用的数据集要满足什么
动手之前,先对着这 5 条检查。少一条,后面都会以"模型不行"的形式还给你。
| 要求 | 说明 | 不满足的后果 |
|---|---|---|
| 图文一一对应 | 每张图都有同名标签(或无目标的空标签) | 训练时静默跳过,实际样本数比你以为的少 |
| 坐标在范围内 | 归一化坐标必须落在 0~1,且宽高为正 | 训练直接报错或学出乱框 |
| 类别定义清晰 | 类别名唯一、互斥、无重叠含义 | 同一目标被标成两类,模型学不会 |
| 划分无泄漏 | 同源数据(同视频/同批次)不跨集合 | val 指标虚高,上线就崩 |
| 有独立 test | 训练时绝不碰的那一份 | 无法判断真实泛化 |
最典型的翻车顺序:随手标一批 → 直接训 → 看一个 mAP 就交付 → 现场发现漏检 → 回头查数据,发现是划分泄漏。所以下面每一步都留一个可复核的产物。
二、收集与清洗:先减量,再增量
数据不是越多越好,是越干净越好。先做三件减法:
去重复。 同一张图被存了两份、或同一视频截了相邻帧,会让训练集虚胖、val 虚高。用感知哈希找出近重复:
# pip install imagehash pillow
import glob, os
from PIL import Image
import imagehash
def find_duplicates(img_dir, threshold=6):
hashes = {}
for p in glob.glob(os.path.join(img_dir, "*")):
try:
h = imagehash.phash(Image.open(p).convert("RGB"))
except Exception:
continue
for prev, ph in hashes.items():
if h - ph <= threshold: # 汉明距离越小越像
print("疑似重复:", os.path.basename(p), "<->", os.path.basename(prev))
hashes[p] = h
剔除损坏与异常。 下载中断、格式损坏、纯色图都会混进来。检查可读性与尺寸:
import glob
from PIL import Image
def scan_broken(img_dir, min_side=32):
bad = []
for p in glob.glob(f"{img_dir}/**/*", recursive=True):
try:
im = Image.open(p)
im.verify() # 校验文件完整性
im = Image.open(p) # verify 后需重新打开
w, h = im.size
if w < min_side or h < min_side:
bad.append((p, f"尺寸过小 {w}x{h}"))
except Exception as e:
bad.append((p, f"无法打开: {e}"))
return bad
剔除无目标图。 场景图(不含任何待检目标)在检测任务里通常是噪声。如果确实要当负样本,保留少量即可,别让它们淹没正样本。
合规提醒:爬取的图片要注意来源站点的服务条款与版权,用于训练前先确认授权范围。
三、标注:工具与规范
工具按格式和场景选:
| 工具 | 输出格式 | 适合 |
|---|---|---|
| LabelImg | VOC XML / YOLO txt | 矩形框,最快上手 |
| labelme | JSON(多边形/矩形) | 需要分割或复杂形状 |
| X-AnyLabeling | 多种,支持自动标注 | 想先用模型预标注再人工修 |
| Roboflow | 在线,可导出多种 | 团队协作、需要在线管理 |
工具只是载体,真正决定质量的是标注规范。开工前把这几条写下来,多人协作时尤其重要:
- 边界框紧贴目标外沿,不刻意留白、不切掉目标;
- 遮挡目标:标可见部分,还是标完整框?必须统一(推荐按可见部分,并在规范里写明);
- 极小目标(小于某像素数)标不标?定一个下限;
- 类别命名统一小写英文 + 下划线(
scratch、oil_stain),避免中文与空格(跨工具最容易出问题); - 不确定的样本单独放
unclear/,不要硬标。
四、三种格式:VOC / COCO / YOLO
三者的差别就三点:存在哪、坐标怎么算、类别怎么表示。

| 维度 | VOC | COCO | YOLO |
|---|---|---|---|
| 存储 | 每图一个 XML | 全库一个 JSON | 每图一个 txt |
| 坐标 | 绝对像素 xmin ymin xmax ymax |
绝对像素 [x, y, w, h] |
归一化 cx cy w h(0~1) |
| 类别 | 名字(<name>) |
数字 id + categories 表 | 数字下标(对应 names 顺序) |
| 常用场景 | 老数据集、LabelImg | 评测/COCO 系模型 | YOLO 系训练 |

记住一句话:VOC 和 COCO 都是绝对像素,YOLO 是归一化。互转时最大的错就是忘了除以图像宽高。
五、格式互转:附脚本
VOC → YOLO
import os, glob
import xml.etree.ElementTree as ET
from PIL import Image
CLASSES = ["defect", "scratch"] # 顺序必须与 data.yaml 的 names 一致
cls_id = {c: i for i, c in enumerate(CLASSES)}
def voc_to_yolo(xml_path, out_dir):
root = ET.parse(xml_path).getroot()
w = int(root.find("size/width").text)
h = int(root.find("size/height").text)
lines = []
for obj in root.findall("object"):
name = obj.find("name").text
if name not in cls_id:
continue
b = obj.find("bndbox")
xmin, ymin = float(b.find("xmin").text), float(b.find("ymin").text)
xmax, ymax = float(b.find("xmax").text), float(b.find("ymax").text)
lines.append("%d %.6f %.6f %.6f %.6f" % (
cls_id[name],
(xmin + xmax) / 2 / w, # cx 归一化
(ymin + ymax) / 2 / h, # cy 归一化
(xmax - xmin) / w, # w 归一化
(ymax - ymin) / h, # h 归一化
))
stem = os.path.splitext(os.path.basename(xml_path))[0]
with open(os.path.join(out_dir, stem + ".txt"), "w", encoding="utf-8") as f:
f.write("\n".join(lines))
for x in glob.glob("voc_annotations/*.xml"):
voc_to_yolo(x, "labels")
YOLO → VOC(反向)
import glob, os
from PIL import Image
def yolo_to_voc(img_path, lbl_path, classes, out_xml):
w, h = Image.open(img_path).size
objs = []
if os.path.exists(lbl_path):
for line in open(lbl_path, encoding="utf-8"):
p = line.split()
if len(p) != 5:
continue
cid, cx, cy, bw, bh = int(p[0]), *map(float, p[1:])
xmin = int((cx - bw / 2) * w); ymin = int((cy - bh / 2) * h)
xmax = int((cx + bw / 2) * w); ymax = int((cy + bh / 2) * h)
objs.append((classes[cid], xmin, ymin, xmax, ymax))
xml = ['<annotation>', f' <size><width>{w}</width><height>{h}</height></size>']
for name, x1, y1, x2, y2 in objs:
xml += [' <object>', f' <name>{name}</name>',
f' <bndbox><xmin>{x1}</xmin><ymin>{y1}</ymin>'
f'<xmax>{x2}</xmax><ymax>{y2}</ymax></bndbox>', ' </object>']
xml.append('</annotation>')
open(out_xml, "w", encoding="utf-8").write("\n".join(xml))
COCO → YOLO
COCO 的坐标是绝对像素 [x, y, w, h],且所有标注集中在一个 JSON 里,需要按 image_id 归组:
import json, os
from collections import defaultdict
def coco_to_yolo(coco_json, img_dir, out_dir, class_names):
data = json.load(open(coco_json, encoding="utf-8"))
id2name = {c["id"]: c["name"] for c in data["categories"]}
name2idx = {n: i for i, n in enumerate(class_names)}
by_img = defaultdict(list)
for a in data["annotations"]:
by_img[a["image_id"]].append(a)
for img in data["images"]:
w, h = img["width"], img["height"]
lines = []
for a in by_img.get(img["id"], []):
x, y, bw, bh = a["bbox"] # 绝对像素
idx = name2idx.get(id2name[a["category_id"]])
if idx is None:
continue
lines.append("%d %.6f %.6f %.6f %.6f" % (
idx, (x + bw / 2) / w, (y + bh / 2) / h, bw / w, bh / h))
stem = os.path.splitext(img["file_name"])[0]
open(os.path.join(out_dir, stem + ".txt"), "w", encoding="utf-8").write("\n".join(lines))
YOLO 训练用的 data.yaml 长这样,names 的顺序必须和转换时的类别下标一致:
path: ./dataset
train: images/train
val: images/val
test: images/test
names:
0: defect
1: scratch
六、划分 train / val / test:别让同源数据泄漏
这是最隐蔽、也最致命的一步。如果同一段视频的相邻帧、或同一批拍摄的图被分到了 train 和 val 两边,val 指标会虚高——因为模型在验证时见过几乎一样的图。
正确做法是按"组"划分,而不是按单张图随机分:
import random
from collections import defaultdict
def group_split(stems, group_of, ratios=(0.8, 0.1, 0.1), seed=42):
"""group_of: {stem: group_key},同一视频/同一批次给同一个 key。"""
by_group = defaultdict(list)
for s in stems:
by_group[group_of[s]].append(s)
keys = list(by_group)
random.Random(seed).shuffle(keys) # 固定种子,划分可复现
n = len(keys)
n_tr, n_va = int(n * ratios[0]), int(n * ratios[1])
tr, va, te = keys[:n_tr], keys[n_tr:n_tr + n_va], keys[n_tr + n_va:]
pick = lambda ks: [s for k in ks for s in by_group[k]]
return pick(tr), pick(va), pick(te)
怎么定 group_key:同一视频的帧 → 用视频名;同一批次拍摄 → 用批次号;同一张原图裁剪/增强出来的 → 用原图名。判断标准很简单:"这两张图会不会让人一眼看出是同一个东西"——会,就必须同组。
七、数据集体检脚本
标完之后,跑一遍体检。这些问题都不会报错,只会让指标失真:
import glob, os
from PIL import Image
from collections import Counter
def health_check(img_dir, lbl_dir, num_classes):
imgs = {os.path.splitext(os.path.basename(p))[0]: p
for p in glob.glob(os.path.join(img_dir, "*"))}
lbls = {os.path.splitext(os.path.basename(p))[0]: p
for p in glob.glob(os.path.join(lbl_dir, "*.txt"))}
problems, dist = [], Counter()
for stem in imgs.keys() - lbls.keys():
problems.append("有图无标签: " + stem)
for stem in lbls.keys() - imgs.keys():
problems.append("有标签无图: " + stem)
for stem in imgs.keys() & lbls.keys():
w, h = Image.open(imgs[stem]).size
for i, line in enumerate(open(lbls[stem], encoding="utf-8"), 1):
p = line.split()
if len(p) != 5:
problems.append(f"{stem} 第{i}行字段数={len(p)}")
continue
cid, cx, cy, bw, bh = int(p[0]), *map(float, p[1:])
dist[cid] += 1
if cid < 0 or cid >= num_classes:
problems.append(f"{stem} 第{i}行类别越界: {cid}")
if not (0 <= cx <= 1 and 0 <= cy <= 1 and 0 < bw <= 1 and 0 < bh <= 1):
problems.append(f"{stem} 第{i}行坐标越界")
return problems, dist
体检要看五件事:

- 图文配对:有没有"有图无标签"(会被静默跳过)或"有标签无图";
- 坐标合法:归一化是否在 0~1、宽高是否为正;
- 类别下标:是否越界、是否和
names对得上; - 类别分布:某一类只有几十个框时,别指望它准;
- 划分泄漏:抽查 val 里的图,是否和 train 有同源。
八、卡住时的排查顺序
数据有问题时,按这个顺序查,别跳步:
- 图能打开吗 —— 先
scan_broken过一遍,排除损坏文件; - 标签有对应吗 —— 跑
health_check,看图文配对; - 坐标在范围内吗 —— 体检脚本的"坐标越界"项;
- 类别对得上吗 ——
names顺序 vs 标签里的下标; - 划分泄漏了吗 —— 抽查 val 与 train 是否同源;
- 可视化抽查 —— 把框画到图上,肉眼确认 10~20 张。
最后一步别省。 前五步是脚本能查的,第六步是脚本查不出的"标错了但格式没错"——框偏了、标错类别、漏标目标,只有画出来看才知道。
最后一句:数据集的质量上限,就是模型效果的上限。 与其花时间调参,不如先把"图文配对、坐标合法、划分无泄漏"这三件事做扎实——它们花的时间最少,收益最大。
说明:文中脚本为通用实现,依赖
pillow、imagehash(去重);格式定义(VOC XML / COCO JSON / YOLO txt)以各官方仓库与所用框架文档为准。类别命名、遮挡标注等规范为工程经验,具体口径需结合自己的项目与团队统一。所有代码请先在小批量数据上验证,再全量执行。
浙公网安备 33010602011771号