简介这是一份面向中医舌诊研究与医学图像分割学习者的完整实战资源聚焦舌象图像分割任务集成Unet深度监督网络。资源内含预处理后的舌象数据集与对应标签、训练好的Unet分割模型以及覆盖训练、测试和图形化界面的Python代码还附带实际操作视频按视频创建基本环境即可运行出一套完整的分割系统。舌象分割是中医舌诊智能化的关键步骤将舌面划分为不同区域并与脏腑、病理变化对应这套材料直接提供可复现的分割系统能帮助绕开数据清洗与模型调参的繁琐环节。压缩包约322.64MB主要包含数据集、代码文件、模型权重和mp4教学视频等类型目录组织清晰便于对照学习。目前已有5000余人学习下载适合希望快速搭建舌象分割实验、理解Unet嵌套密集跳跃连接结构或直接复现系统的研究者与开发者使用。1. 舌象分割为什么值得用 Unet先说结论再拆资源舌象分析在中医数字化和智能问诊里一直是刚需但舌体分割这个前置步骤卡住了很多人。常规分割网络在舌象这种边缘模糊、色彩渐变、光照不均的图像上很容易把舌苔误判成背景或者把嘴唇和舌头糊在一起。Unet 在医学图像分割领域相对更稳它的密集跳跃连接能让解码器在不同尺度上反复看到编码器特征对小目标、弱边界的器官分割效果明显好于原始 U-Net。这份资源把数据集、训练代码、训练好的模型权重、可视化界面和教学视频打包在一起适合刚入门医学图像分割的研究生也适合想快速验证舌象分割效果的算法工程师。本文会按数据组织、网络结构、训练细节、界面使用和排错经验逐层拆解让你拿到手就能跑通而不是只多了一个吃灰的压缩包。2. 舌象数据集拆包目录结构、标注格式与预处理陷阱2.1 压缩包内的数据组织方式解压后首先面对的是数据目录。常见的组织方式有两种一种是images和masks平级分两个文件夹另一种是按病例分文件夹每个病例下放原图和标注。这份资源采用的是第一种即dataset/images存放舌象原图dataset/masks存放分割标签。dataset/ ├── images/ │ ├── 001.jpg │ ├── 002.jpg │ └── ... ├── masks/ │ ├── 001.png │ ├── 002.png │ └── ... ├── train.txt ├── val.txt └── test.txttrain.txt、val.txt、test.txt每行存一个不带扩展名的文件名训练脚本通过读取这些 txt 完成数据划分。这种组织方式的好处是很直观换数据集时只需要重新生成三个 txt 文件训练代码不需要改。标注文件是 PNG 格式的单通道掩码像素值为 0 和 255其中 255 表示舌体区域0 表示背景。需要注意有些公开数据集习惯用 0 和 1 表示类别如果后续自己扩展数据务必统一成 0/255否则在数据增强的归一化环节会出现标签值被拉伸的问题。2.2 标注质量检查和常见脏数据拿到 mask 之后不要急着训练先做一轮快速质量检查。用 Python 脚本统计每张 mask 中 255 像素的占比一方面能筛掉空白标注或全图标注的脏数据另一方面能看出数据集中大舌和小舌的分布对后续损失函数的设计有参考价值。from PIL import Image import numpy as np import os mask_dir dataset/masks for fname in sorted(os.listdir(mask_dir)): mask np.array(Image.open(os.path.join(mask_dir, fname))) ratio (mask 255).sum() / mask.size if ratio 0.05 or ratio 0.95: print(f{fname}: ratio{ratio:.4f}, warning)这段代码遍历所有 mask计算舌体像素占比。ratio 0.05说明这张图可能只有少量舌体甚至标注缺失ratio 0.95则可能是整张图都被标成了前景这两种情况都建议人工确认。我一般会给这两类数据单独建一个文件夹等模型第一轮训练结束、对数据分布有感觉了再回头决定是清洗还是保留。舌象数据还有一个天然问题不同采集设备拍出来的舌头颜色差异很大手机前置摄像头、单反、舌诊仪的色彩空间都不同所以不要盲目做很强的颜色增强否则模型会学到设备的色偏特征而不是舌体本身的形状特征。2.3 数据划分策略按患者划分而非按图片划分如果数据集里同一患者有多张舌象图划分训练集和验证集时一定要按患者划分不能按图片划分。否则同一个人的多张高度相似的舌象图会同时出现在训练集和验证集里验证集的指标会虚高实际部署时效果会打折扣。import random patient_ids list(range(1, 86)) random.seed(42) train_patients random.sample(patient_ids, 68) val_patients [p for p in patient_ids if p not in train_patients] with open(dataset/train.txt, w) as f: for img in sorted(os.listdir(dataset/images)): pid int(img.split(_)[0]) if pid in train_patients: f.write(img.split(.)[0] \n)这里假设文件名格式为患者ID_序号.jpg按患者 ID 取模划分。写完train.txt后用同样的逻辑生成val.txt和test.txt。需要注意划分时用了random.seed(42)固定随机种子这保证每次运行脚本生成的划分是一致的也方便不同模型在同一数据划分下做公平对比。3. Unet 网络结构与模型实现密集跳跃连接和深监督的落地细节3.1 为什么选 Unet 而不是原始 U-Net 或 DeepLabV3舌象分割的难点在于边界不清晰舌根部位和咽喉背景的过渡区域占整张图的比例不小。原始 U-Net 的跳跃连接只在编码器和解码器的同尺度特征之间做一次拼接如果编码器下采样过深浅层细节信息传递到深层解码器时会衰减。DeepLabV3 的 ASPP 模块擅长多尺度语义特征提取但对边界细节的保持不如 Unet。Unet 的核心改动是把跳跃连接变成密集的嵌套结构每个解码器节点不仅接收来自对应编码器层的特征还接收来自前面所有解码器节点的输出。这样解码器在不同深度上都能获取多尺度的特征对小目标和边缘模糊区域的表达能力更强。实际经验是Unet 在舌象分割上的 IoU 通常比原始 U-Net 高 2 到 4 个百分点特别是在舌苔厚薄不一、舌体边缘颜色与嘴唇接近的图片上优势明显。Unet 另一个实用特性是深监督deep supervision。在网络的不同深度上同时计算损失并回传梯度训练时深监督会帮助浅层网络更快收敛。推理阶段可以剪掉深监督的辅助输出路径只保留全分辨率的分割输出这样做不会损失精度还能减少推理时间。3.2 模型定义关键代码逐行拆解Unet 的 PyTorch 实现网上版本很多但组件细节直接影响收敛效果。核心代码涉及两个组件VGGBlock负责卷积 BN ReLUNestUnet负责组装节点和跳跃路径。import torch import torch.nn as nn class VGGBlock(nn.Module): def __init__(self, in_channels, middle_channels, out_channels): super().__init__() self.conv1 nn.Conv2d(in_channels, middle_channels, 3, padding1) self.bn1 nn.BatchNorm2d(middle_channels) self.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv2d(middle_channels, out_channels, 3, padding1) self.bn2 nn.BatchNorm2d(out_channels) def forward(self, x): out self.relu(self.bn1(self.conv1(x))) out self.relu(self.bn2(self.conv2(out))) return outpadding1保证特征图尺寸在卷积前后不变配合后续的最大池化完成下采样。网络输入尺寸不固定只要长宽是 2 的整数次幂即可。密集跳跃连接的核心在NestUnet的前向传播关键是使用列表存取每一层的中间特征维度对齐是最大的坑x0_0 self.conv0_0(input) x1_0 self.conv1_0(self.pool(x0_0)) x0_1 self.conv0_1(torch.cat([x0_0, x1_0], 1)) x2_0 self.conv2_0(self.pool(x1_0)) x1_1 self.conv1_1(torch.cat([x1_0, x2_0], 1)) x0_2 self.conv0_2(torch.cat([x0_0, x1_1, x2_0], 1)) x3_0 self.conv3_0(self.pool(x2_0)) x2_1 self.conv2_1(torch.cat([x2_0, x3_0], 1)) x1_2 self.conv1_2(torch.cat([x1_0, x2_1, x3_0], 1)) x0_3 self.conv0_3(torch.cat([x0_0, x1_2, x2_1, x3_0], 1)) x4_0 self.conv4_0(self.pool(x3_0)) x3_1 self.conv3_1(torch.cat([x3_0, x4_0], 1)) x2_2 self.conv2_2(torch.cat([x2_0, x3_1, x4_0], 1)) x1_3 self.conv1_3(torch.cat([x1_0, x2_2, x3_1, x4_0], 1)) x0_4 self.conv0_4(torch.cat([x0_0, x1_3, x2_2, x3_1, x4_0], 1))x0_1是第一层第二个节点它拼接了x0_0第一层第一个节点和x1_0第二层第一个节点两条路径的特征尺度相同。x0_2拼接了x0_0、x1_1、x2_0三个节点其中x1_1已经融合过x1_0和x2_0的信息所以x0_2的语义信息比x0_1更丰富。每一层的节点数量从左到右递增形成类似全连接的结构这也是 Unet 参数比 U-Net 多的主要原因。3.3 深监督的实现方式与推理裁剪深监督不是必须在训练时使用的选项。它的做法是在不同深度的输出节点上各接一个 1x1 卷积把通道数压缩到 1然后分别计算损失。代码里在forward的return处返回多个尺度的输出def forward(self, x): # ... 上文各层计算 ... if self.deep_supervision: out1 self.final1(x0_1) out2 self.final2(x0_2) out3 self.final3(x0_3) out4 self.final4(x0_4) return [out1, out2, out3, out4] else: out4 self.final4(x0_4) return out4训练时把四个输出都接损失函数损失相加后反向传播。推理阶段设置deep_supervisionFalse只输出x0_4全分辨率结果。这里有个经验训练后期可以逐渐关掉浅层深监督只保留最深两层的损失防止浅层输出对最终结果产生过度约束。这个操作需要在训练循环里动态调整损失权重资源自带代码中体现为深监督损失权重衰减的逻辑。3.4 损失函数和评价指标Dice 和 IoU 怎么配合舌象分割最直接的指标是 IoU交并比但训练时 IoU Loss 的梯度在某些情况下不稳定特别是模型初始预测和真实标注完全没有交集时。所以常见的做法是使用 Dice Loss 和 BCE Loss 的加权组合class DiceBCELoss(nn.Module): def __init__(self, weight_bce0.5, weight_dice0.5): super().__init__() self.weight_bce weight_bce self.weight_dice weight_dice self.bce nn.BCEWithLogitsLoss() def forward(self, logits, targets): probs torch.sigmoid(logits) smooth 1.0 intersection (probs * targets).sum() dice 1 - (2.0 * intersection smooth) / (probs.sum() targets.sum() smooth) bce self.bce(logits, targets) return self.weight_bce * bce self.weight_dice * dicesmooth参数是为了防止分母为零在训练初期目标区域极小的时候作用明显。权重weight_bce设为 0.5 适合前景背景比较均衡的情况。如果数据集中舌体占比普遍低于 10%可以把weight_dice调到 0.7 甚至 0.8让模型更关注区域重合度而不是像素级分类准确性。损失函数的计算是在整张图上做的没有做类别均衡的权重设置因为舌象分割本质上是单类分割背景占多数但不需要对所有背景像素加权惩罚Dice Loss 本身已经隐式处理了类别不平衡的问题。4. 从零到收敛训练全流程、参数配置与模型推理4.1 数据增强策略哪些增强对舌象有效哪些会帮倒忙舌象数据和自然图像数据增强的取向差别很大。旋转、翻转、缩放这类几何增强对舌象有效因为舌头在口腔内的姿态变化本身就存在旋转和平移。但颜色增强需要非常克制舌色是中医辨证的重要依据如果把色调随机偏移太多模型会把不同舌色的特征混在一起导致分割结果对颜色过度敏感。推荐的增强组合放在 albumentations 里实现比较方便import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform A.Compose([ A.RandomRotate90(p0.5), A.HorizontalFlip(p0.5), A.VerticalFlip(p0.2), A.RandomResizedCrop(size(256, 256), scale(0.8, 1.0), ratio(0.9, 1.1)), A.RandomBrightnessContrast(brightness_limit0.1, contrast_limit0.1, p0.3), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ToTensorV2(), ]) val_transform A.Compose([ A.Resize(256, 256), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ToTensorV2(), ])RandomResizedCrop的scale下限设为 0.8防止舌体被过度裁切。RandomBrightnessContrast的幅度控制在 0.1 以内只做轻微光照补偿不改变舌色分布。mean和std用的是 ImageNet 统计值如果数据集的整体色调偏移很大建议重新统计数据集的均值和标准差这个对收敛速度的影响比很多人以为的要大。4.2 训练超参数学习率、batch size 与优化器的配合训练配置直接决定了模型能不能收敛。实际测试中AdamW 配合 OneCycleLR 的收敛速度和最终精度要好于固定学习率的 Adam。基础配置如下import torch.optim as optim from torch.optim.lr_scheduler import OneCycleLR model NestUnet(num_classes1, deep_supervisionTrue) optimizer optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-5) total_steps len(train_loader) * epochs scheduler OneCycleLR( optimizer, max_lr5e-4, total_stepstotal_steps, pct_start0.3, anneal_strategycos )max_lr5e-4配合pct_start0.3前 30% 的步数线性升到最大学习率之后余弦退火。这套配置在单卡 8GB 显存上可以跑 batch size 8。如果显存只有 4GBbatch size 降到 4同时把max_lr降到 3e-4防止小 batch 下学习率过大导致震荡。训练过程中的损失记录建议同时输出每个深监督分支的损失和最终主损失这样能看出浅层分支和深层分支是否都在正常收敛。4.3 训练脚本主循环的完整逻辑for epoch in range(start_epoch, epochs): model.train() train_loss 0.0 for images, masks in train_loader: images images.to(device) masks masks.to(device) outputs model(images) if isinstance(outputs, list): loss 0.0 for idx, out in enumerate(outputs): weights [0.1, 0.2, 0.3, 1.0] loss weights[idx] * criterion(out, masks) else: loss criterion(outputs, masks) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() scheduler.step() train_loss loss.item() * images.size(0)梯度裁剪的max_norm5.0是多次实验后的经验值。太小的裁剪会改变梯度的自然方向让模型收敛变慢太大则起不到防止梯度爆炸的作用。深监督的四个输出权重[0.1, 0.2, 0.3, 1.0]中主输出x0_4权重最大浅层输出权重递减这符合由深到浅信息量递减的直觉。训练时每隔几个 epoch 在验证集上算一次 IoU保存验证集 IoU 最高的模型权重。判断是否保存的指标建议用 IoU而不是只用验证损失因为损失下降不一定代表分割质量提升特别是边界区域。4.4 推理和后处理从模型输出到可视化掩码推理时去掉深监督输入原图先做与验证一致的预处理输出经过 sigmoid 后二值化再做后处理。def predict(model, image, device): model.eval() with torch.no_grad(): transform A.Compose([ A.Resize(256, 256), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ToTensorV2(), ]) input_tensor transform(imageimage)[image].unsqueeze(0).to(device) output model(input_tensor) prob torch.sigmoid(output).cpu().numpy().squeeze() mask (prob 0.5).astype(np.uint8) * 255 mask cv2.resize(mask, (image.shape[1], image.shape[0]), interpolationcv2.INTER_NEAREST) return mask二值化阈值固定为 0.5 在大多数场景下够用。如果后续要提取舌体轮廓做进一步分析可以考虑用大津法在概率图上计算自适应阈值因为部分图片中舌根区域的预测概率确实偏低固定 0.5 可能导致漏检。模型输入尺寸是 256x256推理后需要将掩码恢复为原图尺寸这时必须使用INTER_NEAREST最近邻插值否则掩码边缘会出现灰色过渡带影响后续计算。5. 实战避坑舌象分割最常见的五个翻车记录5.1 深监督的四个输出都参与了损失计算导致主输出精度反而下降现象训练损失正常下降验证集 IoU 却不升反降检查具体分割结果发现边缘比浅层输出还粗糙。原因深监督的浅层输出在训练初期提供梯度帮助模型收敛但训练后期浅层分支的监督信号对深层分支产生了扰动。四个分支的优化目标不完全一致权重分配不合理时主输出会被带偏。解决把四个分支的损失权重改为与训练轮次相关的动态值前三轮让浅层分支全权重参与之后每轮衰减浅层分支权重 20%主输出权重始终为 1.0。训练脚本里增加权重更新逻辑即可不需要改网络结构。5.2 数据增强里加了随机裁剪训练正常但对验证集无效现象训练集的 IoU 一直高于验证集 10 个百分点以上且验证集图像尺寸与训练集不一致时预测结果出现明显的块状伪影。原因验证集使用的是A.Resize(256, 256)直接拉伸没有做中心裁剪或保持长宽比的缩放导致原始图像比例畸变模型看到的舌形和训练分布不匹配。舌头形态对长宽比很敏感横向拉伸超过 15% 就会让边缘出现变形。解决验证集改成等比例缩放加 pad 补边val_transform A.Compose([ A.LongestMaxSize(256), A.PadIfNeeded(min_height256, min_width256, border_mode0, value0), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ToTensorV2(), ])这个方案处理后的图像比例和训练集的随机裁剪近似不会引入畸形缩放。5.3 训练和验证都正常推理单张图时结果全黑现象训练脚本里用 DataLoader 加载数据一切正常但单独写推理脚本时输出 mask 全为 0。原因推理脚本里图片读取用 OpenCV 的cv2.imread返回的是 BGR 顺序而训练时数据增强库加载的 RGB 顺序输入的通道顺序错了但网络的 BN 层已经把通道语义写死导致输入分布完全偏离训练分布。解决推理脚本读到图后加一行image cv2.cvtColor(image, cv2.COLOR_BGR2RGB)或者在预处理里指定图像已经是 RGB。这个问题看起来小但排查起来很费时间建议把预处理封装成一个函数训练和推理共用同一份预处理逻辑。5.4 在 GPU 上训练到一半显存突然溢出现象前一百个 epoch 正常某次训练突然报CUDA out of memory且 batch size 调小后仍然复现。原因OneCycleLR的学习率在最高点附近时模型输出的特征图数值波动较大中间层的激活值显存占用短暂超出预算。此外PyTorch 的缓存分配器不会立即释放显存多次迭代后碎片化导致可用显存减少。解决在训练循环里加上torch.cuda.empty_cache()每隔一定步数执行一次。另外可以把clip_grad_norm_的max_norm从 5.0 调到 3.0控制激活值的极端波动。最稳妥的方案是把 batch size 从 8 改为 6留出 20% 的显存余量。5.5 图像分割效果还行但连通域里有明显的孔洞现象可视化结果整体轮廓正确但舌体内部出现零星的黑点或小孔洞特别是舌苔厚腻的图片上特别明显。原因模型对舌体中颜色与背景接近的区域预测概率偏低二值化后这些像素被归类为背景。另外部分 mask 标注本身存在孔洞训练时模型学到了这些标注中的空心结构。解决后处理阶段对二值掩码做形态学闭运算用小尺寸的椭圆核填充小孔洞kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)之后找到最大连通域并筛掉面积占比小于整张图 1% 的独立区块。6. 系统界面的快速使用基于 PySide 的舌象分割工具6.1 界面运行环境与启动方式资源自带的系统界面是基于 PySide 的桌面工具功能很聚焦加载图片、调用模型推理、显示原图/掩码/分割叠加结果并支持批量导出。启动前需要确认依赖是否安装完整pip install pyside6 opencv-python pillow torch torchvision albumentations启动脚本python main.py界面启动后左侧是图片列表点击图片会显示原图预览右侧是分割结果区。点击「开始分割」按钮后界面调用训练好的 PyTorch 模型权重加载方式和脚本里的torch.load一致。模型加载时如果 GPU 显存不够或没有 GPU界面会自动回退到 CPU 推理只是每张图的耗时从 0.1 秒变成 1 到 2 秒不影响正常使用。6.2 集成到自己项目里的轻量封装如果不想开界面只想在代码里调用分割能力可以直接导入推理函数from inference import TongueSegmentor segmentor TongueSegmentor( model_pathweights/best_model.pth, devicecuda ) mask segmentor.segment(sample.jpg) segmentor.visualize(sample.jpg, mask, save_pathresult_overlay.png)TongueSegmentor类把模型加载、预处理、推理和后处理全部封装在内部segment方法返回与原图同尺寸的掩码visualize方法把掩码以半透明红色叠加到原图上。这套封装可以直接嵌进舌象采集系统不必理解内部实现细节。6.3 验证分割效果的量化方法不只是看图肉眼判断分割效果容易陷入主观偏差特别是舌象这种颜色纹理复杂的图像。我建议每次都跑一遍验证集上的 IoU 和 Dice 系数用数据说话。python evaluate.py --model weights/best_model.pth --data dataset/val.txt脚本会逐张输出每张图的 IoU、Dice 和像素准确率最后打印平均值。看结果时要注意区分两类失败模式IoU 低但 Dice 正常说明模型对边界区域的覆盖不够IoU 和 Dice 都低说明模型基本没分割出目标区域问题更可能出在训练数据或者学习率配置上而不是模型的细调参数。对于要求更高的场景我还会额外检查最大连通域的最小外接矩形的长宽比是否在合理范围内舌体长宽比通常落在 1.2 到 1.8 之间如果出现极端值大概率是推理阶段使用了大尺寸输入且没有做边界处理导致的。这套资源从我个人的使用感受来说数据和代码的匹配度做得比较完整从数据准备到训练再到界面验证的链路是通的不需要再花时间拼接胶水代码。如果你要基于它做更深入的研究建议第一件事就是跑通train.py训练出一个自己的权重再和预训练权重做对比评估确认环境配置无误后再开始尝试修改网络结构或换损失函数。从那以后我每次拿到类似的分割项目都强制自己先跑一遍数据分布统计和验证集基线推理把环境、数据、模型三个变量的状态确认清楚再往下一步走这个习惯帮我省下了不少调参的无效时间希望帮到你。本文还有配套的精品资源点击获取