
简介这份资源面向深度学习入门者、中医舌诊信息化方向的学生及毕业设计开发者提供一套完整的舌苔识别检测鉴定系统实现方案涵盖从理论到落地的全流程。压缩包共110个文件约104.93MB以26个Python源码文件为核心配合10个编译缓存、6个PyTorch模型权重、2个PyQt5界面文件、7张示例图片及若干json配置、docx论文与ttc字体结构清晰便于二次开发。内容围绕舌象数据集构建与扩充、DCGAN生成舌象样本、卷积神经网络舌苔检测网络设计、体质辨识等模块展开并附完整毕业论文覆盖课题背景、机器学习理论、需求分析、数据集标注与增强、网络训练等章节。已有536人学习下载适合需要完整毕设方案、可运行GUI系统与模型参考的读者能帮助快速理解舌诊分类任务的数据处理、模型搭建与界面集成思路。1. 舌苔识别系统到底在做什么从一张手机照片到一份体质报告很多人第一次听到「舌苔识别」会以为是个噱头其实它解决的是一个很具体的痛点中医舌诊依赖医生经验同一张舌头不同人看结论可能不一样而基层体检、健康管理、慢病随访这些场景又需要快速、标准化地给出一个初筛结果。基于深度学习的舌苔识别检测鉴定系统本质是把「舌质颜色、舌苔厚薄、苔色黄白、齿痕裂纹」这些视觉特征交给卷积神经网络去学再用 PyQt5 做成一个能双击运行、能选图片、能出结果的桌面程序。它适合两类人一类是计算机/人工智能方向的毕业生需要一个完整可交付的课程设计或毕设另一类是想把深度学习真正落地到医疗健康边缘场景的工程师想看清一个「模型界面论文」的闭环到底怎么搭。这一章先把边界讲清楚后面几章再动手。2. 数据与标签怎么定舌苔识别系统的地基2.1 舌苔分类的标签体系为什么不能照搬 ImageNet舌苔识别不是通用图像分类它的类别必须来自中医舌诊的实际判读维度。常见做法是把任务拆成两个独立的分类头一个是「苔色」通常分白苔、黄苔、灰黑苔另一个是「苔质」分薄苔、厚苔、腻苔、剥苔。也有项目直接做多标签一张舌头同时输出苔色和苔质。这里有个血泪经验如果你把「白厚腻」当成一个单独类别样本会极度不均衡因为组合爆炸每个组合可能只有几十张图模型根本学不动。我一般会先做单标签的苔色分类等数据量上到每类 800 张以上再考虑加苔质分支。标签来源上最可靠的是找有中医背景的人对采集的舌象图做双人标注不一致的样本直接丢弃。不要用网络爬来的图直接打标因为拍摄光源、白平衡、伸舌姿势差异太大模型会学到「这是暖光还是冷光」而不是「苔是黄还是白」。如果实在没有标注资源可以先用公开的舌诊数据集做预训练再用自己采集的小样本微调这是目前比较稳的路径。2.2 数据采集与预处理的可复现步骤采集时统一用环形补光灯色温 5000K 到 5500K手机或工业相机固定距离 15 到 20 厘米让被采集者自然伸舌不要用力过度导致舌质发红。每张图保存为 JPG分辨率不低于 1080P。预处理的核心是「先定位舌头再裁切归一化」因为整张脸或口腔背景会干扰分类。import cv2 import numpy as np def preprocess_tongue(img_path, out_size(224, 224)): # 读取原图 img cv2.imread(img_path) if img is None: raise ValueError(图片读取失败检查路径) # 转 HSV用肤色红色范围粗定位舌头区域 hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 舌体在 HSV 中大致落在红色到橙红区间饱和度偏高 lower1 np.array([0, 60, 60]) upper1 np.array([15, 255, 255]) lower2 np.array([160, 60, 60]) upper2 np.array([180, 255, 255]) mask cv2.inRange(hsv, lower1, upper1) cv2.inRange(hsv, lower2, upper2) # 形态学去噪闭运算填补舌面小孔 kernel np.ones((7, 7), np.uint8) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations2) # 找最大轮廓作为舌体 contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: raise ValueError(未检测到舌体区域) cnt max(contours, keycv2.contourArea) x, y, w, h cv2.boundingRect(cnt) # 按外接矩形裁切并留 5% 边距 pad int(0.05 * max(w, h)) x1 max(0, x - pad); y1 max(0, y - pad) x2 min(img.shape[1], x w pad); y2 min(img.shape[0], y h pad) crop img[y1:y2, x1:x2] # 统一尺寸后续送进网络 crop cv2.resize(crop, out_size, interpolationcv2.INTER_AREA) return crop这段代码的逻辑是HSV 阈值先粗筛舌体形态学闭运算把舌面反光造成的小洞补上再取最大轮廓裁切。参数上lower1/upper1和lower2/upper2覆盖了红色到橙红的两段是因为 HSV 色相环在红色处是跨 0 的。kernel用 7×7 是为了在 1080P 图上既能去噪又不吃掉舌体边缘。如果你的图偏暗先把V通道下限从 60 调到 40 再试。裁切后统一到 224×224是为了对齐后面 CNN 的输入。提示预处理脚本跑完后一定要人工抽检 50 张裁切结果确认没有把嘴唇或牙齿裁进去否则模型会学到错误特征。3. 模型选型与训练从 ResNet 到轻量级部署3.1 为什么舌苔识别优先选 ResNet18 而不是自己搭 CNN舌苔识别的数据量通常不大几千到几万张自己从零搭一个 5 层 CNN 很容易过拟合验证集准确率卡在 70% 上不去。常见做法是用在 ImageNet 上预训练过的 ResNet18 或 MobileNetV3 做迁移学习把最后的全连接层换成自己的类别数。ResNet18 参数量约 1100 万在 224×224 输入下推理一次在普通 CPU 上约 30 到 50 毫秒完全够桌面端用。如果你要部署到嵌入式设备再考虑 MobileNetV3 或 ShuffleNet精度掉 2 到 3 个百分点但速度翻倍。训练时的关键参数学习率用 1e-3 配合余弦退火batch size 设 32优化器用 AdamW权重衰减 1e-4。数据增强不要用太猛的随机裁剪因为舌体位置本身就是特征裁剪过头会把舌苔边缘切掉。我一般只用随机水平翻转、±15 度旋转、亮度对比度微调。训练轮数 40 到 60 轮早停耐心值设 10。import torch import torch.nn as nn from torchvision import models, transforms def build_model(num_classes3): # 加载预训练 ResNet18 model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) # 替换最后一层输出自己的类别数 in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) return model # 训练时的数据增强注意不要用 RandomResizedCrop 的默认激进参数 train_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees15), transforms.ColorJitter(brightness0.1, contrast0.1), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])build_model里把fc换成num_classes对应的线性层这是迁移学习最标准的做法。train_tf中RandomRotation限制在 15 度是因为舌体旋转超过 20 度后舌苔的纹理方向会失真。Normalize用的均值方差是 ImageNet 的统计值因为预训练权重就是在这个分布上学的保持一致能让微调更稳。如果你的类别只有白苔和黄苔两类num_classes改成 2 即可。3.2 训练脚本与验证指标怎么看训练时不要只盯准确率舌苔识别里「黄苔」和「白苔」的样本数往往差两三倍准确率会被多数类带偏。我一般同时看每类的 F1 和混淆矩阵。如果白苔 F1 0.95、黄苔 F1 0.62说明模型基本在猜白苔这时候要么给黄苔做重采样要么在损失函数里加类别权重。from torch.utils.data import DataLoader, WeightedRandomSampler import numpy as np # 假设 labels 是训练集所有样本的标签列表 labels np.array(train_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) train_loader DataLoader(train_dataset, batch_size32, samplersampler)WeightedRandomSampler的作用是让每个 batch 里少数类样本被抽到的概率提高replacementTrue表示有放回抽样。class_weights取倒数是最简单的平衡策略如果两类差距特别大可以对倒数再开根号避免少数类被过度采样导致过拟合。验证时用sklearn.metrics.classification_report直接打印每类 precision、recall、F1比只看一个总数有用得多。注意训练集和验证集必须按「人」划分同一个人不能同时出现在两边否则模型会记住这个人的舌头验证指标虚高。4. PyQt5 界面与模型推理的对接让系统能双击运行4.1 PyQt5 界面布局的最小可用结构一个能交付的舌苔识别系统界面不需要花哨但要有四个区域图片显示区、按钮区选择图片、开始识别、清空、结果区显示苔色、苔质、置信度、状态栏。用 Qt Designer 拖拽生成.ui文件再用pyuic5转成.py这是最省事的做法。如果你不想装 Designer直接手写布局也行核心是QVBoxLayout套QHBoxLayout。from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QLabel, QPushButton, QVBoxLayout, QHBoxLayout, QFileDialog) from PyQt5.QtGui import QPixmap import sys class TongueWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(舌苔识别检测系统) self.resize(900, 600) central QWidget() self.setCentralWidget(central) # 图片显示标签 self.img_label QLabel(请选择舌象图片) self.img_label.setFixedSize(500, 400) self.img_label.setStyleSheet(border: 1px solid #999;) # 按钮 self.btn_open QPushButton(选择图片) self.btn_run QPushButton(开始识别) self.btn_clear QPushButton(清空) # 结果标签 self.result_label QLabel(识别结果--) # 布局 btn_layout QHBoxLayout() btn_layout.addWidget(self.btn_open) btn_layout.addWidget(self.btn_run) btn_layout.addWidget(self.btn_clear) main_layout QVBoxLayout() main_layout.addWidget(self.img_label) main_layout.addLayout(btn_layout) main_layout.addWidget(self.result_label) central.setLayout(main_layout) # 信号连接 self.btn_open.clicked.connect(self.open_image) self.btn_run.clicked.connect(self.run_inference) self.btn_clear.clicked.connect(self.clear_all) self.current_path None def open_image(self): path, _ QFileDialog.getOpenFileName(self, 选择图片, , Images (*.jpg *.png)) if path: self.current_path path pix QPixmap(path).scaled(500, 400) self.img_label.setPixmap(pix) def clear_all(self): self.current_path None self.img_label.setText(请选择舌象图片) self.img_label.setPixmap(QPixmap()) self.result_label.setText(识别结果--) def run_inference(self): # 推理逻辑下一节补全 pass if __name__ __main__: app QApplication(sys.argv) win TongueWindow() win.show() sys.exit(app.exec_())这段代码搭出了界面骨架。img_label固定 500×400 是为了让图片显示区稳定不会因为图片尺寸跳动导致布局乱掉。btn_open用QFileDialog拿路径scaled时保持比例需要加Qt.KeepAspectRatio这里为了简洁先省略。run_inference留空下一节把模型推理接进去。注意 PyQt5 的信号槽连接必须在界面初始化时完成否则按钮点了没反应。4.2 把训练好的模型塞进按钮回调推理时最容易翻车的地方是「训练时用了 Normalize推理时忘了」。模型在训练时输入是归一化过的推理时如果直接送原始像素预测结果会完全乱掉。所以推理前必须走一遍和验证集一样的 transform只是去掉随机增强。import torch from torchvision import transforms from PIL import Image # 全局加载一次模型不要每次点按钮都重新加载 device torch.device(cuda if torch.cuda.is_available() else cpu) model build_model(num_classes3) model.load_state_dict(torch.load(tongue_resnet18.pth, map_locationdevice)) model.to(device) model.eval() infer_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) class_names [白苔, 黄苔, 灰黑苔] def predict(image_path): img Image.open(image_path).convert(RGB) tensor infer_tf(img).unsqueeze(0).to(device) with torch.no_grad(): logits model(tensor) probs torch.softmax(logits, dim1) conf, pred torch.max(probs, dim1) return class_names[pred.item()], conf.item()model.eval()必须调用否则 BatchNorm 和 Dropout 会按训练模式走单张推理结果不稳定。torch.no_grad()关掉梯度计算省显存也提速。unsqueeze(0)是把单张图变成 batch 维度为 1 的张量。softmax后取最大值和对应类别conf就是置信度。把这个predict函数接到run_inference里再把结果写到result_label上整个闭环就通了。提示模型文件建议放在项目根目录用相对路径加载避免换电脑后路径失效。5. 避坑与排查舌苔识别系统最常见的 5 个翻车点5.1 现象界面能跑但识别结果永远是同一个类原因通常是推理时的预处理和训练时不一致最常见的是忘了 Normalize或者Resize的插值方式不同导致像素分布偏移。解决方法是把训练时的验证 transform 原封不动复制到推理端并打印一张图的 tensor 均值和方差确认在 0 附近、标准差在 1 附近。5.2 现象训练准确率 99%实际用手机拍的照片全错这是典型的域偏移。训练集如果是公开数据集或实验室固定光源拍的手机自然光下白平衡完全不同模型学到的「黄」其实是「暖光」。解决办法是采集一批手机实拍图做微调或者在预处理里加一步白平衡校正用灰度世界算法把整体色偏拉回来。5.3 现象PyQt5 界面点「开始识别」直接卡死原因是推理在主线程里跑模型加载和计算阻塞了 Qt 事件循环。解决方法是把推理放到QThread里或者至少把模型加载提到界面初始化之前只让单次前向传播留在回调里。单张图 CPU 推理 50 毫秒以内一般不会卡但如果模型是 ResNet50 以上就必须开线程。5.4 现象打包成 exe 后提示找不到模型文件或 torch 模块PyInstaller 打包时不会自动把.pth和 torch 的动态库带进去。解决方法是写.spec文件在datas里加上模型文件路径在hiddenimports里加上torch、torchvision相关模块。更稳的做法是把模型转成 ONNX用onnxruntime推理打包体积能小一半依赖也少。5.5 现象黄苔样本少模型对黄苔几乎不召回除了前面说的重采样还可以用 focal loss 替代交叉熵让模型更关注难分样本。另外检查一下标注有些「黄苔」其实偏白标注本身就有歧义。如果 F1 还是上不去考虑先做二分类「正常 vs 异常」再在异常里细分分阶段降低难度。6. 进阶技巧用 Grad-CAM 让舌苔识别结果可解释系统能出结果只是第一步真正让医生或评审信服的是「模型到底看了哪里」。Grad-CAM 可以把模型最后卷积层的梯度加权回原图生成一张热力图高亮区域就是模型判断时最关注的位置。如果热力图集中在舌面中后部说明模型确实在学舌苔如果热力图跑到嘴唇或背景上说明数据有问题得回去重新裁切。import cv2 import numpy as np import torch from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image # 取 ResNet18 最后一个卷积块 target_layers [model.layer4[-1]] cam GradCAM(modelmodel, target_layerstarget_layers) def visualize_cam(image_path, output_pathcam_result.jpg): img Image.open(image_path).convert(RGB) rgb_img np.array(img.resize((224, 224))) / 255.0 tensor infer_tf(img).unsqueeze(0).to(device) # 生成热力图类别取预测类别 grayscale_cam cam(input_tensortensor)[0] visualization show_cam_on_image(rgb_img.astype(np.float32), grayscale_cam, use_rgbTrue) cv2.imwrite(output_path, cv2.cvtColor(visualization, cv2.COLOR_RGB2BGR)) return output_pathtarget_layers选layer4[-1]是因为它是 ResNet18 最后一个有空间信息的卷积层再往后就是全局池化和全连接没有位置信息了。show_cam_on_image把热力图叠加到原图上红色越深表示关注度越高。跑完抽 20 张图看热力图分布如果大部分都集中在舌体说明模型可信如果散在背景先别急着调模型回去查预处理和标注。我自己的习惯是每训完一版模型先不看准确率先跑 20 张 Grad-CAM热力图对了再谈指标。这个习惯帮我省了很多次「指标好看但实际不能用」的后悔药。舌苔识别这个方向数据质量比模型结构重要得多界面和论文只是把工作讲清楚的手段真正值钱的是那套可复现的采集、标注、训练、验证流程。希望帮到你。本文还有配套的精品资源点击获取