简介:将CNN卷积神经网络用于人脸识别的完整实践资源,面向正在入门深度学习、希望以人脸识别为案例动手实践的开发者。压缩包共4个文件,含两个Python脚本,分别对应模型训练与使用测试,另有一个pkl格式的已训练参数模型和一个gif格式的人脸数据集示例图,整体约14.64MB。资源与配套博文紧密配合,从数据预处理、网络结构搭建到训练与测试均提供可直接运行代码,加载已训练参数即可跳过训练环节直接验证识别效果,适合快速复现。扩展来看,还能帮助理解卷积层、池化层、全连接层及softmax分类在人脸识别中的协同作用。目前该资源已被17751人学习下载,是课内实验、毕业设计或项目起步阶段可参考的典型范例。
1. 这套CNN人脸识别项目能干什么:一条龙代码,离线也能跑
我之前在某公司维护本地人脸门禁系统时,最头疼的往往不是模型效果本身,而是把数据清洗、人脸检测、模型训练、指标评估、接口部署这一整条链路串起来。这份基于CNN卷积神经网络的人脸识别项目,就是把你需要的每段流程都写成了可运行代码,适合想在本地跑通人脸识别的开发者。反直觉的一点是:手头人脸数据量不大时,盲目堆网络深度远不如把预处理做扎实,这个项目用相对简洁的CNN结构,就能在中小规模人脸库上达到够用的准确率。它能直接解决“从零搭一套离线人脸识别”的落地问题,也很适合做课程设计或工程参考。
2. 项目架构与技术选型:从原始图像到识别结果的完整链路
整个项目围绕一条主线展开:原始图像 -> 人脸检测 -> 裁剪对齐 -> 统一尺寸 -> CNN推理 -> 输出类别。代码分层清晰,训练脚本和推理脚本分离,数据流是单向的,每一步的产物都落盘,方便排查问题。这种设计对刚入手CNN的人尤其友好,因为你可以随时打断流程,检查中间结果是哪里出了问题。
2.1 目录结构:哪个文件管哪件事
这个项目的目录布局如下,我拆包之后第一件事就是把每个文件的作用标在注释里。
face_recognition_cnn/ ├── config.py # 全局参数,路径、类别数、训练轮数 ├── requirements.txt # 依赖列表 ├── data/ │ ├── raw/ # 原始人脸图像,按类别分目录 │ └── processed/ # 检测裁剪后的图像 ├── src/ │ ├── face_detector.py # 人脸检测与裁剪模块 │ ├── dataset.py # 数据集加载、标签编码、数据增强 │ ├── model.py # CNN模型定义 │ ├── train.py # 训练主脚本 │ └── predict.py # 单张图片推理脚本 └── models/ └── checkpoint.pth # 训练产出的权重文件config.py 是整个项目的“总开关”,所有超参数都集中在这里,改一个文件就能控制全局。data/processed 里保存的是经过人脸检测后裁好的标准尺寸图像,这样做的好处是:训练管线不依赖检测器,后面想换检测算法,只需要重新生成 processed 目录就行。
2.2 技术栈与依赖:为什么这么选
| 模块 | 用途 | 选择理由 |
|---|---|---|
| Python | 胶水语言 | 生态全,写脚本快 |
| PyTorch | 深度学习框架 | 动态图,调试方便,适合教学 |
| OpenCV | 图像读写、人脸检测、尺寸变换 | 内置Haar级联,能快速跑通人脸检测 |
| NumPy | 数组操作 | 图像本身就是多维数组,离不开 |
| scikit-learn | 数据集划分、评估指标 | train_test_split 和混淆矩阵直接可用 |
这里没有使用过于复杂的目标检测模型,而是用OpenCV自带的Haar级联先跑通流程。对于一个人脸识别Demo来说,Haar级联在正脸、光线均匀的场景下已经够用。如果你想换MTCNN或者基于深度学习的检测器,只需要替换face_detector.py里的实现,后续CNN训练代码完全不用动。
2.3 整体流程:五个环节环环相扣
整个项目的运行逻辑可以拆成五步。第一步,读取data/raw下的原始图像,用人脸检测器找到每张图里的人脸位置,裁剪并缩放到64x64,写入data/processed。第二步,扫描processed目录,生成图像路径和对应标签的列表,用分层采样划分出训练集和验证集。第三步,定义CNN模型,用交叉熵损失和Adam优化器开始训练,每个epoch结束用验证集算准确率。第四步,保存验证集准确率最高的checkpoint。第五步,predict.py加载checkpoint,对单张图片完成检测、预处理和分类。
每一步都有独立的脚本,你在命令行里依次执行就好。下面几章会挑最核心的代码逐段拆开讲,重点不是“抄一遍”,而是理解每个参数为什么这么设。
3. 数据准备与人脸预处理:清洗、裁剪和划分的实战细节
做CNN人脸识别,数据预处理的质量直接决定训练曲线的走势。这部分是整个项目里最值得反复打磨的环节,我见过太多人把时间花在调网络结构上,结果瓶颈全在数据端。这一章说的都是可以照着抄的做法。
3.1 人脸检测与裁剪:Haar级联的用法和参数
src/face_detector.py 里核心函数长得像下面这样,它负责把一张原始图变成一张只含人脸的标准尺寸图像。
import cv2 import os detector = cv2.CascadeClassifier("haarcascade_frontalface_default.xml") def crop_face(image_path, output_path, scale_factor=1.1, min_neighbors=5): img = cv2.imread(image_path) if img is None: return False gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces = detector.detectMultiScale( gray, scaleFactor=scale_factor, # 每次图像缩放的比例 minNeighbors=min_neighbors, # 一个区域被判定为人脸需要满足的邻居数 minSize=(64, 64) # 小于此尺寸的人脸窗口直接忽略 ) if len(faces) == 0: return False x, y, w, h = faces[0] face = img[y:y+h, x:x+w] face_resized = cv2.resize(face, (64, 64)) cv2.imwrite(output_path, face_resized) return True这里有几个关键参数要理解。scaleFactor 控制检测时图像金字塔每次缩小的比例,取1.1意味着每层缩小10%,数值越接近1.0检测越慢但更准。minNeighbors 是“去误检”的关键,值越大,能保留下来的框越少,漏检率变高;值太小则会出现大量错误框。minSize 设成64x64,是因为模型输入就是64x64,再小的人脸即使检出来,放大后细节也全丢了。检测到多张人脸时只取第一张,这是为了语义简单,如果你要处理多人场景,需要改成遍历并保留面积最大的框。
3.2 数据集划分:分层抽样的重要性
图像处理好之后,接下来要把所有图片路径和标签整理成训练集和验证集。项目里用的是scikit-learn的train_test_split,关键是stratify参数。
from sklearn.model_selection import train_test_split image_paths, labels = collect_samples("data/processed") train_paths, val_paths, train_labels, val_labels = train_test_split( image_paths, labels, test_size=0.2, # 验证集占20% stratify=labels, # 按标签比例分层抽样 random_state=42 # 固定随机种子,保证结果可复现 )如果你的数据集中有10个人,每个人有50张图,stratify会确保训练集和验证集里每个人大致都有40张和10张,不会出现某个类别只在训练集或只在验证集的情况。random_state固定成42,这样每次跑代码划分结果都一样,否则你昨天训练和今天训练用的数据集不同,改了半天模型也不知道问题出在哪。数据量小的时候,这一步能省掉大量“玄学调参”。
3.3 数据集加载与增强:自定义Dataset的写法
在PyTorch里,数据加载需要继承Dataset并实现三个方法。项目里定义了一个FaceDataset类。
import torch from torch.utils.data import Dataset import cv2 class FaceDataset(Dataset): def __init__(self, paths, labels, transform=None): self.paths = paths self.labels = labels self.transform = transform def __len__(self): return len(self.paths) def __getitem__(self, idx): img = cv2.imread(self.paths[idx]) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (64, 64)) if self.transform: img = self.transform(img) label = self.labels[idx] return img, labelgetitem返回的img会是一个已经被transform处理过的Tensor,label则是对应的类别整数索引。这里要注意OpenCV读出来的是BGR,而模型训练前我们通常期望RGB,所以用cvtColor转换一下。如果不想做转换,也可以保持BGR,只要数据加载和预测时保持一致就行,但混用的话颜色通道会错位,模型根本学不到有效特征。
数据增强方面,我在训练集上只用了随机水平翻转和轻微的亮度抖动,验证集不做增强。理由是:人脸识别场景中,左右对称是天然的,水平翻转能让模型更鲁棒;而亮度抖动可以在光照变化时增加泛化性。但增强强度一定要克制,否则训练集和真实分布偏离太大,会出现“训练时loss降得很美,验证集准确率纹丝不动”的翻车现场。
4. 核心模块拆解:CNN模型的搭建与训练循环
这一章是项目的“发动机”部分。我会把模型定义、损失函数、训练循环、评估保存四块代码都拆开讲,你看完就能明白一个训练脚本是怎么从零跑起来的。
4.1 网络结构:三层卷积加两层全连接
项目里的CNN结构并不复杂,输入是3通道64x64图像,经过三个卷积块,最后接全连接层。
import torch.nn as nn class FaceCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 8 * 8, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))这段定义有几个必须算清楚的地方。输入64x64,第一层卷积用padding=1保持宽高不变,所以经过MaxPool2d(2)后变成32x32。第二层同理,变成16x16。第三层之后变成8x8。通道数从3变成32、再到64、128。所以在进入全连接层时,特征图的通道数是128,空间尺寸是8x8,因此Flatten之后是12888个数值。这个数字一旦算错,Linear层的输入维度就会对不上,PyTorch会直接报维度错误。
Dropout放在全连接层中间,比例是0.5,目的是防止小数据集上的过拟合。如果数据量比较大,可以把这个比例降到0.3;数据量小时,0.5是合理的起步值。
4.2 损失函数与优化器:交叉熵和Adam搭配
训练脚本中损失函数和优化器的选择如下。
import torch.optim as optim criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=5e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.1)CrossEntropyLoss 在PyTorch里已经包含了softmax操作,所以在模型最后一层直接输出原始logits即可。Adam优化器默认的lr是1e-3,这是大多数CNN任务起步的合理值。weight_decay相当于L2正则,给到5e-4可以缓解过拟合,数值太大则会欠拟合。StepLR每20个epoch把学习率乘0.1,这样前期能用较大学习率快速收敛,后期用小学习率微调。如果你的训练总轮数只有20轮,step_size改成10更合适,否则还没触发降学习率训练就已经结束了。
4.3 训练循环:五步套路
训练一个epoch的核心代码十分固定,套路就是五步:取数据、算输出、算loss、梯度清零、反向传播加更新参数。
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0.0 for images, labels in loader: images = images.to(device) labels = labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) return total_loss / len(loader.dataset)需要留意的是 optimizer.zero_grad() 必须放在 backward 之前,否则梯度会跨batch累加,导致参数更新方向完全乱掉。这是一个常见的“坑”,新手容易漏掉。loss.item()取出的是一个Python数值,乘上batch大小是为了按样本数加权,最后除以总样本数得到平均loss。
训练过程中我还会把每个epoch的验证集准确率打印出来,同时记录历史最好的准确率。只有当验证集准确率比之前最好值更高时,才保存当前模型参数。这样能保证最后产出的checkpoint是训练过程中的最优状态,而不是最后一个epoch后可能已经过拟合的参数。
4.4 评估与保存:准确率计算和checkpoint结构
评估函数要切换到eval模式,并且不需要计算梯度。
def evaluate(model, loader, device): model.eval() correct = 0 with torch.no_grad(): for images, labels in loader: images = images.to(device) labels = labels.to(device) outputs = model(images) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() return correct / len(loader.dataset)model.eval() 会关闭Dropout和BatchNorm的统计更新,这一步极其重要。很多人加载模型做预测时,结果总是“每次都不一样”,十有八九就是忘了eval。argmax(dim=1) 对每个样本的输出向量取最大值的索引,这就是预测的类别。最后的返回结果是验证集准确率,范围在0到1之间。
保存checkpoint时,我喜欢把更多信息放在字典里,方便回溯。
torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'best_acc': best_acc, }, "models/checkpoint.pth")训练被打断后,可以从上次的位置接着训练,这也是工程上必须考虑的事情。加载时分别恢复model和optimizer的状态,再根据epoch参数继续跑。如果只做推理,只需要model_state_dict就行了。
5. 避坑指南:人脸识别训练中常见的5个翻车现场
这部分是血泪经验,每个都是我在实际跑项目时踩过的坑。按“现象 -> 原因 -> 解决”的顺序写,方便你快速对照。
5.1 现象:Loss在下降,但验证集准确率一直不动
有一段时间我训练这个项目,训练Loss漂亮地降到0.3,验证集准确率却始终停在50%左右。后来发现是类别不平衡。数据集中一个人有200张图,另一个人只有20张,模型学到的大概率就是“一直预测第一类”,Loss虽然被多数类压低了,但少数类的预测能力几乎为零。
解决方法是先用sklearn的stratify分层采样,保证每个类别在训练集验证集里按比例分布;如果极端不平衡,还要考虑按类别加权采样。第二个原因是数据增强太猛,比如亮度抖动幅度过大,把图像变得连人都认不出,模型在训练集上看到的都是扭曲样本,学不到可泛化的特征。我会把增强强度调低一个量级再对比验证集表现。
5.2 现象:训练集准确率99%,验证集只有60%
这是典型过拟合。CNN参数量大,而人脸数据量往往只有几千张,模型有能力把训练样本“背下来”。我第一版训练时没有用Dropout,weight_decay也设为0,结果就是训练集接近满分,验证集惨不忍睹。
解决方法是加正则化:在model.py里保留Dropout层,训练时设weight_decay为5e-4,并使用早停——当验证集准确率连续多个epoch不再提升时,停止训练,不要迷信“训练轮数越多越好”。另外还有一种隐藏的“数据泄露”:你在保存processed图像时,如果没有确保训练和验证图像来自不同的人,而是同一个人的多张照片被分到两边,那模型实际是靠着记住人脸的相似度“作弊”,验证结果会虚高。应对方法是以“人”为单位划分数据集,保证训练集和验证集中没有同一个人的样本。
5.3 现象:人脸检测框不准,训练图像里有大量背景
Haar级联的检测结果并不总是完美的,尤其当照片里有侧脸、低头或者复杂背景时,检测框可能会偏大或偏小。偏大的框会把头发、衣领甚至背景都带进来,偏小的框会把下巴切掉。这样Negative Sample(背景)混进训练集,模型很容易学会“看到纹理就认为是人脸”,而不是真正识别人的身份。
解决方法是先跑一遍crop_face,然后把裁剪结果按目录抽样,肉眼检查一批图片,确认框是否贴住面部。如果整体偏移严重,可以做两件事:一是把scaleFactor调到1.05,检测精度会更好但速度变慢;二是调整minNeighbors,从5降到3,允许更多候选框通过,再结合保留最大框逻辑减少误检。如果追求更高精度,可以直接把face_detector换成基于深度学习的检测器。
5.4 现象:显存不足,训练直接OOM
GPU显存只有4GB的情况下,我之前把batch_size设为64,输入64x64的三通道图,三层卷积展开后显存直接爆掉。OOM的原因不只是图像大小,还有中间特征图占用。
解决方法是先把batch_size减半,从32开始,慢慢往上加。如果还不行,把输入尺寸从64x64降到48x48,模型前向计算量几乎减半,准确率的损失相对不明显。PyTorch还有一个临时补救手段,就是开启梯度累积:不每次更新参数,而是每跑两个batch再做一次step,效果相当于降低了单步显存消耗,代价是训练速度略微下降。
5.5 现象:加载checkpoint后预测结果每次都不一样
这个坑我印象最深。模型训练完,保存checkpoint,再写predict.py做推理,发现同一个人的同一张照片,连续跑两次会得到不同预测类别。排查了很久,最后发现原因有两点:一是推理脚本没有调用model.eval(),模型停留在train模式,Dropout还在随机丢弃神经元,所以每次预测都有随机性;二是开头的随机种子没有固定,而部分库在GPU运算时默认使用随机性。
解决方法是:推理前显式调用model.eval(),在脚本入口设置torch.manual_seed(0),同时对测试图片固定预处理变换。如果你用了ONNX导出,推理时没有Dropout问题,但输入数据归一化必须和训练时保持一致,比如是否除以255、通道顺序是RGB还是BGR,任何一处不同都会导致结果漂移。
6. 二次开发:把模型改造成自己的小型人脸识别服务
这套代码并不是“跑通就完事”的玩具,稍作改动就能变成一个可用的服务。最后这一章,我讲一下我最常做的三种改造,每一步都是可执行的。
6.1 替换数据集与类别数
要换成自己的场景,比如公司内部员工识别,只需要在data/raw下按“每个人一个文件夹”的方式放好图片,然后修改config.py里的num_classes为实际人数。重跑三个命令:先运行人脸检测生成processed图像,再划分数据集,最后启动训练。整个过程不需要动模型结构,只要类别数变了,classifier最后一层的Linear输出维度会根据num_classes自动调整。
6.2 导出为ONNX并封装Web接口
训练完成后,把PyTorch模型导出成ONNX,方便部署到不安装PyTorch的环境里。
dummy = torch.randn(1, 3, 64, 64) torch.onnx.export( model, dummy, "face_cnn.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}} )导出后可以用Flask快速封装一个接口。这里提供一个简单版本,输入的是图片文件,返回的是预测类别。
from flask import Flask, request, jsonify from PIL import Image import torch from torchvision import transforms app = Flask(__name__) model = load_model() @app.route("/predict", methods=["POST"]) def predict(): file = request.files["image"] img = Image.open(file.stream).convert("RGB") img = img.resize((64, 64)) tensor = transforms.ToTensor()(img).unsqueeze(0) with torch.no_grad(): output = model(tensor) label = int(output.argmax(dim=1).item()) return jsonify({"label": label})如果你用ONNX Runtime部署,就把model换成onnxruntime的InferenceSession,输入和输出同样需要做归一化。Flask接口做好后,用curl验证一下:
curl -X POST -F "image=@test.jpg" http://127.0.0.1:5000/predict返回的JSON里就是预测的类别编号。
6.3 验证方法和部署习惯
二次开发最怕的不是写代码,而是改完之后不知道怎么确认它没问题。我给自己定了一个固定的验证顺序:先用一张训练集中“见过”的人脸图,确认输出类别正确;再用一张新人的图片,确认模型不会强行把陌生人归类到已知类别,通常做法是加一个置信度阈值,小于这个阈值就返回“unknown”。最后用一张完全不是人脸的图片,确认接口不会崩溃。
我做这个项目时就被“推理前忘了eval”坑过一次,从那以后,每次改完代码都会强制走一遍完整流程:训练 -> 评估 -> 导出 -> 接口调用,四步缺一不可。这套流程虽然机械,但在一个人脸识别项目里,它能挡住绝大多数低级错误。希望帮到你。
本文还有配套的精品资源,点击获取