做目标检测这行,绕不开的一件事就是数据。模型结构换来换去,涨点往往只有一两个;但把数据集重新梳理一遍、把标注质量提上去,mAP 直接跳十几个点的案例我见过不止一次。这篇讲的就是制作自己的 YOLOv8 数据集这件事:从想清楚要检测什么,到采集、清洗、标注、格式转换、目录组织、配置校验,再到跑通第一次训练、排查那些“模型没问题但就是不收敛”的诡异情况。它解决的不是“怎么调参”,而是更靠前的那个问题——你喂给模型的东西到底对不对。适合刚接手一个自定义检测任务、准备从零攒数据的人,也适合手里已经有一批数据、但训练结果总是差口气、想回头检查数据链路的同学。全文按真实项目里踩过的顺序推进,中间会给可直接复制的脚本和配置,也会反复强调那些“当时觉得无所谓、后面花两天返工”的细节。
1. 先想清楚:数据集方案的整体设计
1.1 YOLOv8 到底吃什么格式的数据
很多人一上来就问“要标多少张”,其实第一个该问的是“我要做哪一类任务”。YOLOv8 这一代框架把任务分得比较细:目标检测(detect)、实例分割(segment)、姿态关键点(pose)、图像分类(classify)、旋转框(obb)。五种任务的标签格式完全不一样,标注工具选的也不一样,返工成本极高。检测任务最常用,一张图对应一个同名 txt,每行一个目标,格式是类别id x_center y_center width height,后四个值全部归一化到 0 到 1 之间。分割任务在检测的五个值后面追加任意多个多边形点坐标,也是归一化的,点的顺序沿轮廓走。姿态任务追加的是关键点的 x、y 和可见性标志。分类任务最省事,直接按类别建文件夹,图片丢进去就行,连 txt 都不用。
我建议动手之前先在纸上写一句话:输入一张什么样的图,输出什么。是“框出画面里所有戴安全帽的人”,还是“只判这张图里有没有火”。前者是检测,后者是分类,两者的数据成本差好几倍——检测要逐个目标画框,分类只要整图打标签。有些需求其实分类就够了,硬上检测纯属给自己找活干。还有一个容易被忽略的点是输入分辨率:YOLOv8 默认按imgsz=640训练,如果你的目标在原图里本身就只有十来个像素,缩到 640 之后可能只剩两三个像素,这种目标无论怎么标都学不出来。碰到小目标场景,要么提高训练分辨率(imgsz=1280),要么切图训练,要么加一个专门看局部的小模型,这些都得在标注之前决定,不能等标完三千张再说。
1.2 一条完整的数据流水线长什么样
我把这条链路拆成十步,实际项目里基本按这个顺序走,中间允许回退,但不建议跳步:
| 阶段 | 主要动作 | 常见返工点 |
|---|---|---|
| 需求定义 | 确定任务类型、类别表、判定标准 | 类别定义模糊,标到一半改 |
| 数据采集 | 真实场景拍摄、视频抽帧、公开数据补充 | 场景单一,模型泛化差 |
| 数据清洗 | 去重、去模糊、去损坏文件 | 相邻帧几乎一样,等于没增加信息 |
| 标注 | 人工或半自动画框 | 边界标准不统一,多人标注不一致 |
| 抽检 | 交叉复核、抽 10% 到 20% 重标 | 抽检流于形式,错误全进训练集 |
| 划分 | 按场景或来源切 train/val/test | 同源数据跨集,指标虚高 |
| 转换 | 统一成 YOLO txt | 坐标系搞错,1-based 没减 1 |
| 配置 | 写 data.yaml,核对路径与类别顺序 | 路径写错,训练静默跳过标签 |
| 可视化 | 把标签画回图上看 | 这一步省了,后面全是玄学 |
| 试训 | 小模型短轮次跑通 | 指标异常却去调超参 |
这条流水线里,真正决定模型上限的是前三步,决定模型能不能正常学的是后五步。我在实际项目里的时间分配大概是:采集和清洗占三成,标注占四成,剩下三成全是格式、校验和排查。新手最容易犯的错是把九成精力放在标注上,格式和校验随便搞搞,结果训练日志一片正常、指标就是不动,最后回头一行行查标签,花的时间比标注还多。
1.3 一个必须先破除的误区:边标边想
见过太多这样的流程:拿到一批图,打开标注工具就开始框,框了五百张发现“哎这个类别应该拆成两个”,又框了三百张发现“这种目标到底算不算”,最后类别表改了三次,前面标的全要重映射。标注是纯人力成本,改一次就是几天的损失。
我的做法是先用 30 到 50 张图做一次“预标注演练”,只标不训,专门用来暴露问题:这个类别在图上到底长什么样、边界在哪、遮挡到什么程度还算、两个相似类别有没有可能混淆、有没有那种“十个标注员会标出十种结果”的模糊样本。演练完之后写一份简短的标注规范,哪怕只有一页纸,把“框贴紧目标外沿”“被遮挡超过 70% 不标”“画面边缘截断超过一半不标”“小于 8 像素的目标不标”这类规则固定下来。这份规范的作用不是给别人看的,是给三个月后的自己看的——当你回头发现某批数据风格诡异时,能查到当时的判定标准。
另外一个误区是“数据越多越好”。我做过一个对比,同一个场景下,800 张精标图(框贴边、类别准确、场景多样)训出来的效果,比 3000 张粗标图(框松、漏标、重复帧多)要好。数据的价值在信息密度,不在文件数量。一张场景独特、目标清晰的图,抵得上几十张同质化的重复帧。
2. 数据采集与清洗:上限在这一步就定了
2.1 采集策略:把“未来会遇到的情况”提前塞进来
采集最核心的原则只有一条:训练数据的分布要尽量贴近实际推理时的分布。听起来像废话,但真做起来十个人有八个做不到。实验室里架个摄像头、开个常亮灯、拍一下午,得到的数据光照恒定、角度固定、背景干净,训出来的模型在室内测得很好,一搬到室外,逆光、阴影、雨雾一来,直接崩。
我的采集清单一般包含这几个维度:光照(顺光、逆光、侧光、夜间、阴天)、角度(正拍、俯拍、仰拍、斜 45 度)、距离(远、中、近,对应目标大小变化)、遮挡(无遮挡、部分遮挡、严重遮挡)、背景(干净背景、杂乱背景、与目标颜色接近的背景)、运动(静止、快速移动导致的运动模糊)。每个维度不用都拍几百张,但每个组合至少要有几十张,让模型见过。这些“难例”在训练集里的占比建议不低于两成,全是简单样本训出来的模型,遇到难例就懵。
视频抽帧是性价比最高的采集方式,但有个坑:相邻帧之间差异极小,三十帧每秒抽下来,一秒钟的视频给你三十张几乎一样的图,信息量约等于一张。合理的做法是按时间间隔抽(比如每 0.5 秒一帧),或者先做相似度过滤再人工挑。另外抽帧时要注意运动模糊和压缩伪影,视频里模糊的帧要剔掉,不然模型会学到一堆糊成一团的“目标”。
公开数据集可以作为补充,但要注意两点:一是分布差异,公开数据集的拍摄设备、场景、标注风格往往和你自己的差很远,直接混进去可能反而拉低效果,建议先小比例混入做对比实验;二是许可问题,商用项目务必确认数据的使用条款,这个不能含糊。
2.2 数据清洗:去重比想象中重要得多
清洗这一步很多人直接跳过,觉得“图多一点没坏处”。实际上重复和近重复样本会带来两个问题:一是训练时同一张图反复出现,等效于给这批样本加权,模型会过拟合到这些场景;二是划分数据集时,一张图的副本可能同时进了训练集和验证集,验证指标虚高,你以为模型学会了,其实它只是背下来了。
去重分两层。完全重复用文件哈希就行,md5或sha256一行代码搞定。近重复(同一场景不同角度、视频相邻帧、轻微裁剪或调色)要用感知哈希,我常用imagehash的 phash,汉明距离小于 5 到 8 就判定为近重复,只保留其中一张。下面这段可以直接用:
from pathlib import Path from PIL import Image import imagehash def find_duplicates(img_dir, threshold=6): """返回近重复图片分组,每组保留第一张即可""" hashes = {} for p in sorted(Path(img_dir).rglob("*.jpg")): try: h = imagehash.phash(Image.open(p).convert("RGB")) except Exception: print(f"损坏文件,跳过: {p}") continue hashes[p] = h groups, used = [], set() paths = list(hashes.keys()) for i, p1 in enumerate(paths): if p1 in used: continue group = [p1] for p2 in paths[i + 1:]: if p2 in used: continue if hashes[p1] - hashes[p2] <= threshold: group.append(p2) used.add(p2) if len(group) > 1: groups.append(group) used.add(p1) return groups除了去重,还要过滤几类废图:分辨率过低的(长边小于 640 的基本没救)、严重模糊的(可以用拉普拉斯方差粗略判断)、过曝或欠曝到看不出目标的、文件损坏打不开的。这些图留在数据集里不会让模型变差太多,但会让你在排查问题时多怀疑半天。
2.3 数量估算与类别均衡
“多少张才够”没有标准答案,但有一些经验区间可以参考。检测任务里,单个类别的实例数(不是图片数)达到 1500 个左右,通常能训出一个可用的基线;类别越复杂、类间越相似、场景越多,需要的量越大。注意是实例数:一张图里有 20 个人,那张图贡献了 20 个 person 实例。反过来说,如果一张图只有一个目标,标 1500 张才够 1500 个实例。
类别之间的比例也要看一眼。如果 A 类有 5000 个实例、B 类只有 80 个,模型基本会放弃 B 类。经验上把类别比例控制在 1:10 以内比较稳,能做到 1:3 更好。长尾类别实在补不齐,可以考虑合并(把“红色安全帽”和“黄色安全帽”合成“安全帽”)、或者用重采样、类别加权来缓解,但这些是补救手段,不如在采集阶段就有意识地多拍一些稀有类。
还有一个常被忽略的点:负样本。也就是画面里没有目标、或者只有背景的图。数据集里适当加 5% 到 10% 的纯背景图(对应一个空的 txt 文件),能明显降低误检率,尤其是在固定摄像头场景下,背景里的相似物体很容易被误判。很多人不敢加空标签文件,怕训练报错,实际上 YOLO 完全支持空 txt,它表示“这张图没有目标”,是有效的监督信号。
如果只是个原型验证,别一上来就奔着三千张去。先用 200 到 300 张把整条链路跑通,包括标注、转换、配置、训练、推理,确认每一步都没问题,再批量扩数据。链路没跑通就大规模标注,是项目管理层面最常见的浪费。
3. 标注实操:工具选型与质量把控
3.1 标注工具怎么选
工具没有绝对好坏,看团队规模和数据形态。大致这么分:
| 工具类型 | 适合场景 | 上手成本 | 主要限制 |
|---|---|---|---|
| 轻量桌面工具 | 单人、纯矩形框、几百到几千张 | 低,装完就能用 | 协作弱,无版本管理 |
| Web 协作平台 | 多人并行、需要审核流程 | 中,要部署或注册 | 数据量大时上传慢 |
| 支持视频与多边形的工具 | 分割任务、连续帧标注 | 中高 | 界面复杂,标注速度慢 |
| 带 AI 辅助预标注的工具 | 有现成模型可以预跑 | 中 | 需要调模型,预标注质量参差 |
单人小项目,用轻量的桌面标注工具足够,输出直接选 YOLO 格式,省掉转换环节。多人协作就必须上带任务分配和审核的平台,否则两个人标同一张图你都不知道。分割任务优先选支持多边形和关键点的工具,别用只能画矩形框的凑合。视频数据优先选支持帧间插值或跟踪的工具,同一个目标在连续帧里只需要微调,效率能差好几倍。
这里分享一个提速技巧:半自动标注。先用一个通用预训练模型(比如现成的 COCO 预训练权重)在你的图上跑一遍推理,把预测框导出成标注文件,人工只做增删改。对常见类别,这一步能省掉六七成的工作量。但有两个前提必须满足:一是模型输出的类别要映射到你的类别表上,不能直接混用;二是人工复核不能省,预标注的框往往偏松,而且漏检的部分恰恰是难例,直接拿来用会让数据集带着模型的偏见。我的习惯是预标注之后逐张过一遍,漏检的补、框松的收紧,速度还是比从零画快得多。
3.2 边界框的精度标准
框该画多紧?这是标注质量的第一道分水岭。原则上框要紧贴目标的外接矩形,不要留大边,也不要切掉目标的一部分。留大边会让模型学到“目标周围有一圈背景”这个错误特征,切掉一部分会让模型学到的尺寸偏小。实际操作里,贴着目标最外侧像素画,误差控制在两三个像素内是可以接受的。
三个必须提前约定的判定规则:
- 遮挡目标:只标可见部分,还是按推断的完整轮廓标?两种都有道理,但一个数据集里必须统一。我倾向按可见部分标,因为模型推理时也只能看到可见部分。
- 截断目标:目标跑出画面边缘时,框要不要延伸到画面外?答案是不能,框必须裁到画面内。截断超过一半的目标,我一般直接不标,因为剩下的信息太少,标了反而引入噪声。
- 极小目标:设一个像素阈值,比如原图里小于 8×8 像素的目标不标。这些目标在缩放后基本消失,标了也学不到,只会带来标注噪声。
多人标注时还要做一致性检验。做法很简单:抽 20 张图让两个人各标一遍,算同一个目标的 IoU,平均 IoU 低于 0.85 就说明标准没对齐,需要重新对规范。这个检查做一次只要半小时,能省掉后面几天返工。
3.3 类别体系设计里的坑
类别表设计有两个原则:互斥、粒度一致。互斥指的是任何一个目标只能属于一个类别,不能出现“这个是车,也算交通工具”这种重叠定义。粒度一致指的是同一层级的概念放在一起,别把“车辆”和“轮胎”并列——前者是整体,后者是部件,模型会学得很混乱。
几个具体的坑:一是同义词混用,不同标注员把同一个东西标成两个类别,比如“安全帽”和“头盔”,这种情况在数据统计时会暴露出来(某个类别实例数异常少且分布零散),但最好在开始前就把类别表定死。二是类别顺序,YOLO 的 txt 里存的是数字 id,id 到名字的映射写在 data.yaml 的names里,两者必须严格对应。一旦开始标注,类别顺序就不能改了,中途加类别要么加在末尾,要么全局重映射一遍,后者容易出错。三是类别数量,不是越多越好,超过二三十类之后,类间混淆会明显加重,需要考虑分层训练或者多模型。
还有个小细节:names里尽量用英文或者无空格的短标识,别用中文。虽然新版框架对中文类名的支持好了一些,但可视化、日志、跨平台传输时中文编码出问题的概率不低,排查起来很费劲。显示层需要中文的话,在推理后做一次映射就行,没必要让原始标签带中文。
3.4 标注质量抽检怎么做
抽检不是“随便看看”,要有量化标准。我的做法是三步:
- 全量跑一遍自动检查,脚本统计每个 txt 的行数、类别 id 分布、框的宽高是否为 0、坐标是否超出 0 到 1 的范围、是否存在空文件。异常的直接列出来。
- 随机抽 10% 到 20% 做人工复核,重点看漏标和框的松紧。
- 对抽检发现的问题做归因:如果是系统性偏差(比如某个标注员的所有框都偏松),整批返工;如果是零散错误,单独修。
自动检查脚本大概长这样,虽然简单但非常有用:
from pathlib import Path from collections import Counter def check_labels(label_dir, nc): stats = Counter() problems = [] for txt in Path(label_dir).rglob("*.txt"): lines = [l.strip() for l in txt.read_text().splitlines() if l.strip()] if not lines: stats["empty"] += 1 continue for line in lines: parts = line.split() if len(parts) != 5: problems.append((txt.name, f"字段数异常: {len(parts)}")) continue cid = int(float(parts[0])) if cid < 0 or cid >= nc: problems.append((txt.name, f"类别 id 越界: {cid}")) x, y, w, h = map(float, parts[1:]) if not all(0 <= v <= 1 for v in (x, y, w, h)): problems.append((txt.name, "坐标未归一化或越界")) if w <= 0 or h <= 0: problems.append((txt.name, "宽高为 0")) stats[cid] += 1 return stats, problems统计出来的类别分布能直接告诉你类别是否均衡,异常列表能直接告诉你哪些文件有问题。这套检查我一般会在标注阶段跑两三次,而不是等全部标完才跑——越早发现系统性问题,返工越少。
4. 目录结构与格式转换:最容易出错的一段
4.1 标准目录结构,以及那个经典的路径陷阱
YOLOv8 对目录结构有隐含约定,不遵守就会出现“训练跑起来了但一个标签都没读到”的情况,而且日志里不一定报错,只是 mAP 一直是 0。标准结构是这样的:
dataset/ ├── images/ │ ├── train/ │ │ ├── 0001.jpg │ │ └── 0002.jpg │ ├── val/ │ └── test/ ├── labels/ │ ├── train/ │ │ ├── 0001.txt │ │ └── 0002.txt │ ├── val/ │ └── test/ └── data.yaml关键点在于:images和labels是两个平级目录,内部的子目录名(train/val/test)完全一致,图片和 txt同名只是后缀不同。框架在找标签时,是把图片路径里的/images/直接替换成/labels/,再把后缀换成.txt。所以如果你的图片放在dataset/train/images/,或者文件夹叫imgs、pics、JPEGImages,替换逻辑就匹配不上,标签读不到。
这个坑我踩过一次,当时目录是dataset/images_train/,训练跑了两个小时,val mAP 稳定在 0,我以为是学习率问题,调了半天超参才发现标签压根没加载。判断方法很简单:训练启动后看日志里 “train: Scanning … images, … backgrounds, … corrupt” 这一行,如果 backgrounds 的数量等于图片总数,说明一个标签都没找到。另外框架在路径替换失败时会抛一个断言错误,提示标签必须是 images 的同级目录,看到这个报错直接查目录名就行,别去查数据。
4.2 标签格式与归一化计算
YOLO 的检测标签一行五个值:class_id x_center y_center width height。注意顺序和常见理解不一样,是先中心点后宽高,而且是归一化后的值(除以图片宽高)。从常见的左上右下坐标转换,公式是:
x_center = (x_min + x_max) / 2 / img_w y_center = (y_min + y_max) / 2 / img_h width = (x_max - x_min) / img_w height = (y_max - y_min) / img_h两个高频错误。第一个是坐标系基准:有些标注工具导出的坐标是从 1 开始的(比如某些 MATLAB 系工具),直接套公式会整体偏移一个像素,图小的时候看不出来,目标小的时候误差比例就很大了。转换前先看一下数据的最大值,如果是 1 到 W 的范围,要先减 1。第二个是归一化没做:把像素坐标直接写进 txt,训练时这些值远大于 1,模型能跑但学不到东西,可视化一看框全在右下角外面。所以第 5 节的“画回去看一眼”这一步真的不能省。
数值精度方面,保留 6 位小数足够,多了没用还占空间。另外注意别出现科学计数法(1e-05这种),个别解析器处理不好,写的时候用f"{v:.6f}"格式化一下最省心。
4.3 从 VOC 和 COCO 转成 YOLO
手里的数据经常是别的格式,转换脚本得自己写。VOC 是每张图一个 xml,COCO 是一个大 json,两种都常见。
VOC 转 YOLO:
import xml.etree.ElementTree as ET from pathlib import Path CLASSES = ["person", "helmet", "vest"] # 顺序即 class_id def voc_to_yolo(xml_path, out_dir): root = ET.parse(xml_path).getroot() size = root.find("size") w, h = int(size.find("width").text), int(size.find("height").text) lines = [] for obj in root.findall("object"): name = obj.find("name").text.strip() if name not in CLASSES: continue # 不在类别表里的直接丢弃,别硬塞 cid = CLASSES.index(name) box = obj.find("bndbox") x1 = float(box.find("xmin").text) y1 = float(box.find("ymin").text) x2 = float(box.find("xmax").text) y2 = float(box.find("ymax").text) # 坐标裁剪到画面内,防止标注越界 x1, x2 = max(0, min(x1, w)), max(0, min(x2, w)) y1, y2 = max(0, min(y1, h)), max(0, min(y2, h)) cx, cy = (x1 + x2) / 2 / w, (y1 + y2) / 2 / h bw, bh = (x2 - x1) / w, (y2 - y1) / h if bw <= 0 or bh <= 0: continue lines.append(f"{cid} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") out = Path(out_dir) / (Path(xml_path).stem + ".txt") out.write_text("\n".join(lines))COCO 转 YOLO 的逻辑类似,只是要先建一个image_id -> (file_name, width, height)的索引,再遍历 annotations,用bbox字段(格式是[x_min, y_min, w, h],注意和 VOC 不一样)算归一化值。有个细节要留意:COCO 的category_id通常不连续(比如 1、3、7),必须建一个映射表转成 0 开始的连续 id,千万别直接把category_id当 class_id 用。
转换完之后,一定要做一次数量核对:转换前 xml 里有多少个目标,转换后 txt 里有多少行,两者应该相等(减去被丢弃的类别)。数量对不上,说明转换逻辑里有分支走错了。
4.4 data.yaml 怎么写才不出岔子
配置文件是所有路径和类别信息的入口,写错一个字全盘皆输。一个可用的版本:
path: /data/projects/demo/dataset train: images/train val: images/val test: images/test names: 0: person 1: helmet 2: vest几个要点。path建议写绝对路径,用相对路径时框架是相对“运行命令时的工作目录”去解析,换个目录启动就找不到数据,这种问题排查起来最烦。train/val写的是相对path的路径,而且指向的是图片目录,不是标签目录。names可以写成列表也可以写成字典,字典形式可读性更好,但键必须是从 0 开始的连续整数,且和 txt 里的 id 严格对应。类别的数量可以从names的长度推断,手写nc不是必须的,写了也不冲突,但如果nc和names长度不一致,会出问题,所以要么都不写,要么保证一致。
另外提醒一句:yaml 对缩进和冒号后的空格很敏感,用 Tab 缩进会直接解析失败。改完配置文件先跑一下python -c "import yaml; print(yaml.safe_load(open('data.yaml')))",能打印出来就说明格式没问题,比训练跑起来再报错要快得多。
5. 训练前自检:把错误摁在开跑之前
5.1 把标签画回图上看一眼
这一步花五分钟,能省两天。写个小脚本,把每个 txt 里的框按归一化坐标还原成像素坐标,画到对应图片上,保存成可视化结果。检查三件事:框的位置对不对(错位说明坐标转换有问题)、框的松紧合不合理(普遍偏松说明标注标准有问题)、有没有明显漏标(图上目标很多但框很少)。
from pathlib import Path from PIL import Image, ImageDraw COLORS = ["red", "lime", "blue", "orange", "magenta"] def visualize(img_dir, label_dir, out_dir, names): out = Path(out_dir); out.mkdir(parents=True, exist_ok=True) for img_path in Path(img_dir).glob("*.jpg"): txt = Path(label_dir) / (img_path.stem + ".txt") if not txt.exists(): continue im = Image.open(img_path).convert("RGB") W, H = im.size draw = ImageDraw.Draw(im) for line in txt.read_text().splitlines(): if not line.strip(): continue cid, cx, cy, bw, bh = line.split() cid = int(float(cid)) cx, cy, bw, bh = map(float, (cx, cy, bw, bh)) x1, y1 = (cx - bw / 2) * W, (cy - bh / 2) * H x2, y2 = (cx + bw / 2) * W, (cy + bh / 2) * H color = COLORS[cid % len(COLORS)] draw.rectangle([x1, y1, x2, y2], outline=color, width=2) draw.text((x1 + 2, y1 + 2), names[cid], fill=color) im.save(out / img_path.name)抽个二三十张看就行,全看也没必要。重点看边界情况:极小目标、密集目标、边缘截断目标。这三类出问题的概率最高。
5.2 数据集划分与泄漏排查
划分比例一般是 8:1:1 或者 8:2,验证集用来观察训练过程,测试集留到最后评估一次,不要拿测试集调参。小数据集(几百张)可以只分 train/val,8:2 就行,测试集从验证集里临时抽。
比比例更重要的是划分方式。正确做法是按来源划分:同一段视频抽出来的帧、同一个场景拍的一组图、同一台设备采的一批图,必须整体划到同一个集合里。随机按文件打乱是最常见的错误,因为同源图片高度相似,模型在训练集里见过这个场景,在验证集里再见到几乎一样的图,指标当然漂亮,但换个场景就废了。这个现象就是数据泄漏,它是“验证集 mAP 0.95、上线效果一塌糊涂”的头号原因。
如果数据来源很多、每个来源的图不多,可以按“来源分组 + 分层抽样”来做:先按来源分组,再在每个组内按类别分布抽样,保证各集合的类别比例接近。划分完记得固定随机种子,把划分结果存成文件(比如三个 txt 列表),后续所有实验都复用同一份划分,否则不同实验之间的指标没法比较。
5.3 先跑一次小规模试训
链路检查完,别急着上 300 epoch。先用最小的模型、最小的轮次跑一次,确认整条链路通:
yolo detect train \ data=/data/projects/demo/dataset/data.yaml \ model=yolov8n.pt \ imgsz=640 \ epochs=30 \ batch=16 \ workers=4 \ device=0 \ project=runs/demo \ name=smoke_test启动后立刻盯三行日志:扫描到的图片数量对不对、backgrounds 数量是不是 0(不等于 0 说明有图找不到标签)、corrupt 数量是不是 0。跑到第一个 epoch 结束,看 box_loss 有没有下降。30 轮之后看 val 的 mAP50,只要不是 0,链路基本就通了,剩下的才是调参和数据优化的活。如果 mAP50 一直是 0,九成是标签加载问题,回到 4.1 检查目录结构,别去动学习率。
试训还有个副产品:能顺便估算出完整训练的时间和显存占用。用batch=16跑一轮看看显存峰值,爆显存就减半,或者开amp(混合精度,默认就开着)。Windows 环境下workers建议设成 0 或 2,设大了容易在启动阶段卡死,这个和数据集无关,但很多人会误以为是数据有问题。
6. 常见问题与排查实录
6.1 训练指标异常时,先查数据再查模型
我把“训练不收敛”的原因做过简单归类,数据相关的问题大概占七成。下面这张表是我自己的排查顺序,从上往下逐条过,基本能定位到问题:
| 现象 | 最可能的数据原因 | 检查方法 |
|---|---|---|
| mAP 始终为 0 | 标签目录不匹配、txt 全空 | 看日志 backgrounds 数量,抽查 txt 内容 |
| loss 不下降 | 坐标未归一化、类别 id 越界 | 跑 3.4 的检查脚本 |
| 训练集好、验证集差很多 | 数据泄漏、验证集太小 | 查划分是否按来源分组 |
| 某一类完全检不出 | 该类实例数过少、标注缺失 | 统计类别分布 |
| 框位置系统性偏移 | 1-based 坐标没减 1、xy 写反 | 可视化看框是否整体偏移 |
| 误检特别多 | 缺负样本、背景单一 | 加纯背景图,扩场景 |
| 小目标检不出 | 训练分辨率不够、目标被缩没了 | 提高 imgsz 或切图训练 |
有一点要强调:每次只改一个变量。我见过有人一次改五处(换模型、调学习率、重划数据集、加增强、换分辨率),结果效果好得离谱,但根本说不清是哪一项起了作用,下次遇到问题还是不会。数据类的改动尤其要控制变量,因为数据本身不好度量,改动的影响需要靠对比实验才能看出来。
6.2 几个具体的踩坑记录
坑一:labels 目录名不能改。前面说过一次,这里再强调,因为真的太常见。有人习惯把标签叫annotations或者labels_txt,框架的路径替换逻辑认死了images到labels的映射,改名就读不到。如果确实要保留自定义目录名,办法是写一个转换脚本,在训练前拷贝一份符合规范的结构出来,别硬改框架行为。
坑二:加了新数据之后效果反而变差。大概率是新数据的标注风格和老数据不一致。混数据集之前先抽 20 张新数据可视化看一遍,框的松紧、类别的判定、有没有漏标,和老数据对齐之后再合。另外新数据的场景如果占比过高,会冲淡原来的分布,建议控制新数据比例,或者分批加入观察。
坑三:验证集指标好看,实际部署拉胯。除了泄漏,还有一个原因是验证集的场景和实际部署场景差距太大。验证集不是“随便抽一批图”,而是要尽量模拟真实推理时的输入。如果部署在夜间室外,验证集里就必须有夜间的图,否则指标再高也没有参考价值。
坑四:小目标检测怎么调都没用。先确认目标在训练分辨率下还剩多少像素:原图 4000×3000,目标 40×40,缩到 640 长边之后只剩 6×6,这个尺寸基本到极限了。解法是提高训练分辨率(显存换效果)、切图训练(把大图切成小块,每块单独作为样本,推理时再拼回来)、或者换更大的模型。但在动手之前,先确认标注有没有问题——极小目标的标注误差比例很大,框偏两三个像素,IoU 就掉一半。
坑五:标签文件里出现浮点的类别 id。有些转换脚本输出1.0而不是1,大部分情况下框架能解析,但个别版本会报错或者静默丢样本。转换时统一用int()转一下,输出整数,能避免很多莫名其妙的问题。
6.3 几条能省时间的实操心得
第一条,先做 200 张的闭环。从采集到推理完整走一遍,包括最后拿一张新图跑推理看结果。这个闭环跑通之前,不要开始批量标注。闭环里暴露的问题,往往在批量阶段会被放大几十倍。我自己的习惯是先拿一个极小的子集(每类二三十个实例)训 10 个 epoch,能出框就算通,出不来就查链路。
第二条,数据集要版本化。每次调整(加了数据、改了类别、修了标注)都打一个带日期的包,附一份变更说明写在 README 里。听起来很重,其实就是压缩一下加个文件名。等到某天你发现“三天前那版效果更好”想回退时,就知道这一步多值。目录名用dataset_v20240115这种格式,别用dataset_new、dataset_final、dataset_final2——相信我,final2之后还有final_真最终。
第三条,别一边标一边训。标注过程中数据集一直在变,训练结果没法比较,容易得出错误结论。要么标完一批冻结一版再训,要么严格记录每次训练用的数据版本号。
第四条,记录每次实验的数据配置。模型名、超参、数据版本、类别数、划分文件,这几项写在一个表里。数据类项目的实验对比,关键变量经常是数据本身,没有记录的话,两周后你完全不记得那次涨点是因为加了数据还是因为换了学习率。
最后分享一个小技巧:给数据集做一个“体检报告”,每次调整后跑一遍,输出图片数、实例数、各类别分布、空标签数、异常文件数、平均每图目标数、框的宽高分布。这份报告攒几版放在一起对比,能非常直观地看出数据在往哪个方向变化。我做某个检测项目时,就是靠这个报告发现验证集的平均目标尺寸比训练集小了近一半,导致小目标指标一直上不去——问题不在模型,在划分。
这个内容后续还可以往两个方向扩:一是半自动标注流水线的搭建,把预标注、人工修正、质量抽检串成一个能多人并行的流程;二是数据增强的针对性设计,YOLOv8 自带的 mosaic、HSV、翻转对通用场景够用,但对小目标、密集目标、特殊光照的场景,往往需要关掉某些增强或者自己加一些定制变换,这部分等数据链路稳定之后再做,收益会更明显。