yolo系列由于使用了纯c语言写,又是开源代码,所以自然成为了边缘计算的公司首选的架构。
它的重要性和影响力就不再赘述,本文主要从源码的角度,来带你深入理解yolo解析的思路,同时,在阅读源码的过程中也由衷感慨yolo作者代码的优美,整个代码没有依赖过多第三方库,只需要opencv用作效果显示,整个源码的大小只有不到1M(901K),却集成了CPU和GPU版本,可以说是非常强悍了。
注:读者可能会问,这个顺序是如何得知的,其实是阅读源码后,看到代码里如何是操作各个位置的数据总结的,建议可以首先假设已经知道了这个存放顺序,再阅读源码,有助于理解。
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源码进行一些改进,针对这种数据集做样本均衡
由于本人也是在学习阶段,注释中可能会出现一些理解错误,还请高手指出,感谢