项目跑通官方示例之后,真正卡住绝大多数人的一步从来不是改模型结构,而是把自己的业务数据变成模型能吃的格式。我见过太多这样的情况:拿现成公开数据训练,指标漂亮得像模像样,一换到自己的摄像头画面,mAP 直接腰斩,回头查了半天网络结构、学习率、损失曲线,最后发现根子在数据集——类别定义含糊、标注框贴边裁掉半个目标、训练集和验证集里混进了同一段视频的相邻帧。这篇就围绕“制作自己的 YOLOv8 数据集”这件事,把从类别设计、采图、标注、格式转换、划分到体检的整条链路拆开讲清楚。适合两类人看:一是刚跑完官方示例、准备喂自己数据的新手,二是做过一两个项目但模型效果总差一口气、想回头排查数据问题的朋友。文中给的脚本都可以直接改路径就用,坑也都是实打实踩过的。
1. 类别表决定了这个数据集的上限
动手采图之前,先坐下来把类别表写死。这一步看起来最像“ paperwork”,实际上它决定了后面所有工作的返工率。类别表一旦在标注到一半的时候改动,前面标好的文件全部要重命名、重映射,几百张还能忍,几千张就是灾难。
1.1 类别粒度必须互斥且同级
最常见的错误是把不同抽象层级的东西放进同一张表里。比如做一个工地安全检测,类别写成“人、安全帽、未戴安全帽的头部、反光背心”,这就乱了:“人”和“未戴安全帽的头部”在空间上重叠,模型每次看到一个人,都要在“标人还是标头”之间做选择,标签噪声直接爆掉。正确的做法是拆成两个互不干扰的维度:一个维度是“person”,另一个维度是“helmet”,至于“有没有戴”,交给后处理逻辑用框的包含关系去判断,而不是塞进类别里。
另一个高频问题是把状态当类别。像“开着的门、关着的门、半开的门”,如果你的目标是统计门的开合状态,那确实需要三个类;但如果你的目标只是“找到门在哪”,那就一个类,状态交给下游分类模型。判断标准很简单:问自己一句——这些子类在图像上的视觉特征差异,是否大到值得占用一个独立的检测通道?差异不明显、样本又少,合并成一个类往往效果更好。
还有一个隐性坑是类别名的拼写和大小写。有人标的时候写“Car”,配置文件里写“car”,训练脚本按 names 列表的顺序去映射索引,索引对得上所以不报错,但你在推理结果里读到的名字就是错的,排查起来非常浪费时间。定表的时候就全小写、下划线分隔,别用空格和中文。
1.2 每个类至少要有多少实例
这个问题没有标准答案,但有个经验区间可以参照。想让某一类的 AP 看起来可信,验证集里至少要有 20 到 30 个该类的实例,否则单类的指标波动会非常大,这次 0.6 下次 0.85,你根本不知道是模型变了还是抽样抽偏了。训练集这边,简单、背景干净、外观一致的类(比如固定工位上的一个指示灯),一两百个实例就够;外观变化大的类(比如各种颜色款式的安全帽、不同角度的车辆),起步建议五百个实例以上,而且必须覆盖不同光照和遮挡程度。
我一般会在采图阶段就做一个粗略的账:目标类别数乘以每类的最低实例数,再乘以一个 1.2 的冗余系数(因为标注过程中一定会有一批图被判为不可用),得到大概需要标注多少个框。这个数字出来之后,你再去判断项目周期是否现实,比闷头采了五千张图最后发现标不完要明智得多。
1.3 背景图不是凑数,是压误检的
很多人做数据集只采“有目标”的图,拍完发现模型在空场景里疯狂误检。原因很简单:模型从来没见过“这里什么都没有”的样子,它学到的决策边界就是“任何纹理都可能是我要找的东西”。
解决办法是掺入一批纯背景图——只放图,不放任何 txt,或者放一个空的 txt 文件。比例上一般占训练集的 5% 到 10% 就够,太多会拉低正样本的学习效率。背景图的选择要有针对性:把你部署环境里那些容易被误检的东西专门拍下来,比如反光的地面、墙上的海报、树影、监控画面里的噪点区域。这比随机拍一堆天空有效得多。
注意:背景图在 YOLOv8 的数据组织里,图片放在 images/train 下,对应的 labels/train 下要有一个同名的空 txt 文件。有些版本对“完全没有对应 txt”的处理是直接跳过,有些会报警告,养成放空文件的习惯可以省掉一类玄学报错。
2. YOLO 的 txt 标签到底在描述什么
格式这东西,看文档觉得一句话就讲完了,真到自己写转换脚本的时候,坐标系搞反、归一化基准搞错、x/y 和 w/h 顺序写反,三种错误能各花掉你半小时。这一节把一行标签拆开揉碎讲。
2.1 一行标签的五个数字
YOLO 检测格式每行是五个数:类别索引、中心点 x、中心点 y、框宽、框高。后四个全部是相对于图像宽高的归一化值,范围 0 到 1。注意是中心点,不是左上角;注意是整张图的宽高做分母,不是某个裁剪区域。
举一个具体例子:一张 1920×1080 的图,某个目标的左上角在 (400, 300),右下角在 (800, 700)。那么:
- 中心点 x = (400 + 800) / 2 = 600,归一化后 600 / 1920 ≈ 0.3125
- 中心点 y = (300 + 700) / 2 = 500,归一化后 500 / 1080 ≈ 0.4630
- 宽 = 400,归一化后 400 / 1920 ≈ 0.2083
- 高 = 400,归一化后 400 / 1080 ≈ 0.3704
最终写成0 0.3125 0.4630 0.2083 0.3704。数值保留几位小数没有硬性要求,六位足够,多了没意义。
2.2 从 XML 标注转过来的完整脚本
老项目里积累的标注大概率是 XML 格式(左上右下坐标 + 尺寸),迁移到 YOLO 需要转换。下面这段脚本处理了三个容易被忽略的细节:类别名映射、坐标裁剪、无效框过滤。
import xml.etree.ElementTree as ET from pathlib import Path def convert_one(xml_path, out_dir, name2id, eps=1e-4): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") W = float(size.find("width").text) H = float(size.find("height").text) lines = [] for obj in root.findall("object"): cls_name = obj.find("name").text.strip().lower() if cls_name not in name2id: continue # 不在类别表里的直接丢弃,不要静默塞成 0 类 bnd = obj.find("bndbox") x1 = float(bnd.find("xmin").text) y1 = float(bnd.find("ymin").text) x2 = float(bnd.find("xmax").text) y2 = float(bnd.find("ymax").text) # 先裁到图像范围内,再归一化 x1, x2 = max(0.0, min(x1, W)), max(0.0, min(x2, W)) y1, y2 = max(0.0, min(y1, H)), max(0.0, min(y2, H)) bw, bh = x2 - x1, y2 - y1 if bw < eps or bh < eps: continue # 退化框直接丢掉,留着会让 loss 变 NaN cx, cy = (x1 + x2) / 2 / W, (y1 + y2) / 2 / H lines.append(f"{name2id[cls_name]} {cx:.6f} {cy:.6f} " f"{bw / W:.6f} {bh / H:.6f}") out_path = Path(out_dir) / (Path(xml_path).stem + ".txt") out_path.write_text("\n".join(lines), encoding="utf-8") return len(lines) if __name__ == "__main__": name2id = {"helmet": 0, "vest": 1, "person": 2} xml_dir = Path("raw_annotations") out_dir = Path("labels/train") out_dir.mkdir(parents=True, exist_ok=True) total = 0 for x in xml_dir.glob("*.xml"): total += convert_one(x, out_dir, name2id) print("boxes written:", total)这段代码里有几个点值得单独说一下。name2id里没有的类别我选择丢弃而不是报错,是因为实际项目里经常混进几个历史遗留的脏类别,直接丢弃能让流程先跑通,但一定要打印丢失统计,不然会悄悄少掉一批框。坐标先裁剪再归一化,是为了处理标注时手滑把框拖出图像边界的情况——如果先归一化再裁剪,得到的宽高可能变成负数。eps判据看似多余,实际上宽或高正好等于 0 的框一旦进入训练,IoU 计算出 NaN,整个 batch 的 loss 都会变成 NaN,而报错信息完全不会指向标注文件,排查能耗掉一下午。
2.3 转换之后的三个必查项
脚本跑完不要直接进训练。先做三件事:
第一,统计有多少张图对应了空 txt。如果这个比例高得离谱(比如超过 15%),说明要么类别映射写错了导致绝大多数框被丢弃,要么 XML 解析路径不对读了个寂寞。
第二,随机抽三张图画框 visualize 一下,肉眼看框的位置是否贴合目标。这一步能一次性抓出坐标系搞反、宽高对调这类低级错误,而这些错误在 loss 曲线上表现为“能收敛但收敛到很差的水平”,非常迷惑人。
第三,检查框的宽高分布。把所有框的归一化面积统计出来,如果出现大量面积接近 1.0 的框,几乎可以确定是归一化分母用错了(比如用了某个裁剪块的尺寸而不是原图尺寸)。
3. 标注这件事,效率靠流程而不是手速
五千张图的标注量,纯手拉框大概需要一个人连续干一周。用对方法能压到两三天,而且质量更稳。核心思路是把“找目标”和“确认目标”拆成两步,让机器干前一步。
3.1 预标注流水线怎么搭才不埋雷
预标注的基本玩法是:先用一个通用模型或者上一版业务模型对全量图跑推理,把结果直接写成 YOLO txt 作为初始标签,人工只做增删改。这一步能省掉 60% 以上的拉框动作,但有一个必须警惕的副作用:模型的漏检会变成人的漏检。
人的注意力受初始框影响很大。一张图上已经画了三个框,标注员扫一眼觉得“差不多就这些”,第四个小目标就被跳过了。而如果是一张白图,他反而会老老实实从左到右扫一遍。这种偏差在预标注流程里非常隐蔽,因为它不会在数值上表现出来,只会让某一类小目标在后期评估时莫名其妙地低。
应对办法有两个。一是预标注用的模型置信度阈值调低,宁可多框几个错的,让人去做“删”的动作,删比补轻松得多。二是设置一定比例的“盲标图”——随机抽 10% 的图不跑预标注,直接给空白标注,用于后期和预标注组的标注质量做对比。这个对比数据能帮你判断预标注到底引入了多少漏标。
3.2 标注规范要写成能执行的具体句子
“尽量贴着目标边缘”这种话等于没写。好的规范应该是这种颗粒度:
| 场景 | 明确规定 |
|---|---|
| 目标被遮挡超过 70% | 不标 |
| 目标被遮挡 30% 到 70% | 标可见部分,但要包含少量被遮挡区域以保持框的连续性 |
| 目标在图像边缘被裁掉 | 标可见部分,框可以在图像边界处截断 |
| 密集目标间距小于框宽的 10% | 允许框有轻微重叠,不允许合并成一个框 |
| 反光/镜像/水面倒影 | 不标 |
把这几条写成一个文档贴在标注工具旁边,比事后返工划算一百倍。还有一个技巧是给每个类别做一个“正例图集”——挑 10 到 20 张标注得最标准的图存起来,标注员遇到拿不准的看图集,比问人快。
3.3 多人协作的切分方式决定了几倍返工
多人标注千万不要按图片随机分。同一个场景、同一段视频出来的图如果被分给两个人,两人的边界习惯不同(一个习惯贴紧边缘,一个习惯留两像素),模型在训练时会看到自相矛盾的监督信号,学出来的框位置会很飘,表现在指标上就是定位误差偏大。
正确的切法是按视频源、按拍摄批次、按场景整块切分。一个人负责一个场景的全部图片,风格在场景内部保持一致。合并的时候写一个小脚本检查一下 ID 映射是否一致——有人从 0 开始编号,有人从 1 开始,合并后类别全错位,这种事故真的会发生。
4. 划分与配置文件:跑不起来八成错在这里
数据集组织成什么目录结构、配置文件怎么写,官方文档写得很清楚,但报错信息往往指不到真正的问题上。这一节把常见的组织方式和报错对应关系理清楚。
4.1 目录结构就两种,别自己发明
YOLOv8 认的目录结构有两种。第一种是配置文件里分别指定图片路径和标签路径,标签路径用images替换成labels来推导;第二种是标准的 images/labels 平行目录:
dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── data.yaml第二种是最省心的,因为标签路径的推导规则就是“把路径里第一个 images 替换成 labels”,所以 labels 必须和 images 同级,且内部的 train/val 子目录名完全一致。我见过有人写成labels/training,然后整个训练过程一个框都没读到,loss 一直是 0,还以为模型坏了。
4.2 data.yaml 里每个字段在干什么
path: /home/user/dataset train: images/train val: images/val names: 0: helmet 1: vest 2: personpath是根目录,train和val是相对根目录的路径。这三行是全篇最容易出错的地方,几个具体的坑:
路径里带中文或者空格,在某些环境下会静默失败(找不到文件但不报错,只报数据集为空)。~这种家目录缩写不会被展开,必须写绝对路径或者相对当前工作目录的真实路径。Windows 上的反斜杠在 YAML 里被当作转义符,必须写成正斜杠或者双反斜杠。names用字典形式比列表形式安全,因为字典显式绑定了索引,列表形式一旦顺序写错就是错位且无感知。
还有一个细节:配置文件里不用写nc。类别数由names的长度直接推导,写了nc但和names数量不一致,会得到一个非常奇怪的报错。
4.3 三种高频报错和它们的真实原因
| 报错/现象 | 真实原因 | 处理 |
|---|---|---|
No labels found in ... | labels 目录名和 images 不平行,或子目录名不一致 | 检查层级,确认 labels/train 存在 |
| 训练正常但 mAP 恒定 0 | txt 内容为空或类别索引越界 | 抽查 txt,确认索引小于类别数 |
| 第二次启动比第一次慢很多又突然变快 | 缓存文件 .cache 缺失或过期 | 删除 labels 目录下的 .cache 重建 |
第三种情况值得多说一句。YOLOv8 第一次扫描数据集会生成一个.cache缓存标签信息。如果你在生成缓存之后又改了标注文件,缓存不会自动失效,训练用的还是旧标签。症状是“明明改了标注但效果没变”。处理方法很简单:每次改完标注就手动把 labels 目录下的.cache文件删掉。这个坑我在两个项目里各踩了一次,第二次才记住。
4.4 划分比例和分层抽样
7:2:1 是常见配置,但小数据集(几千张以内)我更倾向 8:2,把验证集压到够用就行,因为验证集的作用是提供可信的评估信号,样本太少反而让指标失去参考价值。
真正重要的是分层。随机划分在大数据集上没问题,在小数据集上会导致某些类的实例全部落进训练集,验证集里一个没有,那这个类在评估表里直接显示为 0,你会误以为模型学不会,其实是没测到。稳妥的做法是先按类别统计实例数,再按比例分配到各划分里,保证每类在验证集中至少有 20 个以上实例。
还有一个必须守住的底线:同一个视频抽出来的帧、同一次连拍的多张图,必须整组进入同一个划分。相邻帧之间差异极小,一旦跨划分,验证集指标会虚高十几个点,等你上线才发现模型泛化能力根本不达标。
5. 训练之前,先给数据集做一次体检
数据集做完了,别急着开训。花二十分钟做一次体检,能省掉后面几小时的瞎调参。体检不用搞得很复杂,几个脚本就够了。
5.1 抽样可视化是最划算的一步
随机抽 20 到 30 张图,把标签框画上去,拼成一张图看。这一步能抓出来的问题包括:框整体偏移(归一化分母错)、框宽高对调、类别索引错位(同一个类别的框颜色不对)、标签文件名和图片文件名对不上(画出来是白图)。
import random from pathlib import Path from PIL import Image, ImageDraw def draw_one(img_path, lbl_path, names): im = Image.open(img_path).convert("RGB") W, H = im.size d = ImageDraw.Draw(im) if lbl_path.exists(): for line in lbl_path.read_text().strip().splitlines(): c, cx, cy, w, h = line.split() cx, cy, w, h = map(float, (cx, cy, w, h)) x1, y1 = (cx - w / 2) * W, (cy - h / 2) * H x2, y2 = (cx + w / 2) * W, (cy + h / 2) * H d.rectangle([x1, y1, x2, y2], outline=(255, 0, 0), width=2) d.text((x1 + 3, y1 + 3), names[int(c)], fill=(255, 255, 0)) return im img_dir, lbl_dir = Path("dataset/images/train"), Path("dataset/labels/train") names = ["helmet", "vest", "person"] samples = random.sample(list(img_dir.glob("*.jpg")), 24) canvas = Image.new("RGB", (1600, 1200)) for i, p in enumerate(samples): lbl = lbl_dir / (p.stem + ".txt") tile = draw_one(p, lbl, names).resize((400, 300)) canvas.paste(tile, ((i % 4) * 400, (i // 4) * 300)) canvas.save("check_grid.jpg")拼出来的图扫一眼,问题基本藏不住。
5.2 统计三张分布图,判断难度是否失衡
除了看框画得对不对,还要看数据本身的分布。我一般会统计三个东西:
类别实例数分布。把每类的框总数画成柱状图,长尾特别严重(最多的类是最少的类的 20 倍以上)时,训练时考虑用类别权重或者对少数类做定向过采样。
框面积分布。把所有框的归一化面积(w 乘 h)统计出来,看看小目标(面积小于 0.01,约等于 32×32 像素在 640 输入下的占比)占多少。如果小目标超过三成,而你又按默认 640 输入训练,那这部分目标基本学不好,需要考虑提高输入分辨率或者切片推理。
图像尺寸和长宽比分布。混着各种分辨率的图训练不会报错,但极端长宽比(比如 5:1 的全景图)在默认的 letterbox 处理下会被大量填充黑边,有效信息占比很低。
5.3 增强参数要和数据规模匹配
YOLOv8 默认开启 mosaic,也就是四张图拼一张,这对小数据集是很有价值的,相当于变相扩充了样本量和上下文多样性。但有几个前提要注意。
目标特别小且密集的场景,mosaic 拼接后会进一步缩小每张图的尺度,小目标变得更难学,这时候可以考虑把 mosaic 的概率降下来,或者提高输入尺寸。
你的数据里目标位置有强先验的场景(比如所有图都在画面固定位置有一个固定设备),mosaic 和大幅度的平移缩放会破坏这个先验,反而让训练变慢。这时候把degrees、translate、scale这些增强参数调小一些更合适。
还有close_mosaic这个参数,它控制最后多少个 epoch 关闭 mosaic。默认是最后 10 个 epoch。这个设计的意义在于让模型在训练末段适应“单张完整图”的分布,和推理时的输入保持一致。如果你把训练轮数设得很短(比如 30 轮),那么最后 10 轮关掉 mosaic 占比就很高,整体上 mosaic 的作用被削弱,可以考虑把这个值调小到 5。
6. 从训练结果反推数据集该怎么改
训练跑完,指标不理想,绝大多数人的第一反应是调超参数。但真实项目里,指标上不去的原因七成在数据,两成在输入分辨率,只有一成才值得去动学习率这类东西。YOLOv8 训练完会自动生成几张分析图,会用它们比调参有用得多。
6.1 混淆矩阵告诉你类别定义是不是有问题
混淆矩阵里,如果两个类别之间出现了大量对角线以外的值,说明这两类在特征上难以区分,或者标注的时候人本身就分不清。这时候有三个选项:合并这两个类、重新明确标注规范、或者引入更多能区分的样本。多数情况下前两个更快见效。
还有一种情况是某一类和背景之间大量混淆(矩阵最后一行/列是 background),这意味着模型把该目标当成了背景漏检,或者把背景里的相似纹理当成了目标误检。前者通常是该类样本量太少或者尺度分布不匹配,后者多半是缺负样本。
6.2 逐类看 AP,找短板而不是看均值
总体的 mAP50 是个平均值,会掩盖问题。真正该看的是每一类的 AP 和对应的实例数。如果某一类实例数只有几十个,AP 很低是必然的,不用急着改模型,先补数据。如果某一类实例数很多但 AP 依旧垫底,才值得去看看是不是标注质量问题——把该类验证集的预测结果可视化出来,逐张看漏检和误检的样子,通常能看到明显的模式,比如都是远景小目标、都是侧向角度、都是强逆光。
这一步的经验是:不要看汇总指标做决策,要看具体图片做决策。指标告诉你哪里有问题,图片告诉你问题是什么,后者才能推出动作。
6.3 补数据的正确方向
看完错误样本,补数据就有方向了。常见的三类补法:
针对漏检场景补充样本,比如夜间、逆光、雨雾天气,这些场景在你的采集里本来就少。针对误检来源补充负样本,把那些被误判的背景区域单独截出来做背景图。针对定位不准的情况,回头审查该类别的标注是否边界不一致,尤其是多人标注的项目。
有个小技巧是,用训练好的模型对未标注的新数据跑一遍推理,把置信度在 0.2 到 0.5 之间的结果挑出来人工确认。这批“模型拿不准”的样本信息量最大,补进去对指标的提升效率远高于随机采图。这个思路有些类似主动学习,实操上就是写个脚本按置信度排序取前若干张,成本很低。
7. 几个我反复踩过的坑
第一个是图片格式和通道。采集来的图里混着 PNG 带透明通道、灰度图、CMYK 的 JPEG,读取的时候统一convert("RGB"),不然训练时会在某个 batch 突然报通道数不匹配,而且报错位置离真正的原因很远。
第二个是标签文件里的隐藏字符。从表格软件里复制粘贴出来的 txt,行尾可能带\r,或者行首有空格,解析时会得到“expected 5 values, got 6”这类错误。写个清洗脚本统一 strip 一遍,成本几秒钟。
第三个是验证集别拿来调参。见过不少人反复在验证集上看效果、改增强、再看效果,来回十几轮之后验证集实际上已经被“拟合”了,最终上线效果和验证集指标差一大截。数据量允许的话留一个 test 划分,只在最后看一次。
第四个是数据集版本管理。标注是会反复改的,改完之后一定要给数据集打个版本号,并且记录这一版改了什么(补了哪类样本、修了哪些错标)。等到三个月后回头看某个模型是用哪版数据训的,没有记录就只能靠猜。最简单的做法是在 data.yaml 同级放一个 CHANGELOG 文本,写清楚日期和改动点,不用上什么复杂工具。
我个人在实际操作中的体会是,做数据集这件事的投入产出比非常不均匀:前 70% 的工作量换来 90% 的效果,剩下 30% 的精细打磨(修边界、补长尾、加负样本)只提升最后那 10 个点。所以别一开始就追求完美标注,先把流程跑通、训出一版基线,然后拿着错误样本回过头来有针对性地修数据,这个循环比一次性把数据做到极致要快得多。