昨天讲了DarkNet的底层数据结构,并且将网络配置文件进行了解析存放到了一个network
结构体中,那么今天我们就要来看一下Darknet是如何加载数据进行训练的。
DarkNet的数据加载函数load_data()
在src/data.c
中实现(src/detector.c
函数中的train_detector
直接调用这个函数加载数据)。load_data()
函数调用流程如下:load_data(args)->load_threads()->load_data_in_threads()->load_thread()->load_data_detection()
,前四个函数都是在对线程的调用进行封装。最底层的数据加载任务由load_data_detection()
函数完成。所有的数据(图片数据和标注信息数据)加载完成之后再拼接到一个大的数组中。在DarkNet中,图片的存储形式是一个行向量,向量长度为h*w*3
。同时图片被归一化到[0, 1]
之间。
load_threads
在src/data.c
中实现,代码如下:
// copy from https://github.com/hgpvision/darknet/blob/master/src/data.c#L355
/*
** 开辟多个线程读入图片数据,读入数据存储至ptr.d中(主要调用load_in_thread()函数完成)
** 输入: ptr 包含所有线程要读入图片数据的信息(读入多少张,开几个线程读入,读入图片最终的宽高,图片路径等等)
** 返回: void* 万能指针(实际上不需要返回什么)
** 说明: 1) load_threads()是一个指针函数,只是一个返回变量为void*的普通函数,不是函数指针
** 2) 输入ptr是一个void*指针(万能指针),使用时需要强转为具体类型的指针
** 3) 函数中涉及四个用来存储读入数据的变量:ptr, args, out, buffers,除args外都是data*类型,所有这些变量的
** 指针变量其实都指向同一块内存(当然函数中间有些动态变化),因此读入的数据都是互通的。
** 流程: 本函数首先会获取要读入图片的张数、要开启线程的个数,而后计算每个线程应该读入的图片张数(尽可能的均匀分配),
** 并创建所有的线程,并行读入数据,最后合并每个线程读入的数据至一个大data中,这个data的指针变量与ptr的指针变量
** 指向的是统一块内存,因此也就最终将数据读入到ptr.d中(所以其实没有返回值)
*/
void *load_threads(void *ptr)
{
//srand(time(0));
int i;
// 先使用(load_args*)强转void*指针,而后取ptr所指内容赋值给args
// 虽然args不是指针,args是深拷贝了ptr中的内容,但是要知道ptr(也就是load_args数据类型),有很多的
// 指针变量,args深拷贝将拷贝这些指针变量到args中(这些指针变量本身对ptr来说就是内容,
// 而args所指的值是args的内容,不是ptr的,不要混为一谈),因此,args与ptr将会共享所有指针变量所指的内容
load_args args = *(load_args *)ptr;
if (args.threads == 0) args.threads = 1;
// 另指针变量out=args.d,使得out与args.d指向统一块内存,之后,args.d所指的内存块会变(反正也没什么用了,变就变吧),
// 但out不会变,这样可以保证out与最原始的ptr指向同一块存储读入图片数据的内存块,因此最终将图片读到out中,
// 实际就是读到了最原始的ptr中,比如train_detector()函数中定义的args.d中
data *out = args.d;
// 读入图片的总张数= batch * subdivision * ngpus,可参见train_detector()函数中的赋值
int total = args.n;
// 释放ptr:ptr是传入的指针变量,传入的指针变量本身也是按值传递的,即传入函数之后,指针变量得到复制,函数内的形参ptr
// 获取外部实参的值之后,二者本身没有关系,但是由于是指针变量,二者之间又存在一丝关系,那就是函数内形参与函数外实参指向
// 同一块内存。又由于函数外实参内存是动态分配的,因此函数内的形参可以使用free()函数进行内存释放,但一般不推荐这么做,因为函数内释放内存,
// 会影响函数外实参的使用,可能使之成为野指针,那为什么这里可以用free()释放ptr呢,不会出现问题吗?
// 其一,因为ptr是一个结构体,是一个包含众多的指针变量的结构体,如data* d等(当然还有其他非指针变量如int h等),
// 直接free(ptr)将会导致函数外实参无法再访问非指针变量int h等(实际经过测试,在gcc编译器下,能访问但是值被重新初始化为0),
// 因为函数内形参和函数外实参共享一块堆内存,而这些非指针变量都是存在这块堆内存上的,内存一释放,就无法访问了;
// 但是对于指针变量,free(ptr)将无作为(这个结论也是经过测试的,也是用的gcc编译器),不会释放或者擦写掉ptr指针变量本身的值,
// 当然也不会影响函数外实参,更不会牵扯到这些指针变量所指的内存块,总的来说,
// free(ptr)将使得ptr不能再访问指针变量(如int h等,实际经过测试,在gcc编译器下,能访问但是值被重新初始化为0),
// 但其指针变量本身没有受影响,依旧可以访问;对于函数外实参,同样不能访问非指针变量,而指针变量不受影响,依旧可以访问。
// 其二,darknet数据读取的实现一层套一层(似乎有点罗嗦,总感觉代码可以不用这么写的:)),具体调用过程如下:
// load_data(load_args args)->load_threads(load_args* ptr)->load_data_in_thread(load_args args)->load_thread(load_args* ptr),
// 就在load_data()中,重新定义了ptr,并为之动态分配了内存,且深拷贝了传给load_data()函数的值args,也就是说在此之后load_data()函数中的args除了其中的指针变量指着同一块堆内存之外,
// 二者的非指针变量再无瓜葛,不管之后经过多少个函数,对ptr的非指针变量做了什么改动,比如这里直接free(ptr),使得非指针变量值为0,都不会影响load_data()中的args的非指针变量,也就不会影响更为顶层函数中定义的args的非指针变量的值,
// 比如train_detector()函数中的args,train_detector()对args非指针变量赋的值都不会受影响,保持不变。综其两点,此处直接free(ptr)是安全的。
// 说明:free(ptr)函数,确定会做的事是使得内存块可以重新分配,且不会影响指针变量ptr本身的值,也就是ptr还是指向那块地址, 虽然可以使用,但很危险,因为这块内存实际是无效的,
// 系统已经认为这块内存是可分配的,会毫不考虑的将这块内存分给其他变量,这样,其值随时都可能会被其他变量改变,这种情况下的ptr指针就是所谓的野指针(所以经常可以看到free之后,置原指针为NULL)。
// 而至于free(ptr)还不会做其他事情,比如会不会重新初始化这块内存为0(擦写掉),以及怎么擦写,这些操作,是不确定的,可能跟具体的编译器有关(个人猜测),
// 经过测试,对于gcc编译器,free(ptr)之后,ptr中的非指针变量的地址不变,但其值全部擦写为0;ptr中的指针变量,丝毫不受影响,指针变量本身没有被擦写,
// 存储的地址还是指向先前分配的内存块,所以ptr能够正常访问其指针变量所指的值。测试代码为darknet_test_struct_memory_free.c。
// 不知道这段测试代码在VS中执行会怎样,还没经过测试,也不知道换用其他编译器(darknet的Makefile文件中,指定了编译器为gcc),darknet的编译会不会有什么问题??
// 关于free(),可以看看:http://blog.sina.com.cn/s/blog_615ec1630102uwle.html,文章最后有一个很有意思的比喻,但意思好像就和我这里说的有点不一样了(到底是不是编译器搞得鬼呢??)。
free(ptr);
// 每一个线程都会读入一个data,定义并分配args.thread个data的内存
data* buffers = (data*)xcalloc(args.threads, sizeof(data));
pthread_t* threads = (pthread_t*)xcalloc(args.threads, sizeof(pthread_t));
// 此处定义了多个线程,并为每个线程动态分配内存
for(i = 0; i < args.threads; ++i){
// 此处就承应了上面的注释,args.d指针变量本身发生了改动,使得本函数的args.d与out不再指向同一块内存,
// 改为指向buffers指向的某一段内存,因为下面的load_data_in_thread()函数统一了结口,需要输入一个load_args类型参数,
// 实际是想把图片数据读入到buffers[i]中,只能令args.d与buffers[i]指向同一块内存
args.d = buffers + i;
// 下面这句很有意思,因为有多个线程,所有线程读入的总图片张数为total,需要将total均匀的分到各个线程上,
// 但很可能会遇到total不能整除的args.threads的情况,比如total = 61, args.threads =8,显然不能做到
// 完全均匀的分配,但又要保证读入图片的总张数一定等于total,用下面的语句刚好在尽量均匀的情况下,
// 保证总和为total,比如61,那么8个线程各自读入的照片张数分别为:7, 8, 7, 8, 8, 7, 8, 8
args.n = (i+1) * total/args.threads - i * total/args.threads;
// 开启线程,读入数据到args.d中(也就读入到buffers[i]中)
// load_data_in_thread()函数返回所开启的线程,并存储之前已经动态分配内存用来存储所有线程的threads中,
// 方便下面使用pthread_join()函数控制相应线程
threads[i] = load_data_in_thread(args);
}
for(i = 0; i < args.threads; ++i){
// 以阻塞的方式等待线程threads[i]结束:阻塞是指阻塞启动该子线程的母线程(此处应为主线程),
// 是母线程处于阻塞状态,一直等待所有子线程执行完(读完所有数据)才会继续执行下面的语句
// 关于多线程的使用,进行过代码测试,测试代码对应:darknet_test_pthread_join.c
pthread_join(threads[i], 0);
}
// 多个线程读入所有数据之后,分别存储到buffers[0],buffers[1]...中,接着使用concat_datas()函数将buffers中的数据全部合并成一个大数组得到out
*out = concat_datas(buffers, args.threads);
// 也就只有out的shallow敢置为0了,为什么呢?因为out是此次迭代读入的最终数据,该数据参与训练(用完)之后,当然可以深层释放了,而此前的都是中间变量,
// 还处于读入数据阶段,万不可设置shallow=0
out->shallow = 0;
// 释放buffers,buffers也是个中间变量,切记shallow设置为1,如果设置为0,那就连out中的数据也没了
for(i = 0; i < args.threads; ++i){
buffers[i].shallow = 1;
free_data(buffers[i]);
}
// 最终直接释放buffers,threads,注意buffers是一个存储data的一维数组,上面循环中的内存释放,实际是释放每一个data的部分内存
// (这部分内存对data而言是非主要内存,不是存储读入数据的内存块,而是存储指向这些内存块的指针变量,可以释放的)
free(buffers);
free(threads);
return 0;
}
load_data_in_thread()
函数仍然在src/data.c
中,代码如下:
/*
** 创建一个线程,读入相应图片数据(此时args.n不再是一次迭代读入的所有图片的张数,而是经过load_threads()均匀分配给每个线程的图片张数)
** 输入: args 包含该线程要读入图片数据的信息(读入多少张,读入图片最终的宽高,图片路径等等)
** 返回: phtread_t 线程id
** 说明: 本函数实际没有做什么,就是深拷贝了args给ptr,然后创建了一个调用load_thread()函数的线程并返回线程id
*/
pthread_t load_data_in_thread(load_args args)
{
pthread_t thread;
// 同样第一件事深拷贝了args给ptr(为什么每次都要做这一步呢?求指点啊~)
struct load_args* ptr = (load_args*)xcalloc(1, sizeof(struct load_args));
*ptr = args;
// 创建一个线程,读入相应数据,绑定load_thread()函数到该线程上,第四个参数是load_thread()的输入参数,第二个参数表示线程属性,设置为0(即NULL)
if(pthread_create(&thread, 0, load_thread, ptr)) error("Thread creation failed");
return thread;
}
load_data_detection()
函数也定义在src/data.c
中,带注释的代码如下:
/*
** 可以参考,看一下对图像进行jitter处理的各种效果:
** https://github.com/vxy10/ImageAugmentation
** 从所有训练图片中,随机读取n张,并对这n张图片进行数据增强,同时矫正增强后的数据标签信息。最终得到的图片的宽高为w,h(原始训练集中的图片尺寸不定),也就是网络能够处理的图片尺寸,
** 数据增强包括:对原始图片进行宽高方向上的插值缩放(两方向上缩放系数不一定相同),下面称之为缩放抖动;随机抠取或者平移图片(位置抖动);
** 在hsv颜色空间增加噪声(颜色抖动);左右水平翻转,不含旋转抖动。
** 输入: n 一个线程读入的图片张数(详见函数内部注释)
** paths 所有训练图片所在路径集合,是一个二维数组,每一行对应一张图片的路径(将在其中随机取n个)
** m paths的行数,也即训练图片总数
** w 网络能够处理的图的宽度(也就是输入图片经过一系列数据增强、变换之后最终输入到网络的图的宽度)
** h 网络能够处理的图的高度(也就是输入图片经过一系列数据增强、变换之后最终输入到网络的图的高度)
** c 用来指定训练图片的通道数(默认为3,即RGB图)
** boxes 每张训练图片最大处理的矩形框数(图片内可能含有更多的物体,即更多的矩形框,那么就在其中随机选择boxes个参与训练,具体执行在fill_truth_detection()函数中)
** classes 类别总数,本函数并未用到(fill_truth_detection函数其实并没有用这个参数)
** use_flip 是否使用水平翻转
** use_mixup 是否使用mixup数据增强
** jitter 这个参数为缩放抖动系数,就是图片缩放抖动的剧烈程度,越大,允许的抖动范围越大(所谓缩放抖动,就是在宽高上插值缩放图片,宽高两方向上缩放的系数不一定相同)
** hue 颜色(hsv颜色空间)数据增强参数:色调(取值0度到360度)偏差最大值,实际色调偏差为-hue~hue之间的随机值
** saturation 颜色(hsv颜色空间)数据增强参数:色彩饱和度(取值范围0~1)缩放最大值,实际为范围内的随机值
** exposure 颜色(hsv颜色空间)数据增强参数:明度(色彩明亮程度,0~1)缩放最大值,实际为范围内的随机值
** mini_batch 和目标跟踪有关,这里不关注
** track 和目标跟踪有关,这里不关注
** augment_speed 和目标跟踪有关,这里不关注
** letter_box 是否进行letter_box变换
** show_imgs
** 返回: data类型数据,包含一个线程读入的所有图片数据(含有n张图片)
** 说明: 最后四个参数用于数据增强,主要对原图进行缩放抖动,位置抖动(平移)以及颜色抖动(颜色值增加一定噪声),抖动一定程度上可以理解成对图像增加噪声。
** 通过对原始图像进行抖动,实现数据增强。最后三个参数具体用法参考本函数内调用的random_distort_image()函数
** 说明2:从此函数可以看出,darknet对训练集中图片的尺寸没有要求,可以是任意尺寸的图片,因为经该函数处理(缩放/裁剪)之后,
** 不管是什么尺寸的照片,都会统一为网络训练使用的尺寸
*/
data load_data_detection(int n, char **paths, int m, int w, int h, int c, int boxes, int classes, int use_flip, int use_blur, int use_mixup, float jitter,
float hue, float saturation, float exposure, int mini_batch, int track, int augment_speed, int letter_box, int show_imgs)
{
const int random_index = random_gen();
c = c ? c : 3;
char **random_paths;
char **mixup_random_paths = NULL;
// paths包含所有训练图片的路径,get_random_paths函数从中随机提出n条,即为此次读入的n张图片的路径
if(track) random_paths = get_sequential_paths(paths, n, m, mini_batch, augment_speed);
else random_paths = get_random_paths(paths, n, m);
assert(use_mixup < 2);
int mixup = use_mixup ? random_gen() % 2 : 0;
//printf("\n mixup = %d \n", mixup);
// 如果使用mixup策略,需要再随机取出n条数据,即n张图片
if (mixup) {
if (track) mixup_random_paths = get_sequential_paths(paths, n, m, mini_batch, augment_speed);
else mixup_random_paths = get_random_paths(paths, n, m);
}
int i;
// 初始化为0,清空内存中之前的旧值
data d = { 0 };
d.shallow = 0;
// 一次读入的图片张数:d.X中每行就是一张图片的数据,因此d.X.cols等于h*w*3
// n = net.batch * net.subdivisions * ngpus,net中的subdivisions这个参数暂时还没搞懂有什么用,
// 从parse_net_option()函数可知,net.batch = net.batch / net.subdivision,等号右边的那个batch就是
// 网络配置文件.cfg中设置的每个batch的图片数量,但是不知道为什么多了subdivision这个参数?总之,
// net.batch * net.subdivisions又得到了在网络配置文件中设定的batch值,然后乘以ngpus,是考虑多个GPU实现数据并行,
// 一次读入多个batch的数据,分配到不同GPU上进行训练。在load_threads()函数中,又将整个的n仅可能均匀的划分到每个线程上,
// 也就是总的读入图片张数为n = net.batch * net.subdivisions * ngpus,但这些图片不是一个线程读完的,而是分配到多个线程并行读入,
// 因此本函数中的n实际不是总的n,而是分配到该线程上的n,比如总共要读入128张图片,共开启8个线程读数据,那么本函数中的n为16,而不是总数128
d.X.rows = n;
//d.X为一个matrix类型数据,其中d.X.vals是其具体数据,是指针的指针(即为二维数组),此处先为第一维动态分配内存
d.X.vals = (float**)xcalloc(d.X.rows, sizeof(float*));
d.X.cols = h*w*c;
float r1 = 0, r2 = 0, r3 = 0, r4 = 0, r_scale;
float dhue = 0, dsat = 0, dexp = 0, flip = 0;
int augmentation_calculated = 0;
// d.y存储了所有读入照片的标签信息,每条标签包含5条信息:类别,以及矩形框的x,y,w,h
// boxes为一张图片最多能够处理(参与训练)的矩形框的数(如果图片中的矩形框数多于这个数,那么随机挑选boxes个,这个参数仅在parse_region以及parse_detection中出现,好奇怪?
// 在其他网络解析函数中并没有出现)。同样,d.y是一个matrix,make_matrix会指定y的行数和列数,同时会为其第一维动态分配内存
d.y = make_matrix(n, 5 * boxes);
int i_mixup = 0;
for (i_mixup = 0; i_mixup <= mixup; i_mixup++) {
if (i_mixup) augmentation_calculated = 0;
for (i = 0; i < n; ++i) {
float *truth = (float*)xcalloc(5 * boxes, sizeof(float));
char *filename = (i_mixup) ? mixup_random_paths[i] : random_paths[i];
//读入原始的图片
image orig = load_image(filename, 0, 0, c);
// 原始图像长宽
int oh = orig.h;
int ow = orig.w;
// 缩放抖动大小:缩放抖动系数乘以原始图宽高即得像素单位意义上的缩放抖动
int dw = (ow*jitter);
int dh = (oh*jitter);
if (!augmentation_calculated || !track)
{
augmentation_calculated = 1;
r1 = random_float();
r2 = random_float();
r3 = random_float();
r4 = random_float();
r_scale = random_float();
dhue = rand_uniform_strong(-hue, hue);
dsat = rand_scale(saturation);
dexp = rand_scale(exposure);
flip = use_flip ? random_gen() % 2 : 0;
}
int pleft = rand_precalc_random(-dw, dw, r1);
int pright = rand_precalc_random(-dw, dw, r2);
int ptop = rand_precalc_random(-dh, dh, r3);
int pbot = rand_precalc_random(-dh, dh, r4);
// 这个系数没用到
float scale = rand_precalc_random(.25, 2, r_scale); // unused currently
if (letter_box)
{
float img_ar = (float)ow / (float)oh; //原始图像宽高比
float net_ar = (float)w / (float)h; //输入到网络要求的图像宽高比
float result_ar = img_ar / net_ar; //两者求比值来判断如何进行letter_box缩放
//printf(" ow = %d, oh = %d, w = %d, h = %d, img_ar = %f, net_ar = %f, result_ar = %f \n", ow, oh, w, h, img_ar, net_ar, result_ar);
if (result_ar > 1) // sheight - should be increased
{
float oh_tmp = ow / net_ar;
float delta_h = (oh_tmp - oh) / 2;
ptop = ptop - delta_h;
pbot = pbot - delta_h;
//printf(" result_ar = %f, oh_tmp = %f, delta_h = %d, ptop = %f, pbot = %f \n", result_ar, oh_tmp, delta_h, ptop, pbot);
}
else // swidth - should be increased
{
float ow_tmp = oh * net_ar;
float delta_w = (ow_tmp - ow) / 2;
pleft = pleft - delta_w;
pright = pright - delta_w;
//printf(" result_ar = %f, ow_tmp = %f, delta_w = %d, pleft = %f, pright = %f \n", result_ar, ow_tmp, delta_w, pleft, pright);
}
}
// 以下步骤就是执行了letter_box变换
int swidth = ow - pleft - pright;
int sheight = oh - ptop - pbot;
float sx = (float)swidth / ow;
float sy = (float)sheight / oh;
image cropped = crop_image(orig, pleft, ptop, swidth, sheight);
float dx = ((float)pleft / ow) / sx;
float dy = ((float)ptop / oh) / sy;
// resize到指定大小
image sized = resize_image(cropped, w, h);
// 翻转
if (flip) flip_image(sized);
//随机对图像jitter(在hsv三个通道上添加扰动),实现数据增强
distort_image(sized, dhue, dsat, dexp);
//random_distort_image(sized, hue, saturation, exposure);
// truth包含所有图像的标签信息(包括真实类别与位置
// 因为对原始图片进行了数据增强,其中的平移抖动势必会改动每个物体的矩形框标签信息(主要是矩形框的像素坐标信息),需要根据具体的数据增强方式进行相应矫正
// 后面的参数就是用于数据增强后的矩形框信息矫正
fill_truth_detection(filename, boxes, truth, classes, flip, dx, dy, 1. / sx, 1. / sy, w, h);
if (i_mixup) {
image old_img = sized;
old_img.data = d.X.vals[i];
//show_image(sized, "new");
//show_image(old_img, "old");
//wait_until_press_key_cv();
// 做mixup,混合系数为0.5
blend_images(sized, 0.5, old_img, 0.5);
// 标签也要对应改变
blend_truth(truth, boxes, d.y.vals[i]);
free_image(old_img);
}
d.X.vals[i] = sized.data;
memcpy(d.y.vals[i], truth, 5 * boxes * sizeof(float));
if (show_imgs)// && i_mixup)
{
char buff[1000];
sprintf(buff, "aug_%d_%d_%s_%d", random_index, i, basecfg(filename), random_gen());
int t;
for (t = 0; t < boxes; ++t) {
box b = float_to_box_stride(d.y.vals[i] + t*(4 + 1), 1);
if (!b.x) break;
int left = (b.x - b.w / 2.)*sized.w;
int right = (b.x + b.w / 2.)*sized.w;
int top = (b.y - b.h / 2.)*sized.h;
int bot = (b.y + b.h / 2.)*sized.h;
draw_box_width(sized, left, top, right, bot, 1, 150, 100, 50); // 3 channels RGB
}
save_image(sized, buff);
if (show_imgs == 1) {
show_image(sized, buff);
wait_until_press_key_cv();
}
printf("\nYou use flag -show_imgs, so will be saved aug_...jpg images. Press Enter: \n");
//getchar();
}
free_image(orig);
free_image(cropped);
free(truth);
}
}
free(random_paths);
if (mixup_random_paths) free(mixup_random_paths);
return d;
}
#endif // OPENCV
在src/detector.c
中的的train_detector()
函数共有3
次调用load_data(args)
,第一次调用是为训练阶段做好数据准备工作,充分利用这段时间来加载数据。第二次调用是在resize
操作中,可以看到这里只有random
和count
同时满足条件的情况下会做resize
操作,也就是说resize
加载的数据是未进行resize
过的,因此,需要调整args
中的图像宽高之后再重新调用load_data(args)
加载数据。反之,不做任何处理,之前加载的数据仍然可用。第三次调用就是在数据加载完成后,将加载好的数据保存起来train=buffer
; 然后开始下一次的加载工作。这一次的数据就会进行这一次的训练操作(调用train_network
函数)。
本节从源码角度分析了DarkNet如何加载数据进行训练的详细步骤。相信结合前2节,你已经知道DarkNet是如何构建网络模型,并加载数据训练一个检测器模型的了。
欢迎关注GiantPandaCV, 在这里你将看到独家的深度学习分享,坚持原创,每天分享我们学习到的新鲜知识。( • ̀ω•́ )✧
有对文章相关的问题,或者想要加入交流群,欢迎添加BBuf微信: