简介:这是一套基于PyTorch的Faster R-CNN目标检测实现,面向目标检测学习者与算法工程师。资源覆盖RPN候选区域生成、ROI池化、分类与回归等核心模块,可帮助读者从数据准备、模型定义到训练推理快速搭建完整流程。压缩包共68个文件,以Python源码为主(33个py),另含21个pyc、4个xml、3个txt、2个预训练权重pth及示例图片,整体约239.44MB,目录结构清晰。目前已有1075人浏览学习。训练侧支持ResNet50、MobileNetV2、VGG等主干搭配FPN,提供单/多GPU训练、验证、预测、mAP评估、FPS测试与损失曲线绘制等脚本,并附带VOC2012数据集示例与依赖清单。无论用于课程设计、论文复现,还是迁移到自定义数据集,都有较高参考价值。
1. 为什么目标检测绕不过 Faster R-CNN:两阶段模型的精度下限
很多刚接触目标检测的开发者,一上来就去追 YOLO 系列的实时指标,觉得 Faster R-CNN 又慢又老。真到工业场景里跑一轮才发现,小目标、密集遮挡、误检率这些指标面前,Faster R-CNN 依然是那个不容易翻车的兜底方案。这个 Pytorch 版本的实现,不是让你在生产环境里纠结每秒跑多少帧,而是让你在精度优先的任务里有一个稳定的基准线:它把「在哪找目标」和「这是什么目标」拆成两个阶段,每一阶段都能单独调,这也是为什么直到今天,学术界和工业界做高精度检测时仍把它当默认起点。这篇文章按我平时落地项目的顺序来写,先把结构拆清楚,再给能直接跑的训练脚本,最后把高发问题一次说透。
2. Pytorch 版 Faster R-CNN 的结构拆解:RPN 与 RoI Head 的分工
2.1 输入到输出:一条检测框的完整路径
Faster R-CNN 的 Pytorch 实现虽然封装得漂亮,但底层依旧是一条固定的数据流。一张图进入模型后先过 backbone 提取特征,常见是 ResNet50 + FPN;FPN 把不同尺度的特征图输出成 P2 到 P5,再额外生成一个 P6 给 RPN 用。RPN 在这几层特征图上铺预设框,也就是 anchor,然后对每个 anchor 做一次二分类判断「是不是目标」和一次四参数回归「框该往哪挪」。RPN 输出的 proposal 经过 NMS 过滤后,送进 RoI Head:先用 RoIAlign 把不同大小的候选区域统一池化成固定尺寸,再做分类和精细回归。这里要记住一个关键数字:Pytorch 版本里 RPN 每层铺 3 个比例 × 3 个面积组合,FPN 五层共 15 组 anchor,小目标靠 P2 的高分辨率特征,大目标靠 P5、P6 的高层语义特征,这个设计直接影响后续对小目标的召回。
训练时这个模型同时对四个部分算损失:RPN 的分类损失、RPN 的回归损失、RoI Head 的分类损失、RoI Head 的回归损失。Pytorch 实现把这四部分打包返回,在训练脚本里能看到一个 dict,里面分别是 loss_objectness、loss_rpn_box_reg、loss_classifier、loss_box_reg。我调参时最常看的不是总 loss,而是单独盯这四个分量。如果 loss_objectness 降得慢,说明正负样本不均衡;如果 loss_box_reg 大头是空的,说明 anchor 和真实框的匹配有问题,可以先怀疑数据集标注框是不是有越界或者空标注。
2.2 用 torchvision 还是手写网络,先把边界想清楚
Pytorch 生态里跑 Faster R-CNN 最常见的方式是直接用 torchvision 提供的 fasterrcnn_resnet50_fpn 或者 fasterrcnn_resnet50_fpn_v2。前者是经典结构,后者在训练策略和 backbone 细节上做了强化,两者的权重都是在大规模检测数据上预训练好的。我一般会建议:如果项目目标是快速拿到一个可用模型,直接用 torchvision 的预训练权重做微调,别手写;如果后续要换注意力机制、改检测头、把分类分支换成多任务分支,那再考虑把网络拆开改。很多开发者在这一步被「手写 = 厉害」的想法坑了,花两周写 RPN,最后发现和 torchvision 实现的行为对不齐,后悔药都没处买。
torchvision 版本里有一个容易忽略的模块叫 GeneralizedRCNNTransform,它负责把输入图像统一缩放到短边 800、长边不超过 1333,并且把像素值归一化。这个模块是内置在 fasterrcnn_resnet50_fpn 内部的,所以很多人在自定义数据集时发现图片明明尺寸不同,模型照样能跑,就是它在背后做了规整。如果你决定手写网络,这部分也得自己复刻,否则 batch 内图像尺寸不统一,backbone 是能跑,但 RoIAlign 的坐标映射会出现偏差,典型表现就是 loss 不降、检测框整体偏移。手写前先问自己一句:我要改的到底是 backbone、RPN 还是 head?只改一处的话,优先用 torchvision 的类做继承重写,而不是从零搭。
3. 把数据集装进目标检测训练流程:Dataset、collate 与尺寸策略
3.1 自定义 Dataset 的 target 格式与数据加载
Pytorch 版本的 Faster R-CNN 对数据格式有严格要求,不满足会直接报错或者 silently 出问题。模型期望每个样本返回 image 和 target 两个对象,其中 image 是 [3, H, W] 的浮点张量,target 是一个 dict,至少包含 boxes、labels、image_id,最好再补上 area 和 iscrowd。boxes 必须是 [N, 4] 的浮点张量,每一行是 [x1, y1, x2, y2],注意是左上角和右下角的绝对像素坐标,不是中心点加宽高的格式。labels 是 [N] 的整数张量,类别从 1 开始编号,0 默认留给背景,这个约定和模型内部的背景类设计强绑定。下面是一个从 VOC 风格标注文件读取数据的简化版 Dataset,重点看返回结构和格式转换。
import torch import torchvision from torch.utils.data import Dataset from PIL import Image class VocStyleDataset(Dataset): def __init__(self, image_paths, annotations): """ image_paths: 图片路径列表 annotations: list of dict, 每个 dict 包含 boxes 和 labels 两个 numpy 数组 """ self.image_paths = image_paths self.annotations = annotations def __getitem__(self, idx): img = Image.open(self.image_paths[idx]).convert("RGB") # 转成 float 并归一化到 [0, 1],torchvision 模型内部会再减均值除方差 img = torch.as_tensor(img, dtype=torch.float32).permute(2, 0, 1) / 255.0 ann = self.annotations[idx] boxes = torch.as_tensor(ann["boxes"], dtype=torch.float32) labels = torch.as_tensor(ann["labels"], dtype=torch.int64) # area 必须提供,评估阶段计算 mAP 要用;iscrowd 用于区分重叠标注 area = (boxes[:, 3] - boxes[:, 1]) * (boxes[:, 2] - boxes[:, 0]) iscrowd = torch.zeros((boxes.shape[0],), dtype=torch.int64) target = { "boxes": boxes, "labels": labels, "image_id": torch.tensor([idx], dtype=torch.int64), "area": area, "iscrowd": iscrowd, } return img, target def __len__(self): return len(self.image_paths)这段代码里有几个容易被忽略的细节。image 先手动除以 255,是因为 GeneralizedRCNNTransform 内部假设输入是 [0,1] 浮点图,它会再执行均值和方差归一化;如果你从 OpenCV 读图直接转,容易把值域带成 [0,255],后果是 loss 一开始就畸高。area 用宽乘高算出来,不用真实像素面积也行,但一定得是正数,负数会在评估时触发断言错误。labels 从 1 开始这件事,新手最容易翻车:如果标签文件里背景是 0,前景是 1,忘了给所有类别 +1,训练出的模型类别全部错位一个下标,精度看上去还行,实际推理结果全对不上。
3.2 图片尺寸不统一怎么办:长边缩放与 batch 组装
检测数据集的天然问题是图片尺寸各不相同,Faster R-CNN 虽然内置了缩放逻辑,但 DataLoader 层面你仍然要决定怎么组装 batch。Pytorch 的 DataLoader 默认会把多个样本 stack 成一个张量,检测任务里每张图的 target 结构都不同,直接 stack 必炸,因此必须写一个自定义的 collate_fn。常见做法很简单:返回一个 tuple 列表,而不是把一个 batch 堆成张量。模型的 forward 在训练和推理时会自己处理单图输入,所以这种「松散 batch」是官方推荐的方式。注意如果你的 dataset 里做了随机裁剪或者 resize,torchvision 的 transform 模块和 GeneralizedRCNNTransform 的缩放逻辑会叠加,尺寸策略会变得不可控。
def collate_fn(batch): """ 自定义 batch 组装函数:把一组 (image, target) 打包成两个列表 Faster R-CNN 的前向方法接受这种形式的输入 """ images = [item[0] for item in batch] targets = [item[1] for item in batch] return images, targetscollate_fn 的作用是把原本要 stack 的行为替换成「原样收集」。images 是一个 list,里面每个元素是 [3, H, W] 的张量,H 和 W 可以不同;targets 是一个 list,每个元素是 dict。DataLoader 里设置 collate_fn=collate_fn 后,模型 forward 的签名是 forward(images, targets),正好对上。如果你的数据集中图片分辨率差异极大,比如混着 480p 和 4K 图,我建议在 Dataset 里就做一次长边缩放,缩到短边 800、长边 1333 以内,这样能显著降低显存峰值。不要用方形 resize 硬压,长宽比一旦变化,检测框坐标虽然跟着图走了,但视觉形变会让小目标直接消失。
4. 在 Pytorch 里训练 Faster R-CNN:最小脚本与三个关键超参
4.1 最小训练脚本与关键解释
有了 Dataset 和 collate_fn,训练主循环其实很简洁,难点在超参搭配和资源分配。下面是我在单卡 GPU 上跑微调的最小脚本,覆盖了模型加载、优化器设置、训练循环和保存权重。Pytorch 官方推荐用 SGD + momentum,而不是 Adam,这一点和分类任务习惯差别很大,很多人第一次用 Adam 训检测器,发现 loss 可以降但 mAP 上不去,就是因为检测头对学习率的响应模式和分类任务完全不同。
import torch import torchvision from torchvision.models.detection import fasterrcnn_resnet50_fpn # 类别数 = 你的前景类别数 + 1(背景) num_classes = 5 # 假设有 4 类前景 model = fasterrcnn_resnet50_fpn(weights="DEFAULT") in_features = model.roi_heads.box_predictor.cls_score.in_features model.roi_heads.box_predictor = torchvision.models.detection.faster_rcnn.FastRCNNPredictor( in_features, num_classes ) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) params = [p for p in model.parameters() if p.requires_grad] optimizer = torch.optim.SGD(params, lr=0.005, momentum=0.9, weight_decay=0.0005) # 学习率在第 2/3 个 epoch 后衰减一次 lr_scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=2, gamma=0.1) for epoch in range(6): model.train() for images, targets in data_loader: images = [img.to(device) for img in images] targets = [{k: v.to(device) for k, v in t.items()} for t in targets] loss_dict = model(images, targets) losses = sum(loss for loss in loss_dict.values()) optimizer.zero_grad() losses.backward() optimizer.step() # 打印四个分量,监控哪个部分没收敛 loss_str = " ".join(f"{k}: {v:.3f}" for k, v in loss_dict.items()) print(f"epoch {epoch} loss: {losses:.3f} | {loss_str}") lr_scheduler.step() torch.save(model.state_dict(), f"faster_rcnn_epoch{epoch}.pth")这段代码的关键在替换 box_predictor。原模型的分类头输出是 91 类(COCO 默认),必须替换成 FastRCNNPredictor,输入维度保持 in_features 不变,输出维度改成 num_classes。这里最容易踩的坑是 num_classes 忘记 +1,导致类别数对不上,训练时会直接报 shape mismatch。优化器只对 requires_grad=True 的参数生效,预训练 backbone 默认是可训练状态,如果你的数据集很小,可以考虑把 backbone 冻住只训练 RPN 和 RoI Head。学习率 0.005 是我在 batch_size 8 左右常用的起点,如果你的显存只能跑 batch_size 2 或 4,学习率应降到 0.002 甚至更低,否则 loss 会在前两个 epoch 震荡,后面怎么调都救不回来。
4.2 影响训练收敛的三个超参:lr、anchor 与 NMS 阈值
先记住一个结论:在 Faster R-CNN 里,学习率比模型结构更容易决定成败。SGD 的 lr 我通常从 0.005 起步,StepLR 按 epoch 衰减,如果你打算跑 12 epoch,步长设成 4 或者 5 更稳。这里有个经验公式供参考:batch_size 减半,lr 也减半;数据量增大 10 倍,lr 可以尝试放大 2 倍,但不要超过 0.02。很多人迷信 Adam 在分类任务上的表现,直接迁移过来,结果是 loss 下降快但 mAP 冲到 70 就停滞;替换回 SGD 后同样的 epoch 能逼近 75,这个差异本质上是检测任务的回归分支对二阶动量不敏感,Adam 容易在小梯度上步长过大,导致框的细微调整被跳过。
第二个关键是 anchor。torchvision 默认的 anchor 面积是 32² 到 512²,覆盖常规目标,但如果你检测的是工业零件或者小物体,比如 20×20 的缺陷区域,默认 anchor 完全覆盖不到小尺寸段,RPN 会把这些目标全部当成背景。修改方法是在构造模型时传入 rpn_anchor_generator 参数,自定义 anchor 的面积和比例。比如只想要小目标,可以把 aspect_ratios 保持 [0.5, 1.0, 2.0],但把 anchor_sizes 改成 ((16,), (32,), (64,), (128,), (256,)),这样 P2 层的最小的 anchor 面积就从 32 降到 16。
第三个参数是 NMS 阈值,它出现在 RPN 的 proposal 筛选和 RoI Head 的最终输出两个位置。默认值是 0.7 和 0.5,前者控制候选框保留数量,后者控制最终去重。如果你的场景里重叠目标很多,比如成堆的零件,建议把最终输出的 nms_thresh 调高到 0.6,代价是轻微误检;如果你的场景里目标稀疏且边界清晰,把阈值调到 0.4 能显著降低重复框。注意 RPN 阶段的 NMS 阈值改动会直接影响 proposal 数量,一般不动,只动最终输出层。
4.3 训练中看什么指标:loss_dict 里的四个分量
训练时不要只盯总 loss,要分别记录 loss_objectness、loss_rpn_box_reg、loss_classifier、loss_box_reg 四个分量的变化趋势。loss_objectness 负责「有没有目标」,如果这个值在 0.05 附近波动但降不下去,通常不是模型问题,而是 anchor 和真实框的 IoU 匹配策略导致正样本过少;loss_classifier 负责「是什么类别」,它不降大概率是类别不均衡,某个类的样本是另一个类的 50 倍,需要做类别重采样而不是改损失函数。loss_box_reg 是最容易虚高的分量,如果一开始就在 1.0 以上,先去查数据集里是不是有 boxes 超出图像边界的标注,越界框会让回归目标出现不合理的大位移,模型被迫学习错误映射。
我通常在训练到 1/3 时保存一次 checkpoint,然后用验证集测一版,不是为了看 mAP,而是为了确认 loss 的下降真正转化成了检测效果。从经验看,loss_objectness 低于 0.01、loss_rpn_box_reg 低于 0.05 之后,继续训练对 mAP 的提升很有限,这时候该动手调 anchor 或者 NMS 阈值,而不是盲目加 epoch。这个分量的「温度和手感」,需要多看几轮训练才能建立直觉,建议每次都把 loss_dict 打印出来存到日志文件里,后面对比实验时非常有用。
5. 推理评估与避坑:mAP、score 阈值和四个高发问题
5.1 推理输出结构、score 阈值与后处理
推理时模型返回一个 list,每个元素对应一张图的检测结果,它是一个 dict,包含 boxes、labels、scores 三个键。boxes 是 [M, 4] 的浮点张量,scores 是 [M] 的置信度,labels 是 [M] 的类别编号。注意推理模式下不要传 targets,模型会直接进入纯前向。下面这段代码是完整推理后处理,重点看 score 阈值怎么设、检测框怎么从归一化坐标转回原图坐标。
model.eval() with torch.no_grad(): predictions = model([img.to(device)]) pred = predictions[0] # 筛选置信度大于 0.5 的框 keep = pred["scores"] > 0.5 boxes = pred["boxes"][keep] labels = pred["labels"][keep] scores = pred["scores"][keep] # 转回原始图像尺寸 # 注意 GeneralizedRCNNTransform 会把图片缩放,返回的框坐标是缩放后的 # 如果你的原图被缩放过,需要按比例恢复 scale_w = original_w / img_w scale_h = original_h / img_h boxes[:, [0, 2]] *= scale_w boxes[:, [1, 3]] *= scale_h推理时最关键的是 score 阈值。0.5 只是一个通用起点,不同场景差别很大:做质检系统,误检成本高,阈值拉到 0.7 甚至 0.8 都合理;做安防监控的初步告警,希望不漏,阈值降到 0.3 也能接受。我不建议在推理代码里频繁改动阈值,更推荐训练完成后在验证集上做一次阈值扫描,找到精确率和召回率的平衡点再固定下来。另外,模型输出的 boxes 坐标是相对于内部缩放后图像的,如果你在预处理时手工 resize 过图像,必须自己还原坐标;如果完全交给模型内置 transform,则需要记录原始尺寸和内部尺寸的比例。
5.2 mAP 评估的正确姿势
mAP 是检测任务最重要的量化指标,但算错的人非常多。用 torchvision 提供的 pycocotools 是标准做法,但也有不少细节:评估时你的 boxes 必须还原回原图尺寸,不能用缩放后的坐标;iscrowd 标注要为每个框都设置好,缺少这个字段会导致某些重叠严重的样本在计算时被重复惩罚。用 torchvision 自带的评估工具也行,但它的 AP 计算不包含 COCO 的 101 点插值,结果会和 pycocotools 有细微差距,同一模型两边各跑一次,差 0.5 个点左右属于正常,别把这当成代码 bug。完整评估流程是:推理全部验证集图片,收集所有框、标签、得分和忽略标记,统一转成 COCO 格式后调用 coco_eval。
5.3 避坑:Faster R-CNN 训练翻车的四个常见问题
先给结论,这四个问题覆盖了我见过的 80% 的训练事故,每条都按现象、原因、解决方式写清楚。
第一,训练时 loss 一开始就是 nan。现象是第一个 batch 一跑就出现 inf 或 nan。原因多半是 boxes 坐标出现负值或者超出图像尺寸,导致模型计算回归损失时目标值异常。解决方式是在 Dataset 里做一次 clamp:把 x1、y1 限制到 [0, W-1],x2、y2 限制到 [0, H-1],同时过滤掉宽或高小于 1 的框。
第二,训练能跑但 loss 不降,train loss 一直挂在 0.8 左右。原因是学习率过高,模型在 loss 曲面边缘震荡。解决方式分三步:先确认 loss_box_reg 分量是否在下降,是的话说明模型在学但分类没跟上,把 lr 从 0.005 降到 0.002;如果四个分量全部不动,检查 anchor 和真实框的匹配率,可以在训练脚本里打印每个真实框被分配到的 anchor 数量,如果大多数真实框分配不到 anchor,就要缩小 anchor_sizes。
第三,mAP 评估时程序崩溃或者结果极低。现象是评估脚本在计算 IoU 时抛错,或者 AP 只有 10 几,明显低于训练集上的表现。原因大概率是推理时没有进入 model.eval() 模式或者推理输出没有做阈值筛选,导致所有低置信度的框都参与评估拉低精度。解决方式是强制检查两个点:确认模型处于 eval 模式,确认计算 mAP 前对 scores 做了过滤。
第四,显存占用极大,batch_size 1 都跑不动。现象是 P2 特征图显存爆炸,尤其当输入图分辨率很大时。原因是为保住小目标,很多人会把输入图缩得很小反而更糟,真正问题是 FPN 的 P2 层特征图尺寸大,显存占用按分辨率平方上涨。解决方式是限制输入长边不要超过 1333,这是 torchvision 默认设计的红线;如果必须处理大图,就用滑窗推理把图切成多个 patch,而不是硬塞进模型。另一个容易被忽略的是自动混合精度 AMP,训练时给 loss 做 GradScaler 缩放,能省 20% 到 30% 显存,推理时用 half 精度直接减半,代价是精度极轻微下降,大部分场景完全可接受。
6. 压榨模型价值:ONNX 导出前的 NMS 处理与自定义 anchor
当模型在你自己的数据集上稳定收敛之后,下一步通常是接入部署管线,这里最容易踩的坑是 ONNX 导出。torchvision 的 Faster R-CNN 内部包含一个 NMS 节点,直接导出 ONNX 时这个节点会变成模型的一部分,但很多推理引擎对动态形状的 NMS 支持不完善,导出的模型要么跑不起来,要么在 NMS 处性能极差。常见的做法是导出时把 NMS 拿掉:只导出 backbone + RPN + RoI Head 的特征提取部分,把 proposal 筛选和最终 NMS 放到后处理代码里自己实现。这样 ONNX 模型只负责输出一堆候选框和分数,部署侧用 C++ 或 Python 调用 NMS 库,反而更可控。
另一个值得花时间调的是自定义 anchor。默认 anchor 从 32 到 512,如果你的数据分布集中在 80 到 200 像素区间,保留默认值会浪费大量 RPN 计算在无效尺度上。我一般在项目中先对训练集所有真实框的宽高做一次聚类,得到 3 到 5 个面积中心,然后把 anchor_sizes 替换成这些中心值,比例仍用 [0.5, 1.0, 2.0]。这个改动对小目标数据集带来的提升,往往比换更大的 backbone 更直接,因为 RPN 的正样本命中率上来了,后续分类头能见到更多高质量的候选框。
最后再说一个习惯问题:每次改动 anchor 或者学习率策略后,别急着跑全量数据,先取一个 500 张的子集跑 3 个 epoch,看四个 loss 分量的趋势是否符合预期,再做完整训练。这个「小步快跑」的验证方式能避免一次全量训练后才发现参数选错的后悔药。我自己从前也更倾向于堆 epoch 的玄学,后来发现,Faster R-CNN 这种两阶段模型的性能上限其实在数据质量和 anchor 匹配度上卡着,盲目加轮数只会过拟合背景。做目标检测这几年,最深的教训就是:先让结构帮你把候选框的质量提上来,再谈训练技巧。希望这篇文章能帮你少走一段弯路。
本文还有配套的精品资源,点击获取