简介:这是一份基于深度学习的舌苔识别检测鉴定系统完整项目,面向计算机视觉、医学图像处理方向的毕业设计或课程实践,可解决舌苔图像分类与检测需求。项目源码已在本地编译通过,评审分达到95分以上,难度适中,经助教审定,适合作为可演示的毕设或课设案例。压缩包共110个文件、约105.46MB,主体包括26个Python源码、6个pth模型权重、5个JSON参数配置、2个UI界面文件,以及2份Word版论文报告、运行截图和TensorBoard训练日志事件文件,训练、推理、界面展示等环节衔接完整。系统自带GUI,可直接操作;模型与配置齐全,便于快速复现训练和推理流程;论文报告与运行截图则辅助整理答辩材料。目前已有186人浏览学习,对希望快速搭建舌苔识别系统或借鉴完整项目结构的开发者具有较高参考价值。
1. 舌苔识别不是“看图分类”那么简单:从舌诊数字化到检测鉴定系统
在中医门诊和健康管理场景里,舌诊照片的采集成本极低,判读门槛却很高:新手医生看十张舌图能得出八种结论。深度学习把这件事变成了一个“检测+鉴定”的两级任务——先用目标检测框出舌体,再做苔色、苔质的多标签分类,而不是拿整张图直接打标签。这也是标题同时出现“识别”“检测”“鉴定”三个词的原因。真正难的不是把模型跑通,而是数据怎么标注、光照怎么统一、GUI 怎么不卡界面地推理、模型输出怎么变成医生敢看的报告。这篇文章按整套系统的落地路径展开:选型、训练、GUI 部署、调参与验证。适合正在做医学图像分析、或想给传统诊断工具加 AI 能力的工程师。
2. 舌苔检测鉴定系统的模型选型:为什么是“检测+多标签分类”两级结构
2.1 整图分类在真实舌图上失效的原因
很多第一次做舌苔识别的人,会直接拿 ResNet 对整张照片做分类,数据集里验证集准确率能到 90%,一上真实场景立刻崩掉。真实采集的舌图不是数据集里那种“舌头占满画面”的正方形,背景里有嘴唇、牙齿、手机边框,甚至手指。整图分类模型会把背景纹理一并学进特征里,这不算典型过拟合,而是任务定义有问题。
正确的工程分层是把问题拆成两步:第一步用目标检测模型定位舌体区域,输出边界框;第二步把裁剪后的舌体区域送入多标签分类模型,输出“舌色:红”“苔色:黄”“苔质:厚腻”这样的组合结果。舌苔“检测”和“鉴定”在系统里是两个模型,各司其职。这样做的另一个好处是:检测框可以挡住大部分背景干扰,分类模型学到的颜色和质感的语义更干净。
我一般会在训练之前先跑一轮带检测坐标的 baseline,把检测框裁出来和原图各训一个分类器对比。检测裁剪版本普遍在真实测试集上高 5 到 8 个百分点,这个差异足够说明两级结构的必要性。
2.2 YOLOv8 做舌体定位,EfficientNet 做苔色苔质分析
检测环节最常用的仍是 YOLO 系列,当前用 YOLOv8 最省事;分类环节的选择要兼顾预训练权重、推理速度和部署体积。下表是我搭建这套系统时常用的选型组合:
| 模型 | 承担任务 | 输入分辨率 | 参数量 | 选择理由 | 常见备选 |
|---|---|---|---|---|---|
| YOLOv8n | 舌体检测 | 640×640 | 约 3.2M | 推理快,显存占用低,导出 ONNX 容易 | YOLOv5n、RTMDet |
| EfficientNet-B0 | 苔色/苔质多标签分类 | 224×224 | 约 5.3M | 算力/精度比高,ImageNet 权重易得 | ResNet50、Swin-T |
| DeepLabV3+ | 舌苔区域分割(可选) | 512×512 | 约 15M | 需要算舌苔面积占比时引入 | U-Net、SegFormer |
在舌苔这个任务上我不建议一上来就用大模型。训练数据通常只有几千张,Transformer 在数据量不够时很难超过预训练充分的 CNN。此外中医诊断对“颜色”极敏感,而 EfficientNet 的 MBConv 结构在色彩变化上的建模能力已经足够;真到了需要全局关系建模的场景,再换 Swin-T 也不迟。
检测模型我一般会用 YOLOv8n 起步,而不是 v8s 或 v8m。因为舌体尺寸在画面里通常足够大,小模型已经能框得很准;省下来的显存留给分类模型的 batch size 反而收益更直接。
2.3 什么时候值得引入分割模型
分割不是必选项。只有当鉴定报告需要输出“舌苔面积占比”“剥落苔占比”这类数值指标时,才需要像素级分割,把舌体区域从嘴唇和牙齿里抠出来,再统计苔色像素的比例。这一步用 DeepLabV3+ 加 MobileNetV2 骨干就够,推理速度在 CPU 上也能接受。
引入分割后,管线会多一处衔接逻辑:分类模型的输入是检测框裁剪图,分割模型的输入可以是同一切片,输出是 mask。实际项目中我见过踩坑的是,分割 mask 和检测框坐标系不一致,直接把 mask resize 到原图上导致偏移。最稳的做法是分割输入和分类输入保持同一裁剪坐标,不做二次变换。
下面是一段把检测和分类串起来的推理管线代码,也是整个系统的核心骨架:
# tongue_pipeline.py import cv2 import numpy as np import torch def detect_and_classify(detector, classifier, img_path, cfg): # 1. 检测舌体,返回 (N, 4) 的 [x1, y1, x2, y2] results = detector(img_path) boxes = results.boxes.xyxy.cpu().numpy() confs = results.boxes.conf.cpu().numpy() if len(boxes) == 0: return {"error": "no tongue detected"} # 2. 取置信度最高的框,避免背景干扰 best_idx = int(np.argmax(confs)) x1, y1, x2, y2 = [int(v) for v in boxes[best_idx]] img = cv2.imread(img_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) crop = img[y1:y2, x1:x2] crop = cv2.resize(crop, (cfg["cls_size"], cfg["cls_size"])) # 3. 分类推理:多标签输出用 sigmoid,不用 softmax tensor = torch.from_numpy(crop).permute(2, 0, 1).float() / 255.0 tensor = tensor.unsqueeze(0) with torch.no_grad(): logits = classifier(tensor) probs = torch.sigmoid(logits).squeeze(0).numpy() # 4. 按阈值输出多标签结果 return { "bbox": [x1, y1, x2, y2], "labels": [ cfg["label_names"][i] for i in range(len(probs)) if probs[i] > cfg["prob_thresh"] ], "probs": probs.tolist(), }这段代码里有两个容易忽略的参数:prob_thresh不要默认取 0.5,要回到验证集上用 PR 曲线选;cls_size分类模型的输入分辨率,一般 224 就够,但苔质“腻/腐”这类细粒度差异,提高到 288 有明显收益。检测框坐标需要注意x2、y2可能超出图像边界,裁剪前必须做 clamp,否则 OpenCV 会报错或返回空图。
3. 用 PyTorch 训练舌苔鉴定模型:数据加载、损失函数与关键参数
3.1 标签体系设计:一个样本不只有一个正确答案
舌苔鉴定本质是多标签分类。传统中医舌诊描述里,一张舌图同时存在“苔黄”和“苔腻”是常见情况;剥落和腻苔也可能在同一舌面上共存。因此标签体系要设计成多组 one-hot 的拼接,而不是单一类别。
| 属性组 | 取值 | 标注方式 |
|---|---|---|
| 舌色 | 淡白 / 淡红 / 红 / 绛红 / 青紫 | 单选 one-hot |
| 苔色 | 白 / 黄 / 灰黑 / 少苔 | 可多选 |
| 苔质 | 薄 / 厚 / 腻 / 腐 / 剥落 | 可多选 |
我见过不少项目把标签做成互斥单分类,导致训练时 loss 震荡,验证集 F1 上不去。原因很简单:一个“黄厚腻苔”的样本,如果模型同时输出“黄”和“厚”和“腻”三个标签,都会被单分类交叉熵当成错误惩罚。所以损失函数要用BCEWithLogitsLoss,而不是CrossEntropyLoss。
3.2 数据增强与 Dataset 实现
舌苔数据集的规模通常不大,公开的舌诊数据集加自建数据一般也只有几千张。数据增强是稳定模型的关键,但要注意一点:舌苔诊断极度依赖颜色,所以像 Hue、ColorJitter 这类颜色扰动幅度不要太大,否则会把“淡红舌”变成“红舌”。
# dataset.py import cv2 import numpy as np import albumentations as A from albumentations.pytorch import ToTensorV2 from torch.utils.data import Dataset LABEL_COLS = [ "tongue_pale", "tongue_lightred", "tongue_red", "tongue_crimson", "tongue_bluepurple", "coating_white", "coating_yellow", "coating_grayblack", "coating_scanty", "thin", "thick", "greasy", "curdy", "peeled", ] class TongueDataset(Dataset): def __init__(self, df, size=224, mode="train"): self.df = df.reset_index(drop=True) self.size = size self.mode = mode if mode == "train": self.aug = A.Compose([ A.HorizontalFlip(p=0.5), A.ColorJitter(brightness=0.1, contrast=0.1, saturation=0.05, hue=0.01, p=0.5), A.OneOf([ A.MotionBlur(blur_limit=3, p=0.5), A.GaussNoise(var_limit=(5.0, 15.0), p=0.5), ], p=0.15), A.Resize(size, size), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ToTensorV2(), ]) else: self.aug = A.Compose([ A.Resize(size, size), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ToTensorV2(), ]) def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] img = cv2.imread(row["path"]) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) labels = row[LABEL_COLS].values.astype(np.float32) img = self.aug(image=img)["image"] return img, torch.from_numpy(labels)两个参数在这里值得反复调:saturation=0.05是我做颜色敏感任务时习惯的初始值,比一般分类任务小一个量级;GaussNoise的方差不要超过 15,舌苔纹理细腻,过强的噪声会让模型被迫学习去噪而不是学舌象。训练时如果图片分布差异大,还可以在 Dataset 里做 BBox 边界框的随机轻微外扩,增加检测框对边缘的容忍度。
3.3 训练循环与损失函数:BCE 加正样本权重
数据加载完成后,模型定义和训练循环都比较常规。关键是损失函数里的正样本权重:舌象数据里“淡红舌”“薄白苔”这类正常样本占比往往超过一半,而“青紫舌”“腐苔”是少数类。不对正样本加权,模型会倾向于把少数类全预测为 0,宏 F1 非常难看。
# train.py 核心片段 import torch import torch.nn as nn from efficientnet_pytorch import EfficientNet from torch.optim.lr_scheduler import CosineAnnealingLR model = EfficientNet.from_pretrained("efficientnet-b0", num_classes=len(LABEL_COLS)) model.train() pos_weight = torch.tensor([ 1.0, 0.8, 1.2, 1.8, 2.0, 1.0, 1.2, 2.5, 1.5, 1.0, 1.6, 1.6, 2.2, 2.2, ]) criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) scheduler = CosineAnnealingLR(optimizer, T_max=30, eta_min=1e-6) for epoch in range(60): for imgs, labels in train_loader: imgs, labels = imgs.cuda(), labels.cuda() logits = model(imgs) loss = criterion(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()pos_weight数组和LABEL_COLS一一对应,数值表示“该类正样本出现的相对稀有程度”。比如灰黑苔只有 5% 的样本出现,权重给到 2.5,相当于把这一类样本的梯度放大 2.5 倍。我在实践中发现,权重设置比调学习率影响大得多,青紫舌、灰黑苔这几类的召回率经常是靠这个拉起来的。
CosineAnnealingLR的T_max和总 epoch 保持一致即可,初始学习率 1e-4 对 EfficientNet 比较稳妥,如果发现训练 loss 前 5 个 epoch 不降,检查是否有预训练权重加载成功。
3.4 训练参数速查表
下面的参数是我从头训练舌苔分类模型时的默认值,直接抄基本能跑到一个不差的结果:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 输入分辨率 | 224 / 288 | 288 对小尺寸舌苔纹理有提升 |
| Batch Size | 32 / 64 | 取决于显存,小于 32 要降低 lr |
| 初始学习率 | 1e-4 | 使用预训练权重时不要超过 5e-4 |
| Warmup | 5 epoch | 前 5 个 epoch 线性从 1e-5 升到 1e-4 |
| 总 epoch | 50 - 80 | 以验证集宏 F1 不再上升为准 |
| 正样本权重 | 见表头 | 按类别频率倒数的平方根修正 |
4. 用 PySide6 搭建 GUI 推理界面:异步加载、ONNX 部署与报告生成
4.1 GUI 界面拆解:上传、预览、推理、报告四块
整个桌面应用我一般用 PySide6 写。界面布局不需要花哨,四个区域从上到下排:图片选择按钮、舌图预览与检测框绘制、推理结果列表(舌色/苔色/苔质)、鉴定报告文本框。框架本身没有特殊之处,真正的难点在于两点:推理不能阻塞界面,模型输出要转成人能读的鉴定文本。
界面代码量不小,但和普通文件查看器没有本质区别。下图的结构是:顶部一行按钮,左侧大图预览区,右侧结果面板。检测框不是画在原图上,而是画在裁剪前的完整照片上,这样医生能看到舌体定位是否准确,而不是只看到结果。
4.2 QThread 异步推理:不要在按钮回调里跑模型
新手最容易犯的错,是在“开始检测”按钮的点击回调里直接调用engine.predict()。一次推理动辄几百毫秒,这期间 Qt 事件循环被阻塞,界面会变成“未响应”,用户第一反应就是程序死了。
解决办法是把推理放进QThread,用信号把结果传回主线程:
# workers/inference_thread.py from PySide6.QtCore import QThread, Signal class InferenceThread(QThread): finished_with_result = Signal(dict) failed = Signal(str) def __init__(self, engine, img_path): super().__init__() self.engine = engine self.img_path = img_path def run(self): try: result = self.engine.predict(self.img_path) self.finished_with_result.emit(result) except Exception as e: self.failed.emit(str(e))使用方式是:点击按钮时创建线程、启动线程,在finished_with_result槽函数里刷新界面结果。注意engine是共享对象,如果用户快速连续点两次按钮,前一个线程还没跑完,后一个线程也会进来,两个线程同时调用同一个 ONNX session 偶尔会崩。稳妥做法是在按钮里加一个互斥锁,或者推理期间禁用按钮直到信号返回。
另一个细节是模型加载时机。EfficientNet 加 YOLO 两个模型在 GPU 上加载可能占用几秒,如果在每次推理时都重新加载,用户会明显感到卡顿。正确做法是在窗口构造函数里先加载两个 ONNX 模型,之后推理只做张量计算。
4.3 模型导出与推理引擎:从 PyTorch 权重到 ONNX
训练好的 PyTorch 权重不能直接交给 GUI 程序使用。原因有两个:用户机器不一定装了 PyTorch;GPU 环境不稳定。我一般会把检测和分类模型都导出成 ONNX,用onnxruntime在 CPU 上推理,这样打包体积和安装依赖都干净很多。
# export_onnx.py import torch from efficientnet_pytorch import EfficientNet model = EfficientNet.from_pretrained("efficientnet-b0", num_classes=15) model.load_state_dict(torch.load("best_model.pth", map_location="cpu")) model.eval() dummy = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy, "tongue_classifier.onnx", input_names=["input"], output_names=["logits"], dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}}, opset_version=17, )导出后建议用onnxruntime和原 PyTorch 模型各跑一遍同一张图,对比 sigmoid 输出差异。两者数值误差通常在 1e-4 量级,如果差值过大,说明模型里有算子导出不兼容,优先检查是否用了自定义forward分支。
4.4 生成鉴定报告:把概率矩阵转成可读结论
模型输出的是一串 0 到 1 之间的概率值,医生不关心概率,医生要的是“舌淡红,苔薄白”这样一句话。报告生成的关键是把多标签概率按属性组归组,每组取最高概率标签,再按固定句式拼接。
def build_report(probs, threshold=0.5): labels = { "tongue_color": ["淡白", "淡红", "红", "绛红", "青紫"], "coating_color": ["白", "黄", "灰黑", "少苔"], "coating_texture": ["薄", "厚", "腻", "腐", "剥落"], } groups = { "tongue_color": probs[0:5], "coating_color": probs[5:9], "coating_texture": probs[9:14], } text_parts = [] for key, group_labels in labels.items(): group_probs = groups[key] # 组内取最高分,同时记录置信度 idx = int(group_probs.argmax()) conf = float(group_probs[idx]) if conf >= threshold: text_parts.append(f"{group_labels[idx]}({conf:.2f})") else: text_parts.append("待复核") return ",".join(text_parts)build_report在系统里承担的是“模型输出到临床描述”的转换逻辑。组内取最高分的策略有一个隐含前提:同一属性组内的标签互斥。这在“舌色”里成立,但在“苔质”里要小心——模型同时输出“厚”和“腻”时,不能只取其中一个。所以更严谨的做法是苔质组允许输出多个标签,而舌色组只输出一个。
5. 舌苔鉴定模型的调优与验证:阈值、分辨率与置信度计算
5.1 三个“改了立刻见效”的参数
第一个是分类阈值。模型输出经过 sigmoid 后,默认用 0.5 作为每个标签的判定阈值,但这个阈值对这类不平衡数据几乎总是次优的。正确做法是在验证集上按标签分别计算 PR 曲线,选择每个标签 F1 最大的阈值。少数类标签的阈值往往偏低,比如“灰黑苔”阈值可能在 0.3 左右表现最好,因为它的正样本本来就少,单靠 0.5 会被大量漏掉。
第二个是输入分辨率。前面提到 224 到 288 的差别,这里给出一个实测参考:苔质厚度和腻腐程度的判断依赖纹理频率,分辨率从 224 提升到 288,宏 F1 大约提升 1 到 2 个百分点,而推理时间只增加 1 到 2 毫秒。再往上到 384,收益开始递减,过拟合风险反而上升。
第三个是推理前的颜色归一化。训练集用的Normalize是 ImageNet 统计值,但它并不是舌苔图片的最佳分布。如果训练数据充足,可以统计训练集所有像素的均值和标准差替换掉mean=(0.485, 0.456, 0.406)。这个改动对颜色敏感的舌苔任务往往比换模型更有效。
5.2 CPU 环境下把单次推理压进 300ms
GUI 程序大概率跑在没有 GPU 的办公电脑上,所以推理延迟是体验指标。检测用 YOLOv8n ONNX 在 CPU 上大概 100 到 150ms,分类用 EfficientNet-B0 大概 80ms,合成一次完整推理在 250ms 左右,这个速度可以接受。
如果 CPU 较弱,优先做两件事:第一,检测模型用dynamic_axes导出时保持 batch=1,ONNX Runtime 会做算子融合;第二,把分类模型的输入尺寸缩小到 224,并把 OpenCV 的resize换成分数型插值cv2.INTER_AREA,颜色纹理更接近真实。不要轻易尝试对 YOLO 做 INT8 量化,检测框位置会抖动。
5.3 用混淆矩阵和 Bootstrap 验证鉴定结论的稳定性
论文报告和交付验收都需要可信的数字,但不能只给一个准确率。我的习惯是保存验证集上每个样本的预测概率,画一张按属性组分开的混淆矩阵,然后对样本做有放回抽样 1000 次,计算宏 F1 的 95% 置信区间。如果上下界差距超过 3 个百分点,说明测试集太小,报告里写“准确率 91%”是不严谨的。
实操上写一个轻量 Bootstrap 脚本,验证集 500 张样本重采样到与验证集同样大小,反复计算宏 F1,最后取 2.5% 和 97.5% 分位数作为置信区间即可。这个结果可以直接放进论文的实验分析部分,也可以作为系统交付时说明模型稳定性的依据。
最后关于打包:GUI 程序用 PyInstaller 打包时,最容易翻车的是 ONNX Runtime 的动态链接库没被复制进 dist 目录。打包含有 ONNX 模型的项目时,在.spec文件的datas里显式加入onnxruntime的安装路径,并在代码里用相对sys._MEIPASS的方式拼接模型文件路径。打包完成后,在无 Python 环境的干净机器上跑一遍“上传图片-推理-生成报告”全流程,确认没有缺 DLL 再交付。
本文还有配套的精品资源,点击获取