黑窗口检测wamp的命令_OpenCV AdaBoost + Haar目标检测技术内幕(上)

cd4a6ae79b916fa99bd28880e0acf381.png

很多使用过OpenCV的小伙伴都见过如下代码。这段看似简单的代码,通过读入一个神奇的XML文件,能够找到图像中所有人脸,是不是非常神奇?这其实就是一个Adaboost级联分类器的经典实现;但是当你希望深入代码学习原理时,估计会被代码复杂度吓到。

#include 

代码结果图0-1这样:

04ab081b470d87fb74b39cf545de25cf.png
图0-1

希望这篇文章能帮助你了解背后的所有原理,不再迷茫。文章很长,做好心理准备。

特别声明一下,本文成文于2015年,讲解的是OpenCV2.4.11的实现方式。由于OpenCV版本更新,不排除有一些细节变化,请读者注意自行区分。

尽信书不如无书!

1 Haar特征

在OpenCV接口中,实现了Haar/LBP/HOG等多种特征,本文以Haar特征为例介绍。

1.1 Haar特征的生成

Haar特征最先由Paul Viola等人提出,后经过Rainer Lienhart等扩展引入45°倾斜特征,成为现在OpenCV所使用的的样子。图1-1展示了目前OpenCV(2.4.11版本)所使用的共计14种Haar特征,包括5种Basic特征、3种Core特征和6种Titled(即45°旋转)特征。

4b92d4ab8efdef61865017b24bfb02f0.png
图1-1 OpenCV中使用的的Haar特征

在实际中,Haar特征可以在检测窗口中由放大+平移产生一系列子特征,但是白:黑区域面积比始终保持不变。

如图1-2,以x3特征为例,在放大+平移过程中白:黑:白面积比始终是1:1:1。首先在红框所示的检测窗口中生成大小为3个像素的最小x3特征;之后分别沿着x和y平移产生了在检测窗口中不同位置的大量最小3像素x3特征;然后把最小x3特征分别沿着x和y放大,再平移,又产生了一系列大一点x3特征;然后继续放大+平移,重复此过程,直到放大后的x3和检测窗口一样大。这样x3就产生了完整的x3系列特征。

057dd1245436e306cbda70570713a85f.png
图1-2 x3特征平移+放大产生一系列子特征示意图

那么这些通过放大+平移的获得的子特征到底总共有多少个?Rainer Lienhart在他的论文中给出了完美的解释:假设检测窗口大小为W*H,矩形特征大小为w*h,X和Y为表示矩形特征在水平和垂直方向的能放大的最大比例系数:

80e79def97929f313430fa7ffc3df1e2.png

629a6b38bc4b6245883f9e3decec6e4a.png
图1-3 特征数量计算示意图

则如图1-3,在检测窗口Window中,一般矩形特征(upright rectangle)的数量为:

00f7706e615eb7e9d4d412fd5e526601.png

简单解释一下,上述公式可以理解为:

  1. 特征框竖直放大1倍,即无放大,竖直方向有(H-h+1)个特征
  2. 特征框竖直放大2倍,竖直方向有(H-2h+1)个特征
  3. 特征框竖直放大3倍,竖直方向有(H-3h+1)个特征
  4. 如此到竖直放大Y=floor(H/h)倍,竖直方向有1个特征,即(H-Y*h+1)

那么竖直方向总共有

个特征。考虑到水平和竖直方向缩放是独立的,所以能得到上述公式。对应于之前的x3特征,当x3特征在24*24大小的检测窗口中时(此时W=H=24,w=3,h=1,X=8,Y=24),一共能产生27600个子特征,除x3外其他一般矩形特征数量计算方法类似。

1.2 如何计算Haar特征值

看到这里,该明白了大量的Haar特征是如何产生的。当有了大量的Haar特征用于训练和检测时,接下来的问题是如何计算Haar特征值。按照OpenCV代码,Haar特征值=整个Haar区域内像素和×权重 + 黑色区域内像素和×权重:

6689e24473fe7b30d05d8732da4f7d71.png
  1. 对于图2中的x3和y3特征,
    = 1,
    = -3;
  2. 对于point特征,
    = 1,
    = -9;
  3. 其余11种特征均为
    =1,
    = -2。

这也就是其他文章中提到的所谓“白色区域像素和减去黑色区域像素和”,只不过是加权相加而已。例如:

  • 对于x2特征:(黑 + 白) * 1+黑 * (-2) = 白 - 黑;
  • 对于Point特征:(黑 + 白) * 1 + 黑 * (-9) = 白 - 8 * 黑。

为什么要设置这种加权相减,而不是直接相减?请仔细观察图2中的特征,不难发现x3、y3、point特征黑白面积不相等,而其他特征黑白面积相等。设置权值就是为了抵消面积不等带来的影响,保证所有Haar特征的特征值在灰度分布绝对均匀的图中为0。

了解了特征值如何计算之后,再来看看不同的特征值的含义是什么。我选取了MIT人脸库中2706个大小为20*20的人脸正样本图像,计算如图1-4位置的Haar特征值,结果如图1-5。

a2ca6d2df1a484d967e56a548b35d997.png
图1-4 Haar特征位置示意图

67adc8bbd0ca917fe5b8ac408d7afa57.png
图1-5 图4的2个Haar特征在MIT人脸样本中特征值分布图(左边特征结果为红色,右边蓝色)

可以看到,图1-4中2个不同Haar特征在同一组样本中具有不同的特征值分布,左边特征计算出的特征值基本都大于0,而右边特征的特征值基本均匀分布于0两侧(分布越均匀对样本的区分度越小)。所以,正是由于样本中Haar特征值分布不同,导致了不同Haar特征分类效果不同。显而易见,对样本区分度越大的特征分类效果越好,即红色曲线对应图1-4中的的左边Haar特征分类效果好于右边Haar特征。那么看到这里,应该理解了下面2个问题:

  1. 在检测窗口通过平移+放大可以产生一系列Haar特征,这些特征由于位置和大小不同,分类效果也各异;
  2. 通过计算Haar特征的特征值,可以有将图像矩阵映射为1维特征值,有效实现了降维

1.3 Haar特征如何保存?

对应的,在OpenCV XML文件中,每一个Haar特征都被保存在2~3个形如:

<x y width height weight>

的标签中,其中x和y代表Haar矩形左上角坐标(以检测窗口左上角为原点),width和height代表矩形的宽和高,而weight则对应了上面说的权重值,例如图4中的左边x2类型的Haar特征应该为<4 2 12 8 1.0>(整个Haar,权重1)和<4 2 12 4 -2.0>(黑色区域,权重-2)。

1.4 Haar特征值标准化

从上文图1-5中发现,仅仅一个12*18大小的Haar特征计算出的特征值变化范围从-2000~+6000,跨度非常大。这种跨度大的特性不利于量化评定特征值,所以需要进行“标准化”,压缩特征值范围。假设当前检测窗口中的图像为i(x,y),当前检测窗口为w*h大小(例如图6中为20*20大小),OpenCV采用如下方式“标准化”:

  • 计算检测窗口中间部分(w-2)*(h-2)的图像的灰度值和灰度值平方和:

a0740340604f1a70796648dc1799b90c.png
  • 计算平均值:

090caf940bb833ffe5bf6ba0c14d3f61.png
  • 计算标准化因子:

17372b6d248fdb22e95913545d4cee5e.png
  • 标准化特征值:

0998cc1e0907488bf163f991bcf78729.png

具体代码在cascadedetect.cpp中的HaarEvaluator::setImage()函数中可以看到,关键部分如下:

normrect 

与代码对应的,如图中蓝色为检测窗口,红色为标准化过程中使用到的像素。

34310151e194114be481b5dd086c82aa.png
图1-6

其实如何标准化并不重要,重要的是检测和训练时的方法一定要一致,否则可能会由于标准化不同带来的误差导致模型无法工作!

1.5 积分图

以OpenCV自带的人脸分类器haarcascade_frontalface_alt2.xml为例,其中存储了超过1000个大小和位置都不相同的Haar特征(XML文件解释见下节)。在运算中,伴随着检测窗口的移动,如何快速计算Haar特征值就成了一个很重要的问题。在设计Haar+AdaBoost算法时,Paul Viola等人就提出积分图。

对于灰度图像中任意一点image(x,y),OpenCV定义其积分图为sum(x,y)为:

001f2ebf758f38747197f545bb4e7c04.png

其中第0行和第0列为0:

04f87de60030c263792597b5f74aabc0.png

其中image(x,y)为点(x,y)处的原始灰度图。这样就定义了一张类似于数学中“积分”的积分图。如图1-7,如果要计算D区域内灰度和,只需计算

e34811bd881f8feec4b54787404258d6.png

其中(x1,y1)、(x2,y2)、(x3,y3)和(x4,y4)依次代表图1中image的1 2 3 4点的图像坐标。需要说明,在计算D区域灰度和时sum(x1,y1)深蓝色区域被减去了2次,最后需要补上。显然可以通过此方法快速计算图像中任意位置和大小区域的灰度和,即通过积分图只需要做有限次操作就能获得任意位置的Haar特征值

c02015391045038087262b2b2c70b2f7.png
图1-7 积分图计算Haar矩形框示意图

1.6 旋转积分图

为了提高检测精度,Rainer Lienhart等人首先提出了45°旋转积分图,如图1-8。旋转积分图用于快速计算图1中的titled_x2和titled_y2等共6种旋转Haar特征。

c038da9af9fbcf7da25eb0df39b30a2b.png
图1-8 45°旋转积分图

与一般积分图类似,OpenCV中45°旋转积分图同样采用了“扩边”方式(即旋转积分图比原灰度图多1行和1列,其中第1行和第1列元素为0),对应的计算公式为:

9f3de2c136c69640f43ba38378f4a68a.png

其中第一行第一列为0:

e446f613a50643a021f7458453ac8054.png

有了旋转积分图如何计算任意位置和大小的45°倾斜长方形区域的灰度和呢?

设有如图9红色方框大小的灰度图image,其计算出来的45°旋转灰度图为titled(第0行和第0列为0),虚线代表image中cv::Rect为<3 1 2 3>区域。显然虚线区域的灰度和为:

a3ed80b9be6c7c85ee6d60d7ab11ba9a.png

应该不难理解。

b2d0d17eb4315c51a7164c6bb1c32a4d.png
图1-9

在实际中,如果使用旋转特征,则需要多计算一张积分图。但是旋转特征的效果往往不理想,得不偿失,不建议使用。

PS:45°旋转旋转积分图还有另外一种实现方式,但是OpenCV由于数据存储方式限定,不那样做。

c085269ea0f4b2a1a9e351491f8e6b48.png
图1-10

2 级联分类器结构

了解Haar特征之后,接下来分析级联分类器结构,主要包括以下2个内容:

  1. OpenCV中的Adaboost级联分类器的结构,包括强分类器和弱分类器的形式;
  2. OpenCV自带的XML分类器中各项参数的含义,如internalNodes和leafValues标签里面的一大堆数字的意义。

OpenCV中的Adaboost级联分类是树状结构,如图2-1,其中每一个stage都代表一级强分类器。当检测窗口通过所有的强分类器时才被认为是目标,否则拒绝。实际上,不仅强分类器是树状结构,强分类器中的每一个弱分类器也是树状结构。

ccc7d0b7a56263b1da61370b6c80751a.png
图2-1 强分类器和弱分类器示意图(此图有误,应是stage1,stage2,...,stageN)

这篇文章将结合OpenCV-2.4.11中自带的haarcascade_frontalface_alt2.xml文件介绍整个级联分类器的结构。需要说明,自从2.4.11版本后所有分类器都被替换成新式XML,所以本文介绍新式XML结构。

2.1 XML的头部

在了解OpenCV分类器结构之前,先来看看存储分类器的XML文件中有什么。图2中注释了分类器XML文件头部信息,括号中的参数为opencv_traincascade.exe训练程序对应参数。

a468d53221b101ec2ea2255170804497.png
图2-2 分类器XML文件头部含义

其中<features>标签存储了所有的Haar特性,在之前1.3节有讲解。

2.2 弱分类器结构

Haar特征和弱分类器之间的关系很简单:

一个完整的弱分类器包括:

  1. 若干个Haar特征 + 和Haar特征数量相等的弱分类器阈值
  2. 若干个leftValue
  3. 若干个rightValue

这些元素共同构成了弱分类器,缺一不可。haarcascade_frontalface_alt2.xml的弱分类器Depth=2,包含了2种形式,如图2-3:

  • 左边形式包含2个Haar特征、1个leftValue、2个rightValue和2个弱分类器阈(t1和t2)
  • 右边形式包括2个Haar特征、2个leftValue、1个rightValue和2个弱分类器阈值

6167f96b92ae14eb63e27ba6ed7cc6d8.png
图2-3 Depth=2的树状弱分类器示意图

看图2-3应该明白了弱分类器的大致结构,接下来我们了解树状弱分类器是如何工作的。还是以图3左边的形式为例:

  1. 计算第一个Haar特征的特征值haar1,与第一个弱分类器阈值t1对比,当haar1<t1时,进入步骤2;当haar1>t1时,该弱分类器输出rightValue2并结束。
  2. 计算第二个Haar特征值haar2,与第二个弱分类器阈值t2对比,当haar2<t2时输出leftValue;当haar2>t2时输出rightValue1。

即通过上述步骤计算弱分类器输出值,这与OpenCV的cascadedetect.hpp文件中的predictOrdered()函数代码对应(这里简单解释一下,在OpenCV中所有弱分类器的leftValue和rightValue都依次存储在一个一维数组中,代码中的leafOfs表示当前弱分类器中leftValue和rightValue在该数组中存储位置的偏移量,idx表示在偏移量leafOfs基础上的leftValue和rightValue值的索引,cascadeLeaves[leafOfs - idx]就是该弱分类器的输出):

do

即弱分类器的工作方式:通过计算出的Haar特征值与弱分类器阈值对比,从而选择最终输出leftValue和rightValue值中的哪一个。

那么这些Haar特征、leftValue、rightValue和弱分类器阈值t都是如何存储在xml文件中的?不妨来看haarcascade_frontalface_alt2.xml文件中的第一级的第三个弱分类器,如图2-4。图2-4中的弱分类器恰好是图3中左边类型,包含了<internalNodes>和<leafValues>两个标签。其中<leafValues>标签中的3个浮点数由左向右依次是rightValue2、leftValue和rightValue1;而<internalNodes>中有6个整数和2个浮点数,其中2个浮点数依次分别是弱分类器阈值t1和t2,剩下的6个整数容我慢慢分解。

69f0ec1b153d502e01f34d51b1aca6cf.png

首先来看两个浮点数前的整数,即4和5。这两个整数用于标示所属本弱分类器Haar特征存储在<features>标签中的位置。比如数值4表示该弱分类器的haar1特征存储在xml文件下面<features>标签中第4个位置,即为:

e9a73ea43139c3b582139770782bddb9.png

而<internalNodes>的其他4个整数1、0和-1、-2则用于控制弱分类器树的形状。在运行时,OpenCV会把1赋值给当前的node.left,并把0赋值给node.right(请注意do-while代码中的条件,只有idx<=0时才停止循环,参考图3应该可以理解这4个整数的含义)。如此,OpenCV通过这些巧妙的数值和结构,控制了整个分类器的运行。可以看到,每个弱分类器内部都是类似于这种树状的“串联”结构,所以我称其为“串联组成的的弱分类器”。

1acf25e22ea35ddb06a13403148a67bd.png
图2-4 OpenCV弱分类器运行示意图

而Depth=1(如haarcascade_frontalface_alt.xml)类型的弱分类器,结构更加简单且运行方式对比可知,不在赘述。

45eea608401fc8fd5cea400bff215b48.png
图2-5 Depth=1的stump弱分类器示意图

2.3 强分类器结构

在OpenCV中,强分类器是由多个弱分类器“并列”构成,即强分类器中的弱分类器是两两相互独立的。在检测目标时,每个弱分类器独立运行并输出cascadeLeaves[leafOfs - idx]值,然后把当前强分类器中每一个弱分类器的输出值相加,即:

sum 

e73ff9827abfdc9293554d1bf3468c5e.png
图2-6 OpenCV强分类器运行示意图

之后与本级强分类器的stageThreshold阈值对比,当且仅当结果sum>stageThreshold时,认为当前检测窗口通过了该级强分类器。当前检测窗口通过所有强分类器时,才被认为是一个检测目标。可以看出,强分类器与弱分类器结构不同,是一种类似于“并联”的结构,我称其为“并联组成的强分类器”。

2.4 如何搜索目标?

还有一个问题:检测窗口大小固定(例如alt2是20*20像素)的级联分类器如何遍历图像,以便找到在图像中大小不同、位置不同的目标?

解决方法如下:为了找到图像中不同位置的目标,需要逐次移动检测窗口(窗口中的Haar特征相应也随着移动),这样就可以遍历到图像中的每一个位置;而为了检测到不同大小的目标,一般有两种做法:逐步缩小图像or逐步放大检测窗口:

  1. 缩小图像就是把图像按照一定比例逐步缩小然后滑动窗口检测,如图2-7;
  2. 放大检测窗口是把检测窗口长宽按照一定比例逐步放大,这时位于检测窗口内的Haar特征也会对应放大,然后检测。

2d45a3a3dd9a4b80d2029a0d743c67fb.png
图2-7 经典的Pyramid+Sliding-window检测(借用一张大佬的照片)

新版c++函数CascadeClassifier::detectMultiScale()只实现了缩小图像检测;旧版的c函数cvHaarDetectObject()同时实现了缩小图像和放大窗口两种检测方式,当函数参数flag为CV_HAAR_SCALE_IMAGE时是缩小图像检测,默认flag=0时放检测大窗口检测。

#define CV_HAAR_SCALE_IMAGE          2

3 对检测结果NMS

考虑这样的情况:一个被检测为目标的窗口,其附近窗口也应该被检测到。

图3-1展示检测一副含有人脸图像的结果,左边为合并检测结果窗口之前的结果,右边为合并之后的结果。所以有必要对重叠的检测结果窗口进行合并,同时剔除零散分布的错误检测窗口。该功能其实就是NMS(non-maximum suppression)。

3080ee1e59967d85aedebfab7f43c39a.png
图3-1 检测结果合并窗口前后对比图

3-1 并查集(Union-Set)

在了解如何合并窗口前,先来了解一种数据结构——并查集。为了形象的说明并查集,首先来了解一个例子。江湖上存在各种各样的大侠,他们没什么正当职业,整天背着剑四处游荡,碰到其他大侠就大打出手。俗话说“双拳难敌四手”,这些大侠都会拉帮结派壮大实力。那么为了辨识每个大侠属于哪个帮派,就需要每个帮派都推举一个“老大”。这些大侠只需要知道自己和其他大侠的老大是不是同一个人,就能明白自己和对方是不是一个帮派,从而决定是否动手过招。

fc8e9d682a0b3128771aa14607f6c2a0.png
图3-2 江湖大侠关系图(箭头表示上一级)

如图3-2,现在武当和明教分别推举张三丰和张无忌为老大。当帮派很大时,每个大侠记不住帮派所有人,那么他们只需要记住自己的上一级是谁,一级一级往上问就知道老大是谁了。某日,宋青书和殷梨亭在武当山门遇到,那么宋青书问宋远桥后得知自己的老大是张三丰,而殷梨亭的老大也是张三丰,那么他俩是同门。反之宋青书遇到陈友谅时,一级一级向上询问后发现老大不是一个人,就不是同门。

除此之外,在武林中还需要组建联盟扩大帮派势力。既然杨不悔嫁给了殷梨亭,不妨直接设张无忌的上级为张三丰,这样就可以将明教和武当组成一个更大的联盟(如图3-2红色虚线。需要说明,我们只关心数据的代表,而忽略数据内部结构)。从此以后当宋青书再和杨不悔相遇,一级一级查询后可以确定是同伙了。但是如果大侠们相遇都像这样一级一级往上问,查询路径很长。所以这种直接连接最上级的方法不是最优。

为了解决这个问题,需要压缩路径——每个人记住自己最终的老大就行(如宋青书记住自己老大是张三丰,不在去问宋远桥),基本思路如下:

  • 以武当为例,张三丰创建门派(明教也类似)
  • 宋远桥和殷梨亭加入武当派,上级设置为张三丰
  • 宋青书通过与宋远桥的关系加入武当派,压缩路径后设置上级为张三丰,同时也设置其所有原上级的上级为张三丰(由于原上级宋远桥的上级就是张三丰,没有变化)。

压缩完路径后的武当与明教状态图如下,其中红色代表压缩路径:

ef09192dd8bdafb802167e2a7662e568.png
图3-3
  • 杨不悔通过与殷梨亭的关系也加入武当派别,压缩路径后设置上级为张三丰,同时设置原上级张无忌的上级是张三丰。绿色代表此次压缩路径。

9883bc2038ae768ca0cc556a6d6e79f9.png
图3-4

以后每次在合并中关系到了谁,就压缩谁的路径,同时压缩谁的所有上级的路径。此后宋青书和杨不悔的查询路径就短了很多。

  • 假如某天范右使收徒了,徒弟也要加入联盟。在加入的时候,也需要压缩路径,设置徒弟的上级为张三丰;同时设置徒弟的原上级(范右使和张无忌)的上级为张三丰,如蓝色箭头。由于张无忌的上级就是张三丰,所以没有改变。这样,范右使的路径也得到压缩。

33762de2b145cdf3a12ad87ca5bcf43e.png
图3-5

看完例子之后,一起来看看并查集定义。并查集保持一组不相交的动态集合S={S1,S2,...,Sk},每个动态集合Si通过一个代表ai来识别,代表是集合中的某个元素(ai∈Si)。在某些应用中,哪一个元素被选为代表是无所谓的,我们只关心在不修改动态集合的前提下分别寻找某一集合的代表2次获得的结果相同;在另外一些应用中,如何选择集合的代表可能存在预先说明的规则,如选择集合的最大or最小值作为代表。总之,在并查集中,不改变动态集合S则每个集合Si的代表ai不变。

不妨设x表示每个结点,p[x]表示x的父结点(即上一级,如图2中p[宋远桥]==张三丰),rank[x]表示x节点的秩(即该节点最长路径中结点个数,如图2中最长路径为:张三丰-张无忌-杨左使-杨不悔,所以rank[张三丰]==4)。并查集伪代码如下:

//创建Union-set
MAKE-SET(x)
1 p[x] ← x //←号表示赋值
2 rank[x] ← 0

//合并x和y,底层压缩路径
UNION(x, y)
1 LINK(FIND-SET(x), FIND-SET(y))

LINK(x, y)
1 if rank[x] < rank[y]
2     p[x] ← y
3 else
4     p[y] ← x
5     if rank[x]==rank[y]
6         rank[x] = rank[x] + 1

FIND-SET(x)
1 if x ≠ p[x]
2     p[x] ← FIND-SET(p[x])
3 return p[x]

其中,MAKE-SET函数用于在无序数据中初始化并查集数据结构,将每个结点父结点设为其本身;UNION函数通过调用LINK和FIND-SET实现带压缩路径的并查集合并;LINK函数通过秩进行并查集合并;FIND-SET是带压缩路径的寻找结点代表的函数。如果还有不明白的地方,建议查阅《算法导论》中的第21章:《用于不相交的数据结构》。

3.2 利用并查集合并检测结果窗口

为了将并查集利用到合并窗口中,首先要定义窗口相似函数,即当前的两个窗口是不是“一伙人”。在OpenCV中,图像中的矩形窗口一般用Rect结构体表示,其包含x,y,width,height共4个成员变量,分别代表窗口的左上角点x坐标、y坐标、宽度和高度。下面代码定义了窗口相似函数SimilarRects::operator(),当2个窗口r1和r2位置很接近时返回TRUE,通过SimilarRects::operator()就可以将图1那些重叠的窗口合并在“一伙人”中。

class 

定义好窗口相似性函数后,就可以利用并查集合并窗口函数了,大致过程如下:

  1. 首先利用MAKE-SET函数建立Rect对象的并查集初始结构
  2. 然后遍历整个并查集,用SimilarRects::operator()判断每2个窗口相似性,若相似则将这2个窗口合并为“一伙人”;
  3. 运行完步骤2后应该出现几个相互间不相似的窗口“团伙”,当“团伙”中的窗口数量小于阈值minNeighbors时,丢弃该“团伙”(认为这是零散分布的误检);
  4. 之后剩下若干组由大量重叠窗口组成的大“团伙”,分别求每个“团伙”中的所有窗口位置的平均值作为最终检测结果。

这里只介绍NMS基本算法,代码请读者自行查阅OpenCV源码。不过在算法描述中为了清晰简洁,使用递归实现了整个并查集;但在实际中递归需要保存现场并进行压栈,开销极大,所以OpenCV使用循环替代了递归。

检测部分到此为止。接下来介绍训练部分。

白裳丶:OpenCV AdaBoost + Haar目标检测技术内幕(下)​zhuanlan.zhihu.com
dd207ec2ec597ed4cdf73f8c6372f56c.png
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值