WSL 2の罠と符号規約と、単眼VSLAMが「設定上100%ループを閉じられない」話
全3回の3本目です。
①[プロトコル解析編]/②[MCPで会話から操縦し、喋らせる編]/③ROS 2 VSLAM編(この記事)前々回でDJI RoboMaster S1のプロトコルを解析してアプリなしに制御し、前回は耳と口をつけました。今回はROS 2に載せて地図を作ります。
最初に結論を書いておきます。地図はできました。しばらくの間ループ閉じが1本もできず、しかもそれは調整不足ではなく原理的な理由でした。その調査過程が、この記事で一番書きたかった部分です。
そして原因が分かってしまえば対処は素直で、単眼深度推定でRGB-D化したら、その日のうちにループは閉じました(実ループ20本)。順を追って書きます。
構成
ROS 2 Humble は WSL 2(Ubuntu 22.04)上に構築しました。
[S1 (192.168.1.37)]
│ 10607 UDP(自作セッション)
[Windows] s1_relay.py ── loopback TCP 10620 ──▶ [WSL 2 (Ubuntu 22.04)]
s1_bridge (ROS 2 Humble)
/image_raw /camera_info /odom /imu /tf
│
s1_depth_node(後述・単眼深度)
│ /rgbd/{rgb,depth,camera_info}
RTAB-Map
発行しているトピックは /image_raw /camera_info /odom /imu /tf、購読は /cmd_vel。TFツリーは odom → base_link → gimbal_link → camera です。
base_link → gimbal_linkは静的TFにしてはいけません。 S1のカメラはジンバルに載っていて、車体が回ってもカメラは空間に対して姿勢を保とうとします(スタビライズ)。つまり車体を回すと、静的TFの前提が即座に破綻します。テレメトリのgimbal_posから yaw/pitch を取って 10Hzで動的TFとして流す必要がありました(S1は右+/上+なので符号反転)。その先のgimbal_link → cameraは光学系への回転だけなので静的で構いません。
DDSはCycloneDDS必須でした。既定のFastDDSはWSL環境でディスカバリに失敗します。
なぜ Windows 側にリレーを挟んでいるのか。そこが最初の罠でした。
罠① 映像がWSL内だと2秒で止まる
WSL 2内で直接映像サブストリームを受けると、開始2秒でピタリと止まります。WSL 2のミラーモードネットワークとフロー制御の相性問題です。
同じコードをWindows側で動かすと連続受信できる。ならば、と Windows に軽量なPythonリレーを置き、ローカルループバックのTCPでWSLに転送する構成にしました。
罠② ミラーモードではWSL⇄Windowsが構造的に通らない
ところが、そのリレーにWSLから繋がりません。
networkingMode=mirrored にしていると、WSLからホスト(Windows)のIPへの通信が自分自身にループバックします。ファイアウォールを切っても通りません。設定の問題ではなく構造の問題です。
.wslconfig を NAT モード+ localhostForwarding=true に変更し、デフォルトゲートウェイ経由でWindows側へ到達させて解決しました。
符号規約:数値が自己整合していても物理は間違う
ロボットで最も地味に苦しむのが座標系です。ここは2回間違えました。
S1の内部はNED系(f6+ = 右/CW、att.yaw+ = CW、gyro_z+ = CW)。一方 ROS 2の標準はFLU / REP-103(角度の正 = 左/CCW)。両者は x軸まわり180°回転の関係にあります。
| 項目 | RoboMaster S1 (NED) | ROS 2 (FLU) |
|---|---|---|
| ヨー角 (yaw) | + = 右 (CW) | + = 左 (CCW) |
| ジャイロ (gyro_z) | + = 右 | + = 左 |
したがって /cmd_vel からS1へ、S1のIMU/オドメトリからROSへ変換する際、yaw y gyro_z acc_y の符号を反転させる必要があります。ジンバルのyawだけは別軸で + = 右 のままなので、ここだけ個別処理です。
恥ずかしい話ですが、途中で一度「CCW正でROSと同符号」という補正を入れており、これは誤りで、むしろ壊していました。数値としては辻褄が合っているように見えるのに、実機は逆に回る。最終的には実機を目視して確定させました。
映像パイプラインの刷新:「アプリより画質が悪い」から3つのバグが出た
マッピングを始めてしばらく、「公式アプリで見るより明らかに画質が悪い」という違和感がありました。この一言を追いかけたら、独立した3つのバグが芋づる式に出てきます。
| 指標 | 修正前 | 修正後 |
|---|---|---|
| 映像帯域 | 42 KB/s | 470 KB/s(11倍) |
| 実効 fps | 6.5(大半が重複フレーム) | 30.2 |
| 遅延 | 数秒の残留 | 16 ms |
バグ1:映像サブストリームを一度もACKしていなかった
公式アプリの通信を実測したところ、アプリは t=0x04 のackフィールドに直近の映像seqを入れて送り続けていました(2720本、追随率94.7%)。
こちらの実装を見ると send_rt_ack() は実装済みなのに呼び出しが0行、完全なデッドコードでした。映像8パケットごとにACKするよう直したところ、帯域が11倍に。
効いているメカニズムは再送ではなくフロー制御/レート適応です(ロス率は修正前後とも0.1%台で差がなく、変わったのは機体が送ってくる帯域そのもの)。
ちなみに、コミュニティで知られている「helloを1秒ごとに再送しないとstallする」という対処法は、窓詰まりを力技でリセットしていた対症療法だったことになります。
【2026-07-27 追記】この修正を、片方の経路にしか入れていませんでした。
ここで直したのはROS用のリレー経路だけで、MCPが使う駆動セッションには入れ忘れていました。1週間後、2台目の機体でcapture_imageが何度呼んでも同じ写真を返す形で再発します。
症状の見分け方は返り値のバイト数でした。「126 KB」が毎回同じなら、バッファが伸びていない=受信が止まっています。実測ではセットアップ直後の118KBで完全停止し、以降はテレメトリのパケットだけが98/sで来続けていました。だからテレメトリは正常に見えて、映像だけ死んでいる。
駆動ループに25HzでACKを並走させたら、6秒で+3.0MB(約500KB/s)と、ここと同じ数字に戻りました。
教訓は「同じ真因が複数の経路にあるとき、片方だけ直すと必ず再発する」。詳細は④[2台目編]。
バグ2:同じフレームを何度も発行していた
成長し続けるバッファをオフセットで追跡していたのですが、トリムが走るとオフセットが後退し、30fps送出に対して84fps発行という異常値と約5秒の遅延を生んでいました。「取り出し=消費」に変えて根治。
バグ3:raw Imageのpublishが7.2fps上限だった
切り分けの実測が面白かったので載せます。
| 処理 | 速度 |
|---|---|
| PyAVデコード | 456 fps |
| to_ndarray | 902 fps |
| JPEGエンコード | 192 fps |
| raw 1280x720 (2.76MB) の publish | 7.2 fps |
publishだけが律速でした。640x360に落とすと25.5fps出るので、単純にデータ量の問題です。/image_raw/compressed(JPEG q95)を既定にして解決しました。
なお RTAB-Map 側は
compressed:=trueが正しい引数です(image_transport:=は無視されます)。さらにcompressed_image_transportプラグインが未インストールだと圧縮画像を誰も購読できません(ros-humble-image-transport-plugins)。ここで無言で詰まりました。
マッピングは成功した
手動運転(teleop)で RTAB-Map を回し、地図ができました。
- 7/18:136ノード / 16.5m / DB 197MB
- 7/20:209ノード / 27.1m / DB 228MB(映像刷新後、範囲1.6倍)
そしてループが1本も閉じない
同じ場所に戻ってきても、ループ閉じが全却下されます。エラーはこれ。
Not enough features (old=0, new=883)
「新しいフレームからは883個の特徴が取れているのに、古いキーフレーム側が0個」。当初は古い画像が転送中に壊れているのだろうと考えました。Windows→WSLの転送でフレームが破損しているのではないか、と。
DBをsqliteで直接読んで、これが誤りだと分かりました。
- 全703ノードに 825〜979個/フレームの特徴があり、0特徴のノードは1つも存在しない
- しかし
Featureテーブル636,904行のうち、3D座標を持つものは 0 個
単眼カメラなので、当然です。3D座標を持ちようがない。
そして Vis/EstimationType=1 は PnP(3D→2D)、つまり「古い側の3D点」を必須とします。エラーの old=0 は「壊れている」ではなく、**「古い側の 3D 特徴が常に0」**という意味でした。
つまりこの設定では、ループ閉じは100%必ず却下されます。
運転の仕方をどう工夫しても、画質をどれだけ上げても、1本も閉じません。
2D-2Dに切り替えたら「閉じた」。全部偽物だった
ならばエピポーラ幾何(Vis/EstimationType=2)だ、と切り替えました。
既定のままだと Variance is too high! で347回却下されます。ゲート(Vis/EpipolarGeometryVar=0.1)を2.0に緩め、MinInliers も下げたところ、130件のループが成立しました。喜びました。
そして全リンクの長さを見たら、avg 0.98m / std 0.02m。
2D-2Dは並進の向きしか復元できず、長さは常に単位ベクトル1.0になります。つまり「この2地点はちょうど1m離れている」という中身のない拘束を194本、グラフに注入していただけでした。既定値 0.1 は、まさにこれを防ぐための安全装置だったわけです。
通った=正しい、ではない。既定値が厳しいのには理由があります。
前向きな材料もある
幾何検証まで到達したケースのインライア数は 7/18: 0〜9個 → 7/20: 8〜17個(閾値20)に増えていました。画質改善はちゃんと効いている。深度さえ入れば、この改善がそのまま効く見込みです。
→ 残る正攻法は 深度を与えてRGBD化(単眼深度推定でスケールは /odom で合わせる)。代替は ORB-SLAM3 の単眼モードです。
深度を与えたら、閉じた
正攻法のほうをやりました。Depth Anything V2 (Metric Indoor Small) で単眼画像から深度を推定し、RGB-Dとして RTAB-Map に食わせます。
構成はこうです。
/image_raw/compressed ─┬─▶ s1_depth_node ─▶ /rgbd/rgb/image_raw
/camera_info ──────────┘ /rgbd/depth/image_raw (16UC1, mm)
/rgbd/camera_info
実装で効いたのは次の3点でした。
- 3トピックを入力と同一スタンプで発行する。 RGB・深度・camera_info が必ずペアで揃います。
-
2Hzにスロットルする。 RTAB-Map は
Rtabmap/DetectionRate=1で毎秒1フレームしか処理しません。30fpsぶん推論するのは純粋な無駄です。 -
スケールは単一係数。 推定深度は約1.75倍過大でほぼ一定でした。しかも
fxにほとんど依存しません(fx=369.5→1.77 / 424→1.73 / 500→1.70)。なのでdepth_scale=0.57(=1/1.75)の1つで足ります。有効レンジ外(<0.15m, >12m)は0=「深度なし」にします。
「オフラインで通った」は「実機で動く」ではない
3つを同一スタンプにしたので approx_sync:=false(exact sync)が使える、と考えました。オフライン再生では実際に通ります。
実機では一切マッチしませんでした。
同期対象には /odom も入っています。odomは10Hzで独立したスタンプを持つので、2Hzの画像と厳密一致するはずがありません。オフラインでは画像とodomを同じtickで発行していたため、人工的に一致していただけでした。approx_sync:=true に戻して解決です。
自作の再生基盤で検証すると速いのですが、その基盤が現実にはない都合の良さを持ち込んでいないかは別に疑う必要がありました。
オフライン検証
まず機体を出さずに検証しました。RTAB-Mapが外観では同一場所と判定したのに幾何検証で却下したペア396組を、走行時にダンプしてあったからです(画像805枚+オドメトリ姿勢805件をデータセット化)。ここから25組を選んで深度つきで再検証したところ、22組(88%)が Vis/MinInliers=20 を突破しました。インライアは中央値63・最大224。従来は0%です。
最後のブロッカーは Vis/CorGuessWinSize だった
深度を入れてもまだ閉じませんでした。しかも症状が奇妙で、matches=169〜287 なのに inliers=0 が13回。対応は大量に取れているのに、幾何的に整合するものが1つも無い。
犯人は Vis/CorGuessWinSize(既定40)でした。これはodomの推測位置から40px以内でしか対応を探さないという高速化用の設定です。連続フレーム間なら妥当ですが、ループ閉じでは推測がドリフトしているので、「間違った場所で大量にマッチする」という最悪の挙動になります。
--Vis/CorGuessWinSize 0 で無効化したところ、0件が12件になりました。
ちなみにここに至るまで、PnPパラメータ・深度スケール・SIFTが本当に使われているか、を順に潰しています。深度スケールはインライア数に理論通り無影響でした(スケールは3D点を相似変換するだけで、RANSACの一致・不一致は変えない)。
そのうえで実走行させた結果が下表です。単眼時代のDBと並べます。
| 項目 | 単眼(7/18〜7/20) | RGB-D(7/20) |
|---|---|---|
| ノード数 | 703 | 612 |
| Feature 行数 | 636,904 | 431,453 |
| 3D座標を持つ特徴 | 0(0%) | 431,453(100%) |
| 深度を持つノード | 0 / 703 | 612 / 612 |
| GlobalClosure | 7(すべて長さ0mの縮退) | 12 |
| LocalSpaceClosure | 0 | 8 |
| ループ並進長 | — | avg 0.40 m / std 0.26 / max 0.98 |
| 走行 | 16.5 m → 27.1 m | 16.2 m / 10分36秒 |
※リンク数は
from_id < to_idで数えたユニーク件数です。RTAB-MapのDBは双方向2行で持つので、素で数えると倍になります(Global 24行 / LocalSpace 16行)。
見るべきは長さの分布です。2D-2Dで偽ループを量産したときは avg 0.98 m / std 0.02 m = 単位ベクトルに固着していました。今回は avg 0.40 m / std 0.26 m と幅があり、実際の位置ずれを測っている拘束になっています。しかもループが繋いでいるノードIDの差は 59〜441(1↔442、225↔569、347↔562 など)で、隣接フレーム同士の縮退ではなく時間的に離れた真の再訪です。
なお、変えたのは深度だけではありません。特徴点も GFTT/ORB から SIFT に変えています(Vis/FeatureType=1)。それ以外の主要パラメータ(Vis/EstimationType=1(PnP) / Vis/MinInliers=20 / Reg/Force3DoF=true / Rtabmap/DetectionRate=1 / Rtabmap/LoopThr=0.11)は単眼時代と同じです。ゲートは一切緩めていません。 前章と逆に、既定の厳しさのまま通ったという点が大事です。
「床が26°反っている」——という誤診
地図としては閉じましたが、点群を出すと明らかに変で、占有格子は障害物が放射状に爆発して自由空間が中心の数セルしか残りません。
そこで650万点のPLYを出し、軌跡からの水平距離ごとに z の中央値を取りました。
| 水平距離 | 0.5 m | 1.0 m | 1.5 m | 2.0 m | 3.0 m | 4.0 m | 6.0 m |
|---|---|---|---|---|---|---|---|
| z 中央値 | -0.12 | +0.13 | +0.37 | +0.69 | +1.01 | +1.49 | +2.18 |
きれいに単調上昇しています。傾き約0.48 ≒ 26°のお椀状。旋回で全方位を撮るのだから、カメラ座標系の一定のpitch誤差がワールドでは対称な円錐になる——筋も通っています。「ジンバルpitchのTF符号が逆で、13°の下向きが誤差26°になっている」という原因まで特定し、pitchを+30°補正するのが最良という結論まで出しました。
全部間違いでした。
止まったのは、実際にPLYをビューアで開いた人間の**「床はそんなにおかしくない」**という一言です。
正しく測り直すと、床の傾きは校正前で3.72°、校正後で1.35°。ほぼ平坦です。
誤りの正体は、指標が床を測っていなかったことでした。「軌跡からの距離ごとの全点のz中央値」は、近距離では床を拾いますが、遠距離では壁や家具の上部を拾います。つまり正しく再構成できていれば必ず右肩上がりになる量を見て「歪んでいる」と言っていたわけです。しかも提案した+30°補正を実装していたら、実在する壁や棚を平らに潰す改悪になっていました。
正しい測り方は、各XYセルの最低点を床候補として抜き出し、RANSACで平面フィットです。「床の高さ」を測りたいなら、床だけを取り出してから測る。それだけの話でした。
カタログ値を信じてはいけない(fxが68.6%違った)
では実際の歪みは何だったのか。高さ方向のスケールでした。そして原因は fx です。
ChArUcoボードで76枚撮って校正した結果がこれです(RMS 0.2246 px)。
| 従来(画角120°からの合成値) | ChArUco実測 | |
|---|---|---|
| fx | 369.5 | 622.99 |
| fy | — | 622.09 |
| cx / cy | 640 / 360 | 635.07 / 373.77 |
| 水平画角 | 120°(と仮定) | 91.5°(垂直60.1 / 対角99.4) |
68.6%の誤りです。 レンズには確かに「FOV 120°」と書いてあります。ただしそれは光学系の仕様で、センサーと配信のクロップを経て実際に届く映像の画角ではありません。
Y = (v - cy) * z / fx なので、fx が1.69倍小さければ縦方向に1.69倍伸びます。校正で直った値がこちら。
| 校正前 | 校正後 | |
|---|---|---|
| 床の高さ(正しくは0) | -0.434 m | -0.032 m |
| 点群の z 範囲 | -0.76 〜 4.18 m | -0.17 〜 2.14 m |
| 床の傾き | 3.72° | 1.35° |
部屋として妥当な数字になりました。
ここで大事な区別があります。fxが68.6%も違っていたのに、ループ閉じは20本成立していました。
ループ閉じは画像対画像なので、一定の内部・外部パラメータ誤差にほぼ影響されません。
一方で 3D再構成・占有格子・Nav2 には直撃します。
「閉じているのに地図が使えない」のは矛盾ではなく、必要な精度の種類が違うというだけでした。
内部パラメータはボードの物理寸法に依存しません(実寸が効くのは並進だけ)。なので寸法を測っていない印刷物でも校正できます。モニターに表示したボードでも構いません。
ついでに、自分の砲身が写っていた
これもユーザ指摘です。S1のカメラにはブラスター(自機体の一部)が映り込んでいます。
カメラに固定されたものは、どのフレーム間でも完全一致する偽の対応点を供給します。ループ閉じやPnPを「動いていない」方向へ静かに引っ張るわけで、放置してよいものではありません。
検出で1つ工夫が要りました。分散(std)では床と区別できません——一様な畳も std は低いからです。フレーム間の平均絶対差を使うと分離できました(ブラスター 4.47 / 床 6.68〜7.68)。該当は x=527..732, y=612..720。下端108px(15%)を切るだけで済みます。下から切るぶんには fx/fy/cx/cy は不変で、camera_info は height だけ直せば済みます。
占有格子が汚かったのは、校正ですらなかった
校正後もまだ格子は汚いままでした。こちらは自前ラスタライズの手抜きが原因です。
「点がある=障害物」としか塗っておらず、カメラから点までの光線上を「空き」として塗る処理がありませんでした。自由空間は「観測されなかった場所」ではなく「見通せた場所」です。
結論としては、自前で書かずRTAB-Map自身に作らせるのが正解でした。
--Grid/Sensor 1 --Grid/RayTracing true --Grid/3D false
--Grid/RangeMax 3.0 --Grid/CellSize 0.05
--Grid/MaxObstacleHeight 0.8 --Grid/MaxGroundHeight 0.10
--Grid/NormalsSegmentation false
取り出しは
/mapトピックではなく サービス/rtabmap/rtabmap/get_mapを叩きます(再生終了後はトピックが発行されないため)。サービス名の名前空間が二重になっている点に注意。
これで 146x148セル / 5cm / 7.3x7.4m の格子が出て、Nav2の map_server 形式(PGM+YAML)で保存できました。走行可能領域と軌跡は一致しており、レイトレーシングは効いています。
ただしまだNav2で安全に走らせる品質ではありません。単眼推定深度のばらつきが層状に堆積して、障害物の輪郭が厚く膨らみます。改善案は ①Grid/RangeMax を 3.0→2.0 ②Grid/NoiseFilteringRadius を有効化 ③深度モデルを Small→Base/Large(推論95msなので余裕があります)の順です。
おまけ:横移動を試したらオドメトリの誤差が見つかった
「回転だと特徴を見失うのでは。横移動なら単眼でも成績が上がるのでは」と考えて試しました。メカナムホイールなので横移動ができます。
これは幾何学的に正しい戦略です。純回転は視差ゼロ(ホモグラフィ縮退)で構造情報を生まず、前進も光軸近傍の特徴が動かないので視差効率が悪い(forward-motion degeneracy)。横移動は画像平面と平行な基線なので、正面の特徴に最大の視差を与えます。しかも車体の向きが変わらないのでジンバルも振られず、純粋な平行移動になります。
コード変更は不要でした(teleop_twist_keyboard は Shift併用でholonomic:Shift+J=左 / Shift+L=右)。軸間の混入 ≤7%、yawずれ -0.3〜+0.6° と極めて素直に動きます。
そしてメジャーで実測したら、別の問題が見つかりました。
| 動作 | odom報告 | 実測 | 過大率 |
|---|---|---|---|
| 前進 | 35.0 cm | 33 cm | +6.1% |
| 横移動 | 43.6 cm | 40 cm | +9.0% |
両軸で過大=横移動固有ではありません。つまりこれまでの全地図が数%膨らんだスケールで作られていた(27.1mの地図は実際には約25m)。Nav2で距離を指示する時にも効いてきます。
ただし、この誤差は ①ホイール半径やギア比に起因する表面非依存の定数(単一係数で直せる)と、②畳・カーペット・フローリングで変わる表面依存のスリップ(単一係数では原理的に扱えない)の和です。
較正を深追いするより、表面依存のドリフトはSLAMのループ閉じとグラフ最適化に吸収させるのが筋、という結論になりました。深度取得の優先度がさらに上がったわけです。
現在地と、ここから
フチコマに必要な4能力の進捗です。
| 能力 | 状態 |
|---|---|
| 自分で回る | 地図構築+ループ閉じ+占有格子(PGM/YAML)まで到達。格子の品質改善→Nav2 が残り |
| 変化に気づく | 「見る」は動く。差分を見る仕組みは未着手 |
| 喋る | 達成(第2回) |
| 居続ける | 未着手。S1は自動充電ドックを持たないので自作が要る |
次にやることは、①占有格子の品質改善(Grid/RangeMax を2.0に、ノイズフィルタ、深度モデルをBase/Largeへ)、②Nav2、③硬い床で1.5m走らせてオドメトリの定数誤差を分離、の順です。
なお「変化に気づく」は、異常検知ではなく変化検出でいこうと考えています。何が異常かを機械に決めさせると閾値とデータセットの沼にはまるので、「前回と違う」とだけ出して、意味づけは人間か対話LLMにやらせる。実装難度が桁で違います。
教訓
- 症状が同じでも原因は変わりうる。 ループ閉じ却下を「映像破損のせい」と決めつけていましたが、実際は「単眼だから3D点が無い」でした。DBを直接読むまで分からなかった。
- 通った=正しい、ではない。 ゲートを緩めれば130件「閉じ」ますが、中身は全部長さ1.00mの偽拘束でした。
- 数値が自己整合していても物理は間違っていることがある。符号規約は結局「人が目で見る」まで閉じませんでした。
- 体感の違和感は追う価値がある。 「アプリより画質が悪い」という一言から、独立した3つのバグが出てきました。
- 良い問いは芋づるを引く。 「横移動なら成績が上がるのでは」を検証したら、本来の目的とは別に全地図に効くスケール誤差が見つかりました。
- 原因が正しく分かれば、対処はたいてい素直。 「3D点が無い」と確定した後の作業は、深度を作って同じスタンプで流すだけでした。ゲートは1つも緩めずに閉じています。 時間を食ったのは実装ではなく、原因の特定です。
- 機体を出す前に検証できる形にしておく。ただし再生基盤を信じすぎない。 却下されたループ候補396組と画像805枚をダンプしてあったので、深度の効果は**走らせる前に「25組中22組が閾値突破」**と分かりました。一方で同じ基盤が exact sync を「動く」と誤らせてもいます。自作の再生系が現実にない都合の良さを持ち込んでいないかは、別に疑う必要があります。
- 「閉じた」と「地図が使える」は別。 ループ閉じは画像対画像なので内部・外部パラメータ誤差に強く(fxが68.6%違っても20本閉じました)、3D再構成と占有格子はそれに直撃されます。必要な精度の種類が違うので、片方の成功はもう片方を保証しません。
- その指標は、本当に測りたいものを測っているか。 「距離ごとの全点のz中央値」は、正しく再構成できていれば必ず右肩上がりになる量でした。それを歪みの証拠として読み、存在しない26°の傾きに対する補正まで設計しています。もっともらしく単調な数列ほど危ない。
- カタログ値を信じない。 レンズの「FOV 120°」は光学系の仕様で、クロップ後に配信される映像は91.5°でした。1つの仮値が地図全体の縦スケールを1.69倍にしていました。
AI任せでやってみて(総括)
第1回の冒頭で書いたとおり、このプロジェクトのコードはほぼ全部Claudeが書いています。私は実機を動かして、キャプチャを取って、違和感を報告して、方針を決める係です。
3回ぶんを通して見えたことを、失敗のほうを中心にまとめます。
最悪の一発:実装済みなのに、一度も呼ばれていなかった
映像帯域が11倍になったあのバグです。
send_rt_ack() という関数はちゃんと実装されていました。仕様の理解も正しく、中身も正しい。ただ、呼び出しが0行でした。完全なデッドコード。
これは極めてAIらしい失敗です。「ACKを送る機能を実装する」というタスクは完遂されていて、関数は存在し、レビューしても正しく見える。組み込み忘れだけが抜けている。しかもエラーは出ません。映像は流れます。ただ遅いだけ。
見つかったきっかけは、私の「公式アプリより画質が悪い気がする」という一言でした。数値上は動いていたので、体感の違和感がなければ永久に埋まったままだったはずです。
自信を持って間違え、しかも「直した」と言う
符号規約の話です。途中で「S1のyawはCCW正なのでROSと同符号、よってここの反転は不要」という補正が入りました。
これは誤りで、しかも入れる前より壊れていました。
厄介なのは、この誤りが数値としては自己整合していたことです。ログを見ても矛盾がない。テストも通る。もっともらしい根拠まで付いてくる。最終的には実機の前にしゃがんで、車体がどっちに回るかを目で見るまで決着しませんでした。
AIは「数値の辻褄」で正しさを判断します。物理は見ていません。 ロボティクスでこれは致命的で、符号だけは人間が目視で確定させるしかない、というのが結論です。
間違った指標で、実在する壁を潰すところだった
いちばんヒヤリとしたのはこれです。
「床が26°反っている」という診断には、表があり、単調な数列があり、幾何的な説明(旋回するから円錐になる)があり、原因の特定(ジンバルpitchの符号)があり、対策(+30°補正)まで揃っていました。説得力という点では、この記事で一番よくできた分析です。
そして測っている量が間違っていた。距離が遠ければ床ではなく壁や棚の上部を拾うのだから、右肩上がりになるのは正常な地図の性質です。あのまま+30°補正を実装していれば、実在する壁と棚を平らに潰した地図ができて、しかもそれは「指標が改善した」と報告されたはずです。
止めたのは、点群ビューアを開いた人間の「床はそんなにおかしくない」でした。
AIは「その数字は何を測っているのか」を問い直すのが苦手です。 数字が揃っていて、説明がつき、対策が立つと、そのまま走ります。しかもこの手の誤りは指標の上では必ず改善して見えるので、内側からは検出できません。ブラスターの写り込みも同じで、私はずっと自分の砲身を風景として地図に入れていました。
学習させたら2回とも自家中毒を起こした
旋回精度を上げるために、実測から係数をオンライン学習する仕組みを入れたことがあります。
2回とも自家中毒を起こしました。 最終的には全部の旋回が逆走する状態まで壊れています。
原因は単純で、テレメトリのノイズを「学習すべき傾向」として取り込んでしまうからです。適応の速度がノイズの速度より遅くないと、システムは自分の観測誤差を学習して発散します。
固定定数に戻したら安定しました。「賢くする」方向の提案は魅力的に見えますが、フィードバックループを増やすことのリスクは、AIも私も過小評価していました。
自分のコードを最後に疑う
第2回でも書きましたが、これが一貫した傾向でした。
ループ閉じが却下される問題で、最初に立てた仮説は「WindowsからWSLへの転送でフレームが破損している」「RTAB-Mapのメモリ管理パラメータの調整不足」の2つ。どちらも外れでした。
真相は「単眼だから3D特徴が存在せず、PnPは原理的に成立しない」という、そもそも設定が間違っているという話です。これは調整ではなく設計の問題で、いくらパラメータをいじっても1本も閉じません。
決着したのは、sqliteでDBを直接開いて中身を数えたときでした。703ノードすべてに825〜979個の特徴があり、3D座標を持つものは636,904行中0個。数えれば一発で分かる話に、それまで別の仮説で時間を使っていたわけです。
ちなみにこの「DBを直読みして数える」という作業自体はAIが圧倒的に速い。sqlite3を叩いてスキーマを読んで集計するところまで数分でした。問いさえ正しければ、答えはすぐ出る。問いを間違えると、正しい答えが出ないまま延々と進みます。
そして、その日の地図を消した
オフライン検証を回すとき、--delete_db_on_start が 既定のDBパス(~/.ros/rtabmap.db)に対して使われました。その日の午前に実走行で作った209ノードのマップが消えました。
検証と本番で保存先を分ける、というだけの話です。ただ、こういう「破壊的なフラグを既定のパスに向ける」判断は、手が速いぶんだけ実行されるのも速い。幸い元画像は別途データセット化してあったので再生成できましたが、無ければその日は終わっていました。
以後 ~/run_rtabmap.sh は環境変数 RTABMAP_DB で保存先を指定できるようにしています。AIに実行させるなら、消えて困るものは既定パスに置かない、が実務的な結論です。
で、AI任せは良いのか
良いです。ただし人間の役割が変わるだけで、減りません。
このプロジェクトで一番価値のあった発見を並べると、性格がはっきりします。
| 発見 | きっかけ |
|---|---|
| 検査バイトはXORだった | 総当たりが全滅した後、前提を疑った |
| 映像ACKのデッドコード | 「画質が悪い気がする」という体感 |
| 符号規約の最終確定 | 実機を目視 |
| オドメトリが6〜9%過大 | メジャーで測った |
| exact syncが実機で成立しない | 実機で走らせた |
| 床は歪んでいない(私の誤診の否定) | ユーザが点群を目で見た |
| ブラスターが写り込んでいる | ユーザが映像を見た |
| ループ閉じが原理的に不可能 | DBを直接数えた |
| fxが68.6%違う | ChArUcoで実測した |
上6つは全部、物理世界か「実物を見ること」から来ています。 キーボードの前では出てきません。逆に、下2つのような「大量のデータを正確に数える」「校正計算を回す」作業や、解析コードの生成、ログからのパターン抽出はAIが圧倒的に速い。
つまりこうです。
- AIは検索空間を高速に潰す。空間そのものを間違えたら止まらない
- 人間の仕事は、コードを書くことから「前提を疑うこと」と「物理を見ること」に移る
- AIに"観測できる出口"(CLI・数値の戻り値)を渡すと、生産性が段違いになる
体感として、リバースエンジニアリングからVSLAMまでの道のりは、以前なら数ヶ月かかったはずです。それが大幅に短縮されたのは事実です。ただし短縮されたのは実装であって、理解ではありませんでした。理解が必要な場面では、結局こちらが機体の前にしゃがんでいます。
「詰み」と言われた機体でも、先入観を捨ててログを凝視すれば道は開きます。フチコマまではまだ遠いですが、閉じた地図と声は手に入りました。