commit f65fc211ba4d3f287c1a639582c06ee06864adb9 Author: Eiichi Shimazu Date: Sat Nov 7 20:58:32 2026 +0900 浅いお手本 7 本と指の写った 2 本を外した学習データ mecharm_redblock_box_teach_v1_trim2_corr_v5_deep (21 本 / 3018 コマ) を merge_raw_episodes.py の --exclude-json (理由つき. index.json の excluded と README に残す) で作る. G の measure_axis を pi05/grasp_depth.py にし (G の 30 本と差 0.0px), build_demo_episodes / build_correction_episodes に --max-protrude-px を足す (引数なしでは測らず, trim2 19 本・corr_v5 11 本を作り直して npz・meta・画まで同じ. 測れなかった本は外さない). 位置 5 の深いお手本を足す手順を pi05/DEEP_DEMOS.md に書く (依頼 117) Co-Authored-By: Claude Opus 5.5 diff --git a/logs/20261107_2058.md b/logs/20261107_2058.md new file mode 100644 index 0000000..0cd2346 --- /dev/null +++ b/logs/20261107_2058.md @@ -0,0 +1,25 @@ +# 2026-11-07 依頼 117:浅いお手本と指の写ったお手本を外した学習データを作り、つかみの深さで採否を決める + +実機・Pi・daemon・SSH・シリアル・Runpod には触れていない。 + +## A. 外した学習データ +- `datasets/mecharm_redblock_box_teach_v1_trim2_corr_v5_deep`(新しいフォルダ。元の trim2・corr_v5・teach_v1_trim2_corr_v5 は読んだだけ) +- 21 本 / 3018 コマ(教えた動き 14 本 1989 コマ・補正入り 7 本 1029 コマ、うち補正 282 コマ)。元は 30 本 / 4301 コマ / 補正 430 +- 外した 9 本: 浅い 7 本(181946・192101・184452・184906・183454・192343・192633)、指 2 本(191347・191441)。index.json の excluded・README の表と G への参照 +- 作り方: `merge_raw_episodes.py` に `--exclude-json`(理由つきの外す一覧)・`--readme-note` を足した。一覧は `pi05/excludes/teach_v1_trim2_corr_v5_deep.json` +- check_dataset: FAIL なし・WARN 2(2. 停止 = 元と同じ 190854 の 22.8%、5. ばらつき = j0 +15° 付近が 11/21 = 52.4% で半分を超えた。元は 15/30 = 50.0% で PASS) +- tar 82MB(`._` 0 個・episode.npz 21 個) + +## B. grasp_depth.py +- `pi05/grasp_depth.py`: G の measure_axis・E の red_center・G の red_center_wide をそのまま写し、run のフォルダから閉じる直前・1.0 秒後のはみ出し・手の印(閉じる前のコマで 100px 超)を返す。測れないときは unmeasured と理由 +- G の 30 本と比べて: after・before の差の最大 0.0px、閉じる命令の時刻の差 0、測ったコマ番号も同じ。浅い 7 本と手の 2 本の印も G と同じ +- `build_demo_episodes.py` / `build_correction_episodes.py` に `--max-protrude-px`。引数なしでは測らない +- 引数なしの確かめ: trim2 の 19 本(--trim-mid-idle)と corr_v5 の 11 本(--gap-fill speed20 --trim-hold)を /tmp に作り直し、今のデータセットと npz・meta.json・画のバイト列まで 30 本とも同じ。--max-protrude-px 24 では 7 本が外れ、残り 23 本も同じ +- テスト: `pi05/test_grasp_depth.py` 12 件 OK。pi05/test_*.py 11 本すべて OK + +## C. 手順 +- `pi05/DEEP_DEMOS.md`(位置 5 の `drag_v2_20221013_233323.json` を record_teach_demo.py で再生・閉じる前に止まって確かめる所はない・撮ったあと grasp_depth と --max-protrude-px・電流は教えた動きの記録には残らない) + +## 決めてほしいこと +- 手が写っていそうな本は印を出すだけで外していない(線を超えた本だけ外す)。手の本も自動で外すか +- 位置 5 の同じ動きを 5〜10 本足すと、j0 +15° 付近が 21/31 = 68% になる(check_dataset 5. の WARN が強まる) diff --git a/pi05/DEEP_DEMOS.md b/pi05/DEEP_DEMOS.md new file mode 100644 index 0000000..416fda7 --- /dev/null +++ b/pi05/DEEP_DEMOS.md @@ -0,0 +1,38 @@ +# 深いお手本を足す(試している置き場所・教えた位置 5。2026-11-07 依頼 117) + +学習(`RUNPOD_TRAIN.md`)の前に、π0.5 を試している置き場所(ブロックが画の中央・PICK の j0 +13°)で、ブロックが爪の奥まで入ってから閉じるお手本を 5〜10 本足す手順。今のデータは `mecharm_redblock_box_teach_v1_trim2_corr_v5_deep`(21 本。浅い 7 本・指の写った 2 本を外した版)。**この手順はまだ実機で通していない**(書いただけ)。 + +**1. 使うスクリプト**: 今の `dynamic_tracking_limits/drag_teaching/record_teach_demo.py` で、LOG352 の位置 5 の教えた記録 `drag_v2_20221013_233323.json` を再生する(新しく教え直さないので、Pi の `drag_teach_v2.py teach` は飛ばす)。10/19 の `run_0001_20261019_194818`〜`195120` の 4 本はこの形で撮ったもので、閉じて 1.0 秒後のはみ出しは 8〜15px(深い)。 + +``` +# ブロックを位置 5 に置く(10/19 の measure は block_xz (+45.7, −28.5)。teach_poses_LOG352/measure_20261019_194751.json) +python3 dynamic_tracking_limits/place/place_modules/recorder.py --top --no-side # 別のターミナルで常駐 +python3 dynamic_tracking_limits/drag_teaching/record_teach_demo.py measure # 1 本ごとに置き直したら測り直す (保存先 teach_poses_{日付}/) +python3 dynamic_tracking_limits/drag_teaching/record_teach_demo.py run --teach drag_v2_20221013_233323.json # ドライラン(計画だけ) +python3 dynamic_tracking_limits/drag_teaching/record_teach_demo.py run --teach drag_v2_20221013_233323.json --execute # 再生+記録 +``` + +- **同じ動きの繰り返しになる**: 再生するのは教えたときの関節の角度そのもので、ブロックの位置を見て動きを変えない。5〜10 本とも同じ PICK の姿勢(`pose_used.pick_used` = [13.18, 60.02, 19.51, −2.46, −61.87, −26.71])で閉じる。画の違いは、人が置いたブロックの数 mm のずれと光だけになる。ブロックを毎回同じ所に置くほど深くつかめるが、学習には同じ画と同じ動きが増える(check_dataset の 5. で j0 +15° 付近が今でも 21 本中 11 本・52%。10 本足すと 31 本中 21 本・68%) +- `run` が確かめるのは「measure のあとでブロックが動いていないか」(5mm)だけで、教えたときの位置と同じかは見ない。位置 5 に置けたかは、measure の block_xz を 10/19 の値と人が見比べる。10/19 の 4 本はどれも再生直前の検出で WARN(ブロックを見つけられない・赤いケーブルを拾う)が出ていたが、完走している + +**2. 閉じる前に奥まで入っているかを確かめる所**: **今の作りにはない。** `run --execute` は、始める前の「周囲に手・物が無いか確認して Enter」のあとは止まらない。PICK に着くと、止まったのを最大 8 秒待ってから(止まらなくても WARN だけで)そのまま閉じる。人が見られるのは、Enter の前の画(ブロックの置き方)と、終わったあとの y/n と動画だけ。閉じる前に人がブロックを押し込むと、指が学習の画に写る(191347・191441 で起きた。外した)。浅いと思ったら Enter の前に置き直すか、撮ったあとで外す。 + +**3. 撮ったあとに深さを測って、線を超えた本を外す** + +``` +# 1 本ずつの深さを見る(読むだけ) +python3 pi05/grasp_depth.py /Volumes/KIOXIA/r2r2r-data/runs/run_pick_place/run_0001_YYYYMMDD_HHMMSS +# 新しい本だけのデータセットを作る(新しい名前。線を超えた本は外し、index.json の grasp_depth に値と理由を残す) +python3 pi05/build_demo_episodes.py --dataset mecharm_redblock_box_teach_pos5_deep_v1 --trim-mid-idle \ + --max-protrude-px 24 --run run_0001_YYYYMMDD_HHMMSS --run ... +# 今の deep とまとめる(元は変えない。外す本が増えたら --exclude-json に理由と一緒に書く) +python3 pi05/merge_raw_episodes.py --src mecharm_redblock_box_teach_v1_trim2_corr_v5_deep \ + --src mecharm_redblock_box_teach_pos5_deep_v1 --dataset <新しい名前> --tar +python3 pi05/check_dataset.py /Volumes/KIOXIA/r2r2r-data/datasets/<新しい名前>/raw_episodes +``` + +- `build_demo_episodes.py` の最後に `[DEPTH]` の行が出る: 浅くて外した本・**測れなかった本(外していない)**・**手が写っていそうな本(外していない)** の数と名前。測れなかった本と手の本は、`top.mp4` の閉じる前後を人が見て、外すなら merge の `--exclude-json` に書く +- 線は 24px(閉じて 1.0 秒後のはみ出し。約 9mm)。値の意味は `lab/twin/20261107_失敗の再生と置き場所/G_お手本の閉じる時刻/README.md` +- 補正入りの run(`run_policy_client.py`)を足すときも同じで、`build_correction_episodes.py ... --max-protrude-px 24` を付ける + +**4. 爪の電流でつかめたかを見る(依頼 116)**: 依頼 116 で電流を残すようになったのは実機推論の記録(`pi05/policy_recorder.py` → `{run}/recording/currents.csv`。`run_policy_client.py` で動かしたとき)だけ。**`record_teach_demo.py` の再生の記録(`recorder.py`)には電流は残らない**ので、この手順の教えた動きでは電流で見られない。補正入りの run を足すときは、`column -s, -t < {run}/recording/currents.csv | less` で閉じたあとの `gripper_current` を見る(依頼 116 の確かめ方では、つかむと数百〜990、放すと −50 前後になるはず。まだ実機で確かめていない。daemon に read_servo を置いていなければ `top_meta.json` の `currents_mode` が `get_angles_fallback` で、電流は空)。 diff --git a/pi05/build_correction_episodes.py b/pi05/build_correction_episodes.py index 1eaf821..e5bce79 100644 --- a/pi05/build_correction_episodes.py +++ b/pi05/build_correction_episodes.py @@ -27,6 +27,10 @@ mid_idle_keep_mask. 既定で詰める. --no-trim-mid-idle で前者だけ) - --trim-hold (corr_v3. 依頼 48): 中継点・補正の 1 刻みごとの「着いたあとの止まり」を, 区間ごとに 残す数を変えて詰める (hold_keep_mask. 止まりの判定とずれの切り方は mid_idle_keep_mask と同じ) + - --max-protrude-px (2026-11-07 依頼 117): grasp_depth.py で閉じて 1.0 秒後のブロックのはみ出しを測り, + それを超える本 (浅いつかみ) を外す. 閉じる命令は, エピソードで最初に爪が 1 になったコマの時刻かそれより前の + いちばん遅い命令. 値と理由は index.json の grasp_depth に残す. 測れなかった本は外さない. + 付けないときは測らず, 出力は今までと同じ - 元の記録は読むだけ (上書き・移動・削除しない) Usage: @@ -385,6 +389,18 @@ def build_one(run_dir, out_dir, args): return {"run": stem, "used": False, "reason": f"使えるステップが少ない ({len(sel['idx'])})"} tt = t[sel["idx"]] + # 依頼 117: つかみの深さで採否を決める (--max-protrude-px のときだけ. 既定では測らない) + depth = None + max_pr = getattr(args, "max_protrude_px", None) + if max_pr is not None: + import grasp_depth + drop, why, depth = grasp_depth.judge(run_dir, max_pr, + grasp_depth.first_close_label_time(tt, sel["state"][:, 6])) + if drop: + if out_dir.exists(): + why += " (前に作ったエピソードが残っている. 消していない)" + return {"run": stem, "used": False, "reason": why, "grasp_depth": depth} + # codex R1-4: 画像の候補も同じ決まりで絞る (除いた区間・rewind / home の画像を, 境界の # 最近傍として拾わない). 候補から選ぶので, 除いた時刻の画像は入らない fkeep, _ = step_keep_mask(fts, mode_ts, modes, windows) @@ -465,10 +481,13 @@ def build_one(run_dir, out_dir, args): "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"], - "correction_rows": meta["correction_rows"], "max_step_deg": meta["max_step_deg"]} + res = {"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"], + "correction_rows": meta["correction_rows"], "max_step_deg": meta["max_step_deg"]} + if depth is not None: + res["grasp_depth"] = depth + return res def check_compat(ours, ref): @@ -517,7 +536,8 @@ def write_readme(ds, results, args): "- 作成: `python3 pi05/build_correction_episodes.py`" + ("" if not args.no_trim_mid_idle else " --no-trim-mid-idle") + f" --gap-fill {args.gap_fill}" - + (" --trim-hold" if getattr(args, "trim_hold", False) else ""), + + (" --trim-hold" if getattr(args, "trim_hold", False) else "") + + (f" --max-protrude-px {args.max_protrude_px:g}" if getattr(args, "max_protrude_px", None) is not None else ""), f"- 元データ: `runs/pi05_policy//` (読むだけ。上書き・移動・削除しない)", "", "## 作り方", "", "- 結果が「成功」(y) の run だけ使う (`{run_stem}.json` の outcome)", @@ -534,7 +554,13 @@ def write_readme(ds, results, args): + ("前後の実測角をまっすぐ結ぶ (linear)" if args.gap_fill == "linear" else f"抜けの頭から速度 20 の速さ ({SPEED20_DEG_PER_SEC:.0f}°/秒. いちばん大きく動く関節) で前後の実測角を結ぶ" "直線を進み, 着いたら止まる (speed20)"), - "- 戻りきれなかった巻き戻しがある run は使わない (前後の姿勢が続かない)", "", + "- 戻りきれなかった巻き戻しがある run は使わない (前後の姿勢が続かない)", + ] + if getattr(args, "max_protrude_px", None) is not None: + lines.append(f"- 閉じて 1.0 秒後のブロックのはみ出し (`pi05/grasp_depth.py`) が {args.max_protrude_px:g}px を超える" + " run は使わない (浅いつかみ。値は index.json の grasp_depth。測れなかった run は外さない)") + lines += [ + "", "## 数", "", 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)})", "", @@ -550,12 +576,14 @@ def write_readme(ds, results, args): (ds / "README.md").write_text("\n".join(lines) + "\n") -def copy_code(ds): +def copy_code(ds, with_depth=False): """データと対のコード: このデータを作ったスクリプト (と読み込む部品) を ds/code/ に写す.""" dest = ds / "code" dest.mkdir(parents=True, exist_ok=True) for name in ("build_correction_episodes.py", "rewind_correct.py", "build_demo_episodes.py"): shutil.copy2(_HERE / name, dest / name) + if with_depth: + shutil.copy2(_HERE / "grasp_depth.py", dest / "grasp_depth.py") return dest @@ -573,6 +601,9 @@ def main(): "(corr_v2. 依頼 46)") ap.add_argument("--trim-hold", action="store_true", help="中継点・補正の 1 刻みごとの着いたあとの止まりを区間ごとに詰める (corr_v3. 依頼 48)") + ap.add_argument("--max-protrude-px", type=float, default=None, + help="閉じて 1.0 秒後のブロックのはみ出し (grasp_depth.py) がこれを超える run を外す. " + "例 24 (エイイチの決め 2026-11-07). 既定: 測らない (依頼 117)") ap.add_argument("--data-root", help=argparse.SUPPRESS) # テスト用 (KIOXIA の代わり) args = ap.parse_args() if args.trim_hold and args.no_trim_mid_idle: @@ -610,9 +641,11 @@ def main(): old.update(results) index_path.write_text(json.dumps(old, indent=2, ensure_ascii=False)) write_readme(ds, old, args) - print(f"[CODE] データと対のコード → {copy_code(ds)}") + print(f"[CODE] データと対のコード → {copy_code(ds, args.max_protrude_px is not None)}") 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.max_protrude_px is not None: + bde.print_depth_summary(list(results.values()), args.max_protrude_px) if args.check_with: probs = check_compat(out_root, args.check_with) print("[CHECK] お手本と一緒に使えるか: " + ("OK" if not probs else f"NG {len(probs)} 件")) diff --git a/pi05/build_demo_episodes.py b/pi05/build_demo_episodes.py index 669f520..d15fc31 100644 --- a/pi05/build_demo_episodes.py +++ b/pi05/build_demo_episodes.py @@ -26,6 +26,9 @@ 元の時刻を t_orig に残す. オプション未指定時の出力は従来と同一. - グリッパー切替ステップの前後 (i-1, i, i+1) は どちらの停止詰めでも必ず残す. - 採用条件: pick と place の人の判定がどちらも yes (既定). VLM 判定は使わない. + - --max-protrude-px を付けると (2026-11-07 依頼 117), grasp_depth.py で閉じて 1.0 秒後のブロックのはみ出しを測り, + それを超える本 (浅いつかみ) を外す. 測った値と理由は index.json の grasp_depth に残す. + 測れなかった本は外さない (grasp_depth.status=unmeasured). 付けないときは測らず, 出力は今までと同じ. Usage: python3 pi05/build_demo_episodes.py # 2026-10-15 以降の全試行 @@ -324,6 +327,17 @@ def build_one(run_dir, out_dir, args): state = np.concatenate([q, g[:, None]], axis=1).astype(np.float32) actions = np.concatenate([state[1:], state[-1:]], axis=0) + # 依頼 117: つかみの深さで採否を決める (--max-protrude-px のときだけ. 既定では測らない) + depth = None + max_pr = getattr(args, "max_protrude_px", None) + if max_pr is not None: + import grasp_depth + drop, why, depth = grasp_depth.judge(run_dir, max_pr, grasp_depth.first_close_label_time(t, g)) + if drop: + if out_dir.exists(): + why += " (前に作ったエピソードが残っている. 消していない)" + return {"run": stem, "used": False, "reason": why, "grasp_depth": depth} + # 停止区間を詰める motion = np.max(np.abs(actions - state), axis=1) keep = np.ones(len(t), dtype=bool) @@ -421,10 +435,13 @@ def build_one(run_dir, out_dir, args): "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} + res = {"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} + if depth is not None: + res["grasp_depth"] = depth + return res def assert_trim_target_is_new(ds): @@ -459,6 +476,9 @@ def main(): ap.add_argument("--trim-mid-idle", action="store_true", help="途中の停止区間を先頭 %d ステップだけ残して詰める (既定: しない)" % MID_IDLE_KEEP_STEPS) + ap.add_argument("--max-protrude-px", type=float, default=None, + help="閉じて 1.0 秒後のブロックのはみ出し (grasp_depth.py) がこれを超える本を外す. " + "例 24 (エイイチの決め 2026-11-07). 既定: 測らない (依頼 117)") args = ap.parse_args() ensure_ssd() @@ -498,6 +518,22 @@ def main(): 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 args.max_protrude_px is not None: + print_depth_summary(results, args.max_protrude_px) + + +def print_depth_summary(results, line_px): + """依頼 117: つかみの深さで外した本・測れなかった本・手が写っていそうな本の数を出す.""" + ds_ = [r for r in results if r.get("grasp_depth")] + shallow = [r for r in ds_ if r["grasp_depth"].get("shallow")] + unmeasured = [r for r in ds_ if r["grasp_depth"].get("status") != "ok"] + hand = [r for r in ds_ if r["grasp_depth"].get("hand_suspect")] + print(f"[DEPTH] 線 {line_px:g}px: 測った {len(ds_)} 本 / 浅くて外した {len(shallow)} 本 / " + f"測れなかった {len(unmeasured)} 本 (外していない) / 手が写っていそう {len(hand)} 本 (外していない)") + for r in unmeasured: + print(f" 測れなかった: {r['run']} {r['grasp_depth'].get('reason')}") + for r in hand: + print(f" 手が写っていそう: {r['run']} (画で確かめること)") if __name__ == "__main__": diff --git a/pi05/excludes/teach_v1_trim2_corr_v5_deep.json b/pi05/excludes/teach_v1_trim2_corr_v5_deep.json new file mode 100644 index 0000000..c3557d5 --- /dev/null +++ b/pi05/excludes/teach_v1_trim2_corr_v5_deep.json @@ -0,0 +1,299 @@ +{ + "pi05_20261031_181946": { + "reason": "浅い: 閉じて 1.0 秒後のはみ出し 37.8px > 24px", + "kind": "補正入り", + "after_px": 37.8, + "before_px": 61.4, + "hand_in_frame": false, + "line_px": 24.0, + "measured_by": "依頼113 G g_table.csv (corr_v5_main) / pi05/grasp_depth.py で同じ値", + "grasp_depth": { + "status": "ok", + "reason": null, + "t_close": 1793438589.926, + "frame_before": 1763, + "frame_after": 1773, + "before_px": 61.4, + "after_px": 37.8, + "after_gap_px": 99.0, + "hand_suspect": false, + "pre_px": { + "pre1.5s": 60.9, + "pre0.5s": 61.0, + "before": 61.4 + }, + "center_px": [ + 1148, + 699 + ], + "center_by": "E", + "axis_from": "body", + "max_protrude_px": 24.0, + "shallow": true + } + }, + "pi05_20261101_192101": { + "reason": "浅い: 閉じて 1.0 秒後のはみ出し 33.4px > 24px", + "kind": "補正入り", + "after_px": 33.4, + "before_px": 57.4, + "hand_in_frame": false, + "line_px": 24.0, + "measured_by": "依頼113 G g_table.csv (corr_v5_main) / pi05/grasp_depth.py で同じ値", + "grasp_depth": { + "status": "ok", + "reason": null, + "t_close": 1793528521.445, + "frame_before": 507, + "frame_after": 517, + "before_px": 57.4, + "after_px": 33.4, + "after_gap_px": 101.0, + "hand_suspect": false, + "pre_px": { + "pre1.5s": 57.5, + "pre0.5s": 57.4, + "before": 57.4 + }, + "center_px": [ + 1146, + 695 + ], + "center_by": "E", + "axis_from": "body", + "max_protrude_px": 24.0, + "shallow": true + } + }, + "pi05_20261106_184452": { + "reason": "浅い: 閉じて 1.0 秒後のはみ出し 49.2px > 24px", + "kind": "補正入り", + "after_px": 49.2, + "before_px": 75.0, + "hand_in_frame": false, + "line_px": 24.0, + "measured_by": "依頼113 G g_table.csv (corr_v5_main) / pi05/grasp_depth.py で同じ値", + "grasp_depth": { + "status": "ok", + "reason": null, + "t_close": 1793958333.441, + "frame_before": 321, + "frame_after": 332, + "before_px": 75.0, + "after_px": 49.2, + "after_gap_px": 99.5, + "hand_suspect": false, + "pre_px": { + "pre1.5s": 67.5, + "pre0.5s": 82.9, + "before": 75.0 + }, + "center_px": [ + 1147, + 701 + ], + "center_by": "E", + "axis_from": "body", + "max_protrude_px": 24.0, + "shallow": true + } + }, + "pi05_20261106_184906": { + "reason": "浅い: 閉じて 1.0 秒後のはみ出し 47.1px > 24px", + "kind": "補正入り", + "after_px": 47.1, + "before_px": 73.8, + "hand_in_frame": false, + "line_px": 24.0, + "measured_by": "依頼113 G g_table.csv (corr_v5_main) / pi05/grasp_depth.py で同じ値", + "grasp_depth": { + "status": "ok", + "reason": null, + "t_close": 1793958582.267, + "frame_before": 276, + "frame_after": 286, + "before_px": 73.8, + "after_px": 47.1, + "after_gap_px": 99.4, + "hand_suspect": false, + "pre_px": { + "pre1.5s": 73.4, + "pre0.5s": 82.2, + "before": 73.8 + }, + "center_px": [ + 1141, + 700 + ], + "center_by": "E", + "axis_from": "body", + "max_protrude_px": 24.0, + "shallow": true + } + }, + "run_0001_20261019_183454": { + "reason": "浅い: 閉じて 1.0 秒後のはみ出し 38.9px > 24px", + "kind": "教えた動き", + "after_px": 38.9, + "before_px": 67.2, + "hand_in_frame": false, + "line_px": 24.0, + "measured_by": "依頼113 G g_table.csv (corr_v5_main) / pi05/grasp_depth.py で同じ値", + "grasp_depth": { + "status": "ok", + "reason": null, + "t_close": 1792402514.214, + "frame_before": 476, + "frame_after": 501, + "before_px": 67.2, + "after_px": 38.9, + "after_gap_px": 99.7, + "hand_suspect": false, + "pre_px": { + "pre1.5s": 59.0, + "pre0.5s": 67.2, + "before": 67.2 + }, + "center_px": [ + 608, + 594 + ], + "center_by": "wide", + "axis_from": "tips", + "max_protrude_px": 24.0, + "shallow": true + } + }, + "run_0001_20261019_191347": { + "reason": "人の指が写っている: 閉じる直前〜ラベルのコマで指がブロックを押し込む (閉じて 1.0 秒後 19.7px)", + "kind": "教えた動き", + "after_px": 19.7, + "before_px": 189.5, + "hand_in_frame": true, + "line_px": 24.0, + "measured_by": "依頼113 G g_table.csv (corr_v5_main) / pi05/grasp_depth.py で同じ値", + "grasp_depth": { + "status": "ok", + "reason": null, + "t_close": 1792404847.237, + "frame_before": 466, + "frame_after": 491, + "before_px": null, + "after_px": 19.7, + "after_gap_px": 100.1, + "hand_suspect": true, + "pre_px": { + "pre1.5s": 359.9, + "pre0.5s": 55.4, + "before": 189.5 + }, + "center_px": [ + 549, + 683 + ], + "center_by": "wide", + "axis_from": "body", + "max_protrude_px": 24.0, + "shallow": false + } + }, + "run_0001_20261019_191441": { + "reason": "人の指が写っている: 閉じる直前〜ラベルのコマで指がブロックを押し込む (閉じて 1.0 秒後 14.5px)", + "kind": "教えた動き", + "after_px": 14.5, + "before_px": 177.3, + "hand_in_frame": true, + "line_px": 24.0, + "measured_by": "依頼113 G g_table.csv (corr_v5_main) / pi05/grasp_depth.py で同じ値", + "grasp_depth": { + "status": "ok", + "reason": null, + "t_close": 1792404906.418, + "frame_before": 598, + "frame_after": 623, + "before_px": null, + "after_px": 14.5, + "after_gap_px": 99.5, + "hand_suspect": true, + "pre_px": { + "pre1.5s": 206.8, + "pre0.5s": 68.1, + "before": 177.3 + }, + "center_px": [ + 500, + 791 + ], + "center_by": "wide", + "axis_from": "body", + "max_protrude_px": 24.0, + "shallow": false + } + }, + "run_0001_20261019_192343": { + "reason": "浅い: 閉じて 1.0 秒後のはみ出し 27.6px > 24px", + "kind": "教えた動き", + "after_px": 27.6, + "before_px": 54.5, + "hand_in_frame": false, + "line_px": 24.0, + "measured_by": "依頼113 G g_table.csv (corr_v5_main) / pi05/grasp_depth.py で同じ値", + "grasp_depth": { + "status": "ok", + "reason": null, + "t_close": 1792405442.394, + "frame_before": 452, + "frame_after": 477, + "before_px": 54.5, + "after_px": 27.6, + "after_gap_px": 100.0, + "hand_suspect": false, + "pre_px": { + "pre1.5s": 54.8, + "pre0.5s": 54.7, + "before": 54.5 + }, + "center_px": [ + 747, + 649 + ], + "center_by": "E", + "axis_from": "body", + "max_protrude_px": 24.0, + "shallow": true + } + }, + "run_0001_20261019_192633": { + "reason": "浅い: 閉じて 1.0 秒後のはみ出し 28.9px > 24px", + "kind": "教えた動き", + "after_px": 28.9, + "before_px": 59.0, + "hand_in_frame": false, + "line_px": 24.0, + "measured_by": "依頼113 G g_table.csv (corr_v5_main) / pi05/grasp_depth.py で同じ値", + "grasp_depth": { + "status": "ok", + "reason": null, + "t_close": 1792405612.535, + "frame_before": 450, + "frame_after": 475, + "before_px": 59.0, + "after_px": 28.9, + "after_gap_px": 99.9, + "hand_suspect": false, + "pre_px": { + "pre1.5s": 59.1, + "pre0.5s": 59.0, + "before": 59.0 + }, + "center_px": [ + 775, + 665 + ], + "center_by": "E", + "axis_from": "body", + "max_protrude_px": 24.0, + "shallow": true + } + } +} \ No newline at end of file diff --git a/pi05/excludes/teach_v1_trim2_corr_v5_deep_note.md b/pi05/excludes/teach_v1_trim2_corr_v5_deep_note.md new file mode 100644 index 0000000..001f409 --- /dev/null +++ b/pi05/excludes/teach_v1_trim2_corr_v5_deep_note.md @@ -0,0 +1,23 @@ +## 外した理由(2026-11-07 依頼 117。エイイチの決め) + +`mecharm_redblock_box_teach_v1_trim2_corr_v5`(v3 の学習データ 30 本)から、**浅いつかみの 7 本**と**人の指が写った 2 本**を外した版。元の `teach_v1_trim2`・`corr_v5`・`teach_v1_trim2_corr_v5` は変えていない(読んで写しただけ)。 + +- 調べた記録: `lab/twin/20261107_失敗の再生と置き場所/G_お手本の閉じる時刻/README.md`(依頼 113 G)。値は同じフォルダの `g_table.csv`(dataset = `corr_v5_main`)と `g_grasp_metrics.csv` +- 測り方: G の `g_images.py` の `measure_axis`(爪の向きに沿ったはみ出し。ブロックの赤の画素を爪の向きに投げた最大 − 爪先の青い印 2 つの中点。元の `top.mp4` の 1920×1080 で測る。約 2.6px/mm)。同じ測り方を部品にした `pi05/grasp_depth.py` で測り直し、30 本とも G と同じ値(差 0.0px) +- 浅いとみなす線: 閉じる命令の 1.0 秒後のはみ出しが **24px(約 9mm)を超える**(π0.5 の成功 4 件の最大 14px + 10px) +- 指: 教えた動きの 191347・191441 は、閉じる瞬間に人の指がブロックを爪の間へ押し込んでいて、学習の画に写っている(G で画を見て確かめた。肌を赤として拾うので閉じる直前の値は 177〜190px になり使えない) + +| エピソード | 種類 | 閉じる直前 px | 閉じて 1.0 秒後 px | 理由 | +| --- | --- | --- | --- | --- | +| pi05_20261031_181946 | 補正入り | 61.4 | 37.8 | 浅い | +| pi05_20261101_192101 | 補正入り | 57.4 | 33.4 | 浅い | +| pi05_20261106_184452 | 補正入り | 75.0 | 49.2 | 浅い | +| pi05_20261106_184906 | 補正入り | 73.8 | 47.1 | 浅い | +| run_0001_20261019_183454 | 教えた動き | 67.2 | 38.9 | 浅い | +| run_0001_20261019_192343 | 教えた動き | 54.5 | 27.6 | 浅い | +| run_0001_20261019_192633 | 教えた動き | 59.0 | 28.9 | 浅い | +| run_0001_20261019_191347 | 教えた動き | —(指) | 19.7 | 人の指が写っている | +| run_0001_20261019_191441 | 教えた動き | —(指) | 14.5 | 人の指が写っている | + +- 外した本の一覧と測った値は `index.json` の `excluded`、作るときに渡した一覧は `code/teach_v1_trim2_corr_v5_deep.json`(r2r2r-scripts の `pi05/excludes/` と同じもの) +- 試している置き場所(教えた位置 5 = `drag_v2_20221013_233323`)の教えた動き 4 本(194818〜195120。8〜15px)は残っている。補正入りは浅い 4 本が抜けて 7 本 diff --git a/pi05/grasp_depth.py b/pi05/grasp_depth.py new file mode 100644 index 0000000..37bfbbf --- /dev/null +++ b/pi05/grasp_depth.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +"""grasp_depth.py — 1 本の記録 (run のフォルダ) で, 爪を閉じたときにブロックが爪の奥まで入っていたかを測る (2026-11-07 依頼 117). + +依頼 113 G (lab/twin/20261107_失敗の再生と置き場所/G_お手本の閉じる時刻/g_images.py の measure_axis) と同じ測り方を, +学習データを作るスクリプト (build_demo_episodes.py / build_correction_episodes.py の --max-protrude-px) から使える形にした. +赤の決まり・青の印の決まり・ブロックの探し方は E (e_images.red_center) と G (red_center_wide・measure_axis) のまま. + +入力 (run のフォルダ. runs/run_pick_place/{run}/ でも runs/pi05_policy/{run}/ でもよい): + recording/states.csv 爪の命令 (event_type=gripper_cmd. gripper_state=1 が閉じる) の時刻 + recording/top_frame_log.csv 上カメラのコマの時刻 (frame_idx, capture_ts) + recording/top.mp4 上カメラ (元の 1920x1080 のまま測る) + +測るコマ (G と同じ): + before : 閉じる命令の時刻か, それより前でいちばん近いコマ (閉じる直前) + after : 閉じる命令の 1.0 秒後か, それよりあとでいちばん近いコマ (爪が閉じきった形) + pre1p5 / pre0p5 : 命令の 1.5 秒前・0.5 秒前 (人の手が写っていないかを見るためだけに使う) + +はみ出し (protrude_px): 爪の向き u (胴の青い印 → 爪先の青い印 2 つの中点) に沿って, ブロックの赤の画素を u に投げた +最大 − 爪先の印の中点. 大きいほどブロックが爪先から先へ出ている (= 浅い). 約 2.6px/mm (E の目安). +人の手: 肌を赤として拾うと値が HAND_PX (100px) を超える (G で確かめた). 閉じる前のコマのどれかでそうなったら +hand_suspect=True にし, その回の before_px は使わない (None). after_px は爪が閉じたあとなので残す. + +測れなかった本 (爪の命令がない・コマがない・印が見えない・ブロックが見つからない) は status="unmeasured" と +理由 (reason) を返す. 学習データを作る側は, 測れなかった本は外さずに「測れなかった」と残す. + + python3 pi05/grasp_depth.py /Volumes/KIOXIA/r2r2r-data/runs/run_pick_place/run_0001_20261019_194818 + python3 pi05/grasp_depth.py RUN_DIR [RUN_DIR ...] --csv out.csv # 何本でも. 1 本 1 行 + +読むだけ (記録には何も書かない). +""" +import argparse +import csv +import json +import sys +from pathlib import Path + +import cv2 +import numpy as np + +AFTER_SEC = 1.0 # 閉じてから何秒後のコマで測るか (E・G と同じ) +PRE_SECS = (1.5, 0.5) # 手が写っていないかを見る閉じる前のコマ (G の pre1p5 / pre0p5) +DEFAULT_MAX_PROTRUDE_PX = 24.0 # 浅いとみなす線 (E の成功の最大 14px + 10px. エイイチの決め 2026-11-07) +HAND_PX = 100.0 # 閉じる前のはみ出しがこれを超えたら, 人の手 (肌) を赤として拾っている印 (G) +HALF = 350 # 爪の向きを測るときの切り出しの半分 (胴の青い印まで入るように) +# 赤・青の色の幅 (E と同じ) +BLUE_LO, BLUE_HI = (100, 150, 80), (130, 255, 255) + + +def _red_mask(hsv, s_min): + return cv2.inRange(hsv, (0, s_min, 70), (10, 255, 255)) | cv2.inRange(hsv, (170, s_min, 70), (180, 255, 255)) + + +def red_center(img): + """E の red_center と同じ. 画の x 700〜1400・y 350 より下で, 面積 300 以上・細長くない赤の塊のいちばん大きいもの.""" + hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) + m = _red_mask(hsv, 120) + m[:, :700] = 0; m[:, 1400:] = 0; m[:350] = 0 + n, lab, st, cen = cv2.connectedComponentsWithStats(m) + best = None + for k in range(1, n): + x, y, w, h, a = st[k] + if a < 300 or max(w, h) > 3 * min(w, h): # 細長い赤 (配線) は外す + continue + if best is None or a > best[0]: + best = (a, cen[k]) + return None if best is None else (int(best[1][0]), int(best[1][1])) + + +def red_center_wide(img, s_min=120): + """G の red_center_wide と同じ. 探す範囲を画の全体に広げ, 爪先の青い印 2 つが 200px 以内にある赤の塊だけを候補にする + (10/15〜10/19 の記録はブロックが画の左寄りにあるため).""" + hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) + m = _red_mask(hsv, s_min) + m[:, :40] = 0; m[:, -40:] = 0; m[:40] = 0; m[-40:] = 0 + n, lab, st, cen = cv2.connectedComponentsWithStats(m) + nb, _, stb, cenb = cv2.connectedComponentsWithStats(cv2.inRange(hsv, BLUE_LO, BLUE_HI)) + blues = [cenb[k] for k in range(1, nb) if stb[k][4] >= 60] + best = None + for k in range(1, n): + x, y, w, h, a = st[k] + if a < 300 or max(w, h) > 3 * min(w, h): + continue + d = sorted(np.hypot(*(b - cen[k])) for b in blues) + if len(d) < 2 or d[1] > 200: + continue + if best is None or a > best[0]: + best = (a, cen[k]) + return None if best is None else (int(best[1][0]), int(best[1][1])) + + +def _axis_once(roi, s_min): + hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) + n, lab, st, cen = cv2.connectedComponentsWithStats(_red_mask(hsv, s_min)) + rb = [k for k in range(1, n) if st[k][4] >= 300 and max(st[k][2], st[k][3]) < 3 * min(st[k][2], st[k][3])] + nb, _, stb, cenb = cv2.connectedComponentsWithStats(cv2.inRange(hsv, BLUE_LO, BLUE_HI)) + bb = [k for k in range(1, nb) if stb[k][4] >= 60] + if not rb or len(bb) < 2: + return None + r = max(rb, key=lambda k: st[k][4]) + bb = sorted(bb, key=lambda k: np.hypot(*(cenb[k] - cen[r]))) + c1, c2 = cenb[bb[0]], cenb[bb[1]] + mid = (c1 + c2) / 2 + tip_dir = (c2 - c1) / max(1e-6, np.linalg.norm(c2 - c1)) + body = None + for k in bb[2:]: + v = cenb[k] - mid; d = np.linalg.norm(v) + if 120 <= d <= 400 and abs(np.dot(v / d, tip_dir)) < 0.5: + body = cenb[k]; break + if body is not None: + u = (mid - body) / np.linalg.norm(mid - body); src = "body" + else: + # 胴の印が写らないとき: 爪先の印 2 つを結ぶ線に直角で, 画の下向き (どの記録も爪先は画の下寄りを向く) + u = np.array([-tip_dir[1], tip_dir[0]]) + u = u if u[1] > 0 else -u + src = "tips" + ys, xs = np.nonzero(lab == r) + proj = (np.stack([xs, ys], 1) - mid) @ u + lat = float((cen[r] - mid) @ np.array([-u[1], u[0]])) + return {"axis_protrude_px": round(float(proj.max()), 1), "axis_lateral_px": round(lat, 1), + "axis_marker_gap_px": round(float(np.linalg.norm(c1 - c2)), 1), + "axis_deg": round(float(np.degrees(np.arctan2(u[0], u[1]))), 1), "axis_from": src, + "axis_red_area": int(st[r][4]), "axis_s_min": s_min} + + +def measure_axis(f, c, s_min=120): + """G の measure_axis と同じ. c (ブロックのおおよその中心) のまわりで, 爪の向きに沿うはみ出しを測る. + 彩度 120 で拾った赤が彩度 60 で拾った面積の半分未満なら, 彩度 60 の値を使う (薄いピンクに写る回).""" + H_, W_ = f.shape[:2] + x0, y0 = max(0, c[0] - HALF), max(0, c[1] - HALF) + roi = f[y0:min(H_, c[1] + HALF), x0:min(W_, c[0] + HALF)] + m = _axis_once(roi, s_min) + if s_min == 120: + m60 = _axis_once(roi, 60) + if m60 and (m is None or m["axis_red_area"] < 0.5 * m60["axis_red_area"]): + m = m60 + return m + + +def find_center(f_before, f_after): + """G と同じ順で探す: E の探し方 → 画の全体 (彩度 120) → 画の全体 (彩度 60). + 戻り値 (中心, 彩度の下限, 探し方). 見つからなければ (None, 120, "none").""" + for f in (f_before, f_after): + c = red_center(f) if f is not None else None + if c: + return c, 120, "E" + for f in (f_before, f_after): + c = red_center_wide(f) if f is not None else None + if c: + return c, 120, "wide" + for f in (f_before, f_after): + c = red_center_wide(f, 60) if f is not None else None + if c: + return c, 60, "wide_s60" + return None, 120, "none" + + +# ---------------------------------------------------------------- 記録を読む +def load_gripper_cmds(states_csv): + """states.csv の gripper_cmd 行. 戻り値 (時刻の並び, 状態の並び 0/1). 時刻の順に並べる. + 読み方は build_demo_episodes.load_states と同じ (gripper_state が 1 なら閉じる).""" + ts, ss = [], [] + with Path(states_csv).open(newline="") as f: + for row in csv.DictReader(f): + if row.get("event_type") != "gripper_cmd": + continue + try: + t = float(row["ts"]) + s = 1.0 if int(float(row["gripper_state"])) == 1 else 0.0 + except Exception: + continue + ts.append(t); ss.append(s) + ts, ss = np.array(ts), np.array(ss) + o = np.argsort(ts, kind="stable") + return ts[o], ss[o] + + +def close_time(gt, gs, t_label=None): + """閉じる命令の時刻. + t_label を渡したとき: その時刻か, それより前でいちばん遅い閉じる命令 (G と同じ. エピソードで最初に爪が 1 になった + コマの時刻を渡す). 渡さないとき: 開いた状態 (または始め) から最初に閉じた命令.""" + closes = [float(t) for t, s in zip(gt, gs) if s == 1] + if t_label is not None: + c = [t for t in closes if t <= t_label + 1e-6] + return max(c) if c else None + prev = 0.0 + for t, s in zip(gt, gs): + if s == 1 and prev == 0: + return float(t) + prev = s + return None + + +def load_frame_log(path): + idx, ts = [], [] + with Path(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 _read(cap, idx): + cap.set(cv2.CAP_PROP_POS_FRAMES, int(idx)) + ok, f = cap.read() + return f if ok else None + + +def measure_run(run_dir, t_label=None, max_protrude_px=None): + """1 本を測る. 戻り値は dict (JSON にそのまま書ける値だけ). + status : "ok" / "unmeasured" + reason : unmeasured のときの理由 + t_close : 閉じる命令の時刻 (states.csv の ts) + frame_before / frame_after : 測った top.mp4 のコマ番号 + before_px / after_px : 閉じる直前・閉じて 1.0 秒後のはみ出し px (after が採否に使う値) + after_gap_px : 閉じて 1.0 秒後の爪先の印の間 px (空振りなら 75px 前後. つかめば 98〜101px) + hand_suspect : 閉じる前のコマで人の手が写っていそうか (はみ出しが HAND_PX を超えた) + shallow : max_protrude_px を渡したとき, after_px がそれを超えたか (測れなければ None)""" + run_dir = Path(run_dir) + rec = run_dir / "recording" + out = {"status": "unmeasured", "reason": None, "t_close": None, "frame_before": None, "frame_after": None, + "before_px": None, "after_px": None, "after_gap_px": None, "hand_suspect": False, + "pre_px": {}, "center_px": None, "center_by": None, "axis_from": None} + if max_protrude_px is not None: + out["max_protrude_px"] = float(max_protrude_px) + out["shallow"] = None + 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: + out["reason"] = "missing:" + ",".join(missing) + return out + gt, gs = load_gripper_cmds(rec / "states.csv") + tc = close_time(gt, gs, t_label) + if tc is None: + out["reason"] = "閉じる命令がない" + return out + out["t_close"] = round(tc, 3) + fidx, fts = load_frame_log(rec / "top_frame_log.csv") + if not len(fts): + out["reason"] = "上カメラのコマがない" + return out + o = np.argsort(fts, kind="stable"); fidx, fts = fidx[o], fts[o] + + def at_or_before(x): + return int(fidx[max(0, int(np.searchsorted(fts, x, side="right") - 1))]) + + def after(x): + k = int(np.searchsorted(fts, x)) + return None if k >= len(fts) else int(fidx[k]) + + if fts[0] > tc: + out["reason"] = "閉じる前のコマがない" + return out + fb, fa = at_or_before(tc), after(tc + AFTER_SEC) + if fa is None: + out["reason"] = f"閉じて {AFTER_SEC:.1f} 秒後のコマがない" + return out + out["frame_before"], out["frame_after"] = fb, fa + cap = cv2.VideoCapture(str(rec / "top.mp4")) + try: + img_b, img_a = _read(cap, fb), _read(cap, fa) + if img_a is None: + out["reason"] = f"top.mp4 のコマ {fa} が読めない" + return out + c, s_min, by = find_center(img_b, img_a) + out["center_by"] = by + if c is None: + out["reason"] = "ブロックが見つからない" + return out + out["center_px"] = [int(c[0]), int(c[1])] + ma = measure_axis(img_a, c, s_min) + if ma is None: + out["reason"] = "閉じたあとのコマで爪先の印かブロックが見えない" + return out + out["after_px"] = ma["axis_protrude_px"] + out["after_gap_px"] = ma["axis_marker_gap_px"] + out["axis_from"] = ma["axis_from"] + pre = {} + for sec in PRE_SECS: + if fts[0] <= tc - sec: + img = _read(cap, at_or_before(tc - sec)) + m = measure_axis(img, c, s_min) if img is not None else None + pre[f"pre{sec:g}s"] = None if m is None else m["axis_protrude_px"] + mb = measure_axis(img_b, c, s_min) if img_b is not None else None + bpx = None if mb is None else mb["axis_protrude_px"] + pre["before"] = bpx + out["pre_px"] = pre + out["hand_suspect"] = any(v is not None and v > HAND_PX for v in pre.values()) + out["before_px"] = None if (bpx is None or bpx > HAND_PX) else bpx + finally: + cap.release() + out["status"] = "ok" + if max_protrude_px is not None: + out["shallow"] = bool(out["after_px"] > max_protrude_px) + return out + + +def first_close_label_time(t_abs, grip): + """エピソードの並び (元の時刻 t_abs と爪の列 grip) で最初に 0 → 1 になったコマの時刻. なければ None.""" + sw = np.flatnonzero((np.asarray(grip[1:]) == 1) & (np.asarray(grip[:-1]) == 0)) + 1 + return float(t_abs[sw[0]]) if len(sw) else None + + +def judge(run_dir, max_protrude_px, t_label=None): + """学習データを作る側から呼ぶ. 戻り値 (外すか, 理由の文字, 測った値の dict). + 測れなかった本は外さない (理由 grasp_depth_unmeasured を値に残す).""" + m = measure_run(run_dir, t_label=t_label, max_protrude_px=max_protrude_px) + if m["status"] != "ok": + return False, f"grasp_depth_unmeasured:{m['reason']}", m + if m["shallow"]: + return True, f"shallow_grasp after_px={m['after_px']} > {max_protrude_px:g}", m + return False, "ok", m + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("run_dir", nargs="+", help="run のフォルダ (recording/ がある所)") + ap.add_argument("--max-protrude-px", type=float, default=DEFAULT_MAX_PROTRUDE_PX, + help=f"浅いとみなす線 (閉じて 1.0 秒後のはみ出し px. 既定 {DEFAULT_MAX_PROTRUDE_PX:g})") + ap.add_argument("--csv", help="1 本 1 行の CSV に書く (書く先はこのファイルだけ)") + args = ap.parse_args() + rows = [] + for d in args.run_dir: + m = measure_run(d, max_protrude_px=args.max_protrude_px) + name = Path(d).name + if m["status"] == "ok": + flag = "浅い" if m["shallow"] else "深い" + hand = " 手が写っていそう" if m["hand_suspect"] else "" + print(f"[{flag}] {name} 閉じる直前 {m['before_px']}px 閉じて 1.0 秒後 {m['after_px']}px " + f"(線 {args.max_protrude_px:g}px) 爪先の印の間 {m['after_gap_px']}px{hand}") + else: + print(f"[測れない] {name} {m['reason']}") + rows.append({"run": name, **{k: (json.dumps(v, ensure_ascii=False) if isinstance(v, (dict, list)) else v) + for k, v in m.items()}}) + if args.csv: + keys = [] + for r in rows: + keys += [k for k in r if k not in keys] + with open(args.csv, "w", newline="") as f: + w = csv.DictWriter(f, fieldnames=keys); w.writeheader(); w.writerows(rows) + print(f"[CSV ] {args.csv}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pi05/merge_raw_episodes.py b/pi05/merge_raw_episodes.py index a91cfa4..eba2841 100644 --- a/pi05/merge_raw_episodes.py +++ b/pi05/merge_raw_episodes.py @@ -15,6 +15,12 @@ eval_policy_offline.py) も「学習に使ったデータ」を 1 つのフォ datasets/{DATASET}/code/merge_raw_episodes.py このスクリプトの写し (データと対のコード) datasets/{DATASET}/{DATASET}_raw_episodes.tar (--tar) Pod へ送る形. ._* (AppleDouble) は入れない +外す本 (--exclude-json. 2026-11-07 依頼 117): + {"エピソード名": {"reason": "外す理由", ...測った値など}, ...} の JSON を渡すと, その本は写さない. + index.json の excluded に中身をそのまま残し, README に表を書く. 元のどれにもない名前があれば何も書かずに止まる. + --readme-note に Markdown のファイルを渡すと, README の最後にそのまま足す (外した理由の説明など). + 付けないときの出力は今までと同じ. + 決まり: - 元のデータセットは読むだけ (上書き・移動・削除しない) - 写す前に, 全部の元が お手本 (最初の --src) と一緒に使えるか (キー・型・FPS・画像の大きさ) を確かめ, @@ -69,6 +75,8 @@ def main(): 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("--exclude-json", help="外す本と理由の JSON ({エピソード名: {reason: ..., ...}}). 依頼 117") + ap.add_argument("--readme-note", help="README の最後に足す Markdown のファイル") ap.add_argument("--data-root", help=argparse.SUPPRESS) # テスト用 (KIOXIA の代わり) args = ap.parse_args() if args.data_root: @@ -99,6 +107,14 @@ def main(): if ep.name in names: sys.exit(f"[ABORT] エピソード名が重なる: {ep.name} ({names[ep.name]} と {s})") names[ep.name] = s + excluded = json.loads(Path(args.exclude_json).read_text()) if args.exclude_json else {} + unknown = [k for k in excluded if k not in names] + if unknown: + sys.exit(f"[ABORT] 外す本が元のどれにもない: {', '.join(unknown)}") + no_reason = [k for k, v in excluded.items() if not (isinstance(v, dict) and v.get("reason"))] + if no_reason: + sys.exit(f"[ABORT] 外す本に理由 (reason) がない: {', '.join(no_reason)}") + note = Path(args.readme_note).read_text() if args.readme_note else None ds = dsroot / args.dataset if ds.exists(): @@ -109,7 +125,11 @@ def main(): out.mkdir(parents=True) index = {"sources": {}, "episodes": {}} for src_name, s in zip(args.src, srcs): - eps = episodes(s) + eps = [ep for ep in episodes(s) if ep.name not in excluded] + for ep in episodes(s): + if ep.name in excluded: + excluded[ep.name] = dict(excluded[ep.name], from_dataset=src_name, **{ + k: v for k, v in ep_summary(ep).items() if k in ("steps", "correct_steps")}) 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) @@ -129,22 +149,34 @@ def main(): 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}") + if excluded: + index["excluded"] = excluded (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}`", + + " ".join(f"--src {s}" for s in args.src) + f" --dataset {args.dataset}" + + (f" --exclude-json {Path(args.exclude_json).name}" if excluded else "") + "`", 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']} |") + if excluded: + lines += ["", f"## 外した本 ({len(excluded)} 本. `index.json` の `excluded`)", "", + "| エピソード | 元 | コマ | 理由 |", "| --- | --- | --- | --- |"] + for k, v in excluded.items(): + lines.append(f"| {k} | {v['from_dataset']} | {v['steps']} | {v['reason']} |") + if note: + lines += ["", note.rstrip("\n")] (ds / "README.md").write_text("\n".join(lines) + "\n") code = ds / "code" code.mkdir(exist_ok=True) shutil.copy2(_HERE, code / _HERE.name) # データと対のコード + if excluded: + shutil.copy2(args.exclude_json, code / Path(args.exclude_json).name) # 外した本の一覧も対で残す print(f"\n合計 {total_eps} 本 / {total_steps} コマ → {out}") if args.tar: t = make_tar(ds, args.dataset) diff --git a/pi05/test_grasp_depth.py b/pi05/test_grasp_depth.py new file mode 100644 index 0000000..48da962 --- /dev/null +++ b/pi05/test_grasp_depth.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""test_grasp_depth.py — つかみの深さの部品 (grasp_depth.py) と, 学習データを作る側の --max-protrude-px の確かめ (2026-11-07 依頼 117). + + python3 pi05/test_grasp_depth.py -v + +合成の画と記録だけ (KIOXIA にもアームにも触らない). 確かめること: + - 爪先の青い印 2 つ・胴の印・赤いブロックを描いた画で, 爪の向きに沿うはみ出しが描いた値になる (斜めの爪でも) + - run のフォルダから, 閉じる命令の直前と 1.0 秒後のコマを選んで測る. 線を超えたら shallow + - 閉じる前のコマで大きな赤 (人の手の肌) を拾ったら hand_suspect. その回の閉じる直前の値は使わない + - 測れない本 (閉じる命令がない・ブロックが見つからない) は status=unmeasured と理由を返す + - build_demo_episodes / build_correction_episodes: 引数なしでは測らず, 出力 (npz・meta・画・戻り値) が今までと同じ. + 線を超えた本は外して index に値と理由を残す. 測れなかった本は外さない +""" +import argparse +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import cv2 +import numpy as np + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +import build_correction_episodes as bce # noqa: E402 +import build_demo_episodes as bde # noqa: E402 +import grasp_depth as gd # noqa: E402 +import test_pad_build as tpb # noqa: E402 + +W, H = 1920, 1080 +RED, BLUE = (40, 30, 220), (220, 60, 20) # BGR. HSV で 赤 (H≒178, S≒220) / 青 (H≒112, S≒231) +BG = (90, 110, 100) + + +def scene(protrude, deg=0.0, tip=(1100, 640), gap=136, hand=False): + """上カメラの合成の画. 爪の向き (画の真下から deg 度) に沿って, ブロックの先が爪先の印の中点から protrude px 先.""" + img = np.full((H, W, 3), BG, np.uint8) + a = np.radians(deg) + u = np.array([np.sin(a), np.cos(a)]) # 爪先へ向かう向き (deg=0 で画の真下) + v = np.array([-u[1], u[0]]) # 爪先の印 2 つを結ぶ向き + mid = np.array(tip, float) + if hand: # 肌を赤として拾う大きな塊 (爪の先まで 150px 出る. 印は隠さない) + cv2.rectangle(img, (int(mid[0]) - 110, int(mid[1]) - 100), (int(mid[0]) + 110, int(mid[1]) + 150), RED, -1) + for p in (mid + v * gap / 2, mid - v * gap / 2, mid - u * 260): # 爪先の印 2 つ・胴の印 + cv2.circle(img, tuple(int(round(x)) for x in p), 9, BLUE, -1) + # ブロック: 爪の向きに 60px・横に 60px の四角. 先の辺が mid + u * protrude + c = mid + u * (protrude - 30) + box = np.array([c + u * 30 + v * 30, c + u * 30 - v * 30, c - u * 30 - v * 30, c - u * 30 + v * 30]) + cv2.fillConvexPoly(img, np.round(box).astype(np.int32), RED) + return img + + +def write_video_run(run_dir, before_px, after_px, close_at=2.0, hand=False, blank=False, with_close=True): + """grasp_depth が読む記録 (states.csv の爪の命令・top.mp4・top_frame_log.csv) を作る. 10Hz で 4 秒.""" + rec = run_dir / "recording" + rec.mkdir(parents=True) + base = 1.8e9 + rows = ["ts,event_type,stage,j0,j1,j2,j3,j4,j5,speed,gripper_state", + f"{base + 0.2:.6f},gripper_cmd,,,,,,,,,0"] + if with_close: + rows.append(f"{base + close_at:.6f},gripper_cmd,,,,,,,,,1") + (rec / "states.csv").write_text("\n".join(rows) + "\n") + w = cv2.VideoWriter(str(rec / "top.mp4"), cv2.VideoWriter_fourcc(*"mp4v"), 10.0, (W, H)) + lines = ["frame_idx,capture_ts"] + for k, t in enumerate(np.arange(0, 4.0, 0.1)): + if blank: + img = np.full((H, W, 3), BG, np.uint8) + elif t < close_at + 0.5: + img = scene(before_px, hand=hand and t > close_at - 0.6) + else: + img = scene(after_px, gap=100) + w.write(img) + lines.append(f"{k},{base + t + 0.003:.6f}") + w.release() + (rec / "top_frame_log.csv").write_text("\n".join(lines) + "\n") + return base + + +class TestMeasureAxis(unittest.TestCase): + def test_straight_down(self): + for p in (-15, 0, 14, 24, 40, 75): + img = scene(p) + m = gd.measure_axis(img, gd.red_center(img)) + self.assertAlmostEqual(m["axis_protrude_px"], p, delta=1.0, msg=p) + self.assertEqual(m["axis_from"], "body") + self.assertAlmostEqual(m["axis_marker_gap_px"], 136, delta=1.0) + + def test_slanted_gripper(self): + """10/15〜10/19 の記録のように爪が斜め (−19〜−55°) を向いていても, 爪の向きに沿って測る.""" + for deg in (-20, -45): + img = scene(30, deg=deg, tip=(700, 600)) + c = gd.red_center(img) or gd.red_center_wide(img) + m = gd.measure_axis(img, c) + self.assertAlmostEqual(m["axis_protrude_px"], 30, delta=1.5, msg=deg) + self.assertAlmostEqual(m["axis_deg"], deg, delta=1.0) + + def test_no_marker(self): + img = np.full((H, W, 3), BG, np.uint8) + cv2.rectangle(img, (1070, 600), (1130, 660), RED, -1) + self.assertIsNone(gd.measure_axis(img, (1100, 630))) + + +class TestCloseTime(unittest.TestCase): + def test_first_close_and_label(self): + gt = np.array([1.0, 2.0, 2.05, 5.0, 7.0]) + gs = np.array([0.0, 1.0, 1.0, 0.0, 1.0]) + self.assertEqual(gd.close_time(gt, gs), 2.0) # 開いた状態から最初に閉じた命令 + self.assertEqual(gd.close_time(gt, gs, t_label=2.1), 2.05) # ラベルの時刻かその前のいちばん遅い命令 (G と同じ) + self.assertEqual(gd.close_time(gt, gs, t_label=7.0), 7.0) + self.assertIsNone(gd.close_time(gt, gs, t_label=1.5)) + self.assertIsNone(gd.close_time(np.array([1.0]), np.array([0.0]))) + + def test_label_time_of_episode(self): + t = np.arange(10) * 0.1 + 100.0 + g = np.array([0, 0, 0, 1, 1, 1, 0, 0, 1, 1]) + self.assertAlmostEqual(gd.first_close_label_time(t, g), 100.3) + self.assertIsNone(gd.first_close_label_time(t, np.zeros(10))) + + +class TestMeasureRun(unittest.TestCase): + def setUp(self): + self.td = Path(tempfile.mkdtemp()) + + def test_deep_and_shallow(self): + for name, after, shallow in (("deep", 10, False), ("shallow", 40, True)): + d = self.td / name + base = write_video_run(d, before_px=45, after_px=after) + m = gd.measure_run(d, max_protrude_px=24) + self.assertEqual(m["status"], "ok", m) + self.assertAlmostEqual(m["t_close"], base + 2.0, places=3) + self.assertEqual(m["frame_before"], 19) # 1.903 秒のコマ (閉じる命令の直前) + self.assertEqual(m["frame_after"], 30) # 3.003 秒のコマ (閉じる命令の 1.0 秒後かそのあと) + self.assertAlmostEqual(m["before_px"], 45, delta=3) # mp4 の圧縮で縁が少しにじむ + self.assertAlmostEqual(m["after_px"], after, delta=3) + self.assertAlmostEqual(m["after_gap_px"], 100, delta=3) + self.assertIs(m["shallow"], shallow) + self.assertFalse(m["hand_suspect"]) + drop, why, _ = gd.judge(d, 24) + self.assertIs(drop, shallow) + self.assertTrue(why.startswith("shallow_grasp") if shallow else why == "ok") + json.dumps(m) # index.json にそのまま書ける + + def test_hand(self): + d = self.td / "hand" + write_video_run(d, before_px=45, after_px=15, hand=True) + m = gd.measure_run(d, max_protrude_px=24) + self.assertEqual(m["status"], "ok", m) + self.assertTrue(m["hand_suspect"]) + self.assertIsNone(m["before_px"]) # 手を拾った閉じる直前の値は使わない + self.assertGreater(m["pre_px"]["before"], gd.HAND_PX) + self.assertAlmostEqual(m["after_px"], 15, delta=3) + self.assertFalse(m["shallow"]) + + def test_unmeasured(self): + d = self.td / "blank" + write_video_run(d, 0, 0, blank=True) + m = gd.measure_run(d, max_protrude_px=24) + self.assertEqual((m["status"], m["reason"]), ("unmeasured", "ブロックが見つからない")) + self.assertIsNone(m["shallow"]) + drop, why, _ = gd.judge(d, 24) + self.assertFalse(drop) # 測れなかった本は外さない + self.assertTrue(why.startswith("grasp_depth_unmeasured")) + d = self.td / "noclose" + write_video_run(d, 45, 10, with_close=False) + self.assertEqual(gd.measure_run(d)["reason"], "閉じる命令がない") + self.assertEqual(gd.measure_run(self.td / "nothing")["reason"], + "missing:states.csv,top.mp4,top_frame_log.csv") + + +def _same_episode(a, b): + za, zb = np.load(a / "episode.npz"), np.load(b / "episode.npz") + assert sorted(za.files) == sorted(zb.files) + for k in za.files: + np.testing.assert_array_equal(za[k], zb[k]) + assert json.loads((a / "meta.json").read_text()) == json.loads((b / "meta.json").read_text()) + fa = sorted(p.name for p in (a / "frames").glob("*.jpg")) + assert fa == sorted(p.name for p in (b / "frames").glob("*.jpg")) + for n in fa: + assert (a / "frames" / n).read_bytes() == (b / "frames" / n).read_bytes() + + +def _fake_measure(after_px, status="ok"): + calls = [] + + def f(run_dir, t_label=None, max_protrude_px=None): + calls.append(t_label) + m = {"status": status, "reason": None if status == "ok" else "ブロックが見つからない", + "after_px": after_px if status == "ok" else None, "hand_suspect": False, + "max_protrude_px": max_protrude_px} + m["shallow"] = (after_px > max_protrude_px) if status == "ok" else None + return m + return f, calls + + +class TestBuilders(unittest.TestCase): + """合成の記録 (test_pad_build.write_run. 8.0 秒に爪を閉じる) で, 学習データを作る 2 本の --max-protrude-px を見る.""" + + def setUp(self): + self.td = Path(tempfile.mkdtemp()) + self.run = self.td / "runs" / "pi05_20991107_000000" + tpb.write_run(self.run) + + def demo_args(self, mp=None, **kw): + a = argparse.Namespace(include_failed=False, force=True, trim_mid_idle=True, **kw) + if mp is not None: + a.max_protrude_px = mp + return a + + def corr_args(self, mp=None, **kw): + a = argparse.Namespace(force=True, no_trim_mid_idle=False, gap_fill="speed20", trim_hold=True, **kw) + if mp is not None: + a.max_protrude_px = mp + return a + + def check(self, build, make_args): + out = self.td / "ds" + # 1. 引数なし (属性がない今までの Namespace / None) は測らない. 出力は同じ + boom = mock.patch.object(gd, "measure_run", side_effect=AssertionError("測ってはいけない")) + with boom: + r0 = build(self.run, out / "a" / self.run.name, make_args()) + r1 = build(self.run, out / "b" / self.run.name, make_args(max_protrude_px=None)) + self.assertTrue(r0["used"], r0) + self.assertEqual(r0, r1) + self.assertNotIn("grasp_depth", r0) + _same_episode(out / "a" / self.run.name, out / "b" / self.run.name) + # 2. 線の内側 (深い) なら同じエピソードができ, 値が戻り値 (= index.json) に残る + f, calls = _fake_measure(10.0) + with mock.patch.object(gd, "measure_run", f): + r2 = build(self.run, out / "c" / self.run.name, make_args(24.0)) + self.assertTrue(r2["used"]) + self.assertEqual(r2["grasp_depth"]["after_px"], 10.0) + self.assertEqual({k: v for k, v in r2.items() if k != "grasp_depth"}, r0) + _same_episode(out / "a" / self.run.name, out / "c" / self.run.name) + # エピソードで爪が 1 になった最初のコマの時刻を渡している (8.0 秒の命令の次の 10Hz のコマ) + self.assertGreaterEqual(calls[0], tpb.BASE + 8.0) + self.assertLess(calls[0], tpb.BASE + 8.1 + 1e-6) + self.assertAlmostEqual(gd.close_time(*gd.load_gripper_cmds(self.run / "recording" / "states.csv"), + t_label=calls[0]), tpb.BASE + 8.0, places=3) + # 3. 線を超えたら外す. 値と理由が残る. エピソードは書かない + f, _ = _fake_measure(40.0) + with mock.patch.object(gd, "measure_run", f): + r3 = build(self.run, out / "d" / self.run.name, make_args(24.0)) + self.assertFalse(r3["used"]) + self.assertEqual(r3["reason"], "shallow_grasp after_px=40.0 > 24") + self.assertEqual(r3["grasp_depth"]["after_px"], 40.0) + self.assertFalse((out / "d" / self.run.name).exists()) + # 4. 測れなかった本は外さず, 測れなかったと残す + f, _ = _fake_measure(None, status="unmeasured") + with mock.patch.object(gd, "measure_run", f): + r4 = build(self.run, out / "e" / self.run.name, make_args(24.0)) + self.assertTrue(r4["used"]) + self.assertEqual(r4["grasp_depth"]["status"], "unmeasured") + _same_episode(out / "a" / self.run.name, out / "e" / self.run.name) + # 5. 本物の部品で測る (合成の画は灰色だけなので「ブロックが見つからない」→ 外さない) + r5 = build(self.run, out / "f" / self.run.name, make_args(24.0)) + self.assertTrue(r5["used"]) + self.assertEqual(r5["grasp_depth"]["reason"], "ブロックが見つからない") + + def test_demo(self): + self.check(bde.build_one, self.demo_args) + + def test_correction(self): + self.check(bce.build_one, self.corr_args) + + +class TestMergeExclude(unittest.TestCase): + """merge_raw_episodes.py --exclude-json: 外した本は写さず, index.json と README に理由を残す. 付けなければ今までと同じ.""" + + def setUp(self): + self.root = Path(tempfile.mkdtemp()) + ds = self.root / "datasets" + for src, names in (("A", ["run_1", "run_2"]), ("B", ["pi05_1", "pi05_2"])): + for n in names: + ep = ds / src / "raw_episodes" / n + (ep / "frames").mkdir(parents=True) + st = np.zeros((3, 7), np.float32) + np.savez_compressed(ep / "episode.npz", t=np.arange(3) / 10.0, state=st, actions=st) + cv2.imwrite(str(ep / "frames" / "000000.jpg"), np.zeros((216, 384, 3), np.uint8)) + for k in (1, 2): + cv2.imwrite(str(ep / "frames" / f"{k:06d}.jpg"), np.zeros((216, 384, 3), np.uint8)) + (ep / "meta.json").write_text(json.dumps({ + "steps": 3, "fps": 10, "image_size": [384, 216], "state_dim": 7, "task": "t", + "state_names": list("abcdefg"), "correct_steps": 1 if src == "B" else 0})) + + def merge(self, name, *extra): + import subprocess + r = subprocess.run([sys.executable, str(_HERE / "merge_raw_episodes.py"), "--src", "A", "--src", "B", + "--dataset", name, "--data-root", str(self.root), *extra], + capture_output=True, text=True) + return r, self.root / "datasets" / name + + def test_exclude(self): + r, ds = self.merge("plain") + self.assertEqual(r.returncode, 0, r.stderr) + plain = json.loads((ds / "index.json").read_text()) + self.assertNotIn("excluded", plain) + self.assertNotIn("外した本", (ds / "README.md").read_text()) + ex = self.root / "ex.json" + ex.write_text(json.dumps({"run_2": {"reason": "浅い", "after_px": 30.0}, + "pi05_1": {"reason": "指", "after_px": 10.0}}, ensure_ascii=False)) + note = self.root / "note.md" + note.write_text("## 外した理由\n\nG を見る\n") + r, ds = self.merge("deep", "--exclude-json", str(ex), "--readme-note", str(note)) + self.assertEqual(r.returncode, 0, r.stderr) + idx = json.loads((ds / "index.json").read_text()) + self.assertEqual(sorted(idx["episodes"]), ["pi05_2", "run_1"]) + self.assertEqual(sorted(p.name for p in (ds / "raw_episodes").iterdir()), ["pi05_2", "run_1"]) + self.assertEqual(idx["total_episodes"], 2) + self.assertEqual(idx["excluded"]["run_2"], {"reason": "浅い", "after_px": 30.0, "from_dataset": "A", + "steps": 3, "correct_steps": 0}) + self.assertEqual(idx["sources"]["B"], {"episodes": 1, "steps": 3, "correct_steps": 1}) + txt = (ds / "README.md").read_text() + self.assertIn("| run_2 | A | 3 | 浅い |", txt) + self.assertIn("G を見る", txt) + self.assertTrue((ds / "code" / "ex.json").exists()) + # 外していない本は同じ形で入っている (plain と同じ) + self.assertEqual(idx["episodes"]["run_1"], plain["episodes"]["run_1"]) + + def test_bad_exclude(self): + ex = self.root / "bad.json" + ex.write_text(json.dumps({"run_9": {"reason": "x"}})) + r, ds = self.merge("bad", "--exclude-json", str(ex)) + self.assertNotEqual(r.returncode, 0) + self.assertIn("元のどれにもない", r.stderr) + self.assertFalse(ds.exists()) + ex.write_text(json.dumps({"run_1": {"after_px": 1}})) + r, ds = self.merge("bad2", "--exclude-json", str(ex)) + self.assertIn("理由 (reason) がない", r.stderr) + self.assertFalse(ds.exists()) + + +if __name__ == "__main__": + unittest.main()