"""Pipeline Broadcast Analyzer dùng chung — bàn giao 24 (11/08/2026). Nhấc logic đã nghiệm thu ở P0 (scripts/broadcast/{extract_shots,track_p0, measure_p0}.py — bàn giao 23) thành module để app/worker dùng lại, cộng TẦNG GIẢI TÍCH mới (phôi thai baseline §7.3 design doc BroadcastAnalyzer): - ``analyze_track`` — V0 / φ / va chạm (+ phân loại ``contact`` bi/băng, BG25) / baseline spin thô ``spin_*`` (BG25 — xem ``_read_spin`` và bảng hằng SPIN_*/SIDE_*) / coverage / cảnh báo từ track cue ball. THUẦN NUMPY, không cv2/torch → unit test chạy trên venv app. - ``analyze_clip`` — front-end trọn gói cho MỘT clip 1 cú: decode PTS thật + cột img_diff, YOLO detect, homography 4 pocket → track → analyze_track. Import cv2 LƯỜI trong thân hàm (nếp poolcoach_cv/__init__: venv app không có cv2 vẫn ``import poolcoach_cv.broadcast`` được). Scripts P0 giữ NGUYÊN không sửa (BRIEF cho chọn wrapper hoặc giữ nguyên): số đo bàn giao 23 sinh từ đúng code đó, không đụng là bảo đảm rẻ nhất cho "không đổi số đo cũ". Module này là code path MỚI cho app. Quy ước toạ độ & góc: hệ bàn broadcast x ∈ [0, W=1.27], y ∈ [0, L=2.54] mét (mặt chơi 9ft chuẩn giải — KHÔNG phải bàn pooltool 0.9906×1.9812; FE tự chuẩn hoá khi vẽ). ``phi_deg`` = hướng chuyển động đo bằng độ, 0° = +x, 90° = +y, ngược chiều kim đồng hồ, trả trong [0, 360). Frame TRÙNG (stream 25fps upconvert khai 30fps — HANDOFF 23 Bất ngờ 1): MỌI đạo hàm theo thời gian ở đây chạy trên chuỗi ĐÃ loại frame trùng bằng ``img_diff`` (PTS không đủ — PTS frame trùng vẫn hợp lệ). Không dedup thì V0 dính dip giả về 0 mỗi 6 bước. """ from __future__ import annotations import math from pathlib import Path import numpy as np from poolcoach_cv.homography import TableHomography, orient_corners # ---------------------------------------------------------------- hằng số # MỘT bảng duy nhất (BRIEF bước 1.2) — đổi ngưỡng là đổi Ở ĐÂY, kèm nguồn. TABLE_W_M = 1.27 # mặt chơi 9ft chuẩn giải (BRIEF P0) — băng NGẮN TABLE_L_M = 2.54 # băng DÀI V_STILL_MPS = 0.055 # ngưỡng đứng yên — p99 apparent speed 37 bi tĩnh # (P0, Cowork chốt 55 mm/s trong BRIEF 24) V_INIT_MPS = 0.25 # ngưỡng BẢO THỦ tìm motion_start (measure_p0 — # cố ý ≫ V_STILL để không phụ thuộc số đề xuất) V_INIT_RUN = 3 # số bước liên tiếp vượt V_INIT mới là "bắt đầu chạy" V0_WINDOW_S = 0.2 # cửa sổ đo V0 sau motion_start (BRIEF bước 1.2) BALL_R_M = 0.028575 # bán kính bi 57.15mm/2 (bi pool 2¼", BallParams # pooltool mặc định — cùng bi mà env RL dùng) KINK_CUM_DEG = 35.0 # Δθ tích lũy ≥ mức này → va chạm (Cowork chốt, # tiêu chí 2 tầng thay per-step 31.8° bị blur chi phối) KINK_WIN_STEPS = 2 # tích lũy trên cửa sổ 1–2 BƯỚC frame thật (= 2–3 frame) KINK_ONSET_MIN_M = 2 * BALL_R_M # guard kink tại motion onset (Cowork chốt BRIEF 25, # phương án (a) HANDOFF 24 câu 1): bỏ ứng viên kink # khi quãng đường tích lũy < 2R ≈ 57mm — bi phải đi # hết đường kính mình thì heading mới có nghĩa (bước # đầu chỉ nhích cỡ sàn nhiễu dọc hướng chạy ±13mm → # heading rác → kink giả 164.7° của cú 11). Tính từ # điểm track ĐẦU TIÊN sau motion_start, không phải # frame motion_start danh nghĩa (shot_07: 24% đầu cú # mất track do người che — BRIEF 25 bối cảnh #5). SPEED_DROP_FRAC = 0.45 # tầng 2: tốc độ rơi ≥ 45% → va chạm (phôi thai — # median 2 bước mỗi bên để chịu nhiễu dọc hướng chạy # ±13mm ~ ±0.4 m/s/bước, HANDOFF 23 Bất ngờ 2) SPEED_DROP_MIN_MPS = 1.0 # sàn xét speed-drop (Cowork nâng 0.5 → 1.0, BRIEF 25: # cú 11 sinh 2 va chạm giả t=4.0/4.8 ở ~0.7 m/s giữa # đoạn lăn chậm — vùng nhiễu dọc hướng chạy chi phối). # Hệ quả CÓ CHỦ ĐÍCH, không phải bug: va chạm nhẹ mà # tốc độ trước chạm dưới 1.0 m/s sẽ BỎ SÓT ở tầng này # (tầng kink vẫn có thể bắt nếu đổi hướng đủ lớn). # VAI TRÒ KÉP (BG26 bước 1.2, Cowork chốt từ câu hỏi # HANDOFF 25 #4): cũng là sàn tốc độ quanh va chạm # cho _read_spin — chord tới va chạm chậm hơn mức # này thì spin_confidence bị ÉP "low" (vùng nhiễu # dọc hướng chạy chi phối, bài học cú 11). Cùng MỘT # hằng, không tạo hằng mới. COLLISION_MERGE_S = 0.15 # hai tín hiệu cách < mức này = CÙNG một va chạm # Phân loại va chạm bi/băng/unknown (BRIEF 25 bước 2) — trường ``contact`` # của mỗi va chạm. Tên ``contact`` chứ KHÔNG phải ``kind`` như chữ BRIEF: # ``kind`` đã là tầng tín hiệu (kink/speed_drop) từ bàn giao 24, mà Ranh # giới cấm đổi trường JSON đã có — khai trong HANDOFF 25. CONTACT_CUSHION_M = 1.5 * BALL_R_M # điểm va chạm cách mép băng ≤ ~1.5R → "cushion": # tâm bi lúc chạm băng cách mép đúng 1R, +0.5R cho # nhiễu vị trí + neo lệch 1 bước frame CONTACT_BALL_M = 2.5 * BALL_R_M # cách bi tĩnh ≤ ~2.5R → "ball": tâm-tâm lúc chạm # là 2R, +0.5R như trên. Xét SAU cushion — bi dính # băng thì ưu tiên đọc là chạm băng (thứ tự BRIEF) STATIC_CLUSTER_M = 0.04 # gom detection không-cue thành bi tĩnh: điểm cách # median cụm < ~1.4R nhập cụm (hai bi sát nhau tâm # vẫn cách 2R = 57mm nên không dính cụm nhau) STATIC_MIN_DETS = 3 # cụm ≥ 3 detection mới là bi thật (loại det chớp) # Baseline SPIN giải tích THÔ (BRIEF 25 bước 3; §7.3 design doc — baseline # bắt buộc mà BG26 net phải thắng). Spin bida CHỈ LỘ QUA VA CHẠM (§3.2): # dọc (follow/draw/stun) đọc ở va chạm BI đầu tiên, ngang (side-L/R) đọc ở # va chạm BĂNG; cú không va chạm → null (unidentifiable về nguyên tắc). # Vật lý tham khảo lấy từ pooltool 0.6.0 (KHÔNG import — module này phải # thuần numpy cho venv app; số dẫn ở đây đo bằng probe chạy pooltool thật): # - bi-bi: va chạm giữ thành phần TIẾP TUYẾN, chuyển thành phần dọc đường # nối tâm (resolve/ball_ball/frictionless_elastic) → hướng bật tức thời # của cú cắt (stun) lệch hướng tới đúng 90°−góc_cắt — tối đa 90°. # - trượt→lăn: physics/evolve — sau chạm bi trượt 0.09–0.30s (đo ở 1.2–2.0 # m/s) rồi mới lăn; topspin/backspin còn lại bẻ cong đường đi VỀ/RA XA # hướng tới cũ trong pha này. # - băng: resolve/ball_cushion/han_2005 + evolve — bi lăn KHÔNG side đã bật # lệch phản xạ gương tới ~15° ở hướng lăn ổn định (đo inc 30–60°, wz=0, # e_c 0.85 f_c 0.2 mặc định) → ngưỡng side phải VƯỢT floor đó; hệ quả có # chủ đích: side yếu (per model chỉ nhích thêm vài độ) sẽ BỎ SÓT. SPIN_PRE_S = 0.30 # cửa sổ chord hướng TỚI trước va chạm SPIN_SETTLE_S = 0.15 # pha trượt sau chạm — bỏ khi đo hướng ổn định SPIN_WIN_S = 0.45 # cửa sổ đọc sau va chạm (cắt tại va chạm kế nếu có) SPIN_MIN_CHORD_M = 0.02 # chord ngắn hơn mức này không tin được hướng SPIN_VERT_KINK_MIN_DEG = 40.0 # Δθ < mức này = góc cắt > ~50° (Δθ ≈ 90°−góc cắt): # cú CẮT MỎNG — tangent giữ gần hết vận tốc, xoáy # dọc không lộ → không đọc (ngưỡng góc cắt BRIEF) SPIN_DRAW_KINK_DEG = 110.0 # Δθ > mức này KHÔNG THỂ là tangent cú cắt (tối đa # 90° + nhiễu) → chữ ký draw trực tiếp (đường về) SPIN_BEND_MIN_DEG = 15.0 # cú cắt: đường sau chạm cong ≥ mức này về hướng cũ # → follow, ra xa → draw, còn lại stun (~2× nhiễu # heading chord vài bước: ±13mm trên chord 10–15cm) SPIN_STUN_MAX_M = 0.10 # cú chạm dày: trôi < mức này = dừng/trôi ngắn → stun SPIN_DIR_MIN_M = 0.06 # cú chạm dày: dịch dọc hướng cũ ≥ mức này (~2R, # ≫ sàn nhiễu 13mm) mới kết luận follow/draw SIDE_DEV_MIN_DEG = 20.0 # lệch phản xạ gương ≥ mức này → side (floor tự # nhiên ~15° ở trên + nhiễu); 20–30° đọc kèm dè dặt SIDE_DEV_MED_DEG = 30.0 # từ mức này spin_confidence lên medium DUP_REL = 0.3 # frame trùng: img_diff < 0.3 × median lân cận... DUP_MIN_MEDIAN = 0.08 # ...và lân cận đang thật sự chuyển động (measure_p0) VMAX_MPS = 11.0 # gating vận tốc track NN (design §4.4, track_p0) GATE_SLACK_M = 0.06 # nhiễu vị trí + tâm bbox lệch tâm bi (~2R) IN_TOL_M = 0.05 # tâm chiếu lệch ngoài mặt bàn quá mức này → loại det BALL_DEDUP_M = 0.03 # 2 tâm < ~1R = double-detect (nếp cv_worker) COVERAGE_WARN = 0.80 # dưới mức này cảnh báo (gate G0 dùng cùng mốc) GAP_WARN_S = 0.5 # track đứt dài hơn mức này → cảnh báo V0_SANE_MPS = (1.0, 8.0) # ngoài khoảng này V0 đáng ngờ (BRIEF bước 2.2) DETECT_BATCH = 16 # nếp track_p0 BALL_CLASSES = {"Black", "Cue", "Solid", "Striped"} # Dot = nút thành gỗ # ------------------------------------------------- tiện ích thuần (no cv2) def _wrap_deg(a: float) -> float: """Hiệu góc về (-180, 180].""" return ((a + 180.0) % 360.0) - 180.0 def find_dup_frames(names: list[str], diffs: list[float]) -> set[str]: """Frame nhân đôi theo luật CỤC BỘ trên img_diff (measure_p0, giữ nguyên): diff < DUP_REL × median của ≤6 lân cận VÀ lân cận đang chuyển động (median > DUP_MIN_MEDIAN — tránh cờ giả trong cảnh tĩnh).""" 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 > DUP_MIN_MEDIAN and diffs[i] < DUP_REL * med: dups.add(names[i]) return dups def _speed_series(t: np.ndarray, xy: np.ndarray): """(t, xy) đã dedup → (t_mid, speed m/s, heading deg). len = len(t)-1.""" dt = np.diff(t) dxy = np.diff(xy, axis=0) speed = np.hypot(dxy[:, 0], dxy[:, 1]) / np.maximum(dt, 1e-6) heading = np.degrees(np.arctan2(dxy[:, 1], dxy[:, 0])) return t[:-1] + dt / 2, speed, heading def _find_motion_start(t_mid: np.ndarray, speed: np.ndarray) -> float | None: """Mốc cue ball bắt đầu chạy: V_INIT_RUN bước liên tiếp vượt V_INIT (measure_p0.find_motion_start, giữ nguyên).""" run = 0 for i, v in enumerate(speed): run = run + 1 if v > V_INIT_MPS else 0 if run >= V_INIT_RUN: return float(t_mid[i - V_INIT_RUN + 1]) return None def _find_collisions(t: np.ndarray, xy: np.ndarray, t_mid: np.ndarray, speed: np.ndarray, heading: np.ndarray, t_from: float) -> list[dict]: """Va chạm = gãy khúc 2 tầng đã chốt (BRIEF bước 1.2). Tầng 1 — Δθ tích lũy: |heading[i+w] − heading[i]| (wrap) ≥ KINK_CUM_DEG với w ∈ {1..KINK_WIN_STEPS} và mọi bước trong cửa sổ đang CHẠY. Per-step thuần bị nhiễu blur chi phối (p95 31.8° ~ floor nhiễu — HANDOFF 23). Tầng 2 — speed-drop: median tốc độ 2 bước TRƯỚC vs 2 bước SAU rơi ≥ SPEED_DROP_FRAC (bắt cú full-hit: cue dừng, không còn heading mà gãy). Median 2 bước mỗi bên vì nhiễu dọc hướng chạy làm tốc độ per-step lắc ±0.4 m/s — so per-step sẽ báo láo giữa đoạn lăn chậm. Tín hiệu cách nhau < COLLISION_MERGE_S gộp làm MỘT va chạm (một cú chạm thường kích cả hai tầng). Chỉ xét sau ``t_from`` (motion_start). Guard onset (BRIEF 25 bước 1.1): ứng viên kink có neo nằm trong quãng KINK_ONSET_MIN_M (2R) đầu tiên của track bị BỎ — quãng đo dọc track từ điểm track đầu tiên sau ``t_from`` (xem chú ở bảng hằng). """ moving = speed > max(V_STILL_MPS, V_INIT_MPS / 2) events: list[tuple[float, int, str, float]] = [] # (t, idx điểm, kind, độ mạnh) # quãng đường tích lũy dọc track kể từ điểm đầu tiên sau motion_start i0 = int(np.searchsorted(t, t_from)) cum = np.zeros(len(t)) if i0 < len(t) - 1: seg = np.hypot(*np.diff(xy[i0:], axis=0).T) cum[i0 + 1:] = np.cumsum(seg) for i in range(len(heading) - 1): if t_mid[i] < t_from: continue for w in range(1, KINK_WIN_STEPS + 1): j = i + w if j >= len(heading) or not all(moving[i:j + 1]): continue dth = abs(_wrap_deg(heading[j] - heading[i])) if dth >= KINK_CUM_DEG: # neo tại điểm có bước đổi hướng đơn lớn nhất trong cửa sổ steps = [abs(_wrap_deg(heading[k + 1] - heading[k])) for k in range(i, j)] k = i + 1 + int(np.argmax(steps)) if cum[k] < KINK_ONSET_MIN_M: continue # heading trong 2R đầu là rác — bỏ ứng viên events.append((float(t[k]), k, "kink", dth)) break for i in range(1, len(speed) - 2): if t_mid[i] < t_from or not moving[i]: continue before = float(np.median(speed[i - 1:i + 1])) after = float(np.median(speed[i + 1:i + 3])) if before >= SPEED_DROP_MIN_MPS and after <= (1 - SPEED_DROP_FRAC) * before: events.append((float(t[i + 1]), i + 1, "speed_drop", 1 - after / before)) events.sort(key=lambda e: e[0]) collisions: list[dict] = [] for t_e, idx, kind, strength in events: if collisions and t_e - collisions[-1]["t_s"] < COLLISION_MERGE_S: c = collisions[-1] if kind not in c["kind"]: c["kind"] = c["kind"] + "+" + kind if kind == "kink": c["dtheta_deg"] = max(c.get("dtheta_deg") or 0.0, strength) else: c["drop_frac"] = max(c.get("drop_frac") or 0.0, strength) continue collisions.append({ "t_s": t_e, "x_m": float(xy[idx, 0]), "y_m": float(xy[idx, 1]), "kind": kind, "dtheta_deg": strength if kind == "kink" else None, "drop_frac": strength if kind == "speed_drop" else None, }) for c in collisions: c["t_s"] = round(c["t_s"], 3) c["x_m"] = round(c["x_m"], 4) c["y_m"] = round(c["y_m"], 4) if c["dtheta_deg"] is not None: c["dtheta_deg"] = round(c["dtheta_deg"], 1) if c["drop_frac"] is not None: c["drop_frac"] = round(c["drop_frac"], 3) return collisions def _static_balls(others: list[dict], t_until: float) -> list[tuple[float, float]]: """Bi TĨNH trước lúc cue ball chạy: gom các detection KHÔNG-cue có ``t_s < t_until`` (cửa sổ trước motion_start) thành cụm theo khoảng cách tới median cụm, trả median (x, y) mỗi cụm đủ STATIC_MIN_DETS detection. Không cần BallID, không track bi mục tiêu (BRIEF 25 bước 2).""" clusters: list[list[tuple[float, float]]] = [] for o in others: if float(o["t_s"]) >= t_until: continue p = (float(o["x_m"]), float(o["y_m"])) best = None for ci, cl in enumerate(clusters): med = np.median(np.asarray(cl), axis=0) d = math.hypot(p[0] - med[0], p[1] - med[1]) if d < STATIC_CLUSTER_M and (best is None or d < best[0]): best = (d, ci) if best is None: clusters.append([p]) else: clusters[best[1]].append(p) return [tuple(np.median(np.asarray(cl), axis=0)) for cl in clusters if len(cl) >= STATIC_MIN_DETS] def _classify_contacts(collisions: list[dict], statics: list[tuple[float, float]], t: np.ndarray | None = None, xy: np.ndarray | None = None) -> None: """Gắn ``contact`` ∈ {cushion, ball, unknown} cho từng va chạm — heuristic vị trí (BRIEF 25 bước 2, thứ tự cushion → ball → unknown): cách mép băng (4 mép hệ bàn từ homography) ≤ CONTACT_CUSHION_M → cushion; cách bi tĩnh gần nhất ≤ CONTACT_BALL_M → ball; còn lại unknown. Neo kink lệch (BG26 bước 1.1 — Cowork chốt phương án (a) HANDOFF 25 câu 2): với va chạm CÓ tín hiệu kink, khoảng cách tới mép băng lấy MIN trên ±2 điểm track quanh neo (``t``/``xy`` — chuỗi đã dedup). speed_drop giữ cách tính cũ (đo tại neo): neo drop không lệch hệ thống, nới cửa sổ chỉ tổ đọc nhầm cú chạm BI gần băng thành cushion. Khoảng cách tới BI TĨNH vẫn đo tại neo cho mọi loại. KẾT QUẢ ÂM đã đo (rerun BG26, HANDOFF 26): cửa sổ MIN một mình KHÔNG flip được 2 ca thật BG25 — chạm băng thật rơi GIỮA hai frame nên chính neo đã là điểm track gần mép nhất (cú 11: 1.56R, ±2 điểm quanh nó 1.81–2.6R; cú 12 t=2.6: 1.53R). (b′) BG26b — Cowork chốt từ HANDOFF 26 câu 1: NGƯỠNG cushion cho ứng viên KINK thành ĐỘNG theo hình học lấy mẫu: thresh = CONTACT_CUSHION_M + v·dt/2 (= 1R tâm-bi-chạm-băng + 0.5R nhiễu/neo lệch + nửa quãng bi đi được giữa hai frame) với v = tốc độ CHORD vào va chạm trên cửa sổ SPIN_PRE_S trước neo (cùng định nghĩa "hướng tới" của _read_spin), dt = khoảng frame MEDIAN của chuỗi đã dedup. Lý do hình học: bi chạm băng giữa hai lần lấy mẫu thì điểm sample gần mép nhất vẫn có thể cách thêm tới v·dt/2 (cú 11: 0.58 m/s × 33ms/2 ≈ 10mm ≈ 0.34R — đúng phần hụt 1.6mm + dư). speed_drop GIỮ ngưỡng tĩnh CONTACT_CUSHION_M như cũ. Caveat CÓ CHỦ ĐÍCH (Cowork chấp nhận trong BRIEF): cú NHANH gãy hướng sát băng có thể bị gán nhầm cushion vì slack v·dt/2 lớn theo v — hệ quả của lấy mẫu thưa, không phải ngưỡng cào theo ca.""" dt_med = (float(np.median(np.diff(t))) if t is not None and len(t) > 1 else 0.0) for c in collisions: x, y = c["x_m"], c["y_m"] d_rail = min(x, TABLE_W_M - x, y, TABLE_L_M - y) thresh = CONTACT_CUSHION_M if "kink" in c["kind"] and t is not None and len(t): k = int(np.argmin(np.abs(t - c["t_s"]))) win = xy[max(0, k - 2):k + 3] d_rail = float(min( min(px, TABLE_W_M - px, py, TABLE_L_M - py) for px, py in win)) i0 = int(np.searchsorted(t, c["t_s"] - SPIN_PRE_S - 1e-9)) if i0 < k: v_in = (float(np.hypot(*(xy[k] - xy[i0]))) / max(float(t[k] - t[i0]), 1e-6)) thresh += v_in * dt_med / 2.0 if d_rail <= thresh: c["contact"] = "cushion" elif statics and min(math.hypot(x - sx, y - sy) for sx, sy in statics) <= CONTACT_BALL_M: c["contact"] = "ball" else: c["contact"] = "unknown" _RAILS = ( # (tên hiện cho người xem, pháp tuyến HƯỚNG VÀO BÀN n̂_in) ("băng dài trái", (1.0, 0.0)), ("băng dài phải", (-1.0, 0.0)), ("băng ngắn dưới", (0.0, 1.0)), ("băng ngắn trên", (0.0, -1.0))) def _read_spin(t: np.ndarray, xy: np.ndarray, collisions: list[dict], motion_start: float): """Baseline spin giải tích thô → (spin_class, spin_confidence, evidence). ``spin_class``: "+"-ghép của trục dọc {follow, draw, stun} (đọc ở va chạm ĐẦU, phải là contact "ball", góc cắt không quá mỏng) và trục ngang {side-L, side-R} (đọc ở va chạm "cushion" đầu tiên, so hướng bật ổn định với phản xạ gương) — một cú có thể vừa dọc vừa ngang. Không trục nào đủ chứng cứ → (None, None, evidence-vì-sao) — văn hoá ``number: null`` của BallID, không đoán bừa. Quy ước dấu side (nghiệm thu bằng probe han2005, khớp bẫy spin đã trả giá "a=+1 là mép TRÁI bi"): đánh mép trái → ωz < 0 (kim đồng hồ nhìn từ trên) → thành phần tiếp tuyến sau bật bị KÉO theo −(ẑ×n̂_in); suy ngược sign(ωz) = sign((û_out − û_gương)·(ẑ×n̂_in)); ωz<0 = side-L. Ép confidence theo tốc độ (BG26 bước 1.2): trục nào đọc ở va chạm mà tốc độ chord TỚI va chạm < SPEED_DROP_MIN_MPS thì confidence của trục đó bị ÉP "low" bất kể luật hình học nói gì — dưới sàn đó nhiễu dọc hướng chạy (±13mm ≈ ±0.4 m/s/bước) chi phối chính tín hiệu đang đọc (bài học cú 11 lăn chậm). Evidence ghi rõ khi ép để FE/eval khỏi đoán. Evidence: mỗi dòng {axis, t_s, deg|m, text} — text tiếng Việt FE nối hiện thẳng, số cho máy (BG26 đối chiếu baseline không phải parse text). """ ev: list[dict] = [] parts: list[tuple[str, str]] = [] # (lớp, confidence) def note(axis, text, t_s=None, deg=None, m=None): d = {"axis": axis, "text": text} if t_s is not None: d["t_s"] = round(float(t_s), 3) if deg is not None: d["deg"] = round(float(deg), 1) if m is not None: d["m"] = round(float(m), 3) ev.append(d) if not collisions: note("vertical", "cú không có va chạm nào — spin chỉ lộ qua va chạm, " "không xác định được là ĐÁP ÁN đúng (§3.2 design)") return None, None, ev def pts(t_a, t_b): return np.flatnonzero((t >= t_a - 1e-9) & (t <= t_b + 1e-9)) def unit(v): n = float(np.hypot(v[0], v[1])) return (v / n if n > 1e-9 else None), n def ang(u, v): return float(np.degrees(np.arccos(np.clip(float(np.dot(u, v)), -1.0, 1.0)))) def force_slow(axis, n0, v_loc): """BG26 bước 1.2: trục vừa đọc ra part (len(parts) > n0) mà tốc độ chord tới va chạm dưới sàn SPEED_DROP_MIN_MPS → ép confidence "low" + evidence nói rõ vì sao (kể cả khi luật hình học đã cho low).""" if (len(parts) > n0 and v_loc is not None and v_loc < SPEED_DROP_MIN_MPS): parts[-1] = (parts[-1][0], "low") note(axis, f"tốc độ quanh va chạm {v_loc:.2f} m/s dưới sàn " f"{SPEED_DROP_MIN_MPS:g} m/s — vùng nhiễu dọc hướng chạy " "chi phối, confidence ép low") # ---------------- trục DỌC: follow / draw / stun ở va chạm ĐẦU c0 = collisions[0] t0 = float(c0["t_s"]) i_c = int(np.argmin(np.abs(t - t0))) t_next = float(collisions[1]["t_s"]) if len(collisions) > 1 else np.inf t_end = min(t0 + SPIN_WIN_S, t_next) dth = float(c0.get("dtheta_deg") or 0.0) has_drop = "speed_drop" in c0["kind"] pre = pts(max(motion_start, t0 - SPIN_PRE_S), t0) u_pre, pre_len = (unit(xy[i_c] - xy[pre[0]]) if len(pre) >= 2 else (None, 0.0)) v_pre = (pre_len / max(float(t[i_c] - t[pre[0]]), 1e-6) if len(pre) >= 2 else None) n_vert = len(parts) if c0.get("contact") != "ball": note("vertical", f"va chạm đầu là {c0.get('contact') or 'unknown'}, " "không phải bi — không đọc được xoáy dọc", t_s=t0) elif u_pre is None or pre_len < SPIN_MIN_CHORD_M: note("vertical", "không đủ track trước va chạm để lấy hướng tới", t_s=t0) elif dth >= SPIN_DRAW_KINK_DEG: parts.append(("draw", "medium" if has_drop else "low")) note("vertical", f"sau chạm bi đường đi quay ngược Δθ {dth:.0f}° — " "tangent cú cắt tối đa 90°, chỉ backspin kéo về được → draw", t_s=t0, deg=dth) elif dth >= SPIN_VERT_KINK_MIN_DEG: tan = pts(t0, t0 + SPIN_SETTLE_S) st = pts(t0 + SPIN_SETTLE_S, t_end) u_tan, tan_len = (unit(xy[tan[-1]] - xy[i_c]) if len(tan) >= 2 else (None, 0.0)) u_set, set_len = (unit(xy[st[-1]] - xy[st[0]]) if len(st) >= 2 else (None, 0.0)) if (u_tan is None or tan_len < SPIN_MIN_CHORD_M or u_set is None or set_len < SPIN_MIN_CHORD_M): note("vertical", "không đủ track sau va chạm bi để đo đường " "cong trượt→lăn", t_s=t0) else: bend = ang(u_tan, u_pre) - ang(u_set, u_pre) cls = ("follow" if bend >= SPIN_BEND_MIN_DEG else "draw" if bend <= -SPIN_BEND_MIN_DEG else "stun") parts.append((cls, "low")) mo_ta = ("cong VỀ hướng tới cũ" if cls == "follow" else "cong RA XA hướng tới cũ" if cls == "draw" else "giữ nguyên tangent") note("vertical", f"cú cắt (Δθ {dth:.0f}°): sau pha trượt đường " f"đi {mo_ta} {bend:+.0f}° → {cls}", t_s=t0, deg=bend) elif has_drop: post = pts(t0, t_end) if len(post) < 2: note("vertical", "không đủ track sau va chạm bi", t_s=t0) else: d_net = xy[post[-1]] - xy[i_c] dist = float(np.hypot(d_net[0], d_net[1])) proj = float(np.dot(d_net, u_pre)) if dist < SPIN_STUN_MAX_M: parts.append(("stun", "medium")) note("vertical", f"cú chạm dày: cue dừng/trôi ngắn " f"{dist * 100:.0f}cm → stun", t_s=t0, m=dist) elif proj >= SPIN_DIR_MIN_M: parts.append(("follow", "medium")) note("vertical", f"cú chạm dày: cue tiếp tục TIẾN dọc hướng " f"cũ {proj:.2f}m → follow", t_s=t0, m=proj) elif proj <= -SPIN_DIR_MIN_M: parts.append(("draw", "medium")) note("vertical", f"cú chạm dày: cue LÙI ngược hướng cũ " f"{-proj:.2f}m → draw", t_s=t0, m=-proj) else: parts.append(("stun", "low")) note("vertical", f"cú chạm dày: cue trôi {dist:.2f}m gần " "vuông góc hướng cũ (tangent) → stun", t_s=t0, m=dist) else: note("vertical", f"góc cắt quá mỏng (Δθ {dth:.0f}° < " f"{SPIN_VERT_KINK_MIN_DEG:.0f}°) — xoáy dọc không lộ ra " "quỹ đạo, không đọc", t_s=t0, deg=dth) force_slow("vertical", n_vert, v_pre) # ---------------- trục NGANG: side-L/R ở va chạm BĂNG đầu tiên cc = next((c for c in collisions if c.get("contact") == "cushion"), None) if cc is None: note("lateral", "không có va chạm băng — không đọc được side spin") else: tc = float(cc["t_s"]) j = int(np.argmin(np.abs(t - tc))) k = collisions.index(cc) t_nx = (float(collisions[k + 1]["t_s"]) if k + 1 < len(collisions) else np.inf) d_rails = (cc["x_m"], TABLE_W_M - cc["x_m"], cc["y_m"], TABLE_L_M - cc["y_m"]) rail_name, n_in = _RAILS[int(np.argmin(d_rails))] n = np.asarray(n_in) t_hat = np.array([-n[1], n[0]]) # ẑ × n̂_in pre_c = pts(max(motion_start, tc - SPIN_PRE_S), tc) u_in, in_len = (unit(xy[j] - xy[pre_c[0]]) if len(pre_c) >= 2 else (None, 0.0)) v_in_c = (in_len / max(float(t[j] - t[pre_c[0]]), 1e-6) if len(pre_c) >= 2 else None) n_lat = len(parts) st = pts(tc + SPIN_SETTLE_S, min(tc + SPIN_WIN_S, t_nx)) u_out, out_len = (unit(xy[st[-1]] - xy[st[0]]) if len(st) >= 2 else (None, 0.0)) if (u_in is None or in_len < SPIN_MIN_CHORD_M or u_out is None or out_len < SPIN_MIN_CHORD_M): note("lateral", f"bật {rail_name}: không đủ track hai phía để " "so với phản xạ gương", t_s=tc) else: u_mir = u_in - 2.0 * float(np.dot(u_in, n)) * n dev = float(np.degrees(math.atan2( u_mir[0] * u_out[1] - u_mir[1] * u_out[0], float(np.dot(u_mir, u_out))))) if abs(dev) < SIDE_DEV_MIN_DEG: note("lateral", f"bật {rail_name}: lệch phản xạ gương " f"{dev:+.0f}° dưới ngưỡng {SIDE_DEV_MIN_DEG:.0f}° " "(bi lăn không side đã lệch ~15°) — không kết luận", t_s=tc, deg=dev) else: cls = ("side-R" if float(np.dot(u_out - u_mir, t_hat)) > 0 else "side-L") conf = "medium" if abs(dev) >= SIDE_DEV_MED_DEG else "low" parts.append((cls, conf)) note("lateral", f"bật {rail_name} lệch phản xạ gương " f"{dev:+.0f}° → {cls}", t_s=tc, deg=dev) force_slow("lateral", n_lat, v_in_c) if not parts: return None, None, ev spin_class = "+".join(p[0] for p in parts) conf = "low" if any(p[1] == "low" for p in parts) else "medium" return spin_class, conf, ev def analyze_track(rows: list[dict], others: list[dict] | None = None) -> dict: """Tầng giải tích: track cue ball (mọi frame, cờ covered) → số + cảnh báo. ``rows``: dict per frame — ``frame_file, t_s, covered, table_x_m, table_y_m, img_diff`` (str từ CSV hay số từ pipeline đều nhận). ``others``: detection KHÔNG-cue rời rạc ``{t_s, x_m, y_m}`` (mọi frame, không cần track) — chỉ dùng cửa sổ trước motion_start để dựng bi tĩnh cho phân loại ``contact``; None/[] thì va chạm giữa bàn thành "unknown" (đường CSV cũ không có detection bi khác vẫn chạy nguyên). Trả dict JSON-thuần: ``metrics`` (V0/φ/coverage/...), ``collisions``, ``spin_class``/``spin_confidence``/``spin_evidence`` (baseline spin thô — xem ``_read_spin``), ``track`` (điểm covered ĐÃ loại frame trùng — toạ độ THÔ, không smooth: smooth là việc của FE và CHỈ để vẽ), ``warnings`` (tiếng Việt, FE hiện thẳng). V0 = median tốc độ trong cửa sổ V0_WINDOW_S sau motion_start, CẮT tại va chạm đầu nếu nó tới sớm hơn (không trộn tốc độ trước/sau chạm). φ = hướng dây cung điểm đầu→cuối của chính cửa sổ đó (chord chịu nhiễu per-step tốt hơn heading từng bước). """ if not rows: raise ValueError("Track rỗng — không có frame nào để phân tích.") names = [str(r["frame_file"]) for r in rows] t_all = np.array([float(r["t_s"]) for r in rows]) covered = np.array([str(r.get("covered", "0")) in ("1", "True", "true") for r in rows]) diffs = [float(r.get("img_diff", -1.0)) for r in rows] dups = find_dup_frames(names, diffs) keep = [i for i in range(len(rows)) if covered[i] and names[i] not in dups] t = t_all[keep] xy = np.array([[float(rows[i]["table_x_m"]), float(rows[i]["table_y_m"])] for i in keep]) if keep else np.empty((0, 2)) # coverage theo THỜI LƯỢNG (số gate G0 — measure_p0, giữ nguyên cách đo) dt_med = float(np.median(np.diff(t_all))) if len(t_all) > 1 else 1 / 30 dur = float(t_all[-1] - t_all[0]) + dt_med dts = np.append(np.diff(t_all), dt_med) coverage = float(dts[covered].sum() / dur) if dur > 0 else 0.0 gaps = np.diff(t) if len(t) > 1 else np.array([]) max_gap = float(gaps.max()) if len(gaps) else 0.0 v0 = phi = motion_start = None n_v0_steps = 0 collisions: list[dict] = [] if len(t) >= 3: t_mid, speed, heading = _speed_series(t, xy) motion_start = _find_motion_start(t_mid, speed) if motion_start is not None: collisions = _find_collisions(t, xy, t_mid, speed, heading, motion_start) _classify_contacts(collisions, _static_balls(others or [], motion_start), t, xy) win_end = motion_start + V0_WINDOW_S if collisions: win_end = min(win_end, collisions[0]["t_s"]) in_win = (t_mid >= motion_start) & (t_mid <= win_end) n_v0_steps = int(in_win.sum()) if n_v0_steps: v0 = float(np.median(speed[in_win])) pts = (t >= motion_start - 1e-9) & (t <= win_end + 1e-9) if pts.sum() >= 2: p = xy[pts] d = p[-1] - p[0] if np.hypot(*d) > 1e-6: phi = float(np.degrees(np.arctan2(d[1], d[0])) % 360.0) # baseline spin thô (BRIEF 25 bước 3) — không va chạm/không motion thì # helper tự trả null + lý do (unidentifiable là đáp án, không phải lỗi) spin_class, spin_conf, spin_ev = _read_spin( t, xy, collisions, motion_start if motion_start is not None else 0.0) warnings: list[str] = [] if coverage < COVERAGE_WARN: warnings.append( f"Track chỉ phủ {coverage:.0%} thời lượng — thường do người che " f"cue ball; số đo vẫn dùng được nhưng đọc kèm dè dặt.") if max_gap > GAP_WARN_S: warnings.append(f"Track đứt dài nhất {max_gap:.2f}s — quỹ đạo đoạn " f"đó là nối thẳng, không phải đo.") if motion_start is None: warnings.append("Không thấy lúc cue ball bắt đầu chạy — clip có đúng " "1 cú trọn (mọi bi đứng yên → đánh → đứng yên)?") elif v0 is None: warnings.append("Không đủ frame sau lúc bắt đầu chạy để đo V0.") else: if n_v0_steps < 2: warnings.append("V0 ước lượng từ dưới 2 bước frame — kém tin.") if not (V0_SANE_MPS[0] <= v0 <= V0_SANE_MPS[1]): warnings.append(f"V0 = {v0:.2f} m/s ngoài khoảng tin được " f"{V0_SANE_MPS[0]:g}–{V0_SANE_MPS[1]:g} m/s.") return { "metrics": { "v0_mps": round(v0, 3) if v0 is not None else None, "phi_deg": round(phi, 1) if phi is not None else None, "motion_start_s": (round(motion_start - float(t_all[0]), 3) if motion_start is not None else None), "n_collisions": len(collisions), "coverage": round(coverage, 4), "max_gap_s": round(max_gap, 3), "n_frames": len(rows), "n_covered": int(covered.sum()), "n_dup_frames": len(dups), "duration_s": round(dur, 3), "table_w_m": TABLE_W_M, "table_l_m": TABLE_L_M, }, "collisions": collisions, # spin baseline (BRIEF 25): null = không đọc được — evidence nói vì # sao; KHÔNG bịa lớp khi thiếu chứng cứ (văn hoá number: null) "spin_class": spin_class, "spin_confidence": spin_conf, "spin_evidence": spin_ev, "track": [{"t_s": round(float(ti - t_all[0]), 3), "x_m": round(float(x), 4), "y_m": round(float(y), 4)} for ti, (x, y) in zip(t, xy)], "warnings": warnings, } def track_cue(meta: list[tuple[str, float]], dets_by_frame: dict, diffs: dict[str, float]) -> list[dict]: """NN + gating vận tốc trên det Cue — track_p0.track_cue, nhấc nguyên (chỉ đổi input: dict per-frame thay vì list det phẳng).""" rows = [] last_pos = None last_t = None for name, t in meta: cands = dets_by_frame.get(name, []) chosen = None if cands: if last_pos is None: chosen = max(cands, key=lambda d: d["conf"]) else: dt = max(t - last_t, 1e-3) gate = VMAX_MPS * dt + GATE_SLACK_M best = None for d in cands: dist = float(np.hypot(d["table_x_m"] - last_pos[0], d["table_y_m"] - last_pos[1])) if dist <= gate and (best is None or dist < best[0]): best = (dist, d) chosen = best[1] if best else None if chosen is not None: last_pos = (chosen["table_x_m"], chosen["table_y_m"]) last_t = t rows.append({ "frame_file": name, "t_s": t, "covered": 1 if chosen is not None else 0, "table_x_m": chosen["table_x_m"] if chosen else "", "table_y_m": chosen["table_y_m"] if chosen else "", "img_diff": round(diffs.get(name, -1.0), 4), }) return rows # --------------------------------------- front-end clip (cv2/YOLO — lười) ANALYZE_MAX_DUR_S = 30.0 # 1 cú trọn — quá là clip cắt sai (BRIEF bước 2.1) def analyze_clip(video_path: str | Path, corners_px, model, *, op_conf: float, imgsz: int = 640, device=None, progress=None) -> dict: """MỘT clip 1 cú → detect + track + số giải tích. Chạy phía cv-env. ``corners_px``: 4 pocket GÓC pixel trên frame clip, thứ tự quy ước ``table_corners()`` (2 điểm đầu = một băng ngắn); chấm ngược chiều tự đảo (``orient_corners``). ``model``: YOLO đã load (worker giữ, load một lần). ``progress(stage: str, frac: float)`` gọi mỗi batch — worker dùng để cập nhật status + beat heartbeat trong job dài. Decode TRONG BỘ NHỚ, không ghi frame ra đĩa (clip upload + frame đều KHÔNG được đọng lại — ràng buộc bản quyền BRIEF #7); timestamp là PTS container thật (CAP_PROP_POS_MSEC — nếp extract_shots), img_diff tính trên vùng bàn như track_p0.frame_diffs. Ném ValueError cho mọi lỗi thuộc về input (clip hỏng/quá dài/corners suy biến) — transport trả 422/error validation, nếp cv_worker. """ import cv2 corners = np.asarray(corners_px, dtype=np.float64) if corners.shape != (4, 2): raise ValueError("corners phải là 4 điểm [x, y] pixel — chấm đủ " "4 pocket góc.") th = TableHomography(orient_corners(corners), table_w=TABLE_W_M, table_l=TABLE_L_M) cap = cv2.VideoCapture(str(video_path)) if not cap.isOpened(): raise ValueError("Không mở được clip — cần MP4/MOV (H.264) đọc được.") try: fps = cap.get(cv2.CAP_PROP_FPS) or 0.0 n_est = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0.0 if fps > 0 and n_est > 0 and n_est / fps > ANALYZE_MAX_DUR_S + 0.5: raise ValueError(f"Clip dài {n_est / fps:.1f}s — giới hạn " f"{ANALYZE_MAX_DUR_S:g}s (đúng 1 cú trọn).") # bbox vùng bàn cho img_diff (frame_diffs của track_p0) ci = th.corners_px.astype(int) x0, x1 = ci[:, 0].min(), ci[:, 0].max() y0, y1 = ci[:, 1].min(), ci[:, 1].max() meta: list[tuple[str, float]] = [] diffs: dict[str, float] = {} dets_by_frame: dict[str, list[dict]] = {} first_frame_dets: list[dict] = [] other_dets: list[dict] = [] # detection KHÔNG-cue mọi frame — # nguồn bi tĩnh cho contact (bước 2) prev_crop = None t_first = None batch_imgs: list = [] batch_names: list[str] = [] total = int(n_est) if n_est > 0 else 0 def flush_batch() -> None: if not batch_imgs: return kw = {"device": device} if device is not None else {} results = model.predict(batch_imgs, conf=op_conf, imgsz=imgsz, verbose=False, **kw) for name, res in zip(batch_names, results): names_map = res.names for b in res.boxes: cls = names_map[int(b.cls)] if cls not in BALL_CLASSES: continue bx1, by1, bx2, by2 = (float(v) for v in b.xyxy[0]) cx, cy = (bx1 + bx2) / 2.0, (by1 + by2) / 2.0 tx, ty = th.px_to_table((cx, cy)) if not (-IN_TOL_M <= tx <= TABLE_W_M + IN_TOL_M and -IN_TOL_M <= ty <= TABLE_L_M + IN_TOL_M): continue d = {"cls": cls, "conf": float(b.conf), "table_x_m": float(tx), "table_y_m": float(ty)} if name == meta[0][0]: first_frame_dets.append(d) if cls == "Cue": dets_by_frame.setdefault(name, []).append(d) else: other_dets.append({"t_s": meta[int(name)][1], "x_m": d["table_x_m"], "y_m": d["table_y_m"]}) batch_imgs.clear() batch_names.clear() while True: ok = cap.grab() if not ok: break t = cap.get(cv2.CAP_PROP_POS_MSEC) / 1000.0 if t_first is None: t_first = t if t - t_first > ANALYZE_MAX_DUR_S + 0.5: raise ValueError(f"Clip dài quá {ANALYZE_MAX_DUR_S:g}s " f"(container khai sai số frame) — cắt lại " f"đúng 1 cú trọn.") ok, frame = cap.retrieve() if not ok: break name = f"{len(meta):05d}" crop = frame[max(0, y0):max(1, y1), max(0, x0):max(1, x1)] gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY).astype(np.int16) diffs[name] = (float(np.abs(gray - prev_crop).mean()) if prev_crop is not None else -1.0) prev_crop = gray meta.append((name, t)) batch_imgs.append(frame) batch_names.append(name) if len(batch_imgs) >= DETECT_BATCH: flush_batch() if progress is not None: frac = min(len(meta) / total, 1.0) if total else 0.0 progress("detect", 0.05 + 0.75 * frac) flush_batch() finally: cap.release() if len(meta) < 5: raise ValueError("Clip quá ngắn hoặc không đọc được frame nào — " "cần 1 cú trọn vài giây.") if progress is not None: progress("track", 0.85) rows = track_cue(meta, dets_by_frame, diffs) if progress is not None: progress("analytics", 0.95) out = analyze_track(rows, others=other_dets) # thế bi TĨNH frame đầu cho table view (dedup double-detect, nếp cv_worker) balls_init: list[dict] = [] for d in sorted(first_frame_dets, key=lambda d: -d["conf"]): if any(math.hypot(d["table_x_m"] - k["x_m"], d["table_y_m"] - k["y_m"]) < BALL_DEDUP_M for k in balls_init): continue balls_init.append({"x_m": round(d["table_x_m"], 4), "y_m": round(d["table_y_m"], 4), "type": "cue" if d["cls"] == "Cue" else "ball", "conf": round(d["conf"], 3)}) out["balls_init"] = balls_init return out