ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PCA人脸识别实战:从零搭建特征脸系统与避坑指南

PCA人脸识别实战:从零搭建特征脸系统与避坑指南

人脸识别这个词听起来挺唬人,但真动手做起来,最经典的那套入门方案反而没那么玄乎。我最早接触这个方向的时候,以为非得搞个深度网络、跑上几十万张图才能出效果,后来发现用PCA(主成分分析)这套老方法,在标准人脸数据集上照样能跑到九成以上的识别率,而且整个流程用Python几十行就能跑通。这篇内容就是把我自己从零搭这套东西的完整过程拆开讲——包括PCA到底在人脸识别里干了什么、为什么它管用、特征脸是怎么回事、实际编码时哪些参数会坑你、以及怎么评估识别效果。适合刚入门计算机视觉的学生、想找个练手项目的开发者,或者单纯好奇"机器怎么认人"的技术爱好者。不需要你有深度学习基础,但得会一点Python和NumPy的基本操作。

1. 先搞清楚PCA在人脸识别里到底扮演什么角色

1.1 一张人脸图片在计算机眼里是什么

一张灰度人脸图片,假设尺寸是100×100像素,在计算机里就是一个10000维的向量。每个像素的灰度值就是这一维的数值,范围0到255。你想想,10000维是什么概念——我们平时画个二维散点图就已经觉得挺直观了,三维勉强能在脑子里转一转,10000维的空间根本没法想象。但数学上它就是存在的,而且人脸识别的问题本质上就是在这个超高维空间里做分类。

问题来了:在这个10000维空间里,任意两张不同的人脸图片,它们之间的欧氏距离可能都差不多大。因为维度太高,像素级的差异被均匀分散到了各个维度上,导致"同一个人不同角度拍的两张照片"和"不同人拍的两张照片"在距离上区分度很低。这就是所谓的维度灾难。PCA要解决的核心问题就是:能不能用远低于10000维的一组新坐标来表示人脸,同时保留住区分不同人所需的关键信息?

1.2 PCA降维的直觉理解

PCA做的事情,用一句话说就是:找到一组新的坐标轴,让数据在这些轴上的投影尽可能分散。为什么分散很重要?因为如果所有人脸在某个方向上的投影都挤在一起,那这个方向对区分不同人就没有贡献。反过来,投影越分散的方向,越能体现人脸之间的差异。

打个比方。假设你要区分一群人的身高和体重,但只能用一个指标。显然你会选一个综合了身高和体重的指标,而不是选"鞋码"这种跟体型关系不大的。PCA就是在自动帮你找这种"最有区分力的综合指标"。它找到的第一个新轴叫第一主成分,是数据方差最大的方向;第二个轴叫第二主成分,是与第一主成分正交且方差次大的方向;以此类推。

在人脸识别的语境下,这些主成分有一个很形象的名字——特征脸(Eigenfaces)。因为每一个主成分向量如果还原成图片尺寸,看起来就像一张模糊的、幽灵般的人脸。第一特征脸通常反映的是整体光照方向,第二特征脸可能反映的是人脸左右对称性的差异,后面的特征脸逐渐捕捉更细节的特征。

1.3 为什么不用原始像素直接做分类

有人可能会想:我直接把10000维的像素向量丢给一个分类器不就行了,为什么要多此一举做PCA?原因有三个。

第一,计算量。10000维的协方差矩阵是10000×10000,求特征向量在计算上非常吃力。而PCA降维之后,可能只需要保留100到200个主成分,计算量下降两个数量级。

第二,过拟合。维度越高,分类器需要估计的参数越多,在训练样本有限的情况下极易过拟合。你可能有几百张训练图,但在10000维空间里,这几百个点稀疏得可怜,分类器随便画个超平面就能完美分开训练集,但换到测试集就崩了。

第三,噪声。原始像素里包含大量与身份无关的信息——比如传感器噪声、轻微的位移、背景的杂物。PCA保留方差大的方向,相当于把主要的结构信息留下,把噪声压缩掉。

注意:PCA是无监督方法,它降维时并不看标签,只是找方差最大的方向。这意味着它保留下来的信息未必都对分类有用。这是PCA的固有局限,也是后来LDA(线性判别分析)等方法出现的原因。

2. 从零搭建PCA人脸识别流水线

2.1 数据准备与预处理的几个关键决策

我用的是一组标准的人脸数据集,里面包含若干个人的多张正面灰度照片,每人拍摄时的光照、表情、是否戴眼镜等条件都有变化。拿到数据后的第一步不是急着写PCA,而是把预处理做扎实,这一步偷懒后面全白搭。

尺寸统一是必须的。原始图片尺寸可能不一致,得全部缩放到相同大小。我选的是100×100,这个尺寸在保留足够辨识信息和控制计算量之间比较平衡。如果你用更大的尺寸比如200×200,维度直接变成40000,PCA的计算时间会明显增加,但识别率的提升未必显著。

灰度化也是常规操作。彩色图片有三个通道,直接展平会变成三倍维度,而且颜色信息对人脸识别的贡献远不如纹理和形状。转成灰度后,每个像素只有一个值,处理起来干净利落。

直方图均衡化这一步很多人会忽略,但对识别率影响很大。不同照片的拍摄光照条件不同,有的偏亮有的偏暗,直方图均衡化可以把每张图的灰度分布拉均匀,减少光照差异带来的干扰。实测下来,加不加这一步,识别率能差好几个百分点。

向量化就是把每张100×100的图片展平成10000维的向量。把所有训练图片的向量按行堆叠,得到一个矩阵,行数是图片数量,列数是10000。这个矩阵就是PCA的输入。

2.2 计算流程的每一步在做什么

PCA的计算流程说起来不复杂,但每一步的数学含义值得掰开讲。

第一步:计算均值脸。把所有训练图片的向量加起来除以图片数量,得到一张"平均脸"。这张脸看起来就是一团模糊的人脸轮廓,因为它把所有个体差异都平均掉了。均值脸的作用是作为基准,后续所有图片都要减去它,这叫中心化。中心化的目的是让PCA关注的是"差异"而不是"绝对位置"。

第二步:计算协方差矩阵。中心化后的数据矩阵记为X,形状是(样本数n,维度d)。协方差矩阵就是X的转置乘以X,再除以n-1,得到一个d×d的矩阵。这个矩阵的每个元素表示两个像素维度之间的相关性。但d=10000时,这个矩阵是10000×10000,直接求特征向量非常慢。

第三步:用奇异值分解(SVD)绕过巨型协方差矩阵。这是实操中最重要的一个技巧。我们不直接对10000×10000的协方差矩阵做特征分解,而是对中心化后的数据矩阵X(n×d)做SVD。SVD会给出三个矩阵:U(n×n)、S(对角矩阵)、Vt(d×d)。其中Vt的前k行就是我们要的前k个主成分方向。这个技巧之所以有效,是因为n通常远小于d(比如n=200,d=10000),对X做SVD的计算量远小于对协方差矩阵做特征分解。

第四步:选择保留多少个主成分。这是一个需要权衡的决策。保留太少,信息丢失严重,识别率上不去;保留太多,噪声和冗余信息被带进来,计算量也大。常用的方法是看累计方差贡献率——把每个主成分对应的奇异值平方,除以所有奇异值平方之和,得到每个主成分解释了多少比例的方差。通常保留累计贡献率达到90%到95%的主成分数量。

第五步:投影。把每张训练图片的向量减去均值脸,然后乘以保留的主成分矩阵(形状是d×k),得到一个k维的向量。这个k维向量就是这张人脸在"特征脸空间"中的坐标,也叫特征脸权重向量。后续的分类就在这个k维空间里做。

2.3 代码实现中的关键片段

下面这段代码展示了核心的PCA训练过程。我用的是NumPy手动实现,而不是直接调sklearn的PCA,原因是手动实现能让你看清楚每一步在干什么,遇到问题时也知道去哪里调。

import numpy as np def train_pca(X, n_components=0.95): """ X: 训练数据矩阵,形状为 (n_samples, n_features) n_components: 如果为0到1之间的小数,表示保留的方差比例; 如果为整数,表示保留的主成分个数 返回: 均值向量、投影矩阵、投影后的训练数据 """ # 计算均值脸 mean_face = np.mean(X, axis=0) # 中心化 X_centered = X - mean_face # 对中心化后的矩阵做SVD U, S, Vt = np.linalg.svd(X_centered, full_matrices=False) # 计算累计方差贡献率 explained_variance = (S ** 2) / (X.shape[0] - 1) cumulative_variance = np.cumsum(explained_variance) / np.sum(explained_variance) # 确定保留的主成分数量 if isinstance(n_components, float) and 0 < n_components < 1: k = np.searchsorted(cumulative_variance, n_components) + 1 else: k = int(n_components) # 取前k个主成分方向 components = Vt[:k, :] # 形状 (k, n_features) # 投影 X_projected = X_centered @ components.T # 形状 (n_samples, k) return mean_face, components, X_projected

这段代码里有一个细节值得注意:np.linalg.svd的full_matrices=False参数。如果不加这个参数,SVD会返回完整的U和Vt矩阵,对于大矩阵来说内存开销很大。设为False之后,只返回经济型SVD的结果,U的形状是(n, min(n,d)),Vt的形状是(min(n,d), d),足够我们用了。

2.4 投影之后怎么分类

投影完成后,每张训练图片变成了一个k维向量。分类的思路很直接:对于一张新的测试图片,先减去均值脸,再投影到特征脸空间,得到它的k维坐标,然后看它离哪个训练样本的坐标最近。这就是最近邻分类器。

最近邻的度量用欧氏距离就行。但有一个细节:不同主成分方向上的方差不同,第一主成分的方差远大于最后一个主成分。如果不做处理,距离计算会被方差大的主成分主导。一种改进做法是白化——把每个主成分方向上的坐标除以其标准差,让所有方向上的方差都变成1。白化之后再做最近邻,效果通常更稳定。

def predict(X_test, mean_face, components, X_train_projected, y_train): """ 对测试样本进行预测 """ # 中心化并投影 X_test_centered = X_test - mean_face X_test_projected = X_test_centered @ components.T # 计算与每个训练样本的欧氏距离 # 利用广播机制:(n_test, 1, k) - (1, n_train, k) diff = X_test_projected[:, np.newaxis, :] - X_train_projected[np.newaxis, :, :] distances = np.sqrt(np.sum(diff ** 2, axis=2)) # 取距离最小的训练样本的标签 nearest_indices = np.argmin(distances, axis=1) return y_train[nearest_indices]

3. 实测中那些让你识别率暴跌的坑

3.1 训练集和测试集的划分方式决定成败

我一开始犯过一个很典型的错误:把所有图片随机打乱后按8:2划分训练集和测试集。结果识别率虚高,到了实际使用场景就崩了。原因在于,同一个人可能有多张照片,随机划分会导致同一个人的照片同时出现在训练集和测试集中。分类器在测试时看到的"陌生人",其实在训练时已经见过他另一张照片了,这相当于作弊。

正确的做法是按人划分:某些人的所有照片都进训练集,另一些人的所有照片都进测试集。这样测试集里的人对分类器来说是完全陌生的,才能真实反映模型的泛化能力。这个细节在学术论文里叫"subject-independent"评估协议,实际做项目时一定要按这个来。

3.2 主成分数量不是越多越好

我做过一组对比实验,保留的主成分数量从10到300,每隔一段取一个值,看识别率怎么变。结果是一条先升后降的曲线。主成分太少时,信息不够,识别率低;主成分增加到某个点后识别率达到峰值;继续增加,识别率反而下降,因为后面的主成分主要捕捉的是噪声和训练集特有的模式,对泛化没有帮助。

具体峰值在哪里,取决于数据集的大小和难度。我的经验是,对于几百张图片、几十个人的数据集,保留50到150个主成分通常比较合适。你可以用累计方差贡献率来辅助判断,但最终还是要用验证集上的识别率来定。

3.3 光照和表情变化是最大的敌人

PCA对光照变化非常敏感。同一个人的两张照片,如果一张是正面均匀光照,另一张是侧面强光,它们在像素空间里的差异可能比不同人在相同光照下的差异还大。PCA降维后,这种光照差异会占据方差最大的几个主成分方向,导致真正区分身份的信息被压缩到后面的主成分里。

缓解的办法有几个。一是在预处理阶段做直方图均衡化,这个前面提过了。二是对训练数据做数据增强,把每张图片的亮度调高调低各生成一个副本,让模型见过更多光照变化。三是用**局部二值模式(LBP)**等对光照不敏感的特征替代原始像素,但这已经超出了纯PCA的范畴。

表情变化的处理更棘手。微笑和中性表情之间的差异,在像素层面可能比不同人之间的差异还大。PCA本身没有建模表情的能力,只能靠训练数据里包含足够多的表情变化来"教会"模型忽略这些差异。

3.4 计算SVD时的内存陷阱

当图片尺寸较大时,比如200×200,展平后是40000维。如果训练集有500张图片,中心化后的矩阵是500×40000,占内存约500×40000×8字节=160MB,还能接受。但如果图片尺寸是500×500,维度变成250000,同样500张图片就是1GB内存,SVD分解时还会产生中间变量,很容易爆内存。

解决办法有两个。一是缩小图片尺寸,人脸识别并不需要很高的分辨率,100×100甚至50×50往往就够了。二是用增量PCA,把数据分批次送入,每次只加载一部分数据,逐步更新主成分。sklearn的IncrementalPCA就是干这个的,适合数据量大到内存放不下的场景。

4. 怎么评估这套方案到底行不行

4.1 识别率和验证率的区别

这两个指标经常被混淆。识别率(identification rate)衡量的是:给一张测试图片,系统能不能在已知的人脸库中找到正确的那个人。这是一个"一对多"的问题。验证率(verification rate)衡量的是:给两张图片,系统能不能判断它们是不是同一个人。这是一个"一对一"的问题。

PCA+最近邻的方案天然适合做识别任务,因为最近邻分类器直接输出一个身份标签。如果要做验证,需要设定一个距离阈值——距离小于阈值判为同一个人,大于阈值判为不同人。阈值的选取需要在误接受率(把不同人判为同一人)和误拒绝率(把同一人判为不同人)之间权衡。

4.2 用混淆矩阵看具体错在哪里

光看一个总的识别率数字不够,你还需要知道模型在哪些人身上容易出错。混淆矩阵的行是真实身份,列是预测身份,对角线上的数字是正确分类的数量,非对角线上的数字是错误分类的数量。

我跑过一次实验,发现模型在某两个人之间频繁混淆。把这两个人的照片调出来一看,发现他们拍摄时的光照条件非常接近,而且脸型轮廓有相似之处。这种信息对于后续改进非常有价值——你可以针对性地增加这两个人的训练样本,或者引入对光照更鲁棒的特征。

4.3 和基线方法对比才有意义

单独报一个"识别率90%"没有太大意义,你得知道不用PCA、直接用原始像素做最近邻能到多少。我实测下来,在同一个数据集上,原始像素+最近邻的识别率大约是70%出头,PCA降维后能到90%左右,提升非常明显。这个对比才能说明PCA确实起了作用。

另一个值得对比的基线是随机投影——随机生成一个投影矩阵,把高维数据降到同样的维度,然后做最近邻。如果PCA的识别率显著高于随机投影,说明PCA找到的主成分方向确实比随机方向更有区分力。如果两者差不多,那可能说明你的数据集太简单,或者降维后的维度还是太高。

4.4 交叉验证的正确打开方式

做交叉验证时,同样要注意按人划分的问题。标准的k折交叉验证是随机把样本分成k份,但这在人脸识别场景下会导致同一个人出现在不同的折里。正确的做法是按人分折:把所有人分成k组,每次拿一组作为测试集,其余作为训练集。这样每一折的测试集里都是训练时没见过的人。

from sklearn.model_selection import GroupKFold # 假设 subjects 是每个人的ID数组 gkf = GroupKFold(n_splits=5) for train_idx, test_idx in gkf.split(X, y, groups=subjects): X_train, X_test = X[train_idx], X[test_idx] y_train, y_test = y[train_idx], y[test_idx] # 训练和评估...

5. 从PCA出发还能往哪些方向走

5.1 LDA:让降维直接服务于分类

PCA是无监督的,它找的是方差最大的方向,但这些方向未必对分类最有利。LDA(线性判别分析)则是有监督的,它的目标是最大化类间距离与类内距离的比值。用人话说就是:让同一个人不同照片的投影尽量聚在一起,不同人的投影尽量分开。

在人脸识别任务上,LDA通常比PCA效果更好,尤其是在训练样本充足的情况下。但LDA有一个限制:它最多能提取C-1个判别方向,其中C是类别数。如果人数很少,降维后的维度会非常低,可能不够用。实践中常见的做法是先用PCA降到一个中间维度,再用LDA进一步降维,这叫PCA+LDA两阶段方法。

5.2 核PCA:处理非线性结构

PCA本质上是在做线性变换,它假设数据的主要变化方向是线性的。但人脸图像的变化——比如姿态变化导致的脸部形变——往往是非线性的。核PCA通过一个核函数把数据映射到更高维的空间,在那个空间里做线性PCA,等价于在原空间里做非线性降维。

常用的核函数有高斯核和多项式核。核PCA的缺点是计算量大,因为核矩阵的大小是n×n,n是样本数。如果样本数上万,核矩阵就太大了。而且核PCA没有像标准PCA那样的"特征脸"可视化,解释性差一些。

5.3 增量PCA:应对流式数据

实际项目中,人脸库是不断增长的,今天来了新员工要录入,明天有人离职要删除。如果每次变动都重新训练整个PCA,计算成本太高。增量PCA允许你在已有模型的基础上,用新数据更新主成分方向,而不需要保留所有历史数据。

sklearn的IncrementalPCA提供了partial_fit方法,可以分批次送入数据。但要注意,增量PCA得到的主成分方向可能和批量PCA略有不同,因为它是近似算法。对于精度要求极高的场景,还是得定期做一次完整的批量训练。

5.4 从PCA到深度特征:思路的延续

现在主流的人脸识别方案都是用深度网络提取特征,但PCA的核心思想——把高维数据投影到低维空间,保留最有区分力的方向——在深度学习中依然存在。深度网络的最后一层全连接层,本质上也是在做一个从高维特征到低维嵌入的映射。只不过这个映射是通过大量数据学习出来的,比PCA的线性映射灵活得多。

如果你理解了PCA的局限——线性、无监督、对光照和姿态敏感——你就能理解为什么深度方法能超越它。但PCA依然是一个极好的入门项目,因为它让你亲手触摸到"降维"和"特征提取"这两个核心概念,而且整个流程透明可控,出了问题你知道去哪里找原因。

6. 几个容易被忽略的实操细节

6.1 均值脸到底要不要减

中心化是PCA的标准步骤,但在人脸识别里有一个细节:训练时减了均值脸,测试时也必须减同一个均值脸。我见过有人训练时减了,测试时忘了减,结果识别率直接掉到随机水平。原因很简单:如果不减均值,测试图片的投影坐标会和训练图片的投影坐标处于不同的坐标系里,距离计算完全失去意义。

另一个细节是,均值脸应该只从训练集计算,不能用全部数据计算。如果用全部数据算均值脸,测试集的信息就泄露到了训练过程中,评估结果会偏乐观。

6.2 图片展平的顺序必须一致

把二维图片展平成一维向量时,展平的顺序——是按行优先还是按列优先——必须对所有图片保持一致。这个听起来是废话,但我在实际项目中真的见过有人训练时用行优先,测试时用列优先,导致识别率惨不忍睹。更隐蔽的情况是,用了不同的图像库读取图片,有的库默认返回的数组形状是(高度,宽度),有的是(宽度,高度),如果不注意统一,展平后的向量就完全对不上了。

6.3 距离度量的选择

欧氏距离是最常用的,但余弦距离在某些场景下效果更好。欧氏距离关注的是绝对位置差异,余弦距离关注的是方向差异。在人脸识别中,如果图片的亮度整体偏移(比如所有像素都加了一个常数),欧氏距离会受影响,但余弦距离不受影响,因为亮度偏移不改变向量的方向。

你可以两种都试一下,看哪个在你的数据集上表现更好。切换距离度量只需要改一行代码,成本很低。

6.4 保存和加载模型的注意事项

训练好的PCA模型需要保存下来供后续使用。需要保存的东西包括:均值脸向量、主成分矩阵、训练集的投影坐标和对应的标签。用np.savez或者pickle都可以。加载时要注意数组的形状和数据类型必须和保存时一致,否则投影计算会出错。

还有一个容易忽略的点:如果后续要用新的数据更新模型,你得保留原始的训练数据或者至少保留中心化后的数据矩阵。只保存主成分矩阵是不够的,因为增量更新需要用到原始数据的统计信息。

6.5 处理类别不平衡

如果人脸库里某些人有几十张照片,另一些人只有两三张,最近邻分类器会偏向那些样本多的类别。因为样本多,测试图片找到"近邻"的概率就大。缓解的办法有:对样本少的类别做数据增强,或者用加权最近邻——距离相同时,优先选择样本少的类别。

这个问题在学术数据集上通常不明显,因为标准数据集里每个人的照片数量差不多。但实际项目中,人员照片的采集数量往往很不均衡,这个坑迟早会踩到。

7. 这套方案在实际场景中的边界

PCA人脸识别有它擅长的场景,也有它搞不定的情况。说清楚这个边界,比盲目吹嘘方法本身更有价值。

适合的场景:光照条件相对可控的室内环境、用户配合拍摄的正面照片、人脸库规模在几百到几千人、对实时性要求不高的离线比对。比如门禁系统的底库比对、考勤系统的照片验证,这些场景下PCA方案完全够用,而且部署简单,不需要GPU。

不适合的场景:监控视频中的远距离人脸、大角度侧脸、遮挡严重的人脸、光照剧烈变化的环境、人脸库规模上万甚至百万级。这些场景下PCA的线性假设和像素级特征提取方式都力不从心,需要深度学习方法。

我个人的经验是,把PCA方案当作一个基线系统来用。任何新的人脸识别方案,都应该先和PCA基线比一比。如果深度方案在某个场景下只比PCA好一点点,那可能不值得为此增加的计算复杂度和部署成本。如果好很多,那说明这个场景确实需要非线性特征提取。

最后分享一个我在实际项目中总结的小技巧:当你发现PCA方案的识别率不理想时,先别急着换方法,花半个小时检查一下预处理流程——图片尺寸是否统一、灰度化是否正确、直方图均衡化是否做了、训练集和测试集是否按人划分。我遇到过好几次,问题根本不在PCA本身,而是预处理环节出了纰漏。把预处理做扎实,PCA的识别率往往能超出你的预期。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进