简介:本资源是一套面向计算机专业本科生的高分毕业设计实战项目,聚焦中医舌诊数字化落地,提供完整的基于深度学习的舌苔识别检测与鉴定系统实现方案。适用于毕设选题、课程设计及AI视觉项目练手,尤其适合零基础但具备Python基础的学习者快速上手。压缩包共109个文件,含26个核心Python源码(含数据预处理、模型训练、GUI交互逻辑)、6个预训练.pth模型文件、7张典型舌苔样本图及2个.ui界面文件,辅以论文文档(.docx)和结构化配置(.json),整体105.44MB,代码经导师验收并获99分高分评价,确保环境配置后一键运行。目前已有132人下载学习,配套GUI界面开箱即用,含图像上传、实时检测、结果可视化与置信度反馈功能,同时包含TensorBoard训练日志(events.out.tfevents系列文件),便于复现训练过程与调参分析。
1. 舌苔识别不是“拍张照就出结果”:一个高分毕业设计背后的真实技术链路
“基于深度学习的舌苔识别检测鉴定系统”——这个标题在计算机/医学信息工程类毕业设计选题池里,常年稳居TOP10。但现实是:90%的学生点开GitHub仓库后,第一眼看到train.py就卡住;剩下10%跑通了训练,却在GUI界面加载模型时弹出CUDA out of memory;真正能拿去答辩、让导师点头说“有临床逻辑”的,不到3%。它不是简单的图像分类任务,而是舌体分割 + 舌苔区域定位 + 苔色/苔质多维特征建模 + 中医证候映射四层嵌套问题。你得懂ResNet怎么改造成双分支结构来同时学“苔厚薄”和“苔颜色”,得知道OpenCV二值化前为什么必须先做HSV空间的V通道自适应直方图均衡,还得让Tkinter界面在无GPU笔记本上也能用CPU推理出结果。本文不讲论文怎么写、PPT怎么美化,只拆解从数据采集到GUI交付的可复现、可答辩、可现场演示的完整技术路径——所有代码、参数、避坑点,都来自我带过的7届毕设项目实测(含3个已落地社区中医馆辅助筛查系统)。
2. 数据准备:舌象图不是随便拍的,3类标注缺一不可
舌象数据质量直接决定模型上限。我见过太多学生用手机在白炽灯下拍舌头发黄的照片,结果模型把“灯光色偏”学成了“黄苔主热证”。真实临床舌象采集需满足三要素:标准光源(D65色温)、固定距离(15cm)、无反光(哑光背景板)。但毕业设计没条件搭实验室?别急,我们用数据增强+标注规范来补救。
2.1 获取与清洗:从公开数据集到自采样本的混合策略
首选开源数据集:
- TCM-Tongue-12K(2023年新发布):12,486张舌象,含舌体mask、苔色标签(白/黄/灰/黑)、苔质标签(薄/厚/腻/腐),但原始图分辨率不一(480p~1080p),需统一resize。
- TongueDB(经典老库):仅3,200张,但每张都有中医师手写诊断结论(如“脾虚湿盛”),适合后期证候映射。
提示:不要直接下载压缩包!TCM-Tongue-12K官网提供
download_script.py,但默认下载的是未去重版本。实测发现其中1,200张为同一人不同角度重复拍摄,需用imagehash去重:
# deduplicate.py import imagehash from PIL import Image import os import glob def get_hash(img_path): return imagehash.average_hash(Image.open(img_path)) hashes = {} for img in glob.glob("raw/*.jpg"): h = str(get_hash(img)) if h in hashes: os.remove(img) # 删除重复图 print(f"Removed duplicate: {img}") else: hashes[h] = img逻辑说明:average_hash对光照变化鲁棒性优于phash,且计算快。参数说明:glob.glob("raw/*.jpg")指定原始图路径;os.remove()直接删文件避免手动清理。
2.2 标注规范:为什么单靠“白/黄/灰/黑”四分类会翻车?
临床舌苔判定是连续谱:比如“淡黄苔”和“深黄苔”对应不同热证程度,“薄白苔”和“薄黄苔”病机完全不同。若只标离散类别,模型会把“淡黄”误判为“白”或“黄”。我们采用三元组标注法:
| 图像ID | 舌体mask路径 | 苔色连续值(0~1) | 苔质连续值(0~1) |
|---|---|---|---|
| IMG_001 | masks/001.png | 0.32 | 0.68 |
- 苔色连续值:0=纯白,1=纯黑,中间值按Lab色彩空间L*通道归一化(非RGB!)
- 苔质连续值:0=无苔(镜面舌),1=腐苔(颗粒感最强),通过OpenCV计算图像梯度幅值均值再归一化
# label_tongue_texture.py import cv2 import numpy as np def calc_texture_score(img_path): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) # 高斯模糊降噪,避免毛刺干扰 blurred = cv2.GaussianBlur(img, (3,3), 0) # 计算梯度幅值(Sobel算子) grad_x = cv2.Sobel(blurred, cv2.CV_64F, 1, 0, ksize=3) grad_y = cv2.Sobel(blurred, cv2.CV_64F, 0, 1, ksize=3) grad_mag = np.sqrt(grad_x**2 + grad_y**2) # 取苔区(mask内)的梯度均值,归一化到0~1 mask = cv2.imread("masks/"+os.path.basename(img_path).replace(".jpg",".png"), 0) texture_score = np.mean(grad_mag[mask > 0]) / 255.0 return min(max(texture_score, 0), 1) # 截断到[0,1] # 示例调用 score = calc_texture_score("raw/IMG_001.jpg") print(f"Texture score: {score:.3f}") # 输出:0.678参数说明:cv2.GaussianBlur的(3,3)核大小适合舌苔纹理尺度;ksize=3保证梯度计算精度;np.mean(grad_mag[mask > 0])只统计苔区像素,排除舌体干扰。
3. 模型设计:不是套ResNet就行,双任务头+渐进式训练才是关键
舌苔识别本质是回归+分割联合任务:既要输出苔色/苔质数值(回归),又要精准框出苔区(分割)。若强行用单分类模型,会丢失中医辨证所需的连续性信息。我们采用共享主干+双任务头架构,主干用EfficientNet-B3(参数量适中,适合毕设部署),两个头分别接回归层和U-Net轻量版分割头。
3.1 网络结构:为什么EfficientNet-B3比ResNet50更适合舌象?
对比测试结果(在TCM-Tongue-12K子集上):
| 模型 | 苔色回归MAE | 苔质回归MAE | 分割Dice | 推理速度(RTX3060) | 参数量 |
|---|---|---|---|---|---|
| ResNet50 | 0.182 | 0.215 | 0.731 | 28ms | 25.6M |
| EfficientNet-B3 | 0.137 | 0.162 | 0.798 | 19ms | 12.2M |
原因:EfficientNet的复合缩放(compound scaling)让B3在保持精度的同时大幅压缩参数,且其MBConv模块对舌苔的局部纹理(如颗粒感、裂纹)提取更敏感。而ResNet的残差块易在浅层丢失高频细节。
# model.py import torch import torch.nn as nn from efficientnet_pytorch import EfficientNet class TongueNet(nn.Module): def __init__(self, num_classes=2): # 2: 苔色+苔质 super().__init__() # 加载预训练EfficientNet-B3 self.backbone = EfficientNet.from_pretrained('efficientnet-b3') # 移除原分类头 self.backbone._fc = nn.Identity() # 回归头:全连接层预测连续值 self.regression_head = nn.Sequential( nn.Dropout(0.3), nn.Linear(1536, 512), # 1536是B3最后层输出维度 nn.ReLU(), nn.Dropout(0.2), nn.Linear(512, num_classes) # 输出2维:[color, texture] ) # 分割头:U-Net风格上采样 self.segmentation_head = nn.Sequential( nn.ConvTranspose2d(1536, 512, 2, stride=2), # 上采样x2 nn.ReLU(), nn.Conv2d(512, 256, 3, padding=1), nn.ReLU(), nn.ConvTranspose2d(256, 128, 2, stride=2), # 上采样x2 nn.ReLU(), nn.Conv2d(128, 1, 1) # 输出单通道mask ) def forward(self, x): # 主干提取特征 features = self.backbone.extract_features(x) # [B, 1536, H/32, W/32] # 回归分支 reg_feat = torch.mean(features, dim=[2,3]) # 全局平均池化 regression_out = self.regression_head(reg_feat) # 分割分支(需保留空间维度) seg_out = self.segmentation_head(features) # [B, 1, H, W] return regression_out, seg_out # 初始化模型 model = TongueNet() print(f"Total params: {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M")逻辑说明:extract_features获取特征图而非分类logits;torch.mean(features, dim=[2,3])实现全局池化,适配回归任务;分割头用ConvTranspose2d上采样,避免插值失真。
3.2 渐进式训练:先冻主干,再解冻微调
舌象数据量小(通常<5k),直接端到端训练易过拟合。我们分三阶段:
- 阶段1(冻结主干):只训练回归头和分割头上采样层,学习率1e-3,20轮
- 阶段2(解冻最后2个MBConv):学习率5e-4,15轮
- 阶段3(全参数微调):学习率1e-4,10轮
# train_staged.py def train_stage(model, dataloader, optimizer, criterion, stage): model.train() for epoch in range(EPOCHS[stage]): for batch in dataloader: imgs, color_labels, texture_labels, seg_masks = batch # 前向传播 reg_pred, seg_pred = model(imgs) # 多任务损失:回归+分割 reg_loss = criterion['reg'](reg_pred, torch.stack([color_labels, texture_labels], dim=1)) seg_loss = criterion['seg'](seg_pred, seg_masks) total_loss = reg_loss + 0.8 * seg_loss # 分割权重略低 optimizer.zero_grad() total_loss.backward() optimizer.step() print(f"Stage {stage} Epoch {epoch}: Loss={total_loss.item():.4f}") # 阶段1:冻结主干 for param in model.backbone.parameters(): param.requires_grad = False optimizer1 = torch.optim.Adam(model.parameters(), lr=1e-3) train_stage(model, train_loader, optimizer1, loss_fn, stage=1)参数说明:criterion['reg']用nn.MSELoss();criterion['seg']用nn.BCEWithLogitsLoss()(因seg_pred是logits);0.8 * seg_loss权重经验证最优,过高会导致回归精度下降。
4. GUI开发:Tkinter不是简陋,加这3个技巧就能专业
很多毕设GUI被吐槽“像2005年网页”,根源在于没处理好实时反馈、模型加载状态、结果可视化。Tkinter完全能做出专业界面,关键在三个技巧:异步加载模型、动态进度条、舌象叠加渲染。
4.1 异步加载:避免GUI卡死在torch.load()
torch.load()在CPU上加载12MB模型需1.2秒,Tkinter主线程会冻结。用threading.Thread解耦:
# gui.py import tkinter as tk from tkinter import ttk, filedialog, messagebox import threading import torch class TongueApp: def __init__(self, root): self.root = root self.model = None self.is_loading = False # 加载按钮 self.load_btn = ttk.Button(root, text="加载模型", command=self.load_model_async) self.load_btn.pack(pady=10) # 进度条(隐藏初始) self.progress = ttk.Progressbar(root, mode='indeterminate', length=200) self.status_label = ttk.Label(root, text="就绪") def load_model_async(self): if self.is_loading: return self.is_loading = True self.load_btn.config(state='disabled') self.progress.pack(pady=5) self.progress.start() self.status_label.config(text="正在加载模型...") self.status_label.pack(pady=5) # 启动后台线程 thread = threading.Thread(target=self._load_model_worker) thread.daemon = True thread.start() def _load_model_worker(self): try: # 在后台线程加载模型 self.model = torch.load("models/best_model.pth", map_location='cpu') # 通过after回调主线程更新UI self.root.after(0, self._on_model_loaded) except Exception as e: self.root.after(0, self._on_load_error, str(e)) def _on_model_loaded(self): self.is_loading = False self.load_btn.config(state='normal') self.progress.stop() self.progress.pack_forget() self.status_label.config(text="模型加载成功!") def _on_load_error(self, error_msg): self.is_loading = False self.load_btn.config(state='normal') self.progress.stop() self.progress.pack_forget() messagebox.showerror("加载失败", f"错误:{error_msg}")逻辑说明:threading.Thread避免阻塞;root.after(0, ...)是Tkinter线程安全的UI更新方式;map_location='cpu'确保无GPU环境也能加载。
4.2 舌象结果可视化:用OpenCV叠加苔区mask,比纯文字报告直观10倍
检测结果不能只显示“苔色:0.35,苔质:0.68”,要让导师一眼看出模型是否真的定位对了苔区:
# visualization.py import cv2 import numpy as np def overlay_tongue_mask(original_img, mask_pred, alpha=0.4): """ original_img: PIL.Image or np.ndarray (H,W,3) mask_pred: torch.Tensor [1,H,W] 或 np.ndarray [H,W] """ if isinstance(original_img, Image.Image): original_img = np.array(original_img) if isinstance(mask_pred, torch.Tensor): mask_pred = mask_pred.squeeze().cpu().numpy() # 将mask转为彩色(绿色半透明) mask_colored = np.zeros_like(original_img) mask_colored[..., 1] = (mask_pred * 255).astype(np.uint8) # G通道 # 叠加 overlay = cv2.addWeighted(original_img, 1-alpha, mask_colored, alpha, 0) # 在叠加图上画回归结果文字 cv2.putText(overlay, f"苔色: {color_val:.2f}", (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0,255,0), 2) cv2.putText(overlay, f"苔质: {texture_val:.2f}", (20, 80), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0,255,0), 2) return overlay # 在GUI中调用 def predict_and_show(self): if not self.model: messagebox.showwarning("警告", "请先加载模型!") return # 获取用户图片 img_pil = Image.open(self.current_img_path) # 预处理(同训练时) img_tensor = self.transform(img_pil).unsqueeze(0) # [1,3,H,W] # 推理 with torch.no_grad(): reg_out, seg_out = self.model(img_tensor) color_val, texture_val = reg_out.squeeze().tolist() mask_pred = torch.sigmoid(seg_out).squeeze() # 转为0~1概率图 # 可视化 overlay_img = overlay_tongue_mask(img_pil, mask_pred, color_val, texture_val) # 转回PIL显示 result_pil = Image.fromarray(overlay_img) # 更新GUI中的图片Label...参数说明:alpha=0.4控制透明度,过高则掩盖原图,过低则mask不明显;cv2.FONT_HERSHEY_SIMPLEX字体清晰;cv2.putText坐标(20,40)避开舌体核心区。
5. 避坑指南:答辩前必查的5个致命问题
毕业设计最怕答辩现场翻车。以下是我带学生踩过的坑,按出现频率排序,每条都附真实现象、根因和急救方案:
5.1 现象:GUI点击“检测”后无响应,任务管理器显示Python进程CPU 100%持续1分钟
原因:OpenCV的cv2.cvtColor()在读取某些JPEG时触发内部解码死循环(尤其CMYK色彩空间图片)
解决:强制转换为RGB再处理
# 错误写法 img_cv = cv2.imread(path) # 正确写法 img_pil = Image.open(path).convert('RGB') # 强制转RGB img_cv = cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)5.2 现象:训练Loss降到0.01后突然爆炸(跳到10+),验证集Dice暴跌
原因:数据增强中RandomRotation角度过大(>15°),导致舌体部分移出图像边界,mask变成全零,BCEWithLogitsLoss计算log(0)产生NaN
解决:限制旋转范围,并添加mask有效性检查
# 在DataLoader的__getitem__中 if mask.sum() == 0: # mask全黑,跳过此样本 return self.__getitem__(np.random.randint(0, len(self)))5.3 现象:导出的ONNX模型在GUI中报错RuntimeError: Input type (torch.cuda.FloatTensor) and weight type (torch.FloatTensor) should be the same
原因:导出时用model.cuda(),但GUI运行在CPU环境
解决:导出前确保模型在CPU上
model.eval() model.cpu() # 关键! dummy_input = torch.randn(1,3,224,224) torch.onnx.export(model, dummy_input, "model.onnx", opset_version=11, input_names=['input'], output_names=['regression', 'segmentation'])5.4 现象:答辩时用导师手机拍照上传,模型输出“苔色:0.99”,但照片明显是白苔
原因:手机自动HDR合成导致舌面过曝,RGB值饱和,HSV空间V通道失真
解决:预处理增加曝光校正
def correct_exposure(img): # CLAHE(限制对比度自适应直方图均衡) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)) lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB) lab[..., 0] = clahe.apply(lab[..., 0]) return cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)5.5 现象:论文里写“准确率98.2%”,但答辩时现场拍图准确率仅65%
原因:“准确率”指标错误——用分类准确率评估回归任务(将苔色0.32四舍五入到0/1/2/3类)
解决:论文中必须用回归指标(MAE/RMSE)和分割指标(Dice/IoU),分类准确率仅作对比基线
注意:答辩PPT第一页就写清评估指标定义,避免被问“您说的准确率是怎么算的?”
6. 答辩现场演示技巧:3分钟让导师记住你的工作
答辩不是讲技术细节,而是证明你理解问题本质、解决了真痛点、结果可验证。我让学生统一用这套演示流,通过率100%:
6.1 演示脚本:严格控制在3分钟内
| 时间 | 动作 | 话术要点 |
|---|---|---|
| 0:00-0:20 | 打开GUI,展示空白界面,强调“无GPU也可运行” | “老师好,本系统基于轻量级EfficientNet-B3,在i5-8250U笔记本上推理仅19ms” |
| 0:21-0:50 | 上传一张典型黄厚苔临床图(提前存好),点击检测,等待2秒 | “这是真实患者舌象,注意看绿色高亮区域——模型精准覆盖了舌中后部苔区,未误检舌边” |
| 0:51-1:30 | 展示结果:左侧原图+mask叠加,右侧弹出数值框(苔色0.72,苔质0.85) | “苔色0.72接近临床‘深黄苔’标准,苔质0.85对应‘厚腻苔’,符合‘湿热蕴脾’证候” |
| 1:31-2:10 | 切换到易混淆案例:淡黄苔 vs 白苔(用同一人不同时间照片) | “这张淡黄苔(0.41)与白苔(0.12)数值差异显著,证明模型能分辨临床关键阈值” |
| 2:11-3:00 | 打开论文PDF,翻到“表3:与ResNet50对比”,指向Dice和MAE两列红框数据 | “相比传统ResNet,我们的Dice提升6.7%,苔色MAE降低24.7%,这是结构改进的直接证据” |
6.2 数据可视化:答辩PPT里必须有的2张图
图1:舌苔连续值分布热力图
用Seaborn绘制color_valuevstexture_value散点图,按中医证候着色(如脾虚=蓝色,湿热=红色)。导师一眼看出模型是否学到临床规律——如果所有点挤在左下角(白薄苔),说明数据偏差大;如果呈对角线分布,证明模型捕捉到“苔厚常伴色深”的病理关联。
图2:Grad-CAM舌区热力图对比
并排显示:原图、模型预测mask、Grad-CAM热力图。重点圈出热力图与mask重合度高的区域(如舌中苔厚处),证明模型关注的是真实苔区而非背景噪声。这是打消“黑匣子”质疑的终极武器。
6.3 最后一句收尾话术(务必背下来)
“老师,这个系统不是为了替代中医师,而是帮年轻医师快速建立舌诊手感——就像教钢琴时用节拍器,它提供客观参照,最终判断永远在人脑。”
这句话把技术定位拉回临床价值,暗示你懂中医思维,不是纯码农。我带的学生用这句收尾,9次答辩里8次被追问“你们下一步怎么和医院合作”,说明导师认可了项目的落地潜质。
希望帮到你。
本文还有配套的精品资源,点击获取