
简介一套面向舌头图像语义分割的PyTorch改进项目以UNet为基线并引入Res2Net模块增强多尺度特征提取适合医学影像分割方向的课程设计、毕业选题或算法复现。压缩包共610个文件含300张JPG舌头原图、300张PNG掩码标签4个Python训练与预测脚本以及3个pyc缓存文件、txt参数配置、docx项目说明书和md说明文档整体约7.37MB。项目说明书对代码实现与Res2Net改进原理做了完整讲解训练流程覆盖数据预处理、数据增强、模型训练与指标评估支持自定义数据集和多类别分割任务可灵活调整输入尺寸、通道数及类别数并自动保存最优权重与训练曲线同时内置标准数据组织方式和命令行接口修改路径与标签映射即可迁移到其他医学或遥感分割场景。已有59人学习适合需要从零搭建分割实验并快速复现改进效果的读者。1. 舌头语义分割为什么选U-NetRes2Net先看数据再定网络舌头图像分割是中医舌诊辅助分析里最前置的一步舌体边界不规则、舌苔与舌质纹理差异大、不同光源下色彩偏差明显这些特点决定了网络不能只拿单尺度感受野去硬扛。基础U-Net的编码器用连续3x3卷积堆叠多尺度上下文要等逐层下采样才能获得而Res2Net在单个卷积块内部做通道分组与层级残差让同一层同时出现多个尺度的特征响应。这个项目把Res2Net模块替换进U-Net编码器配套舌头图像数据集、完整PyTorch训练代码和项目说明书。适合两类人想快速跑通医学图像分割全流程的工程师以及准备动手改U-Net但不知道从哪个模块切入的研究者。2. 数据集组织与预处理images/masks目录、标签映射与增强2.1 目录结构与文件名对应检查项目数据集规范是images和masks子目录平级存放图像和掩码靠主文件名对应例如images/236.jpg对应masks/236.png。后缀不强求一致jpg、png都支持但主文件名必须完全相同。训练前先做一次文件名一致性检查避免掩码缺失导致训练集样本数缩水却毫无察觉ls images | sed s/\.[^.]*$// | sort /tmp/img_list ls masks | sed s/\.[^.]*$// | sort /tmp/mask_list comm -3 /tmp/img_list /tmp/mask_listcomm -3只输出在其中一个列表里出现的文件名正常情况下没有任何输出如果有输出需要补齐掩码或把对应图像移出数据集。这一步在项目说明书的数据章节被特意强调实际训练中因为文件缺失引发的loss异常比网络结构问题更隐蔽。2.2 掩码读取与标签映射规则标注工具导出的掩码经常不是从0开始的连续整数常见情况是背景0、舌体边缘128、舌体255。直接把这种掩码扔进CrossEntropyLoss类别数会被误判为256训练极不稳定。数据的加载和重映射是在Dataset里完成的class TongueDataset(Dataset): def __init__(self, data_dir, splittrain, label_mappingNone): image_dir os.path.join(data_dir, split, images) mask_dir os.path.join(data_dir, split, masks) self.image_paths sorted(glob.glob(os.path.join(image_dir, *.jpg)) glob.glob(os.path.join(image_dir, *.png))) self.mask_dir mask_dir self.label_mapping label_mapping or {} def __getitem__(self, idx): img_path self.image_paths[idx] stem os.path.splitext(os.path.basename(img_path))[0] mask_path os.path.join(self.mask_dir, stem .png) image cv2.imread(img_path)[..., ::-1] # BGR转RGB mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # 强制单通道 # 重映射为连续标签 mapped np.zeros_like(mask, dtypenp.int64) for new_id, old_val in enumerate(sorted(self.label_mapping.values())): mapped[mask old_val] new_id return torch.from_numpy(image).permute(2, 0, 1).float(), \ torch.from_numpy(mapped).long()cv2.imread默认按BGR读入训练前要转成RGB否则网络学到的颜色分布是错的。掩码用IMREAD_GRAYSCALE强制单通道避免三通道掩码在计算损失时报形状错误。重映射规则用enumerate(sorted(...))按原始像素值升序分配新标签顺序稳定不会因为字典插入顺序变化导致类别错位。原始像素值语义含义重映射后标签0背景0128舌体边缘1255舌体2提示如果掩码本身就是0、1、2这样的连续值可以不传--label_mapping代码会按原值直接使用。2.3 数据增强与训练/验证划分舌头图像数据量不大增强是提升泛化最直接的手段。项目集成了水平翻转、随机旋转、随机缩放和亮度对比度扰动。这里的关键点是图像和掩码必须使用同一组随机参数不能分别独立增强否则标签和内容错位def aug(image, mask): seed np.random.randint(0, 1 31) for fn in [flip, rotate, scale, brightness]: np.random.seed(seed) random.seed(seed) image, mask fn(image, mask) return image, mask固定同一个seed再依次执行每个变换保证image和mask经历完全相同的几何操作。划分训练/验证集建议按8:2随机切分并固定随机种子保证每次实验划分一致。切分前先统计各类别像素占比如果舌体区域占比超过80%说明掩码的背景和前景可能标反了这类数据训练出来的Dice虚高但可视化结果完全不可用。3. Res2Net模块嵌入U-Net编码器原理、替换策略与代码3.1 Res2Net的层级残差到底改了什么Res2Net的核心改动是把一个卷积块内部的3x3卷积替换成一组小卷积输入特征沿通道维度分成scale份常用scale4第一份直接透传其余每份先与上一份的输出相加再做3x3卷积。这样同一个block内就形成了不同的等效感受野组合第一份感受野最小最后一份最大。对舌头分割而言舌苔裂纹是细粒度纹理舌体轮廓是粗粒度结构Res2Net在不加深网络的前提下同时捕捉这两种尺度训练成本远低于堆叠两倍卷积层。它和DeepLabV3里的ASPP是两种思路ASPP是并行拉大感受野靠多个膨胀率卷积拼接Res2Net是串行构造依赖链每一份的输出都包含前一份的信息特征更连续。实际对比下来Res2Net在舌体这种轮廓光滑但内部纹理丰富的目标上边界定位比ASPP更稳因为它没有膨胀卷积带来的网格伪影问题。3.2 编码器替换策略与跳跃连接把Res2Net嵌入U-Net的常见做法有两种。第一种是把编码器每一层的标准卷积块整体替换成Res2NetBlock第二种是只替换下采样前的最后一个卷积块保留浅层标准卷积以保证空间细节不丢失。项目采用第一种但保留原U-Net的通道规划64、128、256、512这样解码器的跳跃连接不需要任何改动class Res2NetUNet(nn.Module): def __init__(self, in_ch3, num_classes3, base_ch64, scale4): super().__init__() self.enc1 nn.Sequential( nn.Conv2d(in_ch, base_ch, 3, padding1), nn.BatchNorm2d(base_ch), nn.ReLU(inplaceTrue), Res2NetBlock(base_ch, base_ch, scalescale) ) self.pool1 nn.MaxPool2d(2) self.enc2 nn.Sequential( nn.Conv2d(base_ch, base_ch * 2, 3, padding1), nn.BatchNorm2d(base_ch * 2), nn.ReLU(inplaceTrue), Res2NetBlock(base_ch * 2, base_ch * 2, scalescale) ) # enc3、enc4同理通道数翻倍 self.up1 nn.ConvTranspose2d(base_ch * 8, base_ch * 4, 2, stride2) # 解码器每次上采样后与对应编码器输出在通道维拼接跳跃连接要求编码器和解码器在拼接时通道数对齐。这里只替换块内结构、不改通道数所以torch.cat([up_feat, enc_feat], dim1)能直接对上。如果自己动手把编码器某个阶段的通道数改掉解码器必须同步调整这是最常见的一处改崩。3.3 Res2NetBlock实现与陷阱class Res2NetBlock(nn.Module): def __init__(self, in_ch, out_ch, scale4): super().__init__() width out_ch // scale self.scale scale self.width width self.conv1 nn.Conv2d(in_ch, out_ch, 1) # 先降维整理通道 self.bn1 nn.BatchNorm2d(out_ch) self.sub_convs nn.ModuleList() for _ in range(scale - 1): self.sub_convs.append(nn.Sequential( nn.Conv2d(width, width, 3, padding1), nn.BatchNorm2d(width), nn.ReLU(inplaceTrue) )) self.conv2 nn.Conv2d(out_ch, out_ch, 1) self.bn2 nn.BatchNorm2d(out_ch) self.relu nn.ReLU(inplaceTrue) self.shortcut nn.Conv2d(in_ch, out_ch, 1) if in_ch ! out_ch else nn.Identity() def forward(self, x): identity self.shortcut(x) x self.relu(self.bn1(self.conv1(x))) xs torch.split(x, self.width, dim1) # 按通道切成scale份 ys [xs[0]] # 第一份直接透传 for i in range(1, self.scale): inp xs[i] ys[i - 1] if i 1 else xs[i] # 依赖链 ys.append(self.sub_convs[i - 1](inp)) out torch.cat(ys, dim1) out self.relu(self.bn2(self.conv2(out))) return self.relu(out identity)几个必须注意的细节。torch.split按通道维切分输入是NCHW布局时dim1换到NHWC布局必须改scale必须能被out_ch整除base_ch64配scale4或8都没问题但瓶颈层如果设了out_ch32就不能再用scale8。第一份直接透传是Res2Net的刻意设计如果每份都独立卷积就退化成普通分组卷积多尺度依赖链就断了。从参数量角度看编码器模块权重规模64→64通道等效感受野链标准双3x3卷积约7.4万固定5x5Res2NetBlock scale4约1.5万1x1、3x3、5x5、7x7混合提示Res2NetBlock内部包含BatchNorm训练时不要设置eval()模式以外的状态切换BN的running_mean和running_var在每轮更新后才会稳定。4. 训练参数配置与IoU/Dice评估从命令行到权重保存4.1 命令行参数与启动方式项目训练入口是train.py全部配置走命令行参数不依赖额外配置文件。这样跑实验时可以直接对比不同学习率、不同标签映射不需要改代码python train.py \ --data_dir ./tongue_dataset \ --num_classes 3 \ --label_mapping {bg: 0, edge: 128, tongue: 255} \ --learning_rate 1e-4 \ --batch_size 8 \ --epochs 100 \ --input_size 256 \ --scale 4参数对照如下参数建议值作用与注意事项--data_dir数据集根目录内部按train/val的images/masks组织--num_classes3含背景必须等于label_mapping的长度--label_mappingJSON字符串原始像素值与语义的映射--learning_rate1e-4小数据集从1e-4起步震荡就降--batch_size8由显存决定但不要设1--input_size256必须能被2的编码器深度次方整除--scale4Res2Net分组数4或8注意input_size设250这类非对齐尺寸时经过4次最大池化后特征图边长不是整数会在最后一层直接报维度不匹配这种错误只看traceback不容易定位到是尺寸问题。4.2 损失函数与IoU/Dice计算项目默认用交叉熵多分类下配合nn.CrossEntropyLoss即可该损失内部对logits做softmax模型最后一层不需要手动接。如果舌头区域在整图中的像素占比很低交叉熵容易被背景主导这时可以换成Dice loss或与交叉熵按0.5:0.5加权def dice_loss(pred, target, smooth1.0): pred torch.softmax(pred, dim1) n, c, h, w pred.shape target_onehot torch.zeros_like(pred).scatter_(1, target.unsqueeze(1), 1) intersection (pred * target_onehot).sum(dim(2, 3)) union pred.sum(dim(2, 3)) target_onehot.sum(dim(2, 3)) return 1 - ((2 * intersection smooth) / (union smooth)).mean()scatter_把target从[N,H,W]转成[N,C,H,W]的one-hot编码。注意num_classes传大了会让one-hot里出现全零通道Dice分母偏小、指标虚高传小了scatter_直接越界报错。训练中每隔若干个epoch在验证集上计算IoU和Dicedef compute_metrics(pred, target, num_classes): pred pred.argmax(dim1) ious, dices [], [] for c in range(num_classes): p, t (pred c), (target c) inter (p t).sum().item() union (p | t).sum().item() ious.append(inter / (union 1e-6)) dices.append(2 * inter / (p.sum().item() t.sum().item() 1e-6)) return np.mean(ious), np.mean(dices)IoU分母取并集Dice分母取两侧面积和两者对同一组预测的敏感度不同IoU对漏检更敏感Dice对边界毛刺更宽容。报告指标时两个都打印只看Dice容易忽略边界过分割的问题。4.3 训练曲线、最优权重保存与预测可视化训练脚本实时记录每个epoch的loss和验证指标结束后输出loss曲线与IoU/Dice曲线两张图同时按验证集Dice最优保存best_model.pth而不是训练结束才保存。这样即使后20个epoch过拟合也不会覆盖最优权重。保存时机用Dice而不是loss判断因为loss下降不代表分割边界更准。训练中期建议做一次预测可视化对比原图、真实掩码和模型输出model.eval() with torch.no_grad(): out model(img.unsqueeze(0)) pred out.argmax(dim1).squeeze(0).cpu().numpy() plt.imsave(pred_236.png, pred, cmaptab20)cmaptab20在类别数少于20时会给每个类别分配不同的离散颜色比灰度图更容易看出类别是否粘连、边界是否糊成一片。这一步看似简单但对判断模型是「边界糊」还是「类别混」非常有效两种问题的调优方向完全相反。5. U-NetRes2Net排错清单与分割效果调优技巧5.1 训练前必做的三项检查第一项确认掩码是真正的单通道标签图。用cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)读入后打印np.unique(mask)如果值域是0到255的密集分布说明掩码是抗锯齿灰度图而不是标签图必须先做阈值化处理否则交叉熵会把每个灰度值当一类。第二项核对label_mapping里的原始值与掩码的实际值一致。标注工具经常把前景标成255代码里映射却写成1结果前景类别完全没有监督信号Dice看起来不低但预测图是空的。第三项batch_size别设成1。BatchNorm在batch_size1时统计量抖动严重分割输出会出现棋盘格状伪影显存只够放一张时把BN换成GroupNorm比强行训练更稳。5.2 三个实用调优方向如果验证集Dice超过90%但可视化边界不干净优先怀疑数据增强参数。舌体近似椭圆随机旋转超过15度会在边缘产生伪影改成5度和10度各跑一遍对比。如果舌尖裂纹这种细粒度结构分不出来把scale从4调到8分支感受野链条更长细节响应更强代价是参数量下降、收敛变慢可以适当把学习率上调一点或延长epoch。如果训练曲线震荡明显检查学习率是否超过5e-4这种规模的数据集上Adam配1e-4通常已经偏激进降到3e-5观察两个epoch再判断。最后是一个验证改进是否有效的严谨习惯固定数据划分、固定随机种子、固定epoch数标准U-Net和Res2Net-U-Net各跑3次对比IoU和Dice的均值与标准差。只用单次训练的结果判断改进是否有效很容易被初始化和随机增强的运气成分误导3次取均值是成本最低的可靠做法。本文还有配套的精品资源点击获取