血常规检验是医院检验科每天的必修课,红细胞、白细胞、血小板的计数直接关系着感染、贫血、凝血功能的判断。过去靠人工瞪显微镜数细胞,费眼费神不说,结果还容易受疲劳影响。这几年基于深度学习的目标检测算法逐渐普及,我最近就用YOLOv12做了一套完整的血细胞检测系统:模型端基于YOLOv12和公开血细胞数据集完成训练,界面端实现了登录注册UI和检测主界面,整套代码用Python写成,可以打包成桌面应用直接跑起来。这篇文章就当一次完整的技术复盘,把思路、踩坑、实操参数都摊开讲讲。
1. 项目整体设计与技术选型
1.1 血细胞检测到底难在哪
很多人一听说检测红细胞、白细胞、血小板,第一反应是“数数而已,图像分割或者阈值不就搞定了”。真上手就知道不是这么回事。血液涂片经过染色之后,细胞在显微镜下呈现的特征确实有规律,但真实样本里挑战点特别多:
- 目标尺寸差异大。红细胞直径大约6到8微米,白细胞10到20微米,血小板只有2到4微米。在一张640乘640的图像里,血小板可能只占十几个像素,属于典型的小目标检测问题。
- 密度极高且互相粘连。红细胞在视野里常常一个贴着一个,边界模糊,人眼都要仔细看。传统分割算法在这种场景下很难把细胞一个个分出来。
- 类别严重不均衡。一张正常血涂片里,红细胞数量是白细胞的几百倍甚至上千倍,血小板数量居中。模型很容易被红细胞带偏,导致稀有类别检出率低。
这些特点决定了:用传统图像处理只能做做简单场景,想要在真实血涂片图像里稳定数出三类细胞,还是得走目标检测的路子。
1.2 为什么选了YOLOv12而不是其他模型
我最早用的其实是YOLOv5和Faster R-CNN,后来也试过YOLOv8和YOLOv10。简单说下我的感受:
| 模型 | 优势 | 在这个场景里的问题 |
|---|---|---|
| Faster R-CNN | 精度确实高,两阶段检测对小目标友好 | 推理速度慢,帧率上不去,很难做成实时辅助工具 |
| YOLOv5 | 生态成熟,资料多 | 对于密集小目标的召回率不太够 |
| YOLOv10 | 无NMS设计,推理快 | 在密集重叠场景下容易出现重复框或漏检 |
| YOLOv12 | 引入注意力机制,精度提升,速度保持 | 刚出不久,参考资料相对少,需要自己多试 |
YOLOv12是今年初发布的模型架构,和之前版本最大的区别是把注意力机制和YOLO的CSP结构做了有效融合,用的是一种叫区域注意力(Area Attention)的机制。简单理解:传统Transformer里的注意力要对整张图两两计算相似度,计算量大;YOLOv12的区域注意力把计算范围限制在局部区域,显著降低计算量和内存占用,同时保留了对关键特征的建模能力。
放在血细胞检测这个场景里,这种“精度和速度兼顾”的路线正好契合需求:一方面细胞尺寸小、密度大,需要有足够强的特征表达能力;另一方面,临床辅助工具讲究响应够快,最好能做到实时或接近实时。我实测下来,同样的硬件条件下,YOLOv12的精度比YOLOv10有明显提升,推理速度没有明显退步,所以最终选了它。
1.3 系统架构与模块划分
整个系统我按三层来拆:
- 数据层:负责数据集的整理、标注格式转换、数据增强。这一层决定了模型能学到什么,比重最大。
- 模型层:YOLOv12模型的训练、验证、导出。训练阶段跑出最优权重,推理阶段提供检测和统计结果。
- 界面层:Python实现的桌面应用,包含登录注册窗口和检测主窗口。用户在主窗口上传图片、在线推理、查看结果和统计数量。
之所以把界面单独做成桌面应用而不是用Web,原因是我希望这套系统能在不联网的环境下用,比如检验科的内网电脑。Python的PyQt5打包成exe之后,没有网络也能跑,部署成本最低。这套架构对于想快速用起来的场景很实用,后续要加摄像头实时检测、批量处理之类也都在解耦后的模块上做扩展。
2. 数据集构建与预处理
2.1 数据来源与清洗思路
我做这个项目用的血细胞数据集,选取了常用的公开血液涂片数据集,包含了红细胞、白细胞、血小板三类目标的标注框,图像是显微镜下的血液涂片样本。公开数据集的好处是标注相对规范,拿来就能用,适合做模型验证。
但有一说一,公开数据集和真实医院检验科拍出来的图还是有差异的,主要体现在染色条件不同、视野区域不同、放大倍数不同。所以在拿到数据之后,我做了两步过滤:
- 剔除模糊和染色异常的图。有些图明显对焦不准,标注再准也没有训练价值,反而会把模型带偏。
- 检查标注框是否贴合目标边界。偶尔会有标注框把背景大片框进去的情况,这种样本在训练时会引入噪声。
工业落地的时候建议再补充自己实验室的涂片数据。每个人实验室的染色方案、显微镜型号不同,图像分布会偏移,用公开数据集做预验证可行,但要放到真实环境得靠自己的数据做微调。
2.2 标注格式转换与数据集划分
YOLO系列训练需要的是txt格式的标注文件,每行代表一个目标框:
class_id x_center y_center width height注意这里的坐标值是归一化到0到1的浮点数,不是像素坐标。而很多公开数据集的标注是PASCAL VOC格式的XML文件,里面直接给的是bbox的像素坐标。所以第一步要做的就是把XML转成YOLO格式的txt。
转换的核心逻辑是读取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))转换完成之后,建立标准的目录结构:
dataset/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ └── labels/ ├── train/ ├── val/ └── test/数据集划分比例我用的是8:1:1,即80%训练集、10%验证集、10%测试集。划分的时候要注意先按“图像”为单位随机打乱,千万不要直接按文件名顺序切,不然后面的样本全是同一批染色条件,验证集就失去了意义。
2.3 数据增强与类别不平衡
血细胞数据集天然存在类别不平衡,红细胞数量太多,白细胞太少。如果直接灌进去训练,模型会倾向于把一切像细胞的东西都判定为红细胞,白细胞的召回率会很难看。
处理方式我做了三件事:
- 统计类别分布。先跑一遍脚本,把三个类别的标注数量统计出来。拿到数字之后,对数量偏少的类别给更高的样本权重。
- 针对少数类做过采样。训练时对包含白细胞或血小板较多的图像提高采样概率,相当于让模型在每一轮训练里多看几遍这些稀有样本。
- 数据增强。YOLOv12自带的数据增强策略要充分利用起来,我在配置里主要开了这么几项:
| 增强方式 | 参数设置 | 作用 |
|---|---|---|
| HSV颜色抖动 | hsv_h=0.015, hsv_s=0.4, hsv_v=0.4 | 模拟不同染色条件下的颜色差异 |
| 随机水平翻转 | fliplr=0.5 | 增加样本多样性 |
| Mosaic | mosaic=1.0 | 把四张图拼在一起,提升模型对密集场景的适应能力 |
| MixUp | mixup=0.1 | 图像融合增强,缓解过拟合 |
有一点需要注意:血细胞涂片不适合做大幅旋转增强。细胞本身有形态学特征,比如红细胞是中间凹陷的盘状,旋转90度甚至180度之后形态特征会产生歧义,模型容易被带偏。所以我刻意没有开rotate增强,保留flip和颜色抖动就够用了。
3. YOLOv12核心机制与训练实录
3.1 YOLOv12的关键改进
YOLOv12对应的论文标题是“Attention Is All You Need”思路在YOLO架构里的实践,但它没有生搬硬套Transformer,而是做了两个非常关键的取舍。
首先是区域注意力机制。传统注意力理论上是全局的,即每个像素都要和所有其他像素计算相关性,这在图片尺寸稍大一点的时候内存就扛不住了。YOLOv12将注意力计算限制在目标中心的局部邻域内,每片区域独立计算,大幅降低计算量。从效果看,这个设计对中小尺寸目标特别奏效,因为小目标本身需要的就是局部上下文,不属于“全局信息依赖型”的目标。
其次是注意力与跨阶段特征的融合方式。YOLO系列一直用CSP结构减少计算冗余,YOLOv12把注意力模块嵌入到这个结构里,不是简单地在输出端挂一个注意力层,而是让注意力参与不同层级特征的融合,保持实时性的同时提升表征能力。
对我这种做实际项目的人来说,我关心的不是理论多漂亮,而是这个模型在不上大显卡的前提下能不能用。我在一块消费级显卡上跑YOLOv12n,批大小为16,640输入分辨率,显存占用在可控范围内,这比想象中友好。
3.2 训练配置与超参数选择
训练配置这里,我把能直接用的YAML和命令贴出来:
# blood_cell.yaml path: ./dataset train: images/train val: images/val test: images/test names: 0: RBC 1: WBC 2: Platelets训练命令:
yolo train model=yolov12n.pt data=blood_cell.yaml epochs=150 imgsz=640 batch=16 device=0 optimizer=AdamW lr0=0.001 patience=20几个参数的选择逻辑:
- imgsz=640是默认值,但如果目标是血小板这种小目标,我建议提高到768甚至960。分辨率越高,小目标的像素占比就越大,检测效果通常显著变好,代价是训练推理速度下降。我做了一组对比:640输入下血小板AP50接近80%,960输入下能到86%左右。
- optimizer=AdamW。YOLO系列默认优化器是SGD,但我用AdamW在微调阶段收敛更平稳。如果是从零开始训练,SGD也完全可以。
- patience=20是早停参数。验证集mAP连续20轮没有上升就停掉,避免无效的训练时间。
3.3 训练过程与技术指标
训练过程大概分了三个阶段:前30轮loss下降非常快,从十几降到五左右,这是模型在快速学习主导类别红细胞的特征;30到80轮loss下降变缓,主要是在提升白细胞和血小板的识别能力;80轮之后进入平台期,早停在120轮左右触发。
最终在测试集上的指标参考:
| 类别 | Precision | Recall | mAP50 | mAP50-95 |
|---|---|---|---|---|
| 红细胞 | 0.95 | 0.97 | 0.99 | 0.79 |
| 白细胞 | 0.91 | 0.86 | 0.93 | 0.72 |
| 血小板 | 0.90 | 0.83 | 0.88 | 0.58 |
| 整体 | 0.92 | 0.89 | 0.93 | 0.70 |
这个结果基本可用。血小板mAP50-95偏低是正常的,小目标的边界框预测容错空间本来就小,IOU稍高一点就判为错,不影响实际计数使用。如果非要追求更高的mAP50-95,可以试试SAHI切片推理,把大图切块放大再检测,只是速度会慢很多。
4. UI界面、登录注册与系统集成
4.1 界面框架选型
项目里需要的界面有两块:登录注册窗口和检测主窗口。我选了PyQt5来做,理由是:
- 控件生态完善,文件选择、图片展示、表格展示都有现成组件。
- 布局用Qt Designer或者直接代码写都比较顺手,适合快速开发。
- 打包成exe的成熟度高,配合PyInstaller踩坑少。
Tkinter也可以,但做这种带图片显示、实时状态更新的界面,PyQt5的体验好不少。代价是打包体积会大一些,但几十MB的安装包在本地部署完全可以接受。
4.2 用户系统的代码实现
先看数据库设计。用户信息我用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迭代哈希,把盐和哈希结果都存到数据库里。登录验证时用同样的盐把输入的密码哈希一遍,再和数据库存储值比对。下面的代码是核心部分:
import hashlib import os import sqlite3 def hash_password(password, salt=None): 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万次,避免彩虹表和暴力破解。实际做的时候我还加了登录失败次数限制,连续失败五次锁定五分钟,防止有人写脚本暴力爆破。
4.3 检测主界面与整体交互
检测主界面的逻辑不复杂,但有一个关键点:模型推理必须放到子线程,不能放在UI主线程里。否则检测一张大图需要一两秒,界面会直接卡成未响应。我用了QThread来做异步推理:
class DetectorThread(QThread): result_ready = pyqtSignal(dict) def __init__(self, model_path, image_path, conf_thres=0.25, iou_thres=0.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( source=self.image_path, conf=self.conf_thres, iou=self.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)) })主界面布局我分了三个区域:
- 左上:图像展示区。用QLabel承载QPixmap,显示原始图和检测结果图。
- 右上:操作区。上传图片、打开摄像头、开始检测、参数设置。
- 底部:统计栏。显示三类细胞的计数、置信度平均值、总耗时。
对于细胞计数场景,我还在统计栏里额外加了“细胞数量占比”的显示,因为检验医生不仅看绝对值,也会关注白细胞比例是否异常,这个比例在感染诊断里很有参考价值。实测下来这个小功能比想象中受欢迎。
5. 常见问题排查与性能优化
5.1 模型层面:小目标漏检、误检
这个项目里90%的调优时间都花在小目标检测上。我踩过的坑和解决路径如下:
- 漏检大量血小板。起初我把置信度阈值设到了0.5,血小板这种小目标置信度天然偏低,大量被过滤。解决方式是阈值降到0.25,同时对血小板类别在测试集上单独做置信度校准。
- 把染色杂质误检为细胞。血液涂片里常有染色沉淀物,大小和血小板接近,模型早期容易误报。加了HSV颜色抖动增强后,模型学会了分辨细胞和杂质,误检明显减少。
- 白细胞和血小板混淆。个别淋巴细胞体积偏小,边界框和大型血小板很接近。这种场景纯视觉目标检测的区分其实有限,我最后的处理是保持边界框原样,但在统计栏对白细胞做特别提示,让医生肉眼复核。
5.2 系统层面:卡顿、打包、模型文件路径
界面卡顿八成是前面说的线程问题。如果你打开界面上传图片之后就点不了按钮,十有八九是推理代码放在了UI线程。把推理挪到QThread里,界面立刻就顺畅了。
打包成exe之后还有一个非常经典的坑:模型文件路径找不到。开发环境里直接写weights/best.pt没问题,但PyInstaller打包后程序的当前工作目录可能会变,导致模型文件加载失败。解决办法是用绝对路径定位程序所在目录:
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的动态库行为被误报,添加信任目录就好,不是代码问题。
5.3 几条实战调试建议
- 训练之前先只跑十几个step,看loss能不能降下来,能降再全量训练,省时间。
- 训练过程中的可视化看板建议开起来,loss和mAP曲线一目了然,遇到异常能尽早发现。
- 导出模型推理时,如果追求速度,可以导出成ONNX格式,用ONNXRuntime跑推理,实测比PyTorch原生快一倍左右。
- 摄像头实时检测时不要每帧都推理,做跳帧处理,比如每三帧推理一次,中间两帧沿用上一次结果,体验提升很大。
最后再分享一点实际操作中的体会:做这类辅助诊断系统,最忌讳的就是把模型指标当成一切。YOLOv12的mAP做到93%,听起来不错,但真正能放进检验工作流里,还要考虑数据偏移、边界情况、界面交互的顺畅度。我在这套系统的开发过程中花了不少时间做数据清洗和界面细节微调,模型结构反而是最省心的部分。这个项目后续还有很多可以扩展的方向,比如增加白细胞五分类检测、接入摄像头实时多视野扫描、把计数结果自动生成检验报告等。如果你也在做类似的血细胞检测系统,我建议先把单张图像的检测效果打磨扎实,再一步步往工程化方向走,少走很多弯路。