去年有阵子我反复被同一个问题卡住:舌象判读到底能不能脱离老中医的“经验感觉”。我说的“脱离”,不是把专家的结论录入知识库做成选择题,而是从一张舌部照片的像素出发,直接算出可复算、可验证的白盒指标。当时我就想做一个基于MediaPipe + VL的中医舌象重构引擎,一路折腾了大半年,最值得记录的并不是模型指标多漂亮,而是从“黑盒盲猜”到“白盒计算”这条路上,每一步选择背后的理由和踩过的坑。
传统舌诊在临床上有一套成熟的术语体系:舌淡红、苔薄白、齿痕舌、裂纹舌,等等。但这些词汇落到实际观测时,几乎全凭医师肉眼对颜色和形态的主观映射。一位医师说“淡红”,另一位可能记成“偏红”,第三位干脆写“正常”。你很难说谁对谁错,因为大家用的都是同一套词,背后却是不同的感知阈值。
这个项目想干的,说穿了就一件事:把“我觉得”翻译成“数据显示”。本文会按我的真实推进顺序来写——先讲清楚为什么传统舌诊需要白盒化,再讲为什么偏偏选MediaPipe承载几何信息、VL承载语义信息,然后给出落地方案和完整代码骨架,接着用一批实际样本做对比复盘,最后把光照、反光、术语过滤这些最容易翻车的细节单独拎出来说。
1. 舌诊的判断困境:为什么我决定放弃“经验盲猜”
1.1 传统舌象判读的三大不可复制痛点
先说结论:舌诊本身的临床逻辑没有被质疑,真正的问题出在“观测-记录”这一步的可复制性上。我做了一些小范围的交叉判读实验,让三位医师对同一组舌照做独立标注,结果图个热闹,数据冷冰冰地摆在面前后,我自己都吓了一跳。
第一个痛点是颜色缺乏定标。中医说“淡红舌”,但淡红到底对应哪个色号?没人给得出来。同一张照片在不同的显示器上,甚至同一个显示器不同的亮度设置下,呈现的色彩观感都不一样。人在判断颜色时还会被周围环境色影响,这叫颜色恒常性偏差,专业上叫色彩适应问题。临床上医生看的是真人舌体,光线是诊室日光灯还是窗边自然光,结论都可能不一样。
第二个痛点是描述词颗粒度太粗。舌苔“厚”还是“略厚”?齿痕“有”还是“轻度有”?这些词的边界模糊程度远超想象。我让三位医师在“有齿痕/无齿痕/可疑齿痕”三档里做判断,结果在几乎所有样本上,三个人里至少有两个落在相邻档位。换句话说,主观判读体系里存在一个巨大的“灰色地带”,而灰色地带恰恰是临床最常见的状态。
第三个痛点是经验不可迁移。一位资深医师心里那个“正常舌”的模板,是他几十年临床积累出来的个性化参照系。这个参照系没法通过文字完整传递,更没法复制给年轻医师或AI系统。我见过某位医师把一份舌照判为“舌淡胖”,另一位医师判为“正常偏红”,两人各执一词且都有理由。这种场面在临床带教里很常见,但传统上只被归为“经验差异”,从来没有人去量化差异本身。
1.2 一个真实case:同一张舌照,三位医师给出三种结论
当时某高校实验室收集了一批舌象数据,我拿其中一张比较典型的样本做了个小实验。这张舌照的舌体偏胖,边缘能看到轻微波浪状起伏,舌质颜色略红,舌苔薄白但舌尖区域几乎无苔。
三位医师独立标注的结果如下:
- 医师甲:舌淡红,苔薄白,无明显齿痕,舌体略胖
- 医师乙:舌红,苔薄白,可见轻度齿痕
- 医师丙:舌色正常偏红,苔薄,无齿痕,舌体正常
三份报告放在一起,乍看大致方向一致,但细节全在打架。甲认为的“淡红”和乙认为的“红”,对应的可能完全是同一个颜色;“轻度齿痕”和“无齿痕”之间,差的其实就是舌缘波浪起伏的幅度,这个幅度完全可以用几何指标表达出来。事后我用本文这套引擎去量化这张图,舌色归一化后在标准色度空间里的坐标是固定的,舌缘锯齿度指数也是确定值,不存在“淡红还是红”的争论空间。
1.3 从“我觉得”到“数据告诉我”:白盒计算的第一性原理
踩了这个坑之后我就明白了,问题的本质不是医学知识不够,而是缺少一个中间表示层。传统舌诊的知识体系是“症状描述→证候判断”,中间没有任何可计算的物理量。想要把舌诊从经验学科往工程化方向推,就必须在图像像素和舌诊术语之间,插入一层能被人和机器共同理解的白盒特征。
白盒特征的意思是,每一个输出指标都能追溯到一个具体的计算过程。舌色偏红,不是某个神经网络拍脑袋给的标签,而是像素在某个色彩空间里的分布统计结果;齿痕存在,是舌缘轮廓上出现了多少处超过阈值的凹陷;苔厚,是舌面像素按颜色和纹理聚类后某一类占比的量化结果。所有指标都可以画成曲线、检查中间图、调参后重新计算,这就是“白盒”和“黑盒”的本质区别。
黑盒方案,比如端到端的分类网络,输入一张舌照,输出一个“脾虚湿盛”的标签,中间发生什么没人说得清。这种方案在临床辅助场景基本没有可信度,因为专家没法复核系统的推理过程。白盒方案虽然每一步都不完美,但每一步都可审查,可争论,可改进。我最后定下的目标很朴素:让系统输出一份带数值、带中间产物、带可视化证据的舌象特征报告,而不是一个神秘的结论标签。
2. 技术选型推演:MediaPipe负责形状,VL负责语义
2.1 为什么选MediaPipe而不是自己训练分割模型
决定走白盒路线之后,第一个技术问题就是:舌体的几何信息从哪里来。当时一个常规思路是自己标数据集、自己训练一个舌体分割模型,很多同行也确实这么干。但我在调研之后果断转向了MediaPipe路线,原因不是自训练模型不好,而是启动成本和迭代效率都不划算。
MediaPipe这个框架最核心的优点是跨平台推理能力和极低的接入门槛。它自带的FaceMesh模型能输出面部和嘴部关键点,其中包含唇部内侧的边界点。舌头伸出时,唇部内侧点正好框出舌根和口腔周围的关键位置。虽然MediaPipe原生并不提供专门的“舌面”关键点,但我们可以用嘴部关键点定位整个口腔ROI,再在ROI内做进一步分析。说白了,MediaPipe在这里的角色不是最终的舌体分割器,而是一个稳定、快速的嘴部定位锚点。
为什么这个锚点重要?因为自我训练舌体分割模型面临的第一个问题就是舌头形态过于自由。伸舌的长度、角度、弯曲程度千变万化,加上舌体基部和口腔黏膜颜色相近,分割难度不低。完全从零开始收集标注数据,保守估计需要几千张高质量掩膜图才能训练出能用的模型,而MediaPipe已经解决了其中最有代表性的嘴部区域稳定定位问题。我把有限的人力投入到舌体轮廓精细化处理上,而不是重造一个嘴部定位轮子。
另一个实际原因是部署环境。这个引擎要能在普通笔记本上跑起来,尽量不要GPU。MediaPipe的FaceMesh在CPU上的推理速度大概是几十毫秒到一百多毫秒不等,配合OpenCV做图像处理,整体延迟完全可接受。自己训练模型或者用大型分割网络,至少在推理优化上要多花几倍功夫。
需要明确一点,MediaPipe输出的是嘴部关键点,不是舌面关键点。所以我在实际实现里做了一层“转换”:用嘴部ROI裁出舌体区域后,通过颜色阈值和形态学操作获取舌体掩膜,再对掩膜轮廓做均匀重采样,得到一组固定数量的舌缘关键点。这组关键点就像手部骨骼点一样,是后续所有几何计算的输入。
2.2 VL模型在舌象语义理解里到底起什么作用
几何信息搞定了,下一个问题是语义。你算出一堆数值——舌面积、舌缘锯齿度、舌色LAB分布、苔覆盖率——这些数值本身很客观,但没法直接给医生看。“面积1234平方像素”和“舌体偏胖”之间的距离,不是数值能自动跨越的。这个桥梁角色,我交给了VL(视觉语言模型)。
VL在架构里承担的职责是“语义翻译官”,而不是“定量计算器”。我的设计原则非常明确:所有定量参数必须来自可解释的白盒计算,VL要做的是把这些参数转换成符合舌诊习惯的描述语句,并将可能的异常模式提示给用户。比如,白盒算出一组Lab色度均值和方差,VL可以基于预设术语表生成“舌色淡红,舌质润”这样符合中医表达习惯的描述,但不会让VL自己输出“偏红0.3”这种没有白盒依据的数字。
之所以特意做这个职责划分,是因为视觉语言模型虽然对话能力强,但存在幻觉和随机性问题。你问它“这张舌照的苔厚指数是多少”,它可能给出一个看似精确但完全没依据的数字,这个数字就是你最不能信的部分。反过来,如果你只让它基于已经算好的数值做语言润色,它的幻觉空间就小得多,输出更可控。
具体来说,我用的是开源的视觉语言模型,把裁剪后的舌体图和白盒指标拼接成Prompt,要求模型输出三段内容:第一段是舌象特征的自然语言描述,第二段是基于预设中医术语字典的标签建议,第三段是对异常特征的提示性说明。在推理时,我把模型的温度参数压到很低,并在后处理阶段做术语过滤——只保留术语字典里出现的词汇,从机制上杜绝模型创造不存在的诊断词汇。
2.3 职责边界与数据流:先算再比,比完再解释
架构确定后,数据流就非常清晰了。整体上分三条流水线,按顺序执行:
第一条是几何流水线。输入舌象图像,MediaPipe先定位面部和嘴部关键点,裁出ROI,再用形态学方法提取舌体掩膜和轮廓关键点。从这条流水线出来的是舌体面积、宽长比、齿痕指数、舌尖位置等纯几何数值,不经过任何神经网络推理,完全白盒。
第二条是对比流水线。针对舌质和舌苔的颜色关系,把舌面像素映射到感知均匀的色彩空间,然后做聚类。这一步输出舌质色均值、苔覆盖率、苔色均值、湿润反光区域占比等参数。这些参数的物理意义可以直接解释,也能通过可视化中间图验证。
第三条是语义流水线。把前两条流水线产出的数值拼接成特征向量,交给VL模型做文本化输出。这条流水线不接受原始图像作为唯一证据,即便VL看到图像,也会被系统提示词限制为“仅参考构图和拍摄质量,不输出未经白盒确认的定量结论”。所有从VL出来的文本,最后还要过一次术语字典校验,没有匹配到词条的词组会被过滤或替换。
这三条流水线的核心思路可以总结成一句话:先算出证据,再比较证据,最后用语言解释证据。黑盒模型永远无法告诉你“为什么是这个结论”,但这个架构能。
3. 引擎落地实现:从图像输入到白盒参数输出
3.1 第一步:舌体区域定位与关键点采样
实际实现里,我把舌体定位拆成三个子步骤。第一,用MediaPipe FaceMesh得到面部关键点,重点取唇部内侧点。舌头伸出时,唇部内侧点基本贴着舌根,这个位置关系非常稳定。第二,根据嘴角点和唇部内侧点计算扩展矩形,裁出包含完整舌体的ROI。第三,在ROI内部用HSV颜色阈值分离舌体与口腔背景,再用形态学闭运算填平舌体内部的细小孔洞。
舌体颜色和口腔黏膜颜色在HSV空间里有比较明显的区分度,尤其在饱和度和明度上。舌头伸出来后,舌面通常偏红或偏暗红,而口腔内壁的黏膜颜色偏浅,舌根和软腭之间的色差值足够做初分割。当然,不同人体质不同,色阈值不能设死。我在程序里加了一个自动阈值估计,用Otsu方法根据ROI内的饱和度和色度直方图找分割阈值,实测下来对大部分样本都比较稳。
分割完成之后得到舌体掩膜,接下来需要把掩膜转换成关键点序列。最常见的方法是直接取掩膜的最大外部轮廓,然后用Douglas-Peucker算法简化轮廓,最后用等间距采样把轮廓点归一化成固定数量。我习惯设36个点,点数太多会引入不必要的轮廓噪声,太少则无法表现齿痕这类局部细节。
这一步有个隐藏问题:舌尖处的轮廓曲率变化很大,如果单纯按轮廓弧长均匀采样,舌尖和舌根两侧的密度分配会不均。为了兼顾舌缘细节,我做了分段重采样,把轮廓按几何特征切成几个区间,每个区间按照该区间的弧长占比分配采样点数。这样舌缘锯齿状区域能拿到足够多的点,舌根部这些信息量低的区域也不会过度采样。
3.2 第二步:几何特征与颜色特征的量化计算
关键点就位后,几何特征可以直接算。我保留了以下指标:
- 舌体面积:掩膜像素数乘上单像素物理面积换算系数
- 舌宽长比:舌体最小外接矩形的宽高比,用于判断胖大舌
- 齿痕指数:沿舌缘关键点序列计算局部曲率波动幅度,曲率波动超过阈值的点记为可疑齿痕点,其数量占比就是齿痕指数
- 舌尖偏移角:舌尖关键点到舌体中心线的偏转角度,用于评估伸舌姿态是否居中
颜色特征放在感知均匀的色彩空间里计算。RGB空间的欧氏距离和人眼感知的色差不一致,所以我先把像素从sRGB转换到CIELAB空间。这个空间里,L表示亮度,a表示红绿方向,b表示黄蓝方向。舌质颜色主要看a通道和b通道的均值,舌苔的厚重程度则可以结合亮度分布来估计。
舌质和舌苔的区分不是靠一个固定阈值就能解决的。我在程序里先用KMeans在LAB空间的a/b通道上对舌面像素做两类聚类,然后利用舌苔一般覆盖在舌体中后部、舌质在边缘和舌尖显露更充分的形态学先验,确定两类像素各自对应舌苔还是舌质。如果两类聚类结果里不存在明显的空间分布差异,就降低置信度并在输出里标注“舌苔边界不明显”。
苔覆盖率这个指标很多人会误以为很简单,不就是舌苔像素数除以总面积吗?实际上由于舌体中央常常有一层极薄的湿润光泽,颜色和舌质很接近,简单阈值很容易把薄苔漏掉。我额外引入了一个纹理维度:舌苔区域通常表现为粗糙度更高的表面,可以做局部灰度共生矩阵分析,把纹理粗糙度纳入聚类特征。这样在颜色接近的情况下,薄苔也能被分出来一部分。
3.3 第三步:VL语义映射与解释输出
白盒指标计算完,剩下的就是让VL把它们翻译成人话。我的实现分为三层。第一层是特征序列化,把所有的几何、颜色、纹理指标整理成固定顺序的数值列表,并携带单位说明。第二层是Prompt构造,把数值列表和舌面ROI图像一并交给VL,系统提示词里明确写着:“你是一个舌象术语翻译器,只能基于输入的量化指标进行描述性输出,不得自行推断任何数值。”
系统会看到类似这样的输入:
舌体面积相对值:0.82 宽长比:1.27 齿痕指数:6.3% 舌尖偏移角:1.8° 舌质LAB均值:L=48.2, a=11.5, b=8.9 苔覆盖率:67.0% 苔色LAB均值:L=61.0, a=8.4, b=4.3VL需要输出的格式被我固定成JSON,包含“舌色描述”、“舌形描述”、“舌苔描述”、“异常提示”四个字段。温度参数设到0.1,保证输出不会飘。后处理阶段,我会解析JSON并用术语字典过滤一遍,比如字典里有“舌淡红”“舌红”“舌暗红”,就不会出现“舌淡粉”这种非标准说法。
实测来看,这个设计比让VL直接看图片输出结论要稳得多。直接让VL看图,它经常被图像里的阴影、反光、光线色温带偏,同样的舌状体换个光线描述就跑偏。把白盒数值作为主要输入之后,VL的输出稳定性明显提升,因为它能用的证据链条已经被锁死了。
3.4 一份最小可跑代码骨架
整个引擎的最小版本,核心逻辑其实可以压缩在几百行Python里。这里给出一段足够跑通完整流程的骨架代码,省略了界面和部署部分,但关键链路都在。
import cv2 import numpy as np import mediapipe as mp mp_face_mesh = mp.solutions.face_mesh # 嘴部内侧关键点索引(MediaPipe FaceMesh) # 280~290 为上唇内侧弧,291~306 为下唇内侧弧 LIP_INNER_TOP = list(range(281, 291)) LIP_INNER_BOTTOM = list(range(291, 297)) + [40, 22, 36, 185] def get_lip_roi(image): with mp_face_mesh.FaceMesh( static_image_mode=True, max_num_faces=1, refine_landmarks=True) as face_mesh: results = face_mesh.process(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) if not results.multi_face_landmarks: return None landmarks = results.multi_face_landmarks[0].landmark h, w = image.shape[:2] points = [(int(landmarks[i].x * w), int(landmarks[i].y * h)) for i in LIP_INNER_TOP + LIP_INNER_BOTTOM] xs = [p[0] for p in points] ys = [p[1] for p in points] pad_x, pad_y = int(0.4 * (max(xs) - min(xs))), int(0.3 * (max(ys) - min(ys))) x1, y1 = max(0, min(xs) - pad_x), max(0, min(ys) - pad_y) x2, y2 = min(w, max(xs) + pad_x), min(h, max(ys) + pad_y) return (x1, y1, x2, y2) def extract_tongue_mask(roi): hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) # 以饱和度+色度直方图做Otsu阈值 s_channel = hsv[:, :, 1] v_channel = hsv[:, :, 2] _, s_mask = cv2.threshold(s_channel, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 舌体通常比口腔黏膜更暗 _, v_mask = cv2.threshold(v_channel, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) mask = cv2.bitwise_and(s_mask, v_mask) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1) return mask def resample_contour(contour, n_points=36): contour = contour.reshape(-1, 2).astype(np.float32) # 先计算累计弧长,再均匀采样 diffs = np.diff(contour, axis=0) seg_lens = np.linalg.norm(diffs, axis=1) cum_len = np.cumsum(np.concatenate(([0], seg_lens))) total_len = cum_len[-1] target_lens = np.linspace(0, total_len, n_points, endpoint=False) sampled = [] j = 0 for target in target_lens: while j < len(cum_len) - 2 and cum_len[j + 1] < target: j += 1 t = (target - cum_len[j]) / max(cum_len[j + 1] - cum_len[j], 1e-6) pt = contour[j] + t * (contour[j + 1] - contour[j]) sampled.append(pt) return np.array(sampled) def compute_geometric_features(points): # 简化版本:计算宽长比与边缘波动 min_rect = cv2.minAreaRect(points.astype(np.float32)) (w, h), _ = min_rect[1], min_rect[2] aspect_ratio = max(w, h) / min(w, h) # 齿痕指数:相邻关键点的局部曲率变化 curvatures = [] n = len(points) for i in range(n): p1, p2, p3 = points[(i - 1) % n], points[i], points[(i + 1) % n] v1, v2 = p2 - p1, p3 - p2 angle = np.arccos(np.clip(np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-6), -1, 1)) curvatures.append(angle) curvatures = np.array(curvatures) notch_indices = np.sum(curvatures < np.percentile(curvatures, 20)) # 简化示意 return aspect_ratio, notch_indices / n代码虽然短,但要注意两个点。第一,MediaPipe的FaceMesh关键点索引在不同版本里会有调整,我用的索引是基于我在某版本实践时的对照表,你复现时最好先打印出所有关键点坐标,在图上可视化确认一下再使用。第二,HSV阈值里的Otsu只在光照相对均匀时可靠,如果图像里有强反光或明显阴影,建议先做高光掩膜,把高光区域的像素排除在阈值估计之外。
4. 从黑盒盲猜到白盒计算的实测对比
4.1 测试数据与基准设计
为了验证这套引擎到底能不能用,我组了一套不算大但足够暴露问题的测试集。数据来源有三个,某高校实验室脱敏采集的舌象图占一半,互联网公开舌象图占三成,剩下两成是手机拍摄的日常照片,尽量模拟真实应用场景。所有样本都经过了脱敏处理,不包含任何身份信息。
基准设计采用了“三人独立标注+多数表决”的方案。三位有临床经验的医师分别对每张图标注舌色、苔色、舌形、齿痕四个维度,出现分歧时取至少两人的一致意见作为该样本的基准标签。我没有直接用某一位专家的个人判断作为金标准,因为前面已经证明个人判断的稳定性不可靠。
对比的时候,我主要看两个指标。一是引擎输出的白盒参数和人工主观标注之间的一致率,比如人工标注“有齿痕”的样本里,引擎的齿痕指数是否明显高于人工标注“无齿痕”的样本。二是同样一批样本,三位医师之间的交叉一致率,我要对照看看机器和人之间的差距,是否小于人和人之间的差距。
4.2 白盒指标与人工判读对照:一致率与分歧点
从实验结果看,情况比较有意思。舌色描述上,引擎的量化色度值与医师多数判读的一致率最高,这是因为颜色本身是连续物理量,只要把光照和色彩空间问题处理妥当,机器在颜色判读上的稳定性天然比人眼强。舌形判读的宽长比指标也和人工判读吻合得较好,毕竟胖大舌和正常舌在宽长比分布上确实有可量化的区分。
齿痕判读成为分歧最大的维度,这不意外。轻度齿痕在人工标注里本身就是最容易出现分歧的项目,三位医师对轻度齿痕的交叉一致率只有中等水平。引擎给出的齿痕指数,在人工标注为“有齿痕”的样本上平均高出“无齿痕”样本不少,但分布存在交叠。换句话说,引擎能区分“明显齿痕”和“无齿痕”,但落在灰色地带的轻度齿痕,机器和人一样会犹豫。或者更准确地说,机器用连续数值表达了这种犹豫,而人被迫在离散标签里选一个。
这种结果让我意识到一个重要事实:白盒计算的价值不只是提高准确率,更是把主观判读里那些不可言说的灰色地带显性化了。医师被强制在“有”和“无”之间二选一时,丢失的信息量很大;引擎输出“齿痕指数6.3%”时,信息量是连续的。
4.3 误差溯源:哪些误判其实来自采样而非模型
实验里出现的典型误差,我逐个追过根因,结论是大部分误差跟模型能力无关,而是出在图像采集和前处理阶段。
最常见的是光照色温问题。一张在暖黄灯光下拍出来的舌照,舌色明显偏黄,引擎在LAB空间里会把b通道数值推高,导致误判为“舌苔黄腻”或“舌红”。这不是算法错了,是拍摄条件没有满足“光源色温中性”这个前提。要解决这个问题,靠算法卷积不如靠采集规范——拍摄时用灰卡校准白平衡,或者至少用手机的专业模式锁定色温。如果图像已经拍歪了,只能做后验的色温估计和补偿,效果远不如源头控制。
第二个严重误差来源是舌体分割断裂。当舌头紧贴下唇,或者口腔内有阴影,ROI内分割出的舌体掩膜可能把舌头的一部分切掉,导致舌体面积偏小、宽长比失真。这种情况比颜色误差更伤,因为几何特征完全建立在掩膜形状正确的前提上。我后来在分割环节增加了边缘连续性检查,如果掩膜外轮廓出现异常长的直线段,就判定为可疑断裂并启动二次分割。
第三个问题是高光反光区域。舌面覆盖着薄薄一层唾液,在侧面光源下会产生局部高光,像素亮度被压到很高,颜色信息几乎丢失。我把这些高光区域检测出来设成掩膜之外的无效区,参与计算但不参与颜色聚类。这个方法简单但实用,能把反光对颜色统计的干扰降下来不少。
5. 落地过程中的坑与破解办法
5.1 光照、姿态、反光:这三关怎么过
这三关我在每个阶段都撞过墙,拆开讲讲具体的处理思路。
光照问题是颜色计算的头号大敌。直接对sRGB做颜色统计,在不同光源下结果漂移非常明显。我先用两种手段做预处理,一是要求所有输入图像先经过白平衡校正,不管你是用相机里的自动模式还是软件后处理,至少让白色区域接近中性灰;二是在特征计算时用LAB空间,并且把亮度L通道和色度a/b通道分开处理。舌苔厚度的判断跟亮度分布关系大,舌色判断跟a/b通道关系大,两者混在一起时信息耦合太强。
姿态问题主要影响舌体几何量。同一张舌头上,伸舌力度不同,舌体宽长比会有明显变化。用力外伸的舌头会更薄更长,自然放松的舌头更宽更胖。我在引擎里引入了一个姿态校正模块,根据舌尖位置和口裂的相对关系估计伸舌程度,再把几何指标归一化到标准伸展状态下的等效值。这个模块做得比较粗糙,但方向是对的,也让宽长比的跨样本可比性提高了不少。
反光问题的处理相对独立。舌尖和舌面中央是反光高发区,我直接用高光检测生成一个二值掩膜,把高光像素从所有统计中剔除。不过要注意,高光区域往往覆盖了舌体最中央的区域,如果样本里中央苔色信息全被高光吃掉了,苔覆盖率肯定偏低。我会在输出里附带一个“高光影响指数”,让使用者知道这个指标的可靠程度。
5.2 舌质与舌苔被颜色量化掩盖时的补救办法
舌质和舌苔的颜色在不少样本里非常接近,这种情况下KMeans聚类很容易把两类混成一片。薄白苔的像素颜色和舌质相比,只是亮度略高、色度略低,在LAB空间的分布高度重叠。
我在这个坑上试了一大圈方法,最后稳定下来的方案是加入纹理信息做联合聚类。舌苔区域在局部尺度上的纹理粗糙度明显高于舌质的光滑表面,用灰度共生矩阵提取对比度和均匀性指标,再把这些纹理特征和颜色特征拼在一起做聚类,效果比单用颜色好很多。原理不复杂,舌苔本质上是一层附着物,它的微观表面结构和舌乳头不同,纹理特征天然有区分力。
另一个补救措施是用形态学先验。舌苔通常从舌根向舌尖方向延伸,舌根侧苔厚,舌尖侧苔薄甚至无苔。聚类之后,如果“舌苔类”像素集中在舌根部而舌质类像素集中在舌尖和舌缘,那分类基本可靠;如果两类像素在空间上完全随机交错,就要怀疑聚类失效,这时候引擎会主动降低置信度,而不是硬给一个结果。
Vitally,不要试图用一个固定模型解决所有样本。我在引擎里设计了置信度分级:高置信样本会完整输出所有白盒指标和VL语义描述;中置信样本会标出可疑指标并建议复查拍摄条件;低置信样本只输出技术性警告,不给舌象标签。这个分级保护了引擎的整体可信度,也避免在临床应用里给使用者虚假的安全感。
5.3 做产品时必须要有的边界声明
随着引擎功能完善,开始有朋友建议我把它做成面向大众的舌诊产品。我认真考虑过,也做过一版Demo,但最终没有往“诊断工具”的方向走。原因很直接:舌象特征和具体疾病之间的映射关系非常复杂,同样的“舌红少苔”,在甲身上可能是阴虚,在乙身上可能伴随湿热,在丙身上甚至可能是生理性变化。任何一个负责任的人都不该让一个白盒特征引擎直接输出疾病诊断。
我在引擎的可视化界面里加了一段固定的边界声明:本工具输出的舌象特征参数和语义描述,仅作为中医临床舌诊的辅助参考和教学演示,不构成任何形式的疾病诊断或治疗建议。如果你要拿它做科研,请严格遵循医学伦理规范,由具备资质的专业人员对输出结果做二次审核。
边界声明不是一句空话,它直接影响技术设计。因为不做诊断,所以我刻意没有在系统里加入“证候判断”模块,VL的输出也被限制在特征描述层面,术语字典里只有舌象描述词汇,没有任何证候或方剂词汇。这样的设计让引擎成为一个纯粹的“舌象特征测量仪”,而不是一个半吊子的诊断系统。
VL术语过滤这一层,我踩过一个具体教训。早期版本里,VL偶尔会突发奇想输出“肝郁气滞”这种证候词汇,术语字典没拦住,吓得我赶紧补了一次审核。后来我把字典校验从“后处理过滤”改成了“软硬两层”:软层是在Prompt里声明禁止输出证候词汇,硬层是在输出解析阶段直接丢弃非描述性名词。从那以后,类似的越界输出再没出现过。
如果让我重新做一次这个项目,我会把第一版拆成两个更小模块。一个是纯几何特征计算器,专注齿痕、宽长比、舌体面积这些形态学指标;一个是纯颜色特征计算器,专注舌质舌苔的颜色分布和覆盖率。两个模块之间的耦合降到最低,各自独立出报告,反而比一个统筹引擎更好用。我始终觉得这套思路最让人踏实的地方,是它把一个说不清道不明的临床判断,变成了一份可以拿出来对账、追溯、复算的数据。哪怕你对着同一个样本跑十遍,结果永远一致,这一点是任何“经验盲猜”都做不到的。