拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Python舌苔图像识别实战:数据预处理、轻量模型与CPU部署

简介本资源是一套完整的基于Python机器学习的舌苔检测系统实现方案面向计算机视觉初学者、中医信息化研究者及毕业设计/课程设计学生解决传统舌诊主观性强、标准化难的问题。项目融合中医理论与深度学习技术采用EfficientNet网络构建图像分类模型可识别多种舌象特征并辅助体质辨识与健康状态评估。压缩包共113个文件含26个核心Python源码含训练、推理、UI模块、6个预训练.pth模型、47个标注数据及中间结果0类文件、7张示例舌象jpg图、4份Word版论文与设计文档整体大小为105.15MB目录结构清晰含TensorBoard日志文件events.out.tfevents便于复现训练过程。目前已有310人学习下载读者可直接运行完整流程、复现实验结果、理解数据标注逻辑并参考配套论文完成毕设答辩与技术阐述。1. 舌苔不是“拍张照就能认出来”的图像——为什么用 Python 做机器学习舌苔检测必须过数据、模型、部署三道硬坎临床上舌苔厚薄、颜色、润燥、分布是中医辨证的重要依据但人工判读主观性强、一致性低、难以量化。近年不少毕业设计和课程论文尝试用 Python 构建“舌苔检测系统”表面看只是调用 OpenCV 读图 scikit-learn 训练分类器实际落地时却普遍卡在三个真实瓶颈第一手机拍摄的舌象图光照不均、角度倾斜、背景杂乱传统图像增强极易丢失苔质纹理细节第二舌苔类别白厚、黄腻、薄白、剥落等样本极不均衡且同一类在不同人种、年龄、拍摄设备下形态差异巨大简单用 ResNet50 微调准确率常低于 68%第三系统要嵌入到基层中医问诊 App 或便携硬件中模型体积超 120MB、推理耗时 1.2s 就无法接受。本篇不讲论文框架或文献综述只聚焦一个能跑通、能调优、能部署的最小可行路径用 PyTorch 搭建轻量级多尺度特征融合网络在自建 2176 张标注舌象图上达到 89.3% 的细粒度分类准确率模型仅 14.7MBCPU 推理平均 386ms。适合本科毕设、医疗 AI 初创团队快速验证核心逻辑。2. 从原始舌象图到可训练数据集Python 图像预处理不是调几个 cv2.filter2D 就完事2.1 舌体区域自动裁剪——为什么必须绕开传统 HSV 阈值分割多数开源舌象数据集如 TongueDB已提供舌体掩膜但真实场景中用户上传的手机照片无掩膜。直接用 HSV 空间对舌面进行阈值分割如cv2.inRange(hsv, lower_red, upper_red)在强光/阴影下极易漏切舌边或误吸背景。我们采用基于 U-Net 的轻量级舌体分割模型tongue_unet_tiny.pth输入尺寸固定为 320×320输出单通道概率图再经 Otsu 二值化轮廓筛选获取最大连通域作为舌体 ROI。该方案在自采测试集上 Dice 系数达 0.921比 HSV形态学组合高 17.3%。import torch import cv2 import numpy as np from torchvision import transforms # 加载预训练舌体分割模型仅 1.2MB model torch.jit.load(tongue_unet_tiny.pth) model.eval() def crop_tongue_region(img_path): img cv2.imread(img_path) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 缩放并归一化 transform transforms.Compose([ transforms.ToTensor(), transforms.Resize((320, 320)), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) tensor_img transform(img_rgb).unsqueeze(0) # [1,3,320,320] with torch.no_grad(): pred_mask torch.sigmoid(model(tensor_img)).cpu().numpy()[0, 0] # [320,320] # 二值化与轮廓提取 mask_bin (pred_mask 0.5).astype(np.uint8) * 255 contours, _ cv2.findContours(mask_bin, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None largest_contour max(contours, keycv2.contourArea) x, y, w, h cv2.boundingRect(largest_contour) # 扩展 10% 边距并裁剪原图 pad_w, pad_h int(w*0.1), int(h*0.1) x1, y1 max(0, x - pad_w), max(0, y - pad_h) x2, y2 min(img.shape[1], x w pad_w), min(img.shape[0], y h pad_h) cropped img[y1:y2, x1:x2] return cv2.resize(cropped, (256, 256)) # 统一尺寸供后续分类 # 示例调用 tongue_img crop_tongue_region(user_upload.jpg) if tongue_img is not None: cv2.imwrite(tongue_roi.jpg, tongue_img)提示tongue_unet_tiny.pth是我们用 800 张人工精标舌象图训练的 TorchScript 模型结构为 4 层下采样4 层上采样无 BatchNorm 层以适配移动端。若需复现可从 TongueDB 下载原始图用 LabelMe 标注舌体轮廓后生成 mask再用torch.utils.data.Dataset加载训练。2.2 舌苔纹理增强——Lab* 空间局部对比度拉伸比直方图均衡更稳舌苔关键信息集中在 L*明度和 a*红绿通道b*黄蓝通道易受白平衡干扰。我们弃用全局 CLAHE改用分块自适应 Lab* 对比度调整将 ROI 分为 4×4 网格对每个子块单独计算 L* 通道的 5%-95% 百分位数线性映射至 [30, 90] 区间再合并回整图。实测该方法在 iPhone 12 和华为 P50 拍摄图上纹理保真度提升 22%而 CLAHE 在暗部易产生伪影。def enhance_tongue_texture(img_bgr): # 转 L*a*b* 并分离通道 lab cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB) l, a, b cv2.split(lab) # 分块处理 L 通道 h, w l.shape block_h, block_w h // 4, w // 4 enhanced_l np.zeros_like(l) for i in range(4): for j in range(4): y1, y2 i * block_h, min((i 1) * block_h, h) x1, x2 j * block_w, min((j 1) * block_w, w) block l[y1:y2, x1:x2] # 计算局部百分位数 p5, p95 np.percentile(block, [5, 95]) if p95 - p5 1e-3: # 避免除零 enhanced_block np.full_like(block, 60) else: enhanced_block np.clip((block - p5) / (p95 - p5) * 60 30, 30, 90) enhanced_l[y1:y2, x1:x2] enhanced_block # 合并通道并转回 BGR enhanced_lab cv2.merge([enhanced_l.astype(np.uint8), a, b]) return cv2.cvtColor(enhanced_lab, cv2.COLOR_LAB2BGR) # 应用增强 enhanced_img enhance_tongue_texture(tongue_img)注意此增强不改变 a*/b* 通道避免引入虚假色偏。参数[30,90]可根据设备校准微调——若医院内窥镜图偏亮可设为[40,95]若家用手机图偏暗则用[25,85]。2.3 数据增广策略表针对舌苔小样本的 7 种有效变换组合舌苔类别样本量差异极大白厚苔 823 张剥落苔仅 142 张单纯过采样会加剧过拟合。我们采用“类别感知增广”对稀有类剥落、镜面、染苔启用全部 7 种变换对常见类白厚、薄白仅用前 4 种。所有变换均在 GPU 上用 Albumentations 实现单图耗时 12ms。变换类型参数设置适用类别作用说明随机旋转±15°全部模拟拍摄角度偏差中心裁剪缩放0.85~0.95 倍全部强化舌心区域特征高斯模糊kernel_size3, sigma(0.1,1.5)全部模拟镜头轻微失焦颜色抖动brightness0.1, contrast0.1, saturation0.1, hue0.02全部抵抗白平衡漂移网格畸变alpha10, sigma0.03稀有类模拟舌体自然卷曲随机阴影num_shadows2, shadow_dimension50稀有类模拟口腔内光照不均噪声注入gauss_var0.001, poisson_lambda0.01稀有类提升模型鲁棒性import albumentations as A from albumentations.pytorch import ToTensorV2 # 定义两类增广管道 common_aug A.Compose([ A.Rotate(limit15, p0.5), A.RandomResizedCrop(height224, width224, scale(0.85, 0.95), p0.8), A.GaussianBlur(blur_limit(3, 3), sigma_limit(0.1, 1.5), p0.3), A.ColorJitter(brightness0.1, contrast0.1, saturation0.1, hue0.02, p0.5), ToTensorV2() ]) rare_aug A.Compose([ A.Rotate(limit15, p0.7), A.RandomResizedCrop(height224, width224, scale(0.85, 0.95), p0.95), A.GaussianBlur(blur_limit(3, 3), sigma_limit(0.1, 1.5), p0.6), A.ColorJitter(brightness0.15, contrast0.15, saturation0.15, hue0.03, p0.7), A.GridDistortion(num_steps5, distort_limit0.1, p0.4), A.RandomShadow(num_shadows_lower2, num_shadows_upper2, shadow_dimension50, p0.3), A.GaussNoise(var_limit(0.001, 0.002), p0.4), ToTensorV2() ])3. 舌苔分类模型构建为什么不用 ViT 或 DETR而选改进型 EfficientNet-B13.1 模型选型依据在精度、速度、显存间的三重权衡ViT 在 ImageNet 上精度高但舌象图分辨率仅 224×224其全局注意力机制对局部苔质纹理如颗粒感、裂纹建模效率低DETR 更不适合——它依赖目标检测框而舌苔是整体纹理分类任务强行加 bbox head 会引入冗余参数。我们最终选用 EfficientNet-B1MBConv 结构原因有三① 深度可分离卷积天然适合纹理特征提取② 复合缩放系数φ1.0使参数量仅 5.3MGPU 显存占用 320MB③ 通过添加多尺度特征融合模块MSFF在保持推理速度前提下提升细粒度判别力。3.1.1 MSFF 模块设计融合 stage2/stage3/stage4 的 3 个特征图标准 EfficientNet-B1 的 stage240×40、stage320×20、stage410×10特征图分别含不同尺度纹理信息。MSFF 模块先对三者做 1×1 卷积降维至 64 通道再用双线性插值统一至 10×10 尺寸拼接后经 3×3 卷积 ReLU 输出融合特征。该设计比简单相加提升 2.1% 准确率且不增加推理延迟。import torch.nn as nn import torch.nn.functional as F class MSFF(nn.Module): def __init__(self, in_channels_list[24, 40, 112]): # stage2/3/4 输出通道 super().__init__() self.proj2 nn.Conv2d(in_channels_list[0], 64, 1) self.proj3 nn.Conv2d(in_channels_list[1], 64, 1) self.proj4 nn.Conv2d(in_channels_list[2], 64, 1) self.fusion nn.Conv2d(64*3, 128, 3, padding1) def forward(self, x2, x3, x4): # x2: [B,24,40,40], x3: [B,40,20,20], x4: [B,112,10,10] f2 F.interpolate(self.proj2(x2), size(10,10), modebilinear) f3 F.interpolate(self.proj3(x3), size(10,10), modebilinear) f4 self.proj4(x4) # 已是 10x10 fused torch.cat([f2, f3, f4], dim1) # [B,192,10,10] return self.fusion(fused) # [B,128,10,10] # 在 EfficientNet-B1 backbone 后接入 class TongueClassifier(nn.Module): def __init__(self, num_classes6): super().__init__() self.backbone torch.hub.load(nateraw/efficientnet-pytorch, efficientnet_b1, pretrainedTrue) # 替换最后的 classifier 层 self.backbone.classifier nn.Identity() self.msff MSFF() self.classifier nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.3), nn.Linear(128, 256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, num_classes) ) def forward(self, x): # 获取 backbone 各 stage 输出 x self.backbone.conv_stem(x) x self.backbone.bn1(x) x self.backbone.act1(x) # stage1 x self.backbone.blocks[0](x) # stage2 x2 self.backbone.blocks[1](x) # 40x40 # stage3 x3 self.backbone.blocks[2](x2) # 20x20 # stage4 x4 self.backbone.blocks[3](x3) # 10x10 fused self.msff(x2, x3, x4) return self.classifier(fused)提示torch.hub.load自动下载预训练权重若内网环境需提前torch.hub.set_dir(/path/to/cache)。nn.Identity()替换原 classifier 是为了保留 backbone 特征提取能力避免从头训练导致过拟合。3.2 训练策略Focal Loss 余弦退火 梯度裁剪的组合为何比 CrossEntropy 更有效舌苔类别严重不平衡白厚:黄腻:薄白:剥落:镜面:染苔 823:412:387:142:128:284标准 CrossEntropy 会使模型偏向多数类。我们采用 α-balanced Focal Lossγ2.0, α[0.3,0.2,0.2,0.8,0.8,0.5]其中稀有类 α 值更高强制模型关注难样本。学习率用余弦退火初始 1e-3终值 1e-5并在每个 batch 后执行梯度裁剪max_norm1.0防止因小样本批次梯度爆炸。from torch import nn import torch class FocalLoss(nn.Module): def __init__(self, alpha1, gamma2, reductionmean): super().__init__() self.alpha alpha self.gamma gamma self.reduction reduction def forward(self, inputs, targets): ce_loss F.cross_entropy(inputs, targets, reductionnone) pt torch.exp(-ce_loss) focal_weight (self.alpha * (1-pt)**self.gamma) loss focal_weight * ce_loss if self.reduction mean: return loss.mean() elif self.reduction sum: return loss.sum() else: return loss # 训练循环关键片段 optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50) criterion FocalLoss(alphatorch.tensor([0.3,0.2,0.2,0.8,0.8,0.5]), gamma2.0) for epoch in range(50): for batch in train_loader: imgs, labels batch outputs model(imgs) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step()注意α 值需按类别频率反比设置——剥落苔142 张α0.8染苔284 张α0.5避免 α 过大导致模型拒绝学习多数类。γ2.0 是经验最优值γ3 会使损失函数过于平滑收敛变慢。4. 模型部署与性能验证如何用 ONNX OpenVINO 在 CPU 上跑出 386ms 推理4.1 导出 ONNX 模型并验证等价性——这步跳过会导致部署结果错乱PyTorch 模型直接转 ONNX 时若未固定输入尺寸或未禁用 dropout/batchnorm导出模型会与训练版行为不一致。我们指定input_shape(1,3,224,224)并用torch.onnx.export的dynamic_axes参数声明 batch 维度可变同时用torch.no_grad()和model.eval()确保 BN 层使用 running_mean/var。# 导出前确保模型处于 eval 模式 model.eval() dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, tongue_classifier.onnx, export_paramsTrue, opset_version12, do_constant_foldingTrue, input_names[input], output_names[output], dynamic_axes{ input: {0: batch_size}, output: {0: batch_size} } ) # 验证 ONNX 与 PyTorch 输出一致性 import onnxruntime as ort ort_session ort.InferenceSession(tongue_classifier.onnx) torch_out model(dummy_input).detach().numpy() ort_out ort_session.run(None, {input: dummy_input.numpy()})[0] print(Max diff:, np.max(np.abs(torch_out - ort_out))) # 应 1e-5提示opset_version12是 OpenVINO 2023.0 支持的最高版本若用旧版 OpenVINO 需降为 11。do_constant_foldingTrue可折叠常量节点减小 ONNX 文件体积本例从 18.2MB 降至 14.7MB。4.2 OpenVINO 优化流程INT8 量化后精度仅降 0.7%速度提升 2.3 倍原始 ONNX 模型在 Intel i5-1135G7 CPU 上推理耗时 920ms。经 OpenVINO 的 Post-Training Optimization ToolkitPOT进行 INT8 量化后耗时降至 386msTop-1 准确率从 89.3% → 88.6%。关键在于校准数据集需覆盖各类舌苔——我们用验证集中 200 张图每类至少 30 张作校准而非随机采样。# 安装 OpenVINO 工具链 pip install openvino-dev2023.0.1 # 量化命令需准备校准数据集目录 calib_dataset/ pot \ --config pot_config.json \ --input_model tongue_classifier.onnx \ --output_dir quantized_model \ --engine_type CPU # pot_config.json 内容 { model: { model_name: tongue_classifier, model: ./tongue_classifier.onnx, weights: }, engine: { device: CPU, stat_requests_number: 2, eval_requests_number: 2 }, compression: { algorithms: [ { name: DefaultQuantization, params: { target_device: CPU, preset: performance, stat_subset_size: 200 } } ] } }4.2.1 量化后推理代码OpenVINO Runtime API 最简调用from openvino.runtime import Core import numpy as np import cv2 # 加载量化后模型 core Core() model core.read_model(quantized_model/tongue_classifier.xml) compiled_model core.compile_model(model, CPU) # 预处理同训练时 def preprocess_image(img_path): img cv2.imread(img_path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (224, 224)) img img.astype(np.float32) / 255.0 img np.transpose(img, (2, 0, 1)) # HWC→CHW return np.expand_dims(img, axis0) # 添加 batch 维度 # 推理 input_tensor preprocess_image(test_tongue.jpg) result compiled_model([input_tensor])[compiled_model.output(0)] pred_class np.argmax(result) confidence np.max(result) print(fPredicted class: {pred_class}, Confidence: {confidence:.3f})注意OpenVINO 的compile_model默认启用 CPU 多线程若需限制核数如嵌入式设备加参数{INFERENCE_NUM_THREADS: 2}。np.transpose和np.expand_dims必须与训练时完全一致否则通道顺序错位导致结果全错。5. 实战技巧如何用 Grad-CAM 定位模型“到底在看舌苔哪部分做决策”5.1 修改模型结构注入钩子——无需重训即可可视化热力图Grad-CAM 要求获取最后一层卷积输出及对应梯度。我们在 MSFF 模块后插入钩子捕获fused特征图及其梯度。注意钩子必须在model.eval()前注册且requires_gradTrue需手动开启。class GradCAM: def __init__(self, model): self.model model self.gradients None self.features None # 注册钩子到 MSFF 输出 def save_gradient(grad): self.gradients grad def save_features(module, input, output): self.features output # 找到 MSFF 模块假设它是 model.msff target_layer model.msff target_layer.register_forward_hook(save_features) target_layer.register_backward_hook(lambda m, g_in, g_out: save_gradient(g_out[0])) def __call__(self, input_img, target_classNone): self.model.eval() input_img.requires_grad_(True) output self.model(input_img) if target_class is None: target_class output.argmax(dim1).item() # 清零梯度并反向传播 self.model.zero_grad() output[0, target_class].backward() # 计算权重 weights torch.mean(self.gradients, dim(2, 3), keepdimTrue) cam torch.relu(torch.sum(weights * self.features, dim1, keepdimTrue)) # 上采样到原图尺寸 cam F.interpolate(cam, size(224, 224), modebilinear) cam cam.squeeze().cpu().numpy() cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8) return cam # 使用示例 grad_cam GradCAM(model) input_tensor preprocess_image(test_tongue.jpg) # [1,3,224,224] cam_map grad_cam(input_tensor, target_class0) # 白厚苔 # 叠加热力图 img cv2.imread(test_tongue.jpg) img cv2.resize(img, (224, 224)) heatmap cv2.applyColorMap((cam_map * 255).astype(np.uint8), cv2.COLORMAP_JET) result cv2.addWeighted(img, 0.5, heatmap, 0.5, 0) cv2.imwrite(gradcam_result.jpg, result)5.2 解读热力图的三个临床验证点生成的 Grad-CAM 热力图不是装饰而是模型可信度的诊断工具。我们要求学生在论文中必须验证以下三点验证点合格标准不合格表现修正方向舌心聚焦≥70% 热力值集中在舌体中央 1/3 区域热力分散于舌边或背景检查 ROI 裁剪是否准确增强是否过度拉伸边缘苔质对应白厚苔热力区呈均匀弥散剥落苔热力区呈岛屿状斑块白厚苔热力集中在舌根剥落苔热力覆盖全舌调整 MSFF 中 stage2/stage3 权重强化局部纹理分支无背景响应背景区域热力值 0.1背景出现明显红色区块检查数据增广是否引入背景噪声或 U-Net 分割模型漏切例如若某张“黄腻苔”图的热力图集中在舌边而非舌中说明模型可能把“舌边湿润反光”误判为“腻苔”此时应增加“舌边阴影”增广并在损失函数中为舌中区域加 spatial attention mask。提示Grad-CAM 仅反映模型关注区域不保证判断正确。真正可靠的验证是——让 3 位主治中医师盲评 50 张热力图统计其与模型高亮区域的一致率。我们实测该一致率达 83.6%证明模型学到了临床可解释的特征。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门