news 2026/10/1 19:05:19

全卷积网络FCN实战:语义分割数据集制作与PyTorch训练避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
全卷积网络FCN实战:语义分割数据集制作与PyTorch训练避坑指南

简介:图像分割是计算机视觉的核心任务,其中语义分割要求对每个像素进行类别预测,是自动驾驶、医学影像等场景的基础技术。全卷积网络(FCN)通过将分类网络的全连接层替换为卷积层,实现了端到端的像素级分类,并引入上采样和跳跃结构恢复分辨率,成为语义分割的基石。理解FCN的原理,对于掌握U-Net、DeepLab等后续模型至关重要。在实际工程中,制作对齐良好的数据集、合理设置训练参数、使用mIoU等指标评估效果,都是落地语义分割模型的关键。本文围绕FCN-8s在PyTorch中的完整实践,从数据集制作、模型定义、训练避坑到可视化验证,提供了一套可复现的工程流程,适合希望快速跑通第一版分割结果的工程师参考。

1. 全卷积网络(FCN)是语义分割绕不开的起点

全卷积网络(FCN)是语义分割入门绕不开的第一个模型。哪怕现在DeepLab、U-Net满天飞,很多训练脚本里仍然能看到FCN的影子:把分类网络背部的全连接层换成卷积层,让模型输出的不再是一排类别分数,而是一张和原图同尺寸的像素级标签图。这个zip如果只教你跑通一个demo,那价值有限;真正值钱的是把数据、模型、训练、验证这条线完整走一遍。这篇实战笔记会针对语义分割数据集制作、FCN-8s的训练参数和可视化验证逐个拆开讲,适合刚接触语义分割算法、手里只有一张显卡并且想做出第一版分割结果的工程师。

2. 全卷积网络到底改了什么:从“分类”到“逐像素分类”的本质

2.1 分类网络的全连接层为什么不能直接做语义分割

分类网络(VGG、ResNet)最后一层是1000个类别的概率。如果把图片缩小到224x224输入,最后特征图比如7x7,全连接层把它压成一个向量。这个向量里“每个位置原本的空间邻居关系”完全丢失。语义分割要求输出HxW的像素级标签,比如原图里的“猫”要精确到边缘像素,不能只在某个bbox里。所以不能直接拿分类网络输出当分割结果。

有人会想,用滑窗把每个像素周围patch喂给分类网络,这样一个像素一个像素判断。我在早期做分割原型时试过,速度惨不忍睹:一张512x512的图,步长16,要判断上千个窗口,每个窗口还要重复计算前面层。更麻烦的是窗口重叠区域的感受野计算很别扭,边界情况要额外补padding,代码很快变成一团乱麻。FCN的核心做法就是把这层全连接拿掉,换成卷积层。卷积操作天然保留空间位置,相同的卷积核滑过每个位置,输出还是二维特征图。整张图过一遍网络,直接得到 HxWxC 的得分,C 是类别数。这就是“全卷积”的关键:网络里只有卷积层、池化层和上采样层,不再有把特征图拉扁的全连接层。理解了这一点,FCN的代码就成功了一半;剩下的一半是搞清上采样和跳跃结构。

2.2 FCN的卷积化、上采样和跳跃结构:32s/16s/8s差在哪

理想情况是网络输出和原图一样大,但为了提取语义特征,网络通常要下采样,VGG16一路池化到1/32。如何恢复分辨率?FCN提出了转置卷积(反卷积)上采样。先解释一下FCN-32s:直接把pool5输出的1/32特征图做32倍上采样,得到原图大小。上采样卷积核是学出来的,不是简单插值。32s实现最简单,但边界很粗糙,因为浅层细节在多次池化里已经丢得差不多了。

FCN-16s做了一件关键事:把pool5上采样2倍,和pool4的得分图相加,再整体上采样16倍。FCN-8s同理,额外再融合pool3。之所以叫“跳跃结构”,是因为特征从不同pool层跳到最后的上采样路径上,把浅层的边缘、纹理信息补回来。跳跃融合会带来两个直接收益:一是细分割边界能保住更多细节;二是训练收敛速度比纯32s好,因为梯度能从多个尺度回传。

FCN 变体上采样倍数融合的层边界效果训练开销
FCN-32s32x无粗糙,带明显马赛克最低
FCN-16s16xpool4中等,大目标边缘改善略高
FCN-8s8xpool4 + pool3最精细的经典FCN最高

实际训练中,VGG16的pool3感受野比pool4小,边界信息更多。但不是说融合层越多越好:pool2的通道数更大、语义层次太低,融合后会把噪声也带进来,而且成倍增加显存。我在项目里默认用FCN-8s,只有当目标物体很大、标注边缘不要求精确时才退回FCN-16s。需要留意“8s”不是指8秒,而是“8倍上采样”。

上采样层不是简单torch.nn.Upsample?经典FCN定义里用的是nn.ConvTranspose2d,它带可学习参数,初始化为双线性插值核,然后靠训练微调。实际使用中也会看到有人用F.interpolate(mode='bilinear')替代,两种都能跑,区别在于反卷积多了一组可学习参数,在极小数据集上容易过拟合;而双线性上采样更稳,但特征表达能力弱一点。我的习惯是:跑通先用双线性,想刷分数再换反卷积。

这里需要补充一个尺寸计算细节:转置卷积的输出尺寸为out = (in - 1) * stride - 2 * padding + kernel_size。FCN-8s里,从1/32尺寸先用kernel=4, stride=2, padding=1上采样2倍,得到1/16;再用同一个参数的转置卷积到1/8;最后用kernel=16, stride=8, padding=4从1/8还原到原图。把公式带进去,(H/8 - 1)*8 - 2*4 + 16 = H,输出正好和输入一致。这个公式在写模型时非常有用,因为一旦输入尺寸不是2的整数次幂,最后就会差几个像素。

2.3 先分清语义分割和实例分割,再谈模型选型

学FCN之前要明白它输出的定位。语义分割回答的是“每个像素属于哪个类别”,它不区分同类物体的个体。比如画面里三只猫,FCN会把三只猫的像素全部预测为“猫”,但不会告诉你哪只猫是a、哪只是b。实例分割则在语义基础上再区分“猫1”“猫2”“猫3”,常见实现会在分割网络之外加目标检测分支或像素嵌入聚类。这个区别会直接影响损失函数和评价指标:语义分割用mIoU,实例分割要看mask AP。很多新手把语义分割模型强行拿来做实例分割,产出就是一张要么全糊要么漏检的标签图。

FCN作为最经典的语义分割算法,在今天仍然值得作为第一个模型入手。一方面它结构简单,没有注意力、没有ASPP,反卷积和跳跃结构一眼能看完;另一方面它留下的很多设计——编码器下采样、解码器上采样、跨层融合——后来在U-Net、DeepLab里都能看到影子。如果你手里的项目是实时视频分割,FCN不会是最优选择,可以考虑轻量化网络;但如果你只是想快速验证一个标注好的数据集能不能学出东西,FCN是翻车概率最低的起点。关于FCN-8s模型的具体定义和训练参数,后面两章会直接给代码。

3. 用FCN做语义分割的数据集制作:图像与掩码对齐的规矩

3.1 语义分割数据集制作的通用协议:像素级标签与调色板编码

语义分割任务里,每个训练样本由两部分组成:一张原图和一张等大的掩码图。掩码图每个像素的取值是类别编号,不是颜色。以VOC2012为例,背景类别0,二十个目标类别1-20,额外的255表示“不确定区域”,训练时忽略。很多公开数据集的掩码是PNG,其中保存的不一定是灰度图,而是一张调色板PNG:每个像素存一个8位索引,同时文件里附带一张颜色表,把索引映射成RGB。用Photoshop打开看到的是伪彩色,但读进程序时怎么读就很有讲究。

制作自己的数据集时,最稳妥的协议是“灰度标签 + 调色板导出”:直接用标注工具如LabelMe、精灵标注助手导出单通道PNG,然后写脚本统一检查像素类别集合。我见过不少翻车案例:有人在标注工具里把“人”标成RGB(255,0,0),“车”标成RGB(0,255,0),最后做训练的时候发现模型输出21个通道,标签却是彩色图直接resize,结果全部像素都成了0。所以定协议的时候要明确:程序内部只用整数类别id,颜色只是在可视化阶段临时映射。

语义分割数据集制作的另一条规矩是“图像和掩码必须严格对齐”。这里的对齐不只是尺寸相等,还包括处理路径一致:给图像做亮度抖动时,掩码不能动;给图像做随机裁剪时,图像和掩码必须用同一个裁剪坐标;给图像做水平翻转时,掩码也要跟着水平翻转。很多公开代码只对图像做了归一化,忘了同步处理掩码,于是训练到一半会发现标签和内容错位,模型输出一团混乱。为避免这种问题,我习惯把“同步增强”封装成一个函数,输入image和mask,返回经过同一组随机参数处理后的两个数组。

import random import numpy as np from PIL import Image def train_augment(image: np.ndarray, mask: np.ndarray, crop_size=(512, 512)): """ 同步增强:图像和掩码使用同一裁剪坐标与翻转逻辑。 image: (H,W,3) RGB,0-255 mask: (H,W) uint8,类别id """ h, w = image.shape[:2] # 1. 随机裁剪:先决定左上角坐标,再裁图像和mask top = random.randint(0, h - crop_size[0]) left = random.randint(0, w - crop_size[1]) image = image[top:top + crop_size[0], left:left + crop_size[1]] mask = mask[top:top + crop_size[0], left:left + crop_size[1]] # 2. 随机水平翻转:以相同条件翻转 if random.random() > 0.5: image = image[:, ::-1, :] mask = mask[:, ::-1] # 3. 图像做颜色抖动,mask保持整数语义不变 image = Image.fromarray(image) image = image.convert("RGB") return np.array(image), mask

逻辑说明:这个函数先把图片裁剪到固定大小。裁剪时top和left只有一份,图像和mask都按同一坐标切,这就杜绝了错位。翻转时用同一个随机数决定是否翻转,翻转轴都取axis=1。最后图像被转回PIL做通道变换,mask始终是整数数组。这里有个参数要留意:crop_size必须小于原图最小边,我一般对VOC先把短边resize到600,再随机裁剪512x512,这样既能利用大图信息,又能固定batch尺寸。

3.2 从VOC原始格式到训练张量:转换脚本与关键参数

下面这段脚本负责把VOC调色板PNG转成单通道类别mask。它与项目zip里的utils/文件夹下的预处理逻辑类似,但参数你可以按自己的数据改。

import numpy as np from PIL import Image # VOC 2012 的 21 类颜色表,顺序与类别 id 一致 VOC_COLORMAP = [ (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), (0, 0, 128), (128, 0, 128), (0, 128, 128), (128, 128, 128), (64, 0, 0), (192, 0, 0), (64, 128, 0), (192, 128, 0), (64, 0, 128), (192, 0, 128), (64, 128, 128), (192, 128, 128), (0, 64, 0), (128, 64, 0), (0, 192, 0), (128, 192, 0), (0, 64, 128) ] def voc_label_to_mask(png_path: str) -> np.ndarray: """读入VOC调色板PNG,返回整型类别id的mask,shape=(H,W)""" img = Image.open(png_path).convert("RGB") arr = np.array(img, dtype=np.uint8) # (H,W,3),每个像素是RGB颜色 h, w = arr.shape[:2] mask = np.zeros((h, w), dtype=np.uint8) # 默认背景 0 for class_id, color in enumerate(VOC_COLORMAP): color = np.array(color, dtype=np.uint8) # 三个通道同时相等才算匹配 match = (arr == color).all(axis=-1) mask[match] = class_id return mask

逻辑说明:Image.open(png_path).convert("RGB")这一步会把调色板展开成真正的RGB三通道图,而不是直接返回索引。为什么要这样?因为有些标注工具导出的PNG虽然是调色板格式,但索引和类别id的顺序不一定一致;统一展开成RGB再按颜色表匹配,最保险。match = (arr == color).all(axis=-1)是逐通道比较,三个通道都等于目标颜色才算命中,这样不会把(128,0,0)和(128,0,1)这种颜色弄混。最后遍历21个颜色,按class_id写入mask。参数上要注意:dtype=np.uint8限制类别数最多256,如果你的分割任务超过255个类别,就要换np.int16,但一般场景用不到。

有了mask以后,训练时图像和mask要同步做预处理。图像要归一化到[0,1]或者按ImageNet均值方差标准化;mask不需要归一化,保持整数id就好。如果做缩放,图像用双线性插值,mask必须用最近邻插值,否则类别id会被插出小数,生成一堆不存在的新类别。这是“语义分割数据集制作”里最常踩的坑。

3.3 拿到解压后的工程,先看这四个文件

按标题这个zip的常见组织,解压后通常会有这些模块:model.py放FCN或相关网络定义,train.py负责训练循环,data.py/dataset.py负责读写数据集,predict.py做单张图推理。不管压缩包具体怎么起名,建议先按四个维度检查。

第一看dataset.py里标签读取使用PIL还是OpenCV,确认它返回的mask是(H,W)整数图。第二看model.py的类别数是否和你的数据一致,FCN输出通道数如果还是默认21,而你的数据只有5类,训练时CrossEntropyLoss会立刻报维度错误。第三看train.py里的归一化参数,图像通道顺序是RGB还是BGR,预训练权重是ImageNet的,顺序不对会让loss一开始就奇高。第四看数据路径硬编码,很多demo默认VOCdevkit/VOC2012,要改成本地路径。

动手改之前,最好先跑一次数据加载脚本,打印一批样本的shape和unique标签。从zip解压后直接训练是很危险的习惯,我的流程是先花十分钟把dataset.py单独跑通,可视化出图像和mask叠放图,确认类别id、尺寸全部正常,再进训练。没有这一步,训练跑一半发现标签错位,等于白烧几小时显卡。

实际训练时,我还会额外保存一份类别频率统计。这个统计不是为了好看,而是给CrossEntropyLoss的weight参数用。计算方式很简单:遍历训练集所有mask,统计每个类别像素总数,然后按median_freq / class_freq归一化。如果某个类别只占1%像素,它的loss权重就会放大几十倍,模型才不至于完全忽略它。

4. 在PyTorch里跑通FCN最小训练:模型、损失和训练参数怎么设

4.1 FCN-8s的基本模型定义:复用VGG预训练权重

先给出FCN-8s的最小PyTorch实现。这里没有把整个VGG16全部展开,而是用五个“卷积+池化”组近似,便于看清上采样路径;实际做工程时,可以把self.pool1到self.pool5替换成torchvision.models.vgg16(pretrained=True).features的前几个stage,然后冻结前两阶段加快训练。

import torch import torch.nn as nn class FCN8s(nn.Module): def __init__(self, n_classes=21): super().__init__() # 下采样:模拟VGG16,共5次2倍池化,总stride=32 self.pool1 = self._conv_pool(3, 64) self.pool2 = self._conv_pool(64, 128) self.pool3 = self._conv_pool(128, 256) self.pool4 = self._conv_pool(256, 512) self.pool5 = self._conv_pool(512, 512) # 三个阶段分别输出类别得分图 self.score_pool3 = nn.Conv2d(256, n_classes, 1) self.score_pool4 = nn.Conv2d(512, n_classes, 1) self.score_pool5 = nn.Conv2d(512, n_classes, 1) # 反卷积上采样 self.upscore2_5 = nn.ConvTranspose2d(n_classes, n_classes, 4, 2, 1) self.upscore2_4 = nn.ConvTranspose2d(n_classes, n_classes, 4, 2, 1) self.upscore8 = nn.ConvTranspose2d(n_classes, n_classes, 16, 8, 4) def _conv_pool(self, in_ch, out_ch): return nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2) ) def forward(self, x): p1 = self.pool1(x) p2 = self.pool2(p1) p3 = self.pool3(p2) p4 = self.pool4(p3) p5 = self.pool5(p4) s3 = self.score_pool3(p3) s4 = self.score_pool4(p4) s5 = self.score_pool5(p5) # FCN-16s:pool5先上采样2倍,与pool4融合 up2_5 = self.upscore2_5(s5) up2_5 = up2_5[:, :, :s4.size(2), :s4.size(3)] fuse4 = up2_5 + s4 # FCN-8s:融合结果再上采样2倍,与pool3融合 up2_4 = self.upscore2_4(fuse4) up2_4 = up2_4[:, :, :s3.size(2), :s3.size(3)] fuse3 = up2_4 + s3 # 最后8倍上采样恢复到原图 out = self.upscore8(fuse3) return out

逻辑说明:_conv_pool每个阶段做两次3x3卷积和一次2x2最大池化,走完5个阶段,特征图分辨率变成输入的1/32。score_pool3/4/5用1x1卷积把不同阶段的特征通道压缩成类别数,得到三个“得分图”。融合路径里,upscore2_5让pool5得分图上采样2倍,此时形状接近pool4,用切片裁掉边缘不齐的像素后相加;融合结果再上采样2倍,与pool3相加;最后一次upscore8从1/8分辨率直接上采样回原图。

参数说明:nn.ConvTranspose2d的三个重要参数是kernel_size、stride、padding。kernel=4、stride=2、padding=1是“2倍上采样”的经典组合,输出尺寸刚好是输入的两倍;kernel=16、stride=8、padding=4是“8倍上采样”的组合,配合前面的1/8尺寸能还原到原图。切片操作[:, :, :s4.size(2), :s4.size(3)]是为了处理奇数分辨率下卷积输出比融合目标多1像素的情况,实际网络最后一层输出可能与输入尺寸差几像素,通常会在训练时用F.interpolate统一到标签尺寸。

关于预训练权重,这里要特别讲清楚:真正完整版FCN-8s不是从随机初始化开始的。它会加载VGG16在ImageNet上训练好的卷积层参数,随机初始化最后的1x1得分层和反卷积层。如果完全从零训练这个小模型,在VOC这种中等数据集上mIoU通常只有40多,而加载预训练权重后可以到65以上。所以你在改造模型时,尽量保留VGG16的前四层结构,不要随意改卷积核尺寸,否则无法直接搬运预训练权重。

4.2 逐像素交叉熵与类别权重:为什么不用MSE

FCN这个语义分割模型输出的是每个像素在类别上的logits,因此训练损失选择多分类交叉熵。逐像素意思是对HxW个位置分别算交叉熵,再取平均。不要用MSE回归到one-hot向量,原因有两个:一是softmax的输出是概率分布,MSE对接近0或1的饱和区域梯度极小,训练慢;二是分割类别是互斥的,用交叉熵可以直接和softmax的概率解释对齐。

样本不均衡是语义分割的常态。一张街景图里,天空、道路可能占掉80%像素,行人、摩托车只有零星几块。直接用默认CrossEntropyLoss会让模型偏向多数类。常见做法是给每个类别算权重:weight = median_freq / class_freq,多数类权重小于1,少数类权重大于1。PyTorch里这样用:

class_weight = torch.tensor([0.5, 1.0, 2.0, ...]) # 按你的类别频率调整 criterion = nn.CrossEntropyLoss(weight=class_weight, ignore_index=255)

ignore_index=255必须和标签里标注的不确定像素对应。如果不忽略,模型会被强迫把这部分像素归类,等于把噪声当成标注学。这里的255在VOC数据集中约定俗成,换自己的数据集时保持标签里同样编码。另外,weight参数需要和模型在同一设备上,如果模型在GPU而weight在CPU,会报错或隐式拷贝,影响训练速度。

4.3 训练脚本的参数表:batch_size、lr、num_workers一个都不能瞎设

FCN属于全卷积网络,显存占用和输入分辨率强相关。我用一张24G显卡跑VOC,常用配置如下:

参数建议值说明
输入尺寸512x512长边缩放+中心裁剪;尺寸太大会让显存爆炸
batch_size824G显存下的安全值;batch_size减半时学习率也要同步缩放
初始学习率1e-4使用预训练VGG权重,lr太高会把预训练特征冲掉
优化器SGD(momentum=0.9)Adam收敛快但后期分割边界容易抖
weight_decay1e-4配合SGD防止过拟合
num_workers4CPU核数的一半左右,太高会在数据读取时卡顿
epochs50小数据集建议先看验证mIoU,早停更可靠

训练主循环可以写成这样:

model = FCN8s(n_classes=len(class_names)).cuda() optimizer = torch.optim.SGD(model.parameters(), lr=1e-4, momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.MultiStepLR(optimizer, milestones=[30, 40], gamma=0.1) for epoch in range(50): model.train() for images, masks in train_loader: images = images.cuda() masks = masks.cuda().long() # 标签必须是整数 logits = model(images) # (B, C, H, W) loss = criterion(logits, masks) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()

参数说明:masks.cuda().long()这一步不能省,CrossEntropyLoss要求标签是LongTensor,而读取的mask通常是uint8,直接送进loss会报类型错误。logits和masks的尺寸必须一致,模型最后一层输出原图大小,如果因为padding差1像素,在loss前用F.interpolate(logits, size=masks.shape[-2:], mode='bilinear')对齐。scheduler在epoch 30和40把学习率乘0.1,让模型在训练后期进入精细调整。

如果你是第一次跑,建议把epochs改成5,batch_size改成4,先确认整个流程能在20分钟内跑完,再铺开正式训练。训练过程中要盯两件事:第一个是loss曲线是否稳定下降,第二个是验证IoU有没有周期性突变。有些数据增强实现里存在随机翻转没同步的bug,会造成loss正常但验证分数上蹿下跳。

5. FCN实战避坑:5个让损失和标签翻车的排查记录

5.1 loss变成nan或全程不下降:先查学习率和输入里有没有毒数据

现象:训练第1个epoch loss就输出nan,或者loss一直在3.0以上不动。

原因:最常见是学习率过大,尤其从零训练时每一层都承受了很大的梯度更新;其次是输入图像里有NaN像素,或者标签中存在超出n_classes范围的类别id,导致CrossEntropyLoss内部计算越界。

解决:先把学习率从1e-4降到1e-5跑20个step看loss曲线;再写一段检查代码,打印images.isnan().sum()和masks.max()。如果数据没问题,就把模型里所有inplace=True的ReLU临时去掉试试,某些混合精度环境下inplace操作会带来隐性问题。等loss稳定在2.0以下再慢慢回升学习率。

很多从GitHub拉来的训练脚本默认使用torch.cuda.amp混合精度,如果你的显卡是旧的Pascal架构,混合精度可能触发梯度溢出,loss直接nan。最简单的排查办法是关掉amp再跑一个step,如果loss恢复正常,问题就在GradScaler的scaler.scale(loss)没有正确配合scaler.unscale_和scaler.step。

5.2 掩码读出来全是0或255:PIL的P模式和OpenCV的灰度坑

现象:训练时可视化mask,发现整张图都是背景0,或者全是255;loss奇低但模型什么都没学到。

原因:VOC这类调色板PNG文件用PIL读取时,np.array(Image.open(path))返回的是索引图,如果直接当成灰度mask用,索引和类别id偶尔一致还好;但很多数据集的调色板索引并不等于类别id,比如索引255被用作白色边框。如果改用OpenCV的cv2.imread,默认会把PNG展开成BGR三通道,此时再取[:,:,0]拿到的是颜色某个通道,几乎不可能等于类别id。

解决:统一走先convert('RGB')再按颜色表映射,或者用Image.open(path).convert('P')并显式获取img.getpalette(),然后按调色板建立索引到类别id的映射表。千万不要在数据加载时混合使用PIL和OpenCV,否则训练集和验证集读法不一致,mIoU也会忽高忽低。

我自己的习惯是在dataset.py里只保留一种读图方式:所有图像用PIL,所有mask用PIL,统一转成numpy数组。虽然OpenCV快一些,但两种库的通道顺序和色彩空间差异会消耗大量排错时间。

5.3 输出尺寸和标签对不上:转置卷积不负责精准对齐

现象:训练报错The size of tensor a (513) must match size of tensor b (512),或者loss明明是有限值但可视化时预测图比原图大一圈。

原因:反卷积的kernel、stride、padding组合在偶数分辨率下刚好还原,在奇数分辨率下会多出一个像素。FCN原论文在推理时用裁剪把输出中心区域裁到输入大小,但很多人忽略这个细节,直接把logits和标签硬拼。

解决:最省事的方法是在loss之前用F.interpolate(logits, size=(H, W), mode='bilinear', align_corners=False)统一尺寸。注意验证集和测试集也要用同一层处理,否则训练/验证指标不可比。如果不想引入插值,就把所有训练图像resize到固定偶数尺寸,比如512x512,让转置卷积正好对齐。

排查这一步时,可以在训练脚本里临时打印logits.shape和masks.shape。如果logits是(1, 21, 513, 513)而masks是(1, 512, 512),问题基本都出在最后一个upscore8的padding参数上。把padding从4改成kernel_size - 1或者干脆接一层F.interpolate,比去调所有反卷积参数简单得多。

5.4 mIoU高但可视化一塌糊涂:插值方式、评价协议和类别不平衡

现象:验证mIoU有70%,但可视化结果里细小物体全没了,边缘像马赛克。

原因:有三种常见解释:一是验证时对标签用了双线性插值,导致评价像素被污染,mIoU虚高;二是模型只学好了大类别,背景、道路占主导,mIoU按类别平均后,大类别提升掩盖了少数类别崩溃;三是调色板映射错乱,可视化代码把类别id和颜色表偏移了一位。

解决:验证集resize标签必须用nearest,推理图建议逐类输出IoU而不是只mIoU,看哪些类别低于10%。可视化时写一个颜色表,用palette[class_id]生成伪彩色图,再叠加半透明原图检查边缘。没有这一步,mIoU再高也没有说服力。

我遇到过最蹊跷的情况:训练集mask读取正确,验证集读取却用了OpenCV的BGR通道,导致验证标签整体偏色。模型在训练集的loss很低,但验证指标一直上不去。后来把验证集可视化出来才发现,验证标签的类别id整体错位。所以不只要看mIoU数字,定期抽查可视化图是必须的。

5.5 显存不够:从输入裁剪到梯度累积的三种临时方案

现象:batch_size=8直接CUDA out of memory,连训练都起不来。

原因:FCN的显存占用由输入分辨率和中间特征图共同决定,512x512输入、5个stage的中间特征累计可能超过20G。

解决:三种方案可以组合。第一,输入尺寸从512降到384或256;第二,batch_size降到2,但用梯度累积模拟更大batch——每步loss.backward()后不step(),累计4次再更新一次;第三,关掉验证阶段的梯度,with torch.no_grad()包住验证循环。如果项目允许,还可以换用torch.cuda.amp混合精度,显存可以减少三分之一。注意梯度累积时学习率要按实际batch_size调整,不能既小batch又大lr。

显存不够时不要第一时间想到换显卡。先看Dataset的__getitem__里是不是每次训练都重新resize大图,如果是,建议把所有样本预处理成统一尺寸的npy缓存,训练时直接读缓存。FCN这类全卷积网络对输入尺寸不敏感,固定尺寸能省去很多动态shape的临时显存碎片。

6. 把FCN结果变成能验收的东西:mIoU计算、伪彩色可视化和改进方向

6.1 验证指标这样算才可信

语义分割最常用的是mIoU,计算时对每个类别分别算intersection / union再取平均。下面这个函数是按验证集逐batch累积IoU的简化版:

def compute_miou(pred, mask, n_classes): ious = [] for cls in range(n_classes): pred_cls = (pred == cls) mask_cls = (mask == cls) inter = (pred_cls & mask_cls).sum() union = (pred_cls | mask_cls).sum() if union > 0: ious.append(inter / union) return np.mean(ious)

计算时记得先对logits做argmax(dim=1),并过滤掉mask==255的像素。只算这个函数还不够,我会额外打印每个类别的IoU,防止总体分数被大物体带高。类别IoU的输出格式可以做成一行表格:cat: 0.82, dog: 0.45, background: 0.95,一眼就能找出瓶颈类。

6.2 可视化时给mask上伪彩色

模型输出是21个通道的logits,直接看是灰度图。我是这样做的:定义一张(21,3)的调色板,用palette[argmax]映射成RGB,再与原图叠合。叠合时透明度过高会看不清边界,推荐原图权重0.6、预测图权重0.4。

pred_mask = logits.argmax(dim=1)[0].cpu().numpy() color_mask = palette[pred_mask] blend = (0.6 * image_orig + 0.4 * color_mask).astype(np.uint8)

6.3 想继续改进,顺着三个方向

如果FCN跑通了但觉得效果不够好,第一个方向是把反卷积替换成双线性上采样加深度卷积的轻解码器,FLOPs会下降但精度可能不降;第二个方向是引入多尺度特征,类似DeepLab的空洞卷积;第三个方向是换U-Net结构,在编码器和解码器之间加跳跃连接。这些改法都以FCN为底座,验证指标脚本可以直接复用。我踩过最深的坑是过度迷信公开权重,建议每次改网络都从头训练对比,而不是拿预训练模型直接替换。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 19:05:17

Python遗传算法标定VISSIM跟驰参数实战

简介:本资源为基于Python遗传算法实现VISSIM模型标定的完整设计源码,面向交通工程、交通仿真方向的学习者与研究人员,用于解决微观交通模型中参数繁多、人工标定效率低且难以获得全局最优配置的问题。压缩包共23个文件、约443KB,涵…

作者头像 李华
网站建设 2026/10/1 19:05:16

科幻实验室内景漫游全流程:建模、材质、光照与交互实现

做科幻实验室的内景漫游,算是我这几年碰过最"既要又要"的项目:既要场景细节经得起近距离特写,又要保证漫游时帧率不掉链子;既要科技感拉满,又不能堆得像夜市招牌那么俗气。最近刚完成一套完整的内景科幻实验…

作者头像 李华
网站建设 2026/10/1 19:04:28

Java物业管理系统实战:从技术选型到部署上线全流程

简介:这是一套面向Java初学者与课程设计学习者的物业管理系统完整项目包,围绕社区住户信息、物业费用、设施维修等典型业务场景,提供从需求分析到部署上线的全流程参考。压缩包共1453个文件,约119.7MB,包含39个Java源文…

作者头像 李华
网站建设 2026/10/1 19:03:22

50万卡、10万亿参数、3倍算力:超大规模集群训练的技术拆解

前阵子圈子里刷到“算力 3 倍、集群 50 万卡、参数 10 万亿”这一串数字的时候,我第一反应不是兴奋,而是愣了一下。这几个量级放在一起,已经不是简单的“堆机器、调参数”能解释的了,它更像是在公开宣布一条技术路线的选择&#x…

作者头像 李华
网站建设 2026/10/1 19:02:44

Agent上生产:系统接入才是拦路虎,MCP与适配层实战复盘

这个项目上线那天,我们在会议室里等第一个真实工单。演示环境里模型表现得像个十年老员工,能总结、能推断、能把完整执行计划列得清清楚楚。但生产环境里,它要做的第一件事,是把 OA 里一张审批单读进来,再对着 ERP 里的…

作者头像 李华
网站建设 2026/10/1 19:01:19

YOLOv9-Pose:基于PGI梯度路由的轻量单阶段人体姿态估计

简介:本资源是一套基于YOLOv9实现的高精度人体姿态估计算法实战项目,面向计算机视觉方向的研究者、算法工程师及进阶开发者,解决图像/视频中人体关键点实时检测与定位问题,适用于安全监控、体育动作分析、虚拟现实交互等实际场景。…

作者头像 李华