最近一個“最強變臉術”又火爆抖音啦,還不知道的朋友建議先打開抖音,搜索“最強變臉術”看個十來個視頻再回來看這篇文章。視頻看起來炫酷,其實本質就是圖像的各種變換組合到一塊的結果。那我們能不能也搞出一個來玩玩?我利用週末刷了兩天抖音,不停的暫停、繼續… 最終在嘗試了仿射變換
和透視變換
兩種方案後,搞出了一個“低配版最強變臉術”。首先先來看看最終實現的效果(忽略gif顏色問題),也可以到http://www.iqiyi.com/w_19saz1z92h.html查看完整視頻,然後從數學原理、opencv代碼實現入手一步步的搞一個“最強變臉術”。
人臉關鍵點識別
看過“最強變臉術”的都知道,這個效果最基礎的技術就是人臉識別。都2020年了,人臉識別當然不是多難的事了,可以選擇的技術也很多,比如可以利用深度學習自己訓練一個,也可以和我一樣使用dlib
這個三方庫。
dlib
用起來很簡單,下面直接上代碼了。
img = cv2.imread("./imgs/2.jpg")
dets = detector(img, 1)
shape = predictor(img, dets[0])
landmarks = []
for p in shape.parts():
landmarks.append(np.array([p.x, p.y]))
for idx, point in enumerate(landmarks):
cv2.putText(img, str(idx), (point[0], point[1]), fontFace=cv2.FONT_HERSHEY_SCRIPT_SIMPLEX,
fontScale=0.3, color=(0, 255, 0))
cv2.imshow("--", img)
cv2.waitKey()
運行上面的代碼可以看到這樣的結果:
請注意上面圖中36
、45
、29
三個數字的位置,因爲在下面仿射變換
的版本中我們要用到。
版本一:仿射變換實現
人臉關鍵點搞定後的第一次嘗試,我是用的圖像仿射變換
來實現的。通過不斷觀察,我拆解出了一下三種變換方式:
- 平移
- 縮放
- 旋轉
平移
需要平移,是因爲我們需要把兩張圖片上的人臉疊放到一塊。平移的變換操作矩陣是:
例如我們要向右平移100個像素,向下平移50個像素,那麼變換矩陣就應該是:
對應的運算是:
即
所以平移操作的本質就是對每個像素加上一個偏移量。下面是使用opencv對圖像進行平移操作的代碼:
img = cv2.imread("./imgs/2.jpg")
M = np.float32(
[
[1, 0, 100],
[0, 1, 50]
]
)
dst = cv2.warpAffine(img, M, (img.shape[1], img.shape[0]))
cv2.imshow("", dst)
cv2.waitKey()
運行上面的代碼可以看到這樣的結果:
縮放
需要縮放,是因爲我們在人臉對齊的時候需要儘可能的保證兩張人臉大小一致。縮放的變換操作矩陣是:
fx代表x方向的縮放因子,fy代表y方向的縮放因子。所以如果我們想x軸放大1.5倍,y軸放大2倍的代碼如下:
img = cv2.imread("./imgs/2.jpg")
M = np.float32(
[
[1.5, 0, 0],
[0, 2, 0]
]
)
dst = cv2.warpAffine(img, M, (img.shape[1], img.shape[0]))
cv2.imshow("", dst)
cv2.waitKey()
運行上面的代碼可以看到這樣的結果:
旋轉
需要旋轉,是因爲我們需要把兩張圖片上的人臉進行對齊操作。旋轉的變換操作矩陣是:
如果我們想要旋轉30度,可以使用一下代碼:
img = cv2.imread("./imgs/2.jpg")
theta = math.radians(-30)
M = np.float32(
[
[np.cos(theta), -np.sin(theta), 0],
[np.sin(theta), np.cos(theta), 0]
]
)
dst = cv2.warpAffine(img, M, (img.shape[1], img.shape[0]))
cv2.imshow("", dst)
cv2.waitKey()
運行效果如下:
觀察結果可能發現了,這次旋轉的中心是在原點,如果我們想以任意點爲旋轉中心怎麼辦? opencv提供了一個函數:
getRotationMatrix2D(center, angle, scale)
center: 指定旋轉的中心
angle: 旋轉角度
scale: 縮放因子
這個函數還順手解決了我們上面需要的縮放操作。可以比較下面代碼和上面的效果:
img = cv2.imread("./imgs/2.jpg")
M = cv2.getRotationMatrix2D((img.shape[1], img.shape[0]), 30, 1)
dst = cv2.warpAffine(img, M, (img.shape[1], img.shape[0]))
cv2.imshow("", dst)
cv2.waitKey()
最強變臉術第一次實現
仿射變換
版本其實就是利用了以上三種變換方式的組合,首先先定義一個函數入口。
def compose_img(name, frames_per_transformer, wait_frames, *imgs):
pass
參數1:生成視頻的文件名
參數2:每兩張圖像之前的變換(稱之爲1次迭代)需要多少幀
參數3:每個迭代後寫入多少幀靜態圖,也就是每次迭代完成後圖片保持多少幀不變
參數4:參與生成視頻的圖片集合
除了這個函數外,我們還需要幾個輔助函數。
def to_video(name, width, height):
fps = 10
video_writer = cv2.VideoWriter(name, cv2.VideoWriter_fourcc('I', '4', '2', '0'), fps, (width, height))
return video_writer
def get_equation(x0, y0, x1, y1, pow_arg=1):
k = (y1 - y0) / (pow(x1, pow_arg) - pow(x0, pow_arg))
b = y0 - k * pow(x0, pow_arg)
def f(x):
return k * pow(x, pow_arg) + b
return f
def get_rotate_theta(from_landmarks, to_landmarks):
from_left_eye = from_landmarks[36]
from_right_eye = from_landmarks[45]
to_left_eye = to_landmarks[36]
to_right_eye = to_landmarks[45]
from_angle = math.atan2(from_right_eye[1] - from_left_eye[1], from_right_eye[0] - from_left_eye[0])
to_angle = math.atan2(to_right_eye[1] - to_left_eye[1], to_right_eye[0] - to_left_eye[0])
from_theta = -from_angle * (180 / math.pi)
to_theta = -to_angle * (180 / math.pi)
return to_theta - from_theta
to_video
函數主要是用來創建一個視頻生成器的。get_equation
函數是用來生成一個根據時間變化的方程,主要用到了一次方程和二次方程。get_rotate_theta
這個函數是通過計算左右眼的夾角來估計人臉傾斜角度差值,下標的值可以參考第一張圖片。
最後我們就要進入主函數的實現了,主要思路是遍歷所有圖片,每個迭代拿出當前圖和下一張圖,然後識別出兩張人臉中的關鍵點,通過這些關鍵點我們可以計算出兩張圖在某一時刻需要的旋轉角度、旋轉中心、縮放比例、位移像素數等關鍵參數。最終我們再次迭代frames_per_transformer
次通過對兩張圖片分別做旋轉
、平移
變換來達到效果。
def compose_img(name, frames_per_transformer, wait_frames, *imgs):
video_writer = to_video("{}.avi".format(name), imgs[0].shape[1], imgs[0].shape[0])
img_count = len(imgs)
for idx in range(img_count - 1):
from_img = imgs[idx]
to_img = imgs[idx + 1]
from_width = from_img.shape[1]
from_height = from_img.shape[0]
to_width = to_img.shape[1]
to_height = to_img.shape[0]
from_face_region, from_landmarks = face_detector(from_img)
to_face_region, to_landmarks = face_detector(to_img)
# 第一張圖最終的旋轉角度
from_theta = get_rotate_theta(from_landmarks, to_landmarks)
# 第二張圖初始的旋轉角度
to_theta = get_rotate_theta(to_landmarks, from_landmarks)
# 兩張圖的旋轉中心
from_rotate_center = (from_face_region.left() + (from_face_region.right() - from_face_region.left()) / 2, from_face_region.top() + (from_face_region.bottom() - from_face_region.top()) / 2)
to_rotate_center = (to_face_region.left() + (to_face_region.right() - to_face_region.left()) / 2, to_face_region.top() + (to_face_region.bottom() - to_face_region.top())/2)
from_face_area = from_face_region.area()
to_face_area = to_face_region.area()
# 第一張圖的最終縮放因子
to_scaled = from_face_area / to_face_area
# 第二張圖的初始縮放因子
from_scaled = to_face_area / from_face_area
# 平移多少的基準
to_translation_base = to_rotate_center
from_translation_base = from_rotate_center
equation_pow = 1 if idx % 2 == 0 else 2
# 建立變換角度的方程
to_theta_f = get_equation(0, to_theta, frames_per_transformer - 1, 0, equation_pow)
from_theta_f = get_equation(0, 0, frames_per_transformer - 1, from_theta, equation_pow)
# 建立縮放係數的角度
to_scaled_f = get_equation(0, to_scaled, frames_per_transformer - 1, 1, equation_pow)
from_scaled_f = get_equation(0, 1, frames_per_transformer - 1, from_scaled, equation_pow)
for i in range(frames_per_transformer):
# 當前時間點的旋轉角度
cur_to_theta = to_theta_f(i)
cur_from_theta = from_theta_f(i)
# 當前時間點的縮放因子
cur_to_scaled = to_scaled_f(i)
cur_from_scaled = from_scaled_f(i)
# 生成第二張圖片變換矩陣
to_rotate_M = cv2.getRotationMatrix2D(to_rotate_center, cur_to_theta, cur_to_scaled)
# 對第二張圖片執行仿射變換
to_dst = cv2.warpAffine(to_img, to_rotate_M, (to_width, to_height), borderMode=cv2.BORDER_REPLICATE)
# 生成第一張圖片的變換矩陣
from_rotate_M = cv2.getRotationMatrix2D(from_rotate_center, cur_from_theta, cur_from_scaled)
# 對第一張圖片執行仿射變換
from_dst = cv2.warpAffine(from_img, from_rotate_M, (from_width, from_height), borderMode=cv2.BORDER_REPLICATE)
# 重新計算變換後的平移基準
to_left_rotated = to_rotate_M[0][0] * to_translation_base[0] + to_rotate_M[0][1] * to_translation_base[1] + to_rotate_M[0][2]
to_top_rotated = to_rotate_M[1][0] * to_translation_base[0] + to_rotate_M[1][1] * to_translation_base[1] + to_rotate_M[1][2]
from_left_rotated = from_rotate_M[0][0] * from_translation_base[0] + from_rotate_M[0][1] * from_translation_base[1] + from_rotate_M[0][2]
from_top_rotated = from_rotate_M[1][0] * from_translation_base[0] + from_rotate_M[1][1] * from_translation_base[1] + from_rotate_M[1][2]
# 當前時間點的平移數
to_left_f = get_equation(0, from_left_rotated - to_left_rotated, frames_per_transformer - 1, 0, equation_pow)
to_top_f = get_equation(0, from_top_rotated - to_top_rotated, frames_per_transformer - 1, 0, equation_pow)
from_left_f = get_equation(0, 0, frames_per_transformer - 1, to_left_rotated - from_left_rotated, equation_pow)
from_top_f = get_equation(0, 0, frames_per_transformer - 1, to_top_rotated - from_top_rotated, equation_pow)
# 生成第二張圖片平移的變換矩陣
to_translation_M = np.float32(
[
[1, 0, to_left_f(i)],
[0, 1, to_top_f(i)]
]
)
# 對第二張圖片執行平移變換
to_dst = cv2.warpAffine(to_dst, to_translation_M, (to_width, to_height), borderMode=cv2.BORDER_REPLICATE)
# 生成第一張圖片平移的變換矩陣
from_translation_M = np.float32(
[
[1, 0, from_left_f(i)],
[0, 1, from_top_f(i)]
]
)
# 對第一張圖片執行平移變換
from_dst = cv2.warpAffine(from_dst, from_translation_M, (from_width, from_height), borderMode=cv2.BORDER_REPLICATE)
# 將兩張圖片合成到一張,並寫入視頻幀
new_img = cv2.addWeighted(from_dst, 1 - ((i + 1) / frames_per_transformer), to_dst, (i + 1) / frames_per_transformer, 0)
video_writer.write(new_img)
# 一個迭代完成,迭代n次寫入第二張圖片
for _ in range(wait_frames):
video_writer.write(to_img)
video_writer.release()
以上就是利用仿射變換
實現的代碼。效果可以看下面的gif(忽略gif的顏色問題,視頻正常!完整視頻可以到http://www.iqiyi.com/w_19saz225ol.html查看)
通過觀察效果和代碼,我們來總結一下這個版本的不足之處:
- 兩張人臉並未真正實現大小一致。
- 人臉對齊也做的不夠好。
- 僅在2D空間做了變換,對於臉朝向的變換不敏感。
- 代碼複雜。
- 僅利用了68個人臉關鍵點中的一小部分,並未充分利用人臉的特徵。
以上幾個問題其實就決定了仿射變換
版本的使用侷限性很大,跟抖音實現的效果差距很大。這也迫使我尋找另一種解決方案,結果就是透視變換
版本,這個版本代碼簡單而且效果更接近抖音。
透視變換
仿射變換
僅在二維空間做線性變換和平移,所以兩條平行線變換後還是平行的,因而我們感受不到立體變換的效果。而透視變換
則不同,它是在3D空間做變換,最後在映射到2D平面。以下是透視變換
的數學原理。
從公式中可以看到變換後做了第3個維度z
。展開爲方程組形式:
最後映射回2維空間:
從公式中可以看到,假設將a33設爲1,那麼會有8個未知數,也就是我們至少需要4個點才能求得方程的接。在python中可以輕鬆的實現:
img = cv2.imread("./1.jpg")
src_pts = np.float32(
[
[
[0, 0],
[0, 626],
[500, 626],
[500, 0]
]
])
dst_pts = np.float32(
[
[100, 50],
[150, 200],
[500, 626],
[500, 0]
]
)
M = cv2.getPerspectiveTransform(src_pts, dst_pts)
dst = cv2.warpPerspective(img, M, (img.shape[0], img.shape[1]))
cv2.imshow("", dst)
cv2.waitKey()
上面代碼效果如下:
上面的代碼是通過getPerspectiveTransform
函數找到src
的4個點和dst
的4個點的變換矩陣,還有一個函數findHomography
可以在一堆點中找到最佳的變換矩陣,很明顯,第二個函數更符合這個需求的實現,可以直接將人臉識別後的關鍵點扔給這個函數,然後找到最佳變換矩陣。所以透視變換
版本的代碼如下:
def compose_img(name, frames_per_transformer, wait_frames, *imgs):
video_writer = to_video("{}.avi".format(name), imgs[0].shape[1], imgs[0].shape[0])
img_count = len(imgs)
for idx in range(img_count - 1):
from_img = imgs[idx]
to_img = imgs[idx + 1]
from_width = from_img.shape[1]
from_height = from_img.shape[0]
to_width = to_img.shape[1]
to_height = to_img.shape[0]
equation_pow = 1 if idx % 2 == 0 else 2
from_face_region, from_landmarks = face_detector(from_img)
to_face_region, to_landmarks = face_detector(to_img)
homography_equation = get_equation(0, from_landmarks, frames_per_transformer - 1, to_landmarks, equation_pow)
for i in range(frames_per_transformer):
from_H, _ = cv2.findHomography(from_landmarks, homography_equation(i))
to_H, _ = cv2.findHomography(to_landmarks, homography_equation(i))
from_dst = cv2.warpPerspective(from_img, from_H, (from_width, from_height), borderMode=cv2.BORDER_REPLICATE)
to_dst = cv2.warpPerspective(to_img, to_H, (to_width, to_height), borderMode=cv2.BORDER_REPLICATE)
new_img = cv2.addWeighted(from_dst, 1 - ((i + 1) / frames_per_transformer), to_dst, (i + 1) / frames_per_transformer, 0)
video_writer.write(new_img)
for _ in range(wait_frames):
video_writer.write(to_img)
video_writer.release()
可以看到代碼簡化了不少,也僅用了一次變換就完成了。如上面所說,我們使用findHomography
函數,在68個關鍵點中尋找最佳變換矩陣,然後利用warpPerspective
函數進行變換,效果可以看下面的gif(忽略gif的顏色問題,視頻正常!完整視頻可以到http://www.iqiyi.com/w_19saz1z92h.html查看)
可以看到這次的效果完全有了立體感,而且人臉的對齊也比第一個版本好的多,跟抖音的差距也縮小了不少。
最終所有代碼都可以再我的github下載。