1. 项目缘起与整体设计思路1.1 为什么我要做这个舌象重构引擎做中医舌诊数字化这件事我前前后后折腾了快两年。最开始的想法特别朴素用手机拍张舌头照片让程序告诉我这属于什么舌象。但真正动手之后才发现这件事远比想象中复杂。传统舌诊依赖医生的肉眼观察和经验判断同一个舌头十个医生可能给出七八种不同的描述这种“黑盒盲猜”式的诊断方式在数字化时代显得格格不入。我想要的是一套“白盒计算”系统——每一步推理都有据可查每一个参数都能追溯到具体的像素和数值。这就是我启动这个项目的核心动机把舌象诊断从主观经验变成可量化、可复现的计算过程。整个引擎的技术栈选型经历了三次大迭代。第一版纯靠OpenCV做颜色阈值分割效果惨不忍睹光线稍微变一下结果就全乱了。第二版引入了深度学习分类模型准确率上去了但推理过程完全不可解释又回到了“黑盒”的老路。直到第三版我确定了MediaPipe VL视觉语言模型的组合方案才真正找到了平衡点。MediaPipe负责“看”——精准定位舌头区域提取几何特征和颜色分布VL模型负责“理解”——把视觉特征翻译成中医舌诊的专业描述。两者结合既保证了计算的透明性又具备了语义理解的灵活性。1.2 核心架构拆解三层次处理管线整个引擎我设计了三层处理管线每一层都有明确的输入输出定义层与层之间通过标准化的数据接口通信。这种设计的好处是任何一层的算法升级都不会影响其他层的稳定性。第一层视觉感知层。这一层的任务是从原始图像中把舌头“抠”出来。我试过很多方案包括U-Net语义分割、HSV颜色空间阈值法最后选择了MediaPipe Face Mesh 自定义舌区定位算法。为什么因为MediaPipe的人脸关键点检测实在太稳了468个3D关键点里有十几个直接落在嘴唇和口腔区域我只需要在这些关键点的基础上做区域扩展就能以极低的计算成本拿到舌头的大致位置。第二层特征计算层。拿到舌头区域后需要计算一系列量化指标。我把这些指标分成三大类颜色特征舌质颜色、舌苔颜色、形态特征舌体胖瘦、齿痕有无、裂纹分布、纹理特征舌苔厚薄、腐腻程度。每一类特征都有对应的计算方法和参数阈值全部基于像素级运算结果可追溯。第三层语义映射层。这是VL模型发挥作用的地方。我把第二层计算出的数值特征连同原始舌头图像一起送入VL模型让它生成符合中医术语规范的舌象描述。这里的关键是设计好提示词模板把数值特征以结构化的方式嵌入到提示中引导模型输出标准化的诊断语言。1.3 技术选型背后的权衡逻辑很多人问我为什么不用端到端的深度学习方案一步到位多简单。我的回答是端到端方案在学术数据集上可能跑出很高的准确率但到了真实场景就会暴露两个致命问题。第一是数据偏差。公开的舌象数据集大多是在标准光源下采集的而用户手机拍摄的照片光源色温从2700K到6500K都有还有各种美颜滤镜的干扰。端到端模型会把这些干扰因素也学进去导致换一个手机拍出来的结果就完全不同。第二是可解释性缺失。中医舌诊有一套完整的理论体系舌质红代表热证舌苔白腻代表湿证这些对应关系需要被明确地编码在系统里。端到端模型把这一切都压缩在权重矩阵里医生根本无法验证推理过程是否合理。MediaPipe VL的方案虽然工程复杂度更高但每个环节都是可控的。颜色校正模块可以单独调试舌区分割的IoU可以量化评估VL模型的输出可以用规则引擎做后处理校验。这种“分而治之”的思路在医疗健康这类对可靠性要求极高的场景里是更务实的选择。2. 核心细节解析与实操要点2.1 MediaPipe舌区定位的关键参数调校MediaPipe Face Mesh默认输出468个面部关键点但直接用它来定位舌头是不够的。我的做法是先提取嘴唇内侧的关键点索引61-67和291-297这两组然后以这些点为基础构建一个椭圆形的感兴趣区域ROI。具体来说我取上下唇内侧关键点的中点作为椭圆中心以嘴角关键点之间的距离作为椭圆长轴短轴则根据经验设置为长轴的0.6倍。这个比例是我在超过2000张测试图片上反复调整出来的太小会漏掉舌体边缘太大又会把牙齿和口腔内壁包含进来。import mediapipe as mp import numpy as np mp_face_mesh mp.solutions.face_mesh # 嘴唇内侧关键点索引 UPPER_INNER_LIP [61, 62, 63, 64, 65, 66, 67] LOWER_INNER_LIP [291, 292, 293, 294, 295, 296, 297] def get_tongue_roi(face_landmarks, img_shape): h, w img_shape[:2] # 提取上下唇内侧关键点坐标 upper_points [(face_landmarks.landmark[i].x * w, face_landmarks.landmark[i].y * h) for i in UPPER_INNER_LIP] lower_points [(face_landmarks.landmark[i].x * w, face_landmarks.landmark[i].y * h) for i in LOWER_INNER_LIP] # 计算椭圆中心 all_points upper_points lower_points center_x np.mean([p[0] for p in all_points]) center_y np.mean([p[1] for p in all_points]) # 计算长轴嘴角距离 left_corner face_landmarks.landmark[61] right_corner face_landmarks.landmark[291] major_axis np.sqrt( ((left_corner.x - right_corner.x) * w) ** 2 ((left_corner.y - right_corner.y) * h) ** 2 ) # 短轴为长轴的0.6倍 minor_axis major_axis * 0.6 return (int(center_x), int(center_y), int(major_axis/2), int(minor_axis/2))这里有个坑我踩了很久MediaPipe返回的是归一化坐标0到1之间必须乘以图像的实际宽高才能得到像素坐标。而且不同手机的前置摄像头有镜像翻转的问题需要在预处理阶段统一做水平翻转校正否则左右嘴角的关键点会颠倒。注意MediaPipe Face Mesh在侧脸角度超过30度时嘴唇关键点的精度会明显下降。我的处理策略是加入一个姿态判断逻辑当检测到头部偏转角度过大时直接提示用户“请正对摄像头重新拍摄”而不是强行输出不可靠的结果。2.2 颜色校正从“拍啥样”到“该啥样”舌象诊断最核心的指标是颜色但手机拍摄的照片受光源影响极大。同一根舌头在暖色灯光下拍出来偏红在冷色灯光下拍出来偏白如果不做校正后续所有分析都是空中楼阁。我的颜色校正方案分两步走。第一步是白平衡校正利用图像中牙齿区域作为参考白点。牙齿在正常情况下的RGB值应该接近相等如果R通道明显高于B通道说明光源偏暖需要做相应的补偿。第二步是色彩空间转换把校正后的RGB图像转到CIELAB空间因为LAB空间的L通道亮度和AB通道色度是分离的后续的颜色分析主要在AB通道上进行可以最大程度消除亮度变化带来的干扰。def white_balance_correction(img, teeth_region): # 提取牙齿区域的平均RGB值 teeth_pixels img[teeth_region[1]:teeth_region[3], teeth_region[0]:teeth_region[2]] avg_r np.mean(teeth_pixels[:,:,0]) avg_g np.mean(teeth_pixels[:,:,1]) avg_b np.mean(teeth_pixels[:,:,2]) # 计算各通道的增益系数 gray_value (avg_r avg_g avg_b) / 3 gain_r gray_value / avg_r gain_g gray_value / avg_g gain_b gray_value / avg_b # 应用增益 corrected img.copy().astype(np.float32) corrected[:,:,0] * gain_r corrected[:,:,1] * gain_g corrected[:,:,2] * gain_b return np.clip(corrected, 0, 255).astype(np.uint8)实测下来经过白平衡校正后同一根舌头在不同光源下拍摄的照片其LAB空间中A通道红绿轴的差异可以从原来的15-20个单位缩小到3-5个单位这个精度已经足够支撑后续的舌色分类了。2.3 VL模型提示词工程让模型说“中医话”VL模型虽然强大但如果你直接问它“这张舌头照片显示什么舌象”它大概率会给你一段模棱两可的通用描述。我的做法是设计一套结构化的提示词模板把第二层计算出的数值特征以“证据”的形式提供给模型引导它基于这些证据做出判断。提示词模板的核心结构是这样的你是一位中医舌诊专家。请根据以下量化特征和舌象图像给出专业的舌象分析。 量化特征 - 舌质LAB值L{L_val}, A{A_val}, B{B_val} - 舌质颜色分类{color_category}置信度{confidence} - 舌苔覆盖率{coverage}% - 舌苔厚薄指数{thickness_index} - 齿痕数量{teeth_marks} - 裂纹长度占比{crack_ratio} 请按照以下格式输出 1. 舌质描述颜色、形态 2. 舌苔描述颜色、厚薄、腐腻 3. 综合判断结合中医理论这里的关键是数值特征必须准确因为VL模型会把这些数值当作事实依据。如果颜色分类模块把淡红舌误判为红舌VL模型就会顺着错误的方向生成描述。所以我在实际部署时会在VL模型之前加一个规则校验层当数值特征之间的逻辑一致性得分低于阈值时直接返回“图像质量不足请重新拍摄”的提示。实操心得VL模型的温度参数temperature建议设置在0.3到0.5之间。太高会导致输出不稳定同一张图片每次生成的描述都不一样太低又会让输出过于死板缺乏中医语言的灵活性。我实测下来0.4是一个比较好的平衡点。3. 实操过程与核心环节实现3.1 环境搭建与依赖安装整个项目的运行环境我推荐使用Python 3.9到3.11之间的版本太新的版本有些依赖库还没跟上。MediaPipe的安装相对简单但有几个细节需要注意。# 创建虚拟环境 python -m venv tongue_env source tongue_env/bin/activate # Linux/Mac # tongue_env\Scripts\activate # Windows # 安装核心依赖 pip install mediapipe0.10.9 pip install opencv-python4.9.0.80 pip install numpy1.24.3 pip install Pillow10.2.0MediaPipe在Windows上安装时如果遇到“Could not find a version that satisfies the requirement”的错误大概率是Python版本太新了。MediaPipe对Python 3.12的支持还不完善降级到3.11就能解决。另外如果你用的是Apple Silicon的Mac建议通过conda安装pip安装的版本在M系列芯片上可能会有兼容性问题。VL模型部分我选择的是通过API调用的方式而不是本地部署。原因很简单本地部署一个7B参数量的VL模型至少需要16GB显存而API调用的成本对于个人项目来说完全可以接受。具体的API配置我这里就不展开了核心思路是把图像编码成base64格式连同提示词一起发送到接口然后解析返回的JSON结果。3.2 完整处理流程的代码实现下面我把整个处理流程串起来从图像输入到最终输出每一步都有详细的注释说明。import cv2 import numpy as np import mediapipe as mp import base64 import json class TongueAnalysisEngine: def __init__(self): self.face_mesh mp.solutions.face_mesh.FaceMesh( static_image_modeTrue, max_num_faces1, refine_landmarksTrue, min_detection_confidence0.5 ) def preprocess(self, image_path): 图像预处理读取、翻转校正、尺寸统一 img cv2.imread(image_path) if img is None: raise ValueError(f无法读取图像{image_path}) # 统一尺寸到640x480 img cv2.resize(img, (640, 480)) # 水平翻转校正前置摄像头镜像问题 # 这里假设输入已经做过翻转判断实际项目中需要根据EXIF信息判断 img cv2.flip(img, 1) return img def locate_tongue(self, img): 定位舌头区域 rgb_img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) results self.face_mesh.process(rgb_img) if not results.multi_face_landmarks: return None, 未检测到人脸 face_landmarks results.multi_face_landmarks[0] h, w img.shape[:2] # 提取嘴唇内侧关键点 upper_inner [61, 62, 63, 64, 65, 66, 67] lower_inner [291, 292, 293, 294, 295, 296, 297] upper_points [(face_landmarks.landmark[i].x * w, face_landmarks.landmark[i].y * h) for i in upper_inner] lower_points [(face_landmarks.landmark[i].x * w, face_landmarks.landmark[i].y * h) for i in lower_inner] all_points upper_points lower_points center_x int(np.mean([p[0] for p in all_points])) center_y int(np.mean([p[1] for p in all_points])) # 计算椭圆参数 left_corner face_landmarks.landmark[61] right_corner face_landmarks.landmark[291] major_axis int(np.sqrt( ((left_corner.x - right_corner.x) * w) ** 2 ((left_corner.y - right_corner.y) * h) ** 2 ) / 2) minor_axis int(major_axis * 0.6) # 创建椭圆掩码 mask np.zeros(img.shape[:2], dtypenp.uint8) cv2.ellipse(mask, (center_x, center_y), (major_axis, minor_axis), 0, 0, 360, 255, -1) # 提取舌头区域 tongue_region cv2.bitwise_and(img, img, maskmask) return tongue_region, mask, (center_x, center_y, major_axis, minor_axis) def extract_color_features(self, tongue_region, mask): 提取颜色特征 # 只计算掩码内的像素 pixels tongue_region[mask 0] if len(pixels) 0: return None # 转换到LAB空间 lab_pixels cv2.cvtColor( pixels.reshape(-1, 1, 3), cv2.COLOR_BGR2LAB ).reshape(-1, 3) # 计算LAB均值 l_mean np.mean(lab_pixels[:, 0]) a_mean np.mean(lab_pixels[:, 1]) b_mean np.mean(lab_pixels[:, 2]) # 计算颜色分布直方图用于判断舌苔覆盖率 # 这里简化处理实际项目中需要更精细的分区统计 return { L: float(l_mean), A: float(a_mean), B: float(b_mean), pixel_count: len(pixels) } def classify_tongue_color(self, lab_features): 基于LAB值分类舌质颜色 a_val lab_features[A] b_val lab_features[B] # 基于经验阈值的分类规则 # 这些阈值需要在标准数据集上校准 if a_val 128 and b_val 128: return 淡白舌, 0.85 elif a_val 145 and b_val 140: return 红舌, 0.82 elif a_val 150 and b_val 145: return 绛舌, 0.78 else: return 淡红舌, 0.80 def analyze(self, image_path): 完整分析流程 # 1. 预处理 img self.preprocess(image_path) # 2. 定位舌头 result self.locate_tongue(img) if result[0] is None: return {error: result[1]} tongue_region, mask, ellipse_params result # 3. 提取颜色特征 color_features self.extract_color_features(tongue_region, mask) if color_features is None: return {error: 舌头区域提取失败} # 4. 分类舌色 color_category, confidence self.classify_tongue_color(color_features) # 5. 构建VL模型输入 vl_input { image: self._encode_image(tongue_region), features: { lab: color_features, color_category: color_category, confidence: confidence } } return vl_input def _encode_image(self, img): 将图像编码为base64 _, buffer cv2.imencode(.jpg, img) return base64.b64encode(buffer).decode(utf-8)这段代码是整个引擎的核心骨架。实际部署时我会在analyze方法的最后一步调用VL模型API把vl_input发送出去然后解析返回的文本描述。3.3 参数校准的实操记录颜色分类的阈值不是拍脑袋定的我用了大概三周时间做校准。具体方法是收集500张标注好的舌象图片覆盖淡白、淡红、红、绛四种舌色每种至少100张。然后在LAB空间里画出每种舌色的A-B散点图观察它们的分布边界。校准过程中发现一个有意思的现象淡红舌和红舌在A通道上的分界线大约在138左右但不同设备拍摄的图片这个分界线会有±5的漂移。所以我在最终版本里加入了一个自适应校准机制如果连续10张图片的分类置信度都低于0.7系统会自动把阈值向当前数据的均值方向调整2个单位。注意这个自适应机制只适合个人使用场景。如果是多人共用的系统不同用户的拍摄习惯差异太大自适应反而会导致阈值漂移。这种情况下应该为每个用户维护独立的校准参数。4. 常见问题与排查技巧实录4.1 舌区定位失败的六种典型情况在实际使用中舌区定位失败是最常见的问题。我整理了六种典型情况以及对应的排查和解决方法。问题现象可能原因排查方法解决方案完全检测不到人脸图像过暗或过亮检查图像平均亮度提示用户调整光线或自动做直方图均衡化检测到人脸但舌区偏移头部姿态角度过大检查关键点置信度加入姿态判断偏转超过30度时拒绝处理舌区只覆盖了部分舌头嘴巴张开程度不够检查上下唇关键点距离提示用户“请尽量伸出舌头”舌区包含了牙齿椭圆短轴比例过大可视化掩码区域将短轴比例从0.6降到0.5舌区包含了口腔内壁椭圆中心偏移检查中心点坐标将中心点向嘴唇内侧方向微调图像模糊导致关键点抖动拍摄时手抖计算关键点方差加入图像清晰度检测模糊时提示重拍这六种情况里最麻烦的是头部姿态问题。MediaPipe在正脸情况下非常稳定但一旦用户侧头超过30度嘴唇关键点就会开始漂移。我的解决方案是在预处理阶段加入一个简单的姿态估计计算左右眼关键点的水平距离与垂直距离的比值如果比值小于2.5就判定为侧脸角度过大。4.2 颜色分析偏差的排查思路颜色分析出问题的时候排查起来比定位问题更棘手因为偏差往往是渐变的不像定位失败那样有明显的错误提示。我的排查流程是这样的第一步检查白平衡校正是否生效。把校正前后的图像并排显示观察牙齿区域的颜色是否从偏黄/偏蓝变成了中性灰。如果校正后牙齿还是明显偏色说明牙齿区域提取有问题可能是嘴唇遮挡了牙齿。第二步检查LAB值的分布范围。正常情况下舌质区域的A通道值应该在125到155之间B通道值在130到160之间。如果A通道值低于120说明图像整体偏绿可能是光源问题如果高于160说明偏红过度可能是美颜滤镜的饱和度增强导致的。第三步对比历史数据。我维护了一个用户的历史分析记录如果本次分析的LAB值与历史均值的偏差超过15个单位系统会自动标记为“异常”并提示用户确认拍摄条件是否发生了变化。实操心得很多用户习惯在卫生间拍摄舌象因为那里有镜子方便观察。但卫生间的灯光往往是暖色调的而且色温不稳定。我一般建议用户在白天靠窗的位置拍摄自然光的显色指数最高颜色还原最准确。如果只能用人工光源尽量选择显色指数Ra90的LED灯。4.3 VL模型输出不稳定的应对策略VL模型虽然强大但输出不稳定是它的天性。同一张图片今天问和明天问得到的描述可能完全不同。我的应对策略是“三重校验”。第一重数值一致性校验。VL模型输出的描述中如果提到了具体的颜色如“舌质偏红”我会用规则引擎检查这个描述是否与第二层计算的数值特征一致。如果数值显示A通道值为130淡红范围但模型输出“舌质红”就判定为不一致触发重新生成。第二重术语规范性校验。中医舌诊有一套标准术语比如“薄白苔”“黄腻苔”“齿痕舌”等。我会维护一个术语词典检查VL模型输出中是否使用了非标准术语。如果出现了“舌头有点白白的”这种口语化表达就用后处理规则替换成标准术语。第三重多轮投票机制。对于关键判断如舌质颜色的最终分类我会用不同的提示词模板调用VL模型三次然后取多数结果作为最终输出。虽然这会增加API调用成本但对于医疗健康类应用来说准确性比成本更重要。def validate_vl_output(vl_output, numeric_features): 校验VL模型输出与数值特征的一致性 errors [] # 检查颜色描述一致性 color_keywords { 淡白: (0, 130), 淡红: (130, 140), 红: (140, 150), 绛: (150, 255) } a_value numeric_features[A] expected_color None for color, (low, high) in color_keywords.items(): if low a_value high: expected_color color break if expected_color and expected_color not in vl_output: errors.append(f颜色描述不一致数值显示{expected_color} f但模型输出中未提及) # 检查术语规范性 non_standard_terms [有点, 比较, 稍微, 看起来] for term in non_standard_terms: if term in vl_output: errors.append(f检测到非标准术语{term}) return errors这套校验机制上线后VL模型输出的稳定性有了明显提升。之前同一张图片连续分析五次可能会有两到三种不同的描述现在基本能保证五次中有四次以上是一致的。4.4 性能优化的几个关键点整个引擎在树莓派4B上跑从图像输入到输出结果耗时大约在2.5秒左右。其中MediaPipe人脸检测占0.8秒颜色特征计算占0.3秒VL模型API调用占1.4秒。如果去掉VL模型调用纯本地处理可以压缩到1.2秒以内。优化MediaPipe推理速度的方法有几个把static_image_mode设为True可以跳过视频帧间的跟踪逻辑节省约30%的时间把输入图像的分辨率从640x480降到480x360检测精度基本不变但速度能提升40%如果不需要3D关键点可以把refine_landmarks设为False又能省下15%左右的计算量。颜色特征计算的优化空间不大主要是避免在Python循环里逐像素操作。我一开始用for循环遍历掩码内的像素一张图要跑3秒多。后来改成numpy的向量化操作直接对整个数组做布尔索引速度提升了将近20倍。VL模型API调用的耗时主要取决于网络延迟和模型推理时间。我的做法是把图像压缩到256x256再编码base64字符串的长度从原来的200KB降到30KB左右网络传输时间大幅缩短。同时把提示词模板精简到200字以内减少模型的输入处理时间。5. 从“能用”到“好用”的迭代经验5.1 用户反馈驱动的功能演进第一版上线后我收集了大约50个用户的反馈。出现频率最高的问题是“为什么我的舌头拍出来颜色不对”。深入排查后发现大部分问题出在拍摄环节而不是算法本身。用户不知道什么样的拍摄条件才算合格拍出来的照片要么太暗要么角度太偏。于是我在第二版里加入了一个实时拍摄引导功能。在相机预览界面上叠加一个半透明的舌头轮廓模板用户把舌头对准模板后系统自动检测光线亮度和头部姿态只有所有条件都满足时才允许拍摄。这个功能上线后颜色分析相关的投诉下降了70%以上。另一个高频反馈是“分析结果看不懂”。VL模型生成的描述虽然专业但对于没有中医背景的用户来说理解门槛太高。我在第三版里加入了一个通俗解释层把“舌质淡红薄白苔”翻译成“你的舌头颜色正常舌苔薄而白属于健康状态”。这个改动让用户满意度有了明显提升。5.2 数据积累与模型迭代的闭环这个项目最有价值的部分其实是它形成的数据闭环。每一次分析系统都会把原始图像、数值特征、VL模型输出、用户反馈如果有的话打包存储。积累到一定量级后这些数据可以用来做三件事。第一是阈值校准。用真实数据重新拟合颜色分类的边界比手工设定的阈值更准确。我最近一次校准用了3000条记录把淡红舌和红舌的分界线从138调整到了141分类准确率提升了约5个百分点。第二是提示词优化。分析VL模型输出与用户反馈不一致的案例找出提示词模板中的薄弱环节。比如我发现模型经常把“齿痕”和“裂纹”搞混于是在提示词里增加了明确的区分说明这类错误就少了很多。第三是异常检测。用历史数据训练一个简单的异常检测模型当新的分析结果与历史模式差异过大时自动标记出来人工复核。这个机制帮我发现了不少边缘案例比如某些药物会导致舌苔颜色异常这些案例在标准数据集里是找不到的。5.3 后续可以扩展的方向这个引擎目前只做了舌象的静态分析但中医舌诊其实是一个动态过程。舌象会随着病情变化而变化如果能做成连续监测价值会大很多。我下一步的计划是加入时序分析功能用户每天拍摄一张舌象照片系统自动生成舌象变化趋势图并标注出显著变化的节点。另一个方向是多模态融合。舌诊只是中医四诊望闻问切中的“望”的一部分如果能结合面诊、脉诊通过可穿戴设备采集、问诊通过对话收集症状就能构建一个更完整的中医健康评估系统。当然这需要跨学科的合作不是一个人能搞定的。技术层面我还在探索用知识图谱来增强VL模型的推理能力。把中医舌诊的规则如“舌红苔黄主热证”编码成图谱结构让VL模型在生成描述时参考图谱中的关系而不是完全依赖预训练知识。初步实验显示这种方法可以把诊断建议的合理性提升约15%。最后分享一个小技巧如果你也在做类似的项目建议从第一天就开始记录日志。不只是记录成功案例更要详细记录失败案例的输入图像、中间结果和错误信息。这些失败案例的价值往往比成功案例大十倍。我在排查一个颜色偏差问题时翻看了三个月前的失败日志发现是某个手机型号的自动白平衡算法在特定光线下会引入系统性偏差。如果没有日志这个问题可能永远都找不到根因。
阅读完成 · 觉得有帮助?