PyTorch深度学习实战 |手算​​FCN全卷积神经网络

本文涉及的产品
RDS DuckDB + QuickBI 企业套餐,8核32GB + QuickBI 专业版
简介: 本文介绍了FCN-8s语义分割网络的实现细节。首先解释了语义分割的概念及其与图像分类的区别,重点分析了FCN网络结构中的全卷积化、上采样和跳跃连接三个关键技术。全卷积化将传统CNN的全连接层改为卷积层,实现像素级分类;上采样通过双线性插值恢复特征图尺寸;跳跃连接则融合高低层特征以提升细节表现。文章详细推导了损失函数的计算过程,并提供了完整的PyTorch实现代码,包括双线性插值权重初始化、VGG16骨干网络和FCN-8s主体结构。最后通过测试验证了模型能正确输出与输入尺寸匹配的预测结果。

 什么是语义分割?

语义分割:对图像中每一个像素进行分类,确定每个点的类别

图像分类(image classification)

             图像分类是通过各种方式,提取图像信息,判断图像属于哪一类。

语义分割 (Semantic Segmentation)

           对图像中的每个像素进行分类。目标是实现像素级别的场景理解,精确描绘出每个物体的

轮廓与位置。

image.gif


网络结构

      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): 记录了全图每个位置属于“污泥”的分数。

image.gif

全卷积(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

image.gif

上采样

  我们的语义分割任务是像素级别的预测任务,每个所对应的都有个类别,但是经过CNN网络之

后,会经历一系列的下采样,其shape大小还是无法匹配原来的图片。那么我们要做的就是经过上

采样来恢复图片的shape。

最近邻插值法

   在图像处理中,当我们改变图像尺寸(比如将小图放大的上采样),新生成的像素点必须去原图

中寻找对应的色彩值。这个公式描述的就是从目标图(dst)坐标反推回原图(src)坐标的过程。


其中SrcX和SrcY代表原图像的位置,dstX和dstY代表上采样后的图像的位置。

其根本就是我们目标位置乘以缩放因子的值就是其在原图像的位置的值。举个例子就知道了,上采

样后的图像的(3,1)的值为原图像中的(3x(2/4),1x(2/4))=(1.5,0.5),采用四舍五入的规则,故其值为

(2,1)也就是原图像中橙色的位置,直接取橙色的位置的值即可。其上采样的原理计算都非常简单,

当然这所带来的后果就是生成的图像是非常粗糙的。

image.gif

双线性插值

在讲双线性插值前,我们先来讲讲什么叫线性插值。

image.gif

但是线性插值有个很大的弊端,就是我们只是通过单个维度取预测其值,会存在非常大的误差并且

偶然性也非常大。所以双线性插值就来了,我们通过两次插值来进行预测,就是通过多个维度来预

测从而减小误差。双线性插值是用原图像中4(2*2)个点计算新图像中1个点。

反卷积

负责上采样,把缩小的特征图恢复到原图大小,以便实现像素级的分类。

image.gif 编辑

跳跃连接(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之间的数字)

image.gif

image.gif

现在的 (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)

image.gif

image.gif

预测标签:这就是我们刚才经过 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 的概率分布:

image.gif

这样的话,3个通道上的(100,100)位置上会有这三个表示概率的数字。

负对数似然(Negative Log Likelihood)

我们只看真实标签对应的那个概率。真实标签是水体,所以我们只盯死 76.4%(即 0.764)这个数

字。交叉熵 Loss 的公式极其简单:

image.gif

全图的 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会生成两个一维向量

image.gif 编辑

filt = (1 - abs(og[0] - center) / factor) * (1 - abs(og[1] - center) / factor)

image.gif

image.gif

# ==========================================
# 拼图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

image.gif

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")

image.gif

完整代码

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])

image.gif

输入图像尺寸: torch.Size([1, 3, 224, 224])

预测输出尺寸: torch.Size([1, 1, 224, 224])

目录
相关文章
|
16天前
|
人工智能 自然语言处理 文字识别
阿里云百炼Qwen3.7-Max简介:能力、优势、支持订阅计划参考
Qwen3.7-Max是阿里云百炼面向智能体时代推出的新一代旗舰模型,对标GPT-5.5、Claude Opus 4.7等闭源旗舰。该模型支持百万级token上下文窗口,具备顶级推理能力、多模态搜索与视觉理解增强、流式输出低延迟响应等核心优势,覆盖编程、办公、长周期自主执行等复杂场景。同时支持OpenAI接口兼容,便于系统快速迁移。用户可通过Token Plan团队或节省计划等订阅方式灵活调用,适合企业级高要求场景使用。
6021 30
阿里云百炼Qwen3.7-Max简介:能力、优势、支持订阅计划参考
|
1天前
|
数据采集 人工智能 前端开发
让 Coding Agent 从黑盒到透明:阿里云 Agent 观测审计数据采集实践
AI Agent 规模化落地带来执行黑盒、行为难追溯、成本难度量三大难题。阿里云基于 OTel 标准,面向 Coding Agent、个人通用助理和框架型 Agent,推出 LoongSuite Pilot、插件及探针等无侵入采集方案,让 Agent 实现可看见、可分析、可审计、可治理。
573 135
|
11天前
|
存储 定位技术 数据库
CodeGraph 如何让 Claude Code减少 7 成工具调用?
CodeGraph 为 Coding Agent 提供本地代码知识图谱,把函数、类、调用链和框架路由提前整理成“项目地图”,减少盲目搜索和文件读取。它不是新 Agent,而是上下文基础设施,让 Agent 更快找到正确代码路径,平均减少 7 成工具调用。
1190 3
|
8天前
|
人工智能 安全 定位技术
CodeGraph深度解析 让Claude Code工具调用直降七成的核心原理与实操教程
如今以Claude Code为代表的AI编程智能体已经成为开发者日常编码、项目重构、漏洞修复的必备工具。但在长期使用过程中,几乎所有开发者都会遇到同一个明显痛点:AI虽然具备强大的代码生成与分析能力,却常常陷入盲目探索的循环中。
992 1
|
18天前
|
人工智能 自然语言处理 供应链
|
9天前
|
人工智能 弹性计算 安全
阿里云618活动时间、活动入口、优惠活动详细解读
2026年阿里云618创新加速季已全面开启,作为年度力度最大的云产品促销活动,本次大促覆盖轻量应用服务器、ECS云服务器、GPU云服务器、数据库、AI算力、安全服务、CDN等全品类产品,推出5亿元算力补贴、新用户限时秒杀、普惠满减、企业专享、免费试用、云大使返佣等多重福利,个人开发者、中小企业、AI团队均可享受专属低价。本文将系统梳理2026年阿里云618活动的完整时间节点、官方参与入口、各类优惠细则、使用规则、热门产品推荐及实操代码,帮助用户精准参与、高效省钱,以最低成本完成上云部署。
817 5
|
9天前
|
运维
欢迎报名|2026 Agentic AICon—智能体基础设施与AgentOps专场,邀您参会
欢迎报名|2026 Agentic AICon—智能体基础设施与AgentOps专场,邀您参会
1442 0