人脸识别CNN实战:从数据准备到摄像头实时识别的完整代码全解析
简介使用卷积神经网络CNN做人脸识别的示例代码以PDF文档形式呈现面向正在学习深度学习、希望动手实践人脸识别领域的开发者。内容从特征脸法等传统算法的原理回顾切入涵盖图像灰度化、PCA降维、分类器相似度度量等经典思路再过渡到CNN的结构与自动特征提取优势帮助读者理解技术演进的来龙去脉。整个压缩包仅含1个PDF文件大小约722KB篇幅精炼但覆盖完整流程包括Yale人脸库等公开数据集的获取与整理、基于OpenCV的人脸检测与裁剪、Keras搭配TensorFlow搭建CNN模型、训练后的人脸分类与标注展示。资料已有1442人学习浏览适合需要一份可读性强、步骤清晰的人脸识别入门参考的学习者。通过研读这份示例读者能够快速梳理出从数据准备、模型搭建到结果输出的完整技术链路并领会代码实现细节为后续独立完成人脸识别项目打下坚实基础。1. 人脸识别不是玄学从特征脸到 CNN这份示例代码让入门路径清晰了一半很多人第一次接触人脸识别是从特征脸法开始的灰度化、拉直成列向量、PCA 降维、欧氏距离度量。这套流程本身没有错但本质上是在跟像素打交道对光照、角度、遮挡的容忍度很有限。真正把人脸识别推到实用级的是卷积神经网络CNN——它不关心“脸上有没有痣”而是自己从数据里学出“哪些像素组合能代表人”。这份示例代码覆盖了数据准备、CNN 模型搭建、训练、保存到摄像头实时识别的完整链路环境、依赖、参数都是可直接照抄的。适合刚学会 OpenCV 人脸检测、想跨进深度学习识别这一步的开发者也适合拿来做课程设计或公司内部小规模人脸识别 Demo 的从业者。先说结论这份资源不追求 SOTA 准确率但它的工程路径是完整的跟着跑一遍能省下你自己从零踩坑的几个月。2. 数据准备人脸检测提取、无形变 resize 与标签设计的完整流程2.1 从检测到裁剪把 Haar 检测结果转成训练集的核心代码训练 CNN 的第一步不是搭网络而是把原始照片变成“只有脸”的干净样本。这里复用上一篇的人脸检测思路加载 Haarcascade 级联分类器对每张图做灰度化然后detectMultiScale返回人脸坐标和宽高最后用数组切片把脸截出来存成 bmp。这段代码是整套数据的源头后面模型吃到的每一张图都从这里来。import cv2 import os CASE_PATH haarcascade_frontalface_default.xml RAW_IMAGE_DIR me/ DATASET_DIR jm/ face_cascade cv2.CascadeClassifier(CASE_PATH) def save_feces(img, name, x, y, width, height): image img[y:yheight, x:xwidth] cv2.imwrite(name, image) image_list os.listdir(RAW_IMAGE_DIR) count 166 for image_path in image_list: image cv2.imread(RAW_IMAGE_DIR image_path) gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale(gray, scaleFactor1.2, minNeighbors5, minSize(5, 5)) for (x, y, width, height) in faces: save_feces(image, %ss%d.bmp % (DATASET_DIR, count), x, y - 30, width, height 30) count 1这段代码有几个参数需要留意。scaleFactor1.2表示每次搜索窗口缩放 20%值越小检测越精细但速度更慢minNeighbors5表示一个候选框周围至少要出现 5 个重叠检测才确认是人脸值越小误检越多值大了又容易漏检minSize(5, 5)是接受的最小人脸尺寸。裁剪时我把y - 30、height 30做了上下外扩原因是 Haar 检测框往往紧贴五官直接把头发和前额切掉会影响后续特征提取。外扩 30 像素之后比如读到 y80、height120实际裁剪范围就是 y50 到 y230基本能把整个头部带一点背景框进来。这是个小细节但对训练结果影响不小。如果外扩后发现数组越界通常是脸部太靠近图像上边缘需要在检测循环里加y - 30为负数时的保护判断。2.2 无形变 resize涂黑边的思路、实现与边界条件Yale 公开人脸库的图片统一是 100×100而自己用 Haar 裁出来的图长宽比乱七八糟。直接cv2.resize到 100×100 会把脸拉宽或压扁模型看起来没问题但识别率会断崖式下跌。这里用了一个数字图像处理里的老办法先把短边补黑边让整张图变成正方形再统一 resize。def resize_without_deformation(image, size(100, 100)): height, width, _ image.shape longest_edge max(height, width) top, bottom, left, right 0, 0, 0, 0 if height longest_edge: height_diff longest_edge - height top int(height_diff / 2) bottom height_diff - top elif width longest_edge: width_diff longest_edge - width left int(width_diff / 2) right width_diff - left image_with_border cv2.copyMakeBorder( image, top, bottom, left, right, cv2.BORDER_CONSTANT, value[0, 0, 0]) resized_image cv2.resize(image_with_border, size) return resized_image这里的核心逻辑是copyMakeBorder参数top、bottom、left、right分别指定四个方向要补的像素数value[0, 0, 0]表示用黑色填充。补边不是全往一边堆而是上下各分一半这样人脸能保持在画面正中心避免引入位置偏差。补完边再resize长边被压缩到 100短边因为已经通过黑边补到了相同尺寸所以只做等比缩放。注意这段代码对纯横图或纯竖图都没问题但如果输入图本身就是正方形height longest_edge和width longest_edge都不成立四个方向的补边全是 0直接 resize 即可。有一点容易被忽略resize_without_deformation的输入必须是height, width, _这种三通道结构如果是灰度图image.shape只有两个值解包直接报错。所以调用前要先确认图像是 BGR 三通道或者把这个函数改成兼容灰度图的写法。2.3 标签设计为什么不能直接用 1、2、3 当类别读图函数里返回的data_y用的是字符串str(int((i-1)/11.0))这样每个类别对应 11 张图从 0 排到 15最后一共 176 张。这里隐藏了一个关键问题如果直接用 1、2、3 这种整数作为分类标签等于在告诉模型“类别 2 和类别 4 之间有两倍关系”但人脸类别之间压根没有这种数学意义。所以后面必须转成 one-hot 编码。from keras.utils import np_utils ont_hot_labels np_utils.to_categorical(raw_labels)to_categorical会把一个长度为 176 的整数数组变成 176 × 16 的矩阵每个标签对应的那一行只有一个位置是 1其余全是 0。这样所有类别在向量空间里距离相等模型不再被虚假的数值关系误导。这里有个容易犯的错误raw_labels必须是从 0 开始的整数如果你自己在代码里写的是 1、2、3……to_categorical会自动帮你多出一个全零的类别列输出层神经元数量会变成 17最后模型训练不报错但评估时总有一类永远预测不出来非常隐蔽。3. 模型搭建与训练Keras TensorFlow 构建 CNN 的完整参数拆解3.1 网络结构选型两层卷积 池化 全连接为什么够用数据准备好之后就是最重要的环节——搭建卷积神经网络模型。很多人一上来就想堆 ResNet、VGG 这种深层网络但在这个只有 176 张图的样本量下深层网络会在第一轮迭代就过拟合训练集准确率直接冲到 100%验证集却一塌糊涂。相比之下原作者用的结构反而是更合理的选择两层 32 卷积核、两层 64 卷积核、两个池化层、一个 512 神经元全连接层。这个结构能在不把模型参数撑爆的情况下学到足够的人脸特征。from keras.models import Sequential from keras.layers import Conv2D, MaxPooling2D, Dense, Dropout, Flatten face_recognition_model Sequential() face_recognition_model.add(Conv2D(32, 3, 3, border_modevalid, subsample(1, 1), dim_orderingtf, input_shape(100, 100, 3), activationrelu)) face_recognition_model.add(Conv2D(32, 3, 3, border_modevalid, subsample(1, 1), dim_orderingtf, activationrelu)) face_recognition_model.add(MaxPooling2D(pool_size(2, 2))) face_recognition_model.add(Dropout(0.2)) face_recognition_model.add(Conv2D(64, 3, 3, border_modevalid, subsample(1, 1), dim_orderingtf, activationrelu)) face_recognition_model.add(Conv2D(64, 3, 3, border_modevalid, subsample(1, 1), dim_orderingtf, activationrelu)) face_recognition_model.add(MaxPooling2D(pool_size(2, 2))) face_recognition_model.add(Dropout(0.2)) face_recognition_model.add(Flatten()) face_recognition_model.add(Dense(512, activationrelu)) face_recognition_model.add(Dropout(0.4)) face_recognition_model.add(Dense(16, activationsigmoid))这里面的border_modevalid意味着卷积不做边缘填充每卷一次特征图尺寸减 2。输入是 100×100第一层卷积后变成 98×98第二层后 96×96池化后 48×48第三层卷积后 46×46第四层后 44×44池化后 22×22。到 Flatten 时实际送入全连接层的是 22×22×64 共 30976 个值。如果你改成border_modesame特征图尺寸全程不变模型参数会成倍增加但在这个规模的数据集上收益很小。激活函数统一选了 relu原因很简单relu 在正区间的导数是 1反向传播时梯度不会衰减收敛速度明显比 sigmoid 快。输出层用 sigmoid 配合 16 个神经元对应 16 个类别每个输出值代表“属于这个人的概率”。Dropout(0.4)是全连接层的丢包率这个值不是随手拍的——对小数据集来说0.2 只对卷积层做轻微抑制0.4 对全连接层做更强约束因为全连接层参数量最大最容易过拟合。3.2 训练配置SGD 优化器的 lr、decay、momentum 取值逻辑模型编译部分值得多说两句很多人在这里直接用默认的 Adam但原作者选的是 SGD Nesterov 动量这在小数据集上有它的道理。SGD 每一步都基于当前梯度的真实方向更新参数虽然路径震荡但泛化能力通常比自适应学习率的 Adam 更好。关键是三个参数配在一起learning_rate 0.01 decay 1e-6 momentum 0.8 nesterov True sgd_optimizer SGD(lrlearning_rate, decaydecay, momentummomentum, nesterovnesterov) face_recognition_model.compile(losscategorical_crossentropy, optimizersgd_optimizer, metrics[accuracy])learning_rate0.01是初始学习率太大容易在损失函数底部来回震荡太小又收敛得像蜗牛decay1e-6表示每更新一次学习率按 1e-6 的速率衰减训练到第 100 轮时学习率大约是初始值的九成多目的是在后期用更小的步子精细逼近最优解momentum0.8给参数更新加了“惯性”如果前几步梯度方向一致这一步会走得更快如果方向突然反转动量会起缓冲作用。Nesterov 是动量的改良版——先按动量方向预估下一步位置再在那个位置计算梯度这种“向前看一步”的策略在收敛速度和稳定性上都有改善。损失函数选了categorical_crossentropy而不是均方差原因是交叉熵在预测概率和真实标签差距越大时梯度越陡峭对错误分类的惩罚更重特别适合人脸这种多分类任务。3.3 训练流程数据划分、归一化、one-hot 与验证集训练前要对数据做最后一轮加工。原始图片像素值是 0 到 255 的整数直接送进网络会让初始梯度过大必须除以 255 归一到 0 到 1 范围。同时要用train_test_split把数据按 7:3 拆成训练集和验证集注意它默认会打乱数据这非常关键——如果不打乱前 176 张图全是顺序排列的模型只在前面几张图上轮回训练验证集会飞。from sklearn.model_selection import train_test_split train_input, valid_input, train_output, valid_output train_test_split( raw_images, ont_hot_labels, test_size0.3) train_input / 255.0 valid_input / 255.0test_size0.3的含义是 176 张图中约 53 张留作验证剩下的 123 张参与训练。这里有一个样本分配上的坑Yale 数据集中每个人只有 11 张图train_test_split是随机分割的有可能把某个人的 8 张分到训练集、3 张分到验证集也可能极端到某个人只有 2 张在训练集、9 张都在验证集。原作者的数据里混入了自己采集的样本类别分布本来就不均匀所以如果验证集准确率低于训练集很多不要先急着调网络结构先去看一下每个类别在训练集和验证集中各有多少张。随机分割对这类小数据集是很伤的后面我在第 4 章会讲一个更稳的分法。最后是正式训练。batch_size20表示每 20 张图计算一次梯度并更新参数epochs100表示把整个训练集过 100 遍。shuffleTrue让每轮训练前重新洗牌避免模型学到样本顺序带来的伪规律。batch_size 20 epochs 100 face_recognition_model.fit(train_input, train_output, epochsepochs, batch_sizebatch_size, shuffleTrue, validation_data(valid_input, valid_output))这里提醒一句训练过程可以一边跑一边看控制台输出的 loss 和 acc 曲线。正常情况是两者同时下降如果训练集 loss 降到 0.1 以内而验证集还在 2 以上这是典型的过拟合信号应该马上停掉而不是傻等 100 轮跑完。最好的训练状态是训练集和验证集准确率差距保持在 10 个百分点以内最终保存的模型才真正有实用价值。4. 避坑清单训练慢、误识别、过拟合与数据不平衡的排查手记4.1 现象训练 loss 不降准确率一直在某处徘徊有一个很典型的翻车场景模型结构、优化器、数据都跟教程一样但 loss 死活不降跑了 20 轮准确率还在低位。原因大概率出在数据预处理环节——读图函数里有一行代码是np.asarray(im, dtypenp.int8)int8 的取值范围是 -128 到 127而像素值本身是 0 到 255超过 127 的部分会被截断成负数。模型吃着被截断的输入梯度自然乱套。解决方法是把所有图像数组统一转成np.float32而且要在除以 255 之前转否则整数除法直接把你归一的步骤清零了。4.2 现象训练集准确率高但摄像头前疯狂误识别训练集准确率已经到 98% 了满怀期待地打开摄像头结果发现随便谁出现在镜头里都会被标成“自己”这种情况几乎每个人都会遇到一次。原因有两层一是训练数据里“其他人的照片”太少只有 Yale 库那 15 个人的 165 张模型根本没机会学到“像人脸但不属于任何已知类别”的样本长什么样二是类别数量太少模型只需要在 16 个类里做选择当输入一个没见过的陌生人脸时它只能在已知类里硬选一个。解决思路有两条一条是扩充负样本把网上爬的、自己拍的、各种角度各种光照的陌生人脸尽量多地塞进训练集另一条是在输出层之后加一个人脸相似度阈值即对每个预测结果算一下模型输出的置信度低于阈值就标记为“unknown”而不是硬给一个类别。后者实现起来很简单在识别代码里加一个if max_prob 0.7的判断就行。4.3 现象resize 后人脸变形模型完全认不出来有读者照着代码改了自己的照片路径训练也跑完了但识别时发现自己的脸一直标错。排查到最后才发现他直接从 Haar 检测框里截了一张 30×80 的竖长条resize(100, 100)之后整个脸被横向拉伸成了“胖脸”模型在训练时从没见过这种畸变图自然给不出正确结果。这个问题的根源是跳过了resize_without_deformation这一步或者没意识到这个函数的必要性。注意这个函数还有一个隐含前提它补的边是纯黑的会让图片整体亮度分布发生变化特别是人脸靠近边缘时补完黑边后人脸位置偏移到一侧。训练时如果所有样本都是居中正脸推理时碰到偏脸样本就可能识别失败。更稳的方案有两种一种是补边时不补黑色改成复制边缘像素cv2.BORDER_REPLICATE这样不会引入突兀的亮度跳变另一种是干脆检测完人脸后直接向四周外扩一定像素再裁剪保证裁剪出来的区域本身就接近正方形再配合补边逻辑效果会好很多。4.4 现象摄像头把人脸检测成多个框导致重复识别实时识别时detectMultiScale有时会对同一张脸返回好几个重叠的框导致打印出多行不同结果或者一次画面闪烁多个标签。原因很简单minNeighbors5对于视频帧来说会产生较多误检尤其当画面有噪声或人脸离镜头较近时。解决思路有两个层面。第一个是在检测参数上做调整把minSize从 (5, 5) 提到 (30, 30)这个改动对摄像头场景几乎是必须的——摄像头画面里小于 30×30 的“脸”大概率是噪点而不是真人脸。第二个是在代码逻辑上做去重对检测到的人脸框集合加一个非极大值抑制NMS保留置信度最高的框删除与其 IoU 超过 0.5 的其他框。OpenCV 没有直接提供 NMS 封装但朴素实现只需要十几行代码值得花时间写进自己的工具库。4.5 现象GPU 装了用不上训练速度跟 CPU 差不多环境配置是最容易让人想砸键盘的环节。pip install tensorflow-gpu只是装了带 GPU 支持的 TF 包但运行时如果 CUDA 和 cuDNN 版本对不上TF 会直接报加载错误然后默默地退回 CPU 执行训练速度肉眼可见地慢。排查方式是训练开始时看一眼控制台日志如果出现Could not create cudnn handle: CUDNN_STATUS_INTERNAL_ERROR或者device:CPU之类的内容说明 GPU 加载失败。常见解决办法是先把 TF 换成对应 CUDA 版本的组合比如 TF 1.x 搭配 CUDA 9 和 cuDNN 7如果是 TF 2.x则要确认 CUDA 10.1 以上。这些版本矩阵很让人头大我的习惯是把 CUDA 和 cuDNN 的版本号写到虚拟环境目录名里方便回溯比如conda create -n tf113-cuda10。哪怕版本搞错了也能一眼看出哪套环境是哪套配置。5. 识别部署加载 h5 模型、摄像头实时识别与结果标注5.1 模型保存与加载h5 文件的正确打开方式训练完成后模型要以 h5 格式保存到磁盘这一步没有太多玄学但加载时有一个很容易踩的坑load_model会尝试读取完整模型结构如果你在训练时用了keras.Sequential()而不是from keras.models import Sequential加载时也必须在同一个命名空间下。很多人因为训练和识别分别写在两个文件import 路径不一致加载时报Unknown loss function之类的错误。统一用from keras.models import load_model就没这个问题。import cv2 import numpy as np import keras from keras.models import load_model face_recognition_model load_model(face_model.h5)保存和加载这一对动作看起来简单但实际部署时要注意 h5 文件里包含的不只是权重还有优化器状态。如果在训练之后想继续迭代直接load_model再fit是可行的但如果你只想用模型做推理可以用save_weightsload_weights只存权重文件体积会小一些加载也更快。h5 文件大小取决于模型结构上面这个模型大约在 30MB 上下在嵌入式设备上部署时这个体积很可能会成为瓶颈届时需要量化或剪枝这是后话。5.2 识别主循环检测、裁剪、reshape、预测的完整链路识别阶段的核心逻辑是打开摄像头 → 读取一帧 → 灰度化 → 人脸检测 → 按检测框裁剪 → 无形变 resize → 转成网络需要的四维张量 → 归一化 →predict→ 打框标注。cap cv2.VideoCapture(0) ret, image cap.read() gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale(gray, scaleFactor1.2, minNeighbors5, minSize(30, 30)) for (x, y, width, height) in faces: img image[y:yheight, x:xwidth] img resize_without_deformation(img) img img.reshape((1, 100, 100, 3)) img np.asarray(img, dtypenp.float32) img / 255.0 result face_recognition_model.predict_classes(img) cv2.rectangle(image, (x, y), (x width, y height), (0, 255, 0), 2) font cv2.FONT_HERSHEY_SIMPLEX if result[0] 15: cv2.putText(image, me, (x, y-2), font, 0.7, (0, 255, 0), 2) else: cv2.putText(image, No.%d % result[0], (x, y-2), font, 0.7, (0, 255, 0), 2) cv2.imshow(Face Recognition, image) cv2.waitKey(0)这段代码里最关键的细节是img.reshape((1, 100, 100, 3))。训练时模型输入的形状是 (batch_size, 100, 100, 3)单张预测必须手动扩出 batch 维度也就是 1。很多初学者在这里报ValueError就是因为忘了 reshape把二维数组直接丢给了模型。另外注意这里裁剪用的是 BGR 原图image而不是灰度图gray因为模型输入要求三通道。predict_classes返回的是 0 到 15 的整数对应类别索引。原作者把结果 15 特意标记为“自己”这是因为在前面数据读取时自己的照片被安排到了最后一组。实际操作中这个索引号是对应关系不是写死的换成你自己的数据集时记得同步修改。5.3 结果标注putText 的坐标计算与字体大小选择cv2.putText的显示效果直接影响使用体验这里有两个参数值得讲。(x, y-2)是文字左上角坐标放在检测框上方 2 像素处如果 y 本身小于字体高度文字会被截断到画面外这时可以把坐标改成(x, y height 20)放在框下方。0.7是字体缩放比例对应大约 18 到 24 像素的字号(0, 255, 0)是 BGR 颜色绿色在多数场景下都看得清。如果想提升可读性可以在文字下方先画一个黑色矩形底条让文字在任何背景上都清晰可见这个做法在实际项目中很常用几行代码就能实现但很多人因为没有这个底条在白色墙壁前完全看不清识别结果。6. 进阶验证用五张陌生照片检验模型定位误识别的真正原因模型训练完、摄像头也能跑通之后很多人会直接拿自己试觉得“哎能识别出来”就完事了。这个验证方法其实不够——因为训练集里有你自己的照片识别出来是大概率事件真正能检验模型质量的是“没见过的脸”。我的习惯是找五张完全不在训练集里的陌生人照片分别代表不同角度、不同光照、不同表情甚至不同肤色的样本逐个送进模型看它给出什么结果。具体做法是写一段很小的验证脚本把五张测试图依次读入走同一套 Haar 检测 裁剪 无形变 resize reshape 归一化的流程然后打印每个类别对应的输出概率。注意是打印所有 16 个类的概率而不是只打印predict_classes的结果。这一步非常关键如果模型对一张陌生人照片输出的最大概率只有 0.2那说明它本质上“谁都不认识”只是被迫选了一个相对接近的类如果最大概率高达 0.9那就说明训练数据里有严重的偏差模型把某个人的特征泛化成了“所有人”。我遇到过的真实情况是模型对陌生脸的输出概率普遍在 0.6 到 0.8 之间而且集中在某几个固定类别上翻看训练数据才发现这几个类别的样本几乎都是同一个光照条件拍的模型实际上是在学“光照”而不是学“人脸”。从那以后我每次训练完模型都会强制走一遍这个“陌生样本交叉验证”流程并且会把每张测试图的预测概率加到验证结果里一起看。这个习惯帮我发现过不少训练数据里的隐蔽问题尤其是类别不平衡和角度偏差。希望这个验证方法也能帮你少走一段弯路。本文还有配套的精品资源点击获取