简介:这套基于Python的深度学习舌苔识别系统,面向高校计算机、人工智能等专业的毕业设计及医学影像入门研究,实现了舌象特征自动提取与病理状态识别的端到端流程。压缩包共131个文件,约105.67MB,涵盖26个Python脚本、6个模型权重文件(pth)、2个PyQt界面文件(ui)、2个Word文档,以及训练日志、图片样本、配置文件与说明文档,可支撑从模型训练到GUI部署的完整链路。系统基于卷积神经网络与迁移学习,图形界面支持舌象图片导入、实时分析与可视化报告,论文详细阐述了数据预处理、网络结构、训练策略及准确率、召回率等评估指标,并提供模型再训练接口,方便读者复现实验与开展二次开发。压缩包内的TensorBoard事件文件覆盖多次完整训练过程,当前已有70人学习浏览,适合作为人工智能医疗诊断方向的教学案例与毕设参考,资源仅供学习交流使用。
1. 舌苔识别不是玄学?一个 Python GUI 项目能落地到什么程度
在中医辅助诊断场景里,舌苔识别是最典型的视觉任务,也是最容易做成“黑匣子”的任务——模型在命令行跑出几个准确率数字,根本没法给教学或临床场景直接使用。更麻烦的是,舌苔分类不只是一次普通图像分类:样本量小、类别不均衡、拍摄环境差异大,任何一环处理不好,模型到了 GUI 里就“翻车”。这篇文章从任务定义、数据准备、模型训练讲到 GUI 集成,给出可复现的工程链路,把预处理对齐、推理线程、模型加载这些坑摊开。新手可以直接照做,熟手也值得核对一遍自己的推理链路是否和训练时完全一致。如果你正在做中医舌诊辅助工具,或者想找一个图像分类 + GUI 的小型落地项目,这篇正好适合你。
2. 任务定义与数据准备:舌苔分类到底在分什么
2.1 舌苔分类的标定体系:先确定你要分几类
舌苔识别的第一步不是写代码,而是先把分类体系定死。常见的中医舌诊教材会把舌苔分成薄白苔、白腻苔、黄腻苔、灰黑苔等若干类,不同体系之间的定义有交叉,比如“黄腻苔”既强调颜色又强调质地。我在这类项目里的做法是:先和提需求的业务方确定类别之间的互斥性,每张样本图只能属于一个类别,宁可用四分类而不是八分类,也不要用多标签——因为 GUI 输出的展示逻辑天然是单选,多标签会让结果页设计复杂好几倍,而且小样本下的多标签训练很容易因为标签噪声把指标拉低。
我自己常用的基类是四分类:薄白、白腻、黄腻、灰黑。这个分法在多数辅助诊断场景里够用,而且每类都能凑到相对均衡的样本。如果你的数据源本身就有限,建议把灰黑类合并成“厚苔”或“非典型”,先保证训练集的数量下限,别让类间样本比超过 5:1。类间比一旦失控,模型会倾向于输出样本量大的类别,在 GUI 里表现为“总是识别成同一个结果”,到时候再去调损失函数的 class weight,血泪经验告诉我那是最后一根救命稻草,不如一开始在标注阶段就把比例控住。
还有一个容易被忽略的点:类别定义要写进项目说明里,最好配三到五张典型样例图。同一个词在不同标注者眼里的边界不一样,有人把“微黄”归入薄白,有人归入黄腻。团队协作时如果不锁定标注标准,后期洗数据会非常痛苦。我通常会在数据目录下放一份label_guide.md,正好把类别名、临床定义、典型样例路径写清楚,标注新样本时先看这份文档再动鼠标。
2.2 数据集整理与预处理:文件夹就是你的标注表
数据组织的原则只有一条:让标注信息存在于路径和文件名里,而不是散落在 Excel 或脑内。我习惯按 train/val/test 三层分目录,每个类别一个子目录:
data/ ├── train/ │ ├── baibo/ # 薄白苔 │ ├── baini/ # 白腻苔 │ ├── huangni/ # 黄腻苔 │ └── huibei/ # 灰黑苔 ├── val/ │ └── ... └── test/ └── ...这个结构是 PyTorch 的ImageFolder直接能读的标准格式,省去后续写自定义 Dataset 匹配标注表的工作。但有个细节值得注意:同一患者的舌头图像具有很强的相似性,如果同一人在同一个拍摄批次里的多张图被同时分进 train 和 val,那么泛化指标会虚高——所以我一般在 split 之前先按患者 id 去重,保证同一个人只出现在一个集合里。实际操作时,我在文件名里嵌入患者 id 和拍摄时间,比如P023_20250112_tongue_01.jpg,然后写一段脚本按P023分桶做划分。
预处理上,我统一做两步。第一步是用 OpenCV 做一次粗略的舌体裁剪,因为原始照片里往往包含嘴唇、面部甚至背景,直接让分类网络看到太多和舌苔无关的信息。第二步是把所有图 resize 到 224×224 并做归一化。下面这段代码是预处理的最小实现:
import cv2 def preprocess_tongue_image(img_path, out_size=(224, 224)): img = cv2.imread(img_path) if img is None: raise FileNotFoundError(f"图片读取失败: {img_path}") # 粗略裁剪:取图像中心 70% 区域,优先去掉嘴唇和下庭边缘 h, w = img.shape[:2] x1, y1 = int(w * 0.15), int(h * 0.15) x2, y2 = int(w * 0.85), int(h * 0.85) img = img[y1:y2, x1:x2] img = cv2.resize(img, out_size, interpolation=cv2.INTER_AREA) # 转成 RGB,后面和 PyTorch 的 ToTensor 顺序对上 img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) return img这段代码的核心逻辑是先做中心裁剪再缩放到目标尺寸。注意我用的是INTER_AREA而不是INTER_LINEAR:舌苔是纹理敏感的图像,放大时INTER_LINEAR会让纹理变“糊”,而缩小的时候INTER_AREA能保留更多局部对比度信息。BGR 到 RGB 的转换一定不能漏,因为 OpenCV 默认读图是 BGR,而预训练模型全是按 RGB 定义的。
另外,如果你觉得中心裁剪太粗暴,可以换成 YOLO 或 MediaPipe 的舌头关键点检测来做精细裁剪,但那会让项目复杂度上一个台阶,对多数初版系统来说并不值得。我的建议是:第一版先做中心裁剪,把模型跑通;等 GUI 和推理链路都稳定了,再回来替换裁剪逻辑。
2.3 数据增强策略:小样本舌苔图像的防过拟合手段
舌苔数据集往往只有几百到两三千张,这和 ImageNet 的百万级完全不在一个量级。迁移学习带来的一个副作用是:模型容量大、样本少,非常容易过拟合。常用的缓解手段是数据增强。我会在训练侧用下面这套增强配置:
from torchvision import transforms train_transform = transforms.Compose([ transforms.ToTensor(), transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(10), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])这里有个关键权衡:舌头的朝向相对固定,水平翻转问题不大,但垂直翻转基本不能用,因为舌根和舌尖在空间语义上是不能互换的。ColorJitter是我刻意调小饱和度的,因为不同手机拍摄的舌苔批次颜色还原差异很大,训练时把色彩抖动加上一点,能提升模型对新拍摄设备的适应性。饱和度的抖动幅度如果过大,会把“黄苔”和“白苔”之间的颜色边界搅浑,直接表现为验证集里这两类的混淆度升高。
下表是我在舌苔项目里对常见增强操作的取舍经验:
| 增强操作 | 推荐设置 | 原因 |
|---|---|---|
| 水平翻转 | 0.5 | 舌头左右对称性较好,可用 |
| 垂直翻转 | 不用 | 舌根与舌尖空间语义不可互换 |
| 随机旋转 | 10°以内 | 过大容易引入嘴唇或下颌边缘信息 |
| 亮度抖动 | 0.2 | 适应不同拍摄光照 |
| 饱和度抖动 | 0.1 | 保住白/黄苔的颜色边界 |
| 随机裁剪 | 不用 | 可能裁掉关键舌苔区域 |
验证集和测试集不要做任何增强,只做 resize 和归一化。这一点如果写错了,验证集指标会“虚高”,到了新数据上立刻露馅。我自己犯过这个错,当时先在训练集上做得很好,换个新手机拍的照片马上崩,原因就是验证集过度用了增强,模型实际学到的鲁棒性远不如指标显示的那么高。
3. 模型选择与训练落地:用一个可复现的 PyTorch 流程
3.1 为什么选迁移学习而不是从零训练
舌苔图像在 ImageNet 里几乎不存在,但我们仍然建议用 ImageNet 预训练权重做迁移学习,而不是从零训练一个网络。原因是低层特征——边缘、纹理、颜色渐变——在绝大多数视觉任务里是通用的,舌苔分类真正需要重新学的,只有高层语义组合。在样本只有几千张的前提下,从零训练一个 ResNet 的收敛难度和过拟合风险都要高得多。
我在这类任务里一般会选 ResNet18 或者 MobileNetV3-Large。这两个模型都适合单张消费级显卡,甚至纯 CPU 也能跑推理。如果部署机器没有 GPU,MobileNet 的推理速度会明显占优;如果更看重准确率,ResNet18 在这个量级上略微领先。也可以用 EfficientNet-B0,但它对输入分辨率更敏感,换用 240 或 260 的输入尺寸时要同步调整网络结构,麻烦一点。三个模型的对比大致如下:
| 模型 | 输入尺寸 | 推理速度(CPU 参考) | 适合场景 |
|---|---|---|---|
| ResNet18 | 224 | 中等 | 精度优先,显卡部署 |
| MobileNetV3-Large | 224 | 快 | 纯 CPU 部署,GUI 体验优先 |
| EfficientNet-B0 | 224/240 | 较快 | 精度和速度折中,但调参更细 |
3.2 最小可用的训练脚本:数据加载、模型替换与训练循环
下面的代码是一个完整可运行的训练流程,覆盖了数据加载、模型替换、优化器配置、训练循环和验证。你可以直接拿它改路径跑通第一个版本。
import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, models, transforms # 数据加载 train_dataset = datasets.ImageFolder(root="data/train", transform=train_transform) val_dataset = datasets.ImageFolder(root="data/val", transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4) # 加载预训练模型,替换最后一层 model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) num_classes = len(train_dataset.classes) model.fc = nn.Linear(model.fc.in_features, num_classes) # 优化器与损失函数 optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) criterion = nn.CrossEntropyLoss() # 训练与验证 for epoch in range(30): model.train() running_loss = 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) # 验证 model.eval() val_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: outputs = model(images) loss = criterion(outputs, labels) val_loss += loss.item() * images.size(0) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) train_loss_avg = running_loss / len(train_dataset) val_loss_avg = val_loss / len(val_dataset) val_acc = correct / total print(f"epoch {epoch+1}: train_loss {train_loss_avg:.4f}, " f"val_loss {val_loss_avg:.4f}, val_acc {val_acc: .4f}")注意两个细节:一是weights=models.ResNet18_Weights.IMAGENET1K_V1是新版torchvision推荐的写法,旧写法pretrained=True在新版本里会直接报警告甚至弃用,团队协作时如果成员用不同版本,这一行是兼容性问题的高发区。二是优化器我用AdamW而不是Adam,在 weight decay 适中的情况下,AdamW对迁移学习场景的微调更稳,不容易在训练后期因为权重范数过大而抖动。
3.3 关键参数怎么定:学习率、batch size、epochs 与早停
学习率是这个任务里最“玄学”但也最可复盘的参数。我的经验是:全模型微调时从1e-4起步,如果只解冻最后一层 FC,可以开到1e-3。如果 loss 在第一个 epoch 过后不降反升,先别急着调参,检查是不是学习率太大导致梯度爆炸——看 loss 是不是变成了nan,如果连nan都出来了,说明学习率混用了。
我一般会给骨干层和全连接层设置不同的学习率:
param_groups = [ {"params": model.conv1.parameters(), "lr": 1e-5}, {"params": model.layer1.parameters(), "lr": 1e-5}, {"params": model.layer2.parameters(), "lr": 1e-5}, {"params": model.layer3.parameters(), "lr": 1e-4}, {"params": model.layer4.parameters(), "lr": 1e-4}, {"params": model.fc.parameters(), "lr": 1e-3}, ] optimizer = torch.optim.AdamW(param_groups, weight_decay=1e-4)这种分层的逻辑是:靠前的卷积层学的是通用边缘和纹理,不需要大改;靠后的层和 FC 层才需要学习舌苔特定的高阶语义特征,所以给更大的学习率。在数据量少的时候,这种分组能明显降低灾难性遗忘的发生概率。
batch size 在舌苔这种单目标图像上不用太大,32 到 64 之间够用。太大反而会稀释每个 batch 里的类别代表性,让 loss 曲线更抖。epochs 不推荐焊死一个数字,我习惯设一个最大 30 的循环,然后在验证集上做早停,连续 5 个 epoch 验证损失不下降就截断:
best_loss = float("inf") patience = 0 for epoch in range(30): # 训练循环代码省略 val_loss_avg = evaluate(val_loader, model, criterion) if val_loss_avg < best_loss: best_loss = val_loss_avg torch.save(model.state_dict(), "best_model.pt") patience = 0 else: patience += 1 if patience >= 5: print(f"early stop at epoch {epoch + 1}") break核心是保存best_model.pt的时机:一定要在验证损失下降时保存,而不是在每个 epoch 结束时都保存。否则你拿到的可能是过拟合后段的模型,而不是泛化能力最好的那一个。在 GUI 场景里,这个best_model.pt就是后续加载到界面里做推理的权重文件。
4. 把训练好的模型装进 GUI:从加载 checkpoint 到一键预测
4.1 GUI 框架选型:Tkinter 还是 PyQt5
选 GUI 框架时考虑两个约束:部署机器的 Python 环境,以及界面的复杂程度。Tkinter 是标准库的一部分,无需额外安装,写一个“选图-预测-显示结果”的三按钮界面完全够用。PyQt5 的控件更专业、样式更漂亮,适合做带历史记录、多图对比、参数面板的复杂工具。
对于舌苔识别这个场景,我建议先用 Tkinter 快速搭,因为核心价值在模型而不是界面。Tkinter 的发布依赖也少,后面打包时不用额外处理 Qt 的 DLL。但如果你的界面需要显示舌象对比、画出裁剪框、或者在窗口内实时显示 Grad-CAM 热力图,Tkinter 的 Canvas 用起来会比较别扭,PyQt5 的QLabel+QPixmap组合更顺手。
| 对比项 | Tkinter | PyQt5 |
|---|---|---|
| 安装成本 | 内置,零安装 | 需要 pip 安装,打包要带依赖 |
| 控件丰富度 | 够用 | 更专业,适合复杂布局 |
| 图片可视化 | Canvas 较原始 | QLabel 显示图片方便 |
| 线程安全支持 | 较弱,需 after 调度 | 信号槽机制更健壮 |
| 学习成本 | 低 | 中 |
一个稳妥做法是:界面上只放图片显示和结果文本,所有复杂可视化单独弹窗,这样 Tkinter 的第一版就够用。
4.2 模型加载与预处理对齐:训练时怎么处理,推理时就怎么处理
GUI 推理阶段最大的坑是预处理不一致。训练时用的Resize((224, 224))和推理时的Resize((224, 224))必须一字不差地保持一致,包括是否先做了舌头中心裁剪、是否用了同样的归一化参数。只写一次预处理函数并在训练和推理里共用,是最不容易出错的方案。
import torch from PIL import Image from torchvision import transforms device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 推理时使用的预处理(和训练完全一致) infer_transform = transforms.Compose([ transforms.ToTensor(), transforms.Resize((224, 224)), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) model = models.resnet18() model.fc = nn.Linear(model.fc.in_features, 4) model.load_state_dict(torch.load("best_model.pt", map_location=device)) model.to(device) model.eval() def predict_image(img_path): img = Image.open(img_path).convert("RGB") img = infer_transform(img).unsqueeze(0) img = img.to(device) with torch.no_grad(): logits = model(img) probs = torch.softmax(logits, dim=1) return probs[0].tolist()这段代码里最容易忽略的是.convert("RGB")。如果用户拖进来的图片是 PNG 带透明通道,或者本来就是灰度图,不加这一行会在推理时直接报通道数错误。另外model.eval()一定不能漏,它会把 Dropout 和 BatchNorm 切换到推理模式,否则同一个样本每次预测结果都可能不一样,这在 GUI 里是不允许发生的。
4.3 完整调用代码:选图、预测、展示结果
下面是 Tkinter 的最小 GUI 完整示例,把上面两个部分拼起来:
import tkinter as tk from tkinter import filedialog, messagebox class TongueApp: def __init__(self, root): self.root = root self.root.title("舌苔识别 - 深度学习辅助诊断") self.img_path = None btn_select = tk.Button(root, text="选择舌苔图像", command=self.select_image) btn_select.pack(pady=10) self.btn_predict = tk.Button(root, text="开始识别", command=self.predict, state=tk.DISABLED) self.btn_predict.pack(pady=5) self.img_label = tk.Label(root, text="未选择图片", width=50, height=10) self.img_label.pack() self.result_label = tk.Label(root, text="", font=("Arial", 14)) self.result_label.pack(pady=10) def select_image(self): path = filedialog.askopenfilename(filetypes=[("Image", "*.jpg *.jpeg *.png")]) if path: self.img_path = path self.btn_predict.config(state=tk.NORMAL) self.img_label.config(text=f"已选择: {path.split('/')[-1]}") def predict(self): if not self.img_path: messagebox.showwarning("提示", "请先选择图片") return probs = predict_image(self.img_path) class_names = ["薄白苔", "白腻苔", "黄腻苔", "灰黑苔"] idx = probs.index(max(probs)) self.result_label.config( text=f"识别结果: {class_names[idx]} 置信度: {probs[idx]*100:.1f}%" ) if __name__ == "__main__": root = tk.Tk() app = TongueApp(root) root.mainloop()这段 GUI 代码有三个值得强调的点。第一,state=tk.DISABLED初始状态下“开始识别”不可点,必须等用户选完图才激活,避免空路径报错。第二,predict函数里直接调用了推理函数,这会阻塞 GUI 主循环,在图片大、模型大的情况下界面会卡几秒甚至更久,下一章我会把它放到子线程里去。第三,类名列表的顺序要和训练时ImageFolder的目录顺序完全一致——len(train_dataset.classes)返回的顺序是目录名的字母序,所以这里的["薄白苔", "白腻苔", "黄腻苔", "灰黑苔"]必须逐项核对,否则显示的结果会对不上。
5. 避坑:舌苔识别从模型到 GUI 的 5 个常踩的坑
5.1 模型加载时报错:预训练权重和模型结构不匹配
现象:加载best_model.pt时抛出size mismatch for fc.weight之类的异常。
原因:训练脚本里改了model.fc = nn.Linear(old_dim, 4),但推理脚本里没有做同样的结构替换,直接load_state_dict就会对不上维度。
解决:在推理脚本里显式地重建模型结构,确保model.fc的输入维度和输出类别数和训练时一致:
model = models.resnet18() model.fc = nn.Linear(model.fc.in_features, 4) model.load_state_dict(torch.load("best_model.pt", map_location=device))这件事本身不复杂,但它提醒我们:模型的网络结构调整信息和权重文件同样重要。我建议把网络结构定义单独放到一个model_builder.py文件里,训练和推理都从这个文件导入,避免两处各写一份导致不一致。
5.2 预测结果与训练指标差很远:预处理没对齐
现象:训练时验证集准确率 92%,GUI 预测同一张测试图却给出了完全不同的类别。
原因:训练脚本里的train_transform和val_transform都用到了Resize和Normalize,但推理脚本的 transform 少了convert("RGB")或直接用 OpenCV 读图忘了转 RGB。还有一个隐蔽版本:训练和推理只差一个Resize的插值方式,视觉上基本没区别,但模型输出的置信度会有细微差异,累积下来就可能改变类别。
解决:把 transform 定义提到独立的preprocess.py文件里,训练和推理都import同一个函数。这是最省心的一招,因为所有可能出错的地方被收敛到了一个文件里。推理时如果感觉结果不对,第一件事就是打印预处理后的 Tensor 是否和训练时一致。
5.3 GUI 界面卡死:把推理放到子线程去
现象:点击“开始识别”后,窗口变成“无响应”,系统提示强制关闭。
原因:predict_image在 GUI 主线程里执行了 CPU/GPU 推理,耗时超过几百毫秒时窗口消息循环被阻塞,系统判定为未响应。
解决:使用threading.Thread开子线程执行推理,推理完成后通过root.after回到主线程更新界面:
def predict(self): if not self.img_path: return self.result_label.config(text="正在识别中...") t = threading.Thread(target=self._run_predict) t.daemon = True t.start() def _run_predict(self): probs = predict_image(self.img_path) self.root.after(0, self._update_result, probs)注意子线程里不能直接操作 Tkinter 控件,必须通过after(0, ...)排队到主线程再更新,否则轻则界面闪烁,重则直接崩溃。这个规则同样适用于 PyQt5,只是 PyQt5 里用信号槽机制更正规一点。
5.4 数据集太小,准确率忽高忽低:分层采样与固定随机种子
现象:同一份代码跑两次训练,验证集准确率一次 90%、一次 78%,让人怀疑模型是不是玄学。
原因:舌苔数据集小,随机划分的 val 集在不同 run 之间包含的困难样本数量差异大,指标波动被放大了。
解决:固定随机种子,并采用按类别分层的 train/val 划分,保证每一类的样本在两个集合里的比例和总体一致。代码上可以用StratifiedShuffleSplit:
from sklearn.model_selection import StratifiedShuffleSplit all_paths = [...] # 所有图片路径列表 labels = [...] # 对应的类别索引列表 sss = StratifiedShuffleSplit(n_splits=1, test_size=0.15, random_state=42) for train_idx, val_idx in sss.split(all_paths, labels): # train_idx / val_idx 就是划分后的下标 pass配合固定torch.manual_seed(42)和random.seed(42),同一份数据多次训练的结果波动会明显收窄。注意StratifiedShuffleSplit是按标签比例分层的,但同一患者的图片仍然可能分到两边,所以还要先按患者 id 分组再划层。
5.5 部署换机器就崩:环境依赖与相对路径
现象:在开发机上跑得好好的 GUI,拷到另一台机器上双击运行就报ModuleNotFoundError或者找不到模型文件。
原因:开发机装过一堆包但没导出依赖清单,或者模型路径写成了/home/xx/best_model.pt这类绝对路径。
解决:写一份requirements.txt,固定关键版本,至少包括torch、torchvision、Pillow、opencv-python。代码里用相对路径定位模型和资源文件:
from pathlib import Path BASE_DIR = Path(__file__).parent MODEL_PATH = BASE_DIR / "assets" / "best_model.pt"如果你的界面程序和模型文件要一起分发,把所有资源放到一个assets/目录里,用Path(__file__).parent来引用,这样不管程序被放到哪个盘都能找到模型。打包时还要注意把assets目录一并打进去,否则运行时会静默找不到模型。
6. 最后的技巧:用混淆矩阵和 Grad-CAM 验证你的 GUI 模型
6.1 混淆矩阵:看模型真正的短板
GUI 做出来之后,第一件事不是去演示,而是回到测试集上做一次系统验证。我习惯在测试集上一次性跑完所有样本,输出混淆矩阵,找到错分最集中的类别对。例如,白腻苔和黄腻苔之间如果经常混淆,说明模型受颜色影响太大,对“腻”的质地特征感知不足——这时候只调 GUI 是没用的,要回头把ColorJitter的饱和度抖动幅度进一步调小,或者考虑在预处理里做一次亮度归一化后再送进模型。
6.2 Grad-CAM 可视化:让 GUI 显示“模型在看哪里”
Grad-CAM 把最后一层卷积输出的梯度回传到特征图,得到一张热力图,叠加在原图上,就能看出模型关注的区域。在舌苔识别里,一个健康的表现是热力集中在舌头中后部;如果热力集中在嘴唇或背景上,说明模型学到的形状信息有偏。
def grad_cam(model, img_tensor, target_layer): activations = {} def forward_hook_fn(module, input, output): activations["value"] = output def backward_hook_fn(module, grad_input, grad_output): activations["grad"] = grad_output[0] handle_forward = target_layer.register_forward_hook(forward_hook_fn) handle_backward = target_layer.register_full_backward_hook(backward_hook_fn) output = model(img_tensor.unsqueeze(0)) one_hot = torch.zeros_like(output) class_idx = output.argmax(dim=1).item() one_hot[0, class_idx] = 1 model.zero_grad() output.backward(gradient=one_hot) handle_forward.remove() handle_backward.remove() grads = activations["grad"].mean(dim=(2, 3), keepdim=True) cam = torch.relu(activations["value"] * grads).sum(dim=1, keepdim=True) cam = cam.squeeze().cpu().numpy() # 归一化到 0~1 方便叠加显示 cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-7) return cam注意热力图一定要归一化到 0~1 再叠加显示,否则不同图片的热力强度不可比,用户看的时候会误以为模型置信度忽高忽低。在 GUI 里加一个“查看注意力”按钮,就能让使用者直观看到模型判断的依据,这比单纯给一个数字更让人信服。
我习惯把测试集所有样本的预测结果和热力图批量导出到report/文件夹,每次模型迭代后从里面挑典型样本人工复核,形成“训练-验证-复核”的闭环。这一步虽然花时间,却是整个项目里性价比最高的动作——它帮你从“模型在测试集上看指标”切换成“模型在真实场景里看行为”。这个习惯来自第一次做类似项目时被用户问了一句话:你告诉我准确率 90%,但为什么这张明显是黄腻苔的图分成了白腻苔?那一刻我发现,指标数字再好看,都不如让界面自己解释一次。后来加了热力图显示,这类问题不再需要我逐张解释,用户自己就能看出来模型是看错了纹理还是看错了颜色。希望这篇笔记能帮你把舌苔识别系统从脚本一路推到可交差的 GUI,少踩一点我当年踩过的坑。
本文还有配套的精品资源,点击获取