commit df54374b757ab7b5c568604362a4f2fd2c15c4a7
Author: Eiichi Shimazu <studio@adaptdesign.jp>
Date:   Sat Nov 7 22:10:26 2026 +0900

    深いお手本の手順書 pi05/DEEP_DEMOS.md を位置 1〜5 を各 10 本にそろえる形に直し (今の本数と足す本数・位置ごとの撮り方・1 本目で深さを測り 24px 超で止める・位置 1 の教え直し・印・目安時間), 置き場所ごとの本数を数える pi05/count_by_position.py を足す (教えた動きは teach/ のファイル名, 補正入りは上カメラのブロックの位置をいちばん近い教えた位置へ. 30mm 超は分からない). merge_raw_episodes.py は作り終えたら表を README に足す (--no-position-table で足さない. データは同じ). 21 本で 0・3・2・5・11 を確かめた (依頼 118)
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/pi05/DEEP_DEMOS.md b/pi05/DEEP_DEMOS.md
index 416fda7..7db6663 100644
--- a/pi05/DEEP_DEMOS.md
+++ b/pi05/DEEP_DEMOS.md
@@ -1,38 +1,107 @@
-# 深いお手本を足す（試している置き場所・教えた位置 5。2026-11-07 依頼 117）
+# 深いお手本で置き場所ごとの本数をそろえる（位置 1〜5 を各 10 本。2026-11-07 依頼 117・118）
 
-学習（`RUNPOD_TRAIN.md`）の前に、π0.5 を試している置き場所（ブロックが画の中央・PICK の j0 +13°）で、ブロックが爪の奥まで入ってから閉じるお手本を 5〜10 本足す手順。今のデータは `mecharm_redblock_box_teach_v1_trim2_corr_v5_deep`（21 本。浅い 7 本・指の写った 2 本を外した版）。**この手順はまだ実機で通していない**（書いただけ）。
+学習（`RUNPOD_TRAIN.md`）の前に、LOG352 で教えた 5 か所（位置 1〜5）それぞれで、ブロックが爪の奥まで入ってから閉じるお手本を撮り足し、**どの位置も 10 本**にそろえる手順。**この手順はまだ実機で通していない**（書いただけ）。
 
-**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（深い）。
+**なぜそろえるか**: 今のデータ `mecharm_redblock_box_teach_v1_trim2_corr_v5_deep`（21 本。浅い 7 本・指の写った 2 本を外した版）は半分以上が位置 5 に寄っている。SO-101 で π0.5 を学習した例（LOG443）では、タスクごとの本数の偏りがそのまま失敗の形になり、本数をそろえて直していた。エイイチの決め（2026-11-07）: 置き場所ごとの本数をそろえる。目安は各位置 10 本。
+
+## 0. 今の本数と足す本数
+
+`python3 pi05/count_by_position.py mecharm_redblock_box_teach_v1_trim2_corr_v5_deep` の表（2026-11-07）:
+
+| 位置 | 教えた記録 | 今の本数 | うち教えた動き | うち補正入り | 足す本数 | 外した本（浅い・指） |
+| --- | --- | --- | --- | --- | --- | --- |
+| 1 | `drag_v2_20221013_144348.json` | 0 | 0 | 0 | **＋10** | 浅い 1（183454） |
+| 2 | `drag_v2_20221013_230205.json` | 3 | 3 | 0 | **＋7** | 指 2（191347・191441） |
+| 3 | `drag_v2_20221013_231249.json` | 2 | 2 | 0 | **＋8** | 浅い 2（192343・192633） |
+| 4 | `drag_v2_20221013_232345.json` | 5 | 5 | 0 | **＋5** | 0 |
+| 5 | `drag_v2_20221013_233323.json` | 11 | 4 | 7 | **＋0** | 浅い 4（補正入り） |
+| 合計 | | 21 | 14 | 7 | **＋30** | 7 |
+
+- 位置の決め方（`count_by_position.py` の頭にも書いた）: 教えた動きは試行フォルダの `teach/` にある教えた記録のファイル名。補正入りは上カメラで見つけたブロックの位置（マットの上の mm）をいちばん近い教えた位置に割り当て、30mm を超えたら「分からない」
+- 位置 5 は足さない（今の 11 本で目安を超えている）。位置 5 の補正入りを減らすかどうかは決めていない
+
+**目安時間**: 1 本あたり約 3 分（置き直し・measure・再生 約 1 分・y/n・深さを測る。10/19 の位置 4 の 5 本は 1〜2 分おきに撮れていた）。位置を替えるときに印の確認と 1 本目の確かめで 5〜10 分。**全体で約 30 本・2 時間前後**（30 本 × 3 分 ＋ 位置の替え 4 回）（浅くて撮り直す分と、位置 1 を教え直す場合はその分だけ延びる）。
+
+## 1. 撮る前に（全部の位置で 1 回）
+
+- **ブロックを教えた位置からずらして置かない。** 再生するのは教えたときの関節の角度そのもので、ブロックの位置を見て動きを変えない。LOG352 では位置 4 から約 46mm ずらして置いた 193729 が使えなかった（`count_by_position.py` の見つけ方でも位置 4 から 48mm 離れて「分からない」になる）
+- **マットに 5 か所の印を付けておく**（ブロックの四隅が分かる小さな印。上カメラに大きく写る色・赤は使わない）。印の場所は LOG352 で採った試行の上カメラの最初のコマ（HOME で、ブロックが置かれた画）で合わせる: `/Volumes/KIOXIA/r2r2r-data/runs/run_pick_place/run_0001_20261019_{183454（位置 1）・191541（位置 2）・192438（位置 3）・193459（位置 4）・194913（位置 5）}/recording/top.mp4` の 1 コマめ（`teach_poses_LOG352/prerun_*.jpg` はブロックが箱の中に写っている回があり、目安にならない）。`count_by_position.py` の `TEACH_REF_MM`（同じ見つけ方での 10/19 のブロックの中心）も目安になる: 位置 1 (182.7, 188.8)・位置 2 (215.5, 242.2)・位置 3 (123.6, 235.6)・位置 4 (240.8, 101.3)・位置 5 (−48.8, 246.4) mm
+- `measure` の `block_xz` は根元近くの赤いケーブルを拾うことがあり当てにならない（LOG352 の分かったこと）。置けたかは印と画で人が見る
+- 閉じる前に人がブロックを押し込まない（指が学習の画に写る。191347・191441 で起きて外した）。`run --execute` は Enter のあとは止まらないので、浅いと思ったら Enter の前に置き直すか、撮ったあとで外す
 
 ```
-# ブロックを位置 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. 位置ごとに撮る（位置 1 → 2 → 3 → 4 の順。位置 5 は撮らない）
+
+どの位置も同じ流れ。`{教えた記録}` を位置の表のファイル名に替える。
+
+```
+# ブロックを位置 N の印に置く
+python3 dynamic_tracking_limits/drag_teaching/record_teach_demo.py measure                               # 1 本ごとに置き直したら測り直す
+python3 dynamic_tracking_limits/drag_teaching/record_teach_demo.py run --teach {教えた記録}             # ドライラン（計画だけ）
+python3 dynamic_tracking_limits/drag_teaching/record_teach_demo.py run --teach {教えた記録} --execute   # 再生＋記録
+```
 
-**2. 閉じる前に奥まで入っているかを確かめる所**: **今の作りにはない。** `run --execute` は、始める前の「周囲に手・物が無いか確認して Enter」のあとは止まらない。PICK に着くと、止まったのを最大 8 秒待ってから（止まらなくても WARN だけで）そのまま閉じる。人が見られるのは、Enter の前の画（ブロックの置き方）と、終わったあとの y/n と動画だけ。閉じる前に人がブロックを押し込むと、指が学習の画に写る（191347・191441 で起きた。外した）。浅いと思ったら Enter の前に置き直すか、撮ったあとで外す。
+| 位置 | `{教えた記録}` | 撮る本数（外れなければ） | 前に撮った本の深さ（閉じて 1.0 秒後。依頼 113 G の g_table.csv） |
+| --- | --- | --- | --- |
+| 1 | `drag_v2_20221013_144348.json` | 10 | 183454 が 38.9px（浅い）。**教えた記録そのものが浅い可能性がある**（下の 2-1） |
+| 2 | `drag_v2_20221013_230205.json` | 7 | 残っている 3 本は 8.1・15.9・18.2px（深い） |
+| 3 | `drag_v2_20221013_231249.json` | 8 | 4 本中 2 本が 27.6・28.9px（浅い）、残り 2 本は 12.7・17.9px。線のすぐ上なので置き方で変わる可能性 |
+| 4 | `drag_v2_20221013_232345.json` | 5 | 5 本とも 24px 以下（4.4〜22.1px。21.6・22.1px は線に近い） |
 
-**3. 撮ったあとに深さを測って、線を超えた本を外す**
+### 2-0. 各位置の 1 本目を撮ったら、深さを測ってから続ける
 
 ```
-# 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 \
+```
+
+- **閉じて 1.0 秒後のはみ出しが 24px（約 9mm）を超えたら、その位置は撮り続けずに止める。** 置き方を印に合わせ直して 1 本だけ撮り直し、それでも 24px を超えたら、教えた記録が浅いとみて 2-1 に進む（その位置の残りは撮らない）
+- 24px 以下なら、その位置の残りを撮る。2 本目からも 1 本ずつ測ってよい（`grasp_depth.py` は何本でも渡せる）
+- `[測れない]` が出たら（ブロックが見つからない・爪先の印が見えない）、`top.mp4` の閉じる前後を人が見て決める。測れなかった本は学習データを作るときも外されない
+- `手が写っていそう` と出たら、その本は使わない方向で画を見る
+
+### 2-1. 教えた記録が浅いとき（位置 1 で起きそう）: 深くつかむように教え直す
+
+位置 1 の前の 1 本（183454）は 38.9px で浅かった。置き方ではなく、教えた PICK の姿勢が浅い可能性がある。教え直しはエイイチが手で行う。
+
+1. ブロックを位置 1 の印に置く → 【Mac】`record_teach_demo.py measure`
+2. 【Pi】daemon 停止 → `drag_teach_v2.py teach` → daemon 起動（`record_teach_demo.py` の頭の「1 本のお手本を作る流れ」と同じ）
+3. 教えるときの決まり（LOG352 の分かったこと）:
+   - **肩 j1（0 始まり）は約 88° まで。** 88° を超えた命令はアームが丸ごと捨てて降りない（LOG352 で肩 +95.53〜+99.22° の 4 つが動かなかった。手で教えるときはモーターの力が抜けているので超えて曲げられてしまう）
+   - **肩を倒さず、肘と手首で下げる。** 本線は同じ場所を肩 88〜89°・肘 −33°・手首 −52° 前後でつかんでいる
+   - 爪の奥（爪先の印より手前）までブロックが入ってから閉じる
+4. 新しい教えた記録で 1 本撮り（ドライランで PICK の肩が 88° 以下かを見てから `--execute`）、2-0 で深さを測る。24px 以下になったら残りを撮る
+5. **新しい教えた記録のファイル名は `count_by_position.py` の `TEACH_FILES` にまだない**（そのままだと「分からない」に数えられる）。位置 1 として足すかはエイイチが決め、足すなら `TEACH_FILES` に 1 行足す
+
+## 3. 撮り終えたら: データにして、位置ごとの本数を見て、足りない分を撮り足す
+
+```
+# 1 本ずつの深さ（読むだけ）
+python3 pi05/grasp_depth.py /Volumes/KIOXIA/r2r2r-data/runs/run_pick_place/run_0001_YYYYMMDD_HHMMSS ...
+# 新しい本だけのデータセットを作る（新しい名前。24px を超えた本は外し、index.json の grasp_depth に値と理由を残す）
+python3 pi05/build_demo_episodes.py --dataset mecharm_redblock_box_teach_pos1to4_deep_v1 --trim-mid-idle \
     --max-protrude-px 24 --run run_0001_YYYYMMDD_HHMMSS --run ...
+# 位置ごとの本数を見る（今の deep と新しい本を合わせて数える。読むだけ）
+python3 pi05/count_by_position.py mecharm_redblock_box_teach_v1_trim2_corr_v5_deep mecharm_redblock_box_teach_pos1to4_deep_v1
+```
+
+- `build_demo_episodes.py` の最後に `[DEPTH]` の行が出る: 浅くて外した本・**測れなかった本（外していない）**・**手が写っていそうな本（外していない）**の数と名前。測れなかった本と手の本は `top.mp4` の閉じる前後を人が見て、外すなら merge の `--exclude-json` に書く
+- `count_by_position.py` の表の「目標 10 本まで」が ＋ の位置だけ、2. に戻って撮り足す（浅くて外れた分）。全部の位置が ＋0 になったら次へ
+- 「分からない」が出たら、表の下の理由を見る（teach/ がない・教え直した記録が `TEACH_FILES` にない・補正入りのブロックが教えた位置から 30mm を超える など）
+
+```
 # 今の 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
+    --src mecharm_redblock_box_teach_pos1to4_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`
+- `merge_raw_episodes.py` は作り終えたときに、位置ごとの本数の表を README の最後に足す（依頼 118。`--no-position-table` で足さない。raw_episodes と index.json は表があってもなくても同じ）。目標は `--target`（既定 10）
+- 線 24px の意味は `/Volumes/KIOXIA/r2r2r-data/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` で、電流は空）。
+## 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/count_by_position.py b/pi05/count_by_position.py
new file mode 100644
index 0000000..08c9299
--- /dev/null
+++ b/pi05/count_by_position.py
@@ -0,0 +1,378 @@
+#!/usr/bin/env python3
+"""count_by_position.py — 学習データの本数を, ブロックの置き場所 (LOG352 の教えた位置 1〜5) ごとに数える (2026-11-07 依頼 118).
+
+エイイチの決め (2026-11-07): 置き場所ごとの本数をそろえる. 目安は各位置 10 本.
+撮り足す本数を決めるときと, merge_raw_episodes.py でデータセットを作り終えたときの README の表に使う.
+
+入力 (どちらでも. いくつでも):
+  データセット名            datasets/{名前}/raw_episodes を数える
+  raw_episodes のフォルダ   そのフォルダを数える (ひとつ上の index.json があれば外した本も数える)
+
+位置の決め方:
+  教えた動き (run_pick_place の run):
+    試行フォルダの teach/ にある教えた記録のファイル名で決める (LOG352 の README の表と同じ対応. TEACH_FILES).
+    teach/ がない・表にないファイル名なら「分からない」.
+  補正入り (pi05_policy の run. meta.json の source が pi05_policy で始まる):
+    上カメラ (recording/top.mp4) の最初のコマでマットの四隅を見つけ, マットの上の mm に直す対応を作る
+    (依頼 107 twin_prep と同じ. visual_servo_pick の四隅 (±310, −89)・(±310, 361) mm).
+    爪を閉じる命令 (いちばん初め) より前のコマを 3 つおきに見て, ブロックらしい赤 (twin_prep.red_candidates と同じ.
+    根元から 120mm 以内・細長い・埋まりの少ない赤は外す) がいちばん大きく写ったコマの中心を, ブロックの位置とする
+    (HOME の最初のコマでは爪の下に半分隠れていることがあるため).
+    その位置から TEACH_REF_MM のいちばん近い位置を選び, 離れが MAX_DIST_MM (30mm) を超えたら「分からない」.
+    四隅・ブロックが見つからないときも「分からない」(理由を残す).
+  TEACH_REF_MM: 同じ見つけ方を LOG352 で採った 19 本 (10/19) にかけた値の位置ごとの中央値.
+    位置 1 は 183454 の 1 本だけ (爪の陰で赤が小さく写る. 866px). 位置 2 は 191257 が見つからず 4 本,
+    位置 3 は 192539 が見つからず 3 本. 位置 4 から 46mm ずらして置いた 193729 は (237.4, 52.6) で, 位置 4 から 48mm
+    (使わない試行. この線なら「分からない」になる).
+    教えた位置どうしのいちばん近い間は位置 1〜2 の 63mm. 線 30mm はその半分より少し下.
+    座標はマットの上の mm (visual_servo_pick の向き). 依頼 107 C の Unity の値 (T1〜T5) とは原点・向きが違う.
+
+外した本・手の本 (データセットの index.json があるとき):
+  merge_raw_episodes.py の index.json の excluded / build_*_episodes.py の index.json の used=false の本を,
+  同じ決め方で位置に割り当て, 「浅くて外した」(grasp_depth.shallow か理由が「浅い」「shallow」で始まる) と
+  「手で外した」(hand_in_frame か grasp_depth.hand_suspect) を数える.
+  データに入っている本の「手が写っていそう」は index に値があるときだけ数える (--measure-depth を付けると
+  grasp_depth.py で 1 本ずつ測る. 遅い).
+
+  python3 pi05/count_by_position.py mecharm_redblock_box_teach_v1_trim2_corr_v5_deep
+  python3 pi05/count_by_position.py A B --target 10 --json out.json
+
+読むだけ (記録・データセットには何も書かない. --json のファイルだけ書く).
+"""
+import argparse
+import contextlib
+import json
+import math
+import sys
+from pathlib import Path
+
+import cv2
+import numpy as np
+
+_HERE = Path(__file__).resolve()
+sys.path.insert(0, str(_HERE.parent))
+sys.path.insert(0, str(_HERE.parent.parent))   # mat_corner_detection.py (r2r2r-scripts の直下)
+
+import grasp_depth as gd  # noqa: E402
+
+DATA_ROOT = Path("/Volumes/KIOXIA/r2r2r-data")
+RUN_SUBDIRS = ("run_pick_place", "pi05_policy")
+POSITIONS = (1, 2, 3, 4, 5)
+UNKNOWN = "unknown"
+DEFAULT_TARGET = 10
+
+# LOG352 の README の表 (runs/experiments/record_teach_demo/20261019_LOG352/README.md)
+TEACH_FILES = {
+    "drag_v2_20221013_144348.json": 1,
+    "drag_v2_20221013_230205.json": 2,
+    "drag_v2_20221013_231249.json": 3,
+    "drag_v2_20221013_232345.json": 4,
+    "drag_v2_20221013_233323.json": 5,
+}
+# 教えた位置のブロックの中心 (マットの上の mm). 決め方は頭の説明
+TEACH_REF_MM = {
+    1: (182.7, 188.8),
+    2: (215.5, 242.2),
+    3: (123.6, 235.6),
+    4: (240.8, 101.3),
+    5: (-48.8, 246.4),
+}
+MAX_DIST_MM = 30.0
+MAT_CORNERS_MM = [[310, -89], [-310, -89], [-310, 361], [310, 361]]   # visual_servo_pick と同じ (twin_prep)
+BLOCK_MIN_DIST_FROM_BASE_MM = 120.0                                   # record_teach_demo と同じ (twin_prep)
+FRAME_STEP = 3
+
+
+# ---------------------------------------------------------------- 位置を決める
+def teach_position(run_dir):
+    """教えた動きの run: teach/ の教えた記録のファイル名から. 戻り値 (位置 or UNKNOWN, 根拠の文字)."""
+    tdir = Path(run_dir) / "teach"
+    files = sorted(p.name for p in tdir.glob("[!.]*.json")) if tdir.is_dir() else []
+    if not files:
+        return UNKNOWN, "teach/ に教えた記録がない"
+    pos = {TEACH_FILES[f] for f in files if f in TEACH_FILES}
+    if len(pos) != 1:
+        return UNKNOWN, f"teach/ のファイルが表と合わない: {', '.join(files)}"
+    return pos.pop(), f"teach/{files[0]}"
+
+
+def nearest_position(mm, max_dist=MAX_DIST_MM):
+    """ブロックの位置 (mm) → (位置 or UNKNOWN, いちばん近い位置, 離れ mm)."""
+    p, d = min(((k, math.hypot(mm[0] - v[0], mm[1] - v[1])) for k, v in TEACH_REF_MM.items()), key=lambda x: x[1])
+    return (p if d <= max_dist else UNKNOWN), p, round(d, 1)
+
+
+def _px_to_mm(H, x, y):
+    r = cv2.perspectiveTransform(np.array([[[float(x), float(y)]]], np.float32), H)
+    return float(r[0][0][0]), float(r[0][0][1])
+
+
+def red_candidates(frame, mat_bbox, H):
+    """twin_prep.red_candidates と同じ (向きは使わないので出さない)."""
+    hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
+    m = cv2.inRange(hsv, np.array([0, 100, 100]), np.array([10, 255, 255])) | \
+        cv2.inRange(hsv, np.array([160, 100, 100]), np.array([180, 255, 255]))
+    m = cv2.morphologyEx(m, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8))
+    cnts, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
+    out = []
+    for c in cnts:
+        a = cv2.contourArea(c)
+        if a < 500:
+            continue
+        (cx, cy), (rw, rh), _ang = cv2.minAreaRect(c)
+        if not (mat_bbox[0] <= cx <= mat_bbox[1] and mat_bbox[2] <= cy <= mat_bbox[3]):
+            continue
+        aspect = max(rw, rh) / max(1e-6, min(rw, rh))
+        extent = a / max(1e-6, rw * rh)
+        mm = _px_to_mm(H, cx, cy)
+        if math.hypot(*mm) < BLOCK_MIN_DIST_FROM_BASE_MM or aspect > 1.8 or extent < 0.6:
+            continue
+        out.append({"area": float(a), "px": (float(cx), float(cy)), "mm": mm})
+    return out
+
+
+def detect_corners(frame):
+    with contextlib.redirect_stdout(sys.stderr):
+        import mat_corner_detection as mcd
+        return mcd.detect_corners(frame)
+
+
+def block_mm(run_dir, corners_fn=None):
+    """補正入りの run: 上カメラで見つけたブロックの位置. 戻り値 (dict or None, 理由)."""
+    corners_fn = corners_fn or detect_corners
+    rec = Path(run_dir) / "recording"
+    if not (rec / "top.mp4").exists() or not (rec / "top_frame_log.csv").exists():
+        return None, "top.mp4 か top_frame_log.csv がない"
+    tc = None
+    if (rec / "states.csv").exists():
+        gt, gs = gd.load_gripper_cmds(rec / "states.csv")
+        tc = gd.close_time(gt, gs)
+    _, fts = gd.load_frame_log(rec / "top_frame_log.csv")
+    cap = cv2.VideoCapture(str(rec / "top.mp4"))
+    try:
+        ok, f0 = cap.read()
+        if not ok:
+            return None, "top.mp4 が読めない"
+        corners = corners_fn(f0)
+        if corners is None:
+            return None, "マットの四隅が見つからない"
+        co = [[float(x), float(y)] for x, y in corners]
+        H = cv2.getPerspectiveTransform(np.array(co, np.float32), np.array(MAT_CORNERS_MM, np.float32))
+        bbox = (int(min(co[0][0], co[3][0])), int(max(co[1][0], co[2][0])),
+                int(min(co[0][1], co[1][1])), int(max(co[2][1], co[3][1])))
+        best, k, f = None, 0, f0
+        while ok:
+            if k < len(fts) and tc is not None and fts[k] > tc:
+                break
+            if k % FRAME_STEP == 0:
+                cs = red_candidates(f, bbox, H)
+                if cs:
+                    c = max(cs, key=lambda c: c["area"])
+                    if best is None or c["area"] > best["area"]:
+                        best = dict(c, frame=k)
+            ok, f = cap.read()
+            k += 1
+    finally:
+        cap.release()
+    if best is None:
+        return None, "閉じる前のコマでブロックが見つからない"
+    return best, "ok"
+
+
+def find_run_dir(stem, runs_root):
+    for sub in RUN_SUBDIRS:
+        d = Path(runs_root) / sub / stem
+        if d.is_dir():
+            return d
+    return None
+
+
+def is_correction(meta, stem):
+    src = (meta or {}).get("source", "")
+    if src:
+        return src.startswith("pi05_policy")
+    return stem.startswith("pi05_")
+
+
+def classify(stem, meta, runs_root, corners_fn=None):
+    """1 本の位置. 戻り値 dict(stem, kind, position, how, ...)."""
+    kind = "correction" if is_correction(meta, stem) else "teach"
+    out = {"stem": stem, "kind": kind, "position": UNKNOWN, "how": None}
+    rd = find_run_dir(stem, runs_root)
+    if rd is None:
+        out["how"] = "runs/ に試行フォルダがない"
+        return out
+    if kind == "teach":
+        out["position"], out["how"] = teach_position(rd)
+        return out
+    try:
+        b, why = block_mm(rd, corners_fn)
+    except Exception as e:   # 見つけ方で落ちても数えるのは止めない
+        b, why = None, f"exception:{type(e).__name__}:{e}"
+    if b is None:
+        out["how"] = why
+        return out
+    pos, near, d = nearest_position(b["mm"])
+    out.update(position=pos, how=f"top.mp4 のブロック (コマ {b['frame']}) → 位置 {near} まで {d}mm",
+               block_mm=[round(v, 1) for v in b["mm"]], nearest=near, dist_mm=d)
+    if pos == UNKNOWN:
+        out["how"] += f" (線 {MAX_DIST_MM:g}mm を超える)"
+    return out
+
+
+# ---------------------------------------------------------------- 外した本・手の本
+def _shallow(v):
+    g = v.get("grasp_depth") or {}
+    r = str(v.get("reason", ""))
+    return bool(g.get("shallow")) or r.startswith("浅い") or r.startswith("shallow")
+
+
+def _hand(v):
+    g = v.get("grasp_depth") or {}
+    return bool(v.get("hand_in_frame")) or bool(g.get("hand_suspect"))
+
+
+def excluded_entries(ds_dir):
+    """データセットの index.json から, 外した本 {名前: 中身}. merge の excluded と build の used=false の両方."""
+    p = Path(ds_dir) / "index.json"
+    if not p.exists():
+        return {}, {}
+    idx = json.loads(p.read_text())
+    out, kept_info = {}, {}
+    for k, v in (idx.get("excluded") or {}).items():
+        out[k] = v
+    for k, v in idx.items():
+        if isinstance(v, dict) and "used" in v and "run" in v:
+            if v["used"]:
+                kept_info[k] = v
+            elif v.get("grasp_depth") or v.get("hand_in_frame"):
+                out[k] = v
+    return out, kept_info
+
+
+# ---------------------------------------------------------------- 数える
+def resolve_input(x, data_root):
+    p = Path(x)
+    if p.is_dir() and (p.name == "raw_episodes" or list(p.glob("[!.]*/episode.npz"))):
+        return p, p.parent
+    d = Path(data_root) / "datasets" / x
+    return d / "raw_episodes", d
+
+
+def count(inputs, data_root=DATA_ROOT, target=DEFAULT_TARGET, measure_depth=False, corners_fn=None, log=print):
+    runs_root = Path(data_root) / "runs"
+    eps, excl, kept_info, seen = [], [], {}, set()
+    for x in inputs:
+        raw, ds = resolve_input(x, data_root)
+        if not raw.is_dir():
+            raise SystemExit(f"[ABORT] raw_episodes がない: {raw}")
+        for ep in sorted(p.parent for p in raw.glob("[!.]*/episode.npz")):
+            if ep.name in seen:
+                continue
+            seen.add(ep.name)
+            meta = json.loads((ep / "meta.json").read_text()) if (ep / "meta.json").exists() else {}
+            stem = meta.get("run_stem", ep.name)
+            eps.append((ep.name, stem, meta))
+        ex, ki = excluded_entries(ds)
+        kept_info.update(ki)
+        for k, v in ex.items():
+            if k not in seen:
+                seen.add(k)
+                excl.append((k, v))
+    rows = []
+    for name, stem, meta in eps:
+        r = classify(stem, meta, runs_root, corners_fn)
+        r["name"] = name
+        g = (kept_info.get(stem) or {}).get("grasp_depth")
+        if measure_depth:
+            rd = find_run_dir(stem, runs_root)
+            g = gd.measure_run(rd) if rd else None
+        r["hand_suspect"] = bool(g and g.get("hand_suspect"))
+        r["depth_known"] = g is not None
+        rows.append(r)
+        log(f"[POS ] {name}  {'教えた動き' if r['kind'] == 'teach' else '補正入り'}  位置 {r['position']}  {r['how']}")
+    ex_rows = []
+    for name, v in excl:
+        kind_meta = {"source": "pi05_policy"} if (v.get("kind") == "補正入り" or name.startswith("pi05_")) else {}
+        r = classify(name, kind_meta, runs_root, corners_fn)
+        r.update(name=name, shallow=_shallow(v), hand=_hand(v), reason=v.get("reason"))
+        ex_rows.append(r)
+        log(f"[EXCL] {name}  位置 {r['position']}  {r.get('reason')}")
+    return summarize(rows, ex_rows, target)
+
+
+def summarize(rows, ex_rows, target):
+    keys = list(POSITIONS) + [UNKNOWN]
+    table = {}
+    for k in keys:
+        rs = [r for r in rows if r["position"] == k]
+        es = [r for r in ex_rows if r["position"] == k]
+        n = len(rs)
+        table[k] = {
+            "episodes": n,
+            "teach": sum(r["kind"] == "teach" for r in rs),
+            "correction": sum(r["kind"] == "correction" for r in rs),
+            "to_add": (max(0, target - n) if k != UNKNOWN else None),
+            "excluded_shallow": sum(r["shallow"] for r in es),
+            "excluded_hand": sum(r["hand"] for r in es),
+            "hand_suspect_in_data": sum(r["hand_suspect"] for r in rs),
+        }
+    return {"target": target, "max_dist_mm": MAX_DIST_MM, "table": table,
+            "total": len(rows), "depth_known": sum(r["depth_known"] for r in rows),
+            "episodes": rows, "excluded": ex_rows}
+
+
+def table_lines(res):
+    """Markdown の表 (README と端末で同じもの)."""
+    t = res["table"]
+    lines = [f"| 位置 | 本数 | 教えた動き | 補正入り | 目標 {res['target']} 本まで | 浅くて外した | 手で外した | "
+             "入っている本で手が写っていそう |",
+             "| --- | --- | --- | --- | --- | --- | --- | --- |"]
+    for k, v in t.items():
+        name = f"位置 {k}" if k != UNKNOWN else "分からない"
+        add = "—" if v["to_add"] is None else f"+{v['to_add']}"
+        hand_in = v["hand_suspect_in_data"] if res["depth_known"] else "—"
+        lines.append(f"| {name} | {v['episodes']} | {v['teach']} | {v['correction']} | {add} | "
+                     f"{v['excluded_shallow']} | {v['excluded_hand']} | {hand_in} |")
+    lines.append(f"| 合計 | {res['total']} | {sum(v['teach'] for v in t.values())} | "
+                 f"{sum(v['correction'] for v in t.values())} | "
+                 f"+{sum(v['to_add'] or 0 for v in t.values())} | "
+                 f"{sum(v['excluded_shallow'] for v in t.values())} | {sum(v['excluded_hand'] for v in t.values())} | "
+                 f"{sum(v['hand_suspect_in_data'] for v in t.values()) if res['depth_known'] else '—'} |")
+    return lines
+
+
+def readme_section(res):
+    unk = [r for r in res["episodes"] + res["excluded"] if r["position"] == UNKNOWN]
+    lines = ["## 置き場所ごとの本数 (`pi05/count_by_position.py`。依頼 118)", "",
+             "教えた動きは `teach/` の教えた記録のファイル名 (LOG352 の表) で、補正入りは上カメラで見つけたブロックの位置を"
+             f"いちばん近い教えた位置に割り当てた ({res['max_dist_mm']:g}mm を超えたら「分からない」)。"
+             "「浅くて外した」「手で外した」は、このデータセットの `index.json` で外したエピソードの数 (データには入っていない)。", ""]
+    lines += table_lines(res)
+    if not res["depth_known"]:
+        lines += ["", "- 入っている本の「手が写っていそう」は測っていない (`--measure-depth` で測る)"]
+    for r in unk:
+        lines.append(f"- 分からない: {r['name']} ({r['how']})")
+    return lines
+
+
+def main():
+    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
+    ap.add_argument("inputs", nargs="+", help="データセット名 (datasets/ の下) か raw_episodes のフォルダ")
+    ap.add_argument("--target", type=int, default=DEFAULT_TARGET, help=f"位置ごとの目標本数 (既定 {DEFAULT_TARGET})")
+    ap.add_argument("--measure-depth", action="store_true",
+                    help="入っている本を grasp_depth.py で測り, 手が写っていそうな本を数える (遅い)")
+    ap.add_argument("--json", help="結果を JSON に書く (書く先はこのファイルだけ)")
+    ap.add_argument("--data-root", default=str(DATA_ROOT), help=argparse.SUPPRESS)
+    args = ap.parse_args()
+    res = count(args.inputs, args.data_root, args.target, args.measure_depth)
+    print()
+    print("\n".join(table_lines(res)))
+    if args.json:
+        Path(args.json).write_text(json.dumps(res, indent=2, ensure_ascii=False, default=str))
+        print(f"[JSON] {args.json}")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/pi05/merge_raw_episodes.py b/pi05/merge_raw_episodes.py
index eba2841..7e85eed 100644
--- a/pi05/merge_raw_episodes.py
+++ b/pi05/merge_raw_episodes.py
@@ -21,6 +21,11 @@ eval_policy_offline.py) も「学習に使ったデータ」を 1 つのフォ
   --readme-note に Markdown のファイルを渡すと, README の最後にそのまま足す (外した理由の説明など).
   付けないときの出力は今までと同じ.
 
+置き場所ごとの本数 (2026-11-07 依頼 118):
+  作り終えたら count_by_position.py で, 教えた位置 1〜5 ごとの本数 (教えた動き・補正入り)・目標本数 (--target. 既定 10)
+  までの差・外した本の数を README の最後に表で足す. runs/ を読むだけで, raw_episodes・index.json は変わらない.
+  --no-position-table で足さない (README も今までと同じ). 数えるところで落ちても, データは作り終えたまま止めない.
+
 決まり:
   - 元のデータセットは読むだけ (上書き・移動・削除しない)
   - 写す前に, 全部の元が お手本 (最初の --src) と一緒に使えるか (キー・型・FPS・画像の大きさ) を確かめ,
@@ -68,6 +73,18 @@ def make_tar(ds, name):
     return out
 
 
+def position_table(raw, root, target):
+    """依頼 118: 置き場所ごとの本数の表 (README の行). 数えるところで落ちたら, そのことだけ書いて続ける."""
+    import count_by_position as cbp
+    try:
+        res = cbp.count([str(raw)], data_root=root, target=target)
+    except (Exception, SystemExit) as e:
+        print(f"[WARN] 置き場所ごとの本数を数えられなかった: {type(e).__name__}: {e}")
+        return ["", f"(置き場所ごとの本数は数えられなかった: {type(e).__name__})"]
+    print("\n".join(cbp.table_lines(res)))
+    return [""] + cbp.readme_section(res)
+
+
 def main():
     ap = argparse.ArgumentParser()
     ap.add_argument("--src", action="append", required=True,
@@ -77,6 +94,9 @@ def main():
     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("--no-position-table", action="store_true",
+                    help="置き場所ごとの本数の表 (count_by_position.py) を README に足さない. 依頼 118")
+    ap.add_argument("--target", type=int, default=10, help="置き場所ごとの表の目標本数 (既定 10)")
     ap.add_argument("--data-root", help=argparse.SUPPRESS)   # テスト用 (KIOXIA の代わり)
     args = ap.parse_args()
     if args.data_root:
@@ -171,10 +191,14 @@ def main():
             lines.append(f"| {k} | {v['from_dataset']} | {v['steps']} | {v['reason']} |")
     if note:
         lines += ["", note.rstrip("\n")]
+    if not args.no_position_table:
+        lines += position_table(out, root, args.target)
     (ds / "README.md").write_text("\n".join(lines) + "\n")
     code = ds / "code"
     code.mkdir(exist_ok=True)
     shutil.copy2(_HERE, code / _HERE.name)   # データと対のコード
+    if not args.no_position_table:
+        shutil.copy2(_HERE.parent / "count_by_position.py", code / "count_by_position.py")   # 表を作ったコード
     if excluded:
         shutil.copy2(args.exclude_json, code / Path(args.exclude_json).name)   # 外した本の一覧も対で残す
     print(f"\n合計 {total_eps} 本 / {total_steps} コマ → {out}")
diff --git a/pi05/test_count_by_position.py b/pi05/test_count_by_position.py
new file mode 100644
index 0000000..852e35b
--- /dev/null
+++ b/pi05/test_count_by_position.py
@@ -0,0 +1,218 @@
+#!/usr/bin/env python3
+"""test_count_by_position.py — 置き場所ごとの本数 (count_by_position.py) と merge_raw_episodes.py の表の確かめ (2026-11-07 依頼 118).
+
+  python3 pi05/test_count_by_position.py -v
+
+一時フォルダの合成の記録だけ (KIOXIA にもアームにも触らない). 確かめること:
+  - 教えた動き: teach/ の教えた記録のファイル名で位置 1〜5. ない・表にないなら「分からない」
+  - いちばん近い教えた位置. 線 (30mm) を超えたら「分からない」(位置 4 から 48mm ずらした 193729 の値)
+  - 補正入り: 上カメラの合成の動画で, マットの四隅からの mm に直したブロックの位置 → 位置. 閉じたあとのコマは見ない
+  - データセットを数える: 位置ごとの本数・教えた動き/補正入りの内訳・目標までの差・外した本 (浅い・手) の数
+  - merge_raw_episodes.py: 引数なしで README に表が足され, --no-position-table では足さない.
+    どちらでも raw_episodes と index.json は同じ
+"""
+import csv
+import filecmp
+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 count_by_position as cbp  # noqa: E402
+import merge_raw_episodes as mre  # noqa: E402
+from test_merge_and_config import write_ep  # noqa: E402
+
+# 合成のマット: 画の (100,100)→(310,−89)mm・(1340,100)→(−310,−89)・(1340,1000)→(−310,361)・(100,1000)→(310,361). 0.5mm/px
+CORNERS = [(100, 100), (1340, 100), (1340, 1000), (100, 1000)]
+W, H = 1440, 1080
+
+
+def mm_to_px(mm):
+    return int(round(100 + 2 * (310 - mm[0]))), int(round(100 + 2 * (mm[1] + 89)))
+
+
+def fake_corners(_frame):
+    return CORNERS
+
+
+def write_policy_run(d, block_mm, close_after=None, late_block_mm=None, n=9):
+    """補正入りの run の合成の記録. 閉じる命令は close_after コマめの時刻の直後 (None なら命令なし).
+    late_block_mm を渡すと, 閉じたあとのコマにだけそこへ大きな赤を描く (見ないことの確かめ)."""
+    rec = d / "recording"
+    rec.mkdir(parents=True)
+    vw = cv2.VideoWriter(str(rec / "top.mp4"), cv2.VideoWriter_fourcc(*"mp4v"), 10, (W, H))
+    ts = [1000.0 + 0.1 * i for i in range(n)]
+    for i in range(n):
+        img = np.full((H, W, 3), (60, 60, 60), np.uint8)
+        late = close_after is not None and i > close_after
+        if late and late_block_mm is not None:
+            x, y = mm_to_px(late_block_mm)
+            cv2.rectangle(img, (x - 50, y - 50), (x + 50, y + 50), (30, 30, 220), -1)
+        elif not late:
+            x, y = mm_to_px(block_mm)
+            cv2.rectangle(img, (x - 30, y - 30), (x + 30, y + 30), (30, 30, 220), -1)
+        vw.write(img)
+    vw.release()
+    with (rec / "top_frame_log.csv").open("w", newline="") as f:
+        w = csv.writer(f)
+        w.writerow(["frame_idx", "capture_ts"])
+        w.writerows([i, t] for i, t in enumerate(ts))
+    with (rec / "states.csv").open("w", newline="") as f:
+        w = csv.writer(f)
+        w.writerow(["ts", "event_type", "gripper_state"])
+        w.writerow([ts[0] - 1, "gripper_cmd", 0])
+        if close_after is not None:
+            w.writerow([ts[close_after] + 0.05, "gripper_cmd", 1])
+
+
+def write_teach_run(d, teach_file):
+    (d / "teach").mkdir(parents=True)
+    if teach_file:
+        (d / "teach" / teach_file).write_text("{}")
+
+
+class TestPosition(unittest.TestCase):
+    def setUp(self):
+        self._td = tempfile.TemporaryDirectory()
+        self.root = Path(self._td.name)
+
+    def tearDown(self):
+        self._td.cleanup()
+
+    def test_teach_file_mapping(self):
+        for f, pos in cbp.TEACH_FILES.items():
+            d = self.root / f"t{pos}"
+            write_teach_run(d, f)
+            self.assertEqual(cbp.teach_position(d)[0], pos)
+        write_teach_run(self.root / "none", None)
+        self.assertEqual(cbp.teach_position(self.root / "none")[0], cbp.UNKNOWN)
+        write_teach_run(self.root / "other", "drag_v2_20221013_999999.json")
+        self.assertEqual(cbp.teach_position(self.root / "other")[0], cbp.UNKNOWN)
+        self.assertEqual(cbp.teach_position(self.root / "missing")[0], cbp.UNKNOWN)
+
+    def test_nearest_and_line(self):
+        for pos, mm in cbp.TEACH_REF_MM.items():
+            self.assertEqual(cbp.nearest_position(mm)[0], pos)
+        self.assertEqual(cbp.nearest_position((-48.3, 246.2))[0], 5)            # 補正入り 184650 の値
+        pos, near, d = cbp.nearest_position((237.4, 52.6))                      # 位置 4 から ずらした 193729
+        self.assertEqual((pos, near), (cbp.UNKNOWN, 4))
+        self.assertGreater(d, cbp.MAX_DIST_MM)
+
+    def test_block_from_video(self):
+        d = self.root / "p5"
+        write_policy_run(d, (-50.0, 240.0), close_after=5, late_block_mm=cbp.TEACH_REF_MM[4])
+        b, why = cbp.block_mm(d, fake_corners)
+        self.assertEqual(why, "ok")
+        self.assertAlmostEqual(b["mm"][0], -50.0, delta=2)
+        self.assertAlmostEqual(b["mm"][1], 240.0, delta=2)
+        self.assertLessEqual(b["frame"], 5)                                      # 閉じたあとのコマは見ない
+        r = cbp.classify("pi05_x", {"source": "pi05_policy (model + correct)"}, self.root, fake_corners)
+        self.assertEqual(r["position"], cbp.UNKNOWN)                             # runs/ にない
+        self.assertIn("ない", r["how"])
+
+    def test_block_not_found(self):
+        d = self.root / "corner_none"
+        write_policy_run(d, (-50.0, 240.0), close_after=5)
+        b, why = cbp.block_mm(d, lambda f: None)
+        self.assertIsNone(b)
+        self.assertIn("四隅", why)
+
+
+class TestCount(unittest.TestCase):
+    def setUp(self):
+        self._td = tempfile.TemporaryDirectory()
+        self.root = Path(self._td.name)
+        runs = self.root / "runs"
+        ds = self.root / "datasets"
+        files = {v: k for k, v in cbp.TEACH_FILES.items()}
+        # 教えた動き: 位置 2 が 2 本・位置 4 が 1 本・teach/ のない 1 本
+        for stem, pos in [("run_a", 2), ("run_b", 2), ("run_c", 4), ("run_d", None)]:
+            write_teach_run(runs / "run_pick_place" / stem, files.get(pos))
+            write_ep(ds / "a" / "raw_episodes" / stem, n=3)
+        # 補正入り: 位置 5 が 1 本・どこからも遠い 1 本
+        write_policy_run(runs / "pi05_policy" / "pi05_1", cbp.TEACH_REF_MM[5], close_after=5)
+        write_policy_run(runs / "pi05_policy" / "pi05_far", (0.0, 350.0), close_after=5)
+        for stem in ("pi05_1", "pi05_far"):
+            write_ep(ds / "b" / "raw_episodes" / stem, n=4, source="pi05_policy (model + correct)")
+        # 外した本 (merge の excluded と同じ形): 位置 1 の浅い本・位置 2 の手の本・位置 5 の浅い補正入り
+        write_teach_run(runs / "run_pick_place" / "run_s", files[1])
+        write_teach_run(runs / "run_pick_place" / "run_h", files[2])
+        write_policy_run(runs / "pi05_policy" / "pi05_s", cbp.TEACH_REF_MM[5], close_after=5)
+        for stem in ("run_s", "run_h"):
+            write_ep(ds / "a" / "raw_episodes" / stem, n=3)
+        write_ep(ds / "b" / "raw_episodes" / "pi05_s", n=4, source="pi05_policy (model + correct)")
+        self.excl = self.root / "excl.json"
+        self.excl.write_text(json.dumps({
+            "run_s": {"reason": "浅い: 閉じて 1.0 秒後のはみ出し 38.9px > 24px"},
+            "run_h": {"reason": "人の指が写っている", "hand_in_frame": True},
+            "pi05_s": {"reason": "浅い: 33px", "kind": "補正入り", "grasp_depth": {"shallow": True}}}))
+        self.patch = mock.patch.object(cbp, "detect_corners", fake_corners)
+        self.patch.start()
+
+    def tearDown(self):
+        self.patch.stop()
+        self._td.cleanup()
+
+    def merge(self, name, *extra):
+        argv = ["merge_raw_episodes.py", "--data-root", str(self.root), "--src", "a", "--src", "b",
+                "--dataset", name, "--exclude-json", str(self.excl), *extra]
+        with mock.patch.object(sys, "argv", argv), mock.patch("sys.stdout"):
+            return mre.main()
+
+    def test_count_merged(self):
+        self.assertEqual(self.merge("ab"), 0)
+        res = cbp.count(["ab"], data_root=self.root, target=3, log=lambda *a: None)
+        t = res["table"]
+        self.assertEqual(res["total"], 6)
+        self.assertEqual([t[p]["episodes"] for p in cbp.POSITIONS], [0, 2, 0, 1, 1])
+        self.assertEqual(t[cbp.UNKNOWN]["episodes"], 2)                          # teach/ なし・遠い補正入り
+        self.assertEqual((t[5]["teach"], t[5]["correction"]), (0, 1))
+        self.assertEqual([t[p]["to_add"] for p in cbp.POSITIONS], [3, 1, 3, 2, 2])
+        self.assertEqual((t[1]["excluded_shallow"], t[2]["excluded_hand"], t[5]["excluded_shallow"]), (1, 1, 1))
+        self.assertEqual(sum(v["excluded_shallow"] for v in t.values()), 2)
+        # raw_episodes のフォルダを渡しても同じ
+        res2 = cbp.count([str(self.root / "datasets" / "ab" / "raw_episodes")], data_root=self.root, target=3,
+                         log=lambda *a: None)
+        self.assertEqual(res2["table"], t)
+        lines = cbp.table_lines(res)
+        self.assertIn("| 位置 1 | 0 | 0 | 0 | +3 | 1 | 0 | — |", lines)
+        self.assertIn("| 分からない | 2 | 1 | 1 | — | 0 | 0 | — |", lines)
+
+    def test_merge_readme_table_and_same_data(self):
+        self.assertEqual(self.merge("with"), 0)
+        self.assertEqual(self.merge("without", "--no-position-table"), 0)
+        ds = self.root / "datasets"
+        rw, rwo = (ds / "with" / "README.md").read_text(), (ds / "without" / "README.md").read_text()
+        self.assertIn("## 置き場所ごとの本数", rw)
+        self.assertIn("| 位置 2 | 2 | 2 | 0 | +8 |", rw)
+        self.assertNotIn("置き場所ごとの本数", rwo)
+        self.assertEqual(rw.split("\n## 置き場所ごとの本数")[0].rstrip("\n").replace("with", "X"),
+                         rwo.rstrip("\n").replace("without", "X"))
+        iw = json.loads((ds / "with" / "index.json").read_text())
+        iwo = json.loads((ds / "without" / "index.json").read_text())
+        iw.pop("repo_id"); iwo.pop("repo_id")
+        self.assertEqual(iw, iwo)
+        cmp = filecmp.dircmp(ds / "with" / "raw_episodes", ds / "without" / "raw_episodes")
+        self.assertEqual((cmp.left_only, cmp.right_only, cmp.diff_files), ([], [], []))
+        for sub in cmp.common_dirs:
+            c = filecmp.dircmp(ds / "with" / "raw_episodes" / sub, ds / "without" / "raw_episodes" / sub)
+            self.assertEqual(c.diff_files, [])
+        self.assertTrue((ds / "with" / "code" / "count_by_position.py").exists())
+        self.assertFalse((ds / "without" / "code" / "count_by_position.py").exists())
+
+    def test_merge_survives_count_error(self):
+        with mock.patch.object(cbp, "count", side_effect=RuntimeError("x")):
+            self.assertEqual(self.merge("err"), 0)
+        self.assertIn("数えられなかった", (self.root / "datasets" / "err" / "README.md").read_text())
+
+
+if __name__ == "__main__":
+    unittest.main()
