
Unitree Go2 に自作パンチルト+RealSense+物体認識を載せて、見回り監視カメラを作ってみた
Unitree Go2 に自作パンチルト+RealSense+物体認識を載せて、見回り監視カメラを作ってみた
はじめに
インターン生の井出です。
今回は四足歩行ロボット Unitree Go2 (EDU) に、Bambu LabA1 miniで3Dプリントしたパン・チルト機構と深度カメラ Intel RealSense D435i を載せて、「きょろきょろと辺りを見回し、怪しいものが写ったらSlackに自動投稿する」というミニ監視カメラを作りました。
Go2にデフォルトで搭載されたカメラもあるのですが、それだと正面しか捉えることができないので、その死角を補う意図で今回のカメラ付きのパン・チルト機構を作成しました。
ちなみに、パンチルト機構とはカメラの向きを左右(水平)と上下(垂直)に動かす首振り機能のことです。
構成は大きく4つのステップに分かれます。
- RealSenseをGo2に接続して撮影できるか検証
- 撮った写真をSlackへ自動投稿する仕組み
- 3Dプリントしたパン・チルト機構をサーボで制御する
- 「怪しいか」をVLM(Vision-Language Model)に判定させる
ソフトだけでなくハードのハマりどころも多く、電源設計・USBの癖・LLM推論の非決定性など、想定より多くの壁がありました。今回はその過程を、失敗も含めて記録します。また、ClaudeCodeを用いて開発していることもあらかじめ断っておきます。
実際の映像
まずはイメージを掴んでいただくために実際に動いているところのgifです。容量の関係上2秒程度ですが、実際はもっと首が横向きに5箇所動いて、写真を撮って状況を判定しています。

全体構成
[Go2搭載Jetson Orin NX] [DGX Spark (GB10, Blackwell)]
RealSense D435i (撮影) Cosmos-Reason2-2B (物体認識の推論)
Arduino Uno + SG90サーボ×2 (パン・チルト) judge_server.py (Flask常駐)
Slack API (投稿) │
│ │
└──────── 撮った画像をHTTPで送る ──────────────┘
使った機材
| 機材 | 型番・仕様 |
|---|---|
| ロボット | Unitree Go2 EDU モデル |
| カメラ | Intel RealSense D435i(深度カメラ、IMU内蔵、90×25×25mm) |
| サーボ | SG90(プラスチックギア)×2、パン・チルト用 |
| マイコン | Arduino Uno(純正、ATmega16U2搭載) |
| パンチルト機構 | thingiverse.com/thing:2467743 を3Dプリント |
| 給電(サーボ用) | Go2本体の5Vポート(XT30コネクタ)から直接 |
| 物体認識モデル | NVIDIA Cosmos-Reason2-2B(Qwen3-VL-2B-Instructベース) |
| 開発環境 | Go2搭載Jetson Orin NX(JetPack 5.1.1)、DGX Spark(GB10) |
ステップ1: RealSenseをGo2に接続して撮れるか検証する
以前の開発者のおかげにより、librealsenseが既に入っていた
Go2 EDU には Jetson Orin NX が搭載されていて、SDKによる二次開発が公式にサポートされています。「RealSense用のライブラリ(librealsense)をJetson上でソースからビルドする必要がある」と身構えていたのですが、実際にSSHで入って調べてみると——
$ ldconfig -p | grep realsense
librealsense2.so.2.57 (libc6,AArch64) => /usr/local/lib/librealsense2.so.2.57
すでにインストール済みでした。 さらに、/home/unitree/realsense-stream/ というディレクトリに、Flaskで映像配信する既存のスクリプトまで見つかりました。おそらく過去にこの機体をセットアップした人が用意してくれていたようです。
USB3.0で接続、実際に撮影
$ lsusb -t
|__ Port 2: Dev 2, ... Driver=uvcvideo, 5000M ← USB3.0速度で認識
$ rs-enumerate-devices -s
Intel RealSense D435I 013222073242 5.12.6
5000Mという表示がUSB3.0で繋がっている証拠です(480MならUSB2.0で、RealSenseの帯域には不足します)。
きちんと天井の写真が撮れていました
ステップ2: 撮った写真をSlackに送る
Slack接続の手順
Slackへのファイル送信は、以下の3段階の手順が必要です。
import requests
def post_to_slack(token, channel, path, comment):
size = os.path.getsize(path)
filename = os.path.basename(path)
# ① アップロード用URLをもらう
r = requests.post("https://slack.com/api/files.getUploadURLExternal",
headers={"Authorization": f"Bearer {token}"},
data={"filename": filename, "length": size})
upload_url = r.json()["upload_url"]
file_id = r.json()["file_id"]
# ② そのURLに画像をPOST
with open(path, "rb") as f:
requests.post(upload_url, files={"file": (filename, f, "image/jpeg")})
# ③ チャンネルに紐付けて投稿完了
requests.post("https://slack.com/api/files.completeUploadExternal",
headers={"Authorization": f"Bearer {token}"},
json={"files": [{"id": file_id, "title": filename}],
"channel_id": channel, "initial_comment": comment})
slack_sdkライブラリを使えばもっと簡潔に書けますが、Jetson側に新しいパッケージを増やしたくなかったのでrequestsだけで組みました。
権限は必要最小限に
SlackアプリのBot権限(スコープ)は files:writeとchat:writeの2つだけにしました。読み取り系の権限は一切与えていません。この設計が意図通り効いているのは、後から自分で確認しようとしたときに分かりました。
$ curl https://slack.com/api/files.info?file=xxx
{"ok":false,"error":"missing_scope","needed":"files:read"}
投稿はできるけど覗き見はできない、というBotになっています。
今回は個人無料slackワークスペースで
個人の無料Slackワークスペースを作って、そこで開発を進めるという方法を取りました。SlackのAPIはワークスペースが違っても完全に同じなので、組織のslackに移行したい場合はトークンとチャンネルIDを差し替えるだけで移行できます。
ちなみに:投稿時刻がなぜか1時間ズレた
動作確認をしていたら、Slackに投稿されたコメントの時刻が実際の時刻より1時間遅れていることに気づきました。原因を調べると——
Jetson: Time zone: Asia/Shanghai (CST, +0800)
自分の作業機: Time zone: Asia/Tokyo (JST, +0900)
Go2搭載のJetsonのタイムゾーンが、中国時間のままになっていました。 時計そのものはズレておらず(世界標準時では両者一致)、Unitree(中国メーカー)の出荷時設定がそのまま残っていた、という話でした。ロボット本体には触らず、スクリプト側でJSTを明示して解決しました。
JST = datetime.timezone(datetime.timedelta(hours=9))
now = datetime.datetime.now(JST).strftime("%Y-%m-%d %H:%M:%S")

↑実際のslackの画面。今回使用したGo2は社内で「ピカチュウ」と呼ばれているので、アイコンもピカチュウです。
ステップ3: パンチルト機構を作る
パンチルト機構を印刷
3DプリンターのCADデータのwebサイトを漁り、今回はこちらを採用。
→https://www.thingiverse.com/thing:2467743
ダウンロードし、早速BambuLab A1 Miniで印刷。

ざっとサーボのトルクを計算してみる
D435iの重さは約72g。SG90のストールトルクは約1.8kgf·cm なので——
重心がチルト軸から1.5cm : 72g × 1.5cm = 0.108 kgf·cm (定格の約6%)
雑に付けて4cm離れても : 72g × 4.0cm = 0.29 kgf·cm (定格の約16%)
トルクは全く問題ない計算になりました。 壊れるとしたらトルク不足ではなく、プラギアの摩耗(重力に逆らって支え続けるチルト軸)や軸の横荷重(片持ちで機構全体を支えるパン軸)だろうと考え、金属ギアのMG90Sへの換装も検討しましたが、まず現状のSG90で動かしてみることにしました。
電源設計の注意点
サーボの電源をどこから取るかで、いくつか気をつけるべき点がありました。
- RealSenseはUSB給電のみで完結する。外部の12Vを繋いではいけない(そもそもUSB Type-C以外の電源入力を持たない)
- サーボの電源はArduino経由にしない。 Arduino自体のUSB給電では、サーボ2個の突入電流(最大約1.4A)を賄えず、Arduinoがリセットしてしまうので、Go2から直接電源を供給
- サーボモータSG90の動作電圧が4.8~6Vなので、Go2の電源供給の挿し口は12Vでなく、絶対に5Vで。12Vだと電圧が高すぎてサーボが壊れる可能性がある。
- カメラの電源系統とサーボの電源系統は分ける。 モーターのノイズが同じ系統のUSB機器に悪影響することがある
最終的に、Go2本体の5Vポート(XT30コネクタ)から直接給電する構成に落ち着きました。
実際の配線はこんな感じ↓

Arduinoへの書き込みで足止め — USBハブの罠
Arduinoに繋ぐケーブルが手元になく、余っていたケーブルを継ぎ合わせて対応した結果、市販のUSBハブを経由する構成になりました。これが思わぬ落とし穴でした。
$ arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:avr:uno ...
Error: programmer is not responding
Warning: attempt 1 of 10: not in sync: resp=0x00
USBとしての認識自体は正常なのに、書き込みだけ必ず失敗する。 原因は、書き込み時に使う「リセットしてね」という制御信号(DTR)だけが、ハブを経由すると正しく伝わらないことでした。ハブを外し、直結にした瞬間に成功しました。
さらに、書き込み後の「巡回ループの実行」段階でも、ハブの種類を変えても待機時間を伸ばしても直らない不具合に遭遇し、切り分けた結果——
USBケーブルを物理的に抜き差しした直後の「最初の1回」のシリアル接続だけが確実に成功し、それ以降は次の抜き差しまで全部失敗する
という癖があると分かりました(根本原因は未解明)。運用上「毎回抜き差しする」のは非効率だったため、後日これも解決することになります(後述)。
可動範囲を実測する
配線ができたところで、実際にサーボを振って可動範囲を確認しました。仮に決めていた値(パン0〜180度、チルト20〜120度)のまま、10度刻みで両端まで動かします。両端まで、つっぱり・異音・Go2本体への接触、いずれも無く動作しました。 3Dプリント機構の設計値がそのまま実用に耐えることが確認できたので、調整は不要でした。

完成:巡回ループの実行
Arduino制御・撮影・Slack投稿を1本にまとめた patrol_loop.py を実行し、5方向すべてで移動→撮影→投稿が成功しました。
moving to pan=30 → posted to Slack ✅
moving to pan=60 → posted to Slack ✅
moving to pan=90 → posted to Slack ✅
moving to pan=120 → posted to Slack ✅
moving to pan=150 → posted to Slack ✅

ステップ4: 「怪しいか」をVLMに判定させる
ここからが今回の本題です。撮影・投稿・パンチルト制御まで動いたので、いよいよ「本当に怪しいものが写ったときだけ通知する」判定部分を作ります。
あえて重量級のCosmos-Reasonを選ぶ
「もっと軽量なモデルの方が現実的では」とも考えましたが、今回は筆者自身の学習を優先して、NVIDIAの Cosmos-Reason2-2B を使うことにしました。 ベースはQwen3-VL-2B-Instruct、パラメータ数は約2.4Bです。
壁1: Go2搭載Jetsonでは動かせない
公式モデルカードには、こう書いてあります。
最小24GB GPUメモリが必須。対応マイクロアーキテクチャはHopper/Blackwellのみ
Go2搭載のJetson Orin NXは16GB・Ampere世代。要件を満たしません。一方、開発に使っているDGX Spark(GB10、Blackwell世代、121GB統合メモリ)ならこの要件を満たします。そこで、推論はDGX Spark側で行い、Go2は撮影に専念する構成にしました。
[Go2搭載Jetson] [DGX Spark]
RealSense撮影 Cosmos-Reason2-2B
Slack投稿 (Flaskサーバーとして常駐)
│ │
└──── 撮った画像をHTTPで送る ──────┘
環境構築は標準のpip install torchだけで、CUDA 13.0対応ビルドが自動的に入りました。Jetson系だと専用wheelのビルドに苦労することが多いのですが、DGX SparkはL4Tベースではなく通常のLinuxサーバーに近い構成のようです。
正直に言うと、ここから先はまだよく分かっていません
環境が整い、最初の推論には成功しました。「じゃあこれで完成」と思っていたのですが、実際に巡回ループに組み込んでテストすると、判定結果がとにかく不安定でした。
ここから先の内容は、自分でも完全には理解できていません。 何をやりたかったか、何を試したか、何が起きたか、何が足りなかったかを、分かる範囲で書きます。
やりたかったこと: 「本当に怪しいものが写ったときだけ通知する」、それだけです。理屈としてはシンプルなはずでした。
起きたことの例: 刃物っぽいもの(カッター)が写った画像で「怪しい」と判定されたとき、「刃物はどんな状況でもリスクがあるけど、ロボット開発の現場で使っているなら、他の状況よりは安全なはず」と思いました。そこで、「ここはロボット開発ラボです」という一文をAIへの指示に足してみたところ、同じ画像が「怪しくない」に変わりました。
何が足りなかったか: 文脈に依存せず、刃物など潜在的に危険なものは危険であるとして、その上での危険かどうかの判断は文脈に依存させる形を考え、実装してみましたが、Slackに送られてくるCosmos-reasonの回答は、結論として「怪しい」とは言っているものの文章が非常に冗長で、パッと見で「結局、今すぐ対応すべき危険があるのか?」が分かりにくいものでした。
ロボットがパトロールしている場所の「文脈」は、危険判定の重要な指標になります。しかし、これをAIへの指示としてどこまで一般化できるのか、自分でもまだ答えが出ていません。精度の高い判定を行うためには、「普段のその場所の映像」や「環境を説明するテキスト」といったベースラインのデータと、「どこからが異常(危険)なのか」という明確な基準設定が必要不可欠であり、その難易度の高さを痛感しました。
まとめると、「動かしてはみたけれど、状況を判断するための『普段の文脈データ』と『賢い脳みそ』を上手く噛み合わせないと、実用レベルで精度良く判定させるのは非常に難しい」というのが正直なところです。VLMを実際に業務フローに組み込んでみて、思っていたよりもずっとブラックボックスな部分が多いと感じました。
この記事を読んで、思い当たる原因や改善案がある方がいれば、ぜひ教えていただきたいです。
おまけ1: 手動テスト用のライブビュー画面
いちいち巡回ループを回さなくても、その場でカメラの映像を見ながらテストできると開発が捗ります。そこでGo2側に、ブラウザで見られる簡易ダッシュボードを追加しました。

- 上部にRealSenseのライブ映像(MJPEG配信)
- 「撮影して判定する」ボタンを押すと、その瞬間の映像を1枚キャプチャしてCosmos-Reasonに送り、判定結果を画面に表示
このツールを作っている最中にも、教訓になる不具合を踏みました。RealSenseが一時的にフレーム取得に失敗する(Frame didn't arrive within 5000)ことがあり、そのエラーを拾っていなかったため、映像配信用のスレッドがそこで完全に停止してしまうという現象です。
# 修正前: 1回のエラーでスレッドごと死ぬ
pipe.start(cfg)
while True:
fs = pipe.wait_for_frames() # ここで例外が飛ぶと復帰しない
...
# 修正後: 外側のループでパイプラインごと作り直す
while True:
pipe = rs.pipeline()
try:
pipe.start(cfg)
while True:
fs = pipe.wait_for_frames(timeout_ms=5000)
...
except RuntimeError as e:
print(f"camera error, restarting: {e}")
finally:
pipe.stop()
time.sleep(1)
常駐サーバーは、外部ハードウェアが投げてくる例外を最初から拾って回復する設計にしておくべきだった、というのは今回の反省点です。
おまけ2: Arduinoを「毎回抜き差し」しなくて済むようにする
ステップ3で見つけた「USBを抜き差しした直後の1回だけ確実に動く」というArduinoの癖は、実際にとても不便でした。毎回スクリプトを実行するたびにケーブルを抜き差しする必要があったのです。
対策は、Arduinoとの接続を1回だけ開いたまま常駐する小さなサーバーを追加することでした。Cosmos-Reasonの推論サーバーと全く同じ発想です。
[今までの構成] [新しい構成]
スクリプト実行のたびに arduino_server.py が起動時に1回だけ
シリアル接続を開く シリアルを開いて常駐
→ 2回目以降は応答なし 巡回スクリプトはHTTPで「動かして」と頼むだけ
(シリアルを直接開かない)
同じサーバーに対して巡回スクリプトを2回連続実行し、再起動・抜き差し無しで両方とも成功することを確認しました。「常駐させて使い回す」というパターンが、今回のプロジェクトのあちこちで効いています。
まとめ
今回、以下が実機で動くところまで完成しました。
- Go2搭載Jetsonへの RealSense D435i 接続・撮影
- Slack API(現行の3段階アップロード方式)への画像投稿
- Arduino + SG90 によるパン・チルト制御
- Cosmos-Reasonによる「怪しいか」の判定と、条件付きSlack通知
- ブラウザから手動でテストできるライブビュー画面
物体認識のパイプライン自体は簡単に組めましたが、実用に足る精度に近づけるまでに、想定より多くの壁がありました。 判定と理由が矛盾する、否定表現をうまく拾えない、同じ画像なのに結果が変わる、文脈を無視して判定する——そのたびに何かしら手を打ちましたが、正直「なぜそれで直ったのか」を完全には説明できていません。
「動くには動くが、まだまだ改善の余地しかない」といった具合で、一度動いたからといって安心できないタイプの問題ばかりでした。 自分はロボット開発もAIも初心者で、VLMを触るのも初めてだったので、この記事は「うまくいった手順書」というより「初心者が試行錯誤した記録」として読んでもらえたらと思います。
電源設計・USBの癖など、ハードウェア側にも同じくらいの発見がありました。ソフトウェアだけの開発とは違う、実機を触る面白さと難しさを感じた1本になりました。
今後やりたいこと
現在、RealSenseはガムテープでパンチルトに仮止めされています。今後は
- 3Dプリントした専用マウントへの置き換え
- 現状パンチルト機構は横向きに5方向動いて写真を撮るだけなので、上下方向の動きも追加
- パンチルト機構を必要に応じてスティック操作してあらゆる方向を見れるようにする
- カメラで捉えた対象に対して実際にどんな判断を下すかの仕組みづくり
などできていければな、と考えております。
それでは。






