エージェントでROS2を動作させてみた〜4.PX4でドローンを飛ばす〜
こんにちは、竹内 です。
前回は RAI を読みながら、ロボットのAIエージェントをどう組むかを整理しました。役割で分ける、見せるものを絞る、移動は Nav2 に寄せる、の3つです。
今回は同じ構成のまま、機体を地上ロボットからドローンに替えます。走る代わりに飛びます。
替えると、間に PX4 という本物のフライトコントローラが入ります。ここが今回の主役です。
結論を先に書くと、エージェント側のコードは1行も足していません。何も足さずに済んだ理由を順に見ていきます。
地上ロボットとドローンで、間に入るものが違う
前回までの地上ロボットは、ROS 2 から /cmd_vel(速度指令)を出すと Gazebo のプラグインが車輪を回していました。ROS とシミュレータが直結です。
ドローンでは、その間に PX4 が割り込みます。Gazebo は PX4 とだけ会話します。ROS からは、最初は何も見えません。
実際、ドローンを起動した直後に ROS 側から見えるトピックは /clock とカメラ画像だけです。機体は PX4 が握っています。
PX4 とは何か
PX4 は、ドローンのフライトコントローラのファームウェアです。オープンソースで、Pixhawk などの実機の基板の上で動いています。
ROS 2 と競合するものではありません。担当する層が違います。PX4 は機体を落とさないための内側のループを持ちます。
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-beta1+Gazebo Harmonic+ROS 2 Jazzyを macOS 上でネイティブに動かしています。機体は PX4 同梱の x500(4発クアッドコプター)に真下向きのカメラを付けた x500_mono_cam_down です。
ROS から飛ばすには、もう1つプロセスが要る
PX4 は ROS 2 のノードとして動いているわけではありません。既定で話す言葉は MAVLink という別のプロトコルで、UDP で飛んできます。
そこで、ROS 2 と MAVLink を突き合わせるプロセスを1つ書きました。これが無いと、エージェントからは機体が存在しないのと同じです。
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 を突き合わせて自分の位置と姿勢を推定します。そのセンサが生えるように、ワールドの側で環境を宣言する必要があります。
この最後の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 のノードです。管理者が指示を受け、移動担当に依頼します。
移動担当が持つ道具に、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枚にします。
ドローンに替えて分かったのは、境界を1本に絞っておくと機体の入れ替えが安く済むということでした。名前と型を揃えるだけで上流が丸ごと再利用できます。
そのぶん、境界の下では説明を実物に合わせる必要があります。直線で飛ぶ機体の道具に「障害物を避ける」と書いてはいけません。
PX4 を挟んだことで、ハマり方も変わりました。センサが生えていない・推定が発散しているという、ROS より下の層の話が増えます。
次回は風を実際に吹かせて、その中で位置を保てるかを見ます。あわせて、LiDAR を積んだ機体で障害物を避けさせるところまで。お楽しみに!
