系列目录
darknet 源码阅读(零) - Entry Point
darknet 源码阅读(一) - 解析网络配置文件 cfg
darknet 源码阅读(二) - 加载训练样本数据
darknet 源码阅读(三) - 训练网络
darknet 源码阅读(番外篇一) - 卷积层
本文主要围绕 load_data(args) 函数来分析 darknet 是如何加载训练样本数据的.
函数定义在文件 darknet/src/data.c 中. 完整代码参考: https://github.com/pjreddie/darknet/blob/master/src/data.c.
load_data() 函数调用流程如下:
load_data(args) -> load_threads() -> load_data_in_thread() ->
load_thread() -> load_data_detection()
前面的四个函数都是在对线程的调用进行封装, 主要是分配每个线程的加载任务量. 最底层的数据加载任务由 load_data_detection() 函数完成. 所有的数据(图片数据和标注信息数据)加载完成之后再拼接到一个大的数组中.
在 darknet 中, 图片的存储形式是一个行向量, 向量长度为 hw3. 同时图片数据被归一化到 0-1 之间.
1. 线程分配和数据拼接
load_threads() 函数, 扮演加载阶段指挥官的角色.
- 根据线程个数平均分配加载任务;
- 在所有的子线程将数据加载完成后整合数据;
void *load_threads(void *ptr)
{
int i;
load_args args = *(load_args *)ptr;
if (args.threads == 0) args.threads = 1;
data *out = args.d;
int total = args.n;
free(ptr);
data *buffers = calloc(args.threads, sizeof(data));
pthread_t *threads = calloc(args.threads, sizeof(pthread_t));
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);
}
for(i = 0; i < args.threads; ++i){
pthread_join(threads[i], 0);
}
*out = concat_datas(buffers, args.threads); // 为什么要把图片数据堆叠起来?
out->shallow = 0;
for(i = 0; i < args.threads; ++i){
buffers[i].shallow = 1;
free_data(buffers[i]);
}
free(buffers);
free(threads);
return 0;
}
2. 劳动人民
load_data_detection() 函数完成最底层的数据加载任务, 这里先贴出函数的完整代码, 接下来详细分析这个函数的功能.
data load_data_detection(int n, char **paths, int m,
int w, int h, int boxes, int classes,
float jitter,
float hue, float saturation,
float exposure)
{
char **random_paths = get_random_paths(paths, n, m); // 随机选取 n 张图片的路径
int i;
data d = {0};
d.shallow = 0;
d.X.rows = n; // 一个线程加载的样本数量
d.X.vals = calloc(d.X.rows, sizeof(float*));
d.X.cols = h*w*3; // 默认是彩色图像
d.y = make_matrix(n, 5*boxes);
for(i = 0; i < n; ++i){
image orig = load_image_color(random_paths[i], 0, 0); // 读取图片数据
image sized = make_image(w, h, orig.c);
fill_image(sized, .5);
float dw = jitter * orig.w;
float dh = jitter * orig.h;
float new_ar = (orig.w + rand_uniform(-dw, dw)) / (orig.h + rand_uniform(-dh, dh));
float scale = rand_uniform(.25, 2);
float nw, nh;
if(new_ar < 1){
nh = scale * h;
nw = nh * new_ar;
} else {
nw = scale * w;
nh = nw / new_ar;
}
float dx = rand_uniform(0, w - nw);
float dy = rand_uniform(0, h - nh);
place_image(orig, nw, nh, dx, dy, sized);
random_distort_image(sized, hue, saturation, exposure);
int flip = rand()%2;
if(flip) flip_image(sized);
d.X.vals[i] = sized.data;
fill_truth_detection(random_paths[i], boxes, d.y.vals[i], classes, flip, -dx/w, -dy/h, nw/w, nh/h);
free_image(orig);
}
free(random_paths);
return d;
}
再详细的划分, 加载任务可以分为两部分: 加载图像数据和标注信息.
2.1 加载图像数据
2.1.1 读取图片后转换数据格式为 image
根据图片路径加载图像数据, darknet 内部使用结构体 image 保存图像数据. 而使用 OpenCV 读取得到的图像数据格式为 IPL, 因此需要将图片格式转换为 darknet 的 image 结构体类型.
其中, image 结构体的定义如下:
typedef struct {
int w;
int h;
int c;
float *data;
} image;
图片数据格式转换的具体实现可参考函数: ipl_into_image().
void ipl_into_image(IplImage* src, image im)
{
unsigned char *data = (unsigned char *)src->imageData;
int h = src->height;
int w = src->width;
int c = src->nChannels;
int step = src->widthStep;
int i, j, k;
for(k= 0; k < c; ++k){
for(i = 0; i < h; ++i){
for(j = 0; j < w; ++j){
im.data[k*w*h + i*w + j] = data[i*step + j*c + k]/255.;
}
}
}
}
前面说过, darknet 中 image 结构体中的图片数据保存在一个一维数组中, 那图片数据的行列通道是怎么安排的?
先看看 OpenCV 中的图片数据存储格式. 使用 cvLoadImage() 函数将图片数据读取到内存中后, 其存储形式如下图所示:
而 image 结构体中的数据保存格式为:
接下来如果需要调整通道顺序, 例如从 BGR 到 RGB, 只需要交换首尾两个位置对应的像素即可.
注意:
除了 OpenCV 之外,还有一种常见的图像库: stbi. 它对于图片在内存中的存储格式和 OpenCV 的存储格式相同.
2.1.1 数据 resize 操作细节
place_image() 函数究竟做了什么? 对图像执行 resize 操作. resize 的策略是二次线性插值方法, 但是该函数之前的其他操作也是值得研究的.
(1) 新建一个 resize 的图像, 尺寸为 w*h, 并将所有像素初始化为 0.5;
(2) 对原始图像的宽高(orig.w, orig.h)使用 jitter 进行抖动, 得到新的长宽比;
(3) 利用新的长宽比和缩放因子为(0.25, 2)得到缩放后的长和宽(nw, nh);
(4) 新的长宽(nw, nh)减去原来的长宽(w, h)的偏移可用来作平移增广;
这些工作之后调用 place_image() 函数, 其功能是使用插值后的图像填充 resized 的图像;
/**
* \brief: resize 策略使用的是二次线性插值.
*
* \param im: 直接读取到的图像数据;
* w: scale 之后的宽
* h: scale 之后的高
* dx: scale 前后的宽度变化的随机值
* dy: scale 前后的高度变化的随机值
*/
void place_image(image im, int w, int h, int dx, int dy, image canvas)
{
int x, y, c;
for(c = 0; c < im.c; ++c){
for(y = 0; y < h; ++y){
for(x = 0; x < w; ++x){
float rx = ((float)x / w) * im.w;
float ry = ((float)y / h) * im.h;
float val = bilinear_interpolate(im, rx, ry, c); // 二次线性插值
set_pixel(canvas, x + dx, y + dy, c, val); // 由此可以看到平移增广操作;
}
}
}
}
2.1.2 数据增广
上面提到, resize 操作中隐式地使用了平移增广操作. 而显式使用的增广操作由 HSV 亮度增广 random_distort_image() 和随机翻转增广 flip_image().
这部分操作很简单, 切换到 HSV 空间后进行增广, 操作完成后在转换到 RGB 空间即可. 代码也一目了然.
其中关于颜色空间的转换可以参考这里: http://www.cs.rit.edu/~ncs/color/t_convert.html , 其中包含了各种颜色空间之间的转换的 C 代码实现.
2.2 加载标注的 GT 信息
fill_truth_detection() 函数的主要功能有三个:
(1) 读取标注框文件;
(2) 随机打乱标注框顺序;
(3) 根据增广参数矫正框的坐标;
2.2.1 读取标注框文件: read_boxes() 函数
读取标注框文件相对比较简单, 读取后的结果保存在结构体 box_label 中, 其定义如下:
typedef struct{
int id; // 框中目标的类别 id
float x,y,w,h; // BBox 的中心坐标和宽高
float left, right, top, bottom; // BBox 的两个角点的坐标
} box_label;
其中, 标注框文件中的行数就是其对应图片中标注过的 bbox 总数目.
2.2.2 随机打乱标注框顺序: randomize_boxes()
标注人员有可能是一类一类的对目标进行标注, 这样得到的标注框文件中相同类别的标注信息会相邻排列, 因此在最终训练过程中也会被顺序训练. 对同类样本训练过多会导致 loss 波动比较大.
一般的解决方法是将标注框打乱 shuffle 后再进行训练.
2.2.3 根据增广参数矫正框的坐标: correct_boxes()
矫正操作是增广的遗留问题. 增广操作会对图像进行平移、缩放和翻转。因此,对于原图像上的这几个操作, 标注框要进行相应的调整。 原来图像向左平移的, 框自然也要随着向左平移。
注意: 参数传递时, 关于平移部分做了反向(取负)处理。
完整的 GroundTruth 加载函数如下:
void fill_truth_detection(char *path, int num_boxes,
float *truth, int classes, int flip,
float dx, float dy, float sx, float sy)
{
char labelpath[4096];
find_replace(path, "images", "labels", labelpath);
find_replace(labelpath, "JPEGImages", "labels", labelpath);
find_replace(labelpath, "raw", "labels", labelpath);
find_replace(labelpath, ".jpg", ".txt", labelpath);
find_replace(labelpath, ".png", ".txt", labelpath);
find_replace(labelpath, ".JPG", ".txt", labelpath);
find_replace(labelpath, ".JPEG", ".txt", labelpath);
int count = 0;
box_label *boxes = read_boxes(labelpath, &count);
randomize_boxes(boxes, count);
correct_boxes(boxes, count, dx, dy, sx, sy, flip);
if(count > num_boxes) count = num_boxes;
float x,y,w,h;
int id;
int i;
int sub = 0;
for (i = 0; i < count; ++i) {
x = boxes[i].x;
y = boxes[i].y;
w = boxes[i].w;
h = boxes[i].h;
id = boxes[i].id;
if ((w < .001 || h < .001)) {
++sub;
continue;
}
truth[(i-sub)*5+0] = x;
truth[(i-sub)*5+1] = y;
truth[(i-sub)*5+2] = w;
truth[(i-sub)*5+3] = h;
truth[(i-sub)*5+4] = id;
}
free(boxes);
}
该函数最后的操作是对宽度和高度非常小的框进行删除.
3. load_data(args) 的使用技巧
darknet 中关于 load_data(args) 函数的使用也是很巧妙的. 废话不多说, 直接上代码:
void train_detector(char *datacfg, char *cfgfile,
char *weightfile, int *gpus, int ngpus, int clear)
{
...
// 准备加载参数 args
load_args args = get_base_args(net);
args.n = imgs;
args.d = &buffer;
...
args.threads = 64;
// 首次创建并启动加载线程
pthread_t load_thread = load_data(args);
double time;
int count = 0; // 用于控制 Resizing 的频率
while(get_current_batch(net) < net->max_batches){
// 多尺度图像加载过程
if(l.random && count++%10 == 0){
printf("Resizing\n");
int dim = (rand() % 10 + 10) * 32; // dim 的取值范围:[320, 640], s = 32
if (get_current_batch(net)+200 > net->max_batches) dim = 608;
printf("%d\n", dim);
args.w = dim;
args.h = dim;
pthread_join(load_thread, 0); // 等待线程退出, 即数据加载完毕, 但是这次数据加载无效.
train = buffer;
free_data(train);
load_thread = load_data(args);
#pragma omp parallel for
for(i = 0; i < ngpus; ++i){
resize_network(nets[i], dim, dim);
}
net = nets[0];
}
time=what_time_is_it_now();
pthread_join(load_thread, 0);
train = buffer; // 加载完成, 拿到加载后的数据
load_thread = load_data(args); // 开启新的加载线程, 供下一次使用
/*开始训练...*/
}
}
一共有三次 load_data(args) 的调用.
第一次调用(第 13 行)容易理解, 为训练阶段做好数据准备工作; 充分利用这段时间来加载数据.
第二次调用(第 29 行)是在 Resizing 操作内. 这里涉及到一个 “数据有效性” 的问题. 如果 Resizing 操作条件成立(random 和 count 满足条件), 那么之前加载好的数据是未进行过 Resizing 操作的, 因此, 需要在调整 args 中的新图像宽高( w 和 h 变量) 之后再重新加载数据; 反之, 不做任何处理, 之前加载的数据仍然可用.
第三次调用(第 40 行). 不管是前边的第一种情况还是第二种情况, 加载数据完成后, 将加载好的数据保存起来( train = buffer;), 然后开始下一次的加载工作.
总结
以上就是 darknet 中关于数据的加载部分. 对于想要自定义数据增广的同学可以在这部分多下功夫研究修改.
各部分功能的实现都很简洁, 佩服作者的 C 语言功底…