简介:面向医学图像分割学习与研究者的超声乳腺疾病分割项目,基于BUSI数据集,提供ResUNet与UNet两种分割网络并可自行切换,实测Dice约0.82。代码已划分训练集与验证集,支持一键运行;训练采用cos余弦退火学习率与AdamW优化器,修改base-size参数即可适配大尺度输入,评估指标涵盖Dice、IoU、Recall、Precision、F1、Pixel Accuracy等,结果自动写入runs下的JSON文件。推理侧实现了可视化网页交互,运行infer脚本会在本地打开页面,上传图片即可完成分割。资源共900个文件,以874张png图像(数据集样本与训练曲线/分割结果图)为主,另有6个py核心代码、10个pyc、1个pth预训练权重、1个json评估记录及readme说明,压缩包大小约414MB。已有544人下载学习,适合需要完整可运行医学图像分割基线或快速复现ResUNet/UNet对比实验的人群。
1. 用网页推理跑医学图像分割:这个项目到底在解决什么问题
一套能上传B超图、在网页上直接返回病灶分割结果的系统,听起来像个Demo,实际上是把医学图像分割里最经典的两条路线——UNet和ResUNet——从训练到部署完整走了一遍。基于网页版推理实现的这套项目,以BUSI数据集(乳腺超声公开数据集)为训练语料,目标是让医生或研发人员上传一张超声图像,就能看到网络预测的病灶边界。它解决的问题很明确:训练脚本谁都会跑,但让不懂命令行的同事也能用模型,才是落地的那半步。
适合三类人看。第一类是刚入门医学图像分割的研究生,想找一个能跑通又能部署的完整参考;第二类是做医疗影像产品的工程师,需要把分割模型包成网页服务给临床试用;第三类是想在UNet结构上做改进、又怕改完没法验证效果的同学。这个项目把ResUNet和UNet放在同一个训练框架里,改模型结构只动一行配置,对比实验做起来非常顺手。
2. 从BUSI数据集到训练管道:先搞清楚输入输出再动手
2.1 BUSI数据集长什么样:超声图像的三个硬伤
BUSI数据集全称是Breast Ultrasound Images Dataset,公开的乳腺超声图像集合,整体按正常、良性、恶性三类组织,每张样本包含一张原始超声图和对应的病灶掩膜(mask)。Mask由医生手动勾画,是典型的二值分割标注,白色区域代表病灶,黑色代表背景。
做这个项目碰到第一个问题不是网络,而是数据本身。超声图像有三个硬伤,跑通用分割模型的同学不一定遇到过。第一是斑点噪声重,图像上全是颗粒状的灰度波动,不是自然照片那种平滑纹理;第二是对比度低,病灶和周围正常组织的灰度差异经常只有几个灰度级,肉眼看都费劲,模型更容易学偏;第三是目标尺度差异大,有的病灶占满大半个图像,有的只是一个小结节,同一个网络同时处理这两种尺度,分割结果会很不稳定。
所以数据加载不能只做resize和归一化,还得针对超声特性做处理。我常用的预处理序列是:读图转灰度、限制对比度自适应直方图均衡化(CLAHE)、归一化到0-1、resize到固定尺寸。CLAHE这一步是超声分割的关键,它能把局部对比度拉开,让病灶边界更清晰。
import cv2 import numpy as np import torch from torch.utils.data import Dataset class BUSIDataset(Dataset): def __init__(self, image_paths, mask_paths, img_size=256, augment=False): self.image_paths = image_paths self.mask_paths = mask_paths self.img_size = img_size self.augment = augment self.clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) def __getitem__(self, idx): # 图像:读取灰度图,CLAHE增强,归一化,resize img = cv2.imread(self.image_paths[idx], cv2.IMREAD_GRAYSCALE) img = self.clahe.apply(img) img = cv2.resize(img, (self.img_size, self.img_size), interpolation=cv2.INTER_LINEAR) img = img.astype(np.float32) / 255.0 # mask:读取后强制转单通道二值,再resize mask = cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) mask = cv2.resize(mask, (self.img_size, self.img_size), interpolation=cv2.INTER_NEAREST) mask = (mask > 127).astype(np.float32) if self.augment: # 数据增强:随机翻转+小幅旋转,注意mask用相同变换 if np.random.rand() > 0.5: img = np.flip(img, axis=1).copy() mask = np.flip(mask, axis=1).copy() angle = np.random.uniform(-10, 10) M = cv2.getRotationMatrix2D((self.img_size // 2, self.img_size // 2), angle, 1.0) img = cv2.warpAffine(img, M, (self.img_size, self.img_size), flags=cv2.INTER_LINEAR) mask = cv2.warpAffine(mask, M, (self.img_size, self.img_size), flags=cv2.INTER_NEAREST) # 转tensor,图像补通道维度,mask补batch维度后面处理 img_tensor = torch.from_numpy(img).unsqueeze(0) mask_tensor = torch.from_numpy(mask).unsqueeze(0) return img_tensor, mask_tensor几个参数说明。clipLimit=2.0是CLAHE的对比度限制值,太高会放大噪声,太低增强效果不明显,超声图像上2.0到3.0之间比较稳妥;tileGridSize=(8,8)把图像分成8×8的小块分别做直方图均衡,块数太多容易产生块状伪影。Mask的resize插值必须用INTER_NEAREST,如果用线性插值,病灶边缘会多出一圈灰色过渡带,训练时模型会以为边界是模糊的。
2.2 标签问题:mask是三通道还是单通道
这个坑几乎每个第一次用BUSI的人都会踩。公开数据集里很多mask是RGB三通道的PNG,肉眼看着是黑白图,但实际读出来是三个通道完全相同的彩色图。直接拿cv2.imread读出来是(H, W, 3),如果不处理就塞给网络,会报通道维度不匹配,或者更隐蔽地——训练能跑,但loss降不下去。
原因很简单:三通道mask被当成三通道图像,模型在输出层做了3分类,而真实标签每个像素的语义是"病灶/背景",不是"通道0/通道1/通道2"。处理方式已经在上面代码里:读图时用cv2.IMREAD_GRAYSCALE强制转灰度,再阈值化成0和1。还有一点要注意,BUSI里部分正常样本没有病灶,mask是全黑的。这类样本不能删掉,它们在教模型什么是"阴性",但对Dice指标不太友好,后面章节会讲怎么处理。
2.3 数据划分和验证集:别把同一个病人的图拆散
BUSI数据集的组织方式是按图像文件存放的,不是按病例。有些超声图来自同一个病人的不同切面,如果随机划分训练集和验证集,同一病人的相近图像可能同时出现在两边,模型相当于见过答案再考试,验证Dice会虚高。这个在学术上叫数据泄漏。
我一般的做法是:按文件名前缀分组,同一个前缀的文件归到同一个病例组,再按组划分。做到这个粒度,验证集才有参考价值。另外BUSI类别不平衡(正常、良性、恶性数量差异明显),划分时尽量保持三个类别的比例和原始数据集一致,简单用train_test_split的stratify参数按类别分层即可。
3. ResUNet和UNet的模型差异:残差连接到底改了哪一层
3.1 UNet的编码器-解码器骨架
UNet的结构不复杂,核心是编码器逐层下采样提取语义特征,解码器逐层上采样恢复空间分辨率,中间用跳跃连接把编码器每一层的细节特征拼到解码器对应层。这个设计对医学图像特别友好,因为病灶边界细节很多,纯粹靠上采样很容易丢失,跳跃连接相当于给解码器递了一张"原图笔记"。
标准UNet里每个下采样块是两次Conv3x3 + ReLU,然后接一个MaxPool2x2。解码器同理,两次卷积后接上采样。参数设计上有个细节:通道数按照64, 128, 256, 512翻倍,最深一层是1024。这个设计不是拍脑袋,是让模型在浅层保持高分辨率特征,在深层保持语义丰富度。
3.2 ResUNet用ResBlock替换Conv+ReLU
ResUNet的改动集中在卷积块上。它把UNet里的Conv3x3 + ReLU + Conv3x3 + ReLU换成了残差块:Conv3x3 + BN + ReLU + Conv3x3 + BN,然后跳过连接把输入直接加到输出上。这个改动解决的问题很具体——网络深了之后,梯度在反向传播时逐层衰减,前面几层学不到东西。残差连接给梯度开了一条"高速路",让浅层也能拿到有效的更新信号。
从对比实验的角度看,ResUNet在BUSI上通常比UNet的Dice高2到4个百分点,尤其是在病灶边界模糊的恶性样本上差距更明显。原因也不难理解:残差块让模型更容易在已有特征的增量上做判断,超声图像噪声大,残差连接等于让模型可以选择"只在需要修正时动参数",而不是每个卷积层都必须凑出一个完整特征图。
import torch import torch.nn as nn class ResBlock(nn.Module): """残差块:两个3x3卷积 + BN,输入通过shortcut加到输出""" def __init__(self, in_ch, out_ch): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, 3, padding=1) self.bn1 = nn.BatchNorm2d(out_ch) self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1) self.bn2 = nn.BatchNorm2d(out_ch) self.relu = nn.ReLU(inplace=True) # 通道数不一致时,用1x1卷积调整shortcut self.shortcut = nn.Sequential() if in_ch != out_ch: self.shortcut = nn.Sequential( nn.Conv2d(in_ch, out_ch, 1), nn.BatchNorm2d(out_ch) ) def forward(self, x): identity = self.shortcut(x) out = self.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out = out + identity return self.relu(out) class UNetEncoder(nn.Module): """编码器的一层:UNet用DoubleConv,ResUNet用ResBlock""" def __init__(self, in_ch, out_ch, use_res=True): super().__init__() if use_res: self.block = ResBlock(in_ch, out_ch) self.pool = nn.MaxPool2d(2) else: self.block = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.ReLU(inplace=True), ) self.pool = nn.MaxPool2d(2) def forward(self, x): return self.pool(self.block(x))这里的关键是shortcut的通道对齐。UNet每层通道数翻倍,输入输出通道不一致,残差相加前必须用1×1卷积把输入通道转成输出通道。很多ResUNet实现翻车就翻在这,忘了处理in_ch != out_ch的情况,代码一跑就报形状错误。
3.3 损失函数和评估指标:Dice和IoU怎么算
分割任务的损失函数不能只用BCE。超声图像里背景像素占比高,病灶可能只有百分之几的像素,BCE会让模型倾向于把一切预测成背景,Dice很低。常见做法是BCE和Dice Loss加权组合,我习惯用BCE + DiceLoss,权重各0.5。
Dice Loss的计算方式是:预测概率图和真实mask逐像素相乘求和,乘以2,除以两边各自像素和再加一个平滑项。公式理解起来绕,但代码很短。
class DiceLoss(nn.Module): def __init__(self, smooth=1.0): super().__init__() self.smooth = smooth def forward(self, pred, target): # pred: (B, 1, H, W) 概率值,target: (B, 1, H, W) 二值 pred = torch.sigmoid(pred) pred_flat = pred.view(pred.size(0), -1) target_flat = target.view(target.size(0), -1) intersection = (pred_flat * target_flat).sum(dim=1) union = pred_flat.sum(dim=1) + target_flat.sum(dim=1) dice = (2.0 * intersection + self.smooth) / (union + self.smooth) return 1 - dice.mean()self.smooth的作用是防止分母为0,尤其处理全黑mask的样本时。数值设1.0就够了,设太大会让loss下不去,Dice分数看上去不错但实际预测结果偏保守。评价指标用Dice和IoU两个就够了,Dice对分割面积敏感,IoU更严格一些,两者差距大的时候说明模型在边界上犹豫。
4. 训练参数设置:从收敛到过拟合之间有哪些旋钮
4.1 学习率、batch size、图像尺寸怎么定
这套项目在普通单卡GPU上就能跑,不需要特殊硬件。我常用的配置是:图像resize到256×256,batch size设8,初始学习率1e-4,优化器用AdamW,训练100个epoch。如果显存够大,把尺寸提到384或512,对小病灶分割有明显帮助,代价是训练时间变长。
学习率是这套配置里最容易翻车的旋钮。超声波图像噪声大,梯度本身抖动明显,学习率超过3e-4很容易看到loss炸掉。换用余弦退火调度器,让学习率从1e-4逐渐降到1e-6,能比固定学习率稳定提升1到2个点Dice。验证集Dice如果一直在0.7左右上不去,先检查学习率和数据划分,再改网络结构。
batch size和BN层有联动。BUSI单张图像内容差异大,batch太小(比如2)时BN的均值和方差估计不准,训练和验证性能差距大。如果显存只够batch size 4,可以把BN换成GroupNorm,或者用梯度累积模拟更大的batch。
4.2 训练循环和模型保存:怎么判断模型真的学会了
训练代码结构不复杂,但有几个细节要盯住。每个epoch记录训练集BCE+Dice总loss和验证集Dice。保存checkpoint不是只看loss最低,而是看验证集Dice最高——这两个时刻往往不重合。
best_dice = 0.0 for epoch in range(epochs): model.train() train_loss = 0.0 for imgs, masks in train_loader: imgs, masks = imgs.to(device), masks.to(device) preds = model(imgs) loss = bce_loss(preds, masks) + dice_loss(preds, masks) optimizer.zero_grad() loss.backward() optimizer.step() train_loss += loss.item() model.eval() val_dice = 0.0 with torch.no_grad(): for imgs, masks in val_loader: imgs, masks = imgs.to(device), masks.to(device) preds = torch.sigmoid(model(imgs)) preds_bin = (preds > 0.5).float() val_dice += compute_dice(preds_bin, masks).item() val_dice /= len(val_loader) if val_dice > best_dice: best_dice = val_dice torch.save(model.state_dict(), f'best_model_{epoch}.pth') print(f'epoch {epoch}: train_loss={train_loss:.4f}, val_dice={val_dice:.4f}')一个容易被忽略的点:验证时必须先过sigmoid再阈值化,如果直接拿网络输出做阈值,模型没经过概率映射,结果会差很多。compute_dice函数里的mask也要确认是0/1二值,不能是浮点标签。
预测时输出概率图而不是直接阈值化,这很关键。保存的模型输出0到1之间的概率,网页端做可视化时把概率图叠在原图上,医生能看出模型在哪些区域不太确定。直接保存0/1结果会丢掉这层信息。
4.3 训练自己的数据集:从BUSI换到私有数据的四个注意点
跑通BUSI只是第一步,换到自己数据集时会发现四个差异。
第一是mask规范。BUSI的mask是黑白图,但有些私有数据集是多边形标注文件(JSON/XML),得先转成分割掩膜,注意多边形和图像坐标系的对应关系。第二是图像格式。超声设备输出的DICOM文件需要用pydicom读取,像素值范围和普通PNG差别大,预处理要知道窗宽窗位的设定。第三是类别定义。BUSI是二分割,有的项目要同时分割病灶和腺体结构,输出通道得从1改成对应类别数。第四是数据量。私有数据通常只有几十张,这种情况得做更强的数据增强,或者用预训练权重做迁移学习,否则UNet这种深网络很容易在小数据集上过拟合。
5. 网页版推理避坑:超声乳腺分割部署的5个常见问题
5.1 现象:网页上传图后返回的全黑或全白
这是我见过最多的问题。训练时跑得好好的,放进网页推理就输出一张纯黑图。原因基本是预处理链路不一致——训练时做了CLAHE、归一化、resize,但网页端后端脚本直接读取原始图喂给模型。超声图像灰度分布和自然图不同,模型在训练时看到的一直是处理后的分布,输入分布一变,输出直接崩。
解决的方法是写一个独立的preprocess(input_path) -> tensor函数,训练脚本和Web端脚本都调用同一个函数,不要各写各的。灰度图读取也统一用cv2.IMREAD_GRAYSCALE,前端如果上传的是三通道图要强制转灰度。
5.2 现象:推理速度很慢,一个请求要好几秒
项目场景是网页版推理,但很多实现把PyTorch模型直接挂到Flask后端。这个方案能跑,但性能一般。模型权重每次加载几十MB,GPU显存如果被训练任务占满,CPU推理一张256×256图要几百毫秒到一两秒,并发一上来网页直接卡死。
常见的优化路径:后端启动时只加载一次模型,不要每个请求都创建新session;输入图像在传给模型前做好resize;如果用CPU推理,把模型转成ONNX格式能获得明显加速。这个后面展开。
5.3 现象:Dice分数高,但预测mask边缘毛毛糙糙
超声图像本身噪声多,模型预测出的病灶边缘经常有锯齿和凸刺,医生看了说不行。原因是网络输出直接做了0.5阈值化,没有后处理。阈值化之后的mask可以用形态学开运算去掉小毛刺,再用闭运算填补内部空洞。
另外一个更隐蔽的原因:训练时的数据增强加了旋转和翻转,但mask的resize用了默认的线性插值,导致边缘出现过渡带,模型学到的边界就不是锐利的。统一改成INTER_NEAREST之后,边缘质量会显著提升。
5.4 现象:正常样本(无病灶)被预测出病灶
BUSI里正常样本没有mask,训练时它们教会模型输出全零。但超声图像上乳腺组织本身有纹理,模型可能把某些纹理当成病灶,画出一块假阳性区域。
解决思路不是改网络,而是改推理策略。判断预测的最大连通域面积占图像比例是否低于阈值(例如1%),低于则直接输出全黑mask。这个规则在医疗场景里说得通,没有医生会希望模型在正常组织上画个圈。
5.5 现象:mask尺寸和原图对不上,叠加显示错位
网页端要显示分割结果和原图的叠加,前端图片显示尺寸和模型输入尺寸不一致时,mask坐标就对不齐。常见实现里模型输入是256×256,但前端显示原图是800×600,直接把256×256的mask放大显示就会错位。
解决方式是在后端就把mask统一resize到原图尺寸再返回给前端,前端不做任何缩放。如果后端返回的是PNG蒙版,前端显示时用绝对定位叠加即可。
6. 进阶玩法:把PyTorch模型转成ONNX,在网页上跑更快推理
网页版推理的性能瓶颈通常不在模型本身,而在PyTorch的推理开销。每条请求都要经过Python解释器、动态图调度,CPU上的效率不高。把模型导出成ONNX格式,用ONNX Runtime推理,是这个场景下性价比最高的优化。
导出过程不复杂,先准备一个随机输入,调torch.onnx.export。关键是opset_version要选对,推荐11或12,太老不支持某些算子,太新有些推理环境又不兼容。
import torch import onnxruntime as ort # 导出UNet为ONNX model.eval() dummy_input = torch.randn(1, 1, 256, 256) torch.onnx.export( model, dummy_input, "unet_busi.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch_size", 2: "height", 3: "width"}, "output": {0: "batch_size"}}, opset_version=12 ) # 用ONNX Runtime做CPU推理 session = ort.InferenceSession("unet_busi.onnx", providers=["CPUExecutionProvider"]) input_name = session.get_inputs()[0].name output_name = session.get_outputs()[0].name # 输入numpy数组,形状(1,1,256,256),float32 result = session.run([output_name], {input_name: img_np})[0]dynamic_axes这一段是必须的。它允许推理时输入不同尺寸的图像,不用重新导出模型。但要注意,ONNX Runtime在高宽变化时性能会下降,网页端最好固定输入尺寸256×256,前端把上传图直接裁到这个尺寸。我踩过的坑是动态尺寸下ONNX的池化层在非整除尺寸会报错,固定尺寸后彻底消失了。
导出后建议用onnxruntime测一遍,对比PyTorch输出差异。差异要在1e-5量级以内,如果差得多,检查模型里是否有自定义算子或训练时才有的Dropout。最后用onnx-simplifier跑一遍去除冗余计算,CPU推理延迟通常能再降10%到20%。
我在一个内部工具里做过对比,同一份UNet权重,PyTorch CPU推理单张耗时820ms,转成ONNX Runtime后降到390ms,优化后稳定在350ms左右。配合后端复用Session、预热一次推理,网页端体感从"等几秒"变成"基本秒出"。这个优化做完,医生试用时不会再第一句话问"怎么这么慢"。
这套流程的验证方式也简单:把ONNX推理结果和PyTorch前向结果放在同一张图上对比像素差异,肉眼看不到差别就说明导出没问题。最后再拿标注好的测试集跑一遍Dice,确保和训练时的验证指标接近。如果差得多,优先怀疑预处理不一致,而不是模型变了。
做这个项目最深的体会是,医学图像分割在训练上早已不是最难的部分,真正的难点全在细节的贯通——数据怎么读、mask是几通道、训练和推理的预处理是否一致、部署时能不能保持原有精度。先跑通BUSI,再换自己的数据,沿着这套流程一步步调整,比在各种模型结构上反复横跳有用得多。希望帮到你。
本文还有配套的精品资源,点击获取