本地换脸 Web/API 的实现思路
wxk1991 Lv5

本地换脸 Web/API 的实现思路

它的核心目标很直接:

1
上传源媒体 + 上传目标人脸照片 -> 执行人脸替换 -> 返回处理后的图片、GIF 或视频

项目没有做成复杂平台,而是用一个轻量 FastAPI 服务把模型能力包装起来。

从工程结构看,它更像一个本地工具:

1
2
3
4
5
FastAPI 负责接口和页面
InsightFace 负责人脸检测、识别和换脸
OpenCV / Pillow 负责媒体解码和帧处理
ffmpeg 负责视频编码和音频合并
ONNX Runtime 负责模型推理

整个项目代码量不大,但把“上传、识别、逐帧处理、输出文件”这条链路串完整了。


一、项目结构

项目主要文件很少:

1
2
3
4
5
6
7
8
app/main.py
app/static/index.html
scripts/download_models.py
requirements.txt
README.md
models/
uploads/
outputs/

其中最核心的是:

1
app/main.py

它同时负责:

  • 创建 FastAPI 应用
  • 挂载静态输出目录
  • 提供首页
  • 提供健康检查接口
  • 接收上传文件
  • 调用模型处理图片、GIF、视频
  • 返回文件或 JSON

前端页面放在:

1
app/static/index.html

这是一个纯 HTML 页面,没有引入前端框架。

页面里通过 fetch("/api/swap") 调用后端接口,上传两个文件:

  • source_media:源图片、GIF 或视频
  • face_photo:目标人脸照片

处理完成后,页面会根据返回的 content_type 判断展示 <video> 还是 <img>


二、后端技术栈

后端使用的是:

1
FastAPI + Uvicorn

依赖里比较关键的包有:

1
2
3
4
5
6
7
8
9
10
11
fastapi
uvicorn[standard]
python-multipart
numpy
opencv-python
pillow
imageio
imageio-ffmpeg
onnxruntime
onnx
requests

这里 python-multipart 是 FastAPI 接收表单文件上传需要的依赖。

opencv-python 负责读取图片、读取视频帧、写入 fallback 视频。

Pillow 负责 GIF 的帧读取、透明通道处理和 GIF 重新保存。

onnxruntime 负责跑 ONNX 模型。

项目还安装了 insightface==0.7.3,但 README 里建议用:

1
pip install --no-build-isolation --no-deps insightface==0.7.3

这样做是为了避免自动安装当前服务不需要的大型附加依赖。


三、用到的模型

这个项目主要用到两组模型。

第一组是换脸模型:

1
models/inswapper_128.onnx

代码里通过:

1
from insightface.model_zoo import get_model

加载它:

1
self.swapper = get_model(str(SWAPPER_PATH), providers=list(self.providers))

inswapper_128.onnx 是真正执行人脸替换的模型。

项目不会把这个模型文件提交到 Git,而是要求放在:

1
models/inswapper_128.onnx

如果文件不存在,服务会在模型准备阶段抛出错误:

1
Missing model: models/inswapper_128.onnx

第二组是 InsightFace 的 buffalo_l

1
models/models/buffalo_l

代码里通过:

1
from insightface.app import FaceAnalysis

创建人脸分析器:

1
2
3
4
5
self.face_app = FaceAnalysis(
name="buffalo_l",
root=str(MODEL_DIR),
providers=list(self.providers),
)

buffalo_l 目录里通常包含这些 ONNX 文件:

1
2
3
4
5
det_10g.onnx
w600k_r50.onnx
1k3d68.onnx
2d106det.onnx
genderage.onnx

在这个项目里,最关键的是人脸检测和人脸特征相关能力。

代码会从图片或视频帧中找出人脸,然后选择面积最大的人脸:

1
2
3
4
return max(
faces,
key=lambda face: (face.bbox[2] - face.bbox[0]) * (face.bbox[3] - face.bbox[1]),
)

这个设计很直接:

1
每张图只处理最大的人脸

所以项目的输入假设也很清楚:

1
源媒体里按一个主要人脸处理,目标照片里按一个主要人脸处理。

四、模型运行方式

项目把模型封装在一个 FaceSwapEngine 里。

核心字段是:

1
2
3
4
@dataclass
class FaceSwapEngine:
providers: tuple[str, ...]
prepared: bool = False

当前默认 provider 是:

1
engine = FaceSwapEngine(providers=("CPUExecutionProvider",))

也就是说,默认走 CPU 推理。

准备模型时调用:

1
2
3
4
self.face_app.prepare(
ctx_id=0 if self.providers[0] != "CPUExecutionProvider" else -1,
det_size=(640, 640),
)

这里有两个重点。

第一,如果 provider 是 CPU,就使用 ctx_id=-1

第二,人脸检测尺寸固定为:

1
640 x 640

模型采用懒加载方式。

第一次真正处理文件时才会执行:

1
self.prepare()

这样服务启动时不需要立刻加载模型,只有调用换脸接口时才开始准备推理资源。


五、API 设计

主要接口是:

1
POST /api/swap

接收参数:

1
2
3
4
source_media: UploadFile
face_photo: UploadFile
response: file | json
video_crf: int

response 默认是:

1
file

也就是直接返回处理后的文件。

如果传:

1
response=json

则返回:

1
2
3
4
5
{
"output_url": "/outputs/source_swapped.mp4",
"filename": "source_swapped.mp4",
"content_type": "video/mp4"
}

这个设计很实用。

命令行调用时可以直接保存文件:

1
2
3
4
curl -X POST http://127.0.0.1:8000/api/swap \
-F "[email protected]" \
-F "[email protected]" \
-o swapped.mp4

网页调用时则使用 JSON,拿到 URL 后展示结果。


六、文件上传和类型判断

项目支持三类源媒体:

1
2
3
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
GIF_EXTS = {".gif"}
VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".avi", ".mkv", ".webm"}

上传文件会先保存到:

1
uploads/

文件名使用 UUID:

1
path = UPLOAD_DIR / f"{uuid.uuid4().hex}{suffix}"

这样可以避免多个请求上传同名文件时互相覆盖。

后续处理会根据扩展名分发:

1
2
3
4
5
6
if suffix in IMAGE_EXTS:
return process_image(media_path, source_face)
if suffix in GIF_EXTS:
return process_gif(media_path, source_face)
if suffix in VIDEO_EXTS:
return process_video(media_path, source_face, video_crf=video_crf)

这个分发方式很简单,但对当前项目已经够用。


七、图片处理流程

图片是最简单的路径。

流程是:

1
2
3
4
cv2.imread 读取图片
检测目标人脸
调用 inswapper 执行替换
cv2.imwrite 保存 PNG

核心代码是:

1
2
3
4
frame = cv2.imread(str(media_path))
swapped = engine.swap_frame(frame, source_face)
output_path = OUTPUT_DIR / f"{media_path.stem}_swapped.png"
cv2.imwrite(str(output_path), swapped)

图片输出统一保存为:

1
*_swapped.png

八、GIF 处理流程

GIF 比图片多了帧序列和透明通道。

项目使用 Pillow 读取 GIF:

1
2
image = Image.open(media_path)
for frame in ImageSequence.Iterator(image):

每一帧会先转成 RGBA,再转成 RGB,然后交给 OpenCV/InsightFace 处理:

1
2
3
rgba = frame.convert("RGBA")
rgb = np.array(rgba.convert("RGB"))
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)

换脸完成后,再从 BGR 转回 RGB:

1
swapped_rgb = cv2.cvtColor(swapped_bgr, cv2.COLOR_BGR2RGB)

然后重新塞回原来的 alpha 通道:

1
swapped.putalpha(rgba.getchannel("A"))

最后按原始帧时长保存 GIF:

1
2
duration=durations
loop=image.info.get("loop", 0)

这里有一个细节:

1
allow_missing_face=True

也就是说,GIF 某一帧检测不到人脸时,不会直接中断,而是保留原帧继续处理后续帧。

这个策略对动图更友好,因为 GIF 里经常会有模糊帧、转场帧或侧脸帧。


九、视频处理流程

视频处理是项目里最完整的一段。

流程可以拆成:

1
2
3
4
5
6
OpenCV 读取视频
逐帧执行换脸
把处理后的 BGR 原始帧写给 ffmpeg
ffmpeg 编码 H.264
合并原视频音频
输出 MP4

视频读取用:

1
capture = cv2.VideoCapture(str(media_path))

项目会读取:

1
2
3
fps
width
height

如果系统里有 ffmpeg,就走高质量路径:

1
write_video_with_ffmpeg(...)

这里的设计比较聪明。

它不是先把所有帧保存到临时目录,而是直接用管道把原始帧写给 ffmpeg:

1
2
process = subprocess.Popen(command, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
process.stdin.write(swapped.tobytes())

ffmpeg 输入参数是:

1
2
3
4
5
-f rawvideo
-pix_fmt bgr24
-s {width}x{height}
-r {fps}
-i pipe:0

这表示 Python 端不断写入 BGR 原始帧,ffmpeg 端实时接收并编码。

同时,ffmpeg 还会读取原视频:

1
-i original_video

然后映射:

1
2
-map 0:v:0
-map 1:a?

也就是:

1
2
视频来自处理后的帧流
音频来自原视频,如果原视频有音频就合进去

最后输出:

1
*_swapped.mp4

十、视频编码策略

项目会优先检测 ffmpeg 是否支持:

1
libx264

如果支持,就使用:

1
-c:v libx264 -crf {video_crf}

如果没有 libx264,会尝试:

1
h264_videotoolbox

这是 macOS 上常见的硬件编码器。

这时项目会根据分辨率、帧率和质量参数估算 bitrate:

1
bitrate = int(width * height * fps * bits_per_pixel)

并限制在:

1
2.5 Mbps 到 80 Mbps

如果两个 H.264 编码器都不可用,就退回:

1
mpeg4

如果系统没有安装 ffmpeg,则使用 OpenCV 的 VideoWriter 写出无音频 MP4:

1
cv2.VideoWriter_fourcc(*"mp4v")

这个 fallback 保证了项目在没有 ffmpeg 的环境里仍然能跑,只是输出能力会弱一些。


十一、前端页面

前端页面是一个单文件 HTML。

它主要做四件事:

1
2
3
4
检查模型状态
选择源媒体和目标照片
提交 FormData
展示处理结果

模型状态通过:

1
GET /health

返回:

1
2
3
4
5
{
"ok": true,
"model_ready": true,
"model_path": "models/inswapper_128.onnx"
}

页面会根据 model_ready 显示:

1
模型已就绪

或者:

1
缺少 inswapper_128.onnx

提交时使用:

1
2
3
const body = new FormData(form);
body.set("response", "json");
const response = await fetch("/api/swap", { method: "POST", body });

这个页面没有复杂状态管理,也没有打包流程。

对本地工具来说,这种实现反而很合适:

1
少依赖,少构建,打开服务就能用。

十二、自动下载模型脚本

项目提供了:

1
scripts/download_models.py

这个脚本只负责下载:

1
inswapper_128.onnx

它会按顺序尝试多个 URL:

1
2
3
4
5
URLS = [
"https://sourceforge.net/projects/insightface.mirror/files/v0.7/inswapper_128.onnx/download",
"https://huggingface.co/LPDoctor/insightface/resolve/main/inswapper_128.onnx",
"https://huggingface.co/Aitrepreneur/insightface/resolve/main/inswapper_128.onnx",
]

下载后会检查文件大小:

1
SWAPPER_PATH.stat().st_size > 100_000_000

如果文件太小,就认为下载结果不正常,删除后继续尝试下一个镜像。

这是一个很实用的小保护。

模型下载经常会遇到 HTML 错误页、跳转页或中断文件。只检查文件是否存在是不够的,检查大小能挡住一部分明显异常结果。


十三、这个项目的技术特点

这个项目最值得总结的地方有几个。

第一,接口设计简单。

一个 /api/swap 覆盖图片、GIF、视频三种输入,调用方只需要关心上传文件和返回格式。

第二,模型封装清楚。

FaceSwapEngine 把 InsightFace 的加载、检测、换脸封在一起,业务函数只需要调用:

1
2
engine.source_face(...)
engine.swap_frame(...)

第三,媒体处理链路完整。

图片、GIF、视频分别走不同处理函数,没有硬凑成一种流程。

第四,视频输出处理得比较认真。

使用 ffmpeg 管道写入原始帧,避免大量临时图片;同时保留原视频音频,让输出结果更完整。

第五,模型文件不进 Git。

.gitignore 里忽略了:

1
2
3
models/*
uploads/*
outputs/

仓库只保留 .gitkeep,运行时数据和模型权重都放在本地。


十四、可以继续优化的方向

从技术角度看,后续可以继续补几块。

第一,增加 GPU provider 配置。

当前默认:

1
CPUExecutionProvider

如果机器支持 CUDA 或其他加速后端,可以把 provider 做成配置项。

第二,增加任务队列。

视频处理时间比较长,当前接口会一直等待处理完成。后续可以改成:

1
提交任务 -> 返回 task_id -> 后台处理 -> 查询进度 -> 下载结果

第三,增加输出清理机制。

uploads/outputs/ 会持续增长,可以加定时清理或最大保留时间。

第四,增加更细的进度反馈。

视频本身有总帧数,处理时可以记录当前帧进度,前端就能显示百分比。

第五,增加多脸策略。

现在默认取最大脸。后续可以支持选择第几个脸,或者对多个脸分别处理。


十五、结语

huanLian 的实现路线很清楚:

1
2
3
4
5
用 FastAPI 做本地服务入口
用 InsightFace 加载 buffalo_l 和 inswapper_128.onnx
用 OpenCV/Pillow 把图片、GIF、视频拆成可处理的帧
逐帧调用模型
再把结果保存成对应媒体格式

它不是一个复杂项目,但技术链条是完整的。

如果你想理解一个本地 AI 媒体处理服务怎么落地,这个项目很适合作为样例。

核心经验也很简单:

1
模型能力只是一部分,真正让项目可用的是文件上传、格式处理、帧循环、编码输出和错误处理这些工程细节。