这个AlexeyAB DarkNet框架解析在AlexeyAB的DarkNet源码上会做大量注释,我克隆该工程然后添加注释,注释版DarkNet工程地址为:https://github.com/GiantPandaCV/darknet
前言
前面我们已经成功的获取了目标检测的网络结构(cfg
文件的内容),并将网络保存在了一个network
结构体中,然后我们还分析了数据加载方式。现在数据和网络结构都有了,接下来就是开始训练/测试的过程了,这个过程主要调用的是network
的前向传播和反向传播函数,而network
的前向传播和反向传播又可以细分为每一个layer
的前向传播和反向传播,今天我们来看一下网络的前向传播和反向传播以及layer
是如何定义的。
网络的前向传播和反向传播
网络的前向传播函数在src/network.c
中实现,代码如下:
/*
** 前向计算网络net每一层的输出
** state用来标记当前网络的状态,
** 遍历net的每一层网络,从第0层到最后一层,逐层计算每层的输出
*/
void forward_network(network net, network_state state)
{
// 网络的工作空间, 指的是所有层中占用运算空间最大的那个层的 workspace_size,
// 因为实际上在 GPU 或 CPU 中某个时刻只有一个层在做前向或反向运算
state.workspace = net.workspace;
int i;
// 遍历所有层,从第一层到最后一层,逐层进行前向传播,网络共有net.n层
for(i = 0; i < net.n; ++i){
// 当前正在进行第i层的处理
state.index = i;
// 获取当前层
layer l = net.layers[i];
// 如果当前层的l.delta已经动态分配了内存,则调用fill_cpu()函数将其所有元素初始化为0
if(l.delta && state.train){
// 第一个参数为l.delta的元素个数,第二个参数为初始化值,为0
scal_cpu(l.outputs * l.batch, 0, l.delta, 1);
}
//double time = get_time_point();
// 前向传播: 完成当前层前向推理
l.forward(l, state);
// 完成某一层的推理时,置网络的输入为当前层的输出(这将成为下一层网络的输入),注意此处更改的是state,而非原始的net
//printf("%d - Predicted in %lf milli-seconds.\n", i, ((double)get_time_point() - time) / 1000);
state.input = l.output;
}
}
网络的反向传播函数也在src/network.c
中实现,代码如下:
/*
** 反向计算网络net每一层的梯度图,并进而计算每一层的权重、偏置更新值,最后完成每一层权重与偏置更新
** 流程: 遍历net的每一层网络,从最后一层到第一层(此处所指的第一层不是指输入层,而是与输入层直接相连的第一层隐含层)进行反向传播
*/
void backward_network(network net, network_state state)
{
int i;
// 在进行反向传播之前先保存一下原来的net信息
float *original_input = state.input;
float *original_delta = state.delta;
state.workspace = net.workspace;
for(i = net.n-1; i >= 0; --i){
// 标志参数,当前网络的活跃层
state.index = i;
// i = 0时,也即已经到了网络的第1层(或者说第0层,看个人习惯了~)了,
// 就是直接与输入层相连的第一层隐含层(注意不是输入层,我理解的输入层就是指输入的图像数据,
// 严格来说,输入层不算一层网络,因为输入层没有训练参数,也没有激活函数),这个时候,不需要else中的赋值,1)对于第1层来说,其前面已经没有网络层了(输入层不算),
// 因此没有必要再计算前一层的参数,故没有必要在获取上一层;2)第一层的输入就是图像输入,也即整个net最原始的输入,在开始进行反向传播之前,已经用original_*变量保存了
// 最为原始的net,所以net.input就是第一层的输入,不需要获取上一层的输出作为当前层的输入;3)同1),第一层之前已经没有层了,
// 也就不需要计算上一层的delta,即不需要再将net.delta链接到prev.delta,此时进入到l.backward()中后,net.delta就是NULL(可以参看darknet.h中关于delta
// 的注释),也就不会再计算上一层的敏感度了(比如卷积神经网络中的backward_convolutional_layer()函数)
// 这几行代码就是给net.input和net.delta赋值
if(i == 0){
state.input = original_input;
state.delta = original_delta;
}else{
layer prev = net.layers[i-1];
state.input = prev.output;
state.delta = prev.delta;
}
layer l = net.layers[i];
if (l.stopbackward) break;
if (l.onlyforward) continue;
// 反向计算第i层的敏感度图、权重及偏置更新值,并更新权重、偏置(同时会计算上一层的敏感度图,
// 存储在net.delta中,但是还差一个环节:乘上上一层输出对加权输入的导数,也即上一层激活函数对加权输入的导数)
l.backward(l, state);
}
}
Layer的解释
在将具体某个层如卷积层的前向传播和反向传播之前,需要先来看一下layer
是怎么定义的,因为网络的前向传播和反向传播实际上就是各个网络层(layer
)的前向传播和反向传播,这部分加好注释的代码(在src/darknet.h
中)如下:
//定义layer
struct layer {
LAYER_TYPE type; // 网络层的类型,枚举类型,取值比如DROPOUT,CONVOLUTIONAL,MAXPOOL分别表示dropout层,卷积层,最大池化层,可参见LAYER_TYPE枚举类型的定义
ACTIVATION activation; //激活函数类型,枚举类型
COST_TYPE cost_type; //损失函数类型,枚举类型
void(*forward) (struct layer, struct network_state);
void(*backward) (struct layer, struct network_state);
void(*update) (struct layer, int, float, float, float);
void(*forward_gpu) (struct layer, struct network_state);
void(*backward_gpu) (struct layer, struct network_state);
void(*update_gpu) (struct layer, int, float, float, float);
layer *share_layer;
int train;
int avgpool;
int batch_normalize; // 是否进行BN,如果进行BN,则值为1
int shortcut;
int batch; // 一个batch中含有的图片张数,等于net.batch,详细可以参考network.h中的注释,一般在构建具体网络层时赋值(比如make_maxpool_layer()中)
int forced;
int flipped;
int inputs; // 一张输入图片所含的元素个数(一般在各网络层构建函数中赋值,比如make_connected_layer()),第一层的值等于l.h*l.w*l.c,
// 之后的每一层都是由上一层的输出自动推算得到的(参见parse_network_cfg(),在构建每一层后,会更新params.inputs为上一层的l.outputs)
int outputs; // 该层对应一张输入图片的输出元素个数(一般在各网络层构建函数中赋值,比如make_connected_layer())
// 对于一些网络,可由输入图片的尺寸及相关参数计算出,比如卷积层,可以通过输入尺寸以及步长、核大小计算出;
// 对于另一些尺寸,则需要通过网络配置文件指定,如未指定,取默认值1,比如全连接层(见parse_connected()函数)
int nweights;
int nbiases;
int extra;
int truths; // < 根据region_layer.c判断,这个变量表示一张图片含有的真实值的个数,对于检测模型来说,一个真实的标签含有5个值,
// 包括类型对应的编号以及定位矩形框用到的w,h,x,y四个参数,且在darknet中固定每张图片最大处理30个矩形框,(可查看max_boxes参数),
// 因此,在region_layer.c的make_region_layer()函数中赋值为30*5.
int h, w, c; // 该层输入的图片的宽,高,通道数(一般在各网络层构建函数中赋值,比如make_connected_layer())
int out_h, out_w, out_c;// 该层输出图片的高、宽、通道数(一般在各网络层构建函数中赋值,比如make_connected_layer())
int n; // 对于卷积层,该参数表示卷积核个数,等于out_c,其值由网络配置文件指定;对于region_layer层,该参数等于配置文件中的num值
// (该参数通过make_region_layer()函数赋值,在parser.c中调用的make_region_layer()函数),
// 可以在darknet/cfg文件夹下执行命令:grep num *.cfg便可以搜索出所有设置了num参数的网络,这里面包括yolo.cfg等,其值有
// 设定为3,5,2的,该参数就是Yolo论文中的B,也就是一个cell中预测多少个box。
int max_boxes; // 每张图片最多含有的标签矩形框数(参看:data.c中的load_data_detection(),其输入参数boxes就是指这个参数),
// 什么意思呢?就是每张图片中最多打了max_boxes个标签物体,模型预测过程中,可能会预测出很多的物体,但实际上,
// 图片中打上标签的真正存在的物体最多就max_boxes个,预测多出来的肯定存在false positive,需要滤出与筛选,
// 可参看region_layer.c中forward_region_layer()函数的第二个for循环中的注释
int groups; // 应该是控制组卷积的组数,类似于caffe的group参数
int group_id;
int size; // 核尺寸(比如卷积核,池化核等)
int side;
int stride; // 滑动步长,如卷积核的滑动步长
int stride_x;
int stride_y;
int dilation; //空洞卷积参数
int antialiasing;
int maxpool_depth;
int out_channels; // 输出通道数
int reverse;
int flatten;
int spatial;
int pad; // 该层对输入数据四周的补0长度(现在发现在卷积层,最大池化层中有用到该参数),一般在构建具体网络层时赋值(比如make_maxpool_layer()中)
int sqrt;
int flip;
int index;
int scale_wh;
int binary;
int xnor;
int peephole;
int use_bin_output;
int keep_delta_gpu;
int optimized_memory;
int steps;
int state_constrain;
int hidden;
int truth;
float smooth;
float dot;
int deform;
int sway;
int rotate;
int stretch;
int stretch_sway;
float angle;
float jitter;
float saturation;
float exposure;
float shift;
float ratio;
float learning_rate_scale;
float clip;
int focal_loss;
float *classes_multipliers;
float label_smooth_eps;
int noloss;
int softmax;
int classes; // 物体类别种数,一个训练好的网络,只能检测指定所有物体类别中的物体,比如yolo9000.cfg,设置该值为9418,
// 也就是该网络训练好了之后可以检测9418种物体。该参数由网络配置文件指定。目前在作者给的例子中,
// 有设置该值的配置文件大都是检测模型,纯识别的网络模型没有设置该值,我想是因为检测模型输出的一般会为各个类别的概率,
// 所以需要知道这个种类数目,而识别的话,不需要知道某个物体属于这些所有类的具体概率,因此可以不知道。
int coords; // 这个参数一般用在检测模型中,且不是所有层都有这个参数,一般在检测模型最后一层有,比如region_layer层,该参数的含义
// 是定位一个物体所需的参数个数,一般为4个,包括物体所在矩形框中心坐标x,y两个参数以及矩形框长宽w,h两个参数,
// 可以在darknet/cfg文件夹下,执行grep coords *.cfg,会搜索出所有使用该参数的模型,并可看到该值都设置为4
int background;
int rescore;
int objectness;
int does_cost;
int joint;
int noadjust;
int reorg;
int log;
int tanh;
int *mask;
int total;
float bflops;
int adam;
float B1;
float B2;
float eps;
int t;
float alpha;
float beta;
float kappa;
float coord_scale;
float object_scale;
float noobject_scale;
float mask_scale;
float class_scale;
int bias_match;
float random;
float ignore_thresh;
float truth_thresh;
float iou_thresh;
float thresh;
float focus;
int classfix;
int absolute;
int assisted_excitation;
int onlyforward; // 标志参数,当值为1那么当前层只执行前向传播
int stopbackward; // 标志参数,用来强制停止反向传播过程(值为1则停止反向传播),参看network.c中的backward_network()函数
int dontload;
int dontsave;
int dontloadscales;
int numload;
float temperature; // 温度参数,softmax层特有参数,在parse_softmax()函数中赋值,由网络配置文件指定,如果未指定,则使用默认值1(见parse_softmax())
float probability; // dropout概率,即舍弃概率,相应的1-probability为保留概率(具体的使用可参见forward_dropout_layer()),在make_dropout_layer()中赋值,
// 其值由网络配置文件指定,如果网络配置文件未指定,则取默认值0.5(见parse_dropout())
float dropblock_size_rel;
int dropblock_size_abs;
int dropblock;
float scale; // 在dropout层中,该变量是一个比例因子,取值为保留概率的倒数(darknet实现用的是inverted dropout),用于缩放输入元素的值
// (在网上随便搜索关于dropout的博客,都会提到inverted dropout),在make_dropout_layer()函数中赋值
char * cweights;
int * indexes; // 维度为l.out_h * l.out_w * l.out_c * l.batch,可知包含整个batch输入图片的输出,一般在构建具体网络层时动态分配内存(比如make_maxpool_layer()中)。
// 目前仅发现其用在在最大池化层中。该变量存储的是索引值,并与当前层所有输出元素一一对应,表示当前层每个输出元素的值是上一层输出中的哪一个元素值(存储的索引值是
// 在上一层所有输出元素(包含整个batch)中的索引),因为对于最大池化层,每一个输出元素的值实际是上一层输出(也即当前层输入)某个池化区域中的最大元素值,indexes就是记录
// 这些局部最大元素值在上一层所有输出元素中的总索引。记录这些值有什么用吗?当然有,用于反向传播过程计算上一层敏感度值,详见backward_maxpool_layer()以及forward_maxpool_layer()函数。
int * input_layers; //这个层有哪些输入层
int * input_sizes; // 输入层的尺寸
float **layers_output; //产生的一系列输出层
float **layers_delta;
WEIGHTS_TYPE_T weights_type;
WEIGHTS_NORMALIZATION_T weights_normalizion;
/*
* 这个参数用的不多,仅在region_layer.c中使用,该参数的作用是用于不同数据集间类别编号的转换,更为具体的,
* 是coco数据集中80类物体编号与联合数据集中9000+物体类别编号之间的转换,可以对比查看data/coco.names与
* data/9k.names以及data/coco9k.map三个文件(旧版的darknet可能没有,新版的darknet才有coco9k.map这个文件),
* 可以发现,coco.names中每一个物体类别都可以在9k.names中找到,且coco.names中每个物体类别名称在9k.names
* 中所在的行数就是coco9k.map中的编号值(减了1,因为在程序数组中编号从0开始),也就是这个map将coco数据集中
* 的类别编号映射到联和数据集9k中的类别编号(这个9k数据集是一个联和多个数据集的大数集,其名称分类被层级划分,
* )(注意两个文件中物体的类别名称大部分都相同,有小部分存在小差异,虽然有差异,但只是两个数据集中使用的名称有所差异而已,
* 对应的物体是一样的,比如在coco.names中摩托车的名称为motorbike,在联合数据集9k.names,其名称为motorcycle).
*/
int * map;
int * counts;
float ** sums;
// 这个参数目前只发现用在dropout层,用于存储一些列的随机数,这些随机数与dropout层的输入元素一一对应,维度为l.batch*l.inputs(包含整个batch的),在make_dropout_layer()函数中用calloc动态分配内存,
// 并在前向传播函数forward_dropout_layer()函数中逐元素赋值。里面存储的随机数满足0~1均匀分布,干什么用呢?用于决定该输入元素的去留,
// 我们知道dropout层就完成一个事:按照一定概率舍弃输入神经元(所谓舍弃就是置该输入的值为0),rand中存储的值就是如果小于l.probability,则舍弃该输入神经元(详见:forward_dropout_layer())。
// 为什么要保留这些随机数呢?和最大池化层中的l.indexes类似,在反向传播函数backward_dropout_layer()中用来指示计算上一层的敏感度值,因为dropout舍弃了一些输入,
// 这些输入(dropout层的输入,上一层的输出)对应的敏感度值可以置为0,而那些没有舍弃的输入,才有必要由当前dropout层反向传播过去。
float * rand;
float * cost;
float * state;
float * prev_state;
float * forgot_state;
float * forgot_delta;
float * state_delta;
float * combine_cpu;
float * combine_delta_cpu;
float *concat;
float *concat_delta;
float *binary_weights;
float *biases; // 当前层所有偏置,对于卷积层,维度l.n,每个卷积核有一个偏置;对于全连接层,维度等于单张输入图片对应的元素个数即outputs,一般在各网络构建函数中动态分配内存(比如make_connected_layer()
float *bias_updates; // 当前层所有偏置更新值,对于卷积层,维度l.n,每个卷积核有一个偏置;对于全连接层,维度为outputs。所谓权重系数更新值,就是梯度下降中与步长相乘的那项,也即误差对偏置的导数,
// 一般在各网络构建函数中动态分配内存(比如make_connected_layer())
float *scales;
float *scale_updates;
float *weights; //当前层所有权重系数(连接当前层和上一层的系数,但记在当前层上),对于卷积层,维度为l.n*l.c*l.size*l.size,即卷积核个数乘以卷积核尺寸再乘以输入通道数(各个通道上的权重系数独立不一样);
// 对于全连接层,维度为单张图片输入与输出元素个数之积inputs*outputs,一般在各网络构建函数中动态分配内存(比如make_connected_layer())
float *weight_updates;// 当前层所有权重系数更新值,对于卷积层维度为l.n*l.c*l.size*l.size;对于全连接层,维度为单张图片输入与输出元素个数之积inputs*outputs,
// 所谓权重系数更新值,就是梯度下降中与步长相乘的那项,也即误差对权重的导数,一般在各网络构建函数中动态分配内存(比如make_connected_layer()
float scale_x_y;
float max_delta;
float uc_normalizer;
float iou_normalizer;
float cls_normalizer;
IOU_LOSS iou_loss;
NMS_KIND nms_kind;
float beta_nms;
YOLO_POINT yolo_point;
char *align_bit_weights_gpu;
float *mean_arr_gpu;
float *align_workspace_gpu;
float *transposed_align_workspace_gpu;
int align_workspace_size;
char *align_bit_weights;
float *mean_arr;
int align_bit_weights_size;
int lda_align;
int new_lda;
int bit_align;
float *col_image;
float * delta; // 存储每一层的敏感度图:包含所有输出元素的敏感度值(整个batch所有图片)。所谓敏感度,即误差函数关于当前层每个加权输入的导数值,
// 关于敏感度图这个名称,其实就是梯度,可以参考https://www.zybuluo.com/hanbingtao/note/485480。
// 元素个数为l.batch * l.outputs(其中l.outputs = l.out_h * l.out_w * l.out_c),
// 对于卷积神经网络,在make_convolutional_layer()动态分配内存,按行存储,可视为l.batch行,l.outputs列,
// 即batch中每一张图片,对应l.delta中的一行,而这一行,又可以视作有l.out_c行,l.out_h*l.out_c列,
// 其中每小行对应一张输入图片的一张输出特征图的敏感度。一般在构建具体网络层时动态分配内存(比如make_maxpool_layer()中)。
float * output; // 存储该层所有的输出,维度为l.out_h * l.out_w * l.out_c * l.batch,可知包含整个batch输入图片的输出,一般在构建具体网络层时动态分配内存(比如make_maxpool_layer()中)。
// 按行存储:每张图片按行铺排成一大行,图片间再并成一行。
float * activation_input;
int delta_pinned;
int output_pinned;
float * loss;
float * squared;
float * norms;
float * spatial_mean;
float * mean;
float * variance;
float * mean_delta;
float * variance_delta;
float * rolling_mean;
float * rolling_variance;
float * x;
float * x_norm;
float * m;
float * v;
float * bias_m;
float * bias_v;
float * scale_m;
float * scale_v;
float *z_cpu;
float *r_cpu;
float *h_cpu;
float *stored_h_cpu;
float * prev_state_cpu;
float *temp_cpu;
float *temp2_cpu;
float *temp3_cpu;
float *dh_cpu;
float *hh_cpu;
float *prev_cell_cpu;
float *cell_cpu;
float *f_cpu;
float *i_cpu;
float *g_cpu;
float *o_cpu;
float *c_cpu;
float *stored_c_cpu;
float *dc_cpu;
float *binary_input;
uint32_t *bin_re_packed_input;
char *t_bit_input;
struct layer *input_layer;
struct layer *self_layer;
struct layer *output_layer;
struct layer *reset_layer;
struct layer *update_layer;
struct layer *state_layer;
struct layer *input_gate_layer;
struct layer *state_gate_layer;
struct layer *input_save_layer;
struct layer *state_save_layer;
struct layer *input_state_layer;
struct layer *state_state_layer;
struct layer *input_z_layer;
struct layer *state_z_layer;
struct layer *input_r_layer;
struct layer *state_r_layer;
struct layer *input_h_layer;
struct layer *state_h_layer;
struct layer *wz;
struct layer *uz;
struct layer *wr;
struct layer *ur;
struct layer *wh;
struct layer *uh;
struct layer *uo;
struct layer *wo;
struct layer *vo;
struct layer *uf;
struct layer *wf;
struct layer *vf;
struct layer *ui;
struct layer *wi;
struct layer *vi;
struct layer *ug;
struct layer *wg;
tree *softmax_tree; // softmax层用到的一个参数,不过这个参数似乎并不常见,很多用到softmax层的网络并没用使用这个参数,目前仅发现darknet9000.cfg中使用了该参数,如果未用到该参数,其值为NULL,如果用到了则会在parse_softmax()中赋值,
// 目前个人的初步猜测是利用该参数来组织标签数据,以方便访问
size_t workspace_size; // net.workspace的元素个数,为所有层中最大的l.out_h*l.out_w*l.size*l.size*l.c,(在make_convolutional_layer()计算得到workspace_size的大小,在parse_network_cfg()中动态分配内存,此值对应未使用gpu时的情况
#ifdef GPU
int *indexes_gpu;
float *z_gpu;
float *r_gpu;
float *h_gpu;
float *stored_h_gpu;
float *temp_gpu;
float *temp2_gpu;
float *temp3_gpu;
float *dh_gpu;
float *hh_gpu;
float *prev_cell_gpu;
float *prev_state_gpu;
float *last_prev_state_gpu;
float *last_prev_cell_gpu;
float *cell_gpu;
float *f_gpu;
float *i_gpu;
float *g_gpu;
float *o_gpu;
float *c_gpu;
float *stored_c_gpu;
float *dc_gpu;
// adam
float *m_gpu;
float *v_gpu;
float *bias_m_gpu;
float *scale_m_gpu;
float *bias_v_gpu;
float *scale_v_gpu;
float * combine_gpu;
float * combine_delta_gpu;
float * forgot_state_gpu;
float * forgot_delta_gpu;
float * state_gpu;
float * state_delta_gpu;
float * gate_gpu;
float * gate_delta_gpu;
float * save_gpu;
float * save_delta_gpu;
float * concat_gpu;
float * concat_delta_gpu;
float *binary_input_gpu;
float *binary_weights_gpu;
float *bin_conv_shortcut_in_gpu;
float *bin_conv_shortcut_out_gpu;
float * mean_gpu;
float * variance_gpu;
float * rolling_mean_gpu;
float * rolling_variance_gpu;
float * variance_delta_gpu;
float * mean_delta_gpu;
float * col_image_gpu;
float * x_gpu;
float * x_norm_gpu;
float * weights_gpu;
float * weight_updates_gpu;
float * weight_deform_gpu;
float * weight_change_gpu;
float * weights_gpu16;
float * weight_updates_gpu16;
float * biases_gpu;
float * bias_updates_gpu;
float * bias_change_gpu;
float * scales_gpu;
float * scale_updates_gpu;
float * scale_change_gpu;
float * input_antialiasing_gpu;
float * output_gpu;
float * activation_input_gpu;
float * loss_gpu;
float * delta_gpu;
float * rand_gpu;
float * squared_gpu;
float * norms_gpu;
float *gt_gpu;
float *a_avg_gpu;
int *input_sizes_gpu;
float **layers_output_gpu;
float **layers_delta_gpu;
#ifdef CUDNN
cudnnTensorDescriptor_t srcTensorDesc, dstTensorDesc;
cudnnTensorDescriptor_t srcTensorDesc16, dstTensorDesc16;
cudnnTensorDescriptor_t dsrcTensorDesc, ddstTensorDesc;
cudnnTensorDescriptor_t dsrcTensorDesc16, ddstTensorDesc16;
cudnnTensorDescriptor_t normTensorDesc, normDstTensorDesc, normDstTensorDescF16;
cudnnFilterDescriptor_t weightDesc, weightDesc16;
cudnnFilterDescriptor_t dweightDesc, dweightDesc16;
cudnnConvolutionDescriptor_t convDesc;
cudnnConvolutionFwdAlgo_t fw_algo, fw_algo16;
cudnnConvolutionBwdDataAlgo_t bd_algo, bd_algo16;
cudnnConvolutionBwdFilterAlgo_t bf_algo, bf_algo16;
cudnnPoolingDescriptor_t poolingDesc;
#endif // CUDNN
#endif // GPU
};
后记
由于篇幅原因就讲到这里,明天开始分析卷积层的前向传播和反向传播的代码和详细过程,同时包括im2col
以及col2im
等算法。
同期文章
- 【翻译】手把手教你用AlexeyAB版Darknet
- 【AlexeyAB DarkNet框架解析】一,框架总览
- 【AlexeyAB DarkNet框架解析】二,数据结构解析
- 【AlexeyAB DarkNet框架解析】三,加载数据进行训练
欢迎关注GiantPandaCV, 在这里你将看到独家的深度学习分享,坚持原创,每天分享我们学习到的新鲜知识。( • ̀ω•́ )✧
有对文章相关的问题,或者想要加入交流群,欢迎添加BBuf微信: