简介:这是一份基于TensorFlow训练人脸识别神经网络的毕业设计完整教程,面向正在学习卷积神经网络、需要完成人脸识别方向课程设计或毕业设计的开发者。压缩包共6个文件,主要由4个Python脚本、1个Markdown说明文档和1份开源许可文件构成,脚本分工明确:一个采集本人人脸图像,一个采集其他人脸图像,一个训练卷积神经网络,一个用于新图片识别判断;README还说明了Windows或Linux下Python 3.x与TensorFlow的安装运行方式。项目通过让网络学习两组不同人脸图片,能够记住目标人物的面部特征并区分他人,完整演示了从数据准备、模型训练到实际识别的全过程,并给出了清晰的人脸数据集组织思路。资源包仅14KB,已有625人浏览学习,小巧却覆盖关键环节,适合作为毕业设计的基础代码或TensorFlow入门实践。
1. 为什么是 TensorFlow + CNN:这个毕业设计到底在训练什么
先纠正一个常见的预期:这套代码不是要做出一个能识别所有人的通用人脸识别系统,它的目标非常收敛——让网络只认识“作者一个人”。训练时给它看两组照片,一组是目标人物本人的脸部图像,一组是与他无关的其他人的脸部图像,最后用 TensorFlow 把卷积神经网络(CNN)训练成一个二分类器:输入一张人脸,输出“是本人”还是“不是本人”。项目里四个脚本的分工也很直白:get_my_faces.py 负责采集本人的正样本,set_other_faces.py 负责准备其他人的负样本,train_faces.py 完成网络训练,is_my_face.py 在训练结束后做实时识别。这种“单目标识别”本质上是二分类,比通用人脸识别里的开集识别简单许多,但已经把 CNN 从数据采集、预处理、训练到推理的完整链路跑通,对需要快速上手深度学习的在校生和刚转 CV 的工程师都很合适。
2. 先解决数据:get_my_faces.py 与 set_other_faces.py 的样本生产流程
人脸识别任务里,数据集的质量直接决定模型上限,后面调参只能在小范围内修补。这一章把两个数据采集脚本拆开讲,顺带说明正负样本的组织方式和数量配比——这部分最容易被人忽略,却恰恰是训练结果“像不像样”的分水岭。
2.1 get_my_faces.py:从摄像头到训练样本的切割流程
这个脚本的标准动作是:打开前置摄像头,从视频流里抽帧,对人脸区域做检测和裁剪,统一缩放后写入 my_faces 目录。核心不是训练,是把“一段视频”变成“一批尺寸一致的干净人脸图”。常用实现如下:
import cv2 import os # 保存目录和采样参数 save_root = "./my_faces" detect_interval = 3 # 每 3 帧抽 1 帧,避免相邻帧太相似 crop_size = 64 # 统一缩放到 64x64 target_num = 3000 # 期望的正样本数量 os.makedirs(save_root, exist_ok=True) cap = cv2.VideoCapture(0) # 0 表示默认摄像头 # 用 OpenCV 内置的 Haar 级联人脸检测器 cascade = cv2.CascadeClassifier( cv2.data.haarcascades + "haarcascade_frontalface_default.xml" ) count = 0 while count < target_num: ret, frame = cap.read() if not ret: continue count += 1 if count % detect_interval != 0: continue gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = cascade.detectMultiScale( gray, scaleFactor=1.1, minNeighbors=5, minSize=(64, 64) ) for i, (x, y, w, h) in enumerate(faces): face = gray[y:y + h, x:x + w] face = cv2.resize(face, (crop_size, crop_size)) cv2.imwrite( os.path.join(save_root, f"{count}_{i}.jpg"), face, [cv2.IMWRITE_JPEG_QUALITY, 95], ) cap.release()这段代码里最需要关注的不是循环,而是detectMultiScale的三个参数。scaleFactor=1.1表示检测窗口每次缩放 10%,数值越小检测越精细但耗时越长,实战中取 1.1 或 1.2 即可;minNeighbors=5表示一个候选框至少要被邻近区域重复检测到 5 次才被保留,这个值调小会出现大量误检,调大则会漏掉侧面或者模糊的人脸;minSize在这里用来过滤掉距离摄像头过远的小尺寸误检区域。
提示:录制时不要保持一个姿势不动,左右转头、抬头低头、前后移动都来一点,得到的样本姿态多样性足够,模型对新场景的适应性才会好。
采集完成后,花十分钟把明显闭眼、低头过度、模糊的照片删掉。这一批样本是后面所有工作的基础,宁可少也不要脏。
2.2 set_other_faces.py:批量构建负样本集合
负样本决定了模型的分类边界。负样本越贴近真实场景中“不是本人”的多样性,训练出的模型就越不可能把陌生人误判成目标人物。set_other_faces.py 的通常做法是:从一个公共人脸数据集或者收集到的无关人员照片目录里,批量读取图片,用同一个 Haar 检测器做人脸定位,把裁剪缩放后的结果写入 other_faces 目录。
import cv2 import glob import os src_dir = "./lfw" # 原始图像目录,比如公开的人脸数据集 dst_dir = "./other_faces" # 负样本输出目录 crop_size = 64 os.makedirs(dst_dir, exist_ok=True) cascade = cv2.CascadeClassifier( cv2.data.haarcascades + "haarcascade_frontalface_default.xml" ) for idx, img_path in enumerate(glob.glob(os.path.join(src_dir, "*.jpg"))): img = cv2.imread(img_path) if img is None: continue gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces = cascade.detectMultiScale( gray, scaleFactor=1.1, minNeighbors=5, minSize=(48, 48) ) for x, y, w, h in faces: face = gray[y:y + h, x:x + w] face = cv2.resize(face, (crop_size, crop_size)) cv2.imwrite(os.path.join(dst_dir, f"{idx}_{x}_{y}.jpg"), face)这套逻辑本身不难,难点在于负样本的来源选择。常见的做法是从公开人脸数据集中筛一批不同年龄、肤色、光照条件的图片,而不是随意抓取网络图。原因有二:一是网络图的分辨率和人脸占比不稳定,检测器可能裁不准;二是公开数据集大多有相对统一的人脸对齐标准,省去大量人工清洗。实际项目里我也会混入少量同场景下无关人员的前置摄像头照片,让负样本和真实使用场景更接近。
2.3 数据配比:负样本数量不能比正样本少
| 目录 | 内容 | 建议数量 | 说明 |
|---|---|---|---|
| my_faces | 目标人物本人 | 2000 ~ 5000 张 | 单一人物,姿态尽量多样 |
| other_faces | 无关人员 | 5000 ~ 10000 张 | 数量建议为正样本的 2~3 倍 |
| test_faces | 验证集 | 正负各 200 张以上 | 从上面两批中预留,不参与训练 |
负样本建议多于正样本,原因是“不是本人”这个类别覆盖空间远大于“本人”。如果负样本只有正样本的一半,训练出的模型会倾向于把所有输入都判断为“本人”,因为训练阶段“本人”的先验概率被样本分布抬高了。准备数据时就把 test_faces 单独切出去,后面训练和评估才有一个公平的裁判。
3. train_faces.py:卷积网络结构、损失函数与训练参数设计
数据准备好之后,进入训练环节。这个脚本承担三件事:读取图片和标签、定义网络结构、迭代优化参数。下面按这三步展开,主要讲清楚网络为什么这么搭,以及每个超参数崩了之后看哪里。
3.1 网络结构:两层卷积加全连接
单人二分类任务不需要非常深层的网络,两三个卷积块足够。网络过深反而容易在样本量只有几千张的情况下过拟合,训练集准确率接近 100%,验证集却一塌糊涂。基准结构通常是:
import tensorflow as tf # TF 2.x 环境下兼容 v1 接口,如果你的环境是 TF 1.x 则跳过前两行 if tf.__version__.startswith("2"): tf = tf.compat.v1 tf.disable_v2_behavior() IMAGE_SIZE = 64 def cnn(x, keep_prob): # 输入形状: [batch, 64, 64],增加一个通道维度变成 [batch, 64, 64, 1] x_image = tf.reshape(x, [-1, IMAGE_SIZE, IMAGE_SIZE, 1]) # 第一个卷积块:5x5 卷积核 32 个,ReLU 激活,再接 2x2 最大池化 w1 = tf.Variable(tf.truncated_normal([5, 5, 1, 32], stddev=0.05)) b1 = tf.Variable(tf.constant(0.1, shape=[32])) h1 = tf.nn.relu(tf.nn.conv2d(x_image, w1, strides=[1, 1, 1, 1], padding="SAME") + b1) h1_pool = tf.nn.max_pool(h1, ksize=[1, 2, 2, 1], strides=[1, 2, 2, 1], padding="SAME") # 第二个卷积块:5x5 卷积核 64 个 w2 = tf.Variable(tf.truncated_normal([5, 5, 32, 64], stddev=0.05)) b2 = tf.Variable(tf.constant(0.1, shape=[64])) h2 = tf.nn.relu(tf.nn.conv2d(h1_pool, w2, strides=[1, 1, 1, 1], padding="SAME") + b2) h2_pool = tf.nn.max_pool(h2, ksize=[1, 2, 2, 1], strides=[1, 2, 2, 1], padding="SAME") # 全连接层:256 个神经元,DRopout 防过拟合 w3 = tf.Variable(tf.truncated_normal([16 * 16 * 64, 256], stddev=0.05)) b3 = tf.Variable(tf.constant(0.1, shape=[256])) h2_flat = tf.reshape(h2_pool, [-1, 16 * 16 * 64]) h3 = tf.nn.relu(tf.matmul(h2_flat, w3) + b3) h3_drop = tf.nn.dropout(h3, keep_prob) # 输出层:2 类,经过 softmax 变成概率分布 w4 = tf.Variable(tf.truncated_normal([256, 2], stddev=0.05)) b4 = tf.Variable(tf.constant(0.1, shape=[2])) logits = tf.matmul(h3_drop, w4) + b4 return tf.nn.softmax(logits)卷积层使用padding="SAME"是为了让人脸边缘的特征也有机会参与卷积,不会因为尺寸收缩丢掉鬓角和下颚信息。池化窗口取 2x2、步长 2,特征图尺寸减半,计算量降为四分之一,同时带来一个小小的平移不变性——头部轻微偏转时特征响应不至于剧烈变化。这里每个变量的stddev=0.05是经过权衡的:权重初始化标准差太大,前向传播时激活值容易饱和,反向传播的梯度会变得极小。
3.2 损失函数与训练主流程
分类任务的标配是 softmax 配合交叉熵。均方误差也可以做分类,但在配合 softmax 输出时会收敛得异常慢,因为输出层饱和区的梯度很小;交叉熵在概率预测错误时误差大、梯度也大,学习速度明显更快。
def build_train_step(): x = tf.placeholder(tf.float32, shape=[None, IMAGE_SIZE * IMAGE_SIZE]) y_ = tf.placeholder(tf.float32, shape=[None, 2]) keep_prob = tf.placeholder(tf.float32) y_pred = cnn(x, keep_prob) # 加 1e-8 防止 log(0) 导致 loss 变成 nan cross_entropy = tf.reduce_mean( -tf.reduce_sum(y_ * tf.log(y_pred + 1e-8), axis=1) ) train_step = tf.train.AdamOptimizer(1e-4).minimize(cross_entropy) return x, y_, keep_prob, y_pred, train_stepAdamOptimizer(1e-4)是这一类小型 CNN 里最稳妥的选择。Adam 自带自适应学习率,对初始学习率的敏感度比原始 SGD 低很多,初学者不用反复试学习率也能得到一个可用的收敛轨迹。注意这里没有把图片本身和标签读取到内存的细节全部写出来,实际处理时用小批量读取,每批 64 张,避免一次性把几千张图片全塞进feed_dict里撑爆内存。
3.3 超参数参考表与训练异常诊断
| 超参数 | 建议值 | 现象与排查方向 |
|---|---|---|
| 初始学习率 | 1e-4 ~ 3e-4 | loss 一直震荡不下降,调小一个数量级重试 |
| batch_size | 32 ~ 128 | 过大容易收敛到平的极小值,过小则梯度噪声大 |
| 训练轮数 | 20 ~ 50 | 看验证准确率是否还在上升,不要盲目加轮 |
| dropout keep_prob | 训练 0.5 / 测试 1.0 | 测试时忘记设为 1.0 会导致准确率暴跌 |
训练过程中一个最常见的问题是 loss 变成nan。原因通常有两个:一是tf.log(0)产生负无穷,应对方法是在交叉熵里加一个极小量;二是学习率设太大,梯度更新跨过了最优区域。另一个问题是训练集准确率接近 100% 但验证集只有 80% 上下,这说明模型把训练样本的细节背下来了,优先增加负样本数量,其次才是加大 dropout 比例。
4. is_my_face.py:模型恢复、实时推理与 softmax 阈值调优
训练脚本产出的是 checkpoint 文件,里面保存了所有变量的值。训练时模型在内存里跑,而实际识别时需要一个独立的推理脚本把模型重新加载进来。is_my_face.py 的任务就是在摄像头画面里做检测,把裁好的人脸放进网络,输出一个是否匹配的判断。
4.1 从 checkpoint 恢复模型
保存和恢复是成对出现的。训练脚本最后会调用saver.save(sess, "./model/my_face_model.ckpt"),推理脚本则要从同一个 checkpoint 恢复。注意必须保持网络定义一致,TensorFlow 按变量名逐层恢复,任何一层的名字或形状变了都会报错。
saver = tf.train.Saver() sess = tf.Session() # 从指定路径加载训练好的权重 saver.restore(sess, "./model/my_face_model.ckpt") # 这段代码的输入输出结构必须与训练时一致 def is_my_face(face_array): face = face_array.reshape(1, IMAGE_SIZE, IMAGE_SIZE, 1).astype(np.float32) face = face / 255.0 prob = sess.run(y_pred, feed_dict={x: face, keep_prob: 1.0})[0] return probfeed_dict里keep_prob必须显式传 1.0。训练时 dropout 会随机丢弃部分神经元,推理时如果仍然按 0.5 运行,每次前向传播的结果都会不同,识别就会时灵时不灵。这是新手最容易犯的错误,没有之一。
4.2 从摄像头画面到最终判定
实时推理管线要处理的不只是模型,还有前置的检测环节。实现在训练时用 64x64 作为输入,但在摄像头画面上如果拿 64x64 去检测人脸,远处的人脸根本测不到,近处的人脸又会裁掉一部分。所以推理阶段的检测尺寸应该比训练时更大:
def run_camera(): cap = cv2.VideoCapture(0) cascade = cv2.CascadeClassifier( cv2.data.haarcascades + "haarcascade_frontalface_default.xml" ) while True: ret, frame = cap.read() if not ret: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 这里用更大的人脸尺寸做检测,留出裁剪余地 faces = cascade.detectMultiScale( gray, scaleFactor=1.1, minNeighbors=5, minSize=(100, 100) ) for (x, y, w, h) in faces: face_roi = gray[y:y + h, x:x + w] face_resized = cv2.resize(face_roi, (IMAGE_SIZE, IMAGE_SIZE)) prob = is_my_face(face_resized) label = "mine" if prob[0] > 0.8 else "unknown" cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2) cv2.putText(frame, f"{label} {prob[0]:.2f}", (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imshow("face", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()提示:检测尺寸和缩放目标之间差距越大,裁剪时引入的形变越明显。如果你的摄像头分辨率较高,考虑把训练样本统一从 64x64 提升到 128x128,能明显改善检测框与人脸边缘对不齐的问题。
4.3 softmax 概率和阈值不是一回事
网络输出的是两个类的概率向量,例如[0.87, 0.13],含义是模型认为输入人脸是目标人物的概率为 0.87。直接把argmax结果作为判断依据会忽略置信度信息——样本只要不是目标人物,就强行归入负类,实际使用中很容易把侧脸、暗光下的人脸全部误判成“不认识”。所以推理逻辑里单独设一个阈值,建议起点是 0.8,之后根据使用场景调整:如果希望减少“把陌生人认成自己”的情况,阈值往上提到 0.9;如果更在意不要漏掉本人,阈值往下降到 0.7。每次调整后都重新拿 test_faces 跑一遍,观察准确率和漏检率的变化。
| 场景 | 阈值方向 | 可能代价 |
|---|---|---|
| 门禁、解锁类强安全场景 | 调高到 0.9+ | 本人误拒率上升 |
| 考勤、辅助标记类 | 调低到 0.6~0.7 | 陌生人误纳率上升 |
| 多数普通演示项目 | 0.8 作为起始值 | 再根据实测微调 |
5. 从“只认识我”到多人脸:扩展思路与验证技巧
这个项目在原始设定下只识别一个人,但很多实际场景要求“认识几个人”或者“知道这个人是谁”。把二分类网络扩展成多分类网络,改动其实不大。最直接的做法是把输出层从 2 个单位改成 N+1 个,其中 N 是想要识别的目标人数,多出的一个类别代表“未知”。对应地,每个目标人物都要准备一组自己的正样本,负样本仍然共用,训练标签变成 one-hot 的多类向量。另一种做法是保持二分类网络不变,为每个目标人物单独训练一个模型,推理时逐个跑一遍;做法简单但推理耗时线性增长,人少时无所谓,人多了就明显卡顿。
第 5 章除了扩展方向,还应该聊聊怎么确认模型真的可用。不要只盯着训练集准确率,而要看 isolated test set 上的表现——和训练数据完全隔离的人脸照片,才能真实反映模型在未知数据上的泛化能力。建议把 test_faces 里的正负样本都跑一遍,计算两个更直观的指标:误拒率(自己是本人却被判负)和误纳率(不是本人却被判正)。前者影响使用体验,后者影响安全性。还可以把测试时输出的软概率都记录下来,画一条阈值变化之下误拒率和误纳率的联动曲线,选一个自己最能接受的平衡点。
帧间投票是一个成本极低的工程技巧:连续检测 5 帧,只有其中 3 帧以上判定为“是本人”,才输出最终结果。这能过滤掉单帧的抖动、表情变化和瞬时遮挡。配合深度学习框架的最新趋势,也不妨把 TensorFlow 和 PyTorch 的当前版本做一次对比,用同一套数据分别在两个框架里跑,对比训练速度、显存占用和推理延迟——这种横向验证对理解框架差异很有帮助,尤其是你要把这个项目往边缘设备上迁移的时候。
本文还有配套的精品资源,点击获取