
简介图像分割是计算机视觉的核心任务其中语义分割要求对每个像素进行类别预测是自动驾驶、医学影像等场景的基础技术。全卷积网络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(modebilinear)替代两种都能跑区别在于反卷积多了一组可学习参数在极小数据集上容易过拟合而双线性上采样更稳但特征表达能力弱一点。我的习惯是跑通先用双线性想刷分数再换反卷积。这里需要补充一个尺寸计算细节转置卷积的输出尺寸为out (in - 1) * stride - 2 * padding kernel_size。FCN-8s里从1/32尺寸先用kernel4, stride2, padding1上采样2倍得到1/16再用同一个参数的转置卷积到1/8最后用kernel16, stride8, padding4从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) RGB0-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都按同一坐标切这就杜绝了错位。翻转时用同一个随机数决定是否翻转翻转轴都取axis1。最后图像被转回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的maskshape(H,W) img Image.open(png_path).convert(RGB) arr np.array(img, dtypenp.uint8) # (H,W,3)每个像素是RGB颜色 h, w arr.shape[:2] mask np.zeros((h, w), dtypenp.uint8) # 默认背景 0 for class_id, color in enumerate(VOC_COLORMAP): color np.array(color, dtypenp.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。参数上要注意dtypenp.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(pretrainedTrue).features的前几个stage然后冻结前两阶段加快训练。import torch import torch.nn as nn class FCN8s(nn.Module): def __init__(self, n_classes21): super().__init__() # 下采样模拟VGG16共5次2倍池化总stride32 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, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.ReLU(inplaceTrue), 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-16spool5先上采样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。kernel4、stride2、padding1是“2倍上采样”的经典组合输出尺寸刚好是输入的两倍kernel16、stride8、padding4是“8倍上采样”的组合配合前面的1/8尺寸能还原到原图。切片操作[:, :, :s4.size(2), :s4.size(3)]是为了处理奇数分辨率下卷积输出比融合目标多1像素的情况实际网络最后一层输出可能与输入尺寸差几像素通常会在训练时用F.interpolate统一到标签尺寸。关于预训练权重这里要特别讲清楚真正完整版FCN-8s不是从随机初始化开始的。它会加载VGG16在ImageNet上训练好的卷积层参数随机初始化最后的1x1得分层和反卷积层。如果完全从零训练这个小模型在VOC这种中等数据集上mIoU通常只有40多而加载预训练权重后可以到65以上。所以你在改造模型时尽量保留VGG16的前四层结构不要随意改卷积核尺寸否则无法直接搬运预训练权重。4.2 逐像素交叉熵与类别权重为什么不用MSEFCN这个语义分割模型输出的是每个像素在类别上的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(weightclass_weight, ignore_index255)ignore_index255必须和标签里标注的不确定像素对应。如果不忽略模型会被强迫把这部分像素归类等于把噪声当成标注学。这里的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(momentum0.9)Adam收敛快但后期分割边界容易抖weight_decay1e-4配合SGD防止过拟合num_workers4CPU核数的一半左右太高会在数据读取时卡顿epochs50小数据集建议先看验证mIoU早停更可靠训练主循环可以写成这样model FCN8s(n_classeslen(class_names)).cuda() optimizer torch.optim.SGD(model.parameters(), lr1e-4, momentum0.9, weight_decay1e-4) scheduler torch.optim.lr_scheduler.MultiStepLR(optimizer, milestones[30, 40], gamma0.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, sizemasks.shape[-2:], modebilinear)对齐。scheduler在epoch 30和40把学习率乘0.1让模型在训练后期进入精细调整。如果你是第一次跑建议把epochs改成5batch_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()。如果数据没问题就把模型里所有inplaceTrue的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或255PIL的P模式和OpenCV的灰度坑现象训练时可视化mask发现整张图都是背景0或者全是255loss奇低但模型什么都没学到。原因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), modebilinear, align_cornersFalse)统一尺寸。注意验证集和测试集也要用同一层处理否则训练/验证指标不可比。如果不想引入插值就把所有训练图像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_size8直接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(dim1)并过滤掉mask255的像素。只算这个函数还不够我会额外打印每个类别的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(dim1)[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为底座验证指标脚本可以直接复用。我踩过最深的坑是过度迷信公开权重建议每次改网络都从头训练对比而不是拿预训练模型直接替换。希望帮到你。本文还有配套的精品资源点击获取