#はじめに
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 関数の定義です。
int main(int argc, char **argv)
{
いつものやつですね。続きはどうなっているかというと、
//test_resize("data/bad.jpg");
//test_box();
//test_convolutional_layer();
コメントが残っています。何かの実験でしょうか。
実際には動作しませんが、こういうのは職業柄とっても気になります。なので、少し見てみましょう。
まず、test_resize
ですが、image.c
に定義があります。
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ノルムを計算する関数のようです。ということで見てみましょう。
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 gray = grayscale_image(im);
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
は何をしているのでしょう。
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
構造体を初期化して、初期値を設定しているだけです。
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
構造体は以下の通り定義されています。
typedef struct {
int w;
int h;
int c;
float *data;
} image;
まとめると、grayscale_image
では、上記構造体にチャネル1で指定されたサイズのimage
を作成し、指定された画像のRGBをBT.601でグレースケールに変換して設定して返却しています。
この変換で、
⇒
と変換されます。グレーになりましたね。
この処理をまねれば、ライブラリとかを使わなくても自作のグレースケール関数が簡単に作れます。
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
を呼び出しています。
ソースコードを見てみましょう。
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の参照が書いてあります。
// 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_channel
をsat
とval
を引数に呼び出しています。sat
とval
は、HSVのSとVに対応しています。
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未満の場合は、標準エラー出力に使い書いたを出力して終了しています。
if(argc < 2){
fprintf(stderr, "usage: %s <function>\n", argv[0]);
return 0;
}
ここから引数の解析が始まります。
まずは、-i
で指定された引数をfind_int_arg
で取得しています。
gpu_index = find_int_arg(argc, argv, "-i", 0);
if(find_arg(argc, argv, "-nogpu")) {
gpu_index = -1;
}
find_int_arg
は、第3引数で指定されたパラメータの次の値を取得して数値で返却する関数です。
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を設定しています。
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
が、ビルドの際に指定されているかどうかを判定しています。
#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になっています。
GPU=0
CUDNN=0
OPENCV=0
OPENMP=0
DEBUG=0
GPU
に1を設定した場合は、メイクの際、下記部分でGPU
が定義され、#ifndef GPU
がfalseになります。-DGPU
の部分です。
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
は以下のようになっています。
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
が呼び出されています。今回のテーマ的に、その部分を読み進めていきましょう。
} else if (0 == strcmp(argv[1], "detector")){
run_detector(argc, argv);
#run_detector
run_detector
はdetector.cに定義された関数です。
引数を解析し、引数によって次に呼び出す関数を決定し引数を渡しています。
今回のtrain
の場合は、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
から抜き出すと、
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");
の部分です。datacfg
とcfg
とweights
は第3引数と第4引数と第5引数から取得しています。
gpus
とngpus
に関しては、-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
ファイルの読み込みです。
void train_detector(char *datacfg, char *cfgfile, char *weightfile, int *gpus, int ngpus, int clear)
{
list *options = read_data_cfg(datacfg);
返却値はlist
構造体です。
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_cfg
は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_option
でoptions
リストに、読み込んだオプションを設定しています。
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
関数で取得しています。
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引数に指定されたデフォルト値を返却しています。
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
でランダム関数を初期化しています。
srand(time(0));
ちなみに、srand
関数はrand
関数の擬似乱数の発生系列を変更する関数です。この発生系列を変更しないと、rand
関数では毎回同じ乱数を発生させてしまいます。そこで、time(0)
を使って現在時刻で発生系列を初期化することにより、毎回違った乱数を発生させることができるようにしています。
続いて、basecfg
関数で、設定ファイルの拡張子抜きファイル名を取得しています。最初のコマンドの例で行くと、cfg/yolov3-voc.cfg
のうちのyolov3-voc
の部分です。取得した文字列をbase
に設定しています。
char *base = basecfg(cfgfile);
printf("%s\n", base);
avg_loss
を-1で初期化し、calloc
でnetwork
構造体のサイズの領域をngpus
で指定されたGPUの数分だけ確保し、nets
に設定しています。
float avg_loss = -1;
network **nets = calloc(ngpus, sizeof(network));
network
構造体は、こんな構造です。かなり長いです。GPUの数だけこの構造体を作っていますが、どうやってこれらを使用するのかが楽しみです。
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回やっているのでしょうか。で、seed
にrand
関数で乱数を設定しています。rand
関数は、0~RAND_MAXまでの整数の乱数を発生させます。
for
文で、ngpus
で指定されたGPUの数だけ処理を実行しています。最初に、先ほど発生させた乱数を引数にsrand
関数を読みだして、擬似乱数の発生系列を変更しています。この処理が何故なされるのかはよくわかりません。
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をセットしています。
#つづく
ネットワークの読み込み処理等、段々と本質に向かってきたので、いったんここで切ります。
コードを読んでいると、グレイスケールの作り方や、リストの持ち方などいろいろと勉強になります。
この辺りを詳しく知りたいとかあったらコメントください。