
把 yolov8 的 detect、segment、pose 三套头摆在一起看会发现一件挺反直觉的事它们共用同一个骨干、同一个 Neck、同一套正样本分配逻辑差别只在最后那几十个输出通道以及分割任务多出来的一个原型分支。很多人第一次读 yolov8 的源码看到v8DetectionLoss、v8SegmentationLoss、v8PoseLoss三个类就头大其实真正的差异点加起来不到两百行代码。这篇就把目标检测、实例分割、关节点估计三条支线的原理拆开讲清楚包括通道数是怎么算出来的、框和关键点是怎么从网络输出还原回原图坐标的、以及训练和部署时哪些参数一动就会出问题。适合已经跑通过一次官方 demo、想搞清楚为什么这么设计的人也适合准备做课程设计或者要往边缘设备上搬模型的同学。1. 三个任务共用一套骨干YOLOv8 的网络骨架到底长什么样1.1 从 C3 到 C2fBackbone 里最容易被忽略的改动如果拿 yolov5 的结构图跟 yolov8 对比肉眼最明显的差别是 Backbone 里的 C3 模块换成了 C2f。这两个模块干的事类似都是靠 split 加跨层拼接来提升梯度流动但 C2f 的 bottleneck 数量是动态的它把中间所有 bottleneck 的输出都拼起来再走一次卷积而不是只拼输入和最后一个输出。这个改动带来的直接后果是同等深度下参数量略涨、但特征复用更充分尤其在中小目标上表现更稳。另一个藏在细节里的改动是卷积核。yolov8 把主干里部分 3x3 卷积换成了更小的组合方式配合 Pytorch 官方对Conv模块的统一封装Conv2d BN SiLU让整个网络在导出时更容易被推理框架合并。对训练阶段影响不大但对后面要走的 TensorRT、RKNN 这类部署链路来说能合并的算子越多中间就会少掉一堆额外的访存开销。我自己实测过一个细节把yolov8n的 C2f 手动改回 C3参数量会掉一些但 COCO 上 mAP 也会跟着掉小目标的召回下降更明显。所以如果你的场景里目标本身很小比如遥感影像、无人机航拍、鸟类检测这类不建议为了省参数往回改真要压模型压缩比和量化才是更划算的路子。1.2 PAN-FPN 与三个尺度的分工Neck 部分沿用的还是 PAN 结构自顶向下传一次语义自底向上再传一次定位信息。yolov8 具体用了三层输出分别来自 Backbone 的 P3、P4、P5对应输入图像的 1/8、1/16、1/32 下采样。以 640x640 输入为例三个特征图的分辨率是 80x80、40x40、20x20锚点anchor point总数加起来 640016004008400。这个 8400 是后面看输出张量时一定会遇到的数字。你导出 onnx 之后看到形状是(1, 84, 8400)那个 8400 就是这三层铺平拼接的结果不是某一层的。换句话说yolov8 把三个尺度在通道维度之外做了一次拍平让后处理可以用一套统一逻辑处理所有尺度代价是需要在解码时把 stride 信息一起带进去。输出层来源640 输入下分辨率Stride锚点数P3Backbone C2f 末端80 x 8086400P4下采样一次40 x 40161600P5SPPF 输出20 x 2032400三层之间是有明确分工的P3 感受野小、分辨率高负责小目标P5 感受野大、语义强负责大目标和整体场景判断P4 是中间那层很多时候是实际贡献 mAP 最多的一层。如果你想加 P2 层来救小目标思路就是再往下采一次把 stride 4 那层也接进来锚点数会从 8400 涨到 34000 左右显存和推理时间都会明显上去需要权衡。1.3 解耦头与三种任务输出张量的对照yolov8 用的是解耦头Decoupled Head分类和回归走两条独立的小分支。这个设计在 YOLOX 时代就被验证过了分类任务关注的是这是什么回归任务关注的是在哪、多大两者需要的特征分布其实不一样硬塞在一个卷积里会互相拖后腿。解耦之后分类分支和回归分支各自卷两次再拼到输出通道上。三种任务的输出通道数可以直接用公式推任务单尺度输出通道构成640 输入 COCO 类别数额外输出目标检测4 x reg_max nc 64 nc64 80 144无实例分割64 32 nc64 32 80 176proto: (32, 160, 160)关节点估计64 nc 3 x K64 1 51 116无K17单类这里的reg_max16所以框回归是 64 个通道这一点跟4 个坐标 4 个通道的直觉完全不一样后面会专门讲。分割多了 32 个 mask 系数通道另外还有一个独立分支输出原型掩码。关节点估计在单类别前提下COCO 只有 person 一类每个关键点要 3 个数x 偏移、y 偏移、可见性置信度17 个点就是 51。记住这张表之后去读导出后的 onnx 输出就非常清爽了。看到 116 通道就是 pose 模型176 通道加一个 4 维的 proto 输出就是 seg 模型144 通道就是标准检测模型。2. 检测分支Anchor-Free 之后框到底是算出来的还是猜出来的2.1 TaskAlignedAssigner正样本分配这套打分机制Anchor-Free 最容易让人误解的一点是没有锚框了那怎么知道哪个点该负责哪个目标。答案是有锚点但没有预设宽高。每个特征图上的格子中心点就是一个候选锚点训练时靠一套分配策略决定哪个锚点算正样本。yolov8 用的是 TaskAlignedAssigner核心是一个对齐度量align_metric cls_score ^ alpha * iou ^ beta其中alpha0.5beta6.0topk10。含义是每个真实框先找出中心落在框内的所有锚点作为候选然后对每个候选同时看两件事分类分够不够高、预测框和真值框的 IoU 够不够大。两边都好的锚点得分高最后取 topk 个作为正样本。beta 比 alpha 大一个数量级说明定位质量在这套评分里权重远高于分类置信度——这是刻意的因为训练早期分类分普遍偏低且噪声大如果分类权重过高正样本会选得很飘。这套机制的好处是不需要像 yolov3/yolov5 时代那样手工调 anchor 尺寸也不需要为了平衡正负样本去设 ignore 区域。坏处是它对分类分支的早期状态比较敏感。我踩过的一个坑是自己改分类头、加了一些额外输出之后分类分被稀释了结果 TAL 选出来的正样本质量变差loss 能降但 mAP 上不去。后来把分类分支的输出重新对齐回单层、保证它的数值范围和原来一致问题就消失了。2.2 DFL 把四个数变成 64 个数的原因分布式焦点损失Distribution Focal LossDFL是 yolov8 里最值得单独讲的设计。常规做法是让网络直接回归(l, t, r, b)四个距离值yolov8 不这么干它把每个方向的距离离散成 16 个区间网络输出的是 16 个概率最后用期望把概率还原成一个连续值# 简化版从 16 个 bin 的概率还原成距离 # pred_dist: (N, 4 * reg_max) reg_max 16 pred_dist pred_dist.view(-1, 4, 16) prob pred_dist.softmax(dim-1) # 每个方向的概率分布 bins torch.arange(16, dtypeprob.dtype, deviceprob.device) distance (prob * bins).sum(dim-1) # 期望值作为距离这么做的动机有两个。第一直接回归一个精确坐标本质上是在拟合一个冲击函数监督信号是单点的网络只能被迫输出一个点估计改成分布之后监督信号是一个范围模型在边界模糊的目标上能得到更平滑的梯度。第二DFL 的损失不是单纯的交叉熵而是把目标值落在哪两个 bin 之间、小数部分是多少按比例分配给相邻两个 bin 作为软标签这样即使标注有几个像素的抖动损失也不会突然跳变。代价是通道数从 4 变成了 64而且后处理必须做一次 softmax。如果你自己写 C 后处理忘了这一步输出的框会完全乱掉这是一个非常经典的部署 bug。2.3 损失权重与训练早期 cls_loss 不下降的排查yolov8 默认的三个损失权重是box7.5、cls0.5、dfl1.5。分类权重给得特别小跟直觉相反但配合 TAL 就说得通了TAL 已经用分类分参与样本分配了分类分支本身不需要再靠大权重去强行拉。box 权重最大是因为定位精度直接决定 mAP 的计算结果。训练时经常有人问跑了 50 个 epochcls_loss 一直在 1.5 附近波动不降。这通常不是模型坏了而是几个具体原因数据集里存在大量标注不一致的样本同一类目标在不同图里标法不同分类头学不到统一模式这时候可以先把batch调大、imgsz保持默认看曲线是否变平滑。类别极度不均衡比如水果检测里苹果 3000 张、火龙果 40 张稀有类的梯度被淹没。合并过细的类别或者给稀有类做增强比调 loss 权重有效。学习率过高导致分类分支一直在跳。可以先把lr0从 0.01 降到 0.005 试两轮观察曲线形状而不是看最终值。顺便说下看损失曲线的习惯dfl_loss一般会稳定下降到 0.85 到 1.0 之间如果它长期卡在 1.3 以上多半是回归目标有问题比如标注框宽高为 0、或者坐标归一化的时候用了错误的分母。这类脏数据用几十行脚本扫一遍就能找出来比盲目调参快得多。3. 实例分割分支原型掩码与系数矩阵的一次矩阵乘法3.1 proto 分支为什么只输出 32 个通道实例分割的路径和检测只差一个头但原理上多了一套共享原型 实例系数的分解思路。网络额外引出一个 proto 分支它取 Neck 里 P3 那层的输出640 输入下是 80x80经过几次卷积和两次转置卷积上采样最后输出(32, 160, 160)的原型图。160 这个尺寸是输入的四分之一不是原图分辨率。之所以不恢复到原图是因为掩码在 160x160 上已经足够表达轮廓再往上采样只是徒增计算量最终裁剪和缩放放在后处理里做就行。32 这个通道数的选择是一个折中通道太少不同实例之间的掩码表达会互相干扰通道太多矩阵乘法的开销和显存都会涨。32 是在 COCO 这类通用场景下被验证过的平衡点。判断 proto 分支在不在工作有个很直接的观察方式把训练好的 seg 模型对一张图推理看输出的掩码边缘是不是有明显锯齿。如果锯齿严重且形状基本对说明系数分支正常但原型分辨率不够如果掩码完全是一团糊那就是原型分支没被正确训练到通常是因为掩码损失权重或者标注格式出了问题。3.2 从 32x32 到原图系数点乘 裁剪 上采样的顺序问题推理阶段每个检测框会带 32 个 mask 系数。把它和原型的通道维度做一次矩阵乘法就得到该实例在 160x160 上的完整掩码import torch def process_mask(protos, coeffs, boxes, shape, upsampleTrue): # protos: (32, 160, 160) # coeffs: (N, 32) 每个实例一份系数 # boxes: (N, 4) 已经映射回原图坐标的框xyxy 格式 masks coeffs protos.view(32, -1) # (N, 160*160) masks masks.view(-1, 160, 160) masks masks.sigmoid() # 关键一步先把掩码裁到框内再上采样 for i, (x1, y1, x2, y2) in enumerate(boxes): m masks[i] # 把原图坐标换算成 160x160 上的坐标 sx, sy 160 / shape[1], 160 / shape[0] m[: int(y1 * sy), :] 0 m[int(y2 * sy):, :] 0 m[:, : int(x1 * sx)] 0 m[:, int(x2 * sx):] 0 if upsample: masks torch.nn.functional.interpolate( masks[None], sizeshape, modebilinear, align_cornersFalse )[0] return masks 0.5注意顺序先裁剪再上采样。如果反过来在上采样之后再裁边缘会多出一圈由插值带来的虚影掩码会胖一到两个像素。这个细节在低分辨率小目标上尤其明显我之前做零件缺陷分割时就吃过这个亏最后是靠调整顺序把边缘对回去的。还有一个很容易搞混的点掩码和框共用同一套锚点但它们的解码方式完全独立。框走的是 DFL 期望值掩码走的是系数点乘。如果你自己写后处理两边的索引对应关系一定要按输出张量里原本的位置去取不能假设它们排在一起。3.3 标注格式与掩码损失的实际配合分割任务的标注跟检测完全不是一回事。检测的 label 是每行class cx cy w h全部归一化到 0 到 1分割要求每行class x1 y1 x2 y2 ...是目标轮廓的多边形顶点顶点数可以不一样同样是归一化坐标。pose 的格式又是class cx cy w h px1 py1 v1 ...这个后面讲。训练时的掩码损失是逐像素的 BCE真值掩码会先被光栅化到 160x160 再和预测比。这里有个实际影响很大的细节光栅化精度会直接影响掩码质量的上限。如果你的多边形标注本身只有十几个点光栅化出来的掩码边界就是折线网络学到的也是折线最终推理出来的掩码边缘也会偏硬。想改善的话要么在标注时把关键转折点标足要么在数据加载阶段做一次多边形加密。另外小数据集上训练分割模型我个人的经验是把imgsz保持在 640 不要动靠增强来扩数据。把imgsz降到 320 会让原型图的 160 分辨率对应到原图的相对精度下降一半小目标的掩码会糊成一片得不偿失。4. 关节点估计分支Pose 任务多出来的 51 个通道4.1 关键点解码x、y、conf 三段输出的含义关节点估计在 yolov8 里的实现非常紧凑每个锚点输出 51 个数对应 17 个关键点每个点 3 个数。前两个是相对于锚点中心的偏移第三个是该点的可见性置信度。加上前面的4 x reg_max 64个框通道和nc个类别通道单类情况下总共 116 通道。关键点的解码方式和框的解码方式完全不一样这是最容易出错的地方# 简化版关键点解码 # y: 网络输出形状 (N, 17, 3) # anchor: 该锚点中心坐标格子坐标系 # stride: 该层对应的下采样倍数 kpt_xy (y[:, :, :2] * 2.0 (anchor - 0.5)) * stride kpt_conf y[:, :, 2:3].sigmoid() kpt torch.cat([kpt_xy, kpt_conf], dim-1)注意* 2.0和对锚点做- 0.5这两个操作。多数人预期关键点偏移的范围应该和框回归一样被限制在 0 到 1 之间然后乘以 stride但 yolov8 走的是另一条路先放大两倍再把锚点整体平移半格。这个设计让关键点的活动范围能覆盖到相邻格子的中心对分布在目标边缘的点更友好。如果你按框那套逻辑去解码关键点会出现明显的整体偏移尤其是手腕、脚踝这类离人体中心远的点误差能有十几个像素。可见性那一维走 sigmoid输出 0 到 1 的值它同时承担两个作用一是作为该点是否在画面内的置信度二是参与训练时的可见性损失。这里说的可见指的是有标注且未被遮挡跟在图像范围内不完全等价标注规范要在项目开始就定死不然后面数据一多就掰不回来了。4.2 关键点损失的加权方式与 OKS 的关系关键点损失不是把 17 个点一视同仁求平均。做法是先用每个点的标准差做一次归一化再算距离这样得到的结果在量纲上接近 OKS 里的定义weight_i 1 / (2 * sigma_i)其中sigma用的是 COCO 那套横向经验值鼻子、眼睛、耳朵这些点的标准差小约 0.25 到 0.35肩膀、肘、髋、膝、踝这些点的标准差大约 0.72 到 1.07。标准差小的点权重高因为人眼对这些点的定位一致性更好网络也应该在这些点上更精确而手腕、脚踝这类点本身就存在较大标注歧义权重压低一点避免噪声主导梯度。另外关键点损失在整体损失里的权重是12.0比 box 的 7.5 还高。这个数值看起来突兀但考虑到关键点距离是像素级的、除以了标准差它的数值量级本身就比 box 损失小得多不放大权重根本推不动。实际训练时如果你觉得姿态收敛太慢先别动这个权重先检查标注里有没有大量被截断的人体——截断目标的可见性全是 0对定位没有贡献只是白占计算。4.3 遮挡、截断与多人场景下的姿态处理关节点估计在实际落地时难点基本不在网络结构上而在场景。三个反复出现的问题遮挡。人被挡了一半可见点少网络容易把被遮住的点猜到错误的位置。yolov8 的做法是靠可见性分支把不可见点的 confidence 压低让你在后处理时过滤掉。判断阈值一般设 0.5太高的阈值会把侧身、坐姿里的人的点也滤掉。截断。人站在画面边缘下半身完全不在图里。这类样本在训练时要保留但要保证超出图像范围的点坐标仍然按规矩写不要截到边界上可见性标 0。如果标注时习惯把越界坐标裁到边界模型会误以为腿就在底边附近推理时整个人体骨架都是歪的。多人重叠。yolov8 的 pose 是检测 单目标姿态的组合框通过 NMS 去重每个框只输出一套骨架。所以它的上限取决于检测分支。人群极度密集时框本身就漏了骨架自然也不会有。真要做密集场景的姿态得考虑自底向上的方案但那就不是 yolov8 这套结构能解决的了。我做过一个简单对比同一套 17 点数据yolov8n-pose和yolov8s-pose在单人清晰场景下的关键点误差用 PCK0.2 衡量差别不大但在有遮挡的样本上s 版本明显更稳。如果是监控场景里常见的中小尺寸人体直接上 s 甚至 m 更省心n 版本省下的那点算力往往会在后处理阶段加倍还回来。5. 训练与部署中真正会卡住人的几个地方5.1 环境与版本CUDA、PyTorch、TensorRT 三者的搭配环境问题占据了我见到的求助里至少一半。yolov8 本身对 Pytorch 版本的容忍度还不错但一旦涉及导出和推理版本组合就变得很敏感。经验上比较省事的组合是CUDA 11.8 配 Pytorch 2.x导出 onnx 时用opset12或更高如果后续要转 TensorRTTensorRT 8.6 系列对这个 opset 的支持比较成熟。安装之后第一件事不是急着训练而是做一次自检yolo checks这条命令会打印出 Pytorch 版本、CUDA 是否可用、以及当前环境的各项依赖状态。很多人环境配了两小时最后发现是装了个 CPU-only 的 Pytorch 包torch.cuda.is_available()返回 False白折腾。自检能省下大量时间。导出的时候记得把动态 batch 和simplify都开上yolo export modelyolov8s-seg.pt formatonnx imgsz640 opset12 simplifyTrue dynamicTruesimplify会调用 onnx-simplifier 把一些冗余节点干掉动态轴则决定了你的模型能不能接受不同 batch。两者都不开的模型也能跑但部署到 C 侧之后你会发现后处理里多了一堆本可以避免的 reshape。5.2 小显存与边缘设备上的取舍显存不够的时候能动的旋钮其实就那么几个按性价比排序手段显存收益精度影响备注换更小的模型规格大中n 到 s 通常掉 2 到 4 个点 mAP开 AMP 混合精度中极小默认就开别关降 batch线性小会影响 BN 统计稳定性别低于 8降 imgsz大中大对小目标伤害最直接冻结主干若干层中小适合数据量少、想省时间的场景在 6GB 显存的卡上跑 seg 任务我的常规配置是yolov8n-seg或yolov8s-seg、imgsz640、batch8、ampTrue跑得动而且不需要频繁清缓存。如果非要上yolov8m-seg那就得把 imgsz 降到 512 并且 batch 压到 4这时候分割质量的下滑会比检测更明显因为原型图分辨率也跟着降了。边缘设备上又是另一套逻辑。这类平台通常对量化更敏感检测分支一般能接受 INT8但分割的原型分支和姿态的关键点坐标对精度很敏感量化完之后掩码会出现明显的块状伪影、关键点会整体抖动。比较稳妥的做法是主干和检测头做 INT8原型分支保留 FP16让两部分各自跑在合适的精度上代价是量化流程要拆开处理稍微麻烦一点。5.3 导出部署时候选分支的裁剪与输出顺序部署阶段最常被忽略的是输出分支的顺序。yolov8 导出之后检测模型只有一个输出分割模型有两个输出一个是预测张量一个是原型姿态模型又是一个输出。C 侧拿到输出之后如果不确认顺序很容易把原型当成预测去解析。用 Python 侧先确认一次输出结构是很省事的习惯import onnx model onnx.load(yolov8s-seg.onnx) for i, out in enumerate(model.graph.output): dims [d.dim_value for d in out.type.tensor_type.shape.dim] print(i, out.name, dims)典型输出会是(1, 116, 8400)加(1, 32, 160, 160)这种组合通道数按类别数变化。看清楚维度之后C 侧的解析逻辑才能对上。还有两个后处理里必须自己补上的东西。第一是 DFL 的 softmax前面提过不做这一步框会全乱。第二是 NMS 要在统一的尺度上做因为三个尺度的框被拍平在一起了直接对拍平后的结果做 NMS坐标已经是解码后的原图坐标不存在尺度不一致的问题但要注意分类维度上的取值——yolov8 没有独立的目标性分支分类分数本身就兼任了置信度所以 NMS 的阈值需要比 yolov5 时代稍微调高一点否则同一目标的重复框会明显增多。我一般的起始点是conf0.25、iou0.45然后按场景微调。对于分割输出如果最终只需要掩码不需要框可以把原型分支的矩阵乘法放到 GPU 侧用一次批量矩阵乘法完成比逐个实例循环快很多尤其是单图里目标数量超过 20 的时候差异很明显。我自己在项目里最常用的一条判断准则是先把三条支线的通道数和输出结构记清楚再去读源码会快得多。检测看64nc分割看多出来的 32 和那个(32, 160, 160)姿态记住关键点解码里那个*2.0和-0.5基本上八成的输出对不上问题都能自己定位。至于模型大小和任务类型的选择我的习惯是先跑通最小规格再往上加——yolov8n能过就先用它打通全链路等流程顺了再换大的反过来做的话很容易在环境或者后处理上卡住还分不清到底是模型问题还是代码问题。