ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Gabor+PCA+LDA+SVM:小样本人脸表情识别的经典实现链路

Gabor+PCA+LDA+SVM:小样本人脸表情识别的经典实现链路 简介这是一套面向人脸表情与微表情识别的Python源码包适合机器学习初学者、计算机视觉开发者和相关专业学生学习从图像特征提取到分类预测的完整流程。系统以Gabor滤波器提取纹理与方向特征结合PCALDA联合降维增强不同表情类别间的区分度再由SVM完成分类并基于PyQt搭建图形界面便于直观操作。压缩包整体约35.85MB共808个文件以JPG图像、XML配置和模型文件为主包含749张面部表情样本、21个配置文件、6个训练好的模型、5个Python源码、5个数据库文件以及重新编译支持多线程训练的libSVM组件和多个可执行文件适合运行和二次开发。该项目已有410人学习下载资源包内模型、配置、数据集与源码相互配套可支持表情识别课程设计、算法对比实验或微表情预研借助完整工程目录和可视化界面用户能快速复现识别流程也能针对新样本继续优化模型。1. 不用深度学习也能做人脸表情识别Gabor滤波PCALDA降维SVM分类的经典链路如果你手里只有几千张人脸表情图片又不想为它专门配一台带 GPU 的机器Gabor滤波、PCALDA降维和SVM分类这套经典组合是比 CNN 更容易先跑通的方向。它把识别任务拆成四段Gabor 核把人脸照片里的皱纹、嘴角、眉弓这些局部纹理变成高维向量PCA 先压掉冗余和噪声LDA 再挑出最能区分表情的判别方向最后交给 SVM 做多分类。整条链路不需要预训练权重不依赖大算力很适合教学演示、小样本实验和低算力部署。但要注意这个方案在宏表情上相当能打换成微表情就要先补时序信息不然准确率会很难看。2. 先搭数据管线从表情图片到格式统一的灰度人脸很多人拿到这类识别源码第一件事就是跑 SVM 调参结果怎么调都过不了某条准确率线。我把这类项目的顺序反过来了先解决“图片喂进 Gabor 之前长什么样”。Gabor 核是有方向的输入图片没对齐、光照不统一后续所有特征方向都白搭。这一步做对了往往比调 C 和 gamma 省力得多。2.1 目录结构与图片加载让标签跟着文件夹走数据组织是最容易忽略的环节。常见做法是用dataset/train/类别/图片文件这种目录结构类别名直接作为标签避免手工维护一份脆弱的 CSV 映射。加载时按文件夹名字典序生成标签再用一个label_map把数字和类别名对应起来。import os import cv2 import numpy as np IMG_SIZE 128 def load_images_from_folders(root): X, y [], [] label_map {} for idx, name in enumerate(os.listdir(root)): label_map[name] idx folder os.path.join(root, name) for fname in os.listdir(folder): path os.path.join(folder, fname) img cv2.imread(path, cv2.IMREAD_GRAYSCALE) img cv2.resize(img, (IMG_SIZE, IMG_SIZE)) X.append(img) y.append(idx) return np.array(X), np.array(y), label_map这段代码的逻辑很直接遍历每个类别文件夹把每张图统一缩放到128×128灰度图。IMG_SIZE128是 Gabor 方案里比较稳妥的起点32 太小会丢掉纹理细节256 会让后续特征维度直接翻好几倍。依赖就numpy、opencv-python、scikit-learn、matplotlib四个新环境里装一遍 scikit-learn 也就几十秒后面 GridSearch 和 PCA/LDA 全靠它。2.2 用 Haar 把人脸裁出来并统一尺寸表情数据集通常是近景正脸但难免有背景、头发和衣领混进来。直接整图喂给 Gabor 会把额前碎发、衣服条纹也当成表情特征。常见做法是先用人脸检测把脸裁出来再做缩放。OpenCV 自带的 Haar 级联在这里够用没必要为几百张图引入深度学习检测器。cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml ) def crop_face(img): faces cascade.detectMultiScale( img, scaleFactor1.1, minNeighbors5, minSize(64, 64) ) if len(faces) 0: return cv2.resize(img, (IMG_SIZE, IMG_SIZE)) x, y, w, h faces[0] x max(0, x - int(0.1 * w)) y max(0, y - int(0.1 * h)) w min(img.shape[1] - x, int(w * 1.2)) h min(img.shape[0] - y, int(h * 1.2)) face img[y:y h, x:x w] return cv2.resize(face, (IMG_SIZE, IMG_SIZE))detectMultiScale里scaleFactor1.1表示每次缩放 10%检测窗口步长更细但速度略慢minNeighbors5用来过滤掉检测重叠度不足的候选框数值太小容易把背景误检成人脸。裁剪时向外扩 10%~20%是为了把额头和下巴包进来因为皱眉和嘴部动作经常延伸到脸框边缘。2.3 训练/测试划分与像素归一化先切分再算统计量数据加载完成后第一步不是归一化而是切分。最典型的数据泄漏就是把整份数据统一算均值方差之后再划分——测试集的统计信息已经混进训练过程交叉验证分数虚高一到真实场景就崩。from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, stratifyy, random_state42 )这里的stratifyy值得强调。表情数据往往天然不平衡比如“中性”样本远多于“厌恶”随机切分有可能让测试集里某个类别只出现一两张评估结果波动极大。按类别比例分层抽样之后每个类在训练集和测试集里的占比都保持一致。像素层面的归一化放到 Gabor 特征提取内部做原则是对“单张图”做直方图均衡化或 min-max 缩放不依赖全数据统计量。这样既消除了一部分光照干扰又不引入信息泄漏。图片准备到这里就结束了下一步进入真正的主角 Gabor 滤波。3. 用 Gabor 滤波提取表情纹理特征核参数和特征向量怎么定Gabor 核的本质是带方向选择的带通滤波器。人脸表情依赖的是局部纹理变化——眼角细纹、嘴角弧度、眉头上扬这些区域在某个方向上会呈现出规则的边缘和条纹。Gabor 核扫过去对应方向上的纹理响应强其他方向响应弱表情差异就被量化出来了。3.1 Gabor 核的五个参数一张表看明白cv2.getGaborKernel里有五个参数最影响结果ksize、sigma、theta、lambd、gamma。它们各自的含义和作用如下。参数含义常用范围对结果的影响ksize卷积核尺寸必须是奇数31、63核太小包不住完整光栅太小响应不稳sigma高斯包络标准差3~5控制感受野大小越大越关注粗纹理theta滤波器方向弧度制0、π/4、π/2、3π/4决定提取哪个方向的边缘lambd正弦光栅波长3~12 像素越小提取越细密的纹理gamma核的椭圆纵横比0.3~0.8小于 1 时核被压扁方向选择性更强还有个psi通常设 0相位偏移不影响响应幅值分布在全图卷积场景下可以直接忽略。我第一次做的时候把theta写成了角度制的 45结果所有核的方向全乱套特征完全对不上。3.2 用 cv2.getGaborKernel 生成多方向核并提取响应表情纹理不是单一方向所以实际使用中不会只用一个核而是用多个方向、多个尺度的核组合。常见做法是 4 方向、2 尺度共 8 个核。方向太少会漏掉斜向纹理比如嘴角上扬和眉尾下垂尺度太单一则分不清细小皱纹和粗大表情纹。import cv2 import numpy as np def build_gabor_kernels(): thetas [0, np.pi / 4, np.pi / 2, 3 * np.pi / 4] lambdas [4.0, 8.0] kernels [] for theta in thetas: for lam in lambdas: kernel cv2.getGaborKernel( ksize(31, 31), sigma4.0, thetatheta, lambdlam, gamma0.5, psi0, ktypecv2.CV_32F ) kernels.append(kernel) return kernelslambda4.0提取细纹理适合眼角鱼尾纹这类细节lambda8.0提取粗纹理对应咧嘴和皱眉的大面积皮肤褶皱。sigma4.0配合31×31的核高斯包络衰减范围刚好覆盖两个波长周期。gamma0.5把核压成椭圆形让方向选择性明显变强。3.3 提取特征缩小响应图、归一化再拉平有了核下一步就是对每张灰度图做卷积得到响应图。问题在于一张128×128的图和 8 个核卷积后直接拉平是128×128×8131072维这个维度对后面的 SVM 和 LDA 都是负担。常见做法是把每张响应图缩小到16×16相当于对局部区域做池化保留表情纹理的空间相对分布同时容忍像素级的位置漂移。def extract_gabor_features(gray_img, kernels, grid16): gray_img cv2.equalizeHist(gray_img) feats [] for kernel in kernels: resp cv2.filter2D(gray_img, cv2.CV_32F, kernel) resp cv2.resize(resp, (grid, grid)) resp (resp - resp.mean()) / (resp.std() 1e-6) feats.append(resp.flatten()) return np.concatenate(feats)filter2D是 OpenCV 的标准卷积接口ddepthcv2.CV_32F保留浮点响应值避免 uint8 截断把负响应丢掉。对每张响应图做一次独立的零均值单位方差归一化是为了防止某个核的输出幅值天然偏大后续直接主导 PCA 和 SVM 的距离计算。最终得到的特征向量维度是16×16×82048对经典分类器来说非常友好。如果你想让特征更贴近真实表情分布可以把grid提到 32方向加到 8 个此时特征维度变成8192就更依赖 PCA 去压缩。手工特征方案的弹性就在这里不用动模型结构只调空间分辨率和核数量就能改变特征容量。4. PCALDA 组合降维为什么先 PCA 后 LDA维度定多少2048 维特征不是不能直接喂 SVM但有两个隐患一是 8 个 Gabor 响应之间存在大量相关性二是小样本情况下高维空间里的距离度量不稳定。PCALDA 的作用是把 2048 维先压到几十维再压到个位数让 SVM 在一个低维且可分性更强的空间里做决策。4.1 PCA 特征脸和 LDA 的分工不同不能互相替代PCA 是无监督方法它只找方差最大的方向不管这些方向对应的是表情还是身份。表情数据集里脸型、性别、身份差异往往比表情差异的方差更大PCA 按方差排序时可能优先保留身份信息丢掉细微的表情差异。LDA 是有监督方法它利用类别标签最大化类间散度与类内散度的比值专门找“能把不同表情分开”的方向。二者结合的逻辑是先用 PCA 把维度和噪声降下来保证 LDA 的类内散度矩阵可逆再用 LDA 聚焦判别信息。这就是为什么顺序必须是“先 PCA 再 LDA”。顺带一提“特征脸”这个词如果你把 PCA 的投影向量按像素排回成图片会看到一张张像人脸重影的图案这就是经典的特征脸。但在本方案里PCA 作用在 Gabor 特征向量上它的主成分对应的是纹理响应模式重排回16×16看到的是一块块纹理热区不再是人脸形状。4.2 用 Pipeline 把 StandardScaler、PCA、LDA 串起来直接手动拼 PCA 和 LDA 容易漏掉标准化也容易在交叉验证时出错。我一般会用 scikit-learn 的 Pipeline 把三件事串成一步避免训练和测试时的处理不一致。from sklearn.preprocessing import StandardScaler from sklearn.decomposition import PCA from sklearn.discriminant_analysis import LinearDiscriminantAnalysis from sklearn.pipeline import Pipeline pca PCA(n_components80) lda LinearDiscriminantAnalysis(n_components6) pipe Pipeline([ (scaler, StandardScaler()), (pca, pca), (lda, lda) ]) X_train_feat np.array([extract_gabor_features(img, kernels) for img in X_train]) X_test_feat np.array([extract_gabor_features(img, kernels) for img in X_test]) X_train_lda pipe.fit_transform(X_train_feat, y_train) X_test_lda pipe.transform(X_test_feat)StandardScaler必须在 PCA 之前。Gabor 不同方向、不同尺度的响应幅值差异很大不标准化的话PCA 主轴会被幅值大的核主导。n_components80是把 2048 维压缩到 80 维的经验起点n_components6是因为 7 类表情的 LDA 判别空间最多只有类别数减一也就是 6 维。整个 Pipeline 在交叉验证时只需要 fit 一次测试集只走transform从机制上杜绝了数据泄漏。4.3 n_components 怎么定累计方差不等于最优很多人用“累计方差贡献率大于 95%”来决定 PCA 维度这在表情任务里经常翻车。原因是 PCA 不看标签保留 95% 方差的主成分里可能混了大量身份和光照信息。另一个原因是 LDA 的判别力取决于 PCA 之后剩下的维度PCA 压得太多LDA 想用的判别信息也被压掉了。我一般会先算一次累计方差作为取值范围参考再用交叉验证去定最终值。pca_full PCA() pca_full.fit(X_train_feat_std) # 假设已经标准化 cumsum np.cumsum(pca_full.explained_variance_ratio_) for k in [40, 80, 120, 160]: print(k, round(float(cumsum[k - 1]), 4))打印结果通常能看到 80 维覆盖了 90% 以上方差120 维接近 95%。但最终选多少应该把pca__n_components当成超参数放进 GridSearchCV直接以交叉验证准确率为标准。LDA 的维度同样可以搜7 类表情一般试 5 和 6 就够。把 PCA 和 LDA 同时放进参数网格后通常会发现“累计方差 95%”对应的维度并不是准确率最高的那个维度。5. SVM 分类与调参从 RBF 核到 GridSearchCV 的一条龙经过 PCALDA 压缩后特征维度只有 6 维此时 SVM 的优势完全释放训练极快、泛化稳定、不需要 GPU。CNN 的第一个卷积层往往能学出类似 Gabor 的滤波器但 CNN 需要足够数据和调参技巧才能稳定收敛在几百到几千张的小样本表情数据集上SVM 加 RBF 核的性价比明显更高。5.1 先定一个基准RBF 核 SVC 先跑一遍不要一上来就 GridSearch先拿默认参数跑通整个流程确认前面的特征提取和降维没把数据搞坏。默认的 RBF 核 SVM 在低维特征上通常已经有可用的表现。from sklearn.svm import SVC svc SVC(kernelrbf, C1.0, gammascale, class_weightbalanced) svc.fit(X_train_lda, y_train)gammascale是 scikit-learn 较新版本的默认值它会根据特征方差自动缩放gamma比手写固定值更稳。class_weightbalanced是为了应对表情类别不平衡——如果不加SVM 很容易把样本量少的类别全部牺牲掉。5.2 用 GridSearchCV 找 C 和 gamma参数网格直接抄RBF 核 SVM 的主要超参数就两个C和gamma。C控制对误分类的惩罚力度越大越容易过拟合gamma控制 RBF 核的宽度越大每个样本的影响范围越小决策边界越弯。在 6 维特征上搜索这两组参数成本几乎可以忽略。from sklearn.model_selection import GridSearchCV param_grid { C: [0.1, 1, 10, 100], gamma: [0.001, 0.01, 0.1, 1], } gs GridSearchCV( SVC(kernelrbf, class_weightbalanced), param_grid, cv5, scoringaccuracy, n_jobs-1 ) gs.fit(X_train_lda, y_train) print(gs.best_params_) print(gs.best_score_)cv5表示五折交叉验证每一折都在训练子集上训练、验证子集上打分最后取平均。n_jobs-1让所有 CPU 核并行跑。在 6 维特征和几百个样本的规模下这组搜索一般几十秒内结束远没有想象中耗时。搜索完用gs.best_estimator_替代之前的手写svc即可。5.3 看结果别只看准确率混淆矩阵和分类报告表情识别里最迷惑人的问题就是“准确率看着还行但个别类别完全没被识别出来”。比如恐惧和惊讶在眼部和嘴部纹理上非常接近模型很容易互相混淆。只看一个准确率数字你根本不知道问题出在哪。from sklearn.metrics import accuracy_score, classification_report y_pred gs.best_estimator_.predict(X_test_lda) print(accuracy_score(y_test, y_pred)) print(classification_report(y_test, y_pred))classification_report会给出每个类别的精确率、召回率和 F1方便定位哪些类被牺牲。再配合ConfusionMatrixDisplay.from_estimator(gs.best_estimator_, X_test_lda, y_test)画混淆矩阵就能看到恐惧和惊讶是不是互相串了。宏平均 F1 比准确率更能反映类别不平衡条件下的真实水平汇报结果时建议两个都看。6. 避坑GaborPCALDASVM 最容易翻车的 5 个点这套方案流程短、代码少看起来很容易跑通但真正让它“能落地”的不是算法本身而是预处理和评估方式。下面这 5 个坑是我在实际项目里反复踩过的每一条都对应具体的现象、原因和解决路径。6.1 人脸没有对齐Gabor 方向特征对旋转敏感现象用 Haar 裁出人脸直接训练训练集准确率 95%测试集只有 60% 左右。原因Haar 框是矩形双眼连线稍微倾斜Gabor 的 0°、45°、90°、135° 方向响应就全错位了。解决先做人脸对齐用一个简单关键点检测把双眼连线旋转到水平再统一裁剪如果不想引入额外模型至少固定裁剪上边距从眉毛上方开始减少额头位置漂移。6.2 PCA 降维维度过高LDA 报奇异矩阵现象把 PCA 的n_components设成 300降维后跑 LDA直接报类内散度矩阵奇异或者结果忽高忽低。原因LDA 需要计算类内散度矩阵当特征维度接近样本数量时这个矩阵不可逆。解决让 PCA 输出的维度明显低于“样本数减去类别数”例如 500 个样本、7 类表情PCA 维度压到 100 以内比较安全如果自己手写 LDA还要注意先做 PCA 再做 LDA顺序不能反。6.3 把 StandardScaler 放在数据划分之前现象交叉验证分数很高但单独抽一批新照片测试准确率明显低于验证结果。原因scaler 在全量数据上做了 fit测试集的均值和方差提前进入了训练流程这是典型的数据泄漏。解决所有fit操作只允许发生在训练集上测试集只能执行transform换成 Pipeline 之后这个问题会自然消失因为 Pipeline 在fit时只会看到训练折的数据。6.4 光照条件一变准确率掉十几个点现象实验室采集的数据集测试效果不错换到手机实拍的照片就崩。原因Gabor 响应是线性卷积光照梯度和阴影会直接叠加进特征值SVM 很难区分“表情纹理”和“光影纹理”。解决每张图进 Gabor 之前先做直方图均衡化训练时对亮度、对比度做随机抖动相当于做光照增广如果拍摄环境固定尽量统一光源方向和强度。6.5 微表情不能直接照搬宏表情的静态帧方案现象同一套流程在 CK 宏表情数据集上能到 80% 以上换到微表情数据集的静态帧上只有 40% 左右。原因微表情幅度小、持续时间短很多判别信息分布在帧与帧之间的动态变化里单张静态帧丢失了时间维信息。解决把微表情当视频任务处理常见做法是抽顶点帧加帧差特征或光流特征如果坚持用本方案先把目标压缩成三类积极、消极、惊讶并尽量选择幅值最大的顶点帧作为输入。7. 进阶把特征和模型都可视化再导出成能上线的模块7.1 画出 Gabor 核和 PCA 主成分检查特征方向是否合理模型训练完别急着收工先看看分类器到底在关注什么。Gabor 核可以直接imshow出来确认方向是否正确PCA 的主成分在 Gabor 特征空间里对应的是纹理响应组合重排成16×16热图能看到哪种纹理模式被优先保留。import matplotlib.pyplot as plt fig, axes plt.subplots(2, 4, figsize(12, 6)) for idx in range(8): comp pca.components_[idx].reshape(16, 16) axes[idx // 4][idx % 4].imshow(comp, cmapjet) axes[idx // 4][idx % 4].set_title(fPC{idx 1}) plt.tight_layout() plt.show()如果某几个主成分几乎全是噪点说明 Gabor 参数和标准化没配合好如果每个主成分都呈现出明显的横向纹理说明模型主要依赖脸颊和嘴周的横向纹理做判断可以反推当前特征是否足够支撑表情分类。7.2 导出模型并写一个单帧推理函数训练完成后把降维 Pipeline 和 SVM 分类器分别保存推理时只需要三件事裁剪人脸、提取 Gabor 特征、依次走降维和 SVM 预测。import joblib joblib.dump(pipe, expression_pipeline.joblib) joblib.dump(gs.best_estimator_, expression_svm.joblib) def predict_expression(img_bgr): gray cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) face crop_face(gray) if face is None: return no_face feat extract_gabor_features(face, kernels) feat_lda pipe.transform(feat.reshape(1, -1)) label gs.best_estimator_.predict(feat_lda)[0] return list(label_map.keys())[list(label_map.values()).index(label)]这里pipe是包含 StandardScaler、PCA、LDA 的完整降维模块它和 SVM 分开保存推理时先transform再predict顺序不能反。如果想把整个流程封装成一个Pipeline可以用FunctionTransformer把 Gabor 提取也塞进去但调试时不如分开清晰。我每次做这类项目都会把输入图片的裁剪方式、Gabor 核参数、降维维度、SVM 参数这四个变量单独列出来改任何一个就重新跑一遍交叉验证。原因很简单这套链路里每个环节都环环相扣单独调其中一个变量的收益远不如把整条数据流拉通检查一遍。宏表情场景下这条路足够可靠微表情则是另一个问题——没有时序信息前再小的静态帧也像猜谜。希望帮到你。本文还有配套的精品资源点击获取
返回列表