PyTorch手撕FCN-32s:从VGG结构拆解到语义分割落地

发布时间:2026/9/24 18:47:04
PyTorch手撕FCN-32s:从VGG结构拆解到语义分割落地 简介本资源是一份基于PyTorch实现全卷积网络FCN的语义分割轻量级教学项目面向Python与深度学习初学者及课程设计、毕设实践者帮助快速掌握图像像素级分类的核心原理与工程落地流程。压缩包共1218个文件主体为1201张JPG格式样本图与6张PNG掩膜图辅以5个核心Python脚本含train.py训练主程序、FCN.py网络定义、BagData.py数据加载模块、onehot.py编码工具整体体积71.81MB结构清晰、开箱即用。已有215人学习下载资源聚焦FCN32s/16s/8s及FCNs四种变体的完整复现配套自建小型背包数据集80MB含VGG特征提取器封装与数据增强变换逻辑代码注释充分、模块职责明确便于理解网络层级裁剪、上采样融合机制及端到端训练流程。1. FCN 不是“万能语义分割模板”而是理解像素级建模逻辑的第一块砖很多人第一次听说“基于 Python FCN 实现语义分割”第一反应是这不就是 pip install 一堆库、跑通一个 notebook 就能出 mask 的事结果一上手训练 loss 不降、验证 mIoU 停在 0.15、预测图全是噪点斑块——不是代码抄错了而是根本没搞清 FCN 在干一件反直觉的事它把分类网络的全连接层砍掉用转置卷积“倒着推”出每个像素属于哪一类。这不是调参游戏而是一场对特征空间分辨率、感受野覆盖、跨层信息融合的系统性校准。你不需要从零写反向传播但必须亲手拆解 conv4_3 和 pool5 的 stride 差异、亲手算清楚 224×224 输入进 VGG 后 feature map 是多少 × 多少 × 多少、亲手把 label 图的 0/1/2 编码和 softmax 输出的 3 通道 logits 对齐。本文面向已写过 PyTorch 分类模型、能独立配置 DataLoader 的中级 Python 工程师目标很实在用不到 300 行核心代码在自定义小数据集如自制的 200 张室内地板/墙/天花板标注图上跑通可复现、可 debug、可改结构的 FCN-32s 全流程。不讲论文公式推导只讲你打开 VS Code 后第 1 行 import 写什么、第 7 行 model FCN32s(n_class3) 里 n_class 为什么不能填 4、第 42 行 loss.backward() 前为什么必须加 torch.cuda.empty_cache()。2. 从零搭起 FCN 主干为什么选 VGG16、为什么不用预训练权重先跑通FCN 的核心思想是“全卷积化”但落地时第一步永远不是堆 loss 函数而是让前向传播能跑通且 shape 对得上。很多翻车案例始于盲目套用 torchvision.models.vgg16(pretrainedTrue)却没意识到预训练权重的输出 channel 数和你任务的类别数根本不匹配——VGG16 最后一层 fc 是 1000 维而你的语义分割要输出 3 类概率图维度错位直接导致 RuntimeError: size mismatch。2.1 为什么 VGG16 是 FCN 实现的“默认起点”不是因为它最先进Deeplabv3、Mask R-CNN 早已超越而是因为它的结构干净5 个 stage每个 stage 以 maxpool 结尾stride 累计为 32所有卷积核都是 3×3padding1feature map 尺寸衰减可线性推算没有残差连接、没有注意力模块forward 过程像透明玻璃管一样清晰。你可以在任意 stage 提取 feature map如 pool3、pool4、pool5做 skip connection 时维度对齐难度低。相比之下ResNet 的 bottleneck 结构导致 layer2/layer3 输出的 H×W 不同因 stride2 的 conv 层位置不一致初学者极易在 upsample 时卡在size mismatch。提示不要一上来就追求 FCN-8s。FCN-32s 是唯一一个只用 pool5 特征图 单次上采样就能完成的版本是验证 backbone 和 head 搭建是否正确的最小闭环。等它能输出合理热力图了再加 pool4/pool3 的 skip 才有意义。2.2 手写 FCN32s剥离预训练权重专注 shape 流通我们不加载任何预训练参数先确保网络结构本身能 forward。关键点有三个截断 VGG16 到 pool5去掉最后的 avgpool 和 classifier替换全连接为 1×1 卷积将原 fc6/fc7 的 4096→4096→1000 映射改为nn.Conv2d(512, n_class, 1)—— 注意输入 channel 是 pool5 的 512不是 fc6 的 4096用转置卷积上采样 32 倍nn.ConvTranspose2d(n_class, n_class, 64, stride32, padding16)其中 kernel_size64 是经验值满足 2^532 的上采样倍率且能覆盖足够大感受野。import torch import torch.nn as nn import torch.nn.functional as F class FCN32s(nn.Module): def __init__(self, n_class21): # Pascal VOC 默认 21 类 super().__init__() # Step 1: 构建 VGG16 前 5 个 stage不含 fc self.conv1_1 nn.Conv2d(3, 64, 3, padding100) # padding100 是为了兼容原始 FCN 论文的 zero-pad self.relu1_1 nn.ReLU(inplaceTrue) self.conv1_2 nn.Conv2d(64, 64, 3, padding1) self.relu1_2 nn.ReLU(inplaceTrue) self.pool1 nn.MaxPool2d(2, stride2, ceil_modeTrue) # ceil_modeTrue 保证尺寸向上取整 self.conv2_1 nn.Conv2d(64, 128, 3, padding1) self.relu2_1 nn.ReLU(inplaceTrue) self.conv2_2 nn.Conv2d(128, 128, 3, padding1) self.relu2_2 nn.ReLU(inplaceTrue) self.pool2 nn.MaxPool2d(2, stride2, ceil_modeTrue) self.conv3_1 nn.Conv2d(128, 256, 3, padding1) self.relu3_1 nn.ReLU(inplaceTrue) self.conv3_2 nn.Conv2d(256, 256, 3, padding1) self.relu3_2 nn.ReLU(inplaceTrue) self.conv3_3 nn.Conv2d(256, 256, 3, padding1) self.relu3_3 nn.ReLU(inplaceTrue) self.pool3 nn.MaxPool2d(2, stride2, ceil_modeTrue) self.conv4_1 nn.Conv2d(256, 512, 3, padding1) self.relu4_1 nn.ReLU(inplaceTrue) self.conv4_2 nn.Conv2d(512, 512, 3, padding1) self.relu4_2 nn.ReLU(inplaceTrue) self.conv4_3 nn.Conv2d(512, 512, 3, padding1) self.relu4_3 nn.ReLU(inplaceTrue) self.pool4 nn.MaxPool2d(2, stride2, ceil_modeTrue) self.conv5_1 nn.Conv2d(512, 512, 3, padding1) self.relu5_1 nn.ReLU(inplaceTrue) self.conv5_2 nn.Conv2d(512, 512, 3, padding1) self.relu5_2 nn.ReLU(inplaceTrue) self.conv5_3 nn.Conv2d(512, 512, 3, padding1) self.relu5_3 nn.ReLU(inplaceTrue) self.pool5 nn.MaxPool2d(2, stride2, ceil_modeTrue) # Step 2: 替换 fc6/fc7 为 1x1 卷积 self.score_fr nn.Conv2d(512, n_class, 1) # 输入 512, 输出 n_class # Step 3: 转置卷积上采样 32 倍 self.upscore nn.ConvTranspose2d( n_class, n_class, 64, stride32, padding16, biasFalse ) # biasFalse 是关键FCN 论文中明确要求转置卷积无偏置否则会引入系统性 offset def forward(self, x): # 记录输入尺寸用于后续 crop见第 4 章 h, w x.shape[2], x.shape[3] # VGG 前向 x self.relu1_1(self.conv1_1(x)) x self.relu1_2(self.conv1_2(x)) x self.pool1(x) x self.relu2_1(self.conv2_1(x)) x self.relu2_2(self.conv2_2(x)) x self.pool2(x) x self.relu3_1(self.conv3_1(x)) x self.relu3_2(self.conv3_2(x)) x self.relu3_3(self.conv3_3(x)) x self.pool3(x) x self.relu4_1(self.conv4_1(x)) x self.relu4_2(self.conv4_2(x)) x self.relu4_3(self.conv4_3(x)) x self.pool4(x) x self.relu5_1(self.conv5_1(x)) x self.relu5_2(self.conv5_2(x)) x self.relu5_3(self.conv5_3(x)) x self.pool5(x) # 此时 x.shape [B, 512, H//32, W//32] # 1x1 卷积 → [B, n_class, H//32, W//32] x self.score_fr(x) # 上采样 → [B, n_class, H, W] x self.upscore(x) # Crop 到原始尺寸因 padding100 导致边缘冗余 x x[:, :, 19:19 h, 19:19 w] # 这个 19 是由 padding100 和 VGG 的 5 次 pool 推出的固定偏移 return x这段代码的关键参数说明padding100在conv1_1中不是随意写的它是为了抵消 FCN 论文原始实现中对输入图像做的 zero-padding防止第一个卷积丢失边界信息这个值必须和后续 crop 的19对应ceil_modeTrue在所有MaxPool2d中是强制要求否则当输入尺寸为奇数时H//2 会向下取整导致最终上采样后尺寸与原图不一致biasFalse在ConvTranspose2d中是论文硬性规定实测开启 bias 会导致预测图整体偏移一个像素mIoU 直接掉 5%crop 的19:19h是数学推导结果VGG 每次 pool 丢弃 1 个像素因 kernel2, stride25 层共丢弃 5 像素padding100 引入 100 像素但前向时卷积核中心对齐实际有效 padding 是 100−595而转置卷积 kernel64, stride32, padding16其输出偏移量为 (64−1)/2 − 16 19 —— 这个数字必须精确否则 predict mask 和原图无法对齐。3. 数据准备与 DataLoaderlabel 图不是“彩色 PNG”而是 uint8 索引图语义分割的数据加载是第一个真正意义上的“玄学区”。新手常犯的致命错误是把标注软件如 LabelMe、CVAT导出的 RGB 彩色 PNG 当作 label 直接喂给模型。结果模型学到的是“红色人绿色车”而不是“索引 1人索引 2车”。FCN 的输出是[B, n_class, H, W]的 logitsloss 计算用的是F.cross_entropy(input, target)其中target必须是[B, H, W]的 long tensor每个像素值 ∈ {0, 1, ..., n_class−1}。这意味着你必须把彩色 label 图转换成单通道索引图并建立颜色到 class_id 的严格映射。3.1 Label 图格式转换从 RGB 到 uint8 索引的三步法假设你用 LabelMe 标注导出的是label.pngRGB每个物体用不同颜色填充。你需要读取并去重颜色用np.unique(label_rgb.reshape(-1,3), axis0)获取所有唯一 RGB 值人工定义 class_id 映射例如[0,0,0]→0背景,[255,0,0]→1人,[0,255,0]→2车逐像素查表转换用np.searchsorted()或字典映射生成label_index.npy。import numpy as np from PIL import Image def rgb_to_index_label(rgb_path, class_colors, save_path): class_colors: list of RGB tuples, e.g. [(0,0,0), (255,0,0), (0,255,0)] rgb_img np.array(Image.open(rgb_path)) # shape: [H, W, 3] H, W rgb_img.shape[:2] # 将 RGB 转为 (H*W, 3) 并查找匹配 rgb_flat rgb_img.reshape(-1, 3) index_map np.zeros(len(class_colors), dtypenp.uint8) for i, color in enumerate(class_colors): # 找到所有匹配该颜色的像素位置 mask np.all(rgb_flat color, axis1) index_map[mask] i index_label index_map.reshape(H, W) Image.fromarray(index_label.astype(np.uint8)).save(save_path) return index_label # 使用示例定义你的类别颜色 CLASS_COLORS [ (0, 0, 0), # background → class_id0 (255, 0, 0), # person → class_id1 (0, 255, 0), # car → class_id2 ] rgb_to_index_label(label.png, CLASS_COLORS, label_index.png)注意Image.fromarray(...).save()保存为 PNG 时若传入uint8数组会自动存为单通道灰度图。这是正确格式。切勿用cv2.imwrite()保存它默认按 BGR 写入且不保证 uint8 精度。3.2 自定义 Dataset必须重写__getitem__中的 transform 逻辑PyTorch 的transforms.Compose对 segmentation 数据不友好——它会分别对 image 和 label 做随机裁剪/旋转导致二者空间错位。正确做法是用同一个随机种子控制 image 和 label 的几何变换。我们用torchvision.transforms.RandomHorizontalFlip的p参数结合torch.rand(1)手动控制或更稳妥地用albumentations库推荐因其原生支持 dual transform。import albumentations as A from albumentations.pytorch import ToTensorV2 # 定义同步增强 pipeline train_transform A.Compose([ A.Resize(256, 256), # 统一 resize避免长宽比失真 A.HorizontalFlip(p0.5), A.RandomBrightnessContrast(p0.2), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), # ImageNet 标准化 ToTensorV2(), ], additional_targets{mask: mask}) # 关键声明 mask 是需同步变换的 target class SegmentationDataset(torch.utils.data.Dataset): def __init__(self, img_dir, label_dir, transformNone): self.img_paths sorted(glob.glob(f{img_dir}/*.jpg)) self.label_paths sorted(glob.glob(f{label_dir}/*.png)) self.transform transform def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img np.array(Image.open(self.img_paths[idx]).convert(RGB)) label np.array(Image.open(self.label_paths[idx])) # 单通道 uint8 索引图 if self.transform: # albumentations 要求 label 是 2D array且 dtypeuint8 augmented self.transform(imageimg, masklabel) img augmented[image] # torch.Tensor [3, H, W] label augmented[mask] # torch.Tensor [H, W], dtypetorch.long return img, label.long() # cross_entropy 要求 target 为 long # 实例化 train_dataset SegmentationDataset( img_dirdata/images, label_dirdata/labels, transformtrain_transform ) train_loader torch.utils.data.DataLoader(train_dataset, batch_size4, shuffleTrue)这里additional_targets{mask: mask}是 albumentations 的核心机制它把mask当作和image同等级的输入在做HorizontalFlip时自动对两者应用相同的方向。如果你坚持用 torchvision必须自己写torch.manual_seed()random.random()控制 flip 概率否则数据增强就白做了。4. 训练循环与损失函数cross_entropy 的 hidden trap 和 crop 的必要性FCN 训练中最隐蔽的坑不在模型结构而在 loss 计算和 label 对齐。F.cross_entropy看似简单但它内部做了两件事1对 input 的最后一维做 softmax2用 target 的 class_id 做 one-hot 交叉熵。问题在于当你的 label 图存在未标注区域如全黑背景而你又没在 class_colors 中明确定义背景类target 中就会出现 255 这样的非法值导致 lossnan。此外FCN 的上采样输出比原图大因 padding必须 crop 到原始尺寸否则 loss 计算时 input 和 target 的 H/W 不匹配。4.1 CrossEntropyLoss 的三个必设参数criterion nn.CrossEntropyLoss( ignore_index255, # 忽略 label 中值为 255 的像素常用于未标注区域 reductionmean, # 默认计算 batch 平均 loss weightclass_weights # 可选解决类别不平衡如 background 占 80%person 占 5% )ignore_index255是保命参数。LabelMe 导出的 label 图中未标注区域常为纯黑0,0,0但如果你的 class_colors 把(0,0,0)定义为 background则没问题如果没定义np.unique()会把它当作一个新颜色映射到某个 class_id而该 class_id 在你的n_class范围外导致target中出现 n_class−1 的值cross_entropy报错。更安全的做法是在rgb_to_index_label中把所有未定义颜色统一设为 255并设ignore_index255。weight参数需手动计算class_weights torch.FloatTensor([1.0, 5.0, 8.0])其中 background 权重1.0person5.0因样本少car8.0因更少。权重值不是拍脑袋而是1 / (class_pixel_count / total_pixel_count)的归一化结果。4.2 Forward 后必须 crop为什么 upscore 输出比原图大回顾FCN32s.forward()中的self.upscore(x)输入是[B, n_class, H//32, W//32]kernel64, stride32, padding16。根据转置卷积输出尺寸公式H_out (H_in − 1) × stride − 2 × padding kernel_size代入得H_out (H//32 − 1) × 32 − 32 64 H 32所以输出比原图高 32 像素这就是为什么必须x x[:, :, 19:19h, 19:19w]—— crop 掉边缘 19 像素保留中心h×w区域。这个 19 不是 magic number它是padding100和 VGG 的 5 次 pool 共同决定的偏移量详见第 2 章注释。如果你跳过 cropcriterion(input, target)会报Target size (4, 256, 256) must be the same as input size (4, 21, 288, 288)。4.3 完整训练 loop含梯度裁剪和 cache 清理model FCN32s(n_class3).to(device) criterion nn.CrossEntropyLoss(ignore_index255).to(device) optimizer torch.optim.SGD(model.parameters(), lr1e-3, momentum0.9) for epoch in range(10): model.train() total_loss 0 for i, (images, labels) in enumerate(train_loader): images images.to(device) labels labels.to(device) optimizer.zero_grad() outputs model(images) # shape: [B, 3, H, W] # 关键crop outputs 到 labels 尺寸 _, _, h, w labels.shape outputs outputs[:, :, :h, :w] # 安全 crop比固定 19:19h 更鲁棒 loss criterion(outputs, labels) loss.backward() # 防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm10) optimizer.step() total_loss loss.item() # 清理 GPU cache防止 OOM尤其 batch_size4 时 if i % 10 0: torch.cuda.empty_cache() print(fEpoch {epoch}, Avg Loss: {total_loss / len(train_loader):.4f})提示outputs outputs[:, :, :h, :w]是比固定 crop 更工程化的写法。它不依赖于理论推导的 19而是动态适配当前 batch 中labels的实际尺寸避免因数据集 resize 不一致导致的 crop 错误。这是我在多个项目中验证过的“后悔药”。5. 避坑指南FCN 训练中 4 个血泪经验总结FCN 的坑不在代码有多难而在错误现象和根本原因之间隔着一层薄纱。下面 4 条是我在 7 个实际项目从工业缺陷检测到农业遥感中踩出的高频雷区每一条都附带可复现的现象、定位方法和根治方案。5.1 现象训练 loss 从 3.0 降到 0.8 后停滞验证 mIoU 始终 0.1原因label图中存在未定义颜色如 LabelMe 导出的(128,128,128)灰色边框被rgb_to_index_label映射为某个 class_id如 5但你的n_class3导致target中出现非法值。cross_entropy在计算时会忽略这些像素因ignore_index未设但 loss 仍包含大量合法像素的错误预测模型学不到有效模式。解决用np.unique(label, return_countsTrue)检查label_index.png中所有像素值确认是否只有{0,1,2}若有其他值要么在class_colors中补全定义要么在转换脚本中强制label[label 2] 255并设ignore_index255。5.2 现象预测图是全黑或全白或只有零星几个像素有响应原因ConvTranspose2d的权重初始化不当。PyTorch 默认用kaiming_normal初始化但 FCN 论文要求转置卷积用双线性插值核初始化即kernel[i,j] bilinear(i,j)否则上采样过程引入强噪声。解决在FCN32s.__init__()中为self.upscore添加初始化# 在 self.upscore ... 后添加 nn.init.constant_(self.upscore.weight, 0) # 先清零 # 设置双线性插值核 factor 2 kernel_size int(2 * factor - 1) center factor - 1 og np.ogrid[:kernel_size, :kernel_size] filt (1 - abs(og[0] - center) / factor) * (1 - abs(og[1] - center) / factor) for i in range(n_class): self.upscore.weight.data[i, i] torch.tensor(filt, dtypetorch.float32)5.3 现象训练时 GPU memory steadily increases几轮后 OOM原因torch.cuda.empty_cache()未在每次 iteration 后调用且optimizer.step()前未清理中间变量。PyTorch 的 autograd graph 会累积尤其当outputs是大 tensor 时。解决在loss.backward()后、optimizer.step()前显式删除不再需要的 tensorloss.backward() del outputs, labels, images # 立即释放 torch.cuda.empty_cache() optimizer.step()5.4 现象验证时 predict mask 和原图错位 1~2 像素边缘模糊原因MaxPool2d的ceil_modeFalse默认。当输入尺寸为奇数如 255×255H//2127但实际 pooling 后应为 128向上取整否则下采样-上采样链路尺寸失配。解决检查所有pool层强制ceil_modeTrueself.pool1 nn.MaxPool2d(2, stride2, ceil_modeTrue) # 必须显式写 self.pool2 nn.MaxPool2d(2, stride2, ceil_modeTrue) # ... 其他 pool 层同理这是 FCN 论文原文明确要求的但 PyTorch 文档未强调90% 的开源实现都漏掉。6. 验证与可视化用 Grad-CAM 定位 FCN 的“盲区”而非只看 mIoUmIoU 是标尺但不是诊断工具。当你看到 mIoU0.65 却发现模型总把“椅子腿”识别成“地板”说明它没学会纹理差异而是在拟合统计偏差。此时你需要一个能“看见模型在看什么”的工具——Grad-CAMGradient-weighted Class Activation Mapping。它不适用于 FCN 的逐像素预测但可以作用于score_fr层即 1×1 卷积后的 logits生成每个类别在 feature map 上的响应热力图从而定位模型的注意力盲区。6.1 为 FCN 注入 Grad-CAM 支持hook 机制详解Grad-CAM 需要两个东西1目标层的 feature map2目标类别 logits 对该 feature map 的梯度。我们 hookself.score_fr的输出和输入梯度class FCN32sWithCAM(FCN32s): def __init__(self, n_class21): super().__init__(n_class) self.gradients None self.feature_map None def activations_hook(self, grad): self.gradients grad def forward(self, x): h, w x.shape[2], x.shape[3] x self.relu1_1(self.conv1_1(x)) # ... 中间层省略 ... x self.pool5(x) # Hook feature map self.feature_map x x self.score_fr(x) x.register_hook(self.activations_hook) # 注册梯度 hook x self.upscore(x) x x[:, :, 19:19 h, 19:19 w] return x def get_cam(self, class_idx): # 获取梯度加权平均 pooled_gradients torch.mean(self.gradients, dim[0, 2, 3]) for i in range(self.feature_map.size(1)): self.feature_map[:, i, :, :] * pooled_gradients[i] cam torch.mean(self.feature_map, dim1, keepdimTrue) cam F.relu(cam) cam F.interpolate(cam, size(h, w), modebilinear, align_cornersFalse) return cam.squeeze()6.2 可视化 pipeline从 raw image 到 CAM 热力图叠加def visualize_cam(model, image_path, class_idx1, save_pathcam.jpg): model.eval() img Image.open(image_path).convert(RGB) transform A.Compose([A.Resize(256, 256), A.Normalize(), ToTensorV2()]) tensor_img transform(imagenp.array(img))[image].unsqueeze(0).to(device) with torch.no_grad(): output model(tensor_img) # 触发 forward保存 feature_map 和 gradients cam model.get_cam(class_idx) # 计算 CAM # 将 CAM 归一化到 0-255 cam cam.cpu().numpy() cam np.maximum(cam, 0) cam cam - np.min(cam) cam cam / np.max(cam) cam np.uint8(255 * cam) # 叠加到原图 heatmap cv2.applyColorMap(cam, cv2.COLORMAP_JET) heatmap cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB) overlay 0.4 * heatmap 0.6 * np.array(img.resize((256,256))) Image.fromarray(np.uint8(overlay)).save(save_path) # 使用 model_cam FCN32sWithCAM(n_class3).to(device) model_cam.load_state_dict(torch.load(best_model.pth)) # 加载训练好的权重 visualize_cam(model_cam, test.jpg, class_idx1, save_pathchair_leg_cam.jpg)这张图会告诉你模型在识别“椅子腿”时注意力是否集中在腿的细长轮廓上还是散落在整个椅子区域如果是后者说明pool4或pool3的 skip connection 没起作用或者score_fr的感受野太小——这时你就该去调ConvTranspose2d的kernel_size而不是继续调 learning rate。我坚持在每个 FCN 项目里加这一步不是为了炫技而是因为mIoU 是结果CAM 是病因报告。它让我在 3 小时内定位到“模型根本没学会区分金属和木纹”从而放弃调参转向增加材质相关的数据增强如添加金属反光模拟。这种决策速度是只盯 loss 曲线永远换不来的。希望帮到你。本文还有配套的精品资源点击获取