简介:基于Pytorch实现的Mask R-CNN实例分割实战项目,面向具备深度学习基础、希望掌握实例分割算法的开发者和学生,从特征金字塔网络、RPN区域建议到Fast R-CNN检测与掩模分支逐步拆解,适合作为从目标检测进阶到像素级分割的落地练习。资源包共64个文件,以16个Python源码文件为核心,涵盖模型构建、工具脚本与演示程序,另配29张示例图片、6张效果示意图,以及6个头文件、4个C源文件和2个CUDA文件用于NMS等后处理加速,整体仅8.77MB,便于下载部署。目前已有384人浏览学习。通过该工程可完整体验数据集预处理、模型训练、验证与评估流程,理解Mask R-CNN在PyTorch中的架构设计,还能参考清晰规范的代码结构,提升实例分割项目的实战开发能力。同时,配套示例图片带目标掩模标注,可用于快速测试,帮助读者从数据准备到结果可视化建立完整认知。
1. 为什么要拿 PyTorch 从头拆 Mask R-CNN
先看一个具体场景:你手里有一张街景图,里面有几辆车、几个人、几只狗。用普通的目标检测模型(比如 YOLO 或 Faster R-CNN),只能拿到一堆边界框——框里有车、有狗,但框其实是有重叠的,你不知道哪些像素属于哪辆车、哪些像素属于这只狗的毛。实例分割要回答的就是这个「哪些像素属于哪个对象」的问题,它给每个对象输出一块精确的掩模(mask),而不是只有一个粗糙的框。
这个项目用 PyTorch 实现了 Mask R-CNN,是从 Facebook AI Research 2017 年提出来的结构一步步搭起来的。它不是一个靠调库拼接的 Demo,而是从model.py、utils.py、roialign、nms这些底层模块入手,把 FPN 特征金字塔、RPN 区域提议网络、RoIAlign 双线性采样和 Mask 分支全部铺开,代码可以一条一条追下去。对于想搞懂实例分割内部机制的开发者,这个包比直接读 detectron2 源码要友好得多,因为它把 Mask R-CNN 拆成了一个个可以单独运行的零件,你甚至可以只跑demo.py不看训练细节,先把推理链路摸熟。适合两类人:一类是刚接触实例分割、想看完整 PyTorch 实现的学习者;另一类是已经调过现成 API、现在想深入修改骨干网络或自定义数据集训练的工程师。
2. 从 R-CNN 到 Mask R-CNN:这个项目把哪几层结构拆开讲
2.1 两阶段检测框架的骨架:RPN 和 Fast R-CNN 怎么衔接
要读懂这个项目的源码,得先理解 Mask R-CNN 是「两阶段」的模型:第一个阶段是 Region Proposal Network,作用是在输入图像的特征图上滑窗,预测每个位置是否存在目标,并给出初步的边界框偏移量;第二个阶段是 Fast R-CNN 式的检测头,对这些候选框做精细分类和边界框回归。
在项目的model.py里,这两段分别对应rpn和detector两个逻辑块。RPN 的输入是 FPN 输出的多层特征图,因为 FPN 本身就是 5 个尺度(P2 到 P5,再加一个 P6),对应原图上从 4 像素步长到 128 像素步长不等的特征分辨率,目的是让不同尺寸的目标都能在合适的层级上被观察到。项目里对这种多尺度特征图直接做 concat 或者分别走同一个 head,常见做法是每层共享同一个 3x3 卷积 + 两个 1x1 卷积,分别输出 objectness 分数和边界框回归参数。
# model.py 中 RPN 的核心逻辑(经过简化,保留主干) self.rpn = nn.ModuleList([ nn.Sequential( nn.Conv2d(fpn_depth, 512, kernel_size=3, padding=1), nn.ReLU(), nn.Conv2d(512, 2 * len(anchor_ratios), kernel_size=1) # 前景/背景分数 ), nn.Sequential( nn.Conv2d(fpn_depth, 512, kernel_size=3, padding=1), nn.ReLU(), nn.Conv2d(512, 4 * len(anchor_ratios), kernel_size=1) # bbox 回归偏移 ) ])这段代码里的anchor_ratios是关键参数,项目默认是[0.5, 1, 2]三种宽高比,配合 config.py 里的RPN_ANCHOR_SCALES(例如(32, 64, 128, 256, 512)),在每层 FPN 特征图上的每个像素位置生成 3 个 anchor,5 层特征图加起来就是全图的 anchor 集合。RPN 预测出的2 * len(anchor_ratios)个通道里,前 3 个是背景分数,后 3 个是前景分数,这样设计是为了让 sigmoid 和 softmax 都能直接用,项目里用的是 softmax 二分类,注意和 Faster R-CNN 原版做法一致。
RPN 模块最后会做一次 NMS(非极大值抑制),项目在nms目录里同时放了nms_wrapper.py和pth_nms.py两个实现,这就是 PyTorch 里接地气的写法:优先调用torchvision.ops.nms或者编译后的 CUDA 扩展,如果环境不支持再回退到一个纯 PyTorch 实现。这种双写思路在源码里值得仔细看,因为很多开源项目的 NMS 部分会假设你有编译环境,而这个项目把退化路径也补齐了。
2.2 RoIAlign 为什么能替代 RoIPool:双线性采样的细节
RPN 生成的候选框坐标是浮点数,但 RoI Pooling 在历史实现里会把候选框量化到整数网格上,再对每个 bin 做最大池化。这种量化对目标检测影响不大,因为在检测里最后只输出类别和 bbox,误差可以被回归头吸收。但 Mask R-CNN 新增的 Mask 分支要做像素级预测,坐标稍微偏一点,掩模边界就会像锯齿一样,所以原作者引入了 RoIAlign。
项目中roialign目录里有两套实现,一套是roi_align(用 C++/CUDA 扩展),另一套是纯 Python 的roi_align.py。纯 Python 版本更适合用来理解原理,因为它的循环能直接看出双线性采样在做什么事。核心思路是:对于候选框的每个 bin,先在特征图上采样 4 个点,用双线性插值算出各自的像素值,然后对这 4 个值取平均,得到这个 bin 的特征值。
# roi_align.py 中双线性采样的核心逻辑 def bilinear_interpolate(bottom, height, width, y, x): # 边界裁剪,防止越界 if y < 0 or y > height - 1 or x < 0 or x > width - 1: return 0.0 y_low = int(y) x_low = int(x) y_high = min(y_low + 1, height - 1) x_high = min(x_low + 1, width - 1) ly = y - y_low lx = x - x_low # 四个相邻像素的加权和 return (bottom[y_low, x_low] * (1 - ly) * (1 - lx) + bottom[y_low, x_high] * (1 - ly) * lx + bottom[y_high, x_low] * ly * (1 - lx) + bottom[y_high, x_high] * ly * lx)这段代码虽然短,但它是整个 RoIAlign 模块最底层的操作。参数y和x是浮点数,表示在特征图上的真实坐标;y_low、x_low向下取整,y_high、x_high向上取整;ly和lx是权重系数,越靠近哪个整数点,那个点的权重就越大。在真正的 RoIAlign 里,这个函数会被循环调用若干次(比如 7x7 输出分辨率就调用 196 次),然后对每个 bin 内的 4 个采样点取平均。理解这个函数的意义在于:如果一个项目的推理结果和训练结果不一致,很可能就是 RoIAlign 的采样逻辑在训练和推理时写成了两套。
2.3 Mask 分支:28x28 掩模是怎么从 RoI 特征里长出来的
Mask R-CNN 的 Mask 分支和检测分支是并行的,但 Mask 分支只在正样本(与真实框 IoU 大于 0.5)上计算损失。项目中这个分支是一连串 3x3 卷积 + ReLU,最后接一个 2x2 转置卷积把特征图上采样到 28x28,然后输出每个类别的二值掩模。
# model.py 中 mask 分支的结构定义 self.mask_fcn = nn.Sequential( nn.Conv2d(256, 256, 3, padding=1), nn.ReLU(), nn.Conv2d(256, 256, 3, padding=1), nn.ReLU(), nn.ConvTranspose2d(256, 256, 2, stride=2), nn.ReLU(), nn.Conv2d(256, num_classes, 1) # 每个类别一个通道 )为什么中间要插一个转置卷积,而不是直接用 1x1 卷积输出 28x28 的掩模?因为 RoIAlign 的输出特征是 7x7 的,直接用 1x1 卷积虽然能得到 28x28 的输出,但每个像素只能看到自己位置上的 7x7 感受野,空间信息不足。转置卷积的存在把特征图放大到 14x14,经过 ReLU 后再放大到 28x28,让掩模输出每个位置都能看到更宽范围内的特征。num_classes参数来自 config 里的类别数量,在训练时对真实掩模的每个类别通道计算二进制交叉熵损失,推理时则取所有类别通道的最大值作为掩模。
这里有一个在实际调试中常见的坑:训练时因为正样本有限,Mask 分支的梯度很小,损失曲线下降得很慢,看起来像是没在学。遇到这种情况先要确认正样本数量是否过少(一个 batch 里 RPN 选出的正候选框少于 32 个就很危险),这个项目的config.py里TRAIN_BATCH_SIZE默认是 8,也就意味着每个 batch 需要至少 32 个正样本,如果数据集小或者目标少,这个数字很可能达不到,需要在数据增强或 RPN 阈值上做调整。
3. 项目里的 utils.py 和 config.py:训练前必改的参数都在这里
3.1 配置文件里最关键的一组超参数
这个项目的config.py提供了一组接近 Detectron 风格的默认参数,大部分来自原版 Mask R-CNN 论文的设定。对初次接触实例分割的人来说,这组参数可以直接跑通 COCO 子集,但要想让模型在自己的数据集上收敛,必须理解几个关键项的含义。
表格里列出的是我实际训练时最常改的几个参数:
| 参数名 | 默认值 | 含义说明 |
|---|---|---|
| IMAGE_MIN_DIM | 800 | 图像短边缩放到 800,超过就按比例缩放 |
| IMAGE_MAX_DIM | 1024 | 图像长边最多 1024,防止显存爆炸 |
| RPN_ANCHOR_SCALES | (32, 64, 128, 256, 512) | FPN 每层对应的 anchor 边长,与数据集目标大小强相关 |
| RPN_NMS_THRESHOLD | 0.7 | RPN 阶段 NMS 的 IoU 阈值,越高保留的候选框越多 |
| TRAIN_ROIS_PER_IMAGE | 200 | 每张图送入检测头的 RoI 数量,训练时太大会拖慢速度 |
| ROI_POSITIVE_RATIO | 0.33 | 正样本占 RoI 总数的比例,过小会导致 Mask 分支学习不充分 |
| DETECTION_MIN_CONFIDENCE | 0.7 | 推理时最终输出的置信度阈值 |
其中RPN_ANCHOR_SCALES的改动对结果影响最大。假如你的数据集里目标都是小物体(比如无人机视角的人头、远处的小车),最高层的 P5 对应 512 的 anchor 基本用不上,反而应该把尺度整体调小,例如(16, 32, 64, 128, 256)。这个项目的特点是没有把 anchor 设计写死,你可以在 config.py 里改完直接训练,不需要动 model.py 的结构代码。改完后需要同步检查 FPN 的层数是否和 anchor 尺度数量一致,否则代码会在构造 RPN 时报索引错误。
3.2 utils.py 里的数据处理细节
utils.py里有一个容易被忽视但很重要的函数,它的作用是把图像和掩模变换成网络需要的格式。具体来说,它做了三件事:读取图像为 RGB 数组、把对应的 polygon 掩模编码成二值数组(shape 是 H x W x N,N 是实例数)、计算原始图像到输入图像的缩放比例。这里涉及的 transform 接口在后续代码里被多处调用,如果训练时发现掩模和图像错位,先检查这个函数的缩放逻辑是否正确。
# utils.py 中图像和掩模加载的简化逻辑 def load_image_gt(dataset, config, image_id): image = dataset.load_image(image_id) # 读取原图 mask, class_ids = dataset.load_mask(image_id) # 读取当前实例的掩模和类别 # 缩放图像和掩模:短边对齐到 IMAGE_MIN_DIM image, window, scale, padding = resize_image( image, min_dim=config.IMAGE_MIN_DIM, max_dim=config.IMAGE_MAX_DIM, padding=config.IMAGE_PADDING) mask = resize_mask(mask, scale, padding) # 如果掩模太小(面积小于 2x2),直接丢弃这个实例 keep = np.where((mask.sum(axis=(0, 1)) > 2))[0] return image, mask[:, :, keep], class_ids[keep], scale注意最后的keep条件,掩模面积小于 2 像素的实例会被直接丢弃。这个是在训练时过滤极小目标的策略,但推理时demo.py不会做这个过滤,因为推理使用的是模型输出的掩模,不受这个函数影响。这个差异是制造「训练正常但推理漏检」的经典原因之一——如果你的数据里有大量微小目标,训练时它们全被过滤掉了,模型自然学不会识别它们。解决方法是把keep的阈值调低,或者使用基于面积的过滤策略,但代价是会增加训练时的背景噪声。
3.3 ROC 曲线和损失函数的关系
Mask R-CNN 的总体损失是由 RPN 分类损失、RPN 回归损失、检测头分类损失、检测头回归损失和 Mask 损失五部分组成,项目中在model.py里把它们加在一起。调试这个项目时一个比较实用的技巧是,把五部分损失分别打出来看变化趋势,而不是只盯总损失。
# model.py 中五部分损失的计算位置 rpn_class_loss = rpn_class_loss_fn(rpn_class_logits, rpn_match) rpn_bbox_loss = rpn_bbox_loss_fn(rpn_bbox, rpn_target_bbox, rpn_match) mrcnn_class_loss = mrcnn_class_loss_fn(detection_class_logits, target_class_ids) mrcnn_bbox_loss = mrcnn_bbox_loss_fn(detection_bbox, target_bbox, target_class_ids) mrcnn_mask_loss = mrcnn_mask_loss_fn(detection_masks, target_masks, target_class_ids) total_loss = (rpn_class_loss + rpn_bbox_loss + mrcnn_class_loss + mrcnn_bbox_loss + mrcnn_mask_loss)这几行代码的顺序是固定的,因为后面的损失依赖前面的输出:RPN 的回归损失要用 RPN 预测的 bbox 来和真实框计算误差;检测头的分类损失又依赖 Faster R-CNN 层输出的detection_class_logits。训练时如果总损失不降,优先看rpn_class_loss——它下降说明 RPN 在学会提候选区域;如果rpn_class_loss降了但mrcnn_mask_loss不动,说明问题出在 Mask 分支本身(比如正样本太少或者 RoIAlign 采样异常)。
4. 从 coco.py 到 demo.py:数据加载、训练和推理的完整链路
4.1 COCO 数据集的掩模格式转换
coco.py文件实现了 COCO 数据集的加载器,这是整个训练流程的地基。COCO 原始标注格式是 polygin 多边形坐标,而 Mask R-CNN 需要的是二值掩模图,所以load_mask函数要做一次转换,流程是把每个实例的多边形渲染在空白图上,填充区域变成 1,背景为 0,最终得到一个 H x W x N 的布尔数组。
这个转换在 CPU 上做很慢,项目中常见做法是用pycocotools提供的maskUtils.frPyObjects和maskUtils.decode来加速。初次使用这个项目的人最容易在 COCO 数据下载这一步卡住——因为 COCO 数据集很大,完整训练集加标注有 25GB 量级。如果只想跑通流程,项目给出了一种更省事的方式:把数据集换成一个较小的子集(比如train2017里抽 5000 张图,val2017抽 1000 张),在config.py里改NUM_CLASSES和数据集路径即可。这里要特别注意,NUM_CLASSES在 COCO 数据集里是 81(80 个目标类 + 1 个背景类),改数据集时最容易因为类别标号不对导致维度错误。
4.2 训练流程:什么时候要冻结骨干网络
项目的train.py脚本里有一个layers参数,用来指定要训练的网络层范围。默认情况是heads,表示只训练 Mask R-CNN 头部(RPN、检测头、Mask 分支),骨干网络 ResNet 使用 ImageNet 预训练权重并冻结。这样做有两个好处:一是显存占用小(不带 FPN 的 ResNet50 backbone 冻结后约省 2GB 显存),二是收敛速度快,因为 ImageNet 预训练权重已经提供了足够好的低级视觉特征。
# 训练命令示例 python train.py train --dataset=/path/to/coco --model=imagenet --epochs=50当模型已经跑通并验证有效后,再解冻全部层做微调(--layers=all),这时学习率需要减小到原来的十分之一左右(比如从 0.001 降到 0.0001)。项目中的config.py里LEARNING_RATE默认 0.001,如果显存不够导致 batch size 小于 4,这个学习率会显得偏高,容易震荡,建议同步缩小。训练过程会输出每个 epoch 的 loss、mAP 均值等指标,如果发现 mAP 停滞在很低的数值,检查 RPN 阶段的 NMS 阈值:这个值设置过高会让 RPN 保留大量低质量候选框,检测头在大量背景样本上浪费学习能力。
4.3 demo.py 推理链路的输入输出规范
demo.py是拿到模型权重后第一个要看的文件。它的输入是一张图片(可以是文件路径,也可以是摄像头采集的帧),输出是叠加了检测框、类别标签和掩模的可视化结果。整个推理链路基本上是训练时前向计算的一个简化版本:图像缩放、FPN 前向、RPN 生成候选框并做 NMS、检测头分类回归、Mask 分支上采样。其中最需要理解的是两次 NMS 的位置:第一次在 RPN 阶段粗筛,第二次在检测头输出后精筛。
# demo.py 中推理部分的核心调用 results = model.detect([image], verbose=1) r = results[0] # r 包含三个字段:rois(检测框坐标),class_ids,masks(HxWxN 的布尔数组) for i, class_id in enumerate(r['class_ids']): mask = r['masks'][:, :, i] draw_mask(image, mask, r['rois'][i], class_name(class_id))r['masks']的每个实例对应一张二值图,直接叠加在原始图像上就得到可视化的分割结果。这里有一个容易搞混的点:masks的存储顺序是 [H, W, N],N 是检测到的实例数,不是类别数,代码里给每个实例独立画掩模时,循环下标i对应的是实例序号而不是类别索引。实际调试中如果发现掩模和轮廓错位,先检查是否忘了把r['masks']和r['rois']用同一个索引对齐。
4.4 模型权重转换与 .h5 兼容问题
项目里还提供了convert_from_keras.py脚本,这个文件的用途是把 Tom and Jerry 式的 Keras 版 Mask R-CNN 权重迁移到 PyTorch 结构上。因为项目中模型实现时的层顺序和 TensorFlow 版的略有差异,比如 PyTorch 的Conv2d权重布局是 [out_channels, in_channels, kH, kW],而 Keras 的Conv2D布局是 [kH, kW, in_channels, out_channels],直接加载会出现维度匹配错误。
这个脚本会递归遍历模型的所有层,按名字匹配并转置卷积核。实际经验是,转换后跑推理基本没问题,但训练曲线和原版可能不完全一致,因为 BatchNorm 的 momentum 和 epsilon 在两个框架上有细微差别。如果不是为了对比复现论文结果,最好的做法是直接用--model=imagenet从零训练,跳过转换,少踩一层坑。
5. 实操路径:拿现成图像复现分割效果
项目压缩包内附带了多张测试图片,比如assets/street.png、park.png、detection_masks.png等等,这些图片是从 COCO 数据集中抽出来的真实街景和公园场景图片。跑demo.py时要注意虚拟环境里的路径引用,以及中文字体在 matplotlib 下显示的问题,项目里如果直接用了系统默认字体,中文标签会变成方块,解决方法是提前把图片路径和标签换成英文。
6. 用前人踩过的坑反推你自己的调参步骤
这个项目不是拿官方预训练权重直接跑完就结束的玩具,它是一个可以玩一整天的调试对象。真正的实用技巧是把整套流程拆开做对照实验:先把demo.py的默认输入换成图片assets/detection_final.png,同时打印出 RPN 生成候选区域的数量和位置,再对比 FPN 各层的响应情况,很快就能定位到问题出在哪个环节。建议珍惜项目附带的visualize.py这个工具函数,它比用 opencv 硬画要省力得多,换数据集后只需要改class_names列表,然后就能复用全部可视化逻辑。你可以用它来对比不同阈值下的 NMS 输出差异,也可以直接打印中间特征图确认 FPN 每层的内容是否合理。一个值得多试几遍的小技巧是:把DETECTION_MIN_CONFIDENCE调低到 0.3,观察哪些nms阶段被过滤掉但实际是正确检测的框,这个操作能反过来帮你确定 RPN 阶段的阈值是否过狠,这是在换数据集时最常遇到的一类问题。
本文还有配套的精品资源,点击获取