ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CNN人脸识别实战:从数据对齐到模型部署的完整指南

CNN人脸识别实战:从数据对齐到模型部署的完整指南 简介这份资源是《DeepLearning tutorial5CNN卷积神经网络应用于人脸识别》一文的配套代码包面向具备一定深度学习基础、希望动手实践卷积神经网络图像分类的开发者与学习者解决从理论到代码落地过程中缺少可运行示例的问题。压缩包共4个文件约14.64MB包含2个Python脚本分别负责训练与推理、1个pkl模型参数文件以及1个gif数据集图像覆盖数据读取、网络搭建、训练保存与测试预测的完整链路。资源以Olivetti人脸数据集为实验对象读者可据此复现CNN在人脸识别任务上的训练流程理解卷积、池化与全连接层的组织方式并借助已保存的参数文件直接进行推理验证省去重复训练成本。目前已有17745人学习下载适合作为入门CNN实战与课程作业的参考素材。1. 从一张 96×96 的灰度图说起CNN 人脸识别到底在识别什么很多人第一次跑 CNN 人脸识别都是拿一张 96×96 的灰度人脸图喂进网络然后盯着终端里跳出来的Accuracy: 0.98发愣——这 0.98 到底意味着什么是网络认出了“这是张三”还是只学会了“这是人脸”这个区别决定了你后面所有代码是白写还是能用。CNN 卷积神经网络做人脸识别本质上解决的是把一张人脸图像映射到一个高维特征向量再让同一个人的人脸向量彼此靠近、不同人的向量彼此远离。它跟传统 Haar LBPH 那套“手工特征 分类器”最大的不同在于卷积核自己从数据里学该看什么——第一层学边缘和纹理中间层学眼睛鼻子嘴巴的局部组合深层学整张脸的全局结构。你不需要告诉它“眼角到鼻翼的距离是多少”它自己会找到对区分身份最有用的那组特征。这套方案适合谁适合已经会写 Python、装过 PyTorch 或 TensorFlow、想从“调包跑 demo”进阶到“自己搭一套能落地的人脸识别流程”的工程师。也适合手里有几百到几万张人脸图、想搞清楚数据怎么组织、模型怎么选、阈值怎么定、线上怎么排查的人。下面按“数据 → 模型 → 训练 → 部署 → 避坑”的顺序把每个环节的参数和代码都摊开讲。2. 数据管线从原始人脸图到 CNN 能吃的张量2.1 人脸检测与对齐别让背景和角度毁掉你的 CNNCNN 对人脸位置和角度非常敏感。同一张脸偏 15 度、背景多一块特征向量可能就飘到另一个簇里去了。所以第一步不是直接 resize而是检测 对齐。常见做法是用 MTCNN 或 RetinaFace 做人脸检测和 5 点关键点左右眼、鼻尖、左右嘴角定位然后做相似变换把眼睛和嘴巴对齐到一个标准位置。下面这段代码用facenet-pytorch里的 MTCNN 做检测和对齐import cv2 import torch from facenet_pytorch import MTCNN from PIL import Image # 初始化 MTCNNimage_size 设 160 是 FaceNet 的标准输入 mtcnn MTCNN( image_size160, # 输出人脸图尺寸 margin14, # 在检测框外扩的像素防止裁掉下巴和额头 keep_allFalse, # 只保留置信度最高的一张脸 post_processFalse, # 不归一化后面自己处理 devicecuda if torch.cuda.is_available() else cpu ) def detect_and_align(img_path): img Image.open(img_path).convert(RGB) face mtcnn(img) # 返回对齐后的 tensor形状 [3,160,160] if face is None: return None # 转成 numpyHWC 格式方便后面存图或送进自己的网络 face_np face.permute(1, 2, 0).numpy().astype(uint8) return face_np aligned detect_and_align(raw_faces/zhangsan_001.jpg) if aligned is not None: cv2.imwrite(aligned/zhangsan_001.jpg, cv2.cvtColor(aligned, cv2.COLOR_RGB2BGR))逻辑说明MTCNN 先做三级级联检测P-Net、R-Net、O-Net拿到人脸框和 5 个关键点再用相似变换把脸“摆正”。margin14这个参数很关键——太小会裁掉下巴太大会把背景带进来。image_size160是 FaceNet 的标准输入如果你用自己设计的 CNN可以改成 112 或 128但一定要和后面网络的输入层对齐。参数说明keep_allFalse表示一张图只取置信度最高的人脸适合单人脸数据集如果一张图有多个人设成True再按置信度过滤。post_processFalse保留 0-255 的像素值后面自己做归一化避免重复归一化导致像素值全挤在 0 附近。2.2 数据集划分与增强同一个人不能同时出现在训练和测试集人脸识别数据集划分有个铁律按身份划分不是按图片划分。同一个人如果有 10 张图不能 7 张训练 3 张测试——那样测试集里出现的脸网络在训练时已经见过这个身份了准确率会虚高到 99%上线就翻车。正确做法是先把身份列表打乱按 8:1:1 切成 train/val/test 三个身份集合每个集合内部再取该身份的所有图片。下面是一个划分脚本import os import random from collections import defaultdict def split_by_identity(root_dir, train_ratio0.8, val_ratio0.1): # root_dir 下每个子文件夹是一个人的名字里面是这个人的人脸图 identities sorted(os.listdir(root_dir)) random.seed(42) random.shuffle(identities) n len(identities) n_train int(n * train_ratio) n_val int(n * val_ratio) train_ids identities[:n_train] val_ids identities[n_train:n_train n_val] test_ids identities[n_train n_val:] splits {train: train_ids, val: val_ids, test: test_ids} for split_name, ids in splits.items(): with open(f{split_name}_identities.txt, w) as f: for i in ids: f.write(i \n) print(ftrain: {len(train_ids)} ids, val: {len(val_ids)} ids, test: {len(test_ids)} ids) return splits split_by_identity(aligned_faces)逻辑说明random.seed(42)保证每次划分结果一致方便复现。划分结果写成 txt 文件后面 DataLoader 直接读这个文件避免每次重新划分导致训练/测试集不一致。数据增强方面人脸识别常用的有随机水平翻转概率 0.5、随机裁剪裁剪比例 0.9-1.0、轻微颜色抖动亮度/对比度 ±0.2。不要用垂直翻转和大幅旋转——人脸上下颠倒或旋转 90 度在真实场景里几乎不会出现加了反而让网络学偏。2.3 用 Dataset 和 DataLoader 把图喂进 CNNPyTorch 的Dataset负责“一张一张读图 增强”DataLoader负责“攒成 batch 打乱 多进程加载”。下面是一个可直接用的实现import torch from torch.utils.data import Dataset, DataLoader from torchvision import transforms from PIL import Image import os class FaceDataset(Dataset): def __init__(self, root_dir, identity_file, transformNone): self.root_dir root_dir self.transform transform self.samples [] # (img_path, label) self.label2idx {} with open(identity_file) as f: ids [line.strip() for line in f if line.strip()] for idx, identity in enumerate(ids): self.label2idx[identity] idx person_dir os.path.join(root_dir, identity) if not os.path.isdir(person_dir): continue for img_name in os.listdir(person_dir): if img_name.lower().endswith((.jpg, .png, .jpeg)): self.samples.append((os.path.join(person_dir, img_name), idx)) def __len__(self): return len(self.samples) def __getitem__(self, index): img_path, label self.samples[index] img Image.open(img_path).convert(RGB) if self.transform: img self.transform(img) return img, label # 训练集增强翻转 轻微裁剪 颜色抖动 train_tf transforms.Compose([ transforms.RandomHorizontalFlip(p0.5), transforms.RandomResizedCrop(160, scale(0.9, 1.0)), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.5, 0.5, 0.5], std[0.5, 0.5, 0.5]) ]) # 验证/测试集只做 resize 和归一化不做随机增强 val_tf transforms.Compose([ transforms.Resize((160, 160)), transforms.ToTensor(), transforms.Normalize(mean[0.5, 0.5, 0.5], std[0.5, 0.5, 0.5]) ]) train_ds FaceDataset(aligned_faces, train_identities.txt, transformtrain_tf) val_ds FaceDataset(aligned_faces, val_identities.txt, transformval_tf) train_loader DataLoader(train_ds, batch_size64, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_ds, batch_size64, shuffleFalse, num_workers4, pin_memoryTrue)逻辑说明Normalize用 0.5 均值和 0.5 标准差把像素从 [0,1] 映射到 [-1,1]这是人脸识别里最常用的归一化方式。pin_memoryTrue在 GPU 训练时能加速 CPU 到 GPU 的数据传输。num_workers4根据你机器的 CPU 核数调整设太大反而会因为进程切换拖慢速度。参数说明batch_size64是常见起点显存不够就降到 32 或 16。RandomResizedCrop(160, scale(0.9, 1.0))表示随机裁剪原图 90%-100% 的区域再缩放到 160×160模拟人脸框轻微偏移的情况。3. 模型选型与训练从零搭 CNN 还是用预训练骨干3.1 自己搭一个轻量 CNN适合小数据集和教学如果你只有几千张图或者想彻底搞懂每一层在干什么可以从零搭一个 4-5 层的 CNN。下面这个结构在 160×160 输入下参数量约 1.2M单卡 1080Ti 就能跑import torch.nn as nn import torch.nn.functional as F class SmallFaceCNN(nn.Module): def __init__(self, embedding_dim128, num_classes100): super().__init__() # 输入 [3,160,160] self.conv1 nn.Conv2d(3, 32, kernel_size3, padding1) self.bn1 nn.BatchNorm2d(32) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.bn2 nn.BatchNorm2d(64) self.conv3 nn.Conv2d(64, 128, kernel_size3, padding1) self.bn3 nn.BatchNorm2d(128) self.conv4 nn.Conv2d(128, 256, kernel_size3, padding1) self.bn4 nn.BatchNorm2d(256) self.pool nn.MaxPool2d(2, 2) self.gap nn.AdaptiveAvgPool2d(1) # 全局平均池化输出 [B,256,1,1] self.fc nn.Linear(256, embedding_dim) self.classifier nn.Linear(embedding_dim, num_classes) def forward(self, x): x self.pool(F.relu(self.bn1(self.conv1(x)))) # [B,32,80,80] x self.pool(F.relu(self.bn2(self.conv2(x)))) # [B,64,40,40] x self.pool(F.relu(self.bn3(self.conv3(x)))) # [B,128,20,20] x self.pool(F.relu(self.bn4(self.conv4(x)))) # [B,256,10,10] x self.gap(x).flatten(1) # [B,256] embedding self.fc(x) # [B,128] logits self.classifier(embedding) # [B,num_classes] return embedding, logits逻辑说明每个卷积层后面接 BatchNorm 和 ReLU再池化。BatchNorm 让训练更稳学习率可以设大一点。最后用全局平均池化把 [B,256,10,10] 压成 [B,256,1,1]再展平接全连接这样参数量比直接 flatten 少很多也不容易过拟合。参数说明embedding_dim128是输出特征向量的维度后面做人脸比对时就用这个向量算余弦相似度。num_classes等于训练集里的身份数。如果你有 1000 个人就设 1000。3.2 用预训练骨干 ArcFace 损失工业级精度自己搭的 CNN 在几千张图上能跑到 90% 左右但想上 99%常见做法是用预训练骨干如 ResNet50、MobileFaceNet ArcFace 损失。ArcFace 的核心思想是在角度空间里加一个 margin让同类更紧、异类更远。下面用insightface的 ArcFace 实现也可以自己写import math import torch import torch.nn as nn import torch.nn.functional as F class ArcFace(nn.Module): def __init__(self, embedding_dim, num_classes, s64.0, m0.5): super().__init__() self.s s # 缩放因子 self.m m # 角度 margin self.weight nn.Parameter(torch.FloatTensor(num_classes, embedding_dim)) nn.init.xavier_uniform_(self.weight) def forward(self, embeddings, labels): # 归一化权重和特征 cosine F.linear(F.normalize(embeddings), F.normalize(self.weight)) # 计算角度 theta torch.acos(torch.clamp(cosine, -1.0 1e-7, 1.0 - 1e-7)) # 只在目标类上加 margin target_logits torch.cos(theta self.m) one_hot torch.zeros_like(cosine) one_hot.scatter_(1, labels.view(-1, 1).long(), 1) logits one_hot * target_logits (1.0 - one_hot) * cosine logits * self.s loss F.cross_entropy(logits, labels) return loss逻辑说明s64把余弦值放大让 softmax 的梯度更明显m0.5约 28.6 度是加在目标类角度上的 margin让同类样本必须靠得更近才能被正确分类。one_hot用来区分目标类和非目标类只对目标类加 margin。参数说明m一般设 0.5太大训练难收敛太小区分度不够。s一般设 64也可以试 32 或 128。embedding_dim常用 512比 128 表达能力强但计算量也大。训练循环里每个 epoch 结束后在验证集上算准确率保存最好的模型def train_one_epoch(model, arcface, loader, optimizer, device): model.train() total_loss 0 for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) embeddings, _ model(imgs) loss arcface(embeddings, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(loader) # 使用示例 device torch.device(cuda if torch.cuda.is_available() else cpu) model SmallFaceCNN(embedding_dim128, num_classeslen(train_ds.label2idx)).to(device) arcface ArcFace(embedding_dim128, num_classeslen(train_ds.label2idx)).to(device) optimizer torch.optim.Adam(list(model.parameters()) list(arcface.parameters()), lr1e-3) for epoch in range(30): loss train_one_epoch(model, arcface, train_loader, optimizer, device) print(fepoch {epoch}, loss {loss:.4f})逻辑说明ArcFace 的权重也要优化所以optimizer里同时传了model.parameters()和arcface.parameters()。学习率 1e-3 是 Adam 的常用起点如果 loss 震荡就降到 1e-4。3.3 验证与阈值准确率 99% 不等于能用训练完模型下一步是在验证集上找最佳阈值。人脸识别不是简单的“分类正确”而是“判断两张脸是不是同一个人”。你需要算同一人的余弦相似度分布和不同人的相似度分布找一个阈值让误拒率和误识率平衡。import numpy as np from sklearn.metrics import roc_curve def compute_similarities(model, loader, device): model.eval() embeddings_list [] labels_list [] with torch.no_grad(): for imgs, labels in loader: imgs imgs.to(device) emb, _ model(imgs) embeddings_list.append(F.normalize(emb).cpu()) labels_list.append(labels) embeddings torch.cat(embeddings_list) labels torch.cat(labels_list) # 算余弦相似度矩阵 sim_matrix embeddings embeddings.T return sim_matrix.numpy(), labels.numpy() sim, labels compute_similarities(model, val_loader, device) # 构造正负样本对 pos_scores, neg_scores [], [] for i in range(len(labels)): for j in range(i1, len(labels)): if labels[i] labels[j]: pos_scores.append(sim[i, j]) else: neg_scores.append(sim[i, j]) # 用 ROC 找最佳阈值 y_true [1]*len(pos_scores) [0]*len(neg_scores) y_scores pos_scores neg_scores fpr, tpr, thresholds roc_curve(y_true, y_scores) best_idx np.argmax(tpr - fpr) best_threshold thresholds[best_idx] print(f最佳阈值: {best_threshold:.4f}, TPR: {tpr[best_idx]:.4f}, FPR: {fpr[best_idx]:.4f})逻辑说明sim_matrix是 N×N 的余弦相似度矩阵对角线是自身相似度恒为 1我们只取上三角。正样本对是同一人的两张图负样本对是不同人的两张图。ROC 曲线找tpr - fpr最大的点作为阈值。参数说明best_threshold一般在 0.5-0.7 之间。如果业务要求“宁可错放不可错认”就选 FPR 更低的阈值如果要求“尽量不拒真”就选 TPR 更高的阈值。4. 避坑与排查人脸识别 CNN 落地时最容易翻车的 5 个点4.1 现象训练准确率 99%测试准确率 60%原因按图片划分数据集同一个人同时出现在训练和测试集。网络记住了这个人的脸不是学会了区分不同人。解决按身份划分用split_by_identity脚本重新切分。切完后训练准确率会掉到 85% 左右但测试准确率能到 80% 以上这才是真实水平。4.2 现象loss 不下降一直卡在 8.0 左右原因ArcFace 的 marginm设太大比如 1.0或者学习率太高导致梯度爆炸。解决先把m降到 0.3学习率降到 1e-4跑 5 个 epoch 看 loss 是否下降。如果还不降检查输入归一化是不是用了 ImageNet 的 mean/std 而不是 0.5/0.5——人脸识别和 ImageNet 的像素分布不一样用错归一化会让网络第一层就学不动。4.3 现象验证集相似度分布重叠严重找不到清晰阈值原因embedding 维度太低比如 64或者训练数据里每个人只有 2-3 张图网络没学到足够判别力。解决把 embedding_dim 提到 256 或 512每个人至少保证 5 张以上不同角度/光照的图。如果数据实在少用预训练骨干 冻结底层只训最后几层。4.4 现象GPU 显存爆了batch_size 降到 8 还是 OOM原因输入分辨率太高比如 224×224或者模型参数量太大ResNet50 在 224 输入下 batch 32 就要 10G 显存。解决把输入降到 112×112或者换 MobileFaceNet 这种轻量骨干。也可以用梯度累积batch_size 设 8累积 4 次再更新一次参数等效 batch 32。4.5 现象线上推理时同一个人今天能识别明天就识别不了原因线上人脸图没有做对齐或者对齐用的关键点检测器跟训练时不一致。训练时用 MTCNN 对齐线上用 Haar 检测关键点位置差几个像素特征向量就飘了。解决训练和线上用同一套检测对齐代码把对齐后的图存下来做训练数据。线上推理前先检查人脸框是否完整、关键点是否在合理位置不合理的直接拒识不要硬送进网络。5. 从模型文件到可用服务量化、加速与一个验证技巧模型训完只是半成品。真正上线要考虑推理速度、模型大小和跨平台部署。我一般会做三件事导出 ONNX、做动态量化、写一个批量验证脚本。导出 ONNX 的代码import torch model.eval() dummy_input torch.randn(1, 3, 160, 160).to(device) torch.onnx.export( model, dummy_input, face_cnn.onnx, input_names[input], output_names[embedding, logits], dynamic_axes{input: {0: batch}, embedding: {0: batch}}, opset_version11 ) print(ONNX 导出完成)逻辑说明dynamic_axes让 batch 维度可变线上可以一次送多张图。opset_version11兼容性最好大部分推理引擎都支持。量化方面PyTorch 的动态量化对 CNN 很友好模型大小能压到原来的 1/4推理速度提升 1.5-2 倍import torch.quantization model_cpu model.to(cpu).eval() quantized_model torch.quantization.quantize_dynamic( model_cpu, {torch.nn.Linear, torch.nn.Conv2d}, dtypetorch.qint8 ) torch.save(quantized_model.state_dict(), face_cnn_quantized.pth) print(量化完成模型大小约减少 75%)逻辑说明quantize_dynamic只量化 Linear 和 Conv2d 的权重激活值保持浮点精度损失很小通常掉 0.5% 以内。量化后的模型只能在 CPU 上跑适合边缘设备。最后是一个我常用的验证技巧用同一个人的两张不同图算相似度再用这个人的图和另一个人的图算相似度看差值是否大于 0.15。如果差值小于 0.1说明模型对这个人的区分力不够需要补数据或调阈值。def verify(model, img1_path, img2_path, threshold0.6): model.eval() tf transforms.Compose([ transforms.Resize((160, 160)), transforms.ToTensor(), transforms.Normalize([0.5]*3, [0.5]*3) ]) def get_emb(path): img Image.open(path).convert(RGB) img tf(img).unsqueeze(0).to(device) with torch.no_grad(): emb, _ model(img) return F.normalize(emb).cpu() emb1 get_emb(img1_path) emb2 get_emb(img2_path) sim (emb1 emb2.T).item() return sim threshold, sim same, score verify(model, test/zhangsan_a.jpg, test/zhangsan_b.jpg) print(f同一人: {same}, 相似度: {score:.4f})逻辑说明verify函数返回是否同一人以及相似度分数。阈值 0.6 是经验值实际用验证集 ROC 找最佳阈值。如果同一人相似度低于 0.5先检查两张图是否都对齐了再考虑模型是否欠拟合。这套流程我前后调了三个月最大的教训是别在没对齐的数据上死磕模型结构。早期我花了两周换骨干、调损失函数准确率卡在 88% 上不去后来发现是训练图里有一半没做关键点对齐对齐后同样的模型直接跳到 96%。数据管线上的每一分钟都比调参的一小时值钱。希望帮到你。本文还有配套的精品资源点击获取
返回列表