基于RetinaFace与FaceNet的人脸识别系统搭建实战
发布时间:2026/9/28 17:05:29来源:尧图网络
简介面向高校计算机及相关专业学生这是一套毕业设计/课程设计级别的人脸识别管理系统采用RetinaFace进行人脸检测FaceNet提取特征向量以Python实现核心识别逻辑并配合Java后端与Vue前端构成完整可运行的管理界面。项目覆盖人脸入库、实时识别、数据库比对等典型流程兼顾光照变化、遮挡等实际问题适合作为深度学习和计算机视觉的教学样例。资源包共646个文件总大小约57.39MB除Python算法源码、Java服务端代码、Vue页面组件外还包含SQL数据库脚本、PyTorch模型权重文件以及大量演示截图和操作动图可直观呈现系统运行效果。目前已有448人学习下载读者可直接复用项目骨架结合附带的文档快速完成环境配置和二次开发在此基础上扩展考勤、门禁等应用场景有效缩短毕业设计或课程设计的开发周期。1. 人脸识别毕业设计选型为什么是 FaceNetRetinaFace 组合每年毕业季都有学生卡在人脸识别系统这个选题上要么是用了过时的 Haar 级联检测器在教室实拍场景下别说识别了连人脸框都框不准要么是拿个预训练分类模型硬套换个角度就认不出人。这套FaceNetRetinaFace 人脸识别管理系统的价值在于把检测和识别拆成两个成熟模块用 RetinaFace 负责框出人脸并定位关键点用 FaceNet 把每张脸映射成 128 维特征向量再做距离比对整个链路完整、可复现适合作为毕业设计或课程设计的系统基座。它解决的核心问题是一套能跑通注册人脸→实时检测→特征提取→身份比对全流程的代码骨架附带管理端界面而不是一个只能在测试集上刷指标的玩具模型。对想动手写代码的学生来说这套资源能帮你省掉大量模型调参和前后端联调的时间。2. RetinaFace 人脸检测从 anchor 策略到五关键点输出的完整链路2.1 为什么选 RetinaFace 而不是 SSD 或 YOLO人脸检测环节直接决定整个识别系统的上限。如果检测框歪了、关键点偏了后续 FaceNet 提取的特征向量就会带上噪声距离度量自然不准。RetinaFace 与 SSD、YOLO 这类通用目标检测器的核心差异在于它在设计时就针对人脸这个特定目标做了优化。第一是 multi-scale 特征图。RetinaFace 从 P2 到 P6 多层特征图分别输出预测结果浅层特征图保留下采样较少的小人脸细节深层特征图负责大尺度人脸。实际测试里一张 640×480 的教室照片上站着三四米外的人SSD 很容易漏框RetinaFace 还能以 0.7 以上置信度检出。第二是 anchor 策略。它是基于人脸统计宽高比设计的默认 preset 覆盖了从 16×16 到 256×256 的候选框不需要像 YOLO 那样为每个数据集重新聚类 anchor 尺寸。第三是关键点回归。RetinaFace 在训练时额外监督五个关键点左眼、右眼、鼻尖、左嘴角、右嘴角这给后续做人脸对齐提供了现成的输入不需要再单独调 MTCNN。用一句话概括选型理由RetinaFace 在小脸、密集、部分遮挡三类场景下的鲁棒性明显优于传统检测器而且自带关键点输出正好补上 FaceNet 需要对齐输入这个前置条件。2.2 RetinaFace 模型加载与检测代码这里我常用的是 insightface 官方仓库提供的 RetinaFace ONNX 模型能绕开 MXNet 的环境依赖问题。ONNX Runtime 部署简单Windows 和 Linux 都能跑CPU 上处理一帧 640×480 图像大约 40~80ms满足课程设计级别的实时性要求。import onnxruntime as ort import numpy as np import cv2 # 加载 RetinaFace ONNX 模型 sess ort.InferenceSession(retinaface_resnet50.onnx, providers[CPUExecutionProvider]) input_name sess.get_inputs()[0].name def retinaface_detect(img, conf_threshold0.5): img: BGR 图像, shape(H, W, 3) 返回 list每个元素为 (box, landmarks, score) box: [x1, y1, x2, y2] 绝对坐标 landmarks: [(x, y) * 5] 五个人脸关键点 # 预处理Resize 到固定尺寸、归一化、标准化 resized cv2.resize(img, (640, 640)) rgb cv2.cvtColor(resized, cv2.COLOR_BGR2RGB) blob rgb.astype(np.float32) / 255.0 blob (blob - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225]) blob blob.transpose(2, 0, 1)[None, :, :, :].astype(np.float32) outputs sess.run(None, {input_name: blob}) # outputs 解析scores、boxes、landmarks 分别对应不同输出节点 # 具体索引以 onnx 模型导出时定义的输出顺序为准 scores outputs[0][0] boxes outputs[1][0] landmarks outputs[2][0] # 按置信度过滤 keep_idx np.where(scores conf_threshold)[0] result [] for idx in keep_idx: # 坐标还原到原始图像尺寸 scale_x img.shape[1] / 640.0 scale_y img.shape[0] / 640.0 x1, y1, x2, y2 boxes[idx] box [x1 * scale_x, y1 * scale_y, x2 * scale_x, y2 * scale_y] pts landmarks[idx].reshape(-1, 2) pts[:, 0] * scale_x pts[:, 1] * scale_y result.append((box, pts.tolist(), scores[idx])) return result这段代码里要特别注意两个参数conf_threshold控制误检与漏检的平衡点。人脸识别系统里我一般设0.5~0.7低于 0.5 会把背景里的相框、海报误检成人脸高于 0.7 又会漏掉侧脸。另一个是整个预处理流程的顺序——一定要先 Resize 再归一化否则颜色分布被破坏后检测率会明显下降。2.3 检测结果的后处理对齐是容易被忽略的一步拿到 RetinaFace 的五关键点坐标后很多同学直接就把原始人脸图送到 FaceNet这样识别准确率会打折扣。因为 FaceNet 训练时输入的人脸是经过相似变换对齐到标准位置的。常见做法是按左右眼的坐标做仿射变换把两只眼睛映射到水平位置。def align_face(img, landmarks, output_size(160, 160)): landmarks: RetinaFace 输出的五关键点 这里只用两只眼睛和鼻子的相对位置做仿射变换 eye_left landmarks[0] eye_right landmarks[1] nose landmarks[2] # 计算旋转角度让两眼连线保持水平 dx eye_right[0] - eye_left[0] dy eye_right[1] - eye_left[1] angle np.degrees(np.arctan2(dy, dx)) # 以左眼为基准点旋转 M cv2.getRotationMatrix2D(tuple(eye_left), angle, scale1.0) # 旋转后取中心区域裁剪 rotated cv2.warpAffine(img, M, (img.shape[1], img.shape[0])) # 计算两眼距离按比例确定裁剪框大小 eye_dist np.sqrt(dx**2 dy**2) crop_size int(eye_dist * 2.2) center_x int((eye_left[0] eye_right[0]) / 2) center_y int((eye_left[1] eye_right[1]) / 2) - int(eye_dist * 0.2) x1 max(0, center_x - crop_size // 2) y1 max(0, center_y - crop_size // 2) x2 min(img.shape[1], x1 crop_size) y2 min(img.shape[0], y1 crop_size) aligned rotated[y1:y2, x1:x2] return cv2.resize(aligned, output_size)eye_dist * 2.2这个系数是经验值裁剪太紧会丢掉额头和下巴影响 FaceNet 对全局人脸特征的提取裁太宽又会引入背景噪声。初次跑通后建议用同一批测试图多试几个系数观察识别准确率的变化曲线再定最终值。3. FaceNet 特征提取triplet loss 与 128 维嵌入空间的落地3.1 FaceNet 的核心思想距离即相似度FaceNet 没有像传统人脸识别那样训练一个分类器输出这人是谁而是让网络学会把一张人脸图像映射成 128 维的浮点向量。训练用的 triplet loss 每次取三张图anchor基准脸、positive同一个人另一张脸、negative不同人的脸目标是把 anchor 与 positive 的距离拉近、与 negative 的距离推远最终形成一个人脸嵌入空间。在这个空间里同一人的欧氏距离通常小于 1.0不同人通常在 1.0 以上——当然这个数值受训练数据分布影响在不同数据集上会浮动。这意味着识别阶段完全不需要重新训练网络。系统上线时只管生成每个人注册照片的 128 维向量存进数据库新来一个人就提取向量跟库里所有向量算一遍欧氏距离取最小值对应的身份作为识别结果。这种度量学习 最近邻的结构给人脸识别系统带来了一个关键特性新增一个用户不需要改模型往数据库里插一条向量就行。3.2 加载 FaceNet 模型与特征提取代码FaceNet 的官方预训练模型基于 TF2 的facenet包发布常见的有基于 Inception ResNet v1 和 MobileNet 的两个版本。课程设计建议用 MobileNet 版本模型体积小CPU 上提取一张脸的特征约 10~20ms交互体验明显好于大模型。import tensorflow as tf import numpy as np from align_face import align_face # 复用上一章的 align_face # 加载 FaceNet 预训练模型 model tf.saved_model.load(facenet_mobilenet) # 输入是 (None, 160, 160, 3) 的 RGB 图像输出是 (None, 128) 的归一化向量 def extract_face_embedding(img, face_box, landmarks): img: 原始 BGR 图像 face_box: RetinaFace 输出的边界框 landmarks: RetinaFace 输出的五关键点 返回 128 维 float32 特征向量已做 L2 归一化 # 第一步对齐裁剪出标准人脸图 aligned align_face(img, landmarks, output_size(160, 160)) # 第二步转 RGB、归一化到 [-1, 1] rgb cv2.cvtColor(aligned, cv2.COLOR_BGR2RGB) normalized (rgb.astype(np.float32) / 127.5) - 1.0 # 第三步增加 batch 维度并推理 input_tensor tf.expand_dims(normalized, axis0) embedding model(input_tensor).numpy()[0] # FaceNet 输出已经是 L2 归一化的但复算一次确保推理时不会出错 embedding embedding / np.linalg.norm(embedding) return embedding注意代码里的一个细节输入归一化用的是(x / 127.5) - 1.0把像素值映射到 [-1, 1] 区间而不是 [0, 1]。FaceNet 的预训练模型用的是这个标准如果用了/255.0提取出的向量分布就会偏移导致距离计算整体变大。这是新手最容易翻车的地方之一。3.3 特征向量的存储SQLite 还是 NumPy 文件特征向量只有 128 维浮点数每条记录 512 字节一个班级 50 个人也就是 25KB 左右。存储方案看使用场景简单课程设计用 NumPy 文件就能解决。所有人的特征堆成一个(N, 128)矩阵用np.save保持持久化识别时一次性载入内存算距离逻辑简单不依赖数据库环境。但这样做有两个局限无法增量添加注册用户时必须重写整个文件不保存姓名、注册时间等元信息需要额外维护一个 JSON 映射表。稍正式一点的毕业设计建议用 SQLite。Python 内置支持不需要额外装 MySQL表格结构也直观CREATE TABLE IF NOT EXISTS faces ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, embedding BLOB NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );embedding 字段存的是np.float32数组序列化后的字节串。用 SQLite 的好处是增删用户就是增删记录配合 Flask 或 FastAPI 做管理后台时写自然语言查询更方便。下文管理系统的代码示例我按 SQLite 方案给出。4. 管理系统整合从摄像头取帧到身份判定的完整流程4.1 注册流程录入人脸到特征库系统先解决把谁存进特征库的问题。注册阶段我建议用本地图片文件输入而不是直接摄像头抓拍——摄像头画面受环境光影响大注册一次不成功返工很浪费。这里提供一个批量注册脚本import cv2 import numpy as np import sqlite3 from retinaface_det import retinaface_detect from facenet_extract import extract_face_embedding def register_faces(folder_path, db_pathfaces.db): 批量注册人脸遍历文件夹中所有以姓名命名的子目录或图片 目录结构 dataset/ 张三/ img1.jpg img2.jpg 李四/ img1.jpg img2.jpg conn sqlite3.connect(db_path) cursor conn.cursor() cursor.execute(CREATE TABLE IF NOT EXISTS faces (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, embedding BLOB NOT NULL)) import os for person_name in os.listdir(folder_path): person_dir os.path.join(folder_path, person_name) if not os.path.isdir(person_dir): continue for img_file in os.listdir(person_dir): img_path os.path.join(person_dir, img_file) img cv2.imread(img_path) # 检测人脸取置信度最高的那一张 detections retinaface_detect(img, conf_threshold0.6) if not detections: print(f未检测到人脸: {img_path}) continue # 按置信度排序取最高分的那张脸 box, landmarks, score max(detections, keylambda x: x[2]) # 提取特征向量 embedding extract_face_embedding(img, box, landmarks) # 序列化存入数据库每张图片一条记录方便后续多图投票 cursor.execute( INSERT INTO faces (name, embedding) VALUES (?, ?), (person_name, embedding.tobytes()) ) print(f注册成功: {person_name} - {img_file}) conn.commit() conn.close()多图注册是这里的一个关键设计决策。同一个人存多条向量记录识别时遍历所有记录找最小距离比把多张图平均成一条向量更可靠。原因是 FaceNet 的特征空间里同一人的不同姿态、表情分布在一个区域整体取平均会把边界拉平反而损失区分度。多图记录相当于给系统保留了这个分布区域实际识别时取最近邻效果更好。4.2 识别流程实时摄像头比对识别端的核心逻辑是每帧图像走一遍检测→对齐→提特征→比对并把结果显示在画面上import cv2 import numpy as np import sqlite3 from retinaface_det import retinaface_detect from facenet_extract import extract_face_embedding # 启动时加载整个特征库到内存 def load_embeddings(db_pathfaces.db): conn sqlite3.connect(db_path) rows conn.execute(SELECT name, embedding FROM faces).fetchall() conn.close() names [] embeddings [] for name, blob in rows: emb np.frombuffer(blob, dtypenp.float32) names.append(name) embeddings.append(emb) return names, np.vstack(embeddings) # 返回 (N, 128) 矩阵 NAMES, EMB_MATRIX load_embeddings() def recognize_face(img, threshold1.1): 对一帧图像做实时识别 返回 [(name, distance, box, landmarks), ...] detections retinaface_detect(img, conf_threshold0.6) results [] for box, landmarks, score in detections: embedding extract_face_embedding(img, box, landmarks) # 与库里所有人算欧氏距离 distances np.linalg.norm(EMB_MATRIX - embedding, axis1) min_idx np.argmin(distances) min_dist distances[min_idx] if min_dist threshold: results.append((NAMES[min_idx], min_dist, box, landmarks)) else: results.append((未知, min_dist, box, landmarks)) return results # 调用示例摄像头实时识别 cap cv2.VideoCapture(0) while True: ret, frame cap.read() if not ret: break for name, dist, box, lmks in recognize_face(frame): x1, y1, x2, y2 map(int, box) cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(frame, f{name} {dist:.2f}, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow(Face Recognition, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()threshold1.1这个值不是随便拍的。在三方预训练模型默认设定下FaceNet 的类内距离一般在 0.5~0.9类间距离在 1.2~2.0。阈值取 1.1 能让类内误差留出余量同时尽量压掉跨人的误识。实际项目中建议做一个阈值的校准实验——用测试集画出误识率/拒识率随阈值变化的曲线再决定。这里给的是起点不是终点。4.3 Web 管理端扩展特征库可视化项目前端的 CSS 和 JS 资源文件说明系统是带 web 管理界面的这类界面在答辩时很加分。常见做法是用 Flask 提供接口前端页面通过 AJAX 调用识别服务from flask import Flask, request, render_template, jsonify import cv2 import numpy as np from io import BytesIO app Flask(__name__) app.route(/api/recognize, methods[POST]) def api_recognize(): # 接收前端上传的图片 file request.files[image].read() img cv2.imdecode(np.frombuffer(file, np.uint8), cv2.IMREAD_COLOR) results recognize_face(img) # 只返回 JSON 可序列化的内容 return jsonify([ {name: name, distance: round(float(dist), 4), box: [int(v) for v in box]} for name, dist, box, _ in results ])这个接口可以对接任何前端。如果你用 Vue3 写后台管理系统只需要把图片通过multipart/form-data发到这个接口拿到 JSON 后在页面渲染识别框即可。答辩演示时先用本地脚本跑通识别再展示 web 界面操作注册和查询条理会非常清晰。5. 避坑实录人脸识别系统最常见的五个坑5.1 特征向量没归一化距离全面漂移现象注册和识别都正常但不同人的欧氏距离普遍在 0.5~0.8跟陌生人比对也很接近阈值怎么调都区分不开。原因FaceNet 预训练模型输出已经做了 L2 归一化但如果手工对输入做了错误的归一化比如除以 255 而不是 127.5特征向量分布会整体变形distance 计算失去意义。解决统一输入预处理标准坚持(x / 127.5) - 1.0的映射。同时可以在提取函数末尾加一行embedding / np.linalg.norm(embedding)兜底保证进数据库的向量一定是单位向量。5.2 检测到多张脸时取错了目标现象教室里同时出现四五个人系统识别出的人是旁边同学的而不是画面中央的那位。原因代码写了detections[0]直接取第一张脸。RetinaFace 返回的检测结果顺序由模型输出的锚框排序决定不一定是最大、最清晰的人脸。解决按检测框面积排序或者按置信度排序后取目标。注意区分场景——门禁识别应该取最大人脸而视频监控分析可能需要处理所有检测结果不能一刀切。5.3 摄像头画面暗或过度曝光RetinaFace 完全漏检现象室内背光环境下摄像头画面人脸区域偏暗retinaface_detect返回空列表。原因ONNX 模型在训练集上做了大量数据增强但它对极暗光依然敏感。实际部署时摄像头自动曝光算法往往跟不上人物移动导致的明暗突变。解决在检测前做亮度均衡化。用 CLAHE 自适应直方图均衡化能提升暗部细节但注意 CLAHE 的参数clipLimit设太大大于 5会放大噪点检测率反而下降。一个更省事的做法是直接提高摄像头曝光补偿在 OpenCV 的cv2.VideoCapture里调CAP_PROP_EXPOSURE属性。5.4 识别不对人——阈值调了半天还是误报现象阈值设 0.8 时报未知设 1.5 时陌生人经常被认成库里的人。原因单阈值本身不够用。固定阈值处理的是全局平均情况但不同人种、年龄、戴不戴眼镜的特征空间分布密度差异很大单点阈值很难兼顾所有场景。解决不要只调一个阈值改用一个粗匹配精验证的两级策略。第一级用threshold1.2粗筛出 Top-3 候选第二级对 Top-3 计算更严格的threshold0.9验证。如果两级都通过才输出身份。这种策略可以把误识率降低一个量级且不需要重新训练模型。5.5 前后端字符编码问题中文姓名存进去变乱码现象SQLite 里存的张三在 Flask 接口返回时变成\u5f20\u4e09或直接乱码前端渲染后是乱码。原因SQLite 默认 UTF-8 编码Python3 的sqlite3模块处理中文没问题但 Flask JSON 序列化时如果客户端解析指定了错误字符集中文就显示不全。解决前端请求头显式指定Accept-Charset: utf-8并且 Flask 返回时设置app.config[JSON_AS_ASCII] False。这样jsonify输出的是真正的中文字符串而不是\uXXXX转义序列。这个小坑在很多课程设计里都能碰到答辩时最好提前确认浏览器字符集设置。6. 把系统做成能演示的状态自建验证集与识别率自测脚本系统开发完成后最怕答辩现场出丑。我给你一个自己常用的做法先建一个本地验证集把系统的识别率量化出来再决定要不要直接连摄像头演示。这个步骤 20 分钟能跑完但能帮你避掉绝大多数现场翻车。验证集结构不用复杂找个相对干净的环境拍一段视频手工截几十帧做测试就够了。关键是要覆盖三类典型场景正常正脸、侧脸小于 30 度、戴眼镜或戴口罩。没有口罩样本的话至少保证 NG 样本里有不在特征库的陌生人和空场景没有人脸两类。# 自测脚本统计验证集上的识别准确率、误识率、拒识率 import os import cv2 import numpy as np from recognize import recognize_face TEST_ROOT test_data db_names [张三, 李四, 王五] # 特征库里注册过的姓名 metrics { 正确识别: 0, # 库内人员被识别为本人 误识: 0, # 库外人员被识别为库内人员 拒识: 0, # 库内人员被识别为未知 总样本: 0 } for person in os.listdir(TEST_ROOT): person_dir os.path.join(TEST_ROOT, person) if not os.path.isdir(person_dir): continue for img_name in os.listdir(person_dir): img_path os.path.join(person_dir, img_name) img cv2.imread(img_path) metrics[总样本] 1 results recognize_face(img, threshold1.1) # 每张测试图取置信度最高的人脸 if not results: continue top_name, top_dist, _, _ results[0] if person in db_names: # 库内人员识别成自己正确识别成别人或未知误识/拒识 if top_name person: metrics[正确识别] 1 elif top_name 未知: metrics[拒识] 1 else: metrics[误识] 1 else: # 库外人员识别成库内任何一个人都是误识 if top_name ! 未知: metrics[误识] 1 else: metrics[正确识别] 1 total metrics[总样本] print(f总样本数: {total}) print(f正确识别率: {metrics[正确识别] / total:.1%}) print(f误识率: {metrics[误识] / total:.1%}) print(f拒识率: {metrics[拒识] / total:.1%})这个脚本输出的三个指标对应答辩老师最爱问的三个问题系统能不能认出注册过的人、能不能挡住没注册的人、是不是太保守把一个能认出的人也拒绝了。三个指标的合理区间大约是正确识别率 90% 以上、误识率 5% 以下、拒识率 10% 以下。如果正确识别率低先调整对齐代码里的crop_size系数如果误识率高把阈值从 1.1 往下降如果拒识率高往上调。一次调一个变量不要同时动两个。另一个答辩现场的实用技巧是录制一段演示视频作为备用。用系统自己的摄像头实时画面演示一遍再用同一套代码处理预录视频并显示结果。这样即使现场光线突变、摄像头驱动出问题也不会冷场。我带过的学生里凡是做了这步准备的答辩基本都稳。从那以后我每次交付这类人脸识别项目都会强制把标准化预处理 阈值标定 自测脚本这套流程走一遍再交到学生手上。希望这套资源也能帮你把毕设从能跑推到能讲这一步祝顺利。本文还有配套的精品资源点击获取
网站建设高端定制企业官网