YOLO系列---YOLO V1原理及代码(一)

40 篇文章 3 订阅
19 篇文章 44 订阅
本文详细介绍了YOLOV1目标检测算法的原理,包括其与传统两阶段检测算法的区别,训练过程中的数据预处理,网络结构,以及损失函数的计算。重点解析了标签的生成、网络搭建、预测框坐标转换和损失函数的设计。代码示例展示了YOLOV1在TensorFlow中的实现,帮助理解模型工作流程。
摘要由CSDN通过智能技术生成

YOLO系列—YOLO V1原理及代码(一)

yolo系列是第一个单阶段的目标检测网络,与RCNN系列算法(二阶段,即两个网络分别对图片的分类问题,定位问题进行预测)具有本质的区别。同时,由于其单网络的特性,所以训练与预测速度会加快很多,可以实现实时的目标检测任务~
代码链接:https://github.com/hizhangp/yolo_tensorflow
参考:
https://blog.csdn.net/weixin_42278173/article/details/81778217
https://blog.csdn.net/c20081052/article/details/80236015
https://blog.csdn.net/weixin_40092412/article/details/90731258
https://zhuanlan.zhihu.com/p/89143061

原理

在这里插入图片描述
YOLO的核心思想:利用整张图作为网络的输入,直接在输出层回归bounding box的位置和bounding box所属的类别。(faster rcnn虽然也是将整张图片进行卷积,但是faster-RCNN整体还是采用了RCNN那种 proposal+classifier的思想,只不过是将提取proposal的步骤放在CNN中实现了)

训练

1.将原始输入图片进行resize到(448,448,3),并将其进行归一化操作

    def image_read(self, imname, flipped=False):
        image = cv2.imread(imname)
        image = cv2.resize(image, (self.image_size, self.image_size))
        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB).astype(np.float32)
        image = (image / 255.0) * 2.0 - 1.0
        if flipped:
            image = image[:, ::-1, :]
        return image

2.标签的准备

在这里插入图片描述
这一步很重要!因为必须要按照指定的标签结构才能训练模型:
在这里插入图片描述

在这里插入图片描述
参考:https://blog.csdn.net/weixin_40092412/article/details/90731258
下面,就上述讲述,进行计算:

  1. 原始输入图片hw大小为(530,960)
  2. 该训练图片中有两个gt bbox,分别为:(647,67,760,171),(177,15,485,380),格式为xyxy。
  3. 原始标签为:[1,647,67,760,171,0,0,0,1,0,0,0,0,0,0],[1,177,15,485,380,0,0,0,0,0,1,0,0,0,0]
  4. w_ratio = 448/960=0.467, h_ratio = 448/530=0.845
  5. 对于一个gt bbox而言:(647,760)* 0.467 = (298.88,354.92) ;(67,171)*0.845 = (56.62,144.50),合并后:(298.88,56.62,354.92,144.50)
  6. x_center = (298.88 + 354.92)/ 2 = 326.9,y_center = (56.62 + 144.50)/ 2 = 100.56,w = 354.92 - 298.88 = 56.04,h = 144.50 - 56.62 = 87.88
  7. 计算目标落在哪个cell中:假设这里S = 7,i = ceil(326.9 * 7 / 448)= 5,j = ceil(100.56* 7 / 448)= 1,所以第一个gt bbox的中心点在(5,1)这个grid cell中。
  8. 最终这张gt图片第一个gt bbox的标签转化为[1,326.9,100.56,56.04,87.88,0,0,0,1,0,0,0,0,0,0],同理可得第二个bbox的标签。
    def load_pascal_annotation(self, index):
        """
        Load image and bounding boxes info from XML file in the PASCAL VOC
        format.
        """

        imname = os.path.join(self.data_path, 'JPEGImages', index + '.jpg')
        im = cv2.imread(imname)
        h_ratio = 1.0 * self.image_size / im.shape[0]
        w_ratio = 1.0 * self.image_size / im.shape[1]
        # im = cv2.resize(im, [self.image_size, self.image_size])

        label = np.zeros((self.cell_size, self.cell_size, 25))
        filename = os.path.join(self.data_path, 'Annotations', index + '.xml')
        tree = ET.parse(filename)
        objs = tree.findall('object')

        for obj in objs:
            bbox = obj.find('bndbox')
            # Make pixel indexes 0-based
            x1 = max(min((float(bbox.find('xmin').text) - 1) * w_ratio, self.image_size - 1), 0)
            y1 = max(min((float(bbox.find('ymin').text) - 1) * h_ratio, self.image_size - 1), 0)
            x2 = max(min((float(bbox.find('xmax').text) - 1) * w_ratio, self.image_size - 1), 0)
            y2 = max(min((float(bbox.find('ymax').text) - 1) * h_ratio, self.image_size - 1), 0)
            cls_ind = self.class_to_ind[obj.find('name').text.lower().strip()]
            boxes = [(x2 + x1) / 2.0, (y2 + y1) / 2.0, x2 - x1, y2 - y1]
            #判断gt bbox落在哪个grid cell中
            x_ind = int(boxes[0] * self.cell_size / self.image_size)
            y_ind = int(boxes[1] * self.cell_size / self.image_size)
            if label[y_ind, x_ind, 0] == 1: #设置一个标记,看其是否被访问过,同时也表明这是个object
                continue
            label[y_ind, x_ind, 0] = 1 #设置标记,1是已经被访问过了,同时也表明这是个object
            label[y_ind, x_ind, 1:5] = boxes #设置标记的框,框的形式为 ( x_center, y_center, width, height)
            label[y_ind, x_ind, 5 + cls_ind] = 1 #标记类别,pascal_voc数据集一共有20个类,哪个类是哪个,则在响应的位置上的index是1


        return label, len(objs)

3.搭建网络

Yolo采用卷积网络来提取特征,然后使用全连接层来得到预测值。网络结构参考GooLeNet模型。YOLO检测层包含24个卷积层和2个全连接层。
在这里插入图片描述
上图是YOLO V1的网络结构,可以看到其实它只使用了最高层的feature map,所以来说,它没有利用底层的feature map,网络很大程度上丢失了信息,从而预测效果低于使用resnet等特征融合网络结构。

    def build_network(self,
                      images,
                      num_outputs,
                      alpha,
                      keep_prob=0.5,
                      is_training=True,
                      scope='yolo'):
        with tf.variable_scope(scope):
            with slim.arg_scope(
                [slim.conv2d, slim.fully_connected],
                activation_fn=leaky_relu(alpha),
                weights_regularizer=slim.l2_regularizer(0.0005),
                weights_initializer=tf.truncated_normal_initializer(0.0, 0.01)
            ):
                net = tf.pad(
                    images, np.array([[0, 0], [3, 3], [3, 3], [0, 0]]),
                    name='pad_1')
                net = slim.conv2d(
                    net, 64, 7, 2, padding='VALID', scope='conv_2')
                net = slim.max_pool2d(net, 2, padding='SAME', scope='pool_3')
                net = slim.conv2d(net, 192, 3, scope='conv_4')
                net = slim.max_pool2d(net, 2, padding='SAME', scope='pool_5')
                net = slim.conv2d(net, 128, 1, scope='conv_6')
                net = slim.conv2d(net, 256, 3, scope='conv_7')
                net = slim.conv2d(net, 256, 1, scope='conv_8')
                net = slim.conv2d(net, 512, 3, scope='conv_9')
                net = slim.max_pool2d(net, 2, padding='SAME', scope='pool_10')
                net = slim.conv2d(net, 256, 1, scope='conv_11')
                net = slim.conv2d(net, 512, 3, scope='conv_12')
                net = slim.conv2d(net, 256, 1, scope='conv_13')
                net = slim.conv2d(net, 512, 3, scope='conv_14')
                net = slim.conv2d(net, 256, 1, scope='conv_15')
                net = slim.conv2d(net, 512, 3, scope='conv_16')
                net = slim.conv2d(net, 256, 1, scope='conv_17')
                net = slim.conv2d(net, 512, 3, scope='conv_18')
                net = slim.conv2d(net, 512, 1, scope='conv_19')
                net = slim.conv2d(net, 1024, 3, scope='conv_20')
                net = slim.max_pool2d(net, 2, padding='SAME', scope='pool_21')
                net = slim.conv2d(net, 512, 1, scope='conv_22')
                net = slim.conv2d(net, 1024, 3, scope='conv_23')
                net = slim.conv2d(net, 512, 1, scope='conv_24')
                net = slim.conv2d(net, 1024, 3, scope='conv_25')
                net = slim.conv2d(net, 1024, 3, scope='conv_26')
                net = tf.pad(
                    net, np.array([[0, 0], [1, 1], [1, 1], [0, 0]]),
                    name='pad_27')
                net = slim.conv2d(
                    net, 1024, 3, 2, padding='VALID', scope='conv_28')
                net = slim.conv2d(net, 1024, 3, scope='conv_29')
                net = slim.conv2d(net, 1024, 3, scope='conv_30')
                net = tf.transpose(net, [0, 3, 1, 2], name='trans_31')
                net = slim.flatten(net, scope='flat_32')
                net = slim.fully_connected(net, 512, scope='fc_33')
                net = slim.fully_connected(net, 4096, scope='fc_34')
                net = slim.dropout(
                    net, keep_prob=keep_prob, is_training=is_training,
                    scope='dropout_35')
                net = slim.fully_connected(
                    net, num_outputs, activation_fn=None, scope='fc_36')
        return net

tf.pad用法:

import tensorflow as tf

a = tf.constant(value=[[1,2,3],[2,3,4]],
                dtype=tf.float32,
                shape=None,
                name="Const",
                verify_shape=False)

sess = tf.InteractiveSession()

re = tf.pad(tensor=a, #需要填充的tensor张量
             paddings=[[1,1],[2,2]], #上下左右需要填充的大小,格式为[[上,下],[左,右]]
             mode="CONSTANT", #填充方式
             name=None, #节点名称
             constant_values=0 #常量填充数值)
print(sess.run(re))

结果:
[[0. 0. 0. 0. 0. 0. 0.]
 [0. 0. 1. 2. 3. 0. 0.]
 [0. 0. 2. 3. 4. 0. 0.]
 [0. 0. 0. 0. 0. 0. 0.]]

最后经过Goolge net得到的feature map大小为(1470)

4.损失函数

损失函数需要两个输入,一个是predict,就是网络前向的结果(和图片分类的logit是类似的),另一个是label,就是从原图中获取的相关信息。
在这里插入图片描述

参考:https://zhuanlan.zhihu.com/p/89143061

我们可以看出,总共有5个loss,每个loss参与计算损失的参数都不一样。我们需要提取predict和labels中对应的参数来计算每一个loss。所以首先应该对predict和labels进行处理,使他们中的求解loss的参数能够对应起来。以下代码就是做了一件这样的事:
输入参数:
(1)predicts:Tensor(“yolo/fc_36/BiasAdd:0”, shape=(?, 1470), dtype=float32)
(2)labels:Tensor(“Placeholder:0”, shape=(?, 7, 7, 25), dtype=float32)
疑问:为什么这里7725 != 1470??(后面会有解释的)

with tf.variable_scope(scope):
    predict_classes = tf.reshape(  #reshape一下,每个cell一个框,变成[batch_size, 7, 7, 20]
        predicts[:, :self.boundary1],
        [self.batch_size, self.cell_size, self.cell_size, self.num_class])
    predict_scales = tf.reshape( #reshape一下,7*7*20 ~ 7*7*22, 就是分别找到每个cell的两个框的置信度,这里是两个框,可自定义,变成[batch_size, 7, 7, 2]
        predicts[:, self.boundary1:self.boundary2],
        [self.batch_size, self.cell_size, self.cell_size, self.boxes_per_cell])
    predict_boxes = tf.reshape( #reshape,就是分别找到每个cell中两个框的坐标(x_center, y_center, w, h),这里是两个框,可自定义, 变成[batch_size, 7, 7, 2, 4]
        predicts[:, self.boundary2:],  #7 * 7 * 22 ~ 7 * 7 * 30,
        [self.batch_size, self.cell_size, self.cell_size, self.boxes_per_cell, 4])

    #下面是对label部分进行reshape
    response = tf.reshape(
        labels[..., 0],
        [self.batch_size, self.cell_size, self.cell_size, 1])    #reshape, 就是查看哪个cell负责标记object,是的话就为1 ,否则是0 ,维度形式:[batch_size, 7, 7, 1]
    boxes = tf.reshape(
        labels[..., 1:5],
        [self.batch_size, self.cell_size, self.cell_size, 1, 4])  #找到这个cell负责的框的位置,其形式为:(x_center,y_center,width,height), 其维度为:[batch_size, 7, 7, 1, 4]
    boxes = tf.tile(
        boxes, [1, 1, 1, self.boxes_per_cell, 1]) / self.image_size   # tile() 平铺之意,用于在同一维度上的复制, 变成[batch_size, 7, 7, 2, 4], 除以image_size就是得到相对于整张图片的比例
    classes = labels[..., 5:]          #找到这个cell负责的框所框出的类别,有20个类别, 变成[batch_size, 7, 7, 20],正确的类别对应的位置为1,其它为0

这段代码提取了predict中:

(1)7 * 7个grid cell对应的坐标信息(7 * 7 * 2 * 4)= 392,

(2)类别信息(7 * 7 * 20)= 980,

(3)框的置信度信息(7 * 7 * 2)= 98,
相加之后:392 + 980 + 98 = 1470,正好等于最后一层的feature map的维度大小


提取了labels中:

(1) response信息(7 * 7 * 1)

(2) boxes信息(7 * 7 * 4—>7 * 7 * 4 * 2,这里为了和predict中的坐标信息对应,使用了tile函数)

(3) 分类信息(7 * 7 * 20)

注意:

boxes = tf.tile(boxes, [1, 1, 1, self.boxes_per_cell, 1]) / self.image_size

这一行代码是将第4维的数据进行复制一份,这样的话,正好就解决了上述疑问:为什么这里7725 != 1470?这一行代码的目的就是将label中的box格式转换为与predict中box对应的格式。

Gt_box除以图片大小,意思就是将gt_box做了归一化,框的信息限制在了(0,1)中。
我们对predict和labels做了处理后,现在我们思考下,既然我们已经对了labels中的坐标信息做了归一化,那么predict输出框信息应当也需要做归一化,这样在相同的空间中,我们才能求两者之间的IOU了。

offset = tf.reshape(
                tf.constant(self.offset, dtype=tf.float32),
                [1, self.cell_size, self.cell_size, self.boxes_per_cell]) #(1,7,7,2)
            offset = tf.tile(offset, [self.batch_size, 1, 1, 1])
            offset_tran = tf.transpose(offset, (0, 2, 1, 3)) #(1,7,7,2)

这一段代码应该是整个项目代码中最复杂的地方了,很绕,但是好在看到了大佬对这部分的讲解,顿时茅厕顿开啊!,此处必须给链接:

https://zhuanlan.zhihu.com/p/87870736

首先,先搞清楚offset是什么?

np.transpose(np.reshape(np.array(
            [np.arange(self.cell_size)] * self.cell_size * self.boxes_per_cell),
            (self.boxes_per_cell, self.cell_size, self.cell_size)), (1, 2, 0))

最后得到的offset是:

#offset
# array([[[0, 0],
# [1, 1],
# [2, 2],
# [3, 3],
# [4, 4],
#  [5, 5],
# [6, 6]],
#
# [[0, 0],
# [1, 1],
# [2, 2],
# [3, 3],
# [4, 4],
# [5, 5],
# [6, 6]],
#
# [[0, 0],
# [1, 1],
# [2, 2],
# [3, 3],
# [4, 4],
#  [5, 5],
# [6, 6]],
#
# [[0, 0],
# [1, 1],
# [2, 2],
# [3, 3],
# [4, 4],
# [5, 5],
# [6, 6]],
#
# [[0, 0],
# [1, 1],
# [2, 2],
# [3, 3],
# [4, 4],
#  [5, 5],
# [6, 6]],
#
# [[0, 0],
# [1, 1],
# [2, 2],
# [3, 3],
# [4, 4],
# [5, 5],
# [6, 6]],
#
# [[0, 0],
# [1, 1],
# [2, 2],
# [3, 3],
# [4, 4],
#  [5, 5],
# [6, 6]]])

它的shape为(7,7,2),那么经过reshape,tile,transpose,最终得到的offset_tran的输出就是:

#offset_tran如下,只不过batch_size=1
# [[[[0. 0.]
# [0. 0.]
# [0. 0.]
# [0. 0.]
# [0. 0.]
# [0. 0.]
# [0. 0.]]
#
# [[1. 1.]
# [1. 1.]
# [1. 1.]
# [1. 1.]
# [1. 1.]
# [1. 1.]
# [1. 1.]]
#
# [[2. 2.]
# [2. 2.]
# [2. 2.]
# [2. 2.]
# [2. 2.]
# [2. 2.]
# [2. 2.]]
#
# [[3. 3.]
# [3. 3.]
# [3. 3.]
# [3. 3.]
# [3. 3.]
# [3. 3.]
# [3. 3.]]
#
# [[4. 4.]
# [4. 4.]
# [4. 4.]
# [4. 4.]
# [4. 4.]
# [4. 4.]
# [4. 4.]]
#
# [[5. 5.]
# [5. 5.]
# [5. 5.]
# [5. 5.]
# [5. 5.]
# [5. 5.]
# [5. 5.]]
#
# [[6. 6.]
# [6. 6.]
# [6. 6.]
# [6. 6.]
# [6. 6.]
# [6. 6.]
# [6. 6.]]]]
#

它的shape为[batch_size, 7, 7, 2]。

predict_boxes_tran = tf.stack(
                [(predict_boxes[..., 0] + offset) / self.cell_size,
                 (predict_boxes[..., 1] + offset_tran) / self.cell_size,
                 tf.square(predict_boxes[..., 2]),
                 tf.square(predict_boxes[..., 3])], axis=-1)

解说这段代码之前,必须要补充点知识,就是关于predict_boxes的输出,我们知道predict_boxes的输出是网络前向传播后预测的候选框。固定思维让我们认为,predict_boxes的值就是类似gt_box坐标那样的(x,y,d,h)坐标。错!保持这个固有的思维,这段代码就无法看懂了,我也是不断推测的,才知道实际上道predict_boxes各个坐标的含义。
在这里插入图片描述
其实predict_boxes中的前两位,就是中心点坐标(x,y)代表的含义如上图,是predict_boxes中心坐标离所属格子(response)左上角的坐标。而predict_boxes中的后两位,其实并不是predict_boxes的宽度高度,而是predict_boxes的宽度高度相对于图片的大小(归一化后)的开方。
那么我们所说的输入predict中包含的坐标信息,就不是

(中心横坐标,

中心纵坐标,

宽,

高)

而是

(中心横坐标离所属方格左上角坐标的横向距离(假设每个方格宽度为1),

中心纵坐标离所属方格左上角坐标的纵向距离(假设每个方格高度为1),

宽度(归一化)的开方,

高度(归一化)的开方)

这里理解了,后面理解起来就很easy了。
其中,

(predict_boxes[..., 0] + offset) / self.cell_size, 
(predict_boxes[..., 1] + offset_tran) / self.cell_size,

就是将predict_boxes的中心坐标转换为相对于整张图来说的(x,y)中心坐标。还是用上面的图来说明:
在这里插入图片描述
我们标出了response格子对应的offset x和offset y,那么结合上面所说,

(predict_boxes[…, 0] + offset) / self.cell_size,
(predict_boxes[…, 1] + offset_tran) / self.cell_size,

就是先将上面图中的x和y变成(x+offset x,y+offset y),然后除以cell_size=7,相当于对中心坐标进行了归一化,

tf.square(predict_boxes[..., 2]), 
tf.square(predict_boxes[..., 3])],

就是将原来的宽度(归一化)的开方和高度(归一化)的开方恢复成:(宽度(归一化),高度(归一化)),那么predict_bbox中的坐标信息,全部通过这段代码,恢复成了和labels中坐标相同格式的了,难道不是很妙嘛?

最后,得到的predict_boxes_tran 的shape为:
(batch_size,7,7,2,4),其中最后一维的4已经经过了上述的转换之后得到:(相对于最后一层7 * 7的feature map的左上角坐标的中心点位置xy(已经进行归一化操作),相对于最后一层7 * 7的feature map的长宽的预测框的长宽(已经进行归一化操作))。

iou_predict_truth = self.calc_iou(predict_boxes_tran, boxes)   #计算IOU,  其格式为: [batch_size, 7, 7, 2]

# calculate I tensor [BATCH_SIZE, CELL_SIZE, CELL_SIZE, BOXES_PER_CELL],获取obj(ij),第i个格子第j个bbox有obj
object_mask = tf.reduce_max(iou_predict_truth, 3, keep_dims=True)  # Computes the maximum of elements across dimensions of a tensor, 在第四个维度上,维度从0开始算

iou_predict_truth的定义是比较简单的,就是求预测框和实际框的IOU。

输出的结果shape为[batch_size, 7, 7, 2],就是求每个对应位置格子中的对应框的IOU。

接着object_mask对上述的[batch_size, 7, 7, 2]个IOU按照axis=3取最大值,就是在7*7个格子中的2个对象中,找到与实际框IOU最大的那一个,注意这里是去取值,而不是取索引。

object_mask = tf.cast((iou_predict_truth >= object_mask), tf.float32) * response #其维度为[batch_size, 7, 7, 2] , 如果cell中真实有目标,那么该cell内iou最大的那个框的相应位置为1(就是负责预测该框),
# 其余为0,使用response是因为可能会遇到object_mask中最大值为0的情况

这段代码的意思就是找到7*7格子中满足两个以下条件的对象:

(1) 该对象属于的框是response框,负责检测物体

(2) 该对象是所属框中的,与实际物体IOU比例较大的那个

这样我们获得了object_mask,他的shape为[batch_size, 7, 7, 2],满足以上两个条件的框的位置为1,其余为0,说了这么多,这个就是我们公式中的
在这里插入图片描述
那么自然地,公式中的
在这里插入图片描述
是不满足上述两个条件的框的集合,定义如下:

# calculate no_I tensor [CELL_SIZE, CELL_SIZE, BOXES_PER_CELL]
noobject_mask = tf.ones_like(object_mask, dtype=tf.float32) - object_mask #其维度为[batch_size, 7 , 7, 2], 真实没有目标的区域都为1,真实有目标的区域为0

这里有5个LOSS,我们将第一个LOSS(边框中心误差)和第二个LOSS(边框的宽度和高度误差)整合为一个LOSS,程序如下:

# 框坐标的损失,只计算有目标的cell中iou最大的那个框的损失,即用这个iou最大的框来负责预测这个框,其它不管,乘以0
coord_mask = tf.expand_dims(object_mask, 4)  # object_mask其维度为:[batch_size, 7, 7, 2], 扩展维度之后变成[batch_size, 7, 7, 2, 1]
boxes_delta = coord_mask * (predict_boxes - boxes_tran)     #predict_boxes维度为: [batch_size, 7, 7, 2, 4],这些框的坐标都是偏移值
coord_loss = tf.reduce_mean(  #平方差损失函数
    tf.reduce_sum(tf.square(boxes_delta), axis=[1, 2, 3, 4]),
    name='coord_loss') * self.coord_scale

上面LOSS图的第三个LOSS,是置信度损失(框内有对象),代码是:

object_delta = object_mask * (predict_scales - iou_predict_truth)  #用iou_predict_truth替代真实的置信度,真的妙,佩服的5体投递,
#仔细分析一下他的精妙之处,他的精妙之处就在于让他教网络去学习如何计算predict score
object_loss = tf.reduce_mean(  #平方差损失函数
    tf.reduce_sum(tf.square(object_delta), axis=[1, 2, 3]),
    name='object_loss') * self.object_scale

第三个LOSS:置信度损失(框内无对象),代码是:

# 没有目标的时候,置信度的损失函数,这里的predict_scales是(predict_scales-0)
noobject_delta = noobject_mask * predict_scales
noobject_loss = tf.reduce_mean(       #平方差损失函数
    tf.reduce_sum(tf.square(noobject_delta), axis=[1, 2, 3]),
    name='noobject_loss') * self.noobject_scale

最后一个损失是分类损失,代码如下:

# class_loss, 计算类别的损失
class_delta = response * (predict_classes - classes)
class_loss = tf.reduce_mean(   #平方差损失函数
    tf.reduce_sum(tf.square(class_delta), axis=[1, 2, 3]),
    name='class_loss') * self.class_scale   # self.class_scale为损失函数前面的系数

最后再加一句,最后loss部分基本上都是参考周威大佬的blog,写的确实好!再次膜拜!!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

进我的收藏吃灰吧~~

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

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

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

打赏作者

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

抵扣说明:

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

余额充值