尧图精选

FCN语义分割实战:全卷积化、上采样与跳跃连接解析

🕒 发布时间:2026/10/1 8:28:10 📁 来源:尧图网络
简介面向图像分割入门者与进阶开发者的FCN实战资源聚焦全卷积网络在像素级语义分割中的应用。FCN与经典CNN不同支持任意尺寸的输入图像通过反卷积层将最后一个卷积层的特征图上采样到原始尺寸在保留空间信息的同时对每个像素进行分类内容较好地展现了这一从原理到工程实践的完整流程。包内共有约2000个文件压缩包大小约335MB内容以Python脚本、JSON配置、图像及XML标注数据为主涵盖数据准备、训练验证、预测推理等模块同时包含调色板配置、数据集构建和分布式训练辅助工具便于直接调试、替换数据或迁移到其他分割任务。借助示例代码与清晰的目录结构读者可以快速搭建语义分割实验环境理解从图像输入到像素级输出、再到可视化分割结果的具体实现路径也适合作为课程设计或毕业设计的代码基础。目前已有1532人学习下载特别适合希望将FCN落地到实际项目或准备相关课程设计的开发者参考。1. FCN 到底在改什么语义分割从分类网络跨出的第一步很多人第一次接触 FCN全卷积网络是被卷积网络也能做像素级预测这句话吸引的。传统分类网络最后接全连接层输出的是一个类别概率而语义分割要求给每一个像素打标签输出和输入同分辨率的类别图。FCN 的做法很直接把全连接层全部替换成卷积层再用上采样把特征图恢复到原图尺寸。2015 年这篇论文把 VGG 改造成端到端可训练的分割模型后续的 U-Net、DeepLab 在结构上都继承了它编码-解码、跳跃连接的骨架。读这篇笔记的人通常是手里拿着一个 FCN 代码包想跑通训练、理解核心参数、最后能在自己的数据集上复现语义分割效果。你会学到全卷积化的原理、上采样方式的选择、跳跃结构的精度阶梯以及一套从 VOC 数据到自建标注数据都能用的落地流程。适合想弄懂分割模型内部机制的新手也适合调参遇到瓶颈、想排查踩坑点的工程师。2. FCN 的核心设计全卷积化、上采样与跳跃结构怎么配合2.1 全卷积化为什么必须丢掉全连接层FCN 的前身是 VGG 16那是标准的分类网络。VGG 的尾部结构是卷积层输出 7x7x512 的特征图展平后接两个 4096 维的全连接层最后接 1000 类 softmax。全连接层一旦存在输入尺寸就被锁死更重要的是展平操作把空间位置信息全部打散每个像素从哪里来、和邻近像素是什么关系网络不再关心。FCN 的改造方式是卷积化把第一个全连接层看成 7x7 卷积第二个全连接层看成 1x1 卷积最终输出通道数变成类别数。这样网络任何尺寸的图都能进输出是一个 H/32 x W/32 x C 的概率图保留了空间结构。这里的 C 是类别数比如 VOC 数据是 21 类含背景Cityscapes 是 19 类。理解这一步有两个关键点。第一感受野不变全连接层换成卷积后每个输出位置仍然对应原图较大区域的信息这保证了看全局的能力。第二特征图分辨率逐级减半末端只有输入的 1/32所以必须搭配上采样恢复分辨率。很多人误以为全卷积化就是把全连接删掉其实语义分割的精度很大程度取决于怎么把细节找回来这正是跳跃结构要解决的。2.2 上采样方式转置卷积与双线性插值的选型逻辑FCN 原文里上采样用的是转置卷积transposed convolution也叫反卷积。它把低分辨率特征图放大到指定尺寸权重是可学习的。初始权重一般用双线性插值核初始化训练过程中网络会微调这些权重让上采样更好地适配具体类别分布。实现时要注意 stride 和 kernel_size 的配合比如 stride2、kernel_size4 的转置卷积输出刚好放大两倍且不产生棋盘格效应。转置卷积的优点是能学缺点是如果初始化不好或训练数据少容易在边缘产生波纹伪影。很多复现代码默认用双线性插值上采样再用 1x1 卷积调整通道效果更稳。我的建议是小数据集、自建标注数据优先用双线性插值数据量大且追求极限精度再试转置卷积。两种方式在 PyTorch 里分别是nn.Upsample(scale_factor2, modebilinear, align_cornersTrue)和nn.ConvTranspose2d(512, 256, kernel_size4, stride2, padding1)。有一个参数最容易翻车align_corners。FCN 里上采样要和前面特征图相加坐标对齐必须一致。如果一处设 True 一处设 False叠加后特征偏移几个像素小物体的分割边界会整体错位。全代码统一用align_cornersTrue配合双线性模式是目前社区最常见的做法。2.3 跳跃结构FCN-32s、FCN-16s、FCN-8s 的精度阶梯全卷积化之后FCN 作者发现直接把 1/32 特征图放大 32 倍预测结果太粗糙边缘像马赛克。原因很好理解经过五次池化小物体的信息早就被丢了靠上采样也变不回来。于是引入跳跃连接把浅层高分辨率的特征图融合进来。原文给出三个版本FCN-32s单一 1/32 特征图直接 32 倍上采样最快但最糙。FCN-16s把 1/32 特征图 2 倍上采样与 pool4 的 1/16 特征图相加再 16 倍上采样。FCN-8s在 FCN-16s 基础上把 1/16 特征图 2 倍上采样与 pool3 的 1/8 特征图相加再 8 倍上采样。这里的加法是逐元素相加不是拼接。相加前浅层特征会先经过一个 1x1 卷积降维把通道数压到和类别数一致。融合后语义信息来自深层细节边缘来自浅层这正是 FCN-8s 效果最好的原因。实操中FCN-8s 是复现的默认选择。结构上注意每一条跳跃路径的上采样倍数pool5 的 score 要 2 倍上采样、与 pool4 融合后再 2 倍上采样、与 pool3 融合后再 8 倍上采样。很多人写代码时图省事直接把最终融合结果 8 倍上采样忽略了前两步的逐步融合这样实际得到的是伪 8s精度会掉一截。3. 用 FCN 跑通语义分割环境配置、VOC 数据准备与训练推理3.1 环境配置与代码目录结构开始训练前先把环境装好。常见组合是 Python 3.8、PyTorch 1.8、torchvision 配套版本、CUDA 11.x。显存建议 8GB 以上batch_size 可以小一点。安装命令如下conda create -n fcn python3.8 conda activate fcn pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install opencv-python pillow matplotlib tqdm numpyPyTorch 对应的 torchvision 会自动装好不需要手动指定版本。这里的 CUDA 版本要和本机驱动匹配驱动支持的话 cu118 比较通用。如果你用的是新版本 PyTorch2.x命令里的--index-url后缀需要换成对应 cu121 或 cu124否则装完可能无法调用 GPU。代码目录我习惯这样组织fcn/ ├── data/ │ └── VOCdevkit/ ├── datasets/ │ └── voc.py ├── models/ │ └── fcn8s.py ├── train.py ├── predict.py └── utils/ └── metrics.pydata放原始数据集datasets放数据集加载器models放网络结构train.py和predict.py是入口。这样区分的好处是换数据集时只改datasets和data模型和训练脚本可以复用。3.2 VOC 数据准备下载、目录与标签读取VOC 2012 是语义分割最常用的基准数据集。下载和整理如下cd data wget https://pjreddie.com/media/files/VOCtrainval_11-May-2012.tar tar xf VOCtrainval_11-May-2012.tar解压后得到VOCdevkit/VOC2012里面关键的三个子目录JPEGImages存放原图SegmentationClass存放语义分割标签ImageSets/Segmentation存放 train.txt、val.txt。标签是 PNG 格式单通道像素值就是类别编号例如 0 是背景、1 是飞机、2 是自行车15 是 person。读取标签时有个常见坑不要用 OpenCV 的cv2.imread它会默认按三通道 BGR 读入把类别编号变成彩色值。正确做法是用 PILfrom PIL import Image import numpy as np label np.array(Image.open(SegmentationClass/2007_000032.png)) print(np.unique(label)) # 应该看到 0~20 之间的整数255 表示 ignoreVOC 标签里 255 是边界和难标注区域训练时要把 255 排除在损失之外PyTorch 的CrossEntropyLoss直接支持ignore_index255不用手动 mask。3.3 训练脚本FCN-8s 结构、损失函数与关键超参数FCN-8s 的网络结构可以借助 torchvision 的 VGG 实现来搭建import torch import torch.nn as nn import torchvision.models as models class FCN8s(nn.Module): def __init__(self, num_classes21): super().__init__() vgg models.vgg16(pretrainedTrue) features list(vgg.features.children()) # VGG 的 5 个 stage self.stage1 nn.Sequential(*features[0:4]) # 1/2 self.stage2 nn.Sequential(*features[4:9]) # 1/4 self.stage3 nn.Sequential(*features[9:16]) # 1/8 self.stage4 nn.Sequential(*features[16:23]) # 1/16 self.stage5 nn.Sequential(*features[23:30]) # 1/32 # score 层把通道数变成类别数 self.score_pool5 nn.Conv2d(512, num_classes, kernel_size1) self.score_pool4 nn.Conv2d(512, num_classes, kernel_size1) self.score_pool3 nn.Conv2d(256, num_classes, kernel_size1) # 上采样 self.upsample2x nn.Upsample(scale_factor2, modebilinear, align_cornersTrue) self.upsample8x nn.Upsample(scale_factor8, modebilinear, align_cornersTrue) def forward(self, x): # 逐 stage 提取特征 x1 self.stage1(x) x2 self.stage2(x1) x3 self.stage3(x2) x4 self.stage4(x3) x5 self.stage5(x4) # FCN-8s 的三段融合 score5 self.score_pool5(x5) # 1/32 up5 self.upsample2x(score5) # - 1/16 score4 self.score_pool4(x4) # 1/16 fuse4 up5 score4 # 1/16 up4 self.upsample2x(fuse4) # - 1/8 score3 self.score_pool3(x3) # 1/8 fuse3 up4 score3 # 1/8 return self.upsample8x(fuse3) # - 原图尺寸这里的vgg.features只包含卷积和池化层不包含全连接所以直接切分即可。pretrainedTrue会下载 ImageNet 预训练权重这是小数据集训练的保命手段一定要保留。新版本 torchvision 里pretrained参数标记为弃用可以用models.vgg16(weightsmodels.VGG16_Weights.IMAGENET1K_V1)等价替代。训练主循环from torch.utils.data import DataLoader from datasets.voc import VOCSegmentation def train(): model FCN8s(num_classes21).cuda() # 只微调新加的层原 VGG 层用较小学习率 params [ {params: model.stage1.parameters(), lr: 1e-5}, {params: model.stage2.parameters(), lr: 1e-5}, {params: model.stage3.parameters(), lr: 1e-5}, {params: model.stage4.parameters(), lr: 1e-5}, {params: model.stage5.parameters(), lr: 1e-5}, {params: [ *model.score_pool5.parameters(), *model.score_pool4.parameters(), *model.score_pool3.parameters()], lr: 1e-3}, ] optimizer torch.optim.Adam(params) criterion nn.CrossEntropyLoss(ignore_index255) train_loader DataLoader( VOCSegmentation(data/VOCdevkit/VOC2012, train), batch_size8, shuffleTrue, num_workers4, pin_memoryTrue) model.train() for epoch in range(50): total_loss 0 for images, labels in train_loader: images, labels images.cuda(), labels.long().cuda() preds model(images) # [B, 21, H, W] loss criterion(preds, labels) # 标签是 [B, H, W] optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() print(fepoch {epoch} loss {total_loss / len(train_loader):.4f}) torch.save(model.state_dict(), fcheckpoints/fcn8s_{epoch}.pth)关键参数说明骨干网络学习率 1e-5score 层 1e-3这样预训练特征不会被破坏新增的分类头学得快。批次大小按显存调整8GB 显存建议 batch_size 4图片裁剪到 320x320如果 OOM优先减小输入尺寸而不是减 batch。损失函数用CrossEntropyLoss加ignore_index类别不平衡问题先不处理后面单独说。3.4 推理脚本单张图的预测与可视化推理流程和训练略有差异要恢复原始尺寸并把类别索引映射成彩色图。from PIL import Image import numpy as np import torch from models.fcn8s import FCN8s def predict(model_path, img_path, num_classes21): model FCN8s(num_classes21) model.load_state_dict(torch.load(model_path)) model.cuda().eval() # 预处理VGG 要求减均值除以方差 img Image.open(img_path).convert(RGB) w, h img.size img_resized img.resize((320, 320), Image.BILINEAR) x np.array(img_resized).astype(np.float32) / 255.0 mean np.array([0.485, 0.456, 0.406]) std np.array([0.229, 0.224, 0.225]) x (x - mean) / std x torch.from_numpy(x.transpose(2, 0, 1)).unsqueeze(0).cuda() with torch.no_grad(): pred model(x) # [1, 21, 320, 320] pred pred.argmax(dim1).squeeze(0) # [320, 320] pred pred.cpu().numpy().astype(np.uint8) pred_img Image.fromarray(pred, P) # 调色板模式 # VOC 调色板类别编号 - 明显颜色 palette np.zeros((256, 3), dtypenp.uint8) for i in range(num_classes): palette[i] (i * 20 % 255, i * 60 % 255, i * 120 % 255) pred_img.putpalette(palette.flatten().tolist()) pred_img pred_img.resize((w, h), Image.NEAREST) pred_img.save(pred_mask.png) print(saved pred_mask.png)预测后处理有两个关键点argmax(dim1)在类别维度上取最大得到每个像素的类别编号还原尺寸时用Image.NEAREST而不是双线性因为标签是离散值插值会引入一些伪类别。保存成 P 模式加调色板是为了后续可以直接用可视化工具查看也方便计算 mIoU。4. 语义分割数据集制作从 labelme 标注到 VOC 格式的转换链路4.1 标注规范类别编号、边缘与缝隙的统一约定训练脚本写好后最消耗精力的不是改模型而是准备自己的数据集。语义分割数据集制作的核心流程是收集图片 - 用标注工具画多边形 - 转成单通道类别图 - 划分训练验证集。标注工具推荐 labelme安装命令pip install labelme启动后直接labelme进入界面。标注时一个对象画一个多边形标签名写成类别名称比如 car、person、background。这里有一条重要的约定背景也要画出来不一定。VOC 格式里类别 0 默认是背景如果你只标注目标物体背景就是 0不需要专门画。实际操作中的三个规范标注缝隙控制在 1-2 像素。多边形边缘对不齐会产生大量 255 边界影响损失计算。做语义分割不要标框住目标但不贴合边缘的多边形边缘多留背景会让模型在边界处学出错误概率。类别名统一用小写英文字母避免转换脚本里出现字符串匹配错误。如果你标注的是大尺寸遥感图、医学切片建议先裁剪成 512x512 的瓦片再标注否则一张大图的标注文件在训练时被缩到 320x320很多细节遮挡会丢失小物体直接被抹掉。4.2 转换脚本labelme JSON 转单通道 PNGlabelme 的导出功能默认生成的是彩色 PNG每种类别用不同颜色渲染这对人眼友好但训练需要的是像素值为类别编号的单通道图。转换脚本如下import json import numpy as np import os from PIL import Image, ImageDraw from labelme import utils BASE_DIR annotations OUTPUT_DIR segmentation_label CLASS_MAP {background: 0, car: 1, person: 2, road: 3} def convert_one(json_path, output_path): with open(json_path, r, encodingutf-8) as f: data json.load(f) img_height data[imageHeight] img_width data[imageWidth] label_map np.zeros((img_height, img_width), dtypenp.uint8) # 按类别描多边形类别编号写入对应区域 for shape in data[shapes]: label_name shape[label] class_id CLASS_MAP.get(label_name) if class_id is None: continue points [tuple(p) for p in shape[points]] mask Image.new(L, (img_width, img_height), 0) ImageDraw.Draw(mask).polygon(points, outline1, fill1) label_map[np.array(mask) 0] class_id # 转成 P 模式 PNG像素值就是类别编号 result Image.fromarray(label_map, P) result.save(output_path) print(fsaved {output_path} with {np.unique(label_map)}) if __name__ __main__: for fname in os.listdir(BASE_DIR): if fname.endswith(.json): convert_one( os.path.join(BASE_DIR, fname), os.path.join(OUTPUT_DIR, fname.replace(.json, .png)))转换完必须做一次结果验证回到 labelme 可视化工具里对比原图和标签图再统计每个类别像素数。常见问题是多边形边缘因为ImageDraw.polygon的整数取整出现 1 像素错位肉眼在放大视图下可以看到边缘凸出或凹陷。这不是致命问题但如果多条边都有 2 像素以上的偏差训练出来的分割边缘会整体内缩或外扩验证时明显看到 shift。验证代码可以统计每类像素数检查是否出现某一类为 0 的情况from PIL import Image import numpy as np label np.array(Image.open(segmentation_label/xxx.png)) unique, counts np.unique(label, return_countsTrue) for cls, cnt in zip(unique, counts): print(fclass {cls}: {cnt} pixels)如果某一类占比极少比如前景 car 只占整张图的 0.5%又不做类别权重训练时它的损失会被背景完全淹没结果就是模型永远预测背景。这种情况要么先按类别数量过滤掉某些图片要么引入带权重的损失函数。5. FCN 实战避坑五类高频问题与排查记录5.1 现象loss 不降或震荡剧烈训练跑到第 5 个 epochloss 还在 2.7 左右上下抖动到了 20 个 epoch 才勉强到 1.0甚至反复横跳。原因通常是学习率偏大 类别不平衡共同作用。VGG 预训练的部分还好但随机初始化的 score 层在前几个 batch 会产生很大的梯度Adam 的默认学习率下会震荡。解决方法是把 score 层初始化为较小标准差比如nn.init.normal_(module.weight, std0.01)或者更简单先单独训练 score 层 1-2 个 epoch冻结骨干网络让分类头稳定下来。另一个有效操作是加学习率预热前 5 个 epoch 从 1e-4 线性涨到设定值。观察 loss 曲线时别只看平均值记录每个 batch 的 loss如果单个 batch 偶尔出现 5.0 以上的尖峰说明输入里出现了异常标签比如某张图的 mask 全部是 255。5.2 现象预测结果全黑或全背景模型训练完预测出来的 mask 整个是黑的或者大部分区域是背景类。逐像素检查 argmax 的输出全部指向 0。最常见原因是背景像素占绝大比例模型发现全部预测成背景也能拿到很低的损失根本不需要区分前景。另一个隐蔽原因是CrossEntropyLoss默认给每个类别相同权重但只要数据集中 background 占 90% 以上网络就会倾向保守策略。解决方法是给每个类别配置权重前景类权重提高 3-5 倍背景类降低。PyTorch 写法class_weights torch.tensor([0.2] [3.0] * 20).cuda() criterion nn.CrossEntropyLoss(weightclass_weights, ignore_index255)另一个排查点检查训练时是否用了labels.long()CrossEntropyLoss 要求标签是 LongTensor如果你把标签读成 FloatTensor损失计算内部会报错或得到错误梯度表现就是模型学不到东西。5.3 现象标签图读出来全是 255 或颜色怪异用 OpenCV 读取 VOC 标签 PNGnp.unique打印出一堆 255 和零散的 0或者用 matplotlib imshow 看到的是彩色乱码。这是因为 OpenCV 默认按三通道读入 PNG单通道索引图被复制到三通道原本 1 变成 (1,0,0)16 变成 (16,0,0) 这类奇怪值而且超过 255 的编号直接越界。正确读法是用 PIL 的Image.open并转为 numpy 数组PNG 的 P 模式会保留原始索引。如果你的数据管线已经用了 cv2 读取需要补一步转换先把三通道重新映射回单通道但前提是你保留过调色板。我建议从一开始就统一用 PIL 读标签不要混用。5.4 现象分割边缘锯齿严重细长物体断裂FCN-8s 对边缘的恢复能力其实有限池化层带来的空间信息丢失不是一句跳跃连接就能完全补回来。如果验证集上边缘锯齿明显、细长物体出现断裂先检查两点输入分辨率是否太低以及上采样align_corners是否统一。320x320 的输入对 VOC 这类目标偏大的数据够用但自建数据里如果有细长结构道路、血管、裂缝建议把输入调到 512 或 640。分辨率提升后显存不够就把 batch_size 降到 2配梯度累积。第二个检查点是所有上采样层的align_corners必须一致否则不同尺度特征在逐元素相加时错位边缘直接出现重影。这些确认完仍然锯齿明显再考虑用 CRF 后处理方法见下一章。5.5 现象小目标漏检只能标出大目标这是分割任务的通病FCN 因为频繁上下采样会更明显。小目标在第四个、第五个池化层里只剩下几个像素跳跃结构加入 pool3 虽然缓解了一部分但 pool3 的语义信息比较弱小目标的判别力不够。常用的补救手段是类别级 Dice Loss 或 Focal Loss 替换 CrossEntropy。Focal Loss 对难分类小目标的梯度贡献更大公式实现并不复杂或者更省事的方式是数据层面做类别均衡采样每个 batch 里保证包含至少 20% 的小目标样本从训练集中按类别占比加权采样。class WeightedRandomSampler 是 PyTorch 内置方案 sampler torch.utils.data.WeightedRandomSampler(weights, num_samples, replacementTrue)这里的weights是每个样本的权重小目标占比小的图片权重调高这样每个 epoch 能更多地抽到含小目标的图。试下来这个方案的提升往往比改损失函数更直接。6. 让 FCN 结果更可信mIoU 评估、CRF 后处理与二阶段调优技巧6.1 用 mIoU 而不是准确率评估模型像素准确率在类别不平衡时会骗人背景占 90%、整个图全预测成背景也有 90% 准确率。语义分割的公认指标是 mIoU先算每个类别的 IoU两集合交集除以并集再对所有类别取平均。计算时注意 255 要排除。def compute_miou(pred, label, num_classes21): pred pred.flatten() label label.flatten() # 排除 ignore 和边界像素 valid label ! 255 pred, label pred[valid], label[valid] ious [] for cls in range(num_classes): pred_cls pred cls label_cls label cls inter (pred_cls label_cls).sum() union (pred_cls | label_cls).sum() if union 0: ious.append(inter.item() / union.item()) return float(np.mean(ious))我现在的习惯是每次训练结束都打印每个类别的 IoU 明细而不是只看平均。某个类别 IoU 明显低就去翻它的训练样本量和预测 mask找到是样本少还是特征难区分。6.2 条件随机场CRF后处理什么时候值得加CRF 是 FCN 时代最经典的后处理它的思路是像素 i 的类别不仅取决于网络输出还取决于原图颜色和邻近像素的类别一致性。颜色相近的区域更容易同属一类边缘处颜色突变的地方作为分割边界。效果是边缘更干净、小噪点消失。实践上我用 pydensecrf 库参数主要调sxy空间因子和wrgb颜色因子常见初始化是sxy5, wrgb5到sxy10, wrgb10。但注意 CRF 不是免费的推理时间每张图要 1-5 秒且它是独立于训练的后处理不会反过来改善模型本身。如果你的部署对延迟敏感或者用 DeepLab 这类架构CRF 带来的提升很小可以直接不做。6.3 进阶类别权重、多尺度预测与预训练权重迁移如果你想让 FCN 在小数据集上再榨出几个点的 mIoU三个方向按性价比排序第一是类别权重上一章已经讲过第二是多尺度预测推理时把输入缩放成 0.75x、1x、1.25x分别预测后取每个像素概率的最大值或平均值第三是把预训练模型换成 ImageNet 上更强的 ResNet 骨干FCN 的框架不依赖 VGG换成 ResNet 后精度通常更高。最后我的个人习惯是任何一个分割项目跑通后先存一张验证集的可视化图叠加在原图上查看边缘细节再跑 mIoU 指标。指标能看出整体水平可视化能看出错在哪里。边缘锯齿、小目标丢失这类问题指标只会给你一个分数真正定位问题点还是靠翻预测图。FCN 虽然已经是七年前的结构但把这套改网络、调数据、看可视化、后处理的流程练熟换到任何现代分割模型都是直接平移的经验。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →