起因

刷到过太多「AI 换脸」的成品,一直好奇那套东西到底怎么转起来的。真到动手时发现,门槛其实不高——主流链路就一条,模型全是开源的。但「能用」和「不一眼假」之间,隔着一堆没人写清楚的坑。

现有的工具也各有各的别扭:FaceFusion 要装一整套 GUI 环境,Deep-Live-Cam 是给摄像头直播用的,在线换脸又得把照片传上去——拿自己的脸去喂别人的服务器,我是真不放心。

于是自己搭一个:浏览器上传两张图,点一下就出结果。模型权重随项目内置,不用 GPU、不用注册任何云服务,纯 CPU 就能跑。已经推到 GitHub:nbsheep/faceswap-studio。

主流链路就一条:inswapper + ArcFace

市面上一大半换脸产品(FaceFusion、Roop、Deep-Live-Cam)用的是同一条链路:

1
2
3
源脸照片 ── YuNet 检测 ── ArcFace 提身份向量 ── 经 emap 投影
│
目标照片 ── YuNet 检测 ── 对齐 128×128 ── inswapper 生成新脸 ── 贴回原图

它和「把源脸像素剪下来贴过去」的几何法有本质区别:inswapper 是在特征空间里重画一张脸——姿态、表情、光照属于目标,五官身份属于源脸。所以目标大笑时,换上去的脸也跟着笑,这是几何法做不到的。

项目里做了三个引擎:deep(inswapper + ArcFace,默认)、classic(纯几何法,零模型兜底)、insightface(官方包版,一般用不到)。启动时自动选「当前可用的最强引擎」。

四个「一眼假」的坑

同样是 inswapper + ArcFace,下面四步做错任何一步,出来的脸都会一眼假。这些才是这个项目真正值钱的部分。

1. 身份向量必须先过 emap 投影,否则换出来的是「路人脸」

inswapper 的 source 输入不是 ArcFace 的原始 embedding,而是要先乘上模型图里藏着的最后一个 initializer——一个 512×512 的方阵,社区习惯叫 emap:

1
2
latent = normed_embedding @ emap        # 投影到 inswapper 自己的身份空间
latent /= np.linalg.norm(latent) # 再归一化

漏掉这一步,模型读到的是一个「无效身份」,会输出一张跟谁都不像的通用脸。实测自换脸(源=目标)的身份余弦相似度:漏掉 emap 是 0.05,补上是 0.85。

2. 输入输出格式反直觉,反了就得到一张灰蒙蒙的糊图

  • 输入是 x/255 的 RGB(不是常见的 ±1 归一化)
  • 输出是 pred * 255(不是 pred * 127.5 + 127.5)

这两条反了,模型不会报错,只是安静地给你一张灰蒙蒙的糊图——和「stdout 是协议通道」那类 bug 一样烦人。

3. 蒙版椭圆的位置要按对齐空间实测标定

ArcFace 对齐后人脸在画布里的位置是固定的,所以蒙版可以用固定椭圆,但参数必须以对齐空间为基准量出来。我早先写成「五官质心与嘴心的中点」,圆心落到了 y≈0.75*size,椭圆整体下坠 0.15 个画布——额头整块露在蒙版外,融合接缝正好横在发际线上,非常显眼。

实测标定后的参数:眼睛 y≈0.46、鼻尖 y≈0.64、嘴巴 y≈0.82、发际 y≈0.25、下巴 y≈0.93,人脸外接椭圆圆心 (0.50, 0.585)、半轴 (0.305, 0.385)。

4. 颜色迁移要「轻」,细节回填要「只压暗」

inswapper 的输出本来就已经继承了目标图的色调(实测 L/a/b 均值误差 <2),所以 LAB 颜色迁移只该轻轻推一把。我早期用的权重公式明明只想微调,却把 a 通道从 134 压到 129,整张脸发白。现在改成严格插值 out = (1-w)*src + w*matched。

更大的破绽在细节:inswapper 只输出 128px 的「光板脸」,搭在脸上的碎发、睫毛、皮肤纹理会被整片抹掉。回填的办法是把目标原图的高频明暗调制 k = ref / gaussian(ref) 乘回生成脸。但 k 的上界必须卡在 1.0——只压暗、不提亮,否则会把目标脸上的高光/腮红再叠一层,脸就”发白”了。眼睛和嘴唇还要单独降权到 25%,不然会把目标的牙缝、睫毛糊上去。

128 太糊,接 CodeFormer 又踩一坑

inswapper 只出 128px,直接贴回大图就是「塑料脸」。接上 CodeFormer(FaceFusion 官方 ONNX 转换版,360MB)把 128 双三次放大到 512 重建细节,脸才算能看。

这里藏着一个隐蔽坑:对齐矩阵 matrix 是「原图 → 128 对齐空间」的,换到 512 空间后必须整体乘 4,否则脸会以 1/4 尺寸被甩到图角落上。而且引擎把形状错误静默吞掉,表面看起来”像没换”——排查手段是看 cos(结果, 目标),如果接近 1.0,说明目标原脸原封不动留在那了。

CodeFormer 的保真度权重也是个平衡点:0 = 画质猛但可能”换头”,1 = 最忠实输入。换脸场景 0.6~0.8 比较稳(默认 0.7),开它之后整条链路从 ~0.8s 变 ~5s(CPU)。

不靠真脸的自检

调参得有验收指标,但总不能拿真人照片反复试。所以写了个 selftest.py:程序自己画两张「假脸」当素材,检查输出尺寸、脸区域是否真的被改动、背景是否被误伤、色彩匹配是否生效、强度=0 是否等于原图……共 12 项,全部不依赖网络和真实照片。compare.py 则直接打印三个数:cos(结果, 源)、cos(结果, 目标)、脸/脖 LAB 偏差,调参时照着看就行。

关于合规,多说一句

换脸技术本身中立,但未经同意把别人的脸换到任何内容上,在绝大多数司法辖区都是违法的。做产品前得想清楚肖像权授权、不可去除的水印、内容审核、数据最小化这几件事。这个雏形只供学习、研究和经本人同意的娱乐用途——技术可以迭代,法律风险一次就够死。

结尾

整个项目里最有意思的,不是把换脸跑通,而是那几个「看起来肯定没问题、结果一眼假」的细节:一个 emap 矩阵把相似度从 0.05 拉到 0.85,一个 ×4 的缩放把脸甩到图角落,一个椭圆圆心偏了 0.15 就把接缝横在发际线上。深度模型的东西就是这样——它不报错,只是安静地给你一个错的结果,得靠你自己一层层量出来。

源码在 nbsheep/faceswap-studio,模型权重按 README 从 ModelScope / hf-mirror 下,别在 GitHub 上找(1.2GB,传不上去也没必要传)。