简介:一套基于深度学习的舌苔识别检测鉴定系统,面向计算机相关专业正在准备毕业设计的学生,也适合需要项目实战练习的学习者,可作为毕业设计、课程设计或期末大作业。资源提供完整的Python源码、论文文档和GUI界面,覆盖数据预处理、模型训练、舌苔检测识别到结果展示的完整流程,有助于快速搭建可运行的演示项目,并辅助毕业论文撰写。压缩包采用zip格式,共109个文件,主要包含Python源文件、模型权重文件、UI界面文件、Word文档,以及图片、JSON配置和TensorBoard训练日志等,整体约105MB,目录结构清晰,便于定位与学习。项目经导师指导并获高分评价,代码完整确保可运行,训练日志有助于复现实验与排查问题。目前已有131人学习下载,适合希望从零完成一个完整深度学习实战项目并兼顾论文写作与系统演示的读者。
1. 舌苔识别检测鉴定系统:被误判为“目标检测”的细粒度分类项目
拿到“基于深度学习的舌苔识别检测鉴定系统python源码+论文文档+GUI界面(高分毕业设计)”这个标题,第一反应很容易跑偏:既然写了“检测”,是不是得上一套 YOLO 做目标框?实际做过一轮才发现,舌苔识别严格说是细粒度图像分类问题——舌体区域相对固定,真正要区分的是舌苔颜色、厚薄、质地这些属性,而不是去框出一个个目标。对做毕业设计的人来说,这个判断直接决定了整体方案复杂度,也决定了你后续论文能不能自圆其说。本文就从系统边界拆起,把数据、模型、训练、GUI 整合到答辩演示的完整落地路径理一遍,适合正在开题或已经动手但卡在训练效果的读者照着复现。
2. 系统模块划分与模型选型:为什么这里选分类而不是检测
2.1 从标题拆系统边界:先分清“检测鉴定”和“目标检测”
很多毕业设计论文里写“基于深度学习的舌苔识别检测”,这个词组本身有歧义。目标检测(object detection)解决的是“图里有哪些物体、在哪”,输出框和类别;而舌苔识别要解决的是“舌苔属于哪一类、置信度多高”,本质是图像分类。如果选 YOLO 去做,你会遇到一个尴尬问题:舌苔不是独立物体,它覆盖在舌体表面,没有清晰边界,标注框的框法在数据集内部都未必统一,最终效果往往不如一个干净的分类模型。
我的建议是:舌体分割作为前置步骤,之后接分类网络。先做 ROI 提取,把舌头区域裁剪出来,再做舌苔类型判断。这样每个模块都能单独调优,论文里也能多写一章方法论。系统整体可以拆成四个模块:数据预处理模块、模型训练模块、GUI 交互模块、结果可视化模块。对应到交付物就是源码里的 preprocess.py、train.py、predict.py、gui 目录,外加一份说明完整训练流程的论文文档。
2.2 候选模型对比:ResNet、EfficientNet、MobileNet 选谁当骨干
分类网络选型要考虑你的硬件条件和数据集规模。下面这个表是我做这类项目时的默认评价标准:
| 模型 | 参数量量级 | 224x224 单张推理耗时(CPU) | 适合场景 | 选型理由 |
|---|---|---|---|---|
| ResNet18/ResNet50 | 11M / 25M | 中 | 通用 baseline | 迁移学习资源多,调参资料最好找 |
| EfficientNet-B0 | 5.3M | 中 | 追求精度/FLOPs 平衡 | 同样算力下精度略好,但训练技巧要求高 |
| MobileNetV3 | 4.2M | 快 | 打算打包成 exe 做演示 | 推理快,GUI 响应流畅,CPU 也能跑 |
如果数据集只有几千张、甚至更少,我倾向直接用 ResNet18 起步。原因是它的结构简单,过拟合风险比 ResNet50 和 EfficientNet 小,而且 ImageNet 预训练权重在中医舌象这类纹理特征明显的任务上迁移效果很好。MobileNetV3 留到 GUI 阶段再考虑,用来提升界面响应速度。至于输出类别数,常见舌苔分类方案是白苔、黄苔、灰黑苔、厚腻苔四类起步,有的系统还会加剥苔、少苔,但类别越多数据标注压力越大。做毕设建议先压到 3-4 类,保证每类样本数够用。
2.3 项目目录结构:让论文和代码对得上
源码组织直接影响论文截图和“系统实现”章节的写作素材。我一般用下面这种结构:
tongue_identifier/ ├── data/ │ ├── train/ │ │ ├── white/ │ │ ├── yellow/ │ │ └── gray_black/ │ └── val/ ├── preprocess.py # 舌体分割、ROI 提取、数据清洗 ├── train.py # 训练脚本,输出 best_model.pth ├── predict.py # 单张图片推理,供 GUI 调用 ├── gui/ │ └── main_window.py # PyQt5 界面 ├── models/ │ └── model_zoo.py # 模型定义与加载逻辑 └── docs/ └── 论文文档.md这个结构的好处是代码和论文章节一一对应:论文写数据预处理时引 preprocess.py,写模型设计时引 model_zoo.py,GUI 单独一章。答辩老师问任何一块,你都能立刻指到对应文件,而不是在一坨脚本里翻找。
3. 舌苔数据集构建:采集、清洗、标注与增强的完整流程
3.1 数据从哪来:不要等“公开数据集”
舌苔识别没有像 ImageNet 那样随手可下的公开基准数据集,这是做这个方向第一个要认清的现实。常见的做法是两条路并行:一是自己采集或收集公开网络图片,标注舌苔类型;二是配合中医院校的舌象图谱做清洗。但网络图片质量参差,最常遇到的问题是有大量舌头不起舌苔、拍照光线严重偏色、面部占比过小,这三类要直接过滤,不然模型学到的可能是肤色而不是舌苔。
数据标注层面,建议找做过中医舌诊的同学或老师合作,至少保证一个类别定义共识:白苔指舌苔薄白、舌质基本可见;黄苔指苔色偏黄;灰黑苔涵盖了灰苔和黑苔,因为这类样本天然少,合成一类才能凑够数量。标注结果就是目录名,用 data/train/白苔 这种文件夹形式,不需要额外标注框。
3.2 用 OpenCV 做舌体分割与 ROI 提取:实战代码
把原图直接喂给网络不是不行,但口腔、嘴唇、牙齿这些背景会让模型分心。我一般先用颜色空间分割拿到舌体矩形,再裁剪保存为 ROI 图。颜色空间注意用 HSV,比 RGB 对光线更鲁棒:
import cv2 import numpy as np def extract_tongue_roi(image_path, output_size=(512, 512)): # 统一缩放到固定尺寸,避免不同来源图片分辨率差异影响分割 img = cv2.imread(image_path) if img is None: return None img = cv2.resize(img, output_size, interpolation=cv2.INTER_CUBIC) hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 舌体偏红,但面部、嘴唇也是红黄色系,这里用饱和度来压制皮肤干扰 # H 取 0-25 与 165-180 两段红色区间;S 下限 30 过滤灰白噪点 mask1 = cv2.inRange(hsv, (0, 30, 60), (25, 255, 255)) mask2 = cv2.inRange(hsv, (165, 30, 60), (180, 255, 255)) mask = cv2.bitwise_or(mask1, mask2) # 先做一次闭运算填补舌体内部纹理断层,再开运算去掉细小噪点 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) # 取最大连通域作为舌体候选,忽略嘴唇边缘残留区域 contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None c = max(contours, key=cv2.contourArea) x, y, w, h = cv2.boundingRect(c) roi = img[y:y + h, x:x + w] return roi # 使用示例:遍历原始图目录,把 ROI 存成训练集素材 # save_path 按 data/train/类别名/编号.jpg 组织参数说明:H 取 0-25 是纯红区间,165-180 是红色在 HSV 环上的另一侧,两者合起来覆盖舌质主色调;S 下限设 30,把偏白偏灰的舌苔区域边缘也纳入连通域。V 下限 60 防止黑色背景被错误并入。这套阈值对室内均匀光照有效,如果你手里的图有强侧光,先做一次直方图均衡再分割。分割出的 ROI 长宽比不固定,训练时再 Resize 到 224x224,不要在预处理阶段直接拉伸。
3.3 数据增强的度:哪些安全,哪些会翻车
数据增强最容易走极端。舌苔识别里有一个特殊矛盾:舌苔颜色本身就是诊断特征,所以色相(hue)扰动必须非常克制;但亮度、对比度、几何变换可以放开。下面是一套我验证过的增强组合:
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=10), # hue 扰动只给 0.02,防止把黄苔样本强行“增强”成白苔 transforms.ColorJitter(brightness=0.15, contrast=0.15, saturation=0.05, hue=0.02), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ])scale=(0.8, 1.0) 保证裁剪后舌体不会只截到一小块;RandomRotation 给 10 度,超过 10 度会产生明显的不自然旋转,因为舌头照片一般不会倒转。验证集只用 Resize 和 Normalize,不做随机构造。这里要提醒一个血泪教训:ColorJitter 的 hue 参数一旦超过 0.05,训练准确率看似没掉,但混淆矩阵里白苔和黄苔开始互相错分,因为模型学到了被增强出来的颜色偏移。
4. 训练与调参:从迁移学习到收敛的落地细节
4.1 迁移学习的正确方式:不是全解冻就完事
舌苔数据集动辄几百到几千张,从头训练一个分类网络很难收敛。常见做法是加载 ImageNet 预训练权重,但冻结策略要分层设计。浅层卷积学到的是边缘、纹理、颜色块,这些通用特征直接复用;深层语义特征和 ImageNet 的物体类别强相关,需要针对舌苔类别微调。我通常冻结 layer1 和 layer2,解冻 layer3、layer4 和最后的全连接层。
import torch import torch.nn as nn from torchvision import models num_classes = 4 # 白苔、黄苔、灰黑苔、厚腻苔 def build_model(pretrained=True): if pretrained: model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) else: model = models.resnet18(weights=None) # layer1/layer2 的卷积特征偏向通用图形结构,冻结可减少过拟合 for name, param in model.named_parameters(): if name.startswith('layer1') or name.startswith('layer2'): param.requires_grad = False # 替换分类头:原输出1000类,现在改为舌苔类别数 in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) return model参数说明:requires_grad = False的层不会计算梯度也不更新,节约显存;但注意 BatchNorm 层里的 running_mean 和 running_var 仍会更新,这是正常的。冻结层太多会限制拟合能力,全解冻在小数据集上又容易过拟合。这个折中是相对稳的选择。
4.2 训练脚本核心参数:优化器、学习率与损失函数
优化器我直接用 AdamW,学习率调到 1e-4 起步,配合余弦退火。之前用 SGD+momentum 试过,收敛慢且对初始学习率敏感,做毕设时间有限,AdamW 是后悔药更少的选择。损失函数选 CrossEntropyLoss,它内部做了 softmax 归一化,输出的 logits 直接传入即可。训练主循环如下:
import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR from torch.utils.data import DataLoader, random_split from torchvision import datasets transform_train = ... # 接 3.3 节的增强管线 transform_val = ... # 仅 Resize + Normalize train_ds = datasets.ImageFolder('data/train', transform_train) val_ds = datasets.ImageFolder('data/val', transform_val) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False) model = build_model(pretrained=True) # 关键:只把 requires_grad=True 的参数交给优化器 trainable = filter(lambda p: p.requires_grad, model.parameters()) optimizer = optim.AdamW(trainable, lr=1e-4, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=30, eta_min=1e-6) criterion = nn.CrossEntropyLoss() for epoch in range(30): model.train() train_loss, correct, total = 0.0, 0, 0 for imgs, labels in train_loader: outputs = model(imgs) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() train_loss += loss.item() * imgs.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) scheduler.step() # 每个 epoch 结束后在验证集上测一次,保存最佳模型 val_acc = evaluate(model, val_loader) if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_model.pth') print(f"epoch={epoch}, train_acc={correct/total:.3f}, val_acc={val_acc:.3f}")这里值得解释几个参数:batch_size=32在不大的数据集上稳定,太小则 BatchNorm 统计量抖动大,太大则单步更新次数少且吃显存。weight_decay=1e-4是 ResNet 常用的权重衰减量级,主要约束全连接层,避免分类头把训练集噪声背下来。T_max=30要和总 epoch 数一致,余弦退火才会在一个完整周期内把学习率平滑降到 eta_min。
4.3 训练曲线怎么看:欠拟合、过拟合与“玄学”抖动
训练过程最关键的判断是 train_acc 和 val_acc 的剪刀差。如果 train_acc 到 95% 而 val_acc 卡在 70% 出头,这就是典型的过拟合表现,第一步不是调模型结构,而是回头看 3.3 节的增强是否太弱、类别样本是否太偏。如果两个指标都低,比如都在 60% 附近,说明欠拟合,优先做三件事:去掉过强的冻结策略、把 learning rate 从 1e-4 提到 2e-4、增加训练 epoch。还有一种情况让人发懵:验证集准确率每个 epoch 大幅震荡,前一秒 85%,后一秒 65%。这通常是验证集太小导致统计噪声,建议把验证集固定为独立目录而不是随机切分,每次 validate 用同一个子集。玄学抖动还有一种来源是未固定随机种子,训练前加torch.manual_seed(42)可以让你排错时少一层变量。
5. 常见问题排查与避坑:从数据到界面五处翻车现场
5.1 样本量太小,模型直接“背答案”
现象:训练集每类只有 30-50 张,训练 10 个 epoch 后 train_acc 接近 100%,val_acc 却不到 60%,而且 val_acc 几乎不再上升。
原因:参数量远大于有效样本量,卷积核记住了训练图的特异性纹理,甚至记住了某张图的噪声。这类问题不是换个 loss 能解决的。
解决:先扩样板,每类至少 150-200 张 ROI;同时把冻结层扩展到 layer3,至少覆盖一半网络;最后把 ResNet18 换成结构更浅的变体,或者只解冻最后一个残差块和全连接层。扩样与限制模型容量二选一时先做前者。
5.2 类别不平衡:灰黑苔样本只有白苔的五分之一
现象:混淆矩阵里白苔和黄苔精确率还行,但灰黑苔几乎全部被预测成黄苔,召回率惨不忍睹。
原因:CrossEntropyLoss 默认把每个样本损失等权相加,大头类别主导梯度方向,小样本类别学不出来。
解决:给 loss 传入类别权重,权重设为该类样本数的倒数。代码里先统计每类样本数,再构造权重张量:
from collections import Counter from torch import tensor label_counts = Counter([label for _, label in train_ds.samples]) total = len(train_ds) class_weights = tensor([total / label_counts[i] for i in range(num_classes)]) criterion = nn.CrossEntropyLoss(weight=class_weights)补充做法是把灰黑苔样本做更强的复制增强(比如多生成 3 个副本),但注意复制样本要在随机增强之后,否则模型还是容易过拟合到同一批图上。两者配合时,class weight 对训练稳定性帮助更直接。
5.3 GUI 推理结果和训练时不一致:验证集 90%,界面里一用就废
现象:脚本里跑验证集准确率 90%,但是把同样的权重接入 GUI 后,随便拿一张测试图预测,结果和直觉不符,甚至多张图结果一样。
原因:训练前处理和 GUI 前处理不一致,最常见的是 GUI 里用 OpenCV 读图,OpenCV 的 imread 返回 BGR 通道顺序,直接转成 tensor 喂给模型,颜色通道错位。其次是 GUI 里没有做 Normalize,或者对图片做了不同尺寸的 Resize 插值。
解决:把预处理封装成一个独立函数,训练和 GUI 共用同一份实现,不要在 GUI 里重写一套逻辑。特别是通道顺序,务必cv2.cvtColor(img, cv2.COLOR_BGR2RGB)之后再转 PIL,或者干脆在 GUI 里用 PIL 打开图片:
from PIL import Image from torchvision import transforms def preprocess_for_model(img_path): img = Image.open(img_path).convert('RGB') # 同一个 transform,和训练验证保持一致 return transform_val(img).unsqueeze(0)5.4 模型加载报错:state_dict 尺寸对不上或 device 不匹配
现象:torch.load后加载权重,提示size mismatch for fc.weight,或者直接报RuntimeError: Attempting to deserialize object on a CUDA device。
原因:前者是保存的是带分类头的完整模型,而重新构建模型时 num_classes 不一致;后者是训练用 GPU,加载到 CPU 机器时没有指定 map_location。
解决:第一处检查构建模型时传的 num_classes 和训练时一致;第二处在加载时固定用:
state = torch.load('best_model.pth', map_location='cpu') model.load_state_dict(state, strict=True)另外,权重要在 GUI 初始化时就加载好,不要每次点“识别”按钮都加载一次,否则界面会卡死几秒,看起来像“程序无响应”。
5.5 打包成 exe 后模型加载路径失效
现象:在 PyCharm 里跑 GUI 一切正常,打包成 exe 运行后模型文件找不到,或界面皮肤/资源加载失败。
原因:代码里写的相对路径'best_model.pth'依赖当前工作目录,exe 启动时工作目录通常在C:\Windows\System32或安装目录之外。
解决:用基于__file__的绝对路径定位资源文件。在这类项目里资源文件被打包进去后实际会被解压到临时_MEIPASS目录,所以要先判断运行环境:
import sys, os def resource_path(relative_path): # 兼容 PyInstaller 打包后的资源路径 if hasattr(sys, '_MEIPASS'): return os.path.join(sys._MEIPASS, relative_path) return os.path.join(os.path.dirname(__file__), relative_path) model_path = resource_path('best_model.pth')提醒一句:模型文件动辄几十 MB,打包配置里要显式把.pth文件加进去,否则运行时报 FileNotFoundError 很让人摸不着头脑。
6. GUI 集成与答辩演示:把模型封装成可交付的高分毕设
6.1 PyQt5 封装推理逻辑:界面代码和模型代码分离
GUI 界面在毕设里承担的是“可运行、可展示”的角色。我一般用 PyQt5 写一个三栏布局:左侧图片预览、中间控制按钮、右侧识别结果与置信度。核心是把模型封装成一个小类,和界面彻底解耦,这样答辩现场运行出错时能快速定位问题:
from PyQt5.QtWidgets import QWidget, QLabel, QPushButton, QVBoxLayout, QFileDialog, QMessageBox from predict import TonguePredictor class MainWindow(QWidget): def __init__(self): super().__init__() # 模型在窗口初始化时加载一次,按钮响应时只做推理,避免卡顿 self.predictor = TonguePredictor('models/best_model.pth', class_names=['白苔', '黄苔', '灰黑苔', '厚腻苔']) # ... 控件布局略 self.btn_predict.clicked.connect(self.run_predict) def run_predict(self): path, _ = QFileDialog.getOpenFileName(self, '选择舌象图片', '', '图片文件 (*.jpg *.png)') if not path: return # predictor 内部负责 preprocess -> model -> softmax label, confidence = self.predictor.predict(path) self.label_result.setText(f"识别结果:{label}\n置信度:{confidence:.2%}")这里最容易被忽视的点是:predict 函数内部处理的异常要返回给界面显示,比如图片路径为空、通道数不对、图片模糊到分割不出舌体。否则用户点“识别”按钮,界面毫无反应,答辩现场翻车就很难挽回。给每个异常都弹 QMessageBox 提示,也是论文里“系统鲁棒性”的加分细节。
6.2 答辩演示的量化验证:混淆矩阵和消融实验
高分毕设和普通毕设的区别往往就在验证那一章。除了总准确率,把四类舌苔的混淆矩阵画出来,能直观展示模型在哪对哪错。另一个近乎必做的是消融实验:固定数据,对比 ResNet18 有/无预训练权重、有/无数据增强、有/无类别权重,三组实验各跑一遍,把结果列成一个表:
| 配置 | 白苔准确率 | 黄苔准确率 | 灰黑苔准确率 | 总准确率 |
|---|---|---|---|---|
| 无预训练 | 74% | 69% | 31% | 61% |
| 预训练+基础增强 | 87% | 83% | 52% | 78% |
| 预训练+全套增强+类别权重 | 90% | 89% | 68% | 84% |
这种表拿给答辩老师看,信息量比单说“准确率 84%”大得多。我自己的习惯是训练脚本里把每个 epoch 的记录写进 CSV,最后画训练曲线和混淆矩阵;模型文件命名带上 val_acc,比如resnet18_val84.3.pth,省得调参中途忘了哪个权重是哪个实验出来的。这个习惯帮我避过很多次“这个模型到底哪来的”的追问,也省下过重训一版的时间。最后再做一次可视化:挑几张典型成功样本和典型失败样本,标注预测类别和真实类别,摆在 GUI 右侧展示,比任何口头说明都有说服力。整套流程下来,论文、源码、界面三个交付物逻辑自洽,答辩时被挑战的余地就小了。希望这个落地思路能帮到你,少走我当年在数据增强上翻过的车。
本文还有配套的精品资源,点击获取