FCOS: Fully Convolutional One-Stage Object Detection

论文: FCOS: Fully Convolutional One-Stage Object Detection (ICCV 2019)
作者: Zhi Tian, Chunhua Shen, Hao Chen, Tong He ( Adelaide / 商汤 )
代码: tianzhi0549/FCOS / AdelaiDet

一句话总结:FCOS 抛弃 anchor,把目标检测变成”逐像素预测”–特征图上每个位置直接回归到所在 GT 框四条边的距离 (l, t, r, b)。 它证明了只要配合 FPN 多级预测 + center-ness,anchor-free 检测器就能追平甚至超过 anchor-based。


一、概述:为什么要有 anchor-free

主流检测器(RetinaNet / SSD / YOLOv2+ / Faster R-CNN)都依赖预设 anchor。anchor 的问题:

  1. 超参敏感:anchor 的尺寸、宽高比、数量对性能影响大(RetinaNet 在 COCO 上仅调这些就能差 4% mAP);
  2. 泛化差:固定宽高比难以适应新任务的物体形状(尤其小目标);
  3. 负样本爆炸:为高召回需密集放 anchor(短边 800 输入下 FPN 产生 >180K 个),加剧正负不平衡;
  4. 计算复杂:训练时要算所有 anchor 与 GT 的 IoU 匹配,占内存。

FCOS 的思路:像语义分割一样,把特征图上每个位置(像素)当作训练样本,直接回归它到所属物体框四条边的距离。无需 anchor、无需 proposal,只需 NMS 后处理。

与 YOLOv1 的区别:YOLOv1 也是 anchor-free,但只用中心区域的点预测,召回低;FCOS 用框内所有点预测,召回与 anchor-based 持平。与 CornerNet/CenterNet 的区别:它们基于关键点(角点/中心点),FCOS 是逐像素回归,更像实例分割。


二、核心思想:逐像素回归 (l, t, r, b)

设某层特征图 $F_i$ 的 stride 为 $s$,GT 框 $B_i = (x_0, y_0, x_1, y_1, c)$(左上、右下、类别)。

对特征图上每个位置 $(x,y)$,先映射回原图坐标:

$$
(x_{img}, y_{img}) = \left(\left\lfloor \tfrac{s}{2}\right\rfloor + x\cdot s,\ \left\lfloor \tfrac{s}{2}\right\rfloor + y\cdot s\right)
$$

这个映射点近似落在该位置的感受野中心。

如果该位置落在某个 GT 框内部,则它是正样本,负责预测该 GT。它的回归目标是到框四条边的距离:

$$
l^* = x_{img} - x_0,\quad t^* = y_{img} - y_0,\quad r^* = x_1 - x_{img},\quad b^* = y_1 - y_{img}
$$

四个值都 > 0;网络最后一层用 exp(x)(或 ReLU + norm)把输出映射到 $(0, +\infty)$。

歧义问题:如果一个位置落在多个 GT 框内(重叠物体),它该回归哪个?FCOS 的策略:选面积最小的 GT 作为目标。但这只是兜底,真正的解法是 FPN 多级预测(第六节)。

💡 FCOS 能用尽可能多的前景样本训练回归器(框内每个像素都是正样本),而 anchor-based 只把高 IoU 的 anchor 当正样本–这是 FCOS 优于 anchor-based 的重要原因之一。


三、网络架构

1
2
3
4
5
6
7
8
9
输入 (3×H×W)

Backbone: ResNet-50/101 + FPN
↓ 输出 5 级特征图 P3,P4,P5,P6,P7 (stride 8,16,32,64,128)
↓↓↓↓↓
每个特征图接一个共享权重的 Head (FCOSHead), 分 3 个分支:
├─ cls_tower (4×Conv+GN+ReLU) ─→ cls_logits : C×H×W (C 个二分类, 不含背景)
├─ bbox_tower(4×Conv+GN+ReLU) ─→ bbox_pred : 4×H×W (l,t,r,b)
└─ (centerness 共享 cls_tower) ─→ centerness : 1×H×W (中心度)

要点

  • Backbone 取 $C_3, C_4, C_5$,FPN 横向连接得 $P_3, P_4, P_5$;$P_6, P_7$ 由 $P_5$ 经 stride=2 卷积得到(注意:来自 $P_5$ 而非 $C_5$,参数更少、语义更强)。
  • Head 用 GroupNorm(不是 BN),因为 head 通道少、用 GN 训练更稳。
  • 5 个特征图共享同一组 Head 权重(参数高效 + 提性能)。
  • 网络每位置输出 = C(分类) + 4(回归) + 1(center-ness),比 RetinaNet(每位置 9 anchor)少约 9 倍。

四、🔥 数据与目标构造:YOLO 格式如何转 FCOS

这是理解 FCOS 最容易卡住的点。结论先说:FCOS 不需要你在磁盘上准备”特殊的 per-pixel 标注”。l, t, r, b 这些目标是训练时从 GT 框实时算出来的,不存盘。 所以”YOLO 格式转 FCOS”本质上只是把 YOLO 标注转成框架能读的格式(通常是 COCO JSON),剩下的 FCOS 自动处理。

4.1 先理清两个层次

层次 内容 谁来做
磁盘标注格式 每张图的框 + 类别 你准备(YOLO / COCO / VOC 等)
训练目标 (l,t,r,b + class + centerness) 每个特征图位置的目标值 框架训练时实时计算,不存盘
  • FCOS 官方代码 / mmdetection 的 FCOS,输入端和 RetinaNet 完全一样:吃 COCO 格式标注(image + annotations{bbox:[x,y,w,h], category_id})。
  • 你看到的 l, t, r, b、center-ness target,都是在 loss / target assignment 代码里,每个 batch 从 GT 框现算的。

所以别去找”FCOS 格式数据集生成工具”——不存在那种东西。你要做的只有一件事:把 YOLO 标注转成 COCO JSON

4.2 YOLO 格式 vs COCO 格式

YOLO 格式 COCO 格式(FCOS 框架输入)
存储 每图一个 .txt(同名) 一个 JSON 汇总所有图
每条标注 class cx cy w h category_id + bbox=[x,y,w,h]
坐标制 归一化 (0~1), cxcywh 绝对像素, xywh(左上角 + 宽高)
类别 0~C-1 整数 需映射到 category_id(通常 1~C)

4.3 坐标转换(核心一步)

YOLO 一行 class cx cy w h(归一化 cxcywh)→ 绝对 xyxy:

1
2
3
4
设图像宽 W, 高 H
cx_a = cx * W, cy_a = cy * H, w_a = w * W, h_a = h * H
x1 = cx_a - w_a/2, y1 = cy_a - h_a/2
x2 = cx_a + w_a/2, y2 = cy_a + h_a/2
1
2
3
4
5
6
7
8
9
10
import numpy as np

def yolo_to_xyxy(line, img_w, img_h):
"""YOLO 一行 'class cx cy w h'(归一化) -> (cls, x1,y1,x2,y2)(绝对像素)"""
c, cx, cy, w, h = map(float, line.split())
x1 = (cx - w/2) * img_w
y1 = (cy - h/2) * img_h
x2 = (cx + w/2) * img_w
y2 = (cy + h/2) * img_h
return int(c), x1, y1, x2, y2

4.4 YOLO 数据集 → COCO JSON 转换脚本

下面这个脚本把一个 YOLO 数据集目录(images/ + labels/,每图同名 .txt)转成 FCOS/mmdetection 直接能用的 COCO JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import json, glob, os
from PIL import Image

def yolo_to_coco(images_dir, labels_dir, out_json, class_names):
"""
images_dir: 存放 *.jpg/.png 的目录
labels_dir: 存放同名 *.txt 的目录 (YOLO 格式)
out_json: 输出 COCO JSON 路径
class_names: 类别列表, 如 ['person','car',...]
"""
coco = {"images": [], "annotations": [], "categories": []}
# categories: category_id 从 1 开始 (COCO 惯例, 0 留给背景)
for i, name in enumerate(class_names):
coco["categories"].append({"id": i+1, "name": name})

ann_id = 1
for img_id, img_path in enumerate(sorted(glob.glob(os.path.join(images_dir, "*.*"))), 1):
if not img_path.lower().endswith((".jpg", ".png", ".jpeg")):
continue
im = Image.open(img_path)
W, H = im.size
coco["images"].append({"id": img_id, "file_name": os.path.basename(img_path),
"width": W, "height": H})

label_path = os.path.join(labels_dir, os.path.splitext(os.path.basename(img_path))[0] + ".txt")
if not os.path.exists(label_path):
continue
for line in open(label_path):
line = line.strip()
if not line:
continue
c, x1, y1, x2, y2 = yolo_to_xyxy(line, W, H) # 4.3 的函数
w, h = x2 - x1, y2 - y1
coco["annotations"].append({
"id": ann_id, "image_id": img_id,
"category_id": int(c) + 1, # YOLO cls 0~C-1 -> COCO 1~C
"bbox": [x1, y1, w, h], # COCO 用 xywh
"area": w * h, "iscrowd": 0
})
ann_id += 1

json.dump(coco, open(out_json, "w"), ensure_ascii=False)
print(f"done: {len(coco['images'])} images, {len(coco['annotations'])} anns -> {out_json}")

# 用法
# yolo_to_coco("dataset/images/train", "dataset/labels/train",
# "dataset/annotations/train.json", ["cat","dog","person"])

转完得到 train.json / val.json,在 mmdetection 的 FCOS config 里把 ann_file 指过去即可–之后 l,t,r,b 全部自动算,你不用再管。

4.5 FCOS 的目标分配代码(l, t, r, b 是怎么算出来的)

这是 FCOS 最核心的部分(来自 AdelaiDet 实现)。看懂这段,就彻底明白”FCOS 的数据怎么造”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def compute_targets_for_locations(self, locations, targets, size_ranges, num_loc_list):
"""
locations: [N, 2] 所有 FPN 层 cat 后的位置(已映射回原图坐标)
targets: GT (detectron2/mmdet 格式)
size_ranges:[N, 2] 每个位置所在 FPN 层的回归 size 限制 (min, max)
"""
labels, reg_targets = [], []
xs, ys = locations[:, 0], locations[:, 1]

for im_i in range(len(targets)):
bboxes = targets[im_i].gt_boxes.tensor # [M, 4] xyxy
labels_per_im = targets[im_i].gt_classes # [M]

if bboxes.numel() == 0: # 无 GT -> 全负样本
labels.append(labels_per_im.new_zeros(locations.size(0)) + self.num_classes)
reg_targets.append(locations.new_zeros((locations.size(0), 4))); continue

area = targets[im_i].gt_boxes.area() # [M] 每个 GT 面积

# ★ 关键:每个位置到每个 GT 四条边的距离 l,t,r,b
l = xs[:, None] - bboxes[:, 0][None] # [N, M]
t = ys[:, None] - bboxes[:, 1][None]
r = bboxes[:, 2][None] - xs[:, None]
b = bboxes[:, 3][None] - ys[:, None]
reg_targets_per_im = torch.stack([l, t, r, b], dim=2) # [N, M, 4]

# 位置是否在 GT 中心区域内 (center sampling) 或框内
is_in_boxes = self.get_sample_region(...) if self.center_sample \
else (reg_targets_per_im.min(dim=2)[0] > 0)

# 位置是否落在该 FPN 层的 size 限制内: m_{i-1} < max(l,t,r,b) < m_i
max_reg = reg_targets_per_im.max(dim=2)[0] # [N, M]
is_cared_in_level = (max_reg >= size_ranges[:, [0]]) & (max_reg <= size_ranges[:, [1]])

# 把不满足条件的 GT 面积置 INF, 再取最小面积 GT -> 每个位置匹配一个 GT(或无)
loc_area = area[None].repeat(len(locations), 1) # [N, M]
loc_area[is_in_boxes == 0] = float('INF')
loc_area[is_cared_in_level == 0] = float('INF')
loc_min_area, loc_gt_inds = loc_area.min(dim=1) # [N]

# 取出每个位置的回归 target 和类别
reg_targets_per_im = reg_targets_per_im[range(len(locations)), loc_gt_inds]
labels_per_im = labels_per_im[loc_gt_inds]
labels_per_im[loc_min_area == float('INF')] = self.num_classes # 负样本

labels.append(labels_per_im)
reg_targets.append(reg_targets_per_im)

center-ness target 由回归 target 直接算出(无需额外标注):

1
2
3
4
5
def compute_ctrness_targets(reg_targets):
lr = reg_targets[:, [0, 2]] # left, right
tb = reg_targets[:, [1, 3]] # top, bottom
ctrness = (lr.min(-1)[0] / lr.max(-1)[0]) * (tb.min(-1)[0] / tb.max(-1)[0])
return torch.sqrt(ctrness)

📌 总结这条链路:磁盘上只有 bbox(xyxy) + class → dataloader 取出 → compute_targets_for_locations 对每个特征图位置算 l,t,r,b + 分配类别 → compute_ctrness_targets 算 center-ness → 送入 loss。你只需要喂框,per-pixel 目标全是代码算的。

4.6 位置生成 + 中心采样 + FPN 分级

位置生成compute_locations):对每层特征图 (H×W, stride s),生成所有位置坐标并映射回原图:

1
2
3
4
5
6
def compute_locations_per_level(h, w, stride, device):
shifts_x = torch.arange(0, w * stride, step=stride, device=device)
shifts_y = torch.arange(0, h * stride, step=stride, device=device)
sy, sx = torch.meshgrid(shifts_y, shifts_x)
locations = torch.stack((sx.reshape(-1), sy.reshape(-1)), dim=1) + stride // 2
return locations # [H*W, 2], 即每位置映射回 (x*s + s/2, y*s + s/2)

中心采样(center sampling):并非”框内所有点”都算正样本,而是只取以 GT 中心为中心、半径 r·s(r=1.5)的子区域内的位置为正。这能减少远离中心的低质量样本,进一步降低歧义(实现时保证子区域不超出 GT)。

FPN 分级:每个 GT 在哪一层预测,由 max(l,t,r,b) 落在哪个区间决定(详见第六节)。阈值 $m_2..m_7 = 0, 64, 128, 256, 512, \infty$。


五、Center-ness 分支

问题:远离物体中心的位置(靠近框边缘)会预测出低质量框–这些位置感受野只看到物体一小部分,且 l,t,r,b 分布不均(如 l=1, r=100),预测不准。这些”高分类分但低 IoU”的框会拉低 precision。

Center-ness:在回归分支末尾加一个分支,预测位置到物体中心的归一化距离,值域 [0,1]:

$$
centerness^* = \sqrt{\frac{\min(l^, r^)}{\max(l^, r^)} \times \frac{\min(t^, b^)}{\max(t^, b^)}}
$$

  • 位置在中心 → 左右/上下接近 → center-ness → 1;
  • 位置靠边 → 一项很小 → center-ness → 0。

作用

  • 训练用 BCE loss(只在正样本上算);
  • 测试时,最终分数 = $\sqrt{p_{x,y} \times centerness_{x,y}}$(分类分 × 中心度),用于排序。远离中心的框分数被压低,更易被 NMS 滤除。

实验显示 center-ness 约提升 1 个点 AP。代价是不引入任何超参(是个软阈值,网络自己学)。

⚠️ 局限:center-ness 训练时和分类分开,测试时才相乘,存在训练-测试不一致。后续 Generalized Focal Loss 把质量估计和分类联合训练,且用 IoU 代替 center-ness 评估框质量更好(IoU label 通常比 center-ness 大,更易召回):


六、多级预测(FPN)解决歧义与低召回

FCOS 不用 anchor 带来两个风险:① 大 stride 导致 BPR(最佳召回)低;② 重叠框导致歧义。FPN 多级预测同时解决两者。

6.1 按回归范围分级

不同大小的 GT 分配到不同 FPN 层:每个位置算 max(l,t,r,b),落在哪个区间就由哪层负责:

$$
m_{i-1} < \max(l^, t^, r^, b^) < m_i
$$

FPN 层 stride 回归范围 max(l,t,r,b) 目标
P3 8 (0, 64) 小目标
P4 16 (64, 128) 中小
P5 32 (128, 256)
P6 64 (256, 512)
P7 128 (512, ∞) 超大

因为重叠物体大概率尺寸不同,会被分到不同层,歧义大幅减少。同级仍冲突时,仍按”面积最小 GT”兜底:

6.2 回归范围差异的处理

不同层回归值范围差很大(P3 是 0~64,P7 是 512+),但 Head 权重共享。早期 FCOS 用可学习 scale $s_i$:输出 $= \exp(s_i \cdot x)$ 调整指数基数。新版直接把回归 target 除以 stride 归一化(norm_reg_targets),等价且更简洁。

6.3 BPR 与歧义统计

FCOS 的 BPR(最佳可能召回)与 RetinaNet 基本持平:

歧义样本占比(采用中心采样 + FPN 后仅 2.66%):

结论:FCOS 的召回并不比 anchor-based 差,歧义也已压到很低。但对同尺度密集重叠物体(如低空行人、密集人群)仍不友好–FCOS 的分级只能解决不同尺寸间的重叠。


七、损失函数

$$
L = \frac{1}{N_{pos}}\sum_{x,y} L_{cls}(p_{x,y}, c^{x,y}) + \frac{\lambda_1}{N{pos}}\sum_{x,y} \mathbf{1}_{c^{x,y}>0} L{reg}(t_{x,y}, t^{x,y}) + \frac{\lambda_2}{N{pos}}\sum_{x,y} \mathbf{1}_{c^{x,y}>0} L{ctr}(centerness_{x,y}, centerness^*_{x,y})
$$

分支 损失 说明
分类 $L_{cls}$ Focal Loss C 个二分类,所有位置参与
回归 $L_{reg}$ IoU / GIoU Loss 仅正样本,把 l,t,r,b 当整体算 IoU
中心度 $L_{ctr}$ BCE Loss 仅正样本
  • $\mathbf{1}_{c^*>0}$:正样本指示函数,回归和中心度只在正样本上算;
  • $N_{pos}$:正样本数归一化;$\lambda_1, \lambda_2$ 论文取 1;
  • 回归用 GIoU(优于 IoU):IoU 在两框不相交时梯度为 0,GIoU 能反映不相交时的距离,$-1 \le GIoU \le 1$,loss = 1 - GIoU。

八、训练与推理

训练

  • ResNet-50 backbone,ImageNet 预训练;
  • SGD,lr=0.01,batch=16,weight_decay=1e-4,momentum=0.9;
  • 输入短边 800、长边≤1333;数据增强:随机翻转 + 多尺度;
  • head 用 GroupNorm。

推理

  1. 单次前向 → 每层每位置得到 (分类分, l,t,r,b, centerness);
  2. 分类分 > 0 的位置为正,用 l,t,r,b 解码出框;
  3. 最终分数 = $\sqrt{cls \times centerness}$;
  4. 跨 5 层汇总 → NMS(0.5)。

center-ness 只在推理打分/NMS 时用,不参与框的坐标解码。


九、性能

9.1 FCOS vs RetinaNet

  • FCOS(38.9 AP)明显高于原生 RetinaNet(35.9);
  • 但当 RetinaNet 也采用 FCOS 的策略(GN、GIoU、P6/P7 从 P5 出),差距缩小到 ~1 点;
  • 去掉 center-ness 后两者基本持平。
  • ATSS 进一步证明:anchor-free 与 anchor-based 的差异,本质是正负样本定义策略的不同,而非架构。FCOS 的贡献在于把这件事讲清楚、并给出了扎实方案。

9.2 主要结果

  • ResNet-101 + 多尺度训练:~40+ AP;
  • ResNeXt-64x4d-101:44.7 AP,超越当时一阶段 SOTA。

十、优缺点与总结

优点 ✅

  1. 彻底去 anchor:免去 anchor 超参设计、IoU 匹配计算,训练内存减半
  2. 结构简单灵活:易扩展到实例分割、关键点检测
  3. 召回不输 anchor-based:BPR ~98.95%,框内全像素当正样本,回归更充分
  4. center-ness 优雅:无超参抑制低质量框
  5. 范式意义大:与 ATSS 一起,揭示了”样本定义策略”才是关键

局限 ❌

  1. 小网络难训练:易不收敛,依赖 GN 等技巧
  2. center-ness 训测不一致:训练分开、测试相乘(GFL 改进)
  3. 同尺度密集重叠弱:分级只解决不同尺寸重叠,低空行人/密集人群不友好
  4. 仍依赖 NMS:未摆脱后处理(DETR 才去除)
  5. 对数据集敏感:需视场景选用

总结

FCOS 的精髓是把检测变成逐像素回归:每个特征图位置直接预测到框四条边的距离 (l,t,r,b)。它的数据准备其实比 anchor-based 更简单–磁盘上只需普通的框标注(COCO/YOLO 都行),per-pixel 的 l,t,r,b + centerness target 全是训练时从框实时算的。理解了 compute_targets_for_locations 这段代码,就理解了 FCOS 的”数据”是怎么来的。

📌 回到你的疑问:把 YOLO 标注转成 COCO JSON(第四节脚本),喂给 FCOS 框架即可。框架会在训练时自动把每个框展开成它覆盖的所有像素位置的 l,t,r,b 回归目标 + 类别 + center-ness。 你不需要、也不应该手工去生成 per-pixel 标签。


十一、相关链接

  • 📝 [[RetinaNet 详解]] - FCOS 架构与 RetinaNet 几乎一致,可对照阅读;ATSS 的对照也在此
  • 📝 [[YOLOv3 详解]] - 另一类检测器,anchor-based
  • 📝 [[YOLO 系列总览]] - 检测器谱系
  • 📝 [[YOLO 核心概念]] - FPN / IoU / NMS 共享概念
  • 📋 [[10.clippings/感知算法/2D检测/单阶段/FCOS/FCOS算法的原理与实现]] - 知乎 (小小将, 含 compute_targets 核心代码)
  • 📋 [[10.clippings/感知算法/2D检测/单阶段/FCOS/FCOS算法详解]] - 知乎 (辣蜗, 含完整网络结构代码)
  • 📋 [[10.clippings/感知算法/2D检测/单阶段/FCOS/简单剖析FCOS原理及优势]] - 知乎 (李Nik, GT 编码讲解清晰)
  • 📋 [[10.clippings/感知算法/2D检测/单阶段/FCOS/论文阅读笔记10:FCOSFully Convolutional One-Stage Object Detection]] - 论文翻译笔记
  • 📋 [[10.clippings/感知算法/2D检测/单阶段/FCOS/详解FCOS《FCOS Fully Convolutional One-Stage Object Detection》]] - cnblogs (Head 解码详解)
  • 📋 [[10.clippings/感知算法/2D检测/单阶段/FCOS/FCOS算法详解-CSDN博客]] - CSDN
  • 📋 [[10.clippings/感知算法/2D检测/单阶段/FCOS/19.04 FCOS]] - docsaid
  • 📋 论文原文: arxiv.org/abs/1904.01355
  • 📋 官方代码: tianzhi0549/FCOS