很多人一提到人脸识别,第一反应是"这玩意儿得上深度学习框架、得训模型、得有显卡"。我真正把基于 face_recognition 的人脸识别功能从零跑通、又在几个模拟项目里反复改过之后,最深的体会恰恰相反:模型这一层是整个链路里最省心的部分,八成的坑埋在装依赖、对齐颜色通道、调阈值、处理视频流这四件事上。这个库把 dlib 的人脸检测和人脸特征编码包成了几行 Python,装上之后load、encode、compare三个动作就能把"这张脸是谁"给办了,门槛低到会写循环就能上手。但门槛低不等于能直接用,它给出的东西是一堆 128 维浮点数和一个默认阈值,剩下的判断逻辑全得你自己补。下面这套内容适合两类人看:一类是想快速做出可运行 demo 的新手,另一类是已经把 demo 跑起来、但发现结果时好时坏、想搞清楚哪里出了问题的同学。我会按选型、环境、API 语义、建库、阈值、性能、上线的顺序,把每一步为什么这么做讲透。
1. 选型绕了一圈,为什么最后落在 face_recognition
1.1 三条路线摆在一起比
做"识别照片里的人是谁"这件事,市面上能走的路大致三条。第一条是调云服务接口,传图过去返回结果,省事,但按次计费、有网络往返、返回的往往只有标签没有中间特征,你想自己调阈值或者做二次聚类就没抓手。第二条是自己拿深度学习框架训一个或者找一个预训练模型,可控性最强,代价是要处理数据清洗、训练脚本、推理部署一整条链路,对只想解决一个具体小需求的人来说太重。第三条就是 face_recognition 这类封装库,底层是成熟的 C++ 实现,上层是几行 Python,模型文件随包带好,开箱就有 128 维特征输出。
我最后选第三条,理由很实在:我这个场景的核心诉求不是"做到极致精度",而是"在一批照片里把同一个人分到一堆",这本质上是个距离度量问题,不是分类问题。分类问题需要大量标注数据去训,度量问题只需要一个能把人脸映射到向量空间、并且同一个人距离近、不同人距离远的编码器。face_recognition 提供的正是后者,它不告诉我"这是张三",它告诉我"这张脸的特征向量是这个",剩下的比对、聚类、阈值判断我自己说了算,这种灵活性比多几个点的准确率值钱得多。
1.2 掀开盖子:HOG检测 + 128维编码
用之前得知道它在干什么,不然出了问题完全没法排查。这个库的流程拆开是三步。
第一步是人脸检测,找出图里人脸的位置框。默认走的是 HOG 加线性 SVM 的老路子:把图像转灰度、算梯度方向直方图、用一个训练好的分类器在滑动窗口上打分,再做非极大值抑制去重。这条路子在 CPU 上跑得动,对正脸、光照均匀、分辨率够的图效果不错,但遇到侧脸、遮挡、特别小的脸就容易漏。库里另外提供model="cnn"的选项,用的是 dlib 的 MMOD 检测器,召回明显好一截,代价是速度掉一个数量级。
第二步是关键点定位,在检测到的人脸框里找 68 个特征点,眼睛、鼻尖、嘴角这些位置。这一步的意义在于后续的对齐——把人脸摆正,让眼睛处在同一水平线上。很多人不知道这一步存在,但它恰恰解释了为什么同一个人歪头和正脸的特征距离不会差太远。
第三步是编码,把对齐后的人脸裁出来送进一个残差网络,输出一个 128 维的浮点向量。这个网络是在大规模人脸数据上训过的,训的目标就是让同一个人的不同照片向量尽量靠近、不同人的向量尽量远离。128 这个维度不算高,一张脸占的内存不到 1KB,几千张脸全放内存里毫无压力,这也是它能被当成"轻量方案"用的原因。
1.3 它做不到的事,趁早别指望
有些期待得提前掐掉,否则后面会一直别扭。第一,它不做活体检测。拿一张打印出来的照片怼到摄像头前,它照样能编码出向量、照样能比对成功。如果你要做的是门禁、考勤这类需要区分"真人还是照片"的场景,光靠这个库不够,得另外加动作指令、红外、深度之类的辅助手段。
第二,它不做属性分析。性别、年龄、表情,一概没有,别指望从 128 维向量里反推出这些。第三,它对极端姿态和低质量图很无力,戴口罩、大角度侧脸、逆光、分辨率低于人脸框 80 像素的图,检测阶段就漏了,后面的编码根本没机会跑。第四,它的编码器是闭集训练的,没见过的人它也能编出一个向量,这个向量落在哪里没有保证,所以必选项里一定要有"未知"这一档,不能强行在所有已知人脸里挑一个最像的返回,那是最容易出事的设计。
2. dlib 装不上那半天,卡点到底在哪
2.1 Windows 上编译链缺的那几样
新手最常卡住的地方不是代码,是pip install face_recognition报一屏红字。这个包本身是纯 Python,但它依赖dlib,而 dlib 是 C++ 写的,如果当前 Python 版本和平台没有现成的预编译包(wheel),pip 就会退回到源码编译,这时候需要三样东西齐备:一个 C++ 编译器、CMake 构建工具、还有 Python 的开发头文件。
Windows 上最常见的报错是找不到CMake或者找不到Visual Studio的编译环境。解决办法不复杂:装一套 Windows 的 C++ 构建工具(就是装编译器和 MSVC 那套),装的时候务必把"C++ 生成工具"勾上,只装 Python 开发负载是不够的;再单独装一个 CMake 并把它加进 PATH。装完开一个新的终端,cmake --version和cl都能输出东西,再去装 dlib,源码编译那一步通常就能过。
还有一类报错是内存不够。源码编译 dlib 的时候编译器吃内存吃得很凶,虚拟机或者小内存机器上会直接被杀进程,表现出来就是编译到一半莫名其妙中断,日志里没什么有用信息。这种情况优先考虑换方案,别硬刚。
2.2 预编译包和源码编译怎么选
我的建议顺序是这样的:先试 conda 渠道,再试 pip 的预编译包,最后才考虑源码编译。conda 的 conda-forge 渠道长期维护 dlib 的预编译版本,conda install -c conda-forge dlib一条命令下去基本不用管编译的事,装完再 pip 装 face_recognition 本体。这条路在 Windows 和 macOS 上都省心,尤其是在没有编译环境的机器上。
pip 这边要看时机。Python 大版本刚更新的时候,dlib 的预编译包往往要滞后一段时间才跟上,这段时间里 pip 一定会走源码编译。所以如果你对 Python 版本没有硬性要求,不要一上来就用最新版,退一个还在被广泛支持的版本,能省掉大量折腾。这个经验听起来很土,但确实有效。
如果确实走到了源码编译,有两个参数值得调:CMAKE_BUILD_PARALLEL_LEVEL设成 CPU 核数,能明显缩短编译时间;另外如果你机器有独立显卡且装了 CUDA 工具链,可以在编译时打开 GPU 支持,编译出来的 dlib 在跑 CNN 检测器时会快很多。不过 GPU 支持要额外配环境变量,且版本对不上会编译失败,不是刚需的话别开。
2.3 一段最小验证脚本
装完之后别急着写业务代码,先跑一段五行脚本确认整条链路真的通了。这段脚本要做的事是:读一张图、检测人脸、编码、打印向量维度和前几个分量。
import face_recognition img = face_recognition.load_image_file("test.jpg") locs = face_recognition.face_locations(img) print("检测到人脸数:", len(locs)) if locs: encs = face_recognition.face_encodings(img, locs) print("向量维度:", encs[0].shape) print("前五个分量:", encs[0][:5])能跑出(128,)就说明环境没问题。如果这里卡在load_image_file报编码错误,多半是图片本身有问题(比如 CMYK 模式的 JPEG、带透明通道的 PNG),这种图在后续批量处理时一定会再撞上,值得当时就记一笔。如果检测数量是 0,先别怀疑环境,换一张清晰的正面照再试,问题大概率在图上。这一段验证脚本看似多余,但它把"环境问题"和"数据问题"隔开了,后面出任何异常你都能快速判断是哪一类。
3. 四个核心 API 的输入输出,错一个结果就废
3.1 颜色通道错位:最隐蔽的一类翻车
这是我在实际项目里见过最多、也最难查的一个坑。face_recognition.load_image_file()读出来的是RGB顺序的数组,而 OpenCV 的cv2.imread()和cap.read()读出来的是BGR。如果你从摄像头或者用 OpenCV 读图,直接把数组丢给 face_recognition 的函数,代码不会报错,人脸也能检测出来,但编码出来的向量是基于颜色通道错乱的人脸,比对结果会莫名其妙地差。
为什么检测还能过?因为 HOG 检测器内部先转灰度,灰度转换对通道顺序不敏感,所以框照样能框出来。但编码网络是吃彩色信息的,红蓝互换之后,同一个人的两张照片会因为通道处理不一致而拉远距离,表现出来就是"明明是同一个人,阈值怎么调都判不出来"。
正确的做法只有一条:凡是 OpenCV 读进来的图,进 face_recognition 之前一律cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)。我习惯写成一个函数,所有入口都过这个函数,避免漏改。
import cv2 def to_rgb(frame_bgr): return cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)对应的还有一个反向操作:如果你用face_locations拿到的坐标去画框,而画框用的是 OpenCV,坐标本身不用转换,因为 face_locations 返回的是(top, right, bottom, left)这个顺序,跟 OpenCV 的(x, y, w, h)不一样,得手动拆。这个顺序差异也是高频错误点,top, right, bottom, left这四个值的顺序记不住的话,画出来的框一定是歪的。
3.2 upsample 是拿时间换小脸召回
face_locations(img, number_of_times_to_upsample=1)这个参数名字起得有点误导,它做的不是简单的上采样,而是把图像金字塔多建一层。设成 1 意味着在原图之上再放一层,检测器在这两层上都扫一遍;设成 2 就再多一层。层数越多,能检出的小脸越多,耗时也成倍增长。
什么时候该调大?合影、远景、无人机航拍这类人脸占比很小的图。什么时候别动?自拍、证件照、门禁抓拍这类人脸本来就占画面很大比例的图,调大了纯属浪费,还会因为放大后噪声被放大而带来误检。
这里有个权衡经验:与其调 upsample,不如先把大图整体缩到合适尺寸再检测。一张 4000 像素宽的原图直接检测,耗时很吓人;先等比缩到 1000 到 1600 像素宽,人脸框还有一两百像素,检测召回不会掉多少,速度却能快好几倍。缩图的判断逻辑很简单,取长边判断即可。
import cv2 def shrink(img, max_side=1600): h, w = img.shape[:2] if max(h, w) <= max_side: return img s = max_side / max(h, w) return cv2.resize(img, (int(w * s), int(h * s)), interpolation=cv2.INTER_AREA)注意缩图之后所有坐标都变成了缩放后的坐标系,如果要把结果映射回原图,得除以缩放比例。这个映射忘了做,画出来的框会全部偏到左上角。
3.3 num_jitters 改的是编码精度
face_encodings(img, known_face_locations=None, num_jitters=1)里的num_jitters,意思是"抖动次数":对同一张脸做多次随机微扰(轻微位移、缩放),每次编一个码,最后取平均。设成 1 就是只跑一次,设成 10 就是跑十次取平均。
它的效果是让编码更可靠一点,代价是耗时线性增长。我的实测结论是:在大多数场景下num_jitters=1已经够用,提到 10 的收益非常有限,但建库时间是十倍。什么时候值得提?当你的库很小(几十个人)、要求极高、且建库是一次性操作的时候,可以提上去。日常逐帧识别,老老实实保持 1。
另一个参数model可以选"small"或"large",对应 5 点和 68 点关键点模型。"large"精度更好,"small"快一些但编码质量会略降。默认是"large",除非你在做实时视频且已经优化过其他环节,否则不要动它。动了之后你会发现库里的向量和查询向量不在同一个空间尺度上,比对结果一塌糊涂——建库和查询必须用完全相同的参数组合,这条是硬约束,写代码时最好把它固化进一个配置常量里。
3.4 face_distance 和 compare_faces 其实是一回事
这两个函数经常被混着用,实际上compare_faces(known, unknown, tolerance=0.6)内部就是先算face_distance再跟 tolerance 比一下。所以你在生产代码里只需要用其中一个,我建议用face_distance,因为它把距离值返回给你了,你能拿这个值去做排序、做阈值动态调整、做置信度展示。而compare_faces只给你一串布尔的 True/False,信息量少一截。
import numpy as np import face_recognition def match_one(known_encs, known_names, enc, tol=0.45): dists = face_recognition.face_distance(known_encs, enc) idx = int(np.argmin(dists)) best = float(dists[idx]) if best > tol: return "unknown", best return known_names[idx], best这段代码有两个细节值得说。第一,face_distance返回的是一个数组,长度等于已知人脸数量,不是单个值,别直接拿它跟阈值比。第二,np.argmin拿到的是最近的那个,即使它离得很远,代码也会返回一个下标,所以必须再加一层阈值判断,否则任何一张陌生脸都会被硬塞进库里最像的那个人身上。这就是前面说的"必选项里要有未知档"的具体实现方式。
4. 把人脸库搭起来:建库、落盘、增量更新
4.1 入库存档前的清洗规则
建库是整个系统质量的地基,地基歪了后面全是歪的。最省事的做法是"一个文件夹放一个人的照片,文件夹名就是人名",但直接遍历全部塞进去会遇到一堆问题。
首先是多脸图。一张合影里有五个人,你没法判断这张图该归给谁,硬编码会让库里混入大量噪声。我的规则是:建库阶段只接受恰好检测到一张脸的图,检测到零张的说明图质量不够,检测到两张以上的说明不是单人照,统统打日志跳过。这个规则看起来很粗暴,但省事且效果好,因为你可以随时补图。
其次是同一人多张图。同一个人的编码不该只存一条,多存几条然后取最近距离,能有效抵消单张图姿态、光照带来的偏差。实操上我会给每个人存 3 到 5 张不同角度、不同光照的图,但注意别存 20 张近似度极高的连拍,那等于只存了一张,还白白增加了计算量。
最后是尺寸过滤。人脸框小于某个像素数(比如 60 像素)的编码质量很差,宁可不存。这个判断在建库阶段做掉,比在运行时反复踩要划算得多。
4.2 人脸编码怎么存才不后悔
一开始我用的是最简单的方案:把编码 pickle 到一个文件里,程序启动时全部读进内存。这个方案在几百人的规模下工作得很好,加载几十毫秒,比对全量扫描也只要几毫秒。但它有两个明显的短板:一是增量更新麻烦,加一个人得把整个文件重写一遍,并发写入还会互相覆盖;二是没有版本概念,改了参数(比如把 num_jitters 从 1 改成 10)之后,库里的旧向量和新向量不在同一个空间尺度,比出来的距离毫无意义,而你没法一眼看出库里哪些是老规矩生成的。
我现在用的是折中方案:主体还是文件(pickle 或者.npy),但额外存一个元信息字典,记下生成时用的参数组合、生成时间、条目数,写文件时先写临时文件再原子替换。原子替换这一步很关键,程序在写一半的时候被中断,原文件还在,不会变成半截的损坏文件。
规模上万之后,全量线性扫描开始吃力了,这时候再考虑上向量检索库。但在几千这个量级,128 维向量做全量欧氏距离,一次查询也就几毫秒,完全没必要提前上复杂度。这个判断我踩过坑:一开始就引入了检索组件,结果为了维护它花的时间比业务代码还多,最后又删回去了。
4.3 建库与查询的可运行实现
把上面的规则落成代码,大概是这样。
import os, pickle, tempfile import numpy as np import cv2 import face_recognition CFG = {"det_model": "hog", "upsample": 1, "jitters": 1, "enc_model": "large"} DB_PATH = "faces.pkl" def load_rgb(path): img = face_recognition.load_image_file(path) h, w = img.shape[:2] if max(h, w) > 1600: s = 1600 / max(h, w) img = cv2.resize(img, (int(w * s), int(h * s)), interpolation=cv2.INTER_AREA) return img def build_db(img_dir): names, encs, skipped = [], [], [] for person in sorted(os.listdir(img_dir)): pdir = os.path.join(img_dir, person) if not os.path.isdir(pdir): continue got = 0 for fn in sorted(os.listdir(pdir)): if not fn.lower().endswith((".jpg", ".jpeg", ".png")): continue p = os.path.join(pdir, fn) try: img = load_rgb(p) except Exception as e: skipped.append((p, f"读取失败: {e}")) continue locs = face_recognition.face_locations( img, number_of_times_to_upsample=CFG["upsample"], model=CFG["det_model"]) if len(locs) != 1: skipped.append((p, f"人脸数={len(locs)}")) continue top, right, bottom, left = locs[0] if min(bottom - top, right - left) < 60: skipped.append((p, "人脸过小")) continue enc = face_recognition.face_encodings( img, locs, num_jitters=CFG["jitters"], model=CFG["enc_model"])[0] names.append(person) encs.append(enc) got += 1 print(f"{person}: 入库 {got} 张") payload = { "names": names, "encs": np.array(encs), "cfg": CFG, "count": len(names), } fd, tmp = tempfile.mkstemp(dir=".", suffix=".tmp") with os.fdopen(fd, "wb") as f: pickle.dump(payload, f) os.replace(tmp, DB_PATH) print("跳过明细:", skipped) return payload查询侧配套一个函数,把"未知"这条路径走通。
def recognize(rgb, db, tol=0.45): locs = face_recognition.face_locations( rgb, number_of_times_to_upsample=CFG["upsample"], model=CFG["det_model"]) if not locs: return [] encs = face_recognition.face_encodings( rgb, locs, num_jitters=CFG["jitters"], model=CFG["enc_model"]) out = [] for loc, e in zip(locs, encs): d = face_recognition.face_distance(db["encs"], e) i = int(np.argmin(d)) best = float(d[i]) name = db["names"][i] if best <= tol else "unknown" out.append({"box": loc, "name": name, "dist": best}) return out这里有个容易忽略的点:db["encs"]如果是个空数组(库里一条都没有),face_distance会报错。加一行长度判断能省掉一次线上事故。
5. 阈值 0.6 是别人的经验,不是你的结论
5.1 用自己数据画一条距离分布
0.6 这个默认值是库作者在公开评测集上给出的经验值,对应的识别准确率很漂亮,但那是特定数据分布下的结果。你的数据可能是监控截图、可能是手机自拍、可能是二十年前的扫描件,分布完全不同,直接套 0.6 要么漏判一堆,要么把陌生人认成熟人。
正确的做法是自己测。准备两组样本:一组是同一个人的两两配对距离,一组是不同人的配对距离。把两组距离都算出来,画成两张直方图,你会看到两个峰,理想情况下它们分得很开,中间有个低谷,阈值就该落在这个低谷里。
我手上跑过的一组典型数据大致是这样:同一个人的正常照片之间,距离集中在 0.30 到 0.45;不同人之间普遍在 0.65 以上;但有一小撮"困难负样本"(长得像的、光影接近的)会掉到 0.5 附近。这时候把阈值定在 0.6 意味着会有一部分长得像的陌生人被误认,定在 0.45 则意味着同一批人的部分照片会被判成未知。选哪个取决于你的业务更怕哪种错。
| 场景 | 更怕的错误 | 建议阈值 | 说明 |
|---|---|---|---|
| 相册自动聚类 | 认错人(把两个人并成一堆) | 0.42 ~ 0.48 | 宁可分出两堆,也不要把不同人混一起 |
| 内部人员签到 | 漏认(同一个人认不出来) | 0.52 ~ 0.58 | 漏了还能手动补,认错更尴尬 |
| 门禁辅助核验 | 认错人 | 0.40 以下 + 二次确认 | 单靠一次比对不够,必须叠加其他手段 |
| 陌生脸筛查 | 漏检 | 0.60 左右 | 先宽后严,后续人工复核 |
这张表是方向性的参考,具体还得用自己数据跑。有个技巧可以省时间:别只看整体准确率,去看错误样本。把判错的那些对儿挨个看一遍,你很快会发现规律——要么是光照极端,要么是姿态差异大,要么是其中一张图本来就是同一个人换了发型好几年。看清楚错在哪,比调阈值数字有用得多。
5.2 最容易判错的三类场景
第一类是强逆光和老照片。脸整个糊在阴影里,编码出来的向量会向"平均脸"靠拢,跟谁都有一点像。这类图我建议在检测阶段就按亮度过滤掉一部分,或者单独建一个"低质量库",别跟主库混着比。
第二类是同一个人的证件照和生活照。证件照是正脸、打光均匀、表情僵硬;生活照是侧脸、自然光、笑得很开。这两者之间的编码距离有时候比两个陌生人还大。解决办法不是调阈值,是建库时把两种形态都收进去,让同一个人的编码覆盖得更广。
第三类是双胞胎和极像的亲属。这个是真的没办法,128 维向量在这种情况下的区分度就到顶了。如果业务真的涉及,只能在业务层做规避,比如加身份ID二次校验,别指望模型能分辨。
排查思路上,我一般会先打印出每张脸的检测框和编码过程,确认检测框是不是框歪了(框到了半张脸,编码必然错)。判断框歪的办法很简单:把框画到图上存下来看一眼,比读一百行日志快。这一步能排掉相当比例的"疑难杂症"。
5.3 一张图多张脸时的去重
一张合影里同一个人可能因为反光、玻璃、海报出现在多个位置,检测器会全都框出来。如果直接按框遍历,同一个人会被识别多次。去重的判断很简单:如果两个人脸框的 IoU 很高,说明是同一个位置,合并;如果距离很远但编码距离很近,说明是同一个人出现在不同位置(比如镜子里),这种情况在生成结果时按人名去重即可,保留距离最近的那一条。
还有一个容易被忽略的问题:同一张脸上被重复检测。检测器在某些复杂纹理背景上会输出重叠框,尤其在 upsample 设得大的时候。这时候把 IoU 超过 0.5 的框合并成一个,能显著减少后面的计算量。
6. 从能跑到扛得住:速度、并发与视频流
6.1 HOG 与 CNN 的速度差多少
纯 CPU 环境下,HOG 检测器在一张缩到 1600 像素宽的图上,单次耗时大概在零点几秒这个量级;换成 CNN 检测器,会涨到秒级,差一个数量级。GPU 能把 CNN 拉回来,但前提是编译 dlib 时开了 GPU 支持,这条路配置麻烦,而且 face_recognition 官方并没有把 GPU 路径做得特别顺,很多情况下你会发现它根本不走显卡。
所以我的常规选择是:默认用 HOG,只在检测漏脸成为瓶颈时才考虑 CNN,且优先考虑换检测方案而不是换模型参数。所谓换方案,指的是把检测和编码拆成两个服务,检测用更轻量的专用检测器跑,编码只在检测到人脸的区域做,整体吞吐会好很多。
另外提一句,batch_face_locations这个函数的命名有迷惑性。它不是"批量快",而是"用 CNN 模型时把多张图打包送进去以提高显卡利用率"。如果你用的是 HOG,它跟逐张调用没区别,别指望它加速。
6.2 为什么多进程比多线程管用
face_recognition 的底层是 C++,但它跟 Python 之间的交互、以及 numpy 数组的搬运,并没有让计算全程脱离 GIL。实测下来开多线程处理一批图,CPU 根本没跑满,速度提升很有限。换成多进程,每个进程独立加载一份模型、独立算,八核机器基本能跑出六倍左右的吞吐。
from concurrent.futures import ProcessPoolExecutor def work(path): img = load_rgb(path) locs = face_recognition.face_locations(img, model="hog") if not locs: return path, [] encs = face_recognition.face_encodings(img, locs) return path, [e.tolist() for e in encs] if __name__ == "__main__": paths = [p for p in os.listdir("imgs") if p.endswith(".jpg")] with ProcessPoolExecutor(max_workers=6) as ex: for path, encs in ex.map(work, paths): print(path, len(encs))这里有个实实在在的坑:每个进程都会加载一遍模型。模型文件不算小,四五个进程常驻下来,内存占用会成倍上涨。所以进程数不是越多越好,一般取核数的六到七成,留出余量。另外进程池的启动开销不小,如果你要处理的图只有十几张,串行跑反而更快,别为了"用上并行"而并行。
6.3 视频流里没有必要每帧都识
摄像头场景是最考验工程实现的地方。如果按每秒 25 帧全量跑检测加编码,绝大多数的机器都扛不住,而且相邻两帧之间人脸位置几乎没动,重复计算纯粹浪费。我用的策略是分层频率:检测和编码每 N 帧做一次(N 取 5 到 15),中间的帧用一个轻量的位置跟踪把框推过去。
最简陋也最够用的跟踪方式是帧间 IoU 匹配:把上一帧的框留住,这一帧把人脸检测的结果跟上一帧比,重叠度最高的认为还是那个人。这个方法在人物移动慢、镜头固定的场景下效果好得出奇,代码量也就二十行。人物移动快的场景再考虑更复杂的跟踪算法。
def track(prev_boxes, cur_boxes, iou_th=0.3): pairs = [] for i, a in enumerate(prev_boxes): for j, b in enumerate(cur_boxes): if iou(a, b) >= iou_th: pairs.append((i, j)) return pairs还有两个视频场景的小细节。一是分辨率,进来先把帧缩到 640 宽再做检测,比直接处理 1080p 快得多,代价是远处的小脸会丢,这个取舍要按场景定。二是读取缓冲,cv2.VideoCapture默认会缓存若干帧,处理慢了之后你拿到的画面会越来越滞后,把CAP_PROP_BUFFERSIZE设成 1 能缓解,但更彻底的办法是单独开一个线程专门读帧、主线程只取最新一帧,读完就丢。
7. 上线前那些没人提醒的细节
7.1 人脸数据本身怎么管
人脸是生物特征数据,跟普通业务数据不是一个量级的东西,写代码的时候图省事,后面可能要返工。我现在的默认做法是:只存特征向量,不存原始图片。128 维浮点数组反向推不出原图,风险比存原图低一个量级,而且体积小、加载快。原始图片在处理完之后立即清理,确实需要留样的单独放一个受控目录,且明确保留期限。
采集前把告知和授权做清楚,这不是形式主义。用户知道自己的脸被采集了、用在什么地方、留多久,出问题时才有解释的余地。库里存的人名最好用内部 ID 而不是真名,真名单独放一张映射表,需要展示时再关联。这些改动成本很低,但拖着不做,等到要改的时候往往牵扯一大片。
7.2 失败样本的回收比调参更值钱
系统跑起来之后,最先应该加的不是更复杂的算法,而是失败样本的回收通道。每次返回unknown或者距离落在阈值附近(比如 0.40 到 0.55 这个灰区)的样本,都留一份元信息——时间、距离值、检测框大小、来源。攒上一两周,你会得到一份极其宝贵的困难样本集,拿它去重新量距离分布、重新定阈值,比坐在那儿猜参数有效得多。
灰区样本尤其值得看。距离刚好卡在阈值上的那些,往往是系统最不确定的地方,人工快速扫一眼就能判断是"该认没认"还是"差点认错",两种情况的处理方向完全相反。我做过一次统计,某次灰区样本里七成是同一个人因为光照问题被拉远了距离,调整方案就是提高入库照片的多样性,而不是动阈值。
还有一个细节:记录参数版本。库里存向量的时候把生成参数一起写进去,一旦你改了 num_jitters 或者编码模型,能立刻发现库里哪些是老参数生成的、需要重建。这个字段加一行代码的事,不加的话,某天你会面对一堆没法解释的比对结果,完全不知道从哪儿查起。
7.3 后续可以怎么扩
这套东西跑通之后,往上加东西的路子挺多。想提升召回,最直接的办法是换检测器,把 HOG 换成 CNN 或者外部更成熟的检测实现,编码那套不用动。想提升区分度,可以在编码之外叠一层质量评估,把模糊、遮挡、侧脸的图先筛掉,只对高质量图做比对,判错率会明显下降。想支持更大规模,把人脸向量搬进专门的向量检索组件,在几万条以上规模时收益开始显现。
再往深一层走,就是聚类:不预设人脸库,直接对一批照片做无监督分组,这在本库里也没那么玄,把距离矩阵算出来做个层次聚类,选个合适的切断高度就成。我拿它处理过几千张活动照片,效果比预想中好,主要的麻烦不在于算法,在于怎么处理那些只有一两张照片的人——他们很容易被分到错误的簇里,得靠最小簇大小和人工复核兜底。
最后再分享一个我在实际使用中的体会:这套方案真正的价值不在于"识别得多准",而在于它把问题变成了可以自己掌控的形状。所有中间结果都摆在你面前——框在哪、向量是什么、距离是多少,任何一步出问题都能定位、能改。这比一个黑盒返回标签的接口,对想搞清楚事情的人友好太多。