简介:这是一套面向中医数字化与计算机视觉研究者的舌象诊断系统源码包,基于深度学习方法完成舌象图像的分类识别与辅助诊断,适合具备一定编程和深度学习基础的学生、工程师用于复现实验、课题研究或二次开发。压缩包共182个文件,大小42.52MB,包含60张jpg/jpeg/png舌象图像样本、54个py源码及pyc编译文件,并配有文本说明、配置数据、界面文件、项目文档与字体文件等辅助内容,数据、代码、配置与文档层次分明,便于按需查阅。目前已有40人学习下载,除了完整的模型源码和训练脚本之外,还附带了可直接观察的舌象样本图像、界面配置与说明文档,可帮助使用者理解数据预处理、模型构建、界面交互等环节;对正在做课程设计、毕业论文或相关课题的研究者而言,是一套结构完整、便于复现和二次开发的实践资料。
1. 舌象诊断系统不玄:深度学习落地中医望诊的第一关
拿到「舌象诊断系统」这套源码的第一反应,它解决的是一件很具体的事:给一张舌头照片,模型自动输出舌色、舌苔、舌形三个维度的分类判断,并给出分析结果。这类系统这两年热度高,本质是医学影像分类的一个垂直变体,把经典的CNN链路套到中医望诊场景。真正复现下来最反直觉的结论是:模型不是瓶颈,数据才是。标注不一致、拍照光源不统一、样本类严重倾斜,随便一个坑都比修改网络结构对准确率的影响大。适合两类人:一类是拿它当深度学习实战项目来拆的在校学生,另一类是评估中医数字化诊断能不能上生产的从业者。下文按我实际跑这套源码的顺序展开,参数、边界、坑都写在对应位置。
2. 系统架构与数据链路:从舌图录入到诊断输出的完整闭环
2.1 四层架构划分
解压源码之后,第一件事不是跑训练,而是先梳理目录,搞清楚各个模块的职责边界。这套系统从工程结构上可以拆成四层:数据层、预处理层、模型层、服务层。每一层对应源码里一个相对独立的脚本集合,替换任何一个环节都不影响其他层。
| 层级 | 职责 | 对应源码动作 |
|---|---|---|
| 数据层 | 把原始舌头照片整理成带标签的数据集 | 标签解析、数据集划分、样本统计 |
| 预处理层 | 从原图中裁剪舌体、校正颜色、做数据增强 | ROI裁剪、灰度世界白平衡、增强管线 |
| 模型层 | 训练与推理核心网络 | 网络定义、损失计算、训练循环、ONNX导出 |
| 服务层 | 把训练好的模型包装成可用入口 | 单图预测脚本、批量评测脚本 |
这四层的边界划分是有讲究的:数据层可以单独换成医院里重新采集的批量数据,服务层可以换成一个Flask接口或者桌面端,模型层不需要动。对想改造这套系统做毕设或产品原型的人来说,这种可替换性意味着每一层都有独立的实验空间。
2.2 标签体系与采集规范
舌象诊断本质上是多标签分类任务。一张舌头照片,要同时输出舌色、舌苔、舌形三个维度。源码里标签文件的组织方式通常是每行一条样本路径加上三个标签字段,取值的组合决定了分类头的数量。
舌色维度常见取值:淡白、淡红、红、绛、紫。舌苔维度常见取值:薄白、白腻、黄腻、剥苔。舌形维度常见取值:胖大、瘦薄、齿痕。
这三个维度不是独立变化的,红舌常伴黄腻苔,脾虚湿盛多表现于胖大舌加白腻苔。模型能不能学到这层关联,取决于标签质量。文档说明里花了相当篇幅强调采集规范——固定色温、固定光源角度、干净背景、舌头自然伸出状态。这些条件不控好,收集几千张照片也可能是在给模型喂噪声。
标注一致性是另一个容易被低估的问题。医生A把偏红的舌标成红舌,医生B可能会标成绛舌;同一个人不同状态下拍的舌象也存在差异。常见的做法是双人标注、第三方仲裁,标签存疑的样本不进训练集,这是保证模型上限的操作,比调参优先级高得多。
2.3 环境依赖与源码目录结构
复现的第一步是搭环境,这一步翻车率其实是最高的。源码要求的运行环境大概是 Python 3.8 + PyTorch 1.13 + torchvision 0.14 + opencv-python + onnxruntime。建议新建独立的conda环境,不要直接装到系统Python里。
conda create -n tongue_diag python=3.8 -y conda activate tongue_diag pip install torch==1.13.1 torchvision==0.14.1 --index-url https://download.pytorch.org/whl/cu117 pip install opencv-python onnxruntime numpy pandas albumentations版本锁定的原因在于:torchvision版本与PyTorch版本必须严格匹配,否则预训练权重加载会直接报错;onnxruntime在Python 3.10以上部分wheel存在兼容问题,3.8环境最省事。如果机器没有NVIDIA GPU,可以把第一行的cu117换掉、装CPU版本,先跑通流程再上GPU。
源码目录结构大致是:data目录放原始图和标签文件,preprocess目录放ROI裁剪和增强脚本,models目录放网络定义,scripts目录放训练和推理入口,docs目录放文档说明。README里给的是一整套从数据整理到训练评估的流程,对着走一遍比看任何二次讲解都直接。
3. 舌象预处理:从ROI裁剪到数据增强的参数设置
3.1 舌体定位与ROI区域划分
舌头照片不能整张直接喂进网络。背景、嘴唇、牙齿对舌色判断是纯干扰,网络很容易学到错误特征。源码里的做法是先用目标检测框出舌体,或者直接读取标注文件里已有的边界框做裁剪。拿到舌体之后还需要做分区:舌尖、舌中、舌根。颜色信息主要集中在中段,舌尖受环境光和唇色影响最大,舌根则容易被遮挡。
import json import cv2 import numpy as np # 读取标注文件中的舌体边界框 with open("data/labels/sample_001.json", "r") as f: ann = json.load(f) bbox = ann["tongue_bbox"] # [x1, y1, x2, y2] img = cv2.imread("data/images/sample_001.jpg") h, w = img.shape[:2] # 对边界框做向内收缩,剔除嘴唇和背景边缘 margin_x = int((bbox[2] - bbox[0]) * 0.05) margin_y = int((bbox[3] - bbox[1]) * 0.05) x1, y1 = max(0, bbox[0] + margin_x), max(0, bbox[1] + margin_y) x2, y2 = min(w, bbox[2] - margin_x), min(h, bbox[3] - margin_y) roi = img[y1:y2, x1:x2]边界框向内收缩5%这个操作是为了剔除舌尖和舌根边缘的唇色干扰。这里的参数不是死的——如果标注框本身已经标注得很紧,收缩比例可以降到2%;如果标注粗糙、框外带了大量背景,则需要提高到10%。缩小ROI后,模型更容易把注意力放在舌面纹理和颜色分布上。
3.2 白平衡与色彩校正
舌象最有诊断价值的信息是颜色,偏偏颜色最容易失真。同一台手机,自动白平衡在不同光源下拍出的舌头色差肉眼可见。校正常用的方法是灰度世界假设:把R、G、B三个通道的均值拉平到同一个水平,抵消光源色温的影响。
def gray_world_white_balance(img): """灰度世界白平衡:假设图像中RGB三通道均值应相等""" result = img.astype(np.float32) mean_r = np.mean(result[:, :, 2]) mean_g = np.mean(result[:, :, 1]) mean_b = np.mean(result[:, :, 0]) # 以G通道均值为基准,等比例缩放R和B通道 scale_r = mean_g / (mean_r + 1e-6) scale_b = mean_g / (mean_b + 1e-6) result[:, :, 2] *= scale_r result[:, :, 0] *= scale_b # 截断到合法像素范围 result = np.clip(result, 0, 255).astype(np.uint8) return result灰度世界假设对舌象这种红色占比很大的场景其实偏保守,处理完的画面会偏灰、偏中性一些,但对分类任务来说影响是正向的——它把不同拍摄设备之间的色差压缩了。源码里如果遇到极端偏色的样本,还会结合标准色卡做更细的校正,但灰度世界是性价比最高的第一步。
3.3 数据增强参数与注意事项
源码默认的数据增强参数值得单独拿出来分析,因为它们直接决定模型能不能泛化、会不会把颜色学歪。核心增强操作包括:随机旋转15度、水平平移10%、缩放范围0.9到1.1、HSV空间的色相扰动±15、饱和度扰动20%、亮度扰动20%,每个操作的概率设为0.5。
import albumentations as A train_transform = A.Compose([ A.Rotate(limit=15, p=0.5), A.ShiftScaleRotate( shift_limit=0.1, scale_limit=0.1, rotate_limit=0, p=0.5 ), A.HueSaturationValue( hue_shift_limit=15, sat_shift_limit=20, val_shift_limit=20, p=0.5 ), A.Resize(224, 224), A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])色相扰动只给±15是经过权衡的。舌色分类对色相对应关系极其敏感,扰动大了会把淡红舌变成红舌,等于在造虚假样本。亮度扰动同理,过大的亮度扰动会把正常舌变成淡白舌,直接把类别边界搅浑。数据增强在这里是调节模型泛化能力和颜色判断准确率之间的杠杆,幅度必须比通用图像分类小一个量级。
3.4 数据集切分:按样本主体分组
训练集、验证集、测试集按8:1:1切分,但切分粒度必须按样本主体,而不是按单张图片。同一患者的舌头照片如果同时出现在训练集和验证集,模型在验证集上的表现会虚高——它在训练时已经见过同一个人的舌色了。这属于数据泄漏,是验证集准确率接近99%但线上表现惨淡的最常见原因。
from sklearn.model_selection import GroupShuffleSplit # sample_id 是样本主体的唯一标识 gss = GroupShuffleSplit(n_splits=1, train_size=0.8, random_state=42) train_idx, val_idx = next(gss.split(file_list, groups=patient_ids)) train_files = [file_list[i] for i in train_idx] val_files = [file_list[i] for i in val_idx]这里用GroupShuffleSplit而不是普通的train_test_split,核心差异就在于groups参数。如果源码里自带的划分脚本没有按主体分组,建议改成这种写法。同一患者多个时间点采样的照片在临床上很常见,不按主体分组等于验证集直接失效。
4. 训练与部署要点:ResNet50微调、损失设计与ONNX导出
4.1 网络结构:双分类头设计
网络选型上,源码用的是ResNet50作为backbone。选择这个结构不是因为它最新,而是因为它有预训练权重、足够深、在中小规模医学图像数据集上泛化表现稳定。关键改造在分类头部分:去掉原生的ImageNet分类层,换成针对舌色、舌苔、舌形三个维度的独立分类头。
import torch.nn as nn class TongueDiagnosisNet(nn.Module): def __init__(self, num_colors=5, num_coatings=4, num_shapes=3): super().__init__() import torchvision.models as models backbone = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) self.features = nn.Sequential(*list(backbone.children())[:-1]) # 三个分类头共用一个特征提取器 self.color_head = nn.Linear(2048, num_colors) self.coating_head = nn.Linear(2048, num_coatings) self.shape_head = nn.Linear(2048, num_shapes) def forward(self, x): feat = self.features(x) feat = feat.flatten(1) return self.color_head(feat), self.coating_head(feat), self.shape_head(feat)三个分类头共享同一个特征提取器的好处是计算量小,且舌色、舌苔、舌形之间是有相关性的,共享底层特征能让三个任务互相增强。但要注意梯度回传的时候,三个头的loss会同时影响backbone的权重,如果某个维度的标签噪声特别大,会拖累整体特征质量。遇到这种情况,可以退一步让三个头各自接独立的FC层组合,代价是参数量略增。
4.2 超参数设置与损失函数
源码文档里给的超参数组合:优化器AdamW,初始学习率1e-4,batch size 32,训练40到60个epoch。微调阶段的关键经验是:backbone的学习率要比新加的分类头低,一般是分类头的十分之一,避免预训练权重被大幅破坏。
| 超参数 | 数值 | 调整建议 |
|---|---|---|
| 优化器 | AdamW | 比Adam多一个解耦的权重衰减 |
| 初始学习率 | 1e-4(分类头)/ 1e-5(backbone) | 验证集不降就减半 |
| batch size | 32 | 显存不够就降到16 |
| weight decay | 1e-4 | 过拟合时加大到5e-4 |
| 训练轮数 | 40~60 | 以验证集最优为准 |
类别不平衡是舌象诊断绕不开的问题。淡红舌、薄白苔这类正常态在数据集中占比可能超过一半,其他类别加在一起才占另一半。直接用交叉熵损失,模型会倾向把一切预测成多数类。源码给出了两个解决层面:一是给交叉熵加class weight,二是用Focal Loss,让模型把注意力放到难分类的少数类上。
import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, gamma=2.0, alpha=None): super().__init__() self.gamma = gamma self.alpha = alpha def forward(self, logits, targets): ce_loss = F.cross_entropy(logits, targets, reduction="none") pt = torch.exp(-ce_loss) focal_loss = (1 - pt) ** self.gamma * ce_loss if self.alpha is not None: alpha_t = self.alpha[targets] focal_loss = alpha_t * focal_loss return focal_loss.mean()gamma取2.0是Focal Loss论文里的默认值,alpha是各类别权重的张量,从训练集标签分布统计出来即可。注意alpha的取值不要直接按频率倒数算,对于占比特别小的类别,权重拉得太高会导致训练震荡,建议上限控制在5倍以内。
4.3 训练循环与模型保存策略
训练主循环本身不复杂,重点是模型保存时机和学习率衰减策略。源码里常见做法是监控验证集准确率,连续3个epoch不提升就把学习率乘以0.5,同时保存验证集指标最优的那一次权重。
best_acc = 0.0 patience = 0 for epoch in range(num_epochs): model.train() for batch_x, batch_labels in train_loader: batch_x = batch_x.to(device) labels = [lab.to(device) for lab in batch_labels] optimizer.zero_grad() pred_colors, pred_coatings, pred_shapes = model(batch_x) loss = (loss_color(pred_colors, labels[0]) + loss_coating(pred_coatings, labels[1]) + loss_shape(pred_shapes, labels[2])) / 3 loss.backward() optimizer.step() val_acc = evaluate(model, val_loader) if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "best_model.pth") patience = 0 else: patience += 1 if patience >= 3: for g in optimizer.param_groups: g["lr"] *= 0.5 patience = 0三个分类头的loss取平均是常见做法,没有哪个维度应该被优先保证。如果发现训练后期某个维度始终学不好,可以把对应loss的权重加大到2,但不要三个头各自调一套lr,会让优化过程变得不可控。
4.4 ONNX导出与部署对齐
训练结束后的落地动作是导出ONNX。PyTorch模型直接部署有几个麻烦:依赖库重、推理速度慢、C++端不好接。导出ONNX后可以用onnxruntime推理,依赖轻、跨平台、速度接近原生。
import torch import onnxruntime as ort model = TongueDiagnosisNet() model.load_state_dict(torch.load("best_model.pth", map_location="cpu")) model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, "tongue_model.onnx", input_names=["input"], output_names=["color", "coating", "shape"], opset_version=12, dynamic_axes=None ) # 用onnxruntime验证导出结果 sess = ort.InferenceSession("tongue_model.onnx") input_data = preprocess_single_image("test.jpg") # 返回 [1,3,224,224] float32 outputs = sess.run(None, {"input": input_data})导出时固定输入尺寸,dynamic_axes不打开。打开动态轴表面看灵活,实际会让部署端的预处理代码多出一条分支,纯属给自己挖坑。推理端用OpenCV读图时,记得OpenCV读进来是BGR顺序,必须先转到RGB再走训练时的预处理流程,这一条做错能让部署版准确率直接掉回随机水平。
5. 避坑指南:复现这套舌象诊断系统的5个真实踩坑记录
5.1 验证集准确率接近95%,部署后错判一堆
现象:训练阶段验证集准确率很漂亮,把模型接到推理脚本后,对真实场景图片的判断乱七八糟。
原因:训练时的预处理流水线和推理时的预处理流水线不一致。训练用的是ROI裁剪+白平衡+归一化,推理时直接用原图resize就喂进去了,输入分布完全对不上。
解决:把训练脚本里从原始图片到张量的所有步骤抽成一个独立的preprocess函数,训练和推理共用。尤其检查归一化的mean和std是否一致、颜色通道顺序是否一致、是否做了ROI裁剪。
5.2 损失一直震荡,训练曲线像心电图
现象:loss曲线上下剧烈跳动,验证集准确率也跟着抖,模型根本没有收敛趋势。
原因:学习率偏高,或者数据增强过强。舌象数据集不像ImageNet那样千万级规模,直接复用通用图像分类的学习率容易震荡。
解决:先用0.1、0.01、0.001、0.0001四个学习率各跑几十个batch,看初始loss下降情况再定。这个小数据集规模一般1e-4起步,分类头1e-4、backbone 1e-5是相对稳的组合。
5.3 预测结果清一色输出正常舌,少数类完全不出现在结果里
现象:模型对所有测试图片都输出淡红舌、薄白苔,没有任何例外。
原因:类别不平衡叠加交叉熵损失,模型发现全部预测成多数类就能获得极低的loss。少数类样本量太少,对梯度贡献微乎其微。
解决:训练前统计标签分布,给多数类降权或少数类升权,切换到Focal Loss。同时检查验证集里少数类样本有没有被数据划分脚本丢到训练集里,导致验证集根本看不到少数类。
5.4 同一张舌象换台手机拍摄,诊断结果截然不同
现象:模型对同一舌头在不同设备、不同光源下拍的照片给出完全不同的分类,稳定性不可接受。
原因:色彩未做标准化处理。模型学到的颜色分布与训练时的色温绑定,测试时换了光源色温,输入分布整体偏移,结果自然漂移。
解决:推理前强制走一遍灰度世界白平衡,训练时也保持同样的处理流程。如果对颜色准确性要求更高,可在采集时放置标准色卡,按色卡做映射校正,这是临床采集场景的标配操作。
5.5 conda环境按要求装好了,跑训练脚本还是报import error
现象:torch、torchvision、opencv都装成功了,运行train.py提示找不到某个模块或版本冲突。
原因:大概率是torch和torchvision版本不匹配,或者是训练脚本里用了更高版本torchvision才有的预训练权重注册接口。
解决:严格按requirements.txt列出的版本重装。如果装的是预发布版或镜像源版本,卸载后重新指定版本装一次,先装CPU版跑通全流程再换CUDA版,能省去非常多排查时间。
6. 用留出集加典型样本,做一次更可信的系统验证
把模型跑出准确率之后,真正要验证的是它从黑匣子变成可信工具。我的做法是额外加三层验证,而不是只看总准确率。
第一层是用留出集做分维度混淆矩阵。统计舌色、舌苔、舌形在测试集上的整体准确率,再看每个类别之间的混淆分布。舌色里红舌和绛舌最容易互混,舌苔里黄腻和白腻边界模糊,这些是模型的实际短板。第二层是Top-2命中率检测:如果Top-1准确率不高,但Top-2能把真实标签覆盖住,说明模型学到了颜色远近关系,实际问题没那么大。第三层是典型样本覆盖:从验证集里抽淡红舌薄白苔、红舌黄腻苔、胖大舌这三个典型组合,以及模型最容易翻车的临界样本,逐一跑推理、打印三个维度的概率输出。
python scripts/evaluate.py --model_path best_model.pth \ --test_dir data/test_images/ \ --label_file data/test_labels.json \ --top_k 2 \ --output report.csv评估脚本会输出每张测试图三个维度的Top-1和Top-2结果,以及一个按类别汇总的混淆统计表。我一般会把report.csv里模型输出和人工判断不一致的行单独拉出来看原图,人工再核一遍到底是谁错了。这一步对判断「模型是学会了还是背住了」极有价值。
评估指标也不能只看一个。舌色准确率、舌苔准确率、舌形准确率分别看是三件事,整体准确率最高不代表每个维度都可靠。实际使用场景里,如果舌色可靠而舌苔不稳定,产品上的取舍就是侧重输出舌色结果,舌苔结果标注为参考。
从那以后,我每次拿到开源医疗AI项目,都会先把训练预处理和推理预处理两条流水线逐行对齐,再查一遍标签分布和增强参数,确认少数学不会被「洗掉」,最后才会信那个准确率数字。这套动作花不了多少时间,但能过滤掉一大半表面光鲜的项目。希望帮到你。
提示:舌象诊断模型的输出结果为辅助分析参考,不能替代执业医师的诊断结论。
本文还有配套的精品资源,点击获取