浏览器本地视频换脸实践:WebGPU、ONNX Runtime Web 与 WebCodecs 的完整推理链路
前言
过去,视频换脸通常依赖云端 GPU:用户上传素材,服务器逐帧推理,再将处理结果返回客户端。
随着 WebGPU、WebCodecs、Web Worker 和 ONNX Runtime Web 逐渐成熟,一部分视频 AI 能力已经可以直接运行在浏览器中。素材无需上传推理服务器,模型可以按需缓存,处理结果也能直接加入本地视频编辑工程。
但真正实现后会发现,模型推理只是问题的一部分。数据格式转换、线程通信、显存峰值、目标跟踪、遮罩后处理、内存释放和任务取消,都会影响最终体验。
本文以浏览器本地视频换脸为例,拆解一条完整的 WebGPU 视频推理链路,并总结其中值得复用的工程经验。
项目仓库:
负责任使用说明:本文仅讨论浏览器端视觉推理技术。请只使用已取得本人及相关权利人明确授权的人脸和视频素材。不得用于违法、侵权、虚假、误导、身份冒用、诈骗或损害他人权益的内容,也不得将生成结果冒充真实影像。使用者应自行遵守适用法律、素材许可和发布平台规则,并承担违规使用产生的责任。
一、浏览器视频换脸的完整数据链路
一帧视频从解码到重新写入视频,大致需要经过以下流程:
VideoFrame / Canvas
↓
RGBA Uint8ClampedArray
↓
NCHW Float32Array
↓
ONNX Tensor
↓
生成 RGB 与 Alpha Mask
↓
Canvas 合成
↓
WebP 中间帧
↓
WebM 视频编码
这条链路中的性能消耗,不只发生在模型推理阶段。
Canvas 读取到的是按照 RGBA 顺序交错排列的像素,而 SCRFD 和 MobileFaceSwap 使用 NCHW 张量布局。推理前,需要将三个颜色通道拆分到连续内存中:
const plane = width * height;
const tensor = new Float32Array(plane * 3);
for (let i = 0; i < plane; i += 1) {
tensor[i] = normalize(rgba[i * 4]);
tensor[plane + i] = normalize(rgba[i * 4 + 1]);
tensor[plane * 2 + i] = normalize(rgba[i * 4 + 2]);
}
单次转换看起来并不复杂,但视频处理需要逐帧执行。Canvas 像素读取、Float32Array 创建和线程间传输叠加后,会形成明显的 CPU 与内存带宽压力。
因此,浏览器 AI 模型的输入尺寸不能只根据精度决定,还要考虑:
- Canvas 像素读取成本;
- 张量构造成本;
- Worker 通信成本;
- 临时对象引发的垃圾回收;
- GPU Buffer 的分配压力。
二、为什么检测和生成要使用不同分辨率
人脸检测需要观察完整画面,而换脸生成只需要处理对齐后的人脸区域。两者的任务不同,没有必要使用相同分辨率。
| 处理阶段 | 输入尺寸 | 主要目的 |
|---|---|---|
| SCRFD 人脸检测 | 640×640 | 从完整画面中定位人脸与关键点 |
| 身份特征提取 | 112×112 | 提取相对不受姿态影响的身份特征 |
| 换脸生成 | 224×224 | 在目标姿态下生成新的身份人脸 |
| 光流分析 | 最长边不超过 720px | 低成本追踪五点关键点 |
| 最终合成 | 原视频分辨率 | 保留背景和原始画面细节 |
如果直接把完整的 1080p 视频帧输入生成模型,大部分计算都会浪费在背景区域。
更合理的流程是:
完整画面检测
↓
确定目标人脸
↓
根据关键点完成对齐
↓
裁剪 224×224 人脸 ROI
↓
运行换脸生成网络
↓
映射回原视频画面
“全画面检测、局部生成、原分辨率合成”是这类模型能够在浏览器中运行的重要原因。
三、模型并行下载,WebGPU Session 串行初始化
浏览器首次执行任务时,需要下载多个 ONNX 模型,并创建对应的 WebGPU Session。
模型文件之间通常没有下载依赖,因此可以并行获取:
const [
detectorBuffer,
identityBuffer,
conditionerBuffer,
generatorBuffer,
] = await Promise.all(modelDownloads);
但是,WebGPU Session 不适合无节制地并发初始化。
一次 Session 创建可能包含:
- 解析 ONNX 计算图;
- 图优化与算子融合;
- WebGPU Shader 生成;
- Pipeline 编译;
- 模型权重上传;
- GPU Buffer 分配。
如果多个模型同时初始化,浏览器可能在短时间内创建大量 Shader 和 GPU Buffer,导致初始化卡顿、显存峰值上升,部分设备甚至可能出现 GPU Device Lost。
因此可以采用“并行下载、串行编译”的方式:
const [
detectorBuffer,
identityBuffer,
conditionerBuffer,
generatorBuffer,
] = await Promise.all(modelDownloads);
const detector = await createSession(detectorBuffer);
const identity = await createSession(identityBuffer);
const conditioner = await createSession(conditionerBuffer);
const generator = await createSession(generatorBuffer);
这种调度方式同时兼顾了网络吞吐量和 GPU 初始化稳定性。
四、使用 Transferable 减少主线程与 Worker 之间的数据复制
为了避免推理阻塞界面,视频帧预处理、人脸检测和模型推理通常会放到 Web Worker 中执行。
如果发送 ArrayBuffer 时没有指定 transfer list,浏览器可能执行结构化克隆。
以一张 640×640 的 Float32 RGB 张量为例:
640 × 640 × 3 × 4 Bytes ≈ 4.69 MB
如果每个检测锚点都复制一次,内存带宽和垃圾回收压力会迅速增加。
可以通过 Transferable 直接转移 Buffer 所有权:
worker.postMessage(
{
type: "detect",
pixels: tensor.buffer,
},
[tensor.buffer],
);
传输后,原线程中的 Buffer 会进入 detached 状态,Worker 直接接管它的所有权。
换脸生成得到的 RGB Tensor 和 Alpha Mask,也可以用相同方式返回主线程。
Transferable 不会让模型本身的推理速度变快,但可以减少:
- 跨线程内存复制;
- 短生命周期的大对象;
- 垃圾回收频率;
- 连续视频处理中的内存峰值。
五、使用双向光流判断关键点跟踪是否可信
如果每一帧都重新运行完整的人脸检测,计算成本会比较高。
一种常见方案是:
- 在锚点帧运行 SCRFD;
- 在相邻帧之间使用 Lucas–Kanade 光流传播关键点;
- 定期重新执行检测,纠正累计误差。
但光流只能估算局部像素位移,不能保证结果一定正确。因此,需要增加双向检查。
假设上一帧关键点为 (p_t),正向追踪得到:
p t + 1 = F ( I t , I t + 1 , p t ) p_{t+1}=F(I_t,I_{t+1},p_t) pt+1=F(It,It+1,pt)
再将得到的点反向追踪:
p t ^ = F ( I t + 1 , I t , p t + 1 ) \hat{p_t}=F(I_{t+1},I_t,p_{t+1}) pt^=F(It+1,It,pt+1)
双向误差定义为:
e f b = ∥ p t ^ − p t ∥ 2 e_{fb}=\lVert \hat{p_t}-p_t \rVert_2 efb=∥pt^−pt∥2
如果跟踪稳定,反向结果应该回到原位置附近。
实现中可以计算五个关键点的平均双向误差,只有满足以下条件时才接受结果:
- 至少四个关键点有效;
- 平均双向误差低于阈值;
- 人脸框面积变化没有明显异常;
- 目标位置没有发生不合理跳跃。
这种检查能够过滤:
- 快速运动造成的错误匹配;
- 手或物体遮挡;
- 运动模糊;
- 人物离开画面;
- 光照突然变化;
- 关键点漂移到背景纹理。
需要注意,光流只适合短距离传播,不能无限续接。系统仍需定期重新运行 SCRFD,防止误差持续累积。
六、多人画面中如何维持同一个目标身份
如果每帧都选择检测置信度最高的人脸,多人视频中很容易发生身份切换。
新进入画面的人脸可能更大、更清晰,检测分数也可能更高,但他并不是用户最初选择的目标人物。
因此,目标选择需要从“逐帧最高分”转变为“时序匹配”。
可以综合考虑:
- 与上一目标中心点的距离;
- 人脸框面积变化;
- 当前检测置信度;
- 首帧与画面中心的距离。
评分可以抽象为:
S = w c C − w d D − w a A S=w_cC-w_dD-w_aA S=wcC−wdD−waA
其中:
- (C) 表示检测置信度;
- (D) 表示中心点距离;
- (A) 表示面积变化;
- (w_c)、(w_d)、(w_a) 是对应权重。
首帧没有历史信息时,可以优先选择:
- 检测置信度较高;
- 人脸面积较大;
- 距离画面中心较近的候选。
后续帧则更加重视目标在位置和尺度上的连续性。
这还不等于完整的人脸重识别,但比逐帧选择最高置信度候选稳定得多。
如果当前匹配结果不可信,应保留原始帧,不要把换脸结果应用到错误人物上。
七、生成结果为什么还需要传统图像处理
7.1 遮罩形态学处理
换脸生成器输出的 Mask 可能存在孔洞、毛刺或边缘不连续。如果直接用于合成,融合区域可能出现明显闪烁。
一条典型的后处理流程如下:
原始 Mask
↓
二值化
↓
膨胀:填补局部断裂
↓
腐蚀:去除外围毛刺
↓
再次腐蚀:适当收缩融合区域
↓
Box Blur:生成平滑 Alpha
↓
边界安全遮罩:消除裁剪框硬边
膨胀和腐蚀可以使用可分离滑动窗口算法:先执行水平方向过滤,再执行垂直方向过滤。
对于半径为 (r) 的二维形态学操作,直接遍历完整邻域的复杂度大致为:
O ( W × H × r 2 ) O(W \times H \times r^2) O(W×H×r2)
可分离滑动窗口可以将其降低到接近:
O ( W × H ) O(W \times H) O(W×H)
即使模型推理已经使用 GPU,逐帧运行的传统图像算法仍然值得优化。
7.2 限制范围的颜色匹配
如果源身份图片与目标视频的色温差异较大,生成脸与颈部、额头边缘可能出现明显色差。
可以在有效 Mask 区域中统计生成结果与目标人脸的均值和标准差,并执行通道级校正:
I ′ = σ t σ s ( I − μ s ) + μ t I'=\frac{\sigma_t}{\sigma_s}(I-\mu_s)+\mu_t I′=σsσt(I−μs)+μt
其中:
- (\mu_s,\sigma_s) 为生成脸的均值和标准差;
- (\mu_t,\sigma_t) 为目标脸的均值和标准差。
但不能无限制地应用完整校正,否则可能放大噪声,或者在极端光照下生成异常颜色。
可以限制缩放和偏移范围:
const scale = clamp(targetStd / sourceStd, 0.78, 1.22);
const shift = clamp(
targetMean - sourceMean * scale,
-0.12,
0.12,
);
最后,将校正结果与生成结果混合,而不是完全替换。
这样既能减少肤色断层,也能保留生成模型恢复的局部纹理。
八、模型缓存必须绑定不可变版本
浏览器缓存模型时,URL 实际上就是缓存身份的一部分。
如果生产环境始终加载:
repository/resolve/main/model.onnx
即使 URL 没有变化,远端文件内容也可能已经更新,进而产生以下问题:
- 相同前端版本得到不同推理结果;
- 浏览器缓存仍是旧模型,服务器已经变成新模型;
- 出现回归后无法确定实际使用的版本;
- 多个镜像在短时间内内容不一致。
生产环境应该把模型 URL 固定到明确的不可变 revision:
repository/resolve/<immutable-revision>/model.onnx
同时记录:
- 文件大小;
- SHA-256;
- 模型用途;
- 模型许可证;
- 输入输出 Tensor 定义。
下载完成后,还应检查文件大小或哈希。如果结果与配置不一致,应拒绝创建 Session,避免把 CDN 错误页面或不完整文件当作 ONNX 模型加载。
九、视频 AI 的内存管理不能只依赖垃圾回收
浏览器处理视频时,会同时占用多类资源:
- 解码帧;
- Canvas 像素;
- Float32 输入张量;
- ONNX 输出张量;
- 光流灰度图;
- 中间帧 Blob;
- 编码器缓冲区。
如果完全依赖 JavaScript 垃圾回收,处理几十帧之后就可能出现明显的内存增长。
需要主动释放的资源包括:
tensor.dispose?.();
bitmap.close();
mat.delete();
input.dispose();
URL.revokeObjectURL(url);
worker.terminate();
ONNX Tensor、ImageBitmap、OpenCV Mat 和 Object URL 分别由不同的运行时管理,释放方式并不统一。
尤其需要注意 OpenCV.js 的 Mat。它使用的是 WASM 堆内存,即使 JavaScript 对象已经失去引用,底层内存也不一定会立即释放,因此需要明确调用:
mat.delete();
对于长视频任务,资源释放往往比优化某一次推理更加重要。
十、真正的取消必须贯穿整个任务链路
关闭进度弹窗并不代表任务已经停止。
一个完整的取消机制需要覆盖:
- 模型下载;
- 视频帧读取;
- 人脸检测;
- 光流跟踪;
- 逐帧换脸;
- 中间帧压缩;
- 最终视频编码。
主线程可以使用 AbortController 管理任务:
controller.abort();
worker.postMessage({
type: "cancel",
requestId,
});
Worker 需要在以下位置检查取消状态:
- 模型下载循环中;
- 每次推理之前;
- 每次推理之后;
- 帧处理循环中;
- 编码开始之前;
- 任务 ID 发生变化时。
取消后不应继续编码,也不应把不完整结果加入素材库。
否则就会出现“界面已经取消,但后台仍在占用 GPU 和 CPU”的假取消问题。
十一、浏览器 AI 应该如何进行性能测试
“秒级完成”不是一个可复现的性能结论。
测试浏览器视频 AI 时,至少应该记录以下环境:
设备型号:
GPU:
操作系统:
浏览器版本:
WebGPU Adapter:
视频编码格式:
视频分辨率:
视频时长:
输出 FPS:
检测锚点 FPS:
是否首次加载:
模型初始化耗时:
帧处理耗时:
生成器累计推理耗时:
编码耗时:
峰值内存:
同时需要区分冷启动和热启动。
1. 冷启动
冷启动包含:
- 模型下载;
- 文件大小或哈希校验;
- ONNX Session 创建;
- Shader 编译;
- 源身份特征提取;
- 视频处理;
- 最终编码。
冷启动主要反映模型分发和设备初始化体验。
2. 热启动
热启动假设:
- 模型已经缓存;
- Worker 仍然存活;
- Session 已经创建;
- 源身份特征可以复用。
此时只统计:
- 视频解码;
- 锚点检测;
- 光流跟踪;
- 逐帧生成;
- 后处理;
- 视频编码。
热启动更接近用户在同一页面连续处理多个视频片段时的真实体验。
冷启动和热启动数据不应该混在一起,也不应该只展示速度更快的一组。
十二、浏览器本地处理不等于可以忽略合规
浏览器本地推理可以避免把原始素材上传到推理服务器,但这并不意味着可以忽略人脸素材的授权和生成内容的使用边界。
公开或实际使用前,至少应该确认:
- 是否获得本人及素材权利人的明确授权;
- 授权是否覆盖当前处理和发布用途;
- 图片、视频、模型权重及代码是否符合许可证;
- 是否可能造成身份冒用或事实误认;
- 是否需要明确标注内容经过 AI 处理;
- 是否涉及未成年人、公众人物或未授权第三方;
- 是否可能被用于诈骗、诽谤、骚扰或虚假信息;
- 是否提供取消、删除和结果管理能力。
对于换脸类功能,系统设计应该尽可能“失败关闭”:当目标身份无法可靠维持时,不生成效果,而不是把结果错误地应用到其他人物。
总结
浏览器本地视频换脸并不是简单地把 ONNX 模型放进网页。
要让功能从“可以运行”变成“可以持续使用”,至少需要同时处理四类问题:
- 控制计算边界:只在必要的分辨率和人脸 ROI 上运行模型;
- 合理调度资源:并行下载模型、串行初始化 Session,并减少线程间复制;
- 验证结果可信度:利用双向光流、时序匹配和 Mask 质量检查阻止错误传播;
- 管理浏览器运行时:固定模型版本、主动释放资源,并实现真正贯穿底层的取消机制。
WebGPU 为浏览器提供了计算能力,但真正决定体验的,是推理前后的完整工程设计。
这些方法同样适用于其他浏览器视频 AI 场景,例如:
- 人像分割;
- 视频超分辨率;
- 目标跟踪;
- 视频风格化;
- 水印修复;
- 人物轮廓特效;
- 浏览器本地生成式视频编辑。

1492

被折叠的 条评论
为什么被折叠?



