☰
舌苔检测深度学习实战:从数据标注到模型训练避坑指南
2026/10/1 23:53:41 网站建设 项目流程

简介:深度学习在计算机视觉领域的落地离不开目标检测与图像分类的协同。舌苔识别作为医学图像分析的代表性任务,需要先通过目标检测框定舌体区域,再进行苔色与苔质分类,这一级联结构有效降低了背景干扰。数据标注规范、颜色增强策略和验证集划分直接影响模型泛化能力,而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), encoding='utf-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 参数直接设为 0,brightness 和 contrast 幅度也压到 0.1 以内。不是所有数据增强都能提升泛化能力,对医学图像来说,过度扰动会让模型学到扭曲的颜色分布,看起来训练损失降得很漂亮,换成真实场景立刻露馅。

水平翻转也要谨慎。中医舌诊对舌面左右分区有语义考虑,虽然在纯舌体检测任务里水平翻转影响不大,但如果你后续想分析舌尖、舌中、舌根不同区域,翻转会打乱解剖位置的一致关系。空间变换里相对安全的是小角度旋转、仿射缩放和随机裁剪,颜色变换里相对安全的是亮度扰动和轻微对比度扰动。

下面是一套可直接嵌入分类训练流程的 PyTorch transform:

import torchvision.transforms as T train_transform = T.Compose([ T.Resize((256, 256)), T.RandomRotation(degrees=10), T.RandomCrop(224, pad_if_needed=True), # 注意:hue 不扰动,因为白苔/黄苔依赖色相 T.ColorJitter(brightness=0.1, contrast=0.1, hue=0), 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( data='data/tongue.yaml', epochs=100, imgsz=640, batch=16, lr0=0.01, patience=15, device=0, # 0 表示第一块 GPU,CPU 可设 'cpu' seed=42 )

这段代码里各参数效果差异很大。epochs 设 100 但配合 patience 早停,实际会提前结束;如果发现 loss 还在明显下降,可以提高到 150。imgsz 影响检测精度和速度之间的平衡——640 是多数 YOLO 项目的经验值,舌体不是小物体,没必要刻意上 1280,反而拖慢训练。batch 取决于显存,16 在 8G 显存下接近极限,显存小就降到 8,梯度更新并不会因为 batch 变小而失效。lr0 是初始学习率,数据量越少,学习率越不能大,我见过不少初学者用默认 0.01 训到一半发散的案例。

早停参数 patience=15 表示连续 15 轮验证指标没有改善就停止训练。这个值不要调太小,否则模型可能还在震荡期就被砍掉了。device=0 指定 GPU,如果你的机器只有 CPU,就把 device='cpu',同时把 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(weights=ResNet18_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(), lr=1e-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(f"epoch {epoch+1}: 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("早停触发,停止训练") break

evaluate 函数逻辑比较简单:关掉梯度,遍历验证集,统计预测正确的比例。注意这里用小写字母 lr 的 Adam 学习率,初始值建议 1e-4,因为预训练模型已经收敛到了较好的特征表示,lr 太大会破坏底层权重。早停条件设置为连续 10 轮验证集准确率不再提升就停止,Checkpoint 只保留效果最好的权重,避免最后几轮过拟合把模型带偏。

损失函数用 CrossEntropyLoss 是标准做法,如果发现类别分布极度不均,可以进一步给 loss 传入 weight 参数来放大少数类的梯度信号。这个后面在第 5 章的类别不平衡里会展开讨论。

3.4 mAP 之外还要看混淆矩阵:评估指标怎么配

检测模型的评估通常看 mAP,Ultralytics 训练结束后会在验证集上自动计算并打印。舌体只有一类,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(dim=1).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(cmap="Blues") plt.savefig("confusion_matrix.png", dpi=150)

这份混淆矩阵图可以直接放进论文结果部分。看它的时候重点不是对角线上的数字,而是对角线以外的集中区域。比如黄苔大量被误判为白苔,说明模型在色相边界上辨识力弱,可能需要补充更多黄苔训练样本,或者检查训练集和测试集之间的光源一致性。

4. 开题报告与论文怎么写:把系统的每个模块变成可答辩的论据

4.1 开题报告四板块:选题依据、研究现状、技术路线、预期成果

开题报告老师最在意的是三个问题:你要做什么、为什么做、能不能做完。不要沉迷于堆砌深度学习的算法名词,而要体现出你对任务本身的理解。

选题依据这块,要把中医舌诊的背景和数字化的现实痛点结合起来说。舌诊是中医望诊里信息量最大的部分,但传统上依赖医生目测,主观性强、难以量化和归档。基于深度学习的舌象自动分析可以辅助医生做健康初筛,具备明确的落地场景。这块内容不用写得太虚,突出“客观化、可记录、可回放”的价值即可。

研究现状这一小节最容易写成流水账。常见的大纲是从传统图像特征提取开始——颜色矩、纹理特征、GLCM,然后过渡到 CNN 分类、目标检测,再提到开源模型在医学图像上的迁移潜力。你需要做的不是罗列十篇论文,而是归纳出一条技术演进线:传统方法为什么局限,深度学习方法解决了什么,还有什么问题没解决。写成几百字足够,关键是把文献按脉络组织起来。

技术路线是开题报告的灵魂,也是学生最容易写崩的地方。很多开题报告技术路线画得花里胡哨,却没有说明数据从哪来、标注怎么做、模型怎么训练、怎么验证。我建议用一段流程描述替代复杂流程图:数据采集与脱敏 → 舌体标注 → 目标检测模型训练 → 舌体区域裁剪 → 苔质分类模型训练 → 系统集成与界面 → 对比实验与结果分析。每一步都要对应到可执行的交付物。

预期成果要写具体。不要只写“完成一个识别系统”,要写清楚:输出什么格式的检测框,分类覆盖几个类别,准确率目标是多少,系统以什么形式演示。准确率目标合理即可,比如“验证集准确率达到 85% 以上”,留出浮动空间,答辩时才不会被打穿。

4.2 对比实验和消融实验的设计惯例:从 baseline 到消融表

论文方法部分里,“我的模型比别人的好”靠对比实验支撑,“我的设计里每个模块都起作用”靠消融实验支撑。舌苔检测系统没有特别新颖的算法,所以实验设计要格外扎实。

对比实验的设计思路是以模型为主变量,其他条件完全不变。比如分类阶段,固定同一套数据集、同样的增强策略、同样的早停条件,分别训练 ResNet-18、ResNet-50、VGG-16、MobileNetV2,统计各自验证集的准确率、F1、参数量和推理时间。这张表格是论文最核心的结果表。

模型参数量准确率F1 (macro)单张推理耗时
ResNet-1811.2M86.4%0.8412ms
ResNet-5025.6M87.8%0.8522ms
VGG-16138.4M84.1%0.8031ms
MobileNetV23.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(weight=weights.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 python=3.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(weights=ResNet18_Weights.IMAGENET1K_V1) model.fc = torch.nn.Linear(model.fc.in_features, 4) model.load_state_dict(torch.load("best_tongue_classifier.pt", map_location="cpu")) 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(dim=1).item() model.zero_grad() one_hot = torch.zeros_like(out) one_hot[0, pred] = 1 out.backward(gradient=one_hot) with torch.no_grad(): weight = gradients[0].mean(dim=(2, 3), keepdim=True) cam = (activations[0] * weight).sum(dim=1, keepdim=True) cam = torch.relu(cam) cam = torch.nn.functional.interpolate( cam, size=(224, 224), mode="bilinear", align_corners=False ) heatmap = cam[0, 0].numpy()

register_full_backward_hook 会拿到 layer4 最后一层输出的梯度张量,经过全局平均池化变成了每个通道的权重;激活值同样来自 layer4 输出,是 HxW 的特征图。两者按通道加权再取 ReLU,就把仅对预测类别起作用的区域高亮了出来。把这个热力图叠加到原图上,可以直观看到模型决策的依据。

6.2 怎么读热力图:三条判断经验

第一,正常舌象的热力图应该集中在舌体中部或两侧,如果高亮区只出现在舌尖或舌根边缘,说明模型可能过度关注图像的边界纹理。第二,白苔和黄苔之间的分类差异如果主要靠颜色实现,热力图应该呈现弥散分布,而不是聚集成一个锋利的斑块——有锋利斑块往往意味着模型关注了某个不自然的局部特征。第三,把测试集里所有图片的热力图汇总后,检查是否有同一位置反复出现高亮,如果高亮区全部集中在图片左上角,基本可以断定是数据采集时最常出现在左上角的水印或暗角。

这套验证方法同样可用于检测模型——把 YOLO 的特征层接上热力图工具,看检测框内的激活区域是否铺满整个框。做过这一步之后,写论文时讲“模型可解释性”才有底气,答辩时遇到“为什么模型判断这张是黄苔”也不至于只搬准确率数据。希望这些踏过坑的经验能帮你在舌苔检测这个项目上少走弯路,少花冤枉时间。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询