简介:基于主成分分析(PCA)的人脸识别Python工程包,代码在Python 3.7环境中运行,并搭配wxPython图形化界面库,主要面向学习机器学习、数字图像处理以及需要完成课程设计或毕设的开发者。包内模块划分清楚:function.py负责PCA核心算法与训练识别流程,win.py提供可视化操作窗口,shiyan.py用于中间调试与参数调整;同时准备了完整的人脸样本库,便于直接运行验证。整个资源共37个文件,包含30张jpg人脸图像、3个Python源文件、2个db数据库文件、1个txt调试文本及1个pyc缓存文件,压缩包整体大小约255KB,轻量易用。目前已有2126人学习下载,实用性得到一定验证。借助该工程,读者既能观察特征脸提取与降维过程,又能通过界面直观完成人脸比对测试,对理解PCA原理及实战落地很有帮助。
1. 为什么选PCA做人脸识别:一次高维灾难的复盘
某公司的门禁Demo,某开发者当时直接拿112×92的灰度图展开成10304维向量交给KNN去跑。训练集只有300张图,单次推理要遍历所有样本算距离,延迟飙到200毫秒级别,更麻烦的是分类边界被噪声带偏,换了一批光照条件直接崩。后来换成PCA降维到40维,识别率没降反而涨了几个点,速度却是原来的几十倍。这份基于PCA的人脸识别Python工程做的正是这件事:把高维像素压缩成低维特征脸空间,再交给分类器做判断。适合刚接触机器学习但被"维度诅咒"卡住的开发者,也适合需要快速搭建人脸识别原型的从业者。
2. PCA降维核心原理:特征脸是怎么算出来的
2.1 协方差矩阵与特征分解:降维的本质是找方差最大的方向
PCA的核心目标不是"减少数据量",而是在压缩的同时保留样本之间的差异。人脸图片在高维空间里的分布其实集中在少数几个方向上——这些方向对应人脸的全局结构变化,比如光照方向、五官相对位置、面型轮廓。协方差矩阵记录的是不同像素位置之间的共同变化关系,特征分解之后得到的特征向量就是一组新的坐标轴。
特征值的大小代表该方向上的方差贡献。人脸识别场景下,前几个特征向量对应的就是"特征脸"(Eigenface),它们看起来像模糊的人脸轮廓,但本质上是训练集所有人脸图像的共性模式。一个200人、每人5张图的训练集,像素维度可能上万,但有效的主成分往往只有30到80个。
代码层面,如果不借助sklearn,手写PCA需要做三步:
import numpy as np def pca_manual(X, n_components): # X: (n_samples, n_features) 行是样本、列是像素 n = X.shape[0] mu = np.mean(X, axis=0) # 平均脸 X_centered = X - mu # 中心化 cov = np.cov(X_centered, rowvar=False) # d×d 协方差矩阵 eig_vals, eig_vecs = np.linalg.eigh(cov) idx = np.argsort(eig_vals)[::-1] # 从大到小 eig_vecs = eig_vecs[:, idx[:n_components]] return X_centered.dot(eig_vecs), eig_vecs, mu这段代码的逻辑:先算平均脸,把每张图都减去平均脸,消除整体亮度带来的偏差。协方差矩阵的维度是特征数乘特征数,人脸图展平后这个矩阵会非常大,所以手写版本只适合演示。eigh拿到特征值和特征向量,argsort排序后取前n_components列,最后把中心化后的数据投影到这些特征向量上,得到降维结果。
参数说明:n_components是保留的主成分个数,它直接决定后续分类器的输入维度。取太小会把区分不同人的细节丢掉,取太大又保留了大量光照、表情噪声。常见做法是先用累计方差贡献率曲线确定范围,再在验证集上调。
2.2 为什么是前K个:方差贡献率与信息保留的权衡
PCA输出的特征值从大到小排列,每个特征值占总特征值之和的比例就是该方向的方差贡献率。累计到前K个成分的贡献率,可以近似理解为"保留了原始数据多少比例的信息"。
在人脸识别里,前10到20个主成分往往就占了60%到80%的方差,但那些区分"这是张三还是李四"的细节,恰恰分布在贡献率靠后的方向上,所以不能单纯按90%方差阈值来卡。一个稳妥的区间是40到80个主成分,再往上对精度提升很小,计算量和内存却线性增长。
用sklearn可以方便地查看占比:
from sklearn.decomposition import PCA import matplotlib.pyplot as plt pca = PCA(n_components=100) pca.fit(X_train) # X_train 是标准化后的训练集 # 累计方差贡献率 cumsum = np.cumsum(pca.explained_variance_ratio_) plt.plot(cumsum) plt.axhline(y=0.9, color='r', linestyle='--') plt.xlabel('Number of Components') plt.ylabel('Cumulative Explained Variance') plt.show()这段代码的作用:一次性计算前100个主成分的方差贡献率,画出累计曲线。横轴是主成分个数,纵轴是累计贡献率,红线是90%分位线。手写PCA和sklearn的差距在于稳定性——sklearn底层调了LAPACK,数值精度和速度都优于手写版本。
2.3 scikit-learn中的PCA参数:svd_solver与whiten怎么选
实际项目里我几乎不用手写版,直接调sklearn.decomposition.PCA。这里有三个参数值得专门说明。
第一是n_components,可以传整数也可以传0到1之间的小数。传小数表示"我要保留90%的方差";传整数表示"我固定要40个特征"。
第二是svd_solver,有auto、full、arpack、randomized四个选项。样本数和特征数都大的时候,full会非常慢,randomized利用了随机矩阵近似,速度快很多,但需要n_components远小于min(n_samples, n_features)。人脸识别正好符合这个条件。
第三是whiten,设为True时会把主成分缩放到单位方差。它会让特征脸的绝对值失去语义,但对后续SVM这类基于距离的分类器反而有利,因为每个维度的尺度统一了。
注意:whiten=True之后,重建出来的图像会失去原始亮度信息,如果后面要做"重建误差"判断陌生人,不建议开whiten。
3. 数据预处理与实操:从图片文件夹到特征矩阵
3.1 图片读取与统一尺寸:为什么必须resize
PCA要求所有样本的维度一致。原始数据集里图片尺寸可能各不相同,或者比例不对,必须统一。常用做法是转灰度后resize到固定大小,比如112×92,这个尺寸源自早期人脸数据集的标准剪裁,能保留五官信息又不会让特征维度过大。
import cv2 import numpy as np import os def load_images_from_folder(root_path, target_size=(112, 92)): images = [] labels = [] label = 0 for person_dir in sorted(os.listdir(root_path)): person_path = os.path.join(root_path, person_dir) if not os.path.isdir(person_path): continue label += 1 for filename in sorted(os.listdir(person_path)): if not filename.lower().endswith(('.pgm', '.jpg', '.png', '.bmp')): continue img = cv2.imread(os.path.join(person_path, filename), cv2.IMREAD_GRAYSCALE) if img is None: print(f"无法读取: {person_path}/{filename}") continue img = cv2.resize(img, target_size, interpolation=cv2.INTER_CUBIC) images.append(img.flatten()) # (10304,) labels.append(label) return np.array(images), np.array(labels)逻辑说明:外层循环遍历每个人的文件夹,内层逐个读取图片。IMREAD_GRAYSCALE直接读成单通道,省去后续灰度化。resize用INTER_CUBIC,适合缩小;如果是放大,INTER_LINEAR更快但边缘略糊。flatten把二维矩阵展成一维向量,作为PCA的输入特征。
参数说明:target_size越大,保留的细节越多,但特征维度按乘法增长,112×92已经是兼顾精度与计算量的经典选择。返回的images是(n_samples, 10304)矩阵,labels是n_samples长度的数组。
细节上还有几个坑。sorted(os.listdir(...))保证文件夹顺序稳定,不依赖文件系统返回顺序。endswith检查扩展名,避免把非图片文件读进来。img is None的检查很有用,OpenCV读损坏文件时会返回None而不是抛异常,不检查的话后面resize会直接崩。flatten是行优先展开,训练和测试必须用同样的展开顺序,否则特征顺序错位,模型直接失效。
3.2 灰度化与直方图均衡:让PCA不"看"光照
PCA对像素值的绝对大小非常敏感。同一个人的两张照片,一张在明亮环境下、一张在昏暗环境下,灰度分布可能差很多,这些差异会被PCA当成主要方差来源,挤掉真正用于区分身份的信息。直方图均衡是应对光照变化的经典预处理手段。
def preprocess_image(img): # CLAHE 比普通均衡更温和,抑制噪声放大 clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) return clahe.apply(img)逻辑说明:createCLAHE返回一个自适应直方图均衡器,clipLimit限制对比度增强的幅度,tileGridSize把图像划分成8×8的小块分别均衡,避免整图均衡把暗部细节洗掉。对比普通cv2.equalizeHist,CLAHE在面部阴影和边缘细节上表现更好。
参数说明:clipLimit设太大会让皮肤纹理出现噪点,设在1.5到3.0之间比较稳;tileGridSize对低分辨率人脸不建议大于16×16,否则局部块太大,均衡效果退化。
颜色空间也要提一句。为什么不保留RGB?因为人脸识别在灰度域已经够用,RGB三个通道只会让特征维度乘3,对PCA的方差结构没有本质帮助。除非数据集本身就是彩色且光照差异无法用灰度表达的,否则一律灰度化。
3.3 类别标签与数据集划分:stratify是大多数人忽略的参数
标签构建很简单,文件夹名就是类别。但train_test_split有个参数必须强调——stratify。如果不设,10个人各10张图、随机划分时可能出现某人在训练集里只有3张、另一个人有9张,类别分布偏掉,PCA学出来的特征脸会被样本多的类别带偏。
from sklearn.model_selection import train_test_split X = np.array(images) y = np.array(labels) # 保证训练集和测试集里每个类别占比一致 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y )逻辑说明:stratify=y让划分后训练集与测试集中的类别比例与原始数据一致。人脸识别数据集本来就小,动辄出现某类只有2张测试图的情况,stratify能避免这种偶然。
参数说明:test_size=0.3表示拿30%当测试集。人脸识别场景有个不成文的规矩——确保每个类别至少有1张测试图,否则"识别率"是虚的。random_state固定成任意整数都行,重跑实验时结果可复现。
补充一点:如果某个人的样本数远多于其他人,stratify会让两边类别比例一致,但不会解决类别不平衡本身。这时可以考虑给少数类做数据增强,比如水平翻转、小角度旋转,或者调整SVM的class_weight='balanced'。自己采集数据时,务必控制每人照片数差异不要超过2倍。
4. 训练与识别流程:从PCA降维到分类器输出
4.1 标准化与PCA降维:顺序不能错
预处理完的特征矩阵还要过一遍标准化,再进入PCA。标准化的作用是让每个像素值的均值归零、方差归单位,消除不同图片亮度绝对值的影响。先标准化还是先降维?常见做法是先标准化再PCA。sklearn的PCA内部会对输入做中心化,但不会做方差缩放,所以这一步得自己做。
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) # 用训练集拟合 X_test_scaled = scaler.transform(X_test) # 测试集只做变换 pca = PCA(n_components=50, svd_solver='randomized') X_train_pca = pca.fit_transform(X_train_scaled) X_test_pca = pca.transform(X_test_scaled)逻辑说明:fit_transform在训练集上同时完成"学习参数并转换",transform只套用已经学到的参数。对测试集绝不能调用fit_transform,否则会把测试集的均值和方差混进模型,造成数据泄露。
参数说明:n_components=50是经验起点。svd_solver='randomized'在n_features=10304时比默认的auto快一个量级。你可以在验证集上比较40、60、80的效果再最终决定。
4.2 分类器选型:KNN简单但慢,SVM性价比更高
降维之后是分类环节。两类方案最多见:KNN和SVM。KNN在低维空间里表现稳定,但它需要保存全部训练样本,每次推理要算距离,样本量上去之后延迟线性增长。SVM用核函数把低维空间映射到高维找分界面,训练完成后只保留支持向量,推理快,RBF核尤其适合人脸这种非线性分布的数据。
| 方案 | 训练耗时 | 推理耗时 | 需要调参 | 适用规模 |
|---|---|---|---|---|
| KNN(k=5) | 无 | 随样本量线性增长 | k | 几百张以内 |
| 线性SVM | 快 | 快 | C | 特征较线性可分 |
| RBF SVM | 较慢 | 快 | C、gamma | 中小规模最稳 |
我一般会用RBF核SVM做主力,C和gamma通过网格搜索确定。C控制误分类的惩罚力度,太大容易过拟合;gamma控制RBF核的"作用半径",太小会欠拟合、太大会让每个样本都变成孤岛。
from sklearn.svm import SVC from sklearn.model_selection import GridSearchCV svm = SVC(kernel='rbf', probability=True) param_grid = { 'C': [1.0, 10.0, 50.0], 'gamma': [0.0001, 0.001, 0.01] } grid = GridSearchCV(svm, param_grid, cv=5) grid.fit(X_train_pca, y_train) print(grid.best_params_)逻辑说明:GridSearchCV做五折交叉验证,遍历3乘3共9组参数组合,每组都训练5次,取平均精度最高的参数。对几百张图的数据集,这个耗时可以接受。
参数说明:gamma=0.001对PCA降维后的特征向量是一个合理起点,因为PCA输出各维方差差异大,gamma太大极易过拟合。找到best_params_之后,用整个训练集重新fit一次再测试。
4.3 单张图片的识别流程:从读图到输出类别
模型训练完,识别过程不是简单的predict就能完事。新进来的图片要经过与训练集完全相同的预处理管线:灰度化、resize、CLAHE、flatten、scaler、pca、svm.predict。任何一步顺序错了,结果都会偏。
def recognize(test_img_path, model, scaler, pca, target_size=(112, 92)): # 读取并预处理 img = cv2.imread(test_img_path, cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, target_size, interpolation=cv2.INTER_CUBIC) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) img = clahe.apply(img) # 转换为模型输入格式 x = img.flatten().reshape(1, -1) x = scaler.transform(x) x_pca = pca.transform(x) # 预测 label = model.predict(x_pca)[0] proba = model.predict_proba(x_pca)[0] conf = proba.max() return label, conf逻辑说明:reshape(1, -1)把一维数组变成(1, 10304)的二维矩阵,符合sklearn的样本乘特征约定。scaler和pca都是用训练集拟合好的,测试图片只走transform。predict_proba返回每个类别的概率,取最大值作为置信度。
参数说明:返回值conf可以作为后续阈值判断的依据。如果最高置信度都低于0.8,多数情况下是这张脸不在训练集里,或者预处理环节出了问题。
5. 常见问题与避坑:五个真实的踩坑记录
5.1 特征脸图像全黑:忘了中心化还原
现象:把pca.components_直接reshape成图像保存,输出全是接近黑色的图。 原因:PCA的特征向量值域在-1到1之间,直接imwrite会把负值截断成0,看起来就是黑的。 解决:先映射到0到255区间再保存。常见做法是min-max归一化。
def save_eigenface(component, output_path): v_min = component.min() v_max = component.max() normalized = (component - v_min) / (v_max - v_min) * 255 cv2.imwrite(output_path, normalized.reshape((112, 92)).astype(np.uint8))5.2 识别率不升反降:主成分数选错了
现象:n_components从20调到80,识别率反而掉了3个百分点。 原因:不同人的差异分布在贡献率中等的维度上,取太少丢细节;取太多又把光照、表情噪声带进来。这两类信息混在一起时,SVM的决策边界被高方差的噪声维度干扰。 解决:不要只看方差贡献率曲线,直接在验证集上做n_components的扫描实验。我一般会用20、30、40、50、60、80、100这个序列,跑完画精度曲线,找到平台期的起点。
5.3 train_test_split不设stratify导致某类样本全进测试集
现象:识别率忽高忽低,换了random_state结果差异巨大。 原因:数据少时随机划分的偶然性太大,某类2张图可能全被分到测试集,训练集里没有这个类,测试时自然全错。 解决:划分时强制stratify=y,并检查每个类别在两个集合中的数量分布。
5.4 先PCA再标准化:重建语义被破坏
现象:按"先降维再缩放"的顺序处理,调试时发现pca.inverse_transform的结果完全无法还原成可理解的人脸。 原因:PCA找的是最大方差方向,标准化放在降维之后会把主成分的尺度统一,但PCA没有感知这个统一过程,逆变换结果对不回来。 解决:标准化一定在PCA之前做,而且要记住scaler是拟合在raw pixel上的。如果后续需要重建图像检查特征脸,千万不要在PCA和scaler之间插入其他变换。
5.5 内存溢出:full SVD在低配机器上直接卡死
现象:10304维、300个样本的协方差矩阵在某个开发机上直接MemoryError。 原因:手写PCA或sklearn默认的full solver会构造d乘d的协方差矩阵,10304乘10304乘8字节约850MB,加上中间变量,16G内存的机器也会被耗尽。 解决:改用randomized solver,或者用PCA.fit_transform的另一种等价写法——在sklearn里设置svd_solver='randomized'即可;手写版本则不要算协方差,直接用SVD做截断。
pca = PCA(n_components=50, svd_solver='randomized', random_state=0)提示:randomized算法对随机数种子敏感,复现实验时务必固定random_state。
6. 进阶技巧:用重建误差做"陌生人"检测
PCA降维不是只能给分类器喂特征。投影到特征脸空间之后再逆变换回原始像素空间,会得到一个"重建脸"。如果一张测试图属于训练集里出现过的人,重建误差(原始图与重建图之间的欧氏距离)会比较小;如果是完全没见过的陌生人,重建误差会明显偏大。这个特性可以直接用来做开集识别,也就是门禁场景里的"拒绝未知人"。
实现起来不复杂。前面的scaler和pca已经拟合好了,对每张测试图:
def reconstruction_error(img_flat, scaler, pca): x_scaled = scaler.transform(img_flat.reshape(1, -1)) x_pca = pca.transform(x_scaled) x_recon = pca.inverse_transform(x_pca) x_back = scaler.inverse_transform(x_recon) return np.linalg.norm(img_flat - x_back)逻辑说明:transform进入特征脸空间,inverse_transform回到像素空间,再用scaler.inverse_transform还原到原始像素尺度,最后算L2距离。注意scaler一定也要做逆变换,否则重建结果停留在标准化后的尺度上,算出来的距离没有意义。
阈值设定与具体场景强相关,没有通解。一个可落地的方法是:把训练集里所有样本的重建误差算出来,取95%分位数作为阈值。新图的重建误差超过阈值,就判定为未知身份,不进入分类器投票阶段。这个方案在闭集测试里会牺牲一点点精度——因为某些"最难辨认的已知人"误差本来就大——但换来了对陌生面孔的拒识能力。
从那以后我每做一个PCA人脸识别项目,都会强制走一遍"训练集重建误差分布、选阈值、验证集评估"这条检查链,因为没人想在门禁Demo上放进一个谁都识别不了的人。希望帮到你。
本文还有配套的精品资源,点击获取