深度学习应用篇-计算机视觉-图像分类[2]:LeNet、AlexNet、VGG、GoogleNet、DarkNet模型结构、实现、模型特点详细介绍

这篇具有很好参考价值的文章主要介绍了深度学习应用篇-计算机视觉-图像分类[2]:LeNet、AlexNet、VGG、GoogleNet、DarkNet模型结构、实现、模型特点详细介绍。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

深度学习应用篇-计算机视觉-图像分类[2]:LeNet、AlexNet、VGG、GoogleNet、DarkNet模型结构、实现、模型特点详细介绍

1.LeNet(1998)

LeNet是最早的卷积神经网络之一[1],其被提出用于识别手写数字和机器印刷字符。1998年,Yann LeCun第一次将LeNet卷积神经网络应用到图像分类上,在手写数字识别任务中取得了巨大成功。算法中阐述了图像中像素特征之间的相关性能够由参数共享的卷积操作所提取,同时使用卷积、下采样(池化)和非线性映射这样的组合结构,是当前流行的大多数深度图像识别网络的基础。

1.1 LeNet模型结构

LeNet通过连续使用卷积和池化层的组合提取图像特征,其架构如 图1 所示,这里展示的是用于MNIST手写体数字识别任务中的LeNet-5模型:

  • 第一模块:包含5×5的6通道卷积和2×2的池化。卷积提取图像中包含的特征模式(激活函数使用Sigmoid),图像尺寸从28减小到24。经过池化层可以降低输出特征图对空间位置的敏感性,图像尺寸减到12。

  • 第二模块:和第一模块尺寸相同,通道数由6增加为16。卷积操作使图像尺寸减小到8,经过池化后变成4。

  • 第三模块:包含4×4的120通道卷积。卷积之后的图像尺寸减小到1,但是通道数增加为120。将经过第3次卷积提取到的特征图输入到全连接层。第一个全连接层的输出神经元的个数是64,第二个全连接层的输出神经元个数是分类标签的类别数,对于手写数字识别的类别数是10。然后使用Softmax激活函数即可计算出每个类别的预测概率。


提示:

卷积层的输出特征图如何当作全连接层的输入使用呢?

卷积层的输出数据格式是$[N, C, H, W]$,在输入全连接层的时候,会自动将数据拉平,

也就是对每个样本,自动将其转化为长度为$K$的向量,

其中$K = C \times H \times W$,一个mini-batch的数据维度变成了$N\times K$的二维向量。


1.2 LeNet模型实现

LeNet网络的实现代码如下:

#导入需要的包
import paddle
import numpy as np
from paddle.nn import Conv2D, MaxPool2D, Linear

##组网
import paddle.nn.functional as F

#定义 LeNet 网络结构
class LeNet(paddle.nn.Layer):
    def __init__(self, num_classes=1):
        super(LeNet, self).__init__()
        # 创建卷积和池化层
        # 创建第1个卷积层
        self.conv1 = Conv2D(in_channels=1, out_channels=6, kernel_size=5)
        self.max_pool1 = MaxPool2D(kernel_size=2, stride=2)
        # 尺寸的逻辑:池化层未改变通道数;当前通道数为6
        # 创建第2个卷积层
        self.conv2 = Conv2D(in_channels=6, out_channels=16, kernel_size=5)
        self.max_pool2 = MaxPool2D(kernel_size=2, stride=2)
        # 创建第3个卷积层
        self.conv3 = Conv2D(in_channels=16, out_channels=120, kernel_size=4)
        # 尺寸的逻辑:输入层将数据拉平[B,C,H,W] -> [B,C*H*W]
        # 输入size是[28,28],经过三次卷积和两次池化之后,C*H*W等于120
        self.fc1 = Linear(in_features=120, out_features=64)
        # 创建全连接层,第一个全连接层的输出神经元个数为64, 第二个全连接层输出神经元个数为分类标签的类别数
        self.fc2 = Linear(in_features=64, out_features=num_classes)
    #网络的前向计算过程
    def forward(self, x):
        x = self.conv1(x)
        # 每个卷积层使用Sigmoid激活函数,后面跟着一个2x2的池化
        x = F.sigmoid(x)
        x = self.max_pool1(x)
        x = F.sigmoid(x)
        x = self.conv2(x)
        x = self.max_pool2(x)
        x = self.conv3(x)
        #尺寸的逻辑:输入层将数据拉平[B,C,H,W] -> [B,C*H*W]
        x = paddle.reshape(x, [x.shape[0], -1])
        x = self.fc1(x)
        x = F.sigmoid(x)
        x = self.fc2(x)
        return x

1.3 LeNet模型特点

  • 卷积网络使用一个3层的序列组合:卷积、下采样(池化)、非线性映射(LeNet-5最重要的特性,奠定了目前深层卷积网络的基础);
  • 使用卷积提取空间特征;
  • 使用映射的空间均值进行下采样;
  • 使用$tanh$或$sigmoid$进行非线性映射;
  • 多层神经网络(MLP)作为最终的分类器;
  • 层间的稀疏连接矩阵以避免巨大的计算开销。

1.4 LeNet模型指标

LeNet-5在MNIST手写数字识别任务上进行了模型训练与测试,论文中提供的模型指标如 图2 所示。使用 distortions 方法处理后,error rate能够达到0.8%。

  • 参考文献

[1] Gradient-based learn- ing applied to document recognition.

2.AlexNet(2012)

AlexNet[1]是2012年ImageNet竞赛的冠军模型,其作者是神经网络领域三巨头之一的Hinton和他的学生Alex Krizhevsky。

AlexNet以极大的优势领先2012年ImageNet竞赛的第二名,也因此给当时的学术界和工业界带来了很大的冲击。此后,更多更深的神经网络相继被提出,比如优秀的VGG,GoogLeNet,ResNet等。

2.1 AlexNet模型结构

AlexNet与此前的LeNet相比,具有更深的网络结构,包含5层卷积和3层全连接,具体结构如 图1 所示。

1)第一模块:对于$224\times 224$的彩色图像,先用96个$11\times 11\times 3$的卷积核对其进行卷积,提取图像中包含的特征模式(步长为4,填充为2,得到96个$54\times 54$的卷积结果(特征图);然后以$2\times 2$大小进行池化,得到了96个$27\times 27$大小的特征图;

2)第二模块:包含256个$5\times 5$的卷积和$2\times 2$池化,卷积操作后图像尺寸不变,经过池化后,图像尺寸变成$13\times 13$;

3)第三模块:包含384个$3\times 3$的卷积,卷积操作后图像尺寸不变;

4)第四模块:包含384个$3\times 3$的卷积,卷积操作后图像尺寸不变;

5)第五模块:包含256个$3\times 3$的卷积和$2\times 2$的池化,卷积操作后图像尺寸不变,经过池化后变成256个$6\times 6$大小的特征图。

将经过第5次卷积提取到的特征图输入到全连接层,得到原始图像的向量表达。前两个全连接层的输出神经元的个数是4096,第三个全连接层的输出神经元个数是分类标签的类别数(ImageNet比赛的分类类别数是1000),然后使用Softmax激活函数即可计算出每个类别的预测概率。

2.2 AlexNet模型实现

基于Paddle框架,AlexNet的具体实现的代码如下所示:

#-*- coding:utf-8 -*-

#导入需要的包
import paddle
import numpy as np
from paddle.nn import Conv2D, MaxPool2D, Linear, Dropout
##组网
import paddle.nn.functional as F

#定义 AlexNet 网络结构
class AlexNet(paddle.nn.Layer):
    def __init__(self, num_classes=1):
        super(AlexNet, self).__init__()
        # AlexNet与LeNet一样也会同时使用卷积和池化层提取图像特征
        # 与LeNet不同的是激活函数换成了‘relu’
        self.conv1 = Conv2D(in_channels=3, out_channels=96, kernel_size=11, stride=4, padding=5)
        self.max_pool1 = MaxPool2D(kernel_size=2, stride=2)
        self.conv2 = Conv2D(in_channels=96, out_channels=256, kernel_size=5, stride=1, padding=2)
        self.max_pool2 = MaxPool2D(kernel_size=2, stride=2)
        self.conv3 = Conv2D(in_channels=256, out_channels=384, kernel_size=3, stride=1, padding=1)
        self.conv4 = Conv2D(in_channels=384, out_channels=384, kernel_size=3, stride=1, padding=1)
        self.conv5 = Conv2D(in_channels=384, out_channels=256, kernel_size=3, stride=1, padding=1)
        self.max_pool5 = MaxPool2D(kernel_size=2, stride=2)

        self.fc1 = Linear(in_features=12544, out_features=4096)
        self.drop_ratio1 = 0.5
        self.drop1 = Dropout(self.drop_ratio1)
        self.fc2 = Linear(in_features=4096, out_features=4096)
        self.drop_ratio2 = 0.5
        self.drop2 = Dropout(self.drop_ratio2)
        self.fc3 = Linear(in_features=4096, out_features=num_classes)
    
    def forward(self, x):
        x = self.conv1(x)
        x = F.relu(x)
        x = self.max_pool1(x)
        x = self.conv2(x)
        x = F.relu(x)
        x = self.max_pool2(x)
        x = self.conv3(x)
        x = F.relu(x)
        x = self.conv4(x)
        x = F.relu(x)
        x = self.conv5(x)
        x = F.relu(x)
        x = self.max_pool5(x)
        x = paddle.reshape(x, [x.shape[0], -1])
        x = self.fc1(x)
        x = F.relu(x)
        # 在全连接之后使用dropout抑制过拟合
        x = self.drop1(x)
        x = self.fc2(x)
        x = F.relu(x)
        # 在全连接之后使用dropout抑制过拟合
        x = self.drop2(x)
        x = self.fc3(x)
        return x

2.3 AlexNet模型特点

AlexNet中包含了几个比较新的技术点,也首次在CNN中成功应用了ReLU、Dropout和LRN等Trick。同时AlexNet也使用了GPU进行运算加速。

AlexNet将LeNet的思想发扬光大,把CNN的基本原理应用到了很深很宽的网络中。AlexNet主要使用到的新技术点如下:

  • 成功使用ReLU作为CNN的激活函数,并验证其效果在较深的网络超过了Sigmoid,成功解决了Sigmoid在网络较深时的梯度弥散问题。虽然ReLU激活函数在很久之前就被提出了,但是直到AlexNet的出现才将其发扬光大。
  • 训练时使用Dropout随机忽略一部分神经元,以避免模型过拟合。Dropout虽有单独的论文论述,但是AlexNet将其实用化,通过实践证实了它的效果。在AlexNet中主要是最后几个全连接层使用了Dropout。
  • 在CNN中使用重叠的最大池化。此前CNN中普遍使用平均池化,AlexNet全部使用最大池化,避免平均池化的模糊化效果。并且AlexNet中提出让步长比池化核的尺寸小的观点,这样池化层的输出之间会有重叠和覆盖,提升了特征的丰富性。
  • 提出了LRN局部响应归一化层,对局部神经元的活动创建竞争机制,使得其中响应比较大的值变得相对更大,并抑制其他反馈较小的神经元,增强了模型的泛化能力。
  • 使用CUDA加速深度卷积网络的训练,利用GPU强大的并行计算能力,处理神经网络训练时大量的矩阵运算。AlexNet使用了两块GTX 580 GPU进行训练,单个GTX 580只有3GB显存,这限制了可训练的网络的最大规模。因此作者将AlexNet分布在两个GPU上,在每个GPU的显存中储存一半的神经元的参数。因为GPU之间通信方便,可以互相访问显存,而不需要通过主机内存,所以同时使用多块GPU也是非常高效的。同时,AlexNet的设计让GPU之间的通信只在网络的某些层进行,控制了通信的性能损耗。
  • 使用数据增强,随机地从$256\times 256$ 大小的原始图像中截取$224\times 224$大小的区域(以及水平翻转的镜像),相当于增加了$2\times (256-224)^2=2048$倍的数据量。如果没有数据增强,仅靠原始的数据量,参数众多的CNN会陷入过拟合中,使用了数据增强后可以大大减轻过拟合,提升泛化能力。进行预测时,则是取图片的四个角加中间共5个位置,并进行左右翻转,一共获得10张图片,对他们进行预测并对10次结果求均值。同时,AlexNet论文中提到了会对图像的RGB数据进行PCA处理,并对主成分做一个标准差为0.1的高斯扰动,增加一些噪声,这个Trick可以让错误率再下降1%。

2.4 AlexNet模型指标

AlexNet 作为 ImageNet 2012比赛的冠军算法,在 ImageNet 测试集上达到了 15.3% 的 top-5 error rate,远远超过第二名(SIFT+FVs)的 26.2% 。如 图2 所示。

  • 参考文献

[1] Imagenet classification with deep convolutional neural networks.

3.VGG(2012)

随着AlexNet在2012年的ImageNet大赛上大放异彩后,卷积神经网络进入了飞速发展的阶段。2014年,由Simonyan和Zisserman提出的VGG[1]网络在ImageNet上取得了亚军的成绩。VGG的命名来源于论文作者所在的实验室Visual Geometry Group,其对卷积神经网络进行了改良,探索了网络深度与性能的关系,用更小的卷积核和更深的网络结构,取得了较好的效果,成为了CNN发展史上较为重要的一个网络。VGG中使用了一系列大小为3x3的小尺寸卷积核和池化层构造深度卷积神经网络,因为其结构简单、应用性极强而广受研究者欢迎,尤其是它的网络结构设计方法,为构建深度神经网络提供了方向。

3.1 VGG模型结构

图1 是VGG-16的网络结构示意图,有13层卷积和3层全连接层。VGG网络的设计严格使用$3\times 3$的卷积层和池化层来提取特征,并在网络的最后面使用三层全连接层,将最后一层全连接层的输出作为分类的预测。

VGG中还有一个显著特点:每次经过池化层(maxpooling)后特征图的尺寸减小一倍,而通道数增加一倍(最后一个池化层除外)。

在VGG中每层卷积将使用ReLU作为激活函数,在全连接层之后添加dropout来抑制过拟合。使用小的卷积核能够有效地减少参数的个数,使得训练和测试变得更加有效。比如使用两层$3\times 3$ 卷积层,可以得到感受野为5的特征图,而比使用$5 \times 5$的卷积层需要更少的参数。由于卷积核比较小,可以堆叠更多的卷积层,加深网络的深度,这对于图像分类任务来说是有利的。VGG模型的成功证明了增加网络的深度,可以更好的学习图像中的特征模式。

3.2 VGG模型实现

基于Paddle框架,VGG的具体实现如下代码所示:

#-*- coding:utf-8 -*-

#VGG模型代码
import numpy as np
import paddle
#from paddle.nn import Conv2D, MaxPool2D, BatchNorm, Linear
from paddle.nn import Conv2D, MaxPool2D, BatchNorm2D, Linear

#定义vgg网络
class VGG(paddle.nn.Layer):
    def __init__(self):
        super(VGG, self).__init__()

        in_channels = [3, 64, 128, 256, 512, 512]
        # 定义第一个卷积块,包含两个卷积
        self.conv1_1 = Conv2D(in_channels=in_channels[0], out_channels=in_channels[1], kernel_size=3, padding=1, stride=1)
        self.conv1_2 = Conv2D(in_channels=in_channels[1], out_channels=in_channels[1], kernel_size=3, padding=1, stride=1)
        # 定义第二个卷积块,包含两个卷积
        self.conv2_1 = Conv2D(in_channels=in_channels[1], out_channels=in_channels[2], kernel_size=3, padding=1, stride=1)
        self.conv2_2 = Conv2D(in_channels=in_channels[2], out_channels=in_channels[2], kernel_size=3, padding=1, stride=1)
        # 定义第三个卷积块,包含三个卷积
        self.conv3_1 = Conv2D(in_channels=in_channels[2], out_channels=in_channels[3], kernel_size=3, padding=1, stride=1)
        self.conv3_2 = Conv2D(in_channels=in_channels[3], out_channels=in_channels[3], kernel_size=3, padding=1, stride=1)
        self.conv3_3 = Conv2D(in_channels=in_channels[3], out_channels=in_channels[3], kernel_size=3, padding=1, stride=1)
        # 定义第四个卷积块,包含三个卷积
        self.conv4_1 = Conv2D(in_channels=in_channels[3], out_channels=in_channels[4], kernel_size=3, padding=1, stride=1)
        self.conv4_2 = Conv2D(in_channels=in_channels[4], out_channels=in_channels[4], kernel_size=3, padding=1, stride=1)
        self.conv4_3 = Conv2D(in_channels=in_channels[4], out_channels=in_channels[4], kernel_size=3, padding=1, stride=1)
        # 定义第五个卷积块,包含三个卷积
        self.conv5_1 = Conv2D(in_channels=in_channels[4], out_channels=in_channels[5], kernel_size=3, padding=1, stride=1)
        self.conv5_2 = Conv2D(in_channels=in_channels[5], out_channels=in_channels[5], kernel_size=3, padding=1, stride=1)
        self.conv5_3 = Conv2D(in_channels=in_channels[5], out_channels=in_channels[5], kernel_size=3, padding=1, stride=1)

        # 使用Sequential 将全连接层和relu组成一个线性结构(fc + relu)
        # 当输入为224x224时,经过五个卷积块和池化层后,特征维度变为[512x7x7]
        self.fc1 = paddle.nn.Sequential(paddle.nn.Linear(512 * 7 * 7, 4096), paddle.nn.ReLU())
        self.drop1_ratio = 0.5
        self.dropout1 = paddle.nn.Dropout(self.drop1_ratio, mode='upscale_in_train')
        # 使用Sequential 将全连接层和relu组成一个线性结构(fc + relu)
        self.fc2 = paddle.nn.Sequential(paddle.nn.Linear(4096, 4096), paddle.nn.ReLU())

        self.drop2_ratio = 0.5
        self.dropout2 = paddle.nn.Dropout(self.drop2_ratio, mode='upscale_in_train')
        self.fc3 = paddle.nn.Linear(4096, 1)

        self.relu = paddle.nn.ReLU()
        self.pool = MaxPool2D(stride=2, kernel_size=2)

    def forward(self, x):
        x = self.relu(self.conv1_1(x))
        x = self.relu(self.conv1_2(x))
        x = self.pool(x)

        x = self.relu(self.conv2_1(x))
        x = self.relu(self.conv2_2(x))
        x = self.pool(x)

        x = self.relu(self.conv3_1(x))
        x = self.relu(self.conv3_2(x))
        x = self.relu(self.conv3_3(x))
        x = self.pool(x)

        x = self.relu(self.conv4_1(x))
        x = self.relu(self.conv4_2(x))
        x = self.relu(self.conv4_3(x))
        x = self.pool(x)

        x = self.relu(self.conv5_1(x))
        x = self.relu(self.conv5_2(x))
        x = self.relu(self.conv5_3(x))
        x = self.pool(x)

        x = paddle.flatten(x, 1, -1)
        x = self.dropout1(self.relu(self.fc1(x)))
        x = self.dropout2(self.relu(self.fc2(x)))
        x = self.fc3(x)
        return x

3.3 VGG模型特点

  • 整个网络都使用了同样大小的卷积核尺寸$3\times3$和最大池化尺寸$2\times2$。
  • $1\times1$卷积的意义主要在于线性变换,而输入通道数和输出通道数不变,没有发生降维。
  • 两个$3\times3$的卷积层串联相当于1个$5\times5$的卷积层,感受野大小为$5\times5$。同样地,3个$3\times3$的卷积层串联的效果则相当于1个$7\times7$的卷积层。这样的连接方式使得网络参数量更小,而且多层的激活函数令网络对特征的学习能力更强。
  • VGGNet在训练时有一个小技巧,先训练浅层的的简单网络VGG11,再复用VGG11的权重来初始化VGG13,如此反复训练并初始化VGG19,能够使训练时收敛的速度更快。
  • 在训练过程中使用多尺度的变换对原始数据做数据增强,使得模型不易过拟合。

3.4 VGG模型指标

VGG 在 2014 年的 ImageNet 比赛上取得了亚军的好成绩,具体指标如 图2 所示。图2 第一行为在 ImageNet 比赛中的指标,测试集的Error rate达到了7.3%,在论文中,作者对算法又进行了一定的优化,最终可以达到 6.8% 的Error rate。

  • 参考文献

[1] Very deep convolutional networks for large-scale image recognition.

4.GoogLeNet(2014)

GoogLeNet[1]是2014年ImageNet比赛的冠军,它的主要特点是网络不仅有深度,还在横向上具有“宽度”。从名字GoogLeNet可以知道这是来自谷歌工程师所设计的网络结构,而名字中GoogLeNet更是致敬了LeNet。GoogLeNet中最核心的部分是其内部子网络结构Inception,该结构灵感来源于NIN(Network In Network)。

4.1 GoogLeNet模型结构

由于图像信息在空间尺寸上的巨大差异,如何选择合适的卷积核来提取特征就显得比较困难了。空间分布范围更广的图像信息适合用较大的卷积核来提取其特征;而空间分布范围较小的图像信息则适合用较小的卷积核来提取其特征。为了解决这个问题,GoogLeNet提出了一种被称为Inception模块的方案。如 图1 所示:


说明:

  • Google的研究人员为了向LeNet致敬,特地将模型命名为GoogLeNet。

  • Inception一词来源于电影《盗梦空间》(Inception)。

图1(a)是Inception模块的设计思想,使用3个不同大小的卷积核对输入图片进行卷积操作,并附加最大池化,将这4个操作的输出沿着通道这一维度进行拼接,构成的输出特征图将会包含经过不同大小的卷积核提取出来的特征,从而达到捕捉不同尺度信息的效果。Inception模块采用多通路(multi-path)的设计形式,每个支路使用不同大小的卷积核,最终输出特征图的通道数是每个支路输出通道数的总和,这将会导致输出通道数变得很大,尤其是使用多个Inception模块串联操作的时候,模型参数量会变得非常大。

为了减小参数量,Inception模块使用了图(b)中的设计方式,在每个3x3和5x5的卷积层之前,增加1x1的卷积层来控制输出通道数;在最大池化层后面增加1x1卷积层减小输出通道数。基于这一设计思想,形成了上图(b)中所示的结构。下面这段程序是Inception块的具体实现方式,可以对照图(b)和代码一起阅读。


提示:

可能有读者会问,经过3x3的最大池化之后图像尺寸不会减小吗,为什么还能跟另外3个卷积输出的特征图进行拼接?这是因为池化操作可以指定窗口大小$k_h = k_w = 3$,stride=1和padding=1,输出特征图尺寸可以保持不变。


Inception模块的具体实现如下代码所示:

#GoogLeNet模型代码
import numpy as np
import paddle
from paddle.nn import Conv2D, MaxPool2D, AdaptiveAvgPool2D, Linear
##组网
import paddle.nn.functional as F

#定义Inception块
class Inception(paddle.nn.Layer):
    def __init__(self, c0, c1, c2, c3, c4, **kwargs):
        '''
        Inception模块的实现代码,
        
        c1,图(b)中第一条支路1x1卷积的输出通道数,数据类型是整数
        c2,图(b)中第二条支路卷积的输出通道数,数据类型是tuple或list, 
               其中c2[0]是1x1卷积的输出通道数,c2[1]是3x3
        c3,图(b)中第三条支路卷积的输出通道数,数据类型是tuple或list, 
               其中c3[0]是1x1卷积的输出通道数,c3[1]是3x3
        c4,图(b)中第一条支路1x1卷积的输出通道数,数据类型是整数
        '''
        super(Inception, self).__init__()
        # 依次创建Inception块每条支路上使用到的操作
        self.p1_1 = Conv2D(in_channels=c0,out_channels=c1, kernel_size=1)
        self.p2_1 = Conv2D(in_channels=c0,out_channels=c2[0], kernel_size=1)
        self.p2_2 = Conv2D(in_channels=c2[0],out_channels=c2[1], kernel_size=3, padding=1)
        self.p3_1 = Conv2D(in_channels=c0,out_channels=c3[0], kernel_size=1)
        self.p3_2 = Conv2D(in_channels=c3[0],out_channels=c3[1], kernel_size=5, padding=2)
        self.p4_1 = MaxPool2D(kernel_size=3, stride=1, padding=1)
        self.p4_2 = Conv2D(in_channels=c0,out_channels=c4, kernel_size=1)

    def forward(self, x):
        # 支路1只包含一个1x1卷积
        p1 = F.relu(self.p1_1(x))
        # 支路2包含 1x1卷积 + 3x3卷积
        p2 = F.relu(self.p2_2(F.relu(self.p2_1(x))))
        # 支路3包含 1x1卷积 + 5x5卷积
        p3 = F.relu(self.p3_2(F.relu(self.p3_1(x))))
        # 支路4包含 最大池化和1x1卷积
        p4 = F.relu(self.p4_2(self.p4_1(x)))
        # 将每个支路的输出特征图拼接在一起作为最终的输出结果
        return paddle.concat([p1, p2, p3, p4], axis=1)

GoogLeNet的架构如 图2 所示,在主体卷积部分中使用5个模块(block),每个模块之间使用步幅为2的3 ×3最大池化层来减小输出高宽。

  • 第一模块使用一个64通道的7 × 7卷积层。
  • 第二模块使用2个卷积层:首先是64通道的1 × 1卷积层,然后是将通道增大3倍的3 × 3卷积层。
  • 第三模块串联2个完整的Inception块。
  • 第四模块串联了5个Inception块。
  • 第五模块串联了2 个Inception块。
  • 第五模块的后面紧跟输出层,使用全局平均池化层来将每个通道的高和宽变成1,最后接上一个输出个数为标签类别数的全连接层。

说明:
在原作者的论文中添加了图中所示的softmax1和softmax2两个辅助分类器,如下图所示,训练时将三个分类器的损失函数进行加权求和,以缓解梯度消失现象。

4.2 GoogLeNet模型实现

GoogLeNet的具体实现如下代码所示:

#GoogLeNet模型代码
import paddle
from paddle import ParamAttr
import paddle.nn as nn
import paddle.nn.functional as F
from paddle.nn import Conv2D, BatchNorm, Linear, Dropout
from paddle.nn import AdaptiveAvgPool2D, MaxPool2D, AvgPool2D
from paddle.nn.initializer import Uniform
import math

#全连接层参数初始化
def xavier(channels, filter_size, name):
    stdv = (3.0 / (filter_size**2 * channels))**0.5
    param_attr = ParamAttr(initializer=Uniform(-stdv, stdv), name=name + "_weights")
    return param_attr


class ConvLayer(nn.Layer):
    def __init__(self,
                 num_channels,
                 num_filters,
                 filter_size,
                 stride=1,
                 groups=1,
                 act=None,
                 name=None):
        super(ConvLayer, self).__init__()

        self._conv = Conv2D(
            in_channels=num_channels,
            out_channels=num_filters,
            kernel_size=filter_size,
            stride=stride,
            padding=(filter_size - 1) // 2,
            groups=groups,
            weight_attr=ParamAttr(name=name + "_weights"),
            bias_attr=False)

    def forward(self, inputs):
        y = self._conv(inputs)
        return y

#定义Inception块
class Inception(nn.Layer):
    def __init__(self,
                 input_channels,
                 output_channels,
                 filter1,
                 filter3R,
                 filter3,
                 filter5R,
                 filter5,
                 proj,
                 name=None):
      	'''
        Inception模块的实现代码,
        
        c1,图(b)中第一条支路1x1卷积的输出通道数,数据类型是整数
        c2,图(b)中第二条支路卷积的输出通道数,数据类型是tuple或list, 
               其中c2[0]是1x1卷积的输出通道数,c2[1]是3x3
        c3,图(b)中第三条支路卷积的输出通道数,数据类型是tuple或list, 
               其中c3[0]是1x1卷积的输出通道数,c3[1]是3x3
        c4,图(b)中第一条支路1x1卷积的输出通道数,数据类型是整数
        '''
        super(Inception, self).__init__()
				# 依次创建Inception块每条支路上使用到的操作
        self._conv1 = ConvLayer(input_channels, filter1, 1, name="inception_" + name + "_1x1")
        self._conv3r = ConvLayer(input_channels, filter3R, 1, name="inception_" + name + "_3x3_reduce")
        self._conv3 = ConvLayer(filter3R, filter3, 3, name="inception_" + name + "_3x3")
        self._conv5r = ConvLayer(input_channels, filter5R, 1, name="inception_" + name + "_5x5_reduce")
        self._conv5 = ConvLayer(filter5R, filter5, 5, name="inception_" + name + "_5x5")
        self._pool = MaxPool2D(kernel_size=3, stride=1, padding=1)
        self._convprj = ConvLayer(input_channels, proj, 1, name="inception_" + name + "_3x3_proj")

    def forward(self, inputs):
      	# 支路1只包含一个1x1卷积
        conv1 = self._conv1(inputs)
				# 支路2包含 1x1卷积 + 3x3卷积
        conv3r = self._conv3r(inputs)
        conv3 = self._conv3(conv3r)
				# 支路3包含 1x1卷积 + 5x5卷积
        conv5r = self._conv5r(inputs)
        conv5 = self._conv5(conv5r)
				# 支路4包含 最大池化和1x1卷积
        pool = self._pool(inputs)
        convprj = self._convprj(pool)
				# 将每个支路的输出特征图拼接在一起作为最终的输出结果
        cat = paddle.concat([conv1, conv3, conv5, convprj], axis=1)
        cat = F.relu(cat)
        return cat


class GoogLeNet(nn.Layer):
    def __init__(self, class_dim=1000):
        super(GoogLeNetDY, self).__init__()
        # GoogLeNet包含五个模块,每个模块后面紧跟一个池化层
        # 第一个模块包含1个卷积层
        self._conv = ConvLayer(3, 64, 7, 2, name="conv1")
        # 3x3最大池化
        self._pool = MaxPool2D(kernel_size=3, stride=2)
        # 第二个模块包含2个卷积层
        self._conv_1 = ConvLayer(64, 64, 1, name="conv2_1x1")
        self._conv_2 = ConvLayer(64, 192, 3, name="conv2_3x3")
				# 第三个模块包含2个Inception块
        self._ince3a = Inception(192, 192, 64, 96, 128, 16, 32, 32, name="ince3a")
        self._ince3b = Inception(256, 256, 128, 128, 192, 32, 96, 64, name="ince3b")
				# 第四个模块包含5个Inception块
        self._ince4a = Inception(480, 480, 192, 96, 208, 16, 48, 64, name="ince4a")
        self._ince4b = Inception(512, 512, 160, 112, 224, 24, 64, 64, name="ince4b")
        self._ince4c = Inception(512, 512, 128, 128, 256, 24, 64, 64, name="ince4c")
        self._ince4d = Inception(512, 512, 112, 144, 288, 32, 64, 64, name="ince4d")
        self._ince4e = Inception(528, 528, 256, 160, 320, 32, 128, 128, name="ince4e")
				# 第五个模块包含2个Inception块
        self._ince5a = Inception(832, 832, 256, 160, 320, 32, 128, 128, name="ince5a")
        self._ince5b = Inception(832, 832, 384, 192, 384, 48, 128, 128, name="ince5b")
				# 全局池化
        self._pool_5 = AvgPool2D(kernel_size=7, stride=7)

        self._drop = Dropout(p=0.4, mode="downscale_in_infer")
        self._fc_out = Linear(
            1024,
            class_dim,
            weight_attr=xavier(1024, 1, "out"),
            bias_attr=ParamAttr(name="out_offset"))
        self._pool_o1 = AvgPool2D(kernel_size=5, stride=3)
        self._conv_o1 = ConvLayer(512, 128, 1, name="conv_o1")
        self._fc_o1 = Linear(
            1152,
            1024,
            weight_attr=xavier(2048, 1, "fc_o1"),
            bias_attr=ParamAttr(name="fc_o1_offset"))
        self._drop_o1 = Dropout(p=0.7, mode="downscale_in_infer")
        self._out1 = Linear(
            1024,
            class_dim,
            weight_attr=xavier(1024, 1, "out1"),
            bias_attr=ParamAttr(name="out1_offset"))
        self._pool_o2 = AvgPool2D(kernel_size=5, stride=3)
        self._conv_o2 = ConvLayer(528, 128, 1, name="conv_o2")
        self._fc_o2 = Linear(
            1152,
            1024,
            weight_attr=xavier(2048, 1, "fc_o2"),
            bias_attr=ParamAttr(name="fc_o2_offset"))
        self._drop_o2 = Dropout(p=0.7, mode="downscale_in_infer")
        self._out2 = Linear(
            1024,
            class_dim,
            weight_attr=xavier(1024, 1, "out2"),
            bias_attr=ParamAttr(name="out2_offset"))

    def forward(self, inputs):
        x = self._conv(inputs)
        x = self._pool(x)
        x = self._conv_1(x)
        x = self._conv_2(x)
        x = self._pool(x)

        x = self._ince3a(x)
        x = self._ince3b(x)
        x = self._pool(x)

        ince4a = self._ince4a(x)
        x = self._ince4b(ince4a)
        x = self._ince4c(x)
        ince4d = self._ince4d(x)
        x = self._ince4e(ince4d)
        x = self._pool(x)

        x = self._ince5a(x)
        ince5b = self._ince5b(x)

        x = self._pool_5(ince5b)
        x = self._drop(x)
        x = paddle.squeeze(x, axis=[2, 3])
        out = self._fc_out(x)

        x = self._pool_o1(ince4a)
        x = self._conv_o1(x)
        x = paddle.flatten(x, start_axis=1, stop_axis=-1)
        x = self._fc_o1(x)
        x = F.relu(x)
        x = self._drop_o1(x)
        out1 = self._out1(x)

        x = self._pool_o2(ince4d)
        x = self._conv_o2(x)
        x = paddle.flatten(x, start_axis=1, stop_axis=-1)
        x = self._fc_o2(x)
        x = self._drop_o2(x)
        out2 = self._out2(x)
        return [out, out1, out2]

4.3 GoogLeNet模型特色

  • 采用不同大小的卷积核意味着不同大小的感受野,最后通过拼接实现不同尺度特征的融合;
  • 之所以卷积核大小采用1、3和5,主要是为了方便对齐。设定卷积步长stride=1之后,只要分别设定pad=0、1、2,那么卷积之后便可以得到相同维度的特征,然后这些特征就可以直接拼接在一起了;
  • 网络越到后面,特征越抽象,而且每个特征所涉及的感受野也更大了,因此随着层数的增加,3x3和5x5卷积的比例也要增加。但是,使用5x5的卷积核仍然会带来巨大的计算量。 为此,文章采用1x1卷积核来进行降维。

4.4 GoogLeNet模型指标

GoogLeNet 在 2014 年的 ImageNet 比赛上取得了冠军的好成绩,具体指标如 图3 所示。在测试集上Error rate 达到了6.67%。

  • 参考文献

[1] Going deeper with convolutions.

5.DarkNet(YOLOv2、3)

在目标检测领域的YOLO系列算法中,作者为了达到更好的分类效果,自己设置并训练了DarkNet网络作为骨干网络。其中,YOLOv2[1]首次提出DarkNet网络,由于其具有19个卷积层,所以也称之为DarkNet19。后来在YOLOv3[2]中,作者继续吸收了当前优秀算法的思想,如残差网络和特征融合等,提出了具有53个卷积层的骨干网络DarkNet53。作者在ImageNet上进行了实验,发现相较于ResNet-152和ResNet-101,DarkNet53在分类精度差不多的前提下,计算速度取得了领先。

5.1 DarkNet模型结构

5.1.1 DarkNet19

DarkNet19中,借鉴了许多优秀算法的经验,比如:借鉴了VGG的思想,使用了较多的$3\times 3$卷积,在每一次池化操作后,将通道数翻倍;借鉴了network in network的思想,使用全局平均池化(global average pooling)做预测,并把$1\times 1$的卷积核置于$3\times 3$的卷积核之间,用来压缩特征;同时,使用了批归一化层稳定模型训练,加速收敛,并且起到正则化作用。DarkNet19的网络结构如 图1 所示。

DarkNet19精度与VGG网络相当,但浮点运算量只有其 $\frac{1}{5}$ 左右,因此运算速度极快。

5.1.2 DarkNet53

DarkNet53在之前的基础上,借鉴了ResNet的思想,在网络中大量使用了残差连接,因此网络结构可以设计的很深,并且缓解了训练中梯度消失的问题,使得模型更容易收敛。同时,使用步长为2的卷积层代替池化层实现降采样。DarkNet53的网络结构如 图2 所示。

考虑到当前 Darknet19 网络使用频率较低,接下来主要针对Darknet53网络进行实现与讲解。

5.2 DarkNet模型实现

基于Paddle框架,DarkNet53的具体实现的代码如下所示:

import paddle
from paddle import ParamAttr
import paddle.nn as nn
import paddle.nn.functional as F
from paddle.nn import Conv2D, BatchNorm, Linear, Dropout
from paddle.nn import AdaptiveAvgPool2D, MaxPool2D, AvgPool2D
from paddle.nn.initializer import Uniform
import math

#将卷积和批归一化封装为ConvBNLayer,方便后续复用
class ConvBNLayer(nn.Layer):
    def __init__(self,
                 input_channels,
                 output_channels,
                 filter_size,
                 stride,
                 padding,
                 name=None):
      	# 初始化函数
        super(ConvBNLayer, self).__init__()
				# 创建卷积层
        self._conv = Conv2D(
            in_channels=input_channels,
            out_channels=output_channels,
            kernel_size=filter_size,
            stride=stride,
            padding=padding,
            weight_attr=ParamAttr(name=name + ".conv.weights"),
            bias_attr=False)
				# 创建批归一化层
        bn_name = name + ".bn"
        self._bn = BatchNorm(
            num_channels=output_channels,
            act="relu",
            param_attr=ParamAttr(name=bn_name + ".scale"),
            bias_attr=ParamAttr(name=bn_name + ".offset"),
            moving_mean_name=bn_name + ".mean",
            moving_variance_name=bn_name + ".var")

    def forward(self, inputs):
      	# 前向计算
        x = self._conv(inputs)
        x = self._bn(x)
        return x

#定义残差块
class BasicBlock(nn.Layer):
    def __init__(self, input_channels, output_channels, name=None):
      	# 初始化函数
        super(BasicBlock, self).__init__()
				# 定义两个卷积层
        self._conv1 = ConvBNLayer(
            input_channels, output_channels, 1, 1, 0, name=name + ".0")
        self._conv2 = ConvBNLayer(
            output_channels, output_channels * 2, 3, 1, 1, name=name + ".1")

    def forward(self, inputs):
      	# 前向计算
        x = self._conv1(inputs)
        x = self._conv2(x)
        # 将第二个卷积层的输出和最初的输入值相加
        return paddle.add(x=inputs, y=x)


class DarkNet53(nn.Layer):
    def __init__(self, class_dim=1000):
      	# 初始化函数
        super(DarkNet, self).__init__()
				# DarkNet 每组残差块的个数,来自DarkNet的网络结构图
        self.stages = [1, 2, 8, 8, 4]
        # 第一层卷积
        self._conv1 = ConvBNLayer(3, 32, 3, 1, 1, name="yolo_input")
        # 下采样,使用stride=2的卷积来实现
        self._conv2 = ConvBNLayer(
            32, 64, 3, 2, 1, name="yolo_input.downsample")
				# 添加各个层级的实现
        self._basic_block_01 = BasicBlock(64, 32, name="stage.0.0")
        # 下采样,使用stride=2的卷积来实现
        self._downsample_0 = ConvBNLayer(
            64, 128, 3, 2, 1, name="stage.0.downsample")

        self._basic_block_11 = BasicBlock(128, 64, name="stage.1.0")
        self._basic_block_12 = BasicBlock(128, 64, name="stage.1.1")
        # 下采样,使用stride=2的卷积来实现
        self._downsample_1 = ConvBNLayer(
            128, 256, 3, 2, 1, name="stage.1.downsample")

        self._basic_block_21 = BasicBlock(256, 128, name="stage.2.0")
        self._basic_block_22 = BasicBlock(256, 128, name="stage.2.1")
        self._basic_block_23 = BasicBlock(256, 128, name="stage.2.2")
        self._basic_block_24 = BasicBlock(256, 128, name="stage.2.3")
        self._basic_block_25 = BasicBlock(256, 128, name="stage.2.4")
        self._basic_block_26 = BasicBlock(256, 128, name="stage.2.5")
        self._basic_block_27 = BasicBlock(256, 128, name="stage.2.6")
        self._basic_block_28 = BasicBlock(256, 128, name="stage.2.7")
        # 下采样,使用stride=2的卷积来实现
        self._downsample_2 = ConvBNLayer(
            256, 512, 3, 2, 1, name="stage.2.downsample")

        self._basic_block_31 = BasicBlock(512, 256, name="stage.3.0")
        self._basic_block_32 = BasicBlock(512, 256, name="stage.3.1")
        self._basic_block_33 = BasicBlock(512, 256, name="stage.3.2")
        self._basic_block_34 = BasicBlock(512, 256, name="stage.3.3")
        self._basic_block_35 = BasicBlock(512, 256, name="stage.3.4")
        self._basic_block_36 = BasicBlock(512, 256, name="stage.3.5")
        self._basic_block_37 = BasicBlock(512, 256, name="stage.3.6")
        self._basic_block_38 = BasicBlock(512, 256, name="stage.3.7")
        # 下采样,使用stride=2的卷积来实现
        self._downsample_3 = ConvBNLayer(
            512, 1024, 3, 2, 1, name="stage.3.downsample")

        self._basic_block_41 = BasicBlock(1024, 512, name="stage.4.0")
        self._basic_block_42 = BasicBlock(1024, 512, name="stage.4.1")
        self._basic_block_43 = BasicBlock(1024, 512, name="stage.4.2")
        self._basic_block_44 = BasicBlock(1024, 512, name="stage.4.3")
				# 自适应平均池化
        self._pool = AdaptiveAvgPool2D(1)

        stdv = 1.0 / math.sqrt(1024.0)
        # 分类层
        self._out = Linear(
            1024,
            class_dim,
            weight_attr=ParamAttr(
                name="fc_weights", initializer=Uniform(-stdv, stdv)),
            bias_attr=ParamAttr(name="fc_offset"))

    def forward(self, inputs):
        x = self._conv1(inputs)
        x = self._conv2(x)

        x = self._basic_block_01(x)
        x = self._downsample_0(x)

        x = self._basic_block_11(x)
        x = self._basic_block_12(x)
        x = self._downsample_1(x)

        x = self._basic_block_21(x)
        x = self._basic_block_22(x)
        x = self._basic_block_23(x)
        x = self._basic_block_24(x)
        x = self._basic_block_25(x)
        x = self._basic_block_26(x)
        x = self._basic_block_27(x)
        x = self._basic_block_28(x)
        x = self._downsample_2(x)

        x = self._basic_block_31(x)
        x = self._basic_block_32(x)
        x = self._basic_block_33(x)
        x = self._basic_block_34(x)
        x = self._basic_block_35(x)
        x = self._basic_block_36(x)
        x = self._basic_block_37(x)
        x = self._basic_block_38(x)
        x = self._downsample_3(x)

        x = self._basic_block_41(x)
        x = self._basic_block_42(x)
        x = self._basic_block_43(x)
        x = self._basic_block_44(x)

        x = self._pool(x)
        x = paddle.squeeze(x, axis=[2, 3])
        x = self._out(x)
        return x

5.3 DarkNet模型特点

  • DarkNet53模型使用了大量的残差连接,缓解了训练中梯度消失的问题,使得模型更容易收敛。
  • DarkNet53模型使用步长为2的卷积层代替池化层实现降采样。

5.4 DarkNet模型指标

在 YOLOv3 论文中,作者在 ImageNet 数据集上对比了 DarkNet 网络与ResNet 网络的精度及速度,如图3所示。可以看到DarkNet53的top-5准确率可以达到93.8%,同时速度也明显超过了ResNet101和ResNet152。

更多文章请关注公重号:汀丶人工智能

  • 参考文献

[1] YOLO9000: Better, Faster, Stronger

[2] YOLOv3: An Incremental Improvement文章来源地址https://www.toymoban.com/news/detail-474313.html

到了这里,关于深度学习应用篇-计算机视觉-图像分类[2]:LeNet、AlexNet、VGG、GoogleNet、DarkNet模型结构、实现、模型特点详细介绍的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 深度学习应用篇-计算机视觉-图像增广[1]:数据增广、图像混叠、图像剪裁类变化类等详解

    【深度学习入门到进阶】必看系列,含激活函数、优化策略、损失函数、模型调优、归一化算法、卷积模型、序列模型、预训练模型、对抗神经网络等 专栏详细介绍:【深度学习入门到进阶】必看系列,含激活函数、优化策略、损失函数、模型调优、归一化算法、卷积模型、

    2024年02月08日
    浏览(48)
  • 基于深度学习的计算机视觉垃圾分类项目解析

    项目地址:https://gitcode.com/YaoHaozhe/Computer-vision-based-on-deep-learning-garbage-classification 在这个数字化的时代,数据已经成为我们生活和工作的重要组成部分,而其中,图像数据的处理能力更是关键。YaoHaozhe 创建的这个基于深度学习的计算机视觉垃圾分类项目,提供了一个实用的解决

    2024年04月12日
    浏览(34)
  • 计算机视觉:从图像识别到深度学习

    💂 个人网站:【工具大全】【游戏大全】【神级源码资源网】 🤟 前端学习课程:👉【28个案例趣学前端】【400个JS面试题】 💅 寻找学习交流、摸鱼划水的小伙伴,请点击【摸鱼学习交流群】 计算机视觉是人工智能领域中的一个重要分支,它致力于让计算机能够理解和处理

    2024年02月07日
    浏览(58)
  • 深度学习|10.1 深度学习在计算机视觉的应用

    图像中的每一个像素点都是输入层的一部分。而最终最后只有一个输出点,也就是说需要通过乘上中间层/隐藏层内部的矩阵,从而实现降维。 直观上,信息越多,分析的效果应该越好,但也意味着分析的量会越来越大,考虑到分析所需要的时间和空间,往往采用卷积的方式

    2024年02月03日
    浏览(50)
  • 【深度学习:图像分割指南】计算机视觉中的图像分割指南:最佳实践

    图像分割是计算机视觉中的一项关键任务,其目标是将图像划分为不同的有意义且可区分的区域或对象。这是物体识别、跟踪和检测、医学成像和机器人等各种应用中的一项基本任务。 许多技术可用于图像分割,从传统方法到基于深度学习的方法。随着深度学习的出现,图像

    2024年01月23日
    浏览(90)
  • 计算机视觉与深度学习-图像分割-视觉识别任务03-实例分割-【北邮鲁鹏】

    论文题目:Mask R-CNN 论文链接:论文下载 论文代码:Facebook代码链接;Tensorflow版本代码链接; Keras and TensorFlow版本代码链接;MxNet版本代码链接 参考:Mask R-CNN详解 将图像中的每个像素与其所属的目标实例进行关联,并为每个像素分配一个特定的标签,以实现像素级别的目标

    2024年02月07日
    浏览(63)
  • 计算机视觉与深度学习-图像分割-视觉识别任务01-语义分割-【北邮鲁鹏】

    给每个像素分配类别标签。 不区分实例,只考虑像素类别。 滑动窗口缺点 重叠区域的特征反复被计算,效率很低。 所以针对该问题提出了新的解决方案–全卷积。 让整个网络只包含卷积层,一次性输出所有像素的类别预测。 全卷积优点 不用将图片分为一个个小区域然后再

    2024年02月07日
    浏览(82)
  • 计算机竞赛 opencv python 深度学习垃圾图像分类系统

    🔥 优质竞赛项目系列,今天要分享的是 🚩 opencv python 深度学习垃圾分类系统 🥇学长这里给一个题目综合评分(每项满分5分) 难度系数:3分 工作量:3分 创新点:4分 这是一个较为新颖的竞赛课题方向,学长非常推荐! 🧿 更多资料, 项目分享: https://gitee.com/dancheng-senior/p

    2024年02月13日
    浏览(80)
  • 【探索AI】三十一-计算机视觉(六)深度学习在计算机视觉中的应用

    深度学习在计算机视觉中的应用已经取得了显著的成果,并且正在逐步改变我们对图像和视频信息的处理和理解方式。下面将详细讲解深度学习在计算机视觉中的几个关键应用。 首先,我们来看图像分类。图像分类是计算机视觉的基本任务之一,它涉及到将输入的图像自动归

    2024年04月09日
    浏览(70)
  • 计算机视觉学习笔记(图像的灰度与灰度级 图像的深度 图像噪声 图像处理)

    如果把白色和黑色之间按对数关系分为若干等级,称为灰度,灰度分为256阶,0为黑色,灰度就是没有色彩,RGB色彩分量全部相等(150,150,150)就代表灰度为150. 一幅图像中不同位置的亮度是不一样的,可用f(x,y)来表示(x,y)上的亮度。由于光是一种能量形式,因此亮度是非负

    2024年02月01日
    浏览(61)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包