尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

用FCN在Penn-Fudan上实现语义分割:PyTorch入门实战指南

发布时间:2026/9/29 19:05:01

资讯中心
01
ARTICLE

用FCN在Penn-Fudan上实现语义分割:PyTorch入门实战指南

用FCN在Penn-Fudan上实现语义分割:PyTorch入门实战指南
简介语义分割是计算机视觉中像素级分类的基础任务核心在于为图像每个像素赋予类别标签。全卷积网络FCN通过去除全连接层、引入上采样与跳跃连接实现了任意尺寸输入的端到端分割是理解现代分割模型的必经之路。在工程实践中FCN 搭配 Penn-Fudan 行人数据集非常适合入门数据规模小、标注规整可以在 CPU 或低显存环境下快速验证模型。从数据集预处理、ResNet50 主干改造、转置卷积与特征融合到类别不均衡处理与训练指标解析体系化梳理了 FCN 在二分类语义分割任务中的完整 PyTorch 实现并给出常见踩坑点与推理可视化方法帮助开发者建立从理论到实践的完整闭环。1. 全卷积网络配上 Penn-Fudan为什么这个组合最适合入门语义分割很多第一次接触语义分割的人直接把模型从分类网络改成“输出每个像素的类别”结果要么显存爆掉要么掩膜画出来全是噪点。其实把这个任务放在 Penn-Fudan Database 上做是业内最常见的练手路径数据集只有 170 张图和 345 个行人实例规模小到 CPU 都能跑但又真实到能把全卷积网络Fully Convolutional Network的边界问题暴露干净。所谓全卷积网络就是把分类网络的最后池化层和全连接层全部拿掉换成卷积层与上采样层让模型接受任意尺寸输入、输出同尺寸的逐像素类别图。这个标题要解决的问题很具体用 FCN 在 Penn-Fudan 行人数据集上做前景与背景的二分类分割。适合刚入门分割、想在一两个小时内跑通完整流程、并且愿意看清每个参数为什么这么设的开发者。2. 先用 Penn-Fudan 把数据铺平目录、命名和最小 Dataset2.1 数据集结构PNGImages 与 PedMasks 的对应关系拿到 Penn-Fudan 之后目录里通常有两个核心文件夹PNGImages 和 PedMasks。前者存放拍摄行人街景的彩色 PNG 原图后者存放对应的掩膜文件。掩膜是单通道 PNG背景为 0每个行人实例用不同的像素值标出来。和 COCO 那种带 JSON 标注的数据集不同Penn-Fudan 最友好的一点是“图像和掩膜一一对应连文件名都是配套的”。例如 PNGImages 里的 FudanPed00001.png对应 PedMasks 里的 FudanPed00001_mask.png。这个命名规律决定了后续代码里可以安全地用 replace 拼出掩膜路径不用另外维护映射表。这批图的分辨率并不统一常见的是 320×480 上下浮动。图像数量 170 张其中行人姿态、遮挡、夜间场景都有覆盖。正因为规模小才值得用它来做 FCN 的首次落地一个 batch 的训练时间在秒级迭代调参的反馈速度比 Cityscapes 快得多。但它也有一个坑掩膜里不同行人的像素值不是统一的“1”而是 1、2、3 这样的实例编号。很多人第一次训练时直接把掩膜当二分类标签喂给交叉熵发现 loss 一直很大就是因为这里并不是干净的 0/1 标签。处理办法很简单读取后把所有大于 0 的像素值统一置为 1把实例分割归约成语义分割。2.2 数据划分与预处理归一化、缩放和同步变换Penn-Fudan 没有官方划分好的 train/val 切分常见做法是自己按 8:2 切开。170 张图136 张训练、34 张验证。这个比例在分割任务里够用因为每张图包含多个行人模型看到的正样本并不少。预处理上我会固定输入尺寸为 256×256。这个尺寸能整除 32保证 ResNet 全程下采样后每个尺度的特征图边长都是整数后续转置卷积上采样对齐时不容易踩到“差 1 像素”的玄学问题。图像侧做 ImageNet 均值方差归一化因为我们会加载在 ImageNet 上预训练的 ResNet50 权重输入分布不一致会让 BN 层表现很差。掩膜侧不能做同样的归一化它是标签而不是图像只需要转成 long 型张量。Resize 的时候尤其要小心图像用双线性掩膜必须用最近邻否则插值会把边界像素揉成中间值类别就脏了。2.3 用 Dataset 子类封装 Penn-Fudan最小可跑代码把上面这些约定直接落进 PyTorch 的 Dataset 子类。这里我给的是我常用的最小实现没有加太多数据增强先把流程跑通再说。import os import numpy as np import torch from torch.utils.data import Dataset from PIL import Image from torchvision import transforms class PennFudanSeg(Dataset): def __init__(self, root, size(256, 256), trainTrue): self.img_dir os.path.join(root, PNGImages) self.mask_dir os.path.join(root, PedMasks) # 只取 PNG 文件按文件名排序保证顺序稳定 files sorted(f for f in os.listdir(self.img_dir) if f.endswith(.png)) split int(len(files) * 0.8) self.files files[:split] if train else files[split:] self.img_transform transforms.Compose([ transforms.Resize(size), transforms.ToTensor(), # [0,1] transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) self.mask_resize transforms.Resize(size, interpolationImage.NEAREST) def __len__(self): return len(self.files) def __getitem__(self, idx): name self.files[idx] img_path os.path.join(self.img_dir, name) mask_path os.path.join(self.mask_dir, name.replace(.png, _mask.png)) img Image.open(img_path).convert(RGB) mask Image.open(mask_path) img self.img_transform(img) mask self.mask_resize(mask) # 关键把实例编号归一到 0/1语义分割只需要前景背景 mask torch.as_tensor(np.array(mask), dtypetorch.long) mask[mask 0] 1 return img, mask这段代码的逻辑说明getitem里先根据文件名拼接掩膜路径然后分别对图像和掩膜做缩放。图像侧的 transform 做了归一化掩膜侧只用最近邻缩放避免插值伪造新类别。最后一行 mask[mask 0] 1 把多实例编号统一成前景这是整个数据集代码里最不能省的一步。参数说明size 默认 256×256如果你的显存只有 4GB建议不要放大到 512反而可以保持 256 并把 batch 调小。Normalize 的三个均值三个方差来自 ImageNet和后续加载的预训练权重配套。train 参数控制 8:2 切分实际使用时注意同一个 root 下创建两个 Dataset 实例分别传 trainTrue 和 trainFalse。3. 把 ResNet 改造成全卷积网络三层跳跃与转置上采样3.1 为什么去掉全连接层就能做分割分类网络最后接的是全局池化和全连接层它把整张图压成一个类别概率空间位置信息全丢。全卷积网络做的第一件事就是把这两层拿掉让网络输出仍然是一张“特征图”每个像素位置对应原图某个区域的语义信息。R-CNN 系列是“先框出区域再分类”FCN 是“一个前向就把全图每个像素都分了类”这也是它速度快的根本原因。在 Penn-Fudan 这种单类别前景任务上FCN 的输出通道只需要 2对应背景和行人。模型最后经过 softmax 或直接 argmax得到一张和输入同尺寸的预测掩膜。这里有个很容易误会的地方全卷积网络不是“没有全连接层”这么简单它还要保证输出的空间分辨率足够高。ResNet 下采样 32 倍之后一个 256×256 的输入只剩 8×8要是直接上采样回原尺寸行人边界会糊成一团。所以必须引入跳跃连接把浅层的高分辨率特征和深层的强语义特征融合起来。3.2 ResNet50 主干 三层特征融合的模型搭建我采用的实现思路是保留 ResNet50 从 conv1 到 layer4 的卷积主干把 layer2、layer3、layer4 的输出分别看作 stride 为 8、16、32 的三个特征层。然后用 1×1 卷积把每一层的通道数压到类别数得到三张“粗略分数图”。深层分数图先上采样 2 倍和中间层分数图逐元素相加再上采样 2 倍和浅层分数图相加最后一次性上采样 8 倍回到原图尺寸。这个设计对应 FCN-8s 的融合思路是复现成本最低、训练最稳的一种。import torch import torch.nn as nn import torch.nn.functional as F from torchvision.models import resnet50, ResNet50_Weights class FCN8s(nn.Module): def __init__(self, num_classes2): super().__init__() backbone resnet50(weightsResNet50_Weights.DEFAULT) # 前四层是相对浅层的特征stride4 self.stem nn.Sequential( backbone.conv1, backbone.bn1, backbone.relu, backbone.maxpool, backbone.layer1 ) self.layer2 backbone.layer2 # stride8 self.layer3 backbone.layer3 # stride16 self.layer4 backbone.layer4 # stride32 # 三个尺度各自输出类别分数 self.score8 nn.Conv2d(512, num_classes, 1) # 来自 layer2 self.score16 nn.Conv2d(1024, num_classes, 1) # 来自 layer3 self.score32 nn.Conv2d(2048, num_classes, 1) # 来自 layer4 # 转置卷积做 2 倍上采样同时把通道数保持住 self.up2 nn.ConvTranspose2d(num_classes, num_classes, kernel_size4, stride2, padding1) def forward(self, x): # 输入假设 256x256可被 32 整除 f1 self.stem(x) # 64x64 f2 self.layer2(f1) # 32x32 f3 self.layer3(f2) # 16x16 f4 self.layer4(f3) # 8x8 out self.score32(f4) # 8x8 out self.up2(out) # 16x16 out out self.score16(f3) # 与同分辨率分数融合 out self.up2(out) # 32x32 out out self.score8(f2) # 与高分辨率分数融合 # 最后一次上采样用双线性插值直接指定目标尺寸最稳 out F.interpolate(out, size(x.size(2), x.size(3)), modebilinear, align_cornersFalse) return out # shape: [B, 2, 256, 256]逻辑说明stem 输出 stride 为 4 的特征经过 layer2、layer3、layer4 之后分别得到 stride 为 8、16、32 的深层特征。代码先取最深的 score32用转置卷积放大 2 倍和 score16 相加再放大 2 倍和 score8 相加。这样做的好处是不同深度的信息互补浅层特征保留边界细节深层特征提供语义判别力。最后一步用双线性插值而不是转置卷积是因为前面的两轮上采样已经把特征图恢复到原图的 1/8最后 8 倍放大用插值不会有棋盘格噪声实现上也避免了 output_padding 算错导致的尺寸对不上。参数说明kernel_size4、stride2、padding1 是转置卷积做 2 倍上采样的标准组合输出尺寸正好是输入的两倍。如果你把输入改成 320×320 这类能被 32 整除的尺寸f2 到 f4 的特征图边长仍然是整数代码不需要改动。score8、score16、score32 的输出通道都等于最终类别数 2所以融合时可以直接相加。3.3 前向验证先跑一次 dummy 输入再进训练写完模型别急着训练。先构造一个随机输入打印每一层的输出形状确认没有任何一步的尺寸对不上。这一步能省下后面至少半小时的排查时间。model FCN8s(num_classes2) dummy torch.randn(2, 3, 256, 256) out model(dummy) print(out.shape) # 期望 torch.Size([2, 2, 256, 256])逻辑说明dummy 的 batch 为 23 通道256×256。模型输出的第二维是类别数 2第三第四维必须和输入完全一致因为分割任务要求预测掩膜和原图逐像素对应。如果输出是 254×254 或 258×258说明哪一层的 up2 参数配错了如果报 size mismatch说明融合的两个特征图分辨率不一致通常是输入尺寸不能被 32 整除导致的。4. 训练 FCN 的完整配置类别权重、批大小与 30 轮迭代4.1 类别不平衡为什么不能裸用交叉熵Penn-Fudan 图像里行人所占像素比例通常不到 20%背景占绝对多数。如果直接对 logits 做 CrossEntropyLoss模型会学出一个“把全图都预测成背景”的偷懒解mIoU 看着不低但行人区域全丢。解决办法是给交叉熵加类别权重前景的权重远高于背景。常见做法是设权重为 [1.0, 5.0] 或类似的比值让背景错分的惩罚相对变小前景错分的惩罚变大。这里有一个细节值得注意权重不是越大越好。前景权重设成 50 会把训练拉向另一个极端模型疯狂把背景认成行人mIoU 照样上不去。对 Penn-Fudan 这个规模的数据集1.0 比 5.0 到 10.0 之间是比较实用的区间。如果你的显存允许跑更大的 batch还可以考虑 OHEM 这类难例挖掘但对入门实验没必要。4.2 优化器、批大小和指标的计算方式优化器我用 Adam学习率 1e-4几乎不需要调就能收敛。SGD 加动量在这个任务上也能收敛但对学习率的敏感度高新手容易在 0.01 和 0.001 之间反复试探。批大小设为 4 或 8。256×256 输入 ResNet50 主干8 batch 在 8GB 显存上可以跑4 batch 更稳妥。BatchNorm 在 batch 太小时会出问题所以训练批大小不要低于 4。验证指标两个都要看pixel accuracy 衡量逐像素正确率mIoU 衡量每个类别的预测与真实区域的交并比。在背景占比很大的数据集上pixel accuracy 很容易被背景主导所以 mIoU 才是更可信的主指标。代码里我同时计算两者的每个 epoch 均值出现 loss 下降但 mIoU 不升的情况时可以快速判断是不是前景类别消失了。import torch import torch.nn.functional as F from torch.utils.data import DataLoader def pixel_acc(pred, mask): pred pred.argmax(dim1) return (pred mask).float().mean().item() def mean_iou(pred, mask, num_classes2): pred pred.argmax(dim1) ious [] for c in range(num_classes): inter ((pred c) (mask c)).sum().float() union ((pred c) | (mask c)).sum().float() ious.append((inter / (union 1e-6)).item()) return sum(ious) / num_classes device torch.device(cuda if torch.cuda.is_available() else cpu) model FCN8s(num_classes2).to(device) loss_fn torch.nn.CrossEntropyLoss( weighttorch.tensor([1.0, 5.0]).to(device)) optimizer torch.optim.Adam(model.parameters(), lr1e-4) root PennFudanPed # 指向包含 PNGImages 和 PedMasks 的目录 train_ds PennFudanSeg(root, trainTrue) val_ds PennFudanSeg(root, trainFalse) train_loader DataLoader(train_ds, batch_size4, shuffleTrue, num_workers2) val_loader DataLoader(val_ds, batch_size4, shuffleFalse, num_workers2) for epoch in range(30): model.train() total_loss 0.0 for img, mask in train_loader: img, mask img.to(device), mask.to(device) pred model(img) # [B, 2, 256, 256] loss loss_fn(pred, mask) # mask: [B, 256, 256] optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() model.eval() acc_sum, miou_sum, n_batch 0.0, 0.0, 0 with torch.no_grad(): for img, mask in val_loader: img, mask img.to(device), mask.to(device) pred model(img) acc_sum pixel_acc(pred, mask) miou_sum mean_iou(pred, mask) n_batch 1 print(fepoch {epoch:2d} | loss {total_loss/len(train_loader):.3f} f| acc {acc_sum/n_batch:.3f} | mIoU {miou_sum/n_batch:.3f})逻辑说明每个 batch 先前向算出预测 logits再和 long 型掩膜做交叉熵。loss.backward() 后 optimizer.step() 更新参数。验证阶段用 torch.no_grad() 关闭梯度保证显存不涨且评估更快。pixel_acc 和 mean_iou 都基于 argmax 后的硬预测这样才能反映真实推理效果而不是拿概率软值糊弄指标。参数说明CrossEntropyLoss 的 weight 按背景、前景顺序传入。Adam 的默认 betas 和 weight_decay 在 30 轮这种短训练里不用改。batch_size4 对 8GB 显存是安全值如果你用的是 CPU 跑这个小数据集把 num_workers 设成 0否则可能报多进程相关的错误。4.3 训练曲线怎么看什么时候该停Penn-Fudan 这个规模30 轮足够看到收敛趋势。正常现象是前 5 轮 loss 快速下降mIoU 从接近 0 涨到 0.5 上下15 到 25 轮 mIoU 爬到 0.7 到 0.8 区间并趋于平缓。如果 loss 在降但 mIoU 纹丝不动多半是前景被忽略了去检查类别权重和 mask 的 0/1 处理。如果验证 mIoU 开始掉头向下而训练 mIoU 还在涨就是过拟合回退到上一轮保存的模型即可。我习惯每轮保存一次基于验证 mIoU 的最优权重不覆盖最好的那次相当于给自己留了后悔药。训练结束后的评估用这个最优权重而不是最后一轮的权重因为最后一轮可能已经过拟合。5. 常见问题排查Penn-Fudan 上翻车的 5 个细节5.1 掩膜不是 0/1 标签导致 loss 爆炸现象训练一开始 loss 就在 3 以上怎么也降不下来验证集预测结果全黑。原因Penn-Fudan 掩膜里行人是按实例编号填充的像素值有 1、2、3 甚至更大直接当二分类标签喂给 CrossEntropyLoss类别数会对不上模型等于在乱猜。解决在 Datasetgetitem里读取掩膜后统一执行 mask[mask 0] 1把多实例掩膜归约成语义分割的前景标签。这一步放在 Resize 之后因为最近邻插值不会改变像素值。5.2 图像和掩膜尺寸没同步训练时 size mismatch现象dataloader 报错说 batch 里图像的尺寸是 [4, 3, 256, 256]掩膜却是 [4, 250, 250] 或者某个不相同的尺寸。原因常见做法是只对图像做了 Resize掩膜却忘了或者用了不同插值方式但不小心把 Resize 参数写成了不同的目标尺寸。解决把两个 Resize 放进同一个 Dataset 类里图像和掩膜都基于原始 PIL 对象重新缩放确保两者目标尺寸都是同一个 size 变量。掩膜用最近邻图像用双线性两者互不干扰。5.3 BN 在小 batch 下训练不稳预测全是背景现象训练 loss 震荡特别大验证 mIoU 会突然掉回 0.2 以下甚至连续几个 epoch 都不长。原因ResNet 自带大量 BatchNorm 层它的统计量依赖 batch 内的样本分布。batch_size1 或 2 时BN 的均值和方差抖动剧烈尤其在 Penn-Fudan 这种图像内容差异大的数据集上。解决训练 batch_size 至少设到 48 更好。显存不够就先把输入尺寸降到 224×224不要牺牲 batch 大小。验证阶段 batch 再小都没关系因为 eval 模式下 BN 用的是全局统计量。5.4 预训练权重加载报错或效果奇差现象第一次跑模型forward 就报 “size mismatch” 之类的错误或者能训练但 mIoU 始终低于 0.4。原因加载预训练 ResNet50 权重时如果模型里还保留着全连接层或者第一层卷积被改过输入通道权重形状对不上。另一种情况是图像没有做 ImageNet 归一化导致预训练得到的 BN 统计量完全错位。解决按第 3 节的写法只取 backbone 的 conv1 到 layer4不保留 backbone.fc。输入图像必须走 Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])这一点很容易被当成“细节”跳过实际上它直接影响收敛速度和质量。5.5 mIoU 虚高但可视化后前景漏检严重现象验证集 mIoU 显示 0.75看起来不错可把预测掩膜贴到原图上大片行人区域还是没被标出来。原因Penn-Fudan 背景像素占比远高于前景两个类别的 IoU 取平均时背景 IoU 很高就把数值拉上去了。这是二分类分割数据集上典型的“虚高陷阱”。解决训练时看 per-class IoU 而不是只看 mean 值。具体做法是在验证循环里把类别 0 和类别 1 的 IoU 分别打印出来要求前景 IoU 至少达到 0.6 才算真正可用。如果前景 IoU 过低把 CrossEntropyLoss 的权重调大并检查是否出现了过拟合。6. 推理验证不只看 mIoU把预测掩膜叠回原图的两种做法训练结束后千万要做的第一件事是把模型输出的掩膜可视化到原图上而不是盯着 mIoU 数字。一个很实用的习惯是验证时用 256×256 比较指标推理时直接读原图尺寸把预测 logits 插值回原始宽高再与真实掩膜对比。这样可以暴露边界误差和细小行人的漏检。做法是加载模型后把原始图像做同样的 Resize 和归一化前向得到 2 通道 logits再 F.interpolate 到原图尺寸argmax 后就得到全分辨率的预测掩膜。ori_w, ori_h img_pil.size pred model(img_tensor.unsqueeze(0).to(device)) # [1, 2, 256, 256] pred F.interpolate(pred, size(ori_h, ori_w), modebilinear, align_cornersFalse) pred_mask pred.argmax(dim1).squeeze(0).cpu().numpy()逻辑说明这里的关键是最后一步插值到原始尺寸原因在于 256×256 上的 IoU 会把边界误差放大全尺寸推理才能还原模型在真实分辨率下的表现。argmax 得到的是 0/1 数组可以用 PIL 保存成灰度图也可以和原图按 0.5 透明度叠加肉眼检查行人边缘是否贴合。第二种做法是算完总体 mIoU 后再单独算前景 IoU 和背景 IoU两个数字分开记录。如果前景 IoU 比背景低很多就回去调类别权重而不是自我安慰“mIoU 够了”。这两种方法配合起来能比单一指标更快看清模型的真实短板。我自己的习惯是每个 checkpoint 都跑一遍全尺寸推理并保存叠加图等到训练结束再统一翻看比只看曲线更容易发现那些“指标合格但实际没法用”的模型。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。