在检验科待过的人,大概率见过这样的场景:医生坐在显微镜前,一张血涂片一张血涂片地推、看、数,视线在高倍镜和计数器之间来回切换。白细胞分类计数是个精细活,但也是高度重复的机械劳动。我当时就想,如果能把"看图找细胞并分类"这件事交给深度学习,医生只需要复核结果,效率至少提升一个量级。
于是就有了这套基于 YOLOv12/v11/v8/v5 的白细胞检测系统:底层用目标检测模型做定位和分类,上层用 PyQt5 搭建桌面界面,配合训练代码和数据集,从标注、训练到部署形成一条完整链路。本文把这套系统的设计思路、训练调参、界面关键代码、部署坑点全部拆开讲,适合正在做医学图像项目的学生、想用 YOLO 做桌面工具的开发者,以及刚接触目标检测、需要一个真实项目练手的人。
1. 为什么是YOLO:把白细胞检测从分类题变成定位题
1.1 传统方法的死胡同:阈值分割与手工特征为什么不够用
早期做白细胞识别,主流思路是图像分割加特征工程。比如先用颜色空间转换把细胞核和细胞质分出来,再用形态学开闭运算去掉噪声,最后通过面积、周长、圆度这些手工特征判断类别。这套方法在实验室自采的几十张图上可能跑得不错,但换一个染色条件、换一台显微镜,效果立刻崩盘。
原因不难理解。血涂片的染色差异极大,瑞氏染色、吉姆萨染色、H&E 染色的色相分布完全不同,即使同一染色,不同批次试剂、不同曝光时间也会导致颜色漂移。固定阈值和颜色范围天然无法适应这种变化。更麻烦的是细胞重叠,白细胞一旦聚集,分水岭算法经常把两个细胞分成一个,或者把一个细胞切开。手工特征就更脆弱了,细胞核形态、颗粒分布这些特征在不同放大倍数下差异明显,你很难手工定义出鲁棒性足够的规则。
传统方法本质是在"人工设计规则",而深度学习是"从数据里学规则"。当数据量足够、标注足够规范时,后者对染色差异和形态变化的包容度要高得多。
1.2 YOLO恰好命中两个痛点:定位与分类一体化
如果只做图像分类,模型只能回答"这张图里有没有白细胞",回答不了"白细胞在哪里、一共有几个"。而医学检验最需要的恰恰是位置和数量:全片白细胞总数、五分类比例、每个细胞对应的坐标。目标检测的定位能力正好补上这一环。
YOLO 的核心逻辑很朴素:把输入图像划分成网格,每个网格负责预测若干个候选框,每个候选框输出目标置信度、框坐标和类别概率,一次前向推理直接得到所有目标。相比两阶段检测器先提候选区域再逐区域分类的流程,YOLO 在速度和工程复杂度上有明显优势。两阶段模型精度未必差,但推理速度慢,在桌面级 GPU 甚至 CPU 上做实时交互,体验远不如 YOLO 顺畅。
这里顺带说一下版本差异。YOLOv5 是最成熟稳定的版本之一,社区资料多,踩坑记录全,适合做工程备份;YOLOv8 从 anchor-based 转向 anchor-free,省去了手动调 anchor 的步骤,训练配置更简洁;YOLOv11 和 YOLOv12 在特征提取网络和注意力机制上有新改进,精度上限更高,但版本较新,周边工具链的兼容性需要自己试。这套系统之所以把四个版本都纳入,是因为界面层只需要调用统一的 predict 接口,模型权重本质上是可替换的组件。
2. 数据集与标注:九成项目失败在数据上
2.1 数据从哪里来:公开数据集与自采标注的取舍
我一开始想直接找公开的白细胞检测数据集。网上确实有血液细胞图像数据集,但仔细检查后发现问题不少:有的只有分类标签没有位置框,有的标注类别只包含某几类白细胞,与检验科实际需要的五分类对不上,还有的标注质量参差不齐,部分细胞边界明显画偏了。如果直接拿来做训练,模型学到的是别人的错误习惯。
公开数据可以当预训练和验证用,最终落地还是离不开自己的数据。自采的渠道不复杂,用手机对着显微镜目镜拍摄,或者直接用数字显微相机导出图像。这里要提醒一句:采集时一定要固定染色流程、放大倍数和相机参数,至少保证同一批数据的分布相对一致。如果今天用 40 倍物镜拍,明天又换 100 倍油镜拍,模型会被迫学一个"跨倍率通用"的中间表达,这种表达往往两边都不讨好。
2.2 标注规范:哪些边界情况会直接拉低mAP
标注是这整套系统里最枯燥、也最决定上限的环节。我用标注工具画框时,定了几条硬规矩:
- 类别划分按检验科标准走。如果项目目标只是白细胞总数,单设一个 WBC 类即可;如果要做五分类,就分中性粒细胞、淋巴细胞、单核细胞、嗜酸性粒细胞、嗜碱性粒细胞五类。
- 框的范围以细胞膜边界为准。白细胞和红细胞挨得近的时候,宁可稍微收一点框,不要把红细胞包进去。
- 重叠细胞的处理:如果两个细胞重叠但边界可辨,分别画框;如果重叠到完全无法判断边界,直接跳过,不硬标。
最后一条容易被忽略:嗜碱性粒细胞在正常血涂片里数量极少,标注一百张图可能都标不出几个。这种类别不平衡会导致模型对这个类别几乎不学习。解决思路要么增加针对性采集,要么在损失函数里加大这个类别的权重,后者我放在调优章节细说。
2.3 数据增强与划分:让模型看到"染色差异"这个真实分布
标注完成后,我通常会做两轮增强。第一轮是几何增强,包括随机翻转、旋转 90 度、小角度旋转和仿射变换,让模型对细胞朝向不敏感。第二轮是色彩增强,包括亮度、对比度、色相、饱和度的随机扰动。这轮才是关键,因为真实场景中不同涂片染色深浅差异很大,色彩扰动相当于用数据告诉模型:染色变浅变深,它仍然是白细胞。
数据划分有个容易被新手踩坑的地方:不要按单张图片随机划分。同一张涂片的不同视野之间背景非常相似,随机划分会导致验证集和训练集高度相关,验证分数虚高。正确做法是按涂片编号划分,一张涂片产生的所有视野图要么全进训练集,要么全进验证集。我常用的比例是训练集 70%、验证集 15%、测试集 15%。测试集留到最后一版模型才允许看,防止反复调参把测试集"调到过拟合"。
3. 训练前的准备:环境、目录结构与YOLO版本怎么选
3.1 硬件与依赖:没有昂贵GPU也能跑
训练目标检测模型没想象中那么吃硬件。白细胞检测的输入图通常在 640x640 附近,YOLOv8n、YOLOv5s 这类小模型在普通消费级显卡上就能跑。我的主力训练卡只是一张二手中端卡,显存 8GB,batch size 设到 16,训练一百轮完全没问题。如果没有独立 GPU,CPU 也能训练,但速度慢很多,更适合先拿几十张图验证流程,真正的正式训练还是建议租一个有 GPU 的云端实例。
依赖安装很简单,核心是 ultralytics 这个训练推理库。它同时支持 YOLOv8、YOLOv11、YOLOv12 等版本,接口风格统一。YOLOv5 是独立仓库,需要单独 clone 后安装,训练入口是仓库下的train.py。
pip install ultralytics # YOLOv5 独立仓库方式 git clone https://github.com/ultralytics/yolov5 cd yolov5 pip install -r requirements.txt这里有一个版本锁定的建议:项目一旦跑通,就把 ultralytics 的版本号固定在你的环境文件里,不要随手升级。我见过不少项目今天升级了依赖,明天训练参数报错,后天推理结果形状变了,排查半天才发现是新版库改了接口。
3.2 文件目录与训练代码骨架
数据准备好了,目录结构建议长这样:
wbc_project/ ├── datasets/ │ ├── images/ │ │ ├── train/ │ │ └── val/ │ └── labels/ │ ├── train/ │ └── val/ ├── data.yaml ├── train.py └── run.pydata.yaml是训练和推理都要用到的配置文件,内容总共没几行:
path: ./datasets train: images/train val: images/val nc: 5 names: ["neutrophil", "lymphocyte", "monocyte", "eosinophil", "basophil"]注意nc和names的顺序必须与标注文件里的类别 id 严格一致。YOLO 格式的标签是这样的:每一行代表一个目标,格式为class cx cy w h,坐标是相对于图片宽高的归一化值,取值范围 0 到 1。比如一张 4000x3000 的图上,一个中心在 (2000, 1500)、宽高各 300 的淋巴细胞,对应的行就是1 0.5 0.5 0.075 0.1。
3.3 YOLOv5/v8/v11/v12到底怎么选:我按项目需求排序
这是这篇内容里最常被问到的问题。我的选择不完全是"哪个精度高选哪个",而是按照项目阶段来排序。
| 版本 | 成熟度 | 上手成本 | 精度上限 | 注意点 |
|---|---|---|---|---|
| YOLOv5 | 高 | 低 | 中 | 文档多,anchor-based,参数稍繁琐 |
| YOLOv8 | 高 | 低 | 中高 | anchor-free,接口统一,强烈推荐入门 |
| YOLOv11 | 中 | 中 | 高 | 特征提取结构更新,需留意依赖兼容 |
| YOLOv12 | 中低 | 中 | 高 | 最新版本,适合尝鲜,不建议直接上生产 |
如果你的目标是快速跑通 demo,我建议先选 YOLOv8n。n 版本是小模型,训练快,推理快,足够验证数据标注是否靠谱。等你确认标注质量和数据分布没问题,再换 v11、v12 或更大的 m 尺寸去追精度。换模型这件事在界面层几乎是无痛的,因为推理代码只需要改一行模型路径,这也是这套系统设计时故意做的解耦。
4. 训练实操与精度调优:从跑通到能用的距离
4.1 第一轮训练:先把验证集跑出baseline
我习惯不修改任何超参数跑第一轮,目的不是追求精度,而是确认数据链路是通的。训练脚本用 ultralytics 的写法非常短:
from ultralytics import YOLO model = YOLO("yolov8n.pt") results = model.train( data="data.yaml", epochs=100, imgsz=640, batch=16, patience=20, device=0, project="runs/wbc", name="baseline", )这里几个参数依次解释一下。imgsz=640是输入分辨率,显微镜原始图通常几千像素,训练时会等比缩放到 640,这个值兼顾速度和精度,后续调优可以往上提到 1280,但显存消耗会显著增加。batch=16是单次迭代喂入的图片数,显存不够就降到 8。patience=20表示连续 20 轮验证指标没有提升就提前停止,防止数据量小时无谓过拟合。
第一轮跑完,我会重点看三个指标:mAP50 是否大于 0.7、mAP50-95 是否大于 0.5、验证集混淆矩阵里五个类别的召回是否均衡。mAP50 衡量的是框和真实框 IoU 超过 0.5 时的平均检测精度,适合判断"大概找到了没有";mAP50-95 更严格,能反映定位精度。白细胞的形态相对规则,我给自己定的及格线是 mAP50 不低于 0.85。
4.2 三档调优:超参数、增强策略与模型尺寸
baseline 跑通后,调优按三个方向依次推进。
第一档是增强策略。如果训练集只有几百张图,我会把hsv_h、hsv_s、hsv_v适当调大,模拟染色波动的真实分布。但增强不是越大越好,过度增强会让模型在推理时面对干净的图像反而不知所措。我的做法是训练时增强强度设为中等,推理时不去做任何增强。
第二档是超参数。出现过拟合,降低学习率、加大权重衰减;出现欠拟合,适当提高lr0并延长训练轮数。ultralytics 可以用hyp.yaml文件单独管理超参数,方便记录每次实验的差异。这里强烈建议用实验管理表格记录每组实验的配置和指标,否则调几天后你会彻底忘记哪组超参数对应哪次结果。
第三档是模型尺寸。数据量不足 1000 张时,用 n 或 s 尺寸;超过 2000 张,可以上 m 或 l。模型越大不一定越好,小数据集上大模型几乎必然过拟合。白细胞检测和自然场景不同,细胞形态特征并没有复杂到需要超大模型才能表达,m 通常就到头了。
4.3 常见训练异常:loss不降、mAP为0、显存溢出
训练过程中最容易遇到三类问题,我把排查方案列出来:
- loss 直接变成 nan:优先检查学习率是不是太大,把
lr0从默认值降到原来的四分之一试试;其次检查数据集里有没有损坏的图片或空标注文件。 - mAP 一直是 0:九成是类别 id 对不上。训练代码里
names的顺序和标注文件里的 class 值必须一致,比如标注文件中 class 为 0 对应中性粒细胞,data.yaml的第 0 个名字也必须写中性粒细胞。另外检查验证集标签是否为空文件。 - CUDA out of memory:先降 batch 到 8 或 4,再降 imgsz 到 480,两个都不行就换更小的模型。显存溢出多数时候不是机器的错,而是参数设置不切实际。
我在做这套系统时,第二类坑踩得最狠:明明验证集有几百个标注框,mAP 却一直是 0,最后发现某个子目录的图片没有对应的 labels 文件,模型眼里这些图片全部是空白图。镜像检查所有图片和标签文件一一对应,是开始训练前最重要的准备工作。
5. PyQt5界面:让模型不再躺在notebook里
5.1 界面布局与交互逻辑
训练好的模型最终要交给可能完全不懂代码的人用,界面的重要性不亚于模型本身。我用 PyQt5 做了一个三栏式布局:顶部菜单栏负责文件打开、摄像头启动、模型切换;左侧是原图和检测结果图的展示区;右侧是检测结果表格,包括每个检出细胞的类别、置信度、坐标;底部状态栏显示推理耗时和当前模型名称。
这个布局的交互逻辑是:用户点"打开图片",程序加载图像并调用模型推理,推理完成后在左侧显示画好框的图片,同时右侧表格逐行列出血细胞信息。导出功能支持 CSV 和 JSON,方便检验人员把结果导入报告系统。整个界面不需要用户接触任何代码。
5.2 QThread推理线程:不卡界面的核心写法
第一次做界面时我犯过一个典型错误:直接在按钮回调里跑模型推理,结果点一下"开始检测",界面立刻白屏卡死几十秒。原因很简单,PyQt 的主线程负责界面刷新,如果主线程被推理操作阻塞,窗口就会失去响应。
正确写法是把推理放到QThread子线程里,通过信号把结果传回主线程更新界面。核心逻辑可以写成这样:
import threading from PyQt5.QtCore import QThread, pyqtSignal class DetectThread(QThread): finished = pyqtSignal(dict) def __init__(self, model, image, conf=0.25): super().__init__() self.model = model self.image = image self.conf = conf def run(self): results = self.model.predict( source=self.image, conf=self.conf, verbose=False ) boxes = results[0].boxes.xyxy.cpu().numpy() confs = results[0].boxes.conf.cpu().numpy() clses = results[0].boxes.cls.cpu().numpy().astype(int) names = results[0].names self.finished.emit({ "boxes": boxes, "confs": confs, "clses": clses, "names": names, })这里有一个重要的纪律:run()里不能直接操作任何界面控件,只能通过pyqtSignal传数据。界面更新放在主线程的槽函数里,这样无论推理多久,窗口都能保持流畅响应用户操作。模型对象只加载一次,不要每检测一张图就重新加载权重,否则耗时全浪费在 IO 上。
5.3 结果展示、导出与阈值实时调整
检测结果画框时,最容易被忽略的是图片缩放坐标系。界面展示区可能只有 600 像素宽,但原图是 4000 像素,直接把 YOLO 输出的坐标画在 QPixmap 上会对不齐。我的做法是先把原图缩放成用于显示的 QPixmap,记录缩放比例scale = display_width / original_width,画框时把模型输出的坐标乘以这个比例,显示才不会飘。
阈值调整和推理解耦也很重要。界面上放一个置信度滑条,用户拖到 0.2 或 0.5,我不建议立刻重新跑一遍完整模型推理——对单张静态图可以接受,但切到摄像头实时检测时就会卡顿。更合理的方案是推理线程以较低的置信度保留下所有候选框,界面端根据滑条值实时过滤展示和更新表格。这样滑条响应是瞬时的,体验好很多。
导出 JSON 时坐标一定映射回原图尺寸,不然下游分析拿到的是界面缩放后的坐标,轻则数据错位,重则影响计数可靠性。我导出的字段是:类别名、置信度、原图坐标 x1 y1 x2 y2。
6. 部署避坑:从验证集高分到实际落地的差距
6.1 导出ONNX:让推理摆脱训练框架
模型在训练框架里推理没问题,但部署到没有 GPU 的电脑上就麻烦了。我的做法是导出 ONNX 格式,用 ONNX Runtime 做推理,这样目标机器只需要安装 CPU 版本的 ONNX Runtime,连 PyTorch 都不用装。
from ultralytics import YOLO model = YOLO("best.pt") model.export(format="onnx", dynamic=True, simplify=True)导出后要重点检查三件事:输入张量的通道顺序是不是 NCHW、归一化方式是否和训练一致(一般是除以 255)、输出层是原始预测还是已经带 NMS 后处理。不同 YOLO 版本导出的输出格式不同,YOLOv8 之后的版本标准导出会把 NMS 后处理逻辑剥离,输出的是未过滤的预测张量,需要自己在推理代码里实现非极大值抑制。这一点如果没查清楚,直接套用旧代码会得到一堆重叠框。
6.2 批处理文件夹图片时的显存与内存管理
实际使用中用户经常会把整个文件夹的涂片图片一次性丢进来跑批处理。新手最容易犯的错误是循环里把所有图片读进内存再统一推理,几张几千像素的大图就能把内存吃光。正确做法是一次只读取和推理一张图,结果写回磁盘后主动释放输入数组。
批量处理时推理耗时比单张慢是正常的,因为 YOLO 用到 GPU 时每张图都有自己的预热时间。如果追求吞吐量,可以用固定 batch 的批量推理,比如每批次 4 张图。但要特别注意,批量推理时所有输入图必须缩放到相同尺寸,处理回归真实分辨率时,输出坐标再除以各自的缩放比例即可。
6.3 误报与漏检的现场对策
验证集 mAP 很高不代表现场好用。实际血涂片里经常有染料沉渣、杂质颗粒、红细胞聚集区域,这些都会被模型误识别成白细胞。我的对策分三层:
- 第一层是置信度阈值。现场环境可以比验证集调高一点,比如验证集用 0.25,现场固定到 0.4 以上,牺牲少量召回换取干净结果。
- 第二层是面积过滤。白细胞在特定放大倍数下尺寸区间相对固定,把检测框宽度和高度明显偏离区间的结果直接丢弃。
- 第三层是形态学校验。对候选框做轮廓分析,计算圆形度和内部纹理复杂度,过滤掉边缘过于锯齿化或内部颗粒异常的目标。
漏检问题多发生在细胞聚团场景。单个细胞们挤成一团时,YOLO 的检测框经常只框出其中一个,或者把几个并成一个。如果这类场景占比很大,建议引入滑窗切图:把大图切块后放大推理,再按原图坐标合并结果。这样能明显改善小细胞密集区域的漏检,代价是推理耗时成倍增加。
7. 如果重新做一遍,我会如何取舍
这个项目从零到能演示,走了不少弯路。如果让我重新做,第一件事不是选模型,而是花更多时间确定标注规范和采集方案。模型换来换去最多损失一个周末,标注标准不统一会让整个数据集报废,重标代价是几周起。
第二件事是先把最小闭环跑通。用 10 张图标注、训练、界面显示,这套流程哪怕做得粗糙,也要在项目第一周完成。很多人在数据收集阶段就陷入完美主义,标注了上千张才第一次跑模型,结果发现标注格式有问题,全部重来。小规模闭环的意义不是出精度,而是尽早验证每个环节之间的接口是正确的。
第三件事是界面里加一个"保存误检样本"的按钮。现场使用中收集到的误报和漏检图,比公开数据集珍贵得多。把这些图累积下来,每隔一段时间增量训练一轮,模型会越来越贴合真实场景。这也是这套系统最值得长期投入的部分。
最后分享一个个人的小习惯:每次训练出新的最优权重,我不会立刻覆盖旧权重,而是按日期归档。界面切换模型用的是下拉框,回归测试时一键对比新老模型在同一组测试图上的表现。白细胞检测这类医学辅助工具,稳定性比单点精度重要得多。宁可新模型只提升 0.01 的 mAP,也不能让它在一张旧图上凭空多出几个离谱的误检。