
很多人用了很久YOLO调参、训模型、做部署都很熟练但真问起核心原理往往说不清楚网格划分到底怎么工作正负样本怎么分配损失函数每一项怎么算输出张量的每个维度对应什么调包用久了很容易陷入「会用但不懂」的瓶颈遇到定制化需求、精度问题的时候不知道从哪里下手改。最好的理解方式永远是亲手写一遍。本文就带着大家从零开始用PyTorch手写一个极简版YOLO去掉所有工程化技巧和花哨结构只保留最核心的检测逻辑。不用几百行代码就能跑通训练和推理彻底搞懂一阶段目标检测的本质。一、YOLO的核心思想把检测变成回归问题在YOLO出现之前目标检测大多是两阶段方案先生成一堆候选框再对每个框做分类。思路直观但速度很慢很难实时。YOLO的革命性在于它把目标检测变成了一个端到端的回归问题将输入图像划分为S×S个网格每个网格负责预测中心点落在其中的目标每个网格预测B个锚框每个锚框包含坐标、置信度、类别概率三类信息整张图一次性输入网络直接输出所有网格的预测结果一步到位得到检测框没有候选区生成没有多阶段分类单个网络直接输出全部结果这就是YOLO速度快的根本原因。输入图像 416×416×3骨干网络逐层下采样 提取特征多尺度特征融合13×13 检测头大目标26×26 检测头中目标52×52 检测头小目标输出张量每个锚点: xywh conf cls二、整体架构拆解我们实现的简化版YOLO遵循「骨干提取特征 Neck融合多尺度 检测头输出」的经典三段式结构和原版YOLOv3的核心逻辑完全一致只是做了轻量化精简方便理解。各层作用说明骨干网络基于简化Darknet通过五次步长为2的卷积下采样逐步扩大感受野输出三个不同尺度的特征图分别对应52×52、26×26、13×13的网格。Neck特征融合采用FPN自上而下的上采样融合结构把深层的语义信息和浅层的细节信息结合提升不同尺度目标的检测效果。检测头每个尺度对应一个检测头用1×1卷积输出最终预测。每个网格预设3个锚框每个锚框输出4个坐标值 1个置信度 类别概率。三、逐模块代码实现所有代码基于PyTorch实现尽量保持简洁去掉冗余封装重点突出逻辑本身。3.1 基础卷积组件整个网络最基础的单元卷积 批归一化 SiLU激活所有卷积层都复用这个结构。importtorchimporttorch.nnasnnimporttorch.nn.functionalasFclassConvBnSiLU(nn.Module):基础卷积块Conv BN SiLUdef__init__(self,in_channels,out_channels,kernel_size3,stride1):super().__init__()paddingkernel_size//2self.convnn.Conv2d(in_channels,out_channels,kernel_size,stride,padding,biasFalse)self.bnnn.BatchNorm2d(out_channels)self.actnn.SiLU()defforward(self,x):returnself.act(self.bn(self.conv(x)))3.2 残差块骨干网络的核心单元通过残差连接解决深层网络梯度消失的问题让网络可以堆得更深。两个卷积第一个1×1降通道第二个3×3提取特征最后和输入相加。classResBlock(nn.Module):残差块通道数不变def__init__(self,channels):super().__init__()self.conv1ConvBnSiLU(channels,channels//2,kernel_size1)self.conv2ConvBnSiLU(channels//2,channels,kernel_size3)defforward(self,x):returnxself.conv2(self.conv1(x))3.3 简化版Darknet骨干堆叠卷积和残差块完成五次下采样输出三个尺度的特征图。通道数逐步翻倍每个下采样阶段后接残差块提取特征。classDarknet(nn.Module):简化版Darknet骨干输出三个尺度特征def__init__(self):super().__init__()# 初始卷积self.stemConvBnSiLU(3,32,kernel_size3,stride1)# 五次下采样 残差块self.stage1nn.Sequential(ConvBnSiLU(32,64,stride2),ResBlock(64))# 1/2self.stage2nn.Sequential(ConvBnSiLU(64,128,stride2),ResBlock(128))# 1/4self.stage3nn.Sequential(ConvBnSiLU(128,256,stride2),ResBlock(256))# 1/8 - 52x52self.stage4nn.Sequential(ConvBnSiLU(256,512,stride2),ResBlock(512))# 1/16 - 26x26self.stage5nn.Sequential(ConvBnSiLU(512,1024,stride2),ResBlock(1024))# 1/32 - 13x13defforward(self,x):xself.stem(x)xself.stage1(x)xself.stage2(x)p3self.stage3(x)# 52x52p4self.stage4(p3)# 26x26p5self.stage5(p4)# 13x13returnp3,p4,p5输出的三个特征图下采样倍率分别是8、16、32对应检测小、中、大三类目标。3.4 FPN特征融合Neck自上而下的上采样融合把深层的大尺度特征上采样后和浅层特征拼接让深层语义和浅层细节结合。classFPN(nn.Module):简化FPN特征融合def__init__(self):super().__init__()# 降通道卷积减少拼接后的通道数self.conv5ConvBnSiLU(1024,512,kernel_size1)self.conv4ConvBnSiLU(512,256,kernel_size1)self.conv3ConvBnSiLU(256,128,kernel_size1)# 融合后的特征提取self.fuse4ConvBnSiLU(1024,512,kernel_size3)self.fuse3ConvBnSiLU(512,256,kernel_size3)defforward(self,p3,p4,p5):# 顶层处理p5self.conv5(p5)# 融合P4p5_upF.interpolate(p5,scale_factor2,modenearest)p4torch.cat([p4,p5_up],dim1)p4self.fuse4(p4)p4self.conv4(p4)# 融合P3p4_upF.interpolate(p4,scale_factor2,modenearest)p3torch.cat([p3,p4_up],dim1)p3self.fuse3(p3)p3self.conv3(p3)returnp3,p4,p53.5 检测头每个尺度对应一个检测头用1×1卷积输出最终预测。输出通道数 锚框数 × (4坐标 1置信度 类别数)。我们每个网格预设3个锚框和原版YOLO保持一致。classDetectHead(nn.Module):检测头输出预测结果def__init__(self,in_channels,num_anchors3,num_classes20):super().__init__()out_channelsnum_anchors*(5num_classes)self.convnn.Conv2d(in_channels,out_channels,kernel_size1)defforward(self,x):returnself.conv(x)3.6 组装完整YOLO网络把骨干、Neck、检测头拼起来就是完整的简化版YOLO。classSimpleYOLO(nn.Module):def__init__(self,num_classes20,num_anchors3):super().__init__()self.backboneDarknet()self.neckFPN()# 三个尺度的检测头self.head3DetectHead(128,num_anchors,num_classes)# 52x52self.head4DetectHead(256,num_anchors,num_classes)# 26x26self.head5DetectHead(512,num_anchors,num_classes)# 13x13defforward(self,x):p3,p4,p5self.backbone(x)p3,p4,p5self.neck(p3,p4,p5)out3self.head3(p3)out4self.head4(p4)out5self.head5(p5)return[out3,out4,out5]到这里网络结构部分就全部写完了。输入一张416×416的图片输出三个尺度的预测张量形状分别是[batch, 3×(520), 52, 52][batch, 3×(520), 26, 26][batch, 3×(520), 13, 13]四、最核心的逻辑标签分配与损失函数结构只是骨架标签分配和损失函数才是YOLO的灵魂。很多人学了很久还是一知半解本质就是没搞懂「哪些预测框算正样本哪些算负样本」。4.1 先搞懂输出张量的含义以13×13的特征图为例输出shape是 [batch, 75, 13, 13]。13×13对应原图划分的13×13个网格75 3个锚框 × (4坐标 1置信度 20类别)每个网格的3个锚框分别对应不同的预设宽高负责检测不同比例的目标4.2 正负样本分配规则核心原则谁的中心点落在网格里谁就负责预测哪个锚框和真实框最匹配哪个锚框就是正样本。具体步骤遍历每个真实框计算它的中心点坐标中心点落在哪个网格该网格就负责预测这个目标该网格的3个锚框分别和真实框计算IOUIOU最大的那个锚框作为正样本IOU小于0.3的锚框作为负样本只计算置信度损失IOU在0.3~0.7之间的忽略不计算损失这样每个真实框对应唯一的一个正样本其余大部分都是负样本天然存在正负样本失衡后面损失函数里会处理。4.3 损失函数三部分总损失由三部分组成只对正样本计算坐标和分类损失负样本只算置信度损失。defyolo_loss(preds,targets,anchors,num_classes20): preds: 三个尺度的预测输出 targets: 真实框格式 [batch_idx, class_id, x, y, w, h]归一化坐标 anchors: 预设锚框三个尺度各3个 total_loss0.0lambda_coord5.0# 坐标损失权重lambda_noobj0.5# 负样本置信度权重forstride_idx,predinenumerate(preds):batch_size,_,grid_h,grid_wpred.shape stride416/grid_h anchoranchors[stride_idx]/stride# 锚框转换到特征图尺度# 重塑输出[batch, 3, grid_h, grid_w, 5num_classes]predpred.view(batch_size,3,5num_classes,grid_h,grid_w)predpred.permute(0,1,3,4,2).contiguous()# 分别取出坐标、置信度、类别pred_xytorch.sigmoid(pred[...,0:2])pred_whtorch.exp(pred[...,2:4])*anchor pred_conftorch.sigmoid(pred[...,4:5])pred_clstorch.sigmoid(pred[...,5:])# 初始化目标张量target_xytorch.zeros_like(pred_xy)target_whtorch.zeros_like(pred_wh)target_conftorch.zeros_like(pred_conf)target_clstorch.zeros_like(pred_cls)obj_masktorch.zeros_like(pred_conf,dtypetorch.bool)# 构建标签forbinrange(batch_size):batch_targetstargets[targets[:,0]b]fortinbatch_targets:cls_idint(t[1])x,y,w,ht[2:6]# 转换到特征图尺度gxx*grid_w gyy*grid_h gww*grid_w ghh*grid_h# 落在哪个网格giint(gx)gjint(gy)# 计算和三个锚框的IOU选最大的box_whtorch.tensor([[gw,gh]])iouscompute_iou(box_wh,anchor)best_anchortorch.argmax(ious)# 标记正样本obj_mask[b,best_anchor,gj,gi]Truetarget_xy[b,best_anchor,gj,gi]torch.tensor([gx-gi,gy-gj])target_wh[b,best_anchor,gj,gi]torch.tensor([gw,gh])target_conf[b,best_anchor,gj,gi]1.0target_cls[b,best_anchor,gj,gi,cls_id]1.0# 计算损失# 1. 坐标损失仅正样本loss_xyF.mse_loss(pred_xy[obj_mask],target_xy[obj_mask])loss_whF.mse_loss(pred_wh[obj_mask],target_wh[obj_mask])loss_coordloss_xyloss_wh# 2. 置信度损失正负样本都算负样本降权loss_conf_objF.binary_cross_entropy(pred_conf[obj_mask],target_conf[obj_mask])loss_conf_noobjF.binary_cross_entropy(pred_conf[~obj_mask],target_conf[~obj_mask])loss_confloss_conf_objlambda_noobj*loss_conf_noobj# 3. 分类损失仅正样本loss_clsF.binary_cross_entropy(pred_cls[obj_mask],target_cls[obj_mask])total_losslambda_coord*loss_coordloss_confloss_clsreturntotal_loss这里有两个关键权重坐标损失权重放大因为坐标回归对检测效果影响最大负样本置信度权重缩小解决正负样本数量悬殊的问题避免模型学成全背景五、推理解码把输出变成真实检测框模型输出的是相对偏移和对数宽高不能直接用需要解码转换成原图的像素坐标。解码步骤对x、y取sigmoid得到相对于当前网格左上角的偏移量加上网格的行列索引得到特征图尺度的中心点坐标宽高取指数乘以对应锚框的宽高得到特征图尺度的宽高全部乘以下采样倍率得到原图的像素坐标置信度和类别得分相乘得到最终得分过滤掉低于阈值的框执行NMS去重得到最终检测结果核心解码代码defdecode_preds(preds,anchors,num_classes,conf_thres0.25,iou_thres0.45):将模型输出解码为真实检测框all_boxes[]forstride_idx,predinenumerate(preds):batch_size,_,grid_h,grid_wpred.shape stride416/grid_w anchoranchors[stride_idx]# 重塑形状predpred.view(batch_size,3,5num_classes,grid_h,grid_w)predpred.permute(0,1,3,4,2).contiguous()# 生成网格坐标grid_y,grid_xtorch.meshgrid(torch.arange(grid_h),torch.arange(grid_w))gridtorch.stack([grid_x,grid_y],dim-1).float()gridgrid.unsqueeze(0).unsqueeze(0)# 解码pred_xy(torch.sigmoid(pred[...,0:2])grid)*stride pred_whtorch.exp(pred[...,2:4])*anchor*stride pred_conftorch.sigmoid(pred[...,4:5])pred_clstorch.sigmoid(pred[...,5:])# 转xyxy格式x1y1pred_xy-pred_wh/2x2y2pred_xypred_wh/2boxestorch.cat([x1y1,x2y2,pred_conf,pred_cls],dim-1)boxesboxes.view(batch_size,-1,5num_classes)all_boxes.append(boxes)all_boxestorch.cat(all_boxes,dim1)# 置信度过滤 NMSresults[]foriinrange(batch_size):boxall_boxes[i]scorebox[:,4]*box[:,5:].max(dim1)[0]maskscoreconf_thres boxbox[mask]scorescore[mask]# 简单NMSkeepnms(box[:,:4],score,iou_thres)results.append(box[keep])returnresults六、训练跑通与效果说明训练流程准备数据集用VOC2007或者自定义数据集标签转成归一化的xywh格式初始化模型、优化器AdamW、学习率调度训练循环前向传播 → 计算损失 → 反向传播 → 更新权重定期验证保存模型和原版YOLO的差异这个简化版保留了YOLO的全部核心逻辑但去掉了大量工程化优化技巧所以精度会比原版低一些没有CSP结构、没有PAN路径、没有锚框聚类没有Mosaic、MixUp等强数据增强损失用了简单的MSE没有用CIoU没有复杂的正负样本分配策略但它的价值不在于精度而在于纯粹。没有多余的技巧干扰你能清晰看到每一步计算的意义理解检测的本质。最后手写一遍YOLO最大的收获不是得到一个能用的模型而是彻底打破黑盒。你会明白每个张量的形状为什么是这样、损失函数每一项在约束什么、正负样本为什么这么分配。懂了核心原理之后再去看YOLOv5、v8的源码就会发现本质逻辑没变只是加了更多工程优化、更多涨点技巧。遇到定制场景需要改网络、遇到精度问题调损失、遇到部署问题改输出心里都有底不再是盲目调参。建议大家亲手把代码跑一遍改一改参数看看不同改动对结果的影响理解会更深刻。