FCOS无锚框目标检测源码解析:逐像素预测与工程实现 我读 FCOS 源码的时候最大的感受是它把“目标检测”这件事拆得比很多两阶段模型更直接。FCOS 全称 Fully Convolutional One-Stage Object Detection核心思路就是“不加锚框不做区域提议把所有像素都当作潜在样本直接用全卷积网络去预测类别、边框距离和中心度”。如果你之前只跑过经典锚框系列的检测代码第一次打开 FCOS 的工程可能会觉得“怎么少了半个阶段”但耐心读完模型定义和标签分配部分后你会明白这套设计其实更贴近语义分割式的密集预测结构上反而更好理解。这篇内容适合两类人一类是刚读完论文、想在代码层面确认每个公式是怎么落地的同学另一类是在工程里想改造检测头或者想把 FCOS 移植到自己的轻量骨干网络上使用的开发者。我尽量按“先看什么、再看什么、踩了哪些坑”的顺序来写代码块里的文件名我自己做了整理不同版本的工程会稍有差异但主线思路是可以通用的。1. 整体设计与思路拆解1.1 从锚框到逐像素预测读代码前先搞懂这一层过去做单阶段检测最主流的做法是在特征图的每个位置预先铺上几个不同尺寸、不同比例的锚框。训练时把锚框和标注框做 IoU 匹配正样本负责回归“相对锚框的偏移”。这套逻辑工程上非常成熟但锚框数量、正负样本比例、匹配阈值这些超参都要手工调代码里也会因此出现大量跟“生成锚框”“筛选锚框”有关的矩阵操作。FCOS 把问题换了个角度每个像素自身就是一个“隐式锚点”。模型在特征图每个位置上直接预测四个值分别是该位置到目标左、右、上、下四条边的距离也就是(l, t, r, b)。想象一下如果某个像素刚好落在目标内部我们知道它到这个矩形四边的距离就能反推出完整的检测框。正因为如此整个网络不需要锚框相关代码也省掉了“先框选再修正”的环节。在读代码时你首先要抓住的就是这个“距离回归”的概念。后面几乎所有代码都围绕四件事展开一是从标注框计算每个像素的(l,t,r,b)真值二是让网络预测这个四维向量三是用中心度分支压制远离目标中心的低质量预测四是在推理时把预测的距离换算成坐标框。抓住这条主线代码里的函数名再花哨也不会把你绕晕。1.2 代码目录的阅读主线不管你拿到的是官方开源工程还是别人复现的版本目录结构都大差不差。我习惯先按模块把代码归类再决定阅读顺序避免一上来就扎进某个工具函数里出不来。一个比较典型的 FCOS 工程会包含这几个目录configs/ fcos_r50_xxx.yaml # 模型结构、训练超参、损失权重配置 data/ dataset.py # 数据集读取与标注解析 transforms.py # 训练和测试预处理 model/ backbone.py # 骨干网络负责提取特征 fpn.py # 特征金字塔输出多层特征 head.py # 检测头分类/回归/中心度分支 fcos.py # 把骨干、FPN、检测头串起来 loss.py # focal loss、iou loss、centerness loss engine/ trainer.py # 训练循环 evaluator.py # 测试与评测 utils/ postprocess.py # 解码、中心度加权、NMS我的建议是不要从trainer.py开始读因为训练循环里会牵扯太多工程细节。你先打开model/fcos.py看forward是怎么把输入图片变成网络输出的再去model/loss.py和data/dataset.py里看标签是怎么构造的。训练循环可以放到最后再看它只是把前向、损失、反向传播串起来而已。还有一个很关键的阅读技巧给代码里每个输出张量都标上 shape。FCOS 是典型的密集预测模型BCHW 这种形状变化贯穿始终你只要在纸上把输入从[B,3,H,W]经过骨干变成[B,C,H/8,W/8]这类特征再经过 FP 输出多层金字塔最后变成分类分数和回归距离整个网络结构就清晰了。2. 核心细节解析与实操要点2.1 backbone 与 FPN 是怎么串起来的FCOS 的特征提取部分由骨干网络和特征金字塔组成。骨干网络负责生成基础特征图高层特征感受野大、语义信息强但分辨率低低层特征分辨率高、空间细节好但语义信息偏弱。FPN 的作用是把两者结合起来输出一系列不同尺度的特征层每一层负责预测不同大小范围的目标。在代码里FPN 的输入通常是骨干网络最后几层的输出。以 50 层残差骨干为例常用的做法是取三个阶段的输出再通过自顶向下的路径生成 P3、P4、P5 三个基础层。为了覆盖更大尺寸的目标很多实现还会在 P5 基础上继续做步长为 2 的下采样得到 P6、P7最终输出五个层级对应原图的步长分别是 8、16、32、64、128。这里有个必须注意的点每一层特征图的网格尺寸差别很大。比如输入图是 800×1024步长 8 的特征图是 100×128步长 128 的特征图只有 7×8。FCOS 用不同层级负责不同尺度的目标就是为了避免同一个目标出现在多个层上减少匹配歧义。代码里会有类似areas或target_level的变量专门用来判断某个真实框该落到哪个层级上。这种“按面积分层”的做法替代了传统锚框匹配中复杂尺寸设计读代码时会觉得特别克制。实际调试时你可以打印每一层的张量形状确认 FPN 输出的通道数一致通常是 256。如果发现某一层通道数和其他层不一致多半是 FPN 的横向连接或者下采样分支没对齐。这个错误在源码上看起来不明显但前向跑起来就会报错。2.2 head 的四个分支别把它当三个分支FCOS 的检测头通常是共享结构。最朴素的形式是先用若干层卷积对 FPN 输出的 256 通道特征做映射然后分出三个输出分支。分类分支输出的通道数是类别数代表每个位置上属于各类别的概率回归分支输出 4 个通道代表(l,t,r,b)中心度分支输出 1 个通道代表当前位置距离目标中心的程度。我在读代码时发现多数实现不会把中心度跟分类或回归硬拆成三个完全独立的塔而是在共享卷积之后再单独接一层卷积生成中心度预测。有些更轻量的实现干脆把中心度合并到回归分支里多输出一个通道等推理时取出来用。这种设计的区别不影响主干逻辑但会影响参数量和训练稳定性。初始化值得专门说一下。分类分支最后一层卷积的偏置通常要设置成bias -log((1-p)/p)其中p取一个很小的值比如 0.01。这样做的效果是训练刚开始时网络倾向于把所有位置都预测成背景避免大量负样本产生的梯度把模型推偏。这个初始化技巧在很多逐像素检测模型里都适用如果你复现 FCOS 时发现训练初期 loss 很不稳定优先检查这一处。单阶段检测的预测头往往还存在一个现象不同 FPN 层共用同一个检测头。代码里可能是同一个卷积模块被循环调用多次。别忘记设置inplace相关的操作时要小心如果特征图被原地修改下一层 FPN 的输入可能已经被污染这种 bug 比较隐蔽我在调试时遇到过。2.3 标签分配先看这个其余都好懂FCOS 里最值得反复读的代码其实是训练时期的标签分配部分。它不像锚框检测那样需要计算几万个 IoU做法要简洁很多。对每一个真实框代码会先判断它应该由哪个 FPN 层级负责。判断依据通常是真实框的面积是否落在该层级的预设范围里。层级确定后再找出所有落在真实框内部的像素位置这些像素位置全部被当作正样本。对每个正样本计算它到真实框四条边的距离得到(l,t,r,b)再根据公式计算中心度真值。如果某个像素同时落在多个真实框内部代码必须选一个作为回归目标。多数实现选的是面积最小的那个真实框因为面积越小说明这个像素归属于更具体的目标回归歧义更低。这一点从论文到工程实现都是一致的但不同复现版本在细节上会有微调。到这里你应该能理解为啥 FCOS 可以“没有锚框”。因为没有锚框所以不需要计算锚框和真实框的匹配矩阵也不需要设置正样本 IoU 阈值。代码里你看到的会是大量布尔掩码、索引数组、坐标广播但逻辑都非常直接。阅读时你可以在纸上记录一下当前层是什么步长、真实框在哪个层级、哪些像素被标记为 foreground。只要把这三件事对应起来标签分配就算读懂了大半。3. 实操过程与核心环节实现3.1 推理时一条完整的代码链路我建议你先跑通推理再去看训练。因为推理流程简单很快能验证你对网络结构的理解也能帮你定位后面训练代码里的数据是否和推理对齐。推理的第一步是图像的预处理。输入的图片会被缩放到一个固定尺寸通常是保持宽高比的同时让长边不超过某个值短边满足能被下采样倍数整除。这一步很关键因为 FPN 有多层下采样如果输入尺寸不是 32 或 128 的倍数某些特征层的尺寸会往下取整导致最后的解码坐标偏移。所以在 FCOS 的预处理代码里通常会先把图片 padding 到步长的整数倍。前向过程把图片送入骨干和 FPN得到多个特征层。每一层经过检测头后输出的形式大致是# 假设当前层步长 stride输入特征图高 h、宽 w # cls_scores: [B, C, h, w] # reg_preds: [B, 4, h, w] # ctr_preds: [B, 1, h, w]解码时把分类输出 reshape 成[B, h*w, C]回归输出 reshape 成[B, h*w, 4]再根据每个位置在原始图像上的坐标恢复出边框。假设当前特征层步长是s特征图上索引为(i,j)的点对应原图中心坐标可以认为是ctr_x j * s s / 2 ctr_y i * s s / 2那么预测框就可以表示为x1 ctr_x - l y1 ctr_y - t x2 ctr_x r y2 ctr_y b到这里你已经完成了 FCOS 最主要的解码逻辑。接下来通常还会做一个中心度重加权的操作把分类分数和中心度相乘作为最终置信度。这一步的目的是压低远离目标中心的误检框后处理时再按置信度过滤最后做 NMS。你可以把 NMS 前的数据打印出来看会发现虽然有很多框分数不高但经过中心度修正后低质量的框会更靠后NMS 的效果也更好。3.2 一个训练 batch 的标签张量长什么样训练代码最绕的通常是标签张量是怎么组织的。拿 FPN 的一层举例假设输入图是 800×1024这一层的步长是 16那么特征图大小就是 50×64。类别数如果是 80分类标签张量的形状就是[2, 80, 50, 64]回归标签是[2, 4, 50, 64]中心度标签是[2, 1, 50, 64]。实际代码里还会额外存一个 mask 张量形状是[2, 50, 64]用来标记哪些位置是正样本。负样本位置并不是直接置为背景类别而是通过 mask 区分开让 loss 计算时只对有效位置做约束。这也符合 FCOS 的密集预测特征所有位置都要预测类别只不过大部分位置被当做背景而背景位置不参与回归损失和中心度损失。标签匹配过程通常是离线完成的。工程代码里会有类似prepare_targets或compute_target的函数它接收真实标注的框和 FPN 各层信息返回每一层对应的分类目标、回归目标、中心度目标和有效掩码。我在读这种函数时一般会用小 batch 小尺寸输入做单步断点调试把中间输出的unique值打印出来能很快确认目标分配没有歧义。这个地方如果写得不仔细会出现一个很常见的错误中心度目标忘记参与 mask 截断导致大量背景位置的中心度也参与了 BCE loss把损失值撑得很大。正常来说中心度损失只在正样本位置计算因为背景位置的中心度真值本身没有意义。3.3 损失函数的代码实现与公式落地FCOS 的损失函数分成三块代码实现也不算复杂。分类损失使用 focal loss。实现时通常先把预测 logits 和标签转换成sigmoid形式然后按公式计算# 伪代码形式 def sigmoid_focal_loss(logits, targets, alpha0.25, gamma2.0): p torch.sigmoid(logits) ce_loss F.binary_cross_entropy_with_logits(logits, targets, reductionnone) p_t p * targets (1 - p) * (1 - targets) alpha_t alpha * targets (1 - alpha) * (1 - targets) loss alpha_t * (1 - p_t) ** gamma * ce_loss return loss这里targets是 one-hot 形式的类别标签背景类别用全零向量表示。focal loss 会让模型把注意力集中到难分样本上但如果你把alpha和gamma设错损失数值会差很多。我复现时一般先看论文默认值再看配置文件的损失权重确认代码里没有写死。回归损失用 IoU 系列损失。代码会先把预测的距离解码成预测框再跟真实框计算 IoU。常见的是 GIoU 或 DIoU两种实现细节略有差别。中心度损失是 BCE loss预测值经过sigmoid后和中心度真值做交叉熵。中心度真值的计算公式在代码里大概是# l, t, r, b 是该像素到真实框四条边的距离 centerness_target torch.sqrt( (torch.min(l, r) / torch.max(l, r)) * (torch.min(t, b) / torch.max(t, b)) )这个公式的含义很直观越靠近目标中心四个距离越均衡中心度越接近 1越靠近目标边缘中心度越接近 0。推理时预测框的中心度越低最终得分也就越低本质上是一种软性的 NMS 前置过滤。4. 常见问题与排查技巧实录训练和推理 FCOS 时我踩过不少坑也帮别人排查过问题。这里整理几个高频问题基本都是工程实现里容易出事的位置。4.1 训练初期 loss 巨大或者不下降先检查分类分支的偏置初始化。单阶段检测负样本数量远多于正样本如果分类分支初始分数在 0.5 附近focal loss 会被无数个“不确定但算负样本”的位置干扰。正确的做法是让分类分支初始预测偏向背景bias -log((1-0.01)/0.01)是我常用的初始值。某些工程的配置里没写这一步你得自己加上。其次检查 FPN 的层级范围。如果真实框面积和代码里预设的范围不匹配可能出现很多目标没有被分配给任何层级正样本非常稀疏损失自然不稳定。你可以把分配后的正样本数量打印出来正常情况下一个 batch 里应该有成百上千个正样本而不是个位数。4.2 推理结果框位置整体偏移这个问题很典型通常是解码时的坐标没有对齐。特征图上的(i,j)并不是对应原图上的(i,j)而是乘以步长并且往往还要加上半个步长的偏移。如果你的预处理里有 padding还得把 padding 的偏移减掉。我调试时会在图上画几个点然后把特征图对应位置的点也画出来肉眼比对是否对齐比单纯看坐标参数更直观。还有一种情况是图像预处理时做了标准化但推理时忘了做同样的标准化导致模型输出分布完全错乱。FCOS 这类模型对输入均值和方差很敏感检查预处理配置时一定要跟训练配置保持一致。4.3 为什么框很多、NMS 后还是很杂如果你发现中心度分支预测的值没有在最终分数里起作用那后处理可能漏掉了中心度加权。有些代码里把中心度和分类分数相加有些代码里是相乘效果完全不同。相乘更符合论文本意因为它会惩罚那些“分类分数高但中心度低”的框。我常用的方式是直接把最终得分设为cls_score * centerness再按阈值过滤。4.4 显存占用过高或者反向传播 NaNFCOS 的正样本点数量多逐像素计算损失时会摊开很多中间张量。如果显存吃紧可以试试减小输入分辨率或者把损失里的reduction改成先按张量元素计数再取平均避免某些极端情况下除零。反向传播出现 NaN很多时候是回归距离里出现了负值或极端值导致 IoU 损失计算不稳定你可以给回归分支的输出加一个relu或者exp约束看是否缓解。我把排查经验整理成一个速查表方便你对照检查现象可能原因检查位置训练初期 loss 大分类偏置未初始化head 最后一层卷积的 bias框整体偏移解码坐标与特征图步长不对齐预处理、decode 函数误检太多中心度未参与最终评分后处理分数重加权一个目标多个重复框NMS 阈值过松或置信度没过滤后处理参数显存溢出输入分辨率过大数据配置、batch size回归 loss 为 NaN回归距离异常损失函数输入裁剪5. 把阅读心得转成动手实验清单5.1 三个低成本的消融实验读代码最大的收获不是看懂而是能动手验证。我建议你在复现基础上做三个小实验。第一个实验是去掉中心度分支。把中心度预测从网络和损失中去掉推理时直接使用分类分数做 NMS。你会发现在目标密集场景下误检率明显上升。这个实验能让你直观感受到中心度分支的价值。第二个实验是只保留三个 FPN 层中的两层比如去掉最高层或者最低层。你会发现模型对大目标或者小目标的召回率有明显变化这比读论文里的消融表格更有体感。第三个实验是调整正样本分配策略。比如把“所有落在框内的像素都作为正样本”改成“只有中心区域一定范围内的像素作为正样本”看看收敛速度和精度怎么变化。这个实验会让你明白FCOS 对正样本定义其实很敏感也更能理解后来各种改进方法为什么要动分配策略。5.2 边读边改把代码变成自己的工具读 FCOS 代码时我一直坚持做一件事在关键函数的开头写注释注释内容不是复述代码而是写这个函数的目标张量是什么形状、从哪里来、到哪里去。这个过程非常耗时但价值巨大。你写注释时会逼自己把代码和论文公式一一对应等到注释写完整个模型在脑子里的结构也就完整了。如果你有自己的业务场景比如只需要检测一个类别或者需要在移动端上做推理完全可以基于 FCOS 的主干结构裁掉不必要的分支。因为它是全卷积结构没有 ROI 或 anchor 的耦合改起来比两阶段模型容易很多。你只需要保留分类分支、回归分支和你需要的后处理逻辑就行。再往后你会发现许多新的检测思路都跟 FCOS 有关联。读懂这一类“逐像素预测 中心度修正 多层特征分配”的代码以后再看其他类似框架会快得多因为它们的标签分配逻辑、解码逻辑、损失组成本质上都是在同一个骨架上做文章。最后再分享一个我自己的习惯每读完一个模块我会立刻试着用一个小脚本调用它的接口打印输出的形状再故意改错一个维度看报错信息。报错信息是很好的老师它会告诉你网络某个位置期待什么形状也会帮你记住张量流经每个节点时的变化。这个方法在 FCOS 这种密集预测模型上特别有效因为它的张量形状变化非常多一旦熟悉了从 BCHW 到各个分支输出的过渡代码阅读就会越来越有手感。