TT Lab
요금제

한국어

시작하기
블로그

블로그

좌표계와 변환: 회전 행렬, 동차 변환, 오일러 각과 쿼터니언, 카메라에서 탁구대로

상태: 초안 (2026-10-18), 실측. 숫자는 노트북(Apple M1 Max, macOS 26.5)의 Python 3.9.6·numpy 2.0.2 로 직접 계산했습니다. 시간은 7회 반복의 중앙값(첫 회 버림)입니다. 원자료와 코드: 작업 디렉터리 measure/robot-arm-basics/의 m02_frames.py, m02b_float32.py, out/m02_frames.json. 실제 카메라로 보정한 실험은 하지 않았습니다(미실측). 참고 자료는 2026-10-18 에 열람했습니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 확인하지 못한 것은 「미확인」으로 표시합니다.

목차

1. 직관: 같은 공, 네 가지 설명

공 하나가 떠 있습니다. 카메라는 「내 렌즈 앞 3.1 m, 약간 왼쪽 아래」라고 말합니다. 탁구대 기준으로는 「가운데에서 로봇 쪽으로 0.8 m, 높이 25 cm」입니다. 로봇 바닥 기준으로는 「앞으로 0.9 m, 위로 0.4 m」이고, 라켓 기준으로는 「면에서 3 cm 앞」입니다. 모두 같은 점입니다.

로봇 소프트웨어의 버그 가운데 상당수는 이 네 설명을 섞어 쓰는 데서 나옵니다. 그래서 좌표계(frame)마다 이름을 붙이고, 점을 옮길 때는 반드시 변환을 거치게 만듭니다. 이 시리즈는 다음 네 이름을 씁니다.

이름 원점과 축 누가 정하나
{C} 카메라 렌즈 중심, z 가 보는 방향 카메라 설치
{T} 탁구대 윗면 가운데, x 폭·y 길이·z 위 ITTF 규격과 DeepMind 데이터셋 관례
{B} 로봇 바닥 첫 관절 축 위 로봇 설치
{E} 라켓 라켓 면 중심, z 가 면의 법선 정기구학(3편)

2. 그림: 좌표계 사슬

좌표계 사슬: 카메라가 본 공을 라켓 좌표로 카메라 {C} 공 위치를 잼 탁구대 {T} 원점: 윗면 가운데 로봇 바닥 {B} 팔이 서 있는 곳 라켓 {E} 정기구학 결과 보정으로 한 번 구함 설치할 때 잼 관절각 q 로 매번 동차 변환 T = [ R p ; 0 0 0 1 ] (4×4) 탁구대 좌표의 공 = ᵀT_C · (카메라 좌표의 공) 바닥 좌표의 공 = ᴮT_T · ᵀT_C · p_C 사슬은 오른쪽부터 곱하고, 거꾸로 가려면 역변환.
그림: 카메라에서 탁구대, 로봇 바닥, 라켓으로 이어지는 변환 사슬

3. 계산

3.1 회전 행렬

회전 행렬(rotation matrix) R 은 3×3 이고, 열 하나하나가 회전된 좌표계의 축을 원래 좌표계에서 본 단위 벡터입니다. 그래서 항상 다음 두 성질을 만족합니다.

Rᵀ R = I        (열들이 서로 수직이고 길이가 1)
det R = +1      (오른손 좌표계를 유지, -1 이면 거울상)

z 축을 중심으로 θ 만큼 돌리는 회전은 다음과 같습니다. 회전 두 개를 이어 붙이면 행렬을 곱하고, 되돌리려면 전치(transpose)만 하면 됩니다. 역행렬을 따로 계산할 필요가 없습니다.

Rz(θ) = [ cos θ  −sin θ  0 ]
        [ sin θ   cos θ  0 ]
        [   0       0    1 ]

3.2 동차 변환

회전과 이동을 한 번에 다루려고 4×4 행렬을 씁니다. 이를 동차 변환(homogeneous transformation)이라 부릅니다. 점에는 네 번째 성분으로 1 을 붙입니다.

T = [ R  p ]        점 변환:   [x']   = T [x]
    [ 0  1 ]                   [1 ]       [1]

역변환:  T⁻¹ = [ Rᵀ  −Rᵀ p ]
               [ 0     1   ]

역변환도 일반 역행렬이 아니라 위 식으로 계산합니다. 수치적으로 더 안전하고 빠릅니다.

3.3 오일러 각

사람이 읽기 쉬운 자세 표현은 오일러 각(Euler angles)입니다. 로봇과 항공에서 흔한 ZYX 순서(요 yaw, 피치 pitch, 롤 roll)는 R = Rz(요)·Ry(피치)·Rx(롤) 입니다. 회전 행렬에서 거꾸로 뽑아낼 때는 다음 식을 씁니다(r_ij 는 i행 j열).

피치 = asin(−r31)
요   = atan2(r21, r11)
롤   = atan2(r32, r33)

피치가 ±90° 가 되면 r11, r21, r32, r33 이 모두 0 이 되어 요와 롤을 따로 정할 수 없습니다. 두 회전축이 겹쳐서 자유도 하나가 사라진 것이고, 이를 짐벌 잠금(gimbal lock)이라 부릅니다. 정확히 90° 일 때만 문제가 되는 것이 아니라 근처에서 잡음이 크게 부풀어 오른다는 점이 실제로 더 중요합니다. 4.3 절에서 잽니다.

3.4 쿼터니언

단위 쿼터니언(unit quaternion) q = (w, x, y, z) 는 회전축 k 와 회전각 θ 로 q = (cos θ/2, k·sin θ/2) 입니다. 숫자 네 개로 회전을 나타내고, 짐벌 잠금이 없으며, 두 회전 사이를 부드럽게 보간하기 쉽습니다. 대신 q 와 −q 가 같은 회전이라는 점(이중 덮개)을 늘 기억해야 합니다. 자세 오차를 계산할 때 부호를 맞추지 않으면 가까운 두 자세가 360° 가까이 다르다고 나옵니다.

이 시리즈의 코드에서 쿼터니언 곱은 순수 파이썬 함수 하나입니다.

def qmul(a, b):
    aw, ax, ay, az = a
    bw, bx, by, bz = b
    return (aw*bw - ax*bx - ay*by - az*bz,
            aw*bx + ax*bw + ay*bz - az*by,
            aw*by - ax*bz + ay*bw + az*bx,
            aw*bz + ax*by - ay*bx + az*bw)

3.5 카메라에서 탁구대로: 표식점으로 자세 구하기

카메라 두 대로 삼각측량을 하면 카메라 좌표 {C} 의 3차원 점을 얻습니다. 탁구대 모서리처럼 탁구대 좌표 {T} 를 아는 점 N 개를 카메라로도 재면, 두 점 집합을 가장 잘 겹치는 R 과 p 를 구할 수 있습니다. 두 집합의 중심을 빼고 공분산 행렬을 특이값 분해(SVD)하는 방법이고, 흔히 Kabsch 알고리즘이라 부릅니다.

def kabsch(P, Q):                       # Q ≈ R·P + t 가 되는 R, t (P, Q 는 N×3)
    pc, qc = P.mean(0), Q.mean(0)
    U, _, Vt = np.linalg.svd((P - pc).T @ (Q - qc))
    d = np.sign(np.linalg.det(Vt.T @ U.T))      # 거울상(det = -1) 방지
    R = Vt.T @ np.diag([1, 1, d]) @ U.T
    return R, qc - R @ pc

d 로 부호를 고치는 줄이 빠지면 점들이 거의 한 평면에 있을 때(탁구대 윗면이 그렇습니다) 거울상 행렬이 나올 수 있습니다.

4. 실측

4.1 회전을 오래 곱하면 직교성이 무너지는가

자이로 적분처럼 작은 회전(0.06~0.6°)을 계속 곱해 나가면서 RᵀR 이 단위 행렬에서 얼마나 벗어나는지 쟀습니다. 같은 회전열을 쿼터니언으로도 곱했습니다.

곱한 횟수 float64 행렬 ‖RᵀR − I‖ float64 쿼터니언 |‖q‖ − 1| float32 행렬 float32 쿼터니언
1,000 - - 7.8×10⁻⁶ 2.8×10⁻⁶
10,000 - - 2.9×10⁻⁵ 5.6×10⁻⁶
100,000 4.7×10⁻¹³ 2.5×10⁻¹⁴ 2.6×10⁻⁴ 8.6×10⁻⁵
1,000,000 4.4×10⁻¹² 3.1×10⁻¹⁴ (재지 않음) (재지 않음)

float64 행렬을 1,000번마다 SVD 로 가장 가까운 회전 행렬에 되돌리면 백만 번 뒤에도 1.2×10⁻¹⁵ 였습니다.

해석은 둘입니다. 첫째, PC 의 float64 에서는 표류를 걱정할 일이 거의 없습니다. 백만 번은 1 kHz 제어로 17분입니다. 둘째, float32 를 쓰는 마이크로컨트롤러에서는 이야기가 다릅니다. 10만 번(1 kHz 로 100초)에 2.6×10⁻⁴ 은 축이 약 0.015° 비틀린 것과 비슷한 크기이고 계속 자랍니다. 모터 제어기나 IMU 펌웨어처럼 float32 로 회전을 누적하는 코드는 주기적으로 다시 정규화해야 합니다. 쿼터니언은 길이 하나만 1 로 나누면 되므로 이 일이 행렬보다 쉽습니다.

4.2 계산 시간

연산 한 번(µs)
numpy 3×3 행렬 곱 0.68
numpy 4×4 행렬 곱 0.66
numpy 3×3 행렬 × 벡터 0.60
순수 파이썬 쿼터니언 곱 0.55
순수 파이썬 쿼터니언으로 벡터 회전 1.33

numpy 는 3×3 이든 4×4 든 0.6~0.7 µs 로 거의 같습니다. 이 크기에서는 곱셈 자체보다 함수 호출 비용이 대부분이기 때문입니다. 그러니 파이썬에서 표현을 고를 때 속도는 기준이 되지 않습니다. 짐벌 잠금, 보간, 정규화의 쉬움 같은 성질로 고르면 됩니다.

4.3 짐벌 잠금 근처의 잡음 증폭

ZYX 자세를 만들고 0.0057°(10⁻⁴ rad) 크기의 작은 무작위 회전을 잡음으로 더한 뒤, 3.3 절의 식으로 각을 다시 뽑았습니다. 피치를 90° 에 점점 가깝게 하면서 각 2,000번씩 시도했습니다.

피치가 90°에 가까울수록 오일러 각이 흔들린다 30 10 3 1 0.3 0.1 0.03 0.01 0.1 1 10 피치와 90° 사이 간격(도, 로그) 요·롤 오차(도, 로그) 넣은 자세 잡음 0.0057° 중앙값(실선) 95백분위(점선) ZYX 오일러 각, 각 점 2,000번 시도. 잡음은 그대로인데 뽑아낸 각의 오차가 커진다.
그림: 피치가 90도에 가까워질수록 요와 롤 각의 오차가 커지는 모습
피치와 90° 사이 요·롤 오차 중앙값 95백분위
30° 0.007° 0.011°
10° 0.017° 0.032°
3° 0.058° 0.10°
1° 0.17° 0.31°
0.3° 0.54° 1.0°
0.1° 1.6° 3.1°
0.03° 5.4° 10.5°

기울기가 로그 축에서 거의 1 이므로 오차는 대략 「잡음 ÷ 90°까지 남은 각(라디안)」으로 자랍니다. 탁구 라켓은 면을 거의 수직으로 세웠다 눕혔다 하므로 피치 90° 근처를 자주 지납니다. 라켓 자세를 오일러 각으로 저장하거나 필터링하지 말고, 내부 표현은 회전 행렬이나 쿼터니언으로 두고 사람에게 보여 줄 때만 오일러 각으로 바꾸는 편이 안전합니다.

4.4 표식점으로 카메라 자세 구하기

카메라를 로봇 쪽 끝줄 뒤 2 m, 높이 1.6 m 에 두고 탁구대 가운데를 내려다보게 했습니다. 탁구대 네 모서리, 네트 기둥 위 두 점, 가운데 선 양 끝을 기본 표식으로 쓰고, 더 많은 점이 필요하면 탁구대 위 임의의 점을 더했습니다. 카메라 좌표의 점마다 가우시안 잡음 σ 를 더하고 3.5 절의 함수로 자세를 구했습니다. 각 조건 500번 시도의 중앙값입니다.

점 수 σ 회전 오차 카메라 위치 오차 반대편 끝줄 점 오차(중앙/95%)
4 2 mm 0.073° 3.7 mm 1.9 / 3.6 mm
4 5 mm 0.20° 9.4 mm 4.9 / 8.5 mm
8 2 mm 0.061° 2.9 mm 1.4 / 2.6 mm
8 5 mm 0.15° 7.2 mm 3.6 / 6.9 mm
8 10 mm 0.31° 15.1 mm 7.1 / 13.7 mm
32 5 mm 0.10° 4.7 mm 2.1 / 3.8 mm

5. 한계

6. 정리

티티 한마디. 같은 공도 누가 보느냐에 따라 좌표가 달라요. 라켓을 세웠다 눕혔다 할 때는 오일러 각 말고 쿼터니언을 꼭 쥐고 있어야 해요!

참고

모든 자료는 2026-10-18 에 열람했습니다.

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다