--- title: PoolCoach emoji: 🎱 colorFrom: green colorTo: gray sdk: docker app_port: 7860 --- # PoolCoach App huấn luyện **tư duy điều bi 9-ball**: đặt thế bàn → nhận cú đánh gợi ý (hướng, lực, điểm chạm bi cái) kèm quỹ đạo mô phỏng → đánh thử và chơi tiếp chuỗi. Phần lõi nghiên cứu của luận văn thạc sĩ PoolCoach. Hai phần trong một repo: 1. **Web app gợi ý cú đánh** (`app/` + `src/poolcoach_rl/recommend/`) — FastAPI + canvas thuần, chạy trên sim vật lý thật. 2. **Nghiên cứu RL** (`src/poolcoach_rl/envs/` + `scripts/train_*`, `models/`) — môi trường Gymnasium + các thí nghiệm PPO/SAC/BC/Q-field, hồ sơ số liệu của luận văn. ## Engine gợi ý — ZonePlanner Một engine duy nhất, tiêu chí chốt 31/07/2026 (`Documents/PoolCoach_App_ZonePlanner_Design.md` §8, repo poolcoach-docs): - **Tập ứng viên**: chọn 1-2 lỗ gần bi mục tiêu nhất (loại lỗ có góc cắt > 70°) × 11 kỹ thuật cơ bản (tâm bi / cu lê / trô / xoáy ± kết hợp nhẹ) × 9 nấc lực — mô phỏng TỪNG cú bằng `pooltool`, lọc trên kết quả sim. - **Lọc cứng** (AND, không nới): ăn bi trực tiếp không combo, đường bi mục tiêu sạch; đường bi cái SAU va chạm không đụng bất kỳ bi nào (chạm băng thì được); góc cắt sang bi kế tại điểm đáp ≤ 45°; khoảng cách đáp 0.15–1.50 m; lực nấc 2–10 của thang. - **Xếp hạng** lexicographic, không trọng số: đường bi cái lăn sau va chạm NGẮN NHẤT (arc length thật — đường có băng dài hơn đường thẳng) → hoà thì `|d_đáp − 1 m|` nhỏ nhất → hoà nữa thì thứ tự duyệt tất định. - **Hết đường là một câu trả lời**: thế bàn không có cú đạt đủ tiêu chí thì app nói thẳng như vậy — kỹ năng cân bi / kick / safety nằm trong lộ trình, không có thang nới lấy số bằng mọi giá. Lịch sử nghiên cứu dẫn tới tiêu chí này (oracle grid search, hybrid Q-field net, zone V1 raster + ablation của chúng) nằm nguyên vẹn ở nhánh **`v1-full`** và trong nhật ký nghiên cứu (repo poolcoach-docs). ## Chạy web app ```bash # cài core + app (xem mục Cài đặt) python -m uvicorn app.main:app --port 8000 # mở http://localhost:8000 — lần đầu chờ ~40s JIT Numba ``` 4 endpoint: `GET /api/table`, `GET /api/health`, `POST /api/recommend`, `POST /api/trajectory` (quỹ đạo cú thay thế — render lười). Bàn mẫu lấy từ `app/static/demo_boards.json` — danh sách thế bàn đã quét offline sao cho engine tự chơi chạy hết chuỗi tới bi 9 (`scripts/gen_demo_boards.py`). Gate nghiệm thu engine: `scripts/eval_fullrack.py --tables 20` — 0 cú gợi ý phạm luật, search median < 5s (đo 31/07: median 1.42s, max 4.67s, serial). ## Thư viện bài tập (F3 MVP) Tab **"Bài tập"** trên UI: chọn drill → canvas vẽ layout + vòng tròn `cue_zone` ("vùng đích" của bi cái) → tập trên bàn thật, tự khai Đạt/Trượt đủ số `reps` → chốt x/N đạt. Nút **"Xem gợi ý"** gọi engine trên đúng thế drill để so cú của mình với cú máy (engine xếp hạng theo tiêu chí riêng, *không* nhắm zone của drill — cú máy không đạt goal là chuyện bình thường). - Drill chuẩn nằm trong **`app/drills.json`** (file trong repo, không phải bảng DB — Space không có Postgres vẫn demo được F3). API: `GET /api/drills`, `GET /api/drills/{id}`. - Lượt tập ghi qua `POST /api/drill-attempts` — best-effort: không DB vẫn 200 với `persisted: false` (FE hiện "chế độ demo — tiến bộ không được lưu"); lịch sử `GET /api/drill-attempts` cần DB (503 khi không có). Có phiên bàn (QR) thì tab Bài tập hiện khu **"Tiến bộ phiên này"** — pass/fail theo drill của đúng phiên đó. - Sửa/thêm drill thì chạy lại gate khả thi: `python scripts/verify_drills.py` — 100% layout hợp lệ, mỗi drill phải có cú pot sạch và ≥1 cú đạt trọn goal trên grid kỹ thuật × lực của engine (curation, không phải số đo engine). ## TV tại bàn (demo) Trang **`/tv`** cho màn hình đặt cạnh bàn (Android TV box / PC mini cắm HDMI — chạy được trình duyệt là được): mở là hiện **mã pair 6 số**; trên điện thoại bấm **📺 Ghép TV**, nhập mã → từ đó mỗi lần bấm **Gợi ý**, cú đánh được đẩy qua WebSocket (`/display/{id}`) xuống TV: sơ đồ bàn nằm ngang chiếm màn, quỹ đạo cú tốt nhất + 2 cú thay thế, chip lực/điểm chạm cỡ lớn, góc màn hình ghi giờ cập nhật. TV **read-only** và tự reconnect khi rớt mạng. Không cần DB/Redis — registry pair nằm in-memory (restart server là ghép lại; bảng `displays` trong design chờ có auth/dashboard). Trigger hiện là **tay** (bấm Gợi ý trên điện thoại) — camera + edge agent của chế độ realtime (FullVision §5.4) thuộc bàn giao sau. Response `/api/recommend` không đổi một byte dù có hay không TV ghép (test khoá byte-equal). ## Chạy dev với DB App chạy được **không cần DB** (không set `DATABASE_URL` là tầng DB tắt hẳn — HF Space đang chạy đúng chế độ này). DB chỉ cần cho các tính năng quán: QR bàn → guest session, log recommend vào bảng `recommendations`, lưu + xem lịch sử lượt tập drill (bảng `drill_attempts`). ```bash # 1. Postgres dev (Docker Desktop phải đang chạy) docker compose -f docker-compose.dev.yml up -d # 2. Trỏ app vào DB rồi tạo schema set DATABASE_URL=postgresql+psycopg://poolcoach:poolcoach@localhost:5432/poolcoach alembic upgrade head # 3. Seed tenant "dev" + 1 bàn — idempotent, in token QR + URL để thử python scripts\seed_db.py # 4. Chạy app như thường (cùng cửa sổ đã set DATABASE_URL) python -m uvicorn app.main:app --port 8000 ``` **Thử luồng QR end-to-end:** mở URL mà seed in ra (`/?qr=`) trong trình duyệt — FE tự đổi token lấy guest session TTL 24h, header hiện badge "Bàn: …". Phiên nằm trong `sessionStorage` (của lượt ngồi bàn — đóng tab là hết); từ lúc đó mọi `POST /api/recommend` và lượt tập drill gửi kèm `session_id`, và tab Bài tập có khu "Tiến bộ phiên này" đọc lịch sử của đúng phiên. Token sai / server không DB → FE chỉ toast một câu rồi chạy chế độ thường như cũ. `POST /api/recommend` nhận 3 field optional `scan_id`/`edited`/`session_id` — chỉ để ghi log (session sống thì row `recommendations` mang `tenant_id`), response không đổi một byte; DB lỗi hay tắt thì recommend vẫn chạy bình thường (log là best-effort). Secret ký token QR đặt qua env `POOLCOACH_SECRET` (không set → dùng secret dev, chỉ để chạy local). `pytest` **không cần** Docker/Postgres — test DB chạy trên SQLite in-memory. ## Chạy mode queue (worker tách process) App chạy được **không cần Redis** (không set `REDIS_URL` là queue tắt hẳn, `/api/recommend` tính in-process y hệt trước — HF Space đang chạy đúng chế độ này). Mode queue tách engine recommend ra **worker riêng** qua Redis (request-reply tự viết ~100 dòng, `app/jobqueue.py` — không rq/celery): đây là mảnh hạ tầng cho realtime/TV sau này, hiện chỉ có một queue `pc:jobs:recommend`, facade API vẫn đồng bộ. ```bash # 1. Redis dev (Docker Desktop phải đang chạy) docker compose -f docker-compose.dev.yml up -d redis # 2. Worker — process riêng, JIT ~40s lúc boot, xong mới nhận job set REDIS_URL=redis://localhost:6379/0 python scripts\engine_worker.py # 3. API cùng REDIS_URL (cửa sổ khác) — /api/recommend đi qua worker set REDIS_URL=redis://localhost:6379/0 python -m uvicorn app.main:app --port 8000 ``` - Engine hai mode là **một hàm** (`app/engine.py::run_recommend`) — response bằng nhau từng byte, đã đo trên bàn demo (chỉ `search.elapsed_s` — đồng hồ — khác nhau giữa hai lần chạy bất kỳ). Overhead queue ~100ms/request. - `/api/trajectory` giữ in-process có chủ đích (1 sim nhẹ). - Worker chết/kẹt → request 503 message rõ sau timeout (mặc định 30s, env `POOLCOACH_QUEUE_TIMEOUT_S`) — **không** âm thầm fallback in-process. Bật lại worker là request kế chạy tiếp, không cần restart API. - `GET /api/health` thêm `mode` / `redis_ok` / `worker_alive` (heartbeat TTL 15s, chỉ bật sau khi worker JIT xong); ba trường cũ giữ nguyên. - `pytest` **không cần** Docker/Redis — transport test bằng fakeredis. ## Phần nghiên cứu RL - `src/poolcoach_rl/envs/` — `SinkOneBallEnv` (stage 1), `PositionPlayEnv` (stage 2a: reward điều bi qua `_position_q`). - `scripts/train_sink.py`, `train_bc*.py`, `train_qfield.py`, `gen_bc_dataset.py`, `relabel_bc_dataset.py`, `eval_position.py`, `oracle_controllability.py` — chuỗi thí nghiệm PPO/SAC → BC warm-start → Q-field; bản lưu lệnh tái lập ở `scripts/experiments/` (kèm ngưỡng nghiệm thu trong comment từng file). - `models/` (Git LFS) — trong đó `qfield_20260721/qfield.pt` là model Q-field mà luận văn trích số; app thôi dùng nó từ 31/07/2026. ## F2 CV — baseline offline (05/08/2026) Khởi động nhánh CV scan bàn (FullVision §5.2): detect bi từ ảnh + homography 4 góc → toạ độ bàn hệ pooltool. Hoàn toàn offline — chưa camera, chưa tích hợp app. Code ở `src/poolcoach_cv/` (không import lẫn với `poolcoach_rl`), deps riêng `requirements-cv.txt` (môi trường app không cần torch). **Dataset: pix2pockets** (arXiv [2504.12045](https://arxiv.org/abs/2504.12045), SCIA 2025) — bản export Roboflow `8-Ball-Pool-3` tác giả ship trong [repo chính thức](https://github.com/viktorseba/pix2pockets), **License CC BY 4.0** (ghi trong README.dataset.txt + data.yaml của bản export; [project Roboflow gốc](https://universe.roboflow.com/bachelorthesis/8-ball-pool-l530o)). 247 ảnh / 7244 bbox YOLOv5-format, 5 class `Black · Cue · Dot · Solid · Striped` (ảnh frame giải 8-ball quốc tế, nhiều góc camera). Zip gốc dồn hết vào `train/` nên script tải tự dựng split 80/20 tất định (seed 20260805): **train 198 ảnh / 5801 box · val 49 ảnh / 1443 box**. ```bash # tải + giải nén + dựng split (idempotent; ~41 MB, không cần API key) python scripts/cv/fetch_dataset.py ``` Dataset nằm ở `datasets/` — bị gitignore, không vào git. **Hai venv, tách cố ý** (05/08/2026): app/RL dùng `..\poolcoach-env` (torch CPU), CV dùng `..\poolcoach-cv-env` (torch 2.12.1+cu130, train GPU RTX 3070) — vì đổi venv chung sang CUDA sẽ lặng lẽ lật `device="auto"` của SB3 sang GPU, làm số RL tương lai hết so được với số RL cũ đo trên CPU. Dựng venv CV: venv Python 3.12 mới, cài `torch==2.12.1 torchvision` từ index `https://download.pytorch.org/whl/cu130` trước, rồi `requirements-cv.txt` (riêng, kéo ultralytics — cài bằng uv như các tầng khác). **Train baseline** — YOLO11n fine-tune từ COCO, chạy trên `poolcoach-cv-env`: ```bash # launcher: run_cv_train_baseline.bat (epochs 30 — GPU ~1 phút, CPU ~40 phút) python scripts/cv/train_baseline.py --epochs 30 --device 0 ``` Bản đầy đủ (epochs 150 · batch 32 GPU): `run_cv_train_full.bat`. Số baseline 05/08/2026 (CPU, seed 20260805): **mAP50 0.674 · mAP50-95 0.473** val; per-class + args trong `..\cv_baseline_20260805\metrics.json`. Số full 05/08/2026 (GPU, cùng seed/imgsz/split): **mAP50 0.893 · mAP50-95 0.678**, wall 7.8 phút; per-class + args trong `..\cv_full_20260805\metrics.json`. **Demo end-to-end mini** — detect → tâm bbox → homography 4 góc (đo tay, hardcode trong script cho 3 ảnh val) → toạ độ bàn hệ pooltool + overlay: ```bash python scripts/cv/demo_scan.py ``` Kết quả 05/08: 44/45 điểm bi nằm trong [0,W]×[0,L] (ca trượt duy nhất lệch 9 mm — bi sát băng, tâm bbox cao hơn điểm chạm nỉ). Overlay ở `..\cv_baseline_20260805\demo\`. ## Scan ảnh bàn (F2 MVP, 05/08/2026) Nút **"📷 Scan ảnh bàn"** trên UI: chọn ảnh chụp bàn → chấm **4 góc mặt nỉ** theo hướng dẫn từng bước trên overlay (đi vòng quanh bàn, 2 góc đầu là một băng ngắn) → **Quét** → CV detect bi + homography đổ thế bi lên canvas → người chơi **kéo-thả sửa vị trí / đổi số** rồi bấm Gợi ý như thường. Đây là van an toàn FullVision §5.2: CV chỉ *gợi ý* thế bi, người chơi mới là người chốt. Góc đã chấm lưu `sessionStorage` — ảnh sau chụp cùng khung hình (camera cố định) thì khỏi chấm lại. KHÔNG camera, KHÔNG realtime — ảnh do người dùng upload. Kiến trúc: `POST /api/scan` (multipart ảnh + corners) → queue `pc:jobs:scan` (cùng transport request-reply của `app/jobqueue.py`) → **CV worker** (`scripts/cv_worker.py`, process riêng trên venv `..\poolcoach-cv-env` CUDA, load `best.pt` một lần lúc boot, heartbeat riêng) → YOLO predict (conf vận hành **0.2993**, argmax F1 any-ball trên val — `scripts/cv/pick_conf.py`) → tâm bbox → homography (`src/poolcoach_cv/`) → `{scan_id, balls}`. App **không import torch** — không có REDIS_URL/worker thì `/api/scan` trả 503 nói thẳng, mọi thứ còn lại của app y nguyên. ```bash # 1. Redis (Docker Desktop phải đang chạy) docker compose -f docker-compose.dev.yml up -d redis # 2. CV worker — venv CV, load model vài giây rồi mới nhận job # (launcher: run_cv_worker.bat ở thư mục cha) set REDIS_URL=redis://localhost:6379/0 ..\poolcoach-cv-env\Scripts\python.exe scripts\cv_worker.py # 3. App cùng REDIS_URL (cửa sổ khác; kèm engine worker nếu muốn recommend # cũng đi mode queue — xem mục "Chạy mode queue") set REDIS_URL=redis://localhost:6379/0 python -m uvicorn app.main:app --port 8000 ``` Số đo 05/08 (2 ảnh val pix2pockets, GPU RTX 3070): scan end-to-end qua HTTP **~0.3–0.4 s/ảnh** (job thuần trong worker 40–110 ms); bi trả về nằm trọn trong bàn, đúng 1 bi cái, class Dot bị loại. `scan_id` + `edited` (người chơi có sửa sau scan không) đi kèm `POST /api/recommend` chỉ để ghi log — response recommend không đổi một byte. Thước deploy (predict-pipeline, conf 0.2993, any-ball, val 49 ảnh): **P 0.9679 · R 0.9094 · F1 0.9377** (`scripts/cv/eval_deploy_pr.py` — không so ngang AP val-pipeline). **Số bi — BallID hai tầng theo màu** (`src/poolcoach_cv/ballid.py`, 06/08): dataset không có nhãn số nên tầng 1 chấm điểm MÀU per-crop không cần train (chuẩn hoá trắng theo bi cue, hue circular + tỉ lệ pixel trắng cho sọc bi 9), tầng 2 gán toàn cục greedy với ràng buộc mỗi số ≤ 1 bi; bi không khớp trả `number=null` và FE rơi về đánh-số-theo-vị-trí cũ. Đây là baseline có chủ đích để chương CV so "trước/sau classifier" khi có bộ ảnh VN gán nhãn; đo trên ảnh thật: `scripts/cv/eval_ballid.py` (GT tay đang chờ điền). **Hạn chế đã biết (chấp nhận ở MVP):** - **Số bi gán theo màu là gợi ý** — bi quán VN bạc màu/bộ bi không chuẩn sẽ lẫn (kỳ vọng lẫn {4↔7↔3, 1↔9}); người chơi sửa chip/kéo-thả — đó chính là vai trò của van an toàn. - Bi **sát băng lệch ~9 mm** (tâm bbox cao hơn điểm chạm nỉ — đo 05/08). - Model có thể **bỏ sót bi** (vd ảnh val 103 góc quay xiên: bi cái không detect được ở mọi conf) — FE giữ bi cái ở chỗ cũ + toast nhắc kéo tay; không bi cái cũng đồng nghĩa **không chuẩn hoá trắng được** (`wb=false`). - Bi conf < 0.5 hoặc số CV chưa chắc tô **viền cam đứt** nhắc kiểm; kéo bi là viền tắt. - Ảnh quán VN thật **ngoài phân phối** dataset (frame giải 8-ball quốc tế) — gate chỉ đo trên ảnh val; ảnh thật xấu là chất liệu cho bộ ảnh VN sau. ## Analyzer clip broadcast (lát cắt dọc, 11/08/2026) Tab **Analyzer**: upload clip **1 cú trọn** (MP4/MOV ≤ 30s) cắt từ video broadcast → chấm 4 pocket góc trên frame đầu → server track cue ball + đo **V0 / hướng φ / va chạm / coverage** (`src/poolcoach_cv/broadcast.py`, nhấc từ pilot P0) → quỹ đạo vẽ lên table view + bảng số kèm cảnh báo. Từ 11/08 (BG25) va chạm phân loại **bi/băng/unknown** (`contact`) và có **baseline spin giải tích thô**: `spin_class` follow/draw/stun/side-L/R + confidence + evidence — chỉ đọc khi cú có va chạm đủ chứng cứ, không thì `null` có lý do; đây là mốc mà net BG26 (train synthetic) phải thắng. Job async qua queue `pc:jobs:analyze`, cùng CV worker với scan; chạy **local-only** (không deploy — bản quyền frame broadcast). Launcher: `run_analyzer_local.bat` ở thư mục cha (Redis + worker + app một lệnh). Từ 13/08 (BG31) mọi toạ độ map được **bù độ cao tâm bi** k=R/(h−R) theo camera decompose từ homography của chính clip (`poolcoach_cv/camera.py`); kết quả mang cờ `height_comp` (on/off + h_m, f_px) — 4 góc phải chấm đúng **mép nose vải**, không chấm miệng lỗ trên mặt gỗ. Dataset synthetic cho net suy ngược (BG26): `scripts/broadcast/` `gen_synth_shots.py` sinh 150k+5k cú pooltool làm bẩn theo nhiễu P0 đo thật (spec tái lập: `scripts/broadcast/synth_spec.json`; data ở `datasets/bb9_synth/`, ngoài git) — `eval_baseline_synth.py` đo baseline giải tích trên held-out làm mốc gate BG27. Net suy ngược **ShotNet** (BG27, P2): `src/poolcoach_cv/shotnet.py` (transformer + shot token + RoPE timestamp thật) train trên 150k synthetic (`scripts/broadcast/train_shotnet.py`, checkpoint + spec: `models/shotnet_*`), chấm cùng harness với baseline (`--predictor shotnet`). Kết quả 12/08: thắng baseline cả 4 chỉ số trên held-out nhưng trượt bar tuyệt đối P2 — số ở `scripts/experiments/README.md`. ## Stack - [`pooltool`](https://pooltool.readthedocs.io) — mô phỏng billiards có vật lý spin - [Gymnasium](https://gymnasium.farama.org/) — chuẩn hoá môi trường RL - [Stable-Baselines3](https://stable-baselines3.readthedocs.io) — thuật toán (PPO / SAC), phần train - FastAPI + uvicorn — BE; canvas thuần không framework — FE ## Cài đặt (uv — khuyến nghị) ```bash # 0. Cài uv (một lần duy nhất) pip install uv # 1. Tạo/kích hoạt venv uv venv poolcoach-env # hoặc dùng venv sẵn có poolcoach-env\Scripts\activate # Windows # source poolcoach-env/bin/activate # Linux/macOS # 2. CORE: pooltool + gymnasium (~100MB) uv pip install -r requirements.txt --index-strategy unsafe-best-match # 3. APP: fastapi + uvicorn (web app) uv pip install -r requirements-app.txt # 4. RL (chỉ cần cho train): SB3 + torch (~200MB) uv pip install -r requirements-rl.txt # 5. DEV (chạy test): pytest + httpx uv pip install -r requirements-dev.txt ``` > `--index-strategy unsafe-best-match` bắt buộc với uv: panda3d có bản stable > trên PyPI nhưng bản dev 1.11 chỉ có trên `archive.panda3d.org`, flag này cho > uv nhìn cả hai kho. Với pip thì không cần. Kiểm tra: ```bash python -c "import pooltool; print(pooltool.__version__)" # -> 0.6.0 python -m pytest tests -q # contract test, ~5s (không cần vật lý thật) python examples/pooltool_demo.py # lần ĐẦU chậm (~40s Numba JIT), sau nhanh ``` ## Cấu trúc ``` poolcoach-rl/ ├── app/ # FastAPI BE + FE canvas (static/) ├── src/poolcoach_rl/ │ ├── envs/ # môi trường RL (hồ sơ nghiên cứu) │ └── recommend/ # engine ZonePlanner V2 + tầng sim/luật │ ├── simulate.py # facts vật lý (sim tất định) │ ├── rules.py # luật 9-ball + Q/EV │ ├── geometry.py # chọn lỗ khả thi, đường bị chắn │ ├── zone_v2.py # máy lọc + xếp hạng (design §8) │ └── core.py # recommend_v2 + validate + describe ├── scripts/ # eval/train/check + experiments/ (bản lưu) ├── tests/ # contract test (stub pooltool, ~1s) └── Dockerfile # HF Spaces (torch CPU + detector + ShotNet) ``` ## Deploy Hugging Face Spaces (Docker SDK) — push `main` lên remote `space` là build. Chi tiết: `Documents/PoolCoach_Deploy_HFSpaces.md` (repo poolcoach-docs). Từ **14/08/2026** image chạy được cả **Analyzer** trên CPU, trong **một tiến trình**: `POOLCOACH_LOCAL_CV=1` bật mode `local` — transport chạy fakeredis trong tiến trình + một luồng gọi đúng hàm của `scripts/cv_worker.py` (`app/localcv.py`), còn `/api/recommend` vẫn in-process như bản không queue. Không Redis, không worker rời, không GPU. Chạy y hệt bản deploy trên máy mình (giả lập Space): ``` D:\Khoa luan\run_app_local.bat # POOLCOACH_LOCAL_CV=1 + device cpu ``` Kèm image một **video mẫu đã phân tích sẵn** (`app/demo_analyzer/`, xem README trong đó): mở app là bảng danh sách cú hiện ngay, không chờ phân tích — trên CPU một VOD 5 phút mất hàng chục phút, không ai đợi được. File cần có trong image (đều bị `.gitignore` chặn theo mặc định — force-add hoặc khai ngoại lệ): `models/cv_full_20260805/best.pt` (detector), `models/shotnet_20260812_c4b/best.pt` (LFS, đã track), và `app/demo_analyzer/**` (video mẫu; `.gitignore` có dòng ngoại lệ cho `*.mp4` ở đúng thư mục này).