왜 회전 표현을 여러 개나 쓰나

어제 동차변환 T로 좌표계를 잇는 것까지 했다. 오늘은 그 T 안에 들어 있는 R 자체를 파고들었다. 회전행렬은 성분이 9개지만 자유도는 3이다. 나머지 6개는 직교 조건 RᵀR = I가 잡아먹는다.

그 3이 곧 축 2 + 각 1이다. 축은 단위벡터라 성분 3개 중 크기 제약으로 하나가 묶여서 2, 회전각이 1. 축-각과 쿼터니언이 회전행렬과 같은 정보를 담으면서도 성분이 적은 이유가 여기 있다.

표현성분 수자유도쓰는 자리
회전행렬 R93좌표 변환 계산
축-각 (k, θ)43사람이 읽고 쓰기
쿼터니언 q43보간·누적

1. 왕복 변환은 왜 원래 점으로 정확히 안 돌아오나

P' = TP로 보냈다가 T⁻¹로 되돌린 점을 P''라 하면 이론상 P'' = P여야 한다. 그런데 ==로 비교하면 False가 뜬다.

e = ‖P'' − P‖

Problem — T로 변환한 뒤 T⁻¹로 되돌리면 원래 점과 정확히 같지 않다. 코드가 틀린 줄 알고 역변환 공식을 몇 번이나 다시 봤다.

Why — 알고리즘 문제가 아니라 부동소수점 정밀도 한계다. 컴퓨터에서 1.0은 0.9999999…로 저장될 수 있고, 행렬 곱이 누적될수록 오차도 같이 쌓인다. RᵀR = I 조차 정확히 I가 아니라 10⁻¹⁷ 수준의 오차를 달고 있다.

How — 같은지 판단할 때 ==를 쓰지 않고 허용 오차로 비교한다. e < ε 혹은 np.allclose(P2, P).

What — 직접 재보니 float64에서는 e ≈ 10⁻¹⁵였다. 10⁻⁸이 나오면 float32를 썼거나 변환을 수백 번 누적한 것이니, 오차 자릿수 자체가 어디서 정밀도를 잃었는지 알려주는 신호다.

오차 크기를 눈으로 확인해두면 나중에 임계값을 정할 때 근거가 된다.

import numpy as np

T = np.eye(4)
T[:3, :3], T[:3, 3] = R, t

T_inv = np.eye(4)
T_inv[:3, :3], T_inv[:3, 3] = R.T, -R.T @ t

P = np.array([1.234, -5.678, 9.1011, 1.0])
P2 = T_inv @ (T @ P)

np.array_equal(P2, P)                # False
np.linalg.norm(P2 - P)               # 1.256e-15
np.linalg.norm(R.T @ R - np.eye(3))  # 8.27e-17

2. 점 여러 개를 한 번에 변환할 때

점 N개를 쌓는 방법이 두 가지다. 어느 쪽을 고르든 수식은 전치 하나 차이다.

레이아웃형태변환 식
열벡터로 쌓기P_h ∈ ℝ^(4×N)P'_h = T P_h
행벡터로 쌓기P_hᵀ ∈ ℝ^(N×4)(P'_h)ᵀ = P_hᵀ Tᵀ
P'_h = T P_h  ⟺  (P'_h)ᵀ = (T P_h)ᵀ = P_hᵀ Tᵀ

실무에서는 점군이 대개 (N, 3)으로 들어오니까 행벡터 쪽이 전치를 덜 쓴다. 어제 transform_points를 P_h Tᵀ로 쓴 게 이 이유였다.

3. 쿼터니언

q = (x, y, z, w),   ‖q‖ = √(x² + y² + z² + w²) = 1

(x, y, z)는 회전축 성분, w는 회전각 성분을 담는다. 정규화가 깨지면 회전이 아니게 되므로 누적 곱 뒤에는 항상 q ← q / ‖q‖로 맞춰준다.

축-각 → 쿼터니언

q = ( kx·sin(θ/2), ky·sin(θ/2), kz·sin(θ/2), cos(θ/2) )

θ가 아니라 반각 θ/2가 들어간다. k = (0,0,1), θ = 90°이면

q = (0, 0, sin45°, cos45°) ≈ (0, 0, 0.7071, 0.7071)

쿼터니언 → 회전 행렬

     [ 1−2(y²+z²)   2(xy−zw)    2(xz+yw)  ]
R =  [  2(xy+zw)   1−2(x²+z²)   2(yz−xw)  ]
     [  2(xz−yw)    2(yz+xw)   1−2(x²+y²) ]

반각 때문에 q와 −q가 같은 회전을 나타낸다. 위 식을 보면 모든 항이 성분의 곱이라 부호가 통째로 뒤집혀도 값이 그대로다. 그래서 두 자세를 비교할 때 q₁ = q₂로 판정하면 안 되고 |q₁·q₂| ≈ 1로 봐야 한다.

4. 회전 행렬에서 회전축 k 구하기

회전축 위의 벡터는 회전을 해도 그대로다. 이 한 문장이 전부다.

Rk = k  ⟺  Rk = λk   (λ = 1)

고유방정식 Av = λv와 같은 꼴이므로 회전축은 고유값 1에 대응하는 고유벡터다.

(R − λI)k = 0,   det(R − λI) = 0

5. 회전각 θ 구하기 (trace)

3×3 회전행렬의 고유값 세 개는 이렇게 생겼다.

λ₁ = 1,   λ₂ = e^(iθ),   λ₃ = e^(−iθ)

오일러 공식 e^(iθ) = cosθ + i·sinθ에서 e^(iθ) + e^(−iθ) = 2cosθ이므로

λ₁ + λ₂ + λ₃ = 1 + 2cosθ

고유값의 합은 곧 대각합(trace)이다. 그래서

tr(R) = r₁₁ + r₂₂ + r₃₃ = 1 + 2cosθ

∴ θ = arccos( (tr(R) − 1) / 2 ) ∈ [0, π]

대각선 세 개만 더하면 회전각이 나온다. 고유값 분해를 돌릴 필요가 없다.

6. R − Rᵀ로 축 성분 뽑기

고유벡터를 수치로 푸는 대신 로드리게스 공식에서 바로 뽑을 수 있다.

R = I + sinθ·[k]ₓ + (1 − cosθ)·[k]ₓ²

[k]ₓ는 반대칭(skew-symmetric) 행렬이라 [k]ₓᵀ = −[k]ₓ이고, I와 [k]ₓ²는 대칭이라 전치를 빼면 사라진다. 남는 건 반대칭 항 하나뿐이다.

                            [  0   −kz   ky ]
R − Rᵀ = 2sinθ·[k]ₓ = 2sinθ [  kz   0   −kx ]
                            [ −ky   kx    0 ]

성분끼리 비교하면

        1     [ R₃₂ − R₂₃ ]
k = ───────── [ R₁₃ − R₃₁ ]
     2 sinθ   [ R₂₁ − R₁₂ ]
def rotation_to_axis_angle(R):
    """회전행렬에서 축 k와 각 theta를 복원한다. (theta = 0 또는 pi는 별도 처리)"""
    theta = np.arccos((np.trace(R) - 1) / 2)
    k = np.array([R[2, 1] - R[1, 2],
                  R[0, 2] - R[2, 0],
                  R[1, 0] - R[0, 1]]) / (2 * np.sin(theta))
    return k, theta

7. 특이 케이스와 부호 모호성

위 공식에는 sinθ가 분모에 있다. θ가 0이거나 π면 그대로 0으로 나누게 된다.

상황문제내용
θ = 0sinθ = 0 → 0으로 나눔R = I, R − Rᵀ = 0. 아예 안 돌았으므로 회전축이 정의되지 않는다
θ = πsinθ = 0 → 0으로 나눔R − Rᵀ = 0이라 축을 못 뽑는다. (R − I)k = 0을 직접 풀어야 한다
부호(k, θ)와 (−k, −θ)축을 뒤집고 각도 부호도 뒤집으면 같은 회전이다. 표현이 유일하지 않다
def rotation_to_axis_angle_safe(R, eps=1e-8):
    theta = np.arccos(np.clip((np.trace(R) - 1) / 2, -1.0, 1.0))

    if np.sin(theta) < eps:
        if theta < eps:
            return None, 0.0                    # 무회전: 축이 정의되지 않음
        w, v = np.linalg.eig(R)                 # theta = pi: 고유값 1의 고유벡터
        k = np.real(v[:, np.argmin(np.abs(w - 1))])
        return k / np.linalg.norm(k), theta

    k = np.array([R[2, 1] - R[1, 2],
                  R[0, 2] - R[2, 0],
                  R[1, 0] - R[0, 1]]) / (2 * np.sin(theta))
    return k, theta

np.clip을 씌운 것도 같은 맥락이다. 부동소수점 오차 때문에 (tr(R)−1)/2가 1.0000000000000002로 나오면 arccos가 nan을 뱉는다. 1번에서 본 정밀도 문제가 여기서 다시 튀어나온 셈이다.

정리

  1. 회전행렬은 성분 9개지만 자유도는 3이고, 그 3이 곧 축(2) + 각(1)이다. 축-각과 쿼터니언이 같은 정보를 더 적은 성분으로 담는 이유다.
  2. 왕복 변환이 정확히 안 맞는 건 알고리즘이 아니라 부동소수점 정밀도다. float64에서 10⁻¹⁵ 수준이고, == 대신 허용 오차로 비교해야 한다.
  3. 쿼터니언은 반각 θ/2를 쓴다. 그래서 q와 −q가 같은 회전이고, 자세 비교는 |q₁·q₂| ≈ 1로 한다.
  4. 회전축은 고유값 1의 고유벡터다. 회전해도 안 변하는 벡터가 축이라는 정의를 그대로 식으로 옮긴 것이다.
  5. 회전각은 trace에서 나온다. 고유값의 합 1 + 2cosθ가 대각합과 같으니 θ = arccos((tr(R)−1)/2).
  6. R − Rᵀ는 로드리게스 공식에서 대칭 항을 지우고 반대칭 항만 남긴다. 그래서 축 성분이 바로 읽히고, 고유값 분해를 돌릴 필요가 없다.
  7. θ = 0과 θ = π에서는 sinθ = 0이라 축 정보가 반대칭 부분에서 완전히 사라진다. 이때만 (R − I)k = 0을 직접 푼다.
  8. (k, θ)와 (−k, −θ)는 같은 회전이다. 축-각 표현은 유일하지 않으므로 부호 규약을 따로 정해야 비교가 된다.
comments powered by Disqus