📌 目录
-
一、 环境准备与素材要求
-
二、 核心原理拆解与代码解析
-
- 人脸检测与 68 点关键点提取
-
- 生成面部掩膜(Mask)
-
- 计算变换矩阵(核心数学原理)
-
- 仿射变换与颜色迁移
-
- 图像融合(进阶:泊松无缝融合)
-
-
三、 完整实战代码
-
四、 避坑指南与效果优化
-
五、 结语
一、 环境准备与素材要求
在开始写代码前,请确保你的环境已经准备就绪。
🛠️ 依赖安装
bash
pip install opencv-python dlib numpy
📁 必备模型文件
传统换脸依赖于 dlib 的 68 点特征提取模型。请提前下载 shape_predictor_68_face_landmarks.dat(网上搜索即可获取)。
-
image3.jpg:底图(A图,目标图),比如一张证件照。 -
teacherli.jpg:源图(B图,提供脸的图)。
⚠️ 素材避坑建议 :要想换脸自然,两张图最好是绝对正脸、无刘海遮挡、光线均匀、人脸比例相近。如果表情差异过大(如一个大哭一个大类),传统 2D 换脸会因为几何形变产生严重的恐怖谷效应。
二、 核心原理拆解与代码解析
1. 人脸检测与 68 点关键点提取
我们使用 dlib 的 get_frontal_face_detector(基于 HOG 特征 + SVM)来检测人脸框,再用 shape_predictor(基于级联回归树)提取 68 个关键点。
python
import cv2
import dlib
import numpy as np
detector = dlib.get_frontal_face_detector()
predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat")
def getKeyPoints(im):
rects = detector(im, 1) # 1表示上采样1次,提升小脸检测率
shape = predictor(im, rects[0])
# 转换为 numpy 矩阵方便后续数学计算
s = np.matrix([[p.x, p.y] for p in shape.parts()])
return s
2. 生成面部掩膜(Mask)
我们要给 B 图的脸做个"选区"。这里用到了 凸包(Convex Hull) 算法,将五官区域包围起来并填充,然后进行高斯模糊以实现边缘羽化过渡。
python
# 排除下巴(JAW_POINTS),只保留眉毛、眼睛、鼻子、嘴巴,这样换脸更贴合
JAW_POINTS = list(range(0, 17))
# ... 其他点的定义 ...
POINTS = [LEFT_BROW_POINTS + RIGHT_EYE_POINTS + LEFT_EYE_POINTS + RIGHT_BROW_POINTS + NOSE_POINTS + MOUTH_POINTS]
POINTStuple = tuple(POINTS)
def getFaceMask(im, keyPoints):
im = np.zeros(im.shape[:2], dtype=np.float64)
for p in POINTS:
points = cv2.convexHull(keyPoints[p]) # 计算凸包
cv2.fillConvexPoly(im, points, color=1) # 填充多边形
# 单通道扩展为3通道
im = np.array([im, im, im]).transpose((1, 2, 0))
# 高斯模糊,羽化边缘,使融合更自然(15x15 比较合适,太大容易"雾化")
im = cv2.GaussianBlur(im, (15, 15), sigmaX=0)
return im
3. 计算变换矩阵(核心数学原理)
如何把 B 图的五官完美贴合到 A 图?我们需要求解一个仿射变换矩阵 M 。代码使用了普氏分析(Procrustes Analysis) ,结合 SVD(奇异值分解) 来计算两组点集之间的旋转、缩放和平移。
python
def getM(points1, points2):
"""
计算从 points2 (B脸) 到 points1 (A脸) 的仿射变换矩阵
"""
points1 = points1.astype(np.float64)
points2 = points2.astype(np.float64)
# 1. 去中心化(减去均值,消除平移影响)
c1 = np.mean(points1, axis=0)
c2 = np.mean(points2, axis=0)
points1 -= c1
points2 -= c2
# 2. 标准化(除以标准差,消除缩放比例影响)
s1 = np.std(points1)
s2 = np.std(points2)
points1 /= s1
points2 /= s2
# 3. 奇异值分解 (SVD) 求解最优旋转矩阵 R
U, S, Vt = np.linalg.svd(points1.T * points2)
R = (U * Vt).T
# 4. 组合最终的 2x3 仿射变换矩阵
return np.hstack(((s2 / s1) * R, c2.T - (s2 / s1) * R * c1.T))
4. 仿射变换与颜色迁移
拿到矩阵 M 后,我们用 cv2.warpAffine 对 B 图和其掩膜进行形变。
但 A、B 两图的肤质和光照往往不同,直接贴图会像"打补丁"。这里使用高斯滤波提取低频色彩信息,进行颜色迁移。
python
def normalColor(a, b):
"""将 b 图的颜色迁移至 a 图的色调"""
ksize = (51, 51) # 核不能太大,防止带入背景色
aGauss = cv2.GaussianBlur(a, ksize, 0) # A图低频色彩
bGauss = cv2.GaussianBlur(b, ksize, 0) # B图低频色彩
weight = aGauss / bGauss # 计算颜色比例权重
weight[np.isinf(weight)] = 0 # 排除除以0的异常
return b * weight
三、 完整实战代码
整合上述模块,提供一个可以直接跑的脚本:
python
import cv2
import dlib
import numpy as np
# 定义关键点
JAW_POINTS = list(range(0, 17))
RIGHT_BROW_POINTS = list(range(17, 22))
LEFT_BROW_POINTS = list(range(22, 27))
NOSE_POINTS = list(range(27, 35))
RIGHT_EYE_POINTS = list(range(36, 42))
LEFT_EYE_POINTS = list(range(42, 48))
MOUTH_POINTS = list(range(48, 61))
POINTS = [LEFT_BROW_POINTS + RIGHT_EYE_POINTS + LEFT_EYE_POINTS + RIGHT_BROW_POINTS + NOSE_POINTS + MOUTH_POINTS]
POINTStuple = tuple(POINTS)
def getFaceMask(im, keyPoints):
im = np.zeros(im.shape[:2], dtype=np.float64)
for p in POINTS:
points = cv2.convexHull(keyPoints[p])
cv2.fillConvexPoly(im, points, color=1)
im = np.array([im, im, im]).transpose((1, 2, 0))
im = cv2.GaussianBlur(im, (15, 15), sigmaX=0)
return im
def getM(points1, points2):
points1 = points1.astype(np.float64)
points2 = points2.astype(np.float64)
c1 = np.mean(points1, axis=0)
c2 = np.mean(points2, axis=0)
points1 -= c1
points2 -= c2
s1 = np.std(points1)
s2 = np.std(points2)
points1 /= s1
points2 /= s2
U, S, Vt = np.linalg.svd(points1.T * points2)
R = (U * Vt).T
return np.hstack(((s2 / s1) * R, c2.T - (s2 / s1) * R * c1.T))
def getKeyPoints(im, detector, predictor):
rects = detector(im, 1)
shape = predictor(im, rects[0])
return np.matrix([[p.x, p.y] for p in shape.parts()])
def normalColor(a, b):
ksize = (51, 51)
aGauss = cv2.GaussianBlur(a, ksize, 0)
bGauss = cv2.GaussianBlur(b, ksize, 0)
weight = aGauss / bGauss
weight[np.isinf(weight)] = 0
return b * weight
if __name__ == '__main__':
# 1. 读取图片
a = cv2.imread("image3.jpg") # 底图 A
b = cv2.imread("teacherli.jpg") # 源图 B
# 2. 初始化模型
detector = dlib.get_frontal_face_detector()
predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat")
# 3. 获取关键点
aKeyPoints = getKeyPoints(a, detector, predictor)
bKeyPoints = getKeyPoints(b, detector, predictor)
# 4. 获取掩膜
aMask = getFaceMask(a, aKeyPoints)
bMask = getFaceMask(b, bKeyPoints)
# 5. 计算变换矩阵 M (B -> A)
M = getM(aKeyPoints[POINTStuple], bKeyPoints[POINTStuple])
dsize = a.shape[:2][::-1]
# 6. 图像与掩膜进行仿射变换
bMaskWarp = cv2.warpAffine(bMask, M, dsize, borderMode=cv2.BORDER_TRANSPARENT, flags=cv2.WARP_INVERSE_MAP)
bWrap = cv2.warpAffine(b, M, dsize, borderMode=cv2.BORDER_TRANSPARENT, flags=cv2.WARP_INVERSE_MAP)
# 7. 掩膜融合
mask = np.max([aMask, bMaskWarp], axis=0)
# 8. 颜色迁移
bcolor = normalColor(a, bWrap)
# 9. 泊松融合 (Seamless Clone)
mask_uint8 = (mask * 255).astype(np.uint8)
if len(mask_uint8.shape) == 3:
mask_uint8 = cv2.cvtColor(mask_uint8, cv2.COLOR_BGR2GRAY)
M_moments = cv2.moments(mask_uint8)
if M_moments["m00"] != 0:
cX = int(M_moments["m10"] / M_moments["m00"])
cY = int(M_moments["m01"] / M_moments["m00"])
else:
cX, cY = dsize[0] // 2, dsize[1] // 2
center = (cX, cY)
try:
# 使用 B 图的形变图像与 A 图进行泊松融合
out = cv2.seamlessClone(bWrap, a, mask_uint8, center, cv2.NORMAL_CLONE)
except Exception as e:
print("泊松融合失败,退回 Alpha 混合。", e)
out = a * (1.0 - mask) + bcolor * mask
out = np.clip(out, 0, 255).astype(np.uint8)
# 10. 显示结果
cv2.imshow("Original A", a)
cv2.imshow("Original B", b)
cv2.imshow("Result", out)
cv2.waitKey(0)
cv2.destroyAllWindows()
四、 避坑指南与效果优化
在实际测试中,如果你选用的两张图表情差异很大(如一张大笑,一张抿嘴) ,或者肤色差异极大(冷白皮 vs 暖黄皮),你可能会遇到以下问题及解决思路:
-
嘴巴变形(恐怖谷效应):
-
原因:68 个关键点包含嘴巴。如果 A 图闭嘴,B 图大笑,强行几何映射会拉伸下巴,导致极度不自然。
-
解决 :修改
POINTS列表,去掉嘴巴的 48-61 个关键点,即只换眼鼻眉,保留原图的嘴部特征。
-
-
肤色割裂感:
-
原因 :
normalColor的高斯核太大(如原代码的 111),会混入背景颜色。 -
解决 :将
ksize调小至(31, 31)或(51, 51),并配合cv2.seamlessClone泊松融合。
-
-
头发/刘海遮挡:
-
原因:凸包会将背景(如黑发)也包进去。
-
解决:不要使用全脸掩膜,只选取眼睛和鼻子区域。或者在代码运行前,提前用 PS 把源图 B 的刘海抠掉。
-