超声乳腺BUSI分割实战:UNet/ResUNet训练与网页推理部署
简介一份基于ResUNet与UNet的医学图像分割实战项目面向深度学习与医学影像处理学习者以超声乳腺疾病分割BUSI数据集为场景实现病灶区域的端到端分割。项目代码支持一键运行训练与验证集已划分在data目录下可自行选择ResUNet或UNet网络采用AdamW优化器与cos余弦退火学习率训练时可通过修改base-size适配不同输入尺度。评估阶段输出dice、iou、recall、precision、f1、pixel accuracy等指标并在runs目录的json中保存结果。推理端提供可视化网页运行infer脚本后上传图片即可获得分割效果。资源包共900个文件包括874张png图像数据集与可视化结果、6个py脚本训练、评估、推理等、pt模型权重及json日志等压缩包约414MB结构清晰便于对照学习。目前已有544人学习下载适合想复现医学图像分割流程并快速上手的读者。1. 超声乳腺BUSI分割带网页版推理的UNet/ResUNet项目能拿来做什么对着超声乳腺BUSI数据集跑一个UNet网络或ResUNet训练完再挂一个网页交互界面让非技术的人直接传B超图就能看到病灶掩码——这是这个项目最直接的产出。对做过医学图像分割的人来说真正的价值不是模型结构有多新而是整套链路是通的数据怎么清洗、训练参数怎么设、模型怎么导出、网页推理怎么对输入做同样的预处理。BUSI数据集不算大六百多组原图加掩码UNet和ResUNet这类轻量分割网络完全带得动配合合理预处理Dice一般能到0.85上下。适合三类人做医学图像分割课程设计的学生、想把超声分割落成演示Demo的初学者、准备拿乳腺超声做小样本分割实验的从业者。下面按数据集清洗、模型选型、训练参数、网页部署这条路走一遍。2. 原理先立住为什么选UNet和ResUNetBUSI数据预处理怎么才算干净2.1 UNet作为基线的逻辑小数据集分割先不折腾结构超声乳腺分割是典型的单类目标区域提取任务不需要区分实例也不存在“先检测再分割”的必要UNet这种编码器-解码器对称结构就是最稳的起点。编码器不断下采样从256分辨率一路降到16甚至8拿到语义层面的抽象特征解码器再逐步上采样回原尺寸中间的跳连接把下采样丢失的空间边缘信息直接拼回来。这个设计对BUSI这类病灶边缘模糊、背景杂乱的数据非常合适因为跳连接能让分割结果保留更多细节而不是只靠高层语义硬撑。很多人上来就想上Transformer、上大模型但BUSI一共六百多组样本正常、良性、恶性三类混在一起单个样本里的病灶区域又常常只占几个百分点。大模型在这种数据量下极其容易过拟合跑出来的指标反而不如UNet。所以这份资源把UNet作为基线是合理的它意味着你后续做任何改动都有对比基础。跑一个UNet网络先看它在你数据集上的表现再谈改进这是医学图像分割的标准节奏。UNet的另一个优势是显存占用低。256x256输入batch size开到8一块消费级显卡就能跑。对于课程设计和毕业设计来说这决定了你不是被卡在硬件门槛而是能把精力放在数据、增强和参数调优上。代码里UNet的Base通道数一般取64即第一层卷积输出64个通道后面每下采样一次通道翻倍最终到512。想进一步减内存可以把Base从64改成48后面所有层的通道数都会等比缩小效果通常只掉零点几个Dice但训练速度能明显提升。2.2 ResUNet到底改了什么残差连接为什么在超声数据上管用ResUNet属于对UNet模型改进里最稳的一种它没有改动UNet的骨架只是把编码器、解码器里的普通卷积模块替换成带残差连接的模块。普通卷积块是两个卷积加激活输出只由当前输入决定残差块的输出则是“当前输入 卷积变换结果”的和。这个加法让梯度在深层网络里多了一条短路通道反向传播时梯度可以直接穿过若干层回到浅层不会因为连乘衰减成零。BUSI的超声图像噪声重、对比度低病灶和正常腺体组织之间经常没有清晰边界这会让深层网络的训练变得困难。我拆过不少项目ResUNet在这类数据上通常比UNet的Dice高2到5个百分点尤其是边缘区域。代价是训练时间多出20%到30%因为残差连接需要保存更多的中间激活值用于反向传播内存占用也会上去一点。下表是这两者在同一份BUSI预处理数据下的典型差异具体指标会因随机种子和增强策略浮动但趋势稳定对比维度UNetResUNetDice验证集典型区间0.82~0.850.85~0.88边缘分割稳定性一般细长病灶容易漏更好残差保留细节训练显存占用低比UNet高约20%单epoch耗时256x256基准基准 20%~30%适合场景快速基线、资源受限追求指标、课程设计演示如果你的实验报告需要对比实验UNet和ResUNet正好构成一组“基线 vs 改进”的叙事这比硬凑一个新模块更有说服力。项目里两个模型共用同一套数据预处理和训练入口切换模型只改一个参数做消融很方便。2.3 BUSI数据集的清洗掩码二值化和CLAHE是第一步不做必翻车BUSI数据集原始结构是按类别分文件夹存放的正常、良性、恶性各自有原图和掩码。但直接用原始文件训练会踩几个坑第一有一部分原图找不到对应掩码数量不多但混在训练集里会直接导致Label形状不匹配第二官方掩码虽然是黑白图但像素值并不严格只有0和255某些标注的灰度值分布在100到255之间直接按浮点数当标签会让损失函数计算混乱。所以第一步要做的是把掩码统一二值化并剔除没有掩码的样本。import cv2 import numpy as np from glob import glob # 假设目录按类别存放先找出有对应掩码的原图 img_paths sorted(glob(./BUSI/images/*.png)) mask_paths sorted(glob(./BUSI/masks/*.png)) valid_pairs [] img_names {p.split(/)[-1].replace(_mask.png, .png): p for p in mask_paths} for img_path in img_paths: name img_path.split(/)[-1] if name in img_names: valid_pairs.append((img_path, img_names[name])) else: print(f跳过无掩码样本: {name}) # 掩码统一二值化阈值取 127 mask cv2.imread(mask_paths[0], cv2.IMREAD_GRAYSCALE) mask np.where(mask 127, 1, 0).astype(np.uint8) # CLAHE 拉超声图像对比度 clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) img clahe.apply(cv2.imread(img_paths[0], cv2.IMREAD_GRAYSCALE)) # 统一尺寸掩码用最近邻插值避免边界出现灰色伪像素 img cv2.resize(img, (256, 256), interpolationcv2.INTER_CUBIC) mask cv2.resize(mask, (256, 256), interpolationcv2.INTER_NEAREST)这段代码里需要注意两个关键点。掩码二值化时阈值取127是因为BUSI掩码的背景是纯黑0前景灰度分布在100以上取127能把所有标注区域完整保留。掩码resize必须用最近邻插值如果改成默认的线性插值病灶边缘会出现介于0和1之间的灰色过渡训练时模型会被误导推理时输出的边界也会变得模糊。原图用CLAHE是为了增强低对比度区域的纹理超声图像在不同设备上灰度分布差异很大不做这一步同一个模型在不同来源的图上表现会差距很大。数据清洗完下一步就是按7:2:1切分训练、验证、测试集。切片时要保证同一个病人的多张图不跨集BUSI里部分病人有多个切面图跨集会引入数据泄露验证集指标会虚高。这个项目里提供了split脚本输出三个txt文件每行一个样本路径后面所有训练和推理都读取这三个清单这是一个值得保持的好习惯。3. 训练落地数据目录、训练命参配置、评估指标一次讲清楚3.1 项目目录与代码入口拿到资源先对路径这份资源的代码组织比较直观解开压缩包后主要文件如下BUSI/ ├── images/ │ ├── benign/ │ ├── malignant/ │ └── normal/ ├── masks/ │ ├── benign/ │ ├── malignant/ │ └── normal/ ├── split/ │ ├── train.txt │ ├── val.txt │ └── test.txt ├── train.py ├── predict.py ├── app.py └── models/ ├── unet.py └── resunet.pytrain.py是训练入口predict.py是单张图片推理脚本app.py是网页版推理的Gradio程序。models目录下两个模型文件互相独立没有共用复杂的注册机制意味着你想改结构只需要在对应文件里动手不用翻很多层抽象。首次训练前建议先把split脚本跑一遍确认train.txt、val.txt里的路径和实际磁盘目录对得上。我见过不少人拿到项目直接python train.py结果路径里多了一层文件夹数据全部没读到报错信息又不直观白白浪费一个晚上。如果路径对不上优先检查images和masks的根目录层级不要急着改代码。3.2 训练命令与参数这套配置是踩过坑后的版本训练直接用命令行启动模型类型、输入尺寸、批次大小、学习率、训练轮数都在命令行显式给出不藏在配置文件里。这样做的好处是每次实验都能从终端历史里找到完整参数组方便复现和回溯。训练命令大致如下python train.py \ --data_root ./BUSI/split \ --model resunet \ --input_size 256 \ --batch_size 8 \ --epochs 120 \ --lr 1e-4 \ --loss dice_bce \ --val_interval 5这里的参数不是随手填的每个都有讲究。input_size取256是因为BUSI原图尺寸在500到1000像素不等直接按原图训练显存不够统一到256能保留病灶大体形状又不会把训练时间拖太长。如果你想把边缘细节做得更好可以试512输入但显存占用会翻很多batch size必须降到2或4。batch_size取8是ResUNet在单张消费级显卡上比较稳的值UNet可以开到16但两个模型共用这份配置也没什么问题。learning rate取1e-4是医学图像分割里常见做法属于既不会发散也不会太慢的区间。搭配dice_bce复合损失也就是Dice loss和二值交叉熵相加这个组合对前景占比很小的超声数据特别重要单用BCE会让模型只顾着把所有像素预测成背景Dice反而没梯度去优化边界。epochs取120主要是参考了类似项目在BUSI上的收敛曲线一般到80轮以后Dice涨幅就很小了120是留了余量的设定允许你在实验中途不用频繁盯着曲线。val_interval5表示每5个epoch在验证集上跑一次评估这样即使训练中断你也能从日志里看到指标变化趋势。如果训练日志显示前20轮Loss降幅很小不要急着停先确认数据增强和掩码预处理是不是有问题再考虑调学习率。模型每轮结束会保存一份best_model.pth和last_model.pth前者是验证集Dice最高的权重网页推理默认加载best_model.pth这一点在部署时要记得保持一致。3.3 评估脚本Dice和IoU怎么算才是对的评估脚本里最核心的两个指标是Dice和IoU代码实现不复杂但容易写错尤其是边界处理。这里给出一个正确的写法import torch def dice_score(pred, gt, eps1e-6): pred (pred 0.5).float() inter (pred * gt).sum().item() pred_sum pred.sum().item() gt_sum gt.sum().item() return (2 * inter eps) / (pred_sum gt_sum eps) def iou_score(pred, gt, eps1e-6): pred (pred 0.5).float() inter (pred * gt).sum().item() union (pred gt 0).sum().item() return (inter eps) / (union eps)Dice对病灶面积小的样本更敏感IoU更看重整体重叠率这两个指标在BUSI上通常差距在3到5个百分点都是可以接受的。写实验报告时别只报Dice把IoU一起放进去评委或导师会认为你做了完整的评估。另外计算指标前务必把概率图二值化用阈值0.5而不是argmax因为这里只有一个前景类别argmax会把所有位置都当成前景指标直接算错。跑完120轮我建议你在测试集上再跑一遍完整的评估而不是只看验证集。验证集参与了模型保存的选择测试集才是真正没见过的数据。这个项目里评估脚本会输出每个类别的平均Dice和全体平均Dice。正常、良性、恶性三类分开看很重要因为恶性样本的病灶往往边界更不规则Dice会比良性低不少如果只看平均数你可能意识不到模型在恶性样本上的薄弱。4. 避坑实录BUSI UNet/ResUNet 最常见的五个翻车现场4.1 训练半天Loss纹丝不动背景占比太高压过了前景现象训练了20轮Loss下降非常慢Dice始终在0.3以下模型预测结果几乎全是背景。原因BUSI里大部分图像的病灶区域只占全图的5%以下网络很容易学会“全部输出背景”这样BCE损失已经很小但Dice完全没优化。我之前见过一个项目把正常、良性、恶性三类全喂进去其中正常类完全没有掩码模型输出自然一片黑。解决先确认训练清单里没有“正常”类样本分割任务只需要良性、恶性两类带掩码数据。然后把损失函数换成BCE Dice的组合Dice损失天然对前景占比不敏感能强行把模型往“至少要预测出一点东西”的方向拉。如果换完还不行检查掩码是否被误读成全0用上面2.3节说的二值化步骤过一遍。4.2 训练集Dice很高但验证集惨不忍睹掩码没有真正二值化现象训练集Dice到0.93验证集只有0.6差距大到不合理。原因掩码从png读进来后是灰度图没有做阈值处理有些样本的标注灰度值在100到200之间模型输出的概率图在0.5附近徘徊训练时和灰度标签算出来的Dice虚高验证集按0.5阈值一卡就露馅。解决把掩码统一强制成0和1同时可视化几个训练样本的掩码确认病灶区域是纯白、背景是纯黑。这一步属于“UNet使用时的注意事项”里最容易被忽略的一条但也是最致命的一条。我自己的习惯是清洗完数据后随机挑9组图把原图、掩码、重叠图各存一张拼在一起过目一遍不花多少时间能省一大段返工。4.3 ResUNet显存爆了残差连接比想象中更吃显存现象UNet在同一台机器上batch8跑得好好的换成ResUNet后直接OOM或者训练速度骤降。原因残差连接需要把每个残差块输入侧的激活值保存下来用于反向传播计算梯度中间激活量比普通卷积块多出不少。显存不够时PyTorch不一定立刻报错可能表现为温度升高、训练变慢甚至跑了一半被系统杀进程。解决把batch_size从8降到4是最快的办法。如果想保住batch_size把ResUNet的初始通道数从64改成48参数量和显存会明显下降Dice一般只降0.5到1个点。这个项目在模型代码里预留了base_channels参数改一行就行。4.4 网页推理输出的掩码位置不对没把预测结果resize回原图现象模型训练时输入256x256网页上传一张1000x800的B超图输出的掩码贴回原图后病灶位置明显错位或者掩码尺寸对不上。原因推理时只做了模型的forward没有把预测的概率图从256x256恢复到原图尺寸就直接当掩码叠加显示。很多新手以为模型输出的是原图大小但实际不是训练时的resize已经把它压缩了。解决推理脚本里保存原图的高度和宽度模型输出概率图后先二值化再resize回原图尺寸。代码示例放在第五章原理上是记录尺寸、恢复尺寸两步不要想当然。4.5 验证和推理结果差异大数据增强没有关干净现象训练时在图像上做了随机翻转、随机旋转、随机亮度抖动验证时指标还不错但模型导出到推理脚本后效果明显变差。原因推理脚本在预处理阶段没有把增强开关关掉或者增强逻辑写在Dataset类的__getitem__里训练时和推理时都调用了同一个预处理函数推理时图片被随机裁切或翻转病灶位置自然乱套。解决推理脚本里使用预处理函数时显式设置trainFalse只保留resize、归一化、CLAHE这些确定性的操作。如果你在训练代码里写死了增强导出模型前跑一遍predict.py看看两次输出是否一致不一致就逐个排查增强项。这个坑不报错但特别坑属于跑了很多轮才反应过来的玄学问题。5. 网页版推理用Gradio把模型收进浏览器预处理链必须对齐5.1 推理时的预处理和训练保持一致一步都不能省模型训练和网页推理之间的鸿沟最典型的就出在预处理上。训练时图像走了“转灰度 - CLAHE - resize成256 - 归一化 - 增强”这条链推理时如果只做了转灰度就塞进模型输出大概率是乱的。更隐蔽的是通道数不一致训练数据如果是单通道灰度图模型第一层卷积只接受1个通道网页上传的彩色图直接输入必然报维度错误。推理脚本里的预处理必须精确复刻训练时的确定性操作。下面这段是项目里predict.py的核心逻辑也是网页端推理会调用的同一个函数import cv2 import numpy as np import torch # 训练时初始化好的全局CLAHE对象参数必须和训练一致 clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) def preprocess(image_np): # 输入可能是bgr或rgb统一转灰度再进模型 if len(image_np.shape) 3: gray cv2.cvtColor(image_np, cv2.COLOR_RGB2GRAY) else: gray image_np h, w gray.shape[:2] # 记录原始尺寸推理后要恢复 gray clahe.apply(gray) # 1. CLAHE 增对比度 resized cv2.resize(gray, (256, 256), interpolationcv2.INTER_CUBIC) # 2. 归一化到[0,1]转成 (1, 1, 256, 256) tensor torch.from_numpy(resized).float().unsqueeze(0).unsqueeze(0) / 255.0 return tensor, (h, w)这个函数里最容易漏的是记录原始尺寸(h, w)。很多第一次做部署的人看到模型输出256x256的概率图直接在原图上覆盖发现病灶位置整体偏移就是因为没有恢复尺寸。恢复的操作要等模型forward完之后再做概率图先resize回(h, w)然后二值化再和原图叠加这一步顺序反了也会出问题。5.2 Gradio界面一个函数接一个Interface就够跑Gradio是目前最常见的网页推理方案因为它的Interface封装了上传、显示、回调的整套逻辑不需要自己写HTML和JavaScript。这个项目的app.py核心代码大致如下import gradio as gr import torch from models.resunet import ResUNet # 加载训练时保存的best_model.pth model ResUNet(base_channels48) model.load_state_dict(torch.load(./best_model.pth, map_locationcpu)) model.eval() def predict(image_np): tensor, (h, w) preprocess(image_np) with torch.no_grad(): prob torch.sigmoid(model(tensor)).squeeze().numpy() # 概率图恢复原始尺寸并二值化 prob cv2.resize(prob, (w, h), interpolationcv2.INTER_CUBIC) mask (prob 0.5).astype(np.uint8) * 255 # 在原图上画红色轮廓方便直接判断病灶位置 contour_img np.array(image_np).copy() contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cv2.drawContours(contour_img, contours, -1, (255, 0, 0), 2) return mask, contour_img gr.Interface( fnpredict, inputsgr.Image(typenumpy), outputs[ gr.Image(typenumpy, label分割掩码), gr.Image(typenumpy, label轮廓叠加), ], titleBUSI 超声乳腺病灶分割, description上传B超图像输出二值掩码和病灶轮廓, ).launch()这里有几个工程细节值得说。model.eval()必须在推理前调用它可以关闭Dropout和BatchNorm的training状态否则同一张图每次预测结果都可能不一样这是医学图像分割部署时最常见的问题之一。torch.no_grad()省去梯度计算显存占用会低很多。输出的mask是0和255的uint8数组Gradio直接显示成黑白图如果输出0和1的数组显示出来可能是一张全黑图因为像素值太暗。另一个值得做的操作是从概率图上找轮廓叠加到原图上显示。单纯输出黑白掩码医生很难判断病灶边界对应原图的哪个位置画上红色轮廓线后人眼一眼就能看出模型分割的区域是否合理。实践里这样展示的Demo效果好很多也给后续人工复核留了空间。5.3 launch参数与边界情况Gradio的launch()默认在本地起一个Web服务打开终端提示的地址即可访问。如果你想让局域网内其他人也能访问可以显式指定server_name0.0.0.0和server_port7860。端口被占用的话会报错换一个端口即可。上传图像的分辨率没有硬性限制因为predict函数内部会resize但超大图会拖慢推理时间。如果遇到上传失败多半是图像格式问题Gradio支持的常见格式是jpg/pngBMP偶尔会出异常可以先转成jpg再传。这里需要注意一件事predict函数里的preprocess写的是RGB转灰度Gradio的gr.Image(typenumpy)默认返回RGB三通道数组但如果代码里用了cv2.imread读本地文件那是BGR顺序。两种来源颜色通道顺序可能不同但转灰度后差异很小只要保持“训练、推理都用同一套处理逻辑”即可。网页版推理在这个项目里的定位是演示和交付。课程答辩现场让老师现场传一张图看分割结果比翻训练曲线直观得多横向课题沟通时给对方一个本地网页Demo也比递一套命令行脚本友好得多。这也是我建议你不要跳过第五章的原因——模型训练只是前半段能把模型装进可交互的界面项目才真的算落地。6. 进阶把模型转ONNX提速面试和工程交付都加分6.1 导出ONNX移除PyTorch依赖推理速度更快如果模型只在本机跑PyTorch就够了。但一旦涉及跨机器部署、集成到别人的系统或者需要更快的推理速度把best_model.pth导出成ONNX是标准做法。ONNX是静态图格式没有PyTorch的动态图开销推理速度通常会提升一部分而且可以脱离PyTorch环境运行。import torch from models.resunet import ResUNet model ResUNet(base_channels48) model.load_state_dict(torch.load(./best_model.pth, map_locationcpu)) model.eval() dummy torch.randn(1, 1, 256, 256) torch.onnx.export( model, dummy, resunet_busi.onnx, input_names[input], output_names[mask], dynamic_axes{input: {0: batch}, mask: {0: batch}}, opset_version12, )导出时用一张全1的随机张量作为示例输入维度(1, 1, 256, 256)必须和训练时保持一致。dynamic_axes把batch维度设为动态这样导出后的模型既可以单图推理也可以一次喂多张图做批量预测。opset_version取12是因为它兼容性好太新的版本部分推理引擎可能不支持。导出成功后用onnxruntime加载模型推理代码从torch.no_grad()变成ort_session.run()预处理和后处理逻辑完全不用动。如果追求极致的部署体积可以再用OpenVINO把ONNX转成IR格式并量化到FP16甚至INT8模型的体积和推理耗时都能进一步下降。这项操作的收益和成本并存需要拿验证集做量化校准否则精度会明显损失但工程上是加分项。我从这个项目里学到的最有用的一招也是从那以后每次拿到新数据集都会强制自己做的事情第一件事不是改网络结构而是先把掩码可视化一遍。随机选几十个样本把原图和掩码叠在一起看能迅速发现标签噪声、灰度掩码缺失、病灶面积过小等一堆问题。这些问题如果不先暴露出来大概率会在训练很久之后才以玄学误差的形式反咬一口。花半小时做数据检视比花两小时排查训练问题值得多。希望这份拆解能帮你在复现这个项目时少走弯路一次跑通。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →