yolov3的yolo层源码详解

概述

yolo系列由于使用了纯c语言写,又是开源代码,所以自然成为了边缘计算的公司首选的架构。
它的重要性和影响力就不再赘述,本文主要从源码的角度,来带你深入理解yolo解析的思路,同时,在阅读源码的过程中也由衷感慨yolo作者代码的优美,整个代码没有依赖过多第三方库,只需要opencv用作效果显示,整个源码的大小只有不到1M(901K),却集成了CPU和GPU版本,可以说是非常强悍了。

准备知识

  • 要看懂yolov3源码中yolo层的正向传播过程,首先应该弄懂作者是如何保存特征图的。
    我放了一张图,可以参考图1。我们以分辨率为7×7的yolo层为例,假设我们的类别为3类,那么最后我们要完成框和类别的预测,需要多少张特征图呢?答案是3×(4+1+3)=24张,其中3表示该yolo层对应的3个anchor,4代表框的4个坐标,1代表是否有目标(objectness),3代表类别数量,它们按照如图1所示排列,我画了第一个anchor对应的8张特征图,为什么是8张,因为每个anchor都要对应回归和分类8个属性,分别是tx,ty,tw,th,objectness,classn-prob,按照这种顺序,一共有3组这样的特征图。注:在训练时,使用mini-batch,故有batch*3组上述特征图。

yolov3的yolo层源码详解_第1张图片

  • yolo作者并没有选择使用多维数,而是非常强悍地把这些特征图用一个一维数组来存放,这一点不得不佩服作者的代码能力。
    他存放的顺序依次是:按照图1的顺序,首先把第一张特征图的按行依次放入一维数组,后续每一个特征图都按这个顺序操作,故整个一维数组的排列顺序如图2所示:
    yolov3的yolo层源码详解_第2张图片

注:读者可能会问,这个顺序是如何得知的,其实是阅读源码后,看到代码里如何是操作各个位置的数据总结的,建议可以首先假设已经知道了这个存放顺序,再阅读源码,有助于理解。

源码解读

  • 首先从最重要的yolo层的正向传播开始理解,既然是源码理解,那么详细的细节就请参考下方的源码注释。
    yolov3的yolo层源码详解_第3张图片
void forward_yolo_layer(const layer l, network net)
{
    int i,j,b,t,n;
    memcpy(l.output, net.input, l.outputs*l.batch*sizeof(float));

#ifndef GPU
    for (b = 0; b < l.batch; ++b){
        for(n = 0; n < l.n; ++n){
            int index = entry_index(l, b, n*l.w*l.h, 0);
            activate_array(l.output + index, 2*l.w*l.h, LOGISTIC);
            index = entry_index(l, b, n*l.w*l.h, 4);
            activate_array(l.output + index, (1+l.classes)*l.w*l.h, LOGISTIC);
        }
    }
#endif

    memset(l.delta, 0, l.outputs * l.batch * sizeof(float));
    if(!net.train) return;
    float avg_iou = 0;
    float recall = 0;
    float recall75 = 0;
    float avg_cat = 0;
    float avg_obj = 0;
    float avg_anyobj = 0;
    int count = 0;
    int class_count = 0;
    *(l.cost) = 0;
    for (b = 0; b < l.batch; ++b) {
        for (j = 0; j < l.h; ++j) {
            for (i = 0; i < l.w; ++i) {
                for (n = 0; n < l.n; ++n) { // 遍历每一个特征图的格点
                    /*
                    * 现在知道的是该格点在第b个batch,使用第n个anchor预测,在特征图的第j行,第i列,根据存放顺序,要确定该格点的tx,ty,tw,th存放在哪个位置。
                    * 这里作者使用了一个难以理解的函数entry_index,我们走进这个函数,发现这些操作有点儿脱了裤子放屁的意思,但实际上正体现了作者聪明的头脑,
                    * 每一个他自己写的函数都做到了极致的复用(详细得到box_index的讲解请看entry_index内部函数讲解)
                    */
                    int box_index = entry_index(l, b, n*l.w*l.h + j*l.w + i, 0); 
                    box pred = get_yolo_box(l.output, l.biases, l.mask[n], box_index, i, j, l.w, l.h, net.w, net.h, l.w*l.h); // 得到一个相对坐标的,(center_x, center_y, w, h)形式的框坐标
                    float best_iou = 0;
                    int best_t = 0;
                    for(t = 0; t < l.max_boxes; ++t){ // 这里需要注意,作者将l.max_boxes设置为90,目的猜测是控制运行时间,如果你要检测一些密集的物体,比如一群鸽子,那么就要适当增加这个数值
                        /*
                        * 我找了很久,不知道net.truth这个存放GT框的变量在哪里赋值的,其实是在get_next_batch函数里,当load_data_detection读入GT框信息后,就被送到get_next_batch函数中加载。这个过程的了解非必须,有兴趣从detector.c的加载数据开始梳理
                        * 如何得到第t个GT框的信息呢,请跟到float_to_box函数讲解
                        */
                        box truth = float_to_box(net.truth + t*(4 + 1) + b*l.truths, 1); 
                        if(!truth.x) break;
                        float iou = box_iou(pred, truth);
                        if (iou > best_iou) {
                            best_iou = iou;
                            best_t = t;
                        }
                    }
                    int obj_index = entry_index(l, b, n*l.w*l.h + j*l.w + i, 4); // 找objectness在一维数组中的索引
                    avg_anyobj += l.output[obj_index]; // 统计用
                    /*
                    * 这个地方有点儿迷,我先把结论放在这儿,因为要结合后续遍历GT框来理解:
                    * l.delta是存放梯度的数组,梯度是Loss对上一层卷积层每个格点变量的导数,它的大小和l.output,也就是yolo层所有特征图的大小一样,这个也很好理解,每一个特征图的格点都可能产生loss
                    * 作者首先将所有格点都当作是负例,那么objectness就应该为0,于是将0 - l.output[obj_index]放入对应的格点的objectness属性里,其中l.output[obj_index]是预测值
                    * 然后将与GT框IOU大于l.ignore_thresh(0.7)的格点的objectness的loss设置为0,目的是不对IOU大于0.7的格点进行惩罚
                    * 这一步只对负例做了惩罚。后续遍历GT框的步骤,会对正例进行惩罚
                    */
                    l.delta[obj_index] = 0 - l.output[obj_index]; 
                    if (best_iou > l.ignore_thresh) {
                        l.delta[obj_index] = 0;
                    }
                    /*
                    * 作者并没有启用该分支,l.truth_thresh设置为1
                    * 如果启用,那么意义在于对大于l.truth_thresh的格点的框和分类产生loss,可以预想,因为训练过程没有使用NMS,那么与GT框相邻的格点的预测框很可能达到l.truth_thresh(0.7),如果打开,则是对所有相邻的框进行惩罚
                    */
                    if (best_iou > l.truth_thresh) { 
                        l.delta[obj_index] = 1 - l.output[obj_index];

                        int class = net.truth[best_t*(4 + 1) + b*l.truths + 4];
                        if (l.map) class = l.map[class];
                        int class_index = entry_index(l, b, n*l.w*l.h + j*l.w + i, 4 + 1);
                        delta_yolo_class(l.output, l.delta, class_index, class, l.classes, l.w*l.h, 0);
                        box truth = float_to_box(net.truth + best_t*(4 + 1) + b*l.truths, 1);
                        delta_yolo_box(truth, l.output, l.biases, l.mask[n], box_index, i, j, l.w, l.h, net.w, net.h, l.delta, (2-truth.w*truth.h), l.w*l.h);
                    }
                }
            }
        }
        for(t = 0; t < l.max_boxes; ++t){ // 遍历最多90个GT框
            box truth = float_to_box(net.truth + t*(4 + 1) + b*l.truths, 1); // 中心形式的相对坐标

            if(!truth.x) break;
            float best_iou = 0;
            int best_n = 0;
            i = (truth.x * l.w); // 在卷积层分辨率下,GT框中心点对应的格点的坐标(i, j),该格点负责回归该GT框,也就是说只有这个格点负责对这个GT框产生loss
            j = (truth.y * l.h);
            box truth_shift = truth;
            truth_shift.x = truth_shift.y = 0; // 既然已经确定了使用哪个格点来回归这个GT框,接下来就只需要确定使用哪个anchor,方法是将anchor和GT框看作左上角顶点重合,使用w,h计算iou
            for(n = 0; n < l.total; ++n){ // 寻找所有anchor中,与GT框IOU最大的anchor
                box pred = {0};
                pred.w = l.biases[2*n]/net.w;
                pred.h = l.biases[2*n+1]/net.h;
                float iou = box_iou(pred, truth_shift);
                if (iou > best_iou){
                    best_iou = iou;
                    best_n = n;
                }
            }

            int mask_n = int_index(l.mask, best_n, l.n); // l.n记录了该yolo层的anchor数量,l.mask记录了该yolo层的anchor的索引,比如3,4,5
            if(mask_n >= 0){
                int box_index = entry_index(l, b, mask_n*l.w*l.h + j*l.w + i, 0);
                float iou = delta_yolo_box(truth, l.output, l.biases, best_n, box_index, i, j, l.w, l.h, net.w, net.h, l.delta, (2-truth.w*truth.h), l.w*l.h);

                int obj_index = entry_index(l, b, mask_n*l.w*l.h + j*l.w + i, 4);
                avg_obj += l.output[obj_index];
                l.delta[obj_index] = 1 - l.output[obj_index]; // 正例学习

                int class = net.truth[t*(4 + 1) + b*l.truths + 4];
                if (l.map) class = l.map[class];
                int class_index = entry_index(l, b, mask_n*l.w*l.h + j*l.w + i, 4 + 1); // 理解了之前的框的梯度,class的梯度应该不难理解
                delta_yolo_class(l.output, l.delta, class_index, class, l.classes, l.w*l.h, &avg_cat);

                ++count;
                ++class_count;
                if(iou > .5) recall += 1;
                if(iou > .75) recall75 += 1;
                avg_iou += iou;
            }
        }
    }
    *(l.cost) = pow(mag_array(l.delta, l.outputs * l.batch), 2); // 显示的loss就是梯度的平方和
    printf("Region %d Avg IOU: %f, Class: %f, Obj: %f, No Obj: %f, .5R: %f, .75R: %f,  count: %d\n", net.index, avg_iou/count, avg_cat/class_count, avg_obj/count, avg_anyobj/(l.w*l.h*l.n*l.batch), recall/count, recall75/count, count);
}
box get_yolo_box(float *x, float *biases, int n, int index, int i, int j, int lw, int lh, int w, int h, int stride)
{
    box b; // 此处的x,y,w,h都是相对于原图的比例(0-1之间)
    /*
    * 理解了index的含义,还需要理解一下stride的含义,此处stride为l.w*l.h,由存放顺序图可以得知,每个格点位置对应的不同特征图,都是一个属性,那么index对应的是tx,下一个特征图的对应位置就是ty,以此类推tw,th,所以stride是l.w*l.h,即一张特征图大小
    * 要理解b.x的由来,首先需要看图3(论文如何encode框),tx是预测的框中心,相对于格点左上角的偏移(为什么要这么做呢?我还没研究,但大概应该是降低归回的难度),那么如果这个yolo层对应的特征图的分辨率是7*7,i表示格点的横坐标,比如第3行第4列的格点,i就等于4,然后预测框的中心点横坐标就等于i + tx,即i + x[index + 0*stride],在使用特征图的宽l.w进行归一化,得到预测框中心点的相对值
    * 同样的道理可以理解b.y
    * b.w如何理解呢?图3中看出,b.w由tw的指数函数乘以anchor的宽得到(作者在论文中提到他是借鉴了另一篇论文的做法,我还没研究,估计也是降低归回的难度),代码中的biases[2*n]就是目前使用的anchor的宽(yolo的anchor只定义呢宽和高,后续会讲解为什么)
    * 同样的道理理解b.h
    */
    b.x = (i + x[index + 0*stride]) / lw; 
    b.y = (j + x[index + 1*stride]) / lh;
    b.w = exp(x[index + 2*stride]) * biases[2*n]   / w;
    b.h = exp(x[index + 3*stride]) * biases[2*n+1] / h;
    return b;
}
//net.truth + t*(4 + 1) + b*l.truths, 1
box float_to_box(float *f, int stride)
{
    /*
    * GT框信息(center_x, center_y, w, h, class)的存放顺序理解很重要
    * 每个batch都预留呢90*(4+1)= 450长度的数组,l.truths就是90*(4+1),所以第b个batch就应该跳过b*l.truths
    * 那么第t个GT框信息的首地址就等于net.truth + t*(4 + 1) + b*l.truths,其中net.truth是存放GT框数组的首地址
    */
    box b = {0}; 
    b.x = f[0];
    b.y = f[1*stride];
    b.w = f[2*stride];
    b.h = f[3*stride];
    return b;
}
//l.output, l.delta, class_index, class, l.classes, l.w*l.h, &avg_cat
void delta_yolo_class(float *output, float *delta, int index, int class, int classes, int stride, float *avg_cat)
{
    int n;
    /*
    * 这个分支就是论文中提到的miltilabel classification
    * 具体做法是,如果第一次匹配到某一个格点,就会运行下面的for循环,例如这个框是一个rider,(假设我们要检测3类:person, car, rider),那么对应的person:0-pred, car:0-pred,rider:1-pred
    * 而本身person和rider属于miltilabel(想象一下电动车,既是rider也是person),这时,如果person的GT框也对应了这个格点,那么如果只执行for循环,结果就变成了rider:0-pred, car:0-pred, person:1-pred,就会将之前的rider也变成0-pred,导致本来可以同时学习rider和person的,现在只能学习一个
    * 加上这个if分支,就只会更新当前的class的delta,其最后的结果是:rider:1-pred,car:0-pred, person:1-pred,是我们想要的结果
    */
    if (delta[index]){ 
        delta[index + stride*class] = 1 - output[index + stride*class];
        if(avg_cat) *avg_cat += output[index + stride*class];
        return;
    }
    for(n = 0; n < classes; ++n){ // 对应的class应该趋近1,其他class应该趋近0
        delta[index + stride*n] = ((n == class)?1 : 0) - output[index + stride*n];
        if(n == class && avg_cat) *avg_cat += output[index + stride*n];
    }
}
//truth, l.output, l.biases, best_n, box_index, i, j, l.w, l.h, net.w, net.h, l.delta, (2-truth.w*truth.h), l.w*l.h
float delta_yolo_box(box truth, float *x, float *biases, int n, int index, int i, int j, int lw, int lh, int w, int h, float *delta, float scale, int stride)
{
    /* 
    * 计算box的loss相对简单,目的就是计算经过encode后,与特征图格点的差值,
    * 这里需要注意,因为真正的loss其实是L2 loss,但l.delta记录的是梯度,也就是导数,L2 loss的公式为pow((pred-truth), 2),导数就是2*(pred-truth)
    * 这里注意scale的用法,scale = (2-truth.w*truth.h),框越大,scale越小。由于大框的pred-truth会较大,为了消除大框和小框在尺度上的区别,不至于大框产生很大的loss,而忽略小目标的学习,所以进行了此操作
    */
    box pred = get_yolo_box(x, biases, n, index, i, j, lw, lh, w, h, stride);
    float iou = box_iou(pred, truth);

    float tx = (truth.x*lw - i);
    float ty = (truth.y*lh - j);
    float tw = log(truth.w*w / biases[2*n]);
    float th = log(truth.h*h / biases[2*n + 1]);

    delta[index + 0*stride] = scale * (tx - x[index + 0*stride]);
    delta[index + 1*stride] = scale * (ty - x[index + 1*stride]);
    delta[index + 2*stride] = scale * (tw - x[index + 2*stride]);
    delta[index + 3*stride] = scale * (th - x[index + 3*stride]);
    return iou;
}

总结

解析层包含两个大循环,第一个遍历所有特征图的格点,目的是得到负样本的objectness_loss,第二个循环是遍历所有GT框,得到正样本的objectness_loss, box_loss和class_loss。

思考

正负样本问题:在正负样本的选择上,yolo将所有与GT框的IOU小于0.7的预测框都选为负例,当数据集中,每张图片平均的GT框数量较多时,正负样本比较均衡,但如何数据集里的GT框都比较小,且稀疏,那么负例样本就会大大多于正例样本,效果理论上就会不好。所以针对这一点,还是可以对yolo源码进行一些改进,针对这种数据集做样本均衡

最后的话

由于本人也是在学习阶段,注释中可能会出现一些理解错误,还请高手指出,感谢

你可能感兴趣的:(源码详解)