MobileNetV1-V3结构解读及代码解析

本文详细介绍了MobileNetV1、V2和V3的结构特点,包括深度可分离卷积、倒残差结构以及在模型中的实现。MobileNet系列模型因其轻量级和高效性能在移动端广泛应用。MobileNetV1通过深度可分离卷积减少计算量和参数;MobileNetV2引入倒残差结构提高准确率;MobileNetV3则进一步优化,包括使用SE模块、新的激活函数和NAS搜索参数。
摘要由CSDN通过智能技术生成

MobileNetV1-V3结构解读及代码解析

根据百度paddle相关的代码将MobileNet改成pytorch代码,其中包含一个花分类数据集,配好环境后可直接训练分类任务,文章最后介绍如何使用自己的数据集训练模型代码链接



前言

MobileNet系列模型是轻量级模型,主要可以用于图像分类领域,并且有着不错的分类效果,但同样作为backbone也可以用到目标检测、语义分割、关键点检测等领域,在当前工业互联网中使用较多,尤其是在移动端这种算力较低的设备上。在图像算法工程师的面试过程中经常会问MobileNet相关的问题。


提示:以下是本篇文章正文内容,下面案例可供参考

1、MobileNetv1

MobileNetv1论文:https://arxiv.org/abs/1704.04861v1
MobileNet论文中提出了深度可分离卷积(Depthwise Convolution)能够让网络在计算力较低的移动端运行并且保证网络的性能不会下降太多,可分离卷积代替传统卷积大大减少模型参数,并提高了模型推理速度。(以大家熟悉的VGG16网络为例,MobileNetV1准确率减少了0.9%, 但模型参数量只有VGG16的1/32)。
深度可分离卷积是将原来的标准卷积分解成深度卷积以及一个1x1的逐点卷积,第一个卷积称为深度卷积,对每个输入通道应用单通道的轻量级滤波器;第二个卷积为逐点卷积,计算输入通道的线性组合并构建新的特征。

1.1传统卷积:

卷积核通道数 = 输入特征图通道数
输出特征图通道数 = 卷积核个数
在传统卷积里,输出特征通道数就是也就是卷积核个数就是当前图像计算的特征个数(即特征层通道数)。

在这里插入图片描述

1.2 可分离卷积

DW卷积核个数 = 1,逐点卷积核个数 = 输出特征图通道数
输入特征图通道数 = 卷积核个数 = 输出特征图通道数

DW卷积对输入的图像只使用一个卷积核,且卷积核的深度只有1,DW卷积能够极大的减少计算量。逐点卷积卷积个数等于输出特征通道个数。因此两个卷积核的参数量要小于传统卷积核的参数量,将在之后进行计算。
在这里插入图片描述

1.2 可分离卷积与传统卷积的计算量对比

假设输入的特征矩阵的宽,高和通道个数分别为W , H , C。卷积核的宽,高和卷积核个数为Kw , Kh , Kn。假设步幅(stride)为1

普通卷积的参数量为:P1=W×H×C×Kw×Kh×Kn

深度可分离卷积参数量为:P2=W×H×C×Kw×Kh×1+W×H×C×Kn×1×1

所以深度可分离卷积参数量与传统卷积的参数量的比值为:
P 2 P 1 = 1 K n + 1 K w + K h \frac {P2}{P1} = \frac 1{Kn} +\frac 1{Kw+Kh} P1P2=Kn1+Kw+Kh1
有上述公式可以看出
当输出深度越大,卷积核个数越大的情况下,深度可分离卷积的参数越少。
在这里插入图片描述
上图左侧是传统卷积右侧是深度可分离卷积

1.3 MobileNet v1整体结构

在这里插入图片描述

1.4 MobileNet v1 模型代码

# 卷积块,由三部分组成,卷积BatchNorm核激活函数(RelU)组成
class ConvBNReLU(nn.Module):

    def __init__(self, input_channels, output_channels, kernel_size, stride, **kwargs):
        super(ConvBNReLU, self).__init__()
        self.conv = nn.Conv2d(input_channels, output_channels, kernel_size, stride, **kwargs)
        self.bn = nn.BatchNorm2d(output_channels)
        self.relu = nn.ReLU(inplace=True)

    def forward(self, x):
        x = self.conv(x)
        x = self.bn(x)
        x = self.relu(x)
        return x

#深度可分离卷积块,由两部分组成,1.单核dw卷积,2.逐点卷积
class DepthSeparableConv2d(nn.Module):
    def __init__(self, in_channel, out_channel, kernel_size, **kwargs):
        super(DepthSeparableConv2d, self).__init__()
        ## DW卷积
        self.depthConv = nn.Sequential(
            nn.Conv2d(in_channel, in_channel, kernel_size, groups=in_channel, **kwargs),
            nn.BatchNorm2d(in_channel),
            nn.ReLU(inplace=True)
        )
        # 逐点卷积
        self.pointConv = nn.Sequential(
            nn.Conv2d(in_channel, out_channel, (1, 1)),
            nn.BatchNorm2d(out_channel),
            nn.ReLU(inplace=True)
        )

    def forward(self, x):
        x = self.depthConv(x)
        x = self.pointConv(x)
        return x
class MobileNetV1(nn.Module):

    def __init__(self, in_channels=3, scale=1.0, num_classes=0, **kwargs):
        super(MobileNetV1, self).__init__()

        input_channel = make_divisible(32 * scale)
        # 开始的一个卷积快用于映射特征
        self.conv1 = ConvBNReLU(in_channels, input_channel, 3, 2, padding=1, bias=False)
        # 深度可分离卷积参数设置
        # 输入通道、输出通道,stride
        depthSeparableConvSize = [
            # in out ,s, s
            [32, 64, 1],
            [64, 128, 2],
            [128, 256, 1],
            [256, 256, 1],
            [256, 512, 2],
        ]

        conv2 = []

        for i, o, s in depthSeparableConvSize:
            output_channel = make_divisible(o * scale)
            # 加入可分离深度卷积层
            conv2.append(DepthSeparableConv2d(input_channel, output_channel, 3, stride=s, padding=1, bias=False))
            input_channel = output_channel

        self.conv2 = nn.Sequential(*conv2)

        conv3 = []
        for i in range(5):
            # 加深度可分离卷积层,深度5
            conv3.append(DepthSeparableConv2d(input_channel, input_channel, 3, stride=1, padding=1, bias=False))
        self.conv3 = nn.Sequential(*conv3)

        last_channel = make_divisible(1024 * scale)
        # 加入深度可分离卷积,深度1
        self.conv4 = nn.Sequential(
            DepthSeparableConv2d(input_channel, last_channel, 3, stride=2, padding=1, bias=False),
            DepthSeparableConv2d(last_channel, last_channel, 3, stride=2, padding=1, bias=False)
        )
        # 池化层
        self.avgpool = nn.AdaptiveAvgPool2d(1)
        # 线性分类层
        self.fc = nn.Sequential(
            nn.Linear(last_channel, num_classes),
            nn.Softmax(),
        )

    def forward(self, x):
        x = self.conv1(x)
        x = self.conv2(x)
        x = self.conv3(x)
        x = self.conv4(x)
        x = self.avgpool(x)
        # 将三维图像展平为二维分类特征
        x = torch.flatten(x, 1)
        # print(len(x[0]))
        x = self.fc(x)
        return x

2、MobileNetv2

MobileNetv2是在MobileNetv1的基础上进行改进,准确度更高,模型更小。

mobilenetV2相对于V1的改进主要有两点:
倒残差结构:Inverted Residuals
Linear Bottlenecks

2.1 残差与倒残差的区别

在残差结构中,先使用 1x1 卷积进行降维,然后再使用 3x3 卷积进行特征提取,最后使用 1×1 卷积升维。是一个两头大、中间小的结构。
倒残差结构中,先使用 1x1 卷积进行升维,再通过 3x3 的DW 卷积进行提取特征,最后使用 1×1 卷积进行降维。并将 3×3 的标准卷积换为 DW 卷积,是一个两头小、中间大的结构。
在这里插入图片描述
根据残差结构的代码,我们已经知道,当stride=1且输入特征矩阵与输出特征矩阵shape相同时,会有shortcut连接。

残差结构与倒残差结构的区别:
残差模块
(1) 降维 - 卷积 - 升维 的过程;
(2) 卷积降维(1×1标准卷积) - 标准卷积提取特征(3×3) - 卷积升维(1×1标准卷积)(3) 使用 ReLU 激活函数;
倒残差模块
(1) 升维- 卷积 - 降维 的过程;
(2) 卷积升维 (1×1标准卷积) - DW卷积提取特征 (3×3) - 卷积降维 (1×1标准卷积)(3) 使用 ReLU6 激活函数和线性激活函数。

使用线性激活的位置及原因:原论文中作者通过实验得出:ReLU激活函数会对低维特征信息造成大量损失,但是对高维造成的损失比较小,而在倒残差结构中的输出是一个低维特征向量,因此使用线性激活函数避免特征信息损失。因此倒残差结构的最后一个1x1卷积层使用了线性激活函数。

2.2 倒残差结构

下图是论文中给的stride=1和stride=2的结构。残差结构的最后一个激活函数是线性的,而不是Relu6。
在这里插入图片描述
在这里插入图片描述

t表示扩展因子,即经过第一层的1 × 1 的卷积之后的扩展倍率。
c表示输出特征矩阵的channel。
n表示bottleneck的重复次数。
s表示步距大小,他只针对第一层的bottleneck的步距,其他的全为1

2.3 模型代码

#倒残差结构
class InvertedResidualBottleNeck(nn.Module):
    def __init__(self, in_channels, out_channels, stride, t=6):
        super(InvertedResidualBottleNeck, self).__init__()
        # 当stride=1且输入特征矩阵与输出特征矩阵shape相同时,会有shortcut连接
        self.use_shortcut = stride == 1 and in_channels == out_channels
        # 倒残差
        self.residual = nn.Sequential(
            # 这里与V1相同
            ConvBNReLU(in_channels, in_channels * t, kernel_size=1),
            ConvBNReLU(in_channels * t, in_channels * t, kernel_size=3, stride=stride, groups=in_channels * t),
            ConvBNReLU(in_channels * t, out_channels, kernel_size=1, if_act=False)
        )
    def forward(self, x):
        if self.use_shortcut:
            return x + self.residual(x)
        else:
            return self.residual(x)
class MobileNetV2_Rec(nn.Module):
    def __init__(self, in_channels=3, scale=1.0, round_nearest=8,  num_classes=0, **kwargs):
        super(MobileNetV2_Rec, self).__init__()
        features = []
        # 分类类别数
        self.num_classes = num_classes
        input_channel = make_divisible(32 * scale, round_nearest)
        last_channel = make_divisible(1280 * scale, round_nearest)
        # 第一层的特征层
        features.append(ConvBNReLU(in_channels, input_channel, kernel_size=1, stride=2))
        # 倒残差模块参数
        inverted_residual_setting = [
            # t, c, n, s
            [1, 16, 1, 1],
            [6, 24, 2, 2],
            [6, 32, 3, 2],
            [6, 64, 4, 2],
            [6, 96, 3, 1],
            [6, 160, 3, 2],
            [6, 320, 1, 1],
        ]

        for t, c, n, s in inverted_residual_setting:
            output_channel = make_divisible(c * scale, round_nearest)
            for i in range(n):
                stride = s if i == 0 else 1
				#在模型中加入倒残差模块                
                features.append(InvertedResidualBottleNeck(input_channel, output_channel, stride, t=t))
                input_channel = output_channel
         #最后加入一个卷积块
        features.append(ConvBNReLU(input_channel, last_channel, kernel_size=1))

        self.features = nn.Sequential(*features)

        self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
         # 分类线形层
        if num_classes > 0:
            self.classifier = nn.Sequential(nn.Dropout(0.2),
                                            nn.Linear(last_channel, num_classes))
        #初始化权重参数
        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                nn.init.kaiming_normal_(m.weight, mode='fan_out')
                if m.bias is not None:
                    nn.init.zeros_(m.bias)
            elif isinstance(m, nn.BatchNorm2d):
                nn.init.ones_(m.weight)
                nn.init.zeros_(m.bias)
            elif isinstance(m, nn.Linear):
                nn.init.normal_(m.weight, 0, 0.01)
                nn.init.zeros_(m.bias)

    def forward(self, x):
        x = self.features(x)
        x = self.avgpool(x)
        if self.num_classes > 0:
            x = torch.flatten(x, 1)
            # print(len(x))
            x = self.classifier(x)
        return x

3 MobileNetv3

新的Block:加入SE模块、新的激活函数
使用NAS搜索参数(Neural Architecture Search)
重新设计耗时层结构:减少第一个卷积层的核数(32->16),更新last-stage

3.1 新Block

在bottlenet结构中加入了SE结构,放在了depthwise filter之后,SE结构会消耗一定的时间,所以作者在含有SE的结构中,将expansion layer的channel变为原来的1/4,这样作者发现,即提高了精度,同时还没有增加时间消耗。并且SE结构放在了depthwise之后。实质为引入了一个channel级别的注意力机制。
SE:对得到的特征图的每个通道进行池化处理,channel等于多少,得到的一维向量就有多少个元素,后面再接两个全连接层得到输出向量。
在这里插入图片描述
使用h-swish替换swish。由于sigmoid的计算耗时较长,特别是在移动端,这些耗时就会比较明显,所以作者使用ReLU6(x+3)/6来近似替代sigmoid,利用ReLU有几点好处,1.可以在任何软硬件平台进行计算,2.量化的时候,它消除了潜在的精度损失,使用h-swish替换swith,在量化模式下回提高大约15%的效率,h-swish在深层网络中更加明显。
在这里插入图片描述

3.2 使用NAS搜索参数

资源受限的NAS(platform-aware NAS)与NetAdapt。资源受限的NAS,用于在计算和参数量受限的前提下搜索网络来优化各个块(block),所以称之为模块级搜索(Block-wise Search) 。NetAdapt,用于对各个模块确定之后网络层的微调每一层的卷积核数量,所以称之为层级搜索(Layer-wise Search)。一旦通过体系结构搜索找到模型,我们就会发现最后一些层以及一些早期层计算代价比较高昂。作者决定对这些架构进行一些修改,以减少这些慢层(slow layers)的延迟,同时保持准确性。

3.3 重新设计耗时层结构

减少第一个卷积层的核数:作者提到把第一层的卷积核个数减少一半,模型的性能并没有变化。针对last-stage,作者使用NAS搜索出来的Original Last Stage,这个结构比较耗时,于是精简得到Efficient Last Stage结构,模型性能没有变化,还减少了模型的推理时间。
在这里插入图片描述

3.4网络结构

在这里插入图片描述
exp_size表示bneck中的第一个1 × 1升维卷积,即经过1 × 1的卷积之后输出通道为多少、out表示输出的特征矩阵的通道数,即通过bneck的最后一个1 × 1卷积之后的输出通道数、SE表示是否使用注意力机制、NL表示激活函数是什么、s表示步距。bneck后面的3 × 3, 5 × 5,7 × 7等表示DW卷积核的大小。上表中的最后两层有个NBN表示不使用BN层结构。

3.5 模型代码

class MobileNetV3_rec(nn.Module):
    def __init__(self, in_channels=3,
                 model_name="small",
                 scale=0.5,
                 large_stride=None,
                 small_stride=None,
                 num_classes=0,
                 **kwargs):

        super(MobileNetV3_rec, self).__init__()
        if small_stride is None:
            small_stride = [2, 2, 2, 2]
        if large_stride is None:
            large_stride = [1, 2, 2, 2]
        assert isinstance(large_stride, list), "large_stride type must " \
                                               "be list but got {}".format(type(large_stride))
        assert isinstance(small_stride, list), "small_stride type must " \
                                               "be list but got {}".format(type(small_stride))
        assert len(large_stride) == 4, "large_stride length must be " \
                                       "4 but got {}".format(len(large_stride))
        assert len(small_stride) == 4,  "small_stride length must be " \
                                       "4 but got {}".format(len(small_stride))
        self.num_classes = num_classes
        # mobileNetV3的large模型参数配置,参数描述在上一节最后文字部分
        if model_name == "large":
            cfg = [
                # k, exp, c,  se,     nl,  s,
                [3, 16, 16, False, 'relu', large_stride[0]],
                [3, 64, 24, False, 'relu', (large_stride[1], 1)],
                [3, 72, 24, False, 'relu', 1],
                [5, 72, 40, True, 'relu', (large_stride[2], 1)],
                [5, 120, 40, True, 'relu', 1],
                [5, 120, 40, True, 'relu', 1],
                [3, 240, 80, False, 'hardswish', 1],
                [3, 200, 80, False, 'hardswish', 1],
                [3, 184, 80, False, 'hardswish', 1],
                [3, 184, 80, False, 'hardswish', 1],
                [3, 480, 112, True, 'hardswish', 1],
                [3, 672, 112, True, 'hardswish', 1],
                [5, 672, 160, True, 'hardswish', (large_stride[3], 1)],
                [5, 960, 160, True, 'hardswish', 1],
                [5, 960, 160, True, 'hardswish', 1],
            ]
            cls_ch_squeeze = 960
        # mobileNetV3的small模型,参数描述在上一节最后文字部分
        elif model_name == "small":
            cfg = [
                # k, exp, c,  se,     nl,  s,
                [3, 16, 16, True, 'relu', (small_stride[0], 1)],
                [3, 72, 24, False, 'relu', (small_stride[1], 1)],
                [3, 88, 24, False, 'relu', 1],
                [5, 96, 40, True, 'hardswish', (small_stride[2], 1)],
                [5, 240, 40, True, 'hardswish', 1],
                [5, 240, 40, True, 'hardswish', 1],
                [5, 120, 48, True, 'hardswish', 1],
                [5, 144, 48, True, 'hardswish', 1],
                [5, 288, 96, True, 'hardswish', (small_stride[3], 1)],
                [5, 576, 96, True, 'hardswish', 1],
                [5, 576, 96, True, 'hardswish', 1],
            ]
            cls_ch_squeeze = 576
        else:
            raise NotImplementedError("mode[" + model_name +
                                      "_model] is not implemented!")

        supported_scale = [0.35, 0.5, 0.75, 1.0, 1.25]
        assert scale in supported_scale,  "supported scales are {} " \
                                          "but input scale is {}".format(supported_scale, scale)

        inplanes = 16

        self.conv1 = ConvBNLayer(
            in_channels=in_channels,
            out_channels=make_divisible(inplanes * scale),
            kernel_size=3,
            stride=2,
            padding=1,
            groups=1,
            if_act=True,
            act='hardswish')

        i = 0
        block_list = []
        inplanes = make_divisible(scale * inplanes)

        for (k, exp, c, se, nl, s) in cfg:
            block_list.append(ResidualUnit(
                in_channels=inplanes,
                mid_channels=make_divisible(scale * exp),
                out_channels=make_divisible(scale * c),
                kernel_size=k,
                stride=s,
                use_se=se,
                act=nl))
            inplanes = make_divisible(scale * c)
            i += 1
        self.blocks = nn.Sequential(*block_list)

        self.conv2 = ConvBNLayer(in_channels=inplanes,
            out_channels=make_divisible(scale * cls_ch_squeeze),
            kernel_size=1,
            stride=1,
            padding=0,
            groups=1,
            if_act=True,
            act='hardswish')

        self.pool = nn.MaxPool2d(kernel_size=2, stride=2, padding=0)
        self.out_channels = make_divisible(scale * cls_ch_squeeze)

        if num_classes > 0:
            self.pool = nn.AdaptiveAvgPool2d(1)
            self.classifier = nn.Sequential(nn.Linear(self.out_channels, 128),
                                        nn.Hardswish(inplace=True),
                                        nn.Dropout(p=0.2, inplace=True),
                                        nn.Linear(128, 17))

        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                n = m.kernel_size[0] * m.kernel_size[1] * m.out_channels
                m.weight.data.normal_(0, math.sqrt(2. / n))
            elif isinstance(m, nn.BatchNorm2d):
                m.weight.data.fill_(1)
                m.bias.data.zero_()
            elif isinstance(m, nn.Linear):
                nn.init.normal_(m.weight, 0, 0.01)
                nn.init.zeros_(m.bias)

    def forward(self, x):
        x = self.conv1(x)
        x = self.blocks(x)
        x = self.conv2(x)
        x = self.pool(x)
        if self.num_classes > 0:
            x = torch.flatten(x, 1)
            # print(x)
            # print(len(x), len(x[1]))
            x = self.classifier(x)
        # x = x.view(x.size(0), -1)
        return x

4. 训练自己的数据集

在了解MobileNetV1-V3之后,添加训练文件,损失函数和数据集就可以训练模型。

4.1 数据集

训练姐目录结构
在这里插入图片描述
这里train下的每个文件夹表示一个类,每个文件夹下是当前类的图片,validation的结构与train相同。

4.2 修改参数

参数文件在config文件夹下,名为netConfig.yaml文件

GPU: True #是否使用GPU
GPUID: 0 # GPU的id,默认一个GPU的id是0
WORKERS: 0 # 数据处理并行线程数
PRINT_FREQ: 10
SAVE_FREQ: 10
PIN_MEMORY: False
OUTPUT_DIR: 'output' # 输出文件目录
NET: 'mobileNetv3' # 要使用的网络模型,有三个选择mobileNetv1,mobileNetv2,mobileNetv3,这里默认V3

CUDNN:
  BENCHMARK: True
  DETERMINISTIC: False
  ENABLED: True

DATASET:# 数据集
  NAME: 'Flower17-master'#数据集名称
  TRAIN_PATH: './datasets/Flower17-master/dataset/train'#训练文件路径一直到train文件夹
  VAL_PATN: './datasets/Flower17-master/dataset/validation'#测试文件路径一直到validation文件夹
  ROOT: ''
  TRAIN_IMGS_PATH: ''
  TRAIN_TARGET_PATH: ''
  VAL_IMGS_PATH: ''
  VAL_TARGET_PATH: ''
  STD: 0.226
  MEAN: 0.449

TRAIN:
  BATCH_SIZE: 4 #批次大小
  SHUFFLE: True
  BEGIN_EPOCH: 0 # 开始轮次
  END_EPOCH: 1000 # 结束轮次
  RESUME:
    IS_RESUME: False
    FILE_PATH: ''

  OPTIMIZER: 'adam' # 优化策略
  LR: 0.0001 # 初始学习率
  WEIGHT_DENCY: 0.0
  LR_STEP: 100
  LR_FACTOR: 0.1
  MOMENTUM: 0.0
  FINETUNE:
    IS_FINETUNE: False
    FINETUNE_CHECKPOINT: ''
    FREEZE: False

TEST:
  BATCH_SIZE_PER_GPU: 4
  SHUFFLE: True
  NUM_TEST_BATCH: 1000
  NUM_TEST_DISP: 10

MODEL:
  NAME: 'mobileNetv3'
  IMAGE_SIZE:
    HEIGHT: 300 # 图像的宽
    WEIGHT: 300 # 图像的高
  NUM_CLASSES: 17 # 图像分类类别
  NUM_HIDDEN: 256

从上述配置文件可以看出,将自己的数据集按照4.1节的路径配置好以后只需要修改DATASET:下的NAME:‘你自己的数据集名称’,以及TRAIN_PATH: ‘你自己的训练数据集路径’ 和 VAL_PATN: '你自己的测试数据集路径‘

4.3 训练文件

训练文件是项目下最外层的main.py文件,几乎没有需要修改的点,只需要调整部分的参数即可

4.4模型预测

预测文件位于util文件夹下的predict.py文件中,只需要修改以下部分:

def parse_arg():
    parser = argparse.ArgumentParser(description="predict")
    # 改成自己的模型配置路径
    parser.add_argument('--cfg', help='experiment configuration filename', type=str,
                        default='../config/netConfig.yaml')
    # 数据路径,改成自己的预测数据路径
    parser.add_argument('--image_path', type=str, default='',
                        help='the path to your image')
    # 权重路径,改成自己的模型权重路径
    parser.add_argument('--checkpoint', type=str, default='',
                        help='the path to your checkpoints')

    args = parser.parse_args()

    with open(args.cfg, "rb") as f:
        cfg = yaml.safe_load(f)
        cfg = edict(cfg)
        print(cfg)

    return cfg, args

参考

参考博客1

参考博客2

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

凉寒

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值