首页 / 资讯中心 / 文章详情

基于YOLOv8与U-Net的中医舌诊AI系统:从图像分割到五脏指数评分卡

基于YOLOv8与U-Net的中医舌诊AI系统:从图像分割到五脏指数评分卡 ★ FEATURED ARTICLE
简介基于深度学习与计算机视觉的中医舌诊自动化分析系统以Python实现为主面向从事医学图像处理、中医智能诊断及相关课题研究的开发者与科研人员。系统围绕舌体图像自动定位、多维度特征提取和五脏健康指数计算三个核心环节通过卷积神经网络模型训练与推理结合中医专家权重规则输出心肝脾肺肾健康评估结果。压缩包共42个文件含27个Python脚本、6个TXT说明、5个XML配置、docx文档和依赖清单等覆盖舌体分割、特征融合、健康分析、模型训练与测试等模块代码结构清晰附有配置与说明文档便于直接运行和二次开发。资源包大小约175KB目前已有64人学习。通过这套代码可以快速掌握从图像分割到中医辨证分析的系统实现思路获得可用于实验和改进的完整项目基线。1. 一个反直觉的结论深度学习只负责“看见”五脏指数全靠一张权重表拿到这个中医舌诊自动化项目时大多数人会默认它是一套端到端的深度学习魔法上传一张舌头照片模型直接吐出“心肝脾肺肾五个指数”。实际拆开来看深度学习和计算机视觉在其中只承担两件事——把舌头从照片里找出来和把舌面的颜色、纹理量化成数字。真正的“五脏健康指数”是靠一张由中医专家经验标定出来的权重表对特征做加权计算得出的。这个结论很关键它决定了你的项目架构检测模型加上分割模型作为前端OpenCV做特征提取作为中段一张可解释的评分卡作为后端。这三层结构让系统既能落地又能让中医从业者看懂每一项指数的来源而不是面对一个黑匣子。这套方案适合谁如果你是做深度学习视觉方向毕业设计的学生或是在医疗AI公司做图像识别功能开发的工程师又或是想给中医数字化工具做个原型的中医信息化从业者这个标题所指向的项目结构都能让你在两周内跑通一个可演示的完整链路。它不需要你精通中医诊法但需要你对目标检测、图像分割和特征工程这三块计算机视觉基本功有操作经验。接下来我按自己实现这类系统时的步骤把从选型到踩坑的全过程拆给你看。2. 计算机视觉选型舌体检测用YOLOv8舌体分割用U-Net——先想清楚你要坐标还是轮廓2.1 系统整体结构上传、定位、拆特征、算指数四条线整个系统在逻辑上是四条串行的线。第一条线是用户上传舌体照片这里要做图片格式校验、分辨率检查和文件存储。第二条线是舌体区域定位也就是目标检测它输出一个包含舌头的矩形框告诉后续模块“舌头在这张图的哪个位置”。第三条线是舌体精细分割它基于检测框裁出的区域做像素级分类把舌体、舌苔、背景分离开同时按中医舌诊的分区规则把舌面划分为舌尖、舌中、舌根、舌边四个语义区域。第四条线是特征计算和指数映射对分割结果提取颜色、纹理、舌苔面积占比等多维图像特征再乘以专家权重矩阵得到五脏指数。这个结构里容易搞混的是第二条线和第三条线的分工。检测网络解决的是“舌头在哪里”的问题输出的是四个坐标值分割网络解决的是“哪些像素属于舌头”的问题输出的是和输入同尺寸的掩码图。有些项目为了省事只做检测框内的平均颜色计算结果就是背景的嘴唇、牙齿、口腔暗影全部混进特征里指数波动大得没法看。所以我的建议是检测和分割都做哪怕分割网络用一个轻量级的变体它对特征的纯净度提升也远超那一点推理耗时。2.2 为什么选YOLOv8做检测、U-Net做分割以及和中医舌诊分区的对应关系目标检测这块我一般直接用YOLOv8而不是Faster R-CNN或SSD。原因有三条第一舌体检测是单类别目标YOLOv8的单阶段架构在同类任务上训练收敛快几百张标注图就能训出一个能用的模型第二部署时YOLOv8可以导出ONNX格式在CPU上跑一张640x640的输入也只要几十毫秒这对Web后端很友好第三YOLOv8的输出接口直观results.boxes.xyxy直接给出边界框坐标省去锚框后处理的心智负担。如果你更熟悉CS231n里讲的两阶段检测思路用Faster R-CNN当然也能做但部署体积和推理速度都会吃亏。分割网络选U-Net则是另一个逻辑。舌体分割的输入输出尺寸一致属于典型的医学图像语义分割任务U-Net的编码器-解码器结构和跳跃连接在这个场景下被验证过太多次了。而且U-Net对训练数据量的要求不像DeepLabV3那么高我见过用三百张舌体掩码图训练出的U-Net在验证集上能达到0.91以上的IoU。相比之下如果换成基于Transformer的分割模型数据量不够时反而容易欠拟合。这里有一个和中医理论直接相关的设计点舌诊分区。传统中医看舌舌尖候心肺、舌中候脾胃、舌根候肾、舌边候肝胆。这种分区在图像上不是等分的通常舌尖占舌面前三分之一舌根占后三分之一中间再分舌中和舌边。分割网络输出的整舌掩码配合固定比例的分区规则就能把每个区域单独裁剪出来做特征提取。这个做法不需要额外训练一个区域分类模型靠几何比例即可稳定且可解释。2.3 环境与依赖PyTorch 2.x ONNX Runtime OpenCV 的配置清单动手写代码之前先把环境钉死。这个项目的依赖分成三块深度学习训练和推理、传统图像处理、后端服务。以下是我实测可用的组合Python版本建议锁定在3.10。# 创建虚拟环境避免和系统Python打架 conda create -n tongue_ai python3.10 -y conda activate tongue_ai # 深度学习训练框架PyTorch 2.xCUDA按你的显卡版本装 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 # YOLOv8检测ultralytics包安装时注意它会自动拉入opencv-python pip install ultralytics8.2.0 # 分割模型训练如果你要自己训U-Net配合segmentation-models-pytorch pip install segmentation-models-pytorch # 推理部署和图像特征提取 pip install onnxruntime opencv-python scikit-image scikit-learn pandas numpy # 后端服务轻量级API用FastAPI比Flask更适合传文件和异步处理 pip install fastapi uvicorn python-multipart参数说明--index-url指定PyTorch的CUDA 11.8版本源如果你的显卡驱动只支持CUDA 12需要换成cu121后缀的源ultralytics包版本建议固定新版本API有调整8.2.0的接口和本文代码对得上segmentation-models-pytorch是可选依赖如果你直接使用我下面给到的预训练分割流程这一项可以跳过。环境配置是这类深度学习项目第一个翻车点。常见现象是torch.cuda.is_available()返回False原因多半是PyTorch装成了CPU版本解决方式是卸载重装指定CUDA版本的wheel包。另一个坑是ultralytics和opencv-python的版本冲突现象是ImportError: libGL.so.1: cannot open shared object file在Ubuntu上执行apt-get install libgl1 libglib2.0-0即可解决。3. 核心实现用YOLOv8加U-Net把舌体定位和特征提取做成一条pipeline3.1 舌体定位YOLOv8推理代码与输出的坐标系约定先写检测模块。这个模块接收用户上传的原始图片返回舌体的边界框坐标以及一个裁剪后的舌体图像。需要特别注意的是坐标系的约定YOLOv8返回的xyxy格式坐标是基于输入网络的缩放后图像尺寸的如果你把原始图片直接resize到640x640送进网络那输出的坐标也是在这个640x640坐标系下的必须按缩放比例映射回原图。import cv2 import numpy as np from ultralytics import YOLO class TongueDetector: def __init__(self, model_pathbest_detect.pt, conf_thres0.45): # 加载训练好的检测模型conf_thres控制误检和漏检的平衡 self.model YOLO(model_path) self.conf_thres conf_thres def detect(self, image_bgr): 输入BGR格式的原始图像返回检测框坐标和裁剪后的舌体图。 坐标映射回原图坐标系方便后续和分割结果对齐。 orig_h, orig_w image_bgr.shape[:2] results self.model.predict(image_bgr, confself.conf_thres, verboseFalse) boxes results[0].boxes if len(boxes) 0: return None, None, None # 取置信度最高的一个检测框舌体照片里一般只有一个舌头 best boxes[0] x1, y1, x2, y2 best.xyxy[0].cpu().numpy().astype(int) # 检测框已经映射回原图尺寸直接裁剪 pad int(min(orig_h, orig_w) * 0.02) # 向外扩2%防止舌头边缘被切掉 x1 max(0, x1 - pad) y1 max(0, y1 - pad) x2 min(orig_w, x2 pad) y2 min(orig_h, y2 pad) cropped image_bgr[y1:y2, x1:x2] return (x1, y1, x2, y2), cropped, best.conf.item()逻辑说明ultralytics的predict返回的boxes.xyxy已经是原图坐标不需要手动做缩放映射这一点在8.2.0版本里是默认行为。代码里做了2%的边界外扩是因为检测框通常贴合得太紧直接裁剪容易把舌根边缘和舌尖的齿痕部分切掉。置信度阈值conf_thres设成0.45是经验值——设低了会把嘴唇误检成舌头设高了在暗光下容易漏检。3.2 U-Net分割与舌区mask把舌面划分成舌尖、舌中、舌根和舌边检测框拿到之后下一步是对裁剪出的舌体图像做像素级分割输出一个和裁剪图同尺寸的掩码图。掩码图中舌体像素为255背景为0。这里我使用U-Net但推理方式走ONNX Runtime这样部署时不需要安装PyTorch。import onnxruntime as ort import cv2 import numpy as np class TongueSegmentor: def __init__(self, onnx_pathbest_unet.onnx): # 设置CPU推理减少部署依赖如果用GPU把providers改成CUDAExecutionProvider self.session ort.InferenceSession( onnx_path, providers[CPUExecutionProvider] ) self.input_name self.session.get_inputs()[0].name def segment(self, tongue_crop_bgr, target_size256): 输入检测模块裁剪出的舌体BGR图像输出二值掩码和三类比例。 target_size是U-Net训练时的输入尺寸推理时保持一致。 orig_h, orig_w tongue_crop_bgr.shape[:2] resized cv2.resize(tongue_crop_bgr, (target_size, target_size)) input_tensor resized.astype(np.float32) / 255.0 # 调整维度为NCHW格式ONNX Runtime要求这个布局 input_tensor np.transpose(input_tensor, (2, 0, 1))[None, ...] pred self.session.run(None, {self.input_name: input_tensor})[0] mask np.argmax(pred[0], axis0) # 取每个像素概率最大的类别 mask mask.astype(np.uint8) # 把掩码恢复原图尺寸用于后续特征提取 mask cv2.resize(mask, (orig_w, orig_h), interpolationcv2.INTER_NEAREST) # 舌体是前景类取值为1的像素背景为0 tongue_mask (mask 1).astype(np.uint8) * 255 return tongue_mask, mask逻辑说明ONNX模型的输出是形状为[1, num_classes, H, W]的概率图argmax沿通道维度取最大概率类别得到每个像素的类别编号。tongue_mask的值为0或255这正好是OpenCV二值图的标准格式方便后面用cv2.bitwise_and提取舌体区域像素。INTER_NEAREST插值必须用不能用默认的双线性插值否则掩码边缘会出现灰色过渡带影响后续的舌苔面积统计。得到整舌掩码后按中医分区规则划分区域。我采用的比例是舌尖占舌体纵向长度的前30%舌根占后25%中间45%再按左右分成舌中和舌边。这里用一个简单的几何切分函数实现。def split_zones(tongue_mask): 按中医舌诊分区把整舌掩码切成舌尖、舌中、舌根、舌边四个zone。 返回dict每个zone是形状和tongue_mask一致的0/255掩码。 h, w tongue_mask.shape # 找到舌体掩码的纵向范围避免把空白背景算进比例 rows np.where(tongue_mask.max(axis1) 0)[0] if len(rows) 0: return None top, bottom rows[0], rows[-1] length bottom - top 1 # 从舌根方向切分舌尖30%舌中45%舌根25% tip_y top int(length * 0.30) root_y bottom - int(length * 0.25) zones {} # 舌尖顶部到tip_y zones[tip] np.zeros_like(tongue_mask) zones[tip][top:tip_y, :] tongue_mask[top:tip_y, :] # 舌中tip_y到root_y再按左右分为脾区和肝区 middle np.zeros_like(tongue_mask) middle[tip_y:root_y, :] tongue_mask[tip_y:root_y, :] mid_left np.zeros_like(tongue_mask) mid_right np.zeros_like(tongue_mask) mid_left[:, : w // 2] middle[:, : w // 2] mid_right[:, w // 2 :] middle[:, w // 2 :] zones[spleen] mid_left zones[liver] mid_right # 舌根root_y到底部 zones[kidney] np.zeros_like(tongue_mask) zones[kidney][root_y:bottom, :] tongue_mask[root_y:bottom, :] return zones这个函数有两点要说明。第一切分前先通过np.where找到掩码的上下边界避免把没有舌头的背景区域算进分区比例。第二舌中按左右各半分成脾区和肝区这在中医理论上是可以商榷的——传统上左边候肝、右边候胆但胆又在肝的范畴里项目简化成左肝右脾或左右统称中焦都合理关键是你必须在权重表里保持一致。如果你想把分区做得更细可以训练一个关键点检测模型定位舌中线但对大多数项目来说几何中分就够了。3.3 特征提取颜色、纹理与舌苔分离的多维度特征向量特征提取是整个系统里最“计算机视觉”的部分。舌诊图像特征主要分三类颜色特征反映舌质和舌苔的颜色倾向纹理特征反映舌面粗糙度、裂纹和齿痕舌苔面积占比反映厚腻程度。import cv2 import numpy as np from skimage.feature import local_binary_pattern, graycomatrix, graycoprops def extract_features(crop_bgr, zones): 输入裁剪后的舌体图和四个zone掩码输出每个zone的特征字典。 特征包括LAB颜色均值、HSV颜色直方图、LBP纹理、GLCM对比度、舌苔占比。 # 统一转成LAB和HSV颜色空间 lab cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2LAB) hsv cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2HSV) features {} for zone_name, zone_mask in zones.items(): # 提取当前zone内的像素索引 idx zone_mask 0 if idx.sum() 50: features[zone_name] None continue lab_mean lab[idx].mean(axis0) # L, A, B三个通道的均值 hsv_hist cv2.calcHist([hsv], [0, 1], zone_mask, [18, 32], [0, 180, 0, 256]) hsv_hist cv2.normalize(hsv_hist, hsv_hist).flatten() # 灰度图做纹理特征LBP半径取3邻域16个点 gray cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2GRAY) lbp local_binary_pattern(gray, 16, 3, methoduniform) lbp_hist, _ np.histogram(lbp[idx], bins26, range(0, 26)) lbp_hist lbp_hist / (lbp_hist.sum() 1e-6) # GLCM计算0度、45度、90度、135度四个方向的对比度均值 glcm graycomatrix(gray[idx], distances[3], angles[0, np.pi/4, np.pi/2, 3*np.pi/4], levels256, symmetricTrue, normedTrue) contrast graycoprops(glcm, contrast).mean() features[zone_name] { lab_mean: lab_mean, hsv_hist: hsv_hist, lbp_hist: lbp_hist, glcm_contrast: contrast, zone_area_ratio: idx.sum() / (crop_bgr.shape[0] * crop_bgr.shape[1]) } return features参数说明local_binary_pattern的methoduniform模式输出26个bin其中前25个对应均匀模式的旋转不变LBP最后一个收纳所有非均匀模式这个设计对舌面纹理这种非刚性纹理很有效。GLCM的levels256对应灰度图的8bit深度distances[3]表示像素对距离3个像素距离太小捕捉不到裂纹太大会把正常纹理误判为粗糙。HSV直方图用[18, 32]两个维度分别量化色相和饱和度18个色相bin足以区分淡红、暗红、绛红这些舌色32个饱和度bin用于区分薄白苔和黄厚苔。舌苔分离是特征提取里最需要技巧的一步。舌苔在LAB空间的A通道红绿轴上相对舌质更偏向绿色在L通道亮度上更亮所以可以做一个简单的自适应阈值分离。def estimate_tongue_coating(crop_bgr, tongue_mask): 估算舌苔覆盖比例在LAB空间用K-Means把舌体像素聚类成两类 亮且偏绿的一类视为舌苔暗且偏红的一类视为舌质。 lab cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2LAB) pixels lab[tongue_mask 0].reshape(-1, 3).astype(np.float32) # K-Means聚类k2迭代10次用KMEANS_PP_CENTERS初始化避免局部最优 criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 10, 0.2) _, labels, centers cv2.kmeans(pixels, 2, None, criteria, 5, cv2.KMEANS_PP_CENTERS) # 亮度更高的那一类视为舌苔 coating_label 0 if centers[0][0] centers[1][0] else 1 coating_ratio (labels coating_label).sum() / len(labels) return coating_ratio, centers[coating_label]这个聚类方法不算完美但胜在无监督、不需要额外标注。它的前提假设是舌苔和舌质的亮度差异足够分开在实际照片中这个假设在大多数情况下成立。如果遇到严重黄苔或灰黑苔聚类中心的区分度会下降这时可以改用固定阈值例如L通道大于80且A通道小于140视为舌苔。固定阈值的好处是可解释性强坏处是你需要花时间调这三个阈值——建议先跑一百张图看聚类结果的A/L分布再决定是否改用阈值法。3.4 完整pipeline把检测、分割、特征提取串成一条可调用的链路把前面三个模块串起来得到系统的主流程函数。这个函数输入原始照片路径输出一整套特征字典它是后端API的核心调用对象。class TongueAnalysisPipeline: def __init__(self, detect_modelbest_detect.pt, unet_onnxbest_unet.onnx): self.detector TongueDetector(detect_model) self.segmentor TongueSegmentor(unet_onnx) def run(self, image_path): # 读图BGR格式 image_bgr cv2.imread(image_path) if image_bgr is None: raise ValueError(f无法读取图片: {image_path}) # 第一步检测舌体拿到裁剪图 bbox, crop, conf self.detector.detect(image_bgr) if bbox is None: return {success: False, error: 未检测到舌体请调整拍摄角度和光线} # 第二步分割舌体拿到整舌掩码 tongue_mask, class_mask self.segmentor.segment(crop) # 第三步生成四个zone掩码 zones split_zones(tongue_mask) if zones is None: return {success: False, error: 舌体掩码为空分割失败} # 第四步提取每个zone的特征 zone_features extract_features(crop, zones) # 第五步计算舌苔占比 coating_ratio, coating_center estimate_tongue_coating(crop, tongue_mask) # 第六步计算模糊度用于质量检测 gray cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) laplacian_var cv2.Laplacian(gray, cv2.CV_64F).var() result { success: True, detect_conf: conf, coating_ratio: float(coating_ratio), blur_score: float(laplacian_var), zones: {}, } for zone_name, feat in zone_features.items(): if feat is not None: result[zones][zone_name] { lab_mean: feat[lab_mean].tolist(), glcm_contrast: float(feat[glcm_contrast]), zone_area_ratio: float(feat[zone_area_ratio]), hsv_hist: feat[hsv_hist].tolist(), lbp_hist: feat[lbp_hist].tolist(), } return resultpipeline中有一个容易被忽略的环节模糊检测。用户上传的照片可能是随手拍的手抖造成的运动模糊会在纹理特征上产生巨大干扰——GLCM对比度会告诉你舌面像砂纸一样粗糙但那是模糊的伪影不是真实的病理纹理。我加了一个Laplacian方差计算值低于80就提示用户重新拍摄。这个阈值来自经验室内正常光线下手机拍摄的舌照Laplacian方差一般在100到300之间低于80的图像在舌苔纹理判断上基本不可信。4. 五脏健康指数的计算从图像特征到专家经验权重的映射4.1 评分卡设计单特征打分加专家权重的两层结构特征提取完成后系统得到的是一堆数值四个zone的LAB均值、HSV直方图、LBP纹理、GLCM对比度以及舌苔占比。这些数值不能直接作为“心肝脾肺肾”的指数——它们只是图像物理量需要先映射到中医语义层面。我采用的做法是评分卡模型这是金融风控和医疗评分里用得最成熟的解释性模型分两层第一层是单特征打分把一个连续特征值映射到0到100的分数。例如舌苔占比这个特征经验规则是薄白苔健康占比在5%到15%之间记为80分超过30%视为厚腻记为30分低于2%可能提示镜面舌记50分。第二层是专家权重加权每个五脏指数由多个特征按不同权重加权求和。例如“脾指数”主要由舌中zone的舌苔占比、舌色LAB-A值、舌中纹理粗糙度决定权重分别是0.4、0.3、0.3。这层设计有一个很关键的好处可解释性。最终输出的指数下面挂着“脾指数72分舌苔适中、舌色淡红、纹理平滑”这类文字用户和医生都能看懂判断依据。如果你用端到端的深度学习模型去回归五脏指数模型自己都说不清为什么给72分这在医疗场景里是没法交付的。4.2 代码实现权重查表、分项计算与综合指数输出权重表用JSON格式维护方便中医专家直接修改参数而不用碰代码。这个设计很重要专家可能不懂Python但一定能看懂JSON文件里的权重和评分区间。import json import numpy as np # weight_config.json 核心内容示意 WEIGHT_CONFIG { heart: { features: [tip_lab_a, tip_lab_b, tip_texture], weights: [0.5, 0.3, 0.2], labels: [舌尖红润度, 舌尖色泽, 舌尖平滑度] }, liver: { features: [liver_lab_a, liver_lab_b, liver_texture], weights: [0.4, 0.3, 0.3], labels: [舌边色泽, 舌边暗沉, 舌边纹理] }, spleen: { features: [spleen_coating, spleen_lab_a, spleen_texture], weights: [0.4, 0.3, 0.3], labels: [舌苔厚度, 舌中舌色, 舌中纹理] }, lung: { features: [tip_coating, tip_texture, tip_lab_l], weights: [0.4, 0.3, 0.3], labels: [舌尖舌苔, 舌尖纹理, 舌尖亮度] }, kidney: { features: [kidney_lab_a, kidney_lab_b, kidney_lab_l], weights: [0.4, 0.3, 0.3], labels: [舌根色泽, 舌根暗沉, 舌根亮度] } } def feature_to_score(feature_name, value): 单特征打分函数把图像特征值映射到0-100分。 这里用简单分段线性函数实际项目里可以换成查表或拟合曲线。 if lab_a in feature_name: # A通道反映红绿偏向舌质以淡红为佳A值在138-145区间记高分 if value 130: return max(20, 50 - (130 - value) * 3) elif value 145: return 90 - abs(value - 141) * 2 else: return max(20, 70 - (value - 145) * 4) elif coating in feature_name: # 舌苔占比5%-15%为薄白苔记高分 if value 0.02: return 50 elif value 0.05: return 70 elif value 0.15: return 90 elif value 0.30: return 55 else: return 30 elif texture in feature_name: # GLCM对比度舌面平滑为佳 if value 50: return 85 elif value 120: return 65 else: return 40 elif lab_l in feature_name: # L通道亮度 if value 60: return 45 elif value 100: return 80 else: return 60 else: return 60 # 默认给中等分避免未知特征拉低整体 def compute_organ_index(zones_features, coating_ratio, configWEIGHT_CONFIG): 计算五脏指数。zones_features是pipeline输出的特征字典。 coating_ratio是整舌舌苔占比。返回各脏器指数和文字解释。 # 先把特征值整理成扁平结构方便按config取值 flat {} for zone, feat in zones_features.items(): if feat is None: continue flat[f{zone}_lab_a] feat[lab_mean][1] flat[f{zone}_lab_b] feat[lab_mean][2] flat[f{zone}_lab_l] feat[lab_mean][0] flat[f{zone}_texture] feat[glcm_contrast] # 舌中zone的舌苔占比用整舌舌苔占比代替舌尖zone同理 flat[spleen_coating] coating_ratio flat[tip_coating] coating_ratio organ_indices {} explanations {} for organ, rule in config.items(): weighted_sum 0 total_weight 0 score_parts [] for feature_name, weight, label in zip(rule[features], rule[weights], rule[labels]): if feature_name not in flat: continue score feature_to_score(feature_name, flat[feature_name]) weighted_sum score * weight total_weight weight score_parts.append(f{label}{score:.0f}分) if total_weight 0: organ_indices[organ] int(weighted_sum / total_weight) explanations[organ] , .join(score_parts) return organ_indices, explanations逻辑说明feature_to_score里每个分段阈值都是经验值你需要用一批标注好的舌照去校准。校准的方法很简单找一位中医师对50张舌照按五脏健康程度打分0-100然后调整分段阈值让系统输出的指数和专家打分的均方误差最小。这个过程不需要梯度下降手动调三四轮就能收敛因为评分卡的结构本身就是线性的。参数说明里最需要注意的是lab_a通道的映射——OpenCV的LAB空间A通道取值范围是0到255中心值128附近对应中性色大于128偏红、小于128偏绿。舌质淡红的A值通常在140到150之间所以我把高分区间设在135到145。权重表中有一个容易出错的细节五脏和zone的对应关系不能错位。舌尖对应心和肺舌中对应脾舌边对应肝舌根对应肾。如果你把舌尖的特征拿去算“肾指数”虽然分数算得出来但医学语义上完全不通专家一看就会打回票。所以代码里config字典的键名和split_zones输出的zone名需要严格对应建议在pipeline层加一个断言校验。4.3 权重表从哪来“专家经验权重”的数据来源与可信度边界“专家经验权重”这个词经常被做成黑匣子——对外宣称是专家标定实际是开发者拍脑袋填的。这个项目的可信度边界必须说清楚权重表不是深度学习学出来的而是知识工程写出来的。它的来源通常有两种。第一种是文献调研加专家访谈。中医舌诊专著里对五脏配属关系有相对公认的论述比如“舌尖红赤多属心火亢盛”“舌中苔厚腻多属脾胃湿阻”。你可以把这些论述转译成评分规则再请两到三位中医师对规则打分、提修改意见。第二种是小样本专家标注校准。找一批典型的舌照请专家对每个特征和对应脏器打分然后用统计方法计算每个特征的贡献度反推权重。这个做法更严谨但样本量至少要50张以上否则权重不稳定。我的建议是第一版先用文献转译的权重表跑通系统然后在演示和试用中收集反馈再迭代调整。不要想着一上来就搞大样本专家标注——那需要伦理审批和临床资源作为工程原型先把链路打通更重要。同时要在产品说明里写清楚输出的是“健康指数量化参考”不是医疗诊断。这套系统的价值在于辅助中医师快速采集和对比舌象特征而不是取代医生的判断。5. 踩坑记录舌诊图像分析里躲不开的五个大坑5.1 检测框抖动同一个舌头在不同光照下坐标漂移20个像素现象用户在不同时间上传同一舌头的照片检测框位置和大小有明显变化导致后续分割和特征提取的区域不一致同一舌头的两次分析结果差了十几分。原因YOLO检测框对光照敏感。暗光下舌体边缘的对比度下降检测器会把背景中的暗部区域也框进来强光下舌尖反光区域被检测器当成边界导致检测框收缩。此外用户拍摄时的舌头伸出口腔的程度不同也直接影响检测框的尺寸。解决我给检测模块加了两层保护。第一层是检测框外扩后不再直接裁切而是以检测框中心为基准裁剪一个正方形区域——正方形的边长是检测框宽高的1.2倍这样即使检测框轻微漂移裁出的正方形区域也能覆盖完整舌体。第二层是在分割阶段不依赖检测框的精确边界只用它做粗定位最终的特征提取区域全部由U-Net的像素级掩码决定。换句话说检测负责“找到舌头在哪一坨”分割负责“精确画出舌头的边”前者允许有误差后者必须稳定。5.2 手机白平衡把舌色从淡红变成暗红现象同一舌头在暖光灯下拍摄舌色偏红黄在日光灯下拍摄偏蓝白。LAB空间A通道的均值漂移幅度能达到8到10个值远超正常舌色的个体差异范围。这直接摧毁了以颜色为核心特征的指数。原因手机相机的自动白平衡AWB会根据场景调整色温舌体在画面中占比大且偏红色相机为了把画面调“正”会主动削弱红色分量结果就是舌头越红相机越往青色调颜色特征被系统性地扭曲。解决这是舌诊图像分析最让人头疼的问题老实说没有完美解法只能尽量缓解。我的做法是在采集端加校准提示建议用户使用纯白背景并在舌体旁边放置一张标准白卡处理端用白卡区域做色彩校正把白卡的平均颜色映射到标准白色LAB的L98, A0, B0然后对整个图像做颜色迁移变换。这个校正可以把不同光源下的LAB均值差异压缩到3个值以内。如果你的系统走的是线上匿名上传模式没法保证用户放白卡至少要在后端检测白平衡异常——如果整张图像的A通道均值与舌体区域的A通道均值差值超过阈值就提示用户在更中性的光源下重拍。5.3 舌根被遮挡“肾指数”平均缺失率超过三成现象很多用户拍照时舌头没有完全伸出口腔舌根区域被软腭和口腔暗影遮挡U-Net分割出的舌体掩码在舌根处断掉kidney特征为None导致肾指数算不出来。原因这是数据分布问题。训练分割模型时训练集中的舌根往往是完整的模型没见过被遮挡的情况推理时遇到遮挡模型倾向于把遮挡区域也预测成背景而不是给出低置信度的前景。更深层的原因是舌根本身在图像中的表现力弱——没有牙齿和嘴唇作为参考边界舌根和口腔黏膜的颜色相近分割边界天然模糊。解决我在split_zones里给每个zone加了最小面积判断如果掩码面积小于整个舌体面积的8%就不再为这个zone生成特征而是标记为“区域缺失”。同时在compute_organ_index里对缺失zone做权重重归一化——比如肾指数本该由3个特征计算如果舌根区域缺失就用剩余的2个特征按比例折算。另外我在拍摄指引里明确要求用户“伸舌时舌尖轻触下唇舌体放松放平”这个简单的拍摄规范能把遮挡率降低一半以上。你也可以在分割模型训练时加入随机遮挡的数据增强模拟舌根被口腔阴影遮住的情况增强模型的鲁棒性。5.4 齿痕和裂纹在低分辨率下直接消失现象部署初版系统时齿痕检测和裂纹计数在测试集上表现尚可但用户手机上传的照片普遍像素不够很多齿痕和裂纹在缩放到分割模型的输入尺寸256x256后变得不可见特征值趋近于零。原因齿痕和裂纹是中高频图像细节它们的尺度通常在5到15个像素之间。图像被缩放到256x256时这些细节要么被池化掉要么被插值模糊掉。U-Net的编码器下采样四次之后特征图分辨率只有原图的1/16细节信息基本丢失。解决这个问题我换了思路——不再依赖分割模型的输出做齿痕检测而是单独跑一个高分辨率滑窗检测。具体做法是把舌体裁剪图放大到原始分辨率在舌边缘区域用64x64的滑窗配合一个小的二分类网络有齿痕/无齿痕做检测。小网络输入分辨率高、感受野小对齿痕这种局部形态特征比U-Net敏感得多。同样的思路也适用于裂纹检测不过裂纹是线状结构我用了OpenCV的形态学顶帽变换加骨架提取在CPU上实时就能出结果。实践下来滑窗加小网络可以把齿痕检测的F1-score从0.55提升到0.78。5.5 数据增强做过头深色舌苔被增强成黑色噪声现象训练分割模型时使用了随机亮度扰动、对比度扰动和HSV抖动结果验证集IoU不升反降。检查输出掩码发现模型把舌体边缘的阴影区域误判为背景原本应该是黄腻苔的区域被预测成黑色背景。原因数据增强的幅度设置过大。HSV抖动中V通道明度的扰动范围达到正负50S通道饱和度扰动范围达到正负40这已经超出了舌体图像真实拍摄条件下的变化范围。过强的亮度扰动把深色舌苔变成了接近纯黑的像素模型学会了把过暗区域当成背景严重破坏了舌苔和背景的区分度。解决数据增强不是越强越好尤其是医学图像增强范围必须和真实场景的分布对齐。我重新统计了200张真实舌照的HSV分布把V通道的扰动范围缩小到正负15S通道缩小到正负20并把随机亮度扰动设为正负10%。另外增加了一个和本项目强相关的增强策略——随机伽马校正模拟不同显示设备上的亮度差异这个增强对舌体这种大面积平坦区域比HSV抖动更安全。修改后验证集IoU回升了3个百分点深色舌苔的召回率恢复到了可接受水平。6. 验证你的系统用“重拍一致性”和“特征可解释性”代替玄学准确率6.1 三个验证视角可重复、可解释、可查表舌诊分析系统没有金标准——你不能拿五脏指数去跟抽血化验比因为中医的“证”本身就没有仪器可以客观化测量。所以验证一套系统我只看三个维度。第一个维度是重拍一致性同一受试者在同一时间段拍三张舌照系统输出的五个指数方差越小越好。这是最能反映系统工程稳定性的指标。第二个维度是特征可解释性让中医专家盲看系统的中间输出——舌苔占比、舌色A值、纹理对比度——看专家是否认可这些量化结果和肉眼观察的一致性。第三个维度是评分卡可查表任取一个脏器的指数你都能从权重表反推出是哪个特征拉了分。如果你发现自己从权重表和特征值推不出指数分数那说明代码逻辑有问题而不是“深度学习玄学”。6.2 一个可以马上跑的鲁棒性脚本下面这个脚本能帮你快速评估重拍一致性。取三张同一舌头的照片分别跑完整pipeline输出五个脏器的指数计算标准差。标准差超过8分的脏器说明该脏器对应的特征提取不稳定。import glob from tongue_pipeline import TongueAnalysisPipeline, compute_organ_index import numpy as np pipeline TongueAnalysisPipeline(weights/best_detect.pt, weights/best_unet.onnx) # 假设 test_set/same_tongue/ 下放了3张同一舌头的照片 image_paths sorted(glob.glob(test_set/same_tongue/*.jpg)) assert len(image_paths) 3, 至少需要3张重拍照片 organ_scores {organ: [] for organ in [heart, liver, spleen, lung, kidney]} for path in image_paths: result pipeline.run(path) if not result[success]: print(f检测失败: {path}, {result[error]}) continue # actual_config是上一章的权重配置这里直接传入compute_organ_index indices, _ compute_organ_index(result[zones], result[coating_ratio]) for organ, score in indices.items(): organ_scores[organ].append(score) for organ, scores in organ_scores.items(): if len(scores) 3: std_val np.std(scores) mean_val np.mean(scores) status 稳定 if std_val 8 else 不稳定需检查该脏器的特征提取 print(f{organ}: 均值{mean_val:.1f}, 标准差{std_val:.1f}, {status})这个脚本的验证逻辑是如果某个脏器的指数在不同照片间大幅波动优先检查对应zone的特征提取——常见的是舌根遮挡导致特征缺失或者舌边mask不稳定导致左右交替影响肝和脾的指数。我通常在每次修改特征提取逻辑后都跑一遍这个脚本用标准差做回归测试防止改一个特征把另一个zone搞坏。最后说一个我自己的习惯每次迭代权重表或特征提取模块我都会保存一组典型的测试图片和对应的“期望输出”——这些期望输出是我和中医师逐张讨论后确认的参考结果。模型改了跑一遍回归看哪些图片的指数偏差变大偏差大的就人工复查。这个习惯帮我挡住了好几次回归事故也让我面对“系统输出不准”的质疑时能快速定位是特征的问题、权重的问题还是图片质量的问题。希望这套从选型到验证的路径能帮到你少走我踩过的弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站