基于深度学习的舌象诊断系统:Python课设从数据到部署全流程
简介这是一套面向高校学生与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, thresholdBLUR_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_okTrue) 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, interpolationcv2.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(p0.5), # 舌象左右翻转不改变语义 transforms.RandomRotation(degrees10), # 小角度旋转模拟拍摄角度偏差 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( weightssample_weights, num_sampleslen(sample_weights), replacementTrue # 有放回采样保证少样本被反复抽到 ) return samplerreplacementTrue是关键有放回才能让少样本类别在一个 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_classesNUM_CLASSES, freeze_backboneTrue): model models.resnet50(weightsmodels.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, desctrain): 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, descval): 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_decay1e-4) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) criterion nn.CrossEntropyLoss(label_smoothing0.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(fepoch {epoch}: train_loss{tr_loss:.4f} train_acc{tr_acc:.4f} fval_loss{val_loss:.4f} val_acc{val_acc:.4f}) torch.save(model.state_dict(), fcheckpoint_epoch{epoch}.pth) if __name__ __main__: main()逻辑说明build_model里冻结策略是只训layer4和fc这是迁移学习里最常用的折中方案比全冻结精度高比全解冻省显存。train_one_epoch用了torch.cuda.amp混合精度8G 卡上 batch size 能开到 32不用的话只能开到 16。分层学习率是关键分类头是随机初始化的需要大学习率快速收敛layer4是预训练权重小学习率微调避免破坏已有特征。label_smoothing0.1对舌象这种标注可能有歧义的数据特别有用能压住验证 loss 的震荡。参数怎么改NUM_CLASSES改成你的实际类别数T_max30和训练 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 卡跑 ResNet50batch 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_classes6): model build_model(num_classes, freeze_backboneFalse) model.load_state_dict(torch.load(ckpt_path, map_locationDEVICE)) 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, dim1)[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库几行就能跑答辩时放一张热力图老师会觉得你对自己的模型有掌控力不是调包跑完就完事。最后说个我自己的教训带课设这些年见过太多小组把时间全花在调模型上最后数据一塌糊涂答辩时被问「你这数据哪来的、标注标准是什么」直接卡壳。舌象诊断这个题目数据质量决定上限模型只是逼近上限的工具。先把采集和标注做扎实哪怕只有一千张图也比五千张脏数据训出来的模型靠谱。希望帮到你。本文还有配套的精品资源点击获取