フィジカルAI研究室を作る Log#11 | Physical Intelligence の π0.5 は「待ち時間」まで覚えた

机の上の小さなロボットアームに、AI の指示で赤いブロックを白い箱へ入れさせようとしています。朝、学習し直した AI でアームを動かすと、腕はブロックの方へまっすぐ伸び、すぐ近くまで来たところで止まりました。そのあと13回続けて AI に「次の1秒でどう動くか」を聞いても、返ってくる答えは「ほとんど動かない」でした。アームが実際に動いたのは、最初の1分ほどです。

原因は AI の性能ではなく、AI に見せたお手本の中にありました。お手本は、人が手でアームを動かして教えた要所の姿勢を、アームに再生させて録ったものです。再生の途中で、アームは「指定の姿勢に着いたか」を確かめたり、グリッパーが落ち着くのを待ったりするために、何度も止まっていました。止まっている時間はお手本全体の 40%。ブロックの真上に着いたところでは、毎回 1.5〜2 秒、まったく動いていません。AI は、お手本の動きと一緒に、この待ち時間まで「その場面でやるべき動き」として覚えていました。

使った AI は、米国の Physical Intelligence 社が公開しているロボット用のモデル π0.5(パイレイテンゴ)です。上のカメラの画像と関節の角度を受け取り、次の1秒の動きを返します。アームが止まっている間は、画像も関節の角度も変わりません。お手本の中に「1秒先までずっと止まっている」場面が続くと、AI はその場面で「止まる」以外の答えを持てなくなります。お手本を見せて動きをまねさせるやり方(模倣学習)では、人の目には「ただ待っているだけ」の時間も、まねする対象に入ります。

調べていくと、原因はもう1つありました。上のカメラ(普段使いの iPhone を毎回付け外ししています)の写り方が、実機を動かした回だけ、お手本の画像より下に大きくずれていました。AI は、学習で見たことのない写り方の画像を見ながら動いていたことになります。

やったことの全体は次のとおりです。

やったことなぜ結果
学習したモデル(約6GB)を、推論用に借りた GPU へ送った
AI を動かすには GPU が要り、Mac では動かせない
40分以上かかり、その間に Mac のターミナルが2回固まった
実機のアームを π0.5 で動かした
置き場所を5か所に変えたお手本で学習したモデルを試すため
7回の指令でブロックの近くまで来て、そこから動かなくなった
お手本のデータを数えた止まった理由を調べるため
止まっている時間が 40%。ブロックの真上で毎回 1.5〜2 秒止まっていた
止まっている時間を縮めたお手本を作り、外部の AI(codex)に確かめてもらった
「止まる」を覚えさせないため
1回目は NG(削った継ぎ目で、学習の正解が崩れていた)。直した版で 2,717 ステップ
上のカメラの画像を、お手本と並べた
写り方の違いを確かめるため
実機を動かした回だけ、画像が下に約18〜37ピクセルずれていた
直したお手本で学習し直した止まらない AI を作るため
A40 で1ステップ 6.6 秒、約7時間15分の見込み
学習の前と実機の前に、問題を見つける道具を足した
8時間学習して実機で動かすまで、原因が分からない作りだったため
お手本の点検・お手本との答え合わせ・肩の角度の上限・カメラの写り方の確認

この回の作業も、Claude(この記事の手順とプログラムを一緒に組み立てた AI)と進めました。手順・分析・プログラムの一部は Claude、データを作り直すプログラムとアームを動かすプログラムの変更は Claude Code、その確認は codex(別の AI)が行い、アームの操作と目で見た判断はこちらで行っています。ここから下は、使ったコマンド・コード・出力を全量で載せた作業ログです。ログの中の Pod(借りた GPU マシン)の IP アドレスとポート番号は、<POD_IP> と <SSH_PORT> に置き換えています。

作業ログ ― 推論用の Pod に openpi を入れ直し、mechArm 用の設定を2つ登録した

前の回で学習したモデルは、Mac につないだ外付け SSD(KIOXIA)の checkpoints/pi05_mecharm_teach_lora_v1/3999/ に持ち帰ってあります。推論に使うのは、その中の params/(重み 6.0GB)と assets/(正規化の値 1.6MB)です。

学習に使った Pod は削除してあるので、推論用に新しく Pod を借りました。推論だけなら学習ほど GPU のメモリは要らないので、24GB 以上の GPU を選んでいます。この Pod も使い終わってすぐ削除したため、GPU の型番と料金は記録を残せていません。

Pod に入り、tmux(接続が切れても作業が続く画面)の中で、openpi(π0.5 を動かすための公開コード)を前回と同じ版で取り直しました。uv sync の最後の部分です。

 + tyro==0.9.22
 + tzdata==2025.2
 + urllib3==2.4.0
 + virtualenv==20.31.2
 + wadler-lindig==0.1.6
 + wandb==0.19.11
 + wcwidth==0.2.13
 + websockets==15.0.1
 + werkzeug==3.1.3
 + widgetsnbextension==4.0.14
 + wrapt==1.14.1
 + xxhash==3.5.0
 + yarl==1.20.0
 + zarr==3.0.8
 + zipp==3.22.0
root@7f6b9147d13a:/workspace/openpi#

Mac から、mechArm 用のスクリプトと、前の回(LOG355)で作った学習設定の追加スクリプト add_teach_config.py を送り、openpi に登録しました。推論サーバーを起動するときも、学習のときと同じ設定名(pi05_mecharm_teach_lora)が要るためです。

install_addon.py /workspace/openpi
python3 /workspace/r2r2r/add_teach_config.py /workspace/openpi
installed: policies/mecharm_policy.py + config.py (pi05_mecharm_lora)
installed: pi05_mecharm_teach_lora (repo_id=local/mecharm_redblock_box_teach_v1, 4,000 steps)
root@7f6b9147d13a:/workspace/openpi#

モデル 6GB を8本同時に送り始めたら、Mac のターミナルが固まった

LOG345 の経験から、1本の接続で送ると1秒に約1MB しか出ないことが分かっていました。そこで、大きいファイルから順に8本同時に送る形にしました。

Last login: Wed Oct 21 07:35:07 on ttys002
(base) shimazueiichi@shimadzueiichinoMacBook-Pro ~ % P=<SSH_PORT>; IP=<POD_IP>; K=~/.ssh/id_ed25519
scp -P $P -i $K -r ~/r2r2r-scripts/pi05 root@$IP:/workspace/r2r2r/
scp -P $P -i $K /Volumes/KIOXIA/r2r2r-data/lab/Log10/pod/add_teach_config.py root@$IP:/workspace/r2r2r/
cd /Volumes/KIOXIA/r2r2r-data/checkpoints/pi05_mecharm_teach_lora_v1/3999
find . -type d | ssh -p $P -i $K root@$IP "mkdir -p /workspace/ckpt/3999 && cd /workspace/ckpt/3999 && xargs mkdir -p"
find . -type f -exec stat -f '%z %N' {} + | sort -rn | cut -d' ' -f2- | xargs -P 8 -I{} scp -q -P $P -i $K {} root@$IP:/workspace/ckpt/3999/{}
convert_to_lerobot.py                         100% 2873    10.5KB/s   00:00    
run_policy_client.py                          100%   13KB  36.8KB/s   00:00    
build_demo_episodes.py.bak_pre_LOG352_2026101 100%   11KB  40.9KB/s   00:00    
RUNPOD_TRAIN.md                               100% 9229    33.6KB/s   00:00    
install_addon.py                              100% 4786    17.1KB/s   00:00    
mecharm_policy.py                             100% 2321     8.5KB/s   00:00    
build_demo_episodes.cpython-310.pyc           100%   11KB  39.9KB/s   00:00    
build_demo_episodes.cpython-312.pyc           100%   20KB  75.6KB/s   00:00    
policy_ws.cpython-312.pyc                     100% 4580    16.7KB/s   00:00    
build_demo_episodes.py                        100%   15KB  43.6KB/s   00:00    
policy_ws.py                                  100% 2778    10.2KB/s   00:00    
add_teach_config.py                           100% 1906     6.9KB/s   00:00

最後の行の送信は -q で進み具合の表示を消しているので、送っている間は何も出ません。この状態を見て、ターミナルが固まったように感じました。そこで別のウインドウを開こうとしたところ、今度は本当にターミナル全体が反応しなくなりました。メニューを選べず、⌘N も、貼り付けも、キーの入力も効きません。Dock のアイコンの右クリックから新しいウインドウは開けたものの、そこでも入力ができませんでした。

ターミナルを強制終了すると、Mac から送っていた scp も止まります。それでも困らないのは、Pod 側に届いたファイルは残っていて、大きさを比べれば「届いていないファイル」と「途中で切れたファイル」だけを送り直せるからです。強制終了して開き直し、大きさが合わないファイルだけを送り直しました。

Last login: Wed Oct 21 07:47:28 on ttys006
(base) shimazueiichi@shimadzueiichinoMacBook-Pro ~ % P=<SSH_PORT>; IP=<POD_IP>; K=~/.ssh/id_ed25519
cd /Volumes/KIOXIA/r2r2r-data/checkpoints/pi05_mecharm_teach_lora_v1/3999
ssh -p $P -i $K root@$IP "cd /workspace/ckpt/3999 && find . -type f -printf '%s %p\n'" > /tmp/remote_3999.txt
find . -type f | while read f; do s=$(stat -f %z "$f"); grep -qx "$s $f" /tmp/remote_3999.txt || echo "$f"; done > /tmp/todo_3999.txt
wc -l < /tmp/todo_3999.txt
cat /tmp/todo_3999.txt | xargs -P 8 -I{} scp -q -P $P -i $K {} root@$IP:/workspace/ckpt/3999/{}
echo "送信おわり"
      28

29ファイルのうち 28 が、大きさの合わないファイルでした。最初の送信で、合っていたのは1つだけです。

送っている間に、別のウインドウから Pod に届いた量を見ました。

Last login: Wed Oct 21 07:48:29 on ttys001
(base) shimazueiichi@shimadzueiichinoMacBook-Pro ~ % ssh -p <SSH_PORT> -i ~/.ssh/id_ed25519 root@<POD_IP> "du -sh /workspace/ckpt/3999"
2.9G /workspace/ckpt/3999
(base) shimazueiichi@shimadzueiichinoMacBook-Pro ~ %
(base) shimazueiichi@shimadzueiichinoMacBook-Pro ~ % ssh -p <SSH_PORT> -i ~/.ssh/id_ed25519 root@<POD_IP> "du -sh /workspace/ckpt/3999"
3.3G /workspace/ckpt/3999
(base) shimazueiichi@shimadzueiichinoMacBook-Pro ~ %

3分で 0.4GB、1分に約130MB です。前の回に6本同時で送ったときは1分に約400MB だったので、3分の1ほどの速さでした。上書き中のファイルは一度0から数え直されるため、この数字は実際の進み具合より小さめに出ます。

wc -l < /tmp/todo_3999.txt
cat /tmp/todo_3999.txt | xargs -P 8 -I{} scp -q -P $P -i $K {} root@$IP:/workspace/ckpt/3999/{}
echo "送信おわり"
      28
送信おわり
(base) shimazueiichi@shimadzueiichinoMacBook-Pro 3999 %

送り終わったのは 8:18 です。送り直しを始めた 7:50 から28分、最初に送り始めた 7:38 から数えると40分かかりました。最後に、29ファイルすべての大きさを Mac と Pod で比べました。前の回は「全体の大きさが 6.0G 前後になったら送り終わり」と判断して、途中で切れたファイルを見落としています。

(base) shimazueiichi@shimadzueiichinoMacBook-Pro 3999 % ssh -p $P -i $K root@$IP "cd /workspace/ckpt/3999 && find . -type f -printf '%s %p\n'" > /tmp/remote_3999.txt
find . -type f | while read f; do s=$(stat -f %z "$f"); grep -qx "$s $f" /tmp/remote_3999.txt || echo "不一致: $f"; done; echo "確認おわり"
確認おわり
(base) shimazueiichi@shimadzueiichinoMacBook-Pro 3999 %

推論サーバーは、teach_v1 の正規化の値を読み込んで起動した

Pod に入り直し、tmux の画面に戻ってサーバーを起動しました。

cd /workspace/openpi
mkdir -p assets/pi05_mecharm_teach_lora/local && cp -r /workspace/ckpt/3999/assets/local/mecharm_redblock_box_teach_v1 assets/pi05_mecharm_teach_lora/local/
uv run scripts/serve_policy.py policy:checkpoint --policy.config=pi05_mecharm_teach_lora --policy.dir=/workspace/ckpt/3999 2>&1 | tee /workspace/logs/serve.log

KIOXIA の lab/Log11/serve.log に持ち帰ったサーバーのログです。

INFO:root:Loading model...
INFO:2026-10-20 23:20:06,662: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-20 23:20:06,664: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 0x7c58b1a64910>
INFO:absl:Restoring checkpoint from /workspace/ckpt/3999/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.2 GiB/s (total bytes: 6.3 GiB) (time elapsed: 5 seconds) (per-host)
INFO:absl:Finished restoring checkpoint in 5.23 seconds from /workspace/ckpt/3999/params.
INFO:openpi.shared.download:Downloading gs://big_vision/paligemma_tokenizer.model to /root/.cache/openpi/big_vision/paligemma_tokenizer.model
INFO:tqdm_loggable.tqdm_logging:Progress on: -/4264023 rate:- remaining:? elapsed:00:00 postfix:-
INFO:tqdm_loggable.tqdm_logging:Progress on: 4.07MiB/4.07MiB rate:4,260,583.5iB/s remaining:00:00 elapsed:00:01 postfix:-
INFO:root:Loaded norm stats from /workspace/openpi/assets/pi05_mecharm_teach_lora/local/mecharm_redblock_box_teach_v1
INFO:root:Loaded norm stats from /workspace/ckpt/3999/assets/local/mecharm_redblock_box_teach_v1
INFO:root:Creating server (host: 7f6b9147d13a, ip: 172.16.48.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', 40588) opened
INFO:openpi.serving.websocket_policy_server:Connection from ('127.0.0.1', 40588) closed
INFO:websockets.server:connection open
INFO:openpi.serving.websocket_policy_server:Connection from ('127.0.0.1', 34182) opened
INFO:openpi.serving.websocket_policy_server:Connection from ('127.0.0.1', 34182) closed
INFO:websockets.server:connection open
INFO:openpi.serving.websocket_policy_server:Connection from ('127.0.0.1', 39180) opened
INFO:openpi.serving.websocket_policy_server:Connection from ('127.0.0.1', 39180) closed
INFO:websockets.server:connection open
INFO:openpi.serving.websocket_policy_server:Connection from ('127.0.0.1', 39460) opened
INFO:openpi.serving.websocket_policy_server:Connection from ('127.0.0.1', 39460) closed

Loaded norm stats from …/mecharm_redblock_box_teach_v1 の2行で、今回のお手本(teach_v1)から計算した正規化の値(関節の角度を学習しやすい範囲に直すための平均と幅)を読んだことが分かります。後半の connection open と closed の組は、Mac のクライアントが1回つなぐたびに出る行です。

安全範囲を今回のお手本から作るように、--raw-dir を付けた

Mac 側のクライアント run_policy_client.py は、アームへ送る角度を「学習データで通った関節の範囲 ±10°」に収めます(安全範囲)。この範囲を作るお手本のフォルダは、何も付けないと、前の回のお手本(scripted_v1)になっていました。前の回のお手本はブロックがほぼ同じ場所にあるので、土台 j0 の範囲が狭く、今回のお手本(j0 を −61.7° まで使う)の動きが途中で切られてしまいます。2つのお手本で範囲を計算して比べました。

mecharm_redblock_box_scripted_v1 20
  min [-42.4   1.3 -42.2 -13.7 -90.9 -40.6]
  max [ 85.8  89.2  20.4  22.5  57.3 -28.9]
mecharm_redblock_box_teach_v1 19
  min [-61.7   1.3 -35.7  -7.8 -88.2 -40.6]
  max [ 44.7  87.9  31.4  17.    0.4 -23.6]

前のお手本で作ると j0 の下限は −52°、今回のお手本なら −72° です。そのため --raw-dir に今回のお手本を指定して動かしました。下のログの最初の [SAFE] の行が、今回のお手本で作った範囲です。

たたんだ姿勢のままのドライランでは、予測はほとんど0だった

Pi(アームの中の小さなコンピュータ)でアームを動かす常駐プログラム(デーモン)を起動し、まずアームに何も送らない「ドライラン」で予測だけを見ました。ここから実機でアームを動かすまでのターミナルの記録です。

Last login: Wed Oct 21 08:21:34 on ttys005
(base) shimazueiichi@shimadzueiichinoMacBook-Pro ~ % ssh -i ~/.ssh/id_rsa_pi ubuntu@192.168.2.1 'ss -ltn | grep 5555' || { 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'; }
[DAEMON 15:24:04] ✓ MyCobot 初期化完了
[DAEMON 15:24:04] startup_warmup angles=[-0.08, 92.54, 1.14, 1.75, -95.97, -29.26]
[DAEMON 15:24:04] ポート 5555 で待ち受け中...
LISTEN0      1                                 0.0.0.0:5555        0.0.0.0:*    
(base) shimazueiichi@shimadzueiichinoMacBook-Pro ~ % cd ~/r2r2r-scripts
R=/Volumes/KIOXIA/r2r2r-data/datasets/mecharm_redblock_box_teach_v1/raw_episodes 
python3 pi05/run_policy_client.py --raw-dir $R --max-chunks 5
[SAFE  ] 安全範囲 (学習 19 本 ±10°): j0[-72,+55]  j1[-9,+98]  j2[-46,+41]  j3[-18,+27]  j4[-90,+10]  j5[-51,-14]
[MODE  ] DRY-RUN (アームには送らない)  steps/chunk=5  max_move=20.0°  log=/Volumes/KIOXIA/r2r2r-data/runs/pi05_policy/pi05_20261021_082212
[POLICY] 接続 ws://localhost:8000  metadata={}
[WARN  ] HOME から離れています (max|Δ|=96.0°). 学習データは HOME から始まっています
         現在角: j0=-0.1 j1=+92.5 j2=+1.1 j3=+1.8 j4=-96.0 j5=-29.3
[000] 推論 57049ms  5ステップ先  Δ= -0.2  +1.4  -1.0  -0.0  +6.0  -0.0
       [LIMIT] j4 安全範囲でクランプ -95.9→-90.0
[001] 推論 875ms  4ステップ先  Δ= +0.2  +0.1  -0.1  +0.1  +6.0  -0.0  グリッパー→閉
       [LIMIT] j4 安全範囲でクランプ -96.0→-90.0
[002] 推論 804ms  5ステップ先  Δ= -0.0  +0.1  -0.1  +0.0  +6.0  -0.0
       [LIMIT] j4 安全範囲でクランプ -95.8→-90.0
[003] 推論 2696ms  5ステップ先  Δ= +0.0  +0.2  -0.1  -0.0  +6.0  -0.0  グリッパー→閉
       [LIMIT] j4 安全範囲でクランプ -96.1→-90.0
[004] 推論 1140ms  3ステップ先  Δ= +0.0  +0.2  -0.0  -0.0  +6.0  -0.0  グリッパー→閉
       [LIMIT] j4 安全範囲でクランプ -96.0→-90.0
[DONE  ] 記録: /Volumes/KIOXIA/r2r2r-data/runs/pi05_policy/pi05_20261021_082212
(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 pi05/run_policy_client.py --raw-dir $R --execute --max-chunks 1
[SAFE  ] 安全範囲 (学習 19 本 ±10°): j0[-72,+55]  j1[-9,+98]  j2[-46,+41]  j3[-18,+27]  j4[-90,+10]  j5[-51,-14]
[MODE  ] EXECUTE  steps/chunk=5  max_move=20.0°  log=/Volumes/KIOXIA/r2r2r-data/runs/pi05_policy/pi05_20261021_082423
[POLICY] 接続 ws://localhost:8000  metadata={}
[WARN  ] HOME から離れています (max|Δ|=96.0°). 学習データは HOME から始まっています
         現在角: j0=-0.1 j1=+92.5 j2=+1.1 j3=+1.8 j4=-96.0 j5=-29.3
[GRIP  ] 開始時にグリッパーを開く
  [Pi] GRIPPER: OPEN result= -1
[000] 推論 1098ms  5ステップ先  Δ= +0.1  +0.3  -0.3  -0.0  +6.0  -0.0
       [LIMIT] j4 安全範囲でクランプ -96.0→-90.0
       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > h
[SEND] pi05_home: send_angles (single) [  +0.00,   +0.00,   +0.00,   +0.00,   +0.00,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=95.97°, post-send timeout 16.4s)
[SEND] arrival OK: max|Δ|=1.75° (tol 2.5°)
[DONE  ] 記録: /Volumes/KIOXIA/r2r2r-data/runs/pi05_policy/pi05_20261021_082423
(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 pi05/run_policy_client.py --raw-dir $R --max-chunks 5
[SAFE  ] 安全範囲 (学習 19 本 ±10°): j0[-72,+55]  j1[-9,+98]  j2[-46,+41]  j3[-18,+27]  j4[-90,+10]  j5[-51,-14]
[MODE  ] DRY-RUN (アームには送らない)  steps/chunk=5  max_move=20.0°  log=/Volumes/KIOXIA/r2r2r-data/runs/pi05_policy/pi05_20261021_082512
[POLICY] 接続 ws://localhost:8000  metadata={}
[000] 推論 1158ms  5ステップ先  Δ= -4.8  +8.3  -0.1  +1.3 -10.5  -0.4
[001] 推論 982ms  5ステップ先  Δ= -2.6  +4.5  -0.1  +0.7  -5.3  -0.3
[002] 推論 814ms  5ステップ先  Δ= -3.8  +6.7  +0.1  +1.1  -8.7  -0.2
[003] 推論 1062ms  5ステップ先  Δ= -0.1  +0.0  -0.1  +0.0  +0.2  +0.0
[004] 推論 1069ms  5ステップ先  Δ= -0.6  +0.9  -0.1  +0.2  -1.0  -0.1
[DONE  ] 記録: /Volumes/KIOXIA/r2r2r-data/runs/pi05_policy/pi05_20261021_082512
(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 pi05/run_policy_client.py --raw-dir $R --execute
[SAFE  ] 安全範囲 (学習 19 本 ±10°): j0[-72,+55]  j1[-9,+98]  j2[-46,+41]  j3[-18,+27]  j4[-90,+10]  j5[-51,-14]
[MODE  ] EXECUTE  steps/chunk=5  max_move=20.0°  log=/Volumes/KIOXIA/r2r2r-data/runs/pi05_policy/pi05_20261021_082645
[POLICY] 接続 ws://localhost:8000  metadata={}
[GRIP  ] 開始時にグリッパーを開く
  [Pi] GRIPPER: OPEN result= -1
[000] 推論 2440ms  5ステップ先  Δ= -2.1  +3.8  -0.0  +0.7  -4.8  -0.3
       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > 
[SEND] pi05_chunk000: send_angles (single) [  -2.16,   +5.13,   +1.74,   +0.98,   -5.03,  -29.98]  speed=20 wait=1.0s  (pre max|Δ|=4.77°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.08° (tol 3.0°)
[001] 推論 860ms  5ステップ先  Δ= -5.3 +10.9  +2.1  +1.3 -14.5  +0.4
       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > 
[SEND] pi05_chunk001: send_angles (single) [  -7.76,  +16.90,   +4.66,   +1.55,  -18.48,  -29.30]  speed=20 wait=1.0s  (pre max|Δ|=13.65°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=0.94° (tol 3.0°)
[002] 推論 942ms  5ステップ先  Δ= -6.7 +10.4  +2.5  +0.9 -15.0  +0.3
       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > 
[SEND] pi05_chunk002: send_angles (single) [ -14.72,  +28.28,   +7.72,   +2.72,  -32.98,  -29.37]  speed=20 wait=1.0s  (pre max|Δ|=14.97°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=0.98° (tol 3.0°)
[003] 推論 2261ms  5ステップ先  Δ= -7.1 +11.1  +3.1  +0.6 -16.0  +0.1
       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > 
[SEND] pi05_chunk003: send_angles (single) [ -21.75,  +40.39,  +11.41,   +4.14,  -49.17,  -29.61]  speed=20 wait=1.0s  (pre max|Δ|=15.95°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=0.83° (tol 3.0°)
[004] 推論 837ms  5ステップ先  Δ= -6.6 +10.7  +2.8  -0.6 -15.6  +0.1
       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > 
[SEND] pi05_chunk004: send_angles (single) [ -28.16,  +51.95,  +14.71,   +3.21,  -64.38,  -29.64]  speed=20 wait=1.0s  (pre max|Δ|=15.61°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=0.87° (tol 3.0°)
[005] 推論 806ms  5ステップ先  Δ= -5.8  +9.0  +1.7  +0.6 -10.9  -0.0
       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > 
[SEND] pi05_chunk005: send_angles (single) [ -34.26,  +61.83,  +16.87,   +4.25,  -75.09,  -29.71]  speed=20 wait=1.0s  (pre max|Δ|=10.93°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.01° (tol 3.0°)
[006] 推論 934ms  5ステップ先  Δ= -0.1  +0.9  -0.2  -0.1  -0.3  +0.0
       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > 
[SEND] pi05_chunk006: send_angles (single) [ -33.91,  +63.71,  +16.70,   +4.18,  -75.10,  -29.68]  speed=20 wait=1.0s  (pre max|Δ|=0.87°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=0.97° (tol 3.0°)
[007] 推論 881ms  5ステップ先  Δ= +0.0  +0.1  -0.1  -0.0  -0.1  +0.0
       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > 
[008] 推論 2387ms  5ステップ先  Δ= +0.0  +0.2  -0.1  -0.1  -0.2  +0.0
       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > 
[009] 推論 917ms  5ステップ先  Δ= +0.0  +1.0  -0.1  -0.2  -0.4  +0.0
       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > 
[SEND] pi05_chunk009: send_angles (single) [ -33.90,  +65.68,  +16.72,   +4.10,  -75.74,  -29.68]  speed=20 wait=1.0s  (pre max|Δ|=1.00°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=0.94° (tol 3.0°)
[010] 推論 2530ms  5ステップ先  Δ= +0.0  +0.0  -0.1  -0.0  -0.1  +0.0
       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > 
[011] 推論 1412ms  5ステップ先  Δ= +0.1  +0.1  -0.0  -0.0  -0.2  +0.0
       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > 
[012] 推論 1260ms  5ステップ先  Δ= +0.0  +0.1  -0.0  -0.0  -0.2  +0.0
       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > 
[013] 推論 1229ms  5ステップ先  Δ= +0.1  +0.2  -0.1  -0.0  -0.3  +0.0
       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > 
[014] 推論 878ms  5ステップ先  Δ= +0.0  +0.1  -0.1  -0.0  -0.1  +0.0
       Enter=実行 / s=送らず次へ / h=HOMEに戻して終了 / q=終了 > 

最初のドライラン(pi05_20261021_082212)は、アームが電源を入れたときのたたんだ姿勢(肩 j1 +92.5°、手首 j4 −96.0°)のままでした。お手本はすべて HOME(たたまずに立てた待機姿勢)から始まっているので、この姿勢は学習で見たことがありません。予測は手首を安全範囲の端まで戻す +6.0° のほかはほとんど0で、LOG341 と同じでした。1回目の推論は、サーバー側のコンパイルで 57 秒かかっています。

--execute --max-chunks 1 で起動し、最初の確認で h を押して HOME に戻しました。HOME からのドライラン(pi05_20261021_082512)では、土台 j0 を −方向に回し、肩 j1 を前に倒し、手首 j4 を下げる、ブロックへ腕を伸ばす向きの予測が出ています。ドライランではアームが動かないので、5回とも同じ画像と角度から予測しています。それでも予測の大きさが回ごとに違うのは、π0.5 の出力に毎回ばらつきがあるためです。

実機では7回の指令でブロックの近くまで来て、そこから動かなくなった

--execute で、1チャンク(1回の予測)ごとに Enter で確かめながら動かしました。チャンク 00〜05 の6回で、j1 は +5.1° から +61.8° まで、j4 は −5.0° から −75.1° まで進みました。1回の移動はどれも 3° 以内の誤差で届いています(arrival OK)。

チャンク 06 で動きが 1° 未満に落ち、そこから先は予測がほとんど0になりました。チャンク 09 で j1 が 1° 動いたのを最後に、指令は「動かす必要なし」(0.5° 未満)が続いています。ターミナルの記録はチャンク 14 までですが、log.jsonl にはチャンク 19 まで残っていて、最後に h で HOME に戻しています。チャンク 15〜19 の記録です(state はそのチャンクの予測を求めたときの実測角)。

チャンクstate(j0〜j5)送った目標(j0〜j5)実行届いたか
15
-33.9 +66.6 +16.9 +4.3 -75.8 -29.7
-33.9 +66.7 +16.8 +4.2 -76.0 -29.7
Enter(差が 0.5° 未満なので送らず)
—
16
-33.9 +66.6 +16.9 +4.3 -75.8 -29.7
-33.9 +66.8 +16.8 +4.2 -76.1 -29.7
Enter(差が 0.5° 未満なので送らず)
—
17
-33.9 +66.6 +16.9 +4.3 -75.8 -29.7
-33.8 +66.6 +16.8 +4.2 -76.0 -29.7
Enter(差が 0.5° 未満なので送らず)
—
18
-33.9 +66.6 +16.9 +4.3 -75.8 -29.7
-33.9 +66.7 +16.8 +4.3 -76.0 -29.7
Enter(差が 0.5° 未満なので送らず)
—
19
-33.9 +66.6 +16.9 +4.3 -75.8 -29.7
-33.9 +66.7 +16.8 +4.3 -76.1 -29.7
h(HOME に戻して終了)—

上のカメラが、各チャンクで AI に渡した画像を並べました。チャンク 06 以降、アームはブロックのすぐ近くで止まったままです。

π0.5 に渡した上カメラの画像20枚。チャンク00〜05でアームがブロックへ伸び、06以降は同じ姿勢のまま止まっている

お手本では、ブロックの真上に着いてから 1.5〜2 秒止まっていた

止まった姿勢(j0 −33.9°、j1 +65.7°、j2 +16.7°、j3 +4.1°、j4 −75.7°)が、お手本のどこに当たるかを調べました。19本それぞれで、この姿勢にいちばん近い瞬間(near t)と、その姿勢との差(dist、関節ごとの差の最大)を出しています。still はその瞬間から動かずにいたステップ数で、どの1本も 0 でした。いちばん近い瞬間は、どれも止まる直前のまだ動いているコマだったためで、止まっている区間は次の表で見ています。

# 実機で止まった姿勢に、お手本のどの瞬間がいちばん近いか (LOG358)
import numpy as np, glob, os
ROOT = os.environ.get("R2R2R_DATA", "/Volumes/KIOXIA/r2r2r-data")
os.chdir(ROOT + "/datasets/mecharm_redblock_box_teach_v1/raw_episodes")
cur = np.array([-33.90, 65.68, 16.72, 4.10, -75.74])   # 実機で止まった姿勢 j0..j4
for p in sorted(glob.glob("*/episode.npz")):
    d = np.load(p); s = d["state"]
    dist = np.abs(s[:, :5] - cur).max(1); i = dist.argmin()
    mv = np.abs(np.diff(s[:, :6], axis=0)).max(1)
    j = i
    while j < len(mv) and mv[j] < 0.3: j += 1
    g = s[:, 6]; gc = np.where(np.diff(g) != 0)[0]
    print(os.path.dirname(p)[-15:], "len", len(s), "near t", i, "dist %.1f" % dist[i], np.round(s[i, :5], 1), "g", g[i], "still", j - i, "gchg", gc[:3])
20261019_183454 len 208 near t 38 dist 4.6 [-33.8  61.1  14.2   4.3 -79. ] g 0.0 still 0 gchg [ 83 162]
20261019_191257 len 205 near t 124 dist 27.5 [ -8.   61.7 -10.8  11.2 -54.6] g 1.0 still 0 gchg [ 66 165]
20261019_191347 len 208 near t 125 dist 27.4 [ -7.7  61.8 -10.7  11.1 -54.8] g 1.0 still 0 gchg [ 67 165]
20261019_191441 len 241 near t 158 dist 27.6 [ -8.6  62.  -10.9  10.8 -54.9] g 1.0 still 0 gchg [ 94 199]
20261019_191541 len 211 near t 132 dist 27.3 [ -6.7  61.6 -10.5  11.2 -54.6] g 1.0 still 0 gchg [ 77 172]
20261019_191635 len 214 near t 135 dist 27.3 [ -6.6  61.5 -10.6  11.2 -54.8] g 1.0 still 0 gchg [ 74 175]
20261019_192343 len 200 near t 64 dist 7.2 [-27.6  58.5  19.3  -3.  -69.8] g 0.0 still 0 gchg [ 83 160]
20261019_192438 len 202 near t 58 dist 7.4 [-27.8  58.6  19.1  -3.3 -69.8] g 0.0 still 0 gchg [ 77 159]
20261019_192539 len 203 near t 54 dist 7.3 [-28.3  58.4  19.3  -3.2 -69.8] g 0.0 still 0 gchg [ 74 160]
20261019_192633 len 198 near t 54 dist 7.3 [-28.2  58.5  19.3  -3.2 -69.8] g 0.0 still 0 gchg [ 73 159]
20261019_193404 len 228 near t 135 dist 11.4 [-43.4  54.2  20.4   4.4 -74. ] g 1.0 still 0 gchg [ 91 186]
20261019_193459 len 234 near t 120 dist 11.2 [-45.   54.5  20.6   4.1 -73.8] g 1.0 still 0 gchg [ 83 191]
20261019_193617 len 216 near t 121 dist 11.2 [-44.6  54.4  20.6   4.1 -73.7] g 1.0 still 0 gchg [ 84 173]
20261019_193825 len 245 near t 150 dist 11.2 [-44.8  54.5  20.6   4.4 -73.8] g 1.0 still 0 gchg [ 94 202]
20261019_193932 len 212 near t 117 dist 11.2 [-44.7  54.5  20.6   4.  -73.6] g 1.0 still 0 gchg [ 80 169]
20261019_194818 len 183 near t 24 dist 41.2 [  7.3  25.9  18.   -2.7 -37.2] g 0.0 still 0 gchg [ 73 140]
20261019_194913 len 183 near t 24 dist 40.8 [  6.6  24.9  17.4  -3.9 -35.8] g 0.0 still 0 gchg [ 74 140]
20261019_195023 len 180 near t 24 dist 40.5 [  6.5  25.2  17.5  -3.2 -36.1] g 0.0 still 0 gchg [ 71 137]
20261019_195120 len 178 near t 23 dist 41.0 [  6.6  24.7  17.2  -2.8 -35.4] g 0.0 still 0 gchg [ 70 136]

いちばん近いのは 183454 の t=38(差 4.6°)と、192343〜192633 の4本(差 7.2〜7.4°)です。どちらもブロックの真上に来たあたりで、つかむ前です。その前後の動きを 0.3 秒おきに並べました。

# 真上に着いたあたりの動きを 0.3 秒おきに並べる (LOG358)
import numpy as np, os
ROOT = os.environ.get("R2R2R_DATA", "/Volumes/KIOXIA/r2r2r-data")
os.chdir(ROOT + "/datasets/mecharm_redblock_box_teach_v1/raw_episodes")
for r in ["run_0001_20261019_192343", "run_0001_20261019_183454"]:
    d = np.load(r + "/episode.npz"); s = d["state"]; a = d["actions"]
    print(r)
    for t in range(30, 95, 3):
        print(t, np.round(s[t, :6], 1), "g", s[t, 6], " a-s max %.2f" % np.abs(a[t, :6] - s[t, :6]).max())
run_0001_20261019_192343
30 [-10.   22.7   7.8  -2.7 -26.5 -28.7] g 0.0  a-s max 2.83
33 [-14.4  29.7   9.9  -3.1 -35.5 -28.6] g 0.0  a-s max 3.07
36 [-17.9  36.6  12.1  -2.5 -44.5 -28.5] g 0.0  a-s max 2.99
39 [-20.8  43.6  14.2  -2.6 -53.5 -28.4] g 0.0  a-s max 3.01
42 [-24.2  50.5  16.2  -3.  -62.5 -28.3] g 0.0  a-s max 2.96
45 [-26.8  55.8  18.2  -2.8 -68.1 -28.3] g 0.0  a-s max 1.32
48 [-27.6  58.1  19.6  -2.4 -69.3 -28.3] g 0.0  a-s max 0.05
51 [-27.6  58.1  19.7  -2.4 -69.3 -28.3] g 0.0  a-s max 0.03
54 [-27.6  58.1  19.7  -2.4 -69.3 -28.2] g 0.0  a-s max 0.03
57 [-27.6  58.1  19.7  -2.5 -69.4 -28.2] g 0.0  a-s max 0.03
60 [-27.6  58.1  19.7  -2.5 -69.5 -28.2] g 0.0  a-s max 0.27
63 [-27.6  58.1  19.7  -2.6 -70.3 -28.1] g 0.0  a-s max 0.52
66 [-27.6  59.6  18.2  -3.9 -68.1 -28.1] g 0.0  a-s max 0.87
69 [-27.6  61.2  16.7  -5.3 -65.5 -28. ] g 0.0  a-s max 0.87
72 [-27.7  62.8  15.1  -6.7 -62.9 -28. ] g 0.0  a-s max 0.87
75 [-27.7  63.7  14.2  -7.5 -61.3 -27.9] g 0.0  a-s max 0.00
78 [-27.7  63.7  14.2  -7.5 -61.3 -27.9] g 0.0  a-s max 0.03
81 [-27.7  63.7  14.2  -7.6 -61.2 -27.9] g 0.0  a-s max 0.02
84 [-27.7  63.7  14.1  -7.6 -61.2 -27.9] g 1.0  a-s max 0.04
87 [-27.7  63.7  14.1  -7.6 -61.3 -27.9] g 1.0  a-s max 0.04
90 [-27.7  63.7  14.1  -7.6 -61.4 -27.9] g 1.0  a-s max 0.04
93 [-27.7  63.7  14.   -7.6 -61.6 -27.9] g 1.0  a-s max 0.04
run_0001_20261019_183454
30 [-23.1  43.7  10.6   4.6 -56.2 -29.1] g 0.0  a-s max 3.01
33 [-26.9  50.3  12.    3.7 -65.2 -29.1] g 0.0  a-s max 2.94
36 [-31.2  56.7  13.4   3.9 -73.9 -29.1] g 0.0  a-s max 2.82
39 [-34.9  63.2  14.6   4.5 -81.2 -29.2] g 0.0  a-s max 2.23
42 [-36.4  66.4  15.8   4.6 -84.  -29.2] g 0.0  a-s max 0.45
45 [-36.4  66.7  16.5   4.4 -84.  -29.2] g 0.0  a-s max 0.03
48 [-36.4  66.7  16.5   4.3 -84.  -29.2] g 0.0  a-s max 0.00
51 [-36.4  66.7  16.5   4.3 -84.  -29.2] g 0.0  a-s max 0.03
54 [-36.4  66.7  16.5   4.3 -84.1 -29.2] g 0.0  a-s max 0.00
57 [-36.4  66.7  16.5   4.3 -84.1 -29.2] g 0.0  a-s max 0.00
60 [-36.4  66.7  16.5   4.3 -84.3 -29.2] g 0.0  a-s max 0.49
63 [-36.4  66.7  16.5   4.3 -85.7 -29.2] g 0.0  a-s max 1.25
66 [-36.4  70.8  14.2   4.1 -86.2 -29.2] g 0.0  a-s max 1.41
69 [-36.4  75.   11.7   3.9 -86.8 -29.2] g 0.0  a-s max 1.41
72 [-36.4  79.2   9.3   3.6 -87.4 -29.2] g 0.0  a-s max 1.41
75 [-36.4  81.6   7.9   3.5 -87.7 -29.3] g 0.0  a-s max 0.00
78 [-36.4  81.6   7.9   3.5 -87.7 -29.3] g 0.0  a-s max 0.00
81 [-36.4  81.6   7.9   3.5 -87.7 -29.3] g 0.0  a-s max 0.03
84 [-36.4  81.6   7.9   3.5 -87.7 -29.4] g 1.0  a-s max 0.02
87 [-36.4  81.6   7.9   3.5 -87.8 -29.4] g 1.0  a-s max 0.02
90 [-36.4  81.6   7.9   3.5 -87.9 -29.4] g 1.0  a-s max 0.02
93 [-36.4  81.6   7.9   3.5 -87.9 -29.5] g 1.0  a-s max 0.02

192343 では、t=45 から t=60 まで、関節の角度がまったく変わっていません(a-s max は、そのステップの目標と実測の差の最大。0.05° 前後)。その後 t=63〜72 で下ろし、t=75〜84 でまた止まってからグリッパーを閉じています(g が 0 から 1 へ)。183454 も、t=45〜57 で止まってから下ろしています。

π0.5 は1回に10ステップ(1秒)先までの動きを返します。真上での停止は 1.5〜2 秒あり、1秒より長いので、停止の途中のコマから見た「1秒先の正解」は、ずっと止まったままです。しかも停止の間は、上カメラの画像も関節の角度も変わりません。同じ入力に対して、正解が「止まる」になっているコマが何枚も続き、「下ろし始める」になっているコマはその最後の数枚だけです。実機のアームは真上に着いたあと、その「止まる」側の答えを返し続けたことになります。

この関係を、お手本1本で動かして見られるようにしました。つまみを動かすと、その位置から1秒先までの枠が出ます。

止まっているステップは、お手本全体の 40% だった

19本全体で、止まっているステップ(6関節すべてで目標と実測の差が 0.3° 未満、かつグリッパーの状態が変わらない)を数えました。

# お手本 19 本の「止まっているステップ」の割合と、5 ステップ以上続く停止の長さ (LOG358)
import numpy as np, glob, os
ROOT = os.environ.get("R2R2R_DATA", "/Volumes/KIOXIA/r2r2r-data")
os.chdir(ROOT + "/datasets/mecharm_redblock_box_teach_v1/raw_episodes")
tot = still = 0; runs = []
for p in sorted(glob.glob("*/episode.npz")):
    d = np.load(p); s = d["state"]; a = d["actions"]
    m = (np.abs(a[:, :6] - s[:, :6]).max(1) < 0.3) & (a[:, 6] == s[:, 6])
    tot += len(s); still += m.sum()
    r = 0; best = []
    for x in m:
        if x: r += 1
        else:
            if r >= 5: best.append(r)
            r = 0
    if r >= 5: best.append(r)
    runs.append(best)
print("still steps", still, "/", tot, "%.0f%%" % (100 * still / tot))
for p, b in zip(sorted(glob.glob("*/episode.npz")), runs):
    print(os.path.dirname(p), "still runs (>=5 steps):", b)
still steps 1594 / 3949 40%
run_0001_20261019_183454 still runs (>=5 steps): [10, 17, 9, 20, 10, 10]
run_0001_20261019_191257 still runs (>=5 steps): [10, 10, 19, 16, 10, 10]
run_0001_20261019_191347 still runs (>=5 steps): [10, 10, 9, 19, 14, 10, 10]
run_0001_20261019_191441 still runs (>=5 steps): [10, 20, 10, 9, 19, 6, 10, 10, 10]
run_0001_20261019_191541 still runs (>=5 steps): [10, 20, 9, 19, 12, 10, 10]
run_0001_20261019_191635 still runs (>=5 steps): [10, 19, 10, 19, 16, 10, 10]
run_0001_20261019_192343 still runs (>=5 steps): [10, 16, 9, 20, 10, 10]
run_0001_20261019_192438 still runs (>=5 steps): [10, 16, 9, 20, 7, 10, 10]
run_0001_20261019_192539 still runs (>=5 steps): [10, 16, 10, 19, 7, 10, 10]
run_0001_20261019_192633 still runs (>=5 steps): [10, 12, 9, 20, 6, 10, 10]
run_0001_20261019_193404 still runs (>=5 steps): [10, 19, 9, 19, 5, 10, 10]
run_0001_20261019_193459 still runs (>=5 steps): [10, 20, 9, 19, 7, 20, 10]
run_0001_20261019_193617 still runs (>=5 steps): [10, 20, 9, 19, 10, 10]
run_0001_20261019_193825 still runs (>=5 steps): [10, 19, 10, 9, 19, 19, 10, 10]
run_0001_20261019_193932 still runs (>=5 steps): [10, 19, 9, 19, 10, 10]
run_0001_20261019_194818 still runs (>=5 steps): [10, 16, 9, 20, 10, 10]
run_0001_20261019_194913 still runs (>=5 steps): [10, 16, 9, 19, 10, 10]
run_0001_20261019_195023 still runs (>=5 steps): [10, 13, 10, 19, 10, 10]
run_0001_20261019_195120 still runs (>=5 steps): [10, 13, 10, 19, 10, 10]

3,949 ステップのうち 1,594(40%)が止まっていて、1本の中に 1〜2 秒の停止が 6〜9 回あります。お手本を作るスクリプト build_demo_episodes.py には、もともと止まっている区間を詰める処理がありました。ただし条件が「2.0 秒以上」だったため、1.5〜2 秒の停止はすべて残っていました(あとで Claude Code が報告した内容)。

手で教えた要所を再生してお手本を録る方法(LOG349・LOG352)では、アームは要所ごとに「着いたか」を確かめ、グリッパーの開け閉めのあとは2秒待ちます。再生を確実にするための待ちが、そのままお手本の中身になっていました。

止まっている時間を詰める1回目(trim)は、codex のレビューで NG になった

直し方は、お手本から途中の停止を「先頭の2ステップだけ残して」削り、同じ条件で学習し直すことにしました。「止まっていたら少し押し出す」のような、推論の側での手当てはしていません。

データを作り直すプログラムは Claude Code が build_demo_episodes.py に足しました(オプション --trim-mid-idle)。元のお手本(teach_v1)は読むだけで、新しい名前(teach_v1_trim)で出しています。Claude Code の報告です。

データ作成まで完了しました。学習設定には手を付けていません。

  1. 修正内容

  pi05/build_demo_episodes.py に --trim-mid-idle を追加(既定では何もしない)。

  - MID_IDLE_EPS_DEG = 0.3 / MID_IDLE_KEEP_STEPS = 2
  - mid_idle_keep_mask(state, actions) — 停止ステップ = 6関節すべて abs(action - 
  state) < 0.3 かつ action[6] == state[6]。連続する停止は先頭2ステップだけ残す
  - 既存トリムの直後、フレーム抽出の前に適用。frames/ はトリム後の t
  に対して選ぶので枚数は常にステップ数と一致(連番も振り直し)
  - グリッパー切替ステップは action[6] != state[6]
  なので、定義上そもそも削除対象に入りません
  - 各エピソードの先頭・末尾は明示的に残す(両端の state を変えないため)

  原因について1点補足です。既存の停止詰め処理は前からありましたが、しきい値が
  IDLE_MAX_SEC = 2.0 秒で、今回の 1.5〜2 秒の停止(19〜20ステップ)がしきい値のす
  ぐ下に収まって残っていたのが実態でした。

  2. 出力

  datasets/mecharm_redblock_box_teach_v1_trim/(19本 + index.json +
  README.md)。teach_v1 は読み取りのみで、mtime も Oct 19
  のまま・3,949ステップ・19本のままです。

  3. 確かめた結果

  ┌────────────────┬────────────────┬─────────────────────────────┐
  │      項目      │       前       │             後              │
  ├────────────────┼────────────────┼─────────────────────────────┤
  │ 合計ステップ   │ 3,949          │ 2,635(66.7%、1,314削除)   │
  ├────────────────┼────────────────┼─────────────────────────────┤
  │ 停止の割合     │ 1,594(40.4%) │ 280(10.6%)                │
  ├────────────────┼────────────────┼─────────────────────────────┤
  │ 再生時間       │ 394.9秒        │ 263.5秒                     │
  ├────────────────┼────────────────┼─────────────────────────────┤
  │ 停止の最長連続 │ 20             │ 2                           │
  ├────────────────┼────────────────┼─────────────────────────────┤
  │ グリッパー切替 │ 各2回          │ 各2回(19本すべて一致)     │
  ├────────────────┼────────────────┼─────────────────────────────┤
  │ 両端の state   │ —              │ 19本すべて完全一致(7次元) │
  └────────────────┴────────────────┴─────────────────────────────┘

  エピソードごとのステップ数は README の表に全19本入れました(例: 208→144,
  245→155, 178→118)。停止が3ステップ以上続く箇所は0件(最長2 = 設定どおり)。

  action の整合性(max abs(action[t] - state[t+1]))は、全体の最大が 2.275 → 
  2.275 deg で変わりません。最悪値は元データ由来の1本(191441)で、詰めたことで悪
  化していません。ただしエピソード単位では停止の継ぎ目で最大 0.7 deg
  ほど増えます(例: 0.55→1.26)。停止区間中の微小ドリフトが継ぎ目で1ステップに集
  まるためで、これは仕様上避けられない分です。気になる水準なら MID_IDLE_EPS_DEG
  を下げる余地があります。

  4. 変更していないこと

  - 既定動作の非改変を実測で確認: オプション未指定で1本再生成し、t / state /
  actions が完全一致、frames 208枚の md5 も全一致(確認用データセットは削除済み)
  - meta.json の steps_before_mid_trim が teach_v1 の元ステップ数と19本すべて一致
  → 詰める前の段階までは teach_v1
  と同一に再生成できており、前後比較の土台が妥当であることの裏付けになっています
  - 既存の IDLE_* 定数とロジック、FPS / IMG_LONG / 採否ゲート(LOG352分)は非改変
  - 関節・グリッパー・姿勢・実機制御フローには触っていません(Mac側オフラインのデ
  ータ生成のみで、実機へ指令を出す経路を含まない)ため、物理リスクは低と判断して
  います
  - send_coords() 不使用、FKテーブル非参照、台帳CSV非改変、git commit/push なし

  作業ログ: ~/r2r2r-scripts/logs/20261021_0839.md

  なお git diff には私の変更以外に**既存の未コミット分(LOG352 
  の採否ゲート関連)**も含まれています。これは私がファイルを開いた時点で作業ツリ
  ーにあったもので、今回は触っていません。コミットする際は個別ファイル指定でも混
  ざるのでご注意ください。

Claude 側でも、削ったあとの 2,635 ステップが元のどのステップに当たるかを調べ、関節の角度と画像(MD5 という中身から計算する値)が19本すべてで一致することを確かめました。

# trim の 19 本が、元のお手本のどのステップを残したかを確かめる (state・actions・画像の MD5) (LOG358)
import numpy as np, os, hashlib
ROOT = os.environ.get("R2R2R_DATA", "/Volumes/KIOXIA/r2r2r-data")
os.chdir(ROOT + "/datasets")
A = "mecharm_redblock_box_teach_v1/raw_episodes"; B = "mecharm_redblock_box_teach_v1_trim/raw_episodes"
def md5(p): return hashlib.md5(open(p, 'rb').read()).hexdigest()
bad = 0; tot_o = tot_t = 0
for r in sorted(os.listdir(A)):
    if not os.path.exists(f"{A}/{r}/episode.npz"): continue
    o = np.load(f"{A}/{r}/episode.npz"); t = np.load(f"{B}/{r}/episode.npz")
    fo = sorted(os.listdir(f"{A}/{r}/frames")); ft = sorted(os.listdir(f"{B}/{r}/frames"))
    assert len(ft) == len(t["t"]), (r, len(ft), len(t["t"]))
    idx = [int(np.argmin(np.abs(o["t"] - x))) for x in t["t"]]
    ok_state = np.allclose(o["state"][idx], t["state"]) and np.allclose(o["actions"][idx], t["actions"])
    ho = {md5(f"{A}/{r}/frames/{f}"): i for i, f in enumerate(fo)}
    fm = [ho.get(md5(f"{B}/{r}/frames/{f}"), -1) for f in ft]
    ok_frames = fm == idx
    s = t["state"]; a = t["actions"]
    m = (np.abs(a[:, :6] - s[:, :6]).max(1) < 0.3) & (a[:, 6] == s[:, 6])
    run = 0; mx = 0
    for x in m:
        run = run + 1 if x else 0; mx = max(mx, run)
    tot_o += len(o["t"]); tot_t += len(t["t"])
    if not (ok_state and ok_frames): bad += 1
    print(r[-15:], len(o["t"]), "->", len(t["t"]), "state一致", ok_state, "画像一致", ok_frames, "停止最長", mx, "grip変化", int((np.diff(s[:, 6]) != 0).sum()))
print("total", tot_o, "->", tot_t, "不一致エピソード", bad)
20261019_183454 208 -> 144 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_191257 205 -> 142 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_191347 208 -> 140 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_191441 241 -> 155 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_191541 211 -> 135 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_191635 214 -> 134 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_192343 200 -> 137 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_192438 202 -> 134 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_192539 203 -> 135 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_192633 198 -> 135 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_193404 228 -> 160 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_193459 234 -> 153 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_193617 216 -> 150 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_193825 245 -> 155 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_193932 212 -> 147 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_194818 183 -> 120 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_194913 183 -> 121 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_195023 180 -> 120 state一致 True 画像一致 True 停止最長 2 grip変化 2
20261019_195120 178 -> 118 state一致 True 画像一致 True 停止最長 2 grip変化 2
total 3949 -> 2635 不一致エピソード 0

ここで学習に進むつもりでした。ただ、この確認は「どのステップを残したか」を見ただけで、Claude Code の報告と同じ観点です。学習には8時間と約5ドルかかるので、別の AI の codex に、実装とデータを読んでもらいました。結果は NG でした。

**現状のまま約8時間の学習へ進むのは勧めません。** 画像の対応は正しい一方、削除した継ぎ目の action 不整合と、グリッパー切替直前の削除を実データで確認しました。

コード・README・`convert_to_lerobot.py` を読み、元データと trim データ19本を読み取り専用で比較しました。ファイルの変更・再生成はしていません。README の「すべて満たしている」は検証対象の記述として扱いました。

以下、行番号は `build_demo_episodes.py` のものです。

| 項目 | 判定 | 確認結果・修正案 |
|---|---|---|
| **A1 画像と state・action の対応** | ✅ OK | 289・297行で同じマスクを適用し、300〜305行で残った時刻から画像を選んでいます。19本すべてで state・actions が元データの保持行と完全一致し、画像も対応する元JPEGと全枚ハッシュ一致しました。 |
| **A2 action[t] ≈ state[t+1]** | ❌ NG | **297行**で削除前の action をそのまま保持しています。削除後の127継ぎ目のうち80箇所で誤差が0.3°以上、19箇所で1°超。最大2.275°です。**最終マスク適用後に `actions = np.concatenate([state[1:], state[-1:]], axis=0)` として再計算**してください。継ぎ目の移動量自体が適切かも確認が必要です。 |
| **A3 ゆっくりした動き出しの保護** | ⚠️ 要注意 | **187〜198行**は指定された停止定義には忠実ですが、微小な移動と本当の停止を区別できません。再現例では、毎ステップ0.2°ずつ動く12ステップが `[0,1,11]` の3ステップまで削られます。実データでも新しい継ぎ目の最大累積ずれは2.275°でした。連続区間内の姿勢の変動幅にも上限を設けるなど、追加条件が必要です。 |
| **A4 グリッパー切替前後** | ❌ NG | 切替ステップそのものは38回すべて保持。しかし**198行**で、**38回すべての切替直前の1ステップが削除**されています。直後は保持されています。切替行 `i` と `i-1, i+1` を明示的に保護してください。既存の停止処理にも同じ保護が必要です。 |
| **A5 最初・最後の保持** | ✅ OK※ | 202〜204行で保護され、19本すべてで teach_v1 の先頭・末尾 state と完全一致。ただし、保護対象は**既存の停止処理を通過した後の両端**です。生の記録の末尾は既存処理で失われる可能性があります。 |
| **A6 既存処理との二重削除** | ⚠️ 要注意 | **273〜297行**で二段階に削ります。既存処理は両端専用ではなく、途中の停止も対象です。長い停止は「先頭10ステップ→先頭2ステップ」とさらに短縮されます。重複した行の削除ではありませんが、境界保護や action 再計算は両処理をまとめて考える必要があります。 |
| **B1 未指定時の互換性** | ⚠️ 要注意 | 今回の停止オプションだけなら、未指定時は配列・画像の処理を変えません。ただし**342〜344・354〜355行**でメタデータと index に項目が増えるため、出力全体の完全一致ではありません。また Git HEAD との差分には採否条件の変更もあり、「変更前」との全面的な互換性は断言できません。 |
| **B2 teach_v1 の保護** | ⚠️ 要注意 | README の作成コマンドなら出力先は trim で、teach_v1 への書き込みはありません。そもそも入力は teach_v1 ではなく元の run 記録です。ただし**371行**に出力先の保護がなく、`--dataset mecharm_redblock_box_teach_v1 --force` なら**311行**で元エピソードを削除します。trim 指定時は元データセット名・同一パスを拒否するガードを追加してください。 |
| **B3 後段の読み込み形式** | ✅ OK | 19本すべてで `t[N]`、`state[N,7]`、`actions[N,7]`、画像枚数、`meta.steps` の整合を確認しました。変換コードはこのキーと形を読みます。LeRobot 環境での変換実行は未実施です。 |
| **D6 マウント確認** | ❌ NG | 書き込み前の呼び出しはありますが、**82〜84行**は `DATA_ROOT.exists()` だけです。未マウントでもローカルに同名ディレクトリが残れば通過します。**`Path("/Volumes/KIOXIA").is_mount()` などでマウント自体を確認し、失敗時に終了**してください。 |

README の「全体の最大値が2.275°で変わらないので、悪化していない」という評価には注意が必要です。新しい継ぎ目127箇所のうち、**113箇所で、その元の隣接関係より誤差が増加**しています。例えば `run_0001_20261019_191441` の元インデックス69→78の継ぎ目は、0°から2.275°に増えています。全体最大値だけでは、この悪化が隠れます。

また、**337行の `t` は削除前の時間間隔を残します**。後段の変換コードは `t` を使わず10Hzで並べるため読み込みはできますが、圧縮後の時刻として扱うなら `np.arange(N) / FPS` に振り直し、元の時刻は別キーで保存するのが明確です。

学習前に、少なくとも次を直してください。

- 削除後の action を再計算し、継ぎ目の移動量を検証する。
- グリッパー切替前後を保護する。
- 微小移動を削りすぎない条件を追加する。
- マウント確認と teach_v1 の出力先保護を追加する。

**修正後に19本を再生成・再検証してから学習へ進む**のが妥当です。画像対応と形式は確認できていますが、現状は action の意味が継ぎ目で崩れています。

いちばん重いのは A2 です。お手本の「正解(action)」は、もともと「0.1 秒後の実測の角度」です。停止の途中を削ると、削った区間の前のステップの正解は、削られた先の角度のままになり、次に残ったステップの角度と食い違います。停止中にもアームはわずかにずれ続けていたので(A3)、その分が継ぎ目の1か所にまとまり、最大 2.3° の食い違いになっていました。Claude の確認は、対応関係だけを見ていて、継ぎ目での正解の意味までは見ていませんでした。

直す内容は次のとおりにしました。A2 の直し方だけ codex の案から変えています。codex は action の7列すべてを state から作り直す案でしたが、関節の6列だけを作り直し、グリッパーの列は元のまま残しました。グリッパーを開け閉めするタイミングを、1ステップでも動かさないためです。

指摘直し方
A2 継ぎ目で action が「0.1 秒後の state」とずれる
削ったあとに actions[t, :6] = state[t+1, :6] で作り直す(グリッパーの列は元のまま)
A3 ゆっくり動いている区間まで削る
停止区間の最初の角度から 0.5° 以上ずれたら、そこで区間を切る
A4 グリッパーを切り替える直前の1ステップを削る
切り替えのステップ i の前後(i−1, i, i+1)を必ず残す
D6 マウントの確認が「フォルダがあるか」だけ
Path("/Volumes/KIOXIA").is_mount() で確かめる
B2 元のデータセットを上書きできてしまう
出力先が既存のデータセットなら拒否する
t(時刻)が削る前の間隔のまま
np.arange(N) / FPS に振り直し、元の時刻は t_orig に残す

直した版 trim2 は、全ステップで action が「次のステップの state」になった

Claude Code の報告です。前の版(trim)は消さずに、teach_v1_trim2 という新しい名前で出しています。

6件すべて直して teach_v1_trim2 を作りました。trim は消していません。

  修正内容

  ┌─────┬─────────────────────┬───────────────────────────────────────────────┐
  │  #  │        指摘         │                     対応                      │
  ├─────┼─────────────────────┼───────────────────────────────────────────────┤
  │     │ 低速移動を停止と誤  │ MID_IDLE_DRIFT_DEG = 0.5。区間先頭 state      │
  │ R1  │ 判定                │ からのずれが0.5°超で区間を切り、新しい区間と  │
  │     │                     │ して数え直す                                  │
  ├─────┼─────────────────────┼───────────────────────────────────────────────┤
  │     │ グリッパー切替前後  │ gripper_guard_mask で i-1, i, i+1             │
  │ R2  │ の保護              │ を保護。既存 IDLE_* 処理にも keep |= guard    │
  │     │                     │ で適用                                        │
  ├─────┼─────────────────────┼───────────────────────────────────────────────┤
  │     │                     │ actions[:-1,:6] = state[1:,:6] /              │
  │ R3  │ action の作り直し   │ actions[-1,:6] =                              │
  │     │                     │ state[-1,:6]。gripper列は非改変               │
  ├─────┼─────────────────────┼───────────────────────────────────────────────┤
  │ R4  │ t の振り直し        │ t = np.arange(N)/FPS、元時刻は t_orig キー    │
  ├─────┼─────────────────────┼───────────────────────────────────────────────┤
  │ R5  │ マウント確認        │ Path("/Volumes/KIOXIA").is_mount()            │
  │     │                     │ で未マウント時 ABORT                          │
  ├─────┼─────────────────────┼───────────────────────────────────────────────┤
  │ R6  │ 出力先ガード        │ 既存の非trimデータセット宛なら ABORT          │
  └─────┴─────────────────────┴───────────────────────────────────────────────┘

  R1/R3/R4 は --trim-mid-idle 時のみ、R2 は既定経路にも、R5/R6 は常時です。

  確認結果

  ┌──────────────┬───────┬───────┬────────────────┐
  │     項目     │  元   │ trim  │     trim2      │
  ├──────────────┼───────┼───────┼────────────────┤
  │ 合計ステップ │ 3,949 │ 2,635 │ 2,717(68.8%) │
  ├──────────────┼───────┼───────┼────────────────┤
  │ 停止の割合   │ 40.4% │ 10.6% │ 9.6%           │
  └──────────────┴───────┴───────┴────────────────┘

  エピソードごとの数値は README の表に全19本あります(例: 208→144→144,
  245→155→165, 178→118→120)。

  - 継ぎ目の跳び: 161箇所で最大 2.275°。元データの通常1ステップの動きが最大
  3.263° なので、継ぎ目は通常の動きの範囲内に収まっており、特異点になっていません
  - action: 全ステップで actions[t,:6] == state[t+1,:6] 成立(最後は
  actions[-1,:6] == state[-1,:6])
  - グリッパー: 38回すべてで i-1, i, i+1 が残存。切替回数は各エピソード2回のまま
  - 両端 state: 19本すべて元と完全一致
  - 画像: 2,717枚すべて元の対応フレームと MD5 一致
  - オプション未指定時: 19本を再生成して teach_v1 と
  t/state/actions/npzキー/frames MD5(3,949枚)すべて完全一致

  報告すべき2点

  1. R2 の保護は、この19本では0ステップしか効いていません。 trim2 が trim より
  +82ステップ多い内訳は、R1の区間分割が82・R2の保護が0でした。既定経路の出力が完
  全一致したのも同じ理由です。切替ステップの前後がもともと削られていなかったため
  で、R2 は「将来のデータへの安全網」として入った状態です。

  2. R1 で救われた区間は、本当に動いていました。
  該当34区間は端から端の変位が中央値0.78°、変位/経路長が全区間 1.00(=完全に一方
  向)でした。1ステップあたりは中央値0.056°という小ささですが、ジッターではなく一
  方向にずるずる動いている低速移動です。指摘どおりの誤判定が実在していました。

  出力: datasets/mecharm_redblock_box_teach_v1_trim2/(README 同梱)。episode.npz
  は trim2 のみ t_orig
  キーが増えている点にご注意ください。元データは読むだけ(mtime は Oct 19
  のまま、3,949ステップ・19本)。作業ログは logs/20261021_0912.md です。

  openpi 側の学習設定には手を付けていません。

Claude 側でも、Claude Code とは別のやり方で確かめました。元のお手本の時刻(t_orig)から対応を取り直し、関節の角度・グリッパーの action・作り直した action・時刻・画像を見ています。グリッパーの切り替えは、「action が切り替わったステップ」と「state が切り替わったステップ」の両方で数えました。

# trim2 を元のお手本と突き合わせる: state・グリッパーの action・作り直した action・t・画像・継ぎ目・グリッパー切り替えの前後 (LOG358)
import numpy as np, os, hashlib
ROOT = os.environ.get("R2R2R_DATA", "/Volumes/KIOXIA/r2r2r-data")
os.chdir(ROOT + "/datasets")
A = "mecharm_redblock_box_teach_v1/raw_episodes"; B = "mecharm_redblock_box_teach_v1_trim2/raw_episodes"
def md5(p): return hashlib.md5(open(p, 'rb').read()).hexdigest()
tot = 0; issues = []; jumps = []; grip_ok = 0; grip_n = 0
for r in sorted(os.listdir(A)):
    if not os.path.exists(f"{A}/{r}/episode.npz"): continue
    o = np.load(f"{A}/{r}/episode.npz"); t = np.load(f"{B}/{r}/episode.npz")
    idx = np.array([int(np.argmin(np.abs(o["t"] - x))) for x in t["t_orig"]]); tot += len(idx)
    if not np.allclose(o["state"][idx], t["state"]): issues.append((r, "state"))
    if not np.allclose(o["actions"][idx, 6], t["actions"][:, 6]): issues.append((r, "grip action"))
    if not np.allclose(t["actions"][:-1, :6], t["state"][1:, :6]): issues.append((r, "action再計算"))
    if not np.allclose(t["t"], np.arange(len(idx)) / 10): issues.append((r, "t"))
    ft = sorted(os.listdir(f"{B}/{r}/frames")); fo = sorted(os.listdir(f"{A}/{r}/frames"))
    if len(ft) != len(idx) or any(md5(f"{B}/{r}/frames/{ft[k]}") != md5(f"{A}/{r}/frames/{fo[idx[k]]}") for k in range(len(idx))):
        issues.append((r, "frames"))
    for k in np.where(np.diff(idx) > 1)[0]:
        jumps.append(np.abs(t["state"][k + 1, :6] - t["state"][k, :6]).max())
    so = o["state"][:, 6]; ao = o["actions"][:, 6]
    rows = set(np.where(ao != so)[0]) | set(np.where(np.diff(so) != 0)[0] + 1)   # action の切り替え行と state の切り替え行
    keep = set(idx.tolist())
    for i in sorted(rows):
        grip_n += 1
        need = [j for j in (i - 1, i, i + 1) if 0 <= j < len(so)]
        if all(j in keep for j in need): grip_ok += 1
        else: issues.append((r[-15:], f"grip近傍 {i} 欠け {[j for j in need if j not in keep]}"))
print("total", tot, "seams", len(jumps), "seam max %.3f" % max(jumps), "seam>1deg", sum(j > 1 for j in jumps))
print("grip rows", grip_n, "ok", grip_ok)
print("issues", len(issues))
for x in issues: print(" ", x)
total 2717 seams 161 seam max 2.275 seam>1deg 11
grip rows 76 ok 38
issues 38
  ('20261019_183454', 'grip近傍 83 欠け [np.int64(82)]')
  ('20261019_183454', 'grip近傍 162 欠け [np.int64(161)]')
  ('20261019_191257', 'grip近傍 66 欠け [np.int64(65)]')
  ('20261019_191257', 'grip近傍 165 欠け [np.int64(164)]')
  ('20261019_191347', 'grip近傍 67 欠け [np.int64(66)]')
  ('20261019_191347', 'grip近傍 165 欠け [np.int64(164)]')
  ('20261019_191441', 'grip近傍 94 欠け [np.int64(93)]')
  ('20261019_191441', 'grip近傍 199 欠け [np.int64(198)]')
  ('20261019_191541', 'grip近傍 77 欠け [np.int64(76)]')
  ('20261019_191541', 'grip近傍 172 欠け [np.int64(171)]')
  ('20261019_191635', 'grip近傍 74 欠け [np.int64(73)]')
  ('20261019_191635', 'grip近傍 175 欠け [np.int64(174)]')
  ('20261019_192343', 'grip近傍 83 欠け [np.int64(82)]')
  ('20261019_192343', 'grip近傍 160 欠け [np.int64(159)]')
  ('20261019_192438', 'grip近傍 77 欠け [np.int64(76)]')
  ('20261019_192438', 'grip近傍 159 欠け [np.int64(158)]')
  ('20261019_192539', 'grip近傍 74 欠け [np.int64(73)]')
  ('20261019_192539', 'grip近傍 160 欠け [np.int64(159)]')
  ('20261019_192633', 'grip近傍 73 欠け [np.int64(72)]')
  ('20261019_192633', 'grip近傍 159 欠け [np.int64(158)]')
  ('20261019_193404', 'grip近傍 91 欠け [np.int64(90)]')
  ('20261019_193404', 'grip近傍 186 欠け [np.int64(185)]')
  ('20261019_193459', 'grip近傍 83 欠け [np.int64(82)]')
  ('20261019_193459', 'grip近傍 191 欠け [np.int64(190)]')
  ('20261019_193617', 'grip近傍 84 欠け [np.int64(83)]')
  ('20261019_193617', 'grip近傍 173 欠け [np.int64(172)]')
  ('20261019_193825', 'grip近傍 94 欠け [np.int64(93)]')
  ('20261019_193825', 'grip近傍 202 欠け [np.int64(201)]')
  ('20261019_193932', 'grip近傍 80 欠け [np.int64(79)]')
  ('20261019_193932', 'grip近傍 169 欠け [np.int64(168)]')
  ('20261019_194818', 'grip近傍 73 欠け [np.int64(72)]')
  ('20261019_194818', 'grip近傍 140 欠け [np.int64(139)]')
  ('20261019_194913', 'grip近傍 74 欠け [np.int64(73)]')
  ('20261019_194913', 'grip近傍 140 欠け [np.int64(139)]')
  ('20261019_195023', 'grip近傍 71 欠け [np.int64(70)]')
  ('20261019_195023', 'grip近傍 137 欠け [np.int64(136)]')
  ('20261019_195120', 'grip近傍 70 欠け [np.int64(69)]')
  ('20261019_195120', 'grip近傍 136 欠け [np.int64(135)]')

報告と1か所、食い違いがありました。Claude Code は「38回すべてで i−1, i, i+1 が残っている」と報告しています。これは、state のグリッパーが変わったステップを「切り替え」と数えた結果です。codex が指摘した「指令(action)が切り替わったステップ」で数えると、その直前の1ステップは、38回すべてで削られたままでした。183454 の閉じる場面で確かめました。

72 state_g 0.0 act_g 0.0 残る [-36.38  79.24   9.25   3.63 -87.39 -29.25]
73 state_g 0.0 act_g 0.0 残る [-36.38  80.65   8.43   3.56 -87.59 -29.25]
74 state_g 0.0 act_g 0.0 残る [-36.38  81.56   7.91   3.51 -87.71 -29.26]
75 state_g 0.0 act_g 0.0 残る [-36.38  81.56   7.91   3.51 -87.71 -29.26]
76 state_g 0.0 act_g 0.0 削除 [-36.38  81.56   7.91   3.51 -87.71 -29.26]
77 state_g 0.0 act_g 0.0 削除 [-36.38  81.56   7.91   3.51 -87.71 -29.26]
78 state_g 0.0 act_g 0.0 削除 [-36.38  81.56   7.91   3.51 -87.71 -29.26]
79 state_g 0.0 act_g 0.0 削除 [-36.38  81.56   7.91   3.51 -87.71 -29.26]
80 state_g 0.0 act_g 0.0 削除 [-36.38  81.56   7.91   3.51 -87.71 -29.26]
81 state_g 0.0 act_g 0.0 削除 [-36.38  81.56   7.91   3.51 -87.71 -29.28]
82 state_g 0.0 act_g 0.0 削除 [-36.38  81.56   7.91   3.51 -87.71 -29.31]
83 state_g 0.0 act_g 1.0 残る [-36.38  81.56   7.91   3.51 -87.71 -29.34]
84 state_g 1.0 act_g 1.0 残る [-36.38  81.56   7.91   3.51 -87.72 -29.36]
85 state_g 1.0 act_g 1.0 残る [-36.38  81.56   7.91   3.5  -87.74 -29.37]
86 state_g 1.0 act_g 1.0 削除 [-36.38  81.56   7.91   3.5  -87.77 -29.38]
87 state_g 1.0 act_g 1.0 削除 [-36.38  81.56   7.91   3.49 -87.79 -29.4 ]
trim2 で 83 の前後に残った元のステップ: [72, 73, 74, 75, 83, 84, 85, 104]

閉じる指令は元の 83 ステップ目で、直前の 82 は削られています。ただ、82 は 74〜81 と姿勢がまったく同じ(関節の角度の差は 0.1° 未満)で、trim2 には 74・75 が 83 の直前に残っています。「つかむ直前の、止まって落ち着いた姿勢」は学習データに入っているので、実害はないと判断して、このまま学習に進めました。

学習の前に、データをもう1回点検しました。この点検は、あとで作った点検スクリプト check_dataset.py(後述)でも同じ結果になっています。

学習用の Pod で、trim2 を変換して学習を始めた

学習には GPU のメモリが 22.5GB 以上要るので、Runpod で A40(48GB)を借りました。

項目値
GPUNVIDIA A40 ×1
CPU・メモリ
9 vCPU(Intel Xeon Gold 6342 @ 2.80GHz)・50GB
Container Disk200GB
場所
CA-MTL-1・Secure Cloud
テンプレート
runpod/pytorch:1.4.0-cu1281-torch280-ubuntu2404
料金
計算 $0.59/時 + ディスク $0.028/時 = $0.62/時

前の回は A40 の空きを待って起動まで約4時間半かかりましたが、この回は数分で起動しました。準備の手順は前の回と同じです。学習設定は、前の回の add_teach_config.py と同じ方式で、データ名だけ trim2 に変えた add_trim_config.py を作りました。

#!/usr/bin/env python3
"""openpi config.py に teach_v1_trim2 用の学習設定 pi05_mecharm_trim_lora を足す (lab Log11).

install_addon.py を実行した後に 1 回実行 (add_teach_config.py とは独立. 両方入れてもよい):
  python3 add_trim_config.py /workspace/openpi
pi05_mecharm_lora (v1) の設定をそのまま写し、次の 3 か所だけ変える (add_teach_config.py と同じ方式):
  name            pi05_mecharm_lora             -> pi05_mecharm_trim_lora
  repo_id         local/mecharm_redblock_box_v1 -> local/mecharm_redblock_box_teach_v1_trim2
  num_train_steps 5_000                         -> 4_000
学習率の予定 (decay_steps=5_000) は v1・teach_v1 と同じ. データ以外の条件を teach_v1 (Log10) と揃える.
"""
import sys
from pathlib import Path

cfg = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve() / "src" / "openpi" / "training" / "config.py"
s = cfg.read_text()
MARK = "    # >>> r2r2r mecharm teach_v1_trim2 (train config)\n"
if MARK in s:
    print("already installed (config.py は変更なし)")
    sys.exit(0)

A = "    # >>> r2r2r mecharm addon (train config)\n"
B = "    # <<< r2r2r mecharm addon (train config)\n"
assert s.count(A) == 1 and s.count(B) == 1, "先に install_addon.py を実行する"
i, j = s.index(A), s.index(B) + len(B)
block = s[i:j]
reps = [
    (A, MARK),
    (B, "    # <<< r2r2r mecharm teach_v1_trim2 (train config)\n"),
    ('name="pi05_mecharm_lora"', 'name="pi05_mecharm_trim_lora"'),
    ('repo_id="local/mecharm_redblock_box_v1"', 'repo_id="local/mecharm_redblock_box_teach_v1_trim2"'),
    ("num_train_steps=5_000,", "num_train_steps=4_000,"),
]
for a, b in reps:
    assert block.count(a) == 1, f"見つからない/重複: {a!r}"
    block = block.replace(a, b)
assert "decay_steps=5_000" in block
s = s[:j] + block + s[j:]
cfg.write_text(s)
print("installed: pi05_mecharm_trim_lora (repo_id=local/mecharm_redblock_box_teach_v1_trim2, 4,000 steps)")

openpi を模した仮のフォルダで、前の回の設定と並べて入れても二重に入らないことと、Python の文法として正しいことを確かめています。

installed: policies/mecharm_policy.py + config.py (pi05_mecharm_lora)
installed: pi05_mecharm_teach_lora (repo_id=local/mecharm_redblock_box_teach_v1, 4,000 steps)
installed: pi05_mecharm_trim_lora (repo_id=local/mecharm_redblock_box_teach_v1_trim2, 4,000 steps)
already installed (config.py は変更なし)
48:        name="pi05_mecharm_lora",
56:            repo_id="local/mecharm_redblock_box_v1",
74:        num_train_steps=5_000,  # 成功 20 本 (約 6,000 フレーム) で約 27 周
83:        name="pi05_mecharm_trim_lora",
91:            repo_id="local/mecharm_redblock_box_teach_v1_trim2",
109:        num_train_steps=4_000,  # 成功 20 本 (約 6,000 フレーム) で約 27 周
構文OK

最初の変換は、データが Pod に届き切る前に走っていました。正規化の値を計算する段で、Pod の中にデータが見つからず、Hugging Face のサイトに探しに行って失敗しています。

  File "/workspace/openpi/.venv/lib/python3.11/site-packages/huggingface_hub/utils/_validators.py", line 114, in _inner_fn
    return fn(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^
  File "/workspace/openpi/.venv/lib/python3.11/site-packages/huggingface_hub/hf_api.py", line 3200, in list_repo_refs
    hf_raise_for_status(response)
  File "/workspace/openpi/.venv/lib/python3.11/site-packages/huggingface_hub/utils/_http.py", line 459, in hf_raise_for_status
    raise _format(RepositoryNotFoundError, message, response) from e
huggingface_hub.errors.RepositoryNotFoundError: 401 Client Error. (Request ID: Root=1-6ad8002d-7b19f58317a1cdb761b4f5b4;5d5eea2f-95c8-4587-b2cc-4cbe8e90cc70)

Repository Not Found for url: https://huggingface.co/api/datasets/local/mecharm_redblock_box_teach_v1_trim2/refs.
Please make sure you specified the correct `repo_id` and `repo_type`.
If you are trying to access a private or gated repo, make sure you are authenticated. For more details, see https://huggingface.co/docs/huggingface_hub/authentication
Invalid username or password.
root@a890930c92f7:/workspace/openpi#
root@a890930c92f7:/workspace/openpi# ls ~/.cache/huggingface/lerobot/local/ 2>&1 
ls /workspace/data/raw_episodes | wc -l
ls: cannot access '/root/.cache/huggingface/lerobot/local/': No such file or directory
ls: cannot access '/workspace/data/raw_episodes': No such file or directory
0
root@a890930c92f7:/workspace/openpi# ls -la /workspace/data /workspace/r2r2r
/workspace/data:
total 71744
drwxr-xr-x 2 root root       35 Oct 20 23:58 .
drwxr-xr-x 1 root root       95 Oct 20 23:57 ..
-rw-r--r-- 1 root root 73463808 Oct 20 23:59 trim2_raw.tar

/workspace/r2r2r:
total 8
drwxr-xr-x 3 root root   56 Oct 20 23:58 .
drwxr-xr-x 1 root root   95 Oct 20 23:57 ..
-rwx------ 1 root root 1966 Oct 20 23:58 add_trim_config.py
drwxr-xr-x 4 root root 4096 Oct 20 23:58 pi05
root@a890930c92f7:/workspace/openpi#

Mac の tar と大きさを比べて、届き切っていることを確かめました。

(base) shimazueiichi@shimadzueiichinoMacBook-Pro mecharm_redblock_box_teach_v1_trim2 % ls -l ~/trim2_raw.tar
-rw-r--r--  1 shimazueiichi  staff  73463808 10月 21 08:58 /Users/shimazueiichi/trim2_raw.tar
(base) shimazueiichi@shimadzueiichinoMacBook-Pro mecharm_redblock_box_teach_v1_trim2 %

展開してから、変換と正規化の値の計算をやり直しました。

cd /workspace/data && tar xf trim2_raw.tar && ls raw_episodes | wc -l
cd /workspace/openpi
uv run python /workspace/r2r2r/pi05/convert_to_lerobot.py --raw-dir /workspace/data/raw_episodes --repo-id local/mecharm_redblock_box_teach_v1_trim2
uv run scripts/compute_norm_stats.py --config-name pi05_mecharm_trim_lora
Map: 100%|███████████████████████████| 123/123 [00:00<00:00, 1274.38 examples/s]
Creating parquet from Arrow format: 100%|████████| 2/2 [00:00<00:00, 155.15ba/s]
  run_0001_20261019_194818: 123 steps
Map: 100%|████████████████████████████| 126/126 [00:00<00:00, 930.03 examples/s]
Creating parquet from Arrow format: 100%|████████| 2/2 [00:00<00:00, 154.24ba/s]
  run_0001_20261019_194913: 126 steps
Map: 100%|███████████████████████████| 123/123 [00:00<00:00, 1216.17 examples/s]
Creating parquet from Arrow format: 100%|████████| 2/2 [00:00<00:00, 129.94ba/s]
  run_0001_20261019_195023: 123 steps
Map: 100%|███████████████████████████| 120/120 [00:00<00:00, 1261.31 examples/s]
Creating parquet from Arrow format: 100%|████████| 2/2 [00:00<00:00, 118.76ba/s]
  run_0001_20261019_195120: 120 steps
done: 19 episodes / 2717 frames → /workspace/.cache/huggingface/lerobot/local/mecharm_redblock_box_teach_v1_trim2
Resolving data files: 100%|█████████████████| 19/19 [00:00<00:00, 194845.42it/s]
Downloading data: 100%|███████████████████| 19/19 [00:00<00:00, 94871.16files/s]
Generating train split: 2717 examples [00:00, 3731.74 examples/s]
Computing stats: 100%|██████████████████████████| 84/84 [00:22<00:00,  3.76it/s]
Writing stats to: /workspace/openpi/assets/pi05_mecharm_trim_lora/local/mecharm_redblock_box_teach_v1_trim2
root@a890930c92f7:/workspace/openpi#

学習を始めました。条件はデータ以外、前の回と同じです(4,000 ステップ、batch 32、学習率の予定も同じ)。

XLA_PYTHON_CLIENT_MEM_FRACTION=0.9 uv run scripts/train.py pi05_mecharm_trim_lora --exp-name=trim2_v1 --overwrite 2>&1 | tee /workspace/logs/train.log

tmux の別の画面で、GPU の様子を30秒おきに記録しました。最初の5分ほどは、π0.5 の元の重みのダウンロードとコンパイルで、使用率が 0% のままです。

2026/10/21 00:04:58.141, 43976 MiB, 0 %
2026/10/21 00:05:28.141, 43976 MiB, 0 %
2026/10/21 00:05:58.142, 43976 MiB, 0 %
2026/10/21 00:06:28.142, 44006 MiB, 100 %
2026/10/21 00:06:58.143, 44024 MiB, 0 %
2026/10/21 00:07:28.143, 44024 MiB, 0 %
2026/10/21 00:07:58.145, 44032 MiB, 100 %
2026/10/21 00:08:28.146, 44032 MiB, 100 %
2026/10/21 00:08:58.147, 44032 MiB, 100 %
2026/10/21 00:09:28.147, 44032 MiB, 100 %
2026/10/21 00:09:58.148, 44032 MiB, 100 %
2026/10/21 00:10:28.148, 44032 MiB, 100 %
2026/10/21 00:10:58.149, 44032 MiB, 100 %
2026/10/21 00:11:28.150, 44032 MiB, 100 %

時刻は Pod の時計(世界標準時)で、日本時間では 9 時台です。この画面を見ている間にも、Mac のターミナルがもう一度固まり、強制終了しています。学習は Pod の tmux の中で動いているので、止まっていません。Mac から1行で学習の記録をのぞきました。

Last login: Wed Oct 21 09:11:08 on ttys002
(base) shimazueiichi@shimadzueiichinoMacBook-Pro ~ % ssh -p <SSH_PORT> -i ~/.ssh/id_ed25519 root@<POD_IP> "grep -a 'Step' /workspace/logs/train.log | tail -5"
Step 0: grad_norm=0.9165, loss=0.1104, param_norm=1803.7705
(base) shimazueiichi@shimadzueiichinoMacBook-Pro ~ % ssh -p <SSH_PORT> -i ~/.ssh/id_ed25519 root@<POD_IP> "tail -c 1500 /workspace/logs/train.log"
s on: 24.0it/4.00kit rate:6.6s/it remaining:7:16:53 elapsed:06:53 postfix:- (3037:tqdm_logging.py:145)
00:10:40.476 [I] Progress on: 26.0it/4.00kit rate:6.6s/it remaining:7:16:08 elapsed:07:07 postfix:- (3037:tqdm_logging.py:145)
00:10:53.651 [I] Progress on: 28.0it/4.00kit rate:6.6s/it remaining:7:15:59 elapsed:07:20 postfix:- (3037:tqdm_logging.py:145)
00:11:06.817 [I] Progress on: 30.0it/4.00kit rate:6.6s/it remaining:7:15:38 elapsed:07:33 postfix:- (3037:tqdm_logging.py:145)
00:11:19.995 [I] Progress on: 32.0it/4.00kit rate:6.6s/it remaining:7:15:37 elapsed:07:46 postfix:- (3037:tqdm_logging.py:145)
00:11:33.155 [I] Progress on: 34.0it/4.00kit rate:6.6s/it remaining:7:15:12 elapsed:07:59 postfix:- (3037:tqdm_logging.py:145)
00:11:46.314 [I] Progress on: 36.0it/4.00kit rate:6.6s/it remaining:7:14:49 elapsed:08:12 postfix:- (3037:tqdm_logging.py:145)
00:11:59.483 [I] Progress on: 38.0it/4.00kit rate:6.6s/it remaining:7:14:40 elapsed:08:26 postfix:- (3037:tqdm_logging.py:145)
00:12:12.646 [I] Progress on: 40.0it/4.00kit rate:6.6s/it remaining:7:14:25 elapsed:08:39 postfix:- (3037:tqdm_logging.py:145)
00:12:25.830 [I] Progress on: 42.0it/4.00kit rate:6.6s/it remaining:7:14:33 elapsed:08:52 postfix:- (3037:tqdm_logging.py:145)
00:12:38.993 [I] Progress on: 44.0it/4.00kit rate:6.6s/it remaining:7:14:06 elapsed:09:05 postfix:- (3037:tqdm_logging.py:145)
00:12:52.185 [I] Progress on: 46.0it/4.00kit rate:6.6s/it remaining:7:14:18 elapsed:09:18 postfix:- (3037:tqdm_logging.py:145)
(base) shimazueiichi@shimadzueiichinoMacBook-Pro ~ %

1ステップ 6.6 秒(A40)で、残り約7時間14分、16時半ごろに終わる見込みです。最初の loss は 0.1104 で、前の回(teach_v1・0.0858)と違うのは、データの中身が変わったためです。データ全体を回る回数は、前の回の約32周から約49周に増えます(4,000 ステップ × batch 32 ÷ 2,717 ステップ)。

上カメラの写り方が、実機を動かした回だけお手本と違っていた

記事にするために、AI に渡した画像とお手本の画像を並べたところで、写り方の違いに気づきました。

左からお手本(10/19)、8:25 のドライラン、8:26 の実機の回の最初のコマ。黒い横線は同じ高さ。実機の回だけマットの上の端とアームの土台が写り、ブロックと箱が下にずれている

8:26 の実機の回だけ、マットの上の端とアームの土台が写り、ブロックと箱が下にずれています。1分前のドライランは、お手本と同じ写り方でした。マットの左端の列(x=70〜90)で、上から最初に暗くなる行を、データセットと実行の記録ごとに数えました。

# 上カメラの画像で、マットの上の端が何行目に写っているか (左端 x=70〜90 の列で、上から最初に暗くなる行) (LOG358)
import cv2, numpy as np, glob, os
ROOT = os.environ.get("R2R2R_DATA", "/Volumes/KIOXIA/r2r2r-data")
os.chdir(ROOT)
def top(p):
    g = cv2.imread(p, 0).astype(float); col = g[:, 70:90].mean(1); d = np.where(col < 80)[0]
    return int(d.min()) if len(d) else -1
for r in sorted(glob.glob("datasets/mecharm_redblock_box_scripted_v1/raw_episodes/*"))[:20:3]:
    fs = sorted(glob.glob(r + "/frames/*.jpg")); print("scripted", r[-15:], [top(p) for p in fs[::40]])
for r in sorted(glob.glob("datasets/mecharm_redblock_box_teach_v1/raw_episodes/*")):
    fs = sorted(glob.glob(r + "/frames/*.jpg")); print("teach", r[-15:], [top(p) for p in fs[::40]])
for d in sorted(glob.glob("runs/pi05_policy/pi05_2026101[57]*")) + sorted(glob.glob("runs/pi05_policy/pi05_20261021*")):
    fs = sorted(glob.glob(d + "/frames/*.jpg"))
    if fs: print("run", d[-15:], [top(p) for p in fs[::5]])
scripted 20261015_173315 [21, 22, 22, 22, 22, 22, 22, 22]
scripted 20261015_182056 [20, 20, 20, 21, 21, 21, 21]
scripted 20261015_185536 [21, 20, 20, 20, 21, 21, 20]
scripted 20261015_194420 [18, 20, 20, 20, 20, 19, 20, 19]
scripted 20261016_180013 [18, 18, 18, 18, 18, 18, 18]
scripted 20261016_190925 [18, 18, 18, 18, 18, 18]
scripted 20261016_192229 [17, 18, 18, 18, 18, 18, 18, 18]
teach 20261019_183454 [0, 0, 0, 0, 0, 0]
teach 20261019_191257 [0, 1, 1, 1, 1, 0]
teach 20261019_191347 [0, 0, 1, 1, 1, 0]
teach 20261019_191441 [0, 0, 0, 1, 1, 1, 0]
teach 20261019_191541 [0, 0, 0, 0, 1, 1]
teach 20261019_191635 [0, 0, 1, 1, 1, 1]
teach 20261019_192343 [0, 1, 1, 1, 1]
teach 20261019_192438 [0, 1, 1, 1, 1, 0]
teach 20261019_192539 [0, 1, 1, 1, 1, 0]
teach 20261019_192633 [0, 1, 1, 1, 1]
teach 20261019_193404 [0, 1, 0, 0, 1, 1]
teach 20261019_193459 [0, 1, 0, 0, 1, 1]
teach 20261019_193617 [0, 0, 0, 0, 1, 1]
teach 20261019_193825 [0, 0, 0, 0, 0, 1, 1]
teach 20261019_193932 [0, 0, 0, 0, 1, 1]
teach 20261019_194818 [0, 1, 1, 1, 1]
teach 20261019_194913 [0, 1, 1, 1, 1]
teach 20261019_195023 [0, 1, 0, 1, 1]
teach 20261019_195120 [0, 1, 0, 1, 1]
run 20261015_193023 [19]
run 20261017_072035 [22]
run 20261017_072306 [21]
run 20261017_172323 [17]
run 20261017_172526 [17, 17, 17]
run 20261017_172828 [17, 17]
run 20261017_173054 [17, 17]
run 20261017_174533 [16]
run 20261017_174600 [16, 16]
run 20261017_175126 [15]
run 20261017_175143 [15]
run 20261017_175214 [15, 15]
run 20261017_175413 [13]
run 20261017_175438 [13, 13]
run 20261017_221600 [23]
run 20261017_221710 [22]
run 20261017_222053 [22, 23, 23]
run 20261017_222723 [22, 23, 23]
run 20261021_082212 [0]
run 20261021_082423 [0]
run 20261021_082512 [0]
run 20261021_082645 [18, 18, 18, 18]

写り方は大きく2通りあります。

記録マットの上端の行
前のお手本 scripted_v1(10/15〜16)
17〜22
10/17 の実機(LOG345 の回)
13〜23
今回のお手本 teach_v1(10/19)
0〜1
8:22・8:24・8:25 のドライランと HOME 戻し
0
8:26 の実機の回18

画像全体のずれも、cv2.phaseCorrelate(2枚の画像を重ねて、どれだけずらせば合うかを測る)で測りました。基準は teach_v1 の 183454 の最初のコマです。

# 上カメラの画像全体のずれを cv2.phaseCorrelate で測る。基準は teach_v1 の 183454 の最初のコマ (LOG358)
import cv2, numpy as np, os
ROOT = os.environ.get("R2R2R_DATA", "/Volumes/KIOXIA/r2r2r-data")
os.chdir(ROOT)
T = "datasets/mecharm_redblock_box_teach_v1/raw_episodes/"
def g(p): return cv2.imread(p, 0).astype(np.float32)
ref = g(T + "run_0001_20261019_183454/frames/000000.jpg")
win = cv2.createHanningWindow(ref.shape[::-1], cv2.CV_32F)
for name, p in [("teach 191257 t0", T + "run_0001_20261019_191257/frames/000000.jpg"),
                ("teach 195120 t0", T + "run_0001_20261019_195120/frames/000000.jpg"),
                ("today 082512 HOME dry", "runs/pi05_policy/pi05_20261021_082512/frames/0000.jpg"),
                ("today 082645 exec", "runs/pi05_policy/pi05_20261021_082645/frames/0000.jpg"),
                ("10/17 222723", "runs/pi05_policy/pi05_20261017_222723/frames/0000.jpg")]:
    (dx, dy), r = cv2.phaseCorrelate(ref, g(p), win)
    print(f"{name:24s} dx={dx:+6.1f} dy={dy:+6.1f} response={r:.2f}")
teach 191257 t0          dx=  +5.0 dy=  +9.1 response=0.46
teach 195120 t0          dx=  +5.8 dy=  -6.7 response=0.56
today 082512 HOME dry    dx=  +2.8 dy=  -7.8 response=0.58
today 082645 exec        dx=  -1.9 dy= +37.5 response=0.45
10/17 222723             dx=  -4.3 dy=  +8.0 response=0.05

お手本どうしでも、ブロックの位置の違いで上下に 7〜9 ピクセル揺れます。8:26 の実機の回は 37.5 ピクセルで、はっきり外れています。画像の高さは 216 ピクセルなので、2割近いずれです。

上のカメラは、普段使いの iPhone を毎回付け外しして取り付けています。付けるたびに写り方が変わるのは自然なことで、8:25 と 8:26 の間にも何かで位置が変わったと考えています。この回のモデルは、学習で見たことのない写り方の画像を見ながら動いていました。 ブロックの近くで止まったことはお手本の停止で説明がつきますが、手を伸ばした先がずれていた可能性もあります。この2つは、この回の記録だけでは切り分けられません。

実機で通しで動かせていない理由を、これまでの3回で並べた

π0.5 を実機で動かした回を並べると、止まった原因は毎回お手本の側にあり、しかも8時間学習して実機で動かすまで分からない作りになっていました。

回実機で起きたこと原因いつ分かったはずか
Log#6(LOG345)
ブロックの真上まで来て、爪がブロックに乗って止まった
つかむ位置が爪1本分ずれていた
実機で初めて分かった
Log#7(LOG348)
4,000 ステップ学習しても、爪が奥へずれた
お手本のブロックがほぼ同じ場所で、画像を見ずに「いつもの場所」へ手を伸ばしていた
お手本を集めた時点
Log#11(この回)
ブロックの近くで止まり、動かなくなった
お手本の 40% が停止で、真上で 1.5〜2 秒止まっていた。カメラの写り方もお手本と違っていた
停止はお手本を集めた時点(数えれば1分)。写り方は実機を動かす前

1つの原因を見つけて直すたびに、「直す → 学習し直す(約8時間)→ 実機で試す」を1回まわすので、1つにほぼ1日かかっています。そのうえ、GPU の空き待ち(前の回は約4時間半)と、Mac と Pod の間のモデルの転送(この回は40分)が、毎回つきます。

そこで、次の4つを足しました。

足したものいつ使うか見つけるもの
check_dataset.py(お手本の点検)
学習の前
停止の長さと割合、ブロックの位置のばらつき、グリッパーの切り替え、肩 j1 が 88° を超える姿勢、正解と次の角度の食い違い
eval_policy_offline.py(お手本との答え合わせ)
学習のあと、実機の前
お手本の画像と角度をサーバーに送り、予測が「お手本は動くのに止まる」場面がないか
run_policy_client.py の肩の上限と送る前の検査
実機で動かすとき
88° を超える肩の指令(アームが黙って捨てる。LOG352)と、安全範囲の外への指令を、送らずに止める
上カメラの写り方の確認実機で動かす前
お手本と写り方が違えば、アームを動かさずに止める。iPhone を付けたあとに位置を合わせる画面も作る

もう1つ、段取りを変えます。学習が終わったら、モデルを Mac に持ち帰る前に、学習に使った Pod でそのまま推論サーバーを起動して、実機で試します。モデルを送る往復(約1時間)がなくなります。

学習の前の点検 check_dataset.py は、teach_v1 で FAIL、trim2 で PASS になった

check_dataset.py は Claude Code が書きました。学習に出すお手本を読むだけで、FAIL が1つでもあれば終了コード 1 を返します。肩の上限 88° は、本線の fk_lookup.py の値をそのまま読み込んでいます。teach_v1 と trim2 で動かした結果です(Claude Code の報告から)。

A と D を入れて commit しました。codex レビューで必須 1 
  件が出ています(未反映)。

  A. pi05/check_dataset.py(新規・読むだけ)

  python3 pi05/check_dataset.py <raw_episodes> で 6 項目を PASS/WARN/FAIL
  表示、FAIL があれば終了コード 1。書き込み系の呼び出しは無し(grep
  済み)。J1_SAFE_MAX は本線 fk_lookup.py から import(二重定義しない)、停止の
  0.3° は build_demo_episodes.py の MID_IDLE_EPS_DEG と同値。

  teach_v1 の出力(全文)— 停止で FAIL・終了コード 1

  [DATA  ]
  /Volumes/KIOXIA/r2r2r-data/datasets/mecharm_redblock_box_teach_v1/raw_episodes
           19 本 / 3949 ステップ / 394.9 秒相当 / fps 10

  [PASS] 1. そろい方 (画像の枚数・画像サイズ・meta の task)
           task 'put the red block in the white box' (19 本すべて同じ)
           画像サイズ 384x216 (19 本すべて同じ)
           画像の枚数 = ステップ数 (19 本すべて一致)

  [FAIL] 2. 停止 (連続 1.0 秒超で FAIL / 割合 20% 超で WARN)
           全体 40.4% (1594/3949 ステップ) / いちばん長い連続 2.0 秒 (上限 1.0 秒
  = action_horizon 10 x 0.1 秒)
           [WARN] 全体の停止が 40.4% で 20% を超えている
  (停止を『動作』として覚える. build_demo_episodes.py --trim-mid-idle で詰める)
           [FAIL] run_0001_20261019_183454: 停止 37.0% ( 77/208)  最長 2.0 秒 (20
  ステップ)
           [FAIL] run_0001_20261019_191257: 停止 37.1% ( 76/205)  最長 1.9 秒 (19
  ステップ)
           [FAIL] run_0001_20261019_191347: 停止 40.4% ( 84/208)  最長 1.9 秒 (19
  ステップ)
           [FAIL] run_0001_20261019_191441: 停止 44.0% (106/241)  最長 2.0 秒 (20
  ステップ)
           [FAIL] run_0001_20261019_191541: 停止 43.6% ( 92/211)  最長 2.0 秒 (20
  ステップ)
           [FAIL] run_0001_20261019_191635: 停止 44.4% ( 95/214)  最長 1.9 秒 (19
  ステップ)
           [FAIL] run_0001_20261019_192343: 停止 38.0% ( 76/200)  最長 2.0 秒 (20
  ステップ)
           [FAIL] run_0001_20261019_192438: 停止 41.6% ( 84/202)  最長 2.0 秒 (20
  ステップ)
           [FAIL] run_0001_20261019_192539: 停止 40.9% ( 83/203)  最長 1.9 秒 (19
  ステップ)
           [FAIL] run_0001_20261019_192633: 停止 39.4% ( 78/198)  最長 2.0 秒 (20
  ステップ)
           [FAIL] run_0001_20261019_193404: 停止 36.4% ( 83/228)  最長 1.9 秒 (19
  ステップ)
           [FAIL] run_0001_20261019_193459: 停止 41.5% ( 97/234)  最長 2.0 秒 (20
  ステップ)
           [FAIL] run_0001_20261019_193617: 停止 37.0% ( 80/216)  最長 2.0 秒 (20
  ステップ)
           [FAIL] run_0001_20261019_193825: 停止 43.7% (107/245)  最長 1.9 秒 (19
  ステップ)
           [FAIL] run_0001_20261019_193932: 停止 36.8% ( 78/212)  最長 1.9 秒 (19
  ステップ)
           [FAIL] run_0001_20261019_194818: 停止 41.5% ( 76/183)  最長 2.0 秒 (20
  ステップ)
           [FAIL] run_0001_20261019_194913: 停止 41.5% ( 76/183)  最長 1.9 秒 (19
  ステップ)
           [FAIL] run_0001_20261019_195023: 停止 40.6% ( 73/180)  最長 1.9 秒 (19
  ステップ)
           [FAIL] run_0001_20261019_195120: 停止 41.0% ( 73/178)  最長 1.9 秒 (19
  ステップ)

  [PASS] 3. グリッパーの切り替え (各エピソードで「閉 → 開」の 2 回)
           19 本すべて 閉 → 開 の 2 回

  [PASS] 4. 肩 j1 <= 88° / j4 は J4_SAFE [-90, +60] の中
           j1 の最大 +87.89° (run_0001_20261019_191635 state t=64)
           j4 の範囲 -88.15° (run_0001_20261019_183454 actions t=103) .. +0.43°
  (run_0001_20261019_191257 state t=11)

  [PASS] 5. ブロックの位置のばらつき (つかんだ瞬間の j0 を 5° 刻み)
           j0    -60° 付近:  5 本  26.3%  #####
           j0    -35° 付近:  6 本  31.6%  ######
           j0    -30° 付近:  2 本  10.5%  ##
           j0    -25° 付近:  2 本  10.5%  ##
           j0    +15° 付近:  4 本  21.1%  ####

  [PASS] 6. |action[t] - state[t+1]| の最大 (関節 6 列. 3.5° 超で WARN)
           全体の最大 2.27° (run_0001_20261019_191441 t=77 j4)

  [RESULT] 1.PASS  2.FAIL  3.PASS  4.PASS  5.PASS  6.PASS
  [RESULT] FAIL 1 項目 / WARN 0 項目 — このまま学習に出さない
  ===== 終了コード: 1 =====

  teach_v1_trim2 の出力(全文)— 全項目 PASS・終了コード 0

  [DATA  ] /Volumes/KIOXIA/r2r2r-data/datasets/mecharm_redblock_box_teach_v1_trim
  2/raw_episodes
           19 本 / 2717 ステップ / 271.7 秒相当 / fps 10

  [PASS] 1. そろい方 (画像の枚数・画像サイズ・meta の task)
           task 'put the red block in the white box' (19 本すべて同じ)
           画像サイズ 384x216 (19 本すべて同じ)
           画像の枚数 = ステップ数 (19 本すべて一致)

  [PASS] 2. 停止 (連続 1.0 秒超で FAIL / 割合 20% 超で WARN)
           全体 9.6% (261/2717 ステップ) / いちばん長い連続 0.4 秒 (上限 1.0 秒 =
  action_horizon 10 x 0.1 秒)
           run_0001_20261019_183454: 停止  6.9% ( 10/144)  最長 0.2 秒 (2
  ステップ)
           run_0001_20261019_191257: 停止  7.5% ( 11/147)  最長 0.2 秒 (2
  ステップ)
           run_0001_20261019_191347: 停止 11.1% ( 16/144)  最長 0.4 秒 (4
  ステップ)
           run_0001_20261019_191441: 停止 12.3% ( 20/163)  最長 0.2 秒 (2
  ステップ)
           run_0001_20261019_191541: 停止 11.5% ( 16/139)  最長 0.2 秒 (2
  ステップ)
           run_0001_20261019_191635: 停止 10.1% ( 14/138)  最長 0.2 秒 (2
  ステップ)
           run_0001_20261019_192343: 停止  7.1% ( 10/140)  最長 0.2 秒 (2
  ステップ)
           run_0001_20261019_192438: 停止 11.6% ( 16/138)  最長 0.2 秒 (2
  ステップ)
           run_0001_20261019_192539: 停止 10.7% ( 15/140)  最長 0.2 秒 (2
  ステップ)
           run_0001_20261019_192633: 停止 10.2% ( 14/137)  最長 0.2 秒 (2
  ステップ)
           run_0001_20261019_193404: 停止  6.7% ( 11/164)  最長 0.2 秒 (2
  ステップ)
           run_0001_20261019_193459: 停止 11.3% ( 18/159)  最長 0.4 秒 (4
  ステップ)
           run_0001_20261019_193617: 停止  9.6% ( 15/156)  最長 0.2 秒 (2
  ステップ)
           run_0001_20261019_193825: 停止 10.9% ( 18/165)  最長 0.2 秒 (2
  ステップ)
           run_0001_20261019_193932: 停止  7.3% ( 11/151)  最長 0.2 秒 (2
  ステップ)
           run_0001_20261019_194818: 停止  8.9% ( 11/123)  最長 0.2 秒 (2
  ステップ)
           run_0001_20261019_194913: 停止 11.1% ( 14/126)  最長 0.2 秒 (2
  ステップ)
           run_0001_20261019_195023: 停止  8.9% ( 11/123)  最長 0.2 秒 (2
  ステップ)
           run_0001_20261019_195120: 停止  8.3% ( 10/120)  最長 0.2 秒 (2
  ステップ)

  [PASS] 3. グリッパーの切り替え (各エピソードで「閉 → 開」の 2 回)
           19 本すべて 閉 → 開 の 2 回

  [PASS] 4. 肩 j1 <= 88° / j4 は J4_SAFE [-90, +60] の中
           j1 の最大 +87.89° (run_0001_20261019_191635 state t=39)
           j4 の範囲 -88.15° (run_0001_20261019_183454 actions t=55) .. +0.43°
  (run_0001_20261019_191257 state t=11)

  [PASS] 5. ブロックの位置のばらつき (つかんだ瞬間の j0 を 5° 刻み)
           j0    -60° 付近:  5 本  26.3%  #####
           j0    -35° 付近:  6 本  31.6%  ######
           j0    -30° 付近:  2 本  10.5%  ##
           j0    -25° 付近:  2 本  10.5%  ##
           j0    +15° 付近:  4 本  21.1%  ####

  [PASS] 6. |action[t] - state[t+1]| の最大 (関節 6 列. 3.5° 超で WARN)
           全体の最大 0.00° (run_0001_20261019_195120 t=0 j0)

  [RESULT] 1.PASS  2.PASS  3.PASS  4.PASS  5.PASS  6.PASS
  [RESULT] 全項目 PASS
  ===== 終了コード: 0 =====

  読み取りとして補足: teach_v1 と trim2 で項目 4・5 の値が同じなのは、trim2
  が同じ 19 本から停止区間だけ詰めたデータだからです(つかんだ瞬間の j0
  も肩の最大も変わらない)。項目 6 が trim2 で 0.00° なのは、--trim-mid-idle が
  action を作り直しているため。teach_v1 の 2.27° は停止詰めのつなぎ目で、3.5°
  には届いていません。

  D. pi05/run_policy_client.py(指定の 3 点のみ)

  - load_envelope() の末尾に hi[1] = min(hi[1], J1_SAFE_MAX)(88.0 は
  fk_lookup.py から import)。実測で確認: teach_v1_trim2 の j1 上限が +97.9° → 
  +88.0°
  - --raw-dir を required=True にし
  DEFAULT_RAW(scripted_v1)を削除。指定なしで実行すると argparse が終了コード 2
  で止まる(確認済み)。scripted_v1 だと j0 下限 -52.5°、trim2 だと -71.7°
  で、ご指摘の通りズレていました
  - [SAFE  ] に 2 行目(88° で切った旨 + 安全範囲の元データのパス)
  - クランプ・移動量上限・J4_SAFE・グリッパーの待ち・Enter 確認・plan_chunk
  は未変更(diff で確認可能)

  あわせて

  - RUNPOD_TRAIN.md 手順 7 に 3 点+コマンドへの --raw-dir 追加。手順 0 にも 
  check_dataset.py の実行を 1 ブロック足しました(指示は手順 7
  だけでしたが、学習前点検が手順書に無いと実行されないため。不要なら外します)
  - eval_policy_offline.py は中身を変えずに commit

  commit

  47d091c — 個別ファイル指定で 4
  ファイルのみ。pi05/build_demo_episodes.py(LOG352
  採否ゲート)は未コミットのまま残しています。作業サマリーは
  logs/20261021_0933.md(未コミット)。

  codex レビュー結果(未反映・R1 が必須)

  codex exec --sandbox read-only で回しました(ファイル変更なし)。

  R1(必須): apply_limits() は「安全範囲でクランプ →
  移動量上限」の順なので、現在角が 88° より上にあると、送る目標が再び 88° 
  を超えます。実データで再現しました:

  現在 j1 = 110.0  予測 j1 = 100.0  →  送る目標 j1 = 90.0  (88° 超)
  notes: ['j1 安全範囲でクランプ +100.0→+88.0', 'j1 移動量 -22.0° を ±20°
  に制限']
  wr._is_valid_angles([0,110,0,0,0,-30]) = True   # JOINT_LIMITS の上限は 123°
  なので読み取りでも弾かれない

  発生条件は「アームを HOME に置き忘れて、肩が 88° より上でたたまれている」状態 —
  まさに手順 7 の注意 2 と同じ場面です。codex の修正案は「apply_limits()
  は変えず、送る直前に target[1] <= J1_SAFE_MAX を検査し、違反なら送信もグリッパ
  ー操作もせず停止」(再クランプは移動量上限を破るため不可)。

  R2(推奨): グリッパー切替チャンクで元の予測が 88° 超だと、肩だけ 88°
  に置き換わった姿勢で開閉する。未到達でも手動モードはグリッパー処理に進む。
  R3(推奨): データの j1 最小が 98° 超だと lo[1] > hi[1] になるが np.clip
  は例外を出さない。load_envelope() で lo <= hi を検査して中止。

  「問題なし」とされた点: 現在 j1 ≤ 88° なら通常経路の最終 j1 も 88° 以下 / HOME
  復帰(j1=0°)は違反しない / --raw-dir 必須化で壊れる他の呼び出しは無い /
  send_coords 追加・J4_SAFE 変更・待ち時間の削除短縮は無い。

  R1 は「送信前の停止ガードを足す」= 実機を動かす経路の変更なので、ご指示の「それ
  以外は変えない」の外に出ます。反映しますか(R3 も 2
  行で入ります)。実機テストの前に R1 は入れておく方が良いと考えています。

teach_v1 は、19本すべてで「いちばん長い停止が1秒を超える」で FAIL になりました。学習の前にこれを動かしていれば、この回の実機テストまでたどり着く前に止まれたことになります。

#!/usr/bin/env python3
"""check_dataset.py — 学習に出す前に、お手本データ (raw_episodes) を点検する (Mac 側・読むだけ).

2026-10-21 (lab Log11 / LOG358): teach_v1 で学習したモデルが、ブロックの真上で止まった.
原因はお手本の側で、停止しているステップが 40% あり、真上で 1.5〜2 秒止まっていた.
action_horizon は 10 (1 秒) なので、1 秒を超える停止は「止まる」という動作として丸ごと覚える.
学習の前に数えれば分かったことなので、毎回の学習前にこれを実行する.

点検する項目 (FAIL が 1 つでもあれば終了コード 1):
  1. そろい方   画像の枚数 = ステップ数 / 画像サイズが全エピソードで同じ / meta の task が同じ   違えば FAIL
  2. 停止       停止ステップの割合と、いちばん長い連続         1.0 秒超で FAIL / 割合 20% 超で WARN
  3. グリッパー 切り替えが「閉 → 開」の 2 回か                 違えば WARN
  4. 肩と j4    j1 が J1_SAFE_MAX 超 / j4 が J4_SAFE の外       どちらも FAIL
  5. ばらつき   つかんだ瞬間の j0 を 5° 刻みで数える           1 か所が半分を超えたら WARN
  6. action     |action[t] - state[t+1]| の最大                 3.5° 超で WARN

データには一切書き込まない (np.load と meta.json の読み取りだけ).

Usage:
  python3 pi05/check_dataset.py /Volumes/KIOXIA/r2r2r-data/datasets/mecharm_redblock_box_teach_v1_trim2/raw_episodes
"""
import argparse
import json
import sys
from collections import Counter
from pathlib import Path

import cv2
import numpy as np

# 肩の上限は本線 fk_lookup.py の値をそのまま使う (二重定義しない. record_teach_demo.py と同じ扱い)
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dynamic_tracking_limits" / "place" / "place_modules"))
from fk_lookup import J1_SAFE_MAX  # noqa: E402  88.0. これを超える指令はアームが黙って捨てる (LOG352)

FPS_DEFAULT = 10                # meta.json の fps が無いときの既定 (build_demo_episodes.py の FPS)
ACTION_HORIZON = 10             # 学習設定の action_horizon (openpi_addon の config と同じ)
STOP_EPS_DEG = 0.3              # build_demo_episodes.py の MID_IDLE_EPS_DEG と同じ「停止」の定義
STOP_RUN_FAIL_SEC = ACTION_HORIZON / FPS_DEFAULT    # 1.0 秒. これを超える連続停止は丸ごと覚える
STOP_RATIO_WARN = 0.20          # 停止ステップの割合がこれを超えたら WARN
J4_SAFE = (-90.0, 60.0)         # run_pick_place.py の J4_SAFE_MIN/MAX
J0_BIN_DEG = 5.0                # つかんだ瞬間の j0 を分ける刻み (eval_policy_offline.py と同じ)
POS_SHARE_WARN = 0.5            # 1 か所がこれを超えたら WARN (LOG348: 同じ場所ばかりで画像を見ずに手を伸ばした)
ACT_STATE_WARN_DEG = 3.5        # |action[t] - state[t+1]| がこれを超えたら WARN
_LEVELS = {"PASS": 0, "WARN": 1, "FAIL": 2}


class Check:
    """1 項目の結果. fail/warn を呼ぶと status がその分だけ悪くなる."""

    def __init__(self, num, title):
        self.num, self.title, self.status, self.lines = num, title, "PASS", []

    def fail(self, msg):
        self._add("FAIL", msg)

    def warn(self, msg):
        self._add("WARN", msg)

    def note(self, msg):
        self.lines.append(("", msg))

    def _add(self, level, msg):
        if _LEVELS[level] > _LEVELS[self.status]:
            self.status = level
        self.lines.append((level, msg))

    def report(self):
        print(f"[{self.status:4s}] {self.num}. {self.title}")
        for level, msg in self.lines:
            print(f"         {('[' + level + '] ') if level else ''}{msg}")


def load_episodes(raw_dir: Path):
    eps = sorted(p.parent for p in raw_dir.glob("*/episode.npz"))
    if not eps:
        sys.exit(f"[ERROR] お手本が見つからない (*/episode.npz): {raw_dir}")
    out = []
    for ep in eps:
        z = np.load(ep / "episode.npz")
        meta_p = ep / "meta.json"
        out.append({
            "name": ep.name,
            "dir": ep,
            "state": np.asarray(z["state"], dtype=np.float64),
            "actions": np.asarray(z["actions"], dtype=np.float64),
            "frames": sorted((ep / "frames").glob("*.jpg")),
            "meta": json.loads(meta_p.read_text()) if meta_p.exists() else None,
        })
    return out


def fps_of(e):
    meta = e["meta"] or {}
    try:
        return float(meta.get("fps") or FPS_DEFAULT)
    except (TypeError, ValueError):
        return float(FPS_DEFAULT)


def stop_mask(e):
    """停止ステップ: 6 関節すべて |action - state| < 0.3° かつ グリッパーの action と state が同じ."""
    s, a = e["state"], e["actions"]
    n = min(len(s), len(a))
    if n == 0 or s.shape[1] < 7 or a.shape[1] < 7:
        return np.zeros(0, dtype=bool)
    moved = np.abs(a[:n, :6] - s[:n, :6]) < STOP_EPS_DEG
    return moved.all(axis=1) & (a[:n, 6] == s[:n, 6])


def longest_run(mask):
    best = cur = 0
    for v in mask:
        cur = cur + 1 if v else 0
        best = max(best, cur)
    return best


def grip_changes(e):
    """グリッパーの切り替え: [(ステップ, '閉' or '開'), ...]."""
    g = (e["state"][:, 6] > 0.5).astype(int)
    return [(int(i) + 1, "閉" if g[i + 1] == 1 else "開") for i in np.where(np.diff(g) != 0)[0]]


def check_shape(eps):
    c = Check(1, "そろい方 (画像の枚数・画像サイズ・meta の task)")
    tasks, metas, imgs = Counter(), Counter(), Counter()
    for e in eps:
        s, a, n_img = e["state"], e["actions"], len(e["frames"])
        if s.ndim != 2 or s.shape[1] != 7 or a.shape != s.shape:
            c.fail(f"{e['name']}: state{s.shape} / actions{a.shape} が (N,7) で揃っていない")
            continue
        if n_img != len(s):
            c.fail(f"{e['name']}: 画像 {n_img} 枚 ≠ ステップ {len(s)}")
        if e["meta"] is None:
            c.fail(f"{e['name']}: meta.json がない")
        else:
            tasks[str(e["meta"].get("task"))] += 1
            metas[tuple(e["meta"].get("image_size") or ())] += 1
        if n_img:
            bgr = cv2.imread(str(e["frames"][0]))
            if bgr is None:
                c.fail(f"{e['name']}: 画像を読めない: {e['frames'][0].name}")
            else:
                imgs[(bgr.shape[1], bgr.shape[0])] += 1

    if len(tasks) > 1:
        c.fail("meta の task が揃っていない: " + " / ".join(f"{t!r} x{n}" for t, n in tasks.most_common()))
    elif tasks:
        c.note(f"task {list(tasks)[0]!r} ({sum(tasks.values())} 本すべて同じ)")
    if len(imgs) > 1:
        c.fail("画像サイズが揃っていない: " + " / ".join(f"{w}x{h} x{n}" for (w, h), n in imgs.most_common()))
    elif imgs:
        (w, h), n = imgs.most_common(1)[0]
        c.note(f"画像サイズ {w}x{h} ({n} 本すべて同じ)")
    if len(metas) > 1:
        c.fail("meta の image_size が揃っていない: " + " / ".join(f"{list(k)} x{n}" for k, n in metas.most_common()))
    elif metas and imgs:
        (w, h) = imgs.most_common(1)[0][0]
        mw, mh = (list(metas)[0] + (None, None))[:2]
        if (mw, mh) != (w, h):
            c.fail(f"meta の image_size {[mw, mh]} が実際の画像 {w}x{h} と違う")
    if c.status == "PASS":
        c.note(f"画像の枚数 = ステップ数 ({len(eps)} 本すべて一致)")
    return c


def check_stop(eps):
    c = Check(2, f"停止 (連続 {STOP_RUN_FAIL_SEC:.1f} 秒超で FAIL / 割合 {STOP_RATIO_WARN:.0%} 超で WARN)")
    n_stop = n_all = 0
    rows = []
    for e in eps:
        m = stop_mask(e)
        fps = fps_of(e)
        run = longest_run(m)
        ratio = float(m.mean()) if len(m) else 0.0
        n_stop += int(m.sum())
        n_all += len(m)
        rows.append((e["name"], ratio, int(m.sum()), len(m), run, run / fps))
    overall = n_stop / n_all if n_all else 0.0
    c.note(f"全体 {overall:.1%} ({n_stop}/{n_all} ステップ) / "
           f"いちばん長い連続 {max((r[5] for r in rows), default=0.0):.1f} 秒 "
           f"(上限 {STOP_RUN_FAIL_SEC:.1f} 秒 = action_horizon {ACTION_HORIZON} x {1.0 / FPS_DEFAULT:.1f} 秒)")
    if overall > STOP_RATIO_WARN:
        c.warn(f"全体の停止が {overall:.1%} で {STOP_RATIO_WARN:.0%} を超えている "
               "(停止を『動作』として覚える. build_demo_episodes.py --trim-mid-idle で詰める)")
    for name, ratio, n_s, n_t, run, sec in rows:
        msg = f"{name}: 停止 {ratio:5.1%} ({n_s:3d}/{n_t:3d})  最長 {sec:.1f} 秒 ({run} ステップ)"
        if sec > STOP_RUN_FAIL_SEC + 1e-9:
            c.fail(msg)
        elif ratio > STOP_RATIO_WARN:
            c.warn(msg)
        else:
            c.note(msg)
    return c


def check_gripper(eps):
    c = Check(3, "グリッパーの切り替え (各エピソードで「閉 → 開」の 2 回)")
    for e in eps:
        ch = grip_changes(e)
        if [w for _, w in ch] != ["閉", "開"]:
            seq = " → ".join(f"{w}(t={t})" for t, w in ch) or "切り替えなし"
            c.warn(f"{e['name']}: {len(ch)} 回  {seq}")
    if c.status == "PASS":
        c.note(f"{len(eps)} 本すべて 閉 → 開 の 2 回")
    return c


def check_limits(eps):
    c = Check(4, f"肩 j1 <= {J1_SAFE_MAX:.0f}° / j4 は J4_SAFE [{J4_SAFE[0]:+.0f}, {J4_SAFE[1]:+.0f}] の中")
    j1_top, j4_lo, j4_hi = [], [], []
    for e in eps:
        for kind in ("state", "actions"):
            v = e[kind]
            if v.shape[1] < 7:
                continue
            j1 = v[:, 1]
            i = int(j1.argmax())
            j1_top.append((float(j1[i]), e["name"], kind, i))
            if j1[i] > J1_SAFE_MAX:
                c.fail(f"{e['name']}: {kind} の j1 最大 {j1[i]:+.2f}° > {J1_SAFE_MAX:.0f}° (t={i}. "
                       "この指令はアームが黙って捨てる / LOG352)")
            j4 = v[:, 4]
            lo, hi = int(j4.argmin()), int(j4.argmax())
            j4_lo.append((float(j4[lo]), e["name"], kind, lo))
            j4_hi.append((float(j4[hi]), e["name"], kind, hi))
            if j4[lo] < J4_SAFE[0]:
                c.fail(f"{e['name']}: {kind} の j4 最小 {j4[lo]:+.2f}° < {J4_SAFE[0]:+.0f}° (t={lo})")
            if j4[hi] > J4_SAFE[1]:
                c.fail(f"{e['name']}: {kind} の j4 最大 {j4[hi]:+.2f}° > {J4_SAFE[1]:+.0f}° (t={hi})")
    if j1_top:
        v, name, kind, i = max(j1_top)
        c.note(f"j1 の最大 {v:+.2f}° ({name} {kind} t={i})")
    if j4_lo and j4_hi:
        lo, lname, lkind, li = min(j4_lo)
        hi, hname, hkind, hi_t = max(j4_hi)
        c.note(f"j4 の範囲 {lo:+.2f}° ({lname} {lkind} t={li}) .. {hi:+.2f}° ({hname} {hkind} t={hi_t})")
    return c


def check_positions(eps):
    c = Check(5, f"ブロックの位置のばらつき (つかんだ瞬間の j0 を {J0_BIN_DEG:.0f}° 刻み)")
    bins = Counter()
    for e in eps:
        close = [t for t, w in grip_changes(e) if w == "閉"]
        if not close:
            bins["不明"] += 1
            continue
        bins[round(float(e["state"][close[0], 0]) / J0_BIN_DEG) * J0_BIN_DEG] += 1
    total = sum(bins.values())
    for key in sorted(bins, key=lambda k: (isinstance(k, str), k)):
        n = bins[key]
        label = "つかんだ瞬間が不明" if isinstance(key, str) else f"j0 {key:+6.0f}° 付近"
        c.note(f"{label}: {n:2d} 本  {n / total:5.1%}  {'#' * n}")
    top = [(n, k) for k, n in bins.items() if not isinstance(k, str)]
    if top:
        n, key = max(top)
        if n / total > POS_SHARE_WARN:
            c.warn(f"j0 {key:+.0f}° 付近が {n}/{total} ({n / total:.1%}) で半分を超えている "
                   "(同じ場所ばかりだと画像を見ずに手を伸ばす / LOG348)")
    if bins.get("不明"):
        c.warn(f"つかんだ瞬間が分からないエピソードが {bins['不明']} 本 (項目 3 も見る)")
    return c


def check_action_vs_next_state(eps):
    c = Check(6, f"|action[t] - state[t+1]| の最大 (関節 6 列. {ACT_STATE_WARN_DEG}° 超で WARN)")
    worst = []
    for e in eps:
        s, a = e["state"], e["actions"]
        n = min(len(s) - 1, len(a) - 1)
        if n <= 0 or s.shape[1] < 7 or a.shape[1] < 7:
            continue
        d = np.abs(a[:n, :6] - s[1:n + 1, :6])
        t, k = np.unravel_index(int(d.argmax()), d.shape)
        worst.append((float(d[t, k]), e["name"], int(t), int(k)))
        if d[t, k] > ACT_STATE_WARN_DEG:
            c.warn(f"{e['name']}: {d[t, k]:.2f}° (t={t}, j{k}) > {ACT_STATE_WARN_DEG}° "
                   "(action は 0.1 秒後の state のはず. 停止詰めのつなぎ目を確認)")
    if worst:
        v, name, t, k = max(worst)
        c.note(f"全体の最大 {v:.2f}° ({name} t={t} j{k})")
    return c


def main():
    ap = argparse.ArgumentParser(description="π0.5 の学習前に raw_episodes を点検する (読むだけ)")
    ap.add_argument("raw_dir", help="raw_episodes のフォルダ")
    args = ap.parse_args()

    raw_dir = Path(args.raw_dir)
    if not raw_dir.is_dir():
        sys.exit(f"[ERROR] フォルダがない (KIOXIA 未接続 / パスの打ち間違い): {raw_dir}")
    eps = load_episodes(raw_dir)
    steps = sum(len(e["state"]) for e in eps)
    fps_set = sorted({fps_of(e) for e in eps})
    print(f"[DATA  ] {raw_dir}")
    print(f"         {len(eps)} 本 / {steps} ステップ / "
          f"{steps / fps_set[0]:.1f} 秒相当 / fps {'・'.join(f'{f:g}' for f in fps_set)}")
    print()

    checks = [check_shape(eps), check_stop(eps), check_gripper(eps),
              check_limits(eps), check_positions(eps), check_action_vs_next_state(eps)]
    for c in checks:
        c.report()
        print()

    n_fail = sum(1 for c in checks if c.status == "FAIL")
    n_warn = sum(1 for c in checks if c.status == "WARN")
    print("[RESULT] " + "  ".join(f"{c.num}.{c.status}" for c in checks))
    if n_fail:
        print(f"[RESULT] FAIL {n_fail} 項目 / WARN {n_warn} 項目 — このまま学習に出さない")
    elif n_warn:
        print(f"[RESULT] FAIL なし / WARN {n_warn} 項目 — 中身を見て判断する")
    else:
        print("[RESULT] 全項目 PASS")
    return 1 if n_fail else 0


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

お手本との答え合わせ eval_policy_offline.py は、偽のサーバーで判定を確かめた

学習のあと、実機の前に動かすスクリプトは Claude が書きました。お手本の各ステップの画像と角度を、そのままサーバーに送ります。返ってきた1秒分の予測とお手本のその後1秒の動きを比べ、「お手本は1秒で 2° 以上動くのに、予測は 0.5° 未満しか動かない」場面を「止まり」として数えます。アームは動かしません。

#!/usr/bin/env python3
"""eval_policy_offline.py — π0.5 サーバーの予測を、お手本と答え合わせする (アームは動かさない).

学習が終わったら、実機で動かす前にこれを実行する (lab Log11 / LOG358).
お手本の各ステップの「上カメラ画像 + 関節角 + グリッパー」をそのままサーバーへ送り,
返ってきた 1 秒分の予測 (10 x 7) と, お手本のその後 1 秒の動きを比べる.

見るもの:
  - 止まり (STUCK): お手本は 1 秒で 2° 以上動くのに, 予測は 0.5° 未満しか動かない
                    (LOG358: 学習データの停止を覚えて, ブロックの真上で止まった現象)
  - ずれ: 予測とお手本の関節角の差 (1 秒分の平均・最大)
  - グリッパー: 0.5 秒先までの開閉の予測が, お手本と食い違う回数

注意: 使うのは学習に使ったお手本なので, 「覚えたか」の確認であって, 初めて見る位置での確認ではない.
実機は, 着いた姿勢で止まったまま次の画像を撮る. その「止まった姿勢から動き出せるか」を見るのが一番の目的.

前提:
  - Runpod で serve_policy が起動し, Mac から SSH トンネル (localhost:8000) が張れている
  - pip install msgpack "websockets>=11"   (run_policy_client.py と同じ)

Usage:
  python3 pi05/eval_policy_offline.py                          # 位置ごとに 1 本ずつ, 3 ステップおき
  python3 pi05/eval_policy_offline.py --episodes all --stride 1
  python3 pi05/eval_policy_offline.py --raw-dir /Volumes/KIOXIA/r2r2r-data/datasets/mecharm_redblock_box_teach_v1/raw_episodes
記録: /Volumes/KIOXIA/r2r2r-data/lab/Log11/offline_eval/{stamp}/ (rows.csv / summary.json)
"""
import argparse
import csv
import json
import sys
import time
from datetime import datetime
from pathlib import Path

import cv2
import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parent))
from policy_ws import PolicyClient  # noqa: E402

TASK = "put the red block in the white box"
KIOXIA = Path("/Volumes/KIOXIA")
DATA_ROOT = KIOXIA / "r2r2r-data"
DEFAULT_RAW = DATA_ROOT / "datasets" / "mecharm_redblock_box_teach_v1_trim2" / "raw_episodes"
DEFAULT_OUT = DATA_ROOT / "lab" / "Log11" / "offline_eval"
HORIZON = 10              # 学習設定の action_horizon (0.1 秒 x 10 = 1 秒)
CLIENT_STEPS = 5          # run_policy_client.py が 1 チャンクで進む既定のステップ数 (0.5 秒先)
STUCK_DEMO_DEG = 2.0      # お手本が 1 秒でこれ以上動く場面で
STUCK_PRED_DEG = 0.5      # 予測がこれ未満しか動かなければ「止まり」


def pick_episodes(raw_dir: Path, mode: str):
    eps = sorted(p.parent for p in raw_dir.glob("*/episode.npz"))
    if not eps:
        sys.exit(f"[ERROR] お手本が見つからない: {raw_dir}")
    if mode == "all":
        return eps
    # ブロックの位置ごとに 1 本: つかんだ瞬間 (グリッパーの状態が閉に変わった所) の j0 を 5° 刻みで分ける
    groups = {}
    for ep in eps:
        s = np.load(ep / "episode.npz")["state"]
        ch = np.where(np.diff(s[:, 6]) != 0)[0]
        key = round(float(s[ch[0] + 1, 0]) / 5.0) if len(ch) else None
        groups.setdefault(key, ep)
    return [groups[k] for k in sorted(groups, key=lambda k: (k is None, k))]


def load_rgb(path: Path):
    bgr = cv2.imread(str(path))
    if bgr is None:
        raise RuntimeError(f"画像を読めない: {path}")
    return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)   # run_policy_client.py と同じく RGB で送る


def future(arr, t, n):
    """t から n ステップ分. 終わりを越える分は最後の値で埋める."""
    out = arr[t:t + n]
    if len(out) < n:
        out = np.concatenate([out, np.repeat(arr[-1:], n - len(out), axis=0)])
    return out


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--host", default="localhost")
    ap.add_argument("--port", type=int, default=8000)
    ap.add_argument("--raw-dir", default=str(DEFAULT_RAW))
    ap.add_argument("--episodes", default="per-position", choices=["per-position", "all"])
    ap.add_argument("--stride", type=int, default=3, help="何ステップおきに問い合わせるか")
    ap.add_argument("--out", default=str(DEFAULT_OUT))
    args = ap.parse_args()

    out_root = Path(args.out)
    if str(out_root).startswith(str(KIOXIA)) and not KIOXIA.is_mount():
        sys.exit(f"[ERROR] KIOXIA がマウントされていない: {KIOXIA}")
    eps = pick_episodes(Path(args.raw_dir), args.episodes)
    stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    out_dir = out_root / stamp
    out_dir.mkdir(parents=True, exist_ok=True)
    print(f"[EVAL  ] お手本 {len(eps)} 本 / {args.stride} ステップおき / 記録 {out_dir}")
    for ep in eps:
        print(f"         {ep.name}")

    policy = PolicyClient(args.host, args.port)
    print(f"[POLICY] 接続 {policy.uri}")

    fields = ["episode", "t", "j0", "j1", "j2", "j3", "j4", "j5", "grip", "latency_s",
              "demo_move_1s", "pred_move_1s", "demo_move_05s", "pred_move_05s",
              "err_mean", "err_max", "err_max_joint", "grip_demo_05s", "grip_pred_05s", "grip_mismatch", "stuck"]
    rows = []
    per_ep = {}
    t_start = time.time()
    with (out_dir / "rows.csv").open("w", newline="") as f:
        w = csv.DictWriter(f, fieldnames=fields)
        w.writeheader()
        for ep in eps:
            z = np.load(ep / "episode.npz")
            state, actions = z["state"].astype(np.float32), z["actions"].astype(np.float32)
            frames = sorted((ep / "frames").glob("*.jpg"))
            if len(frames) != len(state):
                sys.exit(f"[ERROR] 画像とステップの数が違う: {ep.name} ({len(frames)} / {len(state)})")
            stats = {"queries": 0, "stuck": 0, "grip_mismatch": 0, "err_mean": [], "stuck_t": []}
            for t in range(0, len(state), args.stride):
                q = state[t]
                t0 = time.time()
                out = policy.infer({"observation/image": load_rgb(frames[t]), "observation/state": q, "prompt": TASK})
                lat = time.time() - t0
                pred = np.asarray(out["actions"], dtype=np.float32)[:HORIZON]
                demo = future(actions, t, HORIZON)
                d1 = float(np.abs(demo[-1, :6] - q[:6]).max())
                p1 = float(np.abs(pred[-1, :6] - q[:6]).max())
                d05 = float(np.abs(demo[CLIENT_STEPS - 1, :6] - q[:6]).max())
                p05 = float(np.abs(pred[CLIENT_STEPS - 1, :6] - q[:6]).max())
                diff = np.abs(pred[:, :6] - demo[:, :6])
                g_demo = "".join(str(int(v > 0.5)) for v in demo[:CLIENT_STEPS, 6])
                g_pred = "".join(str(int(v > 0.5)) for v in pred[:CLIENT_STEPS, 6])
                stuck = d1 >= STUCK_DEMO_DEG and p1 < STUCK_PRED_DEG
                row = {"episode": ep.name, "t": t, **{f"j{k}": round(float(q[k]), 2) for k in range(6)},
                       "grip": int(q[6] > 0.5), "latency_s": round(lat, 3),
                       "demo_move_1s": round(d1, 2), "pred_move_1s": round(p1, 2),
                       "demo_move_05s": round(d05, 2), "pred_move_05s": round(p05, 2),
                       "err_mean": round(float(diff.mean()), 2), "err_max": round(float(diff.max()), 2),
                       "err_max_joint": int(diff.max(axis=0).argmax()),
                       "grip_demo_05s": g_demo, "grip_pred_05s": g_pred,
                       "grip_mismatch": int(g_demo != g_pred), "stuck": int(stuck)}
                w.writerow(row)
                rows.append(row)
                stats["queries"] += 1
                stats["stuck"] += int(stuck)
                stats["grip_mismatch"] += int(g_demo != g_pred)
                stats["err_mean"].append(float(diff.mean()))
                if stuck:
                    stats["stuck_t"].append(t)
                mark = "  [STUCK]" if stuck else ""
                gm = f"  グリッパー お手本{g_demo} 予測{g_pred}" if g_demo != g_pred else ""
                print(f"[{ep.name[-15:]} t={t:3d}] 1秒の動き お手本{d1:5.1f}° 予測{p1:5.1f}°  "
                      f"ずれ 平均{diff.mean():4.1f}° 最大{diff.max():4.1f}°(j{row['err_max_joint']}){mark}{gm}")
            per_ep[ep.name] = {"queries": stats["queries"], "stuck": stats["stuck"], "stuck_t": stats["stuck_t"],
                               "grip_mismatch": stats["grip_mismatch"],
                               "err_mean_median": round(float(np.median(stats["err_mean"])), 2),
                               "err_mean_max": round(float(np.max(stats["err_mean"])), 2)}
    policy.close()

    n = len(rows)
    summary = {
        "stamp": stamp, "raw_dir": args.raw_dir, "episodes_mode": args.episodes, "stride": args.stride,
        "queries": n, "elapsed_sec": round(time.time() - t_start, 1),
        "stuck": sum(r["stuck"] for r in rows), "grip_mismatch": sum(r["grip_mismatch"] for r in rows),
        "err_mean_median": round(float(np.median([r["err_mean"] for r in rows])), 2),
        "thresholds": {"stuck_demo_deg": STUCK_DEMO_DEG, "stuck_pred_deg": STUCK_PRED_DEG,
                       "horizon": HORIZON, "client_steps": CLIENT_STEPS},
        "per_episode": per_ep,
    }
    (out_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2))
    print()
    print(f"[SUMMARY] 問い合わせ {n} 回 / 止まり {summary['stuck']} 回 / グリッパーの食い違い {summary['grip_mismatch']} 回 / "
          f"ずれの中央値 {summary['err_mean_median']}°")
    for name, s in per_ep.items():
        print(f"  {name}: 止まり {s['stuck']}/{s['queries']} {s['stuck_t']}  グリッパー {s['grip_mismatch']}  "
              f"ずれ中央値 {s['err_mean_median']}°")
    print(f"[DONE  ] 記録: {out_dir}")


if __name__ == "__main__":
    main()

まだ本物のサーバーがないので、Claude のクラウドの作業環境に、決まった答えを返す偽のサーバーを立てて確かめました。stuck は「今の角度のまま動かない」答え、move は「肩を 0.1 秒ごとに 0.8° ずつ動かす」答えを返します。お手本は、trim2 の 183454 の角度に、画像を同じ1枚にしたものを3本(run_A・run_B は同じ位置、run_C は j0 を −20° ずらした位置)使っています。

import sys, numpy as np
sys.path.insert(0,"test/pi05")
from policy_ws import packb, unpackb
from websockets.sync.server import serve
mode=sys.argv[1]
def h(ws):
    ws.send(packb({}))
    for msg in ws:
        obs=unpackb(msg); s=np.asarray(obs["observation/state"],dtype=np.float32)
        assert obs["observation/image"].shape[2]==3 and obs["prompt"]
        a=np.repeat(s[None],10,0).copy()
        if mode=="move": a[:,1]+=np.arange(1,11)*0.8
        ws.send(packb({"actions":a}))
with serve(h,"localhost",8765,max_size=None) as srv: srv.serve_forever()
[EVAL  ] お手本 2 本 / 10 ステップおき / 記録 out/20261021_101224
         run_C
         run_A
[POLICY] 接続 ws://localhost:8765
[run_C t=  0] 1秒の動き お手本 20.9° 予測  0.0°  ずれ 平均 3.8° 最大20.9°(j4)  [STUCK]
[run_C t= 10] 1秒の動き お手本 29.0° 予測  0.0°  ずれ 平均 6.3° 最大29.0°(j4)  [STUCK]
[run_C t= 20] 1秒の動き お手本 28.8° 予測  0.0°  ずれ 平均 6.4° 最大28.8°(j4)  [STUCK]
[run_C t= 30] 1秒の動き お手本  6.7° 予測  0.0°  ずれ 平均 2.4° 最大 6.7°(j4)  [STUCK]
[run_C t= 40] 1秒の動き お手本 14.0° 予測  0.0°  ずれ 平均 2.2° 最大14.0°(j1)  [STUCK]
[run_C t= 50] 1秒の動き お手本  4.4° 予測  0.0°  ずれ 平均 0.5° 最大 4.4°(j1)  [STUCK]  グリッパー お手本00011 予測00000
[run_C t= 60] 1秒の動き お手本 12.5° 予測  0.0°  ずれ 平均 2.4° 最大12.5°(j1)  [STUCK]
[run_C t= 70] 1秒の動き お手本 28.7° 予測  0.0°  ずれ 平均 5.0° 最大28.7°(j0)  [STUCK]
[run_C t= 80] 1秒の動き お手本 29.4° 予測  0.0°  ずれ 平均 5.1° 最大29.4°(j0)  [STUCK]
[run_C t= 90] 1秒の動き お手本 13.4° 予測  0.0°  ずれ 平均 3.5° 最大13.4°(j1)  [STUCK]
[run_C t=100] 1秒の動き お手本  9.0° 予測  0.0°  ずれ 平均 3.2° 最大10.0°(j1)  [STUCK]
[run_C t=110] 1秒の動き お手本 25.7° 予測  0.0°  ずれ 平均 5.0° 最大25.7°(j1)  [STUCK]
[run_C t=120] 1秒の動き お手本 27.7° 予測  0.0°  ずれ 平均 6.5° 最大27.7°(j4)  [STUCK]
[run_C t=130] 1秒の動き お手本 28.0° 予測  0.0°  ずれ 平均 7.2° 最大28.0°(j4)  [STUCK]
[run_C t=140] 1秒の動き お手本  2.7° 予測  0.0°  ずれ 平均 0.9° 最大 2.7°(j0)  [STUCK]
[run_A t=  0] 1秒の動き お手本 20.9° 予測  0.0°  ずれ 平均 3.8° 最大20.9°(j4)  [STUCK]
[run_A t= 10] 1秒の動き お手本 29.0° 予測  0.0°  ずれ 平均 6.3° 最大29.0°(j4)  [STUCK]
[run_A t= 20] 1秒の動き お手本 28.8° 予測  0.0°  ずれ 平均 6.4° 最大28.8°(j4)  [STUCK]
[run_A t= 30] 1秒の動き お手本  6.7° 予測  0.0°  ずれ 平均 2.4° 最大 6.7°(j4)  [STUCK]
[run_A t= 40] 1秒の動き お手本 14.0° 予測  0.0°  ずれ 平均 2.2° 最大14.0°(j1)  [STUCK]
[run_A t= 50] 1秒の動き お手本  4.4° 予測  0.0°  ずれ 平均 0.5° 最大 4.4°(j1)  [STUCK]  グリッパー お手本00011 予測00000
[run_A t= 60] 1秒の動き お手本 12.5° 予測  0.0°  ずれ 平均 2.4° 最大12.5°(j1)  [STUCK]
[run_A t= 70] 1秒の動き お手本 28.7° 予測  0.0°  ずれ 平均 5.0° 最大28.7°(j0)  [STUCK]
[run_A t= 80] 1秒の動き お手本 29.4° 予測  0.0°  ずれ 平均 5.1° 最大29.4°(j0)  [STUCK]
[run_A t= 90] 1秒の動き お手本 13.4° 予測  0.0°  ずれ 平均 3.5° 最大13.4°(j1)  [STUCK]
[run_A t=100] 1秒の動き お手本  9.0° 予測  0.0°  ずれ 平均 3.2° 最大10.0°(j1)  [STUCK]
[run_A t=110] 1秒の動き お手本 25.7° 予測  0.0°  ずれ 平均 5.0° 最大25.7°(j1)  [STUCK]
[run_A t=120] 1秒の動き お手本 27.7° 予測  0.0°  ずれ 平均 6.5° 最大27.7°(j4)  [STUCK]
[run_A t=130] 1秒の動き お手本 28.0° 予測  0.0°  ずれ 平均 7.2° 最大28.0°(j4)  [STUCK]
[run_A t=140] 1秒の動き お手本  2.7° 予測  0.0°  ずれ 平均 0.9° 最大 2.7°(j0)  [STUCK]

[SUMMARY] 問い合わせ 30 回 / 止まり 30 回 / グリッパーの食い違い 2 回 / ずれの中央値 3.8°
  run_C: 止まり 15/15 [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140]  グリッパー 1  ずれ中央値 3.8°
  run_A: 止まり 15/15 [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140]  グリッパー 1  ずれ中央値 3.8°
[DONE  ] 記録: out/20261021_101224
[EVAL  ] お手本 3 本 / 20 ステップおき / 記録 out/20261021_101228
         run_A
         run_B
         run_C
[POLICY] 接続 ws://localhost:8765
[run_A t=  0] 1秒の動き お手本 20.9° 予測  8.0°  ずれ 平均 3.2° 最大20.9°(j4)
[run_A t= 20] 1秒の動き お手本 28.8° 予測  8.0°  ずれ 平均 5.6° 最大28.8°(j4)
[run_A t= 40] 1秒の動き お手本 14.0° 予測  8.0°  ずれ 平均 1.5° 最大 8.1°(j2)
[run_A t= 60] 1秒の動き お手本 12.5° 予測  8.0°  ずれ 平均 3.1° 最大20.5°(j1)
[run_A t= 80] 1秒の動き お手本 29.4° 予測  8.0°  ずれ 平均 5.8° 最大29.4°(j0)
[run_A t=100] 1秒の動き お手本  9.0° 予測  8.0°  ずれ 平均 2.5° 最大 7.7°(j4)
[run_A t=120] 1秒の動き お手本 27.7° 予測  8.0°  ずれ 平均 7.2° 最大27.7°(j4)
[run_A t=140] 1秒の動き お手本  2.7° 予測  8.0°  ずれ 平均 1.7° 最大 8.7°(j1)
[run_B t=  0] 1秒の動き お手本 20.9° 予測  8.0°  ずれ 平均 3.2° 最大20.9°(j4)
[run_B t= 20] 1秒の動き お手本 28.8° 予測  8.0°  ずれ 平均 5.6° 最大28.8°(j4)
[run_B t= 40] 1秒の動き お手本 14.0° 予測  8.0°  ずれ 平均 1.5° 最大 8.1°(j2)
[run_B t= 60] 1秒の動き お手本 12.5° 予測  8.0°  ずれ 平均 3.1° 最大20.5°(j1)
[run_B t= 80] 1秒の動き お手本 29.4° 予測  8.0°  ずれ 平均 5.8° 最大29.4°(j0)
[run_B t=100] 1秒の動き お手本  9.0° 予測  8.0°  ずれ 平均 2.5° 最大 7.7°(j4)
[run_B t=120] 1秒の動き お手本 27.7° 予測  8.0°  ずれ 平均 7.2° 最大27.7°(j4)
[run_B t=140] 1秒の動き お手本  2.7° 予測  8.0°  ずれ 平均 1.7° 最大 8.7°(j1)
[run_C t=  0] 1秒の動き お手本 20.9° 予測  8.0°  ずれ 平均 3.2° 最大20.9°(j4)
[run_C t= 20] 1秒の動き お手本 28.8° 予測  8.0°  ずれ 平均 5.6° 最大28.8°(j4)
[run_C t= 40] 1秒の動き お手本 14.0° 予測  8.0°  ずれ 平均 1.5° 最大 8.1°(j2)
[run_C t= 60] 1秒の動き お手本 12.5° 予測  8.0°  ずれ 平均 3.1° 最大20.5°(j1)
[run_C t= 80] 1秒の動き お手本 29.4° 予測  8.0°  ずれ 平均 5.8° 最大29.4°(j0)
[run_C t=100] 1秒の動き お手本  9.0° 予測  8.0°  ずれ 平均 2.5° 最大 7.7°(j4)
[run_C t=120] 1秒の動き お手本 27.7° 予測  8.0°  ずれ 平均 7.2° 最大27.7°(j4)
[run_C t=140] 1秒の動き お手本  2.7° 予測  8.0°  ずれ 平均 1.7° 最大 8.7°(j1)

[SUMMARY] 問い合わせ 24 回 / 止まり 0 回 / グリッパーの食い違い 0 回 / ずれの中央値 3.14°
  run_A: 止まり 0/8 []  グリッパー 0  ずれ中央値 3.14°
  run_B: 止まり 0/8 []  グリッパー 0  ずれ中央値 3.14°
  run_C: 止まり 0/8 []  グリッパー 0  ずれ中央値 3.14°
[DONE  ] 記録: out/20261021_101228
--- out を KIOXIA の既定にしたとき(クラウドの作業環境には KIOXIA がない) ---
[ERROR] KIOXIA がマウントされていない: /Volumes/KIOXIA

止まるモデルでは全部が「止まり」、動くモデルでは0件になりました。位置ごとに1本ずつ選ぶ指定では、同じ位置の2本から1本だけが選ばれています。KIOXIA がつながっていないときは、書き込まずに止まります。

ただし、このスクリプトはお手本の画像を使うので、カメラの写り方のずれは見つけられません。写り方は、実機で動かす前に別の確認で見ます。

アームを動かすクライアントには、肩の上限と送る前の検査を入れた

run_policy_client.py の変更は、実機を動かすプログラムなので、Claude Code が実装し、codex がレビューしています。

  1. 安全範囲の肩 j1 の上限を、fk_lookup.py と同じ J1_SAFE_MAX = 88.0 で切る(これまでは +98° まで通っていた)
  2. --raw-dir を必ず指定させる(前のお手本で安全範囲が作られるのを防ぐ)
  3. 送る直前に、目標が全関節で安全範囲の中にあるかを確かめ、外れていれば送らずに止める。HOME に戻す h だけは、この検査を通さずに送れる(範囲の外にいるアームを戻す手段)

3つ目は、codex のレビューで2回指摘されて入りました。1回目は「肩が 108° を超えてたたまれていると、移動量の制限で 88° を超える目標ができる」(R1)、2回目は「同じことが手首 j4 などほかの関節でも起きる」(C2)です。どちらも、安全範囲でそろえた目標を、そのあと移動量の制限で現在の角度に近づける順番になっているために起きます。この回の最後の修正の報告です。

 C2 を入れて commit しました(901758d)。codex は「C2 
  は閉じています。必須指摘はありません」です。

  入れたもの

  check_send_safe(target, lo, hi) を全関節検査に変更しました。物理制約(J4_SAFE /
  wr.JOINT_LIMITS)を個別に並べるのではなく、load_envelope() が返す lo/hi 
  そのもので 6 関節を検査しています。lo/hi は 学習データ ±10° ∩ 
  JOINT_LIMITS(hard) ∩ J4_SAFE ∩ j1<=J1_SAFE_MAX なので、codex
  の修正案より狭い範囲を見ていることになります(この包含関係は codex も
  load_envelope を読んで確認済み)。

  - target[k] < lo[k] - 1e-6 または > hi[k] + 1e-6
  で違反。外れた関節ごとに「関節・値・範囲」を 1 行ずつ。再クランプなし
  - 非有限値は即 1 件返す
  - 停止時は [ABORT] を各行表示し、rec["unsafe"](理由)と
  rec["envelope"](lo/hi)を log.jsonl に残す
  - HOME 復帰はこの検査を通さない: --execute かつ --auto でないときだけ
  h=HOMEに戻して終了 / 他=そのまま終了 を出し、h なら
  _send_and_wait_arrival(HOME, ...) を送って終了。通常(安全な目標)の Enter
  確認の選択肢は 1 文字も変えていません

  確かめたこと(すべて送信なし・アーム未接続)

  1. codex の例(現在 j4=+100°・予測 +90°・上限 20°)→ 止まる

  apply_limits の目標: j4=+80.00
    [LIMIT] j4 安全範囲でクランプ +90.0→+10.4
    [LIMIT] j4 移動量 -89.6° を ±20° に制限
    [ABORT] j4 +80.00° が安全範囲 [-90.00, +10.43] の外 (j4 の範囲は 学習データ ∩
  J4_SAFE [-90, +60])
  参考: j1 と有限値だけの旧ガードでは「送れる」判定 ← C2 で塞いだ穴

  2. 今朝のたたんだ姿勢からの最初のチャンク → 送れる

  runs/pi05_policy/pi05_20261021_082212 の chunk0 の実測値を使いました(j0=-0.08
  j1=+92.54 j2=+1.14 j3=+1.75 j4=-95.97 j5=-29.26)。

  予測 (plan_chunk):   j1=+93.94  j4=-95.88
  apply_limits の目標: j1=+88.00  j4=-90.00   ←
  どちらもクランプされ、移動量の制限には当たらない
  判定: 送れる

  肩 -4.54°・j4 +5.97° の移動で、どちらも範囲内へ戻る目標なので通ります。codex
  も「判定対象は最終目標であって範囲外の現在角そのものではないので、これで正しい
  」と確認しています。

  3. 今朝の通常運転 20 チャンク → 0/20 で停止なし

  pi05_20261021_082645/log.jsonl の state と actions から plan_chunk →
  apply_limits → check_send_safe を再現し、20 チャンクすべて「送れる」。

  chunk  0: 送れる  目標   -2.16   +5.13   +1.74   +0.98   -5.03  -29.98
  chunk  5: 送れる  目標  -34.26  +61.83  +16.87   +4.25  -75.09  -29.71
  chunk 19: 送れる  目標  -33.89  +66.68  +16.79   +4.26  -76.09  -29.68
  止まったチャンク: 0/20

  (余談ですが、chunk 6 以降は目標がほぼ動いていません。j1≈66 / j4≈-76
  で張り付いていて、これが LOG358 の「真上で止まった」状態そのものです。)

  4. 1e-6 の余裕 — 上限ちょうど(88.0)→ 送れる / 上限 +1e-5 →
  止まる。複数関節が同時に外れた場合は 3 行すべて表示、非有限値は 1
  件で返ります。

  codex の C2 レビュー結果

  ▎ C2 は閉じています。通常チャンクの範囲外指令を止める修正は問題なしです。必須指
  ▎ 摘はありません。

  確認された点: lo/hi 検査は C2 の物理制約検査を包含し学習範囲でさらに絞っている
  / 検査位置はそのチャンクの送信・グリッパー操作より前 / finally
  にアーム操作は無い / HOME 例外の実装と
  --auto・ドライランで入力を求めない判断は復帰仕様に合っている / 1e-6
  は今回の演算誤差の許容として妥当 / 検証 2 の「送れる」は正しい。

  新規は推奨 1 件(C2-1・未反映): ABORT レコードの書き込みが確認入力と HOME
  復帰の後にあるため、入力待ちや HOME 復帰中に Ctrl-C が入るとそのチャンクの
  unsafe / envelope が残らない。また _send_and_wait_arrival
  の戻り値を見ていないので、HOME が未到達でも home_return=True
  と記録される。修正案は「入力前にレコードを書いて flush し、HOME は
  home_requested と home_arrived=bool(ok) を別レコードに残す」。

  commit

  901758d — pi05/run_policy_client.py
  のみ個別指定。pi05/build_demo_episodes.py(LOG352)は未コミットのまま。作業ログ
  logs/20261021_0933.md に C2 の節を追記(未コミット)。

  未反映の指摘は、C2-1(推奨・ログの記録順)、3 回目の必須 1 件(framing の
  response 下限)、推奨の dx 上限・終了コード・開始時グリッパー・try/finally
  です。入れるものを指示ください。

朝の実機の 20 チャンクを同じ計算でたどり直すと、1回も止まりませんでした。検査が厳しすぎて、ふつうに動く場面まで止めていないことも確かめています。

上カメラの写り方は、開始時の1枚目をお手本の最初のコマと比べて確かめる

カメラの写り方の確認も、run_policy_client.py に入れました。HOME にいる状態の1枚目を、--raw-dir の最初のお手本の最初のコマ(同じく HOME)と cv2.phaseCorrelate で比べます。次の3つのどれかに当たれば、アームに何も送らずに終了します。

判定条件理由
ずれ
上下のずれ(dy)か左右のずれ(dx)が 20 ピクセルを超える
お手本どうしでも、ブロックの位置の違いで 9 ピクセルほど揺れるため、その外側に線を引いた
測れない
2枚の似ている度合い(response)が 0.20 未満
似ていない2枚では、ずれの値そのものが当てにならない
測れない値が数でない(NaN など)同上

あわせて、開始時にグリッパーを開く操作を、写り方の確認と送る前の検査が通り、Enter で実行を選んだあとへ移しました。確認が通るまで、アームには何も送りません。そのかわり、始める前に爪が閉じていたら、手で開けておく必要があります(起動時に表示が出ます)。

iPhone を付けたあとに位置を合わせる画面 camera_align.py も作りました。上カメラのライブ映像に、お手本の最初のコマを半透明で重ね、ずれと判定を画面の隅に出します。表示だけで、ファイルには何も書きません。

最後の修正の報告です。カメラと Pi のデーモンを偽物に置き換えて main() を動かし、写り方が「ずれ」「測れない」のときは、アームへの呼び出しが1件もないことを確かめています。

 6 件すべて入れて commit しました(05e1b8b)。codex の最後のレビューは
  「必須の指摘なし」 です。コード変更はここで締め切りです。

  入れた 6 件

  1. response 下限 FRAMING_MIN_RESPONSE = 0.20。framing_verdict(dx, dy, response)
  が OK / ずれ / 測れない を返し、下限未満か非有限値なら「測れない」として dx/dy
  を信用しません
  2. dx の上限 FRAMING_MAX_DX = 20.0(お手本の dx は +0.00〜+7.23)
  3. 開始時のグリッパー開放を後ろへ — 写り方の確認を通り、目標が check_send_safe
  を通り、Enter で実行を選んだ後に 1 度だけ開きます。代わりに 1 チャンク目の
  grip=0 が仮定になるので、起動時に「爪が閉じていたら手で開けておく」と表示します
  4. 終了コード sys.exit(main())。1 = 安全停止 / 写り方の不一致 / 途中のエラー、0
  = 最後まで / q / h / Ctrl-C
  5. C2-1 — ABORT のレコードを入力より前に書いて flush。HOME は戻り値から
  home_requested / home_arrived を別レコードに残します
  6. try/finally — カメラ初期化と写り方の確認を try の中へ。finally は log_f /
  cap / policy / rec_sock を None
  判定付きで閉じるだけで、アームは動かしません(戻すのは h のときだけ)

  確かめたこと(すべて送信なし)

  写り方の判定(基準 = teach_v1 183454 の 1 枚目 / |dy|<=20 かつ |dx|<=20 かつ
  resp>=0.20)

  ┌──────────────────────────┬────────┬───────┬──────────┬──────────┬────────┐
  │           画像           │   dy   │  dx   │ response │   判定   │  期待  │
  ├──────────────────────────┼────────┼───────┼──────────┼──────────┼────────┤
  │ teach_v1                 │ +0.00  │ +0.00 │ 1.0000   │ OK       │ 通す ✓ │
  │ 183454(基準そのもの)   │        │       │          │          │        │
  ├──────────────────────────┼────────┼───────┼──────────┼──────────┼────────┤
  │ teach_v1 191257          │ +9.11  │ +4.96 │ 0.4575   │ OK       │ 通す ✓ │
  ├──────────────────────────┼────────┼───────┼──────────┼──────────┼────────┤
  │ teach_v1 195120          │ -6.66  │ +5.79 │ 0.5600   │ OK       │ 通す ✓ │
  ├──────────────────────────┼────────┼───────┼──────────┼──────────┼────────┤
  │ ドライラン 082512        │ -7.82  │ +2.87 │ 0.5960   │ OK       │ 通す ✓ │
  ├──────────────────────────┼────────┼───────┼──────────┼──────────┼────────┤
  │ 実機 082645              │ +37.50 │ -1.96 │ 0.4556   │ ずれ     │ 止める │
  │                          │        │       │          │          │  ✓     │
  ├──────────────────────────┼────────┼───────┼──────────┼──────────┼────────┤
  │ 10/17 222723             │ +8.18  │ -2.46 │ 0.0505   │ 測れない │ 止める │
  │                          │        │       │          │          │  ✓     │
  └──────────────────────────┴────────┴───────┴──────────┴──────────┴────────┘

  10/17 の 1 枚は dy=+8.2 / dx=-2.5
  なので、相関を見なければ「OK」で通っていました。お手本 19 本は 19/19 で
  OK(response 0.411〜1.000)。codex が挙げた合成ケースも、ランダム画像(resp
  0.0375)と真っ黒(0.0000)は「測れない」、横 70px
  ずらし(dx=+69.98)は「ずれ」になります。

  流れ(カメラと daemon をスタブにして main()
  を実行。実機には何も送っていません)

  ┌──────────────────────────────┬────────────┬──────────────────────────────┐
  │            ケース            │ 終了コード │    アームに触った呼び出し    │
  ├──────────────────────────────┼────────────┼──────────────────────────────┤
  │ A 写り方「ずれ」(082645)     │ 1          │ なし                         │
  ├──────────────────────────────┼────────────┼──────────────────────────────┤
  │ B 写り方「測れない」(10/17)  │ 1          │ なし                         │
  ├──────────────────────────────┼────────────┼──────────────────────────────┤
  │                              │            │ get_daemon_socket →          │
  │ C 写り方 OK + 安全な目標     │ 0          │ daemon_set_gripper(0) → SEND │
  │                              │            │  x2                          │
  ├──────────────────────────────┼────────────┼──────────────────────────────┤
  │ D 写り方 OK +                │ 1          │ get_daemon_socket だけ —     │
  │ 危険な目標(現在 j4=+100°)  │            │ グリッパーに触らない         │
  ├──────────────────────────────┼────────────┼──────────────────────────────┤
  │ E たたんだ姿勢(j1=+92.5 /   │ 0          │ …→ SEND j1=+88.0 j4=-90.0    │
  │ j4=-96.0)                   │            │                              │
  └──────────────────────────────┴────────────┴──────────────────────────────┘

  D の log.jsonl には unsafe(関節・値・範囲)と
  envelope(lo/hi)が残ります。A・B で「アームへの呼び出しが 1
  件もない」ことが、「確認が通るまで何も送らない」の実測です。

  codex 最終レビュー

  ▎ 必須の指摘なし

  - グリッパーの仮定: 爪が閉じたまま始めると画像と state
  が食い違い予測は変わりうるが、その予測にもクランプと check_send_safe
  がかかり、送角前に開放される。安全判定を迂回する経路ではない
  - 写り方確認前の送信は通常経路に無い / 安全停止を迂回する送角は明示的な h だけ
  / SystemExit でも finally を通り、finally にアーム操作は無い
  - しきい値 0.20・20px は提示の実測に対して正常例を通し異常例を止める。ただし未
  測定の画像全般で誤通過しないとまでは断定できない(この点はログに残しました)
  - 据え置き 1 件のみ: rec_sock.close() が無い(OS が回収。「今回の変更は不要」)

  今日の commit(すべて個別ファイル指定)

  47d091c π0.5 学習前のデータ点検を追加し, クライアントの肩上限と --raw-dir
  必須化 (LOG358)
  4d79df6 送信前の安全ガードと上カメラの写り方の検査を追加 (codex R1/R3 反映 +
  E/E2)
  901758d 送信前の検査を 6 関節すべてに広げる (codex C2)
  05e1b8b 写り方の判定を response と dx まで広げ, 残っていたレビュー指摘を反映

  対象は pi05/check_dataset.py pi05/camera_align.py pi05/eval_policy_offline.py
  pi05/run_policy_client.py pi05/RUNPOD_TRAIN.md の 5
  ファイルのみ。pi05/build_demo_episodes.py(LOG352
  の採否ゲート)は未コミットのままです。作業ログは
  logs/20261021_0933.md(未コミット)。

  16:30 の手順

  iPhone を付ける → アームを HOME(爪は開けておく)
  → python3 pi05/camera_align.py --raw-dir $RAW          # OK になるまで合わせる
  → python3 pi05/eval_policy_offline.py --raw-dir $RAW    # [STUCK] の出方を見る
  → python3 pi05/run_policy_client.py --raw-dir $RAW --max-chunks 5   #
  ドライラン
  → python3 pi05/run_policy_client.py --raw-dir $RAW --execute

  RAW は学習に使ったデータ(mecharm_redblock_box_teach_v1_trim2/raw_episodes)で
  す。学習前点検は python3 pi05/check_dataset.py $RAW で、trim2 なら全項目 PASS
  になります。

10/17 の画像は、ずれの値だけ見れば dy +8.2・dx −2.5 で「OK」になるところでした。似ている度合いが 0.05 と低いので、ずれの値そのものが当てにならないと判定されています。codex の最終レビューは「必須の指摘なし」でした。ただし codex は、しきい値は手元の実測の画像で正しく分かれることを確かめただけで、まだ測っていない画像すべてで誤って通さないとまでは言えない、と書いています。

最後に、trim2 を作った build_demo_episodes.py を commit しました。このとき Claude Code が、データの作られた時刻と中身から、元の teach_v1 は --trim-mid-idle を入れる前の版で作られていたことを確かめ、こちらの依頼文の前提(teach_v1 もこの版で作った)を訂正しています。

 commit しました(80932d1)。pi05/build_demo_episodes.py と
  logs/20261021_0933.md の 2 ファイル個別指定です。

  1 点、前提を訂正します

  commit メッセージを書く前に生成元を確認したところ、この版で作ったのは 
  teach_v1_trim2(と teach_v1_trim)だけで、teach_v1
  は別の(前の)版で作られています。

  ┌────────────────┬────────────────────┬─────────────────────┬─────────────┐
  │  データセット  │    index.json      │      meta の        │   npz の    │
  │                │     の生成時刻     │    trim_mid_idle    │   t_orig    │
  ├────────────────┼────────────────────┼─────────────────────┼─────────────┤
  │ teach_v1       │ 2026-10-19 19:53   │ キーなし            │ なし        │
  ├────────────────┼────────────────────┼─────────────────────┼─────────────┤
  │ teach_v1_trim  │ 2026-10-21 08:37   │ True                │ あり        │
  ├────────────────┼────────────────────┼─────────────────────┼─────────────┤
  │ teach_v1_trim2 │ 2026-10-21 08:47   │ True                │ あり        │
  └────────────────┴────────────────────┴─────────────────────┴─────────────┘

  trim_mid_idle / steps_before_mid_trim / mid_idle_trimmed_steps の 3 キーと
  t_orig はこの差分で追加されたものです。teach_v1 の meta
  にはどれも無いので、teach_v1 はこの版より前(--trim-mid-idle
  を入れる前)に作られています。LOG352 の採否ゲート部分が入っていたかは meta
  に痕跡が残らないので判定できません。

  スクリプトの mtime は 10-21 08:46 で、trim2
  の生成(08:47)直前・以降は未編集です。つまり作業ツリーの版 = trim2 
  を作った版で間違いありません(16:30 で使うモデルの学習データ)。

  commit メッセージにはこの事実関係どおりに書きました:

  **この版で mecharm_redblock_box_teach_v1_trim2 を作った** (2026-10-21 08:47.
  その前に同じ版で teach_v1_trim を 08:37 に作っている). 16:30 の実機テストで使う
  モデルは trim2 で学習したものなので, 再現できるようにこの版を記録する.
  teach_v1 (2026-10-19 19:53) は --trim-mid-idle を入れる前の版で作ったもので,
  meta.json に trim_mid_idle 系のキーが無い.

  「teach_v1 もこの版で作った」という認識がもし正しいのであれば(私が見落としてい
  る経緯があるなら)、git commit --amend で直せます。

  差分の中身(読んで確認した範囲)

  LOG352 の採否ゲート: recording_enabled=False →
  no_recording、gripper_state_uncertain → 不採用、gripper_check.verified=false
  を独立ゲートで弾く(codex R6 M3)、result が completed /
  completed_with_failures 以外は不採用(result
  が無い旧試行は人の判定のみ)、ensure_ssd を is_mount() 化(codex
  R5)。--include-failed でも迂回させない作りになっています。

  LOG358 の --trim-mid-idle: 停止ステップの定義は 6 関節すべて |action - state| <
  0.3° かつ グリッパーの action == state — 今日作った check_dataset.py の項目 2
  と同じ定義です。区間の先頭 2 ステップを残す / 0.5°
  のドリフトで区間を切る(R1)/ グリッパー切替前後を必ず残す(R2)/
  詰めた後に関節 6 列の action を作り直す(R3)/ t を等間隔に振り直して t_orig
  に原時刻を残す(R4)/ 非 trim データセットへの上書きを
  ABORT(R6)。既定(オプション未指定)では従来と同一出力です。

  py_compile は通り、send_coords の追加や time.sleep の削除・短縮はありません。

  残したもの

  - pi05/build_demo_episodes.py.bak_pre_LOG352_20261019
  は未追跡のまま(退避用のバックアップなので commit
  していません。必要なら追加します)
  - dynamic_tracking_limits/ observe/ visual_servo_pick.py
  などの未コミット分は今日触っておらず、そのままです

  今日の commit は 5 本(47d091c → 80932d1)、対象は pi05 配下の 5
  ファイルと作業ログ 1 ファイルです。

この回の commit は次の5本です(~/r2r2r-scripts)。どれも、変えたファイルを1つずつ指定して入れています。

commit中身
47d091c
学習前のデータ点検 check_dataset.py、クライアントの肩の上限と --raw-dir の必須化、答え合わせ eval_policy_offline.py
4d79df6
送る前の検査(肩)と上カメラの写り方の確認、camera_align.py(codex R1・R3)
901758d
送る前の検査を6関節すべてに広げた(codex C2)
05e1b8b
写り方の判定に response の下限と dx の上限を足し、残りの指摘を入れた
80932d1
build_demo_episodes.py(LOG352 の採否ゲートと、この回の --trim-mid-idle。この版で teach_v1_trim2 を作った)

この回で変えた3つのファイルの全文(run_policy_client.py・camera_align.py・build_demo_episodes.py)

実機を動かすクライアント run_policy_client.py の、この回の最後の版(commit 05e1b8b)です。

#!/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 の外へは出さない (クランプ)
  - 肩 j1 の上限は J1_SAFE_MAX (88°) で更に切る. これを超える指令はアームが黙って捨てる (LOG352)
  - 送る直前に最終検査. 6 関節のどれかが安全範囲の外か非有限値なら, 送信もグリッパー操作もせず
    停止 (codex R1 / C2). HOME 復帰 (h) はこの検査を通さずに送れる
  - 1 回の移動量を --max-move-deg で制限 (クランプ), j4 は J4_SAFE 範囲内
  - グリッパー操作後は 2 秒待つ (DAY175 対策・本線と同じ)
  - --raw-dir は必須. 安全範囲は **学習に使ったデータ** から作る (別のデータだと範囲が合わない)
  - 開始時に上カメラの写り方をお手本と比べ, OK 以外ならアームに何も送らずに終了
    (ずれ / 測れない. --ignore-framing で続行). この確認が通るまで daemon にも触らない
  - 開始時のグリッパー開放も, 最初に送る目標が検査を通ってから (codex R2)
  - 止まったときに finally でアームを動かさない. 戻すときは Enter 確認の h で明示的に送る

終了コード: 0 = 最後まで / q / h / Ctrl-C,  1 = 安全停止・写り方の不一致・途中のエラー

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

Usage:
  RAW=/Volumes/KIOXIA/r2r2r-data/datasets/mecharm_redblock_box_teach_v1_trim2/raw_episodes
  python3 pi05/camera_align.py --raw-dir $RAW                   # 先に上カメラの写り方を合わせる
  python3 pi05/run_policy_client.py --raw-dir $RAW              # ドライラン (予測を表示・記録するだけ)
  python3 pi05/run_policy_client.py --raw-dir $RAW --execute    # 1 チャンクごとに Enter で実行
  python3 pi05/run_policy_client.py --raw-dir $RAW --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 camera_align import (  # noqa: E402
    FRAMING_MAX_DX, FRAMING_MAX_DY, FRAMING_MIN_RESPONSE, framing_verdict, load_reference, measure_framing,
)
from fk_lookup import J1_SAFE_MAX  # noqa: E402  88.0. 本線と二重定義しない
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
SEND_EPS_DEG = 1e-6             # 送信前の検査の余裕 (小数の誤差で止まらないように)
DATA_ROOT = Path("/Volumes/KIOXIA/r2r2r-data")
LOG_ROOT = DATA_ROOT / "runs" / "pi05_policy"
RECORDER = ("127.0.0.1", 5556)


def load_envelope(raw_dir: Path):
    """学習データで通った各関節の範囲 ± マージン. 物理可動域 (wr.JOINT_LIMITS)・J4_SAFE・J1_SAFE_MAX で更に絞る."""
    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])
    # 肩 j1: wr.JOINT_LIMITS[1] の上限は +123° なので, マージンを足すと +98° まで通ってしまう.
    # 88° を超える指令はアームが黙って捨てる (LOG352) ので, ここで切る.
    hi[1] = min(hi[1], J1_SAFE_MAX)
    # codex R3: lo > hi や非有限値のまま運転しない (np.clip は例外を出さないので黙って通る).
    # lo を書き換えて辻褄を合わせるのではなく, 起動を中止する.
    bad = [k for k in range(6)
           if not (np.isfinite(lo[k]) and np.isfinite(hi[k])) or lo[k] > hi[k]]
    if bad:
        sys.exit("[ERROR] 安全範囲が不整合 (lo > hi か非有限値): "
                 + "  ".join(f"j{k}[{lo[k]:+.1f},{hi[k]:+.1f}]" for k in bad)
                 + f"\n        学習データが安全制約 (J1_SAFE_MAX={J1_SAFE_MAX:.0f}° / J4_SAFE / "
                 f"JOINT_LIMITS) と合っていない: {raw_dir}")
    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 check_send_safe(target, lo, hi):
    """送る直前の最終検査 (codex R1 / C2). 違反の理由を返す (空なら送って良い).

    apply_limits は「安全範囲でクランプ → 移動量の上限」の順なので, 現在角が安全範囲の
    外にあると, クランプで範囲内に入れた目標が移動量の制限でまた範囲の外に戻る.
      j1 (codex R1): 現在 110° / 予測 100° / 上限 20° → 88° にクランプ後, 110-20 = 90° を送る
      j4 (codex C2): 現在 100° / 予測  90° / 上限 20° → 60° にクランプ後, 100-20 = 80° を送る
    j1 だけでなく 6 関節すべてを見る. ここで再クランプすると移動量の上限を破るので, 送らずに止める.
    HOME 復帰 (Enter 確認の h) はこの検査を通さない: 範囲の外にいるアームを戻す手段として残す.
    """
    if not np.all(np.isfinite(target)):
        return ["目標に非有限値が入っている: " + " ".join(f"j{k}={v}" for k, v in enumerate(target))]
    bad = []
    for k in range(6):
        if target[k] < lo[k] - SEND_EPS_DEG or target[k] > hi[k] + SEND_EPS_DEG:
            msg = f"j{k} {target[k]:+.2f}° が安全範囲 [{lo[k]:+.2f}, {hi[k]:+.2f}] の外"
            if k == 1 and target[k] > hi[k] and abs(hi[k] - J1_SAFE_MAX) < 1e-9:
                msg += (f" (上限は J1_SAFE_MAX={J1_SAFE_MAX:.0f}°. "
                        "これを超える指令はアームが黙って捨てる / LOG352)")
            elif k == 4:
                msg += f" (j4 の範囲は 学習データ ∩ J4_SAFE [{J4_SAFE[0]:+.0f}, {J4_SAFE[1]:+.0f}])"
            bad.append(msg)
    return bad


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", required=True,
                    help="安全範囲の算出に使う学習エピソード (必須. 学習に使った raw_episodes を指定する)")
    ap.add_argument("--prompt", default=TASK)
    ap.add_argument("--record", action="store_true", help="recorder.py --top に上カメラ動画を記録させる")
    ap.add_argument("--ignore-framing", action="store_true",
                    help="上カメラの写り方がお手本と違っても続ける (既定は何も送らずに終了)")
    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)))
    print(f"[SAFE  ] 肩 j1 の上限は J1_SAFE_MAX={J1_SAFE_MAX:.0f}° で切った "
          f"(これを超える指令はアームが黙って捨てる / LOG352)  元データ: {args.raw_dir}")

    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")
    run_stem = f"pi05_{stamp}"
    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}")

    # 以降は try/finally の中だけで資源を触る (codex R3: 途中で終了しても cap / log_f を必ず閉じる).
    # sys.exit は SystemExit を投げるので, この finally を通って後始末される.
    cap = policy = rec_sock = None
    exit_code = 0
    grip = 0
    grip_opened = False          # 開始時のグリッパー開放を済ませたか (codex R2)
    try:
        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 が出るか)")

        # E (2026-10-21): 上カメラの写り方は 2 通りあり, カメラを開くたびに切り替わることがある.
        # お手本と違う写り方のまま動かすと, 学習と別の画像を見せることになる
        # (082645 は dy=+37.5 のまま実機を動かした. その 1 分前のドライラン 082512 は dy=-7.8).
        # アームは HOME に置いた状態で, 1 枚目を お手本の 1 枚目 (同じく HOME) と比べる.
        # **この確認が通るまで daemon にも触らない** (アームには何も送らない).
        ref_bgr, ref_path = load_reference(Path(args.raw_dir))
        bgr0 = None
        for _ in range(10):
            _rgb0, bgr0 = grab_rgb(cap)
            if bgr0 is not None:
                break
            time.sleep(0.2)
        if bgr0 is None:
            sys.exit("[ERROR] 上カメラの 1 枚目を撮れない")
        dx, dy, resp = measure_framing(ref_bgr, bgr0)
        verdict, why = framing_verdict(dx, dy, resp)
        print(f"[CAMERA] 写り方 dy={dy:+.1f} dx={dx:+.1f} response={resp:.3f} → {verdict} ({why})")
        print(f"[CAMERA] 基準: {ref_path}")
        log_f.write(json.dumps({"type": "framing", "dy": round(dy, 2), "dx": round(dx, 2),
                                "response": round(resp, 3), "verdict": verdict, "why": why,
                                "max_dy": FRAMING_MAX_DY, "max_dx": FRAMING_MAX_DX,
                                "min_response": FRAMING_MIN_RESPONSE,
                                "ok": verdict == "OK", "ignored": bool(args.ignore_framing),
                                "reference": str(ref_path)}) + "\n")
        log_f.flush()
        if verdict != "OK":
            if not args.ignore_framing:
                sys.exit(f"[ABORT ] 上カメラの写り方が『{verdict}』. アームには何も送らずに終了しました.\n"
                         f"         {why}\n"
                         "         pi05/camera_align.py で合わせ直す "
                         "(iPhone を付け直す / 連係カメラを開き直す).\n"
                         "         この写り方のまま続けるなら --ignore-framing")
            print(f"[WARN  ] 写り方が『{verdict}』だが --ignore-framing が指定されたので続けます")

        # ---- ここから先でアームに触る (写り方の確認を通ったあと) ----
        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)
        if args.record:
            notify_recorder(rec_sock, {"type": "trial_start", "run_stem": run_stem})

        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:
            # 開始時の開放は, 最初に送る目標が検査を通ってからにする (codex R2).
            # 以前はここで開いていたので, 1 チャンク目が安全ガードで止まる場合でも
            # 爪だけ先に開いてしまっていた (物を持っていれば落とす).
            print("[GRIP  ] 開始時のグリッパー開放は, 最初の目標が検査を通ってから行います "
                  "(爪が閉じていたら手で開けておく)")

        for i in range(args.max_chunks):
            q = read_angles()
            if q is None:
                print("[ERROR] 関節角を読めない (daemon を確認). 停止"); exit_code = 1; 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 のスリープ等). 停止")
                exit_code = 1; 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}")

            # 送る直前の最終検査 (codex R1 / C2). 送信にもグリッパー操作にも入らず止める.
            # HOME 復帰 (h) だけはこの検査を通さずに送れる (範囲の外にいるアームを戻す手段).
            unsafe = check_send_safe(target, lo, hi)
            if unsafe:
                rec["unsafe"] = unsafe
                rec["envelope"] = {"lo": [round(float(v), 2) for v in lo],
                                   "hi": [round(float(v), 2) for v in hi]}
                for u in unsafe:
                    print(f"       [ABORT] {u}")
                print("[STOP  ] 安全でない目標のため, 送らずに停止します "
                      "(現在角が安全範囲の外にあると, 移動量の制限で範囲の外に戻る)")
                # 記録は入力より前に書いて flush する (codex C2-1: 入力待ちや HOME 復帰中の
                # Ctrl-C でこのチャンクの unsafe / envelope が消えないように).
                log_f.write(json.dumps(rec) + "\n"); log_f.flush()
                exit_code = 1
                if args.execute and not args.auto:
                    ans = input("       h=HOMEに戻して終了 / 他=そのまま終了 > ").strip().lower()
                    if ans == "h":
                        ok_home, _ = _send_and_wait_arrival(HOME, "pi05_home", 2.5)
                        log_f.write(json.dumps({"chunk": i, "type": "home_return", "t": time.time(),
                                                "home_requested": True,
                                                "home_arrived": bool(ok_home)}) + "\n")
                        log_f.flush()
                else:
                    print("         アームを HOME に置き直してからやり直してください")
                break

            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":
                        ok_home, _ = _send_and_wait_arrival(HOME, "pi05_home", 2.5)
                        rec["home_requested"] = True
                        rec["home_arrived"] = bool(ok_home)
                        log_f.write(json.dumps(rec) + "\n"); break
                    if ans == "s":
                        log_f.write(json.dumps(rec) + "\n"); continue
                if not grip_opened:
                    # 開始時の開放はここで 1 度だけ (写り方の確認と目標の検査を通り, 送ると決めてから)
                    print("[GRIP  ] 開始時にグリッパーを開く")
                    daemon_set_gripper(0)
                    time.sleep(GRIPPER_SLEEP)
                    grip_opened = True
                    rec["initial_gripper_open"] = True
                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:
                            exit_code = 1
                            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:
        # finally ではアームを動かさない (HOME 復帰もサーボ解放もしない). 止まった姿勢は
        # そのまま保つ: 未知の姿勢から勝手に動かす方が危ない. 戻すときは h で明示的に送る.
        if args.record and rec_sock is not None:
            notify_recorder(rec_sock, {"type": "trial_end", "run_stem": run_stem})
        if not log_f.closed:
            log_f.close()
        if cap is not None:
            cap.release()
        if policy is not None:
            policy.close()
        try:
            close_daemon_socket()
        except Exception:
            pass
        print(f"[DONE  ] 記録: {log_dir}")
    # 終了コードは正常に抜けた経路でだけ表示する (sys.exit で抜ける場合は, その [ABORT] /
    # [ERROR] の文言が出て終了コード 1 になるので, ここで 0 と書くと食い違う).
    if exit_code:
        print(f"[DONE  ] 終了コード {exit_code} (安全停止 / 途中のエラー)")
    return exit_code


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

上カメラの位置合わせの画面 camera_align.py(commit 05e1b8b)です。

#!/usr/bin/env python3
"""camera_align.py — 上カメラの写り方をお手本に合わせる画面 (Mac 側・アームは動かさない).

上カメラの iPhone は普段使いのもので, 毎回付け外しする. 付けるたびに写り方が変わり,
2026-10-21 の実機では お手本より下に大きくずれたまま動かしてしまった
(runs/pi05_policy/pi05_20261021_082645 は dy=+37.5. 1 分前のドライランは dy=-7.8 で正常).
iPhone を付けたあと, この画面で写り方を合わせてから実機に進む.

画面:
  ライブ映像に 基準画像 (--raw-dir の最初のエピソードの 1 枚目・HOME の姿勢) を半透明で重ねる.
  隅に写り方のずれ (dx, dy) と相関の強さ (response) を出し, 判定を大きく表示する:
    OK       しきい値以内
    ZURE     dx か dy がしきい値の外 (= ずれ)
    NO MATCH 相関が弱く dx/dy が当てにならない (= 測れない)
  q または Esc で終了. **表示だけで, ファイルには何も書かない.**

run_policy_client.py は開始時に この measure_framing / framing_verdict で同じ検査をして,
OK 以外ならアームに何も送らずに終了する (--ignore-framing で続行).

前提:
  - 上カメラ (iPhone 連係) が見えている. アームは HOME に置いておく (基準画像が HOME の姿勢)

Usage:
  RAW=/Volumes/KIOXIA/r2r2r-data/datasets/mecharm_redblock_box_teach_v1_trim2/raw_episodes
  python3 pi05/camera_align.py --raw-dir $RAW
  python3 pi05/camera_align.py --raw-dir $RAW --alpha 0.7 --scale 2   # 基準を濃く / 画面を小さく
"""
import argparse
import sys
import time
from pathlib import Path

import cv2
import numpy as np

_HERE = Path(__file__).resolve().parent
_ROOT = _HERE.parent
_PLACE = _ROOT / "dynamic_tracking_limits" / "place" / "place_modules"   # camera_resolver はここ
for p in (_HERE, _ROOT, _PLACE):
    sys.path.insert(0, str(p))

import camera_resolver  # noqa: E402

IMG_LONG = 384            # build_demo_episodes.py / run_policy_client.py と同じ (学習画像の長辺)
FRAMING_MAX_DY = 20.0     # |dy| がこれを超えたら「ずれ」. 下の実測値の間を取った
FRAMING_MAX_DX = 20.0     # |dx| の上限 (codex R2). お手本の dx は +0.00..+7.23 (2026-10-21 実測)
# response (相関の強さ) がこれ未満なら, dx/dy を信用できないので「測れない」として止める (codex R1).
# 2026-10-21 実測: 正しく対応している比較は 0.411..1.000.
# 無関係な画像は ランダム 0.0375 / 真っ黒 0.0000, 10/17 の 222723 の 1 枚目は 0.0505
# (dy=+8.2 / dx=-2.5 なので, 相関を見ないと「OK」で通ってしまう).
FRAMING_MIN_RESPONSE = 0.20
WINDOW = "camera_align (q で終了)"


def to_small_bgr(frame):
    """学習画像と同じ大きさに縮める (run_policy_client.grab_rgb と同じ処理)."""
    h, w = frame.shape[:2]
    s = IMG_LONG / max(h, w)
    return cv2.resize(frame, (round(w * s), round(h * s)), interpolation=cv2.INTER_AREA)


def load_reference(raw_dir: Path):
    """基準画像 = --raw-dir の最初のエピソードの 1 枚目 (HOME の姿勢). 戻り値: (BGR 画像, パス)."""
    eps = sorted(p.parent for p in raw_dir.glob("*/episode.npz"))
    if not eps:
        sys.exit(f"[ERROR] お手本が見つからない (基準画像を決められない): {raw_dir}")
    frames = sorted((eps[0] / "frames").glob("*.jpg"))
    if not frames:
        sys.exit(f"[ERROR] お手本の画像が無い: {eps[0]}")
    bgr = cv2.imread(str(frames[0]))
    if bgr is None:
        sys.exit(f"[ERROR] 基準画像を読めない: {frames[0]}")
    return bgr, frames[0]


def measure_framing(ref_bgr, cur_bgr):
    """写り方のずれを測る. 戻り値: (dx, dy, response). cv2.phaseCorrelate + ハニング窓.

    2026-10-21 実測 (基準 = teach_v1 の run_0001_20261019_183454 の 1 枚目):
      お手本 191257   dy= +9.1  / お手本 195120 dy= -6.7   ← ブロックの位置で揺れる範囲
      ドライラン 082512 dy= -7.8                            ← お手本と同じ写り方. 通す
      実機 082645       dy=+37.5                            ← 下にずれた写り方. 止める
    dx はブロックの位置で ±6 ほど動く (お手本どうしで +5.0 / +5.8) ので判定には使わず, 表示だけ.
    """
    a = cv2.cvtColor(ref_bgr, cv2.COLOR_BGR2GRAY).astype(np.float32)
    b = cv2.cvtColor(cur_bgr, cv2.COLOR_BGR2GRAY).astype(np.float32)
    if a.shape != b.shape:
        b = cv2.resize(b, (a.shape[1], a.shape[0]), interpolation=cv2.INTER_AREA)
    win = cv2.createHanningWindow((a.shape[1], a.shape[0]), cv2.CV_32F)
    (dx, dy), response = cv2.phaseCorrelate(a, b, win)
    return float(dx), float(dy), float(response)


def framing_verdict(dx, dy, response):
    """写り方の判定. 戻り値: (verdict, 理由). verdict は "OK" / "ずれ" / "測れない".

    相関が弱い (response が低い) ときは dx/dy 自体が当てにならないので,
    「ずれ」ではなく「測れない」として扱う (既定ではどちらも止める).
    """
    if not all(np.isfinite(v) for v in (dx, dy, response)):
        return "測れない", "dx / dy / response に非有限値"
    if response < FRAMING_MIN_RESPONSE:
        return "測れない", (f"相関が弱く dx/dy が当てにならない "
                            f"(response={response:.3f} < {FRAMING_MIN_RESPONSE:.2f})")
    bad = []
    if abs(dy) > FRAMING_MAX_DY:
        bad.append(f"|dy|={abs(dy):.1f} > {FRAMING_MAX_DY:.0f}")
    if abs(dx) > FRAMING_MAX_DX:
        bad.append(f"|dx|={abs(dx):.1f} > {FRAMING_MAX_DX:.0f}")
    if bad:
        return "ずれ", " / ".join(bad)
    return "OK", (f"|dy|<={FRAMING_MAX_DY:.0f} / |dx|<={FRAMING_MAX_DX:.0f} / "
                  f"response>={FRAMING_MIN_RESPONSE:.2f}")


def open_top_camera():
    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] 上カメラを開けない")
    t0 = time.time()
    while time.time() - t0 < 8.0:           # 連係カメラは映像が来るまで数秒かかることがある
        ok, f = cap.read()
        if ok and f is not None:
            return cap
        time.sleep(0.2)
    cap.release()
    sys.exit("[ERROR] 上カメラから映像が来ない. iPhone のロック解除・連係カメラの接続を確認 "
             "(Photo Booth の『カメラ』メニューに iPhone が出るか)")


def draw_overlay(small_bgr, ref_bgr, dx, dy, resp, alpha, scale):
    ref = ref_bgr
    if ref.shape[:2] != small_bgr.shape[:2]:
        ref = cv2.resize(ref, (small_bgr.shape[1], small_bgr.shape[0]), interpolation=cv2.INTER_AREA)
    view = cv2.addWeighted(small_bgr, 1.0 - alpha, ref, alpha, 0)
    big = cv2.resize(view, None, fx=scale, fy=scale, interpolation=cv2.INTER_NEAREST)
    verdict, _why = framing_verdict(dx, dy, resp)
    # cv2.putText は日本語を出せないので画面はローマ字. 端末には日本語で出す.
    label = {"OK": "OK", "ずれ": "ZURE", "測れない": "NO MATCH"}[verdict]
    color = (0, 200, 0) if verdict == "OK" else (0, 0, 255)
    cv2.putText(big, label, (16, 56), cv2.FONT_HERSHEY_SIMPLEX, 1.8, color, 4, cv2.LINE_AA)
    cv2.putText(big, f"dy={dy:+6.1f}  dx={dx:+6.1f}  (OK: both <= {FRAMING_MAX_DY:.0f})", (16, 92),
                cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2, cv2.LINE_AA)
    cv2.putText(big, f"resp={resp:.3f}  (OK: >= {FRAMING_MIN_RESPONSE:.2f})   q: quit", (16, 116),
                cv2.FONT_HERSHEY_SIMPLEX, 0.6, (220, 220, 220), 1, cv2.LINE_AA)
    return big


def main():
    ap = argparse.ArgumentParser(description="上カメラの写り方をお手本に合わせる (アームは動かさない・表示だけ)")
    ap.add_argument("--raw-dir", required=True,
                    help="基準画像に使う学習エピソード (必須. 最初の 1 本の 1 枚目を基準にする)")
    ap.add_argument("--alpha", type=float, default=0.5, help="基準画像の重ね具合 (0=ライブのみ / 1=基準のみ)")
    ap.add_argument("--scale", type=float, default=3.0, help="画面の拡大倍率")
    args = ap.parse_args()

    ref_bgr, ref_path = load_reference(Path(args.raw_dir))
    print(f"[REF   ] 基準画像: {ref_path}")
    print(f"[ALIGN ] |dy| <= {FRAMING_MAX_DY:.0f} なら OK. アームは HOME に置いた状態で合わせる. "
          "q または Esc で終了 (ファイルには何も書かない)")
    cap = open_top_camera()
    last = None
    try:
        while True:
            for _ in range(2):            # バッファに溜まった古いフレームを捨てる
                cap.grab()
            ok, frame = cap.read()
            if not ok or frame is None:
                if cv2.waitKey(100) & 0xFF in (ord("q"), 27):
                    break
                continue
            small = to_small_bgr(frame)
            dx, dy, resp = measure_framing(ref_bgr, small)
            cv2.imshow(WINDOW, draw_overlay(small, ref_bgr, dx, dy, resp, args.alpha, args.scale))
            if last is None or abs(dy - last) > 1.0:      # 端末は値が動いたときだけ出す
                verdict, why = framing_verdict(dx, dy, resp)
                print(f"[ALIGN ] dy={dy:+6.1f}  dx={dx:+6.1f}  response={resp:.3f}  "
                      f"→ {verdict} ({why})")
                last = dy
            if cv2.waitKey(30) & 0xFF in (ord("q"), 27):
                break
    except KeyboardInterrupt:
        print("\n[STOP  ] Ctrl-C")
    finally:
        cap.release()
        cv2.destroyAllWindows()
        print("[DONE  ] 終了 (何も書いていません)")


if __name__ == "__main__":
    main()

お手本を作る build_demo_episodes.py の、teach_v1_trim2 を作った版(commit 80932d1)です。

#!/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・人の入力待ちを「待つ動作」として学習させないため.
  - --trim-mid-idle を付けると、上の処理で残った停止区間もさらに先頭
    MID_IDLE_KEEP_STEPS だけ残して詰める (DAY288. mid_idle_keep_mask を参照).
    このとき 関節 6 列の action を残ったステップで作り直し、t を等間隔に振り直して
    元の時刻を t_orig に残す. オプション未指定時の出力は従来と同一.
  - グリッパー切替ステップの前後 (i-1, i, i+1) は どちらの停止詰めでも必ず残す.
  - 採用条件: 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
  python3 pi05/build_demo_episodes.py --dataset mecharm_redblock_box_teach_v1_trim \
      --trim-mid-idle --since 20261019   # 途中の停止を先頭2ステップまで詰める
"""
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       # 詰めるときに残す長さ
# --trim-mid-idle (DAY288) 用. 既定 (オプション未指定) では一切使わない。
MID_IDLE_EPS_DEG = 0.3    # 6 関節すべての |action - state| がこれ未満 かつ
                          # グリッパーの action == state なら「停止ステップ」
MID_IDLE_KEEP_STEPS = 2   # 連続する停止区間で残す先頭ステップ数
# codex レビュー R1: 1 ステップの変化だけで見ると「0.2°ずつ動き続ける区間」も
# 停止として削れてしまう。区間の先頭 state からのずれがこれを超えたら区間を切る。
MID_IDLE_DRIFT_DEG = 0.5
GRIPPER_GUARD_STEPS = 1   # グリッパー切替ステップ i の前後 i-1, i, i+1 を必ず残す
ANGLE_ABS_MAX = 200.0     # 実測値のサニティ (これを超えたら読み取り異常として捨てる)
MIN_ACTUAL_SAMPLES = 20

# LOG352 (2026-10-19): 試行の状態で採否を決める。
# 人の yes/yes だけでは「爪が開いたか不明な試行」を弾けない
# (codex R3 A9)。record_teach_demo.py は
#   result: completed / aborted / interrupted / error
#   gripper_state_uncertain: True (gripper の指令失敗 or 開いたか確認できなかった)
# を試行 JSON に残すので、それを見て除外する。
#
# OK_RESULTS に "completed_with_failures" を入れているのは **既存データを
# 落とさないため**。run_pick_place.py はステージ単位の失敗があっても完走すると
# この値を入れる。2026-10-19 実測で、mecharm_redblock_box_scripted_v1 の
# 採用 20 本のうち **15 本が completed_with_failures** だった
# (残り 5 本が completed)。人の判定は 20 本すべて yes/yes。
# "completed" だけに絞ると既存データセットが 20 本 -> 5 本に減ってしまう。
# run_pick_place.py 側の失敗は stage_failures に残り、最終的な採否は
# 人の判定が正である、という既存の運用をそのまま維持する。
OK_RESULTS = ("completed", "completed_with_failures")
# 古い試行 JSON には result が無いことがある。その場合は従来どおり
# 人の yes/yes だけで決める (後から基準を変えて過去を落とさない)。


def ensure_ssd():
    """KIOXIA のマウントを確認する (codex レビュー R5).

    DATA_ROOT.exists() だけでは、SSD 未マウントのまま /Volumes/KIOXIA/... が
    ローカルのディレクトリとして存在する場合を見抜けず、SSD だと思って
    内蔵ディスクへ書いてしまう。マウントポイント自体を is_mount() で見る。
    """
    mount = Path("/Volumes/KIOXIA")
    if not mount.is_mount():
        sys.exit(f"[ABORT] KIOXIA not mounted: {mount}")
    if not DATA_ROOT.exists():
        sys.exit(f"[ABORT] data root not found: {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 trial_state_reject_reason(j):
    """試行の状態から「学習に使えない理由」を返す. 使えるなら None (LOG352).

    - gripper_state_uncertain が真 -> 爪が開いた/閉じたかが不明。
      state[:, 6] のラベルが正しいと言えないので使わない。
    - result がある かつ OK_RESULTS に無い -> 中断した試行。
    - result が無い (古い試行 JSON) -> 従来どおり人の判定だけで決める。
    """
    if j.get("gripper_state_uncertain"):
        return "gripper_state_uncertain"
    res = j.get("result")
    if res is not None and res not in OK_RESULTS:
        return f"result={res}"
    return None


def _recording_disabled(run_dir):
    """試行 JSON が recording_enabled=False を明示しているか(LOG352).

    読めない・キーが無い場合は False(従来どおり扱う)。
    """
    p = run_dir / f"{run_dir.name}.json"
    try:
        return json.loads(p.read_text()).get("recording_enabled") is False
    except Exception:
        return False


def gripper_switch_indices(state):
    """グリッパーが切り替わるステップの添字 (新しい値が最初に現れる添字) を返す."""
    if len(state) < 2:
        return np.empty(0, dtype=int)
    return np.flatnonzero(state[1:, 6] != state[:-1, 6]) + 1


def gripper_guard_mask(state):
    """グリッパー切替ステップ i の前後 (i-1, i, i+1) を True にしたマスク (codex R2).

    切替ステップそのものは `action[6] != state[6]` なので停止判定には入らないが、
    **切替の次のステップ (i, i+1)** は「関節が止まっていてグリッパーも変わらない」
    ステップなので停止として削除され得る。爪が閉じた/開いた直後の画像と state が
    消えると、グリッパーを動かす手がかりが学習データから落ちる。
    """
    n = len(state)
    guard = np.zeros(n, dtype=bool)
    for i in gripper_switch_indices(state):
        lo = max(0, i - GRIPPER_GUARD_STEPS)
        hi = min(n, i + GRIPPER_GUARD_STEPS + 1)
        guard[lo:hi] = True
    return guard


def mid_idle_keep_mask(state, actions):
    """停止区間を先頭 MID_IDLE_KEEP_STEPS だけ残す keep マスクを返す (DAY288).

    既存の「停止区間を詰める」処理 (IDLE_MAX_SEC=2.0 超) では、お手本の
    1.5〜2 秒の停止 (19〜20 ステップ) が閾値のすぐ下に収まって残っていた。
    π0.5 の action_horizon は 10 (1 秒) で停止より短いため、停止中は入力が
    変わらず「止まる」しか出せない → 実機でブロック真上から動かなくなる
    (記録: runs/pi05_policy/pi05_20261021_082645)。

    停止ステップの定義:
      - 6 関節すべてで |action - state| < MID_IDLE_EPS_DEG
      - かつ グリッパーの action と state が同じ

    codex レビュー R1: 上の条件は「1 ステップの変化」しか見ないので、
    0.2°ずつ動き続ける区間 (ゆっくりした動き) も停止と判定されてしまう。
    そこで停止区間の中でも、**区間の先頭 state からのずれ**が 6 関節のどれかで
    MID_IDLE_DRIFT_DEG を超えたら、そこで区間を切って新しい区間として数え直す。
    こうするとゆっくり動く区間は短い区間の連続になり、ほぼ削られない。

    グリッパー切替ステップの前後は gripper_guard_mask で必ず残す (codex R2)。
    先頭・末尾のステップも明示的に残す (エピソード両端の state を変えない)。
    """
    n = len(state)
    joint_still = np.max(np.abs(actions[:, :6] - state[:, :6]), axis=1) < MID_IDLE_EPS_DEG
    grip_same = actions[:, 6] == state[:, 6]
    idle = joint_still & grip_same

    keep = np.ones(n, dtype=bool)
    i = 0
    while i < n:
        if not idle[i]:
            i += 1
            continue
        # i から始まる停止区間を、先頭 state からのずれで切りながら進む
        anchor = i
        kept_in_region = 0
        while i < n and idle[i]:
            if np.max(np.abs(state[i, :6] - state[anchor, :6])) > MID_IDLE_DRIFT_DEG:
                anchor = i              # ここから新しい区間として数え直す
                kept_in_region = 0
            if kept_in_region < MID_IDLE_KEEP_STEPS:
                kept_in_region += 1     # 区間の先頭 MID_IDLE_KEEP_STEPS は残す
            else:
                keep[i] = False
            i += 1

    keep |= gripper_guard_mask(state)
    if n:
        keep[0] = True
        keep[-1] = True
    return keep


def build_one(run_dir, out_dir, args):
    stem = run_dir.name
    rec = run_dir / "recording"
    run_json = run_dir / f"{stem}.json"
    # LOG352: まず試行 JSON を読み、記録なしで走らせた試行をはっきり弾く。
    # record_teach_demo.py は --no-record で走らせると recording_enabled=False を残す
    # (段階C の検証はこれで走らせる)。映像が無いのでそもそもエピソードを作れない。
    # --include-failed でも迂回させない (分析用でも映像なしでは作れない)。
    if not run_json.exists():
        return {"run": stem, "used": False, "reason": f"missing:{run_json.name}"}
    try:
        trial = json.loads(run_json.read_text())
    except Exception as e:
        return {"run": stem, "used": False,
                "reason": f"run_json 読めない:{type(e).__name__}"}
    if trial.get("recording_enabled") is False:
        return {"run": stem, "used": False, "reason": "no_recording"}
    # LOG352 (codex R6 M3): グリッパーの開放確認ができない状態で取った試行を
    # **独立のゲート**で弾く。`gripper_state_uncertain` は「走行中に不明だった」
    # 試行しか捕まえられず、起動時に daemon が gv を読めなかった試行
    # (gripper_check.verified=false) は取りこぼす。
    # ABORT_IF_FIRST_OPEN_UNKNOWN を True へ戻し忘れたまま記録した場合の保険。
    # --include-failed でも迂回させない。キーが無い旧試行は従来どおり扱う。
    gc = trial.get("gripper_check")
    if isinstance(gc, dict) and gc.get("verified") is False:
        return {"run": stem, "used": False, "reason": "gripper_not_verified"}

    need = [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, trial = 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}"}
    # LOG352: 人の判定が yes/yes でも、試行の状態が不明/中断なら使わない
    if not args.include_failed:
        bad = trial_state_reject_reason(trial)
        if bad:
            return {"run": stem, "used": False, "reason": f"trial_state {bad}"}

    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
    # codex レビュー R2: グリッパー切替の前後 (i-1, i, i+1) はこの既存処理でも残す。
    guard = gripper_guard_mask(state)
    idle_trim_steps -= int((~keep & guard).sum())
    keep |= guard
    t, state, actions = t[keep], state[keep], actions[keep]

    # 途中の停止を縮める (--trim-mid-idle のときだけ). 既定では何もしない。
    steps_before_mid_trim = int(len(t))
    mid_idle_trimmed_steps = 0
    if args.trim_mid_idle:
        mkeep = mid_idle_keep_mask(state, actions)
        mid_idle_trimmed_steps = int((~mkeep).sum())
        t, state, actions = t[mkeep], state[mkeep], actions[mkeep]
        # codex レビュー R3: 削ったあとは「0.1 秒後の state」が隣のステップに
        # なっていないので、関節 6 列の action を残ったステップで作り直す。
        # グリッパー列 (actions[:, 6]) は元の指令のまま触らない。
        actions = actions.copy()
        actions[:-1, :6] = state[1:, :6]
        actions[-1, :6] = state[-1, :6]

    # 各ステップに最も近い上カメラのフレーム (frames は残したステップ分だけ書き出す)
    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 の不一致)"}

    if args.trim_mid_idle:
        # codex レビュー R4: 詰めたあとの t は実時刻のままだと飛びがあり、
        # 「FPS 10 の等間隔」という前提と食い違う。等間隔に振り直し、
        # 元の時刻は t_orig に残す (どの瞬間を取ったか後から追えるように)。
        np.savez_compressed(out_dir / "episode.npz",
                            t=(np.arange(len(t)) / FPS).astype(np.float64),
                            t_orig=t - t[0], state=state, actions=actions)
    else:
        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),
        "trim_mid_idle": bool(args.trim_mid_idle),
        "steps_before_mid_trim": steps_before_mid_trim,
        "mid_idle_trimmed_steps": mid_idle_trimmed_steps,
        "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"],
            "steps_before_mid_trim": steps_before_mid_trim,
            "mid_idle_trimmed_steps": mid_idle_trimmed_steps}


def assert_trim_target_is_new(ds):
    """--trim-mid-idle の出力先が「既存の非 trim データセット」なら ABORT (codex R6).

    `--dataset mecharm_redblock_box_teach_v1 --trim-mid-idle` のように入力と同じ名前を
    渡すと、お手本データそのものを詰めた版で上書きしてしまう (frames も作り直され、
    元の 10Hz の記録が復元できなくなる)。
    既に全エピソードが trim_mid_idle=true で作られているデータセットは、
    作り直し (--force) を通すため許可する。
    """
    metas = sorted((ds / "raw_episodes").glob("*/meta.json"))
    if not metas:
        return
    not_trimmed = [p.parent.name for p in metas
                   if json.loads(p.read_text()).get("trim_mid_idle") is not True]
    if not_trimmed:
        sys.exit(
            f"[ABORT] --trim-mid-idle の出力先が既存の非 trim データセットです: {ds}\n"
            f"        trim_mid_idle でないエピソード {len(not_trimmed)} 本 "
            f"(例: {', '.join(not_trimmed[:3])})\n"
            f"        元データを上書きしないよう、--dataset に新しい名前を指定してください。")


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="既存エピソードを作り直す")
    ap.add_argument("--trim-mid-idle", action="store_true",
                    help="途中の停止区間を先頭 %d ステップだけ残して詰める (既定: しない)"
                         % MID_IDLE_KEEP_STEPS)
    args = ap.parse_args()
    ensure_ssd()

    ds = DATA_ROOT / "datasets" / args.dataset
    if args.trim_mid_idle:
        assert_trim_target_is_new(ds)   # codex R6: 元データの上書きを防ぐ
    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]
        # LOG352 (codex R5 K3-a): top.mp4 が無い試行は対象から外すのが既定だが、
        # **recording_enabled=False を明示している試行だけは残す**。
        # こうしないと --no-record で走らせた試行が build_one に届かず、
        # index.json に no_recording の理由が残らない。
        runs = [p for p in runs
                if (p / "recording" / "top.mp4").exists()
                or _recording_disabled(p)]

    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()

次は16時半に学習が終わったら、同じ Pod で答え合わせをしてから実機で動かす

学習が終わったあとの順番は、次のように決めました。RAW は学習に使ったお手本(mecharm_redblock_box_teach_v1_trim2/raw_episodes)です。

  1. 学習に使った Pod で、そのまま推論サーバーを起動する(モデルを Mac と行き来させない)
  2. iPhone を上カメラの位置に付け、アームを HOME に置き、爪を手で開けておく
  3. python3 pi05/camera_align.py --raw-dir $RAW で、写り方が「OK」になるまで iPhone の位置を合わせる
  4. python3 pi05/eval_policy_offline.py --raw-dir $RAW で、お手本と答え合わせをする(「止まり」の出方を見る)
  5. python3 pi05/run_policy_client.py --raw-dir $RAW --max-chunks 5 でドライラン
  6. python3 pi05/run_policy_client.py --raw-dir $RAW --execute で、実機で通して動かす(つかむ → 箱に入れる)
  7. モデルとログを KIOXIA に持ち帰り、Pod を削除する

まだ確かめていないことも残っています。つかむ位置のずれ(Log#6)が、位置を変えたお手本で直ったかは、まだ分かりません。箱に入れる後半は、π0.5 ではまだ一度も試していません。写り方の判定のしきい値も、手元の数枚で決めたものです。