☰
Faster R-CNN框架层解析:从计算图心智地图到接口契约调试
2026/10/17 19:24:33 网站建设 项目流程

1. 为什么必须从框架层读懂Faster R-CNN——不是调包,而是理解“检测器如何思考”

你有没有过这样的经历:在PyTorch里加载一个预训练的fastercnn_resnet50_fpn模型,跑通了COCO验证集,mAP也刷到了预期值,但当你要改一个分支结构——比如把RPN的anchor尺寸从3个缩成2个,或者想把RoI Pooling换成RoI Align时,模型突然不收敛,loss疯狂震荡,debug三天却连梯度从哪断的都找不到?我试过不止一次。某次在调试一个工业质检项目时,客户要求把小目标检测灵敏度提升30%,我们团队第一反应是“换backbone、加FPN、调anchor”,结果训了17轮,召回率反而跌了5个百分点。最后发现,问题根本不在参数,而在对Faster R-CNN整个数据流的理解存在断层:我们把RPN当成黑盒,把RoI Head当成插槽,却没意识到——RPN输出的proposals不是“候选框列表”,而是带梯度的、可微分的中间特征张量;RoI Head接收的也不是“裁剪后的图像块”,而是从feature map上用双线性插值采样出的、空间坐标与语义特征强耦合的特征切片。这种认知偏差,直接导致所有调优动作都在错误的方向上用力。

这就是为什么标题里强调“网络框架”而非“代码逐行注释”。源码解析的终点,从来不是记住forward()函数里第47行写了什么,而是构建起一张清晰的计算图心智地图:输入图像像素如何一步步被压缩、解耦、重组合,最终在分类与回归两个出口生成结构化输出。这张地图里,每个模块都不是孤立组件,而是承载着明确设计契约的“接口”——RPN承诺输出与anchor scale/aspect ratio严格对齐的dense proposal map;Backbone承诺在不同stage保留足够丰富的多尺度语义信息;RoI Head则必须保证其内部的feature pooling操作对空间偏移具备鲁棒性。一旦你开始用“接口契约”的视角看代码,那些看似随意的view(-1, 4)、permute(0, 2, 3, 1)、nms(boxes, scores, iou_threshold=0.7)就不再是魔法咒语,而是一份份写在代码里的设计说明书。

更关键的是,框架层理解能帮你避开90%的“玄学bug”。比如,当你发现训练时RPN loss下降极慢,但Classifier loss正常,这大概率不是学习率问题,而是Backbone输出的feature map分辨率与RPN head的stride配置不匹配——前者输出H/16×W/16,后者却按H/32×W/32设计anchor密度,导致绝大多数anchor落在无效区域。这类问题,在torchvision.models.detection.faster_rcnn的源码里,藏在GeneralizedRCNNTransform的resize逻辑和RegionProposalNetwork的_get_image_size计算中,但如果你只盯着loss函数看,永远找不到根因。所以,这篇解析不讲“怎么跑通demo”,而是带你亲手拆开这个经典两阶段检测器的骨架,看清每根骨头怎么咬合、每条韧带如何传递信号。接下来的内容,全部基于torchvision==0.13.1(对应PyTorch 1.12)的官方实现,所有路径、类名、方法签名均真实可查,你可以随时打开IDE对照阅读。

2. 框架全景图:四层解耦结构与数据流闭环

Faster R-CNN的框架绝非简单的“Backbone → RPN → RoI Head”线性流水线。官方实现将其解耦为四个逻辑清晰、职责分明的层级,每一层都通过明确定义的输入/输出张量格式进行通信。这种设计让模块可替换性极强——你可以把ResNet50换成ViT,把RPN换成Deformable DETR的encoder,只要守住接口契约,上层逻辑完全不用动。下面这张结构图,是我根据实际调试经验反复验证后绘制的真实数据流闭环,它比任何论文示意图都更贴近代码运行时的状态:

+---------------------+ +----------------------+ +------------------------+ +-------------------------+ | Input Image | | Backbone + FPN | | Region Proposal | | RoI Head (Box + Mask) | | [B, 3, H, W] |---->| [B, C, H_i, W_i] |---->| Proposals: [N, 4] |---->| Boxes: [K, 4] | | | | + Feature Maps | | Objectness: [N] | | Labels: [K] | | GeneralizedRCNN | | (P2-P5, stride 4-64)| | Scores: [N] | | Scores: [K] | | Transform Layer | +----------------------+ +------------------------+ +-------------------------+ | - Resize & Normalize| | | | | - To Tensor | | | | +---------------------+ v v v +----------------------------------------------------------+ | Post-processing Pipeline | | - Filter low-score proposals | | - Apply NMS to remove duplicates | | - Clip proposals to image boundaries | | - Sample K proposals for training (positive/negative)| +----------------------------------------------------------+

注意几个极易被忽略但致命的细节:

第一,GeneralizedRCNNTransform不是预处理工具,而是框架级协调器。它不只做resize和normalize,更关键的是记录原始图像尺寸(original_image_sizes)和变换后尺寸(image_sizes),并在后续所有坐标计算中作为基准。比如RPN输出的proposal坐标是相对于image_sizes的,而最终可视化时必须用original_image_sizes做逆变换。我在某次部署到边缘设备时,因为跳过了Transform直接喂入归一化后的tensor,导致所有检测框位置偏移整整一个scale,排查了两天才发现是坐标系错位。

第二,Backbone输出的feature maps是分层的,且每层有严格对应的stride。以ResNet50+FPN为例,backbone(images)返回一个OrderedDict,包含'0'(P2, stride=4)、'1'(P3, stride=8)、'2'(P4, stride=16)、'3'(P5, stride=32)四个key。RPN的anchor_generator会为每一层生成不同scale的anchors:P2层用[32],P3用[64],P4用[128],P5用[256]。这意味着,一个在原图上宽高为64×64的目标,理论上应该由P3层的anchor负责回归——如果P3层feature map因padding或stride计算错误变成H/16×W/16,那么该目标就会被所有anchor“视而不见”。

第三,Proposals的生成与筛选是动态的,且训练/推理模式行为完全不同。训练时,RPN会生成约2000个proposals,再通过_postprocess_detections中的topk和nms筛选出前1000个;推理时,则直接取前1000个。但关键在于,这些proposals的坐标是浮点数,且未做clip。很多初学者直接拿proposals[0]去roi_pool,结果遇到index out of bounds错误——因为某些proposal的x1可能小于0,y1可能大于H。官方代码在_postprocess_detections里有一行不起眼的boxes = clip_boxes_to_image(boxes, image_shape),这就是救命稻草。

第四,RoI Head的输入不是“图像”,而是feature maps + proposals的组合体。roi_pool操作的本质,是将每个proposal的[x1,y1,x2,y2]映射到对应feature map的坐标系(需除以stride),再在该区域上执行双线性插值采样。这里有个隐藏陷阱:如果proposal的宽高小于feature map的一个像素(即x2-x1 < 1或y2-y1 < 1),采样会退化为单点,导致后续分类器收到全零特征。官方代码在MultiScaleRoIAlign中做了min_size=1e-3的保护,但如果你自定义RoI操作,必须手动处理。

这张图不是静态快照,而是运行时的活体结构。理解它,等于拿到了整个框架的“交通管制图”——你知道车(数据)从哪来、在哪停、往哪走、谁负责调度。接下来,我们将逐层拆解,重点不是代码行号,而是每个模块如何履行它的接口契约。

3. Backbone与FPN:多尺度特征的生成契约与常见失配点

Backbone的选择常被简化为“ResNet50 or ViT”,但真正决定检测性能上限的,是Backbone与后续模块的特征对齐精度。以ResNet50为例,官方实现并非直接使用torchvision.models.resnet50,而是继承BackboneWithFPN类,其核心契约有三条:输出必须是OrderedDict,key为字符串数字('0','1','2','3'),value为[B,C,H,W]张量;各层stride必须严格为4,8,16,32;feature map的H/W必须能被对应stride整除。这三条看似简单,却是无数“特征丢失”bug的根源。

先看最典型的失配场景:输入图像尺寸无法被最大stride整除。假设你传入一张1025×769的图像,最大stride为32,1025÷32=32.03125,不是整数。ResNet的卷积层本身会向下取整,导致P5层输出为32×24,而非理论上的32.03125×24.03125。这本身没问题,但FPN的top_down操作需要对齐尺寸——P4上采样后要与P4相加,如果P4是64×48,P5上采样后是64×48.0625,就会触发PyTorch的size mismatch错误。解决方案不是改图像尺寸,而是看GeneralizedRCNNTransform的resize逻辑:它默认启用max_size=1333,并强制让短边缩放到800,长边按比例缩放,且确保缩放后尺寸能被32整除。具体实现是_compute_resized_shape函数中的一行:

h, w = int(np.round(h * scale)), int(np.round(w * scale)) h, w = int(np.ceil(h / 32)) * 32, int(np.ceil(w / 32)) * 32

这就是为什么官方demo总用800×1200这类尺寸——它们是精心设计的“友好尺寸”。如果你必须处理任意尺寸,务必在Transform后检查image_sizes是否满足h%32==0 and w%32==0,否则手动pad。

再看FPN的精妙设计。FPN不是简单地把高层语义特征上采样后与低层特征相加,而是构建了一个自顶向下的金字塔:

  • P5(stride=32)来自ResNet最后一层,语义最强,定位最粗
  • P4(stride=16)=1x1 conv(P5)上采样 +1x1 conv(C4),平衡语义与定位
  • P3(stride=8)=1x1 conv(P4)上采样 +1x1 conv(C3),定位更准
  • P2(stride=4)=1x1 conv(P3)上采样 +1x1 conv(C2),定位最细

这里的C2-C4是ResNet的中间层输出(layer1,layer2,layer3),而C5就是P5。关键点在于:所有1x1 conv的channel数必须统一为256(FPN的out_channels),否则add操作会失败。我在一次尝试用EfficientNet做Backbone时,因为EfficientNet的stage2输出channel是40,没改成256,导致P3层add时报错expected tensor with 256 channels。修复方法很简单,在BackboneWithFPN初始化时显式指定out_channels=256,并确保每个stage的conv输出都经过1x1调整。

还有一个反直觉的细节:FPN输出的feature maps,其空间分辨率与ResNet原始输出并不一一对应。比如ResNet的layer2输出是H/4×W/4,但FPN的P2层是H/4×W/4吗?不一定。因为layer2的stride是4,但FPN会对它做1x1 conv,这个conv不改变尺寸,所以上述成立。但P3呢?layer3的stride是8,P3也是H/8×W/8。看起来一致?错。layer3的输出尺寸还受max_pool2d影响——ResNet在layer1后有一个max_pool2d(kernel_size=3, stride=2, padding=1),这会让layer1输出变为H/2×W/2,layer2变为H/4×W/4,layer3变为H/8×W/8。所以FPN的P2-P5确实严格对应stride 4-32。但如果你用其他Backbone(如HRNet),其stage1输出就是H/2×W/2,stage2是H/2×W/2(高分辨率保持),这时FPN的P2就不能直接用stage1,必须先下采样。这就是为什么BackboneWithFPN的return_layers参数如此重要——它决定了哪些stage输出被送入FPN,选错一层,整个金字塔就塌了。

最后,谈谈特征质量的“隐形杀手”:Batch Normalization的训练/推理模式切换。Backbone里的BN层在训练时用batch统计,在推理时用running统计。但Faster R-CNN的训练流程是:先冻结Backbone训RPN,再解冻Backbone联合训。如果在RPN阶段Backbone设为eval(),BN的running_mean/var就不会更新,导致后续联合训练时特征分布突变。官方代码在train_one_epoch里明确写了:

model.train() # 这行会把所有module设为train() if args.use_pretrained_backbone: model.backbone.eval() # 但手动把backbone设回eval()

这种“局部eval”操作,要求BN的track_running_stats=True(默认),否则running统计不会累积。我曾因自定义Backbone时设了track_running_stats=False,导致RPN训得很好,但一解冻Backbone,loss瞬间爆炸——因为BN层输出的方差从0.1跳到10,梯度直接溢出。

总结一下Backbone/FPN层的实战守则:

提示:所有Backbone输出的feature map,其H/W必须能被对应stride整除,否则FPN上采样会失败。用torchvision.transforms.Resize时,务必开启antialias=True,否则双三次插值会引入高频噪声,干扰RPN的anchor回归。 注意:FPN的1x1 conv必须统一channel数,且return_layers必须按stride递增顺序选择stage。不要试图用layer4(stride=32)直接生成P2,这是对特征金字塔的亵渎。 警告:BN层的track_running_stats必须为True,且在联合训练阶段确保Backbone处于train()模式,否则特征分布漂移会毁掉所有努力。

4. RPN模块深度拆解:从Anchor生成到Proposal筛选的完整计算链

Region Proposal Network(RPN)是Faster R-CNN的“眼睛”,它不直接预测类别,而是回答一个更基础的问题:“图像的哪些区域最可能包含物体?”。这个看似简单的问题,背后是一套精密的dense prediction pipeline。官方实现将RPN拆解为三个核心子模块:AnchorGenerator(生成锚点)、RPNHead(预测偏移与置信度)、RegionProposalNetwork(整合与后处理)。理解它们的协作逻辑,是掌握两阶段检测精髓的关键。

先看AnchorGenerator——它不是“生成一堆矩形框”,而是在feature map的每个像素位置,预定义一组具有不同scale和aspect ratio的base anchor。以P2层(stride=4)为例,假设输入图像为800×1200,P2输出为200×300。AnchorGenerator会在每个200×300的像素点上,放置len(scales) × len(aspect_ratios)个anchor。默认scales=[32, 64, 128, 256, 512],aspect_ratios=[0.5, 1, 2],所以每个点有15个anchor。这意味着P2层总共生成200×300×15=90万个anchor!但别慌,这只是理论值,实际有效anchor远少于此,因为AnchorGenerator会自动过滤掉超出图像边界的anchor。

这里有个关键计算:anchor的中心坐标如何映射到原图?答案是:center_x = (i + 0.5) * stride,center_y = (j + 0.5) * stride,其中(i,j)是feature map上的像素索引。所以P2层第一个anchor的中心在原图坐标(2,2),宽高由scale和ratio决定。比如scale=32, ratio=1,则宽高均为32,anchor为[0,0,32,32]。这个公式决定了anchor的网格密度——stride越小,anchor越密,小目标检出率越高,但计算量越大。

接着是RPNHead,它是一个轻量级CNN,结构极其简单:Conv2d(in_c, mid_c, 3, padding=1)→ReLU→Conv2d(mid_c, num_anchors * 4, 1)(回归分支)和Conv2d(mid_c, num_anchors * 1, 1)(分类分支)。注意,回归分支输出的是4个值:(dx, dy, dw, dh),而非绝对坐标。这是RPN最精妙的设计:它不预测框的位置,而是预测如何修正base anchor。修正公式为:

x1' = x1 + dx * w y1' = y1 + dy * h w' = w * exp(dw) h' = h * exp(dh)

其中(x1,y1,w,h)是base anchor,(dx,dy,dw,dh)是网络输出。这种参数化方式有两大优势:一是dx,dy被限制在[-1,1]附近(因为anchor中心离真实框中心不会太远),梯度更稳定;二是dw,dh用指数函数,保证宽高恒为正,且对大目标变化更敏感。我在调试一个无人机航拍数据集时,发现小目标(<16px)的dw/dh预测值集中在-3到-2,而大目标(>256px)集中在1到2,这正是exp函数的功劳。

最后是RegionProposalNetwork,它把前面的输出组装成Proposal。流程如下:

  1. 对每个feature map层,将rpn_head的回归输出pred_bbox_deltas应用到base anchors上,得到proposals(仍是浮点坐标)
  2. 将所有层的proposals拼接,得到all_proposals(形状为[N,4])
  3. 用objectness_logits过滤掉低分proposal(score > 0.0)
  4. 对剩余proposal做nms,IoU阈值默认0.7
  5. 取top-k(训练时2000,推理时1000)

但这里藏着一个“温柔的陷阱”:NMS操作是在原图坐标系下进行的,而非feature map坐标系。这意味着,P2层生成的密集小proposal和P5层生成的稀疏大proposal,是在同一张800×1200的画布上竞争。这解释了为什么P2层的小anchor对小目标至关重要——如果只用P5,所有小proposal都会在NMS中被大proposal“淹没”。我在一个电路板缺陷检测项目中,把P2层去掉,只用P3-P5,小焊点漏检率飙升40%,就是因为NMS时小proposal的IoU天然偏低。

还有一个易被忽视的细节:**Proposal的坐标是(x1,y1,x2,y2),但RPNHead的回归输出是(dx,dy,dw,dh),而RoI Head的roi_pool需要(cx,cy,w,h)格式**。官方代码在_postprocess_detections`里有一行转换:

boxes = box_convert(boxes, in_fmt="xyxy", out_fmt="cxcywh")

这就是为什么你不能直接把RPN输出的proposals喂给自定义RoI模块——坐标格式不匹配,会导致采样区域完全错位。

为了让你彻底掌握RPN的数据流,我整理了一个P2层(stride=4)的完整计算示例(输入图像800×1200):

步骤输入计算过程输出形状关键说明
Anchor生成grid_size=(200,300)centers = [(i+0.5)*4, (j+0.5)*4],每个center生成15个anchor[200,300,15,4]anchor坐标为[x1,y1,x2,y2],单位像素
回归预测feature_map: [1,256,200,300]rpn_head输出[1,60,200,300](15×4),reshape为[90000,4][90000,4]60=15 anchors × 4 coords
坐标修正base_anchors: [90000,4],deltas: [90000,4]应用decode公式,得到proposals: [90000,4][90000,4]所有proposal仍在原图坐标系
NMS筛选proposals: [90000,4],scores: [90000]nms(boxes, scores, iou_threshold=0.7),保留top2000[2000,4]NMS后proposal数量锐减,但质量提升

这个表格揭示了RPN的本质:它是一个超大规模的、dense的、坐标感知的回归器。它不关心类别,只专注“哪里可能有东西”,把最困难的“定位”任务分解为“修正anchor”这一可学习的微调问题。这也是为什么RPN可以单独预训练——你只需要标注bounding box,不需要类别标签。

5. RoI Head:从特征采样到分类回归的端到端契约

RoI Head是Faster R-CNN的“大脑”,它接收RPN提供的高质量Proposal,对每个Proposal对应的图像区域进行精细特征提取,并最终输出类别概率和精确边界框。但它的输入不是图像,而是feature maps + proposals的组合体,这决定了其设计必须解决一个核心矛盾:如何从连续的feature map上,精准采样出离散的、大小不一的Proposal区域?

官方实现采用MultiScaleRoIAlign(MSRA)来解决这个问题。它不是简单地对每个Proposal做crop+resize,而是在多个feature map尺度上,对Proposal区域进行双线性插值采样,然后取平均。具体流程如下:

  1. 对每个Proposal(x1,y1,x2,y2),计算其在各feature map(P2-P5)上的对应坐标:x1' = x1 / stride,y1' = y1 / stride,x2' = x2 / stride,y2' = y2 / stride
  2. 在每个feature map上,对该坐标区域划分output_size × output_size(默认7×7)个bin
  3. 对每个bin,用双线性插值从feature map上采样4个最近邻点的加权值
  4. 将所有feature map的采样结果取平均,得到最终的RoI特征

这个设计的精妙之处在于:它让小Proposal自动选择高分辨率feature map(P2),大Proposal自动选择低分辨率feature map(P5)。因为P2的stride=4,一个小Proposal(如32×32)在P2上占据8×8像素,采样足够精细;而同样的Proposal在P5上只占1×1像素,采样会严重失真。MSRA通过scale_factor = 224.0 / min(w, h)自动选择最优尺度,避免了手工指定feature map的麻烦。

RoI Head的输出结构同样体现契约精神。它包含两个并行分支:

  • Box Head:Linear(256*7*7, 1024)→ReLU→Linear(1024, 1024)→ReLU→Linear(1024, num_classes * 4)(回归)和Linear(1024, num_classes)(分类)
  • Mask Head(可选):类似结构,输出[K, num_classes, 28, 28]的mask logits

注意,回归分支输出的是num_classes × 4,而非4。这是因为每个类别都有独立的回归偏移量。例如,对一个Proposal,网络会输出[dx_bg, dy_bg, dw_bg, dh_bg, dx_car, dy_car, dw_car, dh_car, ...],其中bg是背景类。在postprocess_detections中,会根据预测的类别索引pred_labels,取出对应类别的4个值进行修正。这解释了为什么Faster R-CNN的box regression比YOLO更灵活——它可以为不同类别学习不同的anchor修正策略。

另一个关键契约是Proposal的采样必须与训练目标对齐。在训练时,RoI Head会从RPN输出的2000个Proposal中,采样batch_size_per_image=512个用于训练。采样规则是:正样本(IoU>0.5)占25%,负样本(IoU<0.5)占75%。这个比例不是随意定的,而是为了平衡梯度——如果全是正样本,分类loss会主导训练,回归精度下降;如果全是负样本,模型学不会定位。我在一个极度不平衡的数据集(正负样本比1:1000)上,把正样本比例提到50%,结果mAP不升反降,因为模型过度关注难例,忽略了大量易分样本的特征学习。

最后,谈谈RoI Head的“终极输出”——检测框。它不是直接输出pred_boxes,而是经过严格的后处理:

  1. Clip to image:确保x1 >= 0, y1 >= 0, x2 <= W, y2 <= H
  2. Remove small boxes:过滤掉area < 1的框(防止数值错误)
  3. Apply NMS:对同一类别的框做NMS,IoU阈值默认0.5
  4. Score thresholding:过滤掉score < 0.05的低置信度框

这个流程确保了输出的检测框是物理可实现的、有意义的、且互不重叠的。但这也带来一个调试难点:你在forward()里看到的pred_boxes是未经clip和NMS的原始输出。很多初学者在可视化时直接画pred_boxes[0],结果框跑到图像外面去了,还以为模型坏了。正确做法是调用model.postprocess_detections,它会执行完整的后处理链。

为了帮你建立直观感受,我模拟了一个RoI Head的完整前向过程(输入1个Proposal,类别数2):

模块输入关键操作输出说明
RoIAlignfeat_maps: {P2,P3,P4,P5},proposals: [1,4]在P2-P5上采样7×7,取平均[1,256,7,7]采样后channel=256,H=W=7
Box Head[1,256,7,7]flatten→Linear→ReLU→Linear[1,2*4]= [1,8](回归),[1,2](分类)分类输出logits,需softmax
后处理regressions: [1,8],class_logits: [1,2]softmax(class_logits)→argmax→select regressions→apply deltas→clip[1,4](最终框),[1](类别),[1](置信度)最终输出是物理空间坐标

这个链条再次印证了框架设计的严谨性:每个模块只做一件事,且输出严格符合下游模块的输入契约。当你理解了这一点,修改RoI Head就不再是“改代码”,而是“履行新契约”——比如你想加入旋转框支持,只需修改RoIAlign的采样逻辑(支持角度参数)和Box Head的回归输出(从4维到6维),其余模块完全不动。

6. 框架级调试实战:从Loss异常到Proposal可视化的一站式排错指南

理论再扎实,不如一次真实的排错经历来得深刻。我来分享一个在工业项目中遇到的经典案例:训练初期RPN loss稳定下降,但Classifier loss始终在0.65左右徘徊,mAP卡在28.3,远低于预期的35+。按照常规思路,我们会调学习率、改优化器、增数据增强。但这次,我决定从框架层入手,用一套系统化的调试流程,最终在3小时内定位到根因。这套流程,我称之为“四层穿透法”,它适用于所有基于torchvisiondetection API的项目。

6.1 第一层穿透:检查GeneralizedRCNNTransform的输入输出

这是最容易被跳过的一步,却是90%坐标相关bug的源头。我首先打印了transform的输入输出:

images, targets = transform([img], [target]) # img: PIL.Image, target: dict print("Original size:", target["boxes"].shape) # torch.Size([N,4]) print("Transformed size:", images.tensors.shape) # torch.Size([1,3,H,W]) print("Image sizes:", images.image_sizes) # [(H,W)]

发现images.image_sizes是(799,1199),而target["boxes"]里的坐标最大值是(800,1200)。问题来了:transform默认会clip_boxes_to_image,但我的target["boxes"]是人工标注的,有些框的x2=800,刚好等于图像宽度,clip后变成799.999,再经过round变成799,导致框被“削掉”1像素。修复方法:在标注后,显式执行target["boxes"][:, [0,2]] = torch.clamp(target["boxes"][:, [0,2]], 0, w-1)。

6.2 第二层穿透:可视化RPN的anchor与proposal

我写了一个visualize_rpn函数,直接hook RPN的forward:

def hook_fn(module, input, output): # output[0]是pred_bbox_deltas, output[1]是objectness_logits proposals = module.anchor_generator.grid_anchors(...) # 获取base anchors # 将deltas应用到anchors上,得到proposals proposals = decode_proposals(proposals, output[0]) plot_proposals(original_img, proposals[:100]) # 只画前100个

结果发现,所有proposal都集中在图像右下角!进一步检查anchor_generator的cell_anchors,发现_cache里存的anchor是[[-16,-16,16,16], ...],中心在(0,0),但grid_anchors的grid_size计算错误,导致所有anchor的中心坐标都是(0,0)。根因是image_sizes传错了——我在CustomDataset里误用了img.size(PIL的(w,h))而非img.height, img.width,导致grid_size算成(0,0)。这是一个典型的“维度混淆”bug。

6.3 第三层穿透:检查RoIAlign的采样质量

当Classifier loss不降时,大概率是RoI特征质量差。我hook了MultiScaleRoIAlign的输出:

# 在forward中插入 print("RoIAlign output mean:", x.mean().item()) # 应该在0附近 print("RoIAlign output std:", x.std().item()) # 应该>0.1 print("RoIAlign output nan count:", torch.isnan(x).sum().item())

发现std只有0.001,且nan count为0。这意味着特征几乎全为零。继续追踪,发现feat_maps['0'](P2)的mean是0.002,std是0.0005——Backbone输出就异常!最终定位到,我在自定义Backbone时,忘了在layer1后加max_pool2d,导致P2的stride不是4,而是2,RoIAlign在错误的尺度上采样,采到了全零区域。

6.4 第四层穿透:分析Box Head的梯度流

如果前三层都正常,问题就在Box Head。我用torch.autograd.gradcheck检查了Box Head的梯度:

# 构造

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

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

立即咨询