Spaces:
Sleeping
Sleeping
| """CV worker — scan ảnh bàn ra thế bi, mode queue (bàn giao 18, 05/08/2026). | |
| Process TÁCH KHỎI API, chạy trên venv `poolcoach-cv-env` (CUDA) — app/API | |
| tuyệt đối không import torch/ultralytics (quyết định 2 venv, bàn giao 17). | |
| Load YOLO MỘT lần lúc boot + warmup 1 predict, xong hết mới bật heartbeat | |
| (cùng nếp engine_worker: không báo alive lúc còn đang khởi động). Vòng đời: | |
| BRPOP pc:jobs:scan → handle_scan → LPUSH pc:result:{job_id} (TTL dọn rác) | |
| Job payload: ``{"image_b64": <JPEG/PNG base64>, "corners": [[x,y]×4]}`` — | |
| corners là pixel trên ẢNH ĐÃ GỬI, thứ tự như ``table_corners()`` (đi vòng | |
| quanh bàn, 2 góc đầu là một BĂNG NGẮN: (0,0) → (W,0) → (W,L) → (0,L)). | |
| Đi vòng NGƯỢC chiều cũng được — ``orient_corners`` tự phát hiện và đảo | |
| (06/08), toạ độ bi ra y hệt chiều xuôi. | |
| Reply: ``{"balls": [{x, y, type, conf, ...}]}`` — toạ độ bàn hệ pooltool (m), | |
| ``type`` ∈ {"cue", "ball"}; class Dot (nút kim cương thành gỗ) bị LOẠI. | |
| Bi ``type="ball"`` mang thêm ``number`` (int 1–9 | None — BallID hai tầng | |
| theo MÀU, bàn giao 22), ``number_conf`` ∈ [0,1] và cờ ``wb`` (có chuẩn hoá | |
| trắng theo bi cue không); bi ``cue`` KHÔNG mang các trường này. Mở rộng | |
| thuần — client cũ bỏ qua trường lạ. | |
| Conf vận hành 0.2993 — argmax smooth-F1 any-ball trên val (scripts/cv/ | |
| pick_conf.py, 05/08, chuẩn đo trong opconf.json: F1 0.9322 · P 0.9642 · | |
| R 0.9023, VAL-pipeline; số này không so ngang với predict-pipeline đang chạy | |
| ở đây — hai cái thước, bàn giao 17). Override bằng env ``POOLCOACH_CV_CONF``. | |
| Heartbeat RIÊNG (pc:worker:heartbeat:scan) — chết/sống độc lập engine worker. | |
| Bàn giao 29 (12/08): khối ``shotnet`` mang thêm ``confidence_raw`` + | |
| ``det_density``, và ``confidence`` (hạng tin HIỂN THỊ) đi qua **van mật độ | |
| detection** — xem bảng hằng ``SHOTNET_DET_DENSITY_*``. Van CHỈ đổi hạng chữ | |
| và thêm một dòng ``warnings``: không đụng số model, không ẩn cột, không | |
| chặn inference. | |
| Bàn giao 31 (13/08): job analyze BÙ ĐỘ CAO TÂM BI cho mọi toạ độ map qua | |
| homography (``comp_homography_cls`` — camera decompose từ homography của | |
| chính clip, ``poolcoach_cv.camera``); kết quả mang cờ ``height_comp: | |
| {on, h_m, f_px, cx_m, cy_m}`` để phân biệt hai thời kỳ quy ước (cx/cy | |
| thêm ở BG32, cùng lúc siết biên sanity h ∈ [1.5, 6]m / f ∈ [800, 8000]px | |
| — ca rác cú 11 h=0.686/f=12767 từng lọt biên rộng BG31). Decompose thất | |
| bại / camera ngoài biên → không bù + cờ off (kèm warning). KHÔNG áp hồi | |
| tố track cũ; scan giữ nguyên quy ước cũ (chưa bù — ảnh scan không chắc | |
| cùng camera cao). | |
| Bàn giao 24 (11/08): worker này phục vụ THÊM queue ``pc:jobs:analyze`` | |
| (Broadcast Analyzer — clip 1 cú → track + số giải tích, module | |
| ``poolcoach_cv.broadcast``). Mở rộng thuần: handler scan + contract | |
| /api/scan không đổi một byte. Job analyze DÀI (cỡ phút) nên khác giao thức: | |
| kết quả/tiến độ ghi vào status key (``jobqueue.set_analyze_status``), API | |
| poll — không ai BLPOP reply; heartbeat được beat cả TRONG lúc chạy job | |
| (progress callback) để scan/analyze không fast-fail oan trong lúc worker | |
| bận. Trong lúc một analyze chạy, scan job phải chờ — chấp nhận, demo local | |
| một người dùng (BRIEF #1: mọi trade-off nghiêng về đơn giản). | |
| Lát A1 (13/08): worker phục vụ THÊM queue ``pc:jobs:segment`` (Analyzer đa | |
| cú — video dài → ``poolcoach_cv.segmentation`` cắt danh sách cú → manifest | |
| + thumbnail + clip per cú ghi FILE ngay → enqueue N job analyze NGUYÊN | |
| TRẠNG, chỉ thêm ``result_path`` để kết quả per cú cũng ra file — nếp | |
| BG29b/BG31: Redis chỉ là kênh tiến độ, đĩa mới là nguồn sự thật). Phần SỐ | |
| của scan/analyze (weights c4b, conf 0.2993, imgsz 640, van tin cậy) không | |
| đổi một byte. | |
| Chạy tay (Redis: docker compose -f docker-compose.dev.yml up -d redis): | |
| set REDIS_URL=redis://localhost:6379/0 | |
| D:\\Khoa luan\\poolcoach-cv-env\\Scripts\\python.exe scripts\\cv_worker.py | |
| Launcher: D:\\Khoa luan\\run_cv_worker.bat (ngoài repo, như mọi launcher). | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import binascii | |
| import math | |
| import os | |
| import sys | |
| import time | |
| from pathlib import Path | |
| # Repo KHÔNG cài package: src/ cho poolcoach_cv, ROOT cho app.* (quy ước | |
| # scripts/ — xem CLAUDE.md). KHÔNG import poolcoach_rl: venv CV không có | |
| # pooltool, và worker này không cần vật lý — chỉ cần homography thuần numpy. | |
| ROOT = Path(__file__).resolve().parents[1] | |
| for _p in (ROOT / "src", ROOT): | |
| if str(_p) not in sys.path: | |
| sys.path.insert(0, str(_p)) | |
| import numpy as np # noqa: E402 | |
| from app import analyzer_store as astore # noqa: E402 — thuần stdlib | |
| from app import jobqueue # noqa: E402 — chỉ kéo redis, không FastAPI/torch | |
| from poolcoach_cv.ballid import identify_balls # noqa: E402 — thuần numpy | |
| from poolcoach_cv.camera import HeightCompensation # noqa: E402 — thuần numpy | |
| from poolcoach_cv.homography import (TABLE_L, TABLE_W, # noqa: E402 | |
| TableHomography, orient_corners) | |
| DEFAULT_WEIGHTS = r"D:\Khoa luan\cv_full_20260805\best.pt" | |
| # ---- ShotNet (BG28): model suy ngược (V0 gậy, phi, a, b) chạy KÈM analytic | |
| # trong job analyze — hai phương pháp hiện song song (BRIEF 28 bối cảnh 1). | |
| DEFAULT_SHOTNET = str(ROOT / "models" / "shotnet_20260812_c4b" / "best.pt") | |
| # checkpoint c4b (augmentation BG29) — override bằng | |
| # env POOLCOACH_SHOTNET_WEIGHTS; thiếu file → analyze | |
| # vẫn chạy, chỉ không có khối shotnet (kèm warning rõ) | |
| # | |
| # Gate P2′ BG29 vẫn = ĐỎ theo luật "đạt bar VÀ hơn | |
| # c3" (3/4 ô dưới bar tuyệt đối — kết quả đứng | |
| # nguyên, không chấm lại). App đổi sang c4b theo | |
| # QUYẾT ĐỊNH COWORK 13/08/2026, không phải vì gate | |
| # xanh: c4b hơn c3 cả 4 ô trên thước chính thức | |
| # (lát ≥0.3s, ghép cặp 669 cú: 2.24°/8.56%/74.3%/ | |
| # 72.9% vs 3.03/9.73/69.7/65.3, CI95 dphi/V0 không | |
| # chứa 0), và không thụt lùi trên 5k đầy đủ. | |
| SHOTNET_V0_SCALE_REAL = 2.54 / 1.9812 | |
| # quy đổi V0 model → m/s bàn THẬT (≈1.282). Thuần | |
| # hình học, không fit: net train trên bàn pooltool | |
| # (L=1.9812m), input thật chuẩn hoá đẳng hướng theo | |
| # bàn giải (L=2.54m, cùng tỷ lệ 2:1) — thế giới net | |
| # thấy là bản thu 0.78× của bàn thật với cùng | |
| # timestamp, nên V0 nó đọc ra là m/s của thế giới | |
| # thu; nhân L_thật/L_synth trả về m/s thật. φ/a/b | |
| # bất biến qua phép đồng dạng — không đổi gì. | |
| SHOTNET_CONF_HIGH = 0.8 # p_ident ≥ → hạng tin "high" cho spin model | |
| SHOTNET_CONF_MED = 0.5 # p_ident ≥ → "medium"; dưới → "low" (spin chỉ lộ | |
| # qua va chạm — p_ident thấp nghĩa là net tự khai | |
| # không thấy va chạm để đọc spin) | |
| # ---- Van hiển thị tin cậy theo MẬT ĐỘ DETECTION bi mục tiêu (BG29 bước 3) | |
| # Bài học BG28: p_ident = 1.0 / conf "high" trên CẢ HAI cú thật lệch nặng | |
| # (cú 12 φ lệch 84.9°) — head tin cậy KHÔNG tự khai được input lạ, vì nó | |
| # học "cú này có va chạm không", không phải "input này có giống lúc train | |
| # không". Van thứ hai đo đại lượng NGOÀI model: mật độ detection bi mục | |
| # tiêu của chính clip (det không-cue/frame — đúng đại lượng đã đo được ở | |
| # BG28: cú 11 = 1.01, cú 12 = 3.09). | |
| # | |
| # Ngưỡng lấy từ phân phối TRAIN đo được (shard 0, 2000 cú synth): trung | |
| # bình 5.28 det không-cue/frame, p10 = 2.00, p90 = 8.21. Dưới p10 nghĩa là | |
| # clip thưa hơn 90% dữ liệu net từng thấy lúc đo số val — hạ MỘT bậc; thưa | |
| # hơn nữa (< 1.5) thì bản thân bài toán mất thông tin, hạ HAI bậc. | |
| # Augmentation BG29 mở rộng vùng net CHỊU ĐƯỢC, nhưng không tạo lại thông | |
| # tin đã mất — nên van giữ nguyên ngưỡng theo phân phối gốc. | |
| SHOTNET_DET_DENSITY_MED = 2.0 # < → hạ 1 bậc hạng tin HIỂN THỊ | |
| SHOTNET_DET_DENSITY_LOW = 1.5 # < → hạ 2 bậc | |
| SHOTNET_CONF_RANKS = ("low", "medium", "high") | |
| def density_confidence(rank: str, density: float) -> str: | |
| """Hạ cấp hạng tin HIỂN THỊ theo mật độ detection. CHỈ đổi hạng chữ — | |
| không đụng một số nào của model, không ẩn cột, không chặn inference | |
| (BRIEF 29 bước 3.1).""" | |
| if density < SHOTNET_DET_DENSITY_LOW: | |
| step = 2 | |
| elif density < SHOTNET_DET_DENSITY_MED: | |
| step = 1 | |
| else: | |
| return rank | |
| if rank not in SHOTNET_CONF_RANKS: | |
| return rank | |
| return SHOTNET_CONF_RANKS[max(SHOTNET_CONF_RANKS.index(rank) - step, 0)] | |
| _CONF_VN = {"low": "thấp", "medium": "vừa", "high": "cao"} | |
| def density_warning(blk: dict) -> str | None: | |
| """Dòng cảnh báo tiếng Việt khi van đã hạ cấp — None nếu không hạ. | |
| Server soạn sẵn (nếp ``spin_evidence[].text``): FE nối hiện thẳng.""" | |
| raw, shown = blk.get("confidence_raw"), blk.get("confidence") | |
| if not raw or raw == shown: | |
| return None | |
| return (f"Mật độ detection bi mục tiêu chỉ {blk['det_density']:.2f}/frame " | |
| f"— thưa hơn vùng dữ liệu net được huấn luyện " | |
| f"(p10 ≈ {SHOTNET_DET_DENSITY_MED:g}/frame, trung bình 5.3). " | |
| f"Hạng tin cậy HIỂN THỊ của khối model đã hạ từ " | |
| f"\"{_CONF_VN.get(raw, raw)}\" xuống " | |
| f"\"{_CONF_VN.get(shown, shown)}\"; số model giữ nguyên, đọc " | |
| f"kèm dè dặt.") | |
| OP_CONF = 0.2993 # pick_conf 05/08 (opconf.json) — xem docstring; | |
| # env POOLCOACH_CV_CONF. 0.299 cũ là bake lệch (06/08) | |
| IMGSZ = 640 # cùng imgsz train/val | |
| MAX_DIM = 1280 # phòng hờ: FE đã resize trước khi gửi, nhưng client lạ | |
| # gửi ảnh gốc thì worker tự thu về ~2x imgsz cho đủ nét | |
| BALL_CLASSES = {"Black", "Cue", "Solid", "Striped"} # Dot loại — không phải bi | |
| OUT_TOL_M = 0.03 # tâm chiếu lệch ra ngoài bàn ≤ 3cm → kẹp vào mép (bi | |
| # sát băng lệch ~9mm là hạn chế đã đo, bàn giao 16); | |
| # lệch hơn → loại (detect ngoài mặt bàn: người, băng gỗ) | |
| DEDUP_DIST_M = 0.03 # 2 tâm cách < ~1R (bi r=0.0286m) = cùng một bi bị double | |
| # -detect → giữ conf cao hơn; 2 bi thật chạm nhau cách 2R | |
| def scan_once(payload: dict, model, conf: float, predict_kw: dict) -> dict: | |
| """MỘT job scan: decode → (resize) → YOLO → tâm bbox → homography → balls. | |
| Ném ValueError cho MỌI lỗi thuộc về input (thiếu trường, base64 hỏng, | |
| ảnh không đọc được, corners suy biến — TableHomography tự ném) — phía | |
| API trả 422 message nguyên văn, cùng giao thức với engine worker. | |
| """ | |
| image_b64 = payload.get("image_b64") | |
| corners = payload.get("corners") | |
| if not isinstance(image_b64, str) or not image_b64: | |
| raise ValueError("Thiếu image_b64 (ảnh JPEG/PNG mã hoá base64).") | |
| corners = np.asarray(corners, dtype=np.float64) if corners is not None else None | |
| if corners is None or corners.shape != (4, 2): | |
| raise ValueError("corners phải là 4 điểm [x, y] pixel — chấm đủ 4 góc bàn.") | |
| try: | |
| raw = base64.b64decode(image_b64, validate=True) | |
| except binascii.Error as e: | |
| raise ValueError(f"image_b64 không phải base64 hợp lệ ({e}).") from e | |
| import cv2 | |
| img = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) | |
| if img is None: | |
| raise ValueError("Không giải mã được ảnh — cần JPEG hoặc PNG.") | |
| h, w = img.shape[:2] | |
| scale = 1.0 | |
| if max(h, w) > MAX_DIM: | |
| scale = MAX_DIM / max(h, w) | |
| img = cv2.resize(img, (round(w * scale), round(h * scale)), | |
| interpolation=cv2.INTER_AREA) | |
| # Chấm 4 góc ngược chiều → tự đảo cho hết soi gương (06/08) — lỗi người | |
| # dùng duy nhất sửa được bằng toán; toạ độ bi ra y hệt lần chấm xuôi. | |
| corners = orient_corners(corners) | |
| th = TableHomography(corners) # suy biến → ValueError tiếng Việt sẵn | |
| res = model.predict(img, conf=conf, imgsz=IMGSZ, verbose=False, | |
| **predict_kw)[0] | |
| names = res.names | |
| cands = [] | |
| n_dot = n_out = n_dup = 0 | |
| for b in res.boxes: | |
| cls = names[int(b.cls)] | |
| if cls not in BALL_CLASSES: | |
| n_dot += 1 | |
| continue | |
| x1, y1, x2, y2 = (float(v) for v in b.xyxy[0]) | |
| # tâm bbox về pixel ảnh GỐC (corners đo trên ảnh client gửi, chưa resize) | |
| cx, cy = (x1 + x2) / 2.0 / scale, (y1 + y2) / 2.0 / scale | |
| tx, ty = th.px_to_table((cx, cy)) | |
| if not (-OUT_TOL_M <= tx <= TABLE_W + OUT_TOL_M | |
| and -OUT_TOL_M <= ty <= TABLE_L + OUT_TOL_M): | |
| n_out += 1 | |
| continue | |
| # "box" = bbox pixel trên `img` (ảnh ĐÃ resize — BallID crop từ chính | |
| # ảnh predict, không quy đổi scale); trường nội bộ, pop trước reply | |
| cands.append({"x": min(max(float(tx), 0.0), TABLE_W), | |
| "y": min(max(float(ty), 0.0), TABLE_L), | |
| "type": "cue" if cls == "Cue" else "ball", | |
| "conf": float(b.conf), | |
| "box": (x1, y1, x2, y2)}) | |
| # dedupe double-detect (một bi ra 2 box khác class): duyệt conf giảm dần, | |
| # tâm nào dính tâm đã giữ trong < DEDUP_DIST_M thì bỏ | |
| kept: list[dict] = [] | |
| for c in sorted(cands, key=lambda c: -c["conf"]): | |
| if any(math.hypot(c["x"] - k["x"], c["y"] - k["y"]) < DEDUP_DIST_M | |
| for k in kept): | |
| n_dup += 1 | |
| continue | |
| kept.append(c) | |
| # cue ĐÚNG 1: giữ cue conf cao nhất (kept đang xếp theo conf giảm dần), | |
| # các "Cue" còn lại hạ xuống ball — bi trắng thứ hai gần như chắc là detect | |
| # nhầm loại, người chơi sửa tay ở van an toàn §5.2 | |
| seen_cue = False | |
| for c in kept: | |
| if c["type"] == "cue": | |
| if seen_cue: | |
| c["type"] = "ball" | |
| seen_cue = True | |
| balls = ([c for c in kept if c["type"] == "cue"] | |
| + [c for c in kept if c["type"] != "cue"]) | |
| # BallID hai tầng (bàn giao 22): gán SỐ 1–9 theo màu cho bi thường; WB | |
| # theo crop bi cue ĐÃ CHỐT (sau dedupe + hạ cue thừa). Bi cue không mang | |
| # number; number=None khi màu không khớp số nào dưới ngưỡng. | |
| others = [c for c in balls if c["type"] == "ball"] | |
| if others: | |
| cue = next((c for c in balls if c["type"] == "cue"), None) | |
| assigns, wb = identify_balls(img, [c["box"] for c in others], | |
| cue["box"] if cue else None) | |
| for c, (num, num_conf) in zip(others, assigns): | |
| c["number"] = num | |
| c["number_conf"] = num_conf | |
| c["wb"] = wb | |
| for c in balls: | |
| c.pop("box", None) | |
| c["x"], c["y"] = round(c["x"], 4), round(c["y"], 4) | |
| c["conf"] = round(c["conf"], 3) | |
| return {"balls": balls, | |
| "dropped": {"dot": n_dot, "ngoai_ban": n_out, "trung_tam": n_dup}} | |
| def handle_scan(payload: dict, model, conf: float, predict_kw: dict) -> dict: | |
| """Reply protocol của transport — CÙNG shape với engine.handle_job: | |
| ok/result | error "validation" (422) | error "internal" (500). Worker | |
| KHÔNG được chết vì một job hỏng.""" | |
| try: | |
| return {"ok": True, | |
| "result": scan_once(payload, model, conf, predict_kw)} | |
| except ValueError as e: | |
| return {"ok": False, "error": "validation", "message": str(e)} | |
| except Exception as e: # noqa: BLE001 — job hỏng ≠ worker chết | |
| return {"ok": False, "error": "internal", | |
| "message": f"{type(e).__name__}: {e}"} | |
| # ------------------------------------------- analyze clip (bàn giao 24) | |
| def not_shot_flag(result: dict) -> dict: | |
| """Van "nghi không phải cú đánh" (lát A2 phần 1, quyết định Cowork | |
| 13/08 — hướng (a) HANDOFF A1 câu 1): cờ HIỂN THỊ dựa hoàn toàn trên | |
| 3 tín hiệu cú TỰ KHAI đã có trong result — KHÔNG tín hiệu mới, KHÔNG | |
| đụng segmentation, KHÔNG xoá/lọc cú: | |
| 1. V0 giải tích ngoài khoảng tin ``bc.V0_SANE_MPS`` [1, 8] m/s; | |
| 2. không tìm được motion_start; | |
| 3. coverage < ``bc.COVERAGE_WARN`` (0.8 — mốc P0). | |
| Ba cú ma rack_a1 (idx 7/8/9) tự khai đúng các tín hiệu này (v0 0.27 / | |
| None / 0.99 m/s, coverage 45.5/38.9/60%), còn 11 cú thật sạch cả ba | |
| (coverage thấp nhất 92.6%) — gate A2.1: bắt 3/3 ma, 0/11 oan. | |
| V0 = None mà motion_start CÓ (hiếm: "không đủ frame sau lúc bắt đầu | |
| chạy") thì tín hiệu 1 KHÔNG nổ — BRIEF chốt đúng 3 tín hiệu, "ngoài | |
| khoảng" chỉ xét khi có số; ca đó thường dính tín hiệu 3 nếu thật sự | |
| là rác. Reasons tiếng Việt server soạn sẵn (nếp spin_evidence[].text). | |
| """ | |
| from poolcoach_cv import broadcast as bc | |
| m = result.get("metrics") or {} | |
| reasons: list[str] = [] | |
| v0 = m.get("v0_mps") | |
| if v0 is not None and not (bc.V0_SANE_MPS[0] <= v0 <= bc.V0_SANE_MPS[1]): | |
| reasons.append(f"V0 giải tích {v0:.2f} m/s ngoài khoảng tin " | |
| f"[{bc.V0_SANE_MPS[0]:g}, {bc.V0_SANE_MPS[1]:g}] m/s") | |
| if m.get("motion_start_s") is None: | |
| reasons.append("không tìm được lúc cue ball bắt đầu chạy " | |
| "(motion_start)") | |
| cov = m.get("coverage") | |
| if cov is not None and cov < bc.COVERAGE_WARN: | |
| reasons.append(f"track chỉ phủ {cov:.0%} thời lượng " | |
| f"(< {bc.COVERAGE_WARN:.0%})") | |
| return {"flagged": bool(reasons), "reasons": reasons} | |
| def load_shotnet(device: str): | |
| """Load ShotNet MỘT lần lúc boot (nếp giữ YOLO ấm — BRIEF 28 bối cảnh | |
| 6). Trả dict {model, cfg, tag, device} hoặc None (thiếu file/lỗi load — | |
| analyze vẫn sống, khối shotnet vắng kèm warning per-job).""" | |
| import torch | |
| from poolcoach_cv.shotnet import ShotNet, ShotNetConfig | |
| path = Path(os.environ.get("POOLCOACH_SHOTNET_WEIGHTS", DEFAULT_SHOTNET)) | |
| if not path.exists(): | |
| print(f"[cv_worker] khong thay ShotNet {path} -- analyze chay " | |
| f"KHONG co khoi shotnet (set POOLCOACH_SHOTNET_WEIGHTS neu " | |
| f"model nam cho khac)", flush=True) | |
| return None | |
| dev = device if device else ("cuda" if torch.cuda.is_available() | |
| else "cpu") | |
| if dev.strip().isdigit(): # nếp YOLO: device="0" nghĩa là cuda:0 | |
| dev = f"cuda:{dev.strip()}" | |
| ck = torch.load(path, map_location=dev) | |
| cfg = ShotNetConfig(**ck["model_config"]) | |
| net = ShotNet(cfg).to(dev) | |
| net.load_state_dict(ck["state_dict"]) | |
| net.eval() | |
| return {"model": net, "cfg": cfg, "tag": path.parent.name, | |
| "device": dev} | |
| def load_resim(): | |
| """Load + JIT pooltool cho resim MỘT lần lúc boot (lát A2 phần 2) — | |
| nếp load_shotnet: thiếu/hỏng → None, analyze vẫn sống, khối resim vắng | |
| kèm warning per-job. JIT nguội 40–90s nên phải nằm TRƯỚC heartbeat | |
| (nếp engine_worker: không báo alive lúc còn đang JIT).""" | |
| try: | |
| from poolcoach_cv import resim as rs | |
| except Exception as e: # noqa: BLE001 | |
| print(f"[cv_worker] khong import duoc poolcoach_cv.resim " | |
| f"({type(e).__name__}: {e}) -- resim TAT", flush=True) | |
| return None | |
| state = rs.warmup() | |
| if state is None: | |
| return None | |
| return {"module": rs, "state": state} | |
| def shotnet_infer(sn: dict, rows: list[dict], others: list[dict]) -> dict: | |
| """Track/detections app → khối ``shotnet`` cho JSON kết quả. Feature | |
| dựng bằng ĐÚNG code loader (shot_from_track + featurize_shot — một | |
| nguồn sự thật, BRIEF 28 bước 2.1); bàn thật truyền vào featurize qua | |
| (w, l) của broadcast.""" | |
| import torch | |
| from poolcoach_cv import broadcast as bc | |
| from poolcoach_cv.shotnet import featurize_shot, shot_from_track, \ | |
| spin_classes | |
| t0 = time.perf_counter() | |
| shot = shot_from_track(rows, others) | |
| feats, t = featurize_shot(shot, w=bc.TABLE_W_M, l=bc.TABLE_L_M, | |
| deltas=sn["cfg"].use_deltas) | |
| dev = sn["device"] | |
| x = torch.from_numpy(feats)[None].to(dev) | |
| tt = torch.from_numpy(t)[None].to(dev) | |
| mask = torch.ones(1, len(t), dtype=torch.bool, device=dev) | |
| p = sn["model"].predict(x, tt, mask) | |
| a, b = float(p["a"][0]), float(p["b"][0]) | |
| vert, _side = spin_classes(np.array([a]), np.array([b])) | |
| p_ident = float(p["p_ident"][0]) | |
| conf_raw = ("high" if p_ident >= SHOTNET_CONF_HIGH else | |
| "medium" if p_ident >= SHOTNET_CONF_MED else "low") | |
| # mật độ detection bi mục tiêu của CHÍNH clip này (det không-cue/frame) | |
| # — đại lượng ngoài model, đo trên đúng input đã đưa vào net | |
| density = len(others) / max(len(rows), 1) | |
| conf = density_confidence(conf_raw, density) | |
| return { | |
| "model": sn["tag"], | |
| # V0 GẬY (thước label/net — HANDOFF 26b), quy đổi hình học về m/s | |
| # bàn thật; KHÁC thước metrics.v0_mps (tốc độ BI đo từ track) | |
| "v0_cue_mps": round(float(p["v0"][0]) * SHOTNET_V0_SCALE_REAL, 3), | |
| "phi_deg": round(float(p["phi_deg"][0]), 1), | |
| "a": round(a, 3), "b": round(b, 3), | |
| # cùng mapping harness BG27: side = dấu a thuần (không dead-zone), | |
| # vert = 3 lớp quantize theo B_STUN_MAX (spin_classes) | |
| "spin_vert": str(vert[0]), | |
| "spin_side": "side-L" if a > 0 else "side-R", | |
| "identifiable_prob": round(p_ident, 3), | |
| # `confidence` = hạng HIỂN THỊ (đã qua van mật độ); `confidence_raw` | |
| # = hạng thô từ head identifiable — giữ cả hai để không mất số nào | |
| "confidence": conf, | |
| "confidence_raw": conf_raw, | |
| "det_density": round(density, 3), | |
| "inference_ms": round((time.perf_counter() - t0) * 1000, 1), | |
| "n_target_slots": int(shot["n_balls"]) - 1, | |
| } | |
| def comp_homography_cls(image_wh: tuple[float, float], ball_r: float, | |
| sink: dict): | |
| """Subclass TableHomography có bù độ cao tâm bi (BG31 bước 1.2). | |
| ``px_to_table`` áp bù k = R/(h−R) theo hướng camera→điểm, camera từ | |
| decompose homography CỦA CHÍNH CLIP (không hằng chung). Trong | |
| ``analyze_clip`` MỌI toạ độ map (track cue, detection không-cue, | |
| balls_init frame đầu) đều đi qua ``px_to_table`` nên bù phủ MỌI vị | |
| trí map, không riêng gần băng. Decompose thất bại/camera vô lý → | |
| HeightCompensation tự về identity (không bù — thà không bù còn hơn | |
| bù bằng camera rác). ``sink`` nhận meta ``height_comp`` khi | |
| homography được dựng — worker ghi vào JSON job (bước 1.4). | |
| Patch vào ``bc.TableHomography`` quanh MỘT lần gọi analyze_clip — | |
| nếp "script-level adapter, KHÔNG sửa broadcast.py" (BG26/BG28); | |
| worker phục vụ job tuần tự nên không có race. | |
| """ | |
| class _CompHomography(TableHomography): | |
| def __init__(self, corners_px, **kw) -> None: | |
| super().__init__(corners_px, **kw) | |
| self._comp = HeightCompensation.from_homography( | |
| self.H, image_wh, ball_r) | |
| sink.clear() | |
| sink.update(self._comp.meta()) | |
| def px_to_table(self, pts_px): | |
| return self._comp.apply(super().px_to_table(pts_px)) | |
| return _CompHomography | |
| def clip_frame_wh(clip_path) -> tuple[float, float]: | |
| """(rộng, cao) pixel của clip — principal point danh định cho decompose | |
| (giữa ảnh, quy ước BG30). Không đọc được → (0, 0): bộ bù tự off, còn | |
| clip hỏng thật thì analyze_clip ném ValueError sau đó với message sẵn.""" | |
| import cv2 | |
| cap = cv2.VideoCapture(str(clip_path)) | |
| try: | |
| return (cap.get(cv2.CAP_PROP_FRAME_WIDTH), | |
| cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) | |
| finally: | |
| cap.release() | |
| def match_ballid_to_init(balls_init: list[dict], numbered: list[tuple], | |
| tol_m: float) -> int: | |
| """Gắn (number, number_conf) vào entry ``balls_init`` GẦN NHẤT trong | |
| ``tol_m`` — thuần số học, tách riêng cho unit test (venv app, không cv2). | |
| ``numbered``: [(tx, ty, number, number_conf), ...] toạ độ bàn của bbox | |
| frame đầu, ĐÃ qua cùng homography với balls_init nên khớp gần-đúng-điểm. | |
| Ưu tiên conf cao gắn trước; entry đã có số không bị đè (hai bbox sát | |
| nhau tranh một entry thì bản conf cao thắng). Trả số entry được gắn.""" | |
| import math | |
| n = 0 | |
| for tx, ty, num, nc in sorted(numbered, key=lambda q: -q[3]): | |
| if num is None: | |
| continue | |
| best, bd = None, float("inf") | |
| for entry in balls_init: | |
| if entry.get("type") != "ball" or "number" in entry: | |
| continue | |
| d = math.hypot(entry["x_m"] - tx, entry["y_m"] - ty) | |
| if d < bd: | |
| best, bd = entry, d | |
| if best is not None and bd <= tol_m: | |
| best["number"] = int(num) | |
| best["number_conf"] = round(float(nc), 3) | |
| n += 1 | |
| return n | |
| def ballid_balls_init(clip_path, corners_px, result: dict, | |
| first_dets: list) -> str | None: | |
| """Gắn số bi 1–9 vào ``balls_init`` (A2b phần 4) — CÙNG nguồn màu với | |
| overlay.mp4: ``identify_balls`` (BallID hai tầng theo màu, bàn giao 22) | |
| trên FRAME ĐẦU + đúng bbox mà tầng số đã dùng (RecordingModel), tâm | |
| bbox map qua CÙNG phép homography bù độ cao của job (dựng lại | |
| ``comp_homography_cls`` — decompose tất định nên cùng input là cùng | |
| phép map). Màu không nhận ra / kết quả cũ → entry không có trường | |
| ``number``, FE vẽ bi xám như trước (không bịa số). | |
| Trả None khi trơn, message warning khi có chuyện — kết quả SỐ không | |
| bị đụng trong mọi trường hợp (nếp overlay: hỏng ≠ mất số đo).""" | |
| import cv2 | |
| from poolcoach_cv import broadcast as bc | |
| balls_init = result.get("balls_init") or [] | |
| balls = [(cls, conf, box) for cls, conf, box in first_dets | |
| if cls in bc.BALL_CLASSES] | |
| others = [b for b in balls if b[0] != "Cue"] | |
| if not balls_init or not others: | |
| return None | |
| cap = cv2.VideoCapture(str(clip_path)) | |
| try: | |
| ok, frame = cap.read() | |
| finally: | |
| cap.release() | |
| if not ok: | |
| return ("Không đọc lại được frame đầu để gắn số bi — bi trên bàn " | |
| "metric hiện xám.") | |
| cue = next((b for b in balls if b[0] == "Cue"), None) | |
| assigns, _wb = identify_balls(frame, [b[2] for b in others], | |
| cue[2] if cue else None) | |
| th_cls = comp_homography_cls(clip_frame_wh(clip_path), bc.BALL_R_M, {}) | |
| th = th_cls(orient_corners(np.asarray(corners_px, dtype=np.float64)), | |
| table_w=bc.TABLE_W_M, table_l=bc.TABLE_L_M) | |
| numbered = [] | |
| for (_cls, _conf, (x1, y1, x2, y2)), (num, nc) in zip(others, assigns): | |
| tx, ty = th.px_to_table(((x1 + x2) / 2.0, (y1 + y2) / 2.0)) | |
| numbered.append((float(tx), float(ty), num, float(nc or 0.0))) | |
| match_ballid_to_init(balls_init, numbered, bc.BALL_DEDUP_M) | |
| return None | |
| def analyze_once(payload: dict, model, conf: float, predict_kw: dict, | |
| status, shotnet: dict | None = None, | |
| resim: dict | None = None) -> dict: | |
| """MỘT job analyze: đọc clip từ đường dẫn tạm → poolcoach_cv.broadcast, | |
| rồi ShotNet (BG28) trên cùng track/detections — kết quả có THÊM khối | |
| ``shotnet`` (mở rộng thuần; ``shotnet=None`` → chỉ analytic + warning). | |
| Ném ValueError cho lỗi thuộc về input (clip mất/hỏng/quá dài, corners | |
| suy biến) — cùng phân loại với scan_once. `status(dict)` cập nhật tiến | |
| độ; progress callback đồng thời BEAT heartbeat vì job dài hơn TTL 15s | |
| nhiều lần — không beat là /api/scan lẫn /api/analyze fast-fail oan. | |
| """ | |
| clip_path = payload.get("clip_path") | |
| if not clip_path or not Path(clip_path).exists(): | |
| raise ValueError("Clip không còn trong thư mục tạm — upload lại " | |
| "(file tạm sống theo phiên, không lưu lâu).") | |
| from poolcoach_cv import broadcast as bc | |
| def progress(stage: str, frac: float) -> None: | |
| jobqueue.beat(key=jobqueue.SCAN_HEARTBEAT_KEY) | |
| status({"status": "running", "progress": round(min(frac, 0.99), 3), | |
| "stage": stage}) | |
| # ShotNet (BG28) cần đúng input của analyze_track (rows per-frame + | |
| # detections KHÔNG-cue) mà analyze_clip không trả ra ngoài — bắt bằng | |
| # wrapper mức module quanh MỘT lần gọi (adapter phía worker, nếp | |
| # "script-level adapter, KHÔNG sửa broadcast.py" của eval harness BG26; | |
| # worker phục vụ job tuần tự nên không có race). BG31 patch thêm | |
| # TableHomography cùng nếp: px_to_table áp bù độ cao tâm bi cho MỌI vị | |
| # trí map (comp_homography_cls) — ShotNet vì thế cũng ăn track ĐÃ bù. | |
| captured: dict = {} | |
| comp_meta: dict = {} | |
| orig_analyze_track = bc.analyze_track | |
| orig_homography = bc.TableHomography | |
| def _capture(rows, others=None): | |
| captured["rows"], captured["others"] = rows, others | |
| return orig_analyze_track(rows, others=others) | |
| # overlay per cú (lát A2 phần 3): bọc model để GHI LẠI đúng bbox mà | |
| # tầng số dùng — render không detect lại (cùng nếp adapter quanh một | |
| # lần gọi). Chỉ luồng video đa cú gửi overlay_path; /api/analyze cũ | |
| # không gửi → không đổi một byte hành vi. | |
| overlay_path = payload.get("overlay_path") | |
| rec = None | |
| if overlay_path: | |
| from poolcoach_cv.overlay import RecordingModel | |
| rec = RecordingModel(model) | |
| t0 = time.perf_counter() | |
| bc.analyze_track = _capture | |
| bc.TableHomography = comp_homography_cls(clip_frame_wh(clip_path), | |
| bc.BALL_R_M, comp_meta) | |
| try: | |
| result = bc.analyze_clip(clip_path, payload.get("corners"), | |
| rec if rec is not None else model, | |
| op_conf=conf, progress=progress, | |
| device=predict_kw.get("device")) | |
| finally: | |
| bc.analyze_track = orig_analyze_track | |
| bc.TableHomography = orig_homography | |
| result["metrics"]["elapsed_s"] = round(time.perf_counter() - t0, 2) | |
| # cờ quy ước toạ độ (BG31 bước 1.4): track sinh ra ĐÃ/CHƯA bù độ cao — | |
| # đọc lại JSON là biết thời kỳ; số cũ (BG23–30) không có cờ = chưa bù | |
| result["height_comp"] = dict(comp_meta) if comp_meta else {"on": False} | |
| if not result["height_comp"]["on"]: | |
| result["warnings"].append( | |
| "Không bù độ cao tâm bi (" | |
| + result["height_comp"].get("reason", "không rõ vì sao") | |
| + ") — toạ độ theo quy ước cũ, bi gần băng có thể lệch ra xa " | |
| "băng cỡ 1–3cm.") | |
| # khối shotnet — HAI phương pháp hiện song song (analytic giữ nguyên); | |
| # net lỗi/vắng model KHÔNG giết kết quả analytic, khai qua warnings | |
| if shotnet is None: | |
| result["warnings"].append( | |
| "ShotNet chưa load được (thiếu checkpoint?) — chỉ có số đo " | |
| "giải tích.") | |
| elif captured.get("rows"): | |
| progress("shotnet", 0.99) | |
| try: | |
| result["shotnet"] = shotnet_infer(shotnet, captured["rows"], | |
| captured.get("others") or []) | |
| # van mật độ (BG29 bước 3): CHỈ thêm một dòng cảnh báo, không | |
| # đụng số model và không chặn gì | |
| note = density_warning(result["shotnet"]) | |
| if note: | |
| result["warnings"].append(note) | |
| except Exception as e: # noqa: BLE001 — net hỏng ≠ mất analytic | |
| result["warnings"].append( | |
| f"ShotNet lỗi khi suy đoán ({type(e).__name__}: {e}) — " | |
| f"chỉ có số đo giải tích.") | |
| # RMSE resim (lát A2 phần 2): nạp params suy được vào pooltool sim lại | |
| # cú → khối ``resim`` (hai bộ shotnet/analytic, series + rmse per bộ). | |
| # Bước THÊM SAU analyze — không đụng một số nào có sẵn; resim hỏng/vắng | |
| # pooltool → khai qua warnings, không giết kết quả (nếp shotnet). | |
| if resim is None: | |
| result["warnings"].append( | |
| "pooltool chưa sẵn trong venv CV — không có RMSE resim.") | |
| else: | |
| progress("resim", 0.99) | |
| try: | |
| result["resim"] = resim["module"].resim_result(result, | |
| resim["state"]) | |
| except Exception as e: # noqa: BLE001 — resim hỏng ≠ mất analyze | |
| result["warnings"].append( | |
| f"Resim lỗi ({type(e).__name__}: {e}) — không có RMSE.") | |
| # van "nghi không phải cú đánh" (lát A2 phần 1) — cờ HIỂN THỊ từ tín | |
| # hiệu tự khai, luôn có mặt trong kết quả mới (flagged False = đã kiểm | |
| # và sạch); kết quả cũ không có key = thời kỳ trước van (exclude_none) | |
| result["suspect_not_shot"] = not_shot_flag(result) | |
| # số bi frame đầu (A2b phần 4): gắn `number` vào balls_init từ CÙNG | |
| # nguồn màu với overlay.mp4 — chỉ luồng có RecordingModel (job per cú | |
| # của video đa cú); hỏng chỉ thành warning, không đụng số đo nào | |
| if rec is not None and rec.frames: | |
| try: | |
| note = ballid_balls_init(clip_path, payload.get("corners"), | |
| result, rec.frames[0]) | |
| if note: | |
| result["warnings"].append(note) | |
| except Exception as e: # noqa: BLE001 — màu hỏng ≠ mất kết quả số | |
| result["warnings"].append( | |
| f"Không gắn được số bi khung đầu ({type(e).__name__}: {e}) " | |
| f"— bi trên bàn metric hiện xám.") | |
| # overlay.mp4 per cú (lát A2 phần 3): render NGAY TRONG job, khi clip | |
| # CÒN trên đĩa (handle_analyze xoá clip sau khi analyze_once trả về — | |
| # overlay không render nổi hồi tố, bài học lần đo A1). Render cả cú bị | |
| # van gắn cờ (banner đỏ trên frame — giúp người kiểm bằng mắt); render | |
| # hỏng chỉ thành warning, không giết kết quả số. | |
| if rec is not None: | |
| from poolcoach_cv.overlay import render_shot_overlay | |
| progress("overlay", 0.99) | |
| try: | |
| note = render_shot_overlay( | |
| clip_path, payload.get("corners"), result, rec.frames, | |
| overlay_path, label=Path(str(overlay_path)).stem, | |
| beat=lambda: jobqueue.beat(key=jobqueue.SCAN_HEARTBEAT_KEY)) | |
| result["overlay"] = {"file": Path(str(overlay_path)).name} | |
| if note: | |
| result["warnings"].append(note) | |
| except Exception as e: # noqa: BLE001 — overlay hỏng ≠ mất số đo | |
| result["warnings"].append( | |
| f"Không render được overlay ({type(e).__name__}: {e}) — " | |
| f"số đo không ảnh hưởng.") | |
| return result | |
| def handle_analyze(payload: dict, model, conf: float, | |
| predict_kw: dict, shotnet: dict | None = None, | |
| resim: dict | None = None) -> dict: | |
| """Reply protocol như handle_scan, NHƯNG người đọc thật là status key | |
| (API poll GET /api/analyze/{id}) — reply chỉ về result-list mồ côi rồi | |
| hết TTL. Status ghi best-effort: Redis rớt một nhịp giữa job không được | |
| giết worker (job vẫn chạy nốt, GET thấy status cũ tới khi nhịp sau ghi | |
| đè). Clip tạm XOÁ khi xong/lỗi-input (bản quyền, BRIEF #7); GIỮ khi lỗi | |
| nội bộ để còn truy được vì sao. | |
| Lát A1 (13/08) CHỈ THÊM ``result_path`` (tuỳ chọn trong payload — job | |
| per cú của luồng video đa cú): kết quả/lỗi ghi thêm ra FILE ngay tại | |
| đây, vì kết quả job từng MẤT THẬT khi chỉ nằm Redis TTL 1h (BG29b). | |
| Job /api/analyze cũ không gửi trường này — không đổi một byte hành vi. | |
| """ | |
| analyze_id = str(payload.get("analyze_id", "")) | |
| result_path = payload.get("result_path") | |
| def status(data: dict) -> None: | |
| try: | |
| jobqueue.set_analyze_status(analyze_id, data) | |
| except jobqueue.QueueDown: | |
| pass | |
| def persist(data: dict) -> None: | |
| if not result_path: | |
| return | |
| try: | |
| astore.write_json(Path(result_path), data) | |
| except OSError as e: | |
| print(f"[cv_worker] WARNING: khong ghi duoc {result_path}: " | |
| f"{type(e).__name__}: {e}", flush=True) | |
| def drop_clip() -> None: | |
| p = payload.get("clip_path") | |
| if p: | |
| Path(p).unlink(missing_ok=True) | |
| status({"status": "running", "progress": 0.0, "stage": "decode"}) | |
| try: | |
| result = analyze_once(payload, model, conf, predict_kw, status, | |
| shotnet=shotnet, resim=resim) | |
| except ValueError as e: | |
| drop_clip() | |
| persist({"error": "validation", "message": str(e)}) | |
| status({"status": "error", "message": str(e)}) | |
| return {"ok": False, "error": "validation", "message": str(e)} | |
| except Exception as e: # noqa: BLE001 — job hỏng ≠ worker chết | |
| persist({"error": "internal", | |
| "message": f"{type(e).__name__}: {e}"}) | |
| status({"status": "error", | |
| "message": f"{type(e).__name__}: {e}"}) | |
| return {"ok": False, "error": "internal", | |
| "message": f"{type(e).__name__}: {e}"} | |
| persist(result) # file TRƯỚC Redis — nguồn sự thật nằm trên đĩa | |
| drop_clip() | |
| status({"status": "done", "progress": 1.0, "result": result}) | |
| return {"ok": True, "result": {"analyze_id": analyze_id}} | |
| # ------------------------------------- segment video đa cú (lát A1, 13/08) | |
| def cut_clip(video_path: Path, t0: float, dur: float, out: Path) -> str | None: | |
| """Cắt MỘT clip per cú bằng ffmpeg hệ thống (re-encode h264 — seek | |
| chính xác tới frame, stream-copy sẽ lệch về keyframe trước đó cả giây). | |
| Trả None khi OK, message lỗi tiếng Việt khi hỏng. ``-an`` bỏ audio. | |
| ffmpeg 9.0 đã có trên máy demo (BRIEF 24 #8 — cùng máy chạy ffprobe).""" | |
| import shutil | |
| import subprocess | |
| exe = shutil.which("ffmpeg") | |
| if exe is None: | |
| return "máy không có ffmpeg — không cắt được clip per cú" | |
| out.parent.mkdir(parents=True, exist_ok=True) | |
| try: | |
| r = subprocess.run( | |
| [exe, "-y", "-v", "error", "-ss", f"{max(t0, 0.0):.3f}", | |
| "-i", str(video_path), "-t", f"{dur:.3f}", "-an", | |
| "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", | |
| str(out)], | |
| capture_output=True, text=True, timeout=300) | |
| except subprocess.TimeoutExpired: | |
| return "ffmpeg cắt clip quá 300s — treo?" | |
| if r.returncode != 0 or not out.exists(): | |
| err = (r.stderr or "ffmpeg lỗi không rõ").strip().splitlines()[-1] | |
| return f"ffmpeg không cắt được clip ({err[:200]})" | |
| return None | |
| def segment_once(payload: dict, model, conf: float, predict_kw: dict, | |
| status) -> dict: | |
| """MỘT job segment: video dài → danh sách cú → manifest ghi FILE ngay | |
| → thumbnail + clip per cú → enqueue N job analyze NGUYÊN TRẠNG. | |
| Ném ValueError cho lỗi thuộc về input (video mất/hỏng/quá dài, corners | |
| suy biến) — cùng phân loại scan/analyze. Job DÀI nhất hệ (video 5–20 | |
| phút): progress callback beat heartbeat như analyze. | |
| """ | |
| video_path = payload.get("video_path") | |
| if not video_path or not Path(video_path).exists(): | |
| raise ValueError("Video không còn trên đĩa — upload lại.") | |
| video_path = Path(video_path) | |
| out_dir = Path(payload["out_dir"]) | |
| video_id = str(payload.get("video_id", "")) | |
| from poolcoach_cv.segmentation import segment_video | |
| def progress(stage: str, frac: float) -> None: | |
| jobqueue.beat(key=jobqueue.SCAN_HEARTBEAT_KEY) | |
| status({"status": "running", "progress": round(min(frac, 0.99), 3), | |
| "stage": stage}) | |
| t0 = time.perf_counter() | |
| seg = segment_video(video_path, payload.get("corners"), model, | |
| op_conf=conf, device=predict_kw.get("device"), | |
| max_dur_s=astore.VIDEO_MAX_DUR_S, progress=progress) | |
| # manifest per cú — trạng thái ban đầu queued (cú lành) / error (cú | |
| # segmentation đã kết án: mất cảnh, không đóng được ranh giới...) | |
| shots = [] | |
| for s in seg["shots"]: | |
| idx = int(s["idx"]) | |
| dur = float(s["t_end_s"]) - float(s["t_start_s"]) | |
| entry = dict(s) | |
| entry["analyze_id"] = astore.shot_analyze_id(video_id, idx) | |
| entry["thumb"] = astore.thumb_path(out_dir, idx).name | |
| if s["status"] == "ok": | |
| if dur > bc_max_dur(): | |
| entry["status"] = "error" | |
| entry["reason"] = (f"cú dài {dur:.1f}s vượt trần clip phân " | |
| f"tích {bc_max_dur():g}s") | |
| else: | |
| entry["status"] = "queued" | |
| entry["clip"] = f"clips/{astore.clip_path(out_dir, idx).name}" | |
| shots.append(entry) | |
| # thumbnail frame đầu cú (seek theo PTS tuyệt đối — cùng thước t_start) | |
| import cv2 | |
| cap = cv2.VideoCapture(str(video_path)) | |
| try: | |
| for entry in shots: | |
| jobqueue.beat(key=jobqueue.SCAN_HEARTBEAT_KEY) | |
| cap.set(cv2.CAP_PROP_POS_MSEC, float(entry["t_start_s"]) * 1000.0) | |
| ok, frame = cap.read() | |
| if not ok: | |
| continue | |
| h, w = frame.shape[:2] | |
| scale = 320.0 / max(w, 1) | |
| thumb = cv2.resize(frame, (320, max(1, round(h * scale)))) | |
| cv2.imwrite(str(astore.thumb_path(out_dir, int(entry["idx"]))), | |
| thumb, [cv2.IMWRITE_JPEG_QUALITY, 85]) | |
| finally: | |
| cap.release() | |
| # cắt clip per cú lành — hỏng thì cú đó thành error kèm lý do, các cú | |
| # khác vẫn đi tiếp (một cú hỏng không giết cả danh sách) | |
| n_cut = 0 | |
| for entry in shots: | |
| if entry["status"] != "queued": | |
| continue | |
| jobqueue.beat(key=jobqueue.SCAN_HEARTBEAT_KEY) | |
| status({"status": "running", "stage": "cut", | |
| "progress": round(0.92 + 0.07 * n_cut / max(len(shots), 1), | |
| 3)}) | |
| err = cut_clip(video_path, | |
| float(entry["t_start_s"]), | |
| float(entry["t_end_s"]) - float(entry["t_start_s"]), | |
| astore.clip_path(out_dir, int(entry["idx"]))) | |
| if err is not None: | |
| entry["status"] = "error" | |
| entry["reason"] = err | |
| entry.pop("clip", None) | |
| n_cut += 1 | |
| manifest = { | |
| "status": "done", | |
| "video_id": video_id, | |
| "filename": payload.get("filename"), | |
| "fps_nominal": seg.get("fps_nominal"), | |
| "t_first_s": seg.get("t_first_s"), | |
| "t_last_s": seg.get("t_last_s"), | |
| "duration_s": seg.get("duration_s"), | |
| "n_frames": seg.get("n_frames"), | |
| "n_dup_frames": seg.get("n_dup_frames"), | |
| "elapsed_scan_s": round(time.perf_counter() - t0, 2), | |
| "warnings": seg.get("warnings", []), | |
| "shots": shots, | |
| } | |
| # manifest nằm trên ĐĨA trước khi bất kỳ cú nào được phân tích — chết | |
| # giữa chừng thì danh sách cú + lý do vẫn còn (nếp BG29b/BG31) | |
| astore.write_json(astore.manifest_path(out_dir), manifest) | |
| # enqueue job analyze per cú NGUYÊN TRẠNG — chỉ thêm result_path; | |
| # status "queued" ghi TRƯỚC enqueue (race đã bắt được bằng test BG24) | |
| for entry in shots: | |
| if entry["status"] != "queued": | |
| continue | |
| idx = int(entry["idx"]) | |
| aid = entry["analyze_id"] | |
| try: | |
| jobqueue.set_analyze_status(aid, {"status": "queued", | |
| "progress": 0.0}) | |
| jobqueue.enqueue( | |
| {"analyze_id": aid, | |
| "clip_path": str(astore.clip_path(out_dir, idx)), | |
| "corners": payload.get("corners"), | |
| "result_path": str(astore.shot_json_path(out_dir, idx)), | |
| # lát A2 phần 3: overlay render trong job analyze, trước | |
| # khi clip per cú bị xoá | |
| "overlay_path": str(astore.overlay_path(out_dir, idx))}, | |
| jobs_key=jobqueue.ANALYZE_JOBS_KEY, job_id=aid) | |
| except jobqueue.QueueDown: | |
| # Redis rớt nhịp này: cú nằm "queued" trong manifest mà không | |
| # có job — GET sẽ báo unknown; đường phục hồi là upload lại | |
| print(f"[cv_worker] WARNING: khong enqueue duoc {aid} " | |
| f"(Redis down)", flush=True) | |
| # video gốc XONG VIỆC (đã có manifest + thumb + clip) — xoá, không đọng | |
| # video bản quyền (nếp BRIEF 24 #7); kết quả per cú sống bằng file JSON | |
| video_path.unlink(missing_ok=True) | |
| n_queued = sum(1 for s in shots if s["status"] == "queued") | |
| return {"n_shots": len(shots), "n_queued": n_queued, | |
| "elapsed_scan_s": manifest["elapsed_scan_s"]} | |
| def bc_max_dur() -> float: | |
| """Trần thời lượng clip analyze (30s) — đọc từ broadcast để không mang | |
| bản sao có thể trôi; hàm vì broadcast import lười được ở mọi venv.""" | |
| from poolcoach_cv import broadcast as bc | |
| return bc.ANALYZE_MAX_DUR_S | |
| def handle_segment(payload: dict, model, conf: float, | |
| predict_kw: dict) -> dict: | |
| """Reply protocol như handle_analyze — người đọc thật là status key | |
| per video (GET /api/analyzer/shots) + manifest file. Lỗi cũng ghi | |
| manifest error ra ĐĨA (file-first): status key hết TTL 1h thì người | |
| dùng vẫn thấy vì sao video này không ra danh sách.""" | |
| video_id = str(payload.get("video_id", "")) | |
| out_dir = Path(payload.get("out_dir", "")) | |
| def status(data: dict) -> None: | |
| try: | |
| jobqueue.set_video_status(video_id, data) | |
| except jobqueue.QueueDown: | |
| pass | |
| def persist_error(message: str) -> None: | |
| if not str(out_dir): | |
| return | |
| try: | |
| astore.write_json(astore.manifest_path(out_dir), | |
| {"status": "error", "video_id": video_id, | |
| "message": message}) | |
| except OSError as e: | |
| print(f"[cv_worker] WARNING: khong ghi duoc manifest loi: " | |
| f"{type(e).__name__}: {e}", flush=True) | |
| status({"status": "running", "progress": 0.0, "stage": "decode"}) | |
| try: | |
| result = segment_once(payload, model, conf, predict_kw, status) | |
| except ValueError as e: | |
| p = payload.get("video_path") | |
| if p: | |
| Path(p).unlink(missing_ok=True) # video hỏng — không đọng | |
| persist_error(str(e)) | |
| status({"status": "error", "message": str(e)}) | |
| return {"ok": False, "error": "validation", "message": str(e)} | |
| except Exception as e: # noqa: BLE001 — job hỏng ≠ worker chết | |
| # GIỮ video khi lỗi nội bộ — còn truy được vì sao (nếp analyze) | |
| persist_error(f"{type(e).__name__}: {e}") | |
| status({"status": "error", "message": f"{type(e).__name__}: {e}"}) | |
| return {"ok": False, "error": "internal", | |
| "message": f"{type(e).__name__}: {e}"} | |
| status({"status": "done", "progress": 1.0, **result}) | |
| return {"ok": True, "result": {"video_id": video_id, **result}} | |
| def resolve_weights() -> Path: | |
| """Đường dẫn weights detector — env > máy Danh > BẢN TRONG REPO. | |
| Thứ tự cố ý: máy phát triển giữ nguyên đường cũ ``D:\\Khoa luan\\ | |
| cv_full_20260805\\best.pt`` nên số local KHÔNG đổi một byte; container | |
| (HF Spaces) không có ổ D nên rơi xuống bản force-add trong repo | |
| ``models/cv_full_20260805/best.pt`` — cùng file, chép byte-for-byte. | |
| """ | |
| env = os.environ.get("POOLCOACH_CV_WEIGHTS") | |
| if env: | |
| return Path(env) | |
| for p in (Path(DEFAULT_WEIGHTS), | |
| ROOT / "models" / "cv_full_20260805" / "best.pt"): | |
| if p.exists(): | |
| return p | |
| return Path(DEFAULT_WEIGHTS) # báo lỗi với đường quen thuộc | |
| def load_all(conf: float, device: str, predict_kw: dict): | |
| """Nạp YOLO + ShotNet + pooltool(resim) MỘT lần — phần "khởi động" của | |
| worker, tách ra để chế độ nhúng (app/localcv.py) dùng CHUNG code, không | |
| có bản sao thứ hai của thứ tự nạp/warmup. | |
| Ném FileNotFoundError khi thiếu weights detector; ShotNet/resim thiếu | |
| thì trả None (analyze vẫn sống kèm warning per-job — nếp cũ).""" | |
| weights = resolve_weights() | |
| if not weights.exists(): | |
| raise FileNotFoundError( | |
| f"khong thay weights {weights} -- set POOLCOACH_CV_WEIGHTS neu " | |
| f"model nam cho khac") | |
| t0 = time.time() | |
| from ultralytics import YOLO | |
| model = YOLO(str(weights)) | |
| # warmup 1 predict anh den: dung CUDA context + fuse model ngay bay gio, | |
| # de job dau tien khong phai tra gia (cung ly do JIT warmup cua engine) | |
| model.predict(np.zeros((IMGSZ, IMGSZ, 3), dtype=np.uint8), conf=conf, | |
| imgsz=IMGSZ, verbose=False, **predict_kw) | |
| # ShotNet (BG28): load MOT lan luc boot nhu YOLO — 10.7MB, ~1s; thieu | |
| # file thi None + analyze van song (warning per-job) | |
| shotnet = load_shotnet(device) | |
| # Resim pooltool (lat A2): JIT nguoi 40-90s — TRUOC heartbeat, nhu JIT | |
| # cua engine_worker; thieu pooltool thi None + analyze van song | |
| resim = load_resim() | |
| print(f"[cv_worker] model {weights.name} san sang sau " | |
| f"{time.time() - t0:.1f}s (conf={conf:g}" | |
| f"{', device=' + device if device else ''}" | |
| f", shotnet={'OK ' + shotnet['tag'] if shotnet else 'KHONG'}" | |
| f", resim={'OK' if resim else 'KHONG'})" | |
| f" -- bat heartbeat, cho job tren " | |
| f"'{jobqueue.SCAN_JOBS_KEY}'", flush=True) | |
| return model, shotnet, resim | |
| def serve_forever(model, conf: float, predict_kw: dict, shotnet, resim, | |
| should_stop=None) -> int: | |
| """Vòng phục vụ 3 queue — thân của worker, dùng chung với chế độ nhúng. | |
| ``should_stop()`` (tuỳ chọn) cho chế độ nhúng thoát sạch khi app tắt; | |
| worker process không truyền, thoát bằng Ctrl+C như cũ. | |
| """ | |
| import redis | |
| served = 0 | |
| try: | |
| while True: | |
| if should_stop is not None and should_stop(): | |
| print(f"[cv_worker] dung theo yeu cau sau {served} job", | |
| flush=True) | |
| break | |
| try: | |
| jobqueue.beat(key=jobqueue.SCAN_HEARTBEAT_KEY) | |
| t_job = time.perf_counter() | |
| # 3 queue thay phiên, lát chờ ngắn (3×2s ≈ nhịp 5s cũ) để | |
| # heartbeat vẫn beat đều; scan giữ LƯỢT ĐẦU — job tương | |
| # tác nhanh không phải xếp sau một analyze đang nằm queue. | |
| # segment xếp CUỐI (lát A1): một job segment đẻ N job | |
| # analyze, và analyze phải chạy hết trước khi nhận video | |
| # mới — danh sách cú của video đang xem hiện dần đúng nhịp. | |
| if jobqueue.serve_one( | |
| lambda p: handle_scan(p, model, conf, predict_kw), | |
| timeout_s=2, jobs_key=jobqueue.SCAN_JOBS_KEY): | |
| served += 1 | |
| print(f"[cv_worker] scan job #{served} xong sau " | |
| f"{time.perf_counter() - t_job:.2f}s", flush=True) | |
| elif jobqueue.serve_one( | |
| lambda p: handle_analyze(p, model, conf, predict_kw, | |
| shotnet=shotnet, | |
| resim=resim), | |
| timeout_s=2, jobs_key=jobqueue.ANALYZE_JOBS_KEY): | |
| served += 1 | |
| print(f"[cv_worker] analyze job #{served} xong sau " | |
| f"{time.perf_counter() - t_job:.2f}s", flush=True) | |
| elif jobqueue.serve_one( | |
| lambda p: handle_segment(p, model, conf, predict_kw), | |
| timeout_s=2, jobs_key=jobqueue.SEGMENT_JOBS_KEY): | |
| served += 1 | |
| print(f"[cv_worker] segment job #{served} xong sau " | |
| f"{time.perf_counter() - t_job:.2f}s", flush=True) | |
| except redis.RedisError as e: | |
| # Redis chet/khoi dong lai KHONG duoc giet worker da load model | |
| print(f"[cv_worker] mat ket noi Redis ({type(e).__name__}: " | |
| f"{e}) -- thu lai sau 2s", flush=True) | |
| time.sleep(2) | |
| except KeyboardInterrupt: | |
| print(f"\n[cv_worker] Ctrl+C -- thoat sach sau {served} job", | |
| flush=True) | |
| finally: | |
| jobqueue.clear_heartbeat(key=jobqueue.SCAN_HEARTBEAT_KEY) | |
| if should_stop is None: | |
| # chỉ worker PROCESS mới được đóng transport; chế độ nhúng dùng | |
| # CHUNG client với app — teardown ở đây là giết luôn app | |
| jobqueue.teardown() | |
| return 0 | |
| def main() -> int: | |
| if not os.environ.get("REDIS_URL"): | |
| print("cv_worker: thieu env REDIS_URL -- worker chi co nghia o mode " | |
| "queue (vi du: redis://localhost:6379/0). Chay CHUNG tien " | |
| "trinh voi app thi dat POOLCOACH_LOCAL_CV=1 khi chay app.", | |
| flush=True) | |
| return 2 | |
| conf = float(os.environ.get("POOLCOACH_CV_CONF", OP_CONF)) | |
| device = os.environ.get("POOLCOACH_CV_DEVICE", "") | |
| predict_kw = {"device": device} if device else {} | |
| jobqueue.setup() | |
| try: | |
| model, shotnet, resim = load_all(conf, device, predict_kw) | |
| except FileNotFoundError as e: | |
| print(f"cv_worker: {e}", flush=True) | |
| return 2 | |
| return serve_forever(model, conf, predict_kw, shotnet, resim) | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |