PyTorch锚框生成与IoU匹配代码详解:从候选框到目标检测
锚框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×55120 个。如果后续每个锚框都要做分类和回归计算这个数量已经不算小。所以在工程上你会看到各种降低锚框数量的设计比如只在特征图的某些层铺锚框或者减少每个网格点的框数。我在看代码之前一直觉得锚框是个很复杂的机制。后来想明白了它无非就是“在图像上预先撒一批矩形让模型在这个基础上去做判断”。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, devicedevice) 0.5) * steps_h center_w (torch.arange(in_width, devicedevice) 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, indexingij) shift_y shift_y.reshape(-1) shift_x shift_x.reshape(-1)注意这里用的是indexingij不然长宽顺序很容易搞反。我就在这个参数上翻过车横纵坐标互换之后所有锚框看起来像转了个角度调试半天才发现是网格顺序的问题。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后来算了一下如果 r2那宽就是 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 ], dim1))注意这里算完 w、h 之后立刻除了 2。因为后面要用的是“中心点加减半宽半高”来构造角点坐标所以存的是半宽半高。我第一次看的时候总觉得哪里不对后来把中间变量打出来才意识到除以 2 是为了避免后面每个角点计算时再重复除以 2。还有一点这个双层循环遍历的是所有 size 和 ratio 的组合也就是 num_sizes × num_ratios 种但最终只需要 boxes_per_pixel 个。公开课里的做法是循环全部组合最后只保留前 boxes_per_pixel 组。第一次循环的 i0 会和所有 ratios 组合之后的 size 则只和第一个 ratio 有效这样的结果恰好就是num_sizes num_ratios - 1个框。这个逻辑藏在循环里不细看还真容易忽略。2.4 拼接与形状变换理解输出张量每一轮循环生成的张量形状是(网格中心点数, 4)也就是每个网格中心点对应一组锚框坐标。最后把所有组合串起来output torch.stack(boxes, dim1).reshape(-1, 4)torch.stack(boxes, dim1)会把列表里的多个(中心点数, 4)的张量堆叠成(中心点数, boxes_per_pixel, 4)最后reshape(-1, 4)把它拉平成(中心点数 × boxes_per_pixel, 4)。这个形状意思是说所有网格的所有候选框最终排成一列每个框占一行。第一维的用处是给后续的匹配、回归和分类提供索引。比如 4×4 特征图、每点 5 个锚框输出就是 80×44 列分别是 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(min0) inter_h (inter_y2 - inter_y1).clamp(min0) 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(min0)会把它截断成 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(min0)处理宽度高度时要留意 0 和非常接近 0 的值。IoU 计算中如果 union_area 是 0两个框面积都为 0除法会产生 nan。虽然正常数据里不会出现面积为 0 的框但 debug 或者边界测试时很容易碰到。可以在除法前加一个小数 epsilon比如1e-6避免 nan 污染后续计算。4.3 从 2×2 特征图开始调试很多同学学习锚框代码时喜欢直接拿一个 256×256 的图像去跑然后盯着几千个框发呆这其实很难排查问题。我的习惯是先从极小尺寸开始。如果你把特征图设成 2×2sizes 设成 [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. 锚框之后的路匹配、偏移与 NMS5.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 这两个基础工具彻底吃透后面的部分会顺畅很多。哪怕已经是工作中常用的技术回头补一下基础依然会发现自己曾经的很多理解都只是浮在表面。锚框这篇文章我写的比较细希望它也能帮你真正把这部分内容消化掉。