简介:这是一套面向高校学生与Python初学者的舌象诊断系统完整源码,适用于毕业设计、期末大作业与课程设计场景,帮助读者快速搭建一个功能完善、界面美观的深度学习应用项目。资源包共183个文件,包含54个py源码文件、61张jpg图像样本、40个pyc编译文件,以及txt说明、json配置、ui界面文件、ttf字体与docx学习路线文档等,压缩包约42.7MB,代码附有详细注释,新手也能看懂并完成部署。目前已有165人学习下载。项目围绕舌象图像采集、预处理与深度学习分类诊断展开,配套学习路线文档梳理了从环境配置到模型训练的整体思路,图像样本可直接用于数据加载与效果验证,界面文件则支撑可视化交互。整体目录结构清晰,便于按模块查阅与二次修改,适合需要完整项目方案、排错参考与可运行代码的读者直接使用。
1. 舌象诊断系统落地:从一张舌头照片到可复现的深度学习课设
很多同学做「人工智能大作业」时第一反应是手写数字识别或者猫狗分类,数据集现成、代码一搜一大把,交上去老师看一眼就知道是套模板。舌象诊断这个题目不一样,它天然带着中医场景的落地感:拍一张舌头照片,模型输出舌质颜色、舌苔厚薄、齿痕有无这几类结论,既有计算机视觉的完整链路,又能讲清楚数据标注和医学先验。我带的几届课设里,选这个方向的小组答辩时明显更占优势,因为老师能追问的点多——数据从哪来、类别怎么定、模型为什么这么选。这篇笔记就按我实际带学生做过的路径,把 Python 实现基于深度学习的舌象诊断系统源代码这件事拆开讲:数据集怎么攒、模型怎么搭、训练怎么调、坑在哪。适合正在找大作业选题的本科生,也适合想快速跑通一个医学图像分类 pipeline 的入门者。整套方案用 PyTorch 就能跑,单卡 8G 显存足够,不需要任何特殊硬件。
2. 舌象数据从哪来:采集、标注与预处理的完整链路
2.1 为什么舌象数据集是这类课设最大的门槛
公开的舌象数据集非常少,这是做这个题目第一个要面对的现实。常见做法有三条路:一是找中医学院或附属医院合作,拿到脱敏后的临床舌象照片;二是用公开的中医舌诊数据集,比如部分高校实验室放出的舌象库,但类别定义往往和你的需求对不上;三是自己用手机在标准光源下拍,找几十个志愿者,每人拍舌面、舌底、侧面几个角度。我一般建议课设小组走第三条路加第二条路混合:先下载能拿到的公开数据打底,再自己补拍几百张把类别补齐。
这里有个血泪经验:舌象的类别定义一定要在采集之前定死。舌质颜色分淡白、淡红、红、绛、紫暗,舌苔分薄白、厚白、黄腻、灰黑,再加上齿痕、裂纹、瘀斑这些形态特征。如果你先拍了一堆照片再回头想怎么分类,会发现很多照片根本没法归到任何一类,标注成本直接翻倍。课设规模建议控制在 5 到 8 个类别,每类至少 200 张,低于这个数模型基本学不出东西。
2.2 用 Python 做数据清洗和增强的最小脚本
拿到原始照片后第一步不是直接喂模型,而是清洗。手机拍的照片尺寸不一、有反光、有阴影,还有拍糊的。下面这段脚本做三件事:统一尺寸、剔除模糊图、按类别分目录。
import os import cv2 import shutil import numpy as np RAW_DIR = "raw_tongue" # 原始照片目录,按类别分子文件夹 CLEAN_DIR = "clean_tongue" # 清洗后输出目录 TARGET_SIZE = (224, 224) # 和后续模型输入对齐 BLUR_THRESHOLD = 80.0 # 拉普拉斯方差阈值,低于此值判为模糊 def is_blurry(img_gray, threshold=BLUR_THRESHOLD): # 拉普拉斯算子响应方差越小,图像越模糊 return cv2.Laplacian(img_gray, cv2.CV_64F).var() < threshold def clean_dataset(): for cls in os.listdir(RAW_DIR): src_cls = os.path.join(RAW_DIR, cls) dst_cls = os.path.join(CLEAN_DIR, cls) os.makedirs(dst_cls, exist_ok=True) kept, dropped = 0, 0 for fname in os.listdir(src_cls): fpath = os.path.join(src_cls, fname) img = cv2.imread(fpath) if img is None: dropped += 1 continue gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if is_blurry(gray): dropped += 1 continue # 统一尺寸,用 INTER_AREA 在缩小场景下更平滑 resized = cv2.resize(img, TARGET_SIZE, interpolation=cv2.INTER_AREA) cv2.imwrite(os.path.join(dst_cls, fname), resized) kept += 1 print(f"{cls}: kept={kept}, dropped={dropped}") if __name__ == "__main__": clean_dataset()逻辑说明:is_blurry用拉普拉斯方差判断清晰度,这是图像质量筛选里最省事的指标,阈值 80 是经验值,舌象照片因为纹理细腻可以适当调低到 60。cv2.resize用INTER_AREA而不是默认的双线性,是因为缩小图像时它能避免摩尔纹。参数方面,TARGET_SIZE要和后面模型的输入尺寸一致,选 224 是因为主流 backbone 都是这个规格;如果你打算用更大输入比如 384,显存占用会翻倍,8G 卡上 batch size 得降到 16 以下。
清洗完之后做数据增强。舌象数据量小,增强是必须的,但要注意:颜色抖动要慎用。舌质颜色本身就是分类依据,你把红色调成偏黄,标签就错了。我一般只用几何增强——随机水平翻转、小角度旋转、随机裁剪,颜色相关的增强全部关掉。
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), # 随机裁剪保留主体 transforms.RandomHorizontalFlip(p=0.5), # 舌象左右翻转不改变语义 transforms.RandomRotation(degrees=10), # 小角度旋转,模拟拍摄角度偏差 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], # ImageNet 统计量 std=[0.229, 0.224, 0.225]), ]) val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])注意这里训练和验证的 transform 不一样:训练用RandomResizedCrop加翻转旋转,验证只做 resize。Normalize用的 ImageNet 均值方差,因为后面要用预训练权重,这一步不能省,否则预训练特征分布对不上,收敛会慢很多。旋转角度控制在 10 度以内,舌象的朝向本身有语义,转太多反而不合理。
2.3 数据集划分与类别不均衡的处理
划分训练集验证集测试集,比例按 7:1.5:1.5 走。这里有个容易翻车的地方:如果同一志愿者的多张照片被分到训练集和验证集两边,验证准确率会虚高,因为模型见过这个人的舌头。正确做法是按人划分,同一个人所有照片只进一个集合。代码上就是先按志愿者 ID 分组,再对组做划分。
类别不均衡在舌象里很常见,淡红舌可能占了一半,紫暗舌只有几十张。处理方式有两种:一是用WeightedRandomSampler给少样本类别更高采样概率,二是在 loss 里加类别权重。课设规模我推荐前者,改动小、效果直接。
from torch.utils.data import WeightedRandomSampler import numpy as np def make_sampler(labels): # labels 是训练集所有样本的类别索引列表 class_counts = np.bincount(labels) class_weights = 1.0 / class_counts # 类别越少权重越大 sample_weights = class_weights[labels] # 每个样本对应其类别权重 sampler = WeightedRandomSampler( weights=sample_weights, num_samples=len(sample_weights), replacement=True # 有放回采样,保证少样本被反复抽到 ) return samplerreplacement=True是关键,有放回才能让少样本类别在一个 epoch 里被抽到足够多次。num_samples设成训练集大小,这样每个 epoch 的迭代次数不变,训练节奏好控制。
3. 模型选型与训练:从 ResNet 迁移学习到舌象分类
3.1 为什么课设场景优先选迁移学习而不是从零训练
舌象数据集撑死几千张,从零训一个 CNN 基本没戏,过拟合到验证集准确率卡在 60% 上不去。迁移学习是标准答案:用 ImageNet 预训练的 ResNet50 或 EfficientNet-B0,把最后的全连接层换成你的类别数,前几层冻结或者用很小的学习率微调。我一般推荐 ResNet50 打底,结构简单、资料多、答辩时好解释;如果显存紧张或者想追求推理速度,EfficientNet-B0 是更好的选择,参数量只有 ResNet50 的五分之一,精度还略高。
选型理由说清楚:舌象分类本质是细粒度纹理加颜色分类,浅层特征(边缘、纹理)和 ImageNet 学到的通用特征高度重合,所以迁移效果很好。深层特征需要针对舌象微调,所以解冻最后两个 stage 用低学习率训练是性价比最高的做法。
3.2 搭建模型与训练循环的完整代码
下面这段是核心训练代码,包含模型构建、分层学习率、训练验证循环。用 PyTorch 写,结构清晰,方便你直接改成自己的类别数。
import torch import torch.nn as nn import torch.optim as optim from torchvision import models from torch.utils.data import DataLoader from tqdm import tqdm NUM_CLASSES = 6 # 按你的类别数改 DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") def build_model(num_classes=NUM_CLASSES, freeze_backbone=True): model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) if freeze_backbone: # 冻结除 layer4 和 fc 之外的所有参数 for name, param in model.named_parameters(): if "layer4" not in name and "fc" not in name: param.requires_grad = False in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(0.4), # 舌象数据少,dropout 防过拟合 nn.Linear(in_features, num_classes) ) return model.to(DEVICE) def train_one_epoch(model, loader, criterion, optimizer, scaler): model.train() total_loss, correct, total = 0.0, 0, 0 for imgs, labels in tqdm(loader, desc="train"): imgs, labels = imgs.to(DEVICE), labels.to(DEVICE) optimizer.zero_grad() with torch.cuda.amp.autocast(): # 混合精度,省显存提速 outputs = model(imgs) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() total_loss += loss.item() * imgs.size(0) correct += (outputs.argmax(1) == labels).sum().item() total += imgs.size(0) return total_loss / total, correct / total @torch.no_grad() def evaluate(model, loader, criterion): model.eval() total_loss, correct, total = 0.0, 0, 0 for imgs, labels in tqdm(loader, desc="val"): imgs, labels = imgs.to(DEVICE), labels.to(DEVICE) outputs = model(imgs) loss = criterion(outputs, labels) total_loss += loss.item() * imgs.size(0) correct += (outputs.argmax(1) == labels).sum().item() total += imgs.size(0) return total_loss / total, correct / total def main(): model = build_model() # 分层学习率:新加的分类头用大学习率,微调的 layer4 用小学习率 head_params = list(model.fc.parameters()) backbone_params = [p for n, p in model.named_parameters() if p.requires_grad and "fc" not in n] optimizer = optim.AdamW([ {"params": backbone_params, "lr": 1e-4}, {"params": head_params, "lr": 1e-3}, ], weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) criterion = nn.CrossEntropyLoss(label_smoothing=0.1) # 标签平滑缓解过拟合 scaler = torch.cuda.amp.GradScaler() # train_loader / val_loader 由前面的 Dataset 构建,此处省略 for epoch in range(30): tr_loss, tr_acc = train_one_epoch(model, train_loader, criterion, optimizer, scaler) val_loss, val_acc = evaluate(model, val_loader, criterion) scheduler.step() print(f"epoch {epoch}: train_loss={tr_loss:.4f} train_acc={tr_acc:.4f} " f"val_loss={val_loss:.4f} val_acc={val_acc:.4f}") torch.save(model.state_dict(), f"checkpoint_epoch{epoch}.pth") if __name__ == "__main__": main()逻辑说明:build_model里冻结策略是只训layer4和fc,这是迁移学习里最常用的折中方案,比全冻结精度高,比全解冻省显存。train_one_epoch用了torch.cuda.amp混合精度,8G 卡上 batch size 能开到 32,不用的话只能开到 16。分层学习率是关键:分类头是随机初始化的,需要大学习率快速收敛;layer4是预训练权重,小学习率微调避免破坏已有特征。label_smoothing=0.1对舌象这种标注可能有歧义的数据特别有用,能压住验证 loss 的震荡。
参数怎么改:NUM_CLASSES改成你的实际类别数;T_max=30和训练 epoch 数一致;如果显存不够,把 batch size 降到 16 并去掉混合精度;如果验证准确率一直不涨,先把layer4也冻上,只训分类头看看能不能过拟合一个小数据集,能过拟合说明模型没问题,是数据或学习率的事。
3.3 训练过程中的监控指标与早停策略
光看准确率不够,舌象各类别样本量差异大,准确率会被多数类带偏。必须看混淆矩阵和每类的 precision/recall。我一般每 5 个 epoch 在验证集上算一次混淆矩阵,如果发现紫暗舌总是被预测成淡红舌,说明这两类在颜色空间上太近,要么补数据,要么在 loss 里给这两类加权重。
早停策略:验证 loss 连续 8 个 epoch 不下降就停,保存验证 loss 最低的那个 checkpoint。不要用最后一个 epoch 的模型,过拟合之后准确率会掉。代码上加个计数器就行,不复杂但能省不少训练时间。
4. 避坑与排查:舌象诊断课设里最容易翻车的五件事
4.1 验证准确率虚高,测试集一跑就崩
现象:训练时验证集准确率 92%,换测试集只有 65%。原因:同一志愿者的照片同时出现在训练集和验证集,模型记住了人脸或拍摄环境而不是舌象特征。解决:按志愿者 ID 分组划分数据集,同一个人只进一个集合。这个坑我见过至少三届学生踩,改完之后验证和测试的差距会从 27 个点缩到 5 个点以内。
4.2 颜色增强把标签搞反了
现象:训练 loss 正常下降,但模型对红色和淡白舌总是混淆。原因:用了ColorJitter做增强,把淡红舌调成了偏紫,标签还是淡红,模型学到的颜色特征和标签对不上。解决:关掉所有颜色相关的增强,只保留几何变换。舌象分类里颜色就是标签本身,动颜色等于动标签。
4.3 显存溢出,batch size 一降再降还是 OOM
现象:8G 卡跑 ResNet50,batch size 降到 8 还是报 CUDA out of memory。原因:输入尺寸设成了 384,特征图显存占用是 224 的三倍;或者没开混合精度,中间激活值全是 fp32。解决:输入改回 224,开启torch.cuda.amp,把pin_memory和num_workers设好避免数据加载成为瓶颈。还不行就换 EfficientNet-B0,参数量小很多。
4.4 类别不均衡导致少样本类别 recall 接近零
现象:整体准确率 85%,但紫暗舌的 recall 只有 0.2。原因:没用加权采样,模型直接摆烂全预测多数类。解决:加WeightedRandomSampler,同时在CrossEntropyLoss里传weight参数,两个一起用效果最好。改完之后整体准确率可能掉一两个点,但少样本类别的 recall 能拉到 0.7 以上,这才是模型真正学到了东西。
4.5 推理时预处理和训练不一致
现象:训练时验证准确率 88%,部署成 demo 后同一张图预测结果乱跳。原因:推理脚本里忘了做Normalize,或者 resize 的插值方式和训练不一样。解决:把验证集的 transform 单独存成一个模块,训练和推理共用同一份代码。这个坑最隐蔽,因为模型本身没问题,纯粹是工程细节翻车。
5. 把课设做成能拿出手的东西:推理封装与效果验证技巧
课设答辩时老师最想看的是「你的系统能不能真的用」。光有训练脚本不够,得有一个能输入图片、输出诊断结果的推理接口。我一般会写一个简单的predict.py,加载 checkpoint,对单张图片做预处理、推理、输出 top-3 类别和置信度。这样答辩现场可以拿手机拍一张舌头照片直接演示,比放 PPT 有说服力得多。
import torch import cv2 import numpy as np from PIL import Image from torchvision import transforms CLASS_NAMES = ["淡白舌", "淡红舌", "红舌", "紫暗舌", "薄白苔", "黄腻苔"] def load_model(ckpt_path, num_classes=6): model = build_model(num_classes, freeze_backbone=False) model.load_state_dict(torch.load(ckpt_path, map_location=DEVICE)) model.eval() return model def predict(image_path, model): img = Image.open(image_path).convert("RGB") tensor = val_transform(img).unsqueeze(0).to(DEVICE) # 复用验证集 transform with torch.no_grad(): logits = model(tensor) probs = torch.softmax(logits, dim=1)[0] top3 = torch.topk(probs, 3) for idx, score in zip(top3.indices, top3.values): print(f"{CLASS_NAMES[idx]}: {score.item():.4f}") return CLASS_NAMES[top3.indices[0]]这段代码的关键是复用val_transform,保证推理预处理和验证阶段完全一致。输出 top-3 而不是只输出一个结果,是因为舌象诊断本身有模糊地带,给医生或用户看 top-3 加置信度更诚实。如果 top-1 置信度低于 0.5,建议在 demo 里提示「结果不确定,建议重新拍摄」,这比硬给一个错误答案强。
验证模型有没有真的学到东西,我有个习惯:拿一张训练集里没有的舌象图,用 Grad-CAM 画出模型关注的区域。如果热力图集中在舌体和舌苔上,说明模型学对了;如果集中在嘴唇、牙齿或者背景上,说明数据里有 shortcut,得回去清洗数据。Grad-CAM 用pytorch-grad-cam库几行就能跑,答辩时放一张热力图,老师会觉得你对自己的模型有掌控力,不是调包跑完就完事。
最后说个我自己的教训:带课设这些年,见过太多小组把时间全花在调模型上,最后数据一塌糊涂,答辩时被问「你这数据哪来的、标注标准是什么」直接卡壳。舌象诊断这个题目,数据质量决定上限,模型只是逼近上限的工具。先把采集和标注做扎实,哪怕只有一千张图,也比五千张脏数据训出来的模型靠谱。希望帮到你。
本文还有配套的精品资源,点击获取