十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

FCN全卷积网络实现Cityscapes语义分割:从原理到实战

FCN全卷积网络实现Cityscapes语义分割:从原理到实战 1. 项目概述与核心难点1.1 这个项目到底在做什么FCN全称Fully Convolutional Network也就是全卷积网络是语义分割领域绕不开的基石模型。Cityscapes则是目前自动驾驶场景下最常用的街景分割数据集之一包含50个城市的城区道路影像标注了汽车、行人、交通标志、建筑、天空等30个类别。把这两个词放在一起本质上就是在做一件事用FCN对Cityscapes街景图像做逐像素分类让模型能判断图像里的每一个点属于哪个语义类别。我在第一次跑这个项目时最直观的感受是“看起来简单真正跑通需要踩不少坑”。FCN的核心思路是把传统分类网络的全连接层替换成卷积层这样网络就能接受任意尺寸输入并输出对应尺寸的分割图。Cityscapes的数据特点是图像分辨率高、标注精细、类别分布极不均衡在训练时的显存占用和类别均衡处理上都会给新手带来很大的挑战。这个项目适合谁适合已经了解CNN基础、想系统进入语义分割领域的同学。它能帮助你理解卷积特征图的空间分辨率变化、感受野对分割结果的影响以及上采样方式对边缘细节的作用。就算你以后转向DeepLab、UNet或者Transformer分割模型FCN打下的这些底子一样用得上。1.2 为什么选择Cityscapes作为训练数据集Cityscapes之所以成为这个项目的主流选择有三个原因。第一它的标注质量极高像素级标注由专业团队完成并且提供了fine和coarse两套标注fine标注用于正式训练coarse标注用于探索性实验。第二它的场景复杂度高包含城市街道上的各种交通参与者、道路设施、天空、建筑等类别之间有明显的尺度差异和遮挡关系非常适合检验分割模型的鲁棒性。第三它已经成为自动驾驶视觉感知的事实基准绝大多数论文都会在这个数据集上报告精度选择它便于和其他工作对比。当然Cityscapes也有让人头疼的地方单张图像分辨率达到2048x1024如果用原图直接训练主流消费级显卡几乎撑不住一般需要做缩放或裁剪处理。而且数据集的标注类别中像“摩托车”“自行车”这类小目标样本量远少于“道路”“建筑”如果你不做类别权重处理训练出来的模型会对常见类别严重偏置。这两个问题我会在后面详细展开。2. 核心原理与设计思路拆解2.1 FCN的结构演进与设计动机FCN的原始论文发表于CVPR 2015解决了当时“CNN只能做图像分类、无法输出像素级结果”的问题。作者的方法是把VGG16等分类网络最后的全连接层替换为1x1卷积让网络变成全卷积结构。这样做的好处有两个一是输入图像的尺寸不再受限二是保留的特征图自带空间位置信息可以直接用于逐像素预测。不过只替换全连接层还不够。VGG16经过五次池化后特征图尺寸缩小为输入的1/32直接在这个分辨率上预测分割结果边缘非常粗糙。FCN论文提出了跳跃结构skip architecture把浅层的高分辨率特征图与深层的语义特征图融合得到FCN-32s、FCN-16s、FCN-8s三个版本。其中FCN-8s融合了pool3和pool4的特征分割边缘明显更精细也是实际项目中最常用的版本。我在自己的实现中同样采用这种编码器-解码器思路。编码器负责提取语义信息解码器负责恢复空间分辨率。用VGG16作为编码器时感受野大、分类能力强但参数量大、速度偏慢用ResNet作为编码器时可以通过空洞卷积dilated convolution在保持分辨率的同时扩大感受野。实际做Cityscapes训练时我更推荐后者因为Cityscapes图像中的小目标较多单纯依赖VGG16的大 stride 会丢失很多细节。2.2 关键技术点上采样、跳跃结构与损失函数FCN里的解码器主要由转置卷积Transposed Convolution构成这也是整个网络最容易被误解的部分。转置卷积并不是“逆卷积”它本质上也是一种卷积操作只是在计算时先通过填充把特征图尺寸扩大再做普通卷积。它有一个明显缺点会产生棋盘格效应因为卷积核在不同位置重叠时叠加权重不一致。业内常用的替代方案是双线性插值上采样加3x3卷积或者用PixelShuffle。我在自己的项目中出于稳定性和复现容易度的考量仍然使用双线性插值配合卷积层。损失函数方面FCN论文和大多数实现都直接使用交叉熵损失。但Cityscapes的类别分布很不均衡直接使用标准交叉熵会偏向样本量大的类别。一个非常有效的做法是给每个类别加上权重权重可以通过各类别像素频率的倒数来设定或者在验证集上先统计一次类别分布再结合中位数频率平衡法设置。实际操作中我会在损失函数里加入ignore_index参数把无法标注的区域如车辆玻璃反光、人物轮廓边缘直接忽略避免干扰训练。跳跃结构在FCN-8s中的实现顺序是将pool5特征上采样2倍与pool4特征相加然后整体上采样2倍与pool3特征相加最后再上采样8倍得到与输入同尺寸的预测图。在实现时普通读者最容易搞混的是“特征相加”而不是“特征拼接”。FCN原文用的是逐元素相加这种方式计算量小但信息融合能力有限后来的UNet等模型改用通道拼接效果更好但显存开销更大。如果你的显存吃紧FCN这种相加方式反而是一种优势。3. 实操过程与核心环节实现3.1 环境准备与数据集预处理开始之前先把训练环境列出来我的代码是基于PyTorch实现的。硬件建议至少12GB显存我自己的实验用的是24GB的显卡因为Cityscapes原图太大即使缩放到1024x512batch size设为4时显存占用也已经接近12GB。如果你的显存不够有几个降级方案降低输入分辨率、减小batch size、使用梯度累积或者直接裁剪成固定patch训练。Cityscapes数据集下载需要去官网注册申请下载后把leftImg8bit_trainvaltest原图和gtFine_trainvaltest标注解压到同一个目录。训练数据中每张图像都对应一个*_gtFine_labelIds.png文件这个文件里每个像素的值就是类别ID直接用PIL库的Image.open读取即可。我建议在数据加载阶段写一个缓存机制把图像路径、标注路径和对应城市的列表提前存成json避免每次训练都遍历目录。预处理流程上我做了这样几步第一步把训练图像和标注图统一缩放到1024x512然后用随机水平翻转、随机缩放、颜色抖动做数据增强。第二步在加载标注图时把原始labelId映射到训练类别ID因为Cityscapes的原始标注有34个类别其中很多属于ignore区域例如“ego vehicle”和“rectification border”。我自己写了一个映射表训练时只保留默认的19类。第三步把图像归一化到[0,1]区间使用ImageNet的均值和标准差做标准化。3.2 模型搭建与训练配置下面给出一个可运行的FCN-8s核心代码编码器部分使用ResNet101的预训练权重解码器部分是转置卷积上采样关键在于把ResNet的layer4输出作为深层特征同时引出layer3和layer2的特征做跳跃融合。这是我在自己的训练代码中常用的写法。import torch import torch.nn as nn import torchvision.models as models class FCN8s(nn.Module): def __init__(self, num_classes19, pretrainedTrue): super().__init__() resnet models.resnet101(pretrainedpretrained) # 按ResNet的stage拆解 self.layer0 nn.Sequential( resnet.conv1, resnet.bn1, resnet.relu, resnet.maxpool ) self.layer1 resnet.layer1 self.layer2 resnet.layer2 self.layer3 resnet.layer3 self.layer4 resnet.layer4 # 解码部分 self.score_4 nn.Conv2d(2048, num_classes, kernel_size1) self.score_3 nn.Conv2d(1024, num_classes, kernel_size1) self.score_2 nn.Conv2d(512, num_classes, kernel_size1) self.upsample_2x nn.Upsample(scale_factor2, modebilinear, align_cornersTrue) self.upsample_8x nn.Upsample(scale_factor8, modebilinear, align_cornersTrue) def forward(self, x): x self.layer0(x) s2 self.layer1(x) # 1/4 s3 self.layer2(s2) # 1/8 s4 self.layer3(s3) # 1/16 s5 self.layer4(s4) # 1/32 score_5 self.score_4(s5) # 1/32 score_5 self.upsample_2x(score_5) # 1/16 score_4 self.score_3(s4) # 1/16 merge_4 score_5 score_4 merge_4 self.upsample_2x(merge_4) # 1/8 score_3 self.score_2(s3) # 1/8 merge_3 merge_4 score_3 out self.upsample_8x(merge_3) # 原图尺寸 return out训练配置方面我用SGD优化器初始学习率设为0.01动量为0.9权重衰减为0.0001并且配合Poly学习率衰减策略。所谓Poly策略就是实际学习率等于初始学习率乘以(1 - iter / total_iter) ^ 0.9。在语义分割任务里这个策略几乎是最普遍的选择因为它在训练后期能平稳降低学习率避免在Loss曲面底部震荡。batch size设为8输入分辨率1024x512大约迭代80个epoch后验证集mIoU能做到65%到70%之间如果换成VGG16编码器精度会低2到3个百分点。3.3 训练过程的显存优化与监控训练Cityscapes最现实的问题就是显存。很多人上来就把batch size设为16然后发现显卡直接OOM。我的建议是优先把输入分辨率从1024x512降到768x384分辨率降低对最终精度的影响通常小于1%但显存占用可以减少40%。如果仍然OOM再使用梯度累积也就是每4个batch累积一次梯度等效于把batch size扩大4倍但不会增加显存占用。训练过程中我习惯每500步打印一次loss和当前学习率每1个epoch在验证集上评估一次mIoU。验证集评估需要注意两点一是验证时关闭数据增强尤其不能做随机翻转否则结果不稳定二是验证时使用torch.no_grad()减少显存占用并且把模型切到eval()模式。我在第一次实验时忘了切模式BatchNorm的统计量一直在更新验证指标忽高忽低排查了很久才发现是这个问题。3.4 训练结果评估与量化分析评估语义分割模型最核心的指标是mIoU意思是每个类别的预测结果与真实标注的交并比再对所有类别求平均。除了mIoU我还会同时看Pixel Accuracy和每个类别的IoU因为mIoU高并不代表所有类别都好它可能被常见类别拉高而小目标的IoU依然一塌糊涂。下面是我在Cityscapes验证集上跑出来的一个典型结果模型是ResNet101编码器的FCN-8s输入分辨率1024x512训练80个epoch。类别IoUroad97.1sidewalk80.5building90.3wall51.2fence55.4pole55.8traffic light56.1traffic sign70.3vegetation91.2terrain56.6sky93.1person74.6rider47.8car87.2truck44.3bus55.9train25.1motorcycle52.0bicycle62.3这个结果非常典型。道路、建筑、植被、天空这类大尺度类别轻松超过85而train、truck、rider这些样本量少的类别只有20到50。这个差距说明两个问题一是类别不平衡确实严重影响小样本类别的表现二是FCN的上采样结构对细长物体和边缘的处理能力有限比如pole电线杆和fence围栏这类细长结构很容易断掉。要改善这些类别单纯堆训练时长没用更有效的办法是加大数据增强的力度或者在损失函数里提高这些类别的权重。4. 常见问题与排查技巧实录4.1 经典错误加载预训练权重时遇到的坑用ResNet或者VGG的预训练权重做初始化是语义分割训练中必不可少的一步。但有三个坑我每次都会提醒身边的朋友注意。第一个坑是编码器和预训练权重的state_dict键名不匹配如果你自己搭建的模型里卷积层命名和原版ResNet不一致直接load_state_dict会报错。解决办法是打印一下权重的键名逐一核对或者在加载时设置strictFalse然后手动确认缺失的参数是否只是最后的分类层。第二个坑是BatchNorm层的均值和方差。很多人加载预训练权重后直接在新数据集上用小的batch size做微调BatchNorm会因为batch size太小而计算不准加速收敛失败。我的习惯是冻结编码器的BatchNorm参数简单说就是加载预训练权重后把模型里所有BatchNorm设置为eval()模式只更新卷积层参数。这样labelled数据量不多时也能稳定训练。第三个坑是输入归一化。Cityscapes图像和ImageNet的颜色分布有差异但实践中仍然沿用ImageNet的均值和标准差做归一化是可行的。问题在于有人把归一化放在了数据增强之后、而又忽略了标注图不受归一化影响。标注图永远只能做几何变换不能做颜色变换否则类别ID被篡改训练过程几乎不可能收敛。这一点在做随机颜色抖动时特别容易踩中。4.2 显存不足和训练速度过慢的应对方案如果你的显存只有8GB训练Cityscapes原图分辨率会非常吃力。我尝试过几种方案按优先级排序如下第一把输入分辨率降低到640x320这能显著减少显存而且Cityscapes类别大多是大型物体和结构轻度降分辨率对边缘影响不大。第二使用混合精度训练PyTorch自带的torch.cuda.amp可以把显存占用再降低约30%而且现代显卡上速度几乎无损。第三把ResNet101换成ResNet50参数减少精度下降大约1到2个mIoU显存又能省下来一截。训练速度慢的问题往往不是因为显卡不够好而是dataloader的瓶颈。Cityscapes每张图像接近10MB如果每次训练迭代都做高清解码和实时缩放GPU会等CPU。我的做法是在数据加载时使用num_workers4以上并把数据集做成LMDB或者把图像预先缩放到训练分辨率训练时只做随机裁剪和翻转。此外确认dataloader的pin_memoryTrue这个设置能减少CPU到GPU的传输时间在数据读取中收益非常明显。4.3 分割结果出现网格状伪影的排查思路转置卷积上采样容易产生棋盘格伪影这是一个常见但让人头疼的问题。如果你在FCN-16s或FCN-8s里使用过转置卷积预测图放大后可能会看到明显的规则方块尤其在小目标边缘附近更明显。解决思路有两个第一个是直接换掉转置卷积像我前面代码里那样使用双线性插值上采样然后用一个3x3卷积调整特征。这个方法实现简单且伪影会消失。第二个是用固定初始化或统一卷积核尺寸的转置卷积让它学不到重叠权重不均匀的模式但实现复杂度高收益有限。还有一种伪影不是来自网络结构而是来自跳跃结构里的特征分辨率不一致。比如你直接上采样两倍后和另一个特征相加如果两个特征图的感受野差异太大融合后会在某些区域产生条纹。遇到这种情况可以先打印各层特征图的尺寸和数值范围确认特征没有被归一化到奇怪的范围再检查融合操作有没有对齐通道数。一般来说排查伪影问题时先看网络结构再看数据预处理省时很多。4.4 训练不收敛的快速诊断思路我在自己项目里遇到过loss不下降、甚至震荡上升的情况。这里分享一个非常有效的排查流程。第一步检查数据加载是否正常最简单的方式是把一个batch的输入和标注直接可视化确认图像与标注一一对应颜色通道没有错位类别ID没有越界。第二步检查损失函数计算是否正确Cityscapes标注中很多像素值是255如果在计算交叉熵前没有设置ignore_index255loss会被这些无效像素带偏。第三步检查学习率是否过大语义分割的学习率通常比分类网络低一个数量级用0.01的初始学习率配合poly策略没问题但如果batch size较小学习率就应该相应调低。另外一个容易忽略的细节是在训练初期应该观察梯度范数。如果梯度范数突然爆炸通常是因为数据里有异常值或者学习率过大。我习惯在代码里加一行梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), 10.0)这个操作在训练初期能避免很多灾难性的参数更新。很多初学者觉得梯度裁剪是“作弊”其实它就是给训练过程加了安全带语义分割中非常常用。4.5 提升精度的实际技巧最后聊一聊在FCN框架下还能做哪些改进。如果你已经跑通了baseline接下来优先级最高的动作是数据增强。Cityscapes本身只有几千张带标注的图片不做增强模型非常容易过拟合。我用得最顺手的增强组合是随机水平翻转、随机缩放0.5到2.0、随机亮度对比度调整、随机裁剪。其中随机缩放对街景分割特别有效因为城市里的物体会以不同尺度出现缩放增强能提升模型对尺度变化的鲁棒性。第二个有效技巧是使用多尺度推理也就是预测时把输入图像缩放成多个尺寸分别得到预测图再取平均。这个做法能让mIoU提升1到2个百分点代价是推理时间变长。我在做最终结果汇报时都会用这一招但线上实时推理时则不启用因为Cityscapes场景下的实际部署更看重速度。第三个技巧是训练时用更大的输入分辨率验证时用原分辨率。很多语义分割项目都会遇到训练和验证分辨率不一致的情况只要数据增强足够充分模型是不会因为它而崩掉的。按照我上面这些步骤从环境配置到最终评估一个能稳定跑到65以上mIoU的城市街景分割模型就可以顺利落地。说实话FCN在今天看起来已经不算先进但把它在Cityscapes上完完整整跑一遍你对语义分割的各个环节理解会非常牢固后续做任何分割方向都有底气。
返回列表