基于深度学习的舌苔识别与PyQt5检测鉴定系统设计与实践
发布时间:2026/9/28 14:17:48来源:尧图网络
简介这是一套基于深度学习的舌苔识别检测鉴定系统毕业设计项目源码面向计算机相关专业毕业设计、课程设计与期末大作业场景适合需快速上手完整项目的高校学生及AI实战学习者。项目经导师指导并以99分评审通过代码完整可运行配备PyQt5图形界面支持舌苔图像检测识别流程演示与结果展示降低入门门槛。资源包共109个文件约105.44MB主要包含26个Python源码文件、PyQt5界面ui文件、训练好的pth模型权重、json配置、jpg样例图片及2份docx毕业论文文档另含训练日志文件便于复盘调参过程。已有150人学习下载适合作为毕业设计直接参考或二次开发基础可帮助使用者快速理解深度学习视觉项目从数据、训练到界面集成的完整链路。1. 一套能出论文、能出界面的舌苔识别系统它到底做了什么如果你在医院信息化或中医数字化相关场景待过就会知道“舌苔识别”不是玄学而是一个非常典型的图像分类落地题采集舌头照片判断舌色、苔色、苔质再给出一段可读的鉴定结论。这套基于深度学习的舌苔识别检测鉴定系统把这条链路完整做成了毕业设计Python 源码负责训练和推理PyQt5 界面负责交互配套毕业论文负责把原理、实验和结论讲清楚。下载包里还带了 TensorBoard 训练日志文件events.out.tfevents.*意味着你不需要从零跑一遍训练也能先复盘别人当时的训练过程。适合两类人正在做毕业设计、课程设计但缺一个“完整可运行”参照物的计算机专业学生以及想练手“深度学习桌面应用”全流程的开发者。它不是教学 Demo是一个能跑、能出报告、能写进论文的完整项目。2. 先理数据再谈模型舌苔图像的组织、清洗与增强2.1 目录结构与标签体系怎么设计做图像分类第一件事不是选模型而是把数据目录定好。很多毕设翻车不是因为网络写错而是训练集、验证集、测试集的文件路径乱成一团最后连评估结果都没法解释。这个系统的常见做法是按“类别标签建文件夹”PyTorch 的ImageFolder可以直接读省去自己写 Dataset 的麻烦。dataset/ ├── train/ │ ├── 淡红舌_薄白苔/ │ ├── 红舌_黄腻苔/ │ ├── 绛舌_少苔/ │ ├── 青紫舌_白腻苔/ │ └── ... ├── val/ │ └── ...与 train 同结构 └── test/ └── ...按图片存放不建类别文件夹留作盲测这里的标签体系是“组合标签”把舌色、苔色、苔质拼成一个类别名。组合标签的好处是直观界面展示时可以直接说“淡红舌、薄白苔”坏处是类别数会膨胀如果每个维度拆开组合五六十类很常见。毕设里如果原始数据量不大通常压缩成五到八类主干类型既保证每类有足够样本又能在论文里画出像样的混淆矩阵。你也可以改成 Multi-Label 输出即舌头图片同时输出“舌色淡红、苔色白、苔质薄”三个结果但这会让模型结构从单头分类变成三头分类训练代码和评估逻辑都复杂一截。对于课程设计和本科毕设组合标签是性价比最高的方案。2.2 预处理管线去反光、尺寸统一与归一化舌苔照片有一个很现实的干扰反光。舌尖和舌面会因为唾液产生高光区域这些高光在深度学习模型眼里往往被当成“白色特征”直接影响苔色判断。我一般会在训练前加一道 CLAHE 光照均衡把高光压下去再做归一化。import cv2 import numpy as np def normalize_illumination(image_path): # 用 imdecode 读取兼容中文路径详见第 5 章 img cv2.imdecode(np.fromfile(image_path, dtypenp.uint8), 1) # 转 YCrCbCLAHE 只作用在亮度通道上不污染色度 ycrcb cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb) y, cr, cb cv2.split(ycrcb) clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) y clahe.apply(y) ycrcb cv2.merge([y, cr, cb]) return cv2.cvtColor(ycrcb, cv2.COLOR_YCrCb2BGR)这段代码里clipLimit2.0控制对比度增强幅度太小没效果太大会把舌头纹理搞得像塑料tileGridSize(8, 8)是局部均衡的网格密度对 224×224 的输入图来说 8×8 网格是稳妥值。需要注意预处理脚本必须在训练和推理阶段共用同一套逻辑否则训练时见到的图像分布和在线识别时不一致准确率会明显掉。数据增强方面舌体检测不太依赖水平翻转舌象本来就有左右对称性翻转是安全的但随机旋转、亮度对比度扰动、随机缩放裁剪都很重要。用torchvision.transforms就能完成from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.8, 1.0)), transforms.RandomRotation(degrees10), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.1), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])这里有几个细节值得记下来。RandomResizedCrop的scale(0.8, 1.0)不是随便写的裁剪比例低于 0.8 时模型容易学到“舌头的局部纹理”而不是“整条舌头的形态”这对苔质判断有害。ColorJitter的色相扰动我只给到saturation0.1舌色判断对色相极其敏感调太大等于给模型制造错误标注。归一化用的mean和std是 ImageNet 的统计值因为后面要用在 ImageNet 上预训练过的 ResNet50输入分布必须对齐。2.3 划分数据集避免“同源泄漏”数据划分是这类项目里最容易被低估的一步。舌苔照片往往是一个人不只拍一张同一部手机、同一个光源、同一个角度连续拍五张。如果直接按文件随机划分训练集和验证集里很可能出现同一舌头的不同照片验证集准确率会虚高论文里的数据会失真换到真实场景立刻现原形。正确做法是“按采集批次分组划分”。假设你的数据清单里每一行是图片路径, 类别, 采集批次号批次号代表某次拍摄会话import pandas as pd from sklearn.model_selection import GroupShuffleSplit df pd.read_csv(data_list.csv) gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) # groups 是采集批次号列同一次拍摄的图片只会进同一侧 train_idx, val_idx next(gss.split(df, groupsdf[group_id])) train_df df.iloc[train_idx].reset_index(dropTrue) val_df df.iloc[val_idx].reset_index(dropTrue)GroupShuffleSplit是 sklearn 里做分组划分的现成工具传入groups参数后它保证同组数据不会同时出现在训练集和验证集里。test_size0.2表示留 20% 的批次做验证random_state42固定随机种子保证每次跑论文里的结果可复现。如果你的数据没有批次字段一个补救办法是把同一天、同一个人拍的照片当成同一组手工补一列group_id。这一步省不得。3. 训练部分模型选型与超参数以及 TensorBoard 日志怎么看3.1 用迁移学习还是从零训练舌苔识别项目的数据量通常只有几千到一两万张从零训练一个深度卷积网络是不现实的收敛慢、容易过拟合而且论文里很难解释清楚你“设计”了什么网络结构。这个系统的合理技术路线是迁移学习加载 ImageNet 预训练权重把最后一层全连接换成自己的分类头。主推 ResNet50。理由有三一是预训练权重获取方便PyTorch 官方仓库直接下载二是残差结构在中小规模数据集上不容易退化训练过程稳定三是论文里可以画出清晰的原理图答辩时好讲。MobileNetV3 的优势是轻量推理快适合后续部署到嵌入式设备但作为毕设主力模型ResNet50 的“正统感”更强。下面给出模型构建代码import torch.nn as nn import torchvision.models as models def get_model(num_classes, backboneresnet50, freeze_backboneTrue): if backbone resnet50: model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2) in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) elif backbone mobilenet_v3_large: model models.mobilenet_v3_large(weightsmodels.MobileNet_V3_Large_Weights.IMAGENET1K_V1) in_features model.classifier[-1].in_features model.classifier[-1] nn.Linear(in_features, num_classes) # 冻结 backbone只训练分类头 if freeze_backbone: for name, param in model.named_parameters(): if fc not in name and classifier not in name: param.requires_grad False return model这段代码里weightsmodels.ResNet50_Weights.IMAGENET1K_V2指的是 PyTorch 官方提供的 ImageNet 预训练权重版本V2 比 V1 的 top-1 准确率更高。freeze_backboneTrue时只训练最后的全连接层适合先快速跑通流程、验证数据没问题训练几轮之后如果想提点再解冻 backbone用更小的学习率微调全部参数。千万别一开始就全部解冻小数据集上大概率过拟合。3.2 训练脚本的主干与关键参数说明训练代码本身不复杂核心是交叉熵损失 AdamW 优化器 早停。先说为什么用交叉熵它就是LogSoftmax NLLLoss的组合直接输出每个类别的概率分布不需要在模型最后手动接 Softmax。优化器用 AdamW 而不是普通 Adam因为它把权重衰减从梯度更新里解耦了正则化效果更干净。import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder device torch.device(cuda if torch.cuda.is_available() else cpu) # 假设 train_dataset / val_dataset 已经用 transforms 构建好 train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue) model get_model(num_classes8, freeze_backboneFalse).to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) best_acc 0.0 patience 10 no_improve 0 for epoch in range(50): model.train() for images, labels in train_loader: images, labels images.to(device), labels.to(device) outputs model(images) # 前向传播 loss criterion(outputs, labels) # 计算损失 optimizer.zero_grad() # 清空上一轮梯度 loss.backward() # 反向传播 optimizer.step() # 更新参数 model.eval() correct, total 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, dim1) total labels.size(0) correct (predicted labels).sum().item() val_acc correct / total if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth) no_improve 0 else: no_improve 1 if no_improve patience: print(早停验证集准确率连续 10 轮未提升) break参数选择说明batch_size32在 ResNet50 上大约需要 6~8GB 显存没有独显的话降到 8 也能训练lr1e-4是全量微调时的安全值如果只训练分类头可以用1e-3weight_decay1e-4是 L2 正则的强度太大模型欠拟合太小防不住过拟合。早停的patience10意味着验证集准确率连续 10 轮不刷新就停止训练既省时间又防止继续跑导致验证集过拟合。代码里我保存的是state_dict()而不是整个模型这样换机器加载权重时不会被 Python 版本和类定义位置干扰。3.3 从 tfevents 文件读训练过程判断早停时机下载包里那一堆events.out.tfevents.*文件就是训练过程留下的 TensorBoard 日志。加载方式很简单tensorboard --logdirruns浏览器打开localhost:6006能看到 loss 曲线和 accuracy 曲线。看曲线时要关注三个点。第一训练 loss 和验证 loss 的间距如果训练 loss 一直降、验证 loss 走到低点后反弹说明模型开始过拟合最佳模型应该在验证 loss 的最低点附近取而不是最后一个 epoch。第二准确率曲线的震荡幅度舌苔数据通常类别不平衡准确率曲线上下跳是正常的看平滑后的趋势别被单轮抖动带偏。第三如果 loss 曲线从第 20 轮开始基本走平说明学习率需要衰减或模型容量已经到顶继续训练意义不大。这里的血泪经验是TensorBoard 不只是用来截图放论文的它最大的作用是让你知道“该不该停”。我见过太多人不管曲线直接跑满 50 个 epoch最后保存的模型比第 23 轮的差一大截。4. PyQt5 界面与端到端推理从按钮到鉴定结果4.1 推理函数先跑通再连界面做界面前先把推理逻辑抽成一个纯函数。这样你可以在命令行先验证单张图片再把它接到按钮事件上出问题好定位。推理代码必须和训练时的预处理保持一致这是最容易踩的坑训练时做了 CenterCrop识别时忘了做模型输入分布完全变了。import torch import torch.nn.functional as F from PIL import Image from torchvision import transforms class_names [淡红舌_薄白苔, 红舌_黄腻苔, 绛舌_少苔, 青紫舌_白腻苔, ...] infer_transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def predict(model, image_path, topk3): img Image.open(image_path).convert(RGB) tensor infer_transform(img).unsqueeze(0) # 转成 (1,3,224,224) model.eval() with torch.no_grad(): logits model(tensor) prob F.softmax(logits, dim1) top_prob, top_idx torch.topk(prob, topk, dim1) results [] for i in range(topk): idx top_idx[0][i].item() score top_prob[0][i].item() results.append((class_names[idx], score)) return resultsunsqueeze(0)是把单张图片补一个 batch 维度因为 PyTorch 的卷积层要求输入是四维张量。model.eval()必须调用它会把 Dropout 关闭、BatchNorm 切换到用全局统计量否则同一张图每次预测结果可能不一样。topk3返回概率最高的三个类别界面可以展示“第一候选、第二候选”这在舌苔识别里很实用——有些类别外观接近模型给出的第二候补对医生有参考价值。4.2 PyQt5 主窗口选图、显示、出报告PyQt5 界面是这类系统的门面。主窗口的逻辑很简单一个按钮弹出文件选择框一个 QLabel 显示图片一个 QLabel 或 QTextEdit 显示识别结果但代码细节里藏着几个容易崩的点。import sys from PyQt5.QtWidgets import (QApplication, QMainWindow, QPushButton, QLabel, QFileDialog, QVBoxLayout, QWidget) from PyQt5.QtGui import QPixmap from PyQt5.QtCore import Qt class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(舌苔识别检测鉴定系统) self.image_label QLabel(请选择图片) self.image_label.setAlignment(Qt.AlignCenter) self.result_label QLabel(识别结果将显示在这里) self.result_label.setWordWrap(True) self.btn_open QPushButton(打开图片) self.btn_predict QPushButton(开始识别) self.btn_predict.setEnabled(False) layout QVBoxLayout() layout.addWidget(self.btn_open) layout.addWidget(self.image_label) layout.addWidget(self.btn_predict) layout.addWidget(self.result_label) container QWidget() container.setLayout(layout) self.setCentralWidget(container) self.btn_open.clicked.connect(self.open_image) self.btn_predict.clicked.connect(self.run_predict) self.image_path None def open_image(self): path, _ QFileDialog.getOpenFileName( self, 选择舌苔图片, , 图片文件 (*.jpg *.jpeg *.png)) if path: self.image_path path pixmap QPixmap(path) # 缩放显示避免大图把窗口撑爆 pixmap pixmap.scaled(self.image_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) self.image_label.setPixmap(pixmap) self.btn_predict.setEnabled(True)QPixmap(path)直接传中文路径在 Windows 上可能加载失败这是 PyQt5 的老问题后面避坑章会给解决方案。pixmap.scaled()的三个参数是目标尺寸、保持宽高比、平滑缩放缺了 SmoothTransformation 图片边缘会出现明显锯齿。这里还用了一个小技巧开始识别按钮默认禁用只有选完图才启用避免用户没选图就点按钮导致空指针。4.3 在界面上呈现“鉴定报告”而不是干巴巴的 0/1分类模型输出的是一串数字索引对用户没有意义。界面上的“鉴定报告”要做一层翻译把类别名拆回“舌色、苔色、苔质”再把置信度格式化展示。如果第一置信度低于阈值还要给出“请重新拍摄”的提示这在第 6 章展开。def run_predict(self): if not self.image_path: return # 这里先用同步方式演示实际项目建议放 QThread见第 5 章 results predict(self.model, self.image_path, topk3) lines [] for i, (name, score) in enumerate(results, start1): # 假设类别名格式是 舌色_苔色_苔质 parts name.split(_) if len(parts) 2: tongue_color, coating parts lines.append(f候选 {i}舌色 {tongue_color} / 苔质 {coating}置信度 {score:.2%}) else: lines.append(f候选 {i}{name}置信度 {score:.2%}) self.result_label.setText(\n.join(lines))score:.2%把小数转成百分比并保留两位界面显示“置信度 92.30%”比“0.9230”直观得多。split(_)依赖前面定的组合标签格式如果训练时用了别的分隔符这里要同步改。这个函数虽然简单但把“模型输出”转化成了“人能看懂的报告”论文截图时也好看。5. 避坑与排查从训练到界面最常见的五个翻车点5.1 训练损失不降准确率也不动现象训练了十几个 epochloss 在 2.0 附近波动准确率在 10%~20% 徘徊。原因最常见的是学习率过大模型参数在最优解附近震荡跳不出来其次是标签编码错误类别标号和class_names对不上。解决先把学习率降到 1e-4 或 1e-5 跑几轮看趋势再到验证集上随机抽几张图打印模型的原始输出人工核对argmax的索引是不是对应正确类别。如果数据只有两类但模型最后全连接层写了 8也会出现这种问题检查num_classes。5.2 验证集准确率很高换手机拍的照片识别一塌糊涂现象测试集准确率 90% 以上复制一张手机随手拍的舌苔照片识别完全错误。原因训练集和测试集来自同一数据源光照、相机色彩、舌体占比高度相似模型实际学到的是“这个数据源的风格”而不是舌象特征拍照角度、色温一变分布偏移立刻暴露。解决训练阶段把数据增强里的ColorJitter强度加大一点brightness和contrast都提到 0.3 左右推理前把用户上传的图片也过一遍 CLAHE 光照均衡更彻底的办法是从一开始就混合多个来源的数据而不要只用一个拍摄环境。这也是为什么论文里一定要写数据来源和增强策略评阅老师问的就是这个。5.3 PyQt5 界面点击识别后卡死窗口无响应现象点击“开始识别”按钮后界面变白等十几秒才恢复。原因推理放在 GUI 主线程里执行ResNet50 前向传播阻塞了 Qt 的事件循环窗口自然卡死。解决把推理丢到QThread里通过信号把结果传回主线程。from PyQt5.QtCore import QThread, pyqtSignal class PredictWorker(QThread): result_ready pyqtSignal(list) def __init__(self, model, image_path): super().__init__() self.model model self.image_path image_path def run(self): results predict(self.model, self.image_path, topk3) self.result_ready.emit(results)然后按钮的响应函数改成启动线程而不是直接调预测。注意self.model要在主线程加载一次传给 Worker 复用千万不要每次识别都重新加载权重文件否则 GPU 显存会被反复占用CPU 机器上则会造成明显的启动卡顿。5.4 图片路径含中文导致程序崩溃现象用cv2.imread()读图返回None或者QPixmap(path)显示空白。原因OpenCV 和 Qt 在 Windows 上处理中文路径时编码不一致cv2.imread只认字节串中文路径会转失败。解决OpenCV 改用cv2.imdecode(np.fromfile(path, dtypenp.uint8), 1)PyQt5 里先把路径用os.path.abspath规范化再传给QPixmap。这个坑在毕设答辩演示时最容易触发因为答辩评委的图片很可能放在中文目录下。5.5 GPU 显存不够OOM 报错现象训练刚开始就报CUDA out of memory。原因batch_size 太大或同时加载了多个模型。解决把batch_size降到 16 或 8图片输入尺寸从 224 降到 192 也能省显存但验证时不要再降如果在跑界面推理模型加载后用torch.no_grad()包裹前向传播并且只保留一份权重。没有独立显卡的机器训练用 CPU 也能跑把num_workers调大、模型换成 MobileNetV3一个 epoch 慢但能出结果。6. 让鉴定结果更可信阈值过滤与注意力可视化模型给出“红舌_黄腻苔置信度 92%”不是终点还得回答一个质疑这个结论靠谱吗两个手段常用置信度阈值过滤和热力图可视化。置信度阈值过滤很容易实现。在predict()的基础上加一道判断如果最高概率低于某个阈值不返回具体类别而是提示用户重新拍摄。阈值可以用验证集来定——把验证集里所有样本按类别分开统计每个类别的平均置信度取一个能让绝大多数正确样本通过、错误样本被拦下的值。舌苔识别的合理阈值一般在 0.6~0.8 之间因为类别本身有相似性定太高会导致大量图片“无法识别”定太低则失去过滤意义。def predict_with_threshold(model, image_path, threshold0.65): results predict(model, image_path, topk3) top_label, top_score results[0] if top_score threshold: return None, top_score # 置信度不足不给出鉴定结论 return top_label, top_score返回None时界面显示“置信度不足请调整光线后重新拍摄”。这个提示比给一个错误的鉴定结果更负责任论文里也可以专门写一节“基于置信度阈值的结果可靠性分析”。另一个进阶验证是热力图可视化目的很直接确认模型是在看舌头而不是在看背景或者相框。一种简单的做法是把 ResNet50 最后一个残差块输出的特征图和全连接层的类别权重做加权求和得到一张和原图尺寸一致的注意力图叠加在原图上展示。import torch.nn.functional as F def grad_cam_style_map(model, tensor): # 提取 layer4 输出的特征图 features model.layer4(tensor) # 取目标类别的全连接层权重假设类别索引为 cls_idx cls_idx 0 fc_weight model.fc.weight[cls_idx] # 形状 (512,) # 全局平均池化得到通道权重 pooled F.adaptive_avg_pool2d(features, (1, 1)) # 形状 (1,512,1,1) pooled pooled.view(pooled.size(0), -1) # 特征图与权重加权求和得到 (1,1,H,W) heatmap torch.sum(features * fc_weight.view(1, -1, 1, 1), dim1, keepdimTrue) heatmap F.relu(heatmap) heatmap F.interpolate(heatmap, size(224, 224), modebilinear, align_cornersFalse) return heatmap[0, 0]这段代码是 Grad-CAM 的精简版胜在不需要额外依赖。model.layer4是 ResNet50 最后一个残差阶段的输出空间分辨率 7×7语义信息最丰富fc_weight是目标类别的分类权重它决定了哪些特征通道对这个类别更重要F.interpolate把 7×7 的热力图放大回 224×224 以便叠加。把热力图转成伪彩色叠加到原图上如果高亮区域集中在舌体中央说明模型学到了“舌苔特征”如果高亮区域在图片角落或背景上说明训练数据里背景信息泄漏了必须回炉清洗数据。我自己的习惯是每次训练完专门挑十张验证集图片和十张真实场景图片跑一遍热力图肉眼看完再决定要不要改数据。这个习惯救过我两次——一次发现模型在学手机水印另一次发现暗光环境下模型完全靠亮度判断“淡白舌”都属于典型的学习到错误特征。从那以后我每次做分类项目都强制走一遍热力图检查。这套系统你拿到手后建议也按这个顺序验证先看 TensorBoard 确认训练曲线再用 PyQt5 界面跑真实图片最后用热力图确认模型关注区域。三条线都对上了论文里的图表和答辩时的演示才站得住。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网