ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

人脸识别工程流水线:YOLOv5检测、ArcFace对齐比对与活体检测

人脸识别工程流水线:YOLOv5检测、ArcFace对齐比对与活体检测

简介:这是一份围绕人脸识别全流程搭建的学习实践包,面向具备一定 Python 与深度学习基础、希望把课堂理论落到可运行项目的开发者与学生。内容以 YoloV5 完成人脸检测,结合 ArcFace 的角度间隔损失提取高区分度特征,并接入活体检测模块辨识照片、视频等伪造攻击,覆盖训练、测试与数据生成等环节,适合课程设计、毕业项目与算法入门复现。压缩包共 54 个文件,约 3.4MB,以 26 个 Python 源码为主,辅以 9 个 pyc、7 个 YAML 配置、2 个 pth 权重及少量图片和说明文档,目录按检测、识别、活体检测与模型权重分区,便于按模块阅读和调试。已有 179 人学习。读者可据此理解从人脸检测、特征比对到活体判定的系统链路,参考完整项目架构、参数配置与模型加载方式,并借助现成脚本完成数据准备和效果验证,较快建立可扩展的人脸识别工程雏形。

1. 从一张工位照片到门禁结果:这套人脸识别流水线到底做了什么

先说个反直觉的结论:人脸识别项目里最容易让整个流程失效的,往往不是模型精度不够,而是对齐那一步差了几像素——检测框稍微歪一点,ArcFace 提取出的特征就会从 0.7 的相似度掉到 0.2,接下来无论怎么调阈值都救不回来。这个压缩包做的事情,就是把人脸识别拆成检测、对齐、活体、特征比对四段,每段一个独立模块、一份独立权重,跑通之后你能清楚看到误差是哪一段带进来的,而不是面对一个一大坨代码构成的整体干瞪眼。

YOLOv5 负责人脸框和五点关键点,ArcFace 负责把对齐后的人脸压成 512 维向量做余弦比对,活体检测在识别之前先做一次真伪分类,挡住手机翻拍和打印照片。三件套均为开源方案,权重可直接下载,不需要自己从头训练。适合两类人:一类是想把「人脸识别」从调包 Demo 变成能讲清链路的学习者,另一类是手里有具体场景(考勤、门禁、签到、相册聚类),需要一个能改、能换库、能接业务接口的起点工程。下面按目录结构、环境搭建、核心代码、常见问题排查、二次开发五段拆。

2. 工程结构拆解:detector、recognizer、anti_spoof 三段式目录怎么分工

2.1 一张人脸进来,经过四道关

整条链路的顺序是固定的:检测 → 关键点 → 活体 → 对齐 → 特征 → 比对。这里有两个容易被写反的地方需要记住。

第一,活体判定要放在特征提取之前。原因很实际:活体模型输入是 80×80 的小图,一次前向大约几毫秒;ArcFace 输入 112×112,R50 主干在 CPU 上要几十毫秒。如果先识别再判活体,一张翻拍照片也会完整走完特征提取和库检索,多花的算力白扔。把活体做成短路开关,判定是假脸就直接返回 unknown,后面的活体判定和库检索全部跳过。

第二,对齐必须夹在检测和识别之间,不能省。ArcFace 这一类基于 margin 损失训练出来的模型,训练时所有样本人脸都被五点相似变换摆到固定的 112×112 画布上——左眼在 (38.29, 51.70)、右眼在 (73.53, 51.50) 这种硬编码坐标。推理时如果你的输入没有摆到同一个位置,模型看到的就是一个训练分布外的样本,特征自然对不上。我见过太多人把检测框直接 resize 成 112×112 就送进去,跑出来相似度全在 0.1 到 0.2 之间,然后怀疑权重有问题。

检测框本身只保证「框住脸」,不保证「脸在框里是正的、大小一致的」。侧脸、抬头、低头的时候框会跟着变形,直接裁剪会把人脸拉伸。五点关键点就是用来做相似变换的锚,cv2.estimateAffinePartial2D只解旋转、缩放、平移四个自由度,不引入剪切,五官比例不会被拉歪。

2.2 目录结构与模块职责

拿到包先别急着跑app.py,把目录过一遍,后面改哪块心里有数。典型结构是这样:

face_lab/ ├── configs/ │ └── config.yaml # 阈值、路径、设备全在这儿,改参数不用动代码 ├── weights/ │ ├── yolov5n-face.pt # 检测 + 五点,小模型够用 │ ├── arcface_r50.onnx # 特征提取,onnx 比 pt 好部署 │ └── minifasnet.onnx # 静默活体,3 分类 ├── core/ │ ├── detector.py # letterbox + 前向 + NMS + 坐标还原 │ ├── recognizer.py # 对齐 + 归一化 + 特征 + L2 │ ├── anti_spoof.py # 多尺度裁剪 + 分类 + 融合 │ └── pipeline.py # 把上面三个串起来,唯一对外入口 ├── utils/ │ ├── face_align.py # ARCFACE_DST 模板 + norm_crop │ ├── ops.py # xywh2xyxy / nms / letterbox │ ├── gallery.py # 人脸库读写与检索 │ └── draw.py # 画框、画点、贴名字 ├── data/ │ └── gallery/ # 一人一个子目录,放建库用的原图 ├── enroll.py # 建库脚本:图片 -> embeddings.npy ├── app.py # 摄像头/视频/图片三种输入的演示入口 └── requirements.txt

这套划分的核心思路是每个模块只对外暴露一个方法,输入输出都是 numpy 和 dict,不互相 import。detector不知道有 ArcFace 这回事,recognizer也不知道活体存在,所有编排逻辑集中在pipeline.py。好处是你想换检测器(比如换 RetinaFace 拿更稳的五点),只要新写一个类,输出{"bbox": [...], "kps": (5,2), "score": float}这三样,pipeline 一行不用改。

2.3 模块之间的接口约定

把接口固化成抽象基类,比写注释管用——换模型的时候 IDE 会直接告诉你少实现了什么:

from abc import ABC, abstractmethod import numpy as np class BaseDetector(ABC): @abstractmethod def detect(self, img: np.ndarray) -> list[dict]: """输入 BGR 原图,返回 [{"bbox": [x1,y1,x2,y2], "kps": np.ndarray(5,2), "score": float}]""" class BaseRecognizer(ABC): @abstractmethod def embed(self, aligned_bgr: np.ndarray) -> np.ndarray: """输入已对齐的 112x112 BGR 图,返回 L2 归一化后的 512 维向量""" class BaseAntiSpoof(ABC): @abstractmethod def predict(self, img: np.ndarray, bbox: list) -> float: """输入原图 + 人脸框,返回真人概率 0~1"""

三个约定里最容易忽略的是「BGR 还是 RGB」。OpenCV 读进来是 BGR,YOLOv5 训练时用的也是 BGR 转 RGB 后的数据,ArcFace 官方实现同样吃 RGB。两个模型都要 RGB,但转换时机不同——检测那一步在 letterbox 之后转,识别那一步在对齐之后转。这个约定如果不写死,后面接新模型必踩坑。

2.4 为什么是这三件套

环节这个项目用的常见替代选它的理由
检测 + 关键点YOLOv5-faceMTCNN、RetinaFace一次前向同时出框和五点,速度比 MTCNN 快一个量级,n/s 版本在 1080p 上能跑实时
特征提取ArcFace (R50)FaceNet、CosFacemargin 系列在同样数据量下类间区分度更好,512 维够用且检索快
活体MiniFASNet 静默式眨眼/摇头动作式不需要用户配合,单帧即可判定,适合门禁这种「走过去就要出结果」的场景

动作式活体(让用户眨眼、张嘴)实现简单但体验差,且需要连续帧状态机;静默式只要一张图,工程上更好嵌。代价是对强光、逆光比较敏感,这个后面排查章节会讲。

3. 环境与权重就位:把一张图跑出姓名标签的最小闭环

3.1 环境准备:先确认你装的是 GPU 版

依赖不多,但有一个必踩的坑:直接pip install torch装到的是 CPU 轮子,推理一帧四百毫秒,你会以为模型有问题。先建环境再按显卡的 CUDA 版本装对应的轮子:

conda create -n facelab python=3.9 -y conda activate facelab # 先看显卡驱动支持的 CUDA 版本,再决定装哪个轮子 nvidia-smi # 按上面的版本号去 PyTorch 官网拿对应命令,别照抄别人的 pip install torch torchvision # 其余依赖 pip install opencv-python numpy onnxruntime-gpu pyyaml tqdm # 装完立刻自检,这行输出 False 后面全是白折腾 python -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))"

onnxruntime-gpu和onnxruntime不能共存,两个都装会互相覆盖,报Provider not found之类的错。如果你只打算 CPU 跑,装onnxruntime就行,但记得把 config 里的device改成cpu,否则初始化 provider 时直接异常。

跑之前再确认三件事:cv2.__version__别太老(老版本没有estimateAffinePartial2D的某些重载)、weights/下三个文件都在且非 0 字节、data/gallery/下至少有一个人的子目录。这三条不满足,app.py起来的第一个报错基本就跑不掉。

3.2 配置文件:所有可调参数集中在一处

参数散落在代码里是二次开发的头号敌人。配置统一放configs/config.yaml:

detector: weights: weights/yolov5n-face.pt imgsz: 640 # letterbox 目标边长,必须和训练时一致 conf_thres: 0.5 # 低于这个值的人脸框直接丢,误检多就往上调 iou_thres: 0.45 # NMS 阈值,人脸密集场景可以提到 0.5 max_det: 10 # 单帧最多保留几张脸,门禁场景 1~3 就够 recognizer: weights: weights/arcface_r50.onnx input_size: 112 # ArcFace 的标准输入,别改 sim_thres: 0.35 # 余弦相似度阈值,最需要你自己标定的一个数 anti_spoof: weights: weights/minifasnet.onnx scales: [2.7, 4.0] # 裁剪倍数,两个尺度结果取平均更稳 real_thres: 0.6 # 真人概率低于这个值判定为假脸 runtime: device: cuda half: false # 半精度,onnx 路径下先别开,容易出 NaN

sim_thres是整套系统里最需要自己调的参数,后面的排查章节会单说。scales这两个数不是随便定的——2.7 倍裁剪能带进一点背景和发际线,4.0 倍裁剪把整张脸连同脖子一起框进去,两个尺度互补,单尺度在侧脸上容易翻车。

3.3 建库:把 gallery 目录变成可检索的向量表

识别不是「拿两张图比一下」,而是「拿一张图去库里找最近的」。所以先要有库。约定data/gallery/下一人一个子目录,目录名就是返回的姓名:

data/gallery/ ├── 张三/ │ ├── 01.jpg │ └── 02.jpg └── 李四/ └── 01.jpg

建库脚本做三件事:逐张检测取最大人脸、对齐提特征、L2 归一化后堆成矩阵存盘。每张图还要过一道质量门——检测置信度低于 0.7、关键点超出图像边界、人脸宽度小于 80 像素的,直接跳过并打日志。这条过滤非常关键,用一张侧脸建库,后面这个人永远认不准,你还会怀疑是模型的问题。

# enroll.py 核心片段 embs, metas = [], [] for name_dir in sorted(GALLERY_DIR.iterdir()): for img_path in name_dir.glob("*.jpg"): img = cv2.imread(str(img_path)) faces = detector.detect(img) if not faces: continue face = max(faces, key=lambda f: (f["bbox"][2]-f["bbox"][0]) * (f["bbox"][3]-f["bbox"][1])) w = face["bbox"][2] - face["bbox"][0] if face["score"] < 0.7 or w < 80: # 质量门:置信度 + 分辨率 print(f"[skip] {img_path.name} score={face['score']:.2f} w={w:.0f}") continue aligned = norm_crop(img, face["kps"], 112) embs.append(recognizer.embed(aligned)) # 已 L2 归一化 metas.append({"name": name_dir.name, "img": img_path.name}) np.save("data/embeddings.npy", np.stack(embs).astype(np.float32)) json.dump(metas, open("data/meta.json", "w"), ensure_ascii=False, indent=2)

存下来的embeddings.npy是[N, 512]的矩阵,meta.json是同长度的列表,一一对应。同一个人放多张图是可以的——检索时取最相似的那条返回姓名,相当于多模板匹配,比只存一张稳。但别放太多张同一个角度的,重复模板对召回没有增益,只会拖慢检索。

3.4 三种输入源跑通第一帧

app.py用--source参数区分图片、视频、摄像头:

# 单张图片,先确认整条链路是通的 python app.py --source data/test/demo.jpg --save out/demo_result.jpg # 摄像头,0 是默认设备号 python app.py --source 0 --show # 视频文件 python app.py --source data/test/entry.mp4 --save out/entry_result.mp4

第一次跑一定先用单图。图能出正确姓名,说明权重、对齐、库、阈值全部没问题,再去开摄像头。反过来先开摄像头,一堆实时性问题(掉帧、缓冲堆积、窗口卡死)会盖住真正的问题,你根本分不清是模型错了还是取流错了。

输出目录会生成带框带点的结果图,画面上同时打印姓名和相似度分数。建议调试期把相似度也画上,哪怕最后上线要去掉——它是你判断「差一点认对」还是「完全认错」的唯一依据,比你盯着框猜靠谱得多。

4. 核心代码逐段读:YOLOv5 五点检测、ArcFace 对齐比对、活体分类

4.1 检测输出解析与 NMS 后处理

YOLOv5-face 的输出是[N, 15]:前 4 列是cx, cy, w, h,第 5 列是目标置信度 obj,第 6 列是类别分数 cls,后 10 列是五个关键点的 x、y 坐标。这个排列顺序各家魔改版略有差异,拿到权重先用一张图打印前几行,确认列的含义再写后处理,别信文档。

import torch import numpy as np from torchvision.ops import nms from utils.ops import letterbox, xywh2xyxy @torch.no_grad() def detect_faces(model, img, conf_thres=0.5, iou_thres=0.45, max_det=10, device="cuda"): canvas, ratio, pad_x, pad_y = letterbox(img, 640) # BGR -> RGB, HWC -> CHW, 归一化到 0~1 x = torch.from_numpy(canvas[:, :, ::-1].copy()).permute(2, 0, 1)[None].float().to(device) / 255.0 out = model(x)[0] # [N, 15] out = out[out[:, 4] > conf_thres] # 先按 obj 粗筛,减少 NMS 的输入量 if out.numel() == 0: return [] boxes = xywh2xyxy(out[:, :4]) scores = out[:, 4] * out[:, 5] # 单类场景下等价于 conf,多类要分开算 keep = nms(boxes, scores, iou_thres)[:max_det] # keep 是索引张量 results = [] for i in keep.tolist(): # 关键点必须从 640 的 letterbox 坐标系映射回原图,否则点会整体偏移 kps = out[i, 6:16].reshape(5, 2).clone() kps[:, 0] = (kps[:, 0] - pad_x) / ratio kps[:, 1] = (kps[:, 1] - pad_y) / ratio b = boxes[i].tolist() b = [(b[0]-pad_x)/ratio, (b[1]-pad_y)/ratio, (b[2]-pad_x)/ratio, (b[3]-pad_y)/ratio] results.append({"bbox": b, "score": float(scores[i]), "kps": kps.cpu().numpy()}) return results

几个参数的实际影响:conf_thres设 0.5 是个折中,调到 0.3 会把背景里的纹理误检成人脸,调到 0.8 侧脸和戴口罩的脸就漏了。iou_thres在多人场景下要注意——人脸密集时两个框的重叠度可能超过 0.45,调低会误删相邻的人脸,通常 0.45 到 0.5 之间比较合适。max_det只是截断,不参与筛选逻辑,门禁场景设 3 就够,设 10 只是以防万一。

坐标还原这一步是纯数学,但漏掉(x - pad) / ratio里的减法是最常见的 bug:框会整体朝右下偏,偏差量正好等于 padding 的像素数。小图看不出来,1080p 上偏一百多像素,你会以为是模型输出错了。

4.2 五点对齐:ArcFace 的隐形入口

对齐函数短,但决定了识别的上限。核心是ARCFACE_DST这个模板和estimateAffinePartial2D这个求解器:

import cv2 import numpy as np # ArcFace 标准五点模板:左眼、右眼、鼻尖、左嘴角、右嘴角 ARCFACE_DST = np.array([ [38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041]], dtype=np.float32) def norm_crop(img, kps, image_size=112): if image_size % 112 == 0: # 支持 112/224 等整数倍尺寸 ratio = image_size / 112.0 dst = ARCFACE_DST * ratio else: dst = ARCFACE_DST # partial = 只有旋转+缩放+平移,没有剪切,五官比例不会被拉伸 M, _ = cv2.estimateAffinePartial2D(kps.astype(np.float32), dst, method=cv2.LMEDS) return cv2.warpAffine(img, M, (image_size, image_size), borderValue=0.0)

estimateAffinePartial2D和estimateAffine2D的区别值得强调一次。后者是完整的六自由度仿射,能表达剪切和不等比缩放,五点稍有噪声就会解出一个拉伸矩阵,人脸被压扁或者拉长,ArcFace 拿到的是变形样本。前者只解四个自由度,多余的形变被约束掉,鲁棒性明显更好。这个改动只要一行,但对相似度的提升是肉眼可见的。

borderValue=0.0也不能省。对齐后人脸可能超出原图边界,不指定填充值的话 OpenCV 会保留未初始化的内存,出现花屏条纹,特征直接崩。

另外,传给这个函数的kps顺序必须是左眼、右眼、鼻尖、左嘴角、右嘴角——和ARCFACE_DST一一对应。YOLOv5-face 的默认训练标签一般就是按这个顺序打的,但不同人魔改的数据集不一定,拿到新权重务必先可视化一遍点,别凭假设。

4.3 特征提取与库检索

特征提取用 onnxruntime 推理,比 pt 加载快、显存占用小,也方便后面换 TensorRT。注意输入归一化到[-1, 1],不是常见的[0, 1]——这个也是 ArcFace 系列模型的固定约定,搞错的话特征向量还在,但分布整体偏了,相似度会系统性偏低。

import onnxruntime as ort import numpy as np class ArcFace: def __init__(self, onnx_path): self.sess = ort.InferenceSession( onnx_path, providers=["CUDAExecutionProvider", "CPUExecutionProvider"]) self.input_name = self.sess.get_inputs()[0].name def embed(self, aligned_bgr): # BGR -> RGB,归一化到 [-1, 1],HWC -> NCHW x = (aligned_bgr[:, :, ::-1].astype(np.float32) - 127.5) / 127.5 x = x.transpose(2, 0, 1)[None] emb = self.sess.run(None, {self.input_name: x})[0][0] return emb / (np.linalg.norm(emb) + 1e-12) # 归一化后才能用点积当余弦

归一化之后,检索就是一次矩阵乘法,不需要任何近似索引:

class FaceGallery: def __init__(self, emb_path="data/embeddings.npy", meta_path="data/meta.json"): self.embs = np.load(emb_path) # [N, 512],建库时已归一化 self.meta = json.load(open(meta_path, encoding="utf-8")) def search(self, emb, thres=0.35): sims = self.embs @ emb # 归一化向量点积 = 余弦相似度 idx = int(np.argmax(sims)) best = float(sims[idx]) return (self.meta[idx]["name"], best) if best >= thres else ("unknown", best)

一千人以内的库,这种暴力检索在 CPU 上也就零点几毫秒,完全没必要上 faiss。人多了再考虑,embeddings.npy换成 faiss 的IndexFlatIP就行——内积索引,和归一化点积是一回事,接口几乎透明。

4.4 活体判定与多尺度融合

静默活体模型输入是 80×80,输入来自人脸框按倍数放大后裁剪出来的局部图像。两个尺度分别推理再取平均,能压掉单尺度的抖动:

def _crop_scale(img, bbox, scale): x1, y1, x2, y2 = [int(v) for v in bbox] cx, cy = (x1 + x2) // 2, (y1 + y2) // 2 w, h = int((x2 - x1) * scale), int((y2 - y1) * scale) nx1, ny1 = max(cx - w // 2, 0), max(cy - h // 2, 0) nx2, ny2 = min(cx + w // 2, img.shape[1]), min(cy + h // 2, img.shape[0]) patch = img[ny1:ny2, nx1:nx2] return cv2.resize(patch, (80, 80), interpolation=cv2.INTER_LINEAR) class AntiSpoof: def __init__(self, onnx_path, scales=(2.7, 4.0)): self.sess = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"]) self.scales = scales def predict(self, img, bbox): probs = [] for s in self.scales: patch = _crop_scale(img, bbox, s) x = patch[:, :, ::-1].astype(np.float32).transpose(2, 0, 1)[None] / 255.0 logit = self.sess.run(None, {"input": x})[0][0] e = np.exp(logit - logit.max()) probs.append((e / e.sum())[1]) # 索引 1 是真人,必须和你训练时的 label 顺序对齐 return float(np.mean(probs))

最后那行注释是重点。类别索引 1 到底是真人还是假脸,取决于训练时的类别映射,不同版本仓库有的是['fake', 'real'],有的是['real', 'fake', 'fake_print']三分类。搞反了的表现很典型:照片能过、真人过不去。判断方法很简单——拿一张确认是本人的正脸照,打印出三类概率看一眼,真人概率最高的那个索引就是对的。

活体不做硬性拒绝也可以,做成降权:if not alive: 返回 unknown是最简单的策略,严格但可能误伤逆光下的真人。宽松一点的做法是活体分数低于 0.6 但仍然识别成功的话,返回name + " (待复核)",交给业务侧决定要不要人工确认。哪种更合适取决于你的场景能承受多少误拒。

4.5 串起来:pipeline 的主循环

编排逻辑集中在pipeline.py,二十行不到:

def run_one(img, det, rec, spoof, gallery): out = [] for f in det.detect(img): alive = spoof.predict(img, f["bbox"]) if alive < 0.6: # 短路:假脸不浪费后续算力 out.append({**f, "name": "unknown", "sim": 0.0, "alive": alive}) continue aligned = norm_crop(img, f["kps"], 112) emb = rec.embed(aligned) name, sim = gallery.search(emb, thres=0.35) out.append({**f, "name": name, "sim": sim, "alive": alive}) return out

视频流场景下,det的模型加载放循环外,别在循环里torch.load。另外torch.no_grad()一定要包住推理,忘了的话显存会一路涨到 OOM,这个坑不报错,只是越跑越慢然后崩掉。

5. 排查清单:跑不通、认错人、活体误判的常见坑

5.1 环境与性能类

现象:能跑通,但处理一帧要三百到五百毫秒,nvidia-smi里看不到任何进程。原因:pip install torch装的是 CPU 版轮子,torch.cuda.is_available()返回 False,代码没做断言,默默降级到 CPU 跑。解决:在detector.py初始化时加一句assert device == "cpu" or torch.cuda.is_available(),让它在启动阶段就炸,而不是在推理阶段慢慢磨。然后按显卡支持的 CUDA 版本重装。装完nvidia-smi里应该能看到 python 进程占着几百兆显存。

5.2 检测与坐标类

现象:框整体向右下偏移一段距离,图越大偏得越多,小图上几乎看不出来。原因:letterbox 的pad_x / pad_y和缩放比ratio只用了前半段(缩放),忘了后半段(还原),或者只还原了框没还原关键点。解决:把(v - pad) / ratio这个还原写成一个小函数,框和关键点、x 和 y 全部走同一个函数,杜绝漏改。调试时直接在 640 的 canvas 上画一次,再在原图上画一次,两次位置一致就说明还原对了。

现象:识别相似度全部在 0.1 到 0.2,同一个人也认不出来,但检测框画得挺准。原因:五点顺序和ARCFACE_DST模板不一致,或者用estimateAffine2D全仿射导致人脸被拉伸。解决:拿一张正脸图,把五个点按顺序画上编号,肉眼核对左眼是不是第 0 个、左嘴角是不是第 3 个。顺序对了再检查求解器,换成estimateAffinePartial2D。改完这两个,同一个人的相似度通常能到 0.6 以上。

现象:整体能用,但相似度普遍偏低,边缘样本经常掉到阈值以下。原因:通道顺序搞反。OpenCV 读进来是 BGR,YOLOv5 和 ArcFace 训练时都吃 RGB,转换时机没对准。解决:在detector和recognizer里各自显式写[:, :, ::-1],并且保持 BGR 输入,绝不把 RGB 图存到磁盘之后又cv2.imread读一遍。这种 bug 不会报错,只会让你的阈值怎么调都不对。

5.3 识别与活体类

现象:真人被判定为照片,或者照片被判定为真人。原因:活体模型的类别索引和训练时的 label 映射对不上。三分类模型里经常出现0=real, 1=fake, 2=fake_print和0=fake, 1=real两种排列。解决:写一个三行的调试脚本,把一张确认真人的正脸图送进去,打印完整概率向量,看哪个索引最大。确认之后把索引写进 config 的real_index字段,别硬编码在代码里。

现象:陌生人走过去全被认成库里某一个人,相似度还挺高(0.5 以上)。原因:sim_thres照搬了别人的值。余弦相似度的分布在不同的模型、不同的库规模下差异很大,R50 和 mobilefacenet 的合理阈值能差 0.2。解决:自己标定。准备两组数据——同一个人的不同照片(正样本对)、不同人的照片(负样本对),各跑一遍算出相似度分布,取一个让负样本对误识率可接受的点。工程上一般从 0.3 到 0.4 起调,库越大阈值要越高,因为检索空间大了,撞上一个高分的概率也跟着涨。

6. 二次开发改造:换库、调阈值、接摄像头与业务接口

这套工程的改造点集中在三个地方,按投入产出比排序。

改造方向改哪个文件工作量收益
换自己的人脸库data/gallery/+ 重跑enroll.py半小时立刻能用在自己场景
换检测/识别模型core/下新增一个类,实现对应基类半天精度或速度提升
阈值自动化标定新增tools/eval_thres.py半天告别拍脑袋调参
接业务接口新增server.py(FastAPI)一天从 Demo 变成服务

接口化最快的做法是把run_one包一层 HTTP。注意别在请求处理函数里建 session,onnxruntime 的InferenceSession初始化要几百毫秒,每个请求建一次直接把 QPS 打到个位数。用全局单例 + 线程锁,或者启动时挂到app.state上。

from fastapi import FastAPI, UploadFile import numpy as np, cv2 app = FastAPI() pipe = None # 启动时初始化,全局复用 @app.on_event("startup") def warmup(): global pipe pipe = build_pipeline("configs/config.yaml") dummy = np.zeros((640, 640, 3), dtype=np.uint8) pipe.run_one(dummy) # 先跑一遍预热,避开首个请求的延迟尖峰 @app.post("/recognize") async def recognize(file: UploadFile): buf = np.frombuffer(await file.read(), np.uint8) img = cv2.imdecode(buf, cv2.IMREAD_COLOR) faces = pipe.run_one(img) return {"count": len(faces), "faces": [{"name": f["name"], "sim": round(f["sim"], 3), "alive": round(f["alive"], 3)} for f in faces]}

预热这一步是我觉得最容易被跳过、又最值得加的一段。不预热的话,第一个请求会慢到两三秒,前端超时重试,看起来像是服务挂了。之前在一个考勤场景上,就是因为漏了预热,上线第一天早上第一个人打卡永远失败——用户站在门口刷了三次,第二次之后才成功。从那以后我每次起服务都强制走一遍预热,哪怕只是一个全零的假图。

另一个值得做的改造是把sim_thres和real_thres从配置里的固定值改成按场景分档:门禁严格一点(0.4 / 0.7),相册聚类宽松一点(0.28 / 0.5),两个人的相似度本来就该分场景定。这套拆分做完,你会发现自己不再纠结「到底多少才算对」,而是开始问「这个场景能容忍多少误识」,这才是工程上正确的问法。

希望这些能帮你少走点弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进