poolcoach / README.md
masterdanh's picture
deploy: snapshot for HF Space
78738de
|
Raw
History Blame Contribute Delete
22.7 kB
---
title: PoolCoach
emoji: 🎱
colorFrom: green
colorTo: gray
sdk: docker
app_port: 7860
---
# PoolCoach
<!-- Khối YAML trên là metadata BẮT BUỘC của Hugging Face Spaces (SDK docker).
GitHub render nó thành bảng nhỏ, vô hại. Xem Documents/PoolCoach_Deploy_HFSpaces.md §3. -->
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=<token>`) 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).