
简介深度学习在计算机视觉领域的落地离不开目标检测与图像分类的协同。舌苔识别作为医学图像分析的代表性任务需要先通过目标检测框定舌体区域再进行苔色与苔质分类这一级联结构有效降低了背景干扰。数据标注规范、颜色增强策略和验证集划分直接影响模型泛化能力而YOLO与ResNet的迁移学习组合提供了稳定基线。针对白苔样本占比过高等类别不平衡问题可通过损失函数加权缓解。结合混淆矩阵与Grad-CAM热力图能进一步验证模型依据是否落在合理区域。本文从数据、训练、论文到避坑经验完整拆解舌苔检测系统的工程落地路径。1. 为什么是舌苔检测目标、形态与深度学习落地的真实起点我见过不少把“舌苔检测”做成“玩具分类器”的毕设模型在实验室几十张图里准确率 99%换到食堂门口随手拍的场景立刻失效。舌苔检测本质上不是单任务它是“舌体定位 苔色/苔质分类”两级识别——先用目标检测把舌头从口腔照片中框出来再对舌体区域判断苔色偏白还是偏黄、苔质偏厚还是偏腻。后者的核心难点在于舌苔图像没有大规模公开数据集标注依赖中医专业判断且颜色极易受光源影响。这套系统的工程重心应该放在数据规范和验证设计上模型反而用常见深度学习框架就够了。如果你正打算做深度学习毕设或者想模仿这类医学图像识别项目的整体结构这篇按“数据 → 训练 → 论文 → 避坑”的顺序拆给你新手能照做熟手能复现。2. 舌苔检测的数据准备标注规范与最小 PyTorch 数据集2.1 先把任务拆对舌体检测和苔质分类不是同一件事舌苔图像没有现成的大规模公开数据集能拿到的往往是自己拍的几百张口腔照片外加少量网络整理图。这一步如果不把任务拆清楚后面写论文时会发现自己在反复推翻标注模型也始终收敛不好。我的做法是把系统拆成两个子任务。第一个子是舌体检测输出一个矩形框——只要框能把舌头完整包住框得略松一点也没关系。第二个子是苔质分类输入是从原图裁剪出来的舌体区域输出苔色类别白苔、黄苔、灰黑苔或者苔质类别薄苔、厚苔、腻苔。检测用目标检测模型分类用图像分类网络两个模型独立训练Pipeline 串联起来就是一个完整的识别系统。这个结构和很多医学图像项目一样前级定位降低背景干扰后级分类在裁剪图上做细粒度判断比单独用一个多标签分类器稳定得多。这里有个常见误用把苔色、苔质、舌体形状全部塞进检测框的类别标签里比如“white_coated_tongue”“swollen_tongue”。这样做的问题在于目标检测训练要求每个类别外观相对稳定而“白苔胖大舌”这类复合标签会把训练数据打散模型很难收敛。苔质属性应该放在第二阶段对裁剪后的舌体做图像级分类而不是把复合属性硬塞给检测器。要是样本量本身不足更不要试图一步到位。标注时还要注意图像来源混杂性。手机拍摄的照片背景里可能有牙齿、嘴唇、舌头边缘不同设备、不同光源拍出的色温差异明显。标注规范里要明确框住整个舌体包含舌尖和一定舌根范围不要只框有苔的部分。因为分类阶段需要完整舌面信息整舌裁剪比局部苔面裁剪更利于模型学习。2.2 用 VOC XML 转 YOLO txt 的脚本建立标注流水线开始训练之前得先把标注数据整理成模型能直接读取的格式。常用标注工具 LabelImg 导出的是 VOC XML 格式而 YOLO 系列需要的是一行一个目标的文本格式类别ID 归一化的中心点坐标和宽高。每次手工转换容易出错我一般会写一个小脚本一次性处理整个标注目录。import xml.etree.ElementTree as ET from pathlib import Path def voc2yolo(xml_path, out_dir, class_names): 将 LabelImg 导出的 VOC XML 转为 YOLO txt 格式。 class_names 的列表顺序即类别 ID要与训练配置保持一致。 tree ET.parse(xml_path) root tree.getroot() size root.find(size) img_w float(size.find(width).text) img_h float(size.find(height).text) file_stem Path(xml_path).stem lines [] for obj in root.findall(object): name obj.find(name).text if name not in class_names: continue 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) # 转成归一化的中心点 宽高 x_center (x_min x_max) / 2.0 / img_w y_center (y_min y_max) / 2.0 / img_h box_w (x_max - x_min) / img_w box_h (y_max - y_min) / img_h # 防止越界 x_center max(0.0, min(x_center, 1.0)) y_center max(0.0, min(y_center, 1.0)) lines.append(f{class_names.index(name)} {x_center:.6f} f{y_center:.6f} {box_w:.6f} {box_h:.6f}) out_file Path(out_dir) / f{file_stem}.txt if lines: out_file.write_text(\n.join(lines), encodingutf-8) print(f[OK] {file_stem}.txt 已生成共 {len(lines)} 个目标) else: print(f[WARN] {file_stem}.xml 没有匹配的类别跳过) # 检测阶段只做一个类别舌体 class_names [tongue] voc2yolo(data/annotations/001.xml, data/labels/, class_names)代码逻辑不复杂解析 XML 里的 size 拿到图片宽高遍历 object 里的 bndbox 四个角按公式转成 YOLO 的归一化表示。x_center 和 y_center 表达的是矩形中心点相对图宽图高的比例所以永远落在 0 到 1 之间box_w 和 box_h 同理。最后把转换结果写入与图片同名的 txt 文件Ultralytics YOLO 训练时会自动按图片名找对应标签。需要特别说明两个参数class_names 的列表顺序就是 YOLO 数据配置里的类别 id一旦确定就不能随意变动否则标注和模型输出会对不上如果一张图里只有一个舌头但某些 xml 里混入了“tongue_coating”这类额外类别class_names 不包含它这个目标会被直接跳过这就是脚本里 continue 的作用。2.3 数据增强能用哪几种别让颜色扰动毁掉苔色语义舌苔识别极度依赖颜色分布。白苔、黄苔、灰黑苔在形状上可能没有明显差异区分它们靠的是色相和饱和度。很多通用目标检测项目里默认的颜色抖动增强在这里要非常克制。我给的增强配置分两套。舌体检测阶段因为只是定位可以适度用空间变换随机旋转、随机裁剪、随机缩放但旋转角度控制在小范围内舌体边缘变形不至于太夸张。苔质分类阶段颜色增强里的 hue 参数直接设为 0brightness 和 contrast 幅度也压到 0.1 以内。不是所有数据增强都能提升泛化能力对医学图像来说过度扰动会让模型学到扭曲的颜色分布看起来训练损失降得很漂亮换成真实场景立刻露馅。水平翻转也要谨慎。中医舌诊对舌面左右分区有语义考虑虽然在纯舌体检测任务里水平翻转影响不大但如果你后续想分析舌尖、舌中、舌根不同区域翻转会打乱解剖位置的一致关系。空间变换里相对安全的是小角度旋转、仿射缩放和随机裁剪颜色变换里相对安全的是亮度扰动和轻微对比度扰动。下面是一套可直接嵌入分类训练流程的 PyTorch transformimport torchvision.transforms as T train_transform T.Compose([ T.Resize((256, 256)), T.RandomRotation(degrees10), T.RandomCrop(224, pad_if_neededTrue), # 注意hue 不扰动因为白苔/黄苔依赖色相 T.ColorJitter(brightness0.1, contrast0.1, hue0), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) test_transform T.Compose([ T.Resize((224, 224)), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])我把 Resize 定到 256再随机裁剪 224这是为了在裁剪时引入轻微平移同时保证输入分辨率稳定。Normalize 用 ImageNet 的均值和标准差因为后面做迁移学习时预训练权重的统计分布就是这套数字。2.4 数据目录最小结构训练、验证、测试怎么分数据集目录我自己习惯按天、按贡献者分成子目录而不是一股脑塞进一个 images 文件夹。因为医学图像项目经常出现某个人拍的照片整体偏亮、另一个人偏暗如果这些人恰好都进了验证集最后的评估结果会被虚假抬高。推荐目录结构如下data/ ├─ images/ │ ├─ train/ │ ├─ val/ │ └─ test/ ├─ labels/ │ ├─ train/ │ ├─ val/ │ └─ test/ └─ tongue.yaml划分比例我常用 7:2:1或者更严格一点在样本稀少时用 6:2:2。关键原则是确保同一个人的不同照片不要同时出现在训练集和验证集。具体到舌苔数据拍摄志愿者的舌头可能有十张不同角度照片如果都混进 train 和 val模型在验证集上就被“见过的人”污染了泛化能力无从评估。tongue.yaml 的内容按 Ultralytics 的约定写path: data train: images/train val: images/val names: 0: tongue这里只有一个类别因为检测任务只负责定位舌体。第二阶段的分类数据是另一套目录图片是裁剪后的舌体标签是类别索引。3. 模型选型与训练闭环YOLO 定位 ResNet 分类的最小实现3.1 单阶段检测器为什么更适合舌体定位舌体检测这个任务有两个特点一是目标物单一一张图里最多一到两个舌头极少情况不需要复杂的多目标关联二是舌头和背景的边界总体清晰不像密集小目标那样考验特征分辨率。这两个特点决定了单阶段检测器是最省力、最不容易翻车的选择。对比两阶段检测器 Faster R-CNN单阶段 YOLO 的训练时间短调参维度少对于毕设和中小型项目更友好。尤其你用的如果是 YOLOv8n 这类轻量模型一张 640×640 的图在普通 GTX 1660 上都能跑到不错的帧率。舌体不是小目标也不需要很强的语义上下文轻量模型反而减少过拟合风险。分类阶段我默认用 ResNet。理由不复杂迁移学习的生态最成熟torchvision 里直接加载预训练权重一行代码搞定而且 ResNet 的残差结构对中小数据集非常稳。不要一上来就试 ViT 或 EfficientNet 的复杂变体舌苔数据量一般只有几千张大规模模型很容易过拟合。ResNet-18 或 ResNet-34 在这个任务上已经足够。如果你手里的样本量少于五百张我建议从 ResNet-18 开始先跑通整个训练流程再谈换大模型。样本少的时候重点不是模型容量而是数据增广、迁移学习和验证策略。3.2 用 Ultralytics 训练舌体检测器train 方法与关键参数Ultralytics 训练目标检测模型非常直接核心是把数据配置写清楚。from ultralytics import YOLO # 使用官方预训练权重做迁移学习基础 model YOLO(yolov8n.pt) results model.train( datadata/tongue.yaml, epochs100, imgsz640, batch16, lr00.01, patience15, device0, # 0 表示第一块 GPUCPU 可设 cpu seed42 )这段代码里各参数效果差异很大。epochs 设 100 但配合 patience 早停实际会提前结束如果发现 loss 还在明显下降可以提高到 150。imgsz 影响检测精度和速度之间的平衡——640 是多数 YOLO 项目的经验值舌体不是小物体没必要刻意上 1280反而拖慢训练。batch 取决于显存16 在 8G 显存下接近极限显存小就降到 8梯度更新并不会因为 batch 变小而失效。lr0 是初始学习率数据量越少学习率越不能大我见过不少初学者用默认 0.01 训到一半发散的案例。早停参数 patience15 表示连续 15 轮验证指标没有改善就停止训练。这个值不要调太小否则模型可能还在震荡期就被砍掉了。device0 指定 GPU如果你的机器只有 CPU就把 devicecpu同时把 imgsz 降到 320、batch 降到 4勉强能跑但时间会成倍增加。训练结束后Output 目录里会生成 best.pt 和 last.pt。best.pt 是按验证集指标筛选出的最优权重部署和测试都用它而不是 last.pt。3.3 苔质分类的 ResNet 训练循环迁移学习、早停与 Checkpoint分类网络直接加载 ImageNet 预训练权重然后替换最后一层全连接。舌苔图像的纹理和自然图像差异很大但底层边缘、颜色过渡、纹理基元是共享的所以迁移学习在小数据集上能省大量训练时间。import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision.models import resnet18, ResNet18_Weights num_classes 4 # 白苔、黄苔、灰黑苔、无苔 model resnet18(weightsResNet18_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, num_classes) device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-4) best_acc 0.0 patience 10 bad_epochs 0 for epoch in range(60): model.train() train_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() train_loss loss.item() val_acc evaluate(model, val_loader) avg_loss train_loss / len(train_loader) print(fepoch {epoch1}: loss{avg_loss:.4f}, val_acc{val_acc:.4f}) if val_acc best_acc: best_acc val_acc bad_epochs 0 torch.save(model.state_dict(), best_tongue_classifier.pt) else: bad_epochs 1 if bad_epochs patience: print(早停触发停止训练) breakevaluate 函数逻辑比较简单关掉梯度遍历验证集统计预测正确的比例。注意这里用小写字母 lr 的 Adam 学习率初始值建议 1e-4因为预训练模型已经收敛到了较好的特征表示lr 太大会破坏底层权重。早停条件设置为连续 10 轮验证集准确率不再提升就停止Checkpoint 只保留效果最好的权重避免最后几轮过拟合把模型带偏。损失函数用 CrossEntropyLoss 是标准做法如果发现类别分布极度不均可以进一步给 loss 传入 weight 参数来放大少数类的梯度信号。这个后面在第 5 章的类别不平衡里会展开讨论。3.4 mAP 之外还要看混淆矩阵评估指标怎么配检测模型的评估通常看 mAPUltralytics 训练结束后会在验证集上自动计算并打印。舌体只有一类mAP 等价于这一类别的 AP需要注意 AP50 和 AP75——前者是 IoU 阈值 0.5 下的平均精度后者是 0.75阈值越高越考验框的贴合度。分类模型这边光看准确率不够。舌苔分类的典型坑是类别不平衡白苔占大多数模型全猜白苔也能拿到很高的准确率实际上对黄苔毫无识别能力。这时要看混淆矩阵它能清晰告诉你哪个类别被模型系统性地误判到另一个类别。from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay import matplotlib.pyplot as plt y_true, y_pred [], [] model.eval() with torch.no_grad(): for images, labels in val_loader: images images.to(device) outputs model(images) preds outputs.argmax(dim1).cpu() y_true.extend(labels.tolist()) y_pred.extend(preds.tolist()) cm confusion_matrix(y_true, y_pred) disp ConfusionMatrixDisplay(cm, display_labels[白苔, 黄苔, 灰黑苔, 无苔]) disp.plot(cmapBlues) plt.savefig(confusion_matrix.png, dpi150)这份混淆矩阵图可以直接放进论文结果部分。看它的时候重点不是对角线上的数字而是对角线以外的集中区域。比如黄苔大量被误判为白苔说明模型在色相边界上辨识力弱可能需要补充更多黄苔训练样本或者检查训练集和测试集之间的光源一致性。4. 开题报告与论文怎么写把系统的每个模块变成可答辩的论据4.1 开题报告四板块选题依据、研究现状、技术路线、预期成果开题报告老师最在意的是三个问题你要做什么、为什么做、能不能做完。不要沉迷于堆砌深度学习的算法名词而要体现出你对任务本身的理解。选题依据这块要把中医舌诊的背景和数字化的现实痛点结合起来说。舌诊是中医望诊里信息量最大的部分但传统上依赖医生目测主观性强、难以量化和归档。基于深度学习的舌象自动分析可以辅助医生做健康初筛具备明确的落地场景。这块内容不用写得太虚突出“客观化、可记录、可回放”的价值即可。研究现状这一小节最容易写成流水账。常见的大纲是从传统图像特征提取开始——颜色矩、纹理特征、GLCM然后过渡到 CNN 分类、目标检测再提到开源模型在医学图像上的迁移潜力。你需要做的不是罗列十篇论文而是归纳出一条技术演进线传统方法为什么局限深度学习方法解决了什么还有什么问题没解决。写成几百字足够关键是把文献按脉络组织起来。技术路线是开题报告的灵魂也是学生最容易写崩的地方。很多开题报告技术路线画得花里胡哨却没有说明数据从哪来、标注怎么做、模型怎么训练、怎么验证。我建议用一段流程描述替代复杂流程图数据采集与脱敏 → 舌体标注 → 目标检测模型训练 → 舌体区域裁剪 → 苔质分类模型训练 → 系统集成与界面 → 对比实验与结果分析。每一步都要对应到可执行的交付物。预期成果要写具体。不要只写“完成一个识别系统”要写清楚输出什么格式的检测框分类覆盖几个类别准确率目标是多少系统以什么形式演示。准确率目标合理即可比如“验证集准确率达到 85% 以上”留出浮动空间答辩时才不会被打穿。4.2 对比实验和消融实验的设计惯例从 baseline 到消融表论文方法部分里“我的模型比别人的好”靠对比实验支撑“我的设计里每个模块都起作用”靠消融实验支撑。舌苔检测系统没有特别新颖的算法所以实验设计要格外扎实。对比实验的设计思路是以模型为主变量其他条件完全不变。比如分类阶段固定同一套数据集、同样的增强策略、同样的早停条件分别训练 ResNet-18、ResNet-50、VGG-16、MobileNetV2统计各自验证集的准确率、F1、参数量和推理时间。这张表格是论文最核心的结果表。模型参数量准确率F1 (macro)单张推理耗时ResNet-1811.2M86.4%0.8412msResNet-5025.6M87.8%0.8522msVGG-16138.4M84.1%0.8031msMobileNetV23.5M82.7%0.798ms表格里的数字需要你自己跑出来我这里给出的是格式样板。单张推理耗时也要附上因为答辩时老师会问“你这套系统实时性如何”没有这个数据就答不上来。消融实验要按模块逐个拆常见做法是去掉数据增强对比准确率变化去掉 ImageNet 预训练权重从头训练把检测方案换成传统肤色分割分类阶段是否使用裁剪后的舌体区域每个变量剔除后模型指标的下降幅度证明该模块的必要性。比如去掉预训练的 ResNet-18 准确率从 86% 掉到 72%这就说明迁移学习的价值被充分论证了。4.3 论文图表规范系统流程图、样本隐私与训练曲线论文排版和格式对毕设来说非常关键这决定了老师答辩时的第一印象。系统流程图是必须有的建议用 Visio 或 Draw.io 画按“图像输入 → 预处理 → 目标检测 → 舌体裁剪 → 苔质分类 → 结果输出”的流程整理每个模块标注模型名称和处理后的图像尺寸。舌象图片进论文要注意隐私脱敏。志愿者照片属于敏感数据展示时要隐去可识别的面部特征通常只截取舌部区域并注明“图片已做匿名化处理仅保留舌象区域”。有条件的应该请志愿者签一份简单的数据使用授权这点在开题报告的数据来源里也要提到。训练曲线图包含 loss 曲线和准确率曲线。loss 曲线建议画训练集和验证集两条线方便展示过拟合的位置准确率曲线用验证集数值曲线里如果出现剧烈震荡说明学习率偏大或 batch 过小。5. 舌苔检测系统的 5 个高频翻车点从数据标注到环境依赖5.1 loss 一路下降、测试指标不动过拟合的三个排查方向现象是训练集 loss 从 1.2 一路降到 0.03验证集准确率却始终在 75% 徘徊甚至后段开始回落典型的过拟合表现。原因不外乎三个第一训练集和验证集来自同一个拍摄批次的照片模型学到了拍摄场景的特征而不是舌苔特征第二增强强度不足特别是颜色扰动幅度太小导致模型对光照变化没有鲁棒性第三模型参数量相对于样本量过大ResNet-50 在几百张图上很容易把训练集背下来。解决思路按优先级排。先调整数据划分确保同一个人的照片只在训练集或验证集其一再把第 2.3 节里的增强强度适度放大尤其亮度抖动的范围可以放宽到 0.2最后如果仍然过拟合直接降模型复杂度把 ResNet-50 换回 ResNet-18或者加入 Dropout 和权重衰减。权重衰减系数我一般从 1e-4 开始试。5.2 换台手机拍摄就失效光源偏移与颜色校准现象是在实验室用手机 A 拍摄的数据训练出来换手机 B 拍同一张嘴黄苔被识别成白苔白苔被识别成正常舌象。原因很简单手机白平衡算法不一致同一场景不同设备拍出的色温和曝光完全不同。舌苔分类的核心特征就是颜色色相偏移直接击穿模型。解决有两个层次。新收集一批不同设备拍的数据把白平衡差异作为方差加入训练集是最有效但成本最高的做法。低成本的做法是用传统白平衡算法做预处理比如使用标准灰色卡在校阶段校准或者用 OpenCV 的灰度世界算法把图像全局颜色拉平。灰度世界算法假设平均色彩趋于灰色对口腔这类偏红的图像不完全适用但配合白斑校正能改善很多。5.3 两个标注者画出的框差 30%标注规范的执行成本现象模型训练时 loss 震荡验证集 mAP 稳定在 0.88 但框的位置明显偏移——有些框紧紧贴着舌缘有些框把下巴也包进去。原因标注规范不到位。不同标注者对“舌体边界”的理解不同导致目标框的 IoU 波动大模型在联合分布上学了一个折中结果谁都算漂。解决最核心的是制定统一规范并写入说明文档框的上边界在舌尖上缘下边界覆盖到舌根可见位置左右边界贴着舌缘不允许包含过多牙齿或唇部如果舌尖上翘框整体往上平移以保证舌面完整。规范落实后重新标注一遍框的一致性会显著改善。小技巧是让两个标注者先各自标完 20 张图算一下平均 IoU再统一标准后重新标注反复两轮可以压缩标注差异。5.4 白苔样本占七成类别不平衡的处理手段现象训练结束混淆矩阵里黄苔和灰黑苔几乎空白白苔准确率很高但其他类别全面失效。原因是采集志愿者本来就应该以健康白苔为主病态苔色收集困难样本天然倾斜。解决的常用手段是给 CrossEntropyLoss 传入类别权重。具体做法是先统计训练集各类别数量把最大值除以每个类别的样本数得到权重并在一个 epoch 按权重重采样。import torch.nn as nn # 假设各类别样本数为 [370, 80, 30, 120] samples torch.tensor([370.0, 80.0, 30.0, 120.0]) weights samples.max() / samples criterion nn.CrossEntropyLoss(weightweights.to(device))设置权重后少数类的梯度惩罚被放大模型不再一味拟合多数类。除了 loss 权重在数据层面做类别图表的重复采样也有效——每次 epoch 从少数类里随机复制图像加入训练但注意不要完全照搬到验证集里否则评估会失真。5.5 训练中断报错PyTorch 与 ultralytics 版本冲突现象是同一段代码在 A 电脑正常在 B 电脑第一天装好环境跑通第二天重启后 import ultralytics 直接报错或者训练到一半提示 torchvision 相关算子不存在。原因是 PyTorch、torchvision、ultralytics 三个包的版本矩阵不兼容。比如旧版 torchvision 跑在全新 CUDA 驱动上时某些自定义算子的加载路径会失败ultralytics 更新后对此前版本的 PyTorch API 产生依赖。解决的稳妥方案是建独立的 conda 环境不要直接在 base 环境里混装。conda create -n tongue python3.10 -y conda activate tongue pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install ultralytics这里必须让 torch 和 torchvision 用同一套 CUDA 版本编译尽最大可能避免混装。装完跑几行前向推理确认环境没碎再开始训练。如果显卡驱动是较新的就用 cu121 甚至更高版本对应的 wheel如果是核显或 CPU 机器把 index-url 换成 cpu 版本即可。环境配置的坑不是玄学绝大多数情况都是版本矩阵没对齐。6. 进阶验证用 Grad-CAM 直观确认模型在看什么我把 Grad-CAM 热力图作为系统交付前的最后一道验证它能彻底破除“黑匣子”焦虑。只看到 86% 的准确率并不够你还得知道模型的判断依据是不是合理的舌苔区域。如果一张白苔图的热力图高亮区域集中在舌面中部说明模型抓的是苔面分布如果高亮区域落在了背景的牙齿甚至嘴唇上说明模型靠背景过拟合得逞。6.1 Grad-CAM 的最小实现用 PyTorch 的钩子机制实现 Grad-CAM不需要引入额外的大依赖库。import torch from torchvision.models import resnet18 model resnet18(weightsResNet18_Weights.IMAGENET1K_V1) model.fc torch.nn.Linear(model.fc.in_features, 4) model.load_state_dict(torch.load(best_tongue_classifier.pt, map_locationcpu)) model.eval() gradients [] activations [] def forward_hook(module, input, output): activations.append(output) def backward_hook(module, grad_input, grad_output): gradients.append(grad_output[0]) target_layer model.layer4[-1] target_layer.register_forward_hook(forward_hook) target_layer.register_full_backward_hook(backward_hook) img load_test_image(data/test_images/045.jpg) # 预处理后的 1x3x224x224 out model(img) pred out.argmax(dim1).item() model.zero_grad() one_hot torch.zeros_like(out) one_hot[0, pred] 1 out.backward(gradientone_hot) with torch.no_grad(): weight gradients[0].mean(dim(2, 3), keepdimTrue) cam (activations[0] * weight).sum(dim1, keepdimTrue) cam torch.relu(cam) cam torch.nn.functional.interpolate( cam, size(224, 224), modebilinear, align_cornersFalse ) heatmap cam[0, 0].numpy()register_full_backward_hook 会拿到 layer4 最后一层输出的梯度张量经过全局平均池化变成了每个通道的权重激活值同样来自 layer4 输出是 HxW 的特征图。两者按通道加权再取 ReLU就把仅对预测类别起作用的区域高亮了出来。把这个热力图叠加到原图上可以直观看到模型决策的依据。6.2 怎么读热力图三条判断经验第一正常舌象的热力图应该集中在舌体中部或两侧如果高亮区只出现在舌尖或舌根边缘说明模型可能过度关注图像的边界纹理。第二白苔和黄苔之间的分类差异如果主要靠颜色实现热力图应该呈现弥散分布而不是聚集成一个锋利的斑块——有锋利斑块往往意味着模型关注了某个不自然的局部特征。第三把测试集里所有图片的热力图汇总后检查是否有同一位置反复出现高亮如果高亮区全部集中在图片左上角基本可以断定是数据采集时最常出现在左上角的水印或暗角。这套验证方法同样可用于检测模型——把 YOLO 的特征层接上热力图工具看检测框内的激活区域是否铺满整个框。做过这一步之后写论文时讲“模型可解释性”才有底气答辩时遇到“为什么模型判断这张是黄苔”也不至于只搬准确率数据。希望这些踏过坑的经验能帮你在舌苔检测这个项目上少走弯路少花冤枉时间。本文还有配套的精品资源点击获取