简介:BCD血细胞检测数据集(YOLO格式)面向计算机视觉与医学影像研究者,聚焦白细胞(WBC)、红细胞(RBC)和血小板(Platelets)三类目标的检测任务。包内包含364张显微镜图像及配套YOLO标注文件,训练255张、验证73张、测试36张,可支撑模型训练、验证与精度评估,适用于血细胞分类、计数及异常检测等方向的基础研究与算法验证。资源共731个文件,其中366个txt用于边界框标注,364个jpg为原始图像,另有1个yaml配置文件,可直接接入YOLO系列训练流程;压缩包仅7.38MB,轻量易下载。目前已有417人浏览学习,适合需要快速获取标准格式血细胞数据集的开发者。通过该数据集,可省去从公开BCD数据集中转换格式的步骤,直接用于训练和对比不同检测模型的性能,也可作为迁移学习或数据增强实验的基准数据,帮助读者聚焦算法调优与结果分析。
1. BCD数据集yolo格式:364张血涂片能训出能用的检测模型吗
在血常规镜检场景里,检验科老师每天要看大量涂片,数白细胞、红细胞、血小板,这是典型的小目标密集检测任务。所谓 BCD数据集yolo格式,就是一套针对白细胞(WBC)、红细胞(RBC)和血小板(Platelets)三类细胞的标注数据,统一转成 YOLO 训练需要的 txt 标注形态,共 364 张图。364 这个数字在目标检测里确实不大,但配合预训练权重、数据增强和小目标专项策略,足够把模型训到能辅助预检的程度,前提是你别在格式转换和数据划分阶段翻车。这篇文章按我实际做小样本医学检测的流程来写,包括转换脚本、训练参数、踩坑记录和部署验证,适合正在跟血液细胞检测、小目标检测、YOLO 数据集打交道的人照着复现。
2. 拆开YOLO格式标注:VOC XML转txt的脚本与坐标归一化
2.1 为什么非要归一化坐标:YOLO的txt标注规则
YOLO 系列训练器读的标注不是 XML,也不是 JSON,而是每张图对应一个同名 txt 文件,里面每一行描述一个目标,格式是固定的五列:类别序号、归一化后的中心点 x、中心点 y、归一化后的框宽 w、框高 h。前四列和后两列全部是 0 到 1 之间的小数,除以图片宽高得到,这就是 YOLO 格式的坐标归一化。
为什么要归一化?因为同一张细胞涂片可能被缩放成 640 或者 1280 输入,绝对像素坐标在缩放后就失效了。归一化坐标只描述目标在图片中的相对位置,任何输入尺寸下都能正确映射。BCD 这类血液细胞数据集,原始标注常见的是 VOC 风格的 XML,里面<bndbox>给出xmin、ymin、xmax、ymax四个绝对像素值,所以第一步永远是做坐标换算。
常见做法是把 XML 里的绝对坐标按公式转换:
x_center = (xmin + xmax) / 2 / image_widthy_center = (ymin + ymax) / 2 / image_heightwidth = (xmax - xmin) / image_widthheight = (ymax - ymin) / image_height
这里最容易被忽略的是 image_width 和 image_height 必须以图片真实尺寸为准,而不是以 XML 里写的 width 为准。有些标注工具会把缩略图的宽高写进 XML,和原图不一致,转换后框会整体错位。
2.2 转换脚本:XML里的bbox如何变成四行数字
下面这个脚本是我处理 BCD 数据集时常用的一版,遍历 Annotations 目录下的 XML,对每个目标做类别映射和坐标归一化,输出到 labels 目录。用 Python 写,依赖xml.etree.ElementTree和os,不需要额外安装第三方库。
import os import xml.etree.ElementTree as ET # 类别映射,顺序就是训练时的 class id CLASS_MAP = { 'WBC': 0, 'RBC': 1, 'Platelets': 2, } # 原始 XML 目录和输出 txt 目录 XML_DIR = 'path/to/Annotations' IMG_DIR = 'path/to/JPEGImages' OUT_DIR = 'path/to/labels' os.makedirs(OUT_DIR, exist_ok=True) for xml_name in os.listdir(XML_DIR): if not xml_name.endswith('.xml'): continue xml_path = os.path.join(XML_DIR, xml_name) tree = ET.parse(xml_path) root = tree.getroot() # 判断图片真实宽高 img_name = root.find('filename').text img_path = os.path.join(IMG_DIR, img_name) # 用 OpenCV 读图拿实际宽高,避免 XML 里的假宽高 import cv2 img = cv2.imread(img_path) if img is None: print(f'图片读取失败: {img_path}') continue img_h, img_w = img.shape[:2] lines = [] for obj in root.findall('object'): cls_name = obj.find('name').text.strip() if cls_name not in CLASS_MAP: continue # 跳过被截断或模糊的目标,这类框训练时是噪声 difficult = obj.find('difficult') if difficult is not None and int(difficult.text) == 1: continue bndbox = obj.find('bndbox') xmin = float(bndbox.find('xmin').text) ymin = float(bndbox.find('ymin').text) xmax = float(bndbox.find('xmax').text) ymax = float(bndbox.find('ymax').text) # 边界裁剪,防止坐标越界 xmin = max(0, min(xmin, img_w - 1)) xmax = max(0, min(xmax, img_w - 1)) ymin = max(0, min(ymin, img_h - 1)) ymax = max(0, min(ymax, img_h - 1)) x_center = ((xmin + xmax) / 2) / img_w y_center = ((ymin + ymax) / 2) / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h lines.append(f'{CLASS_MAP[cls_name]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}') # 输出同名 txt,放到 labels 目录 out_name = xml_name.replace('.xml', '.txt') with open(os.path.join(OUT_DIR, out_name), 'w') as f: f.write('\n'.join(lines))脚本核心是两件事:确认图片真实宽高、做坐标归一化。注意我特意用 OpenCV 读了一次图来拿img_w和img_h,而不是直接读 XML 里的 width 字段——血细胞数据集经常出现标注原图被缩放后重新导出 XML 的情况,不校正的话所有框整体偏移。difficult标记的目标在医学数据里通常是模糊、重叠严重的细胞,建议过滤掉。
输出目录结构与 YOLO 约定保持一致,后面第三节训练时直接按这个结构组织。
2.3 转换后的可视化自检:框歪了就说明坐标算错
坐标转完别急着训练,先做一轮可视化自检,这一步能省下后面排查的大把时间。最常见的问题包括:XML 的 width 字段和图片实际尺寸不一致导致框整体偏右上、归一化后出现负值或大于 1 的越界值、类别名大小写不一致导致类别丢失。
自检脚本很简单:读回 txt,把归一化坐标还原成像素坐标,用 OpenCV 在原图上画框和类别名。
import cv2 LABELS = ['WBC', 'RBC', 'Platelets'] def draw_yolo_boxes(img_path, txt_path): img = cv2.imread(img_path) img_h, img_w = img.shape[:2] with open(txt_path, 'r') as f: for line in f: parts = line.strip().split() if not parts: continue cls_id = int(parts[0]) x_center = float(parts[1]) * img_w y_center = float(parts[2]) * img_h w = float(parts[3]) * img_w h = float(parts[4]) * img_h x1 = int(x_center - w / 2) y1 = int(y_center - h / 2) x2 = int(x_center + w / 2) y2 = int(y_center + h / 2) color = (0, 255, 0) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) cv2.putText(img, LABELS[cls_id], (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1, cv2.LINE_AA) cv2.imwrite('check_' + os.path.basename(img_path), img) draw_yolo_boxes('path/to/img_001.jpg', 'path/to/labels/img_001.txt')自检时重点看三个点:框是不是正好包住整个细胞而不是只贴边;血小板这类小目标的框有没有变成几个像素的碎点;红细框密集区域有没有互相重叠成一个大框。如果发现框整体偏移,优先怀疑宽高取错;如果小目标框变成点,检查是不是xmax和xmin接近相等,这类目标在归一化后宽度极小,训练时容易被当成噪声忽略。
我一般会随机抽 20 到 30 张图做可视化,覆盖密集视野和稀疏视野,全部确认没问题再进入训练环节。这一版转换脚本的产物就是标准的 YOLO 目录结构,下一章直接用它来划分和训练。
3. 用364张图跑通YOLO训练:划分、配置和调参
3.1 数据划分:按图不按框,固定随机种子
364 张图的数据量不大,划分方式直接影响验证集的可信度。原则是按图划分,而不是按框划分,同一个视野中的所有标注必须待在同一个集合里,否则模型在训练时见过验证集的细胞,mAP 会虚高。
我常用的划分脚本逻辑:先按图片名排序,固定随机种子后打乱,再按 7:2:1 切成训练集、验证集、测试集。364 张算下来大约训练 254 张、验证 73 张、测试 37 张。测试集只在最终评估时用,训练过程中不碰它。
# 目录结构 dataset/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ └── labels/ ├── train/ ├── val/ └── test/划分脚本可以用纯 Python 实现,核心是随机种子固定,保证每次跑结果一致。
import os import random import shutil random.seed(42) base = 'path/to/dataset' img_dir = os.path.join(base, 'images') lab_dir = os.path.join(base, 'labels') all_imgs = os.listdir(img_dir) all_imgs.sort() random.shuffle(all_imgs) n = len(all_imgs) train_end = int(n * 0.7) val_end = int(n * 0.9) train_files = all_imgs[:train_end] val_files = all_imgs[train_end:val_end] test_files = all_imgs[val_end:] def move_files(files, split): for f in files: shutil.move(os.path.join(img_dir, f), os.path.join(img_dir, split, f)) txt = f.replace('.jpg', '.txt') shutil.move(os.path.join(lab_dir, txt), os.path.join(lab_dir, split, txt)) for split, files in [('train', train_files), ('val', val_files), ('test', test_files)]: os.makedirs(os.path.join(img_dir, split), exist_ok=True) os.makedirs(os.path.join(lab_dir, split), exist_ok=True) move_files(files, split) print(f'train: {len(train_files)}, val: {len(val_files)}, test: {len(test_files)}')这里有个医学数据特有的坑:同一个病人的多张视野图非常相似,如果随机划分时它们同时进了训练集和验证集,验证结果会偏乐观。严格做法是按病人 ID 分组后再划分,但 BCD 这类公开数据集的图片命名未必带病人信息,所以我会先看一眼文件名前缀,如果发现明显分组的命名规律,就把前缀当作分组键,整组划分。
3.2 blood.yaml与最小训练命令:预训练权重是救命稻草
数据划分完成后,写一个 YOLO 训练器的数据集配置文件,指定图片路径和类别名。下面这份是blood.yaml的典型内容,路径用相对路径,建议把整个数据集目录放在一个固定的datasets根目录下。
# blood.yaml path: ../datasets/bcd_yolo # 数据集根目录 train: images/train # 训练集图片目录,相对于 path val: images/val # 验证集图片目录,相对于 path test: images/test # 测试集图片目录,可选 nc: 3 # 类别数量 names: 0: WBC 1: RBC 2: Platelets训练命令用 Ultralytics YOLO 的统一命令行入口:
yolo detect train \ data=blood.yaml \ model=yolov8n.pt \ epochs=150 \ imgsz=640 \ batch=16 \ patience=30 \ device=0 \ amp=True几个参数值得单独说:model=yolov8n.pt表示从官方预训练权重开始微调,而不是随机初始化。364 张图从头训练几乎必挂,预训练权重带来的特征提取能力是小样本医学检测的救命稻草。imgsz=640是默认值,但血细胞很多只有十几个像素,如果显卡允许,建议直接上imgsz=1280,后面避坑章节会详细讲。patience=30表示验证集 mAP 连续 30 个 epoch 不涨就提前停,小数据集上防止后期过拟合浪费时间。amp=True开启混合精度,显存紧张时很有用。
3.3 损失函数与小目标调参:box_loss、cls_loss和增强参数
训练时终端会打印三类损失:box_loss、cls_loss、dfl_loss,分别对应边界框回归、分类、DFL 分布损失。YOLO 的总损失是三者加权和,但在血细胞这种极不平衡的数据集上,这三项的变化趋势比总损失更值得盯。
常见的现象是cls_loss降得很快,box_loss却一直不收敛。原因很简单:血小板目标太小,边界框只有几个像素宽,回归难度远大于大目标。这时一般做三个调整:一是把imgsz提到 1280,目标像素面积直接翻四倍;二是调数据增强参数,把小目标不友好的增强关掉或降权;三是给类别加权重。
在训练命令里加参数即可:
yolo detect train \ data=blood.yaml \ model=yolov8n.pt \ epochs=150 \ imgsz=1280 \ batch=8 \ lr0=0.005 \ cls=1.5 \ hsv_h=0.01 \ hsv_s=0.3 \ hsv_v=0.2 \ mosaic=0.3 \ scale=0.3lr0=0.005是微调场景下更稳妥的初始学习率,预训练权重不太需要大 learning rate。YOLO 的损失函数类别权重由cls控制,默认是 0.5,cls=1.5会把分类损失权重拉高,对解决 WBC 和 Platelets 类别样本少的问题有帮助。mosaic=0.3是降权操作——mosaic 会把四张图拼在一起再随机缩放,血细胞本来就小,mosaic 的缩放让目标变得更小,小数据集上不建议开满。scale=0.3控制图像缩放增强范围,同样是为了避免目标尺寸被过度缩小。
刚开始训练时可以先不管这些参数,用默认配置跑 50 个 epoch,观察验证集 mAP。如果 WBC 或 Platelets 的 AP 明显低于 RBC,再按上面的思路调整,一次只改一个变量,别同时动多个参数。
4. 血细胞YOLO的常见问题与排查:漏检、错检和“玄学涨点”
4.1 血小板全漏:小目标框面积太小,先查这个
现象:训练完成后验证集里 RBC 的 AP 在 0.85 以上,WBC 在 0.7 左右,Platelets 的 AP 直接掉到 0.1 甚至接近 0,预测结果图上几乎看不到血小板框。
原因:血小板的直径只有 2 到 3 微米,在 640 分辨率下换算成像素往往只有 4 到 10 个像素宽,属于极端小目标。YOLO 的下采样倍数最低是 8 倍,640 输入时特征图是 80x80,一个 6 像素的血小板投影到特征图上不到 1 个像素,特征几乎被卷积抹掉了。此外 BCD 数据集中血小板数量远少于红细胞,模型天然把学习重心放在容易的 RBC 上。
解决:先统计标注框面积分布,确认问题定位,然后分三步走。第一步检查转换脚本生成的 txt 里血小板框的宽高是否正常,常有历史数据把血小板标注成几个像素的碎框,这种直接手工过滤。第二步把训练输入从 640 提到 1280,目标像素面积扩大四倍,这是对小目标最直接的改善。第三步如果显存不够,用小图训练加大图推理,或者用切片推理,这个技术放到最后一章展开。
4.2 红细胞粘连导致的误检:NMS阈值与max_det一起调
现象:预测结果里红细胞密集区域出现两种典型错误,一是两个紧挨的红细胞被框成一个椭球大框,二是一个红细胞被重复框成两三个小框,测试集 mAP 不低,但临床看图上没法用。
原因:红细胞在涂片上通常挤在一起,边界互相重叠,检测头给出的候选框本身就有很高的 IoU 重叠。NMS 默认的 IoU 阈值是 0.5,对于密集小目标这个值偏大,互相重叠的候选框容易被保留多个,或者反过来,置信度高的错误大框把正确小框抑制掉。同时max_det默认 300 的限制在满视野 300 到 500 个细胞的场景下也不够用。
解决:验证和部署时统一把 NMS 的 IoU 阈值降到 0.3 左右,max_det调到 1000。Ultralytics YOLO 在推理时传参即可:
yolo detect predict \ model=best.pt \ source=test_images \ conf=0.25 \ iou=0.3 \ max_det=1000训练阶段也可以用同样的参数约束,让模型在训练时就适应密集场景。另外数据增强里的旋转角度建议保持在 30 度以内,血细胞本身没有固定朝向,但大角度旋转会把相邻细胞重叠成奇怪的形状,反而增加误检。
4.3 训练很久mAP不动:数据增强、类别权重和划分泄露
现象:训练日志里box_loss和cls_loss都在稳步下降,但验证集 mAP 从第 40 个 epoch 开始就横盘,甚至轻微下降,看起来像“玄学涨点失败”。
原因:最常见的是数据增强过强,模型在训练集上看到的是被过度变换的细胞图,验证集却是原始分布,两个域之间出现偏移。还有一种隐蔽原因是划分泄露,同一个病人的多张视野图同时落在训练集和验证集,导致验证集失去意义,一切指标都不可信。
解决:先检查划分脚本,确认没有按文件名前缀分组。然后按顺序做减法实验:把mosaic降到 0.1,scale降到 0.2,关闭perspective,让训练分布贴近真实涂片。如果指标能涨回来,说明是增强过强,逐个加回增强项并确认收益,不要迷信“增强越多抗过拟合越好”。类别权重cls=1.5同样值得检查,类别不平衡严重的场景下,分类损失权重过低会让模型对少样本类别不敏感。
这里补一个容易忽略的细节:验证集 mAP 是用模型在训练过程中保存的权重算的,如果patience设置过大,训练器可能保存一个已经很靠后的过拟合权重。我会在训练结束后用测试集把所有检查点(每个 epoch 保存的权重)都跑一遍,取测试集 mAP 最高的那个权重作为最终模型,而不是默认用last.pt或best.pt。
5. 从mAP到TensorRT:血细胞模型的验证与部署
5.1 yolo val评估:每类AP和混淆矩阵才是体检报告
训练结束后,第一件事是跑正式验证,输出每类 AP、混淆矩阵和 PR 曲线。命令是:
yolo detect val \ model=runs/detect/train/weights/best.pt \ data=blood.yaml \ imgsz=1280 \ conf=0.25 \ iou=0.3输出里重点看两个指标:mAP@0.5和mAP@0.5:0.95。前者只要求预测框和真实框的 IoU 超过 0.5 就算对,对密集小目标比较宽容;后者从 0.5 到 0.95 每隔 0.05 算一次平均,要求框的位置非常精确。血细胞检测场景里 WBC 和 Platelets 对框精度要求高,mAP@0.5:0.95比mAP@0.5更能反映真实可用度。
除了 mAP,我会额外导出混淆矩阵,看三类之间的错检方向。血细胞场景里最典型的是 WBC 被识别成 RBC,因为白细胞核深染后和红细胞颜色接近,形态上又都是圆形。混淆矩阵能直接告诉你错在哪个类别对,比盯着总体 mAP 更能定位问题。
5.2 导出ONNX和TensorRT引擎:小目标场景别乱动输入尺寸
验证达标后,把 PyTorch 权重导出成部署格式。常见链路是 PyTorch 权重先转 ONNX,再转 TensorRT engine,或者直接用 ONNX Runtime 推理。导出命令:
yolo export \ model=best.pt \ format=onnx \ imgsz=1280 \ dynamic=True \ opset=12导出时imgsz必须和训练、验证时保持一致。血细胞小目标尤其敏感,如果训练用 1280,部署却用 640,等于把目标缩小了四倍,召回率会肉眼可见地下降。dynamic=True允许输入宽高动态,方便同一套模型处理不同分辨率的涂片,但要牺牲一点推理性能。不追求动态尺寸的话,导出时固定成imgsz=1280会更省心。
转 TensorRT 是下一步:
yolo export \ model=best.onnx \ format=engine \ imgsz=1280 \ half=True \ workspace=4half=True启用 FP16 推理,显存占用减半速度翻倍,血细胞检测本身对数值精度不敏感,FP16 是性价比最高的选择。workspace=4给 TensorRT 的显存工作空间,按显卡显存大小调整。转 engine 的机器要和最终部署机器同型号,TensorRT engine 和 GPU 架构强绑定,换卡就必须重新转。
5.3 部署脚本:从局部图推理到RTSP拉流的路数测算
部署端最常见的需求是把模型接到科室内网的显微镜图像流,或者直接对静态涂片批量推理。下面这段 Python 脚本是 ONNX Runtime 推理的最小骨架,重点是预处理里的 letterbox 操作。
import cv2 import numpy as np import onnxruntime as ort session = ort.InferenceSession('best.onnx', providers=['CUDAExecutionProvider']) input_name = session.get_inputs()[0].name def letterbox(img, new_size=(1280, 1280)): h, w = img.shape[:2] scale = min(new_size[0] / h, new_size[1] / w) new_w, new_h = int(w * scale), int(h * scale) resized = cv2.resize(img, (new_w, new_h)) canvas = np.full((new_size[0], new_size[1], 3), 114, dtype=np.uint8) canvas[:new_h, :new_w] = resized return canvas, scale img = cv2.imread('slide_001.jpg') canvas, scale = letterbox(img) blob = canvas[:, :, ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255.0 outputs = session.run(None, {input_name: blob})[0] # outputs shape: [1, 84, 8400],后处理 NMS 后再把坐标除以 scale 映射回原图这段代码里最容易出错的是预处理:ONNX 模型期望的输入是归一化到 0 到 1 的 RGB 顺序,OpenCV 读出来是 0 到 255 的 BGR 顺序,必须转。letterbox 阶段记住scale并在后处理时用同一scale把坐标映射回原图,整张图被补齐了114灰度值,这些补齐区域在推理时不应该被当成有效视野。
如果要做实时检测,用 OpenCV 拉 RTSP 流然后逐帧推理即可:
cap = cv2.VideoCapture("rtsp://user:pass@192.168.1.100:554/stream") while cap.isOpened(): ret, frame = cap.read() if not ret: break outputs = session.run(None, {input_name: letterbox(frame)[0]}) # 后处理 NMS,画框,显示或转发有人会问“T4 上跑 1080p 25 帧的 TensorRT 640 分辨率检测能支持多少路”,这个问题的答案不是固定数值,取决于预处理耗时、后处理 NMS 的框数量、TensorRT 是否开 FP16。我的做法是先测单路的单帧总耗时(预处理加推理加后处理),然后拿 GPU 利用率做参考,比如单帧耗时 15 毫秒,理论 66 帧每秒,考虑到画框和拉流开销,实际路数再打七折。这个数字必须在自己机器上实测,换数据集换模型都不一样。
6. 364张的天花板:难例挖掘、切片推理和下一步怎么走
364 张数据能做的改进天花板,集中在两个方向:把现有数据的价值榨干,以及把模型暴露出来的错误变成下一批标注样本。
难例挖掘是我每个医学检测项目都会做的流程。训练完第一版模型后,把验证集和测试集所有图片跑一遍推理,挑出三类样本:模型完全漏检的目标、置信度低于 0.3 的预测、类别预测错误的细胞。把这些图和对应的框截图整理出来,交给会看片的同事复核,把漏检的真实目标补上框,修正被错标的类别,然后把这批修正后的数据回填进训练集。这比随机再加几百张没用标注的图有效得多,因为每一张难例都在模型当前的决策边界上。
切片推理是另一项针对性技巧。既然血小板小到特征图上只剩不到一个像素,干脆把整张涂片切成若干重叠 patch,每个 patch 单独推理,再把结果拼回去。常见做法是 SAHI 库,patch 大小设成 640,重叠率设 20%,推理时每个血小板在至少两个 patch 里出现,拼回时做一次加权 NMS。代价是推理时间成倍增加,所以一般只用于离线阅片,不做实时。
给 AUC 或 mAP 已经不错但还想继续提升的人一个方向:增加专业标注数据,把目标从检测扩展到计数回归。血常规镜检的核心诉求其实是计数,如果能拿到一部分带有红细胞、白细胞、血小板计数值的临床样本,可以训练一个计数头直接回归,比纯检测再数框要稳。我自己的习惯是每次训练前先跑一次可视化自检,确认坐标和类别没问题再启动长训练,这个习惯替我省过好几次“训练几十个小时发现坐标没归一化”的翻车。希望帮到你。
本文还有配套的精品资源,点击获取