端侧推理:face-api.js 首屏 92% 是白下载的

端侧推理:face-api.js 首屏 92% 是白下载的

端侧推理最容易翻车的是体积。把 face-api.js 的 public/models 拆开算账:6.67MB 模型里 FaceRecognitionNet 独占 92.1%,而"能框出人脸"只需 0.18MB 的 TinyFaceDetector。本文用真实权重实测,摆出 7 个性能账本。

本文所有数字来自 tools/_measure_faceai.js:直接读 public/models/ 下真实的 *-weights_manifest.json 与 shard 文件,用 face-api.js 0.22.2 自带的 createTinyFaceDetector / createFaceRecognitionNet 跑真实前向。运行环境是 Node 22 + @tensorflow/tfjs-core@1.7.0 的 CPU 后端,绝对耗时只作相对参考,与浏览器 WebGL 后端的量级不同;而体积、参数量、候选框数、张量峰值这些与后端无关,是硬数。

一、项目能干什么

faceAI 是一个零后端的人脸识别小应用:Vue3 + Vite + face-api.js,所有模型和人脸数据都留在浏览器本地,图片不上传服务器。

能力 实现位置 用到的模型
上传图片识别(多人脸) RecognizePanel.vue runRecognize() 检测 + 关键点 + 特征
摄像头实时识别 RecognizePanel.vue detectFromCamera() 检测 + 关键点 + 特征
人脸入库/建图库 addToLibrary() + db.js 检测 + 关键点 + 特征
1:N 比对 faceApi.js findBestMatch() 纯 JS,不用模型
本地存储 db.js(Dexie / IndexedDB) ---

项目里 DISTANCE_THRESHOLD = 0.55,小于它判定为同一个人;摄像头检测间隔 DETECT_INTERVAL = 300ms,检测器输入尺寸 DETECT_INPUT_SIZE = 320。

二、技术选型:端侧推理的四条路线

做端侧推理(on-device inference)时,最常拿来对比的是这几条路线:

方案 体积 优点 本项目没选的原因
face-api.js (TF.js) 6.67MB 三模型一条链式 API,检测+关键点+特征开箱即用 ---
MediaPipe Face Mesh ~3MB 关键点精度高、有 WASM 加速 不直接给 128 维特征向量,1:N 比对要自己训
Transformers.js 视模型而定 2026 年生态最活跃,支持 WebGPU 人脸比对需要自己找/转 ONNX 模型
服务端 API 0 不占端资源 违背"图片不上传"这个立项目标

face-api.js 的代价是版本锁死:face-api.js@0.22.2 依赖 @tensorflow/tfjs-core@1.7.0,这个版本里后端只有 cpu 和 webgl 两个,吃不到 WebGPU / WASM 后端的红利。想真正吃到 WebGPU,得把整条依赖升到 TF.js 4.x 的 tfjs-backend-webgpu------这是后话,先把手头的账算清楚。

三、核心链路:一条链串三个模型

text 复制代码
一帧画面(摄像头 640x480)
   │
   ├─ ① TinyFaceDetector   inputSize=320 ──→ 1 x 10 x 10 x 25
   │      13 类下采样:网格 = inputSize / 32,每格 5 个 anchor
   │      → 10x10x5 = 500 个候选框 → NMS(iou 0.4)
   │
   ├─ ② FaceLandmark68Net  350x350(每张脸跑一次)──→ 68 个点
   │
   └─ ③ FaceRecognitionNet 150x150(每张脸跑一次)──→ Float32Array(128)
                                                        │
                                              findBestMatch 线性扫图库

关键点在这里:②③ 是"每张脸各跑一次" 。face-api.js 源码里 ComputeAllFaceDescriptorsTask 是这么写的:

js 复制代码
// node_modules/face-api.js/dist/face-api.js
Promise.all(faces.map(function (face) {
  return nets.faceRecognitionNet.computeFaceDescriptor(face)
}))

一张合影里有 5 张脸,识别网络就要前向 5 次。这个放大系数在多人脸场景下比 inputSize 还致命。

网格为什么是 inputSize / 32?因为 TinyFaceDetector 的网络配置里 filterSizes: [3, 16, 32, 64, 128, 256, 512],经过 5 次步长为 2 的下采样,特征图边长正好是输入的 1/32;再乘上 BOX_ANCHORS 里的 5 组 anchor,候选框总数就是 (inputSize/32)² × 5。实测输出张量最后一维恒为 25,也就是 5 × (4 个框坐标 + 1 个类别分),与这个推导完全吻合。理解这一点很重要:调 inputSize 不是线性调精度,而是在平方级地买卖算力和召回。

四、7 个性能账本

账本 1:6.67MB 里,92.1% 是识别网络

模型 磁盘体积 占比 张量数 参数量
tinyFaceDetector 0.18 MB 2.8% 19 0.193 M
faceLandmark68Net 0.34 MB 5.1% 49 0.357 M
faceRecognitionNet 6.15 MB 92.1% 117 5.615 M
合计 6.67 MB 100% 185 6.165 M

而 App.vue 的 onMounted 是这么等的:

js 复制代码
// src/App.vue ------ 三个模型全部加载完,UI 才解锁
onMounted(async () => {
  refreshCount()
  try {
    await loadModels((p) => {           // ← 等的是"三个都好"
      const idx = loadingSteps.value.findIndex((s) => s.name.includes(mapName(p.name)))
      if (idx >= 0) loadingSteps.value[idx].done = true
    })
    modelReady.value = true             // ← 6.67MB 全下完才 true
  } catch (err) { /* ... */ }
})

modelReady 不置 true,整个界面就是加载态。首屏体验被 6.15MB 的识别网络绑死了。

账本 2:权重是 uint8 量化的,内存里要放大 3.53 倍

翻 manifest 才发现,185 个张量里 175 个带 quantization 字段(识别网络 117 个里有 109 个)。也就是说磁盘上存的是量化权重,解码回 float32 后:

  • 磁盘 6.67 MB → 内存 23.52 MB float32 参数,整体放大 3.53x(检测器和关键点是整 4.00x,识别网络 3.49x)
  • 反量化本身要花 CPU:Node 上实测串行解码三个模型共 467ms ,其中识别网络一个就 424ms,约合 每 MB 磁盘权重 70ms

量化省了 3.53 倍下载,代价是 467ms 的解码 CPU 时间,移动端只会更慢。这是端侧推理很典型的一笔交换,也是业界公认的"第一杠杆":量化把 7GB 模型压到 2GB 这类案例在 2026 年已经很常见,但很少有人把反量化这一段 CPU 开销单独拎出来算。实测下来每 MB 磁盘权重约 70ms------看着不起眼,可它发生在首屏,而且是主线程上的同步工作,6.15MB 的识别网络意味着主线程要连续干 400 多毫秒才能喘口气。想要缓解,要么把解码挪到 Worker,要么改用浏览器原生支持、无需手动反量化的权重格式。

另外注意一个容易被忽略的点:量化是按张量 做的,识别网络 117 个张量里只有 109 个带量化信息,剩下 8 个(包括 fc 全连接层)以 float32 原样存放,这也是它整体放大倍数只有 3.49x、而另外两个模型都是整 4.00x 的原因。

账本 3:inputSize 是平方级开销,320 已经比 416 省 40%

inputSize 必须是 32 的倍数(TinyYolov2Options 里有 inputSize % 32 !== 0 就 throw)。真实前向实测:

inputSize 输出张量 网格 候选框 峰值张量 相对 416 的耗时
128 1x4x4x25 4x4 80 6.65 MB 10.5%
224 1x7x7x25 7x7 245 19.03 MB 30.3%
320 1x10x10x25 10x10 500 38.24 MB 61.6%
416 1x13x13x25 13x13 845 64.27 MB 100%
512 1x16x16x25 16x16 1280 97.11 MB 154.0%
608 1x19x19x25 19x19 1805 136.78 MB 210.4%

像素量、候选框数、峰值张量、实测耗时四条曲线几乎完全重合------这个检测器是彻底的计算密集型,没有捷径。项目里单图识别用 416、摄像头用 320,320 相比 416 直接省掉 38.4% 的算力和 40.5% 的张量内存,这个取舍是对的。

账本 4:图库为空时,识别网络跑了个寂寞

看 faceApi.js:

js 复制代码
// src/utils/faceApi.js ------ 不管图库有没有东西,描述子照算
export async function detectFaces(input) {
  const detectorOptions = new faceapi.TinyFaceDetectorOptions({
    inputSize: 416,
    scoreThreshold: 0.45
  })
  // 一次拿齐检测 + 关键点 + 描述子(← 无条件 withFaceDescriptors)
  const results = await faceapi
    .detectAllFaces(input, detectorOptions)
    .withFaceLandmarks()
    .withFaceDescriptors()          // ← 每张脸一次 recognition 前向

  return results.map((r) => ({ box: r.detection.box, score: r.detection.score,
    landmarks: r.landmarks, descriptor: r.descriptor }))
}

而调用方 runRecognize() 是这么用的:

js 复制代码
// src/components/RecognizePanel.vue
const detections = await detectFaces(img)      // ← 这里已经把描述子算完了
const library = await listFaces()
const libEmpty = library.length === 0
let matches = []
if (!libEmpty) {                                // ← 图库空的话,描述子一个都没用上
  matches = detections.map((d) => { /* findBestMatch ... */ })
}

新用户第一次打开,图库必然是空的。这时 6.15MB 的识别网络不仅白下载,每张脸还要白跑一次前向(CPU 后端实测单次 425ms)。

账本 5:描述子存成普通数组,膨胀 5.02 倍

js 复制代码
// src/utils/db.js ------ IDB 不支持 TypedArray,只能转普通数组
export async function addFace({ name, descriptor, thumbnail }) {
  const id = await db.faces.add({
    name: name || '未命名',
    descriptor: Array.from(descriptor),   // Float32Array(128) → 长度 128 的普通数组
    thumbnail,
    createdAt: Date.now()
  })
  return id
}

实测同一条 128 维描述子:

  • Float32Array 原始:512 B
  • JSON.stringify(Array.from(...)):2571 B,膨胀 5.02x(项目现在的等价存法)
  • base64(Float32Array):684 B,膨胀 1.34x
  • Int8 absmax 量化:128 B,压缩 4x
图库规模 普通数组 base64 Int8
100 人 0.25 MB 0.07 MB 0.01 MB
1000 人 2.45 MB 0.65 MB 0.12 MB
10000 人 24.52 MB 6.52 MB 1.22 MB

顺带测了 Int8 量化的可行性:反量化后与原向量的欧氏距离只有 0.0269,而项目判定阈值是 0.55------误差不到阈值的 5%,压缩却能做到 4 倍。想省 IndexedDB 空间,这条路是通的。

账本 6:摄像头每帧全量拉一次图库

js 复制代码
// src/components/RecognizePanel.vue ------ detectTimer = setInterval(detectFromCamera, 300)
async function detectFromCamera() {
  // ...
  const result = await faceapi.detectSingleFace(v, detector)
    .withFaceLandmarks().withFaceDescriptor()

  if (result) {
    lastFace = detection
    const library = await listFaces()      // ← 每 300ms 全表拉一次 IndexedDB
    libraryCount.value = library.length
    const m = findBestMatch(detection.descriptor, library)
    // ...
  }
}

db.js 的 listFaces() 是 db.faces.orderBy('createdAt').reverse().toArray(),全表读。图库 1000 人时,每帧要把 1000×128 个数字反序列化出来,实测等价量级:

  • 100 条 → 0.25 MB,反序列化约 1.36 ms
  • 1000 条 → 2.51 MB,约 14.16 ms
  • 5000 条 → 12.56 MB,约 69.66 ms

而每帧预算只有 300ms,光把图库读出来就吃掉 5%~23%。

账本 7:1:N 比对本身不慢,慢的是它旁边那两件事

findBestMatch 就是个 128 维欧氏距离的线性扫描,实测吞吐稳定在 4500~5000 条/ms:

图库规模 中位耗时 吞吐
100 0.017 ms ~5952 条/ms
1000 0.223 ms ~4488 条/ms
5000 1.007 ms ~4965 条/ms
20000 4.499 ms ~4445 条/ms

2 万人的库扫一遍也才 4.5ms。所以别去优化这个循环------真正该动的是它前面"每帧全表读库"和"每张脸跑一次 6MB 模型"。

五、改法清单

按收益从大到小排:

  1. 首屏只加载检测器 :loadModels 拆成两段,tinyFaceDetector 加载完就 modelReady = true,识别网络等真正要入库/比对时再 await。首屏下载量 6.67MB → 0.18MB,省 97.2%。
  2. 描述子改成延迟计算 :detectFaces 加一个 withDescriptor 开关,runRecognize 里只有 !libEmpty 时才走 .withFaceDescriptors()。图库为空时,一次识别能省掉 N 次识别网络前向。
  3. 图库缓存 :listFaces() 结果缓存在内存,只在 libraryChanged 事件时失效。摄像头 1000 人图库下,每帧省掉约 14ms 的反序列化。
  4. 描述子压缩存储 :Array.from 换成 base64(Float32Array)(1.34x)或 Int8 量化(4x,实测误差 0.0269,远低于 0.55 阈值)。
  5. 摄像头 inputSize 再降一档:320 → 224,按实测曲线算力再降一半(30.3%),代价是 500 → 245 个候选框,小脸召回会掉。
  6. 多人脸场景限流:一张图超过 N 张脸时,只对比最大的前 N 张,避免合影场景识别网络前向次数爆炸。

六、踩坑记录

  1. createTinyFaceDetector 收的是扁平 Float32Array,不是 weight map。 一开始我直接把 tf.io.decodeWeights() 的 NamedTensorMap 传进去,报 remainingWeights.slice is not a function。必须按 manifest 里 weights 的顺序把每个张量 data() 拼成一个大 Float32Array 再传。多 shard 的模型(识别网络有两个)还要先把 shard 按顺序拼成一个 ArrayBuffer 再 decode。
  2. tf.profile() 返回的是 Promise,不是 ProfileInfo。 忘了 await 直接读 peakBytes 得到 undefined,表里的"峰值显存"列全是 NaN。补上 await 后才拿到 6.65MB~136.78MB 这组数。
  3. 别拿随机输入出来的描述子当人脸特征用。 我用纯噪声图跑识别网络,L2 范数是 4.4113,分量落在 -1.04, 0.84;真实人脸的描述子量级完全不同。所以账本 5 里我只用它验证"存储体积"和"量化误差"这类与语义无关的性质,绝不敢用它去调 0.55 这个阈值。
  4. inputSize 不是随便填的。 TinyYolov2Options 里有硬校验 inputSize % 32 !== 0 直接抛错,想填 300、350 这种"看起来更合理"的值会在运行时炸掉。项目里写 320 是合规的,但 scoreThreshold 也有校验,必须在 (0,1) 区间。
  5. db.js 里那句注释说得不全。 源码写"IDB 不支持 TypedArray",其实现代浏览器的结构化克隆是支持 TypedArray 的,Dexie 4 也能直接存 Float32Array。真正卡住的是历史兼容和查询便利性,不是能力问题。

七、实测结果汇总

指标 实测值 说明
模型总下载量 6.67 MB 识别网络占 92.1%
内存 float32 参数 23.52 MB 相对磁盘放大 3.53x
权重反量化耗时 467 ms(串行) 并行理论下限 424ms,只省 9.2%
检测器 416 峰值张量 64.27 MB 128 时只要 6.65MB
识别网络单次前向 425 ms CPU 后端,仅供相对参考
描述子存储 512 B → 2571 B 普通数组膨胀 5.02x
1:N 比对吞吐 ~4500 条/ms 2 万条 4.5ms
首屏按需加载收益 省 97.2% 6.67MB → 0.18MB

八、小结

端侧推理的性能优化,八成的工作不在模型内部,而在"什么时候加载、什么场景才需要算"这些调度问题上。这个项目把三个模型一次性全量加载、无条件算描述子、每帧全表读图库,三件事叠起来,把 0.18MB 就能给到的能力拖到了 6.67MB。

把本文的账本换算成代码改动,全部集中在 src/utils/faceApi.js、src/utils/db.js 和 src/components/RecognizePanel.vue 三个文件里,不换库、不改模型,属于低风险改造。

后面想再往上走一步,就得动依赖了:把 TF.js 升到支持 WebGPU 后端的版本,把量化权重换成 WASM/WebGPU 友好的布局,或者等 WebNN 在移动端铺开------那时候端侧推理的性能曲线会是另一个样子。


完整工程(含这套实测脚本 tools/_measure_faceai.js 和一键启动脚本)我已经整理好了,需要的同学在评论区扣「源码」,我看到会一条条回;也欢迎点个关注,端侧推理这个系列我会继续往下写。

相关推荐
yuzhiboyouye1 小时前
com.fasterxml.jackson.annotation 里最常用高频的注解,可以按“控制字段、控制格式、控制结构、控制流程”来记
java·开发语言
HAPPY酷1 小时前
C++ 内存泄漏排查:从裸 new 到 RAII 修复,附 ASan 实战
开发语言·c++
用户83134859306982 小时前
Vue3 v-for:常用用法与开发高频坑点
前端·javascript·vue.js
十年Java程序媛2 小时前
Java 面试常问 this、super:构造方法调用顺序避坑
java·开发语言·spring boot·面试·个人开发
Funlang2 小时前
fun-ui 开源:把浏览器界面接回来
前端·javascript
2603_965896622 小时前
CSS BEM 命名规范|组件化样式分层权威指南
前端·javascript·css
yuniko-n2 小时前
【JUC】内存屏障
java·开发语言
Linux运维技术栈2 小时前
近期 PHP 高危安全风险排查与多环境升级实操
开发语言·安全·php
Fnetlink12 小时前
Fnet 云网安 261023
运维·开发语言·网络·人工智能·安全·web安全·php