云计算百科
云计算领域专业知识百科平台

端侧推理实战:face-api.js 零后端人脸识别踩坑记

端侧推理人工智能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 尺寸,每一个都能让你调半天。

    后续想做的迭代:

  • 把推理丢进 Web Worker + OffscreenCanvas,主线程不再被阻塞;
  • 接入 WebGPU 后端,对比 WebGL 下的实际提速幅度;
  • 补一层活体检测(眨眼/摇头),堵住照片攻击;
  • 底库规模上去之后,把线性比对换成 HNSW 近似检索。

  • 完整工程(含 public/models/ 权重、一键启动脚本、GitHub Pages 部署配置)我已经整理好了,需要的同学评论区扣「源码」,我看到会一一回复;也欢迎点个关注,后续会把端侧推理这个系列继续更下去。

    如果你在跑的过程中遇到了别的坑,评论区一起交流,我踩到的新坑会补进这篇文章。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 端侧推理实战:face-api.js 零后端人脸识别踩坑记
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!