YOLOv2源码分析(二)

本文深入分析YOLOv2的源码,涵盖parse_network_cfg函数,包括parse_net_options、学习率策略的设置,以及convolutional层的解析。详细解释了如何从配置文件中读取网络结构、学习率策略,并转换为程序内部的数据结构。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

文章全部YOLOv2源码分析

0x01 parse_network_cfg

我们继续前面没有说完的parse_network_cfg

    //parse_network_cfg
    node *n = sections->front;
    if(!n) error("Config file has no sections");    

我么先要了解一下list结构

typedef struct list{
    int size;
    node *front;
    node *back;
} list;
typedef struct node{
    void *val;
    struct node *next;
    struct node *prev;
} node;

这其实是一个双向链表,前向和后项都是一个node数据结构。这里,如果,这个链表后没有节点的话,就报错。

接着往后

    //parse_network_cfg
    network *net = make_network(sections->size - 1);

这里使用了一个make_network函数

network *make_network(int n)
{
    network *net = calloc(1, sizeof(network));
    net->n = n;
    net->layers = calloc(net->n, sizeof(layer));
    net->seen = calloc(1, sizeof(size_t));
    net->t    = calloc(1, sizeof(int));
    net->cost = calloc(1, sizeof(float));
    return net;
}

注意这里的这个make_network可能和早期的不太一样。我们先看看这里他做了什么。先看看network这个结构

//这个文件现在放在了darknet.h文件中
typedef enum {
    CONSTANT, STEP, EXP, POLY, STEPS, SIG, RANDOM
} learning_rate_policy;
typedef struct network{
    int n;              //网络总层数
    int batch;          //一个batch包含的图片数目,看下面的subdivisions
    size_t *seen;       //已经读取的图片数量
    int *t;
    float epoch;        //训练的次数
    int subdivisions;   //注意前面的batch/subdivisions才是网络的batch大小,可能目的是防止gpu显存不够
    layer *layers;      //指向网络的层
    float *output;
    learning_rate_policy policy;//学习率的策略,是一个枚举类型

    float learning_rate;//学习率
    float momentum;     //动量,一般0.9
    float decay;        //权重衰减正则项,防止过拟合
    float gamma;        //用于计算学习率,见后面0x0102
    float scale;        //用于计算学习率,见后面0x0102
    float power;        //用于计算学习率,见后面0x0102
    int time_steps;
    int step;           //用于计算学习率,见后面0x0102
    int max_batches;    //最大的训练batch数目
    float *scales;      //用于计算学习率,见后面0x0102
    int   *steps;       //用于计算学习率,见后面0x0102
    int num_steps;      //steps中的数据个数
    int burn_in;

    int adam;           //adam算法
    float B1;           //一阶矩估计的指数衰减率
    float B2;           //二阶矩估计的指数衰减率
    float eps;          //为了防止在实现中除以零

    int inputs;         //h*w*c
    int outputs;
    int truths;
    int notruth;
    int h, w, c;            //输入图像的高,宽,通道数
    int max_crop;           //控制图片缩放的最大值
    int min_crop;           //控制图片缩放的最小值
    float max_ratio;        //控制图片缩放的最大比例
    float min_ratio;        //控制图片缩放的最小比例
    int center;
    float angle;            //设置旋转角度,扩充数据
    float aspect;           //设置方位,扩充数据
    float exposure;         //设置曝光量,扩充数据
    float saturation;       //设置饱和度,扩充数据
    float hue;              //设置色调,扩充数据
    int random;             //random为1时随机使用不同尺寸的图片进行训练

    int gpu_index;          //设置第几个gpu
    tree *hierarchy;

    float *input;
    float *truth;
    float *delta;
    float *workspace;
    int train;
    int index;
    float *cost;

#ifdef GPU
    float *input_gpu;
    float *truth_gpu;
    float *delta_gpu;
    float *output_gpu;
#endif

} network;

由于参数太多,用到哪个说哪个,这个结构的主要作用就是存储网络的配置参数。make_network的作用就是产生network这种数据结构。接着往下

    //parse_network_cfg
    net->gpu_index = gpu_index;//设置gpu
    size_params params;

又出现一个新的结构size_params

typedef struct size_params{
    int batch;      //一个batch包含的图片数目
    int inputs;
    int h;          //图像的高
    int w;          //输入图像的宽
    int c;          //输入图像的通道数
    int index;
    int time_steps;
    network *net;
} size_params;

接着往下

    //parse_network_cfg
    section *s = (section *)n->val;//section这个结构我在(一)中提过

n是一个node结构,这个结构中的val是一个void*,所以这里就是将node结构中的val强转为section*,相当于我在(一)中图上画的[net]等节点。

    //parse_network_cfg   
    list *options = s->options;//这里就是之前说的kvp,也就是size=3,stride=1,pad=1这些
    if(!is_network(s)) error("First section must be [net] or [network]");    

看一下这个is_network函数

int is_network(section *s)
{
    return (strcmp(s->type, "[net]")==0
            || strcmp(s->type, "[network]")==0);
}

这个函数的作用很明显,判断传入的第一个section是不是[net][network]。接着又是一个比较大的函数

//parse_network_cfg    
parse_net_options(options, net);

0x0101 parse_net_options

void parse_net_options(list *options, network *net)//传入的时options参数和我们的network
{
    net->batch = option_find_int(options, "batch",1);//设置net的batch大小
    net->learning_rate = option_find_float(options, "learning_rate", .001);//设置学习率
    net->momentum = option_find_float(options, "momentum", .9);//设置动量
    net->decay = option_find_float(options, "decay", .0001);//设置权重衰减
    int subdivs = option_find_int(options, "subdivisions",1);//设置subdivisions,防止显存不够
    net->time_steps = option_find_int_quiet(options, "time_steps",1);
    net->notruth = option_find_int_quiet(options, "notruth",0);
    net->batch /= subdivs;
    net->batch *= net->time_steps;
    net->subdivisions = subdivs;
    net->random = option_find_int_quiet(options, "random", 0);

    net->adam = option_find_int_quiet(options, "adam", 0);
    if(net->adam){//设置adam参数,这里的默认选项是按照adam论文给的参数设置的
        net->B1 = option_find_float(options, "B1", .9);
        net->B2 = option_find_float(options, "B2", .999);
        net->eps = option_find_float(options, "eps", .0000001);
    }

    net->h = option_find_int_quiet(options, "height",0);
    net->w = option_find_int_quiet(options, "width",0);
    net->c = option_find_int_quiet(options, "channels",0);
    net->inputs = option_find_int_quiet(options, "inputs", net->h * net->w * net->c);
    net->max_crop = option_find_int_quiet(options, "max_crop",net->w*2);
    net->min_crop = option_find_int_quiet(options, "min_crop",net->w);
    net->max_ratio = option_find_float_quiet(options, "max_ratio", (float) net->max_crop / net->w);
    net->min_ratio = option_find_float_quiet(options, "min_ratio", (float) net->min_crop / net->w);
    net->center = option_find_int_quiet(options, "center",0);

    net->angle = option_find_float_quiet(options, "angle", 0);
    net->aspect = option_find_float_quiet(options, "aspect", 1);
    net->saturation = option_find_float_quiet(options, "saturation", 1);
    net->exposure = option_find_float_quiet(options, "exposure", 1);
    net->hue = option_find_float_quiet(options, "hue", 0);

    if(!net->inputs && !(net->h && net->w && net->c)) error("No input parameters supplied");

    char *policy_s = option_find_str(options, "policy", "constant");
    net->policy = get_policy(policy_s);
    net->burn_in = option_find_int_quiet(options, "burn_in", 0);
    net->power = option_find_float_quiet(options, "power", 4);
    if(net->policy == STEP){//如果学习率的策略是STEP的话
        net->step = option_find_int(options, "step", 1);
        net->scale = option_find_float(options, "scale", 1);
    } else if (net->policy == STEPS){//如果学习率的策略是STEPS的话
        char *l = option_find(options, "steps");//指向steps的字符串
        char *p = option_find(options, "scales");//指向scales的字符串
        if(!l || !p) error("STEPS policy must have steps and scales in cfg file");

        int len = strlen(l);
        int n = 1;
        int i;
        for(i = 0; i < len; ++i){
            if (l[i] == ',') ++n;
        }
        int *steps = calloc(n, sizeof(int));//将所有的steps值分开存放到这个数组中
        float *scales = calloc(n, sizeof(float));//将所有的scales值分开存放到这个数组中
        for(i = 0; i < n; ++i){
            int step    = atoi(l);
            float scale = atof(p);
            l = strchr(l, ',')+1;
            p = strchr(p, ',')+1;
            steps[i] = step;
            scales[i] = scale;
        }
        net->scales = scales;
        net->steps = steps;
        net->num_steps = n;
    } else if (net->policy == EXP){
        net->gamma = option_find_float(options, "gamma", 1);
    } else if (net->policy == SIG){
        net->gamma = option_find_float(options, "gamma", 1);
        net->step = option_find_int(options, "step", 1);
    } else if (net->policy == POLY || net->policy == RANDOM){
    }
    net->max_batches = option_find_int(options, "max_batches", 0);
}

这个函数中出现了这个函数option_find_int_quiet

int option_find_int_quiet(list *l, char *key, int def)
{
    char *v = option_find(l, key);
    if(v) return atoi(v);
    return def;
}
char *option_find(list *l, char *key)
{
    node *n = l->front;
    while(n){
        kvp *p = (kvp *)n->val;
        if(strcmp(p->key, key) == 0){
            p->used = 1;
            return p->val;
        }
        n = n->next;
    }
    return 0;
}

这个函数和之前的option_find_int不同。首先看里面的option_find这个函数的作用就是查找list中,nodekey和参数的key相同的node,返回这个nodeval,如果不存在,返回0。通过这个函数我们得到了pad=1 stride=2等参数后的数值信息。接下来就很easy了,如果有这个参数就将这个字符串(得到的是一个字符串,不是一个数)转化为一个整数,没有的话就返回三个参数(有点类似于默认参数)。

回顾整个option_find_int_quiet,它的作用就是找出和keynode的数值大小。类似于一种map里面的查找操作。

而再看option_find_int

int option_find_int(list *l, char *key, int def)
{
    char *v = option_find(l, key);
    if(v) return atoi(v);
    fprintf(stderr, "%s: Using default '%d'\n", key, def);
    return def;
}

它和前者的区别在于,它会打印报错信息。

0x0102 学习率策略

学习率策略的设置是一个枚举类型

typedef enum {
    CONSTANT, STEP, EXP, POLY, STEPS, SIG, RANDOM
} learning_rate_policy;

这是在前面就提到的。我们现在来看看,这几个有什么区别(参考caffe源码)

  • CONSTANT:学习率是一个固定的值learning_rate
  • STEP:是一种均匀分步策略learning_rate* gamma ^ (floor(iter / step))
  • EXP:learning_rate* gamma ^ iter
  • POLY:learning_rate(1 - iter/max_iter) ^ (power)
  • STEPS:同STEP只是这里的scale和step是一个数组
  • SIG:learning_rate ( 1/(1 + exp(-gamma * (iter - stepsize))))
  • RANDOM:代码中没有考虑

回头看整个parse_net_options这个函数,这个函数的主要功能就是读取[net]后的信息,赋值到net所指向的network结构中。

接着我们再回到parse_network_cfg

//parse_network_cfg 
    params.h = net->h;          //将h,w,c赋值size_params对象params,下面类似不再赘述
    params.w = net->w;
    params.c = net->c;
    params.inputs = net->inputs;
    params.batch = net->batch;
    params.time_steps = net->time_steps;
    params.net = net;

    size_t workspace_size = 0;
    n = n->next;                //[net]搞定了,接下来去下一个node
    int count = 0;
    free_section(s);  

我们再来看看这个free_section函数做了什么

void free_section(section *s)//传入的变量是之前的那个section指针
{
    free(s->type);//释放type空间
    node *n = s->options->front;//以下内容是释放s指向的kvp链表
    while(n){
        kvp *pair = (kvp *)n->val;
        free(pair->key);
        free(pair);
        node *next = n->next;
        free(n);
        n = next;
    }
    free(s->options);
    free(s);//最后释放s
}

综上来看这个函数的目的在这里很明显了。我们把cfg的参数从section中copy到了network中,section内存不用了,自然要把它释放。

再回到parse_network_cfg函数

    //parse_network_cfg 
    fprintf(stderr, "layer     filters    size              input                output\n");
    while(n){
        params.index = count;
        fprintf(stderr, "%5d ", count);
        s = (section *)n->val;
        options = s->options;
        layer l = {0};
        LAYER_TYPE lt = string_to_layer_type(s->type);

这是一个非常大的循环体,先看前面一小部分。我们先看看其中的LAYER_TYPE结构和layer结构

//现在这个结构也放在了darknet.h中
struct layer;
typedef struct layer layer;

typedef enum {
    CONVOLUTIONAL,
    DECONVOLUTIONAL,
    CONNECTED,
    MAXPOOL,
    SOFTMAX,
    DETECTION,
    DROPOUT,
    CROP,
    ROUTE,
    COST,
    NORMALIZATION,
    AVGPOOL,
    LOCAL,
    SHORTCUT,
    ACTIVE,
    RNN,
    GRU,
    LSTM,
    CRNN,
    BATCHNORM,
    NETWORK,
    XNOR,
    REGION,
    REORG,
    BLANK
} LAYER_TYPE;

struct layer{
    LAYER_TYPE type;
    ACTIVATION activation;
    COST_TYPE cost_type;
    void (*forward)   (struct layer, struct network);
    void (*backward)  (struct layer, struct network);
    void (*update)    (struct layer, update_args);
    void (*forward_gpu)   (struct layer, struct network);
    void (*backward_gpu)  (struct layer, struct network);
    void (*update_gpu)    (struct layer, update_args);
   ...

我们可以看到LAYER_TYPE就是每一层的类型。而layer就是设置这些层的参数,由于参数太多,我在后面会分开讲。

再来看string_to_layer_type函数

LAYER_TYPE string_to_layer_type(char * type)//传入的参数就是之前的section->type
{

    if (strcmp(type, "[shortcut]")==0) return SHORTCUT;
    if (strcmp(type, "[crop]")==0) return CROP;
    if (strcmp(type, "[cost]")==0) return COST;
    if (strcmp(type, "[detection]")==0) return DETECTION;
    if (strcmp(type, "[region]")==0) return REGION;
    if (strcmp(type, "[local]")==0) return LOCAL;
    if (strcmp(type, "[conv]")==0
            || strcmp(type, "[convolutional]")==0) return CONVOLUTIONAL;
    if (strcmp(type, "[deconv]")==0
            || strcmp(type, "[deconvolutional]")==0) return DECONVOLUTIONAL;
    if (strcmp(type, "[activation]")==0) return ACTIVE;
    if (strcmp(type, "[net]")==0
            || strcmp(type, "[network]")==0) return NETWORK;
    if (strcmp(type, "[crnn]")==0) return CRNN;
    if (strcmp(type, "[gru]")==0) return GRU;
    if (strcmp(type, "[lstm]") == 0) return LSTM;
    if (strcmp(type, "[rnn]")==0) return RNN;
    if (strcmp(type, "[conn]")==0
            || strcmp(type, "[connected]")==0) return CONNECTED;
    if (strcmp(type, "[max]")==0
            || strcmp(type, "[maxpool]")==0) return MAXPOOL;
    if (strcmp(type, "[reorg]")==0) return REORG;
    if (strcmp(type, "[avg]")==0
            || strcmp(type, "[avgpool]")==0) return AVGPOOL;
    if (strcmp(type, "[dropout]")==0) return DROPOUT;
    if (strcmp(type, "[lrn]")==0
            || strcmp(type, "[normalization]")==0) return NORMALIZATION;
    if (strcmp(type, "[batchnorm]")==0) return BATCHNORM;
    if (strcmp(type, "[soft]")==0
            || strcmp(type, "[softmax]")==0) return SOFTMAX;
    if (strcmp(type, "[route]")==0) return ROUTE;
    return BLANK;
}

这个函数的作用很明显,通过比较字符串,将原先的section中的type变成了LAYER_TYPE中的枚举元素。

接着回到parse_network_cfg,后面就是很多的条件判断

        //parse_network_cfg 
        if(lt == CONVOLUTIONAL){
            l = parse_convolutional(options, params);
        }else if(lt == DECONVOLUTIONAL){
            l = parse_deconvolutional(options, params);
        }else if(lt == LOCAL){
            l = parse_local(options, params);
        }else if(lt == ACTIVE){
            l = parse_activation(options, params);
        }else if(lt == RNN){
            l = parse_rnn(options, params);
        }else if(lt == GRU){
            l = parse_gru(options, params);
        }else if (lt == LSTM) {
            l = parse_lstm(options, params);
        }else if(lt == CRNN){
            l = parse_crnn(options, params);
        }else if(lt == CONNECTED){
            l = parse_connected(options, params);
        }else if(lt == CROP){
            l = parse_crop(options, params);
        }else if(lt == COST){
            l = parse_cost(options, params);
        }else if(lt == REGION){
            l = parse_region(options, params);
        }else if(lt == DETECTION){
            l = parse_detection(options, params);
        }else if(lt == SOFTMAX){
            l = parse_softmax(options, params);
            net->hierarchy = l.softmax_tree;
        }else if(lt == NORMALIZATION){
            l = parse_normalization(options, params);
        }else if(lt == BATCHNORM){
            l = parse_batchnorm(options, params);
        }else if(lt == MAXPOOL){
            l = parse_maxpool(options, params);
        }else if(lt == REORG){
            l = parse_reorg(options, params);
        }else if(lt == AVGPOOL){
            l = parse_avgpool(options, params);
        }else if(lt == ROUTE){
            l = parse_route(options, params, net);
        }else if(lt == SHORTCUT){
            l = parse_shortcut(options, params, net);
        }else if(lt == DROPOUT){
            l = parse_dropout(options, params);
            l.output = net->layers[count-1].output;
            l.delta = net->layers[count-1].delta;
#ifdef GPU
            l.output_gpu = net->layers[count-1].output_gpu;
            l.delta_gpu = net->layers[count-1].delta_gpu;
#endif
        }

然后再看以parse开头的函数作用,以其中一个为例parse_convolutional

0x0103 parse_convolutional

convolutional_layer parse_convolutional(list *options, size_params params)
{
    int n = option_find_int(options, "filters",1);  //卷积核个数
    int size = option_find_int(options, "size",1);  //卷积核大小
    int stride = option_find_int(options, "stride",1);//步长
    int pad = option_find_int_quiet(options, "pad",0);//图像周围是否补0
    int padding = option_find_int_quiet(options, "padding",0);//补0的长度
    int groups = option_find_int_quiet(options, "groups", 1);//卷积核组的个数
    if(pad) padding = size/2;//对应SAME补0策略

    char *activation_s = option_find_str(options, "activation", "logistic");//激活函数
    ACTIVATION activation = get_activation(activation_s);

    int batch,h,w,c;
    h = params.h;       //图片的高
    w = params.w;       //图片的宽
    c = params.c;       //图片的通道数
    batch=params.batch;
    if(!(h && w && c)) error("Layer before convolutional layer must output image.");
    int batch_normalize = option_find_int_quiet(options, "batch_normalize", 0);//BN操作
    int binary = option_find_int_quiet(options, "binary", 0);//权重二值化
    int xnor = option_find_int_quiet(options, "xnor", 0);//权重和输入二值化

    convolutional_layer layer = make_convolutional_layer(batch,h,w,c,n,groups,size,stride,padding,activation, batch_normalize, binary, xnor, params.net->adam);
    layer.flipped = option_find_int_quiet(options, "flipped", 0);
    layer.dot = option_find_float_quiet(options, "dot", 0);

    return layer;
}

因为我之前已经讲过了option_find_int函数,所以这里不再多说了。代码的前面部分也非常容易理解,就射设置,不同[]后面的参数。

后面又出现一个新的结构ACTIVATION

typedef enum{
    LOGISTIC, RELU, RELIE, LINEAR, RAMP, TANH, PLSE, LEAKY, ELU, LOGGY, STAIR, HARDTAN, LHTAN
} ACTIVATION;

很明显这个枚举是用来定义不同的激活函数的。

再看get_activation这个函数

ACTIVATION get_activation(char *s)//传入的参数时cfg中的activation
{
    if (strcmp(s, "logistic")==0) return LOGISTIC;
    if (strcmp(s, "loggy")==0) return LOGGY;
    if (strcmp(s, "relu")==0) return RELU;
    if (strcmp(s, "elu")==0) return ELU;
    if (strcmp(s, "relie")==0) return RELIE;
    if (strcmp(s, "plse")==0) return PLSE;
    if (strcmp(s, "hardtan")==0) return HARDTAN;
    if (strcmp(s, "lhtan")==0) return LHTAN;
    if (strcmp(s, "linear")==0) return LINEAR;
    if (strcmp(s, "ramp")==0) return RAMP;
    if (strcmp(s, "leaky")==0) return LEAKY;
    if (strcmp(s, "tanh")==0) return TANH;
    if (strcmp(s, "stair")==0) return STAIR;
    fprintf(stderr, "Couldn't find activation function %s, going with ReLU\n", s);
    return RELU;
}

这个函数的作用也很明显,通过比较字符串,将原先的激活函数的字符串,转化为现在的ACTIVATION枚举元素。

接着看convolutional_layer这个结构

typedef layer convolutional_layer;

其实就是一个layer,再看make_convolutional_layer函数(又是一个非常大的函数)

convolutional_layer make_convolutional_layer(int batch, int h, int w, int c, int n, int groups, int size, int stride, int padding, ACTIVATION activation, int batch_normalize, int binary, int xnor, int adam)//传入的参数就是我们之前设置好的
{
    int i;
    convolutional_layer l = {0};
    l.type = CONVOLUTIONAL;

    l.groups = groups;  //卷积核的组数
    l.h = h;            //图像的高
    l.w = w;            //图像的宽
    l.c = c;            //图像的通道数目
    l.n = n;            //卷积核个数
    l.binary = binary;
    l.xnor = xnor;
    l.batch = batch;
    l.stride = stride;
    l.size = size;
    l.pad = padding;
    l.batch_normalize = batch_normalize;

    l.weights = calloc(c/groups*n*size*size, sizeof(float));//计算所有权重个数,c/groups*n*size*size,分配内存空间
    l.weight_updates = calloc(c/groups*n*size*size, sizeof(float));

    l.biases = calloc(n, sizeof(float));//卷积核个数和偏向的数目一致,分配内存空间
    l.bias_updates = calloc(n, sizeof(float));

    l.nweights = c/groups*n*size*size;
    l.nbiases = n;

    // float scale = 1./sqrt(size*size*c);
    float scale = sqrt(2./(size*size*c/l.groups));//缩放系数
    //scale = .02;
    //for(i = 0; i < c*n*size*size; ++i) l.weights[i] = scale*rand_uniform(-1, 1);
    for(i = 0; i < l.nweights; ++i) l.weights[i] = scale*rand_normal();//初始化权重
    int out_w = convolutional_out_width(l);
    int out_h = convolutional_out_height(l);   

这里出现了新的函数,我们只分析其中一个convolutional_out_width

int convolutional_out_height(convolutional_layer l)
{
    return (l.h + 2*l.pad - l.size) / l.stride + 1;
}
int convolutional_out_width(convolutional_layer l)
{
    return (l.w + 2*l.pad - l.size) / l.stride + 1;
}

函数中这个公式大家应该很熟悉,就是计算卷积后的输出图像的大小。

接着看make_convolutional_layer这个函数后面的部分

//make_convolutional_layer
    l.out_h = out_h;    //输出图像的高
    l.out_w = out_w;    //输出图像的宽
    l.out_c = n;        //输出图像的通道数
    l.outputs = l.out_h * l.out_w * l.out_c;//输出图像的总元素个数
    l.inputs = l.w * l.h * l.c;             //输入图像的总元素个数

    l.output = calloc(l.batch*l.outputs, sizeof(float));
    l.delta  = calloc(l.batch*l.outputs, sizeof(float));

好的,这篇文章的篇幅有些长了,我们把剩余部分放到下一篇

觉得不错,点个赞吧b( ̄▽ ̄)d

由于本人水平有限,文中有不对之处,希望大家指出,谢谢^_^!

下一篇继续分析make_convolutional_layer这个函数后面的部分,敬请关注。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值