659.56 쿼터니언과 오일러 각 간 변환

1. 개요

쿼터니언과 오일러 각은 3차원 회전을 표현하는 두 가지 주요 방식이며, 로봇 시스템에서 두 표현 간의 변환은 빈번하게 수행된다. ROS2에서는 내부적으로 쿼터니언을 사용하지만, 사용자 인터페이스와 설정 파일에서는 오일러 각(RPY)을 사용하므로, 양방향 변환의 정확한 구현이 필수적이다. 본 절에서는 RPY 규약을 기준으로 쿼터니언과 오일러 각 간의 변환 공식, 수치적 안정성 고려사항, 그리고 ROS2 환경에서의 구현 방법을 체계적으로 기술한다.

2. 오일러 각(RPY)에서 쿼터니언으로의 변환

2.1 변환 공식 유도

RPY(외재적 XYZ) 규약에서 Roll=\phi, Pitch=\theta, Yaw=\psi에 대한 합성 회전은:

R = R_z(\psi) R_y(\theta) R_x(\phi)

각 기본 회전에 대응하는 쿼터니언을 구한 후 곱셈으로 합성한다.

x축 \phi 회전의 쿼터니언:

\mathbf{q}_x = \left(\cos\frac{\phi}{2}, \; \sin\frac{\phi}{2}, \; 0, \; 0\right) \quad \text{(w, x, y, z 순서)}

y축 \theta 회전의 쿼터니언:

\mathbf{q}_y = \left(\cos\frac{\theta}{2}, \; 0, \; \sin\frac{\theta}{2}, \; 0\right)

z축 \psi 회전의 쿼터니언:

\mathbf{q}_z = \left(\cos\frac{\psi}{2}, \; 0, \; 0, \; \sin\frac{\psi}{2}\right)

합성 쿼터니언은 곱셈 순서에 유의하여:

\mathbf{q} = \mathbf{q}_z \cdot \mathbf{q}_y \cdot \mathbf{q}_x

2.2 전개된 직접 변환 공식

약기법 c_\alpha = \cos(\alpha/2), s_\alpha = \sin(\alpha/2)을 사용하면, 합성 쿼터니언의 각 성분은:

w = c_\phi c_\theta c_\psi + s_\phi s_\theta s_\psi

x = s_\phi c_\theta c_\psi - c_\phi s_\theta s_\psi

y = c_\phi s_\theta c_\psi + s_\phi c_\theta s_\psi

z = c_\phi c_\theta s_\psi - s_\phi s_\theta c_\psi

여기서 (w, x, y, z)는 해밀턴 순서이며, ROS2의 (x, y, z, w) 순서로 변환하여 사용한다.

2.3 구현

#include <tf2/LinearMath/Quaternion.h>
#include <cmath>

// tf2 라이브러리의 내장 메서드 사용
tf2::Quaternion rpy_to_quaternion(double roll, double pitch, double yaw) {
    tf2::Quaternion q;
    q.setRPY(roll, pitch, yaw);
    return q;
}

// 직접 구현
geometry_msgs::msg::Quaternion rpy_to_quaternion_manual(
    double phi, double theta, double psi) 
{
    double cp = std::cos(phi / 2.0);
    double sp = std::sin(phi / 2.0);
    double ct = std::cos(theta / 2.0);
    double st = std::sin(theta / 2.0);
    double cy = std::cos(psi / 2.0);
    double sy = std::sin(psi / 2.0);
    
    geometry_msgs::msg::Quaternion q;
    q.w = cp * ct * cy + sp * st * sy;
    q.x = sp * ct * cy - cp * st * sy;
    q.y = cp * st * cy + sp * ct * sy;
    q.z = cp * ct * sy - sp * st * cy;
    
    return q;
}
import numpy as np
from tf_transformations import quaternion_from_euler

# tf_transformations 사용
q = quaternion_from_euler(roll, pitch, yaw)  # 반환: [x, y, z, w]

# 직접 구현
def rpy_to_quaternion(roll, pitch, yaw):
    cp = np.cos(roll / 2)
    sp = np.sin(roll / 2)
    ct = np.cos(pitch / 2)
    st = np.sin(pitch / 2)
    cy = np.cos(yaw / 2)
    sy = np.sin(yaw / 2)
    
    w = cp * ct * cy + sp * st * sy
    x = sp * ct * cy - cp * st * sy
    y = cp * st * cy + sp * ct * sy
    z = cp * ct * sy - sp * st * cy
    
    return np.array([x, y, z, w])  # ROS2 순서
# SciPy 사용
from scipy.spatial.transform import Rotation

r = Rotation.from_euler('xyz', [roll, pitch, yaw])
q = r.as_quat()  # [x, y, z, w]

3. 쿼터니언에서 오일러 각(RPY)으로의 변환

3.1 변환 공식 유도

쿼터니언 \mathbf{q} = (w, x, y, z)에 대응하는 회전 행렬의 원소를 쿼터니언 성분으로 표현하면:

R = \begin{pmatrix} 1 - 2(y^2 + z^2) & 2(xy - wz) & 2(xz + wy) \\ 2(xy + wz) & 1 - 2(x^2 + z^2) & 2(yz - wx) \\ 2(xz - wy) & 2(yz + wx) & 1 - 2(x^2 + y^2) \end{pmatrix}

RPY 합성 행렬의 주요 원소와 대조하면:

r_{31} = -\sin\theta = 2(xz - wy)

r_{32} = \cos\theta \sin\phi = 2(yz + wx)

r_{33} = \cos\theta \cos\phi = 1 - 2(x^2 + y^2)

r_{21} = \sin\psi \cos\theta = 2(xy + wz)

r_{11} = \cos\psi \cos\theta = 1 - 2(y^2 + z^2)

이로부터 RPY 각도를 추출한다:

3.2 표준 경우 (\cos\theta \neq 0)

\theta = -\arcsin(2(xz - wy))

\phi = \text{atan2}(2(yz + wx), \; 1 - 2(x^2 + y^2))

\psi = \text{atan2}(2(xy + wz), \; 1 - 2(y^2 + z^2))

3.3 짐벌 락 경우 (\theta = \pm\pi/2)

2(xz - wy) \approx \pm 1인 경우 별도 처리가 필요하다:

\theta = +\pi/2인 경우 (\sin\theta = 1):

\phi + \psi = 2 \cdot \text{atan2}(x, w)

관례적으로 \phi = 0으로 고정하고 \psi = 2 \cdot \text{atan2}(x, w)로 설정한다.

\theta = -\pi/2인 경우 (\sin\theta = -1):

\phi - \psi = 2 \cdot \text{atan2}(x, w)

관례적으로 \phi = 0으로 고정하고 \psi = -2 \cdot \text{atan2}(x, w)로 설정한다.

3.4 구현

#include <tf2/LinearMath/Quaternion.h>
#include <tf2/LinearMath/Matrix3x3.h>

// tf2 라이브러리의 내장 메서드 사용
void quaternion_to_rpy(const tf2::Quaternion& q, 
                       double& roll, double& pitch, double& yaw) {
    tf2::Matrix3x3 m(q);
    m.getRPY(roll, pitch, yaw);
}

// 직접 구현 (짐벌 락 처리 포함)
void quaternion_to_rpy_manual(double qx, double qy, double qz, double qw,
                               double& roll, double& pitch, double& yaw) 
{
    // sin(pitch) 계산
    double sin_pitch = 2.0 * (qx * qz - qw * qy);
    
    // 짐벌 락 검사
    if (std::abs(sin_pitch) >= 1.0 - 1e-6) {
        // 짐벌 락 근방
        pitch = std::copysign(M_PI / 2.0, -sin_pitch);
        roll = 0.0;
        yaw = 2.0 * std::atan2(qx, qw);
        if (sin_pitch > 0) yaw = -yaw;
    } else {
        pitch = std::asin(-sin_pitch);
        roll = std::atan2(2.0 * (qy * qz + qw * qx), 
                          1.0 - 2.0 * (qx * qx + qy * qy));
        yaw = std::atan2(2.0 * (qx * qy + qw * qz), 
                         1.0 - 2.0 * (qy * qy + qz * qz));
    }
}
from tf_transformations import euler_from_quaternion
import numpy as np

# tf_transformations 사용
(roll, pitch, yaw) = euler_from_quaternion([qx, qy, qz, qw])

# 직접 구현
def quaternion_to_rpy(qx, qy, qz, qw):
    sin_pitch = 2.0 * (qx * qz - qw * qy)
    
    if abs(sin_pitch) >= 1.0 - 1e-6:
        pitch = np.copysign(np.pi / 2, -sin_pitch)
        roll = 0.0
        yaw = 2.0 * np.arctan2(qx, qw)
        if sin_pitch > 0:
            yaw = -yaw
    else:
        pitch = np.arcsin(-sin_pitch)
        roll = np.arctan2(2*(qy*qz + qw*qx), 1 - 2*(qx*qx + qy*qy))
        yaw = np.arctan2(2*(qx*qy + qw*qz), 1 - 2*(qy*qy + qz*qz))
    
    return roll, pitch, yaw
# SciPy 사용
from scipy.spatial.transform import Rotation

r = Rotation.from_quat([qx, qy, qz, qw])  # [x, y, z, w]
roll, pitch, yaw = r.as_euler('xyz')

4. 수치적 안정성 고려사항

4.1 atan2 함수의 활용

atan2(y, x) 함수는 atan(y/x) 대비 다음의 장점을 가진다:

  1. 사분면 구분: 반환값의 범위가 (-\pi, \pi]로, 4개 사분면을 모두 구분한다.
  2. 0으로 나누기 방지: x = 0인 경우에도 정의된다.
  3. 정밀도: 인자가 작은 경우에도 수치적으로 안정적이다.

4.2 arcsin의 정의역 클리핑

arcsin 함수의 정의역은 [-1, 1]이며, 부동소수점 오차로 인해 인자가 이 범위를 미세하게 벗어날 수 있다. 반드시 클리핑을 적용해야 한다:

double sin_pitch = std::clamp(2.0 * (qx * qz - qw * qy), -1.0, 1.0);
double pitch = std::asin(-sin_pitch);

클리핑 없이 asin|x| > 1인 값이 전달되면 NaN이 반환되어, 후속 연산 전체가 오염된다.

4.3 쿼터니언 정규화

변환 전에 입력 쿼터니언이 단위 쿼터니언인지 확인하고, 필요시 정규화를 수행해야 한다. 비단위 쿼터니언에 대한 변환 공식은 정확한 결과를 보장하지 않는다:

void safe_quaternion_to_rpy(
    const geometry_msgs::msg::Quaternion& q_msg,
    double& roll, double& pitch, double& yaw) 
{
    // 정규화
    double norm = std::sqrt(q_msg.x*q_msg.x + q_msg.y*q_msg.y + 
                            q_msg.z*q_msg.z + q_msg.w*q_msg.w);
    
    if (norm < 1e-10) {
        roll = pitch = yaw = 0.0;
        return;
    }
    
    double qx = q_msg.x / norm;
    double qy = q_msg.y / norm;
    double qz = q_msg.z / norm;
    double qw = q_msg.w / norm;
    
    quaternion_to_rpy_manual(qx, qy, qz, qw, roll, pitch, yaw);
}

5. 변환의 검증

5.1 왕복 변환 검증

RPY → 쿼터니언 → RPY 왕복 변환의 결과가 원래 RPY와 일치하는지 검증한다:

import numpy as np
from tf_transformations import quaternion_from_euler, euler_from_quaternion

def test_roundtrip():
    test_cases = [
        (0, 0, 0),
        (0.1, 0.2, 0.3),
        (np.pi/4, np.pi/6, np.pi/3),
        (0, np.pi/2 - 0.01, 0),  # 짐벌 락 근방
        (-np.pi, 0, np.pi),       # 경계값
    ]
    
    for roll, pitch, yaw in test_cases:
        q = quaternion_from_euler(roll, pitch, yaw)
        r2, p2, y2 = euler_from_quaternion(q)
        
        # 각도 차이 (주기적 동치 고려)
        dr = np.abs(np.arctan2(np.sin(roll - r2), np.cos(roll - r2)))
        dp = np.abs(np.arctan2(np.sin(pitch - p2), np.cos(pitch - p2)))
        dy = np.abs(np.arctan2(np.sin(yaw - y2), np.cos(yaw - y2)))
        
        assert dr < 1e-10 and dp < 1e-10 and dy < 1e-10, \
            f"왕복 변환 실패: ({roll}, {pitch}, {yaw})"

주의할 점은, 짐벌 락 근방에서는 왕복 변환 후에도 roll과 yaw의 개별 값이 달라질 수 있지만, 합성 회전 자체는 동일하다는 것이다. 이 경우 쿼터니언 수준에서의 동치성을 검증하는 것이 적절하다.

5.2 기본 회전의 검증

알려진 단축 회전에 대해 변환 결과를 검증한다:

입력 RPY (도)기대 쿼터니언 (x, y, z, w)
(0, 0, 0)(0, 0, 0, 1)
(90°, 0, 0)(0.7071, 0, 0, 0.7071)
(0, 90°, 0)(0, 0.7071, 0, 0.7071)
(0, 0, 90°)(0, 0, 0.7071, 0.7071)
(0, 0, 180°)(0, 0, 1, 0)
(90°, 0, 90°)(0.5, 0.5, 0.5, 0.5)

6. 다른 오일러 각 규약에 대한 변환

6.1 일반화된 변환

tf_transformations 라이브러리는 12가지 회전 순서를 모두 지원한다:

from tf_transformations import quaternion_from_euler, euler_from_quaternion

# 외재적 XYZ (RPY) — ROS2 기본
q = quaternion_from_euler(roll, pitch, yaw, axes='sxyz')
(roll, pitch, yaw) = euler_from_quaternion(q, axes='sxyz')

# 내재적 ZYX (YPR)
q = quaternion_from_euler(yaw, pitch, roll, axes='rzyx')
(yaw, pitch, roll) = euler_from_quaternion(q, axes='rzyx')

# 내재적 ZXZ (고전역학)
q = quaternion_from_euler(alpha, beta, gamma, axes='rzxz')
(alpha, beta, gamma) = euler_from_quaternion(q, axes='rzxz')
# SciPy의 일반화된 변환
from scipy.spatial.transform import Rotation

# 다양한 규약 지원
r = Rotation.from_euler('xyz', [roll, pitch, yaw])   # 외재적 xyz
r = Rotation.from_euler('ZYX', [yaw, pitch, roll])    # 내재적 ZYX
r = Rotation.from_euler('ZXZ', [alpha, beta, gamma])  # 내재적 ZXZ

# 쿼터니언으로 변환
q = r.as_quat()  # [x, y, z, w]

# 다른 규약으로 변환
euler_zyz = r.as_euler('ZYZ')
euler_ypr = r.as_euler('ZYX')

6.2 규약 간 변환 유틸리티

다른 규약으로 표현된 오일러 각을 RPY로 변환하려면 쿼터니언을 중간 표현으로 사용한다:

def convert_euler_convention(angles, from_axes, to_axes):
    """오일러 각 규약 간 변환 (쿼터니언 경유)"""
    r = Rotation.from_euler(from_axes, angles)
    return r.as_euler(to_axes)

# ZYZ → XYZ(RPY) 변환
angles_zyz = [1.0, 0.5, 0.3]
angles_rpy = convert_euler_convention(angles_zyz, 'ZYZ', 'xyz')

7. 실용적 활용 패턴

7.1 TF2 변환 발행에서의 사용

#include <tf2_ros/transform_broadcaster.h>
#include <tf2/LinearMath/Quaternion.h>

void broadcast_transform(
    std::shared_ptr<tf2_ros::TransformBroadcaster> br,
    const rclcpp::Time& stamp,
    const std::string& parent, const std::string& child,
    double x, double y, double z,
    double roll, double pitch, double yaw)
{
    geometry_msgs::msg::TransformStamped t;
    t.header.stamp = stamp;
    t.header.frame_id = parent;
    t.child_frame_id = child;
    
    t.transform.translation.x = x;
    t.transform.translation.y = y;
    t.transform.translation.z = z;
    
    tf2::Quaternion q;
    q.setRPY(roll, pitch, yaw);
    q.normalize();
    
    t.transform.rotation.x = q.x();
    t.transform.rotation.y = q.y();
    t.transform.rotation.z = q.z();
    t.transform.rotation.w = q.w();
    
    br->sendTransform(t);
}

7.2 자세 로깅에서의 사용

import rclpy
from rclpy.node import Node
from geometry_msgs.msg import PoseStamped
from tf_transformations import euler_from_quaternion

class PoseLogger(Node):
    def __init__(self):
        super().__init__('pose_logger')
        self.sub = self.create_subscription(
            PoseStamped, '/robot/pose', self.callback, 10)
    
    def callback(self, msg):
        q = msg.pose.orientation
        roll, pitch, yaw = euler_from_quaternion([q.x, q.y, q.z, q.w])
        
        self.get_logger().info(
            f'위치: ({msg.pose.position.x:.3f}, '
            f'{msg.pose.position.y:.3f}, '
            f'{msg.pose.position.z:.3f}), '
            f'자세: R={np.degrees(roll):.1f}° '
            f'P={np.degrees(pitch):.1f}° '
            f'Y={np.degrees(yaw):.1f}°')

8. 요약

쿼터니언과 오일러 각(RPY) 간의 변환은 ROS2 시스템에서 내부 표현(쿼터니언)과 사용자 인터페이스(RPY) 사이의 교량 역할을 한다. RPY에서 쿼터니언으로의 변환은 세 기본 회전의 반각 삼각함수를 조합하는 폐쇄형 공식으로 구현되며, 역변환은 회전 행렬의 원소를 쿼터니언 성분으로 표현한 후 atan2asin을 사용하여 각도를 추출한다. 짐벌 락 근방에서의 별도 처리, arcsin의 정의역 클리핑, 쿼터니언 정규화 등 수치적 안정성을 위한 세심한 처리가 필수적이다.

9. 참고 문헌

  • Diebel, J. (2006). “Representing Attitude: Euler Angles, Unit Quaternions, and Rotation Vectors.” Stanford University Technical Report.
  • Slabaugh, G. G. (1999). “Computing Euler Angles from a Rotation Matrix.” City University London.
  • Kuipers, J. B. (1999). Quaternions and Rotation Sequences. Princeton University Press.
  • Open Robotics. “tf2 — ROS 2 Documentation.” https://docs.ros.org/en/rolling/Concepts/Intermediate/About-Tf2.html
  • SciPy Documentation. “scipy.spatial.transform.Rotation.” https://docs.scipy.org/doc/scipy/