Spaces:
Sleeping
Sleeping
File size: 13,739 Bytes
78738de | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 | """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
|