ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零搭建舌苔检测系统:数据集构建、模型训练到推理封装的完整指南

从零搭建舌苔检测系统:数据集构建、模型训练到推理封装的完整指南

简介:这份基于Python机器学习的舌苔检测系统资源,融合深度学习方法与中医舌诊理论,面向毕业设计、课程设计、工程实训等场景,适合具备一定Python基础、希望将机器学习落地到实际项目的学习者。项目采用EfficientNet网络实现舌苔图像分类,可识别多种舌象特征,并结合中医经验推断体质与健康状况,提供从数据集制作、模型训练到结果分析的完整路径。压缩包内含113个文件,以Python脚本(26个py)、训练权重(6个pth)、配置信息(5个json)和Word文档(4个docx)为主,另含舌苔图片样本、界面UI文件、字体文件与TensorBoard训练日志,便于复现项目流程并二次开发。整体大小约105MB,目录结构清晰有序,覆盖源码、模型与配套说明文档。目前已有311人学习下载,可作为毕业设计或初期项目立项的完整参考资料。

1. 为什么舌苔检测系统值得自己从零搭一套

舌苔检测在图像分类里属于典型的细粒度识别任务:类间差异小,类内差异反而大,同样的白腻苔在不同光照下可能被模型判成两种结果。用Python做机器学习方案,完整链路包括舌体图像采集、数据集清洗、模型训练、推理封装和结果解释,比直接调一个通用分类接口可控得多,尤其面向论文场景时,每一环节都需要可复现的证据链。

这篇文章写给三类人:要做毕业设计的学生,想验证中医舌诊数字化的小团队,以及被「准确率99%」宣传打动、想落地却不知从何下手的开发者。读完你会清楚整个系统的数据从哪来、模型怎么选、参数怎么调、真实环境中第一个翻车点在哪。整套方案不需要GPU集群,单张消费级显卡就能跑完,代码层面的依赖也只有PyTorch、OpenCV和Streamlit这类常见库。

2. 舌苔数据集的构建与清洗:模型上限从这里决定

2.1 公开数据集与自采数据的取舍

舌苔检测的公开数据集非常有限,且标注风格差异极大。有的数据集按「白苔、黄苔、灰苔」分三类,有的按「薄白、白腻、黄腻、剥苔」分四类,还有的混合了舌体分割掩码。我一般会先做一件事:把所有候选数据集的类别定义列成一张对照表,再决定是直接使用还是重新映射标签。

import pandas as pd # 假设你有三个来源的标注文件,格式分别为csv、json、txt csv_df = pd.read_csv("source_a/labels.csv") # 列: image_id, label json_df = pd.read_json("source_b/labels.json") # 键: image_name, category txt_df = pd.read_csv("source_c/labels.txt", sep=" ", header=None, names=["image_name", "category"]) # 统一映射到自定义类别体系 label_map = { "white": "薄白苔", "white_curdy": "白腻苔", "yellow": "黄苔", "yellow_curdy": "黄腻苔", "peeled": "剥苔", "gray": "灰黑苔" } def normalize_label(raw: str) -> str: raw = raw.strip().lower() # 不同来源的命名可能带前缀或缩写,统一走映射 return label_map.get(raw, "其他") csv_df["category"] = csv_df["label"].map(normalize_label) json_df["category"] = json_df["category"].map(normalize_label) txt_df["category"] = txt_df["category"].map(normalize_label)

这段代码的核心逻辑是「把不同来源的标注统一到一个体系」。多数公开数据集的原始标注并不规范,直接合并训练会让模型学到来源特征而非舌苔特征。参数调整上,label_map 是关键,如果你只需要二分类(正常/异常),映射表可以压缩成两个目标类别。需要提醒的是,标注中含「其他」类的样本建议直接剔除,这类样本在细粒度任务中通常只是噪声。

2.2 标注规范与质量控制:宁可少而精,不要多而糙

舌苔标注的主观性极强。同一个舌面,两位标注者可能一个判「薄白」一个判「白腻」。如果你打算自采数据或重新标注,必须先定一份书面标注规范,至少包含:舌体是否伸出、光线条件、舌面湿润度、苔色主色调判定规则。我见过某项目因为标注规范没定清,模型训练完才发现某个类别里有大量「舌尖特写」和「全舌图」混在一起,模型学到的是构图差异。

标注完成后必须做一致性检查。常见的做法是让两位标注者分别标注同一批样本,计算 Cohen's Kappa 系数,低于 0.7 的类别要重新讨论标准。实际执行时,我一般会抽 20% 的样本做二次校验,发现有系统偏差(比如某人总倾向把「黄腻」标成「黄苔」),就针对性地修改规范或补充参考图。

除了标注一致性,还要处理图像采集条件问题。舌苔图像的色温敏感度极高,同一舌面在日光灯和暖光灯下拍出来,色差肉眼可见。我习惯在数据清洗阶段做一次色彩统计,看看整体分布是否偏向某个色温,如果确实偏移明显,可以考虑加一层简单的白平衡归一化,但要注意不要过度处理,否则会抹掉苔色本身的纹理特征。另外,舌体边缘的嘴唇、牙齿、面部皮肤占比过高时,模型很容易走捷径——直接靠肤色和唇形分类,而不是看舌苔。这类样本要么裁剪掉,要么在训练时用分割掩码把非舌体区域遮住。

2.3 数据增强与样本均衡:别让模型靠背景猜答案

舌苔检测的训练数据往往类别不平衡,灰黑苔和剥苔的样本量可能只有薄白苔的十分之一。直接用原始数据训练,模型对少数类基本会放弃预测。我常用的策略是:先做离线增强补齐少数类,再用在线增强做整体泛化。

import albumentations as A import cv2 import numpy as np from pathlib import Path # 针对少数类做离线增强:翻转、旋转、亮度扰动、HSV扰动 aug_pipeline = A.Compose([ A.HorizontalFlip(p=0.5), A.Rotate(limit=15, p=0.7), A.RandomBrightnessContrast(brightness_limit=0.15, contrast_limit=0.1, p=0.8), A.HueSaturationValue(hue_shift_limit=8, sat_shift_limit=10, val_shift_limit=10, p=0.6), ]) def augment_minority_class(img_dir: Path, target_count: int): img_paths = list(img_dir.glob("*.jpg")) for i in range(target_count - len(img_paths)): src = img_paths[i % len(img_paths)] image = cv2.imread(str(src)) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) n = i // len(img_paths) + 1 for _ in range(n): aug = aug_pipeline(image=image)["image"] save_name = f"{src.stem}_aug{i:04d}.jpg" cv2.imwrite(str(img_dir / save_name), cv2.cvtColor(aug, cv2.COLOR_RGB2BGR))

参数设定逻辑:rotate 的 limit 设 15 度而不是 30 度,因为舌体方向有一定的解剖学约束,过大旋转会生成现实中不存在的样本,反而干扰模型学形态特征。HueSaturationValue 的 hue_shift_limit 只设 8,舌苔颜色是诊断关键线索,色相偏移太夸张会把「薄白」变成「淡黄」,等于制造错误标注。upsampling 后要注意,增强样本与原样本特征高度相关,如果增强倍数超过 10 倍,模型会过度拟合增强模式而非真实语义特征。遇到少数类样本实在不够的情况,优先考虑采集,而不是无限增强。

训练阶段的在线增强则更激进一些,随机裁剪、缩放、ColorJitter 都可以加,但同样要控制色相扰动幅度。我习惯把舌苔类任务的 ColorJitter 设为 brightness=0.2, contrast=0.2, saturation=0.15, hue=0.03,这类参数既保证了光照泛化,又不会把苔色语义改掉。

3. 模型选型与训练:从迁移学习到参数调优

3.1 预训练模型怎么选:为什么不是越大越好

舌苔检测的图像规模通常不大,公开数据集总量少则几百张,多则几千张。在这种规模下,直接从头训练一个深度网络基本会严重过拟合,而用 ImageNet 预训练权重做迁移学习是更稳妥的起点。模型结构选择上,我一般建议从 MobileNetV2 或 EfficientNet-B0 起步,而不是一上来就上 ResNet50 或 ViT。

原因有两点。第一,舌苔图像与 ImageNet 自然图像差异不小,但浅层边缘、纹理特征仍然可复用,所以从预训练权重初始化是划算的。第二,细粒度分类靠的还是中高层特征,小模型在这个任务上并不吃亏,反而因为参数量少、更容易收敛。我自己测试过相同数据下 ResNet50 和 MobileNetV2 的效果,前者的精确率上限大概高 2-3 个百分点,但训练时间接近两倍,部署体积也大五倍以上。对论文场景来说,MobileNetV2 的性价比更高,也更容易写清楚消融实验的变量。

数据量如果超过五千张,可以换 EfficientNet-B1 或 ResNet34;如果只有几百张,建议把 MobileNetV2 的最后一层换成更大的全连接头,并在数据增强上做足功夫。另一种常见做法是冻结预训练骨干,只训练分类头,等分类头收敛后再解冻骨干低层微调。这个策略在小数据集上非常有效,能显著降低过拟合风险。

3.2 训练脚本与关键超参数:一版能直接跑起来的最小配置

下面这份训练配置来自一个我在模拟项目X里反复验证过的基线方案,数据集是大约 2000 张、六分类的舌苔图像。模型用 MobileNetV2,优化器用 AdamW,学习率用余弦退火。

import torch from torch import nn from torch.utils.data import DataLoader, Dataset from torchvision import models, transforms from torchvision.datasets import ImageFolder import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR transform_train = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=10), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.15, hue=0.03), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) transform_val = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_data = ImageFolder("data/train", transform=transform_train) val_data = ImageFolder("data/val", transform=transform_val) # 类别不均衡时用加权采样器兜底 from torch.utils.data import WeightedRandomSampler class_counts = torch.bincount(torch.tensor(train_data.targets)) class_weights = 1.0 / class_counts.float() sample_weights = class_weights[torch.tensor(train_data.targets)] sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True) train_loader = DataLoader(train_data, batch_size=32, sampler=sampler, num_workers=4) val_loader = DataLoader(val_data, batch_size = 32, shuffle=False, num_workers=4) model = models.mobilenet_v2(weights=models.MobileNet_V2_Weights.IMAGENET1K_V1) # 冻结前两层,只训练高层特征和分类头 for name, param in model.features[:10].named_parameters(): param.requires_grad = False # 替换分类头 model.classifier[1] = nn.Linear(model.classifier[1].in_features, 6)

这段代码里三个值得说明的细节:冻结层数用 features[:10],这个值不是拍脑袋定的,MobileNetV2 的 features 一共有 19 层,前 10 层学到的是通用边缘和纹理,后 9 层更偏向 ImageNet 的类别语义,冻结前 10 层既保留通用特征又把手头数据的高层语义交给新分类头学习。采样器参数 replacement=True 表示每个 epoch 采样时允许重复抽取样本,这样才能让少数类被抽到的概率真正提升。ImageFolder 的类别顺序是按文件夹名排序的,训练前一定要确认model.classifier的输出维度与文件夹类别数量对齐。

3.3 消融实验与过拟合排查:怎么看训练曲线发现问题

训练过程中我会同时记录训练集和验证集的 loss 与准确率,画在同一张图里。最容易出现的情况是:训练集准确率快速超过 90%,验证集停在 70% 左右不动,损失曲线呈分离状。这是过拟合的典型信号,而这个任务里过拟合的成因通常不只是数据量少,还有「模型学到了舌体外的作弊特征」。

排查顺序一般是这样的:先看验证集预测错误的样本里,是不是集中在某个类别;如果是,回到数据层面,看这个类别的样本是不是有明显的构图统一性,比如全部是舌体居中特写,而另外的类别全是带嘴唇的半身图。这种情况下模型大概率靠构图分类,而不是舌苔纹理。解决办法不是加 dropout,而是把数据裁剪统一,让每类样本的整体区域分布近似。

# 训练循环核心片段:loss、准确率、早停与模型保存 best_acc = 0.0 patience = 8 bad_epochs = 0 for epoch in range(30): model.train() running_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() running_loss += loss.item() model.eval() correct, total = 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs.data, 1) total += labels.size(0) correct += (predicted == labels).sum().item() val_acc = correct / total if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "best_model.pth") bad_epochs = 0 else: bad_epochs += 1 if bad_epochs >= patience: print("early stop at epoch", epoch) break

关键参数说明:patience 设 8,表示验证集连续 8 个 epoch 不涨就停。这个值在舌苔这种小数据集上够用,但如果你用了更强力的数据增强,收敛会变慢,建议放宽到 12。criterion 用 CrossEntropyLoss 时,遇到类别不均衡可以传入 weight 参数,设置为 class_weights 能直接给少数类更高的惩罚权重,比只靠 sampler 更稳。早停保存的是验证集最优权重而不是最后一个 epoch 的权重,这点一定要在代码里写清楚,否则后续推理用的可能是过拟合状态的模型。

另外,我习惯把每次训练的超参数和结果自动记进 CSV 文件,包括 learning_rate、batch_size、冻结层数、增强策略、验证准确率、precision 和 recall。论文阶段做表格对比时,这份记录能省大量追溯时间。

4. 把模型装进检测系统:推理封装与交互界面

4.1 推理管线的完整流程:从上传到出结果

模型训练完成只是第一步,真正能交付的检测系统至少包含三条环节:图像输入、预处理、推理与后处理。在实际编码之前,先想清楚输入从哪里来。舌苔检测的常见使用场景有两个:一是手机拍图上传,二是客户端摄像头直接采集。前者需要收网络图片,后者需要打开本机摄像头截帧。

我在模拟项目X里采用的方式是:后端用 FastAPI 接收图片,前端用 Streamlit 做界面,两者组合成一套轻量级系统。这样做的原因是 FastAPI 便于提供标准的 HTTP 接口,后续接小程序或 App 都很方便,Streamlit 则能在极短时间内做出可演示的界面,适合论文答辩演示。

推理部分的代码要单独封装成类,不要和 Web 框架耦合在一起,这样接口逻辑变更时模型服务能独立复用。模型加载时注意把模型切到 eval 模式,并写好注释,否则 BatchNorm 和 Dropout 的行为会不一样。

import torch from torchvision import transforms from PIL import Image class TongueInference: def __init__(self, model_path: str, num_classes: int, device=None): # 加载保存的权重,注意这里要和训练时使用同一个模型结构 self.device = device or ("cuda" if torch.cuda.is_available() else "cpu") self.model = models.mobilenet_v2(weights=None) self.model.classifier[1] = nn.Linear( self.model.classifier[1].in_features, num_classes ) self.model.load_state_dict(torch.load(model_path, map_location=self.device)) self.model.to(self.device) self.model.eval() self.transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def predict(self, image: Image.Image) -> tuple[str, float]: # 输入转RGB,去掉透明通道 image = image.convert("RGB") tensor = self.transform(image).unsqueeze(0).to(self.device) with torch.no_grad(): logits = self.model(tensor) probs = torch.softmax(logits, dim=1) top_prob, top_idx = torch.max(probs, dim=1) class_names = ["薄白苔", "白腻苔", "黄苔", "黄腻苔", "剥苔", "灰黑苔"] return class_names[top_idx.item()], top_prob.item()

这段代码有几个坑要避开。第一,torch.load 加载权重时默认会把张量绑到保存时的设备上,如果你的 checkpoint 是在 GPU 上保存的,换到 CPU 机器推理会报错,所以 map_location=self.device 是必须的。第二,保存权重时只保存 model.state_dict() 而不是整个模型对象,因为后者会把训练时引入的各种辅助属性一起带上,换环境后容易版本冲突。第三,预测时每次都要做 resize 到 224,这不仅是模型输入要求,也是为了让舌苔的纹理尺度保持一致——训练时如果也做了相同 resize,推理结果才能对齐。

4.2 图像分割与置信度过滤:模型输出的最后一道防线

训练集里的舌苔图像大多是居中的舌头特写,但真实场景中用户上传的可能是一张面部特写甚至一张带脸的全身照。直接用整图推理会出现什么结果?

模型会强行把整张图归到六个类别之一,哪怕图片里根本没有舌头。这正是我在前面强调的「输入分布漂移」问题。一个可靠的系统必须在推理前做舌体区域检测或至少做区域裁剪。

import cv2 import numpy as np def detect_tongue_region(image: np.ndarray) -> np.ndarray | None: # 转为HSV,利用舌体与唇部颜色的差异提取候选区域 hsv = cv2.cvtColor(image, cv2.COLOR_RGB2HSV) # 舌体在HSV空间一般落在这个范围,这是粗略估计值 lower = np.array([0, 30, 60]) upper = np.array([20, 255, 255]) mask = cv2.inRange(hsv, lower, upper) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None max_contour = max(contours, key=cv2.contourArea) x, y, w, h = cv2.boundingRect(max_contour) # 候选区域太小时说明可能是噪点,不是舌体 if w * h < image.shape[0] * image.shape[1] * 0.05: return None return image[y:y+h, x:x+w]

这里 HSV 阈值参数需要在你自己的数据集上微调,不同相机拍摄的舌体色相会有差异。lower 的 H 通道从 0 到 20 覆盖了红-橙区间,基本对应舌体的血色范围,但如果数据集里黄腻苔占比高,可以适当调高到 25。这里的代码用的是传统颜色分割,虽然不够优雅,但对舌苔这种红色为主的目标来说足够实用。如果想要更稳的舌体提取效果,可以换用训练好的语义分割模型,但那样系统复杂度会上升不少,建议先把颜色方案跑通后再考虑升级。

推理后处理的另一个关键点是置信度阈值。六分类 softmax 输出总和为 1,但模型有可能给出的最高概率只有 0.35,说明它对这张图根本不确定。这时候别硬着头皮输出类别,应该返回「低置信度,请重新拍摄」的提示。我在系统中设定的阈值是 0.6,低于 0.6 就判定为无效输入,用户需要重新拍。

4.3 用 Streamlit 搭一个可演示的检测界面

论文场景里,界面的作用不是追求工程完备,而是让评委直观看到「上传图片 → 显示分类结果 → 显示置信度」这样一条链路。Streamlit 在这个场景下非常合适,几十行代码就能实现。

import streamlit as st from PIL import Image from tongue_inference import TongueInference @st.cache_resource def load_model(): return TongueInference("best_model.pth", num_classes=6) st.title("基于机器学习的舌苔检测系统") model = load_model() uploaded_file = st.file_uploader("上传舌苔图像", type=["jpg", "png", "jpeg"]) if uploaded_file is not None: image = Image.open(uploaded_file) st.image(image, caption="原始图像", use_container_width=True) # 转成numpy做舌体区域检测 img_np = np.array(image) tongue = detect_tongue_region(img_np) if tongue is None: st.error("未能检测到舌体区域,请重新拍摄") else: pil_tongue = Image.fromarray(tongue) label, prob = model.predict(pil_tongue) st.markdown(f"### 检测结果:{label}") st.markdown(f"置信度:{prob:.2%}") # 给出简单的健康提示(仅供演示,不构成诊断) tips = { "薄白苔": "正常舌苔表现", "白腻苔": "提示体内可能湿气偏重", "黄苔": "提示可能有热象", "黄腻苔": "提示湿热内蕴", "剥苔": "提示阴液耗伤,建议进一步检查", "灰黑苔": "提示病情较重,建议就医" } st.info(tips[label])

运行方式是streamlit run app.py,浏览器会自动打开本地端口。需要注意,st.cache_resource确保模型只加载一次,不然每次点击上传都会重新读权重,演示现场会很卡。舌体检测函数返回 None 时,界面给出重新拍摄的提示,这比硬分类出错误结果要好得多。这里的诊断提示文案仅为演示逻辑,真正的医学建议需要专业背书,界面上应注明「结果仅供研究参考,不构成医疗诊断」。

5. 避坑:舌苔检测系统最常见的五个翻车场景

5.1 类别定义不统一,模型学的是「标注者的手」

现象:训练集准确率很高,但把模型放到另一批真实采集图片上验证时,准确率暴跌超过 20 个百分点。

原因:不同来源的数据集对「白腻苔」和「薄白苔」的划分标准不一致,有的把颜色偏淡的都归为白腻,有的要求舌苔厚到能盖住舌体才算白腻。模型学到的是某个标注团队的判断习惯,而不是客观的苔质特征。

解决:把所有训练图片拿出来做一次人工复核,按你自己定义的规范重新标注,而不是直接信任公开数据集的原始标签。这个步骤耗时,但它是整个系统上限的决定因素。如果时间实在不够,就统一用颜色直方图和苔厚度的量化指标重新定义类别边界。

5.2 舌体分割成摆设,模型靠背景作弊

现象:验证集准确率很高,但 Grad-CAM 热力图显示模型关注的是嘴唇和牙齿边缘,而不是舌面中心。

原因:训练集里舌体占比小、部分样本带有明显的唇部和牙齿区域,模型发现这些背景特征和类别标签之间存在更强的相关性,于是走了捷径。

解决:训练前对每张图做舌体区域裁剪,让舌面占据画面的主要部分,或者干脆使用带分割掩码的数据集训练一个两阶段模型——先分割舌体,再对裁剪后的区域分类。裁剪操作要在离线阶段完成并保存,不要在训练循环里重复计算,否则训练速度会明显下降。

5.3 光照和色温干扰严重,同一张图两次推理结果不同

现象:同一部手机、同一个舌头,在窗边和室内分别拍一张,模型分类结果从「白腻苔」变成了「黄苔」。

原因:舌苔分类极大依赖颜色特征,而手机自动白平衡会随环境色温调整图像色彩。偏暖光环境下拍摄的薄白苔看起来偏黄,模型自然被骗了。

解决:推理前做一次白平衡校正。简单的做法是假设舌体是中性偏红区域,用灰度世界假设做颜色归一化。更稳妥的做法是在数据增强阶段加入较大的亮度扰动,让模型对光照差异更鲁棒。如果条件允许,用色卡在采集环境里做颜色校正,效果最直接。

5.4 类别不均衡被忽略,少数类准确率几乎为零

现象:整体准确率有 85%,但查看每一类的 recall 时,灰黑苔和剥苔的召回率只有 10% 左右,而这些类别恰恰是临床上最需要关注的异常信号。

原因:数据集中灰黑苔样本极少,模型没有足够的正样本学习这类特征,分类边界被多数类完全压制。

解决:除了用 WeightedRandomSampler 做样本平衡,还可以在损失函数里给少数类加权重,具体做法是把 CrossEntropyLoss 的 weight 参数设为 class_weights。我实际经验是两者同时使用效果最好,单独用其中一个都容易出现边界振荡。另外,生成少数类的离线增强样本也能帮忙,但记得不要过度增强导致重复样本超过原始数据的三倍。

5.5 把「演示」当「生产」,模型版本管理缺失

现象:跑通后代码里写死了模型路径,某次重新训练覆盖了权重文件,系统里的所有结果在不知不觉中发生了变化。

原因:没有给模型权重加版本号,也没有记录训练参数。舌苔检测这类研究型项目经常反复训练,稍不注意就会覆盖掉表现更好的版本。

解决:训练脚本里增加自动存档逻辑,每个 epoch 结束把最优权重复制一份带时间戳的文件,比如best_model_0902_1435.pth。同时训练时打印的内容也要输出到日志文件,方便后续回溯哪一版参数产生哪一版结果。这个习惯在论文复现阶段能省下大把时间。

6. 进阶:用 Grad-CAM 验证模型是否真的在「看舌头」

模型部署完成后,最重要的不是再调高一个百分点的准确率,而是确认模型关注的区域在语义上是合理的。这一章分享一个我常用的验证方法:Grad-CAM 可视化。它能生成热力图,直观显示模型在做分类决策时,图像中哪些区域贡献最大。

import torch from torchvision import models, transforms import cv2 import numpy as np from PIL import Image class GradCAM: def __init__(self, model, target_layer): self.model = model self.gradients = None self.activations = None target_layer.register_forward_hook(self.save_activation) target_layer.register_backward_hook(self.save_gradient) def save_activation(self, module, input, output): self.activations = output.detach() def save_gradient(self, module, grad_input, grad_output): self.gradients = grad_output[0].detach() def generate(self, input_tensor, target_class=None): self.model.eval() output = self.model(input_tensor) if target_class is None: target_class = output.argmax(dim=1).item() self.model.zero_grad() one_hot = torch.zeros_like(output) one_hot[0][target_class] = 1 output.backward(gradient=one_hot) weights = torch.mean(self.gradients, dim=(2, 3)) cam = torch.einsum("bc, bhw -> bhw", weights, self.activations) cam = torch.relu(cam) cam = cam.squeeze(0).cpu().numpy() # 归一化到0-255 cam = (cam - cam.min()) / (cam.max() - cam.min()) cam = np.uint8(cam * 255) return cv2.applyColorMap(cam, cv2.COLORMAP_JET)

使用方式:加载训练好的模型,把 MobileNetV2 的model.features[-1]作为 target_layer 传入。对验证集里的每张图生成热力图,叠加在原图上,然后用肉眼判断高亮区域是否集中在舌面中心。如果热力图在舌体边缘或者嘴唇上高亮,说明模型学到的特征有问题,要回到数据清洗和标注环节排查。

我实践中的一个场景:模拟项目X 里做舌苔检测时,版本 A 的准确率比版本 B 高了 1.5 个百分点,但 Grad-CAM 显示版本 A 模型的高亮区集中在左侧嘴角,版本 B 的高亮集中在舌苔纹理上。这个时候,准确率本身已经不说明问题了,版本 B 才是真正值得用的那个模型。后来我养成了一个习惯:每次训练完不只看指标,先随机抽 10 张图做 Grad-CAM,确认模型在「看舌头」再继续做后续流程。这个习惯帮我避开了好几次「指标高但实际不可用」的陷阱。

如果你想让系统更完整,还可以继续做三件事:一是引入舌体分割模型替代颜色提取,提升复杂背景下的鲁棒性;二是把类别从六分类扩展到包含舌形、舌质属性的多标签方案;三是把推理接口封装成 Docker 镜像,方便异地部署和横向对比。每一项都在既有框架上增量开发,不需要推翻重来。

希望这篇笔记能帮你在舌苔检测这个方向上少走弯路,做出自己真正能复现、能解释、能交付的完整系统。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进