Spaces:
Sleeping
Sleeping
| """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 | |