系列目录
- (二)SCRFD 人脸检测:16800 个锚点的设计哲学
- (三)关键点检测:为什么 106 个点全挤在 5×7 像素里
- (六)mask、膨胀与泊松融合:最后 10% 的细节
不能用一个变换搞定整张脸
假设你已经把两张脸都对齐到了 192×192 的规范空间。现在要做的就是把源脸的像素"搬"到目标脸上。
最简单的想法:用 cv2.warpAffine,整张脸一个变换矩阵。但这是行不通的——一个全局仿射变换只能处理旋转、缩放、平移、剪切。它没法把一个人的嘴巴变成另一个人的嘴巴形状,也没法处理表情变化。
你需要的是分段仿射变换:把脸切成很多小块,每块独立变形。
Delaunay 三角剖分:怎么切?
把 106 个关键点当成顶点,用 Delaunay 算法把它们连成三角形网格。
Delaunay 三角剖分有一条黄金性质:任意一个三角形的外接圆内,不包含其他顶点。
这保证了每个三角形都尽可能"饱满"(接近等边三角形),避免出现又长又瘦的三角形——瘦三角形在仿射变换时容易产生锯齿和形变。
# 在 192×192 的画布上建立三角剖分subdiv = cv2.Subdiv2D((0, 0, 192, 192))for pt in all_target_points: subdiv.insert((float(pt[0]), float(pt[1])))# 获取所有三角形triangles = subdiv.getTriangleList()# 每个三角形是 6 个浮点数: [x1, y1, x2, y2, x3, y3]
额外加点料:边界锚点
只有 106 个关键点的话,三角剖分只覆盖关键点围成的区域。但在人脸边缘(脸颊、下巴下方)需要三角形延伸到画布边界,否则 warp 会有缺口。
解决方法:在 106 个点之外,添加 8 个边界锚点:
border = [ [0, 0], [96, 0], [191, 0], # 上边 [0, 96], [191, 96], # 左右中 [0, 191], [96, 191], [191, 191], # 下边]
加上这 8 个点,三角剖分就能覆盖整个 192×192 画布。当然,纯边界点构成的三角形(不含任何关键点的)会被过滤掉——它们不包含人脸信息。
逐三角形 warp
对三角剖分产生的每一个三角形,做三步:
1. 定位:找到源和目标对应的三角形
# 三角形的三个顶点在目标关键点集中的索引i, j, k = triangle_indices# 目标三角形坐标(在规范空间中)tgt_tri = np.float32([tgt_landmarks[i], tgt_landmarks[j], tgt_landmarks[k]])# 源三角形坐标(同样在规范空间中,但形状不同)src_tri = np.float32([src_landmarks[i], src_landmarks[j], src_landmarks[k]])
2. 变换:计算仿射矩阵
三个点确定一个仿射变换——三个顶点不够做透视变换,但刚好够做仿射(6 个方程,6 个未知数):
M = cv2.getAffineTransform(src_tri, tgt_tri)
M 是一个 2×3 矩阵,它把源三角形上的任意点 (x, y) 映射到目标三角形:
[x'] [a b tx] [x][y'] = [c d ty] [y] [1]
3. 合成:warp + 累加
# 取三角形的包围盒tx, ty, tw, th = cv2.boundingRect(tgt_tri.astype(np.int32))# warp 源图对应区域src_patch = src_aligned[src_rect[1]:src_rect[1]+src_rect[3], src_rect[0]:src_rect[0]+src_rect[2]]warped_patch = cv2.warpAffine(src_patch, M, (tw, th))# 用三角形 mask 只取三角形内的像素tri_mask = np.zeros((th, tw), dtype=np.uint8)cv2.fillConvexPoly(tri_mask, tgt_tri_local.astype(np.int32), 255)# 累加到输出画布output[ty:ty+th, tx:tx+tw][tri_mask > 0] = warped_patch[tri_mask > 0]
为什么用 float32 累积?
一个容易被忽略的细节。
如果把 warp 后的三角形直接写入 uint8 数组,每次写入都会截断到 [0, 255]。多个三角形可能在边缘重叠——反复的截断会产生累积的精度损失。
# ❌ 不好:反复 uint8 截断output_uint8[roi] = warped_patch # 截断!# ✅ 好:float32 累加,最后一次性 clipoutput_float[roi] = output_float[roi] * (1 - mask) + warped_patch * mask# ...所有三角形处理完后...result = np.clip(output_float, 0, 255).astype(np.uint8)
复杂度
- 106 个关键点 + 5 额头扩展 + 8 边界 = 119 个顶点
- Delaunay 三角剖分产生约 220 个三角形
- 每个三角形做一次
warpAffine,210 次 warp
在我的笔记本(i7-12700H)上,这 210 次 warp 大约需要 800 毫秒。主要的计算开销不是 warpAffine 本身,而是 Python 层面的循环开销。如果改写为 C++ 或在 GPU 上做,可以快一个数量级。
小结
这一篇讲了换脸的核心算法:
- Delaunay 三角剖分把脸切成 210+ 个小三角形,保证每个三角形饱满
- 逐三角形仿射变换用三个顶点确定每个三角形的变形,210 个小变形拼成完整的人脸变形
- float32 累积缓冲区避免多次 uint8 截断的精度损失
下一篇是这个系列的最后一篇——mask、膨胀和泊松融合。这三个技术解决的是"怎么让换上去的脸和背景无缝融合"。
如果这篇文章对你有帮助,您的点赞、收藏、加关注是我前进的动力。
下一篇:(六)mask、膨胀与泊松融合:最后 10% 的细节