同一个目标该交给哪层负责?FPN 的尺度分配与跨层去重
从密集检测的责任歧义出发,手算 FCOS 尺度范围、中心采样与跨层 NMS,并用 PyTorch 与 torchvision 0.28 检查训练—推理坐标契约。
上一篇已经得到了语义更一致的 P3...P5 特征金字塔(Feature Pyramid)。但“每层都能看见目标”并不等于“每层都应该学这个目标”。若一辆车同时在 P3、P4 和 P5 上被标为正样本,训练时三层会争抢责任;推理时又会吐出多个几乎重合的框。
本文只讲透这一个接口问题:以无锚框检测器 FCOS(Fully Convolutional One-Stage Object Detection)为例,看清一个原图目标如何按回归距离分给某个 FPN 层,各层预测如何还原到同一坐标系,以及非极大值抑制(Non-maximum Suppression, NMS)如何清理跨层重复框。
01 FPN 输出之后,还缺一份“责任表”#
假设原图是 [N,3,256,256],FPN 通道数都是 :
| 层级 | 步幅 | 特征形状 | 空间位置数 | 更适合的回归距离示例 |
|---|---|---|---|---|
| P3 | 8 | [N,256,32,32] | 1024 | |
| P4 | 16 | [N,256,16,16] | 256 | |
| P5 | 32 | [N,256,8,8] | 64 |
密集检测头(Dense Detection Head)会在每个空间位置输出类别、边界框和中心度。若有 个类别,一层的数据流可写成:
P_l [N,D,H_l,W_l]
│
├─ 分类分支 ──► cls_l [N,K,H_l,W_l] ─► [N,H_l W_l,K]
├─ 回归分支 ──► box_l [N,4,H_l,W_l] ─► [N,H_l W_l,4]
└─ 中心分支 ──► ctr_l [N,1,H_l,W_l] ─► [N,H_l W_l,1]text金字塔解决了“特征从哪里来”,但没有自动定义以下契约:
- 训练时,哪些位置是某个真值框(Ground-truth Box)的正样本;
- 一个目标应由哪个尺度层负责;
- 推理时,各层的局部预测怎样还原为原图像素坐标。
这三条若有一条在数据增强、训练和推理之间不一致,张量形状仍可完全正确,模型却会学到错位的标签。
02 先把网格位置送回原图坐标系#
对步幅为 的特征层,一种常见约定是把网格 (i,j) 的预测点放在原图中:
因此 P3 的 (i=7,j=7) 对应 ,而不是 。不同检测实现可能使用不同的网格原点;关键不是强行选 0.5,而是标签生成、框解码和可视化共用同一约定。
设真值框为 ,且不含逆序或零面积框。点 到四条边的距离是:
它们都大于 0 时,该点严格位于框内。反过来,已知点和预测距离 ,就能解码为:
这里的边界框与预测点必须在同一度量下。若回归目标除以了步幅,推理解码前必须乘回同一步幅。
03 只要点在框内,为什么还不够?#
一个大框可能包含数百个 P3 位置。若它们全是正样本,靠近边缘的点必须预测非常不对称的长距离,而邻近物体的重叠区又容易出现归属歧义。
FCOS 引入中心采样(Center Sampling):候选点除了位于真值框内,还要靠近框中心 。若半径系数为 ,常用条件是:
这是以 距离定义的中心方形,不是欧氏圆。半径随层级步幅放大,使不同密度网格拥有近似的“几个单元”责任范围。
04 尺度范围怎样把目标分给某一层?#
对某个候选点和真值框,定义回归难度的尺度:
然后为每个层级设定责任区间 ,只有 时才允许匹配。它不是直接用目标面积或长边,而是看“这个位置需要回归多远”。
torchvision 0.28 当前 FCOS 源码中,默认每层的点由一个边长等于步幅的单锚框表示,匹配下界为 ,上界为 ;最浅层下界改为 0,最深层上界改为 。因此用 P3/P4/P5 的简化金字塔时,就得到表中的 、 和 。
小回归距离 大回归距离
0 64 128 +∞
├───── P3 负责 ────┤───── P4 负责 ────┤─────── P5 负责 ───────►
网格密,定位细 中等尺度 网格稀,感受野大text这些边界不是物理定律。更换输入分辨率、金字塔层数、数据集目标分布或匹配器后,都要重新检查正样本是否均衡,不能只复制一组阈值。
05 用一个框手算 P3 与 P4 的归属#
设真值框为:
在 P3 上取预测点 ,步幅 ,中心半径系数 。四边距离为:
- 四个距离都为正,点在框内;
- ,通过中心采样;
- ,落在 P3 责任区间。
因此 是该框的 P3 正样本。
再看 P4 上靠近中心的点 ,:
它在框内,且 ,却不满足 P4 的 。这正是尺度分配的作用:P4 “看得到”这个框,但不对它负责。
若同一个点同时满足多个重叠真值框,torchvision FCOS 当前会匹配面积更小的那一个。这个冲突规则也是标签契约的一部分。
06 不调用检测器,先写出匹配本体#
下面的函数把所有层的点展平后一次匹配。points 形状为 [P,2],boxes 为 [M,4],regress_ranges 为 [P,2];中间距离张量是 [P,M,4]。
import torch
def assign_fcos_points(
points: torch.Tensor, # [P, 2], image-space (x, y)
strides: torch.Tensor, # [P]
regress_ranges: torch.Tensor, # [P, 2], image-space (low, high)
boxes: torch.Tensor, # [M, 4], (x0, y0, x1, y1)
labels: torch.Tensor, # [M], int64
center_radius: float = 1.5,
) -> tuple[torch.Tensor, torch.Tensor]:
assert points.ndim == 2 and points.shape[1] == 2
assert boxes.ndim == 2 and boxes.shape[1] == 4
assert len(boxes) > 0 # empty-target images should take a separate background path
assert strides.shape == (len(points),)
assert regress_ranges.shape == (len(points), 2)
assert labels.dtype == torch.int64 and labels.shape == (len(boxes),)
px, py = points[:, None, 0], points[:, None, 1] # [P, 1]
x0, y0, x1, y1 = boxes[None].unbind(dim=-1) # each [1, M]
distances = torch.stack(
(px - x0, py - y0, x1 - px, y1 - py),
dim=-1,
) # [P, M, 4]
inside_box = distances.amin(dim=-1) > 0 # [P, M]
centers = (boxes[:, :2] + boxes[:, 2:]) / 2 # [M, 2]
center_delta = (points[:, None] - centers[None]).abs()
inside_center = center_delta.amax(dim=-1) < (
center_radius * strides[:, None]
) # [P, M]
max_distance = distances.amax(dim=-1) # [P, M]
low = regress_ranges[:, None, 0]
high = regress_ranges[:, None, 1]
inside_scale = (max_distance > low) & (max_distance < high)
valid = inside_box & inside_center & inside_scale
areas = (
(boxes[:, 2] - boxes[:, 0])
* (boxes[:, 3] - boxes[:, 1])
) # [M]
candidate_areas = areas[None].expand(len(points), -1).clone()
candidate_areas.masked_fill_(~valid, float("inf"))
best_area, best_gt = candidate_areas.min(dim=1) # each [P]
matched_labels = labels[best_gt].clone()
background = torch.isinf(best_area)
matched_labels[background] = -1
row = torch.arange(len(points), device=points.device)
matched_distances = distances[row, best_gt].clone() # [P, 4]
matched_distances[background] = 0
return matched_labels, matched_distances
points = torch.tensor([[60.0, 60.0], [56.0, 56.0]])
strides = torch.tensor([8.0, 16.0])
ranges = torch.tensor([[0.0, 64.0], [64.0, 128.0]])
boxes = torch.tensor([[36.0, 36.0, 92.0, 84.0]])
labels = torch.tensor([2], dtype=torch.int64)
matched_labels, distances = assign_fcos_points(
points, strides, ranges, boxes, labels
)
assert matched_labels.tolist() == [2, -1]
assert distances[0].tolist() == [24.0, 24.0, 32.0, 24.0]python这段代码刻意不计算分类损失:匹配先决定“谁是正样本”,损失再决定“错多少要罚多重”。把两者混成一个黑盒时,正样本全空往往只表现为损失“很稳定”。
07 训练时的完整数据流#
原图 + 原图框
│ resize / crop / pad:图像和框必须同步变换
▼
训练坐标系中的 image [N,3,H,W] 与 boxes [M,4]
│
├─► backbone + FPN ─► P3...P7 ─► cls / ltrb / centerness
│ │
└─► 生成各层 points ─► 中心采样 + 尺度匹配 ─┼─► losses
│
loss.backward() ◄─ head ◄─ FPN ◄─ backbone ◄───────────┘text对每个匹配点,常见的中心度目标为:
在框中心, 且 , 接近 1;越贴近边缘则越小。torchvision FCOS 当前用分类损失、GIoU 框回归损失和中心度损失训练三条分支,并按前景点数归一化。
训练前至少要记录每层:num_points、num_positive、positive / gt、最大回归距离分布和三项损失。总损失下降时,P5 可能仍然从未得到正样本。
08 推理时为什么还会出现跨层重复框?#
尺度匹配是训练目标,不是推理时的硬门。学到的决策边界不会严格停在 64 或 128 像素,相邻层也可能对同一物体输出高分框。
常见后处理数据流是:
每个层级:
logits + centerness ─► score ─► 阈值过滤 ─► level top-k
ltrb + point ─► 解码为训练图像坐标 ─► clip
│
所有层 boxes / scores / labels ─► concat ─► 分类别 NMS
│
└─► 缩放回原始图像坐标texttorchvision FCOS 当前将每个候选的分类概率 与中心度 合成为 ,逐层做阈值和 topk_candidates 过滤,再拼接全部层级。这样先减少候选数,避免对数万个低分框直接做 NMS。
09 用两个框手算 NMS#
设 P3 和 P4 对同一辆车分别输出:
面积为 , 面积为 。交集宽高为 和 ,交集面积为 2592,所以交并比(Intersection over Union, IoU)为:
若 NMS 阈值是 0.6,先保留得分更高的 ,再因 删除 。层级编号不应阻止这次比较:两个框虽来自不同 FPN 层,但类别相同、坐标系相同,就是跨层重复候选。
反之,若两个高重叠框分别预测为“自行车”与“摩托车”,分类别 NMS 不会互相抑制。是否需要跨类别竞争属于另一个任务决策。
10 用 torchvision 0.28 当前官方 API 做跨层去重#
torchvision.ops.batched_nms ↗ 接收 boxes[N,4]、scores[N] 和用于分组的 idxs[N]。只有 idxs 相同的框会互相抑制;因此这里传类别编号,不要传 FPN 层级编号。
import torch
from torchvision.ops import batched_nms, box_iou
boxes = torch.tensor([
[36.0, 36.0, 92.0, 84.0], # from P3
[38.0, 35.0, 94.0, 85.0], # from P4, same object
[38.0, 35.0, 94.0, 85.0], # another class hypothesis
]) # [N=3, 4]
scores = torch.tensor([0.90, 0.82, 0.78]) # [N]
labels = torch.tensor([1, 1, 2]) # [N], not pyramid levels
iou = box_iou(boxes[:1], boxes[1:2]) # [1, 1]
assert torch.allclose(iou, torch.tensor([[2592 / 2896]]))
keep = batched_nms(boxes, scores, labels, iou_threshold=0.6)
assert keep.tolist() == [0, 2]python当前官方文档规定框为 (x1,y1,x2,y2),宽高必须为正;返回的 int64 索引按保留框得分降序排列。在得分完全相同时,CPU 与 GPU 对被保留框的选择不一定相同;评测代码不要依赖并列框的固定顺序。
11 完整 FCOS API 的输入与输出#
torchvision 0.28 当前的 fcos_resnet50_fpn ↗ 检测模块仍标记为 Beta,不保证向后兼容。无权重的最小调用如下:
import torch
from torchvision.models.detection import fcos_resnet50_fpn
model = fcos_resnet50_fpn(
weights=None,
weights_backbone=None,
num_classes=4, # current API: includes background
min_size=320,
max_size=512,
center_sampling_radius=1.5,
score_thresh=0.25,
nms_thresh=0.6,
topk_candidates=300,
detections_per_img=100,
)
images = [torch.rand(3, 300, 420)] # list of [C,H,W], values in [0,1]
targets = [{
"boxes": torch.tensor([[36.0, 36.0, 92.0, 84.0]]), # [M,4]
"labels": torch.tensor([1], dtype=torch.int64), # [M]
}]
model.train()
losses = model(images, targets) # dict[str, scalar Tensor]
loss = sum(losses.values())
loss.backward()
model.eval()
with torch.inference_mode():
predictions = model(images) # list[dict[str, Tensor]]
result = predictions[0]
assert result["boxes"].ndim == 2 and result["boxes"].shape[1] == 4
assert result["scores"].shape == result["labels"].shapepython该模型先在内部保持宽高比缩放图像,将短边尽量调到 min_size,同时不让长边超过 max_size。训练框会同步变换,推理结果再还原到每张输入图的原始尺寸。因此若在模型外自己写匹配或后处理,必须先说清代码位于内部变换之前还是之后。
12 一条可执行的调试路径#
- 单独画坐标网格。 在 resize/pad 后图像上画出 P3–P5 预测点,检查原点、 顺序、步幅和半像素偏移。
- 对一个框画匹配掩码。 用不同颜色显示
inside_box、inside_center和inside_scale,不要只画最终正样本。 - 逐层统计正样本。 检查每个真值框至少有一个匹配,也检查是否大量框卡在区间边界。
- 用真值距离做解码回环。 将生成的
(l,t,r,b)立即解码,应在浮点误差内还原原框。 - 跟踪三个坐标系。 同时打印原图尺寸、模型变换后尺寸、padding 后 batch 尺寸,不要用一个
H,W变量代指全部。 - 在 NMS 前保留来源层。 分层统计阈值前数量、top-k 后数量和被 NMS 删除数量,才能看到跨层重复来自哪里。
- 先过拟合一张图。 只留一个边界不在阈值附近的目标;若无法学到近乎完美的框,先修坐标和匹配,不要先调学习率。
13 最常见的“能训练,但契约错了”#
- 用特征图坐标减原图框。 一边是 7,另一边是 92,距离值没有意义。
- 数据增强只改图像,没改框。 水平翻转后训练仍可运行,却等于主动制造错标。
- 用目标面积代替最大回归距离。 两者相关但不等价,尤其对极端长宽比目标。
- 区间边界两边都用闭区间。 可能同时分给 P3 和 P4;若改规则,必须明确哪一层包含边界。
- 回归目标除以步幅,解码时没乘回。 小框会缩在预测点周围,且误差随层级变化。
- 合并前没把各层框解码到同一图像坐标。 此时 IoU 与 NMS 结果都不可解释。
batched_nms的idxs传了金字塔层级。 这会禁止跨层抑制,正好保留了要删除的重复框。- 只凭 NMS 后框数判断阈值。 框少可能是正确去重,也可能是密集物体被误删,必须与召回率和分类别案例一起看。
14 它会在哪些场景失败?#
- 尺度分布严重偏斜。 遥感小目标可能几乎都落到最浅层,其他层缺乏监督;固定范围没有自动平衡能力。
- 密集或遮挡场景。 同类物体高度重叠时,硬 NMS 可能把真实的第二个实例删除。
- 长条形目标。 可能因一条长边把窄小目标分到过深层,损失横向细节。
- 区间边界抖动。 目标稍微 resize 就可能从 P3 跳到 P4,使监督对尺度增强不连续。
- 后处理成为延迟瓶颈。 阈值太低或逐层 top-k 太大会让 NMS 候选数暴涨,尤其在类别很多时。
- 训练与上线前处理不一致。 上线端若用拉伸代替保宽高比 resize,尺度归属和框坐标会同时改变。
15 与相近方法的边界#
| 方法 | 训练时如何定义候选 | 尺度责任 | 需要的去重/筛选 |
|---|---|---|---|
| FCOS 固定范围 | 网格点 + 中心采样 | 按 (l,t,r,b) 最大值区间 | 通常需要 NMS |
| Anchor-based 检测 | 预设尺寸/比例的锚框 | 通常按锚框与真值 IoU | 通常需要 NMS |
| MultiScale RoIAlign | 先有 proposal/RoI | 按 RoI 尺寸选池化层 | 候选生成与最终阶段可各有 NMS |
| ATSS | 多层候选中选距中心近的样本 | 用候选 IoU 统计自适应阈值 | 通常需要 NMS |
| 学习式匹配 | 用分类与定位代价联合分配 | 由当前模型质量动态决定 | 取决于检测器设计 |
固定尺度范围的优点是规则清晰、向量化简单、调试可视化。它的局限也来自同一点:责任边界是手工的,不知道当前样本在哪一层实际预测得更好。
16 今天真正需要记住什么?#
- FPN 只产生多尺度特征;检测器还必须定义网格点、真值框、尺度范围与冲突处理的训练契约。
- FCOS 用点到四边的
(l,t,r,b)回归框;框内、中心采样和尺度范围需同时满足。 - 尺度分配只是训练责任,不能保证推理时只有一层响应;所有层候选必须先解码到同一坐标系。
- 分类别 NMS 应比较同类别的跨层框;把层级号当成
batched_nms分组会让重复框永远无法相遇。 - 最有价值的调试信号不是总损失,而是每层正样本、解码回环、NMS 前后候选数和原图/变换图/pad 图的坐标记录。
17 思考题与小练习#
- 真值框为
(16,24,144,88),分别对 P3 点(76,60)和 P4 点(72,56)计算(l,t,r,b)与 。按本文范围判断它们是否通过尺度匹配,再检查 的中心采样。 - 将
assign_fcos_points扩展为返回[P]的真值索引和中心度。构造两个重叠框,验证同时合法时面积更小者获得该点。 - 在三层候选中保留
source_level,分别将类别号和层级号传给batched_nms的idxs。统计两种结果的重复框数,并解释密集目标上为何不能只用“框更少”作为优劣标准。
相关工作#
- Lin et al. (2017), Feature Pyramid Networks for Object Detection ↗:用自顶向下路径和横向连接建立语义一致的多尺度特征。
- Lin et al. (2017), Focal Loss for Dense Object Detection ↗:以 Focal Loss 处理密集检测中前景与背景的极度不平衡。
- Tian et al. (2019), FCOS: Fully Convolutional One-Stage Object Detection ↗:把目标检测表达为逐点分类、四边距离回归和中心度预测。
- Zhang et al. (2020), Bridging the Gap Between Anchor-based and Anchor-free Detection via Adaptive Training Sample Selection ↗:用候选样本 IoU 统计自适应选择正样本。
- Ge et al. (2021), OTA: Optimal Transport Assignment for Object Detection ↗:将标签分配表达为考虑全局代价的最优输运问题。
18 下一篇预告#
至此,图像主线已从卷积、下采样走到了多尺度特征与检测责任分配。下一篇将转向序列建模:固定窗口为什么记不住任意长的上下文,循环神经网络(Recurrent Neural Network, RNN)如何用隐状态在时间上传递信息。