:從手腕骨檢測到TW3評分)
簡介基于Python和YOLOv5的骨齡檢測項目面向畢業(yè)設(shè)計、課程設(shè)計以及目標檢測方向開發(fā)者尤其適合醫(yī)學影像智能分析相關(guān)課題提供從模型訓練到推理的完整代碼框架。項目源碼經(jīng)過嚴格測試可以放心參考并在其基礎(chǔ)上擴展二次開發(fā)。壓縮包共25個文件其中20個Python腳本為核心覆蓋數(shù)據(jù)加載、模型配置、訓練驗證、推理導出等流程另有YAML、TOML、TXT等配置和依賴文件便于還原運行環(huán)境Markdown文檔則說明項目結(jié)構(gòu)及使用方式。整體大小約95KB輕量精簡便于快速閱讀與上手目前已有107人學習資源涵蓋源碼、項目文檔、數(shù)據(jù)模型、數(shù)據(jù)集與權(quán)重并采用工具模塊和模型模塊等目錄化組織方便定位功能代碼數(shù)據(jù)模型與權(quán)重可用于直接推理數(shù)據(jù)集也便于重新訓練與驗證。讀者可根據(jù)說明文檔和配置文件構(gòu)建環(huán)境在骨齡檢測場景中替換或微調(diào)模型也可借鑒其工程結(jié)構(gòu)完成其他目標檢測任務(wù)。1. 骨齡檢測項目不玄乎yolov5檢測手腕骨權(quán)重數(shù)據(jù)集一次到位骨齡檢測在兒童生長發(fā)育評估里是剛需但傳統(tǒng)TW3法要醫(yī)生逐塊讀片一個醫(yī)生一天看幾十張片子眼睛和判斷力都到極限。這份基于pythonyolov5的骨齡檢測項目把目標檢測用在左手腕X光片上先檢測橈骨、尺骨、指骨等骨骺區(qū)域再按區(qū)域成熟度映射骨齡。它自帶訓練好的權(quán)重、劃分好的數(shù)據(jù)集、預處理腳本和項目文檔適合畢業(yè)設(shè)計、課程設(shè)計和想在項目里快速加檢測能力的開發(fā)者。拿到手先跑通推理再改數(shù)據(jù)訓練自己的模型。我建議上手前先花二十分鐘把yolov5的檢測流程捋一遍后面改哪里、怎么改心里才有數(shù)這也是我這次拆包最花時間的地方。2. 骨齡檢測的任務(wù)拆解為什么用yolov5而不是一個分類網(wǎng)絡(luò)2.1 骨齡評估的本質(zhì)是先找區(qū)域、再評等級Tanner-Whitehouse法TW3法的核心是讀左手腕X光片對橈骨遠端、尺骨遠端、第一到第五指骨、腕骨等骨骺區(qū)域逐塊對照圖譜把成熟度分成A到I等級每個等級對應分數(shù)最后加權(quán)算骨齡。傳統(tǒng)做法里醫(yī)生干的兩件事就是找區(qū)域、定等級。找區(qū)域這件事對神經(jīng)網(wǎng)絡(luò)來說正是目標檢測的活兒。如果直接把整張X光片丟給ResNet回歸出一個年齡模型確實能“學出來”但你看不到它依據(jù)什么它可能同時把圖像里的金屬偽影、拍攝條件當成特征換一臺DR設(shè)備表現(xiàn)就崩。yolov5至少先把“哪些區(qū)域參與了判斷”畫出來——框住骨骺區(qū)域醫(yī)生看到框就知道模型在看哪后面再掛評分模塊。這種先檢測后評分的兩段式結(jié)構(gòu)是骨齡方向的主流做法答辯時也站得住。再說網(wǎng)絡(luò)結(jié)構(gòu)yolov5的backbone用CSP結(jié)構(gòu)提取特征neck用PANet做多尺度融合head輸出三個尺度的檢測結(jié)果。手腕骨里指骨很小、橈骨很大這種尺度差異正好需要多尺度head去覆蓋小目標分支負責近節(jié)指骨大目標分支負責橈骨遠端。再加上yolov5生態(tài)成熟資料多改起來有據(jù)可依這就是我推薦直接用yolov5而不是YOLOX或純分割網(wǎng)絡(luò)的原因。還有一點值得注意yolov5自帶的anchor先驗是從COCO數(shù)據(jù)集聚類出來的COCO里大多數(shù)目標是日常物體長寬比和骨骺差別很大。不過這個項目的訓練流程里用到了autoanchor也就是train.py啟動時會重新對訓練集標簽做k-means聚類生成更適合骨骺尺度的錨框后面第四章我再展開講。2.2 項目文件逐一拆解這份yolov5改了什么拿到壓縮包后先別急著跑按下面這張表把文件對應到流程里后面出問題才知道去哪翻。文件/目錄在流程里干什么什么時候需要改utils/loss.py定義損失計算yolov5的boxobjcls三項loss訓練時最常改utils/autoanchor.pyk-means聚類錨框換數(shù)據(jù)集自動運行utils/augmentations.py在線數(shù)據(jù)增強灰度圖要關(guān)色彩抖動utils/dataloaders.py加載數(shù)據(jù)集、緩存圖片報錯高發(fā)區(qū)models/yolo.pyDetectionModel定義、前向推理組裝一般不動models/common.pyC3、SPPF等基礎(chǔ)模塊改backbone時才動Bone-pre.py數(shù)據(jù)預處理入口轉(zhuǎn)標注格式用export.py導出ONNX/TorchScript部署用.streamlit/config.tomlstreamlit界面主題配置不影響檢測邏輯utils里最值得先看的是loss.py和augmentations.py。loss.py決定梯度怎么回傳如果你覺得模型對某個區(qū)域總學不好多半要動這里的類別權(quán)重augmentations.py決定數(shù)據(jù)增強是否適合醫(yī)學灰度圖X光片沒有顏色信息默認帶HSV色彩抖動的增強方案就要關(guān)掉。models/yolo.py的ModuleDict里可以看到模型的組裝順序配合官方y(tǒng)olov5網(wǎng)絡(luò)結(jié)構(gòu)圖對照看能快速定位你想改的模塊在哪一層。Bone-pre.py是作者寫的預處理入口功能是把原始X光數(shù)據(jù)整理成yolov5能吃的目錄格式常見做法是統(tǒng)一改名、裁剪、歸一化。還有一個容易被忽略的文件是.github/workflows很多派生倉庫保留CI工作流文件內(nèi)容一般是自動化測試和release構(gòu)建和本地跑項目沒關(guān)系刪掉也行。README.md里寫了數(shù)據(jù)集來源、權(quán)重復現(xiàn)說明、使用方法遇到問題先翻它別急著改代碼。2.3 從X光片到骨齡分數(shù)整條數(shù)據(jù)流整個流程可以拆成四段。第一段是輸入左手腕X光片灰度圖分辨率通常在一千五百乘兩千以上。第二段是預處理Bone-pre.py把圖片統(tǒng)一尺寸、裁剪、歸一化盡量讓腕骨區(qū)域在畫面中央否則原圖直接進網(wǎng)絡(luò)顯存根本扛不住。第三段是檢測yolov5模型輸出多個框每個框帶類別、置信度、坐標類別代表這個框?qū)儆谀膫€骨骺區(qū)域。第四段是評分后處理腳本把檢測結(jié)果按區(qū)域類別聚合查TW3評分表或加權(quán)算出骨齡估計值。yolov5本身不負責“年紀”預測負責的是第三段。很多初次接觸的人以為換個訓練數(shù)據(jù)集、把標注改成“年齡”模型就能回歸出年齡不對。目標檢測的數(shù)據(jù)集標注是“框類別”類別是區(qū)域編號不是數(shù)值標簽骨齡數(shù)字來自評分層。理解這一點你就明白為什么權(quán)重文件里沒有“年齡回歸頭”也明白換數(shù)據(jù)集時該保留什么標注。這個思路也解釋了第四章的一個建議類別別用年齡。如果指導老師說“你加一個回歸分支直接出年齡”技術(shù)上可以做在head后面加回歸頭但你得同時準備數(shù)值標簽數(shù)據(jù)標注量完全不同。這個項目里用的是先檢測后查表的方式改動最小、解釋性最好也是我建議你沿用的方案。3. 把項目跑起來環(huán)境、權(quán)重、數(shù)據(jù)三步到位3.1 conda創(chuàng)建虛擬環(huán)境與依賴安裝先用conda隔離環(huán)境避免污染系統(tǒng)Python。yolov5這套代碼是在torch 1.10到1.13時代寫的python 3.8和3.9對應最省心直接用系統(tǒng)python 3.11往上torchvision版本對不上import就報錯。這里是我反復踩過的血淚經(jīng)驗不要在這上面浪費時間。# 創(chuàng)建獨立Python環(huán)境并激活 conda create -n bone python3.8 -y conda activate bone # 進入項目根目錄后安裝依賴 cd Bone_Age_Predict pip install -r requirements.txt依賴裝完先驗證torch能不能調(diào)用GPU這一步能省掉后面一半的玄學問題python -c import torch; print(torch.__version__, torch.cuda.is_available())如果輸出里cuda.is_available()是False先別懷疑代碼大概率是torch版本和顯卡驅(qū)動不匹配。常見做法是到pytorch官網(wǎng)按你的CUDA版本重新裝torch再回來跑上面的命令。如果你的機器沒有NVIDIA顯卡CPU跑推理也夠用只是訓練會慢很多batch-size要調(diào)小。3.2 確認目錄和權(quán)重、數(shù)據(jù)集的落位跑通項目之前先確認三樣東西在不在權(quán)重文件、數(shù)據(jù)集目錄、入口腳本。用ls檢查根目錄結(jié)構(gòu)重點看權(quán)重文件放在哪個目錄通常叫best.pt或yolov5s.pt可能放在weights/下也可能直接在根目錄。入口腳本里weights參數(shù)寫的是什么路徑就得讓文件待在哪個路徑這是最蠢也最常見的啟動失敗原因。ls -la # 確認是否存在 weights/best.pt 或根目錄下的 .pt 文件 find . -name *.pt -maxdepth 3查數(shù)據(jù)集的路徑同理。默認訓練好的數(shù)據(jù)集一般放在datasets/下用find檢查images和labels目錄是否存在。best.pt是訓練過程中val loss最低的那個checkpoint推理優(yōu)先選它last.pt是最后一個epoch的存檔復現(xiàn)訓練過程才用到。如果你只想跑演示認準best.pt就夠了。很多人在這一步拿著只有模型沒有數(shù)據(jù)的壓縮包折騰半天結(jié)果發(fā)現(xiàn)是文件沒放全。3.3 用streamlit打開Web檢測界面這個項目根目錄里放了一個.streamlit/config.toml說明作者用streamlit做了Web展示層。streamlit的好處是不用寫前端頁面自動生成畢設(shè)演示時老師不用敲命令直接瀏覽器里傳圖看結(jié)果。啟動命令的一般寫法是streamlit run app.py --server.port 8501根目錄的入口腳本名以實際倉庫為準常見叫app.py或web.py找到帶streamlit調(diào)用和title配置的那個.py文件就是入口。啟動后瀏覽器打開http://localhost:8501上傳一張左手腕X光片就能看到檢測結(jié)果頁面會把檢測框畫在圖上附帶置信度和骨齡估計。config.toml里一般是主題配置類似[theme] primaryColor #4CAF50 font sans serif這兩行只影響按鈕、背景的主題色不參與任何檢測邏輯。如果你改了不生效刷新頁面如果頁面布局亂了八成是streamlit版本和config.toml里寫的theme字段不兼容升級或降級streamlit到對應版本就行不用改代碼。3.4 走一次命令行推理Web界面方便演示但調(diào)試問題還是命令行直接日志清晰。yolov5派生倉庫一般保留官方detect.py入口這個包對官方代碼做了瘦身如果根目錄沒有detect.py就用作者自定義的推理腳本參數(shù)傳法一致python detect.py --weights weights/best.pt --source ./data/test/001.png --img 640 --conf-thres 0.25--weights指定權(quán)重路徑--source可以指向單張圖、一個文件夾或攝像頭設(shè)備號--img要和你訓練時一致yolov5會在輸入時把圖縮放到這個分辨率--conf-thres是置信度閾值調(diào)低會出很多假框調(diào)高容易漏掉小骨骺。第一次跑通時控制臺會逐張打印檢測結(jié)果和耗時save目錄下生成帶框的圖。如果一張X光片上只檢測出一兩個框或者完全沒有框先不要懷疑模型壞了按這個順序排查權(quán)重路徑有沒有寫對、圖片有沒有被resize到正常范圍、置信度閾值是不是太高。這三條查完還沒解決直接看第五章的坑前兩個坑就是針對這類現(xiàn)象寫的。4. 訓練自己的數(shù)據(jù)集換骨齡庫、換部位的關(guān)鍵參數(shù)4.1 標注格式與目錄組織yolov5要求的數(shù)據(jù)集目錄結(jié)構(gòu)固定images和labels一一對應同名文件匹配不要手動復制粘貼這個結(jié)構(gòu)決定了dataloaders.py能不能正常找到每一張圖dataset/ images/ train/ val/ labels/ train/ val/images里放一張left_001.pnglabels里就要有對應的left_001.txttxt每行格式為class x_center y_center width height五個值都是相對坐標范圍0到1。之所以要求相對坐標是因為輸入圖縮放后坐標不用重算。很多拿公開骨齡數(shù)據(jù)集的人原始標注是CSV或XML里面只有骨齡值沒有框這時就要用Bone-pre.py把標注轉(zhuǎn)成txt。一個常見的做法是先用預訓練模型出一批偽標簽再人工修正把框轉(zhuǎn)成txt的腳本邏輯就在Bone-pre.py里轉(zhuǎn)換前先打印幾行看看格式別直接跑全量。4.2 寫data.yaml之前先理清目標和類別data.yaml是訓練配置的入口路徑錯了訓練直接報錯內(nèi)容不多但每個字段都有講究path: ./dataset train: images/train val: images/val nc: 5 names: [radius, ulna, metacarpal, proximal_phalanx, carpal]path是數(shù)據(jù)集根目錄train和val是相對path的子目錄nc是類別數(shù)names列表的順序必須和標注txt里的class id一一對應順序錯位會導致模型把橈骨當成尺骨訓練。類別應該按照骨骺區(qū)域定義不要圖省事把class直接設(shè)為“年齡”。如果把類別設(shè)成年齡模型會把不同年齡但形態(tài)相近的骨頭混成一類框都學不準。骨齡檢測的類別就是區(qū)域名年齡信息放在后處理評分階段這是我最想強調(diào)的一點。4.3 訓練命令與超參數(shù)調(diào)優(yōu)訓練時用COCO預訓練權(quán)重做遷移學習收斂速度比隨機初始化快很多?;A(chǔ)訓練命令python train.py --data dataset/data.yaml --weights yolov5s.pt \ --img 640 --batch-size 16 --epochs 100 \ --hyp data/hyps/hyp.scratch-low.yaml--img是輸入分辨率骨齡X光片細節(jié)多有條件可以提到1280但顯存占用會翻好幾倍8G顯存建議640起。--batch-size按顯存調(diào)8G卡建議8到16之間。--hyp指定超參數(shù)文件yolov5把超參數(shù)收斂在hyp文件里而不是散落在代碼里方便復盤。骨齡場景建議改兩個地方一是把hsv_h和hsv_s這兩項色彩增強降到最低X光是灰度圖顏色抖動只會引入噪聲二是degrees旋轉(zhuǎn)幅度別開大骨骺旋轉(zhuǎn)后形態(tài)會失真影響等級判斷。這兩個參數(shù)就在hyp文件里改完重跑訓練即可。訓練啟動時留意日志里有沒有AutoAnchor的k-means聚類輸出如果數(shù)據(jù)里指骨很小、橈骨很大自動聚類能明顯提升召回率想關(guān)掉就加--noautoanchor參數(shù)。4.4 從訓練日志判斷過擬合與欠擬合訓練時盯著兩個東西看train和val的loss曲線以及val的mAP0.5。如果train loss一直降、val loss開始回升就是過擬合骨齡公開數(shù)據(jù)集往往只有幾千張圖過擬合非常常見。緩解手段按優(yōu)先級排列調(diào)大數(shù)據(jù)增強、降低模型復雜度yolov5s換成yolov5n、早停參數(shù)patience調(diào)小。如果mAP0.5一直上不去先看是不是類別不平衡指骨樣本遠多于橈骨可以在loss.py里調(diào)整cls損失權(quán)重給樣本少的區(qū)域更高權(quán)重。訓練完不要只看總mAP用val.py跑一遍驗證集看per-class的mAP哪個區(qū)域最差就針對性補哪個區(qū)域的數(shù)據(jù)。這一步很多人跳過結(jié)果答辯時被問“你這個模型哪類檢測最差”答不上來。per-class結(jié)果是一張表打印出來存好寫進論文里也是一個像樣的實驗分析。5. 避坑與排查骨齡檢測最容易翻車的五個坑5.1 現(xiàn)象訓練loss正常下降但模型一個骨骺框都檢測不到loss曲線很漂亮mAP卻一直是0檢測結(jié)果全空白。這個現(xiàn)象我第一次遇到時也懵了后來發(fā)現(xiàn)原因在于loss是box、obj、cls三個分支的總和總loss下降不代表每個分支都收斂了。obj分支沒收斂說明錨框尺寸和目標的真實尺寸嚴重不匹配先驗框還是COCO的通用尺度對細長的骨骺完全不適用。解決方法是確認訓練日志里有沒有AutoAnchor的輸出。如果日志里沒有k-means重聚類的結(jié)果說明autoanchor被關(guān)掉了或者沒跑成去掉--noautoanchor參數(shù)重新跑。要是聚類結(jié)果還是不理想手動在hyp文件里按數(shù)據(jù)集的框尺寸分布設(shè)置初始錨框。這個問題排查路徑很固定先看日志再改參數(shù)不用動網(wǎng)絡(luò)結(jié)構(gòu)。5.2 現(xiàn)象訓練或推理時顯存OOM程序直接崩X光原圖分辨率動輒2000以上--img默認640沒問題一旦設(shè)成12808G顯存直接爆。這種情況的典型報錯是CUDA out of memory有時還會把機器整個卡死只能重啟。原因是模型輸入尺寸乘了平方關(guān)系640變1280顯存占用翻四倍不是翻一倍。解決思路有兩個方向。一是把--img降到640batch-size降到4同時啟用amp混合精度訓練顯存能省將近一半。二是從源頭處理用Bone-pre.py先把X光長邊壓到1280以內(nèi)再做數(shù)據(jù)增強而不是讓yolov5在dataloader里直接縮放。我一般兩個方向一起做長邊壓到1280--img設(shè)640這樣既保留細節(jié)又不爆顯存。5.3 現(xiàn)象streamlit頁面能打開但上傳X光片后一直轉(zhuǎn)圈不出結(jié)果Web界面正常顯示一上傳圖片就卡住控制臺也沒有報錯看起來像死循環(huán)。這個問題的原因一般是兩層一是Web入口調(diào)用的推理函數(shù)里寫的權(quán)重路徑是相對路徑streamlit運行時的工作目錄和命令行不一樣找不到權(quán)重文件二是入口腳本里模型輸入尺寸和權(quán)重訓練尺寸不一致模型前向推理時靜默失敗。解決時先用命令行跑一遍同一張圖確認權(quán)重本身沒問題再改Web層。把權(quán)重路徑換成絕對路徑推理函數(shù)里先打印輸入張量的shape確認和訓練時一致。凡是streamlit這類Web框架包檢測代碼的我都建議把推理邏輯單獨封裝成一個函數(shù)Web只做調(diào)用這樣排查時直接在命令行調(diào)函數(shù)不用反復開頁面。5.4 現(xiàn)象val集mAP很高實際對正常X光片預測的骨齡誤差卻很大驗證集指標漂亮得不行一上真實圖片就露餡。這個坑我見過不止一次原因是數(shù)據(jù)劃分時按文件名單純隨機切分同一個病人的多張片子同時出現(xiàn)在訓練集和驗證集里模型記住的是病人特征而不是骨齡特征這在醫(yī)學影像里叫數(shù)據(jù)泄漏。公共數(shù)據(jù)集經(jīng)常一個病人拍了兩三次文件名只按順序編號不按病人分組。解決方法是按病人ID分組劃分數(shù)據(jù)集保證同一個人的所有片子只出現(xiàn)在一個集合里。拿到數(shù)據(jù)先看有沒有病人ID這一列有就先groupby再切分。這個細節(jié)在答辯時主動講出來老師會認為你真處理過醫(yī)療數(shù)據(jù)而不是只會跑通一個demo。5.5 現(xiàn)象Bone-pre.py轉(zhuǎn)換標注時報KeyError或轉(zhuǎn)換后某些類別的txt是空的預處理腳本跑到一半報KeyError檢查生成結(jié)果發(fā)現(xiàn)有些類別的txt文件一個框都沒有。原因基本可以鎖定在標注名稱沒有完全映射到類別字典上常見的情況有幾種左右手沒區(qū)分原始數(shù)據(jù)里left和right的標注都在腕骨分塊過細一個腕骨有多個骨化中心每個都被單獨標注了。這些名稱沒有進到你的類別映射表腳本就報KeyError或者直接跳過。解決方法是轉(zhuǎn)換前先寫一段代碼把所有標注名稱去重打印出來逐一核對映射字典把不需要參與評分的區(qū)域注釋掉。這個步驟不能省我一般會先跑一遍統(tǒng)計腳本輸出每個標注名的樣本數(shù)量分布再決定哪些保留、哪些合并。還有一個習慣轉(zhuǎn)換完成后隨機抽三張圖把txt標注可視化畫在圖上人工檢查一遍這一步能擋掉絕大多數(shù)標注錯位問題。6. 進階用法導出ONNX模型用CPU跑批量推理6.1 用export.py導出ONNX項目根目錄里有export.py這是yolov5官方的導出腳本把PyTorch權(quán)重轉(zhuǎn)成ONNX格式導出后可以不依賴PyTorch環(huán)境推理python export.py --weights weights/best.pt --img 640 --include onnx導出時--img必須和訓練時一致否則模型輸入輸出尺寸對不上推理結(jié)果會離譜。ONNX模型可以用onnxruntime加載純CPU也能跑單張圖在幾百毫秒級別對一個檢測任務(wù)來說完全夠用。導出成功后根目錄會生成best.onnx文件比.pt小不少發(fā)給別人也方便。6.2 檢測結(jié)果到骨齡分數(shù)的映射ONNX輸出的原始結(jié)果是檢測框坐標加上置信度和類別要得到骨齡分數(shù)還得做一層映射。下面這段示意代碼展示了yolo輸出和骨齡評分的銜接邏輯def bone_age_from_boxes(boxes, region_weights): total_score 0.0 regions_used 0 for box in boxes: cls_id int(box[5]) conf float(box[4]) if conf 0.5: continue # 每個區(qū)域按成熟等級加權(quán)權(quán)重來自TW3評分表 total_score region_weights.get(cls_id, 0) regions_used 1 if regions_used 0: return None return total_score / regions_usedyolo的每個框是一個數(shù)組第4列是置信度第5列是類別id這個切片順序很多人寫錯導致把類別當置信度過濾。實際項目里region_weights會單獨存在一個py或json文件里按區(qū)域類別編號查表這里只展示思路。6.3 我的工程習慣自從有一次交付后對方問我“為什么這次跑出來的結(jié)果和上次不一樣”我開始意識到y(tǒng)olov5推理本身是確定的不確定的是預處理。從那以后我每次交付檢測類項目都強制自己走一遍固定流程干凈環(huán)境里跑通一次、導出ONNX、固定輸入尺寸和歸一化參數(shù)再把推理結(jié)果落盤成CSV方便回看分析。骨齡檢測這種醫(yī)療場景更要留痕每個框的坐標、置信度、類別都記錄下來回查時有據(jù)可依。你自己上手時也建議從第一天就養(yǎng)成這個習慣后面省的事遠不止翻半天日志。希望幫到你。本文還有配套的精品資源點擊獲取