1. 从一张特征图说起:anchor generator到底在干什么
很多人第一次翻开Faster R-CNN的源码,看到anchor_generator.py这个文件的时候,第一反应是“这玩意儿不就是生成一堆框吗,有什么好分析的”。我当初也是这么想的,直到有一次在自定义数据集上训练,模型死活学不动,排查了整整两天才发现是anchor的尺度和长宽比跟我的数据分布完全对不上。从那以后我才真正意识到,anchor generator这个看起来只有几十行代码的模块,实际上是整个检测流程里最容易被忽视、却又最致命的一环。
先把话说清楚:anchor generator做的事情,本质上就是在特征图的每一个空间位置上,预先铺设一组具有不同尺度和不同长宽比的矩形框。这些框就是所谓的anchor,也叫先验框。它们的作用是给RPN(Region Proposal Network)提供一个参考基准,让网络不需要从零开始预测目标的位置,而是去预测“相对于某个anchor,真实目标偏移了多少、缩放了多少”。这个思路跟人做事情的方式很像——你让一个人凭空画一个框把图里的猫圈出来,他可能无从下手;但你如果先给他一堆大小形状各异的框,让他判断“哪个框最接近猫,然后微调一下”,这个事情就变得可操作了。
Faster R-CNN之所以能成为两阶段检测器的经典之作,anchor机制功不可没。它把目标检测从“回归绝对坐标”变成了“回归相对偏移”,大大降低了学习难度。而anchor generator就是这套机制的起点。它需要回答三个核心问题:在哪些位置生成anchor?每个位置生成多少个anchor?每个anchor的尺寸和形状怎么定?这三个问题分别对应了特征图的空间维度、anchor的数量(scales × ratios)以及anchor的具体几何参数。
这篇文章我打算从源码级别的细节出发,把anchor generator的每一个参数、每一步计算都拆开来讲清楚。不管你是刚接触目标检测的新手,还是已经跑过几个项目但一直没深究底层机制的老手,我相信都能从中找到一些之前忽略的东西。尤其是那些正在自定义数据集上踩坑的人,这篇文章可能会帮你省下不少调试时间。
2. 核心设计思路拆解:为什么是九个框,为什么是这些尺寸
2.1 anchor的三要素:位置、尺度、长宽比
anchor generator的设计可以用三个维度来概括。第一个维度是位置,也就是特征图上的每一个像素点。假设输入图像经过骨干网络后得到的特征图大小为W×H,那么就会有W×H个位置,每个位置都会生成一组anchor。第二个维度是尺度(scale),也就是anchor的面积大小。第三个维度是长宽比(ratio),控制anchor的形状是偏宽还是偏高。
在原始的Faster R-CNN论文中,作者使用了3种尺度和3种长宽比的组合,所以每个位置生成3×3=9个anchor。这9个anchor的面积分别是128²、256²、512²,长宽比分别是1:1、1:2、2:1。这个配置不是拍脑袋想出来的,而是基于PASCAL VOC和COCO数据集中目标尺寸分布的统计结果。大部分目标在归一化后的尺寸都落在这个范围内,所以这9个anchor能够覆盖绝大多数情况。
但这里有一个很容易被忽略的细节:当你把尺度和长宽比组合在一起的时候,实际的anchor宽高并不是简单的scale×ratio。具体的计算公式是这样的:对于面积A和长宽比r,anchor的宽度w=sqrt(A/r),高度h=sqrt(A×r)。这个公式保证了anchor的面积始终等于A,而长宽比始终等于r。很多人第一次看源码的时候会在这里卡住,因为直觉上会觉得宽应该是A×r,但实际上那样算出来的面积就不对了。
2.2 为什么特征图上的一个点能对应原图的一大片区域
这里涉及到感受野的概念。特征图上的一个像素点,实际上对应了原图上的一个区域,这个区域的大小就是感受野。越深的特征图,感受野越大,单个像素点包含的语义信息越丰富,但空间分辨率越低。anchor generator在特征图的每个位置生成anchor,实际上是在原图的对应区域中心生成这些框。
以VGG16为例,经过conv5之后,特征图相对于原图的下采样倍数是16。也就是说,特征图上的一个点,对应原图上16×16的区域。如果原图是800×600,那么特征图大约是50×38。在这50×38=1900个位置上,每个位置生成9个anchor,总共就是17100个anchor。这个数量看起来很大,但相比滑动窗口方法在每个位置枚举所有可能的尺度和长宽比,已经高效太多了。
注意:下采样倍数(stride)是anchor生成中一个极其关键的参数。如果你换了骨干网络,比如从VGG16换成ResNet50,stride可能会变化,anchor的生成位置也会随之改变。这个参数如果设置错了,后面RPN的回归目标计算会全部错位。
2.3 从anchor到proposal:RPN如何使用这些框
anchor生成之后,RPN会对每个anchor输出两个东西:一个是前景/背景的分类得分,另一个是位置回归的偏移量。训练的时候,每个anchor会根据与真实框的IoU被分配一个标签。通常IoU大于0.7的被认为是正样本,小于0.3的被认为是负样本,中间的不参与训练。推理的时候,RPN会对所有anchor的得分进行排序,取前N个,然后用回归偏移量对anchor进行修正,得到最终的proposal。
这个流程里有一个关键点:anchor的质量直接决定了RPN的学习难度。如果anchor的尺度和长宽比跟数据集中的目标分布严重不匹配,那么大部分anchor都会落在背景区域,正样本极其稀少,训练就会变得非常困难。这就是为什么在自定义数据集上,调整anchor的尺度和长宽比往往能带来显著的性能提升。
3. 源码级细节解析:每一个参数背后的计算逻辑
3.1 初始化参数:sizes、ratios和scales
在PyTorch版本的Faster R-CNN实现中,AnchorGenerator类的初始化参数通常包括sizes、aspect_ratios和scales。sizes定义了anchor的基本面积,aspect_ratios定义了长宽比,scales则是对基本面积的缩放系数。有些实现把sizes和scales合并在一起,直接用一组面积值来表示。
以torchvision中的实现为例,默认配置是这样的:
anchor_sizes = ((32, 64, 128, 256, 512),) aspect_ratios = ((0.5, 1.0, 2.0),)这里anchor_sizes是一个元组,每个元素对应一个特征图层级。aspect_ratios也是类似的。如果是单尺度特征图,就只有一个元素。如果是FPN结构,就会有多个层级,每个层级有不同的anchor尺寸。
计算每个anchor的宽高时,代码会遍历所有的size和ratio组合:
for size in sizes: area = size ** 2 for aspect_ratio in aspect_ratios: w = area / aspect_ratio h = area * aspect_ratio # 然后对w和h开根号等一下,这里有一个容易混淆的地方。有些实现是先算w和h再开根号,有些是先开根号再算w和h。实际上正确的做法是:w = sqrt(area / ratio),h = sqrt(area * ratio)。这样w×h = area,w/h = ratio,两个条件同时满足。如果你看到某个实现里w和h的计算方式不同,那可能是用了不同的参数化方式,但最终生成的anchor形状应该是一致的。
3.2 网格生成:从特征图坐标到原图坐标
anchor的生成位置是由特征图的大小决定的。假设特征图的高度为H,宽度为W,stride为S,那么每个位置的中心点在原图上的坐标就是(i×S + S/2, j×S + S/2),其中i和j分别是特征图上的行索引和列索引。这里加S/2是因为特征图上的一个点对应原图上的一个S×S的区域,中心点自然在区域的正中间。
这个坐标映射看起来简单,但在实际实现中有很多细节需要注意。比如不同的深度学习框架对坐标的定义可能不同,有的以像素中心为原点,有的以像素左上角为原点。这些差异在计算回归目标的时候会导致偏移,如果不注意,训练出来的模型可能会有系统性的位置偏差。
还有一个常见的问题是边界处理。当anchor的中心点靠近图像边缘时,生成的anchor可能会超出图像边界。在训练的时候,通常会把超出边界的anchor过滤掉,不参与损失计算。在推理的时候,则会对超出边界的部分进行裁剪。这个处理逻辑虽然简单,但如果忘了做,可能会导致训练不稳定或者推理结果异常。
3.3 不同特征图层的anchor配置策略
在FPN(Feature Pyramid Network)结构中,anchor generator需要在多个特征图层上分别生成anchor。通常的做法是让浅层特征图负责小目标,深层特征图负责大目标。具体的配置方式是:如果一共有5个特征图层,最小的anchor尺寸为32,那么每一层的anchor尺寸依次为32、64、128、256、512,形成一个等比数列,公比为2。
这种配置的逻辑是:浅层特征图的分辨率高,感受野小,适合检测小目标;深层特征图的分辨率高,感受野大,适合检测大目标。每个层级的anchor尺寸应该与该层级的感受野相匹配。如果匹配不当,比如在浅层用很大的anchor,那么大部分anchor都会覆盖到背景区域,正样本比例极低,训练效果会很差。
实操心得:在自定义数据集上,我通常会先统计所有真实框的宽高分布,画出散点图,然后根据分布的中心和范围来设置anchor的尺度和长宽比。如果目标的宽高比集中在某个特定范围,比如车牌大约是3:1到5:1,那么就应该增加对应的ratio,而不是死守默认的0.5、1.0、2.0。
4. 完整实操流程:从零配置一组适合自己数据的anchor
4.1 统计数据集的目标分布
第一步永远是了解你的数据。我一般会写一个简单的脚本,遍历标注文件,收集所有真实框的宽度、高度和面积。然后计算这些量的统计特征:均值、中位数、标准差、最小值和最大值。更重要的是,我会画出宽高的散点图和面积的直方图。
import numpy as np import matplotlib.pyplot as plt widths = [] heights = [] for ann in annotations: for box in ann['boxes']: w = box[2] - box[0] h = box[3] - box[1] widths.append(w) heights.append(h) widths = np.array(widths) heights = np.array(heights) areas = widths * heights ratios = widths / heights print(f"Width: mean={widths.mean():.1f}, median={np.median(widths):.1f}") print(f"Height: mean={heights.mean():.1f}, median={np.median(heights):.1f}") print(f"Area: mean={areas.mean():.1f}, median={np.median(areas):.1f}") print(f"Ratio: mean={ratios.mean():.2f}, median={np.median(ratios):.2f}") plt.scatter(widths, heights, s=1, alpha=0.3) plt.xlabel('Width') plt.ylabel('Height') plt.show()这个脚本跑完之后,你对数据的分布就有了直观的认识。如果大部分目标的面积在32²到256²之间,那么anchor的尺寸就应该覆盖这个范围。如果宽高比集中在0.5到2.0之间,那么默认的ratio配置可能就够用了;如果宽高比分布很广,比如有些目标特别宽,有些特别高,那就需要增加更多的ratio。
4.2 用K-means聚类确定anchor尺寸
如果不想手动分析,可以用K-means聚类来自动确定anchor的尺寸。这个方法在YOLO系列中被广泛使用,但其实对Faster R-CNN同样有效。具体做法是把所有真实框的宽高作为二维数据点,用K-means聚成K个簇,每个簇的中心就是一组anchor的宽高。
from sklearn.cluster import KMeans boxes = np.column_stack([widths, heights]) kmeans = KMeans(n_clusters=9, random_state=42).fit(boxes) centers = kmeans.cluster_centers_ print("Anchor centers:") for c in centers: print(f" width={c[0]:.1f}, height={c[1]:.1f}, area={c[0]*c[1]:.1f}, ratio={c[0]/c[1]:.2f}")聚类的数量K通常设为9,对应3种尺度×3种长宽比。但如果你想要更精细的控制,也可以设为12或15。聚类的好处是生成的anchor完全由数据驱动,不需要凭经验猜测。我试过在一个工业缺陷检测数据集上用这个方法,mAP比默认配置提升了将近8个百分点,效果非常明显。
注意:K-means对初始值敏感,建议多跑几次取最优结果。另外,聚类得到的anchor宽高是绝对值,需要根据特征图的stride换算成相对于特征图的尺寸。这个换算关系如果搞错了,anchor就会全部偏大或偏小。
4.3 修改配置文件并验证anchor生成结果
确定好anchor尺寸之后,下一步就是修改配置文件。以常见的Faster R-CNN实现为例,通常需要修改anchor_generator部分的sizes和aspect_ratios参数。如果是FPN结构,还需要为每个特征图层分别指定尺寸。
修改完成之后,不要急着开始训练。先写一个简单的验证脚本,把生成的anchor画在原图上,直观地检查一下anchor的覆盖情况。具体做法是:取一张训练图片,用anchor generator生成所有anchor,然后随机采样一部分画在图上。
import cv2 img = cv2.imread('sample.jpg') anchors = anchor_generator.grid_anchors([feature_map_size], device='cpu') # 随机采样100个anchor indices = np.random.choice(len(anchors[0]), 100, replace=False) for idx in indices: x1, y1, x2, y2 = anchors[0][idx].numpy().astype(int) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 1) cv2.imwrite('anchors_vis.jpg', img)如果画出来的anchor能够较好地覆盖图中的目标,说明配置基本合理。如果发现某些尺寸的目标完全没有对应的anchor覆盖,或者大量anchor集中在背景区域,那就需要回去调整参数。
5. 常见问题与排查技巧实录
5.1 anchor全部超出图像边界怎么办
这个问题通常出现在特征图尺寸计算错误或者stride设置不当的时候。比如原图是416×416,stride是32,那么特征图应该是13×13。如果你错误地用了stride=16,特征图就变成了26×26,生成的anchor中心点坐标会翻倍,导致大量anchor超出图像边界。
排查方法很简单:打印出特征图的尺寸和stride,手动算一下最大中心点坐标是否超过原图尺寸。如果超过了,说明stride或者特征图尺寸有问题。另外,有些实现会在生成anchor之后做一个clip操作,把超出边界的anchor裁剪到图像范围内。这个操作虽然能避免报错,但会改变anchor的形状和面积,可能影响回归目标的计算。我的建议是尽量在生成阶段就保证anchor不越界,而不是依赖后处理。
5.2 正样本太少导致训练不收敛
这是自定义数据集上最常见的问题。RPN训练的时候,如果正样本比例太低,分类损失会被负样本主导,网络学到的全是“预测背景”。造成正样本少的原因通常有两个:一是anchor的尺度和长宽比跟目标不匹配,二是IoU阈值设置得太高。
解决方法分两步走。第一步是调整anchor配置,用前面说的统计或聚类方法重新设计anchor。第二步是适当降低正样本的IoU阈值,比如从0.7降到0.5。但要注意,阈值降得太低会引入大量低质量的正样本,反而影响回归精度。我一般会在0.5到0.6之间找一个平衡点。
还有一个技巧是使用在线难例挖掘(OHEM),让网络在训练过程中动态选择那些分类损失大的样本参与梯度更新。这个方法对正负样本不平衡的情况特别有效,但实现起来稍微复杂一些,需要修改损失函数的计算逻辑。
5.3 多尺度训练时anchor如何适配
多尺度训练是指每次输入网络的图像尺寸随机变化,比如在600到1000之间随机选一个值。这种情况下,anchor的绝对尺寸是不变的,但相对于图像的比例会发生变化。如果图像变小了,同样的anchor就会显得更大;图像变大了,anchor就显得更小。
处理这个问题有两种思路。一种是在每个尺度下重新生成anchor,保证anchor相对于图像的比例始终一致。另一种是保持anchor不变,让网络自己去适应尺度变化。第一种方法更合理,但实现起来需要在每次前向传播时重新计算anchor,会增加一些计算开销。第二种方法简单,但对网络的容量要求更高。
我个人的经验是,如果尺度变化范围不大(比如600到800),直接用固定anchor就行;如果变化范围很大(比如400到1200),最好还是动态生成anchor。torchvision的实现里有一个AnchorGenerator的变体支持这种动态生成,但需要自己写一些胶水代码来适配训练循环。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 训练loss不下降 | anchor与目标不匹配 | 可视化anchor覆盖情况 | 重新设计anchor尺寸 |
| 正样本比例极低 | IoU阈值过高 | 打印正负样本数量 | 降低正样本阈值 |
| anchor大量越界 | stride或特征图尺寸错误 | 检查stride配置 | 修正stride或添加clip |
| 小目标检测效果差 | 浅层anchor尺寸过大 | 检查各层anchor配置 | 为浅层设置更小anchor |
| 推理速度慢 | anchor数量过多 | 统计每张图的anchor总数 | 减少scale和ratio组合 |
| 回归目标异常大 | anchor中心点计算错误 | 验证坐标映射关系 | 修正中心点偏移量 |
实操心得:每次修改anchor配置之后,一定要重新跑一遍验证集,不要只看训练loss。我踩过的一个坑是训练loss降得很好,但验证集mAP反而下降了,后来发现是anchor过拟合到了训练集的分布,泛化能力变差了。所以验证集上的表现才是最终标准。
6. 影响范围分析:anchor generator如何牵动整个检测流程
6.1 对RPN训练收敛速度的影响
anchor generator的配置直接决定了RPN的训练难度。如果anchor的尺度和长宽比覆盖了大部分目标,那么正样本数量充足,分类和回归的梯度信号都很强,网络收敛就快。反之,如果anchor配置不合理,正样本稀少,网络需要花很长时间才能学到有效的前景特征,甚至可能永远学不到。
我做过一个对比实验:在同一个数据集上,用默认anchor配置和用K-means聚类得到的anchor配置分别训练RPN。默认配置下,RPN的分类loss在第5个epoch左右才开始明显下降;而聚类配置下,第2个epoch就已经降到了很低的水平。最终收敛后的mAP差距在3到5个百分点。这个差距在工业级应用中是非常显著的。
6.2 对最终检测精度的影响
anchor的质量不仅影响RPN,还会通过proposal传递给后续的RoI Head。如果RPN生成的proposal质量不高,比如大量proposal偏离真实目标,那么RoI Head再强也很难把精度拉回来。这就是所谓的“垃圾进,垃圾出”。
具体来说,anchor配置对最终精度的影响体现在两个方面。一是召回率:如果某些尺寸或形状的目标没有对应的anchor覆盖,那么这些目标就永远不会被RPN召回,后续再怎么做都没用。二是定位精度:如果anchor与真实框的初始偏差太大,回归网络需要预测很大的偏移量,这比预测小偏移量要困难得多,定位精度自然会下降。
6.3 对推理速度的影响
anchor的数量直接决定了RPN的计算量。每个anchor都需要经过分类和回归两个分支,anchor数量翻倍,RPN的计算量也大致翻倍。在原始的9个anchor配置下,一张800×600的图片大约有17100个anchor。如果把scale和ratio的组合增加到15个,anchor数量就变成了28500个,推理时间会增加30%到40%。
所以在实际部署的时候,需要在精度和速度之间做权衡。如果应用场景对实时性要求很高,比如视频监控,那么可能需要减少anchor的数量,比如只用2种尺度和2种长宽比,总共4个anchor。如果对精度要求更高,比如医学影像分析,那么可以适当增加anchor的数量,提高召回率。
6.4 不同骨干网络下的适配策略
不同的骨干网络有不同的下采样倍数和感受野分布,anchor generator需要相应地调整。比如VGG16的stride是16,ResNet50的stride是32(如果只用conv5),而MobileNet的stride可能是16或32。stride越大,特征图越小,anchor的生成位置越稀疏,大目标的检测效果越好,但小目标容易漏检。
在使用轻量级骨干网络的时候,我通常会配合FPN来使用,让浅层特征图负责小目标,深层特征图负责大目标。这样即使骨干网络的下采样倍数很大,也能通过多尺度特征融合来弥补小目标检测的不足。另外,轻量级网络的感受野通常较小,anchor的尺寸也应该相应减小,否则anchor会覆盖到太多背景区域。
7. 一些踩坑之后的个人体会
anchor generator这个模块,代码量不大,但里面的门道是真的多。我刚开始做检测项目的时候,总觉得anchor就是个超参数,随便设一下就行,结果在自定义数据集上反复翻车。后来才慢慢明白,anchor的本质是对数据分布的一种先验建模。你对数据了解得越深入,anchor就设得越合理,模型训练就越顺畅。
有一个习惯我坚持了很久:每次拿到一个新数据集,第一件事不是跑模型,而是花半个小时做数据统计分析。看看目标的尺寸分布、宽高比分布、每张图的目标数量。这些信息看起来跟模型无关,但实际上决定了anchor怎么设、特征图怎么选、甚至损失函数怎么调。磨刀不误砍柴工,这半个小时往往能省下后面几天的调试时间。
另外,不要迷信默认配置。默认的9个anchor是在COCO和VOC上调出来的,你的数据分布跟这两个数据集可能差很远。该改就改,该聚类就聚类,不要怕麻烦。我见过太多人拿着默认配置在工业数据集上硬跑,跑了两天效果不好就放弃,其实只要改一下anchor尺寸就能解决的问题。
最后再分享一个小技巧:如果你不确定anchor设得对不对,可以把RPN生成的proposal画出来看看。如果proposal大量集中在某些区域,或者大量重叠在一起,说明anchor的分布有问题。如果proposal能够较好地覆盖图中的目标,并且位置比较准确,那说明anchor配置基本到位了。这个可视化方法比看loss曲线直观得多,强烈推荐试一试。