poolcoach / src /poolcoach_cv /resim.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
13.7 kB
"""Resim + RMSE — lát A2 phần 2 (13/08/2026, BRIEF A2).
Sau khi analyze xong MỘT cú, nạp params suy được vào pooltool sim lại cú
trên bàn kích thước broadcast (2.54 × 1.27 m — cùng hệ toạ độ track) rồi
đo RMSE (mm, theo điểm track cue ball) giữa quỹ đạo resim và quan sát.
Chạy trong CV WORKER (venv ``poolcoach-cv-env`` — pooltool cài thêm ở
requirements-cv.txt, lát A2): worker là nơi duy nhất còn giữ đủ ngữ cảnh
per cú trước khi clip bị xoá, và JSON per cú phải ra ĐĨA ngay trong job
(nếp BG29b/BG31 — artifact ghi file ngay).
HAI bộ params, KHÔNG trộn (BRIEF A2 bối cảnh 3):
- ``shotnet`` — V0 GẬY (đã quy đổi m/s bàn thật), φ, (a, b) từ model.
- ``analytic`` — V0 là tốc độ BI đo từ track (thước KHÁC): quy về V0 gậy
bằng tỉ lệ ``v_ball/V0`` đo một lần lúc warmup (strike giữa bi trên
chính bàn resim — thuần pooltool, không fit); spin GIẢ ĐỊNH a=b=0
(analytic không có số a/b — chỉ có lớp spin thô). Hai giả định này ghi
thẳng vào JSON (``assumptions``) — người đọc RMSE analytic phải biết nó
gánh thêm mô hình hoá, không so ngang mù với RMSE shotnet.
Neo thời gian: sim t=0 là lúc gậy chạm bi; quan sát chỉ có ``motion_start``
(phát hiện SAU cú chạm tới ~1 bước frame — V_INIT_RUN bước vượt ngưỡng).
Nên t0 được DÒ trên lưới [0, 2·dt] trước motion_start (ALIGN_*), chọn nấc
RMSE nhỏ nhất, và ghi ``t_align_s`` vào JSON — khớp là khớp HÌNH quỹ đạo,
không để lệch pha lấy mẫu ~30–70mm giả dạng sai số suy ngược. Lưới dò là
tất định và được khai — không phải "vá để lấy số đẹp".
Series resim (toạ độ bàn + t, mọi bi dịch chuyển đủ xa) lưu LUÔN vào JSON
— trang chi tiết (nét đứt) và nút "Đánh lại cú" dùng chung, không tính
hai lần (BRIEF A2 phần 2).
Import pooltool LƯỜI trong hàm — venv app import module này được (tests
contract chạy không pooltool thật, nếp poolcoach_cv); phần toán thuần
(``rmse_mm``, ``align_t0``, ``sample_traj``) test được bằng quỹ đạo giả.
"""
from __future__ import annotations
import math
import time
import numpy as np
# Bàn resim = bàn broadcast (poolcoach_cv.broadcast TABLE_*) — nhắc lại số
# ở đây sẽ thành bản sao có thể trôi, nên import từ MỘT nguồn.
from poolcoach_cv.broadcast import BALL_R_M, TABLE_L_M, TABLE_W_M
RESIM_DT_S = 0.02 # bước lấy mẫu series ghi JSON (50 Hz — đủ mượt để
# vẽ nét đứt + animate, JSON không phình)
RESIM_MIN_DISP_M = 0.02 # bi resim dịch tổng dưới mức này không ghi series
# (bi đứng yên — series chỉ tổ phình JSON)
ALIGN_STEPS = 8 # dò t0 trên lưới ALIGN_STEPS+1 nấc đều...
ALIGN_SPAN_DT = 2.0 # ...phủ [0, ALIGN_SPAN_DT·dt_med] TRƯỚC motion_start
# (motion_start trễ sau cú chạm ≤ ~2 bước frame —
# V_INIT_RUN=3 bước, neo lấy bước đầu của run)
WARMUP_V0 = 2.0 # strike đo tỉ lệ v_ball/V0 (giữa bi, bàn resim)
# ------------------------------------------------------- toán thuần (no pt)
def sample_traj(sim_t: np.ndarray, sim_xy: np.ndarray,
at_t: np.ndarray) -> np.ndarray:
"""Nội suy tuyến tính quỹ đạo sim tại các mốc ``at_t`` (giây, trục sim).
Ngoài hai đầu → kẹp về điểm đầu/cuối (bi chưa đánh / đã dừng): so với
quan sát vẫn ĐÚNG nghĩa — quan sát còn chạy mà sim đã dừng thì lệch đó
là lệch thật, không được cắt bỏ."""
return np.stack([np.interp(at_t, sim_t, sim_xy[:, 0]),
np.interp(at_t, sim_t, sim_xy[:, 1])], axis=1)
def rmse_mm(obs_xy: np.ndarray, sim_xy: np.ndarray) -> float:
"""RMSE (mm) theo điểm giữa hai chuỗi vị trí cùng độ dài."""
d = np.asarray(obs_xy, dtype=np.float64) - np.asarray(sim_xy,
dtype=np.float64)
return float(np.sqrt(np.mean(d[:, 0] ** 2 + d[:, 1] ** 2)) * 1000.0)
def align_t0(obs_t: np.ndarray, obs_xy: np.ndarray, sim_t: np.ndarray,
sim_xy: np.ndarray, motion_start: float,
dt_med: float) -> tuple[float, float, float]:
"""Dò mốc t0 của sim trên trục track: t0 = motion_start − Δ, Δ trên
lưới đều [0, ALIGN_SPAN_DT·dt_med] (ALIGN_STEPS+1 nấc). Trả
``(t0, t_align=Δ, rmse_mm)`` của nấc RMSE nhỏ nhất — tất định, khai
trong JSON."""
best = None
for k in range(ALIGN_STEPS + 1):
delta = ALIGN_SPAN_DT * dt_med * k / ALIGN_STEPS
t0 = motion_start - delta
err = rmse_mm(obs_xy, sample_traj(sim_t, sim_xy, obs_t - t0))
if best is None or err < best[2]:
best = (t0, delta, err)
return best
def _series(sim_t: np.ndarray, sim_xy: np.ndarray, t0: float) -> list:
"""Series một bi cho JSON: [[t_s, x_m, y_m], ...] lấy mẫu RESIM_DT_S,
t_s trên CÙNG trục thời gian với ``track`` của kết quả analyze."""
ts = np.arange(0.0, float(sim_t[-1]) + RESIM_DT_S / 2, RESIM_DT_S)
xy = sample_traj(sim_t, sim_xy, ts)
return [[round(float(t0 + t), 3), round(float(x), 4), round(float(y), 4)]
for t, (x, y) in zip(ts, xy)]
# --------------------------------------------------- pooltool (import lười)
def _build_system(cue_xy, others_xy, v0, phi, a, b, state):
import pooltool as pt
balls = {"cue": pt.Ball.create("cue", xy=(float(cue_xy[0]),
float(cue_xy[1])))}
for i, xy in enumerate(others_xy, start=1):
balls[f"b{i}"] = pt.Ball.create(f"b{i}", xy=(float(xy[0]),
float(xy[1])))
system = pt.System(table=state["table"], balls=balls,
cue=pt.Cue(cue_ball_id="cue"))
system.cue.set_state(V0=float(v0), phi=float(phi) % 360.0,
a=float(a), b=float(b))
return system
def _sim_trajs(cue_xy, others_xy, v0, phi, a, b, state) -> dict:
"""Sim MỘT cú → {ball_id: (ts, xy (n,2))} theo history continuize.
Ném exception khi pooltool ném — caller khai ``error`` per bộ, worker
không được chết vì một bộ params rác (cú ma vẫn được resim thử)."""
import pooltool as pt
system = _build_system(cue_xy, others_xy, v0, phi, a, b, state)
pt.simulate(system, inplace=True)
pt.continuize(system, dt=0.01, inplace=True)
out = {}
for bid, ball in system.balls.items():
rvw, _ss, ts = ball.history_cts.vectorize()
out[bid] = (np.asarray(ts, dtype=np.float64),
np.asarray(rvw[:, 0, :2], dtype=np.float64))
return out
def warmup() -> dict | None:
"""Dựng bàn resim + JIT pooltool MỘT lần lúc boot worker (nếp engine
worker: không báo alive lúc còn đang JIT — sim nguội 40–90s, sim ấm
~40ms). Warmup phủ các resolver hay gặp: chạm bi, chạm băng, spin,
vào lỗ. Đo luôn tỉ lệ ``v_ball/V0`` (strike giữa bi) cho bộ analytic.
Trả state dict hoặc None khi pooltool vắng/hỏng (resim tắt, analyze
sống nguyên — nếp shotnet)."""
try:
import pooltool as pt
from pooltool.objects.table.specs import PocketTableSpecs
except Exception as e: # noqa: BLE001 — thiếu pooltool ≠ worker chết
print(f"[resim] pooltool khong import duoc ({type(e).__name__}: {e})"
f" -- resim TAT, analyze van chay", flush=True)
return None
t0 = time.time()
state = {"table": pt.Table.from_table_specs(
PocketTableSpecs(l=TABLE_L_M, w=TABLE_W_M))}
try:
# 1) strike giữa bi, không chạm gì — đo tỉ lệ v_ball/V0
trajs = _sim_trajs((TABLE_W_M / 2, 0.8), [], WARMUP_V0, 90.0,
0.0, 0.0, state)
ts, xy = trajs["cue"]
v = np.hypot(*np.diff(xy, axis=0).T) / np.maximum(np.diff(ts), 1e-9)
state["v_ball_per_v0"] = round(float(v[0]) / WARMUP_V0, 4)
# 2) chạm bi + spin + băng + dồn về góc lỗ — JIT các resolver còn lại
_sim_trajs((TABLE_W_M / 2, 0.6), [(TABLE_W_M / 2 + 0.02, 1.4)],
3.0, 90.0, 0.25, 0.3, state)
_sim_trajs((0.3, 0.4), [(1.0, 2.2)], 4.0, 60.0, -0.3, -0.3, state)
except Exception as e: # noqa: BLE001
print(f"[resim] warmup pooltool hong ({type(e).__name__}: {e}) "
f"-- resim TAT", flush=True)
return None
print(f"[resim] pooltool JIT xong sau {time.time() - t0:.1f}s "
f"(v_ball/V0 = {state['v_ball_per_v0']:g}, ban "
f"{TABLE_W_M}x{TABLE_L_M}m)", flush=True)
return state
# ------------------------------------------------------------- khối resim
def _obs_arrays(result: dict):
"""(obs_t, obs_xy) các điểm track TỪ motion_start trở đi + vị trí cue
lúc nghỉ (median track trước motion_start; thiếu thì điểm đầu cửa sổ)."""
ms = (result.get("metrics") or {}).get("motion_start_s")
track = result.get("track") or []
t = np.array([float(p["t_s"]) for p in track])
xy = np.array([[float(p["x_m"]), float(p["y_m"])] for p in track]
) if track else np.empty((0, 2))
after = t >= ms - 1e-9
before = ~after
if int(before.sum()) >= 1:
cue0 = np.median(xy[before], axis=0)
elif int(after.sum()):
cue0 = xy[after][0]
else:
cue0 = None
return t[after], xy[after], cue0
def resim_result(result: dict, state: dict) -> dict:
"""Khối ``resim`` cho JSON kết quả MỘT cú — hai bộ params độc lập,
mỗi bộ hoặc đủ (params/rmse/series) hoặc ``error`` nói vì sao vắng.
Không đụng một số nào có sẵn trong ``result``."""
m = result.get("metrics") or {}
out: dict = {"table_w_m": TABLE_W_M, "table_l_m": TABLE_L_M,
"ball_r_m": BALL_R_M, "sets": {}}
ms = m.get("motion_start_s")
if ms is None:
why = ("không có motion_start để neo t=0 của sim — cú không resim "
"được (chính nó cũng là tín hiệu van)")
out["sets"]["shotnet"] = {"error": why}
out["sets"]["analytic"] = {"error": why}
return out
obs_t, obs_xy, cue0 = _obs_arrays(result)
if len(obs_t) < 2 or cue0 is None:
why = "không đủ điểm track sau motion_start để so quỹ đạo"
out["sets"]["shotnet"] = {"error": why}
out["sets"]["analytic"] = {"error": why}
return out
dt_med = (float(np.median(np.diff(obs_t))) if len(obs_t) > 1
else 1 / 30.0)
others = [(float(b["x_m"]), float(b["y_m"]))
for b in result.get("balls_init") or []
if b.get("type") != "cue"
and math.hypot(float(b["x_m"]) - cue0[0],
float(b["y_m"]) - cue0[1]) > 2 * BALL_R_M]
sets: dict[str, dict] = {}
sn = result.get("shotnet")
if sn:
sets["shotnet"] = {
"params": {"v0_mps": float(sn["v0_cue_mps"]),
"phi_deg": float(sn["phi_deg"]),
"a": float(sn["a"]), "b": float(sn["b"])},
"assumptions": "V0 gậy (model, đã quy đổi m/s bàn thật); "
"a/b từ model",
}
else:
sets["shotnet"] = {"error": "kết quả không có khối shotnet"}
v0_ball = m.get("v0_mps")
phi_an = m.get("phi_deg")
if v0_ball is not None and phi_an is not None:
sets["analytic"] = {
"params": {"v0_mps": round(float(v0_ball)
/ state["v_ball_per_v0"], 3),
"phi_deg": float(phi_an), "a": 0.0, "b": 0.0},
"assumptions": f"a=b=0 (analytic không có số a/b); V0 gậy suy "
f"từ tốc độ bi đo qua tỉ lệ v_ball/V0 = "
f"{state['v_ball_per_v0']:g} (đo lúc warmup, "
f"strike giữa bi)",
}
else:
sets["analytic"] = {"error": "analytic không đo được V0/φ"}
for name, blk in sets.items():
if "params" not in blk:
continue
p = blk["params"]
try:
trajs = _sim_trajs(cue0, others, p["v0_mps"], p["phi_deg"],
p["a"], p["b"], state)
except Exception as e: # noqa: BLE001 — params rác ≠ worker chết
blk.pop("params", None)
blk["error"] = (f"pooltool không sim được bộ {name} "
f"({type(e).__name__}: {e})")
continue
sim_t, sim_xy = trajs["cue"]
t0, dl, err = align_t0(obs_t, obs_xy, sim_t, sim_xy, float(ms),
dt_med)
blk["rmse_mm"] = round(err, 1)
blk["n_points"] = int(len(obs_t))
blk["t0_s"] = round(t0, 3)
blk["t_align_s"] = round(dl, 3)
series = {}
for bid, (bt, bxy) in trajs.items():
disp = float(np.linalg.norm(np.diff(bxy, axis=0),
axis=1).sum()) if len(bxy) > 1 else 0
if bid == "cue" or disp >= RESIM_MIN_DISP_M:
series[bid] = _series(bt, bxy, t0)
blk["series"] = series
out["sets"] = sets
return out