计算机视觉入门到实战系列(二十二)语义分割之FCN-8s算法
语义分割之FCN-8s算法
- 加载ResNet101
- 实现FCN模型
- 初始化部分
- 前向传播过程(关键!)
- 双线性核
- 公式解释:
- 示例(kernel_size=4):
我们选择ResNet101作为模型的主干网络.之前我们已经实现了ResNet34,这里我们不从零实现ResNet101了,而是直接从模型库里面加载。这里需要注意一般我们考虑两个方面通道数和分辨率,从这两个绝度去思考下面的转化。通道数和分辨率是两码事不要混淆。
加载ResNet101
from torchvision import models
# 使用 ResNet101 作为主干网络,模型使用ImageNet预训练
pretrained_net = models.resnet101(pretrained='imagenet')
实现FCN模型
- 网络架构概述
FCN-8s 是一个全卷积网络(Fully Convolutional Network),用于语义分割任务。它的核心思想是:
- 用卷积层替换全连接层,实现任意尺寸输入
- 使用跳跃连接融合多尺度特征
- 通过转置卷积恢复空间分辨率
- 架构图解析
输入图像
↓
ResNet-101 (stage1) → 1/8分辨率特征 (512通道) → scores3 → 特征s1
↓
ResNet层 (stage2) → 1/16分辨率特征(1024通道) → scores2 → 特征s2
↓
ResNet层 (stage3) → 1/32分辨率特征(2048通道) → scores1 → 特征s3
↓ ↑
└─────→ 2倍上采样(upsample_2x) ──────────────┘
↓
└─────→ 相加(s2 + s3) ←───────────────────── 特征s2
↓
└─────→ 2倍上采样(upsample_4x) ←───────────── 特征s1
↓
└─────→ 相加(s1 + s2)
↓
└─────→ 8倍上采样(upsample_8x)
↓
输出分割图
完整代码实现
import torch.nn as nn
# FCN-8s模型
class FCN8s(nn.Module):
def __init__(self, num_classes):
super(FCN8s, self).__init__()
# 这里使用children()调用ResNet101的部分网络
# 该深度特征图的大小为输入图像的1/8
self.stage1 = nn.Sequential(*list(pretrained_net.children())[:-4])
# 该深度特征图的大小为输入图像的1/16
self.stage2 = list(pretrained_net.children())[-4]
# 该深度特征图的大小为输入图像的1/32
self.stage3 = list(pretrained_net.children())[-3]
# 调整stage3输出的通道数
self.scores1 = nn.Conv2d(2048, num_classes, 1)
# 调整stage2输出的通道数
self.scores2 = nn.Conv2d(1024, num_classes, 1)
# 调整stage1输出的通道数
self.scores3 = nn.Conv2d(512, num_classes, 1)
# 对pool3与pool4、pool5的输出进行8倍上采样
self.upsample_8x = nn.ConvTranspose2d(
num_classes, num_classes, 16, 8, 4, bias=False)
self.upsample_8x.weight.data = bilinear_kernel(
num_classes, num_classes, 16) # 使用双线性 kernel
# 对pool4和pool5融合的特征2倍上采样
self.upsample_4x = nn.ConvTranspose2d(
num_classes, num_classes, 4, 2, 1, bias=False)
self.upsample_4x.weight.data = bilinear_kernel(
num_classes, num_classes, 4) # 使用双线性 kernel
# 对pool5的输出2倍上采样
self.upsample_2x = nn.ConvTranspose2d(
num_classes, num_classes, 4, 2, 1, bias=False)
self.upsample_2x.weight.data = bilinear_kernel(
num_classes, num_classes, 4) # 使用双线性 kernel
def forward(self, x):
x = self.stage1(x)
# s1 1/8
s1 = x
x = self.stage2(x)
# s2 1/16
s2 = x
x = self.stage3(x)
# s3 1/32
s3 = x
# 调整pool5输出特征图的通道数
s3 = self.scores1(s3)
# 进行两倍上采样
s3 = self.upsample_2x(s3)
# 调整pool4输出特征图的通道数
s2 = self.scores2(s2)
# 融合pool5、pool4的特征图
s2 = s2 + s3
# 调整pool3输出特征图的通道数
s1 = self.scores3(s1)
# 将s2两倍上采样
s2 = self.upsample_4x(s2)
# 融合特征图
s = s1 + s2
# 8倍上采样得到与原图像大小一致的特征图
s = self.upsample_8x(s2)
return s
初始化部分
3.1 初始化部分
def __init__(self, num_classes):
super(FCN8s, self).__init__()
3.2 特征提取器(使用预训练ResNet101)
# 这里使用children()调用ResNet101的部分网络
self.stage1 = nn.Sequential(*list(pretrained_net.children())[:-4])
# 该深度特征图的大小为输入图像的1/8
self.stage2 = list(pretrained_net.children())[-4]
# 该深度特征图的大小为输入图像的1/16
self.stage3 = list(pretrained_net.children())[-3]
# 该深度特征图的大小为输入图像的1/32
ResNet101的分层结构:
ResNet101.children() 包含:
[0]: 初始卷积层 (conv1) 7×7, stride=2, padding=3 → 输出: 1/2 (256×256)
[1]: 批量归一化 (bn1) → 输出: 1/2 (256×256)
[2]: ReLU激活函数 → 输出: 1/2 (256×256)
[3]: 最大池化 (maxpool) 3×3, stride=2 → 输出: 1/4 (128×128)
[4]: layer1 (包含3个残差块) → 输出: 1/4 (128×128) ← 注意:没有下采样!
[5]: layer2 (包含4个残差块) → 输出: 1/8 (64×64) ← stage1 结束在这里!
[6]: layer3 (包含23个残差块) → 输出: 1/16 (32×32) ← stage2
[7]: layer4 (包含3个残差块) → 输出: 1/32 (16×16) ← stage3
[8]: 平均池化 (avgpool) → 输出: 1×1
[9]: 全连接层 (fc) → 输出: 1000维向量
3.3 1×1卷积调整通道数
# 调整stage3输出的通道数 (2048 → num_classes)
self.scores1 = nn.Conv2d(2048, num_classes, 1)
# 调整stage2输出的通道数 (1024 → num_classes)
self.scores2 = nn.Conv2d(1024, num_classes, 1)
# 调整stage1输出的通道数 (512 → num_classes)
self.scores3 = nn.Conv2d(512, num_classes, 1)
ResNet101通道数变化
输入图像: 3通道 (RGB)
↓ conv1: 64通道
↓ maxpool: 64通道
↓ layer1: 256通道 ← 注意:不是512!
↓ layer2: 512通道 ← stage1结束(1/8分辨率)
↓ layer3: 1024通道 ← stage2结束(1/16分辨率)
↓ layer4: 2048通道 ← stage3结束(1/32分辨率)
作用:将特征图的通道数从特征维度调整为类别数,为后续分类做准备。
3.4 转置卷积上采样层
# 对pool3与pool4、pool5的输出进行8倍上采样
self.upsample_8x = nn.ConvTranspose2d(
num_classes, num_classes, 16, 8, 4, bias=False)
# 对pool4和pool5融合的特征2倍上采样
self.upsample_4x = nn.ConvTranspose2d(
num_classes, num_classes, 4, 2, 1, bias=False)
# 对pool5的输出2倍上采样
self.upsample_2x = nn.ConvTranspose2d(
num_classes, num_classes, 4, 2, 1, bias=False)
参数解释:
nn.ConvTranspose2d(num_classes, num_classes, kernel_size, stride, padding)- 转置卷积:也称为反卷积,用于上采样
- 双线性插值初始化:让上采样过程更平滑
3.5 双线性核初始化
self.upsample_8x.weight.data = bilinear_kernel(num_classes, num_classes, 16)
self.upsample_4x.weight.data = bilinear_kernel(num_classes, num_classes, 4)
self.upsample_2x.weight.data = bilinear_kernel(num_classes, num_classes, 4)
作用:使用双线性插值初始化转置卷积的权重,这样网络在初始阶段就能产生合理的上采样结果。
前向传播过程(关键!)
4.1 第一阶段:提取多尺度特征
def forward(self, x):
x = self.stage1(x) # 得到1/8分辨率特征 (512通道)
s1 = x # 保存为s1
x = self.stage2(x) # 得到1/16分辨率特征 (1024通道)
s2 = x # 保存为s2
x = self.stage3(x) # 得到1/32分辨率特征 (2048通道)
s3 = x # 保存为s3
4.2 融合最深层特征(pool5 + pool4)
# 1. 处理最深层的特征s3 (1/32分辨率)
s3 = self.scores1(s3) # 2048通道 → num_classes通道
s3 = self.upsample_2x(s3) # 2倍上采样 → 1/16分辨率
# 2. 处理中间层特征s2 (1/16分辨率)
s2 = self.scores2(s2) # 1024通道 → num_classes通道
# 3. 融合s2和s3 (同分辨率,直接相加)
s2 = s2 + s3 # 融合1/16分辨率的特征
图示:
s3 (1/32) → scores1 → upsample_2x → (1/16)
↓
s2 (1/16) → scores2 → → → → → 相加 → 融合特征s2
4.3 融合浅层特征(pool3)
# 1. 处理浅层特征s1 (1/8分辨率)
s1 = self.scores3(s1) # 512通道 → num_classes通道
# 2. 将融合后的特征s2上采样到1/8分辨率
s2 = self.upsample_4x(s2) # 2倍上采样 → 1/8分辨率
# 3. 融合s1和s2
s = s1 + s2 # 融合1/8分辨率的特征
图示:
融合特征s2 (1/16) → upsample_4x → (1/8)
↓
s1 (1/8) → scores3 → → → → → 相加 → 融合特征s
4.4 最终上采样
# 8倍上采样得到与原图像大小一致的特征图
s = self.upsample_8x(s) # 注意:这里用的是s,不是s2
return s
最后一行应该是 s = self.upsample_8x(s),而不是 s2。
为什么叫FCN-8s?
名称含义:最终预测是通过1/8分辨率特征图上采样8倍得到的。
特征来源:
- pool5 (1/32):最深层,语义信息丰富,但空间细节丢失
- pool4 (1/16):中层特征,平衡语义和细节
- pool3 (1/8):浅层特征,空间细节丰富,但语义信息不足
融合策略:通过跳跃连接将不同尺度的特征融合,既保留细节又保持语义准确性。
分辨率变化过程
假设输入图像为 H×W:
输入: H × W
↓ stage1 (下采样8倍)
s1: H/8 × W/8 (512通道)
↓ stage2 (下采样2倍)
s2: H/16 × W/16 (1024通道)
↓ stage3 (下采样2倍)
s3: H/32 × W/32 (2048通道)
处理过程:
s3 → 调整通道 → 2倍上采样 → H/16 × W/16
s2 → 调整通道 → 与s3融合 → H/16 × W/16
融合特征 → 2倍上采样 → H/8 × W/8
s1 → 调整通道 → 与融合特征相加 → H/8 × W/8
最终 → 8倍上采样 → H × W
双线性核
上面类还有一个方法bilinear_kernel没有实现
def bilinear_kernel(in_channels, out_channels, kernel_size):
factor = (kernel_size + 1) // 2
if kernel_size % 2 == 1:
center = factor - 1
else:
center = factor - 0.5
og = np.ogrid[:kernel_size, :kernel_size]
filt = (1 - abs(og[0] - center) / factor) *
(1 - abs(og[1] - center) / factor)
weight = np.zeros((in_channels, out_channels,
kernel_size, kernel_size), dtype='float32')
weight[range(in_channels), range(out_channels),:,:] = filt
return torch.from_numpy(weight)
这个方法主要是生成一个用于双线性插值的卷积核,可以使图像放大时保持平滑过渡。
- 函数定义
def bilinear_kernel(in_channels, out_channels, kernel_size):
- 参数:
in_channels:输入通道数out_channels:输出通道数kernel_size:卷积核大小(通常为4,用于2倍上采样)
- 计算中心位置和缩放因子
factor = (kernel_size + 1) // 2
计算缩放因子:对于大小为k的核,缩放因子通常是(k+1)//2。
if kernel_size % 2 == 1:
center = factor - 1
else:
center = factor - 0.5
确定卷积核的中心坐标:
- 奇数核:中心在整数位置,如3×3核的中心是(1,1)
- 偶数核:中心在半个像素位置,如4×4核的中心是(1.5,1.5)
- 创建坐标网格
og = np.ogrid[:kernel_size, :kernel_size]
使用np.ogrid创建两个独立的坐标数组:
# 当kernel_size=4时:
og[0] = [[0], og[1] = [[0, 1, 2, 3]]
[1],
[2],
[3]]
- 计算双线性插值权重
filt = (1 - abs(og[0] - center) / factor) *
(1 - abs(og[1] - center) / factor)
这是双线性插值的核心公式:
公式解释:
权重 = (1 - |x - center_x| / factor) × (1 - |y - center_y| / factor)
示例(kernel_size=4):
center = 1.5, factor = 2.5
对于位置(0,0):
(1 - |0-1.5|/2.5) × (1 - |0-1.5|/2.5)
= (1 - 1.5/2.5) × (1 - 1.5/2.5)
= (1 - 0.6) × (1 - 0.6)
= 0.4 × 0.4 = 0.16
整个4×4滤波器矩阵:
[[0.16, 0.24, 0.24, 0.16],
[0.24, 0.36, 0.36, 0.24],
[0.24, 0.36, 0.36, 0.24],
[0.16, 0.24, 0.24, 0.16]]
- 构建完整的权重张量
weight = np.zeros((in_channels, out_channels,
kernel_size, kernel_size), dtype='float32')
创建4D权重张量,PyTorch卷积权重形状为:
[输出通道, 输入通道, 高度, 宽度]
但这里创建的是[in_channels, out_channels, ...],后面需要转置。
weight[range(in_channels), range(out_channels),:,:] = filt
关键操作:将对角线上的输入-输出通道对设置为双线性滤波器。
- 这意味着每个输入通道只影响对应的输出通道
- 其他通道间的权重为0
return torch.from_numpy(weight)
将NumPy数组转换为PyTorch张量。
这就是FCN-8s的工作原理,它开创了深度学习语义分割的先河!









