ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

超声乳腺分割实战:基于BUSI数据集的UNet/ResUNet网页推理部署

超声乳腺分割实战:基于BUSI数据集的UNet/ResUNet网页推理部署 简介这份资源是以超声乳腺疾病分割BUSI数据集为场景的医学图像分割项目采用ResUNet与UNet双网络结构支持在网页端完成可视化推理适合正在学习医学影像语义分割流程的研究生、算法工程师及相关方向开发者。压缩包共900个文件包括874张png图像、10个pyc、6个py、4张jpg、3个txt、1个readme、1个pth模型权重与1个json配置总大小414.09MB其中py脚本承载训练、验证与infer推理逻辑png图像为数据集及可视化结果pth和json则保存训练好的模型参数与评估记录。目前已吸引544人学习下载代码可一键运行训练部分内置ResUNet与UNet选择、余弦退火学习率调度和AdamW优化器支持修改base-size适配大尺度训练评估阶段输出dice、iou、recall、precision、f1、pixel accuracy等多项指标并同步保存在runs目录下的json文件中。推理时执行infer脚本会在本地打开网页上传图片即可看到分割效果同时项目还保留了loss曲线、iou/dice曲线、学习率衰减曲线等可视化图表便于直接分析模型训练过程与收敛情况。1. 为什么超声乳腺分割要单独做一个网页版推理项目基于网页版推理实现的ResUNet和UNet医学图像分割项目超声乳腺疾病分割(BUSI数据集)拆开看其实只负责两件事第一用 BUSI 数据集把 UNet 和 ResUNet 训练成能分割乳腺超声病灶的模型第二把训练好的模型包装成网页服务让不写代码的人打开浏览器上传一张超声图就能看到病灶掩码。医学图像分割的难点从来不只在网络结构还在数据划分、预处理一致性和推理落地。命令行脚本只能自己用网页版推理才算交付。下面按“数据准备、网络实现、训练验证、网页推理、自检验收”的顺序展开适合刚开始跑 unet 模型、正在做医学图像分割项目的人。2. BUSI数据集与超声乳腺分割任务先搞懂要切的是什么2.1 BUSI 数据集的真实结构BUSI 数据集全称 Breast Ultrasound Images Dataset是乳腺超声分割任务里很常见的基准数据集。文件通常按 normal、benign、malignant 三个类别目录存放每张超声原图对应一张同名_mask.png掩码图。原图是超声设备输出的灰度图掩码是黑白图白色像素代表病灶区域黑色像素代表背景。第一次拿到数据集不要直接训练先做一次结构体检否则后期各种问题都会归到“网络没调好”其实是数据加载写错了。一个典型的检查脚本长这样import os import cv2 data_root ./BUSI/benign images [f for f in sorted(os.listdir(data_root)) if f.endswith(.png) and mask not in f] masks [f for f in sorted(os.listdir(data_root)) if f.endswith(_mask.png)] # 检查原图和掩码是否一一对应 missing [img for img in images if img.replace(.png, _mask.png) not in masks] extra [m for m in masks if m.replace(_mask.png, .png) not in images] print(缺掩码:, missing) print(多掩码:, extra) # 统计掩码里有几个独立病灶 for m in masks[:5]: mask cv2.imread(os.path.join(data_root, m), cv2.IMREAD_GRAYSCALE) _, binary cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY) num, _ cv2.connectedComponents(binary) print(m, 独立病灶数:, num - 1)这段代码有两个目的。第一确认掩码文件名规则统一避免后面 DataLoader 因为找不到对应文件直接崩掉。第二用 connectedComponents 统计掩码里的独立连通域数量。BUSI 里经常出现一张掩码包含多个病灶的情况后处理如果只保留最大连通域就会漏掉另一个真实目标。先把这个数量统计出来后续设计损失函数和后处理才有依据。还有一个细节容易被忽略掩码的像素值可能是 0 和 255也可能因为标注工具导出变成 0 和 1。训练时统一阈值化成 0/1不要直接在损失函数里用 255否则 BCE 会被放大很多倍模型很难收敛。另外BUSI 的原图尺寸不统一读取后先打印几组长宽决定后续 resize 的基准尺寸。不要默认所有图都是 256x256。2.2 超声图像为什么不能套用自然图像分割流程自然图像分割里很多人习惯直接找 ImageNet 预训练权重把 ResNet50 当作编码器。乳腺超声图像和自然图像差别很大超声图灰度范围窄、对比度低、边界模糊还充满散斑噪声。更麻烦的是病灶经常伴随声影肿瘤内部灰度不均匀边缘不闭合。从 ImageNet 迁移来的卷积核更擅长识别颜色和纹理对超声图像并不友好。所以在 BUSI 这种医学图像分割项目里网络设计往往从 UNet 这类医学分割结构开始而不是盲目套语义分割大模型。这并不意味着 UNet 落后。超声乳腺病灶和背景在灰度上差异不大真正有用的反而是浅层边缘信息。UNet 的跳跃连接把下采样过程中丢失的高频细节直接送到解码器对应层保留边缘定位能力。ResUNet 则是在这个结构上做 unet 模型改进把每个普通卷积块替换成残差块让信息能跨层传递。对小样本超声数据集残差连接能缓解深层网络梯度消失训练更稳。还有一个容易踩的坑是输入通道数。BUSI 原图是灰度图读出来是单通道网络第一层输入通道要设成 1。很多代码从自然图像拷贝过来默认输入 3 通道结果训练和推理都不报错但指标一直很差。处理超声图像时不要保留 RGB 三次重复直接把灰度图变成[1, H, W]即可。如果设备导出的实际上本来就是三通道彩色超声图也要显式转灰度避免颜色通道引入噪声。2.3 划分数据集时避免“同图不同帧泄漏”小数据集上训练医学图像分割模型最容易翻车的不是网络结构而是数据划分。BUSI 里同一个病例可能有多张不同切面的超声图这些图对应同一个病灶区域。如果随机按单张图片划分同一个病例的图可能同时出现在训练集和验证集验证指标会虚高。模型实际上记住了病例而不是学会了分割。正确做法是把文件名里的 case id 提取出来按病例分组再把整个 group 随机划分到 train/val/test。代码可以这样写import random from pathlib import Path def collect_pairs(root): pairs [] for cls in [normal, benign, malignant]: cls_dir Path(root) / cls for img in sorted(cls_dir.glob(*.png)): if mask in img.name: continue # 用文件名去掉后缀的方式配对掩码 mask img.with_name(img.stem _mask.png) if mask.exists(): pairs.append({image: img, mask: mask, case: img.stem.split(_)[0], cls: cls}) return pairs pairs collect_pairs(./BUSI) case_ids list({p[case] for p in pairs}) random.seed(42) random.shuffle(case_ids) train_cases set(case_ids[:int(len(case_ids) * 0.8)]) val_cases set(case_ids[int(len(case_ids) * 0.8):int(len(case_ids) * 0.9)]) test_cases set(case_ids[int(len(case_ids) * 0.9):]) train_pairs [p for p in pairs if p[case] in train_cases] val_pairs [p for p in pairs if p[case] in val_cases] test_pairs [p for p in pairs if p[case] in test_cases] print(train/val/test:, len(train_pairs), len(val_pairs), len(test_pairs))代码里img.stem.split(_)[0]是提取 case id 的常见做法。比如benign_1_2.png会得到benign_1。不同来源的 BUSI 命名可能略有差异建议打印前 20 个 case id 确认。划分后最好按类别再检查一次分布。良性样本比恶性样本多随机划分可能导致良性占满训练集恶性样本在测试集表现很差。此时要对 case id 做分层 shuffle而不是只对 pairs 做 shuffle。训练集、验证集、测试集一旦划分完毕整个实验周期不要再改动。固定random.seed(42)只是保证可复现并不解决所有问题。同一个 case 的视频帧或不同切面必须始终只出现在同一个集合里。验证时如果发现 Dice 高得离谱最先怀疑的应该是数据泄漏而不是模型能力强。这一章看起来只是在处理数据但医学图像分割项目里数据决定上限。超声乳腺分割的难点集中在边界不清和样本不均衡UNet 和 ResUNet 都是对这种场景比较友好的结构下一步就可以真正搭网络了。3. UNet与ResUNet的选型与实现把网络搭到能训练3.1 UNet 结构为什么跳跃连接对超声边界有用UNet 由编码器、解码器和跳跃连接三部分组成。编码器通过“卷积 池化”逐步缩小特征图尺寸并增加通道数解码器通过上采样恢复空间分辨率。如果没有跳跃连接解码器只能依赖高层的抽象特征浅层的边缘和细节会大量丢失。乳腺超声病变边界在灰度图上通常是几个像素宽的渐变带这些高频细节保留在浅层特征图中跳跃连接把它们直接拼到解码器同尺寸层让网络同时参考语义信息和边界线索。一个标准的卷积块定义如下import torch import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.block nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size3, padding1), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), nn.Conv2d(out_channels, out_channels, kernel_size3, padding1), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.block(x)这里两层 3x3 卷积都设置padding1特征图尺寸不变。BatchNorm 在 batch size 很小时统计不稳定如果训练时 batch size 只有 4 甚至更小建议把 BatchNorm 换成 GroupNorm。inplaceTrue能省一点显存但网络结构生成时通常会保持原计算图这个写法学起来不会有问题。UNet 的编码器通道数一般从 64 开始每次下采样通道翻倍变成 64、128、256、512。底部再做一个 1024 通道的瓶颈层。显存有限时不要硬上大 batch先把第一层通道数改成 32。切换解码器时输入通道数要记得加上跳跃连接传入的通道数否则拼接后维度对不上。这些都是 unet 代码里最常见的报错点。3.2 ResUNet 是把残差块塞进 UNet而不是照搬 ResNetResUNet 的争议点在于到底是“改进的 UNet”还是“UNet 化的 ResNet”。常见做法是在 UNet 每个卷积块内部加入残差连接整体结构仍然保持编码器、解码器、跳跃连接所以本质上是带残差的 UNet。它不等于把 ResNet18 的下采样部分搬过来因为 ResNet 没有对称的上采样解码器。一个残差块可以写得很简洁class ResBlock(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, 3, padding1) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, 3, padding1) self.bn2 nn.BatchNorm2d(out_channels) # 通道数不一致时用 1x1 卷积调整 self.shortcut nn.Identity() if in_channels out_channels else nn.Conv2d(in_channels, out_channels, 1) def forward(self, x): identity self.shortcut(x) h torch.relu(self.bn1(self.conv1(x))) h self.bn2(self.conv2(h)) return torch.relu(h identity)参数说明shortcut的作用是把输入张量对齐到输出通道数。输入输出通道相同时直接使用恒等映射不同时用 1x1 卷积改变通道数特征图尺寸不变。残差相加放在激活函数之前两个卷积先提取特征再和原始输入相加最后一起经过 ReLU这是残差网络里的标准写法。把 ResBlock 拼成 UNet 时每个 stage 内部可以连续放两个 ResBlock下采样继续使用 MaxPool。这样改动最小和标准 UNet 的编码器尺寸保持一致。如果一开始就加入注意力、空洞卷积等一堆改动出了问题很难定位。先用标准 UNet 跑通训练再把 ConvBlock 换成 ResBlock 做对比是更稳妥的验证路径。UNet 和 ResUNet 在 BUSI 上的取舍可以看下面这个表项目UNetResUNet编码器 block普通双卷积带残差的双卷积参数量少一些略多梯度传导常规路径存在跳线短路小样本收敛需要仔细调学习率通常稍微稳一点显存占用较低略高对 BUSI 这种规模的数据集两种结构都能用。ResUNet 的改进意义体现在训练稳定性上但不要指望 Dice 有质的飞跃。如果基础 UNet 已经能到 0.85ResUNet 可能只提升零点几个点。选择的关键是硬件条件和实验目标。3.3 训练参数和损失函数怎么定BUSI 的分割目标是病灶区域背景占比远大于前景直接使用 BCE 会让模型倾向输出全背景边界也很毛糙。一般做法是把 BCE 和 Dice Loss 组合在一起。Dice Loss 对前景和背景不平衡更稳健BCE 则提供更平滑的梯度信号。两者相加可以互补。一个常见的 dice_loss_from_logits 实现def dice_loss_from_logits(logits, target, smooth1.0): prob torch.sigmoid(logits) prob prob.reshape(prob.size(0), -1) target target.reshape(target.size(0), -1) # 计算每个样本的 Dice 再取平均 intersection (prob * target).sum(dim1) return 1.0 - (2.0 * intersection smooth) / (prob.sum(dim1) target.sum(dim1) smooth)说明smooth是平滑项防止分母为 0也避免了训练前期数值震荡。target必须是 0/1 的浮点型不能直接传入掩码文件读出的 255。手动实现 Dice Loss 时一定要展平后逐样本计算不要在 batch 层面直接累加否则不同样本的前景占比差异会互相干扰。调用时组合方式通常如下bce nn.functional.binary_cross_entropy_with_logits(logits, target) loss bce dice_loss_from_logits(logits, target)如果发现小病灶漏检严重可以把 Dice Loss 权重从 1.0 提高到 1.5。训练超参常见初始值是输入尺寸 256x256batch size 8AdamW 学习率 1e-4训练 100 个 epoch配合 ReduceLROnPlateau。显存不够时优先缩小输入尺寸或减小 batch而不是换一个更复杂的模块。unet 训练自己的数据集时这个组合能覆盖大部分二分类分割需求。4. 在BUSI上训练与验证从数据生成器到 Dice/IoU 曲线4.1 预处理和在线增强让有限样本发挥更大作用BUSI 的总样本量不大直接训练很容易过拟合。常见做法是在线增强随机水平翻转、垂直翻转、旋转、缩放。旋转时原图和掩码必须使用同一个变换矩阵不能分开处理。OpenCV 的仿射变换可以同时对两张图操作注意 ROI 外的填充值原图填 0 问题不大掩码填 0 表示背景不要填 255。预处理里最关键的是 resize 插值方式。原图下采样用INTER_AREA可以避免高频混叠掩码必须用INTER_NEAREST否则原本 0/255 的二值掩码会变成 0~255 之间的灰色值后续阈值化会产生多余边缘。归一化也要和推理端保持一致。比较简单的方案是把像素从 0~255 映射到 -1~1因为超声图像灰度集中在中间范围这样能让网络输入更平稳。这里给出一个完整的 Dataset 类import numpy as np import torch import cv2 class BUSIDataset(torch.utils.data.Dataset): def __init__(self, pairs, img_size256, trainFalse): self.pairs pairs self.img_size img_size self.train train def __len__(self): return len(self.pairs) def __getitem__(self, idx): image cv2.imread(str(self.pairs[idx][image]), cv2.IMREAD_GRAYSCALE) mask cv2.imread(str(self.pairs[idx][mask]), cv2.IMREAD_GRAYSCALE) # 统一尺寸掩码用最近邻保持二值 image cv2.resize(image, (self.img_size, self.img_size), interpolationcv2.INTER_AREA) mask cv2.resize(mask, (self.img_size, self.img_size), interpolationcv2.INTER_NEAREST) if self.train: if np.random.random() 0.5: image cv2.flip(image, 1) mask cv2.flip(mask, 1) if np.random.random() 0.5: image cv2.flip(image, 0) mask cv2.flip(mask, 0) image (image / 255.0 - 0.5) / 0.5 mask (mask 127).astype(np.float32) # 返回 [1,H,W] 的张量 return torch.from_numpy(image).float().unsqueeze(0), torch.from_numpy(mask).float().unsqueeze(0)代码说明unsqueeze(0)把二维灰度图变成[1, H, W]对应模型的单通道输入。掩码二值化是mask 127不管版本是 0/255 还是 0/1都能统一成 0/1。原图归一化后范围是大约 [-1, 1]如果训练时用 z-score推理端也必须用同一组均值和标准差不能改。增强强度不要过大。超声图本身存在声影和器官变形随机旋转超过 15 度可能让病灶相对位置失真。BUSI 训练里水平翻转和垂直翻转已经足够再加小角度旋转即可。复杂的弹性形变在小数据集上未必稳定建议先用简单增强跑一个基线再决定是否升级。4.2 训练自己的数据集核心训练循环与参数训练过程的关键不是把 epoch 跑完而是保存验证集 Dice 最高的权重。很多人只保存最后一个 epoch结果前面出现过更好的模型也丢掉了。下面是训练循环的核心片段def evaluate(model, loader, device): model.eval() dice_sum 0.0 count 0 with torch.no_grad(): for image, mask in loader: image, mask image.to(device), mask.to(device) logits model(image) # 注意这里 logits 没有经过 sigmoid prob torch.sigmoid(logits) pred (prob 0.5).float() intersection (pred * mask).sum() denom pred.sum() mask.sum() dice (2.0 * intersection 1.0) / (denom 1.0) dice_sum dice.item() * image.size(0) count image.size(0) return dice_sum / count model ResUNet(in_channels1, out_channels1).to(device) optimizer torch.optim.AdamW(model.parameters(), lr1e-4) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, modemax, factor0.5, patience10) best_dice 0.0 for epoch in range(100): model.train() for image, mask in train_loader: image, mask image.to(device), mask.to(device) logits model(image) loss bce dice_loss_from_logits(logits, mask) optimizer.zero_grad() loss.backward() optimizer.step() val_dice evaluate(model, val_loader, device) scheduler.step(val_dice) if val_dice best_dice: best_dice val_dice torch.save(model.state_dict(), best_resunet_busi.pth) print(fepoch {epoch} best dice {val_dice:.4f} saved)ReduceLROnPlateau的mode必须和监控指标对应监视 Dice 时用max监视 loss 时用min。很多新手在这里写反导致学习率迟迟不降低。保存模型时只保存state_dict()不要保存整个 model 对象。这样网页推理端可以自己定义模型结构然后加载权重不受训练代码中模块路径影响。DataLoader 可以设置num_workers2或4加速读取。如果增强逻辑里有随机数需要避免多个 worker 使用同一个随机种子导致数据重复。PyTorch 的 DataLoader 默认会对每个 worker 单独处理不需要额外操心。验证集不要开 shuffle数据顺序不会影响指标但可复现性更好。4.3 验证指标Dice、IoU、PA 分别说明什么二分类分割的验证指标主要看 Dice、IoU 和像素准确率 PA。像素准确率在超声分割里基本没意义因为背景占比太大模型全输出背景时 PA 也可能超过 90%。BUSI 上不同病例的病灶大小差别很大只报一个总的 Dice 会掩盖小病灶漏检问题。建议同时打印 Dice、IoU并按 normal、benign、malignant 三个类别分别统计。一个直接可用的指标函数def calculate_metrics(pred, mask): pred (pred 0.5).astype(np.uint8) mask (mask 0.5).astype(np.uint8) inter (pred mask).sum() union (pred | mask).sum() iou inter / union if union 0 else 1.0 dice 2.0 * inter / (pred.sum() mask.sum()) if (pred.sum() mask.sum()) 0 else 1.0 return dice, iou实际使用中Dice 会比 IoU 高一点因为 Dice 对交集更敏感。不要只盯着验证集 Dice还要随机挑几张测试集图片把预测掩码叠加到原图上人工看边缘。有的模型指标不低但边界多出几个像素临床和演示场景里这种误差很影响观感。如果预测结果出现大量散落的孤立噪声点可以在后处理阶段用连通域过滤掉面积小于阈值的区域。这个操作会提升指标但会掩盖模型本身的不足。写论文时如果要报告提升必须把后处理写明做网站演示时则无所谓用户体验优先。另一个常见误用是把验证集指标当成测试集指标如果前面按 case 划分没有做好这个数字会虚高。先切好数据再训练才能让指标有可信度。5. 网页版推理实现与避坑从 Flask 接口到 ONNX 加速5.1 为什么网页推理要单独做而不是直接让人跑 predict.py模型训练好之后如果只保留.pth文件使用门槛很高。没有 GPU 的人要配置 PyTorch还要读懂预测脚本里的参数才能把一张超声图跑出掩码。网页版推理的目标是让操作者上传图片就能看到结果。这个环节容易失败不是因为 Flask 难写而是训练和推理的预处理不一致。训练时用了多卡、混合精度、在线增强这些都不能直接搬进推理服务。网页推理和训练推荐分开维护两个入口。训练代码重在可迭代网页推理重在稳定。推理服务里不应该出现model.train()、反向传播、优化器只保留前向计算。常见做法是先写一个独立infer.py在命令行验证单张图片能输出掩码再把这个流程封装成 Flask 接口。这样即使网页出问题也能快速排除是模型还是接口的问题。5.2 Flask 推理接口模型加载、预处理、后处理Flask 是单机网页推理里最常见的选项代码量少部署简单。BUSI 项目多半是内部演示或毕设展示不需要高并发Flask 足够。如果后续要做多用户同时上传再换 FastAPI 或加队列也不迟。关键是把模型在模块加载阶段初始化不要在每次请求时重新load_state_dict。from flask import Flask, request, Response import torch import cv2 import numpy as np app Flask(__name__) device cuda if torch.cuda.is_available() else cpu # 模型类必须在这个文件里可导入 from model import ResUNet model ResUNet(1, 1) state torch.load(best_resunet_busi.pth, map_locationdevice) model.load_state_dict(state) model.to(device).eval() def preprocess(raw_bytes): arr np.frombuffer(raw_bytes, np.uint8) img cv2.imdecode(arr, cv2.IMREAD_GRAYSCALE) # 统一 resize 到训练尺寸推理端不能改 img cv2.resize(img, (256, 256), interpolationcv2.INTER_AREA) img (img / 255.0 - 0.5) / 0.5 x torch.from_numpy(img.astype(np.float32)).unsqueeze(0).unsqueeze(0) return x app.post(/predict) def predict(): file request.files[image] raw file.read() x preprocess(raw).to(device) with torch.no_grad(): logits model(x) mask torch.sigmoid(logits[0, 0]).cpu().numpy() # 二值化并转成 uint8否则 PNG 编码会失败 mask ((mask 0.5).astype(np.uint8)) * 255 ok, encoded cv2.imencode(.png, mask) if not ok: return encode failed, 500 return Response(encoded.tobytes(), mimetypeimage/png) if __name__ __main__: app.run(host0.0.0.0, port8000, threadedFalse)逻辑说明torch.load(..., map_locationdevice)支持原来在 GPU 上训练的权重加载到 CPU 机器。preprocess返回的 shape 是[1, 1, 256, 256]固定 batch size 为 1。输出 mask 在二值化后乘 255再用imencode编码成 PNG。前端只需要一个img标签把请求发到/predict就能显示结果。这个接口有三个容易忽略的地方。第一resize 尺寸必须和训练一致否则模型输入分布改变掩码会变差。第二上传图片可能是 BGR 或 RGBA直接用IMREAD_GRAYSCALE可以规避通道问题。第三threadedFalse避免多线程下 PyTorch 推理互相抢占资源如果并发量上来再单独处理。提示后端响应最好加上Cache-Control: no-store否则浏览器可能缓存上一张图的掩码换图后显示的还是旧结果。5.3 模型导出到 ONNX以及三个常见问题如果部署机器不想安装完整 PyTorch可以把模型导出成 ONNX再用 onnxruntime 推理。导出时要先用model.eval()切到推理模式固定一个输入尺寸导出避免动态 shape 带来额外复杂度。训练时输入是 256x256导出也保持这个尺寸。导出代码可以这样写# 导出 ONNX 模型 model.eval() dummy_input torch.randn(1, 1, 256, 256) torch.onnx.export( model, dummy_input, resunet_busi.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}}, opset_version13 )dynamic_axes只让 batch 维可变宽高仍然固定。这样加载和推理都简单。ONNX Runtime 的推理接口如下import onnxruntime as ort import numpy as np session ort.InferenceSession(resunet_busi.onnx, providers[CPUExecutionProvider]) def infer_onnx(x): x x.numpy() output session.run(None, {input: x})[0] # 重新计算 sigmoid把 logits 转成概率 mask 1.0 / (1.0 np.exp(-output)) return mask[0, 0]常见问题一torch.load报错提示找不到模块。原因是训练时保存了完整模型对象类文件路径变了。解决方法是只用state_dict保存网页端先实例化模型再load_state_dict。常见问题二导出 ONNX 后输出全是 0 或 1。原因是模型没有切到 eval 模式BN 层在训练状态下使用了 mini-batch 统计。解决方法是导出前调用model.eval()。常见问题三网页端处理大图很慢。原因是把原图直接 resize 到 512 甚至更大。BUSI 原图虽大但分割任务对高分辨率不敏感256 输入已经能拿到不错的效果。网页端叠加显示可以交给后端做把原图和 mask 用cv2.addWeighted合成在一起再返回给前端。合成前要先记录原图尺寸把 256x256 的 mask resize 回原图大小否则叠加会错位。浏览器端不需要额外开 canvas减轻了前端开发负担。真正做产品上线时再考虑把预处理和后处理移到 GPU 上但 BUSI 项目这个规模CPU 推理完全够用。6. 给网页推理做一次完整自检单图、批量、异常图一个都不能少6.1 一个独立于训练代码的自检脚本训练代码和网页推理代码是两套逻辑很容易出现归一化参数不一致。我每次部署前都会写一个selfcheck.py不通过 Flask直接用同一套预处理函数读取测试集里的五张原图把模型输出保存到outputs/目录。如果这批输出和训练时验证批次里看到的视觉效果一致再启动 Flask。这个习惯能快速把模型加载、预处理、后处理分成三段排查。自检脚本不需要太复杂只要能稳定复现以下动作加载权重、读取单张图、resize、归一化、前向计算、转 mask、保存可视化结果。我通常还会把原图和 mask 用np.hstack拼成一张图一眼就能看出边缘是否对齐。这样即使后面改了训练尺寸也能通过自检脚本发现网页端还留在旧尺寸。6.2 给网页推理加一组“异常图”回归用例有一次我被一张只有背景的超声图坑了。模型输出了一个全黑掩码这本身没问题但前端把黑图转成 PNG 后浏览器缓存了第二次换图结果还是黑的。排查下来是 HTTP 缓存问题解决方法是给响应加Cache-Control: no-store。如果提前准备一组回归用例包括全黑输入、全白输入、纯噪声图、正常病灶图这种问题能在几秒钟内暴露。我把这些测试用例维护成一个固定列表每次换权重或改输入尺寸都先跑一遍跑完才敢把网页服务交给别人。网页推理项目真正的难点不在模型精度而在输入分布变化和缓存这些细节。希望上面的实现和踩坑记录能帮你少走一段弯路也希望你在部署时保留这个自检习惯希望帮到你。本文还有配套的精品资源点击获取
返回列表