检测数据集制作全流程:收集、标注、VOC、COCO、YOLO 格式互转一次搞对

检测数据集制作全流程:收集、标注、VOC、COCO、YOLO 格式互转一次搞对

目标检测项目里,最容易被低估、又最容易致命的一步是数据集。

模型效果不好,第一件该查的永远是数据。而数据出问题,往往不是"图不够",是格式不对、坐标越界、划分泄漏这些看不见的坑——它们不会报错,只会让你的指标悄悄失真。

这篇按做事的顺序走一遍:想清楚要什么 → 收集清洗 → 标注 → 格式互转 → 划分 → 体检。每一步给出可直接用的脚本,最后附一份体检清单。

系列导航:本篇是数据准备。往下看标注质量看《数据增强怎么配》,看指标看《检测评估指标怎么看》,看上线看《检测模型部署成 Web 服务》。

image

一、先想清楚:一份能用的数据集要满足什么

动手之前,先对着这 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 系训练

image

记住一句话: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

体检要看五件事:

image

  1. 图文配对:有没有"有图无标签"(会被静默跳过)或"有标签无图";
  2. 坐标合法:归一化是否在 0~1、宽高是否为正;
  3. 类别下标:是否越界、是否和 names 对得上;
  4. 类别分布:某一类只有几十个框时,别指望它准;
  5. 划分泄漏:抽查 val 里的图,是否和 train 有同源。

八、卡住时的排查顺序

数据有问题时,按这个顺序查,别跳步:

  1. 图能打开吗 —— 先 scan_broken 过一遍,排除损坏文件;
  2. 标签有对应吗 —— 跑 health_check,看图文配对;
  3. 坐标在范围内吗 —— 体检脚本的"坐标越界"项;
  4. 类别对得上吗 —— names 顺序 vs 标签里的下标;
  5. 划分泄漏了吗 —— 抽查 val 与 train 是否同源;
  6. 可视化抽查 —— 把框画到图上,肉眼确认 10~20 张。

最后一步别省。 前五步是脚本能查的,第六步是脚本查不出的"标错了但格式没错"——框偏了、标错类别、漏标目标,只有画出来看才知道。

最后一句:数据集的质量上限,就是模型效果的上限。 与其花时间调参,不如先把"图文配对、坐标合法、划分无泄漏"这三件事做扎实——它们花的时间最少,收益最大。


说明:文中脚本为通用实现,依赖 pillow、imagehash(去重);格式定义(VOC XML / COCO JSON / YOLO txt)以各官方仓库与所用框架文档为准。类别命名、遮挡标注等规范为工程经验,具体口径需结合自己的项目与团队统一。所有代码请先在小批量数据上验证,再全量执行。

posted @ 2026-09-25 00:38  橘和柠  阅读(4)  评论(0)    收藏  举报