commit f65fc211ba4d3f287c1a639582c06ee06864adb9
Author: Eiichi Shimazu <studio@adaptdesign.jp>
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 <noreply@anthropic.com>

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/<run_stem>/` (読むだけ。上書き・移動・削除しない)", "",
         "## 作り方", "",
         "- 結果が「成功」(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()
