目标检测做到 2020 年前后,有一批工作开始琢磨一个问题:我们到底需不需要 anchor?FCOS 就是这股“无锚框(anchor-free)”浪潮里绕不开的一篇。它的思路很有趣——干脆把检测当成“逐像素”的密集预测来做,也就是标题里说的“用图像分割的思路处理目标检测”。我当初啃这篇论文的时候,最大的感受是:原来去掉 anchor 之后,很多之前习惯性的设计反而变得多余,但随之而来的是一批新问题需要重新解决。这篇文章把 FCOS 从动机到细节完整拆一遍,配上实操踩坑记录,希望对啃论文或者想复现的朋友有帮助。
1. FCOS 整体设计思路拆解:为什么可以丢掉 anchor?
要理解 FCOS,先得理解 anchor-based 方法到底在干什么。以 Faster R-CNN 为代表的经典范式是:预先在图像上铺一堆固定大小、固定比例的框(anchor),然后让网络判断每个 anchor 里有没有目标、有的话怎么微调坐标。RetinaNet、YOLOv2/v3 本质上也是这套,只是要不要两阶段、要不要框分类的区别。这套东西的问题是:anchor 的超参(大小、比例、数量)对最终效果影响很大,不同数据集得反复调;而且一张图上 anchor 数量动辄几十万,但绝大多数是背景,正负样本严重不平衡;小目标和极端长宽比物体还需要专门设计 anchor 来兜底。
FCOS 的做法非常直接:不要 anchor 了,每个像素点直接预测一个 4D 向量,分别是该点到目标左边界、上边界、右边界、下边界的距离。用论文里的符号,就是 l、t、r、b 四个值。相当于把这个像素点“绑”到它所在的那个目标框上,预测它到四条边的距离。你把这个过程和语义分割里每个像素预测一个类别标签对比一下,思维模式确实很像——只不过分割预测的是“这个像素属于哪个类”,FCOS 预测的是“这个像素属于哪个框、离框边多远”。
我第一眼看到这个设计时觉得太简单了,简单到怀疑它能不能 work。但顺着它的逻辑走一遍就明白了,它有三个很扎实的支撑点:
第一,利用全卷积网络做逐点密预测,把检测问题彻底化成了“图像到图像”的映射。输入一张图,输出分辨率相同的 feature map,在每一个位置上做分类和回归,这就是分割的骨架,检测只是换了回归目标。
第二,中心度(centerness)分支用来兜住“低质量框”的问题。去掉 anchor 后,目标框内的所有像素点都会被视为正样本去参与回归,但框边缘附近的点其实很难准确回归出完整物体范围,预测出来的框质量参差不齐。FCOS 引入一个额外的分支预测“这个点离目标中心有多近”,用一个 0 到 1 之间的分数来压低边缘点的权重,推理时用这个分数和分类分数相乘作为最终置信度。
第三,FPN 多尺度分层解决重叠和尺度差异。不同大小目标分给不同层级的特征图去处理,避免一个目标在多层同时被预测导致大量重复检测。这实际上就是把 RetinaNet 的 FPN 结构拿过来,只是把每个 level 的 anchor 分配规则换成了“依据目标尺寸划分回归范围”。
所以 FCOS 最后呈现出来的结构就很干净:骨干网络提取特征、FPN 出多尺度特征层、每个 level 共享一个检测头,检测头里并行出三个分支——分类、回归、中心度。论文标题里说的“用图像分割处理目标检测”,本质就是把检测头和分割头统一到了同一套全卷积、逐像素预测框架下。
2. FCOS 核心技术点拆解:逐像素回归、中心度、多尺度分配
2.1 像素点如何绑定到目标框:4D 回归向量
假设某个像素点坐标是 (x, y),它属于某个目标框 B,这个框的四个边界分别是 x1, y1, x2, y2。FCOS 让这个点回归的目标就是:
- l = x - x1
- t = y - y1
- r = x2 - x
- b = y2 - y
一句话:点到四条边的距离。这里有个关键细节:一个点只有在某个目标框内部,才被当成这个框的正样本。如果多个框有重叠区域,也就是同一个点落在多个框里,那这个点对应哪个框呢?FCOS 的最初版本有个简单规则——“面积最小的框获胜”,这其实是一个消歧策略,但重叠严重时依旧会混乱。后续的多尺度 FPN 分配加上回归范围的限制,很大程度缓解了这一问题,这也是为什么论文中强调多尺度分支对重叠检测的重要性。
另外注意,这里的回归目标全是相对量,不需要像 anchor-based 那样做“相对于预设框的偏移量”编码,少掉一组超参,训练时也少了对 anchor 匹配的依赖。实现上直接用 Sigmoid 或者 ReLU 之类的约束保证输出为正即可。论文里的 backbone 输出 stride 是 8、16、32、64、128 这样的递进值,每个位置的回归目标都和原图对应位置的坐标直接相关,训练时不需任何 anchor 匹配步骤。
2.2 centerness 到底解决了什么
你可能会问:既然目标框内所有点都当正样本,那一个中等大小的目标,可能有上千个点同时做回归,这些点里靠近边缘的点和靠近中心的点,难度显然不同。靠近边缘的点看到的局部信息少,很难猜出准确的框边界;如果所有点一视同仁地参与训练,会拖低整体框质量。
中心度就是给每个像素点算一个“抠图权重”。公式为:
centerness = sqrt( (min(l, r) / max(l, r)) * (min(t, b) / max(t, b)) )
这个公式的含义很直观:当 l 和 r 越接近、t 和 b 越接近,也就是点越靠近框中心时,centerness 越接近 1;点越靠近边界时,centerness 趋近于 0。训练时,分类分支的 loss 会乘以 centerness(作为软标签权重);推理时,最终分数 = 分类分数 × centerness,这样高质量、靠近中心的预测框会排在前面,边缘点产生的低质量框被压下去。
我复现时测试过去掉 centerness 分支的效果:mAP 大概掉 2 到 3 个点,尤其小目标掉得更明显。原因也简单,小目标在 feature map 上可能只占几个像素,边缘点占比高,没有 centerness 压着,大量不准确的框直接拉低精度。
2.3 多尺度特征上的回归范围分配
FCOS 用了 FPN 五层特征(P3 到 P7),每层特征图负责不同尺度范围的目标。这里核心是“回归范围”的概念:不是按 anchor 大小,而是按目标框的尺寸划分。论文定义的分配规则是:
- P3 负责尺度 [0, 64] 的目标
- P4 负责 [64, 128]
- P5 负责 [128, 256]
- P6 负责 [256, 512]
- P7 负责 [512, 无穷大)
具体实现时,如果某个目标框的尺寸落在某个范围内,那么这个框内的所有像素点都送到那一层去回归;如果一个目标同时满足多个范围(也就是跨层),则取满足条件的那一层。如果多个尺度的层都覆盖了一个目标怎么办?论文中是仅选择尺寸匹配的那一层参与预测,其余层忽视这个框。这个分配方式实际上替代了传统 anchor matching 的过程,逻辑上简洁很多。
注意:我在实际实现中发现,这里的“目标尺寸”一般取 sqrt(w * h),也就是框的几何平均边长,而不是最大边长或者面积。这个细节影响挺大,取面积容易让大目标跨层,达不到分层消歧的效果;取平均边长则跟各层感受野的比例更匹配。
另外,P6、P7 不是单独从 backbone 抽特征,而是从 P5 做 stride=2 的下采样得到的。这和 RetinaNet 的做法一致。这样做比直接接 backbone 的深层特征更稳定,因为高层语义特征经过多次下采样后空间分辨率太低,直接用于像素级回归不利。
3. 从 Anchor 到 Anchor-Free:FCOS 带动的范式转变
FCOS 发表之后,anchor-free 迅速从“非主流”变成主流研究方向。不是说之前没有 anchor-free 尝试,YOLOv1 就是最早的逐像素检测思路,但当时受限于特征表达能力和训练技巧,效果一直没起来。FCOS 能重新带火这条路线,主要原因是它把三个问题系统性地解决了:
- 用 FPN 分层解决尺度变化
- 用 centerness 解决逐像素预测带来的低质量框
- 用大规模训练技巧和 loss 设计把精度做到超过当时主流的 anchor-based 方法
对比 CenterNet 之类的关键点方法:CenterNet 只预测目标的中心点,然后用中心点回归出宽高。这个思路和人体的关键点检测更像。FCOS 则让框内所有点都参与预测,本质上是“稠密”预测。CenterNet 的优点是后处理简单,不需要 NMS 甚至也可以;缺点是中心点一旦定位偏移就全错,对遮挡、截断目标不够鲁棒。FCOS 是“所有人都来投票”,每个点都贡献一个框,然后通过 NMS 合并,鲁棒性天然更好。这也是为什么 FCOS 在 COCO 这种密集场景下表现更稳的原因。
与 anchor-based 方法对比,FCOS 在训练时的优势尤其明显:
- 不需要计算 IoU 做 anchor 匹配,省掉一个复杂度较高的模块
- 没有 anchor 超参,换数据集时不用重新聚类
- 正样本数量大幅增加,网络看到的“目标上下文”更多,训练效率更高
- 回归目标是直接距离,不需要额外编码解码
我当时在自己的数据集上做过一次对照实验:同样的数据,RetinaNet 需要先聚类出合适的 anchor 配置,FCOS 直接训练即可,最终 mAP 还高 1.5 到 2 个点。这在工程上是非常实在的收益。
4. 训练细节与损失函数设计:复现 FCOS 必看的实操要点
4.1 损失函数的具体形式
FCOS 的 loss 分三部分:
- 分类 loss:使用 focal loss,原因是逐像素预测的正负样本极度不平衡,一个目标框内几十上百个正样本,但全图负样本可能是几十万。Focal loss 的公式为 FL(p_t) = -(1 - p_t)^γ * log(p_t),论文中 γ 取 2、α 取 0.25。
- 回归 loss:使用 IoU loss 或者 GIoU loss。IOU loss 表达式为 L = 1 - IoU(pred, target)。如果直接用 L1 或 Smooth L1 损失,四个距离的数值范围差异较大,需要额外归一化;而 IoU loss 天然对尺度不敏感。论文实现中用的是 GIoU,对极端框的梯度更友好。
- 中心度 loss:使用 BCE(Binary Cross Entropy),就是把 center-ness 当成一个二分类问题在训练(目标是 0-1 之间的软标签)。
总 loss = 分类 loss + 回归 loss(只针对正样本)+ 中心度 loss(只针对正样本,一般权重为 1)。具体实现时,回归分支的输出会通过 exp(x) 映射为正数,并且限制输出范围(比如最大 128 * stride),用于匹配多尺度分配规则。
4.2 训练技巧与超参选择
复现 FCOS 时,有几组超参和训练配置不能乱改:
- 输入尺度:论文默认训练尺寸是 800×1024,短边 800 长边 1024。这个尺寸对显存要求高,8 张卡才能跑 batch size 16。如果只有单卡,可以用短边 640 或 512 先验证逻辑,再放大训练。
- 学习率:初始 LR 0.01(batch size 16),warmup 500 次迭代,采用 SGD + momentum 0.9、weight decay 0.0001。如果用 Adam,我实测效果不如 SGD 稳,建议按论文配置来。
- 骨干网络:默认 ResNet-50 + FPN。骨干的 stride 和空洞卷积配置跟 Detectron2 的默认配置一致。若替换为 ResNet-101 或 Swin Transformer 等,由于特征图通道数变化,需要同步修改 head 的隐藏层维度。
- GroupNorm:论文的 head 里使用了 GroupNorm,而不是常见的 BatchNorm。原因在于 head 是共享的,每个 FPN level 的统计量差异大,BN 在逐 level 上统计容易抖动,GN 对 batch 大小不敏感,更适合这种设计。
建议:如果你只是复现做实验,直接把 head 里的归一化层改成 GN,group 数设为 32。不要改成 BN,否则小 batch 下效果波动比较明显。
4.3 正负样本分配实践
FCOS 的正样本其实就是“落在目标框内的点”。具体实现的时候,需要把目标框从原图坐标映射到 feature map 坐标。假设 backbone stride 为 s,原图框 (x1, y1, x2, y2) 对应到特征图的位置就是 (x1/s, y1/s, x2/s, y2/s)。落在该区域内的特征图位置就是正样本。
这里有一个实现细节:FCOS 论文在计算回归目标时,默认每个框内所有点都是正样本,没有像后续的 ATSS、PAA 那样做自适应选择。如果你发现训练初期 loss 降不下去,可以检查一下是不是有些目标框太小(比如宽度小于 stride),导致映射到特征图上后没有任何正样本。这种情况可以采用“扩框”策略,或者在数据增强阶段保证目标最小尺寸。
5. 实操记录:我用 FCOS 训练一个自定义检测模型的完整过程
拿我最近做过的一个项目举例:目标是检测工业零件,类别只有 6 类,数据量约 3 万张,图像尺寸在 1000×1000 左右,硬件是单张 3090。这种情况直接原版 FP16 训练非常吃紧,我做了几个调整。
第一步,换轻量骨干。ResNet-50 在这个任务上过于奢侈,我换成了 ResNet-18 + FPN,head 保持一致。用 COCO 预训练权重初始化骨干和 FPN,head 部分随机初始化。这个做法能显著加速收敛——预训练权重真的重要,尤其是 FPN 的 lateral 卷积,权重初始化不好会让梯度爆炸。
第二步,调整输入尺寸。工业零件图像是方形,我统一 resize 到 800×800 训练和推理。不需要保持原始长宽比,因为后面测试集也是正方形的;但如果你面对的是自然图像,最好保留原始比例,否则物体畸变会影响回归精度。
第三步,修改输出层类别数。分类分支的通道数从 80 改成 6。这里有个容易踩的坑:有些代码实现里,分类分支会额外加一个背景类,FCOS 的 focal loss 端到端处理正负样本,不需要背景类。如果加了背景类,推理时背景类会被当成目标,mAP 直接崩掉。
训练配置如下:
- 优化器:SGD,初始 LR 0.005,warmup 200 步
- 迭代轮数:12 epoch,在第 8 和第 11 epoch 做 LR decay,乘 0.1
- Batch size:4(单卡),这个规模下 GN 依然稳定
- 数据增强:随机翻转、随机缩放(0.5 到 1.5 倍)、随机裁剪
- Loss 权重:分类 1.0、回归 1.0、centerness 1.0
训练 12 epoch 后,测试集 mAP 到 0.71,对 6 类任务来说够用。如果只是拿 COCO 预训练模型直接推理,不做任何 fine-tune,效果大约只有 0.3 左右,差距主要在类别语义差异上。
6. 常见问题与调参经验:FCOS 复现时的那些坑
6.1 训练初期 loss 很大且不下降
大概率是框太小没有正样本,或者回归输出没有做尺度限制。检查一下是否所有 GT 在映射到 FPN 各层后都有对应的正样本点;另外确认回归分支最后一层是否用了 exp,因为 exp(0) = 1,初始预测的 l、t、r、b 约为 1 个像素,和真实目标(几十上百像素)差距巨大,梯度一开始会非常大,加上 warmup 不够就容易震荡。
6.2 推理结果重复框特别多
FCOS 的推理流程是:在每层特征图的每个位置预测框,然后按置信度阈值筛一遍,再做 NMS。因为逐像素预测特性,一个目标可能产生几十个框,如果 NMS 阈值设得过大(比如 0.7),重复框就会大量残留。建议 NMS 阈值设为 0.5 到 0.6,更低也可以。另外,后处理时一定要先乘 centerness 再排序,不然边缘低质量框可能排在前面,NMS 会把好的框误删掉。
6.3 小目标检测效果差
FCOS 默认的 P3 层负责 0 到 64 像素的目标,如果你的目标大量在 20 像素以下,P3 的 stride 是 8,一个 20×20 的目标在特征图上只有约 2.5×2.5 像素,采样点太少,难以回归。处理办法:提高输入分辨率,或者增加 P2 层(stride 4),把低层特征也引入 FPN。论文附录中提到了 P2 的尝试,对小目标确实有提升,但训练显存和时间成本会涨 30% 左右。
6.4 centerness 数值分布极端
训练过程中可以打印 centerness 的数值分布。如果大多数值集中在 0 和 1 附近,说明正样本点要么在目标中心附近,要么在边缘附近,中间值很少。这会导致中心度分支学不到连续语义。解决办法是降低 focal loss 对难样本的压制力度(调低 γ),或者提高中心度分支的 loss 权重到 1.5 左右。
6.5 多卡训练时 BatchNorm 不一致
如果不用 GN 而坚持用 BN,多卡同步时 BN 的统计量会有偏差。我的建议是:head 用 GN,backbone 用 BN(因为 backbone 是预训练权重,BN 参数已经收敛)。这套组合我试下来是最省心、效果最平衡的配置。
7. 从 FCOS 出发:检测头的结构与代码实现骨架
FCOS 的检测头结构不长,核心代码概括起来就是:输入一个特征层,过一个 4 层 256 通道的卷积,然后分三个分支输出。
import torch import torch.nn as nn class FCOSHead(nn.Module): def __init__(self, in_channels=256, num_classes=80): super().__init__() self.num_classes = num_classes # 共享卷积子网 self.convs = nn.Sequential( nn.Conv2d(in_channels, in_channels, 3, padding=1), nn.GroupNorm(32, in_channels), nn.ReLU(inplace=True), nn.Conv2d(in_channels, in_channels, 3, padding=1), nn.GroupNorm(32, in_channels), nn.ReLU(inplace=True), nn.Conv2d(in_channels, in_channels, 3, padding=1), nn.GroupNorm(32, in_channels), nn.ReLU(inplace=True), nn.Conv2d(in_channels, in_channels, 3, padding=1), nn.GroupNorm(32, in_channels), nn.ReLU(inplace=True), ) # 分类分支:每个位置预测 num_classes 个分数 self.cls_logits = nn.Conv2d(in_channels, num_classes, 3, padding=1) # 回归分支:每个位置预测 4 个距离 self.reg_logits = nn.Conv2d(in_channels, 4, 3, padding=1) # 中心度分支:每个位置预测 1 个分数 self.centerness = nn.Conv2d(in_channels, 1, 3, padding=1) # 初始化:分类分支 bias 为负值,减少初期正样本损失波动 nn.init.normal_(self.cls_logits.weight, std=0.01) nn.init.constant_(self.cls_logits.bias, -np.log((1 - 0.01) / 0.01)) def forward(self, x): x = self.convs(x) cls = self.cls_logits(x) reg = self.reg_logits(x) cent = self.centerness(x) return cls, reg, cent这里的初始化细节值得展开说一下:分类分支的 bias 初始化为一个较小的负值(对应 0.01 的先验概率),可以让训练初期不至于正样本 loss 过大,这是 RetinaNet 和 FCOS 都采用的经验设置。回归分支的输出没有做额外激活,IOU loss 可以接受负距离的反向传播,但推理时需要 exp 或者 relu 确保正数,建议在 loss 计算处统一处理,不要在模型 forward 里先 exp。
训练时,解码过程很简单:给定特征图位置 (i, j),原图坐标是 ( (j + 0.5) * stride, (i + 0.5) * stride )。然后预测的 l、t、r、b 分别减去或加上就得到最终框。后处理就是“所有层 → 阈值筛选 → 乘 centerness → NMS”四步。
8. 一点个人体会
FCOS 这种把检测往分割上靠的思路,看着简单,但它背后其实是对“目标检测到底在预测什么”的一次重新定义:不是预测“哪里可能有框”,而是预测“每个像素属于哪个物体”。这个思维转换的价值,比 FCOS 本身刷的精度更重要。之后很多工作(比如 ATSS、PAA)又回头去研究“如何自动选取正样本”,本质上也是在补 FCOS 简化后的短板。我在实际项目中用 FCOS 的最大体会是:它好调、稳、泛化好,换数据集时不需要重新设计 anchor,省下来的时间足够你再跑三组消融实验。如果你之前只接触过 anchor-based 的检测器,FCOS 值得作为你进入 anchor-free 世界的第一篇论文去精读。