SegNet(2016):一种用于图像分割的深度卷积编码器解码器架构

SegNet(2016):一种用于图像分割的深度卷积编码器解码器架构

导出时间:2025/11/23 20:31:06

1、什么是语义分割,什么是FCN


我们提出了一种新颖且实用的深度全卷积神经网络架构,用于语义像素级分割,命名为SegNet。

语义分割是指为图像中的每个像素分配一个类别标签(如道路、天空、汽车),以实现对场景的细粒度理解。
全卷积神经网络(Fully Convolutional Network,简称 FCN)于2015提出。是深度学习用于语义分割领域的开山之作,在传统卷积神经网络(CNN)的基础上进行了改进,传统 CNN 主要用于图像分类任务。它的结构通常包括卷积层、池化层和全连接层。在图像分类中,CNN 的输出是一个固定大小的向量(例如,1000 类分类任务的输出向量长度为 1000),表示输入图像属于各个类别的概率分布。
全连接层的输入大小是固定的,这意味着输入图像的大小也必须固定。此外,全连接层的参数量巨大,导致模型复杂度高且难以扩展到像素级任务。
全卷积神经网络的核心思想是将传统 CNN 中的全连接层替换为卷积层,实现对任意大小输入图像的像素级处理。
image.png
如fig1所示,全卷积网络先使用卷积神经网络抽取图像特征,然后通过卷积层将通道数变换为类别个数,最后再通过转置卷积层将特征图的高和宽变换为输入图像的尺寸。因此,模型输出与输入图像的高和宽相同,且最终输出通道包含了该空间位置像素的类别预测
image.png

2、SegNet架构简介


该核心可训练分割引擎由编 码器网络、对应的解码器网络以及像素级分类层组成。编码器网络的拓扑结构与VGG16网络[1]中的13个卷积层完全一致。解码器网络 的作用是将低分辨率编码器特征图映射为全输入分辨率特征图,以便进行像素级分类

SegNet 的核心就是一个“分割引擎”,它由三部分组成:
  1. 编码器网络(Encoder):负责提取图像的特征。
  2. 解码器网络(Decoder):负责把提取到的特征恢复成和输入图像一样大的特征图。
  3. 像素级分类层(Pixel-wise Classification Layer):负责给每个像素分配一个类别(比如“这是道路”“这是车辆”“这是行人”)。
编码器网络(Encoder)
编码器网络的结构和 VGG16 网络中的前 13 个卷积层一模一样。你可以把 VGG16 想象成一个“特征提取器”,它通过一层一层的卷积操作,把输入的图像变成一个低分辨率的特征图。这个特征图虽然小,但包含了图像的关键信息。
简单来说,编码器的工作就是:
  • 输入一张大图像(比如 224×224)。
  • 经过 13 层卷积操作,输出一个很小的特征图(比如 7×7)。
  • 这个特征图虽然小,但包含了图像的“精华”信息。
解码器网络(Decoder)
解码器的作用是把编码器输出的低分辨率特征图恢复成和输入图像一样大的特征图。这个过程有点像“放大镜”,把小图变成大图。
解码器的工作流程是:
  • 上采样(Upsampling):解码器利用编码器阶段保存的最大池化索引,把小特征图“放大”成大特征图。这个过程是非线性的,不需要学习额外的参数。
  • 稀疏特征图(Sparse Feature Map):上采样后的特征图是稀疏的,只有部分位置有值,其他位置是零。
  • 卷积操作(Convolution):通过卷积操作,把稀疏特征图变成密集特征图。这个过程会让特征图更加“完整”,为后续的分类做好准备。
像素级分类层(Pixel-wise Classification Layer)
最后一步是像素级分类。解码器输出的高分辨率特征图包含了每个像素的特征信息。像素级分类层的任务是:
  • 对每个像素的特征进行分类,判断它属于哪个类别(比如“道路”“车辆”“行人”)。
  • 输出一个和输入图像一样大的分割图,每个像素都有一个类别标签。

3、SegNet创新点

SegNet的创新点在于解码器对低分辨率输入特征图进行上采样的方式。具体而言,解码器利用编码器对应步骤中计算的最大池化索引进行非线性上采样,从而无需学习上采样过程。 上采样后的特征图具有稀疏性,随后通过与可训练滤波器卷积生成密集特征图。

优势总结

  1. 无需学习上采样参数:
    • 传统方法需要学习额外的参数来进行上采样,而 SegNet 直接利用编码器阶段保存的索引信息进行上采样,避免了学习上采样参数的复杂性和计算开销。
  2. 保留更多细节信息:
    • 通过最大池化索引进行上采样,能够更准确地恢复特征图的细节信息,因为它是基于原始特征图中的最大值位置进行恢复的,而不是通过插值或其他近似方法。
  3. 稀疏特征图的高效性:
    • 稀疏特征图在存储和计算上更加高效,因为只有部分位置有非零值。通过卷积操作生成密集特征图后,这些稀疏特征图可以被有效地转换为适合分类的密集特征图。

1. 背景:为什么需要上采样?

在 SegNet 中,编码器部分会把输入图像逐步压缩成低分辨率的特征图,这样可以提取图像的核心特征。但是,为了进行像素级分割,我们需要把这些低分辨率的特征图恢复到和输入图像一样的分辨率,这样才能对每个像素进行分类。这个过程就叫“上采样”。

2. 传统方法的局限性

在传统的上采样方法中,比如反卷积(Deconvolution)或插值(Interpolation),通常需要学习额外的参数来完成上采样。这些方法的问题是:
  • 需要额外的参数:这些参数需要通过训练来学习,增加了模型的复杂性和训练时间。
  • 可能丢失细节:这些方法在恢复图像细节方面可能不够精确,导致分割结果不够准确。

4、对比

Deconvolutional Network
DeepLab-LargeFOV
DeepLab-LargeFOV 是一种用于语义分割的深度学习模型,属于 DeepLab 系列的早期版本。它的核心思想是通过使用空洞卷积(Atrous Convolution)来扩大感受野(Field of View)

1. 普通卷积(Normal Convolution)

先来说说普通的卷积。想象一下,你有一张照片,你想用一个小窗口(卷积核)在照片上滑动,每次滑动都计算一下窗口内的像素值,然后生成一个新的像素值。这个过程就像是用一个小刷子在照片上涂抹,每次涂抹只覆盖一小块区域。
举个例子:
  • 假设你有一个 3×3 的卷积核,它会在输入图像上滑动,每次计算一个 3×3 区域内的像素值,生成一个新的像素值。
  • 如果输入图像的分辨率是 224×224,经过普通卷积后,输出图像的分辨率会变小(比如 222×222),因为卷积核滑动时会丢失边缘信息。

2. 空洞卷积(Atrous Convolution)

空洞卷积和普通卷积有点像,但它有一个特别的地方:它会在卷积核中“跳过”一些像素。这就像是你在用一个小刷子涂抹时,故意跳过一些点,而不是连续地涂抹。
具体来说:
  • 膨胀系数(Dilation Rate):空洞卷积有一个参数叫膨胀系数(dilation rate)。膨胀系数决定了卷积核中像素之间的间隔。
  • 膨胀卷积核:如果膨胀系数是 1,空洞卷积就和普通卷积一样。但如果膨胀系数大于 1,卷积核就会“膨胀”,跳过一些像素。
举个例子:
  • 假设你有一个 3×3 的卷积核,膨胀系数是 2。那么,这个卷积核实际上会覆盖一个 5×5 的区域,但中间的像素是跳过的。
  • 具体来说,普通卷积的 3×3 卷积核是这样的:
  • 复制
1 2 3
4 5 6
7 8 9
  • 但膨胀系数为 2 的空洞卷积核覆盖的区域是这样的:
  • 复制
1 0 2 0 3
0 0 0 0 0
4 0 5 0 6
0 0 0 0 0
7 0 8 0 9
  • 其中,0 表示跳过的像素。

3. 空洞卷积的优势

  • 扩大感受野:普通卷积的感受野比较小,每次只能看到一个小区域。空洞卷积通过跳过像素,可以覆盖更大的区域,从而扩大感受野。这就好像是你用一个小刷子,但每次涂抹时可以跳过一些点,覆盖更大的范围。
  • 不丢失分辨率:普通卷积会导致输出图像的分辨率变小,但空洞卷积可以在不丢失分辨率的情况下扩大感受野。这就好像是你在用一个小刷子涂抹时,虽然跳过了一些点,但整体覆盖的范围更大了,而且不会丢失细节。
  • 计算效率高:空洞卷积通过跳过像素,减少了计算量,同时保持了输出图像的分辨率。


5、SegNet的背景和设计动机


  1. 语义分割的应用和背景
语义分割是一种技术,它的任务是把图像里的每个像素都标注出它属于什么类别(比如道路、建筑物、汽车、行人等)。这种技术用途很广,比如:
  • 场景理解:让计算机明白图像里都有哪些东西,它们之间的关系是什么。
  • 自动驾驶:帮助自动驾驶系统识别道路、行人、交通标志等。
  1. 早期方法和深度学习的突破
以前,人们主要用一些简单的视觉线索(比如颜色、纹理)来做语义分割,但这种方法效果不太好。后来,深度学习出现了,它在很多领域都取得了重大突破,比如:
  • 手写数字识别:识别手写的数字。
  • 语音处理:把语音转换成文字。
  • 图像分类:判断图像里是什么东西。
  • 物体检测:在图像里找到物体的位置。
深度学习的出现让语义分割的效果也有了很大的提升。
  1. 像素级标注的挑战
虽然深度学习在语义分割上取得了进步,但直接用传统的深度学习架构(比如VGG16)来做像素级标注还是有问题的。主要问题是:
  • 最大池化和下采样:这些操作会让特征图的分辨率变低,导致边界信息丢失。这在需要精确边界的地方(比如道路和人行道的分界线)就不太好了。
  1. SegNet的设计动机
SegNet就是为了解决这些问题而设计的。它的目标是:
  • 高效:既要节省内存,又要运行速度快。
  • 精确:能够精确地标注每个像素的类别,尤其是边界部分。
  • 端到端训练:通过随机梯度下降(SGD)等技术,一次性训练整个网络,这样更容易重复和优化。
  1. SegNet的设计细节

编码器(Encoder)

  • 结构:SegNet的编码器和VGG16的卷积层一模一样,但移除了VGG16的全连接层。这样参数量大大减少,训练起来也更容易。
  • 功能:提取图像的特征,但不会丢失太多细节。

解码器(Decoder)

  • 核心组件:解码器是SegNet的关键,它由多个解码器层组成,每个解码器层都对应一个编码器层。
  • 最大池化索引:解码器利用编码器记录的最大池化索引,对特征图进行上采样。这样可以恢复边界信息,让分割结果更精确。
  • 非线性上采样:通过这种方式,解码器可以把低分辨率的特征图还原成高分辨率的图像。
  1. SegNet的优势
  • 高效:参数量少,运行速度快,内存占用低。
  • 精确:通过记录最大池化索引,能够恢复边界信息,适合需要精确分割的任务。
  • 端到端训练:整个网络可以一次性训练,优化起来更方便。
  1. 应用场景
SegNet特别适合道路场景理解,比如:
  • 道路和建筑物:这些大类别的像素占大部分,需要生成平滑的分割结果。
  • 行人和小目标:即使目标很小,也能通过保留边界信息来精确分割。

6、模型解剖

编码器网络中的每个编码器通过滤波器组进行卷积运算, 生成一组特征图

  • 操作:编码器通过一组滤波器(也可以叫卷积核)在图像上滑动,每个滤波器会提取图像的某种特征(比如边缘、纹理等),最后生成一组特征图。
  • 举例:就像用不同的放大镜去看一幅画,每个放大镜(滤波器)会帮你看到画里不同的细节(特征)。

随后对这些特征图进行批量归一化处理

  • 操作:对生成的特征图进行批量归一化处理。
  • 目的:让特征图的数据分布更加“规整”,避免在训练过程中出现数值不稳定的情况,这样可以让训练过程更加顺利。
  • 举例:就像在比赛前把所有选手的起跑线都调整到同一个位置,这样比赛才会更公平。

然后应用元素级修正线性非线性函数

  • 操作:对特征图的每个元素应用ReLU函数,即如果元素的值大于0就保持不变,小于0就变成0。
  • 目的:给模型引入非线性,让模型能够学习更复杂的特征和模式。

随后执行最大池化操作,采用2×2窗口和步 长为2(非重叠窗口),并将输出结果进行2倍下采样。最大 池化用于实现对输入图像小范围空间位移的平移不变性。下 采样使得特征图中每个像素对应一个较大的输入图像上下文 (空间窗口)

  • 操作:用一个2×2的窗口在特征图上滑动,每次取窗口里最大的值,然后把特征图缩小2倍(步长为2)。
  • 目的:
    • 平移不变性:让模型对图像的小范围移动不那么敏感。比如,一个物体在图像里稍微移动了一下位置,模型仍然能认出来。
    • 提取上下文信息:下采样后,特征图中的每个像素对应了输入图像中一个更大的区域(空间窗口),这样可以让模型更好地理解图像的整体结构。
  • 举例:就像从远处看一幅画,你只能看到大致的轮廓,但看不到细节。不过这样也有好处,比如你可以更容易地看出画的整体布局。

因此我们提出了一种更高效的存储方案:仅需存储最大池化索引i。e、每个池化窗口中最 大特征值的位置信息会被存储在对应的编码器特征图中。从 原理上说,这种方法可以为每个2×2的池化窗口分配2位数 据,因此相比以浮点精度存储特征图

虽然最大池化和下采样有很多好处,但它们也会导致一个问题:特征图的空间分辨率降低,边界细节丢失。这在需要精确分割的任务(比如语义分割)中是不利的,因为我们需要明确的边界划分。

(1)存储所有特征图的方案

  • 方案:如果内存足够,可以存储所有经过下采样的特征图。
  • 问题:在实际应用中,内存通常是有限的,所以这个方案不太可行。

(2)存储最大池化索引的方案

  • 方案:只存储每个池化窗口中最大特征值的位置信息(索引),而不是存储整个特征图。
  • 优点:
    • 高效:每个2×2的池化窗口只需要2位数据来存储索引,相比存储浮点特征图,这种方式节省了很多内存。
    • 举例:假设一个2×2的窗口里有4个值,最大值的位置可以用2位二进制数来表示(00、01、10、11),这样存储起来非常节省空间。
  • 缺点:虽然这种方法节省了内存,但会导致精度略有损失。不过,后续的研究表明,这种损失在实际应用中是可以接受的。

7、源码讲解

#定义了一个裁剪函数,用于处理上采样后的特征图与跳跃连接特征图的尺寸对齐问题
'''
upsampled: 上采样后的特征图
bypass: 编码器对应阶段的特征图(跳跃连接)
'''
def crop(upsampled, bypass):
    #获取两个特征图的高度(h)和宽度(w)(PyTorch中特征图维度为[batch, channel, height, width])
    h1, w1 = upsampled.shape[2], upsampled.shape[3]
    h2, w2 = bypass.shape[2], bypass.shape[3]

    # 计算两个特征图在高度和宽度上的尺寸差异
    deltah = h2 - h1
    deltaw = w2 - w1

    # 计算填充的起始和结束位置
    # 对于高度
    pad_top = deltah // 2
    pad_bottom = deltah - pad_top
    # 对于宽度
    pad_left = deltaw // 2
    pad_right = deltaw - pad_left

    # 对 upsampled 进行中心填充
    upsampled_padded = F.pad(upsampled, (pad_left, pad_right, pad_top, pad_bottom), "constant", 0)

    return upsampled_padded

举个实际例子 🌰

假设:
  • 上采样后的图片(upsampled):高100像素,宽100像素
  • 编码器对应层的图片(bypass):高104像素,宽102像素

第一步:计算差距 📏

  • 高度差:104 - 100 = 4(需要补4行)
  • 宽度差:102 - 100 = 2(需要补2列)

第二步:分配补丁 🧩

想象给照片加相框:
  • 高度方向(上下加边框):
    • 上面补:4 ÷ 2 = 2
    • 下面补:4 - 2 = 2
  • 宽度方向(左右加边框):
    • 左边补:2 ÷ 2 = 1
    • 右边补:2 - 1 = 1

第三步:实际填充效果 🖼️

原始图片:[100×100] 填充后:
text
[上补2行]
[左补1列][100×100图片][右补1列]
[下补2行]
最终变成 104×102,和编码器图片尺寸一致
class SegNet(nn.Module):
    def __init__(self,num_classes=12):
        super(SegNet, self).__init__()
        self.encoder1 = nn.Sequential(
            nn.Conv2d(3,64,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(),
            nn.Conv2d(64,64,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(),
        )
        self.encoder2 = nn.Sequential(
            nn.Conv2d(64,128,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(),
            nn.Conv2d(128,128,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(),
        )
        self.encoder3 = nn.Sequential(
            nn.Conv2d(128,256,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(256),
            nn.ReLU(),
            nn.Conv2d(256,256,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(256),
            nn.ReLU(),
            nn.Conv2d(256,256,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(256),
            nn.ReLU(),
        )
        self.encoder4 = nn.Sequential(
            nn.Conv2d(256,512,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU(),
            nn.Conv2d(512,512,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU(),
            nn.Conv2d(512,512,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU(),
        )
        self.encoder5 = nn.Sequential(
            nn.Conv2d(512,512,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU(),
            nn.Conv2d(512,512,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU(),
            nn.Conv2d(512,512,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU(),
        )

        self.decoder1 = nn.Sequential(
            nn.Conv2d(512,512,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU(),
            nn.Conv2d(512,512,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU(),
            nn.Conv2d(512,512,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU(),
        )
        self.decoder2 = nn.Sequential(
            nn.Conv2d(512,512,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU(),
            nn.Conv2d(512,512,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU(),
            nn.Conv2d(512,256,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(256),
            nn.ReLU(),
        )
        self.decoder3 = nn.Sequential(
            nn.Conv2d(256,256,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(256),
            nn.ReLU(),
            nn.Conv2d(256,256,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(256),
            nn.ReLU(),
            nn.Conv2d(256,128,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(),
        )
        self.decoder4 = nn.Sequential(
            nn.Conv2d(128,128,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(),
            nn.Conv2d(128,64,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(),
        )
        self.decoder5 = nn.Sequential(
            nn.Conv2d(64,64,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(),
            nn.Conv2d(64,64,kernel_size=3,stride=1,padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(),
            nn.Conv2d(64,num_classes,kernel_size=1),
        )

        self.max_pool = nn.MaxPool2d(2,2,return_indices=True)
        self.max_uppool = nn.MaxUnpool2d(2,2)

        self.initialize_weights()

    def initialize_weights(self):
        for m in self.modules():
            if isinstance(m, nn.Conv2d) or isinstance(m, nn.ConvTranspose2d):
                nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
                if m.bias is not None:
                    nn.init.constant_(m.bias, 0)
            elif isinstance(m, nn.BatchNorm2d):
                nn.init.constant_(m.weight, 1)
                nn.init.constant_(m.bias, 0)

    def forward(self, x):
        #通过encoder1得到特征图x1,最大池化,保存池化索引pool_indices1用于后续反池化

        x1 = self.encoder1(x)
        x,pool_indices1 = self.max_pool(x1)
        x2 = self.encoder2(x)
        x,pool_indices2 = self.max_pool(x2)
        x3 = self.encoder3(x)
        x,pool_indices3 = self.max_pool(x3)
        x4 = self.encoder4(x)
        x,pool_indices4 = self.max_pool(x4)
        x5 = self.encoder5(x)
        x,pool_indices5 = self.max_pool(x5)
        #@开始解码:使用存储的索引进行反池化,裁剪使尺寸与对应编码层特征匹配
        x = self.max_uppool(x,pool_indices5)
        x = crop(x, x5)
        x = self.decoder1(x)
        x = self.max_uppool(x,pool_indices4)
        x = crop(x, x4)
        x = self.decoder2(x)
        x = self.max_uppool(x,pool_indices3)
        x = crop(x, x3)
        x = self.decoder3(x)
        x = self.max_uppool(x,pool_indices2)
        x = crop(x, x2)
        x = self.decoder4(x)
        x = self.max_uppool(x,pool_indices1)
        x = crop(x, x1)
        x = self.decoder5(x)
        #对称的解码过程,逐步恢复空间分辨率.返回最终的分割结果(每个像素点的类别预测)
        return x
模型像两个对称的"沙漏"(编码器+解码器):

1️⃣ 编码器(下采样)

  • 作用:像榨汁机一样逐步提取图像特征
  • 5个阶段的卷积块(类似VGG16):
    • 每阶段包含:卷积 → 批归一化 → ReLU 的重复组合
    • 通道数变化:3 → 64 → 128 → 256 → 512
  • 最大池化:每次缩小一半尺寸(记下最大值位置)

2️⃣ 解码器(上采样)

  • 作用:把特征图"还原"到原图尺寸
  • 与编码器对称的5个阶段:
    • 最大反池化:利用之前记录的池化位置精确还原
    • 裁剪对齐:通过crop()函数调整尺寸
    • 最后用1x1卷积输出分类结果(通道数=类别数)
  1. 池化索引保留
    • 普通CNN池化会丢失位置信息,而SegNet记录最大值位置,上采样时能更精确还原边界(类似拼图时记住碎片原本位置)

    举个例子:用SegNet处理街景时,它能准确区分"路面-人行道-车辆-行人"的边界,而普通CNN可能只会框出物体位置。