ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零构建YOLOv8数据集:目录、标注、转换、划分与数据体检

从零构建YOLOv8数据集:目录、标注、转换、划分与数据体检

上次帮一个朋友收拾他训到一半崩掉的检测项目,打开数据集目录我大概就知道问题出在哪了:images和labels两个树对不上号,有几个 txt 里存的还是像素绝对坐标,还有十几张图压根没有对应的标注文件,而他把这批数据直接喂给了模型,然后抱怨模型不收敛。我把话说直白一点,制作自己的 YOLOv8 数据集这件事,难度不在标注本身,而在于你有没有一套能自我校验的流程:目录怎么摆、坐标怎么算、类别怎么定、训练集和验证集怎么切、什么时候该认为"这批数据可以了"。这些环节任何一处松了,后面调参调到天亮都是白费。

这篇东西写给两类人:一类是手上已经有一批自己领域图片(产线上的零件、仓库的货架、田里的作物、桌上的零件、玩具、宠物都行),想训一个能用的检测模型的;另一类是被别人的开源数据集喂惯了,第一次自己做数据、不知道从哪下手的。我会把目录结构、归一化坐标的算法、采集和清洗的做法、标注规范的条文、格式转换脚本、划分策略、配置文件写法、上线前的体检项目,以及训练完怎么从结果反推数据集的毛病,全部摊开讲,能把操作直接抄走的代码我也都放上来了。你自己动手的时候,大概率还是会在其中两三个点上卡住,这很正常,卡住的时候回来对一遍流程就行。

1. 先把"YOLOv8数据集"这件事的轮廓画出来

1.1 目录长什么样,为什么必须长成这个形状

YOLO 系列的目录约定不是随便定的,它背后是一套"图片路径推标签路径"的硬映射规则:拿到一张图的路径,把路径里的images换成labels、后缀换成.txt,就是它的标注文件。框架内部就是这么找标签的,所以你目录一旦摆歪,它不会报错说"你放错了",它只会安安静静地认为这批图是背景图,然后你训练出来的模型什么都检测不到,还觉得自己参数调得不够好。

标准形态大概是这样:

mydataset/ ├── images/ │ ├── train/ │ │ ├── 0001.jpg │ │ └── 0002.jpg │ └── val/ │ ├── 0101.jpg │ └── 0102.jpg ├── labels/ │ ├── train/ │ │ ├── 0001.txt │ │ └── 0002.txt │ └── val/ │ ├── 0101.txt │ └── 0102.txt └── data.yaml

关键点有几个:train、val这些子目录名是约定俗成的,你在 yaml 里写什么名字它就找什么名字,但images和labels必须在同一层级、并且子目录结构完全镜像;文件名去掉后缀必须一模一样,0001.jpg对应0001.txt,一旦存在0001.JPG和0001.jpeg混用,某些系统上文件名大小写不敏感、某些敏感,你就会遇到"本地跑得好好的、换台机器全乱了"的玄学问题。我的习惯是采集入库时就统一转成小写、统一.jpg、统一用连续编号或内容哈希命名,避免中文文件名和空格——中文路径在部分环境下的编码问题足够消耗你一整天。

还有一种更省事但我不太推荐的做法:把所有图平铺在一个目录,用两个 txt 文件分别写训练集和验证集的图片绝对路径。这种方式能跑,但可移植性差,换个目录就得改 txt,团队协作时更是噩梦。老老实实镜像目录,后面拷来拷去、打包上传、给别人复现都省心。

1.2 一行标注文本背后的归一化数学

.txt里每一行是五个数字,形如:

2 0.487500 0.653333 0.145000 0.226667

依次是类别 id、中心点 x、中心点 y、框宽 w、框高 h,后面四个数全部是归一化到 0~1 的比例值,不是像素。你可以把它理解成"用百分比描述这个框在图上的位置和大小"——不管这张图是 640x480 还是 1920x1080,比例值都能直接乘回去还原,这也是为什么同一个标注文件能适配不同的训练分辨率。

从像素坐标换算的公式,假设原图宽W、高H,标注框左上角(x1, y1),右下角(x2, y2):

cx = (x1 + x2) / 2 / W cy = (y1 + y2) / 2 / H w = (x2 - x1) / W h = (y2 - y1) / H

很多人第一次做数据集翻车就翻在这里:有的标注工具导出的是x1 y1 x2 y2绝对像素,有的是x y w h绝对像素,有的是 COCO 那种绝对像素的左上角加宽高,格式各不相同,而 YOLO 只认上面这一种。你要是直接把别的格式改名成.txt塞进去,模型不会报错,它会把这些几百上千的数字当成比例值,然后在训练时全部裁到边界,最终表现就是"框总是缺一半、模型学了个寂寞"。

还有一个容易忽略的细节:类别 id 从 0 开始。你有 3 个类,id 就是 0、1、2,写 1、2、3 的话,那个 3 会被框架当作第 4 个类,而你的 yaml 里只定义了 3 个名字,于是要么报错,要么出现莫名其妙的越界。我在检查脚本里专门加了一条"最大类别 id 是否等于类别数减一",就是为了拦这种蠢错误。

提示:验证集和训练集的类别 id 映射必须是同一张表。中途加了一个新类,最稳妥的做法是整批重新映射并做好版本记录,而不是在两批数据里各写各的编号。

1.3 数据量到底要多少:一份可参考的经验刻度

这个问题没有标准答案,但有几条我在实际项目里反复验证过的经验刻度,可以当作起点:

场景复杂度类别数每类建议实例数大致图片数说明
单类、背景干净、目标大1500~800300~500固定机位、光照稳定,最省数据
单类、多背景多角度11500~3000800~1500需要覆盖视角和光照变化
多类、形态规整5~10每类 500 以上1500~3000注意类别均衡
多类、形态多变、小目标10+每类 800~15003000+边缘类、长尾类要专门补
细粒度区分(外观极像)任意每类 1000+视情况靠数量堆不如靠难例

这里要强调"实例数"而不是"图片数"。一张图里有 8 个目标,那这张图贡献 8 个实例。判断够不够,看的永远是实例数,尤其是最少的那个类的实例数——模型对少样本类的 recall 会明显低于其他类,这在后面的混淆矩阵里一眼就能看出来。

另外有个反直觉的结论:500 张精标、场景覆盖到位的图,往往比 5000 张粗标、场景高度重复的图训出来的模型更稳。因为重复场景带来的信息增量接近零,反而会放大过拟合。所以别一上来就想着"我先标个一万张",先把 300 张标明白,训一版看看模型在哪些场景上翻车,再针对性地补,这个循环比闷头标注高效得多。

2. 数据采集:这一步决定了模型的天花板

2.1 从哪收、收什么场景

采集的核心原则一句话:训练数据的分布要尽量接近部署时的分布。你在网上爬了一堆白底商品图,训出来的模型拿去识别仓库货架上的同类商品,准确率会难看到你想删库。反过来,如果你就是要做电商场景,那白底图反而是对的。所以采集之前先问自己三个问题:模型最终跑在什么设备上(手机、摄像头、无人机)?目标出现的典型距离和角度是什么?环境光、遮挡、背景会有什么变化?

拿工业零件检测举例,我一般会按这几个维度做覆盖矩阵:光照(顺光、逆光、阴影、灯光不均)、角度(正视、侧视、俯视、倾斜 30~60 度)、距离(远中近三档,让目标在图上的像素尺寸跨越一个大范围)、遮挡(无遮挡、部分遮挡 20%~50%、严重遮挡)、背景(干净背景、杂乱背景、相似干扰物)。每个维度组合起来会很多,不需要穷举,挑部署时最常见的前 20% 组合,用 800~1000 张覆盖住,剩下靠后面的难例迭代补。

现场采集还有一个实用技巧:先拍视频再抽帧。拿手机或者摄像头绕目标走一圈,比一张一张摆拍自然得多,也更容易覆盖连续的视角变化。

2.2 视频抽帧的两个陷阱

抽帧这件事看着简单,坑却不小。第一个陷阱是帧间高度相似。你用 30fps 的视频抽帧,抽出来的 1000 张图里可能有 900 张几乎是同一张,模型在训练集上看着很美,验证集稍微换个角度就崩。我的做法是按时间间隔抽,间隔取决于目标运动速度,一般 0.5~2 秒一帧;抽完再做一次相似度去重,把相邻帧的相似度压下去。

第二个陷阱更隐蔽:同一段视频的帧同时出现在训练集和验证集里,这叫数据泄漏。因为相邻帧长得太像,模型在训练时其实已经"见过"验证集了,指标虚高,你拿着这个数字去汇报,上线就露馅。解决办法很简单——按"源"划分,同一段视频、同一次拍摄批次的所有帧,整体划入训练或验证,不能拆开。这个原则在后面 4.2 节还会展开。

抽完帧之后我习惯做一轮自动预处理:统一短边缩放到一个基准(比如 1280),保持长宽比,格式转 jpg 质量 90 左右。统一尺寸的目的不是省钱,而是让标注时的框大小分布更均匀,减少"同一类目标在图上差十倍大小"给模型带来的困扰。当然如果你的部署场景本身就是远近差异极大,那这一步要慎重,最好保留原始分辨率。

2.3 清洗脚本:去重、验完整性、纠方向

采集回来的东西一定是脏的,别信任何人说"我给你的图都是好的"。清洗我一般三步走,代码不复杂,但能省下后面几天的排查时间。

第一步,验完整性。有些图下载中断、传输损坏,打开只有半张或者直接抛异常。用 PIL 逐个打开验证:

from PIL import Image import os, hashlib def check_images(img_dir): bad, ok = [], [] for name in sorted(os.listdir(img_dir)): p = os.path.join(img_dir, name) try: with Image.open(p) as im: im.verify() # 只验文件头,不解码 with Image.open(p) as im: im.load() # 真正解码一次 w, h = im.size if w < 32 or h < 32: bad.append((name, f"尺寸过小 {w}x{h}")) else: ok.append(name) except Exception as e: bad.append((name, repr(e))) return ok, bad

第二步,去重。完全重复的用文件哈希就够了,改过尺寸或质量的重复图用均值哈希,自己实现也就十来行,不用装额外依赖:

def avg_hash(path, size=8): with Image.open(path) as im: im = im.convert("L").resize((size, size)) px = list(im.getdata()) avg = sum(px) / len(px) bits = "".join("1" if v > avg else "0" for v in px) return bits def hamming(a, b): return sum(x != y for x, y in zip(a, b)) # 阈值 5 以内基本是同一张图的变体,可以直接删掉后出现的那张

第三步,纠方向,这是我最想强调的一个坑。手机拍的图会在 EXIF 里记录一个旋转标记,看图软件会自动转正,但有些标注工具和有些读图库不认这个标记,结果就是你在标注工具里看到的是竖图、框也是竖着画的,导出后存进 txt 的坐标却是按横向像素算的,标注和图像彻底错位。这类问题肉眼看不出来,量化统计也很正常,只有等模型训歪了你才会怀疑人生。

处理方法是在入库时就把 EXIF 方向烘焙进像素:

from PIL import Image, ImageOps def normalize_orientation(src, dst): with Image.open(src) as im: im = ImageOps.exif_transpose(im) # 按 EXIF 把像素真正转正 im = im.convert("RGB") im.save(dst, "JPEG", quality=92)

入库这一关做完,后面所有环节(标注、转换、训练)看到的都是同一份像素数据,就不会再有方向不一致的问题。

心得:我给每个项目都建一个_raw原始目录,任何清洗、缩放、转格式都输出到新目录,绝不覆盖原始文件。等你标注到一半发现缩放策略不对,还能从原始数据重来一遍。这个习惯救过我两次。

3. 标注规范与工具选型

3.1 工具横向对比与选择逻辑

标注工具选型不是"哪个名气大用哪个",而是看你的团队规模、标注量、是否需要多人协作和审核。我把常见的几类对比一下:

工具类型代表形态适合场景主要优点主要缺点
轻量桌面工具单机可执行的框标注软件个人、几百到几千张装完就能用,零配置,输出直接是 YOLO txt无协作、无审核流程、大项目易乱
多边形/分割标注工具支持多边形、掩膜的工具需要精细轮廓或做分割任务通用性强,格式多画框反而更绕,容易多点一步
预标注辅助工具能加载模型自动出框的工具大批量、类别固定效率提升明显,人工只做修正需要先把模型跑通,前期有配置成本
在线协作平台浏览器端多人标注团队、外包标注有任务分发、审核、统计数据要传出本地,量大有成本

我自己的默认选择是:个人或小批量用轻量桌面工具,几百张以内完全够用;上了三千张或者有第二个人参与,就切到带审核流程的方案,因为此时最大的风险已经不是标注速度,而是两个人对同一条规则的理解不一致。至于预标注辅助,它在"类别已经定好、只是想扩量"的阶段收益最大,后面 3.3 节专门讲。

不管用什么工具,有一个设置必须确认:导出格式选 YOLO。绝大多数工具都直接支持,省掉你自己写转换脚本的麻烦。如果工具只支持 VOC XML 或 COCO JSON,那就走第 4 节的转换流程,别手动改。

3.2 一份能落地的标注规范要写清哪几条

标注规范这东西,一个人标的时候觉得多余,两个人标的时候就变成刚需。我见过最常见的情况是:A 同学把被遮挡一半的目标框了整个身体,B 同学只框了可见部分,模型学出来就是"有的框大有的框小",定位精度怎么调都上不去。所以下面这几条,必须在开标之前白纸黑字定下来:

第一条,框的贴合度。边界框要紧贴目标的外接矩形,不留白、不切边。留白会让模型学到"目标周围一圈也算目标",推理时框就会偏大;切边会让模型对完整目标的边界不敏感。实践中允许 1~2 像素的误差,超过就返工。

第二条,遮挡怎么标。两种策略选一个并全程统一:一是只框可见部分,二是按推断的完整轮廓框。我倾向第一种,因为推理时模型看到的也是可见部分,分布更一致;但如果你的应用需要估计目标的完整尺寸(比如测距、测体长),那就得用第二种,并且要保证被遮挡样本占比足够,否则模型没学过"补全"这件事。

第三条,截断目标标不标。目标被图像边缘切掉一部分,我的规则是:可见面积占完整目标 30% 以上就标,低于 30% 不标也不计入负样本,直接从这张图的标注里忽略。这条阈值很关键,因为不同标注员对"边缘那半个目标"的判断差异极大。

第四条,最小框尺寸。在训练分辨率下,边长小于 8~10 像素的框对模型基本是噪声,学不到有效特征还稀释了损失。规范里写死"小于 X 像素的目标忽略",让大家有据可依。如果你的场景里小目标就是核心(比如航拍、遥感),那做法不是硬标小框,而是提高采集分辨率、或者用切片的方式把大图切块训练。

第五条,类别互斥与优先级。多个类别在视觉上重叠时(比如"包装盒"和"礼盒"),要明确谁优先、是否允许同一目标标两个类。允许重叠标注在检测任务里通常是坏主意,会让模型在两类的边界上疯狂摇摆。

第六条,一致性抽检怎么做。每标完 200 张,随机抽 20 张让第二个人复核,统计框的 IoU 和类别判断的一致率。IoU 平均低于 0.9 说明贴合度标准没对齐,需要开会重申。这一步花的时间不多,但能把系统性偏差在早期掐掉。

注意:规范文档本身要配几张"正确示范"和"错误示范"的截图,纯文字描述永远有人理解偏。我一般会做一页对比图贴在标注工具旁边,比讲半小时管用。

3.3 半自动标注:先训一个"能用就行"的模型

标注量上千以后,纯手工的边际成本会让人崩溃。这时候半自动标注是性价比最高的一招,思路很朴素:先用 200~300 张精标数据训一个"能用但很烂"的模型,拿它去给剩下的图出预标注框,人工只做增删改。实践下来,修正一个已有的框比从零画一个框快 3~5 倍,尤其是目标密集的场景。

具体流程我一般是这么走的:

第一步,从清洗后的数据里挑 200~300 张,刻意覆盖各种场景(不要全挑同一批拍的),精标,严格按规范来。这批叫"种子集"。

第二步,用最小的模型规格、较小的输入尺寸快速训一版,比如 50 个 epoch、imgsz=640。目标不是精度,是"能大致框出来"。

第三步,用这一版对剩余未标注的图批量推理,把结果按 YOLO txt 格式导出。命令行大致是这样:

yolo detect predict \ model=runs/detect/seed/weights/best.pt \ source=datasets/_unlabeled \ conf=0.25 \ save_txt=True \ save_conf=True \ project=runs/prelabel \ name=v1

这里几个参数值得解释。conf=0.25是个偏低但不算离谱的阈值,宁可多出一些误检框让人工删,也不要漏掉真正目标让人工从零补——删一个框比画一个框快得多。save_conf=True会把置信度写进 txt 的第六列,方便你按置信度排序,优先复核低置信度的那些。

第四步,把预标注文件和原图一起导进标注工具,人工修正。重要:修正完记得把多出来的那列置信度去掉,YOLO 训练只认 5 列,多一列会引发解析异常或者被静默忽略,不同版本行为不一致,稳妥起见直接裁掉前 5 列。

def keep_first5(src_dir, dst_dir): os.makedirs(dst_dir, exist_ok=True) for name in os.listdir(src_dir): if not name.endswith(".txt"): continue with open(os.path.join(src_dir, name)) as f: lines = [ln.strip() for ln in f if ln.strip()] out = [" ".join(ln.split()[:5]) for ln in lines] with open(os.path.join(dst_dir, name), "w") as f: f.write("\n".join(out) + ("\n" if out else ""))

第五步,把修正后的数据合并进种子集,重新训练一版更强的模型,再对下一批未标注数据预标注。这个循环跑三轮左右,标注效率基本能翻倍。前提是你的种子集质量要过硬,种子集标歪了,后面所有预标注都会跟着歪,而且是批量歪,返工成本极高。

4. 格式转换与数据集划分

4.1 VOC 与 COCO 转 YOLO 的关键代码

手上拿到的数据十有八九不是 YOLO 格式。VOC 是一图一个 XML,COCO 是一个大 JSON,转的时候有两个地方最容易出错:类别 id 的生成方式和坐标系的差异。

类别 id 绝对不能用"按文件出现顺序动态编号",因为处理顺序一旦不同,同一类在不同批次里就会拿到不同 id,合并数据集时直接乱套。正确做法是先扫一遍全量数据,收集所有类别名,排序后固定成一张映射表,落盘保存,后续所有转换都用这张表。

VOC 转 YOLO 的核心逻辑:

import xml.etree.ElementTree as ET from PIL import Image def voc_to_yolo(xml_path, img_path, cls_map, out_txt): tree = ET.parse(xml_path) root = tree.getroot() with Image.open(img_path) as im: W, H = im.size lines = [] for obj in root.findall("object"): name = obj.find("name").text.strip() if name not in cls_map: continue bb = obj.find("bndbox") x1 = float(bb.find("x1").text) y1 = float(bb.find("y1").text) x2 = float(bb.find("x2").text) y2 = float(bb.find("y2").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)) if x2 - x1 < 1 or y2 - y1 < 1: continue cx = (x1 + x2) / 2 / W cy = (y1 + y2) / 2 / H w = (x2 - x1) / W h = (y2 - y1) / H lines.append(f"{cls_map[name]} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") with open(out_txt, "w") as f: f.write("\n".join(lines) + ("\n" if lines else ""))

注意我用的是 XML 里记录的图像尺寸之外,重新从图片文件读一遍宽高。原因很现实:有些 XML 的size字段是标注工具按缩略图写的,和真实图不符;还有的是后续重新缩放图片但没更新 XML。以真实文件为准,最稳。

COCO 转换的坑在两点:一是标注里的bbox是[x, y, w, h]绝对像素,不是[x1,y1,x2,y2];二是iscrowd=1的标注要过滤掉,那是人群区域,没有实际训练意义。把x, y, w, h转成中心点归一化:

cx = (x + w / 2) / W cy = (y + h / 2) / H nw = w / W nh = h / H

如果 COCO 里带了segmentation多边形,而你需要的是检测框,可以直接取多边形的外接矩形,也可以用bbox字段,两者通常一致。转换完之后,强烈建议随机抽 20 张把新生成的框画回图上肉眼对一遍,颜色区分类别。这一步花五分钟,能拦住百分之八十的坐标算错问题。

4.2 划分策略:随机划分是最容易埋雷的

默认的train / val = 8:1:1或7:2:1没问题,但随机打乱划分在很多真实场景里是错的,因为它忽略了数据之间的相关性。

最典型的泄漏场景就是前面说的视频抽帧:相邻两帧几乎一样,随机划分之后,训练集里有第 100 帧,验证集里有第 101 帧,模型在验证集上的表现等于开卷考试。另一种泄漏是同一批次拍摄:你在同一天同一个地点拍了 600 张,随机分完之后训练集和验证集的背景、光照完全一致,验证指标虚高,一换环境就崩。

正确的做法是按分组划分:给每张图打一个group_id(比如来源视频编号、拍摄批次号、场景编号),先按 group 划分到训练/验证/测试,再在 group 内部正常使用。代码上就是先对 group 列表做 shuffle,再按比例切:

import random from collections import defaultdict def split_by_group(items, ratios=(0.8, 0.1, 0.1), seed=42): # items: [(img_path, group_id), ...] groups = defaultdict(list) for p, g in items: groups[g].append(p) keys = sorted(groups.keys()) random.Random(seed).shuffle(keys) n = len(keys) n_train = int(n * ratios[0]) n_val = int(n * ratios[1]) train_g = keys[:n_train] val_g = keys[n_train:n_train + n_val] test_g = keys[n_train + n_val:] pick = lambda gs: [p for g in gs for p in groups[g]] return pick(train_g), pick(val_g), pick(test_g)

小数据集还有个现实问题:验证集太小,指标波动巨大,今天 0.72 明天 0.65,你根本分不清是模型变了还是运气。这时候要么多训几个随机种子取平均,要么用分组交叉验证的思路,把数据分成 5 折轮着验证,看整体趋势。数据量在 500 张以下的时候,我基本不看单次验证指标,只看趋势和混淆矩阵的结构。

另外提醒一句:测试集要真的留着别动。很多人调参调到最后,把测试集当验证集用了十几次,测试指标已经失去参考意义。测试集只在最终定版的时候跑一次,跑完就封存。

4.3 data.yaml 的写法与三个常见错

配置文件是整套数据集的入口,写错了轻则训练报错,重则类别错位。一个完整可用的例子:

path: /data/mydataset train: images/train val: images/val test: images/test names: 0: helmet 1: vest 2: person

path是根目录,train和val写相对路径,框架会拼起来。names用明确的 id 到名称映射,比纯列表更不容易错位。

第一个常见错:names的顺序和标注 id 不一致。你以为 0 是 helmet,结果标注脚本里 0 其实是 person,模型训完检测出来的框颜色全乱,类别名对不上。这个错误在训练阶段完全看不出来,只有推理时才发现,很要命。防法是在转换脚本里把cls_map直接打印出来存成文件,写 yaml 时照抄。

第二个常见错:Windows 路径的反斜杠。YAML 里反斜杠是转义字符,path: C:\data\mydataset会解析失败或者变成奇怪的东西。要么用正斜杠C:/data/mydataset,要么给整个字符串加引号。跨平台的项目我一律用正斜杠,省事。

第三个常见错:路径是相对的,但你在别的目录下启动训练。相对路径的基准是当前工作目录,不是 yaml 文件所在目录,所以换个终端就会找不到数据。我习惯在 yaml 里写绝对路径,或者干脆把 yaml 放在数据集根目录、并且训练时先cd到那个目录。

提示:负样本(纯背景图、没有目标的图)要显式放一个空的 txt 文件,而不是干脆不建 txt。不同版本对"缺少标注文件"的处理策略不完全一致,有的会跳过、有的会当背景图,显式放空文件行为最确定,还能配合后面统计脚本对数量。

5. 上线前的数据体检

5.1 六个必跑的一致性检查

数据整理完,先别急着训。我有一套固定要跑的检查,五分钟能跑完,能拦掉绝大多数低级错误:

检查项判断逻辑常见后果
图片与标签一一对应图片去后缀后是否有同名 txt,反之亦然静默丢数据、训练集实际比你以为的小
坐标是否越界每个值是否都在 0~1 之间,且cx±w/2在范围内训练时被裁剪,定位学不准
框面积是否为零w > 0 and h > 0损失出现 nan 或异常
类别 id 是否合法max(id) == num_classes - 1且无负数越界报错或类别错位
是否有重复标注同一行完全重复同一目标被重复计入损失
类别实例分布逐类统计实例数长尾类 recall 极低

对应脚本的核心部分大概这样写:

import os, glob from collections import Counter def audit(img_dir, lbl_dir, num_classes): report = {"missing_label": [], "missing_img": [], "oob": [], "zero_area": [], "dup": [], "bad_cls": []} cls_counter = Counter() imgs = {os.path.splitext(f)[0] for f in os.listdir(img_dir) if f.lower().endswith((".jpg", ".jpeg", ".png"))} lbls = {os.path.splitext(f)[0] for f in os.listdir(lbl_dir) if f.endswith(".txt")} report["missing_label"] = sorted(imgs - lbls) report["missing_img"] = sorted(lbls - imgs) for stem in sorted(imgs & lbls): p = os.path.join(lbl_dir, stem + ".txt") with open(p) as f: lines = [ln.strip() for ln in f if ln.strip()] seen = set() for ln in lines: parts = ln.split() if len(parts) != 5: report["oob"].append((stem, "列数异常", ln)); continue cid = int(parts[0]) cx, cy, w, h = map(float, parts[1:]) cls_counter[cid] += 1 if cid < 0 or cid >= num_classes: report["bad_cls"].append((stem, ln)) if w <= 0 or h <= 0: report["zero_area"].append((stem, ln)) if any(v < 0 or v > 1 for v in (cx, cy, w, h)) or \ cx + w / 2 > 1.0001 or cx - w / 2 < -0.0001 or \ cy + h / 2 > 1.0001 or cy - h / 2 < -0.0001: report["oob"].append((stem, "越界", ln)) if ln in seen: report["dup"].append((stem, ln)) seen.add(ln) report["cls_counter"] = dict(cls_counter) return report

越界这一项我要单独说:越界比你想的常见。标注时框画到图像边界外一点点,很多工具会允许,导出时也不裁剪,于是cx + w/2变成 1.02。训练时框架会裁剪,裁剪后的框中心变了,模型学到的是"被裁剪过的框",推理时你的框就会系统性偏小或偏移。我的做法是在转换阶段就裁剪到 [0,1],同时记录被裁剪的次数,如果某个项目里裁剪频繁,说明标注规范里的边界处理没讲清楚,要回去补。

5.2 标注可视化抽检

脚本查出来的是数值问题,还有一类问题是数值正常但语义错了:类别标反、框框歪了、同一目标框了两次、把背景纹理当成目标。这些只能靠看。随机抽 20~30 张,把框画上去,按类别配色,存成大图拼一起看。

from PIL import Image, ImageDraw COLORS = [(255, 0, 0), (0, 160, 255), (0, 200, 80), (255, 160, 0), (170, 0, 255)] def draw_labels(img_path, lbl_path, out_path, names, thickness=3): with Image.open(img_path) as im: im = im.convert("RGB") d = ImageDraw.Draw(im) W, H = im.size with open(lbl_path) as f: for ln in f: parts = ln.split() if len(parts) < 5: continue cid = int(parts[0]) cx, cy, w, h = map(float, parts[1:5]) x1, y1 = (cx - w / 2) * W, (cy - h / 2) * H x2, y2 = (cx + w / 2) * W, (cy + h / 2) * H color = COLORS[cid % len(COLORS)] d.rectangle([x1, y1, x2, y2], outline=color, width=thickness) d.text((x1 + 3, max(0, y1 - 14)), names[cid], fill=color) im.save(out_path, "JPEG", quality=88)

看的时候重点盯三处:图像边缘那一圈(越界和截断目标的高发区)、目标密集重叠的区域(漏标和重复标的高发区)、以及每个类别最少的那些(往往是错标的重灾区)。这一轮抽检我愿意花二十分钟,因为它能发现的问题,靠训练指标是绕一大圈才发现的。

5.3 分布统计:类别、尺寸、宽高比

数据集的"形状"对训练策略影响很大,至少要看三个分布:

类别分布,看长尾。如果最多的类有 3000 个实例、最少的类只有 40 个,那少样本类基本训不出来,要么补数据,要么在训练时用类别权重,要么干脆合并相近类别。补数据永远是首选,权重调整是权宜之计。

目标尺寸分布,把框的像素边长按训练分辨率换算一下,分成小(<32px)、中(32~96px)、大(>96px)三档统计。如果小目标占比很高,那imgsz=640可能不够,考虑上 1280 或者做切片训练;如果小目标占比极低但你的业务需要检小目标,说明采集距离不对,得重拍。

宽高比分布,看看有没有极端细长的目标(比如 1:15 的电线、杆状物)。极端宽高比在标准锚框和默认损失下比较难学,可能需要调整锚框尺寸或者专门增加这类样本。

sizes = {"small": 0, "medium": 0, "large": 0} for ...: # 遍历所有框,按 imgsz 换算像素边长 px_w, px_h = w * imgsz, h * imgsz side = max(px_w, px_h) if side < 32: sizes["small"] += 1 elif side < 96: sizes["medium"] += 1 else: sizes["large"] += 1

这三个分布看完,你心里对"这批数据训出来大概什么水平、瓶颈在哪"会有一个相当准确的预判,比训完再看结果省时间。

6. 用训练结果反推数据集问题

6.1 训练命令与参数

数据集模样对了,跑一版基线。命令本身很简单:

yolo detect train \ data=/data/mydataset/data.yaml \ model=yolov8n.pt \ epochs=100 \ imgsz=640 \ batch=16 \ workers=4 \ patience=30 \ project=runs/detect \ name=baseline

参数背后的逻辑值得说清。model=yolov8n.pt先用最小规格跑通流程,别一上来就上大模型,数据有问题的时候大模型只会让你更慢发现问题。imgsz=640是通用起点,如果你统计出来小目标多,直接拉到 960 或 1280,但显存要跟着算:显存大致随imgsz的平方增长,从 640 到 1280 是四倍左右,batch要相应减半或减到四分之一。patience=30是早停,验证指标 30 轮不涨就停,省时间。workers在 Windows 上经常出问题,设 0 或者 2 更稳,别盲目调大。

第一次训练的目的是暴露问题,不是为了拿最高分。所以我一般会先跑一版只看 loss 曲线有没有正常下降、各类的 recall 分布如何、混淆矩阵有没有结构性的乱。这版跑完,数据集的毛病基本就摊在桌面上了。

6.2 从混淆矩阵和 PR 曲线读数据病灶

训练日志里有几个东西是直接给你"数据体检报告"的,很多人只看 mAP,太浪费了。

混淆矩阵,最左边一列和最下面一行通常标着 background(背景)。重点看两件事:一是某类被大量判成 background,说明这类目标漏标严重、样本太少,或者特征太弱;二是两类之间互相串,说明这两类的视觉区分度不够,可能是标注时就没分清楚,也可能类别定义本身有重叠,这时候要做的是回去重新定义类别边界,不是调参。

每类的 mAP50 和 mAP50-95 的差距,能看出定位质量。如果某类 mAP50 有 0.9 但 mAP50-95 只有 0.4,说明"能找到但框不准",典型原因是标注框贴合度不一致,或者这个类的目标形态变化大(比如可形变的物体、姿态多变)。回去查这个类的标注一致性,往往能挖出问题。

PR 曲线看曲线形状。如果曲线在低置信度区域就急剧下降,说明假阳性多,背景干扰大或者是类别混淆;如果整条曲线都压得很低,那是这个类本身学不动,先看实例数够不够。

训练集和验证集的 loss 分叉,是最直接的过拟合信号。训练 loss 一直降、验证 loss 从第 30 轮开始往上走,说明数据量不够或者场景覆盖太窄。这时候补数据的收益远大于加正则。

6.3 补数据闭环:从错误样本反向找采集目标

模型训完,别急着上线,先拿它去跑一批真实场景的图(不是训练集里的),把检测错的、漏掉的、框歪的全都捞出来。这些叫难例,是下一轮采集和标注的最高优先级素材。

具体做法:用训练好的权重对现场视频或新采集的图做批量推理,把置信度低的(比如 0.1~0.4 之间)和明显漏检的帧挑出来,人工复核后加入训练集。这一轮补个两三百张难例,往往比原来堆一千张常规样本的收益都大,因为它直接对准了模型的短板。

补完重训,再跑一轮现场数据,再补。一般两到三轮,模型的现场表现就稳定了。这个过程里我最常见到的错误是:只补模型检错的正样本,忘了补负样本。模型在某个特定背景上疯狂误报,正确的解法是把那个背景的图作为负样本(空 txt)加进去,让模型学会"这里什么都没有",而不是只加更多目标图。误报率高的时候,负样本的效果立竿见影。

7. 踩坑速查表与几个实用小技巧

把前面散落的问题汇总成一张表,出问题的时候直接对号入座:

现象最可能的数据集原因排查动作
训练 loss 正常但 mAP 一直很低坐标没归一化 / 格式不对抽一张图把框画回去看位置
检出的框整体偏小或偏移标注越界被裁剪 / 贴合度不统一跑越界检查 + 抽样看可视化
某一类完全检不出实例数太少 / 类别 id 映射错位统计逐类实例数,核对 yaml 的 names
验证指标虚高、上线拉胯训练验证数据泄漏(同源视频/同批次)改成按分组划分,重跑
大量背景误报负样本缺失 / 背景单一补空 txt 的负样本图
小目标几乎全漏分辨率不足 / 小目标样本少提高 imgsz 或改切片训练
图片明明标注了却像没标文件名大小写、后缀不一致比对 images 与 labels 的 stem 集合
换机器后训练直接找不到数据yaml 里相对路径 / 反斜杠转义改绝对路径、统一正斜杠
同一张图训练时方向转了 90 度EXIF 方向未烘焙入库时统一 exif_transpose

最后分享几个我自己一直在用的小习惯,都是吃过亏之后养成的。第一,给数据集打版本号。每次划分、每次补数据都存一个新版本目录,配置文件里写清用的是哪一版。出现"昨天还好今天怎么这样"的时候,你能立刻回滚对比。第二,把类别映射表和规范文档跟数据放在一起,别存在某个聊天记录里,三个月后回来接手的人(可能就是你本人)会感谢你。第三,先跑 300 张的小闭环,再上量。整套流程用 300 张跑通一遍,训练、评估、看混淆矩阵、补数据,全部走一遍,暴露出来的流程问题在 300 张的量级上是小时级的修复成本,等到三万张的时候就是周级的灾难。

数据这件事没有"做完"的时候,只有"够用"的时候。我现在判断一批数据能不能收工的标准很简单:拿它训出来的模型,在你真实部署的那几个场景里,连续跑一周没有出现让你想立刻重训的失误,那就可以先停手,把精力放到工程侧去了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进