フィジカルAI研究室を作る Log#4 | Physical Intelligenceπ0.5を mechArm 270 のデモで追加学習し、Mac で予測を受け取るまで

Log#3 では、Physical Intelligence 社が公開しているロボット用の AI モデル π0.5 を、LIBERO というシミュレータの中で動かしました。シミュレータの中のアームに「黒いボウルを拾って皿に置いて」と言葉で作業をさせて、20回すべて成功しています。ただ、あのとき使った π0.5 は、LIBERO のアーム(Franka Panda、7軸)で集めたデータで追加学習されたものです。手元の mechArm 270(6軸の小型アーム)は、関節の数も、カメラの位置も、動かし方も違うので、そのままでは動かせません。

mechArm で π0.5 を動かすには、mechArm で集めたデモ(お手本の動き)を π0.5 に追加で学ばせる必要があります。これをファインチューニング(追加学習)と呼びます。

この回は、そのための工程を最初から最後まで一度通しました。既存のスクリプトで mechArm を動かして、上カメラの映像と関節角を記録します。記録を学習用のデータに直して、Runpod で借りた GPU(NVIDIA A40)で π0.5 に学ばせます。学ばせたモデルをサーバーとして起動し、Mac から上カメラの画像と今の関節角を送って、π0.5 が返す「次の1秒の動き」を受け取るところまでです。

学習に使ったのは、成功した試行2本だけです。学習の長さ(ステップ数)も、本番の予定の5,000ではなく200にしました。目的はモデルを賢くすることではなく、記録から予測の受け取りまでの工程に、途中で切れているところがないかを確かめることです。受け取った予測は画面に表示しただけで、アームには送っていません。実機のアームを π0.5 で動かすのは、成功したデモが20本そろって、本番の学習をしてからにします。

この回の作業は、Claude(Anthropic の AI)と一緒に進めました。プログラムと手順書は Claude が書き、アームの操作、カメラとケーブルの接続、Runpod の画面操作、目視での成否の判定はこちらで行っています。

π0.5 を mechArm 270 で動かすには、mechArm のデモで追加学習する必要がある

題材は、これまで既存のスクリプトで続けてきた「赤いブロックを拾って、白い箱に入れる」です。π0.5 で動かせるようになったら、同じ題材でスクリプト版と並べて比べます。比べるときに条件がそろうように、題材は変えていません。

既存のスクリプト(run_pick_place.py)は、上カメラの画像からブロックと箱の位置を見つけ、あらかじめ用意した関節角の表から姿勢を選んで、決まった順番で動きます。π0.5 は、カメラの画像と今の関節角と指示文(「赤いブロックを白い箱に入れて」)を受け取って、次の1秒分の関節角を自分で出します。順番を書いたプログラムがない点が、いちばん大きな違いです。

全体の流れは次のとおりです。

[Mac + mechArm 270]
  既存スクリプト (run_pick_place.py --record-demo) で試行
  → 上カメラ映像 + 実測の関節角 + グリッパーの開閉を記録 (recorder.py --top)
  → 1試行 = 1エピソードに直す (build_demo_episodes.py)
        ↓ scp で送る
[Runpod の Pod (A40)]
  LeRobot 形式に変換 (convert_to_lerobot.py) → 正規化の統計 → π0.5 を LoRA で学習
  → 学習したモデルをサーバーとして起動 (serve_policy.py、ポート8000)
        ↑↓ SSH トンネル
[Mac]
  上カメラ画像 + 実測の関節角を送り、1秒分の予測を受け取る (run_policy_client.py)

デモの集め方は、手でアームを動かして記録する方法と、既存のスクリプトを繰り返し回して記録する方法を比べて、後者にしました。手で動かすと、上カメラに自分の手と腕が映り込みます。π0.5 で動かすときは手が映らないので、手が映っている画像を前提に覚えてしまう心配がありました。代わりに、スクリプトの癖(後で書く、置いた後の遠回りなど)まで覚えることになります。それは承知の上で進めています。

比較の基準として、既存のスクリプトで「赤ブロックを白箱へ」を横から撮影した

π0.5 版と比べるための素材として、既存のスクリプトで動かしている様子を、iPhone で横から撮影しました。撮影のあいだに3回動かし、目視の判定では3回とも、ブロックを掴んで箱に入れ、HOME(アームをまっすぐ立てた初期姿勢)に戻っています。

フィジカルAI研究室を作る Log#4 | Physical Intelligenceπ0.5を mechArm 270 のデモで追加学習し、Mac で予測を受け取るまで|比較の基準として、既存のスクリプトで「赤ブロックを白箱へ」を横から撮影した

撮影した動画(2本・4K・24fps)と、そのときのターミナル画面の収録は、外付け SSD(KIOXIA)の recordings/20261015_mecharm_scripted_baseline/ にまとめました。上の画像は、撮影した動画から等間隔に切り出したコマを並べたものです。ブロックを掴んだ後、アームは一度ブロックを持ち上げてから箱の上まで回り、箱の中で手を開いています。

デーモンの確認スクリプトは、プロセスが止まっていても「OK」と表示していた

mechArm は、Mac から Raspberry Pi 上のデーモン(常駐して指令を待つプログラム、ポート5555)に TCP でつないで動かしています。しばらく動かしていなかったので、電源を入れてから、デーモンの確認スクリプト(bin/daemon_check.sh)を実行しました。結果は「動いている」でした。

[OK] daemon alive: PID=12727

ところが、既存のスクリプトを動かすと、つながりませんでした。

[ABORT ] stage=0  reason=daemon ping 失敗: [Errno 61] Connection refused

Pi の中を調べると、デーモンのプロセスはありませんでした。確認スクリプトは、Pi の上で pgrep -f 'python3 /home/ubuntu/mecharm_daemon.py' を実行して、該当するプロセスがあれば「動いている」と判断します。ところが、この pgrep を実行するシェル自身のコマンド文字列にも、同じ文字列が含まれています。そのため、毎回シェル自身を見つけて OK と返していました。確認するたびに PID が変わっていたのも、このためです。

Pi には、電源を入れたときにデーモンを自動で起動する仕組みもありませんでした。ついでに見ると、Pi の時計は 2022年10月13日になっていました。有線でつないだ Mac 以外にネットワークがないので、時刻が合わせられていません。動作には関係しませんが、Pi 側のログの時刻が実際とずれます。

この回は、デーモンを手で起動して進めました。確認スクリプトの直し方(pgrep の検索文字列を '[p]ython3 ...' のように書いて、シェル自身に一致しないようにする)は分かっていますが、まだ直していません。

ssh -i ~/.ssh/id_rsa_pi ubuntu@192.168.2.1 "setsid nohup python3 /home/ubuntu/mecharm_daemon.py </dev/null >>/home/ubuntu/daemon.log 2>&1 &"

上カメラが iPhone 14 Pro Max の連係カメラでつながっていないと、スクリプトは「上カメラが無い」と判断する

カメラの構成でもつまずきました。今のスクリプトは、上カメラを iPhone 14 Pro Max の連係カメラ(Continuity Camera、iPhone を Mac の Webカメラとして使う macOS の機能)、横カメラを iPhone 11 の Iriun(iPhone を Webカメラにするアプリ)から取る構成です。カメラを探すプログラム(camera_resolver)は、カメラの名前に「iphone」が入っているものを上カメラとして選びます。

久しぶりだったので、上の iPhone を Iriun でつないでいました。Iriun のカメラの名前には「iphone」が入らないため、スクリプトからは上カメラが無い状態でした。Mac の Photo Booth のカメラの一覧にも「iPhone 14 Pro Max のカメラ」は出ていませんでした。iPhone のロックを解除してケーブルをつなぎ直すと、連係カメラとして一覧に出て、スクリプトも上カメラを見つけました。

横カメラは、コードを読んで、アームの動きの判断には使われていない(記録用の画像を残すだけ)ことを確かめ、この回はつないでいません。

朝の6回のうち白箱に入ったのは4回。外れた回は j4(手首)が指令した角度に届いていなかった

準備が整ってから、既存のスクリプトで6回続けて試しました。白箱に入ったのは4回です。

失敗した2回は、どちらも j4(手首の曲げの関節)が指令した角度まで回っていませんでした。例えば、-75° を指令して -59° で止まっています。回を重ねるほど届かない量が増え、最後に HOME へ戻そうとしたときは、j4 だけが +53° のまま動きませんでした。電源を切ってしばらく冷ましてから動かすと、元に戻りました。手首のサーボ(モーター)が熱を持って、出せる力が落ちているのだと考えています。温度は測っていないので、これは推測です。

デーモンには同時に1つしかつなげないので、スクリプトが読んだ関節角を記録係へ流す形にした

π0.5 の学習には、アームが動いている間の「カメラ映像」と「その瞬間の関節角」を、時刻をそろえて並べたデータが必要です。既存のスクリプトは、試行ごとに静止画を数枚残すだけで、動いている間の映像や関節角の流れは残していませんでした。

記録の仕組みを足すにあたって、制約が1つありました。デーモンは、つないできた相手に1つずつしか応答しません。既存のスクリプトは試行の間ずっとデーモンにつなぎっぱなしにしているので、別のプログラムから同時に関節角を読むことはできません。

そこで、既存のスクリプトが関節角を読むたびに、その値を記録係のプログラム(recorder.py)にも送る仕組みを足しました。スクリプトはもともと、アームを動かすたびに、目標の角度に届いたかを0.3秒ごとに確かめています。その読み取り値をそのまま記録係にも渡すだけなので、アームの動かし方、待ち時間、届いたかどうかの判定には触れていません。--record-demo を付けたときだけ働き、付けなければ従来とまったく同じ動きです。

グリッパーの開閉も、指令を出した瞬間の時刻で記録係に送るようにしました。それまでの通知は、開閉の後に2秒待ってから送られていました。また、箱の上で手を開く動作には、通知自体がありませんでした。

記録係の側には、上カメラを開きっぱなしにして録画する機能を足しました(--top)。ここでもう1つ確かめることがありました。既存のスクリプトも、ブロックや箱を見つけるたびに同じ上カメラを開きます。2つのプログラムで1台のカメラを同時に使えるかを、先に試しました。A が10秒間ずっとカメラを開いて読み続け、その途中で B が2秒だけ開いて読みます。

top camera index = 2
A(常時): open=True frames=244 (約24.4fps) B が開いている間のフレーム=60  0.5秒超の途切れ=[]
B(途中): open=True frames=63
RESULT: OK 同時に使える

B が開いている間も、A の映像は途切れませんでした(0.5秒を超える途切れは0回)。これで、記録係が録画を続けたまま、既存のスクリプトがいつもどおり検出できることが分かりました。

記録モードでは、ターミナルを2つ使います。

# ターミナル1: 記録係(上カメラを録画・横カメラは使わない)
cd ~/r2r2r-scripts
python3 dynamic_tracking_limits/place/place_modules/recorder.py --top --no-side

# ターミナル2: アームを動かす(人が目視で判定・デモを記録)
cd ~/r2r2r-scripts
./run_pick_place.sh --human-judge --record-demo

--human-judge は、掴めたか、箱に入ったかを、人が目で見て y/n で答える設定です。学習に使う試行は、この人の判定で選びます。

以下、変更したプログラムを載せます。run_pick_place.py は全体で5,301行あるので、追加した関数1つと、変更した関数3つ(_do_gripper、_do_stage9_release_gripper、main)をそれぞれ全文で載せます。

run_pick_place.py:追加した _install_demo_angle_tap(583〜624行目)

python

# ---- 2026-10-15: デモ記録 (π0.5 学習データ用) --------------------------------
# --record-demo 指定時のみ, daemon_get_angles の戻り値 (実測 6 軸) を recorder へ
# "actual" イベントとして fire-and-forget 送信する.
# 制御・待ち時間・到達判定・リトライ条件には一切触れない (戻り値も素通し).
# 未指定時はタップを一切インストールしない = 挙動は従来と完全同一.
# デーモンは試行中ずっと 1 接続を張るため, 別プロセスから関節角は読めない.
# よって「本線が読んだ値をそのまま観測者へ流す」方式にしている.
_DEMO_TAP_INSTALLED = False


def _install_demo_angle_tap():
    global _DEMO_TAP_INSTALLED, daemon_get_angles
    if _DEMO_TAP_INSTALLED:
        return
    import visual_servo_pick as _vsp
    _orig = _vsp.daemon_get_angles

    def _tapped_daemon_get_angles(*args, **kwargs):
        res = _orig(*args, **kwargs)
        try:
            if res is not None and len(res) == 6:
                recorder_notify({"type": "actual",
                                 "angles": [float(x) for x in res],
                                 "ts": time.time()})
        except Exception:
            pass
        return res

    # daemon_get_angles を名前で import 済みの全モジュール (j0_sweep_scan 等) を差し替え.
    patched = []
    for _name, _mod in list(sys.modules.items()):
        try:
            if getattr(_mod, "daemon_get_angles", None) is _orig:
                setattr(_mod, "daemon_get_angles", _tapped_daemon_get_angles)
                patched.append(_name)
        except Exception:
            pass
    daemon_get_angles = _tapped_daemon_get_angles
    _DEMO_TAP_INSTALLED = True
    print("[DEMO  ] record-demo ON: 実測角を recorder へ送信 (patched: {})".format(
        ", ".join(sorted(patched))))

run_pick_place.py:変更した _do_gripper と _do_stage9_release_gripper(2247〜2334行目)

python

def _do_gripper(action, stage_label, log_record, dry_run):
    """グリッパー操作。戻り値(result=-1 含む)は DAY175 仕様で判定に使わない。
    一方、daemon TCP 例外は本物の失敗として bool False を返す(close 未遂のまま
    LIFT などの事故を防ぐ)。

    DAY261: close は set_gripper_value(GRIP_VALUE) で push 保持に切替(位置で
    止まらず押し込み力を残す)。open は従来どおり set_gripper_state(0)。

    戻り値: True = 正常 or dry_run / False = daemon 例外(呼び出し側で ABORT 連動)
    """
    state = 1 if action == "close" else 0
    is_close = (action == "close")
    print("[GRIP  ] {}  action={}  method={}  sleep={:.1f}s  dry_run={}".format(
        stage_label, action,
        "value({})".format(GRIP_VALUE) if is_close else "state(0)",
        GRIPPER_SLEEP, dry_run))
    rec = {
        "stage": stage_label, "action": action, "state": state,
        "sleep_sec": GRIPPER_SLEEP, "dry_run": dry_run,
        # DAY261: 把持方式の判別用。dry-run でも残るよう rec 構築時に書く。
        "grip_method": "value" if is_close else "state",
    }
    if is_close:
        rec["grip_value"] = GRIP_VALUE
    ok = True
    if not dry_run:
        # 2026-10-15 record-demo: 指令した瞬間の時刻で開閉を記録 (既存 "gripper" 通知は sleep 後).
        if _DEMO_TAP_INSTALLED:
            recorder_notify({"type": "gripper_cmd", "state": state, "note": stage_label})
        try:
            if is_close:
                rec["daemon_return"] = daemon_set_gripper_value(GRIP_VALUE)
            else:
                rec["daemon_return"] = daemon_set_gripper(state)
            # 戻り値(result=-1 等)は判定に使わない(DAY175・成否無関係)
        except Exception as e:
            rec["error"] = str(e)
            ok = False
            print("[GRIP  ] [ERROR] {}: daemon 例外: {}".format(stage_label, e))
        time.sleep(GRIPPER_SLEEP)
    # DAY287: recorder へ gripper 通知 (dry_run 含めて 1 度 emit).
    recorder_notify({"type": "gripper", "state": state, "note": stage_label})
    log_record.setdefault("gripper", []).append(rec)
    return ok


def _do_stage9_release_gripper(log_record, dry_run):
    """stage9 専用のグリッパー開放。

    既知問題(戻り値が成否と無関係、連続コマンド無視、sleep 必須)に合わせ、
    stage9 だけ open を二重送信する。把持 close 側や他 stage の open には触れない。
    """
    print("[GRIP  ] stage9_release  action=open  pre_sleep={:.1f}s  "
          "attempts={}  sleep_each={:.1f}s  dry_run={}".format(
              STAGE9_RELEASE_PRE_SLEEP, STAGE9_RELEASE_OPEN_ATTEMPTS,
              GRIPPER_SLEEP, dry_run))
    rec = {
        "stage": "stage9_release",
        "action": "open",
        "state": 0,
        "pre_sleep_sec": STAGE9_RELEASE_PRE_SLEEP,
        "sleep_sec": GRIPPER_SLEEP,
        "attempt_count": STAGE9_RELEASE_OPEN_ATTEMPTS,
        "dry_run": dry_run,
        "attempts": [],
    }
    ok = True
    if not dry_run:
        time.sleep(STAGE9_RELEASE_PRE_SLEEP)
        ok = False
        for attempt in range(1, STAGE9_RELEASE_OPEN_ATTEMPTS + 1):
            attempt_rec = {"attempt": attempt}
            # 2026-10-15 record-demo: stage9 の開放は従来 recorder へ通知が無かったため追加.
            if _DEMO_TAP_INSTALLED and attempt == 1:
                recorder_notify({"type": "gripper_cmd", "state": 0, "note": "stage9_release"})
            try:
                attempt_rec["daemon_return"] = daemon_set_gripper(0)
                ok = True
            except Exception as e:
                attempt_rec["error"] = str(e)
                print("[GRIP  ] [ERROR] stage9_release attempt {}: daemon 例外: {}"
                      .format(attempt, e))
            rec["attempts"].append(attempt_rec)
            time.sleep(GRIPPER_SLEEP)
    log_record.setdefault("gripper", []).append(rec)
    return ok

run_pick_place.py:変更した main(5177〜5301行目)

python

def main():
    ap = argparse.ArgumentParser(
        description="通し Pick & Place(j0 対応表方式)")
    ap.add_argument(
        "--dry-run", action="store_true",
        help="送信せず、各段の lookup 結果とガード判定だけを出力",
    )
    ap.add_argument(
        "--dummy-block-x", type=float, default=None,
        help="dry_run 用 block_xz の x(指定時は --dummy-block-z とセット)",
    )
    ap.add_argument(
        "--dummy-block-z", type=float, default=None,
        help="dry_run 用 block_xz の z(指定時は --dummy-block-x とセット)",
    )
    ap.add_argument(
        "--dummy-box-x", type=float, default=None,
        help="dry_run 用 box_xz の x(指定時は --dummy-box-z とセット)。"
             "未指定なら BOX_XZ={} をデフォルト".format(BOX_XZ),
    )
    ap.add_argument(
        "--dummy-box-z", type=float, default=None,
        help="dry_run 用 box_xz の z(指定時は --dummy-box-x とセット)",
    )
    ap.add_argument(
        "--dummy-pick-verdict", choices=["yes", "no", "unknown"], default=None,
        help="dry_run 用 pick 判定の verdict 注入(デフォルト unknown)",
    )
    ap.add_argument(
        "--dummy-place-verdict", choices=["yes", "no", "unknown"], default=None,
        help="dry_run 用 place 判定の verdict 注入(デフォルト unknown)",
    )
    ap.add_argument(
        "--record-demo", action="store_true",
        help="π0.5 学習用デモ記録 (2026-10-15). 実測角を recorder へ送る. "
             "制御は無改変. recorder.py --top を別ターミナルで起動しておくこと",
    )
    ap.add_argument(
        "--human-judge", action="store_true",
        help="pick/place 判定で人による目視判定を入力させる(dry_run 時は無効)。"
             "校正フェーズ向け。VLM 判定は記録のみで分岐は人の verdict を採用",
    )
    args = ap.parse_args()
    if args.record_demo:
        _install_demo_angle_tap()

    dummy_block = None
    dummy_box = None
    if args.dry_run:
        if args.dummy_block_x is not None and args.dummy_block_z is not None:
            dummy_block = (args.dummy_block_x, args.dummy_block_z)
        else:
            dummy_block = (0.05, 0.30)
            print("[INIT  ] dry_run dummy block_xz default = {}".format(dummy_block))
        if args.dummy_box_x is not None and args.dummy_box_z is not None:
            dummy_box = (args.dummy_box_x, args.dummy_box_z)
        else:
            dummy_box = tuple(BOX_XZ)
            print("[INIT  ] dry_run dummy box_xz default (BOX_XZ) = {}".format(dummy_box))

    # DAY283 §3-A: 常駐ループ. 1 プロセスで複数試行を回す (FK cache / import cost を
    # 2 試行目以降ゼロにするため wrap 方式). run() は毎回そのまま呼ぶ (init_once を
    # 新設しない・モジュールキャッシュがプロセス寿命で持続する・§6 表参照).
    # DAY283 FIX-5: プロンプト案内は断定表現を避け, 目視/h コマンドを促す (C4 対応).
    # 前試行が把持中に失敗した場合, 次試行 stage1 の gripper open が空中で
    # ブロックを落とし得る (stage1 のロジックは不変・従来挙動維持). そのため
    # プロンプト先頭で「掴んだまま失敗した場合は h で HOME 確認」を明示する.
    print("[LOOP] 常駐モード開始. コマンド: n または Enter=次試行 / h=HOME 復帰 / q=終了")
    print("[LOOP] 注意: 前試行が把持中に失敗 (HOME 未到達で握ったまま等) した場合,"
          " 次試行 stage1 の gripper open で空中落下し得る."
          " その場合は h で HOME を試行してから n で次試行へ進むこと.")
    _trial_counter = 0
    try:
        while True:
            try:
                _cmd = input(
                    "\n[LOOP] 次の操作 [n=次試行 (Enter可) / h=HOME / q=終了]: "
                ).strip().lower()
            except EOFError:
                # 標準入力 EOF (pipe 終端等) は正常終了扱い.
                print("[LOOP] EOF 検出 → 常駐モード終了")
                break
            if _cmd in ("q", "quit", "exit"):
                print("[LOOP] q 受領 → 常駐モード終了")
                break
            if _cmd == "h":
                _manual_home(dry_run=args.dry_run)
                continue
            if _cmd in ("", "n", "next"):
                _trial_counter += 1
                print("[LOOP] === 試行 #{} 開始 ===".format(_trial_counter))
                try:
                    success, log_path = run(
                        dry_run=args.dry_run,
                        dummy_block_xz=dummy_block,
                        dummy_box_xz=dummy_box,
                        dummy_pick_verdict=args.dummy_pick_verdict,
                        dummy_place_verdict=args.dummy_place_verdict,
                        human_judge=args.human_judge,
                    )
                except KeyboardInterrupt:
                    # FIX-5-3: 断定形の「試行済み・追記済み」は虚偽になる経路がある
                    # (setup 前・SSD 未マウント等の早期 return では auto-HOME も台帳も
                    # 走らない). ここでは事実だけ伝え, 詳細は home_recovery / JSON を
                    # 参照するよう促す.
                    print("[LOOP] 試行中 Ctrl-C 受領. auto-HOME/台帳の実施状況は"
                          " log の 'home_recovery' セクションで確認のこと"
                          " (daemon 接続前 or SSD 未マウント等の早期中断では未実施).")
                    print("[LOOP] メニューに戻らずセッション終了 (§4 セマンティクス).")
                    break
                print("[LOOP] === 試行 #{} 終了: success={} log={} ===".format(
                    _trial_counter, success, log_path))
                continue
            print("[LOOP] 未知のコマンド: {!r}. n / Enter / h / q のいずれか".format(_cmd))
    except KeyboardInterrupt:
        # FIX-5-3: 断定「HOME 位置のはず」をやめ, 目視/h コマンドを促す.
        # 前試行が未完走 or HOME 未到達の場合, アームは中途姿勢 or 把持中の可能性あり.
        print("\n[LOOP] プロンプト中 Ctrl-C 受領.")
        print("[LOOP] 前試行が未完走または HOME 未到達の場合, 現在姿勢と把持状態を"
              " 目視確認のこと. 必要なら h で HOME を試行してから次試行へ.")
    print("[LOOP] 常駐モード終了. 実行試行数 = {}".format(_trial_counter))


if __name__ == "__main__":
    main()

recorder.py の全文(742行)です。TopRecorder クラスと、main の中の --top --no-side の扱いが、この回に足した部分です。

python

#!/usr/bin/env python3
"""試行の連続記録 recorder (別プロセス・観測者専任・DAY287 R2).

- UDP 127.0.0.1:5556 で本線 (run_pick_place.py) からのイベントを受け,
  横カメラ (Iriun) の連続録画 + commanded / stage / gripper 状態を
  1 試行 = 1 フォルダで書き出す.
- **観測者原則**: アーム・Pi daemon TCP ポートに一切触れない (禁止動詞
  4 種と daemon ポート番号は本ファイルに 1 度も現れない・grep で保証).
  recorder が起動していなくても・死んでも・応答しなくても本線は完走する.

出力: /Volumes/KIOXIA/r2r2r-data/runs/run_pick_place/{run_stem}/recording/
  - episode.mp4         (avc1, 1280x720, コンテナ fps=30 は参考値, 真実は frame_log)
                        (突然死時は moov 欠落で mp4 破損しうる. 真実は frame_log)
  - frame_log.csv       (frame_idx, capture_ts)
  - states.csv          (ts, event_type, stage, j0..j5, speed, gripper_state,
                         ts_send, ts_arrival, attempt, arrived, note)
                         DAY287 R2 A1: commanded 行に ts_send/ts_arrival/attempt/arrived
                         を追加. ts は受信時刻として維持.
  - recording_meta.json (fps・frames_captured・frames_written・status 等. atomic write)
  - extracted/          (frame_request 応答で切り出した jpg)

status 語彙: ok / truncated / camera_lost / mount_lost / none (最後は本線側で使用)

**運用制約** (DAY287 R3 D5 で許容): マウント消失時は recording_meta.json も
SSD に書けず, 永続 status が標準出力にしか残らない. ローカル fallback は
上位ルールで禁止 (代替の書き込み先を作らない) のため, この制約は仕様として
受け入れる. 実運用では KIOXIA マウントを外さない前提.

**既知の制約 1 — Iriun 切断は検出不能** (DAY287 スモーク実測):
iPhone 側で Iriun アプリを終了しても Mac 側の仮想カメラデバイスは生存し
`cap.read()` が失敗しない (直近フレームがフリーズしたまま ok=True で返る)
ため, camera_lost は発火しない. 再接続は同一デバイス上で透過的に復帰する
(実測: 復帰後のライブフレームが即座に取得できた). camera_lost 経路は
物理 USB カメラ (将来の ELP 等) で機能する設計. 実運用では**試行前に
Iriun の接続を目視確認**すること (代替検知手段は現時点で無い).

**既知の制約 2 — mount_lost 時の残骸パターン** (DAY287 スモーク実測):
マウント消失時は `recording_meta.json` の atomic write が失敗する
(`os.replace` が `No such file or directory` を投げる). 結果として
`recording/` には episode.mp4 / frame_log.csv / states.csv のみ残り,
`recording_meta.json` が欠ける状態になる. **「meta なし = 異常終了の痕跡」**
として読む (集計/検査ツール側でこの規約に依拠して良い). 例外はログ 1 行に
収まりプロセスは生存, その後カメラは再解決される.

Usage:
  python3 recorder.py
  (Ctrl-C で停止. daemon 化はしない — 本線と同じ常駐運用感覚)
"""
from __future__ import annotations

import csv
import json
import os
import argparse
import socket
import sys
import threading
import time
from datetime import datetime
from pathlib import Path

import cv2

_HERE = Path(__file__).resolve().parent
if str(_HERE) not in sys.path:
    sys.path.insert(0, str(_HERE))

import camera_resolver  # noqa: E402

# ---- 定数 --------------------------------------------------------------------
UDP_HOST = "127.0.0.1"
UDP_PORT = 5556
RUNS_ROOT = Path("/Volumes/KIOXIA/r2r2r-data/runs/run_pick_place")
KIOXIA_MOUNT = Path("/Volumes/KIOXIA")
ORPHAN_FRAMES_DIR = Path("/Volumes/KIOXIA/r2r2r-data/diag/recorder_orphan_frames")

CAMERA_WIDTH = 1280
CAMERA_HEIGHT = 720
MP4_CONTAINER_FPS = 30.0
FOURCC = "avc1"

# DAY287 R2 A3: frame_request で返してよい直近フレームの経過時間上限.
FRAME_AGE_LIMIT_SEC = 0.5
# DAY287 R2 C4: 1 試行中の CSV flush 周期 (突然死時の損失を最大 1 秒に限定).
CSV_FLUSH_INTERVAL_SEC = 1.0
# DAY287 R2 D5: マウントガードの writable-check 一時ファイル名.
MOUNT_WRITE_TEST_NAME = ".recorder_mount_write_test"

# read 失敗の連続許容数. 超えたら camera_lost.
READ_FAIL_LIMIT = 30
# read fail 後の camera 再解決までのクールダウン秒.
CAMERA_REOPEN_COOLDOWN_SEC = 2.0
# UDP recv のポーリング周期 (blocking recv は使わず, camera read と交互).
UDP_POLL_INTERVAL_SEC = 0.005

STATES_COLS = ["ts", "event_type", "stage",
               "j0", "j1", "j2", "j3", "j4", "j5",
               "speed", "gripper_state",
               "ts_send", "ts_arrival", "attempt", "arrived",
               "note"]
FRAME_LOG_COLS = ["frame_idx", "capture_ts"]


# ---- DAY287 R2 D5: マウントガードの実体化 ---------------------------------
def ensure_kioxia_mounted() -> bool:
    """os.path.ismount で真マウント確認 + 一時ファイル書き込みテスト.
    どちらか失敗なら False. shim (単なるパス存在) を弾く."""
    try:
        if not os.path.ismount(str(KIOXIA_MOUNT)):
            return False
    except Exception:
        return False
    try:
        RUNS_ROOT.parent.parent.mkdir(parents=True, exist_ok=True)
        test = RUNS_ROOT.parent.parent / MOUNT_WRITE_TEST_NAME
        test.write_text("x")
        test.unlink()
        return True
    except Exception:
        return False


# ---- 試行ごとの writer 束 ---------------------------------------------------
class TrialWriter:
    """1 試行 = 1 インスタンス. trial_start で作り, trial_end で close する."""

    def __init__(self, run_stem: str, actual_width: int, actual_height: int):
        self.run_stem = run_stem
        self.dir = RUNS_ROOT / run_stem / "recording"
        self.dir.mkdir(parents=True, exist_ok=True)
        (self.dir / "extracted").mkdir(parents=True, exist_ok=True)
        self.mp4_path = self.dir / "episode.mp4"
        self.frame_log_path = self.dir / "frame_log.csv"
        self.states_path = self.dir / "states.csv"
        self.meta_path = self.dir / "recording_meta.json"
        self.meta_tmp_path = self.dir / "recording_meta.json.tmp"

        fourcc = cv2.VideoWriter_fourcc(*FOURCC)
        self.writer = cv2.VideoWriter(
            str(self.mp4_path), fourcc, MP4_CONTAINER_FPS,
            (actual_width, actual_height))
        self.writer_ok = self.writer.isOpened()
        if not self.writer_ok:
            print(f"[RECO] [WARN] VideoWriter open 失敗 ({FOURCC}): {self.mp4_path}")

        self.frame_log_file = self.frame_log_path.open("w", newline="")
        self.frame_log_writer = csv.writer(self.frame_log_file)
        self.frame_log_writer.writerow(FRAME_LOG_COLS)

        self.states_file = self.states_path.open("w", newline="")
        self.states_writer = csv.writer(self.states_file)
        self.states_writer.writerow(STATES_COLS)

        # DAY287 R2 C4: frames_captured (read 成功数) と frames_written
        # (writer.write 成功数) を分けて記録. write 失敗は書込数を増やさない.
        self.frames_captured = 0
        self.frames_written = 0
        self.started_ts = time.time()
        self.current_stage = ""
        self.status = "ok"
        self.ended_ts: float | None = None
        self.last_flush_ts = self.started_ts

    def _maybe_flush(self) -> None:
        """DAY287 R2 C4: 1 秒周期で CSV を flush. 突然死時の損失を最大 1 秒に."""
        now = time.time()
        if now - self.last_flush_ts >= CSV_FLUSH_INTERVAL_SEC:
            try:
                self.frame_log_file.flush()
                self.states_file.flush()
            except Exception:
                pass
            self.last_flush_ts = now

    def write_frame(self, frame, capture_ts: float) -> None:
        # frames_captured は常に増える (read 成功数).
        self.frames_captured += 1
        wrote = False
        if self.writer_ok:
            try:
                self.writer.write(frame)
                wrote = True
            except Exception as e:
                print(f"[RECO] [WARN] mp4 write 失敗: {e}")
        if wrote:
            self.frames_written += 1
        # frame_log は「captured」を正とする (真実は frame_log の capture_ts).
        self.frame_log_writer.writerow(
            [self.frames_captured - 1, f"{capture_ts:.6f}"])
        self._maybe_flush()

    def write_state(self, event_type: str, payload: dict) -> None:
        ts = float(payload.get("ts") or time.time())
        if event_type == "stage":
            self.current_stage = str(payload.get("stage") or "")
        angles = payload.get("angles") or [None] * 6
        row = [
            f"{ts:.6f}",
            event_type,
            self.current_stage,
        ]
        for i in range(6):
            v = angles[i] if i < len(angles) else None
            row.append("" if v is None else f"{float(v):.3f}")
        speed = payload.get("speed")
        row.append("" if speed is None else str(speed))
        gs = payload.get("state") if event_type in ("gripper", "gripper_cmd") else None
        row.append("" if gs is None else str(gs))
        # DAY287 R2 A1: commanded 行の ts_send / ts_arrival / attempt / arrived.
        ts_send = payload.get("ts_send")
        ts_arrival = payload.get("ts_arrival")
        attempt = payload.get("attempt")
        arrived = payload.get("arrived")
        row.append("" if ts_send is None else f"{float(ts_send):.6f}")
        row.append("" if ts_arrival is None else f"{float(ts_arrival):.6f}")
        row.append("" if attempt is None else str(attempt))
        row.append("" if arrived is None else ("True" if arrived else "False"))
        note = payload.get("note", "")
        row.append(str(note or ""))
        self.states_writer.writerow(row)
        self._maybe_flush()

    def _atomic_meta_write(self, meta: dict) -> None:
        """DAY287 R2 A4: tmp に書いて os.replace で原子的置換.
        本線側の bounded poll と組み合わせて「半端な JSON」を読ませない."""
        try:
            self.meta_tmp_path.write_text(
                json.dumps(meta, indent=2, ensure_ascii=False))
            os.replace(str(self.meta_tmp_path), str(self.meta_path))
        except Exception as e:
            print(f"[RECO] [WARN] meta atomic write 失敗: {e}")

    def close(self, status: str) -> None:
        self.ended_ts = time.time()
        self.status = status
        try:
            self.frame_log_file.flush(); self.frame_log_file.close()
        except Exception:
            pass
        try:
            self.states_file.flush(); self.states_file.close()
        except Exception:
            pass
        if self.writer_ok:
            try:
                self.writer.release()
            except Exception:
                pass
        elapsed = (self.ended_ts - self.started_ts) if self.ended_ts else 0.0
        eff_fps = (self.frames_captured / elapsed) if elapsed > 0 else 0.0
        meta = {
            "run_stem": self.run_stem,
            "started_ts": self.started_ts,
            "ended_ts": self.ended_ts,
            "elapsed_sec": round(elapsed, 3),
            "frames_captured": self.frames_captured,
            "frames_written": self.frames_written,
            "frame_count": self.frames_captured,   # 後方互換 (本線読取用)
            "effective_fps": round(eff_fps, 3),
            "container_fps": MP4_CONTAINER_FPS,
            "width": CAMERA_WIDTH,
            "height": CAMERA_HEIGHT,
            "fourcc": FOURCC,
            "status": status,
            "recording_path": "recording/",
        }
        self._atomic_meta_write(meta)
        print(f"[RECO] trial 終了 run_stem={self.run_stem} "
              f"captured={self.frames_captured} written={self.frames_written} "
              f"eff_fps={eff_fps:.2f} status={status}")


# ---- カメラ管理 --------------------------------------------------------------
class SideCamera:
    def __init__(self):
        self.cap: cv2.VideoCapture | None = None
        self.actual_width = CAMERA_WIDTH
        self.actual_height = CAMERA_HEIGHT
        self.last_frame = None
        self.last_frame_ts: float | None = None
        self.consecutive_read_fails = 0
        self.last_reopen_attempt = 0.0

    def open(self) -> bool:
        try:
            cams = camera_resolver.resolve_cameras(
                require_top=False, require_side=True, verbose=False)
        except Exception as e:
            print(f"[RECO] [WARN] camera_resolver: {e}")
            return False
        idx = cams.get("side")
        if idx is None:
            print("[RECO] [WARN] Iriun (side) 未解決")
            return False
        cap = cv2.VideoCapture(idx)
        if not cap.isOpened():
            print(f"[RECO] [WARN] VideoCapture open 失敗 index={idx}")
            return False
        cap.set(cv2.CAP_PROP_FRAME_WIDTH, CAMERA_WIDTH)
        cap.set(cv2.CAP_PROP_FRAME_HEIGHT, CAMERA_HEIGHT)
        # fps は無指定. Iriun 側の実力に任せる (実効 ~27fps 想定).
        for _ in range(5):
            cap.read()
        self.cap = cap
        self.actual_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or CAMERA_WIDTH
        self.actual_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or CAMERA_HEIGHT
        print(f"[RECO] カメラ open 成功 index={idx} {self.actual_width}x{self.actual_height}")
        return True

    def close(self) -> None:
        # DAY287 R2 A3: close 時に last_frame をクリア.
        # camera_lost / mount_lost 時に古いフレームが frame_request に返るのを防ぐ.
        if self.cap is not None:
            try:
                self.cap.release()
            except Exception:
                pass
            self.cap = None
        self.last_frame = None
        self.last_frame_ts = None

    def read(self) -> tuple[bool, object]:
        if self.cap is None:
            return False, None
        ok, frame = self.cap.read()
        ts = time.time()
        if ok and frame is not None:
            self.last_frame = frame
            self.last_frame_ts = ts
            self.consecutive_read_fails = 0
            return True, frame
        self.consecutive_read_fails += 1
        return False, None

    def try_reopen_if_needed(self) -> None:
        now = time.time()
        if now - self.last_reopen_attempt < CAMERA_REOPEN_COOLDOWN_SEC:
            return
        self.last_reopen_attempt = now
        self.close()
        if self.open():
            self.consecutive_read_fails = 0


# ---- 2026-10-15: 上カメラ (iPhone 連係カメラ) 連続録画 -----------------------
# π0.5 学習用デモ記録のため追加. 別スレッドで上カメラを読み続け, 試行中だけ
# recording/top.mp4 と recording/top_frame_log.csv に書く.
# 観測者原則は不変: アーム・daemon には一切触れない. 本線 (run_pick_place) が
# 検出のたびに同じカメラを開いても同時利用できることを実測済み
# (experiments/test_top_cam_dual_open.py: 2026-10-15 RESULT OK).
TOP_FRAME_LOG_COLS = ["frame_idx", "capture_ts"]


class TopRecorder:
    def __init__(self):
        self.lock = threading.Lock()
        self.cap = None
        self.stop_flag = False
        self.trial_dir: Path | None = None
        self.writer = None
        self.log_file = None
        self.log_writer = None
        self.n = 0
        self.size = None
        self.started_ts: float | None = None
        self.last_flush_ts = 0.0
        self.thread = threading.Thread(target=self._run, daemon=True)

    def start(self) -> None:
        self.thread.start()

    def _open(self) -> bool:
        try:
            cams = camera_resolver.resolve_cameras(
                require_top=True, require_side=False, verbose=False)
        except Exception as e:
            print(f"[RECO-TOP] [WARN] camera_resolver: {e}")
            return False
        idx = cams.get("top")
        cap = cv2.VideoCapture(idx)
        if not cap.isOpened():
            print(f"[RECO-TOP] [WARN] VideoCapture open 失敗 index={idx}")
            return False
        for _ in range(5):
            cap.read()
        self.cap = cap
        print(f"[RECO-TOP] 上カメラ open 成功 index={idx}")
        return True

    def begin_trial(self, trial_dir: Path) -> None:
        with self.lock:
            self._close_trial_locked("truncated")
            self.trial_dir = trial_dir
            self.n = 0
            self.size = None
            self.writer = None   # 最初のフレームでサイズを確定して作る
            self.started_ts = time.time()
            self.last_flush_ts = self.started_ts
            try:
                self.log_file = (trial_dir / "top_frame_log.csv").open("w", newline="")
                self.log_writer = csv.writer(self.log_file)
                self.log_writer.writerow(TOP_FRAME_LOG_COLS)
            except Exception as e:
                print(f"[RECO-TOP] [WARN] top_frame_log 作成失敗: {e}")
                self.log_file = None
                self.log_writer = None

    def end_trial(self) -> None:
        with self.lock:
            self._close_trial_locked("ok")

    def _close_trial_locked(self, status: str) -> None:
        if self.trial_dir is None:
            return
        ended = time.time()
        if self.writer is not None:
            try:
                self.writer.release()
            except Exception:
                pass
        if self.log_file is not None:
            try:
                self.log_file.flush(); self.log_file.close()
            except Exception:
                pass
        elapsed = ended - (self.started_ts or ended)
        meta = {
            "frames": self.n,
            "elapsed_sec": round(elapsed, 3),
            "effective_fps": round(self.n / elapsed, 3) if elapsed > 0 else 0.0,
            "width": self.size[0] if self.size else None,
            "height": self.size[1] if self.size else None,
            "status": status,
        }
        try:
            (self.trial_dir / "top_meta.json").write_text(
                json.dumps(meta, indent=2, ensure_ascii=False))
        except Exception as e:
            print(f"[RECO-TOP] [WARN] top_meta 書き込み失敗: {e}")
        print(f"[RECO-TOP] trial 終了 frames={self.n} fps={meta['effective_fps']} status={status}")
        self.trial_dir = None
        self.writer = None
        self.log_file = None
        self.log_writer = None

    def _run(self) -> None:
        fails = 0
        last_open = 0.0
        while not self.stop_flag:
            if self.cap is None:
                if time.time() - last_open >= CAMERA_REOPEN_COOLDOWN_SEC:
                    last_open = time.time()
                    self._open()
                else:
                    time.sleep(0.05)
                continue
            ok, frame = self.cap.read()
            ts = time.time()
            if not ok or frame is None:
                fails += 1
                if fails >= READ_FAIL_LIMIT:
                    print("[RECO-TOP] read 連続失敗 → 上カメラを開き直す")
                    try:
                        self.cap.release()
                    except Exception:
                        pass
                    self.cap = None
                    fails = 0
                continue
            fails = 0
            with self.lock:
                if self.trial_dir is None:
                    continue
                try:
                    if self.writer is None:
                        h, w = frame.shape[:2]
                        self.size = (w, h)
                        self.writer = cv2.VideoWriter(
                            str(self.trial_dir / "top.mp4"),
                            cv2.VideoWriter_fourcc(*FOURCC), MP4_CONTAINER_FPS, (w, h))
                    self.writer.write(frame)
                    if self.log_writer is not None:
                        self.log_writer.writerow([self.n, f"{ts:.6f}"])
                    self.n += 1
                    if ts - self.last_flush_ts >= CSV_FLUSH_INTERVAL_SEC and self.log_file:
                        self.log_file.flush()
                        self.last_flush_ts = ts
                except Exception as e:
                    print(f"[RECO-TOP] [WARN] 書き込み失敗: {e}")

    def shutdown(self) -> None:
        self.stop_flag = True
        self.thread.join(timeout=3.0)
        with self.lock:
            self._close_trial_locked("truncated")
        if self.cap is not None:
            try:
                self.cap.release()
            except Exception:
                pass
            self.cap = None


# ---- orphan 保存 -------------------------------------------------------------
def save_orphan_frame(frame, capture_ts: float, tag: str) -> Path | None:
    if frame is None:
        return None
    # DAY287 R2 D5: orphan 保存にもマウントガード. ガード不成立ならログのみ.
    if not ensure_kioxia_mounted():
        print(f"[RECO] [WARN] orphan 保存断念 (KIOXIA マウントなし) tag={tag}")
        return None
    try:
        ORPHAN_FRAMES_DIR.mkdir(parents=True, exist_ok=True)
        ts_str = datetime.fromtimestamp(capture_ts).strftime("%Y%m%d_%H%M%S_%f")[:-3]
        path = ORPHAN_FRAMES_DIR / f"{tag}_{ts_str}.jpg"
        if cv2.imwrite(str(path), frame):
            return path
    except Exception as e:
        print(f"[RECO] [WARN] orphan 保存失敗: {e}")
    return None


# ---- main loop ---------------------------------------------------------------
def main() -> int:
    # 2026-10-15: --top で上カメラ録画を追加, --no-side で横カメラ (Iriun) を使わない.
    # どちらも未指定なら従来と完全同一の挙動.
    ap = argparse.ArgumentParser(description="試行の連続記録 recorder")
    ap.add_argument("--top", action="store_true",
                    help="上カメラ (iPhone 連係) を recording/top.mp4 に録画する")
    ap.add_argument("--no-side", action="store_true",
                    help="横カメラ (Iriun) を開かない")
    args = ap.parse_args()

    if not ensure_kioxia_mounted():
        print(f"[RECO] [ABORT] KIOXIA 未マウント (ismount + write test 失敗): {KIOXIA_MOUNT}")
        return 2

    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((UDP_HOST, UDP_PORT))
    sock.setblocking(False)
    print(f"[RECO] UDP listen {UDP_HOST}:{UDP_PORT}")

    camera = SideCamera()
    if args.no_side:
        print("[RECO] --no-side: 横カメラは使わない")
    elif not camera.open():
        print("[RECO] [WARN] 起動時カメラ open 失敗 — 定期再試行しながら待機")

    top = TopRecorder() if args.top else None
    if top is not None:
        top.start()
        print("[RECO] --top: 上カメラ録画スレッド開始")

    trial: TrialWriter | None = None
    reply_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

    def handle_frame_request(payload: dict) -> None:
        nonlocal trial
        tag = str(payload.get("tag") or "frame")
        reply_port = payload.get("reply_port")
        if not isinstance(reply_port, int):
            print(f"[RECO] [WARN] frame_request reply_port 無効: {reply_port}")
            return
        frame = camera.last_frame
        capture_ts = camera.last_frame_ts
        now = time.time()
        # DAY287 R2 A3: frame age 上限 (FRAME_AGE_LIMIT_SEC) を判定.
        if frame is None or capture_ts is None:
            reply = {"path": None, "capture_ts": None, "frame_age_ms": None,
                     "reason": "no_frame_available"}
        else:
            age_ms = (now - capture_ts) * 1000.0
            if age_ms > FRAME_AGE_LIMIT_SEC * 1000.0:
                reply = {"path": None, "capture_ts": capture_ts,
                         "frame_age_ms": round(age_ms, 1),
                         "reason": "stale_frame"}
            elif trial is not None:
                # DAY287 R3 D5: 試行内 frame_request のマウントガード.
                # extracted_dir.mkdir / cv2.imwrite に到達させない (ローカル側
                # /Volumes/KIOXIA/... 再作成の余地を残さない・fallback 禁止).
                if not ensure_kioxia_mounted():
                    print("[RECO] [WARN] frame_request 時 KIOXIA マウント消失 → mount_lost で trial+camera を閉じる")
                    trial.close("mount_lost")
                    trial = None
                    camera.close()
                    reply = {"path": None, "capture_ts": capture_ts,
                             "frame_age_ms": round(age_ms, 1),
                             "reason": "mount_lost"}
                else:
                    extracted_dir = trial.dir / "extracted"
                    ts_str = datetime.fromtimestamp(capture_ts).strftime(
                        "%Y%m%d_%H%M%S_%f")[:-3]
                    out_path = extracted_dir / f"{tag}_{ts_str}.jpg"
                    try:
                        extracted_dir.mkdir(parents=True, exist_ok=True)
                        if cv2.imwrite(str(out_path), frame):
                            reply = {"path": str(out_path),
                                     "capture_ts": capture_ts,
                                     "frame_age_ms": round(age_ms, 1),
                                     "reason": "ok"}
                        else:
                            reply = {"path": None, "capture_ts": None,
                                     "frame_age_ms": round(age_ms, 1),
                                     "reason": "imwrite_failed"}
                    except Exception as e:
                        reply = {"path": None, "capture_ts": None,
                                 "frame_age_ms": round(age_ms, 1),
                                 "reason": f"exception:{type(e).__name__}"}
            else:
                # 試行外. save_orphan_frame 内で ensure_kioxia_mounted 済み.
                orphan_path = save_orphan_frame(frame, capture_ts, tag)
                reply = {"path": str(orphan_path) if orphan_path else None,
                         "capture_ts": capture_ts,
                         "frame_age_ms": round(age_ms, 1),
                         "reason": "ok_orphan" if orphan_path else "orphan_save_failed",
                         "orphan": True}
        try:
            reply_sock.sendto(
                (json.dumps(reply) + "\n").encode("utf-8"),
                (UDP_HOST, reply_port))
        except Exception as e:
            print(f"[RECO] [WARN] frame_request 応答失敗: {e}")

    def start_trial(payload: dict) -> None:
        nonlocal trial
        run_stem = str(payload.get("run_stem") or "")
        if not run_stem:
            print("[RECO] [WARN] trial_start run_stem 空 → 無視")
            return
        if trial is not None:
            print(f"[RECO] 二重 trial_start: 前 {trial.run_stem} を truncated で閉じる")
            trial.close("truncated")
            trial = None
        if not ensure_kioxia_mounted():
            print("[RECO] [WARN] trial_start 時 KIOXIA 未マウント → 記録断念")
            return
        try:
            trial = TrialWriter(run_stem, camera.actual_width, camera.actual_height)
            print(f"[RECO] trial 開始 run_stem={run_stem} → {trial.dir}")
            if top is not None:
                top.begin_trial(trial.dir)
        except Exception as e:
            print(f"[RECO] [WARN] TrialWriter 作成失敗: {e}")
            trial = None

    def end_trial(payload: dict) -> None:
        nonlocal trial
        if top is not None:
            top.end_trial()
        if trial is None:
            print("[RECO] trial_end 受領 (該当 trial なし)")
            return
        trial.close(trial.status)
        trial = None

    print("[RECO] 待機中. 本線からの trial_start を待つ (Ctrl-C で終了)")
    try:
        while True:
            for _ in range(64):
                try:
                    data, _addr = sock.recvfrom(65536)
                except BlockingIOError:
                    break
                except Exception as e:
                    print(f"[RECO] [WARN] UDP recv: {e}")
                    break
                try:
                    payload = json.loads(data.decode("utf-8").strip())
                except Exception as e:
                    print(f"[RECO] [WARN] 壊れた JSON を破棄: {e}")
                    continue
                ev = payload.get("type")
                if ev == "trial_start":
                    start_trial(payload)
                elif ev == "trial_end":
                    end_trial(payload)
                elif ev == "frame_request":
                    handle_frame_request(payload)
                elif ev in ("stage", "commanded", "gripper", "actual", "gripper_cmd"):
                    if trial is not None:
                        try:
                            trial.write_state(ev, payload)
                        except Exception as e:
                            print(f"[RECO] [WARN] state 書き込み: {e}")
                else:
                    print(f"[RECO] [WARN] 未知 type を破棄: {ev}")

            if camera.cap is None:
                if args.no_side:
                    time.sleep(UDP_POLL_INTERVAL_SEC)
                else:
                    camera.try_reopen_if_needed()
            else:
                ok, frame = camera.read()
                if ok:
                    if trial is not None:
                        if ensure_kioxia_mounted():
                            trial.write_frame(frame, camera.last_frame_ts or time.time())
                        else:
                            # DAY287 R2 D5 + R3 A3: 試行中マウント消失は
                            # mount_lost で閉じる. カメラも即 close して
                            # last_frame を無効化 (以後の frame_request に古い
                            # フレームを返させない).
                            if trial.status == "ok":
                                print("[RECO] [WARN] 試行中 KIOXIA マウント消失 → mount_lost で閉じる")
                                trial.close("mount_lost")
                                trial = None
                                camera.close()
                elif camera.consecutive_read_fails >= READ_FAIL_LIMIT:
                    if trial is not None and trial.status == "ok":
                        print(f"[RECO] read 30 連続失敗 → trial を camera_lost で閉じる")
                        trial.close("camera_lost")
                        trial = None
                    # DAY287 R3 A3: 30 連続失敗の確定時は cooldown 無関係に
                    # 即 camera.close() で last_frame をクリア.
                    # cooldown 判定は「close 後の再 open 試行」にのみ適用する.
                    camera.close()
                    camera.try_reopen_if_needed()
                else:
                    time.sleep(UDP_POLL_INTERVAL_SEC)
    except KeyboardInterrupt:
        print("\n[RECO] Ctrl-C 受領 → 終了")
    finally:
        if top is not None:
            top.shutdown()
        if trial is not None:
            trial.close("truncated")
        camera.close()
        try:
            sock.close()
        except Exception:
            pass
        try:
            reply_sock.close()
        except Exception:
            pass
    return 0


if __name__ == "__main__":
    sys.exit(main())

カメラを2つのプログラムで同時に開けるかを試した experiments/test_top_cam_dual_open.py の全文です。

python

#!/usr/bin/env python3
"""上カメラ(iPhone 連係カメラ)を 2 プロセスで同時に開けるかの確認 (デモ記録設計の前提チェック).

- プロセスA: 10 秒間ずっと開きっぱなしで読み続ける (recorder 役)
- プロセスB: 3 秒後に開いて 2 秒読んで閉じる (run_pick_place の検出役)
両方のフレーム数と、B が開いている間に A が止まらなかったかを表示する.
アームには一切触れない.
Usage: python3 experiments/test_top_cam_dual_open.py
"""
import multiprocessing as mp
import sys
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dynamic_tracking_limits/place/place_modules"))


def _idx():
    import camera_resolver
    return camera_resolver.resolve_cameras(require_top=True, require_side=False, verbose=False)["top"]


def holder(idx, q):
    import cv2
    cap = cv2.VideoCapture(idx)
    ok_open = cap.isOpened()
    stamps = []
    t0 = time.time()
    while time.time() - t0 < 10.0:
        ok, f = cap.read()
        if ok and f is not None:
            stamps.append(time.time() - t0)
    cap.release()
    q.put(("A", ok_open, stamps))


def visitor(idx, q):
    import cv2
    time.sleep(3.0)
    cap = cv2.VideoCapture(idx)
    ok_open = cap.isOpened()
    n = 0
    t0 = time.time()
    while time.time() - t0 < 2.0:
        ok, f = cap.read()
        if ok and f is not None:
            n += 1
    cap.release()
    q.put(("B", ok_open, n))


if __name__ == "__main__":
    idx = _idx()
    print(f"top camera index = {idx}")
    q = mp.Queue()
    pa = mp.Process(target=holder, args=(idx, q))
    pb = mp.Process(target=visitor, args=(idx, q))
    pa.start(); pb.start()
    res = {}
    for _ in range(2):
        k, ok_open, v = q.get(timeout=30)
        res[k] = (ok_open, v)
    pa.join(); pb.join()
    a_open, stamps = res["A"]
    b_open, b_n = res["B"]
    during = [s for s in stamps if 3.0 <= s <= 5.0]
    gaps = [round(b - a, 2) for a, b in zip(stamps, stamps[1:]) if b - a > 0.5]
    print(f"A(常時): open={a_open} frames={len(stamps)} (約{len(stamps)/10:.1f}fps) "
          f"B が開いている間のフレーム={len(during)}  0.5秒超の途切れ={gaps}")
    print(f"B(途中): open={b_open} frames={b_n}")
    ok = a_open and b_open and b_n > 0 and len(during) > 0 and not gaps
    print("RESULT:", "OK 同時に使える" if ok else "NG 同時利用に問題あり")

build_demo_episodes.py は、1回の試行を10Hzの「画像・関節角・0.1秒後の目標」の並びに直す

記録した1回の試行を、学習用の1エピソードに直すプログラム(pi05/build_demo_episodes.py)を作りました。0.1秒ごと(10Hz)に、次の3つを並べます。

  • 画像:その時刻に一番近い上カメラのフレーム(長辺384px に縮小)

  • 状態:関節6つの角度(約0.3秒ごとの実測を線形に補間)と、グリッパーの開閉(開=0、閉=1)

  • 行動:0.1秒後の状態(π0.5 は、この「少し先の姿勢」を出すように学ぶ)

学習に使うのは、人が目で見て「掴めた」「箱に入った」の両方に yes と答えた試行だけです。

また、ブロックや箱を探す検出や、人の判定を待つ間は、アームが止まったまま長い時間が過ぎます。止まっている区間(関節がどれも0.3°以上動かない区間)が2秒より長いときは、最初の1秒だけ残して詰めました。止まって待つことを「動作」として覚えさせないためです。

1本目のエピソードを、画像と関節角の並びで確かめました。

フィジカルAI研究室を作る Log#4 | Physical Intelligenceπ0.5を mechArm 270 のデモで追加学習し、Mac で予測を受け取るまで|build_demo_episodes.py は、1回の試行を10Hzの「画像・関節角・0.1秒後の目標」の並びに直す

フィジカルAI研究室を作る Log#4 | Physical Intelligenceπ0.5を mechArm 270 のデモで追加学習し、Mac で予測を受け取るまで|build_demo_episodes.py は、1回の試行を10Hzの「画像・関節角・0.1秒後の目標」の並びに直す

上の画像は、1本目(run_0001_20261015_173315)の307ステップから12コマを選んで並べたものです。左上の数字はステップ番号、関節角(j0・j1・j4)、グリッパー(g=0 が開、g=1 が閉)です。下のグラフは、関節6つの角度を307ステップ分つないだもので、縦線がグリッパーの指令です。閉じる指令(79ステップ目)の直後の画像では、爪がブロックの真上にあります。開く指令(207ステップ目)は箱の上です。108秒の試行のうち77秒が検出や判定の待ち時間で、学習用には30.7秒・307ステップになりました。

グラフの右側で、j0(土台の回転、青)が大きく持ち上がっています。置いた後、アームは j0 を85°まで回して箱から離れ、一度箱の上に戻ってから HOME に帰っています。既存のスクリプトが、戻るときに箱にぶつからないように入れている回り道です。π0.5 はこの回り道も覚えるはずです。この回は、そのまま残しました。

pi05/build_demo_episodes.py の全文です。

python

#!/usr/bin/env python3
"""build_demo_episodes.py — run_pick_place の記録から π0.5 学習用エピソードを作る (Mac 側・段階1).

入力 (1 試行 = 1 run_stem):
  runs/run_pick_place/{run_stem}/{run_stem}.json      判定 (judgments.pick/place.human_verdict)
  runs/run_pick_place/{run_stem}/recording/states.csv  actual (実測6軸) / gripper_cmd / stage 行
  runs/run_pick_place/{run_stem}/recording/top.mp4 + top_frame_log.csv  上カメラ
  (recorder.py --top と run_pick_place.py --record-demo で記録したもの)

出力 (1 エピソード = 1 フォルダ):
  datasets/{DATASET}/raw_episodes/{run_stem}/
    frames/000000.jpg ...   上カメラ (長辺 IMG_LONG px, 縦横比維持)
    episode.npz             t[N], state[N,7], actions[N,7]   (7 = j0..j5 [deg] + gripper 0/1)
    meta.json
  datasets/{DATASET}/index.json   採用/不採用とその理由

設計:
  - 時間軸は FPS (10Hz) で並べ直す.
  - state: 実測角 (約3Hz) を線形補間. gripper は指令した瞬間で 0→1 / 1→0 に切替.
  - action: 0.1 秒後の state (絶対角). π0.5 は action chunk でこれを予測する.
  - 停止区間 (動き < IDLE_EPS_DEG が IDLE_MAX_SEC 超) は先頭 IDLE_KEEP_SEC だけ残して詰める.
    検出・VLM・人の入力待ちを「待つ動作」として学習させないため.
  - 採用条件: pick と place の人の判定がどちらも yes (既定). VLM 判定は使わない.

Usage:
  python3 pi05/build_demo_episodes.py                # 2026-10-15 以降の全試行
  python3 pi05/build_demo_episodes.py --since 20261020 --dataset mecharm_redblock_box_scripted_v1
  python3 pi05/build_demo_episodes.py --run run_0001_20261015_180000 --force
"""
import argparse
import csv
import json
import shutil
import sys
from pathlib import Path

import cv2
import numpy as np

DATA_ROOT = Path("/Volumes/KIOXIA/r2r2r-data")
RUNS_ROOT = DATA_ROOT / "runs" / "run_pick_place"
DEFAULT_DATASET = "mecharm_redblock_box_scripted_v1"
TASK = "put the red block in the white box"
FPS = 10
IMG_LONG = 384            # 保存画像の長辺 px. openpi 側で 224x224 に resize_with_pad される
JPEG_QUALITY = 95
IDLE_EPS_DEG = 0.3        # 1 ステップでの最大関節変化がこれ未満なら「停止」
IDLE_MAX_SEC = 2.0        # これより長い停止区間は詰める
IDLE_KEEP_SEC = 1.0       # 詰めるときに残す長さ
ANGLE_ABS_MAX = 200.0     # 実測値のサニティ (これを超えたら読み取り異常として捨てる)
MIN_ACTUAL_SAMPLES = 20


def ensure_ssd():
    if not DATA_ROOT.exists():
        sys.exit(f"[ERROR] SSD not mounted: {DATA_ROOT}")


def load_states(path):
    actual_t, actual_q, grip_t, grip_s = [], [], [], []
    with path.open(newline="") as f:
        for row in csv.DictReader(f):
            ev = row.get("event_type")
            try:
                ts = float(row["ts"])
            except Exception:
                continue
            if ev == "actual":
                try:
                    q = [float(row[f"j{i}"]) for i in range(6)]
                except Exception:
                    continue
                if any(abs(v) > ANGLE_ABS_MAX for v in q):
                    continue
                actual_t.append(ts); actual_q.append(q)
            elif ev == "gripper_cmd":
                try:
                    grip_t.append(ts); grip_s.append(1.0 if int(float(row["gripper_state"])) == 1 else 0.0)
                except Exception:
                    pass
    return (np.array(actual_t), np.array(actual_q, dtype=np.float64).reshape(-1, 6),
            np.array(grip_t), np.array(grip_s))


def drop_spikes(t, q, max_jump_deg=40.0):
    """前後どちらとも max_jump_deg 以上離れた孤立点 (読み取り異常) を捨てる."""
    if len(t) < 3:
        return t, q
    keep = np.ones(len(t), dtype=bool)
    for i in range(1, len(t) - 1):
        a = np.max(np.abs(q[i] - q[i - 1])); b = np.max(np.abs(q[i] - q[i + 1]))
        c = np.max(np.abs(q[i + 1] - q[i - 1]))
        if a > max_jump_deg and b > max_jump_deg and c < max_jump_deg:
            keep[i] = False
    return t[keep], q[keep]


def load_frame_log(path):
    idx, ts = [], []
    with path.open(newline="") as f:
        for row in csv.DictReader(f):
            idx.append(int(row["frame_idx"])); ts.append(float(row["capture_ts"]))
    return np.array(idx), np.array(ts)


def judge(run_json):
    j = json.loads(run_json.read_text())
    jd = j.get("judgments") or {}
    pick_h = (jd.get("pick") or {}).get("human_verdict")
    place_h = (jd.get("place") or {}).get("human_verdict")
    return pick_h, place_h, j


def build_one(run_dir, out_dir, args):
    stem = run_dir.name
    rec = run_dir / "recording"
    run_json = run_dir / f"{stem}.json"
    need = [run_json, rec / "states.csv", rec / "top.mp4", rec / "top_frame_log.csv"]
    missing = [p.name for p in need if not p.exists()]
    if missing:
        return {"run": stem, "used": False, "reason": "missing:" + ",".join(missing)}

    pick_h, place_h, _ = judge(run_json)
    if not args.include_failed and not (pick_h == "yes" and place_h == "yes"):
        return {"run": stem, "used": False, "reason": f"human_verdict pick={pick_h} place={place_h}"}

    at, aq, gt, gs = load_states(rec / "states.csv")
    if len(at) < MIN_ACTUAL_SAMPLES:
        return {"run": stem, "used": False, "reason": f"actual 実測が少ない ({len(at)})"}
    order = np.argsort(at); at, aq = at[order], aq[order]
    at, aq = drop_spikes(at, aq)
    fidx, fts = load_frame_log(rec / "top_frame_log.csv")
    if len(fts) < FPS:
        return {"run": stem, "used": False, "reason": f"top フレームが少ない ({len(fts)})"}

    t0, t1 = max(fts[0], at[0]), min(fts[-1], at[-1])
    if t1 - t0 < 3.0:
        return {"run": stem, "used": False, "reason": "記録の重なりが短い"}
    t = np.arange(t0, t1, 1.0 / FPS)

    q = np.stack([np.interp(t, at, aq[:, k]) for k in range(6)], axis=1)
    g = np.zeros(len(t))
    if len(gt):
        go = np.argsort(gt); gt, gs = gt[go], gs[go]
        for i, ti in enumerate(t):
            k = np.searchsorted(gt, ti, side="right") - 1
            g[i] = gs[k] if k >= 0 else 0.0
    state = np.concatenate([q, g[:, None]], axis=1).astype(np.float32)
    actions = np.concatenate([state[1:], state[-1:]], axis=0)

    # 停止区間を詰める
    motion = np.max(np.abs(actions - state), axis=1)
    keep = np.ones(len(t), dtype=bool)
    i, idle_trim_steps = 0, 0
    while i < len(t):
        if motion[i] < IDLE_EPS_DEG:
            j = i
            while j < len(t) and motion[j] < IDLE_EPS_DEG:
                j += 1
            if (j - i) / FPS > IDLE_MAX_SEC:
                cut_from = i + int(IDLE_KEEP_SEC * FPS)
                keep[cut_from:j] = False
                idle_trim_steps += j - cut_from
            i = j
        else:
            i += 1
    t, state, actions = t[keep], state[keep], actions[keep]

    # 各ステップに最も近い上カメラのフレーム
    nearest = np.clip(np.searchsorted(fts, t), 1, len(fts) - 1)
    left = fts[nearest - 1]; right = fts[nearest]
    nearest = np.where(np.abs(t - left) <= np.abs(right - t), nearest - 1, nearest)
    want = {int(fidx[n]): [] for n in nearest}
    for step, n in enumerate(nearest):
        want[int(fidx[n])].append(step)

    if out_dir.exists():
        if not args.force:
            return {"run": stem, "used": True, "reason": "既存 (スキップ・--force で再生成)",
                    "steps": int(len(t))}
        shutil.rmtree(out_dir)
    (out_dir / "frames").mkdir(parents=True)

    cap = cv2.VideoCapture(str(rec / "top.mp4"))
    fi, written, max_need = 0, 0, max(want)
    size = None
    while fi <= max_need:
        ok, frame = cap.read()
        if not ok:
            break
        if fi in want:
            h, w = frame.shape[:2]
            s = IMG_LONG / max(h, w)
            small = cv2.resize(frame, (round(w * s), round(h * s)), interpolation=cv2.INTER_AREA)
            size = small.shape[1], small.shape[0]
            for step in want[fi]:
                cv2.imwrite(str(out_dir / "frames" / f"{step:06d}.jpg"), small,
                            [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY])
                written += 1
        fi += 1
    cap.release()
    if written != len(t):
        shutil.rmtree(out_dir)
        return {"run": stem, "used": False,
                "reason": f"フレーム抽出不足 written={written} steps={len(t)} (mp4 と frame_log の不一致)"}

    np.savez_compressed(out_dir / "episode.npz", t=t - t[0], state=state, actions=actions)
    meta = {
        "run_stem": stem, "task": TASK, "fps": FPS, "steps": int(len(t)),
        "duration_sec": round(len(t) / FPS, 1),
        "idle_trimmed_sec": round(idle_trim_steps / FPS, 1),
        "image_size": size, "state_dim": 7,
        "state_names": ["j0", "j1", "j2", "j3", "j4", "j5", "gripper"],
        "action": "absolute target = state 0.1s later",
        "actual_samples": int(len(at)), "gripper_cmds": int(len(gt)),
        "human_verdict": {"pick": pick_h, "place": place_h},
    }
    (out_dir / "meta.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False))
    return {"run": stem, "used": True, "reason": "ok", "steps": int(len(t)),
            "idle_trimmed_sec": meta["idle_trimmed_sec"]}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--dataset", default=DEFAULT_DATASET)
    ap.add_argument("--since", default="20261015", help="run_stem の日付 (YYYYMMDD) 以降")
    ap.add_argument("--run", action="append", help="特定の run_stem だけ (複数可)")
    ap.add_argument("--include-failed", action="store_true", help="人の判定 no も含める (分析用)")
    ap.add_argument("--force", action="store_true", help="既存エピソードを作り直す")
    args = ap.parse_args()
    ensure_ssd()

    ds = DATA_ROOT / "datasets" / args.dataset
    out_root = ds / "raw_episodes"
    out_root.mkdir(parents=True, exist_ok=True)
    runs = sorted(p for p in RUNS_ROOT.glob("run_*") if p.is_dir())
    if args.run:
        runs = [p for p in runs if p.name in set(args.run)]
    else:
        runs = [p for p in runs if p.name.split("_")[2] >= args.since]
        runs = [p for p in runs if (p / "recording" / "top.mp4").exists()]

    results = []
    for r in runs:
        try:
            res = build_one(r, out_root / r.name, args)
        except Exception as e:
            res = {"run": r.name, "used": False, "reason": f"exception:{type(e).__name__}:{e}"}
        results.append(res)
        flag = "OK " if res["used"] else "-- "
        print(f"[{flag}] {res['run']}  {res.get('steps', '')}  {res['reason']}")

    index_path = ds / "index.json"
    old = json.loads(index_path.read_text()) if index_path.exists() else {}
    for res in results:
        old[res["run"]] = res
    index_path.write_text(json.dumps(old, indent=2, ensure_ascii=False))
    used = [v for v in old.values() if v.get("used")]
    print(f"\n採用エピソード合計: {len(used)}  総ステップ: {sum(v.get('steps', 0) for v in used)}"
          f"  → {out_root}")


if __name__ == "__main__":
    main()

記録モードの最初の12回で成功は2本。置き場所を絞り、3回ごとに休ませたら8回続けて成功した

記録モードで集め始めた最初の12回で、掴んで箱に入れるところまで成功したのは2回でした。5回目あたりからは、j4 が指令より22〜31°届かない回が続きました。朝と同じく、手首の発熱だと考えています。

そこで、やり方を2つ変えました。

  • ブロックの置き場所を、過去に成功しやすかった範囲(次の節)に絞る

  • 3回動かしたら、HOME の姿勢で5分休ませる(電源は切らない。切ると、腕が自分の重さで垂れ下がるため)

変えた後は、8回続けて成功しています。この日の記録モードの成功は、合わせて10本(3,487ステップ)になりました。

記録モードで動かした20回の全量です。判定は人の目視、ブロックの位置はスクリプトが上カメラから求めたロボット座標(mm)、ステップ数は学習用に直した後の数です。データは /Volumes/KIOXIA/r2r2r-data/runs/pick_place_ledger_v3.csv と /Volumes/KIOXIA/r2r2r-data/datasets/mecharm_redblock_box_scripted_v1/index.json から取りました。

試行 (run_stem)

掴めた

箱に入った

ブロック x

ブロック z

学習に使う

ステップ

詰めた待ち時間

20261015_173058

(判定なし)

(判定なし)

-166.3

193.0

×

20261015_173315

yes

yes

206.9

252.0

○

307

77.0秒

20261015_173928

yes

(判定なし)

224.9

239.9

×

20261015_174159

no

no

191.4

286.2

×

20261015_174402

yes

no

181.2

271.5

×

20261015_174847

no

no

240.4

233.7

×

20261015_175150

(判定なし)

(判定なし)

236.0

285.2

×

20261015_175333

yes

yes

224.5

247.8

○

293

95.3秒

20261015_175544

no

no

151.7

286.3

×

20261015_175856

yes

(判定なし)

143.8

267.6

×

20261015_180056

yes

no

263.7

207.8

×

20261015_180309

yes

no

209.4

257.8

×

(ここで置き場所を絞り、3回ごとに休ませる形に変えた)

20261015_181752

yes

yes

204.6

244.9

○

291

146.4秒

20261015_182056

yes

yes

209.6

266.8

○

279

88.8秒

20261015_182552

yes

yes

211.5

253.4

○

663

215.6秒

20261015_183310

yes

yes

206.1

260.3

○

294

263.7秒

20261015_185536

yes

yes

199.4

255.8

○

267

131.1秒

20261015_193907

yes

yes

208.0

247.2

○

282

115.5秒

20261015_194135

yes

yes

216.6

245.9

○

524

101.6秒

20261015_194420

yes

yes

165.8

285.8

○

287

91.6秒

「判定なし」は、人の判定を入力する前に試行が止まった回です。1回目(173058)はブロックの x が -166.3 で、ほかの回とまったく違う値になっています。止まった原因は、まだ調べていません。

10本のうち 182552 は、掴み損ねて3回目で掴めた回です(グリッパーの指令が他の回の3回に対して7回)。長さも66.3秒と、他の約2倍あります。失敗からやり直す動きを覚えさせるか、そろった成功だけで学ばせるかは、本番の学習の前に決めます。194135 も52.4秒と長めですが、理由はまだ調べていません。

もう1つ、気になる点がありました。台帳には、スクリプトが座標から計算した「箱に入ったか」(in_box_geom)の列もあります。人が「箱に入った」と答えた10本のうち、185536 はこの列が False でした。朝の試行にも同じ食い違いが2回あります。π0.5 版とスクリプト版を比べるとき、何をもって「成功」と数えるかは、比べる前に決めておく必要があります。

過去の653回の試行は静止画4枚ずつしか残っておらず、学習データにはできなかった

このスクリプトは、6〜7月にも653回動かしていて、そのうち246回は掴むのも置くのも成功しています。これを学習に使えないか調べました。

使えませんでした。過去の試行で残っているカメラ画像は、1回につき静止画4枚(掴む直前、持ち上げた後、置く直前、置いた後)だけです。動いている間の映像と関節角の流れがないので、0.1秒ごとの並びが作れません。

その代わり、成功しやすいブロックの置き場所が分かりました。過去の試行を、ブロックの位置(ロボット座標、mm)ごとに集計した結果です。

ブロックの位置 (x, z)

試行

成功率

200, 240

55

80%

160, 260

13

69%

240, 220

9

67%

200, 260

154

53%

140, 260

13

8%

200, 280

11

0%

(200, 240) 付近が安定しています。休ませる形に変えた後の8回も、1回(165.8, 285.8)を除いて、x = 199〜217、z = 245〜267 に置きました。

openpi に mechArm 用の学習設定 pi05_mecharm_lora を足した

openpi(Physical Intelligence が π0.5 のコードと重みを公開している GitHub のリポジトリ)は、LeRobot 形式(Hugging Face が作ったロボット学習用のデータ形式)のデータで追加学習できるようになっています。mechArm 用に、次の設定を足しました。

項目

値

元のモデル

π0.5 base(pi05_base、特定のアーム向けに追加学習される前の π0.5)

学習方法

LoRA(元の重みは固定して、追加した小さな部分だけを学習する方法。GPU のメモリが少なくて済む)

カメラ

上カメラ1台。π0.5 が持っている手首カメラ2台分の入力枠は、使わないことを示す印を付けて空ける

状態・行動

7つ(関節6つ + グリッパー)

1回の予測

10ステップ分(10Hz で1秒先まで)

行動の扱い

関節6つは「今の姿勢からの差」にして学習し、予測を使うときに角度へ戻す。グリッパーは0か1のまま

学習

batch 32、本番は5,000ステップの予定。学習率は300ステップかけて 5e-5 まで上げ、その後 5e-6 まで下げる

openpi 本体のファイルを手で書き換える代わりに、設定を登録するスクリプト(install_addon.py)を用意しました。clone した openpi に対して1回実行すれば、設定の追加と、mechArm 用の入出力の変換(mecharm_policy.py)の配置が済みます。2回実行しても、二重には足されません。

記録したエピソードを LeRobot 形式に直すのは convert_to_lerobot.py で、これは Pod の中(openpi の環境)で動かします。

pi05/openpi_addon/install_addon.py の全文です。

python

#!/usr/bin/env python3
"""openpi (215abfb) に mechArm 270-Pi 用の学習設定を登録する (r2r2r 2026-10-15).

Runpod で openpi を clone した後に 1 回実行:
  python3 install_addon.py /workspace/openpi
何度実行しても同じ結果 (二重登録しない). 変更点:
  - src/openpi/policies/mecharm_policy.py を追加
  - src/openpi/training/config.py に
      import / LeRobotMecharmDataConfig クラス / TrainConfig "pi05_mecharm_lora" を追記
"""
import shutil
import sys
from pathlib import Path

here = Path(__file__).resolve().parent
root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
src = root / "src" / "openpi"
cfg = src / "training" / "config.py"
assert cfg.exists(), f"openpi ルートではない: {root}"

shutil.copy(here / "mecharm_policy.py", src / "policies" / "mecharm_policy.py")
s = cfg.read_text()
MARK = "# >>> r2r2r mecharm addon"

IMPORT = "import openpi.policies.mecharm_policy as mecharm_policy\n"
IMPORT_ANCHOR = "import openpi.policies.libero_policy as libero_policy\n"

DATA_CLASS = f'''{MARK} (data config)
@dataclasses.dataclass(frozen=True)
class LeRobotMecharmDataConfig(DataConfigFactory):
    """mechArm 270-Pi: 上カメラ 1 台 / state 7 = j0..j5[deg] + gripper / actions 7 (絶対値で記録)."""

    @override
    def create(self, assets_dirs: pathlib.Path, model_config: _model.BaseModelConfig) -> DataConfig:
        repack_transform = _transforms.Group(
            inputs=[
                _transforms.RepackTransform(
                    {{
                        "observation/image": "image",
                        "observation/state": "state",
                        "actions": "actions",
                        "prompt": "prompt",
                    }}
                )
            ]
        )
        data_transforms = _transforms.Group(
            inputs=[mecharm_policy.MecharmInputs(model_type=model_config.model_type)],
            outputs=[mecharm_policy.MecharmOutputs()],
        )
        # actions は絶対角で記録. 関節 6 軸だけチャンク先頭 state 基準の差分にして学習し,
        # 推論時に絶対角へ戻す. グリッパー (7 軸目) は絶対値のまま.
        delta_action_mask = _transforms.make_bool_mask(6, -1)
        data_transforms = data_transforms.push(
            inputs=[_transforms.DeltaActions(delta_action_mask)],
            outputs=[_transforms.AbsoluteActions(delta_action_mask)],
        )
        model_transforms = ModelTransformFactory()(model_config)
        return dataclasses.replace(
            self.create_base_config(assets_dirs, model_config),
            repack_transforms=repack_transform,
            data_transforms=data_transforms,
            model_transforms=model_transforms,
        )
# <<< r2r2r mecharm addon (data config)


'''
DATA_ANCHOR = "_CONFIGS = [\n"

TRAIN = f'''    {MARK} (train config)
    TrainConfig(
        name="pi05_mecharm_lora",
        model=pi0_config.Pi0Config(
            pi05=True,
            action_horizon=10,  # 10Hz x 10 = 1 秒先までを 1 回で予測
            paligemma_variant="gemma_2b_lora",
            action_expert_variant="gemma_300m_lora",
        ),
        data=LeRobotMecharmDataConfig(
            repo_id="local/mecharm_redblock_box_v1",
            base_config=DataConfig(prompt_from_task=True),
        ),
        weight_loader=weight_loaders.CheckpointWeightLoader("gs://openpi-assets/checkpoints/pi05_base/params"),
        freeze_filter=pi0_config.Pi0Config(
            pi05=True,
            action_horizon=10,
            paligemma_variant="gemma_2b_lora",
            action_expert_variant="gemma_300m_lora",
        ).get_freeze_filter(),
        ema_decay=None,  # LoRA では EMA を使わない (openpi の LoRA 例に合わせる)
        batch_size=32,
        lr_schedule=_optimizer.CosineDecaySchedule(
            warmup_steps=300,
            peak_lr=5e-5,
            decay_steps=5_000,
            decay_lr=5e-6,
        ),
        num_train_steps=5_000,  # 成功 20 本 (約 6,000 フレーム) で約 27 周
        log_interval=50,
        save_interval=1_000,
        keep_period=1_000,
        wandb_enabled=False,
    ),
    # <<< r2r2r mecharm addon (train config)
'''
TRAIN_ANCHOR = "    *polaris_config.get_polaris_configs(),\n"

if MARK in s:
    print("already installed (config.py は変更なし)")
else:
    for a in (IMPORT_ANCHOR, DATA_ANCHOR, TRAIN_ANCHOR):
        assert s.count(a) == 1, f"anchor not unique/missing: {a!r}"
    s = s.replace(IMPORT_ANCHOR, IMPORT_ANCHOR + IMPORT, 1)
    s = s.replace(DATA_ANCHOR, DATA_CLASS + DATA_ANCHOR, 1)
    s = s.replace(TRAIN_ANCHOR, TRAIN_ANCHOR + TRAIN, 1)
    cfg.write_text(s)
    print("installed: policies/mecharm_policy.py + config.py (pi05_mecharm_lora)")

pi05/openpi_addon/mecharm_policy.py の全文です。

python

"""mechArm 270-Pi 用の入出力変換 (r2r2r / Pixels to Motors 2026-10-15).

libero_policy.py を元にした最小構成:
  - カメラ: 上カメラ 1 台 (observation/image)。手首カメラは無いのでゼロ画像 + マスク False
  - state: 7 次元 = j0..j5 [deg] + gripper (0=open, 1=close)
  - actions: 7 次元 (同上・絶対値)。学習時は config 側で関節 6 軸だけ差分化する
"""

import dataclasses

import einops
import numpy as np

from openpi import transforms
from openpi.models import model as _model

ACTION_DIM = 7


def make_mecharm_example() -> dict:
    return {
        "observation/state": np.zeros(ACTION_DIM, dtype=np.float32),
        "observation/image": np.random.randint(256, size=(216, 384, 3), dtype=np.uint8),
        "prompt": "put the red block in the white box",
    }


def _parse_image(image) -> np.ndarray:
    image = np.asarray(image)
    if np.issubdtype(image.dtype, np.floating):
        image = (255 * image).astype(np.uint8)
    if image.shape[0] == 3:
        image = einops.rearrange(image, "c h w -> h w c")
    return image


@dataclasses.dataclass(frozen=True)
class MecharmInputs(transforms.DataTransformFn):
    model_type: _model.ModelType

    def __call__(self, data: dict) -> dict:
        base_image = _parse_image(data["observation/image"])
        no_image = np.zeros_like(base_image)
        is_fast = self.model_type == _model.ModelType.PI0_FAST
        inputs = {
            "state": np.asarray(data["observation/state"], dtype=np.float32),
            "image": {
                "base_0_rgb": base_image,
                "left_wrist_0_rgb": no_image,
                "right_wrist_0_rgb": no_image,
            },
            "image_mask": {
                "base_0_rgb": np.True_,
                "left_wrist_0_rgb": np.True_ if is_fast else np.False_,
                "right_wrist_0_rgb": np.True_ if is_fast else np.False_,
            },
        }
        if "actions" in data:
            inputs["actions"] = data["actions"]
        if "prompt" in data:
            inputs["prompt"] = data["prompt"]
        return inputs


@dataclasses.dataclass(frozen=True)
class MecharmOutputs(transforms.DataTransformFn):
    def __call__(self, data: dict) -> dict:
        return {"actions": np.asarray(data["actions"][..., :ACTION_DIM])}

pi05/convert_to_lerobot.py の全文です。

python

#!/usr/bin/env python3
"""convert_to_lerobot.py — raw_episodes (build_demo_episodes.py の出力) を LeRobot 形式にする (段階2).

openpi の環境 (Runpod) で実行する. Mac では openpi が入らないため動かない (Log#3 で確認済み).
  cd /workspace/openpi
  uv run python /workspace/r2r2r/pi05/convert_to_lerobot.py \
      --raw-dir /workspace/data/mecharm_redblock_box_scripted_v1/raw_episodes

出力: $HF_LEROBOT_HOME/local/mecharm_redblock_box_v1  (既定 ~/.cache/huggingface/lerobot)
openpi の学習設定 pi05_mecharm_lora はこの repo_id を読む.
"""
import argparse
import json
import shutil
from pathlib import Path

import numpy as np
from PIL import Image
from lerobot.common.datasets.lerobot_dataset import HF_LEROBOT_HOME
from lerobot.common.datasets.lerobot_dataset import LeRobotDataset

REPO_ID = "local/mecharm_redblock_box_v1"


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--raw-dir", required=True)
    ap.add_argument("--repo-id", default=REPO_ID)
    args = ap.parse_args()

    raw = Path(args.raw_dir)
    eps = sorted(p for p in raw.iterdir() if (p / "episode.npz").exists() and (p / "meta.json").exists())
    assert eps, f"エピソードが見つからない: {raw}"
    first_meta = json.loads((eps[0] / "meta.json").read_text())
    w, h = first_meta["image_size"]
    fps = int(first_meta["fps"])

    out = HF_LEROBOT_HOME / args.repo_id
    if out.exists():
        shutil.rmtree(out)
    ds = LeRobotDataset.create(
        repo_id=args.repo_id,
        robot_type="mecharm270",
        fps=fps,
        features={
            "image": {"dtype": "image", "shape": (h, w, 3), "names": ["height", "width", "channel"]},
            "state": {"dtype": "float32", "shape": (7,), "names": ["state"]},
            "actions": {"dtype": "float32", "shape": (7,), "names": ["actions"]},
        },
        image_writer_threads=8,
        image_writer_processes=4,
    )

    total = 0
    for ep in eps:
        meta = json.loads((ep / "meta.json").read_text())
        z = np.load(ep / "episode.npz")
        state, actions = z["state"].astype(np.float32), z["actions"].astype(np.float32)
        frames = sorted((ep / "frames").glob("*.jpg"))
        assert len(frames) == len(state) == len(actions), f"長さ不一致: {ep.name}"
        assert tuple(meta["image_size"]) == (w, h), f"画像サイズ不一致: {ep.name}"
        for i in range(len(state)):
            img = np.asarray(Image.open(frames[i]).convert("RGB"))   # jpg は BGR ではなく RGB で読まれる
            ds.add_frame({"image": img, "state": state[i], "actions": actions[i], "task": meta["task"]})
        ds.save_episode()
        total += len(state)
        print(f"  {ep.name}: {len(state)} steps")
    print(f"done: {len(eps)} episodes / {total} frames → {out}")


if __name__ == "__main__":
    main()

GPU を借りる前に、GPU のない環境で学習の直前までを確かめた

GPU は1時間ごとにお金がかかるので、借りる前に、Claude の作業環境(GPU のない Linux)で確かめられるところまで確かめました。Log#3 と同じ版の openpi(コミット 215abfb)と、openpi が指定している版の LeRobot(コミット 0cf8648)に、成功2本を入れて、学習の直前まで通しています。LeRobot 形式への変換、正規化(値の大きさをそろえる処理)に使う統計の計算、学習用のデータの取り出しまでです。

取り出したデータは、画像が 224×224、手首カメラ2台分の枠は「使わない」印付き、行動は (10, 32) でした。10はステップ数、32は π0.5 が扱う行動の数で、mechArm の7つに足りない分はゼロで埋められます。どれも想定どおりです。

この作業環境からは、Google のストレージに置かれている π0.5 の重みと語彙ファイルを取りに行けませんでした。なので、学習が実際に進むかどうかは、Runpod で確かめることになります。

Runpod の Connect 画面に「SSH over exposed TCP」が出ず、Pod の設定を編集したら準備が消えた

Log#3 と同じテンプレートで Pod(Runpod で借りる GPU マシンの単位)を作りました。

項目

値

GPU

NVIDIA A40 × 1(48GB)

メモリ

50GB

CPU

9 vCPU

テンプレート

Runpod Pytorch 2.8.0(Ubuntu 24.04)

コンテナディスク

200GB(学習結果の保存に備えて、Log#3 の 80GB から増やした)

料金

1時間 0.62 ドル(GPU 0.59 + ディスク 0.028)

ここでつまずきました。Pod の Connect 画面に、「SSH over exposed TCP」の欄が出ていませんでした。出ていたのは ssh.runpod.io を経由する SSH だけです。こちらは対話の操作はできますが、ファイルの送信(scp)と、後で使う SSH トンネルができません。

Pod の設定を編集して、TCP の22番ポートを公開すると、欄が出ました。ただし、設定を編集すると Pod が作り直され、それまでに済ませた準備(openpi の取得とライブラリのインストール)が消えました。外から接続するためのポート番号も 22034 から 22190 に変わっています。Pod を作るときに、最初から22番ポートを公開しておくのが正解でした。手順書(後で全文を載せます)には、この順番で書いています。

準備をやり直してから、Mac から scp でデータとスクリプトを送り、Pod の中で順に実行しました。この記事では、Pod の IP アドレスを <POD_IP>、外から接続するポート番号を <SSH_PORT> に置き換えています。

# Mac で実行: データとスクリプトを Pod へ送る
scp -P <SSH_PORT> -i ~/.ssh/id_ed25519 ~/mecharm_raw_episodes.tar root@<POD_IP>:/workspace/data/
scp -P <SSH_PORT> -i ~/.ssh/id_ed25519 -r ~/r2r2r-scripts/pi05 root@<POD_IP>:/workspace/r2r2r/

# Pod で実行: 設定の登録 → LeRobot 形式へ変換 → 正規化の統計 → 200ステップの学習
cd /workspace/openpi
python3 /workspace/r2r2r/pi05/openpi_addon/install_addon.py /workspace/openpi
uv run python /workspace/r2r2r/pi05/convert_to_lerobot.py --raw-dir /workspace/data/raw_episodes
uv run scripts/compute_norm_stats.py --config-name pi05_mecharm_lora
XLA_PYTHON_CLIENT_MEM_FRACTION=0.9 uv run scripts/train.py pi05_mecharm_lora --exp-name=smoke --overwrite --num-train-steps=200 --save-interval=200 --log-interval=10 2>&1 | tee /workspace/logs/train_smoke.log

変換は2エピソード・600フレームで、すぐに終わりました。

  run_0001_20261015_173315: 307 steps
  run_0001_20261015_175333: 293 steps
done: 2 episodes / 600 frames → /workspace/.cache/huggingface/lerobot/local/mecharm_redblock_box_v1

A40 で成功2本・200ステップを学習:1ステップ約6.6秒、loss は 0.1031 から 0.0162 まで下がった

学習を始めると、最初に π0.5 base の重み(11.6GiB)を Google のストレージからダウンロードします。毎秒約200MB で、1分18秒かかりました。

学習用のデータは、GPU のない環境で確かめたときと同じ形で読み込まれていました。

[0].images['base_0_rgb']: (32, 224, 224, 3)@float32
[0].images['left_wrist_0_rgb']: (32, 224, 224, 3)@float32
[0].images['right_wrist_0_rgb']: (32, 224, 224, 3)@float32
[0].image_masks['base_0_rgb']: (32,)@bool
[0].image_masks['left_wrist_0_rgb']: (32,)@bool
[0].image_masks['right_wrist_0_rgb']: (32,)@bool
[0].state: (32, 32)@float32
[0].tokenized_prompt: (32, 200)@int32
[0].tokenized_prompt_mask: (32, 200)@bool
[1]: (32, 10, 32)@float32

時間の内訳です(ログの時刻から計算)。

区間

時間

重みのダウンロード(11.6GiB)

1分18秒

重みの読み込み

6.28秒

最初の1ステップ(コンパイルを含む)

4分18秒

以後の1ステップ

約6.5〜6.6秒

200ステップ全体(学習の開始から保存まで)

26分26秒

学習結果の保存

22.99秒

コマンドを打ってから終わるまで

28分57秒

最初の1ステップが長いのは、Log#3 と同じく、JAX(π0.5 を動かしている計算ライブラリ)が計算を GPU 用に変換(コンパイル)するためです。

loss(π0.5 の予測とお手本の動きのずれ)は、10ステップごとに次のように下がりました。grad_norm は1ステップで重みを動かそうとする量、param_norm は重み全体の大きさです。

ステップ

loss

grad_norm

param_norm

0

0.1031

1.0302

1803.7705

10

0.0774

0.6888

1803.7705

20

0.0607

0.2169

1803.7705

30

0.0548

0.1718

1803.7705

40

0.0560

0.1768

1803.7705

50

0.0510

0.1691

1803.7705

60

0.0461

0.1963

1803.7708

70

0.0430

0.1737

1803.7708

80

0.0404

0.1670

1803.7714

90

0.0373

0.1604

1803.7715

100

0.0324

0.1610

1803.7715

110

0.0329

0.1675

1803.7717

120

0.0312

0.1372

1803.7725

130

0.0306

0.1769

1803.7731

140

0.0252

0.1318

1803.7734

150

0.0242

0.1396

1803.7743

160

0.0233

0.1380

1803.7753

170

0.0185

0.1225

1803.7764

180

0.0201

0.1272

1803.7773

190

0.0162

0.1266

1803.7787

200ステップで、loss は約6分の1になりました。データの読み込みから学習までの配線は正しく、学習は進んでいると判断しています。ただし、データは2本だけなので、この下がり方が示しているのは「2本の動きを覚えた」ことだけです。別の場所に置いたブロックでも動けるかは、この結果からは分かりません。

param_norm は 1803.7705 から 1803.7787 で、ほとんど変わっていません。LoRA では元の重みを固定して、追加した小さな部分だけを学習するので、重み全体の大きさはほぼ動かないのが普通です。

train_smoke.log の全量

学習のログの全量です。進み具合の表示(tqdm)が行の先頭に戻る文字(CR)で上書きしていた箇所は、改行にして載せています。

09:50:42.948 [I] Running on: 491506d76144                                                         (2358:train.py:196)
INFO:2026-10-15 09:50:43,365:jax._src.xla_bridge:925: Unable to initialize backend 'rocm': module 'jaxlib.xla_extension' has no attribute 'GpuAllocatorConfig'
09:50:43.365 [I] Unable to initialize backend 'rocm': module 'jaxlib.xla_extension' has no attribute 'GpuAllocatorConfig' (2358:xla_bridge.py:925)
INFO:2026-10-15 09:50:43,366:jax._src.xla_bridge:925: Unable to initialize backend 'tpu': INTERNAL: Failed to open libtpu.so: libtpu.so: cannot open shared object file: No such file or directory
09:50:43.366 [I] Unable to initialize backend 'tpu': INTERNAL: Failed to open libtpu.so: libtpu.so: cannot open shared object file: No such file or directory (2358:xla_bridge.py:925)
09:50:44.249 [I] Created BasePyTreeCheckpointHandler: use_ocdbt=True, use_zarr3=False, pytree_metadata_options=PyTreeMetadataOptions(support_rich_types=False), array_metadata_store=<orbax.checkpoint._src.metadata.array_metadata_store.Store object at 0x7eff0e410210> (2358:base_pytree_checkpoint_handler.py:334)
09:50:44.249 [I] Created BasePyTreeCheckpointHandler: use_ocdbt=True, use_zarr3=False, pytree_metadata_options=PyTreeMetadataOptions(support_rich_types=False), array_metadata_store=<orbax.checkpoint._src.metadata.array_metadata_store.Store object at 0x7eff0e410210> (2358:base_pytree_checkpoint_handler.py:334)
09:50:44.249 [I] [thread=MainThread] Failed to get flag value for EXPERIMENTAL_ORBAX_USE_DISTRIBUTED_PROCESS_ID. (2358:multihost.py:390)
09:50:44.249 [I] [process=0][thread=MainThread] CheckpointManager init: checkpointers=None, item_names=None, item_handlers={'assets': <openpi.training.checkpoints.CallbackHandler object at 0x7efd71ef4ad0>, 'train_state': <orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeCheckpointHandler object at 0x7efd71d9a410>, 'params': <orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeCheckpointHandler object at 0x7efd71be0a50>}, handler_registry=None (2358:checkpoint_manager.py:620)
09:50:44.250 [I] Deferred registration for item: "assets". Adding handler `<openpi.training.checkpoints.CallbackHandler object at 0x7efd71ef4ad0>` for item "assets" and save args `<class 'openpi.training.checkpoints.CallbackSave'>` and restore args `<class 'openpi.training.checkpoints.CallbackRestore'>` to `_handler_registry`. (2358:composite_checkpoint_handler.py:234)
09:50:44.250 [I] Deferred registration for item: "train_state". Adding handler `<orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeCheckpointHandler object at 0x7efd71d9a410>` for item "train_state" and save args `<class 'orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeSaveArgs'>` and restore args `<class 'orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeRestoreArgs'>` to `_handler_registry`. (2358:composite_checkpoint_handler.py:234)
09:50:44.250 [I] Deferred registration for item: "params". Adding handler `<orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeCheckpointHandler object at 0x7efd71be0a50>` for item "params" and save args `<class 'orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeSaveArgs'>` and restore args `<class 'orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeRestoreArgs'>` to `_handler_registry`. (2358:composite_checkpoint_handler.py:234)
09:50:44.250 [I] Deferred registration for item: "metrics". Adding handler `<orbax.checkpoint._src.handlers.json_checkpoint_handler.JsonCheckpointHandler object at 0x7efd71b0ea50>` for item "metrics" and save args `<class 'orbax.checkpoint._src.handlers.json_checkpoint_handler.JsonSaveArgs'>` and restore args `<class 'orbax.checkpoint._src.handlers.json_checkpoint_handler.JsonRestoreArgs'>` to `_handler_registry`. (2358:composite_checkpoint_handler.py:234)
09:50:44.250 [I] Initialized registry DefaultCheckpointHandlerRegistry({('assets', <class 'openpi.training.checkpoints.CallbackSave'>): <openpi.training.checkpoints.CallbackHandler object at 0x7efd71ef4ad0>, ('assets', <class 'openpi.training.checkpoints.CallbackRestore'>): <openpi.training.checkpoints.CallbackHandler object at 0x7efd71ef4ad0>, ('train_state', <class 'orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeSaveArgs'>): <orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeCheckpointHandler object at 0x7efd71d9a410>, ('train_state', <class 'orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeRestoreArgs'>): <orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeCheckpointHandler object at 0x7efd71d9a410>, ('params', <class 'orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeSaveArgs'>): <orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeCheckpointHandler object at 0x7efd71be0a50>, ('params', <class 'orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeRestoreArgs'>): <orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeCheckpointHandler object at 0x7efd71be0a50>, ('metrics', <class 'orbax.checkpoint._src.handlers.json_checkpoint_handler.JsonSaveArgs'>): <orbax.checkpoint._src.handlers.json_checkpoint_handler.JsonCheckpointHandler object at 0x7efd71b0ea50>, ('metrics', <class 'orbax.checkpoint._src.handlers.json_checkpoint_handler.JsonRestoreArgs'>): <orbax.checkpoint._src.handlers.json_checkpoint_handler.JsonCheckpointHandler object at 0x7efd71b0ea50>}). (2358:composite_checkpoint_handler.py:502)
09:50:44.250 [I] orbax-checkpoint version: 0.11.13                                                (2358:abstract_checkpointer.py:35)
09:50:44.250 [I] [process=0][thread=MainThread] Using barrier_sync_fn: <function get_barrier_sync_fn.<locals>.<lambda> at 0x7efd59708040> timeout: 7200 secs and primary_host=0 for async checkpoint writes (2358:async_checkpointer.py:170)
09:50:44.250 [I] Found 0 checkpoint steps in /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke (2358:checkpoint_manager.py:1701)
09:50:44.250 [I] [process=0][thread=MainThread] CheckpointManager created,  primary_host=0, CheckpointManagerOptions=CheckpointManagerOptions(save_interval_steps=1, max_to_keep=1, keep_time_interval=None, keep_period=1000, should_keep_fn=None, best_fn=None, best_mode='max', keep_checkpoints_without_metrics=True, step_prefix=None, step_format_fixed_length=None, step_name_format=None, create=False, cleanup_tmp_directories=False, save_on_steps=frozenset(), single_host_load_and_broadcast=False, todelete_subdir=None, enable_background_delete=False, read_only=False, enable_async_checkpointing=True, async_options=AsyncOptions(timeout_secs=7200, barrier_sync_fn=None, post_finalization_callback=None, create_directories_asynchronously=True), multiprocessing_options=MultiprocessingOptions(primary_host=0, active_processes=None, barrier_sync_key_prefix=None), should_save_fn=None, file_options=FileOptions(path_permission_mode=None), save_root_metadata=True, temporary_path_class=None, save_decision_policy=None, prevent_write_metrics=False), root_directory=/workspace/openpi/checkpoints/pi05_mecharm_lora/smoke: <orbax.checkpoint.checkpoint_manager.CheckpointManager object at 0x7efd71ef5a10> (2358:checkpoint_manager.py:801)
09:50:44.330 [I] Loaded norm stats from /workspace/openpi/assets/pi05_mecharm_lora/local/mecharm_redblock_box_v1 (2358:config.py:197)
09:50:44.332 [I] data_config: DataConfig(repo_id='local/mecharm_redblock_box_v1', asset_id='local/mecharm_redblock_box_v1', norm_stats={'state': NormStats(mean=array([  4.80522633,  66.15283203,  -8.67440605,  -5.18624401,
       -62.63858414, -29.77212524,   0.36805555]), std=array([43.34184647, 23.12460899, 25.17373276,  4.55006647, 22.46751213,
        0.36368006,  0.48227653]), q01=array([-42.00999832,   1.49000001, -42.00999832, -12.47999954,
       -90.51999664, -30.53955193,   0.        ]), q99=array([ 85.13456966,  89.02999878,  17.99576836,   0.42945002,
        -0.098088  , -29.032548  ,   0.9998    ])), 'actions': NormStats(mean=array([ 0.29550478,  0.45091581,  0.13539551,  0.01441995, -0.51590323,
        0.01034101,  0.36805558]), std=array([7.96584558, 6.93389273, 5.1586237 , 1.41664672, 6.92239475,
       0.12155879, 0.48227656]), q01=array([-16.88825395, -17.69094424, -14.21244404,  -4.72323612,
       -20.75121702,  -0.41761039,   0.        ]), q99=array([27.31579877, 33.7101847 , 20.87067329,  4.30341967, 29.30441929,
        0.34754324,  0.9998    ]))}, repack_transforms=Group(inputs=[RepackTransform(structure={'observation/image': 'image', 'observation/state': 'state', 'actions': 'actions', 'prompt': 'prompt'})], outputs=()), data_transforms=Group(inputs=(MecharmInputs(model_type=<ModelType.PI05: 'pi05'>), DeltaActions(mask=(True, True, True, True, True, True, False))), outputs=(AbsoluteActions(mask=(True, True, True, True, True, True, False)), MecharmOutputs())), model_transforms=Group(inputs=[InjectDefaultPrompt(prompt=None), ResizeImages(height=224, width=224), TokenizePrompt(tokenizer=<openpi.models.tokenizer.PaligemmaTokenizer object at 0x7efd71ec1110>, discrete_state_input=True), PadStatesAndActions(model_action_dim=32)], outputs=()), use_quantile_norm=True, action_sequence_keys=('actions',), prompt_from_task=True, rlds_data_dir=None, action_space=None, datasets=()) (2358:data_loader.py:243)
09:50:44.754 [I] local_batch_size: 32                                                             (2358:data_loader.py:324)
09:50:56.282 [I] Initialized data loader:
[0].images['base_0_rgb']: (32, 224, 224, 3)@float32
[0].images['left_wrist_0_rgb']: (32, 224, 224, 3)@float32
[0].images['right_wrist_0_rgb']: (32, 224, 224, 3)@float32
[0].image_masks['base_0_rgb']: (32,)@bool
[0].image_masks['left_wrist_0_rgb']: (32,)@bool
[0].image_masks['right_wrist_0_rgb']: (32,)@bool
[0].state: (32, 32)@float32
[0].tokenized_prompt: (32, 200)@int32
[0].tokenized_prompt_mask: (32, 200)@bool
[1]: (32, 10, 32)@float32 (2358:train.py:227)
09:50:57.312 [I] Downloading gs://openpi-assets/checkpoints/pi05_base/params to /root/.cache/openpi/openpi-assets/checkpoints/pi05_base/params (2358:download.py:86)
09:50:57.312 [W] gsutil not found, falling back to gcsfs. This may fail if GCP credentials are not configured correctly. (2358:download.py:111)
09:51:24.755 [I] Progress on: -/12441721931 rate:- remaining:? elapsed:00:00 postfix:-            (2358:tqdm_logging.py:145)
09:51:34.786 [I] Progress on: 1.95GiB/11.6GiB rate:224,949,493.8iB/s remaining:00:46 elapsed:00:10 postfix:- (2358:tqdm_logging.py:145)
09:51:44.816 [I] Progress on: 4.00GiB/11.6GiB rate:222,053,383.9iB/s remaining:00:36 elapsed:00:20 postfix:- (2358:tqdm_logging.py:145)
09:51:54.848 [I] Progress on: 6.12GiB/11.6GiB rate:227,958,769.7iB/s remaining:00:25 elapsed:00:30 postfix:- (2358:tqdm_logging.py:145)
09:52:04.884 [I] Progress on: 8.01GiB/11.6GiB rate:196,116,361.4iB/s remaining:00:19 elapsed:00:40 postfix:- (2358:tqdm_logging.py:145)
09:52:14.921 [I] Progress on: 9.59GiB/11.6GiB rate:167,839,681.1iB/s remaining:00:12 elapsed:00:50 postfix:- (2358:tqdm_logging.py:145)
09:52:24.957 [I] Progress on: 10.9GiB/11.6GiB rate:115,082,354.3iB/s remaining:00:06 elapsed:01:00 postfix:- (2358:tqdm_logging.py:145)
09:52:34.981 [I] Progress on: 11.3GiB/11.6GiB rate:44,325,550.8iB/s remaining:00:07 elapsed:01:10 postfix:- (2358:tqdm_logging.py:145)
09:52:43.005 [I] Progress on: 11.6GiB/11.6GiB rate:36,343,952.5iB/s remaining:00:00 elapsed:01:18 postfix:- (2358:tqdm_logging.py:145)
09:52:43.011 [I] Created BasePyTreeCheckpointHandler: use_ocdbt=True, use_zarr3=False, pytree_metadata_options=PyTreeMetadataOptions(support_rich_types=False), array_metadata_store=<orbax.checkpoint._src.metadata.array_metadata_store.Store object at 0x7eff0e410210> (2358:base_pytree_checkpoint_handler.py:334)
09:52:43.044 [I] Restoring checkpoint from /root/.cache/openpi/openpi-assets/checkpoints/pi05_base/params. (2358:checkpointer.py:298)
09:52:49.322 [I] [process=0] /jax/checkpoint/read/bytes_per_sec: 2.0 GiB/s (total bytes: 12.5 GiB) (time elapsed: 6 seconds) (per-host) (2358:base_pytree_checkpoint_handler.py:114)
09:52:49.323 [I] Finished restoring checkpoint in 6.28 seconds from /root/.cache/openpi/openpi-assets/checkpoints/pi05_base/params. (2358:checkpointer.py:309)
09:53:09.776 [I] Initialized train state:
['PaliGemma']['img']['Transformer']['encoder_norm']['bias'].value: (1152,)@float32
['PaliGemma']['img']['Transformer']['encoder_norm']['scale'].value: (1152,)@float32
['PaliGemma']['img']['Transformer']['encoderblock']['LayerNorm_0']['bias'].value: (27, 1152)@float32
['PaliGemma']['img']['Transformer']['encoderblock']['LayerNorm_0']['scale'].value: (27, 1152)@float32
['PaliGemma']['img']['Transformer']['encoderblock']['LayerNorm_1']['bias'].value: (27, 1152)@float32
['PaliGemma']['img']['Transformer']['encoderblock']['LayerNorm_1']['scale'].value: (27, 1152)@float32
['PaliGemma']['img']['Transformer']['encoderblock']['MlpBlock_0']['Dense_0']['bias'].value: (27, 4304)@float32
['PaliGemma']['img']['Transformer']['encoderblock']['MlpBlock_0']['Dense_0']['kernel'].value: (27, 1152, 4304)@float32
['PaliGemma']['img']['Transformer']['encoderblock']['MlpBlock_0']['Dense_1']['bias'].value: (27, 1152)@float32
['PaliGemma']['img']['Transformer']['encoderblock']['MlpBlock_0']['Dense_1']['kernel'].value: (27, 4304, 1152)@float32
['PaliGemma']['img']['Transformer']['encoderblock']['MultiHeadDotProductAttention_0']['key']['bias'].value: (27, 16, 72)@float32
['PaliGemma']['img']['Transformer']['encoderblock']['MultiHeadDotProductAttention_0']['key']['kernel'].value: (27, 1152, 16, 72)@float32
['PaliGemma']['img']['Transformer']['encoderblock']['MultiHeadDotProductAttention_0']['out']['bias'].value: (27, 1152)@float32
['PaliGemma']['img']['Transformer']['encoderblock']['MultiHeadDotProductAttention_0']['out']['kernel'].value: (27, 16, 72, 1152)@float32
['PaliGemma']['img']['Transformer']['encoderblock']['MultiHeadDotProductAttention_0']['query']['bias'].value: (27, 16, 72)@float32
['PaliGemma']['img']['Transformer']['encoderblock']['MultiHeadDotProductAttention_0']['query']['kernel'].value: (27, 1152, 16, 72)@float32
['PaliGemma']['img']['Transformer']['encoderblock']['MultiHeadDotProductAttention_0']['value']['bias'].value: (27, 16, 72)@float32
['PaliGemma']['img']['Transformer']['encoderblock']['MultiHeadDotProductAttention_0']['value']['kernel'].value: (27, 1152, 16, 72)@float32
['PaliGemma']['img']['embedding']['bias'].value: (1152,)@float32
['PaliGemma']['img']['embedding']['kernel'].value: (14, 14, 3, 1152)@float32
['PaliGemma']['img']['head']['bias'].value: (2048,)@float32
['PaliGemma']['img']['head']['kernel'].value: (1152, 2048)@float32
['PaliGemma']['img']['pos_embedding'].value: (1, 256, 1152)@float32
['PaliGemma']['llm']['embedder']['input_embedding'].value: (257152, 2048)@bfloat16
['PaliGemma']['llm']['final_norm']['scale'].value: (2048,)@bfloat16
['PaliGemma']['llm']['final_norm_1']['Dense_0']['bias'].value: (3072,)@bfloat16
['PaliGemma']['llm']['final_norm_1']['Dense_0']['kernel'].value: (1024, 3072)@bfloat16
['PaliGemma']['llm']['layers']['attn']['attn_vec_einsum']['lora_a'].value: (18, 8, 256, 16)@float32
['PaliGemma']['llm']['layers']['attn']['attn_vec_einsum']['lora_b'].value: (18, 8, 16, 2048)@float32
['PaliGemma']['llm']['layers']['attn']['attn_vec_einsum']['w'].value: (18, 8, 256, 2048)@bfloat16
['PaliGemma']['llm']['layers']['attn']['attn_vec_einsum_1']['lora_a'].value: (18, 8, 256, 32)@float32
['PaliGemma']['llm']['layers']['attn']['attn_vec_einsum_1']['lora_b'].value: (18, 8, 32, 1024)@float32
['PaliGemma']['llm']['layers']['attn']['attn_vec_einsum_1']['w'].value: (18, 8, 256, 1024)@bfloat16
['PaliGemma']['llm']['layers']['attn']['kv_einsum']['lora_a'].value: (18, 2, 1, 2048, 16)@float32
['PaliGemma']['llm']['layers']['attn']['kv_einsum']['lora_b'].value: (18, 2, 1, 16, 256)@float32
['PaliGemma']['llm']['layers']['attn']['kv_einsum']['w'].value: (18, 2, 1, 2048, 256)@bfloat16
['PaliGemma']['llm']['layers']['attn']['kv_einsum_1']['lora_a'].value: (18, 2, 1, 1024, 32)@float32
['PaliGemma']['llm']['layers']['attn']['kv_einsum_1']['lora_b'].value: (18, 2, 1, 32, 256)@float32
['PaliGemma']['llm']['layers']['attn']['kv_einsum_1']['w'].value: (18, 2, 1, 1024, 256)@bfloat16
['PaliGemma']['llm']['layers']['attn']['q_einsum']['lora_a'].value: (18, 8, 2048, 16)@float32
['PaliGemma']['llm']['layers']['attn']['q_einsum']['lora_b'].value: (18, 8, 16, 256)@float32
['PaliGemma']['llm']['layers']['attn']['q_einsum']['w'].value: (18, 8, 2048, 256)@bfloat16
['PaliGemma']['llm']['layers']['attn']['q_einsum_1']['lora_a'].value: (18, 8, 1024, 32)@float32
['PaliGemma']['llm']['layers']['attn']['q_einsum_1']['lora_b'].value: (18, 8, 32, 256)@float32
['PaliGemma']['llm']['layers']['attn']['q_einsum_1']['w'].value: (18, 8, 1024, 256)@bfloat16
['PaliGemma']['llm']['layers']['mlp']['gating_einsum'].value: (18, 2, 2048, 16384)@bfloat16
['PaliGemma']['llm']['layers']['mlp']['gating_einsum_lora_a'].value: (18, 2, 2048, 16)@float32
['PaliGemma']['llm']['layers']['mlp']['gating_einsum_lora_b'].value: (18, 2, 16, 16384)@float32
['PaliGemma']['llm']['layers']['mlp']['linear'].value: (18, 16384, 2048)@bfloat16
['PaliGemma']['llm']['layers']['mlp']['linear_lora_a'].value: (18, 16384, 16)@float32
['PaliGemma']['llm']['layers']['mlp']['linear_lora_b'].value: (18, 16, 2048)@float32
['PaliGemma']['llm']['layers']['mlp_1']['gating_einsum'].value: (18, 2, 1024, 4096)@bfloat16
['PaliGemma']['llm']['layers']['mlp_1']['gating_einsum_lora_a'].value: (18, 2, 1024, 32)@float32
['PaliGemma']['llm']['layers']['mlp_1']['gating_einsum_lora_b'].value: (18, 2, 32, 4096)@float32
['PaliGemma']['llm']['layers']['mlp_1']['linear'].value: (18, 4096, 1024)@bfloat16
['PaliGemma']['llm']['layers']['mlp_1']['linear_lora_a'].value: (18, 4096, 32)@float32
['PaliGemma']['llm']['layers']['mlp_1']['linear_lora_b'].value: (18, 32, 1024)@float32
['PaliGemma']['llm']['layers']['pre_attention_norm']['scale'].value: (18, 2048)@bfloat16
['PaliGemma']['llm']['layers']['pre_attention_norm_1']['Dense_0']['bias'].value: (18, 3072)@bfloat16
['PaliGemma']['llm']['layers']['pre_attention_norm_1']['Dense_0']['kernel'].value: (18, 1024, 3072)@bfloat16
['PaliGemma']['llm']['layers']['pre_ffw_norm']['scale'].value: (18, 2048)@bfloat16
['PaliGemma']['llm']['layers']['pre_ffw_norm_1']['Dense_0']['bias'].value: (18, 3072)@bfloat16
['PaliGemma']['llm']['layers']['pre_ffw_norm_1']['Dense_0']['kernel'].value: (18, 1024, 3072)@bfloat16
['action_in_proj']['bias'].value: (1024,)@float32
['action_in_proj']['kernel'].value: (32, 1024)@float32
['action_out_proj']['bias'].value: (32,)@float32
['action_out_proj']['kernel'].value: (1024, 32)@float32
['time_mlp_in']['bias'].value: (1024,)@float32
['time_mlp_in']['kernel'].value: (1024, 1024)@float32
['time_mlp_out']['bias'].value: (1024,)@float32
['time_mlp_out']['kernel'].value: (1024, 1024)@float32 (2358:train.py:238)
09:53:09.784 [I] Progress on: -/200 rate:- remaining:? elapsed:00:00 postfix:-                    (2358:tqdm_logging.py:145)


Step 0: grad_norm=1.0302, loss=0.1031, param_norm=1803.7705
09:57:28.173 [I] Progress on: -/200 rate:- remaining:? elapsed:04:18 postfix:-                    (2358:tqdm_logging.py:145)
09:57:41.564 [I] Progress on: 4.00it/200it rate:39.4s/it remaining:2:08:47 elapsed:04:31 postfix:- (2358:tqdm_logging.py:145)
09:57:54.623 [I] Progress on: 6.00it/200it rate:20.4s/it remaining:1:06:00 elapsed:04:44 postfix:- (2358:tqdm_logging.py:145)
09:58:07.718 [I] Progress on: 8.00it/200it rate:12.9s/it remaining:41:18 elapsed:04:57 postfix:-  (2358:tqdm_logging.py:145)
09:58:20.846 [I] Progress on: 10.0it/200it rate:9.6s/it remaining:30:20 elapsed:05:11 postfix:-   (2358:tqdm_logging.py:145)


Step 10: grad_norm=0.6888, loss=0.0774, param_norm=1803.7705
09:58:33.690 [I] Progress on: 10.0it/200it rate:9.6s/it remaining:30:20 elapsed:05:23 postfix:-   (2358:tqdm_logging.py:145)
09:58:47.238 [I] Progress on: 14.0it/200it rate:7.0s/it remaining:21:45 elapsed:05:37 postfix:-   (2358:tqdm_logging.py:145)
09:59:00.423 [I] Progress on: 16.0it/200it rate:6.8s/it remaining:20:51 elapsed:05:50 postfix:-   (2358:tqdm_logging.py:145)
09:59:14.110 [I] Progress on: 18.0it/200it rate:6.8s/it remaining:20:45 elapsed:06:04 postfix:-   (2358:tqdm_logging.py:145)
09:59:26.803 [I] Progress on: 20.0it/200it rate:6.6s/it remaining:19:50 elapsed:06:17 postfix:-   (2358:tqdm_logging.py:145)


Step 20: grad_norm=0.2169, loss=0.0607, param_norm=1803.7705
09:59:39.655 [I] Progress on: 20.0it/200it rate:6.6s/it remaining:19:50 elapsed:06:29 postfix:-   (2358:tqdm_logging.py:145)
09:59:53.237 [I] Progress on: 24.0it/200it rate:6.3s/it remaining:18:34 elapsed:06:43 postfix:-   (2358:tqdm_logging.py:145)
10:00:06.453 [I] Progress on: 26.0it/200it rate:6.5s/it remaining:18:46 elapsed:06:56 postfix:-   (2358:tqdm_logging.py:145)
10:00:19.665 [I] Progress on: 28.0it/200it rate:6.5s/it remaining:18:45 elapsed:07:09 postfix:-   (2358:tqdm_logging.py:145)
10:00:32.870 [I] Progress on: 30.0it/200it rate:6.6s/it remaining:18:37 elapsed:07:23 postfix:-   (2358:tqdm_logging.py:145)


Step 30: grad_norm=0.1718, loss=0.0548, param_norm=1803.7705
10:00:45.743 [I] Progress on: 30.0it/200it rate:6.6s/it remaining:18:37 elapsed:07:35 postfix:-   (2358:tqdm_logging.py:145)
10:00:59.342 [I] Progress on: 34.0it/200it rate:6.3s/it remaining:17:30 elapsed:07:49 postfix:-   (2358:tqdm_logging.py:145)
10:01:13.046 [I] Progress on: 36.0it/200it rate:6.6s/it remaining:18:05 elapsed:08:03 postfix:-   (2358:tqdm_logging.py:145)
10:01:25.775 [I] Progress on: 38.0it/200it rate:6.5s/it remaining:17:34 elapsed:08:15 postfix:-   (2358:tqdm_logging.py:145)
10:01:38.990 [I] Progress on: 40.0it/200it rate:6.6s/it remaining:17:29 elapsed:08:29 postfix:-   (2358:tqdm_logging.py:145)


Step 40: grad_norm=0.1768, loss=0.0560, param_norm=1803.7705
10:01:51.848 [I] Progress on: 40.0it/200it rate:6.6s/it remaining:17:29 elapsed:08:42 postfix:-   (2358:tqdm_logging.py:145)
10:02:05.484 [I] Progress on: 44.0it/200it rate:6.3s/it remaining:16:26 elapsed:08:55 postfix:-   (2358:tqdm_logging.py:145)
10:02:18.695 [I] Progress on: 46.0it/200it rate:6.5s/it remaining:16:36 elapsed:09:08 postfix:-   (2358:tqdm_logging.py:145)
10:02:31.921 [I] Progress on: 48.0it/200it rate:6.5s/it remaining:16:34 elapsed:09:22 postfix:-   (2358:tqdm_logging.py:145)
10:02:45.141 [I] Progress on: 50.0it/200it rate:6.6s/it remaining:16:26 elapsed:09:35 postfix:-   (2358:tqdm_logging.py:145)


Step 50: grad_norm=0.1691, loss=0.0510, param_norm=1803.7705
10:02:58.010 [I] Progress on: 50.0it/200it rate:6.6s/it remaining:16:26 elapsed:09:48 postfix:-   (2358:tqdm_logging.py:145)
10:03:12.107 [I] Progress on: 54.0it/200it rate:6.5s/it remaining:15:45 elapsed:10:02 postfix:-   (2358:tqdm_logging.py:145)
10:03:24.834 [I] Progress on: 56.0it/200it rate:6.4s/it remaining:15:27 elapsed:10:15 postfix:-   (2358:tqdm_logging.py:145)
10:03:38.037 [I] Progress on: 58.0it/200it rate:6.5s/it remaining:15:26 elapsed:10:28 postfix:-   (2358:tqdm_logging.py:145)
10:03:51.258 [I] Progress on: 60.0it/200it rate:6.6s/it remaining:15:19 elapsed:10:41 postfix:-   (2358:tqdm_logging.py:145)


Step 60: grad_norm=0.1963, loss=0.0461, param_norm=1803.7708
10:04:04.134 [I] Progress on: 60.0it/200it rate:6.6s/it remaining:15:19 elapsed:10:54 postfix:-   (2358:tqdm_logging.py:145)
10:04:17.740 [I] Progress on: 64.0it/200it rate:6.3s/it remaining:14:20 elapsed:11:07 postfix:-   (2358:tqdm_logging.py:145)
10:04:30.955 [I] Progress on: 66.0it/200it rate:6.5s/it remaining:14:26 elapsed:11:21 postfix:-   (2358:tqdm_logging.py:145)
10:04:44.175 [I] Progress on: 68.0it/200it rate:6.5s/it remaining:14:23 elapsed:11:34 postfix:-   (2358:tqdm_logging.py:145)
10:04:57.387 [I] Progress on: 70.0it/200it rate:6.6s/it remaining:14:14 elapsed:11:47 postfix:-   (2358:tqdm_logging.py:145)


Step 70: grad_norm=0.1737, loss=0.0430, param_norm=1803.7708
10:05:10.255 [I] Progress on: 70.0it/200it rate:6.6s/it remaining:14:14 elapsed:12:00 postfix:-   (2358:tqdm_logging.py:145)
10:05:23.849 [I] Progress on: 74.0it/200it rate:6.3s/it remaining:13:13 elapsed:12:14 postfix:-   (2358:tqdm_logging.py:145)
10:05:37.065 [I] Progress on: 76.0it/200it rate:6.5s/it remaining:13:20 elapsed:12:27 postfix:-   (2358:tqdm_logging.py:145)
10:05:50.280 [I] Progress on: 78.0it/200it rate:6.5s/it remaining:13:17 elapsed:12:40 postfix:-   (2358:tqdm_logging.py:145)
10:06:03.490 [I] Progress on: 80.0it/200it rate:6.6s/it remaining:13:08 elapsed:12:53 postfix:-   (2358:tqdm_logging.py:145)


Step 80: grad_norm=0.1670, loss=0.0404, param_norm=1803.7714
10:06:16.361 [I] Progress on: 80.0it/200it rate:6.6s/it remaining:13:08 elapsed:13:06 postfix:-   (2358:tqdm_logging.py:145)
10:06:29.948 [I] Progress on: 84.0it/200it rate:6.3s/it remaining:12:13 elapsed:13:20 postfix:-   (2358:tqdm_logging.py:145)
10:06:43.172 [I] Progress on: 86.0it/200it rate:6.5s/it remaining:12:17 elapsed:13:33 postfix:-   (2358:tqdm_logging.py:145)
10:06:56.390 [I] Progress on: 88.0it/200it rate:6.5s/it remaining:12:12 elapsed:13:46 postfix:-   (2358:tqdm_logging.py:145)
10:07:10.075 [I] Progress on: 90.0it/200it rate:6.7s/it remaining:12:18 elapsed:14:00 postfix:-   (2358:tqdm_logging.py:145)


Step 90: grad_norm=0.1604, loss=0.0373, param_norm=1803.7715
10:07:22.469 [I] Progress on: 90.0it/200it rate:6.7s/it remaining:12:18 elapsed:14:12 postfix:-   (2358:tqdm_logging.py:145)
10:07:36.078 [I] Progress on: 94.0it/200it rate:6.3s/it remaining:11:09 elapsed:14:26 postfix:-   (2358:tqdm_logging.py:145)
10:07:49.286 [I] Progress on: 96.0it/200it rate:6.5s/it remaining:11:12 elapsed:14:39 postfix:-   (2358:tqdm_logging.py:145)
10:08:02.508 [I] Progress on: 98.0it/200it rate:6.5s/it remaining:11:07 elapsed:14:52 postfix:-   (2358:tqdm_logging.py:145)
10:08:15.724 [I] Progress on: 100it/200it rate:6.6s/it remaining:10:57 elapsed:15:05 postfix:-    (2358:tqdm_logging.py:145)


Step 100: grad_norm=0.1610, loss=0.0324, param_norm=1803.7715
10:08:28.573 [I] Progress on: 100it/200it rate:6.6s/it remaining:10:57 elapsed:15:18 postfix:-    (2358:tqdm_logging.py:145)
10:08:42.173 [I] Progress on: 104it/200it rate:6.3s/it remaining:10:07 elapsed:15:32 postfix:-    (2358:tqdm_logging.py:145)
10:08:55.390 [I] Progress on: 106it/200it rate:6.5s/it remaining:10:08 elapsed:15:45 postfix:-    (2358:tqdm_logging.py:145)
10:09:09.056 [I] Progress on: 108it/200it rate:6.7s/it remaining:10:14 elapsed:15:59 postfix:-    (2358:tqdm_logging.py:145)
10:09:21.801 [I] Progress on: 110it/200it rate:6.5s/it remaining:09:49 elapsed:16:12 postfix:-    (2358:tqdm_logging.py:145)


Step 110: grad_norm=0.1675, loss=0.0329, param_norm=1803.7717
10:09:34.660 [I] Progress on: 110it/200it rate:6.5s/it remaining:09:49 elapsed:16:24 postfix:-    (2358:tqdm_logging.py:145)
10:09:48.280 [I] Progress on: 114it/200it rate:6.3s/it remaining:09:03 elapsed:16:38 postfix:-    (2358:tqdm_logging.py:145)
10:10:01.497 [I] Progress on: 116it/200it rate:6.5s/it remaining:09:03 elapsed:16:51 postfix:-    (2358:tqdm_logging.py:145)
10:10:14.712 [I] Progress on: 118it/200it rate:6.5s/it remaining:08:56 elapsed:17:04 postfix:-    (2358:tqdm_logging.py:145)
10:10:27.919 [I] Progress on: 120it/200it rate:6.6s/it remaining:08:45 elapsed:17:18 postfix:-    (2358:tqdm_logging.py:145)


Step 120: grad_norm=0.1372, loss=0.0312, param_norm=1803.7725
10:10:40.784 [I] Progress on: 120it/200it rate:6.6s/it remaining:08:45 elapsed:17:30 postfix:-    (2358:tqdm_logging.py:145)
10:10:54.396 [I] Progress on: 124it/200it rate:6.3s/it remaining:08:01 elapsed:17:44 postfix:-    (2358:tqdm_logging.py:145)
10:11:08.088 [I] Progress on: 126it/200it rate:6.6s/it remaining:08:09 elapsed:17:58 postfix:-    (2358:tqdm_logging.py:145)
10:11:20.846 [I] Progress on: 128it/200it rate:6.5s/it remaining:07:49 elapsed:18:11 postfix:-    (2358:tqdm_logging.py:145)
10:11:34.063 [I] Progress on: 130it/200it rate:6.6s/it remaining:07:39 elapsed:18:24 postfix:-    (2358:tqdm_logging.py:145)


Step 130: grad_norm=0.1769, loss=0.0306, param_norm=1803.7731
10:11:46.910 [I] Progress on: 130it/200it rate:6.6s/it remaining:07:39 elapsed:18:37 postfix:-    (2358:tqdm_logging.py:145)
10:12:00.505 [I] Progress on: 134it/200it rate:6.3s/it remaining:06:57 elapsed:18:50 postfix:-    (2358:tqdm_logging.py:145)
10:12:13.716 [I] Progress on: 136it/200it rate:6.5s/it remaining:06:54 elapsed:19:03 postfix:-    (2358:tqdm_logging.py:145)
10:12:26.932 [I] Progress on: 138it/200it rate:6.5s/it remaining:06:45 elapsed:19:17 postfix:-    (2358:tqdm_logging.py:145)
10:12:40.147 [I] Progress on: 140it/200it rate:6.6s/it remaining:06:34 elapsed:19:30 postfix:-    (2358:tqdm_logging.py:145)


Step 140: grad_norm=0.1318, loss=0.0252, param_norm=1803.7734
10:12:52.987 [I] Progress on: 140it/200it rate:6.6s/it remaining:06:34 elapsed:19:43 postfix:-    (2358:tqdm_logging.py:145)
10:13:07.047 [I] Progress on: 144it/200it rate:6.5s/it remaining:06:02 elapsed:19:57 postfix:-    (2358:tqdm_logging.py:145)
10:13:19.801 [I] Progress on: 146it/200it rate:6.4s/it remaining:05:48 elapsed:20:10 postfix:-    (2358:tqdm_logging.py:145)
10:13:33.017 [I] Progress on: 148it/200it rate:6.5s/it remaining:05:39 elapsed:20:23 postfix:-    (2358:tqdm_logging.py:145)
10:13:46.231 [I] Progress on: 150it/200it rate:6.6s/it remaining:05:28 elapsed:20:36 postfix:-    (2358:tqdm_logging.py:145)


Step 150: grad_norm=0.1396, loss=0.0242, param_norm=1803.7743
10:13:59.078 [I] Progress on: 150it/200it rate:6.6s/it remaining:05:28 elapsed:20:49 postfix:-    (2358:tqdm_logging.py:145)
10:14:12.678 [I] Progress on: 154it/200it rate:6.3s/it remaining:04:51 elapsed:21:02 postfix:-    (2358:tqdm_logging.py:145)
10:14:25.889 [I] Progress on: 156it/200it rate:6.5s/it remaining:04:44 elapsed:21:16 postfix:-    (2358:tqdm_logging.py:145)
10:14:39.101 [I] Progress on: 158it/200it rate:6.5s/it remaining:04:34 elapsed:21:29 postfix:-    (2358:tqdm_logging.py:145)
10:14:52.318 [I] Progress on: 160it/200it rate:6.6s/it remaining:04:23 elapsed:21:42 postfix:-    (2358:tqdm_logging.py:145)


Step 160: grad_norm=0.1380, loss=0.0233, param_norm=1803.7753
10:15:05.177 [I] Progress on: 160it/200it rate:6.6s/it remaining:04:23 elapsed:21:55 postfix:-    (2358:tqdm_logging.py:145)
10:15:18.762 [I] Progress on: 164it/200it rate:6.3s/it remaining:03:46 elapsed:22:08 postfix:-    (2358:tqdm_logging.py:145)
10:15:31.988 [I] Progress on: 166it/200it rate:6.5s/it remaining:03:39 elapsed:22:22 postfix:-    (2358:tqdm_logging.py:145)
10:15:45.207 [I] Progress on: 168it/200it rate:6.5s/it remaining:03:29 elapsed:22:35 postfix:-    (2358:tqdm_logging.py:145)
10:15:58.416 [I] Progress on: 170it/200it rate:6.6s/it remaining:03:17 elapsed:22:48 postfix:-    (2358:tqdm_logging.py:145)


Step 170: grad_norm=0.1225, loss=0.0185, param_norm=1803.7764
10:16:11.269 [I] Progress on: 170it/200it rate:6.6s/it remaining:03:17 elapsed:23:01 postfix:-    (2358:tqdm_logging.py:145)
10:16:24.866 [I] Progress on: 174it/200it rate:6.3s/it remaining:02:44 elapsed:23:15 postfix:-    (2358:tqdm_logging.py:145)
10:16:38.093 [I] Progress on: 176it/200it rate:6.5s/it remaining:02:35 elapsed:23:28 postfix:-    (2358:tqdm_logging.py:145)
10:16:51.302 [I] Progress on: 178it/200it rate:6.5s/it remaining:02:24 elapsed:23:41 postfix:-    (2358:tqdm_logging.py:145)
10:17:04.974 [I] Progress on: 180it/200it rate:6.7s/it remaining:02:14 elapsed:23:55 postfix:-    (2358:tqdm_logging.py:145)


Step 180: grad_norm=0.1272, loss=0.0201, param_norm=1803.7773
10:17:17.356 [I] Progress on: 180it/200it rate:6.7s/it remaining:02:14 elapsed:24:07 postfix:-    (2358:tqdm_logging.py:145)
10:17:30.955 [I] Progress on: 184it/200it rate:6.3s/it remaining:01:41 elapsed:24:21 postfix:-    (2358:tqdm_logging.py:145)
10:17:44.159 [I] Progress on: 186it/200it rate:6.5s/it remaining:01:30 elapsed:24:34 postfix:-    (2358:tqdm_logging.py:145)
10:17:57.383 [I] Progress on: 188it/200it rate:6.5s/it remaining:01:18 elapsed:24:47 postfix:-    (2358:tqdm_logging.py:145)
10:18:10.598 [I] Progress on: 190it/200it rate:6.6s/it remaining:01:05 elapsed:25:00 postfix:-    (2358:tqdm_logging.py:145)


Step 190: grad_norm=0.1266, loss=0.0162, param_norm=1803.7787
10:18:23.446 [I] Progress on: 190it/200it rate:6.6s/it remaining:01:05 elapsed:25:13 postfix:-    (2358:tqdm_logging.py:145)
10:18:37.059 [I] Progress on: 194it/200it rate:6.3s/it remaining:00:37 elapsed:25:27 postfix:-    (2358:tqdm_logging.py:145)
10:18:50.259 [I] Progress on: 196it/200it rate:6.5s/it remaining:00:25 elapsed:25:40 postfix:-    (2358:tqdm_logging.py:145)
10:19:03.923 [I] Progress on: 198it/200it rate:6.7s/it remaining:00:13 elapsed:25:54 postfix:-    (2358:tqdm_logging.py:145)
10:19:16.681 [I] [process=0][thread=MainThread][wait_until_finished] No Save Finalize thread to wait for. Returning. (2358:checkpoint_manager.py:1987)
10:19:16.682 [I] [process=0] Saving checkpoint at step 199                                        (2358:checkpoint_manager.py:1408)
10:19:16.682 [I] [process=0] Started async saving checkpoint to /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199. (2358:async_checkpointer.py:439)
10:19:16.684 [I] Using ThreadSafeKeyValueSignalingClient                                          (2358:signaling_client.py:332)
10:19:16.686 [I] Creating tmp directory /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199.orbax-checkpoint-tmp-0 (2358:atomicity.py:144)
10:19:16.697 [I] Wrote Metadata={'item_handlers': None, 'metrics': {}, 'performance_metrics': {}, 'init_timestamp_nsecs': 1792059556696084280, 'commit_timestamp_nsecs': None, 'custom_metadata': {}}, json={"item_handlers": null, "metrics": {}, "performance_metrics": {}, "init_timestamp_nsecs": 1792059556696084280, "commit_timestamp_nsecs": null, "custom_metadata": {}} to /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199.orbax-checkpoint-tmp-0/_CHECKPOINT_METADATA (2358:checkpoint.py:186)
10:19:16.700 [I] Creating tmp directory /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199.orbax-checkpoint-tmp-0/assets.orbax-checkpoint-tmp-1 (2358:atomicity.py:144)
10:19:16.701 [I] Creating tmp directory /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199.orbax-checkpoint-tmp-0/train_state.orbax-checkpoint-tmp-3 (2358:atomicity.py:144)
10:19:16.701 [I] Creating tmp directory /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199.orbax-checkpoint-tmp-0/params.orbax-checkpoint-tmp-2 (2358:atomicity.py:144)
10:19:16.716 [I] Transferring arrays to host memory with options: use_replica_parallel=True, enable_pinned_host_transfer=False (2358:replica_slices.py:341)
10:19:31.274 [I] Transferring arrays to host memory with options: use_replica_parallel=True, enable_pinned_host_transfer=False (2358:replica_slices.py:341)
10:19:31.283 [I] Array name: 'params.PaliGemma.llm.layers.mlp.gating_einsum.value', global shape: (18, 2, 2048, 16384), write shape: (18, 2, 2048, 16384), chosen chunk shape: (18, 2, 2048, 8192) (2358:tensorstore_utils.py:408)
10:19:31.341 [I] [process=0][thread=array_type_handler] Wrote 71 array_metadata.ArrayMetadata to /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199.orbax-checkpoint-tmp-0/params.orbax-checkpoint-tmp-2/array_metadatas/process_0 (2358:array_metadata_store.py:198)
10:19:36.040 [I] [process=0] /jax/checkpoint/write/blocking_bytes_per_sec: 381.4 MiB/s (total bytes: 7.2 GiB) (time elapsed: 19 seconds) (per-host) (2358:base_pytree_checkpoint_handler.py:114)
10:19:36.096 [I] [process=0] /jax/checkpoint/write/blocking_bytes_per_sec: 183.6 MiB/s (total bytes: 3.5 GiB) (time elapsed: 19 seconds) (per-host) (2358:base_pytree_checkpoint_handler.py:114)
10:19:36.103 [I] [process=0][thread=async_save] Background save thread started.                   (2358:async_checkpointer.py:77)
10:19:36.104 [I] Finished blocking save in 19.42 seconds. Continuing to save asynchronously to /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199. (2358:async_checkpointer.py:548)
10:19:36.104 [I] [process=0][thread=MainThread][step=199] Starting CheckpointManager Save Finalize thread=save_finalize (2358:checkpoint_manager.py:1453)
10:19:36.105 [I] [process=0][thread=save_finalize] Waiting for background save thread=async_save. (2358:async_checkpointer.py:258)
10:19:36.106 [I] {'step': 199, 'event_type': 'save', 'directory': '/workspace/openpi/checkpoints/pi05_mecharm_lora/smoke', 'reached_preemption': False, 'preemption_received_at': None, 'synchronous': False, 'wait_for_prev_start_time': 1792059556.6818378, 'wait_for_prev_duration_secs': 0.00016021728515625, 'checkpointer_blocking_start_time': 1792059556.6824417, 'checkpointer_blocking_duration_secs': 19.42193293571472, 'get_old_steps_start_time': 1792059576.104413, 'get_old_steps_duration_secs': 1.5974044799804688e-05, 'checkpoint_manager_blocking_start_time': 1792059556.6815596, 'checkpoint_manager_blocking_duration_secs': 19.42430019378662} (2358:standard_logger.py:34)
10:19:36.106 [I] Progress on: 200it/200it rate:12.4s/it remaining:00:00 elapsed:26:26 postfix:-   (2358:tqdm_logging.py:145)
10:19:36.106 [I] Progress on: 200it/200it rate:12.4s/it remaining:00:00 elapsed:26:26 postfix:-   (2358:tqdm_logging.py:145)
10:19:36.107 [I] Waiting for checkpoint manager to finish                                         (2358:train.py:275)
10:19:36.107 [I] [process=0][thread=MainThread][step=199][wait_until_finished] Waiting for Save Finalize thread (save_finalize) to complete. (2358:checkpoint_manager.py:1998)
10:19:36.136 [I] [process=0][thread=array_type_handler] Wrote 105 array_metadata.ArrayMetadata to /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199.orbax-checkpoint-tmp-0/train_state.orbax-checkpoint-tmp-3/array_metadatas/process_0 (2358:array_metadata_store.py:198)
10:19:36.174 [I] [process=0] /jax/checkpoint/write/bytes_per_sec: 378.8 MiB/s (total bytes: 7.2 GiB) (time elapsed: 19 seconds) (per-host) (2358:base_pytree_checkpoint_handler.py:114)
10:19:39.648 [I] [process=0] /jax/checkpoint/write/bytes_per_sec: 155.2 MiB/s (total bytes: 3.5 GiB) (time elapsed: 22 seconds) (per-host) (2358:base_pytree_checkpoint_handler.py:114)
10:19:39.649 [I] [process=0][thread=async_save] 5 Handler Commit operations completed.            (2358:async_checkpointer.py:87)
10:19:39.649 [I] Read Metadata={'item_handlers': None, 'metrics': {}, 'performance_metrics': {}, 'init_timestamp_nsecs': 1792059556696084280, 'commit_timestamp_nsecs': None, 'custom_metadata': {}} from /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199.orbax-checkpoint-tmp-0/_CHECKPOINT_METADATA (2358:checkpoint.py:226)
10:19:39.649 [I] Renaming /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199.orbax-checkpoint-tmp-0/assets.orbax-checkpoint-tmp-1 to /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199.orbax-checkpoint-tmp-0/assets (2358:atomicity.py:289)
10:19:39.652 [I] Updated Metadata={'item_handlers': {'assets': 'openpi.training.checkpoints.CallbackHandler', 'params': 'orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeCheckpointHandler', 'train_state': 'orbax.checkpoint._src.handlers.pytree_checkpoint_handler.PyTreeCheckpointHandler'}, 'metrics': {}, 'performance_metrics': {}, 'init_timestamp_nsecs': 1792059556696084280, 'commit_timestamp_nsecs': None, 'custom_metadata': {}} to /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199.orbax-checkpoint-tmp-0/_CHECKPOINT_METADATA (2358:checkpoint.py:245)
10:19:39.654 [I] [process=0][thread=async_save] Skipped cross-host ArrayMetadata validation because only one process is found: process_index=0. (2358:array_metadata_store.py:362)
10:19:39.661 [I] Renaming /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199.orbax-checkpoint-tmp-0/train_state.orbax-checkpoint-tmp-3 to /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199.orbax-checkpoint-tmp-0/train_state (2358:atomicity.py:289)
10:19:39.664 [I] [process=0][thread=async_save] Skipped cross-host ArrayMetadata validation because only one process is found: process_index=0. (2358:array_metadata_store.py:362)
10:19:39.668 [I] Renaming /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199.orbax-checkpoint-tmp-0/params.orbax-checkpoint-tmp-2 to /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199.orbax-checkpoint-tmp-0/params (2358:atomicity.py:289)
10:19:39.668 [I] Renaming /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199.orbax-checkpoint-tmp-0 to /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199 (2358:atomicity.py:289)
10:19:39.669 [I] [process=0][thread=async_save] Finished saving checkpoint (finalized tmp dir) to `/workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199`. (2358:atomicity.py:573)
10:19:39.669 [I] Finished asynchronous save (blocking + background) in 22.99 seconds to /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199 (2358:async_checkpointer.py:412)
10:19:39.670 [I] [process=0][thread=async_save] Background save thread done.                      (2358:async_checkpointer.py:143)
10:19:39.670 [I] [process=0][thread=save_finalize] Done with waiting for background save thread=async_save. (2358:async_checkpointer.py:266)
10:19:39.670 [I] [process=0][thread=save_finalize] No errors found in background save thread=async_save. (2358:async_checkpointer.py:276)
10:19:39.670 [I] [process=0][thread=save_finalize][step=199] CheckpointManager Save Finalize is syncing with other hosts... (2358:checkpoint_manager.py:2095)
10:19:39.670 [I] [process=0][thread=save_finalize][step=199] CheckpointManager Save Finalize is done on all hosts. (2358:checkpoint_manager.py:2110)
10:19:39.670 [I] [process=0][thread=MainThread][step=199][wait_until_finished] Done waiting for Save Finalize thread (save_finalize) running at step=199. (2358:checkpoint_manager.py:2010)

学習したモデルを serve_policy.py で起動し、Mac から SSH トンネルでつないだ

学習結果を、openpi のサーバー(serve_policy.py)で起動しました。

# Pod で実行
cd /workspace/openpi
uv run scripts/serve_policy.py policy:checkpoint --policy.config=pi05_mecharm_lora --policy.dir=checkpoints/pi05_mecharm_lora/smoke/199 2>&1 | tee /workspace/logs/serve_smoke.log

学習結果(6.3GiB)の読み込みは4.83秒で、学習のときと同じ正規化の統計も読み込まれました。Mac からは、Log#2 と同じく SSH トンネル(SSH の接続の中に、別の通信を通す仕組み)で、Pod のポート8000を Mac の localhost:8000 につなぎます。

# Mac で実行(このターミナルは開いたままにする)
ssh -N -L 8000:localhost:8000 -p <SSH_PORT> -i ~/.ssh/id_ed25519 root@<POD_IP>

サーバー側のログの全量です。Mac からの接続が3回あり、最後の1回が、後で載せるドライランの出力の回です。

INFO:root:Loading model...
INFO:2026-10-15 10:22:42,887:jax._src.xla_bridge:925: Unable to initialize backend 'rocm': module 'jaxlib.xla_extension' has no attribute 'GpuAllocatorConfig'
INFO:jax._src.xla_bridge:Unable to initialize backend 'rocm': module 'jaxlib.xla_extension' has no attribute 'GpuAllocatorConfig'
INFO:2026-10-15 10:22:42,888:jax._src.xla_bridge:925: Unable to initialize backend 'tpu': INTERNAL: Failed to open libtpu.so: libtpu.so: cannot open shared object file: No such file or directory
INFO:jax._src.xla_bridge:Unable to initialize backend 'tpu': INTERNAL: Failed to open libtpu.so: libtpu.so: cannot open shared object file: No such file or directory
INFO:absl:orbax-checkpoint version: 0.11.13
INFO:absl:Created BasePyTreeCheckpointHandler: use_ocdbt=True, use_zarr3=False, pytree_metadata_options=PyTreeMetadataOptions(support_rich_types=False), array_metadata_store=<orbax.checkpoint._src.metadata.array_metadata_store.Store object at 0x72a1e1c08710>
INFO:absl:Restoring checkpoint from /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199/params.
INFO:absl:[thread=MainThread] Failed to get flag value for EXPERIMENTAL_ORBAX_USE_DISTRIBUTED_PROCESS_ID.
INFO:absl:[process=0] /jax/checkpoint/read/bytes_per_sec: 1.3 GiB/s (total bytes: 6.3 GiB) (time elapsed: 4 seconds) (per-host)
INFO:absl:Finished restoring checkpoint in 4.83 seconds from /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199/params.
INFO:root:Loaded norm stats from /workspace/openpi/assets/pi05_mecharm_lora/local/mecharm_redblock_box_v1
INFO:root:Loaded norm stats from /workspace/openpi/checkpoints/pi05_mecharm_lora/smoke/199/assets/local/mecharm_redblock_box_v1
INFO:root:Creating server (host: 491506d76144, ip: 172.23.0.2)
INFO:websockets.server:server listening on 0.0.0.0:8000
INFO:websockets.server:connection open
INFO:openpi.serving.websocket_policy_server:Connection from ('127.0.0.1', 35494) opened
INFO:openpi.serving.websocket_policy_server:Connection from ('127.0.0.1', 35494) closed
INFO:websockets.server:connection open
INFO:openpi.serving.websocket_policy_server:Connection from ('127.0.0.1', 40670) opened
INFO:openpi.serving.websocket_policy_server:Connection from ('127.0.0.1', 40670) closed
INFO:websockets.server:connection open
INFO:openpi.serving.websocket_policy_server:Connection from ('127.0.0.1', 59594) opened
INFO:openpi.serving.websocket_policy_server:Connection from ('127.0.0.1', 59594) closed

Mac 側の run_policy_client.py は、既定ではアームに送らず、学習データの範囲±10°の外には出さない

Mac 側のクライアント(pi05/run_policy_client.py)は、上カメラの画像、今の関節角、グリッパーの状態を π0.5 に送り、返ってきた1秒分(10ステップ)の予測のうち、0.5秒先(5ステップ目)の姿勢へ動かす作りにしました。

フィジカルAI研究室を作る Log#4 | Physical Intelligenceπ0.5を mechArm 270 のデモで追加学習し、Mac で予測を受け取るまで|Mac 側の run_policy_client.py は、既定ではアームに送らず、学習データの範囲±10°の外には出…

上の画像は、π0.5 に送った上カメラの画像(384×216)です。学習データと同じ大きさに縮めてから送っています。

動かし方は既存のスクリプトと同じで、目標の角度を1回送って、届いたかを確かめる方式です。速さも学習データと同じ20にそろえています。グリッパーの開閉が予測の途中で切り替わるときは、切り替わる直前の姿勢まで動いてから開閉し、開閉の後は既存のスクリプトと同じく2秒待ちます。

安全のために、次の制限を入れました。

制限

中身

既定はドライラン

何も付けずに実行すると、予測を表示して記録するだけで、アームには送らない

1回ずつ確認

--execute を付けても、1回動かすごとに Enter を押すまで止まる(--auto で連続)

動ける範囲

記録済みのエピソードで実際に動いた各関節の範囲 ±10° の外には出さない。さらに既存の関節の可動範囲と、j4 の安全範囲(-90°〜+60°)でも絞る

1回の移動量

各関節20°まで(--max-move-deg)

このとき Mac に記録済みだったエピソードから作られた動ける範囲は、j0 が -52°〜+95°、j1 が -9°〜+99°、j2 が -52°〜+29°、j3 が -22°〜+11°、j4 が -90°〜+10°、j5 が -41°〜-19° でした。

openpi の公式クライアント(openpi-client)は使わず、通信の部分だけを pi05/policy_ws.py に写しました。openpi-client は numpy を版2より前に限定していて、Mac の既存の環境(既存のスクリプトが使っている numpy)を入れ替えてしまうおそれがあったためです。

pi05/policy_ws.py の全文です。

python

"""policy_ws.py — openpi のポリシーサーバー (scripts/serve_policy.py) と話す最小クライアント.

openpi-client パッケージ (packages/openpi-client) の websocket_client_policy / msgpack_numpy と
同じ通信形式を、依存を増やさずに写したもの. openpi-client は numpy<2 を要求するため,
既存の Mac 環境を壊さないよう直接は入れない. 必要なのは msgpack と websockets(>=11) だけ.
  pip install msgpack "websockets>=11"
"""
import functools
import time

import msgpack
import numpy as np
import websockets.sync.client


def _pack_array(obj):
    if isinstance(obj, (np.ndarray, np.generic)) and obj.dtype.kind in ("V", "O", "c"):
        raise ValueError(f"Unsupported dtype: {obj.dtype}")
    if isinstance(obj, np.ndarray):
        return {b"__ndarray__": True, b"data": obj.tobytes(), b"dtype": obj.dtype.str, b"shape": obj.shape}
    if isinstance(obj, np.generic):
        return {b"__npgeneric__": True, b"data": obj.item(), b"dtype": obj.dtype.str}
    return obj


def _unpack_array(obj):
    if b"__ndarray__" in obj:
        return np.ndarray(buffer=obj[b"data"], dtype=np.dtype(obj[b"dtype"]), shape=obj[b"shape"])
    if b"__npgeneric__" in obj:
        return np.dtype(obj[b"dtype"]).type(obj[b"data"])
    return obj


packb = functools.partial(msgpack.packb, default=_pack_array)
unpackb = functools.partial(msgpack.unpackb, object_hook=_unpack_array)


class PolicyClient:
    def __init__(self, host="localhost", port=8000, wait_sec=60.0):
        self.uri = host if host.startswith("ws") else f"ws://{host}:{port}"
        t0 = time.time()
        while True:
            try:
                # ping_interval=None: openpi のサーバーは推論 (特に初回のコンパイル数分) の間 ping に応答できないため,
                # keepalive を切らないと約 40 秒で接続が落ちる (2026-10-15 実測).
                self.ws = websockets.sync.client.connect(
                    self.uri, compression=None, max_size=None, ping_interval=None, close_timeout=5)
                self.metadata = unpackb(self.ws.recv())
                return
            except (ConnectionRefusedError, OSError):
                if time.time() - t0 > wait_sec:
                    raise
                print(f"[POLICY] サーバー待ち {self.uri} ... (SSH トンネルは張れていますか)")
                time.sleep(5)

    def infer(self, obs: dict) -> dict:
        self.ws.send(packb(obs))
        resp = self.ws.recv()
        if isinstance(resp, str):
            raise RuntimeError(f"ポリシーサーバー側のエラー:\n{resp}")
        return unpackb(resp)

    def close(self):
        try:
            self.ws.close()
        except Exception:
            pass

pi05/run_policy_client.py の全文です。

python

#!/usr/bin/env python3
"""run_policy_client.py — π0.5 (Runpod の serve_policy) の出力で mechArm 270 を動かす Mac 側クライアント.

流れ (1 チャンクごと):
  上カメラ画像 + 実測 6 軸 + グリッパー状態 → π0.5 → 行動チャンク (10 x 7, 0.1 秒刻みの絶対角)
  → 先頭 N ステップ先 (既定 5 = 0.5 秒先) の姿勢へ単発 send_angles (学習データと同じ速度 20)
  → グリッパーの開閉がチャンク内で切り替わるなら, その直前の姿勢まで動いてから開閉
  → 撮り直して次のチャンクへ

安全策:
  - 既定はドライラン (アームに送らない). 送るには --execute
  - --execute でも既定は 1 チャンクごとに Enter で確認 (--auto で連続)
  - 学習データで通った関節範囲 ±ENVELOPE_MARGIN_DEG の外へは出さない (クランプ)
  - 1 回の移動量を --max-move-deg で制限 (クランプ), j4 は J4_SAFE 範囲内
  - グリッパー操作後は 2 秒待つ (DAY175 対策・本線と同じ)

前提:
  - Pi の daemon が待ち受け中 / 上カメラ (iPhone 連係) が見えている
  - Runpod で serve_policy が起動し, Mac から SSH トンネル (localhost:8000) が張れている
  - pip install msgpack "websockets>=11"

Usage:
  python3 pi05/run_policy_client.py                 # ドライラン (予測を表示・記録するだけ)
  python3 pi05/run_policy_client.py --execute       # 1 チャンクごとに Enter で実行
  python3 pi05/run_policy_client.py --execute --auto --max-chunks 40
記録: /Volumes/KIOXIA/r2r2r-data/runs/pi05_policy/{stamp}/ (log.jsonl + 各チャンクの入力画像)
--record を付けると recorder.py --top にも trial_start/trial_end を送り, 上カメラ動画を残す.
"""
import argparse
import json
import socket
import sys
import time
import types
from datetime import datetime
from pathlib import Path

import cv2
import numpy as np

# ---- パス (run_pick_place.py と同じ構成) ----------------------------------------
_HERE = Path(__file__).resolve().parent
_ROOT = _HERE.parent
_PLACE = _ROOT / "dynamic_tracking_limits" / "place" / "place_modules"
_DRAG = _ROOT / "dynamic_tracking_limits" / "drag_teaching"
for p in (_HERE, _ROOT, _PLACE, _DRAG):
    sys.path.insert(0, str(p))

# pymycobot スタブ (waypoint_replay 経由の import を Mac 上で通すため. アーム制御は daemon 経由のみ)
if "pymycobot" not in sys.modules:
    _pkg = types.ModuleType("pymycobot"); _mod = types.ModuleType("pymycobot.mycobot")

    class _MyCobotStub:
        def __init__(self, *a, **k):
            raise RuntimeError("MyCobot は直接使わない (daemon 経由のみ)")
    _mod.MyCobot = _MyCobotStub
    sys.modules["pymycobot"] = _pkg; sys.modules["pymycobot.mycobot"] = _mod

import camera_resolver  # noqa: E402
import waypoint_replay as wr  # noqa: E402
from j0_sweep_scan import _send_and_wait_arrival  # noqa: E402
from policy_ws import PolicyClient  # noqa: E402
from visual_servo_pick import (  # noqa: E402
    close_daemon_socket, daemon_get_angles, daemon_set_gripper, daemon_set_gripper_value, get_daemon_socket,
)

TASK = "put the red block in the white box"
IMG_LONG = 384                  # build_demo_episodes.py と同じ (学習画像の長辺)
HOME = [0.0, 0.0, 0.0, 0.0, 0.0, -30.0]
GRIP_VALUE = 20                 # run_pick_place.py の close 値
GRIPPER_SLEEP = 2.0
J4_SAFE = (-90.0, 60.0)         # run_pick_place.py の J4_SAFE_MIN/MAX
ENVELOPE_MARGIN_DEG = 10.0
DATA_ROOT = Path("/Volumes/KIOXIA/r2r2r-data")
DEFAULT_RAW = DATA_ROOT / "datasets" / "mecharm_redblock_box_scripted_v1" / "raw_episodes"
LOG_ROOT = DATA_ROOT / "runs" / "pi05_policy"
RECORDER = ("127.0.0.1", 5556)


def load_envelope(raw_dir: Path):
    """学習データで通った各関節の範囲 ± マージン. 物理可動域 (wr.JOINT_LIMITS) と J4_SAFE で更に絞る."""
    qs = [np.load(p)["state"][:, :6] for p in raw_dir.glob("*/episode.npz")]
    if not qs:
        sys.exit(f"[ERROR] 学習エピソードが見つからない (安全範囲を作れない): {raw_dir}")
    q = np.concatenate(qs)
    lo = q.min(axis=0) - ENVELOPE_MARGIN_DEG
    hi = q.max(axis=0) + ENVELOPE_MARGIN_DEG
    for k, (plo, phi) in enumerate(wr.JOINT_LIMITS):
        lo[k], hi[k] = max(lo[k], plo), min(hi[k], phi)
    lo[4], hi[4] = max(lo[4], J4_SAFE[0]), min(hi[4], J4_SAFE[1])
    return lo, hi, len(qs)


def read_angles():
    for _ in range(5):
        try:
            a = daemon_get_angles()
        except Exception:
            a = None
        if wr._is_valid_angles(a):
            return np.array([float(x) for x in a])
        time.sleep(0.1)
    return None


def grab_rgb(cap):
    for _ in range(3):            # バッファに溜まった古いフレームを捨てる
        cap.grab()
    ok, frame = cap.read()
    if not ok or frame is None:
        return None, None
    h, w = frame.shape[:2]
    s = IMG_LONG / max(h, w)
    small = cv2.resize(frame, (round(w * s), round(h * s)), interpolation=cv2.INTER_AREA)
    return cv2.cvtColor(small, cv2.COLOR_BGR2RGB), small   # モデルへは RGB, 保存は BGR


def plan_chunk(actions, q, grip, n_steps):
    """チャンクから今回動かす目標を決める. 戻り値: (目標6軸, グリッパー指令 None/0/1, 使ったステップ)."""
    want = (actions[:, 6] > 0.5).astype(int)
    for f in range(min(n_steps, len(actions))):
        if want[f] != grip:
            target = q.copy() if f == 0 else actions[f - 1, :6]
            return np.asarray(target, dtype=float), int(want[f]), f
    k = min(n_steps, len(actions)) - 1
    return np.asarray(actions[k, :6], dtype=float), None, k


def apply_limits(target, q, lo, hi, max_move):
    notes = []
    t = target.copy()
    clipped = np.clip(t, lo, hi)
    for k in np.where(np.abs(clipped - t) > 1e-6)[0]:
        notes.append(f"j{k} 安全範囲でクランプ {t[k]:+.1f}→{clipped[k]:+.1f}")
    t = clipped
    d = t - q
    big = np.abs(d) > max_move
    for k in np.where(big)[0]:
        notes.append(f"j{k} 移動量 {d[k]:+.1f}° を ±{max_move:.0f}° に制限")
    t = q + np.clip(d, -max_move, max_move)
    return t, notes


def notify_recorder(sock, payload):
    try:
        payload = dict(payload, ts=time.time())
        sock.sendto((json.dumps(payload) + "\n").encode(), RECORDER)
    except Exception:
        pass


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--host", default="localhost")
    ap.add_argument("--port", type=int, default=8000)
    ap.add_argument("--execute", action="store_true", help="アームに送る (未指定はドライラン)")
    ap.add_argument("--auto", action="store_true", help="Enter 確認なしで連続実行")
    ap.add_argument("--steps-per-chunk", type=int, default=5, help="1 チャンクで何ステップ先まで進むか (0.1 秒単位)")
    ap.add_argument("--max-chunks", type=int, default=60)
    ap.add_argument("--max-move-deg", type=float, default=20.0, help="1 回の移動で各関節が動ける上限")
    ap.add_argument("--raw-dir", default=str(DEFAULT_RAW), help="安全範囲の算出に使う学習エピソード")
    ap.add_argument("--prompt", default=TASK)
    ap.add_argument("--record", action="store_true", help="recorder.py --top に上カメラ動画を記録させる")
    args = ap.parse_args()

    if not DATA_ROOT.exists():
        sys.exit(f"[ERROR] SSD 未マウント: {DATA_ROOT}")
    lo, hi, n_eps = load_envelope(Path(args.raw_dir))
    print(f"[SAFE  ] 安全範囲 (学習 {n_eps} 本 ±{ENVELOPE_MARGIN_DEG:.0f}°): "
          + "  ".join(f"j{k}[{lo[k]:+.0f},{hi[k]:+.0f}]" for k in range(6)))

    stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    log_dir = LOG_ROOT / f"pi05_{stamp}"
    (log_dir / "frames").mkdir(parents=True, exist_ok=True)
    log_f = (log_dir / "log.jsonl").open("w")
    mode = "EXECUTE" if args.execute else "DRY-RUN (アームには送らない)"
    print(f"[MODE  ] {mode}  steps/chunk={args.steps_per_chunk}  max_move={args.max_move_deg}°  log={log_dir}")

    cams = camera_resolver.resolve_cameras(require_top=True, require_side=False, verbose=False)
    cap = cv2.VideoCapture(cams["top"])
    if not cap.isOpened():
        sys.exit("[ERROR] 上カメラを開けない")
    t_cam = time.time()
    while time.time() - t_cam < 8.0:           # 連係カメラは映像が来るまで数秒かかることがある
        ok, _f = cap.read()
        if ok and _f is not None:
            break
        time.sleep(0.2)
    else:
        sys.exit("[ERROR] 上カメラから映像が来ない. iPhone のロック解除・連係カメラの接続を確認 "
                 "(Photo Booth の『カメラ』メニューに iPhone が出るか)")

    get_daemon_socket()
    policy = PolicyClient(args.host, args.port)
    print(f"[POLICY] 接続 {policy.uri}  metadata={policy.metadata}")
    rec_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    run_stem = f"pi05_{stamp}"
    if args.record:
        notify_recorder(rec_sock, {"type": "trial_start", "run_stem": run_stem})

    grip = 0
    try:
        q = read_angles()
        if q is None:
            sys.exit("[ERROR] 関節角を読めない (daemon を確認)")
        if np.max(np.abs(q - np.array(HOME))) > 5.0:
            print(f"[WARN  ] HOME から離れています (max|Δ|={np.max(np.abs(q - np.array(HOME))):.1f}°). "
                  "学習データは HOME から始まっています")
            print("         現在角: " + " ".join(f"j{k}={v:+.1f}" for k, v in enumerate(q)))
        if args.execute:
            print("[GRIP  ] 開始時にグリッパーを開く")
            daemon_set_gripper(0)
            time.sleep(GRIPPER_SLEEP)

        for i in range(args.max_chunks):
            q = read_angles()
            if q is None:
                print("[ERROR] 関節角を読めない (daemon を確認). 停止"); break
            rgb = bgr_small = None
            for _ in range(10):
                rgb, bgr_small = grab_rgb(cap)
                if rgb is not None:
                    break
                time.sleep(0.2)
            if rgb is None:
                print("[ERROR] 上カメラの映像が途切れた (iPhone のスリープ等). 停止"); break
            state = np.concatenate([q, [float(grip)]]).astype(np.float32)
            t0 = time.time()
            out = policy.infer({"observation/image": rgb, "observation/state": state, "prompt": args.prompt})
            latency = time.time() - t0
            acts = np.asarray(out["actions"], dtype=float)
            target, grip_cmd, used = plan_chunk(acts, q, grip, args.steps_per_chunk)
            target, notes = apply_limits(target, q, lo, hi, args.max_move_deg)
            delta = target - q

            cv2.imwrite(str(log_dir / "frames" / f"{i:04d}.jpg"), bgr_small)
            rec = {"chunk": i, "t": time.time(), "latency_s": round(latency, 3), "state": state.tolist(),
                   "actions": acts.tolist(), "used_step": used, "target": target.tolist(),
                   "grip_cmd": grip_cmd, "notes": notes, "executed": False}
            gtxt = "" if grip_cmd is None else ("  グリッパー→閉" if grip_cmd == 1 else "  グリッパー→開")
            print(f"[{i:03d}] 推論 {latency*1000:.0f}ms  {used + 1}ステップ先  Δ="
                  + " ".join(f"{d:+5.1f}" for d in delta) + gtxt)
            for n in notes:
                print(f"       [LIMIT] {n}")

            if args.execute:
                if not args.auto:
                    ans = input("       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > ").strip().lower()
                    if ans == "q":
                        log_f.write(json.dumps(rec) + "\n"); break
                    if ans == "h":
                        _send_and_wait_arrival(HOME, "pi05_home", 2.5)
                        log_f.write(json.dumps(rec) + "\n"); break
                    if ans == "s":
                        log_f.write(json.dumps(rec) + "\n"); continue
                if np.max(np.abs(delta)) > 0.5:
                    ok, _ = _send_and_wait_arrival(target.tolist(), f"pi05_chunk{i:03d}", 3.0)
                    rec["arrived"] = bool(ok)
                    if not ok:
                        print("       [WARN] 未到達. j4 の発熱などを確認してください")
                        if args.auto:
                            log_f.write(json.dumps(rec) + "\n"); break
                if grip_cmd is not None:
                    if grip_cmd == 1:
                        daemon_set_gripper_value(GRIP_VALUE)
                    else:
                        daemon_set_gripper(0)
                    time.sleep(GRIPPER_SLEEP)
                    grip = grip_cmd
                rec["executed"] = True
            log_f.write(json.dumps(rec) + "\n"); log_f.flush()
    except KeyboardInterrupt:
        print("\n[STOP  ] Ctrl-C")
    finally:
        if args.record:
            notify_recorder(rec_sock, {"type": "trial_end", "run_stem": run_stem})
        log_f.close(); cap.release(); policy.close()
        try:
            close_daemon_socket()
        except Exception:
            pass
        print(f"[DONE  ] 記録: {log_dir}")


if __name__ == "__main__":
    main()

ドライランで3か所つまずいた:デーモンの停止、iPhone のスリープ、websockets の生存確認

1つ目は、デーモンにつながりませんでした(Connection refused)。朝に手で起動したデーモンが、止まっていました。止まった原因は、まだ調べていません。手で起動し直しています。

2つ目は、上カメラから映像が来ませんでした。カメラの一覧に iPhone は出ていて、開くこともできるのに、5秒間で1枚も取れません。

top index = 2
opened = True
5秒で取れたフレーム数 = 0  サイズ = None

iPhone がスリープして、連係カメラの映像が止まっていました。iPhone のロックを解除して、ケーブルを挿し直すと直りました。π0.5 で動かしている間に止まらないよう、iPhone の自動ロックは「なし」にしておく必要があります。

3つ目は、最初の推論の途中で接続が切れました。

websockets.exceptions.ConnectionClosedError: sent 1011 (internal error) keepalive ping timeout; no close frame received

Log#3 で調べたのと同じ仕組みです。websockets(Python の通信ライブラリ)は版15.0から、20秒ごとに相手へ「生きているか」の確認(ping)を送り、返事がなければ接続を切ります。openpi のサーバーは、推論している間、特に最初のコンパイルの数分間、この確認に返事ができません。Mac 側に入っていた websockets が、この確認を送る版だったため、ここで切れたと考えています。policy_ws.py で確認を送らない設定(ping_interval=None)にすると、切れなくなりました。上の全文は、直した後のものです。

ドライランの5回の予測:推論は0.55〜2.29秒で、j1(肩)が上がり j4(手首)が下がる向きだった

直した後のドライランの出力です。アームは動いていません。HOME の姿勢のまま、同じ姿勢と、ほぼ同じ画像を5回送っています。

[POLICY] 接続 ws://localhost:8000  metadata={}
[000] 推論 954ms  5ステップ先  Δ= +2.7  +4.3  +1.3  +0.1  +2.1  -0.0
[001] 推論 547ms  5ステップ先  Δ= -0.2  +3.7  +0.7  -0.2  +1.9  -0.0
[002] 推論 906ms  5ステップ先  Δ= +2.3  +3.0  +0.9  -0.2  +0.9  -0.0
[003] 推論 2288ms  5ステップ先  Δ= -2.2  +7.9  -2.7  -0.5  -8.8  -0.0
[004] 推論 1051ms  5ステップ先  Δ= -0.2 +12.9  -3.6  -0.7  -9.9  -0.0

Δ は、0.5秒先の姿勢と今の姿勢の差で、左から j0〜j5 です(単位は度)。推論の時間は1回0.55〜2.29秒で、Mac と Runpod の間の往復を含みます。1回ずつ確かめながら動かす使い方なら、問題のない速さです。ただ、0.5秒先の姿勢を出すのに最大2.3秒かかるので、止まらずに連続で動かすには足りません。

予測は、HOME から j1(肩)が上がり、j4(手首)が下がる向きでした。学習データでブロックを掴みに行くときの、最初の動きと同じ向きです。ただ、2本・200ステップのモデルなので、正しい動きを覚えたと言える段階ではありません。同じ入力に対しても、5回の予測は毎回違っています。π0.5 は動きの候補を少しずつ変えながら出す作りなので、毎回違うこと自体はおかしくありません。動ける範囲と1回の移動量の制限には、一度も引っかかりませんでした。

次の図で、5回それぞれの予測(10ステップ分)を関節ごとに見られます。クライアントがどのステップを使うかも切り替えられます。

この5回の記録(/Volumes/KIOXIA/r2r2r-data/runs/pi05_policy/pi05_20261015_193023/log.jsonl)の全量です。state が送った姿勢(j0〜j5 とグリッパー)、actions が返ってきた10ステップ分の予測、used_step が使ったステップ(0から数えて4 = 5ステップ目)、target がその姿勢です。

{"chunk": 0, "t": 1792060226.803159, "latency_s": 0.954, "state": [-0.3499999940395355, 1.309999942779541, 2.0999999046325684, 0.25999999046325684, -0.25999999046325684, -29.700000762939453, 0.0], "actions": [[2.2271324945090534, 5.326047969364991, 3.3773553735735344, 0.35843855881557385, 1.8184287004274147, -29.71310106188002, 0.0044003609264194965], [2.18537143883691, 5.291649760124935, 3.365695306262513, 0.3481971449823309, 1.9018323064393527, -29.712094702892887, 0.007354583776831627], [2.213049665324977, 5.3640644627388685, 3.456223935033817, 0.35519315780469274, 1.8835327118311795, -29.713191408891422, 0.006381344233900309], [2.2200607800974446, 5.444065000674856, 3.424190116680597, 0.36217894804429474, 1.8646543088176912, -29.71398191103608, 0.0049991194181442265], [2.3410347858172393, 5.62005423552427, 3.39855929798777, 0.38435011595815105, 1.869390692031267, -29.7134740340452, 0.007212157024383545], [2.3458340137608857, 5.7212788745077265, 3.368459762680226, 0.37485710278905504, 1.7476007512628655, -29.713811204895915, 0.0060339183063805105], [2.295826137631085, 5.799421250439343, 3.1472352879669234, 0.38714572332760344, 1.693001814060679, -29.715514435328867, 0.006722661901116371], [2.257864415856684, 5.941609706536505, 3.0814017031177006, 0.3769704872669637, 1.7058310725289516, -29.714764751243372, 0.007605231024026871], [2.2493804663762837, 6.173547540632169, 3.0195799258610503, 0.369433215589007, 1.6934344285904217, -29.716298461312185, 0.008946604986727238], [2.337711027348444, 6.249194495372423, 2.9628416535601225, 0.37510943917401995, 1.6647147909123774, -29.716804422818722, 0.006805555462896825]], "used_step": 4, "target": [2.3410347858172393, 5.62005423552427, 3.39855929798777, 0.38435011595815105, 1.8693906920312668, -29.7134740340452], "grip_cmd": null, "notes": [], "executed": false}
{"chunk": 1, "t": 1792060227.513921, "latency_s": 0.547, "state": [-0.3499999940395355, 1.309999942779541, 2.0999999046325684, 0.25999999046325684, -0.25999999046325684, -29.700000762939453, 0.0], "actions": [[0.32951802906748995, 5.189734246397892, 3.4842135334839686, 0.12112399020162279, 1.8622092908375159, -29.714628979889042, 0.0017883794313669206], [-0.1289880623931019, 5.107504833180483, 3.3650031465996815, 0.10717285488793848, 1.8452269328837012, -29.7155107411805, 0.0015998278645277023], [-0.40559775004057386, 5.046929977336374, 3.17928478969619, 0.09733388799702691, 1.7188199508409596, -29.71565663723777, 0.003343691486656666], [-0.5223192419524914, 4.982779729803722, 3.0280029443561727, 0.06331098044993322, 1.6909864270617696, -29.71674029970008, 0.0028750836339890957], [-0.5900986209534977, 4.994618044700497, 2.795395475308661, 0.04540908591494652, 1.620752204044937, -29.715721262030883, 0.0017529515216052533], [-0.6386889924806738, 5.173615878211569, 2.5555527421119137, 0.04773122629342197, 1.5720442833184727, -29.715598214965386, 0.005108352990210056], [-0.5568755274835979, 5.212729192686131, 2.2367504838738164, 0.06278640054515794, 1.64369867541226, -29.71615963430426, 0.012526462674200536], [-0.657410328826316, 5.319883712229297, 2.155165561682363, 0.04019772083016715, 1.8916464719252417, -29.71641580753143, 0.04482289085108042], [-0.5514953334342287, 5.5975142555305855, 1.9179921390886499, 0.04851567501256415, 1.935570272662428, -29.718274374623054, 0.16185617498973012], [-0.11534260451763956, 5.442986562050718, 0.6090596654036844, 0.04328816900763677, 1.7997457198394322, -29.721414537558843, 0.9198123480416537]], "used_step": 4, "target": [-0.5900986209534977, 4.994618044700497, 2.795395475308661, 0.04540908591494652, 1.620752204044937, -29.715721262030883], "grip_cmd": null, "notes": [], "executed": false}
{"chunk": 2, "t": 1792060228.5795572, "latency_s": 0.906, "state": [-0.3499999940395355, 1.309999942779541, 2.0999999046325684, 0.25999999046325684, -0.25999999046325684, -29.700000762939453, 0.0], "actions": [[2.176112867882214, 5.086989989353148, 3.4248739118762046, 0.10237792554294955, 1.1329808135327184, -29.714200777925377, -0.0007223837209939957], [2.242174378851786, 5.050408861023046, 3.27583165263556, 0.10917970929287613, 0.8021202047329226, -29.714706374577755, -0.003501850733935833], [2.2288938361946578, 4.722140722324772, 3.1372951642289504, 0.11520350069171936, 0.817545150379658, -29.716267311888263, -0.0012103890246748924], [2.0286568198557475, 4.354874159630361, 2.996461584010797, 0.11428777248871569, 0.6924509296685564, -29.713657008406496, -0.0024717894719839097], [1.9536858445482963, 4.32910192751036, 2.965983556561344, 0.1074327237023045, 0.6180412305525707, -29.71468179252872, 0.0011594371948242187], [1.8733491664095325, 4.171622314671522, 2.9122523102276396, 0.11763486129652279, 0.619148127039022, -29.714100169390726, 0.0007459228704571725], [1.7058254161416215, 4.020066454900212, 2.8002531396493193, 0.10971128359638271, 0.4794852390952258, -29.716118998672176, 0.0014048402770757676], [1.6346656311830507, 3.8501955209951197, 2.758234238546791, 0.08499523060602332, 0.3754429364660119, -29.715402835562642, 0.0004319284944534302], [1.6066554240631419, 3.8944697157604735, 2.8118818402082493, 0.08698379196812844, 0.46208816790255014, -29.715467460355754, 0.0001735341854095459], [1.608087419883823, 3.892176501811137, 2.7595547788098678, 0.0925185134865183, 0.560601955196482, -29.715862369377305, 0.002467558384358883]], "used_step": 4, "target": [1.9536858445482963, 4.32910192751036, 2.965983556561344, 0.1074327237023045, 0.6180412305525707, -29.71468179252872], "grip_cmd": null, "notes": [], "executed": false}
{"chunk": 3, "t": 1792060231.011368, "latency_s": 2.288, "state": [-0.3499999940395355, 1.309999942779541, 2.0999999046325684, 0.25999999046325684, -0.25999999046325684, -29.700000762939453, 0.0], "actions": [[0.26345915286483645, 5.539725876756515, 2.2783375693558767, 0.07429702873262212, -1.280632335330683, -29.711690216449632, -0.001859890771508217], [-0.5743348104748591, 5.820133705889566, 1.513800171630331, 0.038691772980455674, -2.8197435560269817, -29.70878045891595, -0.0007746465920805931], [-1.505810546399971, 7.193977196276604, 0.9389500705063245, 0.044732512345453834, -4.475477211522602, -29.707796857707052, 0.004039735197365284], [-1.9584503293145659, 7.6946178175635165, 0.4361243055361115, -0.029588623486533017, -6.282208255641313, -29.717260125664705, 0.0006229829581975938], [-2.5143176453634855, 9.234467326161473, -0.6111467221968674, -0.20316108434814772, -9.10312201716181, -29.72558482060895, -0.0009596821846961976], [-3.0687753611105375, 10.69347376664834, -1.577138130792088, -0.6036495950343741, -12.027042789988615, -29.72698841456291, -0.0018737758900523186], [-3.596793190821627, 12.44781460722506, -2.4502588597931307, -1.141335926969711, -14.80864979204626, -29.7279464759806, -0.00014564476275444031], [-4.263465629665001, 13.838023097358773, -3.2445288018267426, -1.726978008722992, -16.918058846013302, -29.725191416610805, 0.0020280616064071656], [-4.478957946467114, 15.340640431582225, -4.012936856760449, -2.4729801321362626, -18.89426588627214, -29.719818756676045, 0.0008500910559296608], [-4.696832092564346, 16.509229784455762, -4.251427682942028, -2.7961627818399952, -19.65373262743181, -29.721027336081423, -0.0009845919681787491]], "used_step": 4, "target": [-2.5143176453634855, 9.234467326161473, -0.6111467221968674, -0.20316108434814772, -9.10312201716181, -29.72558482060895], "grip_cmd": null, "notes": [], "executed": false}
{"chunk": 4, "t": 1792060232.2078578, "latency_s": 1.051, "state": [-0.3499999940395355, 1.309999942779541, 2.0999999046325684, 0.25999999046325684, -0.25999999046325684, -29.700000762939453, 0.0], "actions": [[0.9570299271881453, 4.994811060704249, 2.4120498512907282, 0.025422860558316884, -0.17465478181416572, -29.715275683887842, -0.018079497014522553], [0.37657496624758835, 5.276905482060561, 1.7586385828769409, 0.058882988022952, -1.5246358807517915, -29.713099374429532, -0.015676954356908798], [-0.1307915603494152, 7.660303861340973, 1.5248538150675746, -0.12798690088639475, -3.582592149389857, -29.708917461653073, -0.02238453928381205], [-0.5581296765372237, 9.879298561616212, 0.6582406415672324, -0.18669411761422516, -6.117528313684385, -29.710585164412187, -0.037119808476626874], [-0.5794449409149927, 14.207118716469688, -1.479846830269734, -0.4144199035995424, -10.129780442536871, -29.722090110643066, -0.028519914304018023], [-0.9719988642331678, 17.401901228093053, -2.5242990325480417, -0.5377058657739928, -13.958078906245209, -29.7199865895897, -0.029461241911411286], [-1.0081979269385073, 20.64545857748989, -4.302588946720989, -0.6504453507244237, -17.04820868962545, -29.722665440046796, -0.03016842947262526], [-1.5200909831030014, 23.0701930190341, -5.283310182098642, -0.9292380488860101, -19.594085526199784, -29.72945624249515, -0.03094194380182028], [-1.329399727312996, 24.85046847027366, -5.986175317419178, -1.062013527030191, -21.188744452515223, -29.730711340805943, -0.03550454608118534], [-1.6764051175124166, 25.760272382053493, -6.430432037324689, -1.2653759391303598, -22.03917809508615, -29.73950646958289, -0.027597418016195297]], "used_step": 4, "target": [-0.5794449409149927, 14.207118716469688, -1.479846830269734, -0.4144199035995424, -10.129780442536871, -29.722090110643066], "grip_cmd": null, "notes": [], "executed": false}

これで、デモの記録から、Mac で予測を受け取るまでが一度つながりました。学習と推論のログは、Pod を片付ける前に Mac へ移して、KIOXIA の runs/pi05_smoke_20261015/logs/ に置いています。Runpod の残高は、作業を始める前の 9.11 ドルから、学習を終えた後の19時21分の時点で 8.66 ドルでした。その後のドライランまで含めて、費用は約0.6ドルと見ています(残高の動きからの概算です)。

本番の学習の見積もり:5,000ステップなら約9.2時間・約5.7ドル、まず2,000ステップで試す

本番では、成功20本を学習させます。1ステップ約6.6秒のままなら、5,000ステップで約9.2時間、1時間0.62ドルで約5.7ドルになります。データが20本だと、5,000ステップは多すぎて、20本の動きをそのままなぞるだけになるかもしれません。なので、まず2,000ステップ(約3.7時間・約2.3ドル)で試すつもりです。どちらも、この回の実測からの計算で、まだ試していません。

次にやることです。

  • 成功を、あと10本集めて20本にする(3回ごとに休ませ、置き場所は (200, 240) 付近)

  • 本番の学習をして、実機のアームを π0.5 で動かす(--execute で1回ずつ確認しながら)

  • 同じ題材で、π0.5 版とスクリプト版を並べて撮影し、比べる

決めていないこともあります。掴み直しを含む 182552 を学習に入れるか。成功の数え方を、人の目視・VLM(画像を見て答える AI)・座標からの計算のどれにするか(この回も食い違いがありました)。確認スクリプト daemon_check.sh の誤判定と、j4 の発熱への対策にも、まだ手を付けていません。

RUNPOD_TRAIN.md の全文(Pod の作成から片付けまで)

Runpod での手順書の全文です。すべてのコマンドに「Mac で打つか、Pod で打つか」を書きました。Log#3 で、Pod 用のコマンドを Mac で実行してしまったことへの対策です。この回の通し運転でつまずいたところ(22番ポートの公開、iPhone の自動ロック、最初の推論のコンパイル待ち)も書き足してあります。

markdown

# π0.5 を mechArm 270 用に学習して動かす手順(Runpod A40)

_作成: 2026-10-15 / openpi 215abfb / Log#3 と同じ Runpod 構成_

各コマンドの頭に **どこで打つか** を書いてある。打つ前に、ターミナルの行頭で場所を確かめる。

| 表示 | 場所 | 行頭の見え方 |
|---|---|---|
| **[Mac]** | Mac のターミナル | `(base) shimazueiichi@...MacBook-Pro` |
| **[Pod]** | SSH で入った Runpod の中 | `root@xxxxxxxx:` |

`<POD_IP>` `<SSH_PORT>` のように `< >` で囲んだ部分は **自分の値に置き換える** 印。
値は Runpod の Pod 画面 →「Connect」→「SSH over exposed TCP」のコマンドに書いてある(IP とポート番号)。

---

## 0. 事前条件(Mac)

- 成功エピソードが 20 本以上ある

**[Mac]**
```
cd ~/r2r2r-scripts && python3 pi05/build_demo_episodes.py
```
最後の `採用エピソード合計` が 20 以上なら OK。

アップロード用にまとめる。

**[Mac]**
```
cd /Volumes/KIOXIA/r2r2r-data/datasets/mecharm_redblock_box_scripted_v1 && tar cf ~/mecharm_raw_episodes.tar raw_episodes
ls -lh ~/mecharm_raw_episodes.tar
```

---

## 1. Pod を作る(Runpod の画面)

| 項目 | 値 |
|---|---|
| GPU | **A40**(作成画面で L40 が選ばれていたら A40 に替える) |
| テンプレート | Runpod Pytorch(Ubuntu 24.04)— Log#3 と同じ |
| Container Disk | **200GB**(学習途中の保存が 1 回あたり数 GB 〜十数 GB になるため Log#3 の 80GB では足りない可能性) |
| Volume | 付けない(Pod を消すと中身も消える。必要な物は最後に Mac へ持ち帰る) |

作成後、「Connect」→「SSH over exposed TCP」のコマンドをコピーして **[Mac]** で実行し、Pod に入る。

- 「SSH over exposed TCP」が無い場合(2026-10-15 に発生): Pod の「⋮」→「Edit Pod」→「Expose TCP Ports」に `22` を追加して保存。
  **保存すると Pod が再起動し、中身が消え、ポート番号も変わる**(22034→22190 になった)。Connect 画面で番号を確認し直す
- 「SSH」欄(`...@ssh.runpod.io`)は中継経由で scp と SSH トンネルが使えない。必ず「SSH over exposed TCP」の方を使う
- 初回接続の `yes/no/[fingerprint]` には `yes` と打つ

---

## 2. Pod の準備

**[Pod]**(以降の作業は tmux の中で。ターミナルが固まっても作業は続く)
```
apt-get update && apt-get install -y tmux
mkdir -p /workspace/logs /workspace/r2r2r /workspace/data
tmux new -s lab
```

**[Pod]**(tmux の中で)
```
cd /workspace
git clone --recurse-submodules https://github.com/Physical-Intelligence/openpi.git
cd openpi && git checkout 215abfb
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env
GIT_LFS_SKIP_SMUDGE=1 uv sync
```

---

## 3. データとスクリプトを Pod へ送る

別の **[Mac]** ターミナルで(Pod に入っていない方):
```
scp -P <SSH_PORT> -i ~/.ssh/id_ed25519 ~/mecharm_raw_episodes.tar root@<POD_IP>:/workspace/data/
scp -P <SSH_PORT> -i ~/.ssh/id_ed25519 -r ~/r2r2r-scripts/pi05 root@<POD_IP>:/workspace/r2r2r/
```

**[Pod]**
```
cd /workspace/data && tar xf mecharm_raw_episodes.tar && ls raw_episodes | wc -l
```
エピソード本数が表示されれば OK。

---

## 4. mechArm 用の設定を openpi に登録 → LeRobot 形式へ変換 → 正規化

**[Pod]**
```
cd /workspace/openpi
python3 /workspace/r2r2r/pi05/openpi_addon/install_addon.py /workspace/openpi
uv run python /workspace/r2r2r/pi05/convert_to_lerobot.py --raw-dir /workspace/data/raw_episodes
uv run scripts/compute_norm_stats.py --config-name pi05_mecharm_lora
```
- 1 行目の最後に `installed:` か `already installed` が出れば OK
- 2 行目の最後に `done: N episodes / M frames` が出れば OK
- 3 行目は `Writing stats to: .../assets/pi05_mecharm_lora/local/mecharm_redblock_box_v1` で終わる
- 途中の `gsutil not found, falling back to gcsfs` は Log#2・#3 と同じ通知で、問題なし

---

## 5. 学習(LoRA・5,000 ステップ)

**[Pod]**
```
cd /workspace/openpi
XLA_PYTHON_CLIENT_MEM_FRACTION=0.9 uv run scripts/train.py pi05_mecharm_lora --exp-name=mecharm_v1 --overwrite 2>&1 | tee /workspace/logs/train.log
```
- 最初に π0.5 base の重み(約 12GB)をダウンロードする
- 50 ステップごとに loss が出る。下がっていけば学習は進んでいる
- 1,000 ステップごとに `checkpoints/pi05_mecharm_lora/mecharm_v1/<step>/` に保存。最後は `4999`
- GPU メモリ不足 (`RESOURCE_EXHAUSTED` / OOM) が出たら、末尾に `--batch-size=16` を付けてやり直す
- 実測(2026-10-15 通し運転・成功 2 本・200 ステップ): 重み 11.6GiB のダウンロード約 1 分 / 初回コンパイル 4 分 18 秒 / 以後 **1 ステップ約 6.6 秒**(batch 32・A40)/ 200 ステップで約 26 分
- 5,000 ステップならこの速さで約 9 時間・約 6 ドル。データ量に応じて `--num-train-steps=` で減らすことを検討

別の tmux 画面(`control+b` → 指を離して `c`)で GPU の様子を見る場合:
```
nvidia-smi --query-gpu=timestamp,memory.used,utilization.gpu --format=csv -l 30 | tee /workspace/logs/gpu_train.csv
```

---

## 6. 学習したモデルをサーバーとして起動

**[Pod]**(tmux の新しい画面で)
```
cd /workspace/openpi
uv run scripts/serve_policy.py policy:checkpoint --policy.config=pi05_mecharm_lora --policy.dir=checkpoints/pi05_mecharm_lora/mecharm_v1/4999 2>&1 | tee /workspace/logs/serve.log
```
`server listening on 0.0.0.0:8000` が出たら待ち受け完了。

---

## 7. Mac から動かす

**[Mac]** ターミナル A: SSH トンネル(開いたままにする。何も表示されないのが正常)
```
ssh -N -L 8000:localhost:8000 -p <SSH_PORT> -i ~/.ssh/id_ed25519 root@<POD_IP>
```

**[Mac]** ターミナル B: 初回だけ通信用ライブラリを入れる
```
pip install msgpack "websockets>=11"
```

**[Mac]** ターミナル B: デーモン起動(daemon_check.sh は誤判定するので手動で)
```
ssh -i ~/.ssh/id_rsa_pi ubuntu@192.168.2.1 "setsid nohup python3 /home/ubuntu/mecharm_daemon.py </dev/null >>/home/ubuntu/daemon.log 2>&1 &"
sleep 5
ssh -i ~/.ssh/id_rsa_pi ubuntu@192.168.2.1 'tail -3 ~/daemon.log; ss -ltn | grep 5555'
```

**[Mac]** ターミナル B: まずドライラン(アームは動かない。予測だけ表示)
```
cd ~/r2r2r-scripts && python3 pi05/run_policy_client.py --max-chunks 5
```
- 推論時間(ms)と、各関節をどれだけ動かそうとしているか(Δ)が出る
- Δ が数度〜十数度で、グリッパーの開閉が出ていれば正常
- **1 回目の推論はサーバー側のコンパイルで 1〜3 分止まる**。2 回目以降は約 0.5〜2 秒(男鹿→Runpod の往復込み・2026-10-15 実測)
- 上カメラから映像が来ないと止まる: iPhone のロック解除 / USB の抜き挿し。iPhone の自動ロックは「なし」にしておく
- `[LIMIT]` が大量に出る場合は、送る前に相談

**[Mac]** ターミナル B: 実機(1 チャンクごとに Enter で確認)
```
python3 pi05/run_policy_client.py --execute
```
- Enter=実行 / s=送らず次へ / h=HOME に戻して終了 / q=終了
- 電源はすぐ切れる位置に手を置く
- 慣れたら `--auto` で連続、`--record` で上カメラ動画も記録(recorder.py --top を起動しておく)

記録は `/Volumes/KIOXIA/r2r2r-data/runs/pi05_policy/pi05_<日時>/` に残る(各チャンクの入力画像・予測・実行内容)。

---

## 8. 片付け(Pod を消す前に)

学習済みモデルを Mac へ持ち帰る(次回は学習せずにサーバー起動から始められる)。

**[Mac]**
```
mkdir -p /Volumes/KIOXIA/r2r2r-data/checkpoints/pi05_mecharm_lora_v1
scp -P <SSH_PORT> -i ~/.ssh/id_ed25519 -r root@<POD_IP>:/workspace/openpi/checkpoints/pi05_mecharm_lora/mecharm_v1/4999 /Volumes/KIOXIA/r2r2r-data/checkpoints/pi05_mecharm_lora_v1/
scp -P <SSH_PORT> -i ~/.ssh/id_ed25519 -r root@<POD_IP>:/workspace/logs /Volumes/KIOXIA/r2r2r-data/checkpoints/pi05_mecharm_lora_v1/
```

最後に Runpod の画面で Pod を **Stop → Terminate**(Stop だけではディスク代がかかり続ける)。

---

## つまずいたとき

| 症状 | 原因と対処 |
|---|---|
| `command not found: apt-get` / `Read-only file system` | Pod 用のコマンドを Mac で打っている(Log#3 と同じ)。行頭を確認 |
| `ssh: Could not resolve hostname <pod_ip>` | `< >` の部分を自分の値に置き換えていない |
| 学習の最初で `Norm stats not found` | 手順 4 の compute_norm_stats を飛ばしている |
| クライアントが `サーバー待ち` のまま | 手順 7 の SSH トンネルが切れている / サーバーが起動していない |
| クライアントで `[ERROR] 学習エピソードが見つからない` | KIOXIA 未接続。安全範囲を学習データから作るため必須 |
| 実機で `未到達` が続く | j4 の発熱(2026-10-15 に確認済み)。HOME で数分休ませる |

再現手順(openpi 215abfb・LeRobot 0cf8648・Runpod A40)

この回のプログラムと手順書は、r2r2r-scripts リポジトリの pi05/ フォルダと、上に全文を載せた変更にあります。

ファイル

役割

どこで動かすか

dynamic_tracking_limits/place/place_modules/run_pick_place.py

既存のスクリプト。--record-demo で関節角とグリッパーの指令を記録係へ送る

Mac

dynamic_tracking_limits/place/place_modules/recorder.py

記録係。--top で上カメラを録画する

Mac

pi05/build_demo_episodes.py

記録 → 学習用エピソード(10Hz)

Mac

pi05/convert_to_lerobot.py

エピソード → LeRobot 形式

Pod

pi05/openpi_addon/install_addon.py、mecharm_policy.py

openpi に mechArm 用の学習設定を登録

Pod

pi05/policy_ws.py、pi05/run_policy_client.py

予測を受け取って、アームを動かす(既定はドライラン)

Mac

pi05/RUNPOD_TRAIN.md

Pod の作成から片付けまでの手順書

—

experiments/test_top_cam_dual_open.py

上カメラを2つのプログラムで同時に開けるかの確認

Mac

項目

版・値

openpi

コミット 215abfb

LeRobot

コミット 0cf8648(openpi が指定している版)

データセットの名前

local/mecharm_redblock_box_v1

学習設定

pi05_mecharm_lora

GPU

NVIDIA A40 × 1(Runpod、1時間0.62ドル、コンテナディスク200GB)

指示文

put the red block in the white box

再現の順番は次のとおりです。

  1. Mac で recorder.py --top --no-side と run_pick_place.sh --human-judge --record-demo を起動し、成功を集める(3回ごとに HOME で5分休ませる)

  2. Mac で python3 pi05/build_demo_episodes.py を実行し、raw_episodes/ を tar にまとめる

  3. Runpod で A40 の Pod を、TCP の22番ポートを公開して作る

  4. RUNPOD_TRAIN.md の順に、送信 → 設定の登録 → 変換 → 正規化の統計 → 学習 → サーバー起動

  5. Mac で SSH トンネルを張り、python3 pi05/run_policy_client.py(ドライラン)で予測が返ることを確かめる

  6. ログを Mac へ移してから、Pod を削除する(停止だけではディスクの料金がかかり続ける)