0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NumPyで手を動かして学ぶ「回転の表現」:オイラー角・回転行列・クォータニオンの違いを理解

0
Posted at

はじめに

3Dの「姿勢」(=物体がどっちを向いているか)を表す方法は、実は1つではありません。代表的なものだけで、回転行列・オイラー角・軸-角度・クォータニオンの4つがあります。

この記事を読むと、こうなります

  • 4つの表現がそれぞれ何者で、なぜ複数あるのかが分かる
  • 有名な罠 「ジンバルロック」 が、なぜ起きるのかを図と実行結果で理解できる
  • NumPyで全部を手実装して、相互変換まで動かせる

数式は最小限に、手を動かして納得することを優先します。この記事のコードは numpyscipy(答え合わせ用)だけで動きます。

背景:なぜ「回転の表現」でつまずくのか

平行移動(位置をずらすこと)は「x, y, z を足すだけ」で直感的です。ところが回転は、いざ扱うと急に難しく感じます。理由は3つあります。

  1. 表現が複数あり、変換が必要:ライブラリAはオイラー角、ライブラリBはクォータニオンを要求する、といったことが日常茶飯事
  2. 回転は「順番」で結果が変わる:足し算と違い、回転は交換法則が成り立たない(先にX軸で回すか、先にY軸で回すかで最終姿勢が変わる)
  3. ジンバルロックという有名な罠:オイラー角では、ある角度で「自由度が1つ消える」現象が起きる

この記事は、この混乱を1本で解きほぐします。

想定読者:三角関数と行列の掛け算がなんとなく分かり、Pythonが読める方。クォータニオンは初めてでも大丈夫です。ロボット・3D・ゲーム・IMU(慣性計測センサ:加速度やジャイロで姿勢を測る部品)を触る人に特に役立ちます。

全体像:4つの表現マップ

まず地図を持ちましょう。下図(図1)のように、4つの表現は 「同じ回転」を別の言葉で言い換えているだけで、相互に変換できます。

図1:4つの姿勢表現の関係。どれも「同じ回転」を表し、回転行列を経由して相互変換できる。

座標系は右手系(親指=X, 人差し指=Y, 中指=Z)を使います。回転の向きは「軸の正方向を見て反時計回りが正」です(図2)。

coord_frame.png

図2:本記事で使う右手座標系(x × y = z)。

それぞれを一言でまとめると、下表のようになります。

下表の 「格納する数」 は、その表現をコンピュータに保存するのに使う数(成分)の個数のことです。ここで1つ大事な区別があります。回転そのものの 自由度(じゆうど=本当に自由に決められる向きの数)は、どの表現でも常に3で共通です。にもかかわらず、格納に使う数は表現ごとに違います(回転行列は9個と最も冗長で、余った6個ぶんは「正規直交」という制約でつじつまを合わせています)。

表現 格納する数 一言でいうと
回転行列 9 点を実際に回す計算の主役
オイラー角 3 人間が読み書きしやすい(罠あり)
軸-角度 4 「どの軸に何度」で幾何的に明快
クォータニオン 4 合成・補間に強く、ロックしない

表1:4つの表現の早わかり。「格納する数」は保存に使う成分の個数(自由度はどれも3で共通)。

では1つずつ見ていきます。

① 回転行列

一番の基本は回転行列(3×3の行列で回転を表す)です。ある点の座標ベクトルに左からこの行列を掛けると、回転後の座標が得られます。

まずは回転を絵で見てみましょう(図3)。点 (1, 0, 0) をZ軸まわりに90°回すと、ちょうど (0, 1, 0) に移ります。

coord_z_rotation.png

図3:右手座標系で点(1,0,0)をZ軸まわりに90°回すと(0,1,0)になる。

たとえばZ軸まわりに角度θ回す行列は次の形です(図4)。

R_z(\theta) =
\begin{pmatrix}
\cos\theta & -\sin\theta & 0 \\
\sin\theta & \cos\theta & 0 \\
0 & 0 & 1 \\
\end{pmatrix}

図4:Z軸まわりθ回転の回転行列。左上2×2が平面回転、Z成分は変わらない。

回転行列には 「正規直交」(各列が長さ1で互いに直交している)という性質があり、これが「形を変えず向きだけ変える」ことを保証しています。

NumPyでZ軸回転行列を作り、点 (1, 0, 0) を90°回すと (0, 1, 0) になることを確かめます。

import numpy as np


def rot_z(theta: float) -> np.ndarray:
    """Z軸まわりに角度theta[rad]回す回転行列を返す。"""
    c, s = np.cos(theta), np.sin(theta)
    return np.array([
        [c, -s, 0.0],
        [s,  c, 0.0],
        [0.0, 0.0, 1.0],
    ])


# 点(1,0,0)をZ軸まわり90°回すと(0,1,0)になる
# 行列とベクトルの積は np.dot() で計算する
print(np.dot(rot_z(np.radians(90.0)), np.array([1.0, 0.0, 0.0])))
# -> [0. 1. 0.]

同じ要領で、X軸・Y軸まわりの回転行列も作れます(次の節で使います)。

def rot_x(theta: float) -> np.ndarray:
    """X軸まわりに角度theta[rad]回す回転行列を返す。"""
    c, s = np.cos(theta), np.sin(theta)
    return np.array([
        [1.0, 0.0, 0.0],
        [0.0,  c,  -s],
        [0.0,  s,   c],
    ])


def rot_y(theta: float) -> np.ndarray:
    """Y軸まわりに角度theta[rad]回す回転行列を返す。"""
    c, s = np.cos(theta), np.sin(theta)
    return np.array([
        [ c, 0.0,  s],
        [0.0, 1.0, 0.0],
        [-s, 0.0,  c],
    ])
  • 長所:点を回す計算がそのまま掛け算。合成も行列の積。
  • 短所:9個の数で冗長。人間には読み取りにくい。

② オイラー角

オイラー角(3つの角度で姿勢を表す方法)は、飛行機の姿勢でおなじみの roll(ロール=機体軸まわりの傾き)/pitch(ピッチ=上下の傾き)/yaw(ヨー=左右の首振り) です。人間にとって一番直感的です(図5)。

euler_rpy.png

図5:飛行機で見る roll / pitch / yaw。人間には分かりやすい。

オイラー角は、各軸まわりの回転行列を順番に掛けて1つの回転行列に合成します。ここで大事なのが 「順番」 です。X→Y→Zで掛けるのと Z→Y→X では結果が変わります。だからライブラリごとに「回転順序(例:ZYX)」が決まっているのです。

ここでは、yaw → pitch → roll の順(ZYX順、ロボットで定番)で合成します。

def euler_zyx_to_matrix(roll: float, pitch: float, yaw: float) -> np.ndarray:
    """roll(X)→pitch(Y)→yaw(Z)の順で合成した回転行列を返す。"""
    # R = Rz(yaw)・Ry(pitch)・Rx(roll) を、np.dot() を2回重ねて計算する
    return np.dot(np.dot(rot_z(yaw), rot_y(pitch)), rot_x(roll))

SciPy(科学計算ライブラリ)の実装と一致するかで答え合わせをしておきます。

from scipy.spatial.transform import Rotation

roll, pitch, yaw = np.radians([30.0, 20.0, 45.0])
R_mine = euler_zyx_to_matrix(roll, pitch, yaw)
R_scipy = Rotation.from_euler("ZYX", [yaw, pitch, roll]).as_matrix()
print(np.max(np.abs(R_mine - R_scipy)))
# -> 2.220446049250313e-16 (ほぼ0=一致)
  • 長所:3つの数で直感的。人間の入力・表示に向く
  • 短所:順序依存でややこしい。そして次に見るジンバルロックという致命的な罠がある

ジンバルロック:自由度が1つ消える瞬間

オイラー角最大の弱点がジンバルロック(gimbal lock:3つの回転軸のうち2つが重なって、回せる方向が1つ減ってしまう現象)です。

pitch(真ん中の回転)を±90°にすると、1つ目の軸と3つ目の軸が同じ向きに揃ってしまい、本来3方向あったはずの自由度が2方向に減ります。「別々に回したはずが、同じ回転になってしまう」状態です。

これを数値で確かめてみましょう。roll・pitch・yaw を1つずつ微小に動かしたときの姿勢変化を3本のベクトルに並べ、その3×3行列の行列式(=3軸が独立に姿勢を変えられる「体積」)を測ります。値が 1 なら3軸フルに独立、0 ならジンバルロックです。

def dof_volume(pitch: float) -> float:
    """pitchでの「3つの軸が独立に姿勢を変えられる度合い」を返す(1〜0)。"""
    eps = 1e-5
    base = euler_zyx_to_matrix(0.0, pitch, 0.0)
    cols = []
    for idx in range(3):  # 0=roll, 1=pitch, 2=yaw
        angles = [0.0, pitch, 0.0]
        angles[idx] += eps
        R_pert = euler_zyx_to_matrix(angles[0], angles[1], angles[2])
        # 微小回転 dR = R_pert・base^T を回転ベクトル化して列に並べる
        rotvec = Rotation.from_matrix(np.dot(R_pert, base.T)).as_rotvec()
        cols.append(rotvec / eps)
    return abs(np.linalg.det(np.column_stack(cols)))


for p in [0, 30, 60, 80, 89]:
    print(f"pitch={p:2d}° -> 独立性 {dof_volume(np.radians(p)):.3f}")
# pitch= 0° -> 独立性 1.000
# pitch=30° -> 独立性 0.866
# pitch=60° -> 独立性 0.500
# pitch=80° -> 独立性 0.174
# pitch=89° -> 独立性 0.017  ← ほぼロック

pitch を 0°→90° と動かすと、独立性が cos(pitch) のカーブでなめらかに 0 へ潰れていくのが分かります(下図)。

gimbal_lock.png

図6:pitchが90°に近づくほど3軸の独立性が0へ潰れていく(ジンバルロック)。

この「1自由度の消失」があるため、姿勢を内部で保持する用途にオイラー角は不向きとされます。これを避けるのが、次の軸-角度とクォータニオンです。

③ 軸-角度(回転ベクトル)

軸-角度表現(axis-angle:回転を「回転軸の向き」と「その軸まわりの角度」で表す)は、とても幾何的で分かりやすい方法です。「単位ベクトル n の軸まわりに、角度 θ 回す」——それだけです(図7)。

axis_angle.png

図7:軸-角度表現。「どの軸に・何度」の2要素だけ。

軸ベクトルに角度を掛け込んで1本の回転ベクトル(長さ=回転角、向き=回転軸)にまとめる書き方もよく使います。軸-角度から回転行列への変換はロドリゲスの回転公式(axis-angleを回転行列に直す有名な公式)で計算できます。

単位軸 $n=(n_x, n_y, n_z)$ から、その軸との外積($n \times v$)を行列で表した外積行列 $K$ を作ります。

K =
\begin{pmatrix}
0 & -n_z & n_y \\
n_z & 0 & -n_x \\
-n_y & n_x & 0
\end{pmatrix}

この $K$ を使うと、軸 $n$ まわりに角度 $\theta$ 回す回転行列は次のように書けます($I$ は3×3の単位行列)。これがロドリゲスの回転公式です。

R = I + \sin\theta K + (1 - \cos\theta) K^2

下のコードは、この式をそのまま np.eye(3) + np.sin(theta) * K + (1 - np.cos(theta)) * np.dot(K, K) として実装しています。

def axis_angle_to_matrix(axis: np.ndarray, theta: float) -> np.ndarray:
    """単位ベクトルaxisまわりに角度theta回す回転行列を返す(ロドリゲスの公式)。"""
    n = axis / np.linalg.norm(axis)  # 念のため正規化
    x, y, z = n
    K = np.array([          # 外積行列(np.dot(K, v) が n × v になる)
        [0.0, -z,   y],
        [z,   0.0, -x],
        [-y,   x,  0.0],
    ])
    return np.eye(3) + np.sin(theta) * K + (1.0 - np.cos(theta)) * np.dot(K, K)


# SciPyの回転ベクトル実装と一致(誤差 0.0)
axis, theta = np.array([1.0, 1.0, 0.0]), np.radians(60.0)
R_aa = axis_angle_to_matrix(axis, theta)
R_ref = Rotation.from_rotvec(theta * axis / np.linalg.norm(axis)).as_matrix()
print(np.max(np.abs(R_aa - R_ref)))  # -> 0.0
  • 長所:幾何的に明快。ジンバルロックしない
  • 短所:回転の合成(2つの回転を続ける)が素直にできない。そこで登場するのがクォータニオン

④ クォータニオン

いよいよ主役クォータニオン(quaternion:4つの数 (w, x, y, z) で回転を表す方法)です。「軸-角度を、合成しやすい形に詰め直したもの」と考えると腹落ちします。

軸 n=(nx, ny, nz)・角度θの回転は、次のように4つの数になります(図8)。角度が「θ/2」になっているのがポイントです。

w = cos(θ/2)
x = nx * sin(θ/2)
y = ny * sin(θ/2)
z = nz * sin(θ/2)

図8:軸-角度からクォータニオンへの変換。角度が半分になる。

def axis_angle_to_quat(axis: np.ndarray, theta: float) -> np.ndarray:
    """軸-角度からクォータニオン(w, x, y, z)を作る。"""
    n = axis / np.linalg.norm(axis)
    w = np.cos(theta / 2.0)
    xyz = n * np.sin(theta / 2.0)
    return np.array([w, xyz[0], xyz[1], xyz[2]])

すごいのは合成です。2つの回転を続けて行うとき、クォータニオン同士を掛け算(ハミルトン積)するだけで合成後の回転が出ます(図9)。

ハミルトン積(Hamilton product:クォータニオン専用の掛け算)は、普通の数の掛け算と違って順番を入れ替えると結果が変わります(回転が順序依存なのと同じ)。$q_1 \otimes q_2$ は「先に $q_2$ の回転を行い、そのあと $q_1$ の回転を行う」合成回転を表します。2つのクォータニオン $q_1=(w_1, x_1, y_1, z_1)$、$q_2=(w_2, x_2, y_2, z_2)$ に対して、成分ごとに次のように計算します。

q_1 \otimes q_2 =
\begin{pmatrix}
w_1 w_2 - x_1 x_2 - y_1 y_2 - z_1 z_2 \\
w_1 x_2 + x_1 w_2 + y_1 z_2 - z_1 y_2 \\
w_1 y_2 - x_1 z_2 + y_1 w_2 + z_1 x_2 \\
w_1 z_2 + x_1 y_2 - y_1 x_2 + z_1 w_2
\end{pmatrix}

図9:ハミルトン積。クォータニオンの合成は、この掛け算1回で済む。

この式は、下の quat_multiply の4行の戻り値とそのまま対応しています。

クォータニオン $q=(w, x, y, z)$(長さ1に正規化済み)は、次の式で回転行列に変換できます。「クォータニオンの4つの数」と「回転行列の9成分」は、こうして相互に行き来できます。

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}

なお、この記事のコードでは行列へ変換せず、次に示す「$q \otimes v \otimes q^{*}$」で点を直接回します(結果はこの行列を掛けるのと一致します)。ハミルトン積と、クォータニオンで点を回す関数を手実装します。

def quat_multiply(q1: np.ndarray, q2: np.ndarray) -> np.ndarray:
    """ハミルトン積 q1 ⊗ q2(q2の後にq1を行う合成)を返す。"""
    w1, x1, y1, z1 = q1
    w2, x2, y2, z2 = q2
    return np.array([
        w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2,
        w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2,
        w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2,
        w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2,
    ])


def quat_rotate(q: np.ndarray, v: np.ndarray) -> np.ndarray:
    """クォータニオンqで点vを回す(q ⊗ v ⊗ q*)。"""
    q_v = np.array([0.0, v[0], v[1], v[2]])          # 点を純虚クォータニオン化
    q_conj = np.array([q[0], -q[1], -q[2], -q[3]])   # 共役(逆回転)
    return quat_multiply(quat_multiply(q, q_v), q_conj)[1:]

きちんと回転行列と同じ結果になり、合成も行列積と一致します。

# クォータニオンで点を回す結果は回転行列と一致(誤差 ~1e-16)
q = axis_angle_to_quat(axis, theta)
v = np.array([1.0, 0.0, 0.0])
print(np.max(np.abs(quat_rotate(q, v) - np.dot(R_aa, v))))  # -> ~1e-16

# 合成:ハミルトン積 と 回転行列の積 が一致
qa = axis_angle_to_quat(np.array([0.0, 0.0, 1.0]), np.radians(90.0))
qb = axis_angle_to_quat(np.array([1.0, 0.0, 0.0]), np.radians(90.0))
Ra = axis_angle_to_matrix(np.array([0.0, 0.0, 1.0]), np.radians(90.0))
Rb = axis_angle_to_matrix(np.array([1.0, 0.0, 0.0]), np.radians(90.0))
print(quat_rotate(quat_multiply(qa, qb), v))  # -> [0. 1. 0.]
print(np.dot(np.dot(Ra, Rb), v))              # -> [0. 1. 0.]

なぜロックしないのかを直感で言うと、オイラー角のように「3本の軸に分解」せず、回転を4次元の1つの塊として持つから、途中で軸が潰れることがないのです。

  • 長所:合成が掛け算1回、補間が得意、ロックしない、数が4つで省メモリ
  • 短所:4つの数の意味が直感的でない(人間の目視・入力には向かない)

比較:いつ、どれを使う?

ここまでを1枚に畳みます(表2)。凡例は ◎=最も得意/○=得意/△=一応できるが不向き/✕=苦手・要変換 です。

観点 回転行列 オイラー角 軸-角度 クォータニオン
格納する数(自由度は全て3) 9 3 4 4
人間への直感性
点を回す計算 ✕(要変換) ✕(要変換)
回転の合成
なめらかな補間
ジンバルロック 起きない 起きる 起きない 起きない
主な用途 点の変換 入力・表示 幾何・微小回転 内部保持・合成

表2:4つの姿勢表現の使い分け早見表。

実務の勘所を一言でいうと——

  • 人間が入力・表示するところは オイラー角(分かりやすいから)
  • 内部で姿勢を保持・合成・補間するところは クォータニオン(ロックせず高速)
  • 実際に点を回す計算の瞬間は 回転行列(掛けるだけ)

つまり「入り口はオイラー角、心臓部はクォータニオン、実行は回転行列」というリレーで使い分けるのが定番です。

まとめ

  • 姿勢の表現は4つ(回転行列・オイラー角・軸-角度・クォータニオン)あり、同じ回転の言い換えで相互変換できる。
  • オイラー角は直感的だがジンバルロックする。pitchが90°に近づくと自由度が1つ消えるため、内部保持には不向き。
  • クォータニオンは合成が掛け算1回・ロックしない・補間に強いので、ロボットやゲームの心臓部で使われる。
  • 使い分けは「入力=オイラー角/保持=クォータニオン/実行=回転行列」。

次回は、ここで作ったクォータニオンを実際にロボットアームの手先姿勢に適用して、姿勢制御・姿勢の補間まで繋げる予定です。

おわりに

姿勢表現は、ロボットアームや経路生成を実装するときの「地味だけど必ず要る土台」です。関連する実装記事は下記から辿れます。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?