简介:面向高校计算机、医学信息工程等专业的毕业设计或图像识别实践,这份打包提供基于深度学习的舌苔识别检测系统,内含完整Python源码、PyQt5图形界面、训练好的模型权重及配套毕业论文。包内共110个文件,以py源码与pyc编译文件为主,另有ui界面文件、pth权重、jpg/png样例图、json配置文件、docx论文文档等,压缩包约104.93MB,目录结构清晰,便于直接运行、验证效果与二次开发。内容覆盖课题背景、机器学习与卷积神经网络理论、舌苔检测需求分析、舌象数据集构建与扩充、检测网络设计等章节,并引入图像增强与DCGAN生成舌象图片,形成从数据预处理、模型训练到GUI界面展示的完整闭环;代码注释完整,适合需要系统实现图像分类/检测项目的学习者参考。已有536人学习浏览,对筹备相关毕业设计或想掌握深度学习落地流程的读者具有较高参考价值。
1. 深度学习舌苔识别检测鉴定系统在做什么:从舌象图到定量结果
中医舌诊里,舌苔的苔色、厚薄、润燥是判断身体状态的重要依据,但传统上主要靠医生目测,主观性很强。深度学习做舌苔识别检测鉴定,要解决的是把“看舌苔”的动作拆成两个可计算的任务:先用目标检测模型定位舌头区域,再用分类模型输出苔色等具体属性。项目交付物里通常包含三类资源:训练好的模型权重、封装好的 Python 推理源码、以及基于 PyQt5 的桌面界面。
这类项目经常出现在两类人手里:相关专业本科生用它完成毕业设计的完整交付,或中医数字化团队用它验证桌面舌诊工具的可行性。判断系统好不好用,不能只看模型准确率,还要看数据标注方式、推理封装的边界和界面线程模型是否合理。下面按模型、Python 源码、PyQt5 界面三条线拆开,把关键参数和常见误用都覆盖到。
2. 舌苔识别模型的训练选型与 PyTorch 迁移学习配置
2.1 先判断任务形态:舌苔分类和舌体定位在实现上的区别
标题里的“识别检测鉴定”三个词不能混在一个模型里解决。分类和检测在深度学习里是两种任务形态,直接决定标注格式、网络结构和最终输出。分类任务的输入通常是已经裁剪好的舌部图像,网络输出苔色类别;检测任务面对的是整张口腔图像,先由目标检测网络找出舌头边界,再对框内区域做属性判定。两者串联起来,就是舌象系统里最常见的“检测-分类流水线”,也是中医影像 AI 的常规工程结构。
检测阶段比较实用的选择是 YOLOv8(轻量部署)或 Faster R-CNN(精度优先),分类阶段用 ResNet-50 或 MobileNetV3 做骨干网络。数据量在两千张这个规模时,不建议从零训练,用 ImageNet 预训练权重做迁移学习是投入产出比最高的起点。部分同学想直接用 Vision Transformer 替换骨干,但在舌苔这类依赖细节纹理差别的任务上,Transformer 的数据需求和数据增强要求都比较高,常规课程项目里 CNN 迁移学习仍然更稳。
2.2 舌象数据集的标注规范与增强参数:哪些增强能留,哪些不能用
数据准备按两个任务分开进行。目标检测最小数据集是 500 张带框标注的原始口腔图像,用 LabelImg 或 LabelStudio 标注,导出 COCO JSON 格式最省事,后续无论接 torchvision 的 Faster R-CNN 还是接 YOLO 训练脚本,都不需要再做一次格式转换。分类数据集按类别目录组织,推荐结构是train/薄白苔/、train/黄腻苔/这样,每个类别 300 张以上,总量超过 1500 张再开始训练,低于这个量级时迁移学习的微调空间很有限。
数据增强策略必须贴合舌象本身的特点。旋转范围限制在 ±15 度以内,翻转增强只保留水平翻转,亮度饱和度扰动各保持 ±20% 随机变化,用来模拟拍摄现场的光照差异。不要使用 90 度旋转和高亮椒盐噪声这类激进增强,舌苔颜色分布是模型判定的核心依据,被破坏后模型学到的是伪特征而不是真实苔色。训练前建议让两位标注者对一批样本做一致性检验,Kappa 值低于 0.8 的类别需要重新商定标准,这个工作放在数据阶段做,比训完模型后再回头筛查高效得多。
2.3 ResNet-50 微调训练舌苔分类模型:核心配置与代码框架
分类阶段用 PyTorch 微调 ResNet-50 是最常见的做法,实现上只需要替换最后一层全连接,输出维度改成当前任务的实际类别数。下面是一套以单卡 6GB 显存为假设的训练配置,适用于舌苔分类这类中小规模视觉任务:
| 参数 | 推荐值 | 设置意图 |
|---|---|---|
| 输入尺寸 | 224 × 224 | 与 ImageNet 预训练条件保持一致 |
| Batch Size | 32 | 6-8GB 显存的安全范围 |
| 优化器 | SGD,momentum=0.9 | 中小数据集上比 Adam 收敛更稳 |
| 初始学习率 | 0.01 | 新分类头可以承接更大的学习率 |
| 学习率调度 | CosineAnnealingLR,T_max=120 | 训练后期梯度更新更平缓 |
| Epoch | 120 | 配合验证集早停机制使用 |
| 损失函数 | CrossEntropyLoss | 多类分类的标准选项 |
# train_classifier.py —— 舌苔分类模型微调 import torch import torch.nn as nn import torch.optim as optim import torchvision.models as models num_classes = 5 # 薄白苔、黄腻苔、白腻苔、灰黑苔、剥苔 model = models.resnet50(weights="IMAGENET1K_V2") model.fc = nn.Linear(model.fc.in_features, num_classes) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=120) for epoch in range(120): model.train() train_loss = 0.0 for imgs, labels in train_loader: logits = model(imgs) loss = criterion(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() train_loss += loss.item() scheduler.step() # 每个 epoch 结束后在验证集上评估,保留 val_acc 最优的权重这段代码里三个决策最关键。第一,backbone 直接加载 ImageNet 预训练权重,特征提取能力直接继承,新分类头从头学习,这是迁移学习最朴素也最有效的用法。第二,优化器选择 SGD 而不是直接抄 Adam 的默认配置,舌苔类别之间的视觉差异并不大,SGD 配 momentum 在中小数据集上泛化更稳定。第三,学习率走 cosine 退火,120 个 epoch 的后期仍保持有效的梯度更新。训练期间要持续记录每个 epoch 的验证集 acc,只保留最优权重;如果表现为训练集精度趋向 100% 而验证集在一个平台期徘徊,优先加大增强强度而不是换更深的骨干网络。
注意:舌苔分类标准在不同文献里并不完全一致,训练前先固化一份可执行的标注规范,比训练后再调整类别边界成本低很多。
3. Python 源码的推理与封装方式:加载、预处理、后处理三层分工
3.1 推理代码的目录结构:模型加载器先解决权重格式问题
拿到源码项目后,先看训练代码和推理代码是否分开。模型的训练和部署在工程上是两个环节,训练涉及数据加载、损失计算、反向传播,这些逻辑不值得被界面层关心。如果 PyQt5 的界面代码里直接出现torch.load和model(images),后续更换模型或接入新的推理服务时改动面会很广。好的做法是把推理逻辑集中到一个独立模块,至少拆成三个文件:
inference/ ├── model_loader.py # 模型加载与权重格式清洗 ├── preprocess.py # 图像预处理 ├── predictor.py # 统一预测入口 └── postprocess.py # 输出解析与可读化model_loader.py的职责不只是load_state_dict。训练时用 DataParallel 包裹模型很常见,保存的 state dict 里所有的键名都会带上module.前缀,换到单卡机器上直接加载会报 key 不匹配;换一种情况,训练时用 FP16 保存的权重,推理时要先转回 FP32 再送入网络。这些状态差异应该在模型加载器里统一处理掉,而不是散落在多个调用位置。
3.2 图像预处理参数必须和训练保持一致,BGR 到 RGB 的顺序不能错
推理阶段的预处理必须与训练时完全一致,任何一个参数的偏移都会导致置信度下降。ResNet 系列要求的输入是[B, 3, 224, 224],像素归一化到 [0,1] 之后,再按 ImageNet 的 mean 和 std 做标准化。最容易出错的地方是 OpenCV 默认读入的通道顺序是 BGR,不转换直接送入网络,模型拿到的是一张偏色图,舌苔这类对颜色敏感的任务会立刻在准确率上体现出来。
# inference/preprocess.py —— 推理侧预处理 import cv2 import numpy as np def preprocess(image: np.ndarray, size: int = 224) -> np.ndarray: img = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (size, size)) img = img.astype(np.float32) / 255.0 mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) std = np.array([0.229, 0.224, 0.225], dtype=np.float32) img = (img - mean) / std return np.transpose(img, (2, 0, 1))[np.newaxis, ...]预处理函数内部依次完成 BGR 转 RGB、缩放、归一化、标准化和通道轴置换。最后一步把形状从(H, W, 3)变成(3, H, W),并补上 batch 维度,输出(1, 3, 224, 224),正好是 PyTorch 分类网络要求的布局。这段代码里的 mean 和 std 是 ImageNet 预训练时使用的标准值,不能随意替换,否则会削弱预训练权重本身的特征表达能力。
3.3 后处理输出结构化结果,Predictor 类作为界面唯一入口
模型的原始输出是一个 logits 向量,不能直接展示给使用者。后处理阶段要把 logits 转成概率,并按置信度排序,输出一个列表。舌苔经常存在混合情况,比如薄白和黄腻同时出现,返回 top-k 候选比返回单一 argmax 在实际诊断辅助中更有参考价值:
# inference/postprocess.py —— 输出解析 import numpy as np CLASS_NAMES = ["薄白苔", "白腻苔", "黄腻苔", "灰黑苔", "剥苔"] def parse_probs(probs: np.ndarray, top_k: int = 3, threshold: float = 0.3): idx = np.argsort(probs)[::-1][:top_k] result = [] for i in idx: if probs[i] >= threshold: result.append({ "label": CLASS_NAMES[i], "probability": round(float(probs[i]), 4) }) return resulttop_k控制返回几个候选类别,threshold过滤低置信度结果。两个参数做成界面可配置项比较好:静态单张图像分析时 threshold 设 0.3 左右,保留更多可能性;摄像头连续推理时设到 0.5 以上,避免相邻帧的分类结果频繁跳动。再往上一层,封装一个 Predictor 类作为统一入口,让界面和命令行工具共用同一份推理代码:
# inference/predictor.py —— 统一预测入口 import torch import numpy as np from .preprocess import preprocess from .postprocess import parse_probs from .model_loader import make_model class Predictor: def __init__(self, weight_path: str, device: str = "auto"): self.device = ( "cuda" if torch.cuda.is_available() else ("mps" if torch.backends.mps.is_available() else "cpu") ) if device == "auto" else device self.model = make_model(weight_path) self.model.to(self.device).eval() @torch.no_grad() def predict(self, image: np.ndarray): tensor = torch.from_numpy(preprocess(image)).to(self.device) logits = self.model(tensor) probs = torch.softmax(logits, dim=1)[0].cpu().numpy() return parse_probs(probs)设备选择放在初始化阶段自动完成,predict方法接收 ndarray 图像,冻结梯度并返回解析后的结果列表。界面里只保留一个 Predictor 实例,不要每次点击按钮都重新加载模型,否则单张图片推理会额外增加数秒的初始化开销。后续要更换模型或预处理器,只改动对应文件即可,界面层完全不受影响。
4. PyQt5 界面与模型推理的联动工程:线程、信号槽和摄像头实时检测
4.1 界面与推理模块解耦:为什么按钮回调函数里不能直接跑模型
PyQt5 界面在项目里不只是图片查看器,它是用户与模型交互的窗口。界面至少支持三件事:选择本地舌象图像并展示识别结果、摄像头实时舌象检测、检测结果保存导出。三个功能共享同一套推理服务,界面自身不持有模型对象,所有模型调用都通过 Predictor 类完成。
界面层要做得足够薄,耗时的推理必须放在后台线程。预测如果直接写在按钮回调函数里,界面会阻塞数秒,拖动窗口和点击其他按钮时,操作系统会直接显示“程序未响应”提示。PyQt5 里正确的做法是继承 QObject 写 Worker 类,用moveToThread把 Worker 放进 QThread,QThread 本身不承载业务逻辑,只负责线程生命周期和事件循环。这样处理界面关闭时的资源回收也方便,不会出现“主窗口关了但 python 进程还占着 GPU 显存”的问题。
4.2 单张图片推理的 QThread Worker 封装与信号槽连接
单张图像推理的流程是:用户点击按钮,文件对话框返回图片路径,主线程创建 Worker,把路径通过信号发给后台,Worker 调用 Predictor 得到结果,再由信号槽把结果带回主线程刷新界面。Worker 的标准写法如下:
# worker.py —— 推理线程 Worker import cv2 from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot class InferenceWorker(QObject): result_ready = pyqtSignal(dict) error_occurred = pyqtSignal(str) def __init__(self, predictor): super().__init__() self.predictor = predictor @pyqtSlot(str) def process_image(self, image_path: str): try: image = cv2.imread(image_path) if image is None: self.error_occurred.emit( f"无法读取图像: {image_path}") return result = self.predictor.predict(image) self.result_ready.emit({ "path": image_path, "items": result }) except Exception as exc: self.error_occurred.emit(str(exc))这里@pyqtSlot(str)显式声明了槽的参数类型,让 PyQt5 在为跨线程连接选择队列方式时能准确匹配。result_ready信号只携带一个字典,不直接携带图像数组,避免大对象在跨线程复制时产生额外内存开销。界面收到信号后,按路径重新加载图像做显示,数据流保持单向。主窗口侧连接代码是:
# main_window.py —— 信号连接与界面刷新 self.worker.result_ready.connect(self._show_result) self.worker.error_occurred.connect(self._show_error) def _show_result(self, data: dict): self.result_table.setRowCount(len(data["items"])) for row, item in enumerate(data["items"]): self.result_table.setItem( row, 0, QTableWidgetItem(item["label"]) ) self.result_table.setItem( row, 1, QTableWidgetItem(str(item["probability"])) )注意:工作线程中创建的 QImage 在传给主线程时,要用
image.copy()显式复制,直接传原对象有可能在界面刷新时因底层数据被回收而出现花屏。
4.3 摄像头实时舌象检测的隔帧推理与显示分离
摄像头场景的难点在于推理速度跟不上硬件帧率。普通笔记本 USB 摄像头大约 30 FPS,而一个 ResNet-50 的推理耗时在 CPU 上可能超过 60 毫秒,每帧都做推理,实际帧率会掉到 10 FPS 以下。常规方案是隔帧推理、显示与推理并行:预览始终按采集帧率刷新,推理按固定间隔跳帧执行。
# camera_thread.py —— 摄像头实时检测线程 import cv2 from PyQt5.QtCore import QThread, pyqtSignal class CameraThread(QThread): frame_ready = pyqtSignal(object) result_ready = pyqtSignal(dict) def __init__(self, predictor, skip_interval: int = 3): super().__init__() self.predictor = predictor self.skip_interval = skip_interval self.frame_count = 0 self._running = True def run(self): cap = cv2.VideoCapture(0) while self._running and cap.isOpened(): ok, frame = cap.read() if not ok: continue self.frame_count += 1 if self.frame_count % self.skip_interval == 0: result = self.predictor.predict(frame) self.result_ready.emit(result) self.frame_ready.emit(frame) cap.release() def stop(self): self._running = Falseskip_interval是隔几帧做一次推理,需要根据实际机器性能调整。如果模型的推理耗时在 30 毫秒以内,设置 2 就够了;当界面预览本身占用主线程资源时,建议调到 3 或 4,保证界面操作始终跟手。帧计数逻辑放在线程内部维护,不需要加锁。检测结果的绘制也应该放在工作线程里完成,主界面只负责刷新带结果的帧。
5. 验证识别效果的两个实用技巧:混淆矩阵与 ONNX 部署
模型训练完,不能只给一个总体准确率就当交付完成。舌苔类别之间的混淆往往有固定规律,薄白和剥苔、黄腻和白腻都容易交叉误判。对测试集输出一张混淆矩阵,哪两类在“打架”一目了然;配合单类别 precision 和 recall 表,能直观发现哪一类数据量不足或标注一致性差。把每次跑错的图收集成难例集,留在界面里做一个“复核模式”,人工二次判断后补充进训练集,比单纯堆模型精度更有实际价值。
部署环节有一个很值得花半小时做的优化:把模型从 PyTorch 权重转成 ONNX,用 ONNX Runtime 做推理。纯 PyTorch 的依赖体积在 2GB 以上,配合 PyInstaller 打包 PyQt5 桌面程序会让安装包变得很大;ONNX Runtime 的 CPU 包只有几十 MB,打包体积能缩小一半以上。转换方式不复杂:
# export_onnx.py —— PyTorch 模型转 ONNX import torch model.eval() dummy = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy, "tongue_model.onnx", input_names=["input"], output_names=["logits"], dynamic_axes=None )转换完成后,用onnxruntime.InferenceSession替换原来的 PyTorch 推理,对比转换前后同一张图输出的五类概率向量,最大差值不超过 1e-4 即可视为无损转换。如果发现 ONNX Runtime 在低配机器上启动偏慢,可以在 SessionOptions 里把intra_op_num_threads设为 2,控制 CPU 线程数来平衡资源占用。毕业论文的实验章节也可以按这个思路组织:先放数据集构成与标注一致性说明,再放混淆矩阵、类间精度表、转换前后性能对比表,最后补 2 到 3 张界面运行截图,整套数据链比大段描述模型原理更有说服力。
本文还有配套的精品资源,点击获取