完成上篇博客semantic segmentation的任务之后,一方面继续研究对其精度及性能的提升,另一方面着手处理更加复杂,算法与语义分割也不尽相同的实例分割instance segmentation。由于作者已经给出了源码,话不多说,直接开始上代码,由于算法的复杂性,可能有的地方说的不详细或者不对的,欢迎找我讨论。
-
Embedding降维
在了解实例分割代码之前,首先需要掌握一种非常重要的降维方式:Embedding
通常情况下,如果需要使用数字对现实中的物体进行分类,我们会使用one-hot矩阵,one-hot矩阵是指每一行有且只有一个元素为1,其他元素都是0的矩阵。引用https://www.sohu.com/a/210757729_826434的例子来解释,针对每一个单词,我们分配一个编号,对某句话进行编码时,将里面的每个单词转换成字典里面这个单词编号对应的位置为1的one-hot矩阵就可以了。比如我们要表达“the cat sat on the mat”,可以使用如下的矩阵表示:
当需要区分的种类数量较小时这种表示方法比较直观也很方便,但是当遇到COCO这种共有80类目标或iseg90个目标的数据集,表示每一个分类都需要80个值,而其中有79个值都是0,非常的浪费内存空间;同时计算时也会轮询所有的值,是对GPU资源的浪费。而且这种单一的表示方法略显冷酷,对于相似性较高的目标也没有体现出互相之间的联系。
Embedding解决了这两个问题。Embedding矩阵给每个类分配一个固定长度的向量表示,这个长度可以自行设定,比如iseg中用到的32,实际上会远远小于总的目标数量(比如90)。而且两个类向量之间的夹角值可以作为他们之间关系的一个衡量。
讲到这里大家应该就明白embedding的含义了:用更少数量但更丰富的数值去实现矩阵的降维。
所以,之所以Iseg层的ids设为32,意思就是用32个值去表示一个类
2. 训练的Truth矩阵
truth[i*(mw*mh+1)] = id;
for(j = 0; j < mw*mh; ++j){
truth[i*(mw*mh + 1) + 1 + j] = mask.data[j];
}
如上图所示即是Iseg的Truth或者说label的矩阵形状,每一个目标的大小都是1+l.w*l.h,因此矩阵大小为90×(1+l.w×l.h),如果说直观形象点的话,大家可以理解为是90张图像;第一位代表该目标所属的种类,剩下的就是01的二值Mask图像。那么假如图像中只有10个目标怎么办呢?那就把后面80个目标的种类位也就是第一个值设为-1就好了,到时候用if语句进行判断。
3. Iseg代码解析
首先看layer的构造函数,解释一下参数的含义:
layer make_iseg_layer(int batch, int w, int h, int classes, int ids)
{
layer l = {0};
l.type = ISEG;
l.h = h;
l.w = w;
l.c = classes + ids; //这里classes就是和semantic一样,ids意思是用多少维的向量去表示
// 一个目标
l.out_w = l.w;
l.out_h = l.h;
l.out_c = l.c;
l.classes = classes;
l.batch = batch;
l.extra = ids;
l.cost = calloc(1, sizeof(float));
l.outputs = h*w*l.c;
l.inputs = l.outputs;
l.truths = 90*(l.w*l.h+1); //这个地方90就代表truth中默认一张图像最多有90个object
l.delta = calloc(batch*l.outputs, sizeof(float));
l.output = calloc(batch*l.outputs, sizeof(float));
l.counts = calloc(90, sizeof(int));
l.sums = calloc(90, sizeof(float*));
if(ids){
int i;
for(i = 0; i < 90; ++i){
l.sums[i] = calloc(ids, sizeof(float)); //l.sums是一个90*ids的二维数组
}
}
l.forward = forward_iseg_layer;
l.backward = backward_iseg_layer;
#ifdef GPU
l.forward_gpu = forward_iseg_layer_gpu;
l.backward_gpu = backward_iseg_layer_gpu;
l.output_gpu = cuda_make_array(l.output, batch*l.outputs);
l.delta_gpu = cuda_make_array(l.delta, batch*l.outputs);
#endif
fprintf(stderr, "iseg\n");
srand(0);
return l;
}
在forward开始之后首先就是对output和delta进行参数初始化,output直接接收net.input,delta初始化分为两部分,classes*l.w*l.h和ids*l.w*l.h部分,至于为什么始终ids层的delta都要比classes层小很多,具体原因可能是在不断训练中改善的,理论原因就是那句英文所说的,embedding should be small magnitude。
for(i = 0; i < l.classes; ++i){
for(k = 0; k < l.w*l.h; ++k){
int index = b*l.outputs + i*l.w*l.h + k;
l.delta[index] = 0 - l.output[index];
}
}
// a priori, embedding should be small magnitude
for(i = 0; i < ids; ++i){
for(k = 0; k < l.w*l.h; ++k){
int index = b*l.outputs + (i+l.classes)*l.w*l.h + k;
l.delta[index] = .1 * (0 - l.output[index]);
}
}
for(i = 0; i < 90; ++i){
fill_cpu(ids, 0, l.sums[i], 1); //set all 90 sums to 0
int c = net.truth[b*l.truths + i*(l.w*l.h+1)]; //the class of the i-th box in truth label
if(c < 0) break; //如果类别值为-1的话就代表后面没有目标了,直接break
// add up metric embeddings for each instance
for(k = 0; k < l.w*l.h; ++k){
int index = b*l.outputs + c*l.w*l.h + k;
float v = net.truth[b*l.truths + i*(l.w*l.h + 1) + 1 + k]; //直接找到c类别所在的那一层w*h
if(v){ //假如第k个点label值为1,这里需要非常注意,这可能只是第c层的所有正样本点的一部分,比如说第i个label目标有100个正样本点
l.delta[index] = v - l.output[index]; //该层该点处的delta值(作者目前大体思路就是只管正样本点,负样本点就保持同样的更新梯度不管)
axpy_cpu(ids, 1, l.output + b*l.outputs + l.classes*l.w*l.h + k, l.w*l.h, l.sums[i], 1);//将第每一个id层的100个点的输出和给到l.sum[i][id]值
++l.counts[i]; //第i个label中目标正样本像素点的数量
}
}
}
上图就是上段代码所实现的内容。左上方图为label,右下方图为l.output,上图sums的列向量下标反了,请注意,不过并不影响。首先轮询90个目标,
1. 以第i个目标为例首先找到第i个目标所属的分类k,然后直接到第k层;对于v != 0的点,也就是左下方图中阴影区域classes部分,计算出阴影区域的delta,非阴影区域不管;
2. sums是一个90×ids的二维数组,对于sums[i]中ids个值的计算方式就是将输出图中ids部分的对应阴影区域内所有值相加求和,如图中所示;
3. 同时输出阴影区域像素点的总和 l.counts[i]。
float *mse = calloc(90, sizeof(float));
for(i = 0; i < 90; ++i){
int c = net.truth[b*l.truths + i*(l.w*l.h+1)];
if(c < 0) break;
for(k = 0; k < l.w*l.h; ++k){
float v = net.truth[b*l.truths + i*(l.w*l.h + 1) + 1 + k];
if(v){
int z;
float sum = 0;
for(z = 0; z < ids; ++z){
int index = b*l.outputs + (l.classes + z)*l.w*l.h + k;
sum += pow(l.sums[i][z]/l.counts[i] - l.output[index], 2); //l.sums[i][z]/l.counts[i]就是这一层第i个样本正样本点的输出平均值,然后减去第z层的k这一点的输出值
} //要对所有的输出做均方
mse[i] += sum; //将ids层的100个像素点的所有的差方进行相加
}
}
mse[i] /= l.counts[i]; //100个点的平均差方
}
这一步的话数学比较好的同学可能一眼就能看出来,这是在求第i个目标在输出中每一个点处的32个(ids)值构成的向量的标准差,用于表征这32个值的离散程度,或者说距离平均值的差异程度。那么为什么要计算这个标准差呢,就是为了后面将每一个目标的区域区分开来以实现实例分割。
// Calculate embedding loss
for(i = 0; i < 90; ++i){
if(!l.counts[i]) continue;
for(k = 0; k < l.w*l.h; ++k){
float v = net.truth[b*l.truths + i*(l.w*l.h + 1) + 1 + k];
if(v){
for(j = 0; j < 90; ++j){
if(!l.counts[j])continue;
int z;
for(z = 0; z < ids; ++z){
int index = b*l.outputs + (l.classes + z)*l.w*l.h + k;
float diff = l.sums[j][z] - l.output[index]; //用所有id层的所有目标区域的和减去在该层的k点的输出值
if (j == i) l.delta[index] += diff < 0? -.1 : .1;
else l.delta[index] += -(diff < 0? -.1 : .1); //这里是算法的关键,将第id层的不同目标区域分开,也就是car1和car2的输出区域区分开;同时用ids*l.counts维
} //度的向量去表示一个目标,这也是一种降维的方式
}
}
}
}
关于ids层的delta计算方式有必要进行一下详细说明,如我代码中所说,这里是实例分割的关键。
这里需要大家明白的就是最开始说的问题,在output中用32个值的向量去代表一个图像中的点(这里只讨论truth值为1的正样本点)。
然后这里做的内容就是让每个90个目标中的object的32个值的向量(确切的说其实是每个点32个值,总共要有l.counts[i]组32个值)与其他目标的32个值互相区分开来,达到实例分割的目的。
如上图所示,就是用每一个32个值的筒状矩阵去代表一个object(图中一共两个object),这里delta所需要做的就是这两个筒状自己筒内counts个点之间差异越来越小,反而与另外一个筒状内点的差异越来越大。
那么是如何实现的呢?
我们以下面大的那个为基准,然后可以看代码中同样会轮询所有的object,也就是那个for j<90的循环。 举个简单例子吧,用2个4个值表示的向量看这几行代码是如何工作的:
向量1:0.1, 0.2, 0.3, -0.4 l.sums[1] = 0.2
向量2:0.5, 0.6, -0.7, 0.8 l.sums[2] = 1.2
以向量1为准,开始循环,让j=1,也就是delta判断语句中的j == i,
diff1-1: 0.1, 0, -0.1, 0.6
delta 1-1: 0.1, 0.1, -0.1, 0.1,根据yolo的反向更新规则,当delta大于0时,该点输出值会增大,因此实现的效果就是让4个点都向着0.2趋近;
diff 1-2:-0.3, -0.4, 0.9, -0.6
delta 1-2:0.1, 0.1, -0.1, 0.1,这里实现的效果就是让向量2的四个点都向着远离0.2的方向发展。
明白了吧,这样就实现了让两个筒状向量互相远离的目的了,也就是实现了实例分割。
4. 总结
整个实例分割的难点有两个:
一个就是作者使用output中一个ids的向量组去表示label中的一个点;
第二个就是实例分割是如何让两个向量组互相远离从而达到区分的目的。
这些都是我自己的理解,有可能根本就不对,如果大家有问题或者觉得我的理解有错误的话麻烦告知下,谢谢。