舌苔识别系统实战:数据标注、模型训练与GUI封装全流程解析

发布时间:2026/10/25 19:20:57
舌苔识别系统实战:数据标注、模型训练与GUI封装全流程解析

简介:基于深度学习的舌苔识别检测鉴定系统是一套完整的毕业设计项目,面向计算机相关专业正在准备毕设的学生,以及需要项目实战练习的初学者。项目包含Python源码、深度学习模型权重、基于Qt的GUI界面和完整的毕业论文文档,覆盖数据准备、模型训练、效果评估到界面部署的端到端流程,整体评审得99分,代码完整可直接运行。压缩包共109个文件,大小105.44MB,主要包含26个Python脚本、6个模型权重文件、5个JSON配置、2个界面UI文件及2个Word论文文档,另有训练过程中生成的TensorBoard日志,可观察模型收敛曲线与训练细节。已有132人浏览学习,这套项目既可作为课程设计或期末大作业的完整参考,也可帮助学习者快速搭建舌苔识别Demo,理解从算法到应用的落地方法。包内还配有示例图片与说明文本,运行环境清晰,适合从零开始完成毕设或学习深度学习项目实践。

1. 舌苔识别系统:为什么说数据标注比训练模型更值得投入

舌苔识别在中医数字化里是个“看似好上手、实则会卡你一个月”的方向:图像分类、目标检测、语义分割技术都能套上去,Python 生态里随便找一个开源框架就能把模型训练起来,但真正决定你能不能拿高分毕业设计的,往往不是网络结构,而是你有没有一套靠谱的舌苔图像数据集和标注规范。这套系统做的是把“黄苔、白苔、厚腻苔、剥苔”这类中医描述转化成可量化的视觉特征,用深度学习模型给出类别判断或区域定位,再用 GUI 界面把结果直观展示出来,补齐课题、开题、答辩、论文里的完整链路。适合两类人:想用毕设切入医学图像方向的本科生,以及打算做中医量化辅助工具、需要先跑通可视化原型的开发者。先给结论:模型选型用 ResNet 这类成熟分类网络即可,真正拉开差距的是数据清洗、标注一致性和 GUI 交互设计这三件事。

2. 先解决数据:舌苔图像数据集的采集、标注与预处理

2.1 数据从哪来:公开数据集、自采与伦理红线

我见过不少同学一上来就搜“舌苔数据集下载”,结果要么找到的是几十张分辨率不一的零散图片,要么是带水印的电商截图,训练出来的模型泛化能力几乎为零。常见做法是三条路并行:第一,公开的舌象数据集,比如一些高校开源的中医舌象图像库,百级到千级规模,够做类别均衡的实验;第二,自己用手机统一光源拍摄,找同学或门诊配合采集,注意遮挡面部只保留舌部区域;第三,论文里的公开数据作为补充,但必须确认版权许可。

这个环节要特别留意伦理问题。舌苔图像虽然不像人脸那样强身份属性,但口腔黏膜和舌象同样属于个人健康信息,正规做法是对图像做脱敏处理,只保留舌体区域,并在论文里写明数据来源与参与者的知情同意。很多学校的开题答辩会专门问这件事,提前在文档里写清楚能省掉后期大改的麻烦。

2.2 标注方案:分类用文件夹,检测用 VOC 转 YOLO

如果你的系统定位是“识别鉴定”,建议至少做成目标检测——不仅说出是黄苔还是白苔,还要框出舌体区域。这样论文里能多一张可视化检测图,答辩时效果远好于只给一个分类概率。

标注工具我用的是 LabelImg,导出 Pascal VOC 格式的 XML。但训练时 YOLO 系列要 txt 格式,中间必须过一道转换脚本。下面这段是每次都要用的转换工具,把 XML 里的坐标转成归一化的 YOLO 格式:

import os import xml.etree.ElementTree as ET def convert_voc_to_yolo(xml_path, out_dir, class_names): tree = ET.parse(xml_path) root = tree.getroot() img_w = int(root.find("size/width").text) img_h = int(root.find("size/height").text) txt_name = os.path.splitext(os.path.basename(xml_path))[0] + ".txt" lines = [] for obj in root.iter("object"): class_name = obj.find("name").text if class_name not in class_names: continue cls_id = class_names.index(class_name) box = obj.find("bndbox") x_min = float(box.find("xmin").text) y_min = float(box.find("ymin").text) x_max = float(box.find("xmax").text) y_max = float(box.find("ymax").text) # 转成 YOLO 需要的中心点 + 宽高格式,并做归一化 center_x = (x_min + x_max) / 2.0 / img_w center_y = (y_min + y_max) / 2.0 / img_h box_w = (x_max - x_min) / img_w box_h = (y_max - y_min) / img_h lines.append(f"{cls_id} {center_x:.6f} {center_y:.6f} {box_w:.6f} {box_h:.6f}") with open(os.path.join(out_dir, txt_name), "w") as f: f.write("\n".join(lines)) class_names = ["tongue", "yellow_coating", "white_coating", "thick_greasy"] out_dir = "yolo_labels" os.makedirs(out_dir, exist_ok=True) for xml_file in os.listdir("voc_annotations"): if xml_file.endswith(".xml"): convert_voc_to_yolo(os.path.join("voc_annotations", xml_file), out_dir, class_names)

这段代码的核心是把 XML 里记录的目标左上角和右下角坐标,换算成 YOLO 系列模型要求的“中心点坐标 + 目标宽高”格式,同时除以图片宽高做归一化,避免不同分辨率图片之间数值量纲不一致。注意 class_names 的顺序必须和后续训练配置文件完全一致,否则会出现框的位置对但类别错乱的诡异问题。我在实际使用中遇到过一个典型坑:标注时把"white_coating"写成了"white coating",空格和下划线混用,训练时类别映射全部错位,这种低级错误靠肉眼很难查出来,建议标注前先把类别清单固定并做一次脚本校验。

2.3 预处理与数据增强:颜色扰动要克制

舌苔识别对颜色极其敏感——黄苔和白苔的主要差异就是颜色倾向和覆盖程度,所以预处理阶段不能像做自然影像分类那样开很强的颜色扰动。我一般固定这样一套:先等比缩放到 640×640 或 416×416,保留舌体区域居中裁剪;再做归一化,用 ImageNet 的 mean 和 std 即可,没必要自己重新统计;数据增强只用水平翻转、轻微旋转 ±10 度、小范围亮度抖动,色相饱和度扰动控制在 0.02 以内,否则训练集里的人工偏色会直接污染模型对真实舌色的判断。

另外一个容易被忽略的点是曝光一致性。很多人用手机拍舌苔图,室内外色温差很大,白平衡不统一会让模型把“偏蓝”和“偏黄”学成类别特征。我一般会先用 OpenCV 做一次灰度世界白平衡校正,代码就一行,让图片的整体色彩偏移回到中性,再进入训练管线。这一步对最终精度的影响经常有 3 到 5 个百分点的差距。

3. 模型选型与训练参数:分类网络的克制选择与检测头的尝试

3.1 为什么优先用 ResNet 而不是堆新架构

舌苔识别这个场景的数据量通常只有几千张,类别之间差异又集中在纹理和颜色细节上,用太深的网络或者 Transformer 架构很容易过拟合。我一般首推 ResNet18 或 ResNet50,理由很直接:参数量适中,PyTorch 官方有预训练权重可以直接做迁移学习,训练时间在单张消费级显卡上可以控制在几小时内。ResNet18 走轻量路线,适合机器配置一般的情况;ResNet50 表达能力强一些,但数据不足时对正则化要求高,需要同时在数据增强和 Dropout 上做补偿。

如果坚持要做目标检测出来的“框”,那就用 YOLOv8 或 Faster R-CNN。我遇到过不少同学想在毕设里用 Faster R-CNN 以显示“有难度”,结果训练时间直接翻了几倍,数据量不够时收敛也不稳定。合理折中是 YOLOv8 的 nano 或 small 版本,既出检测框,训练速度也快,论文里还能放一张带置信度的检测图。

3.2 训练脚本:从加载预训练权重到完整微调

下面这段是训练分类模型的最小可用脚本,我把关键配置都写在注释里。它先加载 ResNet18 预训练权重,把最后一层全连接改成自己的类别数,然后用带权重衰减的 SGD 做微调。

import torch import torch.nn as nn from torchvision import models, transforms from torch.utils.data import DataLoader from dataset import TongueDataset # 类别按数据标注顺序固定,乱序会导致训练指标正确但实际预测错位 num_classes = 4 device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = models.resnet18(pretrained=True) model.fc = nn.Linear(model.fc.in_features, num_classes) model = model.to(device) # 只对最后的全连接层用较大的学习率,前面的卷积层用小学习率做温和微调 optimizer = torch.optim.SGD([ {"params": model.layer4.parameters(), "lr": 1e-4}, {"params": model.fc.parameters(), "lr": 1e-3}, ], momentum=0.9, weight_decay=1e-4) criterion = nn.CrossEntropyLoss() scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1) train_loader = DataLoader( TongueDataset("data/train", transform=transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ])), batch_size=32, shuffle=True, num_workers=4) for epoch in range(15): model.train() running_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) scheduler.step() print(f"Epoch {epoch+1}/15, Loss: {running_loss / len(train_loader.dataset):.4f}")

这段代码有三处最关键:第一,分组学习率,卷积层用 1e-4 保持预训练特征不剧烈变化,全连接层用 1e-3 快速适应新类别;第二,交叉熵损失直接配合预训练模型,不需要自行实现权重初始化;第三,StepLR 每 5 个 epoch 降一次学习率,让后期收敛更稳。如果你发现 loss 在 0.8 左右就降不下去,先检查是不是类别不均衡——舌苔数据里健康薄白苔通常占绝对多数,这时候要把 CrossEntropyLoss 换成带类别权重的版本。

3.3 类别不平衡:加权损失与 Focal Loss 的取舍

舌苔数据天然存在严重不平衡,薄白苔占 60% 以上,剥苔、灰黑苔可能只有几十张。常见做法是给每个类别算权重,让少数类在损失函数里获得更大的梯度。我用的是 sklearn 的 compute_class_weight,然后传入 CrossEntropyLoss 的 weight 参数。如果少数类样本实在太少(个位数),就得额外考虑用数据增强复制,或者干脆在论文里说明这类样本的局限性。Focal Loss 是另一个选项,它对难分类样本更敏感,但参数调起来比较玄学,alpha 和 gamma 组合需要多轮网格搜索,时间紧的话不建议在毕设里硬上。

我在实践里见过的翻车案例是:不做类别平衡直接训练,模型对薄白苔的准确率冲到 95%,看起来整体指标很漂亮,但把黄苔、厚腻苔的召回率拉到 30% 以下。答辩时老师随手问一句“模型区分黄白苔的能力怎么样”,就答不上来。所以务必要看每类的混淆矩阵,不能只看总准确率。

4. GUI 界面与系统集成:用 PyQt5 把模型封装成可直接演示的工具

4.1 框架选择的理由:Tkinter 太素,PyQt5 的收益更明显

毕设答辩现场给老师演示,界面观感直接影响印象分。Tkinter 虽然零依赖,但做出来的东西偏工具风,按钮和布局都比较简陋。PyQt5 虽然要多装两个包,但样式、布局、图片展示效果专业很多,而且 PyQt5 的 QGraphicsView 做标注框叠加显示非常顺手,论文里截一张图出来档次就不一样。常见做法是:主界面左侧放图片选择预览区,右侧放识别结果面板,下方是置信度条和保存报告按钮。

如果机器的 Python 环境装 PyQt5 有兼容问题,退而求其次用 Qt for Python 的 PySide6,API 基本一致。

4.2 最小 GUI 代码:加载模型、选择图片、输出结果

下面这段是封装推理流程的最小 PyQt5 代码,去掉菜单栏和样式细节,只保留核心链路:选择图片、预处理、模型推理、展示结果和置信度。

import sys import torch from PIL import Image from torchvision import transforms from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QPushButton, QFileDialog, QVBoxLayout, QWidget from PyQt5.QtGui import QPixmap class TongueGUI(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("舌苔识别鉴定系统") self.model = torch.load("tongue_model.pth", map_location="cpu") self.model.eval() self.transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) self.class_names = ["薄白苔", "黄苔", "厚腻苔", "剥苔"] self._init_ui() def _init_ui(self): self.image_label = QLabel("请选择舌苔图片") self.result_label = QLabel("识别结果:") self.btn = QPushButton("打开图片") self.btn.clicked.connect(self.open_image) layout = QVBoxLayout() layout.addWidget(self.image_label) layout.addWidget(self.result_label) layout.addWidget(self.btn) container = QWidget() container.setLayout(layout) self.setCentralWidget(container) def open_image(self): path, _ = QFileDialog.getOpenFileName(self, "选择图片", "", "Image Files (*.jpg *.png)") if not path: return pixmap = QPixmap(path).scaled(300, 300) self.image_label.setPixmap(pixmap) image = Image.open(path).convert("RGB") input_tensor = self.transform(image).unsqueeze(0) with torch.no_grad(): outputs = self.model(input_tensor) prob = torch.softmax(outputs, dim=1).squeeze(0) conf, idx = torch.max(prob, dim=0) self.result_label.setText( f"识别结果:{self.class_names[idx]},置信度:{conf.item():.2%}" ) app = QApplication(sys.argv) win = TongueGUI() win.show() sys.exit(app.exec_())

这段代码把整个推理链封装在 GUI 类里,核心细节是模型输出先过 softmax 再取最大值和对应索引,避免直接对 logits 取 argmax 时无法给出置信度数值。注意 torch.load 默认会保留训练时的设备信息,如果训练是在 GPU 上做的,加载到 CPU 机器必须加 map_location="cpu"。实际交付时还要补一个异常处理:用户选了非图片文件、图片损坏、模型文件缺失,都要用 try-except 包住,否则一闪而退的崩溃会让演示现场很尴尬。

4.3 让界面更可信:检测框叠加与报告导出

如果做的是 YOLO 检测模型,GUI 里除了显示类别和置信度,还要能把检测框画在图片上。OpenCV 的 rectangle 加 putText 就能实现,画完再转成 QImage 显示。报告导出我一般生成一份简单的文本或 PDF,包含图片路径、识别类别、置信度、时间戳几个字段,答辩时可以作为系统完整性的佐证。这块不需要做得太重,能导出就比不能导出好很多。

5. 舌苔识别系统避坑指南:从数据到部署最常踩的 5 个问题

5.1 颜色偏色让模型学到错误特征

现象:模型在验证集上准确率很高,换一批用不同手机拍的照片就大幅下降。原因:训练集白平衡不一致,模型把“偏蓝的舌头”或“偏暗的整体亮度”学成了类别特征,而不是真正区分舌苔本身。解决:在预处理阶段加灰度世界白平衡校正,并统一采集光源条件,进模型前再做一次颜色扰动很小的数据增强。

5.2 标注类别定义不清导致标签噪声

现象:训练 loss 一直震荡,验证集准确率卡在 60% 左右上不去。原因:多个标注者对“薄白苔”和“厚腻苔”的边界理解不一致,比如有颗粒感到底算不算腻,导致同一张图出现不同标签,网络学到的是互相矛盾的信号。解决:标注前先写一份简单的标注规范,把每种类别的判定标准量化,让两个人背靠背各标注一遍,再用脚本统计标注一致率,低于 90% 的类别重新讨论定义。

5.3 数据不平衡但只看总准确率

现象:答辩时被问“黄苔准确率多少”,打开混淆矩阵才发现黄苔召回率只有 30%。原因:训练时直接用普通交叉熵,模型为了降低整体 loss 牺牲少数类。解决:用 sklearn 的 compute_class_weight 计算权重,把嘴巴交给 CrossEntropyLoss 的 weight 参数;论文里多放一张混淆矩阵图,比任何文字都更有说服力。

5.4 GPU 训练完本地 CPU 加载报错

现象:模型在服务器上训练好,拿回本地电脑做演示,torch.load 直接报错。原因:模型保存时包含了 CUDA 设备信息,本地没有对应 GPU。解决:加载时加 map_location="cpu",即torch.load("tongue_model.pth", map_location="cpu");更稳妥的是保存时用 state_dict 而不是整个模型,这样可以绕开模型类定义不一致的问题。

5.5 GUI 里图片显示和推理结果不一致

现象:界面上图片正常展示,但推理结果明显不对,比如选了黄苔的图却识别成薄白苔。原因:GUI 展示用的原图和模型输入端处理的不是同一尺寸,或者路径里包含中文导致 PIL 读取失败。解决:统一在推理前用 PIL 重新打开原图,并和显示用的图片保持同一切换逻辑;路径处理建议用 os.path 拼接而不是字符串加法,Windows 下尤其容易在中文字符上踩坑。

6. 从“能跑”到“可信”:验证模型可靠性的一剂后悔药

很多人做完整套系统就收工了,但我建议再多花两天做一件事:统计口径验证。训练完先冻结模型,准备一套独立测试集,计算每个类别的准确率、召回率和 F1,然后算一个加权 Kappa 系数,这是医学图像论文里很常用的评判标准,可以客观地说明模型鉴定结果和标注者之间的一致性。Kappa 在 0.6 以上算“中等一致”,0.8 以上就能在论文里写“具有较高一致性”,这个数字比准确率更有参考价值,答辩时也更好讲。

如果最后发现黄苔和白苔混淆严重,一个有效的补救手段是调整阈值,不一定要推翻模型。把 softmax 的输出阈值从 0.5 调高到 0.7,模型对低置信度样本选择“不支持判断”而不是硬分类,会大幅减少误报。这个改动只需要在推理代码里加一行判断,却能让演示现场的可靠性显著提升。

我每次做完这类医学图像识别系统,都会留一整个下午做“反推测试”:故意把黄苔图调亮、调暗、加噪,看模型什么时候开始误判,找到边界后写进论文的“局限性”一节。这个习惯救过我太多次,也让审稿老师看到了对问题的理解深度。做舌苔识别不只是把深度学习模型调通,更要在数据、标注、界面、验证这条链路上都让人放心。希望帮到你。

本文还有配套的精品资源,点击获取