在医学检验科待过的人都知道,粪便镜检找寄生虫虫卵这件事,看起来不难,实际上特别磨人。一套用于YOLO训练的寄生虫虫卵检测数据集,总共3600张标注图像,覆盖10个常见虫卵类别以及阴性背景,是我最近两个月一直在跟的项目核心。这篇不是科普,也不是官方文档,而是从数据采集、标注、训练到排查问题的完整复盘,希望能给做临床图像AI的人一点参考。整套流程踩过的坑不少,但模型最终在测试集上跑到了mAP50接近0.96的水平,已经能满足实验室辅助初筛的实用需求。
1. 数据集立项:为什么需要一套专业的寄生虫虫卵检测数据集
1.1 医学检验里的人工镜检痛点
寄生虫虫卵检测是临床检验科的基本功,也是最容易出主观偏差的项目之一。传统做法是检验人员将粪便样本做生理盐水涂片或者碘液染色涂片,放到显微镜下,用低倍镜扫视全片,再用高倍镜(通常是400倍)确认形态。一份样本往往要看几十个视野,虫卵密度低的时候,在充满食物残渣、气泡、植物纤维的背景里找到几枚十几微米到几十微米的虫卵,完全靠眼力和耐心。长时间工作后视觉疲劳,漏检率会明显上升,尤其是蛲虫卵、华支睾吸虫卵这类形态很小、颜色又浅的目标。
我一直觉得,用目标检测模型做辅助筛查,价值不在于取代检验医师的判断,而在于先把“可疑区域”圈出来。人的精力应该花在确认上,而不是漫无目的地找。YOLO这类单阶段检测器刚好合适:单张显微镜图像推理耗时在毫秒到几十毫秒级别,能实时处理视频流,也可以一次性批量筛查存档图片。这和检验科每天的样本吞吐量是匹配的。项目立项时,我们就明确要建一个覆盖常见虫卵类别、同时包含大量阴性背景干扰的专用数据集,而不是简单拿公开的通用目标数据集来迁移。
1.2 3600张数据集的组成思路与场景覆盖
这套数据集的原始图像来自临床粪便常规检查中的常见虫卵类型,包括受精蛔虫卵、未受精蛔虫卵、钩虫卵、鞭虫卵、蛲虫卵、华支睾吸虫卵(肝吸虫)、肺吸虫卵、日本血吸虫卵、带绦虫卵、微小膜壳绦虫卵,一共10类。之所以选这些类别,是因为它们基本都是国内人体寄生虫感染中检出率较高、检验科镜检时需要重点识别的对象,形态上也有足够的区分度。
数据集总规模3600张,其中阳性样本3000张,另外600张是经专家确认为“视野内无虫卵”的阴性样本。阴性样本特别重要,后面训练时会发现,没有负样本或者负样本太少,模型很容易把粪便里的未消化植物碎片、花粉颗粒、淀粉粒误判成虫卵。阳性图像中不同类别框的数量有差异,覆盖了常见类别多、罕见类别少这种真实分布。大致分布如下表:
| 类别 | 标注框数量 | 参考形态尺寸 | 常见误判对象 |
|---|---|---|---|
| 受精蛔虫卵 | 850 | 45~75 μm | 未受精蛔虫卵、花粉粒 |
| 未受精蛔虫卵 | 650 | 40~60 μm | 食物空泡 |
| 钩虫卵 | 520 | 35~40 μm | 细胞碎片、气泡 |
| 鞭虫卵 | 500 | 50~55 μm | 植物细胞 |
| 蛲虫卵 | 480 | 50~60 μm | 真菌孢子、结晶 |
| 华支睾吸虫卵 | 450 | 27~35 μm | 小型虫卵、背景颗粒 |
| 肺吸虫卵 | 380 | 80~120 μm | 细胞团块 |
| 日本血吸虫卵 | 330 | 90~120 μm | 粪便残渣 |
| 带绦虫卵 | 280 | 30~40 μm | 花粉粒细胞 |
| 微小膜壳绦虫卵 | 260 | 35~50 μm | 酵母菌、孢子 |
图像覆盖了生理盐水直接涂片、碘液染色涂片、改良加藤厚涂片三类常见制片方式。这个覆盖很重要:不同染色条件下,虫卵的颜色和背景对比度差异很大,只在单一条件下训练出来的模型,换一家医院就废掉一半。加上同一种虫卵本身就有发育程度差别(比如受精与未受精蛔虫卵的形态差距),以及虫卵在不同焦平面下呈现出的清晰度变化,最终模型学到的不应该是某个固定像素模板,而是一组可迁移的形态特征。
1.3 为什么选择YOLO而不是传统图像处理或双阶段检测器
早期医学图像识别喜欢先做一个分割或者特征提取,再用分类器判断。但虫卵检测场景里,形态学特征非常不稳定:虫卵在涂片中可能任意旋转,可能相互重叠,也可能被染料染成不同深浅。传统方法比如阈值分割、霍夫变换找圆、模板匹配,在干净的标准样本上都能跑,一旦放进真实临床样本,漏检率、误检率立刻变得没法看。原因很简单,传统特征依赖人工定义的轮廓、纹理、颜色分布,泛化能力跟不上真实样本的复杂度。
双阶段检测器比如Faster R-CNN,精度确实高,但推理速度慢,并且在训练时要维护候选区域生成环节,整体工程链更重。YOLO把目标检测当成一个回归问题,直接在特征图上预测边界框和类别,结构简单、训练流程统一,而且YOLOv8的生态已经非常成熟,转ONNX、转TensorRT都方便,后续部署到Windows工作站或者边缘设备都不用重写代码。所以在立项时,模型框架直接定了YOLO,先把数据管道和标注规范做好,用数据质量的提升换取模型精度的提升。
2. 数据采集与标注:决定模型上限的隐蔽工程
2.1 样本来源、图像采集与质量控制
图像数据全部来自合作检验科在常规临床检测中产生的涂片和数字显微图像。这里说一句经验:做医学图像数据集,采集前一定要把伦理和隐私流程走干净。我们使用的样本全部为临床检测剩余脱敏样本,图像中不含患者姓名、病历号等任何身份信息,采集方案也经过了相关伦理审查。在技术层面,采集使用的是数码显微镜摄像头,物镜40倍、目镜10倍,存储为不经过有损压缩过度处理的JPG或PNG。如果直接用手机拍目镜,虽然方便,但视野边缘会出现畸变和反光,后面模型很难收敛,不建议使用。
采集质量控制有几个容易被忽略的点。第一,同一标本上不要连续拍完全相同的视野,可以调整载物台移动,确保视野之间有一定重叠但不重复,增加背景多样性。第二,曝光和光源亮度要尽量统一,太亮会把浅色的未受精蛔虫卵洗掉,太暗又看不清鞭虫卵的透明卵壳。第三,要保留一小部分“带干扰”的图像,比如有大量气泡、食物纤维、涂片较厚区域的视野,这部分并不是坏数据,反而是训练模型学会忽略背景噪声的关键。第四,原始图像分辨率不强制统一,因为后续训练时可以统一resize,但每张图的元信息要记录采集放大倍数,后面分析size对精度影响时才有依据。
2.2 标注规范与YOLO格式转换细节
我们标注使用的是LabelImg,先标成Pascal VOC格式的XML,再统一转成YOLO训练用的txt格式。标注工具其实只是顺手用,关键在标注规则定义。我对规则的要求只有三条:一是标注框必须紧贴虫卵最外缘,把完整的卵壳包进去,不能为了省事留出大段背景;二是两个虫卵即使挨在一起,只要边界能够辨认,就分别标注,不能在中间画一个把两个目标包含进去的大框;三是如果某个目标形态不典型、自身也拿不准,宁可不标也不要硬标。第一轮标注完成后,必须经过一名寄生虫检验专家复核,复核时重点看两类:标注框偏移超过2个像素的,和类别存疑的。
YOLO格式的标签文件是纯文本,每行一个目标,格式是class x_center y_center width height,四个坐标值全部除以图像宽高进行归一化。从XML转YOLO时,很多新手会写错坐标换算。常见的转换代码如下:
import xml.etree.ElementTree as ET import os def convert_voc_to_yolo(xml_path, out_dir, class_dict): tree = ET.parse(xml_path) root = tree.getroot() img_w = int(root.find("size/width").text) img_h = int(root.find("size/height").text) objects = root.findall("object") if not objects: return base_name = os.path.splitext(os.path.basename(xml_path))[0] with open(os.path.join(out_dir, base_name + ".txt"), "w") as f: for obj in objects: cls_name = obj.find("name").text if cls_name not in class_dict: continue cls_id = class_dict[cls_name] box = obj.find("bndbox") x1 = float(box.find("xmin").text) y1 = float(box.find("ymin").text) x2 = float(box.find("xmax").text) y2 = float(box.find("ymax").text) x_center = (x1 + x2) / 2.0 / img_w y_center = (y1 + y2) / 2.0 / img_h w = (x2 - x1) / img_w h = (y2 - y1) / img_h f.write(f"{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}\n")转换之后,强烈建议做两件事。第一,随机抽一批图把边界框画回图上,肉眼检查有没有框错位、坐标越界。第二,写个脚本扫一遍所有txt文件,检查是否存在坐标值小于0或大于1的异常数据。一个越界的框足够让训练过程的损失值剧烈震荡。
2.3 数据划分与类别分布设计
数据集划分不是随意打乱就行。如果同一个患者的多张图像既出现在训练集又出现在验证集,模型其实已经通过图像间的高度相似性“作弊”了,验证结果会虚高。我在划分时,先按患者或者标本来源分组,确保同一来源的全部图像只进入同一个子集,再对上海量随机划分。训练集、验证集、测试集比例大约是70%、15%、15%。测试集和验证集的职能要分开:验证集用于选模型、看收敛状态,测试集只在最终评估时碰一次,否则调参时摸到测试集信息,评估就没说服力了。
类别均衡上,微小膜壳绦虫卵和肺吸虫卵数量相对少,尤其是微小膜壳绦虫卵,只有260多个框。我不会在训练集里简单复制这些图片,因为复制会造成重复样本过拟合。更合理的办法是在训练数据加载时用概率性采样让少数类别进入batch的概率稍微提高,或者使用mosaic、mixup增强后让少数类别目标保留。此外,600张阴性样本的分配也值得注意,建议其中大约4成放进训练集,3成放进验证集,3成放进测试集,确保模型训练时能看到背景干扰,评估时也能统计“健康样本误报率”。这部分细节不用特别复杂,但对模型落地时有直接影响。
3. 基于YOLO的训练全流程实操
3.1 环境准备与数据组织
训练环境我建议直接用conda管理,避免把系统Python搞乱。我使用的配置是Python 3.9、PyTorch 2.0.1、CUDA 11.8,显卡为NVIDIA RTX 3090,显存24GB。ultralytics库安装一行搞定:
pip install ultralytics如果显卡显存只有8GB到12GB,也不是不能跑,只是输入分辨率和batch要相应下调。数据目录结构尽量保持和YOLO官方惯例一致,方便后续接力训练和脚本处理。我的目录结构长这样:
dataset_root/ ├── data.yaml ├── images/ │ ├── train/ │ ├── val/ │ └── test/ └── labels/ ├── train/ ├── val/ └── test/data.yaml 内容也简单,直接列清楚路径和类别。需要注意YOLO的类别索引是从0开始的,所以第一个类的id是0,不要出现从1开始的错觉。class字典一旦确定,后面尽量不要再增删顺序,否则之前转好的标签全部要重来一遍。
path: /data/parasite_egg_dataset train: images/train val: images/val test: images/test names: 0: fertile_ascaris_egg 1: infertile_ascaris_egg 2: hookworm_egg 3: whipworm_egg 4: pinworm_egg 5: clonorchis_egg 6: paragonimus_egg 7: schistosoma_japonicum_egg 8: taenia_egg 9: hymenolepis_nana_egg3.2 模型选型与超参数配置
YOLOv8系列有n/s/m/l/x五个规格。医学检测场景通常不是追求极速,而是追求稳定和精度,所以我从s起步。参数量上,YOLOv8n约3.2M,YOLOv8s约11.2M,YOLOv8m约25.9M。在数据集只有3600张图像、标注框5100个左右的规模下,直接选m或l很容易陷入过拟合,训练时间长,提升不明显。先跑通s,确认数据管道无误后,如果要提点再看m也不迟。
训练时的关键参数我列在下面这张表里。注意这不是万能参数,但适合多数医学显微图像的YOLO检测任务:
| 参数 | 建议值 | 理由 |
|---|---|---|
| imgsz | 960 | 虫卵在640输入下往往只有20~40像素,960能显著改善小目标回归 |
| batch | 16 | RTX 3090下显存刚好,更大的batch会冲掉细节纹理信息 |
| epochs | 300 | 给足收敛时间,配合早停避免无效训练 |
| optimizer | SGD | 比AdamW更稳,泛化性好,调参经验成熟 |
| lr0 | 0.01 | SGD常用初始学习率,过大容易震荡 |
| lrf | 0.01 | 余弦退火末期学习率下降到初始的1/100,协调细颗粒回归 |
| momentum | 0.937 | YOLOv8默认推荐值 |
| weight_decay | 0.0005 | 轻量正则,对抗少量过拟合 |
| close_mosaic | 10 | 最后10轮关闭mosaic增广,让模型在正常图像分布上稳定收敛 |
这里单独说说输入分辨率。很多人用YOLO时习惯性填640,但虫卵检测不是常规物体检测。蛲虫卵和肝吸虫卵本身只有30微米左右,放到一张画面里,可能只有十几个像素宽,即便YOLOv8有深层的语义特征和浅层的尺度特征融合,关键信息也可能在多次降采样后损失掉。我把imgsz从640提到960后,验证集mAP50直接涨了3到4个点,代价只是训练时间增加约四成。如果你的显卡顶不住960,可以先在640把数据管道跑通,再用960训最终模型。
数据增强应该保留mosaic和mixup吗?医学图像和自然图像不同,不能无脑开满。像旋转、翻转、平移对虫卵形态没有破坏性,因为涂片方向随机,模型需要学的是形态而非朝向;但亮度扰动和颜色扰动要克制,因为染色条件本身已经是临床变量,过度颜色增强会让模型把“假的染色差异”当成特征。我最终用的是ultralytics默认增强里保留mosaic=1.0、mixup=0.1、degrees=180、fliplr=0.5,颜色相关的增强调低。
3.3 训练过程监控与权重评估
训练命令并不复杂,直接在项目目录下执行:
yolo detect train \ data=/data/parasite_egg_dataset/data.yaml \ model=yolov8s.pt \ imgsz=960 \ batch=16 \ epochs=300 \ patience=50 \ project=parasite_egg \ name=yolov8s_960 \ seed=42训练日志里要重点看三个loss曲线:box_loss、cls_loss、dfl_loss。box_loss下降缓慢是正常的,如果它出现周期性的尖峰,优先检查标注数据是否有坐标越界的框。cls_loss最直接反映分类难易,如果个别类别比如微小膜壳绦虫卵的cls_loss长期不降,就得回去看那个类别的正样本够不够。另外观察mAP50和mAP50-95在验证集上的走向,选模型时以“验证集mAP50-95”为主,不要单独追val loss。
训练结束后,用下面的命令在测试集上做一次严格评估:
yolo detect val \ data=/data/parasite_egg_dataset/data.yaml \ model=runs/detect/yolov8s_960/weights/best.pt \ split=test我这套配置训练到大约180轮时收敛,最终测试集结果大致是:mAP50 0.96,mAP50-95 0.88,其中蛔虫卵、钩虫卵、鞭虫卵这些框数量多的类别表现最好,微小膜壳绦虫卵和带绦虫卵因为样本少,mAP50在0.92左右。还需要额外看一列指标:阴性样本上的误检率。测试集有180张阴性图,我统计下来平均每张阴性图会被误报0.12个框,也就是大约每8张阴性图才出现一次假阳性,通过把置信度阈值从默认0.25提到0.4,误检率能进一步压下去。
4. 训练中的常见问题与排查技巧
4.1 小目标漏检与背景误检
寄生虫虫卵检测最典型的问题就是小目标漏检,尤其是华支睾吸虫卵和微小膜壳绦虫卵,一个框中可能只有20个像素宽。提高输入分辨率最直接,但还有两个更便宜的办法。一个是在推理时做滑窗切分,把原图切成若干有重叠的patch,分别送进模型,再把结果合并,这样每个patch中的目标尺寸相对变大,小目标检出率明显提升。另一个是训练时自定义增加浅层检测头,类似给YOLO加一个P2输出层,让高分辨率特征图直接参与预测。这个方法需要对ultralytics的模型结构做一点改动,但收益不小。
背景误检则更多是负样本不足导致的。粪便镜检图像里,气泡、脂肪滴、植物细胞、结晶都可能长得像虫卵。训练集里如果只有阳性图像,模型会认为“只要是个圆形的深色块就可能是虫卵”。我的经验是,把600张阴性样本全部加入训练还不够,最好在推理前再做一次规则去重:对检测框做形态学特征统计,比如排除宽高比极端异常或者面积明显超出合理范围的框。当然这一步不要做得太狠,否则又把真正的虫卵框误杀了。
4.2 标注错位与边界框回归不稳
标注问题会直接表现为训练损失震荡、验证集定位精度上不去。最典型的症状是预测框普遍偏大或偏小,而且偏移方向固定。这时候首先要怀疑的不是模型,而是标注框本身。用训练阶段的输出图片可视化,也就是把yolo detect train生成目录里带标签叠加的预览图翻出来,和原始图像逐张对比,很快就能找到标注通病。
我写了一个极简检查脚本,用来扫描转换后的标签文件是否有越界坐标。
import os def check_labels(label_dir): for fn in os.listdir(label_dir): if not fn.endswith(".txt"): continue path = os.path.join(label_dir, fn) with open(path, "r") as f: for line_no, line in enumerate(f, 1): parts = line.strip().split() if len(parts) != 5: print(f"{fn}: line {line_no} field error") continue _, xc, yc, w, h = parts x1 = float(xc) - float(w) / 2 y1 = float(yc) - float(h) / 2 x2 = float(xc) + float(w) / 2 y2 = float(yc) + float(h) / 2 if not (0 <= x1 <= x2 <= 1 and 0 <= y1 <= y2 <= 1): print(f"{fn}: out of range at line {line_no}")如果发现越界,不要抱着侥幸心理,直接在训练前重新定位那几张图。还有一个隐蔽问题是不同标注员对“卵壳外缘”的理解不同,有人喜欢把模糊的最外圈也包进去,有人只标内层轮廓。这种系统性偏差会导致同一个小目标被标注成不同大小,模型回归就很难稳定。解决办法是给标注团队提供一份带标准框示例的标注图集,每周抽一批复核。
4.3 过拟合与泛化能力不足
只有3600张图像,模型很容易过拟合。过拟合的显著特征就是train loss持续下降,val loss在某个epoch后开始反弹,测试集mAP和验证集相差非常大。对付过拟合,我看顺序是这样的:先加数据增强,再缩短训练轮次或早停,最后再考虑模型裁剪。数据增强方面,除了前面说的旋转和翻转,我还会对少数类目标做“复制粘贴增广”:从其他图像中裁出稀有虫卵的小图,以合理的物理尺寸粘贴到背景图像上,同时生成对应标签。这种方法相当于增加了稀有类别的监督信号,前提是粘贴位置不能太靠边,也不能覆盖别的目标。
迁移学习在医学图像里是一把双刃剑。使用COCO预训练权重能省不少训练时间,但COCO上的物体和虫卵外观差异巨大,主干浅层学到的边缘、纹理特征有用,深层语义分类基本需要全部重新学。我建议不要冻结太多层,只把浅层冻结10个以内,后面全部放开,否则模型能力会被锁死。如果数据量确实不允许全量微调,从YOLOv8m预训练开始也是一个妥协方案,但最终效果不一定比s好。
最后再补一个我自己踩出来的经验:数据分析阶段,先把训练时序上的loss曲线和验证集one-by-one的预测图放在一起看。遇到“某些类别的AP高,某些极低”时,不要马上调模型,先看是不是标注类别顺序有误,或者训练集里对应类别的目标太小导致增强后直接消失。把数据问题排干净,模型折腾才有意义。做医疗AI的都知道,很多时候瓶颈不在网络结构,而在标注质量和数据分布是否真正逼近了临床现场。