资讯动态

PyTorch目标检测锚框原理与代码详解:从生成到标签分配

发布时间:2026/10/21 15:47:27 来源:尧图企业网站定制
很多人学目标检测时第一个真正卡住的地方就是锚框anchor box。不管是跟着经典入门课程里的PyTorch版代码走一遍还是自己去看开源检测项目锚框相关的张量操作总是绕不过去。课程节奏通常比较快像生成锚框、计算IoU、给锚框分配标签这几段代码跟着敲一遍似乎能跑但过几天回头看连变量形状都对不上。这篇文章就把PyTorch版本的锚框核心代码彻底拆开从锚框要解决的问题讲起一直到标签分配和偏移量是怎么算出来的再把常见坑一次性列清楚。适合刚看完卷积神经网络、准备进入目标检测的读者也适合那些公式看得懂、代码看得懵的人。1. 先搞清楚锚框到底在解决什么问题1.1 目标检测和图像分类的本质区别图像分类只回答“图里有什么”输出一个类别标签就够了。目标检测要回答的则是“图里有什么、分别在什么位置”这多出来的“位置”就是一切复杂度的源头。位置怎么表达最自然的方式就是给目标画一个矩形框用四个数字表示常见的有(x0, y0, x1, y1)和(cx, cy, w, h)两种。前者是左上角坐标加右下角坐标后者是中心点坐标加宽高。但问题来了模型输出什么才能定位目标你不能让模型凭空“想”出一个坐标那太难训练了。更现实的做法是预先布置一大堆候选框让模型回答“哪些框里真的有目标以及这些框需要怎么微调才能正好框住目标”。这些预先布置的候选框就是锚框。打个比方你在一片池塘里捞鱼不知道鱼在哪最笨但有效的办法就是撒一张网把池塘分成很多个小格子然后逐个格子检查有没有鱼、鱼有多大。锚框就是这张网的格子检测模型做的事情就是挨个格子判断并修正。1.2 锚框的数学定义两个关键参数锚框不是随便乱放的每个锚框都由中心坐标、宽度、高度唯一确定。为了让这套机制覆盖不同大小和不同形状的目标我们给每个像素位置生成多个框最常用的控制参数是两个尺寸scale控制锚框整体大小比如 0.75、0.5、0.25表示相对图像尺寸的比例。宽高比aspect ratio控制形状比如 1:1、2:1、1:2分别对应正方形、横矩形、竖矩形。假设我们选了 3 个尺寸和 3 个宽高比是不是每个像素点就有 3×39 个锚框常见实现里并不是这样的。很多代码会把组合数量算成num_sizes num_ratios - 1也就是 33-15 个。原因在于尺寸和宽高比不是完全交叉组合的而是取“某个基准尺寸配合所有宽高比”加上“其他尺寸配合基准宽高比”。这样既能控制锚框数量又能保证多尺度和多形状的覆盖。看这段大家熟悉的生成逻辑它先构造宽度和高度张量w torch.cat((size_tensor * torch.sqrt(ratio_tensor[0]), size_tensor[0] * torch.sqrt(ratio_tensor[1:]))) h torch.cat((size_tensor / torch.sqrt(ratio_tensor[0]), size_tensor[0] / torch.sqrt(ratio_tensor[1:])))第一次看这段很容易蒙为什么宽高比要套根号因为锚框是“中心点加宽高”描述的一个尺寸为 s、宽高比为 r 的框宽度是s * sqrt(r)高度是s / sqrt(r)。这样乘起来之后宽乘高正好等于s^2也就是这个锚框的面积保持不变变的只是形状。这样设计很合理尺寸决定面积级别宽高比决定拉长方向。1.3 为什么要在特征图的每个位置上都放锚框锚框是放在特征图上的不是直接放在原图上。一张 224×224 的输入图经过几轮卷积和下采样后特征图可能变成 32×32 或者更小。特征图每个位置对应原图上一个局部区域这些位置天然保留了空间信息所以把锚框布置在特征图格点上等价于在原图上铺了一层密集的候选框。锚框数量膨胀得非常快。假设特征图只有 32×321024 个点每个点 5 个锚框一张图就是 5120 个候选框。如果特征图再大一些比如 64×64每个点 9 个框那就是 36864 个。这还只是一张图。所以训练目标检测模型时输入尺寸不能太大背后一个很重要的原因就是锚框数量随特征图尺寸呈平方级增长显存和算力都扛不住。理解这一点后面看代码里各种 reshape 和 repeat 操作时心里就有底了。2. 锚框生成代码逐行拆解2.1 网格中心点是怎么生成的我们已经知道锚框要放在特征图的每个格点上。第一步需要得到特征图每个格点的中心坐标。常规实现会先把坐标归一化到 [0, 1] 区间这样不管输入图像多大锚框坐标系始终统一。核心代码是这一段feature_map_height, feature_map_width data.shape[-2:] steps_h 1.0 / feature_map_height steps_w 1.0 / feature_map_width center_h (torch.arange(feature_map_height, devicedevice) 0.5) * steps_h center_w (torch.arange(feature_map_width, devicedevice) 0.5) * steps_w这里的 0.5不是随便加的。像素坐标是整数比如 (0, 0)、(0, 1)代表的是一个个小方块。如果直接拿整数坐标当锚框中心所有锚框都会偏向像素块的左上角。加上 0.5 再除以总长度得到的是每个像素方块的中心。这个细节很多人忽略但它直接影响锚框和真实框的 IoU 计算精度。拿到一维的中心坐标后要用meshgrid把它扩展成二维网格。这里需要注意indexing参数的写法PyTorch 的meshgrid默认是ij和 MATLAB 一致。如果你用了xy得到的 shift_x 和 shift_y 就互换位置了后面锚框会整体错位。我强烈建议这里统一用indexingij并且生成后立刻打印形状确认维度别凭感觉往下写。shift_y, shift_x torch.meshgrid(center_h, center_w, indexingij) shift_y shift_y.reshape(-1) shift_x shift_x.reshape(-1)reshape 成一维后shift_x 和 shift_y 的长度都是feature_map_height * feature_map_width它们一一对应共同构成特征图上的所有中心点。2.2 从中心点坐标到完整锚框张量有了中心点和每个组合的宽度高度就可以组装锚框了。完整生成函数的典型实现长这样def multibox_prior(data, sizes, ratios): in_height, in_width data.shape[-2:] device data.device num_sizes, num_ratios len(sizes), len(ratios) boxes_per_pixel num_sizes num_ratios - 1 size_tensor torch.tensor(sizes, devicedevice) ratio_tensor torch.tensor(ratios, devicedevice) # 生成所有宽高组合 w torch.cat((size_tensor * torch.sqrt(ratio_tensor[0]), size_tensor[0] * torch.sqrt(ratio_tensor[1:]))) h torch.cat((size_tensor / torch.sqrt(ratio_tensor[0]), size_tensor[0] / torch.sqrt(ratio_tensor[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 shift_y, shift_x torch.meshgrid(center_h, center_w, indexingij) shift_y shift_y.reshape(-1) shift_x shift_x.reshape(-1) # 每个中心点都要重复 boxes_per_pixel 次 shift_x_rep shift_x.repeat_interleave(boxes_per_pixel) shift_y_rep shift_y.repeat_interleave(boxes_per_pixel) # 宽和高也要铺满所有位置 all_w w.repeat(in_height * in_width) all_h h.repeat(in_height * in_width) # 组合成 [center_x, center_y, w, h] anchor_center torch.stack((shift_x_rep, shift_y_rep, all_w, all_h), dim1) # 转成左上角右下角形式 x0 anchor_center[:, 0] - anchor_center[:, 2] / 2 y0 anchor_center[:, 1] - anchor_center[:, 3] / 2 x1 anchor_center[:, 0] anchor_center[:, 2] / 2 y1 anchor_center[:, 1] anchor_center[:, 3] / 2 out torch.stack((x0, y0, x1, y1), dim1) return out.reshape(in_height, in_width, boxes_per_pixel, 4)这段代码里最容易写错的是repeat_interleave和repeat的混用。shift_x.repeat_interleave(boxes_per_pixel)是让每个中心坐标连续重复 5 次得到[x1, x1, x1, x1, x1, x2, x2, ...]而w.repeat(in_height * in_width)是把整组宽高列表反复平铺得到[w1, w2, w3, w4, w5, w1, w2, ...]。它们对齐之后每个中心点对应的 5 个锚框正好是相邻的。如果你把 repeat_interleave 和 repeat 用反了锚框的中心和宽高就错位了轻则可视化时框的位置不对重则后面标签分配全乱掉。课程里常见版本可能写法稍有不同比如有的先用torch.stack((shift_x, shift_y, shift_x, shift_y), dim1).repeat(...)再调整有的包装成了更简洁的形式但核心逻辑都是这一个中心点重复、宽高平铺、转成坐标。我建议你以能理解的方式重写一遍不要死记硬背原版。2.3 用最小特征图验证生成结果代码写完之后别急着拿去训练先用一个极端小的例子验证。比如特征图是 3×3sizes[0.5, 0.25]ratios[1, 2, 0.5]组合数就是 23-14每个位置 4 个锚框总共 3×3×436 个。打印出来前 4 个框的坐标anchors multibox_prior(torch.zeros((1, 3, 3, 3)), [0.5, 0.25], [1, 2, 0.5]) print(anchors.shape) # torch.Size([3, 3, 4, 4]) print(anchors[0, 0]) # 第一个像素的4个锚框你能看到这 4 个框共享同一个中心点只是宽高不同。这就对了。这种小规模验证比直接跑完整模型快得多也能在第一时间暴露坐标顺序、维度错误这类低级问题。我每次改完锚框相关代码都会先跑这一步。3. IoU 计算两个框到底重合约多少3.1 交并比的直觉理解有了成千上万个锚框怎么判断哪些框和目标靠得近靠得近的标准是什么最常用的度量就是 IoUIntersection over Union交并比——两个框交集面积除以并集面积。IoU 1 说明两个框完全重合IoU 0 说明完全不相交。检测任务里IoU 是衡量“预测得准不准”最核心的标尺。用一个停车位的类比来说你倒车入库你的车身是一个框车位线是另一个框。车身和车位线重合得越多说明停得越正。IoU 就是这个“重合程度”的量化。3.2 从单个框理解交集计算两个矩形框求交集的规则非常朴素交集矩形的左上角是两者左上角中的较大者交集矩形的右下角是两者右下角中的较小者。如果这样算出来的宽高是负数说明两者根本没有交集。def box_iou_single(box1, box2): # box1, box2 都是 [x0, y0, x1, y1] left_top_x max(box1[0], box2[0]) left_top_y max(box1[1], box2[1]) right_bottom_x min(box1[2], box2[2]) right_bottom_y min(box1[3], box2[3]) inter_w max(right_bottom_x - left_top_x, 0) inter_h max(right_bottom_y - left_top_y, 0) inter_area inter_w * inter_h area1 (box1[2] - box1[0]) * (box1[3] - box1[1]) area2 (box2[2] - box2[0]) * (box2[3] - box2[1]) union_area area1 area2 - inter_area return inter_area / union_area这里inter_w和inter_h必须做max(..., 0)处理否则两个不相交的框会算出负面积IoU 变负数后面所有逻辑都会跟着错。3.3 批量 IoU 的广播实现上面只是单个框对单个框。实际场景里我们要拿几千个锚框去对比几个真实框需要一次性得到一个(num_anchors, num_gt)形状的 IoU 矩阵。这时候用 PyTorch 的广播机制最优雅def box_iou(boxes1, boxes2): def box_area(boxes): return (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) area1 box_area(boxes1) # (N,) area2 box_area(boxes2) # (M,) # 关键在前后各插入一个维度让两个张量广播成 (N, M, 2) left_top torch.max(boxes1[:, None, :2], boxes2[None, :, :2]) right_bottom torch.min(boxes1[:, None, 2:], boxes2[None, :, 2:]) wh (right_bottom - left_top).clamp(min0) inter wh[:, :, 0] * wh[:, :, 1] # (N, M) union area1[:, None] area2[None, :] - inter return inter / union这段代码第一次看可能有点晕核心全在boxes1[:, None, :2]和boxes2[None, :, :2]。boxes1的形状是 (N, 4)插入一个维度变成 (N, 1, 2)boxes2形状是 (M, 4)变成 (1, M, 2)。两个张量做 max 运算时PyTorch 会自动把 (N, 1, 2) 和 (1, M, 2) 广播成 (N, M, 2)正好对应任意一对锚框和真实框左上角坐标的比较。IoU 的取值区间和直观含义大致如下IoU 区间含义常见用途0 ~ 0.1基本不重合可视为背景负样本候选0.1 ~ 0.3弱重叠语义模糊训练时通常忽略或降低权重0.5 ~ 0.7明显重叠但不够精确许多检测器默认正样本阈值取 0.50.7 ~ 1.0高度重合定位质量高高精度要求任务的正样本标准实际训练里正样本阈值选 0.5 还是 0.7会直接影响召回率和精确率。0.5 更宽松正样本多模型不容易漏检但容易误检0.7 更严格正样本少但质量高。这个取舍在调参时非常重要。4. 给锚框分配标签正样本、负样本和偏移量4.1 基于 IoU 矩阵的匹配策略锚框只是候选真正训练时要让模型知道哪些锚框应该负责预测哪个目标。这个过程叫标签分配也就是把每个锚框标记为正样本、负样本或者忽略。常见实现基于 IoU 矩阵做贪婪匹配大概分这几步计算所有锚框与所有真实框的 IoU 矩阵形状是 (num_anchors, num_gt)。针对每个真实框找到一个与之 IoU 最大的锚框把这个锚框直接分配为该真实框的正样本。这一步很关键它保证了每个真实框至少有一个锚框“负责”不管 IoU 阈值设得多高。处理剩余的锚框凡是与任一真实框的 IoU 大于预设阈值比如 0.5的也标记为正样本负责预测离它最近的那个真实框。剩下的锚框全部视为背景负样本。注意一点步骤 2 和步骤 3 是有先后逻辑的。如果不先给每个真实框拉一个“保底锚框”极端情况下可能出现某个真实框附近的所有锚框都低于阈值导致这个目标完全没有正样本负责训练直接漏检。先保底、再扩展这是锚框匹配里一个很经典的工程细节。# 简化版找到每个真实框的最佳匹配锚框 def assign_anchor_to_bbox(ground_truth, anchors, iou_threshold0.5): num_anchors anchors.shape[0] num_gt ground_truth.shape[0] jaccard box_iou(anchors, ground_truth) # (num_anchors, num_gt) # 每个GT先抢一个IoU最大的锚框 max_iou_per_gt, max_idx_per_gt torch.max(jaccard, dim0) anchor_to_gt torch.full((num_anchors,), -1, dtypetorch.long) for gt_idx in range(num_gt): anchor_to_gt[max_idx_per_gt[gt_idx]] gt_idx # 其余超过阈值的锚框也分配 high_iou_mask jaccard.max(dim1).values iou_threshold best_gt_for_anchor jaccard.argmax(dim1) anchor_to_gt[high_iou_mask] best_gt_for_anchor[high_iou_mask] return anchor_to_gt上面是高度简化的版本真实课程代码会用一个 while 循环维护行和列的“丢弃”状态逻辑更严谨但核心思想一样。看懂这个简化版再回看原版会轻松很多。4.2 回归目标为什么设计成“中心偏移 长宽缩放”有了正样本锚框模型的任务就是把这些锚框微调到真实框。微调不是直接回归真实框坐标而是回归一个偏移量。最常见的编码方式是把真实框中心和锚框中心的差值除以锚框宽高同时把真实框宽高和锚框宽高的比值取对数。def offset_boxes(anchors, assigned_bb, eps1e-6): c_anc box_corner_to_center(anchors) c_ass box_corner_to_center(assigned_bb) offset_xy (c_ass[:, :2] - c_anc[:, :2]) / c_anc[:, 2:] offset_wh torch.log(eps c_ass[:, 2:] / c_anc[:, 2:]) offset torch.cat((offset_xy, offset_wh), dim1) return offset这里要解释两个为什么。第一为什么要除以锚框的宽高因为不同锚框大小差异很大如果不归一化大锚框的偏移量数值天然就大模型会被这些大数值主导小目标和精细位置就学不好。归一化之后偏移量变成“相对比例”不管锚框大小尺度都统一了。第二宽高比为什么要取 log原因很直白宽高比的数值范围可能横跨好几个数量级比如 0.25 到 4。如果直接回归这个比值模型需要拟合的非线性关系太强。取 log 之后log(2) 和 log(0.5) 分别等于 0.69 和 -0.69对称且数值区间收窄回归起来平滑得多。这种“把乘法关系变成加法关系”的处理方式在检测里几乎成了标准操作。4.3 损失函数是怎么同时管住分类和回归的锚框分配结束后每个锚框身上扛着两种任务判断自己属于哪个类别包括背景以及预测自己到真实框的偏移量。训练损失由两部分组成分类损失所有锚框都参与用交叉熵正样本的标签是具体目标类别负样本的标签是背景。回归损失只要正样本参与典型用 Smooth L1 损失负样本因为没有要拟合的真实框回归损失直接置为 0。总损失一般是分类损失和回归损失的加权和回归部分往往会乘以一个平衡系数。为什么需要这个系数因为负样本数量通常远多于正样本分类任务的梯度容易被海量背景框带偏适当压低回归分支的幅度或者对负样本降采样训练会更稳。这段理解透了再去看检测器的训练循环代码里那些mask操作、loss_cls和loss_bbox分开累加的逻辑一下子就通了。5. 可视化锚框把抽象的坐标变成眼睛能看到的东西5.1 一个顺手的最小可视化函数代码调通了锚框也生成了如果不可视化你很难直观感受到“尺寸 0.75 配宽高比 1:1”到底是什么概念。可视化函数其实很简单用 matplotlib 往图上贴矩形就行。import matplotlib.pyplot as plt import matplotlib.patches as patches def show_bboxes(axes, bboxes, labelsNone, colorsNone): for i, bbox in enumerate(bboxes): color colors[i % len(colors)] if colors else red rect patches.Rectangle( xy(bbox[0], bbox[1]), widthbbox[2] - bbox[0], heightbbox[3] - bbox[1], fillFalse, edgecolorcolor, linewidth2 ) axes.add_patch(rect) if labels: axes.text(bbox[0], bbox[1], labels[i], bboxdict(facecolorcolor, alpha0.5), fontsize10)用法很简单先在推理模式下载入一张测试图得到真实框然后从某个位置取几个锚框画上去。我第一次这样做的时候最大的感受是原来“尺寸为 0.75”的锚框在 400×400 的图上能覆盖三分之一的区域而“0.25”的框小得像个贴纸。多尺度覆盖这个概念看十遍公式都不如肉眼扫一眼图来得直观。5.2 可视化之后的调参心得看可视化结果时重点观察三件事第一大目标区。目标高度超过图像高度一半的时候如果不设置一个接近 0.5 甚至更大的尺寸所有锚框都包不住目标匹配时 IoU 必然偏低正样本质量会很差。第二长条形目标。比如躺在草地里的狗或者竖着的路灯用 1:1 的框去匹配无论怎么微调都很难达到高 IoU。这时候就要靠 1:2 或 2:1 的宽高比来覆盖。第三密集小目标场景。如果图里有大量小物体就需要更小的尺度或者干脆提高特征图分辨率否则小目标附近根本没有足够细密的锚框。很多人调整锚框参数喜欢拍脑袋我的建议是做一个简单的统计分析把数据集中所有真实框的宽高比排个序看看分布集中在哪里再反推 sizes 和 ratios。数据驱动地调参比自己瞎试高效得多。5.3 锚框代码看完之后下一步看什么锚框是检测头的前置基础。理解锚框之后再看别的检测模型会很顺畅因为它们本质上都是在解决同一个问题怎么把候选框和真实目标对齐。Fast R-CNN 系列是“先提框再分类”有了锚框的概念你就知道它提出的那些 region proposal 其实可以被锚框替代。SSD 和 YOLO 直接在不同特征图层级上铺锚框充分利用了深层特征图负责大目标、浅层特征图负责小目标的特性。看到这里你已经具备了自己手写一个最小目标检测训练流程的基础。下一步可以找一个公开的小数据集比如单类别的玩具数据集套上自己写的锚框生成、标签分配和损失计算跑一个极小的检测模型看看效果。这个过程走通了你对检测的理解会明显不一样。6. 常见问题排查与避坑清单代码实践里锚框这块的 bug 往往不是难在算法而是难在张量形状和坐标约定的混乱。我把实际踩过的坑整理成了一张速查表现象常见原因解决思路生成的锚框坐标有负数或大于 1中心点坐标没有归一化步长用的像素值而非 1/尺寸检查 steps 计算确认除以了特征图尺寸画出来的框位置整体偏移meshgrid 的 indexing 参数不一致导致 x 和 y 互换统一用 indexingij打印前两个中心点验证IoU 计算结果全是 0两组框的坐标约定不一致一组是[x0,y0,x1,y1]另一组是[cx,cy,w,h]统一转换函数先转成同一种表示再算 IoU正样本数量少到离谱锚框的 sizes 和真实目标尺寸差太多IoU 全部低于阈值用可视化观察覆盖情况调整 sizes 或降低阈值训练时显存不足特征图太大导致锚框数量爆炸缩小输入尺寸或者减少每像素锚框数量锚框顺序和标签对不上repeat 和 repeat_interleave 混用小张量打印验证确认每个中心的框是按固定顺序排列的除此之外有一条非常实用的排错技巧任何和张量形状相关的疑虑都不要靠肉眼读代码直接把中间结果的 shape 打出来看。比如要确认shift_x_rep和all_w是否对齐打印前 15 个元素就一目了然。这种“打印十行胜过读十遍”的习惯在学检测代码时特别好用。还有一点我自己第一次把锚框跑通之后犯过的错是用了训练时的数据预处理把图像边缘做了归一化或裁剪却忘了把锚框坐标的归一化基准同步改掉。锚框坐标是基于原始图归一化还是基于预处理后图像归一化必须在代码注释里写清楚否则后续可视化全部错位。这类问题很难一眼找出花半小时对比代码才能定位。动手练一遍的最终建议锚框这节代码最终目标不是背下来而是成为你工具箱里可以随手组装的基础组件。我自己的做法是抛开课程原码按照理解自己独立写一份生成函数和 IoU 函数然后用小特征图跑一遍和原版输出对比。第一次完全跑通时输出的张量形状一比对那种“原来这段代码是这个意思”的感觉比看多少视频都管用。后面做检测任务时遇到新问题比如多尺度锚框部署在多个特征图上或者需要自定义锚框匹配策略再回头看这节课的思路理解也会快很多。锚框并不是一个已经过时的技术很多现代检测算法依然保留着类似的思想只是换了个形态。把这层地基打牢后面学任何检测模型都不会虚。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑