Spaces:
Sleeping
Sleeping
| """CV worker chạy TRONG tiến trình app — mode "local" (14/08/2026, việc B). | |
| Vì sao tồn tại: Space HF free là MỘT container. Bản deploy 23/07 chỉ có app | |
| gợi ý nên không cần gì thêm, còn Analyzer cần detector + ShotNet + segment. | |
| Ba đường đi được cân: | |
| 1. Cài redis-server + chạy cv_worker (và cả engine_worker) làm tiến trình | |
| phụ trong container — giữ nguyên code path, nhưng bật ``REDIS_URL`` là | |
| ``/api/recommend`` cũng đòi engine worker, thành ra phải nuôi BA tiến | |
| trình + một binary Redis, và hai lần JIT pooltool trên 2 vCPU. | |
| 2. Viết một hàng đợi in-memory mới — thêm một bản hiện thực thứ hai của | |
| transport, tức thêm một chỗ để lệch khỏi bản Redis đã nghiệm thu. | |
| 3. (chọn) fakeredis trong tiến trình + LUỒNG worker gọi ĐÚNG hàm của | |
| ``scripts/cv_worker.py``. Không tiến trình phụ, không binary, không bản | |
| sao logic: ``handle_scan``/``handle_analyze``/``handle_segment`` và vòng | |
| ``serve_forever`` là CÙNG code chạy trên máy có GPU. | |
| Đánh đổi đã biết, ghi rõ để đừng ngộ nhận: một luồng phục vụ tuần tự, nên | |
| đúng như bản Redis "trong lúc một analyze chạy, scan job phải chờ" — demo | |
| một người dùng (nếp BG24). GIL nhả trong numpy/torch/cv2 nên API vẫn trả | |
| lời được trong lúc worker chạy, nhưng có tranh CPU: số đo thời gian trên | |
| Space KHÔNG so ngang với số đo worker riêng máy GPU. | |
| Bật bằng env ``POOLCOACH_LOCAL_CV=1`` (xem ``jobqueue.setup``). Không bật | |
| thì file này không được import. | |
| """ | |
| from __future__ import annotations | |
| import importlib.util | |
| import os | |
| import sys | |
| import threading | |
| import time | |
| from pathlib import Path | |
| ROOT = Path(__file__).resolve().parents[1] | |
| _thread: threading.Thread | None = None | |
| _stop = threading.Event() | |
| _state: dict = {"stage": "off", "error": None, "since": None} | |
| def state() -> dict: | |
| """Trạng thái luồng worker nhúng — cho log/health đọc, không cho FE.""" | |
| return dict(_state) | |
| def _load_worker_module(): | |
| """Nạp ``scripts/cv_worker.py`` như một module. | |
| Import theo ĐƯỜNG DẪN vì repo không cài package và ``scripts/`` không | |
| phải package (quy ước repo — xem CLAUDE.md). Chỉ nạp, không gọi | |
| ``main()``: mọi thứ mức module của cv_worker là đẩy sys.path + import | |
| numpy/jobqueue/poolcoach_cv, an toàn khi nạp trong app. | |
| """ | |
| name = "poolcoach_cv_worker" | |
| if name in sys.modules: | |
| return sys.modules[name] | |
| path = ROOT / "scripts" / "cv_worker.py" | |
| spec = importlib.util.spec_from_file_location(name, path) | |
| if spec is None or spec.loader is None: | |
| raise ImportError(f"không nạp được {path}") | |
| mod = importlib.util.module_from_spec(spec) | |
| sys.modules[name] = mod | |
| spec.loader.exec_module(mod) | |
| return mod | |
| def _run() -> None: | |
| t0 = time.time() | |
| try: | |
| _state.update(stage="loading", error=None) | |
| cw = _load_worker_module() | |
| conf = float(os.environ.get("POOLCOACH_CV_CONF", cw.OP_CONF)) | |
| device = os.environ.get("POOLCOACH_CV_DEVICE", "") | |
| predict_kw = {"device": device} if device else {} | |
| model, shotnet, resim = cw.load_all(conf, device, predict_kw) | |
| _state.update(stage="serving", since=round(time.time() - t0, 1)) | |
| print(f"[poolcoach] CV worker nhung san sang sau " | |
| f"{time.time() - t0:.1f}s -- tab Analyzer dung duoc", | |
| flush=True) | |
| cw.serve_forever(model, conf, predict_kw, shotnet, resim, | |
| should_stop=_stop.is_set) | |
| except BaseException as e: # noqa: BLE001 — luồng chết ≠ app chết | |
| _state.update(stage="error", error=f"{type(e).__name__}: {e}") | |
| print(f"[poolcoach] LOI CV worker nhung: {type(e).__name__}: {e} " | |
| f"-- tab Analyzer se bao 'CV worker khong chay', phan con lai " | |
| f"cua app van chay", flush=True) | |
| finally: | |
| if _state.get("stage") != "error": | |
| _state.update(stage="stopped") | |
| def start() -> None: | |
| """Bật luồng worker nhúng (daemon). Gọi ở lifespan khi mode == local. | |
| Không chặn startup: nạp YOLO + ShotNet + JIT pooltool mất hàng chục | |
| giây, nhưng trang danh sách cú của video mẫu ĐÃ PHÂN TÍCH SẴN đọc từ | |
| file nên dùng được ngay — heartbeat chỉ bật sau khi nạp xong, đúng nếp | |
| "không báo alive lúc còn đang khởi động". | |
| """ | |
| global _thread | |
| if _thread is not None and _thread.is_alive(): | |
| return | |
| _stop.clear() | |
| _thread = threading.Thread(target=_run, daemon=True, | |
| name="poolcoach-localcv") | |
| _thread.start() | |
| def stop(timeout: float = 10.0) -> None: | |
| """Xin luồng dừng ở vòng lặp kế tiếp (job đang chạy được chạy nốt).""" | |
| _stop.set() | |
| if _thread is not None and _thread.is_alive(): | |
| _thread.join(timeout=timeout) | |