什么是语义分割?
语义分割:对图像中每一个像素进行分类,确定每个点的类别
图像分类(image classification)
图像分类是通过各种方式,提取图像信息,判断图像属于哪一类。
语义分割 (Semantic Segmentation)
对图像中的每个像素进行分类。目标是实现像素级别的场景理解,精确描绘出每个物体的
轮廓与位置。
网络结构
FCN与普通卷积神经网络的区别如下图所示,普通卷积神经网络的最后层是全连接层用于判
别类别。FCN的最后层是卷积层,输出二维特征热图,用于判断每个像素的类别。
在基于 VGG16 的 FCN 论文中,作者将最后原本属于全连接层(FC6, FC7, FC8)的部分进行
了“卷积化”改造。
FC6 ->Conv6(7 * 7 的大卷积核)捕捉整张图的全局语义信息。
FC7 ->Conv7(1 * 1的卷积)增加非线性,进一步提取深层抽象特征。
FC8 ->Conv8(1 * 1的卷积)但它的输出通道数(Out Channels) 被设定为任务的类别数 N。
假设我们要区分 3 类物体:背景、水体、污泥。最后 Conv8 的输出维度是 (1, 3, H, W)。
第 1 层通道 (Channel 0): 记录了全图每个位置属于“背景”的分数。
第 2 层通道 (Channel 1): 记录了全图每个位置属于“水体”的分数。
第 3 层通道 (Channel 2): 记录了全图每个位置属于“污泥”的分数。
全卷积(Fully Convolution)
| 阶段 (Stage) | 关键操作(多个卷积和一个最大池化) | 输出特征图尺寸 (H×W×C) | 尺寸缩小倍数 |
| 输入层 | 原始图像 | 224* 224 *3 | - |
| Conv1 | 3 * 3 卷积 + MaxPool | 112* 112 *64 |
2x |
| Conv2 | 3 * 3 卷积 + MaxPool | 56* 56 *128 |
4x |
| Conv3 | 3 * 3 卷积 + MaxPool | 28* 28 *256 |
8x |
| Conv4 | 3 * 3 卷积 + MaxPool | 14* 14 *512 |
16x |
| Conv5 | 3 * 3 卷积 + MaxPool | 7* 7 *512 |
32x |
上采样
我们的语义分割任务是像素级别的预测任务,每个所对应的都有个类别,但是经过CNN网络之
后,会经历一系列的下采样,其shape大小还是无法匹配原来的图片。那么我们要做的就是经过上
采样来恢复图片的shape。
最近邻插值法
在图像处理中,当我们改变图像尺寸(比如将小图放大的上采样),新生成的像素点必须去原图
中寻找对应的色彩值。这个公式描述的就是从目标图(dst)坐标反推回原图(src)坐标的过程。
其中SrcX和SrcY代表原图像的位置,dstX和dstY代表上采样后的图像的位置。
其根本就是我们目标位置乘以缩放因子的值就是其在原图像的位置的值。举个例子就知道了,上采
样后的图像的(3,1)的值为原图像中的(3x(2/4),1x(2/4))=(1.5,0.5),采用四舍五入的规则,故其值为
(2,1)也就是原图像中橙色的位置,直接取橙色的位置的值即可。其上采样的原理计算都非常简单,
当然这所带来的后果就是生成的图像是非常粗糙的。
双线性插值
在讲双线性插值前,我们先来讲讲什么叫线性插值。
但是线性插值有个很大的弊端,就是我们只是通过单个维度取预测其值,会存在非常大的误差并且
偶然性也非常大。所以双线性插值就来了,我们通过两次插值来进行预测,就是通过多个维度来预
测从而减小误差。双线性插值是用原图像中4(2*2)个点计算新图像中1个点。
反卷积
负责上采样,把缩小的特征图恢复到原图大小,以便实现像素级的分类。
编辑
跳跃连接(Skip Connection)
我们的网络在进行特征提取的时候是从低级语义信息不断不断的进行提取到最后的输出的高级
语义信息的。网络的低层提取的语义信息更多代表了图像的纹理、边缘等一些显性的信息,网络的
高层所提取的一些语义信息更多的就是其数据核心的抽象的语义信息了。那我们最后进行语义分割
的特征图的语义信息肯定损失了很多关键的细节、边缘信息了,并且最后还会有上采样的过程,
现象就会更加加剧。我们想要最后进行语义分割也能够有些这些细节信息怎么办?
这就是跳跃连接了。想要低层语义信息,直接把低层的语义信息加回来不就好了,简单粗暴,
但是同样的也非常有效。其实我们仔细看看,是不是也就是残差网络的残差连接的形式,但是注意
哈,这个时候是还没有残差网络出现的哦。所以FCN也算是残差结构的前身了。
具体如何操作,以FCN-8s为例:
在进行逐点相加(Element-wise Add)之前,必须满足两个前提:尺寸相同且通道数相同。
尺寸控制: 靠 2 倍上采样(转置卷积)。
通道控制: 靠 1 *1卷积。在 FCN 论文中,作者会先对 pool4 和 pool3 进行 1* 1 卷积,直接把它
们的通道数压缩到目标类别数(比如 3)。
顶层降维: conv7(原本是 4096 维)经过一个 1*1 卷积,变成 (7, 7, 3)。
第一次融合(FCN-16s 路径):
将 conv7 的结果 2 倍上采样,变成 (14, 14, 3)。
关键点: 将 pool4 (14, 14, 512) 经过一个 1 * 1卷积,也变成 (14, 14, 3)。
两者逐点相加,得到融合特征 A (14, 14, 3)。
第二次融合(FCN-8s 路径):
将特征 A 进行 2 倍上采样,变成 (28, 28, 3)。
关键点: 将 pool3 (28, 28, 256) 经过一个 1 *1 卷积,也变成 (28, 28, 3)。
两者逐点相加,得到融合特征 B (28, 28, 3)。
最终输出:
将特征 B 进行 8 倍上采样,直接跳回 (224, 224, 3)。
最后进行 Softmax。(每个通道代表一个类别,里面都是0-1之间的数字)
现在的 (224, 224, 3) 特征图,可以看作是 3 层重叠的概率图(背景、水体、污泥)在通道
(Channel)维度上取最大值的索引。对于坐标 (x, y)处的像素如果 Channel_0 分数是 0.1
如果 Channel_1分数是 0.8
如果 Channel_2 分数是 0.1
结果: 该像素被判定为类别 1(水体)。
# 预测输出: [Batch, 3, 224, 224] # 真实标签: [Batch, 224, 224] (这里的每个像素是类别的索引,如0,1,2) loss = criterion(output, target)
预测标签:这就是我们刚才经过 Argmax 之后得到的东西。它是一个维度为 (224, 224) 的二维矩阵
(或者叫单通道图像)。矩阵里的每一个元素,都是一个离散的整数(索引)。如果模型认为左上
角那个像素是背景,那个位置的值就是 0。如果模型认为中间那个像素是水体,那个位置的值就是
1。这个 0、1、2 就是模型赋予给每个像素的预测标签。
训练时的“真实标签” (Ground Truth Label / Mask):它的维度也是绝对标准的 (224, 224),里面
的数值也是绝对正确的 0、1、2。在学术界,这种图通常被称为 Mask(掩膜)。
损失函数应该怎么计算的呐?
在语义分割任务(比如 FCN)中,计算损失函数(Loss)其实就是把图像分类的逻辑,在每一个
像素点上重复执行了几万次。因为我们是对每一个像素进行独立分类,所以最常用的损失函数就是
交叉熵损失(Cross Entropy Loss)。我们把一个像素点的 Loss 计算过程彻底拆解开来。
假设我们要把图片分为三类:0-背景,1-水体,2-污泥。
对于原图中的某一个像素点(比如坐标 (100, 100)),真实标签(Ground Truth)告诉我们:“这
个点是水体(标签为 1)”。但是,模型在经过所有卷积和上采样后,在 (100, 100) 这个位置输出
了三个通道的得分(Logits),比如:
通道 0(背景):1.5
通道 1(水体):2.8
通道 2(污泥):-0.5
手算交叉熵
模型输出的得分是没有上下限的,我们先用 Softmax 公式把它变成总和为 1 的概率分布:
这样的话,3个通道上的(100,100)位置上会有这三个表示概率的数字。
负对数似然(Negative Log Likelihood)
我们只看真实标签对应的那个概率。真实标签是水体,所以我们只盯死 76.4%(即 0.764)这个数
字。交叉熵 Loss 的公式极其简单:
全图的 Loss 是怎么算的?
刚才我们算了一个像素点的 Loss 是 0.269。
在一张 224* 224 的图片里,有 50176 个像素点。
全图的最终 Loss,就是把这 50176 个像素点的 Loss 全部加起来,然后求一个平均值
(Mean)。 这个平均值,就是反向传播时用来更新网络参数的那个核心数值。
网络结构代码
_make_bilinear_weights
定义双线性插值的权重初始化函数
size = 3卷积核尺寸它决定了你在做反卷积(上采样)时,一个像素点会被“放大并摊开”到多大的
区域。在反卷积操作中,原图里的 1 个像素点,会带着自身的权重,辐射到新图上一个 3 * 3 的正
方形范围内。
num_channels = 1 它决定了我们要准备几套一模一样的 3 * 3 矩阵。
factor=(3 + 1) // 2 = 2 center = 2 - 1 = 1 在一个坐标为 0, 1, 2 的长度为 3 的线段上,绝对中心点正
好落在坐标 1 上。
og = np.ogrid[:size, :size]
og会生成两个一维向量。
编辑
filt = (1 - abs(og[0] - center) / factor) * (1 - abs(og[1] - center) / factor)
# ========================================== # 拼图1:定义双线性插值的权重初始化函数 # (呼应博客理论:让反卷积层初始状态就具有双线性插值的能力) # ========================================== def _make_bilinear_weights(size, num_channels): """ 创建双线性插值的卷积核权重 """ factor = (size + 1) // 2 if size % 2 == 1: center = factor - 1 else: center = factor - 0.5 # 构造一个网格,根据距离中心的距离计算权重 og = np.ogrid[:size, :size] filt = (1 - abs(og[0] - center) / factor) * (1 - abs(og[1] - center) / factor) filt = torch.from_numpy(filt).float() # 生成权重矩阵 [out_channels, in_channels/groups, H, W] w = torch.zeros(num_channels, 1, size, size) w[:, 0, :, :] = filt return w
VGG16 Backbone
迁移学习(Transfer Learning) 它是用来“请外援”的。它负责去 PyTorch 官方库里,把已经身经百
战的 VGG16 模型下载下来,作为我们 FCN 网络的“眼睛”(骨干特征提取器)。
def get_backbone(backbone='vgg16', pretrained=True): if backbone == 'vgg16': # 使用 torchvision 自带的 VGG16 model = models.vgg16(pretrained=pretrained) return model else: raise NotImplementedError("目前只支持 vgg16")
完整代码
import torch import torch.nn as nn import torchvision.models as models import numpy as np # ========================================== # 拼图1:定义双线性插值的权重初始化函数 # (呼应博客理论:让反卷积层初始状态就具有双线性插值的能力) # ========================================== def _make_bilinear_weights(size, num_channels): """ 创建双线性插值的卷积核权重 """ factor = (size + 1) // 2 if size % 2 == 1: center = factor - 1 else: center = factor - 0.5 # 构造一个网格,根据距离中心的距离计算权重 og = np.ogrid[:size, :size] filt = (1 - abs(og[0] - center) / factor) * (1 - abs(og[1] - center) / factor) filt = torch.from_numpy(filt).float() # 生成权重矩阵 [out_channels, in_channels/groups, H, W] w = torch.zeros(num_channels, 1, size, size) w[:, 0, :, :] = filt return w # ========================================== # 拼图2:获取预训练的 VGG16 Backbone # ========================================== def get_backbone(backbone='vgg16', pretrained=True): if backbone == 'vgg16': # 使用 torchvision 自带的 VGG16 model = models.vgg16(pretrained=pretrained) return model else: raise NotImplementedError("目前只支持 vgg16") # ========================================== # 核心网络:FCN-8s 完整架构 # ========================================== class FCN8s(nn.Module): def __init__(self, num_classes=21, backbone='vgg16'): super(FCN8s, self).__init__() self.num_classes = num_classes # 加载特征提取网络 vgg_model = get_backbone(backbone=backbone, pretrained=True) features = list(vgg_model.features.children()) # 找到所有 MaxPool2d 层的位置,用于划分 Stage pool_indices = [i + 1 for i, layer in enumerate(features) if isinstance(layer, nn.MaxPool2d)] pool_indices = [0] + pool_indices + [len(features)] # 划分 5 个下采样阶段 self.stage1 = nn.Sequential(*features[pool_indices[0]:pool_indices[1]]) self.stage2 = nn.Sequential(*features[pool_indices[1]:pool_indices[2]]) self.stage3 = nn.Sequential(*features[pool_indices[2]:pool_indices[3]]) self.stage4 = nn.Sequential(*features[pool_indices[3]:pool_indices[4]]) self.stage5 = nn.Sequential(*features[pool_indices[4]:pool_indices[5]]) # 全卷积头 (替换原有的全连接层) self.fcn_head = nn.Sequential( nn.Conv2d(512, 4096, kernel_size=7, padding=3), nn.ReLU(inplace=True), nn.Dropout2d(), nn.Conv2d(4096, 4096, kernel_size=1), nn.ReLU(inplace=True), nn.Dropout2d(), nn.Conv2d(4096, self.num_classes, kernel_size=1), # 输出通道数为类别数 ) # 1x1 卷积,用于控制池化层的通道数,使其与类别数一致 self.pool3_score = nn.Conv2d(256, self.num_classes, kernel_size=1) self.pool4_score = nn.Conv2d(512, self.num_classes, kernel_size=1) # 反卷积上采样层 self.upsample2_1 = nn.ConvTranspose2d(self.num_classes, self.num_classes, kernel_size=4, stride=2, padding=1, bias=False) self.upsample2_2 = nn.ConvTranspose2d(self.num_classes, self.num_classes, kernel_size=4, stride=2, padding=1, bias=False) self.upsample8 = nn.ConvTranspose2d(self.num_classes, self.num_classes, kernel_size=16, stride=8, padding=4, bias=False) # 初始化反卷积层的权重为双线性插值 for m in self.modules(): if isinstance(m, nn.ConvTranspose2d): m.weight.data.zero_() m.weight.data = _make_bilinear_weights(m.kernel_size[0], m.out_channels) def forward(self, x): # 记录原始输入尺寸,用于最后一步对齐 input_size = x.size()[2:] # 前向传播:提取特征 x = self.stage1(x) x = self.stage2(x) pool3 = self.stage3(x) pool4 = self.stage4(pool3) x = self.stage5(pool4) # 全卷积化 x = self.fcn_head(x) # ==================== 跳跃连接与融合 ==================== # 第一次融合:2倍上采样的深层特征 + 降维后的 pool4 x = self.upsample2_1(x) pool4_score = self.pool4_score(pool4) # 高级技巧:强制裁剪对齐尺寸,防止因池化计算带来的1像素误差 pool4_score = pool4_score[:, :, :x.size()[2], :x.size()[3]] x = x + pool4_score # 第二次融合:再进行2倍上采样 + 降维后的 pool3 x = self.upsample2_2(x) pool3_score = self.pool3_score(pool3) pool3_score = pool3_score[:, :, :x.size()[2], :x.size()[3]] x = x + pool3_score # ==================== 最终上采样 ==================== # 第三次:直接 8倍 上采样还原到接近原图大小 x = self.upsample8(x) # 最后一次裁剪,确保输出尺寸与原图绝对一致 x = x[:, :, :input_size[0], :input_size[1]] return x # ========================================== # 简单的测试脚本 (供读者验证) # ========================================== if __name__ == '__main__': # 假设输入是一张 224x224 的图片,做 3 分类任务(比如:背景、水体、污泥) dummy_input = torch.randn(1, 3, 224, 224) model = FCN8s(num_classes=3) output = model(dummy_input) print(f"输入图像尺寸: {dummy_input.shape}") print(f"预测输出尺寸: {output.shape}") # 预期输出: torch.Size([1, 3, 224, 224])
输入图像尺寸: torch.Size([1, 3, 224, 224])
预测输出尺寸: torch.Size([1, 1, 224, 224])