本地换脸 Web/API 的实现思路
它的核心目标很直接:
1 | 上传源媒体 + 上传目标人脸照片 -> 执行人脸替换 -> 返回处理后的图片、GIF 或视频 |
项目没有做成复杂平台,而是用一个轻量 FastAPI 服务把模型能力包装起来。
从工程结构看,它更像一个本地工具:
1 | FastAPI 负责接口和页面 |
整个项目代码量不大,但把“上传、识别、逐帧处理、输出文件”这条链路串完整了。
一、项目结构
项目主要文件很少:
1 | app/main.py |
其中最核心的是:
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 | fastapi |
这里 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 | self.face_app = FaceAnalysis( |
buffalo_l 目录里通常包含这些 ONNX 文件:
1 | det_10g.onnx |
在这个项目里,最关键的是人脸检测和人脸特征相关能力。
代码会从图片或视频帧中找出人脸,然后选择面积最大的人脸:
1 | return max( |
这个设计很直接:
1 | 每张图只处理最大的人脸 |
所以项目的输入假设也很清楚:
1 | 源媒体里按一个主要人脸处理,目标照片里按一个主要人脸处理。 |
四、模型运行方式
项目把模型封装在一个 FaceSwapEngine 里。
核心字段是:
1 |
|
当前默认 provider 是:
1 | engine = FaceSwapEngine(providers=("CPUExecutionProvider",)) |
也就是说,默认走 CPU 推理。
准备模型时调用:
1 | self.face_app.prepare( |
这里有两个重点。
第一,如果 provider 是 CPU,就使用 ctx_id=-1。
第二,人脸检测尺寸固定为:
1 | 640 x 640 |
模型采用懒加载方式。
第一次真正处理文件时才会执行:
1 | self.prepare() |
这样服务启动时不需要立刻加载模型,只有调用换脸接口时才开始准备推理资源。
五、API 设计
主要接口是:
1 | POST /api/swap |
接收参数:
1 | source_media: UploadFile |
response 默认是:
1 | file |
也就是直接返回处理后的文件。
如果传:
1 | response=json |
则返回:
1 | { |
这个设计很实用。
命令行调用时可以直接保存文件:
1 | curl -X POST http://127.0.0.1:8000/api/swap \ |
网页调用时则使用 JSON,拿到 URL 后展示结果。
六、文件上传和类型判断
项目支持三类源媒体:
1 | IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"} |
上传文件会先保存到:
1 | uploads/ |
文件名使用 UUID:
1 | path = UPLOAD_DIR / f"{uuid.uuid4().hex}{suffix}" |
这样可以避免多个请求上传同名文件时互相覆盖。
后续处理会根据扩展名分发:
1 | if suffix in IMAGE_EXTS: |
这个分发方式很简单,但对当前项目已经够用。
七、图片处理流程
图片是最简单的路径。
流程是:
1 | cv2.imread 读取图片 |
核心代码是:
1 | frame = cv2.imread(str(media_path)) |
图片输出统一保存为:
1 | *_swapped.png |
八、GIF 处理流程
GIF 比图片多了帧序列和透明通道。
项目使用 Pillow 读取 GIF:
1 | image = Image.open(media_path) |
每一帧会先转成 RGBA,再转成 RGB,然后交给 OpenCV/InsightFace 处理:
1 | rgba = frame.convert("RGBA") |
换脸完成后,再从 BGR 转回 RGB:
1 | swapped_rgb = cv2.cvtColor(swapped_bgr, cv2.COLOR_BGR2RGB) |
然后重新塞回原来的 alpha 通道:
1 | swapped.putalpha(rgba.getchannel("A")) |
最后按原始帧时长保存 GIF:
1 | duration=durations |
这里有一个细节:
1 | allow_missing_face=True |
也就是说,GIF 某一帧检测不到人脸时,不会直接中断,而是保留原帧继续处理后续帧。
这个策略对动图更友好,因为 GIF 里经常会有模糊帧、转场帧或侧脸帧。
九、视频处理流程
视频处理是项目里最完整的一段。
流程可以拆成:
1 | OpenCV 读取视频 |
视频读取用:
1 | capture = cv2.VideoCapture(str(media_path)) |
项目会读取:
1 | fps |
如果系统里有 ffmpeg,就走高质量路径:
1 | write_video_with_ffmpeg(...) |
这里的设计比较聪明。
它不是先把所有帧保存到临时目录,而是直接用管道把原始帧写给 ffmpeg:
1 | process = subprocess.Popen(command, stdin=subprocess.PIPE, stderr=subprocess.PIPE) |
ffmpeg 输入参数是:
1 | -f rawvideo |
这表示 Python 端不断写入 BGR 原始帧,ffmpeg 端实时接收并编码。
同时,ffmpeg 还会读取原视频:
1 | -i original_video |
然后映射:
1 | -map 0:v:0 |
也就是:
1 | 视频来自处理后的帧流 |
最后输出:
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 | 检查模型状态 |
模型状态通过:
1 | GET /health |
返回:
1 | { |
页面会根据 model_ready 显示:
1 | 模型已就绪 |
或者:
1 | 缺少 inswapper_128.onnx |
提交时使用:
1 | const body = new FormData(form); |
这个页面没有复杂状态管理,也没有打包流程。
对本地工具来说,这种实现反而很合适:
1 | 少依赖,少构建,打开服务就能用。 |
十二、自动下载模型脚本
项目提供了:
1 | scripts/download_models.py |
这个脚本只负责下载:
1 | inswapper_128.onnx |
它会按顺序尝试多个 URL:
1 | URLS = [ |
下载后会检查文件大小:
1 | SWAPPER_PATH.stat().st_size > 100_000_000 |
如果文件太小,就认为下载结果不正常,删除后继续尝试下一个镜像。
这是一个很实用的小保护。
模型下载经常会遇到 HTML 错误页、跳转页或中断文件。只检查文件是否存在是不够的,检查大小能挡住一部分明显异常结果。
十三、这个项目的技术特点
这个项目最值得总结的地方有几个。
第一,接口设计简单。
一个 /api/swap 覆盖图片、GIF、视频三种输入,调用方只需要关心上传文件和返回格式。
第二,模型封装清楚。
FaceSwapEngine 把 InsightFace 的加载、检测、换脸封在一起,业务函数只需要调用:
1 | engine.source_face(...) |
第三,媒体处理链路完整。
图片、GIF、视频分别走不同处理函数,没有硬凑成一种流程。
第四,视频输出处理得比较认真。
使用 ffmpeg 管道写入原始帧,避免大量临时图片;同时保留原视频音频,让输出结果更完整。
第五,模型文件不进 Git。
.gitignore 里忽略了:
1 | models/* |
仓库只保留 .gitkeep,运行时数据和模型权重都放在本地。
十四、可以继续优化的方向
从技术角度看,后续可以继续补几块。
第一,增加 GPU provider 配置。
当前默认:
1 | CPUExecutionProvider |
如果机器支持 CUDA 或其他加速后端,可以把 provider 做成配置项。
第二,增加任务队列。
视频处理时间比较长,当前接口会一直等待处理完成。后续可以改成:
1 | 提交任务 -> 返回 task_id -> 后台处理 -> 查询进度 -> 下载结果 |
第三,增加输出清理机制。
uploads/ 和 outputs/ 会持续增长,可以加定时清理或最大保留时间。
第四,增加更细的进度反馈。
视频本身有总帧数,处理时可以记录当前帧进度,前端就能显示百分比。
第五,增加多脸策略。
现在默认取最大脸。后续可以支持选择第几个脸,或者对多个脸分别处理。
十五、结语
huanLian 的实现路线很清楚:
1 | 用 FastAPI 做本地服务入口 |
它不是一个复杂项目,但技术链条是完整的。
如果你想理解一个本地 AI 媒体处理服务怎么落地,这个项目很适合作为样例。
核心经验也很简单:
1 | 模型能力只是一部分,真正让项目可用的是文件上传、格式处理、帧循环、编码输出和错误处理这些工程细节。 |