
简介面向已掌握 CNN 基础、想尽快上手语义分割的深度学习开发者这是一套以全卷积网络FCN为核心的实战代码包覆盖像素级分类、反卷积上采样、任意尺寸输入处理等关键环节可用于 Pascal VOC 等数据集的训练、验证与预测。压缩包大小约 335.39MB文件总数标注为 2000文件类型以 XML 标注、PNG/JPG 图像为主另含少量 Python 脚本与 JSON 类别配置数据与代码搭配完整。包内训练、评估、预测流程均提供对应脚本并包含数据集读取、数据增强、分布式训练辅助模块和类别/调色板配置目录结构清晰方便直接替换数据后复现模型效果也便于逐模块阅读源码理解 FCN 实现细节。已有 1533 人学习下载适合希望从理论走向项目实操、需要一份可运行语义分割基线工程的读者快速上手与二次开发。1. 先把话说透FCN是什么为什么语义分割绕不开它FCNFully Convolutional Network全卷积网络是语义分割方向绕不开的起点模型。它把分类网络末尾令人头疼的全连接层整个换成卷积层于是网络不再输出一个类别分数而是直接输出一张与原图同尺寸的类别图。你给它一张街景照片它返回的是一张每个像素都被标了“道路”“汽车”“天空”的标签图。对从遥感影像提取建筑、在医学切片里分离病灶、替自动驾驶标注车道线的工程师来说这套思想到今天依然是很多语义分割模型的底色值得花一个周末亲手跑通。这篇笔记适合刚做过图像分类但没接触过逐像素预测的读者也适合想用 FCNet 快速出第一版分割结果的人。2. 语义分割为什么绕不开FCN从全连接到全卷积的三个关键转变2.1 第一个转变把全连接层扔掉让网络输出变成 h×w 的图分类网络以 VGG16 为代表最后三层全连接把特征图拍扁成了 4096 维向量最后接 Softmax 给出 1000 类概率。这个结构对“整张图属于哪一类”没问题可语义分割需要的是“每个像素属于哪一类”。你无法为一张 512×1024 的街景图单独跑 1024×512 次分类来给每个像素定类别计算量是天方夜谭。FCN 的思路是把最后 3 层全连接改造成 1×1 卷积这样网络不再关心输入尺寸卷积层滑过整张图输出变成通道数为类别数、宽高和输入相关的热力图。实际实现里我一般不会手工改 VGG 的权重文件而是把结构写清楚conv5_3 之后的特征图经过两个 1×1 卷积通道数从 512 先降到 4096 再降到类别数。注意第一层的 stride 不能乱动保持卷积核滑动窗口走满全图。改完之后网络参数只是换了排列方式几乎不增加额外参数量却能从batch, 512, h, w一路映射到batch, num_classes, h, w恢复出空间维度。这个转变里踩过坑的人都知道最容易翻车的是输入尺寸必须能被 stride 整除。VGG16 总步长是 32输入尺寸若不是 32 的倍数最后的特征图宽高会出现小数取整导致后续上采样和原图的对应关系错位。我一般会用 padding 或 resize 先把输入固定成 32 的倍数。2.2 第二个转变上采样三选一为什么常见落地选双线性特征图经过上面这些卷积尺寸缩小到了原图的 1/32比如 512×512 的输入变成了 16×16。要把这个粗糙的热力图恢复回原尺寸FCN 作者给出了三种上采样转置卷积、双线性插值、以及转置卷积前用跳跃连接叠加浅层特征。最原始的 FCN-32s 直接把 16×16 的结果用 32 倍双线性插值拉回 512×512缺点一目了然边缘太糊车和路的分界线像浆糊。转置卷积是有学习参数的上采样它能学习到更锐利的边界代价是会让网络多出大量参数并且在输入尺寸不是 32 的整数倍时容易产生棋盘格伪影。所以很多实战项目不会一上来就用转置卷积而是先上双线性到目标分辨率再接一层普通卷积去修正细节。torchvision 里提供的 fcn_resnet50 也是这种组合上采样层用的是双线性插值scale_factor 配合 align_cornersTrue再通过一个 conv2d 把类别概率图磨得更干净。我一般做工程验证时优先选双线性原因很简单不需要额外调转置卷积的 kernel_size 和 output_padding参数少训练过程中更省心。等模型基线正常了再决定要不要把上采样换成更贵的算子。2.3 第三个转变跳跃连接语义和边界缺一不可FCN-32s 恢复出来的结果虽然大方向不错但边界细节完全不可用。原因在于 1/32 分辨率的热力图只剩高层语义池化把大量几何信息丢掉了。FCN 论文里有两个改进版本FCN-16s 和 FCN-8s。它们做的事本质上是把浅层特征通过跳跃连接加到深层上采样结果上再把融合结果继续上采样。FCN-8s 之所以成为经典是因为它叠加了 pool3 和 pool4 两层的特征既有足够语义又保留了一部分边界响应。你可以在代码里看到这样的 shape 变化pool3 输出是输入的 1/8pool4 输出是 1/16conv7 输出是 1/32。把 conv7 的结果上采样 2 倍和 pool4 相加得到 1/16再上采样 2 倍和 pool3 相加得到 1/8最后上采样 8 倍回原图。这三个版本里我建议直接做 8s 版本16s 作为中间调试用。8s 为了对齐尺寸在 add 前往往会做一个 1×1 卷积把浅层通道数映射到类别数这一步不能省否则 add 会因为通道数不一致直接报错。还有一点不少新手忽略跳跃连接要把 pool3、pool4 的坐标空间处理好浅层特征不需要经过额外的对齐层但深层的上采样尺寸必须和浅层特征完全一样。由于 pool3 在代码里很容易被误改成 ceil_modeTrue 导致输出多一个像素建议每次 add 前都打印一下 tensor.shape 确认。2.4 感受野与显存的账为什么FCN只能接受小 batchFCN 本身不复杂复杂的是它的显存账单。骨干网络 VGG16 总步长 32前向传播时特征图即使经过 maxpool 体积缩小但 conv1 层保持原图分辨率卷积显存消耗极大。512×512 输入在 VGG16 上跑一轮训练batch size 开到 8 就已经让一张 12G 的卡吃紧。原版论文训练时用的输入是缩略图加随机裁剪很多人照搬“必须整图输入”的错觉结果 OOM 后把 batch 降到 1又发现 BN 层统计量失效。常见做法是固定分辨率训练先把训练图等比缩放到短边约 400 像素再随机裁剪成 384×384 或 400×400 的 patch 喂给网络。这样显存可控BN 也有统计意义。它同时解释了另一个现象FCN 对目标尺寸极度敏感。如果训练集里目标占图比例很大随机裁剪后小目标很容易被裁出画面外模型就会偏向大目标。遥感图像语义分割尤其明显建筑、道路目标在整幅影像里只占几个百分点不切片训练几乎学不到小目标。我的做法是在裁剪时叠加类别分布约束让每个 patch 至少包含 k 个目标像素否则重新裁剪。这个逻辑在第四章会再细讲。3. 把VOC变成FCN能吃的标签数据准备与class字典3.1 全卷积网络进场前先认清VOC数据里到底有什么PASCAL VOC 2007/2012 里语义分割标注文件一般叫 SegmentationClass里面是彩色的 PNG 图片。别看它是 PNG 格式它的编码方式和日常照片完全不同它是调色板 PNGIndexed PNG文件里有一张颜色表PALETTE每个像素存储的值不是 RGB 三元组而是颜色表索引。你如果直接用 cv2.imread 去读OpenCV 会默认把它转成 BGR 彩色图索引信息完全丢失标签类别就废了。这就是为什么很多人在数据准备阶段就翻车。正确读法是用 PIL 的 Image.open保持 P 模式不要 convert 到 RGB然后 np.array 读出 shape 为h, w的索引数组。这个索引数组里 0 代表背景1 是飞机2 是自行车以此类推20 类是室内物品255 代表的是标注者都拿不准的难例训练时应该被忽略。还有 SegmentationClassAug 这个版本部分类别被打散重组只有 21 类以内的小模型能用别盲目混用。在动手解析前我们要明确一个目标把原始彩色标签全部转成两部分——索引标注图.png和类别映射字典。索引图用于训练时的 CrossEntropyLoss字典用于把预测结果还原成彩色可视化图。有的实践里把这个字典结构称为 hdict 语义分割标签本质就是 {类别名: 索引} 的映射。你可以把它序列化成 json跑完推理后用同一份字典上色保证颜色顺序始终一致。先写类名清单再写转换脚本顺序搞反后面调色会疯。3.2 解析XML并生成索引标注图转换脚本与参数说明下面这个脚本是我常用的 V0C 转换方案重点不在于逐行实现而在于理解为什么用 PIL 而不是 OpenCV。import os import json import numpy as np import xml.etree.ElementTree as ET from PIL import Image # 类别字典顺序固定训练、可视化、推理共用同一份 CLASSES [ background, aeroplane, bicycle, bird, boat, bottle, bus, car, cat, chair, cow, diningtable, dog, horse, motorbike, person, pottedplant, sheep, sofa, train, tvmonitor ] CLASS_TO_ID {name: idx for idx, name in enumerate(CLASSES)} def parse_voc_xml(xml_path): # 只取 segmentation 任务需要的 object 框和类别名 tree ET.parse(xml_path) objects [] for obj in tree.findall(object): name obj.find(name).text if name not in CLASS_TO_ID: continue # 跳过未定义类别 objects.append(name) return objects def convert_voc_seg(seg_dir, out_dir): os.makedirs(out_dir, exist_okTrue) for label_path in sorted(os.listdir(seg_dir)): if not label_path.endswith(.png): continue label_png os.path.join(seg_dir, label_path) img Image.open(label_png) # 不要 convert RGB label_idx np.array(img, dtypenp.uint8) save_path os.path.join(out_dir, label_path) Image.fromarray(label_idx, modeP).save(save_path, formatPNG) with open(os.path.join(out_dir, class_dict.json), w) as f: json.dump(CLASS_TO_ID, f, indent2)逻辑说明PIL 打开后始终保持 P 模式np.array 得到的二维索引数组就是训练标签这期间不再做任何 RGB 转换避免调色板被破坏。保存时用 modeP 的意思是告诉 PIL 把二维索引原样写回调色板 PNG而不是当成灰度图去映射到黑白图像。注意 parse_voc_xml 在这个脚本里只用于校验类别名是否在预定义范围内真正生成 label 时并不需要修改原始 PNG。参数说明CLASS_TO_ID 就是前文说的 hdict 语义分割标签key 是类别名value 是训练时 loss 的 target 索引。如果你的数据集里出现了 VOC 没有的类别比如遥感里的植被和水体请先改 CLASSES再重算索引两处必须同步更新。另外原始 VOC 标签里的 255难例在这个脚本中被原样保留这个 255 在训练时才决定是忽略还是参与计算。提示如果训练时出现类别索引越界报错第一件事回头检查 class_dict.json看看类别数和 num_classes 是否对得上。索引图、调色板、类别字典三者永远要绑在一起迁移。3.3 可视化检查你辛苦做的标签是不是一坨黑做完转换先别急着训练。打开一张标签用 Python 快速检查像素分布确认不是一整块黑。因为你如果忘了把标签里的 255 处理掉或者用了 cv2 读图导致所有值变成奇怪的 BGR 组合训练过程里 loss 会一会正常一会 NaN。可视化检查其实很简单读取索引图用调色板上色后再保存一份 JPG。import numpy as np from PIL import Image # 读取刚才生成的索引图 idx np.array(Image.open(VOCdevkit/VOC2007/SegmentationClass/000001.png), dtypenp.uint8) # 打印像素分布检查类别是否正常 unique, counts np.unique(idx, return_countsTrue) print(dict(zip(unique.tolist(), counts.tolist()))) # 按调色板上色还原成肉眼可读的彩色图 palette_img Image.open(原始彩色标签路径/000001.png) palette np.array(palette_img.getpalette(), dtypenp.uint8).reshape(-1, 3) color_img Image.fromarray(idx, modeP) color_img.putpalette(palette) color_img.save(check_000001.jpg)这段脚本里最关键的是 print 像素分布。正常图片应该同时出现 0 和一些 1~20 的类别值如果出现 255说明难例像素原样保留要在训练时对应 ignore_index如果出现 250 以上非 255 的数值说明源标签和你的类别定义不匹配基本是用了 SegmentationClassAug 且没做重映射。可视化上色环节里从原始图片调色板取颜色是为了确保你看到的效果和 VOC 官方一致颜色顺序错了模型不背锅只会让你白白浪费时间 debug 可视化。3.4 数据增强的边界插值不允许碰标注图训练 FCN 基本都会做随机翻转和随机缩放。很多人会用同一个 transform 同时作用于图像和标签这是个容易被忽略的细节。图像增强的插值方式可以是 bilinear标签增强却不能用 bilinear否则 0 和 2 之间会插出像素值 1类别凭空多出来。你的数据管线里如果出现了原本不存在的类别 id损失函数会计算出一个虚假的交叉熵训练还不会报错验证时预测多了一个类别你还以为是模型幻觉。做法是给图像和标签分别走增强管线图像用 bilinear标签用 nearest。翻转没有插值问题可以用同一套随机种子但要小心水平翻转和垂直翻转的随机状态要一致。有个快速检查增强后的标签用 np.unique 打印类别集合跟 CLASSES 集合做一个差集如果出现新值说明你的标签增强里混入了插值。这一条在遥感切片里尤其常见因为遥感图像尺寸大、切片多增强代码容易图省事直接复用图像管线的插值模式。4. 用FCN做语义分割网络训练模型搭建、损失函数与关键参数4.1 最小可用模型VGG骨干换成全卷积的写法不建议自己从零实现 FCN。工程上最省事的方式是直接使用 torchvision 里的现成实现这并不违背标题里“使用 FCN 实现语义分割”的核心诉求反而让你把精力花在数据、损失和推理上。我一般直接用 fcn_resnet50它是官方把 ResNet50 骨干和 FCN 头部缝合好的版本跳跃连接、双线性上采样都处理好了。import torch from torchvision.models.segmentation import fcn_resnet50 # pretrained_backboneTrue 表示骨干用 ImageNet 预训练权重 model fcn_resnet50(weights_backboneIMAGENET1K_V2, num_classes21) model.train() # 输入是 (B, C, H, W)H/W 不必是32倍数但建议固定到32的倍数 dummy torch.randn(2, 3, 384, 384) out model(dummy)[out] print(out.shape) # (2, 21, 384, 384)逻辑说明fcn_resnet50 把输出封装成字典[out] 是B, num_classes, H, W的 logits跟原版 FCN 思想一致resnet 骨干做下采样头部双线性上采样恢复分辨率。我用它做主模型主要是省去手动搭跳跃连接的步骤代码 review 时也更简单。如果确实想复现论文里的 FCN-8s 三联跳连也可以加载 VGG16 的 features再写一个上采样 head但那个头需要自己保证四个 tensor 的 shape 一致新手调起来容易半夜挠头。参数说明weights_backboneIMAGENET1K_V2 不是说模型已经完成了语义分割训练它只代表骨干部分用了分类预训练分割头仍然是随机初始化要用分割数据继续训练。如果你的数据集类别只有 5 类比如最常见的遥感四类加背景把 num_classes 改成 5 即可不要继续用 21。忘记改 num_classes 会让最后一层超出训练标签的最大索引训练中大概率报错或预测出无意义类别。关于骨干选择ResNet50 比原版 VGG16 快、省显存边界表现差不了太多。你要是显存只有 4G把 resnet50 换 resnet18 或 resnet34效果降一点但能跑。4.2 损失函数与学习率CrossEntropy、ignore_index与poly衰减训练语义分割损失函数几乎默认是逐像素交叉熵但有两个参数值得单独说ignore_index 和类别不平衡。给出一个最小训练循环。import torch import torch.nn.functional as F def train_one_step(model, images, labels, optimizer, device): images images.to(device) labels labels.to(device).long() # 索引图必须是 long不能是 float optimizer.zero_grad() logits model(images)[out] loss F.cross_entropy(logits, labels, ignore_index255) loss.backward() optimizer.step() return loss.item() # poly 学习率衰减分割任务里比 step 衰减更常用的曲线 def poly_lr(optimizer, base_lr, cur_iter, total_iter, power0.9): lr base_lr * (1 - cur_iter / total_iter) ** power for param_group in optimizer.param_groups: param_group[lr] lr逻辑说明labels 必须转成 long 型且形状和 logits 的空间尺寸完全一致CrossEntropy 会自动在通道维做 softmax 并计算每个像素的交叉熵最终返回的是全图平均损失。ignore_index255 正是处理 3.1 节那种难例loss 在 255 处梯度直接置零这样难例不会教坏模型。如果 labels 不是 long 而是 floatCrossEntropy 会抛错说 expected target type Long如果分辨率不一致则会出现 broadcast 失败。参数说明poly_lr 是语义分割里比 steplr 更常用的曲线。power 默认取 0.9意思是学习率随训练步数平滑走低让后期微调边界。base_lr 我用 0.01 配 SGD momentum0.9 weight_decay5e-4换成 Adam 的话 base_lr 降到 1e-4。batch_size 如果小于 4BN 的统计量会抖这时要么开 SyncBN要么把输入分辨率降一点来换取更大 batch。训练终止用什么做信号不要只看 loss。我一般每隔几百步存一个 checkpoint在验证集上算 mIoU 再决定是否继续。只盯着训练 loss 经常会发现它降到 0.1 以下了边界还是一团糟因为 FCN 对类别不平衡敏感loss 低不代表小目标被识别了。4.3 遥感图像语义分割的训练差异与显存调整如果你手头不是 VOC 而是遥感影像训练逻辑要换一下。遥感图像语义分割的目标精细但稀疏原始影像动辄几千像素宽直接 resize 到 512 会把建筑和道路磨成线状模型根本学不到形状。常见做法是大图切片用滑动窗把原图切成 512×512 或 1024×1024 的切片切之前先统计标签里各类别像素占比尽量保证每个切片至少包含一类前景。这个过程用 PIL 或 OpenCV 都能做切片之间保留 overlap推理时再用重叠投票消除边界缝隙。显存方面遥感切片往往还用多光谱数据比如 4 波段以上第一层卷积的 in_channels 就得从 3 改成对应通道数。此时不能直接加载 ImageNet 预训练权重常见做法是保留 RGB 三个通道的预训练参数其余通道用随机初始化或者干脆做通道平均复制。改完注意重新初始化第一层否则预训练权重 shape 对不上加载就报错。训练时 BN 统计量需要预热所以必须先跑几十个 step 让统计量就位再开始评估。5. 语义分割模型推理翻车现场5个让输出乱码的常见坑5.1 预测图和原图颜色对不上整张图像霓虹灯现象训练结束做推理代码跑通了出来的预测图颜色和 VOC 标签颜色截然不同明明天空应该是浅蓝预测图里天空变成了品红色。原因预测输出是索引图你直接拿它当灰度或者按训练时的 CLASS_TO_ID 重新上色但是调色板顺序没和 VOC 官方保持一致。VOC 的 PALETTE 不是简单的纯色排列不同类别穿插得很怪你从某个开源库抄的调色板只要错一个位置后面整条街的颜色全错位。解决推理时从原始标签 PNG 里读取 palette预测索引图也用同一份 palette 上色。代码顺序是先用 np.argmax 得到h, w索引图Image.fromarray 成 P 模式putpalette 原始 palette再转 RGB 保存。这样保证预测颜色和标注颜色永远一致不要试图用“肉眼调色”。5.2 模型没开eval()BatchNorm把每张图的输出搞得不一样现象同一个 checkpoint训练时验证 mIoU 还有 58单独推理时同一张图输出却有大量杂点且每次跑结果还不一样。原因模型还在 model.train() 模式。BN 层在 train 模式下会用当前 batch 的均值和方差做归一化推理时数据是一张张进来的batch 统计量反映的只是这一张图的特性如果模型里有 Dropouttrain 模式会随机砍一部分神经元输出自然飘。解决推理前必须 model.eval()并且包在 torch.no_grad() 里面。eval 模式下 BN 会切换到累计的 running_mean 和 running_var输出就正常了。这是 FCN 实战里最典型的一类“玄学”问题很多人看到输出乱码首先怀疑模型却忘了这行代码。另外注意如果推理脚本里用了 DataLoader 且 num_workers0多进程环境下每个 worker 都要共享同一个 model 实例的 eval 状态可以在 worker_init_fn 里也设置 model.eval()。5.3 输入尺寸不是32的倍数边缘出现锯齿和贴图错位现象对任意尺寸的测试图直接推理预测图边缘出现规律的锯齿部分区域像是被拉伸过。原因ResNet/FCN 骨干的 stride 总和是 32所以输入尺寸必须能被 32 整除。打印一下 shape 就会发现输入宽高一旦不是 32 倍数下采样后特征图被取整上采样时插值到原图尺寸就和真实位置对不上边缘尤其明显。解决推理前做 padding 到 32 的倍数推理后再 crop 回原尺寸。常见做法是右侧和下侧做零填充如果输入图巨大把它切成 512×512 的 patch 分别推理再拼接不要直接整图塞进去否则 GPU 直接 OOM。切图重叠 50 像素以上拼图时用重叠区域的平均概率代替 argmax边界会干净很多。5.4 背景类占95%像素mIoU虚高而Kappa难看现象遥感场景里房子只占图面 3%训练结束后 mIoU 是 0.62 听起来不错可 Kappa 系数只有 0.2业务方看了直摇头。为什么 mIoU 虚高因为背景类像素占比大而 mIoU 是所有类别 IoU 算数平均背景这一类就能把整体拉高。解决这时要按类别查看 IoU逐类别打印确认小目标类别的 IoU。训练阶段可以通过设置类别权重让 loss 对前景类更敏感比如给背景权重设 1、前景权重设 5~10。另一种办法是采样时对前景目标做约束训练数据里必须有这个 patch没有的 patch 跳过。验证指标建议两个都算mIoU 和 Kappa部署报告里写“整体 mIoU”容易被人一眼看穿写成逐类 IoU 表格才是硬功夫。5.5 loss变NaN或前期一直振荡怎么排查现象训练前几百步 loss 从 2.3 降到 1.6然后某一步直接 NaN或者 loss 上下跳跃方差极大batch size 已经往大调了还是不稳。原因常见原因有三个。一是标签里出现了超出 num_classes 的像素值比如把 255 当普通标签导致 CrossEntropy 索引越界二是学习率过高FCN 的分割头是随机初始化的直接用骨干的 0.01 学习率头部的梯度会爆炸三是数据归一化没做像素值直接 0~255 进网络激活值一开就很大。解决先看一眼数据集像素分布确认标签只有 0~20 和 255随后把分割头的 learning_rate 单独设成 0.01骨干用 0.001 去微调因为骨干已经收敛归一化做到 mean[0.485, 0.456, 0.406]std[0.229, 0.224, 0.225]。如果还 NaN查看最近几轮 loss 增量把 base_lr 缩小 10 倍重新训练基本不会再翻车。顺带说一个血泪教训保存断点时要同时保存 optimizer 状态和 epoch/iter不然从断点恢复后 poly_lr 会重新按第 0 轮算学习率突然又猛又陡训练出问题也难查。6. 把FCN用到遥感图像语义分割切片推理多尺度投票与验证6.1 多尺度推理让FCN在切片上更可靠现在模型稳定了推理时要拿更高精度。单尺度推理用一张原图直接预测多尺度则把图片缩放到多个尺度分别预测再把概率图叠到一起取平均最后 argmax。对遥感建筑这类多尺度目标0.5x、1.0x、1.5x 三个尺度通常够用。给出一个小函数import torch import torch.nn.functional as F def infer_multiscale(model, image, scales(0.5, 1.0, 1.5)): model.eval() _, H, W image.shape probs [] for s in scales: new_h, new_w int(H * s) // 32 * 32, int(W * s) // 32 * 32 resized F.interpolate( image.unsqueeze(0), size(new_h, new_w), modebilinear, align_cornersFalse ) with torch.no_grad(): logits model(resized)[out] prob torch.softmax(logits, dim1) prob F.interpolate(prob, size(H, W), modebilinear, align_cornersFalse) probs.append(prob) avg torch.stack(probs).mean(dim0) return avg.argmax(dim1).squeeze(0).cpu().numpy()逻辑说明每个尺度先 resize 到 32 的倍数乘 scale 后取整数再对齐到 32预测完把概率图插值回原图尺寸最后直接对概率做平均。这样比 argmax 平均更稳还能缓解模糊边缘的单尺度抖动。scales 列表不要贪多实测 0.5/1.0/1.5 和 0.6/1.0/1.4 差别不大尺度过多推理时间线性上涨工业环境会先算好推理预算再做取舍。6.2 切片拼接的后处理重叠与投票遥感图经常是几万乘几万的大幅影像一次性推理不可能我会用 1024×1024 窗口、128 像素重叠滑窗推理然后重叠区域用最大概率投票拼接。做过这个的都知道普通拼接缝极其明显重叠区域投票后缝隙几乎肉眼不可见。在拼接阶段需要把每个 patch 的概率图存储下来而不是只存 argmax否则投票无从谈起。存储用 np.float16 保存概率图内存能省一半。CRF 后处理如果还有余力对多尺度概率图做一次 DenseCRF可以把建筑边缘的毛刺磨掉一点。但 CRF 很慢1024×1024 的图一张要好几秒只有离线生产才推荐在线推理直接放弃。用 opencv-contrib 里带的条件随机场版本能提速但需要自行调参数。我的经验是先花时间做多尺度CRF 放到最后因为大多数项目多尺度收益已经足够。6.3 一个实在习惯先跑通后调优再算账我每次接到 FCN 任务都先用小数据集、小分辨率、少 epoch 先跑通全链路确认推理输出能与标签对上色再开始堆数据和调优。这个习惯救过我很多次有一次明明是训练代码里忘记做归一化我却在模型结构上折腾了两天后来回到“先跑通最小 case”的习惯十分钟就发现了问题。所以如果你正打算用 FCN 做语义分割建议第一步先把训练脚本缩到单卡小 batch 跑通第二步去验证一眼可视化结果第三步才开始调 poly_lr、多尺度、后处理。调参的时候一个变量一个变量改每次只改一个不然翻车都不知道哪一步引入的。做 FCN 和排障一样先把底打对再谈锦上添花。希望帮到你。本文还有配套的精品资源点击获取