포스트

MediaPipe Face Mesh의 Procrustes Analysis - 화면 좌표에서 3D 얼굴 자세까지

MediaPipe Face Mesh의 Procrustes Analysis - 화면 좌표에서 3D 얼굴 자세까지

들어가며

mediapipe_face_mesh 패키지를 개발하면서 MediaPipe의 Face Geometry 모듈을 분석할 일이 있었다.

Face Mesh 모델은 얼굴의 468개 landmark를 화면 좌표로 출력한다. 그런데 AR 필터를 얼굴에 붙이거나 머리 방향을 계산하려면 화면 좌표만으로는 부족하다. “얼굴이 카메라 앞 3D 공간의 어디에, 어떤 자세로 있는가”를 나타내는 변환 행렬(pose transform matrix)이 필요하다.

MediaPipe는 이 문제를 가상 카메라(virtual camera)Procrustes analysis의 조합으로 푼다. 이 글에서는 그 과정을 실제 소스 코드를 따라가며 정리한다.

분석 대상 코드는 MediaPipe의 face_geometry 모듈이며, 핵심 파일은 geometry_pipeline.ccprocrustes_solver.cc 두 개다.

Face Mesh 출력의 한계

Face Mesh 모델의 출력 landmark는 다음과 같은 좌표계를 가진다.

  • x, y : [0, 1]로 정규화된 화면 좌표
  • z : 머리 중심을 기준으로 한 상대적 깊이. 스케일은 x와 동일

z는 “코가 귀보다 카메라에 가깝다” 같은 얼굴 내부의 상대적 깊이만 담고 있을 뿐, 얼굴이 카메라에서 30cm 떨어져 있는지 1m 떨어져 있는지는 알 수 없다. 절대적인 위치와 단위가 없는 것이다.

단위가 있는 3D 공간으로 가려면 두 가지가 더 필요하다.

  1. 기준이 되는 얼굴 모델 — 실측 단위를 가진 표준 얼굴
  2. 카메라 모델 — 화면 좌표와 3D 공간을 잇는 투영 규칙

Canonical Face Model

MediaPipe는 canonical face model이라는 표준 얼굴 메시를 내장하고 있다. cm 단위의 metric 3D 공간에 정의된 468개 정점으로, Face Mesh landmark와 1:1로 대응한다.

발상은 이렇다. 이 표준 얼굴을 3D 공간에서 회전하고, 늘리고, 이동시켜서 카메라로 관측된 landmark에 최대한 겹치게 만들 수 있다면, 그때 사용한 변환이 곧 얼굴의 자세(pose)다.

\[\text{pose transform} \;=\; \text{canonical face} \rightarrow \text{runtime face 정렬 변환}\]

“두 점군을 최대한 겹치게 만드는 최적의 변환 찾기” — 이것이 바로 Procrustes analysis가 푸는 문제다.

Procrustes Analysis

이름은 그리스 신화에서 왔다. 프로크루스테스(Procrustes)는 지나가는 나그네를 침대에 눕혀 놓고, 침대보다 크면 잘라내고 작으면 늘려서 침대에 맞췄다는 강도다. 침대(기준 형상)에 맞춰 대상을 변형한다는 의미로, 형상을 정렬하는 통계 기법에 이 이름이 붙었다.

문제 정의

source 점군 $a_i$ (canonical landmark)와 target 점군 $b_i$ (관측된 landmark), 그리고 점별 가중치 $w_i$가 주어졌을 때, 다음을 최소화하는 스케일 $s$, 회전 $R$, 이동 $t$를 찾는다.

\[\underset{s,\,R,\,t}{\arg\min} \; \sum_i w_i \,\bigl\| \, s R a_i + t - b_i \, \bigr\|^2 \quad \text{s.t.} \;\; R^\top R = I,\;\; \det(R) = +1\]

$R$이 직교 행렬로 제약되어 있어 orthogonal Procrustes problem이라 부른다. 비선형 최적화처럼 보이지만, 놀랍게도 SVD를 이용한 닫힌 형태의 해(closed-form solution)가 존재한다. MediaPipe 구현은 가중치가 있는 일반화 버전으로, 주석에 Akca, Generalized Procrustes analysis and its applications in photogrammetry (2003)를 근거로 밝히고 있다.

풀이 과정

procrustes_solver.ccSolveWeightedOrthogonalProblem은 다음 순서로 해를 구한다.

1) 가중 무게중심 계산과 중심화

\[\bar{a} = \frac{\sum_i w_i a_i}{\sum_i w_i}, \qquad \bar{b} = \frac{\sum_i w_i b_i}{\sum_i w_i}\]

이동 $t$를 소거하기 위해 두 점군을 각각의 무게중심 기준으로 옮긴다.

2) Design matrix (cross-covariance)

\[M = \sum_i w_i \,(b_i - \bar{b})(a_i - \bar{a})^\top\]

두 점군 사이의 상관 구조를 담은 $3 \times 3$ 행렬이다.

3) SVD로 최적 회전 계산

\[M = U \Sigma V^\top, \qquad R = U \begin{bmatrix} 1 & & \\ & 1 & \\ & & \det(UV^\top) \end{bmatrix} V^\top\]

가운데 대각 행렬은 반사(reflection)를 막는 장치다. 순수 최소화만 하면 $\det(R) = -1$인 거울상 변환이 나올 수 있는데, 얼굴이 뒤집힌 해는 의미가 없으므로 최소 특이값에 해당하는 열의 부호를 뒤집어 $\det(R) = +1$을 보장한다.

1
2
3
4
5
// Disallow reflection by ensuring that det(`rotation`) = +1 (and not -1)
if (postrotation.determinant() * prerotation.determinant() < 0) {
  postrotation.col(2) *= -1;
}
rotation = postrotation * prerotation;

4) 최적 스케일

\[s = \frac{\sum_i w_i \,\bigl\langle R(a_i - \bar{a}),\; b_i - \bar{b} \bigr\rangle}{\sum_i w_i \,\| a_i - \bar{a} \|^2}\]

회전된 source를 target에 투영한 비율이다.

5) 최적 이동

\[t = \bar{b} - sR\,\bar{a}\]

최종 결과는 이들을 합친 $4 \times 4$ 변환 행렬이다.

\[T = \begin{bmatrix} sR & t \\ 0 & 1 \end{bmatrix}\]

왜 가중치가 필요한가

Procrustes가 구하는 것은 강체 변환 + 스케일이다. 그런데 얼굴은 강체가 아니다. 입을 벌리거나 눈을 감으면 landmark가 움직이는데, 이것은 자세 변화가 아니라 표정 변화다. 468개 점을 모두 동일하게 취급하면 표정이 자세 추정에 새어 들어간다.

그래서 MediaPipe는 geometry_pipeline_metadataprocrustes_landmark_basis라는 가중치 목록을 정의한다. 468개 중 33개의 landmark에만 0이 아닌 가중치를 주는데, 코 주변·눈가·얼굴 윤곽처럼 표정과 무관하게 움직이는 강체(rigid)에 가까운 점들이다.

1
2
3
4
5
procrustes_landmark_basis { landmark_id: 4   weight: 0.070909 }  # 코 끝
procrustes_landmark_basis { landmark_id: 6   weight: 0.032100 }
procrustes_landmark_basis { landmark_id: 33  weight: 0.058724 }  # 눈가
procrustes_landmark_basis { landmark_id: 129 weight: 0.120625 }  # 코 옆
...

가장 큰 가중치(약 0.12)는 코 양옆 점(129, 358)에 있다. 코는 표정으로 거의 변형되지 않는, 얼굴에서 가장 강체에 가까운 부위다.

가상 카메라와 Camera Matrix

Pinhole 카메라 모델

Procrustes를 돌리려면 관측 landmark도 canonical 모델과 같은 metric 3D 공간에 있어야 한다. 화면 좌표를 3D로 되돌리려면(unprojection) 카메라 모델이 필요하다.

일반적인 pinhole 카메라 모델에서 3D 점 $(X, Y, Z)$는 다음과 같이 이미지에 투영된다.

\[\begin{bmatrix} x \\ y \\ 1 \end{bmatrix} \sim K \begin{bmatrix} X \\ Y \\ Z \end{bmatrix} = \begin{bmatrix} f_x & 0 & c_x \\ 0 & f_y & c_y \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} X \\ Y \\ Z \end{bmatrix} \;\;\Longrightarrow\;\; x = f_x \frac{X}{Z} + c_x, \quad y = f_y \frac{Y}{Z} + c_y\]

여기서 $K$가 camera intrinsic matrix, $f_x, f_y$는 픽셀 단위 초점 거리, $(c_x, c_y)$는 주점(principal point)이다. 핵심은 투영이 깊이 $Z$로 나누는 연산이라는 점이다. 멀리 있는 것은 작게 보인다는 원근법이 이 나눗셈 하나에 담겨 있다.

MediaPipe의 가상 카메라

문제는 일반 사용자의 카메라는 캘리브레이션되어 있지 않다는 것이다. $K$를 알 수 없다. 그래서 MediaPipe는 실제 카메라 파라미터를 요구하는 대신, 합리적인 기본값을 가진 가상 perspective 카메라를 가정한다.

1
2
3
4
5
6
7
8
environment: {
  origin_point_location: TOP_LEFT_CORNER
  perspective_camera: {
    vertical_fov_degrees: 63.0  # 63 degrees
    near: 1.0    # 1cm
    far: 10000.0 # 100m
  }
}

카메라를 intrinsic matrix가 아니라 수직 화각(vertical FOV)과 near/far 클리핑 평면으로 정의하는데, 이는 OpenGL 스타일의 카메라 표현이다. 두 표현은 동치이며 다음 관계로 변환된다.

\[f_y = \frac{h}{2 \tan(\theta_v / 2)} \quad\Longleftrightarrow\quad \theta_v = 2 \arctan\!\left(\frac{h}{2 f_y}\right)\]

($h$: 이미지 세로 픽셀 수, $\theta_v$: 수직 화각)

geometry_pipeline.cc는 이 카메라 정의로부터 view frustum(시야 절두체)을 계산한다.

1
2
3
4
5
6
7
8
9
10
11
const float height_at_near =
    2.f * perspective_camera.near() *
    std::tan(0.5f * kDegreesToRadians *
             perspective_camera.vertical_fov_degrees());

const float width_at_near = frame_width * height_at_near / frame_height;

left   = -0.5f * width_at_near;
right  =  0.5f * width_at_near;
bottom = -0.5f * height_at_near;
top    =  0.5f * height_at_near;

near 평면(카메라에서 1cm)에서 frustum의 물리적 크기를 구하는 코드다. FOV 63°, 세로 이미지 기준이면 near 평면의 높이는 $2 \times 1 \times \tan(31.5°) \approx 1.23$cm가 된다.

가상 카메라이므로 실제 카메라의 화각과 다를 수 있다. 이 경우 회전(자세)은 크게 어긋나지 않지만, 절대 거리와 스케일은 부정확해진다. 얼굴까지의 실제 거리가 필요하다면 사용 중인 카메라의 intrinsic에 맞는 FOV를 environment에 넣어줘야 한다.

Unprojection

투영이 $x = f\,X/Z$였으니, 역투영은 화면 좌표에 깊이를 곱하는 것이다. near 평면 위의 좌표 $(x, y)$와 깊이 $Z$가 있으면,

\[X = x \cdot \frac{Z}{near}, \qquad Y = y \cdot \frac{Z}{near}\]

코드도 정확히 이 형태다.

1
2
3
4
5
static void UnprojectXY(const PerspectiveCameraFrustum& pcf,
                        Eigen::Matrix3Xf& landmarks) {
  landmarks.row(0) = landmarks.row(0).cwiseProduct(landmarks.row(2)) / pcf.near;
  landmarks.row(1) = landmarks.row(1).cwiseProduct(landmarks.row(2)) / pcf.near;
}

그런데 여기서 닭과 달걀 문제가 생긴다.

  • 역투영을 하려면 절대 깊이 $Z$ 가 필요하다.
  • $Z$를 알려면 얼굴의 실제 크기 대비 스케일을 알아야 한다.
  • 스케일은 canonical 모델과 Procrustes 정렬을 해야 나온다.
  • 그런데 Procrustes를 하려면 metric 좌표, 즉 역투영된 좌표가 필요하다.

MediaPipe는 이 순환을 Procrustes를 두 번 돌리는 반복 추정으로 끊는다.

Geometry Pipeline 전체 흐름

ScreenToMetricSpaceConverter::Convert의 실제 처리 순서다.

(1) ProjectXY — 화면 좌표를 near 평면 위로

정규화 좌표 [0, 1]을 near 평면의 물리 좌표 [left, right] × [bottom, top]으로 매핑한다. 원점이 좌상단(TOP_LEFT_CORNER)이면 y를 뒤집고, z도 같은 비율(x_scale)로 스케일한다. 이제 landmark는 “near 평면에 그려진 cm 단위의 얼굴 그림”이 된다.

(2) 1차 Procrustes — 스케일 초기 추정

near 평면 위의 landmark와 canonical 모델 사이에 Procrustes를 풀어 첫 번째 스케일 $s_1$을 얻는다.

1
2
3
4
5
6
7
absl::StatusOr<float> EstimateScale(Eigen::Matrix3Xf& landmarks) const {
  Eigen::Matrix4f transform_mat;
  MP_RETURN_IF_ERROR(procrustes_solver_->SolveWeightedOrthogonalProblem(
      canonical_metric_landmarks_, landmarks, landmark_weights_,
      transform_mat));
  return transform_mat.col(0).norm();
}

변환 행렬의 좌상단 $3 \times 3$은 $sR$이고 $R$의 열은 단위 벡터이므로, 첫 열의 norm이 곧 스케일 $s$다.

이 단계에서 역투영을 하지 않는 이유를 소스 주석이 직접 설명한다. z가 상대값이라 절대 깊이가 없는 상태에서는 역투영이 안전하지 않기 때문에(“it is not safe to unproject due to the relative nature of the input screen landmark Z coordinate”), 일단 투영된 XY 평면 좌표만으로 대략적인 스케일부터 잡는 것이다.

(3) 1차 역투영 — 중간(intermediate) landmark

$s_1$을 이용해 깊이를 재배치하고 역투영한다.

1
2
3
4
5
6
static void MoveAndRescaleZ(const PerspectiveCameraFrustum& pcf,
                            float depth_offset, float scale,
                            Eigen::Matrix3Xf& landmarks) {
  landmarks.row(2) =
      (landmarks.array().row(2) - depth_offset + pcf.near) / scale;
}

depth_offset(z의 평균)을 빼서 얼굴 중심을 near 평면에 놓고, 스케일로 나눈다. 기하 직관은 닮은꼴 삼각형이다. near 평면에 투영된 얼굴이 canonical 얼굴보다 $s$배 크다면, 실제 얼굴은 대략 $near/s$ 거리에 있다. near가 1cm이고 투영된 얼굴이 표준 얼굴의 1/30 크기라면 실제 얼굴은 약 30cm 거리에 있는 셈이다.

이어서 UnprojectXY로 XY를 역투영하고, 좌표계의 손대칭성을 맞추기 위해 z 부호를 뒤집는다(화면 좌표는 왼손 좌표계, metric 공간은 오른손 좌표계).

(4) 2차 Procrustes — 스케일 정밀화

이제 landmark가 원근이 풀린 그럴듯한 metric 형상이 되었으므로, 같은 Procrustes를 한 번 더 돌려 보정 스케일 $s_2$를 얻는다. 1차 추정은 원근 왜곡이 남은 상태에서 계산된 값이라 오차가 있는데, 2차에서 이를 잡는다.

(5) 최종 역투영

\[s_{total} = s_1 \times s_2\]

원본 화면 landmark에 $s_{total}$로 MoveAndRescaleZUnprojectXYChangeHandedness를 다시 적용하면 최종 metric landmark가 나온다.

(6) 최종 Procrustes — pose transform matrix

canonical 모델과 최종 metric landmark 사이에 Procrustes를 한 번 더 풀면, 그 결과가 바로 우리가 원하던 face pose transform matrix다.

1
2
3
4
5
6
7
8
9
10
MP_RETURN_IF_ERROR(procrustes_solver_->SolveWeightedOrthogonalProblem(
    canonical_metric_landmarks_, metric_landmarks, landmark_weights_,
    pose_transform_mat));

// Multiply each of the metric landmarks by the inverse pose
// transformation matrix to align the runtime metric face landmarks with
// the canonical metric face landmarks.
metric_landmarks = (pose_transform_mat.inverse() *
                    metric_landmarks.colwise().homogeneous())
                       .topRows(3);

마지막 줄이 흥미로운데, 출력용 metric landmark에는 pose의 역행렬을 곱한다. 즉 최종 출력은 두 갈래로 나뉜다.

  • mesh (metric landmarks) : pose가 제거된, canonical 모델 위치에 정렬된 얼굴 형상. 자세와 무관한 순수한 얼굴 모양(표정 포함)이다.
  • pose_transform_matrix : 그 형상을 카메라 앞 실제 위치·자세로 가져다 놓는 $4 \times 4$ 변환.

형상과 자세를 분리해 놓았기 때문에, AR 렌더링에서는 canonical 공간에 이펙트를 모델링해 두고 pose matrix 하나만 곱해서 얼굴에 붙일 수 있다.

정리

전체 파이프라인을 한 줄씩 요약하면 다음과 같다.

단계연산목적
1ProjectXY정규화 좌표 → near 평면의 cm 단위 좌표
2Procrustes ①스케일 초기 추정 $s_1$
3MoveAndRescaleZ + UnprojectXY$s_1$ 기반 1차 역투영
4Procrustes ②스케일 정밀화 $s_2$
5최종 역투영$s_1 s_2$로 metric landmark 확정
6Procrustes ③pose transform matrix 산출

카메라 행렬(가상 perspective 카메라)이 화면과 3D 공간 사이의 다리를 놓고, Procrustes analysis가 그 3D 공간 안에서 표준 얼굴과 관측 얼굴을 정렬한다. 캘리브레이션 없는 카메라에서 상대 깊이만 가진 landmark로 절대 자세를 뽑아내는, 제약 대비 상당히 우아한 설계라고 생각한다.

참고로 MediaPipe Tasks API(FaceLandmarker)에서 outputFacialTransformationMatrixes 옵션을 켰을 때 받는 행렬이 바로 이 파이프라인의 결과물이다.

참고 자료

이 기사는 저작권자의 CC BY-NC 4.0 라이센스를 따릅니다.