资讯详情 舌象检测实战:OpenCV+MediaPipe实现人脸定位与舌苔分析
📅 2026/10/7 5:54:01
简介基于Python实现的舌象/舌苔检测项目主要用于判断输入图像是否为面部、伸舌头或翘舌头状态面向计算机视觉方向的学生、研究者及开发者适合作为毕业设计、课程设计、项目演示甚至入门练习的源代码参照。整个资源共包含36个文件主体为8个Python脚本覆盖人脸检测、嘴部检测、舌头检测、HSV颜色分析、区域分割等功能模块辅以18张JPG测试图、2个XML级联分类器、JSON配置及Markdown说明文档压缩包整体约21.48MB目录与文件组织较为清晰。目前已有164人浏览/学习。项目代码经实际测试运行成功所附说明文档和测试图片可帮助快速理解检测流程与调参思路也可在原始代码基础上继续修改扩展出其他图像识别功能对入门图像处理与计算机视觉实践来说是一份兼具可读性和改扩建空间的参考样例。1. 舌象/舌苔检测不是玄学先让程序分清“脸”和“舌头”舌象/舌苔检测这个项目听起来像是用深度学习识别舌头颜色但真正落地时你会发现最难的不是“舌苔是黄是白”而是先用代码确认“镜头里这个人到底伸舌头了没有”。我见过不止一个团队直接用 MobileNet 对整张图做分类结果在真实光照下把嘴唇、牙齿甚至下巴都当成了舌头准确率很难突破 70%。反过来把任务拆成“人脸检测 → 嘴部 ROI → 伸舌/翘舌分类 → 舌苔颜色统计”四段每一段用最简单可靠的手段反而能稳定跑到 90% 以上。这套方案用 Python 就能实现依赖只有 numpy、opencv-python、mediapipe、tensorflow数据量不大时 CPU 也能训练。适合正在做中医数字化、健康打卡 App或者只是想给舌象检测这个方向写一个可用原型的开发者。后面几章我会直接给出可复现代码以及我从暗光、反光、摄像头色差这些坑里爬出来的经验。2. 整体方案与模型选型把舌象检测拆成“人脸状态舌苔”三段2.1 舌象检测与通用目标检测的差异别一上来就训 YOLO舌象检测之所以特殊是因为舌体和嘴唇的颜色、纹理在常见光照下非常接近而且每个人舌色差异很大。如果像通用目标检测那样用 YOLO 把舌头框出来你需要准备几千张带框的舌象图标框时舌头与下唇的边界会让标注员吵起来。更麻烦的是翘舌头是一个三维动作从正面看往往只改变舌尖的局部弧度边界框本身提供不了这么多信息。所以在实际项目里我更喜欢把问题切成三段先用传统人脸检测把镜头里有没有脸这个前置条件解决掉再用一个轻量分类器判断舌头状态最后用颜色统计做舌苔分析。这样做的好处是每一段都能单独调试、单独替换。比如人脸检测翻车了你不需要重新训练后面的分类器摄像头换了你只需要重新标定舌苔分析的 HSV 阈值。下面是一段总流程框架先感受一下结构。import cv2 import numpy as np from face_detector import FaceDetector from mouth_roi import MouthROI from tongue_classifier import TongueClassifier def run_pipeline(image_path): # 全部按 RGB 处理OpenCV 读进来是 BGR 需要转 img cv2.imread(image_path) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 1. 人脸检测 face_detector FaceDetector(conf_threshold0.5) face_box face_detector.detect(img_rgb) if face_box is None: return {label: no_face} # 2. 基于 MediaPipe 关键点裁剪嘴部 ROI mouth_roi MouthROI() roi mouth_roi.extract(img_rgb, face_box) # 3. 三分类face / tongue_out / tongue_up cls TongueClassifier(mobilenetv2_3class.h5) label, proba cls.predict(roi) # 4. 只有在伸舌或翘舌时才做舌苔分析 if label ! face: features analyze_tongue_coating(roi) return {label: label, proba: proba, features: features} return {label: label, proba: proba}这段代码里每个函数都是单文件方便你逐个替换。conf_threshold0.5是实际项目里比较稳妥的起点如果漏检率高了可以降到 0.35但随之而来的是更多人脸区域被误检成脸后面 ROI 提取时要多做一层有效性校验。舌苔分析不是每帧都要做只有状态分类结果不是 face 时才执行这样能省掉大量无谓计算。2.2 模型选型对比为什么是 OpenCV DNN MobileNetV2 HSV我把备选方案放在一起比过选择并不复杂。环节备选方案选型理由人脸检测Haar Cascade vs OpenCV DNN vs MediaPipe Face DetectionHaar 对侧脸和暗光太敏感翻车率太高。OpenCV DNN 的 SSD 模型体积小CPU 上也能跑作为第一步够用。MediaPipe Face Detection 也行但后面反正要用 Face Mesh 关键点所以 DNN 负责粗定位Face Mesh 负责精确定位更合理。嘴部定位单用 DNN 人脸框 vs Face Mesh 关键点人脸框只给一个矩形嘴部位置全靠猜。Face Mesh 能给出 468 个关键点取嘴部轮廓生成 ROI 更稳定。舌头状态分类几何规则 vs 端到端 CNN伸舌头和翘舌头的形态差异大规则法只能处理“舌尖超过下唇”这种简单场景翘舌头的卷曲程度、方向变化多用迁移学习微调 MobileNetV2 比手工设计特征可靠得多。舌苔分析语义分割 vs 颜色阈值舌苔本身不需要语义分割那么细致的边界HSV 颜色分桶在固定光照下足够稳定而且可解释性强医生认可。这里特别提醒一句不要试图用一个 MobileNet 对完整图像直接分类。因为完整图像里背景、头发、衣服占的面积远大于舌头分类器很容易学着靠着背景特征蒙对而不是真的看到了镜头里的舌头。我吃过这个亏后来把分类器的输入切成嘴部 ROI准确率才有实质提升。2.3 MediaPipe Face Mesh 的输入要求和关键点基础MediaPipe Face Mesh 接收 RGB 图像输出 468 个面部关键点。它需要你设置static_image_modeTrue表示处理单张图片而不是视频流。另一个参数min_detection_confidence建议设在 0.5 以上因为嘴部 ROI 如果定位错了后面全错。import mediapipe as mp mp_face_mesh mp.solutions.face_mesh def create_face_mesh(): return mp_face_mesh.FaceMesh( static_image_modeTrue, max_num_faces1, min_detection_confidence0.5, min_tracking_confidence0.5 )min_tracking_confidence在静态模式下不生效但保留也无所谓。max_num_faces 设为 1 很关键因为舌象检测永远只针对镜头里的主脸如果一个人吞吞吐吐进入画面多张脸会互相干扰。代码要封装成独立模块不要到处初始化 FaceMesh它有比较大的初始化开销一次性创建实例后续复用。注意 MediaPipe 文档里强调Face Mesh 对光照和脸的角度比较敏感。如果镜头里的人侧着脸嘴部关键点的精确度会下降。所以我在实际流程里会用第 3 章的 DNN 人脸框先粗筛一次框内人脸面积太小时直接放弃避免把远处的路人脸当成检测目标。3. 人脸检测与嘴部 ROI 提取用 OpenCV DNN 跑通前置链路3.1 用 OpenCV DNN 做第一层人脸检测OpenCV DNN 加载的是 TensorFlow 导出的 SSD 人脸检测模型文件就两个opencv_face_detector_uint8.pb和opencv_face_detector.pbtxt。这两个文件在 OpenCV 官方仓库的 samples/dnn 目录下能找得到不需要自己训练。很多人看到.pb文件头就大了实际上只要放在项目models/目录下代码很简笔。import cv2 import numpy as np face_net cv2.dnn.readNetFromTensorflow( models/opencv_face_detector_uint8.pb, models/opencv_face_detector.pbtxt ) def detect_face(img_rgb, conf_threshold0.5): h, w img_rgb.shape[:2] # 注意blobFromImage 默认输入是 BGR但前面已经转成 RGB # 这里需要把 mean 顺序也按 RGB 调整 blob cv2.dnn.blobFromImage( cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR), 1.0, (300, 300), (104, 177, 123) ) face_net.setInput(blob) detections face_net.forward() best_box None best_conf 0.0 for i in range(detections.shape[2]): conf detections[0, 0, i, 2] if conf conf_threshold: continue x1 int(detections[0, 0, i, 3] * w) y1 int(detections[0, 0, i, 4] * h) x2 int(detections[0, 0, i, 5] * w) y2 int(detections[0, 0, i, 6] * h) if conf best_conf: best_conf conf best_box (x1, y1, x2, y2) return best_box这段代码里(104, 177, 123)是模型训练时的 BGR 均值不能随便改。输入尺寸固定 300x300这个值越小速度越快但精度会掉如果你的 CPU 性能吃紧可以降到 200x200blobFromImage里对应改成(200, 200)。detections 的输出结构是[batch, 1, max_detections, 7]其中第 3 到第 6 位是归一化后的框坐标所以乘上原图像宽高才是像素坐标。为什么不用 HaarHaar 级联模型在视频里会出现明显的抖动有时候一帧检出人脸一帧丢失。换成 DNN 之后虽然每一帧耗时高一点但召回率稳定得多。后续接 MediaPipe 时Face Mesh 需要人脸基本在画面中心附近DNN 这一层把明显不正的人脸过滤掉能减少很多无效计算。3.2 用 Face Mesh 提取嘴部轮廓关键点人脸框到手后如果直接把整张人脸图送进分类器背景干扰仍然太大。我一般会把人脸框裁剪出来再跑 MediaPipe这样关键点定位更快也更准。媒体管道 FaceMesh 的关键点坐标是归一化的裁剪区域的宽高要参与换算。import mediapipe as mp mp_face_mesh mp.solutions.face_mesh face_mesh mp_face_mesh.FaceMesh( static_image_modeTrue, max_num_faces1, min_detection_confidence0.5 ) # 常用嘴部外轮廓索引覆盖上唇和下唇 MOUTH_OUTLINE_IDX [ 61, 146, 91, 181, 84, 17, 314, 405, 321, 375, 78, 191, 95, 88, 40, 185 ] def get_mouth_points(img_rgb, face_box): x1, y1, x2, y2 face_box face_roi img_rgb[y1:y2, x1:x2] results face_mesh.process(face_roi) if not results.multi_face_landmarks: return None landmarks results.multi_face_landmarks[0].landmark h, w face_roi.shape[:2] points [] for i in MOUTH_OUTLINE_IDX: lx int(landmarks[i].x * w) x1 ly int(landmarks[i].y * h) y1 points.append((lx, ly)) return points这里选择的 16 个索引是我在实际项目里反复比过的它们基本覆盖嘴唇外轮廓的上下左右四个端点。如果你担心不够也可以直接取所有脸部关键点里 y 坐标最大的那批点但没必要索引固定更快。Face Mesh 输出的 landmark 是相对face_roi的归一化坐标所以必须乘w、h再加上x1、y1映射回原图。实测下来如果人脸框本身偏大嘴部关键点在 ROI 里占比小精度会稍微下降。所以我会在detect_face返回框时做一次压缩把框向中心收缩 10%让脸在 face_roi 里更饱满。这个修正逻辑简单但对后续关键点稳定性提升很明显。3.3 生成带扩展的嘴部 ROI嘴部轮廓点拿到之后直接用最小外接矩形框住这些点是不够的。因为伸舌头时舌体可能伸到矩形外翘舌头时舌尖可能卷向上唇方向。我一般会对高度方向做额外扩展宽度方向少扩避免引入太多脸颊背景。def extract_mouth_roi(img_rgb, points, expand_ratio0.3): xs [p[0] for p in points] ys [p[1] for p in points] x_min, x_max min(xs), max(xs) y_min, y_max min(ys), max(ys) w x_max - x_min h y_max - y_min # 宽度方向扩大 20%高度方向扩大 80% # 高度方向必须留足舌根和舌尖伸展空间 expand_w int(w * (1 0.2 * 2)) expand_h int(h * (1 0.8 * 2)) cx (x_min x_max) / 2 cy (y_min y_max) / 2 x1 max(0, int(cx - expand_w / 2)) y1 max(0, int(cy - expand_h / 2)) x2 min(img_rgb.shape[1], int(cx expand_w / 2)) y2 min(img_rgb.shape[0], int(cy expand_h / 2)) return img_rgb[y1:y2, x1:x2]这里expand_ratio参数我拆成了两个方向处理虽然函数名里传了个expand_ratio实际内部用的是高度 0.8、宽度 0.2。为什么要差别对待因为这轮 ROI 后面要进分类器如果宽度扩展太多会把两边脸颊皮肤包进来而脸颊颜色和舌色在某些光照下非常接近分类器容易被干扰。高度方向则不同舌头伸展的空间主要在下方向和上方向多留一些会减少误切。完整的嘴部 ROI 提取应保存成单独文件供第 4 章的数据集准备脚本调用。你可以让采集助手把人脸对准镜头自动人脸框出现后按空格保存当前帧脚本自动吐出一批裁剪好的嘴部 ROI。这样收集训练数据就快多了。4. 面部/伸舌头/翘舌头三分类用 MobileNetV2 微调一个靠谱的分类器4.1 数据集组织按会话划分别按帧划分准备训练数据时最容易犯的一个错误是从同一段视频里连续抽几百帧然后随机分配到训练集和验证集。这样验证集的图像和训练集高度相关模型其实是在“背答案”换一个摄像头立刻现原形。正确的做法是按“采集会话”划分。比如张三今天拍的 10 段视频前 8 段进训练集后 2 段进验证集李四明天拍的视频全部进独立测试集。这样才能真正检验模型的泛化能力。文件目录结构建议这样data/ ├── train/ │ ├── face/ │ ├── tongue_out/ │ └── tongue_up/ ├── val/ │ ├── face/ │ ├── tongue_out/ │ └── tongue_up/ └── test/ ├── face/ ├── tongue_out/ └── tongue_up/每个类别下面放从嘴部 ROI 脚本抠出来的图统一保存为 JPG大小不等没关系后面预处理会 resize 到 224x224。类别定义必须明确face 是正常闭嘴或说话但舌头不外露tongue_out 是舌头自然伸出口腔能看到舌体tongue_up 是舌尖明显上翘卷向硬腭方向。如果一个样本你觉得模棱两可宁可直接删掉也不要硬塞进去边界样本只会放大噪声。4.2 数据增强舌象不能水平翻转用 Keras 的 ImageDataGenerator 做增强时有一个医学细节容易被忽略舌象的左右在中医诊断里有不同含义所以训练时不要开启horizontal_flipTrue。开了之后模型会学到左右对称的特性但实际上舌体左右颜色可能不对称这会导致推理结果被无意义的干扰影响。from tensorflow.keras.preprocessing.image import ImageDataGenerator train_datagen ImageDataGenerator( rescale1.0 / 255, rotation_range10, width_shift_range0.1, height_shift_range0.1, brightness_range[0.9, 1.1], zoom_range0.1, horizontal_flipFalse, fill_modenearest ) val_datagen ImageDataGenerator(rescale1.0 / 255) train_generator train_datagen.flow_from_directory( data/train, target_size(224, 224), batch_size32, class_modecategorical, classes[face, tongue_out, tongue_up] ) val_generator val_datagen.flow_from_directory( data/val, target_size(224, 224), batch_size32, class_modecategorical, classes[face, tongue_out, tongue_up] )brightness_range设为[0.9, 1.1]是经验值太大会让模型依赖亮度信息在暗光下反而翻车。rotation_range10是因为舌头动作有轻微的角度偏移不用给太大的旋转。如果你有不同摄像头采集的数据可以在增强里加一点channel_shift_range模拟色温差异但注意不要破坏舌苔颜色本身的意义——舌色是诊断信息随机通道偏移会导致模型忽略颜色。4.3 模型构建冻结骨干先训再解冻微调MobileNetV2 是很好的选择它在 ImageNet 上预训练过的特征对纹理和边缘敏感而我们只需要把最后的分类层换成三类。训练分两步先冻结骨干只训练新加的 Dense 层等 loss 不再下降再解冻后半部分骨干用小学习率微调。这是最快的稳定路径。from tensorflow.keras.applications import MobileNetV2 from tensorflow.keras.models import Model from tensorflow.keras.layers import Dense, GlobalAveragePooling2D, Dropout from tensorflow.keras.optimizers import Adam base MobileNetV2(input_shape(224, 224, 3), include_topFalse, weightsimagenet) x base.output x GlobalAveragePooling2D()(x) x Dropout(0.3)(x) out Dense(3, activationsoftmax)(x) model Model(inputsbase.input, outputsout) base.trainable False model.compile( optimizerAdam(learning_rate1e-3), losscategorical_crossentropy, metrics[accuracy] ) model.fit( train_generator, steps_per_epochtrain_generator.samples // 32, validation_dataval_generator, validation_stepsval_generator.samples // 32, epochs20, verbose1 ) # 解冻最后 20 层骨干用小学习率微调 base.trainable True for layer in base.layers[:-20]: layer.trainable False model.compile( optimizerAdam(learning_rate1e-5), losscategorical_crossentropy, metrics[accuracy] ) model.fit( train_generator, steps_per_epochtrain_generator.samples // 32, validation_dataval_generator, validation_stepsval_generator.samples // 32, epochs10, verbose1 ) model.save(models/tongue_3class.h5)学习率从1e-3降到1e-5是必须的因为解冻后骨干层的参数已经有了良好初始化用大学习率会把预训练权重冲坏。Dropout 0.3 对两三千张的小数据集是合理的如果验证集准确率明显低于训练集把 Dropout 提到 0.5。steps_per_epoch的取整问题如果 samples 不能被 batch_size 整除Keras 会丢弃末尾多余样本这在小数据集上会浪费数据可以把 batch_size 改为 16 或者 64保证整除。4.4 推理封装输入预处理别搞错模型训练里用的是rescale1./255但推理时不能只做除法因为 MobileNetV2 的预训练输入要求 ImageNet 的 mean/std 归一化。Keras 提供了preprocess_input直接调它就行。如果你自己写 mean 减法必须使用 ImageNet 的 RGB mean 值而不是(104, 177, 123)那个 BGR mean 值。我在这里踩过坑训练时 acc 高推理时全预测成 face就是因为预处理不一致。import numpy as np from tensorflow.keras.models import load_model from tensorflow.keras.applications.mobilenet_v2 import preprocess_input class TongueClassifier: def __init__(self, model_path): self.model load_model(model_path) self.labels [face, tongue_out, tongue_up] def predict(self, roi_bgr): roi_rgb cv2.cvtColor(roi_bgr, cv2.COLOR_BGR2RGB) roi_resized cv2.resize(roi_rgb, (224, 224)).astype(np.float32) roi_prep preprocess_input(roi_resized) roi_batch np.expand_dims(roi_prep, axis0) proba self.model.predict(roi_batch, verbose0)[0] idx int(np.argmax(proba)) return self.labels[idx], probapreprocess_input会把像素值按通道做近似归一化它要求输入是 RGB 且范围 [0, 255]所以这里先转 RGB 再 resize。如果你在图省事直接传入 BGR模型会学到错误颜色映射换台机器就失效。推理时verbose0关掉进度条在视频流里能少刷屏。5. 避坑指南舌象检测落地最容易翻车的 5 个地方舌象检测的坑不解决模型再准也白搭。下面这 5 个问题每一个我都加过班按“现象、原因、解决”记下来供你对照排查。5.1 暗光下把嘴唇当成舌头现象晚上室内拍张嘴闭嘴都容易识别成舌象分类器置信度还很高。原因相机自动白平衡在低照度下把红色压低嘴唇和舌头的 HSV 范围高度重合模型学到的是“暗红色区域 舌头”而不是真正的形态特征。解决ROI 输入分类器前先做光照归一化我常用 CLAHE 加在 LAB 空间的 L 通道上然后合并回 RGB。另一个办法是训练时收紧 brightness_range 到[0.9, 1.1]避免模型依赖绝对亮度。def normalize_illumination(roi_bgr): lab cv2.cvtColor(roi_bgr, cv2.COLOR_BGR2LAB) l, a, b cv2.split(lab) clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) l clahe.apply(l) lab cv2.merge((l, a, b)) return cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)注意 CLAHE 的clipLimit不要超过 3.0否则舌头的纹理会被抹平后面做舌苔分析容易失真。5.2 伸舌头检测框总是截断舌尖现象舌体明显伸出来了但分类器输出 tongue_out 的概率不稳定有时候甚至给出 face。原因嘴部 ROI 的高度扩展不够舌尖超出了 ROI 下边界。尤其对于宽度较窄的脸固定扩展比会失效。解决不要用固定扩展比例改用动态扩展。先通过嘴部关键点算出嘴高h然后让向下扩展量为0.8 * h。如果舌头的颜色已经出现在 ROI 边缘就进一步扩大。我在实际代码里加了一个简单检查ROI 底部边缘若存在舌色像素则把 ROI 向下扩展 10% 重新裁剪。5.3 视频里翘舌头一闪一闪现象同一个动作上一帧是 tongue_up下一帧变成 tongue_out再下一帧又回到 tongue_up结论没法看。原因翘舌头是个渐进动作正面 2D 图像上前后帧差异极小分类器在决策边界附近抖动。解决不要对每一帧做最终判定。我采用一个长度为 5 的滑动窗口对窗口内每一帧的分类结果取众数或者要求连续 3 帧都判定为 tongue_up 才触发一次状态变化。这个技巧不只适用于舌象检测任何“状态判定”类任务都该使用。from collections import deque class TemporalSmoother: def __init__(self, window_size5, trigger_count3): self.buffer deque(maxlenwindow_size) self.trigger_count trigger_count def update(self, label): self.buffer.append(label) if len(self.buffer) self.buffer.maxlen: return None counts {l: self.buffer.count(l) for l in set(self.buffer)} majority max(counts, keycounts.get) if counts[majority] self.trigger_count: return majority return Nonetrigger_count设为 3表示一个状态至少要有 3 帧支持才输出。这样既避免抖动也不会延迟太久。5.4 牙齿反光被当成白苔现象舌苔分析结果里白苔覆盖率突然飙升但肉眼看到舌头只是有一道牙齿反光。原因牙齿表面湿润产生镜面反射HSV 里 Value 很高、饱和度很低正好落在“白苔”的阈值范围内。解决在 HSV 分割前先排除高光像素(V 240) (S 30)的像素直接置为无效。另外舌苔分析只统计舌头区域而不是整个 ROI。按经验把 ROI 从纵向分成三份上 1/3 是舌尖附近中 1/3 是舌体中部牙齿通常不会出现在这两个位置。5.5 换摄像头准确率暴跌现象训练时验证集 accuracy 90% 以上换一台摄像头拍实测降到 60%。原因训练集和验证集来自同一摄像头甚至同一段视频抽帧颜色响应、白平衡、锐度都高度一致模型学到了特定摄像头的色彩偏置。解决第一按采集会话划分数据而不是按帧随机划分第二训练时加入channel_shift_range10模拟不同色偏第三在验证阶段强制使用一台没参与过训练的摄像头拍摄视频。这个坑是数据分层的问题不是模型结构问题别急着换模型。6. 舌苔颜色与纹理分析把检测结果变成可读特征把舌头状态分准只是第一步舌象/舌苔检测的真正价值在于输出可读的舌色、苔色。传统颜色统计虽然“土”但胜在稳定且可解释医生更愿意看“舌苔覆盖率 0.23舌质偏红”而不是一个黑匣子输出。这里提供一个能直接跑的分析函数输入是第 3 章裁剪的嘴部 ROI输出是舌苔覆盖率和舌质饱和度。def analyze_tongue_coating(roi_bgr): roi_resized cv2.resize(roi_bgr, (224, 224)) hsv cv2.cvtColor(roi_resized, cv2.COLOR_BGR2HSV) h, s, v cv2.split(hsv) # 排除高光 non_spec ~((v 240) (s 30)) # 舌苔黄、白、灰白H 在 10~40S 不高不低 coating_mask cv2.inRange(hsv, (10, 20, 60), (40, 255, 255)) coating_mask coating_mask non_spec # 舌质淡红到暗红 body_mask cv2.inRange(hsv, (0, 60, 60), (10, 255, 255)) body_mask body_mask non_spec coating_ratio coating_mask.sum() / max(1, non_spec.sum()) body_ratio body_mask.sum() / max(1, non_spec.sum()) return { coating_ratio: round(float(coating_ratio), 3), tongue_body_ratio: round(float(body_ratio), 3), mean_hue: round(float(h[non_spec].mean()), 1), mean_sat: round(float(s[non_spec].mean()), 1) }这个 HSV 范围是经验值换摄像头后大概率要重新标定。所以我建议写一个小工具读取一段测试视频对每一帧画上 ROI 框、分类结果和这三个指标然后用键盘快捷键微调 HSV 范围实时观察输出变化。把阈值调到肉眼看到“反光被排除、白苔覆盖区域大致正确”即可。这个工具会帮你省掉大量盲目调参的时间。舌头状态的时序平滑和舌苔分析的实时反馈我建议一定要结合起来。我曾经在项目验收时遇到一个场景测试者伸舌头的时间很短分类器虽然识别正确但舌苔分析还没跑完舌头就缩回去了。后来我把舌苔分析帧缓存了 200 毫秒等状态稳定后再从缓存帧里取 ROI 做分析。这个细节不算复杂但能避免很多“明明看到了却识别不出”的尴尬。这个习惯我保留了很久先把中间结果可视化做好再开始调参数比对着日志猜快得多。希望帮到你。本文还有配套的精品资源点击获取