简介:这份资源围绕全卷积网络(FCN)在Penn-Fudan Database上的行人检测与分割实践展开,面向已具备CNN基础、希望深入图像分割的深度学习学习者与研究者。内容涵盖FCN端到端像素级预测原理、以VGG或ResNet为骨干叠加反卷积上采样层、跳跃连接融合浅层与深层特征等关键设计,并配套Penn-Fudan数据集的图像与分割掩模训练流程,可用于智能监控、自动驾驶等场景的语义分割入门与复现。资源包共533个文件,以340张png图像与掩模、172个txt标注说明为主,另含6个py训练脚本、4个npy指标文件、4个xml配置及若干模型权重与工程文件,压缩包约184.68MB,目录结构便于按数据、代码、结果分块查阅。目前已有504人学习下载,适合需要完整跑通FCN分割流程、对照实验参数与评估指标的读者参考。
1. 从行人分割说起:为什么 Penn-Fudan 是 FCN 落地的第一块试金石
行人分割这件事,听起来像是自动驾驶或者安防监控里的高端需求,但真正动手做的时候,大多数人卡在第一步:找不到一个干净、标注完整、规模适中的数据集来验证全卷积网络(FCN)到底能不能跑通。Penn-Fudan Database 就是在这个场景下被反复提起的——它包含 170 张图像、345 个行人实例,每张图都有像素级掩码标注,覆盖了校园、街道、室内等多种场景。规模不大,但足够让你把 FCN 的完整链路走一遍:数据加载、标签对齐、模型前向、损失回传、掩码可视化。
这个数据集最适合两类人:一是刚接触语义分割、想用最小成本理解 FCN 编解码结构的开发者;二是已经用过 DeepLab 或 U-Net,但想回头补上“全卷积”这个基础概念的人。它的标注质量足够高,图像分辨率统一为 256×256,行人姿态多样,遮挡和重叠情况都有覆盖,不会像某些玩具数据集那样跑出来的结果好看但没参考价值。下面我会从数据准备、模型搭建、训练调参到避坑排查,把这条链路拆开讲清楚。
2. 数据准备与标签对齐:把 Penn-Fudan 转成 FCN 能吃的格式
2.1 原始数据结构与读取方式
Penn-Fudan 的原始发布格式通常是每张图像对应一个标注文件,标注以 PNG 掩码或文本坐标形式存在。常见做法是先把所有图像和掩码按文件名配对,然后统一缩放到网络输入尺寸。这里有一个容易被忽略的点:掩码的像素值不是 0/1 二值,而是每个行人实例有独立 ID,背景为 0。如果你直接拿来做二分类分割,需要先把多实例掩码合并成前景/背景二值图。
我一般会先写一个校验脚本,确认图像和掩码的数量、尺寸、命名规则是否一致。下面这段代码做三件事:遍历目录、配对文件、检查尺寸是否匹配。
import os import cv2 import numpy as np def check_pairing(img_dir, mask_dir): img_files = sorted(os.listdir(img_dir)) mask_files = sorted(os.listdir(mask_dir)) # 文件名去掉扩展名后应一一对应 img_stems = [os.path.splitext(f)[0] for f in img_files] mask_stems = [os.path.splitext(f)[0] for f in mask_files] assert img_stems == mask_stems, "图像与掩码文件名不匹配" for stem in img_stems: img = cv2.imread(os.path.join(img_dir, stem + '.jpg')) mask = cv2.imread(os.path.join(mask_dir, stem + '.png'), 0) if img.shape[:2] != mask.shape[:2]: print(f"尺寸不一致: {stem}, img={img.shape[:2]}, mask={mask.shape[:2]}") else: print(f"OK: {stem}, shape={img.shape[:2]}, mask唯一值={np.unique(mask)}") # 调用示例 check_pairing('./PennFudanPed/PNGImages', './PennFudanPed/PedMasks')逻辑说明:cv2.imread第二个参数设为 0 表示以灰度模式读取掩码,避免三通道干扰。np.unique(mask)能快速看出掩码里有哪些像素值,正常情况下应该是 0 和若干个正整数(每个行人一个 ID)。如果出现 255 或浮点值,说明标注格式和预期不符,需要先做归一化或阈值处理。
参数方面,图像目录和掩码目录的路径根据实际解压位置调整。如果掩码是彩色 PNG,读取后还要做通道合并或取单通道,否则后续转二值会出错。
2.2 转二值掩码与数据增强策略
确认配对无误后,下一步是把多实例掩码转成二值前景掩码。做法很简单:所有大于 0 的像素置为 1,背景保持 0。但这里有个坑——有些标注文件里行人之间会有 1 像素的间隙,直接二值化后这些间隙会变成背景,导致训练时模型学到错误的边界。常见做法是先做一次形态学闭运算,把间隙填上,再转二值。
def to_binary_mask(mask, kernel_size=3): # 多实例转二值 binary = (mask > 0).astype(np.uint8) # 闭运算填补小间隙 kernel = np.ones((kernel_size, kernel_size), np.uint8) closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) return closed # 数据增强:随机水平翻转 + 随机裁剪 def augment(img, mask): if np.random.rand() > 0.5: img = cv2.flip(img, 1) mask = cv2.flip(mask, 1) # 随机裁剪到 224x224 h, w = img.shape[:2] if h > 224 and w > 224: top = np.random.randint(0, h - 224) left = np.random.randint(0, w - 224) img = img[top:top+224, left:left+224] mask = mask[top:top+224, left:left+224] return img, mask逻辑说明:cv2.morphologyEx的MORPH_CLOSE先膨胀后腐蚀,能填掉小孔洞但不会明显改变行人轮廓。kernel_size一般取 3 或 5,太大容易把相邻行人粘在一起。数据增强部分,水平翻转对行人分割是安全的,因为行人左右对称;随机裁剪则要求原图尺寸大于裁剪尺寸,Penn-Fudan 原图 256×256,裁到 224×224 刚好留出一点边缘变化。
提示:如果掩码里不同行人的 ID 有重叠区域(标注错误),闭运算后可能出现粘连。建议在转二值前先检查每个 ID 的连通域数量,超过 1 的说明标注有碎片,需要手动清理或丢弃该样本。
2.3 数据集封装与批处理
PyTorch 的Dataset和DataLoader是标准做法,但 Penn-Fudan 样本量小,批大小不宜过大。我一般设 batch_size=4 或 8,配合num_workers=2足够。注意collate_fn要能处理不同尺寸的掩码——如果做了随机裁剪,同一批里图像尺寸可能不一致,需要统一 resize 到固定尺寸再拼接。
from torch.utils.data import Dataset, DataLoader import torch class PennFudanDataset(Dataset): def __init__(self, img_dir, mask_dir, transform=None): self.img_dir = img_dir self.mask_dir = mask_dir self.transform = transform self.stems = [os.path.splitext(f)[0] for f in sorted(os.listdir(img_dir))] def __len__(self): return len(self.stems) def __getitem__(self, idx): stem = self.stems[idx] img = cv2.imread(os.path.join(self.img_dir, stem + '.jpg')) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask = cv2.imread(os.path.join(self.mask_dir, stem + '.png'), 0) mask = to_binary_mask(mask) if self.transform: img, mask = self.transform(img, mask) # 统一 resize 到 224x224 img = cv2.resize(img, (224, 224)) mask = cv2.resize(mask, (224, 224), interpolation=cv2.INTER_NEAREST) img = torch.from_numpy(img).permute(2, 0, 1).float() / 255.0 mask = torch.from_numpy(mask).long() return img, mask # 使用示例 dataset = PennFudanDataset('./PNGImages', './PedMasks', transform=augment) loader = DataLoader(dataset, batch_size=4, shuffle=True, num_workers=2)逻辑说明:permute(2, 0, 1)把 HWC 转成 CHW,符合 PyTorch 卷积输入格式。掩码用INTER_NEAREST插值,避免引入非整数标签。归一化除以 255 是常规操作,但如果你用预训练骨干网络,要换成对应的均值和标准差。
参数方面,batch_size根据显存调整,4 在 6GB 显存下跑 FCN 8 倍下采样没问题。num_workers在 Windows 上建议设 0,否则容易卡在共享内存。
3. 全卷积网络搭建:从 VGG 骨干到跳级连接
3.1 为什么 FCN 要用全卷积替换全连接
传统 CNN 做分类时,最后几层是全连接层,输出固定长度的向量。但分割任务要求输出和输入同尺寸的掩码,全连接层会把空间信息压扁,没法恢复。FCN 的核心思想就是用卷积层替换全连接层,让网络在任意尺寸输入下都能输出对应的空间响应图。具体做法是把 VGG16 的最后三个全连接层换成 1×1 卷积,通道数分别设为 4096、4096、类别数。这样输出的特征图保留了空间维度,再通过上采样恢复到原图尺寸。
Penn-Fudan 只有行人和背景两类,所以最后一层 1×1 卷积输出通道为 2。但实际训练时我一般设成 2 通道后接 softmax,或者 1 通道接 sigmoid,两者等价。用 2 通道的好处是方便扩展到多类。
3.2 跳级连接与上采样参数
FCN-8s 是常用版本,它把 pool4 和 pool3 的特征图通过跳级连接融合进来,再一次性上采样 8 倍。相比 FCN-32s,FCN-8s 的边界更精细,但计算量稍大。Penn-Fudan 图像尺寸小,FCN-8s 完全跑得动。
import torch.nn as nn import torch.nn.functional as F from torchvision import models class FCN8s(nn.Module): def __init__(self, n_class=2): super().__init__() vgg = models.vgg16(pretrained=True) features = list(vgg.features.children()) # 编码器:VGG16 前 5 个卷积块 self.conv1 = nn.Sequential(*features[0:5]) # 64 self.conv2 = nn.Sequential(*features[5:10]) # 128 self.conv3 = nn.Sequential(*features[10:17]) # 256 self.conv4 = nn.Sequential(*features[17:24]) # 512 self.conv5 = nn.Sequential(*features[24:31]) # 512 # 替换全连接为 1x1 卷积 self.fc6 = nn.Conv2d(512, 4096, 1) self.fc7 = nn.Conv2d(4096, 4096, 1) self.score_fr = nn.Conv2d(4096, n_class, 1) # 跳级连接得分层 self.score_pool4 = nn.Conv2d(512, n_class, 1) self.score_pool3 = nn.Conv2d(256, n_class, 1) # 上采样层 self.upscore2 = nn.ConvTranspose2d(n_class, n_class, 4, stride=2, padding=1) self.upscore4 = nn.ConvTranspose2d(n_class, n_class, 4, stride=2, padding=1) self.upscore8 = nn.ConvTranspose2d(n_class, n_class, 16, stride=8, padding=4) def forward(self, x): # 编码 c1 = self.conv1(x) c2 = self.conv2(c1) c3 = self.conv3(c2) c4 = self.conv4(c3) c5 = self.conv5(c4) # 全卷积替换 f6 = F.relu(self.fc6(c5)) f7 = F.relu(self.fc7(f6)) score = self.score_fr(f7) # 跳级融合 up2 = self.upscore2(score) score_pool4 = self.score_pool4(c4) score_pool4 = score_pool4[:, :, 5:5+up2.size(2), 5:5+up2.size(3)] fuse4 = up2 + score_pool4 up4 = self.upscore4(fuse4) score_pool3 = self.score_pool3(c3) score_pool3 = score_pool3[:, :, 9:9+up4.size(2), 9:9+up4.size(3)] fuse3 = up4 + score_pool3 up8 = self.upscore8(fuse3) # 裁剪到输入尺寸 return up8[:, :, 31:31+x.size(2), 31:31+x.size(3)]逻辑说明:ConvTranspose2d的stride和padding决定上采样倍数。upscore2是 2 倍,upscore4再 2 倍,upscore8是 8 倍。跳级连接时,pool4 和 pool3 的得分图尺寸比上采样结果大,需要裁剪对齐。裁剪偏移量 5、9、31 是根据 VGG16 各层感受野和池化累积算出来的,不同输入尺寸下可能需要微调。
参数方面,n_class=2对应行人和背景。如果显存不够,可以把fc6和fc7的通道数从 4096 降到 1024,精度损失不大但显存占用明显下降。
3.3 损失函数与类别不平衡处理
Penn-Fudan 里行人像素占比通常不到 20%,背景占大多数。直接用交叉熵损失会让模型倾向于预测背景。常见做法是给前景像素加权,权重设为背景的 5 到 10 倍。或者用 Dice Loss,它对类别不平衡不敏感。
class WeightedCrossEntropy(nn.Module): def __init__(self, weight=None): super().__init__() self.weight = weight def forward(self, pred, target): # pred: [B, C, H, W], target: [B, H, W] return F.cross_entropy(pred, target, weight=self.weight) # 使用:前景权重设为 8 weights = torch.tensor([1.0, 8.0]).cuda() criterion = WeightedCrossEntropy(weight=weights)逻辑说明:weight张量的长度等于类别数,索引 0 是背景,索引 1 是行人。权重值需要根据实际前景占比调整,如果前景占比 10%,权重设 9 左右比较合适。训练时观察 loss 曲线,如果 loss 下降但 IoU 不升,说明权重可能过大,模型在过度补偿前景。
注意:加权交叉熵的权重不要设得太极端,否则模型会把背景像素也预测成前景,导致召回率高但精确率低。建议先用权重 5 跑一轮,看验证集 IoU 再调。
4. 训练调参与验证:Penn-Fudan 上的实操参数
4.1 优化器选择与学习率策略
FCN 微调时,编码器部分通常用较小的学习率,解码器和跳级连接用较大的学习率。常见做法是分参数组:VGG 骨干 lr=1e-4,新增的 1×1 卷积和上采样层 lr=1e-3。优化器用 SGD 加动量 0.9,或者 Adam。Penn-Fudan 样本少,Adam 收敛更快,但最终精度可能略低于调好的 SGD。
# 分参数组 backbone_params = list(model.conv1.parameters()) + list(model.conv2.parameters()) + \ list(model.conv3.parameters()) + list(model.conv4.parameters()) + \ list(model.conv5.parameters()) head_params = list(model.fc6.parameters()) + list(model.fc7.parameters()) + \ list(model.score_fr.parameters()) + list(model.score_pool4.parameters()) + \ list(model.score_pool3.parameters()) + list(model.upscore2.parameters()) + \ list(model.upscore4.parameters()) + list(model.upscore8.parameters()) optimizer = torch.optim.SGD([ {'params': backbone_params, 'lr': 1e-4}, {'params': head_params, 'lr': 1e-3} ], momentum=0.9, weight_decay=1e-4) # 学习率衰减:每 20 个 epoch 降一半 scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.5)逻辑说明:weight_decay设 1e-4 防止过拟合,Penn-Fudan 只有 170 张图,不加正则化很容易过拟合。StepLR的step_size根据总 epoch 数调整,一般训练 50 到 80 个 epoch 足够。
参数方面,如果显存允许,batch_size 可以提到 8,学习率相应乘以 1.5 左右。但 Penn-Fudan 样本少,batch_size 太大反而梯度不稳定。
4.2 验证指标与可视化检查
分割任务不能只看 loss,要看 IoU 和像素准确率。IoU 对前景更敏感,像素准确率容易被背景拉高。我一般两个都算,但以 IoU 为准。
def compute_iou(pred, target, n_class=2): ious = [] pred = pred.argmax(dim=1) # [B, H, W] for cls in range(n_class): pred_inds = pred == cls target_inds = target == cls intersection = (pred_inds & target_inds).sum().item() union = (pred_inds | target_inds).sum().item() if union == 0: ious.append(float('nan')) else: ious.append(intersection / union) return np.nanmean(ious) # 训练循环中每 5 个 epoch 验证一次 model.eval() with torch.no_grad(): for img, mask in val_loader: img, mask = img.cuda(), mask.cuda() pred = model(img) iou = compute_iou(pred, mask) print(f"Val IoU: {iou:.4f}")逻辑说明:argmax(dim=1)把 2 通道输出转成类别索引。nanmean忽略没有该类别的样本,避免除零。Penn-Fudan 验证集一般从 170 张里划 30 张,剩下的训练。
可视化检查同样重要。把原图、真实掩码、预测掩码并排画出来,能直观看出模型是漏检还是误检。常见问题是边界模糊,这通常是上采样倍数不够或跳级连接没对齐。
4.3 训练轮次与早停策略
Penn-Fudan 上 FCN-8s 通常 40 到 60 个 epoch 收敛。如果验证 IoU 连续 10 个 epoch 不升,就可以停了。早停能省时间,也能防止过拟合。我一般会保存验证 IoU 最高的模型权重,而不是最后一个 epoch 的。
best_iou = 0.0 patience = 10 wait = 0 for epoch in range(80): model.train() for img, mask in train_loader: img, mask = img.cuda(), mask.cuda() optimizer.zero_grad() pred = model(img) loss = criterion(pred, mask) loss.backward() optimizer.step() scheduler.step() # 验证 model.eval() val_iou = evaluate(model, val_loader) if val_iou > best_iou: best_iou = val_iou torch.save(model.state_dict(), 'best_fcn8s.pth') wait = 0 else: wait += 1 if wait >= patience: print(f"Early stop at epoch {epoch}") break逻辑说明:patience=10表示连续 10 个 epoch 没提升就停。torch.save保存的是state_dict,加载时用model.load_state_dict(torch.load(...))。注意保存路径不要带中文,否则在某些环境下会报错。
参数方面,如果训练 loss 还在降但验证 IoU 平了,说明过拟合,可以加 dropout 或增大 weight_decay。如果两者都平了但 IoU 不高,可能是学习率太小或跳级连接有问题。
5. 避坑与排查:Penn-Fudan 跑 FCN 时最容易翻车的五个点
5.1 掩码读取后全是 0 或全是 1
现象:np.unique(mask)只返回[0]或[0, 255],没有中间 ID。原因通常是掩码文件是调色板模式或 16 位 PNG,cv2.imread默认按 8 位读取导致数值截断。解决:用cv2.imread(path, cv2.IMREAD_UNCHANGED)读取,然后检查位深。如果是 16 位,除以 256 或做归一化。调色板模式则要转成灰度再处理。
5.2 上采样后尺寸对不上
现象:RuntimeError: The size of tensor a (X) must match the size of tensor b (Y)。原因是跳级连接时裁剪偏移量算错,或者输入图像尺寸不是 32 的倍数。FCN 经过 5 次池化,下采样 32 倍,输入尺寸最好是 32 的倍数。Penn-Fudan 原图 256×256 没问题,但如果做了随机裁剪到 224×224,224 不是 32 的倍数,上采样后就会差几个像素。解决:裁剪尺寸改成 256 或 192,或者在 forward 最后用F.interpolate强制对齐到输入尺寸。
5.3 训练 loss 不降或变成 NaN
现象:loss 从第一个 epoch 就很大,或者几个 batch 后变成 NaN。原因可能是学习率太大、权重初始化不当、或者输入图像没有归一化。解决:先把学习率降到 1e-5 跑几个 batch,确认 loss 能降再逐步调大。检查输入是否除以 255,如果用了预训练 VGG,还要按 ImageNet 的均值和标准差归一化。另外,ConvTranspose2d的权重初始化如果用了默认的 Kaiming,在某些配置下会输出过大值,可以手动改成xavier_uniform_。
5.4 验证 IoU 高但可视化结果很差
现象:验证集 IoU 0.85 以上,但画出来的掩码边界模糊、行人粘连。原因是 Penn-Fudan 里行人像素占比低,背景预测对了就能拉高 IoU。解决:单独算前景 IoU,或者用 Dice 系数。可视化时把预测掩码叠加到原图上,用半透明红色显示,能更清楚看出边界问题。如果边界确实差,检查跳级连接的裁剪偏移是否对齐,或者把 FCN-8s 换成 FCN-16s 再对比。
5.5 显存溢出或训练速度异常慢
现象:batch_size=4 就 OOM,或者每个 epoch 要跑十几分钟。原因可能是fc6和fc7的 4096 通道太占显存,或者num_workers设太大导致数据加载阻塞。解决:把fc6和fc7通道降到 1024,显存能省一半以上。num_workers在 Windows 上设 0,Linux 上设 2 到 4。另外,如果用了pin_memory=True但没配 CUDA,反而会拖慢速度。
6. 进阶技巧:用 FCN 做迁移学习与多数据集验证
Penn-Fudan 只有 170 张图,单独训练容易过拟合。一个实用技巧是先在 COCO 或 PASCAL VOC 上预训练 FCN,再用 Penn-Fudan 微调。PyTorch 的torchvision里有预训练的 FCN ResNet50,可以直接加载,然后把最后一层改成 2 类。这样即使 Penn-Fudan 样本少,也能在 20 个 epoch 内收敛到不错的 IoU。
from torchvision.models.segmentation import fcn_resnet50 # 加载预训练 FCN model = fcn_resnet50(pretrained=True) # 替换分类头为 2 类 model.classifier[4] = nn.Conv2d(512, 2, 1) # 辅助分类器也改 model.aux_classifier[4] = nn.Conv2d(256, 2, 1) # 只微调分类头和最后两个卷积块 for name, param in model.named_parameters(): if 'classifier' in name or 'aux_classifier' in name or 'layer4' in name: param.requires_grad = True else: param.requires_grad = False optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4)逻辑说明:fcn_resnet50的classifier是一个 Sequential,索引 4 是最后的 1×1 卷积。替换后输出通道变成 2。冻结大部分骨干只微调高层,能显著减少训练时间和显存占用。filter(lambda p: p.requires_grad, ...)只把需要更新的参数传给优化器,避免更新被冻结的层。
参数方面,微调学习率设 1e-4 到 5e-5,比从头训练小一个量级。如果显存够,可以把layer3也解冻,但 Penn-Fudan 上提升有限。
另一个技巧是交叉验证。Penn-Fudan 只有 170 张,划 30 张验证可能波动大。用 5 折交叉验证,每折取 34 张验证,能更稳定地评估模型。但训练时间会变成 5 倍,适合最后调参阶段用。
验证方法上,除了 IoU,还可以算边界 F1 分数。具体做法是对预测掩码和真实掩码分别做边缘提取,然后算匹配率。Penn-Fudan 的行人边界清晰,边界 F1 能反映模型对轮廓的还原能力。如果 IoU 高但边界 F1 低,说明模型只学到了大致区域,没抓住细节。
我自己的习惯是每次换数据集或改网络结构,都先跑一个 5 epoch 的小实验,确认 loss 能降、IoU 能升,再开完整训练。这样能避免跑了半天才发现参数设错。从那以后我每次动 FCN 的跳级连接或上采样参数,都强制走一遍这个短跑验证,省了不少后悔药。希望帮到你。
本文还有配套的精品资源,点击获取