简介:一份整合了Python源码、PyQt5图形界面、训练模型与毕业论文的深度学习舌苔识别检测系统,适合计算机视觉或医学图像处理方向的毕业设计及项目实践者。压缩包共110个文件,主要包含Python脚本、pyc编译文件、PyQt5界面ui与ttc字体、模型pth权重、训练日志及数据集图片,另有json配置与docx论文文档,整体约104.93MB,目录结构清晰,便于按模块查阅。目前已有536人学习下载。资源完整覆盖舌苔识别检测全流程,从舌象数据集构建与图像增强扩充,到DCGAN生成舌象图片和卷积神经网络设计训练,再到体质辨识需求与功能实现均有涉及;配套毕业论文详细阐述了研究背景、机器学习理论、需求分析、数据集构建及网络设计等内容,同时提供TensorBoard训练日志可供复盘调参,适合希望快速复现系统、理解模型训练与界面联调细节的学习者。
1. 为什么舌苔识别值得用深度学习做
中医舌诊讲究望闻问切,但舌苔颜色、厚度、腻度全靠医生肉眼归类,主观性很强。同一张舌象照片,不同医生可能给出不同结论,这种不一致恰好是深度学习能解决的问题。你手里这套系统,做的就是舌苔分类和体质辨识:输入一张舌面照片,由卷积神经网络推断苔色、苔质以及对应的体质倾向。更难得的是,它不是一个孤立的算法demo,而是把数据集标注、图像增强、DCGAN数据扩充、ResNet训练和PyQt5桌面界面串成了完整闭环,里面还带了训练事件文件和可以直接跑的Python源码。对想入门图像分类的从业者或者做毕业设计的学生来说,最有价值的点在于你能完整看到数据怎么整理、模型怎么训练、模型怎么被GUI程序调用,这三个环节正好是工程落地的全部。
2. 数据集先于模型:舌象标注、图像增强与DCGAN扩充
2.1 舌象标注:先定义分类轴
舌苔识别首要问题不是网络结构,而是标签体系。直接使用连续症状描述没法训练分类器,绝大多数项目会把舌象映射到固定类别。常见做法是沿着苔色和苔质两个维度打标:苔色分为白苔、黄苔、灰苔、黑苔,苔质分为薄、厚、腻、剥。落到体质辨识场景里,还要把这些特征组合映射成体质类型,这样输出结果对普通用户更直观。
我在类似舌象项目里常用下面这套标签表,实际项目中建议把label存成csv,让训练脚本和界面共用一份映射文件,避免两边各写一个顺序。
| 类别标签 | 舌象特征 | 对应体质倾向 |
|---|---|---|
| 0 | 舌淡红、苔薄白 | 平和质 |
| 1 | 舌红、苔黄腻 | 湿热质 |
| 2 | 舌淡胖、苔白腻 | 痰湿质 |
| 3 | 舌红少津、苔少裂纹 | 阴虚质 |
标注时有几个容易踩的坑:一是同一张图尽量由两个人独立标注,标签冲突的样本协商或者剔除;二是图片命名不要用中文和空格,目录结构提前分成train、val、test三份;三是原始舌象通常是手机拍摄,环境光照参差不齐,后续增强策略要覆盖亮度变化。
2.2 基础图像增强:为什么用这几组参数
数据量小是这类课题共同的痛点,几百张原始舌象不可能直接喂给深度网络。最直接的扩充手段是几何变换和颜色变换。我一般只对训练集做增强,验证集和测试集保持原图,否则在线增强会让验证集失去参考意义。
from PIL import Image import random def basic_augment(img): random.seed(123) # 左右翻转,模拟舌头左右摆放偏移 if random.random() > 0.5: img = img.transpose(Image.FLIP_LEFT_RIGHT) # 旋转限制在±15度,避免舌面空间关系被破坏 angle = random.uniform(-15, 15) img = img.rotate(angle, fillcolor=(255, 255, 255)) # 亮度调整,覆盖不同拍摄光照场景 factor = random.uniform(0.8, 1.2) img = img.point(lambda p: min(255, max(0, int(p * factor)))) return img逻辑说明:旋转角度范围取±15度,因为真实采集时舌头倾斜幅度不会太大,角度过大产生的黑色填充区域会干扰模型;fillcolor设置成白色是为了保持舌面背景的自然感。亮度因子放在0.8到1.2之间,相当于在同一张图上叠加了4种亮度环境。point操作是对每个像素做乘法,再用min、max做颜色截断,防止数值溢出。如果采集环境整体偏暗,可以把factor下限降到0.7、上限升到1.3,但不能继续扩大,否则舌色从淡红变成暗红,标签语义就被破坏了。
除了上面的基本操作,还可以把HSV空间里的H通道做小幅偏移,比如偏移±5度,以及随机裁剪后再缩放到统一尺寸。随机裁剪的采样中心最好居中偏下,因为舌面主体通常出现在取景框下半部分,裁剪到牙齿反而会引入无关纹理。
2.3 DCGAN扩充舌象样本:结构、损失与不稳定判断
当基础增强手段用尽仍然不够时,就要考虑生成对抗网络。DCGAN相比StyleGAN训练成本低,生成图像分辨率不高,但用于扩充分类模型的训练样本基本够用。生成器和判别器通常按下面的方式构造。
import torch.nn as nn class Generator(nn.Module): def __init__(self, z_dim=100, out_channels=3): super().__init__() self.net = nn.Sequential( nn.ConvTranspose2d(z_dim, 256, 4, 1, 0), nn.BatchNorm2d(256), nn.ReLU(True), nn.ConvTranspose2d(256, 128, 4, 2, 1), nn.BatchNorm2d(128), nn.ReLU(True), nn.ConvTranspose2d(128, 64, 4, 2, 1), nn.BatchNorm2d(64), nn.ReLU(True), nn.ConvTranspose2d(64, out_channels, 4, 2, 1), nn.Tanh() ) def forward(self, z): return self.net(z) class Discriminator(nn.Module): def __init__(self, in_channels=3): super().__init__() self.net = nn.Sequential( nn.Conv2d(in_channels, 64, 4, 2, 1), nn.LeakyReLU(0.2), nn.Conv2d(64, 128, 4, 2, 1), nn.BatchNorm2d(128), nn.LeakyReLU(0.2), nn.Conv2d(128, 256, 4, 2, 1), nn.BatchNorm2d(256), nn.LeakyReLU(0.2), nn.Conv2d(256, 1, 4, 1, 0) ) def forward(self, x): return self.net(x)逻辑说明:生成器从100维高斯噪声z上采样出64x64三通道图像,转置卷积加BatchNorm加ReLU是DCGAN的标准配置;输出层用Tanh把像素压到-1到1,因此训练前真实舌象图片要同步归一化到[-1,1]区间。判别器用LeakyReLU,斜率取0.2,可以避免生成图像梯度消失。实际训练中判别器很容易压过生成器,典型信号是D loss快速跌到接近0而G loss飙升。这时生成样本对判别器来说太假了,常见对策是把生成器学习率调到0.0004、判别器保持0.0002,或者每训练两步判别器再训练一步生成器。
DCGAN生成的图片需要人工抽检,剔除带网格伪影或舌头形状畸变的样本,再与真实数据混合。生成样本比例不要超过真实数据的30%,否则分类模型会对生成分布产生偏好,遇到真实拍摄图反而掉点。项目论文第四章专门写了GAN相关概念,这里的结构可以直接承接那一章内容。
3. 舌苔分类网络设计:从输入尺寸到训练策略
3.1 主干选ResNet而不是ViT的理由
舌苔识别属于细粒度图像分类,类别差异集中在颜色和纹理变化,对局部特征敏感。ResNet通过残差连接让梯度可以跨层传播,在几千张级别的数据上就能训出稳定效果。现在不少文章把transformer模型详解讲得很彻底,于是总想上ViT,但ViT需要超大训练集和大规模预训练才能发挥优势,几百到几千张舌象直接训练容易过拟合。即便使用加载了ImageNet预训练权重的ViT,也要配更强的数据增强和更长的训练策略。这个项目里使用ResNet系列,是医疗小样本图像里的稳妥选择。
3.2 数据加载器:归一化参数不能乱改
先看数据预处理pipeline,重点不是模型花哨,而是预处理参数要和预训练权重匹配。
from torch.utils.data import DataLoader from torchvision import transforms transform_train = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.15, contrast=0.15), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) transform_val = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True)逻辑说明:Resize到224是ResNet的标准输入尺寸,模型最后的全连接层输出对应类别数。RandomHorizontalFlip解决舌头左右偏移,ColorJitter的幅度比第2章手工版本温和,因为ToTensor以后像素范围变成0到1,手工增强里0.8到1.2的亮度因子到这里需要按比例折算。Normalize用ImageNet统计值不是随手写的,加载预训练权重时必须配套这组mean和std,否则第一个batch的特征分布就和预训练参数不匹配,表现为loss下降缓慢。
DataLoader的batch_size设为32,显存不足时降到16,不要优先调num_workers为0,否则数据加载会拖慢训练速度。pin_memory=True让GPU拷贝更快,CPU训练时设成False,否则白白占用内存。
3.3 训练循环与超参设置
下面的训练循环保留早停和最优模型保存,适合复现论文里的实验曲线。
from torchvision import models import torch.nn as nn import torch model = models.resnet18(pretrained=True) model.fc = nn.Linear(512, 4) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.1) best_acc = 0.0 for epoch in range(60): model.train() for images, labels in train_loader: images, labels = images.cuda(), labels.cuda() outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.cuda(), labels.cuda() _, predicted = torch.max(model(images), 1) total += labels.size(0) correct += (predicted == labels).sum().item() acc = correct / total print(f"epoch {epoch} acc {acc:.4f}") if acc > best_acc: best_acc = acc torch.save(model.state_dict(), "tongue_resnet.pth") scheduler.step()逻辑说明:替换最后一层全连接,把ImageNet的1000类输出改成4类。CrossEntropyLoss内部自带Softmax,模型尾部不需要再接一层。Adam初始学习率0.001对迁移学习来说比较稳,如果loss剧烈震荡就降到0.0003。StepLR每20轮乘0.1,让后期步长变小,拟合更精细。模型保存时机是验证集准确率最高的一刻,文件名用相对路径,但要注意和PyQt5界面加载时的工作目录保持一致。
这套配置可以直接沉淀成超参表:
| 参数名 | 值 | 调整建议 |
|---|---|---|
| 输入尺寸 | 224x224 | 数据量大可升到256,量小维持224 |
| batch_size | 32 | 显存不足时降到16 |
| 学习率 | 0.001 | 微调用0.001,从头训练用0.01 |
| weight_decay | 1e-4 | 过拟合明显时加大到5e-4 |
| 训练轮数 | 60 | 搭配早停策略 |
3.4 迁移学习和冷启动的细微差别
这里有个容易踩的坑:ResNet加载pretrained=True之后,如果直接冻结backbone只训练fc层,对舌象这种与ImageNet差异较大的任务效果反而一般。我一般选择不冻结,整个网络参与训练,只是把初始学习率调低,让预训练特征逐渐适应舌象分布。观察训练过程,如果训练集准确率快速升到95%以上而验证集落后超过10个点,说明已经过拟合,优先把第2章的在线增强参数加强,再调大weight_decay,最后才考虑换成更深网络。
4. PyQt5 界面、模型加载与推理链路
4.1 界面组件如何划分
PyQt5的作用是给训练好的模型套一个桌面壳。一个能交付的界面至少要有图片选择按钮、图片显示区、识别结果区、体质建议区。项目提供的是.py源码,说明界面是QtDesigner拖出来的或者完全手写的。手写UI的好处是后续调整widget布局方便,缺点是代码稍长。我习惯把模型加载和推理封装到独立类里,让界面代码和深度学习代码不互相纠缠。
| 控件类型 | 变量名 | 作用 |
|---|---|---|
| QPushButton | btn_open | 触发文件选择对话框 |
| QLabel | image_label | 显示缩放后的舌象图片 |
| QLabel | result_label | 显示识别结果和置信度 |
| QPlainTextEdit | advice_text | 显示体质倾向建议 |
4.2 加载本地模型与预处理一致性
推理链路的关键是让模型处于和训练时完全一致的状态。加载模型后必须调用eval(),关闭梯度计算,否则BatchNorm层在训练和推理两种状态下输出会漂移。这里是从界面上传图片到输出完整结果的最小实现。
from torchvision import transforms, models import torch.nn.functional as F from PIL import Image import torch class TongueClassifier: def __init__(self, model_path, class_names): self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") self.model = models.resnet18(num_classes=len(class_names)) self.model.load_state_dict( torch.load(model_path, map_location=self.device)) self.model.to(self.device) self.model.eval() self.class_names = class_names self.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]) ]) def predict(self, image_path): img = Image.open(image_path).convert("RGB") x = self.transform(img).unsqueeze(0).to(self.device) with torch.no_grad(): logits = self.model(x) prob = F.softmax(logits, dim=1)[0] idx = int(torch.argmax(prob).item()) probs = {c: round(p, 4) for c, p in zip(self.class_names, prob.tolist())} return self.class_names[idx], probs逻辑说明:torch.load需要加上map_location,因为训练事件文件来自LAPTOP主机,训练产物可能在不同机器之间迁移,不指定位置容易遇到CUDA不可用错误。load_state_dict之前必须先构造结构一致的模型,这里用resnet18并传num_classes。predict方法返回类别名和所有类别的概率字典,方便界面展示百分比。F.softmax的dim=1表示在类别维度归一化,不要用torch.softmax替代,不同版本对维度语义的处理容易造成混淆。
类别名列表的顺序必须和第2章的标签顺序完全一致,顺序错一位,结果全错。
4.3 槽函数里调用推理并刷新显示
界面部分用按钮触发文件选择,选中图片后既显示在界面上,也把路径传给分类器。
from PyQt5.QtWidgets import (QWidget, QPushButton, QLabel, QFileDialog, QVBoxLayout) from PyQt5.QtGui import QPixmap class MainWindow(QWidget): def __init__(self, classifier): super().__init__() self.classifier = classifier self.btn = QPushButton("选择舌象图片") self.image_label = QLabel() self.result_label = QLabel("点击按钮选择图片") layout = QVBoxLayout() layout.addWidget(self.btn) layout.addWidget(self.image_label) layout.addWidget(self.result_label) self.setLayout(layout) self.btn.clicked.connect(self.on_click) def on_click(self): path, _ = QFileDialog.getOpenFileName( self, "选择舌象", "", "图片文件 (*.jpg *.png)") if not path: return pixmap = QPixmap(path).scaledToWidth(320) self.image_label.setPixmap(pixmap) name, probs = self.classifier.predict(path) self.result_label.setText(f"识别:{name}\n置信度:{probs}")逻辑说明:QFileDialog把文件类型过滤为jpg和png,不放开webp是因为部分采集设备和老版本库支持不稳定。scaledToWidth只固定宽度320像素,高度按比例缩放,避免图片变形。predict方法在主线程执行,ResNet18推理一次几十毫秒,界面不会明显卡顿;如果后续换成更重网络或者需要同时处理多张图,再把推理放到QThread的run方法里,用信号把结果传回主线程。
界面和模型解耦之后,TongueClassifier替换成MobileNet或ViT实现,主窗口代码完全不用改,这个接口设计值得保留。
5. 训练日志排查:从tfevents到实测调参
5.1 直接解析events.out.tfevents文件
项目源码目录里有一组events.out.tfevents.*文件,这些是TensorFlow训练时TensorBoard的事件文件。从时间戳跨度看覆盖了多轮实验,比如1652188470和1649325615这两个编号对应不同训练阶段,直接跑TensorBoard就能看到曲线。
tensorboard --logdir . --port 6006然后在浏览器打开localhost:6006。如果只想快速读取数值而不开浏览器,可以这样解析:
from tensorflow.python.summary.summary_iterator import summary_iterator for event in summary_iterator("events.out.tfevents.1652188470.LAPTOP-ACFSLO5L.12688.0"): if event.HasField("summary"): for value in event.summary.value: if value.tag.startswith("loss") or value.tag.startswith("acc"): print(event.step, value.tag, value.simple_value)逻辑说明:summary_iterator按照事件文件顺序逐条返回记录,判读HasField可以跳过没有summary的空事件。tag过滤保留loss和acc相关量,step就是训练迭代步数。把这个脚本批量跑在不同事件文件上,就能对比出哪一轮实验收敛更快。
5.2 从loss曲线判断训练状态
看loss曲线时按三个特征判断:训练loss和验证loss同步下降,说明学习率合适;训练loss持续下降但验证loss中途回升,说明过拟合,需要回看增强配置;loss曲线呈锯齿状震荡,多数情况下学习率偏大,或者batch_size太小。多个tfevents文件恰好对应不同配置的实验,比较文件大小和时间跨度可以推断出哪次训练迭代更充分。
5.3 用测试集验证留存模型
训练曲线只能说明模型在验证集上的表现,最后一步必须放到完全没参与训练的测试集上算混淆矩阵。
from sklearn.metrics import classification_report, confusion_matrix import numpy as np y_true = [] y_pred = [] for images, labels in test_loader: images = images.cuda() with torch.no_grad(): preds = torch.argmax(model(images), dim=1).cpu().numpy() y_true.extend(labels.numpy()) y_pred.extend(preds) print(classification_report(y_true, y_pred, target_names=["0", "1", "2", "3"])) print(confusion_matrix(y_true, y_pred))逻辑说明:手动遍历测试集,把每个batch的预测值拼起来,再交给sklearn计算精确率和召回率,比在训练循环里写metric要直观。混淆矩阵对角线越集中说明分类越稳定。当某一列的数值集中而其他列为0,说明模型对这个类别过拟合,回头优先做类别平衡和数据增强。部署前用这个混淆矩阵替换掉论文里的准确率曲线,能明显提升实测结果的可信度。
本文还有配套的精品资源,点击获取