mechArm 270のリアル動作がUnityシミュレーションで再現された|R2R2Rループ検証
LOG132R2R2R2026/4/8

mechArm 270のリアル動作がUnityシミュレーションで再現された|R2R2Rループ検証

完全自動ループの初回実行。そして実機の関節角度データがUnityのロボットを動かすまで。

💡 Key Takeaway

「Real → Render」が繋がった。実機mechArmが動いた軌跡のCSVを、Unityのロボットがそのまま再生できる。指令値と実測値のズレがデータとして見えた瞬間、Phase 2の本当の作業が始まった。

やったこと

起きたこと

判断

r2r2r_loop.pyを実行した

paramikoが未インストールだった

pip installして即解決

SSH経由でPiにスクリプトを転送した

~/がパス展開されずエラー

/home/ubuntu/にフルパスで修正

バックグラウンドスレッドで角度をロギングした

pymycobotがシリアル競合で全サンプル失敗(0行)

メインスレッドでステップ後に1回取得する方式に切り替えた

11ステップ分の関節角度を取得してUnityで再生した

実機の動きがシミュレーションで再現された

指令値と実測値のズレがデータとして見えた

完全自動ループ、初回実行

— コマンドを1本打てば、ロボットが動いてカメラが判定して結果が記録される。

有線Ethernetを永続化して、paramikoによるSSH自動実行を実装した。それを初めて本番で動かす日だった。

cd ~/r2r2r-scripts && python3 r2r2r_loop.py

最初のエラーは想定内だった。

ModuleNotFoundError: No module named 'paramiko'

pip install paramiko で即解決。次のエラーはもう少し込み入っていた。

× ファイル転送エラー: [Errno 2] No such file

原因はREMOTE_SCRIPTの設定だった。r2r2r_loop.py の中でこう書いていた。

REMOTE_SCRIPT = "~/pick_and_place_fixed.py"

paramikoのSFTPはシェルを介さないため、チルダを展開しない。フルパスに修正した。

REMOTE_SCRIPT = "/home/ubuntu/pick_and_place_fixed.py"

その後、カメラエラーも出た。IriunがTP-Link_9824に接続されていなかった。接続して再実行した。

Step3:  APPROACH_PICK位置へ移動  → 目標角度: [-54.22, 81.91, -30.14, -8.08, -50.27, -29.44]  ✓ 完了
Step4:  PICK位置へ移動           → 目標角度: [-46.93, 102.74, -43.33, 3.6, -60.11, -31.9]    ✓ 完了
Step5:  グリッパーを閉じる                                                                       ✓ 完了
Step6:  APPROACH_PICK位置へ戻る  → 目標角度: [-54.22, 81.91, -30.14, -8.08, -50.27, -29.44]  ✓ 完了
Step7:  APPROACH_PLACE位置へ移動 → 目標角度: [59.85, 16.87, 27.94, 10.01, -45.17, -35.33]    ✓ 完了
Step8:  PLACE位置へ移動          → 目標角度: [54.49, 45.17, 26.63, 2.9, -70.57, -29.26]      ✓ 完了
Step9:  グリッパーを開く                                                                         ✓ 完了
Step10: APPROACH_PLACE位置へ戻る → 目標角度: [59.85, 16.87, 27.94, 10.01, -45.17, -35.33]    ✓ 完了
Step11: ホーム位置へ戻る         → 目標角度: [0, 0, 0, 0, 0, -30]                              ✓ 完了

[Attempt 1/3] Red ratio: 18.3% → OBJECT DETECTED ✓
📝 ログ記録: trial=1 result=SUCCESS fail_step=None red_ratio=18.3%
=== Loop Complete: SUCCESS ===

初回実行で成功した。Mac → SSH(有線Ethernet)→ Pi → mechArm → 動作実行 → カメラ判定 → CSVログ記録。このパイプラインが1本のコマンドで動いた。

実機の関節角度を記録する

— ループが動くなら、その中にロギングを追加するだけだ。

完全自動ループが成立した時点で、次のミッションが見えた。実機の関節角度を時系列で記録して、UnityのArticulationBodyに渡す。Real → Renderのデータパイプラインだ。

失敗:バックグラウンドスレッド方式(0行)

最初の実装はバックグラウンドスレッドで50msごとにget_angles()をポーリングする方式だった。

_logging_active = False
_log_data = []

def start_logging(arm):
    global _logging_active, _log_data
    _log_data = []
    _logging_active = True
    def _loop():
        while _logging_active:
            try:
                angles = arm.get_angles()   # ← シリアル競合で全部失敗
                ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
                _log_data.append([ts] + angles)
            except:
                pass  # 無音で失敗し続ける
            time.sleep(0.05)
    t = threading.Thread(target=_loop, daemon=True)
    t.start()

結果:

✓ ログ保存: motion_log_20221013_144912.csv(0行)

原因はpymycobotのシリアル通信とスレッドの競合だった。メインスレッドがsend_angles()でシリアルポートを占有している間、バックグラウンドのget_angles()は全部失敗して、except: pass で無音になっていた。

解決:メインスレッドでステップ後に1回取得

スレッドをやめて、各ステップのtime.sleep()の直後に同期的に1回だけ取得する方式に切り替えた。

_log_data = []

def log_angles(label):
    try:
        angles = mc.get_angles()
        ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
        _log_data.append([ts, label] + angles)
    except Exception as e:
        print(f"  (ログ取得失敗: {e})")

def save_log(filepath):
    with open(filepath, "w", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(["timestamp","label","J1","J2","J3","J4","J5","J6"])
        writer.writerows(_log_data)
    print(f"  ✓ ログ保存: {filepath} ({len(_log_data)}行)")

各ステップの後にこう呼ぶ。

# Step3: APPROACH_PICK
mc.send_angles(APPROACH_PICK, SPEED)
time.sleep(3)
log_angles("APPROACH_PICK")   # send_anglesが完了してから取得

# Step4: PICK
mc.send_angles(PICK, SPEED)
time.sleep(3)
log_angles("PICK")

# Step5: グリッパーを閉じる
mc.set_gripper_state(1, 70)
time.sleep(2)
log_angles("GRIPPER_CLOSE")

最後にPiのホームディレクトリに保存して、ファイルパスを標準出力する。Mac側がこの行を正規表現で読む。

log_path = f"/home/ubuntu/motion_log_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
save_log(log_path)
print(f"  LOG_FILE={log_path}")   # r2r2r_loop.pyがこの行をキャッチする

r2r2r_loop.py 側でSFTP取得する。

import re

# SSH実行後のoutputからログパスを正規表現で取得
match = re.search(r"LOG_FILE=(.+\.csv)", output)
if match:
    remote_log = match.group(1).strip()
    local_log = os.path.join(os.path.dirname(__file__), f"motion_log_{attempt}.csv")
    try:
        client = paramiko.SSHClient()
        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        client.connect(SSH_HOST, username=SSH_USER, key_filename=SSH_KEY_PATH, timeout=10)
        sftp = client.open_sftp()
        sftp.get(remote_log, local_log)   # Pi → Mac にダウンロード
        sftp.close()
        client.close()
        print(f"  ✓ モーションログ取得: {local_log}")
    except Exception as e:
        print(f"  × ログ取得失敗: {e}")

結果:

✓ ログ保存: /home/ubuntu/motion_log_20221013_145630.csv(11行)
✓ モーションログ取得: /Users/shimazueiichi/r2r2r-scripts/motion_log_1.csv

11ステップ分、取れた。

CSVの中身が語ること

— 指令値と実測値が一致していない。これがデータだ。

MacにSFTPで自動転送されたCSVを全行確認した。

timestamp,                    label,                  J1,      J2,     J3,      J4,     J5,      J6
2022-10-13 14:55:41.439277,   HOME,                   0.17,    1.58,   1.75,    0.26,  -0.26,  -30.23
2022-10-13 14:55:45.010662,   GRIPPER_OPEN,           0.17,    1.58,   1.75,    0.26,  -0.26,  -30.23
2022-10-13 14:55:49.597778,   APPROACH_PICK,         -53.7,   83.05, -28.74,   -8.34, -48.6,  -29.53
2022-10-13 14:55:54.179388,   PICK,                  -51.24,  90.7,  -33.83,   -4.3,  -53.52, -29.7
2022-10-13 14:55:57.751868,   GRIPPER_CLOSE,         -51.15,  90.7,  -33.83,   -4.13, -54.22, -29.7
2022-10-13 14:56:02.314592,   APPROACH_PICK_RETURN,  -53.61,  84.81, -29.35,   -7.29, -51.06, -29.7
2022-10-13 14:56:06.981955,   APPROACH_PLACE,         59.67,  18.89,  28.47,    9.93, -45.7,  -34.71
2022-10-13 14:56:11.540778,   PLACE,                  54.31,  46.14,  28.3,     3.42, -69.08, -29.79
2022-10-13 14:56:21.665881,   GRIPPER_OPEN_PLACE,     59.5,   31.9,   28.3,     9.58, -45.79, -34.8
2022-10-13 14:56:26.238152,   APPROACH_PLACE_RETURN,  59.5,   18.45,  28.3,     9.75, -45.52, -34.98
2022-10-13 14:56:30.772345,   HOME_RETURN,             0.26,   1.58,   1.75,    0.7,   -0.35, -30.76

PICK姿勢で指令値と実測値を比較する。

関節

指令値

実測値

ズレ

J1

-46.93°

-51.24°

4.31°

J2

102.74°

90.7°

12.04°

J3

-43.33°

-33.83°

9.5°

J4

3.6°

-4.3°

7.9°

J5

-60.11°

-53.52°

6.59°

J6

-31.9°

-29.7°

2.2°

J2で約12度、J3で約9.5度のズレがある。

これが「シミュレーションと現実の差」の正体だ。Unityで設計した指令値通りには動いていない。重力・イナーシャ・摩擦・モーター応答遅延、すべてが積み重なって、実機は指令値とは異なる位置で止まる。

このズレを計測して記録することが、Phase 2の本質だ。

Unityで実機の動きを再生した

— CSVをStreamingAssetsに置いて、MotionLogPlayerでステップを順番に再生する。

MotionLogPlayer.cs をUnityに追加した。

using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;

public class MotionLogPlayer : MonoBehaviour
{
    [Header("CSV設定")]
    public string csvFileName = "motion_log_1.csv";

    [Header("関節(J1〜J6順に設定)")]
    public ArticulationBody[] joints;

    [Header("再生設定")]
    public float stepInterval = 1.5f;
    public bool playOnStart = false;

    private List<float[]> _frames = new List<float[]>();
    private List<string> _labels = new List<string>();

    void Start()
    {
        LoadCSV();
        if (playOnStart) StartCoroutine(PlayMotion());
    }

    void LoadCSV()
    {
        string path = Path.Combine(Application.streamingAssetsPath, csvFileName);
        if (!File.Exists(path)) { Debug.LogError($"CSV not found: {path}"); return; }

        string[] lines = File.ReadAllLines(path);
        for (int i = 1; i < lines.Length; i++)
        {
            string[] cols = lines[i].Split(',');
            if (cols.Length < 8) continue;
            _labels.Add(cols[1]);
            float[] angles = new float[6];
            for (int j = 0; j < 6; j++)
                float.TryParse(cols[j + 2], out angles[j]);
            _frames.Add(angles);
        }
        Debug.Log($"MotionLogPlayer: {_frames.Count}フレーム読み込み完了");
    }

    [ContextMenu("Play Motion")]
    public void Play() => StartCoroutine(PlayMotion());

    IEnumerator PlayMotion()
    {
        for (int i = 0; i < _frames.Count; i++)
        {
            Debug.Log($"Step {i+1}: {_labels[i]}");
            SetJointAngles(_frames[i]);
            yield return new WaitForSeconds(stepInterval);
        }
        Debug.Log("再生完了");
    }

    void SetJointAngles(float[] angles)
    {
        for (int i = 0; i < joints.Length && i < angles.Length; i++)
        {
            if (joints[i] == null) continue;
            var drive = joints[i].xDrive;
            drive.target = angles[i]; // 実測値をdrive.targetにセット
            joints[i].xDrive = drive;
        }
    }
}

CSVをStreamingAssetsにコピーしてUnityに渡す。

mkdir -p "/Users/shimazueiichi/Robotics_UX/Robotics_UX_New/Assets/StreamingAssets"
cp ~/r2r2r-scripts/motion_log_1.csv \
   "/Users/shimazueiichi/Robotics_UX/Robotics_UX_New/Assets/StreamingAssets/motion_log_1.csv"

mecharm270mpi_0_0にアタッチ。Joints配列にlink1〜link6をセット。既存のTrajectoryFollowerのチェックを外してInspector右クリック → Play Motionを実行した。

アームが動いた。

実機のmechArmが今日描いた軌跡を、Unityのロボットがなぞった。

Real → Renderが繋がった

今までのR2R2Rのフローはこうだった。

Render(Unity設計)→ Real(mechArm実行)

これから、逆方向が動く。

Real(mechArm実測)→ CSV → Render(Unity再生)

これは単なるデータの記録ではない。「現実で何が起きたか」をシミュレーション空間で再現できる、という意味だ。

指令値と実測値のズレが可視化されると、次の問いが立つ。Unityのパラメータを実測値に合わせて補正したら、次の実行はもっと精度が上がるか。補正したUnityで再設計した指令値で実機を動かしたら、ズレは縮まるか。

このループがR2R2Rの本体だ。

そのループが初めて両方向で動いた。

※ プロジェクト名「R2R2R」は、UC Berkeley・Toyota Research Instituteの研究「Real2Render2Real」(Yu, Fu et al., 2025)の概念に触発されています。現実空間をデジタルツイン化し、仮想空間での試行から得た知見を実世界のモーター駆動へ還元する——その設計と実装過程を毎日記録しています。技術アプローチおよび目的は独自のものです。