檢測實(shí)戰(zhàn):從訓(xùn)練到后處理全解析)
簡介這份資源面向深度學(xué)習(xí)入門者與計(jì)算機(jī)視覺開發(fā)者提供基于Keras框架實(shí)現(xiàn)YOLOv1目標(biāo)檢測算法并訓(xùn)練自定義數(shù)據(jù)集的完整項(xiàng)目包幫助讀者繞開環(huán)境搭建與代碼組織的門檻快速把個(gè)人標(biāo)注數(shù)據(jù)接入訓(xùn)練流程。壓縮包共11個(gè)文件約128KB以3個(gè)Python腳本、3個(gè)txt列表文件、4張jpg示例圖和1份md說明文檔為主腳本涵蓋網(wǎng)絡(luò)訓(xùn)練、測試與Darknet19骨干實(shí)現(xiàn)txt用于訓(xùn)練與精煉數(shù)據(jù)清單圖片展示檢測效果文檔則交代項(xiàng)目結(jié)構(gòu)與使用說明。目前已有108人學(xué)習(xí)下載。借助其中的模型定義、訓(xùn)練腳本與配置示例讀者可對照完成數(shù)據(jù)清洗、標(biāo)注劃分、超參數(shù)調(diào)整與過擬合排查并在此基礎(chǔ)上修改網(wǎng)絡(luò)結(jié)構(gòu)、優(yōu)化檢測精度適合作為YOLOv1原理學(xué)習(xí)與自定義數(shù)據(jù)集實(shí)戰(zhàn)的起步模板。1. 從一份 Keras 版 YOLOv1 源碼包說起單階段檢測的入門樣本如果你手頭正好有一份標(biāo)注好的小規(guī)模數(shù)據(jù)集想跑通一個(gè)目標(biāo)檢測模型看看效果又不想一上來就被 YOLOv5、YOLOv8 那一堆配置文件和依賴版本折騰到崩潰那這份基于 Keras 的 YOLOv1 實(shí)現(xiàn)包其實(shí)是個(gè)挺合適的起點(diǎn)。它把 YOLOv1 的整套訓(xùn)練流程——數(shù)據(jù)讀取、網(wǎng)格劃分、損失計(jì)算、模型搭建、權(quán)重保存——都塞進(jìn)了一個(gè)相對輕量的 Keras 工程里沒有復(fù)雜的分布式訓(xùn)練也沒有一堆需要編譯的自定義算子裝好 TensorFlow 就能跑。YOLOv1 本身是單階段檢測的開山之作把檢測直接建模成回歸問題一張圖過一次網(wǎng)絡(luò)就出框結(jié)構(gòu)清晰到幾乎可以手推。這份資源適合兩類人一類是想搞懂檢測模型底層邏輯、不想只當(dāng)調(diào)包俠的開發(fā)者另一類是想拿個(gè)小數(shù)據(jù)集快速驗(yàn)證標(biāo)注質(zhì)量、跑通訓(xùn)練閉環(huán)的從業(yè)者。它不追求 SOTA 精度但勝在鏈路完整、代碼可讀拿來當(dāng)檢測入門的“解剖樣本”非常合適。2. YOLOv1 的網(wǎng)格回歸邏輯與 Keras 工程結(jié)構(gòu)拆解2.1 為什么是 7×7 網(wǎng)格加兩個(gè)框YOLOv1 的核心設(shè)計(jì)思路是把一張輸入圖像切成 S×S 個(gè)網(wǎng)格原版論文里 S7。每個(gè)網(wǎng)格負(fù)責(zé)預(yù)測 B 個(gè)邊界框原版 B2外加 C 個(gè)類別的條件概率。也就是說網(wǎng)絡(luò)最后一層輸出的張量形狀是 S×S×(B×5C)其中每個(gè)框的 5 個(gè)值分別是 x、y、w、h 和置信度。x、y 是框中心相對于所在網(wǎng)格的偏移w、h 是相對于整張圖寬高的比例都被歸一化到 0 到 1 之間。這個(gè)設(shè)計(jì)的好處是輸出維度固定不管圖里有多少目標(biāo)網(wǎng)絡(luò)結(jié)構(gòu)都不用變。代價(jià)也很明顯一個(gè)網(wǎng)格只能負(fù)責(zé)一個(gè)目標(biāo)兩個(gè)框里只有一個(gè)會(huì)被分配為正樣本另一個(gè)在訓(xùn)練時(shí)被當(dāng)作負(fù)樣本壓制。所以當(dāng)兩個(gè)小目標(biāo)擠在同一個(gè)網(wǎng)格里時(shí)YOLOv1 基本就無能為力了這也是它召回率偏低的根源之一。Keras 工程里這一層邏輯通常體現(xiàn)在模型的最后一層卷積或全連接上輸出通道數(shù)正好等于 B×5C。你在讀代碼時(shí)可以重點(diǎn)看兩個(gè)地方一是模型輸出張量的 reshape 操作把扁平輸出還原成 S×S×N 的網(wǎng)格結(jié)構(gòu)二是損失函數(shù)里對坐標(biāo)、置信度、類別三部分的分項(xiàng)加權(quán)。原版損失用 λ_coord5 放大坐標(biāo)誤差用 λ_noobj0.5 壓低無目標(biāo)網(wǎng)格的置信度損失這兩個(gè)系數(shù)在 Keras 實(shí)現(xiàn)里一般會(huì)寫成常量改它們之前最好先理解各自的作用。2.2 工程目錄里每個(gè)文件在干什么拿到壓縮包解壓后常見的目錄結(jié)構(gòu)大致是這樣一個(gè)主訓(xùn)練腳本一個(gè)模型定義文件一個(gè)數(shù)據(jù)生成器一個(gè)損失函數(shù)模塊外加配置和權(quán)重保存目錄。不同作者的命名會(huì)有差異但職責(zé)劃分基本一致。下面這張表是我拆這類工程時(shí)習(xí)慣先掃一遍的清單你可以對照自己手里的包快速定位關(guān)鍵文件。文件/目錄典型職責(zé)讀代碼時(shí)的關(guān)注點(diǎn)train.py訓(xùn)練入口組裝模型、生成器、回調(diào)學(xué)習(xí)率策略、batch size、epoch 數(shù)model.py定義 YOLOv1 網(wǎng)絡(luò)結(jié)構(gòu)輸出層通道數(shù)是否等于 B×5Cdata.py數(shù)據(jù)讀取與增強(qiáng)標(biāo)注格式、歸一化方式、增強(qiáng)是否同步變換框loss.py自定義損失函數(shù)λ_coord、λ_noobj 取值正負(fù)樣本分配邏輯config.py超參數(shù)與路徑配置輸入尺寸、網(wǎng)格數(shù)、類別數(shù)、權(quán)重路徑weights/權(quán)重保存目錄保存頻率、命名規(guī)則、是否保存最優(yōu)讀這類工程有個(gè)血淚經(jīng)驗(yàn)先別急著跑訓(xùn)練先把 config 里的類別數(shù)和數(shù)據(jù)標(biāo)注里的類別對齊。很多翻車現(xiàn)場都是類別數(shù)寫錯(cuò)導(dǎo)致模型輸出通道對不上訓(xùn)練時(shí) loss 直接飆到 nan 或者一直不降。類別數(shù)、網(wǎng)格數(shù)、框數(shù)這三個(gè)參數(shù)一旦確定模型輸出維度就鎖死了改任何一個(gè)都要同步改數(shù)據(jù)生成和損失函數(shù)里的解析邏輯。2.3 數(shù)據(jù)標(biāo)注格式與生成器的對接方式Y(jié)OLOv1 訓(xùn)練需要的是每張圖對應(yīng)的標(biāo)注文件常見格式是每行一個(gè)目標(biāo)內(nèi)容為類別索引加歸一化后的 x、y、w、h。數(shù)據(jù)生成器要做的事情是把這些標(biāo)注映射到 S×S 的網(wǎng)格上生成和網(wǎng)絡(luò)輸出同形狀的目標(biāo)張量。具體來說對于每個(gè)目標(biāo)先算出它的中心落在哪個(gè)網(wǎng)格然后把這個(gè)網(wǎng)格對應(yīng)的那個(gè)框的置信度置為 1坐標(biāo)填真實(shí)值類別做 one-hot。如果兩個(gè)目標(biāo)的中心落在同一個(gè)網(wǎng)格后寫的會(huì)覆蓋先寫的這也是 YOLOv1 的固有缺陷不是代碼 bug。下面這段代碼是一個(gè)簡化的目標(biāo)張量構(gòu)造邏輯幫你理解生成器內(nèi)部在做什么import numpy as np def build_target(label_lines, S7, B2, C20): # 初始化目標(biāo)張量形狀為 S x S x (B*5 C) target np.zeros((S, S, B * 5 C)) for line in label_lines: cls, x, y, w, h line # 計(jì)算目標(biāo)中心落在哪個(gè)網(wǎng)格 grid_x int(x * S) grid_y int(y * S) # 防止邊界情況越界 grid_x min(grid_x, S - 1) grid_y min(grid_y, S - 1) # 計(jì)算相對于網(wǎng)格的偏移 x_offset x * S - grid_x y_offset y * S - grid_y # 只填第一個(gè)框第二個(gè)框保持為負(fù)樣本 target[grid_y, grid_x, 0:5] [x_offset, y_offset, w, h, 1.0] target[grid_y, grid_x, 5 cls] 1.0 return target這段邏輯里S、B、C 三個(gè)參數(shù)必須和模型輸出嚴(yán)格一致。x、y 是歸一化到 0 到 1 的中心坐標(biāo)w、h 也是歸一化后的寬高。grid_x 和 grid_y 用 int 截?cái)嗟玫骄W(wǎng)格索引偏移量則是原始?xì)w一化坐標(biāo)乘以 S 再減去網(wǎng)格索引。類別部分從第 5 個(gè)通道開始做 one-hot。實(shí)際工程里還會(huì)處理“哪個(gè)框負(fù)責(zé)哪個(gè)目標(biāo)”的匹配問題常見做法是選與真實(shí)框 IoU 最大的那個(gè)預(yù)測框作為正樣本這里為了簡化只用了第一個(gè)框。你在讀源碼時(shí)如果看到更復(fù)雜的匹配邏輯不用慌本質(zhì)就是在做這件事。3. 用 Keras 跑通自定義數(shù)據(jù)集訓(xùn)練從配置到第一個(gè) loss 下降3.1 環(huán)境準(zhǔn)備與依賴版本選擇這類 Keras 版 YOLOv1 工程對 TensorFlow 版本比較敏感。原版代碼很多是基于 TensorFlow 1.x 加獨(dú)立 Keras 寫的如果你直接裝最新的 TensorFlow 2.x大概率會(huì)遇到 tf.placeholder、session 之類的 API 報(bào)錯(cuò)。我一般會(huì)先看工程里 import 的是 keras 還是 tensorflow.keras再?zèng)Q定裝哪個(gè)版本。如果是老代碼用 TensorFlow 1.15 加 Keras 2.2 到 2.3 這一檔比較穩(wěn)如果是已經(jīng)遷到 tf.keras 的版本TensorFlow 2.4 以上都能跑但要注意自定義損失函數(shù)里的張量運(yùn)算是否用了兼容寫法。安裝命令大致如下建議用虛擬環(huán)境隔離python -m venv yolo_env source yolo_env/bin/activate # Windows 下用 yolo_env\Scripts\activate pip install tensorflow1.15.0 keras2.2.4 numpy1.16.4 opencv-python pillow這里把 numpy 鎖在 1.16.4 是因?yàn)?TensorFlow 1.15 對更高版本的 numpy 兼容性不好容易出現(xiàn) np.object 之類的棄用報(bào)錯(cuò)。opencv 和 pillow 用于數(shù)據(jù)讀取和增強(qiáng)。裝完之后先跑一個(gè) import 測試確認(rèn)沒有報(bào)錯(cuò)再往下走。如果你用的是 tf.keras 版本把 tensorflow 換成 2.x 對應(yīng)版本keras 不用單獨(dú)裝直接用 tensorflow.keras 即可。3.2 配置文件里必須改的五個(gè)參數(shù)在啟動(dòng)訓(xùn)練之前配置文件里有五個(gè)參數(shù)必須按你的數(shù)據(jù)集改改錯(cuò)任何一個(gè)都跑不起來。下面這張表列出了它們的作用和常見取值參數(shù)含義改錯(cuò)后果input_size網(wǎng)絡(luò)輸入圖像尺寸與權(quán)重不匹配會(huì)報(bào)維度錯(cuò)誤S網(wǎng)格劃分?jǐn)?shù)量與模型輸出、目標(biāo)張量不一致B每個(gè)網(wǎng)格預(yù)測框數(shù)輸出通道數(shù)對不上num_classes數(shù)據(jù)集類別數(shù)輸出通道數(shù)對不上loss 異常label_path標(biāo)注文件路徑讀不到數(shù)據(jù)生成器為空其中 S 和 B 一般保持原版 7 和 2 不動(dòng)除非你明確知道自己在做什么。num_classes 必須和你標(biāo)注文件里的類別索引最大值加一一致。比如你的標(biāo)注里類別索引是 0 到 4那 num_classes 就是 5。input_size 常見是 448×448也有改成 416 或 320 的改小能提速但精度會(huì)降。label_path 指向存放標(biāo)注文件的目錄確保每張圖都能找到對應(yīng)的標(biāo)注。3.3 啟動(dòng)訓(xùn)練與觀察 loss 是否正常下降配置改好后直接運(yùn)行訓(xùn)練腳本python train.py --config config.py --gpu 0如果你的工程沒有命令行參數(shù)就直接 python train.py。啟動(dòng)后重點(diǎn)看前幾個(gè) epoch 的 loss 變化。正常的 loss 曲線應(yīng)該是先快速下降然后緩慢震蕩下行。如果 loss 一直不動(dòng)或者直接變成 nan按下面幾個(gè)方向排查學(xué)習(xí)率是不是太大常見初始值 1e-3 到 1e-4數(shù)據(jù)里有沒有坐標(biāo)越界或者寬高為 0 的臟標(biāo)注損失函數(shù)里有沒有除零操作。我一般會(huì)在損失函數(shù)里加一句打印把坐標(biāo)損失、置信度損失、類別損失分開輸出這樣一眼就能看出是哪一項(xiàng)在搗亂。訓(xùn)練過程中權(quán)重會(huì)按 epoch 保存到 weights 目錄建議同時(shí)保存最近一次和最優(yōu)一次。判斷最優(yōu)的依據(jù)可以是驗(yàn)證集 loss也可以是自己寫的一個(gè)簡單 mAP 計(jì)算。YOLOv1 在小數(shù)據(jù)集上通常幾十個(gè) epoch 就能看到框大致收斂但要想框得準(zhǔn)標(biāo)注質(zhì)量比調(diào)參重要得多。4. 訓(xùn)練不收斂、框亂飛、顯存爆幾個(gè)高頻翻車點(diǎn)4.1 loss 變成 nan 或一直不降現(xiàn)象訓(xùn)練開始幾個(gè) batch 后 loss 直接變 nan或者連續(xù)十幾個(gè) epoch 停在同一個(gè)數(shù)值附近不動(dòng)。原因通常有三個(gè)學(xué)習(xí)率過大導(dǎo)致梯度爆炸標(biāo)注文件里有坐標(biāo)超出 0 到 1 范圍或者寬高為負(fù)損失函數(shù)里對 w、h 開了根號后遇到 0 值。解決方法是先把學(xué)習(xí)率降到 1e-4 試一輪同時(shí)寫個(gè)腳本掃一遍所有標(biāo)注把越界值裁剪回 0 到 1寬高為 0 的樣本直接剔除。如果用的是原版損失檢查根號里有沒有加極小值 eps。4.2 預(yù)測框全部堆在圖像中央現(xiàn)象推理時(shí)所有框都集中在畫面中間大小也差不多像是網(wǎng)絡(luò)沒學(xué)到東西。原因多半是正負(fù)樣本分配出了問題或者置信度損失權(quán)重太低導(dǎo)致網(wǎng)絡(luò)傾向于把所有框的置信度壓到接近 0只有中心區(qū)域偶爾被激活。解決方法是檢查目標(biāo)張量構(gòu)造邏輯確認(rèn)每個(gè)有目標(biāo)的網(wǎng)格置信度確實(shí)被置為 1并且損失函數(shù)里對正樣本的置信度誤差給了足夠權(quán)重。另外可以臨時(shí)把 λ_noobj 調(diào)小一點(diǎn)讓負(fù)樣本的壓制不要那么強(qiáng)。4.3 顯存不夠?qū)е掠?xùn)練中斷現(xiàn)象batch size 設(shè)到 8 或 16 時(shí)跑幾個(gè) batch 就報(bào) OOM。原因可能是輸入尺寸太大或者模型里全連接層參數(shù)過多。YOLOv1 原版在 448 輸入下參數(shù)量不小顯存吃緊很常見。解決辦法是先把 batch size 降到 2 或 4再考慮把輸入尺寸降到 320 或 256。如果還是不夠可以把模型里最后一個(gè)全連接層之前的特征圖通道數(shù)適當(dāng)減少但這會(huì)改變網(wǎng)絡(luò)結(jié)構(gòu)需要重新從頭訓(xùn)練。4.4 標(biāo)注格式對但訓(xùn)練效果很差現(xiàn)象標(biāo)注檢查過沒問題loss 也在降但推理時(shí)框的位置和類別都不對。原因可能是數(shù)據(jù)增強(qiáng)時(shí)只變換了圖像沒有同步變換框?qū)е聢D像和標(biāo)注錯(cuò)位。另一個(gè)常見原因是類別索引從 1 開始而不是從 0 開始導(dǎo)致 one-hot 整體偏移一位。解決方法是把增強(qiáng)前后的圖像和框畫出來目測幾張確認(rèn)框還套在目標(biāo)上同時(shí)檢查標(biāo)注文件里的類別索引是不是從 0 開始連續(xù)編號。4.5 推理時(shí)框的坐標(biāo)還原錯(cuò)誤現(xiàn)象訓(xùn)練 loss 正常但推理畫出來的框位置明顯偏移或者大小不對。原因通常是坐標(biāo)還原時(shí)忘了乘回原圖尺寸或者把相對于網(wǎng)格的偏移當(dāng)成了相對于整圖的坐標(biāo)。解決方法是理清推理階段的后處理流程網(wǎng)絡(luò)輸出的是相對于網(wǎng)格的偏移和相對于整圖的寬高需要先加上網(wǎng)格左上角坐標(biāo)再除以 S最后乘以原圖寬高。這一步在 train.py 和 predict.py 里往往各寫了一遍改的時(shí)候要同步改。5. 從能跑到能用置信度閾值調(diào)優(yōu)與框篩選的實(shí)操技巧訓(xùn)練跑通只是第一步真正讓 YOLOv1 在你自己的數(shù)據(jù)上“能用”后處理里的置信度閾值和 NMS 參數(shù)才是關(guān)鍵。YOLOv1 每個(gè)網(wǎng)格輸出兩個(gè)框推理時(shí)先按置信度閾值過濾掉大部分低分框再對剩下的框做非極大值抑制。置信度閾值設(shè)高了漏檢明顯設(shè)低了同一個(gè)目標(biāo)會(huì)出好幾個(gè)重疊框。我一般會(huì)先在驗(yàn)證集上跑一遍把閾值從 0.1 到 0.5 按 0.05 步長掃一遍看哪個(gè)點(diǎn)召回和誤檢的平衡最好。NMS 的 IoU 閾值常用 0.4 到 0.5如果你的數(shù)據(jù)里目標(biāo)比較密集可以適當(dāng)調(diào)高到 0.6但別超過 0.7否則相鄰目標(biāo)容易被誤合并。下面這段后處理代碼是我常用的寫法你可以直接嵌到推理腳本里import numpy as np def yolo_postprocess(pred, conf_thresh0.3, iou_thresh0.5, S7, B2, C20): boxes [] for gy in range(S): for gx in range(S): for b in range(B): base b * 5 conf pred[gy, gx, base 4] if conf conf_thresh: continue # 還原中心坐標(biāo)到整圖比例 cx (gx pred[gy, gx, base]) / S cy (gy pred[gy, gx, base 1]) / S w pred[gy, gx, base 2] h pred[gy, gx, base 3] cls_scores pred[gy, gx, 5 * B:] cls_id int(np.argmax(cls_scores)) score conf * cls_scores[cls_id] boxes.append([cx, cy, w, h, score, cls_id]) # 按分?jǐn)?shù)排序后做 NMS boxes.sort(keylambda x: x[4], reverseTrue) keep [] while boxes: best boxes.pop(0) keep.append(best) boxes [b for b in boxes if iou(best, b) iou_thresh or b[5] ! best[5]] return keep def iou(a, b): # 把中心寬高轉(zhuǎn)成左上右下 ax1, ay1 a[0] - a[2] / 2, a[1] - a[3] / 2 ax2, ay2 a[0] a[2] / 2, a[1] a[3] / 2 bx1, by1 b[0] - b[2] / 2, b[1] - b[3] / 2 bx2, by2 b[0] b[2] / 2, b[1] b[3] / 2 inter max(0, min(ax2, bx2) - max(ax1, bx1)) * max(0, min(ay2, by2) - max(ay1, by1)) union a[2] * a[3] b[2] * b[3] - inter return inter / union if union 0 else 0這段代碼里conf_thresh 控制進(jìn)入 NMS 的框數(shù)量iou_thresh 控制合并程度。注意類別不同的框不應(yīng)該互相抑制所以 NMS 里加了一個(gè)類別判斷。實(shí)際工程里還會(huì)把框的坐標(biāo)乘回原圖尺寸再畫出來這一步別忘。另外 YOLOv1 的框回歸沒有 anchor 機(jī)制對小目標(biāo)本身就不友好如果你的數(shù)據(jù)里小目標(biāo)占比高與其死磕 YOLOv1不如把這份代碼當(dāng)作理解檢測流程的跳板后面換到帶 anchor 的版本會(huì)順很多。從那以后我每次拿到一個(gè)新的檢測工程都會(huì)先把后處理單獨(dú)拎出來在幾張圖上跑一遍確認(rèn)框的還原邏輯沒問題再去看訓(xùn)練部分。希望這份拆解能幫到你。本文還有配套的精品資源點(diǎn)擊獲取