故障した通信衛星に補給船が無人で近づいて燃料を補給する、無制御回転するロケット上段にロボットアームが寄り添って捕獲する — 軌道上サービス(On-Orbit Servicing, OOS)や能動的デブリ除去(Active Debris Removal, ADR)と呼ばれるこれらのミッションでは、地上からの遠隔操作だけでは到底間に合わないほど精密で連続的な「観測 → 判断 → 制御」が必要になります。その中心にいるのが、搭載カメラ一台の画像から相手宇宙機の位置と向きをリアルタイムに推定する自律ナビゲーション機能です。
人間であれば、初めて見る形の機械でも「これは長い構造物が左上を向いていて、太陽電池が手前に張り出している」と直感的に把握できます。しかしコンピュータにとって、たった1枚の2D画像から3次元の姿勢を読み取るのは長らく難題でした。古典的な幾何手法はマーカーや初期姿勢に依存し、テクスチャの少ない金属面や激しいコントラストで容易に破綻します。この壁を破ったのが、近年急速に発展した深層学習による6DoF姿勢推定であり、その評価基盤を提供しているのが本記事の主役であるSPEED+データセットです。
深層学習による宇宙機の6DoF姿勢推定を理解すると、以下のような応用が見えてきます。
- 自律ランデブー・ドッキング: 補給船や軌道間輸送機が、相手機の協力なしに姿勢を推定して接近する
- デブリ除去ミッション: 形状や姿勢が未知のデブリに対して、視覚情報だけから把持計画を立てる
- オンボードAIナビゲーション: 通信遅延を回避し、衛星自身が画像から自律的に判断する
- 月・惑星着陸: 地形画像から探査機自身の姿勢と位置を推定する(同じ枠組みが応用可能)
本記事の内容
- なぜ宇宙機の6DoF姿勢推定が難しいのか
- SPEED / SPEED+ データセットの構成とドメインギャップ問題
- 直接回帰アプローチ vs キーポイント検出 + PnP の長所と短所
- クォータニオン姿勢ロス、対称性ロス、位置ロスの設計
- PyTorchによるResNet18相当の小型CNNでの位置・姿勢回帰
- 推論結果の3D可視化と誤差ヒストグラムによる定量評価
- Sim-to-Real(合成→実画像)の課題と回避策
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。
- 畳み込みニューラルネットワーク(CNN)の基本構造
- 単回帰分析の理論と実装
- 四元数(クォータニオン)による回転の表現
- 宇宙における視覚誘導 — 非協力物体の姿勢推定手法
- 深層学習による宇宙物体の認識と姿勢推定 — CNNからTransformerまで
なぜ宇宙機の6DoF姿勢推定が難しいのか
「6DoF」が意味すること
まず、私たちが推定したい量を整理します。カメラから見て対象宇宙機がどこにあり、どちらを向いているか — これを完全に表現するには、位置 $\bm{t} \in \mathbb{R}^3$ と回転 $\bm{R} \in SO(3)$ の合計6自由度が必要です。$SO(3)$ は3次元の回転群で、3つの独立なパラメータ(オイラー角、軸角、あるいはクォータニオンの自由度)で表せます。「3+3=6」、これが「6DoF」という言葉の正体です。
日常的なイメージとしては、地上のドローンを操縦するときに「縦・横・高さ」の位置と「ロール・ピッチ・ヨー」の3軸回転を同時に把握する状況に似ています。違いは、宇宙では重力で向きが定まらないため上下感覚が使えず、しかも相手は無制御に回転していることが珍しくないという点です。
宇宙環境がもたらす困難
地上のコンピュータビジョンで磨かれた手法を宇宙にそのまま持ち込もうとすると、以下の壁にぶつかります。
極端な照明コントラスト: 大気がない宇宙では太陽光が直射するため、日照面は飽和寸前の白、影面は真っ黒に近くなります。ヒストグラムは中間域が抜け落ち、特徴点検出器が好む「ほどよい明暗」が得られません。
テクスチャの欠如: 多くの衛星は多層断熱材(MLI)の金色・銀色の箔で覆われています。SIFT・ORBが好む「点状の局所特徴」がほぼ存在しないため、古典的なマッチングが効きません。
地球の写り込み: 背景には深宇宙だけでなく、強く反射する地球の青い縁や雲が頻繁に写り込みます。学習データに地球の多様な見え方が含まれていないと、推論時に背景に引きずられて誤推定します。
実データの希少性: 実際の非協力物体への接近画像は世界中を見渡しても数えるほどしか存在しません。100万枚の写真で犬と猫を分類するような富豪的アプローチは不可能であり、合成画像に頼らざるを得ません。
「測れる量」と「推定したい量」の溝
カメラはあくまで2D画像を返すセンサで、奥行きを直接測ってはくれません。3次元の位置と向きを2次元の画像から逆問題として復元するため、形状の事前知識と幾何モデルが本質的に必要です。深層学習はその知識をデータから暗黙に学ぶ仕組みであり、ここに古典手法との大きな違いがあります。
この溝を埋める評価基盤として登場したのが、次に紹介するSPEEDおよびSPEED+データセットです。
SPEED / SPEED+データセットの全体像
SPEEDの誕生
SPEED(Spacecraft Pose Estimation Dataset)は、スタンフォード大学のSpace Rendezvous Laboratory(SLAB)が2019年に公開した、宇宙機の6DoF姿勢推定の標準ベンチマークです。対象機体はESAのフォーメーションフライト実証衛星PRISMAミッションのTango衛星で、合成画像(synthetic)と実験室画像(real)の両方が含まれています。
合成画像は3Dモデルからレンダリングしたもので、$1920 \times 1200$ 程度のグレースケール画像と、それぞれに対応する位置 $\bm{t}$(メートル単位)・クォータニオン $\bm{q}$ の正解ラベルがついています。実験室画像はTBSラボ(Testbed for Rendezvous and Optical Navigation)でTango模型を実写したもので、KUKA産業用ロボットによって正確なground truth姿勢が記録されています。
SPEED+による拡張
2022年、SLABはSPEEDをアップグレードしたSPEED+を公開しました。SPEED+の特徴は、Sim-to-Real(合成→実画像)のドメインギャップ問題を正面から扱える設計になっている点です。データは3つのドメインに分かれます。
| ドメイン | 概要 | 用途 |
|---|---|---|
| Synthetic | レンダリングによる合成画像(約60,000枚)。ポーズラベル付き | 学習用 |
| Lightbox | 実機Tango模型を暗室で撮影(約9,500枚)。緩やかな照明 | テスト用(ラベルなし) |
| Sunlamp | 太陽光を模擬した強い平行光下の実写(約20,000枚)。極端なコントラスト | テスト用(ラベルなし) |
学習時に使えるのはSyntheticのみで、評価はLightboxとSunlampで行われます。つまり合成だけで学んだモデルが、見たことのない実画像でどこまで通用するかを問うチャレンジング設計です。これはESAが主催する国際コンペティションSPEC(Satellite Pose Estimation Challenge) の課題設定にもなっています。
評価指標 — 回転誤差と正規化並進誤差
SPEED+の評価指標は、回転誤差と並進誤差の和で定義されます。回転誤差は推定クォータニオン $\hat{\bm{q}}$ と正解クォータニオン $\bm{q}^*$ の間の最短回転角度で、
$$ e_{\text{rot}} = 2 \arccos\left(|\langle \hat{\bm{q}}, \bm{q}^* \rangle|\right) $$
として計算されます($\bm{q}$ と $-\bm{q}$ が同じ回転を表すため、絶対値で内積を取ります)。並進誤差は対象との距離で正規化された相対誤差で、
$$ e_{\text{trans}} = \frac{\|\hat{\bm{t}} – \bm{t}^*\|_2}{\|\bm{t}^*\|_2} $$
と定義されます。正規化することで、近距離(誤差が絶対的には小さくても影響が大きい)と遠距離(絶対誤差が大きくても影響が相対的に小さい)を公平に比較できるようになります。最終スコアはこれらの和または重み付き和です。
ベンチマークの設計を理解したところで、いよいよモデル側の話に移ります。深層学習でこの問題を解くアプローチは大きく2系統に分かれます。
直接回帰 vs キーポイント検出 + PnP
直接回帰アプローチ
最も素直な方針は、CNNに画像を入力して位置 $\bm{t}$ と回転 $\bm{R}$ を直接出力させることです。出力は典型的には
$$ \bm{y} = \begin{bmatrix} \bm{t} \\ \bm{q} \end{bmatrix} \in \mathbb{R}^{3+4} $$
の7次元ベクトルで、損失関数を設計してend-to-endに学習します。アーキテクチャはResNetやEfficientNetなどの事前学習済みCNNをバックボーンに使い、最終層を回帰ヘッドに置き換える形が一般的です。
直接回帰の長所は実装が単純なことと、推論がフォワードパス一回で終わることです。衛星オンボードCPUのように計算資源が限られる環境では、推論時間が予測可能であることが大きな価値を持ちます。
一方の短所は、画像の幾何学的構造を陽に使えない点です。CNNが特徴ベクトルを経て出力する数値は、画像のどの画素にも対応していません。そのため、特に並進方向の精度が幾何手法に比べて劣る傾向があります。
キーポイント検出 + PnP(Perspective-n-Point)
これに対する強力な代替案が、ニューラルネットワークでキーポイントを検出し、古典的なPnPで姿勢を解くハイブリッドアプローチです。
考え方は、地図上で目的地を直接緯度経度で答える代わりに「あの交差点の右側」とランドマークで答えるのに似ています。3Dモデル上に意味のあるキーポイント(衛星本体の角、太陽パネルの端、アンテナの先端など)を $K$ 個事前に定義しておき、CNNはそれらが画像のどこに映るかを予測します。
予測された2Dキーポイント $\{(\hat{u}_k, \hat{v}_k)\}_{k=1}^K$ と既知の3Dキーポイント $\{\bm{P}_k\}_{k=1}^K$ に対し、ピンホールカメラモデル
$$ \lambda_k \begin{bmatrix} \hat{u}_k \\ \hat{v}_k \\ 1 \end{bmatrix} = \bm{K}(\bm{R}\bm{P}_k + \bm{t}) $$
を満たす $\bm{R}, \bm{t}$ を求めるのがPnP問題です。EPnP + RANSACで外れ値に頑健に解けます。$\bm{K}$ はカメラの内部パラメータ行列、$\lambda_k$ はスケール因子(奥行き相当)です。
このアプローチが直接回帰を上回るのは、幾何学的整合性が陽に保証されるためです。CNNはピクセル位置の予測にだけ集中すればよく、6つの実数を「正しく」出力する責任は古典PnPに肩代わりさせる構造になっています。SPEC上位チームの多くがこの方針を取っています。
どちらを選ぶか
実用上の判断基準を整理すると次のようになります。
| 観点 | 直接回帰 | キーポイント + PnP |
|---|---|---|
| 実装の単純さ | 高い | 中(2段階パイプライン) |
| 精度(特に並進) | 中 | 高 |
| 推論時間の予測性 | 高(一定) | 中(RANSAC反復あり) |
| 3Dモデルの必要性 | 学習時のみ | 学習時・推論時両方 |
| 教育・プロトタイプ | 適 | やや複雑 |
教育目的や最初のベースライン構築では直接回帰、SPEC本番の精度を狙う場合はキーポイント + PnPを採用するのが標準的な選択になります。本記事のPython実装では、直接回帰でモデルが学ぶ気持ちを掴むことを優先します。
それでは、直接回帰の心臓部 — 損失関数の設計を見ていきましょう。
損失関数の設計
位置損失
位置 $\bm{t}$ の損失は、推定値 $\hat{\bm{t}}$ と正解 $\bm{t}^*$ のL2距離が基本です。
$$ \mathcal{L}_{\text{pos}} = \|\hat{\bm{t}} – \bm{t}^*\|_2 $$
ただし、対象機との距離($\|\bm{t}^*\|$)が大きい場面では数値スケールが大きくなり学習が不安定になります。SPEED+の評価指標と整合させるためには、SPEC公式の正規化バージョン
$$ \mathcal{L}_{\text{pos,norm}} = \frac{\|\hat{\bm{t}} – \bm{t}^*\|_2}{\|\bm{t}^*\|_2} $$
を使うのが定石です。距離に依らず誤差が「相対値」で評価され、近距離と遠距離のサンプルが公平に扱われます。
クォータニオン姿勢損失
回転をクォータニオン $\bm{q} = (w, x, y, z)^T$ で出力する場合、まず満たすべき制約は単位ノルム $\|\bm{q}\| = 1$ です。CNNの出力 $\bm{a} \in \mathbb{R}^4$ を $\bm{q} = \bm{a} / \|\bm{a}\|$ と正規化してから損失を計算します。
最も自然な損失は、$SO(3)$ 上の測地線距離に対応する次のものです。
$$ \mathcal{L}_{\text{rot}} = 2 \arccos\left(|\langle \hat{\bm{q}}, \bm{q}^* \rangle|\right) $$
ここで内積に絶対値を付ける理由は、クォータニオンの二重被覆にあります。$\bm{q}$ と $-\bm{q}$ は同じ回転を表すため、両者を同一視するために $|\cdot|$ を取ります。これを忘れると、ネットワークが「正解の符号」を学ぶ無意味なタスクに混乱します。
ただし $\arccos$ は微分の数値安定性が低いため、実装上は内積そのものを使う「コサインロス」
$$ \mathcal{L}_{\text{rot,cos}} = 1 – |\langle \hat{\bm{q}}, \bm{q}^* \rangle| $$
や、軸角表現に直してから距離を取る方法が好まれます。これらは内積が1に近いほど(角度が0に近いほど)損失が小さくなり、$\arccos$ と同じ単調性を持ちますが、勾配の計算が安定します。
対称性ロス
宇宙機の中には、視覚的に対称な構造を持つものがあります。例えば対面が同じ形状の立方体衛星や、軸対称の上段ロケットが代表例です。このとき、同じ画像に対して複数の「正解姿勢」が許されるため、単一のクォータニオン正解への損失は本質的に矛盾を含んでしまいます。
対策として導入されるのが対称性ロスで、正解姿勢の許容変換 $\{\bm{R}_s\}_{s=1}^S$(対称群の元)に対して、最も近いものを取る min-loss
$$ \mathcal{L}_{\text{sym}} = \min_{s \in \{1,\ldots,S\}} \mathrm{dist}\bigl(\hat{\bm{R}}, \bm{R}^* \bm{R}_s\bigr) $$
を使います。$\mathrm{dist}$ には先ほどの測地線距離やコサインロスを使えばOKです。対称性の事前知識(4回対称か2回対称か)を陽に入れることで、学習データの「見かけ上の正解の揺れ」を取り除くことができます。
統合損失
最終的な損失は、位置と姿勢を重みづけて足し合わせます。
$$ \mathcal{L} = \mathcal{L}_{\text{pos,norm}} + \beta \, \mathcal{L}_{\text{rot,cos}} $$
$\beta$ は単位系と数値スケールを揃えるハイパーパラメータで、最初は$\beta = 1$から始め、検証誤差を見ながら調整します。Kendall & Cipolla(2017)が提案した、$\beta$ を不確かさパラメータ $\sigma$ から学習する自動重み付け
$$ \mathcal{L} = \frac{1}{\sigma_t^2}\mathcal{L}_{\text{pos,norm}} + \frac{1}{\sigma_q^2}\mathcal{L}_{\text{rot,cos}} + \log \sigma_t + \log \sigma_q $$
も実装が容易で、ハイパーパラメータの手動調整を減らせます。$\log \sigma$ 項は $\sigma$ が無限大に発散する自明解を防ぐ正則化です。
損失関数が組めたら、もう一つ重要なテーマ — 学習データのドメイン適応に進みます。
Sim-to-Real問題と回避策
なぜ性能が落ちるのか
合成画像で学習したモデルを実画像に当てると、なぜ性能が落ちるのでしょうか。原因を順に挙げると次の通りです。
反射特性の差: MLI箔は皺の入り方によって複雑な異方性反射を示します。Lambertian + Phongのような単純シェーダでは再現できません。
センサの特性: 実カメラのレンズ歪み、暗電流、読み出しノイズ、ホットピクセル(宇宙線で生じる)はレンダリングでは省略されがちです。
3Dモデルの不完全性: CADは設計時の形状であり、実機の組立公差、配線、デカール、経年劣化を反映していません。
背景の写り込み: 地球の写真を背景に貼る場合でも、実画像の地球反射光の空間的グラデーションは合成データで再現しきれません。
代表的な回避策
これらに対する標準的な対策は次の3つです。
ドメインランダマイゼーション(DR): 学習時に照明方向、テクスチャ、ノイズ、コントラスト、背景をランダムに大きく変動させ、「ありうるすべての見え方の上位集合」をネットワークに見せます。実データを一切使わない zero-shot な汎化が狙えます。
ドメイン適応(DA): 敵対的学習やMMD損失で、合成画像と実画像の特徴量分布を一致させます。実画像のラベルは不要ですが、画像そのものは必要です。
スタイル変換: CycleGAN等で合成画像を実画像のスタイルに変換します。幾何構造を保ったまま見た目だけ近づけるのがポイントです。
SPEC上位チームの典型的構成は、強力なDR + テスト時オーグメンテーション(TTA) + アンサンブルです。実画像を一切使わずにここまで来られることが、合成データ主導のパラダイムの強みです。
ここまでで理論側はほぼ揃いました。手を動かして直感を得るために、PyTorchでミニマルな直接回帰モデルを組んで、合成画像での学習と推論を体験しましょう。
PyTorchによる直接回帰の実装
全体方針
SPEED+の本物のデータセットは数十GBあり、本記事のスコープでは扱えません。代わりに、直方体宇宙機を模した簡易合成画像を自作し、PyTorchの小型CNN(ResNet18相当の軽量版)で位置・姿勢を直接回帰します。意図的にシンプルな問題設定にすることで、SPEED+風の損失設計と評価フローを最小コードで体験するのが目的です。
実装のステップは次の通りです。
- 直方体形状の宇宙機モデルをmatplotlibで合成レンダリング(位置・姿勢をランダム化)
- PyTorchのDatasetでオンザフライにバッチ生成
- ResNet18相当のCNNで $(\bm{t}, \bm{q})$ を回帰
- クォータニオン正規化と二重被覆対応の損失を実装
- 検証データで回転誤差・並進誤差のヒストグラムを描画
合成データジェネレータ
まず、ピンホールカメラで3D直方体を投影する関数を作ります。透視投影の基本式は
$$ \begin{bmatrix} u \\ v \\ 1 \end{bmatrix} = \frac{1}{Z_c}\bm{K}\bm{X}_c, \quad \bm{X}_c = \bm{R}\bm{X}_o + \bm{t} $$
で、$\bm{X}_o$ はオブジェクト座標、$\bm{X}_c$ はカメラ座標、$\bm{K}$ は内部パラメータ行列です。
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
import matplotlib.pyplot as plt
# 再現性
np.random.seed(0)
torch.manual_seed(0)
# カメラ内部パラメータ(仮想的な値)
IMG_SIZE = 128
FX = FY = 200.0
CX = CY = IMG_SIZE / 2
K_INTR = np.array([
[FX, 0, CX],
[0, FY, CY],
[0, 0, 1]
], dtype=np.float32)
# 直方体宇宙機の8頂点(メートル)
BOX = np.array([
[-1, -0.5, -0.3], [ 1, -0.5, -0.3],
[ 1, 0.5, -0.3], [-1, 0.5, -0.3],
[-1, -0.5, 0.3], [ 1, -0.5, 0.3],
[ 1, 0.5, 0.3], [-1, 0.5, 0.3],
], dtype=np.float32)
EDGES = [(0,1),(1,2),(2,3),(3,0),
(4,5),(5,6),(6,7),(7,4),
(0,4),(1,5),(2,6),(3,7)]
def quat_to_rot(q):
"""クォータニオン (w,x,y,z) を回転行列に変換"""
w, x, y, z = q
return np.array([
[1-2*(y*y+z*z), 2*(x*y-z*w), 2*(x*z+y*w)],
[2*(x*y+z*w), 1-2*(x*x+z*z), 2*(y*z-x*w)],
[2*(x*z-y*w), 2*(y*z+x*w), 1-2*(x*x+y*y)]
], dtype=np.float32)
def random_quat():
"""SO(3)上の一様分布クォータニオン"""
z = np.random.randn(4)
return (z / np.linalg.norm(z)).astype(np.float32)
def project(box3d, R, t):
"""3D頂点群を画像平面に投影"""
Xc = (R @ box3d.T).T + t # (8,3)
uv = (K_INTR @ Xc.T).T
uv = uv[:, :2] / uv[:, 2:3]
return uv
def render_image(R, t):
"""直方体のエッジを2D画像(128x128, グレースケール)にレンダリング"""
uv = project(BOX, R, t)
fig, ax = plt.subplots(figsize=(2, 2), dpi=64)
ax.set_xlim(0, IMG_SIZE); ax.set_ylim(IMG_SIZE, 0)
ax.set_facecolor('black')
for i, j in EDGES:
ax.plot([uv[i,0], uv[j,0]], [uv[i,1], uv[j,1]],
color='white', linewidth=1.5)
ax.axis('off')
fig.canvas.draw()
rgba = np.asarray(fig.canvas.buffer_rgba())
img = rgba[..., :3].mean(axis=-1).astype(np.float32) / 255.0
plt.close(fig)
from PIL import Image
img_pil = Image.fromarray((img * 255).astype(np.uint8)).resize((IMG_SIZE, IMG_SIZE))
return np.asarray(img_pil, dtype=np.float32) / 255.0
ここまでで「ランダムな位置・姿勢から1枚の合成画像を作る関数」が揃いました。エッジだけの単純なレンダリングですが、姿勢が変われば見える線の構成が変わるため、CNNが学習する手掛かりとしては十分です。次にこの関数をDatasetでラップします。
Dataset と DataLoader
class SpacecraftPoseDataset(Dataset):
def __init__(self, n_samples=512, t_range=(4.0, 8.0)):
self.n = n_samples
self.t_range = t_range
def __len__(self):
return self.n
def __getitem__(self, idx):
# ランダム姿勢
q = random_quat()
R = quat_to_rot(q)
# ランダム位置(カメラ前方)
tx = np.random.uniform(-1.0, 1.0)
ty = np.random.uniform(-1.0, 1.0)
tz = np.random.uniform(*self.t_range)
t = np.array([tx, ty, tz], dtype=np.float32)
img = render_image(R, t)
img_tensor = torch.from_numpy(img).unsqueeze(0) # (1,H,W)
target = np.concatenate([t, q]).astype(np.float32)
return img_tensor, torch.from_numpy(target)
train_ds = SpacecraftPoseDataset(n_samples=512)
val_ds = SpacecraftPoseDataset(n_samples=128)
train_loader = DataLoader(train_ds, batch_size=16, shuffle=True)
val_loader = DataLoader(val_ds, batch_size=16, shuffle=False)
img, target = next(iter(train_loader))
print("image batch:", img.shape, "target batch:", target.shape)
__getitem__ のたびに新しい合成画像を生成するため、エポックごとに実質無限のバリエーションが得られます。これは合成データの大きな強みです。出力サイズは画像が $(B, 1, 128, 128)$、ターゲットが $(B, 7) = (\bm{t}, \bm{q})$ で、想定通りの形になっています。
モデル定義(ResNet18相当の軽量CNN)
ResNet18フルサイズを使ってもよいですが、教育目的では「Residualブロックを2回繰り返す軽量版」で十分です。
class BasicBlock(nn.Module):
def __init__(self, in_ch, out_ch, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(in_ch, out_ch, 3, stride, 1, bias=False)
self.bn1 = nn.BatchNorm2d(out_ch)
self.conv2 = nn.Conv2d(out_ch, out_ch, 3, 1, 1, bias=False)
self.bn2 = nn.BatchNorm2d(out_ch)
if stride != 1 or in_ch != out_ch:
self.shortcut = nn.Sequential(
nn.Conv2d(in_ch, out_ch, 1, stride, bias=False),
nn.BatchNorm2d(out_ch))
else:
self.shortcut = nn.Identity()
def forward(self, x):
out = F.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
out = out + self.shortcut(x)
return F.relu(out)
class PoseCNN(nn.Module):
def __init__(self):
super().__init__()
self.stem = nn.Sequential(
nn.Conv2d(1, 32, 7, 2, 3, bias=False),
nn.BatchNorm2d(32), nn.ReLU(inplace=True),
nn.MaxPool2d(3, 2, 1)
)
self.layer1 = BasicBlock(32, 64, stride=2)
self.layer2 = BasicBlock(64, 128, stride=2)
self.layer3 = BasicBlock(128, 256, stride=2)
self.pool = nn.AdaptiveAvgPool2d(1)
self.head_t = nn.Linear(256, 3)
self.head_q = nn.Linear(256, 4)
def forward(self, x):
x = self.stem(x)
x = self.layer3(self.layer2(self.layer1(x)))
x = self.pool(x).flatten(1)
t = self.head_t(x)
q = self.head_q(x)
q = q / (q.norm(dim=1, keepdim=True) + 1e-8)
return t, q
stemで解像度を1/4にし、その後3つのResidualブロックでさらに1/8まで縮めます。最終的に特徴マップは $4 \times 4 \times 256$ となり、Global Average Poolingで $(B, 256)$ のベクトルに集約してから、位置ヘッドと姿勢ヘッドに分岐します。姿勢ヘッドの出力は forward 内で正規化しており、出力されるクォータニオンは常にノルム1になります。
損失関数と学習ループ
def quat_cosine_loss(q_pred, q_true):
"""二重被覆を考慮したコサインロス"""
dot = (q_pred * q_true).sum(dim=1)
return (1 - dot.abs()).mean()
def pos_norm_loss(t_pred, t_true):
"""SPEC仕様の正規化位置ロス"""
num = (t_pred - t_true).norm(dim=1)
den = t_true.norm(dim=1) + 1e-6
return (num / den).mean()
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = PoseCNN().to(device)
optim = torch.optim.Adam(model.parameters(), lr=1e-3)
history = []
n_epochs = 8
for ep in range(n_epochs):
model.train()
tr_losses = []
for img, target in train_loader:
img, target = img.to(device), target.to(device)
t_true, q_true = target[:, :3], target[:, 3:]
t_pred, q_pred = model(img)
loss = pos_norm_loss(t_pred, t_true) + 1.0 * quat_cosine_loss(q_pred, q_true)
optim.zero_grad(); loss.backward(); optim.step()
tr_losses.append(loss.item())
model.eval()
val_losses = []
with torch.no_grad():
for img, target in val_loader:
img, target = img.to(device), target.to(device)
t_true, q_true = target[:, :3], target[:, 3:]
t_pred, q_pred = model(img)
loss = pos_norm_loss(t_pred, t_true) + 1.0 * quat_cosine_loss(q_pred, q_true)
val_losses.append(loss.item())
tr = float(np.mean(tr_losses)); vl = float(np.mean(val_losses))
history.append((tr, vl))
print(f"Epoch {ep+1}/{n_epochs} train_loss={tr:.4f} val_loss={vl:.4f}")
学習が進むと、train_loss と val_loss が同時に減少していくはずです。検証損失の減り方が頭打ちになっていく様子から、合成データの単純さゆえに比較的早く収束することがわかります。実際のSPEED+ではドメインギャップで val_loss だけが下がり続け、テスト性能が伸び悩むパターンが多いのですが、今回の手作り合成データでは学習データと検証データが同じ分布なので穏やかな振る舞いになります。
評価 — 回転誤差と並進誤差のヒストグラム
学習が終わったら、SPEC流の評価指標で性能を可視化します。
def quat_angular_error(q_pred, q_true):
"""クォータニオン間の最短回転角度(度)"""
dot = np.clip(np.abs(np.sum(q_pred * q_true, axis=1)), 0.0, 1.0)
return 2 * np.degrees(np.arccos(dot))
model.eval()
all_rot_err = []
all_pos_err = []
with torch.no_grad():
for img, target in val_loader:
img, target = img.to(device), target.to(device)
t_true, q_true = target[:, :3], target[:, 3:]
t_pred, q_pred = model(img)
rot_err = quat_angular_error(q_pred.cpu().numpy(),
q_true.cpu().numpy())
pos_err = ((t_pred - t_true).norm(dim=1) /
(t_true.norm(dim=1) + 1e-6)).cpu().numpy()
all_rot_err.extend(rot_err.tolist())
all_pos_err.extend(pos_err.tolist())
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
ax[0].hist(all_rot_err, bins=20, color='steelblue', edgecolor='black')
ax[0].set_xlabel("Rotation error [deg]")
ax[0].set_ylabel("Count")
ax[0].set_title("Rotation error distribution")
ax[1].hist(all_pos_err, bins=20, color='salmon', edgecolor='black')
ax[1].set_xlabel("Normalized translation error")
ax[1].set_ylabel("Count")
ax[1].set_title("Translation error distribution")
plt.tight_layout(); plt.show()
print(f"median rot err = {np.median(all_rot_err):.2f} deg")
print(f"median pos err = {np.median(all_pos_err):.4f}")
実行すると、回転誤差は概ね数十度以内、正規化並進誤差は数%以内に分布するはずです。これは「合成データのみで、教科書サイズのCNNで、しかも128×128ピクセルの入力で」達成された値であることを考えると上出来です。SPEED+の実画像評価では同じネットワークでは到底この値に届かないため、合成→実のドメインギャップがいかに大きいかも逆に実感できる結果です。
推論結果の3D可視化
数値だけでは姿勢推定の精度感が掴みにくいので、推定姿勢で直方体を再投影し、入力画像と重ねて可視化します。
img_batch, target_batch = next(iter(val_loader))
img_batch_d = img_batch.to(device)
with torch.no_grad():
t_pred, q_pred = model(img_batch_d)
t_pred = t_pred.cpu().numpy()
q_pred = q_pred.cpu().numpy()
img_np = img_batch.numpy()
fig, axes = plt.subplots(2, 4, figsize=(13, 7))
for k in range(8):
ax = axes[k // 4, k % 4]
ax.imshow(img_np[k, 0], cmap='gray')
R_hat = quat_to_rot(q_pred[k])
uv_hat = project(BOX, R_hat, t_pred[k])
for i, j in EDGES:
ax.plot([uv_hat[i,0], uv_hat[j,0]],
[uv_hat[i,1], uv_hat[j,1]],
color='red', linewidth=1.2, alpha=0.85)
ax.set_xticks([]); ax.set_yticks([])
ax.set_title(f"sample {k}", fontsize=9)
plt.tight_layout(); plt.show()
入力画像(白いエッジ)と再投影されたエッジ(赤)がほぼ重なっていれば、推定姿勢が概ね正解と一致しています。ズレが大きいサンプルを見ると、対象が極端に小さく写っている、あるいは見える面が少ない(ほぼ正面しか見えていない)など、本質的に情報の少ない画像であることが多いです。これは深層学習がデータから「見えにくいケースほど不確かである」という人間と同じ直感を学んでいる証拠でもあります。
ここまでで、合成データでの直接回帰がどこまで通用するかの肌感覚が得られました。次にSPEED+本番に挑む際の追加項目を簡単に整理しておきましょう。
SPEED+本番に向けた追加要素
教育用の最小コードに対して、SPEC本番に向けてはさらに以下を加えると競技レベルに近づきます。
バウンディングボックス段階の前処理: YOLO等で対象機を切り出してから姿勢推定ヘッドに渡すと、画像のスケール変動が抑えられ、特に遠距離サンプルの性能が改善します。
キーポイントベースモデル: HRNetなどのキーポイント検出器でヒートマップを予測し、EPnP + RANSACで姿勢を解く構成に置き換えます。並進精度が劇的に向上します。
強力なドメインランダマイゼーション: 太陽光方向、地球の反射、MLI皺、センサノイズなどを学習時にランダム化します。
TTA(テスト時オーグメンテーション): 推論時にフリップ・回転・スケール変換を適用し、複数結果を平均化します。
アンサンブル: 異なるアーキテクチャ・学習データシードのモデルを組み合わせ、SO(3)上での平均(クォータニオン主固有ベクトルなど)を取ります。
これらを段階的に積み上げていくことで、SPEC上位のスコアに迫るシステムを構築できます。
まとめ
本記事では、深層学習による宇宙機の6DoF姿勢推定について、SPEED+データセットを軸に解説しました。
- 6DoF姿勢推定の重要性: 軌道上サービスやデブリ除去では、相手機が非協力であるためカメラ映像から自律的に姿勢を推定する技術が不可欠
- SPEED+とSPECチャレンジ: 学習は合成、テストは実画像という設計でSim-to-Real問題を正面から扱う標準ベンチマーク
- 直接回帰 vs キーポイント + PnP: 単純さの直接回帰、精度のキーポイントベースという棲み分け。SPEC上位はほぼキーポイント系
- 損失関数: 正規化並進ロスとクォータニオンコサインロスの組み合わせ、対称性ロスによる多義性の解消
- PyTorch実装: ResNet18相当の軽量CNNで簡易合成画像での回帰を体験。誤差ヒストグラムと3D再投影で精度を可視化
- Sim-to-Real対策: ドメインランダマイゼーション、敵対的ドメイン適応、スタイル変換が標準ツールキット
ここで身についた感覚を活かして、次はキーポイントベース手法、ドメイン適応、オンボードAI実装などに進むと、宇宙機の自律GNCに必要な視覚ナビゲーションの全体像がはっきり見えてきます。
次のステップとして、以下の記事も参考にしてください。