端侧推理: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 BJSON.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 模型"。
五、改法清单
按收益从大到小排:
- 首屏只加载检测器 :
loadModels拆成两段,tinyFaceDetector加载完就modelReady = true,识别网络等真正要入库/比对时再await。首屏下载量 6.67MB → 0.18MB,省 97.2%。 - 描述子改成延迟计算 :
detectFaces加一个withDescriptor开关,runRecognize里只有!libEmpty时才走.withFaceDescriptors()。图库为空时,一次识别能省掉 N 次识别网络前向。 - 图库缓存 :
listFaces()结果缓存在内存,只在libraryChanged事件时失效。摄像头 1000 人图库下,每帧省掉约 14ms 的反序列化。 - 描述子压缩存储 :
Array.from换成base64(Float32Array)(1.34x)或 Int8 量化(4x,实测误差 0.0269,远低于 0.55 阈值)。 - 摄像头 inputSize 再降一档:320 → 224,按实测曲线算力再降一半(30.3%),代价是 500 → 245 个候选框,小脸召回会掉。
- 多人脸场景限流:一张图超过 N 张脸时,只对比最大的前 N 张,避免合影场景识别网络前向次数爆炸。
六、踩坑记录
createTinyFaceDetector收的是扁平 Float32Array,不是 weight map。 一开始我直接把tf.io.decodeWeights()的 NamedTensorMap 传进去,报remainingWeights.slice is not a function。必须按 manifest 里 weights 的顺序把每个张量data()拼成一个大Float32Array再传。多 shard 的模型(识别网络有两个)还要先把 shard 按顺序拼成一个 ArrayBuffer 再 decode。tf.profile()返回的是 Promise,不是 ProfileInfo。 忘了await直接读peakBytes得到undefined,表里的"峰值显存"列全是 NaN。补上await后才拿到 6.65MB~136.78MB 这组数。- 别拿随机输入出来的描述子当人脸特征用。 我用纯噪声图跑识别网络,L2 范数是 4.4113,分量落在 -1.04, 0.84;真实人脸的描述子量级完全不同。所以账本 5 里我只用它验证"存储体积"和"量化误差"这类与语义无关的性质,绝不敢用它去调 0.55 这个阈值。
inputSize不是随便填的。TinyYolov2Options里有硬校验inputSize % 32 !== 0直接抛错,想填 300、350 这种"看起来更合理"的值会在运行时炸掉。项目里写 320 是合规的,但scoreThreshold也有校验,必须在 (0,1) 区间。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 和一键启动脚本)我已经整理好了,需要的同学在评论区扣「源码」,我看到会一条条回;也欢迎点个关注,端侧推理这个系列我会继续往下写。