エージェントでROS2を動作させてみた〜4.PX4でドローンを飛ばす〜

こんにちは、竹内 です。

前回は RAI を読みながら、ロボットのAIエージェントをどう組むかを整理しました。役割で分ける、見せるものを絞る、移動は Nav2 に寄せる、の3つです。

今回は同じ構成のまま、機体を地上ロボットからドローンに替えます。走る代わりに飛びます。

替えると、間に PX4 という本物のフライトコントローラが入ります。ここが今回の主役です。

結論を先に書くと、エージェント側のコードは1行も足していません。何も足さずに済んだ理由を順に見ていきます。

地上ロボットとドローンで、間に入るものが違う

前回までの地上ロボットは、ROS 2 から /cmd_vel(速度指令)を出すと Gazebo のプラグインが車輪を回していました。ROS とシミュレータが直結です。

ドローンでは、その間に PX4 が割り込みます。Gazebo は PX4 とだけ会話します。ROS からは、最初は何も見えません。

これまで:地上ロボット ROS 2 エージェント / Nav2 /cmd_vel(速度) Gazebo DiffDrive プラグインが車輪を回す 今回:ドローン ROS 2 エージェント PX4 フライトコントローラ Gazebo 4つのローターを回す Gazebo はこことだけ会話する
図1:地上ロボットは ROS 2 とシミュレータが直結。ドローンでは間に PX4 が入る

実際、ドローンを起動した直後に ROS 側から見えるトピックは /clock とカメラ画像だけです。機体は PX4 が握っています。

PX4 とは何か

PX4 は、ドローンのフライトコントローラのファームウェアです。オープンソースで、Pixhawk などの実機の基板の上で動いています。

ROS 2 と競合するものではありません。担当する層が違います。PX4 は機体を落とさないための内側のループを持ちます。

AIエージェント(ROS 2) 「西の水田の上空 6m へ」=どこへ行くか。判断は数秒〜数十秒に1回 〜0.1 Hz PX4(フライトコントローラ) 姿勢制御・推力配分・EKF による自己位置推定。止めると機体は落ちる 数百 Hz Gazebo(物理) ローターの推力から機体の運動を解く。IMU・気圧・地磁気・GNSS を作る 1000 Hz
図2:担当する時間の刻みが3桁ずつ違う。LLM が入れるのは一番上の段だけ

LLM が入れるのは一番上の段だけです。「どこへ行くか」は秒の世界、「どう飛ぶか」はミリ秒の世界で、そこに LLM を置く余地はありません。

なぜ PX4 が要るのか

飛ぶ機体は、放っておくと落ちます。クアッドコプターは4つのローターしか持たず、機体を傾けることでしか横に動けません

傾いたまま放置すれば、そのまま加速して落ちます。姿勢を測り、4つの回転数を配り直す。これを数ミリ秒ごとに、止めずに続ける必要があります。

地上ロボットは違います。/cmd_vel を出すのをやめれば、そこで止まるだけです。指令の間隔が空いても倒れません

この差が、間に PX4 を入れる理由です。ROS 2 のノードも Python も LLM の応答も、ミリ秒の周期を守る保証がありません。1回の遅れが墜落になります。

PX4 が持っているのは姿勢制御だけではありません。arm 前の点検・センサ異常時の failsafe・EKF による自己位置推定も、同じ内側のループにあります。

だからエージェントは、落とさない仕事を丸ごと PX4 に預けて、どこへ行くかだけを考えられます

SITL:実機と同じコードを PC で動かす

今回使うのは SITL(Software In The Loop)です。実機のファームウェアをそのまま PC 上のプロセスとして起動して、センサの値だけシミュレータから貰います。

PX4 のリポジトリをビルドすると、px4 という実行ファイルができます。起動すると、実機と同じ起動スクリプトが走ります。

INFO  [commander] LED: open /dev/led0 failed (22)
INFO  [uxrce_dds_client] init UDP agent IP:127.0.0.1, port:8888
INFO  [mavlink] mode: Onboard, data rate: 4000000 B/s on udp port 14580
WARN  [health_and_arming_checks] Preflight Fail: no heading reference
INFO  [logger] Opened full log file: ./log/2026-08-20/18_10_19.ulg
INFO  [commander] Ready for takeoff!

/dev/led0 を開こうとして失敗しているのが分かります。実機用のコードがそのまま動いているので、PC には無いデバイスを探しにいきます。

飛行ログ(.ulg)も実機と同じ形式で残ります。実機の解析ツールがそのまま使えます。

今回の構成は PX4 v1.18.0-beta1Gazebo HarmonicROS 2 Jazzyを macOS 上でネイティブに動かしています。機体は PX4 同梱の x500(4発クアッドコプター)に真下向きのカメラを付けた x500_mono_cam_down です。

ROS から飛ばすには、もう1つプロセスが要る

PX4 は ROS 2 のノードとして動いているわけではありません。既定で話す言葉は MAVLink という別のプロトコルで、UDP で飛んできます。

そこで、ROS 2 と MAVLink を突き合わせるプロセスを1つ書きました。これが無いと、エージェントからは機体が存在しないのと同じです。

エージェント 移動担当(ROS 2 ノード) Web UI もここに並ぶ navigate_to_pose /cmd_vel ブリッジ NED ↔ ENU の座標変換 高度を保持する P 制御 ミッションの組み立て MAVLink / UDP 位置・姿勢 PX4 udp 14580 姿勢制御・EKF /odom + TF(odom → base_link) 機体が変わっても同じ形で出す約束 ブリッジが守る契約:どの機体も /cmd_vel を購読し /odom を publish する。だから上流は機体を知らずに済む 同じ約束を地上ロボット(DiffDrive)は Gazebo のプラグインが、ドローンはこのブリッジが果たしている
図3:ブリッジが MAVLink を ROS 2 の形に翻訳する。上流から見た機体の顔つきは変わらない

PX4 に直接つなぐ道もある

では、エージェントを PX4 にそのままつなげないのか。つなぐ口はあります。さきほどの起動ログに出ていた uxrce_dds_client がそれです。

PX4 の内部変数を DDS に流すしくみで、px4_msgs のトピックとして ROS 2 から見えます。PX4 と ROS 2 の組み合わせは特殊ではなく、公式にサポートされた経路です。

MAVLink 側にも MAVROS という定番の橋があります。実機の自律飛行でも、このどちらかを使う構成が普通です。

今回どちらも選ばなかったのは、macOS の環境で重かったからです。conda で入る MAVROS は Python 3.12 版しかなく、既定環境の 3.11 と依存が解けません

uXRCE-DDS の方は、相方の Agent が conda に無くソースビルドが要り、px4_msgs の colcon ビルドも別に必要です。pymavlink なら追加は1パッケージだけでした。

ただし、どの経路を選んでもエージェントに直結はしません。PX4 が出すのは NED の生の状態と設定値で、そのままでは LLM の道具になりません。

「(-9, 9.5) へ 6m で」を緯度経度のミッションに直し、返ってきた位置を /odom の形に戻す層は、どこかに必ず要ります。ブリッジはその層です。

翻訳で間違えるのは座標系

翻訳で一番間違えやすいのが座標系です。PX4 は NED(北・東・下)、ROS は ENU(東・北・上)。軸の順番も上下の向きも違います。

# 位置・速度は入れ替えて z を反転するだけ
(x, y, z)_ENU = (y, x, -z)_NED

# 姿勢は前後を挟んで回す(px4_ros_com と同じ式)
q_ENU_FLU = q_(NED→ENU) * q_NED_FRD * q_(FLU→FRD)

ここを間違えると、機体は素直に明後日の方向へ飛びます。符号1つで南北が入れ替わるので、最初は座標を出すだけのテストから始めました。

高度は /cmd_vel に無いので作った

地上ロボット用の /cmd_vel は、前後の速度と旋回だけの2次元です。そのままでは高度が決まりません。

そこで linear.z「目標高度の変化率」と決めて、ブリッジの中に高度を保つ制御を置きました。0 なら今の高度を維持します。

これで「1m前進して」のような地上ロボット用の指示が、そのまま飛行に化けます。着陸中に /cmd_vel が来たら自動で離陸します。

行き先を渡す口は、Nav2 と同じ名前にした

前回、地上ロボットの移動は Nav2 の navigate_to_pose アクションに寄せました。ドローンにも同じ名前・同じ型のアクションを立てます。

中身は Nav2 ではありません。PX4 の AUTO.MISSION(ウェイポイント飛行)です。ゴールを受けると MAVLink のミッション項目を組み立ててアップロードします。

ミッション項目は緯度経度でしか渡せません。ROS 側のゴールはメートルなので、EKF の原点を基準に平面近似で変換します。

[px4_bridge] EKF 原点を取得: GlobalOrigin(lat=47.3979708, lon=8.5461639, alt=0.3)
[px4_bridge] PX4 の NAV_ACC_RAD を 1.5 m に設定しました。
[px4_bridge] ミッション 2 項目を受理(目標 seq=0)
[px4_bridge] PX4 が目標ウェイポイントの通過を通知(最終接近に入ります)。
[px4_bridge] 到達(残 1.33 m / 高度差 0.03 m / 8 秒)。LOITER で待機します。

PX4 の NAV_ACC_RAD(到達とみなす半径)は既定 10m です。空撮なら妥当でも、10m 離れて「着いた」と言われると検証にならないので 1.5m に書き換えています。

名前を揃えた効果はそのまま出ます。エージェントはnavigate_to_pose が居るか」だけを見て道具立てを決めるので、中身が Nav2 でも PX4 でも同じ経路に乗ります。

ドローンに Nav2 相当はあるのか

では、ドローンで Nav2 の代わりに置けるものは何か。そのまま差し替えられるものは、まだありません

Nav2 は地面を走る前提です。障害物を平面のマスに落とした costmap で経路を引くので、「上を越える」という選択肢がありません

高度を固定して2次元として使う例はありますが、それは Nav2 を飛行に流用しているだけです。今回の PX4 の AUTO.MISSION も、Nav2 のうち「運ぶ」だけを担当します。

飛行で障害物を避けたい場合、選択肢はこのあたりになります。

  • PX4 内蔵の Collision Prevention。距離センサの向きへ進めなくする機能で、避けて回り込むのではなく、止まる/押し戻す
  • 3次元の占有地図(OctoMap / voxblox / nvblox)+軌道生成(FAST-Planner・EGO-Planner など)。研究室発のものが多く、ROS 1 世代のままの実装もある
  • PX4/avoidance の local planner(3DVFH+)。ROS 1 前提

つまり「Nav2 を入れれば済む」形にはなっていません。3次元の地図と軌道生成を自分で組む領域です。今回はどれも試していません。

ワールドの側に「地球」を用意する

地上ロボットのときは、Gazebo のワールドに置くものは地面と障害物だけでした。ドローンでは足りません。

PX4 の EKF は、IMU・気圧・地磁気・GNSS を突き合わせて自分の位置と姿勢を推定します。そのセンサが生えるように、ワールドの側で環境を宣言する必要があります。

ワールドが宣言する「環境」 重力 0 0 -9.8 地磁気ベクトル 6e-06 2.3e-05 -4.2e-05 大気 断熱(高度で気圧が変わる) 地球上の原点 WGS84 / 緯度経度・ENU ワールドが宣言する「センサを作るしくみ」 Imu AirPressure(気圧) AirSpeed(対気速度) Magnetometer(地磁気) NavSat(GNSS) Contact / ApplyLinkWrench 地上ロボットには不要だったもの PX4 の EKF 4種のセンサを突き合わせ 位置と姿勢を推定する 収束しないと arm できない 落とし穴:ワールドに plugin を1つでも書くと、Gazebo は PX4 が用意した既定のプラグイン一覧を無視する
図4:ドローンのために増えた宣言。地上ロボットでは重力すら気にしていなかった

この最後の1行で、けっこう時間を溶かしました……。

PX4 は自前の設定ファイルで必要なプラグインを並べています。ところがワールドの側に <plugin> を1つでも書くと、Gazebo はそのファイルを丸ごと無視します

私のワールドは LiDAR やカメラのために既に plugin を書いていました。結果、気圧も地磁気も GNSS も生えず、EKF が収束せず、PX4 は arm を拒否します。

エラーは「センサが無い」とは言ってくれません。Preflight Fail: no heading reference のように、推定できないという結果だけが出ます。

直し方は、PX4 側の一覧をワールドに全部書き写すことです。地上ロボットには無害なので、同じワールドを両方で使い回せます。

風もワールドに設定できる

ワールドに書けるのは重力やセンサだけではありません。風も <wind> で設定できます。向きと強さを持ったベクトルを1つ置くだけです。

ただし、風を受ける側にも宣言が要ります。リンクごとに enable_wind を書いた物体だけが流されます。書かない物体は、風の中でも無風です。

PX4 同梱の x500 には、その enable_wind がありません。いま風を吹かせても、機体は揺れません。私の農地ワールドに並べた防風林も、今のところ見た目だけです。

エージェントの構成は、地上ロボットのまま

ここまでで機体側の話は終わりです。ではエージェントを何本書き足したか。0本です

起動しているのは前回と同じ管理者と移動担当の2プロセスで、それぞれが ROS 2 のノードです。管理者が指示を受け、移動担当に依頼します。

管理者 delegate_to_motion WaitForSeconds ROS を直接触らない 依頼 移動担当が持つ道具(20本) 行き先を渡す 3本 navigate_to_pose ほか 目 2本 1枚取得・動いたか判定 汎用 ROS 2 14本+待機1本 読み取り中心 navigate_to_pose アクション(Nav2 と同じ名前・同じ型) 行き先の口が居ると、速度で押す道具は取り上げられる drive_distance / turn_angle を渡さない。さらに /cmd_vel への書き込み権限そのものを空にする PX4 由来の道具は1本も無い arm・離陸・着陸・飛行モードの切り替えは、どれもエージェントの手の届かない場所にある
図5:ドローン用に足した道具は0本。飛ぶ機体だからと増えたのは、行き先の入力に高度が1つ増えただけ

移動担当が持つ道具に、PX4 や MAVLink の名前は1つも出てきません。エージェントは PX4 の存在を知らずに飛ばしています。

飛ぶ機体だからと変えたのは3点だけです。行き先に高度を足した、1回で狙える距離を 20m から 60m に広げた、道具の説明文を書き替えた。

# ドローンの道具に書いてある説明(抜粋・意訳)
経路は直線で、障害物を避けるものは何もありません。
見通しの良い空間にだけ行き先を置いてください。
見通しが無いときは、直線で飛べる区間に分けてください。

離陸を LLM にやらせていないのも意図的です。navigate_to_pose は、着陸中に呼ばれたらブリッジの側で離陸してから飛びます

離陸と着陸は ROS のサービスとしても用意してありますが、移動担当はサービスを呼ぶ権限を持っていません。手順を間違える余地を残さないためです。

ワールドの地図は、プロンプトで渡している

管理者が「西の水田」を座標で知っているのは、人格ファイルにそう書いてあるからです。役ごとに JSON を1つ持たせています。

RAI の EmbodimentInfo と同じ形で、機体の説明・禁止事項・できること・振る舞いを並べたものが、そのまま system prompt になります。

そこにワールドの地物を手で書き写しています。SDF からの自動生成はしていません。

# 管理者の人格ファイル(embodiments/drone/manager_nav2.json 抜粋)
"West paddy field, flooded and flat: centre (-9, 9.5)."
"Power poles, 8 m tall and the tallest obstacles here: (-16, -1.8), (0, -1.8) and (16, -1.8)."
"The autopilot flies a straight line and does not avoid obstacles: there is no map
 and no LiDAR on this airframe. So do think about height."

地図を持たない機体なので、何が高いかを言葉で渡さないと、6m 巡航のまま電柱に当たります。8m の電柱3本だけが越えられない、と書いてあります。

弱点もそのままです。ワールドを変えたら、この文章も書き換える必要があります。costmap のように勝手には更新されません。

地上ロボットの側には get_map_image という道具があって、Nav2 の地図を画像で見られます。ドローンにはそれが無く、文章だけが頼りです。

飛ばしてみる

舞台は農地のワールドです。十字の農道を中心に、水田・畑の畝・用水路・ビニールハウスが並んでいます。真下向きのカメラで水田を覗きます。

指示は日本語で1文だけ投げます。

ros2 topic pub -1 /agent/instruction std_msgs/msg/String \
  '{data: "西の水田の中央、座標(-9, 9.5)の上空 高度6mへ飛んで、真下に何が見えるか教えて"}'

あとは勝手に進みます。ログを時系列で並べるとこうなりました。

03:14:59 [管理者]   delegate_to_motion  … 移動担当に依頼
03:15:01 [移動担当] get_current_pose    … まず今どこか読む
03:15:15 [移動担当] navigate_to_pose {'x': -9, 'y': 9.5, 'z': 6}
03:15:15 [ブリッジ] ゴール受理: (-9.0, 9.5) 高度 6.0m yaw 0° [odom]
03:15:23 [ブリッジ] 到達(残 1.33 m / 高度差 0.03 m / 8 秒)。LOITER で待機
03:15:30 [移動担当] get_ros2_camera_image … 真下を1枚見る

離陸の指示は出していません。地上で待機していた機体が、ゴールを受けた側の判断で離陸して 6m まで上がり、水田の上まで飛んで止まりました

返ってきた答えがこれです。

現在の状況です。

- 現在地:西の水田中央、座標 (-9, 9.5) 付近、高度6mでホバリング中です。
- 真下のカメラ:水を張った西の水田が見えます。灰緑色の水面が画面の
  大部分を占め、周囲を茶色の畦(あぜ)が囲んでいます。

座標は言い値ではありません。道具の戻り値には飛び終わってから測り直した位置が入っていて、要求した座標との差も一緒に返ります。

「着いたと言っているが実は届いていない」を人が検証できるようにするためです。地上ロボットのときと同じ作法です。

まとめ

最後に全体を1枚にします。

人の言葉 — Web UI / トピック 管理者 → 移動担当(どちらも ROS 2 のノード。機体が変わっても同じ) navigate_to_pose = 機体をまたぐ1本の口 地上ロボット:Nav2 地図を見て迂回する・スタックから復帰する ドローン:ブリッジ + PX4 緯度経度のミッションに直す・直線で飛ぶ Gazebo(同じワールドを両方で使う)
図6:入れ替わるのは真ん中の段だけ。上と下はそのまま使える

ドローンに替えて分かったのは、境界を1本に絞っておくと機体の入れ替えが安く済むということでした。名前と型を揃えるだけで上流が丸ごと再利用できます。

そのぶん、境界の下では説明を実物に合わせる必要があります。直線で飛ぶ機体の道具に「障害物を避ける」と書いてはいけません

PX4 を挟んだことで、ハマり方も変わりました。センサが生えていない・推定が発散しているという、ROS より下の層の話が増えます。

次回は風を実際に吹かせて、その中で位置を保てるかを見ます。あわせて、LiDAR を積んだ機体で障害物を避けさせるところまで。お楽しみに!

新規CTA