FCOS源码解读:从FPN到centerness的anchor-free检测器全流程 第一次翻开FCOS的源码时我下意识地在代码里找anchor。找了半天没找到后来才反应过来这个检测器压根就不用anchor。这其实是很多人的第一反应——FCOS论文里说自己是anchor-free但代码里依然有FPN、有centerness、有focal loss乍一看和RetinaNet的代码也没什么区别。真正的区别藏在数据层正样本是怎么选出来的、每个位置负责预测什么、最终分数用什么乘。这篇就把我阅读FCOS代码的完整路径写一遍从网络前向、标签分配、loss到推理decode和复现时踩过的坑目标是让读完的人可以自己打开代码逐行对照而不是停留在anchor-free这个概念上。1. 读代码之前先建立FCOS的无锚思维框架1.1 Anchor-based的代码为什么读起来那么累在FCOS出现之前一阶段检测器的代码基本都有这么几条固定套路生成密密麻麻的anchor框、计算anchor和GT的IoU、用IoU阈值划分正负样本、再让每个anchor学习类别和偏移量。举个例子一张800x1000的输入图在FPN的5个层级上可能要生成几万个anchor代码里最长的部分永远是anchor生成和标签分配那一大坨。这种设计思路的tensor维度尤其磨人分类分支输出的是[N, A, C, H, W]这种形状A就是每个位置的anchor数量。你读代码时脑子里必须时刻记着这个C是类别数那个C是通道数稍不留神就混了。而且anchor本身有一堆超参长宽比、面积尺度、正负样本阈值任何一处配置出错训练出来的模型表现都会很诡异还不好排查。读完FCOS的代码再回头看就明白为什么作者要砍掉anchor——它本质上是把定位检测任务从在预定义的框里做选择变成了直接在feature map上做逐像素预测这一刀切得干净利落。1.2 FCOS用三个改动绕开了什么FCOS的核心改动可以浓缩成三句话去掉anchor的生成与匹配流程让每个feature map位置直接预测一个边界框引入centerness分支来压低远离物体中心位置的分数利用FPN的多层特征来覆盖不同尺度的目标。对应的代码量是直接减半的head输出从[N, A, C4, H, W]变成了[N, C5, H, W]A这个维度完全消失了。注意第三点FPN在FCOS里不是花架子。因为anchor-free之后每个位置都要负责预测它到GT四个边界的距离如果不分层小目标和超大目标就会在同一个stride上打架。FPN把不同尺度的目标打散到不同层级每个位置只看自己那一亩三分地模型的学习压力小得多。1.3 读代码的推荐路径我建议按前向推理 → 标签分配 → 损失计算 → 推理decode的顺序来读。前向推理最简单顺着输入到输出走一遍先建立形状和数据的直觉标签分配是灵魂必须搞懂一个GT框是怎么落到某个feature map的具体位置上的损失计算要结合标签分配来看特别是centerness的target怎么来的最后推理decode一结束整个闭环就通了。如果倒着读一上来就看到一堆l,t,r,b的回归值和topk、NMS的调用很容易劝退因为你看不到这些值的来源。2. 前向链路从Backbone到FPN再到Head的代码走读2.1 Backbone与FPN的shape流动以ResNet-50作为Backbone的常见实现为例输入图像假设是[N, 3, H, W]经过stage2、stage3、stage4、stage5之后分别得到C2、C3、C4、C5四个特征层。FCOS一般在C5之后开始构建FPNC5经过一个1x1卷积得到P5P5上采样和C4融合得到P4P4再上采样和C3融合得到P3然后P5经过stride2的卷积得到P6P6再卷积得到P7。# 伪代码展示FPN构建的shape变化 c3, c4, c5 backbone(x) # shape: [N, 512, H/8, W/8], [N, 1024, H/16, W/16], [N, 2048, H/32, W/32] p5 lateral_conv_c5(c5) # 1x1 conv, channel - 256, shape: [N, 256, H/32, W/32] p4 lateral_conv_c4(c4) upsample(p5) # [N, 256, H/16, W/16] p3 lateral_conv_c3(c3) upsample(p4) # [N, 256, H/8, W/8] p6 conv_p5(p5, stride2) # [N, 256, H/64, W/64] p7 conv_p6(p6, stride2) # [N, 256, H/128, W/128]对应到FCOS的FPN strides就是[8, 16, 32, 64, 128]。P3的stride是8意味着feature map上一个位置对应原图8x8的区域P7的stride是128一个位置对应原图一个很大的区域。读代码时看到fpn_strides这个列表就把它和FPN的层级一一对应起来后面所有locations计算都依赖这个映射。2.2 Head的两个并行分支FCOS的Head结构很直接分类分支和回归分支各自带一叠卷积但共享Backbone特征。常见实现是两个独立的tower每个tower是4个3x3卷积加GroupNorm然后分类tower输出[N, C, H, W]回归tower输出[N, 5, H, W]。注意这个5是[l, t, r, b, centerness]不是4。class FCOSHead(nn.Module): def forward(self, features): # features: List[Tensor], P3-P7 cls_outs [] reg_outs [] for feat in features: cls_feat self.cls_tower(feat) # 共享卷积 reg_feat self.reg_tower(feat) # 共享卷积 cls_outs.append(self.cls_pred(cls_feat)) # [N, C, H, W] reg_outs.append(self.reg_pred(reg_feat)) # [N, 5, H, W] return cls_outs, reg_outs我自己读代码时有一个小技巧打印每一层输出的shape跟在纸上走一遍把[N, C, H, W]的C和H、W记下来后面标签分配时要用这些shape去生成locationsshape对不上基本就是理解偏了。2.3 和RetinaNet相比FCOS的Head其实简化了什么RetinaNet的Head输出一般是[N, A*C, H, W]和[N, A*4, H, W]A是每个位置的anchor数量通常A9。FCOS把A直接去掉了每个位置只有C个类别分数和41个回归量。代码上省去了reshape的步骤不再需要把输出拆成anchor维度再逐一处理。这对显存也是友好的同样输入下FCOS的head输出tensor比RetinaNet小一个数量级。但这不意味着FCOS的head更弱。去掉anchor后每个位置需要自己去感知应该负责多大范围的物体这个能力是FPN和逐层监督训练出来的。读代码时你会发现FCOS的标签分配完全是在特征图的坐标空间做的而不是在anchor空间做的这就是思路上的根本区别。3. 标签分配locations与GT的匹配逻辑这是FCOS的灵魂3.1 locations生成stride与坐标换算FCOS没有anchor但它也有参照点就是每个feature map位置映射回原图的坐标代码里叫locations。常见的实现方式是用arange生成一组坐标然后乘上stridedef compute_locations_per_level(h, w, stride): shift_x torch.arange(0, w * stride, stepstride, dtypetorch.float32) shift_y torch.arange(0, h * stride, stepstride, dtypetorch.float32) shift_y, shift_x torch.meshgrid(shift_y, shift_x) locations torch.stack([shift_x, shift_y], dim-1) # [H, W, 2] return locations注意这里的坐标是(x, y)顺序shift_x是横坐标方向。因为图像坐标系里x往右y往下所有后面的l, t, r, b计算都要顺着这个顺序来。P3层如果feature map是100x100stride是8生成的locations就是从(0,0)到(792,792)的100x100个点相邻点间隔8个像素。有一个细节值得提有些实现会在这个基础上加stride // 2作为偏移表示像素中心但官方常见实现是不加的。这个差异不影响大效果但如果你要自己写精度敏感的后处理代码记住你的locations生成方式要和训练时的label assignment完全一致。3.2 正样本匹配的完整逻辑有了locations接下来就是确定哪些位置算正样本、每个正样本回归目标是什么。FCOS的判断逻辑非常符合直觉如果一个location落在某个GT框内部那它就有资格成为这个GT的正样本。for loc, label in zip(locations, gt_labels): # l, t, r, b 分别表示距离GT四条边的距离 l loc_x - gt_x1 t loc_y - gt_y1 r gt_x2 - loc_x b gt_y2 - loc_y inside (l 0) and (t 0) and (r 0) and (b 0)这四条距离其实就是训练时的回归target所以FCOS的回归分支学的东西非常直白知道我在框内那请预测出我离四条边的距离。相比anchor-based方法要预测相对anchor的偏移量FCOS回归的是绝对像素距离直观也简单。如果某个location同时落在多个GT框内代码常见的处理是选择面积最小的那个GT作为target面积小意味着目标更具体语义歧义更小。3.3 FPN层级的分配条件一个GT框落在feature map的多个层上都是在框内的不能每一层都拿它当正样本。FCOS通过FPN层级的尺度范围来判断GT属于哪一层或哪几层。常见实现中每一层都有一个[min_size, max_size)区间比如FPN层级stride尺度范围以GT面积或边长为准P38[0, 64)P416[64, 128)P532[128, 256)P664[256, 512)P7128[512, inf)不同开源实现的具体划分依据略有差异有的按GT面积w*h有的按sqrt(w*h)等效边长但核心思想一致——小物体去浅层低stride大物体去深层高stride。代码里常见的写法是预先算每个GT的尺度值再和每个layer的区间做比较筛选出哪些层应该分配哪些GT。这里有一个容易踩坑的点如果你在自定义数据集上发现小目标检测效果差先去看P3层的min_size是不是被设得太高了导致很小面积的GT在P3上不算正样本。3.4 center sampling和ambiguity问题FCOS论文里专门讨论了如果一个location同时落在多个GT框内怎么办。除了取面积最小这种处理还有一个更重要的机制叫center sampling也就是只把GT中心附近区域内的location算作正样本。# center sampling 的常见实现思路 radius stride * center_sample_radius # 通常 radius 取 1.5 或根据GT尺度按比例 inside_box ( (loc_x gt_x1 radius) (loc_x gt_x2 - radius) (loc_y gt_y1 radius) (loc_y gt_y2 - radius) )这个设计的直觉是远离物体中心的feature map位置本身就分不清它到底属于前面的车还是后面的行人既然ambiguity主要在物体边界附近干脆把那些位置全部标记成负样本只保留中心区域的正样本。代码中很多实现默认使用center sampling论文给出的效果提升也非常明显从center sampling出现以后几乎成了FCOS标配。我在读代码时把这个逻辑走了很多遍先是所有框内的位置都是正样本再加上FPN尺度限制再加上center sampling缩框三条件叠加之后正样本的数量比anchor-based方法少得多但每个正样本的质量高很多。4. 损失函数与Center-ness分支代码里容易被忽略的细节4.1 三个输出的loss分别怎么算FCOS的总loss是三部分相加权重在论文中是1:1:1。分类分支用的是Focal Loss焦点损失因为正负样本严重不平衡绝大部分位置是负样本回归分支用IoU Loss或GIoU Loss因为直接回归l、t、r、b的欧氏距离对不同尺度的物体差异很大IoU系loss天然尺度不变centerness分支用BCE二元交叉熵。# Focal Loss 常用实现的核心参数 alpha 0.25 gamma 2.0 # loss -alpha * (1 - p_t)^gamma * log(p_t)代码里读focal loss时最重要的就是确认它接收的是sigmoid之前的logits还是sigmoid之后的概率。很多复现坑都是从这个地方开始的——输入搞错loss曲线会非常难看。常见实现是计算sigmoid之后得到概率p再用p_t p if target 1 else 1 - p来加权。回归分支如果按GIoU实现代码会比较长因为要计算两个box的交集、并集、最小外接矩形但这些操作都可以在tensor维度上直接做不需要循环。注意GIoU的输入输出形状pred和target都是[N, 4]形状的批量box坐标loss值是一个标量。4.2 centerness的实现细节centerness的target在代码里长这样# 对每个正样本 left loc_x - gt_x1 top loc_y - gt_y1 right gt_x2 - loc_x bottom gt_y2 - loc_y # 防止除零 epsilon 1e-12 centerness_target torch.sqrt( (torch.min(left, right) / (torch.max(left, right) epsilon)) * (torch.min(top, bottom) / (torch.max(top, bottom) epsilon)) )这个公式做了一件很聪明的事越靠近GT中心的locationmin(dist_left, dist_right) 和 max(dist_left, dist_right) 越接近比值趋近于1越靠近边缘比值趋近于0。所以centerness可以看作一个该位置距离物体中心的归一化程度范围是[0,1]。线程上这个值是每个正样本单独算的不是全局共享。推理时把它和分类概率相乘可以压低边缘位置的最终分数让NMS选出更贴近中心的框。实操中我注意到一个小细节有些实现会对centerness的输出再做一次sigmoid因为原始网络的输出是logits。训练loss用BCEWithLogitsLoss时target是刚才计算的值但推理时一定要对输出做sigmoid再参与乘法否则分数范围完全不对NMS结果会崩。4.3 loss masking和样本权重loss的mask逻辑一般是这样所有location都参与分类loss的focal计算只有正样本location参与回归loss和centerness loss。代码里常见的做法是计算一个pos_mask用pos_mask去索引回归和centerness的预测值只计算这批子集的loss。reg_pred_pos reg_pred[pos_mask] reg_target_pos reg_target[pos_mask] giou_loss compute_giou_loss(reg_pred_pos, reg_target_pos) center_pred_pos center_pred[pos_mask] center_target_pos center_target[pos_mask] center_loss bce_with_logits(center_pred_pos, center_target_pos)这个mask操作不复杂但必须想清楚pos_mask是在什么tensor形状上做的。FPN有5个层级每个层级的H, W不同所以要先把所有层级的输出展平到一个大tensor里再统一做mask这个展平顺序要和locations的生成顺序保持一致否则错位之后loss在数值上可能都看不出来但模型学的是错的东西。5. 推理阶段的解码与后处理inference为什么和training不一样5.1 decode公式训练时模型输出的是相对于location的距离推理时需要把它还原成原图坐标的box。代码里这个decode过程非常短# pred_reg: [N, 4, H, W] - l, t, r, b x1 loc_x - l y1 loc_y - t x2 loc_x r y2 loc_y b注意这里不需要任何anchor先验直接把四个距离映射回去就行。实际代码中通常先把pred_reg从[N, 4, H, W]转换到[N, H, W, 4]然后和locations进行广播运算。decode之后的x1、y1、x2、y2是原图尺度。如果训练时做过数据增强输入尺寸和原图尺寸可能不一致推理时还要做一下rescale。这个细节经常被忽略尤其是从官方预训练模型直接接自己的推理脚本时输出box的坐标尺度对不上往往就是差这一步。5.2 多level结果合并推理时每个FPN level都会输出一批box不能直接全丢进NMS。一个常见策略是对每个level先做topk筛选每个level保留前1000个或者按分数阈值过滤然后合并所有level的结果再统一做NMS。for level in range(num_levels): scores_cls sigmoid(cls_out[level]) # [N, C, H, W] scores_ctr sigmoid(center_out[level]) # [N, 1, H, W] scores scores_cls * scores_ctr # 关键centerness乘分类分数 # 展平 - topk - 记录index和对应box这里有一个FCOS特有的排序细节最终分数是分类概率乘以centerness而不是只用分类概率。这个乘法的意义是一个位置即便分类很自信如果它偏离物体中心很远预测的框大概率不够准分数就应该被压低。这样NMS阶段真正比较的是这个位置对应的框有多可信而不是这个位置属于某类的概率有多高。5.3 NMS前的score计算实际代码中还要注意类别维度怎么处理。常见做法是遍历每个类别分别做NMS因为不同类别之间的框理论上是可以重叠的。如果所有类别的框混在一起做NMS同一个人身上相邻位置预测的猫和狗两个类别的框会互相抑制导致漏检。关于NMS阈值FCOS常用0.5或0.6和anchor-based方法差不多没有特殊要求。但centerness分支会让一个目标往往只有中心附近几个位置分数高所以输出的重复框数量明显少于anchor-based方法NMS的负担小很多这是我在处理高分辨率输入时体会很深的一点。6. 复现FCOS时我踩过的坑与改进经验6.1 训练不收敛先查这几处我第一次从头训练FCOS时loss降到1附近就再也不动了检测结果里一堆重复框。排查了一圈发现是head初始化的问题FCOS的head是随机初始化的但FPN的层是从预训练Backbone上接过来的如果head的学习率没有单独设置很容易把FPN的特征带偏。后来把head部分的初始化改成更保守的设置并且训练初期使用线性warmup问题才消失。代码里检查warmup的常见位置是optimizer的scheduler部分不要只在训练脚本里找有些复现把warmup写在了检测框架的hook机制里。还有一个很隐蔽的坑GroupNorm在head里用的num_groups必须能被通道数整除常见是32或16如果你的自定义Backbone输出通道数不是256的倍数报错可能不明显但训练曲线会异常。建议一开始就把head通道数固定成256不要乱改。6.2 center sampling的超参调整center sampling的radius设置直接影响正样本数量。radius太小正样本太少模型学不到东西radius太大又回到边界模糊的问题。官方常用的是1.5倍stride这个值在COCO这类标准数据集上表现良好。在自定义数据集上如果目标重叠严重比如一堆小物体挤在一起建议适当减小radius如果目标是细长条形比如桥梁、电线杆这种radius可以稍微放大让更多的位置参与学习否则大量正样本集中在一条线上回归分支不够稳。实测中我发现radius不要跨层统一不同FPN层可以设置不同的倍数。P3层目标通常小而密集radius稍小P7层目标大一个GT可能对应很多个位置radius稍大反而有利于稳定训练。这种改动在代码里就是给每一层配不同的超参做起来不复杂。6.3 接自定义数据集时的适配经验如果你把FCOS用到自己的数据上最需要关注的是类别数和FPN尺度范围。类别数变了head输出通道跟着变代码里就是改一个数字的事。但尺度范围不是数字那么简单——你的数据集里如果目标普遍偏小需要把P3的max_size从64适当往上调否则小目标的正样本匹配不到P3。我在某个小目标场景里测试过把P3的范围从[0, 64)改成[0, 96)mAP提升了2个多点。但要同时注意P4的范围也要相应上调否则P3和P4的分配区间重叠太严重部分目标会被两层同时匹配反而引入歧义。另外我建议在接自定义数据之前先用官方预训练的模型在你的测试图上跑一遍定性看效果。这一步能快速暴露数据的标注格式是不是符合代码预期、坐标是不是归一化的等问题。因为FCOS代码里对GT坐标的假设通常有两种原图像素坐标或者归一化坐标搞混了标签分配会全部错位但模型loss可能还在下降属于最难排查的那类bug。6.4 一个小经验先读deploy推理脚本再回头改训练配置最后分享一个读代码的小习惯很多框架会同时提供训练配置和推理脚本我建议在改任何代码之前先读一遍推理脚本。推理脚本里浓缩了所有训练时学到的东西——从输入预处理、FPN输出到NMS参数全程走一遍基本上就能建立这个模型应该怎么用的直觉。之后再回到训练代码里你会更容易判断哪一段是核心逻辑、哪一段只是工程细节。FCOS的代码量在目标检测器里算少的正是因为少了anchor机制这一大块。但这不代表它简单标签分配和centerness的设计都很精巧值得反复看几遍。如果你读第一遍觉得卡不用着急先把locations生成和label assignment这两段代码手写一遍很快就通了。