10971 words
55 minutes
从零开始学 AI - 第二章:图像分割
首次发布: 2026-04-05
... 次访问

图像分割任务的范式#

图像分割(Image Segmentation)是计算机视觉中比图像分类更为精细的任务。如果说图像分类回答的是“这张图里有什么”,那么图像分割则要回答“它们在哪里”以及“每个像素属于哪个对象”。其核心目标是将输入图像的每一个像素分配到一个特定的语义类别或实例标识中,从而实现对图像内容的像素级理解。

给定一个输入图像 xRH×W×Cx \in \mathbb{R}^{H \times W \times C},其中 HHWWCC 分别表示图像的高度、宽度和通道数。假设总共有 KK 个类别。图像分割任务的目标是学习出一个从图像 xx 到像素标签 MM 的映射函数

fsegment:RH×W×C{1,2,...,K}H×Wf_{\text{segment}}: \mathbb{R}^{H \times W \times C} \rightarrow \{1, 2, ..., K\}^{H \times W}

其中输出 M{1,2,...,K}H×WM \in \{1, 2, ..., K\}^{H \times W} 是一个与输入图像空间尺寸相同的像素标签,称作掩码 (Mask)

从掩码 MM 的结构上看,对于图像中的每一个像素位置 (h,w)(h, w),它都反映了该像素位置对应的一个类别标签 Mh,wM_{h,w}。相较于图像分类,图像分割是一种更为精细化的图像识别任务。图像分割的输出不再是一个单一的标量值,而是一个高密度的空间分布图。这意味着模型不仅要识别物体的内容,还要精确地刻画物体的几何形状和边界。

根据不同的应用需求,图像分割任务主要演化为三种不同的范式:

原始输入图像
原始输入图像,摘自 https://zhuanlan.zhihu.com/p/368904941

1. 语义分割 (Semantic Segmentation)#

语义分割是最基础的分割形式。它的目标是为图像中的每个像素分配一个预定义的语义类别标签,但不区分同一类别的不同个体

  • 输入: 图像 xx
  • 输出: 语义标签图 S{1,2,...,K}H×WS \in \{1, 2, ..., K\}^{H \times W},其中 KK 为类别总数。
  • 特点: “只分种类,不分个体”。如果图像中有两个人,语义分割会将这两个人的所有像素都标记为“人”这一类别的标签,而不会区分“人A”和“人B”。
  • 应用: 自动驾驶中的道路/车道线检测、医学影像中的器官区域提取。

示例说明: 假设有一个包含“汽车”和“行人”的图像。在语义分割任务中,无论图像中有多少辆车,所有车的像素都会被赋予同一个类别ID(例如 ID=3);同样,所有行人的像素都会被赋予另一个类别ID(例如 ID=5)。

像素坐标 (h, w)真实语义语义分割输出
(10, 20)汽车3
(10, 21)汽车3
(10, 22)行人5
(10, 23)行人5

注意:模型无法通过输出区分这是两辆不同的车,还是两个不同的人。

语义分割结果
语义分割结果示例,摘自 https://zhuanlan.zhihu.com/p/368904941

2. 实例分割 (Instance Segmentation)#

实例分割在语义分割的基础上进一步升级,它不仅要求识别像素的语义类别,还要求区分同一类别下的不同个体实例

  • 输入: 图像 xx
  • 输出: 实例掩码图 I{0,1,...,N}H×WI \in \{0, 1, ..., N\}^{H \times W},其中 NN 是该图像中检测到的物体实例总数(通常包括背景0)。
  • 特点: “既分种类,又分个体”。每个独立的物体实例都被赋予一个唯一的ID。
  • 应用: 机器人抓取(需要知道具体抓哪一辆车)、密集人群计数、视频分析中的目标跟踪。

示例说明: 继续上面的例子,如果有两辆汽车和两个人。实例分割不仅会告诉你是“汽车”和“行人”,还会给第一辆车分配ID=3,第二辆车分配ID=4;第一个人分配ID=5,第二个人分配ID=6。

像素坐标 (h, w)真实语义实例分割输出
(10, 20)汽车 (左)3
(10, 21)汽车 (左)3
(10, 22)汽车 (右)4
(10, 23)汽车 (右)4
(10, 24)行人 (前)5
(10, 25)行人 (后)6

注意:模型能够清晰地将两辆相邻的汽车区分开来。

实例分割结果
实例分割结果示例,摘自 https://zhuanlan.zhihu.com/p/368904941

3. 全景分割 (Panoptic Segmentation)#

全景分割是近年来提出的统一框架,旨在将上述两种任务合二为一。它试图同时完成语义分割和实例分割的任务,并根据物体的属性进行智能处理:

  • 对于“可数物体”(Countable Things,如人、车、动物),执行实例分割,区分个体。
  • 对于“不可数物体”(Stuff,如天空、草地、道路),执行语义分割,不区分个体。

这种范式解决了传统方法中难以平衡“区分个体”和“覆盖背景”的问题,是目前最接近人类视觉理解的分割范式。

全景分割结果
全景分割结果示例,摘自 https://zhuanlan.zhihu.com/p/368904941

图像分割的评估指标#

为了衡量图像分割模型的性能,我们通常使用平均交并比 (mean Intersection over Union, mIoU) 作为核心评估指标。IoU衡量了预测区域与真实标注区域的重叠程度:

IoUc=PredictioncGroundTruthcPredictioncGroundTruthc\text{IoU}_c = \frac{\text{Prediction}_c \cap \text{GroundTruth}_c}{\text{Prediction}_c \cup \text{GroundTruth}_c}

其中 cc 代表某个特定的类别。mIoU则是所有类别IoU的平均值。

真实标签\模型预测预测为“汽车”预测为“非汽车”
真实为“汽车”TP (真阳性)FN (假阴性)
真实为“非汽车”FP (假阳性)TN (真阴性)

对于类别 cc,其IoU计算如下:

IoUc=预测区域真实区域预测区域真实区域=TPcTPc+FPc+FNc\text{IoU}_c = \frac{\text{预测区域} \cap \text{真实区域}}{\text{预测区域} \cup \text{真实区域}} = \frac{TP_c}{TP_c + FP_c + FN_c}

如果一个模型在测试集上对“汽车”类别的预测结果如下:

  • 真正例 (TP): 80 (正确预测为汽车的像素数)
  • 假正例 (FP): 20 (被误判为汽车的背景像素数)
  • 假负例 (FN): 10 (被漏掉的真实汽车像素数)

则该类别的 IoU 为:

IoUcar=8080+20+10=801100.727\text{IoU}_{\text{car}} = \frac{80}{80 + 20 + 10} = \frac{80}{110} \approx 0.727

mIoU即为所有类别IoU值的算术平均,反映了模型整体的分割能力。数值越接近1,表示分割精度越高。

mIoU=1Ni=1NIoUi\text{mIoU} = \frac{1}{N} \sum_{i=1}^{N} \text{IoU}_i

图像分割的损失函数#

对于图像分割,我们希望模型在每个像素上的预测与真实掩码尽可能一致。既然预测掩码本质上是给每个像素位置做一个分类任务,那显然可以仿照着图像分类,用交叉熵损失作为任务的损失函数。因此,分割任务的损失函数可以看作是分类损失在空间维度上的自然扩展。

考虑到每一个像素位置都有一个交叉熵损失,那就有一个交叉熵的矩阵了。我们可以对这个矩阵进行求和或者求平均,得到一个标量损失值,因为不论是哪种方式,当求和或求平均的损失值最小时,所有的交叉熵损失也都是最小的 (交叉熵是非负的)。一般地,选择每个像素的交叉熵的平均值作为图像分割的交叉熵损失函数:

LCE=1H×Wh=1Hw=1Wc=1Kyh,w,clog(y^h,w,c)\mathcal{L}_{CE} = -\frac{1}{H \times W} \sum_{h=1}^{H} \sum_{w=1}^{W} \sum_{c=1}^{K} y_{h,w,c} \log(\hat{y}_{h,w,c})

其中 yh,w,cy_{h,w,c} 是 one‑hot 编码的真实标签(若像素 (h,w)(h,w) 属于类别 cc 则为1,否则0),y^h,w,c\hat{y}_{h,w,c} 是模型对该像素属于类别 cc 的预测概率(通常由 Softmax 输出)。

然而,在实际应用中,如果图像中背景占据了绝大部分位置,换句话讲就是类别信息极度不平衡的情况下,倘若只使用交叉熵损失,模型会陷入到将所有像素预测为背景的局面。这是因为交叉熵的求和或者平均操作忽视了占少数像素区域的类别的重要性,导致模型在训练过程中被大量的主导类别像素如背景像素主导,从而无法有效学习到前景类别的特征。

为了克服类别不平衡问题,分割任务中常引入基于区域重叠的损失函数,其中最典型的是 Dice Loss。它直接优化预测区域与真实区域的相似度,而不是逐像素的独立分类。

Dice 系数(Dice coefficient)是一种集合相似度度量,定义如下

Dice=2PredGTPred+GT\text{Dice} = \frac{2 \cdot |\text{Pred} \cap \text{GT}|}{|\text{Pred}| + |\text{GT}|}

其中 Pred 和 GT 分别表示预测的前景像素集合和真实前景像素集合。|\cdot| 表示集合中像素的数量。Dice 系数的值域为 [0,1][0,1],越接近1表示两个区域重叠越好。相应地,Dice Loss 被定义为

LDiceOriginal=1Dice\mathcal{L}_{Dice}^{\text{Original}} = 1 - \text{Dice}

为了将其应用于神经网络(需要可微的表达式),我们通常使用概率化的形式,以保证梯度可以传播。令 ph,wp_{h,w} 为模型预测像素 (h,w)(h,w) 属于前景的概率(对于二分类,Sigmoid 输出),gh,w{0,1}g_{h,w} \in \{0,1\} 为真实标签,则:

LDice=12h,wph,wgh,wh,wph,w+h,wgh,w+ϵ\mathcal{L}_{Dice} = 1 - \frac{2 \sum_{h,w} p_{h,w} g_{h,w}}{\sum_{h,w} p_{h,w} + \sum_{h,w} g_{h,w} + \epsilon}

其中 ϵ\epsilon 是一个很小的常数(如 10510^{-5}),用于避免分母为零。对于多类别分割,通常对每个类别分别计算 Dice Loss 然后取平均。

观察 Dice Loss 的分母项,它同时考虑了预测区域的大小和真实区域的大小。如果模型将所有像素预测为背景(即所有 ph,w0p_{h,w} \approx 0),分子为0,分母为 0+GT0 + |GT|,此时 LDice=1\mathcal{L}_{Dice}=1(最大损失);如果模型只预测出很少的前景像素,分子很小,分母中预测区域也很小,导致 Dice 系数较低。换句话说,Dice Loss 强制模型关注前景区域,无论它多么小

特性逐像素交叉熵 (CE)Dice Loss
优化粒度每个像素独立整个前景区域
对类别不平衡的鲁棒性差(易被背景主导)好(聚焦前景)
梯度平滑度稳定可能在小目标上震荡
适用场景类别相对平衡的分割前景占比极小的分割(如病灶、缺陷)
多类别扩展天然支持需逐类计算后平均

实践中的常见组合:由于 CE 和 Dice 各有优劣,许多分割模型将两者结合使用

L=λLCE+(1λ)LDice\mathcal{L} = \lambda \cdot \mathcal{L}_{CE} + (1-\lambda) \cdot \mathcal{L}_{Dice}

其中 λ\lambda 是平衡超参数,通常取 0.50.5 或根据验证集调整。这种组合既能利用 CE 的稳定收敛性,又能借助 Dice 提升小目标的分割效果,成为许多现代分割模型的默认选择。

图像分割任务的技术发展#

图像分割的核心挑战在于如何同时捕捉图像的全局语义信息(理解“是什么”)和局部空间细节(理解“在哪里”)。它对模型理解语义的能力提出了更高的要求,同时也对模型的计算效率和优化稳定性提出了更高的挑战。

图像分割技术的发展并非一蹴而就,而是经历了一个从“手工特征工程”到“端到端深度学习”,再到“架构创新与深层网络优化”的漫长演变过程。

朴素的想法:基于像素差异的传统分割#

在深度学习爆发之前,计算机视觉中的分割任务主要依赖于低层视觉特征手工设计的规则。这些方法的核心理念非常朴素:“相似的像素属于同一个区域”

基于聚类的方法 (Clustering)

早期的算法如 K-Means 聚类Mean Shift,将图像中的每个像素视为一个高维空间中的点(由颜色、纹理等特征组成)。算法试图将这些点划分为 KK 个簇,使得同一簇内的像素相似度最大,不同簇之间的差异最大。

  • 数学形式: 最小化簇内平方误差 i=1KxCixμi2\sum_{i=1}^K \sum_{x \in C_i} ||x - \mu_i||^2
  • 局限性: 这种方法忽略了像素的空间邻域信息。如果两个物体颜色相似但位置相距较远,它们可能会被错误地归为一类;反之,如果物体内部颜色有渐变,会被错误地切分成多个区域。

基于边缘检测的方法 (Edge Detection)

另一种思路是先检测物体的边界,再填充区域。经典的算子如 Sobel, Canny 通过计算图像梯度的幅值来寻找亮度剧烈变化的地方。

  • 数学形式: Sobel 算子在 xx 方向的梯度近似为 Gx=I[101202101]G_x = I * \begin{bmatrix} -1 & 0 & 1 \\ -2 & 0 & 2 \\ -1 & 0 & 1 \end{bmatrix}
  • 局限性: 边缘检测往往只能得到不连续的细线,难以形成封闭的区域。此外,噪声极易产生虚假边缘,且无法处理纹理复杂但边缘模糊的场景(如云朵、草地)。

总结: 传统方法严重依赖人工设计的特征(Feature Engineering),缺乏对高层语义的理解。当面对复杂的自然场景时,这些基于局部像素差异的规则往往显得力不从心。

范式转变:全卷积型网络 (FCSN) 的诞生#

随着卷积神经网络(CNN)在图像分类任务上的巨大成功,研究人员开始思考:能否将分类网络改造为分割网络?

传统的 CNN(如 VGG, GoogLeNet)最后通常接有全连接层(Fully Connected Layers),这要求输入图像的尺寸必须是固定的(例如 224×224224 \times 224),且输出是一个标量类别概率。这种结构天然不适合处理任意尺寸的图像,也无法输出像素级的标签图。

既然 CNN 输出的就是具有二维结构的特征图,那么去掉全连接层是不是就可以用于输出像素级的标签图呢?这个革命性的想法——移除所有全连接层,用卷积层替代它们——是可行的,我们姑且称这类网络为全卷积型网络 (Fully Convolution Style Networks, FCSN)

注意这里的 FCSN 和一般文献中提到的全卷积网络 (FCN) 是有区别的,本文的 FCSN 指的是一种类型的网络架构模式,不是一个具体的模型名称,而一般文献中的 FCN 是指 Long 等人提出的第一个全卷积网络模型 (FCN 里面是有特征降采样操作的,在此区分)。

核心思想

  1. 全卷积化: 将原本的全连接层替换为 1×11 \times 1 或者 3×3,padding=13 \times 3, \text{padding} = 1,步长为 1 的卷积层。这样,无论输入图像尺寸如何变化,网络都能输出一个特征图(Feature Map),而不是一个固定长度的向量。
  2. 无池化操作: 不使用任何池化层(如 Max Pooling),以保持特征图的空间分辨率不变。这样,输出的特征图与输入图像具有相同的空间尺寸。
Output MaskRH×W×K\text{Output Mask} \in \mathbb{R}^{H \times W \times K}

虽然 FCSN 有效的达到了用神经网络做分割的目的,但它生成的分割结果往往比较粗糙,边界模糊。这是因为深层的特征图虽然包含了丰富的语义信息(“是什么”),却丢失了精细的空间细节(“在哪里”)。

深层网络的困境:梯度消失与残差网络 (ResNet)#

网络越深,看到的“世界”越丰富#

浅层网络(如早期的 CNN)是只能识别图像中的低级特征的——比如颜色边缘简单的纹理;而深层网络通过层层堆叠,能够将低级特征组合成高级抽象——如局部形状物体部件,最后识别出完整的物体及其上下文关系

这就好比人类视觉系统一样:从视网膜接收光信号,经过大脑皮层逐步处理,才能最终识别出”这是一个人正在跑步”这样复杂的场景。正是因为这种信息处理的性质,在图像分割、图像识别等需要关注语义理解的任务中,需要深层网络的强语义理解能力来判断图像的内容信息的。

📐 分析:

根据数据处理不等式 (Data Processing Inequality),当信号经过多级非线性变换后,原始信息与输出的互信息是非递增的

I(X;Z0)I(X;Z1)I(X;Z2)I(X;ZL)I(X; Z_0) \ge I(X; Z_1) \ge I(X; Z_2) \ge \dots \ge I(X; Z_L)

其中 XX 是原始输入图像,ZkZ_k 是第 kk 层的特征输出。这意味着,网络越深,从输入保留的细节信息越少。由于通道数固定或减小的限制,深层网络必然对信息进行压缩与筛选,只保留最关键的特征表示。

那么深层压缩保留的关键信息是什么? 答案是全局的语义信息。为了理解这一点,需要明白什么是感受野,然后理解感受野为何会扩大。感受野是指网络中某个神经元能够“看到”的输入图像区域的大小。由于卷积等操作的本质是对上一层特征的局部加权求和,而下一层再对这些求和结果做同样的操作——这意味着每一层的神经元不仅接收本层的局部邻域,还会通过上层的间接依赖”追溯”到更远的前向输入范围。这种层级累积效应使得感受野随深度逐层扩展:浅层神经元可能只看几十像素的局部区域,但深层神经元经过多次叠加,其信息追溯可以覆盖整个输入张量。无论是非全连接网络(如卷积神经网络)的感受野随深度累积扩大,还是全连接网络从一开始就覆盖整个输入,到深层时神经元的计算都已基于完整的全局输入空间进行。因此,深层的信息压缩不是局部特征的叠加,而是在全局张量层面上提取跨区域、抽象化的语义模式——这就是浅层特征更多的是关于图像像素层级的信息 (如边界定位),而深层特征属于语义层级的根本原因。

梯度消失问题#

但是,在尝试构建更深的网络以提取更强语义特征时,研究人员发现了一个严重的瓶颈——梯度消失问题 (Gradient Vanishing Problem)

在反向传播过程中,梯度需要通过链式法则逐层传递。对于深层网络,梯度是多层导数的连乘:

Lw1=LyLyLyL1y2y1y1w1\frac{\partial L}{\partial w_1} = \frac{\partial L}{\partial y_L} \cdot \frac{\partial y_L}{\partial y_{L-1}} \cdots \frac{\partial y_2}{\partial y_1} \cdot \frac{\partial y_1}{\partial w_1}

如果每一层的激活函数导数(如 Sigmoid 或 Tanh)都小于 1,那么连乘的结果会指数级衰减,趋近于 0。这导致浅层网络的参数几乎得不到更新,网络无法训练。

残差学习 (Residual Learning)#

2015 年,He et al. 提出的 ResNet 巧妙地解决了这一问题。其核心思想不再是直接学习目标映射 H(x)H(x),而是学习残差 F(x)=H(x)xF(x) = H(x) - x

通过引入跳跃连接 (Skip Connection),网络结构变为:

y=F(x,{Wi})+xy = F(x, \{W_i\}) + x

在反向传播时,梯度可以直接通过加法操作流向浅层:

Lx=Ly(Fx+1)=Ly+LyFx\frac{\partial L}{\partial x} = \frac{\partial L}{\partial y} \cdot \left( \frac{\partial F}{\partial x} + 1 \right) = \frac{\partial L}{\partial y} + \frac{\partial L}{\partial y} \cdot \frac{\partial F}{\partial x}

那个关键的 "+1+1" 保证了梯度至少可以无损地传回浅层,即使深层的导数部分很小,也不会导致梯度完全消失。这使得训练数百甚至上千层的网络成为可能。

ResNet 残差块

ResNet 残差块

import torch.nn as nn

class BasicBlock(nn.Module):
    expansion = 1

    def __init__(self, in_channels, out_channels, stride=1):
        super(BasicBlock, self).__init__()
        # 主路径
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, 
                               stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.relu = nn.ReLU(inplace=True)
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, 
                               stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels)

        # 跳跃连接 (Shortcut)
        self.shortcut = nn.Sequential()
        if stride != 1 or in_channels != out_channels:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_channels, out_channels, kernel_size=1, 
                          stride=stride, bias=False),
                nn.BatchNorm2d(out_channels)
            )

    def forward(self, x):
        identity = x
        
        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)

        out = self.conv2(out)
        out = self.bn2(out)

        # 关键步骤:跳跃连接相加
        out += self.shortcut(identity)
        out = self.relu(out)

        return out

ResNet 的提出使得训练更深的神经网络成为可能,它在图像分类任务上大放异彩,成功超越了当时的所有模型。但是残差学习的想法远不局限在图像分类领域,其广泛应用为后续更复杂的分割网络(如 DeepLab 系列、Mask R-CNN)也提供了强大的骨干网络(Backbone),使其能够提取极其丰富的深层语义特征。

从 FCSN 到 U-Net:解决深度网络的两难困境#

加深 FCSN 的困难#

FCSN 架构虽然开创了语义分割的先河,但也暴露出了明显的短板:

  • 优化难题:为了获得更强的语义理解能力,网络层数需要加深。但网络越深,反向传播时的梯度信号就越弱,导致训练越来越不稳定,甚至难以收敛。
  • 资源瓶颈:如果在深层网络中强行保持与原图一致的高分辨率特征,计算量和显存占用将呈指数级上升,普通显卡根本无法承载。

如何破局?考虑到既然图像分割任务既需要有精细空间定位能力来画出准确的边界,也要有高度抽象的语义理解能力,那么将浅层特征和深层特征进行有效融合是构建图像分割模型的关键。一个直观的解决方案是结合 ResNet 的残差连接(解决梯度消失的同时融合深层和浅层特征)与金字塔网络结构(解决计算开销)。这里的金字塔网络结构,如下图所示,形式上先压缩特征图大小,再逐步恢复原图分辨率。

特征金字塔结构

残差 + 金字塔结构带来的问题#

然而,直接将这套组合拳应用到图像分割时,我们遇到了两个棘手的问题:

  1. 维度不兼容:标准的 ResNet 残差连接要求“残差连接”两端的张量维度完全一致。但在金字塔结构中,我们需要频繁地进行下采样和上采样,这直接破坏了维度匹配的前提。
  2. 融合机制的错位:残差连接的核心逻辑是 F(x)=H(x)xF(x) = H(x) - x,即让网络仅学习输入与目标之间的“残差”。这种机制更适用于分类等任务,却不适用于分割
    • 细节丢失风险:分割任务需要像素级的精确还原。较深层的特征经过多次下采样后,虽然包含了丰富的语义信息(如”这是汽车”),但空间分辨率的下降导致物体边界的精确位置信息被”压缩”;而浅层特征保留了原始图像的空间细节。如果使用 Add 操作强行融合两者,深层特征的数值可能会干扰浅层的高频细节,导致模型难以学会精确的边界定位。
    • 数值冲突:不同阶段的特征图数值分布差异巨大,强行相加往往导致信息混淆甚至梯度震荡。

UNet 的方案——对称结构与跳跃连接#

UNet 使用对称结构和跳跃连接(Skip Connection)巧妙地解决了上述问题:

  • 模型结构上,U-Net 的框架像一个字母”U”,由两部分组成:

    1. 收缩路径 (Contracting Path / Encoder):

      • 通过重复应用两个 3×33 \times 3、填充为 11 的卷积,每个卷积后接 BatchNorm 和 ReLU 激活,然后使用 2×22 \times 2 的最大池化进行下采样。
      • 每下采样一次,通道数翻倍,特征图尺寸减半。这一步提取了越来越抽象的语义特征。
      • 我们把收缩路径这部分的网络称为编码器 (Encoder),它负责从输入图像中提取多层次的特征表示。
    2. 扩展路径 (Expansive Path / Decoder):

      • 目的是恢复特征图的空间分辨率,以便进行像素级预测。
      • 每一步先进行 2×22 \times 2 的上采样(转置卷积实现),将特征图尺寸扩大一倍,通道数减半。
      • 然后与收缩路径中对应层级的特征图进行拼接 (Concatenation)
      • 最后经过两个 3×33 \times 3 卷积,每个卷积后接 BatchNorm 和 ReLU。
      • 我们把扩展路径这部分的网络称为解码器 (Decoder),它负责将编码器提取的语义特征与空间细节融合,最终输出与输入图像同尺寸的分割掩码。
  • 特征连接上,U-Net 最终选择了用 Concat (拼接) 去跳跃连接浅层和深层的特征。这种方式不强制使用 Add 注入浅层特征,这样既保留了原始空间细节,又让后续网络能够自主学习如何融合“物体是什么”与“物体在哪里”,从而便于实现高精度的像素级分割。

    • 编码器保留了高分辨率的空间细节(如边缘、纹理),但语义信息较弱。
    • 解码器拥有强语义信息,但空间分辨率低。
    • 拼接操作将编码器的空间细节直接注入解码器,使得模型在恢复分辨率的同时,能够利用这些细节来精确定位物体边界。
U-Net 结构图
U-Net 结构图

U-Net 代码实现

class DoubleConv(nn.Module):
    """两次卷积 + BN + ReLU"""
    def __init__(self, in_ch, out_ch):
        super(DoubleConv, self).__init__()
        self.conv = nn.Sequential(
            nn.Conv2d(in_ch, out_ch, 3, padding=1),
            nn.BatchNorm2d(out_ch),
            nn.ReLU(inplace=True),
            nn.Conv2d(out_ch, out_ch, 3, padding=1),
            nn.BatchNorm2d(out_ch),
            nn.ReLU(inplace=True)
        )

    def forward(self, x):
        return self.conv(x)

class UNet(nn.Module):
    def __init__(self, n_classes=2, base_filters=64):
        super(UNet, self).__init__()
        
        # --- 编码器 (Encoder) ---
        self.enc1 = DoubleConv(3, base_filters)      # 3 -> 64
        self.pool1 = nn.MaxPool2d(2)
        
        self.enc2 = DoubleConv(base_filters, base_filters * 2) # 64 -> 128
        self.pool2 = nn.MaxPool2d(2)
        
        self.enc3 = DoubleConv(base_filters * 2, base_filters * 4) # 128 -> 256
        self.pool3 = nn.MaxPool2d(2)
        
        self.enc4 = DoubleConv(base_filters * 4, base_filters * 8) # 256 -> 512
        
        # --- 瓶颈层 (Bottleneck) ---
        self.bottleneck = DoubleConv(base_filters * 8, base_filters * 16)
        
        # --- 解码器 (Decoder) ---
        self.upconv4 = nn.ConvTranspose2d(base_filters * 16, base_filters * 8, 2, stride=2)
        self.dec4 = DoubleConv(base_filters * 16, base_filters * 8) # 注意:这里通道数是 8+8=16
        
        self.upconv3 = nn.ConvTranspose2d(base_filters * 8, base_filters * 4, 2, stride=2)
        self.dec3 = DoubleConv(base_filters * 8, base_filters * 4)
        
        self.upconv2 = nn.ConvTranspose2d(base_filters * 4, base_filters * 2, 2, stride=2)
        self.dec2 = DoubleConv(base_filters * 4, base_filters * 2)
        
        self.upconv1 = nn.ConvTranspose2d(base_filters * 2, base_filters, 2, stride=2)
        self.dec1 = DoubleConv(base_filters * 2, base_filters)
        
        self.out = nn.Conv2d(base_filters, n_classes, 1)

    def forward(self, x):
        # 编码器前向
        e1 = self.enc1(x)
        e2 = self.enc2(self.pool1(e1))
        e3 = self.enc3(self.pool2(e2))
        e4 = self.enc4(self.pool3(e3))
        
        # 瓶颈
        b = self.bottleneck(self.pool3(e4))
        
        # 解码器前向 (包含跳跃连接)
        d4 = self.upconv4(b)
        # 拼接:上采样后的特征 + 对应的编码器特征
        d4 = torch.cat((e4, d4), dim=1) 
        d4 = self.dec4(d4)
        
        # 同理继续上采样和拼接
        d3 = self.upconv3(d4)
        d3 = torch.cat((e3, d3), dim=1)
        d3 = self.dec3(d3)
        
        d2 = self.upconv2(d3)
        d2 = torch.cat((e2, d2), dim=1)
        d2 = self.dec2(d2)
        
        d1 = self.upconv1(d2)
        d1 = torch.cat((e1, d1), dim=1)
        d1 = self.dec1(d1)
        
        return self.out(d1)

在 Pascal VOC 2012 上做语义分割#

Pascal VOC 2012 是计算机视觉领域最具影响力的基准数据集之一,由英国牛津大学和剑桥大学联合发布,广泛用于评估图像分类、目标检测及语义分割等算法性能。该数据集包含约 11,000 张标注图像,涵盖 20 个常见物体类别和 10 个人体动作类别,其数据结构清晰完整,包括原始 RGB 图像(JPEGImages)、目标检测标注文件(Annotations/)、任务划分文件(ImageSets/)以及语义分割掩码(SegmentationClass/)。在语义分割任务中,通常使用 ImageSets/Segmentation/train.txtval.txt 作为官方划分,并将像素值 255 视为忽略标签(ignore index)。作为像素级分割的重要试验平台,VOC 2012 自发布以来一直是经典 benchmark;不过需要注意的是,官方测试集真实标签并未公开,通常需要通过在线评测服务器提交结果,因此教学与研究中常使用训练集与验证集进行离线评估。

数据集下载#

VOC 2012 数据集可以从官方链接下载:

下载并解压后,会得到如下的文件结构

VOCDEVKIT
└─VOC2012
    ├─Annotations
    ├─ImageSets
    │  ├─Action
    │  ├─Layout
    │  ├─Main
    │  └─Segmentation
    ├─JPEGImages
    ├─SegmentationClass
    └─SegmentationObject

其中:

  • Annotations/: 包含每张图像的 XML 格式标注文件,主要用于目标检测任务。
  • ImageSets/: 包含不同任务的训练/验证/测试集划分文件。语义分割任务通常使用 Segmentation/train.txtSegmentation/val.txt
  • JPEGImages/: 包含原始 RGB 图像。
  • SegmentationClass/: 包含语义分割的掩码图像,每个像素的值对应一个类别 ID。
  • SegmentationObject/: 包含实例分割的掩码图像,每个像素的值对应一个实例 ID。

构建自定义数据集类——torch.utils.data.Dataset#

在实际项目中,我们很少能直接使用 PyTorch 内置的标准数据集。学会如何将自己的图像数据加载到模型中,是迈向实战的关键一步。PyTorch 提供了 torch.utils.data.DatasetDataLoader 两个核心工具来高效地处理数据。

Dataset 是一个抽象类,它定义了数据集的接口。要创建自己的数据集,只需继承这个类并实现两个核心方法:

  • __len__(self): 返回数据集的总样本数量。
  • __getitem__(self, idx): 根据给定的索引 idx,从数据集中读取并返回一个样本(通常是图像和对应的标签)。

这种设计将数据加载逻辑与模型训练循环解耦,使代码更加模块化和清晰。下面给出一个教学版示例;在本章工程代码中,我们进一步将图像与掩码的几何变换做了同步处理,并将预处理流程内置在数据集中。

import os
import numpy as np
from PIL import Image
import torch
from torch.utils.data import Dataset
from torchvision import transforms

class PascalVOC2012Dataset(Dataset):
    """极简版 Pascal VOC 2012 数据集
    仅包含基础的 Resize 和 Normalize 操作。
    """
    
    CLASSES = [
        'background', 'aeroplane', 'bicycle', 'bird', 'boat', 'bottle', 
        'bus', 'car', 'cat', 'chair', 'cow', 'diningtable', 'dog', 
        'horse', 'motorbike', 'person', 'pottedplant', 'sheep', 'sofa', 
        'train', 'tvmonitor'
    ]
    
    def __init__(self, root_dir, image_set='train', image_size=(512, 512)):
        """
        Args:
            root_dir (str): VOCdevkit/VOC2012 的根目录路径
            image_set (str): 'train', 'val', 或 'trainval'
            image_size (tuple): 输出图像大小 (H, W)
        """
        self.root_dir = root_dir
        self.image_size = image_size
        self.image_ids = self._load_split_file(image_set)
        
        # 基础预处理:转为张量并标准化
        self.transform = transforms.Compose([
            transforms.ToTensor(),
            transforms.Normalize(mean=[0.485, 0.456, 0.406],
                                 std=[0.229, 0.224, 0.225])
        ])

    def _load_split_file(self, split):
        """读取图片ID列表"""
        split_path = os.path.join(self.root_dir, 'ImageSets', 'Segmentation', f'{split}.txt')
        with open(split_path, 'r') as f:
            lines = f.readlines()
        return [line.strip() for line in lines]

    def __len__(self):
        return len(self.image_ids)

    def __getitem__(self, idx):
        img_id = self.image_ids[idx]
        
        # 1. 加载图像
        img_path = os.path.join(self.root_dir, 'JPEGImages', f'{img_id}.jpg')
        image = Image.open(img_path).convert('RGB')
        
        # 2. 加载掩码 (Segmentation Mask)
        mask_path = os.path.join(self.root_dir, 'SegmentationClass', f'{img_id}.png')
        mask = Image.open(mask_path).convert('P') # 'P' 表示调色板模式
        
        # 3. 统一调整大小 (Resize)
        # 使用 BILINEAR 插值调整图像,NEAREST 邻近插值调整掩码
        image = transforms.functional.resize(image, self.image_size, 
                                            interpolation=transforms.InterpolationMode.BILINEAR)
        mask = transforms.functional.resize(mask, self.image_size, 
                                           interpolation=transforms.InterpolationMode.NEAREST)
        
        # 4. 转换与归一化
        image = self.transform(image)
        mask = torch.as_tensor(np.array(mask), dtype=torch.long) # 转为 LongTensor
        
        return {'image': image, 'target': mask, 'image_id': img_id}

得到数据集对象后,我们就可以使用 DataLoader 来创建一个可迭代的数据加载器,支持批处理、打乱数据和多线程加载等功能。

from torch.utils.data import DataLoader

dataset = PascalVOC2012Dataset(root_dir='VOCdevkit/VOC2012', image_set='train')
dataloader = DataLoader(dataset, batch_size=4, shuffle=True, num_workers=2)
for batch in dataloader:
    images = batch['image']  # 形状: [B, C, H, W]
    targets = batch['target'] # 形状: [B, H, W]
    image_ids = batch['image_id']
    # 在这里可以进行训练循环的前向传播等操作

训练效果#

经过训练以后,会发现 UNet 模型在 Pascal VOC 2012 上的表现其实非常有限,mIoU 只能达到 6% 左右。这是因为 UNet 本身是为做细胞图像分割任务而设计的,UNet 提出的时候最初的数据集 ISBI 2012 细胞追踪挑战赛 的图像分辨率较低,且场景并不复杂。

图像分割本身是一个困难的任务,而 UNet 作为一个基础的分割网络,缺乏足够的语义理解能力来处理 VOC 2012 中复杂的场景和多样的物体类别。为了提升性能,可以使用更强大的骨干网络(如 ResNet101、ResNeXt101)来替换 UNet 中的编码器部分,或者引入注意力机制(如 SE 模块、CBAM 模块)来增强特征表达能力。此外,数据增强、损失函数设计(如 Dice Loss、Focal Loss)以及训练策略(如学习率调度、预训练权重微调)等方面的改进也能显著提升模型在 VOC 2012 上的分割性能。

教学部分的代码可以在 codes/ 中找到,可以使用下面的指令来训练模型

python main.py \
  --epochs 40 \                # 训练总轮数:模型将遍历整个数据集 40 次
  --batch-size 2 \             # 批大小:每次迭代处理 2 张图片(显存受限时的常见设置)
  --num-workers 0 \            # 数据加载线程数:0 表示在主线程中加载数据(便于调试,无多进程)
  --base-filters 32 \          # 基础通道数:网络第一层的卷积核数量(后续层通常以此倍增)
  --norm-type gn \             # 归一化类型:使用 Group Normalization (分组归一化),常用于小 batch size
  --image-size 512 \           # 图像尺寸:输入图片将被缩放或裁剪为 512x512
  --use-dice \                 # 启用 Dice 损失:开启 Dice Loss,常用于分割任务
  --dice-weight 0.2            # Dice 损失权重:总损失 = CE_Loss + Dice_Loss * 0.2

练习部分——在 Caltech101 上训练 ResNet、目标检测与小样本语义分割#

任务一:在 Caltech101 上训练 ResNet 完成图像分类#

背景知识#

数据集介绍

Caltech-101 是计算机视觉领域最经典且广泛使用的图像分类基准数据集之一,由加州理工学院(Caltech)于 2003 年发布。该数据集包含 102 个类别 的图像,其中 101个 目标类别(如各类动物、车辆、乐器等)和 1 个背景类别。每个目标类别下大约有 40到800张不等的图像,总计约 9,000 多张图片,涵盖了物体在大小、姿态、光照、背景和遮挡等方面的巨大变化,旨在测试算法对复杂真实场景的识别能力。

由于其数据标注规范、类别丰富且具有挑战性,Caltech-101 长期以来被作为评估图像分类、目标检测及视觉定位(Visual Grounding)等模型性能的“标准试金石”。尽管随着更大数据集(如ImageNet)的出现,其地位有所变化,但它依然是研究小样本学习、细粒度分类和多模态任务中不可或缺的参考基准。

模型介绍

ResNet 有多种不同参数量大小的模型,可以参考以下表格

模型名称层数参数量(百万)适用场景huggingface 模型
ResNet-181811.7资源受限、小型数据集https://huggingface.co/microsoft/resnet-18
ResNet-343421.8中等资源、较大数据集https://huggingface.co/microsoft/resnet-34
ResNet-505025.6大型数据集、需要更强特征提取能力https://huggingface.co/microsoft/resnet-50
ResNet-10110144.5大型数据集、追求更高性能https://huggingface.co/microsoft/resnet-101
ResNet-15215260.2大型数据集、追求极致性能https://huggingface.co/microsoft/resnet-152

数据增强

数据增强是提升模型泛化能力的关键手段,尤其在数据量有限的情况下更显重要。常见的数据增强方法包括:

  • 几何变换:如随机裁剪、水平翻转、旋转等,可以增加数据的多样性,帮助模型学习到更鲁棒的特征。
  • 颜色变换:如随机调整亮度、对比度、饱和度等,可以模拟不同的光照条件,增强模型对颜色变化的适应能力。
  • 噪声添加:如高斯噪声、椒盐噪声等,可以提高模型对输入数据中的噪声的鲁棒性。
  • Cutout:随机遮挡图像的一部分,迫使模型关注图像的其他区域,提升模型的泛化能力。
  • Mixup:将两张图像及其标签进行线性混合,生成新的训练样本,可以有效缓解过拟合问题。

在 PyTorch 中,可以使用 torchvision.transforms 模块轻松实现这些数据增强方法。例如:

from torchvision import transforms

data_transforms = transforms.Compose([
    transforms.RandomResizedCrop(224),  # 随机裁剪并调整大小
    transforms.RandomHorizontalFlip(),  # 随机水平翻转
    transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1),  # 随机调整颜色
    transforms.Mixup(alpha=0.2),  # Mixup 数据增强
    transforms.RandomErasing(),  # Cutout 数据增强
    transforms.ToTensor(),  
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

这些数据增强方法可以单独使用,也可以组合使用,根据具体任务和数据集的特点进行选择和调整,以达到最佳的训练效果。

要求#

  1. 你需要实现一个自定义的 Dataset 类来加载 Caltech101 数据集,并使用数据增强的方法扩展训练数据。
  2. 选择某一个参数量的 ResNet 模型,用代码实现它,并在 Caltech101 上进行训练和评估。
  3. 你需要记录训练过程中的损失和准确率,并在验证集上评估模型性能。
  4. (可选) 对比不同参数量大小的 ResNet 模型在 Caltech101 上的表现,分析模型复杂度与性能之间的关系。
  5. 调用 HuggingFace 上预训练的 ResNet 模型在 Caltech101 训练集上进行微调,在验证集上评估性能,并与自己训练的模型进行性能对比。

任务二:目标检测任务#

背景知识#

目标检测任务介绍

目标检测是计算机视觉中的一项核心任务,它的目标是让机器能够:

  • 识别图像中有哪些物体 (分类);
  • 定位这些物体在图像中的具体位置 (通常用矩形边界框 Bounding Box 表示)。

给定一个输入图像 xRH×W×Cx \in \mathbb{R}^{H \times W \times C},其中 HHWWCC 分别表示图像的高度、宽度和通道数。假设总共有 KK 个类别。目标检测任务的目标是学习出一个从图像 xx边界框序列及其对应类别的映射函数:

fdetect:xB={(b1,c1),(b2,c2),,(bN,cN)}f_{\text{detect}}: x \mapsto \mathcal{B} = \{(b_1, c_1), (b_2, c_2), \dots, (b_N, c_N)\}

其中输出 B\mathcal{B} 是一个包含 NN 个检测结果的集合(NN 为不定值,取决于图像中物体的数量),且满足以下条件:

  1. 边界框定义:每个 biR4b_i \in \mathbb{R}^4 表示第 ii 个物体的位置坐标。通常采用中心点-宽高形式 bi=[xc,yc,w,h]b_i = [x_c, y_c, w, h] 或左上角-右下角形式 bi=[xmin,ymin,xmax,ymax]b_i = [x_{min}, y_{min}, x_{max}, y_{max}],其数值范围需与图像尺寸 (H,W)(H, W) 对齐。
  2. 类别标签:每个 ci{0,1,,K}c_i \in \{0, 1, \dots, K\} 表示第 ii 个物体的类别索引(通常 00 代表背景类 Background)。
  3. 非重复性约束:对于任意 iji \neq j,物体 ii 和物体 jj 在空间上不应存在严重的重叠(即IoU低于阈值),且每个预测框必须对应图像中的真实物体。
目标检测示例
目标检测结果示例,摘自 https://zhuanlan.zhihu.com/p/368904941

YOLO 系列模型介绍

YOLO(You Only Look Once)是计算机视觉领域最具影响力的实时目标检测算法系列。其核心突破在于将检测任务转化为单一的回归问题,摒弃了传统两阶段方法中耗时的“候选区域生成”步骤,仅需一次前向传播即可同时预测物体的位置和类别。这种设计赋予了YOLO极高的推理速度,使其能够轻松达到每秒几十帧甚至上百帧,完美适配自动驾驶、视频监控等对实时性要求严苛的场景,成为连接学术研究与工业落地的桥梁。

YOLOv3 发布于 2018 年,是该系列中承前启后的经典之作,也是初学者理解目标检测原理的最佳入门版本。它引入了特征金字塔(FPN)多尺度预测机制,利用 Darknet-53 骨干网络在三个不同分辨率的特征图上分别检测大、中、小物体,显著提升了小目标检测能力。此外,YOLOv3采用了经典的**锚框(Anchor Box)**机制来辅助定位。尽管架构相对现代版本略显陈旧,但其逻辑清晰、结构完整,是学习如何从零搭建神经网络和编写损失函数的理想教材。

截至 2024 年底,YOLOv11(Ultralytics发布)是目前工业界最主流的最新版本,它在保持轻量级的同时通过改进的注意力机制进一步提升了精度;学术界则已出现引入纯注意力机制的YOLOv12。对于开发者,无需手动搭建模型,直接使用ultralytics库调用预训练模型只需几行代码:

from ultralytics import YOLO
# 加载预训练模型 (n=微小, s=小, m=中等)
model = YOLO("yolov11n.pt") 
# 直接进行推理
results = model.predict(source="image.jpg", save=True)
# 如需微调,只需一行代码
# results = model.train(data="data.yaml", epochs=50)

YOLO v3 从零搭建

ultralytics 官方仓库和手册

要求#

  1. 思考目标检测任务的损失函数应该是什么样的?交叉熵或者 Dice Loss 还适合这个任务吗?你需要自己设计一个适合目标检测的损失函数,并在训练过程中使用它来优化模型。
  2. 参考从零搭建 YOLOv3 的教程,理解其核心组件,并在自行设计的基于 Pascal VOC 数据集的数据加载器上进行训练,并使用 mAP(mean Average Precision)指标进行评估模型性能。
  3. 直接使用 ultralytics 库调用预训练的 YOLOv11 模型,在 Pascal VOC 数据集上进行微调,并评估其性能,比较与自己搭建的模型在 mAP 上的差异。

任务三:理解小样本语义分割任务#

背景知识#

小样本语义分割(Few-Shot Semantic Segmentation)是语义分割任务的扩展,旨在让模型能够在仅有少量标注样本的情况下,学习到新的类别并进行像素级的分割预测。这一任务具有重要的实际意义,因为在许多应用场景中,获取大量标注数据既昂贵又耗时。

要求#

调研小样本语义分割领域的研究发展历程,弄清楚从领域开始到最新的研究成果中,各个方法流派的核心思想和代表性工作,并形成一份调研报告。

更多参考资料#

图像分割与目标检测

聚类

边缘检测

ResNet 模型以及扩展

转置卷积

数据增强

从零开始学 AI - 第二章:图像分割
https://adalovelemon.github.io/blog/en/posts/content/coursenotes/learn-ai-from-scratch/chapter2/
Author
Ada Lovelemon
Published at
2026-04-05

Comments Section