文章目录
- 摘要
- 第13章 计算机视觉
- 13.5 树叶分类竞赛总结
- 13.6 目标检测算法
- 13.6.1 区域卷积神经网络系列算法R-CNN(Region-based CNN or Regions with CNN features)
- 13.6.2 单发多框检测(SSD)
- 13.6.3 YOLO(You Only Look Once)
- 13.7 多尺度目标检测
- 13.7.1 多尺度锚框
- 13.7.2 单发多框检测(SSD)代码实现
- 13.8 语义分割和数据集
- 13.8.1 语义分割
- 13.8.2 语义分割数据集
- 13.9 转置卷积 (transposed convolution)
- 基础卷积
- 转置卷积与卷积的异同
- 与矩阵变换的联系
- 总结
摘要
本周主要学习了计算机视觉中的语义分割和目标检测技术。在语义分割部分,重点分析了Pascal VOC2012数据集的处理方法,包括RGB标签到类别ID的转换、随机裁剪数据增强以及自定义数据集的构建。目标检测方面深入研究了R-CNN系列算法(R-CNN、Fast R-CNN、Faster R-CNN、Mask R-CNN)的演进过程,对比了两阶段检测与单阶段检测(SSD、YOLO)的特点,并实现了SSD模型的完整训练流程。此外还详细探讨了转置卷积的原理及其与常规卷积的异同,包括填充、步幅和多通道处理等核心概念,并通过矩阵变换的角度解释了卷积运算的数学本质。
由于上周锚框代码和这周的单发多框检测SSD代码实现比较复杂,花了很多时间去看,需要根据书上代码一行一行理解原理,
第13章 计算机视觉
13.5 树叶分类竞赛总结
比赛地址:树叶分类
技术分析
1、数据增强
- 在测试的时候多次使用较弱的增强然后取平均(在测试的时候是在中心点 crop 裁剪出一张图片做预测,很多时候也有在四个角上 crop 一次,然后将五次所得的结果取平均)
2、使用多个模型预测,最后加权平均
- 有使用多个模型加权取平均的,也有使用单一模型多次训练然后取平均的
- 模型多为 ResNet 变种(对模型进行了更多的细节上的调整):DenseNet,ResNeXt,ResNeSt,EfficientNet
3、优化算法和学习率
- 优化算法一般使用的是 SGD 或者 Adam(多为 Adam 或者其变种,SGD 通过仔细调参(如 momentum 等)通常能够获得比 Adam 更好的结果,但是 Adam 对学习率不太敏感,换句话说,Adam 在不需要过多调参的情况下也能够获得不错的加过,Adam 的结果更加平滑(可以理解为:如果两种算法的解集看成是同一坐标系中的两条二次函数曲线的话,SGD 所得的解集波峰比 Adam 更高,但是 SGD 对调参的要求也更高,Adam 的曲线更加平滑,虽然波峰比不上 SGD 但是相对来讲整体更加稳定,受参数的影响较小),所以在不那么关注优化算法而是对模型和数据进行改进的情况下,可以使用 Adam 来弱化调参队最终结果的影响)
- 学习率基本上是固定不变的,也有可能经过一段时间之后下降一次:一般是基于 Cosine下降 (好处是不用调参)或者是
训练不动的时候往下调(每隔一定的 epoch 之后学习率下降一次)
4、清理数据
- 有重复的图片,可以手动去除
- 图片背景较多,而且树叶没有方向性,可以做更多增强(随机旋转,更大的裁剪)
- 跨图片增强:Mixup:随机取两张图片,然后取一个随机权重,再将两张图片进行叠加(标号也进行叠加)得到一个新的训练图片参加训练);CutMix:(在不同的图片中随机采样一些块,然后取一个随机的权重将这些块进行随机的组合,最终所得的图片的标号也取决于随机组合的权重,这样做的好处是能够尽可能地关注数据中的局部信息)
小结
- 提升精度思路:根据数据挑选增强,使用新模型、新优化算法,多个模型融合,测试时使用增强
- 在工业界应用:
- -少使用模型融合和测试时增强,计算代价过高
- 通过固定模型超参数,而将经理主要花在提升数据质量
13.6 目标检测算法
本节只简要介绍一下区域卷积神经网络(region-based CNN或regions with CNN features,R-CNN)及其一系列改进方法 Fast R-CNN 、Faster R-CNN、掩码Mask R-CNN 和单发多框检测SSD和YoLo (You Only Look Once)这些模型的设计思路
目标检测算法主要分为两个类型
(1)two-stage方法,如R-CNN系算法(region-based CNN),其主要思路是先通过启发式方法(selective search)或者CNN网络(RPN)产生一系列稀疏的候选框,然后对这些候选框进行分类与回归,two-stage方法的优势是准确度高
(2)one-stage方法,如Yolo和SSD,其主要思路是均匀地在图片的不同位置进行密集抽样,抽样时可以采用不同尺度和长宽比,然后利用CNN提取特征后直接进行分类与回归,整个过程只需要一步,所以其优势是速度快,但是均匀的密集采样的一个重要缺点是训练比较困难,这主要是因为正样本与负样本(背景)极其不均衡,导致模型准确度稍低
13.6.1 区域卷积神经网络系列算法R-CNN(Region-based CNN or Regions with CNN features)
R-CNN
R-CNN首先从输入图像中选取若干(例如2000个)提议区域(如锚框也是一种选取方法),并标注它们的类别和边界框(如偏移量)。然后,用卷积神经网络对每个提议区域进行前向传播以抽取其特征。 接下来,我们用每个提议区域的特征来预测类别和边界框。下图展示了R-CNN模型

R-CNN包括以下四个步骤:
1.对输入图像使用选择性搜索来选取多个高质量的提议区域 。这些提议区域通常是在多个尺度下选取的,并具有不同的形状和大小。每个提议区域都将被标注类别和真实边界框;
2.选择一个预训练的卷积神经网络,并将其在输出层之前截断。将每个提议区域变形为网络需要的输入尺寸,并通过前向传播输出抽取的提议区域特征;
3.将每个提议区域的特征连同其标注的类别作为一个样本。训练多个支持向量机对目标分类,其中每个支持向量机用来判断样本是否属于某一个类别;
4.将每个提议区域的特征连同其标注的边界框作为一个样本,训练线性回归模型来预测真实边界框。
每次选取的锚框大小是不一样的,怎么使这些大小不一的锚框变成一个batch?
Rol pooling(兴趣区域池化层)
- R-CNN比较关键的一层,让大小不一的锚框变成统一的形状
- 给定一个锚框,先均匀的分割成nm块,然后输出每一块里面的最大值,这样不论锚框有多大都只输出nm个值。不同大小的锚框就变成了同样的大小,作为一个小批量处理比较方便

- 上图中对 3 * 3 的黑色方框中的区域进行 2 * 2 的兴趣区域池化,由于 3 * 3 的区域不能均匀地进行切割成 4 块,所以会进行取整(最终将其分割成为 2 * 2、1 * 2、2 * 1、1 * 1 四块),在做池化操作的时候分别对四块中每一块取最大值,然后分别填入 2 * 2 的矩阵中相应的位置
兴趣区域池化层(RoI Pooing)和最大池化层的区别?
-
池化层是通过窗口大小、填充、步幅来间接控制输出形状
-
在兴趣区域池化层,对每个区域的
输出形状是可以直接指定的
小结
- 尽管 R-CNN 模型通过预训练的卷积神经网络有效地抽取了图像特征,但是速度非常慢(如果从一张图片中选取了上千个提议区域,就需要上千次的卷积神经网络的前向传播来执行目标检测,计算量非常大)
Fast R-CNN
Fast R-CNN对R-CNN最主要的改进之一:仅在整张图像上执行卷积神经网络的前向传播

- Fast R-CNN 的改进是:在拿到一张图片之后,首先使用 CNN 对图片进行特征提取(不是对图片中的锚框进行特征提取,而是对整张图片进行特征提取,仅在整张图像上执行卷积神经网络的前向传播),最终会得到一个 7 * 7 或者 14 * 14 的 feature map
抽取完特征之后,再对图片进行锚框的选择(selective search),搜索到原始图片上的锚框之后将其(按照一定的比例)映射到 CNN 的输出上 - 映射完锚框之后,再使用 RoI pooling 对 CNN 输出的 feature map 上的锚框进行特征抽取,生成固定长度的特征(将 n * m 的矩阵拉伸成为 nm 维的向量),之后再通过一个全连接层(这样就不需要使用SVM一个一个的操作,而是一次性操作了)对每个锚框进行预测:物体的类别和真实的边缘框的偏移
Fast R-CNN 相对于 R-CNN 更快的原因是:Fast R-CNN 中的 CNN 不再对每个锚框抽取特征,而是对整个图片进行特征的提取(这样做的好处是:不同的锚框之间可能会有重叠的部分,如果对每个锚框都进行特征提取的话,可能会对重叠的区域进行多次重复的特征提取操作),然后再在整张图片的feature中找出原图中锚框对应的特征,最后一起做预测
Faster R-CNN
- 为了精确地检测目标结果,Fast R-CNN 模型通常需要在选择性搜索中生成大量的提议区域
- 使用了一个
区域提议网络来替代启发式搜索获得更好的锚框,从而减少区域的生成数量,并保证目标检测的精度

- Faster R-CNN 的改进:使用 RPN 神经网络来替代 selective search (选择性搜索)
- RoI 的输入是CNN 输出的 feature map 和生成的锚框
- RPN 的输入是 CNN 输出的 feature map,输出是一些比较高质量的锚框(可以理解为一个比较小而且比较粗糙的目标检测算法: CNN 的输出进入到 RPN 之后再做一次卷积,然后生成一些锚框(可以是 selective search 或者其他方法来生成初始的锚框),再训练一个二分类问题:预测锚框是否框住了真实的物体以及锚框到真实的边缘框的偏移,最后使用 NMS 进行去重,使得锚框的数量变少)
- RPN 的作用是生成大量结果很差的锚框,然后进行预测,最终输出比较好的锚框供后面的网络使用(预测出来的比较好的锚框会进入 RoI pooling,后面的操作与 Fast R-CNN 类似)
- 通常被称为两阶段的目标检测算法:RPN 做小的目标检测(粗糙),整个网络再做一次大的目标检测(精准)
- ·
Faster R-CNN 目前来说是用的比较多的算法,准确率比较高,但是速度比较慢
区域提议网络的步骤如下图所示:

- 区域提议网络作为Faster R-CNN模型的一部分,是和整个模型一起训练得到的。 (
Faster R-CNN的目标函数不仅包括目标检测中的类别和边界框预测,还包括区域提议网络中锚框的二元类别和边界框预测。) - 作为端到端训练的结果,区域提议网络能够学习到如何生成高质量的提议区域,从而在减少了从数据中学习的提议区域的数量的情况下,仍保持目标检测的精度。
Mask R-CNN

Mask R-CNN 是基于 Faster R-CNN 修改而来的,改进在于
- 假设有每个像素的标号的话,就可以对每个像素做预测(FCN)
- 将兴趣区域汇聚层替换成了兴趣区域对齐层(RoI pooling -> RoI align),使用双线性插值(bilinear interpolation)保留特征图上的空间信息,进而更适于像素级预测:对于pooling来说,假如有一个3 * 3的区域,需要对它进行2 * 2的RoI pooling操作,那么会进行取整从而切割成为不均匀的四个部分,然后进行 pooling 操作,这样切割成为不均匀的四部分的做法对于目标检测来说没有太大的问题,因为目标检测不是像素级别的,偏移几个像素对结果没有太大的影响。但是对于像素级别的标号来说,会产生极大的误差;RoI align 不管能不能整除,如果不能整除的话,会直接将像素切开,切开后的每一部分是原像素的加权(它的值是原像素的一部分)
- 兴趣区域对齐层的输出包含了所有与兴趣区域的形状相同的特征图,它们不仅被用于预测每个兴趣区域的类别和边界框,还通过额外的全卷积网络预测目标的像素级位置
小结
- R-CNN 是最早、也是最有名的一类基于锚框和 CNN 的目标检测算法(R-CNN 可以认为是使用神经网络来做目标检测工作的奠基工作之一),它对图像选取若干提议区域,使用卷积神经网络对每个提议区域执行前向传播以抽取其特征,然后再用这些特征来预测提议区域的类别和边框
- Fast/Faster R-CNN持续提升性能:Fast R-CNN 只对整个图像做卷积神经网络的前向传播,还引入了兴趣区域汇聚层(RoI pooling),从而为具有不同形状的兴趣区域抽取相同形状的特征;Faster R-CNN 将 Fast R-CNN 中使用的选择性搜索替换为参与训练的区域提议网络,这样可以在减少提议区域数量的情况下仍然保持目标检测的精度;Mask R-CNN 在 Faster R-CNN 的基础上引入了一个全卷积网络,从而借助目标的像素级位置进一步提升目标检测的精度
- Faster R-CNN 和 Mask R-CNN 是在追求高精度场景下的常用算法(Mask R-CNN 需要有像素级别的标号,所以相对来讲局限性会大一点,在无人车领域使用的比较多)
13.6.2 单发多框检测(SSD)
生成锚框

下面是SSD的网络结构:

- 输入图像之后,首先进入一个基础网络来提取特征,提取完特征之后对每个像素生成大量的锚框(每个锚框就是一个样本,然后预测锚框的类别以及到真实边界框的偏移),多个卷积块来减半宽高
- SSD 在给定锚框之后直接对锚框进行预测,而不需要做两阶段(为什么 Faster RCNN 需要做两次,而 SSD 只需要做一次?SSD 通过做不同分辨率下的预测来提升最终的效果,越到底层的 feature map,就越大,越往上,feature map 越少,因此底层更加有利于小物体的检测,而上层更有利于大物体的检测)
- SSD 不再使用 RPN 网络,而是直接在生成的大量样本(锚框)上做预测,看是否包含目标物体;如果包含目标物体,再预测该样本到真实边缘框的偏移
模型精度

- SSD 相对于Faster RCNN 来讲速度快很多,但是精度不是太好
- SSD 的实现相对来讲比较简单,R-CNN 系列代码的实现非常困难
13.6.3 YOLO(You Only Look Once)
- yolo也是一个单阶段算法,只有一个单神经网络来预测
- yolo 也需要锚框,这点和 SSD 相同,但是 SSD 是对每个像素点生成多个锚框,所以在绝大部分情况下两个相邻像素的所生成的锚框的重叠率是相当高的,这样就会导致很大的重复计算量。
- yolo 的想法是尽量让锚框不重叠:首先将图片均匀地分成 S * S 块,每一块就是一个锚框,每一个锚框预测 B 个边缘框(考虑到一个锚框中可能包含多个物体),所以最终就会产生 S ^ 2 * B 个样本,因此速度会远远快于 SSD
- yolo 在后续的版本(V2,V3,V4…)中在细节上有持续的改进,但是核心思想没有变,真实的边缘框不会随机的出现,真实的边缘框的比例、大小在每个数据集上的出现是有一定的规律的,在知道有一定的规律的时候就可以使用聚类算法将这个规律找出来(给定一个数据集,先分析数据集中的统计信息,然后找出边缘框出现的规律,这样之后在生成锚框的时候就会有先验知识,从而进一步做出优化)
13.7 多尺度目标检测
如果以输入图像的每个像素为中心生成锚框,会得到太多需要计算的锚框。例如,一个561像素*728像素的输入图像,以每个像素为中心生成五个形状不同的锚框,就需要标注和预测超过200万个锚框.( 561 ∗ 728 ∗ 5 = 2042040 561*728*5=2042040 561∗728∗5=2042040)
13.7.1 多尺度锚框
在不同尺度下,可以生成不同数量和不同大小的锚框。例如 1 像 素 × 1 像 素 1像素\times1像素 1像素×1像素、 1 像 素 × 2 像 素 1像素\times2像素 1像素×2像素、 2 像 素 × 2 像 素 2像素\times2像素 2像素×2像素的目标可以分别以4种、2种、1种的方式出现在 2 像 素 × 2 像 素 2像素\times2像素 2像素×2像素的图像上。
下面使用代码演示如何在多个尺度下生成锚框,先读取一张图片,他的宽度和高度分别是561像素和728像素。
%matplotlib inline
# 在Jupyter Notebook中内嵌显示matplotlib图像
import torch
from d2l import torch as d2l
img=d2l.plt.imread('../img/catdog.jpg') #调用的matplotlib.pyplot.imread() 函数来读取图像文件
h,w=img.shape[:2]
h,w

通过定义特征图的形状,我们可以确定任何图像上均匀抽样锚框的中心。
给定特征图的宽度fmap_w和高度fmap_h,下面的函数将均匀第对任何输入图像中fmap_h行和fmap_w列中的像素进行抽样。然后以这些均匀抽样的像素为中心,将会生成尺度为s且宽高比ratios不同的锚框
def display_anchors(fmap_w,fmap_h,s):
"""在特征图 (feature map) 上生成锚框 (anchors),每个单位(像素)作为锚框的中心"""
d2l.set_figsize()
#前两个维度上的值不影响输出
fmap=torch.zeros(1,10,fmap_h,fmap_w) #创建10个假特征图
anchors=d2l.multibox_prior(fmap,sizes=s,ratios=[1,2,0.5]) #生成锚框 (基于特征图尺寸和比例)
bbox_scale=torch.tensor((w,h,w,h)) #将归一化锚框坐标缩放回原图尺寸 (w,h,w,h格式)
d2l.show_bboxes(d2l.plt.imshow(img).axes,anchors[0]*bbox_scale) #在图像上绘制锚框
探测小目标
display_anchors(fmap_w=4, fmap_h=4, s=[0.15])
#在4x4特征图上生成缩放比例为0.15的锚框,占图片的15%

将特征图的宽高减半,使用较大锚框来检测较大的目标。当尺度被设置为0.4时,一些锚框将会重叠
display_anchors(fmap_w=2, fmap_h=2, s=[0.4])

再进一步减半宽高,增加锚框尺度到0.8。此时锚框的中心即图像的中心
display_anchors(fmap_w=1, fmap_h=1, s=[0.8])

13.7.2 单发多框检测(SSD)代码实现
模型

- 通过多尺度特征块,单发多框检测生成不同大小的锚框,并通过预测边界框的类别和偏移量来检测大小不同的目标,因此这是一个多尺度目标检测模型。
- SSD基础网络块由锚框来进行类别预测和边界框预测
类别预测层
类别预测层使用一个保持输入的宽度和高度不变的卷积层,输出通道数=锚框数*(类别数+1是背景类)
%matplotlib inline
import torch
import torchvision
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l
def cls_predictor(nums_inputs,nums_anchors,num_classes):
#使用卷积层来输出类别的预测可以降低模型复杂度,不改变宽高(而不是全连接层)
return nn.Conv2d(nums_inputs,
nums_anchors*(num_classes+1), #输出通道数
kernel_size=3, #卷积核大小
padding=1
)
边界框预测层
与类别预测框不同,需要为每个锚框预测4个偏移量
def box_predictor(nums_inputs,nums_anchors):#边界框预测
return nn.Conv2d(nums_inputs,
nums_anchors*4, #预测4个偏移
kernel_size=3,
padding=1)
多尺度预测
在不同尺度下,特征图的形状和以同一单元为中心的锚框数量有所不同,预测输出的形状也有所不同
def forward(x, block):#执行前向计算
return block(x)
Y1 = forward(torch.zeros((2, 8, 20, 20)), cls_predictor(8, 5, 10))
#批量大小,通道数,高度,宽度 输入通道,锚框数,类别数
Y2 = forward(torch.zeros((2, 16, 10, 10)), cls_predictor(16, 3, 10))
Y1.shape, Y2.shape #输出通道数=5*(10+1)=55 3*(10+1)=33

为了将不同的预测输出连接起来提高计算效率,需要把张量转换成一致的格式。
不同尺度下批量大小不变,先将预测结果转换成二维格式(批量大小,高度宽度通道数),再在维度1上拼接
可以用下面的函数来是实现展平:
torch.flatten(input,start_dim,end_dim)
start_dim:0 开始展平的起始维度(默认从第 0 维开始)。
end_dim:-1 结束展平的终止维度(默认为最后一维)。
def flatten_pred(pred): #展平预测标签,start_dim=1是把高度宽度通道数拉成一维
return torch.flatten(pred.permute(0, 2, 3, 1), start_dim=1) #这里最后得到二维张量[0,[2,3,1]]
def concat_preds(preds): #将多个预测张量(preds)拼接(concatenate)成一个更大的张量
return torch.cat([flatten_pred(p) for p in preds], dim=1) #拼接上面组合而成的宽高通道数,保留批次数
concat_preds([Y1, Y2]).shape #[2,55*20*20+33*10*10]=[2,22000+3300]

宽高减半块
每个宽高减半块由两个填充为1的33卷积快以及步幅为2的22最大汇聚层组成,虽然填充为1的3*3卷积块不改变特征图的形状,但是可以增加每个单元在其输出特征图上的感受野。
def down_sample_blk(in_channels, out_channels):
blk=[]
for _ in range(2):
blk.append(nn.Conv2d(in_channels,out_channels,kernel_size=3,padding=1)) #卷积层
blk.append(nn.BatchNorm2d(out_channels)) #批量标准化
blk.append(nn.ReLU()) #relu激活函数
in_channels=out_channels
blk.append(nn.MaxPool2d(2))#最大池化层来减半宽高
return nn.Sequential(*blk)
基础网络块
一个基础网络块串联了三个高宽减半块,并逐步将通道数翻倍
def base_net(): #基本网络块
blk = []
num_filters = [3, 16, 32, 64] #[输入, 第1层输出, 第2层输出, 第3层输出]
for i in range(len(num_filters) - 1):
blk.append(down_sample_blk(num_filters[i], num_filters[i + 1]))
return nn.Sequential(*blk)
forward(torch.zeros((2, 3, 256, 256)), base_net()).shape
#输出从3——>16->32->64 宽高减半三次256-128-64-32
完整的模型
完整的SSD模型由五个模块组成,第一个模块是基础网络块,第二个到第四个是高宽减半块,最后一个模块使用全局最大汇聚层将宽高都降为1.
def get_blk(i):
if i == 0:
blk = base_net()
elif i == 1:
blk = down_sample_blk(64, 128)
elif i == 4:
blk = nn.AdaptiveMaxPool2d((1, 1))
else:
blk = down_sample_blk(128, 128)
return blk
定义前向传播函数,与图像分类不同,输出不仅包含特征图Y还包含在当前尺度下根据Y生成的锚框、预测类别、偏移量。
def blk_forward(X,blk,size,ratio,cls_predictor,bbox_predictor):
Y=blk(X) #特征图
anchors=d2l.multibox_prior(Y,sizes=size,ratios=ratio) #锚框
cls_pred=cls_predictor(Y) #预测类别
bbox_preds=bbox_predictor(Y) #边界框预测
return(Y,anchors,cls_pred,bbox_preds)
设置超参数
sizes = [[0.2, 0.272], [0.37, 0.447], [0.54, 0.619], [0.71, 0.79],
[0.88, 0.961]] #这里可以随便取,较小值是0.2-1.05之间
ratios = [[1, 2, 0.5]] * 5
num_anchors = len(sizes[0]) + len(ratios[0]) - 1 #锚框数
定义完整的TinySSD模型
setattr 和 getattr 是 Python 的内置函数,用于动态地设置和获取对象的属性。它们在 PyTorch 的模型定义中经常用于模块化地管理子模块(如 nn.Module 的子模块)
class TinySSD(nn.Module):
def __init__(self, num_classes, **kwargs):
super(TinySSD, self).__init__(**kwargs)
self.num_classes = num_classes #类别数
idx_to_in_channels = [64, 128, 128, 128, 128] #输出通道
for i in range(5):
setattr(self, f'blk_{i}', get_blk(i)) # 设置第i个特征提取块(基础网络+下采样)
setattr( # # 设置第i个分类预测器(预测每个锚框的类别)
self, f'cls_{i}',
cls_predictor(idx_to_in_channels[i], num_anchors,
num_classes))
setattr(self, f'bbox_{i}', # 设置第i个边界框预测器(预测每个锚框的偏移量)
bbox_predictor(idx_to_in_channels[i], num_anchors))
def forward(self, X): # 对每个特征提取块进行前向传播
anchors, cls_preds, bbox_preds = [None] * 5, [None] * 5, [None] * 5
for i in range(5):
X, anchors[i], cls_preds[i], bbox_preds[i] = blk_forward(
X, getattr(self, f'blk_{i}'), sizes[i], ratios[i],
getattr(self, f'cls_{i}'), getattr(self, f'bbox_{i}'))
anchors = torch.cat(anchors, dim=1) #将所有锚框沿维度1拼接(合并不同特征图生成的锚框)
cls_preds = concat_preds(cls_preds) # 拼接所有分类预测结果并调整形状
cls_preds = cls_preds.reshape(cls_preds.shape[0], -1, # 重塑为(batch_size, 总锚框数, 类别数+1)
self.num_classes + 1) #加1是背景类
bbox_preds = concat_preds(bbox_preds) #拼接边缘预测框的结果
return anchors, cls_preds, bbox_preds
创建实例,执行前向计算
net = TinySSD(num_classes=1)
X = torch.zeros((32, 3, 256, 256))
anchors, cls_preds, bbox_preds = net(X)
print('output anchors:', anchors.shape) #锚框集合数,锚框个数,每个锚框四个坐标
print('output class preds:', cls_preds.shape)#输入图像,锚框数,类别数+1
print('output bbox preds:', bbox_preds.shape)#输入,锚框数*4=5444*4=21776

读取数据集和初始化
batch_size = 32
train_iter, _ = d2l.load_data_bananas(batch_size)
device, net = d2l.try_gpu(), TinySSD(num_classes=1)
trainer = torch.optim.SGD(net.parameters(), lr=0.2, weight_decay=5e-4) #SGD优化
定义损失函数和评价函数
cls_loss = nn.CrossEntropyLoss(reduction='none') #锚框类别的损失,使用交叉熵损失用于计算锚框类别预测的损失(多分类问题)
# reduction='none'表示不自动求平均或求和,保留每个样本的损失值
bbox_loss = nn.L1Loss(reduction='none') #L1范数损失,预测值与真实值之差的绝对值
#bbox_mask令负类锚框和填充锚框不参与损失的计算
def calc_loss(cls_preds, cls_labels, bbox_preds, bbox_labels, bbox_masks):
""" 计算总损失=分类损失+边界框回归损失
cls_preds (Tensor): 分类预测结果,形状为(batch_size, 总锚框数, num_classes+1)
cls_labels (Tensor): 分类真实标签,形状为(batch_size, 总锚框数)
bbox_preds (Tensor): 边界框偏移量预测,形状为(batch_size, 总锚框数, 4)
bbox_labels (Tensor): 边界框偏移量真实值,形状同bbox_preds
bbox_masks (Tensor): 边界框掩码,用于过滤负类锚框和填充锚框,形状同bbox_preds """
batch_size, num_classes = cls_preds.shape[0], cls_preds.shape[2]
cls = cls_loss(cls_preds.reshape(-1, num_classes), #预测结果reshape为(-1, num_classes),即(批量*锚框数, 类别数)
cls_labels.reshape(-1)).reshape(batch_size, -1).mean(dim=1) #reshape回(batch_size, 总锚框数),再对每个样本的所有锚框损失求平均(dim=1)
bbox = bbox_loss(bbox_preds * bbox_masks,
bbox_labels * bbox_masks).mean(dim=1)
return cls + bbox
def cls_eval(cls_preds, cls_labels):
return float( #类别预测结果在最后一维,argmax需要指定最后一维
(cls_preds.argmax(dim=-1).type(cls_labels.dtype) == cls_labels).sum())
#因为偏移量使用了L1范数损失,这里使用平均绝对误差来评价边界框
def bbox_eval(bbox_preds, bbox_labels, bbox_masks):
return float((torch.abs((bbox_labels - bbox_preds) * bbox_masks)).sum())
训练模型
# 设置训练轮次为20,并初始化计时器
num_epochs, timer = 20, d2l.Timer()
# 创建动画可视化器,用于绘制训练过程中的分类错误率和边界框MAE
animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs],
legend=['class error', 'bbox mae'])
# 将模型移动到指定设备(GPU/CPU)
net = net.to(device)
# 开始训练循环
for epoch in range(num_epochs):
# 初始化指标累加器(存储4个值:分类正确数、总样本数、边界框误差和、有效锚框数)
metric = d2l.Accumulator(4)
# 设置为训练模式(启用dropout/batch norm等训练专用层)
net.train()
# 遍历训练数据集
for features, target in train_iter:
# 开始计时(测量单次迭代时间)
timer.start()
# 清空优化器的梯度
trainer.zero_grad()
# 将数据移动到指定设备
X, Y = features.to(device), target.to(device)
# 前向传播:获取锚框、分类预测和边界框预测
anchors, cls_preds, bbox_preds = net(X)
# 生成训练目标:为每个锚框分配真实标签
bbox_labels, bbox_masks, cls_labels = d2l.multibox_target(anchors, Y)
# 计算总损失(分类损失 + 边界框回归损失)
l = calc_loss(cls_preds, cls_labels, bbox_preds, bbox_labels, bbox_masks)
# 反向传播(计算梯度)
l.mean().backward()
# 更新模型参数
trainer.step()
# 累计评估指标:
# [0]分类正确数 [1]总锚框数 [2]边界框误差和 [3]有效锚框数
metric.add(cls_eval(cls_preds, cls_labels), cls_labels.numel(),
bbox_eval(bbox_preds, bbox_labels, bbox_masks),
bbox_labels.numel())
# 计算本轮次指标:
# 分类错误率 = 1 - 正确数/总数
# 边界框MAE = 误差和/有效锚框数
cls_err, bbox_mae = 1 - metric[0] / metric[1], metric[2] / metric[3]
# 更新可视化曲线
animator.add(epoch + 1, (cls_err, bbox_mae))
# 输出最终性能指标
print(f'class err {cls_err:.2e}, bbox mae {bbox_mae:.2e}')
# 计算并输出训练速度(样本/秒)
print(f'{len(train_iter.dataset) / timer.stop():.1f} examples/sec on '
f'{str(device)}')

预测目标
x.squeeze()用于去除所有大小为1的维度(即“压缩”维度)。它的作用可以理解为“去掉不必要的单维度”,类似于数学中的降维操作。
X = torchvision.io.read_image('../img/banana.jpg').unsqueeze(0).float()
img = X.squeeze(0).permute(1, 2, 0).long()
## 1. 去除batch维度(从[1,C,H,W]变为[C,H,W])
# 2. 调整通道顺序从[C,H,W]变为[H,W,C](适合matplotlib显示)
# 3. 转换为long类型(整数像素值)
def predict(X):
net.eval()#设置为评估模型
# 前向传播获取模型输出:
# anchors: 生成的锚框 [1, 5444, 4]
# cls_preds: 类别预测 [1, 5444, 2]
# bbox_preds: 边界框偏移量 [1, 5444, 4]
anchors, cls_preds, bbox_preds = net(X.to(device))
# 对类别预测进行softmax归一化,得到概率分布
# 调整维度从[1,5444,2]变为[1,2,5444](符合multibox_detection输入要求)
cls_probs = F.softmax(cls_preds, dim=2).permute(0, 2, 1)
#执行多框检测
output = d2l.multibox_detection(cls_probs, bbox_preds, anchors)
# # 过滤无效检测结果(类别ID=-1表示无效预测)
idx = [i for i, row in enumerate(output[0]) if row[0] != -1]
return output[0, idx]
output = predict(X)
筛选所有置信度不低于 0.9 的边界框,做为最终输出
def display(img, output, threshold):
d2l.set_figsize((5, 5)) # 设置图像显示大小为5x5英寸
fig = d2l.plt.imshow(img)
for row in output:
score = float(row[1])
if score < threshold:
continue
h, w = img.shape[0:2]
bbox = [row[2:6] * torch.tensor((w, h, w, h), device=row.device)]
d2l.show_bboxes(fig.axes, bbox, '%.2f' % score, 'w')
display(img, output.cpu(), threshold=0.9)
#output.cpu(): 将检测结果移回CPU(如果之前在GPU上)
#threshold=0.9: 只显示置信度≥90%的检测框

13.8 语义分割和数据集
13.8.1 语义分割

- 在图片分类中,其主要任务是给定一张图片,识别图片中主体物体
- 目标检测,也叫物体检测,其主要任务是找出图片中多个感兴趣的物体,并且找到每个物体的具体位置(
使用方形边界框来标注和预测图像中的目标),问题是这些框很多时候比较粗糙,只能标注出大致的位置,但是无法标注出物体各部分的具体位置以及物体与背景之间的分割线(物体的具体轮廓) - 语义分割可以识别并理解图像中每一个像素的内容(将图片中的每个像素分类到对应的类别),其语义区域的标注和预测是像素级的
- 与图片分类、目标检测相比,语义分割标注的像素级边框更加精细
应用场景
背景虚化

路面分割

对比
- 图像分割:将图像分割成若干区域,根据图像中像素之间的相关性来分割。不需要有关图像像素的标签信息,在预测时也不能保证分割的区域有我们希望得到的语义。
- 实例分割(同时检测并分割),研究如何识别图像中各个目标实例的像素级区域。不仅需要区分语义,还要区分不同的目标实例。如下图所示,实例分割还要区分狗的不同标号。

13.8.2 语义分割数据集
最重要的语义分割数据集是 Pascal VOC2012
下载数据集
和前面一样使用url和SHA-1验证码验证文件完整性下载数据集,再解压
%matplotlib inline
import os
import torch
import torchvision
from d2l import torch as d2l
#下载数据集,使用url和SHA-1验证码来验证文件完整性
d2l.DATA_HUB['voc2012'] = (d2l.DATA_URL + 'VOCtrainval_11-May-2012.tar',
'4e443f8a2eca6b1dac8a6c57641b67dd40621a49')
#解压文件
voc_dir = d2l.download_extract('voc2012', 'VOCdevkit/VOC2012')
将输入的特征图和标注后的标签图读入内存
def read_voc_images(voc_dir, is_train=True):
"""读取所有VOC图像并标注。"""
#判断要读取的文件是训练集还是测试集
txt_fname = os.path.join(voc_dir, 'ImageSets', 'Segmentation',
'train.txt' if is_train else 'val.txt')
mode = torchvision.io.image.ImageReadMode.RGB ## 设置图像读取模式为RGB(三通道)
with open(txt_fname, 'r') as f:
images = f.read().split() #按空格、换行来分割文件名
features, labels = [], []
for i, fname in enumerate(images): #读取图片和标签
features.append(#读取原始图片
torchvision.io.read_image(
os.path.join(voc_dir, 'JPEGImages', f'{fname}.jpg')))
labels.append(## 读取对应的标签(分割标注图)
torchvision.io.read_image(
os.path.join(voc_dir, 'SegmentationClass', f'{fname}.png'),
mode))
return features, labels
train_features, train_labels = read_voc_images(voc_dir, True)
print(train_features[0])
绘制前五个图像和标签(标注后的图片)
n = 5
imgs = train_features[0:n] + train_labels[0:n] #这里的lables是语义分割标注后的图片
imgs = [img.permute(1, 2, 0) for img in imgs] # 调整张量维度顺序,从PyTorch的(通道数,高,宽转为Matplotlib需要的(高,宽,通道数)
d2l.show_images(imgs, 2, n); #show_images(图片列表,行数,列数,scale显示比例默认为2)

列举RGB颜色值和类名
VOC_COLORMAP = [[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
[0, 0, 128], [128, 0, 128], [0, 128, 128], [128, 128, 128],
[64, 0, 0], [192, 0, 0], [64, 128, 0], [192, 128, 0],
[64, 0, 128], [192, 0, 128], [64, 128, 128], [192, 128, 128],
[0, 64, 0], [128, 64, 0], [0, 192, 0], [128, 192, 0],
[0, 64, 128]]
VOC_CLASSES = [
'background', 'aeroplane', 'bicycle', 'bird', 'boat', 'bottle', 'bus',
'car', 'cat', 'chair', 'cow', 'diningtable', 'dog', 'horse', 'motorbike',
'person', 'potted plant', 'sheep', 'sofa', 'train', 'tv/monitor']
查找标签中每个像素的类索引
这里使用(r*256+g)*256+b的结果来作为索引唯一标识一个类别是一个向量计算,比逐元素(r,g,b)快很多。这种方法是计算机视觉中,处理颜色标注数据的经典技巧。
def voc_colormap2label():
"""构建从RGB到VOC类别索引的映射。"""
colormap2label = torch.zeros(256**3, dtype=torch.long)
for i, colormap in enumerate(VOC_COLORMAP):
colormap2label[(colormap[0] * 256 + colormap[1]) * 256 +
colormap[2]] = i #从RGB列表转换为VOC类别
return colormap2label
def voc_label_indices(colormap, colormap2label):
"""将VOC标签中的RGB值映射到它们的类别索引。"""
colormap = colormap.permute(1, 2, 0).numpy().astype('int32')
idx = ((colormap[:, :, 0] * 256 + colormap[:, :, 1]) * 256 +
colormap[:, :, 2])
return colormap2label[idx] #从类别转换为RGB对应的像素值
举例
y = voc_label_indices(train_labels[0], voc_colormap2label())
y[105:115, 130:140], VOC_CLASSES[1] #y[105:115, 130:140]的像素区域

- 输出张量中,飞机头部的类别索引为1,背景为0
预处理数据
在语义分割中,为了避免预测的像素类别映射到原始图像不够精细,我们将图像裁剪为固定尺寸,而不是在缩放。使用随机裁剪,裁剪输入图像和标签的相同区域。
def voc_rand_crop(feature, label, height, width):
"""随机裁剪特征和标签图像。"""
rect = torchvision.transforms.RandomCrop.get_params( # 获取随机裁剪参数(相同的随机种子保证特征和标签裁剪位置一致)
feature, (height, width))
feature = torchvision.transforms.functional.crop(feature, *rect) #特征图裁剪
label = torchvision.transforms.functional.crop(label, *rect) #标签图裁剪
return feature, label
imgs = []
for _ in range(n):
imgs += voc_rand_crop(train_features[0], train_labels[0], 200, 300)
imgs = [img.permute(1, 2, 0) for img in imgs]
d2l.show_images(imgs[::2] + imgs[1::2], 2, n);

自定义数据集
class VOCSegDataset(torch.utils.data.Dataset):
"""一个用于加载VOC数据集的自定义数据集。"""
def __init__(self, is_train, crop_size, voc_dir):
self.transform = torchvision.transforms.Normalize( #标准化
mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
self.crop_size = crop_size #裁剪尺寸
features, labels = read_voc_images(voc_dir, is_train=is_train)
self.features = [ #过滤尺寸不足的特征图,并初始化
self.normalize_image(feature)
for feature in self.filter(features)]
self.labels = self.filter(labels) #过滤尺寸不足的标注图
self.colormap2label = voc_colormap2label() #建立颜色矩阵和类别的映射
print('read ' + str(len(self.features)) + ' examples')
def normalize_image(self, img):
return self.transform(img.float()) #转化为float后标准化
def filter(self, imgs):
return [
img for img in imgs if (img.shape[1] >= self.crop_size[0] and #高度检测
img.shape[2] >= self.crop_size[1])] #宽度检测
def __getitem__(self, idx):
feature, label = voc_rand_crop(self.features[idx], self.labels[idx], #随机裁剪
*self.crop_size)
return (feature, voc_label_indices(label, self.colormap2label)) #把RGB标签图转换成类别ID图
def __len__(self):
return len(self.features)
crop_size = (320, 480)
voc_train = VOCSegDataset(True, crop_size, voc_dir) #加载训练集
voc_test = VOCSegDataset(False, crop_size, voc_dir) #加载测试集

读取数据集
batch_size = 64
train_iter = torch.utils.data.DataLoader(
voc_train, batch_size, shuffle=True, drop_last=True, #batch_size = 64
train_iter = torch.utils.data.DataLoader(
voc_train, batch_size, shuffle=True, drop_last=True,
# drop_last=True 丢弃最后不足batch_size的批次(保持批次形状一致)
num_workers=d2l.get_dataloader_workers())
for X, Y in train_iter:
print(X.shape)
print(Y.shape)
break
num_workers=d2l.get_dataloader_workers())
for X, Y in train_iter:
print(X.shape)
print(Y.shape)
break

整合所有组件
def load_data_voc(batch_size, crop_size):
"""加载VOC语义分割数据集。"""
voc_dir = d2l.download_extract('voc2012',
os.path.join('VOCdevkit', 'VOC2012'))
num_workers = d2l.get_dataloader_workers()
train_iter = torch.utils.data.DataLoader(
VOCSegDataset(True, crop_size, voc_dir), batch_size, shuffle=True,
drop_last=True, num_workers=num_workers)
test_iter = torch.utils.data.DataLoader(
VOCSegDataset(False, crop_size, voc_dir), batch_size, drop_last=True,
num_workers=num_workers)
return train_iter, test_iter
小结
- 语义分割通过将图像划分为属于不同语义类别的区域,来识别并理解图像中像素级别的内容。
- 语义分割的一个重要的数据集叫做Pascal VOC2012。
- 由于语义分割的输入图像和标签在像素上一一对应,
输入图像会被随机裁剪为固定尺寸而不是缩放。
13.9 转置卷积 (transposed convolution)
- 卷积不会增大输入的高宽,通常要么不变要不减半
转置卷积可以增加输入的高宽- 如果输入图像和输出图像的空间维度相同(高宽),像素级的语义分割会很方便
基础卷积

- 上图的步幅stride为1,填充为0
- 这个核会在输入上以步幅为 1 进行滑动且没有填充,对于输入的每一个元素,它会跟核上的每一个元素按元素做乘法,然后逐次写回到对应的位置(写回到一个更大的矩阵中,除了写回的位置,其他元素初始化为 0 )
- 生成的结果是(n+
import torch
from torch import nn
from d2l import torch as d2l
def trans_conv(X, K):
""" 实现转置卷积 """
h, w = K.shape #卷积核的高宽
Y = np.zeros((X.shape[0] + h - 1, X.shape[1] + w - 1))
for i in range(X.shape[0]):
for j in range(X.shape[1]):
Y[i: i + h, j: j + w] += X[i, j] * K
return Y
#验证上图结果
X = torch.tensor([[0.0, 1.0], [2.0, 3.0]])
K = torch.tensor([[0.0, 1.0], [2.0, 3.0]])
trans_conv(X, K)

调包实现
nn.ConvTranspose2d 二维转置卷积
nn.ConvTranspose2d(
in_channels=1, # 输入通道数
out_channels=1, # 输出通道数
kernel_size=2, # 转置卷积核大小
stride=1, # 步长(默认1)
padding=0, # 输入填充(默认0)
output_padding=0, # 输出填充(默认0)
bias=False # 是否使用偏置项
)
X, K = X.reshape(1, 1, 2, 2), K.reshape(1, 1, 2, 2)
tconv = nn.ConvTranspose2d(1, 1, kernel_size=2, bias=False) # bias是否使用偏置项
tconv.weight.data = K
tconv(X)

转置卷积与卷积的异同
1、工作原理
- 常规卷积是输入跟核进行按元素的乘法并相加,最后得到对应位置的值,通过核在输入上进行滑动从而得到输出
- 转置卷积是输入的单个元素跟核按元素做乘法但不相加,保持核的大小,然后按元素写回到一个更大的矩阵的对应位置(输入的每个元素会生成与核大小相同的矩阵写入到一个更大的矩阵的对应位置,所以输出的高宽相对于输入来讲是变大的)
2、输入输出
- 常规卷积通过卷积核 “减少” 输入元素
- 转置卷积通过卷积核 “广播” 输入元素,从而产生大于输入的输出
3、填充
- 常规卷积中将填充应用于输入(如果将高和宽两侧的填充数指定为 1 时,常规卷积的输入中将增加第一和最后的行和列)
- 转置卷积中将填充应用于输出(如果将高和宽两侧的填充数指定为 1 时,转置卷积的输出中将删除第一和最后的行和列)
tconv = nn.ConvTranspose2d(1, 1, kernel_size=2, padding=1, bias=False)
tconv.weight.data = K
tconv(X) #填充为1,输出删掉第一行第三行,第一列和第三列,只剩中间的4

4、步幅
- 常规卷积中,步幅所指定的是卷积核在输入上的滑动距离
- 转置卷积中,
步幅所指定的是卷积核每次运算结果写回到中间结果(输出)矩阵中对应位置的滑动距离,上图两张图是卷积核为 2 × 2 ,步幅为1和2 的转置卷积运算

- stride步幅为1

- 步幅为2
tconv = nn.ConvTranspose2d(1, 1, kernel_size=2, stride=2, bias=False)
tconv.weight.data = K
tconv(X)

5、多通道
- 对于多输入和输出通道,转置卷积与常规卷积以相同的方式运作
一个张量经过卷积和转置卷积得到的张量形状相同
X = torch.rand(size=(1, 10, 16, 16))
conv = nn.Conv2d(10, 20, kernel_size=5, padding=2, stride=3)
tconv = nn.ConvTranspose2d(20, 10, kernel_size=5, padding=2, stride=3)
tconv(conv(X)).shape == X.shape

与矩阵变换的联系
可以用矩阵乘法来实现卷积
X = torch.arange(9.0).reshape(3, 3)
print(X)
K = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
Y = d2l.corr2d(X, K)
Y

- 0* 1+1* 2+3* 3+4* 4=27
把卷积核K重写为包含大量0的稀疏矩阵W,非0元素来自卷积核
- 添加多个零的目的是为了 将 2D 卷积核转换为等效的矩阵乘法形式(即 Toeplitz 矩阵)
def kernel2matrix(K):
k, W = torch.zeros(5), torch.zeros((4, 9))
k[:2], k[3:5] = K[0, :], K[1, :]
W[0, :5], W[1, 1:6], W[2, 3:8], W[3, 4:] = k, k, k, k
return W
W = kernel2matrix(K)
W

Y == torch.matmul(W, X.reshape(-1)).reshape(2, 2)

- 逐行连接输入X,w和向量化的X相乘实现了上述的Y
Z = trans_conv(Y, K)
Z == torch.matmul(W.T, Y.reshape(-1)).reshape(3, 3)

- 卷积的前向传播可以由输入向量X和权重矩阵W相乘来实现 y=Wx
- 卷积的反向传播可以通过输入向量X和权重矩阵转置 W^T xy=W ^T
- 转置卷积可以交换卷积层的前向传播函数和反向传播函数
小结
- 与通过卷积核减少输入元素的常规卷积相反,转置卷积通过卷积核广播输入元素,从而产生形状大于输入的输出
- 如果我们将 X 输入卷积层 f 来获得输出 Y = f(X) 并创造一个与 f 有相同的超参数、但输出通道数是 X 中通道数的转置卷积层 g ,那么 g(Y) 的形状将与 X 相同。
- 可以使用矩阵乘法来实现卷积。转置卷积层能够交换卷积层的正向传播函数和反向传播函数
总结
本周学习了计算机视觉中的目标检测和语义分割技术,重点掌握了锚框生成、SSD模型实现、转置卷积原理以及Pascal VOC数据集的处理方法。通过代码实践,理解了多尺度特征融合、类别预测和边界框回归等核心概念。下周计划完成全卷积网络和风格迁移的学习,尽快结束计算机视觉章节,开始循环神经网络的学习。

被折叠的 条评论
为什么被折叠?



