简介:这是一套基于深度学习的舌苔识别检测鉴定系统毕业设计项目源码,面向计算机相关专业毕业设计、课程设计与期末大作业场景,适合需快速上手完整项目的高校学生及AI实战学习者。项目经导师指导并以99分评审通过,代码完整可运行,配备PyQt5图形界面,支持舌苔图像检测识别流程演示与结果展示,降低入门门槛。资源包共109个文件,约105.44MB,主要包含26个Python源码文件、PyQt5界面ui文件、训练好的pth模型权重、json配置、jpg样例图片及2份docx毕业论文文档,另含训练日志文件便于复盘调参过程。已有150人学习下载,适合作为毕业设计直接参考或二次开发基础,可帮助使用者快速理解深度学习视觉项目从数据、训练到界面集成的完整链路。
1. 一套能出论文、能出界面的舌苔识别系统:它到底做了什么
如果你在医院信息化或中医数字化相关场景待过,就会知道“舌苔识别”不是玄学,而是一个非常典型的图像分类落地题:采集舌头照片,判断舌色、苔色、苔质,再给出一段可读的鉴定结论。这套基于深度学习的舌苔识别检测鉴定系统,把这条链路完整做成了毕业设计:Python 源码负责训练和推理,PyQt5 界面负责交互,配套毕业论文负责把原理、实验和结论讲清楚。下载包里还带了 TensorBoard 训练日志文件(events.out.tfevents.*),意味着你不需要从零跑一遍训练,也能先复盘别人当时的训练过程。适合两类人:正在做毕业设计、课程设计但缺一个“完整可运行”参照物的计算机专业学生,以及想练手“深度学习+桌面应用”全流程的开发者。它不是教学 Demo,是一个能跑、能出报告、能写进论文的完整项目。
2. 先理数据再谈模型:舌苔图像的组织、清洗与增强
2.1 目录结构与标签体系怎么设计
做图像分类,第一件事不是选模型,而是把数据目录定好。很多毕设翻车不是因为网络写错,而是训练集、验证集、测试集的文件路径乱成一团,最后连评估结果都没法解释。这个系统的常见做法是按“类别标签建文件夹”,PyTorch 的ImageFolder可以直接读,省去自己写 Dataset 的麻烦。
dataset/ ├── train/ │ ├── 淡红舌_薄白苔/ │ ├── 红舌_黄腻苔/ │ ├── 绛舌_少苔/ │ ├── 青紫舌_白腻苔/ │ └── ... ├── val/ │ └── ...(与 train 同结构) └── test/ └── ...(按图片存放,不建类别文件夹,留作盲测)这里的标签体系是“组合标签”,把舌色、苔色、苔质拼成一个类别名。组合标签的好处是直观,界面展示时可以直接说“淡红舌、薄白苔”;坏处是类别数会膨胀,如果每个维度拆开组合,五六十类很常见。毕设里如果原始数据量不大,通常压缩成五到八类主干类型,既保证每类有足够样本,又能在论文里画出像样的混淆矩阵。
你也可以改成 Multi-Label 输出,即舌头图片同时输出“舌色=淡红、苔色=白、苔质=薄”三个结果,但这会让模型结构从单头分类变成三头分类,训练代码和评估逻辑都复杂一截。对于课程设计和本科毕设,组合标签是性价比最高的方案。
2.2 预处理管线:去反光、尺寸统一与归一化
舌苔照片有一个很现实的干扰:反光。舌尖和舌面会因为唾液产生高光区域,这些高光在深度学习模型眼里往往被当成“白色特征”,直接影响苔色判断。我一般会在训练前加一道 CLAHE 光照均衡,把高光压下去,再做归一化。
import cv2 import numpy as np def normalize_illumination(image_path): # 用 imdecode 读取,兼容中文路径(详见第 5 章) img = cv2.imdecode(np.fromfile(image_path, dtype=np.uint8), 1) # 转 YCrCb,CLAHE 只作用在亮度通道上,不污染色度 ycrcb = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb) y, cr, cb = cv2.split(ycrcb) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) y = clahe.apply(y) ycrcb = cv2.merge([y, cr, cb]) return cv2.cvtColor(ycrcb, cv2.COLOR_YCrCb2BGR)这段代码里,clipLimit=2.0控制对比度增强幅度,太小没效果,太大会把舌头纹理搞得像塑料;tileGridSize=(8, 8)是局部均衡的网格密度,对 224×224 的输入图来说 8×8 网格是稳妥值。需要注意:预处理脚本必须在训练和推理阶段共用同一套逻辑,否则训练时见到的图像分布和在线识别时不一致,准确率会明显掉。
数据增强方面,舌体检测不太依赖水平翻转(舌象本来就有左右对称性,翻转是安全的),但随机旋转、亮度对比度扰动、随机缩放裁剪都很重要。用torchvision.transforms就能完成:
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomRotation(degrees=10), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])这里有几个细节值得记下来。RandomResizedCrop的scale=(0.8, 1.0)不是随便写的:裁剪比例低于 0.8 时,模型容易学到“舌头的局部纹理”而不是“整条舌头的形态”,这对苔质判断有害。ColorJitter的色相扰动我只给到saturation=0.1,舌色判断对色相极其敏感,调太大等于给模型制造错误标注。归一化用的mean和std是 ImageNet 的统计值,因为后面要用在 ImageNet 上预训练过的 ResNet50,输入分布必须对齐。
2.3 划分数据集,避免“同源泄漏”
数据划分是这类项目里最容易被低估的一步。舌苔照片往往是一个人不只拍一张:同一部手机、同一个光源、同一个角度连续拍五张。如果直接按文件随机划分,训练集和验证集里很可能出现同一舌头的不同照片,验证集准确率会虚高,论文里的数据会失真,换到真实场景立刻现原形。
正确做法是“按采集批次分组划分”。假设你的数据清单里每一行是图片路径, 类别, 采集批次号,批次号代表某次拍摄会话:
import pandas as pd from sklearn.model_selection import GroupShuffleSplit df = pd.read_csv("data_list.csv") gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) # groups 是采集批次号列,同一次拍摄的图片只会进同一侧 train_idx, val_idx = next(gss.split(df, groups=df["group_id"])) train_df = df.iloc[train_idx].reset_index(drop=True) val_df = df.iloc[val_idx].reset_index(drop=True)GroupShuffleSplit是 sklearn 里做分组划分的现成工具,传入groups参数后,它保证同组数据不会同时出现在训练集和验证集里。test_size=0.2表示留 20% 的批次做验证;random_state=42固定随机种子,保证每次跑论文里的结果可复现。如果你的数据没有批次字段,一个补救办法是把同一天、同一个人拍的照片当成同一组,手工补一列group_id。这一步省不得。
3. 训练部分:模型选型与超参数,以及 TensorBoard 日志怎么看
3.1 用迁移学习,还是从零训练
舌苔识别项目的数据量通常只有几千到一两万张,从零训练一个深度卷积网络是不现实的,收敛慢、容易过拟合,而且论文里很难解释清楚你“设计”了什么网络结构。这个系统的合理技术路线是迁移学习:加载 ImageNet 预训练权重,把最后一层全连接换成自己的分类头。
主推 ResNet50。理由有三:一是预训练权重获取方便,PyTorch 官方仓库直接下载;二是残差结构在中小规模数据集上不容易退化,训练过程稳定;三是论文里可以画出清晰的原理图,答辩时好讲。MobileNetV3 的优势是轻量,推理快,适合后续部署到嵌入式设备,但作为毕设主力模型,ResNet50 的“正统感”更强。下面给出模型构建代码:
import torch.nn as nn import torchvision.models as models def get_model(num_classes, backbone="resnet50", freeze_backbone=True): if backbone == "resnet50": model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) elif backbone == "mobilenet_v3_large": model = models.mobilenet_v3_large(weights=models.MobileNet_V3_Large_Weights.IMAGENET1K_V1) in_features = model.classifier[-1].in_features model.classifier[-1] = nn.Linear(in_features, num_classes) # 冻结 backbone,只训练分类头 if freeze_backbone: for name, param in model.named_parameters(): if "fc" not in name and "classifier" not in name: param.requires_grad = False return model这段代码里weights=models.ResNet50_Weights.IMAGENET1K_V2指的是 PyTorch 官方提供的 ImageNet 预训练权重版本,V2 比 V1 的 top-1 准确率更高。freeze_backbone=True时,只训练最后的全连接层,适合先快速跑通流程、验证数据没问题;训练几轮之后如果想提点,再解冻 backbone,用更小的学习率微调全部参数。千万别一开始就全部解冻,小数据集上大概率过拟合。
3.2 训练脚本的主干与关键参数说明
训练代码本身不复杂,核心是交叉熵损失 + AdamW 优化器 + 早停。先说为什么用交叉熵:它就是LogSoftmax + NLLLoss的组合,直接输出每个类别的概率分布,不需要在模型最后手动接 Softmax。优化器用 AdamW 而不是普通 Adam,因为它把权重衰减从梯度更新里解耦了,正则化效果更干净。
import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 假设 train_dataset / val_dataset 已经用 transforms 构建好 train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True) model = get_model(num_classes=8, freeze_backbone=False).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) best_acc = 0.0 patience = 10 no_improve = 0 for epoch in range(50): model.train() for images, labels in train_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) # 前向传播 loss = criterion(outputs, labels) # 计算损失 optimizer.zero_grad() # 清空上一轮梯度 loss.backward() # 反向传播 optimizer.step() # 更新参数 model.eval() correct, total = 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, dim=1) total += labels.size(0) correct += (predicted == labels).sum().item() val_acc = correct / total if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "best_model.pth") no_improve = 0 else: no_improve += 1 if no_improve >= patience: print("早停:验证集准确率连续 10 轮未提升") break参数选择说明:batch_size=32在 ResNet50 上大约需要 6~8GB 显存,没有独显的话降到 8 也能训练;lr=1e-4是全量微调时的安全值,如果只训练分类头可以用1e-3;weight_decay=1e-4是 L2 正则的强度,太大模型欠拟合,太小防不住过拟合。早停的patience=10意味着验证集准确率连续 10 轮不刷新就停止训练,既省时间又防止继续跑导致验证集过拟合。代码里我保存的是state_dict()而不是整个模型,这样换机器加载权重时不会被 Python 版本和类定义位置干扰。
3.3 从 tfevents 文件读训练过程,判断早停时机
下载包里那一堆events.out.tfevents.*文件,就是训练过程留下的 TensorBoard 日志。加载方式很简单:
tensorboard --logdir=runs浏览器打开localhost:6006,能看到 loss 曲线和 accuracy 曲线。看曲线时要关注三个点。第一,训练 loss 和验证 loss 的间距:如果训练 loss 一直降、验证 loss 走到低点后反弹,说明模型开始过拟合,最佳模型应该在验证 loss 的最低点附近取,而不是最后一个 epoch。第二,准确率曲线的震荡幅度:舌苔数据通常类别不平衡,准确率曲线上下跳是正常的,看平滑后的趋势,别被单轮抖动带偏。第三,如果 loss 曲线从第 20 轮开始基本走平,说明学习率需要衰减,或模型容量已经到顶,继续训练意义不大。
这里的血泪经验是:TensorBoard 不只是用来截图放论文的,它最大的作用是让你知道“该不该停”。我见过太多人不管曲线直接跑满 50 个 epoch,最后保存的模型比第 23 轮的差一大截。
4. PyQt5 界面与端到端推理:从按钮到鉴定结果
4.1 推理函数先跑通,再连界面
做界面前,先把推理逻辑抽成一个纯函数。这样你可以在命令行先验证单张图片,再把它接到按钮事件上,出问题好定位。推理代码必须和训练时的预处理保持一致,这是最容易踩的坑:训练时做了 CenterCrop,识别时忘了做,模型输入分布完全变了。
import torch import torch.nn.functional as F from PIL import Image from torchvision import transforms class_names = ["淡红舌_薄白苔", "红舌_黄腻苔", "绛舌_少苔", "青紫舌_白腻苔", ...] infer_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def predict(model, image_path, topk=3): img = Image.open(image_path).convert("RGB") tensor = infer_transform(img).unsqueeze(0) # 转成 (1,3,224,224) model.eval() with torch.no_grad(): logits = model(tensor) prob = F.softmax(logits, dim=1) top_prob, top_idx = torch.topk(prob, topk, dim=1) results = [] for i in range(topk): idx = top_idx[0][i].item() score = top_prob[0][i].item() results.append((class_names[idx], score)) return resultsunsqueeze(0)是把单张图片补一个 batch 维度,因为 PyTorch 的卷积层要求输入是四维张量。model.eval()必须调用,它会把 Dropout 关闭、BatchNorm 切换到用全局统计量,否则同一张图每次预测结果可能不一样。topk=3返回概率最高的三个类别,界面可以展示“第一候选、第二候选”,这在舌苔识别里很实用——有些类别外观接近,模型给出的第二候补对医生有参考价值。
4.2 PyQt5 主窗口:选图、显示、出报告
PyQt5 界面是这类系统的门面。主窗口的逻辑很简单:一个按钮弹出文件选择框,一个 QLabel 显示图片,一个 QLabel 或 QTextEdit 显示识别结果,但代码细节里藏着几个容易崩的点。
import sys from PyQt5.QtWidgets import (QApplication, QMainWindow, QPushButton, QLabel, QFileDialog, QVBoxLayout, QWidget) from PyQt5.QtGui import QPixmap from PyQt5.QtCore import Qt class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("舌苔识别检测鉴定系统") self.image_label = QLabel("请选择图片") self.image_label.setAlignment(Qt.AlignCenter) self.result_label = QLabel("识别结果将显示在这里") self.result_label.setWordWrap(True) self.btn_open = QPushButton("打开图片") self.btn_predict = QPushButton("开始识别") self.btn_predict.setEnabled(False) layout = QVBoxLayout() layout.addWidget(self.btn_open) layout.addWidget(self.image_label) layout.addWidget(self.btn_predict) layout.addWidget(self.result_label) container = QWidget() container.setLayout(layout) self.setCentralWidget(container) self.btn_open.clicked.connect(self.open_image) self.btn_predict.clicked.connect(self.run_predict) self.image_path = None def open_image(self): path, _ = QFileDialog.getOpenFileName( self, "选择舌苔图片", "", "图片文件 (*.jpg *.jpeg *.png)") if path: self.image_path = path pixmap = QPixmap(path) # 缩放显示,避免大图把窗口撑爆 pixmap = pixmap.scaled(self.image_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) self.image_label.setPixmap(pixmap) self.btn_predict.setEnabled(True)QPixmap(path)直接传中文路径在 Windows 上可能加载失败,这是 PyQt5 的老问题,后面避坑章会给解决方案。pixmap.scaled()的三个参数是目标尺寸、保持宽高比、平滑缩放,缺了 SmoothTransformation 图片边缘会出现明显锯齿。这里还用了一个小技巧:开始识别按钮默认禁用,只有选完图才启用,避免用户没选图就点按钮导致空指针。
4.3 在界面上呈现“鉴定报告”而不是干巴巴的 0/1
分类模型输出的是一串数字索引,对用户没有意义。界面上的“鉴定报告”要做一层翻译:把类别名拆回“舌色、苔色、苔质”,再把置信度格式化展示。如果第一置信度低于阈值,还要给出“请重新拍摄”的提示,这在第 6 章展开。
def run_predict(self): if not self.image_path: return # 这里先用同步方式演示,实际项目建议放 QThread,见第 5 章 results = predict(self.model, self.image_path, topk=3) lines = [] for i, (name, score) in enumerate(results, start=1): # 假设类别名格式是 "舌色_苔色_苔质" parts = name.split("_") if len(parts) == 2: tongue_color, coating = parts lines.append(f"候选 {i}:舌色 {tongue_color} / 苔质 {coating},置信度 {score:.2%}") else: lines.append(f"候选 {i}:{name},置信度 {score:.2%}") self.result_label.setText("\n".join(lines))score:.2%把小数转成百分比并保留两位,界面显示“置信度 92.30%”比“0.9230”直观得多。split("_")依赖前面定的组合标签格式,如果训练时用了别的分隔符,这里要同步改。这个函数虽然简单,但把“模型输出”转化成了“人能看懂的报告”,论文截图时也好看。
5. 避坑与排查:从训练到界面最常见的五个翻车点
5.1 训练损失不降,准确率也不动
现象:训练了十几个 epoch,loss 在 2.0 附近波动,准确率在 10%~20% 徘徊。原因:最常见的是学习率过大,模型参数在最优解附近震荡跳不出来;其次是标签编码错误,类别标号和class_names对不上。解决:先把学习率降到 1e-4 或 1e-5 跑几轮看趋势;再到验证集上随机抽几张图,打印模型的原始输出,人工核对argmax的索引是不是对应正确类别。如果数据只有两类但模型最后全连接层写了 8,也会出现这种问题,检查num_classes。
5.2 验证集准确率很高,换手机拍的照片识别一塌糊涂
现象:测试集准确率 90% 以上,复制一张手机随手拍的舌苔照片,识别完全错误。原因:训练集和测试集来自同一数据源,光照、相机色彩、舌体占比高度相似,模型实际学到的是“这个数据源的风格”而不是舌象特征;拍照角度、色温一变,分布偏移立刻暴露。解决:训练阶段把数据增强里的ColorJitter强度加大一点,brightness和contrast都提到 0.3 左右;推理前把用户上传的图片也过一遍 CLAHE 光照均衡;更彻底的办法是从一开始就混合多个来源的数据,而不要只用一个拍摄环境。这也是为什么论文里一定要写数据来源和增强策略,评阅老师问的就是这个。
5.3 PyQt5 界面点击识别后卡死,窗口无响应
现象:点击“开始识别”按钮后界面变白,等十几秒才恢复。原因:推理放在 GUI 主线程里执行,ResNet50 前向传播阻塞了 Qt 的事件循环,窗口自然卡死。解决:把推理丢到QThread里,通过信号把结果传回主线程。
from PyQt5.QtCore import QThread, pyqtSignal class PredictWorker(QThread): result_ready = pyqtSignal(list) def __init__(self, model, image_path): super().__init__() self.model = model self.image_path = image_path def run(self): results = predict(self.model, self.image_path, topk=3) self.result_ready.emit(results)然后按钮的响应函数改成启动线程而不是直接调预测。注意:self.model要在主线程加载一次,传给 Worker 复用,千万不要每次识别都重新加载权重文件,否则 GPU 显存会被反复占用,CPU 机器上则会造成明显的启动卡顿。
5.4 图片路径含中文导致程序崩溃
现象:用cv2.imread()读图返回None,或者QPixmap(path)显示空白。原因:OpenCV 和 Qt 在 Windows 上处理中文路径时编码不一致,cv2.imread只认字节串,中文路径会转失败。解决:OpenCV 改用cv2.imdecode(np.fromfile(path, dtype=np.uint8), 1);PyQt5 里先把路径用os.path.abspath规范化,再传给QPixmap。这个坑在毕设答辩演示时最容易触发,因为答辩评委的图片很可能放在中文目录下。
5.5 GPU 显存不够,OOM 报错
现象:训练刚开始就报CUDA out of memory。原因:batch_size 太大,或同时加载了多个模型。解决:把batch_size降到 16 或 8;图片输入尺寸从 224 降到 192 也能省显存,但验证时不要再降;如果在跑界面推理,模型加载后用torch.no_grad()包裹前向传播,并且只保留一份权重。没有独立显卡的机器,训练用 CPU 也能跑,把num_workers调大、模型换成 MobileNetV3,一个 epoch 慢但能出结果。
6. 让鉴定结果更可信:阈值过滤与注意力可视化
模型给出“红舌_黄腻苔,置信度 92%”不是终点,还得回答一个质疑:这个结论靠谱吗?两个手段常用:置信度阈值过滤,和热力图可视化。
置信度阈值过滤很容易实现。在predict()的基础上,加一道判断:如果最高概率低于某个阈值,不返回具体类别,而是提示用户重新拍摄。阈值可以用验证集来定——把验证集里所有样本按类别分开,统计每个类别的平均置信度,取一个能让绝大多数正确样本通过、错误样本被拦下的值。舌苔识别的合理阈值一般在 0.6~0.8 之间,因为类别本身有相似性,定太高会导致大量图片“无法识别”,定太低则失去过滤意义。
def predict_with_threshold(model, image_path, threshold=0.65): results = predict(model, image_path, topk=3) top_label, top_score = results[0] if top_score < threshold: return None, top_score # 置信度不足,不给出鉴定结论 return top_label, top_score返回None时,界面显示“置信度不足,请调整光线后重新拍摄”。这个提示比给一个错误的鉴定结果更负责任,论文里也可以专门写一节“基于置信度阈值的结果可靠性分析”。
另一个进阶验证是热力图可视化,目的很直接:确认模型是在看舌头,而不是在看背景或者相框。一种简单的做法是把 ResNet50 最后一个残差块输出的特征图,和全连接层的类别权重做加权求和,得到一张和原图尺寸一致的注意力图,叠加在原图上展示。
import torch.nn.functional as F def grad_cam_style_map(model, tensor): # 提取 layer4 输出的特征图 features = model.layer4(tensor) # 取目标类别的全连接层权重(假设类别索引为 cls_idx) cls_idx = 0 fc_weight = model.fc.weight[cls_idx] # 形状 (512,) # 全局平均池化得到通道权重 pooled = F.adaptive_avg_pool2d(features, (1, 1)) # 形状 (1,512,1,1) pooled = pooled.view(pooled.size(0), -1) # 特征图与权重加权求和,得到 (1,1,H,W) heatmap = torch.sum(features * fc_weight.view(1, -1, 1, 1), dim=1, keepdim=True) heatmap = F.relu(heatmap) heatmap = F.interpolate(heatmap, size=(224, 224), mode="bilinear", align_corners=False) return heatmap[0, 0]这段代码是 Grad-CAM 的精简版,胜在不需要额外依赖。model.layer4是 ResNet50 最后一个残差阶段的输出,空间分辨率 7×7,语义信息最丰富;fc_weight是目标类别的分类权重,它决定了哪些特征通道对这个类别更重要;F.interpolate把 7×7 的热力图放大回 224×224 以便叠加。把热力图转成伪彩色叠加到原图上,如果高亮区域集中在舌体中央,说明模型学到了“舌苔特征”;如果高亮区域在图片角落或背景上,说明训练数据里背景信息泄漏了,必须回炉清洗数据。
我自己的习惯是:每次训练完,专门挑十张验证集图片和十张真实场景图片跑一遍热力图,肉眼看完再决定要不要改数据。这个习惯救过我两次——一次发现模型在学手机水印,另一次发现暗光环境下模型完全靠亮度判断“淡白舌”,都属于典型的学习到错误特征。从那以后我每次做分类项目都强制走一遍热力图检查。这套系统你拿到手后,建议也按这个顺序验证:先看 TensorBoard 确认训练曲线,再用 PyQt5 界面跑真实图片,最后用热力图确认模型关注区域。三条线都对上了,论文里的图表和答辩时的演示才站得住。希望帮到你。
本文还有配套的精品资源,点击获取