端侧推理人工智能TensorFlow.js人脸识别前端# 端侧推理实战:face-api.js 零后端人脸识别踩坑记
一个纯前端的人脸识别 Demo:照片不上传、不走任何后端接口、断网也能跑。整套模型 6.7MB,浏览器里 WebGL 加速,实时摄像头模式能稳定出 FPS。这篇文章把「检测 → 关键点 → 128 维特征 → 本地比对」这条链路从源码层面拆开,再把我踩过的 6 个坑原样奉上。
一、先看结果:它到底能做到什么
很多人一听"前端人脸识别"就觉得是玩具。先把我这套东西的能力边界摆出来,避免你读完了发现不是自己要的:
| 照片人脸检测 | TinyFaceDetector,inputSize 416 | 否 |
| 68 个关键点定位 | faceLandmark68Net | 否 |
| 128 维人脸特征提取 | faceRecognitionNet | 否 |
| 人脸库存储 | IndexedDB(Dexie),存 128 维向量 | 否 |
| 1:N 比对识别 | 欧氏距离 + 阈值 0.55 | 否 |
| 摄像头实时识别 | 300ms 一次检测,inputSize 320 | 否 |
| 抓拍入库 | canvas 截图 → 缩略图 → 入库 | 否 |
一句话总结:除了首次加载模型权重需要 HTTP 请求,之后所有计算和数据都在浏览器里。刷新、断网、关掉后端服务,照样识别。
这套东西适合的场景很明确:
不适合的场景也说清楚:金融级 1:1 核身、防伪活体攻击、大规模万人底库检索。轻量级模型 + 浏览器算力,撑不起这些。
二、技术选型:为什么是 face-api.js
选型时我对比了四条路线,最终选了 face-api.js,理由和代价都写在表里:
| face-api.js(TF.js) | 约 6.7MB | WebGL 自动加速 | 低,API 链式调用 | ✅ 选中:生态成熟、离线可用 |
| 云端 SDK(百度/阿里) | 0 | 服务端 | 低 | ❌ 数据出设备、按量计费 |
| MediaPipe Face Mesh | 约 3MB | WASM + GPU | 中,需要自己接识别 | ⚠️ 检测强,但不自带比对 |
| Transformers.js v4 | 视模型而定 | WebGPU | 中高 | ⚠️ 2026 年很火,但人脸识别不是它的强项 |
技术栈最终定下来:
{
"vue": "^3.4.21",
"face-api.js": "^0.22.2",
"dexie": "^4.0.7",
"vite": "^5.2.8"
}
dexie 是关键一环——人脸特征向量要长期留存,localStorage 那 5MB 和只能存字符串的限制根本不够用,必须上 IndexedDB。
三、核心原理:128 维向量 + 欧氏距离
整条链路其实就四步,理解了这四步,代码就全通了:
┌─────────────┐ ┌──────────────┐ ┌────────────────┐ ┌─────────────┐
│ 输入图像 │ → │ 人脸检测 │ → │ 68 关键点对齐 │ → │ 特征提取 │
│ img/video │ │ TinyFaceDet. │ │ Landmark68 │ │ Recognition │
└─────────────┘ └──────────────┘ └────────────────┘ └──────┬──────┘
│
Float32Array(128)
│
┌─────────────────────────────────────────▼──────┐
│ 与 IndexedDB 人脸库逐条算欧氏距离 │
│ dist < 0.55 → 判定为同一个人 │
└────────────────────────────────────────────────┘
为什么关键点对齐这一步不能省? 因为特征提取网络(ResNet-34 变体)对人脸的姿态很敏感。侧着脸和正脸直接抽特征,同一个人的向量距离会拉得很开。Landmark 网络先定位 68 个点,再做仿射变换把人脸"摆正",后续特征的稳定性才有保障。
为什么用欧氏距离而不是余弦相似度? face-api.js 的 Recognition 网络输出的是经过 L2 归一化的 128 维向量,在这个前提下欧氏距离和余弦相似度是单调等价的,而欧氏距离计算更省事,少一次点积和模长运算。
128 维这个数字是怎么来的? 它不是随便定的,而是网络最后一层全连接的输出维度。你可以把它理解成:模型把一张脸压缩成了一个 128 长度的"数字指纹"。维度越高区分度越好,但存储和比对成本也线性上升——128 维在这个量级上是个平衡点,一条特征存成普通数组也就几 KB。
为什么比对不用后端向量数据库? 因为底库小。几十到几百条特征,JS 里一个 for 循环逐条算距离,耗时在毫秒级,引入向量库属于过度设计。真到了万人级别,才需要考虑 HNSW 之类的近似检索方案。
检测器的两个参数怎么理解? inputSize 决定图像被缩放到的尺寸,值越大越能检出小脸,但计算量近似按平方增长;scoreThreshold 是置信度门槛,调高能过滤掉误检框,代价是漏检侧脸和模糊人脸。这两个参数没有标准答案,只能结合场景实测。
四、关键代码拆解
4.1 模型加载:单例 + 进度回调
模型 6.7MB,绝对不能重复加载。这里用一个模块级变量做单例,同时把加载中的 Promise 缓存下来,防止并发调用触发多次请求:
// src/utils/faceApi.js
import * as faceapi from 'face-api.js'
// 用相对路径,避免 GitHub Pages 子路径部署时丢前缀
const MODEL_URL = 'models'
const DISTANCE_THRESHOLD = 0.55 // 越小越严格,0.55 是经验值
let loaded = false
let loading = null
export async function loadModels(onProgress) {
if (loaded) return
if (loading) return loading // 关键:并发调用复用同一个 Promise
loading = (async () => {
await faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL)
onProgress?.({ name: '人脸检测器', done: true })
await faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL)
onProgress?.({ name: '关键点定位', done: true })
await faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL)
onProgress?.({ name: '特征提取', done: true })
loaded = true
})()
return loading
}
三个模型的真实体积(来自 public/models/):
- tiny_face_detector_model:189 KB
- face_landmark_68_model:348 KB
- face_recognition_model(2 个 shard):6.15 MB
大头全在特征提取网络上,这也是端侧推理的典型局面:检测器很便宜,识别网络很贵。
4.2 检测:链式 API 一次拿齐
export async function detectFaces(input) {
const detectorOptions = new faceapi.TinyFaceDetectorOptions({
inputSize: 416, // 输入尺寸越大越准,但耗时近似平方增长
scoreThreshold: 0.45 // 置信度过滤,太低会出现大量误检框
})
// 一次拿齐检测 + 关键点 + 描述子
const results = await faceapi
.detectAllFaces(input, detectorOptions)
.withFaceLandmarks()
.withFaceDescriptors()
return results.map((r) => ({
box: r.detection.box,
score: r.detection.score,
landmarks: r.landmarks,
descriptor: r.descriptor // Float32Array(128)
}))
}
注意 inputSize 这个参数,它是端侧推理性能调优的第一旋钮:416 能保证小脸、侧脸的召回;但如果你要跑实时视频流,得往下压。
4.3 比对:欧氏距离 + 阈值判定
export function euclideanDistance(a, b) {
if (!a || !b) return Infinity
let sum = 0
for (let i = 0; i < a.length; i++) {
const d = a[i] – b[i]
sum += d * d
}
return Math.sqrt(sum)
}
export function findBestMatch(queryDescriptor, library) {
if (!queryDescriptor || !library?.length) return null
let best = null
for (const item of library) {
if (!item.descriptor) continue
const dist = euclideanDistance(queryDescriptor, item.descriptor)
if (!best || dist < best.distance) {
best = {
id: item.id,
name: item.name,
distance: dist,
similarity: Math.max(0, 1 – dist) // 0~1 相似度,夹住负值
}
}
}
return best
}
export function isPass(threshold = DISTANCE_THRESHOLD) {
return (match) => match && match.distance < threshold
}
Math.max(0, 1 – dist) 这行不是多余的:距离大于 1 时相似度会变成负数,直接展示给用户会出现"-12.3%"这种鬼东西。
4.4 人脸库:IndexedDB 存 128 维向量
// src/utils/db.js
import Dexie from 'dexie'
class FaceDB extends Dexie {
constructor() {
super('FaceAILibrary')
this.version(1).stores({
faces: '++id, name, createdAt' // 自增主键 + name/createdAt 索引
})
}
}
export async function addFace({ name, descriptor, thumbnail }) {
const id = await db.faces.add({
name: name || '未命名',
descriptor: Array.from(descriptor), // Float32Array → 普通数组
thumbnail,
createdAt: Date.now()
})
return id
}
Array.from(descriptor) 这行看着不起眼,是踩过坑才知道必须写——下面第六节详细说。
4.5 实时摄像头:降频 + 降尺寸 + 单人检测
实时模式不能每帧都跑,我用了三重节流:
const DETECT_INTERVAL = 300 // ms:每 300ms 检测一次,而不是每帧
const DETECT_INPUT_SIZE = 320 // 比单图模式的 416 小,实时更流畅
async function detectFromCamera() {
const v = videoEl.value
if (!v || !streaming.value) return
if (v.readyState < 2) return // 视频元数据还没加载完,跳过这一轮
frameCount++
try {
const detector = new faceapi.TinyFaceDetectorOptions({
inputSize: DETECT_INPUT_SIZE,
scoreThreshold: 0.5
})
// 实时模式只检一张脸,省掉多脸排序的开销
const result = await faceapi.detectSingleFace(v, detector)
.withFaceLandmarks()
.withFaceDescriptor()
if (result) {
const library = await listFaces()
const m = findBestMatch(result.descriptor, library)
lastMatch.value = m ? { …m, pass: isPass(threshold)(m) } : null
}
} catch (err) {
console.warn('检测异常', err) // 单帧失败不能让整个循环崩掉
}
}
三个关键点:用 setInterval 而非 requestAnimationFrame(可控频率)、readyState < 2 直接返回(避免黑屏期间的无效推理)、try/catch 包住单帧(偶发异常不能中断循环)。
五、真实踩坑记录(6 个)
坑 1:Vite 预构建 face-api.js 直接报错
face-api.js 内部依赖了一些 Node 侧的写法,Vite 的依赖预构建会把它搞崩。解法是在 vite.config.js 里显式排除:
export default defineConfig({
optimizeDeps: {
exclude: ['face-api.js'] // 不让它进预构建
}
})
坑 2:模型路径带前导斜杠,部署到 GitHub Pages 子路径直接 404
我一开始写的是 const MODEL_URL = '/models',本地 npm run dev 一切正常,部署到 GitHub Pages 的 xxx.github.io/faceAI/ 子路径之后全部 404——因为绝对路径会指向站点根,而不是仓库子目录。
改成相对路径 'models',配合 Vite 的 base 配置才对:
const isProd = process.env.NODE_ENV === 'production' || process.env.VITE_BASE
const base = isProd ? (process.env.VITE_BASE || '/faceAI/') : '/'
坑 3:Float32Array 存不进 IndexedDB
descriptor 是 Float32Array(128),直接 db.faces.add({ descriptor }) 读出来会是空的或者结构异常。IndexedDB 的结构化克隆算法对 TypedArray 的支持在不同实现下有差异,稳妥做法是转成普通数组:
descriptor: Array.from(descriptor)
读出来比对时,普通数组照样能按下标取值,不影响 euclideanDistance 的计算。
坑 4:摄像头镜像后,检测框和脸错位
为了符合自拍习惯,视频做了镜像:
.camera-video {
transform: scaleX(-1);
}
但 overlay canvas 是独立元素,不镜像的话框会画在脸的反方向。必须同步加上:
.camera-overlay {
position: absolute;
inset: 0;
transform: scaleX(-1); /* 与视频保持同步镜像 */
}
坑 5:canvas 尺寸没同步 video 分辨率,框整体偏移
<video> 的 CSS 尺寸和实际分辨率是两回事。canvas 必须按 videoWidth/videoHeight 设置,而不是 CSS 宽高:
const syncSize = () => {
if (!canvas || !v.videoWidth) return
canvas.width = v.videoWidth
canvas.height = v.videoHeight
}
syncSize()
v.addEventListener('loadedmetadata', syncSize) // 元数据就绪后再同步一次
漏掉 loadedmetadata 那次监听,第一次启动摄像头时 canvas 还是 300×150 的默认尺寸。
坑 6:阈值 0.55 是经验值,别当标准答案
DISTANCE_THRESHOLD = 0.55 在室内正常光照、正脸的场景下表现不错。但逆光、戴帽子、侧脸、低分辨率都会让距离明显变大。我的做法是把阈值做成界面上可见的参数,让用户根据实际场景微调,而不是写死一个"看起来对"的数字。
同时要提醒一句:这个模型没有活体检测。拿一张照片对着摄像头,照样能通过。真要上生产,必须补活体。
六、性能实测感受
在我的机器上(Ryzen 7 5800H + Chrome/WebGL 后端):
- 模型首次加载:三个模型加起来 6.7MB,本地加载 1~2 秒,之后进 Service Worker 缓存基本秒开;
- 单图识别(inputSize 416,一张正脸):从点击到出框大概几百毫秒,主要耗时在特征提取;
- 实时模式(inputSize 320,300ms 间隔):FPS 稳定在个位数到十几之间——注意这里的 FPS 是"检测轮次/秒",不是渲染帧率。想再快,把 DETECT_INTERVAL 调到 500ms、inputSize 压到 224,代价是侧脸和小脸的召回下降。
三个调优旋钮的实际影响,我整理成这张表,方便你直接照着调:
| inputSize | 224~320,速度快,小脸易漏 | 416~608,召回高,明显变卡 | 单图 416 / 实时 320 |
| DETECT_INTERVAL | 100ms,跟手但吃 CPU | 500ms,省电,框有拖影 | 300ms |
| 检测人数 | detectSingleFace,只取最大脸 | detectAllFaces,支持多人 | 实时单人 / 单图多人 |
端侧推理的本质就是用精度换延迟和隐私,这三个旋钮怎么拧,取决于你的场景更在意哪一个。另外提醒一点:别在主线程上无节制地跑推理。我这个项目当前还是主线程跑 TF.js,图库规模小、检测频率低所以问题不大;一旦你把间隔压到 100ms 以内,输入框会明显发涩,那时候就必须上 Web Worker 和 OffscreenCanvas 了。
顺带说一句,2026 年 WebGPU 后端和 Google 新出的 LiteRT.js 让浏览器推理速度又上了一个台阶。如果你现在新建项目,值得把 LiteRT.js 放进选型清单;但如果像我这样要求"零改造、稳定可用",face-api.js 依然是最省事的一条路。
七、总结与后续
这套东西的核心价值就一句话:把生物特征留在用户设备里。技术上没有魔法,检测 + 对齐 + 提特征 + 比距离,四步而已;难的是工程细节——模型路径、存储格式、镜像同步、canvas 尺寸,每一个都能让你调半天。
后续想做的迭代:
完整工程(含 public/models/ 权重、一键启动脚本、GitHub Pages 部署配置)我已经整理好了,需要的同学评论区扣「源码」,我看到会一一回复;也欢迎点个关注,后续会把端侧推理这个系列继续更下去。
如果你在跑的过程中遇到了别的坑,评论区一起交流,我踩到的新坑会补进这篇文章。
网硕互联帮助中心


评论前必须登录!
注册