はじめに
3Dの「姿勢」(=物体がどっちを向いているか)を表す方法は、実は1つではありません。代表的なものだけで、回転行列・オイラー角・軸-角度・クォータニオンの4つがあります。
この記事を読むと、こうなります
- 4つの表現がそれぞれ何者で、なぜ複数あるのかが分かる
- 有名な罠 「ジンバルロック」 が、なぜ起きるのかを図と実行結果で理解できる
- NumPyで全部を手実装して、相互変換まで動かせる
数式は最小限に、手を動かして納得することを優先します。この記事のコードは numpy と scipy(答え合わせ用)だけで動きます。
背景:なぜ「回転の表現」でつまずくのか
平行移動(位置をずらすこと)は「x, y, z を足すだけ」で直感的です。ところが回転は、いざ扱うと急に難しく感じます。理由は3つあります。
- 表現が複数あり、変換が必要:ライブラリAはオイラー角、ライブラリBはクォータニオンを要求する、といったことが日常茶飯事
- 回転は「順番」で結果が変わる:足し算と違い、回転は交換法則が成り立たない(先にX軸で回すか、先にY軸で回すかで最終姿勢が変わる)
- ジンバルロックという有名な罠:オイラー角では、ある角度で「自由度が1つ消える」現象が起きる
この記事は、この混乱を1本で解きほぐします。
想定読者:三角関数と行列の掛け算がなんとなく分かり、Pythonが読める方。クォータニオンは初めてでも大丈夫です。ロボット・3D・ゲーム・IMU(慣性計測センサ:加速度やジャイロで姿勢を測る部品)を触る人に特に役立ちます。
全体像:4つの表現マップ
まず地図を持ちましょう。下図(図1)のように、4つの表現は 「同じ回転」を別の言葉で言い換えているだけで、相互に変換できます。
図1:4つの姿勢表現の関係。どれも「同じ回転」を表し、回転行列を経由して相互変換できる。
座標系は右手系(親指=X, 人差し指=Y, 中指=Z)を使います。回転の向きは「軸の正方向を見て反時計回りが正」です(図2)。
図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) に移ります。
図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)。
図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 へ潰れていくのが分かります(下図)。
図6:pitchが90°に近づくほど3軸の独立性が0へ潰れていく(ジンバルロック)。
この「1自由度の消失」があるため、姿勢を内部で保持する用途にオイラー角は不向きとされます。これを避けるのが、次の軸-角度とクォータニオンです。
③ 軸-角度(回転ベクトル)
軸-角度表現(axis-angle:回転を「回転軸の向き」と「その軸まわりの角度」で表す)は、とても幾何的で分かりやすい方法です。「単位ベクトル n の軸まわりに、角度 θ 回す」——それだけです(図7)。
図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回・ロックしない・補間に強いので、ロボットやゲームの心臓部で使われる。
- 使い分けは「入力=オイラー角/保持=クォータニオン/実行=回転行列」。
次回は、ここで作ったクォータニオンを実際にロボットアームの手先姿勢に適用して、姿勢制御・姿勢の補間まで繋げる予定です。
おわりに
姿勢表現は、ロボットアームや経路生成を実装するときの「地味だけど必ず要る土台」です。関連する実装記事は下記から辿れます。




