简介基于Python与PyTorch的FCN语义分割完整复现项目面向初学语义分割的深度学习新手以及需要完成课程设计、毕业设计或工程实训的学员帮助掌握从数据准备、模型搭建到训练评估的完整流程。资源包共1218个文件以1201张jpg图片作为训练数据另有6张png标签图、5个Python脚本和少量配置说明压缩后约71.81MB数据量小、运行门槛低。压缩包内附有全部训练图片与对应标签解压后可直接运行避免再花时间搜集数据。代码包含FCN32s、FCN16s、FCN8s与FCNs四种网络定义基于VGGNet构建其中train.py负责训练与可视化FCN.py给出网络结构BagData.py实现自定义Dataset和DataLoaderonehot.py完成标签onehot编码结构清晰便于按模块学习。数据集为随机背景上的包类图片适合快速实验和验证算法效果。已有215人学习下载可作为入门语义分割、理解反卷积与跳跃结构的第一手实战资料。1. 基于 pythonFCN 实现语义分割为什么我推荐先跑这份小项目做语义分割的人第一反应通常是去啃 Cityscapes 或者 VOC 这种多类别大数据集结果光下载、预处理、标签对齐就能耗掉一整天。这个基于 python 的 FCN 语义分割复现项目反着来数据集是随机背景上的包的图片全部文件不到 80M代码只有 train.py、FCN.py、BagData.py、onehot.py 四个文件。它把语义分割算法的主干流程——VGG 特征提取、全卷积化、跳层融合、上采样、onehot 编码、训练可视化——完整走了一遍特别适合做毕设、课程设计或者第一次接触语义分割的人。你要理解 FCN32s/16s/8s 的区别、想跑通 PyTorch 的完整训练链路这份资源比任何大项目的复现都容易落地。2. 拆 FCN 模型定义VGG16 换掉全连接层四种变体差在哪2.1 从分类网络到全卷积特征层 stride 就是上采样的依据FCN 的核心思路是让网络接受任意尺寸输入、输出同尺寸的像素级预测。做法很直接把 VGG 后面的全连接层换成卷积层保留前面的 5 个 block。VGG 每个 block 末端有一个 MaxPool2dstride 都是 2从 pool1 到 pool5特征图相对于原图依次缩小 2、4、8、16、32 倍。这个 stride 倍数直接决定了后面转置卷积的上采样倍数FCN32s 对应 32 倍FCN16s 对应 16 倍FCN8s 对应 8 倍。VGG 不同种类只是中间卷积层堆叠数量不同代码里用一个配置字典就能生成。FCN.py 里构建 VGG 网络的函数就是按这个配置循环创建卷积层和 ReLU 的vgg_cfg { A: [64, M, 128, M, 256, 256, M, 512, 512, M, 512, 512, M], D: [64, 64, M, 128, 128, M, 256, 256, 256, M, 512, 512, 512, M, 512, 512, 512, M], } def make_layers(cfg, in_channels3): layers [] for v in cfg: if v M: layers.append(nn.MaxPool2d(2, 2)) else: conv nn.Conv2d(in_channels, v, kernel_size3, padding1) layers [conv, nn.ReLU(inplaceTrue)] in_channels v return nn.Sequential(*layers)这段代码里M代表 MaxPool2d数字代表卷积输出通道数。VGG16 对应配置D13 个卷积层加 5 个池化层。实际训练时如果显存紧张也可以换AVGG11效果差一点但速度更快。需要特别注意的是 ReLU 用了inplaceTrue这在 PyTorch 里能省一点显存但如果后续操作依赖 ReLU 前的输入要小心别被原地修改FCN 的跳层融合会用到 pool3、pool4 的输出好在这些输出取自池化层之前不受影响。全连接层转卷积是 FCN 的关键一步。VGG 原本的 fc6 是 7x7 输入但全卷积化后需要变成卷积层常见做法是 fc6 用 kernel_size7、padding3fc7 用 kernel_size1这样能保持空间维度不变。如果输入不是经典的 224 尺寸fc6 的 kernel 也要跟着改这是很多人初始化后才发现的问题。2.2 FCN32s、FCN16s、FCN8s、FCNs 的跳层融合差异四种变体共享同一个 VGG backbone区别在于上采样路径怎么融合浅层特征。FCN32s 最简单经过 pool5 的特征图stride 32过 1x1 卷积输出类别数然后一个 32 倍转置卷积直接拉回原图尺寸。FCN16s 在 32s 基础上把 pool4 的 1x1 卷积结果加进来再 16 倍上采样。FCN8s 继续融合 pool3所以边缘细节更细。FCNs 在不同实现里定义略有差异通常指融合层更多或结构更灵活的版本这里可以把它理解为跳层融合的进一步加强版。class FCN8s(nn.Module): def __init__(self, n_class, vgg_features): super().__init__() self.features vgg_features self.fc6 nn.Conv2d(512, 4096, 7, padding3) self.relu6 nn.ReLU(inplaceTrue) self.drop6 nn.Dropout2d() self.fc7 nn.Conv2d(4096, 4096, 1) self.relu7 nn.ReLU(inplaceTrue) self.drop7 nn.Dropout2d() self.score nn.Conv2d(4096, n_class, 1) self.score_pool3 nn.Conv2d(256, n_class, 1) self.score_pool4 nn.Conv2d(512, n_class, 1) self.upsample2x nn.ConvTranspose2d(n_class, n_class, 4, stride2, padding1) self.upsample8x nn.ConvTranspose2d(n_class, n_class, 16, stride8, padding4) def forward(self, x): pool3 pool4 None for i, layer in enumerate(self.features): x layer(x) if i 17: pool3 x elif i 23: pool4 x x self.relu6(self.fc6(x)) x self.relu7(self.fc7(x)) x self.drop7(x) score self.score(x) score_pool4 self.score_pool4(pool4) x self.upsample2x(score) score_pool4 score_pool3 self.score_pool3(pool3) x self.upsample2x(x) score_pool3 x self.upsample8x(x) return xforward 里的pool3、pool4是通过遍历 features 层抓取的索引 17 和 23 对应 VGG16 的 pool3 和 pool4。为什么先算 pool4 融合再算 pool3因为 pool4 的特征图 stride 是 16scorestride 32先 2 倍上采样到 stride 16 才能相加相加后 stride 16 再 2 倍上采样到 stride 8才能和 pool3 对齐。每一步融合都要求两边的空间尺寸严格一致差一个像素都会在训练时报尺寸不匹配错误。2.3 转置卷积参数反推与 checkerboard 伪影转置卷积的输出尺寸公式是(H-1)*stride - 2*padding kernel output_padding。以 FCN8s 为例目标是 8 倍上采样常见配置是 kernel16、stride8、padding4、output_padding0代入公式刚好能还原到池化前尺寸。如果 input 是 28x28计算出来是(28-1)*8 - 8 16 224刚刚好。很多人直接抄参数不验证改输入尺寸后输出和标签对不上就是这个公式没算清楚。建议每次换输入尺寸前先拿一张图 forward 一遍打印输出 shape。转置卷积还有个经典问题是棋盘格伪影当 kernel 不能被 stride 整除时上采样结果会出现不均匀的重叠视觉上像棋盘。kernel16、stride8 整除没问题但 FCN32s 里 kernel64、stride32 也整除所以原始 FCN 结构还好。真要细究U-Net 里很多实现改用双线性插值先放大再卷积就是为了规避这个问题但在复现 FCN 时不建议改因为跳层融合的加法和固定倍率上采样都是按转置卷积设计的。表格对比四种结构更直观模型融合层上采样倍率输出细节训练难度FCN32s无32x粗糙最简单FCN16spool416x中等中等FCN8spool3pool48x较细稍复杂FCNs多层混合视实现而定最细最复杂3. 跑通数据管线BagData 与 onehot 编码的配合3.1 这份小数据集的结构和读取方式数据集分两个目录bag_data放原始图片bag_data_mask放掩码。图片是数字命名的 jpg比如 53.jpg、352.jpg、107.jpg内容是随机背景上的包的图片。因为文件总数不大全部加起来不到 80M这也是为什么我推荐用它做第一次语义分割实验——不会像 VOC 那样一解压几个 G也不存在标签文件缺失的问题。语义分割数据集制作的核心是图像和掩码严格对齐这里文件名一一对应就是最简单可靠的对齐方式。掩码通常存成灰度图每个像素的灰度值代表类别编号。这个项目里前景只有包一类所以背景是 0、包是 1保存时可能是 0/255 而不是 0/1读取时要先归一化。我用 PIL 读掩码时习惯先转成L模式再做一个阈值二值化这样即使掩码文件被保存成了 JPG有损压缩也能稳定拿到类别from PIL import Image import numpy as np mask Image.open(mask_path).convert(L) mask_np np.array(mask) mask_bin (mask_np 127).astype(np.float32)这种 0/255 的掩码很常见。如果直接用原始灰度值当类别标签包的区域是 255CrossEntropyLoss 会把它当成第 255 类训练必然出问题。数据加载的第一步就是统一类别范围到 0 到 C-1。3.2 BagData.py 里的 Dataset 和 DataLoader 设计BagData.py 的核心是继承torch.utils.data.Dataset写一个可迭代的数据类。__init__里扫描目录、建立图片和掩码的配对列表__getitem__里按索引读取并做增广变换。这里有个设计细节图片用convert(RGB)掩码用convert(L)因为掩码是单通道索引图不需要也没有彩色信息。from torch.utils.data import Dataset from torchvision import transforms import os class BagData(Dataset): def __init__(self, img_dir, mask_dir, transformNone, target_size(224, 224)): self.img_dir img_dir self.mask_dir mask_dir self.transform transform self.target_size target_size self.samples sorted([f for f in os.listdir(img_dir) if f.lower().endswith(.jpg)]) def __len__(self): return len(self.samples) def __getitem__(self, idx): name self.samples[idx] img_path os.path.join(self.img_dir, name) mask_path os.path.join(self.mask_dir, name) image Image.open(img_path).convert(RGB).resize(self.target_size, Image.BILINEAR) mask Image.open(mask_path).convert(L).resize(self.target_size, Image.NEAREST) mask_np (np.array(mask) 127).astype(np.int64) if self.transform: image self.transform(image) mask torch.from_numpy(mask_np).long() return image, mask掩码的 resize 用的是Image.NEAREST这很重要双线性插值会在类别边界上插出中间值产生不存在的类别把 0 和 1 插成 0.5训练时 loss 直接暴涨。图片缩放可以用BILINEAR保持锐度掩码必须用最近邻。transform只作用于图片掩码不走 ToTensor因为它已经从 ndarray 转成了 int64 的张量形状是 HxW正好是 CrossEntropyLoss 需要的 target 格式。3.3 onehot.py索引图与 onehot 编码的双向转换onehot.py 做的事是把 HxW 的索引图变成 HxWxC 的 0/1 向量。PyTorch 里最简单的实现是用单位矩阵取索引def onehot_from_mask(mask, n_class2): # mask: H x W, dtypetorch.int64 return torch.eye(n_class)[mask] def mask_from_onehot(onehot): # onehot: H x W x C return torch.argmax(onehot, dim-1).long()第一行torch.eye(n_class)[mask]利用张量索引广播mask 里每个位置的值作为行号从单位矩阵里取对应行最后形状是 HxW x C。反向转换用argmax取概率最大的类别索引。这个工具在当前项目里主要用途是可视化训练时打印的预测图往往是 onehot 形式的要还原成灰度图就得用mask_from_onehot。这里提醒一句PyTorch 的nn.CrossEntropyLoss官方要求 target 是索引图HxW int64不是 onehot。所以训练主流程里 fed 给 loss 的是 BagData 返回的索引掩码不是 onehot 结果。onehot 更多用在可视化、计算 IoU 或者某些自定义 loss 上。如果哪份代码把 onehot 直接当 target 喂给 CrossEntropyLoss会报维度错误或者静默地按错误方式计算后面避坑章节会专门展开。4. 训练与可视化train.py 里值得改的参数和小数据集经验4.1 损失函数与类别权重背景远多于包时怎么办包的图片大多是随机背景前景包只占画面的一小部分背景像素数量可能超过前景好几倍。如果直接对每个像素等权算交叉熵模型学到的几乎全是背景预测结果会整体偏向全黑。解决办法是给 CrossEntropyLoss 传weight让前景类别的梯度贡献更大。权重可以按像素占比反比算也可以直接手工给经验值counts np.bincount(all_mask_flat, minlength2) weights 1.0 / (counts 1e-6) weights weights / weights.sum() * len(weights) # 归一化 criterion nn.CrossEntropyLoss(weighttorch.tensor(weights, dtypetorch.float32).to(device))这个权重是针对类别像素占比的反比归一化类别c的权重越大loss 里属于该类的像素贡献越高。常见的训练结果背景权重约 0.5、前景权重约 1.5具体数值要看你的 mask 统计。如果发现预测图里包的区域被吃掉一半优先调这个权重比调学习率见效快得多。注意weight要放在和设备一致的张量上否则模型在 GPU 上算 loss 会报 device mismatch。4.2 优化器与学习率Adam 起步、验证集说话小数据集上 Adam 比 SGD 稳定得多我一般用lr1e-4起步配合 StepLR 每 30 个 epoch 降一半。SGD 需要自己调 momentum 和权重衰减在小数据集上容易一步迈太大直接发散。项目给的train.py默认参数通常就是 Adam下面是可复现的训练循环骨架device torch.device(cuda if torch.cuda.is_available() else cpu) model FCN8s(n_class2, vgg_featuresmake_layers(vgg_cfg[D])).to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-4, weight_decay1e-5) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size30, gamma0.5) for epoch in range(epochs): model.train() total_loss 0.0 for imgs, masks in train_loader: imgs, masks imgs.to(device), masks.to(device) out model(imgs) loss criterion(out, masks) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() * imgs.size(0) scheduler.step() print(fepoch {epoch}, loss {total_loss / len(train_loader.dataset):.4f})weight_decay1e-5是防止小数据集过拟合的常用设置但别调太大否则模型欠拟合、loss 降不下去。epochs不要盲目设 200先跑 50 个 epoch 看 loss 曲线如果还在稳定下降再继续。这个项目的数据量很小通常 30-50 个 epoch 就能看到明显的分割效果再多就开始过拟合了。4.3 训练循环与可视化输出把预测 mask 打出来看训练目标不只是 loss 下降还要直观看到模型把包分成了什么样。最简单的方式是每个 epoch 结束后从验证集抽几张图把模型输出 argmax 变成预测类别图和原图叠在一起保存。可视化代码要固定在.eval()和torch.no_grad()上下文中执行model.eval() with torch.no_grad(): for i, (imgs, masks) in enumerate(val_loader): imgs imgs.to(device) out model(imgs) pred torch.argmax(out, dim1).squeeze(0).cpu().numpy() mask pred * 255 Image.fromarray(mask.astype(np.uint8)).save(fvis_epoch{epoch}_{i}.png) breakargmax(dim1)是在类别维上取最大值索引输出形状从 NxCxHxW 变成 NxHxW 的索引图。乘 255 是为了把 0/1 映射成 0/255 的灰度图方便肉眼确认。注意保存时转成uint8不然Image.fromarray会报类型不支持。这个可视化脚本一定不要漏掉torch.no_grad()否则模型在推理状态下仍会构建计算图显存占用翻倍小卡很容易直接 OOM。5. 小数据集训练 FCN 的五个典型翻车点避坑5.1 现象loss 一路不降甚至某个 epoch 直接 NaN原因基本就三个学习率太大、输入没做归一化、标签里混入了超出类别范围的数值。小数据集上 Adam 用 1e-3 以上很容易在几十步后冲到 NaN尤其是 VGG 这种深网络梯度范数会随着层数剧烈波动。另外如果transform里漏了Normalize输入像素还停在 0-255 整数区间和预训练权重期望的mean/std分布差太远反向传播数值也不稳定。解决先把学习率降到 1e-4 再试确认transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225])已经加上然后在每个 epoch 打印loss.item()如果出现 NaN 立刻调小 lr 或者截断梯度torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm10.0)。这个截断能兜底但不解决根源根源多半还是输入数值范围。5.2 现象预测图全黑或全白看起来像瞎猜原因多数是 target 格式错了。CrossEntropyLoss 要求 target 是 HxW 的 int64 索引图类别范围 0 到 C-1。如果训练代码里把 onehot 后的 HxWxC 张量直接传给 lossPyTorch 在 C2 时不会立刻报错但计算出来的 loss 和梯度完全错误模型输出就会朝着错误方向优化。另一类原因是掩码读到的是 0/255 但没有做阈值映射255 被当成类别 255FCN 输出只有 C2 通道索引越界后 argmax 拿到的是第 1 类于是整张图都变白。解决在__getitem__里强制做(mask_np 127).astype(np.int64)并打印一次torch.unique(mask)确认只有 0 和 1 两个值。如果已经在用 onehot喂给 loss 前用torch.argmax(onehot, dim-1)转回索引图。5.3 现象训练时报尺寸不匹配或者跑通但输出比原图小一圈原因在转置卷积的参数没算对或者跳层融合时两侧特征图尺寸差了 1-2 像素。FCN16s 要把 stride 32 的 score 上采样 2 倍后和 pool4 相加如果转置卷积的padding或output_padding设置不当加法的两边尺寸对不上直接抛 RuntimeError如果只是最后输出尺寸和原图差了几个像素PyTorch 不报错但可视化时 mask 和原图无法叠加。解决每个转置卷积的尺寸用公式(H-1)*stride - 2*padding kernel output_padding手算一遍。最保险的做法是改完模型后先构造一个随机输入 forward 一遍把各层输出 shape 打印出来人工核对。对不齐时不要改网络结构直接用中心裁剪crop把多出来的像素切掉这是 FCN 论文里就有的做法。5.4 现象加载官方 VGG 预训练权重时报 missing or unexpected key原因官方vgg16权重里包含classifier.0.weight这类全连接层参数而 FCN 模型里 fc6、fc7 已经被替换成了卷积层key 对不上。如果直接model.load_state_dict(state_dict)PyTorch 会报缺少 fc6.weight、多出 classifier.0.weight 之类的错误。解决加载时设置strictFalse只把 features 部分的权重载入state_dict torchvision.models.vgg16(pretrainedTrue).state_dict() model.load_state_dict(state_dict, strictFalse)strictFalse会跳过 key 不匹配的参数但也会让 FCN 自己初始化的 fc6、fc7 保持随机初始化状态。要想更精细可以先过滤掉classifier.前缀的所有参数再加载。对小数据集来说用strictFalse就够用了随机初始化的头会在训练过程中快速收敛。5.5 现象训练 loss 很好看验证集一塌糊涂原因很简单数据集只有不到 80M不动声色地过拟合了。包的图片背景随机但同质化高模型很容易记住训练图里的背景纹理而不是包的语义特征。最直接的证据是训练集 loss 降到 0.1 以下验证集 loss 还在 1 以上。解决加数据增强随机水平翻转、亮度扰动、小角度旋转都能增加泛化性。transforms.Compose里在 ToTensor 之前加RandomHorizontalFlip(p0.5)、ColorJitter(brightness0.2, contrast0.2)。这种小数据集训练时我的习惯是三分之一的数据量做验证并且每个 epoch 都保存验证集 IoU 最好的权重而不是最后一个 epoch 的权重这样复现时拿到的模型质量会明显高一个档次。6. 把推理可视化变成条件反射单图验证模型结构正确性改完网络或换数据集后别急着启动完整训练。我现在的习惯是训练前先拿一张图跑推理确认模型在前向传播时尺寸不报错、输出能正确还原成掩码再正式训练。这套顺序帮我避开了不少玄学报错。推理脚本可以独立成一个函数输入一张图、输出预测 mask 和叠加图def predict_single(img_path, model, device): mean [0.485, 0.456, 0.406] std [0.229, 0.224, 0.225] transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean, std) ]) image Image.open(img_path).convert(RGB) x transform(image).unsqueeze(0).to(device) model.eval() with torch.no_grad(): out model(x) pred torch.argmax(out, dim1).squeeze(0).cpu().numpy() mask (pred * 255).astype(np.uint8) return np.array(image.resize((224, 224))), mask这段脚本把数据预处理、前向传播、argmax 解码、可视化数据准备串在了一起。unsqueeze(0)补 batch 维度squeeze(0)在拿到预测后去掉 batch 维度这两个操作成对出现写错一个输出就变成 1x1xHxW 的形状叠加可视化时矩阵维度就对不上。如果你换用了 FCN32s 或者 FCNs只需要替换模型类这个推理函数不用改因为接口统一是输入 Nx3xHxW、输出 NxCxHxW。有用的小技巧是把预测 mask 和原图按 0.5 透明度叠加用cv2.addWeighted或者直接 PIL 的Image.blend都能做到这样能直观看到分割边界是否贴合包的轮廓。对于这个二分类任务mask 上白色区域就是模型认为的包的位置和原图一叠过拟合还是欠拟合一眼就能判断。从那以后我每次改 FCN 结构都先拿一张图做单图推理可视化确认 mask 形状、类别走向对了再启动全量训练。这个习惯帮我避开了不少玄学报错希望帮到你。本文还有配套的精品资源点击获取