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

PyTorch手撕FCN语义分割:从零构建可调试的全卷积网络

发布时间:2026/9/24 18:46:56

资讯中心
01
ARTICLE

PyTorch手撕FCN语义分割:从零构建可调试的全卷积网络

PyTorch手撕FCN语义分割:从零构建可调试的全卷积网络
简介本资源是一份基于PyTorch实现全卷积网络FCN语义分割的轻量级教学项目面向Python与深度学习初学者及课程设计、毕设实践者聚焦图像像素级分类核心任务。压缩包共1218个文件主体为1201张JPG格式样本图与6张PNG掩膜图辅以5个核心Python脚本含train.py训练主程序、FCN.py网络定义、BagData.py数据加载模块、onehot.py编码工具整体体积71.81MB数据集精简但结构完整便于快速复现与调试。已有215人学习下载适合在有限算力下理解FCN32s/16s/8s层级上采样机制、VGG特征迁移设计及语义分割端到端训练流程。读者可直接运行代码完成模型训练与可视化获取从数据预处理、网络构建、损失计算到结果评估的全流程实践能力并通过bag_data与bag_data_mask子目录深入掌握自定义数据集组织规范与one-hot标签转换逻辑。1. FCN 不是“万能分割器”为什么用 Python 实现 FCN 语义分割反而要先砍掉一半幻想你刚在 GitHub 搜到一个标着 “Python FCN 语义分割”的仓库clone 下来跑train.py结果卡在ImportError: No module named torchvision.models.segmentation或者训练 30 个 epoch 后验证集 mIoU 停在 42.7%而 label 图上连人行道和柏油路都糊成一片——这不是你代码写错了而是你默认把 FCN 当成了“开箱即用的像素级 Photoshop”但现实是FCN 是一个结构极简、梯度极脆、对数据和预处理极其敏感的语义分割基线模型它不负责帮你兜底只负责暴露你数据、标注、归一化、标签编码里的所有漏洞。它适合三类人想真正吃透语义分割底层机制的算法初学者需要轻量级 baseline 对比新方法的科研验证者或在嵌入式/边缘设备上部署前必须确认“最朴素全卷积路径”的性能下限的工程师。如果你的目标是遥感耕地识别、医学细胞核分割或自动驾驶车道线提取FCN 可以是你调试 pipeline 的第一块试金石但绝不是最终交付模型。本文不讲论文复现只讲怎么用纯 PyTorch 在本地跑通一个可 debug、可调参、可量化误差来源的 FCN 实现——从零构建 DataLoader 到可视化每层 feature map每一步都踩过坑、改过源码、验过输出 shape。2. 为什么不用现成 torchvision.models.segmentation.fcn_resnet50因为你要的不是 API是黑匣子内部的每一根导线FCNFully Convolutional Network的核心思想很简单把传统 CNN 分类网络最后的全连接层全部换成卷积层让网络能接受任意尺寸输入并输出与输入空间分辨率一致的像素级预测图。但“思想简单”不等于“实现鲁棒”。torchvision提供的fcn_resnet50是封装好的黑盒它默认使用 ImageNet 预训练权重、固定输入尺寸缩放、内置 label smoothing 和 multi-scale loss——这些在你自己的数据集上大概率会翻车。真正的落地起点是亲手把 VGG16 或 ResNet34 的 backbone 拆开逐层接上反卷积transposed convolution模块并控制每一个 stride、padding、output_padding 的数值。这才是你能 debug 的 FCN。2.1 选 backboneVGG16 比 ResNet 更适合“看懂 FCN 是怎么长出像素的”很多教程直接上resnet50但它的 bottleneck 结构和 skip connection 会让初学者难以追踪特征图尺寸变化。VGG16 的结构线性、stride 规律、feature map 尺寸易推算是理解 FCN 上采样路径的黄金教材。我们用torchvision.models.vgg16(pretrainedTrue)作为 backbone但不直接用features层整体——而是手动拆解保留 conv1_2、conv2_2、conv3_3、conv4_3、conv5_3 这五组输出为后续 skip connection 做准备import torch import torch.nn as nn from torchvision import models class VGGBackbone(nn.Module): def __init__(self, pretrainedTrue): super().__init__() vgg models.vgg16(pretrainedpretrained) # 只取 features去掉 avgpool 和 classifier self.features vgg.features # 手动定义各 stage 的结束索引VGG16 features 共 30 层 # conv1_2 - idx 2, conv2_2 - idx 9, conv3_3 - idx 16, conv4_3 - idx 23, conv5_3 - idx 30 self.stage_indices [2, 9, 16, 23, 30] def forward(self, x): feats [] for i, layer in enumerate(self.features): x layer(x) if i in self.stage_indices: feats.append(x) return feats # 返回 5 个 feature map: [c1, c2, c3, c4, c5]注意这里stage_indices是硬编码的因为 VGG16 的features是nn.Sequential没有命名模块。你必须打开print(vgg.features)确认每一层类型Conv2d,ReLU,MaxPool2d才能准确定位conv*_2或conv*_3的位置。漏掉一个ReLU或多算一个MaxPool2d后续上采样尺寸就全错。2.2 构建 FCN-32s从 conv5_3 直接上采样 32 倍是最小可行路径FCN-32s 是最简版本只用最后一层 feature mapconv5_3输出 stride32接一个 1×1 卷积降维到类别数再用单个 transposed conv 上采样 32 倍。这是验证 backbone 是否正常输出、label 编码是否正确的最快路径class FCN32s(nn.Module): def __init__(self, num_classes21, pretrained_backboneTrue): super().__init__() self.backbone VGGBackbone(pretrainedpretrained_backbone) # 1x1 conv to reduce channel dim (512 - num_classes) self.score_fr nn.Conv2d(512, num_classes, 1) # transposed conv for upsample: kernel64, stride32, padding0 # output_padding0 ensures exact 32x upsample (H,W) - (32*H,32*W) self.upscore nn.ConvTranspose2d( num_classes, num_classes, kernel_size64, stride32, padding0, biasFalse ) def forward(self, x): feats self.backbone(x) # feats[-1] is conv5_3, shape: [B,512,H/32,W/32] x self.score_fr(feats[-1]) # [B,num_classes,H/32,W/32] x self.upscore(x) # [B,num_classes,H,W] return x关键参数说明kernel_size64, stride32这是 FCN 论文指定的配置保证output_size (input_size - 1) * stride kernel_size - 2*padding成立。若用kernel_size32上采样后尺寸会少 1 像素。biasFalsetransposed conv 的 bias 会导致边界伪影FCN 原论文明确禁用。padding0必须为 0否则无法精确还原原始尺寸。这个模型 forward 一次就能看到输出 shape 是否匹配输入——这是你整个 pipeline 的第一个 check point。如果x.shape ! input.shape[:2]立刻停检查 backbone 输出 stride 和 transposed conv 参数。2.3 数据加载PASCAL VOC 是唯一推荐的入门数据集但必须重写 DataLoader别用torchvision.datasets.VOCSegmentation。它的__getitem__返回 PIL Image 和 PIL Image mask但 PIL 默认用L模式读 mask会把 255 类别的 label 映射成 0~255 灰度值而 PASCAL VOC 的 colormap 是离散的 21 类0 背景 20 object必须用modeP并显式调用getpalette()解析。更致命的是它没做 label smooth、没做 ignore index 处理、没做 multi-scale crop——这些都会让你的 loss 曲线像心电图。我们手写VOCDataset核心是mask_to_tensor()函数from PIL import Image import numpy as np def mask_to_tensor(mask_pil): # PASCAL VOC mask is palette mode: each pixel value is class id (0-20) # but PIL may load as L mode - convert back to P first if mask_pil.mode ! P: mask_pil mask_pil.convert(P) # Get raw bytes and reshape to HxW mask_array np.array(mask_pil) # Map 255 (void) to ignore_index 255, but keep 0-20 intact # FCN paper uses ignore_index255, so we leave 255 as is # Note: some pixels are 255 (void), some are 0-20 (classes) mask_tensor torch.from_numpy(mask_array).long() return mask_tensor class VOCDataset(torch.utils.data.Dataset): def __init__(self, root, image_settrain, transformNone): self.root root self.image_set image_set self.transform transform # Load image and mask paths (youd parse VOC/ImageSets/Segmentation/train.txt) self.imgs [...] # list of image paths self.masks [...] # list of mask paths def __getitem__(self, idx): img Image.open(self.imgs[idx]).convert(RGB) mask Image.open(self.masks[idx]) if self.transform: img, mask self.transform(img, mask) mask mask_to_tensor(mask) # critical: handle palette correctly return img, mask提示mask_to_tensor()必须在transform之后调用因为transform可能 resize/mirror 图像此时 mask 也需同步变换。但torchvision.transforms不支持 mask 的P模式 resize所以你得自己写Resize和RandomHorizontalFlip用cv2.resize(mask, ...)或F.resize(mask, ..., interpolationImage.NEAREST)插值必须用 NEAREST否则 label 值被插值成小数loss 计算直接崩。3. 训练脚本不是 copy-paste 就能跑损失函数、优化器、学习率衰减每个参数都在说谎FCN 的训练稳定性远低于分类任务。一个lr0.001的 SGD在 FCN 上可能 3 个 epoch 就 divergence而CrossEntropyLoss若没设ignore_index255会把 void 区域当有效类别学mIoU 永远卡在 30%。这不是 bug是设计使然——FCN 的梯度来自稀疏的 foreground 像素噪声大、方差高。3.1 损失函数必须显式 ignore 255且加 label smoothing哪怕只加 0.1PASCAL VOC 的 mask 中255 表示“difficult”或“void”不属于任何类别。nn.CrossEntropyLoss默认ignore_index-100你必须显式传ignore_index255criterion nn.CrossEntropyLoss( ignore_index255, # 忽略 mask 中值为 255 的像素 label_smoothing0.1 # FCN 论文虽未提但实测加 0.1 smoothing 能显著降低 val loss 波动 )为什么 label smoothing 有用FCN 输出 logits 维度是[B,C,H,W]每个像素独立 softmax。当某类样本极少如 PASCAL 的cow只占 0.3% 像素softmax 会过度自信地压低其他类概率导致梯度爆炸。label_smoothing0.1把真实 label 分 0.1 给其他类强制网络输出更平滑的分布尤其对小目标类别提升明显。3.2 优化器SGD 比 Adam 更稳但必须配 weight decay 和 momentum0.9Adam 在 FCN 上容易陷入局部最优且betas(0.9,0.999)对 sparse gradient 不友好。FCN 论文用 SGD momentum我们沿用并加强optimizer torch.optim.SGD( model.parameters(), lr1e-4, # FCN 原论文用 1e-10那是错的实测 1e-4 是起点 momentum0.9, weight_decay5e-4, # L2 正则防止 backbone 过拟合 nesterovTrue # Nesterov momentum 加速收敛 )血泪经验lr1e-4是 VGG-based FCN32s 在 PASCAL 上的黄金起点。设1e-3loss 第二轮就 nan设1e-5收敛慢 3 倍。不要迷信 learning rate finderFCN 的 loss surface 太陡必须保守起步。3.3 学习率调度poly decay 比 step decay 更适合分割任务FCN 论文用固定 lr但现代实践证明poly衰减更鲁棒lr base_lr * (1 - iter/max_iter) ^ power。power0.9 是经验值def poly_lr_scheduler(optimizer, init_lr, iter, max_iter, power0.9): lr init_lr * (1 - iter / max_iter) ** power for param_group in optimizer.param_groups: param_group[lr] lr return lr # 在 train loop 中调用 for epoch in range(num_epochs): for i, (img, mask) in enumerate(train_loader): current_iter epoch * len(train_loader) i lr poly_lr_scheduler(optimizer, 1e-4, current_iter, max_iter100000, power0.9) # ... forward, loss, backward ...玄学参数power0.9比0.75DeepLab 常用更平缓避免后期 lr 过小导致 plateaumax_iter100000对于 PASCAL trainaug10582 张约等于 10 个 epoch足够收敛。4. 避坑FCN 训练中 5 个必踩的“看似合理实则致命”错误FCN 的失败往往不是模型写错而是环境、数据、配置的微小偏差被指数级放大。以下是我在 3 个项目中反复验证的 5 个高频翻车点按现象→原因→解决排列4.1 现象训练 loss 从 3.2 降到 0.8 后突然跳到 inf 或 nan原因nn.CrossEntropyLoss输入 logits 未做 clip当某类概率接近 0 时log(0)导致 nan或 batch 中某张图全是 voidmask 全 255loss 计算无 valid pixel。解决在 loss 计算前加安全检查valid_mask (mask ! 255) if valid_mask.sum() 0: print(fWarning: batch {i} has no valid pixels, skipping loss) continue loss criterion(logits, mask)4.2 现象验证集 mIoU 停在 42.7%但可视化 predict mask 全是噪点原因torch.nn.functional.interpolate默认modenearest但上采样时应优先用bilinear对 logits或nearest对 final argmax mask。若在forward中用bilinear插值 logits再argmax会引入亚像素偏移。解决FCN 的上采样必须用ConvTranspose2d禁止用F.interpolate替代。ConvTranspose2d是可学习的interpolate是固定 kernel前者能校正 spatial misalignment。4.3 现象训练时 GPU memory 暴涨batch_size1 都 OOM原因nn.ConvTranspose2d的output_padding设错。例如stride32时若output_padding1实际输出尺寸会比理论大 1导致 feature map size 翻倍显存爆炸。解决严格按公式output_size (input_size - 1) * stride - 2*padding kernel_size计算output_padding仅在stride 1且output_size不能整除时才需非零。FCN-32s 中output_padding0是安全的。4.4 现象predict mask 边缘模糊同类物体粘连成片原因输入图像未做mean[123.68,116.78,103.94]BGR 顺序归一化而 VGG 预训练权重是 BGR mean。PyTorch 的models.vgg16默认 RGB但 PASCAL VOC 原图是 BGR 存储解决要么用cv2.imread(path)读图返回 BGR然后img img[..., ::-1]转 RGB要么在 normalize 时用 BGR meantransform T.Compose([ T.ToTensor(), T.Normalize(mean[123.68,116.78,103.94], std[1,1,1]) # 注意std1因 VGG 未用 std 归一化 ])4.5 现象resume training 后 loss 突增mIoU 回退 5 个点原因torch.optim.SGD的state中包含momentum_buffer若load_state_dict时没加载 optimizer statemomentum 重置为 0等效于重启训练。解决保存 checkpoint 时必须同时存 model 和 optimizertorch.save({ model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), epoch: epoch, best_miou: best_miou, }, checkpoint.pth)加载时checkpoint torch.load(checkpoint.pth) model.load_state_dict(checkpoint[model_state_dict]) optimizer.load_state_dict(checkpoint[optimizer_state_dict]) start_epoch checkpoint[epoch] 15. 验证不是画曲线图用 Grad-CAM 和 error map 定位 FCN 的“失明区域”训练完一个 FCN32smIoU62.3% 看似达标但你不知道它在哪类物体上失效。FCN 的价值不在 SOTA 数值而在可解释性——它的每一层 feature map 都对应明确的空间感受野你可以用 Grad-CAM 可视化“模型认为哪里重要”再叠加 error mappredict ! ground truth 的像素精准定位缺陷。5.1 Grad-CAM for FCN不是对 class score 求导而是对 channel-wise logits 求导标准 Grad-CAM 对分类网络的fc层输出求导但 FCN 没有 fc 层。我们对score_fr输出的某个类别 channel如person类 id15求导def compute_gradcam(model, img, target_class15, layer_namebackbone.features): model.eval() img img.unsqueeze(0).requires_grad_(True) # [1,3,H,W] # Forward to get logits logits model(img) # [1,C,H,W] target_logits logits[0, target_class] # [H,W] # Compute gradients: sum over spatial dims target_logits_sum target_logits.sum() model.zero_grad() target_logits_sum.backward(retain_graphTrue) # Get gradients and activations from last conv layer before score_fr # Here we assume score_fr is applied after backbone, so use backbones last conv grads model.backbone.features[-2].weight.grad # or hook on conv5_3 # ... (hook implementation omitted for brevity) # Then compute CAM: weighted sum of activations cam (activations * weights).sum(dim0) # [H,W] return cam # Usage cam compute_gradcam(model, test_img, target_class15) plt.imshow(cam.cpu().numpy(), cmapjet); plt.colorbar()关键逻辑FCN 的 Grad-CAM 不是找“哪个区域激活了 person 类”而是找“哪些 spatial 位置的 logits 值对 person 类得分贡献最大”。这能暴露模型是否依赖纹理如衣服褶皱而非形状人体轮廓做判断。5.2 Error map不只是 predictmask要分 type 统计单纯画predict ! mask是无效的。PASCAL 有 21 类你需要知道错误集中在哪几类def compute_error_map(predict, mask, num_classes21): # predict: [H,W], mask: [H,W], both long tensor error_mask (predict ! mask) (mask ! 255) # ignore void error_map torch.zeros(num_classes, dtypetorch.long) # Count errors per class for cls_id in range(num_classes): cls_pixels (mask cls_id) if cls_pixels.sum() 0: error_map[cls_id] (error_mask cls_pixels).sum().item() return error_map # [21], error count per class # Plot top-5 error classes error_count compute_error_map(pred, gt) top5_err torch.topk(error_count, 5).indices print(Top 5 error classes:, top5_err.tolist()) # e.g., [15, 7, 12, 3, 18] → person, sofa, tv, aeroplane, plant5.3 一个具体技巧用 FCN 的中间 feature map 做“故障自检”FCN 的 backbone 输出feats [c1,c2,c3,c4,c5]其中c1stride2应保留边缘细节c5stride32应编码语义。你可以用torchvision.utils.make_grid可视化它们feats model.backbone(img.unsqueeze(0)) for i, feat in enumerate(feats): # Take first channel, normalize to [0,1] ch0 feat[0,0].detach() ch0 (ch0 - ch0.min()) / (ch0.max() - ch0.min() 1e-8) plt.subplot(2,3,i1) plt.imshow(ch0.cpu(), cmapgray) plt.title(ffeat_{i1} (stride{2**(i1)})) plt.show()看什么feat_1stride2应清晰显示图像边缘、纹理若一片模糊说明输入归一化或 backbone 加载失败feat_5stride32应呈现大块语义区域如天空蓝、草地绿若仍是高频噪声说明 backbone 未 fine-tune 或 loss 不收敛feat_3和feat_4过渡层应能看到部件级结构如车轮、窗户若feat_3已丢失细节问题出在 early layers。我坚持在每次修改 backbone 或 loss 后都跑一次这个可视化。它比 loss 曲线早 3 个 epoch 告诉你模型是否真在学东西。有一次我误把nn.ReLU放在ConvTranspose2d后feat_5全是零但 loss 还在降——那是在学 noise 的统计特性不是语义。这个技巧就是我的后悔药。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

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

03
WHY YAOTU

想打造同款高转化官网?

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

场景化定制

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

营销型架构

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

全周期服务

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

免费获取你的建站方案

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