ARTICLE DETAIL

资讯详情

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

Gabor+PCA+LDA+SVM:小样本人脸表情识别的经典基线方案

Gabor+PCA+LDA+SVM:小样本人脸表情识别的经典基线方案 简介这套源码实现基于Gabor滤波、PCALDA降维与SVM分类的人脸表情/微表情识别完整流程面向计算机视觉学习者、算法研究人员和Python开发者可用于表情识别、人脸特征提取与分类实验。压缩包共35.85MB包含808个文件以749张JPG图像样本为主另有21个XML配置、6个模型文件、5个Python源码、5个数据库文件及libsvm.dll等可执行文件覆盖数据集、模型参数、训练脚本与运行程序。系统用Gabor滤波器提取纹理特征PCALDA提升特征可分性再由SVM完成分类PyQt提供图形界面libSVM重新编译后支持多线程训练兼顾效果与效率。目前已有410人学习下载各类文件按功能组织便于直接运行观察效果也可对照源码理解传统机器学习在表情识别中的完整链路适合作为二次开发与算法替换的参考。1. 为什么GaborPCALDASVM这个老配方在表情识别里还能打先说一个反直觉的结论在深度学习把图像分类刷到各种视觉榜单的时代人脸表情识别在小样本实验室数据集上真正可复现、可解释、能在纯CPU上跑完的基线往往还是Gabor滤波、PCALDA降维、SVM分类这三件套。这个组合不是过时的玩具而是理解特征工程和传统机器学习配合方式的一个完整样板。Gabor负责把人脸纹理变成有物理含义的响应图PCALDA把动辄上千维的特征压缩成低维判别子空间SVM在低维空间里画分类边界。它适合三类人用CK、JAFFE这类几百张样本做课设或毕设的学生需要快速搭一个表情识别原型验证算法的工程师以及想搞懂“降维为什么有用”的机器学习入门者。这套方案能解决的问题很具体训练样本少、算力有限、要能解释每个环节在干什么。接下来我按一条完整可跑通的流水线把参数、代码和踩过的坑逐个拆开讲。2. 用Gabor滤波构造表情纹理核参数、响应图与特征矩阵2.1 为什么表情识别要用Gabor而不是普通边缘提取人脸表情本质上是一组面部肌肉带动皮肤纹理的形变眉毛上挑、嘴角上扬、眼睑收缩、鼻唇沟加深这些变化在图像上表现为不同方向和不同粗细的局部纹理。普通边缘检测算子比如Sobel或Canny只能给出某个方向的梯度信息而且对光照变化非常敏感。Gabor滤波器的设计灵感来自视觉皮层简单细胞感受野的响应特性它在频率和方向上都具有选择性可以同时刻画“纹理走向”和“纹理粗细”。对表情识别来说一个滤波核能响应特定方向的皱纹另一个核能响应特定粗细的纹理组合起来就构成一个覆盖人脸形变模式的特征字典。我常用三尺度六方向的组合尺度上覆盖细纹、中等褶痕、粗大肌肉纹理方向上从0度到150度每隔30度一个方向。你问我为什么不是四尺度八方向因为在小样本数据集上特征维度每翻一倍过拟合风险也跟着涨三乘六的18个核已经在性能和维度之间取了平衡。Gabor对光照变化也有一定的鲁棒性因为滤波核本身是带方向的带通滤波器均值漂移这类全局光照变化会被滤除这比直接比较原始像素灰度可靠得多。2.2 三个尺度乘六个方向的滤波器组怎么生成OpenCV里已经封装好了核生成函数cv2.getGaborKernel不需要自己写复数卷积。下面这段代码生成18个滤波核并返回对应的参数记录方便后面调试时定位是哪个核出了问题。import cv2 import numpy as np def build_gabor_bank(): # 方向0度到150度覆盖水平、垂直及斜向纹理 thetas np.deg2rad([0, 30, 60, 90, 120, 150]) # 每个元素是 (核尺寸, 高斯标准差)对应不同纹理粗细 scales [(11, 3.0), (21, 4.5), (31, 6.0)] lambd 10.0 # 波长控制条纹间距 gamma 0.5 # 空间纵横比0.5表示椭圆感受野 psi 0.0 # 相位偏移 kernels [] params [] for ksize, sigma in scales: for theta in thetas: kernel cv2.getGaborKernel( (ksize, ksize), sigma, theta, lambd, gamma, psi, ktypecv2.CV_32F ) kernels.append(kernel) params.append((ksize, sigma, theta)) return kernels, paramsgetGaborKernel的参数里最容易影响结果的是sigma和lambd。sigma决定高斯包络的宽度也就是核的实际作用范围数值越大越能覆盖粗纹理lambd是正弦波波长越小越能捕捉细密纹理。gamma控制核的椭圆程度取1.0时核是圆形取0.5时核在垂直于方向的方向上被压扁适合描述条形纹理。我把核尺寸和sigma绑定在一起按尺度递增而不是所有核都用同一个ksize因为小核配大sigma时高斯包络会溢出边缘出现奇怪的振铃响应。生成核之后对输入人脸图像做滤波。注意输入必须是灰度图而且尺寸最好统一我习惯缩放到128乘128。滤波输出是浮点型响应图值域不再是0到255后续统计特征时会用到这些浮点值。def apply_gabor_bank(gray_img, kernels): # 深度转float32滤波结果会有正有负 responses [] for kernel in kernels: resp cv2.filter2D(gray_img, cv2.CV_32F, kernel) responses.append(resp) return responsesfilter2D是线性卷积滤波输出尺寸默认和输入一致边界用镜像补边。这里的CV_32F很关键如果省略默认输出类型和输入一样是uint8负响应会被截断成0等于丢掉了暗纹理的信息。镜像补边也比默认的补零更友好补零会在图像边缘制造一条假边界响应图四周会出现很亮或很暗的条纹直接影响后续特征统计。2.3 把18张响应图拼成可训练的特征向量维度与池化现在手里有18张响应图每张都是128乘128的浮点矩阵。如果直接把响应图摊平拼成一个向量维度就是18乘128乘128接近30万维这个维度交给PCA和LDA处理既慢又容易过拟合不是好选择。常见做法是对响应图做池化把数值分布压缩成几个统计量我保留了整图统计和分块统计两个层级。def response_to_feature(responses, block8): feats [] for resp in responses: h, w resp.shape # 整图三个统计量均值、方差、能量 feats.append(resp.mean()) feats.append(resp.var()) feats.append(np.sqrt((resp ** 2).mean())) # 分块均值保留粗粒度的空间位置信息 h_cut h // block * block w_cut w // block * block resp_block resp[:h_cut, :w_cut] cell_h, cell_w h_cut // block, w_cut // block local_mean resp_block.reshape(block, cell_h, block, cell_w).mean(axis(1, 3)) feats.extend(local_mean.ravel()) return np.array(feats, dtypenp.float32)这段代码里reshape(block, cell_h, block, cell_w)把响应图切成8乘8的网格mean(axis(1, 3))分别在行块和列块方向求均值得到8乘8的局部均值矩阵。每个核贡献3个整图统计量加64个分块均值一共67维18个核合起来就是1206维。这个维度对几百个样本来说已经不算小但PCA恰好能在这种维度下发挥作用。整图统计量保留了整体明暗和纹理强度分块均值保留了“眉毛附近有横向纹理”这类空间线索。特征类型用float32而不用float64在样本量几百、维度上千时能省一半内存训练速度也更快。对每张图像执行这段代码把结果纵向拼接成特征矩阵行是样本列是特征后续降维分类都基于这个矩阵。3. PCALDA串联降维先最大化方差再最大化判别力3.1 为什么协方差矩阵是PCA的核心特征脸思想很多人初学PCA时都会问为什么总要反复提协方差矩阵这不是在换着花样讲同一个概念而是PCA的优化目标天然指向它。PCA要做的事是找到一组新的坐标轴让数据投影到这些轴上后方差尽可能大。样本各维特征之间的相关程度恰恰写在协方差矩阵里。协方差矩阵对角元素是各特征自身的方差非对角元素是两两特征间的协方差。求解PCA本质上是在对这个矩阵做特征分解特征值大的方向就是数据波动最剧烈的方向。人脸表情识别里Gabor特征各维度之间存在大量相关性相邻分块的均值本就相近如果直接送分类器冗余信息会干扰决策边界。我把PCA理解成一个去相关的过程。它输出的每个主成分是原始特征的线性组合而且各个主成分之间互不相关。前几个主成分往往已经解释了绝大部分方差我常保留120维左右或方差的95%后面的成分大多是噪声。PCA在降维之外还有一个副产品叫特征脸把PCA投影矩阵每一行重新reshape成图像尺寸会得到类似人脸轮廓的图这就是主成分分析在视觉上的意义所在。表情识别里的PCA不需要保留完整脸型信息它更在意的是把1206维纹理特征压缩成一组紧凑、互不相关的新特征。LDA和PCA视角完全不同。PCA不关心样本属于哪一类只关心数据的整体方差LDA则是一个有监督的降维方法它的优化目标是让不同类别的中心尽量分散同时让同类样本尽量聚集这就是Fisher判别准则。用LDA处理表情数据时7类表情的样本会投影到一个类间可分离性最大的低维空间。结果就是同一表情的样本扎堆不同表情之间距离拉远这个特性对SVM分类非常友好。先说结论这两个方法离了谁都不行。PCA把特征维度从上千压缩到一两百避免LDA在计算类内散度矩阵时遇到不可逆的奇异问题LDA再把PCA的结果压缩到6维让SVM只在一个极低维空间里画边界。3.2 把PCA与LDA串成一条流水线训练集只fit一次工程实现上最大的坑是让PCA和LDA各写一套代码手动传递中间结果稍不留神就把测试集的信息混进拟合过程。正确的做法是用sklearn.pipeline.Pipeline把它们连同标准化封装成一条流水线。from sklearn.decomposition import PCA from sklearn.discriminant_analysis import LinearDiscriminantAnalysis as LDA from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline feature_pipeline Pipeline([ (scaler, StandardScaler()), # Gabor特征尺度差异大先标准化 (pca, PCA(n_components120, whitenTrue, random_state42)), (lda, LDA(n_components6)) # 7类表情最多6个判别方向 ]) X_train_low feature_pipeline.fit_transform(X_train, y_train) X_test_low feature_pipeline.transform(X_test)先看scaler。Gabor响应统计量里均值的取值范围可能比能量小一个数量级如果不做标准化PCA会被数值范围大的特征主导。再看PCA我设置了whitenTrue这一步会把主成分的方差归一化到1使得后续LDA计算散度矩阵时所有维度处在同一尺度。n_components120是经验值它必须小于样本数和特征数如果你只有80个样本这个值就得改成79后文避坑章节会专门讲。LDA的n_components6来自类别数减一7种表情最多有6个有效判别方向设置超过这个数会直接警告并截断。代码里最关键的一行是测试集只调用了transform没有调用fit_transform。PCA和LDA的投影矩阵必须完全由训练集确定测试集只负责被投影。如果测试集也参与了拟合相当于在考试前把答案透给了模型识别的准确率会虚高且完全没有参考价值。这也是为什么我强烈建议用Pipeline封装而不是手写步骤Pipeline天然限制了中间步骤只能对训练集学习参数。3.3 降维之后维度怎么定看方差曲线和分类器反馈固定n_components120只是起点不是标准答案。正确的定参方式是先做一次完整PCA画出累计方差贡献率曲线找到斜率变缓的肘点。如果前60个主成分已经解释了95%的方差保留120个就是在放大噪声。我给一个更实用的判断方法以20为步长从40试到200对每个候选维度跑一遍5折交叉验证看LDA降维后SVM的平均准确率选择准确率不再明显提升的最小的那个值。这个搜索不需要额外写框架一个for循环加cross_val_score就够。这种网格式的调参在样本量只有几百时尤其重要。维度太低会丢掉区分某些表情的细节纹理维度太高又会把噪声学进分类器。LDA的有效维度上限是类别数减一所以它本身不关心你给它几个主成分但PCA输出太少会让LDA无米下炊。我习惯先固定LDA再把PCA的保留维度当作超参去搜索而不是两头一起改否则出了问题你根本分不清是哪个环节导致的。还有一个容易忽略的边界数据划分必须在进入PCA之前完成。有些人先把全部样本合并起来做标准化或PCA再切训练测试集这会把测试集的统计信息泄漏进训练过程准确率比随机猜测高很多但模型一上真实数据就原形毕露。先切分再fit再transform顺序不能乱。4. 用SVM做最终分类核函数选型与C、gamma调参4.1 为什么在低维空间里选SVM而不是MLP或KNN降维之后特征维度只有个位数到几十样本量几百这个场景下SVM支持向量机几乎是天然的选择。KNN在这个尺度下表现不稳定因为样本本来就少近邻的判定很容易被一两个离群点干扰MLP或神经网络需要大量数据来拟合权重几百个样本很容易过拟合。SVM的核心思想是找到一个决策边界让它离两类样本的边界点最远这个“最大间隔”性质让它在小样本下泛化能力很好。SVM还有一个工程上的优势是决策边界只由支持向量决定大部分远离边界的样本对模型没有影响。换句话说在样本分布不均匀时SVM不太容易被多数类带偏。配合class_weightbalanced它还会自动根据类别频率调整惩罚权重某个表情样本只占5%时也照样能学到决策面。它在推理时只需要计算新样本与支持向量的核函数6维输入下这个计算量小到可以忽略。核函数方面我直接选RBF。线性核在6维空间里只能切一个超平面分不动形状复杂的类间边界多项式核参数多容易在低维空间里振荡。RBF核本质上是在度量样本之间的相似度通过gamma参数控制影响半径配合C控制对误分类的容忍度已经能拟合出足够平滑的非线性边界。除非你明确知道数据是线性可分的否则RBF是默认起点。4.2 RBF核的C与gamma对数网格搜索与交叉验证C和gamma是RBF核的两个关键旋钮。C是对误分类的惩罚系数C越大模型越追求把训练样本全部分对边界越曲折越容易过拟合C越小边界越平滑但可能欠拟合。gamma控制单个训练样本的影响范围gamma越大影响半径越小决策边界越复杂gamma越小边界越平滑接近线性。这两个参数的合理取值往往跨越多个数量级所以用对数网格搜索。from sklearn.svm import SVC from sklearn.model_selection import GridSearchCV, StratifiedKFold svm_clf SVC( kernelrbf, class_weightbalanced, cache_size1024, decision_function_shapeovr, random_state42 ) param_grid { C: [0.5, 1, 5, 10, 50], gamma: [scale, 0.001, 0.01, 0.1, 1.0] } cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) searcher GridSearchCV( svm_clf, param_grid, scoringaccuracy, cvcv, n_jobs-1, verbose1 ) searcher.fit(X_train_low, y_train) print(searcher.best_params_) print(searcher.best_score_) y_pred searcher.best_estimator_.predict(X_test_low)我用StratifiedKFold做交叉验证它保证每一折里7类表情的比例和原始训练集一致。如果不做分层碰到某个少数类表情在某一折里一条样本都没有那一折的训练和评估都会失真。n_jobs-1让网格搜索并行样本量小时实际运行很快。网格搜索结束后best_params_给出最优参数best_score_是所有折的平均准确率这个分数比单次划分出来的测试集准确率更适合用来对比不同特征方案。我调参的习惯是先粗后细第一次用上面这个5乘5网格看整体趋势如果最优值落在C等于1或0.01附近就缩小范围在最优值两侧再搜一轮比如C取[0.3, 0.6, 1, 1.6, 2.5]gamma取[0.004, 0.007, 0.01, 0.015, 0.02]。这比一次性铺一个超密网格要快得多也更符合实际情况。C和gamma之间存在一定耦合最优参数往往在对角线上小C配小gamma大C配大gamma。如果搜索出的C特别大比如50甚至几百说明特征空间里样本本身就难分模型在硬撑这时更应该回头检查特征提取而不是继续加大C。4.3 评估结果别只看准确率分类报告与混淆矩阵低维空间里SVM训练很快所以评估也值得做得细一些。表情识别里各类别的混淆模式差异很大比如“生气”和“悲伤”都可能伴随嘴角下压“恐惧”和“惊讶”都有眼睛睁大的成分这些类别的混淆不是平均分布的只看准确率会把这类信息全部盖住。我每次迭代都会打印一份分类报告和混淆矩阵。from sklearn.metrics import classification_report, confusion_matrix, ConfusionMatrixDisplay print(classification_report(y_test, y_pred, digits3)) cm confusion_matrix(y_test, y_pred) ConfusionMatrixDisplay(cm).plot()classification_report会按类别给precision、recall、f1-score。对表情识别来说我更关注f1-score最低的那个类别它往往指向特征提取的盲区。比如发现“恐惧”的错误率一直偏高我会去检查Gabor滤波的方向对“眼睛睁大”这块区域是否响应不足而不是盲目调SVM参数。ConfusionMatrixDisplay输出的矩阵里主对角线是正确分类数非对角线上数值大的位置就是最常见的混淆对。我见过有人在FER2013上把“恐惧”和“惊讶”混成一片两个类别加起来6成样本被互相认错。这类问题靠调大C解决不了得回到特征环节去补这个方向的信息。5. 表情识别避坑五个典型翻车现场与排查步骤5.1 训练集准确率97%测试集只有55%现象模型在训练集上分类近乎完美一到测试集就崩盘。这种情况我遇到太多次第一反应不是检查模型而是检查数据划分。原因大都是随机划分时没有分层导致某个表情类别在测试集里占比极低或者更严重的同一个人的不同表情分别落进了训练集和测试集被模型当成已经见过的脸来识别。解决方式分两层先用train_test_split加stratifyy保证类别比例一致再按人脸身份分组保证同一个人只出现在训练集或只出现在测试集。分组划分的代码不复杂但跑一组对比实验就知道随机切分的结果通常会偏高几个百分点到十几个百分点。from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, stratifyy, random_state42 )5.2 一跑PCA就报维度超限的ValueError现象报错信息大致是n_components120 must be between 0 and min(n_samples, n_features)80。原因很直白PCA是矩阵分解类方法它的有效成分数不能超过样本数和特征数中较小的那个你设置了120但训练样本只有80张。很多人会下意识把n_components调小到79但更稳妥的写法是直接让它按方差比例决定维度PCA(n_components0.97)。这个值表示保留97%的方差PCA会自动向下取合适的主成分数不会被样本数卡死。再做LDA之前建议把降维后的维度打印出来看看如果前十几个主成分就解释了97%的方差说明Gabor特征本身冗余度很高这是正常的。5.3 LDA输出维度不足预期还带着警告现象设置了LDA(n_components6)结果输出只有两三个非零维度控制台还警告说明有效维度为类数减一。原因就是你手里的训练集实际类别数少于7或者某些类别的样本数太少LDA在计算类内散布矩阵时数值不稳定把它们的判别方向当成了噪声。排查方法很简单打印np.unique(y_train)看训练集标签里到底有哪几类。如果只有4类LDA最多只能输出3个判别方向这是数学上限。另外一个隐含问题某个表情类别只有两三张样本LDA算出来的离散度会非常依赖这几张转去建模时极容易过拟合。解决思路就一句话要么删掉样本数过少的类别要么用类别增强手段把少数类补到至少十来个样本。5.4 提取Gabor特征时内存直接爆掉现象程序跑着跑着MemoryError或者内存占用一路飙到十几个GB。原因通常是把所有图片、所有尺度的响应图都保留在内存里或者把响应图完整摊平成一维向量再拼接。我见过有人把所有18张响应图拉直后全部concat单张图就产出近30万维特征几百张图光存储特征矩阵就超过一亿个float内存不爆才怪。解决方式是分三块响应图统计完立即丢弃只保留特征向量特征矩阵统一用np.float32把特征按批写入磁盘用np.savez或np.memmap缓存。特征提取往往只做一次缓存到磁盘后重复调参时直接读取既省内存又省时间。features [] for img_path, label in samples: gray load_and_preprocess(img_path) responses apply_gabor_bank(gray, kernels) features.append(response_to_feature(responses, block8)) X np.vstack(features).astype(np.float32) np.savez_compressed(gabor_features.npz, XX, yy)5.5 按全脸特征做微表情识别准确率始终徘徊在三四成现象用同一套GaborSVM流程跑微表情数据集准确率比表情数据集掉一大截甚至接近随机猜测。原因很明确微表情的幅度太小时长也只有0.2到0.5秒肌肉位移通常只有几个像素单帧图像上的纹理变化在Gabor响应图里被淹没在整脸皮肤纹理中。解决思路是先把时序信息压缩进单帧再走这套流程。最直接的做法是取视频峰值帧与中性帧做差分增强发生变化的面部区域再对差分图做Gabor滤波。peak load_face_frame(video, peak_frame_id) neutral load_face_frame(video, neutral_frame_id) diff np.abs(peak.astype(np.float32) - neutral.astype(np.float32)) diff_norm (diff - diff.min()) / (diff.max() - diff.min() 1e-6)先差分再进Gabor这条血泪经验让我在微表情项目里把准确率从随机水平拉到了可用水平。需要提醒的是差分图对人脸对齐精度要求很高人脸没对齐时差分结果会包含大量因位偏移产生的假轮廓噪声比真实的微表情纹理还大。所以这个流程的前置步骤一定是对人脸关键点做仿射对齐把眼睛、鼻尖这些固定点统一到一个模板坐标上。6. 微表情识别该往哪走时序增强、逐类评估与特征缓存6.1 从单帧静态识别转向峰值帧与中性帧的差分特征单帧Gabor对普通表情有效但对微表情基本失效原因是微表情的纹理变化太微弱。差分预处理把变化信号放大后再提Gabor这是目前资源受限场景下最实用的微表情识别做法。更进一步可以只取面部分区的关键区域比如眉毛、眼角、嘴角三块分别做差分和Gabor统计其他区域直接丢弃。这样既降低了维度又增强了与表情最相关的局部纹理。表情识别里也能借用这个思路用峰值帧替换中性帧的差异幅度作为一维附加特征和Gabor特征拼接相当于给SVM补充了肌肉运动方向的信息。6.2 用分组划分和逐类混淆来找模型的真实盲区我最终评估一个表情识别模型固定看三样东西按人划分的测试集准确率、分类报告里每个类别的f1-score、混淆矩阵里最大非对角元。按人划分的含义是训练集里出现过的人不允许出现在测试集里这样才能检验模型识别的是“表情”还是“熟脸”。逐类看f1是为了发现特定表情的盲区比如“恐惧”的召回率低就去检查对应分区的Gabor响应是否有区分度。每次改动特征或参数后把这套评估结果存档不同版本的差距一目了然。6.3 一个省时间的工程习惯把特征提取结果缓存起来我自己的习惯是Gabor特征提取和分类调参分成两个阶段特征提取只跑一次结果缓存到磁盘后续所有降维和SVM的实验都直接读取缓存文件。这个习惯帮我在反复调参时省掉了大量重复计算也避免了每次重新提取特征引入随机误差。样本量增加时只要跑一次增量特征提取再合并到原有特征文件里。表情识别这类项目花时间最多的地方往往不是模型而是特征验证和数据划分把流程拆开并固定下来实验才能可复现地推进。希望这份血泪经验帮你在自己的数据集上少走几步弯路。本文还有配套的精品资源点击获取
返回列表