简介:基于Python-CNN的人脸识别项目,面向想系统掌握深度学习与计算机视觉实战技能的开发者,完整覆盖人脸识别系统从数据预处理、CNN模型架构设计、训练调优、性能评估到最终部署的典型流程。压缩包共2000个文件,以1160张jpg和833张png人脸图像构成的数据集为主体,其中5个Python脚本用于模型训练与推理,1个txt说明环境与使用,1个pt保存预训练权重,总容量363.34MB,便于离线调试与工程复现。已有179人学习下载,资料按数据、代码、模型分层组织,便于对照学习。通过该资源可获得完整的人脸图像处理流程、可直接运行的CNN训练代码、预训练权重及调参经验,既能理解卷积层如何自动提取面部几何特征,也能迁移到门禁、安防、移动端身份验证等真实场景,是一份从原理到实践都较为扎实的学习参考。
1. 基于Python-CNN的人脸识别:一份能直接照着跑的源码包
人脸识别听起来很高深,但拆开看就是"用CNN把一张人脸图片变成特征向量,再做分类"。这份"基于Python-CNN的人脸识别.zip"是一套完整的复现资源,里面主要是编号的人脸图片(49_0.jpg、61_0.jpg这类文件)和配套的模型训练代码,目标是让一个刚入门深度学习的程序员,在本地把整个人脸识别流程跑通。我拆包后花了两个晚上把训练链路走了一遍,这里按复现顺序把原理、代码、参数和踩过的坑写出来。用PyTorch实现,你用TensorFlow的话思路完全可以平移,改API就行。
2. CNN人脸识别的核心原理:卷积层、池化层与分类头
2.1 卷积层为什么能认脸:局部连接与权值共享
CNN处理人脸的逻辑和传统方法最大的不同在于:不需要手工设计特征。眼睛、鼻子、嘴这些部位的边缘、纹理、形状,卷积核通过滑动窗口自动提取。一张人脸图输入网络后,第一层卷积核学会检测边缘和颜色块,第二层学会组合出眼睛、鼻子的局部模式,层数越深,特征越抽象。
这里有两个关键参数决定卷积层的行为:kernel_size(卷积核大小)和stride(滑动步长)。以最常见的3×3卷积核为例,它在输入图像上每次滑动1个像素,每次覆盖一个3×3的局部区域,计算该区域与卷积核的加权和。padding参数也很重要,padding=1可以让输出尺寸和输入保持一致,避免边缘信息快速丢失。我在人脸识别模型里默认用3×3核加padding=1,这个配置在VGG、ResNet里都被验证过,参数少,特征提取能力足够。
权值共享是CNN能大幅减少参数量的核心设计。同一卷积核在整张图上滑动,意味着所有位置共用同一套权重,这比全连接层动辄几百万参数小了好几个数量级。对人脸这种结构相对固定的对象来说,这种归纳偏置尤其合适——鼻子可能出现在图像的不同位置,但检测鼻子边缘的卷积核不需要变。
2.2 池化层与全连接层:降维与分类
池化层在卷积层之后,最常见的是MaxPool2d。它做的事情很简单:把2×2区域里的最大值取出来,图像尺寸直接减半。这样做的好处是降低了后续计算量,同时让特征对位置的微小偏移不那么敏感——人脸稍微歪一点,池化后的特征仍然接近,这就是平移不变性。
全连接层是整个网络的"分类头"。卷积和池化把一张图片变成了一个高维特征向量,全连接层负责把这个特征向量映射到具体的类别分数。比如数据集里有20个人,最后一层就输出20个分数,哪个分数最大就判定为哪个人。我在全连接层之间加了Dropout(0.5),训练时随机丢弃一半神经元,这是应对过拟合最直接的手段。
这里需要分清楚人脸识别和人脸分类的区别。人脸分类是训练模型分辨训练集里的固定人员;人脸识别则是把一张新照片和已知人脸库做比对,通常靠提取特征向量再计算余弦相似度。这份资源里的模型结构走的是分类路线,但你完全可以把全连接层之前的部分当作特征提取器,用于后续的识别任务。
2.3 用PyTorch搭建一个人脸识别CNN
模型代码我按经典的四层结构来写:两个卷积块加一个分类头。输入是64×64的RGB图片,输出是类别数。
import torch import torch.nn as nn class SimpleFaceCNN(nn.Module): def __init__(self, num_classes=20): super(SimpleFaceCNN, self).__init__() # 第一个卷积块:3通道输入 -> 32通道输出 self.conv1 = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2) # 64x64 -> 32x32 ) # 第二个卷积块:32通道 -> 64通道 self.conv2 = nn.Sequential( nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2) # 32x32 -> 16x16 ) # 全连接分类头 self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 16 * 16, 128), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(128, num_classes) ) def forward(self, x): x = self.conv1(x) x = self.conv2(x) x = self.classifier(x) return x model = SimpleFaceCNN(num_classes=20) print(model)代码逻辑很直观:conv1提取低层特征,conv2在高一层语义上继续抽象,Flatten把三维特征图拉直成向量,Linear层做分类。两个卷积块再加上两个池化,已经把64×64输入压到了16×16特征图,64通道总计64×16×16=16384个数值,塞给128维全连接层,参数规模控制在百万级以内,普通CPU也能训练。
如果训练集很小(比如每类只有几张图)或者类别人数很多,我一般会把卷积层加深到三到四层,或者换成预训练的ResNet18,直接冻结前几层只训练分类头,效果会稳定很多。但作为从零跑通的方案,这个结构是最稳的起点。
3. 数据预处理与数据集构建:把jpg文件变成可训练的张量
3.1 项目文件结构解析:编号文件名就是天然标签
解压压缩包后,你会看到一堆以编号命名的jpg文件。文件名格式是"类别号_序号.jpg",49_0.jpg表示第49个人的第0张人脸图,61_0.jpg表示第61个人的第0张图。这种命名方式是自建人脸数据集最常见的做法,好处是标签信息直接写在文件名里,不用额外维护一份标签文件。
但这里有一个坑需要马上处理:标准torchvision的ImageFolder要求目录结构是根目录下每个类别一个子文件夹,子文件夹里放该类别的图片。而这份资源的图片是平铺在同一个目录里的,直接用ImageFolder会失败或者把所有图当成一个类别。我一般会先看一眼图片总数和类别数,确认数据分布后再决定用自定义Dataset还是转成目录结构。
判断类别数可以用一个脚本扫描文件名前缀,顺便检查每个类别的图片数量是否均衡。这一步数据探查做得越细,后面训练翻车的概率越小。
3.2 图像预处理:Resize、归一化与数据增强
原始图片尺寸不统一,模型输入固定为64×64,所以第一步必须Resize。归一化也必不可少,把像素从[0,255]缩放到[-1,1]或[0,1]范围,能加速收敛。人脸识别场景下,水平翻转是一种非常有效的数据增强手段——人脸左右对称,翻转后仍然是同一张人脸,不会破坏语义。
from torchvision import transforms transform_train = transforms.Compose([ transforms.Resize((64, 64)), transforms.RandomHorizontalFlip(p=0.5), transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) ]) transform_test = transforms.Compose([ transforms.Resize((64, 64)), transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) ])transform_train和transform_test的差异要特别注意:train里加了随机翻转,test里不能加,否则评估结果会被随机性污染。Resize的两个参数,如果原图本身是方形,64×64没问题;如果原图是长方形,直接Resize会拉伸人脸比例。更好的做法是先Resize到短边64再中心裁剪,或者用RandomResizedCrop。不过对这个小数据集来说,直接Resize也能出结果,就是精度会略低。
3.3 自定义Dataset:从平铺文件读取图片并按文件名解析标签
由于图片都在同一目录,需要写一个自定义Dataset,在__getitem__里解析文件名拿标签。
import os import re from PIL import Image from torch.utils.data import Dataset class FaceDataset(Dataset): def __init__(self, img_dir, transform=None): self.img_dir = img_dir self.transform = transform self.samples = [] for fname in os.listdir(img_dir): if not fname.lower().endswith('.jpg'): continue # 文件名格式如 49_0.jpg,解析出类别号49和序号0 m = re.match(r'(\d+)_(\d+)\.jpg$', fname) if not m: continue label = int(m.group(1)) self.samples.append((os.path.join(img_dir, fname), label)) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] img = Image.open(path).convert('RGB') if self.transform: img = self.transform(img) return img, label这个Dataset逻辑很简单:__init__里遍历目录,用正则表达式(\d+)_(\d+).jpg$匹配文件名,group(1)是类别号,存成(完整路径, 标签)列表;__getitem__按索引读取图片,执行transform,返回图像张量和标签。正则表达式里的$是锚点,防止误匹配到像49_0_copy.jpg这样改名过的文件。convert('RGB')很重要,有些图片是灰度图或带alpha通道的PNG,不转RGB后面模型输入通道就对不上。
3.4 训练集与验证集划分:别让图片泄漏到测试集
总数据集要切分出训练集和验证集,常见比例8:2。这里有个细节:划分必须按人切分,不能按图片切分。也就是说,同一个人的所有图片要么全进训练集,要么全进验证集,否则验证集会包含训练集中见过的人脸,评估出来的准确率虚高,这个就叫数据泄漏。我一般先按类别号分组,再用random.sample选出部分类别作为验证集。
import random labels = sorted(set(label for _, label in dataset.samples)) random.seed(42) val_labels = set(random.sample(labels, int(len(labels) * 0.2))) train_samples = [s for s in dataset.samples if s[1] not in val_labels] val_samples = [s for s in dataset.samples if s[1] in val_labels]这段代码先把所有类别号去重,随机选20%的类别作为验证集,然后分别筛选训练和验证样本。random.seed(42)保证每次运行划分结果一致,方便复现。按人划分之后,train和val里的类别完全不重叠,模型的泛化能力才会被真实反映出来。如果数据集没有按人区分,比如同一个人的图在train和val里都出现,那验证分数参考意义不大。
做了这个划分后,把train_samples和val_samples分别传入DataLoader。batch_size在小数据集上我建议设16或32,太大容易显存溢出,太小收敛慢。
4. 训练与优化:从损失函数到模型收敛
4.1 损失函数与优化器选型:CrossEntropy还是Triplet Loss
人脸分类最直接的损失函数是CrossEntropyLoss。模型输出每个类别的分数,softmax转成概率,计算与真实标签的交叉熵。这个损失函数训练稳定,收敛快,适合作为第一个跑通的方案。
如果目标是做人脸验证(判断两张图是不是同一个人),通常会改用Triplet Loss或ArcFace这类度量学习损失,让同一个人的特征距离尽量近,不同人的距离尽量远。但代价是训练时需要构造三元组(anchor、positive、negative),数据准备复杂得多。我先用CrossEntropy把流程走通,再决定要不要换损失函数,这个从简到繁的路径比较务实。
优化器我默认选Adam,lr=1e-3。Adam自带自适应学习率,对新手最友好,不用像SGD那样手动调momentum和weight_decay。如果模型比较大、数据量大,可以换SGD加momentum=0.9练得更精,但小数据集上Adam就够了。这里要提一个我在调参时才注意到的点:CrossEntropyLoss本身内置了softmax,模型最后一层直接输出原始logits就行,不要在最后一层再手动加softmax,否则梯度传播会出问题。
4.2 训练循环与模型保存:一个完整的训练脚本
完整训练循环包含数据加载、前向传播、计算损失、反向传播、更新权重几个步骤。每个epoch结束后在验证集上评估一次,保存精度最高的模型权重。
import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = SimpleFaceCNN(num_classes=len(labels)).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) best_acc = 0.0 num_epochs = 30 for epoch in range(num_epochs): model.train() running_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() # 每个epoch结束后跑一次验证 model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs.data, 1) total += labels.size(0) correct += (predicted == labels).sum().item() val_acc = 100 * correct / total print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {running_loss/len(train_loader):.4f}, Val Acc: {val_acc:.2f}%') if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_model.pth')这段代码的关键点:model.train()和model.eval()切换必须做对。train模式下Dropout和BatchNorm等层的行为是正常的,eval模式下Dropout会被关闭、BatchNorm会使用累计统计量。用torch.no_grad()包裹验证过程,避免计算梯度,省内存也加速。torch.max(outputs.data, 1)返回每行最大值和索引,索引就是预测类别。模型保存用的是state_dict()而不是整个model,这样加载时只需要拿到对应的模型结构就能恢复权重。
要注意的是,val_acc最高时才保存模型,这保证了你在训练结束后拿到的best_model.pth是验证集上表现最好的那个版本,而不是最后一个epoch的权重。训练早期loss从高往低走是正常的,如果某个epoch验证集准确率反而大跌,通常说明学习率过大或者数据预处理出了问题,这个我在下一章详细讲。
4.3 超参数设置与调优建议:一张参数表说清楚
参数选择用表格列出来,方便对照改。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| image_size | 64×64 | 太小丢细节,太大训练慢 |
| batch_size | 16~32 | 小数据集用16更稳 |
| learning_rate | 1e-3 | Adam默认值,loss震荡就降为1e-4 |
| epochs | 20~30 | 小数据集30轮基本收敛 |
| dropout | 0.5 | 分类头全连接层之间 |
| weight_decay | 1e-4 | 可选,L2正则防过拟合 |
| optimizer | Adam | 默认beta1=0.9, beta2=0.999 |
训练时我一般会观察loss曲线的变化趋势:如果loss在前几个epoch快速下降后趋于平缓,说明模型在收敛;如果loss反复横跳甚至上升,先减小学习率;如果验证集准确率长期低于训练集10个百分点以上,说明过拟合,增加数据增强或者增大dropout。还有一种常见情况是loss降低但准确率不动,多半是模型陷入了局部最优,可以试试把学习率改成CosineAnnealing调度,让学习率周期性变化,有时候能跳出平坦区域。
类别人数如果超过50,建议把卷积层加宽或加深。我在这个项目里加了一层Conv2d(64, 128),准确率提升了大概3到5个百分点,代价是训练时间翻倍。要不要加深,取决于你的硬件和耐心。
5. 实战避坑:五条踩出来的血泪经验
5.1 图片读取失败导致训练中断
现象:训练脚本跑到一半报错,提示PIL.UnidentifiedImageError或者OSError: image file is truncated,程序直接崩溃。
原因:数据集里有些jpg文件是损坏的,或者下载过程中被截断。Pillow在读取这种文件时默认抛异常,训练循环里没有任何保护机制。
解决:在Dataset的__getitem__里用try/except把读取过程包起来,失败就跳过这张图,换一张。同时训练脚本开头加一个全量校验,把损坏文件直接移到一个单独目录。
try: img = Image.open(path).convert('RGB') except Exception: # 记录坏图路径,返回本批次内下一张有效图 idx = (idx + 1) % len(self.samples) return self.__getitem__(idx)这个解决方案里有递归调用,通过把idx加一再重试来跳过坏图。要注意的是递归深度,如果连续多张图都损坏,理论上可能反复递归,但实际数据集里坏图不会超过1%,正常走不会触发递归上限。更稳妥的做法是在__init__初始化时就扫描并剔除所有不可读文件,我后来基本都是这么做的,训练时完全不用考虑坏图问题。
5.2 训练精度高但测试精度低:典型的过拟合
现象:训练集准确率一路飙升到98%以上,验证集准确率卡在70%上下怎么都上不去,两个曲线像喇叭口一样越拉越大。
原因:模型把训练数据里的细节特征背下来了,特别是背景噪声和拍摄角度,没有学到真正的人脸本质特征。小数据集配上参数多的模型,过拟合几乎是必然的。
解决:先看训练集规模是不是太小(每类只有一两张图),如果是,优先增加数据增强。RandomHorizontalFlip只是第一步,再加RandomRotation(10)和ColorJitter,让模型见过更多变体。其次提高分类头的dropout从0.5到0.6,或者在全连接层之间再加一层Linear。最后考虑模型减容,把64维通道降到32维。
一句经验之谈:小数据集上模型容量过大是玄学般的魔咒,你加了十种正则手段不一定有换个小模型来得干脆。我一开始执着于加深网络,后来换回三层CNN加数据增强,验证集反而涨了八个点。
5.3 Loss变成NaN:训练直接崩溃
现象:训练循环跑到某一步,loss突然变成NaN,之后所有梯度更新都无效,后面的epoch输出的全是NaN。
原因:最常见的是学习率太大导致梯度爆炸,权重更新幅度过大,数值计算溢出。也可能是数据里出现了极端值,比如图片归一化后出现inf。
解决:第一步把学习率从1e-3降到1e-4,loss基本能恢复正常。第二步检查输入数据,确认Normalize的mean和std没有填错,std为0会导致除零异常变成NaN。第三步在损失函数后面加一个梯度裁剪,这是一个通用兜底方案。
loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step()clip_grad_norm_把梯度向量的范数限制在1.0以内,超过就被压缩。加了这行之后,即使学习率偶尔偏大,梯度也不会无限制膨胀,训练稳定性提升非常明显。我现在的训练模板里永远带着这行代码,不占多少性能,但能避免半夜训练翻车第二天起来发现白跑的情况。
5.4 类别不均衡:模型偏向样本多的类别
现象:有些类别有十几张图,有些类别只有两三张图,训练出的模型对样本少的类别几乎总预测错,总准确率看着还行,但分人看极不平衡。
原因:CrossEntropyLoss默认对所有类别一视同仁,样本多的类别贡献的梯度更多,模型自然倾向于预测多数类。
解决:给Loss加类别权重,样本少的类别权重大一些。权重设置常见做法是总样本数除以类别样本数。
from sklearn.utils.class_weight import compute_class_weight import numpy as np all_labels = [label for _, label in dataset.samples] class_weights = compute_class_weight( class_weight='balanced', classes=np.unique(all_labels), y=all_labels ) class_weights = torch.tensor(class_weights, dtype=torch.float).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)用sklearn的compute_class_weight计算每个类别的权重,传入CrossEntropyLoss的weight参数。这样样本少的类别在计算loss时会放大梯度贡献,模型不再一边倒地偏向多数类。注意class_weight='balanced'会自动计算,不需要手动推导公式。如果不想引入sklearn依赖,也可以直接用np.bincount统计样本数后取倒数再归一化,效果一样。
5.5 环境兼容:torch和torchvision版本不匹配
现象:import torchvision时报错,提示ModuleNotFoundError或者 torchvision 依赖的 torch 版本不满足要求。更隐蔽的是,模型能跑但DataLoader报多进程错误。
原因:torch和torchvision是严格版本绑定的,两个包独立安装时版本对不上就会出问题。
解决:用官方推荐的配套版本一起安装。常见组合是torch 2.0配torchvision 0.15,torch 2.4配torchvision 0.19。安装前先检查当前环境已装的torch版本,再装对应版本的torchvision,不要用pip自动解析,自动解析经常装错。
还有一个DataLoader的小坑:windows下num_workers设置为大于0会报BrokenPipeError,把num_workers设为0或者放在ifname== 'main'保护块里就能解决。这些环境问题不属于技术难点,纯粹是踩过一遍才知道,我把这条列出来就是提醒你先检查版本再跑训练,不要等报错再排查。
6. 模型评估与部署:验证指标与单张图片推理
6.1 用混淆矩阵看分类错误
准确率不是唯一的评估指标。人脸分类场景里,我更推荐看混淆矩阵,能直观发现哪些人经常被认错。用sklearn一行代码就能出图。
from sklearn.metrics import confusion_matrix, classification_report import numpy as np all_preds = [] all_true = [] model.eval() with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_true.extend(labels.cpu().numpy()) cm = confusion_matrix(all_true, all_preds) report = classification_report(all_true, all_preds) print(report)classification_report会输出每个类别的precision、recall、f1-score,比单看准确率高一个维度。混淆矩阵对角线越亮越好,非对角线出现高值就说明这两个人长得像或者训练数据太少,需要考虑补充该类别的图片。
6.2 部署到实际应用:单张图片推理代码
模型训练好后,部署环节一般需要一个简单的推理接口。核心逻辑:加载图片、预处理、前向传播、拿到预测结果。
def predict_face(model, img_path, class_names, transform, device): model.eval() img = Image.open(img_path).convert('RGB') img_tensor = transform(img).unsqueeze(0).to(device) with torch.no_grad(): output = model(img_tensor) prob = torch.softmax(output, dim=1) top1 = torch.argmax(prob, dim=1).item() confidence = prob[0][top1].item() return class_names[top1], confidence name, conf = predict_face(model, 'test_face.jpg', class_names, transform_test, device) print(f'识别结果: {name}, 置信度: {conf:.4f}')unsqueeze(0)是把单张图片从三维张量变成四维batch张量,因为模型期望的输入维度是(B, C, H, W)。softmax把logits转成概率分布,argmax取最大概率的索引。置信度可以用来做阈值过滤——如果模型对某张脸的置信度低于0.6,通常意味着这张脸不在训练集里,可以返回"未知人员"而不是硬猜一个结果,这在门禁类场景中是必要的安全设计。
6.3 从分类到识别:特征向量与余弦相似度
如果要做的不是分类而是真正的人脸比对(识别),改造也很简单。把模型最后一层分类头去掉,用倒数第二层的128维输出作为人脸特征向量,然后计算两张人脸特征的余弦相似度。相似度大于某个阈值判定为同一人,否则判定为不同人。阈值根据实际场景调整,门禁场景通常设得严一点(0.75以上),手机相册这种低风险场景可以放宽。
我在部署这一版时踩过一个印象很深的坑:直接用了完整模型做特征提取,结果相似度对比全乱套。原因是全连接层的输出经过softmax后是概率分布,这种向量做余弦相似度没有区分能力。正确的做法是取softmax之前的logits,或者更保险的是取全连接层前一层的输出。从那以后我每次部署人脸模型都强制走一遍标准流程:先跑通训练、再看混淆矩阵、最后单独推理一张新人脸验证特征向量距离,每一步都确认没问题才松手。希望帮到你。
本文还有配套的精品资源,点击获取