拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于CNN的人脸在线识别系统:从模型训练到FastAPI部署全流程解析

简介基于卷积神经网络的人脸在线识别系统是一份适合毕业设计、期末大作业及课程设计的高分完整项目。项目已获导师指导并通过代码可下载直接运行既适合想要快速完成课题的学生也适合希望入门人脸识别实战的初学者。压缩包共10个文件涵盖Python源码、TensorFlow模型文件checkpoint、meta、index及data文件、LFW数据集tgz格式等便于训练、验证与二次开发。资源包整体约195.48MB包含多人脸识别、人脸检测、模型训练与在线识别等核心模块可帮助读者掌握基于CNN的人脸识别流程与完整工程目录结构。目前已有421人学习使用适合作为课题参考、算法复现或项目模板具有较强的实用价值。1. 从“识别”到“在线识别”差的不只是网络接口人脸识别教程网上很多但大部分止步于给你一段训练脚本跑完之后得到一堆权重文件然后就没了。毕业设计里要求的是“在线识别系统”意味着模型训练完只是第一步你还得把它变成能接收图片、返回识别结果的服务。常见做法是用 CNN 训练人脸特征提取器把每张注册人脸转成特征向量对外提供一个 HTTP 接口客户端上传图片后由服务端完成检测、对齐、特征提取、比对和结果返回。这套链路做完模型本身不再是孤立的脚本而是一个随时可被调用的系统。对 5 年以上经验的人来说难点不在卷积层怎么搭而在数据组织、接口边界和阈值策略这些容易被忽略的工程细节上。下面的方案适合在本地 GPU 或者普通台式机上完整复现也能直接拿去做毕设的核心模块。2. CNN 选型与特征提取为什么最后落在 128 维向量上2.1 分类网络做不了“在线识别”的深层原因很多入门教程会把人脸识别做成分类任务训练集有 50 个人模型输出 50 个类别的概率。但真实场景里系统上线后要识别的人不在这 50 人里面。分类模型只能回答“这张脸最像 50 人中的谁”却回答不了“这张脸是谁”更无法处理“库里没有这个人”的情况。换成特征向量就解决了模型学到的最后一层全连接前的输出可以视为一张人脸在特征空间中的坐标。注册阶段把已知人脸存成向量识别阶段拿到新向量后计算与库中向量的距离距离小于阈值才判定为同一个人否则标记为未知。这个“开放集识别”的问题是分类网络无法覆盖的。于是网络结构的输出层从 one-hot 分类头改为 embedding 层。常见的做法是保留前面所有卷积层和池化层把最后一层全连接改成输出一个固定长度向量比如 128 维或者 512 维。训练时不再直接监督“是哪个人”而是监督“同一人的向量要尽量近不同人的向量要尽量远”。最轻量级的实现是孪生网络加对比损失但训练复杂度偏高实际毕业设计里两种方式都可以走如果训练数据里类别数量固定且可穷举就用带 margin 的 Softmax 加分类头推理时提前去掉分类头只取特征如果追求更规范的人脸识别范式就切到 ArcFace 或 CosFace 这类带角度间隔的损失函数。2.2 轻量卷积网络结构怎么选在线识别系统有一个特殊约束特征提取要在服务端实时完成不能像离线批量识别那样一帧图等好几秒。模型体积和单次前向耗时就成了硬指标。MobileNet 系列是这里最稳妥的选择深度可分离卷积把计算量降到传统卷积的十分之一左右在 CPU 上处理一张 160×160 的对齐人脸单次前向可以控制在几十毫秒量级。如果你用的训练框架对 MobileNet 支持有限也可以自己写一个精简 CNN4 个卷积块加全局平均池化加一个全连接输出层。下面是按 PyTorch 风格写的卷积特征提取网络核心思路是控制通道数从 32 起步逐步翻倍在每个卷积块后紧跟批归一化和 ReLU用最大池化做空间降采样。import torch.nn as nn class FaceFeatureNet(nn.Module): def __init__(self, embed_dim128, num_classesNone): super().__init__() self.features nn.Sequential( # Block1: 输入 3x112x112 的对齐人脸 nn.Conv2d(3, 32, kernel_size3, stride1, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 56x56 nn.Conv2d(32, 64, kernel_size3, stride1, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 28x28 nn.Conv2d(64, 128, kernel_size3, stride1, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 14x14 nn.Conv2d(128, 256, kernel_size3, stride1, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 7x7 ) self.global_pool nn.AdaptiveAvgPool2d((1, 1)) # 全局平均池化 self.embedding nn.Linear(256, embed_dim) # 训练阶段的分类头推理时直接丢掉 self.classifier None if num_classes is not None: self.classifier nn.Linear(embed_dim, num_classes) def forward(self, x, get_embeddingFalse): x self.features(x) x self.global_pool(x) x x.view(x.size(0), -1) emb self.embedding(x) if get_embedding: return emb return self.classifier(emb)输入尺寸定为 112×112 而不是 224×224是为了对齐主流人脸识别数据集的裁剪尺寸也避免在 CPU 上推理时浪费计算。参数含义说明一下embed_dim 是特征向量维度128 在精度和检索速度之间比较均衡num_classes 是训练集的类别总数等于注册人脸的ID数量。forward 里 get_embedding 这个开关用于控制是否输出分类结果训练时走分类头计算损失推理时只取 embedding拿去比对相似度。2.3 损失函数选择与训练收敛的关系如果只用普通的交叉熵损失训练上面的网络特征向量虽然可分但类内距离不够紧凑实际识别时阈值很难调。建议用label_smooth配合ArcFace的简化实现来训练。ArcFace 的核心是在权重和特征之间的夹角上加上一个固定角度间隔 m迫使模型学到更紧凑的特征分布。公式层面你不用从零写很多开源仓库都有实现但有一个容易踩的细节是这是一个在线识别系统训练阶段和推理阶段的输入分辨率要保持一致。训练时如果你用 224×224 的图推理时却用 112×112 的对齐图特征的分布会直接漂移识别率大幅下降。所以训练脚本和推理脚本里统一用同一个预处理管线和同一尺寸这条要写进代码注释里。3. 数据集组织与训练流程从原始人脸图到 embedding 模型3.1 数据集怎么选、怎么摆目录毕设场景下人脸数据集有三个可选的来源一是公开数据集如 LFW、CASIA-WebFace适合预训练和验证模型效果二是自己用摄像头或手机采集适合做一个封闭班级或部门的识别系统三是爬取公开人物照片来扩充但要注意版权和肖像权不建议大规模使用。LFW 直接下载好之后是按人名分目录的正好和 PyTorch 的 ImageFolder 格式兼容省去写自定义 Dataset 的麻烦。目录结构按下面的方式来组织比较稳faces/ ├── train/ │ ├── person_001/ │ │ ├── 001.jpg │ │ └── 002.jpg │ ├── person_002/ │ │ └── ... ├── val/ │ ├── person_001/ │ └── ... └── test/ ├── known/ └── unknown/test 下区分 known 和 unknown 是很多毕设忽略的关键点。known 目录放注册过的人的照片用来看通过率unknown 目录放从未注册过的脸用来看误识率。系统上线后会不会把陌生人识别成库里的某人全靠 unknown 这部分数据来验证。如果只用 known 测试准确率虚高答辩时一演示就露馅。3.2 对齐预处理检测和眼睛坐标是藏得最深的坑直接把原始照片塞进 CNN 是不行的。人脸姿态、背景、光照的影响远大于网络结构本身。常见的做法是用 OpenCV 的 DNN 人脸检测器先框出人脸再通过眼睛坐标做仿射变换把两只眼睛对齐到固定位置。代码上OpenCV 自带的人脸检测模型封装成了cv2.FaceDetectorYN输入原始图输出人脸框和五个人脸关键点包括双眼中心、鼻尖、左右嘴角。对齐这一步决定了后续识别精度的下限。import cv2 import numpy as np # 模型文件解压后用绝对路径指向它 detector cv2.FaceDetectorYN.create( face_detection_yunet_2023mar.onnx, , (320, 320), score_threshold0.6, nms_threshold0.3, top_k10 ) def align_face(img_rgb): h, w img_rgb.shape[:2] detector.setInputSize((w, h)) _, faces detector.detect(img_rgb) if faces is None or len(faces) 0: return None # 取第一个检测到的人脸x, y, w, h, 右眼x, 右眼y, 左眼x, 左眼y, 鼻x, 鼻y, ... face faces[0] left_eye (face[6], face[7]) # 注意这里索引顺序是右眼在前左眼在后 right_eye (face[4], face[5]) # 计算需要旋转的角度使两眼水平 dx right_eye[0] - left_eye[0] dy right_eye[1] - left_eye[1] angle np.degrees(np.arctan2(dy, dx)) center ((left_eye[0] right_eye[0]) / 2.0, (left_eye[1] right_eye[1]) / 2.0) rot_mat cv2.getRotationMatrix2D(center, angle, scale1.0) rotated cv2.warpAffine(img_rgb, rot_mat, (w, h), flagscv2.INTER_CUBIC) # 旋转后再按人脸框裁剪并缩放到 112x112 x, y, bw, bh int(face[0]), int(face[1]), int(face[2]), int(face[3]) margin 0.15 pad_x, pad_y int(bw * margin), int(bh * margin) x1 max(0, x - pad_x) y1 max(0, y - pad_y) x2 min(w, x bw pad_x) y2 min(h, y bh pad_y) crop rotated[y1:y2, x1:x2] aligned cv2.resize(crop, (112, 112), interpolationcv2.INTER_LINEAR) return aligned.astype(np.float32) / 255.0上面的代码里UNet 检测器输出的关键点顺序非常容易搞反右侧眼睛的坐标在前左侧在后不少人在这一步栽了跟头。margin 参数控制裁剪范围扩展0.15 表示在人脸框基础上外扩 15%把部分额头和下巴也包进来这能让特征提取器看到更多面部轮廓调太小会切掉发际线调太大又会引入背景干扰。对齐后统一除以 255 做归一化这和训练时的预处理保持一致。3.3 训练超参数与启动命令训练用 AdamW 优化器初始学习率 3e-4权重衰减 5e-4。批量大小按显存来GPU 上可以设 128纯 CPU 训练就降到 32。学习率采用余弦退火总共训练 60 个 epoch。每个 epoch 结束用验证集算一次 Top-1 准确率连续 5 个 epoch 不提升就把模型存为 best_model.pth。以下是精简后的训练启动命令python train.py \ --train_dir data/faces/train \ --val_dir data/faces/val \ --arch mobilenet_v1 \ --embed_dim 128 \ --batch_size 64 \ --lr 0.0003 \ --epochs 60 \ --output checkpoints/face_net.pth参数说明–train_dir 和 –val_dir 必须指向按人名分目录的根目录–arch 目前支持内部实现的 mobilenet_v1 和自写的精简 CNN–embed_dim 要和后续注册人脸库时使用的模型输出维度完全一致如果训练用了 128 但注册阶段加载的模型输出是 512接口会直接报维度错误–lr 这个值在 batch_size 减半时可以同步减半否则梯度更新步长过大容易震荡。训练时日志里关注 loss 和 val_acc 两个值如果 val_acc 在某个 epoch 后突然掉到和随机猜测一样大概率是学习率过热导致梯度爆炸这时加载最近的 checkpoint 把学习率降到原来的十分之一再继续。4. 在线识别服务写一个能扛并发的人脸检索接口4.1 服务端架构与接口设计在线识别的核心服务不需要复杂的框架Flask 足够轻FastAPI 则自带异步支持和 OpenAPI 文档。这里推荐 FastAPI它接收图片后可以直接走异步处理避免多个请求同时进来时 IO 阻塞。注册和识别分成两个接口/register接收一张人脸照片和一个 ID提取特征后存入特征库/recognize接收一张照片返回最相似注册人的 ID 和相似度分数。注册库可以用 SQLite 加一个内存索引SQLite 存元数据内存里加载全部特征向量做相似度计算。这个方案在注册人数不超过一万的时候完全够用。每次服务启动时扫描特征表把向量加载为 NumPy 数组每注册一个新用户追加一条记录并同步更新内存数组。先建立数据库表CREATE TABLE face_features ( id INTEGER PRIMARY KEY AUTOINCREMENT, person_id VARCHAR(64) UNIQUE NOT NULL, embedding BLOB NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );person_id 是业务层面的用户唯一标识比如工号或学号embedding 以二进制形式存储 128 个 float 组成的字节串。人脸向量作为一个整体存取区分度和检索效率都比直接存图片要好得多。实际开发中经常遇到有人把图片 Base64 存进数据库的服务会越跑越慢而且检索时还要重新提特征完全没必要。4.2 FastAPI 接口实现与特征比对逻辑下面是识别接口的核心实现包含图片解码、人脸检测、特征提取、相似度计算和阈值判断from fastapi import FastAPI, UploadFile, File import numpy as np import cv2 import io import torch app FastAPI() # 全局加载模型只加载一次不随请求反复初始化 device torch.device(cuda if torch.cuda.is_available() else cpu) model FaceFeatureNet(embed_dim128) model.load_state_dict(torch.load(checkpoints/face_net.pth, map_locationdevice)) model.to(device) model.eval() # 内部维护的注册向量库 embedding_db [] # list of (person_id, embedding_array) id_list [] def compute_embedding(face_rgb): 输入对齐后的 112x112 RGB float 图输出 128 维归一化向量 tensor torch.from_numpy(face_rgb.transpose(2, 0, 1)).unsqueeze(0) tensor tensor.to(device) with torch.no_grad(): emb model(tensor, get_embeddingTrue) emb emb.cpu().numpy().flatten().astype(np.float32) norm np.linalg.norm(emb) if norm 0: emb / norm return emb app.post(/recognize) async def recognize(file: UploadFile File(...)): raw await file.read() img cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) aligned align_face(img_rgb) if aligned is None: return {code: 400, message: no face detected} emb compute_embedding(aligned) max_score -1.0 best_id unknown for pid, db_emb in embedding_db: score float(np.dot(emb, db_emb)) # 余弦相似度 if score max_score: max_score score best_id pid threshold 0.65 if max_score threshold: return {code: 200, result: unknown, score: round(max_score, 4)} return {code: 200, result: best_id, score: round(max_score, 4)}逻辑说明人脸检测失败时返回 400这是一个容易被忽略的边界有些测试图片是用手机拍的很暗或者侧脸角度大直接传进模型会得到一个极不稳定的特征向量embedding 归一化后余弦相似度和欧氏距离在排序上等价但余弦相似度的解释性更强score 接近 1 表示极其相似threshold 是识别阈值根据验证集上的 FAR/FRR 曲线来选择0.65 是个保守起步值后续要按测试结果调整。如果注册人数上千线性扫描会开始吃力那时候可以把 embedding_db 换成 faiss 的 IndexFlatIP构建索引只需一行index faiss.IndexFlatIP(128)识别时把所有人的向量矩阵化后调用index.search()即可。4.3 注册接口与增量更新注册接口做的事是把一张已知身份照片预处理成向量后插入数据库并同步内存数组app.post(/register) async def register(person_id: str, file: UploadFile File(...)): raw await file.read() img cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) aligned align_face(img_rgb) if aligned is None: return {code: 400, message: no face detected, register failed} emb compute_embedding(aligned) # 更新内存 embedding_db.append((person_id, emb)) # 更新持久化 blob emb.tobytes() cursor.execute(INSERT OR REPLACE INTO face_features (person_id, embedding) VALUES (?, ?), (person_id, blob)) conn.commit() return {code: 200, message: fregister {person_id} ok}这段代码里有一个值得留意的坑同一个 person_id 下如果有多张照片有些业务希望一人存多个特征向量识别时用“投票”或“取最大值”的方式。简单实现就直接追加多条记录比对时对该 ID 的所有向量取最高分。不建议用平均向量代替一个 ID 的全部向量因为同一人不同光线条件下的人脸向量可能不在一个线性簇里平均后反而偏离了所有实际样本。4.4 用 curl 做接口冒烟测试服务启动后立刻要验证链路是否通别直接上前端。启动命令uvicorn app:app --host 0.0.0.0 --port 8000然后注册一张照片再用另一张同人不同角度的照片识别curl -X POST http://127.0.0.1:8000/register?person_id2024001 \ -F fileface_sample_zhang3_front.jpg # 期望返回 code 200message 注册成功 curl -X POST http://127.0.0.1:8000/recognize \ -F fileface_sample_zhang3_side.jpg # 期望返回识别出 2024001score 大于阈值这一步冒烟测试的目的是尽早暴露预处理管线的不一致。如果注册时用的图经过了对齐和归一化但识别请求里因为某些原因漏了这个步骤两个特征向量的分布会明显错位。测试时用两张同人照片验证通过率再用一张完全陌生的人脸验证是否被正确拒绝。冒烟测试不过后面的所有优化都无从谈起。5. 调参、验证与三个隐蔽的可靠性坑5.1 threshold 的选择FAR/FRR 曲线是必交的作业在线识别系统的关键参数不是网络深度也不是学习率而是相似度阈值。threshold 设得过高注册用户经常被拒通过率达不到演示效果设得过低陌生人可以轻松冒认。正确做法是用验证集里的 known 和 unknown 数据画 FAR误识率和 FRR拒识率曲线。import numpy as np import matplotlib.pyplot as plt def roc_threshold(scores_positive, scores_negative): # scores_positive: 同人比对分数列表 # scores_negative: 不同人比对分数列表 thresholds np.arange(0.3, 1.0, 0.005) far_list, frr_list [], [] for t in thresholds: far np.mean(np.array(scores_negative) t) frr np.mean(np.array(scores_positive) t) far_list.append(far) frr_list.append(frr) plt.plot(thresholds, far_list, labelFAR) plt.plot(thresholds, frr_list, labelFRR) plt.xlabel(threshold) plt.ylabel(error rate) plt.legend() plt.savefig(far_frr.png, dpi150) best_idx np.argmin(np.abs(np.array(far_list) - np.array(frr_list))) return thresholds[best_idx], far_list[best_idx], frr_list[best_idx]选阈值时有一个常见倾向追求 FRR 为 0让所有注册人都能识别出来。这在毕设答辩演示时确实好看但会牺牲安全性陌生人照片很容易撞进库里。更稳妥的做法是取 FAR 低于 0.01 的最大阈值或者在 FAR 和 FRR 只能保一头的时候优先保 FAR毕竟“宁可拒绝熟人不能放进陌生人”。5.2 隐蔽坑一模型推理时被当作 0 维张量用 PyTorch 写推理时最容易报的错是RuntimeError: Expected 4-dimensional input for 4-dimensional weight [32, 3, 3, 3], but got 2-dimensional input。原因是输入图片经过了某种处理变成了灰度图变成了单通道。OpenCV 读取彩色图时默认是 BGR 三通道但某些摄像头采集的帧是灰度的。解决方法是读图后统一cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)再转 RGB同时检查img.ndim必须等于 3否则直接返回错误。5.3 隐蔽坑二多人同框时只识别第一个人上面的 align_face 里用了faces[0]这意味着合影照片永远识别第一张脸。毕设系统定位是单人人脸识别这个限制可以接受但如果你希望扩展成多人识别需要在检测循环里逐个处理所有框。有一种折中方案检测后按框的面积排序优先处理最大的人脸这更符合多数应用场景。5.4 隐蔽坑三注册和识别阶段的人脸框位置漂移同一个人的两张照片如果一张人脸在画面中央另一张在角落对齐后的裁剪区域会有明显差异。业内通用对策是检测后做相似变换而不是直接平移加裁剪相似变换包含旋转和缩放能更稳定地把人脸摆正到模板位置。代码里 align_face 的旋转中心是两眼中心这一步已经把姿态影响吸收了一部分。真正麻烦的是侧脸超过 45 度的情况这时候检测器要么检不到要么检到但关键点不准。处理方式是识别请求里优先选带正脸角度的照片注册同时可以收集同一人的多张照片各注册一个特征向量比单张照片的鲁棒性高很多。在线识别系统的完整链路从 CNN 选型、数据集整理、模型训练到 FastAPI 服务部署和阈值分析本质上是一个闭环工程。最终验收时要确保训练脚本、对齐脚本、注册接口、识别接口在独立的干净环境中能按顺序跑通并把far_frr.png曲线放进最终报告的关键位置这份曲线图比任何文字描述都能说明你的系统边界在哪里。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门