衝突回避の経路計画 — RRTとPRMをわかりやすく解説して実装する

宇宙ステーションの外壁には、太陽電池パネル、冷却配管、通信ケーブルなど、無数の構造物が所狭しと配置されています。ロボットアームがこの環境で作業するとき、「腕を伸ばして目標物を掴む」だけでは済みません。途中でパネルの角に衝突したり、ケーブルに引っかかったりすれば、数百億円の機器を破壊し、乗員の安全も脅かすことになります。

では、障害物が存在する環境でロボットの腕を安全に動かすには、どのような方法があるのでしょうか? この問いに答えるのが衝突回避経路計画(collision-free motion planning)です。

衝突回避経路計画を理解すると、以下のような幅広い応用が可能になります。

  • 宇宙ロボットアーム: ISS のロボットアーム(Canadarm2)が外壁の構造物を避けながら貨物を移動させる
  • 自動倉庫ロボット: 棚や他のロボットを回避しながら最短経路で荷物を運搬する
  • 自動運転: 他の車両・歩行者・ガードレールを避けて安全に走行する
  • 手術支援ロボット: 臓器や血管を傷つけずに手術器具を目標位置まで導く

本記事の内容

  • コンフィギュレーション空間(C空間)の概念 — ロボットの形状を「点」に変換する方法
  • C空間障害物の構成 — 物理空間の障害物を関節空間に写像する
  • PRM(確率的ロードマップ法)— 前処理フェーズとクエリフェーズ
  • RRT(高速探索ランダム木)— 増分的に木を伸ばす探索
  • RRT* — 漸近最適性を持つ改良版
  • 双方向RRT(BiRRT)— 始点と終点から同時に木を伸ばす
  • 宇宙環境特有の考慮事項
  • Pythonでの2D空間および関節空間でのRRT・PRMの実装と可視化

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

なぜ「空間の変換」が必要なのか — コンフィギュレーション空間

作業空間での衝突判定の難しさ

経路計画の最初のハードルは、「衝突しているかどうか」をどう判定するかです。直感的には、ロボットの各リンクの形状と障害物の形状の交差判定を行えばよいのですが、ロボットが移動するたびに全リンクの位置・姿勢を順運動学で計算し、その都度交差判定を行うのは計算コストが大きく、また「どの関節角度の組み合わせが安全か」を系統的に探索するには不便です。

ここで登場するのがコンフィギュレーション空間(Configuration Space, C空間)という概念です。

C空間の定義

C空間のアイデアは、ロボットの「姿勢」を一つのとして表現することです。

たとえば、平面上の2リンクロボットアームを考えましょう。このロボットは2つの関節角度 $\theta_1, \theta_2$ で完全に姿勢が決まります。つまり、ロボットのあらゆる姿勢は $(\theta_1, \theta_2)$ という2次元空間上の1点に対応します。この2次元空間がC空間です。

一般に、$n$ 自由度のロボットのC空間は $n$ 次元空間になります。

$$ \mathcal{C} = \{ \bm{q} = (q_1, q_2, \dots, q_n) \mid q_i \in [q_{i,\min}, q_{i,\max}] \} $$

ここで $\bm{q}$ はロボットのコンフィギュレーション(関節角度ベクトル)、$q_i$ は第 $i$ 関節の角度、$[q_{i,\min}, q_{i,\max}]$ は各関節の可動範囲です。

C空間の威力は、ロボットの複雑な形状を「点」に変換できることです。ロボットのリンクが3次元空間でどれだけ複雑な形をしていても、C空間上ではただの1点 $\bm{q}$ として表現されます。経路計画の問題は、C空間上で「始点 $\bm{q}_{\text{start}}$ から終点 $\bm{q}_{\text{goal}}$ まで、障害物を避けて点を移動させる問題」に帰着されます。

C空間の自由領域と障害物領域

C空間は2つの領域に分けられます。

$$ \mathcal{C} = \mathcal{C}_{\text{free}} \cup \mathcal{C}_{\text{obs}} $$

  • $\mathcal{C}_{\text{free}}$(自由空間): ロボットがその姿勢をとったとき、障害物と衝突しない全てのコンフィギュレーションの集合
  • $\mathcal{C}_{\text{obs}}$(C空間障害物): ロボットがその姿勢をとったとき、障害物と衝突するコンフィギュレーションの集合

経路計画の目的は、$\bm{q}_{\text{start}} \in \mathcal{C}_{\text{free}}$ から $\bm{q}_{\text{goal}} \in \mathcal{C}_{\text{free}}$ までの、$\mathcal{C}_{\text{free}}$ 内に完全に含まれる連続経路 $\sigma: [0, 1] \to \mathcal{C}_{\text{free}}$ を見つけることです。

$$ \sigma(0) = \bm{q}_{\text{start}}, \quad \sigma(1) = \bm{q}_{\text{goal}}, \quad \sigma(t) \in \mathcal{C}_{\text{free}} \;\; \forall t \in [0, 1] $$

このように定式化すると、ロボットの自由度や形状に関わらず、「$n$ 次元空間内での障害物回避の経路探索」という統一的な枠組みで扱えるようになります。

しかし、ここで重要な疑問が生まれます。作業空間の障害物は目に見えますが、C空間の障害物 $\mathcal{C}_{\text{obs}}$ はどのようにして構成するのでしょうか?

C空間障害物の構成

物理空間からC空間への写像

C空間障害物の構成は、経路計画の中で最も概念的に重要な部分です。ここでは、「作業空間で目に見える障害物が、C空間ではどのような形になるか」を理解しましょう。

まず最も単純な例として、2次元平面上を並進のみする円形ロボットを考えます。ロボットの半径を $r$、障害物を凸多角形とします。この場合、C空間は2次元の $(x, y)$ 平面そのものです。

ロボットが障害物に衝突する条件は、ロボットの中心から障害物の表面までの距離が $r$ 以下になることです。したがって、C空間障害物は、元の障害物を半径 $r$ だけミンコフスキー和で膨張させたものになります。

$$ \mathcal{C}_{\text{obs}} = \mathcal{O} \oplus \mathcal{B}(r) = \{ \bm{p} + \bm{b} \mid \bm{p} \in \mathcal{O}, \, \bm{b} \in \mathcal{B}(r) \} $$

ここで $\mathcal{O}$ は障害物の領域、$\mathcal{B}(r)$ は原点中心・半径 $r$ の円盤、$\oplus$ はミンコフスキー和を表します。直感的に言えば、障害物の輪郭を全方向に $r$ だけ太らせた領域が、ロボットの中心が入ってはいけない禁止領域です。

回転を含む場合

ロボットが回転も含む場合、C空間障害物の形はさらに複雑になります。たとえば、平面上の棒状ロボット(位置 $(x, y)$ と角度 $\theta$)では、C空間は3次元 $(x, y, \theta)$ です。同じ障害物でも、$\theta$ の値によって「衝突するかどうか」が変わるため、C空間障害物は3次元空間内で複雑にねじれた形状になります。

多関節ロボットの場合

6自由度や7自由度のロボットアームになると、C空間は6次元や7次元になります。C空間障害物を陽に(幾何学的に)構成することは、計算量が指数的に爆発するため事実上不可能です。

ここで重要な方針転換が生まれます。C空間障害物を陽に構成するのではなく、ある点 $\bm{q}$ が $\mathcal{C}_{\text{free}}$ に属するかどうかを判定する関数(衝突判定関数)だけを用意すればよいのです。

$$ \text{CollisionFree}(\bm{q}) = \begin{cases} \text{True} & \text{if } \bm{q} \in \mathcal{C}_{\text{free}} \\ \text{False} & \text{if } \bm{q} \in \mathcal{C}_{\text{obs}} \end{cases} $$

この判定関数は、コンフィギュレーション $\bm{q}$ に対して順運動学でロボットの各リンクの位置を計算し、それらが障害物と交差するかどうかをチェックすることで実現されます。C空間を陽に構成しなくても、「この姿勢は安全か?」を聞けばよいのです。

この発想こそが、次に紹介するサンプリングベース経路計画の基盤です。C空間からランダムに点をサンプリングし、衝突判定関数で安全な点だけを集め、それらをつないで経路を構築するのです。

PRM — 確率的ロードマップ法

PRMの考え方

PRM(Probabilistic Roadmap Method)は、1996年にKavrakiらによって提案された、サンプリングベース経路計画の先駆けとなる手法です。

PRMの考え方は、都市の道路網に例えると分かりやすいでしょう。新しい都市に引っ越したとき、まず主要な交差点と道路を地図で把握しますよね。一度地図を作ってしまえば、どこからどこへ行くにも地図を見て最短ルートを探せます。PRMはまさにこの「道路網の構築」をC空間上で行います。

PRMは前処理フェーズ(ロードマップ構築)とクエリフェーズ(経路検索)の2段階で動作します。

前処理フェーズ — ロードマップの構築

前処理フェーズでは、C空間上に「道路網」(グラフ)を構築します。

ステップ1: ランダムサンプリング

C空間から $N$ 個の点をランダムにサンプリングし、衝突判定関数で自由空間に属するものだけを残します。

$$ V = \{ \bm{q}_i \in \mathcal{C}_{\text{free}} \mid i = 1, 2, \dots, N \} $$

ステップ2: 近傍接続

各サンプル点 $\bm{q}_i$ に対して、一定の距離 $r$ 以内にある他のサンプル点を近傍として見つけます。近傍の探索には $k$-近傍法(距離が近い上位 $k$ 個を選ぶ)または半径 $r$ 内の全点を選ぶ方法があります。

$$ \text{Neighbors}(\bm{q}_i) = \{ \bm{q}_j \in V \mid \| \bm{q}_i – \bm{q}_j \| < r, \, j \neq i \} $$

ステップ3: 局所経路の衝突チェック

$\bm{q}_i$ とその近傍 $\bm{q}_j$ を直線(C空間上の線分)で結び、その線分上の全ての点が $\mathcal{C}_{\text{free}}$ に含まれるかどうかを確認します。実際には、線分を細かく離散化して各点で衝突判定を行います。衝突がなければ、$\bm{q}_i$ と $\bm{q}_j$ の間にエッジを追加します。

$$ E = \{ (\bm{q}_i, \bm{q}_j) \mid \text{LocalPath}(\bm{q}_i, \bm{q}_j) \subset \mathcal{C}_{\text{free}} \} $$

こうして構築されたグラフ $G = (V, E)$ がロードマップです。

クエリフェーズ — 経路の探索

ロードマップが構築されたら、任意の始点 $\bm{q}_{\text{start}}$ と終点 $\bm{q}_{\text{goal}}$ に対して以下の手順で経路を求めます。

  1. $\bm{q}_{\text{start}}$ と $\bm{q}_{\text{goal}}$ をロードマップの最近傍ノードに接続する(衝突チェック付き)
  2. グラフ上でダイクストラ法や $A^*$ 法を使って最短経路を探索する
  3. 見つかった経路(ノード列)を返す

PRMの性質

PRMには理論的に重要な性質があります。

確率的完全性(Probabilistic Completeness): 解が存在するならば、サンプル数 $N \to \infty$ で解が見つかる確率が1に収束します。

$$ \lim_{N \to \infty} P(\text{解が見つかる} \mid \text{解が存在する}) = 1 $$

ただし、有限のサンプル数では解が見つからない可能性があります。また、狭い通路(narrow passage)がある場合、そこにサンプルが落ちる確率が低く、多くのサンプルが必要になるという弱点があります。

PRMの利点と限界

利点: – 一度ロードマップを構築すれば、同じ環境内の異なる始点・終点のクエリに高速に応答できる(マルチクエリ型) – 高次元C空間でも動作する – 実装が比較的シンプル

限界: – 前処理に時間がかかる – 環境が動的に変化する場合、ロードマップの再構築が必要 – 狭い通路の通過が困難

PRMは「事前に地図を作っておく」手法ですが、もし1回だけ経路を見つければよい場合や、環境が動的に変化する場合はどうでしょうか? このような状況に適した手法がRRTです。

RRT — 高速探索ランダム木

RRTの考え方

RRT(Rapidly-exploring Random Tree)は、1998年にLaValleによって提案された手法で、名前のとおり「高速に空間を探索するランダムな木構造」を構築します。

RRTを理解するために、暗い部屋で出口を探す場面を想像してみてください。あなたは部屋の中央に立ち、ランダムな方向に手を伸ばしては壁にぶつからないか確認し、少しずつ探索範囲を広げていきます。PRMが「まず部屋全体の地図を作る」アプローチだとすれば、RRTは「今いる場所から少しずつ手探りで進む」アプローチです。

PRMとの最大の違いは、RRTがシングルクエリ型であることです。始点から木を伸ばし始め、終点に到達した時点で探索を終了します。事前にロードマップを構築する必要がないため、環境が変わっても即座に対応できます。

RRTのアルゴリズム

RRTのアルゴリズムは驚くほどシンプルです。始点 $\bm{q}_{\text{start}}$ を根とする木 $\mathcal{T}$ を初期化し、以下を繰り返します。

ステップ1: ランダムサンプリング

C空間からランダムに1点 $\bm{q}_{\text{rand}}$ をサンプリングします。

ステップ2: 最近傍探索

木 $\mathcal{T}$ の中で、$\bm{q}_{\text{rand}}$ に最も近いノード $\bm{q}_{\text{near}}$ を見つけます。

$$ \bm{q}_{\text{near}} = \underset{\bm{q} \in \mathcal{T}}{\arg\min} \| \bm{q} – \bm{q}_{\text{rand}} \| $$

ステップ3: 拡張(Extend)

$\bm{q}_{\text{near}}$ から $\bm{q}_{\text{rand}}$ の方向にステップ幅 $\delta$ だけ進んだ点 $\bm{q}_{\text{new}}$ を計算します。

$$ \bm{q}_{\text{new}} = \bm{q}_{\text{near}} + \delta \cdot \frac{\bm{q}_{\text{rand}} – \bm{q}_{\text{near}}}{\| \bm{q}_{\text{rand}} – \bm{q}_{\text{near}} \|} $$

$\bm{q}_{\text{rand}}$ が $\bm{q}_{\text{near}}$ からの距離が $\delta$ 以内であれば、$\bm{q}_{\text{new}} = \bm{q}_{\text{rand}}$ とします。

ステップ4: 衝突チェックと追加

$\bm{q}_{\text{near}}$ から $\bm{q}_{\text{new}}$ への局所経路が衝突しないなら、$\bm{q}_{\text{new}}$ を木に追加し、$\bm{q}_{\text{near}} \to \bm{q}_{\text{new}}$ のエッジを追加します。

ステップ5: 終了判定

$\bm{q}_{\text{new}}$ と $\bm{q}_{\text{goal}}$ の距離がしきい値 $\epsilon$ 以下なら、$\bm{q}_{\text{goal}}$ を木に接続して終了します。

ゴールバイアス

純粋にランダムなサンプリングだけでは、ゴールに向かう方向性がなく、収束が遅くなることがあります。そこで実用上は、一定の確率 $p_{\text{goal}}$(典型的には5〜10%)でゴール自体をサンプル点として選ぶゴールバイアスを導入します。

$$ \bm{q}_{\text{rand}} = \begin{cases} \bm{q}_{\text{goal}} & \text{確率 } p_{\text{goal}} \\ \text{UniformRandom}(\mathcal{C}) & \text{確率 } 1 – p_{\text{goal}} \end{cases} $$

これにより、木はゴール方向にも偏りを持って成長し、探索の効率が大幅に改善されます。

RRTの空間充填性

RRTの最も重要な理論的性質は、空間充填性(space-filling property)です。木がまだ疎な領域のノードほど、ボロノイ領域が大きく、ランダムサンプルがその近傍に落ちる確率が高くなります。したがって、木は自動的に「探索が足りない方向」に優先的に伸びていきます。

ボロノイ領域とは、空間中の各ノードに対して「そのノードが最近傍であるような点の集合」です。木の端にあるノードはボロノイ領域が大きいため、サンプルが選ばれやすく、結果として木は空間をまんべんなく探索します。

この性質のおかげで、RRTは高次元空間でも効率的に動作し、確率的完全性を持ちます。

RRTは経路を見つけることには優れていますが、見つかった経路は最適(最短)とは限りません。ランダムなサンプリングに基づくため、経路はジグザグで無駄な回り道を含むことが多いです。この問題を解決するのがRRT*です。

RRT* — 漸近最適性を持つ改良版

RRTの限界 — 最適性の欠如

RRTが見つける経路は、一般に最適経路とは程遠いものです。最初に見つかった経路はランダムサンプリングの影響でジグザグになり、サンプル数を増やしても経路の質は改善されません。この問題は理論的にも証明されており、標準的なRRTは漸近最適でない(asymptotically suboptimal)ことが知られています。

RRT*のアイデア — 再配線

2011年にKaramanとFrazzoli が提案したRRT*(RRT-star)は、RRTに2つの操作を追加することで漸近最適性を実現します。

操作1: 近傍からの最良親の選択(ChooseParent)

新しいノード $\bm{q}_{\text{new}}$ を木に追加するとき、単に最近傍ノードを親にするのではなく、一定半径 $r_n$ 内の全近傍ノードを調べ、$\bm{q}_{\text{start}}$ からのコスト(経路長)が最小になる親を選びます。

近傍の探索半径は、ノード数 $n$ に応じて以下のように設定します。

$$ r_n = \gamma \left( \frac{\log n}{n} \right)^{1/d} $$

ここで $d$ はC空間の次元数、$\gamma$ は $\mathcal{C}_{\text{free}}$ の体積とC空間の次元に依存する定数です。この半径はノード数が増えるにつれて縮小しますが、$\log n / n$ の形で縮小するため、漸近最適性の証明に必要な十分な接続性を保ちます。

$\bm{q}_{\text{new}}$ の親 $\bm{q}_{\text{parent}}$ は、近傍ノードの中からコスト最小のものを選びます。

$$ \bm{q}_{\text{parent}} = \underset{\bm{q} \in \text{Near}(\bm{q}_{\text{new}}, r_n)}{\arg\min} \left[ \text{Cost}(\bm{q}) + c(\bm{q}, \bm{q}_{\text{new}}) \right] $$

ここで $\text{Cost}(\bm{q})$ はルートから $\bm{q}$ までの累積コスト、$c(\bm{q}, \bm{q}_{\text{new}})$ は $\bm{q}$ から $\bm{q}_{\text{new}}$ への局所経路のコスト(距離)です。

操作2: 再配線(Rewire)

$\bm{q}_{\text{new}}$ を木に追加した後、近傍ノードに対して「$\bm{q}_{\text{new}}$ を経由した方がコストが下がるか」をチェックします。下がるなら、そのノードの親を $\bm{q}_{\text{new}}$ に変更(再配線)します。

近傍ノード $\bm{q}_{\text{near}}$ に対して、再配線の条件は以下です。

$$ \text{Cost}(\bm{q}_{\text{new}}) + c(\bm{q}_{\text{new}}, \bm{q}_{\text{near}}) < \text{Cost}(\bm{q}_{\text{near}}) $$

この条件が成り立ち、かつ $\bm{q}_{\text{new}}$ から $\bm{q}_{\text{near}}$ への局所経路が衝突しないなら、$\bm{q}_{\text{near}}$ の親を $\bm{q}_{\text{new}}$ に変更します。

漸近最適性の定理

RRTの最も重要な理論的保証は、漸近最適性*(asymptotic optimality)です。

$$ \lim_{n \to \infty} P\left( \text{Cost}(\sigma_n) \leq (1 + \epsilon) \cdot \text{Cost}(\sigma^*) \right) = 1 \quad \forall \epsilon > 0 $$

ここで $\sigma_n$ は $n$ サンプル後にRRTが見つけた最良経路、$\sigma^*$ は真の最適経路です。つまり、サンプル数を増やせば増やすほど、RRTの経路は最適経路に確率1で収束します。

この性質は実用上非常に重要です。時間制約がある場合は途中で打ち切って「そこそこ良い経路」を得ることもでき、時間をかければより最適に近い経路が得られるというanytime性を持っています。

RRT*は漸近最適性を持つ優れた手法ですが、収束が遅い場合があります。特に、始点とゴールが遠い場合に探索の効率を高める手法が双方向RRTです。

双方向RRT(BiRRT)

なぜ双方向にするのか

標準のRRTでは、始点から木を伸ばしてゴールに到達するまで探索します。しかし、始点とゴールが遠い場合や、C空間に狭い通路がある場合、一方向からの探索では非常に多くのイテレーションが必要になります。

双方向RRT(Bidirectional RRT, BiRRT)は、始点と終点の両方から同時に木を伸ばし、2つの木が出会ったら経路を接続するという手法です。2人の探検家が洞窟の両端から入って中で出会う、というイメージです。

BiRRTのアルゴリズム

始点を根とする木 $\mathcal{T}_a$ と終点を根とする木 $\mathcal{T}_b$ を初期化し、以下を交互に繰り返します。

ステップ1: ランダムサンプリングと拡張

C空間からランダムに $\bm{q}_{\text{rand}}$ をサンプリングし、$\mathcal{T}_a$ を $\bm{q}_{\text{rand}}$ の方向に拡張して $\bm{q}_{\text{new}}$ を得ます。

ステップ2: もう一方の木の接続試行

$\bm{q}_{\text{new}}$ に向かって $\mathcal{T}_b$ を接続(Connect)します。Connectは、Extendを繰り返して $\bm{q}_{\text{new}}$ に到達するか障害物にぶつかるまで木を伸ばす操作です。

ステップ3: 接続判定と木の交換

$\mathcal{T}_b$ が $\bm{q}_{\text{new}}$ に到達できたら、2つの木が接続され、経路が見つかります。到達できなかった場合は、$\mathcal{T}_a$ と $\mathcal{T}_b$ の役割を交換して次のイテレーションに進みます。

BiRRTの効率

BiRRTはRRTに比べて以下の点で効率的です。

  • 探索の高速化: 2つの木が両端から空間を探索するため、合流が早い
  • 狭い通路の通過: 両方向から通路に向かって木が伸びるため、片方向よりも通過確率が高い
  • 実用的な性能: 多くのベンチマーク問題でRRTより数倍〜数十倍高速

役割の交換(swap)により、各イテレーションでノード数が少ない方の木が拡張側になるため、最近傍探索のコストが平均的に抑えられるという副次的なメリットもあります。

ここまでで、PRM、RRT、RRT*、BiRRTという4つの代表的なサンプリングベース手法を学びました。次に、これらの手法を宇宙環境で使う際の特有の課題を見ていきましょう。

宇宙環境特有の考慮事項

微小重力下での動力学制約

地上のロボットアームは重力の影響を常に受けますが、宇宙ステーション上のロボットアームは微小重力環境で動作します。これは一見すると経路計画を容易にするように思えますが、実際にはユニークな課題を生みます。

反力の問題: 微小重力環境では、ロボットアームが動くとその反力が宇宙ステーション本体に伝わり、姿勢を乱す可能性があります。大質量の物体を急に動かすと、宇宙ステーション全体が回転し始めます。したがって、経路計画では単に衝突を避けるだけでなく、動作中の反力トルクを最小化することも求められます。

動的障害物

宇宙ステーションでは、太陽電池パネルが太陽追尾のためにゆっくり回転し、冷却ラジエータが展開・収納される場合があります。また、EVA(船外活動)中の宇宙飛行士も動的障害物です。

動的障害物に対しては、C空間に時間軸を追加したCT空間(Configuration-Time Space)での計画が有効です。

$$ \mathcal{CT} = \mathcal{C} \times [0, T] $$

$\mathcal{CT}$ 空間では、障害物は時間とともにC空間内を移動する「トンネル」のような形状になります。経路計画は、このトンネルを避けながら $(q_{\text{start}}, t_0)$ から $(q_{\text{goal}}, t_f)$ まで到達する経路を探すことになります。

ケーブルなどの柔軟体

宇宙ステーションの外壁には多数のケーブルやホースが配置されています。これらは剛体ではなく柔軟体であるため、ロボットアームの動きに応じて変形します。柔軟体の障害物を扱うには、以下の方法が考えられます。

  • 保守的なバウンディングボリューム: ケーブルの可動範囲を大きめの球や円柱で囲い、C空間障害物を保守的に設定する
  • オンライン再計画: センサでケーブルの現在位置を検出し、RRTを短い区間で繰り返し実行する
  • 柔軟体シミュレーション: FEMなどで柔軟体の変形をシミュレーションし、衝突判定に組み込む(計算コスト大)

通信遅延と自律性

地球からの遠隔操作には通信遅延が伴います。LEO(低軌道)でも数百ミリ秒、火星では片道4〜24分の遅延があります。このため、リアルタイムの操作が困難であり、ロボット自身が自律的に衝突回避経路を計画・実行する能力が求められます。

宇宙用の経路計画アルゴリズムには、計算リソースが限られたオンボードコンピュータで動作する必要があるという制約があります。RRTは反復1回あたりの計算量が軽く、途中で打ち切っても有効な経路(始点から現在のノードまでの部分経路)が得られるため、宇宙用途に適した特性を持っています。

では、ここまでの理論をPythonで実装し、視覚的に理解を深めましょう。まずは2D空間での基本実装から始め、その後、関節空間での実装に発展させます。

Pythonで2D空間のRRTを実装する

問題設定

まずは最もシンプルなケースとして、2次元平面上で点ロボットが矩形障害物を避けて始点から終点まで移動する問題を実装します。この例ではC空間と作業空間が一致するため、アルゴリズムの本質に集中できます。

以下のコードでは、障害物を定義するヘルパー関数と衝突判定関数を実装します。

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
from collections import defaultdict

# --- 環境設定 ---
class Environment2D:
    """2D平面上の障害物環境"""
    def __init__(self, x_range, y_range, obstacles):
        """
        x_range: (xmin, xmax)
        y_range: (ymin, ymax)
        obstacles: [(x, y, w, h), ...] 矩形障害物のリスト
        """
        self.x_range = x_range
        self.y_range = y_range
        self.obstacles = obstacles  # (x, y, width, height)

    def is_collision_free(self, point):
        """点が障害物と衝突しないか判定"""
        x, y = point
        if x < self.x_range[0] or x > self.x_range[1]:
            return False
        if y < self.y_range[0] or y > self.y_range[1]:
            return False
        for (ox, oy, ow, oh) in self.obstacles:
            if ox <= x <= ox + ow and oy <= y <= oy + oh:
                return False
        return True

    def is_edge_collision_free(self, p1, p2, resolution=0.5):
        """2点間の直線経路が衝突しないか判定(離散化チェック)"""
        dist = np.linalg.norm(np.array(p2) - np.array(p1))
        n_checks = max(int(dist / resolution), 2)
        for i in range(n_checks + 1):
            t = i / n_checks
            point = (1 - t) * np.array(p1) + t * np.array(p2)
            if not self.is_collision_free(point):
                return False
        return True

# 環境の作成
obstacles = [
    (20, 20, 10, 30),   # 障害物1
    (50, 10, 10, 40),   # 障害物2
    (35, 60, 25, 8),    # 障害物3
    (70, 30, 8, 35),    # 障害物4
    (10, 70, 30, 5),    # 障害物5
]
env = Environment2D((0, 100), (0, 100), obstacles)
start = np.array([5.0, 5.0])
goal = np.array([95.0, 95.0])

print(f"環境サイズ: {env.x_range} x {env.y_range}")
print(f"障害物数: {len(env.obstacles)}")
print(f"始点: {start}, 終点: {goal}")
print(f"始点は衝突フリー: {env.is_collision_free(start)}")
print(f"終点は衝突フリー: {env.is_collision_free(goal)}")

このコードでは、Environment2D クラスが2D環境を管理します。is_collision_free は点が障害物内にないかを判定し、is_edge_collision_free は2点間の線分を細かく離散化して全点の衝突判定を行います。離散化の解像度 resolution を小さくするほど判定の精度が上がりますが、計算量も増加します。

次に、RRTアルゴリズム本体を実装します。

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle

class RRT:
    """基本的なRRTアルゴリズム"""
    def __init__(self, env, start, goal, step_size=5.0,
                 goal_bias=0.1, max_iter=3000, goal_threshold=5.0):
        self.env = env
        self.start = np.array(start)
        self.goal = np.array(goal)
        self.step_size = step_size
        self.goal_bias = goal_bias
        self.max_iter = max_iter
        self.goal_threshold = goal_threshold

        # 木構造: nodes[i] = 座標, parent[i] = 親ノードのインデックス
        self.nodes = [self.start.copy()]
        self.parent = [-1]  # ルートノードの親は-1

    def random_sample(self):
        """ランダムサンプリング(ゴールバイアス付き)"""
        if np.random.random() < self.goal_bias:
            return self.goal.copy()
        x = np.random.uniform(self.env.x_range[0], self.env.x_range[1])
        y = np.random.uniform(self.env.y_range[0], self.env.y_range[1])
        return np.array([x, y])

    def nearest_node(self, point):
        """木の中で最近傍ノードのインデックスを返す"""
        dists = [np.linalg.norm(node - point) for node in self.nodes]
        return int(np.argmin(dists))

    def steer(self, from_node, to_point):
        """from_nodeからto_pointの方向にstep_sizeだけ進んだ点を返す"""
        direction = to_point - from_node
        dist = np.linalg.norm(direction)
        if dist <= self.step_size:
            return to_point.copy()
        return from_node + self.step_size * direction / dist

    def plan(self):
        """RRTで経路計画を実行"""
        for i in range(self.max_iter):
            # ステップ1: ランダムサンプリング
            q_rand = self.random_sample()

            # ステップ2: 最近傍探索
            nearest_idx = self.nearest_node(q_rand)
            q_near = self.nodes[nearest_idx]

            # ステップ3: 拡張
            q_new = self.steer(q_near, q_rand)

            # ステップ4: 衝突チェックと追加
            if self.env.is_edge_collision_free(q_near, q_new):
                self.nodes.append(q_new)
                self.parent.append(nearest_idx)

                # ステップ5: 終了判定
                if np.linalg.norm(q_new - self.goal) < self.goal_threshold:
                    # ゴールへの接続
                    if self.env.is_edge_collision_free(q_new, self.goal):
                        self.nodes.append(self.goal.copy())
                        self.parent.append(len(self.nodes) - 2)
                        path = self._extract_path(len(self.nodes) - 1)
                        print(f"経路発見! イテレーション: {i+1}, "
                              f"ノード数: {len(self.nodes)}")
                        return path
        print("経路が見つかりませんでした")
        return None

    def _extract_path(self, goal_idx):
        """ゴールから始点まで親をたどって経路を抽出"""
        path = []
        idx = goal_idx
        while idx != -1:
            path.append(self.nodes[idx])
            idx = self.parent[idx]
        path.reverse()
        return path

# RRTの実行
np.random.seed(42)
obstacles = [
    (20, 20, 10, 30),
    (50, 10, 10, 40),
    (35, 60, 25, 8),
    (70, 30, 8, 35),
    (10, 70, 30, 5),
]
env = Environment2D((0, 100), (0, 100), obstacles)
start = np.array([5.0, 5.0])
goal = np.array([95.0, 95.0])

rrt = RRT(env, start, goal, step_size=5.0, goal_bias=0.1, max_iter=3000)
path = rrt.plan()

# 可視化
fig, ax = plt.subplots(1, 1, figsize=(8, 8))
for (ox, oy, ow, oh) in env.obstacles:
    ax.add_patch(Rectangle((ox, oy), ow, oh,
                            facecolor='gray', edgecolor='black', alpha=0.7))
# 木の描画
for i in range(1, len(rrt.nodes)):
    parent_idx = rrt.parent[i]
    p1 = rrt.nodes[parent_idx]
    p2 = rrt.nodes[i]
    ax.plot([p1[0], p2[0]], [p1[1], p2[1]], 'c-', linewidth=0.3, alpha=0.5)

# 経路の描画
if path is not None:
    path_arr = np.array(path)
    ax.plot(path_arr[:, 0], path_arr[:, 1], 'r-', linewidth=2.5, label='RRT Path')

ax.plot(*start, 'go', markersize=12, label='Start')
ax.plot(*goal, 'r*', markersize=15, label='Goal')
ax.set_xlim(env.x_range)
ax.set_ylim(env.y_range)
ax.set_aspect('equal')
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_title('RRT Path Planning in 2D')
ax.legend(loc='upper left')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

上のグラフには、水色の細い線で描かれたRRTの木構造と、赤い太線で描かれた経路が表示されます。RRTの木は始点から放射状に広がり、空間を探索しながらゴールに向かって伸びていく様子がわかります。経路はジグザグしており、最短経路とは言えません。障害物(灰色の矩形)を避けるルートが自動的に発見されていることが確認できます。また、木はゴールバイアスの効果でゴール方向にもある程度集中して伸びていますが、空間全体にまんべんなく広がる空間充填性も見て取れます。

Pythonで2D空間のPRMを実装する

次に、同じ環境でPRMを実装します。PRMではまずロードマップを構築し、その後クエリに応答します。

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
import heapq

class PRM:
    """確率的ロードマップ法(PRM)"""
    def __init__(self, env, n_samples=300, k_neighbors=10):
        self.env = env
        self.n_samples = n_samples
        self.k_neighbors = k_neighbors
        self.nodes = []
        self.edges = {}  # {node_idx: [(neighbor_idx, distance), ...]}

    def build_roadmap(self):
        """前処理フェーズ: ロードマップの構築"""
        # ステップ1: ランダムサンプリング
        samples = []
        while len(samples) < self.n_samples:
            x = np.random.uniform(self.env.x_range[0], self.env.x_range[1])
            y = np.random.uniform(self.env.y_range[0], self.env.y_range[1])
            point = np.array([x, y])
            if self.env.is_collision_free(point):
                samples.append(point)
        self.nodes = samples
        self.edges = {i: [] for i in range(len(self.nodes))}

        # ステップ2&3: 近傍接続と衝突チェック
        n_edges = 0
        for i in range(len(self.nodes)):
            # k近傍を計算
            dists = [(j, np.linalg.norm(self.nodes[i] - self.nodes[j]))
                     for j in range(len(self.nodes)) if j != i]
            dists.sort(key=lambda x: x[1])
            neighbors = dists[:self.k_neighbors]

            for j, d in neighbors:
                if self.env.is_edge_collision_free(self.nodes[i], self.nodes[j]):
                    # 双方向エッジ
                    if (j, d) not in self.edges[i]:
                        self.edges[i].append((j, d))
                        self.edges[j].append((i, d))
                        n_edges += 1

        print(f"ロードマップ構築完了: ノード数={len(self.nodes)}, "
              f"エッジ数={n_edges}")

    def query(self, start, goal):
        """クエリフェーズ: 始点と終点を接続して経路を検索"""
        # 始点と終点をロードマップに追加
        start = np.array(start)
        goal = np.array(goal)
        start_idx = len(self.nodes)
        goal_idx = len(self.nodes) + 1
        self.nodes.append(start)
        self.nodes.append(goal)
        self.edges[start_idx] = []
        self.edges[goal_idx] = []

        # 始点・終点を近傍ノードに接続
        for idx in [start_idx, goal_idx]:
            dists = [(j, np.linalg.norm(self.nodes[idx] - self.nodes[j]))
                     for j in range(len(self.nodes) - 2)]
            dists.sort(key=lambda x: x[1])
            for j, d in dists[:self.k_neighbors]:
                if self.env.is_edge_collision_free(self.nodes[idx],
                                                   self.nodes[j]):
                    self.edges[idx].append((j, d))
                    self.edges[j].append((idx, d))

        # ダイクストラ法で最短経路を探索
        path = self._dijkstra(start_idx, goal_idx)

        # 追加したノードとエッジを削除(ロードマップを元に戻す)
        for idx in [start_idx, goal_idx]:
            for j, d in self.edges[idx]:
                self.edges[j] = [(k, dd) for k, dd in self.edges[j]
                                 if k != idx]
            del self.edges[idx]
        self.nodes = self.nodes[:-2]

        return path

    def _dijkstra(self, start_idx, goal_idx):
        """ダイクストラ法で最短経路を探索"""
        dist = {start_idx: 0}
        prev = {start_idx: None}
        pq = [(0, start_idx)]

        while pq:
            d, u = heapq.heappop(pq)
            if u == goal_idx:
                # 経路を復元
                path = []
                node = goal_idx
                while node is not None:
                    path.append(self.nodes[node])
                    node = prev[node]
                path.reverse()
                return path
            if d > dist.get(u, float('inf')):
                continue
            for v, w in self.edges.get(u, []):
                new_dist = d + w
                if new_dist < dist.get(v, float('inf')):
                    dist[v] = new_dist
                    prev[v] = u
                    heapq.heappush(pq, (new_dist, v))

        print("PRM: 経路が見つかりませんでした")
        return None

# PRMの実行
np.random.seed(42)
obstacles = [
    (20, 20, 10, 30),
    (50, 10, 10, 40),
    (35, 60, 25, 8),
    (70, 30, 8, 35),
    (10, 70, 30, 5),
]
env = Environment2D((0, 100), (0, 100), obstacles)
start = np.array([5.0, 5.0])
goal = np.array([95.0, 95.0])

prm = PRM(env, n_samples=300, k_neighbors=10)
prm.build_roadmap()
path_prm = prm.query(start, goal)

# 可視化
fig, ax = plt.subplots(1, 1, figsize=(8, 8))
for (ox, oy, ow, oh) in env.obstacles:
    ax.add_patch(Rectangle((ox, oy), ow, oh,
                            facecolor='gray', edgecolor='black', alpha=0.7))
# ロードマップの描画
for i, neighbors in prm.edges.items():
    for j, d in neighbors:
        if j > i:
            p1, p2 = prm.nodes[i], prm.nodes[j]
            ax.plot([p1[0], p2[0]], [p1[1], p2[1]],
                    'c-', linewidth=0.3, alpha=0.4)
# サンプル点の描画
for node in prm.nodes:
    ax.plot(node[0], node[1], 'c.', markersize=2)

# 経路の描画
if path_prm is not None:
    path_arr = np.array(path_prm)
    ax.plot(path_arr[:, 0], path_arr[:, 1], 'r-',
            linewidth=2.5, label='PRM Path')
    print(f"PRM経路長: {sum(np.linalg.norm(path_arr[i+1]-path_arr[i]) for i in range(len(path_arr)-1)):.1f}")

ax.plot(*start, 'go', markersize=12, label='Start')
ax.plot(*goal, 'r*', markersize=15, label='Goal')
ax.set_xlim(env.x_range)
ax.set_ylim(env.y_range)
ax.set_aspect('equal')
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_title('PRM Path Planning in 2D')
ax.legend(loc='upper left')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

PRMの可視化では、水色の細い線でロードマップ(グラフ構造)全体が描かれ、赤い太線でクエリに応答した最短経路が表示されます。RRTの結果と比較すると、PRMの経路はダイクストラ法による最短経路探索のおかげで、RRTよりも短くスムーズな経路になっていることが確認できます。ロードマップは環境全体に均等に広がっており、一度構築すれば異なる始点・終点に対しても高速に応答できるというPRMの利点が視覚的にわかります。一方で、障害物間の狭い通路(たとえば障害物1と障害物5の間)にはサンプル点が少なく、通路を通る経路が見つかりにくいことも確認できます。

RRT*の実装と経路品質の改善

RRT*を実装して、標準的なRRTとの経路品質の違いを確認しましょう。

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle

class RRTStar:
    """RRT*アルゴリズム(漸近最適性を持つ改良版RRT)"""
    def __init__(self, env, start, goal, step_size=5.0,
                 goal_bias=0.1, max_iter=3000, goal_threshold=5.0,
                 gamma=50.0):
        self.env = env
        self.start = np.array(start)
        self.goal = np.array(goal)
        self.step_size = step_size
        self.goal_bias = goal_bias
        self.max_iter = max_iter
        self.goal_threshold = goal_threshold
        self.gamma = gamma
        self.dim = 2

        self.nodes = [self.start.copy()]
        self.parent = [-1]
        self.cost = [0.0]  # ルートからの累積コスト

    def random_sample(self):
        if np.random.random() < self.goal_bias:
            return self.goal.copy()
        x = np.random.uniform(self.env.x_range[0], self.env.x_range[1])
        y = np.random.uniform(self.env.y_range[0], self.env.y_range[1])
        return np.array([x, y])

    def nearest_node(self, point):
        dists = [np.linalg.norm(node - point) for node in self.nodes]
        return int(np.argmin(dists))

    def steer(self, from_node, to_point):
        direction = to_point - from_node
        dist = np.linalg.norm(direction)
        if dist <= self.step_size:
            return to_point.copy()
        return from_node + self.step_size * direction / dist

    def near_nodes(self, point, n):
        """半径r_n内の近傍ノードのインデックスを返す"""
        r_n = min(self.gamma * (np.log(n) / n) ** (1 / self.dim),
                  self.step_size * 3)
        indices = []
        for i, node in enumerate(self.nodes):
            if np.linalg.norm(node - point) < r_n:
                indices.append(i)
        return indices

    def plan(self):
        """RRT*で経路計画を実行"""
        best_goal_idx = None
        best_goal_cost = float('inf')

        for iteration in range(self.max_iter):
            q_rand = self.random_sample()
            nearest_idx = self.nearest_node(q_rand)
            q_near = self.nodes[nearest_idx]
            q_new = self.steer(q_near, q_rand)

            if not self.env.is_edge_collision_free(q_near, q_new):
                continue

            # --- RRT*の追加処理 ---
            new_idx = len(self.nodes)
            n = len(self.nodes)

            # ChooseParent: 近傍からコスト最小の親を選択
            near_indices = self.near_nodes(q_new, n)
            best_parent = nearest_idx
            best_cost = (self.cost[nearest_idx]
                         + np.linalg.norm(q_new - q_near))

            for ni in near_indices:
                edge_cost = np.linalg.norm(q_new - self.nodes[ni])
                candidate_cost = self.cost[ni] + edge_cost
                if (candidate_cost < best_cost
                    and self.env.is_edge_collision_free(
                        self.nodes[ni], q_new)):
                    best_parent = ni
                    best_cost = candidate_cost

            self.nodes.append(q_new)
            self.parent.append(best_parent)
            self.cost.append(best_cost)

            # Rewire: 近傍ノードの親を再配線
            for ni in near_indices:
                edge_cost = np.linalg.norm(q_new - self.nodes[ni])
                new_cost = self.cost[new_idx] + edge_cost
                if (new_cost < self.cost[ni]
                    and self.env.is_edge_collision_free(
                        q_new, self.nodes[ni])):
                    self.parent[ni] = new_idx
                    self._propagate_cost(ni)

            # ゴール到達判定
            if np.linalg.norm(q_new - self.goal) < self.goal_threshold:
                if self.env.is_edge_collision_free(q_new, self.goal):
                    goal_cost = (self.cost[new_idx]
                                 + np.linalg.norm(q_new - self.goal))
                    if goal_cost < best_goal_cost:
                        best_goal_cost = goal_cost
                        best_goal_idx = new_idx

        if best_goal_idx is not None:
            # ゴールノードを追加
            self.nodes.append(self.goal.copy())
            self.parent.append(best_goal_idx)
            self.cost.append(best_goal_cost)
            path = self._extract_path(len(self.nodes) - 1)
            print(f"RRT* 経路発見! コスト: {best_goal_cost:.1f}, "
                  f"ノード数: {len(self.nodes)}")
            return path

        print("RRT*: 経路が見つかりませんでした")
        return None

    def _propagate_cost(self, idx):
        """再配線後のコストを子孫に伝播"""
        parent_idx = self.parent[idx]
        self.cost[idx] = (self.cost[parent_idx]
                          + np.linalg.norm(self.nodes[idx]
                                           - self.nodes[parent_idx]))
        for i in range(len(self.nodes)):
            if self.parent[i] == idx:
                self._propagate_cost(i)

    def _extract_path(self, goal_idx):
        path = []
        idx = goal_idx
        while idx != -1:
            path.append(self.nodes[idx])
            idx = self.parent[idx]
        path.reverse()
        return path

# RRTとRRT*の比較実行
np.random.seed(42)
obstacles = [
    (20, 20, 10, 30),
    (50, 10, 10, 40),
    (35, 60, 25, 8),
    (70, 30, 8, 35),
    (10, 70, 30, 5),
]
env = Environment2D((0, 100), (0, 100), obstacles)
start = [5.0, 5.0]
goal = [95.0, 95.0]

rrt = RRT(env, start, goal, step_size=5.0, goal_bias=0.1, max_iter=3000)
path_rrt = rrt.plan()

np.random.seed(42)
rrt_star = RRTStar(env, start, goal, step_size=5.0, goal_bias=0.1,
                   max_iter=3000, gamma=50.0)
path_rrt_star = rrt_star.plan()

# 比較可視化
fig, axes = plt.subplots(1, 2, figsize=(16, 8))

for ax, planner, path, title in [
    (axes[0], rrt, path_rrt, 'RRT'),
    (axes[1], rrt_star, path_rrt_star, 'RRT*')
]:
    for (ox, oy, ow, oh) in env.obstacles:
        ax.add_patch(Rectangle((ox, oy), ow, oh,
                                facecolor='gray', edgecolor='black',
                                alpha=0.7))
    for i in range(1, len(planner.nodes)):
        parent_idx = planner.parent[i]
        p1 = planner.nodes[parent_idx]
        p2 = planner.nodes[i]
        ax.plot([p1[0], p2[0]], [p1[1], p2[1]],
                'c-', linewidth=0.3, alpha=0.4)

    if path is not None:
        path_arr = np.array(path)
        path_length = sum(np.linalg.norm(path_arr[i+1] - path_arr[i])
                          for i in range(len(path_arr) - 1))
        ax.plot(path_arr[:, 0], path_arr[:, 1], 'r-', linewidth=2.5,
                label=f'Path (length={path_length:.1f})')

    ax.plot(*start, 'go', markersize=12, label='Start')
    ax.plot(*goal, 'r*', markersize=15, label='Goal')
    ax.set_xlim(env.x_range)
    ax.set_ylim(env.y_range)
    ax.set_aspect('equal')
    ax.set_xlabel('x')
    ax.set_ylabel('y')
    ax.set_title(title)
    ax.legend(loc='upper left')
    ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

左右のグラフを比較すると、RRTの経路(右図)はRRTの経路(左図)よりも明らかに短く滑らかであることがわかります。RRTの経路はサンプリングの順番に依存してジグザグになりますが、RRTは再配線(Rewire)の操作により、経路が漸近的に最適化されています。特に障害物を迂回する部分で、RRTはよりタイトなカーブで障害物の角を回るルートを見つけている点に注目してください。また、木構造自体を比較すると、RRTの木はRRTの木よりも「整理された」構造をしていることがわかります。これは、再配線により不要な回り道が常に修正されているためです。

BiRRTの実装

始点と終点の双方から木を伸ばすBiRRTを実装し、収束速度を比較します。

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle

class BiRRT:
    """双方向RRT(BiRRT)"""
    def __init__(self, env, start, goal, step_size=5.0,
                 max_iter=3000, connect_dist=5.0):
        self.env = env
        self.start = np.array(start)
        self.goal = np.array(goal)
        self.step_size = step_size
        self.max_iter = max_iter
        self.connect_dist = connect_dist

        # 始点側の木
        self.tree_a_nodes = [self.start.copy()]
        self.tree_a_parent = [-1]
        # 終点側の木
        self.tree_b_nodes = [self.goal.copy()]
        self.tree_b_parent = [-1]

    def nearest_node(self, nodes, point):
        dists = [np.linalg.norm(node - point) for node in nodes]
        return int(np.argmin(dists))

    def steer(self, from_node, to_point):
        direction = to_point - from_node
        dist = np.linalg.norm(direction)
        if dist <= self.step_size:
            return to_point.copy()
        return from_node + self.step_size * direction / dist

    def extend(self, nodes, parent, q_rand):
        """木を1ステップ拡張"""
        nearest_idx = self.nearest_node(nodes, q_rand)
        q_near = nodes[nearest_idx]
        q_new = self.steer(q_near, q_rand)
        if self.env.is_edge_collision_free(q_near, q_new):
            nodes.append(q_new)
            parent.append(nearest_idx)
            return len(nodes) - 1, q_new
        return None, None

    def connect(self, nodes, parent, q_target):
        """q_targetに到達するまで木を伸ばし続ける"""
        while True:
            nearest_idx = self.nearest_node(nodes, q_target)
            q_near = nodes[nearest_idx]
            q_new = self.steer(q_near, q_target)
            if not self.env.is_edge_collision_free(q_near, q_new):
                return False
            nodes.append(q_new)
            parent.append(nearest_idx)
            if np.linalg.norm(q_new - q_target) < self.connect_dist:
                return True

    def plan(self):
        """BiRRTで経路計画を実行"""
        tree_a_nodes = self.tree_a_nodes
        tree_a_parent = self.tree_a_parent
        tree_b_nodes = self.tree_b_nodes
        tree_b_parent = self.tree_b_parent

        for i in range(self.max_iter):
            # ランダムサンプリング
            x = np.random.uniform(self.env.x_range[0], self.env.x_range[1])
            y = np.random.uniform(self.env.y_range[0], self.env.y_range[1])
            q_rand = np.array([x, y])

            # 木Aを拡張
            new_idx, q_new = self.extend(tree_a_nodes, tree_a_parent,
                                         q_rand)
            if q_new is not None:
                # 木Bをq_newに向かって接続試行
                if self.connect(tree_b_nodes, tree_b_parent, q_new):
                    # 接続成功 — 経路を構築
                    path_a = self._extract_path_forward(
                        tree_a_nodes, tree_a_parent, new_idx)
                    path_b = self._extract_path_forward(
                        tree_b_nodes, tree_b_parent,
                        len(tree_b_nodes) - 1)
                    path_b.reverse()
                    path = path_a + path_b
                    print(f"BiRRT 経路発見! イテレーション: {i+1}")
                    return path

            # 木の役割を交換
            tree_a_nodes, tree_b_nodes = tree_b_nodes, tree_a_nodes
            tree_a_parent, tree_b_parent = tree_b_parent, tree_a_parent

        print("BiRRT: 経路が見つかりませんでした")
        return None

    def _extract_path_forward(self, nodes, parent, idx):
        """指定インデックスからルートまで経路を抽出"""
        path = []
        while idx != -1:
            path.append(nodes[idx])
            idx = parent[idx]
        path.reverse()
        return path

# BiRRTの実行
np.random.seed(42)
obstacles = [
    (20, 20, 10, 30),
    (50, 10, 10, 40),
    (35, 60, 25, 8),
    (70, 30, 8, 35),
    (10, 70, 30, 5),
]
env = Environment2D((0, 100), (0, 100), obstacles)
start = [5.0, 5.0]
goal = [95.0, 95.0]

birrt = BiRRT(env, start, goal, step_size=5.0, max_iter=3000)
path_birrt = birrt.plan()

# 可視化
fig, ax = plt.subplots(1, 1, figsize=(8, 8))
for (ox, oy, ow, oh) in env.obstacles:
    ax.add_patch(Rectangle((ox, oy), ow, oh,
                            facecolor='gray', edgecolor='black', alpha=0.7))

# 木Aの描画(青系)
for i in range(1, len(birrt.tree_a_nodes)):
    pi = birrt.tree_a_parent[i]
    p1 = birrt.tree_a_nodes[pi]
    p2 = birrt.tree_a_nodes[i]
    ax.plot([p1[0], p2[0]], [p1[1], p2[1]], 'b-',
            linewidth=0.3, alpha=0.5)

# 木Bの描画(緑系)
for i in range(1, len(birrt.tree_b_nodes)):
    pi = birrt.tree_b_parent[i]
    p1 = birrt.tree_b_nodes[pi]
    p2 = birrt.tree_b_nodes[i]
    ax.plot([p1[0], p2[0]], [p1[1], p2[1]], 'g-',
            linewidth=0.3, alpha=0.5)

if path_birrt is not None:
    path_arr = np.array(path_birrt)
    path_length = sum(np.linalg.norm(path_arr[i+1] - path_arr[i])
                      for i in range(len(path_arr) - 1))
    ax.plot(path_arr[:, 0], path_arr[:, 1], 'r-', linewidth=2.5,
            label=f'BiRRT Path (length={path_length:.1f})')

ax.plot(*start, 'go', markersize=12, label='Start')
ax.plot(*goal, 'r*', markersize=15, label='Goal')
ax.set_xlim(env.x_range)
ax.set_ylim(env.y_range)
ax.set_aspect('equal')
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_title('Bidirectional RRT (BiRRT)')
ax.legend(loc='upper left')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

BiRRTの可視化では、始点からの木(青)と終点からの木(緑)が別々に描かれています。2つの木が空間の中央付近で出会い、接続されている様子が確認できます。BiRRTのイテレーション数はRRTよりも大幅に少なくなっていることに注目してください。これは、2つの木が両端から同時に空間を探索するため、「出会う」までの距離が片方向のRRTに比べて短くなるためです。ただし、BiRRT自体は最適性の保証を持たないため、経路にはジグザグが残る場合があります。実用上は、BiRRTで高速に経路を見つけた後、経路の平滑化(path smoothing)を適用することが一般的です。

関節空間でのRRT — 2リンクロボットアーム

2リンクロボットアームのC空間計画

ここまでは点ロボットの2D計画を扱いましたが、経路計画の真価はC空間での計画に発揮されます。2リンクの平面ロボットアームを題材に、関節空間でのRRTを実装しましょう。

ロボットアームは2つの関節角度 $(\theta_1, \theta_2)$ で姿勢が決まります。各関節角度が $[0, 2\pi)$ の範囲で、C空間は2次元トーラスの形をしています。作業空間上に障害物がある場合、順運動学でアームの位置を計算し、障害物との衝突を判定します。

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Rectangle
from matplotlib.collections import LineCollection

class TwoLinkArm:
    """2リンク平面ロボットアーム"""
    def __init__(self, l1=1.0, l2=0.8, base=(0, 0)):
        self.l1 = l1  # リンク1の長さ
        self.l2 = l2  # リンク2の長さ
        self.base = np.array(base)

    def forward_kinematics(self, theta1, theta2):
        """順運動学: 関節角度から各関節・手先の位置を計算"""
        # 第1関節の先端(第2関節の位置)
        joint1 = self.base + np.array([
            self.l1 * np.cos(theta1),
            self.l1 * np.sin(theta1)
        ])
        # 手先の位置
        end_effector = joint1 + np.array([
            self.l2 * np.cos(theta1 + theta2),
            self.l2 * np.sin(theta1 + theta2)
        ])
        return self.base, joint1, end_effector


class ArmEnvironment:
    """ロボットアーム用の衝突判定環境"""
    def __init__(self, arm, obstacles):
        """
        arm: TwoLinkArmインスタンス
        obstacles: [(cx, cy, r), ...] 円形障害物のリスト
        """
        self.arm = arm
        self.obstacles = obstacles  # (中心x, 中心y, 半径)

    def is_collision_free(self, config):
        """コンフィギュレーションが衝突しないか判定"""
        theta1, theta2 = config
        base, joint1, end_eff = self.arm.forward_kinematics(theta1, theta2)

        # 各リンクを線分として障害物との距離をチェック
        for (cx, cy, r) in self.obstacles:
            center = np.array([cx, cy])
            # リンク1: base -> joint1
            if self._segment_circle_intersect(base, joint1, center, r):
                return False
            # リンク2: joint1 -> end_effector
            if self._segment_circle_intersect(joint1, end_eff, center, r):
                return False
        return True

    def _segment_circle_intersect(self, p1, p2, center, radius):
        """線分と円の交差判定"""
        d = p2 - p1
        f = p1 - center
        a = np.dot(d, d)
        b = 2 * np.dot(f, d)
        c = np.dot(f, f) - radius ** 2
        discriminant = b ** 2 - 4 * a * c
        if discriminant < 0:
            return False
        discriminant = np.sqrt(discriminant)
        t1 = (-b - discriminant) / (2 * a)
        t2 = (-b + discriminant) / (2 * a)
        # 線分上(0 <= t <= 1)で交差するか
        if (0 <= t1 <= 1) or (0 <= t2 <= 1):
            return True
        if t1 < 0 and t2 > 1:
            return True
        return False

    def is_edge_collision_free(self, config1, config2, n_checks=20):
        """2つのコンフィギュレーション間の補間経路が衝突しないか"""
        for i in range(n_checks + 1):
            t = i / n_checks
            config = (1 - t) * np.array(config1) + t * np.array(config2)
            if not self.is_collision_free(config):
                return False
        return True


# ロボットアームと環境の設定
arm = TwoLinkArm(l1=1.0, l2=0.8, base=(0, 0))
obstacles_arm = [
    (0.8, 0.8, 0.25),   # 障害物1
    (-0.5, 1.0, 0.2),   # 障害物2
    (0.3, -0.6, 0.3),   # 障害物3
]
arm_env = ArmEnvironment(arm, obstacles_arm)

# C空間の衝突マップを可視化
resolution = 200
theta1_range = np.linspace(0, 2 * np.pi, resolution)
theta2_range = np.linspace(0, 2 * np.pi, resolution)
cspace_map = np.zeros((resolution, resolution))

for i, t1 in enumerate(theta1_range):
    for j, t2 in enumerate(theta2_range):
        if not arm_env.is_collision_free((t1, t2)):
            cspace_map[j, i] = 1  # 障害物

fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# 作業空間の可視化
ax = axes[0]
theta_vis = np.linspace(0, 2 * np.pi, 100)
for (cx, cy, r) in obstacles_arm:
    circle = Circle((cx, cy), r, facecolor='gray',
                    edgecolor='black', alpha=0.7)
    ax.add_patch(circle)

# アームの例示(2つの姿勢)
for theta1, theta2, color in [(0.5, 1.0, 'blue'), (2.0, -0.5, 'orange')]:
    base, j1, ee = arm.forward_kinematics(theta1, theta2)
    ax.plot([base[0], j1[0], ee[0]], [base[1], j1[1], ee[1]],
            '-o', color=color, linewidth=3, markersize=6, alpha=0.8)

ax.set_xlim(-2.0, 2.0)
ax.set_ylim(-2.0, 2.0)
ax.set_aspect('equal')
ax.set_xlabel('x [m]')
ax.set_ylabel('y [m]')
ax.set_title('Work Space')
ax.grid(True, alpha=0.3)

# C空間の可視化
ax = axes[1]
ax.imshow(cspace_map, origin='lower', cmap='Greys', alpha=0.7,
          extent=[0, 360, 0, 360])
ax.set_xlabel(r'$\theta_1$ [deg]')
ax.set_ylabel(r'$\theta_2$ [deg]')
ax.set_title('C-Space Obstacle Map')
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

左図は作業空間で、灰色の円が障害物、2つの異なる姿勢(青とオレンジ)のロボットアームが描かれています。右図はC空間の障害物マップで、黒い領域が衝突する関節角度の組み合わせ($\mathcal{C}_{\text{obs}}$)、白い領域が自由空間($\mathcal{C}_{\text{free}}$)です。

C空間障害物マップを見ると、作業空間では単純な円形障害物が、C空間では曲がりくねった複雑な帯状の領域に写像されていることが確認できます。これがC空間の本質的な難しさです。作業空間の障害物の形が単純であっても、関節空間に写像すると非凸な複雑な形になるため、C空間障害物を陽に構成するのではなく、衝突判定関数で「その場」で判定するサンプリングベース手法が有効であることが実感できます。

次に、このC空間上でRRTを実行します。

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Circle

class RRT_CSpace:
    """C空間上でのRRT(2リンクアーム用)"""
    def __init__(self, arm_env, start_config, goal_config,
                 step_size=0.2, goal_bias=0.1,
                 max_iter=5000, goal_threshold=0.2):
        self.arm_env = arm_env
        self.start = np.array(start_config)
        self.goal = np.array(goal_config)
        self.step_size = step_size
        self.goal_bias = goal_bias
        self.max_iter = max_iter
        self.goal_threshold = goal_threshold

        self.nodes = [self.start.copy()]
        self.parent = [-1]

    def random_sample(self):
        if np.random.random() < self.goal_bias:
            return self.goal.copy()
        t1 = np.random.uniform(0, 2 * np.pi)
        t2 = np.random.uniform(0, 2 * np.pi)
        return np.array([t1, t2])

    def nearest_node(self, point):
        dists = [np.linalg.norm(node - point) for node in self.nodes]
        return int(np.argmin(dists))

    def steer(self, from_node, to_point):
        direction = to_point - from_node
        dist = np.linalg.norm(direction)
        if dist <= self.step_size:
            return to_point.copy()
        return from_node + self.step_size * direction / dist

    def plan(self):
        for i in range(self.max_iter):
            q_rand = self.random_sample()
            nearest_idx = self.nearest_node(q_rand)
            q_near = self.nodes[nearest_idx]
            q_new = self.steer(q_near, q_rand)

            if self.arm_env.is_edge_collision_free(q_near, q_new):
                self.nodes.append(q_new)
                self.parent.append(nearest_idx)

                if np.linalg.norm(q_new - self.goal) < self.goal_threshold:
                    if self.arm_env.is_edge_collision_free(q_new, self.goal):
                        self.nodes.append(self.goal.copy())
                        self.parent.append(len(self.nodes) - 2)
                        path = self._extract_path(len(self.nodes) - 1)
                        print(f"C空間RRT 経路発見! イテレーション: {i+1}")
                        return path
        print("C空間RRT: 経路が見つかりませんでした")
        return None

    def _extract_path(self, goal_idx):
        path = []
        idx = goal_idx
        while idx != -1:
            path.append(self.nodes[idx])
            idx = self.parent[idx]
        path.reverse()
        return path

# 関節空間でのRRT実行
np.random.seed(42)
arm = TwoLinkArm(l1=1.0, l2=0.8, base=(0, 0))
obstacles_arm = [
    (0.8, 0.8, 0.25),
    (-0.5, 1.0, 0.2),
    (0.3, -0.6, 0.3),
]
arm_env = ArmEnvironment(arm, obstacles_arm)

start_config = np.array([0.5, 1.0])
goal_config = np.array([2.5, 4.5])

rrt_cspace = RRT_CSpace(arm_env, start_config, goal_config,
                         step_size=0.2, goal_bias=0.1, max_iter=5000)
path_cspace = rrt_cspace.plan()

# C空間上の経路可視化
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# C空間のプロット
ax = axes[0]
resolution = 200
theta1_range = np.linspace(0, 2 * np.pi, resolution)
theta2_range = np.linspace(0, 2 * np.pi, resolution)
cspace_map = np.zeros((resolution, resolution))
for i, t1 in enumerate(theta1_range):
    for j, t2 in enumerate(theta2_range):
        if not arm_env.is_collision_free((t1, t2)):
            cspace_map[j, i] = 1

ax.imshow(cspace_map, origin='lower', cmap='Greys', alpha=0.5,
          extent=[0, 2*np.pi, 0, 2*np.pi])

# RRTの木を描画
for i in range(1, len(rrt_cspace.nodes)):
    pi = rrt_cspace.parent[i]
    p1 = rrt_cspace.nodes[pi]
    p2 = rrt_cspace.nodes[i]
    ax.plot([p1[0], p2[0]], [p1[1], p2[1]], 'c-',
            linewidth=0.3, alpha=0.4)

if path_cspace is not None:
    path_arr = np.array(path_cspace)
    ax.plot(path_arr[:, 0], path_arr[:, 1], 'r-', linewidth=2.5,
            label='RRT Path')
    ax.plot(path_arr[0, 0], path_arr[0, 1], 'go', markersize=10,
            label='Start')
    ax.plot(path_arr[-1, 0], path_arr[-1, 1], 'r*', markersize=12,
            label='Goal')

ax.set_xlabel(r'$\theta_1$ [rad]')
ax.set_ylabel(r'$\theta_2$ [rad]')
ax.set_title('RRT in C-Space')
ax.legend()
ax.grid(True, alpha=0.3)

# 作業空間上でアームの動きをスナップショットで可視化
ax = axes[1]
for (cx, cy, r) in obstacles_arm:
    circle = Circle((cx, cy), r, facecolor='gray',
                    edgecolor='black', alpha=0.7)
    ax.add_patch(circle)

if path_cspace is not None:
    n_snapshots = min(10, len(path_cspace))
    indices = np.linspace(0, len(path_cspace) - 1,
                          n_snapshots, dtype=int)
    colors = plt.cm.coolwarm(np.linspace(0, 1, n_snapshots))

    for k, idx in enumerate(indices):
        t1, t2 = path_cspace[idx]
        base, j1, ee = arm.forward_kinematics(t1, t2)
        ax.plot([base[0], j1[0], ee[0]], [base[1], j1[1], ee[1]],
                '-o', color=colors[k], linewidth=2, markersize=4,
                alpha=0.6)

    # 始点と終点のアームを強調
    t1_s, t2_s = path_cspace[0]
    base, j1, ee = arm.forward_kinematics(t1_s, t2_s)
    ax.plot([base[0], j1[0], ee[0]], [base[1], j1[1], ee[1]],
            '-o', color='green', linewidth=3, markersize=8,
            label='Start config')

    t1_g, t2_g = path_cspace[-1]
    base, j1, ee = arm.forward_kinematics(t1_g, t2_g)
    ax.plot([base[0], j1[0], ee[0]], [base[1], j1[1], ee[1]],
            '-o', color='red', linewidth=3, markersize=8,
            label='Goal config')

ax.set_xlim(-2.0, 2.0)
ax.set_ylim(-2.0, 2.0)
ax.set_aspect('equal')
ax.set_xlabel('x [m]')
ax.set_ylabel('y [m]')
ax.set_title('Arm Motion in Work Space')
ax.legend(loc='lower left')
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

左図はC空間上でのRRT探索結果を示しています。灰色の領域がC空間障害物(衝突する関節角度の組み合わせ)で、赤い太線がRRTが発見した経路です。C空間障害物の複雑な形状を巧みに避けて、始点から終点まで経路が見つかっています。

右図は、C空間の経路を作業空間に戻して可視化したものです。アームが初期姿勢(緑)から最終姿勢(赤)まで、灰色の障害物を衝突せずに移動する様子がスナップショットで表示されています。青から赤へのグラデーションが時間的な進行を表しています。C空間では単なる2次元の経路ですが、作業空間ではアームが複雑に折り畳んだり伸ばしたりしながら障害物を回避する動きに対応していることが確認できます。

これこそがC空間の力です。関節空間上で「点の移動」として計画された経路が、作業空間では高度な衝突回避動作を実現しています。

手法の比較と選択指針

ここまで学んだ4つの手法の特性を整理し、どのような状況でどの手法を選ぶべきかをまとめます。

手法の比較表

特性 PRM RRT RRT* BiRRT
クエリタイプ マルチクエリ シングルクエリ シングルクエリ シングルクエリ
最適性 近似最短(グラフ上) なし 漸近最適 なし
確率的完全性 あり あり あり あり
前処理 必要(重い) 不要 不要 不要
動的環境 再構築が必要 対応可能 対応可能 対応可能
収束速度 中程度 遅い 速い
計算量/反復 $O(n)$ $O(n \log n)$ $O(n)$

選択指針

PRM が適する場面: – 同じ環境で繰り返し異なる始点・終点のクエリが発生する場合(工場のロボットアーム等) – 環境が静的で事前計算の時間が確保できる場合

RRT が適する場面: – 1回限りの経路を素早く見つけたい場合 – 環境が動的に変化し、頻繁に再計画が必要な場合 – 計算リソースが限られている場合(宇宙用オンボードコンピュータ等)

RRT* が適する場面: – 経路の最適性が重要な場合(エネルギー消費や時間を最小化したい) – 計算時間に余裕がある場合 – anytime性が活かせる場合(時間をかけるほど良い経路を得たい)

BiRRT が適する場面: – 始点と終点が明確で、できるだけ早く経路を見つけたい場合 – 狭い通路が存在する環境

実用上は、BiRRTで高速に経路を見つけた後、RRTの再配線操作で経路を最適化するInformed RRT**や、BiRRT + 経路平滑化のハイブリッド手法が広く使われています。

経路の平滑化

RRTやBiRRTで得られたジグザグな経路は、後処理として経路平滑化(path smoothing)を行うのが一般的です。最もシンプルな方法は、経路上の2点をランダムに選び、直線で結んでも衝突しないなら間の点を省略する「ショートカット法」です。

$$ \text{Smooth}(\sigma) = \text{ShortCut}(\sigma, N_{\text{trials}}) $$

ショートカットを $N_{\text{trials}}$ 回繰り返すことで、経路はかなり滑らかになります。数学的に最適ではありませんが、計算が軽く効果が大きいため、実用上はほぼ必須の後処理です。

まとめ

本記事では、衝突回避の経路計画について、コンフィギュレーション空間の概念から代表的なサンプリングベース手法まで解説しました。

  • コンフィギュレーション空間(C空間) は、ロボットの複雑な形状を「点」に変換し、経路計画を $n$ 次元空間内の障害物回避問題として統一的に定式化する枠組みである
  • PRM はC空間上にロードマップ(グラフ)を前処理で構築し、任意のクエリに高速応答できるマルチクエリ型手法である
  • RRT はランダムに木を伸ばし、空間充填性により高次元空間でも効率的に経路を探索するシングルクエリ型手法である
  • RRT* は再配線操作(ChooseParentとRewire)を追加することで漸近最適性を実現し、サンプル数を増やすほど最適経路に収束する
  • BiRRT は始点と終点の双方から木を伸ばすことで収束速度を大幅に改善する
  • 宇宙環境では微小重力下の反力問題、動的障害物、柔軟体、通信遅延などの特有の課題があり、RRTの軽量な計算と打ち切り可能な性質が宇宙用途に適している

経路計画の次のステップとして、見つかった経路を実際のロボットが追従するための時間最適軌道計画が重要になります。経路は「どこを通るか」を決めますが、「いつどの速度で通るか」は決めていません。関節の速度・加速度・トルクの制約を考慮しながら最短時間で経路を辿る軌道を生成するのが、次の記事のテーマです。

次のステップとして、以下の記事も参考にしてください。