#!/usr/bin/env python3 """merge_raw_episodes.py — いくつかの raw_episodes を 1 つのデータセットにまとめる (2026-10-31 依頼 45). お手本 (teach_v1_trim2) と補正エピソード (corr_v1) を合わせて, 1 つの raw_episodes にする. 1 つにするのは, 学習 (convert_to_lerobot.py --raw-dir) も実機 (run_policy_client.py --raw-dir の安全範囲・ eval_policy_offline.py) も「学習に使ったデータ」を 1 つのフォルダで受け取るため. python3 pi05/merge_raw_episodes.py \\ --src mecharm_redblock_box_teach_v1_trim2 --src mecharm_redblock_box_corr_v1 \\ --dataset mecharm_redblock_box_teach_v1_trim2_corr_v1 --tar 出力: datasets/{DATASET}/raw_episodes/{エピソード}/ 元のエピソードの写し (frames/ episode.npz meta.json) datasets/{DATASET}/index.json / README.md どの元から何本・何コマ datasets/{DATASET}/code/merge_raw_episodes.py このスクリプトの写し (データと対のコード) datasets/{DATASET}/{DATASET}_raw_episodes.tar (--tar) Pod へ送る形. ._* (AppleDouble) は入れない 決まり: - 元のデータセットは読むだけ (上書き・移動・削除しない) - 写す前に, 全部の元が お手本 (最初の --src) と一緒に使えるか (キー・型・FPS・画像の大きさ) を確かめ, 合わなければ何も書かずに止まる - エピソード名が重なったら止まる. 出力先がもうあれば止まる (--force で作り直す) - exFAT に macOS が作る ._* (AppleDouble) は写さない """ import argparse import json import os import shutil import subprocess import sys from pathlib import Path _HERE = Path(__file__).resolve() sys.path.insert(0, str(_HERE.parent)) import build_demo_episodes as bde # noqa: E402 from build_correction_episodes import _rmtree, check_compat # noqa: E402 DATA_ROOT = bde.DATA_ROOT def episodes(raw): return sorted(p.parent for p in Path(raw).glob("[!.]*/episode.npz")) def ep_summary(ep): meta = json.loads((ep / "meta.json").read_text()) return {"steps": int(meta["steps"]), "correct_steps": int(meta.get("correct_steps", 0)), "model_steps": int(meta.get("model_steps", 0)), "source": meta.get("source", "")} def _ignore_appledouble(_dir, names): return [n for n in names if n.startswith("._")] def make_tar(ds, name): """Pod へ送る tar (中身は raw_episodes/ だけ). ._* と macOS の拡張属性を入れない.""" out = ds / f"{name}_raw_episodes.tar" env = dict(os.environ, COPYFILE_DISABLE="1") subprocess.run(["tar", "--exclude", "._*", "-cf", str(out), "raw_episodes"], cwd=str(ds), env=env, check=True) return out def main(): ap = argparse.ArgumentParser() ap.add_argument("--src", action="append", required=True, help="まとめる元のデータセット名 (datasets/ の下). 最初の 1 つをお手本として形をそろえる") ap.add_argument("--dataset", required=True, help="まとめた先のデータセット名 (新しい名前)") ap.add_argument("--force", action="store_true", help="まとめた先を作り直す (元には触らない)") ap.add_argument("--tar", action="store_true", help="Pod へ送る tar も作る") 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 dsroot = root / "datasets" if args.dataset in args.src: sys.exit(f"[ABORT] まとめた先が元と同じ名前: {args.dataset}") srcs = [dsroot / s / "raw_episodes" for s in args.src] for s in srcs: if not episodes(s): sys.exit(f"[ABORT] エピソードがない: {s}") # 形の確かめ (書く前に) ref = srcs[0] for s in srcs[1:]: probs = check_compat(s, ref) if probs: print(f"[ABORT] {s} がお手本 {ref} と合わない:") for p in probs: print(" " + p) return 1 names = {} for s in srcs: for ep in episodes(s): if ep.name in names: sys.exit(f"[ABORT] エピソード名が重なる: {ep.name} ({names[ep.name]} と {s})") names[ep.name] = s ds = dsroot / args.dataset if ds.exists(): if not args.force: sys.exit(f"[ABORT] もうある: {ds} (作り直すなら --force)") _rmtree(ds) out = ds / "raw_episodes" out.mkdir(parents=True) index = {"sources": {}, "episodes": {}} for src_name, s in zip(args.src, srcs): eps = episodes(s) for ep in eps: shutil.copytree(ep, out / ep.name, ignore=_ignore_appledouble) index["episodes"][ep.name] = dict(ep_summary(ep), from_dataset=src_name) index["sources"][src_name] = { "episodes": len(eps), "steps": sum(index["episodes"][e.name]["steps"] for e in eps), "correct_steps": sum(index["episodes"][e.name]["correct_steps"] for e in eps)} print(f"[COPY] {src_name}: {len(eps)} 本 / {index['sources'][src_name]['steps']} コマ") # 写したあとも同じ形か (コマ数・キー) probs = check_compat(out, ref) if probs: print("[NG ] まとめたあとの確かめ:") for p in probs: print(" " + p) return 1 total_eps = len(index["episodes"]) total_steps = sum(v["steps"] for v in index["episodes"].values()) index.update(total_episodes=total_eps, total_steps=total_steps, repo_id=f"local/{args.dataset}") (ds / "index.json").write_text(json.dumps(index, indent=2, ensure_ascii=False)) lines = [ f"# {args.dataset}", "", "いくつかの raw_episodes を 1 つにまとめたデータセット (元は読むだけ。写しを置く)。", "", "- 作成: `python3 pi05/merge_raw_episodes.py " + " ".join(f"--src {s}" for s in args.src) + f" --dataset {args.dataset}`", f"- LeRobot に変換するときの repo_id: `local/{args.dataset}`", "", f"合計 {total_eps} 本 / {total_steps} コマ", "", "| 元 | 本数 | コマ | うち補正 (correct) |", "| --- | --- | --- | --- |", ] for k, v in index["sources"].items(): lines.append(f"| {k} | {v['episodes']} | {v['steps']} | {v['correct_steps']} |") (ds / "README.md").write_text("\n".join(lines) + "\n") code = ds / "code" code.mkdir(exist_ok=True) shutil.copy2(_HERE, code / _HERE.name) # データと対のコード print(f"\n合計 {total_eps} 本 / {total_steps} コマ → {out}") if args.tar: t = make_tar(ds, args.dataset) print(f"[TAR ] {t} ({t.stat().st_size / 1e6:.0f} MB)") return 0 if __name__ == "__main__": sys.exit(main())