ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于PyTorch的舌苔识别系统:从CNN模型训练到GUI界面部署的完整实战

基于PyTorch的舌苔识别系统:从CNN模型训练到GUI界面部署的完整实战 简介这份资源是面向深度学习入门者与计算机相关专业毕业设计学生的舌苔识别检测鉴定系统完整项目包围绕中医舌象智能分析场景解决舌苔分类与体质辨识的算法落地问题。包内共110个文件以Python源码为主体含26个py脚本、6个pth模型权重、10个pyc编译文件另有7张jpg与2张png运行截图、5个json配置、2个ui界面文件、2份docx论文报告及ttc字体等辅助资源压缩包约105.46MB。项目覆盖课题背景、机器学习与卷积神经网络理论、需求分析、舌象数据集构建与图像增强、生成对抗网络扩充样本等章节并附训练日志与模型文件便于复现实验与二次开发。已有183人学习下载适合需要完整源码、GUI界面、预训练模型与论文报告一体化参考的读者可据此快速理解舌诊分类流程、界面调用方式与报告撰写结构。1. 舌苔识别系统到底在做什么从一张手机照片到一份体质报告很多人第一次听到「舌苔识别检测鉴定系统」会以为是个中医玄学项目其实它要解决的问题非常具体用户对着手机伸舌头拍一张照系统自动判断舌苔的厚薄、颜色、腐腻、剥落等特征再映射到「薄白苔」「黄厚腻苔」「地图舌」这类中医舌诊结论上。传统做法靠老中医肉眼观察主观性强、复现性差而深度学习要做的就是把这种经验判断变成可量化、可复现的推理流程。这个标题里的 Python 源码、GUI 界面、模型、论文报告四件套本质上是一套完整的本科或研究生毕业设计交付物训练好的权重文件、能点按钮出结果的桌面程序、以及解释方法论的文档。它适合两类人——一类是中医信息化方向的学生需要一套能跑通、能答辩的完整工程另一类是刚入门深度学习、想找一个图像分类落地场景练手的工程师。核心链路只有三步图像预处理、CNN 特征提取分类、结果可视化。听起来简单但真正动手你会发现舌象数据集比 ImageNet 难搞得多光照、色偏、舌头姿态每一个都能让模型翻车。2. 舌苔识别为什么不能直接套用现成分类模型2.1 舌象数据的三个特殊性决定了模型选型通用图像分类模型在 ImageNet 上能到 80% 以上的 top-5 准确率但直接拿来识别舌苔大概率会失败原因有三个。第一是颜色敏感度舌苔的核心判别依据是颜色白、黄、灰、黑和润燥程度而手机拍摄的白平衡差异会让同一根舌头在不同设备上呈现完全不同的色调模型很容易学到「拍摄设备」而不是「舌苔特征」。第二是类别边界模糊薄白苔和正常苔、黄腻苔和黄燥苔之间的差异非常细微标注者之间的一致性可能只有 70% 左右这意味着标签本身带噪声。第三是样本量小公开的舌象数据集规模通常在几千张量级远达不到 ImageNet 的百万级从头训练一个 ResNet 必然过拟合。所以选型上我一般会走迁移学习路线用 ImageNet 预训练的骨干网络ResNet50、EfficientNet-B0 或 MobileNetV3 都行冻结前面的卷积层只微调最后的分类头。如果算力有限MobileNetV3 是性价比最高的选择参数量只有 5.4M在普通笔记本 CPU 上也能跑推理。如果追求精度且实验室有显卡EfficientNet-B0 在舌象任务上通常比 ResNet50 高 2 到 3 个百分点因为它的复合缩放策略对小数据集更友好。2.2 数据增强必须针对舌象做定制通用的随机裁剪、翻转对舌象帮助有限甚至有害——舌头翻转后解剖结构就不对了。真正有效的是颜色空间增强在 HSV 空间随机扰动色调±10、饱和度±20、亮度±15模拟不同光照和设备色偏。另外加入随机旋转±15度和轻微弹性形变模拟伸舌姿态差异。import cv2 import numpy as np from torchvision import transforms class TongueAugment: 针对舌象的颜色空间增强避免翻转破坏解剖结构 def __init__(self, h_gain10, s_gain20, v_gain15): self.h_gain h_gain self.s_gain s_gain self.v_gain v_gain def __call__(self, img): # img: RGB numpy array, shape (H, W, 3) hsv cv2.cvtColor(img, cv2.COLOR_RGB2HSV).astype(np.float32) # 色调扰动注意 OpenCV 中 H 范围是 0-179 h_shift np.random.uniform(-self.h_gain, self.h_gain) hsv[:, :, 0] (hsv[:, :, 0] h_shift) % 180 # 饱和度和亮度扰动 hsv[:, :, 1] np.clip(hsv[:, :, 1] * np.random.uniform(1 - self.s_gain/100, 1 self.s_gain/100), 0, 255) hsv[:, :, 2] np.clip(hsv[:, :, 2] * np.random.uniform(1 - self.v_gain/100, 1 self.v_gain/100), 0, 255) return cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB) # 组合增强流水线 train_transform transforms.Compose([ transforms.ToPILImage(), transforms.RandomRotation(15), transforms.Lambda(lambda x: TongueAugment()(np.array(x))), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])这段代码的关键在于TongueAugment类它只做颜色扰动不做几何翻转。h_gain控制色调偏移幅度默认 ±10 对应 OpenCV 的 0-179 范围大约相当于人眼能感知的轻微色偏。s_gain和v_gain用百分比表示20 和 15 是经过几轮实验后比较稳的值再大就会让黄苔变白苔、白苔变灰苔反而制造错误标签。transforms.Lambda那一步是把 PIL 图像转成 numpy 再增强因为 OpenCV 的 HSV 转换只接受 numpy 数组。归一化参数用的是 ImageNet 的标准值因为骨干网络是在 ImageNet 上预训练的保持一致能避免特征分布偏移。2.3 类别不平衡要用加权损失而不是简单过采样舌象数据集中薄白苔通常占 40% 以上而剥落苔、黑苔可能不到 5%。简单复制少数类样本会导致过拟合我一般用带类别权重的交叉熵损失权重取类别频率的倒数再归一化。import torch import torch.nn as nn from collections import Counter def build_weighted_loss(labels, num_classes, device): 根据训练集标签分布构建加权交叉熵 counter Counter(labels) total len(labels) weights [] for i in range(num_classes): count counter.get(i, 1) # 避免除零 weights.append(total / (num_classes * count)) weights torch.tensor(weights, dtypetorch.float32).to(device) return nn.CrossEntropyLoss(weightweights) # 假设有 6 类舌苔训练标签列表为 train_labels criterion build_weighted_loss(train_labels, num_classes6, devicecuda)build_weighted_loss的逻辑是某个类别样本越少它的损失权重越大模型在训练时就会更关注这些难样本。total / (num_classes * count)是标准的逆频率加权公式保证所有类别权重之和等于类别数避免整体损失量级变化太大影响学习率。实际用的时候建议把权重限制在 0.5 到 5 之间极端权重会让训练不稳定。3. 用 PyTorch 搭一套能跑通的舌苔分类训练流程3.1 骨干网络替换分类头的标准写法迁移学习的核心操作是把预训练模型的最后一层全连接替换成自己的类别数。以 ResNet50 为例原始输出是 1000 类改成 6 类舌苔只需要替换fc层。但要注意如果直接替换后立刻用大学习率训练随机初始化的分类头会产生大梯度把预训练的卷积权重也带偏。稳妥做法是分两阶段先冻结骨干只训分类头再解冻最后几个 block 做微调。import torchvision.models as models import torch.nn as nn def build_tongue_model(num_classes6, backboneresnet50, pretrainedTrue): if backbone resnet50: model models.resnet50(pretrainedpretrained) in_features model.fc.in_features model.fc nn.Sequential( nn.Dropout(0.5), # 防止小数据集过拟合 nn.Linear(in_features, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(256, num_classes) ) elif backbone mobilenet_v3: model models.mobilenet_v3_small(pretrainedpretrained) in_features model.classifier[-1].in_features model.classifier[-1] nn.Linear(in_features, num_classes) return model # 阶段一冻结骨干 model build_tongue_model(num_classes6, backboneresnet50) for name, param in model.named_parameters(): if fc not in name: param.requires_grad False # 阶段二训练几个 epoch 后解冻 layer4 # for name, param in model.named_parameters(): # if layer4 in name or fc in name: # param.requires_grad Truebuild_tongue_model里加了两层 Dropout因为舌象数据集小分类头太复杂会直接记住训练样本。nn.Dropout(0.5)放在全连接之前nn.Dropout(0.3)放在中间层之后这两个值是我在几个舌象数据集上试出来比较稳的。MobileNetV3 的写法略有不同它的分类器是一个 Sequential直接替换最后一层即可。冻结策略上requires_grad False要放在模型移到 GPU 之前执行否则某些版本的 PyTorch 会报错。3.2 训练循环里必须记录的三类指标很多人训练时只看 loss 和 accuracy但舌象任务里这两个指标会骗人。必须同时记录每类的 precision 和 recall因为模型很可能把少数类全部预测成多数类accuracy 看起来还行但实际不可用。另外要保存验证集上的混淆矩阵方便定位哪两类容易混。import torch from sklearn.metrics import classification_report, confusion_matrix import numpy as np def train_one_epoch(model, loader, criterion, optimizer, device): model.train() running_loss 0.0 all_preds, all_labels [], [] for images, labels in 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) preds outputs.argmax(dim1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) epoch_loss running_loss / len(loader.dataset) # 每个 epoch 打印分类报告重点关注 macro avg 的 f1 print(classification_report(all_labels, all_preds, digits3)) return epoch_loss, np.array(all_preds), np.array(all_labels) def validate(model, loader, criterion, device): model.eval() running_loss 0.0 all_preds, all_labels [], [] with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) running_loss loss.item() * images.size(0) preds outputs.argmax(dim1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) epoch_loss running_loss / len(loader.dataset) cm confusion_matrix(all_labels, all_preds) return epoch_loss, np.array(all_preds), np.array(all_labels), cmtrain_one_epoch里每个 epoch 都调用classification_report重点看macro avg的 f1-score它比 accuracy 更能反映类别不平衡下的真实性能。如果某个类的 recall 低于 0.5说明模型基本没学会这一类需要检查该类样本量或者单独调整权重。validate返回混淆矩阵训练结束后把它画成热力图能直观看到「黄腻苔被误判成黄燥苔」这类具体错误。注意model.eval()和torch.no_grad()必须同时用前者切换 Dropout 和 BatchNorm 的行为后者关闭梯度计算节省显存。3.3 学习率调度和早停的配合舌象任务通常在 30 到 50 个 epoch 内收敛用余弦退火配合早停是最省心的组合。初始学习率设 1e-3 训练分类头解冻骨干后降到 1e-4。早停的耐心值设 7 到 10监控指标用验证集 macro f1 而不是 loss。from torch.optim.lr_scheduler import CosineAnnealingLR optimizer torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr1e-3, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max50, eta_min1e-6) best_f1 0.0 patience 8 counter 0 for epoch in range(50): train_loss, _, _ train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_preds, val_labels, cm validate(model, val_loader, criterion, device) from sklearn.metrics import f1_score val_f1 f1_score(val_labels, val_preds, averagemacro) scheduler.step() if val_f1 best_f1: best_f1 val_f1 torch.save(model.state_dict(), best_tongue_model.pth) counter 0 else: counter 1 if counter patience: print(fEarly stop at epoch {epoch}, best f1: {best_f1:.4f}) breakAdamW的weight_decay1e-4是解耦权重衰减比 Adam 自带的 L2 正则更稳定。CosineAnnealingLR的T_max设成总 epoch 数学习率会从初始值平滑降到eta_min。早停的patience8意味着连续 8 个 epoch 验证 f1 没提升就停这个值太小容易在震荡期误停太大浪费训练时间。保存模型时只存state_dict()而不是整个模型对象这样加载时不依赖原始类定义部署更灵活。4. GUI 界面怎么把模型包装成能演示的工具4.1 用 Tkinter 搭最小可用界面毕业设计里的 GUI 不需要多漂亮核心功能就三个选图片、点识别、显示结果。Tkinter 是 Python 自带的不用额外装 Qt 那套几百兆的依赖打包成 exe 也方便。界面布局用 grid 比 pack 好控制左边放图片预览右边放识别结果和置信度。import tkinter as tk from tkinter import filedialog, messagebox from PIL import Image, ImageTk import torch from torchvision import transforms class TongueGUI: def __init__(self, model_path, class_names): self.root tk.Tk() self.root.title(舌苔识别检测系统) self.root.geometry(800x500) self.class_names class_names self.device torch.device(cuda if torch.cuda.is_available() else cpu) self.model self.load_model(model_path) 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]) ]) self.build_ui() def load_model(self, path): from model import build_tongue_model # 复用训练时的模型定义 model build_tongue_model(num_classeslen(self.class_names)) model.load_state_dict(torch.load(path, map_locationself.device)) model.to(self.device) model.eval() return model def build_ui(self): self.img_label tk.Label(self.root, text请选择舌象图片, width40, height20, bg#f0f0f0) self.img_label.grid(row0, column0, rowspan4, padx10, pady10) tk.Button(self.root, text选择图片, commandself.select_image, width15).grid(row0, column1, pady5) tk.Button(self.root, text开始识别, commandself.predict, width15).grid(row1, column1, pady5) self.result_label tk.Label(self.root, text, font(Arial, 14), fg#333) self.result_label.grid(row2, column1, pady10) self.conf_label tk.Label(self.root, text, font(Arial, 10), fg#666) self.conf_label.grid(row3, column1) def select_image(self): path filedialog.askopenfilename(filetypes[(图片, *.jpg *.png *.jpeg)]) if path: self.img_path path img Image.open(path).resize((320, 320)) self.tk_img ImageTk.PhotoImage(img) self.img_label.config(imageself.tk_img, text) def predict(self): if not hasattr(self, img_path): messagebox.showwarning(提示, 请先选择一张舌象图片) return img Image.open(self.img_path).convert(RGB) tensor self.transform(img).unsqueeze(0).to(self.device) with torch.no_grad(): outputs self.model(tensor) probs torch.softmax(outputs, dim1)[0] conf, pred torch.max(probs, dim0) self.result_label.config(textf识别结果{self.class_names[pred.item()]}) self.conf_label.config(textf置信度{conf.item():.2%}) def run(self): self.root.mainloop() if __name__ __main__: gui TongueGUI(best_tongue_model.pth, [薄白苔, 黄腻苔, 白腻苔, 剥落苔, 黑苔, 灰苔]) gui.run()load_model里用map_locationself.device是为了在没有 GPU 的机器上也能加载 GPU 训练的权重。build_ui用 grid 布局把图片区域跨 4 行右边按钮和结果依次排列窗口大小 800x500 在 1080p 屏幕上刚好。predict方法里unsqueeze(0)是给单张图片加 batch 维度因为模型期望输入是(B, C, H, W)。torch.softmax把 logits 转成概率torch.max同时返回最大值和索引。置信度显示成百分比更直观但要注意 softmax 输出的置信度在类别不平衡时可能偏高不能完全当可靠性指标。4.2 打包成 exe 时最容易踩的坑用 PyInstaller 打包 Tkinter PyTorch 程序最大的坑是 PyTorch 的动态链接库和模型文件不会自动打进去。常见做法是用--add-data把.pth权重文件和可能的图标资源加进去然后在代码里用sys._MEIPASS定位临时解压目录。pyinstaller --onefile --windowed \ --add-data best_tongue_model.pth;. \ --add-data model.py;. \ --hidden-import torch \ --hidden-import torchvision \ --collect-all torch \ tongue_gui.py--onefile把所有东西打成一个 exe方便拷贝但启动慢因为每次要解压到临时目录。--windowed去掉控制台窗口适合演示。--collect-all torch是必须的否则 PyInstaller 会漏掉 torch 的很多子模块和 DLL。--add-data的格式是源路径;目标路径Windows 上用分号Linux 和 Mac 上用冒号。打包后的 exe 体积通常在 500MB 到 1GB 之间因为 PyTorch 本身就很大这是正常现象不要试图用 UPX 压缩容易把 DLL 压坏导致运行时报错。5. 避坑与排查舌苔识别项目里最容易翻车的五个地方5.1 现象训练准确率 95%换一张手机照片就乱判原因几乎总是数据域不匹配。训练集用的是标准舌象采集仪拍的图背景干净、光照均匀、色温固定而手机照片背景杂乱、有阴影、白平衡漂移。模型学到的是「采集仪背景」而不是「舌苔特征」。解决办法是在训练时加入随机背景替换和更强的颜色扰动或者用少量手机拍摄的图片做微调。更彻底的做法是先跑一个舌头分割模型把舌头区域抠出来再送分类网络这样背景干扰直接被消除。5.2 现象验证集 loss 一直降但 macro f1 卡在 0.6 上不去这是类别不平衡的典型表现。多数类样本多模型只要全预测成多数类就能拿到不错的 loss但少数类 recall 接近零。先打印混淆矩阵确认是不是这个情况如果是检查加权损失是否真的生效。常见错误是权重张量没有移到 GPU 上或者CrossEntropyLoss的weight参数传成了 Python list 而不是 tensor。另外要确认验证集的类别分布和训练集一致如果验证集里少数类更少f1 会更低。5.3 现象GUI 点识别按钮后界面卡死几秒钟模型推理在 CPU 上跑一张图通常要 200 到 500 毫秒如果模型是 ResNet50 且没做优化可能超过 1 秒。Tkinter 是单线程的推理期间主循环被阻塞界面就无响应。解决办法有两个一是换 MobileNetV3 这类轻量模型CPU 推理能压到 100 毫秒以内二是把推理放到独立线程里用threading.Thread执行完成后通过root.after回调更新界面。注意 Tkinter 的控件更新必须在主线程做子线程里只能发信号。5.4 现象PyInstaller 打包后运行报「找不到 model.py」这是因为--add-data只把文件放到了临时目录但 Python 的sys.path不包含那个目录。需要在代码开头加一段路径处理import sys import os if getattr(sys, frozen, False): # 打包后的运行环境 base_path sys._MEIPASS sys.path.insert(0, base_path) else: base_path os.path.dirname(os.path.abspath(__file__))sys.frozen是 PyInstaller 注入的标志打包后为 True。sys._MEIPASS指向临时解压目录把它的路径插入sys.path后import model才能找到打包进去的model.py。加载权重文件时也要用os.path.join(base_path, best_tongue_model.pth)而不是相对路径。5.5 现象同一张图连续识别两次结果不一样如果模型里还有 Dropout 或者 BatchNorm 处于训练模式推理结果就会随机。检查model.eval()是否在加载权重后调用以及 GUI 的predict方法里是否误用了model.train()。另一个可能原因是数据增强的 transform 里包含了随机操作推理时的 transform 必须是确定性的只保留 Resize、ToTensor、Normalize 三步。训练时的增强流水线和推理流水线要分开定义不要混用。6. 把舌苔识别做到能答辩的进阶技巧6.1 用 Grad-CAM 给答辩加一张「模型在看哪里」的图答辩老师最常问的问题是「你怎么知道模型学的是舌苔而不是背景」。光说没用得把热力图甩出来。Grad-CAM 的原理是取目标类别对最后一个卷积层特征图的梯度加权求和后叠加到原图上高亮区域就是模型关注的位置。如果热力图集中在舌头区域说明模型确实在学舌苔如果高亮在背景或边框那就得回去检查数据。import torch import cv2 import numpy as np from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image def generate_gradcam(model, img_tensor, target_layer, class_idx, original_img): 生成 Grad-CAM 热力图并叠加到原图 cam GradCAM(modelmodel, target_layers[target_layer]) grayscale_cam cam(input_tensorimg_tensor, targets[class_idx])[0] # original_img 是 0-1 范围的 RGB float 数组 visualization show_cam_on_image(original_img, grayscale_cam, use_rgbTrue) return visualization # 使用示例ResNet50 的最后一个卷积层是 model.layer4[-1] # cam_img generate_gradcam(model, tensor, model.layer4[-1], pred_class, img_array) # cv2.imwrite(gradcam_result.jpg, cv2.cvtColor(cam_img, cv2.COLOR_RGB2BGR))target_layers传最后一个卷积层ResNet50 是model.layer4[-1]MobileNetV3 是model.features[-1]。targets[class_idx]指定要解释的类别通常用预测类别。show_cam_on_image要求原图是 0 到 1 的 float 数组如果原图是 0 到 255 的 uint8要先除以 255。生成的热力图保存下来放进论文里比任何文字描述都有说服力。6.2 用 ONNX 导出把推理速度再压一截PyTorch 模型直接推理有 Python 解释器开销导出成 ONNX 后用 ONNX Runtime 跑CPU 上通常能快 30% 到 50%。导出时注意把模型设为 eval 模式并且提供一个 dummy input。import torch import onnx import onnxruntime as ort import numpy as np def export_to_onnx(model, save_pathtongue_model.onnx, input_size(1, 3, 224, 224)): model.eval() dummy torch.randn(*input_size) torch.onnx.export( model, dummy, save_path, input_names[input], output_names[output], dynamic_axes{input: {0: batch_size}, output: {0: batch_size}}, opset_version11 ) # 验证导出模型 onnx_model onnx.load(save_path) onnx.checker.check_model(onnx_model) print(fONNX 模型已保存大小{os.path.getsize(save_path) / 1024 / 1024:.1f} MB) def onnx_inference(save_path, img_tensor): session ort.InferenceSession(save_path, providers[CPUExecutionProvider]) input_name session.get_inputs()[0].name outputs session.run(None, {input_name: img_tensor.numpy()}) return outputs[0] # 导出 # export_to_onnx(model) # 推理 # result onnx_inference(tongue_model.onnx, tensor)dynamic_axes把 batch 维度设成动态的这样同一个 ONNX 模型既能处理单张图也能处理批量。opset_version11是兼容性比较好的版本再高有些旧版 ONNX Runtime 不支持。导出后一定要用onnx.checker.check_model验证一遍有时候模型里有不支持的算子会静默失败。ONNX Runtime 的CPUExecutionProvider在大多数机器上都能用如果部署环境有 GPU 可以换成CUDAExecutionProvider。6.3 一个我踩过的坑别在答辩前一天换模型说个血泪经验。有一次帮学弟调舌苔识别项目答辩前三天他觉得 ResNet50 不够「高级」非要换成 Vision Transformer。结果 ViT 在小数据集上严重过拟合验证集 f1 从 0.82 掉到 0.61调了两天没调回来最后连夜换回 ResNet50 才勉强赶上。舌象数据量小CNN 的归纳偏置局部性、平移不变性反而是优势ViT 需要大量数据或者强增强才能发挥。如果非要用 Transformer至少用 DeiT 的蒸馏版本或者用预训练权重加极低学习率微调。答辩项目的核心是稳定可复现不是堆最新模型。我现在的习惯是答辩前一周冻结模型选型只做推理优化和界面打磨不再动训练相关的任何超参数。希望帮到你。本文还有配套的精品资源点击获取
返回列表