尧图精选

YOLOv8 Detect层输出维度修改全指南:从源码原理到部署实战

🕒 发布时间:2026/9/15 15:41:29 📁 来源:尧图网络
我接触YOLOv8也快两年了中间改过不少次它的Detect层输出维度。这东西说简单确实简单改一个数字就完事但说复杂也真复杂改完之后loss不收敛、导出报错、部署维度对不上每一个坑我都踩过。所以这次干脆把Detect层的输出维度改动从头到尾捋一遍从源码级原理到实操改法再到部署端的注意事项一次说透。先说结论Detect层是整个YOLOv8模型最后出口所有backbone和neck的特征都要汇到这一层最终转成预测结果。你改动输出维度本质是在改这个“出口的形状”。如果只是做分类数变化那确实改一行配置就行但如果你想改输出结构本身比如加关键点分支或者旋转框参数那就得动手改head.py的Detect类了。接下来我按自己的理解从Detect层的核心机制讲起再一步步拆开输出维度的计算逻辑最后给几个实战改动案例和问题排查。1. 先把Detect层的结构彻底拆开1.1 Detect层在YOLOv8里到底干了什么YOLOv8的Detect层放在整个模型的最后接收来自neck的P3、P4、P5三层特征图。这三层特征图分别对应输入图像的三个不同尺度比如输入640x640时P3是80x80P4是40x40P5是20x20。Detect层就是对这些特征图做最终预测输出目标的类别、位置和置信度。要理解输出维度的来源得看Detect类的核心代码结构。Ultralytics官方源码在ultralytics/nn/modules/head.py里面关键部分是forward和_inference这两个函数class Detect(nn.Module): def __init__(self, nc80, ch()): super().__init__() self.nc nc self.nl len(ch) self.reg_max 16 self.no nc self.reg_max * 4 self.stride torch.zeros(self.nl) c2, c3 max((16, ch[0] // 4, self.reg_max * 4)), max(ch[0], min(self.nc, 100)) self.cv2 nn.ModuleList( nn.Sequential(Conv(x, c2, 3), Conv(c2, c2, 3), nn.Conv2d(c2, 4 * self.reg_max, 1)) for x in ch ) self.cv3 nn.ModuleList( nn.Sequential(Conv(x, c3, 3), Conv(c3, c3, 3), nn.Conv2d(c3, self.nc, 1)) for x in ch ) self.dfl DFL(self.reg_max) if self.reg_max 1 else nn.Identity()这段代码把输出分成了两个分支cv2分支负责预测边界框的回归参数输出通道是4 * reg_max也就是4个距离值每个距离值用reg_max个bin表示。默认reg_max16所以这一路通道数是64。cv3分支负责预测类别分数输出通道数是nc也就是类别数。默认COCO是80。所以Detect层最终每个位置上的输出通道是nc 4 * reg_max也就是80 64 144。这就是大家经常看到的YOLOv8输出张量里的144这个数字的来历。1.2 训练状态和推理状态完全不是一回事很多新手在改Detect层的时候最容易困惑的就是为什么明明改了输出维度但打印模型结构的时候看到的输出shape和自己想的不一样这是因为Detect层在训练状态和推理状态走的是完全不同的逻辑。看forward函数的实现def forward(self, x): shape x[0].shape for i in range(self.nl): x[i] torch.cat((self.cv2[i](x[i]), self.cv3[i](x[i])), 1) if self.training: return x elif self.dynamic or self.shape ! shape: self.anchors, self.strides (x.transpose(0, 1) for x in make_anchors(x, self.stride, 0.5)) self.shape shape x_cat torch.cat([xi.view(shape[0], self.no, -1) for xi in x], 2) box, cls x_cat.split((self.reg_max * 4, self.nc), 1) dbox self.decode_bboxes(self.dfl(box), self.anchors.unsqueeze(0)) * self.strides y torch.cat((dbox, cls.sigmoid()), 1) return y关键区别就在这里训练时返回的是一个list里面每个元素是某一层特征图的原始输出shape是(batch, 64 nc, h, w)这个结果直接喂给loss函数做计算。推理时才会把三个尺度的结果全部拼接到一起变成(batch, 4 nc, total_anchors)这样的格式然后做DFL解码和距离转坐标。这个区别直接影响了你在改输出维度时的判断。比如你改了nc之后发现训练能跑但导出onnx后output shape不对多半就是因为没理解这条路径差异。1.3 DFL结构才是输出维度的隐藏主角说到detect层的输出维度绝大多数人第一反应是加类别数或者减类别数但很少人注意到reg_max这个参数。在我们做自定义改动的时候这个参数往往才是真正需要动的那个。reg_max是Distribution Focal Loss里的回归区间数YOLOv8默认取16意味着边界框的每条边用16个概率值来表示。DFL做的就是把这16个值做一个softmax加权求和得到一个精确的偏移量class DFL(nn.Module): def __init__(self, c116): super().__init__() self.conv nn.Conv2d(c1, 1, 1, biasFalse).requires_grad_(False) x torch.arange(c1, dtypetorch.float) self.conv.weight.data[:] nn.Parameter(x.view(1, c1, 1, 1)) self.c1 c1 def forward(self, x): b, c, a x.shape return self.conv(x.view(b, 4, self.c1, a).transpose(2, 1).softmax(1)).view(b, 4, a)这个DFL本身不需要训练参数它的conv权重是固定好的0到15的序列值。所以如果你想把reg_max改成别的值比如想压缩模型尺寸改成8输出维度就会变成nc 4 * 8 n c 32这个改动会直接影响检测精度和模型大小而且改动逻辑完全和类别数无关。我自己的经验是改reg_max最难受的地方不是Detect层本身而是后续的loss计算和anchor生成。YOLOv8的loss里用到了Dist2BboxLoss它把DFL输出的4个距离通过dist2bbox函数转成真实的xyxy坐标时会依赖这个reg_max值。所以只改Detect层的输出维度、不考虑配套的decode逻辑训练时经常会遇到loss为nan或者收敛极慢的情况。2. 输出维度的完整计算链路2.1 从输入到输出的Shape变化全过程要彻底掌握输出维度改动得先把整条计算链路捋清楚。以输入一张640x640x3的图片训练COCO检测模型为例输出维度的演变过程是这样的输入图片: (1, 3, 640, 640) 经过backbone和neck后得到三路特征: - P3: (1, 256, 80, 80) - P4: (1, 512, 40, 40) - P5: (1, 1024, 20, 20) 进入Detect层: - cv2分支输出回归参数: (1, 64, hi, wi) - cv3分支输出类别分数: (1, 80, hi, wi) - concat后: (1, 144, hi, wi) 推理时拼接三个尺度的anchor: - 80x80: 6400个anchor - 40x40: 1600个anchor - 20x20: 400个anchor - 总数: 8400个anchor 最终输出shape: (1, 84, 8400)注意这个最终输出两个维度的含义84 4 80分别是4个坐标值(xyxy)和80个类别分数8400是所有anchor数量总和。这就是很多人在用ONNX导出或者部署到RK3588这类设备上时看到的(1, 84, 8400)的来历。这里我特意强调一下做部署端适配的时候84这个数字和8400这个数字是不能写死的。8400和输入分辨率挂钩改了输入大小它会变84和nc挂钩改了类别数它会变。所以当你在RK3588上做推理程序时应该从模型的输入输出结构动态解析这两个维度而不是写死。2.2 n c、reg_max和输出维度三者的数学关系输出维度 4 nc这里的4是解码后的坐标维度因为在推理时DFL解码和距离转坐标已经提前完成了。但这个4和reg_max之间是有转换关系的训练时的原始输出维度 nc 4 * reg_max推理时的最终输出维度 4 nc这里的转换关系很重要我见过很多人在看模型结构时发现Detect层输出的第一个维度是144(默认情况训练时)或者84(推理时)然后就开始混乱了。其实两者的关系就是推理时把4 * reg_max这一块通过DFL和距离解码变成了4个坐标值。再展开说YOLOv8的边界框回归方式从YOLOv5的anchor-based变成了anchor-free DFL。YOLOv5的输出格式是(batch, 5 nc, num_anchors)其中5是x、y、w、h、conf。YOLOv8去掉了objectness分支变成了(batch, 4 nc, num_anchors)因为DFL机制认为当前的分类分支已经隐含了目标存在的置信度信息没必要再单独搞一个置信度打分。这个改动在输出维度上的体现就是YOLOv8比YOLOv5少了一个维度。我们在改动输出维度的时候要时刻记得这个设计理念不要尝试把objectness加回去那样会让整个head结构偏离YOLOv8的设计初衷。2.3 Detect层两个关键函数make_anchors和dist2bboxDetect层改动之后要想正常工作还需要理解两个绑定的函数make_anchors和dist2bbox。它们通常不是写在Detect类里面的而是在训练loss或推理脚本里调用但它们对输出维度的影响是决定性的。make_anchors负责根据特征图的尺寸生成anchor点每个特征图位置生成一个anchor。它返回的是归一化到特征图坐标系的中心点坐标。比如80x80的特征图就生成80x806400个anchor点每个点有x和y两个坐标所以shape是(6400, 2)。dist2bbox则负责把DFL解码得到的4个距离值加上anchor的中心点坐标转换回预测框的xyxy格式。这个函数处理的是距离和坐标之间的数学关系def dist2bbox(distance, anchor_points, xywhTrue, dim-1): lt, rb distance.chunk(2, dim) x1y1 anchor_points - lt x2y2 anchor_points rb if xywh: c_xy (x1y1 x2y2) / 2 wh x2y2 - x1y1 return torch.cat((c_xy, wh), dim) return torch.cat((x1y1, x2y2), dim)这段代码的逻辑值得花几分钟看看anchors表示当前特征图位置的中心坐标lt表示左上角距离中心点的偏移距离rb表示右下角距离中心点的偏移距离。所以anchor - lt得到左上角坐标anchor rb得到右下角坐标两者合并就是xyxy格式的框。理解了这两个函数改动输出维度时的思路才能清晰。比如你要把输出坐标维度改成xywh格式就不能只改head输出的张量形状还要改dist2bbox的输出策略以及后处理时读取坐标的方式。3. 实操如何正确修改Detect层输出维度3.1 改类别数最基础但不简单的改动最经典的需求就是把官方预训练的80类模型改成自己的n类模型。这个过程看起来就是在yaml配置文件里把nc改成你的类别数但在实际改动中你还需要同步处理好训练数据、anchor策略、以及最后的识别效果验证。以我实际把YOLOv8改成5类检测的经验为例ultralytics/cfg/models/v8/yolov8.yaml里面核心内容是这样的nc: 80 scales: n: [0.33, 0.25, 2.0] s: [0.33, 0.50, 2.0] m: [0.67, 0.75, 1.5] l: [1.00, 1.00, 1.0] x: [1.00, 1.25, 1.0] backbone: - [-1, 1, Conv, [64, 3, 2]] ... head: - [-1, 1, nn.Upsample, [None, 2, nearest]] ... - [-1, 1, Detect, [nc]]如果只是改类别数把nc改成5就够了。但我的建议是不要只改yaml最好在训练前打印一下模型的输出shape确认改动生效了from ultralytics import YOLO model YOLO(yolov8n.yaml) model.train(datamy_dataset.yaml, epochs100, imgsz640)如果训练过程中报出和维度相关的错误通常都不是nc本身没改而是数据集的labels文件里class id超出了新的nc范围。比如你把nc改成了5但有一个标注文件的类别id写的是6训练就会直接崩。另外改nc之后建议用预训练权重做迁移学习。官方给的yolov8n.pt是基于COCO 80类训练的它的检测头nc80。如果你直接用新nc5的模型结构去加载预训练权重Ultralytics会自动跳过shape不匹配的Detect层参数剩下的backbone和neck参数正常加载。实际操作下来我发现这样训练比随机初始化收敛快非常多因为backbone提取通用特征的能力已经被充分训练过了。我第一次改类别数的时候没经验直接从零训练跑了100个epoch效果还是稀烂后来改成加载预训练权重做迁移50个epoch就达到了之前的水平。3.2 修改输出坐标维度从xyxy到xywh的实战改造再举一个实际项目里经常遇到的需求有些部署框架或者上位机程序要求模型直接输出xywh格式的框信息而YOLOv8默认输出xyxy格式。这时候就需要改Detect层的_inference逻辑。dist2bbox函数里有个xywh参数默认是True。但在Detect类的_inference里调用方式是dist2bbox(dfl(box), self.anchors.unsqueeze(0), xywhFalse)也就是说YOLOv8推理时默认输出xyxy格式。如果你想输出xywh格式改动其实很简单把xywhFalse改成xywhTruey torch.cat((dbox, cls.sigmoid()), 1) # dbox现在是(cx, cy, w, h)而不是(x1, y1, x2, y2)但这里有个隐蔽的坑改了推理输出格式之后decode_bboxes的内部逻辑也要跟着调整因为decode_bboxes调用dist2bbox时传的参数要和最终输出格式保持一致。如果你只改外层、不改内层两个函数计算的框会不一致但程序不会报错这个问题排查起来极其恶心。我实际改造时是把Detect类的逻辑改成了这样class Detect(nn.Module): def __init__(self, nc80, ch(), xywhFalse): super().__init__() self.xywh xywh ... def forward(self, x): ... if self.training: return x # 推理时通过 self.xywh 控制输出格式 dbox self.decode_bboxes(self.dfl(box), self.anchors.unsqueeze(0)) * self.strides y torch.cat((dbox, cls.sigmoid()), 1) return y def decode_bboxes(self, bboxes, anchors): return dist2bbox(bboxes, anchors, xywhself.xywh)这样改完之后你实例化模型时通过xywhTrue就能控制输出为xywh格式不用动内部逻辑。很多嵌入式部署或者上位机开发场景拿到的是xywh格式的框可以直接用OpenCV的rectangle画框不用再写坐标转换省了不少事。3.3 新增分支修改Detect层以输出关键点检测结果这是比改坐标格式复杂很多的改动。以YOLOv8-pose为例它在Detect层的基础上增加了一个关键点分支所以最终输出维度变成了4 nc 2 * kpt kpt_conf。我当时第一次看yolov8-pose的head结构时一度以为是自己把输出维度理解错了后来仔细翻了源码才明白它其实是在Detect基础上扩展了cv4分支。YOLOv8-pose的Detect是独立的一个Pose类核心结构如下class Pose(nn.Module): def __init__(self, nc80, kpt_shape(17, 3), ch()): super().__init__() self.kpt_shape kpt_shape self.nk kpt_shape[0] # 关键点数量 self.kpt_dim kpt_shape[1] # 每个关键点维度一般是3代表x、y、可见性 c4 max(ch[0] // 4, self.nk * self.kpt_dim) self.cv4 nn.ModuleList( nn.Sequential(Conv(x, c4, 3), Conv(c4, c4, 3), nn.Conv2d(c4, self.nk * self.kpt_dim, 1)) for x in ch )关键点分支通过一个单独的cv4序列输出通道数是nk * kpt_dim默认17类的COCO关键点就是51个通道17点 * 3维其中3是x坐标、y坐标、可见性。推理时Pose类concat了三个分支x[i] torch.cat((self.cv2[i](x[i]), self.cv3[i](x[i]), self.cv4[i](x[i])), 1)所以最终单个尺度的输出通道是4 * reg_max nc nk * kpt_dim。如果你想在自己的自定义任务里加一个别的分支完全可以参考Pose类的写法在Detect基础上加一个cvN系列。比如想加segmentation分支就加一个输出通道为num_masks的卷积序列。加完别忘了把训练时的loss函数同步修改不然新增分支没有对应的loss监督信号训练时根本学不出有用的东西。3.4 修改reg_max压缩输出维度的操作最后说一个相对少见的改动压缩reg_max把每个框的回归输出维度从4*1664降到更小这样模型头部参数变少推理速度能提升一些。我在GTX 1660 Ti上做过对比实验把reg_max从16改成8类目不变的条件下Detect层输出通道从144降到(8*480)112模型体积有小幅缩小推理速度在batch1、640分辨率下大概提升了8%-12%但mAP50下降了大概1.5个百分点。这个trade-off对小模型应用场景还是值得考虑的。具体改法一行代码的事直接把self.reg_max 16改成self.reg_max 8。但真正麻烦的是配套逻辑你要把DFL初始化、输出维度计算的地方全部同步改# 修改后的Detect __init__ self.reg_max 8 self.no nc self.reg_max * 4 # 这里自动变成 nc32然后DFL、make_anchors、loss.py里的集成回归相关逻辑全部都会通过self.reg_max自适应调整因为源码本身是用reg_max变量来推导维度的所以你只要保证reg_max这个值在前后端保持同步就行。我用下来感觉最需要注意的是改完reg_max后如果加载预训练权重Detect层的所有参数都会因为shape不匹配被跳过整个head要从头训练。要是训练数据不够多精度可能还不如原版大reg_max效果好所以这个操作需要结合实际数据集量级来评估。4. 改动输出维度时训练和部署的连锁反应4.1 loss.py里藏着输出维度的隐性依赖改了Detect层输出维度训练阶段报错最多的地方往往不是head本身而是loss函数。YOLOv8的loss在ultralytics/utils/loss.py里它的BboxLoss类和v8DetectionLoss类都直接依赖Detect层输出的通道数。比如v8DetectionLoss的初始化class v8DetectionLoss: def __init__(self, model): device next(model.parameters()).device h model.args m model.model[-1] # Detect() module self.bce nn.BCEWithLogitsLoss(reductionnone) self.hyp h self.stride m.stride self.nc m.nc self.no m.nc m.reg_max * 4 self.reg_max m.reg_max self.device device ...注意这一行self.no m.nc m.reg_max * 4它是直接读取Detect层里的属性来初始化loss的。这也就是为什么只要你正确修改了Detect类里的nc或者reg_maxloss这边基本会自动适配。但如果你不是通过源码配置改的而是直接在Detect类外面手动改了什么输出维度又没同步改这里的no属性那训练就一定会报shape mismatch。我自己遇到过这样的情况为了做一个多任务模型我在Detect层concat的时候额外拼了一个语义分割分支的输出训练时loss计算完全正常但eval的时候模型输出维度比之前宽了程序直接崩了。排查了半天发现是v8DetectionLoss里对输出切分的代码pred_distri, pred_scores torch.cat([xi.view(shape[0], self.no, -1) for xi in x], 2).split( (self.reg_max * 4, self.nc), 1 )这里用的是split((self.reg_max * 4, self.nc), 1)它假设Detect原始输出只包含回归分类两部分。如果你在Detect里加了第三个分支数据格式跟loss里预期的对不上自然会出问题。所以对于复杂改动loss部分也得跟着加逻辑不能指望它自动适配。4.2 导出ONNX和部署端的维度适配经验训练完模型之后很多人会在导出onnx这个环节卡住。YOLOv8官方导出命令非常简洁一行搞定yolo export modelyolov8n.pt formatonnx opset11这里有个和输出维度强相关的细节导出onnx时模型会走推理态不再经过loss逻辑这时的输出shape就是(batch, 4 nc, 8400)。如果你在自定义数据集上训练nc发生了改变导出的onnx第二个维度就会同步变成4 你的类别数。我遇到过最典型的情况是训练阶段自己改了Detect层训练损失曲线看上去正常保存的pt文件也能正常预测但导出成onnx之后维度就错了。原因主要是自定义改动没有走官方支持的修改路径导致pt里保存的Detect层状态和onnx导出时代码走的分支不一致。如果按官方的方式在yaml里调nc是不会有这个问题的。但如果自己做了一些自定义分支改动导出前必须先跑一遍推理模式检查输出shape是否符合预期import torch from ultralytics import YOLO model YOLO(runs/train/exp/weights/best.pt) model.eval() dummy torch.zeros(1, 3, 640, 640) with torch.no_grad(): output model.model(dummy) print(output.shape) # 应该是 (1, 4 nc, 8400)如果输出维度和期待值不一致千万别急着导出先在pt层面排查清楚。部署端很多问题都是因为这个环节没检查到位导致RK3588或者Jetson上跑的时候shape硬编码不对推理直接报错或者返回空结果。4.3 用Netron彻底看懂输出维度的变化谁也不能保证自己记性一直在线特别是模型结构改动多了之后输出维度到底是多少靠人脑记忆根本不靠谱。我的习惯是每次改动完输出维度之后都会导出onnx然后拖进Netron看一眼模型结构。Netron打开onnx文件后找到最后的输出节点直接就能看到输出的维度标注。比如YOLOv8n原版显示的是[batch, 84, 8400]如果你把nc改成了5最后一个输出节点就会显示[batch, 9, 8400]。这里9 4 5一眼就能确认改动是否正确。在Netron里还有个好用的功能点开Detect节点前面的卷积层能具体看到每个分支的输出通道数。如果改了reg_max你能从卷积层的权重维度上看到变化这个对排查输出维度异常非常有帮助。我习惯在导出onnx之后先拖进Netron确认一下再进行下一步的部署工作这已经形成了一个固定习惯。很多人嫌麻烦跳过这一步结果最后在部署端反复调shape浪费的时间比看一眼Netron多得多。5. 常见问题排查与避坑记录5.1 训练时报shape mismatch的常规检查流程改动输出维度后训练阶段最崩溃的就是task_aligned_assigner或者loss计算时报shape mismatch。这类报错通常长这样RuntimeError: The size of tensor a (5) must match the size of tensor b (80) at non-singleton dimension 0出现这种错的时候先别急着怀疑代码写错了按下面这个顺序排查第一确认yaml配置文件里的nc写的是不是你目标类别数。第二打印模型head部分的输出shape确认Detect层实际输出的通道数是多少。第三检查数据集label文件里的类别id值有没有超过nc-1的范围。第四如果用了预训练权重确认加载时是否因为shape不匹配自动跳过了Detect层参数。第五检查hpy参数里的loss权重配置看有没有硬编码某个类别数量。我遇到的最常见情况是第二个Detect层实际输出已经是(batch, 9, 8400)了但训练代码里还按某个历史缓存的shape去切分数据新旧两套维度混在一起。这种问题难在它不是必现的有时候跑几个step才崩特别磨人。5.2 后处理换维度踩过的坑Detect层输出维度的变化不只是在模型内部有影响后处理逻辑里的非极大值抑制NMS处理也必须同步调整。YOLOv8源码自带的NMS在ultralytics/nn/autobackend.py和ultralytics/utils/ops.py里都有涉及它假设输入格式是(batch, 4 nc, num_anchors)。比如你在Detect层输出里加了一个extra分支导致输出变成(batch, 4 nc extra, num_anchors)后处理的NMS函数就会把extra这部分当成类别分数来处理结果越弄越乱。所以改动Detect层输出维度之后一定要同步排查下游的对输出维度有硬编码假设的地方。我整理了一个自查清单每次改动后滤一遍改动点需要同步调整的地方改nc数据集label、后处理类别过滤逻辑、可视化标签映射改reg_maxDetect类、loss里no计算、DFL初始化、导出后处理距离解码改输出坐标格式dist2bbox参数、NMS输入坐标顺序、可视化函数、部署端IOU计算新增分支Detect类的cvN分支、loss新增对应损失项、后处理拆分逻辑、部署端解析逻辑压缩输出通道模型yaml缩放因子、输出通道计算、部署端内存估计5.3 经验心得小目标检测场景下输出维度的取舍最后分享一个实际项目中的经验。之前做过一个安全帽佩戴检测项目摄像头的俯视角能看到大量小目标每个人头加上安全帽特征在640x640的输入下往往只占十几个像素。一开始用默认的YOLOv8lreg_max16检测率不够理想。后来尝试压缩reg_max到8来加速推理结果发现小目标检测能力明显下降漏检率高出不少。复盘之后想明白了原因reg_max16意味着DFL用16个离散值来表示边界框的每条边分布更细腻压缩到8之后偏移量的表达精度下降对于小目标来说几个像素的偏移误差就足以让检测框偏移目标。所以如果做小目标检测没必要为了省一点算力去动reg_max得不偿失。但如果做的是大目标检测比如传送带上的包裹分拣目标占据画面大部分区域回归精度要求没那么高那压缩reg_max就是个不错的提速手段。这也提醒我们Detect层输出维度的改动从来不只是数字大小的问题它本质上是在精度、速度、复杂度之间做权衡。动手之前想清楚自己真正要什么才能选对改法。实际训练中还有一个容易被忽略的细节修改输出维度之后训练初期的anchor匹配策略也会受影响学习率、epoch的设定最好重新评估。我一般会把初始学习率降低一点让head部分自适应新维度的过程平稳一些等loss过了前几十个step的波动期之后再加回去。这虽说是训练策略上的微调但对于刚改动完输出维度、模型还没完全稳定下来的阶段帮助还是很大的。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →