라이브 스트리밍 프로젝트를 진행하면서 요즘 유행하는 버튜버 기능을 구현하고자 시도해 보았다.
실제 버튜버 적용을 어떻게 하는지 알아본 바에 따르면 Vroid studio라는 프로그램으로 버튜버 캐릭터를 생성 및 저장할 수 있으며 (.vrm 파일로 생성된다.) 이를 animaze 라는 소프트웨어를 통해 캐릭터를 불러오면 웹캠에 비춰진 내 모션과 캐릭터가 연동되어 움직이는 연출이 즉시 적용되었고, 해당 화면을 OBS에서 가상 카메라 형태로 송출하는 방식임을 알 수 있었다.
이처럼 단순하게는 ZOOM의 아바타 기능에서 더 나아가 버튜버처럼 최소 눈과 입 모션 캡처를 적용하는 방법은 어디서부터 어떻게 구현해야할지 당장 떠오르지 않았다. 하지만 당장 시도해볼 만한 방법은 face detection을 통해 카메라에 비춰진 내 모습에 이미지를 합성하는 방식이었다.
이 조차 완벽하게 구현해내지 못했지만 지금까지 구현한 방식을 공유하고자 한다.
얼굴 인식하기
다소 불안정한 요소가 있지만 가장 접근이 쉬운 방법은 python의 openCV 패키지를 활용하는 방법이었다. 몇 개월 간 javascript만 보다가 오랜만에 python 코드를 보니 약간의 적응 시간이 필요했다.
import cv2
import mediapipe as mp
mp_face_detection = mp.solutions.face_detection
mp_drawing = mp.solutions.drawing_utils
cap = cv2.VideoCapture(0) // 캠 선택 (mp4와 같은 비디오를 설정할 수도 있음)
with mp_face_detection.FaceDetection(model_selection=0, min_detection_confidence=0.7) as face_detection:
while cap.isOpened():
success, image = cap.read()
if not success:
break
results = face_detection.process(image)
if results.detections:
for detection in results.detections:
mp_drawing.draw_detection(image, detection)
cv2.imshow('Face Detection', cv2.flip(image, 1))
if cv2.waitKey(1) == ord('q'):
break
cap.release()
cv2.destroyAllWindows()
먼저 mediapipe 패키지를 이용해 FaceDetection 모델을 적용하면 제공되는 영상에서 얼굴을 인식하고 눈, 코, 입, 귀에 해당하는 지점을 빨간색 점으로 추적하는 결과를 볼 수 있다.
FaceDetection 클래스에서 model_selection은 어떤 얼굴 인식 모델을 사용할 것인가에 대한 설정으로 0으로 설정할 경우 캠과 얼굴의 거리가 약 2m를 유지한 상태에서 탐지를 잘 하는 모델이 선택된다고 한다. 아마도 2m라는 거리를 기준으로 학습된 탐지 모델을 사용하는 것으로 예상된다.
INFO: Created TensorFlow Lite XNNPACK delegate for CPU.
나의 경우 해당 테스트를 16년 인텔맥북으로 진행했는데 GPU가 없다보니 CPU에서도 감당이 되는 텐서플로 기반 모델을 붙여주는 것을 확인할 수 있었다. 당연히 그만큼 얼굴 부위별 인식 정확도도 높지 않았다. 정확히 눈에 해당하는 지점에 빨간 포인트가 위치하지 못하는 것을 확인할 수 있었다.
눈, 코, 입, 귀 인식하기
if results.detections:
for detection in results.detections:
mp_drawing.draw_detection(image, detection)
keypoints = detection.location_data.relative_keypoints
right_eye = keypoints[0]
left_eye = keypoints[1]
nose_tip = keypoints[2]
results.detections
부분에 주목하자. 이는 for문을 통해서 부위별 좌표를 획득할 수 있다.
먼저 mp_drawing.draw_detection(image, detection)
코드를 입력하면 얼굴로 인식된 영역을 직사각형으로 표시하고, 눈, 코, 입, 귀 부분을 빨간 점으로 표시하는 기능을 지니고 있다. 이 코드는 단순히 표시를 위한 코드이므로 나중에는 주석처리 해야한다.
detection.location_data.relative_keypoints
메서드를 통해 위 코드와 같이 부위별 좌표를 획득할 수 있는데 특히 상대 좌표를 얻을 수 있음에 주목해야 한다. 여기서 상대 좌표라 함은 실제 width와 height의 픽셀 위치를 반환하기 않고, 어떤 길이와 넓이를 가지는 이미지든지 좌표의 시작점을 0, 끝지점을 1로 설정한 기준에서의 좌표를 반환한다. 그래서 좌표값이 0과 1사이의 값을 갖는다.
얼굴에 썬글라스 이미지 씌우기
내 얼굴에 썬글라스 이미지를 씌우기 위해서는 어떤 과정이 필요할까?
- 뒷배경이 투명한 썬글라스.png 준비
- 눈 위치를 기준으로 썬글라스 이미지가 리사이징 되어야 함
- 리사이징된 썬글라스 이미지가 눈 이미지 위에 안착되어야 함
- 썬글라스 이미지가 실시간으로 얼굴을 계속 따라다녀야 함
간단하게 위 과정을 거치면 되지만 좀 더 고민이 필요한 부분은 어떻게 썬글라스를 눈에 안착시킬 것인가? 이다.
if results.detections:
for detection in results.detections:
mp_drawing.draw_detection(image, detection)
keypoints = detection.location_data.relative_keypoints
right_eye = keypoints[0]
left_eye = keypoints[1]
# 절대값 좌표 반영
h, w, _ = image.shape
right_eye = (int(right_eye.x * w), int(right_eye.y * h))
left_eye = (int(left_eye.x * w), int(left_eye.y * h))
# 두 눈 간의 거리 계산
distance_between_eyes = abs(right_eye[0] - left_eye[0])
# 썬글라스 이미지 크기 조정
image_sunglasses = cv2.resize(sunglasses,
(distance_between_eyes,
int(sunglasses.shape[0] * (distance_between_eyes / sunglasses.shape[1]))))
# 썬글라스를 중앙 위치에 배치
x_offset = min(left_eye[0], right_eye[0])
y_offset = min(left_eye[1], right_eye[1])
# 합성할 위치 계산
y1, y2 = y_offset, y_offset + image_sunglasses.shape[0]
x1, x2 = x_offset, x_offset + image_sunglasses.shape[1]
# 알파 채널을 고려하여 이미지 합성
alpha_s = image_sunglasses[:, :, 3] / 255.0
alpha_l = 1.0 - alpha_s
for c in range(0, 3):
image[y1:y2, x1:x2, c] = (alpha_s * image_sunglasses[:, :, c] + alpha_l * image[y1:y2, x1:x2, c])
위 코드를 살펴보자
먼저 keypoints를 통해 두 눈의 상대좌표를 획득한다. 이후 절대 좌표를 획득하기 위해 image 원본 사이즈(웹캠 송출 크기에 해당한다.)의 width와 height값에 상대 좌표를 곱한다. 이를 통해 눈 부위의 좌표값을 획득할 수 있다. 이는 눈 간격에 대한 정보를 획득하기 위해서였다.
내가 가져온 썬글라스.png의 원본 크기는 800 * 230 이었다. 이는 캠에 비춰지는 두 눈의 위치에 따라 이미지가 리사이징이 되어야 한다. 실제로 width 800은 상당히 큰 편에 속하는데 웹캠 상에서 두 눈의 간격은 고작 120px 정도 밖에 안되기 때문이다. 그래서 cv2.resize를 통해 썬글라스 이미지를 매 순간 변경하고 있음을 볼 수 있다.
리사이징 방식은 간단하다. 먼저 두 눈의 거리를 계산한 뒤 이를 썬글라스 이미지의 width로 설정되도록 하고, 이에 따라 원본 비율을 유지한 채 이미지를 줄일 수 있도록 height 또한 같은 비율로 줄인다.
이 부분은 수정이 필요하다. 우선적으로는 구현 성공에 중점을 두어 대략적인 계산을 적용했지만 실제로는 두 눈 사이의 간격이 썬글라스 이미지의 width가 된다면 당연히 썬글라스 이미지는 눈 전체를 덮지 못할 것이다. 즉 이미지 크기를 더 키워줄 필요가 있다는 뜻이다. 최소한 왼쪽눈의 왼쪽 끝 지점과 오른쪽 눈의 오른쪽 끝 지점의 좌표를 획득하여 썬글라스의 width길이로 설정하는 것이 더 합리적일 수 있다.
또한 썬글라스 이미지를 합성할 좌표 계산 또한 문제점이 있다. 위 공식대로 적용한다면 내가 얼굴을 살짝 옆으로 기울인다 하더라도 썬글라스는 이를 따라오지 못하고 수평을 그대로 유지하게 된다. 왜냐하면 오프셋 값 즉 이미지 붙여넣기의 시작 지점에서 단순히 선글라스 이미지의 width를 더해주므로 수평으로 반영될 수 밖에 없기 떄문이다. 이는 추후 얼굴 기울임에 따른 각도가 반영될 필요가 있다.
마지막으로 알파 채널을 고려한 이미지 합성을 보자
알파 채널은 투명도에 해당하는 채널이다. 기본적으로 캠 이미지는 RGB 이렇게 3채널을 가지지만 썬글라스 이미지는 투명배경이 적용된 이미지 이므로 이를 캠 이미지와의 합성 과정에서 인식시켜 줘야한다. 어떻게 하는 걸까?
먼저 alpha_s
는 썬글라스 이미지에서 알파채널에 해당하는 모든 이미지 픽셀값을 255로 나누고 있다. 이는 정규화를 의미한다. 각 픽셀의 값은 0-255의 범위를 갖기 떄문에 255로 나눈다는 것은 normalize 함를 의미한다. 하지만 사실 캠과 썬글라스 이미지의 알파 채널값은 0 또는 255의 값을 가질 것이다. 완전 투명하거나 불투명하거나이기 때문이다. 그래서 255로 나누는 결과값은 0 아니면 1이 도출된다. 그러므로 썬글라스 이미지의 알파채널 벡터 값은 투명한 부분은 1 썬글라스 부분은 0값이 나왔을 것이다. alpha_l
은 alpha_s
값에 상반되는 값을 구하는 과정이다. 이렇게 하는 이유는 썬글라스 이미지에서 투명에 해당하는 영역은 캠 이미지에서 투명이 아니어야 하고 썬글라스에 해당하는 영역에서 캠 이미지는 투명이어야 하기 때문이다.
여기서 투명 정보를 어떻게 활용하는지 살펴보자. for c in range(0, 3):
반복문은 0-2 즉 3개의 채널을 도는 것이다. R, G, B(BGR일 수도 있다...)에 해당하는 각 채널을 돌면서 alpha
를 곱해주고 있다. 이게 무슨 의미일까? 가령 R 채널을 예로 들어보자. 10 by 10 이미지라면 총 100개의 픽셀이 존재하고, 각 픽셀은 0-255 사이의 값을 갖는다. 여기에 alpha_l
라고 하는 10 by 10 벡터를 곱해주는데 이 벡터에는 0또는 1값만 존재한다. 이를 Element wise product하면 어떻게 될까? alpha의 1에 해당하는 픽셀만 그 값을 유지하고 나머지는 전부 0이 된다. 이 상태에서 이와 완전히 상반되는 alpha_s
는 썬글라스 이미지를 기반으로 이전에 0이 되었던 픽셀 자리에 그 값이 살아남고 나머지는 0이 될 것이다. 이 두 결과를 합치는 것으로 합성이 완성된다.
간단히 정리하자면 이렇다. 썬글라스에 해당하는 픽셀 좌표, 즉 썬글라스 영역에 대해 캠 이미지 입장에서 픽셀값을 0으로 만들어 썬글라스 영역 픽셀값으로 덮어씌운다.
가상 카메라 구현하기
pyvirtualcam이라는 패키지를 통해 합성 이미지를 가상 카메라로 띄울 수 있고, 이를 OBS에서 인식하도록 할 수 있다.
mp_face_detection = mp.solutions.face_detection
mp_drawing = mp.solutions.drawing_utils
face_detection = mp_face_detection.FaceDetection(model_selection=0, min_detection_confidence=0.5)
image_sunglasses = cv2.imread('/Users/leitmotiv/Downloads/sunglasses.png', cv2.IMREAD_UNCHANGED)
# 가상 카메라 생성
with pyvirtualcam.Camera(width=1280, height=720, fps=30) as cam:
print(f'Using virtual camera: {cam.device}')
# 웹캠 캡처 시작
cap = cv2.VideoCapture(0)
while cap.isOpened():
success, image = cap.read()
if not success:
break
results = face_detection.process(image)
if results.detections:
for detection in results.detections:
mp_drawing.draw_detection(image, detection)
keypoints = detection.location_data.relative_keypoints
right_eye = keypoints[0]
left_eye = keypoints[1]
# 절대값 좌표 반영
h, w, _ = image.shape
right_eye = (int(right_eye.x * w), int(right_eye.y * h))
left_eye = (int(left_eye.x * w), int(left_eye.y * h))
# 두 눈 간의 거리 계산
distance_between_eyes = abs(right_eye[0] - left_eye[0])
# 썬글라스 이미지 크기 조정
image_sunglasses = cv2.resize(sunglasses,
(distance_between_eyes,
int(sunglasses.shape[0] * (distance_between_eyes / sunglasses.shape[1]))))
# 썬글라스를 중앙 위치에 배치
x_offset = min(left_eye[0], right_eye[0])
y_offset = min(left_eye[1], right_eye[1])
# 합성할 위치 계산
y1, y2 = y_offset, y_offset + image_sunglasses.shape[0]
x1, x2 = x_offset, x_offset + image_sunglasses.shape[1]
# 알파 채널을 고려하여 이미지 합성
alpha_s = image_sunglasses[:, :, 3] / 255.0
alpha_l = 1.0 - alpha_s
for c in range(0, 3):
image[y1:y2, x1:x2, c] = (alpha_s * image_sunglasses[:, :, c] +
alpha_l * image[y1:y2, x1:x2, c])
image = cv2.cvtColor(cv2.flip(image, 1), cv2.COLOR_BGR2RGB)
# 가상 카메라에 프레임 전송
cam.send(image)
# OBS로 전송된 프레임을 확인할 수 있도록 작은 지연 시간을 추가
cam.sleep_until_next_frame()
# 버튼 클릭 시 while문 빠져나오도록해서 cap.release되도록 해야함
cap.release()
위 코드의 하단을 살펴보면 합성을 완료한 image 변수가 cam.send의 파라미터로 들어가는 것을 볼 수 있다. 저렇게 pyvirtualcam
에 대한 with문을 활용하면 캠 이미지를 가상 카메라로 구현이 가능해 진다. 해당 코드를 작동시키면 OBS의 소스에서 비디오 캡처 장치를 일반 캠이 아닌 OBS Virtual Camera
로 설정할 경우 pyvirtualcam
의 결과물 즉 합성 사진을 볼 수 있게 된다.
위 코드에서 해결해야할 문제점은 아직 남아있다.
- 실행 및 종료 도구가 필요하다. 위 코드는 기본적으로 while 문이 기반이 되므로 특정 키 또는 버튼을 눌렀을 경우 while문을 빠져나오도록 로직을 구현할 필요가 있다. 이는 tkinter와 같은 GUI 패키지를 활용하여 하나의 소프트웨어 형태로 구현한 뒤 정지 버튼을 구현함으로써 해결할 수 있다.
- 얼굴 기울임에 따른 썬글라스의 인식 문제 해결이 필요하다.
- 썬글라스가 매우 떨린다.
'백엔드 개발자(node.js)가 되는 과정' 카테고리의 다른 글
AWS Cloudfront의 work flow 살펴보기 (0) | 2023.09.12 |
---|---|
Nestjs passport로 카카오, 구글 로그인 인증 구현하기 (1) | 2023.09.06 |
Node.js 기반 라이브 스트리밍 구현 흐름 살펴보기 (0) | 2023.08.31 |
S3, cloudfront를 활용한 라이브 스트리밍 구현 (0) | 2023.08.26 |
AWS의 EC2, Nginx, OBS로 라이브 스트리밍 구현하기 (1) | 2023.08.23 |