简介:面向前端开发者的 face-api.js 人脸识别资源包,集成了多个预训练模型及演示页面,可在浏览器或本地 App 中实现人脸检测、面部特征点定位、年龄性别估计、表情识别与身份比对。资源包含 21 个文件,以 JSON 权重清单、模型分片文件为主,如 SSD MobileNet、Tiny Face Detector、Face Recognition 等常见模型,另附 camera.html 示例页面与压缩后的 face-api.min.js 库,压缩包整体大小 10.15MB。模型文件较全面,运行在本地时无需依赖网络请求,适合对响应速度有要求或需离线使用的场景。目前已有 934 人学习下载。通过该资源可以快速搭建人脸识别前端原型,了解模型调用与封装流程,尤其适合初学者在简易项目中灵活选用对应模型。
1. 一个 zip 包里的 face-api:不用后端也能做人脸识别
拿到“face-api人脸识别.zip”这份资源的人,多半不是在找论文或算法源码,而是想给网页、门禁 demo、签到小程序加一个能跑的人脸识别能力。我先说结论:face-api 是一套运行在浏览器里的 JavaScript 人脸识别方案,整个识别链路——检测、关键点定位、特征提取、身份比对——都在前端完成,不需要部署 Python 服务,不需要 GPU 服务器,解压 zip 后把模型文件放进静态目录就能跑。适合的人很明确:前端工程师、全栈开发、做树莓派或行空板这类边缘设备的爱好者,以及需要快速出人脸识别门禁系统原型的学生。但它不是开箱即用的黑匣子,模型文件、浏览器兼容、阈值这些环节都有能让人翻车的细节,这篇文章就把路径和坑一次讲完。
2. face-api 的模型构成与选型:先搞懂模型再跑 hello world
2.1 一个库拆成四个模型:检测、关键点、特征三件事分开做
face-api 不是一个大而全的“人脸识别算法”,它把识别流程拆成了几个独立的神经网络,分别负责不同阶段。初次解压 zip 后,建议你先看 models 目录下有几个子目录,因为少一个模型,代码要等运行到对应 API 时才报错,这种错误是新手最容易卡住的。
标准的人脸识别流程分三步:检测出人脸位置、定位五官关键点、提取代表这张脸的向量。对应到 face-api 里分别是:
- 人脸检测:tinyFaceDetector 或 ssdMobilenetv1,输出人脸边界框
- 关键点定位:faceLandmark68Net,输出 68 个面部关键点坐标
- 特征提取:faceRecognitionNet,输出 128 维特征向量
有意思的点在于,这套模型体系沿用了 OpenFace 的思路:识别阶段不是“查图片”,而是把人脸压缩成一个向量,后续的比对变成向量之间的距离计算。这也意味着你可以把特征向量存进数据库,而不是永远依赖原始图片。
我一般建议项目里至少加载检测、关键点、特征三个网络。如果你只是做人脸框选显示,只需要检测网络;但做身份识别,三个缺一不可。曾经有个同事只加载了检测模型就调withFaceDescriptors(),结果报错“descriptor 为 undefined”,排查了半天才发现是特征网络没加载。
2.2 tinyFaceDetector 与 SSD MobileNet:速度与精度的取舍
检测这一步是整个流程里唯一有明显速度与精度取舍的环节。tinyFaceDetector 模型权重只有几百 KB,在普通笔记本上单帧检测只需几十毫秒,适合视频流实时处理;SSD MobileNet 精度更高、边界框更准,但对遮挡和小脸的鲁棒性优势在近距离门禁场景下并不明显,权重却有 5MB 以上,加载时间肉眼可见变慢。
我做过一个树莓派上的人脸识别门禁 demo,用 SSD MobileNet 时帧率只有 8-10 FPS,换成 tinyFaceDetector 后能到 25 FPS 左右,代价是在光照很暗的楼道里偶尔漏检。对于门禁、考勤这类近景场景,tinyFaceDetector 完全够用;只有做安防监控这类需要识别远处小脸的场景,才值得上 SSD MobileNet。
另外一个经验:不要把 inputSize 调太大。这个参数控制输入网络的图像缩放尺寸,默认 416,支持 320、416、512 三档。640 以上的值在低端设备上会直接卡到不可用,而它对精度的提升远不如把摄像头画面裁一刀来得实在。
2.3 WebGL 与 WASM:推理后端怎么选
face-api 基于 TensorFlow.js,底层推理有 WebGL、WASM、CPU 三种后端。WebGL 是默认选项,利用 GPU 并行计算,速度最快;但 GPU 上下文数量有限制,后面讲踩坑时会细说。WASM 走 CPU 但经过 SIMD 优化,速度居中,胜在稳定。
如果你的运行环境是普通浏览器,不用管后端,face-api 会自动选择 WebGL。但有几个特例:一是部分虚拟化环境的浏览器禁用 GPU,WebGL 不可用;二是 Electron 内嵌窗口或老笔记本驱动有问题;三是同一台机器上开了太多标签页,WebGL 上下文耗尽。这些时候代码不会报错,只是推理速度掉到 CPU 水平,帧率断崖式下跌,很多人误以为是模型太重,其实是后端降级了。
| 模型 | 职责 | 权重量级 | 适用场景 |
|---|---|---|---|
| tinyFaceDetector | 人脸检测 | 几百 KB | 视频流实时检测、边缘设备 |
| ssdMobilenetv1 | 人脸检测 | 数 MB | 小脸、远距离、遮挡场景 |
| faceLandmark68Net | 五官关键点 | 几百 KB | 姿态判断、活体辅助 |
| faceRecognitionNet | 特征向量 | 数 MB | 身份比对,必需 |
3. 把 zip 跑起来:本地加载模型、检测人脸、做身份比对
3.1 解压与静态托管:models 目录的路径是第一道坎
先做一件枯燥但重要的事:把 zip 解压并正确放到静态目录。face-api 加载模型时通过 HTTP 请求读取 JSON 和权重文件,文件必须能被浏览器访问到,这是最常见的 404 来源。
如果你用的是 Webpack/Vite 这类构建工具,模型目录放在public或static下,保证构建后原样复制到站点根目录。如果是纯静态页面,直接把目录放在项目根目录下,然后确认浏览器地址栏能访问到http://localhost:8080/models/tiny_face_detector_model-weights_manifest.json。
Linux 或服务器上解压时,用标准的 unzip 命令即可,注意中文文件名可能导致编码问题:
unzip face-api人脸识别.zip -d /var/www/html/ ls -la /var/www/html/models/提示:解压后一定要看一眼目录结构。如果发现嵌套了一层同名目录(常见于 Windows 压缩习惯),加载路径会变成
/models/models/xxx,需要把目录层级调整回来。
3.2 先跑通检测:TinyFaceDetector 最小代码
模型放好之后,写一个最小页面跑通人脸检测。以下代码从视频流里检测人脸并把边界框画到 canvas 上:
import * as faceapi from 'face-api.js'; async function init() { // 加载三个网络:检测、关键点、特征 // 路径是相对站点根目录的,不是相对 JS 文件 await faceapi.nets.tinyFaceDetector.loadFromUri('/models'); await faceapi.nets.faceLandmark68Net.loadFromUri('/models'); await faceapi.nets.faceRecognitionNet.loadFromUri('/models'); const video = document.getElementById('video'); const stream = await navigator.mediaDevices.getUserMedia({ video: true }); video.srcObject = stream; const canvas = document.getElementById('canvas'); const displaySize = { width: video.width, height: video.height }; faceapi.matchDimensions(canvas, displaySize); // 循环检测,保持实时性 setInterval(async () => { const detections = await faceapi .detectAllFaces(video, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: 0.5 })) .withFaceLandmarks() .withFaceDescriptors(); const resized = faceapi.resizeResults(detections, displaySize); canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height); faceapi.draw.drawDetections(canvas, resized); faceapi.draw.drawFaceLandmarks(canvas, resized); }, 100); } init();代码里的关键参数有两个。inputSize: 416是输入网络的图像边长,值越小计算越快但精度越低,移动端建议 320,桌面端 416 性价比最高。scoreThreshold: 0.5是人脸置信度阈值,调高会减少误检但可能漏掉侧脸或模糊脸,门禁场景调到 0.6 能显著减少误触发。
matchDimensions和resizeResults这两个 API 值得单独说。视频流输出尺寸往往不是 canvas 尺寸,直接把检测框画上去会导致框和脸位置错位。先matchDimensions对齐画布,检测结果再用resizeResults映射回显示尺寸,这是很多人一开始都会忽略的细节。
3.3 做身份识别:欧氏距离阈值怎么定
检测只是定位“脸在哪”,识别要回答“这是谁”。face-api 的做法是用withFaceDescriptors()得到 128 维特征向量,然后比对两个向量的欧氏距离,距离越小说明越可能是同一个人。
先录入一张目标人脸的描述子,保存为参考值:
// 录入:从一张照片中提取特征向量 async function enroll(imageEl, label) { const detection = await faceapi .detectSingleFace(imageEl, new faceapi.TinyFaceDetectorOptions()) .withFaceLandmarks() .withFaceDescriptor(); if (!detection) { console.warn(`${label} 未检测到人脸`); return null; } // 实际项目这里应该把 vector 存到后端或 IndexedDB return { label, descriptor: detection.descriptor }; }识别时,把视频帧的特征向量和所有已录入的参考向量逐一算距离,取最小距离对应的标签:
function recognize(descriptor, knownProfiles, threshold = 0.5) { let best = { label: 'unknown', distance: Infinity }; for (const profile of knownProfiles) { const distance = faceapi.euclideanDistance(descriptor, profile.descriptor); if (distance < best.distance) { best = { label: profile.label, distance }; } } // 距离超过阈值,认为是陌生人 return best.distance < threshold ? best.label : 'unknown'; }threshold是最关键的参数,没有放之四海而皆准的固定值。同一个人的不同角度、不同光照,距离一般在 0.35-0.55 之间;两个人的距离通常在 0.7 以上。但这只是经验区间,不同摄像头、不同人脸距离下会有波动。
我的血泪经验是:不要直接用网上的 0.6 阈值,先录 5-10 个人、每人不同角度多张照片,把类内距离和类间距离各算一遍,再在两组距离的中间位置取阈值。后面第 5 章会给出具体校准方法。
4. 离线部署避坑:五个让 face-api 翻车的常见问题
4.1 模型加载 404,页面卡在初始化
现象:控制台报Failed to load resource: 404,指向某个-weights_manifest.json文件,页面一直停留在加载状态。
原因:绝大多数是路径问题。loadFromUri('/models')里写的路径是相对当前页面 URL 的,不是相对 JS 文件。页面在https://site.com/demo/page.html时,路径会被解析成https://site.com/demo/models,如果 models 在根目录就必然 404。部署在子路径时(比如https://site.com/app/),同样会出错。
解决:用绝对路径,或动态拼接部署前缀。常见做法是loadFromUri(${import.meta.env.BASE_URL}models)或在入口处定义const MODEL_PATH = window.location.pathname.includes('/app/') ? '/app/models' : '/models'。另外别忘了确认 zip 解压后文件权限是 755,服务器对静态目录没有读取权限也会报 404。
4.2 识别结果全是 unknown,或者是错误的人
现象:代码不报错,人脸框能画出来,但识别结果要么全是unknown,要么把 A 认成 B。
原因:两种情况分开看。全unknown大概率是阈值设置过严(比如 0.3),同一人稍微换个角度距离就超过阈值;把 A 认成 B 则是对参考描述子的质量没做筛选,录入时用了模糊照片、侧脸照片,或者录入时检测到了非目标人脸。
解决:把 threshold 暂时调到 0.8,观察实际距离分布,再用第 5 章的校准方法确定最终值。录入阶段加一个质量检查:检测得分低于 0.5 的照片不采用,且提示用户正对摄像头。更保险的做法是每人录入 2-3 张参考向量,识别时取与所有参考向量的最小距离。
4.3 多标签页打开后页面变黑或卡死
现象:单开一个页面正常,浏览器里多开两三个同样页面后,视频画面卡住,GPU 占用飙升,甚至整个浏览器黑屏。
原因:face-api 的推理默认走 WebGL,而浏览器对 WebGL 上下文数量有上限(通常 16 个)。每个标签页都要独立的 GPU 上下文,上下文被占满后新页面无法创建,TensorFlow.js 虽会尝试降级,但旧版本行为不稳定,经常卡死。
解决:业务设计上避免同窗口多页面并行识别,这是最有效的办法。技术层面可以强制走 WASM 后端,牺牲一点速度换稳定,在初始化时显式指定:
import * as tf from '@tensorflow/tfjs'; async function initBackend() { await tf.setBackend('wasm'); await tf.ready(); // 再执行 faceapi.nets.xxx.loadFromUri }注意:
tf.setBackend('wasm')要在加载任何模型之前调用,同时需要在 page 里引入@tensorflow/tfjs-backend-wasm的 WASM 二进制文件。纯 CPU 环境不要指望这一步能提速多少,它只是把“卡死”变成“慢但能用”。
4.4 透明背景 canvas 检测不到人脸
现象:给一段带透明通道的 canvas 直接传给人脸检测,返回值始终为空;同样内容画到图片上就能检测到。
原因:TensorFlow.js 在解码 canvas 的 RGBA 像素时,会保留 alpha 通道。透明区域的像素值为 0,意味着“这个区域是黑色”,而不是“这个区域不存在”。人脸检测网络是在自然图像上训练的,纯黑色背景会严重干扰检测结果。这属于 face-api 的已知边界,不算 bug。
解决:把透明 canvas 先绘制到一张白色背景的画布上再参与检测。两行代码的事:
const whiteBg = document.createElement('canvas'); whiteBg.width = sourceCanvas.width; whiteBg.height = sourceCanvas.height; const ctx = whiteBg.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, whiteBg.width, whiteBg.height); ctx.drawImage(sourceCanvas, 0, 0); // 之后用 whiteBg 作为检测输入这也侧面对摄像头画面有要求:视频画面本身是 RGB,不是 RGBA,所以正常摄像头场景不会遇到这个问题。只有做自拍框裁剪、头像合成时才需要处理。
4.5 设备没有 GPU 时,帧率从 30 掉到 5
现象:在虚拟机、远程桌面、老办公机上跑 demo,检测框以内的画面严重拖影,CPU 风扇狂转。
原因:这些环境 WebGL 不可用或实际是在 CPU 上模拟,TensorFlow.js 自动降级到了 CPU 后端。CPU 跑卷积网络的算力远低于 GPU,帧率下降是必然的。
解决:先确认后端再优化模型。按 2.3 节的方法强制 WebGL 后端,然后用tf.engine().backend打印当前后端名称确认。注意mobileNetv1在 CPU 上几乎不可用,只有 tinyFaceDetector 勉强能跑出个位数的帧率。如果硬件实在太弱,另一个思路是降低采集帧率:不必每帧都跑检测,改为主线程每 300ms 检测一次,虽不连续但能保证单人场景基本可用。
4.6 一段自检代码:确认模型真的在走本地文件
排查问题之前,先确认浏览器加载的模型文件来自本地静态目录,而不是被缓存或走了 CDN。Network 面板是最直接的观测手段,另外也可以用一段简单的 fetch 检查做快速确认:
async function checkModelLoaded() { const manifestPath = '/models/tiny_face_detector_model-weights_manifest.json'; const resp = await fetch(manifestPath); if (resp.ok) { const manifest = await resp.json(); console.log('模型文件存在,包含', manifest.weightsManifest.length, '个分片'); } else { console.warn('模型文件不可访问,HTTP 状态:', resp.status); } }检查weightsManifest里引用的每个.bin文件也要能访问。有一个常见疏忽是只确认了 JSON 能读取,但 bin 文件路径错误,模型加载会卡在最后一步。当年我在内网服务器上就是栽在这:JSON 文件正常,bin 文件因 Nginx 静态目录配置漏了一层,模型始终加载不完。
5. 帧率与阈值校准:把 demo 变成能用的门禁
5.1 用 requestAnimationFrame 替代 setInterval
前面示例用了setInterval(100)做循环检测,这在 demo 里没问题,但正式场景有两个隐患:定时器不能和屏幕刷新对齐,卡顿时会出现检测间隔忽长忽短;浏览器最小化时定时器不会自动降频,白白消耗资源。更稳的做法是requestAnimationFrame配合“每第 N 帧跑一次检测”的策略:
let frameCount = 0; function loop() { frameCount++; if (frameCount % 3 === 0) { // 每 3 帧检测一次,约 20 FPS 的检测频率 detectOnce(); } requestAnimationFrame(loop); }检测函数本身是异步的,需要加锁防止上一帧没算完下一帧又进来,造成请求堆积:
let detecting = false; async function detectOnce() { if (detecting) return; detecting = true; try { // 执行检测与比对 } finally { detecting = false; } }5.2 阈值校准的正规做法:按距离分布选阈值
与其相信网上抄来的 0.6,不如直接用数据说话。校准流程分三部分:先采集数据,再算距离,最后定阈值。
数据采集阶段,对每个参与者录 5 张不同角度和表情的照片,两两组合算同一人的类内距离;再拿不同人的描述子两两组合算类间距离。以下脚本用最朴素的方式输出距离分布:
const intraDistances = []; const interDistances = []; // profiles: [{label, descriptors: [desc1, desc2, ...]}] for (const profile of profiles) { const descs = profile.descriptors; for (let i = 0; i < descs.length; i++) { for (let j = i + 1; j < descs.length; j++) { intraDistances.push(faceapi.euclideanDistance(descs[i], descs[j])); } } } for (let i = 0; i < profiles.length; i++) { for (let j = i + 1; j < profiles.length; j++) { for (const a of profiles[i].descriptors) { for (const b of profiles[j].descriptors) { interDistances.push(faceapi.euclideanDistance(a, b)); } } } } console.log('类内最大距离:', Math.max(...intraDistances)); console.log('类间最小距离:', Math.min(...interDistances));理想情况下,类内最大距离小于类间最小距离,这样任意中间值都能作为阈值。实际场景会有重叠地带,我的做法是取两者中位数之间的分界点,宁可比交叉点偏严一点,也不让陌生人混进来——宁可误拒,不能误放。
5.3 一个调试时高频使用的小技巧:把检测信息打到画面里
调试人脸识别时最怕黑匣子:代码在跑,但不知道检测得到底准不准、距离是多少、人脸框置信度是多少。我现在的习惯是调试阶段把信息直接画到 canvas 上,而不是只依赖 console:
canvas.getContext('2d').font = '16px monospace'; detections.forEach((d, idx) => { const { x, y } = d.detection.box; const score = d.detection.score.toFixed(2); const label = recognizedLabels[idx] || 'unknown'; ctx.fillStyle = '#00ff00'; ctx.fillText(`${label} | ${score}`, x, y - 8); });把置信度和识别结果显示在画面上后,很多问题一眼就能看出来:置信度低是光照问题还是算力问题,标注延迟是网络问题还是渲染问题。等系统稳定后再把这些调试信息关掉。
人脸识别项目做到最后,真正难的往往不是算法,而是把摄像头环境、模型选择和用户体验捏合到一起。光线的变化、人脸角度的偏移、边缘设备的性能抖动,这些都会把看起来“没问题”的 demo 击穿。我的教训是:先跑通最小链路,再按数据调阈值,最后做资源降级,次序反了就会一直在玄学调参里打转。希望这套思路帮你在自己的项目里少走一段弯路。
本文还有配套的精品资源,点击获取