把 YOLOv8 当成“调个包就能跑”的黑盒,是我刚开始接触目标检测时犯的最大错误。那时候我照着教程敲完命令,模型确实是跑起来了,runs/detect/train/weights/best.pt也生成了,可等我换到自己的数据集上,mAP 卡在 0.4 上不去,loss 曲线看着也挺“正常”,我完全不知道问题出在哪。后来花了大概三周时间,把网络结构、标签分配、损失函数、数据增强、后处理这一条链路从头捋了一遍,才发现前面卡住的原因根本不是“模型不行”,而是我在数据准备和参数理解上欠了太多账。这篇笔记就是那三周的产物,它不讲“YOLOv8 是什么”,而是讲一个真正动手的人会遇到的每一个坎:坐标怎么归一化、data.yaml里的路径为什么明明写对了却读不到、batch 调大以后显存为什么爆得莫名其妙、mAP50 看着挺高但实际检测框歪得离谱到底是哪里出了问题。不管你是刚准备入门目标检测,还是已经能跑通 demo 但效果始终不理想,下面这些内容应该都能帮你少走几天弯路。
1. 先说清楚 YOLOv8 到底“新”在哪,别急着敲命令
我见过太多人学一个新模型的第一反应是找一条能跑的命令,跑通了就觉得自己会了。这种学法在 YOLOv5 时代还能凑合,因为那时候社区教程多、坑都被踩平了;但到了 YOLOv8,结构上的改动直接影响你对参数的理解,如果不先搞清楚它在“框怎么来、正负样本怎么分、损失怎么算”这三件事上做了什么变化,后面调参基本就是盲猜。
1.1 从“框怎么生成”看锚框时代的结束
YOLOv5 及更早的版本是 anchor-based 的,意思是模型不是直接预测框的坐标,而是预测“相对于某个预设锚框的偏移量”。这套机制的好处是收敛稳定,坏处是你必须提前用聚类算法在训练集上算出一组锚框尺寸,锚框的宽高比、数量都要针对数据集调。换一个场景,比如从“检测行人”换到“检测细长的工业零件”,锚框就得重算,不算就掉点,这事儿当年坑了不少人。
YOLOv8 改成了 anchor-free,直接对特征图上的每个位置预测“这个点到框四条边的距离”,也就是分布式回归。这个改动带来的最直接好处是:你不需要再关心锚框超参了。数据集换了、目标尺度分布变了,模型自己学。我第一次切到 v8 的时候最直观的感受是,同一个数据集上不用调任何锚框相关的东西,第一轮训练出来的召回就比 v5 高了一截。
但这里有个容易被忽略的代价:anchor-free 对标签质量更敏感。因为它把每个位置都当成潜在的正样本候选,如果你的标注框本身偏了几个像素,或者有几张图的框画歪了,这些噪声会被更直接地灌进回归损失里。所以我后来的习惯是,用 v8 之前一定先做一遍标注可视化抽检,这个动作在后面第 7 节会讲具体怎么做。
1.2 解耦头与 C2f:结构上的两处关键改动
解耦头(decoupled head)这个概念最早在别的检测框架里被验证有效,YOLOv8 把它拿了过来。原来分类和回归共用一个卷积头,两个任务的特征互相干扰;拆开以后,分类分支专心判断“这是什么”,回归分支专心判断“框在哪”。实测下来最明显的变化是分类置信度和定位精度的相关性变弱了,以前经常出现“置信度 0.9 但框偏得离谱”的情况,拆开之后这种不一致少了很多。
另一个改动是 backbone 里的 C2f 模块,它替换了 v5 的 C3。C2f 的核心思路是保留更多的梯度分流路径,让特征复用更充分,同时在同等参数量下拿到更强的特征表达。这部分对使用者的实际影响是:同样标称的 n/s/m/l/x 五档模型,v8 的参数量和计算量跟 v5 不是一一对应的。我一开始拿 v5s 的经验去估 v8s 的显存,结果估少了将近 40%,直接 OOM。下面这张表是我在自己机器上实测的粗略对照,只做量级参考,具体数值会随输入尺寸和框架版本浮动:
| 模型档位 | 参数量(约) | 640 输入下的计算量(约) | 单卡 batch=16 训练显存(约) |
|---|---|---|---|
| n | 3.2M | 8.7 GFLOPs | 4~6 GB |
| s | 11.2M | 28.6 GFLOPs | 8~11 GB |
| m | 25.9M | 78.9 GFLOPs | 14~18 GB |
| l | 43.7M | 165 GFLOPs | 22~28 GB |
| x | 68.2M | 258 GFLOPs | 32 GB 以上 |
这张表最有用的地方不是具体数字,而是让你建立一个“档位翻倍,显存大致翻倍略多”的直觉。你要在 8G 卡上训练,就老老实实从 n 或者 s 起步,别一上来就 m。
1.3 标签分配与损失:训练能收敛的真正原因
YOLOv8 用的是 Task-Aligned Assigner 做正负样本分配,简单说就是:一个预测框该不该被判定为正样本,同时看它的分类得分和定位质量,两个都好的才给高权重。这比早期“按 IoU 阈值一刀切”的做法温和得多,训练早期模型还很差的时候不会一口气把所有样本都判成负样本,收敛自然更稳。
损失由三部分组成:分类用 BCE,回归用 CIoU 加 DFL(分布焦点损失)。你在results.csv里看到的三列box_loss、cls_loss、dfl_loss就是它们。这三个 loss 的量级差异很大,千万不要横向比较,也不要因为dfl_loss数值看着比cls_loss大就以为出问题了,它们各自的尺度本来就不一样。我当时就是因为看到 dfl_loss 一直是 1.2 左右、cls_loss 只有 0.4,误以为回归分支学不动,折腾了半天才发现这是正常范围。具体怎么判断正常与否,第 5 节会给出对照。
2. 环境与目录:第一次跑通前的三道门槛
环境这一步看起来最没技术含量,实际上是我见过新手放弃率最高的地方。原因很简单:报错信息要么长得像天书,要么干脆不报错但结果不对。我把这一步拆成三个必须过的坎,每个坎都有明确的验证方法,过了就往下走,别抱着“先跑起来再说”的心态硬冲。
2.1 版本对齐比装包本身更重要
深度学习环境的核心矛盾从来不是“装不上”,而是“装上了但版本不匹配”。YOLOv8 依赖 PyTorch,而 PyTorch 又依赖 CUDA 驱动,这三者的版本必须对齐。我建议的顺序是:先看显卡驱动支持的 CUDA 上限,再去选 PyTorch 版本,最后装 YOLOv8 的包。反过来装经常出现“能 import 但一训练就说 device 不可用”。
# 第一步:确认驱动和 CUDA 版本上限 nvidia-smi # 第二步:按官方矩阵选 PyTorch(示例,具体版本按你的 CUDA 版本查) pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 # 第三步:装 YOLOv8 的训练推理包 pip install ultralytics装完之后不要急着训练,先跑三行验证:
import torch print(torch.__version__, torch.cuda.is_available(), torch.cuda.device_count()) from ultralytics import YOLO print("ok")cuda.is_available()返回False是最常见的坑,九成情况是装成了 CPU 版本的 PyTorch。这时候不要试图“修复”,直接卸载重装正确版本,比你在那儿折腾环境变量快得多。另外,如果你用的是 conda,注意别让系统里另一个 Python 环境的 torch 抢先被 import,python -c "import torch; print(torch.__file__)"看一眼路径就知道有没有串。
2.2 目录放错位置,训练脚本会静默出错
YOLOv8 的默认目录约定跟很多传统项目不一样,它会把所有输出塞进runs/下面,并且同名任务自动递增编号:第一次训练是runs/detect/train,第二次是runs/detect/train2,以此类推。这个设计本身挺好,但如果你在脚本里写死了runs/detect/train/weights/best.pt去读结果,第二次跑就会读到上一次的旧模型,而且不会报错。我第一次遇到这个情况时,盯着一个“怎么调都不变”的 mAP 看了两个小时。
我后来固定下来的做法是:训练时显式指定project和name,并且打开exist_ok之外的模式,让每次实验目录唯一。
from ultralytics import YOLO model = YOLO("yolov8s.pt") model.train( data="configs/mydata.yaml", epochs=100, imgsz=640, batch=16, project="runs/exp", name="v8s_baseline_640", exist_ok=False, )这样输出路径就是确定的runs/exp/v8s_baseline_640/,实验名字里带上模型档位和输入尺寸,回头翻记录的时候一眼就知道是哪个配置。这个习惯看起来琐碎,但等你跑到第二十几个实验的时候,会感谢当初的自己。
2.3 用一张图跑通推理,再谈训练
我的建议是永远先用预训练权重跑一次推理,确认“模型加载、图像读取、结果解析、画框保存”这四步全通,再去碰训练。因为训练一旦出问题,你可能要等半小时才看到第一个 epoch 的结果,而推理是秒级的。
from ultralytics import YOLO model = YOLO("yolov8n.pt") results = model.predict("test.jpg", conf=0.25, save=True, project="runs/pred", name="smoke") for r in results: print(r.boxes.xyxy.shape, r.boxes.cls.tolist(), r.boxes.conf.tolist())这里要重点看的是r.boxes.xyxy的形状,正常是一张N×4的张量;如果 N 是 0,说明这张图里没检出东西,先换一张明显有目标的图再试。如果连这一步都出不来结果,问题一定在环境或者输入路径上,跟模型能力无关,别往模型上找原因。
3. 数据集这一关:坐标归一化和 yaml 是翻车重灾区
我可以很肯定地说,初学者在 YOLOv8 上遇到的 70% 的“训练不收敛”,根子都在数据集。而且这类问题最恶心的地方是它不报错。标签格式错了、坐标没归一化、类别 id 越界,训练照样能跑完,loss 照样在降,只是最后模型学出来的是垃圾。所以这一节我会写得比较细,每一条都对应一个具体的检查动作。
3.1 YOLO txt 里那四个数到底是什么
YOLO 格式的标签文件是纯文本,每行代表一个目标,格式是:
class_id x_center y_center width height关键在于后四个数全部是归一化到 0~1 的比例值,而且 x_center、y_center 是框的中心点,不是左上角。这一点跟 Pascal VOC 的xmin, ymin, xmax, ymax和 COCO 的x, y, w, h(绝对像素)都不一样,转换的时候极容易搞混。我见过最典型的错误是把 VOC 的绝对坐标直接除以图片宽高当成 YOLO 格式用,结果 x_center 算出来是对的,但意义完全错了。
正确的转换逻辑是这样:
# VOC 格式 (xmin, ymin, xmax, ymax) -> YOLO 格式 def voc_to_yolo(xmin, ymin, xmax, ymax, img_w, img_h): x_center = (xmin + xmax) / 2.0 / img_w y_center = (ymin + ymax) / 2.0 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h # 夹紧到 [0, 1],防止标注越界 x_center = min(max(x_center, 0.0), 1.0) y_center = min(max(y_center, 0.0), 1.0) w = min(max(w, 0.0), 1.0) h = min(max(h, 0.0), 1.0) return x_center, y_center, w, h最后那四行夹紧操作是我后来加上去的,因为真实标注里确实存在越界框,比如框的右下角超出了图片边缘。不夹紧的话,归一化之后会出现大于 1 的值,YOLOv8 在训练时不会报错,但这个样本的回归目标本身就是错的,等于给模型喂了一个坏样本。数据集大的时候,几十个坏样本足以把某一类的指标拉下来一大截。
3.2 data.yaml 的字段语义与相对路径陷阱
data.yaml看起来简单,但字段语义有好几个容易踩的点。先看一个我实际在用的模板:
path: /data/mydataset # 数据集根目录(绝对路径最稳) train: images/train # 相对于 path val: images/val test: images/test # 可选 names: 0: person 1: helmet 2: vest第一个坑是path和train的关系。如果train写的是相对路径,它是相对于path解析的,不是相对于 yaml 文件本身,也不是相对于你执行命令的当前目录。这三个“相对”搞混了就会出现“明明文件在那儿但说找不到”的情况。我的做法是path一律写绝对路径,train/val写相对路径,这样整个数据集目录搬走的时候只需要改一行。
第二个坑是标签目录的推导规则。YOLOv8 会根据图片路径自动推导标签路径,规则是把路径中最后一个images替换成labels,扩展名换成.txt。也就是说,如果你的图片放在images/train/xxx.jpg,标签就必须在labels/train/xxx.txt。如果你的目录结构不是这个约定,比如标签和图片混在一个文件夹里,那就得自己写一份图片清单文件传给train。这个规则文档里写得很轻描淡写,但它是“No labels found”报错的第一大来源。
第三个坑是names的写法。用字典{0: person, 1: helmet}或者列表["person", "helmet"]都可以,但列表的索引必须从 0 开始连续,字典的 key 也必须是 0 开始的连续整数。中间断号不会报错,但类别映射会整体错位,模型学出来的东西全乱。我现在的习惯是写完 yaml 立刻用一行代码验证:
import yaml cfg = yaml.safe_load(open("configs/mydata.yaml")) print(cfg["names"]) print(sorted(cfg["names"].keys()) == list(range(len(cfg["names"]))))最后那个布尔值必须是True。
3.3 训练前必须做的五项数据自检
我现在有固定的一套自检清单,跑完这五项才开始训练,能省掉后面 80% 的无效调试时间:
- 数量对齐:图片数量和标签数量必须一致,多出来的图片如果没标签,会被当成负样本,反而可能有害。
- 类别分布:统计每个类别的框数量,如果某一类只有个位数,基本别指望它能学好,得考虑合并类别或者补数据。
- 框尺寸分布:把所有框的宽高按输入尺寸缩放后统计,如果大量框小于 8 像素,说明小目标占比高,
imgsz就得往上提。 - 坐标合法性:检查所有归一化值是否落在
[0, 1],以及w、h是否大于 0。 - 图片可读性:所有图片必须能被正常解码,坏图会让训练在某个随机 epoch 突然崩掉。
这五项我写成了一个脚本,几十行的事,但收益极高。尤其是“框尺寸分布”这一项,它直接决定你后面imgsz该设 640 还是 1280,比凭感觉试要靠谱得多。
3.4 缓存策略:cache 选错会把内存吃光
YOLOv8 提供了cache参数,可以设成True、"ram"、"disk"或者False。它的作用是把解码后的图片缓存起来,避免每个 epoch 重复读盘解码。对于小数据集,开ram缓存能显著提速;但对于几万张图的数据集,ram缓存会直接把你内存吃干净然后进程被系统杀掉,而且这种“杀进程”往往没有明显报错,看起来就像训练莫名其妙中断了。
我的判断标准很粗糙但好用:数据集总大小(图片原始字节)如果小于可用内存的一半,就开ram;否则用disk。disk会往磁盘写缓存文件,速度比ram慢一些但内存安全。用disk的时候记得留够磁盘空间,缓存文件的大小大致跟原始图片总量相当。
4. 训练参数不是玄学:我逐个试出来的取值逻辑
参数这一块,网上的文章两极分化严重:一类是“全部用默认值就行”,另一类是贴一大堆参数让你照抄。照抄的问题在于你不知道哪些参数之间有耦合,改一个动了全身。我在这一节里只讲我真正调过、并且能解释清楚为什么的参数,每个参数都给出“什么情况下该动它”的判断依据。
4.1 imgsz、batch、workers 三者的相互牵制
imgsz是输入分辨率,默认 640。它决定了模型“看得多清楚”,也是最直接影响小目标检测效果的参数。显存占用大致跟imgsz的平方成正比,所以从 640 提到 1280,显存差不多要翻四倍,batch 就得相应砍到四分之一。
batch是批大小,它影响的是梯度估计的稳定性和 BN 层的统计质量。batch 太小(比如 2、4)会让训练变得很抖,尤其是 BN 层的均值和方差估计不准,指标波动大。我的经验下限是 batch 不低于 8,如果显存实在不够,宁可降imgsz也不要降 batch 到 4 以下。
workers是数据加载的进程数,默认 8。它的作用是让 CPU 提前准备好下一个 batch 的数据,避免 GPU 空转等数据。判断这个值合不合适有个特别直观的方法:看训练日志里每个 epoch 的耗时,如果 GPU 利用率长期上不去、results.csv里 epoch 之间的时间间隔忽大忽小,多半是workers不够或者数据读取本身太慢(比如图片存在网络盘上)。但workers也不是越大越好,超过 CPU 核数反而会因为进程切换开销变慢,一般设成 CPU 物理核数的 0.75 倍左右比较合适。
这三者的调参顺序我固定为:先定imgsz(由目标任务的最小目标尺寸决定),再定batch(在显存允许下取大值),最后调workers(看 GPU 利用率)。顺序反了会来回改。
4.2 学习率与优化器:不要迷信默认值也不要乱改
YOLOv8 的默认lr0是 0.01,优化器是auto,也就是根据训练总步数自动选。这个默认值在 batch=16 左右是比较合适的,但如果你把 batch 改成了 64,学习率还留在 0.01,训练前期就会特别抖。业界有个经验性的线性缩放规则:学习率大致跟 batch 成正比,以 batch=64 为基准的话,lr = lr0 * batch / 64。所以 batch=16 时,线性缩放得到的 lr 大约是 0.0025。这个规则不是硬约束,但它是一个很好的起点。
这里要提醒一句:不要在一次实验里同时改学习率和别的参数。我早期犯过这个错,把 lr、batch、增强参数一起调了,结果指标掉了,根本不知道是哪个引起的。现在我每次实验只动一个变量,实验名里把这个变量标出来,比如v8s_lr0.005_640,这样对比的时候一目了然。
lrf是最终学习率相对于初始学习率的比例,默认 0.01,配合余弦退火使用。这个值我几乎不动,因为它在训练末段的作用是把学习率压到很小,让模型在最优解附近精细收敛。如果你发现训练最后几十个 epoch 指标还在缓慢上升但幅度很小,说明lrf可能设得太大了,模型一直在最优解附近“跳”,压小一点会更稳。
4.3 数据增强开关的取舍(mosaic 什么时候该关)
YOLOv8 的默认增强里,影响最大的是mosaic,它把四张图拼成一张,强迫模型学习不同尺度和上下文。这个增强对小目标检测特别有效,因为它让小目标在拼图后相对变大了一部分。但mosaic有个副作用:拼图会引入不自然的边界,导致模型学到一些“假上下文”。
默认配置里有个close_mosaic=10,意思是最后 10 个 epoch 关掉 mosaic,让模型在真实分布上做最后的适应。这个设计很聪明,我基本不动。但如果你训练轮数很少(比如只有 30 个 epoch),close_mosaic=10就意味着三分之一的训练没有 mosaic,这时候可以适当调小到 5。
mixup和copy_paste默认都是关闭的。mixup会把两张图按透明度混合,对分类任务有用,对检测任务容易让框的语义变模糊,我一般不开。copy_paste是做实例分割时才真正有意义的增强,检测任务里开了效果有限。hsv_h/s/v这三个颜色增强默认值(0.015 / 0.7 / 0.4)在光照变化大的场景下很有用,如果你的数据集本身光照就很统一,可以适当调小,避免把有用的颜色信息破坏掉。
4.4 冻结训练与预训练权重的使用时机
freeze参数可以冻结 backbone 的前若干层,只训练后面的检测头。这个技巧在小数据集上特别有用:数据量不够的时候,整个网络一起训容易过拟合,冻结骨干能让模型保留预训练学到的通用特征,只调整跟你的任务相关的部分。
我的经验是:数据量在几百张到一两千张之间时,先试freeze=10(冻结前 10 层),训 30 个 epoch 看看指标,如果明显比不冻结好,就继续;如果冻结反而不如不冻结,说明你的数据集跟预训练数据分布差异太大,冻结反而是负担,那就放开。这个方法比看论文里的建议靠谱,因为分布差异这件事只有你自己的数据知道。
4.5 早停、保存周期与断点续训
patience默认 50,意思是连续 50 个 epoch 验证指标没提升就停。这个值在总轮数 100 的设定下偏大,实际训练很少能真的训满 100 轮还不触发。如果你的算力有限,可以调小到 20~30,代价是有可能过早停止。我一般设 30,并且配合save_period每 10 个 epoch 存一个检查点,这样即使早停判早了,也能从中间某个检查点接着训。
断点续训用resume=True,它会从last.pt恢复优化器状态、学习率调度和 epoch 计数。这里有个细节:续训的时候不要重新指定其他训练参数,因为resume会优先用检查点里保存的参数,你新传的会被忽略或者引发冲突。我一般就是单独写一个 resume 的入口,参数全部从检查点读。
5. 盯着 results.csv 训练:loss 和 mAP 各说明了什么
训练跑起来之后,绝大多数人的动作是“等它跑完看结果”。这其实是浪费了最有价值的信息。results.csv里每一行都是一个完整的学习状态快照,会看这张表的人能在第 20 个 epoch 就判断出这次训练要不要提前终止,不用等两个小时。
5.1 三个 loss 分别对应什么,正常曲线长什么样
train/box_loss是回归损失,衡量预测框和真实框的位置偏差;train/cls_loss是分类损失;train/dfl_loss是分布焦点损失,属于回归分支的一部分,衡量预测的边界分布跟真实分布的距离。
正常的训练曲线应该是这样的:box_loss和dfl_loss在前 10~20 个 epoch 快速下降,然后放缓,整体呈一条平滑的下凸曲线;cls_loss起点通常不高(因为大部分预测都是负样本,负样本的分类损失很快就降下去了),中期会有一次明显的下降,这是模型开始能区分正负样本的信号。
我第一次训练时盯着dfl_loss停在 1.1 左右不动,以为是回归分支学死了。后来才知道 DFL 的损失尺度跟分类损失完全不同,1.0 左右是很正常的收敛值。所以判断 loss 是否正常,绝对不要看绝对值,要看趋势:还在缓慢下降就是正常的,连续十几个 epoch 完全水平甚至回升才是问题。
5.2 mAP50 和 mAP50-95 的差距是定位问题的信号
metrics/mAP50是 IoU 阈值取 0.5 时的平均精度,metrics/mAP50-95是把 IoU 从 0.5 到 0.95 每隔 0.05 取一次再平均。后者严格得多,因为它要求框的位置非常准。
这两个数的差距能直接告诉你问题出在哪:
| 现象 | 含义 | 优先排查方向 |
|---|---|---|
| mAP50 高,mAP50-95 明显低 | 能检出目标但框不准 | 标注框质量、imgsz是否够、回归损失权重 |
| 两个都低 | 检出能力本身不足 | 数据量、类别分布、模型档位、训练轮数 |
| mAP50 早期就高但很快平台 | 模型容量或数据量到顶了 | 换更大档位模型、补数据、检查标签噪声 |
| 两个指标都剧烈震荡 | 学习率过大或 batch 过小 | 降lr0、升batch |
我自己遇到过一次典型的“mAP50 有 0.85,mAP50-95 只有 0.38”,排查下来是标注框普遍比真实目标小了 5~8 个像素,因为标注的人习惯贴着目标内边缘画。这种系统性的标注偏差对 IoU 0.5 的判定影响不大,但一严格就崩。后来统一了标注规范(按目标外轮廓画,包含半个像素的边缘)之后,mAP50-95 直接涨了将近 10 个点。
5.3 异常曲线的对照排查表
训练多了以后,我发现常见的异常就那么几种,把它们整理成一张表,出问题的时候按表对号入座,比漫无目的地猜快得多:
| 曲线表现 | 最可能的原因 | 验证方法 |
|---|---|---|
| loss 一开始就是 nan | 学习率过大、标签坐标异常、图片损坏 | 用 1 张图过拟合测一下 |
| train loss 降但 val loss 上升 | 过拟合 | 看数据量是否太少、开增强、加 freeze |
| 两个 loss 都不降 | 数据或标签根本没读对 | 可视化训练 batch 图 |
| 指标在某个 epoch 突然暴跌 | 学习率调度切换点、数据里有坏样本 | 定位到具体 epoch 检查 lr 曲线 |
| 分类 loss 一直很高不降 | 类别严重不平衡或者类别标注混乱 | 统计每类框数、检查是否有重复类别 |
5.4 训练结束该看的几张图
训练目录里除了weights/和results.csv,还有几张图特别值得看,但很多人直接忽略了:
train_batch0.jpg之类的是训练时的输入可视化,它展示的是经过增强后模型实际看到的图像和标签。这张图能一眼看出增强是不是过猛、标签框是不是对得上目标、mosaic 拼接是不是自然。我有一次发现这张图里的标签框整体偏移了,一查才发现是某批图的尺寸信息和实际文件不符。
confusion_matrix.png混淆矩阵能看出哪些类别之间互相混淆。如果两个类别在矩阵里互相高频误判,说明它们的视觉特征确实太像,或者标注时边界定义不清。这种情况最有效的解决办法往往不是调模型,而是重新明确这两类的定义,甚至合并成一类。
PR_curve.png和F1_curve.png用来确定推理时的conf阈值。F1 曲线上的峰值点对应的置信度,通常就是分类效果最好的工作点。不过要注意,这个峰值是在验证集上算的,实际部署时还要根据你对漏检和误检的容忍度做偏移。
6. 推理与导出:从 predict 到能落地的模型
训练出一个best.pt只完成了工作的一半。真到用的时候你会发现,推理参数的默认值不一定适合你的场景,导出的模型格式也可能因为一个输入尺寸没对齐而精度掉一截。这一节讲的是“把模型变成能用的东西”这一步。
6.1 推理参数里最容易被忽略的三个
conf是置信度阈值,默认 0.25。这个值直接决定漏检和误检的平衡,太低会出一堆假框,太高会漏掉真目标。我的做法是在验证集上跑一遍model.val(),看 F1 曲线的峰值,然后在这个峰值附近取,再根据业务需求往某个方向偏 0.05~0.1。
iou是 NMS 的 IoU 阈值,默认 0.7。它的作用是合并重叠框,值越小合并越激进。对于密集目标场景(比如一群人挤在一起),这个值要调大(0.8 以上),否则相邻目标的框会被错误地合并掉。反过来,如果你的场景里同一个目标经常被重复检出好几个框,就把这个值调小。
max_det是单张图最多保留多少个框,默认 300。这个参数看起来无关紧要,但在密集场景下会悄悄限制你的召回。我有一次做人群计数,怎么调都数不满,最后发现是max_det卡在 300,而实际一张图里有四百多人。改成 1000 之后问题立刻解决。这个坑特别隐蔽,因为超出的框是被静默丢弃的,不会有任何提示。
6.2 导出 ONNX 时对齐输入尺寸与 opset
导出 ONNX 是部署的常规路径,命令本身很简单:
from ultralytics import YOLO model = YOLO("runs/exp/v8s_baseline_640/weights/best.pt") model.export(format="onnx", opset=12, simplify=True, imgsz=640, dynamic=False)这里有几个必须注意的点。第一,imgsz必须跟训练时一致,否则精度会掉,因为模型学到的特征尺度是跟输入分辨率绑定的。第二,dynamic=False表示固定输入尺寸,这样导出的模型在推理引擎里能被更好地优化;如果你确实需要动态尺寸,才设成True,但性能会有损失。第三,opset版本要跟你的推理引擎匹配,太新的 opset 有些老版本引擎不支持,12 是一个比较通用的选择。
导出之后一定要做一次数值对齐验证:用同一张图分别跑 PyTorch 模型和 ONNX 模型,比较输出的框坐标差异。正常情况下差异应该在 1e-3 量级以内,如果差很多,说明导出过程有问题,最常见的原因是预处理(归一化方式、通道顺序)不一致。
6.3 小目标的补救办法:提高分辨率与切片推理
小目标检测是目标检测里最头疼的问题,因为小目标在特征图上占的像素太少,经过几次下采样之后几乎消失了。YOLOv8 在这方面比早期版本好,但不是万能。
第一个手段是把imgsz提上去。我做过对比,同一个数据集从 640 提到 1280,小目标类别的 mAP50 能涨 15 个点以上,代价是推理速度降到四分之一左右。这个取舍要看你的场景能不能接受。
第二个手段是切片推理,思路是把大图切成若干小块分别检测,再把结果拼回去。这样每个小目标在切片里就相对变大了。实现上要注意两点:切片之间要有重叠区域,否则跨边界的目标会被切断;拼接后要做一次全局 NMS,否则重叠区域会出现重复框。这套思路的实现不算复杂,但要调到好用需要花点时间在切片尺寸和重叠比例的配合上。
6.4 后处理里的坐标还原
如果你做了 letterbox 预处理(保持长宽比缩放并填充),那么模型输出的坐标是在填充后的图上的,必须还原回原图坐标。这一步错了,框会整体偏移或者缩放比例不对,而且因为是系统性的偏差,看单张图不一定能发现。
还原的核心是两步:先减去填充的偏移量,再除以缩放比例。写这段代码时最容易出错的地方是填充量的计算,尤其是当宽高比不同时,填充只发生在一边。我的建议是不要自己手写这套逻辑,直接用框架提供的results[0].boxes.xyxy配合results[0].orig_shape,框架内部已经处理好了。如果确实需要自己算,务必用一张已知坐标的测试图验证一次。
7. 我自己写过的四个小工具,用来验证有没有真懂
学完一个模型之后怎么判断自己是不是真的懂了?我的标准是:能不能不调库,手写出核心的几个组件。这个方法听起来有点笨,但极其有效。下面这四个小工具是我学 YOLOv8 期间写的,加起来不到两百行,但对理解整个流程的帮助远超看十篇博客。
7.1 手写 IoU 与 NMS
IoU 是交并比,是所有检测指标的基础。手写一遍你就明白为什么框的位置偏差对小目标影响更大:同样偏移 5 个像素,对一个 20×20 的框和一个 200×200 的框,IoU 的下降幅度差了一个数量级。这个直觉在做小目标优化的时候特别重要。
import numpy as np def iou(box, boxes): # box: [x1, y1, x2, y2] x1 = np.maximum(box[0], boxes[:, 0]) y1 = np.maximum(box[1], boxes[:, 1]) x2 = np.minimum(box[2], boxes[:, 2]) y2 = np.minimum(box[3], boxes[:, 3]) inter = np.clip(x2 - x1, 0, None) * np.clip(y2 - y1, 0, None) area_a = (box[2] - box[0]) * (box[3] - box[1]) area_b = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) return inter / (area_a + area_b - inter + 1e-9)NMS 是抑制重复框的过程,按置信度从高到低遍历,保留当前最高的框,然后把跟它 IoU 超过阈值的候选框全部丢掉。手写完这个之后我才真正理解iou这个参数为什么会影响密集目标的检出数量:阈值设 0.7,两个真实目标如果重叠超过 0.7,其中一个就会被误删。
7.2 用 20 张图手算一遍 mAP
mAP 的计算过程看起来复杂,其实拆开就是:对每个类别,把预测按置信度排序,逐个累加 TP 和 FP 得到 precision 和 recall 序列,画出 PR 曲线,算曲线下面积,再对所有类别求平均。我用 20 张图、2 个类别手算了一遍,过程中才真正搞明白为什么“只有正样本没有负样本”的验证集会算出虚高的 mAP,也明白了为什么类别不平衡会让 mAP 这个平均值掩盖掉小类别的糟糕表现。
手算完之后我养成了一个习惯:除了看总 mAP,一定要单独看每个类别的 AP。如果某一类的 AP 只有 0.3 而其他类是 0.9,总 mAP 看起来还不错,但实际部署的时候这类基本就是不可用的。
7.3 标签可视化检查脚本
这个脚本是我用得最频繁的。它把标签框直接画到原图上,人眼扫一遍就能发现标注错误。写法很简单,读 txt、反归一化、用 OpenCV 画矩形、保存成一张拼图。
我一般会随机抽 50 张图拼成一张大图来看,重点看三类问题:框是不是把目标完整包住了、有没有目标漏标、有没有框画到了背景上。这三类问题在统计数字上完全看不出来,但抽检几十张图基本就能看出标注质量的整体水平。我第一次跑这个脚本的时候,在一批标好的数据里发现了将近一成的漏标,都是小目标,标注的人可能根本没注意到。
7.4 一份可复用的实验记录表
这是最不“技术”但对我帮助最大的东西。我用一个表格记录每次实验的:实验名、模型档位、imgsz、batch、lr0、增强开关、数据版本、最终 mAP50、mAP50-95、训练耗时、备注。这个表的价值在于,当你试到第十几次实验的时候,你能清楚地看到哪次改动带来了提升、哪次是白费功夫。
| 实验名 | 模型 | imgsz | batch | 关键改动 | mAP50 | mAP50-95 |
|---|---|---|---|---|---|---|
| v8n_base | n | 640 | 16 | 基线 | 0.781 | 0.412 |
| v8s_base | s | 640 | 16 | 换大档位 | 0.803 | 0.446 |
| v8s_1280 | s | 1280 | 4 | 提分辨率 | 0.836 | 0.503 |
| v8s_1280_frz | s | 1280 | 4 | 加 freeze=10 | 0.841 | 0.511 |
这张表最有说服力的地方是“换大档位”和“提分辨率”两条的对比:换模型只涨了 2 个点的 mAP50,提分辨率涨了 3 个多点,而且 mAP50-95 涨得更多。这个结论直接影响了后续的优化方向,让我把精力放在输入尺寸和数据质量上,而不是一味追求更大的模型。
8. 踩过的坑与经验教训(按排查链路写)
前面几节讲的是“应该怎么做”,这一节讲“我怎么做错的”。我特意按排查链路来写,因为真正排错的时候,你面对的是一个现象,需要一步步缩小范围,而不是直接知道答案。
8.1 “No labels found” 的四种成因
这个报错大概是 YOLOv8 使用者遇到频率最高的一个。它的字面意思是“在标签目录里没找到标签”,但实际原因有四种,必须按顺序排查。
第一种,也是最常见的:目录结构不符合images/labels的约定。前面讲过,框架是把路径里的images替换成labels来推导的。如果你的图片在data/imgs/train,它在找的是data/labels/train,而对不上。排查方法很简单,把报错信息里打印的那个路径复制出来,去文件管理器里粘贴一下,看存不存在。
第二种:标签文件的扩展名不对。必须是.txt,有些转换脚本会输出.TXT大写,在某些系统上不匹配。
第三种:图片和标签的文件名主体不一致。abc.jpg对应abc.txt,如果标签文件叫abc_1.txt,就对不上。批量改名的时候特别容易出这个问题。
第四种:缓存导致的假报错。YOLOv8 会在数据集目录下生成.cache缓存文件,如果你改了标签之后没删缓存,它会读旧的索引。这个坑我踩过两次,表现是“明明标签在那儿,但就是说找不到”。解决办法是把*.cache文件删掉重新跑。
8.2 类别 id 从 0 开始,越界不报错但会毁掉训练
YOLO 的类别 id 从 0 开始,这是常识。但真正坑人的是:如果你的data.yaml里定义了 3 个类别(id 0、1、2),而某个标签文件里写了 id 3 甚至 id 5,训练不一定会立刻报错,在某些版本上它会静默地忽略这些样本,或者更糟,把它们当成无效样本处理掉。
我遇到过一次,某个类别怎么都学不好,最后统计发现这一类有三成样本的 id 写成了 1-based,也就是本该是 0 的写成了 1。这些样本被错误地归到了另一个类别里,导致两个类别的标注互相污染。这种问题从 loss 曲线和 mAP 上完全看不出来,只能靠数据自检发现。所以第 3 节里我强调的“坐标合法性”检查,其实还要加上一条“类别 id 范围检查”,这一步是必须的。
8.3 Windows 下多进程与中文路径
这两个问题单独出现都好解决,凑在一起就特别折磨人。Windows 下 DataLoader 的多进程是用 spawn 方式启动的,这意味着你训练脚本里如果直接写了顶层代码而没有放在if __name__ == "__main__":保护里,子进程会重新执行一遍整个脚本,表现就是训练卡死或者无限递归启动进程。
中文路径的问题更隐蔽:OpenCV 的imread在部分版本和部分系统上读中文路径会返回None,而 YOLOv8 内部读图用的就是这类接口。结果是图片读不到,但报错信息可能只是“找不到图片”或者更奇怪的东西。我的处理方式有两个:一是尽量让数据集路径全英文,这是最省事的;二是实在不行就改掉读取逻辑,用字节流读入再解码,绕开路径编码的问题。
8.4 显存爆掉但 batch 明明不大
显存 OOM 的时候,很多人的第一反应是“batch 调小”。但有时候 batch 已经从 16 调到 8 了还是爆,这时候问题可能不在 batch 上。我总结的几个隐蔽原因是:
第一个,imgsz被无意中改了。有些教程里的配置和你自己的脚本混在一起,imgsz设成了 1280,显存占用是 640 的四倍,batch 调到 4 都不一定够。
第二个,workers设得太大,每个 worker 都会持有数据副本,整体内存和显存占用会上去。这种情况表现为“刚开始训练还行,跑了几十个 batch 之后爆”,因为缓存是逐步填满的。
第三个,验证阶段爆。训练阶段用的小 batch,验证阶段默认可能用更大的 batch,或者验证时没有torch.no_grad()保护。表现是每个 epoch 训练都正常,一到验证就崩。
排查这类问题的顺序应该是:先打印实际生效的配置(model.train会打印所有参数),确认imgsz、batch真的是你以为的值;再单步跑一个 batch 看显存峰值;最后才考虑降 batch。这个顺序能帮你避开很多“白降 batch 但问题还在”的无效操作。
9. 从“能跑”走到“能用”:我后来固定下来的几件事
写到这儿,我想分享几个后来变成肌肉记忆的习惯,它们跟具体参数无关,但决定了我能不能把一次训练真正变成可交付的结果。
第一件事是每次训练前固定跑一遍数据自检,哪怕数据集是我自己整理的、已经用过很多次。因为数据集是会变的,加了几张新图、改了几个标签,这些变动往往不会有记录,只有自检能兜住。
第二件事是永远保留一份基线配置。我现在的项目里都有一个baseline目录,里面是那份跑出过可靠指标的完整配置和数据版本记录。每次想试新东西,都是从这个基线复制一份出来改,而不是在原来的脚本上直接改。这个习惯救过我一次:某次改了一堆参数效果反而变差,回去一比才发现是数据划分的随机种子变了,导致验证集里混进了一批特别难的图。
第三件事是把验证集当“考试”,不当“练习”。我早期的坏习惯是反复拿验证集调参,调到最后那个 mAP 已经完全不能代表泛化能力了。后来我改成从训练集里再切一小部分出来做“开发集”,所有调参都在开发集上做,验证集只在最后评估一次。这个改动让我的指标预估准确度提高了非常多,因为验证集不再被“污染”了。
第四件事是推理速度要和精度一起看。同一个场景下,yolov8s 在 640 输入上的精度可能只比 yolov8m 低两三个点,但速度能快一倍多。如果你的业务对延迟敏感,那两三个点的差距往往不值得用一倍的速度去换。我现在的做法是先确定延迟上限,再在这个上限内找精度最高的配置,而不是反过来。
最后一件小事:训练日志一定要存下来。results.csv、几张关键的可视化图、最终的配置,全部归档到一个带日期的目录里。我吃过一次亏,某个跑出最好指标的实验因为没记录具体的增强参数,后面怎么都复现不出来,只能从头再试一遍。这件事之后我才明白,能复现的实验才叫实验,不能复现的只能叫“运气好”。