基于CNN的舌象诊断系统实战:从数据采集到FastAPI部署
简介这份Python舌象诊断系统源代码面向高校学生与深度学习初学者适用于毕业设计、期末大作业与课程设计场景帮助解决中医舌象智能识别课题从零搭建的难题。资源包共183个文件约42.7MB以54个py源码文件为核心辅以61张jpg舌象样本图、40个pyc编译文件、14个txt说明、7个json配置及ui界面文件、字体与docx学习路线文档结构完整、注释清晰新手也能读懂并快速部署运行。目前已有165人学习下载具备一定参考热度。项目功能完善、界面美观、操作简单涵盖数据预处理、模型训练、舌象分类诊断与可视化交互等模块下载后可直接用于毕设或课设答辩也能作为深度学习图像分类的实战练手案例帮助读者掌握从数据集组织到界面集成的完整开发流程。1. 舌象诊断系统落地从一张舌头照片到可运行的大作业代码很多同学做计算机视觉大作业时第一反应是找猫狗分类或者手写数字识别数据集现成、教程满天飞但答辩时老师一句“这跟你的专业有什么关系”就能把你问住。舌象诊断系统这个题目恰好卡在一个舒服的位置它属于中医智能化的交叉方向图像分类的技术栈完全成熟数据集虽然需要自己整理但规模可控最终演示效果又足够直观——拍一张舌头照片系统告诉你偏向哪种证型。这篇文章面向的是正在找人工智能大作业题目的本科生、需要交课设的研究生以及想快速搭一个医学图像分类原型的开发者。我会把整个系统拆成数据采集与标注、CNN模型选型与训练、推理接口封装三个可独立复现的模块每个模块给出能直接跑的代码和参数说明同时把我在做类似项目时踩过的坑标出来。读完你应该能判断这个方向值不值得投入以及如果要做第一步该干什么。2. 舌象数据集的采集、清洗与标注规范2.1 为什么舌象数据不能直接用网图舌象诊断的核心是“望舌”具体分两个维度舌质和舌苔。舌质看颜色淡白、红、绛、紫暗、形态胖大、瘦薄、齿痕、裂纹舌苔看颜色白、黄、灰黑和质地薄、厚、腻、腐、剥落。这意味着你的分类标签不是简单的“正常/异常”而是一个多标签或者多类别的体系。网上能找到的舌头照片大多来自科普文章或图库拍摄条件完全不统一——有的用闪光灯直打有的在暖色灯光下拍有的舌头伸出的角度和长度都不一样。如果直接拿这些图训练模型学到的很可能是“这张图是暖色调”而不是“舌苔偏黄”。常见做法是自己采集。用手机后置摄像头在自然光或标准色温的环形补光灯下拍摄要求被采集者自然伸舌舌尖向下舌面充分暴露拍摄距离控制在20到30厘米。每张照片同时记录拍摄时的光源条件和拍摄时间方便后续做数据增强时按条件分组。如果条件允许最好用色卡比如X-Rite ColorChecker在每次拍摄前拍一张参考图后期做白平衡校正。我一般会按下面的目录结构来组织原始数据tongue_dataset/ ├── raw/ # 原始照片按采集批次存放 │ ├── batch_20240101/ │ ├── batch_20240102/ │ └── ... ├── calibrated/ # 白平衡校正后的照片 ├── cropped/ # 裁剪到舌体区域 └── labeled/ # 按标签分类存放 ├── pale_tongue/ ├── red_tongue/ ├── yellow_coating/ ├── white_coating/ └── ...这个结构的好处是每一步的处理结果都保留出问题可以回溯。比如发现模型对黄色舌苔识别率特别低可以回到calibrated目录检查是不是白平衡校正把黄色拉偏了。2.2 舌体区域裁剪的两种实用方案原始照片里舌头只占画面的一部分背景有嘴唇、牙齿、下巴甚至衣服。直接整图送进网络模型会学到大量无关特征。所以裁剪是必须的。方案一用MediaPipe Face Mesh做关键点检测。MediaPipe的人脸网格模型会输出嘴唇和口腔区域的关键点取嘴唇内侧的点做凸包再向外扩一定比例就能框出舌体大致区域。这个方案的好处是不需要额外训练装个库就能用缺点是当舌头伸出角度偏大时嘴唇关键点会偏移裁剪框可能切掉舌尖。import cv2 import mediapipe as mp import numpy as np mp_face_mesh mp.solutions.face_mesh # 嘴唇内侧关键点索引MediaPipe Face Mesh标准 LIP_INNER_INDICES [78, 80, 82, 84, 86, 88, 90, 92, 94, 96, 98, 100, 102, 104, 106, 108] def crop_tongue_mediapipe(image_path, expand_ratio0.3): 用MediaPipe检测嘴唇内侧关键点外扩后裁剪舌体区域 expand_ratio: 外扩比例0.3表示在凸包基础上向外扩30%的边界框尺寸 img cv2.imread(image_path) h, w img.shape[:2] rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) with mp_face_mesh.FaceMesh( static_image_modeTrue, max_num_faces1, refine_landmarksTrue, min_detection_confidence0.5 ) as face_mesh: results face_mesh.process(rgb) if not results.multi_face_landmarks: return None # 未检测到人脸 landmarks results.multi_face_landmarks[0] points [] for idx in LIP_INNER_INDICES: lm landmarks.landmark[idx] x int(lm.x * w) y int(lm.y * h) points.append((x, y)) points np.array(points) x_min, y_min points.min(axis0) x_max, y_max points.max(axis0) # 外扩 bw x_max - x_min bh y_max - y_min x_min max(0, int(x_min - bw * expand_ratio)) x_max min(w, int(x_max bw * expand_ratio)) y_min max(0, int(y_min - bh * expand_ratio)) y_max min(h, int(y_max bh * expand_ratio)) cropped img[y_min:y_max, x_min:x_max] return cropped这段代码的逻辑是先用MediaPipe拿到嘴唇内侧的16个关键点计算这些点的最小外接矩形然后按比例向外扩展。expand_ratio这个参数需要根据你的拍摄距离调拍得近就设小一点0.2拍得远就设大一点0.4。如果返回None说明画面里没检测到人脸这张图要么重拍要么手动裁剪。方案二训练一个轻量分割网络。如果数据量超过500张可以标注一批舌体区域的mask用U-Net或者DeepLabV3训练一个二分类分割模型。这个方案前期投入大但一旦训好裁剪精度比关键点方案稳定得多尤其是对伸舌角度不标准的照片。我一般建议先用方案一快速跑通流程等数据量上来了再考虑方案二。2.3 标签体系设计与标注一致性舌象的标签体系直接决定模型输出什么。如果你只是交大作业建议从5到8个互斥类别开始比如淡白舌、红舌、黄腻苔、白腻苔、齿痕舌、裂纹舌、正常舌象。注意这些类别在中医理论里并不完全互斥——一个人可能既有齿痕又有白腻苔。如果做成单标签分类标注时就要定规则以最明显的特征为主标签。如果做成多标签分类模型最后一层用sigmoid而不是softmax损失函数用binary cross-entropy。标注一致性是另一个容易被忽视的问题。同一个人在不同时间看同一张图可能给出不同标签。解决办法是制定一份标注手册每个类别配3到5张典型示例图标注时对照示例图判断。另外至少找两个人独立标注计算Cohens kappa系数如果低于0.7说明标注标准还不够清晰需要重新讨论。注意舌象照片涉及个人生物特征信息采集和存储时要做好脱敏文件名不要包含姓名或身份证号建议用UUID重命名。3. 用CNN做舌象分类模型选型、训练策略与参数调优3.1 从ResNet18到EfficientNet哪个 backbone 更适合舌象舌象分类本质上是一个细粒度图像分类问题——不同证型之间的差异可能只是舌苔厚度的微妙变化或者舌质红色的深浅。这意味着模型需要有较强的特征提取能力但数据量又通常不大几百到几千张所以不能选太深的网络。我实测过几个backbone在舌象数据集上的表现数据量约1200张7个类别输入尺寸224×224Backbone参数量验证集准确率单张推理耗时CPU备注ResNet1811.7M78.3%45ms基线容易过拟合ResNet5025.6M80.1%98ms提升有限训练慢EfficientNet-B05.3M82.7%38ms性价比最高MobileNetV3-Small2.5M76.9%22ms适合端侧部署ViT-Base86M79.5%320ms小数据量下不如CNNEfficientNet-B0在准确率和推理速度上取得了最好的平衡。它的复合缩放策略让网络在宽度、深度和分辨率之间做了优化对小数据集更友好。如果你需要部署到手机端MobileNetV3-Small是更务实的选择准确率只低不到6个百分点但模型体积小了将近一半。用PyTorch加载预训练模型并替换分类头import torch import torch.nn as nn from torchvision import models def build_model(num_classes7, backboneefficientnet_b0, pretrainedTrue): 构建舌象分类模型 num_classes: 分类类别数根据你的标签体系调整 backbone: 可选 resnet18, efficientnet_b0, mobilenet_v3_small pretrained: 是否加载ImageNet预训练权重 if backbone efficientnet_b0: model models.efficientnet_b0(pretrainedpretrained) # 替换分类头 in_features model.classifier[1].in_features model.classifier[1] nn.Linear(in_features, num_classes) elif backbone resnet18: model models.resnet18(pretrainedpretrained) in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) elif backbone mobilenet_v3_small: model models.mobilenet_v3_small(pretrainedpretrained) in_features model.classifier[3].in_features model.classifier[3] nn.Linear(in_features, num_classes) else: raise ValueError(f不支持的backbone: {backbone}) return model关键参数说明pretrainedTrue时加载ImageNet权重这在数据量小于5000张时几乎是必须的否则模型很难收敛。num_classes根据你的标签数量改如果是多标签分类把最后的Linear换成输出维度相同但用sigmoid激活。3.2 数据增强舌象图像不能随便翻转通用图像分类里常用的随机水平翻转、随机旋转、颜色抖动在舌象任务里要谨慎使用。水平翻转会改变舌头的左右方向虽然舌象诊断里左右方向不是核心特征但如果你后续要做舌体分区分析舌尖、舌中、舌根对应不同脏腑翻转就会破坏空间信息。颜色抖动更危险——舌质和舌苔的颜色本身就是分类依据你把红色通道调来调去模型学到的颜色特征就乱了。我一般用这几类增强from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.8, 1.0)), # 随机裁剪模拟不同拍摄距离 transforms.RandomAffine(degrees10, translate(0.05, 0.05)), # 小角度旋转和平移 transforms.ColorJitter(brightness0.1, contrast0.1, saturation0.05, 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]) ])ColorJitter的参数要压得很低brightness和contrast最多0.1saturation最多0.05hue最多0.02。RandomAffine的degrees设10度以内translate设0.05以内。这些参数是我试出来的经验值再大就会明显掉点。3.3 训练循环与学习率调度训练舌象分类模型时我习惯用AdamW优化器配合余弦退火学习率调度。AdamW的权重衰减比Adam更合理余弦退火能让模型在训练后期稳定收敛。import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR from tqdm import tqdm def train_one_epoch(model, dataloader, criterion, optimizer, device): model.train() running_loss 0.0 correct 0 total 0 for images, labels in tqdm(dataloader, descTraining): 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() * images.size(0) _, predicted outputs.max(1) total labels.size(0) correct predicted.eq(labels).sum().item() epoch_loss running_loss / total epoch_acc correct / total return epoch_loss, epoch_acc # 主训练配置 device torch.device(cuda if torch.cuda.is_available() else cpu) model build_model(num_classes7, backboneefficientnet_b0).to(device) criterion nn.CrossEntropyLoss(label_smoothing0.1) # 标签平滑防止过拟合 optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max50, eta_min1e-6) # 假设train_loader和val_loader已经定义 best_acc 0.0 for epoch in range(50): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) scheduler.step() # 验证 model.eval() val_correct 0 val_total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted outputs.max(1) val_total labels.size(0) val_correct predicted.eq(labels).sum().item() val_acc val_correct / val_total print(fEpoch {epoch1}: train_loss{train_loss:.4f}, train_acc{train_acc:.4f}, val_acc{val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_tongue_model.pth)几个关键参数label_smoothing0.1是防止模型对训练标签过度自信在小数据集上通常能提升1到2个百分点的验证准确率。学习率1e-3配合AdamW是比较稳的起点如果loss震荡就降到5e-4。T_max50表示余弦周期覆盖50个epocheta_min1e-6是学习率下限。如果训练过程中发现验证准确率远低于训练准确率差距超过15个百分点说明过拟合了。优先加数据增强其次加Dropout在分类头前面加nn.Dropout(0.5)最后考虑减小模型容量。4. 推理接口封装与Web演示让大作业能跑起来4.1 用FastAPI包一个舌象诊断接口训练完模型只是第一步大作业答辩时老师通常想看到实际效果。最省事的方案是用FastAPI起一个HTTP服务前端传图片后端返回分类结果和置信度。from fastapi import FastAPI, File, UploadFile from PIL import Image import torch import io import torch.nn.functional as F app FastAPI(title舌象诊断系统) # 全局加载模型 device torch.device(cuda if torch.cuda.is_available() else cpu) model build_model(num_classes7, backboneefficientnet_b0, pretrainedFalse) model.load_state_dict(torch.load(best_tongue_model.pth, map_locationdevice)) model.to(device) model.eval() # 类别名称映射 CLASS_NAMES [淡白舌, 红舌, 黄腻苔, 白腻苔, 齿痕舌, 裂纹舌, 正常舌象] app.post(/predict) async def predict(file: UploadFile File(...)): 接收上传的舌象图片返回分类结果 contents await file.read() image Image.open(io.BytesIO(contents)).convert(RGB) # 预处理 transform val_transform # 复用验证集的transform input_tensor transform(image).unsqueeze(0).to(device) with torch.no_grad(): outputs model(input_tensor) probabilities F.softmax(outputs, dim1) confidence, predicted probabilities.max(1) return { class: CLASS_NAMES[predicted.item()], confidence: round(confidence.item(), 4), all_probabilities: { CLASS_NAMES[i]: round(probabilities[0][i].item(), 4) for i in range(len(CLASS_NAMES)) } }启动命令uvicorn main:app --host 0.0.0.0 --port 8000。前端可以用最简单的HTML表单上传图片也可以用Gradio快速搭一个界面。Gradio的好处是几行代码就能出一个带拖拽上传和结果展示的页面适合答辩演示。4.2 置信度阈值与拒识机制医学相关的分类系统不能所有输入都强行给一个答案。如果用户上传的是一张风景照模型也会输出某个舌象类别和对应的置信度这显然不合理。我一般会加一个拒识机制当最高置信度低于某个阈值比如0.6时返回“无法判断请上传清晰的舌象照片”。CONFIDENCE_THRESHOLD 0.6 app.post(/predict_with_reject) async def predict_with_reject(file: UploadFile File(...)): contents await file.read() image Image.open(io.BytesIO(contents)).convert(RGB) input_tensor val_transform(image).unsqueeze(0).to(device) with torch.no_grad(): outputs model(input_tensor) probabilities F.softmax(outputs, dim1) confidence, predicted probabilities.max(1) if confidence.item() CONFIDENCE_THRESHOLD: return { class: 无法判断, confidence: round(confidence.item(), 4), message: 图片置信度过低请确保上传的是清晰的舌象照片 } return { class: CLASS_NAMES[predicted.item()], confidence: round(confidence.item(), 4) }阈值0.6不是绝对的需要根据你的验证集调。做法是在验证集上统计正确分类样本的置信度分布和错误分类样本的置信度分布找一个能过滤掉大部分错误样本同时保留大部分正确样本的阈值。如果验证集准确率本身只有75%左右阈值设太高会导致大量正确样本被拒识用户体验很差。5. 避坑与排查舌象分类项目里最容易翻车的五个地方5.1 现象训练loss正常下降但验证准确率始终在随机水平附近原因标签和图片的对应关系错了。常见于用文件夹名做标签时文件夹嵌套层级不对或者用CSV标注时图片路径和标签列错位。另一个可能是数据加载时shuffle没开模型每次看到的都是同一类样本。解决写一个检查脚本随机抽10张图打印其路径和标签人工核对。另外确认DataLoader的shuffleTrue只在训练集开启验证集必须shuffleFalse。5.2 现象模型在验证集上准确率很高但实际拍照测试时完全不准原因验证集和训练集来自同一批采集数据拍摄条件、设备、光照都一致模型学到了采集条件相关的伪特征。实际使用时换了手机、换了光线分布偏移导致性能崩塌。解决采集数据时就有意识地分批次、分设备、分光照条件。训练集和验证集按采集批次划分而不是随机划分。如果条件允许留出一个完全独立的测试集用不同手机在不同时间段采集。5.3 现象某些类别的召回率极低几乎全被预测成另一个类别原因类别不平衡。比如“裂纹舌”只有50张图“正常舌象”有500张模型倾向于把少数类预测成多数类。另一个可能是这两个类别在视觉上确实难以区分标注时就有混淆。解决先用加权交叉熵或者Focal Loss给少数类更高权重。如果加权后仍然不行检查标注一致性把混淆的样本挑出来重新标注或剔除。数据量差距超过10倍时考虑对少数类做定向增强只对少数类做旋转、裁剪等。5.4 现象推理时GPU显存溢出或者CPU推理速度慢到无法接受原因模型输入尺寸设得太大比如448×448或者batch size设得太大。CPU推理慢通常是因为没有做模型量化或ONNX导出。解决舌象分类224×224足够不需要更大。推理时batch size设1到4即可。如果部署在CPU上用torch.quantization做动态量化模型体积能压到原来的1/4推理速度提升2到3倍。或者导出ONNX用onnxruntime推理通常比原生PyTorch快30%以上。5.5 现象FastAPI服务运行一段时间后内存持续增长原因每次请求都重新加载模型或者图像数据没有及时释放。PyTorch的CUDA缓存如果没有清空也会导致显存缓慢增长。解决模型在应用启动时加载一次全局复用。图像处理完及时del掉临时变量。如果用的是GPU定期调用torch.cuda.empty_cache()但不要每次请求都调会拖慢速度。更彻底的做法是用torch.no_grad()包裹推理过程并且把模型设为eval模式。6. 把舌象诊断系统做扎实的两个进阶方向第一个方向是引入注意力机制做可解释性。舌象诊断系统如果只输出一个类别和置信度医生或者老师会问“你凭什么这么判断”。解决办法是在CNN里加一个Grad-CAM或者CBAM模块推理时同时输出热力图标出模型最关注的舌体区域。如果模型关注的是舌尖而标签是“齿痕舌”齿痕通常在舌边说明模型学偏了。实现上用pytorch-grad-cam库几行代码就能出图from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image # 取EfficientNet最后一个卷积层作为目标层 target_layers [model.features[-1]] cam GradCAM(modelmodel, target_layerstarget_layers) grayscale_cam cam(input_tensorinput_tensor) visualization show_cam_on_image(original_image, grayscale_cam[0], use_rgbTrue)这个热力图在答辩时非常加分因为它把“黑匣子”变成了可以讨论的对象。如果热力图显示模型关注区域和中医理论不符你就有明确的优化方向——要么补充对应区域的训练数据要么在数据增强时加入区域裁剪。第二个方向是做成多标签分类。实际舌象往往同时呈现多个特征单标签分类会丢失信息。把最后一层改成输出N个logit用sigmoid激活损失函数换成BCEWithLogitsLoss。评估指标也要从accuracy换成mAP或者每个类别的F1。这个改动不大但系统实用性会明显提升。我自己做这类项目最大的教训是不要一上来就追求高准确率。先把数据采集、标注、训练、推理这条链路跑通哪怕模型只有70%的准确率只要链路是通的后面每一步优化都有明确的对比基线。最怕的是花两周调模型最后发现是标注文件里图片路径写错了。先跑通再调优这个顺序不能反。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →