人脸识别实战:从 Haar 检测到深度学习特征比对

· 约 6 分钟 ·
本文目录(11 节)

人脸识别是计算机视觉里最有”体感”的应用:手机解锁、门禁打卡、相册自动归类人物,都是它。但先纠正一个普遍的概念混淆——人脸检测和人脸识别是两件事:

  • 检测(Detection):图里有没有人脸?在哪?(画框)
  • 识别(Recognition):这张人脸是谁?(比对身份)

检测是识别的前置步骤。完整的识别系统是一条流水线:检测 → 对齐 → 特征提取 → 特征比对。这篇把这条链路走通。

前置阅读:OpenCV 图像轮廓处理与几何特征提取(OpenCV 基础)、深度学习课程 04:CNN 图像分类(CNN 基础)。OpenCV 系列其他文章见 OpenCV 实战项目。

第一步:人脸检测

传统方案:Haar 级联

OpenCV 自带的 Haar 检测器是 2001 年的经典算法,优点是零依赖、CPU 毫秒级、不用训练:

import cv2

face_cascade = cv2.CascadeClassifier(
    cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
)

img = cv2.imread("group_photo.jpg")
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

faces = face_cascade.detectMultiScale(
    gray, scaleFactor=1.1, minNeighbors=5, minSize=(60, 60)
)

for (x, y, w, h) in faces:
    cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2)

cv2.imwrite("detected.jpg", img)
print(f"检测到 {len(faces)} 张人脸")

两个参数值得理解:scaleFactor 控制图像金字塔的缩放步长(1.1 表示每次缩小 10%),越小检测越细但越慢;minNeighbors 是”候选框要重叠多少次才算数”,调大减少误检、调小减少漏检。

Haar 的局限也很明显:侧脸、遮挡、大角度、暗光下表现差,只适合正脸近景的教学演示。

现代方案:深度学习检测器

实际项目建议直接上 RetinaFace 或 YuNet(OpenCV 4.5+ 内置):

# OpenCV 内置 YuNet,ONNX 模型文件需单独下载
detector = cv2.FaceDetectorYN.create("face_detection_yunet_2023mar.onnx", "",
                                     (320, 320), score_threshold=0.9)
h, w = img.shape[:2]
detector.setInputSize((w, h))
_, faces = detector.detect(img)

YuNet 不仅画框,还返回 5 个关键点(双眼、鼻尖、双嘴角)——这正是下一步”对齐”需要的输入。

第二步:人脸对齐

同一个人,正脸照和侧头照在人眼看来是一个人,但对模型来说像素差异巨大。对齐就是根据关键点把人脸旋转、缩放到标准姿态(比如双眼水平、固定在图像固定位置),消除姿态差异,让后续模型只关注”长相”本身:

import numpy as np

def align_face(img, left_eye, right_eye, output_size=(112, 112)):
    # 根据双眼连线计算旋转角度
    dx, dy = right_eye[0] - left_eye[0], right_eye[1] - left_eye[1]
    angle = np.degrees(np.arctan2(dy, dx))

    # 以双眼中心为原点旋转
    center = tuple(((np.array(left_eye) + np.array(right_eye)) / 2).astype(int))
    M = cv2.getRotationMatrix2D(center, angle, scale=1.0)
    aligned = cv2.warpAffine(img, M, output_size)
    return aligned

对齐是工程上性价比极高的一步:同样的识别模型,加上对齐后准确率明显提升。

第三步:特征提取——人脸变成 512 维向量

核心思想:用一个预训练的深度模型(FaceNet、ArcFace 等)把对齐后的人脸图像压缩成一个特征向量(Embedding)。训练这类模型时用了特殊的损失函数(三元组损失、ArcFace 的加性角度间隔),使得:

同一个人的照片,向量距离近;不同人的照片,向量距离远。

import onnxruntime as ort

# ArcFace ONNX 模型,输入 112x112 对齐人脸,输出 512 维向量
session = ort.InferenceSession("arcface_r100.onnx")

def get_embedding(aligned_face):
    blob = cv2.dnn.blobFromImage(aligned_face, 1.0 / 127.5, (112, 112),
                                 (127.5, 127.5, 127.5), swapRB=True)
    emb = session.run(None, {session.get_inputs()[0].name: blob})[0][0]
    return emb / np.linalg.norm(emb)   # L2 归一化,方便算余弦相似度

归一化后,两个人脸向量之间的余弦相似度就是它们的”相似分数”。

第四步:比对——“这是谁”变成一道查表题

最近的向量并不等于正确身份。开放集场景允许来人不在注册库里,因此必须设置拒绝匹配,并在目标场景的同人、异人样本对上评估阈值。注册人数、光照和摄像头变化后,还要重新检查误接受与误拒绝。

识别阶段就简单了:把库里所有人脸的特征向量预存好,新人脸来了算一遍相似度,找最像的:

def identify(face_emb, database, threshold=0.4):
    best_name, best_score = "未知", -1
    for name, emb in database.items():
        score = float(np.dot(face_emb, emb))   # 归一化后点积=余弦相似度
        if score > best_score:
            best_name, best_score = name, score
    return best_name if best_score >= threshold else "未知", best_score

阈值是识别系统的总开关:设高了,真人识别不出(拒真率高);设低了,陌生人被认错(认假率高)。门禁宁可拒真不可认假(0.5+),相册归类宁可认错不可漏(0.3 左右)。这个值必须用你自己的数据实测标定,别抄别人的。

人脸库大了之后,逐个比对会慢,这时该上向量索引(Faiss / Milvus),毫秒级从百万库里找最近邻——思路和 Milvus + Neo4j 构建 RAG 实战 里的语义检索完全一样。

完整的最小系统

把四步串起来,一个”录入 + 识别”的小系统:

def enroll(img_path, name, database):
    """录入:检测 → 对齐 → 提特征 → 存库"""
    img = cv2.imread(img_path)
    faces = detect_and_align(img)           # YuNet 检测 + 对齐
    if len(faces) != 1:
        raise ValueError(f"录入照必须恰好一张正脸,检测到 {len(faces)} 张")
    database[name] = get_embedding(faces[0])

def recognize(img_path, database):
    """识别:同样流水线,然后比对"""
    img = cv2.imread(img_path)
    for face in detect_and_align(img):
        emb = get_embedding(face)
        yield identify(emb, database)

加上摄像头实时读取(cv2.VideoCapture)就是实时门禁的雏形。活体检测(防照片/视频欺骗)是另一个大话题,商用场景必须加,教学场景先放。

踩坑排查清单

症状原因处理
Haar 一张脸都检测不到图像太小/侧脸/光线暗调 minSize/scaleFactor;换 YuNet
同一个人两次识别分数差很多没做对齐或光照差异大检查对齐步骤;录入多角度照片取均值向量
陌生人全被认成某人阈值太低用真实数据标定阈值,画 ROC 曲线选工作点
真人经常识别失败阈值太高 / 录入照质量差录录入规范(正脸、均匀光照、无遮挡)
识别速度慢逐库暴力比对Faiss/Milvus 建索引
ONNX 模型加载报错输入尺寸/通道顺序不对检查 blobFromImage 的 swapRB 和归一化参数
戴墨镜/口罩全失效训练数据里这类样本少换支持遮挡的模型,或要求配合摘口罩

伦理与合规提醒

人脸是敏感生物特征信息。国内《个人信息保护法》把人脸信息列为敏感个人信息,采集使用需要单独同意,公共场所无差别采集、售楼部人脸识别这类玩法已经被监管明确处罚过。技术学习没问题,做成产品前务必想清楚数据来源的合法性。此外模型在不同人种的准确率差异(算法偏见)也是严肃的工程问题,商用系统要做分人群的精度测试。

练习

  1. 用 Haar 检测器处理一张多人合影,调整 minNeighbors 从 3 到 8,观察误检/漏检变化。
  2. 录入 3 个人各 3 张照片(正脸、微侧、微笑),对比”只存正脸向量”和”存三个向量取均值”两种录入策略的识别准确率。
  3. 在 50 对人脸对上计算相似度分数分布(25 对同人、25 对不同人),画出两组分布,据此选阈值并估算拒真率/认假率。
  4. 给识别系统加一个”未登记人脸自动聚类”功能:未知人脸的向量存起来,用 K-Means 聚类,看看能不能把同一个人的多次出现聚到一团。

面试常问

Q:人脸识别的完整流程是什么? 检测(找到人脸框和关键点)→ 对齐(按关键点摆正到标准姿态)→ 特征提取(深度模型输出 Embedding)→ 比对(与库内向量算相似度,过阈值定身份)。追问活体检测就补:在检测后加防攻击环节(红外、动作配合、纹理分析)。

Q:为什么用余弦相似度而不是欧氏距离? 特征向量经 L2 归一化后,余弦相似度(点积)只反映方向差异,不受向量模长影响;人脸特征的判别信息主要在方向上。且归一化后余弦相似度和欧氏距离单调相关,但余弦计算更直接、阈值含义更直观。

Q: threshold 怎么定? 用验证集画 ROC 曲线:扫描所有候选阈值,统计拒真率(FRR)和认假率(FAR),按业务代价选工作点。安防类 FAR 优先压到万分之一以下,体验类(相册归类)可以适当放松 FRR。

Q:一个人登记多张照片,特征怎么存? 常见做法:存每张照片的向量、识别时取最大相似度;或求平均向量(要先 L2 归一化再平均再归一化)。前者对姿态多样性好,后者省存储省计算。

Q:百万级人脸库怎么做实时比对? 暴力比对不可行,用向量索引:Faiss(本地、IVF/HNSW 索引)或 Milvus(分布式服务化)。思路是把”最近邻搜索”从 O(N) 降到近似 O(log N),精度和速度通过索引参数权衡。


视觉和听觉的单点技术都过了一遍,下一篇聊数据规模大起来之后的管理问题:大数据管理:从单机 DuckDB 到分布式思维。

💬 评论

这套方案在哪一步卡住了?也欢迎补充你的实现方式或踩坑经验。

正在连接评论服务,首次访问可能需要几秒…