
简介基于深度学习的舌苔识别检测鉴定系统是一套面向计算机专业毕业设计、课程设计和期末大作的完整项目方案也适合想通过实战掌握深度学习图像识别的学习者。资源压缩包共109个文件、约105.44MB其中包含26个Python源码、2个UI界面文件、6个训练好的模型权重文件pth、7张测试图片、5个JSON配置以及2份Word毕业论文文档等还附有TensorFlow训练事件记录便于查看训练过程与指标变化。整体代码注释完整、结构清晰从数据加载、模型构建、训练评估到PyQt5图形界面交互形成闭环可帮助用户快速跑通舌苔识别检测流程。目前已有150人学习下载。项目附带的毕业论文文档和源码可直接复用适合作为开题参考或功能扩展基础也为正在准备答辩和动手实操的同学提供了可落地的示例。1. 舌苔识别这个毕设题为什么说难点不在模型而在数据舌苔识别检测鉴定系统听上去像是给中医诊断做个“AI眼睛”但真把它当深度学习项目做下去你会发现模型反而不是最头疼的部分。拿 PyTorch 写一个分类或者检测网络半天就能跑通真正折磨人的是舌苔图像这种小样本医学数据——类别不均衡、标注标准不统一、样本之间差异小到肉眼都容易判断错。这套基于深度学习的舌苔识别系统本质上是把“图像分类/检测”这个成熟套路迁移到中医舌诊这个特定垂直场景里再配一个 PyQt5 桌面界面把模型包起来形成一套可演示、可答辩的完整毕设作品。如果你正准备做毕业设计或者想快速把深度学习套到医学图像识别上这个题的性价比相当高技术栈全是公开资料PyTorch 预训练模型随便挑数据集也不需要自己标注到医学级标准参考教材和公开舌象图谱就能起步。它能一次性覆盖数据预处理、模型训练、模型部署、GUI 开发、论文撰写五个环节是典型的“一个项目串起整个专业课程”的题目。接下来我按自己做过类似项目的顺序从数据准备讲到界面集成把每一步的选型理由、关键参数和踩过的坑一次说清楚。2. 舌苔图像数据集从采集到预处理的最小可用方案2.1 舌苔分类任务的类别设计分几类依据是什么舌苔识别首先是个分类问题不是目标检测。检测要画框定位舌体分类只需要判断整张图属于哪一类。毕设题目里写“检测鉴定”大多数实现其实是“分类 定位”的混合先用分类网络判断舌苔类型再用热力图或者检测框把舌体区域标出来。我建议第一步先把分类做扎实分类准确率能到 90% 以上再考虑往上叠加检测框。类别设计直接决定数据集标注成本和模型收敛难度。常见的舌苔分类按中医舌诊教材大致分 5 到 6 类白苔、黄苔、灰黑苔、厚腻苔、剥苔少苔再加一个正常舌象作为对照组。这是一个比较稳妥的划分因为每类之间的颜色、纹理差异相对明显模型容易学到特征。如果你把类别细分到“薄白苔”“白厚腻苔”“黄腻苔”这种粒度样本量不够的话模型基本会翻车——类间差异小类内差异反而大准确率可能只有六成。我一般会在项目里把类别数量做成配置项放在一个常量文件里方便后面改# config.py CLASS_NAMES [normal, white, yellow, gray_black, thick_greasy, peeled] NUM_CLASSES len(CLASS_NAMES)这个设计的思路是训练阶段只关心类别索引显示阶段用 CLASS_NAMES 把索引映射成中文标签。后面加类别、换数据都不用动训练代码。2.2 数据预处理尺寸、归一化、增强策略舌苔图像和 ImageNet 通用图片不一样它的有效信息集中在舌头区域背景往往占了三分之一以上。直接整图缩放会让模型把背景当特征学这是新手最容易踩的坑。所以预处理第一步不是缩放而是裁剪——手动或者用简单规则把图像中心区域裁出来去掉大部分嘴唇、面部和背景。常见做法是裁掉上下各 15%、左右各 10%因为舌体基本落在图像中心偏下位置。裁剪之后统一缩放常见做法是 Resize 到 224×224对应 ResNet 系列默认输入尺寸。归一化套用 ImageNet 的 mean 和 std因为后面要用预训练权重输入分布必须跟预训练时一致。# dataset.py import torch from torch.utils.data import Dataset from torchvision import transforms from PIL import Image class TongueDataset(Dataset): def __init__(self, image_paths, labels, modetrain): self.image_paths image_paths self.labels labels self.mode mode if mode train: self.transform transforms.Compose([ transforms.CenterCrop(int(224 * 0.75)), # 先裁掉周围背景 transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(10), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) else: self.transform transforms.Compose([ transforms.CenterCrop(int(224 * 0.75)), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img Image.open(self.image_paths[idx]).convert(RGB) label self.labels[idx] return self.transform(img), label这段代码里有几个参数值得注意。CenterCrop 的 0.75 比例是我调过的经验值太大把舌体边缘裁掉太小背景残留过多0.75 对大多数舌象图片都是安全的。RandomRotation 只给了 10 度因为医学图像不像自然图像旋转太多违背生理结构。ColorJitter 的亮度和对比度扰动对舌苔识别这类颜色敏感任务尤其关键——不同手机拍摄的舌象颜色差异很大不增强的话模型对颜色非常脆弱。测试集不能做 Random 增强只做 CenterCrop这是为了验证时结果稳定可复现。2.3 数据集划分与标注文件格式毕设项目的数据量一般不会太大公开的舌象数据集大多在几千到一万张量级。标注文件我建议用最简单的 CSV 格式每行一条路径和一个数字标签不引入 JSON 或 XML后面调试和论文里展示起来都方便。划分比例按 8:1:1 做训练集、验证集、测试集但要注意按类别分层抽样。舌苔数据集的类别分布天然不均衡——正常舌象和薄白苔样本多灰黑苔样本可能只有几十张。直接随机划分会让某个类别在验证集里恰好缺失模型在这个类上的表现就成了黑匣子。用 sklearn 的 train_test_split 加 stratify 参数就能按比例保留每个类别的样本量。# split_data.py import pandas as pd from sklearn.model_selection import train_test_split df pd.read_csv(tongue_labels.csv) # 列: image_path, label train_df, temp_df train_test_split( df, test_size0.2, stratifydf[label], random_state42 ) val_df, test_df train_test_split( temp_df, test_size0.5, stratifytemp_df[label], random_state42 ) train_df.to_csv(train.csv, indexFalse) val_df.to_csv(val.csv, indexFalse) test_df.to_csv(test.csv, indexFalse)random_state 固定成 42 是为了论文里的实验结果可复现。审阅老师看到你换了随机种子准确率上下浮动两个点会质疑实验的稳定性。stratify 参数传入的是标签列sklearn 会按各类别占比做分层保证训练集、验证集、测试集里每一类的相对比例一致。3. 模型选型与训练从 ResNet 到可部署的权重文件3.1 模型骨架选择为什么不是越深越好舌苔识别是典型的“小数据 强迁移”任务。用 ResNet50 甚至 ResNet101 在几千张图上从零训练几乎必然过拟合——训练集准确率能跑到 99%验证集卡在 75% 上下不去。常见的做法是用 ImageNet 预训练权重做迁移学习锁定前几层只训练后面几层或者微调全部参数但学习率调小。我一般会选 ResNet34 或者 ResNet18理由不是性能而是这个数据量配上这两个模型收敛速度和最终精度最均衡。ResNet50 的参数量是 ResNet18 的三倍以上在舌苔这种特征相对简单的图像上拿不到多少精度收益反而训练和推理时间都变长。如果后续要跑实时摄像头识别ResNet18 在 CPU 上单帧推理也能控制在 100 毫秒左右桌面端体验好很多。3.2 训练脚本关键参数和学习率策略训练脚本的核心是几块加载预训练模型、替换全连接层、设置损失函数、配置优化器然后循环训练并保存最优权重。下面这个脚本直接可以跑注释标了需要根据实际数据调整的地方。# train.py import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import models from dataset import TongueDataset # 1. 加载预训练模型替换分类头 model models.resnet34(weightsmodels.ResNet34_Weights.IMAGENET1K_V1) in_features model.fc.in_features model.fc nn.Linear(in_features, NUM_CLASSES) # 2. 冻结前几层只训练后面 Block 和全连接层 for name, param in model.named_parameters(): if layer4 not in name and fc not in name: param.requires_grad False # 3. 优化器只传入 requires_gradTrue 的参数 optimizer optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr1e-4, weight_decay1e-4 ) criterion nn.CrossEntropyLoss() # 4. 学习率每 10 个 epoch 衰减一半 scheduler optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.5) # 5. 训练循环简化版实际还要加验证和模型保存 for epoch in range(30): model.train() running_loss 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() scheduler.step() print(fEpoch {epoch1}, Loss: {running_loss/len(train_loader):.4f})冻结策略这里有个讲究我只冻到 layer4 之前放开 layer4 和全连接层参与微调。layer4 学习的是高层语义特征比如舌苔的纹理和颜色组合前面几层学到的是通用的边缘和纹理基元不需要在几千张图上重新学。学习率设 1e-4 而不是默认的 1e-3因为微调阶段的参数已经在 ImageNet 上收敛到一个比较好的位置学习率大了会把权重冲走。损失函数用 CrossEntropyLoss如果类别不均衡严重可以给 loss 加权重。PyTorch 的 CrossEntropyLoss 支持传一个 weight 参数比如灰黑苔样本少就给它更高的权重代价是模型会更多预测这个类精确率会受影响。这个要根据验证集的混淆矩阵来调不能一开始就凭感觉设。3.3 导出模型TorchScript 比你想的更适合 PyQt5 集成训练完保存模型有两种做法直接保存 state_dict或者导出 TorchScript 格式。PyQt5 集成阶段TorchScript 格式省很多事情——不需要在界面代码里定义模型类结构不需要管理预训练权重的加载方式一个 torch.jit.load 就能把模型完整还原出来。# export.py import torch from train_model import model # 假设你已经加载好训练好的模型 model.eval() example_input torch.randn(1, 3, 224, 224) traced_model torch.jit.trace(model, example_input) traced_model.save(tongue_model.pt)这里有个细节torch.jit.trace 是按实际输入路径“记录”模型计算图的如果模型内部有依赖数据的分支逻辑比如 if 条件判断trace 可能记录不完整。ResNet 没有这类结构所以可以直接 trace。导出前一定要 model.eval()把 dropout 和 batchnorm 切到推理模式否则导出模型的行为和训练时一样预测结果会不稳定。batch_size 固定成 1 再导出会更稳妥避免动态维度带来的兼容性问题。4. PyQt5 界面集成把模型变成能演示的桌面应用4.1 界面功能规划和线程模型毕设答辩时老师不会看你的训练代码但一定会看界面。PyQt5 界面在舌苔识别项目里承担三个功能上传图片识别、摄像头实时识别、识别结果可视化展示。核心界面元素就是三个——图像显示区域QLabel、识别按钮QPushButton、结果信息区域QPlainTextEdit 或 QTableWidget。界面规划中最容易犯的错是直接在按钮点击事件里跑模型推理。PyTorch 模型推理在 CPU 上大概需要 50 到 200 毫秒这个时间会让 Qt 的事件循环卡住界面表现为“未响应”。常见做法是引入 QThread 做后台推理线程推理完成后再通过信号把结果传回主线程更新 UI。这个思路同时服务于静态图片和摄像头识别两种模式。4.2 静态图片识别从文件选择到结果展示静态图片识别是毕设里最核心的演示功能也是后面摄像头识别的基座。我一般会把模型推理封装成一个独立的 worker 类界面层只负责触发和接收结果。# inference_worker.py from PyQt5.QtCore import QThread, pyqtSignal import torch from torchvision import transforms class InferWorker(QThread): result_ready pyqtSignal(str, float, str) # 类别名, 置信度, 耗时 error_occurred pyqtSignal(str) def __init__(self, model_path, image_path, parentNone): super().__init__(parent) self.model torch.jit.load(model_path, map_locationcpu) self.model.eval() self.image_path image_path self.transform transforms.Compose([ transforms.CenterCrop(int(224 * 0.75)), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def run(self): try: from PIL import Image img Image.open(self.image_path).convert(RGB) tensor self.transform(img).unsqueeze(0) with torch.no_grad(): import time start time.time() outputs self.model(tensor) elapsed (time.time() - start) * 1000 probs torch.softmax(outputs, dim1) confidence, idx torch.max(probs, dim1) class_name CLASS_NAMES[idx.item()] self.result_ready.emit(class_name, confidence.item(), f{elapsed:.1f}ms) except Exception as e: self.error_occurred.emit(str(e))这段代码有几点实践上的考量。模型在 worker 的构造函数里加载而不是在 run 里加载是为了避免每次识别都要重新读取模型文件——舌苔模型文件几十兆磁盘加载几百毫秒放到循环里会让摄像头识别“一顿一顿”的。置信度不会真的 top-1 概率我用 torch.max 取了最高概率项如果你想展示前三个候选类别改成 torch.topk 就行。耗时统计用 time.time() 包住推理部分而不是整段 run是为了后面论文里能写干净的推理耗时数据。界面主窗口这边的代码就是标准的 PyQt5 写法创建按钮、绑定信号、启动线程、在槽函数里更新标签。# main_window.py from PyQt5.QtWidgets import (QMainWindow, QPushButton, QLabel, QFileDialog, QVBoxLayout, QWidget) from inference_worker import InferWorker class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(舌苔识别检测鉴定系统) self.image_label QLabel(请选择图片) self.result_label QLabel(识别结果: 待识别) self.btn_open QPushButton(打开图片) self.btn_open.clicked.connect(self.open_image) layout QVBoxLayout() layout.addWidget(self.image_label) layout.addWidget(self.result_label) layout.addWidget(self.btn_open) container QWidget() container.setLayout(layout) self.setCentralWidget(container) self.worker None def open_image(self): path, _ QFileDialog.getOpenFileName( self, 选择舌苔图片, , 图片文件 (*.jpg *.png *.jpeg) ) if not path: return self.result_label.setText(识别中...) self.worker InferWorker(tongue_model.pt, path) self.worker.result_ready.connect(self.show_result) self.worker.start() def show_result(self, class_name, confidence, elapsed): self.result_label.setText( f识别结果: {class_name}\n置信度: {confidence:.2%}\n耗时: {elapsed} )注意这里每个 worker 都新建了一个实例而不是复用同一个。原因是 QThread 在 run 返回后线程就结束了不能 start 两次。如果每次识别都新建线程旧线程会被 Python 的垃圾回收机制清理不会有明显的内存泄漏。论文里的内存优化部分如果想多写一段可以改成线程池复用但毕设阶段没必要。4.3 摄像头实时识别帧率优化和 UI 刷新策略摄像头识别是答辩现场最容易出彩的功能也是最容易翻车的功能。用 OpenCV 打开摄像头捕捉画面每隔 N 帧做一次推理再把结果画到画面上逻辑上不复杂但实现起来有性能陷阱。# camera_worker.py from PyQt5.QtCore import QThread, pyqtSignal import cv2 import torch from torchvision import transforms class CameraWorker(QThread): frame_ready pyqtSignal(object) # 处理后的帧 result_text pyqtSignal(str) def __init__(self, model_path, parentNone): super().__init__(parent) self.model torch.jit.load(model_path, map_locationcpu) self.model.eval() self.running True self.transform transforms.Compose([ transforms.ToPILImage(), transforms.CenterCrop(int(224 * 0.75)), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def run(self): cap cv2.VideoCapture(0) frame_count 0 while self.running: ret, frame cap.read() if not ret: continue frame_count 1 if frame_count % 3 ! 0: # 每 3 帧推理一次 self.frame_ready.emit(frame) continue tensor self.transform(frame).unsqueeze(0) with torch.no_grad(): outputs self.model(tensor) probs torch.softmax(outputs, dim1) conf, idx torch.max(probs, dim1) label f{CLASS_NAMES[idx.item()]}: {conf.item():.1%} self.result_text.emit(label) self.frame_ready.emit(frame) cap.release() def stop(self): self.running False摄像头识别的核心调优点是“跳过推理帧数”。普通笔记本摄像头是 30 帧每秒CPU 推理一次要 100 多毫秒意味着每帧都推理的话帧率会掉到 5 帧左右画面非常卡。每 3 帧推理一次画面平滑度提升明显识别结果只滞后不到 100 毫秒人眼完全无感。这个参数我建议做成界面上的下拉框从 1 到 5 可调答辩现场可以演示“连续识别模式下帧率和准确率的权衡”。还有一点摄像头画面的 CenterCrop 比例可以调回到 0.6因为摄像头画面里舌体占的比例比单独拍摄的舌苔照片小很多裁太多可能把舌头切没。5. 舌苔识别系统中的 4 个高频翻车点现象、原因、解决5.1 模型在验证集上准确率很高测试集上却一塌糊涂这是一个非常典型的“复制粘贴训练脚本”事故。现象是验证集准确率 93%测试集准确率只有 70% 出头。原因通常是验证集和训练集来自同一批数据的随机划分而测试集来自另一批拍摄条件完全不同的图片——手机拍摄角度、光线色温、图片压缩率都不一样。解决的办法是检查数据划分是否真的随机以及训练集和测试集是否混入了同一个来源批次的图片。我一般会把测试集图片单独放在一个子目录里手动检查有没有和训练集重复的文件。论文里这个部分可以写“跨域鲁棒性验证”也算一个加分的小点。5.2 灰黑苔类别几乎不被预测准确率为 0舌苔数据集最常见的不均衡——白苔和正常舌象占了六成以上灰黑苔只有几十张。现象是训练的模型无论给什么图都预测成“白苔”因为整体准确率已经很高交叉熵损失不会对少数类产生足够大的惩罚梯度。解决思路有两个一是用加权交叉熵给少数类 2 到 3 倍的损失权重二是做离线过采样——复制少数类样本进训练集配合随机增强让同一样本在每轮 epoch 中呈现不同的形态。我实测下来过采样比加权更方便观察因为混淆矩阵里的数字更直观。5.3 PyQt5 界面点击识别后整个窗口卡死现象是点击“识别”按钮后窗口变白系统提示“程序未响应”几秒后恢复或者彻底崩溃。原因是推理放在了主线程模型加载加推理合计几百毫秒超出了 Qt 事件循环的可忍受范围。解决就是用前面写的 QThread 方案把加载模型、预处理、推理全部丢到子线程主线程只负责接收信号更新 UI。这里有一个容易忽略的细节worker 对象必须保存为类成员self.worker不能是局部变量——局部变量在函数返回后被回收线程还没跑完对象就没了会出现程序直接崩溃的玄学问题。5.4 torch.load 加载模型报错不匹配的键和意外的键现象是加载训练好的权重时PyTorch 报错提示缺少某个 key 或者多了某个 key。原因有两个一个是训练时改了模型结构但没重新保存权重另一个是保存的 state_dict 来自 DataParallel 包装后的模型key 比普通模型多了 module. 前缀。第二个原因尤其坑因为单卡训练的人很少注意到 DataParallel 的影响。解决办法是加载时做个 key 名替换把 module. 前缀删除再 load或者直接导出 TorchScript 格式绕开这个坑。# fix_state_dict.py state_dict torch.load(checkpoint.pt, map_locationcpu) from collections import OrderedDict new_state_dict OrderedDict() for k, v in state_dict.items(): name k[7:] if k.startswith(module.) else k # 去掉 module. 前缀 new_state_dict[name] v model.load_state_dict(new_state_dict)6. 进阶验证与论文加分用 Grad-CAM 热力图解释模型舌苔识别的论文最难写的部分是“分析模型学到了什么”。深度学习模型是个黑匣子只给混淆矩阵和准确率曲线导师会觉得论证单薄。常见做法是给模型加上 Grad-CAM 可视化把模型判断“白苔”时的注意力区域标出来。如果模型的热力图集中在舌体中央而不是背景皮肤上就说明它学到了有意义的特征这一页图在毕设论文里能顶一整章分析。Grad-CAM 的原理不复杂取模型最后一个卷积层的特征图用分类得分对该层梯度做全局平均池化得到权重再和特征图加权求和得到热力图。# grad_cam.py import torch import cv2 import numpy as np from torchvision import transforms def grad_cam(model, image_path, target_layer, class_idxNone): model.eval() img cv2.imread(image_path) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) transform transforms.Compose([ transforms.ToPILImage(), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) input_tensor transform(img_rgb).unsqueeze(0) input_tensor.requires_grad_(True) # 注册前向钩子和反向钩子获取目标层的特征图和梯度 features {} gradients {} def forward_hook(module, input, output): features[value] output def backward_hook(module, grad_input, grad_output): gradients[value] grad_output[0] layer_handle target_layer.register_forward_hook(forward_hook) grad_handle target_layer.register_full_backward_hook(backward_hook) output model(input_tensor) if class_idx is None: class_idx torch.argmax(output, dim1).item() model.zero_grad() output[0, class_idx].backward() layer_handle.remove() grad_handle.remove() weights gradients[value].mean(dim(2, 3), keepdimTrue) cam (weights * features[value]).sum(dim1, keepdimTrue) cam torch.relu(cam).squeeze().detach().numpy() cam cv2.resize(cam, (img.shape[1], img.shape[0])) cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8) return img, cam使用 torchvision 内置的 ResNet 模型时target_layer 一般传 model.layer4[-1]。如果你最后用了其他结构的模型这个层名需要自己打印 model.named_modules() 确认。register_full_backward_hook 是 PyTorch 1.8 之后的写法老版本要用 register_backward_hook两者在 grad_output 的含义上有细微差别。论文里写实验环境时PyTorch 版本必须如实写清楚有同学因为版本写错被导师要求重新跑实验。论文里除了 Grad-CAM还建议补一张每类样本的置信度分布箱线图。这个图能直观展示模型对哪一类判断最犹豫比大段文字更有说服力。验证方法上给每个类别算 precision、recall、F1-score 构成一张表放在论文实验部分基本就是标准的医学图像识别论文配置了。最后说一句我在类似项目上的血泪经验舌苔识别这个方向数据质量永远比模型先进重要。先把数据集整理到每个类别都有独立文件夹、文件名规范、标注无错再动模型能省下后面一半的调试时间。PyQt5 界面部分不要追求复杂——一个能演示识别流程、能截图放进论文的简洁界面比花里胡哨的自定义控件更稳。希望帮到你。本文还有配套的精品资源点击获取