胞檢測系統(tǒng)實戰(zhàn):從訓(xùn)練到桌面應(yīng)用)
血常規(guī)檢驗是醫(yī)院檢驗科每天的必修課紅細(xì)胞、白細(xì)胞、血小板的計數(shù)直接關(guān)系著感染、貧血、凝血功能的判斷。過去靠人工瞪顯微鏡數(shù)細(xì)胞費眼費神不說結(jié)果還容易受疲勞影響。這幾年基于深度學(xué)習(xí)的目標(biāo)檢測算法逐漸普及我最近就用YOLOv12做了一套完整的血細(xì)胞檢測系統(tǒng)模型端基于YOLOv12和公開血細(xì)胞數(shù)據(jù)集完成訓(xùn)練界面端實現(xiàn)了登錄注冊UI和檢測主界面整套代碼用Python寫成可以打包成桌面應(yīng)用直接跑起來。這篇文章就當(dāng)一次完整的技術(shù)復(fù)盤把思路、踩坑、實操參數(shù)都攤開講講。1. 項目整體設(shè)計與技術(shù)選型1.1 血細(xì)胞檢測到底難在哪很多人一聽說檢測紅細(xì)胞、白細(xì)胞、血小板第一反應(yīng)是“數(shù)數(shù)而已圖像分割或者閾值不就搞定了”。真上手就知道不是這么回事。血液涂片經(jīng)過染色之后細(xì)胞在顯微鏡下呈現(xiàn)的特征確實有規(guī)律但真實樣本里挑戰(zhàn)點特別多目標(biāo)尺寸差異大。紅細(xì)胞直徑大約6到8微米白細(xì)胞10到20微米血小板只有2到4微米。在一張640乘640的圖像里血小板可能只占十幾個像素屬于典型的小目標(biāo)檢測問題。密度極高且互相粘連。紅細(xì)胞在視野里常常一個貼著一個邊界模糊人眼都要仔細(xì)看。傳統(tǒng)分割算法在這種場景下很難把細(xì)胞一個個分出來。類別嚴(yán)重不均衡。一張正常血涂片里紅細(xì)胞數(shù)量是白細(xì)胞的幾百倍甚至上千倍血小板數(shù)量居中。模型很容易被紅細(xì)胞帶偏導(dǎo)致稀有類別檢出率低。這些特點決定了用傳統(tǒng)圖像處理只能做做簡單場景想要在真實血涂片圖像里穩(wěn)定數(shù)出三類細(xì)胞還是得走目標(biāo)檢測的路子。1.2 為什么選了YOLOv12而不是其他模型我最早用的其實是YOLOv5和Faster R-CNN后來也試過YOLOv8和YOLOv10。簡單說下我的感受模型優(yōu)勢在這個場景里的問題Faster R-CNN精度確實高兩階段檢測對小目標(biāo)友好推理速度慢幀率上不去很難做成實時輔助工具YOLOv5生態(tài)成熟資料多對于密集小目標(biāo)的召回率不太夠YOLOv10無NMS設(shè)計推理快在密集重疊場景下容易出現(xiàn)重復(fù)框或漏檢YOLOv12引入注意力機制精度提升速度保持剛出不久參考資料相對少需要自己多試YOLOv12是今年初發(fā)布的模型架構(gòu)和之前版本最大的區(qū)別是把注意力機制和YOLO的CSP結(jié)構(gòu)做了有效融合用的是一種叫區(qū)域注意力Area Attention的機制。簡單理解傳統(tǒng)Transformer里的注意力要對整張圖兩兩計算相似度計算量大YOLOv12的區(qū)域注意力把計算范圍限制在局部區(qū)域顯著降低計算量和內(nèi)存占用同時保留了對關(guān)鍵特征的建模能力。放在血細(xì)胞檢測這個場景里這種“精度和速度兼顧”的路線正好契合需求一方面細(xì)胞尺寸小、密度大需要有足夠強的特征表達能力另一方面臨床輔助工具講究響應(yīng)夠快最好能做到實時或接近實時。我實測下來同樣的硬件條件下YOLOv12的精度比YOLOv10有明顯提升推理速度沒有明顯退步所以最終選了它。1.3 系統(tǒng)架構(gòu)與模塊劃分整個系統(tǒng)我按三層來拆數(shù)據(jù)層負(fù)責(zé)數(shù)據(jù)集的整理、標(biāo)注格式轉(zhuǎn)換、數(shù)據(jù)增強。這一層決定了模型能學(xué)到什么比重最大。模型層YOLOv12模型的訓(xùn)練、驗證、導(dǎo)出。訓(xùn)練階段跑出最優(yōu)權(quán)重推理階段提供檢測和統(tǒng)計結(jié)果。界面層Python實現(xiàn)的桌面應(yīng)用包含登錄注冊窗口和檢測主窗口。用戶在主窗口上傳圖片、在線推理、查看結(jié)果和統(tǒng)計數(shù)量。之所以把界面單獨做成桌面應(yīng)用而不是用Web原因是我希望這套系統(tǒng)能在不聯(lián)網(wǎng)的環(huán)境下用比如檢驗科的內(nèi)網(wǎng)電腦。Python的PyQt5打包成exe之后沒有網(wǎng)絡(luò)也能跑部署成本最低。這套架構(gòu)對于想快速用起來的場景很實用后續(xù)要加攝像頭實時檢測、批量處理之類也都在解耦后的模塊上做擴展。2. 數(shù)據(jù)集構(gòu)建與預(yù)處理2.1 數(shù)據(jù)來源與清洗思路我做這個項目用的血細(xì)胞數(shù)據(jù)集選取了常用的公開血液涂片數(shù)據(jù)集包含了紅細(xì)胞、白細(xì)胞、血小板三類目標(biāo)的標(biāo)注框圖像是顯微鏡下的血液涂片樣本。公開數(shù)據(jù)集的好處是標(biāo)注相對規(guī)范拿來就能用適合做模型驗證。但有一說一公開數(shù)據(jù)集和真實醫(yī)院檢驗科拍出來的圖還是有差異的主要體現(xiàn)在染色條件不同、視野區(qū)域不同、放大倍數(shù)不同。所以在拿到數(shù)據(jù)之后我做了兩步過濾剔除模糊和染色異常的圖。有些圖明顯對焦不準(zhǔn)標(biāo)注再準(zhǔn)也沒有訓(xùn)練價值反而會把模型帶偏。檢查標(biāo)注框是否貼合目標(biāo)邊界。偶爾會有標(biāo)注框把背景大片框進去的情況這種樣本在訓(xùn)練時會引入噪聲。工業(yè)落地的時候建議再補充自己實驗室的涂片數(shù)據(jù)。每個人實驗室的染色方案、顯微鏡型號不同圖像分布會偏移用公開數(shù)據(jù)集做預(yù)驗證可行但要放到真實環(huán)境得靠自己的數(shù)據(jù)做微調(diào)。2.2 標(biāo)注格式轉(zhuǎn)換與數(shù)據(jù)集劃分YOLO系列訓(xùn)練需要的是txt格式的標(biāo)注文件每行代表一個目標(biāo)框class_id x_center y_center width height注意這里的坐標(biāo)值是歸一化到0到1的浮點數(shù)不是像素坐標(biāo)。而很多公開數(shù)據(jù)集的標(biāo)注是PASCAL VOC格式的XML文件里面直接給的是bbox的像素坐標(biāo)。所以第一步要做的就是把XML轉(zhuǎn)成YOLO格式的txt。轉(zhuǎn)換的核心邏輯是讀取XML里的bndbox信息做歸一化import xml.etree.ElementTree as ET def xml_to_yolo(xml_path, out_txt_path, class_map): tree ET.parse(xml_path) root tree.getroot() img_w int(root.find(size/width).text) img_h int(root.find(size/height).text) lines [] for obj in root.findall(object): class_name obj.find(name).text if class_name not in class_map: continue class_id class_map[class_name] box obj.find(bndbox) x_min float(box.find(xmin).text) y_min float(box.find(ymin).text) x_max float(box.find(xmax).text) y_max float(box.find(ymax).text) x_center (x_min x_max) / 2.0 / img_w y_center (y_min y_max) / 2.0 / img_h w (x_max - x_min) / img_w h (y_max - y_min) / img_h lines.append(f{class_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}) with open(out_txt_path, w) as f: f.write(\n.join(lines))轉(zhuǎn)換完成之后建立標(biāo)準(zhǔn)的目錄結(jié)構(gòu)dataset/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ └── labels/ ├── train/ ├── val/ └── test/數(shù)據(jù)集劃分比例我用的是8:1:1即80%訓(xùn)練集、10%驗證集、10%測試集。劃分的時候要注意先按“圖像”為單位隨機打亂千萬不要直接按文件名順序切不然后面的樣本全是同一批染色條件驗證集就失去了意義。2.3 數(shù)據(jù)增強與類別不平衡血細(xì)胞數(shù)據(jù)集天然存在類別不平衡紅細(xì)胞數(shù)量太多白細(xì)胞太少。如果直接灌進去訓(xùn)練模型會傾向于把一切像細(xì)胞的東西都判定為紅細(xì)胞白細(xì)胞的召回率會很難看。處理方式我做了三件事統(tǒng)計類別分布。先跑一遍腳本把三個類別的標(biāo)注數(shù)量統(tǒng)計出來。拿到數(shù)字之后對數(shù)量偏少的類別給更高的樣本權(quán)重。針對少數(shù)類做過采樣。訓(xùn)練時對包含白細(xì)胞或血小板較多的圖像提高采樣概率相當(dāng)于讓模型在每一輪訓(xùn)練里多看幾遍這些稀有樣本。數(shù)據(jù)增強。YOLOv12自帶的數(shù)據(jù)增強策略要充分利用起來我在配置里主要開了這么幾項增強方式參數(shù)設(shè)置作用HSV顏色抖動hsv_h0.015, hsv_s0.4, hsv_v0.4模擬不同染色條件下的顏色差異隨機水平翻轉(zhuǎn)fliplr0.5增加樣本多樣性Mosaicmosaic1.0把四張圖拼在一起提升模型對密集場景的適應(yīng)能力MixUpmixup0.1圖像融合增強緩解過擬合有一點需要注意血細(xì)胞涂片不適合做大幅旋轉(zhuǎn)增強。細(xì)胞本身有形態(tài)學(xué)特征比如紅細(xì)胞是中間凹陷的盤狀旋轉(zhuǎn)90度甚至180度之后形態(tài)特征會產(chǎn)生歧義模型容易被帶偏。所以我刻意沒有開rotate增強保留flip和顏色抖動就夠用了。3. YOLOv12核心機制與訓(xùn)練實錄3.1 YOLOv12的關(guān)鍵改進YOLOv12對應(yīng)的論文標(biāo)題是“Attention Is All You Need”思路在YOLO架構(gòu)里的實踐但它沒有生搬硬套Transformer而是做了兩個非常關(guān)鍵的取舍。首先是區(qū)域注意力機制。傳統(tǒng)注意力理論上是全局的即每個像素都要和所有其他像素計算相關(guān)性這在圖片尺寸稍大一點的時候內(nèi)存就扛不住了。YOLOv12將注意力計算限制在目標(biāo)中心的局部鄰域內(nèi)每片區(qū)域獨立計算大幅降低計算量。從效果看這個設(shè)計對中小尺寸目標(biāo)特別奏效因為小目標(biāo)本身需要的就是局部上下文不屬于“全局信息依賴型”的目標(biāo)。其次是注意力與跨階段特征的融合方式。YOLO系列一直用CSP結(jié)構(gòu)減少計算冗余YOLOv12把注意力模塊嵌入到這個結(jié)構(gòu)里不是簡單地在輸出端掛一個注意力層而是讓注意力參與不同層級特征的融合保持實時性的同時提升表征能力。對我這種做實際項目的人來說我關(guān)心的不是理論多漂亮而是這個模型在不上大顯卡的前提下能不能用。我在一塊消費級顯卡上跑YOLOv12n批大小為16640輸入分辨率顯存占用在可控范圍內(nèi)這比想象中友好。3.2 訓(xùn)練配置與超參數(shù)選擇訓(xùn)練配置這里我把能直接用的YAML和命令貼出來# blood_cell.yaml path: ./dataset train: images/train val: images/val test: images/test names: 0: RBC 1: WBC 2: Platelets訓(xùn)練命令yolo train modelyolov12n.pt datablood_cell.yaml epochs150 imgsz640 batch16 device0 optimizerAdamW lr00.001 patience20幾個參數(shù)的選擇邏輯imgsz640是默認(rèn)值但如果目標(biāo)是血小板這種小目標(biāo)我建議提高到768甚至960。分辨率越高小目標(biāo)的像素占比就越大檢測效果通常顯著變好代價是訓(xùn)練推理速度下降。我做了一組對比640輸入下血小板AP50接近80%960輸入下能到86%左右。optimizerAdamW。YOLO系列默認(rèn)優(yōu)化器是SGD但我用AdamW在微調(diào)階段收斂更平穩(wěn)。如果是從零開始訓(xùn)練SGD也完全可以。patience20是早停參數(shù)。驗證集mAP連續(xù)20輪沒有上升就停掉避免無效的訓(xùn)練時間。3.3 訓(xùn)練過程與技術(shù)指標(biāo)訓(xùn)練過程大概分了三個階段前30輪loss下降非??鞆氖畮捉档轿遄笥疫@是模型在快速學(xué)習(xí)主導(dǎo)類別紅細(xì)胞的特征30到80輪loss下降變緩主要是在提升白細(xì)胞和血小板的識別能力80輪之后進入平臺期早停在120輪左右觸發(fā)。最終在測試集上的指標(biāo)參考類別PrecisionRecallmAP50mAP50-95紅細(xì)胞0.950.970.990.79白細(xì)胞0.910.860.930.72血小板0.900.830.880.58整體0.920.890.930.70這個結(jié)果基本可用。血小板mAP50-95偏低是正常的小目標(biāo)的邊界框預(yù)測容錯空間本來就小IOU稍高一點就判為錯不影響實際計數(shù)使用。如果非要追求更高的mAP50-95可以試試SAHI切片推理把大圖切塊放大再檢測只是速度會慢很多。4. UI界面、登錄注冊與系統(tǒng)集成4.1 界面框架選型項目里需要的界面有兩塊登錄注冊窗口和檢測主窗口。我選了PyQt5來做理由是控件生態(tài)完善文件選擇、圖片展示、表格展示都有現(xiàn)成組件。布局用Qt Designer或者直接代碼寫都比較順手適合快速開發(fā)。打包成exe的成熟度高配合PyInstaller踩坑少。Tkinter也可以但做這種帶圖片顯示、實時狀態(tài)更新的界面PyQt5的體驗好不少。代價是打包體積會大一些但幾十MB的安裝包在本地部署完全可以接受。4.2 用戶系統(tǒng)的代碼實現(xiàn)先看數(shù)據(jù)庫設(shè)計。用戶信息我用SQLite存建表語句CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, salt TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );密碼不能明文存儲這是底線。我用了加鹽哈希方案注冊時生成一個隨機鹽把鹽和密碼拼接之后做SHA256迭代哈希把鹽和哈希結(jié)果都存到數(shù)據(jù)庫里。登錄驗證時用同樣的鹽把輸入的密碼哈希一遍再和數(shù)據(jù)庫存儲值比對。下面的代碼是核心部分import hashlib import os import sqlite3 def hash_password(password, saltNone): if salt is None: salt os.urandom(16).hex() digest hashlib.pbkdf2_hmac(sha256, password.encode(), salt.encode(), 100000) return salt, digest.hex() def register_user(username, password): conn sqlite3.connect(system.db) cursor conn.cursor() if cursor.execute(SELECT id FROM users WHERE username?, (username,)).fetchone(): return False, 用戶名已存在 salt, digest hash_password(password) cursor.execute(INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?), (username, digest, salt)) conn.commit() conn.close() return True, 注冊成功這里的核心是pbkdf2_hmac迭代10萬次避免彩虹表和暴力破解。實際做的時候我還加了登錄失敗次數(shù)限制連續(xù)失敗五次鎖定五分鐘防止有人寫腳本暴力爆破。4.3 檢測主界面與整體交互檢測主界面的邏輯不復(fù)雜但有一個關(guān)鍵點模型推理必須放到子線程不能放在UI主線程里。否則檢測一張大圖需要一兩秒界面會直接卡成未響應(yīng)。我用了QThread來做異步推理class DetectorThread(QThread): result_ready pyqtSignal(dict) def __init__(self, model_path, image_path, conf_thres0.25, iou_thres0.45): super().__init__() self.model YOLO(model_path) self.image_path image_path self.conf_thres conf_thres self.iou_thres iou_thres def run(self): results self.model.predict( sourceself.image_path, confself.conf_thres, iouself.iou_thres )[0] boxes results.boxes counts {RBC: 0, WBC: 0, Platelets: 0} for cls_id, conf in zip(boxes.cls.tolist(), boxes.conf.tolist()): class_name results.names[int(cls_id)] counts[class_name] 1 self.result_ready.emit({ counts: counts, img: results.plot(), conf: conf, total: int(len(boxes)) })主界面布局我分了三個區(qū)域左上圖像展示區(qū)。用QLabel承載QPixmap顯示原始圖和檢測結(jié)果圖。右上操作區(qū)。上傳圖片、打開攝像頭、開始檢測、參數(shù)設(shè)置。底部統(tǒng)計欄。顯示三類細(xì)胞的計數(shù)、置信度平均值、總耗時。對于細(xì)胞計數(shù)場景我還在統(tǒng)計欄里額外加了“細(xì)胞數(shù)量占比”的顯示因為檢驗醫(yī)生不僅看絕對值也會關(guān)注白細(xì)胞比例是否異常這個比例在感染診斷里很有參考價值。實測下來這個小功能比想象中受歡迎。5. 常見問題排查與性能優(yōu)化5.1 模型層面小目標(biāo)漏檢、誤檢這個項目里90%的調(diào)優(yōu)時間都花在小目標(biāo)檢測上。我踩過的坑和解決路徑如下漏檢大量血小板。起初我把置信度閾值設(shè)到了0.5血小板這種小目標(biāo)置信度天然偏低大量被過濾。解決方式是閾值降到0.25同時對血小板類別在測試集上單獨做置信度校準(zhǔn)。把染色雜質(zhì)誤檢為細(xì)胞。血液涂片里常有染色沉淀物大小和血小板接近模型早期容易誤報。加了HSV顏色抖動增強后模型學(xué)會了分辨細(xì)胞和雜質(zhì)誤檢明顯減少。白細(xì)胞和血小板混淆。個別淋巴細(xì)胞體積偏小邊界框和大型血小板很接近。這種場景純視覺目標(biāo)檢測的區(qū)分其實有限我最后的處理是保持邊界框原樣但在統(tǒng)計欄對白細(xì)胞做特別提示讓醫(yī)生肉眼復(fù)核。5.2 系統(tǒng)層面卡頓、打包、模型文件路徑界面卡頓八成是前面說的線程問題。如果你打開界面上傳圖片之后就點不了按鈕十有八九是推理代碼放在了UI線程。把推理挪到QThread里界面立刻就順暢了。打包成exe之后還有一個非常經(jīng)典的坑模型文件路徑找不到。開發(fā)環(huán)境里直接寫weights/best.pt沒問題但PyInstaller打包后程序的當(dāng)前工作目錄可能會變導(dǎo)致模型文件加載失敗。解決辦法是用絕對路徑定位程序所在目錄import os import sys def resource_path(relative_path): base_path getattr(sys, _MEIPASS, os.path.abspath(.)) return os.path.join(base_path, relative_path) model YOLO(resource_path(weights/best.pt))另外exe啟動后如果殺毒軟件攔截多半是PyQt5的動態(tài)庫行為被誤報添加信任目錄就好不是代碼問題。5.3 幾條實戰(zhàn)調(diào)試建議訓(xùn)練之前先只跑十幾個step看loss能不能降下來能降再全量訓(xùn)練省時間。訓(xùn)練過程中的可視化看板建議開起來loss和mAP曲線一目了然遇到異常能盡早發(fā)現(xiàn)。導(dǎo)出模型推理時如果追求速度可以導(dǎo)出成ONNX格式用ONNXRuntime跑推理實測比PyTorch原生快一倍左右。攝像頭實時檢測時不要每幀都推理做跳幀處理比如每三幀推理一次中間兩幀沿用上一次結(jié)果體驗提升很大。最后再分享一點實際操作中的體會做這類輔助診斷系統(tǒng)最忌諱的就是把模型指標(biāo)當(dāng)成一切。YOLOv12的mAP做到93%聽起來不錯但真正能放進檢驗工作流里還要考慮數(shù)據(jù)偏移、邊界情況、界面交互的順暢度。我在這套系統(tǒng)的開發(fā)過程中花了不少時間做數(shù)據(jù)清洗和界面細(xì)節(jié)微調(diào)模型結(jié)構(gòu)反而是最省心的部分。這個項目后續(xù)還有很多可以擴展的方向比如增加白細(xì)胞五分類檢測、接入攝像頭實時多視野掃描、把計數(shù)結(jié)果自動生成檢驗報告等。如果你也在做類似的血細(xì)胞檢測系統(tǒng)我建議先把單張圖像的檢測效果打磨扎實再一步步往工程化方向走少走很多彎路。