简介:基于Python的深度学习舌苔识别系统是一份面向高校人工智能、计算机相关专业毕业设计及医学图像入门研究的完整工程资源。系统以卷积神经网络为特征提取主干,引入迁移学习优化训练效率,并基于PyQt开发图形界面,支持舌象图像导入、实时病理分析及可视化报告生成。压缩包含131个文件,涵盖Python源码、pth模型权重、PyQt的ui界面设计、json配置、jpg/png样本图像、docx论文文档及tfevents训练日志等,整体约105.67MB,模块化目录便于定位算法、界面与模型文件。项目提供端到端的舌象病理特征自动识别流程、完整的模型再训练接口,以及准确率、召回率等多维度评估体系,配套论文对数据预处理、网络结构与训练策略均有系统论述,可作为毕业设计参考或医学AI课程综合训练案例。目前已有70人浏览学习,适合需要快速搭建舌苔识别原型、研究深度学习医疗应用的读者。
1. 舌苔识别这事,难的不是模型而是把模型塞进 GUI
做基于 Python 的深度学习舌苔识别系统,很多人以为难点在模型,等真动手才发现,训练一个能跑的分类网络是最顺的一段路,真正折磨人的是把 PyTorch 模型装进 GUI、让不懂深度学习的用户能点开就用。这个项目就是典型的全链路工程:数据预处理、CNN 训练、模型导出、界面集成、打包发布,每一步都踩得到坑。适合三类人:拿它做毕业设计的学生,想练完整项目经验的新手,以及想复用一套舌象分类工程做二次开发的从业者。这套思路不挑任务,换成皮肤分类、眼底图像筛查,流程完全一样。
2. 数据集与预处理:先把舌苔图片变成模型能吃的张量
2.1 舌苔数据集从哪里来、怎么划分
舌苔识别的公开数据集不算多,常见做法是从学校开源项目、Kaggle 医学图像合集里收集,或者自己拍。总量几百到几千张都行,关键是类别要符合你的标签体系——有的人按舌色分(淡红、红、绛红),有的人按苔质分(薄白、黄腻、剥苔),这个项目里我一般建议按 2 到 5 类做,类别太多小数据集扛不住。
数据集划分很关键,我常用的比例是 7:2:1,分别对应 train、val、test。训练集负责学特征,验证集用来挑超参和做早停,测试集只碰一次,模拟真实的识别效果。划分时还要注意同一张舌象的多个角度图必须放在同一个集合里,不能把一个人的图同时分进训练集和验证集,否则验证集会被“作弊”——模型见过这个人,验证准确率虚高。
划分脚本不复杂,一个纯 Python 的 random 操作就行,但必须先保证目录组织是对的。常见做法是按类别建文件夹:
datasets/ ├── train/ │ ├── class_0/ │ │ ├── 001.jpg │ │ └── 002.jpg │ ├── class_1/ │ └── class_2/ ├── val/ └── test/这种结构直接用文件夹名当标签,加载代码简单,也不容易出错。我见过有些人图省事用 CSV 记录标签,代码写起来反而啰嗦,还得维护两份映射关系。
2.2 预处理函数:归一化、尺寸统一与数据增强
舌苔图片是自然光下拍的,各设备白平衡不一样,这在深度学习里属于标准的图像分类问题,所以预处理直接套用 ImageNet 那套经验即可。统一到 224×224,归一化用 ImageNet 的 mean 和 std,因为后面要加载在 ImageNet 上预训练过的模型权重,数据分布必须对得上。
import cv2 import numpy as np from torchvision import transforms # 训练集:带增强,防过拟合 train_transform = transforms.Compose([ transforms.RandomRotation(degrees=10), # 小幅旋转,模拟拍摄角度偏差 transforms.RandomHorizontalFlip(p=0.5), # 水平翻转,舌象左右对称可安心用 transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 验证/测试集:只做尺寸统一和归一化 eval_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]) ])这里有个细节:增强只加在训练集,验证集和测试集不能做 RandomRotation、RandomHorizontalFlip 这类随机操作,否则每次跑出来的评估结果都不一样,你没法判断模型到底是变好了还是随机扰动的影响。ColorJitter 的 brightness 和 contrast 参数别调太大,舌苔颜色本身是重要特征,增强过度会把模型搞糊涂。
2.3 数值稳定与加载:一个把坑堵死的 DataLoader 封装
预处理写完后,下一步是用 DataLoader 把数据源接进训练循环。这里有一个特别容易翻车的地方:训练集和验证集的 transform 不同,但很多人偷懒只写一个 transform,最后模型在训练集上 acc 99%,验证集上 acc 60%,还不明白怎么回事。
from torch.utils.data import DataLoader from torchvision import datasets, transforms def build_dataloader(data_root, batch_size=32): train_dataset = datasets.ImageFolder( root=os.path.join(data_root, 'train'), transform=train_transform ) val_dataset = datasets.ImageFolder( root=os.path.join(data_root, 'val'), transform=eval_transform ) train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, num_workers=4, drop_last=True) val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False, num_workers=4) return train_loader, val_loader, train_dataset.classesbatch_size 在显存不够时要往下调,很多人跑 ResNet18 直接上 64 或 128,结果显存溢出。默认 32 是安全的,入门项目定量上 32 够了。num_workers 在 Windows 上建议改成 0,否则某些环境下会报 DataLoader worker 相关的错误。drop_last=True 是为了防止最后一个 batch 样本数太少导致 BN 层统计量抖动,训练集用它,验证集不要用。
3. 模型选型与训练:ResNet18 为什么是舌苔识别的甜点位
3.1 选型理由:小数据集上 ResNet 比 VGG 稳
舌苔识别的特征集中在颜色、纹理、厚薄区域,属于中低层视觉特征,不需要特别深的网络。常见做法是拿 torchvision 里预训练好的 ResNet18 做骨干,把最后一层全连接改成自己的分类数。选 ResNet18 有三个理由:参数只有 1170 万左右,CPU 上推理能接受;残差结构在小数据集上不容易退化;在 ImageNet 上预训练过的特征对舌苔纹理这种视觉模式有很强的迁移能力。
VGG16 在这个场景下我不推荐,参数多达 1.38 亿,训练慢,推理也慢,而且在小数据集上更容易过拟合。更深一点的 ResNet50 也不是不能用,但舌苔数据集通常只有几百到几千张,深网络的优势发挥不出来,反而要花更多时间调正则化。
import torchvision.models as models import torch.nn as nn def create_model(num_classes=4, pretrained=True): model = models.resnet18(weights='IMAGENET1K_V1' if pretrained else None) in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(p=0.2), # 防止过拟合,常见做法是放在全连接前 nn.Linear(in_features, num_classes) ) return model这里把原来的全连接层换成 Dropout 加 Linear,是因为小数据集上 fc 层最容易过拟合。Dropout 的 p 值我一般用 0.2,不要开到 0.5 以上,舌苔图像特征本来就不强,丢太多信息会欠拟合。
3.2 训练配置:优化器、学习率与早停
训练配置我习惯直接抄一套成熟配方:SGD + momentum 0.9,学习率从 0.001 开始,配合 ReduceLROnPlateau 在验证集 loss 不降时衰减;batch size 设 32;训练最多 50 个 epoch,早停 patience 设 10。用 Adam 也行,但在这个数据规模下 SGD 对学习率的敏感度更低,不容易跑飞。
import torch import torch.nn as nn from torch.optim import lr_scheduler def train_model(model, train_loader, val_loader, epochs=50): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.001, momentum=0.9, weight_decay=1e-4) scheduler = lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.1, patience=5) best_loss = float('inf') patience_counter = 0 for epoch in range(epochs): model.train() running_loss = 0.0 for inputs, labels in train_loader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() val_loss = evaluate(model, val_loader, criterion, device) scheduler.step(val_loss) if val_loss < best_loss: best_loss = val_loss torch.save(model.state_dict(), 'best_model.pth') patience_counter = 0 else: patience_counter += 1 if patience_counter >= 10: print(f'早停在第 {epoch + 1} 轮') breakweight_decay 是 L2 正则,这个值很影响结果。1e-4 是常见做法,太小等于没用,太大会把所有权重压到接近 0 导致欠拟合。ReduceLROnPlateau 的 patience 设 5 的意思是验证集 loss 连续 5 轮不降就把学习率乘以 0.1,早停的 patience 设 10 则是在学习率衰减后仍不见好转就彻底停掉。
3.3 训练曲线的判读:loss 和 acc 对不上的时候怎么处理
训练完第一件事不是看准确率,是看曲线。干净的训练曲线应该是训练 loss 稳步下降、验证 loss 先降后平,两条线之间距离不大。常见的异常有几种:
训练 loss 降了但验证 loss 一直涨,这是典型的过拟合,处理顺序是:先加数据增强强度,再加 Dropout,最后才考虑缩小模型。反过来训练 loss 都不降,先看学习率是不是太小,再看标签有没有错——舌苔图类别之间长得太像,标注时经常把薄白苔和厚白苔混在一起。
还有一种玄学现象是 validation loss 在某个节点突然跳高又恢复,这通常是验证集里混入了质量很差的图(比如对焦模糊、舌头没伸出来),不一定是模型问题。我一般会把这轮出错的样本打出来看一眼,很多“模型有问题”最后都是数据问题。这个习惯能在后面 GUI 阶段帮你省下大量排查时间。
4. 模型导出与推理封装:从 .pth 到 ONNX 的落地之路
4.1 为什么必须转 ONNX:PyTorch 的推理环境太笨重
模型训练完是 .pth 权重,但在 GUI 里直接加载它意味着用户环境得装好 PyTorch、torchvision、gcc 那一整套东西,动辄几个 GB,装完还有各种版本冲突。转成 ONNX 之后,推理只需要 onnxruntime 一个包,CPU 版本才几十 MB,而且推理速度通常比 PyTorch 的 CPU 模式快一截。
ONNX 是模型的中间格式,相当于把网络结构、权重、算子全部固化成一个文件。常见做法是训练完立即导出一次,导出时记下输入尺寸和输出张量结构,这些参数后面写推理封装时要用。注意导出和推理用的版本要匹配,PyTorch 官方文档建议 opset_version 选 11 到 15,我用 11 就够,兼容性最好。
import torch def export_onnx(model, dummy_input_size=(1, 3, 224, 224), output_path="tongue_model.onnx"): device = torch.device('cpu') model = model.to(device).eval() dummy_input = torch.randn(*dummy_input_size, device=device) torch.onnx.export( model, dummy_input, output_path, opset_version=11, input_names=['input'], output_names=['output'], dynamic_axes={ 'input': {0: 'batch_size'}, 'output': {0: 'batch_size'} } ) print(f'导出完成:{output_path}')dynamic_axes 让 batch 维度可以动态变化。GUI 里一次只推理一张图,动态 batch 没有实际用处,但如果你想写一个支持批量识别的脚本,这个配置就省事了。注意导出前必须调用 eval(),把 BN 层和 Dropout 层切到推理模式,否则导出的模型行为会不一致。
4.2 导出后的校验:这一步不做等于给自己埋雷
导出完别急着接进 GUI,先拿同一张图分别跑 PyTorch 模型和 ONNX 模型,对比输出张量。ONNX 在算子转换过程中偶尔会有细微差异,特别是遇到 ResNet 里的 BatchNorm 和 GlobalAveragePooling 时,理论误差应该在 1e-3 以内,超过这个量级说明导出参数有问题或者算子不兼容。
import onnxruntime as ort import numpy as np import torch def check_onnx(onnx_path, torch_model, test_input): # PyTorch 输出 with torch.no_grad(): torch_output = torch_model(test_input).numpy() # ONNX Runtime 输出 sess = ort.InferenceSession(onnx_path, providers=['CPUExecutionProvider']) onnx_output = sess.run(['output'], {'input': test_input.numpy()})[0] diff = np.max(np.abs(torch_output - onnx_output)) print(f'最大绝对误差:{diff:.6f}') assert diff < 1e-3, '误差超过阈值,导出有问题'这个校验脚本每次改完模型都要跑一遍,能挡住九成 GUI 阶段的诡异 bug。我见过一个真实案例:模型在 Torch 里准确率 92%,转完 ONNX 之后准确率掉到 60%,原因是导出时漏写了 eval(),BN 层还在用训练时的统计量。这类问题不看校验结果根本想不到。
4.3 推理封装:把预处理、推理、后处理收进一个类
GUI 阶段最容易写乱的就是逻辑散落一地:界面代码里做图像读取,另一个函数里做归一化,再在按钮回调里写推理。正确做法是把推理收成一个类,界面只负责拿图片路径调用它,拿到的是已经映射好类别名的结果。
import onnxruntime as ort import numpy as np import cv2 class TongueClassifier: def __init__(self, onnx_path, class_names, img_size=224): self.sess = ort.InferenceSession(onnx_path, providers=['CPUExecutionProvider']) self.class_names = class_names self.mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) self.std = np.array([0.229, 0.224, 0.225], dtype=np.float32) self.img_size = img_size def preprocess(self, img_path): img = cv2.imread(img_path, cv2.IMREAD_COLOR) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (self.img_size, self.img_size)) img = img.astype(np.float32) / 255.0 img = (img - self.mean) / self.std img = img.transpose(2, 0, 1) # HWC -> CHW return np.expand_dims(img, axis=0).astype(np.float32) def predict(self, img_path, top_k=3): input_tensor = self.preprocess(img_path) logits = self.sess.run(['output'], {'input': input_tensor})[0] scores = self._softmax(logits[0]) indices = np.argsort(scores)[::-1][:top_k] return [(self.class_names[i], float(scores[i])) for i in indices] def _softmax(self, x, temperature=1.0): x = x / temperature e_x = np.exp(x - np.max(x)) return e_x / e_x.sum()softmax 里的 temperature 参数可以调:想要模型输出更自信,把 temperature 设成 0.8;想要更保守,设成 1.2。GUI 里展示置信度时用这个分数,用户能直观看到模型对这张图的把握有多大。class_names 的列表顺序必须和训练时文件夹的类索引完全一致,否则结果全是错位的。
5. 舌苔识别 GUI 实现与避坑:PyQt5 的线程、路径与打包坑
5.1 界面框架选型:PyQt5 还是 Tkinter
GUI 框架主要两个选择。Tkinter 是 Python 自带,零依赖,打包体积小,但控件丑、布局麻烦,做个上传加识别的小界面还行;PyQt5 控件丰富、样式成熟,文件对话框、图片缩放、表格展示都现成,缺点是打包后体积大几十 MB。做舌苔识别这种带图片预览和结果展示的系统,我选 PyQt5。
| 维度 | PyQt5 | Tkinter |
|---|---|---|
| 依赖大小 | 约 100 MB | 内置,零依赖 |
| 图片显示 | QLabel + QPixmap,缩放平滑 | 需要配合 PIL |
| 线程支持 | QThread 完善 | 用 threading 自己封装 |
| 界面美观度 | 可写 QSS,现代感强 | 基本就是原生控件的丑 |
| 适合场景 | 带预览、多控件、产品化 | 简单工具、内部脚本 |
Tkinter 不是不能做,但它的事件循环和线程机制比较脆,遇到耗时推理时很容易出现“窗口白屏”的假死状态,还得自己处理线程间通信。PyQt5 的 signal-slot 机制天然解决这个问题,代码写起来也直观很多。
5.2 GUI 骨架实现:上传、预览、识别、结果展示
界面布局常见做法是左右分栏:左边是图片预览区,右边是结果区,底部放操作按钮。核心逻辑在识别按钮的槽函数里,它要做三件事:取文件路径、调用推理类预测、把结果写进界面控件。直接写一个最小可跑的骨架。
import sys from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton, QLabel, QFileDialog, QVBoxLayout, QHBoxLayout, QWidget, QProgressBar from PyQt5.QtGui import QPixmap from PyQt5.QtCore import Qt, QThread, pyqtSignal class Worker(QThread): finished = pyqtSignal(list) # 推理完成后发信号,参数是预测结果列表 def __init__(self, classifier, img_path): super().__init__() self.classifier = classifier self.img_path = img_path def run(self): result = self.classifier.predict(self.img_path) self.finished.emit(result) class MainWindow(QMainWindow): def __init__(self, classifier): super().__init__() self.classifier = classifier self.current_image = None self.preview_label = QLabel('选择舌苔图片') self.preview_label.setAlignment(Qt.AlignCenter) self.preview_label.setFixedSize(400, 300) self.result_label = QLabel('识别结果') self.result_label.setWordWrap(True) self.btn_open = QPushButton('选择图片') self.btn_predict = QPushButton('开始识别') self.btn_predict.setEnabled(False) self.progress = QProgressBar() self.progress.hide() self.btn_open.clicked.connect(self.open_image) self.btn_predict.clicked.connect(self.start_predict) layout = QHBoxLayout() layout.addWidget(self.preview_label) right_layout = QVBoxLayout() right_layout.addWidget(self.result_label) right_layout.addWidget(self.btn_open) right_layout.addWidget(self.btn_predict) right_layout.addWidget(self.progress) layout.addLayout(right_layout) container = QWidget() container.setLayout(layout) self.setCentralWidget(container) def open_image(self): path, _ = QFileDialog.getOpenFileName(self, '选择图片', '', '图片文件 (*.jpg *.png)') if path: self.current_image = path pixmap = QPixmap(path).scaled(400, 300, Qt.KeepAspectRatio) self.preview_label.setPixmap(pixmap) self.btn_predict.setEnabled(True) def start_predict(self): if not self.current_image: return self.btn_predict.setEnabled(False) self.progress.show() self.worker = Worker(self.classifier, self.current_image) self.worker.finished.connect(self.on_predicted) self.worker.start() def on_predicted(self, result): text = '\n'.join(f'{name}: {score:.2%}' for name, score in result) self.result_label.setText(text) self.progress.hide() self.btn_predict.setEnabled(True)Worker 是这里的关键。推理直接放到 QThread 里跑,主线程只负责显示界面,推理完成后通过 finished 信号把结果传回来。如果不用线程,点击识别时界面会卡死几秒,Windows 上甚至会提示“程序无响应”,这是 GUI 应用最不能容忍的体验。
5.3 避坑记录:五个真实踩过的 GUI 集成问题
以下五条都是我在实际部署中遇到过的,每条都按现象、原因、解决写清楚。
坑一:点击识别后窗口假死
- 现象:点击按钮后窗口转圈,标题栏显示“未响应”,过几秒恢复。
- 原因:推理逻辑直接写在按钮槽函数里,阻塞了 Qt 的事件循环。
- 解决:用 QThread 跑推理,结果通过信号传回主线程。注意 Worker 不能被垃圾回收,要存成 self.worker。
坑二:Qt 会莫名其妙崩溃,报错 QThread: Destroyed while thread is still running
- 现象:程序运行几次后崩溃,控制台输出 QThread destroyed 错误。
- 原因:关闭窗口时 Worker 线程还在跑,线程被强制销毁。
- 解决:在 closeEvent 里调用 self.worker.wait() 或 requestInterruption(),先停线程再收窗口。
坑三:打包后 QSS 样式和图片加载不出来
- 现象:源码里界面正常,PyInstaller 打包后按钮样式丢失、预览区空白。
- 原因:相对路径在打包解包后失效,资源文件被 PyInstaller 放进了临时目录 _MEIPASS。
- 解决:用 sys._MEIPASS 拼资源路径,代码里不要写死相对路径。
import sys, os def resource_path(relative_path): base = getattr(sys, '_MEIPASS', os.path.abspath('.')) return os.path.join(base, relative_path)坑四:onnxruntime 安装失败或加载 DLL 失败
- 现象:pip install onnxruntime 报错,或者在别的机器上运行提示找不到 VCRUNTIME140.dll。
- 原因:onnxruntime 依赖 Microsoft Visual C++ 运行库,新环境默认没装。
- 解决:下载 VC_redist.x64.exe 装上;否则打包时把 onnxruntime 相关的 DLL 手动加进 --add-binary。
坑五:ONNX 模型路径写死导致别的目录下启动失败
- 现象:在 IDE 里跑正常,打包后双击 exe 报找不到模型文件。
- 原因:模型路径用了相对路径,启动目录一变就找不到。
- 解决:按坑三的 resource_path 拿到绝对路径,再传给 TongueClassifier。资源文件在打包时用 --add-data 手动加进去。
6. 打包发布与推理加速:让没装 Python 的同学也能点开就用
6.1 PyInstaller 打包:命中 hidden import 才是关键
GUI 程序做出来之后,最后一步是把它变成一个普通用户能双击运行的 exe。PyInstaller 是最成熟的方案,常见做法是先写 spec 文件再打包,避免命令行参数写太长遗漏。我的打包命令基准是这样的:
pyinstaller --onedir --windowed --add-data "tongue_model.onnx;." --add-data "assets;assets" --hidden-import onnxruntime main.py--onedir 比 --onefile 好,启动速度快很多,--onefile 每次运行都要解压临时文件,在机械硬盘上能等出心理阴影。--add-data 把 ONNX 模型和界面资源打进去,Windows 下分隔符是分号不是冒号,这个细节导致过不少打包失败。如果打包后提示缺少模块,多半是动态导入没被发现,要手动补 --hidden-import。
打包完第一件事不是发给别人,是先在自己机器上把 exe 挪到一个全新目录运行一遍,排查“本地开发路径下的资源失效问题”。我吃过一次亏:exe 和模型文件放在同一目录下能跑,换到别的目录就崩,因为打包时忘了加 --add-data,模型根本没有进入包内。
6.2 推理加速:ONNX Runtime 能比 PyTorch 快一半以上
舌苔识别这种单图推理对延迟不敏感,但 GUI 里每等一秒用户体验就掉一截。ONNX Runtime 的 CPU 推理速度通常比 PyTorch CPU 模式快 30% 到 60%,如果还不够,可以加两个配置参数:
session_options = ort.SessionOptions() session_options.intra_op_num_threads = 4 session_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL sess = ort.InferenceSession('tongue_model.onnx', sess_options=session_options, providers=['CPUExecutionProvider'])intra_op_num_threads 设成 4 是因为大部分 CPU 的物理核就是 4 个,设太高线程切换反而拖慢速度。graph_optimization_level 开到 ALL 会让 ONNX Runtime 自动合并算子和消除冗余计算,这是免费的性能提升。如果对推理速度还有更高要求,可以试 ONNX 的动态量化,把权重从 float32 压到 int8,模型体积缩小四分之一,但舌苔识别对颜色细节敏感,量化后准确率可能掉 1 到 3 个点,需要自己权衡。
从那以后我每次打包发布前都强制走一遍固定流程:跑 ONNX 一致性校验、检查 resource_path、确认线程安全关闭,然后才敢把 exe 发出去。这套流程堵住过一次“模型文件没打包进去”的事故,也堵住过一次“图片路径含中文时 QPixmap 读不出来”的坑。这条路走到最后你会发现,深度学习项目真正难的不是前面训练时调 acc,而是让一个完全不懂模型的人双击鼠标就能得到可靠的结果。希望这份拆解能帮你在自己的部署路上少踩几个坑。
本文还有配套的精品资源,点击获取