FEATURED · 精选文章

深度学习舌象识别:从数据预处理到模型部署的完整实践

发布时间 / 2026/9/15 20:01:35
来源 / 创域科博编辑部
栏目 / 资讯中心
深度学习舌象识别:从数据预处理到模型部署的完整实践 简介这是一套面向医学图像处理与中医AI应用开发者的深度学习舌象诊断系统完整工程包。资源以Python脚本为核心涵盖数据预处理、CNN模型构建、训练优化与测试评估等关键环节并附带61张舌象样本图片、界面UI和JSON配置可帮助初学者理解卷积神经网络在医学图像分类中的落地流程。包内另有《基于深度学习的舌象诊断系统学习路线》Word文档梳理了从数据收集到应用部署的六个实施步骤适合作为课程设计或毕业设计的参考资料。压缩包共184个文件包含py源码、pyc编译文件、图片、文本说明、模型配置及字体文件等整体体积42.65MB结构组织清晰。目前已有205人学习下载适合正在研究舌诊智能化或需要完整项目代码的学习者快速上手。1. 舌象识别为什么值得用深度学习重做一遍中医舌诊靠的是医生肉眼观察舌色、苔质、舌形和瘀斑分布这套经验体系在临床上有效但长期存在两个问题一是主观性强同一张舌图在不同年资医生手里可能得出不同结论二是难以量化随访复诊时舌象发生了多少变化、治疗方向是否需要调整缺少可比对的数据支撑。舌象诊断系统的核心工作就是把“舌象长什么样”这件事从目测经验转成可计算的视觉特征——舌色落在哪个色域、苔的厚薄在纹理上的响应如何、舌体边缘的齿痕在形状特征上怎么表达。这正好落在深度学习图像分类的射程内卷积神经网络天然擅长提取纹理、颜色和局部形态特征而且端到端学习不需要人工设计特征算子。不过这类系统在真实落地时和普通图像分类项目有明显区别。舌象图片的采集环境高度不统一——手机拍摄、诊室补光、舌面反光和唾液反光都会改变颜色分布而中医辨证恰恰对颜色极其敏感。另一个难点是类别体系复杂同样是“红舌”可能是实热也可能是阴虚需要结合苔色和舌形综合判断。因此这个项目既适合作为深度学习图像分类的完整实战练习也适合想往医学影像方向走的开发者作为切入点。接下来按数据准备、模型构建、训练调参到部署验证的顺序逐步拆解。2. 舌象数据集的构建与预处理颜色标准化是关键2.1 数据从哪里来怎么组织目录舌象数据的获取是这个项目里最容易被低估的一步。公开的舌象数据集规模都不大且标注体系不统一有的按“淡红、红、绛、紫”分类有的按“薄白苔、白腻苔、黄腻苔”分类。项目包里提供的舌象图片素材适合用来打通流程、验证模型能收敛但要支撑到临床应用级别至少需要数千张以上、由中医师协同标注的图片。常见的做法是先从少量明确分类的图片开始跑通完整训练流程再逐步扩充数据。目录组织建议如下按类别分文件夹即可PyTorch的ImageFolder可以直接读取tongue_dataset/ ├── train/ │ ├── dan_hong/ # 淡红舌 │ ├── hong/ # 红舌 │ ├── jiang/ # 绛舌 │ ├── zi/ # 紫舌 │ └── dan_bai/ # 淡白舌 ├── val/ │ └── ... └── test/ └── ...2.2 颜色校正比数据增强更优先舌象诊断对颜色极其敏感而深度学习模型对颜色分布的变化也很敏感。同一个训练集如果使用不统一的采集设备模型会学到“设备相关”的颜色特征而不是“舌象本身”的颜色特征。因此预处理时颜色标准化应该排在数据增强之前。我推荐的做法是先用灰度世界算法做白平衡校正再把图片缩放到统一尺寸。灰度世界假设色彩足够丰富时RGB三个通道的平均值应该趋近于灰色据此调整通道增益。在PyTorch里可以在Dataset的__getitem__中加载图片后先做校正import cv2 import numpy as np import torch from torch.utils.data import Dataset from torchvision import transforms class TongueDataset(Dataset): def __init__(self, root_dir, transformNone): self.root_dir root_dir self.transform transform self.samples [] # (img_path, label) 列表 def gray_world_correction(self, img_bgr): 灰度世界白平衡校正返回BGR格式校正结果 result img_bgr.astype(np.float32) avg_b np.mean(result[:, :, 0]) avg_g np.mean(result[:, :, 1]) avg_r np.mean(result[:, :, 2]) avg_gray (avg_b avg_g avg_r) / 3.0 # 各通道乘以增益系数并做饱和截断 scale_b avg_gray / (avg_b 1e-6) scale_g avg_gray / (avg_g 1e-6) scale_r avg_gray / (avg_r 1e-6) result[:, :, 0] np.clip(result[:, :, 0] * scale_b, 0, 255) result[:, :, 1] np.clip(result[:, :, 1] * scale_g, 0, 255) result[:, :, 2] np.clip(result[:, :, 2] * scale_r, 0, 255) return result.astype(np.uint8) def __getitem__(self, idx): img_path, label self.samples[idx] img cv2.imread(img_path) # BGR格式 img self.gray_world_correction(img) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) if self.transform: img self.transform(img) return img, label校正逻辑说明先分别计算BGR三个通道的全图均值再以三通道均值的平均作为目标灰度各通道乘以对应增益系数逼近灰色假设。分母加1e-6是为了防止纯色图片出现除零。注意这里必须在缩放之前做校正因为缩放会改变像素统计分布。实际项目中如果采集环境固定可以直接用一个标准色卡做映射矩阵效果会更好。2.3 数据增强策略哪些增强对舌象有效舌象图片的数据增强和自然图像有所差别。随机旋转、水平翻转是安全的因为舌象不存在方向敏感的问题随机亮度、对比度调整要谨慎幅度过大会破坏颜色语义随机裁剪可以模拟舌体在画面中占比不同的情况但如果裁剪范围太大可能把舌体外的嘴唇、牙齿纳入模型引入噪声。我一般使用的增强组合如下train_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomRotation(15, fill0), # 小幅旋转fill0填充黑色 transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.15, contrast0.15, saturation0.1, hue0.02), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_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]) ])这里的参数选取依据是旋转角度控制在15度以内模拟患者舌头摆放姿态的自然偏差ColorJitter的亮度、对比度调整幅度控制在0.15以内允许一定光照变化的同时不过度扭曲舌色hue参数只给0.02因为色相偏移对舌色判断的影响是致命的。标准化使用的均值方差是ImageNet的统计值目的在于配合预训练模型的权重分布。提示如果后续发现模型对暗光环境下的图片效果差可以额外增加transforms.GaussianBlur(kernel_size(3,3))模拟轻微对焦不准但不要用太大的模糊核否则会抹掉舌苔纹理信息。3. 模型选型与构建从ResNet到轻量化改进3.1 为什么用ResNet做基线而不是Vision Transformer舌象诊断本质上是细粒度图像分类类别之间的差异往往集中在颜色细微变化和舌苔纹理分布上。Vision TransformerViT在大规模数据集上表现很好但在数据量只有几千张的医学图像场景下其归纳偏置不足的问题会被放大训练起来需要更多trick。而ResNet的残差连接缓解了深层网络梯度消失的问题在中小规模数据集上更容易收敛也更容易迁移。具体到舌象任务ResNet还有一个结构上的优势残差分支让网络在初始阶段可以退化为浅层网络只学习颜色这类全局特征随着训练深入深层路径逐渐激活开始关注苔质纹理等局部细节。这种“先粗后细”的学习路径对细粒度分类很友好。这里选ResNet18/50作为基线117层深的主要是配合预训练权重不是说层数越多效果必然越好。3.2 迁移学习的实现细节舌象数据集规模有限直接从随机初始化训练ResNet50大概率会过拟合。用ImageNet预训练权重做迁移学习相当于让模型先具备通用纹理和边缘检测能力然后用自己的舌象数据微调高层特征。加载预训练权重时注意要把最后一层全连接替换成自己的分类头import torch.nn as nn import torchvision.models as models def build_tongue_model(num_classes5, model_nameresnet18, pretrainedTrue): if model_name resnet18: model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1 if pretrained else None) fc_in_features model.fc.in_features model.fc nn.Sequential( nn.Dropout(p0.3), nn.Linear(fc_in_features, 256), nn.ReLU(inplaceTrue), nn.Dropout(p0.2), nn.Linear(256, num_classes) ) elif model_name resnet50: model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2 if pretrained else None) fc_in_features model.fc.in_features model.fc nn.Sequential( nn.Dropout(p0.3), nn.Linear(fc_in_features, num_classes) ) return model这个设计的考虑是ResNet18容量较小在数据量不足时加一个窄全连接层做特征映射避免直接用2048维特征映射到类别时过拟合ResNet50输出特征维度更高非线性能力更强一个Dropout加单层全连接就可以隐藏层过多反而增加过拟合风险。Dropout放在全连接层前是让模型不要过度依赖某一个特征通道强迫它学习多个互补的舌象特征。3.3 损失函数与类别不平衡处理舌象分类的标签分布天然不平衡。常见舌色如淡红舌占比高而绛舌、紫舌出现频率低。如果直接用交叉熵损失模型会倾向把所有样本预测为多数类。两种常用处理方式一是对损失函数施加类别权重二是用Focal Loss让模型关注难分样本。import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alphaNone, gamma2.0): super().__init__() self.alpha alpha # 类别权重Tensor类型 self.gamma gamma def forward(self, logits, targets): ce_loss F.cross_entropy(logits, targets, weightself.alpha, reductionnone) pt torch.exp(-ce_loss) # 预测正确的概率 focal_loss (1 - pt) ** self.gamma * ce_loss return focal_loss.mean()参数含义gamma2.0是Focal Loss的默认设置当样本被正确分类且置信度高时pt接近1(1-pt)^gamma趋近0损失被压低难分样本的损失被放大。alpha是类别频率的倒数归一化给少数类更大的权重。实际调参时可以先用alphaNone跑一版看各类别的F1再根据表现差的类别调整权重不要一开始就用极端权重否则会引入噪声。4. 训练策略与调参关注验证集而不是训练集4.1 训练脚本与学习率调度训练阶段最容易犯的错误是全程用一个学习率。舌象数据量不大模型很快就能在训练集上达到接近100%的准确率但此时验证集准确率可能已经停止上升甚至下降这是典型的过拟合信号。我的做法是使用余弦退火调度器配合热身策略前5个epoch用较小学习率热身避免预训练权重被大步幅破坏之后学习率按余弦曲线衰减到接近0。import torch from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR def create_scheduler(optimizer, train_loader, epochs): warmup_epochs 5 total_steps epochs * len(train_loader) warmup_steps warmup_epochs * len(train_loader) warmup_scheduler LinearLR( optimizer, start_factor0.1, end_factor1.0, total_iterswarmup_steps ) cosine_scheduler CosineAnnealingLR(optimizer, T_maxtotal_steps - warmup_steps) return SequentialLR(optimizer, schedulers[warmup_scheduler, cosine_scheduler], milestones[warmup_steps])说明LinearLR的start_factor0.1表示初始学习率是设定值如0.001的十分之一逐步增加到设定值CosineAnnealingLR的T_max是余弦退火的半周期这里设置为剩余训练步数保证最后一步学习率衰减到接近0。使用SequentialLR组合两者在warmup_steps处切换。学习率上限的选择上Adam优化器推荐设为0.001SGD加动量可以设为0.01。4.2 训练循环中的关键检查点训练循环里除了一般的loss记录我还会在每轮验证后额外记录三个指标每个类别的准确率、混淆矩阵、以及错误样本的预测置信度。只盯整体准确率会掩盖多数类主导的问题。以一个五个类别的舌象分类为例训练过程中可以这样组织验证逻辑def evaluate(model, val_loader, device): model.eval() class_correct [0] * num_classes class_total [0] * num_classes all_preds [] all_labels [] with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) logits model(images) preds logits.argmax(dim1) for i in range(len(labels)): class_total[labels[i].item()] 1 if preds[i] labels[i]: class_correct[labels[i].item()] 1 all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) class_acc [class_correct[i] / max(class_total[i], 1) for i in range(num_classes)] return class_acc, all_preds, all_labels验证时必须调用model.eval()这会关闭Dropout和BatchNorm的统计更新。如果忘了这步Dropout在验证阶段依然生效预测结果会有随机性。另外一个细节验证阶段不需要计算梯度用torch.no_grad()包裹可以减少显存占用在单卡上能有效增大batch size的余量。4.3 过拟合的识别与应对怎么判断模型是过拟合了还是正常的高方差表现舌象装置下有两个比较可靠的信号训练损失持续下降但验证损失开始回升或验证集上某几个易混淆类别的准确率明显下降。应对方式按优先级排列不必一上来就换模型手段适用场景参数参考注意点数据增强增强训练损失下降快且验证损失高Rotation 扩大到30度、增加RandomAffine不要动色调相关参数提高Dropout全连接层过拟合明显p从0.3调到0.5对卷积层效果有限权重衰减模型整体过拟合L2系数从1e-4调到5e-4Adam配合weight_decay冻结浅层数据量极小时冻结前3个残差块只微调后面3个块和全连接这里重点说下冻结浅层。舌象的低级特征边缘、颜色块和ImageNet的通用特征高度相似浅层不需要重新学习。冻结后模型参数量大幅减少可以显著降低过拟合风险。在PyTorch里实现时对需要冻结的层设置requires_grad_(False)优化器只接收需要训练的参数for param in model.parameters(): param.requires_grad False # 从layer3开始解冻 for param in model.layer3.parameters(): param.requires_grad True for param in model.layer4.parameters(): param.requires_grad True for param in model.fc.parameters(): param.requires_grad True optimizer torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr0.001)filter(lambda p: p.requires_grad, ...)是关键如果不过滤而直接把model.parameters()交给优化器冻结层仍然会被更新——优化器只看参数是否在传入列表中不理会requires_grad标志。5. 模型评估与关键指标解读5.1 评估维度不能只看准确率舌象诊断落地时医生的容错标准跟做一般图像识别不同。一个“红舌”被误判为“绛舌”与“淡白舌”被误判为“红舌”在临床上的影响完全不一样。所以评估指标必须拆到类别级别来看。准确率、精确率、召回率、F1分数这四类指标都要分别按类别输出指标含义舌象场景说明精确率预测为该类的样本中预测正确的比例精确率低说明模型容易把其他舌色误判进来召回率真实为该类的样本中被正确找出的比例召回率低说明该类别漏诊风险高F1精确率与召回率的调和平均数据不平衡时比准确率更可靠混淆矩阵各类别间的误判分布重点看哪些成对类别容易被混淆以红舌和绛舌为例两者在颜色上相近模型如果精确率高但召回率低说明模型把很多红舌判定成了绛舌这会直接导致诊断结果偏热。这种情况下单纯提高全局准确率没有意义反而可能是模型偏向把所有偏红色调的成员都划给比例更高的类。5.2 可视化验证CAM热力图检查模型到底在看哪训练结束时除了打印评估指标我建议用Grad-CAM可视化一下模型的注意力区域。舌象诊断和一般图像分类不同模型不能只看背景、嘴唇或舌头以外的区域。如果CAM热力图显示高响应区域集中在舌根边缘或者卡片背景说明模型学到了错误特征。Grad-CAM的实现不复杂核心是获取目标层输出的梯度对特征图通道做加权求和。下面是一段针对ResNet最后一层卷积的简化实现def grad_cam(model, img_tensor, target_layer, target_classNone): gradients {} activations {} def forward_hook(module, input, output): activations[value] output def backward_hook(module, grad_input, grad_output): gradients[value] grad_output[0] hook_handle_forward target_layer.register_forward_hook(forward_hook) hook_handle_backward target_layer.register_full_backward_hook(backward_hook) model.eval() output model(img_tensor.unsqueeze(0)) if target_class is None: target_class output.argmax(dim1).item() model.zero_grad() one_hot torch.zeros_like(output) one_hot[0][target_class] 1.0 output.backward(gradientone_hot) weights gradients[value].mean(dim(2, 3), keepdimTrue) # 全局平均池化 cam torch.relu((weights * activations[value]).sum(dim1, keepdimTrue)) cam F.interpolate(cam, size(224, 224), modebilinear, align_cornersFalse) hook_handle_forward.remove() hook_handle_backward.remove() return cam.squeeze().cpu().detach().numpy(), target_class代码中的逻辑是前向钩子保存目标层的特征图反向钩子保存该层收到的梯度对梯度做全局平均池化得到每个通道的权重再对激活值加权求和最后经过ReLU只保留正响应区域放大到输入尺寸。使用register_full_backward_hook可以拿到梯度输出。target_layer通常选model.layer4[-1]即最后一个残差块的输出因为这里的特征图同时保有语义信息和空间位置信息。如果CAM热力图在大部分测试图片上都集中在舌体中心区域说明模型学到的特征基本可信如果集中在下巴边缘或图片角落需要回到数据预处理环节检查舌体分割是否准确。6. 部署落地把模型封装成可用服务6.1 模型导出TORCHScript与ONNX对比模型训练完成后部署方式需要根据使用场景决定。如果目标是做一个网页端演示工具推荐导出为ONNX格式如果目标是集成进已有的PyTorch服务框架TORCHScript可能更顺滑。ONNX导出的主要好处是跨框架、跨语言使用ONNX Runtime推理时性能比PyTorch的Eager模式稳定。导出时需要注意一个坑如果训练时使用nn.Sequential包裹了全连接层导出时需要把模型切换为推理模式并固定输入维度和batchmodel.eval() dummy_input torch.randn(1, 3, 224, 224).to(device) torch.onnx.export( model, dummy_input, tongue_model.onnx, export_paramsTrue, opset_version14, do_constant_foldingTrue, input_names[input], output_names[output], dynamic_axes{input: {0: batch_size}, output: {0: batch_size}} )dynamic_axes设置了动态batch维度这样推理服务可以一次处理一张或多张图片。do_constant_foldingTrue会把常量计算在导出时折叠掉减少运行时计算量。opset版本不要开太高14以上在部分设备上的兼容性更好。导出后可以用onnxruntime跑一次推理和PyTorch的推理结果对比两者的输出差异应该在1e-5级别。6.2 FastAPI服务的最小实现部署时我用FastAPI封装推理接口异步处理图片上传和预测。对医学场景而言响应速度不是唯一指标更重要的是输出内容要包含置信度和可能类别的排序——不能只返回一个标签。import io import numpy as np import onnxruntime as ort from fastapi import FastAPI, UploadFile, File from PIL import Image import torchvision.transforms as T app FastAPI() session ort.InferenceSession(tongue_model.onnx, providers[CPUExecutionProvider]) 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]) ]) CLASS_NAMES [淡红舌, 红舌, 绛舌, 紫舌, 淡白舌] app.post(/predict) async def predict(file: UploadFile File(...)): image_data await file.read() img Image.open(io.BytesIO(image_data)).convert(RGB) # 注意这里同样需要先做灰度世界白平衡此处省略实现 input_tensor transform(img).unsqueeze(0).numpy() outputs session.run(None, {input: input_tensor})[0] # shape: (1, num_classes) probs 1 / (1 np.exp(-outputs[0])) # sigmoid转为概率 # 按置信度排序返回top-3 indices np.argsort(probs)[::-1][:3] result [{class: CLASS_NAMES[i], probability: float(probs[i])} for i in indices] return {code: 0, data: result}说明这里用sigmoid代替softmax是为了单独看每个类别被模型独立确认的程度方便医生了解模型对多标签可能性的判断。生产环境下如果训练时用的是CrossEntropyLosssoftmax更适合如果用了BCEWithLogitsLoss做多标签训练则sigmoid是正确选择。6.3 部署后的性能验证和备份策略服务上线前要做两件事一是压测并发场景下的推理延迟二是准备一份模型回滚方案。ONNX Runtime在CPU下的ResNet18推理耗时一般在15到40毫秒GPU下可控制在5毫秒内。如果线上延迟超过100毫秒优先检查是否有数据预处理在CPU上串行执行、是否每次都重新加载模型权重而非常驻内存。模型文件的备份建议使用版本号命名而不是覆盖式保存models/ ├── tongue_resnet18_v1_20250401.onnx ├── tongue_resnet18_v2_20250501.onnx └── tongue_resnet50_v1_20250601.onnx这样一旦新模型在临床侧反馈出现系统性误判比如对某一类舌苔颜色出现整体偏移可以用加载旧版本的接口快速回退。舌象模型的迭代本质上是数据和标注质量驱动的架构层面的调整倒在其次。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻