poolcoach / scripts /broadcast /measure_p0.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
18.7 kB
"""Bước 4 P0 (BRIEF 11/08/2026): đo số gate G0 từ track đã sinh ở bước 3.
Đọc per-shot ``track.csv`` + ``detections.csv`` + ``meta.json``, xuất CSV ra
ROOT ``D:\\Khoa luan`` theo nếp eval, tiền tố ``bb9_p0_``:
- ``bb9_p0_shots.csv`` — per shot: tỷ lệ frame detect cue, coverage theo
THỜI LƯỢNG (số gate G0), max gap, thời điểm cue bắt đầu chạy, số gãy khúc.
- ``bb9_p0_noise.csv`` — noise vị trí (mm) per-ball: các bi đứng yên trong
cửa sổ trước khi cue chạy → std/phân vị sau homography.
- ``bb9_p0_segments.csv`` — verifier vật lý bản thô: giữa hai gãy khúc fit
đoạn thẳng (PCA) + giảm tốc tuyến tính; residual mm/phân vị. CHỈ fit-và-báo.
- ``bb9_p0_summary.csv`` — bảng tổng + verdict G0.
- ``bb9_p0_plots\\`` — speed trace + path per shot, histogram tổng.
Ngưỡng "đứng yên" và "gãy khúc" là OUTPUT (đề xuất từ percentile đo thật,
kèm biểu đồ — Cowork chốt vào design doc §5), KHÔNG phải input chỉnh tay.
Cửa sổ đứng yên cắt bằng ngưỡng khởi động bảo thủ V_INIT (hằng số, ghi rõ)
— không phụ thuộc số đề xuất để tránh vòng lặp gà-trứng.
Ghi chú tay per-shot (cue có chạm băng/bi sau chạm đầu → spin identifiable?)
đọc từ ``<root>/notes.json`` nếu có: {"1": {"contact_after_first": true,
"comment": "..."}, ...} — thống kê tỷ lệ spin-unidentifiable (rủi ro §10).
D:\\Khoa luan\\poolcoach-cv-env\\Scripts\\python.exe \
scripts/broadcast/measure_p0.py
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] # poolcoach-rl/
sys.path.insert(0, str(ROOT / "src"))
import numpy as np # noqa: E402
PILOT_ROOT = ROOT / "datasets" / "bb9_pilot"
OUT_ROOT = ROOT.parent # D:\Khoa luan — nếp eval CSV ra root
BALL_CLASSES = {"Black", "Cue", "Solid", "Striped"}
V_INIT = 0.25 # m/s — ngưỡng BẢO THỦ chỉ để cắt cửa sổ đứng yên
V_INIT_RUN = 3 # số bước liên tiếp vượt V_INIT mới tính là "bắt đầu chạy"
STILL_MARGIN = 2 # bỏ 2 frame sát lúc chạy khỏi cửa sổ đứng yên
CLUSTER_R_M = 0.06 # bán kính gom det đứng yên về cùng một bi (~2R bi)
MIN_STILL_FRAMES = 5
GAP_SPLIT_S = 0.15 # track đứt quá mức này → cắt đoạn fit
MIN_SEG_PTS = 6
PCTS = (50, 90, 95, 99)
def read_csv(path: Path) -> list[dict]:
with open(path, encoding="utf-8") as f:
return list(csv.DictReader(f))
def write_csv(path: Path, rows: list[dict]) -> None:
if not rows:
return
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
def pct_dict(a: np.ndarray, prefix: str, scale: float = 1.0,
nd: int = 2) -> dict:
out = {}
for p in PCTS:
out[f"{prefix}_p{p}"] = round(float(np.percentile(a, p)) * scale, nd)
out[f"{prefix}_max"] = round(float(a.max()) * scale, nd)
return out
def find_dup_frames(track: list[dict]) -> set[str]:
"""Frame nhân đôi (stream 30fps từ nguồn 25fps → ~1/6 frame gần trùng).
Luật CỤC BỘ trên cột ``img_diff`` (track_p0): frame là bản trùng nếu
diff của nó < 0.3 × median diff của ≤6 frame lân cận VÀ lân cận đang
thực sự chuyển động (median > 0.08 — tránh cờ giả trong cảnh tĩnh,
nơi mọi diff đều ~nhiễu encoder).
"""
names = [r["frame_file"] for r in track]
diffs = [float(r.get("img_diff", -1)) for r in track]
dups: set[str] = set()
for i in range(1, len(diffs)):
if diffs[i] < 0:
continue
win = [diffs[j] for j in range(max(1, i - 3), min(len(diffs), i + 4))
if j != i and diffs[j] >= 0]
if not win:
continue
med = float(np.median(win))
if med > 0.08 and diffs[i] < 0.3 * med:
dups.add(names[i])
return dups
def cue_speed_series(track: list[dict], skip: frozenset = frozenset()):
"""(t, x, y) các frame covered → (t_mid, speed m/s, heading rad).
``skip``: frame nhân đôi cần loại khỏi chuỗi vận tốc (dt tự cộng dồn
sang frame thật kế tiếp — timestamp thật vẫn đúng).
"""
pts = [(float(r["t_s"]), float(r["table_x_m"]), float(r["table_y_m"]))
for r in track
if r["covered"] == "1" and r["frame_file"] not in skip]
t = np.array([p[0] for p in pts])
xy = np.array([[p[1], p[2]] for p in pts])
if len(pts) < 2:
return t, xy, np.array([]), np.array([]), np.array([])
dt = np.diff(t)
dxy = np.diff(xy, axis=0)
dist = np.hypot(dxy[:, 0], dxy[:, 1])
speed = dist / np.maximum(dt, 1e-6)
heading = np.arctan2(dxy[:, 1], dxy[:, 0])
t_mid = t[:-1] + dt / 2
return t, xy, t_mid, speed, heading
def find_motion_start(t_mid: np.ndarray, speed: np.ndarray) -> float | None:
run = 0
for i, v in enumerate(speed):
run = run + 1 if v > V_INIT else 0
if run >= V_INIT_RUN:
return float(t_mid[i - V_INIT_RUN + 1])
return None
def noise_for_shot(dets: list[dict], t0: float, t_still_end: float,
shot: str) -> tuple[list[dict], np.ndarray, np.ndarray]:
"""Cluster det đứng yên per-ball → noise mm + apparent speed pooled."""
still = [d for d in dets
if d["at_op"] == "1" and d["in_table"] == "1"
and d["cls"] in BALL_CLASSES
and t0 <= float(d["t_s"]) <= t_still_end]
if not still:
return [], np.array([]), np.array([])
frames = sorted({d["frame_file"] for d in still})
n_frames = len(frames)
first = [d for d in still if d["frame_file"] == frames[0]]
clusters = [{"cls": d["cls"], "pts": [], "ts": []} for d in first]
anchors = np.array([[float(d["table_x_m"]), float(d["table_y_m"])]
for d in first])
for d in still:
p = np.array([float(d["table_x_m"]), float(d["table_y_m"])])
dist = np.hypot(*(anchors - p).T)
j = int(dist.argmin())
if dist[j] <= CLUSTER_R_M:
clusters[j]["pts"].append(p)
clusters[j]["ts"].append(float(d["t_s"]))
rows, devs_all, speeds_all = [], [], []
for j, c in enumerate(clusters):
if len(c["pts"]) < max(MIN_STILL_FRAMES, int(0.5 * n_frames)):
continue
pts = np.array(c["pts"])
mean = pts.mean(axis=0)
dev = np.hypot(*(pts - mean).T) # m, so voi tam trung binh
order = np.argsort(c["ts"])
ts = np.array(c["ts"])[order]
ps = pts[order]
dt = np.diff(ts)
ok = dt > 1e-6
sp = np.hypot(*np.diff(ps, axis=0).T)[ok] / dt[ok]
row = {"shot": shot, "ball_idx": j, "cls": c["cls"],
"n_dets": len(pts), "window_frames": n_frames,
"std_x_mm": round(float(pts[:, 0].std()) * 1000, 2),
"std_y_mm": round(float(pts[:, 1].std()) * 1000, 2)}
row.update(pct_dict(dev, "r_mm", scale=1000.0))
rows.append(row)
devs_all.append(dev)
speeds_all.append(sp)
devs = np.concatenate(devs_all) if devs_all else np.array([])
spds = np.concatenate(speeds_all) if speeds_all else np.array([])
return rows, devs, spds
def split_segments(t: np.ndarray, xy: np.ndarray, heading: np.ndarray,
speed: np.ndarray, t_mid: np.ndarray, v_still: float,
kink_deg: float):
"""Cắt track cue thành đoạn giữa các gãy khúc / khoảng đứt / dừng."""
moving = speed > max(v_still, V_INIT / 2)
dtheta = np.abs(np.degrees(np.diff(np.unwrap(heading))))
kinks = [] # index vao mang diem (xy)
for i in range(len(dtheta)):
if moving[i] and moving[i + 1] and dtheta[i] > kink_deg:
kinks.append(i + 1) # diem giua 2 buoc doi huong
cut = set(kinks)
for i in range(len(t) - 1):
if t[i + 1] - t[i] > GAP_SPLIT_S:
cut.add(i + 1)
for i in range(len(speed)):
if not moving[i]:
cut.add(i + 1)
segs, start = [], 0
for i in sorted(cut) + [len(t)]:
if i - start >= MIN_SEG_PTS:
segs.append((start, i))
start = max(start, i)
return segs, kinks, dtheta, moving
def fit_segment(t: np.ndarray, xy: np.ndarray) -> dict:
"""PCA line fit (residual vuông góc, mm) + giảm tốc tuyến tính."""
mean = xy.mean(axis=0)
X = xy - mean
_, _, Vt = np.linalg.svd(X, full_matrices=False)
d = Vt[0]
perp = np.abs(X @ np.array([-d[1], d[0]])) # m
dt = np.diff(t)
sp = np.hypot(*np.diff(xy, axis=0).T) / np.maximum(dt, 1e-6)
t_mid = t[:-1] + dt / 2
if len(sp) >= 2:
A = np.vstack([t_mid - t_mid[0], np.ones_like(t_mid)]).T
coef, *_ = np.linalg.lstsq(A, sp, rcond=None)
v_resid = sp - A @ coef
decel = -float(coef[0])
else:
v_resid = np.array([0.0])
decel = float("nan")
out = {"n_pts": len(t), "dur_s": round(float(t[-1] - t[0]), 3),
"decel_mps2": round(decel, 3),
"v_mean_mps": round(float(sp.mean()), 3) if len(sp) else 0.0}
out.update(pct_dict(perp, "line_resid_mm", scale=1000.0))
out["v_resid_rms_mps"] = round(float(np.sqrt((v_resid ** 2).mean())), 4)
return out
def main() -> None:
ap = argparse.ArgumentParser(description="Do so gate G0 (P0)")
ap.add_argument("--root", type=Path, default=PILOT_ROOT)
ap.add_argument("--out-root", type=Path, default=OUT_ROOT)
ap.add_argument("--only", type=int, nargs="*", default=None)
args = ap.parse_args()
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
plots = args.out_root / "bb9_p0_plots"
plots.mkdir(exist_ok=True)
shots = sorted(d for d in args.root.glob("shot_*") if d.is_dir()
if (d / "track.csv").exists())
if args.only:
want = {f"shot_{i:02d}" for i in args.only}
shots = [d for d in shots if d.name in want]
if not shots:
sys.exit("[ERROR] Khong co shot nao co track.csv — chay track_p0.py truoc.")
notes_path = args.root / "notes.json"
notes = (json.loads(notes_path.read_text(encoding="utf-8"))
if notes_path.exists() else {})
shot_rows, noise_rows, seg_rows = [], [], []
all_dev, all_still_sp, all_dtheta_smooth = [], [], []
# ---- pass 1: noise + still-speed de xuat v_still --------------------
per_shot_cache = {}
for d in shots:
track = read_csv(d / "track.csv")
dets = read_csv(d / "detections.csv") if (d / "detections.csv").exists() else []
t_all = np.array([float(r["t_s"]) for r in track])
dups = frozenset(find_dup_frames(track))
t, xy, t_mid, speed, heading = cue_speed_series(track, skip=dups)
motion_start = find_motion_start(t_mid, speed) if len(speed) else None
dt_med = float(np.median(np.diff(t_all))) if len(t_all) > 1 else 1 / 30
t_still_end = (motion_start - STILL_MARGIN * dt_med
if motion_start is not None else t_all[0] + 5 * dt_med)
nrows, dev, sp = noise_for_shot(dets, t_all[0], t_still_end, d.name)
noise_rows += nrows
if len(dev):
all_dev.append(dev)
if len(sp):
all_still_sp.append(sp)
per_shot_cache[d.name] = (track, dets, t_all, t, xy, t_mid, speed,
heading, motion_start, dt_med, t_still_end,
dups)
still_sp = np.concatenate(all_still_sp) if all_still_sp else np.array([0.0])
v_still_prop = float(np.percentile(still_sp, 99))
# ---- pass 2: kink + segments voi v_still de xuat --------------------
# Nguong gay khuc de xuat: p95 cua |dtheta| khi dang chay TRON —
# lay tu phan phoi thuc te, bao cao percentile de Cowork chot.
dtheta_pool, dtheta_pool_raw = [], []
for d in shots:
(track, _dets, _t_all, t, xy, t_mid, speed, heading,
_ms, _dtm, _tse, _dups) = per_shot_cache[d.name]
for pool, series in ((dtheta_pool, (speed, heading)),
(dtheta_pool_raw,
cue_speed_series(track)[3:5])):
sp, hd = series
if len(sp) < 3:
continue
moving = sp > max(v_still_prop, V_INIT / 2)
dth = np.abs(np.degrees(np.diff(np.unwrap(hd))))
for i in range(len(dth)):
if moving[i] and moving[i + 1]:
pool.append(dth[i])
dtheta_pool = np.array(dtheta_pool) if dtheta_pool else np.array([0.0])
dtheta_raw = (np.array(dtheta_pool_raw) if dtheta_pool_raw
else np.array([0.0]))
kink_prop = float(np.percentile(dtheta_pool, 95))
kink_prop_raw = float(np.percentile(dtheta_raw, 95))
for d in shots:
(track, dets, t_all, t, xy, t_mid, speed, heading, motion_start,
dt_med, t_still_end, dups) = per_shot_cache[d.name]
dur = float(t_all[-1] - t_all[0]) + dt_med
covered = np.array([r["covered"] == "1" for r in track])
dts = np.append(np.diff(t_all), dt_med)
cov_time = float(dts[covered].sum() / dur)
n_cue_frames = sum(1 for r in track if int(r["n_cands"]) > 0)
gaps = [float(r["gap_s"]) for r in track if r["gap_s"]
and float(r["gap_s"]) > 0]
segs, kinks, dtheta, moving = ([], [], np.array([]), np.array([]))
if len(speed) >= 3:
segs, kinks, dtheta, moving = split_segments(
t, xy, heading, speed, t_mid, v_still_prop, kink_prop)
for a, b in segs:
row = {"shot": d.name, "t_start_s": round(float(t[a]), 3)}
row.update(fit_segment(t[a:b], xy[a:b]))
seg_rows.append(row)
sid = d.name.split("_")[1].lstrip("0") or "0"
note = notes.get(sid, notes.get(d.name, {}))
shot_rows.append({
"shot": d.name, "n_frames": len(track),
"dur_s": round(dur, 3),
"cue_det_rate": round(n_cue_frames / len(track), 4),
"coverage_time": round(cov_time, 4),
"max_gap_s": round(max(gaps), 3) if gaps else 0.0,
"motion_start_s": (round(motion_start - t_all[0], 3)
if motion_start is not None else ""),
"n_kinks": len(kinks),
"n_segments": len(segs),
"n_dup_frames": len(dups),
"contact_after_first": note.get("contact_after_first", ""),
"note": note.get("comment", ""),
})
# plot per shot: speed trace + path
fig, axes = plt.subplots(1, 2, figsize=(13, 4.5))
if len(speed):
axes[0].plot(t_mid - t_all[0], speed, lw=1)
for k in kinks:
axes[0].axvline(float(t[k] - t_all[0]), color="r", lw=0.6,
alpha=0.6)
if motion_start is not None:
axes[0].axvline(motion_start - t_all[0], color="g", ls="--",
lw=1, label="motion start")
axes[0].axhline(v_still_prop, color="orange", ls=":",
label=f"v_still p99 = {v_still_prop:.3f} m/s")
axes[0].legend(fontsize=8)
axes[0].set_xlabel("t (s)")
axes[0].set_ylabel("cue speed (m/s)")
axes[0].set_title(f"{d.name} speed trace (kinks do)")
if len(xy):
axes[1].plot(xy[:, 0], xy[:, 1], ".-", ms=2, lw=0.8)
axes[1].plot(xy[0, 0], xy[0, 1], "go", label="start")
axes[1].legend(fontsize=8)
axes[1].set_xlim(-0.05, 1.32)
axes[1].set_ylim(-0.05, 2.59)
axes[1].set_aspect("equal")
axes[1].set_title("cue path (table m)")
fig.tight_layout()
fig.savefig(plots / f"{d.name}_trace.png", dpi=110)
plt.close(fig)
# ---- histogram tong hop --------------------------------------------
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
if len(still_sp) > 1:
axes[0].hist(still_sp * 1000, bins=40)
axes[0].axvline(v_still_prop * 1000, color="r",
label=f"p99 = {v_still_prop * 1000:.1f} mm/s")
axes[0].set_xlabel("apparent speed khi DUNG YEN (mm/s)")
axes[0].legend(fontsize=8)
if len(dtheta_pool) > 1:
axes[1].hist(dtheta_pool, bins=40)
axes[1].axvline(kink_prop, color="r", label=f"p95 = {kink_prop:.1f} deg")
axes[1].set_xlabel("|delta heading| giua 2 buoc khi DANG CHAY (deg, da loai frame trung)")
axes[1].legend(fontsize=8)
fig.tight_layout()
fig.savefig(plots / "thresholds_hist.png", dpi=110)
plt.close(fig)
# ---- summary + G0 ---------------------------------------------------
write_csv(args.out_root / "bb9_p0_shots.csv", shot_rows)
write_csv(args.out_root / "bb9_p0_noise.csv", noise_rows)
write_csv(args.out_root / "bb9_p0_segments.csv", seg_rows)
n = len(shot_rows)
n80 = sum(1 for r in shot_rows if r["coverage_time"] >= 0.80)
dev_pool = np.concatenate(all_dev) if all_dev else np.array([])
contact_vals = [r["contact_after_first"] for r in shot_rows
if r["contact_after_first"] != ""]
n_noc = sum(1 for v in contact_vals if v is False or v == "False")
n_dup_total = sum(r["n_dup_frames"] for r in shot_rows)
n_frames_total = sum(r["n_frames"] for r in shot_rows)
summary = {"n_shots": n, "n_cov80": n80,
"G0_track": f"{n80}/{n} >= 7/10: "
f"{'DAT' if n80 >= 7 and n >= 10 else 'DO'}",
"v_still_prop_mms": round(v_still_prop * 1000, 1),
"kink_prop_deg": round(kink_prop, 1),
"kink_prop_raw_deg": round(kink_prop_raw, 1),
"dup_frame_rate": round(n_dup_total / max(n_frames_total, 1), 4),
"n_noise_balls": len(noise_rows),
"n_segments": len(seg_rows),
"spin_unident_noted": (f"{n_noc}/{len(contact_vals)}"
if contact_vals else "chua ghi chu")}
if len(dev_pool):
summary.update(pct_dict(dev_pool, "noise_r_mm", scale=1000.0))
write_csv(args.out_root / "bb9_p0_summary.csv", [summary])
print("\n===== G0 =====")
for k, v in summary.items():
print(f" {k}: {v}")
print(f"\nCSV: {args.out_root}\\bb9_p0_*.csv plots: {plots}")
if __name__ == "__main__":
main()