简介:面向需要训练YOLOv5等目标检测模型的研究者与开发者,这是一份超声波肾脏结石检测数据集,共含2个类别:结石与正常肾脏。图像统一为250×250 RGB分辨率,标注框清晰完整,数据已按YOLOv5目录格式划分为训练集与验证集,下载后可直接接入训练流程,无需额外整理。压缩包内共2000个文件,以txt标注文件为主(1999个),每张图片配套对应YOLO格式的标签文本,另有1个show.py可视化脚本,可直接运行并随机绘制边界框,便于快速检查标注质量。压缩包整体19.73MB,体量适中,适合快速验证检测效果;目前已有454人学习下载。资源包含训练/验证两个独立子集,训练集2564张、验证集183张,并附2个类别文本说明;标注信息完整,类别边界清晰,对刚接触医学图像检测或希望快速搭建肾脏超声检测模型的读者,是一份可直接上手的低门槛数据集。
1. 超声波肾脏结石数据集:YOLOv5 在这里到底靠不靠谱
B 超影像的肾脏结石检测,是我见过“数据集比模型更决定成败”的目标检测场景之一。结石在超声图像里通常表现为强回声团块加后方声影,边界模糊、灰度对比度低,还经常和肾窦高回声混在一起。直接把通用目标检测数据集训练出来的 YOLOv5 拿过来跑,几乎必然翻车;而一份标注规范、类别定义清晰、训练集与验证集划分合理的超声波肾脏结石数据集,才是让 YOLOv5 在真实超声设备上可用的前提。这篇文章写给两类人:想在医学超声影像上做辅助诊断的算法工程师,以及手里有一批 B 超图像不知道怎么整理成 YOLO 格式的研究者。下文从标注格式转换、数据划分、训练参数到避坑点完整走一遍。
2. 从 B 超图像到 YOLOv5 标注格式:转换脚本与两个类别的定义
2.1 为什么超声波肾脏结石用目标检测而不是图像分类
很多刚入门的人会问:肾结石不是“有”就是“没有”,一个二分类模型不就行了吗?真实的超声诊断场景里不是这样。超声扫查时探头在人体表面移动,肾脏在视野里的位置、大小、角度都在变化,一张图里可能同时出现多个结石,也可能只有一处可疑高回声。分类模型只能回答“这张图里有没有结石”,却不能告诉医生结石在哪里,也没法同时区分正常组织和结石区域。
YOLOv5 作为单阶段检测器,输出的是边界框加类别概率,天然匹配“定位加分类”的诉求。选择 YOLOv5 而不是更重的两阶段检测器,主要是落地速度。超声检查本身是实时动态过程,视频流每秒几十帧,医生不会等一两秒才看到检测结果。YOLOv5 在普通 GPU 上跑 640 输入可以达到实时推理,部署到超声设备配套的工控机上也比较成熟,这一点在实际项目里比高几个点的 mAP 更重要。
数据集层面,YOLOv5 的标注格式简单:每张图像对应一个同名的 txt 文件,每一行是“类别 x_center y_center width height”,坐标全部归一化到 0 到 1 之间。这个格式没有复杂的 JSON 嵌套或分割标注,手工标注和程序转换都不容易出错。对 B 超这种边界本身就模糊的图像,简洁的标注格式能让标注员更快上手,也方便统一质检。
2.2 两个类别怎么定义:结石、正常结构,以及一个常见误区
“2 类别”这个设定,在这类数据集里通常是指:0 是 stone(肾结石),1 是 normal(正常肾结构,包括肾实质、肾盂、肾窦等)。但也有人把 1 设为肾积水或囊肿之类的其他病变。到底怎么定义,取决于诊断场景。如果目标是“找结石”,我建议第二类宁可定义为“容易与结石混淆的正常结构”,而不是泛泛的“正常”。
实际踩坑经验是这样的:如果只标注结石这一类,训练时所有没被框的区域都当作背景,模型确实能把结石框出来,但也会把肾窦内的高回声误判成结石,因为肾窦本身的回声强度经常和结石相近。加入 normal 类别,把肾窦、肾锥体等容易混淆的结构也框出来,模型的决策边界会清晰很多。这一点在后面的避坑章节里还会展开。
2.3 从 labelme 标注到 YOLO txt:一个可以直接改用的转换脚本
常见的数据标注工具是 Labelme 或 LabelImg。我这边大多数时候拿到的原始标注是 labelme 的 JSON 文件,里面记录了多边形点坐标和图片宽高。下面这个脚本把 labelme 标注转成 YOLOv5 需要的 txt:
"""labelme多边形标注转YOLOv5 txt格式""" import json import os from glob import glob # 类别定义,顺序必须与data.yaml中的names保持一致 LABEL_DICT = {"stone": 0, "normal": 1} def labelme_to_yolo(json_path, save_dir): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) img_w, img_h = data["imageWidth"], data["imageHeight"] if img_w == 0 or img_h == 0: print(f"跳过:{json_path} 没有正确的图像尺寸") return out_name = os.path.basename(json_path).replace(".json", ".txt") with open(os.path.join(save_dir, out_name), "w", encoding="utf-8") as out: for shape in data["shapes"]: label = shape["label"] if label not in LABEL_DICT: continue pts = shape["points"] if len(pts) < 2: continue x_min = min(p[0] for p in pts) x_max = max(p[0] for p in pts) y_min = min(p[1] for p in pts) y_max = max(p[1] for p in pts) w = x_max - x_min h = y_max - y_min if w <= 0 or h <= 0: continue x_center = x_min + w / 2.0 y_center = y_min + h / 2.0 out.write(f"{LABEL_DICT[label]} {x_center / img_w:.6f} " f"{y_center / img_h:.6f} {w / img_w:.6f} {h / img_h:.6f}\n") os.makedirs("labels/train", exist_ok=True) os.makedirs("labels/val", exist_ok=True) for json_file in glob("raw_annotations/*.json"): labelme_to_yolo(json_file, "labels/train")这段代码的逻辑是逐行读取 labelme 的 shapes 数组,对每个多边形取外接矩形的左上和右下点,计算中心坐标和宽高,再除以图片宽高做归一化。几个要注意的地方:
一是类别索引要和后续 data.yaml 里 names 的顺序严格一致,0 对应第一个类,1 对应第二个类,否则训练出来的模型类别就错位了。
二是这里用外接矩形代替多边形,B 超里结石形状不规则,外接矩会带进一小部分背景。这在实际训练中影响不大,YOLOv5 的边界框回归本身允许一定冗余,但要避免把声影区域也框进去,否则模型会去学“结石后面的一长条暗区”。常见做法是用矩形只包住强回声核心,不要向下延伸到声影。
三是如果原始标注是 VOC 格式,转换逻辑同理,只是把解析 XML 的部分替换为 xml.etree.ElementTree,按同样的公式输出即可。如果你拿到的 B 超图像是 DICOM 文件,建议先用影像工具导出成 PNG 或 JPG 再走标注流程,直接用 DICOM 做训练集需要额外的像素归一化处理,收益不大。
2.4 标注转换后的可视化抽检
转换完之后不要急着训练,花十分钟抽查一下。把标注框绘制到原图上直观看一遍,重点看三类问题:框是否明显偏大、框是否偏出图像、类别是否标错。下面这段代码可以把标注画回图像:
"""把YOLO txt标注画回图像,人工抽检""" import cv2 import os LABEL_COLORS = {0: (0, 255, 0), 1: (0, 0, 255)} # BGR CLASS_NAMES = {0: "stone", 1: "normal"} def draw_yolo_boxes(img_path, txt_path): img = cv2.imread(img_path) h, w = img.shape[:2] with open(txt_path, "r") as f: for line in f: cls, xc, yc, bw, bh = line.strip().split() cls, xc, yc, bw, bh = int(float(cls)), float(xc), float(yc), float(bw), float(bh) x1 = int((xc - bw / 2) * w) y1 = int((yc - bh / 2) * h) x2 = int((xc + bw / 2) * w) y2 = int((yc + bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), LABEL_COLORS.get(cls, (0, 255, 255)), 2) cv2.putText(img, CLASS_NAMES.get(cls, "?"), (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, LABEL_COLORS.get(cls, (0, 255, 255)), 2) return img # 抽样10张图检查 os.makedirs("check", exist_ok=True) sample_files = os.listdir("labels/train")[:10] for txt in sample_files: img_path = os.path.join("images/train", txt.replace(".txt", ".jpg")) vis = draw_yolo_boxes(img_path, os.path.join("labels/train", txt)) cv2.imwrite(os.path.join("check", txt.replace(".txt", "_check.jpg")), vis)这段代码的作用是把归一化坐标乘回像素尺寸,再画框和类别名。我一般会把抽查数量放到至少 30 张,覆盖不同图像亮度、不同结石大小。如果发现 10% 以上的框有明显偏移,说明标注标准不统一,要回头跟标注员对齐,而不是急着训练。这一关过了,后面训练才不至于变成反复试错。
3. 训练集与验证集的划分:按病人分组,而不是按图像随机打散
3.1 YOLOv5 标准目录结构:images 与 labels 严格同名
训练集和验证集的划分,是整个数据集质量里最容易被低估的一环。YOLOv5 对目录结构约束很严格:数据集的根目录下要有 images 和 labels 两个文件夹,各自再分 train 和 val,图像和标注文件必须同名同前缀,只是后缀不同。例如 images/train/kidney_001.jpg 对应 labels/train/kidney_001.txt。这个同名对应关系一旦破坏,训练时 YOLOv5 会静默跳过找不到标注的图像,最终效果差还不容易发现。
常见结构如下:
ultrasound_kidney/ ├── images/ │ ├── train/ │ │ ├── patient007_frame02.jpg │ │ ├── patient007_frame05.jpg │ │ └── ... │ └── val/ │ ├── patient011_frame01.jpg │ └── ... ├── labels/ │ ├── train/ │ │ ├── patient007_frame02.txt │ │ ├── patient007_frame05.txt │ │ └── ... │ └── val/ │ ├── patient011_frame01.txt │ └── ... └── kidney_stone.yaml图像和标签的命名要避免空格、中文和特殊符号。超声设备导出的文件名偶尔会带下划线前缀或中文日期,最好在做划分之前统一重命名为 patient 编号加 frame 编号这种格式。否则后续写脚本处理时,文件名编码问题会消耗大量时间,而且训练脚本在部分系统上可能直接因为路径解析失败而中断。
3.2 划分脚本的核心逻辑:按病人 ID 分组
医学影像数据和自然图像数据有一个关键区别:同一个病人的多张超声图像高度相似,因为超声视频是一段时间的连续扫查,相邻帧之间的差异很小。如果直接按文件随机划分,同一个病人的几十帧会同时出现在训练集和验证集里,模型相当于在考试时提前看过答案,验证集 mAP 会虚高到接近 1。换一个新病人做测试时,性能立刻断崖式下跌。
正确做法是按病人 ID 划分:先把所有图像按病人分组,然后把整个病人组划分到训练集或验证集,同一个病人的任何图像都不能跨组。下面是一个划分脚本:
"""按病人分组划分训练集与验证集""" import os import random import shutil SOURCE_IMAGES = "raw_images" # 原始图像,文件名形如 patient007_frame02.jpg SOURCE_LABELS = "raw_labels" # 已转换好的YOLO txt,与图像同名 TRAIN_RATIO = 0.8 # 80%病人进训练集,20%进验证集 random.seed(42) # 固定随机种子,保证可复现 def get_patient_id(filename): """从 patient007_frame02.jpg 中提取 patient007""" return filename.split("_")[0] # 1. 收集所有病人ID image_files = [f for f in os.listdir(SOURCE_IMAGES) if f.endswith(".jpg")] patient_ids = sorted(set(get_patient_id(f) for f in image_files)) random.shuffle(patient_ids) # 2. 按病人数量切分 split = int(len(patient_ids) * TRAIN_RATIO) train_patients = set(patient_ids[:split]) val_patients = set(patient_ids[split:]) # 3. 按病人归属复制图像与标签 for split_name, patient_set in [("train", train_patients), ("val", val_patients)]: os.makedirs(f"images/{split_name}", exist_ok=True) os.makedirs(f"labels/{split_name}", exist_ok=True) for img_name in image_files: pid = get_patient_id(img_name) if pid not in patient_set: continue src_img = os.path.join(SOURCE_IMAGES, img_name) dst_img = os.path.join("images", split_name, img_name) shutil.copy(src_img, dst_img) txt_name = img_name.replace(".jpg", ".txt") src_txt = os.path.join(SOURCE_LABELS, txt_name) if os.path.exists(src_txt): shutil.copy(src_txt, os.path.join("labels", split_name, txt_name)) else: print(f"警告:{txt_name} 缺少标注文件")这里最关键的参数是 TRAIN_RATIO 和 random.seed。TRAIN_RATIO 控制训练集比例,一般取 0.8。random.seed 固定后,无论跑多少次脚本,划分结果都一样,这在复现实验时非常重要。get_patient_id 里的字符串规则要根据实际文件名调整,如果文件名里没有病人编号,就得从原始记录表或者 DICOM 头文件里把病人 ID 对应起来,再重命名文件。
提示:如果你拿到的原始图像是 DICOM 格式,不要用文件名作为分组依据,DICOM 头里的 PatientID 字段才是最可靠的病人标识。
比例上,我一般用 8:2。如果样本总量很少,比如小于 300 张,可以考虑 9:1,甚至用 K 折交叉验证来评估。但交叉验证的训练成本会成倍增加,通常只有论文场景才这么做。实际项目里我建议保持 8:2,并预留一部分从其他设备采集的图像做最终测试,这样能更真实地估计泛化性能。
3.3 数据配置文件 data.yaml 与类别配额检查
划分完成后,写 data.yaml 文件告诉 YOLOv5 去哪里读数据。注意 train 和 val 路径写的是 images 文件夹,YOLOv5 会自动在同级 labels 目录下找同名 txt:
# kidney_stone.yaml train: ultrasound_kidney/images/train val: ultrasound_kidney/images/val nc: 2 names: 0: stone 1: normaltrain 和 val 路径可以是绝对路径也可以是相对路径,关键是不要写成 labels 目录。YOLOv5 的 dataloader 逻辑是拿到图像路径后把 images 替换成 labels、把扩展名替换成 .txt。很多人第一次用会搞错,训练时提示大量图像无标签,其实只是路径指向错了。
配置文件写好后,我习惯在训练前跑一段统计脚本,确认每个组里类别和框数量的分布:
"""统计train/val两个分组的标注分布""" import os from glob import glob for split_name in ["train", "val"]: total_boxes = 0 class_counts = {0: 0, 1: 0} empty_files = 0 label_files = glob(f"labels/{split_name}/*.txt") for path in label_files: with open(path) as f: lines = f.readlines() if not lines: empty_files += 1 for line in lines: cls = int(line.split()[0]) class_counts[cls] = class_counts.get(cls, 0) + 1 total_boxes += 1 print(f"[{split_name}] 文件数:{len(label_files)},空标注文件:{empty_files}," f"总框数:{total_boxes},类别分布:{class_counts}")统计结果里值得注意两个数字:空标注文件数和类别分布。空标注文件如果过多,说明很多图像没有框出目标,这类图像在训练时被当作纯背景处理,会影响模型对背景的建模。类别分布如果 stone 和 normal 的样本量相差超过 3 倍,就要考虑在后续训练时调整类别权重或补充样本。验证集如果太小,比如少于 200 张,一次训练的指标波动就会很大,不足以支撑任何结论。
4. 训练前先做数据校验:灰度图、标注框质量与小目标参数
4.1 B 超灰度图进入 YOLOv5 的通道问题
超声图像本质是灰度图。常见做法是把灰度图复制成三通道再训练,因为 YOLOv5 的默认模型结构第一层卷积接受 3 通道输入,预训练权重也是按 3 通道 ImageNet 分布初始化的。直接改成单通道需要改动模型定义并放弃预训练权重,收益不大,不建议这样做。
复制通道的代码很简单:
"""把灰度图转成三通道灰度图,保留原始像素值""" import cv2 import os from glob import glob for img_path in glob("images/train/*.jpg") + glob("images/val/*.jpg"): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: print(f"读取失败:{img_path}") continue img3 = cv2.merge([img, img, img]) # 三个通道共用同一个灰度值 cv2.imwrite(img_path, img3)关键点是不要去调用 cvtColor 做灰度到 RGB 的伪彩色映射。伪彩色会改变像素值分布,相当于在训练和推理之间引入了不一致,实际落地时设备输出的还是原始灰度流,模型就见不到训练时见过的颜色分布了。最稳的做法是把灰度图复制三份,训练和推理走同一个预处理链路。
4.2 标注框的物理校验:面积阈值、越界与重复框
标注质量直接决定训练效果。YOLOv5 训练时如果标注框越界,虽然程序会自动裁切,但中心坐标和宽高的组合可能出现负数,导致 loss 异常。我在每个数据集训练前都会跑一遍标注校验脚本,检查三类问题:
"""批量检查YOLO标注的越界、退化与最小尺寸问题""" import cv2 import os from glob import glob MIN_BOX_PIXELS = 16 # 最小目标边长像素 issues = [] for txt_path in glob("labels/train/*.txt") + glob("labels/val/*.txt"): img_path = txt_path.replace("labels", "images").replace(".txt", ".jpg") if not os.path.exists(img_path): issues.append(f"缺少图像:{img_path}") continue img = cv2.imread(img_path) if img is None: issues.append(f"图像无法读取:{img_path}") continue h, w = img.shape[:2] with open(txt_path) as f: lines = f.readlines() if not lines: issues.append(f"空标注:{txt_path}") continue for line in lines: parts = line.strip().split() if len(parts) != 5: issues.append(f"格式错误:{txt_path}: {line}") continue cls, xc, yc, bw, bh = map(float, parts) if cls not in (0, 1): issues.append(f"未知类别 {cls}:{txt_path}") if not (0 <= xc <= 1 and 0 <= yc <= 1 and 0 <= bw <= 1 and 0 <= bh <= 1): issues.append(f"坐标越界:{txt_path}: {line.strip()}") px_w, px_h = bw * w, bh * h if px_w < MIN_BOX_PIXELS or px_h < MIN_BOX_PIXELS: issues.append(f"目标过小:{txt_path}: {line.strip()} ({px_w:.0f}x{px_h:.0f}px)") print(f"共发现 {len(issues)} 个问题") for issue in issues[:50]: print(issue)这段脚本的检查逻辑是逐行解析 txt 的五个值:类别、中心 x、中心 y、宽、高。前面检查的是格式和范围,最后一项是像素尺寸。B 超中的结石目标小的可能只有十几个像素,这种目标即使人眼也很难可靠判断,保留在训练集里很容易变成噪声样本,我一般直接过滤掉。如果过滤的目标比例超过 5%,说明图像采集分辨率偏低或者标注粒度太细,要从源头确认。
4.3 小目标检测的参数设置:img_size、anchor 与增强策略
针对结石这种小目标,最直接有效的手段是提高输入分辨率。YOLOv5 默认训练尺寸是 640,但如果原始 B 超图在 800x600 以上,而结石又很小,建议把训练和推理的 img_size 都提高到 768 甚至 896。代价是显存占用增加、训练变慢,但对小目标检测的收益通常比调其他任何参数都明显。
锚点设置上也值得花一点时间。YOLOv5 自带自动锚点计算,训练时会执行 k-means 聚类来根据你的数据集重新计算 anchor。如果你觉得自动计算的效果不稳定,可以在训练命令里加 --noautoanchor 关闭自动计算,手动使用 yolov5s.yaml 里的默认锚点。我发现在超声图像这种目标尺寸分布极不均匀的数据集上,自己基于标注框统计出来的锚点往往比默认的好。统计锚点时按像素坐标算,跑一下 k-means 看聚类中心即可,不必手动微调太多。
训练命令示例:
python train.py --img 768 --batch 16 --epochs 100 \ --data kidney_stone.yaml --weights yolov5s.pt \ --hyp hyp.scratch-low.yaml --cache ram --device 0参数的作用分别是:--img 768 用更大输入尺寸;--batch 16 在显存允许下尽量大;--hyp 选用低增强策略,因为医学超声数据不适合激进的色彩和几何扰动;--cache ram 把数据加载进内存,B 超数据集通常不大,能显著减少磁盘读取停顿。如果显存不足,batch 降到 8,并配合 --workers 4 保持数据加载速度。
增强策略方面,hyp.scratch-low.yaml 里的 mosaic 默认是 1.0,也就是每张图都会做马赛克拼接。小目标在马赛克拼接时容易被裁剪掉,导致有效训练样本不足。如果训练发现小目标类别召回率上不去,可以把 mosaic 降到 0.5,同时把上下翻转 flipud 关掉,旋转角度 degrees 设为 0,左右翻转 fliplr 可以保留 0.5。超声图像里肾脏左右位置存在结构对称性,左右翻转不会破坏解剖语义,但上下翻转会颠倒声像图的方向,不推荐使用。
训练过程中的 loss 曲线也要关注。如果 box_loss 和 obj_loss 在前 20 个 epoch 内持续下降,说明数据质量基本没问题。如果 loss 很快收敛到平台期并开始震荡,大概率是标注噪声偏大,优先回头检查标注而不是继续调参。这部分的判断经验,下面避坑章节里再展开。
5. B 超影像数据集训练 YOLOv5 的避坑记录:5 个值得记住的翻车现场
超声数据集和普通目标检测数据集最大的不同,在于图像对比度低、目标小而模糊、且同病人图像高度相关。下面这 5 个坑是实际项目里反复出现过很多次的,每一条都按现象、原因、解决的顺序写,可以直接对照排查。
5.1 现象:训练 loss 不降或震荡厉害
训练一直维持在高位震荡,第一反应通常是调学习率,但后来查明原因基本都在数据侧。最常见的两个原因:一是标注框包含了声影区域,模型既要学强回声核心又要学后面的暗区,回归目标自相矛盾;二是不同标注员标准不一致,同样的结石有人框得紧、有人框得松,导致标签本身有较大方差。
解决方法是回到标注层面统一标准。声影是结石后方的低回声带,宽度和结石差不多,向下延伸很远,标注时只框强回声核心。最好在标注规范里配示例图,明确“结石外边界到高亮区域的边缘为止”,并且让两个人同时标注同一批 50 张图计算 IoU,低于 0.7 就要重新对齐标准。标注一致性上去了,loss 曲线通常自然就稳定了。
5.2 现象:验证集 mAP 接近 1,一到新病人就崩
这是医学图像项目最常见的假完美现场。直接随机把图像文件划分成 train 和 val,同一个病人的连续帧会同时出现在两边。B 超视频的相邻帧差别极小,模型相当于已经背过这些图像,验证集指标自然漂亮。但换到没见过的病人,器官位置、脂肪厚度、探头角度全变了,性能立刻掉下来。
解决方式就是前面第 3 章写的按病人分组划分,没有捷径。这里补充一个判断技巧:训练完成后,把验证集里属于同一个病人的图像单独聚合起来,统计按病人的 mAP。如果按病人聚合后的 mAP 比整体 mAP 低 10 个百分点以上,就有理由怀疑存在数据泄露。这个检查和 mAP 一样重要,能提前识别出验证集的置信度泡沫。
5.3 现象:误检多,正常肾窦被当成结石
正常肾脏的肾窦回声也很强,形态上有时和早期小结石非常相似。如果训练集里只有结石类别的框,肾窦区域在模型眼里就是背景,但它和结石在纹理上高度相似,模型自然会把两者混淆。这个误检在临床上是比较危险的,因为它会把正常病人标记成阳性。
解决方法是给 normal 类别补数据。很多 B 超视频里能截到大量不含结石的肾窦图像,把肾窦区域框出来,作为 normal 类的正样本。目标不是让模型识别“这是正常肾脏”,而是让模型学到“这类高回声结构不是结石”。这里有一个反直觉的操作:如果你发现 normal 类别样本太少,宁可先删掉一部分结石样本,把两个类别比例控制在 3:1 以内,也不要让类别比失衡到 10:1。类别不平衡会让模型对少数类的召回率极低。
5.4 现象:训练用了 JPEG,推理时用 DICOM 或灰度流,效果变差
超声图像的灰度范围、对比度在不同设备上差异很大。训练集如果是从 PACS 系统导出的 JPEG,像素值已经做过窗口变换,而实时推理时拿到的是超声设备输出的原始视频帧,亮度分布和 JPEG 完全不同。模型泛化能力再强,也难以承受这种分布偏移,表现出来就是离线测试还可以、一上设备就误检漏检。
解决方法是训练和推理尽量保持同源的预处理。如果最终部署要接实时视频流,建议直接从采集端截流保存样本,而不是从 PACS 导出。预处理时统一做三通道灰度复制,同时计算训练集的均值和标准差,在推理链路上用同一个均值和标准差做标准化。这样能把设备差异带来的影响降到可控范围。
5.5 现象:马赛克增强反而让小目标检测变差
YOLOv5 的 mosaic 增强会把四张图缩放到同一张画布,对于小目标,缩放后目标可能只有几个像素,标注框在拼接时还可能被截断。超声结石本身就是小目标,再用 mosaic 会雪上加霜。我做过一组对比实验:mosaic=1.0 的模型在小结石上的召回率比 mosaic=0.5 低大概 8 个百分点,而大结石基本没有差别。
解决方式是改 hyp 文件。把 hyp.scratch-low.yaml 拷贝一份,把 mosaic 从 1.0 改成 0.5,同时把 scale 从 0.5 改到 0.25,减小缩放幅度对目标的压缩。对医学图像,不建议开启上下翻转和旋转增强,只保留左右翻转和轻微亮度对比度扰动。数据增强的目的是增加多样性,但盲目增强会破坏组织结构的解剖关系,反而降低模型在真实场景上的稳定性。
6. 用验证集评估反向检验数据集质量:混淆矩阵与保守的数据增强
6.1 训练完成后第一件事:看混淆矩阵而不是 mAP
训练结束后,YOLOv5 会在 runs/val 目录下生成 confusion_matrix.png。我的习惯是先看混淆矩阵,再看 mAP。混淆矩阵能直接告诉你模型把哪些类别的目标混淆了。
比如 stone 预测成 normal 的格子占比较高,说明两个类别在形态上重叠太多,需要补充标注来区分。反过来,如果 background 那一列的误检很高,说明负样本不足或正常结构的高回声干扰强,此时应重点补 normal 类别,而不是盲目调超参数。mAP 是一个压缩过的数值,方便汇报,但对定位问题帮助有限。
6.2 数据增强的边界与最终验证
医学图像与其他自然图像不同,组织结构具有解剖学约束。旋转 90 度或者上下翻转会改变器官的解剖方向,模型会学到错误的先验知识。我在实际项目中会做的最小增强集合是:左右翻转(概率 0.5)、轻微亮度对比度扰动、轻微缩放(0.1 以内)。马赛克增强不是完全禁用,而是保持在 0.5 以下。对结石这类形态不规则的目标,过度增强的收益远小于它引入的噪声。
验证数据质量还有一个更本质的手段:把模型在一个完全不参与训练的新数据集上测试,比如留出 10% 的图像作为最终测试集。训练集和验证集划分得再合理,也只是在同一批病人内部验证。最终还是要靠一批全新的病人数据来检验模型,这个习惯比任何调参技巧都重要。
我印象最深的一次教训,是第一次做医学超声检测时只顾着把训练集和验证集的 mAP 刷到 0.95 以上,结果在真实设备上一测全崩,后来才意识到是数据泄露和预处理不一致两个问题叠加。现在养成的习惯是:拿到数据先按病人分组,再画框检查标注,然后跑一个最小的基线模型,最后才考虑调增强和超参数。这样做虽然多花一两天时间,但能省下后面反复试错的几周。希望这份超声波肾脏结石数据集的落地笔记能帮你在正式训练前少走一段弯路。
本文还有配套的精品资源,点击获取