项目实战:从零搭建智能门禁系统

人脸检测 + 识别 + 权限管理 + 日志记录全流程。

说实话,做嵌入式这么多年,我接过不少“智能门禁”的需求。有的客户拿个树莓派就想上,有的非要上云端识别。但真正要在 Jetson 上落地一个可用的门禁系统,你得把整个链路打通——从摄像头抓图,到人脸检测,再到特征比对、权限判断,最后还得把谁进谁出记下来。

这一章,我就带你手搭一套完整的门禁系统。咱们不玩虚的,直接上代码。

1. 系统架构概览

先看整体逻辑。我习惯把门禁系统拆成四个模块:

  • 采集层:USB 摄像头或 CSI 摄像头,负责抓取视频帧
  • 推理层:Jetson 上的 TensorRT 加速,跑人脸检测 + 特征提取
  • 业务层:权限判断、白名单管理、开门信号输出
  • 记录层:日志写入本地文件或 SQLite 数据库

嗯,这里要注意:千万别把识别和检测混在一个模型里做。我见过有人用一个大模型同时干两件事,结果 Jetson Nano 上跑不到 5fps。分开做,检测用轻量模型,识别用特征向量比对,这样效率高得多。

核心原则:检测模型负责“找到人脸”,识别模型负责“认出是谁”。两个模型串行跑,但用不同的推理引擎实例。

摄像头采集 USB / CSI 推理引擎 人脸检测 (RetinaFace) 特征提取 (ArcFace) TensorRT 加速 业务逻辑 特征比对 权限判断 开门信号 日志记录 SQLite / 本地文件 白名单库 特征向量存储 图例 采集层 推理层 业务层 记录层

2. 硬件选型与连接

我这次用的是 Jetson Orin Nano,配一个 USB 摄像头(1080p 30fps)。为什么不用 CSI?因为 USB 摄像头调试方便,换镜头也容易。你想想看,如果现场安装时发现焦距不对,USB 摄像头拧下来换一个就行,CSI 就得拆排线。

组件 型号 备注
主控 Jetson Orin Nano 8GB JetPack 6.0
摄像头 Logitech C920 USB 2.0,支持 1080p
继电器模块 1路 5V 低电平触发 控制电锁
存储 NVMe SSD 256GB 存放模型和日志

小提示:继电器模块的 GPIO 引脚记得接上拉电阻。我曾经因为没接上拉,导致 Jetson 启动时继电器误触发,门锁直接弹开了……嗯,那场面挺尴尬的。

3. 人脸检测模块

检测这块,我选的是 RetinaFace 的 TensorRT 版本。为什么不用 MTCNN?因为 MTCNN 在 Jetson 上跑三阶段级联,延迟不稳定。RetinaFace 单阶段搞定,配合 TensorRT 能跑到 30ms 以内。

代码实现如下:

import cv2
import numpy as np
import tensorrt as trt
import pycuda.driver as cuda

class FaceDetector:
    def __init__(self, engine_path):
        # 加载 TensorRT 引擎
        self.logger = trt.Logger(trt.Logger.WARNING)
        with open(engine_path, 'rb') as f:
            runtime = trt.Runtime(self.logger)
            self.engine = runtime.deserialize_cuda_engine(f.read())
        self.context = self.engine.create_execution_context()
        
        # 分配 GPU 内存
        self.inputs, self.outputs, self.bindings = [], [], []
        for binding in self.engine:
            size = trt.volume(self.engine.get_binding_shape(binding))
            dtype = trt.nptype(self.engine.get_binding_dtype(binding))
            host_mem = cuda.pagelocked_empty(size, dtype)
            device_mem = cuda.mem_alloc(host_mem.nbytes)
            self.bindings.append(int(device_mem))
            if self.engine.binding_is_input(binding):
                self.inputs.append({'host': host_mem, 'device': device_mem})
            else:
                self.outputs.append({'host': host_mem, 'device': device_mem})
        
        self.stream = cuda.Stream()
    
    def detect(self, image):
        # 预处理:resize 到 640x640,归一化
        img = cv2.resize(image, (640, 640))
        img = img.astype(np.float32) / 255.0
        img = img.transpose(2, 0, 1)  # HWC -> CHW
        img = np.expand_dims(img, axis=0)
        
        # 拷贝到 GPU
        cuda.memcpy_htod_async(self.inputs[0]['device'], img, self.stream)
        
        # 推理
        self.context.execute_async_v2(bindings=self.bindings, stream_handle=self.stream.handle)
        
        # 取回结果
        cuda.memcpy_dtoh_async(self.outputs[0]['host'], self.outputs[0]['device'], self.stream)
        self.stream.synchronize()
        
        # 后处理:解析检测框
        output = self.outputs[0]['host']
        boxes = self.parse_output(output)
        return boxes
    
    def parse_output(self, output):
        # 简化版后处理,实际需要 NMS
        boxes = []
        for i in range(0, len(output), 6):
            conf = output[i+4]
            if conf > 0.5:
                x1, y1, x2, y2 = output[i:i+4]
                boxes.append([x1, y1, x2, y2, conf])
        return boxes

注意:TensorRT 引擎文件是跟 Jetson 的 CUDA 版本绑定的。你在 Orin 上生成的 .engine 文件,拿到 Xavier 上跑不了。反过来也一样。所以模型转换这一步,必须在目标设备上做。

4. 人脸识别与特征提取

检测到人脸之后,下一步是提取特征向量。我用的是 ArcFace 模型,输出 512 维的 float 向量。说白了,就是把一张人脸压缩成一个“数字指纹”。

比对的时候,计算当前人脸向量和白名单库中所有向量的余弦相似度。超过阈值(我一般设 0.65)就算匹配成功。

import numpy as np
from scipy.spatial.distance import cosine

class FaceRecognizer:
    def __init__(self, engine_path, whitelist_path):
        # 加载 TensorRT 引擎(类似检测模块)
        self.engine = self.load_engine(engine_path)
        # 加载白名单特征库
        self.whitelist = np.load(whitelist_path, allow_pickle=True).item()
        # self.whitelist 结构: { "name": feature_vector }
    
    def extract_feature(self, face_img):
        # 预处理:对齐、归一化
        aligned = self.align_face(face_img)
        # 推理得到特征向量
        feature = self.infer(aligned)
        # L2 归一化
        feature = feature / np.linalg.norm(feature)
        return feature
    
    def match(self, feature):
        best_name = "unknown"
        best_score = 0.0
        for name, ref_feat in self.whitelist.items():
            sim = 1 - cosine(feature, ref_feat)
            if sim > best_score:
                best_score = sim
                best_name = name
        if best_score < 0.65:
            return "unknown", best_score
        return best_name, best_score

关键点:特征向量一定要做 L2 归一化。不做归一化的话,不同光照条件下同一个人的特征距离会很大。我刚开始做的时候没注意,白天能识别,晚上就认不出了。归一化之后,光照鲁棒性明显提升。

5. 权限管理与开门逻辑

权限管理其实不复杂。白名单里每个人可以带一个权限等级:

  • admin:全天可进
  • staff:工作日 8:00-20:00 可进
  • visitor:需管理员远程审批

判断逻辑:

import datetime

def check_access(person_name, level):
    now = datetime.datetime.now()
    if level == "admin":
        return True
    elif level == "staff":
        if now.weekday() < 5 and 8 <= now.hour < 20:
            return True
        else:
            return False
    elif level == "visitor":
        # 这里可以触发远程审批流程
        return False
    return False

开门信号通过 GPIO 控制继电器。Jetson 的 GPIO 用 jetson-gpio 库操作:

import RPi.GPIO as GPIO  # 兼容 Jetson

RELAY_PIN = 18
GPIO.setmode(GPIO.BCM)
GPIO.setup(RELAY_PIN, GPIO.OUT)
GPIO.output(RELAY_PIN, GPIO.HIGH)  # 默认高电平,锁住

def open_door():
    GPIO.output(RELAY_PIN, GPIO.LOW)  # 低电平触发
    time.sleep(2)                     # 保持开门 2 秒
    GPIO.output(RELAY_PIN, GPIO.HIGH) # 重新锁住

安全提醒:千万不要把继电器直接接 Jetson 的 5V 引脚。Jetson 的 GPIO 是 3.3V 逻辑电平,继电器模块需要 5V 驱动。中间加一个电平转换模块,或者用光耦隔离。我曾经偷懒直接接,结果烧了一个 GPIO 口……

6. 日志记录模块

日志记录我用 SQLite,轻量、零配置。每条日志包含:时间、人名、是否通过、相似度分数。

import sqlite3
import datetime

class AccessLogger:
    def __init__(self, db_path="access.db"):
        self.conn = sqlite3.connect(db_path)
        self.cursor = self.conn.cursor()
        self.cursor.execute('''
            CREATE TABLE IF NOT EXISTS access_log (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                timestamp TEXT,
                person_name TEXT,
                access_granted INTEGER,
                confidence REAL
            )
        ''')
        self.conn.commit()
    
    def log(self, name, granted, confidence):
        now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        self.cursor.execute(
            "INSERT INTO access_log (timestamp, person_name, access_granted, confidence) VALUES (?, ?, ?, ?)",
            (now, name, 1 if granted else 0, confidence)
        )
        self.conn.commit()
    
    def query_recent(self, limit=10):
        self.cursor.execute("SELECT * FROM access_log ORDER BY id DESC LIMIT ?", (limit,))
        return self.cursor.fetchall()

小技巧:日志文件最好放在 SSD 上,不要放 SD 卡。SD 卡的写入寿命有限,频繁写日志容易坏。我有一块 Jetson Nano 的 SD 卡就是被日志写挂的。

7. 主循环:把所有模块串起来

最后,写一个主循环把上面所有模块串起来。嗯,这里要注意帧率控制——检测和识别不能每帧都做,否则 Jetson 会过热降频。

def main():
    cap = cv2.VideoCapture(0)
    detector = FaceDetector("retinaface.engine")
    recognizer = FaceRecognizer("arcface.engine", "whitelist.npy")
    logger = AccessLogger()
    
    frame_count = 0
    while True:
        ret, frame = cap.read()
        if not ret:
            break
        
        # 每 3 帧做一次检测
        if frame_count % 3 == 0:
            boxes = detector.detect(frame)
            for box in boxes:
                x1, y1, x2, y2, conf = box
                face = frame[y1:y2, x1:x2]
                if face.size == 0:
                    continue
                
                # 提取特征
                feature = recognizer.extract_feature(face)
                name, score = recognizer.match(feature)
                
                # 权限判断
                if name != "unknown":
                    level = whitelist.get(name, {}).get("level", "visitor")
                    granted = check_access(name, level)
                    if granted:
                        open_door()
                    logger.log(name, granted, score)
                else:
                    logger.log("unknown", False, score)
        
        frame_count += 1
        
        # 显示画面(调试用)
        cv2.imshow("Access Control", frame)
        if cv2.waitKey(1) & 0xFF == ord('q'):
            break
    
    cap.release()
    cv2.destroyAllWindows()

性能数据:在 Jetson Orin Nano 上,检测 + 识别单次耗时约 45ms(检测 30ms + 识别 15ms)。跳帧处理后,整体系统可以稳定跑在 10fps 左右,完全满足门禁场景需求。

8. 部署与测试

部署时我建议先做离线测试:用录好的视频文件代替摄像头,验证识别准确率和权限逻辑。没问题了再接真实摄像头。

测试用例:

  • 已注册人员正常通行 → 应开门
  • 已注册人员非工作时间 → 应拒绝
  • 未注册人员 → 应拒绝并记录
  • 多人同时出现在画面 → 只识别最靠近的那个人

这些用例跑通了,系统基本就稳了。

好了,这一章的内容就到这里。从摄像头采集到日志记录,整个链路你应该已经清楚了。代码都在上面,拿回去改改就能用。如果你在实际部署中遇到什么问题,欢迎交流。


公众号:蓝海资料掘金营,微信deep3321