首先说一下,本人由于之后嵌入式开发移植的需求,因此主要改动的框架就是darknet。前面一篇博文主要介绍了我所使用的数据集及其预处理方式,本文我将对darknet中相关语义分割的修改进行介绍,供大家参考,如果大家有兴趣的话可以参考我的github主页:https://github.com/ArtyZe/yolo_segmentation
当然,yolo作者也在不断的对整个框架进行更新,大家也可以实时关注:https://github.com/pjreddie/darknet
有任何问题欢迎大家找我讨论,下面开始正文:
-
主程序修改
-
Origin image及Lable image读取方式修改
-
损失函数修改
-
网络结构修改
-
修改经验及错误更正总结
-
训练结果
1. 主程序修改
主要是根据我的训练类别将classes设为1,其实在后面代码修改过程中,这个参数已经不起作用了,在instance segmentation过程中会关系到你生成多少层的label图像。但是为了让大家知道后续这个参数起到什么作用,这里还是进行相应修改。至于threads,它主要与你的ngpus有关系,我在后面代码中,发现在每个thread中都会加载一次我的image和label,然而若按作者的32线程,意味着我有31个线程其实什么都没做,通过printf imgs这个参数同样验证了我的想法。
void train_segmenter(char *datacfg, char *cfgfile, char *weightfile, int *gpus, int ngpus, int clear, int display)
{
args.classes = 1;
args.threads = 1;
}
void *load_threads(void *ptr)
{
for(i = 0; i < args.threads; ++i){
args.d = buffers + i;
args.n = (i+1) * total/args.threads - i * total/args.threads;
threads[i] = load_data_in_thread(args);
}
}
2. Origin image及Lable image读取方式修改
在原作者的代码中,估计是从coco数据集的想法而来,需要和json文件的配合,我以主要代码举例
image orig = load_image_color(random_paths[i], 0, 0);
image mask = get_segmentation_image(random_paths[i], orig.w, orig.h, classes);
可以看到,主要是通过两个函数将图像读取后返回至image结构体对象中
image get_segmentation_image(char *path, int w, int h, int classes)
{
find_replace(labelpath, ".JPEG", ".txt", labelpath);
image mask = make_image(w, h, classes);
FILE *file = fopen(labelpath, "r");
if(!file) file_error(labelpath);
image part = make_image(w, h, 1);
while(fscanf(file, "%d %s", &id, buff) == 2){
int n = 0;
int *rle = read_intlist(buff, &n, 0);
load_rle(part, rle, n);
or_image(part, mask, id);
free(rle);
}
return mask;
}
做了几句删减,请忽略语法错误,可以看到对应每一张输入图像,都需要一个对应的.txt文件,这个文件具体什么格式等下细说。
可以看到最后return回的mask对象是一个l.w*l.h*classes的图像,因此对于作者代码来说,这个classes需要根据自己的训练集进行修改,或者在txt文件中进行修改。下面看下其中的子函数:
void load_rle(image im, int *rle, int n)
{
int count = 0;
int curr = 0;
int i,j;
for(i = 0; i < n; ++i){
for(j = 0; j < rle[i]; ++j){
im.data[count++] = curr;
}
curr = 1 - curr;
}
for(; count < im.h*im.w*im.c; ++count){
im.data[count] = curr;
}
}
这里所做的工作就是根据txt文件中的每一行首尾参数对每一个part图像进行赋值0-1操作,给大家个示意图
其实很简单,txt中就是存储的每一行的首尾坐标,这样就能先将0-x2的值设为1,然后再将0-x1的值设为0,这样就成功的将x1-x2之间的值赋为了1,其他行同理;不过需要注意的是整张图像其实是一个l.w*l.h的一维向量,这里只是给大家说明一下。
void or_image(image src, image dest, int c)
{
int i;
for(i = 0; i < src.w*src.h; ++i){
if(src.data[i]) dest.data[dest.w*dest.h*c + i] = 1;
}
}
这里就简单了,根据每一个类别的part图像,将对应的mask图像中对应那一层设为0或者1。了解完作者的代码后,觉得自己需要只要输入label图像就能自动生成label矩阵的代码,因此下面着手进行修改。
image mask = load_image_gray(random_paths[i], orig.w, orig.h);
首先将mask的输入方式进行修改如上
image load_image_gray(char *path, int w, int h){
char labelpath[4096];
find_replace(labelpath, "_leftImg8bit.png", "_gtFine_instanceIds.png", labelpath);
find_replace(path, "images", "mask", labelpath);
find_replace(labelpath, "JPEGImages", "mask", labelpath);
find_replace(labelpath, ".jpg", ".txt", labelpath);
find_replace(labelpath, ".JPG", ".txt", labelpath);
find_replace(labelpath, "_leftImg8bit.png", "_gtFine_instanceIds.png", labelpath);
return load_image(labelpath, w, h, 1);
}
这里非常简单,相信大家能够看懂,就是根据我的_leftImg8bit.png寻找对应label图像_gtFine_instanceIds.png,然后通过load_image导入,大家如果使用我的框架的话,一定要根据自己的数据集对find_replace函数的参数进行修改,否则一、找不到对应图像,导致data结构体中d.x和d.y都是0;二、报出can not find file的错误
这里需要说出一个非常重要的改动:作者对图像的normalization
在load_image时,发现作者读取的并不是每个通道原像素值,而是除以255。但是通过对前面代码的研究,其实d.y矩阵中的值是0-1的,因此如果我如果按照作者读取图像的方式,我的值就是0-1/255,因此这里需要对代码进行修改,判断是否在读取label图像(因为我的label图像都是灰度的,channel == 0)
image im = make_image(w, h, c);
if(c==1){
for(k = 0; k < c; ++k){
for(j = 0; j < h; ++j){
for(i = 0; i < w; ++i){
int dst_index = i + w*j + w*h*k;
int src_index = k + c*i + c*w*j;
im.data[dst_index] = (float)data[src_index];
}
}
}
}else{
for(k = 0; k < c; ++k){
for(j = 0; j < h; ++j){
for(i = 0; i < w; ++i){
int dst_index = i + w*j + w*h*k;
int src_index = k + c*i + c*w*j;
im.data[dst_index] = (float)data[src_index]/255.;
}
}
}
}
第二个重要的改动:原图像和label图像的局部双线性插值
相信读过代码的同学应该知道,作者读取输入图像后并不是直接作为训练图像,除normalization之外,还对图像进行裁剪,双线性插值并进行随机旋转,对于原图像来说可能没什么影响,但是对于0-1的mask图像来说影响较大,这会造成一些1内部的点插值称为0,或者一些边缘值不是0-1而是中间值。
image rotate_crop_image_seg(image im, float rad, float s, int w, int h, float dx, float dy, float aspect)
{
int x, y, c;
float cx = im.w/2.;
float cy = im.h/2.;
image rot = make_image(w, h, im.c);
for(c = 0; c < im.c; ++c){
for(y = 0; y < h; ++y){
for(x = 0; x < w; ++x){
float rx = cos(rad)*((x - w/2.)/s*aspect + dx/s*aspect) - sin(rad)*((y - h/2.)/s + dy/s) + cx;
float ry = sin(rad)*((x - w/2.)/s*aspect + dx/s*aspect) + cos(rad)*((y - h/2.)/s + dy/s) + cy;
float val = bilinear_interpolate(im, rx, ry, c);
//if(val!=0) printf("the value is %f\n",val);
//if(val >=0.25) //给定一个阈值,判断插值后的每个点像素值
//{
// val = 1;
//}else{
// val = 0;
//}
set_pixel(rot, x, y, c, val);
}
}
}
return rot;
}
上面代码中我注释掉的部分即为判断语句,保证双线性插值的图像同样只为0-1图像。不过后来在实验中发现,其实按照作者就不进行该改进,同样可以出现效果,不过会有些噪点。
3. 损失函数修改
其实这里不能算是修改,应该是选择。作者在给出了多种选择方案,这里给大家列举一下:① logistic; ② softmax layer,相比于logistic来说,主要是适用于1个类别以上的分类,这点相信了解softmax函数的同学应该会理解; ③ cost layer,这里可以设置参数为seg来保证是用于语义分割,当然作者在这里调皮的说他自己也觉得这里的损失函数好像有点问题,哈哈
这里说一个比较重要的点吧,相信对于许多同学来说还不太知道:其实在语义分割darknet所有的计算中,作者的delta也就是反传梯度用于weights更新的值都是一样的,都是truth-pred,只不过不同的loss计算方式不同。那么这两个值有什么区别呢,loss是给你看自己的模型拟合到什么程度了,而delta才是真正用于权值更新起作用的。
void backward_cost_layer_gpu(const cost_layer l, network net)
{
axpy_gpu(l.batch*l.inputs, l.scale, l.delta_gpu, 1, net.delta_gpu, 1); //将l.delta_gpu拷贝给net.delta_gpu
}
不相信的同学可以去看下convolutional层的update函数和network.c文件即可,顺便了解下darknet的梯度反传及训练方式 :)
4. cfg也就是网络结构
这里没有什么特别说的,按照各种比如UNET或者SegNet的网络结构写即可
5. 修改经验及错误更正总结
先说比较常遇到的错误吧:
- CUDA OUT OF MEMORY
这个原因很清楚了,就是你的gpu比较弱,内存太小,这时候需要做的就是修改你的网络,将节点数或者层数删除一点;然后将batch设为1,subdivision设为1
- Segmentation fault:
第一种可能就是访问越界,比如说你的图像是l.w*l.h的,你访问的指针到了l.w*l.h+1了,就会造成这个错误; 第二种可能就是你直接访问GPU中的数据,这也是不了解darknet框架的同学经常犯的错误,下面给出正确访问GPU数据后修改并返回的代码示例
cuda_pull_array(l.output_gpu, net.input, l.batch*l.inputs);
image im = make_image(1024, 512, 1);
for(i=0; i<l.w*l.h; i++){
l.delta[i] = 0 - net.input[i];
im.data[i] = (float)net.input[i];
}
cuda_push_array(l.delta_gpu, l.delta, l.batch*l.outputs);
save_image(im, "feature_map");
free_image(im);
这里实现将该层的输出矩阵变成图像保存下来,需要做的就是首先将l.output_gpu也就是GPU中的数据pull到CPU,然后进行操作,之后如果需要将计算所得的CPU中的delta回传到GPU用于反传,又需要用PUSH函数将其push回去,而不能直接将l.output_gpu保存为一张图像,这样就会报segmentation fault的错误
再说一些训练的点:
- 学习率
主要看你一张图像正样本像素点所占比例,如果高的话可以大一些,小的话就要小一些,我设为了10-7
- 训练损失不下降
其实我的训练过程中,损失值一直 是跳变的,不过在最初是有下降趋势的;这里需要做的就是将你最后一层的输出图像保存下来和你的输入图像对比一下,看热度图是不是和你的类似,如果类似的话就没关系,尽管训练,到最后再看结果,一定要耐心多训练几千几万步再看结果,不要一开始看着损失值不下降就停了改代码,如果在我说的前提下就可以放心的训练。
6. 训练结果
总的来说,我自己的修改过程也摸索了2个多月,没有免费的午餐,都是自己看代码写代码做出来的,希望大家可以静下心来多看源码,多思考。当然如果有任何问题,欢迎讨论,也可以在我github主页下留言