
简介面向医学图像分割学习与研究者的超声乳腺疾病分割项目基于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_size256, augmentFalse): self.image_paths image_paths self.mask_paths mask_paths self.img_size img_size self.augment augment self.clahe cv2.createCLAHE(clipLimit2.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), interpolationcv2.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), interpolationcv2.INTER_NEAREST) mask (mask 127).astype(np.float32) if self.augment: # 数据增强随机翻转小幅旋转注意mask用相同变换 if np.random.rand() 0.5: img np.flip(img, axis1).copy() mask np.flip(mask, axis1).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), flagscv2.INTER_LINEAR) mask cv2.warpAffine(mask, M, (self.img_size, self.img_size), flagscv2.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几个参数说明。clipLimit2.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替换ConvReLUResUNet的改动集中在卷积块上。它把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, padding1) self.bn1 nn.BatchNorm2d(out_ch) self.conv2 nn.Conv2d(out_ch, out_ch, 3, padding1) self.bn2 nn.BatchNorm2d(out_ch) self.relu nn.ReLU(inplaceTrue) # 通道数不一致时用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用DoubleConvResUNet用ResBlock def __init__(self, in_ch, out_ch, use_resTrue): 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, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.ReLU(inplaceTrue), ) 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, smooth1.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(dim1) union pred_flat.sum(dim1) target_flat.sum(dim1) 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×256batch 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记录训练集BCEDice总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(), fbest_model_{epoch}.pth) print(fepoch {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后端。这个方案能跑但性能一般。模型权重每次加载几十MBGPU显存如果被训练任务占满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_version12 ) # 用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再换自己的数据沿着这套流程一步步调整比在各种模型结构上反复横跳有用得多。希望帮到你。本文还有配套的精品资源点击获取