卷积神经网络的基础算子

二、卷积神经网络的基础算子

卷积神经网络是目前计算机视觉中使用最普遍的模型结构,如图5.8 所示,由 M M M个卷积层和 b b b个汇聚层组合作用在输入图片上,在网络的最后通常会加入 K K K个全连接层。

图8 卷积神经网络经典结构

从上图可以看出,卷积网络是由多个基础的算子组合而成。下面我们先实现卷积网络的两个基础算子:卷积层算子和汇聚层算子。

1. 卷积算子

卷积层是指用卷积操作来实现神经网络中一层。为了提取不同种类的特征,通常会使用多个卷积核一起进行特征提取。

(1) 多通道卷积

在前面介绍的二维卷积运算中,卷积的输入数据是二维矩阵。但实际应用中,一幅大小为 M × N M\times N M×N的图片中的每个像素的特征表示不仅仅只有灰度值的标量,通常有多个特征,可以表示为 D D D维的向量,比如RGB三个通道的特征向量。因此,图像上的卷积操作的输入数据通常是一个三维张量,分别对应了图片的高度 M M M、宽度 N N N和深度 D D D,其中深度 D D D通常也被称为输入通道数 D D D。如果输入如果是灰度图像,则输入通道数为1;如果输入是彩色图像,分别有 R 、 G 、 B R、G、B RGB三个通道,则输入通道数为3。
此外,由于具有单个核的卷积每次只能提取一种类型的特征,即输出一张大小为 U × V U\times V U×V特征图(Feature Map)。而在实际应用中,我们也希望每一个卷积层能够提取多种不同类型的特征,所以一个卷积层通常会组合多个不同的卷积核来提取特征,经过卷积运算后会输出多张特征图,不同的特征图对应不同类型的特征。输出特征图的个数通常将其称为输出通道数 P P P

假设一个卷积层的输入特征图 X ∈ R D × M × N \mathbf X\in \mathbb{R}^{D\times M\times N} XRD×M×N,其中 ( M , N ) (M,N) (M,N)为特征图的尺寸, D D D代表通道数;卷积核为 W ∈ R P × D × U × V \mathbf W\in \mathbb{R}^{P\times D\times U\times V} WRP×D×U×V,其中 ( U , V ) (U,V) (U,V)为卷积核的尺寸, D D D代表输入通道数, P P P代表输出通道数。

一张输出特征图的计算

对于 D D D个输入通道,分别对每个通道的特征图 X d \mathbf X^d Xd设计一个二维卷积核 W p , d \mathbf W^{p,d} Wp,d,并与对应的输入特征图 X d \mathbf X^d Xd进行卷积运算,再将得到的 D D D个结果进行加和,得到一张输出特征图 Z p \mathbf Z^p Zp。计算方式如下:
Z p = ∑ d = 1 D W p , d ⊗ X d + b p \begin{align} \mathbf Z^p = \sum_{d=1}^D \mathbf W^{p,d} \otimes \mathbf X^d + b^p \end{align} Zp=d=1DWp,dXd+bp Y p = f ( Z p ) \begin{align} \mathbf Y^p = f(\mathbf Z^p) \end{align} Yp=f(Zp)其中 p p p表示输出特征图的索引编号, W p , d ∈ R U × V \mathbf W^{p,d} \in \mathbb{R}^{U\times V} Wp,dRU×V为二维卷积核, b p b^p bp为标量偏置, f ( ⋅ ) f(·) f()为非线性激活函数,一般用ReLU函数。

公式(1)对应的可视化如图9所示。

图9 多输入通道的卷积运算

多张输出特征图的计算

对于大小为 D × M × N D\times M\times N D×M×N的输入特征图,每一个输出特征图都需要一组大小为 W ∈ R D × U × V \mathbf W\in \mathbb{R}^{D\times U\times V} WRD×U×V的卷积核进行卷积运算。使用 P P P组卷积核分布进行卷积运算,得到 P P P个输出特征图 Y 1 , Y 2 , ⋯   , Y P \mathbf Y^1, \mathbf Y^2,\cdots,\mathbf Y^P Y1,Y2,,YP。然后将 P P P个输出特征图进行拼接,获得大小为 P × M ′ × N ′ P\times M' \times N' P×M×N的多通道输出特征图。上面计算方式的可视化如图10所示。

图10 多输出通道的卷积运算

(2) 多通道卷积层算子

根据上面的公式,多通道卷积卷积层的代码实现如下:

import torch
import torch.nn as nn

class Conv2D(nn.Module):
    def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0, weight_attr=1.0, bias_attr=0.0):
        super(Conv2D, self).__init__()
        # 创建卷积核
        self.weight = nn.Parameter(torch.full((out_channels, in_channels, kernel_size, kernel_size), weight_attr))
        # 创建偏置
        self.bias = nn.Parameter(torch.full((out_channels, 1), bias_attr))
        self.stride = stride
        self.padding = padding
        # 输入通道数
        self.in_channels = in_channels
        # 输出通道数
        self.out_channels = out_channels

    # 基础卷积运算
    def single_forward(self, X, weight):
        # 零填充
        new_X = torch.zeros([X.shape[0], X.shape[1] + 2 * self.padding, X.shape[2] + 2 * self.padding])
        new_X[:, self.padding:X.shape[1] + self.padding, self.padding:X.shape[2] + self.padding] = X
        u, v = weight.shape
        output_w = (new_X.shape[1] - u) // self.stride + 1
        output_h = (new_X.shape[2] - v) // self.stride + 1
        output = torch.zeros([X.shape[0], output_w, output_h])
        for i in range(0, output.shape[1]):
            for j in range(0, output.shape[2]):
                output[:, i, j] = torch.sum(
                    new_X[:, self.stride * i:self.stride * i + u, self.stride * j:self.stride * j + v] * weight,
                    dim=[1, 2])
        return output

    def forward(self, inputs):
        """
        输入:
            - inputs:输入矩阵,shape=[B, D, M, N]
            - weights:P组二维卷积核,shape=[P, D, U, V]
            - bias:P个偏置,shape=[P, 1]
        """
        feature_maps = []
        # 进行多次多输入通道卷积运算
        p = 0
        for w, b in zip(self.weight, self.bias):  # P个(w,b),每次计算一个特征图Zp
            multi_outs = []
            # 循环计算每个输入特征图对应的卷积结果
            for i in range(self.in_channels):
                single = self.single_forward(inputs[:, i, :, :], w[i])
                multi_outs.append(single)
                # print("Conv2D in_channels:",self.in_channels,"i:",i,"single:",single.shape)
            # 将所有卷积结果相加
            feature_map = torch.sum(torch.stack(multi_outs), dim=0) + b  # Zp
            feature_maps.append(feature_map)
            # print("Conv2D out_channels:",self.out_channels, "p:",p,"feature_map:",feature_map.shape)
            p += 1
        # 将所有Zp进行堆叠
        out = torch.stack(feature_maps, 1)
        return out


inputs = torch.tensor([[[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]],
                        [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]]])
conv2d = Conv2D(in_channels=2, out_channels=3, kernel_size=2)
print("inputs shape:", inputs.shape)
outputs = conv2d(inputs)
print("Conv2D outputs shape:", outputs.shape)

# 比较与torch API运算结果
conv2d_torch = Conv2D(in_channels=2, out_channels=3, kernel_size=2, weight_attr=1.0, bias_attr=0.0)
outputs_torch = conv2d_torch(inputs)
# 自定义算子运算结果
print('Conv2D outputs:', outputs)
# torch API运算结果
print('nn.Conv2D outputs:', outputs_torch)

代码执行结果:

inputs shape: torch.Size([1, 2, 3, 3])
Conv2D outputs shape: torch.Size([1, 3, 2, 2])
Conv2D outputs: tensor([[[[20., 28.],
          [44., 52.]],

         [[20., 28.],
          [44., 52.]],

         [[20., 28.],
          [44., 52.]]]], grad_fn=<StackBackward0>)
nn.Conv2D outputs: tensor([[[[20., 28.],
          [44., 52.]],

         [[20., 28.],
          [44., 52.]],

         [[20., 28.],
          [44., 52.]]]], grad_fn=<StackBackward0>)

(3) 卷积算子的参数量和计算量

参数量

对于大小为 D × M × N D\times M\times N D×M×N的输入特征图,使用 P P P组大小为 W ∈ R D × U × V \mathbf W\in \mathbb{R}^{D\times U\times V} WRD×U×V的卷积核进行卷积运算,参数量计算方式为:
p a r a m e t e r s = P × D × U × V + P \begin{align} parameters = P \times D \times U \times V + P \end{align} parameters=P×D×U×V+P其中,最后的 P P P代表偏置个数。例如:输入特征图大小为 3 × 32 × 32 3\times 32\times 32 3×32×32,使用 6 6 6组大小为 3 × 3 × 3 3\times 3\times 3 3×3×3的卷积核进行卷积运算,参数量为:
p a r a m e t e r s = 6 × 3 × 3 × 3 + 6 = 168 parameters = 6 \times 3 \times 3 \times 3 + 6= 168 parameters=6×3×3×3+6=168计算量

对于大小为 D × M × N D\times M\times N D×M×N的输入特征图,使用 P P P组大小为 W ∈ R D × U × V \mathbf W\in \mathbb{R}^{D\times U\times V} WRD×U×V的卷积核进行卷积运算,计算量计算方式为:
F L O P s = M ′ × N ′ × P × D × U × V + M ′ × N ′ × P \begin{align} FLOPs=M'\times N'\times P\times D\times U\times V + M'\times N'\times P \end{align} FLOPs=M×N×P×D×U×V+M×N×P其中 M ′ × N ′ × P M'\times N'\times P M×N×P代表加偏置的计算量,即输出特征图上每个点都要与 P P P组卷积核 W ∈ R D × U × V \mathbf W\in \mathbb{R}^{D\times U\times V} WRD×U×V进行 U × V × D U\times V\times D U×V×D次乘法运算后再加上偏置。比如对于输入特征图大小为 3 × 32 × 32 3\times 32\times 32 3×32×32,使用 6 6 6组大小为 3 × 3 × 3 3\times 3\times 3 3×3×3的卷积核进行卷积运算,计算量为:
F L O P s = M ′ × N ′ × P × D × U × V + M ′ × N ′ × P = 30 × 30 × 3 × 3 × 6 × 3 + 30 × 30 × 6 = 151200 FLOPs=M'\times N'\times P\times D\times U\times V + M'\times N'\times P= 30\times 30\times 3\times 3\times 6\times 3 + 30\times 30\times 6= 151200 FLOPs=M×N×P×D×U×V+M×N×P=30×30×3×3×6×3+30×30×6=151200


2. 汇聚层算子

汇聚层的作用是进行特征选择,降低特征数量,从而减少参数数量。由于汇聚之后特征图会变得更小,如果后面连接的是全连接层,可以有效地减小神经元的个数,节省存储空间并提高计算效率。

常用的汇聚方法有两种,分别是:平均汇聚和最大汇聚。

  • 平均汇聚:将输入特征图划分为 2 × 2 2\times2 2×2大小的区域,对每个区域内的神经元活性值取平均值作为这个区域的表示;
  • 最大汇聚:使用输入特征图的每个子区域内所有神经元的最大活性值作为这个区域的表示。

图11 给出了两种汇聚层的示例。

图11 汇聚层

汇聚层输出的计算尺寸与卷积层一致,对于一个输入矩阵 X ∈ R M × N \mathbf X\in\Bbb{R}^{M\times N} XRM×N和一个运算区域大小为 U × V U\times V U×V的汇聚层,步长为 S S S,对输入矩阵进行零填充,那么最终输出矩阵大小则为
M ′ = M + 2 P − U S + 1 \begin{align} M' = \frac{M + 2P - U}{S} + 1 \end{align} M=SM+2PU+1 N ′ = N + 2 P − V S + 1 \begin{align} N' = \frac{N + 2P - V}{S} + 1 \end{align} N=SN+2PV+1由于过大的采样区域会急剧减少神经元的数量,也会造成过多的信息丢失。目前,在卷积神经网络中比较典型的汇聚层是将每个输入特征图划分为 2 × 2 2\times2 2×2大小的不重叠区域,然后使用最大汇聚的方式进行下采样。

由于汇聚是使用某一位置的相邻输出的总体统计特征代替网络在该位置的输出,所以其好处是当输入数据做出少量平移时,经过汇聚运算后的大多数输出还能保持不变。比如:当识别一张图像是否是人脸时,我们需要知道人脸左边有一只眼睛,右边也有一只眼睛,而不需要知道眼睛的精确位置,这时候通过汇聚某一片区域的像素点来得到总体统计特征会显得很有用。这也就体现了汇聚层的平移不变特性。

汇聚层的参数量和计算量

由于汇聚层中没有参数,所以参数量为 0 0 0;最大汇聚中,没有乘加运算,所以计算量为 0 0 0,而平均汇聚中,输出特征图上每个点都对应了一次求平均运算。

使用PyTorch实现一个简单的汇聚层,代码实现如下:

class Pool2D(nn.Module):
    def __init__(self, size=(2, 2), mode='max', stride=1):
        super(Pool2D, self).__init__()
        # 汇聚方式
        self.mode = mode
        self.h, self.w = size
        self.stride = stride

    def forward(self, x):
        output_w = (x.shape[2] - self.w) // self.stride + 1
        output_h = (x.shape[3] - self.h) // self.stride + 1
        output = torch.zeros([x.shape[0], x.shape[1], output_w, output_h])
        # 汇聚
        for i in range(output.shape[2]):
            for j in range(output.shape[3]):
                # 最大汇聚
                if self.mode == 'max':
                    output[:, :, i, j] = torch.max(
                        x[:, :, self.stride * i:self.stride * i + self.w, self.stride * j:self.stride * j + self.h])
                # 平均汇聚
                elif self.mode == 'avg':
                    output[:, :, i, j] = torch.mean(
                        x[:, :, self.stride * i:self.stride * i + self.w, self.stride * j:self.stride * j + self.h],
                        dim=[2, 3])

        return output

inputs = torch.tensor([[[[1., 2., 3., 4.], [5., 6., 7., 8.], [9., 10., 11., 12.], [13., 14., 15., 16.]]]])
pool2d = Pool2D(stride=2)
outputs = pool2d(inputs)
print("input: {}, \noutput: {}".format(inputs.shape, outputs.shape))

# 比较Maxpool2D与torch API运算结果
maxpool2d_torch = nn.MaxPool2d(kernel_size=(2, 2), stride=2)
outputs_torch = maxpool2d_torch(inputs)
# 自定义算子运算结果
print('Maxpool2D outputs:', outputs)
# torch API运算结果
print('nn.Maxpool2D outputs:', outputs_torch)

# 比较Avgpool2D与torch API运算结果
avgpool2d_torch = nn.AvgPool2d(kernel_size=(2, 2), stride=2)
outputs_torch = avgpool2d_torch(inputs)
pool2d = Pool2D(mode='avg', stride=2)
outputs = pool2d(inputs)
# 自定义算子运算结果
print('Avgpool2D outputs:', outputs)
# torch API运算结果
print('nn.Avgpool2D outputs:', outputs_torch)

代码执行结果:

input: torch.Size([1, 1, 4, 4]), 
output: torch.Size([1, 1, 2, 2])
Maxpool2D outputs: tensor([[[[ 6.,  8.],
          [14., 16.]]]])
nn.Maxpool2D outputs: tensor([[[[ 6.,  8.],
          [14., 16.]]]])
Avgpool2D outputs: tensor([[[[ 3.5000,  5.5000],
          [11.5000, 13.5000]]]])
nn.Avgpool2D outputs: tensor([[[[ 3.5000,  5.5000],
          [11.5000, 13.5000]]]])

3. Convolution Demo的实现

该部分的内容参考文章如下:
cs231n, CS231n Convolutional Neural Networks for Visual Recognition

(1) 文章选段翻译

Convolution Demo. Below is a running demo of a CONV layer. Since 3D volumes are hard to visualize, all the volumes (the input volume (in blue), the weight volumes (in red), the output volume (in green)) are visualized with each depth slice stacked in rows. The input volume is of size W 1 = 5 , H 1 = 5 , D 1 = 3 W_1 = 5, H_1 = 5, D_1 = 3 W1=5,H1=5,D1=3, and the CONV layer parameters are K = 2 , F = 3 , S = 2 , P = 1 K = 2, F = 3, S = 2, P = 1 K=2,F=3,S=2,P=1. That is, we have two filters of size 3 × 3 3 \times 3 3×3, and they are applied with a stride of 2. Therefore, the output volume size has spatial size (5 - 3 + 2)/2 + 1 = 3. Moreover, notice that a padding of P = 1 P = 1 P=1 is applied to the input volume, making the outer border of the input volume zero. The visualization below iterates over the output activations (green), and shows that each element is computed by elementwise multiplying the highlighted input (blue) with the filter (red), summing it up, and then offsetting the result by the bias.

卷积的可视化。下图为一个卷积层卷积过程的运行演示。由于三维体难以可视化,因此每一维(输入维(红)、权重维(蓝)、输出维(绿))都将以各自的深度切片成行堆叠的的方式可视化。输入维的大小: W 1 = 5 , H 1 = 5 , D 1 = 3 W_1 = 5, H_1 = 5, D_1 = 3 W1=5,H1=5,D1=3,卷积层的参数: K = 2 , F = 3 , S = 2 , P = 1 K = 2, F = 3, S = 2, P = 1 K=2,F=3,S=2,P=1。现在,我们有两个 3 × 3 3 \times 3 3×3大小的滤波器,且步长为2。因此,输出维的空间大小为(5-3+2)/2+1=3。此外,我们需要留意:输入维做了 P = 1 P = 1 P=1的含零填充,以此实现围绕该维度一圈的零填充。下图对输出维(绿)进行了迭代,且每个元素都是由输入维(红)和权重维(蓝)的张量进行矩阵乘法运算并求和后,用偏差抵消结果得到的。

(2) 代码实现功能

Convolution demo (此网址为上文提到的gif)

代码实现如下:

import torch
import torch.nn as nn

class Conv2D(nn.Module):
    def __init__(self, in_channels, out_channels, kernel_size, stride=2, padding=1, weight_arr=1.0, bias_attr=0.0):
        super(Conv2D, self).__init__()
        # 创建卷积核
        self.weight = torch.nn.Parameter(torch.tensor([[[[-1, 1, 0],
                                                         [0, 1, 0],
                                                         [0, 1, 1]],

                                                        [[-1, -1, 0],
                                                         [0, 0, 0],
                                                         [0, -1, 0]],

                                                        [[0, 0, -1],
                                                         [0, 1, 0],
                                                         [1, -1, -1]]],

                                                       [[[1, 1, -1],
                                                         [-1, -1, 1],
                                                         [0, -1, 1]],

                                                        [[0, 1, 0],
                                                         [-1, 0, -1],
                                                         [-1, 1, 0]],

                                                        [[-1, 0, 0],
                                                         [-1, 0, 1],
                                                         [-1, 0, 0]]]], dtype=torch.float32))
        # 创建偏置
        self.bias = nn.Parameter(torch.tensor([[1], [0]], dtype=torch.float32))
        self.stride = stride
        self.padding = padding
        # 输入通道数
        self.in_channels = in_channels
        # 输出通道数
        self.out_channels = out_channels

    # 基础卷积运算
    def single_forward(self, X, weight):
        # 零填充
        new_X = torch.zeros([X.shape[0], X.shape[1] + 2 * self.padding, X.shape[2] + 2 * self.padding])
        new_X[:, self.padding:X.shape[1] + self.padding, self.padding:X.shape[2] + self.padding] = X
        u, v = weight.shape
        output_w = (new_X.shape[1] - u) // self.stride + 1
        output_h = (new_X.shape[2] - v) // self.stride + 1
        output = torch.zeros([X.shape[0], output_w, output_h])
        for i in range(0, output.shape[1]):
            for j in range(0, output.shape[2]):
                output[:, i, j] = torch.sum(
                    new_X[:, self.stride * i:self.stride * i + u, self.stride * j:self.stride * j + v] * weight,
                    dim=[1, 2])
        return output

    def forward(self, inputs):
        """
        输入:
            - inputs:输入矩阵,shape=[B, D, M, N]
            - weights:P组二维卷积核,shape=[P, D, U, V]
            - bias:P个偏置,shape=[P, 1]
        """
        feature_maps = []
        # 进行多次多输入通道卷积运算
        p = 0
        for w, b in zip(self.weight, self.bias):  # P个(w,b),每次计算一个特征图Zp
            multi_outs = []
            # 循环计算每个输入特征图对应的卷积结果
            for i in range(self.in_channels):
                single = self.single_forward(inputs[:, i, :, :], w[i])
                multi_outs.append(single)
                # print("Conv2D in_channels:",self.in_channels,"i:",i,"single:",single.shape)
            # 将所有卷积结果相加
            feature_map = torch.sum(torch.stack(multi_outs), dim=0) + b  # Zp
            feature_maps.append(feature_map)
            # print("Conv2D out_channels:",self.out_channels, "p:",p,"feature_map:",feature_map.shape)
            p += 1
        # 将所有Zp进行堆叠
        out = torch.stack(feature_maps, 1)
        return out

inputs = torch.tensor([[[[0, 1, 1, 0, 2],
                         [2, 2, 2, 2, 1],
                         [1, 0, 0, 2, 0],
                         [0, 1, 1, 0, 0],
                         [1, 2, 0, 0, 2]],

                        [[1, 0, 2, 2, 0],
                         [0, 0, 0, 2, 0],
                         [1, 2, 1, 2, 1],
                         [1, 0, 0, 0, 0],
                         [1, 2, 1, 1, 1]],

                        [[2, 1, 2, 0, 0],
                         [1, 0, 0, 1, 0],
                         [0, 2, 1, 0, 1],
                         [0, 1, 2, 2, 2],
                         [2, 1, 0, 0, 1]]]], dtype=torch.float32)
conv2d = Conv2D(in_channels=3, out_channels=2, kernel_size=3)
print("inputs shape:", inputs.shape)
outputs = conv2d(inputs)
print("Conv2D outputs shape:", outputs.shape)

# 自定义算子运算结果
print('Conv2D outputs:', outputs)

代码执行结果:

inputs shape: torch.Size([1, 3, 5, 5])
Conv2D outputs shape: torch.Size([1, 2, 3, 3])
Conv2D outputs: tensor([[[[ 6.,  7.,  5.],
          [ 3., -1., -1.],
          [ 2., -1.,  4.]],

         [[ 2., -5., -8.],
          [ 1., -4., -4.],
          [ 0., -5., -5.]]]], grad_fn=<StackBackward0>)
  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
在HLS中实现卷积神经网络(Convolutional Neural Network,CNN算子,通常需要以下步骤: 1. 确定卷积层的输入和输出维度。这包括输入特征图的尺寸、卷积核的尺寸、步长和填充大小等。 2. 定义卷积操作。在HLS中,可以使用二维数组表示特征图和卷积核。通过遍历输入特征图和卷积核的元素,并进行乘法累加操作,可以实现卷积操作。 3. 添加偏置项。在卷积操作后,通常会将偏置项添加到每个卷积结果中。可以使用固定的偏置数组,或者通过配置参数传递偏置值。 4. 应用激活函数。在卷积结果上应用激活函数,如ReLU等。可以使用条件语句或函数来实现激活函数。 5. 重复上述步骤,直到完成所有卷积层的计算。 此外,还有一些优化技巧可以应用于HLS中的卷积神经网络实现: 1. 数据流设计:通过合理地划分数据流,可以提高并行度并减少存储器开销。 2. 缓存优化:使用缓存来存储中间结果,以减少存储器访问延迟。 3. 权值共享:对于具有相同权重的卷积核,可以共享权值以减少计算量。 4. 量化:对于卷积层的权重和激活值进行量化,可以减少资源占用和功耗。 最后,为了实现一个完整的CNN模型,您需要将各个卷积层、池化层和全连接层等组合在一起,并通过数据流和控制信号来协调它们的计算顺序。 请注意,这只是一个基本的概述,具体实现细节会因具体的CNN架构和HLS工具的使用而有所不同。如果您有特定的网络结构或需求,可以提供更多信息,以便我能够给出更具体的指导。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值