☰
PyTorch锚框生成与IoU匹配代码详解:从候选框到目标检测
2026/10/21 15:47:12 网站建设 项目流程

锚框(anchor box)这个概念,我在学目标检测的时候卡了很长时间。跟着公开课把 PyTorch 版本的代码一行一行看下来,才真正理解了它到底在做什么。这篇文章就打算把锚框的生成与匹配代码拆开揉碎讲清楚,适合正在看检测相关章节、又不想只停留在调包层面的同学。我不光会讲每行代码的含义,还会把设计的动机、容易踩的坑、以及我自己调试时的一些土办法一并写进来。

1. 锚框到底解决什么问题,为什么检测模型离不开它

1.1 检测任务的核心:定位和分类一起做

图像分类解决的是“这是什么”的问题,而目标检测还要回答“它在哪里”。这个“在哪里”通常用边界框来表达,也就是一个尽可能框住目标的矩形,一般用四个值表示:左上角坐标和右下角坐标,或者中心坐标加宽高。

但“在哪里”这个问题比“是什么”难得多。难就难在,你事先不知道图里有多少个目标、每个目标长什么样、有多大、是横着还是竖着。如果让我用一个词形容检测任务,那就是“不可预知”。分类任务你至少知道要分多少类,检测任务连目标数量的上限都不好定。

那一个朴素的想法是:我能不能干脆把整张图上所有可能出现的矩形都先试一遍?这就是滑窗法。但滑窗的问题一眼就能看出来——假设一张 256×256 的图像,你用各种尺寸、各种比例的窗口从左上角滑到右下角,稍微算一下就是一个天文数字。而且滑窗出来的窗口大多不包含目标,纯属浪费算力。

有人可能会说,那让网络自己预测一下矩形的位置不就好了?这就是另一个思路:直接回归出四个坐标值。但实践下来你会发现,直接让网络从零开始预测一个矩形是相当困难的。因为坐标的范围太大、对尺度的变化太敏感,网络很难稳定收敛。这时候就需要一个“中间一步”:先铺好一堆候选框,网络只需要在这些候选框的基础上做微调,告诉模型“框往左挪一点、拉长一点”就行。

锚框就是这堆候选框。它不依赖于图像内容,是一组提前定义好的、均匀撒在图像上的矩形。模型要做的事情,是在锚框的基础上做分类和微调,而不是凭空想象一个框。

1.2 锚框的本质:一组预先铺好的候选框

锚框听着有点玄,实际上可以想象成在图像上铺了一张网。这张网的每个节点都对应几个不同尺寸、不同长宽比例的矩形框。不管图像里有没有目标、目标长什么样,这些矩形框都老老实实地放在那里,等模型去判断。

举个例子,假设我们把一张图划分成 4×4 的网格,每个网格中心点生成 5 个锚框,那整张图就有了 80 个候选框。如果目标出现在某个网格附近,那距离目标最近的锚框,就是最有希望的候选。网络后面要做的事情,其实是回答两个问题:这个锚框里有没有目标?如果有,这个锚框离真实目标还差多少?

这种“先粗后细”的思路,本质上是在给网络降低任务的难度。直接让网络输出任意矩形,等价于让一个新手在不知道答案范围的情况下乱猜;而让网络在一堆预设好的框里选一选、调一调,就像做选择题加微调,难度小了很多。

1.3 尺度、宽高比和数量:三个绕不开的参数

既然要铺候选框,就必然要面对三个参数:尺度(scale)、宽高比(aspect ratio)和锚框数量。

尺度控制锚框的整体大小,对应目标在图像中所占的面积比例。宽高比控制锚框的形状,比如 1:1 适合正方形目标,2:1 可能适合横向的长条目标,1:2 可能适合竖向的杆子。这两个参数一组合,就是在各种形状的目标上都先给一个“笼统的猜测”。

至于数量,它是精度和计算量的折中。锚框铺得越密集、形状组合越多,越容易覆盖到目标,但计算量也会成倍增加。比如一张 256×256 的输入图,下采样到 32×32 的特征图,每个像素生成 5 个锚框,那候选框数量就是 32×32×5=5120 个。如果后续每个锚框都要做分类和回归计算,这个数量已经不算小。所以在工程上,你会看到各种降低锚框数量的设计,比如只在特征图的某些层铺锚框,或者减少每个网格点的框数。

我在看代码之前,一直觉得锚框是个很复杂的机制。后来想明白了,它无非就是“在图像上预先撒一批矩形,让模型在这个基础上去做判断”。

2. PyTorch 生成锚框的代码逐行拆解

2.1 生成函数的核心输入:sizes、ratios 和特征图尺寸

公开课里生成锚框的函数,输入通常是数据张量和两个列表:sizes 和 ratios。数据张量其实就是图像或特征图,代码里要用它拿高和宽。sizes 是归一化后的尺度,比如 [0.75, 0.5, 0.25],表示锚框的边长占图像边长的比例。ratios 是宽高比,比如 [1, 2, 0.5],表示三种形状比例。

这里有个细节值得注意:sizes 的取值范围。如果设成 1,那锚框的边长就相当于整张图像的边长,也就是说这个框能框住整张图。实践中很少有人用这么大的锚框,通常都是零点几的小值,因为目标不会铺满整张图。

生成逻辑的第一步,是确定每个像素位置要生成多少个锚框:

boxes_per_pixel = len(sizes) + len(ratios) - 1

这是锚框章节非常经典的一个公式。我第一次看到这个-1的时候愣了一下。为什么要减一?因为 sizes 的第一个元素要和 ratios 中的所有元素各组合一次,剩下的 sizes 元素只和 ratios 中的 1 组合。也就是说,如果 sizes 有 3 个,ratios 有 3 个,那实际组合是 3 + 3 - 1 = 5 个,而不是 9 个。

这种截断组合的设计有一个实际动机:最小尺度要和各种宽高比都配一遍,保证小目标也能覆盖到不同的形状;而更大的尺度只保留 1:1 的形态,避免大锚框数量爆炸。你会在很多检测模型的代码里看到类似的策略,目的都是控制候选框总量。

2.2 网格中心点的生成:0.5 偏移和归一化

生成锚框的第一步,是算出每个网格单元的中心点坐标。这里的“中心点”不是整数像素坐标,而是归一化到 [0, 1] 区间的相对坐标。代码通常是这样的:

steps_h = 1.0 / in_height steps_w = 1.0 / in_width center_h = (torch.arange(in_height, device=device) + 0.5) * steps_h center_w = (torch.arange(in_width, device=device) + 0.5) * steps_w

解释一下这个+ 0.5。如果特征图尺寸是 4×4,直接用torch.arange(4)得到的是 0、1、2、3,对应的归一化坐标是 0、0.25、0.5、0.75。但 0 和 0.75 分别贴着图像边缘,不是网格的几何中心。加上 0.5 再除以 4,得到的是 0.125、0.375、0.625、0.875,这才是每个网格方格的真正中心。

这个离中心点还差半步的偏移,在目标检测里还挺关键。锚框是以网格中心为基准铺开的,中心偏了,整个锚框也偏了。虽然网络后续有能力微调回来,但初始候选的位置当然越准越好。

有了中心坐标之后,代码会用torch.meshgrid生成二维网格的坐标矩阵,再把它们展开成一维向量。这样每个网格中心点就对应向量里的一个位置:

shift_y, shift_x = torch.meshgrid(center_h, center_w, indexing='ij') shift_y = shift_y.reshape(-1) shift_x = shift_x.reshape(-1)

注意这里用的是indexing='ij',不然长宽顺序很容易搞反。我就在这个参数上翻过车,横纵坐标互换之后,所有锚框看起来像转了个角度,调试半天才发现是网格顺序的问题。

2.3 每种组合的宽高推导:为什么 ratio 要开根号

锚框的宽高计算是这套代码里最需要理解的数学部分。核心就是这两个式子:

w = size * sqrt(ratio) h = size / sqrt(ratio)

为什么宽要乘根号、高要除根号?这要从宽高比的定义说起。一般我们说宽高比 r,是指宽 / 高 = r。如果已知一个锚框的“基准尺寸”是 s,那宽和高可以设成 w = s * sqrt(r),h = s / sqrt(r)。这样一算,w * h = s²,面积不变,但宽高比例正好是 r。换句话说,这个式子保证了无论宽高比怎么变,锚框的面积都保持为 s² 不变,变的只是它的形状。

这个设计跟我一开始的直觉不太一样。我当时以为宽就是 s*r、高就是 s,后来算了一下,如果 r=2,那宽就是 2s、高就是 s,面积从 s² 变成了 2s²,相当于比例一变,框的大小也跟着变了,这样很难把“尺度”和“形状”两个因素解耦。用带根号的方式,s 控制整体大小,r 控制形状,互不干扰。

完整代码里,这个计算是在一个循环里对着每个 size、ratio 组合做的:

boxes = [] for i in range(num_sizes): for j in range(num_ratios): h = size_tensor[i] * torch.sqrt(ratio_tensor[j]) / 2 w = size_tensor[i] / torch.sqrt(ratio_tensor[j]) / 2 boxes.append(torch.stack([ shift_x - w, shift_y - h, shift_x + w, shift_y + h ], dim=1))

注意这里算完 w、h 之后立刻除了 2。因为后面要用的是“中心点加减半宽半高”来构造角点坐标,所以存的是半宽半高。我第一次看的时候总觉得哪里不对,后来把中间变量打出来才意识到,除以 2 是为了避免后面每个角点计算时再重复除以 2。

还有一点,这个双层循环遍历的是所有 size 和 ratio 的组合,也就是 num_sizes × num_ratios 种,但最终只需要 boxes_per_pixel 个。公开课里的做法是循环全部组合,最后只保留前 boxes_per_pixel 组。第一次循环的 i=0 会和所有 ratios 组合,之后的 size 则只和第一个 ratio 有效,这样的结果恰好就是num_sizes + num_ratios - 1个框。这个逻辑藏在循环里,不细看还真容易忽略。

2.4 拼接与形状变换:理解输出张量

每一轮循环生成的张量形状是(网格中心点数, 4),也就是每个网格中心点对应一组锚框坐标。最后把所有组合串起来:

output = torch.stack(boxes, dim=1).reshape(-1, 4)

torch.stack(boxes, dim=1)会把列表里的多个(中心点数, 4)的张量堆叠成(中心点数, boxes_per_pixel, 4),最后reshape(-1, 4)把它拉平成(中心点数 × boxes_per_pixel, 4)。

这个形状意思是说:所有网格的所有候选框,最终排成一列,每个框占一行。第一维的用处是给后续的匹配、回归和分类提供索引。比如 4×4 特征图、每点 5 个锚框,输出就是 80×4,4 列分别是 x1、y1、x2、y2。这里的坐标是归一化的,也就是说图像的高和宽都当成 1,所有框的坐标都在 0 到 1 之间。

理解这个输出形状之后,再去看可视化锚框的代码,就轻松多了。无非就是把归一化解算回像素坐标,然后用画框函数画出来。

2.5 可视化验证一下生成的锚框

光看数字很难建立直觉。我强烈建议你把生成的锚框画出来看。我当时写了一个小函数,把锚框从归一化坐标映射回像素坐标:

def draw_anchors(img, boxes, indices): h, w = img.shape[:2] boxes = boxes.clone() * torch.tensor([w, h, w, h]) for idx in indices: x1, y1, x2, y2 = boxes[idx].tolist() cv2.rectangle(img, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2)

选几个不同位置的锚框画出来,你会立刻明白什么叫“以网格中心为基准、按不同尺度和比例扩张”。尤其是把同一中心点的 5 个框叠在一起看,效果非常直观:一个小的正方形、一个更小的竖向矩形、一个更小的横向矩形、一个中等的正方形、一个更大的正方形,像花朵一样在一个点上展开。

我看完可视化之后,就完全理解了锚框为什么能覆盖不同形状的目标。网格铺得越密,锚框在空间上就撒得越密;每个点的形状组合越丰富,目标无论长得怪不怪,总有一个初始框离它比较近。

3. 锚框配准的核心工具:IoU 实现解析

3.1 坐标体系切换:center 与 corner 的互转

生成锚框后,代码里经常要在两种坐标表示之间切换:一种是 (x1, y1, x2, y2) 的角点式,另一种是 (cx, cy, w, h) 的中心式。为什么都需要?因为生成锚框时用中心式更直观,而计算 IoU 时用角点式更直接(只需要算重叠区间的长度)。所以代码里会配两个转换函数:

def box_corner_to_center(boxes): x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] cx = (x1 + x2) / 2 cy = (y1 + y2) / 2 w = x2 - x1 h = y2 - y1 return torch.stack([cx, cy, w, h], dim=-1) def box_center_to_corner(boxes): cx, cy, w, h = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] x1 = cx - w / 2 y1 = cy - h / 2 x2 = cx + w / 2 y2 = cy + h / 2 return torch.stack([x1, y1, x2, y2], dim=-1)

这两个函数逻辑上完全对称,只要保证输入张量最后一维是 4、列的顺序对应正确就行。我曾在处理批量数据时直接把 shape 是(batch, num_boxes, 4)的张量传进去,结果转完发现维度对不上。后来养成了习惯:写这类转换函数时,都用boxes[:, 0]这种索引方式,而不是写死boxes[0],这样对最后一位做切片,批量维度再多也能正常工作。

这里有个容易忽略的点:角点式要求 x2 > x1、y2 > y1。如果某个框的坐标顺序写反了,后续算 IoU 会得到连自己都看不懂的负数。后面我会专门说这个问题。

3.2 向量化 IoU 代码拆解

IoU 全称是 Intersection over Union,也就是交并比。它的定义很朴素:两个框的交集面积除以两个框的并集面积。IoU 越大,说明两个框重叠越多。如果 IoU 为 1,两个框完全重合;如果为 0,就是完全没有重叠。

锚框和真实框之间做匹配,就是通过计算每个锚框和每个真实框的 IoU,把那些重叠度足够高的锚框挑出来当成正样本。

向量化实现能一次性算完所有锚框和真实框之间的 IoU,不需要写 Python 循环:

def box_iou(boxes1, boxes2): area1 = (boxes1[:, 2] - boxes1[:, 0]) * (boxes1[:, 3] - boxes1[:, 1]) area2 = (boxes2[:, 2] - boxes2[:, 0]) * (boxes2[:, 3] - boxes2[:, 1]) inter_x1 = torch.max(boxes1[:, None, 0], boxes2[:, 0]) inter_y1 = torch.max(boxes1[:, None, 1], boxes2[:, 1]) inter_x2 = torch.min(boxes1[:, None, 2], boxes2[:, 2]) inter_y2 = torch.min(boxes1[:, None, 3], boxes2[:, 3]) inter_w = (inter_x2 - inter_x1).clamp(min=0) inter_h = (inter_y2 - inter_y1).clamp(min=0) inter_area = inter_w * inter_h union_area = area1[:, None] + area2 - inter_area return inter_area / union_area

这段代码最核心的技巧是boxes1[:, None, 0]这种 None 索引。它把维度从(n,)扩成(n, 1),再和boxes2[:, 0]这个(m,)做广播,得到(n, m)的矩阵。矩阵的每个元素 [i, j] 表示 boxes1 的第 i 个框和 boxes2 的第 j 个框在某个坐标轴上的值。

交集坐标的求法是:左边界取大的,右边界取小的,上边界取大的,下边界取小的。一句话总结就是“双方往中间靠”。右边界减左边界得到重叠宽度,如果是负数说明在 x 方向上根本没碰上,这时候clamp(min=0)会把它截断成 0。这个概念很重要,因为不重叠的两个框交叠宽度明明是负数,如果忘了 clamp,最终 IoU 会变成负数甚至导致梯度异常。

最后一步的并集计算用的是容斥定理:两个面积之和减去交集面积。这里直接用了向量化写法area1[:, None] + area2 - inter_area,跟用两层循环展开的数学完全一样,但速度快很多。在候选框数量比较大的时候,比如几千个锚框对几十个真实框,这种向量化是必须的。

3.3 IoU 计算的边界情况和可读性改进

我在实际写代码时,会把boxes1[:, None, 0]这类写法换成可读性更好的版本:

boxes1 = boxes1[:, None, :] # (n, 1, 4) boxes2 = boxes2[None, :, :] # (1, m, 4) x1 = torch.max(boxes1[..., 0], boxes2[..., 0])

这样逻辑完全一样,但能少写很多重复的[:, None, 0]。另外还有一种做法是先转成(n, m, 4)的张量,再切片:

boxes1 = boxes1[:, None, :].expand(-1, boxes2.shape[0], -1)

这种方式在调试时打印形状更直观,唯一的代价是 expand 要小心内存消耗,不过对于几万规模的框来说完全没有问题。

IoU 的一个边界情况是两个完全不相交的框,得到的 IoU 是 0。这是正常现象,但在做正负样本分配时,如果一张图里小目标特别多,可能大量锚框和真实框的 IoU 都接近 0,这时候阈值设得不对,就会导致没有锚框被分配到某个真实框上。后面我会讲这个问题的处理办法。

4. 实操踩坑与调试心得

4.1 归一化坐标与像素坐标的切换

锚框代码里,坐标通常是归一化到 [0, 1] 的。这意味着画图、算面积、或者跟某些库交互的时候,需要先把坐标乘回图像的宽高。但不同库对坐标的定义可能有差异,有的要整数像素,有的要浮点,有的左上角是 (0, 0) 有的从 (1, 1) 开始。我踩过的坑是:直接拿归一化坐标去调某个可视化函数,结果框画到了图外。

正确做法是,先用一个辅助函数把归一化锚框转成像素坐标,再统一交给可视化函数。转的时候别用round取整,直接用int()或者floor,否则会丢失框的边界准确性。画标注框的时候,像素坐标偏一个像素问题不大,但如果你在做数据标注或者保存结果为 json,那每一个像素都可能影响后续评估的 IoU。

4.2 浮点精度和小数误差

锚框生成的代码里大量涉及除法、开根号和累加,浮点误差很容易悄悄溜进来。最典型的问题是几个锚框明明应该完全对称,打印出来却不对称,比如 x1 和 y1 是 0.12499999 而不是 0.125。

这种误差一般不影响模型训练,但会影响断言的判断。我第一次写的时候加了这样一行断言:

assert torch.allclose(anchor, anchor_expected)

发现一直报错,后来打印出来才发现是 1e-7 量级的误差。记住要用torch.allclose而不是torch.equal,比较时给一个atol和rtol容差。

另外,在锚框里用clamp(min=0)处理宽度高度时,要留意 0 和非常接近 0 的值。IoU 计算中如果 union_area 是 0(两个框面积都为 0),除法会产生 nan。虽然正常数据里不会出现面积为 0 的框,但 debug 或者边界测试时很容易碰到。可以在除法前加一个小数 epsilon,比如1e-6,避免 nan 污染后续计算。

4.3 从 2×2 特征图开始调试

很多同学学习锚框代码时,喜欢直接拿一个 256×256 的图像去跑,然后盯着几千个框发呆,这其实很难排查问题。我的习惯是先从极小尺寸开始。

如果你把特征图设成 2×2,sizes 设成 [0.5, 0.25],ratios 设成 [1, 2, 0.5],那最后生成的锚框就极少。你可以手动计算每一组锚框的坐标,再跟代码输出对比。比如第一个网格中心在 x = 0.25、y = 0.25 的位置,那么 sizes[0]=0.5、ratios[0]=1 时,生成的是边长 0.5 的正方形框,角点应该是 (0, 0) 到 (0.5, 0.5)。如果代码输出的不是这个值,那就是逻辑有误,而不是浮点误差的问题。

等小尺寸验证通过后,再慢慢把特征图调大。这样排查问题的时间能缩短一个数量级。调试的时候,用print打印中间变量的 shape 和值,比用各种高级 debugger 都要直接。锚框生成本质上就是坐标的算术,打开控制台手算一遍,比猜快得多。

4.4 可视化锚框的三种手段

我在调试过程中试过三种可视化方案,给后来者一个参考。

第一种是直接把锚框画在原始图像上。这个最自然,能直观看到框和目标的位置关系。适合检查锚框生成是否正确、是否有偏离图像边缘的异常框。

第二种是在特征图尺度上画,而不是原始图。因为锚框坐标是相对于特征图的归一化值,直接把锚框叠加在特征图的热力图上,可以帮你理解锚框和卷积感受野之间的对应关系。这一点在做检测模型调参时尤其有用。

第三种是输出成 COCO 格式或 VOC 格式的标注文件,再用现成的标注工具打开看。这个适合当锚框数量特别多、需要系统检查时使用。缺点是导入导出稍微麻烦,但好处是可以跟可视化工具里的其他标注叠加对比。

不管用哪种方式,都要记得把归一化坐标还原成像素坐标,并且注意通道顺序。RGB 和 BGR 搞混之后,图像会偏色,容易让人误以为框画错了。

5. 锚框之后的路:匹配、偏移与 NMS

5.1 正负样本分配:锚框如何“认领”真实框

锚框生成出来之后,并不能直接拿来做损失计算,因为还没人告诉模型每个锚框对应哪个真实目标。这个过程叫标签分配,也叫匹配。最基本的策略是:对每个真实框,找到和它 IoU 最大的那个锚框,强制匹配成正样本;然后再把其他锚框里,和任一真实框 IoU 超过某个阈值(比如 0.5)的也当成正样本。剩下 IoU 过低的都算负样本。

这个策略看起来朴素,实现里却有两个容易错的地方。第一个是“强制匹配”这一步。因为每张图里总有那么一两个目标,形状和尺度很极端,导致没有任何锚框能和它达到阈值,如果不强制匹配,这个目标就被白白丢掉了。第二个是“一个锚框只能匹配一个真实框”。当两个真实框重叠时,一个锚框可能跟两个真实框都有较高的 IoU,必须选择 IoU 最高的那一个作为它的标签。如果你忘了这一步,训练时梯度就会自相矛盾。

5.2 边界框偏移量的编码方式

匹配完成之后,锚框会被赋予两类监督信息:类别标签和偏移量。类别标签好理解,就是“这个框里是猫还是背景”。偏移量则描述锚框和真实框之间的差异,模型要学的是“把锚框怎么调一下才能贴近真实框”。

常见的编码方式是中心偏移加宽高缩放:

dx = (gt_cx - anchor_cx) / anchor_w dy = (gt_cy - anchor_cy) / anchor_h dw = log(gt_w / anchor_w) dh = log(gt_h / anchor_h)

这里除宽高是做了归一化,log 则是把宽高比从线性空间映射到对数空间。为什么要 log?因为宽高的变化率是相对的,2 倍和 3 倍之间的差距在 log 空间是均等的,这样模型拟合起来更平稳。

这一段代码和前面的锚框生成、IoU 配合起来,就构成了训练检测网络的主要数据流:生成候选框 → 计算 IoU → 分配标签 → 计算偏移量 → 让网络预测这些标签和偏移量。

5.3 非极大值抑制:把重复框压掉

推理阶段会碰到另一个问题:模型预测时,同一个目标附近会有大量正样本锚框都输出“我这里有目标”,而且它们的框还都差不多。如果全保留下来,图像上就会堆满重叠的框。这时候需要用 NMS,也就是非极大值抑制。

思路也很直白:先把所有框按置信度从高到低排序,选出当前置信度最高的框,然后把所有和它 IoU 大于某个阈值的框都删掉。这个阈值通常设在 0.5 左右,阈值太高会留下大量重复框,太低又容易把相邻的两个目标误删成一个。

NMS 在代码上比 IoU 简单不少,但因为是在循环里逐个处理,速度往往不如 IoU 那么快。工程上会用各种加速手段,比如先按类别分组、用向量化实现批量抑制。不过这些都是优化层面的事了,理解 Anchor 的整套流程时,先把这个逻辑理顺就行。

回头再看这套代码,锚框生成、IoU 计算、标签分配、偏移量回归、NMS,每一步都是环环相扣的。单看某一个环节都可能觉得抽象,但把它们串起来,目标检测的训练和推理流程就清晰了。我个人学这一块最大的体会是:不要急着去看模型整体代码,先把锚框生成和 IoU 这两个基础工具彻底吃透,后面的部分会顺畅很多。哪怕已经是工作中常用的技术,回头补一下基础,依然会发现自己曾经的很多理解都只是浮在表面。锚框这篇文章我写的比较细,希望它也能帮你真正把这部分内容消化掉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询