ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

从零构建CNN人脸识别系统:数据预处理、模型训练与推理全流程

从零构建CNN人脸识别系统:数据预处理、模型训练与推理全流程

简介:这份资源是《DeepLearning tutorial(5)CNN卷积神经网络应用于人脸识别》一文的配套代码包,面向已具备一定深度学习基础、希望动手实践卷积神经网络人脸识别的开发者与学习者,帮助其跳过环境搭建与代码拼凑,直接跑通从训练到识别的完整链路。压缩包共4个文件,约14.64MB,包含2个Python脚本分别承担模型训练与推理调用,1个pkl文件保存训练好的网络参数,另有1个gif图片作为人脸数据集的可视化素材,结构精简、职责分明。资源基于Olivetti Faces数据集展开,读者可据此理解CNN在人脸特征提取与分类中的具体实现方式,对照代码复现训练过程、观察参数保存与加载机制,并在此基础上替换数据集或调整网络结构做进一步实验。目前已有17752人学习下载,适合作为入门CNN人脸识别的实操起点。

1. 从一张 96×96 的灰度脸说起:这套 CNN 人脸识别流程到底能跑通什么

很多人第一次做人脸识别,卡住的不是模型结构,而是数据怎么摆、标签怎么对齐、训练完怎么验证。这套 CNN 人脸识别流程的价值就在这儿:它把从原始人脸图像到最终识别输出的整条链路拆开讲清楚,配了可直接跑的代码实现,适合手里有一批人脸图、想快速搭出第一个能出结果的识别系统的人。它不追求刷榜精度,追求的是流程完整、每一步都能复现、每个参数都知道为什么这么设。你如果是刚接触 CNN 的在校学生,或者要从零接一个人脸识别模块的工程师,照着走一遍能省掉大量试错。下面我按数据准备、网络搭建、训练调参、推理验证的顺序拆,中间夹着我自己踩过的坑。

2. 数据准备与预处理:把人脸图变成 CNN 能吃的张量

2.1 目录结构与标签映射

人脸识别项目翻车,十有八九死在数据组织上。常见做法是按“一人一目录”摆放,目录名就是类别标签,这样用现成的数据集加载器能直接读出标签索引。我一般会先写一段脚本把目录扫一遍,确认每个类别下的图片数量,避免出现某个类别只有两三张图导致训练时 batch 里全是同一类。

import os from collections import Counter data_root = "faces_dataset" class_counts = {} for person in os.listdir(data_root): person_dir = os.path.join(data_root, person) if not os.path.isdir(person_dir): continue imgs = [f for f in os.listdir(person_dir) if f.lower().endswith((".jpg", ".png", ".jpeg"))] class_counts[person] = len(imgs) print("类别总数:", len(class_counts)) print("样本数分布:", Counter(class_counts.values())) # 找出样本过少的类别,后续要么补图要么剔除 few = {k: v for k, v in class_counts.items() if v < 5} print("样本不足5张的类别:", few)

这段脚本干的事很朴素:统计每个类别的图片数,并标出少于 5 张的类别。参数上data_root换成你自己的根目录即可,扩展名列表按实际格式增减。逻辑说明一句:CNN 分类任务里类别不平衡会直接让模型偏向样本多的类,识别时表现为“总把陌生人认成熟人”,所以这一步必须在训练前做,而不是训练完发现精度不对再回头查。

2.2 人脸检测、对齐与尺寸归一化

原始照片里人脸可能偏左偏右、大小不一,直接缩放到固定尺寸会把背景也塞进网络,等于让 CNN 学背景而不是学脸。常见做法是先用人脸检测框出人脸区域,再按眼睛位置做对齐,最后统一缩放到网络输入尺寸。这套流程里输入一般取 96×96 或 112×112 的灰度图,灰度化能砍掉颜色通道带来的干扰,对小数据集更稳。

import cv2 import numpy as np def preprocess_face(img_path, size=(96, 96)): img = cv2.imread(img_path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 用 Haar 级联做人脸检测,拿到人脸框 detector = cv2.CascadeClassifier( cv2.data.haarcascades + "haarcascade_frontalface_default.xml") faces = detector.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30)) if len(faces) == 0: return None # 检测不到人脸,直接丢弃这张图 x, y, w, h = max(faces, key=lambda r: r[2] * r[3]) # 取最大人脸 roi = gray[y:y+h, x:x+w] roi = cv2.resize(roi, size, interpolation=cv2.INTER_AREA) roi = roi.astype(np.float32) / 255.0 # 归一化到 0~1 return roi

逻辑上分四步:灰度化、检测、裁剪最大人脸、缩放并归一化。参数里scaleFactor=1.1控制多尺度检测的步长,值越小检测越细但越慢;minNeighbors=5是候选框的邻域阈值,调高能减少误检但可能漏掉侧脸;minSize限制最小人脸,避免把远处的小噪点当脸。归一化到 0~1 是为了让梯度稳定,这一步漏了训练 loss 很容易震荡。注意检测不到人脸的图要直接丢弃并在日志里记数,别偷偷补零,否则等于给模型喂噪声。

2.3 训练集/验证集划分的坑

划分不能简单随机切,因为同一个人可能有多张相似照片,随机切会让验证集里出现和训练集几乎一样的图,验证精度虚高。我一般按人划分:每个人的图片按比例抽一部分进验证集,保证验证集里的人训练时也见过,但具体图片没见过。这样验证的是“识别同一个人”的能力,而不是“区分没见过的人”。如果你的目标是开门禁那种“陌生人拒识”,还得额外留一批完全没出现在训练集里的人做开集测试,这是两套评估逻辑,别混。

3. 网络结构与训练:一个能落地的轻量 CNN 怎么搭

3.1 卷积层、池化层与全连接层的取舍

人脸识别用的 CNN 不需要很深。输入 96×96 的灰度图,三到四个卷积块足够提取出有判别力的局部特征。每个卷积块一般是“卷积 + 激活 + 池化”的组合,卷积核取 3×3、通道数逐层翻倍(32、64、128),池化用 2×2 最大池化把特征图尺寸减半。这样设计的原因是:浅层卷积抓边缘和纹理,深层卷积抓眼睛、鼻子这类部件组合,池化负责降维和一定程度的平移不变性。

import torch import torch.nn as nn class FaceCNN(nn.Module): def __init__(self, num_classes): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 96 -> 48 nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 48 -> 24 nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 24 -> 12 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 12 * 12, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): return self.classifier(self.features(x))

结构说明:三个卷积块把 96×96 压到 12×12×128,展平后接 256 维全连接,最后输出类别数。padding=1保证卷积后尺寸不变,方便算特征图大小;Dropout(0.5)放在全连接前是防过拟合的常规手段,小数据集上尤其必要。num_classes由你的类别数决定,训练前用len(class_counts)传进去。这里没加 BatchNorm,是因为小 batch 下 BN 反而不稳,如果你数据量上千可以加上试试。

3.2 损失函数、优化器与学习率设置

分类任务用交叉熵损失,优化器选 Adam 起步最省心,学习率从 1e-3 开始,训练中期如果 loss 不降再手动降。batch size 取 32 或 64,取决于显存。这里有个容易被忽略的点:人脸识别如果类别很多(几百上千人),全连接层参数量会爆炸,常见做法是改用带 margin 的度量学习损失(如 ArcFace 思路),但那属于进阶,第一版先用交叉熵把流程跑通。

from torch.utils.data import DataLoader from torchvision import datasets, transforms transform = transforms.Compose([ transforms.Grayscale(num_output_channels=1), transforms.Resize((96, 96)), transforms.ToTensor(), ]) train_ds = datasets.ImageFolder("faces_dataset/train", transform=transform) val_ds = datasets.ImageFolder("faces_dataset/val", transform=transform) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=2) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False) model = FaceCNN(num_classes=len(train_ds.classes)) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for epoch in range(30): model.train() for imgs, labels in train_loader: optimizer.zero_grad() loss = criterion(model(imgs), labels) loss.backward() optimizer.step() # 每个 epoch 后在验证集上算一次准确率 model.eval() correct = total = 0 with torch.no_grad(): for imgs, labels in val_loader: pred = model(imgs).argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) print(f"epoch {epoch}, val_acc={correct/total:.4f}")

参数说明:lr=1e-3是 Adam 的常用起点,epoch=30对小数据集够用,验证准确率连续几个 epoch 不涨就可以停。shuffle=True只在训练集开,验证集必须关,否则评估结果不可比。num_workers=2按机器核数调,Windows 上设 0 更稳。这段代码跑通后你会看到 val_acc 曲线,如果它远高于训练集准确率,说明验证集划分有问题(见 2.3),别急着高兴。

3.3 训练过程监控与早停

光看最终准确率不够,训练中要盯 loss 曲线和验证准确率两条线。训练 loss 持续下降但验证准确率停滞甚至下降,是过拟合的典型信号,这时候加 Dropout、加数据增强(随机水平翻转、小角度旋转)比继续堆 epoch 有用。我一般会存验证准确率最高的那版权重,而不是最后一版,因为最后一版往往已经过拟合了。

best_acc = 0.0 for epoch in range(30): # ... 训练与验证同上 ... if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "best_face_cnn.pth") print(f"保存最优权重, acc={best_acc:.4f}")

这段逻辑就是早停加最优权重保存,best_face_cnn.pth是后续推理要加载的文件。注意保存的是state_dict而不是整个模型对象,加载时先实例化同结构模型再load_state_dict,这样跨环境迁移不容易出问题。

4. 推理与验证:模型训完怎么确认它真能用

4.1 单张图片推理与置信度输出

训练完的模型不能只看验证集数字,得拿真实图片走一遍推理,看输出的类别和置信度是否合理。推理时要复用训练时的预处理,灰度化、检测、缩放、归一化一步不能少,否则输入分布和训练时不一致,结果会莫名其妙地差。

def predict(img_path, model, class_names): face = preprocess_face(img_path, size=(96, 96)) if face is None: return "未检测到人脸", 0.0 tensor = torch.from_numpy(face).unsqueeze(0).unsqueeze(0) # 1x1x96x96 model.eval() with torch.no_grad(): logits = model(tensor) prob = torch.softmax(logits, dim=1) conf, idx = prob.max(dim=1) return class_names[idx.item()], conf.item()

逻辑说明:unsqueeze(0)补 batch 维,unsqueeze(0)再补通道维,因为单张灰度图是 96×96,模型要的是 1×1×96×96。softmax把 logits 转成概率,取最大值的类别和置信度。参数上class_names要和训练时ImageFolder的classes顺序一致,这个顺序是按目录名排序的,别自己手写列表对不上。置信度低于某个阈值(比如 0.6)时,实际系统里应该判为“不确定”,而不是硬给一个类别。

4.2 用混淆矩阵定位具体错在哪

整体准确率会掩盖问题,比如 90% 的准确率可能意味着某两个人之间互相认错特别严重。用混淆矩阵能看出错分集中在哪些类别对,再针对性补数据或调参。

from sklearn.metrics import confusion_matrix import numpy as np all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for imgs, labels in val_loader: preds = model(imgs).argmax(dim=1) all_preds.extend(preds.tolist()) all_labels.extend(labels.tolist()) cm = confusion_matrix(all_labels, all_preds) # 找出错分最多的类别对 for i in range(len(cm)): for j in range(len(cm)): if i != j and cm[i][j] > 0: print(f"真实类{i} 被误判为 类{j}: {cm[i][j]} 次")

这段代码输出的是错分明细,cm[i][j]表示真实为 i 却被判成 j 的次数。拿到结果后回头看这两类的图片,常见原因是光照差异大、或者两人长得像且训练样本都少。解决方向是给这两类补样本、做光照增强,而不是盲目加深网络。

4.3 阈值与拒识策略

实际部署时模型面对的是训练集里没有的人,必须有一套拒识逻辑。常见做法是设一个置信度阈值,低于阈值就输出“未知”。阈值不能拍脑袋定,要在验证集上扫一遍不同阈值下的误接受率和误拒绝率,取一个业务能接受的平衡点。门禁场景宁可多拒几次也别放错人,阈值就调高;相册聚类场景可以放宽。

5. 避坑与排查:这套流程里最容易翻车的五个地方

5.1 验证准确率虚高,上线就崩

现象:验证集准确率 98%,换一批新照片识别率掉到一半。原因:验证集和训练集来自同一批照片的相邻帧,几乎一样,等于自己考自己。解决:按人划分验证集,并额外留一批完全没参与训练的人做开集测试,两个数字都看。

5.2 loss 变成 nan 或一直不降

现象:训练几个 batch 后 loss 变 nan,或者一直卡在 2.3 左右不动。原因:输入没归一化、学习率过大、或者标签越界。解决:确认预处理里除以了 255,学习率从 1e-3 往下降到 1e-4 试,检查num_classes是否和实际类别数一致。

5.3 检测不到人脸导致样本被静默丢弃

现象:训练集明明有几千张图,实际参与训练的少了一大截。原因:Haar 检测对侧脸、遮挡、暗光不敏感,minNeighbors设太高会漏检。解决:在预处理脚本里统计丢弃数量并打印,丢弃率超过 10% 就换更稳的检测器或调低minNeighbors,别让数据在无声无息中流失。

5.4 推理结果和训练对不上

现象:训练时准确率正常,推理时全错。原因:推理预处理和训练预处理不一致,比如训练用了ToTensor自动归一化到 0~1,推理却忘了除 255;或者通道维顺序反了。解决:把预处理封装成一个函数,训练和推理共用同一份代码,别各写一套。

5.5 类别不平衡导致总认成熟人

现象:陌生人照片也被判成某个样本多的类别。原因:交叉熵在类别不平衡时偏向多数类。解决:给损失函数加类别权重,或者对少数类做重采样,同时配合 4.3 的拒识阈值一起用。

6. 把识别率再往上推一档:数据增强与度量学习的取舍

第一版流程跑通后,多数人会发现准确率卡在某个水平上不去。这时候先别急着换更深的网络,我一般先做两件事:一是检查数据增强够不够,二是判断该不该上度量学习。数据增强对人脸任务性价比最高,随机水平翻转、±10 度旋转、亮度对比度微调,这几样加上去往往能涨几个点,而且不增加推理成本。

train_transform = transforms.Compose([ transforms.Grayscale(num_output_channels=1), transforms.Resize((96, 96)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(10), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), ])

这段增强只加在训练集上,验证集保持 3.2 里的干净变换。p=0.5是翻转概率,人脸基本对称,翻转安全;RandomRotation(10)控制在 10 度内,再大可能把脸转出画面;ColorJitter模拟不同光照。注意增强不是越多越好,过度旋转和裁剪会让模型学到扭曲的人脸,反而掉点。

如果增强做完还是不够,再考虑度量学习。交叉熵学的是“分类边界”,度量学习学的是“特征距离”,后者在类别多、每类样本少时更有优势。常见做法是把全连接层换成特征嵌入加 ArcFace 类损失,训练时让同类特征聚拢、异类推开,推理时用特征余弦相似度比对而不是直接分类。这条路代码改动不小,建议第一版稳定运行、明确瓶颈确实在特征判别力上再动。

验证方法上,我习惯每次改动只动一个变量,改完在固定验证集上跑三遍取平均,避免单次波动误导判断。下面这张表是我自己排查时常用的对照:

现象优先排查常见调整
训练 loss 不降学习率、输入归一化降 lr、确认除 255
验证准确率虚高数据划分按人划分、加开集测试
陌生人误认拒识阈值、类别不平衡调阈值、加类权重
推理全错预处理一致性训练推理共用预处理函数

血泪经验是:别在流程没跑通时就去调网络深度,八成问题出在数据和预处理上。从那以后我每次接新人脸数据集,都强制先跑一遍 2.1 的统计脚本和 4.2 的混淆矩阵,把数据底细摸清再动模型。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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