简介这是一份基于深度学习的舌苔识别检测鉴定系统完整项目面向计算机视觉、医学图像处理方向的毕业设计或课程实践可解决舌苔图像分类与检测需求。项目源码已在本地编译通过评审分达到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、RTMDetEfficientNet-B0苔色/苔质多标签分类224×224约 5.3M算力/精度比高ImageNet 权重易得ResNet50、Swin-TDeepLabV3舌苔区域分割可选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, size224, modetrain): self.df df.reset_index(dropTrue) self.size size self.mode mode if mode train: self.aug A.Compose([ A.HorizontalFlip(p0.5), A.ColorJitter(brightness0.1, contrast0.1, saturation0.05, hue0.01, p0.5), A.OneOf([ A.MotionBlur(blur_limit3, p0.5), A.GaussNoise(var_limit(5.0, 15.0), p0.5), ], p0.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(imageimg)[image] return img, torch.from_numpy(labels)两个参数在这里值得反复调saturation0.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_classeslen(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_weightpos_weight) optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-5) scheduler CosineAnnealingLR(optimizer, T_max30, eta_min1e-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 / 288288 对小尺寸舌苔纹理有提升Batch Size32 / 64取决于显存小于 32 要降低 lr初始学习率1e-4使用预训练权重时不要超过 5e-4Warmup5 epoch前 5 个 epoch 线性从 1e-5 升到 1e-4总 epoch50 - 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 程序使用。原因有两个用户机器不一定装了 PyTorchGPU 环境不稳定。我一般会把检测和分类模型都导出成 ONNX用onnxruntime在 CPU 上推理这样打包体积和安装依赖都干净很多。# export_onnx.py import torch from efficientnet_pytorch import EfficientNet model EfficientNet.from_pretrained(efficientnet-b0, num_classes15) model.load_state_dict(torch.load(best_model.pth, map_locationcpu)) 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_version17, )导出后建议用onnxruntime和原 PyTorch 模型各跑一遍同一张图对比 sigmoid 输出差异。两者数值误差通常在 1e-4 量级如果差值过大说明模型里有算子导出不兼容优先检查是否用了自定义forward分支。4.4 生成鉴定报告把概率矩阵转成可读结论模型输出的是一串 0 到 1 之间的概率值医生不关心概率医生要的是“舌淡红苔薄白”这样一句话。报告生成的关键是把多标签概率按属性组归组每组取最高概率标签再按固定句式拼接。def build_report(probs, threshold0.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 环境下把单次推理压进 300msGUI 程序大概率跑在没有 GPU 的办公电脑上所以推理延迟是体验指标。检测用 YOLOv8n ONNX 在 CPU 上大概 100 到 150ms分类用 EfficientNet-B0 大概 80ms合成一次完整推理在 250ms 左右这个速度可以接受。如果 CPU 较弱优先做两件事第一检测模型用dynamic_axes导出时保持 batch1ONNX 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 再交付。本文还有配套的精品资源点击获取