#!/usr/bin/env python3 """build_correction_episodes.py — 実機推論 (巻き戻し・補正つき) の記録から π0.5 学習用エピソードを作る (2026-10-29). 入力 (1 試行 = 1 run_stem. run_policy_client.py --execute が残したもの): runs/pi05_policy/{run_stem}/{run_stem}.json 結果 (outcome / judgments) runs/pi05_policy/{run_stem}/log.jsonl 推論・巻き戻し・補正の記録 runs/pi05_policy/{run_stem}/recording/states.csv actual (実測6軸) / gripper_cmd / stage (= 状態) runs/pi05_policy/{run_stem}/recording/top.mp4 + top_frame_log.csv 出力 (お手本 mecharm_redblock_box_teach_v1_trim2 と同じ形): datasets/{DATASET}/raw_episodes/{run_stem}/frames/000000.jpg ... / episode.npz / meta.json datasets/{DATASET}/index.json / README.md 作り方: - 使うのは 結果が「成功」(y) の run だけ - 時間軸は 10Hz. state は実測角の線形補間 + グリッパーは指令の時刻で 0/1 (build_demo_episodes と同じ) - 学習に使うのは状態が model / correct のステップだけ. rewind / home / wait (確認の入力待ち. 人がブロックを置き直すと手が写る) のステップと, 巻き戻しで取り消した区間 (区間を始めた時刻 〜 巻き戻しが終わった時刻) と, 取り消した補正 (補正を始めた時刻 〜 戻り終えた時刻) は除く. 除いたあとは「その手前」と「巻き戻したあとの続き」をつなぐ (戻った先は区間の始めの姿勢なので続く) - actions は「0.1 秒後の state」. 除いたあとに残ったステップで作り直す (trim2 と同じ作り方) - 止まっている区間は build_demo_episodes と同じ決まりで詰める (IDLE_* と --trim-mid-idle の mid_idle_keep_mask. 既定で詰める. --no-trim-mid-idle で前者だけ) - 元の記録は読むだけ (上書き・移動・削除しない) Usage: python3 pi05/build_correction_episodes.py # runs/pi05_policy の全 run python3 pi05/build_correction_episodes.py --run pi05_20261030_101500 --force python3 pi05/build_correction_episodes.py --check-with \\ /Volumes/KIOXIA/r2r2r-data/datasets/mecharm_redblock_box_teach_v1_trim2/raw_episodes """ import argparse import csv import json import shutil import sys from pathlib import Path import cv2 import numpy as np _HERE = Path(__file__).resolve().parent sys.path.insert(0, str(_HERE)) import build_demo_episodes as bde # noqa: E402 from rewind_correct import MODE_CORRECT, MODE_MODEL, excluded_windows, step_keep_mask # noqa: E402 DATA_ROOT = bde.DATA_ROOT RUNS_ROOT = DATA_ROOT / "runs" / "pi05_policy" DEFAULT_DATASET = "mecharm_redblock_box_corr_v1" FPS = bde.FPS def load_stage_times(path): """states.csv の stage 行 (状態の切り替わり) と, 各行の stage 列.""" ts, modes = [], [] with path.open(newline="") as f: for row in csv.DictReader(f): if row.get("event_type") != "stage": continue try: ts.append(float(row["ts"])); modes.append(row.get("stage") or MODE_MODEL) except Exception: continue return np.array(ts), modes def load_log(path): out = [] if not path.exists(): return out for line in path.read_text().splitlines(): line = line.strip() if line: try: out.append(json.loads(line)) except Exception: continue return out def idle_keep_mask(state, actions): """build_demo_episodes.build_one の「停止区間を詰める」(IDLE_*) と同じ決まり. 戻り値: (keep, 詰めた数).""" n = len(state) motion = np.max(np.abs(actions - state), axis=1) keep = np.ones(n, dtype=bool) i, trimmed = 0, 0 while i < n: if motion[i] < bde.IDLE_EPS_DEG: j = i while j < n and motion[j] < bde.IDLE_EPS_DEG: j += 1 if (j - i) / FPS > bde.IDLE_MAX_SEC: cut_from = i + int(bde.IDLE_KEEP_SEC * FPS) keep[cut_from:j] = False trimmed += j - cut_from i = j else: i += 1 guard = bde.gripper_guard_mask(state) trimmed -= int((~keep & guard).sum()) keep |= guard return keep, trimmed def next_state_actions(state): """actions = 次のステップの state (最後は自分自身). 7 列とも.""" return np.concatenate([state[1:], state[-1:]], axis=0) def select_steps(t, q, g, mode_ts, modes, windows, trim_mid_idle=True): """学習に使うステップを選び, state / actions を作る (ファイルを読まない部分. テストで使う). 戻り値: dict(idx=元の t の添字, state, actions, modes, counts) """ state_all = np.concatenate([q, g[:, None]], axis=1).astype(np.float32) keep, m_all = step_keep_mask(t, mode_ts, modes, windows) idx = np.flatnonzero(keep) counts = {"grid_steps": int(len(t)), "excluded_mode": int((~np.isin(m_all, (MODE_MODEL, MODE_CORRECT))).sum()), "excluded_window": int((np.isin(m_all, (MODE_MODEL, MODE_CORRECT)) & ~keep).sum())} if len(idx) == 0: return {"idx": idx, "state": state_all[:0], "actions": state_all[:0], "modes": [], "counts": counts} state = state_all[idx] # 除いたあとに残ったステップでつなぐ. actions は「つないだ並びでの次の state」 actions = next_state_actions(state) k1, idle_trimmed = idle_keep_mask(state, actions) idx, state, actions = idx[k1], state[k1], actions[k1] counts["idle_trimmed"] = int(idle_trimmed) counts["steps_before_mid_trim"] = int(len(idx)) mid = 0 if trim_mid_idle and len(idx): mk = bde.mid_idle_keep_mask(state, actions) mid = int((~mk).sum()) idx, state, actions = idx[mk], state[mk], actions[mk] # trim2 と同じ: 関節 6 列は残ったステップで作り直す. グリッパー列は元の (次の) 指令のまま actions = actions.copy() actions[:-1, :6] = state[1:, :6] actions[-1, :6] = state[-1, :6] counts["mid_idle_trimmed"] = mid sel_modes = [str(m_all[k]) for k in idx] counts["model_steps"] = sel_modes.count(MODE_MODEL) counts["correct_steps"] = sel_modes.count(MODE_CORRECT) return {"idx": idx, "state": state, "actions": actions, "modes": sel_modes, "counts": counts} def build_one(run_dir, out_dir, args): stem = run_dir.name trial_p = run_dir / f"{stem}.json" rec = run_dir / "recording" if not trial_p.exists(): return {"run": stem, "used": False, "reason": f"missing:{trial_p.name}"} trial = json.loads(trial_p.read_text()) if trial.get("recording_enabled") is not True: return {"run": stem, "used": False, "reason": "no_recording"} if trial.get("outcome") != "success": return {"run": stem, "used": False, "reason": f"outcome={trial.get('outcome')}"} 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)} log = load_log(run_dir / "log.jsonl") unfinished = [r for r in log if r.get("type") == "rewind" and not r.get("finished", True)] unfinished += [r for r in log if r.get("type") == "correction" and r.get("action") == "cancel" and not r.get("finished", True)] if unfinished: # 戻りきれなかった巻き戻しがあると, つないだ前後で姿勢が続かない return {"run": stem, "used": False, "reason": f"rewind_unfinished x{len(unfinished)}"} at, aq, gt, gs = bde.load_states(rec / "states.csv") if len(at) < bde.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 = bde.drop_spikes(at, aq) try: tm = json.loads((rec / "top_meta.json").read_text()) except Exception: tm = {} if tm.get("status") != "ok": return {"run": stem, "used": False, "reason": f"recording_status={tm.get('status')}"} fidx, fts = bde.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 mode_ts, modes = load_stage_times(rec / "states.csv") windows = excluded_windows(log) sel = select_steps(t, q, g, mode_ts, modes, windows, trim_mid_idle=not args.no_trim_mid_idle) if len(sel["idx"]) < FPS: return {"run": stem, "used": False, "reason": f"使えるステップが少ない ({len(sel['idx'])})"} tt = t[sel["idx"]] # codex R1-4: 画像の候補も同じ決まりで絞る (除いた区間・rewind / home の画像を, 境界の # 最近傍として拾わない). 候補から選ぶので, 除いた時刻の画像は入らない fkeep, _ = step_keep_mask(fts, mode_ts, modes, windows) fidx, fts = fidx[fkeep], fts[fkeep] if len(fts) < 2: return {"run": stem, "used": False, "reason": "使える画像が少ない"} nearest = np.clip(np.searchsorted(fts, tt), 1, len(fts) - 1) left = fts[nearest - 1]; right = fts[nearest] nearest = np.where(np.abs(tt - left) <= np.abs(right - tt), nearest - 1, nearest) want = {} for step, n in enumerate(nearest): want.setdefault(int(fidx[n]), []).append(step) if out_dir.exists(): if not args.force: return {"run": stem, "used": True, "reason": "既存 (スキップ・--force で再生成)", "steps": int(len(tt))} shutil.rmtree(out_dir) (out_dir / "frames").mkdir(parents=True) cap = cv2.VideoCapture(str(rec / "top.mp4")) fi, written, max_need, size = 0, 0, max(want), None while fi <= max_need: ok, frame = cap.read() if not ok: break if fi in want: h, w = frame.shape[:2] s = bde.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]: # codex R3 P2: 保存に失敗したコマは数えない (欠けたエピソードを採用しない) if cv2.imwrite(str(out_dir / "frames" / f"{step:06d}.jpg"), small, [cv2.IMWRITE_JPEG_QUALITY, bde.JPEG_QUALITY]): written += 1 fi += 1 cap.release() if written != len(tt): shutil.rmtree(out_dir) return {"run": stem, "used": False, "reason": f"フレーム抽出不足 written={written} steps={len(tt)} (mp4 と frame_log の不一致)"} np.savez_compressed(out_dir / "episode.npz", t=(np.arange(len(tt)) / FPS).astype(np.float64), t_orig=tt - tt[0], state=sel["state"], actions=sel["actions"]) c = sel["counts"] meta = { "run_stem": stem, "task": bde.TASK, "fps": FPS, "steps": int(len(tt)), "duration_sec": round(len(tt) / FPS, 1), "idle_trimmed_sec": round(c.get("idle_trimmed", 0) / FPS, 1), "trim_mid_idle": not args.no_trim_mid_idle, "steps_before_mid_trim": c.get("steps_before_mid_trim"), "mid_idle_trimmed_steps": c.get("mid_idle_trimmed", 0), "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": "yes", "place": "yes"}, "source": "pi05_policy (model + correct)", "model_steps": c.get("model_steps", 0), "correct_steps": c.get("correct_steps", 0), "excluded_mode_steps": c["excluded_mode"], "excluded_window_steps": c["excluded_window"], "excluded_windows": [[round(a, 3), round(b, 3)] for a, b in windows], "rewinds": sum(1 for r in log if r.get("type") == "rewind"), "corrections_accepted": sum(1 for r in log if r.get("type") == "correction" and r.get("action") == "accept"), "step_modes": sel["modes"], } (out_dir / "meta.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False)) return {"run": stem, "used": True, "reason": "ok", "steps": int(len(tt)), "model_steps": meta["model_steps"], "correct_steps": meta["correct_steps"], "rewinds": meta["rewinds"], "corrections_accepted": meta["corrections_accepted"]} def check_compat(ours, ref): """補正エピソードがお手本と一緒に学習に使えるか (形・キー・FPS・画像の大きさ). 問題の一覧を返す.""" probs = [] def _eps(d): return sorted(p.parent for p in Path(d).glob("*/episode.npz")) a, b = _eps(ours), _eps(ref) if not a: return ["補正エピソードがない"] if not b: return ["お手本エピソードがない"] rm = json.loads((b[0] / "meta.json").read_text()) rz = np.load(b[0] / "episode.npz") for ep in a: m = json.loads((ep / "meta.json").read_text()) z = np.load(ep / "episode.npz") if sorted(z.files) != sorted(rz.files): probs.append(f"{ep.name}: npz のキー {sorted(z.files)} != お手本 {sorted(rz.files)}") for k in ("state", "actions"): if z[k].dtype != rz[k].dtype or z[k].shape[1:] != rz[k].shape[1:]: probs.append(f"{ep.name}: {k} {z[k].dtype}{z[k].shape} != お手本 {rz[k].dtype}{rz[k].shape}") for k in ("fps", "image_size", "state_dim", "state_names", "task"): if m.get(k) != rm.get(k): probs.append(f"{ep.name}: meta {k}={m.get(k)} != お手本 {rm.get(k)}") n = len(z["state"]) nf = len(list((ep / "frames").glob("*.jpg"))) if not (n == len(z["actions"]) == nf == len(z["t"])): probs.append(f"{ep.name}: 長さが合わない state={n} actions={len(z['actions'])} frames={nf}") img = cv2.imread(str(ep / "frames" / "000000.jpg")) if img is None or (img.shape[1], img.shape[0]) != tuple(rm["image_size"]): probs.append(f"{ep.name}: 画像の大きさ {None if img is None else img.shape[:2][::-1]} " f"!= {rm['image_size']}") return probs def write_readme(ds, results, args): used = [r for r in results.values() if r.get("used")] lines = [ f"# {ds.name}", "", "実機推論 (`pi05/run_policy_client.py --execute`) で, 失敗しかけたら巻き戻し (b) /", "キーボードで関節を直し (c) ながら成功させた run から作ったエピソード。", "お手本 `mecharm_redblock_box_teach_v1_trim2` と同じ形 (frames/ + episode.npz + meta.json, 10Hz)。", "", "- 作成: `python3 pi05/build_correction_episodes.py`" + ("" if not args.no_trim_mid_idle else " --no-trim-mid-idle"), f"- 元データ: `runs/pi05_policy//` (読むだけ。上書き・移動・削除しない)", "", "## 作り方", "", "- 結果が「成功」(y) の run だけ使う (`{run_stem}.json` の outcome)", "- 状態が model / correct のステップだけ使う。rewind / home / wait (確認の入力待ち。手が写り得る) は除く", "- 巻き戻しで取り消した区間 (区間を始めた時刻 〜 巻き戻しが終わった時刻) と, 取り消した補正は除き,", " その手前と巻き戻したあとの続きをつなぐ", "- actions は「0.1 秒後の state」(つないだ並びで作り直す。グリッパー列は次の指令)", "- 止まっている区間は build_demo_episodes と同じ決まり (IDLE_* と mid_idle_keep_mask) で詰める", "- 戻りきれなかった巻き戻しがある run は使わない (前後の姿勢が続かない)", "", "## 数", "", f"採用 {len(used)} 本 / 総ステップ {sum(r.get('steps', 0) for r in used)} " f"(model {sum(r.get('model_steps', 0) for r in used)} / correct {sum(r.get('correct_steps', 0) for r in used)})", "", "| run_stem | 採用 | steps | model | correct | 巻き戻し | 補正 | 理由 |", "| --- | --- | --- | --- | --- | --- | --- | --- |", ] for k in sorted(results): r = results[k] lines.append(f"| {k} | {'○' if r.get('used') else '-'} | {r.get('steps', '')} | " f"{r.get('model_steps', '')} | {r.get('correct_steps', '')} | {r.get('rewinds', '')} | " f"{r.get('corrections_accepted', '')} | {r.get('reason', '')} |") (ds / "README.md").write_text("\n".join(lines) + "\n") def main(): ap = argparse.ArgumentParser() ap.add_argument("--dataset", default=DEFAULT_DATASET) ap.add_argument("--runs-root", default=str(RUNS_ROOT), help="run_policy_client の記録の置き場所") ap.add_argument("--run", action="append", help="特定の run_stem だけ (複数可)") ap.add_argument("--force", action="store_true", help="既存エピソードを作り直す") ap.add_argument("--no-trim-mid-idle", action="store_true", help="途中の停止を mid_idle_keep_mask で詰めない (既定は詰める = trim2 と同じ)") ap.add_argument("--check-with", help="お手本の raw_episodes. 一緒に学習に使えるかを確かめる") ap.add_argument("--data-root", help=argparse.SUPPRESS) # テスト用 (KIOXIA の代わり) args = ap.parse_args() if args.data_root: root = Path(args.data_root) else: bde.ensure_ssd() root = DATA_ROOT runs_root = Path(args.runs_root) ds = root / "datasets" / args.dataset out_root = ds / "raw_episodes" # 既存のお手本データセット (pi05_policy 以外から作ったエピソード) には書かない (codex R1 B3) others = [p.parent.name for p in out_root.glob("*/meta.json") if not str(json.loads(p.read_text()).get("source", "")).startswith("pi05_policy")] if others: sys.exit(f"[ABORT] 出力先に補正エピソード以外が入っています: {ds} (例: {', '.join(others[:3])})\n" " 元データを上書きしないよう, --dataset に別の名前を指定してください") runs = sorted(p for p in runs_root.glob("pi05_*") if p.is_dir() and (p / "recording").is_dir()) if args.run: runs = [p for p in runs if p.name in set(args.run)] if not runs: print(f"[INFO] 記録つきの run がない: {runs_root}") out_root.mkdir(parents=True, exist_ok=True) 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[r.name] = res print(f"[{'OK ' if res['used'] else '-- '}] {res['run']} {res.get('steps', '')} {res['reason']}") index_path = ds / "index.json" old = json.loads(index_path.read_text()) if index_path.exists() else {} old.update(results) index_path.write_text(json.dumps(old, indent=2, ensure_ascii=False)) write_readme(ds, old, args) 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)} → {out_root}") if args.check_with: probs = check_compat(out_root, args.check_with) print("[CHECK] お手本と一緒に使えるか: " + ("OK" if not probs else f"NG {len(probs)} 件")) for p in probs: print(" " + p) return 1 if probs else 0 return 0 if __name__ == "__main__": sys.exit(main())