简介:一套基于OpenCV与Dlib的人脸识别门禁系统完整源码,主要面向计算机相关专业毕业生及需要完成课程设计、期末大作业的高校学生。源码已经本地编译验证可运行,项目评审分数达到98分,难度适中,适合作为毕业设计或综合实践项目的直接参考与二次开发基底。压缩包共34个文件,涵盖Python逻辑脚本、QML界面文件、中文字体与说明文档,整体大小约29.43MB,结构清晰便于快速定位核心模块。目前已有468人学习使用,内容经过助教老师审定,能够覆盖人脸录入、识别比对、门禁控制等典型流程,附带的README与环境配置脚本可有效降低搭建门槛。整体来说,这是一份兼具完整性与实用性的高分项目资料,能帮助读者节省从零搭建的时间,专注核心功能的调试与优化。
1. 人脸识别门禁系统毕设:为什么 OpenCV + Dlib 依旧是最稳妥的组合
人脸识别门禁系统是 Python 毕设里出现频率最高的选题之一。市面上的方案不少——云厂商 API、深度学习框架重训模型,各有拥护者。但基于 OpenCV 和 Dlib 的传统方案,恰恰最契合毕设的核心诉求:把「检测、对齐、特征提取、比对、控制」这条链路完整跑通,并且能在答辩时讲清楚每个环节的原理。OpenCV 负责图像采集与预处理,Dlib 负责关键点检测和特征描述,两者组合可以纯离线完成从摄像头帧到开门指令的闭环。硬件要求低,普通笔记本加一个 USB 摄像头即可运行,代码量可控,调试过程可见,是毕业设计最稳妥的技术底座。即便后续想迁移到深度学习方案,检测、对齐、数据库、门控这些模块都可以原样保留,替换的只有特征提取这一段,框架性工作不会白做。
2. 基于 OpenCV 和 Dlib 的人脸检测与人脸对齐预处理
2.1 Haar Cascade 与 Dlib HOG 检测器的选型逻辑
人脸检测是人脸识别门禁系统里成本最低但影响最大的一环。OpenCV 自带 Haar Cascade 分类器,通过cv2.CascadeClassifier加载 XML 文件即可使用。它的原理是基于 AdaBoost 训练出的级联分类器,用 Haar 特征快速排除大量非人脸区域。优点是模型文件小、加载快、CPU 上运行速度尚可,缺点是侧脸和暗光环境下漏检率偏高,在门禁这种设备位置偏低、人经常低头看手机的场景里,漏检问题会被放大。
Dlib 的 HOG 检测器是另一种思路。它计算图像梯度方向的直方图作为特征,配合线性 SVM 分类器对滑动窗口打分,dlib.get_frontal_face_detector()返回的即是。相比 Haar Cascade,HOG 对姿态变化的容忍度更好,精度略高,代价是检测速度稍慢,但普通笔记本上处理 640 宽的画面仍然能跑到每秒 20 帧以上,绰绰有余。
在实际的门禁项目里,我一般把两套检测器都保留,默认用 Dlib 的 HOG,因为后续还要依赖 Dlib 的 68 点关键点检测器做对齐,检测和关键点用同一套库,坐标系不用来回转换。Haar Cascade 可以作为快速模式备用——比如在树莓派这类性能受限的板子上优先跑 Haar,省下来的 CPU 留给特征提取。
| 对比项 | OpenCV Haar Cascade | Dlib HOG + SVM |
|---|---|---|
| 模型加载 | 读取 XML,毫秒级 | 内置,无额外文件 |
| 检测精度 | 正脸较好,侧脸漏检 | 对姿态变化更鲁棒 |
| CPU 速度 | 快,适合嵌入式 | 中等,PC 上无压力 |
| 坐标输出 | OpenCV 矩形 (x, y, w, h) | Dlib 矩形,接口不同 |
| 与 68 点关键点配合 | 需手动转换坐标 | 原生配合 |
选型结论:毕业设计场景下优先 Dlib HOG,保底用 Haar。两者的输出都是人脸矩形框,代码里抽象成统一接口,切换检测器时只改一行调用。这个抽象很关键,答辩时你可以现场切换两种检测器演示同一段视频的检测效果差异,是一个不错的加分展示点。
2.2 Dlib 68 点关键点检测与人脸对齐的代码实现
检测到人脸框后直接送去识别,会出两个问题:人脸角度不一致导致特征提取不稳定,框内背景噪声干扰描述子计算。所以中间必须加一步对齐。Dlib 的shape_predictor_68_face_landmarks.dat可以输出人脸 68 个关键点,利用左右眼外角坐标计算旋转角度,把脸摆正。
import cv2 import dlib import numpy as np detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat") def align_face(img, face_rect, output_size=(256, 256)): shape = predictor(img, face_rect) landmarks = np.array([[p.x, p.y] for p in shape.parts()]) # 68 点模型中,36 是左眼外角,45 是右眼外角 left_eye = landmarks[36] right_eye = landmarks[45] # 计算两眼连线与水平轴的夹角,单位转为角度 dy = right_eye[1] - left_eye[1] dx = right_eye[0] - left_eye[0] angle = np.degrees(np.arctan2(dy, dx)) # 以左眼为基准旋转整幅图,使人脸水平 center = tuple(left_eye) rot_matrix = cv2.getRotationMatrix2D(center, angle, scale=1.0) rotated = cv2.warpAffine(img, rot_matrix, (img.shape[1], img.shape[0])) # 在原人脸框范围内裁剪并统一缩放 x, y, w, h = face_rect.left(), face_rect.top(), face_rect.width(), face_rect.height() cropped = rotated[y:y + h, x:x + w] aligned = cv2.resize(cropped, output_size, interpolation=cv2.INTER_CUBIC) return alignedpredictor的两个参数分别是原始图像和检测器返回的dlib.rectangle。关键点索引 36 和 45 是 68 点模型的固定约定,分别对应左右眼外眼角,这个索引映射表在 Dlib 的官方文档里有完整标注。旋转是绕左眼进行的,所以裁剪时用的原始人脸框坐标依然基本吻合。对齐后统一缩放到 256×256,后续特征提取的输入尺寸保持一致,模型输出才稳定。
这一步常被简化掉,但它决定了识别准确率的天花板。同一个人的歪头照和正脸照,提取出的 128 维描述子之间的欧氏距离可能相差 0.1 以上,阈值设在 0.5 时会直接导致识别失败。所以在答辩时被问到「为什么在对齐上花这么多代码」,你可以直接用这个数据回答。
2.3 人脸框重叠过滤与多目标检测处理
摄像头画面里可能同时出现多张脸,比如有人陪同走到门前。Dlib 的 HOG 检测器内部虽然做了非极大值抑制,但为了业务稳定,我习惯在识别前再过滤一次重叠框,避免同一张脸被重复提取描述子,也避免两个人挨太近时框互相嵌套导致的误匹配。
def filter_overlapping_rects(rects, iou_threshold=0.5): if len(rects) <= 1: return rects # 面积大的框优先保留 rects = sorted(rects, key=lambda r: r.width() * r.height(), reverse=True) keep = [] for r in rects: overlap = False for k in keep: x1 = max(r.left(), k.left()) y1 = max(r.top(), k.top()) x2 = min(r.right(), k.right()) y2 = min(r.bottom(), k.bottom()) inter_area = max(0, x2 - x1) * max(0, y2 - y1) union_area = r.width() * r.height() + k.width() * k.height() - inter_area iou = inter_area / union_area if union_area > 0 else 0 if iou > iou_threshold: overlap = True break if not overlap: keep.append(r) return keepiou_threshold取 0.4 到 0.6 之间比较合适。太大会放过重复检测,导致同一张脸匹配两次,数据库写入重复记录;太小可能误删挨得近的两个不同的人。注意 Dlib 矩形框的坐标接口是left()/top()/right()/bottom(),和 OpenCV 的(x, y, w, h)语义不同,写混了会导致裁剪区域完全错位,这是新手最常见的报错来源,排查时要先确认坐标语义。
3. Dlib 人脸特征提取与 128 维描述子比对阈值设定
3.1 用 face_recognition_model_v1 计算人脸描述子
对齐之后进入特征提取环节,这是整个识别系统的核心。Dlib 官方提供了dlib_face_recognition_resnet_model_v1.dat预训练模型,基于 ResNet 结构,输入对齐后的人脸图像,输出 128 维浮点向量。这个向量的设计目标就是让同一个人的不同照片在向量空间里距离相近,不同人距离较远。Dlib 是开源库,模型文件也免费提供,商用和毕设场景都不涉及授权问题。
face_rec_model = dlib.face_recognition_model_v1( "dlib_face_recognition_resnet_model_v1.dat" ) def get_face_descriptor(aligned_face, num_jitter=1): # aligned_face 是 BGR 图像,先转 RGB,模型按 RGB 顺序训练 rgb = cv2.cvtColor(aligned_face, cv2.COLOR_BGR2RGB) descriptor = face_rec_model.compute_face_descriptor(rgb, num_jitter) return np.array(descriptor)这里有两个容易踩的坑。第一,摄像头读出来的是 BGR 顺序,不转成 RGB 直接送入模型,描述子会整体偏移,识别率明显下降,而且这种错误很难通过肉眼观察发现。第二,num_jitter参数:大于 1 时模型会对图像做轻微平移、缩放、旋转后取多次计算结果的平均,num_jitter=10时特征更稳定但耗时接近 10 倍。注册人脸时我用 10,识别时用 1,兼顾精度和速度。如果注册照片光线条件不好,可以提高到 20,用时间换稳定性。
提示:
shape_predictor_68_face_landmarks.dat和dlib_face_recognition_resnet_model_v1.dat需要从 Dlib 官网模型文件列表单独下载,代码不会自动拉取,两个文件合计约 100MB,下载后放在项目根目录或配置文件指定的路径下。
3.2 欧氏距离比对与阈值校准方法
128 维描述子之间的相似度,Dlib 官方建议用欧氏距离衡量,距离越小越相似。实际项目中也有不少人用余弦相似度,归一化后的描述子上两者结论基本一致,选一个用到底就行,不要在同一个项目里混用两种度量方式。真正的难点在阈值设定。
我统计过一组注册库样本:同一个人的不同照片,描述子间欧氏距离通常在 0.35 到 0.5;不同人的距离普遍在 0.7 以上。所以阈值取 0.5 到 0.6 是常见做法。取 0.5 更严格,误识率低但拒识率偏高,用户站在门前刷好几次都进不去;取 0.6 更宽容,通过率高但可能放行长相相似的人,比如双胞胎或者同寝室互相刷脸的情况需要格外注意。
| 距离范围 | 判定建议 | 典型成因 |
|---|---|---|
| < 0.4 | 同一人,高置信 | 现场光照与注册时接近 |
| 0.4 ~ 0.6 | 同一人,低置信 | 角度或光照变化较大 |
| 0.6 ~ 0.8 | 灰色地带 | 不同人但脸型相似 |
| > 0.8 | 不同人 | 基本可判定为陌生人 |
def match_face(query_desc, face_db, threshold=0.55): min_dist = float("inf") matched_name = None for name, ref_desc in face_db.items(): dist = np.linalg.norm(query_desc - ref_desc) if dist < min_dist: min_dist = dist matched_name = name if min_dist <= threshold: return matched_name, min_dist return None, min_distnp.linalg.norm计算的是欧氏距离。这个函数的输出直接决定门禁是否放行,所以阈值不能写死在代码里。我在项目里把它放到配置文件的[recognition]一节,答辩时现场调整阈值并展示误识率与拒识率的变化。具体操作是:用一小段录好的视频反复跑识别,把阈值从 0.4 逐步调到 0.65,记录每个阈值下的通过率和误判次数,画两条曲线。这个实验数据比任何口头解释都有说服力。
3.3 人脸注册库的持久化与增量更新
注册流程决定系统上线后能不能用。最简单的做法是把每个用户的人脸描述子序列化到本地文件,启动时加载到内存。几百人以内,内存里暴力遍历完全够用,每帧比对耗时毫秒级,不需要引入向量数据库。等数据量到了几千人甚至边缘端要承载大量人脸数据的场景,才需要考虑换用支持 ANN 检索的库,这是后话。
import json def save_descriptors(face_db, path="face_db.json"): # ndarray 不能直接 json 序列化,先转 list data = {name: desc.tolist() for name, desc in face_db.items()} with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def load_descriptors(path="face_db.json"): with open(path, "r", encoding="utf-8") as f: data = json.load(f) return {name: np.array(desc) for name, desc in data.items()}保存时np.ndarray必须先转list,加载时再转回ndarray,这个转换遗漏了会在json.dump时直接抛类型错误。如果多人同时注册,建议每个用户独立文件,用学号或工号命名,避免单个 JSON 过大导致加载变慢。增量更新的含义是:不需要重启整个门禁系统,新用户注册完成后,只需要把新的描述子 dict 重新save_descriptors一次,主循环里维护一个全局face_db变量,加载完就生效。
注册环节每个用户采集 3 到 5 张不同角度的照片,分别计算描述子后取平均,作为基准向量。单张照片注册在遇到光线变化时很容易失效,尤其是上午和傍晚的光照条件差异很大,这个细节在答辩演示时特别容易暴露问题。
4. 门禁系统完整链路:摄像头帧到继电器开门指令的整合
4.1 模块划分与主循环控制逻辑
人脸识别门禁系统的代码组织,我习惯拆成四个模块:图像采集模块负责打开摄像头并读取帧;识别模块负责检测、对齐、特征提取和比对;控制模块负责发送开门信号;数据模块负责记录识别日志。模块之间用简单函数调用连接,不引入复杂框架,答辩时数据流一画就清楚,别人问起某个函数在哪也能立刻定位。
主循环的时序问题比想象中多。摄像头读取速度约 30 FPS,完整识别速度只有 2 到 5 FPS,每一帧都识别会让 CPU 飙满且结果重复。常见做法是隔帧识别,每 5 帧做一次完整检测,其余帧只维持画面读取。这个 trade-off 要在代码里显式体现,而不是依赖偶然的 CPU 调度。
import cv2 import time def main_loop(recognition_callback, door_control_callback): cap = cv2.VideoCapture(0) if not cap.isOpened(): print("无法打开摄像头,检查设备编号") return frame_interval = 5 frame_count = 0 last_open_time = 0 cooldown = 10 while True: ret, frame = cap.read() if not ret: time.sleep(0.1) continue frame_count += 1 if frame_count % frame_interval != 0: continue # 缩放到 640 宽,降低检测耗时 h, w = frame.shape[:2] small = cv2.resize(frame, (640, int(h * 640 / w))) name, distance = recognition_callback(small) now = time.time() if name is not None and now - last_open_time > cooldown: door_control_callback(name, distance) last_open_time = now cap.release()frame_interval控制识别频率,5 帧一识别意味着每秒约 6 次识别尝试,足够覆盖一个人走近到离开的全过程。cooldown防止识别成功后连续触发开门,否则继电器频繁动作,寿命会明显缩短,而且门会反复开合看起来很业余。先缩放到 640 宽度再送入检测器,检测耗时下降明显,识别精度几乎不受影响,因为后续对齐会裁剪出 256×256 的人脸区域,缩放不影响这个区域的细节。
4.2 识别日志与考勤数据表设计
识别结果需要落到数据库里,否则「谁在几点几分通过了门禁」无法追溯。数据库建议用 SQLite,零配置、单文件,答辩演示时直接把 db 文件拷走即可,不需要在演示机器上装 MySQL。SQLite 的性能在这个数据量级下完全够用,一天几万条日志也不会有压力。
CREATE TABLE IF NOT EXISTS access_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, distance REAL NOT NULL, allowed INTEGER NOT NULL DEFAULT 1, recognize_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS attendance ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, first_seen_time TIMESTAMP, last_seen_time TIMESTAMP, date TEXT );access_log记录每次识别尝试,allowed标记是否放行,既能统计通过率,也能排查误判样本。attendance按天聚合每个人的首次和末次识别时间,作为考勤依据。CURRENT_TIMESTAMP默认是 UTC 时间,要显示北京时间的话,写入前在 Python 侧用datetime.now()生成字符串再存,避免时区偏移。这个坑很容易被忽略,等到答辩时发现日志时间比实际时间晚了 8 小时才意识到,会很尴尬。
写入日志的代码放在识别回调里,每次识别尝试都有据可查。查询考勤用GROUP BY name, date聚合first_seen_time和last_seen_time,两张表配合,可以回答「今天谁没来、谁迟到了、识别失败的是谁」这类答辩时大概率会被问到的问题。
4.3 串口继电器控制与无硬件演示方案
识别通过后怎么开门,取决于硬件方案。两种主流做法:树莓派上用 GPIO 驱动继电器;PC 通过 USB 转串口控制 Arduino 或单片机。毕业设计展示阶段,串口方案更稳妥,笔记本没有 GPIO,而 USB 转串口模块很便宜,驱动的兼容性也更好。
import serial import time class DoorController: def __init__(self, port="COM3", baudrate=9600, open_seconds=3): self.ser = serial.Serial(port, baudrate, timeout=1) self.open_seconds = open_seconds def open_door(self): # 协议约定:'O' 开门,'C' 关门 self.ser.write(b'O') time.sleep(self.open_seconds) self.ser.write(b'C')串口输出要先定义字节协议,O和C是 PC 端与单片机端的事先约定,单片机收到后拉高或拉低继电器。serial.Serial打开串口后,write必须传字节类型,字符串要加b前缀或先encode(),直接传字符串会报类型错误。没有真实硬件时,可以在单片机端用 LED 模拟继电器动作,灯亮代表开门,答辩时同样能演示完整的信号链路,而且比对着空气讲代码有说服力得多。
open_seconds控制门锁保持时间,一般是 3 到 5 秒。太长有尾随风险,后面的人没刷卡就跟着进来了;太短人还没过门门就关了,感应模块如果装在门框上还会把正在通过的人夹住。这个参数应该在配置项里暴露出来,方便现场微调。
5. 人脸识别门禁系统落地收尾:环境搭建、参数校准与投票优化
5.1 三步装好 OpenCV 与 Dlib 运行环境
Dlib 的安装在 Windows 上是个经典痛点,直接pip install dlib多数会触发源码编译,要求系统装有 Visual Studio 的 C++ 构建工具,编译过程耗时很长。想快速跑通,优先找预编译的 wheel 包,或先装face_recognition库自动拉取适合当前平台的 Dlib 预编译版本。
pip install opencv-python numpy face_recognition dlib python -c "import cv2, dlib; print(cv2.__version__, dlib.__version__)"遇到ModuleNotFoundError: No module named 'cv2',多半是包装进了另一个 Python 环境,用python -m pip install可以保证装进当前解释器。项目启动按注册、识别两步走:python register.py --name 张三 --photos ./photos/zhangsan/写入face_db.json,再python main.py --config config.ini打开摄像头开始识别。这两个命令分离,答辩时可以现场注册陌生面孔再验证识别,展示效果更好。
5.2 识别阈值与光照问题的现场校准
阈值用自采数据校准:采集注册人 20 张不同角度照片,逐张计算与基准描述子的欧氏距离并画分布图。距离集中在 0.4 以下,阈值设 0.5;集中在 0.5 以上,阈值就要往上提,否则拒识率居高不下,演示时反复失败会很影响答辩节奏。
光照是门禁最不可控的因素。逆光时人脸过暗,Dlib 检测器经常漏检。缓解办法是加补光灯,或在检测前对整帧做cv2.equalizeHist直方图均衡化。注意均衡化要覆盖人脸区域,整图操作容易让背景过曝,反而干扰关键点定位。
5.3 用滑动窗口投票过滤单帧误判
单帧识别结果抖动是门禁最常见的误判来源:前一帧识别成功、后一帧识别失败,或两秒内交替匹配到不同人,这类情况在光线变化和人员走动的过程中经常出现。给识别结果加一个滑动窗口投票,稳定性会明显提升。
class VoteRecognizer: def __init__(self, window_size=5, vote_threshold=3): self.window = [] self.window_size = window_size self.vote_threshold = vote_threshold def add_result(self, name, distance): self.window.append((name, distance)) if len(self.window) > self.window_size: self.window.pop(0) counter = {} for n, _ in self.window: counter[n] = counter.get(n, 0) + 1 top_name = max(counter, key=counter.get) if counter[top_name] >= self.vote_threshold and top_name is not None: return top_name return None窗口大小 5 表示连续 5 次识别结果参与投票,阈值 3 表示至少 3 次为同一人才判有效。它过滤掉光线抖动造成的偶发错误,代价是首次识别延迟约 1 秒,这个延迟在门禁场景里完全可接受。窗口和阈值分别设置的好处是,可以独立调整灵敏度:窗口越大越平滑,阈值越高越严格。答辩时把加投票前后的识别日志对比展示,能直观体现系统的工程化水平。
本文还有配套的精品资源,点击获取