AIエージェントでROS2を動作させてみた〜1.SLAM ,NAV 2 ,Gazebo ,Rviz とは〜

こんにちは、竹内 です。

前回は、ロボットAIエージェントを作るための土台として ROS 2 を勉強しました。ノードに分けて、トピックやアクションで繋ぐ——という、いわば配管の作法の話です。

ただ、配管が分かっても、肝心の走らせる場所も、まわりを見る目もまだありません。「2メートル前進」を /cmd_vel に変えられたところで、その先に壁があるかどうかを知る手段が無いのです。

そこから先に進むには、ROS 2 とほぼセットで語られる 4 つの道具を知る必要がありました。Gazebo・SLAM・Nav2・RViz です。

名前は全部どこかで見たことがあったのですが、正直、最初はこの4つの区別がついていませんでした。特に Gazebo と RViz は、しばらく同じようなものだと思っていました……。

今回は、その整理をしていきます。

4つの道具は、役割がぜんぶ違う

細かい話に入る前に、まず全体像から。この4つは競合するものではなく、それぞれ別の仕事をしていて、一列に繋がっています

RViz 3つの中身をぜんぶ覗く(表示するだけ) Gazebo 仮想の世界+物理 センサもここで作る /scan /odom SLAM 地図と現在地を 同時に作る 地図+自己位置 Nav2 ゴールまでの 動き方を決める /cmd_vel 動いた結果また新しいセンサ値が出るので、ここはぐるぐる回り続ける
図1:4つの関係。Gazebo → SLAM → Nav2 → Gazebo と一周し、RViz だけがその輪の外にいる

ひとことで言うと、こうなります。

  • Gazebo:ロボットを走らせる場所。世界と物理とセンサを作る。
  • SLAM地図を作る。ついでに「自分が地図のどこにいるか」も出す。
  • Nav2:地図とゴールを受け取って、実際の速度指令まで落とす。
  • RViz:上の3つが何をしているかを見る。それだけ。ロボットは動かさない。

ここを押さえてから個別に見ていくと、だいぶ迷子になりにくいです。

Gazebo — 何度でも壊せる、仮想の実験室

Gazebo はロボットのシミュレータです。3D の世界を用意して、その中にロボットを置いて、物理法則付きで動かしてくれます。

Gazebo は「絵を描いている」だけではありません。重力・摩擦・衝突を計算し、さらにセンサの値まで作って ROS 2 のトピックに流してくれます

つまり ROS 2 側から見ると、Gazebo の中のロボットは実機とほぼ同じ顔をしています/scan を購読して /cmd_vel を出す、という関係は全く変わりません。

Gazebo ワールド 床・壁・棚・障害物 ロボットのモデル 形・重さ・車輪の付き方 物理エンジン 重力・摩擦・衝突 センサ LiDAR・カメラ・IMU /scan /odom /cmd_vel ROS 2 のノードたち 実機かGazeboかは こちらからは分からない
図3:Gazebo の中身。外に出ていくのは実機と同じトピックなので、上位のノードは書き換えずに済む

シミュレータの利点は、失敗できることです。実機だと、壁に突っ込めば本当に壊れますし、1回試すのに机まで歩いて行って充電して……となります。

シミュレータなら同じ失敗を100回でも再現できます。AIエージェントのように「どう動くか事前に読み切れない」ものを試すときは、ここがかなり大きいです。

ここで最初にハマったのが Gazebo が2種類ある問題です。古い「Gazebo Classic」と、新しい Gazebo(Harmonic など)は別物で、設定ファイルも起動コマンドも連携用のパッケージ名も違います。

ネットの記事がどちらの話をしているのか分からないまま真似して、動かない理由が分からず……けっこう時間を溶かしました。検索して出てきた手順は、まずどちらの Gazebo 向けか確認するのがおすすめです。

どのくらい別物なのかというと、このくらいです。

Gazebo Classic Gazebo 11 が最後。2025年1月でサポート終了 起動コマンド gazebo ROS 2 連携 gazebo_ros_pkgs プラグイン名 libgazebo_ros_* 別物 Gazebo(新) 旧 Ignition。Fortress / Garden / Harmonic … 起動コマンド gz sim ROS 2 連携 ros_gz プラグイン名 gz-sim-* 同じ「Gazebo」でも、打つコマンドから入れるパッケージまで全部ちがう
図4:新旧 Gazebo の違い。手順を真似る前に、まずどちらの話かを見分ける

もう1つ、シミュレータならではの注意点として時刻があります。Gazebo の中の時間は、実際の時計より速かったり遅かったりします。

そこで各ノードに use_sim_time を立てて、シミュレータが配信する時刻を使わせます。これを忘れるとセンサ値と位置の時刻がズレて、地図がぐちゃぐちゃになります

SLAM — 地図と現在地を、同時に作る

SLAM は Simultaneous Localization and Mapping の略で、日本語だと「自己位置推定と地図作成の同時実行」です。名前のとおりです。

なぜ「同時」が問題になるのか。地図が無いと自分の位置が分からず、自分の位置が分からないと地図が描けないという、鶏と卵の関係になっているからです。

SLAM はこれを、少しずつ交互に進めることで解きます。

1 まわりを測る LiDAR で1周スキャン 2 前の絵と重ねる 一番ぴったり合う位置を探す 3 現在地を直す ズレた分だけ自己位置を補正 4 地図に描き足す 新しく見えた所を埋める 少し進んで、また 1 へ 1周ごとの補正はごく小さい。それを何百回も積み重ねて地図になる
図5:SLAM のループ。「測る → 重ねる → 位置を直す → 地図を足す」をひたすら繰り返す

やっていることは間違い探しです。さっき見た景色と今見えている景色を重ねて、一番ぴったり合う位置のずれが、自分が動いた量になります。

そして SLAM が吐き出す地図は、写真ではありません。マス目に区切って、1マスずつ「通れる/壁/まだ知らない」を持つだけの地図です。占有格子地図と呼ばれます。

ロボット 白 = 通れる センサが届いて、何も無かった所 黒 = 壁 センサが跳ね返ってきた所 灰 = 未知 まだ一度も見ていない所
図6:占有格子地図。ロボットにとっての世界は、この3種類のマス目でしかない

「未知」というマスが独立してあります。通れないから灰色なのではなく、まだ見ていないから灰色です。だからロボットは、灰色を減らすためにわざわざ探索しに行ったりします。

私が勘違いしていたのですが、SLAM は毎回動かすものではありません。一度きれいな地図が作れたら、それを保存しておいて次からは読み込むだけで済みます。

デモを何度も回すときは、この「作る」と「使う」を分けておくと、毎回同じ条件で再現できて楽でした。

保存はコマンド1本です。地図の画像(.pgm)と、縮尺などを書いた設定(.yaml)の2ファイルが出てきます。

# 走り終わったあと、その時点の地図を保存する
ros2 run nav2_map_server map_saver_cli -f my_map

Nav2 — 「あそこへ行って」を車輪の回転に変える

地図ができて、自分の位置も分かった。次は目的地まで実際に移動する役です。これを丸ごと引き受けてくれるのが Nav2(Navigation 2)です。

Nav2 は、この問題を2段構えで解きます。

ゴール x, y, 向き グローバルプランナ 地図ぜんぶを見て ゴールまでの線を1本引く 経路 ローカルプランナ 数秒先だけを見て 今出す速度を決める /cmd_vel コストマップ 壁を太らせて余白を作る リカバリ行動 詰まったら回る・下がる
図7:Nav2 の2段構え。大まかな経路と、目の前の回避を、別々の担当が受け持つ

先にグローバルプランナが、保存した地図の上で「だいたいこのルート」を1本引きます。カーナビが出発前に経路を出すのと同じイメージです。

ただ、地図には載っていないもの——置きっぱなしの段ボール、歩いている人——が必ずあります。そこでローカルプランナが、数秒先だけを見て実際の速度を細かく決め直します。

この2つを支えているのがコストマップです。壁のマスを実際より少し太らせておくことで、「通れるけどギリギリすぎる道」を自然に避けさせます

それでも詰まることはあります。そのときはリカバリ行動——その場で一回転してみる、少し下がる、古いコストマップを捨てる——を順番に試して、自力で復帰しようとします。

前回の話がここで繋がります。Nav2 へのゴール指示は、トピックでもサービスでもなく アクションNavigateToPose)です。

移動は数十秒かかり、途中経過を知りたくて、しかも途中でやめたくなる——まさにアクションを使うべき場面でした。「なぜ4種類あるのか」が、実物を見てようやく分かりました。

コマンドから直接ゴールを投げることもできます。map 座標系の (2.0, 1.0) へ行け、という指示です。

ros2 action send_goal /navigate_to_pose \
  nav2_msgs/action/NavigateToPose \
  "{pose: {header: {frame_id: 'map'},
           pose: {position: {x: 2.0, y: 1.0}}}}"

RViz — ロボットの頭の中を映すモニタ

最後が RViz です。冒頭に書いたとおり、私が Gazebo と混同していたのがこれです。

どちらも 3D のロボットが表示されるので、Gazebo と RViz は似たようなものに見えます。でも役割は正反対でした。

Gazebo = 実際に起きていること 段ボール(実物) ロボット(実物) 物理も衝突もここで起きる RViz = ロボットが分かっていること LiDAR が当たった点だけ 推定した自分の位置 黄色 = 引いた経路 ここで何をしても、ロボットは動かない 同じ場面でも、見えているものが違う。ズレていたら、それがバグ
図8:Gazebo は世界そのもの、RViz はロボットの認識。両方を並べて見るのがデバッグの基本

RViz は ROS 2 のトピックを購読して、それを絵にするだけのビューアです。世界を作らないし、物理も計算しないし、ロボットを動かしもしません。

逆に言うと、RViz に映っているものは全部ロボットが実際に受け取った/計算したデータです。だから、ここが実物とズレていたら、そのズレがそのままバグということになります。

この性質のおかげで、RViz はデバッグの主戦場になります。私がよく見ているのはこのあたりです。

  • LiDAR の点群が、壁の位置とちゃんと重なっているか。ズレていたら座標変換が怪しい。
  • コストマップの膨らみ具合。太らせすぎると、通れるはずの通路が塞がる。
  • 引かれた経路が、そもそも意図した方向を向いているか。

RViz でよくある「何も表示されない」は、たいてい壊れているわけではありません。基準にする座標系(Fixed Frame)の選び間違いが犯人であることが多かったです。

地図を見たいのに base_link(ロボット自身)を基準にしていると、ロボットは常に画面中央から動かず、地図の方がぐるぐる回ります。地図を見るときは map にします。

この4つは、ROS 2 のどこで繋がっているのか

ここまで4つを別々に見てきましたが、実際に起動すると、これらはそれぞれ独立したプロセスです。お互いを関数として呼び出したりはしません。

では何が繋いでいるのか。前回出てきたトピックとアクションです。同じ名前のトピックに publish / subscribe している、ただそれだけで繋がります。

ROS 2 のグラフ ── 同じ名前で publish / subscribe すれば、それだけで繋がる Gazebo ROS 2 は喋らない gz の通信 ros_gz_bridge 翻訳するだけのノード /scan /odom /clock /tf /cmd_vel SLAM 地図と自己位置 /map /tf /scan RViz 見るだけ 受け取るだけで、 自分では何も出さない Nav2 経路と速度 /cmd_vel /map /scan /tf AIエージェント 言葉 → 行き先の座標 アクション NavigateToPose 進捗と結果が返ってくる 出す 受け取る アクション
図9:実際の配線。4つとも同じグラフにぶら下がっているだけで、直接は呼び合っていない

この図であらためて気づいたのは、Gazebo だけが ROS 2 を喋らないことでした。Gazebo には Gazebo 独自の通信があります。

その隙間を埋めるのが ros_gz_bridge です。翻訳しかしないノードを1個挟むことで、ROS 2 側からは普通のトピックに見えるようになります。

実際に私が使っている起動はこんな形です。トピックごとに「型」と「向き」を書いて渡します。

ros2 run ros_gz_bridge parameter_bridge \
  '/scan@sensor_msgs/msg/LaserScan[gz.msgs.LaserScan' \
  '/cmd_vel@geometry_msgs/msg/Twist@gz.msgs.Twist'

この 記号が向きです。[ は Gazebo から ROS 2 への片方向、@ は双方向。センサは片方向、指令は双方向、という書き分けになります。

ここを間違えると、ros2 topic list には名前は出るのに、中身が永遠に来ません。エラーも出ないので、原因にたどり着くまで時間がかかりました……。

もう1本、TF という配線がある

図の中に /tf というトピックが何度か出てきました。これが TF(座標変換)で、位置の話をするときの共通語にあたります。

「地図の (2.0, 1.0)」と「ロボットの前方2m」と「LiDAR から見て右斜め前」は、全部別の座標系の言い方です。TF はこれを相互に変換します。

/tf は1本のトピックですが、複数のノードが区間を分担して流し込んでいます。誰がどこを埋めるかが決まっています。

map 地図の原点 SLAM が出す ズレの補正ぶん odom 走り出した場所 オドメトリが出す 車輪の回転から base_link ロボット本体 laser LiDAR の付き位置 camera カメラの付き位置 ロボットの形。動かないので最初に1回出すだけ この鎖が1本でも切れると、RViz は何も映さず、Nav2 はゴールを受け取らない
図10:TF の鎖。1本のトピックを、複数のノードが区間ごとに分担して埋めている

SLAM が埋めているのは mapodom の1区間だけです。SLAM は「オドメトリのズレぶん」を出しています。

私はここで、機体側のリンク名と設定ファイルのフレーム名を食い違わせて、鎖を切ってしまいました。RViz は真っ白、Nav2 はゴールを受け取らない。それでいてエラーは出ません。

怪しいと思ったら ros2 run tf2_tools view_frames です。今そこにある鎖が PDF で出てくるので、どこで切れているか一目で分かります。

そして RViz は、この配線にぶら下がって眺めているだけです。閉じてもロボットは走り続けます。デバッグ中に気軽に開け閉めできるのは、この構造のおかげでした。

4つ繋ぐと、AIエージェントの仕事はむしろ減る

4つを繋ぐと、AIエージェント側の作りも変わります。

素朴に作ると、AIエージェントが自分で /cmd_vel を出す役を全部背負うことになります。LLM が決めるのは「前へ2メートル」「右へ90度」といった、いま居る場所からの相対的な動き方です。

Nav2 が入ると、そこは全部 Nav2 の担当になります。エージェントがやることは 言葉を「行き先の座標」に翻訳して、あとは投げるだけです。

「倉庫の入口まで行って」 あなた AIエージェント 言葉 → 行き先の座標 ここを自分で作る アクション Nav2 経路・回避・リカバリ 地図(SLAM)を使う /cmd_vel Gazebo ロボットが動く RViz = この全部を横から眺めている どう動くかを決めるのは、もう LLM の仕事ではない
図11:Nav2 を挟んだあとの構成。エージェントの責任範囲が「翻訳」まで狭くなる

入口はテキスト1本のまま。変わるのは、その先で誰が何を担当するかだけです。

ros2 topic pub --once /agent/instruction std_msgs/String \
  "{data: '倉庫の入口まで行って'}"

役割を分ければ、壊れたときにどこが悪いかも分かりやすくなります。経路が変なら Nav2、地図が変なら SLAM、座標が変ならエージェント、という切り分けができるはずです。

まとめ

  • Gazebo=走らせる場所。物理もセンサも作ってくれるので、上位のノードは実機と同じままでいい。
  • SLAM=地図と現在地を同時に作る。出てくるのは白・黒・灰の3値のマス目。作ったら保存して使い回す。
  • Nav2=ゴールを速度指令に変える。大まかな経路(グローバル)と目の前の回避(ローカル)の2段構え
  • RVizシミュレータではなくビューア。ロボットの頭の中を映すので、実物とのズレがそのままバグになる。
  • ハマったのは Gazebo の新旧混同・use_sim_time の付け忘れ・RViz の Fixed Frame。全部「エラーが出ないのに動かない」系でした。

次回は、いよいよこれを実際に動かします。環境を用意して、地図を作って、自然言語の指示だけでロボットが目的地まで自走するところまでを、手順ごと書く予定です。

ここまで名前だけ出してきたAIエージェントのノードを実際にどう組むか——ROS 2 側をどういう構成にするのかも、そこで一緒に紹介します。

たぶんまた、素直には動いてくれないと思います……。お楽しみに!

新規CTA