【AlexeyAB DarkNet框架解析】三,載入數據進行訓練
- 2020 年 2 月 21 日
- 筆記
前言
昨天講了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()完成執行緒分配和數據拼接
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()分配執行緒
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()完成底層的數據載入任務
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
load_data(args)使用方法
在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是如何構建網路模型,並載入數據訓練一個檢測器模型的了。