왜 회전 표현을 여러 개나 쓰나
어제 동차변환 T로 좌표계를 잇는 것까지 했다. 오늘은 그 T 안에 들어 있는 R 자체를 파고들었다. 회전행렬은 성분이 9개지만 자유도는 3이다. 나머지 6개는 직교 조건 RᵀR = I가 잡아먹는다.
그 3이 곧 축 2 + 각 1이다. 축은 단위벡터라 성분 3개 중 크기 제약으로 하나가 묶여서 2, 회전각이 1. 축-각과 쿼터니언이 회전행렬과 같은 정보를 담으면서도 성분이 적은 이유가 여기 있다.
| 표현 | 성분 수 | 자유도 | 쓰는 자리 |
|---|---|---|---|
회전행렬 R | 9 | 3 | 좌표 변환 계산 |
축-각 (k, θ) | 4 | 3 | 사람이 읽고 쓰기 |
쿼터니언 q | 4 | 3 | 보간·누적 |
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으로 나누게 된다.
| 상황 | 문제 | 내용 |
|---|---|---|
θ = 0 | sinθ = 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번에서 본 정밀도 문제가 여기서 다시 튀어나온 셈이다.
정리
- 회전행렬은 성분 9개지만 자유도는 3이고, 그 3이 곧 축(2) + 각(1)이다. 축-각과 쿼터니언이 같은 정보를 더 적은 성분으로 담는 이유다.
- 왕복 변환이 정확히 안 맞는 건 알고리즘이 아니라 부동소수점 정밀도다. float64에서
10⁻¹⁵수준이고,==대신 허용 오차로 비교해야 한다. - 쿼터니언은 반각
θ/2를 쓴다. 그래서q와−q가 같은 회전이고, 자세 비교는|q₁·q₂| ≈ 1로 한다. - 회전축은 고유값 1의 고유벡터다. 회전해도 안 변하는 벡터가 축이라는 정의를 그대로 식으로 옮긴 것이다.
- 회전각은 trace에서 나온다. 고유값의 합
1 + 2cosθ가 대각합과 같으니θ = arccos((tr(R)−1)/2). R − Rᵀ는 로드리게스 공식에서 대칭 항을 지우고 반대칭 항만 남긴다. 그래서 축 성분이 바로 읽히고, 고유값 분해를 돌릴 필요가 없다.θ = 0과θ = π에서는sinθ = 0이라 축 정보가 반대칭 부분에서 완전히 사라진다. 이때만(R − I)k = 0을 직접 푼다.(k, θ)와(−k, −θ)는 같은 회전이다. 축-각 표현은 유일하지 않으므로 부호 규약을 따로 정해야 비교가 된다.