把视频换脸放进浏览器:一次 WebGPU 推理链路的工程化实践
过去,人们往往认为视频换脸必须依赖云端 GPU:上传视频、等待服务端逐帧处理,再下载生成结果。但随着 WebGPU、WebCodecs、Web Worker 和 ONNX Runtime Web 逐渐成熟,一部分原本只能运行在服务端的视觉任务,已经可以迁移到浏览器本地完成。
真正把它做成可用功能后,我们发现,模型推理反而只是问题的一部分。数据搬运、显存峰值、目标跟踪、遮罩后处理、资源释放和任务取消,都会直接决定最终体验。
本文不讨论界面功能,而是拆解一条浏览器本地视频换脸链路,重点介绍其中几项容易被忽略的工程问题。
负责任使用说明: 本文仅讨论浏览器端视觉推理技术。请只使用已获得明确授权的人脸和视频素材,不得用于违法、侵权、虚假或误导性内容,不得冒用他人身份,也不得将生成内容冒充真实影像。违规使用者须自行承担相应责任。
相关实现已集成在开源项目 Timeline Studio 中,项目仓库:MartinDelophy/ai-video-editor。
一、先看完整链路:一帧视频经历了什么
一帧视频从解码到重新写入视频,大致会经过以下过程:
VideoFrame / Canvas
↓
RGBA Uint8ClampedArray
↓
NCHW Float32Array
↓
ONNX Tensor
↓
生成结果与 Alpha Mask
↓
Canvas 合成
↓
WebP 中间帧
↓
WebM 视频编码
这条链路中,每一个箭头都可能产生时间和内存成本。
SCRFD 与 MobileFaceSwap 使用 NCHW 张量布局,Canvas 读取到的像素则以 RGBA 交错排列。推理前,需要把像素拆分为三个连续的颜色通道:
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 像素读取、Float32 张量构造和线程间传输,可能共同形成明显的 CPU 与内存带宽开销。
因此,模型输入尺寸不能只按精度决定,还要把数据转换成本纳入设计。
二、不要把整张 1080p 画面交给生成模型
检测与生成解决的是两个不同问题,适合使用不同分辨率。
| 阶段 | 输入尺寸 | 主要目的 |
|---|---|---|
| SCRFD 检测 | 640×640 | 从完整画面中定位人脸及关键点 |
| 身份提取 | 112×112 | 提取对姿态相对稳定的源身份特征 |
| 换脸生成 | 224×224 | 在目标姿态下生成新的身份人脸 |
| 光流分析 | 最长边不超过 720px | 以较低成本追踪五点关键点 |
| 最终合成 | 原视频分辨率 | 保留背景和原始画面细节 |
检测阶段需要观察完整画面,640×640 有助于提高小人脸的召回率;生成阶段只需要处理完成对齐的人脸区域,因此固定在 224×224。
如果直接把完整的 1080p 视频帧送入生成网络,大部分计算都会消耗在不会发生变化的背景上。先检测、再对齐、最后只处理人脸 ROI,是这类模型能够在浏览器中运行的关键。
三、模型下载可以并行,WebGPU Session 最好串行创建
浏览器首次运行任务时,需要完成模型下载与 Session 初始化。两者适合采用不同的并发策略。
模型文件之间没有依赖,可以并行下载;但创建 ONNX Session 通常还包含:
- 解析 ONNX 计算图;
- 图优化与算子融合;
- 生成 WebGPU Shader;
- 编译 Pipeline;
- 上传权重;
- 分配 GPU Buffer。
如果多个模型同时创建 Session,浏览器会在短时间内编译大量 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 初始化压力集中到同一时刻。
四、跨线程传输时,少一次复制就是一次优化
为了避免推理和图像处理阻塞界面,视频帧与模型张量通常会被送入 Web Worker。
问题在于,如果向 Worker 发送 ArrayBuffer 时没有提供 transfer list,浏览器可能执行结构化克隆。以一张 640×640 的 Float32 RGB 张量为例:
640 × 640 × 3 × 4 Bytes ≈ 4.69 MB
当检测锚点不断产生时,重复复制这类对象会快速增加内存带宽与垃圾回收压力。
更合适的方式是直接转移 Buffer 所有权:
worker.postMessage(
{
type: "detect",
pixels: tensor.buffer,
},
[tensor.buffer],
);
传输完成后,原线程中的 Buffer 会进入 detached 状态,Worker 直接接管其所有权。换脸生成的 RGB 结果和 Alpha Mask 也可以用同样的方式返回主线程。
Transferable 并不会缩短模型本身的推理时间,但能显著减少跨线程复制和短生命周期大对象,这在连续视频处理里十分重要。
五、光流不是答案,可信度检查才是
如果每一帧都重新运行完整的人脸检测,成本会很高。一个常见做法是在锚点帧执行检测,在相邻帧之间使用 Lucas–Kanade 光流传播关键点。
但光流只能估算局部像素位移,不能保证结果一定正确。为此,可以使用双向光流检查。
假设上一帧关键点为 (p_t),正向追踪得到:
[
p_{t+1}=F(It,I{t+1},p_t)
]
再把结果从下一帧反向追踪:
[
\hat{pt}=F(I{t+1},It,p{t+1})
]
双向误差定义为:
[
e_{fb}=\lVert \hat{p_t}-p_t \rVert_2
]
如果跟踪稳定,反向结果应该回到原位置附近。实现中可以计算五个关键点的平均双向误差,只有至少四个关键点有效、且平均误差不超过阈值时,才接受当前跟踪结果。
这项检查能够过滤多类异常:
- 快速运动造成的错误匹配;
- 手或物体遮挡人脸;
- 运动模糊;
- 人物离开画面;
- 光照突变;
- 关键点漂移到背景纹理。
需要强调的是,光流适合短距离传播,不适合无限续接。系统仍应按固定间隔重新运行 SCRFD,以抑制累计误差。
六、多人画面里,最高置信度不等于正确目标
逐帧选择检测分数最高的人脸,在单人视频中可能有效,但在多人画面中很容易发生身份切换。新进入画面的人可能更大、更清晰,检测置信度也可能更高。
更稳妥的思路是把目标选择变成时序匹配问题。对每个候选人脸综合计算:
- 与上一目标中心点的距离;
- 人脸框面积变化;
- 当前检测置信度;
- 首帧与画面中心的距离。
评分可以抽象为:
[
S=w_cC-w_dD-w_aA
]
其中,(C) 表示检测置信度,(D) 表示中心点距离,(A) 表示面积变化,(w_c)、(w_d)、(w_a) 为相应权重。
首帧没有历史信息时,优先选择置信度较高、面积较大且靠近画面中心的人脸;后续帧则更重视目标在空间和尺度上的连续性。
这还不是完整的人脸重识别,但已经比“每帧取最高分”稳定得多。更重要的是,当匹配结果不可信时,应保留原始帧,而不是把换脸结果应用到错误人物上。
七、生成之后,还有两项传统图像处理工作
7.1 遮罩形态学处理
生成器输出的遮罩可能包含孔洞、毛刺和不连续边缘,直接用于合成会造成融合区域闪烁。
一条典型的后处理链路如下:
原始 Mask
↓
二值化
↓
膨胀:填补局部断裂
↓
腐蚀:移除外围毛刺
↓
再次腐蚀:适当收缩融合区域
↓
Box Blur:生成平滑 Alpha
↓
边界安全遮罩:消除裁剪框硬边
膨胀和腐蚀可以使用可分离滑动窗口算法:先处理水平方向,再处理垂直方向。
对于半径为 (r) 的二维形态学运算,直接遍历完整邻域的复杂度大致为:
[
O(W \times H \times r^2)
]
可分离滑动窗口实现则可以降低到接近:
[
O(W \times H)
]
深度模型之后的传统算法同样值得优化,因为它们也会在每一帧上运行。
7.2 有边界的颜色匹配
当源身份与目标视频的色温差异明显时,生成脸与颈部、额头边缘之间容易出现色差。
可以在有效遮罩区域内统计生成结果与目标人脸的均值和标准差,并执行通道级校正:
[
I'=\frac{\sigma_t}{\sigma_s}(I-\mu_s)+\mu_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、模型用途、许可证以及输入输出张量定义。
下载完成后还应检查实际文件大小或哈希。若结果与配置不一致,则拒绝创建 Session,避免把 CDN 错误页或不完整文件当成 ONNX 模型加载。
九、内存管理往往比单次推理耗时更危险
视频任务会同时占用多种资源:
- 解码帧;
- 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 对象失去引用,不代表底层内存会立即归还,因此必须显式调用 delete()。
十、取消任务必须到达处理链路的最深处
把进度弹窗关闭,不等于任务已经取消。一个真正可用的取消机制需要覆盖:
- 模型下载;
- 视频帧读取;
- 人脸检测;
- 光流跟踪;
- 逐帧换脸;
- 中间帧压缩;
- 最终视频编码。
主线程可以用 AbortController 管理任务,并向 Worker 发送带 requestId 的取消消息:
controller.abort();
worker.postMessage({
type: "cancel",
requestId,
});
Worker 则需要在下载循环、推理前后和任务切换点检查取消状态。取消后应立即停止后续编码,并确保不完整结果不会进入素材库。
这样才能避免一种常见的“假取消”:界面已经恢复,但后台仍持续占用 GPU、CPU 和内存。
十一、如何给浏览器 AI 做一组可信的性能测试
“秒级完成”不是一个可复现的性能结论。浏览器 AI 的测试至少应记录以下环境信息:
设备型号:
GPU:
操作系统:
浏览器版本:
WebGPU Adapter:
视频编码格式:
视频分辨率:
视频时长:
输出 FPS:
检测锚点 FPS:
是否首次加载:
模型初始化耗时:
帧处理耗时:
生成器累计推理耗时:
编码耗时:
峰值内存:
测试结果还应明确区分冷启动与热启动。
冷启动
冷启动包含模型下载、文件校验、ONNX Session 创建、Shader 编译、源身份提取、视频处理与编码,主要反映模型分发和设备初始化体验。
热启动
热启动假设模型已经缓存,Session 与源身份权重可以复用,只统计视频解码、锚点检测、光流跟踪、逐帧生成、后处理和视频编码。
热启动更接近用户在同一页面连续处理多个片段时的体验。两组数据不应混在一起,也不应只展示其中速度更快的一组。
结语
浏览器本地视频换脸并不是简单地把 ONNX 模型放进网页。要让它从“能运行”走向“可持续使用”,至少需要同时处理四类问题:
- 计算边界:只在必要分辨率和必要 ROI 上运行模型;
- 资源调度:并行下载、串行初始化,并减少跨线程复制;
- 结果可信度:用双向光流、时序匹配和遮罩质量控制防止错误传播;
- 运行时治理:固定模型版本、主动释放资源、提供真正可达底层的取消机制。
WebGPU 提供了算力,但工程设计决定了这份算力能否稳定地转化为用户体验。对于其他浏览器端视频 AI 任务——例如人像分割、超分辨率、目标跟踪或视频风格化——这些方法同样具有参考价值。