用PyTorch代码逐行实现Faster RCNN的RPN网络从Anchor生成到Proposal可视化当第一次看到Faster RCNN论文中那个复杂的RPN网络结构图时相信很多开发者都有过这样的困惑这些密密麻麻的Anchor到底是怎么生成的二分类和边界框回归的两个分支如何协同工作本文将用PyTorch代码逐行拆解这个黑盒子并通过Matplotlib可视化让你亲眼看到Anchor在原图上的分布最后输出符合要求的Proposal。不同于理论讲解我们将聚焦于代码实现细节让你真正掌握RPN的核心实现逻辑。1. 环境准备与基础配置在开始之前确保你的开发环境满足以下要求Python 3.6PyTorch 1.7OpenCVMatplotlibJupyter Notebook可选但推荐用于可视化首先导入必要的库并设置随机种子保证实验可复现import numpy as np import torch import torch.nn as nn import torch.nn.functional as F import cv2 import matplotlib.pyplot as plt from matplotlib.patches import Rectangle # 设置随机种子 torch.manual_seed(42) np.random.seed(42)定义基础配置参数这些参数直接影响Anchor的生成和后续处理class Config: # Anchor基础尺寸对应特征图上1个像素在原图上的感受野 anchor_base_size 16 # Anchor比例宽高比 anchor_ratios [0.5, 1, 2] # Anchor尺度面积倍数 anchor_scales [8, 16, 32] # 特征图下采样步长VGG16为16 feat_stride 16 # 用于训练的正负样本数量 n_train_pre_nms 12000 n_train_post_nms 2000 n_test_pre_nms 6000 n_test_post_nms 300 # NMS阈值 nms_thresh 0.7 # 判断正负样本的IoU阈值 pos_iou_thresh 0.7 neg_iou_thresh 0.3 # 正样本占batch的比例 pos_ratio 0.52. Anchor生成机制详解Anchor是RPN网络的核心概念它通过在特征图的每个位置上预设不同尺度和比例的候选框为后续的分类和回归提供基础。理解Anchor的生成过程是掌握RPN的关键。2.1 Anchor的数学原理Anchor的生成基于以下三个核心参数基础尺寸(base_size)特征图上1个像素对应原图的感受野大小VGG16中为16×16比例(ratios)控制Anchor的宽高比常用[0.5, 1, 2]尺度(scales)控制Anchor的面积大小常用[8, 16, 32]对于特征图上的每个点(x,y)会生成len(ratios)×len(scales)个Anchor。以默认参数为例每个点生成3×39个Anchor。2.2 Anchor生成代码实现下面我们实现Anchor生成的核心函数def generate_anchors(base_size16, ratios[0.5, 1, 2], scales[8, 16, 32]): 生成基础Anchor模板9个 返回anchors (9, 4)格式每行代表(x1,y1,x2,y2) # 中心点位于(base_size-1)/2处的基准Anchor base_anchor np.array([1, 1, base_size, base_size]) - 1 # 计算不同比例的Anchor ratio_anchors _ratio_enum(base_anchor, ratios) # 计算不同尺度的Anchor anchors np.vstack([_scale_enum(ratio_anchors[i], scales) for i in range(ratio_anchors.shape[0])]) return anchors def _ratio_enum(anchor, ratios): 枚举给定Anchor的不同比例变换 w, h, x_ctr, y_ctr _whctrs(anchor) size w * h size_ratios size / ratios ws np.round(np.sqrt(size_ratios)) hs np.round(ws * ratios) anchors _mkanchors(ws, hs, x_ctr, y_ctr) return anchors def _scale_enum(anchor, scales): 枚举给定Anchor的不同尺度变换 w, h, x_ctr, y_ctr _whctrs(anchor) ws w * np.array(scales) hs h * np.array(scales) anchors _mkanchors(ws, hs, x_ctr, y_ctr) return anchors def _whctrs(anchor): 将(x1,y1,x2,y2)格式转换为(width, height, x_center, y_center) w anchor[2] - anchor[0] 1 h anchor[3] - anchor[1] 1 x_ctr anchor[0] 0.5 * (w - 1) y_ctr anchor[1] 0.5 * (h - 1) return w, h, x_ctr, y_ctr def _mkanchors(ws, hs, x_ctr, y_ctr): 将(width, height, x_center, y_center)转换回(x1,y1,x2,y2)格式 ws ws[:, np.newaxis] hs hs[:, np.newaxis] anchors np.hstack(( x_ctr - 0.5 * (ws - 1), y_ctr - 0.5 * (hs - 1), x_ctr 0.5 * (ws - 1), y_ctr 0.5 * (hs - 1) )) return anchors2.3 Anchor可视化为了直观理解Anchor的分布我们可以在特征图上选择一个点可视化其对应的9个Anchordef plot_anchors(anchors, image_size800): 可视化生成的Anchor fig, ax plt.subplots(1, figsize(10, 10)) ax.set_xlim(0, image_size) ax.set_ylim(image_size, 0) # 图像坐标系y轴向下 # 假设Anchor中心在图像中心 center image_size // 2 for i, (x1, y1, x2, y2) in enumerate(anchors): # 将Anchor平移到图像中心 w x2 - x1 h y2 - y1 x1 center - w // 2 y1 center - h // 2 x2 center w // 2 y2 center h // 2 # 绘制Anchor rect Rectangle((x1, y1), w, h, linewidth1, edgecolornp.random.rand(3,), facecolornone) ax.add_patch(rect) ax.text(x1, y1, fA{i}, colorwhite, fontsize8, bboxdict(facecolorblack, alpha0.5)) plt.title(Generated Anchors (9种组合), fontsize15) plt.axis(off) plt.show() # 生成并可视化Anchor base_anchors generate_anchors() plot_anchors(base_anchors)运行上述代码你将看到中心在图像中间的9个不同大小和比例的Anchor这就是RPN网络在每个特征图位置上生成的候选框模板。3. RPN网络架构实现RPN网络由以下几个核心组件构成3×3卷积层提取每个位置的特征1×1分类卷积层输出每个Anchor的前景/背景分数1×1回归卷积层输出每个Anchor的坐标偏移量3.1 RPN类实现class RegionProposalNetwork(nn.Module): def __init__(self, in_channels512, mid_channels512): super().__init__() # 3x3卷积保持空间分辨率 self.conv nn.Conv2d(in_channels, mid_channels, 3, 1, 1) # 分类分支每个Anchor输出2个分数前景/背景 self.cls_score nn.Conv2d(mid_channels, len(Config.anchor_ratios) * len(Config.anchor_scales) * 2, 1, 1, 0) # 回归分支每个Anchor输出4个坐标偏移量 self.bbox_pred nn.Conv2d(mid_channels, len(Config.anchor_ratios) * len(Config.anchor_scales) * 4, 1, 1, 0) # 初始化权重 self._init_weights() def _init_weights(self): # 卷积层使用正态分布初始化 nn.init.normal_(self.conv.weight, std0.01) nn.init.normal_(self.cls_score.weight, std0.01) nn.init.normal_(self.bbox_pred.weight, std0.01) # 偏置项初始化 nn.init.constant_(self.conv.bias, 0) nn.init.constant_(self.cls_score.bias, 0) nn.init.constant_(self.bbox_pred.bias, 0) def forward(self, x): 输入 x: 特征图 (N, C, H, W) 输出 rpn_scores: 分类分数 (N, 2*9, H, W) rpn_deltas: 回归偏移量 (N, 4*9, H, W) # 3x3卷积 x F.relu(self.conv(x)) # 分类分支 rpn_scores self.cls_score(x) # (N, 18, H, W) # 回归分支 rpn_deltas self.bbox_pred(x) # (N, 36, H, W) return rpn_scores, rpn_deltas3.2 生成所有位置的Anchor在特征图的每个位置上应用基础Anchor模板def generate_all_anchors(feature_map_size, feat_stride16): 为整张特征图生成所有Anchor 返回anchors (H*W*9, 4) # 生成基础Anchor模板 (9,4) base_anchors generate_anchors() # 计算特征图上每个点的坐标 shift_x np.arange(0, feature_map_size[1]) * feat_stride shift_y np.arange(0, feature_map_size[0]) * feat_stride shift_x, shift_y np.meshgrid(shift_x, shift_y) shifts np.vstack((shift_x.ravel(), shift_y.ravel(), shift_x.ravel(), shift_y.ravel())).transpose() # 将基础Anchor应用到每个位置上 A base_anchors.shape[0] # 9 K shifts.shape[0] # H*W anchors base_anchors.reshape((1, A, 4)) shifts.reshape((1, K, 4)).transpose((1, 0, 2)) anchors anchors.reshape((K * A, 4)).astype(np.float32) return anchors3.3 可视化特征图上的Anchor为了理解Anchor在实际图像上的分布我们可以将生成的Anchor绘制在原图上def plot_anchors_on_image(image, anchors, n_anchors100): 在图像上随机采样n_anchors个Anchor进行可视化 fig, ax plt.subplots(1, figsize(10, 10)) ax.imshow(image) # 随机采样部分Anchor避免过于密集 if len(anchors) n_anchors: sample_idx np.random.choice(len(anchors), n_anchors, replaceFalse) anchors anchors[sample_idx] for i, (x1, y1, x2, y2) in enumerate(anchors): w x2 - x1 h y2 - y1 rect Rectangle((x1, y1), w, h, linewidth0.5, edgecolorr, facecolornone) ax.add_patch(rect) plt.title(fAnchors on Image (Sampled {n_anchors}/{len(anchors)}), fontsize15) plt.axis(off) plt.show() # 示例用法 # 假设我们有一个800x600的图像特征图大小为50x38 feature_map_size (50, 38) all_anchors generate_all_anchors(feature_map_size) # 加载示例图像 # image cv2.imread(example.jpg)[:,:,::-1] # BGR to RGB # plot_anchors_on_image(image, all_anchors)4. Proposal生成流程RPN网络的最终目标是生成高质量的Proposal这需要以下几个步骤将分类分数转换为概率应用回归偏移量调整Anchor位置裁剪超出图像边界的Proposal按分数排序并应用NMS4.1 处理RPN输出def process_rpn_outputs(rpn_scores, rpn_deltas, anchors, image_size): 处理RPN输出生成Proposal 参数 rpn_scores: (H*W*A, 2) rpn_deltas: (H*W*A, 4) anchors: (H*W*A, 4) image_size: (height, width) 返回 proposals: (N, 4) scores: (N,) # 将分类分数转换为概率 (使用softmax) scores F.softmax(rpn_scores, dim1)[:, 1] # 取前景分数 # 将deltas应用到Anchor上得到Proposal proposals apply_deltas_to_anchors(anchors, rpn_deltas) # 裁剪Proposal到图像边界内 proposals clip_boxes(proposals, image_size) # 移除太小或无效的Proposal keep filter_boxes(proposals, min_size16) proposals proposals[keep] scores scores[keep] # 按分数排序 order scores.argsort(descendingTrue) if Config.n_train_pre_nms 0: order order[:Config.n_train_pre_nms] proposals proposals[order] scores scores[order] # 应用NMS keep nms(proposals, scores, Config.nms_thresh) if Config.n_train_post_nms 0: keep keep[:Config.n_train_post_nms] proposals proposals[keep] scores scores[keep] return proposals, scores def apply_deltas_to_anchors(anchors, deltas): 应用回归偏移量到Anchor上 参数 anchors: (N, 4) [x1,y1,x2,y2] deltas: (N, 4) [dx,dy,dw,dh] 返回 boxes: (N, 4) [x1,y1,x2,y2] # 将Anchor转换为(center_x, center_y, w, h)格式 widths anchors[:, 2] - anchors[:, 0] 1.0 heights anchors[:, 3] - anchors[:, 1] 1.0 ctr_x anchors[:, 0] 0.5 * widths ctr_y anchors[:, 1] 0.5 * heights # 应用回归量 dx deltas[:, 0] dy deltas[:, 1] dw deltas[:, 2] dh deltas[:, 3] pred_ctr_x dx * widths ctr_x pred_ctr_y dy * heights ctr_y pred_w torch.exp(dw) * widths pred_h torch.exp(dh) * heights # 转换回(x1,y1,x2,y2)格式 pred_boxes torch.zeros_like(deltas) pred_boxes[:, 0] pred_ctr_x - 0.5 * pred_w # x1 pred_boxes[:, 1] pred_ctr_y - 0.5 * pred_h # y1 pred_boxes[:, 2] pred_ctr_x 0.5 * pred_w # x2 pred_boxes[:, 3] pred_ctr_y 0.5 * pred_h # y2 return pred_boxes def clip_boxes(boxes, image_size): 裁剪boxes到图像边界内 height, width image_size boxes[:, 0].clamp_(0, width - 1) # x1 boxes[:, 1].clamp_(0, height - 1) # y1 boxes[:, 2].clamp_(0, width - 1) # x2 boxes[:, 3].clamp_(0, height - 1) # y2 return boxes def filter_boxes(boxes, min_size): 过滤掉太小或无效的boxes ws boxes[:, 2] - boxes[:, 0] 1 hs boxes[:, 3] - boxes[:, 1] 1 keep (ws min_size) (hs min_size) return keep def nms(boxes, scores, threshold): 非极大值抑制 返回保留的索引 x1 boxes[:, 0] y1 boxes[:, 1] x2 boxes[:, 2] y2 boxes[:, 3] areas (x2 - x1 1) * (y2 - y1 1) order scores.argsort(descendingTrue) keep [] while order.numel() 0: if order.numel() 1: i order.item() keep.append(i) break else: i order[0].item() keep.append(i) # 计算当前box与其他box的IoU xx1 x1[order[1:]].clamp(minx1[i]) yy1 y1[order[1:]].clamp(miny1[i]) xx2 x2[order[1:]].clamp(maxx2[i]) yy2 y2[order[1:]].clamp(maxy2[i]) w (xx2 - xx1 1).clamp(min0) h (yy2 - yy1 1).clamp(min0) inter w * h iou inter / (areas[i] areas[order[1:]] - inter) # 保留IoU小于阈值的box idx (iou threshold).nonzero().squeeze() if idx.numel() 0: break order order[idx 1] # 1因为order[1:] return torch.tensor(keep, dtypetorch.long)4.2 可视化最终Proposaldef plot_proposals(image, proposals, scores, top_n50): 可视化得分最高的top_n个Proposal fig, ax plt.subplots(1, figsize(10, 10)) ax.imshow(image) # 按分数排序 order scores.argsort(descendingTrue)[:top_n] proposals proposals[order] scores scores[order] for i, (x1, y1, x2, y2) in enumerate(proposals): w x2 - x1 h y2 - y1 rect Rectangle((x1, y1), w, h, linewidth1, edgecolorg, facecolornone) ax.add_patch(rect) ax.text(x1, y1, f{scores[i]:.2f}, colorwhite, fontsize8, bboxdict(facecolorblack, alpha0.5)) plt.title(fTop {top_n} Proposals with Scores, fontsize15) plt.axis(off) plt.show() # 示例用法 # 假设我们已经有了RPN输出 # proposals, scores process_rpn_outputs(rpn_scores, rpn_deltas, all_anchors, (800,600)) # plot_proposals(image, proposals, scores)5. 训练RPN网络的技巧虽然我们已经实现了RPN的前向传播过程但要训练一个好的RPN网络还需要注意以下几点5.1 样本选择策略RPN训练时需要为每个Anchor分配标签正样本、负样本或忽略。正样本满足以下条件之一与某个GT box的IoU最高与任意GT box的IoU 0.7负样本与所有GT box的IoU 0.3。其余样本在训练中被忽略。def assign_anchor_labels(anchors, gt_boxes, pos_iou_thresh0.7, neg_iou_thresh0.3): 为Anchor分配标签 返回 labels: (N,) 1正样本, 0负样本, -1忽略 matched_gt_boxes: (N,4) 正样本匹配的GT box num_anchors anchors.shape[0] num_gt_boxes gt_boxes.shape[0] # 初始化标签为-1忽略 labels torch.full((num_anchors,), -1, dtypetorch.float32) matched_gt_boxes torch.zeros((num_anchors, 4), dtypetorch.float32) if num_gt_boxes 0: return labels, matched_gt_boxes # 计算所有Anchor与所有GT box的IoU (N,M) ious box_iou(anchors, gt_boxes) # 规则1为每个GT box分配IoU最高的Anchor best_anchor_per_gt, best_anchor_per_gt_idx ious.max(dim0) # 规则2分配IoU pos_iou_thresh的Anchor max_iou_per_anchor, _ ious.max(dim1) pos_mask max_iou_per_anchor pos_iou_thresh # 合并规则1和规则2 for gt_idx in range(num_gt_boxes): labels[best_anchor_per_gt_idx[gt_idx]] 1 labels[pos_mask] 1 # 分配负样本标签 labels[max_iou_per_anchor neg_iou_thresh] 0 # 为每个正样本分配匹配的GT box _, matched_gt_idx ious.max(dim1) matched_gt_boxes gt_boxes[matched_gt_idx] return labels, matched_gt_boxes def box_iou(boxes1, boxes2): 计算两组boxes之间的IoU 参数 boxes1: (N,4) boxes2: (M,4) 返回 iou: (N,M) area1 (boxes1[:,2] - boxes1[:,0] 1) * (boxes1[:,3] - boxes1[:,1] 1) area2 (boxes2[:,2] - boxes2[:,0] 1) * (boxes2[:,3] - boxes2[:,1] 1) lt torch.max(boxes1[:,None,:2], boxes2[:,:2]) # (N,M,2) rb torch.min(boxes1[:,None,2:], boxes2[:,2:]) # (N,M,2) wh (rb - lt 1).clamp(min0) # (N,M,2) inter wh[:,:,0] * wh[:,:,1] # (N,M) iou inter / (area1[:,None] area2 - inter) return iou5.2 损失函数设计RPN的损失函数由分类损失二分类交叉熵和回归损失Smooth L1组成class RPNLoss(nn.Module): def __init__(self): super().__init__() self.cls_loss nn.CrossEntropyLoss(reductionsum) self.reg_loss nn.SmoothL1Loss(reductionsum) def forward(self, pred_scores, pred_deltas, gt_labels, gt_deltas): 计算RPN损失 参数 pred_scores: (N,2) 预测的分类分数 pred_deltas: (N,4) 预测的回归偏移量 gt_labels: (N,) 真实标签 (1正样本, 0负样本, -1忽略) gt_deltas: (N,4) 真实的回归目标 返回 total_loss: 标量 # 只计算正负样本的损失忽略标签为-1的样本 pos_mask gt_labels 1 neg_mask gt_labels 0 valid_mask pos_mask | neg_mask # 分类损失 cls_loss self.cls_loss( pred_scores[valid_mask], gt_labels[valid_mask].long() ) # 回归损失只计算正样本 reg_loss self.reg_loss( pred_deltas[pos_mask], gt_deltas[pos_mask] ) # 归一化 num_pos max(1, pos_mask.sum().item()) cls_loss cls_loss / num_pos reg_loss reg_loss / num_pos # 总损失 total_loss cls_loss reg_loss return total_loss5.3 训练流程示例def train_rpn(model, dataloader, optimizer, device, epochs10): model.train() criterion RPNLoss() for epoch in range(epochs): for images, gt_boxes in dataloader: images images.to(device) gt_boxes gt_boxes.to(device) # 前向传播 features backbone(images) # 假设有预训练的主干网络 rpn_scores, rpn_deltas model(features) # 生成所有Anchor anchors generate_all_anchors(features.shape[-2:]) anchors torch.from_numpy(anchors).to(device) # 为Anchor分配标签 gt_labels, gt_deltas assign_anchor_labels(anchors, gt_boxes) # 计算损失 loss criterion(rpn_scores, rpn_deltas, gt_labels, gt_deltas) # 反向传播 optimizer.zero_grad() loss.backward() optimizer.step() print(fEpoch {epoch1}, Loss: {loss.item():.4f})6. 完整RPN实现与调试技巧将上述所有组件整合成一个完整的RPN实现并分享一些实际调试中的经验6.1 完整RPN类class FasterRCNN_RPN(nn.Module): def __init__(self, backbone, in_channels512, mid_channels512): super().__init__() self.backbone backbone self.rpn RegionProposalNetwork(in_channels, mid_channels) def forward(self, images, gt_boxesNone): # 提取特征 features self.backbone(images) # RPN前向传播 rpn_scores, rpn_deltas self.rpn(features) # 生成所有Anchor anchors generate_all_anchors(features.shape[-2:]) anchors torch.from_numpy(anchors).to(images.device) if self.training: # 训练模式计算损失 gt_labels, gt_deltas assign_anchor_labels(anchors, gt_boxes) loss self.compute_loss(rpn_scores, rpn_deltas, gt_labels, gt_deltas) return loss else: # 测试模式生成Proposal proposals, scores process_rpn_outputs( rpn_scores, rpn_deltas, anchors, images.shape[-2:] ) return proposals, scores def compute_loss(self, rpn_scores, rpn_deltas, gt_labels, gt_deltas): criterion RPNLoss() return criterion(rpn_scores, rpn_deltas, gt_labels, gt_deltas)6.2 调试技巧Anchor可视化检查确保Anchor在图像上的分布合理检查不同尺度和比例的Anchor是否正确生成训练过程监控正负样本比例是否平衡理想情况下正样本约占10-20%分类损失和回归损失的变化趋势Proposal的质量随训练的变化常见问题排查如果Proposal质量差检查Anchor的尺度和比例是否适合你的数据集如果回归损失不下降尝试减小学习率或检查梯度如果正样本太少可以适当降低pos_iou_thresh性能优化使用CUDA加速Anchor生成对NMS实现进行优化如使用torchvision.ops.nms批量处理图像提高吞吐量6.3 实际应用示例# 初始化模型 backbone ... # 预训练的VGG16或其他主干网络 model FasterRCNN_RPN(backbone).cuda() # 训练模式 model.train() optimizer torch.optim.SGD(model.parameters(), lr0.001, momentum0.9) # 训练循环 for epoch in range(10): for images, gt_boxes in train_loader: images images.cuda() gt_boxes gt_boxes.cuda() loss model(images, gt_boxes) optimizer.zero_grad() loss.backward() optimizer.step() print(fEpoch {epoch1}, Loss: {loss.item():.4f}) # 测试模式 model.eval() with torch.no_grad(): proposals, scores model(test_images.cuda()) # 可视化结果 plot_proposals(test_images[0].permute(1,2,0).cpu().numpy(), proposals.cpu(), scores.cpu())通过本文的代码实现和可视化你应该已经对RPN网络的工作原理有了直观的理解。记住理解RPN的关键在于掌握Anchor的生成机制和如何通过分类与回归分支来优化这些Anchor。在实际应用中你可能需要根据具体任务调整Anchor的尺度和比例以获得最佳性能。