#!/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_20261109_094222.json": 1,     # 位置 1 を教え直した記録 (DEEP_DEMOS 2-1・LOG463)
    "drag_v2_20221013_230205.json": 2,
    "drag_v2_20221013_231249.json": 3,
    "drag_v2_20261109_123000.json": 3,     # 位置 3 を教え直した記録 (DEEP_DEMOS 2-1・2026-11-09)
    "drag_v2_20221013_232345.json": 4,
    "drag_v2_20261109_130000.json": 4,     # 位置 4 のブロックの上を差し替えた記録 (2026-11-09)
    "drag_v2_20261109_130300.json": 4,     # 位置 4 をゲームパッドで教え直した記録 (DEEP_DEMOS 2-1・2026-11-09)
    "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())
