3Dモデリングでハマるカメラ座標系の罠

# この記事について

3Dモデルを生成・処理するライブラリとして、COLMAP・Blender・OpenCV等があるが、**ツールごとにカメラ座標系の軸の定義方法が異なる**。

例えば、COLMAPで計算した座標変換行列（世界座標系→カメラ座標系）をBlender上でもそのまま適用しようとすると、後段の処理に失敗することがある。

そこでこの記事では、以下の３点について説明する。
1. **カメラ座標系の基礎**
1. それぞれのツールにおける**カメラ座標系の定義**
1. **座標変換の方法**

# 1. カメラ座標系の基礎
## 1.1 座標系とは？
3D空間上に (x,y,z) 軸をどのように設定するかのルール。3Dモデリングツールではこの**座標系が統一されていない**ことから混乱を招きがち。
<!--<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/882064/b54d9d20-1508-4cd4-3f0b-1a4d9cca91d3.png" width=400>-->


## 1.2 さまざまな座標系
- **世界座標系**: 空間中のある位置を原点とする座標系
- **カメラ座標系**: カメラ位置を原点とする座標系
- 局所座標系: 各3Dモデル固有の座標系
- スクリーン座標系: 画面表示で使う座標系
<img src="https://cvml-expertguide.net/wp-content/uploads/2021/08/c5aeb6cc15ae884986060dc6ef81b585.png" width=500>


## 1.3 座標変換（剛体変換）
- モデリング変換: 局所座標系→世界座標系
- **ビューイング変換**: 世界座標系→カメラ座標系

例えば、COLMAPでは**世界座標系からカメラ座標系**へのビューイング変換行列（回転行列+並進成分）が与えられる。ただし、COLMAPのカメラ座標系における軸の取り方は、後段の処理で使うツールのカメラ座標系の軸の取り方と同じとは限らないため、必要に応じて座標変換を施す必要がある。

# 2. ツールごとのカメラ座標系の定義

カメラ位置を原点とした時のそれぞれの軸の向きを表す。

<!--
(x,y)平面は画像平面と平行な平面を表し、z軸は画像平面に垂直な視線方向を表す。この(x,y,z)の向きはツールによって異なる。

まず、x,y軸の取り方は、画像平面上で左/右/上/下の直交関係を満たす組み合わせなので、次の8種類が考えられる。
- (right, up)
- (right, down)
- (left, up)
- (left, down)
- (up, left)
- (up, right)
- (down, left)
- (down, right)

続いて、z軸方向としては、次の2種類が考えられる。
- forward (front): 正面方向
- backward (back): 手前方向
-->
例えば、代表的な3Dモデリングツールで使用されるカメラ座標系は以下の図・表の通りである。

<img src="https://miro.medium.com/v2/resize:fit:720/format:webp/0*TPernTNZ9LDjf1sw.png" width=500>

|ツール  |x  |y  |z  | 系　|
|---|---|---|---|---|
|COLMAP  | right | down | forward | 右手系(rdf)　|
|OpenCV  | right | down | forward | 右手系(rdf)　|
|pytorch3D  | left | up | forward | 右手系(luf)　|
|OpenGL  | right | up | backward | 右手系(rub)　|
|Blender  | right | up | backward | 右手系(rub)　|
|Unity  | right | up | forward | 左手系(ruf)　|

<!--※本節では、z軸が視線方向に対応する座標系に絞ったが、z軸を上向きにとる座標系（Unreal Engine等）も存在するようなので注意。-->

参考までに、NeRFでも実装によってカメラ座標系における軸の定義方法が異なるのが厄介。

|ツール  |x  |y  |z  | 系　| 備考 |
|---|---|---|---|---|---|
|NeRF[^1]  | right | up | backward | 右手系(rub) | 公式実装 |
|InstantNGP[^2] | right | up | backward | 右手系(rub) |  |
|Nerfstudio[^1]  | right | up | backward | 右手系(rub) |  |
|Nerfies[^3]  | right | down | forward | 右手系(rdf) |  |
|LLFF[^4]  | down | right | backward | 右手系(drb) |  |

[^1]: https://docs.nerf.studio/quickstart/data_conventions.html#camera-view-space: NeRF, Nerfstudioのカメラ座標系について記載
[^2]: https://github.com/NVlabs/instant-ngp/issues/61#issuecomment-1016849021: InstantNGPではblenderの座標系を採用
[^3]: https://github.com/google/nerfies/issues/39#issuecomment-986345766: NerfiesではOpenCVの座標系を採用
[^4]: https://github.com/Fyusion/LLFF?tab=readme-ov-file#using-your-own-poses-without-running-colmap: LLFFのカメラ座標系について記載

# 3. 座標変換の方法
ここでは、COLMAP等で出力されるビューイング変換（世界座標系→カメラ座標系）を後段で使用するツールのカメラ座標系に対応するように変換する方法を考える。

まず、

ビューイング変換（world-to-camera, `w2c`）は、回転行列と並進ベクトルを用いて、次のように定義できる。

```math
\begin{align}
\begin{bmatrix}
x_c \\
y_c \\
z_c
\end{bmatrix}
&=
\begin{bmatrix}
r_{x1} & r_{y1} & r_{z1} & t_{x}\\
r_{x2} & r_{y2} & r_{z2} & t_{y}\\
r_{x3} & r_{y3} & r_{z3} & t_{z}
\end{bmatrix}
\begin{bmatrix}
x_w \\
y_w \\
z_w \\
1
\end{bmatrix}\\
\mathbf{x}_c
&=
\mathbf{T}_{wc}
\begin{bmatrix}
\mathbf{x}_w \\
1
\end{bmatrix}\\
&= \mathbf{R}_{wc} \mathbf{x}_w + \mathbf{t}_{wc}

\end{align}
```
ビューイング変換で用いる上の行列 $\mathbf{T}_{wc}$ のことを一般的には**外部パラメータ**とも呼ぶ。

また、カメラ座標系から世界座標系への逆変換（camera-to-world, `c2w`）は次のように表せる。
```math
\begin{align}
\mathbf{x}_w
&=
\mathbf{R}_{wc}^{-1} (\mathbf{x}_c - \mathbf{t}_{wc}) \\
&=
\mathbf{R}_{wc}^{T} \mathbf{x}_c - \mathbf{R}_{wc}^{T} \mathbf{t}_{wc} \\
&=
\mathbf{R}_{cw} \mathbf{x}_c + \mathbf{t}_{cw}
\end{align}
```
ただし、
```math
\mathbf{R}_{cw} = \mathbf{R}_{wc}^{T}, \quad \mathbf{t}_{cw} = - \mathbf{R}_{wc}^{T} \mathbf{t}_{wc}
```
とした。

ここで、世界座標系におけるカメラの位置は$\mathbf{t}_{cw}$であり、カメラ座標系の軸の取り方によって変わることはない。

ただし、回転行列$\mathbf{R}_{cw}$は、カメラ座標系の軸の取り方によって意味が変わってしまうので、適切な変換が必要となる。

## 3.1 座標変換方法

ここでは、OpenCVの座標系 (rdf) で記述された `c2w` 変換行列から、OpenGLの座標系 (rub) における `c2w` 変換行列を求める場合 (rdf_to_rub) を考える。
<img src="https://user-images.githubusercontent.com/1303847/119395403-a7a09980-bcfd-11eb-83a2-8d3d03ef1e9e.png">
上の図からわかる通り、2つの座標系では x 軸の取り方が共通で、(y, z) 軸の取り方がいずれも反転している。そのため、回転行列 $\mathbf{R}_{cw}^\mathrm{rdf}$ の (y, z) 軸成分（2, 3列目）に-1をかければよい。

```math
\begin{align}
\mathbf{R}_{cw}^\mathrm{rdf}
&=
\begin{bmatrix}
r_{x1} & r_{y1} & r_{z1} & t_{x}\\
r_{x2} & r_{y2} & r_{z2} & t_{y}\\
r_{x3} & r_{y3} & r_{z3} & t_{z}
\end{bmatrix}\\
\mathbf{R}_{cw}^\mathrm{rub}
&=
\begin{bmatrix}
r_{x1} & -r_{y1} & -r_{z1} & t_{x}\\
r_{x2} & -r_{y2} & -r_{z2} & t_{y}\\
r_{x3} & -r_{y3} & -r_{z3} & t_{z}
\end{bmatrix}\\

\end{align}
```
上の例では、各軸の成分を反転させることで変換できるが、場合によっては軸を入れ替えることもある。例えば、LLFF (drb) からNeRF (rub) へカメラ座標系を変換する場合 (drb_to_rub)、$\mathbf{R}_{cw}^\mathrm{drb}$ のx軸を反転させたあと、x軸とy軸を入れ替える必要がある。

```math
\begin{align}
\mathbf{R}_{cw}^\mathrm{drb}
&=
\begin{bmatrix}
r_{x1} & r_{y1} & r_{z1} & t_{x}\\
r_{x2} & r_{y2} & r_{z2} & t_{y}\\
r_{x3} & r_{y3} & r_{z3} & t_{z}
\end{bmatrix}\\
\mathbf{R}_{cw}^\mathrm{rub}
&=
\begin{bmatrix}
r_{y1} & - r_{x1} & r_{z1} & t_{x}\\
r_{y2} & - r_{x2} & r_{z2} & t_{y}\\
r_{y3} & - r_{x3} & r_{z3} & t_{z}
\end{bmatrix}\\

\end{align}
```

## 3.2 座標変換コード

`[num_poses, 3, 4]`のビューイング逆変換から、使用されることの多いrubのビューイング逆変換を求めるコードを以下に示す。

```py
def rdf_to_rub(c2w):
    """
    Input: c2w (num_poses, 3, 4) as (right, down, forward)
    Output: c2w (num_poses, 3, 4) as (right, up, backward)
    """
    c2w_ = np.zeros_like(c2w)
    c2w_ = np.concatenate([c2w[:, :, [0]], -c2w[:, :, [1]], -c2w[:, :, [2]], c2w[:, :, [3]]], axis=2)
    return c2w_

def luf_to_rub(c2w):
    c2w_ = np.zeros_like(c2w)
    c2w_ = np.concatenate([-c2w[:, :, [0]], c2w[:, :, [1]], -c2w[:, :, [2]], c2w[:, :, [3]]], axis=2)
    return c2w_

def drb_to_rub(c2w):
    c2w_ = np.zeros_like(c2w)
    c2w_ = np.concatenate([c2w[:, :, [1]], -c2w[:, :, [0]], c2w[:, :, [2]], c2w[:, :, [3]]], axis=2)
    return c2w_

def drf_to_rub(c2w):
    c2w_ = np.zeros_like(c2w)
    c2w_ = np.concatenate([c2w[:, :, [1]], -c2w[:, :, [0]], -c2w[:, :, [2]], c2w[:, :, [3]]], axis=2)
    return c2w_

def ufl_to_rub(c2w):
    c2w_ = np.zeros_like(c2w)
    c2w_ = np.concatenate([-c2w[:, :, [2]], c2w[:, :, [0]], -c2w[:, :, [1]], c2w[:, :, [3]]], axis=2)
    return c2w_
```

# 4. Tips
- 座標系変換行列がc2wなのかw2cなのか明示されていないことがあり、ツールによって異なる
    - 外部パラメータはw2cを指す
- 回転行列は直交行列のため、その逆行列は転置行列となる

# 参考記事
- [3Dライブラリのカメラ座標系の違い](https://qiita.com/Simossyi/items/d95de9925c0fccdbc9cc)
- [OpenCVのカメラとOpenGLでレンダリングする](https://qiita.com/forfeit/items/c63157e668a072d80f68)
- [なぜ座標には右手系と左手系があるのか](https://note.com/ninagawa123/n/ne24fcf08c36f)
- [カメラモデルと透視投影(CVMLエキスパートガイド)](https://cvml-expertguide.net/terms/cv/camera-geometry/camera-model)
- [Converting camera poses from OpenCV to OpenGL can be easy](https://medium.com/check-visit-computer-vision/converting-camera-poses-from-opencv-to-opengl-can-be-easy-27ff6c413bdb)
- [Camera poses in Python](https://medium.com/check-visit-computer-vision/convert-camera-poses-in-python-35debb8460ec)
- [Coordinate Frames (Stereo Labs)](https://www.stereolabs.com/docs/positional-tracking/coordinate-frames/)
- [What are the coordinates?](https://medium.com/@christophkrautz/what-are-the-coordinates-225f1ec0dd78)