LoginSignup
9
12

More than 3 years have passed since last update.

YOLOv3のソースコードを読み解く ~detector train編~ ①

Last updated at Posted at 2019-11-04

はじめに

YOLOv3を使ってて、Cのインターフェイスを直接触ったりする中で、中の動きがどうなっているんだろうという興味を持ちました。なので、中でどんな処理がおこなわれているかを読み解いてみたいと思います。

今回の対象

以下のようなコマンドを実行したときに動作する処理を読み解いていきます。

./darknet detector train data/coco.data cfg/yolov3-voc.cfg models/darknet53.conv.74

darknet の 引数にdetector train を指定した場合、つまり、訓練の場合です。

引用元

下記に配置してあるソースコード(2019年11月2日時点)をもとに読み解きます。
https://github.com/pjreddie/darknet

main

yolov3は主にC言語で記述されています。
コマンド実行で最初に動作する関数はmain関数なので、そこから見ていきましょう。

main 関数の定義です。

darknet.c(main)
int main(int argc, char **argv)
{

いつものやつですね。続きはどうなっているかというと、

darknet.c(mainつづき)

    //test_resize("data/bad.jpg");
    //test_box();
    //test_convolutional_layer();

コメントが残っています。何かの実験でしょうか。
実際には動作しませんが、こういうのは職業柄とっても気になります。なので、少し見てみましょう。
まず、test_resizeですが、image.cに定義があります。

image.c(test_resize)
void test_resize(char *filename)
{
    image im = load_image(filename, 0,0, 3);
    float mag = mag_array(im.data, im.w*im.h*im.c);
    printf("L2 Norm: %f\n", mag);

指定されたファイル名の画像を読み込んでimにセットしています。次に、mag_arrayを呼び出しています。
次の行のprintf("L2 Norm: %f\n", mag);を見る限りでは、L2ノルムを計算する関数のようです。ということで見てみましょう。

utils.c(main)
float mag_array(float *a, int n)
{
    int i;
    float sum = 0;
    for(i = 0; i < n; ++i){
        sum += a[i]*a[i];   
    }
    return sqrt(sum);
}

指定されたfloatの配列を一つずつ取り出し、2乗して足し合わせ、最後にルートをとっています。
Lpノルムの定義は、

\| {\bf x} \|_p = (\ |x_1|^p + |x_2|^p + \cdots + |x_n|^p )^{1/p}

なので、L2ノルムの計算のようです。計算結果をmagに入れています。
次に、grayscale_imageでグレイスケールイメージを取得しています。

image.c(test_resize)
    image gray = grayscale_image(im);

grayscale_imageで何をしているのかを見てみましょう。

image.c(grayscale_image)
image grayscale_image(image im)
{
    assert(im.c == 3);
    int i, j, k;
    image gray = make_image(im.w, im.h, 1);
    float scale[] = {0.299, 0.587, 0.114};
    for(k = 0; k < im.c; ++k){
        for(j = 0; j < im.h; ++j){
            for(i = 0; i < im.w; ++i){
                gray.data[i+im.w*j] += scale[k]*get_pixel(im, i, j, k);
            }
        }
    }
    return gray;
}

まず最初に、make_imageでチャネルが1の画像を作成しています。
で、そのあとRGBをグレイスケールに変換しているんですが、そのときの変換方法は、

float scale[] = {0.299, 0.587, 0.114};

この配列で指定しています。これらの数値は、ITU-R Rec BT.601で定義された変換で、JPEG の YCbCr の Y に用いられる式としても有名な代物です。

あとは、それぞれのRGBにscaleで定義した係数をかけて足してグレイスケールに変換しています。

make_imageは何をしているのでしょう。

utils.c(make_image)
image make_image(int w, int h, int c)
{
    image out = make_empty_image(w,h,c);
    out.data = calloc(h*w*c, sizeof(float));
    return out;
}

make_empty_imageでからのイメージを作成して、dataに領域を再確保しています。
ついでに、make_empty_imageですが、下記の通り、image構造体を初期化して、初期値を設定しているだけです。

utils.c(make_empty_image)
image make_empty_image(int w, int h, int c)
{
    image out;
    out.data = 0;
    out.h = h;
    out.w = w;
    out.c = c;
    return out;
}

image構造体は以下の通り定義されています。

darknet.h
typedef struct {
    int w;
    int h;
    int c;
    float *data;
} image;

まとめると、grayscale_imageでは、上記構造体にチャネル1で指定されたサイズのimageを作成し、指定された画像のRGBをBT.601でグレースケールに変換して設定して返却しています。
この変換で、
 ⇒ 
と変換されます。グレーになりましたね。
この処理をまねれば、ライブラリとかを使わなくても自作のグレースケール関数が簡単に作れます。

test_resizeに戻りましょう。

image.c(test_resize)
    image c1 = copy_image(im);
    image c2 = copy_image(im);
    image c3 = copy_image(im);
    image c4 = copy_image(im);
    distort_image(c1, .1, 1.5, 1.5);
    distort_image(c2, -.1, .66666, .66666);
    distort_image(c3, .1, 1.5, .66666);
    distort_image(c4, .1, .66666, 1.5);

copy_imageでイメージをコピーして、distort_imageを呼び出しています。
ソースコードを見てみましょう。

image.c(distort_image)
void distort_image(image im, float hue, float sat, float val)
{
    rgb_to_hsv(im);
    scale_image_channel(im, 1, sat);
    scale_image_channel(im, 2, val);
    int i;
    for(i = 0; i < im.w*im.h; ++i){
        im.data[i] = im.data[i] + hue;
        if (im.data[i] > 1) im.data[i] -= 1;
        if (im.data[i] < 0) im.data[i] += 1;
    }
    hsv_to_rgb(im);
    constrain_image(im);
}

まずは、rgb_to_hsvでRGBからHSVへ変換を行っています。関数の定義を見ると、URLの参照が書いてあります。

image.c(rgb_to_hsv)
// http://www.cs.rit.edu/~ncs/color/t_convert.html
void rgb_to_hsv(image im)

Color Conversion Algorithms
というもののURLのようです。興味がある方は上記リンク先を参照してみてください。

HSVとは、色相(Hue)、彩度(Saturation・Chroma)、明度(Value・Brightness)の三つの成分からなる色空間のことで、頭文字をとってHSVモデルと呼ばれています。

次に、scale_image_channelsatvalを引数に呼び出しています。satvalは、HSVのSとVに対応しています。

image.c(scale_image_channel)
void scale_image_channel(image im, int c, float v)
{
    int i, j;
    for(j = 0; j < im.h; ++j){
        for(i = 0; i < im.w; ++i){
            float pix = get_pixel(im, i, j, c);
            pix = pix*v;
            set_pixel(im, i, j, c, pix);
        }
    }
}

指定されたチャネルに指定された値をかけて再設定しています。
その後、画像の幅と高さ分、forループを回し、色相を変換しています。
それぞれの変換結果は以下の通りになります。
c1 c2 c3 c4はそれぞれ
 
 
という風に変換されます。

c1とc4は、明度を1.5倍にしているので、結構明るめになっています。そのほかも、色相や彩度を変換しています。
以上で、test_resizeの読み解きは完了です。
次は、test_boxなのですが、このままだといつまでたっても本題に入れないので、コメント部分の読み解きは少し横に置いておいて、mainの続きを読み解いていきます。

つづきを見てみましょう。
main関数に渡された引数が2未満の場合は、標準エラー出力に使い書いたを出力して終了しています。

darknet.c(mainつづき)
    if(argc < 2){
        fprintf(stderr, "usage: %s <function>\n", argv[0]);
        return 0;
    }

ここから引数の解析が始まります。
まずは、-iで指定された引数をfind_int_argで取得しています。

darknet.c(mainつづき)
    gpu_index = find_int_arg(argc, argv, "-i", 0);
    if(find_arg(argc, argv, "-nogpu")) {
        gpu_index = -1;
    }

find_int_argは、第3引数で指定されたパラメータの次の値を取得して数値で返却する関数です。

utils.c(find_int_arg)
int find_int_arg(int argc, char **argv, char *arg, int def)
{
    int i;
    for(i = 0; i < argc-1; ++i){
        if(!argv[i]) continue;
        if(0==strcmp(argv[i], arg)){
            def = atoi(argv[i+1]);
            del_arg(argc, argv, i);
            del_arg(argc, argv, i);
            break;
        }
    }
    return def;
}

argvの2番目からargc-1番目までの引数と指定された値をstrcmpで比較して、その次の値をatoiで数値に返却して返しています。
del_argでは指定されたインデックスからの配列へ、それ以降の配列をシフトして、最後の項目にNULLを設定しています。

utils.c(del_arg)
void del_arg(int argc, char **argv, int index)
{
    int i;
    for(i = index; i < argc-1; ++i) argv[i] = argv[i+1];
    argv[i] = 0;
}

次回からはif(!argv[i]) continueの部分で一度見た配列の項目は参照されないようにしています。

次に、GPUが、ビルドの際に指定されているかどうかを判定しています。

darknet.c(mainつづき)
#ifndef GPU
    gpu_index = -1;
#else
    if(gpu_index >= 0){
        cuda_set_device(gpu_index);
    }
#endif

GPUビルドではない場合は、gpu_indexを-1に設定しており、GPUビルドの場合で、先ほど引数から取得したgpu_indexが0以上の場合は、cuda_set_deviceでGPUデバイスのインデックスを設定しています。

まずは、GPU指定ですが、Makefileの下記の部分で、設定を1にするか0にするかで変更します。デフォルトは0になっています。

Makefile
GPU=0
CUDNN=0
OPENCV=0
OPENMP=0
DEBUG=0

GPUに1を設定した場合は、メイクの際、下記部分でGPUが定義され、#ifndef GPUがfalseになります。-DGPUの部分です。

Makefile
ifeq ($(GPU), 1) 
COMMON+= -DGPU -I/usr/local/cuda/include/
CFLAGS+= -DGPU
LDFLAGS+= -L/usr/local/cuda/lib64 -lcuda -lcudart -lcublas -lcurand
endif

cuda_set_deviceは以下のようになっています。

cuda.c(cuda_set_device)
void cuda_set_device(int n)
{
    gpu_index = n;
    cudaError_t status = cudaSetDevice(n);
    check_error(status);
}

cudaSetDeviceの定義は、リンク先を参照してください。GPUの実行で使用されるデバイスを設定するための関数です。

次に、引数の2番目をstrcmpで判定しています。ここでやっとこさ、detector指定の場合に進むことができます。
下のコードの、5個目の比較のdetectorの部分でrun_detectorが呼び出されています。今回のテーマ的に、その部分を読み進めていきましょう。

darknet.c(mainつづきの一部)

    } else if (0 == strcmp(argv[1], "detector")){
        run_detector(argc, argv);

run_detector

run_detectorはdetector.cに定義された関数です。
引数を解析し、引数によって次に呼び出す関数を決定し引数を渡しています。

今回のtrainの場合は、train_detectorが呼び出されます。

detector.c(train_detector)
    else if(0==strcmp(argv[2], "train")) train_detector(datacfg, cfg, weights, gpus, ngpus, clear);

その時の引数はdatacfg, cfg, weights, gpus, ngpus, clearの6個が指定されています。
その部分の処理をtrain_detectorから抜き出すと、

detector.c(train_detector順不同抜粋)
    char *datacfg = argv[3];
    char *cfg = argv[4];
    char *weights = (argc > 5) ? argv[5] : 0;
    char *gpu_list = find_char_arg(argc, argv, "-gpus", 0);
    int *gpus = 0;
    int gpu = 0;
    int ngpus = 0;
    if(gpu_list){
        printf("%s\n", gpu_list);
        int len = strlen(gpu_list);
        ngpus = 1;
        int i;
        for(i = 0; i < len; ++i){
            if (gpu_list[i] == ',') ++ngpus;
        }
        gpus = calloc(ngpus, sizeof(int));
        for(i = 0; i < ngpus; ++i){
            gpus[i] = atoi(gpu_list);
            gpu_list = strchr(gpu_list, ',')+1;
        }
    } else {
        gpu = gpu_index;
        gpus = &gpu;
        ngpus = 1;
    }
    int clear = find_arg(argc, argv, "-clear");

の部分です。datacfgcfgweightsは第3引数と第4引数と第5引数から取得しています。
gpusngpusに関しては、-gpusの指定がある場合は、カンマ区切りで指定されたGPUの番号を配列に設定しなおして、その数をngpusに設定しています。指定がない場合は、gpu_idnexを設定しています。
clearに関しては、「Adding -clear 1 at the end of your training command will clear the stats of how many images this model has seen in previous training. Then you can fine-tune your model on new data(set).」という効果があるようです。実際には、ソースコードを参照するときに詳しく見てみましょう。

train_detector

訓練をします。
まず最初にdataを読み込んでいます。これは、最初の例に書いたコマンドのdata/coco.dataファイルの読み込みです。

detector.c(train_detector)
void train_detector(char *datacfg, char *cfgfile, char *weightfile, int *gpus, int ngpus, int clear)
{
    list *options = read_data_cfg(datacfg);

返却値はlist構造体です。

darknet.h
typedef struct list{
    int size;
    node *front;
    node *back;
} list;

typedef struct node{
    void *val;
    struct node *next;
    struct node *prev;
} node;

list構造体内ではnode構造体を内部で利用しています。リストのサイズと前後のnodeを管理しています。nodeは前後のノードと値を管理しています。

read_data_cfgoption_list.cで定義されています。

option_list.c
list *read_data_cfg(char *filename)
{
    FILE *file = fopen(filename, "r");
    if(file == 0) file_error(filename);
    char *line;
    int nu = 0;
    list *options = make_list();
    while((line=fgetl(file)) != 0){
        ++ nu;
        strip(line);
        switch(line[0]){
            case '\0':
            case '#':
            case ';':
                free(line);
                break;
            default:
                if(!read_option(line, options)){
                    fprintf(stderr, "Config file error line %d, could parse: %s\n", nu, line);
                    free(line);
                }
                break;
        }
    }
    fclose(file);
    return options;
}

まず指定されたファイルを開いて、からのリストを作成しています。ファイルを一行ずつ読み込み前処理としてstripで空白文字を除去しています。
case '\0':case '#':case ';':で空行とコメントを無視し、read_optionoptionsリストに、読み込んだオプションを設定しています。

option_list.c
int read_option(char *s, list *options)
{
    size_t i;
    size_t len = strlen(s);
    char *val = 0;
    for(i = 0; i < len; ++i){
        if(s[i] == '='){
            s[i] = '\0';
            val = s+i+1;
            break;
        }
    }
    if(i == len-1) return 0;
    char *key = s;
    option_insert(options, key, val);
    return 1;
}

read_optionは、=で文字列を分割し、その前をキーとして、その後ろを値として取得してoption_insertに渡しています。option_insert内では、kvpという構造体に、キーと値を設定して、optionsリストにインサートしています。

まとめると、data/coco.dataファイルは、一行ずつ処理され、=の前後で分割されたうえでkvp構造体に設定されてoptionsリストに追加されています。

続いて、先ほど取得し解析したoptionsリストから、trainというキーの値と、backupというキーの値をoption_find_str関数で取得しています。

detector.c(train_detectorつづき)
    char *train_images = option_find_str(options, "train", "data/train.list");
    char *backup_directory = option_find_str(options, "backup", "/backup/");

option_find_str関数は、内部でoption_find関数を呼び出しています。値が見つからなかった場合は第3引数に指定されたデフォルト値を返却しています。

option_find.c
char *option_find_str(list *l, char *key, char *def)
{
    char *v = option_find(l, key);
    if(v) return v;
    if(def) fprintf(stderr, "%s: Using default '%s'\n", key, def);
    return def;
}

デフォルトを使用するときは、標準エラー出力にその旨を通知していますね。親切設計です。

続いて、srandでランダム関数を初期化しています。

detector.c(train_detectorつづき)
    srand(time(0));

ちなみに、srand関数はrand関数の擬似乱数の発生系列を変更する関数です。この発生系列を変更しないと、rand関数では毎回同じ乱数を発生させてしまいます。そこで、time(0)を使って現在時刻で発生系列を初期化することにより、毎回違った乱数を発生させることができるようにしています。

続いて、basecfg関数で、設定ファイルの拡張子抜きファイル名を取得しています。最初のコマンドの例で行くと、cfg/yolov3-voc.cfgのうちのyolov3-vocの部分です。取得した文字列をbaseに設定しています。

detector.c(train_detectorつづき)
    char *base = basecfg(cfgfile);
    printf("%s\n", base);

avg_lossを-1で初期化し、callocnetwork構造体のサイズの領域をngpusで指定されたGPUの数分だけ確保し、netsに設定しています。

detector.c(train_detectorつづき)
    float avg_loss = -1;
    network **nets = calloc(ngpus, sizeof(network));

network構造体は、こんな構造です。かなり長いです。GPUの数だけこの構造体を作っていますが、どうやってこれらを使用するのかが楽しみです。

darknet.h
typedef struct network{
    int n;
    int batch;
    size_t *seen;
    int *t;
    float epoch;
    int subdivisions;
    layer *layers;
    float *output;
    learning_rate_policy policy;

    float learning_rate;
    float momentum;
    float decay;
    float gamma;
    float scale;
    float power;
    int time_steps;
    int step;
    int max_batches;
    float *scales;
    int   *steps;
    int num_steps;
    int burn_in;

    int adam;
    float B1;
    float B2;
    float eps;

    int inputs;
    int outputs;
    int truths;
    int notruth;
    int h, w, c;
    int max_crop;
    int min_crop;
    float max_ratio;
    float min_ratio;
    int center;
    float angle;
    float aspect;
    float exposure;
    float saturation;
    float hue;
    int random;

    int gpu_index;
    tree *hierarchy;

    float *input;
    float *truth;
    float *delta;
    float *workspace;
    int train;
    int index;
    float *cost;
    float clip;

#ifdef GPU
    float *input_gpu;
    float *truth_gpu;
    float *delta_gpu;
    float *output_gpu;
#endif

} network;

続いて、ここでもう一度srandを呼び出しています。大事なことだから2回やっているのでしょうか。で、seedrand関数で乱数を設定しています。rand関数は、0~RAND_MAXまでの整数の乱数を発生させます。
for文で、ngpusで指定されたGPUの数だけ処理を実行しています。最初に、先ほど発生させた乱数を引数にsrand関数を読みだして、擬似乱数の発生系列を変更しています。この処理が何故なされるのかはよくわかりません。

detector.c(train_detectorつづき)
    srand(time(0));
    int seed = rand();
    int i;
    for(i = 0; i < ngpus; ++i){
        srand(seed);
#ifdef GPU
        cuda_set_device(gpus[i]);
#endif
        nets[i] = load_network(cfgfile, weightfile, clear);
        nets[i]->learning_rate *= ngpus;
    }

cuda_set_deviceで、コマンド実行時に-gpusで指定されたGPUをセットしています。

つづく

ネットワークの読み込み処理等、段々と本質に向かってきたので、いったんここで切ります。
コードを読んでいると、グレイスケールの作り方や、リストの持ち方などいろいろと勉強になります。
この辺りを詳しく知りたいとかあったらコメントください。

YOLOv3のソースコードを読み解く ~detector train編~ ②に続きます。

9
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
12