darknet 源码阅读(二) - 加载训练样本数据

系列目录

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 语言功底…

  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值