
简介基于Python机器学习的舌苔检测系统整合配套论文与完整源码面向毕业设计、课程设计、大作业或工程实训项目也适合希望系统学习深度学习图像分类的初学者与进阶者。系统结合中医舌诊理论采用EfficientNet网络对舌象图片进行自动分类能够较精准地分辨多种舌象特征辅助判断体质与健康状态。压缩包共113个文件大小约105MB主要包含Python源码及编译文件py/pyc、模型权重pth、JSON配置、UI界面文件、图片样本、Word论文文档以及TensorFlow训练日志等目录结构清晰覆盖数据准备、模型训练、效果评估到界面展示的完整流程。已有311人学习下载。借助这套资源读者可以了解舌苔数据集的构建与标注方法、EfficientNet的迁移学习与调参细节还能基于现有代码快速复现实验并根据自身需求改进模型或扩展界面功能为完成课题研究、撰写论文或准备答辩提供切实支撑。1. 舌苔检测系统到底在做什么先别急着写代码想清楚你要检测的是什么第一次拿到“基于python机器学习的舌苔检测系统论文源码”这个题目你大概率会以为又是一个图像分类demo喂一批舌头照片训练一个模型能输出“有苔/无苔”就行。但我做下来之后要泼一盆冷水——这个项目真正的难点不在模型而在数据。舌苔检测本质上是一套用Python实现图像采集、预处理、特征提取、模型训练和结果可视化的完整机器学习应用流程目标是把中医舌诊里的“苔色、苔质、厚薄”这些主观描述变成可量化的分类结果。它适合两类人一类是做毕设或课程设计的计算机学生想找一个既涉及图像处理又有模型对比的题目另一类是中医信息化方向的研究者拿它做远程问诊、体质辨识的辅助工具。在动手写代码前先想清楚你的输出是什么是输出“薄白苔/厚白苔/黄腻苔/剥苔”几个类别还是只输出一个“正常/异常”的二分类这个决定会直接影响后面的数据集规模和标注成本。2. 舌苔图像的数据准备从采集到标注的完整流程2.1 舌苔图像采集光源、角度和手机摄像头够不够用很多人会纠结要不要用专业单反或医用舌象仪。我的答案是如果你的目标是跑通系统、写论文手机摄像头完全够用如果你要做严肃的临床辅助那才需要上标准化采集设备。但无论用什么拍有三个参数必须固定下来。第一个是光源。舌苔颜色是最主要的区分特征而自动白平衡会让同一张嘴在不同环境下拍出完全不同的颜色。最省事的做法是白天在窗边用自然光避免黄钨丝灯和荧光灯混合。第二个是拍摄角度。让被测者把舌头自然伸出舌尖略微向下舌面尽量平展镜头与舌面垂直。角度偏了舌头会变形舌苔的纹理特征会被拉长或压缩。第三个是拍摄范围——要把整个舌体拍进来同时不要让快门闪光直射否则舌面会出现高光区那一块的高光在预处理里很难去掉。数据集规模上常见做法是每个类别至少准备200张总共1000张以上才稳。不要直接从搜索引擎爬图因为你不知道图片的版权更糟糕的是爬来的图尺寸、色温、舌形千奇百怪很难建立一个统一的预处理流程。我一般会用一个简单的表格记录每张图的元信息文件名、受试者编号、拍摄设备、环境光类型、标注类别、是否被剔除。这个表在后续做“按受试者划分数据集”时非常有用这也是避免数据泄漏的关键。2.2 舌头区域分割不用深度学习之前怎么快速抠图拿到原始照片后第一件事是去掉背景和脸部的干扰。舌体本身是红色或粉红色面部也是红色直接RGB阈值很容易把脸也框进来。我一般用HSV颜色空间来做因为HSV里色调H对红色系的区分更稳定也不会被亮度变化带偏。下面这段是用OpenCV做舌体区域提取的代码也是我跑通整个流程的第一步import cv2 import numpy as np def extract_tongue_region(img_path): img cv2.imread(img_path) hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 舌体在HSV中的常见范围需要根据自己的数据集微调 lower np.array([0, 30, 60]) # H低阈值红色起始 upper np.array([15, 255, 255]) # H高阈值红色结束10-15为偏橙红 mask cv2.inRange(hsv, lower, upper) # 形态学闭运算填掉舌面上的裂缝和纹理空隙 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 取最大连通区域去掉脸部或其他皮肤区 contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None, None max_contour max(contours, keycv2.contourArea) mask_clean np.zeros_like(mask) cv2.drawContours(mask_clean, [max_contour], -1, 255, thicknesscv2.FILLED) # 用掩膜抠出舌体 result cv2.bitwise_and(img, img, maskmask_clean) return result, mask_clean这段代码里最需要调的是lower和upper两个阈值。[0, 30, 60]的含义是H在0到15之间红色系S饱和度不低于30V亮度不低于60。S和V设太低会把嘴唇、暗部皮肤也纳进来设太高又会把舌根处的阴影区域切掉。调参数时不要只看一张图至少跑十张不同人、不同光线的图统计mask的面积是否覆盖整个舌体。闭运算的kernel大小15,15是我试出来的经验值如果舌面裂纹太大可以适当加大到20但kernel太大容易把舌尖和舌体之间的轮廓吃进去。2.3 数据增强与样本均衡别让小样本毁了你的模型假设你辛苦拍到了800张图分6类每类约130张。这点数据对深度学习来说太少对传统机器学习来说也不够用来充分训练一个复杂的SVM。所以数据增强是必须要做的。但对于舌苔图像增强幅度要格外克制——舌苔识别对颜色和纹理高度敏感如果旋转超过±20度舌形就失真了如果亮度扰动太猛黄苔可能被改成白苔。我常用的增强参数范围水平翻转随机概率0.5注意舌尖本身有左右偏翻转会改变舌象的左右对称性吗舌苔分布不对称也是特征所以不要轻易翻转旋转±10度缩放0.9到1.1倍亮度乘性因子0.85到1.15高斯噪声均值为0方差为5到10下面这段使用scikit-image和numpy手工做增强比用Keras的ImageDataGenerator更可控import numpy as np from skimage import transform, exposure, util def augment_tongue(img, augment_prob0.7): if np.random.random() augment_prob: return img, False # 随机旋转 angle np.random.uniform(-10, 10) img transform.rotate(img, angle, modereflect, preserve_rangeTrue) # 随机缩放 factor np.random.uniform(0.9, 1.1) img transform.resize(img, (int(img.shape[0]*factor), int(img.shape[1]*factor)), modereflect, preserve_rangeTrue) # 亮度扰动 img exposure.adjust_gamma(img, gammanp.random.uniform(0.85, 1.15)) # 加高斯噪声 img util.random_noise(img, modegaussian, var0.005) return (img * 255).astype(np.uint8), True注意adjust_gamma的gamma参数小于1会变亮大于1会变暗。实际处理时每个样本保存为单独的增强副本不要在内存里做流式增强不然论文里没法复现。增强后的数据集需要和原始数据分开存放目录结构建议是data/train/class_name/*.jpg这样后续用ImageFolder或自制读取脚本时都不容易出错。样本不均衡也是个难题。舌苔类别天然不均衡“薄白苔”可能占了40%而“剥苔”只有5%。解决方式我优先推荐在训练时给少数类别加权而不是简单复制少数类样本。在sklearn的SVM里可以直接设置class_weightbalanced它会按类别频率自动计算权重。如果做了加权之后少数类还是不行再考虑用SMOTE对特征空间做过采样——但注意SMOTE要在特征提取之后、归一化之前做不要对原始图片做合成。3. 舌苔特征提取与模型选型HOG/颜色直方图到CNN的取舍3.1 传统机器学习特征颜色空间和纹理描述子怎么组合很多人一上来就想用CNN但在我做过的这个小规模数据上传统机器学习的特征往往能拿到和简单CNN差不多的精度而且训练速度快了一个数量级。舌苔的特征主要分两块颜色和纹理。颜色方面不要直接用RGB而应该转成LAB颜色空间。L是亮度a和b是颜色对立轴。舌苔的黄腻程度主要体现在b轴黄蓝轴上而白苔的厚度则和L值相关。对每个通道统计均值和标准差再做一个32bin的直方图比直接拉平像素要稳定得多。纹理方面我用的是LBP局部二值模式和GLCM灰度共生矩阵的组合。LBP能捕捉舌苔表面的颗粒状和裂纹GLCM能反映纹路的方向性。但LBP的特征维度很高原始radius1, n_points8会生成256维直方图通常我会用uniform模式降成59维。下面是完整的特征提取函数import cv2 import numpy as np from skimage.feature import local_binary_pattern, graycomatrix, graycoprops def extract_features_from_tongue(tongue_img): # tongue_img 是BGR的舌体区域 lab cv2.cvtColor(tongue_img, cv2.COLOR_BGR2LAB) features [] # 1. LAB三通道均值和标准差 for ch in range(3): channel lab[:,:,ch] features.append(np.mean(channel) / 255.0) features.append(np.std(channel) / 255.0) # 2. 每个通道的直方图取32个bin for ch in range(3): hist, _ np.histogram(lab[:,:,ch].ravel(), bins32, range(0, 255)) hist_norm hist.astype(np.float32) / (hist.sum() 1e-6) features.extend(hist_norm) # 3. 在灰度图上提取LBP和GLCM gray cv2.cvtColor(tongue_img, cv2.COLOR_BGR2GRAY) lbp local_binary_pattern(gray, P8, R1, methoduniform) lbp_hist, _ np.histogram(lbp.ravel(), binsnp.arange(0, 60), range(0, 59)) lbp_hist lbp_hist.astype(np.float32) / (lbp_hist.sum() 1e-6) features.extend(lbp_hist) # GLCM计算0,45,90,135四个方向的对比度和能量 glcm graycomatrix(gray, distances[1], angles[0, np.pi/4, np.pi/2, 3*np.pi/4], levels256, symmetricTrue, normedTrue) for angle_idx in range(4): glcm_angle glcm[:,:,0,angle_idx] contrast graycoprops(glcm_angle, contrast)[0][0] / 1000.0 energy graycoprops(glcm_angle, energy)[0][0] features.extend([contrast, energy]) return np.array(features, dtypenp.float32)这里有几个参数说明P8, R1是LBP最常用的配置虽然纹理粗但对低分辨率图片更鲁棒。graycoprops返回的是2D数组用[0][0]取第一行第一列是因为单距离单方向时矩阵是1x1。对比度除以1000是为了把数值压缩到和颜色特征同一量级否则SVM会被大数值特征主导。整个特征向量维度是32 332 59 4*2 127维。对小数据集来说127维已经足够表达再高反而容易过拟合。3.2 分类模型对比SVM、随机森林和轻量CNN在我实验里的表现在同样的数据集上我分别试了SVMRBF核、随机森林和一个小型CNN3层卷积2层全连接。下面是我某一个私有数据集上的实测数字不能代表普遍情况但方向可以给你参考模型特征/输入平均准确率训练时间800样本模型体积SVM (RBF核, C1, gammascale)上面127维特征0.873秒0.2MB随机森林 (n_estimators500, max_depth10)上面127维特征0.8315秒12MB轻量CNN (3Conv2FC, 64-128-256)原始图像resize到128x1280.8555秒/epoch约50epoch收敛25MB这个结果说明几个问题第一传统特征SVM在小数据上根本不虚CNN第二随机森林虽然更抗过拟合但对连续型的颜色特征不如SVM的核方法直观第三如果你的数据只有几百张CNN很容易在50个epoch内过拟合需要在训练时加DataAugmentation和Dropout才能稳住。所以我的选型建议是先把传统特征SVM当作基线跑通然后根据实际精度决定要不要上CNN。论文里最怕的是没有基线对比如果你只放一个CNN的结果答辩时被问“为什么不用传统方法验证”就会很被动。而有了这个对比表你的论文实验章节就丰满起来了。3.3 训练参数与验证指标精确率、召回率之外还要看混淆矩阵确定用SVM后有几个参数必须解释清楚。C是正则化系数的倒数C越小正则越强gamma是RBF核的宽度gamma越大决策边界越复杂。在sklearn里我习惯用GridSearchCV来搜索C在[0.1, 1, 10, 100]、gamma在[0.001, 0.01, 0.1, scale]的范围内组合但搜索前要先把特征做归一化。SVM对特征的尺度极度敏感颜色直方图的数值范围在0~1而LBP分布也在0~1但GLCM的对比度没有归一化所以一定在训练前用StandardScaler做标准化。下面是训练和验证的核心代码import joblib from sklearn.svm import SVC from sklearn.model_selection import train_test_split, GridSearchCV from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline # X是特征矩阵y是标签已经编码成0,1,2... X, y load_feature_data() # 自己写读取函数 # 先按受试者分组避免同一个人的舌头出现在训练和测试里 # 这里假设X_format里有subject_id列没有则跳过 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy) pipe Pipeline([ (scaler, StandardScaler()), (svc, SVC(kernelrbf, class_weightbalanced, probabilityTrue)) ]) param_grid { svc__C: [0.1, 1, 10, 100], svc__gamma: [0.001, 0.01, 0.1, scale] } grid GridSearchCV(pipe, param_grid, cv5, scoringf1_macro, n_jobs-1) grid.fit(X_train, y_train) print(best params:, grid.best_params_) print(test acc:, grid.score(X_test, y_test)) # 保存模型和归一化器两者都在pipe里 joblib.dump(grid.best_estimator_, tongue_svm_model.joblib)这里stratifyy是必须的它会按类别比例划分训练集和测试集否则某类只有一个样本时可能全被分到测试集。scoringf1_macro比默认的accuracy更合适因为舌苔类别不均衡时accuracy会偏向多数类。probabilityTrue允许在推理时输出每个类别的概率方便后面画ROC曲线和做阈值调整。训练完之后还一定要看混淆矩阵因为准确率会骗人。比如“薄白苔”和“厚白苔”颜色相近模型可能会把它们互相认错而“剥苔”因为舌苔剥落、舌质暴露红色更明显反而容易被识别出来。混淆矩阵能让你看到哪些类别是真正难分的从而决定是否要增加该类别的样本或者调整特征权重。4. 从训练到落地用Python搭一个可复现的舌苔检测流程4.1 项目结构与核心脚本当你把“论文源码”作为交付物时代码的可复现性比功能更可怕。我见过太多课程设计源码是没有固定随机种子、依赖不锁版本、路径写死到C:\Users\张三\Desktop的。这样的源码给评审老师看老师大概率跑不起来你再想解释都很被动。我自己的项目目录结构是这样tongue_detection/ ├── config.py # 所有路径和超参数配置 ├── data/ │ ├── raw/ # 原始图片按类别分文件夹 │ └── processed/ # 增强后的图片或提取的特征npy文件 ├── src/ │ ├── preprocessing.py # 分割、增强、特征提取 │ ├── train.py # 训练脚本 │ ├── inference.py # 单张图片推理 │ └── evaluate.py # 生成混淆矩阵、ROC图 ├── models/ # 保存训练好的模型文件 ├── results/ # 输出图表和指标 ├── requirements.txt └── README.mdconfig.py里把所有可调参数集中在一个地方是一个能救命的设计。比如数据路径、图片尺寸、SVM的C和gamma、增强开关、随机种子都放进config里。换一台电脑跑的时候只需要改config.py的路径而不是去改业务代码。下面是我习惯的config.py写法import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) RAW_DATA_DIR os.path.join(BASE_DIR, data, raw) PROCESSED_DIR os.path.join(BASE_DIR, data, processed) MODEL_DIR os.path.join(BASE_DIR, models) RESULT_DIR os.path.join(BASE_DIR, results) SEED 42 IMG_SIZE 128 TEST_SIZE 0.2 CV_FOLDS 5 ENABLE_AUGMENT True ENABLE_WHITE_BALANCE True注意这里用了os.path.dirname(os.path.abspath(__file__))拿绝对路径而不是../data这种相对路径。__file__是当前文件所在位置abspath是绝对路径这样做的好处是无论从哪个目录执行脚本路径都不会跑偏。4.2 预处理与特征提取的示例代码可抄作业下面是一段可直接运行的流程代码把“读原图 → 分割 → 预处理 → 特征提取 → 组织特征向量”串起来。我用tkinter做一个简单的批处理入口但核心仍然是函数化设计import cv2 import numpy as np import os from preprocessing import extract_tongue_region from feature_extraction import extract_features_from_tongue from config import RAW_DATA_DIR, PROCESSED_DIR, SEED def preprocess_all_images(): 遍历raw目录下所有类别的图片提取特征并保存为npy np.random.seed(SEED) X_list, y_list [], [] class_names sorted(os.listdir(RAW_DATA_DIR)) class_to_idx {name: i for i, name in enumerate(class_names)} for class_name in class_names: class_dir os.path.join(RAW_DATA_DIR, class_name) if not os.path.isdir(class_dir): continue for filename in os.listdir(class_dir): if not filename.lower().endswith((.jpg, .jpeg, .png)): continue img_path os.path.join(class_dir, filename) tongue, mask extract_tongue_region(img_path) if tongue is None: print(fsegmentation failed: {img_path}) continue # 把分割出来的舌体统一resize到固定尺寸避免特征尺度不一致 tongue cv2.resize(tongue, (128, 128), interpolationcv2.INTER_CUBIC) feature extract_features_from_tongue(tongue) X_list.append(feature) y_list.append(class_to_idx[class_name]) X np.array(X_list) y np.array(y_list) np.save(os.path.join(PROCESSED_DIR, X.npy), X) np.save(os.path.join(PROCESSED_DIR, y.npy), y) print(fsaved {X.shape[0]} samples with {X.shape[1]} features) print(class mapping:, class_to_idx) return X, y, class_to_idx这段代码的逻辑说明为什么要resize到128x128因为LBP的R1在原始大图上会丢失细小纹理统一尺寸可以保证特征计算的一致性但也不建议缩到太小否则颜色直方图会失去空间信息。分割失败的图直接continue并打印出来这是好事——你可以在日志里发现哪些图需要重新调整阈值而不是静默地放弃样本。保存成npy的好处是之后训练模型不需要再重新读图、分割、提特征几秒钟就能加载到位也方便每次实验复用同一份特征做对比。4.3 模型训练与保存的示例代码训练脚本要分成“加载特征 → 划分数据集 → 网格搜索 → 保存模型”四步。上面已经给过SVM训练代码这里补一个细节网格搜索完成之后除了保存模型还应该把测试集上的预测结果一起保存成CSV方便后续画图和写论文。代码import numpy as np import pandas as pd import joblib from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report, confusion_matrix from config import PROCESSED_DIR, MODEL_DIR, RESULT_DIR, TEST_SIZE, SEED X np.load(os.path.join(PROCESSED_DIR, X.npy)) y np.load(os.path.join(PROCESSED_DIR, y.npy)) X_train, X_test, y_train, y_test train_test_split( X, y, test_sizeTEST_SIZE, random_stateSEED, stratifyy) # 假设已经通过GridSearchCV得到best_estimator_ model joblib.load(os.path.join(MODEL_DIR, tongue_svm_model.joblib)) y_pred model.predict(X_test) y_prob model.predict_proba(X_test) # 保存预测概率用于ROC曲线 pd.DataFrame(y_prob, columns[fprob_{i} for i in range(y_prob.shape[1])]).to_csv( os.path.join(RESULT_DIR, test_prob.csv), indexFalse) # 保存分类报告 report classification_report(y_test, y_pred, output_dictTrue) pd.DataFrame(report).transpose().to_csv(os.path.join(RESULT_DIR, classification_report.csv))这里的核心逻辑是先加载已经保存的特征矩阵这是为了让实验可以重复。classification_report会直接生成每个类的精确率、召回率、F1分数这个表格拷到论文里当结果展示非常方便。但注意output_dictTrue返回的是嵌套字典转成DataFrame后行是类别名列是指标保存成了CSV后再在论文里用三线表排版。4.4 对单张图片的推理函数系统最终要给一张新图片做检测所以inference.py要把整个pipeline串起来读图、分割、resize、特征提取、标准化、SVM预测。注意标准化这一步不能遗忘因为StandardScaler是在训练时拟合训练集的保存模型时要把整个pipeline保存下来而不是只保存SVM。import cv2 import joblib import numpy as np from preprocessing import extract_tongue_region from feature_extraction import extract_features_from_tongue def predict_tongue(img_path, model_pathmodels/tongue_svm_model.joblib): # 加载pipeline包含scaler和svc pipe joblib.load(model_path) img cv2.imread(img_path) tongue, mask extract_tongue_region(img_path) if tongue is None: return {error: tongue region not found} tongue cv2.resize(tongue, (128, 128), interpolationcv2.INTER_CUBIC) feature extract_features_from_tongue(tongue).reshape(1, -1) label pipe.predict(feature)[0] proba pipe.predict_proba(feature)[0] class_names [薄白苔, 厚白苔, 黄腻苔, 剥苔] # 自己按实际类别修改 result { predicted_class: class_names[label], probabilities: {cls: round(p, 3) for cls, p in zip(class_names, proba)} } return result if __name__ __main__: import sys print(predict_tongue(sys.argv[1]))注意feature.reshape(1, -1)是为了把一维数组转成(1, n_features)的二维矩阵否则predict会报维度错误。proba返回的是所有类别的概率列表顺序和训练时的class_to_idx一致。如果推理时发现概率最高的是“厚白苔”但第二高的是“黄腻苔”且两者相差不到0.1就说明这个样本的类别边界很模糊我一般在系统里加一个if max_prob 0.6: return 不确定的置信度阈值否则给用户一个错误判断比“不确定”更危险。5. 实际项目里最容易翻车的5个坑5.1 现象模型在训练集上98%测试集只有60%这个现象太经典了几乎每个做舌苔检测的人都会撞上。原因通常有两个一是数据泄漏——同一个受试者的多张照片被同时分到了训练集和测试集因为人间隔了一天重拍但画面的背景、光源和舌形都极度相似这相当于模型“记住了人”而不是“学会了舌苔”的特征。二是特征维度太高或模型太复杂在小样本上直接过拟合。解决方法是划分数据时一定要按受试者分组而不是按图片。比如把受试者ID列表随机打乱前80%的受试者进训练集后20%的进测试集某个受试者的所有图片必须进入同一个集合。然后在train_test_split前检查X_train和X_test是否有来自同一ID的样本。如果过拟合纯粹是模型复杂度造成的就把SVM的gamma调小C调小或者减少特征维度比如把LBP的bin从59降到32。最容易观察过拟合的方法是画训练曲线如果训练和测试之间的gap随epoch增大而拉大就是过拟合的信号。5.2 现象不同手机拍出来的舌头颜色差很多同一套代码在iPhone拍的图上准确率还行换到安卓低端机黄苔全被识别成白苔。原因很简单手机相机的自动白平衡和ISP色彩渲染不同。iPhone的偏真实很多安卓会刻意提高饱和度让照片更“通透”结果舌苔颜色就漂了。解决办法分两步。第一步是硬性规范采集时在舌头旁边放一个灰卡哪怕是一张标准白纸拍摄后按灰卡做白平衡校正。OpenCV的gray_world算法可以简单做但效果一般更好的是用cv2.xphoto.createGrayworldWB()。第二步是在数据增强里模拟不同设备色差对每个样本随机改变LAB空间的a和b通道的均值偏移让模型对这些颜色漂移不那么敏感。这个方法虽然简单但实测能降低跨设备的准确率波动。注意不要直接把RGB通道加偏移那会造成所有通道的同时变动看起来像调了亮度而不是色调。5.3 现象标注标准不一致导致类别边界漂移如果项目里有两个人标注数据比如A认为某张属于“薄白苔”B认为属于“厚白苔”模型就会在训练时学到矛盾的信号训练loss无法下降或者预测结果总是偏向标注更丰富的那一类。更隐蔽的是同一类在标注标准下其实存在“伪子类”比如“黄腻苔”包含了黄厚腻和黄薄腻标注时没有区分特征分布就是双峰。解决方法是先做一份标注手册给每个类别配2-3张示例图写明判断依据比如“厚苔舌苔覆盖舌体看不到舌质”。然后让两个标注者独立标100张图算一个Cohens Kappa系数如果低于0.6就说明标准不清楚需要讨论重新定义。最后对全量数据采用多数投票每张图由三个人独立标取两个以上一致的结果作为最终标签。如果三个人全不一致的图直接剔除不要硬塞给模型。5.4 现象训练时loss下降但推理结果总把“正常”判成“异常”这通常不是代码错误而是类别不平衡加决策阈值不合理。比如“正常”占80%“异常”占20%时模型为了总体准确率会倾向于把所有样本都判成“正常”所以你才会看到训练loss在下降、accuracy很高但测试集里的“异常”样本的召回率几乎为0。反过来如果你把class_weightbalanced加上了模型又可能过度倾向少数类把很多正常样本误判为异常。解决方法是先算测试集每个类别的F1如果“异常”召回率很低就对少数类别过采样再做阈值搜索。SVM的decision_function或predict_proba返回的概率可以做阈值扫描尝试把“异常”的判定阈值从0.5降到0.3同时画出PR曲线选一个在precision和recall之间平衡的点。在我的项目里最终把阈值设为0.45后整体F1提升了5个百分点。这个阈值一旦确定要写进论文“实验设置”里因为不同阈值下的指标不能直接比较。5.5 现象代码换一台电脑就跑不起来最常见的连环踩坑是用os.system(python train.py)跑通了但换电脑后ModuleNotFoundError或者模型加载时报错说joblib版本不同。根本原因不是代码写错了而是没有做环境锁定和路径通用化。解决方法是坚持三件事第一用requirements.txt固定依赖版本直到小数点后两位例如scikit-learn1.2.2、opencv-python4.8.1.78、numpy1.24.3。因为numpy大版本升级后joblib保存的模型可能出现兼容问题。第二让README.md里写出“安装Python 3.9然后运行pip install -r requirements.txt”的完整步骤不要假设用户熟悉Python环境。第三在脚本入口用if __name__ __main__:避免直接执行时误加载被import的模块。如果你用了绝对路径就用我前面给出的os.path.dirname(os.path.abspath(__file__))方案别用C:\Users\...这种写法。这样做的本质是让源码成为论文的“可复现附件”而不是一次性玩具。6. 把检测结果变成论文里的图表评估指标、可解释性与实验对比6.1 用一段代码生成论文用的混淆矩阵答辩时老师最爱问“你的模型错在哪”。一张混淆矩阵配合类别名称比任何文字都直观。下面这段代码读取之前保存的预测结果生成带数值标签的混淆矩阵图import matplotlib.pyplot as plt import numpy as np from sklearn.metrics import confusion_matrix def plot_confusion_matrix(y_true, y_pred, class_names, save_pathresults/confusion_matrix.png): cm confusion_matrix(y_true, y_pred) cm_norm cm.astype(float) / (cm.sum(axis1)[:, np.newaxis] 1e-6) plt.figure(figsize(6, 5)) plt.imshow(cm_norm, interpolationnearest, cmapBlues) plt.colorbar() tick_marks np.arange(len(class_names)) plt.xticks(tick_marks, class_names, rotation45, haright) plt.yticks(tick_marks, class_names) thresh cm_norm.max() / 2 for i in range(cm.shape[0]): for j in range(cm.shape[1]): color white if cm_norm[i, j] thresh else black plt.text(j, i, f{cm_norm[i,j]:.2f}, hacenter, vacenter, colorcolor) plt.xlabel(Predicted) plt.ylabel(True) plt.tight_layout() plt.savefig(save_path, dpi300)这里归一化用的是行归一化每一行代表真实类别中预测到各个类别的比例这样能够直接比较不同类别的表现。savefig时dpi300是论文要求的最低标准否则印刷出来会糊。6.2 论文图表之外的三个细节点除了混淆矩阵我建议论文里再放三样东西第一是按受试者划分数据集的说明这是数据集章节的卖点第二是SVM的网格搜索结果表证明你调过参而不是盲选第三是集成对比表传统特征SVM vs CNN这样能沿着“机器学习的方法”这条线体现出你的工作量。我自己的教训是曾经把随机种子忘了固定答辩现场演示时重跑了一遍脚本准确率从0.85变成了0.81老师马上就问“是不是数据处理有bug”。后来我养成了一个习惯在config.py里固定SEED42并在preprocess_all_images开头就np.random.seed(SEED)模型训练前再加一次这样同样的输入每次输出完全一致。做项目一定要让你的数学过程可复现这是论文和源码能够说服别人的底线。从数据采集到特征提取、模型选型再到图表生成这条链路每一步都有陷阱但也就是这些陷阱让一个普通图像分类demo变成了真正能拿到论文里的“系统”。你如果照着这个流程走一遍哪怕最后只跑了传统特征SVM也比那些堆了半天的CNN却无法解释结果的要靠谱得多。希望这篇笔记能帮你在自己的舌苔检测项目里少走一段弯路。本文还有配套的精品资源点击获取