当前位置: 首页 > news >正文

从代码学习深度学习 - 全卷积神经网络 PyTorch版

文章目录

  • 前言
  • 全卷积网络 (FCN) 简介
  • 构造模型
    • 加载预训练的 ResNet-18
    • 修改网络结构
    • 添加1x1卷积层和转置卷积层
    • 初始化转置卷积层:双线性插值
  • 读取数据集
  • 训练
  • 预测与可视化
  • 总结
  • 附录:工具函数代码
    • utils_for_data.py
    • utils_for_huitu.py
    • utils_for_train.py


前言

欢迎来到我们的深度学习代码学习系列!今天,我们将深入探讨一种在计算机视觉领域中至关重要的技术——语义分割(Semantic Segmentation),并重点学习其经典实现方法:全卷积网络(Fully Convolutional Network, FCN)。语义分割的目标是为图像中的每一个像素分配一个类别标签,这使得机器能够理解图像内容的精细细节,远超于简单的图像分类或目标检测。

在本篇博客中,我们将使用 PyTorch 框架,一步步构建、训练和测试一个 FCN 模型。我们将利用预训练的 ResNet-18 网络作为特征提取主干,并通过转置卷积(Transposed Convolution)来实现上采样,最终得到与输入图像同样大小的像素级预测图。无论您是深度学习的初学者还是有一定经验的开发者,希望通过本文的代码实践,能帮助您更深入地理解 FCN 的工作原理和实现细节。

完整代码:下载链接
数据集:下载链接

全卷积网络 (FCN) 简介

语义分割是对图像中的每个像素分类。全卷积网络(FCN)采用卷积神经网络实现了从图像像素到像素类别的变换。与之前在图像分类或目标检测部分介绍的卷积神经网络不同,全卷积网络将中间层特征图的高和宽变换回输入图像的尺寸:这是通过转置卷积(transposed convolution)实现的。因此,输出的类别预测与输入图像在像素级别上具有一一对应关系:通道维的输出即该位置对应像素的类别预测。

构造模型

全卷积网络先使用卷积神经网络抽取图像特征,然后通过卷积层将通道数变换为类别个数,最后通过转置卷积层将特征图的高和宽变换为输入图像的尺寸。因此,模型输出与输入图像的高和宽相同,且最终输出通道包含了该空间位置像素的类别预测。

加载预训练的 ResNet-18

我们使用在ImageNet数据集上预训练的ResNet-18模型来提取图像特征,并将该网络记为pretrained_net。ResNet-18模型的最后几层包括全局平均汇聚层和全连接层,然而全卷积网络中不需要它们。

%matplotlib inline
import os
import torch
import torchvision
from torch import nn
from torch.nn import functional as F
# 设置预训练模型权重的下载路径
# 注意:这必须在加载模型之前设置
download_path = './model_weights'  # 替换为你想要的路径
# 确保下载目录存在
os.makedirs(download_path, exist_ok=True)
# 方法1:使用 torch.hub.set_dir() 设置下载缓存目录
torch.hub.set_dir(download_path)
# 方法2:也可以通过环境变量设置(适用于无法修改代码的情况)
# import os
# os.environ['TORCH_HOME'] = download_path
# 现在加载预训练模型,权重将下载到指定路径
# 注意:在较新版本的 torchvision 中,'pretrained' 参数已被弃用
# 对于 torchvision >= 0.13.0 的版本
try:# 新版本写法weights = torchvision.models.ResNet18_Weights.IMAGENET1K_V1  # 预训练权重pretrained_net = torchvision.models.resnet18(weights=weights)print("使用新版API加载模型")
except:# 兼容旧版本写法pretrained_net = torchvision.models.resnet18(pretrained=True)print("使用旧版API加载模型")
# 检查模型是否成功加载
print(f"模型加载成功:ResNet18 预训练模型已加载")
print(f"模型权重保存在:{download_path}")

输出:

使用新版API加载模型
模型加载成功:ResNet18 预训练模型已加载
模型权重保存在:./model_weights

我们可以查看pretrained_net的结构:

pretrained_net

输出:

ResNet((conv1): Conv2d(3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False)(bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)(relu): ReLU(inplace=True)(maxpool): MaxPool2d(kernel_size=3, stride=2, padding=1, dilation=1, ceil_mode=False)(layer1): Sequential(...)(layer2): Sequential(...)(layer3): Sequential(...)(layer4): Sequential(...)(avgpool): AdaptiveAvgPool2d(output_size=(1, 1))(fc): Linear(in_features=512, out_features=1000, bias=True)
)

以及最后几层:

list(pretrained_net.children())[-3:]

输出:

[Sequential((0): BasicBlock(...)(1): BasicBlock(...)),AdaptiveAvgPool2d(output_size=(1, 1)),Linear(in_features=512, out_features=1000, bias=True)]

修改网络结构

创建一个全卷积网络net。它复制了ResNet-18中大部分的预训练层,除了最后的全局平均汇聚层和最接近输出的全连接层。

# 从预训练神经网络中提取特征提取部分(去除最后两层)
# 此技术常用于迁移学习,保留预训练网络的特征提取能力,同时允许自定义分类层# 输入: pretrained_net - 预训练神经网络模型,例如ResNet、VGG等,维度取决于具体模型架构
# 输出: net - 不包含最后两层的特征提取器网络# 获取预训练网络的所有层(模块)
# pretrained_net.children() 返回一个迭代器,包含网络的所有顶层模块
# list() 将迭代器转换为列表,便于索引操作
all_layers = list(pretrained_net.children())
# all_layers的维度: 列表,长度等于网络层数,每个元素是一个神经网络模块# 移除最后两层(通常是分类层,如全连接层和softmax层)
# [:-2] 表示取除了最后两个元素外的所有元素
feature_layers = all_layers[:-2]
# feature_layers的维度: 列表,长度比all_layers少2,包含除最后两层外的所有层# 使用这些层创建新的顺序模型作为特征提取器
# nn.Sequential 创建一个按顺序执行各层的模型
# *feature_layers 解包列表中的所有层作为Sequential的参数
net = nn.Sequential(*feature_layers)
# net的输入维度: 与原模型相同,例如对于图像输入,通常是 [batch_size, channels, height, width]
# net的输出维度: 通常是中间特征图,例如 [batch_size, feature_channels, feature_height, feature_width]
#              具体尺寸取决于原模型架构和移除的层

对于一个 320x480 的输入图像,经过这个特征提取网络后,输出特征图的尺寸会发生变化。由于ResNet-18中的卷积层和池化层(特别是带有步幅的)会逐步减小特征图的空间维度,同时增加通道数。

# 创建随机输入样本,模拟输入图像
# 维度说明: [批量大小, 通道数, 高度, 宽度]
# - 批量大小为1,表示单张图像
# - 通道数为3,表示RGB三通道彩色图像
# - 高度为320像素
# - 宽度为480像素
X = torch.rand(size=(1, 3, 320, 480))
# X的维度: [1, 3, 320, 480]# 将输入传入截断后的ResNet-18网络(去掉了最后两层)并获取输出张量的形状
# pretrained_net为ImageNet数据集上预训练的ResNet-18模型
# 原始ResNet-18结构:
# 1. 初始卷积层和最大池化层
# 2. 4个残差块组(每组2个残差块)
# 3. 全局平均池化层(被移除)
# 4. 全连接分类层(被移除)# 执行前向传播,获取特征图
output = net(X)
# output的维度: [1, 512, 10, 15]
# - 批量大小保持为1
# - 通道数为512,对应ResNet-18最后一个残差块组的输出通道数
# - 特征图高度为10 (320/32),宽度为15 (480/32)
# - 高宽缩小是因为ResNet-18中的下采样操作(步长为2的卷积和池化)# 打印输出张量的形状
print(output.shape)  # 输出: torch.Size([1, 512, 10, 15])

输出:

torch.Size([1, 512, 10, 15])

可以看到,输出特征图的高度和宽度分别是输入图像的 1/32。

添加1x1卷积层和转置卷积层

接下来使用1×1卷积层将输出通道数转换为Pascal VOC2012数据集的类数(21类)。 最后需要将特征图的高度和宽度增加32倍,从而将其变回输入图像的高和宽。由卷积层输出形状的计算方法: 由于(320−64+16×2+32)/32=10且(480−64+16×2+32)/32=15,我们构造一个步幅为32的转置卷积层,并将卷积核的高和宽设为64,填充为16。 我们可以看到如果步幅为s,填充为s/2(假设s/2是整数)且卷积核的高和宽为2s,转置卷积核会将输入的高和宽分别放大s倍。

1. 使用1×1卷积层调整通道数

# 使用1×1卷积调整通道数,从512转为21个类别
# 输入维度: [batch_size, 512, 10, 15]
# 输出维度: [batch_size, 21, 10, 15]
conv_cls = nn.Conv2d(in_channels=512, out_channels=21, kernel_size=1)
# Y = conv_cls(net(X)) # 假设net(X)是上一阶段的输出

这一步使用1×1卷积层的作用:

  • 将通道数从512减少到21(Pascal VOC2012的类别数)
  • 每个像素位置获得21个通道的预测分数,对应21个类别
  • 1×1卷积不改变特征图的空间维度,仍然是10×15

2. 使用转置卷积上采样恢复原始分辨率

# 构造转置卷积层,将特征图尺寸放大32倍
# 步幅s=32,卷积核大小k=64,填充p=16
# 输入维度: [batch_size, 21, 10, 15]
# 输出维度: [batch_size, 21, 320, 480]
trans_conv = nn.ConvTranspose2d(in_channels=21, out_channels=21,kernel_size=64, padding=16, stride=32)

转置卷积参数解析
转置卷积的输出尺寸计算公式为:
输出尺寸 = (输入尺寸 - 1) × 步幅 + 卷积核大小 - 2 × 填充
验证高度方向:
(10 - 1) × 32 + 64 - 2 × 16 = 9 × 32 + 64 - 32 = 288 + 32 = 320
验证宽度方向:
(15 - 1) × 32 + 64 - 2 × 16 = 14 × 32 + 64 - 32 = 448 + 32 = 480

参数间的关系
当满足以下条件时,转置卷积会精确地将输入尺寸放大s倍:

  • 步幅 = s(本例中s=32)
  • 填充 = s/2(本例中16=32/2)
  • 卷积核大小 = 2s(本例中64=2×32)
    这可以通过代数证明:
    输出尺寸 = (输入尺寸 - 1) × s + 2s - 2 × (s/2)
    = (输入尺寸 - 1) × s + 2s - s
    = (输入尺寸 - 1) × s + s
    = 输入尺寸 × s
    这正是我们所需的32倍放大效果,使特征图从10×15恢复到原始图像的320×480分辨率,同时保持每个像素的21个类别通道,从而实现语义分割任务的像素级预测。
# 定义类别数量为21,对应Pascal VOC2012数据集的类别数
# 维度: 标量
num_classes = 21# 向网络添加1×1卷积层,将通道数从512调整为类别数量
# 该卷积层不改变特征图的空间尺寸,仅调整通道数
# 参数说明:
# - 'final_conv': 模块名称
# - nn.Conv2d: 2D卷积层
# - 512: 输入通道数,对应ResNet-18去掉最后两层后的输出通道数
# - num_classes: 输出通道数,对应21个类别
# - kernel_size=1: 卷积核大小为1×1
# 输入维度: [批量大小, 512, 10, 15]
# 输出维度: [批量大小, 21, 10, 15]
net.add_module('final_conv', nn.Conv2d(512, num_classes, kernel_size=1))# 向网络添加转置卷积层,将特征图尺寸放大32倍,恢复到输入图像的原始分辨率
# 参数说明:
# - 'transpose_conv': 模块名称
# - nn.ConvTranspose2d: 2D转置卷积层(反卷积)
# - num_classes: 输入通道数,与前一层输出通道数一致,为21
# - num_classes: 输出通道数,保持类别数不变,仍为21
# - kernel_size=64: 转置卷积核大小为64×64
# - padding=16: 填充大小为16,等于步幅的一半
# - stride=32: 步幅为32,决定了特征图将被放大的倍数
# 输入维度: [批量大小, 21, 10, 15]
# 输出维度: [批量大小, 21, 320, 480]
# 输出尺寸计算:
# - 高度: (10-1)×32 + 64 - 2×16 = 320
# - 宽度: (15-1)×32 + 64 - 2×16 = 480
net.add_module('transpose_conv', nn.ConvTranspose2d(num_classes, num_classes,kernel_size=64, padding=16, stride=32))

初始化转置卷积层:双线性插值

在图像处理中,我们有时需要将图像放大,即上采样(upsampling)。 双线性插值(bilinear interpolation) 是常用的上采样方法之一,它也经常用于初始化转置卷积层。

为了解释双线性插值,假设给定输入图像,我们想要计算上采样输出图像上的每个像素。

  1. 将输出图像的坐标(x,y)映射到输入图像的坐标(x′,y′)上。 例如,根据输入与输出的尺寸之比来映射。 请注意,映射后的x′和y′是实数。
  2. 在输入图像上找到离坐标(x′,y′)最近的4个像素。
  3. 输出图像在坐标(x,y)上的像素依据输入图像上这4个像素及其与(x′,y′)的相对距离来计算。

双线性插值的上采样可以通过转置卷积层实现,内核由以下bilinear_kernel函数构造。

def bilinear_kernel(in_channels, out_channels, kernel_size):"""生成双线性插值卷积核权重函数参数:- in_channels: 输入通道数,标量- out_channels: 输出通道数,标量- kernel_size: 卷积核大小,标量返回:- weight: 双线性插值卷积核权重,维度[in_channels, out_channels, kernel_size, kernel_size]"""# 计算用于归一化的因子# 对于kernel_size=4,factor=2.5# 维度: 标量factor = (kernel_size + 1) // 2# 计算卷积核的中心位置# 对于kernel_size=4,center=1.5# 维度: 标量center = (kernel_size - 1) / 2 print(f'center={center}')  # 打印中心位置,用于调试# 创建坐标网格# og[0]: 垂直方向坐标,维度[kernel_size, 1],例如[[0], [1], [2], [3]]# og[1]: 水平方向坐标,维度[1, kernel_size],例如[[0, 1, 2, 3]]og = (torch.arange(kernel_size).reshape(-1, 1),torch.arange(kernel_size).reshape(1, -1))# 计算双线性权重# 1. 对每个坐标,计算到中心的归一化距离# 2. 将垂直和水平方向的权重相乘得到最终权重# filt维度: [kernel_size, kernel_size],例如4×4的双线性权重矩阵filt = (1 - torch.abs(og[0] - center) / factor) * \(1 - torch.abs(og[1] - center) / factor)print(f'filt={filt}')  # 打印滤波器权重,用于调试# 创建最终的卷积核权重张量,初始化为零# 维度: [in_channels, out_channels, kernel_size, kernel_size],例如[3, 3, 4, 4]weight = torch.zeros((in_channels, out_channels,kernel_size, kernel_size))# 将双线性权重分配给相应的输入-输出通道对# 只有当输入通道索引等于输出通道索引时才分配权重# 这里使用了Python的range函数和PyTorch的高级索引功能weight[range(in_channels), range(out_channels), :, :] = filtreturn weight

我们构造一个将输入的高和宽放大2倍的转置卷积层,并将其卷积核用bilinear_kernel函数初始化。

# 创建转置卷积层(用于上采样)
# - 输入通道数: 3(RGB图像)
# - 输出通道数: 3(保持RGB格式)
# - 卷积核大小: 4×4
# - 填充: 1(四周各填充1个像素)
# - 步长: 2(空间尺寸放大2倍)
# - bias=False: 不使用偏置参数
# 转置卷积输入维度: [批量大小, 3, 高, 宽]
# 转置卷积输出维度: [批量大小, 3, 高*2, 宽*2]
conv_trans = nn.ConvTranspose2d(3, 3, kernel_size=4, padding=1, stride=2,bias=False)# 使用自定义的双线性卷积核初始化转置卷积的权重
# 这样初始化后,转置卷积将执行双线性插值上采样,比随机初始化更平滑
conv_trans.weight.data.copy_(bilinear_kernel(3, 3, 4))

输出:

center=1.5
filt=tensor([[0.0625, 0.1875, 0.1875, 0.0625],[0.1875, 0.5625, 0.5625, 0.1875],[0.1875, 0.5625, 0.5625, 0.1875],[0.0625, 0.1875, 0.1875, 0.0625]])
tensor([[[[0.0625, 0.1875, 0.1875, 0.0625],[0.1875, 0.5625, 0.5625, 0.1875],[0.1875, 0.5625, 0.5625, 0.1875],[0.0625, 0.1875, 0.1875, 0.0625]],...[[0.0625, 0.1875, 0.1875, 0.0625],[0.1875, 0.5625, 0.5625, 0.1875],[0.1875, 0.5625, 0.5625, 0.1875],[0.0625, 0.1875, 0.1875, 0.0625]]]])

读取图像X,将上采样的结果记作Y。为了打印图像,我们需要调整通道维的位置。

from PIL import Image
# 使用torchvision的转换函数将图像文件转换为张量
# Image.open('../img/catdog.jpg')加载图像文件
# torchvision.transforms.ToTensor()将PIL图像转换为张量并归一化到[0,1]范围
# 输入: PIL图像对象
# 输出: 张量,维度[通道数, 高度, 宽度],例如[3, H, W]
# 请确保你有名为 'img/03_catdog.jpg' 的图片,或者替换为你的图片路径
try:img = torchvision.transforms.ToTensor()(Image.open('img/03_catdog.jpg'))
except FileNotFoundError:print("请将一张名为 '03_catdog.jpg' 的图片放在 'img/' 目录下,或修改代码中的图片路径。")# 为了让代码继续运行,这里创建一个随机图像img = torch.rand(3, 561, 728) # 假设原始图像尺寸# img的维度: [3, H, W],其中H和W是原始图像的高度和宽度,3表示RGB三通道# 添加批量维度
# unsqueeze(0)在第0维添加一个维度,将图像转换为批量为1的小批量
# 输入: [3, H, W]
# 输出: [1, 3, H, W],表示一个批量中的一张图像
X = img.unsqueeze(0)
# X的维度: [1, 3, H, W]# 使用转置卷积对图像进行上采样
# conv_trans是之前定义的初始化了双线性插值权重的转置卷积层
# 输入: [1, 3, H, W]
# 输出: [1, 3, H*2, W*2],因为步长为2,所以空间尺寸扩大两倍
Y = conv_trans(X)
# Y的维度: [1, 3, H*2, W*2]# 准备输出图像用于显示
# [0]: 提取批量中的第一张图像,维度变为[3, H*2, W*2]
# permute(1, 2, 0): 调换维度顺序,从[通道, 高, 宽]变为[高, 宽, 通道],便于显示
# detach(): 从计算图中分离,避免梯度计算,得到独立的张量
# 输入: [1, 3, H*2, W*2]
# 输出: [H*2, W*2, 3],是一个可以直接用mat

相关文章:

  • YOLOv11融合[AAAI2025]的PConv模块
  • 技术视角下的TikTok店铺运营:从0到1的5个关键点
  • Flask+HTML+Jquery 文件上传下载
  • DeepSeek 赋能汽车全生态:从产品到服务的智能化跃迁
  • supabase 怎么新建项目?
  • Oracle 在线日志文件和控制文件损坏处理思路
  • FedTracker:为联邦学习模型提供所有权验证和可追溯性
  • 黑马k8s(五)
  • javax.servlet.Filter 介绍-笔记
  • 邀请函|PostgreSQL培训认证报名正式开启
  • FFmpeg 与 C++ 构建音视频处理全链路实战(三)—— FFmpeg 内存模型
  • 什么情况会导致JVM退出?
  • 游戏引擎学习第275天:将旋转和剪切传递给渲染器
  • 基于TouchSocket实现WebSocket自定义OpCode扩展协议
  • 【Folium】使用离线地图
  • 百度导航广告“焊死”东鹏特饮:商业底线失守,用户安全成隐忧
  • 【NLP 72、Prompt、Agent、MCP、function calling】
  • R²AIN SUITE:AI+文档切片,重塑知识管理新标杆
  • 《驱动开发硬核特训 · 专题篇》:深入理解 I2C 子系统
  • Spring Boot 的自动配置为 Spring MVC 做了哪些事情?
  • 国务院关税税则委:调整对原产于美国的进口商品加征关税措施
  • 27岁杨阳拟任苏木镇党委副职,系2020年内蒙古自治区选调生
  • “饿了么”枣庄一站点两名连襟骑手先后猝死,软件显示生前3天每日工作超11小时
  • 欧阳娜娜担任江西吉安文化旅游大使
  • “犍陀罗艺术与亚洲文明”在浙大对外展出
  • 水豚出逃40天至今未归,江苏扬州一动物园发悬赏公告