简介:本资源是基于Penn-Fudan Database数据集实现全卷积网络(FCN)图像分割的完整训练工程包,面向深度学习初学者与计算机视觉实践者,聚焦行人检测与像素级语义分割任务。资源包含533个文件,主体为340张带标注的PNG图像、172个训练日志与配置说明TXT、6个核心Python训练/推理脚本、4个Numpy格式评估结果(meanIU.npy等),以及XML标注文件和少量编译缓存文件,整体压缩包大小184.68MB,结构清晰,便于复现实验流程与结果分析。已有505人学习下载,适合希望深入理解FCN架构设计、上采样机制、跳跃连接实现及BCEWithLogitsLoss在分割任务中应用的学习者。包内已集成多组超参配置模型(如RMSprop优化器、step学习率调度、不同epoch与batch size组合),并附带均交并比(meanIU)等关键评估指标输出,可直接用于模型对比、性能调优与课程作业交付。
1. 全卷积网络在 Penn-Fudan 数据集上的行人检测实战:不是调个model = FCN()就能跑通的端到端复现
你手头刚下好 Penn-Fudan Database,解压后看到 170 张带精细像素级掩码的行人图像,心里一热:“终于能跑个全卷积网络(FCN)做语义分割了!”——结果torchvision.models.segmentation.fcn_resnet50(pretrained=True)加载完,loss.backward()直接报RuntimeError: Expected tensor for argument #1 'input' to have the same number of classes as the target。这不是玄学,是 FCN 在目标检测场景中被严重误用的典型翻车现场。Penn-Fudan 本质是实例分割数据集(每个行人有独立 mask),而标准 FCN 是为语义分割设计的(只区分“人”和“背景”,不区分张三李四)。本文带你从零厘清:为什么直接套用 FCN 主干做行人检测会失败?如何把 FCN 的特征提取能力真正嫁接到实例分割流程里?怎样用 PyTorch 官方torchvision工具链,在不重写 backbone 的前提下,完成从数据加载、mask 预处理、损失函数定制到可视化验证的完整闭环。适合正在啃图像分割入门项目、卡在数据格式与模型对齐环节的工程师和研究生。
2. FCN 不是万能分割器:Penn-Fudan 数据结构与 FCN 设计边界的硬性对齐
2.1 Penn-Fudan 的真实数据形态:170 张图,但每张图含 1~12 个独立行人实例
Penn-Fudan Database 并非简单的“人/背景”二分类数据集。其标注采用 PNG 格式掩码图(.png),每张掩码图中,不同行人被赋予不同灰度值(如第一个行人区域像素值=1,第二个=2,依此类推),背景统一为 0。这意味着:
- 语义标签数 ≠ 实例数:一张图有 5 个行人,语义类别仍是“person”(1 类),但实例数为 5;
- mask 是整数型,非 one-hot:不能直接喂给
nn.CrossEntropyLoss(它要求 target 是 class index,且 shape 匹配 logits 的 C 维); - 无 bbox 坐标文件:原始数据不提供
.txt或.xml格式的边界框,所有空间信息都藏在 mask 的连通域里。
提示:别急着写
transforms.ToTensor()!Penn-Fudan 的 mask 是PIL.Image的L模式(单通道灰度),ToTensor()会将其转为[0,1]归一化浮点张量,但实例 ID(1,2,3…)会被缩放成0.004, 0.008, 0.012…,彻底丢失整数语义。这是新手第一大坑。
2.2 FCN 的原始设计定位:语义分割的“全局上下文建模器”
标准 FCN(Long et al., 2015)的核心思想是:用全连接层替换 CNN 分类头,再通过上采样恢复空间分辨率,输出与输入同尺寸的逐像素类别预测图。其输出 logits shape 为[B, C, H, W],其中C是语义类别数(如 PASCAL VOC 是 21 类)。关键约束在于:
- 输出通道数
C必须等于数据集中语义类别总数; - loss 计算时,target 必须是
[B, H, W]的 long tensor,每个元素 ∈[0, C-1]; - FCN 本身不具备实例区分能力:它无法告诉模型“这张图里有两个穿红衣服的人,要分开预测”。
所以,当你把 Penn-Fudan 的 mask(含 ID=1,2,3…)直接作为 target 输入 FCN,nn.CrossEntropyLoss会尝试将 ID=3 的像素映射到第 4 类(索引 3),但你的模型输出只有 2 个通道(background + person),必然越界报错。
2.3 正确的技术选型路径:用 FCN 主干 + Mask R-CNN 头,而非纯 FCN
既然 Penn-Fudan 是实例分割任务,而 FCN 是语义分割主干,那最优解不是“改造 FCN”,而是复用 FCN 的 backbone 特征,接入实例分割 head。PyTorch 官方torchvision.models.detection.maskrcnn_resnet50_fpn()正是为此而生:
- 其 backbone 默认使用
resnet50(与fcn_resnet50共享相同 stem 和 stage 结构); - FPN(Feature Pyramid Network)提供多尺度特征,适配不同大小行人;
- RPN(Region Proposal Network)生成候选框,解决“不知道人在哪里”的问题;
- Mask head 对每个 proposal 输出 binary mask,天然支持实例分离。
因此,本项目的真实技术栈是:FCN 级别的 backbone(ResNet50-FPN) + Mask R-CNN 实例分割 head。所谓“全卷积网络(Penn-Fudan)”,实为“基于全卷积特征提取器的实例分割实现”。
3. 从原始 ZIP 到可训练 Dataset:Penn-Fudan 数据预处理的四个强制步骤
3.1 解压与目录结构标准化:拒绝“双击解压就开干”的野路子
Penn-Fudan 官方提供 ZIP 包,解压后默认结构混乱(如PNGImages/,PedMasks/,README.txt散落)。必须重构为torchvisionCocoDetection或自定义Dataset可识别的格式:
# 创建标准目录结构(务必执行!) mkdir -p penn_fudan_dataset/{images,masks} # 将 PNGImages/* 移入 images/ mv PNGImages/* penn_fudan_dataset/images/ # 将 PedMasks/* 移入 masks/,并重命名以匹配图片名(关键!) for mask in PedMasks/*.png; do base=$(basename "$mask" .png) # Penn-Fudan 命名规则:FudanPed00001.png → FudanPed00001_mask.png new_name=$(echo "$base" | sed 's/$/_mask/')".png" mv "$mask" "penn_fudan_dataset/masks/$new_name" done逻辑说明:
torchvision的CocoDetection要求 mask 文件名与 image 文件名严格对应(仅后缀不同)。原始PedMasks/FudanPed00001.png必须改为masks/FudanPed00001_mask.png,否则__getitem__中mask_path = img_path.replace('images', 'masks').replace('.png', '_mask.png')会找不到文件。
3.2 自定义 Dataset 类:实例 ID 提取与 bbox 生成的不可省略逻辑
Penn-Fudan 无现成 bbox,需从 mask 中提取。核心是scipy.ndimage.label找连通域,再用skimage.measure.regionprops计算 bounding box:
# penn_fudan_dataset.py import os import torch from PIL import Image import numpy as np from scipy import ndimage from skimage import measure class PennFudanDataset(torch.utils.data.Dataset): def __init__(self, root, transforms=None): self.root = root self.transforms = transforms self.imgs = list(sorted(os.listdir(os.path.join(root, "images")))) self.masks = list(sorted(os.listdir(os.path.join(root, "masks")))) def __getitem__(self, idx): # 加载图像 img_path = os.path.join(self.root, "images", self.imgs[idx]) img = Image.open(img_path).convert("RGB") # 加载 mask(注意:必须用 'L' 模式保持整数ID) mask_path = os.path.join(self.root, "masks", self.masks[idx]) mask = Image.open(mask_path).convert("L") # ← 关键!保留灰度整数值 mask = np.array(mask) # shape: [H, W], dtype: uint8 # 提取实例ID(排除背景0) obj_ids = np.unique(mask) obj_ids = obj_ids[obj_ids != 0] # [1, 2, 3, ...] # 为每个实例生成 binary mask 和 bbox masks = np.zeros((len(obj_ids), *mask.shape), dtype=np.uint8) boxes = [] for i, obj_id in enumerate(obj_ids): # 生成 binary mask masks[i] = (mask == obj_id).astype(np.uint8) # 计算 bbox:[x_min, y_min, x_max, y_max] pos = np.where(masks[i]) xmin = np.min(pos[1]) xmax = np.max(pos[1]) ymin = np.min(pos[0]) ymax = np.max(pos[0]) boxes.append([xmin, ymin, xmax, ymax]) # 转为 tensor boxes = torch.as_tensor(boxes, dtype=torch.float32) labels = torch.ones((len(obj_ids),), dtype=torch.int64) # 所有实例都是 'person' masks = torch.as_tensor(masks, dtype=torch.uint8) image_id = torch.tensor([idx]) area = (boxes[:, 3] - boxes[:, 1]) * (boxes[:, 2] - boxes[:, 0]) iscrowd = torch.zeros((len(obj_ids),), dtype=torch.int64) target = {} target["boxes"] = boxes target["labels"] = labels target["masks"] = masks target["image_id"] = image_id target["area"] = area target["iscrowd"] = iscrowd if self.transforms is not None: img, target = self.transforms(img, target) return img, target参数说明:
mask.convert("L")确保读取为单通道灰度图,np.array()后 dtype 为uint8,完美保留实例 ID(1,2,3…)。scipy.ndimage.label在此不适用,因其会重新编号连通域,破坏原始 ID 与实例的对应关系;np.unique+ 循环是更鲁棒的方案。
3.3 Transform 链:为何ToTensor()必须放在最后,且 mask 不参与归一化?
torchvision.transforms中,ToTensor()对 PIL Image 的作用是:[0,255] uint8 → [0.0,1.0] float32。这对 RGB 图像合理,但对 mask 是灾难性的:
# 错误示范:mask 被归一化,ID=1 变成 0.00392... transform = T.Compose([ T.ToTensor(), # ← mask 在这里被毁掉! T.RandomHorizontalFlip(0.5), ]) # 正确做法:先做几何变换(需保持 mask 整数性),最后才 ToTensor transform = T.Compose([ T.RandomHorizontalFlip(0.5), T.ToTensor(), # ← 仅对 image 归一化;mask 在 __getitem__ 中已转为 uint8 tensor,不走此步 ])实际__getitem__中,我们已手动将masks转为torch.uint8tensor,因此ToTensor()仅作用于img(PIL.Image→float32),masktensor 保持原样。这是保证实例 ID 不失真的底层机制。
4. 模型构建与训练配置:复用 FCN backbone,但 head 必须是 Mask R-CNN
4.1 加载预训练 backbone:resnet50_fpn是 FCN 与 Mask R-CNN 的共同基石
torchvision.models.detection.maskrcnn_resnet50_fpn()内部 backbone 即resnet50+fpn,与fcn_resnet50的 backbone 完全一致(共享conv1,bn1,layer1~4,fpn权重)。区别仅在 head:
import torchvision from torchvision.models.detection import maskrcnn_resnet50_fpn from torchvision.models.detection.faster_rcnn import FastRCNNPredictor from torchvision.models.detection.mask_rcnn import MaskRCNNPredictor # 加载预训练 Mask R-CNN(backbone 自动加载 ImageNet 权重) model = maskrcnn_resnet50_fpn(pretrained=True) # 修改 head 以适配 Penn-Fudan 的 2 类(background + person) num_classes = 2 in_features = model.roi_heads.box_predictor.cls_score.in_features model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes) # 修改 mask head in_features_mask = model.roi_heads.mask_predictor.conv5_mask.in_channels hidden_layer = 256 model.roi_heads.mask_predictor = MaskRCNNPredictor( in_features_mask, hidden_layer, num_classes )逻辑说明:
pretrained=True加载的是 COCO 上预训练的权重,其 backbone(ResNet50-FPN)已具备强大特征提取能力,可直接迁移。我们只需替换最后两层 head:box_predictor(负责分类+回归)和mask_predictor(负责生成 28×28 mask),因为 Penn-Fudan 只有 1 个前景类。
4.2 训练循环中的关键 loss 分解:理解loss_dict里的四个核心项
Mask R-CNN 的总 loss 是加权和,model(images, targets)返回字典:
| key | 含义 | 典型值(训练初期) | 优化重点 |
|---|---|---|---|
loss_classifier | RPN 提出的 proposal 的分类 loss(前景/背景) | ~1.2 | 若持续 >1.0,检查 bbox 标签是否全为 0(漏提) |
loss_box_reg | proposal 的 bbox 回归 loss(IoU 优化) | ~0.8 | 若 >1.5,检查 bbox 坐标是否越界(xmax>xmin) |
loss_mask | mask 分割 loss(per-pixel BCE) | ~0.6 | Penn-Fudan 的核心指标,应优先收敛 |
loss_objectness | RPN 自身的 objectness score loss | ~0.4 | 若为 nan,检查 RPN anchor 设置 |
# 训练 step 示例 model.train() for images, targets in data_loader: images = list(image.to(device) for image in images) targets = [{k: v.to(device) for k, v in t.items()} for t in targets] loss_dict = model(images, targets) # ← 返回 dict,非 scalar! losses = sum(loss for loss in loss_dict.values()) optimizer.zero_grad() losses.backward() optimizer.step() # 打印各 loss 项(调试必备) print(f"cls:{loss_dict['loss_classifier']:.3f} " f"box:{loss_dict['loss_box_reg']:.3f} " f"mask:{loss_dict['loss_mask']:.3f} " f"obj:{loss_dict['loss_objectness']:.3f}")参数说明:
loss_mask使用torch.nn.functional.binary_cross_entropy_with_logits,对每个 proposal 的 28×28 mask 预测图计算 pixel-wise BCE,因此对 mask 质量最敏感。若loss_mask下降缓慢,优先检查maskstensor 是否为uint8(非float32),否则 BCE 会因数值范围错配而失效。
5. 避坑指南:Penn-Fudan + FCN backbone 实战中踩过的五个血泪坑
5.1 现象:RuntimeError: invalid argument 0: Sizes of tensors must match
原因:maskstensor 在__getitem__中未统一 shape。Penn-Fudan 中一张图可能有 1 个行人(mask shape[1,H,W]),另一张有 5 个([5,H,W]),但torch.stack()要求所有 tensor 的H,W完全一致。若某张图 mask 尺寸与其他图不同(如因 resize 未同步),stack失败。
解决:在__getitem__中,对masks列表统一torch.stack()前,先用torch.nn.functional.interpolate将所有 mask resize 到固定尺寸(如256x256),或更稳妥地——在 transform 中对 image 和 mask 同步 resize(使用albumentations库的DualTransform,或自定义Resize类确保 image/mask 同比例缩放)。
5.2 现象:训练时loss_mask为nan,且loss_classifier极高(>5.0)
原因:targets["masks"]中存在全零 mask(即某个 proposal 对应的 mask 全为 0)。这通常源于 bbox 坐标计算错误:ymin=ymax或xmin=xmax导致pos = np.where(masks[i])返回空数组,masks[i]初始化为全零。
解决:在__getitem__的 bbox 计算后,添加校验:
# 在生成 masks[i] 后立即校验 if masks[i].sum() == 0: # 跳过该实例,或用 bbox 区域填充一个最小 mask masks[i][int(ymin), int(xmin)] = 15.3 现象:验证时model.eval()预测出的 mask 全黑(全 0)
原因:model.eval()时,mask_head的输出是logits(未 sigmoid),而torchvision.utils.draw_segmentation_masks要求输入是[0,1]概率图。直接传入 logits 会导致阈值判断失效。
解决:预测后显式应用 sigmoid:
with torch.no_grad(): pred = model([img])[0] # pred['masks'] shape: [N,1,28,28] masks_prob = torch.sigmoid(pred['masks']) # ← 必须加! # 然后 draw_segmentation_masks(masks_prob > 0.5)5.4 现象:loss_mask收敛到 0.01 后停滞,但可视化 mask 边缘模糊、细节丢失
原因:Mask R-CNN 默认输出 28×28 mask,上采样回原图时使用双线性插值,导致边缘平滑。Penn-Fudan 的 mask 是像素级精确的,需更高分辨率。
解决:修改mask_predictor的输出尺寸。在MaskRCNNPredictor初始化时,传入mask_size=56(或 112):
model.roi_heads.mask_predictor = MaskRCNNPredictor( in_features_mask, hidden_layer, num_classes, resolution=56 # ← 默认 28,改为 56 提升 mask 精度 )5.5 现象:训练速度极慢(<1 iter/sec),GPU 利用率 <30%
原因:Penn-Fudan 仅 170 张图,若batch_size=2,每个 epoch 仅 85 steps,但DataLoader的num_workers>0会因数据量小引发进程间通信开销,反拖慢速度。
解决:设num_workers=0(Windows 必须为 0;Linux 可试num_workers=1),并启用pin_memory=True:
data_loader = torch.utils.data.DataLoader( dataset, batch_size=2, shuffle=True, num_workers=0, # ← 关键!小数据集禁用多进程 collate_fn=lambda x: tuple(zip(*x)), pin_memory=True )6. 验证与可视化:用三行代码画出带 bbox 和 mask 的原始图,确认 pipeline 闭环
6.1 构建可复现的 inference pipeline:从 raw image 到 annotated plot
验证不是“跑通 loss”,而是亲眼看到模型在原始图像上画出的 bbox 和 mask 是否贴合行人轮廓。以下代码可直接粘贴运行:
import matplotlib.pyplot as plt import torchvision.transforms as T from torchvision.utils import draw_bounding_boxes, draw_segmentation_masks def show_prediction(model, img_path, device='cuda'): model.eval() img = Image.open(img_path).convert("RGB") transform = T.Compose([T.ToTensor()]) img_tensor = transform(img).to(device) with torch.no_grad(): pred = model([img_tensor])[0] # 提取预测结果 boxes = pred['boxes'].cpu() scores = pred['scores'].cpu() masks = torch.sigmoid(pred['masks']).cpu() > 0.5 # 转为 bool # 过滤低置信度预测(避免杂点) keep = scores > 0.7 boxes = boxes[keep] masks = masks[keep] # 绘制 bbox(绿色)和 mask(半透明红色) img_tensor_uint8 = (img_tensor * 255).to(torch.uint8) img_with_boxes = draw_bounding_boxes( img_tensor_uint8, boxes, colors="green", width=2 ) img_with_masks = draw_segmentation_masks( img_with_boxes, masks.squeeze(1), alpha=0.5, colors="red" ) # 显示 plt.figure(figsize=(12, 8)) plt.imshow(img_with_masks.permute(1, 2, 0)) plt.title(f"Pred: {len(boxes)} persons, confidence > 0.7") plt.axis('off') plt.show() # 调用示例(确保 img_path 是 penn_fudan_dataset/images/ 下的某张图) show_prediction(model, "penn_fudan_dataset/images/FudanPed00001.png")逻辑说明:
draw_segmentation_masks要求masks是bool类型且 shape 为[N, H, W](squeeze(1)是因为pred['masks']是[N,1,28,28],需去掉 channel 维)。alpha=0.5控制 mask 透明度,确保 bbox 线条仍清晰可见。
6.2 定量评估:用 COCO API 计算 AP@0.5:0.95,但 Penn-Fudan 需微调
torchvision内置CocoEvaluator,但 Penn-Fudan 无官方 COCO 格式 annotation。需导出为 COCO JSON:
# 将 Penn-Fudan 转为 COCO 格式(简化版) coco_json = { "images": [], "annotations": [], "categories": [{"id": 1, "name": "person"}] } ann_id = 1 for idx, (img_name, mask_name) in enumerate(zip(dataset.imgs, dataset.masks)): coco_json["images"].append({ "id": idx, "file_name": img_name, "height": 384, "width": 512 }) # 从 mask 中提取每个 instance 的 segmentation(RLE 编码) mask = np.array(Image.open(f"penn_fudan_dataset/masks/{mask_name}")) obj_ids = np.unique(mask)[1:] # skip background for obj_id in obj_ids: mask_binary = (mask == obj_id).astype(np.uint8) # 此处用 pycocotools.mask.encode() 生成 RLE(略,需安装 pycocotools) # ... coco_json["annotations"].append({ "id": ann_id, "image_id": idx, "category_id": 1, "segmentation": rle_encoded, "area": int(mask_binary.sum()), "bbox": [xmin, ymin, width, height] # 从 regionprops 获取 }) ann_id += 1提示:Penn-Fudan 官方不提供 COCO 格式,因此
CocoEvaluator需自行构造coco_gt。实践中,目视检查 5~10 张图的预测效果,比纠结 AP 数值更有工程价值——毕竟 170 张图的 AP 统计意义有限。
6.3 模型轻量化技巧:冻结 backbone 前 3 个 stage,只微调 FPN 和 head
Penn-Fudan 数据量小(170 张),过拟合风险高。经验表明:冻结 ResNet50 的conv1~layer3,只训练layer4、fpn和所有 head,可提升泛化性:
# 冻结 backbone 前 3 个 stage for name, param in model.backbone.body.named_parameters(): if "layer1" in name or "layer2" in name or "layer3" in name: param.requires_grad = False # 验证:打印 requires_grad 状态 print("layer1 grad:", next(model.backbone.body.layer1.parameters()).requires_grad) # False print("layer4 grad:", next(model.backbone.body.layer4.parameters()).requires_grad) # True从那以后我每次用小数据集训实例分割,都强制走一遍param.requires_grad检查——哪怕只是print一行,也比训完发现 backbone 全在动要强。Penn-Fudan 这种“小而精”的数据,特征迁移比从头学更重要。希望帮到你。
本文还有配套的精品资源,点击获取