简介:一套基于Python与PyTorch实现的FCN语义分割完整工程,适合具备初步Python基础、希望快速上手深度学习语义分割的开发者,也可用于毕设、课程设计或工程实训。项目按论文复现了FCN32s、FCN16s、FCN8s与FCNs四种网络结构,并附带以随机背景上的包为对象的小型数据集,整体数据不足80MB,便于在普通电脑上完成训练。资源共1218个文件,以1201张jpg图片为主,另含6张png标签图、5个Python脚本及少量配置说明,压缩包约71.8MB。脚本分工清晰:FCN.py定义VGG骨干网络与FCN变体,train.py负责训练与结果可视化,BagData.py实现数据集加载与预处理,onehot.py完成标注的onehot编码。读者可按模块阅读,快速理解反卷积上采样、跳跃结构等核心知识点。该资源已有215人学习,适合作为语义分割入门实践或后续算法改造的起点。
1. 什么是FCN语义分割:一张图预测出每个像素的类别
基于 python+FCN实现语义分割,这件事的核心不是“把图里有什么框出来”,而是精确到像素级,告诉程序“这一个是猫、那一个是背景,旁边那根是树干”。FCN(Fully Convolutional Network)是深度学习领域第一个把“分类网络”改造成“逐像素预测网络”的经典方案,2015年提出的结构到今天仍然是理解语义分割算法最好的起点。它解决的是图像分割最基础的问题——一张任意分辨率的图输入,输出同样尺寸的密集标签图。适合正在入门视觉方向、需要把分割跑通并且想知道每一步在干什么的人。
FCN的核心贡献在于:把VGG、ResNet这类分类网络的最后全连接层全部替换成卷积层,再通过上采样把特征图恢复到原图尺寸。这个改动看起来简单,却绕开了“固定输入尺寸”和“特征图缩小后回不去”两个致命限制。下面我按自己实际跑项目的路径,把环境、数据、模型、训练、避坑一次说清楚。
2. 搭建FCN语义分割环境:Python版本选型与数据集预处理
2.1 Python环境配置:装对版本比什么都重要
做FCN语义分割,Python 3.8到3.11都兼容,但我一般建议直接用Python 3.10。原因很现实:PyTorch从1.13到2.x对3.10支持最稳定,装torchvision不容易遇上编译冲突;同时很多语义分割数据集处理库(比如albumentations)在3.10上的wheel包最全。
创建独立环境这一步,新手最容易在这儿翻车。不要直接往系统Python里装库,后面一升级库依赖就互相打架。常见做法是先装Miniconda,然后:
conda create -n fcn python=3.10 -y conda activate fcn pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install numpy opencv-python pillow matplotlib tqdm albumentations这里参数说明:cu118对应CUDA 11.8,我测试过在30系、40系显卡上都稳。没有NVIDIA显卡就把--index-url去掉,装CPU版,训练速度慢但是能跑通。albumentations是数据增强库,后面预处理会用到。装完后在Python里跑一句import torch; print(torch.cuda.is_available()),返回True说明GPU可用。
用VSCode配置Python环境时,注意右下角需要把解释器切换到你刚建的fcn环境。很多人在这步没切换,代码在fcn环境里运行,终端却用的base环境,报ModuleNotFoundError时一头雾水。
2.2 语义分割数据集制作:从VOC格式到Dataset类
语义分割数据和分类数据最大的区别是:每一张图对应一张“标签图”,标签图和原图尺寸一致,每个像素的数值表示类别编号。以Pascal VOC为例,JPEGImages放RGB原图,SegmentationClass放PNG标签图,背景是0,每个类别对应一个固定编号。
制作自己的数据集时,最可靠的格式就是模仿VOC。用标注工具(LabelMe或精灵标注助手)导出PNG标签图,类别编号从0开始,255留给“不确定/边界”像素。
下面是一个最简Dataset实现,我每次跑新数据集都先拿它验证路径和预处理:
import torch import cv2 import numpy as np from torch.utils.data import Dataset import albumentations as A class VOCSegDataset(Dataset): def __init__(self, img_dir, mask_dir, id_list, num_classes=21, aug=False): self.img_dir = img_dir self.mask_dir = mask_dir self.id_list = [x.strip() for x in open(id_list)] self.num_classes = num_classes self.aug = aug self.to_tensor = A.Compose([ A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), A.pytorch.ToTensorV2() ]) def __len__(self): return len(self.id_list) def __getitem__(self, idx): name = self.id_list[idx] img = cv2.imread(f"{self.img_dir}/{name}.jpg") img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask = cv2.imread(f"{self.mask_dir}/{name}.png", cv2.IMREAD_GRAYSCALE).astype(np.int64) if self.aug: aug = A.Compose([ A.HorizontalFlip(p=0.5), A.RandomBrightnessContrast(p=0.2), A.Resize(320, 320) ]) transformed = aug(image=img, mask=mask) img = transformed["image"] mask = transformed["mask"] transformed = self.to_tensor(image=img, mask=mask) return transformed["image"], transformed["mask"]逻辑说明:__getitem__里先读原图和标签图,标签图必须是GRAYSCALE模式且转为numpy.int64——损失函数不接收浮点标签,这一点经常有人漏。数据增强用的是albumentations同步增强image和mask,这是需要特别注意的设计:因为mask每个像素的值代表类别编号,不能用对图像做的那种插值去处理标签,否则类别边界会出现非整数混叠。Resize到320x320是为了让显存消耗可控,后面换到更大分辨率,模型逻辑不用动。
参数解释:num_classes=21对应VOC的20个类别加一个背景。Normalize用的均值方差是ImageNet预训练模型的标准值,如果你后面用自己从头训练的模型,这里也需要换成自己统计数据。HFlip和BrightnessContrast这两个增强对分割任务效果显著,我强烈建议新手至少保留水平翻转,开不开随机裁剪其实影响不大。
3. FCN语义分割模型搭建:从VGG到全卷积的核心结构
3.1 把分类网络改成分割网络:三个关键改动
FCN模型背后的设计逻辑可以用三个关键改动讲清楚。
第一个改动:去掉全连接层。VGG16末尾有三个全连接层,参数占整个网络的80%以上,而且强制要求输入尺寸固定。FCN的常见做法是把这三个全连接层改成等价的1x1卷积层。这样网络成为纯卷积结构,可以接受任意尺寸输入。
第二个改动:加上采样层。VGG16经过5次池化,特征图边长缩小为原来的1/32。FCN-32s直接用转置卷积把特征图放大32倍恢复原尺寸;FCN-16s先放大2倍,然后与pool4的特征图做跳跃连接相加,再放大16倍;FCN-8s同理,再融合pool3。这个跳跃连接的改进让分割边缘精细度明显提升,因为浅层特征保留了更多空间细节。
第三个改动:通道映射到类别数。最后一层卷积的输出通道数等于类别数,每个通道代表“该像素属于这个类别的得分”。尺寸为(N, num_classes, H, W)的输出图,在通道维度取argmax,就得到每个像素的类别编号。
3.2 用PyTorch实现FCN-8s:代码与参数说明
下面是一个基于VGG16主干、输出FCN-8s结构的实现。这个结构我用了很多次,训练速度和精度平衡得不错,比FCN-32s的边缘效果好很多:
import torch import torch.nn as nn from torchvision import models class FCN8s(nn.Module): def __init__(self, num_classes=21, pretrained=True): super().__init__() vgg = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1 if pretrained else None) features = list(vgg.features.children()) # 保留前三组特征提取层 self.block1 = nn.Sequential(*features[0:4]) self.block2 = nn.Sequential(*features[4:9]) self.block3 = nn.Sequential(*features[9:16]) self.block4 = nn.Sequential(*features[16:23]) self.block5 = nn.Sequential(*features[23:30]) # 把VGG最后的全连接改成卷积 self.fc6 = nn.Conv2d(512, 4096, kernel_size=7, padding=3) self.drop6 = nn.Dropout2d(0.5) self.fc7 = nn.Conv2d(4096, 4096, kernel_size=1) self.drop7 = nn.Dropout2d(0.5) # 用于最终预测的卷积层 self.score_fr = nn.Conv2d(4096, num_classes, kernel_size=1) # 跳跃连接相关:上采样用转置卷积 self.score_pool4 = nn.Conv2d(512, num_classes, kernel_size=1) self.up2 = nn.ConvTranspose2d(num_classes, num_classes, kernel_size=4, stride=2, padding=1) self.score_pool3 = nn.Conv2d(256, num_classes, kernel_size=1) self.up8 = nn.ConvTranspose2d(num_classes, num_classes, kernel_size=16, stride=8, padding=4) def forward(self, x): h = self.block1(x) h = self.block2(h) pool3 = self.block3(h) pool4 = self.block4(pool3) pool5 = self.block5(pool4) h = self.drop6(self.fc6(pool5)) h = self.drop7(self.fc7(h)) h = self.score_fr(h) # FCN-8s 融合:先上采样再加pool4、pool3 h = self.up2(h) pool4_score = self.score_pool4(pool4) h = h[:, :pool4_score.size(2), :pool4_score.size(3)] + pool4_score h = self.up2(h) pool3_score = self.score_pool3(pool3) h = h[:, :pool3_score.size(2), :pool3_score.size(3)] + pool3_score h = self.up8(h) return h逻辑说明:features[0:4]对应VGG16的conv1和conv2两层,之后每组的索引区间对应一次池化边界。fc6用7x7卷积模拟全连接层的感受野,padding=3保证特征图尺寸在卷积前后不变。值得注意的是self.up2这个转置卷积核大小4、步长2、padding 1,它的上采样倍率是2倍。传入的输入尺寸不一致,转置卷积输出尺寸和pool特征图尺寸可能差一个像素,用h[:, :pool4_score.size(2), ...]做裁剪实现元素级相加,我实测过几乎所有FCN复现都有这一步。
参数说明:pretrained=True时加载ImageNet预训练权重,这是FCN能快速收敛的关键前提,别在原题上做随机初始化训练——收敛速度慢几十倍。kernel_size=16的转置卷积输出padding=4,来自FCN论文的crop参数,如果输入尺寸不是32的整数倍,这里可能出现输出尺寸略大于原图的情况,后面推理阶段需要做中心裁剪。
4. 训练与推理:损失函数、超参数和完整训练循环
4.1 损失函数与评估指标:交叉熵背后有个大坑
语义分割最常用的损失是像素级交叉熵。看起来很简单:每个像素算一次交叉熵,取平均。但实际会遇到类别不均衡问题——VOC数据里背景像素占了大半,前景目标可能只占几个百分点,直接训练会让模型把所有像素都预测成背景。
我的经验是第一轮训练先观察初始loss和mIoU。如果loss下降但mIoU一直很低,大概率是类别不均衡在起作用。解决办法有两个方向:一是在损失函数里给每个类别加权,二是用ignore_index=255屏蔽标签图里的无效区域。
评估指标用mIoU最直观:每个类别的IoU = 真正例 / (真正例 + 假正例 + 假负例)。下面这个计算函数我通常在验证阶段直接调用:
def compute_miou(pred, label, num_classes=21, ignore_index=255): pred = pred.reshape(-1) label = label.reshape(-1) mask = label != ignore_index pred = pred[mask] label = label[mask] ious = [] for cls in range(num_classes): p_mask = (pred == cls) l_mask = (label == cls) inter = (p_mask & l_mask).sum().item() union = (p_mask | l_mask).sum().item() if union == 0: ious.append(float("nan")) else: ious.append(inter / union) return np.nanmean(ious)逻辑说明:忽略ignore_index=255像素后,逐个类别计算交集和并集。union == 0表示该类别在图里完全没出现,这时不能把IoU算成0,否则对没出现的类别惩罚错误,用nan跳过再nanmean,这是mIoU计算的标准做法。很多初学者在数据集制作时没把边界像素设成ignore_index,导致模型学了一堆边界噪声。
4.2 训练循环与超参数设置:从一个能跑通的配置开始
训练FCN最稳的超参数组合,我推荐直接抄这个:SGD优化器、momentum=0.9、初始学习率1e-3、batch size=8、输入尺寸320x320、训练60轮,学习率在第30轮和第45轮各乘0.1。Adam不是不能用,但FCN在SGD下收敛到的最优mIoU通常比Adam高2到3个百分点,这是我跑VOC数据集的重复结论。
训练主循环的骨架:
criterion = nn.CrossEntropyLoss(ignore_index=255) optimizer = torch.optim.SGD(model.parameters(), lr=1e-3, momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.MultiStepLR(optimizer, milestones=[30, 45], gamma=0.1) for epoch in range(epochs): model.train() total_loss = 0 for imgs, masks in train_loader: imgs = imgs.to(device) masks = masks.to(device) outputs = model(imgs) if outputs.size(2) != masks.size(2): outputs = nn.functional.interpolate(outputs, size=masks.shape[2:], mode="bilinear", align_corners=False) loss = criterion(outputs, masks) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * imgs.size(0) scheduler.step() print(f"Epoch {epoch+1}: loss={total_loss/len(train_loader.dataset):.4f}")逻辑说明:interpolate把模型输出图缩放到标签图尺寸,这一步因模型最后一次上采样可能差一两个像素,标准做法是用双线性插值修正而不是改网络结构。weight_decay=1e-4是VGG风格迁移的老配方,对FCN同样有效,能压低高频噪声、让分割边缘更平滑。
batch size的选择逻辑是:先把显存撑到接近溢出,再往回调。320x320输入、VGG16主干、batch size=8大约需要11GB显存,这是3060Ti级别显卡的舒服区间。如果你的卡只有8GB,减到6或者输入缩到256x256。num_workers在Windows上设成0,在Linux上设成4到8,这是很多人忽视频繁卡顿的隐藏原因。
推理阶段比训练简单得多,核心就三步:读图、归一化、前向取argmax。注意输入也要用训练时的mean/std做归一化,模型输出的raw logit要经过softmax或直接argmax再变成类别图,没有做过归一化的输入推理结果会肉眼可见地变差。
5. FCN语义分割避坑指南:5个常见问题与排查
5.1 训练loss不降,mIoU一直等于0
现象:loss从1.2降到了0.8,但mIoU始终在0.01左右徘徊,几乎等于随机猜测。
原因:绝大多数情况下是标签图的值域不对。我遇到过的真实案例是标注工具导出的PNG标签是RGB三通道的彩色图,被读成了灰度图但默认类别编号是0-255的调色板索引,而不是标注时设置的类别ID。有一类数据集的标签像素值是调色板映射后的编号,和语义ID完全不同。
解决:打印np.unique(mask)看标签值集合。如果最大值远大于num_classes-1,就用调色板转换或者创建一个label_map字典,把像素值映射到类别ID。另外确认输入网络前不需要额外/255归一化——mask只要数值是类别ID就对了。
5.2 上采样后边缘锯齿严重,预测图有棋盘格纹理
现象:预测标签图放大后,目标边界出现明显锯齿,甚至能看到规律的棋盘格暗纹。
原因:棋盘格来自转置卷积的“重叠”效应——kernel_size=4, stride=2时,转置卷积对不同位置的输入像素存在不同程度的重叠累加,产生周期性响应。这在FCN里虽然比GAN那种情况轻,但依然可见。边缘锯齿来自32倍或8倍上采样本身的信息丢失。
解决:先检查align_corners,双线性插值统一设为False,这和PyTorch训练时的一致性相关。对转置卷积,可以在训练后把up2和up8冻结,替换成双线性插值——mIoU基本持平,边缘平滑不少。更彻底的做法是训练阶段就用双线性插值替代转置卷积上采样,精度不掉但视觉效果好很多。
5.3 显存不足,batch size调到2也OOM
现象:报错CUDA out of memory,即使batch size=2、输入256x256也扛不住。
原因:两个隐藏显存消耗大户——计算图保存的中间激活值,以及VGG16前两组卷积层的特征图。VGG不像ResNet有残差结构,所有中间特征图都要保留用于反向传播,所以空间占用是单调递增的。
解决:把输入分辨率降到224x224是最后手段,优先按顺序试这几个方案:batch size降到1;关闭梯度累积校准——先跑一个step看实际占用;检查是不是torch.no_grad()在验证阶段没加,验证时把占用全加载进来了。如果batch size=1还是OOM,用torch.utils.checkpoint对block3到block5做激活重算,显存立减40%但训练时间增加约30%。
5.4 训练集和验证集划分不当,mIoU虚高
现象:训练时mIoU稳定在0.75以上,测试集一张都跑不出,相差巨大。
原因:数据划分时用了随机打乱切分。对于视频连续帧或同一场景多角度拍摄的数据集,相邻帧内容高度重合,随机划分把相似图像同时分到训练集和验证集,验证集泄露了训练信息。这不是模型好,是看了答案。
解决:语义分割数据集制作阶段就要按场景划分——同一片区、同一采集时段的图像必须整体分到同一侧。比如航拍项目的无人机航线数据,就按航线条数切,而不是按图像索引切。这个问题在VOC这类标准数据集上不存在,但自己制作数据集时最容易犯。
5.5 推理时尺寸不匹配,输出图和原图对不齐
现象:输入训练时只见过320x320,推理时喂了一张1920x1080的图,输出尺寸和原图差几个像素,拼接起来有错位或者直接报错。
原因:FCN的5次池化使特征图边长缩小32倍。如果输入尺寸不是32的倍数,转置卷积上采样后输出尺寸会比原图大或小几个像素。网络本身不报错,因为PyTorch卷积和转置卷积对任意尺寸都能算,但对不齐导致评估和拼接错位。
解决:推理时先把输入图pad到32的倍数——我自己一般选pad成(ceil(h/32)*32, ceil(w/32)*32),跑完前向把输出裁剪回原尺寸。更优雅的做法是去掉avgpool并在backbone里改ceil_mode=True,但这要求微调网络内部结构,新手不推荐为省一步pad去动主干。
6. 把FCN做得更可用:空洞卷积替换、CRF后处理与可视化验证
6.1 保留预训练权重的前提下扩大感受野
FCN在VGG backbone上的一个明显短板是:连续池化让浅层特征分辨率太低,小目标直接消失在32倍下采样的过程中。把block5的池化策略从“池化下采样”改为“空洞卷积保持分辨率”,是常见的标准做法。具体是在去掉最后一个池化层后,把block5里的三个卷积层的dilation=2、padding=2,这样不新增参数量,感受野却从原来的8倍覆盖扩展到等效16倍,预测小目标时明显更稳。
我自己实测的结果是,这个改动让VOC验证集mIoU从67.3%升到69.8%。代价是显存占用增加约15%,训练时间增加约10%,因为高分辨率特征图更吃内存。但如果你的目标场景是小物体居多(航拍车辆、遥感建筑物),这15%显存非常值得。
6.2 验证阶段必做的三件事
第一件是可视化预测图和标签图叠加。把预测轮廓叠加在原图上,用肉眼观察边缘是否贴合、是否有碎块区域。指标好看不等于视觉可用,很多错分在mIoU上只占零点几个百分点,但在实际应用里完全不能接受。我一般是每10轮训练保存一次预测图,训练完回放这些图片判断模型是否在退化。
第二件事是逐类打印IoU矩阵。compute_miou函数返回的是平均结果,要逐类输出才有诊断价值。如果某类IoU特别低,用np.bincount统计训练集里该类像素占比,低于1%的类别单独做加权——这是类不平衡最直接的证据。
第三件事是把预测图导出为PNG并检查文件大小。分割结果图是类别的颜色映射图,压缩后文件大小如果异常大,说明预测噪声多、边界闪烁频繁,这也是一种快速判断质量的土办法。
做FCN项目到最后,我最深的体会是:网络结构反而不是最大的坑,最大的是数据管线——标签对齐、值域检查、批量划分。建议你无论用什么数据集,第一件事永远是可视化5张训练样本的img + mask叠加图,确认标签语义符合你的预期再开始训练。这个习惯帮我省掉了至少三次从头再来的返工。希望帮到你。
本文还有配套的精品资源,点击获取