Unitree Go2とG1を活用したロボット目視検査エージェント:Go2の実カメラ映像をローカルVLMで継続解析する

Unitree Go2とG1を活用したロボット目視検査エージェント:Go2の実カメラ映像をローカルVLMで継続解析する

Week 2 progress of the Robot Visual Inspection Agent project!
2026.07.16

はじめに

今週では、先週構築したRobot Visual Inspection AgentのPoCを、手動撮影画像から実際のUnitree Go2フロントカメラへ接続しました。

先週の時点では、iPhoneで低い位置から撮影した画像を使い、以下のパイプラインが動作していました。

ローカル画像

Qwen2.5-VLによる解析

構造化JSON出力

InspectionResultスキーマ検証

Markdownレポート生成

今週は、Go2フロントカメラから一定間隔で静止画を取得し、常駐させたQwenで継続的に解析する構成へ拡張しました。

Go2フロントカメラ

数秒ごとに静止画を取得

JPEGとして監視フォルダへ保存

Qwenを一度だけロードした常駐プロセス

新しい画像を継続的に解析

JSON / Markdownレポートを生成

現在の実装は動画ファイルや映像ストリームそのものをVLMへ入力しているわけではありません。

Go2カメラから一定間隔で静止画を取得する、周期撮影ベースの継続検査です。

そのため、本記事では「リアルタイム動画解析」ではなく、「定期取得したカメラ画像の継続解析」と表現します。


今週できたこと

今週は、主に以下の作業を進めました。

  • Go2と推論環境のネットワーク接続を確認
  • Go2上のROS 2環境とカメラデバイスを調査
  • Unitree SDK2 Python / CycloneDDS環境を構築
  • Go2フロントカメラから実画像を取得
  • 実際のGo2画像をqwen_localで解析
  • Qwenを一度だけロードする常駐型watch runnerを実装
  • Go2カメラ画像を一定間隔で取得するループを実装
  • 新しい画像が保存されたときだけ解析する仕組みを実装
  • フレームごとの処理時間を計測
  • optionalなspeech queueを実装
  • JSON repairとalias normalizationを強化
  • リスクレベル判定を実画像で評価
  • プロンプト例文のコピーによる誤判定を分析
  • 後処理によるリスク判定の誤変換を分析
  • Go2本体スピーカーの利用方法を調査
  • VLMによる観測とPythonによるリスク判定を分離する方針を整理

Go2フロントカメラ画像の取得

Go2フロントカメラは、一般的なUSBカメラのように/dev/video0から取得できませんでした。

また、今回確認した環境ではROS 2のコマンドも正常に利用できなかったため、Unitree SDK2 Python / DDS経由で画像を取得する方法を採用しました。

画像取得は、Unitree SDK2 Pythonに含まれる公式カメラ取得サンプルを利用しています。

実行イメージは以下です。

python example/go2/front_camera/capture_image.py <NETWORK_INTERFACE>

取得された画像は以下の形式でした。

format:
  JPEG

resolution:
  1920 x 1080

size:
  約224KB

事前には1280×720程度を想定していましたが、実際に取得できた画像は1920×1080でした。


実際のGo2画像をQwenで解析

取得したGo2画像を、ローカルのQwen2.5-VL-3B-Instructへ入力しました。

アプリケーション側の実行イメージは以下です。

python app/cli.py \
  --image samples/real_tests/go2_front_001.jpg \
  --robot Go2 \
  --location "office test area" \
  --image-source dds \
  --camera-source go2_front_camera \
  --vlm qwen_local

推論時には、処理時間を抑えるために画像を以下のサイズへ縮小しています。

1920 x 1080

768 x 432

Raw outputの一部は以下でした。

{
  "scene": "office",
  "quality": "clear",
  "objects": [
    "black door",
    "glass wall",
    "stacked boxes",
    "monitor",
    "cable on floor"
  ],
  "hazard": "cable",
  "riskLevel": "medium",
  "action": "Ask an operator to confirm or secure the cable.",
  "confidenceScore": 0
}

最終的には、正規化されたJSONとMarkdownレポートを生成できました。

Go2フロントカメラ

Unitree SDK2 Python / DDS

JPEG画像

Qwen2.5-VL

JSON repair / normalization

InspectionResult

JSON / Markdownレポート

実際のロボットカメラ画像から、自分で作ったパイプラインが最後まで動作したため、とても感動しました。


単発画像から周期撮影ベースの継続検査へ

これまでのCLIでは、画像を1枚解析するたびにQwenをロードしていました。

プロセス起動

Qwenロード: 約40〜50秒

画像を1枚解析

終了

この構成では、カメラ画像を10秒ごとに取得しても、毎回モデルロードが必要になります。

そこで、app/watch_run.pyを追加しました。

プロセス起動

Qwenを1回だけロード

画像フォルダを監視

新しい画像を解析

再び監視

Ctrl+Cまで継続

現在は、以下の3プロセスに役割を分けています。

Terminal A:
  Qwenを一度だけロード
  新しく保存された画像を解析
  JSON / Markdownを保存

Terminal B:
  speech request JSONを監視
  risk levelをprintまたは発話

Terminal C:
  Go2フロントカメラから一定間隔で静止画を保存

主要なコマンドは以下のような形です。

# 解析プロセス
python app/watch_run.py \
  --image-dir samples/go2_periodic_eval \
  --robot Go2 \
  --location "office test area" \
  --image-source dds \
  --camera-source go2_front_camera \
  --vlm qwen_local \
  --poll-interval 1 \
  --summary-file watch_summary.json \
  --skip-existing
# カメラ画像の周期取得
python robot_io/go2_capture_loop.py \
  --iface <NETWORK_INTERFACE> \
  --out-dir samples/go2_periodic_eval \
  --interval 10 \
  --count 12

現在はデバッグしやすさを優先して3つのターミナルに分けています。

将来的にはtmuxやsupervisorスクリプトを利用し、1コマンドで起動できるようにしたいと考えています。


処理時間の計測

watch_run.pyには、画像ごとの処理時間を記録する処理を追加しました。

{
  "image": "samples/go2_periodic_eval/go2_front_xxx.jpg",
  "status": "success",
  "risk_level": "medium",
  "path_blocked": false,
  "processing_time_sec": 5.24
}

実際に計測した結果は以下でした。

Qwen cold start:
  約40〜50秒

モデルロード後の画像処理:
  約4〜7秒

重い処理は毎回の画像解析よりも、モデルの初期ロードです。

Qwenを常駐させることで、10秒間隔の周期撮影であれば、現時点では大きなバックログを発生させずに処理できました。

5秒間隔については、一部の画像で処理時間が5秒を超えるため、バックログが発生する可能性があります。


optionalなrisk level通知

VLMの解析結果に応じて、以下のようなメッセージを通知する仕組みも追加しました。

Risk level, low.
Risk level, medium.
Risk level, high.

解析プロセスと音声出力を分離するため、speech queueを利用しています。

watch_run.py

speech request JSON

speech_watch.py

printまたは音声出力

現時点では、print backendで動作を確認しています。

[SPEAK] Risk level, medium.

Go2本体スピーカーについても複数の方法を調査しましたが、現在の環境ではプログラムから任意音声を再生する方法を確立できませんでした。

来週、新しい外付けスピーカーを用意していただける予定です。

外付けスピーカーが利用できれば、以下のようなシンプルな構成にできます。

speech request JSON

speech_watch.py

ローカルTTS

外付けスピーカー

例えば、以下のようなコマンドをspeech watcherから実行できます。

espeak-ng "Risk level, medium."

外付けスピーカーにより音声出力部分の実装を簡略化できる見込みのため、音声機能の続きは来週に進めることにしました。


JSON出力の安定化

Go2のカメラ画像を継続的に解析する中で、QwenのJSON出力が不安定になるケースがありました。

確認した形式崩れは以下です。

single quotes
PythonのTrue / False
コメント
trailing comma
Markdown code fence
ネストされたscene / image / path
bbox_2dを含む物体検出形式

そこで、現在は以下の順序でパースしています。

strict JSON parse

軽微なJSON repair

Python boolean / None変換

ast.literal_eval

alias normalization

nested structure repair

生成設定も単純化しました。

generated_ids = model.generate(
    **inputs,
    max_new_tokens=384,
    do_sample=False
)

repetition_penaltyno_repeat_ngram_sizeは、JSONに必要な繰り返し構造を壊す可能性があるため削除しました。

最新の評価では、12枚すべての画像でJSONのパースに成功しました。

JSON parse success:
  12 / 12

リスク判定の評価

JSON形式が安定した一方で、リスク判定については課題が残っています。

ケーブルに敏感すぎる問題

初期ルールでは、画像内にケーブルが見えるだけでmediumになる傾向がありました。

しかし、以下のようなケーブルは必ずしも危険ではありません。

遠くにあるケーブル
壁際のケーブル
家具の横にあるケーブル
進行方向に入っていないケーブル

そこで、距離と進路上の位置を考慮するようにプロンプトを変更しました。


すべてhighになる問題

近距離物体をhighにするため、具体的な表現をプロンプトへ追加したところ、Qwenがその表現を画像に関係なくコピーすることがありました。

large box blocking lower-center path
person very close in center path

例えば、実際には検出されていない箱や人を、障害物として出力するケースがありました。

これは、プロンプトの具体例が出力へ漏れる、prompt example leakageに近い問題です。

そのため、具体的な例文を削除し、以下のルールへ変更しました。

  • 現在の画像に見える物体だけを出力する
  • detected_objectsにない物体を障害物として扱わない
  • human_presence=falseの場合は人に関するハザードを除去する
  • プロンプト内の表現をコピーしない
  • 物体と近距離表現が同じ項目に含まれる場合のみhigh候補にする

後処理による誤判定

Raw outputがlowでも、正規化後にhighになる問題も見つかりました。

以前の後処理では、以下のフィールドを結合してキーワード検索していました。

detected_objects
obstacles
potential_hazards
recommended_action
summary_ja

例えば、デフォルトの推奨アクションは以下です。

No immediate action is required.

この中のimmediateが、近距離を表すキーワードとして誤って利用されていました。

現在は、リスク判定に利用する情報を以下へ限定しています。

Primary evidence:
  obstacles
  potential_hazards

Secondary evidence:
  近距離表現を同じ文字列に含むdetected_objects

Do not use:
  recommended_action
  summary_ja
  speech text

推奨アクションや要約は、リスク判断から生成される側の情報です。

それらを再びリスク判定の根拠に使うと、循環したロジックになってしまいます。


最新の評価結果

最新の評価では、12枚のGo2カメラ画像を処理しました。

JSON parse success:
  12 / 12

normalized risk:
  low: 10
  medium: 2
  high: 0

processing time:
  average: 約5.24秒
  minimum: 約4.47秒
  maximum: 約6.46秒

前回発生していた「ほぼすべてhighになる」という問題は解消できました。

また、以下のように明示的な進路情報がある場合はmediumとして正規化できました。

boxes blocking the path
cables crossing the path
boxes in the way
clutter obstructing the path

一方で、人や箱が近くに存在していても、VLMの出力が一般名詞だけの場合、highを判断できませんでした。

{
  "detected_objects": ["boxes", "person"],
  "obstacles": ["boxes"],
  "potential_hazards": ["person"]
}

この情報だけでは、以下の違いを区別できません。

遠くにいる人
目の前にいる人

壁際の箱
ロボットの直前にある箱

これが、現在のリスク判断における最大の課題です。


次の設計方針

今後は、VLMに最終的なリスクレベルを直接決めさせるのではなく、画像から観測情報を構造化して出力する役割を担当させる方針です。

リスク判断は、Pythonで決定的に計算する構成を検討しています。

VLM:
  シーンや物体を観測する
  位置・距離・進路状態を構造化する

Python Policy:
  観測情報からrisk levelを決定する

LLM:
  最終結果を人間向けに説明する
  曖昧なケースの補助判断を行う

観測スキーマには、以下のようなフィールドを追加したいと考えています。

{
  "forward_path_status": "partially_obstructed",
  "closest_forward_object": "boxes",
  "closest_forward_object_position": "center",
  "closest_forward_object_proximity": "near",
  "closest_forward_object_size": "large",
  "cable_crosses_forward_path": false
}

Python側では、例えば以下のようなルールでリスクを決定できます。

if forward_path_status == "blocked":
    risk_level = "high"
    path_blocked = True

elif (
    closest_forward_object_position == "center"
    and closest_forward_object_proximity == "very_near"
):
    risk_level = "high"
    path_blocked = True

elif forward_path_status in {
    "caution",
    "partially_obstructed"
}:
    risk_level = "medium"
    path_blocked = False

elif cable_crosses_forward_path:
    risk_level = "medium"
    path_blocked = False

else:
    risk_level = "low"
    path_blocked = False

LLMを最終リスク判定に毎回利用する方法も考えられますが、現在のVLM処理だけで1枚あたり約4〜7秒かかっています。

さらにLLM処理を追加すると、10秒間隔の周期撮影でもバックログが発生する可能性があります。

また、安全判断をLLMだけに任せると、同じ入力でも出力が揺れる可能性があります。

そのため、現時点では以下のハイブリッド構成が適していると考えています。

通常ケース:
  Python Policyで判定

曖昧なケース:
  LLMによる補助確認

人間向け説明:
  LLMまたはReport Generatorで生成

時間方向の情報も利用したい

現在は、一定間隔で取得した各画像を独立して解析しています。

しかし、同じシーンでも以下のように結果が揺れる可能性があります。

image 1:
  low

image 2:
  medium

image 3:
  low

今後は、直近3枚程度の結果を利用した時間方向の平滑化も追加したいと考えています。

low
medium
low

→ final risk: low

高リスクについては、安全側に倒しつつ誤検知を抑えるため、連続して検出された場合のみ確定する方式も考えられます。

high
high
high

→ confirmed high risk

周期撮影ベースの検査でも、単一画像だけで判断するより、直近の履歴を利用することで結果を安定させられる可能性があります。


次のステップ

次に取り組む予定は以下です。

  1. VLMの出力を、物体の位置・距離・見た目の大きさ・進路状態などの観測情報を中心としたスキーマへ変更する
  2. VLMの観測結果をもとに、Python Policyで決定的にrisk levelを判定する
  3. 同じ評価画像を使ってbefore / afterを比較し、直近数枚の結果を利用した平滑化も検討する
  4. 現在の3プロセス構成をtmuxまたはsupervisorでまとめ、起動方法を簡略化する
  5. 来週到着予定の外付けスピーカーを接続し、risk levelの音声通知を実装する
  6. 目視検査の精度と安定性を確認した後、VLAを利用した安全な移動連携を段階的に検討する

移動連携については、VLMから直接歩行コマンドを生成する構成にはしません。

以下のように、安全ポリシーと事前定義された移動スキルを介した構成にします。

VLMによる観測

Python Safety Policy

人間の承認または安全条件の確認

事前定義された移動スキル

ロボット移動

最初はシミュレーションや安全が確保された限定環境で検証し、緊急停止や人間によるオーバーライドを用意した上で段階的に進める予定です。


まとめ

先週作成したローカル画像ベースのPoCを、実際のUnitree Go2フロントカメラへ接続できました。

さらに、単発画像処理から、Go2カメラ画像を一定間隔で取得して継続的に解析する構成へ拡張しました。

今週実現できた主な内容は以下です。

Go2実カメラ画像の取得
Qwenによる実機画像解析
Qwenの常駐化
周期撮影された画像の継続解析
JSON / Markdownレポート生成
画像ごとの処理時間計測
optionalなspeech queue
JSON repairの強化
risk levelポリシーの実画像評価

一方で、実機カメラを使ったことで、ローカル画像だけでは分からなかった課題も見えてきました。

特に、VLMに物体検出・距離推定・進路判定・リスク判断をすべて担当させると、プロンプト変更によって結果が大きく変化します。

今回の検証で得た大きな学びは以下です。

「VLMは画像から観測情報を抽出する役割に寄せ、最終的なリスク判断は決定的なPython Policyとして分離した方が、安定性・説明可能性・評価可能性を高められる。」

また、周期的に取得した画像を扱う場合、各画像を独立して判断するだけではなく、時間方向の一貫性も重要です。

次週は、観測スキーマとリスクポリシーを分離し、同じ評価画像を使ったbefore / after比較を進めます。

さらに、来週到着予定の外付けスピーカーを利用したrisk levelの音声通知、3プロセス構成の起動方法の簡略化、安全な移動連携に向けた設計にも取り組む予定です。

この記事をシェアする

関連記事