Hoang Duc Hung commited on
Commit ·
350d731
1
Parent(s): 56251f6
feat: stabilize push-up feedback and VLM handling
Browse files- .env.example +5 -2
- README.md +65 -18
- docs/LLM_FEEDBACK_NVIDIA_LANGGRAPH.md +57 -159
- docs/Migrating Agent to OpenAI.md +0 -750
- docs/README.md +47 -0
- docs/SETUP_NOTES.md +73 -25
- docs/UI_REDESIGN.md +70 -18
- docs/implementation_plan.md +52 -75
- docs/legacy_agent_notes.md +22 -0
- docs/problem_statement.md +37 -4
- push_up/analysis_service.py +381 -12
- push_up/feedback_graph.py +563 -0
- reflex_frontend/state.py +24 -3
- reflex_frontend/ui.py +86 -7
- requirements.txt +2 -1
- scripts/log_hook.py +134 -6
- scripts/run_pushup_eval_tests.py +255 -53
.env.example
CHANGED
|
@@ -2,8 +2,11 @@
|
|
| 2 |
# ANTHROPIC_API_KEY=sk-ant-...
|
| 3 |
# OPENAI_API_KEY=sk-...
|
| 4 |
GEMINI_API_KEY=...
|
| 5 |
-
NVIDIA_API_KEY=nvapi-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
# Default model
|
| 9 |
# DEFAULT_MODEL=claude-sonnet-4-20250514
|
|
|
|
| 2 |
# ANTHROPIC_API_KEY=sk-ant-...
|
| 3 |
# OPENAI_API_KEY=sk-...
|
| 4 |
GEMINI_API_KEY=...
|
| 5 |
+
NVIDIA_API_KEY=nvapi-your-real-nvidia-key
|
| 6 |
+
NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
| 7 |
+
NVIDIA_MODEL=deepseek-ai/deepseek-v4-flash
|
| 8 |
+
NVIDIA_VISION_MODEL=meta/llama-3.2-11b-vision-instruct
|
| 9 |
+
NVIDIA_VISION_MAX_TOKENS=1200
|
| 10 |
|
| 11 |
# Default model
|
| 12 |
# DEFAULT_MODEL=claude-sonnet-4-20250514
|
README.md
CHANGED
|
@@ -1,27 +1,74 @@
|
|
| 1 |
-
# AI
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
##
|
| 6 |
-
- Trích xuất khung xương 3D (33 điểm) và bàn tay bằng MediaPipe Holistic.
|
| 7 |
-
- Đồng bộ hóa động tác giữa người dùng và chuyên gia bằng thuật toán DTW.
|
| 8 |
-
- Tính toán góc sinh cơ học (Biomechanics) để phát hiện lỗi sai.
|
| 9 |
-
- Video Overlay: Chồng hình và khoanh đỏ vị trí lỗi trực quan.
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
| 15 |
pip install -r requirements.txt
|
| 16 |
```
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
```
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AI Coach - Push-up Video Analysis
|
| 2 |
|
| 3 |
+
Ung dung Reflex phan tich bai tap hit dat tu video. He thong dung MediaPipe Pose de trich xuat landmark, rule engine de phat hien loi ky thuat, DTW de so sanh voi video mau, va tuy chon NVIDIA VLM de sinh feedback chu bang tieng Viet.
|
| 4 |
|
| 5 |
+
## Tinh Nang
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
- Upload video hoc vien va phan tich theo tung rep.
|
| 8 |
+
- So sanh voi video mau `data/templates/push_up_template.mp4`.
|
| 9 |
+
- Cham diem tong quan va diem tung rep.
|
| 10 |
+
- Phat hien loi rule-based:
|
| 11 |
+
- Chua ha nguoi du sau.
|
| 12 |
+
- Vong lung.
|
| 13 |
+
- Co the chua giu thang.
|
| 14 |
+
- Gap co hoac cui dau qua muc.
|
| 15 |
+
- Nho mong qua cao.
|
| 16 |
+
- Ve arrow vao vi tri loi dua tren landmark deterministic.
|
| 17 |
+
- Hien thi feedback rule-based va optional feedback tu VLM.
|
| 18 |
+
- Batch test tat ca video trong `data/tests` va xuat file JSON tong hop.
|
| 19 |
|
| 20 |
+
## Cai Dat
|
| 21 |
+
|
| 22 |
+
```powershell
|
| 23 |
+
python -m venv .venv
|
| 24 |
+
.\.venv\Scripts\Activate.ps1
|
| 25 |
pip install -r requirements.txt
|
| 26 |
```
|
| 27 |
|
| 28 |
+
## Chay App
|
| 29 |
+
|
| 30 |
+
```powershell
|
| 31 |
+
reflex run
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
Mo frontend tai:
|
| 35 |
+
|
| 36 |
+
```text
|
| 37 |
+
http://localhost:3000/
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
## Cau Hinh VLM Tuy Chon
|
| 41 |
+
|
| 42 |
+
Copy `.env.example` thanh `.env` va dien NVIDIA API key:
|
| 43 |
+
|
| 44 |
+
```env
|
| 45 |
+
NVIDIA_API_KEY=nvapi-your-real-nvidia-key
|
| 46 |
+
NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
| 47 |
+
NVIDIA_MODEL=deepseek-ai/deepseek-v4-flash
|
| 48 |
+
NVIDIA_VISION_MODEL=meta/llama-3.2-11b-vision-instruct
|
| 49 |
+
NVIDIA_VISION_MAX_TOKENS=1200
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
Neu khong co API key, app van chay rule-based analysis binh thuong.
|
| 53 |
+
|
| 54 |
+
## Chay Batch Test
|
| 55 |
+
|
| 56 |
+
```powershell
|
| 57 |
+
python scripts\run_pushup_eval_tests.py
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
Luu artifact anh va arrow:
|
| 61 |
+
|
| 62 |
+
```powershell
|
| 63 |
+
python scripts\run_pushup_eval_tests.py --save-artifacts
|
| 64 |
```
|
| 65 |
|
| 66 |
+
Bat VLM trong batch test:
|
| 67 |
+
|
| 68 |
+
```powershell
|
| 69 |
+
python scripts\run_pushup_eval_tests.py --save-artifacts --enable-vlm
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
## Tai Lieu
|
| 73 |
+
|
| 74 |
+
Xem `docs/README.md` de doc map va giai thich chi tiet luong hoat dong hien tai.
|
docs/LLM_FEEDBACK_NVIDIA_LANGGRAPH.md
CHANGED
|
@@ -1,201 +1,99 @@
|
|
| 1 |
-
# NVIDIA
|
| 2 |
|
| 3 |
-
##
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
Current core stays deterministic:
|
| 8 |
|
| 9 |
```text
|
| 10 |
-
|
|
|
|
| 11 |
```
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
```text
|
| 16 |
-
analysis result JSON -> LangGraph feedback graph -> Vietnamese coaching text
|
| 17 |
-
```
|
| 18 |
|
| 19 |
-
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
Default model:
|
| 24 |
-
|
| 25 |
-
```text
|
| 26 |
-
nvidia/nvidia-nemotron-nano-9b-v2
|
| 27 |
-
```
|
| 28 |
-
|
| 29 |
-
Reason:
|
| 30 |
-
|
| 31 |
-
- The task is short Vietnamese coaching text generation from structured JSON, not long visual reasoning.
|
| 32 |
-
- Low latency matters because feedback is generated inside the Reflex request flow.
|
| 33 |
-
- It is strong enough for instruction following and agentic workflows.
|
| 34 |
-
- Cost/latency is more appropriate than larger models for this use case.
|
| 35 |
-
|
| 36 |
-
Upgrade option:
|
| 37 |
-
|
| 38 |
-
```text
|
| 39 |
-
nvidia/nemotron-3-nano-30b-a3b
|
| 40 |
-
```
|
| 41 |
-
|
| 42 |
-
Use this if the 9B model produces weak Vietnamese, ignores formatting, or needs stronger reasoning across many rep-level errors.
|
| 43 |
-
|
| 44 |
-
Do not start with a vision-language model. The app already has pose landmarks and error labels; sending frames/video to a VLM would add cost, latency, and privacy risk without improving the core decision logic.
|
| 45 |
-
|
| 46 |
-
## Environment
|
| 47 |
-
|
| 48 |
-
Add these variables:
|
| 49 |
|
| 50 |
```env
|
| 51 |
-
NVIDIA_API_KEY=nvapi-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
```text
|
| 58 |
-
langgraph
|
| 59 |
-
langchain-nvidia-ai-endpoints
|
| 60 |
```
|
| 61 |
|
| 62 |
-
|
| 63 |
|
| 64 |
-
|
| 65 |
|
| 66 |
-
|
| 67 |
-
START
|
| 68 |
-
-> build_evidence
|
| 69 |
-
-> generate_feedback
|
| 70 |
-
-> validate_feedback
|
| 71 |
-
-> END
|
| 72 |
-
```
|
| 73 |
|
| 74 |
-
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
evidence: dict
|
| 80 |
-
feedback: str
|
| 81 |
-
warnings: list[str]
|
| 82 |
-
```
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
Input: full result from `analyze_pushup()`.
|
| 87 |
-
|
| 88 |
-
Output: compact evidence object for the LLM.
|
| 89 |
-
|
| 90 |
-
Keep only:
|
| 91 |
-
|
| 92 |
-
- overall score
|
| 93 |
-
- student rep count
|
| 94 |
-
- expert rep count
|
| 95 |
-
- main errors with count/severity/guidance
|
| 96 |
-
- top 3 weakest reps
|
| 97 |
-
- rep-level score and error labels
|
| 98 |
-
|
| 99 |
-
Do not send image bytes or video paths to the text LLM.
|
| 100 |
-
|
| 101 |
-
### Node: generate_feedback
|
| 102 |
-
|
| 103 |
-
Use `ChatNVIDIA` and a strict prompt.
|
| 104 |
-
|
| 105 |
-
Prompt rules:
|
| 106 |
-
|
| 107 |
-
- Vietnamese only.
|
| 108 |
-
- Maximum 120-150 words.
|
| 109 |
-
- Do not invent errors outside `evidence`.
|
| 110 |
-
- Mention the main repeated error first.
|
| 111 |
-
- Include one correction cue for the next set.
|
| 112 |
-
- Include one drill.
|
| 113 |
-
- If no errors, encourage maintenance and tempo control.
|
| 114 |
-
|
| 115 |
-
### Node: validate_feedback
|
| 116 |
-
|
| 117 |
-
Fast deterministic validation:
|
| 118 |
-
|
| 119 |
-
- If empty, use fallback summary.
|
| 120 |
-
- If too long, trim or ask model to rewrite shorter.
|
| 121 |
-
- If it mentions an error not in `main_errors` or `rep_results`, discard and use fallback.
|
| 122 |
-
|
| 123 |
-
## Integration Points
|
| 124 |
-
|
| 125 |
-
Add a new file:
|
| 126 |
|
| 127 |
```text
|
| 128 |
-
|
|
|
|
| 129 |
```
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
```python
|
| 134 |
-
def generate_coach_feedback(analysis_result: dict) -> str:
|
| 135 |
-
...
|
| 136 |
-
```
|
| 137 |
|
| 138 |
-
|
| 139 |
|
| 140 |
-
```
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
```
|
| 143 |
|
| 144 |
-
|
| 145 |
|
| 146 |
-
|
| 147 |
-
coach_feedback: str = ""
|
| 148 |
-
```
|
| 149 |
|
| 150 |
-
|
| 151 |
|
| 152 |
-
|
| 153 |
-
self.coach_feedback = result.get("coach_feedback", "")
|
| 154 |
-
```
|
| 155 |
|
| 156 |
-
|
|
|
|
|
|
|
| 157 |
|
| 158 |
-
##
|
| 159 |
|
| 160 |
-
|
| 161 |
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
-
|
| 165 |
-
Rule engine -> decides error type
|
| 166 |
-
Landmark mapping -> decides arrow target
|
| 167 |
-
OpenCV -> draws arrow
|
| 168 |
-
LLM -> explains how to fix
|
| 169 |
-
```
|
| 170 |
|
| 171 |
-
|
| 172 |
|
| 173 |
-
##
|
| 174 |
|
| 175 |
-
|
| 176 |
|
| 177 |
```powershell
|
| 178 |
-
python scripts
|
| 179 |
```
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
- `template_vs_template`: near-perfect score, no major errors.
|
| 184 |
-
- `hv01_cuoi_dau_thap`: main error should be head misalignment.
|
| 185 |
-
- `hv01_hit_nua_rep`: main error should include not deep enough.
|
| 186 |
-
- `hv01_khong_gong_bung`: main error should include body alignment or hip sag.
|
| 187 |
-
- `hv01_mong_cao`: main error should be hip pike.
|
| 188 |
-
- `hv01_rep_sai_rep_dung`: mixed good and bad reps.
|
| 189 |
-
- `hv01_tap_dung`: no major errors.
|
| 190 |
-
- `hv02_tap_dung`: should auto-flip orientation and produce no major errors.
|
| 191 |
-
- `vo_teakwondo`: should be rejected as not push-up.
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
- detected orientation
|
| 197 |
-
- overall score
|
| 198 |
-
- student/expert rep count
|
| 199 |
-
- per-rep score
|
| 200 |
-
- per-rep errors
|
| 201 |
|
|
|
|
|
|
| 1 |
+
# NVIDIA VLM Feedback
|
| 2 |
|
| 3 |
+
## Vai Tro
|
| 4 |
|
| 5 |
+
VLM la thanh phan phu tro sau khi computer vision va rule engine da phan tich xong. No khong thay the rule engine.
|
|
|
|
|
|
|
| 6 |
|
| 7 |
```text
|
| 8 |
+
rule engine -> loi chinh + diem + arrow target
|
| 9 |
+
VLM -> feedback chu ngan bang tieng Viet
|
| 10 |
```
|
| 11 |
|
| 12 |
+
Trong UI, phan nay duoc hien thi la `Feedback tu VLM`.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
## Provider Va Model
|
| 15 |
|
| 16 |
+
Ung dung dung NVIDIA endpoint OpenAI-compatible:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
```env
|
| 19 |
+
NVIDIA_API_KEY=nvapi-your-real-nvidia-key
|
| 20 |
+
NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
| 21 |
+
NVIDIA_MODEL=deepseek-ai/deepseek-v4-flash
|
| 22 |
+
NVIDIA_VISION_MODEL=meta/llama-3.2-11b-vision-instruct
|
| 23 |
+
NVIDIA_VISION_MAX_TOKENS=1200
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
```
|
| 25 |
|
| 26 |
+
Model text `NVIDIA_MODEL` phuc vu feedback tong quan neu can. Model vision `NVIDIA_VISION_MODEL` nhan anh hoc vien + anh mau de sinh feedback tung rep.
|
| 27 |
|
| 28 |
+
Neu `NVIDIA_API_KEY` thieu hoac bang placeholder, app bo qua VLM va dung fallback rong cho `llm_feedback`.
|
| 29 |
|
| 30 |
+
## Input Cho VLM
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
+
Moi rep loi co the gui:
|
| 33 |
|
| 34 |
+
- Anh hoc vien da ve pose landmark.
|
| 35 |
+
- Anh mau da ve pose landmark.
|
| 36 |
+
- Rule context gom rep number, score, rule errors, severity, guidance.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
+
Anh duoc ghep ngang trong `push_up.feedback_graph._make_comparison_image()`:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
```text
|
| 41 |
+
left = student rep
|
| 42 |
+
right = mentor/template rep
|
| 43 |
```
|
| 44 |
|
| 45 |
+
## Output Mong Doi
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
+
VLM phai tra ve mot JSON object ngan:
|
| 48 |
|
| 49 |
+
```json
|
| 50 |
+
{
|
| 51 |
+
"is_error": true,
|
| 52 |
+
"visual_error_label": "Nhô mông quá cao",
|
| 53 |
+
"diagnosis": "Học viên đẩy hông lên cao trong rep này.",
|
| 54 |
+
"correction": "Giữ vai và hông di chuyển cùng nhau.",
|
| 55 |
+
"feedback": "Học viên đẩy hông lên cao trong rep này. Giữ vai và hông di chuyển cùng nhau."
|
| 56 |
+
}
|
| 57 |
```
|
| 58 |
|
| 59 |
+
VLM khong tra ve arrow. Arrow duoc tinh deterministic tu landmark trong `analysis_service`.
|
| 60 |
|
| 61 |
+
## Ly Do Khong Dung VLM Cho Arrow
|
|
|
|
|
|
|
| 62 |
|
| 63 |
+
Thu nghiem thuc te cho thay VLM co the tra `x/y` hop le nhung diem do nam tren nen anh, ghe, san tap, hoac vung khong phai co the. Voi ung dung coaching, arrow sai vi tri gay nham lan hon la khong co arrow.
|
| 64 |
|
| 65 |
+
Vi vay:
|
|
|
|
|
|
|
| 66 |
|
| 67 |
+
- Rule engine quyet dinh `primary_error`.
|
| 68 |
+
- Landmark cua frame loi quyet dinh target arrow.
|
| 69 |
+
- VLM chi bo sung cau feedback de nguoi dung de hieu hon.
|
| 70 |
|
| 71 |
+
## Validation Va Fallback
|
| 72 |
|
| 73 |
+
`feedback_graph.py` co cac lop bao ve:
|
| 74 |
|
| 75 |
+
- Bo qua VLM neu thieu API key.
|
| 76 |
+
- Retry mot lan neu response khong parse duoc JSON.
|
| 77 |
+
- Phat hien response bi cat do `finish_reason == "length"`.
|
| 78 |
+
- Trim feedback qua dai.
|
| 79 |
+
- Fallback ve feedback rong neu VLM loi, de rule-based feedback van hien thi.
|
| 80 |
|
| 81 |
+
## Ten Truong Payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
+
Payload hien tai van giu ten `llm_feedback`, `llm_feedback_source`, `llm_feedback_error` de tranh refactor rong. Ve mat y nghia san pham, day la VLM feedback. UI da hien thi la `Feedback tu VLM`.
|
| 84 |
|
| 85 |
+
## Cach Test
|
| 86 |
|
| 87 |
+
Mac dinh batch test tat VLM:
|
| 88 |
|
| 89 |
```powershell
|
| 90 |
+
python scripts\run_pushup_eval_tests.py
|
| 91 |
```
|
| 92 |
|
| 93 |
+
Bat VLM that:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
+
```powershell
|
| 96 |
+
python scripts\run_pushup_eval_tests.py --save-artifacts --enable-vlm
|
| 97 |
+
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
+
Khi bat VLM, can dam bao `.env` co `NVIDIA_API_KEY` hop le.
|
docs/Migrating Agent to OpenAI.md
DELETED
|
@@ -1,750 +0,0 @@
|
|
| 1 |
-
# Chat Conversation
|
| 2 |
-
|
| 3 |
-
Note: _This is purely the output of the chat conversation and does not contain any raw data, codebase snippets, etc. used to generate the output._
|
| 4 |
-
|
| 5 |
-
### User Input
|
| 6 |
-
|
| 7 |
-
update src/agent.py to use OpenAI API
|
| 8 |
-
|
| 9 |
-
*Viewed [agent.py](file:///home/fed/ai/projec/A20-App-032/src/agent.py) *
|
| 10 |
-
|
| 11 |
-
*Viewed [config.py](file:///home/fed/ai/projec/A20-App-032/src/config.py) *
|
| 12 |
-
|
| 13 |
-
*Viewed [tools.py](file:///home/fed/ai/projec/A20-App-032/src/tools.py) *
|
| 14 |
-
|
| 15 |
-
*Edited relevant file*
|
| 16 |
-
|
| 17 |
-
*Edited relevant file*
|
| 18 |
-
|
| 19 |
-
*Edited relevant file*
|
| 20 |
-
|
| 21 |
-
### Planner Response
|
| 22 |
-
|
| 23 |
-
I have updated `src/agent.py` to use the OpenAI API instead of the Anthropic API.
|
| 24 |
-
|
| 25 |
-
In addition to `src/agent.py`, I proactively updated the following files to align with the new OpenAI integration:
|
| 26 |
-
1. `src/tools.py` – Updated the `get_tool_schemas` function to successfully construct the tool payloads in OpenAI's precise `{"type": "function", ...}` JSON schema format.
|
| 27 |
-
2. `src/config.py` – Adjusted the app's default model to use `gpt-4o` rather than `claude-sonnet-4-20250514`.
|
| 28 |
-
|
| 29 |
-
### Key differences handled:
|
| 30 |
-
- Switched the Anthropic client pattern over to use `OpenAI()` instances.
|
| 31 |
-
- Re-architected `run_agent_loop` to use `response.choices[0].message.tool_calls` for tracking required tool runs, decoding the arguments correctly, and mapping those tool results back to the messages list properly (`{"role": "tool", "tool_call_id": id, "content": ...}`).
|
| 32 |
-
- Pushed the `SYSTEM_PROMPT` inside the `messages` array under the `{"role": "system"}` property as expected by OpenAI APIs.
|
| 33 |
-
|
| 34 |
-
Make sure you have `openai` installed (`pip install openai`) and that you add `OPENAI_API_KEY` to your local `.env`! Let me know if you want me to write tests or interact with the loop itself.
|
| 35 |
-
|
| 36 |
-
### User Input
|
| 37 |
-
|
| 38 |
-
đọc yêu cầu từ problem_statement.md và lên kế họach xây dựng phần mềm
|
| 39 |
-
|
| 40 |
-
*Viewed [problem_statement.md](file:///home/fed/ai/projec/A20-App-032/problem_statement.md) *
|
| 41 |
-
|
| 42 |
-
*Edited relevant file*
|
| 43 |
-
|
| 44 |
-
### Planner Response
|
| 45 |
-
|
| 46 |
-
Chào bạn, mình đã đọc kĩ yêu cầu dự án trong file `problem_statement.md` và đã xây dựng một kế hoạch triển khai chi tiết cho phần mềm "Huấn luyện viên thể thao AI" (AI Sports Coach).
|
| 47 |
-
|
| 48 |
-
Mời bạn xem chi tiết Kế hoạch triển khai (bao gồm cả kiến trúc luồng dữ liệu và lộ trình phát triển) tại artifact đính kèm dưới đây:
|
| 49 |
-
[implementation_plan.md](file:///home/fed/.gemini/antigravity/brain/3368571a-8f93-438f-bc63-09a787991718/implementation_plan.md)
|
| 50 |
-
|
| 51 |
-
### Một số Decision (quyết định thiết kế) quan trọng:
|
| 52 |
-
1. **Đồng bộ hóa Video (Synchronization)**: Vì sinh viên và mẫu chuyên nghiệp hiếm khi thực hiện động tác ở cùng một tốc độ, chúng ta cần một thuật toán gióng hàng frame (ví dụ như Dynamic Time Warping - DTW) chứ không thể so sánh `Frame-to-Frame` đơn thuần để tránh báo sai góc độ.
|
| 53 |
-
2. **Overlay Video vs Draw Landmark**: Thay vì chỉ vẽ lên khung xương (stick-figure), chúng ta sẽ có **Alpha Blending** chồng 2 luồng video và highlight phần lỗi cụ thể (như vùng cánh tay đang đánh sai) để mang tính hình ảnh và thiết thực với người dùng Taekwondo hơn.
|
| 54 |
-
3. **Streamlit (Mobile Responsive)**: Mặc dù Streamlit sinh ra cho Web App, một số thủ thuật CSS nội bộ (internal CSS hacks với `st.markdown`) sẽ được thực hiện để bố cục layout được gọn nhẹ cho màn hình điện thoại, nơi sinh viên thực sự sẽ xem và tự tập luyện.
|
| 55 |
-
|
| 56 |
-
Bạn có muốn mình bắt đầu khởi tạo cấu trúc thư mục và viết script cho giai đoạn 1 – **Trích xuất dữ liệu mẫu (Reference Data Extraction)** luôn không?
|
| 57 |
-
|
| 58 |
-
### User Input
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
*Viewed [implementation_plan.md](file:///home/fed/.gemini/antigravity/brain/3368571a-8f93-438f-bc63-09a787991718/implementation_plan.md) *
|
| 63 |
-
|
| 64 |
-
*Edited relevant file*
|
| 65 |
-
|
| 66 |
-
### Planner Response
|
| 67 |
-
|
| 68 |
-
Đồng ý, phân tích các đòn bài quyền trong Taekwondo thì cấu hình của bàn tay (ví dụ như nắm đấm - vươn chưởng/cạnh tay) là cực kỳ quan trọng.
|
| 69 |
-
|
| 70 |
-
Mình đã cập nhật lại artifact `implementation_plan.md` để bổ sung xử lý **bàn tay**:
|
| 71 |
-
- Chuyển từ việc chỉ sử dụng BlazePose sang sử dụng kết hợp **MediaPipe Holistic (hoặc Pose + Hand Tracking)**. Việc này sẽ cho phép thu thập thêm tọa độ các ngón tay/bàn tay, thay vì chỉ dừng lại ở cổ tay.
|
| 72 |
-
- Mở rộng thuật toán sinh cơ học (Biomechanics analysis) để xử lý logic không chỉ các góc lớn (cùi chỏ, vai, hông) mà còn đối chiếu biên độ và **cấu hình tiểu tiết của các khớp ngón tay** để đảm bảo người dùng đang đánh bằng kỹ thuật tay đúng chuẩn.
|
| 73 |
-
|
| 74 |
-
Bạn check lại bản cập nhật trong file [implementation_plan.md](file:///home/fed/.gemini/antigravity/brain/3368571a-8f93-438f-bc63-09a787991718/implementation_plan.md) nhé. Mình có thể đi tiếp sang phần nào ngay bây giờ?
|
| 75 |
-
|
| 76 |
-
### User Input
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
*Viewed [implementation_plan.md](file:///home/fed/.gemini/antigravity/brain/3368571a-8f93-438f-bc63-09a787991718/implementation_plan.md) *
|
| 81 |
-
|
| 82 |
-
*Edited relevant file*
|
| 83 |
-
|
| 84 |
-
### Planner Response
|
| 85 |
-
|
| 86 |
-
Tuyệt vời, đây đều là hai điểm cực kỳ thiết thực để cải thiện trải nghiệm và độ chuẩn xác của công cụ AI này. Mình đã update vào bản kế hoạch theo đúng góp ý của bạn:
|
| 87 |
-
|
| 88 |
-
1. **Xử lý thời điểm bắt đầu trễ:** Bổ sung thuật toán *Start Frame Detection / Keypose Matching*. Chúng ta sẽ quét qua các frame đầu tiên để dò xem người dùng đã thực sự vào thế tay chuẩn bị của bài quyền hay chưa, sau đó mới kích hoạt đồng bộ hóa DTW để so sánh. Việc này sẽ giải quyết hoàn toàn trường hợp sinh viên bấm máy quay nhưng đứng chờ một lúc mới bắt đầu diễn.
|
| 89 |
-
2. **Khoanh viền đỏ phần lỗi trực quan:** Cập nhật yêu cầu module Video Overlay. Thay vì chỉ in ra text đơn thuần, khi phát hiện độ lệch góc của một bộ phận (như Tay/Chân) vượt quá ngưỡng cho phép, thuật toán OpenCV sẽ vẽ trực tiếp một vòng khoanh đỏ lên vị trí khớp xương đó (dựa vào tọa độ XY của xương qua frame tương ứng). Điều này giúp sinh viên lập tức biết họ cần sửa ở đâu.
|
| 90 |
-
|
| 91 |
-
Bạn xem lại artifact đã được làm mới tại đây nhé: [implementation_plan.md](file:///home/fed/.gemini/antigravity/brain/3368571a-8f93-438f-bc63-09a787991718/implementation_plan.md)
|
| 92 |
-
|
| 93 |
-
Bạn có đồng ý chốt kế hoạch này để chúng ta bắt đầu cài đặt thư viện và setup **Giai đoạn 1** không?
|
| 94 |
-
|
| 95 |
-
### User Input
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
### User Input
|
| 100 |
-
|
| 101 |
-
tiếp tục
|
| 102 |
-
|
| 103 |
-
*User accepted the command `python3 --version`*
|
| 104 |
-
|
| 105 |
-
*User accepted the command `bash scripts/setup_hooks.sh`*
|
| 106 |
-
|
| 107 |
-
*Edited relevant file*
|
| 108 |
-
|
| 109 |
-
*User accepted the command `mkdir -p src/core src/data/reference_vids`*
|
| 110 |
-
|
| 111 |
-
*Edited relevant file*
|
| 112 |
-
|
| 113 |
-
*Edited relevant file*
|
| 114 |
-
|
| 115 |
-
*Edited relevant file*
|
| 116 |
-
|
| 117 |
-
*Edited relevant file*
|
| 118 |
-
|
| 119 |
-
*Edited relevant file*
|
| 120 |
-
|
| 121 |
-
*Edited relevant file*
|
| 122 |
-
|
| 123 |
-
*Edited relevant file*
|
| 124 |
-
|
| 125 |
-
*User accepted the command `ls -R src/`*
|
| 126 |
-
|
| 127 |
-
### Planner Response
|
| 128 |
-
|
| 129 |
-
Mình đã hoàn thành việc khởi tạo nền tảng cho dự án **AI Sports Coach (Taekwondo)** dựa trên kế hoạch đã được phê duyệt.
|
| 130 |
-
|
| 131 |
-
### Các công việc đã thực hiện:
|
| 132 |
-
1. **Cấu trúc thư mục**: Thiết lập các module lõi (`core`) và thư mục chứa dữ liệu mẫu (`data/reference_vids`).
|
| 133 |
-
2. **Core AI (`src/core/pose.py`)**: Sử dụng **MediaPipe Holistic** để trích xuất đồng thời 33 điểm khung xương Pose và chi tiết các khớp bàn tay/ngón tay (theo yêu cầu bổ sung của bạn).
|
| 134 |
-
3. **Biomechanics Engine (`src/core/compare.py`)**:
|
| 135 |
-
- Triển khai hàm tính toán góc giữa các khớp (Vai, Cùi chỏ, Hông, Đầu gối).
|
| 136 |
-
- Tích hợp thuật toán **DTW (Dynamic Time Warping)** để đồng bộ hóa chuỗi động tác của sinh viên và người mẫu ngay cả khi tốc độ thực hiện khác nhau.
|
| 137 |
-
4. **Overlay Engine (`src/core/overlay.py`)**:
|
| 138 |
-
- Hỗ trợ trộn 2 luồng video (Alpha blending).
|
| 139 |
-
- Có chức năng `draw_error_highlight` để **khoanh vùng màu đỏ** tại các vị trí khớp xương bị lỗi (theo yêu cầu bổ sung của bạn).
|
| 140 |
-
5. **Giao diện người dùng (`src/app.py`)**: Xây dựng UI bằng **Streamlit** với CSS tùy chỉnh để tối ưu cho trải nghiệm di động, hỗ trợ upload video và hiển thị kết quả phân tích.
|
| 141 |
-
6. **Tài liệu & Cài đặt**: Tạo file `requirements.txt` và `README.md` hướng dẫn chi tiết cách chạy ứng dụng.
|
| 142 |
-
|
| 143 |
-
### Bước tiếp theo:
|
| 144 |
-
- Bạn có thể chuẩn bị một video thực hiện bài quyền mẫu (Taegeuk) và đặt vào `src/data/reference_vids/`.
|
| 145 |
-
- Sau đó, mình sẽ hỗ trợ bạn chạy script `src/core/preprocess_reference.py` để trích xuất "mã gene chuyển động" chuẩn cho ứng dụng đối chiếu.
|
| 146 |
-
|
| 147 |
-
Để bắt đầu, bạn có thể cài đặt thư viện bằng lệnh:
|
| 148 |
-
```bash
|
| 149 |
-
pip install -r requirements.txt
|
| 150 |
-
```
|
| 151 |
-
Và chạy ứng dụng bằng:
|
| 152 |
-
```bash
|
| 153 |
-
streamlit run src/app.py
|
| 154 |
-
```
|
| 155 |
-
|
| 156 |
-
Bạn có muốn mình demo cách viết thêm logic nhận diện cấu hình bàn tay (ví dụ: đòn đấm nắm đấm chặt hay lỏng) vào module `compare.py` không?
|
| 157 |
-
|
| 158 |
-
### User Input
|
| 159 |
-
|
| 160 |
-
2026-04-12 18:09:02.276 Uncaught app execution
|
| 161 |
-
Traceback (most recent call last):
|
| 162 |
-
File "/home/fed/ai/projec/A20-App-032/venv/lib64/python3.14/site-packages/streamlit/runtime/scriptrunner/exec_code.py", line 129, in exec_func_with_error_handling
|
| 163 |
-
result = func()
|
| 164 |
-
File "/home/fed/ai/projec/A20-App-032/venv/lib64/python3.14/site-packages/streamlit/runtime/scriptrunner/script_runner.py", line 689, in code_to_exec
|
| 165 |
-
exec(code, module.__dict__) # noqa: S102
|
| 166 |
-
~~~~^^^^^^^^^^^^^^^^^^^^^^^
|
| 167 |
-
File "/home/fed/ai/projec/A20-App-032/src/app.py", line 7, in <module>
|
| 168 |
-
from src.core.pose import PoseEstimator
|
| 169 |
-
ModuleNotFoundError: No module named 'src'
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
*Viewed [app.py](file:///home/fed/ai/projec/A20-App-032/src/app.py) *
|
| 173 |
-
|
| 174 |
-
*User accepted the command `ls -F src/core/`*
|
| 175 |
-
|
| 176 |
-
*User accepted the command `mv src/app.py ./app.py && touch src/__init__.py src/core/__init__.py src/data/__init__.py`*
|
| 177 |
-
|
| 178 |
-
*Viewed [app.py](file:///home/fed/ai/projec/A20-App-032/app.py) *
|
| 179 |
-
|
| 180 |
-
*Edited relevant file*
|
| 181 |
-
|
| 182 |
-
### Planner Response
|
| 183 |
-
|
| 184 |
-
Lỗi này xảy ra do Streamlit khi chạy lệnh `streamlit run src/app.py` sẽ thêm thư mục `src/` vào danh sách các đường dẫn tìm kiếm (PYTHONPATH), dẫn đến việc nó không tìm thấy module cha tên là `src`.
|
| 185 |
-
|
| 186 |
-
Mình đã xử lý bằng cách:
|
| 187 |
-
1. **Chỉnh sửa cấu trúc dự án**: Di chuyển file `app.py` từ thư mục `src/` ra **thư mục gốc** của dự án. Đây là cách làm chuẩn để Streamlit có thể nhận diện đúng các package bên trong thư mục `src/`.
|
| 188 |
-
2. **Khởi tạo Package**: Thêm các file `__init__.py` cần thiết vào các thư mục để Python hiểu chúng là các module có thể import được.
|
| 189 |
-
3. **Cập nhật tài liệu**: Sửa lại hướng dẫn trong `README.md`.
|
| 190 |
-
|
| 191 |
-
Bây giờ, bạn hãy chạy lại từ thư mục gốc bằng lệnh sau:
|
| 192 |
-
```bash
|
| 193 |
-
streamlit run app.py
|
| 194 |
-
```
|
| 195 |
-
|
| 196 |
-
Lỗi `ModuleNotFoundError` sẽ biến mất và ứng dụng sẽ hoạt động bình thường!
|
| 197 |
-
|
| 198 |
-
### User Input
|
| 199 |
-
|
| 200 |
-
2026-04-12 18:14:43.096 Uncaught app execution
|
| 201 |
-
Traceback (most recent call last):
|
| 202 |
-
File "/home/fed/ai/projec/A20-App-032/venv/lib64/python3.14/site-packages/streamlit/runtime/scriptrunner/exec_code.py", line 129, in exec_func_with_error_handling
|
| 203 |
-
result = func()
|
| 204 |
-
File "/home/fed/ai/projec/A20-App-032/venv/lib64/python3.14/site-packages/streamlit/runtime/scriptrunner/script_runner.py", line 689, in code_to_exec
|
| 205 |
-
exec(code, module.__dict__) # noqa: S102
|
| 206 |
-
~~~~^^^^^^^^^^^^^^^^^^^^^^^
|
| 207 |
-
File "/home/fed/ai/projec/A20-App-032/app.py", line 50, in <module>
|
| 208 |
-
estimator = PoseEstimator()
|
| 209 |
-
File "/home/fed/ai/projec/A20-App-032/src/core/pose.py", line 10, in __init__
|
| 210 |
-
self.mp_holistic = mp.solutions.holistic
|
| 211 |
-
^^^^^^^^^^^^
|
| 212 |
-
AttributeError: module 'mediapipe' has no attribute 'solutions'
|
| 213 |
-
|
| 214 |
-
*User accepted the command `ls -F && pip show mediapipe`*
|
| 215 |
-
|
| 216 |
-
*Checked command status*
|
| 217 |
-
|
| 218 |
-
*User accepted the command `python3 -c "import mediapipe as mp; print(f'Version: {mp.__version__}'); print(f'Has solutions: {hasattr(mp, \"solutions\")}')"`*
|
| 219 |
-
|
| 220 |
-
*Checked command status*
|
| 221 |
-
|
| 222 |
-
*Edited relevant file*
|
| 223 |
-
|
| 224 |
-
*Viewed [pose.py](file:///home/fed/ai/projec/A20-App-032/src/core/pose.py) *
|
| 225 |
-
|
| 226 |
-
*Edited relevant file*
|
| 227 |
-
|
| 228 |
-
*Grep searched codebase*
|
| 229 |
-
|
| 230 |
-
*User accepted the command `./venv/bin/pip install -r requirements.txt`*
|
| 231 |
-
|
| 232 |
-
### Planner Response
|
| 233 |
-
|
| 234 |
-
Lỗi `AttributeError: module 'mediapipe' has no attribute 'solutions'` thường xảy ra do thư viện MediaPipe không tự động nạp các submodule vào namespace chính trong một số môi trường hoặc phiên bản nhất định.
|
| 235 |
-
|
| 236 |
-
Mình đã khắc phục bằng cách cập nhật lại file `src/core/pose.py`:
|
| 237 |
-
- Thay vì gọi trực tiếp `mp.solutions`, mình đã chuyển sang **import đích danh** các module cần thiết (`mediapipe.python.solutions.holistic`, `drawing_utils`, v.v.). Đây là cách tiếp cận an toàn và tường minh hơn, giúp tránh lỗi Attribute trên.
|
| 238 |
-
|
| 239 |
-
Bạn hãy thử chạy lại ứng dụng:
|
| 240 |
-
```bash
|
| 241 |
-
streamlit run app.py
|
| 242 |
-
```
|
| 243 |
-
|
| 244 |
-
Bây giờ hệ thống sẽ khởi tạo được bộ khung xương MediaPipe mà không gặp lỗi này nữa!_
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
### User Input
|
| 248 |
-
|
| 249 |
-
2026-04-12 18:16:30.203 Uncaught app execution
|
| 250 |
-
Traceback (most recent call last):
|
| 251 |
-
File "/home/fed/ai/projec/A20-App-032/venv/lib64/python3.14/site-packages/streamlit/runtime/scriptrunner/exec_code.py", line 129, in exec_func_with_error_handling
|
| 252 |
-
result = func()
|
| 253 |
-
File "/home/fed/ai/projec/A20-App-032/venv/lib64/python3.14/site-packages/streamlit/runtime/scriptrunner/script_runner.py", line 689, in code_to_exec
|
| 254 |
-
exec(code, module.__dict__) # noqa: S102
|
| 255 |
-
~~~~^^^^^^^^^^^^^^^^^^^^^^^
|
| 256 |
-
File "/home/fed/ai/projec/A20-App-032/app.py", line 7, in <module>
|
| 257 |
-
from src.core.pose import PoseEstimator
|
| 258 |
-
File "/home/fed/ai/projec/A20-App-032/src/core/pose.py", line 4, in <module>
|
| 259 |
-
import mediapipe.python.solutions.holistic as mp_holistic
|
| 260 |
-
ModuleNotFoundError: No module named 'mediapipe.python'
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
*User accepted the command `ls -R /home/fed/ai/projec/A20-App-032/venv/lib64/python3.14/site-packages/mediapipe/ | grep solutions`*
|
| 264 |
-
|
| 265 |
-
*User accepted the command `python3 -c "import site; print(site.getsitepackages())"`*
|
| 266 |
-
|
| 267 |
-
*User accepted the command `./venv/bin/python3 -c "import site; print(site.getsitepackages())"`*
|
| 268 |
-
|
| 269 |
-
*User accepted the command `ls -F /home/fed/ai/projec/A20-App-032/venv/lib64/python3.14/site-packages/mediapipe/`*
|
| 270 |
-
|
| 271 |
-
*User accepted the command `ls -F /home/fed/ai/projec/A20-App-032/venv/lib/python3.14/site-packages/mediapipe/`*
|
| 272 |
-
|
| 273 |
-
*Viewed [__init__.py](file:///home/fed/ai/projec/A20-App-032/venv/lib64/python3.14/site-packages/mediapipe/__init__.py) *
|
| 274 |
-
|
| 275 |
-
*User accepted the command `find /home/fed/ai/projec/A20-App-032/venv/ -name holistic.py`*
|
| 276 |
-
|
| 277 |
-
*User accepted the command `./venv/bin/python3 -c "import mediapipe.solutions"`*
|
| 278 |
-
|
| 279 |
-
*Checked command status*
|
| 280 |
-
|
| 281 |
-
*User accepted the command `find /home/fed/ai/projec/A20-App-032/venv/lib64/python3.14/site-packages/mediapipe/ -maxdepth 2`*
|
| 282 |
-
|
| 283 |
-
*User accepted the command `ls -F /home/fed/.local/lib/python3.14/site-packages/mediapipe/`*
|
| 284 |
-
|
| 285 |
-
*User accepted the command `find /home/fed/ai/projec/A20-App-032/venv/ -name "*.task" -o -name "*.tflite"`*
|
| 286 |
-
|
| 287 |
-
*User accepted the command `./venv/bin/python3 -c "import mediapipe; print(dir(mediapipe))"`*
|
| 288 |
-
|
| 289 |
-
*Checked command status*
|
| 290 |
-
|
| 291 |
-
*User accepted the command `./venv/bin/pip install mediapipe --force-reinstall`*
|
| 292 |
-
|
| 293 |
-
*Checked command status*
|
| 294 |
-
|
| 295 |
-
*User accepted the command `./venv/bin/python3 -c "import mediapipe; print(f'Has solutions: {hasattr(mediapipe, \"solutions\")}')"`*
|
| 296 |
-
|
| 297 |
-
*Checked command status*
|
| 298 |
-
|
| 299 |
-
*User accepted the command `ls -F /home/fed/ai/projec/A20-App-032/venv/lib/python3.14/site-packages/mediapipe/`*
|
| 300 |
-
|
| 301 |
-
*User accepted the command `mkdir -p src/data/models && wget -O src/data/models/pose_landmarker.task https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_heavy/float16/1/pose_landmarker_heavy.task && wget -O src/data/models/hand_landmarker.task https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task`*
|
| 302 |
-
|
| 303 |
-
*Checked command status*
|
| 304 |
-
|
| 305 |
-
*Edited relevant file*
|
| 306 |
-
|
| 307 |
-
*User accepted the command `ls -R src/data/models/`*
|
| 308 |
-
|
| 309 |
-
*Edited relevant file*
|
| 310 |
-
|
| 311 |
-
*Edited relevant file*
|
| 312 |
-
|
| 313 |
-
### Planner Response
|
| 314 |
-
|
| 315 |
-
Hiện tại, phiên bản MediaPipe được cài đặt cho Python 3.14 (vốn là phiên bản Python rất mới) dường như đã loại bỏ API `solutions` cũ (Legacy API) và chuyển hoàn toàn sang **MediaPipe Tasks API** mới. Điều này giải thích tại sao bạn gặp lỗi `ModuleNotFoundError` dù đã cài đặt thư viện thành công.
|
| 316 |
-
|
| 317 |
-
Mình đã tiến hành một đợt nâng cấp hệ thống để tương thích hoàn toàn với API mới này:
|
| 318 |
-
|
| 319 |
-
1. **Chuyển đổi API (`src/core/pose.py`)**: Mình đã viết lại toàn bộ lớp `PoseEstimator` để sử dụng `mediapipe.tasks`.
|
| 320 |
-
- Sử dụng `PoseLandmarker` cho khung xương.
|
| 321 |
-
- Sử dụng `HandLandmarker` cho bàn tay.
|
| 322 |
-
- Vẫn giữ nguyên cấu trúc đầu ra (`Pose`, `Left_hand`, `Right_hand`) để không làm hỏng các phần so sánh và overlay đã viết trước đó.
|
| 323 |
-
2. **Tải Model AI**: Vì API mới yêu cầu file model vật lý (không tự tải như bản cũ), mình đã tự động tải về các file model chuẩn từ Google:
|
| 324 |
-
- `pose_landmarker.task` (bản Heavy để đạt độ chính xác cao nhất cho Taekwondo).
|
| 325 |
-
- `hand_landmarker.task`.
|
| 326 |
-
- Các file này được lưu tại `src/data/models/`.
|
| 327 |
-
3. **Tự trình vẽ (Custom Drawing)**: Do `mp.solutions.drawing_utils` cũng không còn khả dụng, mình đã viết thêm logic vẽ khung xương và bàn tay trực tiếp bằng OpenCV bên trong lớp `PoseEstimator`.
|
| 328 |
-
|
| 329 |
-
Giờ đây, bạn có thể chạy lại ứng dụng:
|
| 330 |
-
```bash
|
| 331 |
-
streamlit run app.py
|
| 332 |
-
```
|
| 333 |
-
|
| 334 |
-
Code sẽ hoạt động ổn định trên môi trường Python 3.14 hiện tại của bạn! Nếu có bất kỳ lỗi nhỏ nào về tọa độ, hãy cho mình biết nhé.
|
| 335 |
-
|
| 336 |
-
### User Input
|
| 337 |
-
|
| 338 |
-
bỏ mục chọn bài mẫu, yêu cầu người dùng upload 1 file video mẫu và 1 file video dùng làm so sánh
|
| 339 |
-
|
| 340 |
-
*Viewed [app.py](file:///home/fed/ai/projec/A20-App-032/app.py) *
|
| 341 |
-
|
| 342 |
-
*Edited relevant file*
|
| 343 |
-
|
| 344 |
-
### Planner Response
|
| 345 |
-
|
| 346 |
-
Mình đã cập nhật xong `app.py`. Bạn có thể kiểm tra các thay đổi mới nhất như sau:
|
| 347 |
-
|
| 348 |
-
1. **Giao diện linh hoạt (Dynamic Upload)**:
|
| 349 |
-
- Mình đã loại bỏ danh sách bài quyền mẫu cố định ở Sidebar.
|
| 350 |
-
- Thay vào đó là 2 khu vực Upload chuyên biệt: một cho **Video Mẫu (Chuyên gia)** và một cho **Video Của Bạn**. Điều này cho phép ứng dụng so sánh bất kỳ bài quyền hoặc đòn đánh nào mà bạn muốn, không giới hạn trong dữ liệu có sẵn.
|
| 351 |
-
2. **Thiết kế cao cấp (Premium UI)**:
|
| 352 |
-
- Chuyển sang Dark Mode làm chủ đạo với các Card Upload được thiết kế bóng bẩy.
|
| 353 |
-
- Nút "BẮT ĐẦU PHÂN TÍCH" được thiết kế với hiệu ứng Gradient và tương tác Hover sống động.
|
| 354 |
-
3. **Quy trình xử lý song song**:
|
| 355 |
-
- Ứng dụng sẽ trích xuất khung xương của cả hai video tuần tự và sử dụng thuật toán **BiomechanicsMatcher** kết hợp **DTW** để gióng hàng thời gian giữa chúng.
|
| 356 |
-
- Trạng thái xử lý được hiển thị chuyên nghiệp thông qua `st.status`.
|
| 357 |
-
|
| 358 |
-
Bây giờ bạn có thể mở ứng dụng và tải lên 2 video để bắt đầu trải nghiệm nhé!
|
| 359 |
-
```bash
|
| 360 |
-
streamlit run app.py
|
| 361 |
-
```
|
| 362 |
-
|
| 363 |
-
### User Input
|
| 364 |
-
|
| 365 |
-
tiến hành giai đọan 2 trong implementation_plan.md
|
| 366 |
-
|
| 367 |
-
*Viewed [compare.py](file:///home/fed/ai/projec/A20-App-032/src/core/compare.py) *
|
| 368 |
-
|
| 369 |
-
*Edited relevant file*
|
| 370 |
-
|
| 371 |
-
*Edited relevant file*
|
| 372 |
-
|
| 373 |
-
*Viewed [compare.py](file:///home/fed/ai/projec/A20-App-032/src/core/compare.py) *
|
| 374 |
-
|
| 375 |
-
*Edited relevant file*
|
| 376 |
-
|
| 377 |
-
### Planner Response
|
| 378 |
-
|
| 379 |
-
Mình đã hoàn thành việc nâng cấp **Giai đoạn 2** trong kế hoạch triển khai, tập trung vào xây dựng bộ phân tích sinh cơ học (Biomechanics Engine) chuyên sâu cho Taekwondo.
|
| 380 |
-
|
| 381 |
-
### Các cải tiến quan trọng đã thực hiện trong `src/core/compare.py`:
|
| 382 |
-
|
| 383 |
-
1. **Phân tích trạng thái bàn tay (`get_hand_state`)**:
|
| 384 |
-
- Không chỉ dừng lại ở khung xương, mình đã thêm logic tính toán độ mở/đóng của bàn tay. Điều này giúp hệ thống nhận diện được sinh viên đang thực hiện đòn đấm (nắm chặt) hay đòn xỉa/chưởng (mở tay) để đối chiếu chính xác với mẫu.
|
| 385 |
-
2. **Chuẩn hóa khung xương (`normalize_pose`)**:
|
| 386 |
-
- Implement hàm chuẩn hóa dữ liệu dựa trên chiều cao thân người và điểm gốc (giữa hông). Điều này giúp việc so sánh không bị ảnh hưởng bởi khoảng cách đứng xa/gần camera hay sự khác biệt về chiều cao giữa người dùng và chuyên gia.
|
| 387 |
-
3. **Thuật toán tìm điểm bắt đầu (`find_start_frame`)**:
|
| 388 |
-
- Hệ thống giờ đây sẽ tự động quét qua những giây đầu tiên của video người dùng để tìm thời điểm họ bắt đầu vào thế giống video mẫu nhất, sau đó mới kích hoạt đồng bộ hóa DTW. Điều này giải quyết vấn đề "chờ đợi" trước khi bắt đầu bài quyền.
|
| 389 |
-
4. **Vector đặc trưng mở rộng**:
|
| 390 |
-
- Tích hợp thêm các góc quan trọng như Góc Hông (Hip angles) và Góc Vai (Shoulder rotation) vào vector đặc trưng để tăng độ chi tiết cho việc tính điểm.
|
| 391 |
-
|
| 392 |
-
Giờ đây, bộ lõi phân tích đã sẵn sàng để xử lý dữ liệu phức tạp hơn và trả về kết quả định lượng (Errors/Score) một cách khoa học.
|
| 393 |
-
|
| 394 |
-
Bạn có muốn mình tiến tới **Giai đoạn 3: Overlay & Visual Correction** (vẽ khoanh đỏ vùng lỗi lên video) không?
|
| 395 |
-
|
| 396 |
-
### User Input
|
| 397 |
-
|
| 398 |
-
tiến tới Giai đoạn 3
|
| 399 |
-
|
| 400 |
-
*Viewed [overlay.py](file:///home/fed/ai/projec/A20-App-032/src/core/overlay.py) *
|
| 401 |
-
|
| 402 |
-
*Edited relevant file*
|
| 403 |
-
|
| 404 |
-
*Edited relevant file*
|
| 405 |
-
|
| 406 |
-
*Edited relevant file*
|
| 407 |
-
|
| 408 |
-
*Edited relevant file*
|
| 409 |
-
|
| 410 |
-
### Planner Response
|
| 411 |
-
|
| 412 |
-
Mình đã hoàn tất **Giai đoạn 3: Phân tích lỗi và Overlay Video (Correction & Result Generation)**. Toàn bộ logic hiển thị trực quan lỗi sai hiện đã được tích hợp hoàn chỉnh:
|
| 413 |
-
|
| 414 |
-
### Các tính năng mới trong Giai đoạn 3:
|
| 415 |
-
|
| 416 |
-
1. **Hệ thống Khoanh vùng Lỗi (Visual Highlighting)**:
|
| 417 |
-
- Trong `src/core/overlay.py`, mình đã xây dựng hàm `create_combined_overlay` tự động ánh xạ (mapping) các chỉ số lỗi sinh cơ học sang đúng tọa độ các khớp xương của MediaPipe.
|
| 418 |
-
- Khi phát hiện sai lệch (ví dụ: góc cùi chỏ lệch > 20 độ), hệ thống sẽ **vẽ một vòng tròn đỏ rực** ngay tại vị trí khớp đó để người dùng nhận biết tức thì.
|
| 419 |
-
2. **HUD (Heads-Up Display) Thông minh**:
|
| 420 |
-
- Video kết quả sẽ có một bảng điều khiển (HUD) ở góc trên bên trái, hiển thị danh sách các cảnh báo cụ thể theo thời gian thực (ví dụ: `! Tay trai: Sai lech 25 deg`).
|
| 421 |
-
3. **Xuất Video Kết quả (`generate_result_video`)**:
|
| 422 |
-
- Mình đã bổ sung trình render video mạnh mẽ. Nó sẽ lấy video của người dùng làm nền, sau đó "chồng" (alpha blend) video mẫu mờ ảo lên trên (hiệu ứng bóng ma - ghost effect) và vẽ thêm khung xương + các cảnh báo lỗi.
|
| 423 |
-
- Video cuối cùng được xuất ra định dạng `.mp4` và hiển thị trực tiếp trên giao diện Streamlit sau khi quá trình phân tích kết thúc.
|
| 424 |
-
|
| 425 |
-
### Cách kiểm tra:
|
| 426 |
-
Bây giờ khi bạn tải lên 2 video và nhấn nút phân tích, bạn sẽ thấy:
|
| 427 |
-
- Một thanh tiến trình chạy qua việc trích xuất AI.
|
| 428 |
-
- Sau đó là quá trình render video overlay.
|
| 429 |
-
- Cuối cùng là một Video kết quả chuyên nghiệp, nơi các lỗi được khoanh đỏ rõ rệt.
|
| 430 |
-
|
| 431 |
-
Bạn hãy thử chạy lệnh:
|
| 432 |
-
```bash
|
| 433 |
-
streamlit run app.py
|
| 434 |
-
```
|
| 435 |
-
|
| 436 |
-
Chúng ta đã đi được 3/5 giai đoạn chính. Bạn có muốn mình tiếp tục với **Giai đoạn 4: Tối ưu UI/UX nâng cao và tích hợp Plugin WebRTC** (nếu bạn muốn quay video trực tiếp bằng Camera) không?
|
| 437 |
-
|
| 438 |
-
### User Input
|
| 439 |
-
|
| 440 |
-
2026-04-12 18:52:29.115 Uncaught app execution
|
| 441 |
-
Traceback (most recent call last):
|
| 442 |
-
File "/home/fed/ai/projec/A20-App-032/venv/lib64/python3.14/site-packages/streamlit/runtime/scriptrunner/exec_code.py", line 129, in exec_func_with_error_handling
|
| 443 |
-
result = func()
|
| 444 |
-
File "/home/fed/ai/projec/A20-App-032/venv/lib64/python3.14/site-packages/streamlit/runtime/scriptrunner/script_runner.py", line 689, in code_to_exec
|
| 445 |
-
exec(code, module.__dict__) # noqa: S102
|
| 446 |
-
~~~~^^^^^^^^^^^^^^^^^^^^^^^
|
| 447 |
-
File "/home/fed/ai/projec/A20-App-032/app.py", line 8, in <module>
|
| 448 |
-
from src.core.compare import BiomechanicsMatcher, get_pose_angles
|
| 449 |
-
ImportError: cannot import name 'get_pose_angles' from 'src.core.compare' (/home/fed/ai/projec/A20-App-032/src/core/compare.py)
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
*Viewed [app.py](file:///home/fed/ai/projec/A20-App-032/app.py) *
|
| 453 |
-
|
| 454 |
-
*Grep searched codebase*
|
| 455 |
-
|
| 456 |
-
*Edited relevant file*
|
| 457 |
-
|
| 458 |
-
### Planner Response
|
| 459 |
-
|
| 460 |
-
Lỗi này phát sinh do trong quá trình nâng cấp thư viện ở Giai đoạn 2, mình đã đổi tên hàm `get_pose_angles` thành `get_biomechanics_features` để phản ánh đúng việc phân tích cả bàn tay và các chỉ số sinh cơ học khác, nhưng chưa cập nhật dòng import trong `app.py`.
|
| 461 |
-
|
| 462 |
-
Mình đã sửa lại file `app.py` để loại bỏ phần import không còn tồn tại này. Ứng dụng hiện tại chủ yếu sử dụng class `BiomechanicsMatcher` để xử lý so sánh nên việc xóa này không ảnh hưởng đến tính năng.
|
| 463 |
-
|
| 464 |
-
Bạn hãy thử chạy lại nhé:
|
| 465 |
-
```bash
|
| 466 |
-
streamlit run app.py
|
| 467 |
-
```
|
| 468 |
-
|
| 469 |
-
### User Input
|
| 470 |
-
|
| 471 |
-
loại bỏ thông số tĩnh (Text/HUD) trên video đầu ra, chỉ sử dụng video của sinh viên trên video đầu ra
|
| 472 |
-
|
| 473 |
-
*Viewed [overlay.py](file:///home/fed/ai/projec/A20-App-032/src/core/overlay.py) *
|
| 474 |
-
|
| 475 |
-
*Edited relevant file*
|
| 476 |
-
|
| 477 |
-
*Edited relevant file*
|
| 478 |
-
|
| 479 |
-
### Planner Response
|
| 480 |
-
|
| 481 |
-
Mình đã cập nhật hệ thống hiển thị kết quả theo yêu cầu mới nhất của bạn:
|
| 482 |
-
|
| 483 |
-
1. **Loại bỏ Text/HUD**: Các bảng thông số và dòng chữ cảnh báo đã được gỡ bỏ hoàn toàn khỏi video đầu ra để tránh gây rối mắt và che khuất động tác.
|
| 484 |
-
2. **Chế độ xem đơn (Single Video Output)**: Video kết quả bây giờ chỉ hiển thị duy nhất **video thực tế của sinh viên**. Mình đã loại bỏ hiệu ứng bóng ma (alpha blending) của video chuyên gia.
|
| 485 |
-
3. **Hệ thống chỉ thị lỗi trực quan**: Các vòng tròn khoanh đỏ (và màu cam cho bàn tay) vẫn được giữ lại tại các vị trí khớp xương bị sai lệch. Điều này giúp sinh viên tập trung tối đa vào việc quan sát chính bản thân mình và nhận diện lỗi sai ngay trên cơ thể mình.
|
| 486 |
-
|
| 487 |
-
Bạn hãy chạy lại ứng dụng để xem kết quả mới nhé:
|
| 488 |
-
```bash
|
| 489 |
-
streamlit run app.py
|
| 490 |
-
```
|
| 491 |
-
|
| 492 |
-
### User Input
|
| 493 |
-
|
| 494 |
-
video kết quả không xuất hiện trên trình duyêt web, tôi cho là trình duyệt không nhận diện được đường dẫn file tạm
|
| 495 |
-
|
| 496 |
-
*Edited relevant file*
|
| 497 |
-
|
| 498 |
-
*Edited relevant file*
|
| 499 |
-
|
| 500 |
-
### Planner Response
|
| 501 |
-
|
| 502 |
-
Đúng như bạn dự đoán, việc truyền đường dẫn file trực tiếp (`filepath`) đôi khi khiến trình duyệt gặp khó khăn trong việc truy cập file tạm hoặc không nhận diện được định dạng nén của OpenCV.
|
| 503 |
-
|
| 504 |
-
Mình đã thực hiện 2 thay đổi quan trọng để xử lý triệt để vấn đề này:
|
| 505 |
-
|
| 506 |
-
1. **Chuyển đổi Codec (`avc1`)**: Trong `src/core/overlay.py`, mình đã đổi bộ mã hóa video từ `mp4v` sang `avc1` (H.264). Đây là chuẩn nén video phổ biến nhất mà tất cả các trình duyệt hiện đại (Chrome, Safari, Firefox) đều có thể giải mã và phát trực tiếp trên web.
|
| 507 |
-
2. **Truyền dữ liệu nhị phân (Binary Stream)**: Trong `app.py`, thay vì đưa cho Streamlit một cái tên file, mình đã chuyển sang cách **đọc file dưới dạng bytes** rồi mới đẩy vào `st.video()`. Cách này đảm bảo video được "nhúng" trực tiếp vào trang web, loại bỏ hoàn toàn các lỗi liên quan đến đường dẫn hay quyền truy cập file trên server.
|
| 508 |
-
|
| 509 |
-
Bạn hãy thử chạy lại, bây giờ video kết quả chắc chắn sẽ hiện ra mượt mà ngay trên trình duyệt!
|
| 510 |
-
```bash
|
| 511 |
-
streamlit run app.py
|
| 512 |
-
```
|
| 513 |
-
|
| 514 |
-
### User Input
|
| 515 |
-
|
| 516 |
-
[h264_v4l2m2m @ 0x7f9461481b40] Could not find a valid device
|
| 517 |
-
[h264_v4l2m2m @ 0x7f9461481b40] can't configure encoder
|
| 518 |
-
[ERROR:0@360.131] global cap_ffmpeg_impl.hpp:3514 open Could not open codec h264_v4l2m2m, error: Unspecified error (-22)
|
| 519 |
-
[ERROR:0@360.131] global cap_ffmpeg_impl.hpp:3531 open VIDEOIO/FFMPEG: Failed to initialize VideoWriter
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
*Edited relevant file*
|
| 523 |
-
|
| 524 |
-
### Planner Response
|
| 525 |
-
|
| 526 |
-
Lỗi bạn vừa gặp (`h264_v4l2m2m`) là do hệ thống đang cố gắng sử dụng card đồ họa hoặc chip xử lý phần cứng để nén video H.264 nhưng không tìm thấy thiết bị phù hợp, dẫn đến việc OpenCV không thể khởi tạo trình ghi video.
|
| 527 |
-
|
| 528 |
-
Để khắc phục, mình đã:
|
| 529 |
-
|
| 530 |
-
1. **Chuyển về Codec `mp4v`**: Đây là bộ mã hóa phần mềm tiêu chuẩn của OpenCV, nó không phụ thuộc vào thiết bị ph��n cứng đặc thù nên sẽ hoạt động ổn định trên hầu hết các máy Linux.
|
| 531 |
-
2. **Kết hợp với Binary Stream**: Vì mình đã cập nhật `app.py` để đọc file dưới dạng nhị phân ở bước trước, nên ngay cả khi dùng `mp4v`, dữ liệu vẫn sẽ được đẩy lên trình duyệt một cách hiệu quả.
|
| 532 |
-
|
| 533 |
-
Bạn hãy thử chạy lại nhé, lần này quá trình render sẽ không còn bị ngắt quãng bởi lỗi phần cứng nữa:
|
| 534 |
-
```bash
|
| 535 |
-
streamlit run app.py
|
| 536 |
-
```
|
| 537 |
-
|
| 538 |
-
### User Input
|
| 539 |
-
|
| 540 |
-
video vẫn không xuất hiện
|
| 541 |
-
|
| 542 |
-
*User accepted the command `./venv/bin/python3 -c "import cv2; print(cv2.VideoWriter_fourcc(*'VP80'))"`*
|
| 543 |
-
|
| 544 |
-
*Edited relevant file*
|
| 545 |
-
|
| 546 |
-
*Edited relevant file*
|
| 547 |
-
|
| 548 |
-
### Planner Response
|
| 549 |
-
|
| 550 |
-
Có vẻ như trình duyệt web không hỗ trợ phát trực tiếp định dạng MP4 nếu nó được nén bằng thuật toán cũ của OpenCV (`mp4v`), đồng thời hệ thống của bạn cũng đang gặp lỗi khi cố gắng nén bằng chuẩn H.264 (do không tìm thấy driver phần cứng).
|
| 551 |
-
|
| 552 |
-
Vì vậy, mình đã chuyển đổi toàn bộ quy trình sang định dạng **WebM** với codec **VP8**:
|
| 553 |
-
|
| 554 |
-
1. **Định dạng WebM**: Đây là định dạng video mở, được thiết kế riêng cho web. Nó nhẹ và có khả năng tương thích cực cao với các trình duyệt mà không cần đến hỗ trợ phần cứng đặc thù như H.264.
|
| 555 |
-
2. **Cập nhật Codec (`VP80`)**: Trong module overlay, mình đã đổi codec sang `VP80` để ghi file `.webm`. Trình duyệt sẽ nhận diện và phát được ngay lập tức thông qua luồng dữ liệu nhị phân mà chúng ta đã thiết lập.
|
| 556 |
-
|
| 557 |
-
Bạn hãy thử chạy lại một lần nữa, kết quả sẽ hiện ra ngay sau khi quá trình render hoàn tất:
|
| 558 |
-
```bash
|
| 559 |
-
streamlit run app.py
|
| 560 |
-
```
|
| 561 |
-
|
| 562 |
-
### User Input
|
| 563 |
-
|
| 564 |
-
trong video có những khoanh tròn màu xanh, chúng có ý nghĩa gfi
|
| 565 |
-
|
| 566 |
-
### Planner Response
|
| 567 |
-
|
| 568 |
-
Các vòng tròn màu xanh (Blue) mà bạn thấy trên video chính là **các điểm khớp xương của bàn tay (Hand Landmarks)**.
|
| 569 |
-
|
| 570 |
-
Cụ thể, ý nghĩa của các ký hiệu màu sắc trên video kết quả như sau:
|
| 571 |
-
|
| 572 |
-
1. **Vòng tròn màu xanh (Blue)**: Hiển thị chi tiết vị trí các ngón tay và cổ tay của bạn. Vì trong Taekwondo, cấu hình bàn tay (nắm đấm hay mở tay) rất quan trọng, nên các điểm này giúp bạn thấy rõ cử động chi tiết của bàn tay so với video mẫu.
|
| 573 |
-
2. **Vòng tròn màu đỏ (Red)**: Đây là **cảnh báo lỗi**. Khi một bộ phận cơ thể (như vai, cùi chỏ, hoặc đầu gối) có góc lệch quá lớn so với video mẫu, hệ thống sẽ tự động khoanh tròn đỏ để bạn biết chính xác điểm cần sửa.
|
| 574 |
-
3. **Vòng tròn màu cam (Orange)**: Cảnh báo lỗi riêng cho **bàn tay**. Nếu video mẫu là nắm đấm mà bạn lại mở tay (hoặc ngược lại), hệ thống sẽ khoanh vùng màu cam ở cổ tay.
|
| 575 |
-
4. **Các đường kẻ màu xanh lá (Green)**: Là bộ khung xương cơ thể kết nối các khớp chính (vai, hông, chân), giúp bạn quan sát tổng thể tư thế (pose) của mình.
|
| 576 |
-
|
| 577 |
-
Nếu bạn thấy các vòng tròn màu xanh quá nhỏ hoặc khó nhìn, mình có thể điều chỉnh lại kích thước hoặc đổi sang màu khác nổi bật hơn nhé!
|
| 578 |
-
|
| 579 |
-
### User Input
|
| 580 |
-
|
| 581 |
-
ghi chú mã nguồn, giải thích các hằng số được sử dụng trong mã nguồn
|
| 582 |
-
|
| 583 |
-
*Edited relevant file*
|
| 584 |
-
|
| 585 |
-
*Edited relevant file*
|
| 586 |
-
|
| 587 |
-
*Edited relevant file*
|
| 588 |
-
|
| 589 |
-
*Edited relevant file*
|
| 590 |
-
|
| 591 |
-
*Viewed [compare.py](file:///home/fed/ai/projec/A20-App-032/src/core/compare.py) *
|
| 592 |
-
|
| 593 |
-
*Edited relevant file*
|
| 594 |
-
|
| 595 |
-
*Edited relevant file*
|
| 596 |
-
|
| 597 |
-
### Planner Response
|
| 598 |
-
|
| 599 |
-
Mình đã hoàn tất việc ghi chú mã nguồn bằng tiếng Việt và giải thích chi tiết các hằng số quan trọng cho toàn bộ hệ thống.
|
| 600 |
-
|
| 601 |
-
Dưới đây là tóm tắt các hằng số và thông số kỹ thuật cốt lõi vừa được chú thích:
|
| 602 |
-
|
| 603 |
-
### 1. Hệ thống Ngưỡng (Thresholds)
|
| 604 |
-
- **`error_threshold = 120.0`** (trong `overlay.py`): Đây là ngưỡng sai lệch góc cộng dồn. Nếu tổng sai số tại một khớp xương vượt quá 120 (đơn vị Độ), hệ thống sẽ kích hoạt vòng tròn đỏ cảnh báo.
|
| 605 |
-
- **`START_DETECTION_THRESHOLD = 15.0`** (trong `compare.py`): Ngưỡng độ lệch tối đa để hệ thống coi là sinh viên bắt đầu động tác giống mẫu.
|
| 606 |
-
- **`major_errors_threshold > 60.0`** (trong `app.py`): Một frame được coi là lỗi nghiêm trọng nếu sai số trung bình vượt quá 60.
|
| 607 |
-
- **`hand_error_threshold > 30.0`** (trong `overlay.py`): Dùng để khoanh vùng màu cam cho lỗi cấu hình bàn tay (nắm/mở).
|
| 608 |
-
|
| 609 |
-
### 2. Logic Kỹ thuật
|
| 610 |
-
- **`timestamp += 33`** (trong `pose.py`): MediaPipe Tasks API ở chế độ Video yêu cầu mỗi frame có một mốc thời gian tăng dần. 33ms tương đương với tần suất 30 khung hình/giây (FPS) – tiêu chuẩn của hầu hết camera điện thoại.
|
| 611 |
-
- **`FEATURE_TO_LANDMARK`**: Bảng ánh xạ giúp hệ thống biết khi tính toán sai lệch ở index số 0 (Khuỷu tay trái) thì phải vẽ vòng tròn đỏ vào Landmark ID số 13 của MediaPipe.
|
| 612 |
-
- **`Scale Hand (* 100)`** (trong `compare.py`): Vì góc khớp tính theo Độ (0-180), còn trạng thái bàn tay tính theo tỷ lệ (0-1), nên mình nhân 100 cho bàn tay để chúng có "trọng số" tương đương khi tính toán tổng sai số.
|
| 613 |
-
|
| 614 |
-
Mã nguồn hiện đã rõ ràng và dễ bảo trì hơn cho các giai đoạn tiếp theo. Bạn có thể kiểm tra trực tiếp trong các file `src/core/*.py` để xem chi tiết các đoạn comment mới.
|
| 615 |
-
|
| 616 |
-
### User Input
|
| 617 |
-
|
| 618 |
-
tiếp tục với giai đọan 4
|
| 619 |
-
|
| 620 |
-
*Edited relevant file*
|
| 621 |
-
|
| 622 |
-
### Planner Response
|
| 623 |
-
|
| 624 |
-
Mình đã thực hiện xong **Giai đoạn 4: Nâng cấp Giao diện và Dashboard kết quả**.
|
| 625 |
-
|
| 626 |
-
Ứng dụng hiện tại không chỉ hiển thị video mà còn cung cấp một bảng phân tích số liệu chuyên sâu để người tập có cái nhìn tổng quan nhất về kỹ thuật của mình:
|
| 627 |
-
|
| 628 |
-
### Các điểm mới trong Giai đoạn 4:
|
| 629 |
-
|
| 630 |
-
1. **Dashboard Chỉ số Hiệu suất**:
|
| 631 |
-
- Hiển thị điểm số chính xác kèm theo so sánh với mục tiêu.
|
| 632 |
-
- Đánh giá độ nhịp điệu (Rhythm) dựa trên khả năng đồng bộ của thuật toán DTW.
|
| 633 |
-
2. **Biểu đồ sai lệch thời gian thực**:
|
| 634 |
-
- Sử dụng `st.line_chart` để vẽ biểu đồ trực quan hóa toàn bộ quá trình tập luyện. Người tập có thể biết được chính xác giây thứ mấy là lúc họ thực hiện tệ nhất (đỉnh biểu đồ) để quay lại xem kỹ video.
|
| 635 |
-
3. **Phân tích chi tiết từng bộ phận (Body Part Breakdown)**:
|
| 636 |
-
- Hệ thống tính toán sai số trung bình cho từng khớp xương (Tay trái/phải, Vai, Hông, Chân).
|
| 637 |
-
- Sử dụng các icon trực quan: ✅ (Tốt), ⚠️ (Cần chú ý), ❌ (Sai nhiều) kèm theo con số độ lệch trung bình.
|
| 638 |
-
4. **Tối ưu hóa Mobile UI**:
|
| 639 |
-
- Bố cục Dashboard được chia thành các cột nhỏ tự động co giãn (responsive), giúp sinh viên dễ dàng xem báo cáo trên điện thoại ngay tại sân tập.
|
| 640 |
-
|
| 641 |
-
Với bản cập nhật này, ứng dụng đã đạt ngưỡng **Production-Ready** về mặt tính năng và giao diện.
|
| 642 |
-
|
| 643 |
-
Bạn đã sẵn sàng để tiến tới **Giai đoạn cuối cùng (Giai đoạn 5: Tối ưu hiệu năng và Kiểm thử)** chưa? Ở bước này chúng ta sẽ tinh chỉnh lại tốc độ xử lý và giới hạn tài nguyên để đảm bảo app chạy mượt mà trên môi trường Web.
|
| 644 |
-
|
| 645 |
-
### User Input
|
| 646 |
-
|
| 647 |
-
2026-04-12 20:53:36.452 Uncaught app execution
|
| 648 |
-
Traceback (most recent call last):
|
| 649 |
-
File "/home/fed/ai/projec/A20-App-032/venv/lib64/python3.14/site-packages/streamlit/runtime/scriptrunner/exec_code.py", line 129, in exec_func_with_error_handling
|
| 650 |
-
result = func()
|
| 651 |
-
File "/home/fed/ai/projec/A20-App-032/venv/lib64/python3.14/site-packages/streamlit/runtime/scriptrunner/script_runner.py", line 689, in code_to_exec
|
| 652 |
-
exec(code, module.__dict__) # noqa: S102
|
| 653 |
-
~~~~^^^^^^^^^^^^^^^^^^^^^^^
|
| 654 |
-
File "/home/fed/ai/projec/A20-App-032/app.py", line 142, in <module>
|
| 655 |
-
from src.core.overlay import FEATURE_LABELS
|
| 656 |
-
ImportError: cannot import name 'FEATURE_LABELS' from 'src.core.overlay' (/home/fed/ai/projec/A20-App-032/src/core/overlay.py)
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
*Viewed [overlay.py](file:///home/fed/ai/projec/A20-App-032/src/core/overlay.py) *
|
| 660 |
-
|
| 661 |
-
*Edited relevant file*
|
| 662 |
-
|
| 663 |
-
### Planner Response
|
| 664 |
-
|
| 665 |
-
Lỗi này là do sơ suất của mình khi dọn dẹp mã nguồn ở bước trước (khi bạn yêu cầu bỏ HUD), mình đã vô tình xóa mất từ điển `FEATURE_LABELS` khiến Dashboard kết quả không tìm thấy tên các bộ phận để hiển thị.
|
| 666 |
-
|
| 667 |
-
Mình đã khôi phục lại các nhãn tên bộ phận bằng tiếng Việt trong file `src/core/overlay.py`. Bây giờ Dashboard phân tích chi tiết sẽ hoạt động bình thường trở lại.
|
| 668 |
-
|
| 669 |
-
Bạn hãy chạy lại ứng dụng nhé:
|
| 670 |
-
```bash
|
| 671 |
-
streamlit run app.py
|
| 672 |
-
```
|
| 673 |
-
|
| 674 |
-
### User Input
|
| 675 |
-
|
| 676 |
-
kết xuất video đầu ra riêng rẽ cho từng phiên làm việc, cho các video đó vào 1 thư mục tên là outputs, xóa các video cũ hơn 5 phút
|
| 677 |
-
|
| 678 |
-
*User accepted the command `mkdir -p outputs`*
|
| 679 |
-
|
| 680 |
-
*Edited relevant file*
|
| 681 |
-
|
| 682 |
-
*Edited relevant file*
|
| 683 |
-
|
| 684 |
-
### Planner Response
|
| 685 |
-
|
| 686 |
-
Mình đã cập nhật hệ thống quản lý file video đầu ra theo yêu cầu của bạn:
|
| 687 |
-
|
| 688 |
-
1. **Thư mục lưu trữ riêng biệt**: Mình đã tạo thư mục `outputs/` để chứa tất cả các video kết quả.
|
| 689 |
-
2. **Định danh theo phiên (Session ID)**: Thay vì ghi đè lên một file duy nhất, mỗi lần bạn nhấn "Phân tích", ứng dụng sẽ tạo ra một file webm mới có kèm timestamp (ví dụ: `outputs/result_1712932726.webm`). Điều này giúp tránh xung đột dữ liệu khi có nhiều người dùng hoặc nhiều phiên làm việc cùng lúc.
|
| 690 |
-
3. **Tự động dọn dẹp (Auto-cleanup)**: Mình đã thêm hàm `cleanup_old_videos` vào bộ khởi tạo của ứng dụng. Mỗi khi có một phiên phân tích mới bắt đầu, hệ thống sẽ tự động quét thư mục `outputs/` và **xóa bỏ bất kỳ video nào cũ hơn 5 phút**. Việc này giúp tiết kiệm dung lượng ổ đĩa và bảo vệ quyền riêng tư của người dùng.
|
| 691 |
-
|
| 692 |
-
Bạn có thể kiểm tra danh sách video trong thư mục `outputs/` của project để thấy sự thay đổi.
|
| 693 |
-
|
| 694 |
-
```bash
|
| 695 |
-
streamlit run app.py
|
| 696 |
-
```
|
| 697 |
-
|
| 698 |
-
### User Input
|
| 699 |
-
|
| 700 |
-
giảm yêu cầu về độ chính xác
|
| 701 |
-
|
| 702 |
-
*Edited relevant file*
|
| 703 |
-
|
| 704 |
-
*Edited relevant file*
|
| 705 |
-
|
| 706 |
-
### Planner Response
|
| 707 |
-
|
| 708 |
-
Mình đã điều chỉnh hệ thống để trở nên "dễ tính" hơn, giúp người mới tập không cảm thấy quá áp lực về độ chính xác tuyệt đối:
|
| 709 |
-
|
| 710 |
-
1. **Nới lỏng công thức tính điểm**: Trong `src/core/compare.py`, mình đã giảm hệ số phạt sai số từ **2.5** xuống còn **1.5**.
|
| 711 |
-
- *Ví dụ*: Trước đây nếu bạn lệch trung bình 20 độ thì chỉ được 50 điểm, bây giờ với cùng mức lệch đó bạn sẽ đạt khoảng **70 điểm**. Điều này giúp phản ánh đúng hơn nỗ lực của người tập ở giai đoạn bắt đầu.
|
| 712 |
-
2. **Tăng ngưỡng "Lỗi nghiêm trọng"**: Trong `app.py`, mình đã tăng ngưỡng để một lỗi bị coi là "Cần sửa" từ 60 độ lên **90 độ**. Hệ thống sẽ chỉ đếm những lỗi thực sự quá lớn, giúp Dashboard trông tích cực hơn.
|
| 713 |
-
3. **Vòng tròn cảnh báo (Red Circles)**: Vẫn giữ ở mức **120.0** như bạn đã chỉnh trước đó, đảm bảo chỉ những động tác sai lệch hẳn về phương hướng mới bị khoanh đỏ.
|
| 714 |
-
|
| 715 |
-
Hệ thống giờ đây sẽ mang tính khuyến khích (supportive) nhiều hơn là khắt khe. Bạn có thể thử nghiệm ngay với bài tập tiếp theo nhé!
|
| 716 |
-
|
| 717 |
-
### User Input
|
| 718 |
-
|
| 719 |
-
chấm dứt việc khoanh màu xanh trên video
|
| 720 |
-
|
| 721 |
-
*Viewed [pose.py](file:///home/fed/ai/projec/A20-App-032/src/core/pose.py) *
|
| 722 |
-
|
| 723 |
-
*Viewed [pose.py](file:///home/fed/ai/projec/A20-App-032/src/core/pose.py) *
|
| 724 |
-
|
| 725 |
-
*Edited relevant file*
|
| 726 |
-
|
| 727 |
-
### Planner Response
|
| 728 |
-
|
| 729 |
-
Mình đã loại bỏ hoàn toàn các vòng tròn màu xanh (các điểm khớp bàn tay) khỏi video. Giờ đây video kết quả sẽ chỉ tập trung hiển thị:
|
| 730 |
-
- Khung xương chính (Green lines).
|
| 731 |
-
- Các vòng tròn đỏ/cam cảnh báo lỗi khi có sai lệch lớn.
|
| 732 |
-
|
| 733 |
-
Video sẽ trông gọn gàng và tập trung vào các lỗi chính hơn. Bạn có thể kiểm tra kết quả ngay trong lượt phân tích tiếp theo nhé.
|
| 734 |
-
|
| 735 |
-
### User Input
|
| 736 |
-
|
| 737 |
-
định dạng tên video đầu ra như sau: tên video mẫu_tên video võ sinh_độ chính xác_timestamp
|
| 738 |
-
|
| 739 |
-
*Edited relevant file*
|
| 740 |
-
|
| 741 |
-
### Planner Response
|
| 742 |
-
|
| 743 |
-
Mình đã cập nhật logic đặt tên file video đầu ra theo đúng định dạng bạn yêu cầu.
|
| 744 |
-
|
| 745 |
-
Tên file mới sẽ có cấu trúc: `{Tên_Video_Mẫu}_{Tên_Video_Sinh_Viên}_{Điểm_Số}_{Timestamp}.webm`
|
| 746 |
-
|
| 747 |
-
**Ví dụ**: Nếu bạn tải lên file `mau_quyen_1.mp4` và `nguyen_van_a.mov`, đạt điểm 85, video kết quả sẽ có tên là:
|
| 748 |
-
`outputs/mau_quyen_1_nguyen_van_a_85_1712932726.webm`
|
| 749 |
-
|
| 750 |
-
Hệ thống cũng tự động loại bỏ đuôi file gốc và thay thế khoảng trắng bằng dấu gạch dưới để đảm bảo tên file an toàn cho hệ thống. Mời bạn kiểm tra lại trong thư mục `outputs/` sau khi chạy phân tích.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AI Coach Docs
|
| 2 |
+
|
| 3 |
+
Bo tai lieu nay mo ta phien ban hien tai cua du an: ung dung Reflex phan tich bai tap hit dat bang video.
|
| 4 |
+
|
| 5 |
+
## Doc Map
|
| 6 |
+
|
| 7 |
+
- `problem_statement.md`: muc tieu san pham va pham vi hien tai.
|
| 8 |
+
- `implementation_plan.md`: kien truc, module chinh, luong xu ly va quyet dinh thiet ke.
|
| 9 |
+
- `SETUP_NOTES.md`: cach cai dat, chay app, cau hinh VLM va test batch.
|
| 10 |
+
- `LLM_FEEDBACK_NVIDIA_LANGGRAPH.md`: vai tro NVIDIA VLM, input/output, fallback va ly do arrow khong dung VLM.
|
| 11 |
+
- `UI_REDESIGN.md`: cau truc UI hien tai, cach phan biet rule-based feedback voi VLM feedback.
|
| 12 |
+
- `legacy_agent_notes.md`: ghi chu ngan ve tai lieu agent/OpenAI cu khong con thuoc luong production.
|
| 13 |
+
|
| 14 |
+
## Tom Tat He Thong
|
| 15 |
+
|
| 16 |
+
```text
|
| 17 |
+
Reflex UI
|
| 18 |
+
-> upload video hoc vien
|
| 19 |
+
-> MediaPipe Pose local CPU
|
| 20 |
+
-> rep segmentation + DTW
|
| 21 |
+
-> rule-based error detection
|
| 22 |
+
-> annotated rep frames + deterministic arrows
|
| 23 |
+
-> optional NVIDIA VLM text feedback
|
| 24 |
+
-> per-rep result cards
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
## Nguon Su That Trong Ung Dung
|
| 28 |
+
|
| 29 |
+
- Loi chinh: rule engine.
|
| 30 |
+
- Diem so: ket hop rule score va DTW score.
|
| 31 |
+
- Arrow: landmark rule-based.
|
| 32 |
+
- Feedback rule-based: `ERROR_GUIDANCE`.
|
| 33 |
+
- Feedback VLM: chi la giai thich bo sung bang ngon ngu tu nhien.
|
| 34 |
+
|
| 35 |
+
## Lenh Thuong Dung
|
| 36 |
+
|
| 37 |
+
```powershell
|
| 38 |
+
reflex run
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
```powershell
|
| 42 |
+
python scripts\run_pushup_eval_tests.py
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
```powershell
|
| 46 |
+
python scripts\run_pushup_eval_tests.py --save-artifacts --enable-vlm
|
| 47 |
+
```
|
docs/SETUP_NOTES.md
CHANGED
|
@@ -1,41 +1,89 @@
|
|
| 1 |
# Setup Notes
|
| 2 |
|
| 3 |
-
##
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
```
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
|
| 17 |
-
- `
|
| 18 |
-
-
|
| 19 |
|
| 20 |
-
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
```
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
##
|
| 30 |
|
| 31 |
-
|
| 32 |
-
- `.states/` is generated by Reflex in development to persist small state snapshots. It is not user video storage and can be deleted; Reflex may recreate it.
|
| 33 |
-
- `uploaded_files/` is the default Reflex upload directory. This project redirects Reflex upload temp files to `/tmp/pushupai_reflex_uploads`, so student videos are not stored in the repo.
|
| 34 |
|
| 35 |
-
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Setup Notes
|
| 2 |
|
| 3 |
+
## Moi Truong
|
| 4 |
|
| 5 |
+
Du an duoc phat trien va test chinh tren Windows voi PowerShell.
|
| 6 |
|
| 7 |
+
Khuyen nghi:
|
| 8 |
+
|
| 9 |
+
- Python 3.11.
|
| 10 |
+
- Virtual environment `.venv`.
|
| 11 |
+
- Chay tu repo root.
|
| 12 |
+
|
| 13 |
+
```powershell
|
| 14 |
+
python -m venv .venv
|
| 15 |
+
.\.venv\Scripts\Activate.ps1
|
| 16 |
+
pip install -r requirements.txt
|
| 17 |
```
|
| 18 |
|
| 19 |
+
## Chay App
|
| 20 |
+
|
| 21 |
+
```powershell
|
| 22 |
+
reflex run
|
| 23 |
+
```
|
| 24 |
|
| 25 |
+
Mac dinh Reflex se chay:
|
| 26 |
|
| 27 |
+
- Frontend: `http://localhost:3000/`
|
| 28 |
+
- Backend: `http://0.0.0.0:8000`
|
| 29 |
|
| 30 |
+
Neu port ban, Reflex co the chon port khac.
|
| 31 |
|
| 32 |
+
## Cau Hinh VLM
|
| 33 |
+
|
| 34 |
+
VLM la tuy chon. Neu khong cau hinh NVIDIA API, app van chay rule-based analysis va feedback rule-based.
|
| 35 |
+
|
| 36 |
+
Tao `.env` tu `.env.example` va dien:
|
| 37 |
+
|
| 38 |
+
```env
|
| 39 |
+
NVIDIA_API_KEY=nvapi-your-real-nvidia-key
|
| 40 |
+
NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
| 41 |
+
NVIDIA_MODEL=deepseek-ai/deepseek-v4-flash
|
| 42 |
+
NVIDIA_VISION_MODEL=meta/llama-3.2-11b-vision-instruct
|
| 43 |
+
NVIDIA_VISION_MAX_TOKENS=1200
|
| 44 |
```
|
| 45 |
|
| 46 |
+
## Thu Muc Runtime
|
| 47 |
+
|
| 48 |
+
- `.web/`: Reflex build output, co the xoa.
|
| 49 |
+
- `.states/`: Reflex state snapshot trong dev, co the xoa.
|
| 50 |
+
- `uploaded_files/`: Reflex upload directory neu dung cau hinh mac dinh.
|
| 51 |
+
- `analysis_artifacts/`: ket qua debug/test, artifact anh, JSON test output. Thu muc nay gitignored.
|
| 52 |
+
- `.ai-log/`: prompt/test logs tu hook, gitignored.
|
| 53 |
+
|
| 54 |
+
## Du Lieu Chinh
|
| 55 |
+
|
| 56 |
+
- Template video: `data/templates/push_up_template.mp4`.
|
| 57 |
+
- Template cache: `data/processed/pushup_template.pkl`.
|
| 58 |
+
- Browser asset template: `assets/push_up_template.mp4`.
|
| 59 |
+
- Test videos: `data/tests/*.mp4`.
|
| 60 |
|
| 61 |
+
## Logging Hook
|
| 62 |
|
| 63 |
+
Theo `AGENTS.md`, prompt logging duoc cau hinh tu dong qua hook. Khong can ghi log prompt thu cong.
|
|
|
|
|
|
|
| 64 |
|
| 65 |
+
Khi clone/chuyen OS, chay:
|
| 66 |
|
| 67 |
+
```powershell
|
| 68 |
+
python scripts\setup_hooks.py
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
## Test Batch
|
| 72 |
+
|
| 73 |
+
Chay nhanh, khong goi VLM:
|
| 74 |
+
|
| 75 |
+
```powershell
|
| 76 |
+
python scripts\run_pushup_eval_tests.py
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
Chay va luu artifact anh:
|
| 80 |
+
|
| 81 |
+
```powershell
|
| 82 |
+
python scripts\run_pushup_eval_tests.py --save-artifacts
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
Chay ca VLM:
|
| 86 |
+
|
| 87 |
+
```powershell
|
| 88 |
+
python scripts\run_pushup_eval_tests.py --save-artifacts --enable-vlm
|
| 89 |
+
```
|
docs/UI_REDESIGN.md
CHANGED
|
@@ -1,27 +1,79 @@
|
|
| 1 |
-
# UI
|
| 2 |
|
| 3 |
-
##
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
-
- Exercise catalog: list supported exercises.
|
| 11 |
-
- Exercise analysis: sample video, user video upload, and analysis result in the same workflow.
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
-
- Font: Be Vietnam Pro or Inter.
|
| 17 |
-
- Palette: light neutral background, white cards, dark text, green primary CTA, blue secondary accents.
|
| 18 |
-
- Cards: moderate radius, subtle borders, soft shadows.
|
| 19 |
-
- Text: short Vietnamese labels, no long marketing sections.
|
| 20 |
|
| 21 |
-
|
| 22 |
|
| 23 |
-
-
|
| 24 |
-
-
|
| 25 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# UI Guide
|
| 2 |
|
| 3 |
+
## Muc Tieu UI
|
| 4 |
|
| 5 |
+
UI can giong mot cong cu coaching thuc dung, khong phai landing page marketing. Man hinh phan tich la luong chinh:
|
| 6 |
|
| 7 |
+
```text
|
| 8 |
+
chon video -> phan tich -> xem ket qua tung rep
|
| 9 |
+
```
|
| 10 |
|
| 11 |
+
## Cac Trang
|
|
|
|
|
|
|
| 12 |
|
| 13 |
+
- Home: gioi thieu ngan va CTA vao bai tap.
|
| 14 |
+
- Exercise catalog: hien cac bai tap dang ho tro.
|
| 15 |
+
- Push-up analysis: video mau, upload video hoc vien, nut phan tich, ket qua.
|
| 16 |
|
| 17 |
+
## Trang Phan Tich Hit Dat
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
Trang nay gom:
|
| 20 |
|
| 21 |
+
- Video mau.
|
| 22 |
+
- Video hoc vien upload.
|
| 23 |
+
- Nut `Phan tich`.
|
| 24 |
+
- Progress label khi dang xu ly.
|
| 25 |
+
- Summary tong quan.
|
| 26 |
+
- Metrics: diem, so rep, rep tot, rep can chu y.
|
| 27 |
+
- Card tung rep.
|
| 28 |
|
| 29 |
+
## Card Tung Rep
|
| 30 |
+
|
| 31 |
+
Moi rep card hien:
|
| 32 |
+
|
| 33 |
+
- `Rep N`.
|
| 34 |
+
- Trang thai: `Tot`, `Can chu y`, hoac `Co loi`.
|
| 35 |
+
- Diem rep.
|
| 36 |
+
- Primary error, vi du `Nho mong qua cao`.
|
| 37 |
+
- Anh hoc vien da ve landmark va arrow neu co loi.
|
| 38 |
+
- Anh mau tu template.
|
| 39 |
+
- Feedback tu VLM neu co.
|
| 40 |
+
- Feedback rule-based luon co.
|
| 41 |
+
|
| 42 |
+
## Phan Biet Feedback
|
| 43 |
+
|
| 44 |
+
- `primary_error`: rule-based label, khong phai VLM.
|
| 45 |
+
- `Feedback rule-based`: cau huong dan deterministic tu `ERROR_GUIDANCE`.
|
| 46 |
+
- `Feedback tu VLM`: cau feedback sinh tu vision model, co the vang mat neu API loi/thieu key.
|
| 47 |
+
|
| 48 |
+
## Arrow
|
| 49 |
+
|
| 50 |
+
Arrow duoc ve len anh hoc vien, khong ve len anh mau. Target arrow lay tu landmark rule-based:
|
| 51 |
+
|
| 52 |
+
- `not_deep_enough`: vung nguc/vai.
|
| 53 |
+
- `hip_sag`: hong.
|
| 54 |
+
- `body_not_straight`: hong.
|
| 55 |
+
- `hip_pike`: hong.
|
| 56 |
+
- `head_misaligned`: dau/co.
|
| 57 |
+
|
| 58 |
+
Label arrow dung tieng Viet co dau, vi du:
|
| 59 |
+
|
| 60 |
+
- `Ngực chưa đủ thấp`
|
| 61 |
+
- `Hông bị võng xuống`
|
| 62 |
+
- `Hông lệch khỏi trục`
|
| 63 |
+
- `Cổ cúi quá mức`
|
| 64 |
+
- `Hông quá cao`
|
| 65 |
+
|
| 66 |
+
## Style
|
| 67 |
+
|
| 68 |
+
- Nen sang, text toi, card trang.
|
| 69 |
+
- Mau chinh xanh la cho CTA.
|
| 70 |
+
- Mau xanh duong cho VLM/info.
|
| 71 |
+
- Mau do cho loi/arrow.
|
| 72 |
+
- Card gon, uu tien scan nhanh.
|
| 73 |
+
|
| 74 |
+
## Luu Y Khi Sua UI
|
| 75 |
+
|
| 76 |
+
- Khong doi `llm_feedback` thanh `vlm_feedback` trong UI rieng le neu chua refactor state/payload dong bo.
|
| 77 |
+
- Khong an feedback rule-based, vi day la nguon giai thich deterministic.
|
| 78 |
+
- Khong doi arrow ve VLM coordinates.
|
| 79 |
+
- Kiem tra mobile va desktop sau khi sua card layout.
|
docs/implementation_plan.md
CHANGED
|
@@ -1,87 +1,64 @@
|
|
| 1 |
-
#
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
-
- **Sản phẩm:** Ứng dụng Web/Mobile Web hỗ trợ sinh viên Taekwondo tự luyện tập bài quyền thông qua việc so sánh đối chiếu với video chuẩn.
|
| 7 |
-
- **Công nghệ cốt lõi (Core Tech):**
|
| 8 |
-
- **AI / Computer Vision:** MediaPipe Holistic/Pose & Hand (cho khả năng nhận diện bộ khung xương cơ thể 33 điểm và chi tiết cử động các khớp bàn tay/ngón tay).
|
| 9 |
-
- **Logic so sánh:** Biomechanics analysis (tính toán Vector, góc giữa các khớp như Cùi chỏ, Vai, Đầu gối, Hông, và chi tiết cấu hình Bàn tay).
|
| 10 |
-
- **Video Processing:** OpenCV / Video overlay engine (Kỹ thuật gióng lề khung xương, vẽ đè khung xương hoặc overlay 2 video lên nhau, xuất video kết quả).
|
| 11 |
-
- **Frontend / Giao diện người dùng:** Streamlit (phiên bản responsive hỗ trợ giao diện Mobile).
|
| 12 |
-
- **Ngôn ngữ / Môi trường:** Python 3.14.
|
| 13 |
|
| 14 |
-
##
|
| 15 |
|
| 16 |
-
```
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
F --> G
|
| 27 |
-
G -->|Dynamic Time Warping / Angle Compare| H[Tính toán Độ Lệch %]
|
| 28 |
-
end
|
| 29 |
-
|
| 30 |
-
H --> I[Video Engine - Overlay]
|
| 31 |
-
I -->|Frames| J[Tạo Video Kết quả MP4]
|
| 32 |
-
J --> B
|
| 33 |
-
B -->|Hiển thị Video & Metrics| A
|
| 34 |
```
|
| 35 |
|
| 36 |
-
##
|
| 37 |
|
| 38 |
-
|
| 39 |
-
-
|
| 40 |
-
-
|
| 41 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
-
##
|
| 44 |
-
- **Module Nhận diện Khung xương:** Bọc (wrap) BlazePose để trích xuất 33 điểm tọa độ 3D.
|
| 45 |
-
- **Tính toán Sinh cơ học (Biomechanics):**
|
| 46 |
-
- Hàm tính toán góc giữa 3 điểm landmark (VD: vai - khuỷu tay - cổ tay).
|
| 47 |
-
- Hàm chuẩn hóa khung hình (Padding/Scaling) vì chiều cao của người dùng và người mẫu trong video có thể khác nhau.
|
| 48 |
-
- **Thuật toán Đồng bộ hóa Video (Synchronization):**
|
| 49 |
-
- Vì thời điểm bắt đầu thực hiện bài quyền của sinh viên có thể trễ (ví dụ: đứng yên chuẩn bị ở những giây đầu tiên) và tốc độ ra đòn không khớp, cần áp dụng thuật toán tìm kiếm Frame bắt đầu (Start Frame Detection / Keypose Matching) kết hợp với **DTW (Dynamic Time Warping)** để gióng hàng chính xác 2 chuỗi video frames.
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
##
|
| 61 |
-
- **Giao diện chính (UI/UX):**
|
| 62 |
-
- Section 1: Hướng dẫn sử dụng & Lựa chọn bài quyền Taekwondo.
|
| 63 |
-
- Section 2: Upload Video (hoặc Quay video trực tiếp bằng Mobile Camera thông qua plugin Streamlit webrtc, nếu khả thi).
|
| 64 |
-
- Section 3: Hiển thị thanh tiến trình xử lý (Progress bar).
|
| 65 |
-
- Section 4: Màn hình Kết quả (Dashboard) bao gồm: Video Overlay (nhấn Play), Biểu đồ thể hiện độ lệch xương, Điểm tổng kết.
|
| 66 |
-
- Tối ưu hóa UI bằng Vanilla CSS tích hợp vào Streamlit (thông qua `st.markdown(unsafe_allow_html=True)`) giúp các layout thân thiện với màn hình dọc của Mobile.
|
| 67 |
|
| 68 |
-
|
| 69 |
-
-
|
| 70 |
-
-
|
| 71 |
-
-
|
|
|
|
| 72 |
|
| 73 |
-
##
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
│ │ ├── pose.py # MediaPipe BlazePose logic
|
| 80 |
-
│ │ ├── compare.py # Logic DTW & Biomechanic Vectors
|
| 81 |
-
│ │ └── overlay.py # Xử lý render OpenCV Video
|
| 82 |
-
│ └── data/
|
| 83 |
-
│ └── reference_vids/ # Chứa video chuyên nghiệp & JSON frames
|
| 84 |
-
├── requirements.txt
|
| 85 |
-
├── README.md
|
| 86 |
-
└── problem_statement.md
|
| 87 |
-
```
|
|
|
|
| 1 |
+
# Implementation Plan
|
| 2 |
|
| 3 |
+
## Trang Thai Hien Tai
|
| 4 |
|
| 5 |
+
Du an da chuyen tu prototype Taekwondo/Streamlit sang ung dung Reflex phan tich bai tap hit dat. Luong xu ly production hien tai nam trong cac module `push_up/*` va `reflex_frontend/*`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
## Kien Truc Chinh
|
| 8 |
|
| 9 |
+
```text
|
| 10 |
+
Reflex UI
|
| 11 |
+
-> upload video hoc vien
|
| 12 |
+
-> push_up.analysis_service.analyze_pushup()
|
| 13 |
+
-> VideoProcessor + MediaPipe Pose
|
| 14 |
+
-> PushUpEvaluator
|
| 15 |
+
-> PushUpRuleEngine
|
| 16 |
+
-> frame artifacts + rule-based arrows
|
| 17 |
+
-> optional NVIDIA VLM feedback
|
| 18 |
+
-> Reflex result cards
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
```
|
| 20 |
|
| 21 |
+
## Module Chinh
|
| 22 |
|
| 23 |
+
- `reflex_frontend/state.py`: quan ly upload, tien trinh xu ly, state ket qua.
|
| 24 |
+
- `reflex_frontend/ui.py`: giao dien trang chu, danh sach bai tap, trang phan tich hit dat, card ket qua tung rep.
|
| 25 |
+
- `push_up/processor.py`: doc video, phat hien huong nguoi tap, flip neu can, trich xuat landmark theo frame.
|
| 26 |
+
- `push_up/engine.py`: boc MediaPipe Pose, tinh cac chi so kinematics nhu elbow angle, hip angle, body line angle, depth signal.
|
| 27 |
+
- `push_up/evaluator.py`: segment rep, can chinh voi template bang DTW, ket hop diem rule score va DTW score.
|
| 28 |
+
- `push_up/rules.py`: phat hien loi ky thuat bang nguong deterministic.
|
| 29 |
+
- `push_up/analysis_service.py`: orchestration cua toan bo flow, tao payload cho UI, luu artifact, ve arrow.
|
| 30 |
+
- `push_up/feedback_graph.py`: goi text LLM/VLM qua NVIDIA OpenAI-compatible API va validate JSON feedback.
|
| 31 |
+
- `scripts/run_pushup_eval_tests.py`: batch test tat ca video trong `data/tests` va xuat file JSON tong hop.
|
| 32 |
|
| 33 |
+
## Luong Phan Tich Video
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
+
1. UI luu video upload vao thu muc tam cua Reflex.
|
| 36 |
+
2. `prepare_template_cache()` dam bao video mau da duoc xu ly va cache landmark tai `data/processed/pushup_template.pkl`.
|
| 37 |
+
3. `VideoProcessor` doc video hoc vien, lay mau frame, auto flip neu nguoi tap nam nguoc huong template.
|
| 38 |
+
4. `PoseEngine` trich xuat 33 landmark pose va tinh kinematics.
|
| 39 |
+
5. `PushUpEvaluator` segment rep dua tren depth signal va elbow angle.
|
| 40 |
+
6. Moi rep duoc so sanh voi golden template bang DTW va pose similarity.
|
| 41 |
+
7. `PushUpRuleEngine` chay rule-based checks:
|
| 42 |
+
- `not_deep_enough`
|
| 43 |
+
- `hip_sag`
|
| 44 |
+
- `body_not_straight`
|
| 45 |
+
- `head_misaligned`
|
| 46 |
+
- `hip_pike`
|
| 47 |
+
8. `analysis_service` tao card ket qua tung rep, main error, feedback rule-based, va summary.
|
| 48 |
+
9. Khi `save_artifacts=True`, app luu frame hoc vien/mau va ve arrow vao frame hoc vien.
|
| 49 |
+
10. Neu co `NVIDIA_API_KEY`, VLM duoc goi de sinh feedback chu cho rep loi.
|
| 50 |
|
| 51 |
+
## Quyet Dinh Thiet Ke
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
+
- Rule engine la nguon quyet dinh loi chinh, diem rule, feedback rule-based va arrow target.
|
| 54 |
+
- VLM khong duoc dung de quyet dinh toa do arrow vi de tra sai vi tri tren nen anh.
|
| 55 |
+
- VLM chi sinh feedback ngan bang tieng Viet dua tren anh hoc vien, anh mau va rule context.
|
| 56 |
+
- Arrow label dung tieng Viet co dau va duoc render bang Pillow de tranh loi Unicode cua OpenCV.
|
| 57 |
+
- Ket qua test batch mac dinh tat VLM de lap lai, nhanh hon va khong ton API.
|
| 58 |
|
| 59 |
+
## Viec Can Lam Tiep
|
| 60 |
+
|
| 61 |
+
- Them expected assertions vao batch test, vi du `hv01_mong_cao` phai co `hip_pike`.
|
| 62 |
+
- Luu anh artifact cho test run co chon loc thay vi tat ca rep neu can giam dung luong.
|
| 63 |
+
- Tach `llm_*` field thanh `vlm_*` trong payload/UI de ten bien khop voi y nghia hien tai.
|
| 64 |
+
- Them exercise registry neu mo rong sang bai tap khac.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/legacy_agent_notes.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Legacy Agent Notes
|
| 2 |
+
|
| 3 |
+
File nay thay the tai lieu lich su `Migrating Agent to OpenAI.md`.
|
| 4 |
+
|
| 5 |
+
Noi dung cu la ban ghi hoi thoai ve prototype agent/OpenAI va Taekwondo. Phan do khong con mo ta luong production hien tai cua ung dung.
|
| 6 |
+
|
| 7 |
+
## Trang Thai Hien Tai
|
| 8 |
+
|
| 9 |
+
- Ung dung production hien tai la Reflex app cho bai tap hit dat.
|
| 10 |
+
- LLM/VLM integration hien tai nam trong `push_up/feedback_graph.py`.
|
| 11 |
+
- Provider hien tai dung NVIDIA OpenAI-compatible API, khong phai agent loop rieng trong `src/agent.py`.
|
| 12 |
+
- Rule engine van la nguon quyet dinh loi va diem.
|
| 13 |
+
|
| 14 |
+
## Khi Nao Can Xem Lai Phan Legacy
|
| 15 |
+
|
| 16 |
+
Chi can quan tam neu du an quay lai huong:
|
| 17 |
+
|
| 18 |
+
- agent tool-calling rieng;
|
| 19 |
+
- OpenAI API truc tiep thay vi NVIDIA endpoint;
|
| 20 |
+
- phan tich Taekwondo prototype cu.
|
| 21 |
+
|
| 22 |
+
Voi roadmap hien tai, hay doc `implementation_plan.md` va `LLM_FEEDBACK_NVIDIA_LANGGRAPH.md` thay cho file legacy nay.
|
docs/problem_statement.md
CHANGED
|
@@ -1,4 +1,37 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Problem Statement
|
| 2 |
+
|
| 3 |
+
## Ten Du An
|
| 4 |
+
|
| 5 |
+
AI Coach - ung dung phan tich dong tac hit dat bang video.
|
| 6 |
+
|
| 7 |
+
## Muc Tieu
|
| 8 |
+
|
| 9 |
+
Nguoi tap thuong kho tu nhan ra loi ky thuat khi tap mot minh, dac biet cac loi nhu chua ha nguoi du sau, hong vong, hong qua cao, co the khong giu thang, hoac cui dau qua muc. Du an nay xay dung mot ung dung web giup nguoi dung tai video tap luyen, so sanh voi video mau, va nhan phan hoi truc quan theo tung rep.
|
| 10 |
+
|
| 11 |
+
## Pham Vi Hien Tai
|
| 12 |
+
|
| 13 |
+
Phien ban hien tai tap trung vao bai tap hit dat:
|
| 14 |
+
|
| 15 |
+
- Phat hien tu dong so rep trong video.
|
| 16 |
+
- So sanh chuoi dong tac cua hoc vien voi video mau trong `data/templates/push_up_template.mp4`.
|
| 17 |
+
- Cham diem tong quan va diem tung rep.
|
| 18 |
+
- Phat hien loi bang rule engine dua tren landmark va goc khop.
|
| 19 |
+
- Hien thi frame hoc vien va frame mau cho tung rep.
|
| 20 |
+
- Ve mui ten do vao vi tri loi dua tren landmark rule-based.
|
| 21 |
+
- Sinh feedback tieng Viet ngan gon bang VLM neu cau hinh NVIDIA API.
|
| 22 |
+
- Luu ket qua test video thanh file JSON de review lai.
|
| 23 |
+
|
| 24 |
+
## Ngoai Pham Vi Hien Tai
|
| 25 |
+
|
| 26 |
+
- Chua ho tro nhieu bai tap ngoai hit dat trong UI production.
|
| 27 |
+
- Chua phan tich video realtime tu camera.
|
| 28 |
+
- Chua dung VLM de quyet dinh loi chinh hoac toa do arrow. VLM chi ho tro feedback chu.
|
| 29 |
+
- Chua dung ket qua VLM de thay the rule engine.
|
| 30 |
+
|
| 31 |
+
## Yeu Cau Thanh Cong
|
| 32 |
+
|
| 33 |
+
- App chay duoc bang `reflex run`.
|
| 34 |
+
- Nguoi dung upload video va nhan ket qua theo tung rep.
|
| 35 |
+
- Loi chinh phai den tu rule-based analysis, co the giai thich va kiem thu.
|
| 36 |
+
- Arrow phai tro vao co the hoc vien, khong tro vao nen anh.
|
| 37 |
+
- Cac video trong `data/tests` co the duoc chay batch test va xuat mot file JSON tong hop.
|
push_up/analysis_service.py
CHANGED
|
@@ -9,8 +9,10 @@ import uuid
|
|
| 9 |
|
| 10 |
import cv2
|
| 11 |
import mediapipe as mp
|
|
|
|
| 12 |
|
| 13 |
from push_up.evaluator import PushUpEvaluator
|
|
|
|
| 14 |
from push_up.processor import VideoProcessor
|
| 15 |
|
| 16 |
|
|
@@ -18,6 +20,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|
| 18 |
TEMPLATE_SOURCE = BASE_DIR / "data" / "templates" / "push_up_template.mp4"
|
| 19 |
TEMPLATE_CACHE = BASE_DIR / "data" / "processed" / "pushup_template.pkl"
|
| 20 |
DEBUG_ARTIFACTS_ROOT = BASE_DIR / "analysis_artifacts"
|
|
|
|
| 21 |
ANALYSIS_DIR = Path("analysis")
|
| 22 |
|
| 23 |
ERROR_GUIDANCE = {
|
|
@@ -36,6 +39,14 @@ ERROR_LABELS = {
|
|
| 36 |
"hip_pike": "Nhô mông quá cao",
|
| 37 |
}
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
SEVERITY_RANK = {"high": 3, "medium": 2, "low": 1}
|
| 40 |
|
| 41 |
|
|
@@ -96,11 +107,6 @@ def analyze_pushup(
|
|
| 96 |
if not template_video_path.exists():
|
| 97 |
template_video_path = TEMPLATE_SOURCE
|
| 98 |
|
| 99 |
-
student_data, _ = processor.process_video_from_path(str(student_video_path))
|
| 100 |
-
result = evaluator.evaluate(expert_data, student_data)
|
| 101 |
-
if result.get("error"):
|
| 102 |
-
return {"error": result["error"]}
|
| 103 |
-
|
| 104 |
run_id = ""
|
| 105 |
run_dir: Path | None = None
|
| 106 |
if save_artifacts:
|
|
@@ -108,39 +114,75 @@ def analyze_pushup(
|
|
| 108 |
run_dir = upload_root / ANALYSIS_DIR / run_id
|
| 109 |
run_dir.mkdir(parents=True, exist_ok=True)
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
rep_cards = []
|
| 112 |
error_counter: Counter[str] = Counter()
|
| 113 |
high_severity_reps = 0
|
| 114 |
good_reps = 0
|
| 115 |
|
| 116 |
for rep in result["rep_results"]:
|
| 117 |
-
|
|
|
|
| 118 |
expert_frame = expert_data[rep["w_pair"][1]]
|
| 119 |
|
| 120 |
student_rel = ""
|
| 121 |
expert_rel = ""
|
|
|
|
| 122 |
|
| 123 |
if save_artifacts:
|
| 124 |
student_artifact = ANALYSIS_DIR / run_id / f"student_rep_{rep['rep_num']}.jpg"
|
| 125 |
expert_artifact = ANALYSIS_DIR / run_id / f"expert_rep_{rep['rep_num']}.jpg"
|
| 126 |
-
|
|
|
|
|
|
|
| 127 |
processor=processor,
|
| 128 |
video_path=student_video_path,
|
| 129 |
frame_idx=student_frame["frame_idx"],
|
| 130 |
-
destination=
|
| 131 |
flip=student_frame.get("flipped", False),
|
| 132 |
)
|
| 133 |
_save_pose_frame(
|
| 134 |
processor=processor,
|
| 135 |
video_path=template_video_path,
|
| 136 |
frame_idx=expert_frame["frame_idx"],
|
| 137 |
-
destination=
|
| 138 |
flip=expert_frame.get("flipped", False),
|
| 139 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
student_rel = str(student_artifact)
|
| 141 |
expert_rel = str(expert_artifact)
|
| 142 |
|
| 143 |
-
rep_errors = [_serialize_error(error) for error in rep["errors"]]
|
| 144 |
if rep["score"] >= 0.88 and not rep_errors:
|
| 145 |
good_reps += 1
|
| 146 |
if any(error["severity"] == "high" for error in rep_errors):
|
|
@@ -158,6 +200,12 @@ def analyze_pushup(
|
|
| 158 |
"error_labels": [error["label"] for error in rep_errors],
|
| 159 |
"primary_error": rep_errors[0]["label"] if rep_errors else "Không có lỗi nghiêm trọng",
|
| 160 |
"feedback": _rep_feedback(rep_errors),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
"student_frame_path": student_rel,
|
| 162 |
"expert_frame_path": expert_rel,
|
| 163 |
"rule_score_pct": round(float(rep["rule_score"]) * 100, 1),
|
|
@@ -199,28 +247,103 @@ def analyze_pushup(
|
|
| 199 |
"rep_results": rep_cards,
|
| 200 |
"student_video_path": "",
|
| 201 |
}
|
|
|
|
| 202 |
|
| 203 |
if save_artifacts and run_dir is not None:
|
| 204 |
payload["student_video_path"] = _safe_relative_path(student_video_path, upload_root)
|
| 205 |
with (run_dir / "result.json").open("w", encoding="utf-8") as file:
|
| 206 |
json.dump(payload, file, ensure_ascii=False, indent=2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
|
| 208 |
return payload
|
| 209 |
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
def _save_pose_frame(
|
| 212 |
processor: VideoProcessor,
|
| 213 |
video_path: Path,
|
| 214 |
frame_idx: int,
|
| 215 |
destination: Path,
|
| 216 |
flip: bool,
|
| 217 |
-
) ->
|
| 218 |
destination.parent.mkdir(parents=True, exist_ok=True)
|
| 219 |
frame = processor.get_frame(str(video_path), frame_idx, flip=flip)
|
| 220 |
if frame is None:
|
| 221 |
-
return
|
| 222 |
|
| 223 |
_, landmarks = processor.engine.extract_kinematics(frame.copy(), is_static=True)
|
|
|
|
| 224 |
if landmarks:
|
| 225 |
mp.solutions.drawing_utils.draw_landmarks(
|
| 226 |
frame,
|
|
@@ -228,6 +351,245 @@ def _save_pose_frame(
|
|
| 228 |
mp.solutions.pose.POSE_CONNECTIONS,
|
| 229 |
)
|
| 230 |
cv2.imwrite(str(destination), frame)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
|
| 233 |
def _serialize_error(error: dict) -> dict:
|
|
@@ -319,3 +681,10 @@ def _safe_relative_path(path: Path, base: Path) -> str:
|
|
| 319 |
return path.relative_to(base).as_posix()
|
| 320 |
except ValueError:
|
| 321 |
return path.as_posix()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
import cv2
|
| 11 |
import mediapipe as mp
|
| 12 |
+
import numpy as np
|
| 13 |
|
| 14 |
from push_up.evaluator import PushUpEvaluator
|
| 15 |
+
from push_up.feedback_graph import generate_rep_visual_feedback
|
| 16 |
from push_up.processor import VideoProcessor
|
| 17 |
|
| 18 |
|
|
|
|
| 20 |
TEMPLATE_SOURCE = BASE_DIR / "data" / "templates" / "push_up_template.mp4"
|
| 21 |
TEMPLATE_CACHE = BASE_DIR / "data" / "processed" / "pushup_template.pkl"
|
| 22 |
DEBUG_ARTIFACTS_ROOT = BASE_DIR / "analysis_artifacts"
|
| 23 |
+
VIDEO_TEST_LOG_FILE = BASE_DIR / ".ai-log" / "video_test_runs.jsonl"
|
| 24 |
ANALYSIS_DIR = Path("analysis")
|
| 25 |
|
| 26 |
ERROR_GUIDANCE = {
|
|
|
|
| 39 |
"hip_pike": "Nhô mông quá cao",
|
| 40 |
}
|
| 41 |
|
| 42 |
+
ARROW_LABELS = {
|
| 43 |
+
"not_deep_enough": "Ngực chưa đủ thấp",
|
| 44 |
+
"hip_sag": "Hông bị võng xuống",
|
| 45 |
+
"body_not_straight": "Hông lệch khỏi trục",
|
| 46 |
+
"head_misaligned": "Cổ cúi quá mức",
|
| 47 |
+
"hip_pike": "Hông quá cao",
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
SEVERITY_RANK = {"high": 3, "medium": 2, "low": 1}
|
| 51 |
|
| 52 |
|
|
|
|
| 107 |
if not template_video_path.exists():
|
| 108 |
template_video_path = TEMPLATE_SOURCE
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
run_id = ""
|
| 111 |
run_dir: Path | None = None
|
| 112 |
if save_artifacts:
|
|
|
|
| 114 |
run_dir = upload_root / ANALYSIS_DIR / run_id
|
| 115 |
run_dir.mkdir(parents=True, exist_ok=True)
|
| 116 |
|
| 117 |
+
student_data, _ = processor.process_video_from_path(str(student_video_path))
|
| 118 |
+
result = evaluator.evaluate(expert_data, student_data)
|
| 119 |
+
if result.get("error"):
|
| 120 |
+
payload = {
|
| 121 |
+
"error": result["error"],
|
| 122 |
+
"exercise": "pushup",
|
| 123 |
+
"exercise_label": "Hít đất / Push-up",
|
| 124 |
+
"student_video_path": _safe_relative_path(student_video_path, upload_root),
|
| 125 |
+
}
|
| 126 |
+
if save_artifacts and run_dir is not None:
|
| 127 |
+
with (run_dir / "result.json").open("w", encoding="utf-8") as file:
|
| 128 |
+
json.dump(payload, file, ensure_ascii=False, indent=2)
|
| 129 |
+
_write_video_test_run_log(
|
| 130 |
+
payload=payload,
|
| 131 |
+
run_id=run_id,
|
| 132 |
+
run_dir=run_dir,
|
| 133 |
+
student_video_path=student_video_path,
|
| 134 |
+
template_video_path=template_video_path,
|
| 135 |
+
)
|
| 136 |
+
return payload
|
| 137 |
+
|
| 138 |
rep_cards = []
|
| 139 |
error_counter: Counter[str] = Counter()
|
| 140 |
high_severity_reps = 0
|
| 141 |
good_reps = 0
|
| 142 |
|
| 143 |
for rep in result["rep_results"]:
|
| 144 |
+
rep_errors = [_serialize_error(error) for error in rep["errors"]]
|
| 145 |
+
student_frame = _select_student_frame_for_rep(rep, student_data)
|
| 146 |
expert_frame = expert_data[rep["w_pair"][1]]
|
| 147 |
|
| 148 |
student_rel = ""
|
| 149 |
expert_rel = ""
|
| 150 |
+
llm_visual = _empty_rep_visual_feedback()
|
| 151 |
|
| 152 |
if save_artifacts:
|
| 153 |
student_artifact = ANALYSIS_DIR / run_id / f"student_rep_{rep['rep_num']}.jpg"
|
| 154 |
expert_artifact = ANALYSIS_DIR / run_id / f"expert_rep_{rep['rep_num']}.jpg"
|
| 155 |
+
student_abs = upload_root / student_artifact
|
| 156 |
+
expert_abs = upload_root / expert_artifact
|
| 157 |
+
student_landmarks = _save_pose_frame(
|
| 158 |
processor=processor,
|
| 159 |
video_path=student_video_path,
|
| 160 |
frame_idx=student_frame["frame_idx"],
|
| 161 |
+
destination=student_abs,
|
| 162 |
flip=student_frame.get("flipped", False),
|
| 163 |
)
|
| 164 |
_save_pose_frame(
|
| 165 |
processor=processor,
|
| 166 |
video_path=template_video_path,
|
| 167 |
frame_idx=expert_frame["frame_idx"],
|
| 168 |
+
destination=expert_abs,
|
| 169 |
flip=expert_frame.get("flipped", False),
|
| 170 |
)
|
| 171 |
+
if _should_request_rep_visual_feedback(rep, rep_errors):
|
| 172 |
+
llm_visual = generate_rep_visual_feedback(
|
| 173 |
+
student_image_path=str(student_abs),
|
| 174 |
+
expert_image_path=str(expert_abs),
|
| 175 |
+
rep_context=_build_rep_visual_context(rep, rep_errors),
|
| 176 |
+
)
|
| 177 |
+
rule_arrow = _build_rule_arrow(rep_errors, student_landmarks)
|
| 178 |
+
if rule_arrow:
|
| 179 |
+
llm_visual["arrow"] = rule_arrow
|
| 180 |
+
_draw_llm_arrow_on_image(student_abs, rule_arrow)
|
| 181 |
+
elif llm_visual.get("arrow"):
|
| 182 |
+
_draw_llm_arrow_on_image(student_abs, llm_visual["arrow"])
|
| 183 |
student_rel = str(student_artifact)
|
| 184 |
expert_rel = str(expert_artifact)
|
| 185 |
|
|
|
|
| 186 |
if rep["score"] >= 0.88 and not rep_errors:
|
| 187 |
good_reps += 1
|
| 188 |
if any(error["severity"] == "high" for error in rep_errors):
|
|
|
|
| 200 |
"error_labels": [error["label"] for error in rep_errors],
|
| 201 |
"primary_error": rep_errors[0]["label"] if rep_errors else "Không có lỗi nghiêm trọng",
|
| 202 |
"feedback": _rep_feedback(rep_errors),
|
| 203 |
+
"rule_feedback": _rep_feedback(rep_errors),
|
| 204 |
+
"llm_feedback": llm_visual.get("feedback", ""),
|
| 205 |
+
"llm_feedback_source": llm_visual.get("source", ""),
|
| 206 |
+
"llm_feedback_error": llm_visual.get("error", ""),
|
| 207 |
+
"llm_visual_error_label": llm_visual.get("visual_error_label", ""),
|
| 208 |
+
"llm_arrow": llm_visual.get("arrow"),
|
| 209 |
"student_frame_path": student_rel,
|
| 210 |
"expert_frame_path": expert_rel,
|
| 211 |
"rule_score_pct": round(float(rep["rule_score"]) * 100, 1),
|
|
|
|
| 247 |
"rep_results": rep_cards,
|
| 248 |
"student_video_path": "",
|
| 249 |
}
|
| 250 |
+
payload["coach_feedback"] = ""
|
| 251 |
|
| 252 |
if save_artifacts and run_dir is not None:
|
| 253 |
payload["student_video_path"] = _safe_relative_path(student_video_path, upload_root)
|
| 254 |
with (run_dir / "result.json").open("w", encoding="utf-8") as file:
|
| 255 |
json.dump(payload, file, ensure_ascii=False, indent=2)
|
| 256 |
+
_write_video_test_run_log(
|
| 257 |
+
payload=payload,
|
| 258 |
+
run_id=run_id,
|
| 259 |
+
run_dir=run_dir,
|
| 260 |
+
student_video_path=student_video_path,
|
| 261 |
+
template_video_path=template_video_path,
|
| 262 |
+
)
|
| 263 |
|
| 264 |
return payload
|
| 265 |
|
| 266 |
|
| 267 |
+
def _write_video_test_run_log(
|
| 268 |
+
*,
|
| 269 |
+
payload: dict,
|
| 270 |
+
run_id: str,
|
| 271 |
+
run_dir: Path,
|
| 272 |
+
student_video_path: Path,
|
| 273 |
+
template_video_path: Path,
|
| 274 |
+
) -> None:
|
| 275 |
+
"""Append a lightweight per-video test log without copying image artifacts."""
|
| 276 |
+
try:
|
| 277 |
+
index_entry = _build_video_test_index_entry(
|
| 278 |
+
payload=payload,
|
| 279 |
+
run_id=run_id,
|
| 280 |
+
run_dir=run_dir,
|
| 281 |
+
student_video_path=student_video_path,
|
| 282 |
+
template_video_path=template_video_path,
|
| 283 |
+
)
|
| 284 |
+
VIDEO_TEST_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 285 |
+
with VIDEO_TEST_LOG_FILE.open("a", encoding="utf-8") as file:
|
| 286 |
+
file.write(json.dumps(index_entry, ensure_ascii=False) + "\n")
|
| 287 |
+
except OSError as exc:
|
| 288 |
+
print(f"Video test logging failed: {exc}")
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def _build_video_test_index_entry(
|
| 292 |
+
*,
|
| 293 |
+
payload: dict,
|
| 294 |
+
run_id: str,
|
| 295 |
+
run_dir: Path,
|
| 296 |
+
student_video_path: Path,
|
| 297 |
+
template_video_path: Path,
|
| 298 |
+
) -> dict:
|
| 299 |
+
rep_results = payload.get("rep_results", [])
|
| 300 |
+
created_at = datetime.now().isoformat(timespec="seconds")
|
| 301 |
+
return {
|
| 302 |
+
"ts": created_at,
|
| 303 |
+
"run_id": run_id,
|
| 304 |
+
"exercise": payload.get("exercise", "pushup"),
|
| 305 |
+
"student_video_name": student_video_path.name,
|
| 306 |
+
"student_video_path": student_video_path.as_posix(),
|
| 307 |
+
"template_video_path": template_video_path.as_posix(),
|
| 308 |
+
"overall_score_pct": payload.get("overall_score_pct"),
|
| 309 |
+
"student_reps": payload.get("student_reps"),
|
| 310 |
+
"expert_reps": payload.get("expert_reps"),
|
| 311 |
+
"error": payload.get("error"),
|
| 312 |
+
"summary": payload.get("summary", ""),
|
| 313 |
+
"main_errors": payload.get("main_errors", []),
|
| 314 |
+
"result_json": _project_relative_path(run_dir / "result.json"),
|
| 315 |
+
"artifact_dir": _project_relative_path(run_dir),
|
| 316 |
+
"rep_logs": [
|
| 317 |
+
{
|
| 318 |
+
"rep_num": rep.get("rep_num"),
|
| 319 |
+
"score_pct": rep.get("score_pct"),
|
| 320 |
+
"primary_error": rep.get("primary_error"),
|
| 321 |
+
"rule_feedback": rep.get("rule_feedback"),
|
| 322 |
+
"llm_feedback": rep.get("llm_feedback"),
|
| 323 |
+
"llm_feedback_source": rep.get("llm_feedback_source"),
|
| 324 |
+
"llm_feedback_error": rep.get("llm_feedback_error"),
|
| 325 |
+
"student_frame_path": rep.get("student_frame_path", ""),
|
| 326 |
+
"expert_frame_path": rep.get("expert_frame_path", ""),
|
| 327 |
+
}
|
| 328 |
+
for rep in rep_results
|
| 329 |
+
],
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
|
| 333 |
def _save_pose_frame(
|
| 334 |
processor: VideoProcessor,
|
| 335 |
video_path: Path,
|
| 336 |
frame_idx: int,
|
| 337 |
destination: Path,
|
| 338 |
flip: bool,
|
| 339 |
+
) -> list[dict]:
|
| 340 |
destination.parent.mkdir(parents=True, exist_ok=True)
|
| 341 |
frame = processor.get_frame(str(video_path), frame_idx, flip=flip)
|
| 342 |
if frame is None:
|
| 343 |
+
return []
|
| 344 |
|
| 345 |
_, landmarks = processor.engine.extract_kinematics(frame.copy(), is_static=True)
|
| 346 |
+
landmark_points = _landmarks_to_points(landmarks) if landmarks else []
|
| 347 |
if landmarks:
|
| 348 |
mp.solutions.drawing_utils.draw_landmarks(
|
| 349 |
frame,
|
|
|
|
| 351 |
mp.solutions.pose.POSE_CONNECTIONS,
|
| 352 |
)
|
| 353 |
cv2.imwrite(str(destination), frame)
|
| 354 |
+
return landmark_points
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def _landmarks_to_points(landmarks) -> list[dict]:
|
| 358 |
+
return [
|
| 359 |
+
{
|
| 360 |
+
"x": float(landmark.x),
|
| 361 |
+
"y": float(landmark.y),
|
| 362 |
+
"visibility": float(getattr(landmark, "visibility", 1.0)),
|
| 363 |
+
}
|
| 364 |
+
for landmark in landmarks.landmark
|
| 365 |
+
]
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def _select_student_frame_for_rep(rep: dict, student_data: list[dict]) -> dict:
|
| 369 |
+
error_frame_indices = []
|
| 370 |
+
for error in rep.get("errors", []):
|
| 371 |
+
error_frame_indices.extend(error.get("frames", []))
|
| 372 |
+
|
| 373 |
+
if not error_frame_indices:
|
| 374 |
+
return student_data[rep["w_pair"][0]]
|
| 375 |
+
|
| 376 |
+
target_frame = error_frame_indices[0]
|
| 377 |
+
start, end = rep.get("range", (0, len(student_data)))
|
| 378 |
+
candidates = student_data[start:end] or student_data
|
| 379 |
+
return min(candidates, key=lambda frame: abs(frame["frame_idx"] - target_frame))
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def _should_request_rep_visual_feedback(rep: dict, rep_errors: list[dict]) -> bool:
|
| 383 |
+
return bool(rep_errors) or float(rep.get("score", 1.0)) < 0.88
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def _empty_rep_visual_feedback() -> dict:
|
| 387 |
+
return {
|
| 388 |
+
"source": "",
|
| 389 |
+
"is_error": False,
|
| 390 |
+
"visual_error_label": "",
|
| 391 |
+
"feedback": "",
|
| 392 |
+
"arrow": None,
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def _build_rep_visual_context(rep: dict, rep_errors: list[dict]) -> dict:
|
| 397 |
+
return {
|
| 398 |
+
"rep_num": int(rep["rep_num"]),
|
| 399 |
+
"score_pct": round(float(rep["score"]) * 100, 1),
|
| 400 |
+
"rule_score_pct": round(float(rep["rule_score"]) * 100, 1),
|
| 401 |
+
"dtw_score_pct": round(float(rep["dtw_score"]) * 100, 1),
|
| 402 |
+
"rule_errors": [
|
| 403 |
+
{
|
| 404 |
+
"type": error["type"],
|
| 405 |
+
"label": error["label"],
|
| 406 |
+
"severity": error["severity"],
|
| 407 |
+
"guidance": error["guidance"],
|
| 408 |
+
}
|
| 409 |
+
for error in rep_errors
|
| 410 |
+
],
|
| 411 |
+
"rule_feedback": _rep_feedback(rep_errors),
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def _build_rule_arrow(rep_errors: list[dict], landmark_points: list[dict]) -> dict | None:
|
| 416 |
+
if not rep_errors or not landmark_points:
|
| 417 |
+
return None
|
| 418 |
+
|
| 419 |
+
for error in rep_errors:
|
| 420 |
+
error_type = error.get("type", "")
|
| 421 |
+
target = _arrow_target_for_error(error_type, landmark_points)
|
| 422 |
+
if target:
|
| 423 |
+
return {
|
| 424 |
+
"x": target["x"],
|
| 425 |
+
"y": target["y"],
|
| 426 |
+
"label": ARROW_LABELS.get(error_type, error.get("label") or "Vị trí cần sửa"),
|
| 427 |
+
"source": "rule_landmark",
|
| 428 |
+
"error_type": error_type,
|
| 429 |
+
}
|
| 430 |
+
return None
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def _arrow_target_for_error(error_type: str, landmark_points: list[dict]) -> dict | None:
|
| 434 |
+
if error_type in {"hip_sag", "hip_pike", "body_not_straight"}:
|
| 435 |
+
return (
|
| 436 |
+
_average_pose_points(landmark_points, [23, 24])
|
| 437 |
+
or _average_pose_points(landmark_points, [11, 12, 23, 24])
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
if error_type == "not_deep_enough":
|
| 441 |
+
shoulder = _average_pose_points(landmark_points, [11, 12])
|
| 442 |
+
hip = _average_pose_points(landmark_points, [23, 24])
|
| 443 |
+
if shoulder and hip:
|
| 444 |
+
return _interpolate_pose_points(shoulder, hip, 0.30)
|
| 445 |
+
return shoulder or _average_pose_points(landmark_points, [13, 14])
|
| 446 |
+
|
| 447 |
+
if error_type == "head_misaligned":
|
| 448 |
+
return (
|
| 449 |
+
_average_pose_points(landmark_points, [0, 7, 8])
|
| 450 |
+
or _average_pose_points(landmark_points, [0, 11, 12])
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
return _average_pose_points(landmark_points, [11, 12, 23, 24])
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def _average_pose_points(landmark_points: list[dict], indices: list[int]) -> dict | None:
|
| 457 |
+
valid_points = [
|
| 458 |
+
point
|
| 459 |
+
for index in indices
|
| 460 |
+
if (point := _pose_point(landmark_points, index)) is not None
|
| 461 |
+
and point["visibility"] >= 0.10
|
| 462 |
+
]
|
| 463 |
+
if not valid_points:
|
| 464 |
+
valid_points = [
|
| 465 |
+
point
|
| 466 |
+
for index in indices
|
| 467 |
+
if (point := _pose_point(landmark_points, index)) is not None
|
| 468 |
+
]
|
| 469 |
+
if not valid_points:
|
| 470 |
+
return None
|
| 471 |
+
|
| 472 |
+
return {
|
| 473 |
+
"x": _clamp_float(sum(point["x"] for point in valid_points) / len(valid_points), 0.02, 0.98),
|
| 474 |
+
"y": _clamp_float(sum(point["y"] for point in valid_points) / len(valid_points), 0.02, 0.98),
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def _pose_point(landmark_points: list[dict], index: int) -> dict | None:
|
| 479 |
+
if index >= len(landmark_points):
|
| 480 |
+
return None
|
| 481 |
+
|
| 482 |
+
point = landmark_points[index]
|
| 483 |
+
x = float(point.get("x", -1.0))
|
| 484 |
+
y = float(point.get("y", -1.0))
|
| 485 |
+
if not np.isfinite(x) or not np.isfinite(y) or x < -0.05 or x > 1.05 or y < -0.05 or y > 1.05:
|
| 486 |
+
return None
|
| 487 |
+
return {
|
| 488 |
+
"x": _clamp_float(x, 0.02, 0.98),
|
| 489 |
+
"y": _clamp_float(y, 0.02, 0.98),
|
| 490 |
+
"visibility": float(point.get("visibility", 1.0)),
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
def _interpolate_pose_points(start: dict, end: dict, ratio: float) -> dict:
|
| 495 |
+
ratio = _clamp_float(ratio, 0.0, 1.0)
|
| 496 |
+
return {
|
| 497 |
+
"x": _clamp_float(start["x"] + (end["x"] - start["x"]) * ratio, 0.02, 0.98),
|
| 498 |
+
"y": _clamp_float(start["y"] + (end["y"] - start["y"]) * ratio, 0.02, 0.98),
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def _clamp_float(value: float, lower: float, upper: float) -> float:
|
| 503 |
+
return max(lower, min(value, upper))
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
def _draw_llm_arrow_on_image(image_path: Path, arrow: dict) -> None:
|
| 507 |
+
frame = cv2.imread(str(image_path))
|
| 508 |
+
if frame is None:
|
| 509 |
+
return
|
| 510 |
+
|
| 511 |
+
height, width = frame.shape[:2]
|
| 512 |
+
target_x = int(float(arrow["x"]) * width)
|
| 513 |
+
target_y = int(float(arrow["y"]) * height)
|
| 514 |
+
target_x = _clamp(target_x, 0, width - 1)
|
| 515 |
+
target_y = _clamp(target_y, 0, height - 1)
|
| 516 |
+
start_x, start_y = _arrow_start_point(target_x, target_y, width, height)
|
| 517 |
+
label = str(arrow.get("label") or "Vị trí cần sửa")
|
| 518 |
+
|
| 519 |
+
cv2.arrowedLine(
|
| 520 |
+
frame,
|
| 521 |
+
(start_x, start_y),
|
| 522 |
+
(target_x, target_y),
|
| 523 |
+
(0, 0, 255),
|
| 524 |
+
5,
|
| 525 |
+
tipLength=0.22,
|
| 526 |
+
)
|
| 527 |
+
cv2.circle(frame, (target_x, target_y), 22, (0, 0, 255), 5)
|
| 528 |
+
_draw_label(frame, label, start_x, start_y - 42)
|
| 529 |
+
cv2.imwrite(str(image_path), frame)
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def _arrow_start_point(target_x: int, target_y: int, width: int, height: int) -> tuple[int, int]:
|
| 533 |
+
x_offset = _clamp(int(width * 0.24), 150, 240)
|
| 534 |
+
y_offset = _clamp(int(height * 0.20), 90, 150)
|
| 535 |
+
|
| 536 |
+
start_x = target_x - x_offset
|
| 537 |
+
if start_x < 20:
|
| 538 |
+
start_x = target_x + x_offset
|
| 539 |
+
|
| 540 |
+
start_y = target_y - y_offset
|
| 541 |
+
if start_y < 48:
|
| 542 |
+
start_y = target_y + y_offset
|
| 543 |
+
|
| 544 |
+
return (
|
| 545 |
+
_clamp(start_x, 20, width - 40),
|
| 546 |
+
_clamp(start_y, 48, height - 32),
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def _draw_label(frame, label: str, x: int, y: int) -> None:
|
| 551 |
+
height, width = frame.shape[:2]
|
| 552 |
+
try:
|
| 553 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 554 |
+
except ImportError:
|
| 555 |
+
return
|
| 556 |
+
|
| 557 |
+
font = _load_label_font(ImageFont, _clamp(width // 44, 18, 26))
|
| 558 |
+
image = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
|
| 559 |
+
draw = ImageDraw.Draw(image)
|
| 560 |
+
text_bbox = draw.textbbox((0, 0), label, font=font)
|
| 561 |
+
text_w = text_bbox[2] - text_bbox[0]
|
| 562 |
+
text_h = text_bbox[3] - text_bbox[1]
|
| 563 |
+
padding_x = 9
|
| 564 |
+
padding_y = 7
|
| 565 |
+
x = _clamp(x, 8, width - text_w - padding_x * 2 - 8)
|
| 566 |
+
y = _clamp(y, 8, height - text_h - padding_y * 2 - 8)
|
| 567 |
+
|
| 568 |
+
draw.rounded_rectangle(
|
| 569 |
+
(x, y, x + text_w + padding_x * 2, y + text_h + padding_y * 2),
|
| 570 |
+
radius=4,
|
| 571 |
+
fill=(220, 0, 0),
|
| 572 |
+
)
|
| 573 |
+
draw.text((x + padding_x, y + padding_y), label, font=font, fill=(255, 255, 255))
|
| 574 |
+
frame[:, :] = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
def _load_label_font(image_font, size: int):
|
| 578 |
+
font_candidates = [
|
| 579 |
+
Path("C:/Windows/Fonts/arial.ttf"),
|
| 580 |
+
Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
|
| 581 |
+
Path("/System/Library/Fonts/Supplemental/Arial Unicode.ttf"),
|
| 582 |
+
]
|
| 583 |
+
for font_path in font_candidates:
|
| 584 |
+
if font_path.exists():
|
| 585 |
+
return image_font.truetype(str(font_path), size=size)
|
| 586 |
+
return image_font.load_default()
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
def _clamp(value: int, lower: int, upper: int) -> int:
|
| 590 |
+
if upper < lower:
|
| 591 |
+
return lower
|
| 592 |
+
return max(lower, min(value, upper))
|
| 593 |
|
| 594 |
|
| 595 |
def _serialize_error(error: dict) -> dict:
|
|
|
|
| 681 |
return path.relative_to(base).as_posix()
|
| 682 |
except ValueError:
|
| 683 |
return path.as_posix()
|
| 684 |
+
|
| 685 |
+
|
| 686 |
+
def _project_relative_path(path: Path) -> str:
|
| 687 |
+
try:
|
| 688 |
+
return path.relative_to(BASE_DIR).as_posix()
|
| 689 |
+
except ValueError:
|
| 690 |
+
return path.as_posix()
|
push_up/feedback_graph.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from functools import lru_cache
|
| 6 |
+
import ast
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
import re
|
| 11 |
+
import unicodedata
|
| 12 |
+
from typing import Any, TypedDict
|
| 13 |
+
|
| 14 |
+
from dotenv import load_dotenv
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
load_dotenv()
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"
|
| 22 |
+
DEFAULT_NVIDIA_MODEL = "deepseek-ai/deepseek-v4-flash"
|
| 23 |
+
DEFAULT_NVIDIA_VISION_MODEL = "meta/llama-3.2-11b-vision-instruct"
|
| 24 |
+
DEFAULT_NVIDIA_VISION_MAX_TOKENS = 1200
|
| 25 |
+
MAX_FEEDBACK_CHARS = 850
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class FeedbackState(TypedDict, total=False):
|
| 29 |
+
analysis: dict[str, Any]
|
| 30 |
+
evidence: dict[str, Any]
|
| 31 |
+
feedback: str
|
| 32 |
+
warnings: list[str]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _env_int(name: str, default: int) -> int:
|
| 36 |
+
raw_value = os.getenv(name, "").strip()
|
| 37 |
+
if not raw_value:
|
| 38 |
+
return default
|
| 39 |
+
try:
|
| 40 |
+
return int(raw_value)
|
| 41 |
+
except ValueError:
|
| 42 |
+
return default
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def generate_coach_feedback(analysis_result: dict[str, Any]) -> str:
|
| 46 |
+
"""Generate a short Vietnamese coaching note from deterministic analysis.
|
| 47 |
+
|
| 48 |
+
The graph is intentionally narrow: MediaPipe/rules decide the errors, the
|
| 49 |
+
LLM only rewrites those facts into actionable coaching language.
|
| 50 |
+
"""
|
| 51 |
+
fallback = _fallback_feedback(analysis_result)
|
| 52 |
+
api_key = os.getenv("NVIDIA_API_KEY", "").strip()
|
| 53 |
+
if not api_key or api_key == "nvapi-...":
|
| 54 |
+
return fallback
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
graph = _compiled_graph()
|
| 58 |
+
final_state = graph.invoke(
|
| 59 |
+
{
|
| 60 |
+
"analysis": analysis_result,
|
| 61 |
+
"feedback": fallback,
|
| 62 |
+
"warnings": [],
|
| 63 |
+
}
|
| 64 |
+
)
|
| 65 |
+
return final_state.get("feedback") or fallback
|
| 66 |
+
except Exception:
|
| 67 |
+
return fallback
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def generate_rep_visual_feedback(
|
| 71 |
+
*,
|
| 72 |
+
student_image_path: str,
|
| 73 |
+
expert_image_path: str,
|
| 74 |
+
rep_context: dict[str, Any],
|
| 75 |
+
) -> dict[str, Any]:
|
| 76 |
+
"""Use a VLM to inspect one student/expert frame pair.
|
| 77 |
+
|
| 78 |
+
Returns compact text feedback. Arrow placement is handled downstream from
|
| 79 |
+
rule-based landmarks so it stays anchored to the student's body.
|
| 80 |
+
"""
|
| 81 |
+
api_key = os.getenv("NVIDIA_API_KEY", "").strip()
|
| 82 |
+
if not api_key or api_key == "nvapi-...":
|
| 83 |
+
return _rep_visual_fallback(rep_context, "missing NVIDIA_API_KEY")
|
| 84 |
+
|
| 85 |
+
try:
|
| 86 |
+
comparison_image_path = _make_comparison_image(student_image_path, expert_image_path)
|
| 87 |
+
parsed = _call_and_parse_vlm_for_rep(
|
| 88 |
+
comparison_image_path=str(comparison_image_path),
|
| 89 |
+
rep_context=rep_context,
|
| 90 |
+
api_key=api_key,
|
| 91 |
+
)
|
| 92 |
+
return _validate_rep_visual_feedback(parsed, rep_context)
|
| 93 |
+
except Exception as exc:
|
| 94 |
+
logger.warning("VLM rep feedback failed: %s", exc)
|
| 95 |
+
return _rep_visual_fallback(rep_context, str(exc))
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@lru_cache(maxsize=1)
|
| 99 |
+
def _compiled_graph():
|
| 100 |
+
from langgraph.graph import END, START, StateGraph
|
| 101 |
+
|
| 102 |
+
graph = StateGraph(FeedbackState)
|
| 103 |
+
graph.add_node("build_evidence", _build_evidence)
|
| 104 |
+
graph.add_node("generate_feedback", _generate_feedback)
|
| 105 |
+
graph.add_node("validate_feedback", _validate_feedback)
|
| 106 |
+
graph.add_edge(START, "build_evidence")
|
| 107 |
+
graph.add_edge("build_evidence", "generate_feedback")
|
| 108 |
+
graph.add_edge("generate_feedback", "validate_feedback")
|
| 109 |
+
graph.add_edge("validate_feedback", END)
|
| 110 |
+
return graph.compile()
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _build_evidence(state: FeedbackState) -> FeedbackState:
|
| 114 |
+
analysis = state["analysis"]
|
| 115 |
+
rep_results = analysis.get("rep_results", [])
|
| 116 |
+
weakest_reps = sorted(rep_results, key=lambda rep: rep.get("score_pct", 100))[:3]
|
| 117 |
+
|
| 118 |
+
evidence = {
|
| 119 |
+
"exercise": analysis.get("exercise_label", "Hít đất"),
|
| 120 |
+
"overall_score_pct": analysis.get("overall_score_pct", 0),
|
| 121 |
+
"student_reps": analysis.get("student_reps", 0),
|
| 122 |
+
"expert_reps": analysis.get("expert_reps", 0),
|
| 123 |
+
"good_reps": analysis.get("good_reps", 0),
|
| 124 |
+
"serious_reps": analysis.get("serious_reps", 0),
|
| 125 |
+
"summary": analysis.get("summary", ""),
|
| 126 |
+
"main_errors": [
|
| 127 |
+
{
|
| 128 |
+
"label": err.get("label", ""),
|
| 129 |
+
"count": err.get("count", 0),
|
| 130 |
+
"severity": err.get("severity", ""),
|
| 131 |
+
"guidance": err.get("guidance", ""),
|
| 132 |
+
}
|
| 133 |
+
for err in analysis.get("main_errors", [])[:3]
|
| 134 |
+
],
|
| 135 |
+
"weakest_reps": [
|
| 136 |
+
{
|
| 137 |
+
"rep_num": rep.get("rep_num"),
|
| 138 |
+
"score_pct": rep.get("score_pct"),
|
| 139 |
+
"status": rep.get("status"),
|
| 140 |
+
"errors": rep.get("error_labels", []),
|
| 141 |
+
"feedback": rep.get("feedback", ""),
|
| 142 |
+
}
|
| 143 |
+
for rep in weakest_reps
|
| 144 |
+
],
|
| 145 |
+
}
|
| 146 |
+
return {**state, "evidence": evidence}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _generate_feedback(state: FeedbackState) -> FeedbackState:
|
| 150 |
+
from openai import OpenAI
|
| 151 |
+
|
| 152 |
+
client = OpenAI(
|
| 153 |
+
base_url=os.getenv("NVIDIA_BASE_URL", NVIDIA_BASE_URL),
|
| 154 |
+
api_key=os.getenv("NVIDIA_API_KEY"),
|
| 155 |
+
)
|
| 156 |
+
model = os.getenv("NVIDIA_MODEL", DEFAULT_NVIDIA_MODEL)
|
| 157 |
+
prompt = _feedback_prompt(state["evidence"])
|
| 158 |
+
completion = client.chat.completions.create(
|
| 159 |
+
model=model,
|
| 160 |
+
messages=[
|
| 161 |
+
{
|
| 162 |
+
"role": "system",
|
| 163 |
+
"content": (
|
| 164 |
+
"Bạn là huấn luyện viên thể hình. Chỉ dùng dữ liệu đã cung cấp, "
|
| 165 |
+
"không bịa lỗi mới. Trả lời tiếng Việt, ngắn gọn, tối đa 150 chữ."
|
| 166 |
+
),
|
| 167 |
+
},
|
| 168 |
+
{"role": "user", "content": prompt},
|
| 169 |
+
],
|
| 170 |
+
temperature=0.25,
|
| 171 |
+
top_p=0.9,
|
| 172 |
+
max_tokens=500,
|
| 173 |
+
)
|
| 174 |
+
feedback = (completion.choices[0].message.content or "").strip()
|
| 175 |
+
return {**state, "feedback": feedback}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _call_vlm_for_rep(
|
| 179 |
+
*,
|
| 180 |
+
comparison_image_path: str,
|
| 181 |
+
rep_context: dict[str, Any],
|
| 182 |
+
api_key: str,
|
| 183 |
+
retry: bool = False,
|
| 184 |
+
) -> str:
|
| 185 |
+
from openai import OpenAI
|
| 186 |
+
|
| 187 |
+
client = OpenAI(
|
| 188 |
+
base_url=os.getenv("NVIDIA_BASE_URL", NVIDIA_BASE_URL),
|
| 189 |
+
api_key=api_key,
|
| 190 |
+
)
|
| 191 |
+
model = os.getenv("NVIDIA_VISION_MODEL") or DEFAULT_NVIDIA_VISION_MODEL
|
| 192 |
+
completion = client.chat.completions.create(
|
| 193 |
+
model=model,
|
| 194 |
+
messages=[
|
| 195 |
+
{
|
| 196 |
+
"role": "system",
|
| 197 |
+
"content": (
|
| 198 |
+
"Bạn là huấn luyện viên hít đất. Bạn nhận 2 nguồn thông tin: "
|
| 199 |
+
"1) ảnh rep của học viên và ảnh mẫu mentor, 2) kết quả rule-based có thể sai. "
|
| 200 |
+
"Ưu tiên đánh giá trực tiếp từ ảnh. Nếu rule-based mâu thuẫn với ảnh, hãy theo ảnh. "
|
| 201 |
+
"Chỉ trả về JSON hợp lệ, không markdown. "
|
| 202 |
+
"Giữ mọi string value thật ngắn để JSON không bị cắt giữa chừng."
|
| 203 |
+
),
|
| 204 |
+
},
|
| 205 |
+
{
|
| 206 |
+
"role": "user",
|
| 207 |
+
"content": [
|
| 208 |
+
{
|
| 209 |
+
"type": "text",
|
| 210 |
+
"text": _rep_vlm_prompt(rep_context, retry=retry),
|
| 211 |
+
},
|
| 212 |
+
{
|
| 213 |
+
"type": "image_url",
|
| 214 |
+
"image_url": {"url": _image_as_data_url(comparison_image_path)},
|
| 215 |
+
},
|
| 216 |
+
],
|
| 217 |
+
},
|
| 218 |
+
],
|
| 219 |
+
temperature=0.15,
|
| 220 |
+
top_p=0.9,
|
| 221 |
+
max_tokens=_env_int("NVIDIA_VISION_MAX_TOKENS", DEFAULT_NVIDIA_VISION_MAX_TOKENS),
|
| 222 |
+
)
|
| 223 |
+
if not completion.choices:
|
| 224 |
+
raise ValueError("empty VLM choices")
|
| 225 |
+
|
| 226 |
+
choice = completion.choices[0]
|
| 227 |
+
content = _extract_message_text(choice.message)
|
| 228 |
+
if not content:
|
| 229 |
+
finish_reason = getattr(choice, "finish_reason", "")
|
| 230 |
+
raise ValueError(f"empty VLM content; finish_reason={finish_reason}; model={model}")
|
| 231 |
+
finish_reason = getattr(choice, "finish_reason", "")
|
| 232 |
+
if finish_reason == "length":
|
| 233 |
+
raise ValueError(
|
| 234 |
+
"VLM response was truncated before JSON completed; "
|
| 235 |
+
"increase NVIDIA_VISION_MAX_TOKENS or use a shorter vision model response"
|
| 236 |
+
)
|
| 237 |
+
return content.strip()
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _call_and_parse_vlm_for_rep(
|
| 241 |
+
*,
|
| 242 |
+
comparison_image_path: str,
|
| 243 |
+
rep_context: dict[str, Any],
|
| 244 |
+
api_key: str,
|
| 245 |
+
) -> dict[str, Any]:
|
| 246 |
+
first_error = ""
|
| 247 |
+
for retry in (False, True):
|
| 248 |
+
try:
|
| 249 |
+
response_text = _call_vlm_for_rep(
|
| 250 |
+
comparison_image_path=comparison_image_path,
|
| 251 |
+
rep_context=rep_context,
|
| 252 |
+
api_key=api_key,
|
| 253 |
+
retry=retry,
|
| 254 |
+
)
|
| 255 |
+
parsed = _parse_json_object(response_text)
|
| 256 |
+
if not retry and _needs_vlm_retry(parsed):
|
| 257 |
+
raise ValueError("VLM returned incomplete feedback")
|
| 258 |
+
return parsed
|
| 259 |
+
except Exception as exc:
|
| 260 |
+
first_error = first_error or str(exc)
|
| 261 |
+
if retry:
|
| 262 |
+
raise ValueError(f"invalid VLM JSON after retry: {exc}") from exc
|
| 263 |
+
logger.info("Retrying VLM rep feedback after parse failure: %s", exc)
|
| 264 |
+
|
| 265 |
+
raise ValueError(first_error or "unknown VLM parse failure")
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def _extract_message_text(message: Any) -> str:
|
| 269 |
+
content = getattr(message, "content", "")
|
| 270 |
+
if isinstance(content, str):
|
| 271 |
+
return content
|
| 272 |
+
if isinstance(content, list):
|
| 273 |
+
parts = []
|
| 274 |
+
for item in content:
|
| 275 |
+
if isinstance(item, dict):
|
| 276 |
+
text = item.get("text") or item.get("content")
|
| 277 |
+
if isinstance(text, str):
|
| 278 |
+
parts.append(text)
|
| 279 |
+
else:
|
| 280 |
+
text = getattr(item, "text", None)
|
| 281 |
+
if isinstance(text, str):
|
| 282 |
+
parts.append(text)
|
| 283 |
+
return "\n".join(parts)
|
| 284 |
+
return str(content or "")
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _validate_feedback(state: FeedbackState) -> FeedbackState:
|
| 288 |
+
feedback = (state.get("feedback") or "").strip()
|
| 289 |
+
fallback = _fallback_feedback(state["analysis"])
|
| 290 |
+
if not feedback:
|
| 291 |
+
return {**state, "feedback": fallback}
|
| 292 |
+
|
| 293 |
+
if len(feedback) > MAX_FEEDBACK_CHARS:
|
| 294 |
+
feedback = feedback[:MAX_FEEDBACK_CHARS].rsplit(" ", 1)[0].strip() + "..."
|
| 295 |
+
|
| 296 |
+
allowed_labels = {
|
| 297 |
+
err.get("label", "").lower()
|
| 298 |
+
for err in state["analysis"].get("main_errors", [])
|
| 299 |
+
if err.get("label")
|
| 300 |
+
}
|
| 301 |
+
if allowed_labels and _mentions_unknown_error(feedback, allowed_labels):
|
| 302 |
+
return {**state, "feedback": fallback}
|
| 303 |
+
|
| 304 |
+
return {**state, "feedback": feedback}
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _feedback_prompt(evidence: dict[str, Any]) -> str:
|
| 308 |
+
return f"""
|
| 309 |
+
Bạn là huấn luyện viên thể hình cho người mới tập.
|
| 310 |
+
Dữ liệu phân tích bên dưới đã được tạo bởi hệ thống computer vision và rule engine.
|
| 311 |
+
|
| 312 |
+
Yêu cầu:
|
| 313 |
+
- Chỉ dùng lỗi có trong dữ liệu, không tự bịa lỗi mới.
|
| 314 |
+
- Viết tiếng Việt, ngắn gọn, không quá 150 chữ.
|
| 315 |
+
- Có 4 phần: Nhận xét, Lỗi chính, Cách sửa, Bài tập bổ trợ.
|
| 316 |
+
- Nếu không có lỗi nghiêm trọng, tập trung khen form và nhắc giữ nhịp kiểm soát.
|
| 317 |
+
- Không nhắc tới JSON, AI, model, MediaPipe, DTW.
|
| 318 |
+
|
| 319 |
+
Dữ liệu:
|
| 320 |
+
{json.dumps(evidence, ensure_ascii=False, indent=2)}
|
| 321 |
+
""".strip()
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def _rep_vlm_prompt(rep_context: dict[str, Any], *, retry: bool = False) -> str:
|
| 325 |
+
retry_instruction = ""
|
| 326 |
+
if retry:
|
| 327 |
+
retry_instruction = """
|
| 328 |
+
Lần gọi trước không trả về JSON hợp lệ. Lần này chỉ trả về đúng MỘT object JSON minified.
|
| 329 |
+
Không dùng markdown, không giải thích, không thêm chữ trước/sau JSON.
|
| 330 |
+
Tất cả key và string value phải dùng dấu nháy kép.
|
| 331 |
+
""".strip()
|
| 332 |
+
|
| 333 |
+
return f"""
|
| 334 |
+
Bạn nhận MỘT ảnh so sánh ghép ngang:
|
| 335 |
+
- Nửa TRÁI là rep của học viên.
|
| 336 |
+
- Nửa PHẢI là rep mẫu của mentor.
|
| 337 |
+
|
| 338 |
+
Nguồn thông tin 1 - hình ảnh:
|
| 339 |
+
- So sánh trực tiếp tư thế học viên với mẫu: đầu/cổ, vai, lưng, hông, khuỷu tay, độ sâu khi xuống.
|
| 340 |
+
- Đây là nguồn ưu tiên cao nhất.
|
| 341 |
+
|
| 342 |
+
Nguồn thông tin 2 - rule-based:
|
| 343 |
+
- Có thể đúng hoặc sai do góc quay/landmark nhiễu.
|
| 344 |
+
- Chỉ dùng như gợi ý phụ, không được chép lại nếu ảnh không ủng hộ.
|
| 345 |
+
|
| 346 |
+
Rule context:
|
| 347 |
+
{json.dumps(rep_context, ensure_ascii=False, indent=2)}
|
| 348 |
+
|
| 349 |
+
Trả về JSON đúng schema:
|
| 350 |
+
{{
|
| 351 |
+
"is_error": true,
|
| 352 |
+
"visual_error_label": "tên lỗi chính quan sát từ ảnh hoặc 'Không có lỗi rõ ràng'",
|
| 353 |
+
"diagnosis": "nhận xét ngắn về rep này dựa trên ảnh",
|
| 354 |
+
"correction": "lời khuyên sửa cụ thể cho lần tập tiếp theo, hoặc 'Tiếp tục giữ form hiện tại' nếu đã đúng",
|
| 355 |
+
"feedback": "ghép diagnosis + correction thành 1-2 câu tiếng Việt cụ thể cho rep này"
|
| 356 |
+
}}
|
| 357 |
+
|
| 358 |
+
Quy tắc:
|
| 359 |
+
- Không trả về tọa độ arrow. Arrow sẽ được hệ thống đặt từ landmark rule-based.
|
| 360 |
+
- diagnosis, correction, feedback đều phải ngắn. Feedback tối đa 2 câu.
|
| 361 |
+
- Feedback không được chỉ là tên lỗi. Phải nói lỗi ảnh hưởng gì và sửa bằng hành động cụ thể.
|
| 362 |
+
- Không trả lời ngoài JSON.
|
| 363 |
+
{retry_instruction}
|
| 364 |
+
""".strip()
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def _image_as_data_url(path: str) -> str:
|
| 368 |
+
with open(path, "rb") as file:
|
| 369 |
+
encoded = base64.b64encode(file.read()).decode("ascii")
|
| 370 |
+
return f"data:image/jpeg;base64,{encoded}"
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def _make_comparison_image(student_image_path: str, expert_image_path: str) -> Path:
|
| 374 |
+
import cv2
|
| 375 |
+
import numpy as np
|
| 376 |
+
|
| 377 |
+
student = cv2.imread(student_image_path)
|
| 378 |
+
expert = cv2.imread(expert_image_path)
|
| 379 |
+
if student is None:
|
| 380 |
+
raise ValueError(f"Cannot read student image: {student_image_path}")
|
| 381 |
+
if expert is None:
|
| 382 |
+
raise ValueError(f"Cannot read expert image: {expert_image_path}")
|
| 383 |
+
|
| 384 |
+
target_height = min(student.shape[0], expert.shape[0], 720)
|
| 385 |
+
|
| 386 |
+
def resize_to_height(image):
|
| 387 |
+
ratio = target_height / image.shape[0]
|
| 388 |
+
width = max(1, int(image.shape[1] * ratio))
|
| 389 |
+
return cv2.resize(image, (width, target_height), interpolation=cv2.INTER_AREA)
|
| 390 |
+
|
| 391 |
+
student_resized = resize_to_height(student)
|
| 392 |
+
expert_resized = resize_to_height(expert)
|
| 393 |
+
separator = np.full((target_height, 10, 3), 255, dtype=np.uint8)
|
| 394 |
+
combined = np.hstack([student_resized, separator, expert_resized])
|
| 395 |
+
output_path = Path(student_image_path).with_name(Path(student_image_path).stem + "_vlm_compare.jpg")
|
| 396 |
+
cv2.imwrite(str(output_path), combined, [int(cv2.IMWRITE_JPEG_QUALITY), 88])
|
| 397 |
+
return output_path
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def _parse_json_object(text: str) -> dict[str, Any]:
|
| 401 |
+
cleaned = _strip_json_wrappers(text)
|
| 402 |
+
candidates = [
|
| 403 |
+
cleaned,
|
| 404 |
+
_first_balanced_json_object(cleaned),
|
| 405 |
+
]
|
| 406 |
+
for candidate in candidates:
|
| 407 |
+
if not candidate:
|
| 408 |
+
continue
|
| 409 |
+
parsed = _try_parse_json_object(candidate)
|
| 410 |
+
if parsed is not None:
|
| 411 |
+
return parsed
|
| 412 |
+
|
| 413 |
+
preview = cleaned.replace("\n", " ")[:220]
|
| 414 |
+
raise ValueError(f"VLM returned non-JSON content: {preview or '<empty>'}")
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def _strip_json_wrappers(text: str) -> str:
|
| 418 |
+
cleaned = (text or "").strip()
|
| 419 |
+
if cleaned.startswith("```"):
|
| 420 |
+
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned, flags=re.IGNORECASE)
|
| 421 |
+
cleaned = re.sub(r"\s*```$", "", cleaned)
|
| 422 |
+
return cleaned.strip()
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def _first_balanced_json_object(text: str) -> str:
|
| 426 |
+
start = text.find("{")
|
| 427 |
+
if start < 0:
|
| 428 |
+
return ""
|
| 429 |
+
|
| 430 |
+
depth = 0
|
| 431 |
+
in_string = False
|
| 432 |
+
escape = False
|
| 433 |
+
for index in range(start, len(text)):
|
| 434 |
+
char = text[index]
|
| 435 |
+
if escape:
|
| 436 |
+
escape = False
|
| 437 |
+
continue
|
| 438 |
+
if char == "\\":
|
| 439 |
+
escape = True
|
| 440 |
+
continue
|
| 441 |
+
if char == '"':
|
| 442 |
+
in_string = not in_string
|
| 443 |
+
continue
|
| 444 |
+
if in_string:
|
| 445 |
+
continue
|
| 446 |
+
if char == "{":
|
| 447 |
+
depth += 1
|
| 448 |
+
elif char == "}":
|
| 449 |
+
depth -= 1
|
| 450 |
+
if depth == 0:
|
| 451 |
+
return text[start : index + 1]
|
| 452 |
+
return text[start:]
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def _try_parse_json_object(text: str) -> dict[str, Any] | None:
|
| 456 |
+
for candidate in (text, _repair_loose_json(text)):
|
| 457 |
+
try:
|
| 458 |
+
parsed = json.loads(candidate)
|
| 459 |
+
except json.JSONDecodeError:
|
| 460 |
+
try:
|
| 461 |
+
parsed = ast.literal_eval(candidate)
|
| 462 |
+
except (ValueError, SyntaxError):
|
| 463 |
+
continue
|
| 464 |
+
if isinstance(parsed, dict):
|
| 465 |
+
return parsed
|
| 466 |
+
return None
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def _repair_loose_json(text: str) -> str:
|
| 470 |
+
repaired = text.strip()
|
| 471 |
+
repaired = re.sub(r"([{,]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:", r'\1"\2":', repaired)
|
| 472 |
+
repaired = re.sub(r"\bTrue\b", "true", repaired)
|
| 473 |
+
repaired = re.sub(r"\bFalse\b", "false", repaired)
|
| 474 |
+
repaired = re.sub(r"\bNone\b", "null", repaired)
|
| 475 |
+
return repaired
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def _validate_rep_visual_feedback(
|
| 479 |
+
parsed: dict[str, Any],
|
| 480 |
+
rep_context: dict[str, Any],
|
| 481 |
+
) -> dict[str, Any]:
|
| 482 |
+
diagnosis = str(parsed.get("diagnosis") or "").strip()
|
| 483 |
+
correction = str(parsed.get("correction") or "").strip()
|
| 484 |
+
feedback = str(parsed.get("feedback") or "").strip()
|
| 485 |
+
if len(feedback) < 35 and (diagnosis or correction):
|
| 486 |
+
feedback = " ".join(part for part in (diagnosis, correction) if part)
|
| 487 |
+
if not feedback:
|
| 488 |
+
return _rep_visual_fallback(rep_context, "empty VLM feedback")
|
| 489 |
+
|
| 490 |
+
return {
|
| 491 |
+
"source": "vlm",
|
| 492 |
+
"is_error": bool(parsed.get("is_error", True)),
|
| 493 |
+
"visual_error_label": str(parsed.get("visual_error_label") or "").strip(),
|
| 494 |
+
"feedback": feedback[:MAX_FEEDBACK_CHARS],
|
| 495 |
+
"arrow": None,
|
| 496 |
+
"error": "",
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
def _rep_visual_fallback(rep_context: dict[str, Any], reason: str = "") -> dict[str, Any]:
|
| 501 |
+
return {
|
| 502 |
+
"source": "fallback",
|
| 503 |
+
"is_error": bool(rep_context.get("rule_errors")),
|
| 504 |
+
"visual_error_label": "",
|
| 505 |
+
"feedback": "",
|
| 506 |
+
"arrow": None,
|
| 507 |
+
"error": reason,
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
def _needs_vlm_retry(parsed: dict[str, Any]) -> bool:
|
| 512 |
+
feedback = str(parsed.get("feedback") or parsed.get("diagnosis") or "").strip()
|
| 513 |
+
return len(feedback) < 20
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
def _is_usable_arrow_point(x: float, y: float) -> bool:
|
| 517 |
+
# Reject common placeholder/edge coordinates that point at image corners.
|
| 518 |
+
return 0.03 <= x <= 0.97 and 0.03 <= y <= 0.97
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
def _safe_arrow_label(label: str) -> str:
|
| 522 |
+
ascii_label = unicodedata.normalize("NFKD", label).encode("ascii", "ignore").decode("ascii")
|
| 523 |
+
cleaned = re.sub(r"[^A-Za-z0-9 _-]+", "", ascii_label).strip()
|
| 524 |
+
return (cleaned or "Can sua")[:18]
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
def _fallback_feedback(analysis: dict[str, Any]) -> str:
|
| 528 |
+
main_errors = analysis.get("main_errors", [])
|
| 529 |
+
if not main_errors:
|
| 530 |
+
return (
|
| 531 |
+
"Nhận xét: Form tổng thể ổn định, chưa phát hiện lỗi nghiêm trọng.\n"
|
| 532 |
+
"Lỗi chính: Chưa có lỗi nổi bật.\n"
|
| 533 |
+
"Cách sửa: Tiếp tục giữ thân người thẳng và kiểm soát nhịp xuống-lên.\n"
|
| 534 |
+
"Bài tập bổ trợ: Plank 2 hiệp, mỗi hiệp 30 giây."
|
| 535 |
+
)
|
| 536 |
+
|
| 537 |
+
top_error = main_errors[0]
|
| 538 |
+
return (
|
| 539 |
+
f"Nhận xét: Bạn hoàn thành {analysis.get('student_reps', 0)} rep với điểm "
|
| 540 |
+
f"{analysis.get('overall_score_pct', 0)}%.\n"
|
| 541 |
+
f"Lỗi chính: {top_error.get('label', 'Form chưa ổn định')} xuất hiện "
|
| 542 |
+
f"{top_error.get('count', 0)} lần.\n"
|
| 543 |
+
f"Cách sửa: {top_error.get('guidance', 'Giữ nhịp chậm và kiểm soát thân người tốt hơn.')}\n"
|
| 544 |
+
"Bài tập bổ trợ: Plank 2 hiệp, mỗi hiệp 30 giây trước khi tập lại."
|
| 545 |
+
)
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
def _mentions_unknown_error(feedback: str, allowed_labels: set[str]) -> bool:
|
| 549 |
+
known_error_words = {
|
| 550 |
+
"võng lưng": "võng lưng",
|
| 551 |
+
"nhô mông": "nhô mông",
|
| 552 |
+
"mông quá cao": "nhô mông",
|
| 553 |
+
"cúi đầu": "cúi đầu",
|
| 554 |
+
"gập cổ": "cúi đầu",
|
| 555 |
+
"chưa hạ": "chưa hạ",
|
| 556 |
+
"chưa xuống": "chưa hạ",
|
| 557 |
+
"cơ thể chưa giữ thẳng": "cơ thể chưa giữ thẳng",
|
| 558 |
+
}
|
| 559 |
+
text = feedback.lower()
|
| 560 |
+
for phrase, normalized in known_error_words.items():
|
| 561 |
+
if phrase in text and not any(normalized in label for label in allowed_labels):
|
| 562 |
+
return True
|
| 563 |
+
return False
|
reflex_frontend/state.py
CHANGED
|
@@ -44,6 +44,7 @@ class AnalysisState(rx.State):
|
|
| 44 |
good_reps: int = 0
|
| 45 |
serious_reps: int = 0
|
| 46 |
summary: str = ""
|
|
|
|
| 47 |
|
| 48 |
main_errors: list[dict] = []
|
| 49 |
rep_results: list[dict] = []
|
|
@@ -110,6 +111,7 @@ class AnalysisState(rx.State):
|
|
| 110 |
self.good_reps = 0
|
| 111 |
self.serious_reps = 0
|
| 112 |
self.summary = ""
|
|
|
|
| 113 |
self.main_errors = []
|
| 114 |
self.rep_results = []
|
| 115 |
self.is_uploading = False
|
|
@@ -130,6 +132,7 @@ class AnalysisState(rx.State):
|
|
| 130 |
self.good_reps = 0
|
| 131 |
self.serious_reps = 0
|
| 132 |
self.summary = ""
|
|
|
|
| 133 |
self.main_errors = []
|
| 134 |
self.rep_results = []
|
| 135 |
|
|
@@ -167,6 +170,7 @@ class AnalysisState(rx.State):
|
|
| 167 |
self.progress_value = 0
|
| 168 |
self.main_errors = []
|
| 169 |
self.rep_results = []
|
|
|
|
| 170 |
return rx.toast(result["error"], level="error")
|
| 171 |
|
| 172 |
rep_results = []
|
|
@@ -188,6 +192,7 @@ class AnalysisState(rx.State):
|
|
| 188 |
self.good_reps = result["good_reps"]
|
| 189 |
self.serious_reps = result["serious_reps"]
|
| 190 |
self.summary = result["summary"]
|
|
|
|
| 191 |
self.main_errors = result["main_errors"]
|
| 192 |
self.rep_results = rep_results
|
| 193 |
self.analysis_artifact_relpaths = artifact_paths
|
|
@@ -226,20 +231,36 @@ def _cleanup_stale_temp_files(max_age_seconds: int = 6 * 60 * 60) -> None:
|
|
| 226 |
except OSError:
|
| 227 |
return
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
_cleanup_stale_files(upload_root / "students", max_age_seconds)
|
| 230 |
_cleanup_stale_files(upload_root / "analysis", max_age_seconds)
|
| 231 |
|
| 232 |
|
| 233 |
def _cleanup_stale_files(directory: Path, max_age_seconds: int) -> None:
|
| 234 |
-
|
|
|
|
|
|
|
| 235 |
return
|
| 236 |
|
| 237 |
cutoff = time.time() - max_age_seconds
|
| 238 |
-
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
parent = path.parent
|
| 241 |
_delete_path(str(path))
|
| 242 |
_delete_empty_dir(parent)
|
|
|
|
|
|
|
| 243 |
|
| 244 |
|
| 245 |
def _delete_empty_dir(path: Path) -> None:
|
|
|
|
| 44 |
good_reps: int = 0
|
| 45 |
serious_reps: int = 0
|
| 46 |
summary: str = ""
|
| 47 |
+
coach_feedback: str = ""
|
| 48 |
|
| 49 |
main_errors: list[dict] = []
|
| 50 |
rep_results: list[dict] = []
|
|
|
|
| 111 |
self.good_reps = 0
|
| 112 |
self.serious_reps = 0
|
| 113 |
self.summary = ""
|
| 114 |
+
self.coach_feedback = ""
|
| 115 |
self.main_errors = []
|
| 116 |
self.rep_results = []
|
| 117 |
self.is_uploading = False
|
|
|
|
| 132 |
self.good_reps = 0
|
| 133 |
self.serious_reps = 0
|
| 134 |
self.summary = ""
|
| 135 |
+
self.coach_feedback = ""
|
| 136 |
self.main_errors = []
|
| 137 |
self.rep_results = []
|
| 138 |
|
|
|
|
| 170 |
self.progress_value = 0
|
| 171 |
self.main_errors = []
|
| 172 |
self.rep_results = []
|
| 173 |
+
self.coach_feedback = ""
|
| 174 |
return rx.toast(result["error"], level="error")
|
| 175 |
|
| 176 |
rep_results = []
|
|
|
|
| 192 |
self.good_reps = result["good_reps"]
|
| 193 |
self.serious_reps = result["serious_reps"]
|
| 194 |
self.summary = result["summary"]
|
| 195 |
+
self.coach_feedback = result.get("coach_feedback", "")
|
| 196 |
self.main_errors = result["main_errors"]
|
| 197 |
self.rep_results = rep_results
|
| 198 |
self.analysis_artifact_relpaths = artifact_paths
|
|
|
|
| 231 |
except OSError:
|
| 232 |
return
|
| 233 |
|
| 234 |
+
try:
|
| 235 |
+
upload_root.mkdir(parents=True, exist_ok=True)
|
| 236 |
+
except OSError:
|
| 237 |
+
return
|
| 238 |
+
|
| 239 |
_cleanup_stale_files(upload_root / "students", max_age_seconds)
|
| 240 |
_cleanup_stale_files(upload_root / "analysis", max_age_seconds)
|
| 241 |
|
| 242 |
|
| 243 |
def _cleanup_stale_files(directory: Path, max_age_seconds: int) -> None:
|
| 244 |
+
try:
|
| 245 |
+
directory.mkdir(parents=True, exist_ok=True)
|
| 246 |
+
except OSError:
|
| 247 |
return
|
| 248 |
|
| 249 |
cutoff = time.time() - max_age_seconds
|
| 250 |
+
try:
|
| 251 |
+
paths = list(directory.rglob("*"))
|
| 252 |
+
except OSError:
|
| 253 |
+
return
|
| 254 |
+
|
| 255 |
+
for path in paths:
|
| 256 |
+
try:
|
| 257 |
+
if not path.is_file() or path.stat().st_mtime >= cutoff:
|
| 258 |
+
continue
|
| 259 |
parent = path.parent
|
| 260 |
_delete_path(str(path))
|
| 261 |
_delete_empty_dir(parent)
|
| 262 |
+
except OSError:
|
| 263 |
+
continue
|
| 264 |
|
| 265 |
|
| 266 |
def _delete_empty_dir(path: Path) -> None:
|
reflex_frontend/ui.py
CHANGED
|
@@ -589,6 +589,11 @@ def results_panel() -> rx.Component:
|
|
| 589 |
padding="16px",
|
| 590 |
width="100%",
|
| 591 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
rx.vstack(
|
| 593 |
rx.foreach(AnalysisState.rep_results, rep_result_item),
|
| 594 |
spacing="3",
|
|
@@ -602,6 +607,47 @@ def results_panel() -> rx.Component:
|
|
| 602 |
)
|
| 603 |
|
| 604 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
def metric(title: str, value, label: str) -> rx.Component:
|
| 606 |
return rx.box(
|
| 607 |
rx.text(title, color=COLORS["muted"], font_size="0.84rem"),
|
|
@@ -639,13 +685,21 @@ def rep_result_item(rep: dict) -> rx.Component:
|
|
| 639 |
),
|
| 640 |
rx.fragment(),
|
| 641 |
),
|
| 642 |
-
rx.
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
),
|
| 650 |
spacing="3",
|
| 651 |
align="start",
|
|
@@ -658,6 +712,31 @@ def rep_result_item(rep: dict) -> rx.Component:
|
|
| 658 |
)
|
| 659 |
|
| 660 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
def frame_image(title: str, path) -> rx.Component:
|
| 662 |
return rx.box(
|
| 663 |
rx.text(title, font_weight="700",
|
|
|
|
| 589 |
padding="16px",
|
| 590 |
width="100%",
|
| 591 |
),
|
| 592 |
+
rx.cond(
|
| 593 |
+
AnalysisState.coach_feedback != "",
|
| 594 |
+
coach_feedback_card(),
|
| 595 |
+
rx.fragment(),
|
| 596 |
+
),
|
| 597 |
rx.vstack(
|
| 598 |
rx.foreach(AnalysisState.rep_results, rep_result_item),
|
| 599 |
spacing="3",
|
|
|
|
| 607 |
)
|
| 608 |
|
| 609 |
|
| 610 |
+
def coach_feedback_card() -> rx.Component:
|
| 611 |
+
return rx.box(
|
| 612 |
+
rx.vstack(
|
| 613 |
+
rx.hstack(
|
| 614 |
+
rx.box(
|
| 615 |
+
rx.icon("sparkles", size=18, color="white"),
|
| 616 |
+
width="34px",
|
| 617 |
+
height="34px",
|
| 618 |
+
border_radius="10px",
|
| 619 |
+
display="flex",
|
| 620 |
+
align_items="center",
|
| 621 |
+
justify_content="center",
|
| 622 |
+
background=COLORS["blue"],
|
| 623 |
+
),
|
| 624 |
+
rx.vstack(
|
| 625 |
+
rx.text("Nhận xét từ AI Coach", font_weight="800"),
|
| 626 |
+
rx.text("Dựa trên lỗi từng rep và điểm tổng quan",
|
| 627 |
+
color=COLORS["muted"], font_size="0.86rem"),
|
| 628 |
+
spacing="1",
|
| 629 |
+
align="start",
|
| 630 |
+
),
|
| 631 |
+
spacing="3",
|
| 632 |
+
align="center",
|
| 633 |
+
),
|
| 634 |
+
rx.text(
|
| 635 |
+
AnalysisState.coach_feedback,
|
| 636 |
+
color=COLORS["text"],
|
| 637 |
+
line_height="1.65",
|
| 638 |
+
white_space="pre-line",
|
| 639 |
+
),
|
| 640 |
+
spacing="3",
|
| 641 |
+
align="start",
|
| 642 |
+
),
|
| 643 |
+
background="linear-gradient(135deg, #eff6ff 0%, #ffffff 70%)",
|
| 644 |
+
border=f"1px solid {COLORS['blue_soft']}",
|
| 645 |
+
border_radius="16px",
|
| 646 |
+
padding="16px",
|
| 647 |
+
width="100%",
|
| 648 |
+
)
|
| 649 |
+
|
| 650 |
+
|
| 651 |
def metric(title: str, value, label: str) -> rx.Component:
|
| 652 |
return rx.box(
|
| 653 |
rx.text(title, color=COLORS["muted"], font_size="0.84rem"),
|
|
|
|
| 685 |
),
|
| 686 |
rx.fragment(),
|
| 687 |
),
|
| 688 |
+
rx.cond(
|
| 689 |
+
rep["llm_feedback"] != "",
|
| 690 |
+
feedback_note(
|
| 691 |
+
"Feedback từ VLM",
|
| 692 |
+
"Dựa trên ảnh học viên, ảnh mẫu và rule-based context",
|
| 693 |
+
rep["llm_feedback"],
|
| 694 |
+
"info",
|
| 695 |
+
),
|
| 696 |
+
rx.fragment(),
|
| 697 |
+
),
|
| 698 |
+
feedback_note(
|
| 699 |
+
"Feedback rule-based",
|
| 700 |
+
"Dựa trên landmark, góc khớp và ngưỡng kỹ thuật",
|
| 701 |
+
rep["rule_feedback"],
|
| 702 |
+
"neutral",
|
| 703 |
),
|
| 704 |
spacing="3",
|
| 705 |
align="start",
|
|
|
|
| 712 |
)
|
| 713 |
|
| 714 |
|
| 715 |
+
def feedback_note(title: str, subtitle: str, content, tone: str) -> rx.Component:
|
| 716 |
+
bg = COLORS["blue_soft"] if tone == "info" else COLORS["surface_soft"]
|
| 717 |
+
border = COLORS["blue_soft"] if tone == "info" else COLORS["border"]
|
| 718 |
+
return rx.box(
|
| 719 |
+
rx.vstack(
|
| 720 |
+
rx.hstack(
|
| 721 |
+
rx.text(title, font_weight="800", font_size="0.92rem"),
|
| 722 |
+
badge("VLM" if tone == "info" else "Rule", "info" if tone == "info" else "neutral"),
|
| 723 |
+
spacing="2",
|
| 724 |
+
align="center",
|
| 725 |
+
),
|
| 726 |
+
rx.text(subtitle, color=COLORS["muted"], font_size="0.82rem"),
|
| 727 |
+
rx.text(content, color=COLORS["muted"],
|
| 728 |
+
font_size="0.93rem", line_height="1.55"),
|
| 729 |
+
spacing="2",
|
| 730 |
+
align="start",
|
| 731 |
+
),
|
| 732 |
+
background=bg,
|
| 733 |
+
border=f"1px solid {border}",
|
| 734 |
+
border_radius="12px",
|
| 735 |
+
padding="12px",
|
| 736 |
+
width="100%",
|
| 737 |
+
)
|
| 738 |
+
|
| 739 |
+
|
| 740 |
def frame_image(title: str, path) -> rx.Component:
|
| 741 |
return rx.box(
|
| 742 |
rx.text(title, font_weight="700",
|
requirements.txt
CHANGED
|
@@ -2,11 +2,12 @@
|
|
| 2 |
fastdtw>=0.3.4
|
| 3 |
google-genai>=0.1.0
|
| 4 |
httpx>=0.28.0
|
| 5 |
-
langchain-nvidia-ai-endpoints>=0.3.0
|
| 6 |
langgraph>=1.0.0
|
| 7 |
mediapipe==0.10.11
|
| 8 |
numpy>=2.4.4
|
|
|
|
| 9 |
opencv-python-headless>=4.13.0.92
|
|
|
|
| 10 |
plotly>=6.0.0
|
| 11 |
python-dotenv>=1.0.0
|
| 12 |
reflex>=0.8.18
|
|
|
|
| 2 |
fastdtw>=0.3.4
|
| 3 |
google-genai>=0.1.0
|
| 4 |
httpx>=0.28.0
|
|
|
|
| 5 |
langgraph>=1.0.0
|
| 6 |
mediapipe==0.10.11
|
| 7 |
numpy>=2.4.4
|
| 8 |
+
openai>=2.0.0
|
| 9 |
opencv-python-headless>=4.13.0.92
|
| 10 |
+
Pillow>=11.0.0
|
| 11 |
plotly>=6.0.0
|
| 12 |
python-dotenv>=1.0.0
|
| 13 |
reflex>=0.8.18
|
scripts/log_hook.py
CHANGED
|
@@ -16,6 +16,7 @@ from pathlib import Path
|
|
| 16 |
|
| 17 |
# Vietnam timezone (used by existing logs)
|
| 18 |
VN_TZ = timezone(timedelta(hours=7))
|
|
|
|
| 19 |
|
| 20 |
# Typical artifacts when UTF-8 bytes are decoded with a legacy codepage first.
|
| 21 |
MOJIBAKE_TOKENS = ('Ã', 'Â', 'Ä', 'Å', 'Æ', 'â€', 'á»')
|
|
@@ -97,6 +98,7 @@ def _get_git_metadata() -> dict:
|
|
| 97 |
except Exception:
|
| 98 |
pass
|
| 99 |
|
|
|
|
| 100 |
return metadata
|
| 101 |
|
| 102 |
|
|
@@ -113,6 +115,130 @@ def _write_entry(entry: dict) -> None:
|
|
| 113 |
raise
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
def _make_entry(prompt: str, response: str, tool: str = 'manual') -> dict:
|
| 117 |
meta = _get_git_metadata()
|
| 118 |
return {
|
|
@@ -142,8 +268,16 @@ def main() -> None:
|
|
| 142 |
return
|
| 143 |
|
| 144 |
# 2️⃣ Otherwise read from stdin (piped JSON). If nothing comes in, exit silently.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
raw = _read_stdin_text().strip()
|
| 146 |
if not raw:
|
|
|
|
|
|
|
| 147 |
sys.exit(0)
|
| 148 |
|
| 149 |
raw = _fix_mojibake_text(raw)
|
|
@@ -152,12 +286,6 @@ def main() -> None:
|
|
| 152 |
except json.JSONDecodeError:
|
| 153 |
sys.exit(0)
|
| 154 |
|
| 155 |
-
# Detect tool name – honour explicit flag if provided.
|
| 156 |
-
parser_tool = None
|
| 157 |
-
if '--tool' in sys.argv:
|
| 158 |
-
idx = sys.argv.index('--tool')
|
| 159 |
-
if idx + 1 < len(sys.argv):
|
| 160 |
-
parser_tool = sys.argv[idx + 1]
|
| 161 |
tool = parser_tool or os.getenv('AI_TOOL_NAME', 'manual')
|
| 162 |
|
| 163 |
meta = _get_git_metadata()
|
|
|
|
| 16 |
|
| 17 |
# Vietnam timezone (used by existing logs)
|
| 18 |
VN_TZ = timezone(timedelta(hours=7))
|
| 19 |
+
DEFAULT_STUDENT_EMAIL = 'akirahoang617@gmail.com'
|
| 20 |
|
| 21 |
# Typical artifacts when UTF-8 bytes are decoded with a legacy codepage first.
|
| 22 |
MOJIBAKE_TOKENS = ('Ã', 'Â', 'Ä', 'Å', 'Æ', 'â€', 'á»')
|
|
|
|
| 98 |
except Exception:
|
| 99 |
pass
|
| 100 |
|
| 101 |
+
metadata['student'] = os.getenv('AI_LOG_STUDENT_EMAIL') or DEFAULT_STUDENT_EMAIL or metadata['student']
|
| 102 |
return metadata
|
| 103 |
|
| 104 |
|
|
|
|
| 115 |
raise
|
| 116 |
|
| 117 |
|
| 118 |
+
def _read_existing_keys() -> set[tuple[str, str, str]]:
|
| 119 |
+
log_file = Path(os.getenv('AI_LOG_DIR', '.ai-log')) / 'session.jsonl'
|
| 120 |
+
keys: set[tuple[str, str, str]] = set()
|
| 121 |
+
if not log_file.exists():
|
| 122 |
+
return keys
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
with open(log_file, 'r', encoding='utf-8') as f:
|
| 126 |
+
for line in f:
|
| 127 |
+
try:
|
| 128 |
+
item = json.loads(line)
|
| 129 |
+
except json.JSONDecodeError:
|
| 130 |
+
continue
|
| 131 |
+
keys.add((
|
| 132 |
+
str(item.get('tool', '')),
|
| 133 |
+
str(item.get('session_id', '')),
|
| 134 |
+
str(item.get('prompt', '')),
|
| 135 |
+
))
|
| 136 |
+
except Exception:
|
| 137 |
+
pass
|
| 138 |
+
return keys
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _extract_text_from_content(content) -> str:
|
| 142 |
+
if isinstance(content, str):
|
| 143 |
+
return content
|
| 144 |
+
if isinstance(content, list):
|
| 145 |
+
parts = []
|
| 146 |
+
for item in content:
|
| 147 |
+
if isinstance(item, dict) and isinstance(item.get('text'), str):
|
| 148 |
+
parts.append(item['text'])
|
| 149 |
+
return '\n'.join(parts)
|
| 150 |
+
return ''
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _latest_codex_session_file() -> Path | None:
|
| 154 |
+
root = Path(os.getenv('CODEX_HOME', Path.home() / '.codex')) / 'sessions'
|
| 155 |
+
if not root.exists():
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
+
candidates = list(root.rglob('rollout-*.jsonl'))
|
| 159 |
+
if not candidates:
|
| 160 |
+
return None
|
| 161 |
+
return max(candidates, key=lambda path: path.stat().st_mtime)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _extract_codex_prompt_from_session(path: Path) -> dict | None:
|
| 165 |
+
cwd = str(Path.cwd()).lower()
|
| 166 |
+
session_id = path.stem
|
| 167 |
+
current_cwd = ''
|
| 168 |
+
last_prompt = ''
|
| 169 |
+
model = ''
|
| 170 |
+
|
| 171 |
+
try:
|
| 172 |
+
with open(path, 'r', encoding='utf-8') as f:
|
| 173 |
+
for line in f:
|
| 174 |
+
try:
|
| 175 |
+
item = json.loads(line)
|
| 176 |
+
except json.JSONDecodeError:
|
| 177 |
+
continue
|
| 178 |
+
|
| 179 |
+
payload = item.get('payload') or {}
|
| 180 |
+
if item.get('type') == 'turn_context':
|
| 181 |
+
current_cwd = str(payload.get('cwd', '')).lower()
|
| 182 |
+
model = str(payload.get('model') or model)
|
| 183 |
+
continue
|
| 184 |
+
|
| 185 |
+
if current_cwd and current_cwd != cwd:
|
| 186 |
+
continue
|
| 187 |
+
|
| 188 |
+
if payload.get('type') == 'user_message':
|
| 189 |
+
message = str(payload.get('message', '')).strip()
|
| 190 |
+
if message:
|
| 191 |
+
last_prompt = message
|
| 192 |
+
continue
|
| 193 |
+
|
| 194 |
+
if payload.get('type') == 'message' and payload.get('role') == 'user':
|
| 195 |
+
message = _extract_text_from_content(payload.get('content')).strip()
|
| 196 |
+
if message:
|
| 197 |
+
last_prompt = message
|
| 198 |
+
except Exception:
|
| 199 |
+
return None
|
| 200 |
+
|
| 201 |
+
if not last_prompt:
|
| 202 |
+
return None
|
| 203 |
+
return {
|
| 204 |
+
'session_id': session_id,
|
| 205 |
+
'model': model,
|
| 206 |
+
'prompt': last_prompt,
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _log_latest_codex_session() -> bool:
|
| 211 |
+
session_file = _latest_codex_session_file()
|
| 212 |
+
if not session_file:
|
| 213 |
+
return False
|
| 214 |
+
|
| 215 |
+
extracted = _extract_codex_prompt_from_session(session_file)
|
| 216 |
+
if not extracted:
|
| 217 |
+
return False
|
| 218 |
+
|
| 219 |
+
prompt = _fix_mojibake_text(extracted['prompt'])[:1000]
|
| 220 |
+
key = ('codex', extracted['session_id'], prompt)
|
| 221 |
+
if key in _read_existing_keys():
|
| 222 |
+
return True
|
| 223 |
+
|
| 224 |
+
meta = _get_git_metadata()
|
| 225 |
+
entry = {
|
| 226 |
+
'ts': datetime.now(VN_TZ).isoformat(),
|
| 227 |
+
'tool': 'codex',
|
| 228 |
+
'event': 'codex_session_fallback',
|
| 229 |
+
'session_id': extracted['session_id'],
|
| 230 |
+
'model': extracted['model'],
|
| 231 |
+
'repo': meta['repo'],
|
| 232 |
+
'branch': meta['branch'],
|
| 233 |
+
'commit': meta['commit'],
|
| 234 |
+
'student': meta['student'],
|
| 235 |
+
'prompt': prompt,
|
| 236 |
+
'response_summary': '',
|
| 237 |
+
}
|
| 238 |
+
_write_entry(entry)
|
| 239 |
+
return True
|
| 240 |
+
|
| 241 |
+
|
| 242 |
def _make_entry(prompt: str, response: str, tool: str = 'manual') -> dict:
|
| 243 |
meta = _get_git_metadata()
|
| 244 |
return {
|
|
|
|
| 268 |
return
|
| 269 |
|
| 270 |
# 2️⃣ Otherwise read from stdin (piped JSON). If nothing comes in, exit silently.
|
| 271 |
+
parser_tool = None
|
| 272 |
+
if '--tool' in sys.argv:
|
| 273 |
+
idx = sys.argv.index('--tool')
|
| 274 |
+
if idx + 1 < len(sys.argv):
|
| 275 |
+
parser_tool = sys.argv[idx + 1]
|
| 276 |
+
|
| 277 |
raw = _read_stdin_text().strip()
|
| 278 |
if not raw:
|
| 279 |
+
if parser_tool == 'codex':
|
| 280 |
+
_log_latest_codex_session()
|
| 281 |
sys.exit(0)
|
| 282 |
|
| 283 |
raw = _fix_mojibake_text(raw)
|
|
|
|
| 286 |
except json.JSONDecodeError:
|
| 287 |
sys.exit(0)
|
| 288 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
tool = parser_tool or os.getenv('AI_TOOL_NAME', 'manual')
|
| 290 |
|
| 291 |
meta = _get_git_metadata()
|
scripts/run_pushup_eval_tests.py
CHANGED
|
@@ -1,9 +1,16 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
from __future__ import annotations
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from pathlib import Path
|
| 5 |
import sys
|
| 6 |
import time
|
|
|
|
|
|
|
| 7 |
|
| 8 |
ROOT = Path(__file__).resolve().parent.parent
|
| 9 |
if str(ROOT) not in sys.path:
|
|
@@ -13,74 +20,269 @@ from push_up.analysis_service import TEMPLATE_SOURCE, analyze_pushup, prepare_te
|
|
| 13 |
from push_up.processor import VideoProcessor
|
| 14 |
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
("hv01_khong_gong_bung", ROOT / "data" / "tests" / "hv01_khong_gong_bung.mp4"),
|
| 21 |
-
("hv01_mong_cao", ROOT / "data" / "tests" / "hv01_mong_cao.mp4"),
|
| 22 |
-
("hv01_rep_sai_rep_dung", ROOT / "data" / "tests" / "hv01_rep_sai_rep_dung.mp4"),
|
| 23 |
-
("hv01_tap_dung", ROOT / "data" / "tests" / "hv01_tap_dung.mp4"),
|
| 24 |
-
("hv02_tap_dung", ROOT / "data" / "tests" / "hv02_tap_dung.mp4"),
|
| 25 |
-
("vo_teakwondo", ROOT / "data" / "tests" / "vo_teakwondo.mp4"),
|
| 26 |
-
]
|
| 27 |
|
| 28 |
|
| 29 |
-
def
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def main() -> int:
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
print("[error] Missing test videos:")
|
| 42 |
-
for path in missing:
|
| 43 |
-
print(f" - {path}")
|
| 44 |
return 1
|
| 45 |
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
print("=" * 88)
|
| 48 |
-
print(f"[case] {label}")
|
| 49 |
-
print(f"[
|
| 50 |
-
|
| 51 |
started = time.perf_counter()
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
if result.get("error"):
|
| 56 |
-
print(f"[result] ERROR: {result['error']}")
|
| 57 |
-
print(f"[time] {elapsed:.1f}s")
|
| 58 |
-
continue
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
print(
|
| 61 |
-
"
|
| 62 |
-
f"
|
| 63 |
-
f"
|
| 64 |
-
f"
|
| 65 |
-
f"
|
| 66 |
-
f"serious_reps={result['serious_reps']}"
|
| 67 |
)
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
f"errors={errors}"
|
| 80 |
-
)
|
| 81 |
-
print(f"[time] {elapsed:.1f}s")
|
| 82 |
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
|
| 86 |
if __name__ == "__main__":
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
+
import argparse
|
| 5 |
+
from collections import Counter
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
from pathlib import Path
|
| 10 |
import sys
|
| 11 |
import time
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
|
| 15 |
ROOT = Path(__file__).resolve().parent.parent
|
| 16 |
if str(ROOT) not in sys.path:
|
|
|
|
| 20 |
from push_up.processor import VideoProcessor
|
| 21 |
|
| 22 |
|
| 23 |
+
VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".avi", ".webm"}
|
| 24 |
+
DEFAULT_TESTS_DIR = ROOT / "data" / "tests"
|
| 25 |
+
DEFAULT_OUTPUT_DIR = ROOT / "analysis_artifacts" / "video_test_runs"
|
| 26 |
+
EXPECTED_REJECTION_LABELS = {"vo_teakwondo"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
+
def parse_args() -> argparse.Namespace:
|
| 30 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 31 |
+
parser = argparse.ArgumentParser(
|
| 32 |
+
description="Run push-up analysis against every video in data/tests and write one JSON result file."
|
| 33 |
+
)
|
| 34 |
+
parser.add_argument(
|
| 35 |
+
"--tests-dir",
|
| 36 |
+
type=Path,
|
| 37 |
+
default=DEFAULT_TESTS_DIR,
|
| 38 |
+
help="Directory containing test videos. Default: data/tests",
|
| 39 |
+
)
|
| 40 |
+
parser.add_argument(
|
| 41 |
+
"--output",
|
| 42 |
+
type=Path,
|
| 43 |
+
default=DEFAULT_OUTPUT_DIR / f"pushup_eval_{timestamp}.json",
|
| 44 |
+
help="Single JSON file to write. Default: analysis_artifacts/video_test_runs/pushup_eval_<timestamp>.json",
|
| 45 |
+
)
|
| 46 |
+
parser.add_argument(
|
| 47 |
+
"--artifact-root",
|
| 48 |
+
type=Path,
|
| 49 |
+
default=DEFAULT_OUTPUT_DIR / f"artifacts_{timestamp}",
|
| 50 |
+
help="Root directory for annotated images when --save-artifacts is used.",
|
| 51 |
+
)
|
| 52 |
+
parser.add_argument(
|
| 53 |
+
"--save-artifacts",
|
| 54 |
+
action="store_true",
|
| 55 |
+
help="Save per-rep annotated student/expert frames. Also enables deterministic rule-based arrows.",
|
| 56 |
+
)
|
| 57 |
+
parser.add_argument(
|
| 58 |
+
"--enable-vlm",
|
| 59 |
+
action="store_true",
|
| 60 |
+
help="Allow NVIDIA VLM calls for per-rep text feedback. By default VLM is disabled for repeatable tests.",
|
| 61 |
+
)
|
| 62 |
+
parser.add_argument(
|
| 63 |
+
"--include-template",
|
| 64 |
+
action="store_true",
|
| 65 |
+
help="Also test template video against itself.",
|
| 66 |
+
)
|
| 67 |
+
return parser.parse_args()
|
| 68 |
|
| 69 |
|
| 70 |
def main() -> int:
|
| 71 |
+
args = parse_args()
|
| 72 |
+
tests_dir = (ROOT / args.tests_dir).resolve() if not args.tests_dir.is_absolute() else args.tests_dir
|
| 73 |
+
output_path = (ROOT / args.output).resolve() if not args.output.is_absolute() else args.output
|
| 74 |
+
artifact_root = (
|
| 75 |
+
(ROOT / args.artifact_root).resolve() if not args.artifact_root.is_absolute() else args.artifact_root
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
if not tests_dir.exists():
|
| 79 |
+
print(f"[error] Test directory does not exist: {tests_dir}")
|
| 80 |
+
return 1
|
| 81 |
+
|
| 82 |
+
videos = discover_videos(tests_dir)
|
| 83 |
+
if args.include_template:
|
| 84 |
+
videos = [TEMPLATE_SOURCE] + videos
|
| 85 |
|
| 86 |
+
if not videos:
|
| 87 |
+
print(f"[error] No test videos found in: {tests_dir}")
|
|
|
|
|
|
|
|
|
|
| 88 |
return 1
|
| 89 |
|
| 90 |
+
if not args.enable_vlm:
|
| 91 |
+
os.environ["NVIDIA_API_KEY"] = "nvapi-..."
|
| 92 |
+
|
| 93 |
+
print(f"[setup] template={TEMPLATE_SOURCE}")
|
| 94 |
+
print(f"[setup] tests_dir={tests_dir}")
|
| 95 |
+
print(f"[setup] output={output_path}")
|
| 96 |
+
print(f"[setup] save_artifacts={args.save_artifacts}")
|
| 97 |
+
print(f"[setup] vlm={'enabled' if args.enable_vlm else 'disabled'}")
|
| 98 |
+
prepare_template_cache()
|
| 99 |
+
|
| 100 |
+
batch_started = time.perf_counter()
|
| 101 |
+
results = []
|
| 102 |
+
for index, video_path in enumerate(videos, start=1):
|
| 103 |
+
label = video_path.stem if video_path != TEMPLATE_SOURCE else "template_vs_template"
|
| 104 |
print("=" * 88)
|
| 105 |
+
print(f"[case {index}/{len(videos)}] {label}")
|
| 106 |
+
print(f"[video] {video_path}")
|
| 107 |
+
|
| 108 |
started = time.perf_counter()
|
| 109 |
+
orientation = orientation_label(video_path)
|
| 110 |
+
try:
|
| 111 |
+
result = analyze_pushup(
|
| 112 |
+
video_path,
|
| 113 |
+
artifact_root if args.save_artifacts else None,
|
| 114 |
+
save_artifacts=args.save_artifacts,
|
| 115 |
+
)
|
| 116 |
+
elapsed = time.perf_counter() - started
|
| 117 |
+
entry = compact_result(
|
| 118 |
+
label=label,
|
| 119 |
+
video_path=video_path,
|
| 120 |
+
orientation=orientation,
|
| 121 |
+
elapsed_seconds=elapsed,
|
| 122 |
+
result=result,
|
| 123 |
+
)
|
| 124 |
+
except Exception as exc:
|
| 125 |
+
elapsed = time.perf_counter() - started
|
| 126 |
+
entry = {
|
| 127 |
+
"label": label,
|
| 128 |
+
"video_path": project_relative_path(video_path),
|
| 129 |
+
"orientation": orientation,
|
| 130 |
+
"elapsed_seconds": round(elapsed, 2),
|
| 131 |
+
"error": f"{type(exc).__name__}: {exc}",
|
| 132 |
+
"ok": False,
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
results.append(entry)
|
| 136 |
+
print_case_summary(entry)
|
| 137 |
+
|
| 138 |
+
payload = {
|
| 139 |
+
"created_at": datetime.now().isoformat(timespec="seconds"),
|
| 140 |
+
"tests_dir": project_relative_path(tests_dir),
|
| 141 |
+
"template_video_path": project_relative_path(TEMPLATE_SOURCE),
|
| 142 |
+
"save_artifacts": args.save_artifacts,
|
| 143 |
+
"artifact_root": project_relative_path(artifact_root) if args.save_artifacts else "",
|
| 144 |
+
"vlm_enabled": args.enable_vlm,
|
| 145 |
+
"total_videos": len(results),
|
| 146 |
+
"passed_videos": sum(1 for item in results if item.get("ok")),
|
| 147 |
+
"failed_videos": sum(1 for item in results if not item.get("ok")),
|
| 148 |
+
"elapsed_seconds": round(time.perf_counter() - batch_started, 2),
|
| 149 |
+
"error_distribution": error_distribution(results),
|
| 150 |
+
"results": results,
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 154 |
+
with output_path.open("w", encoding="utf-8") as file:
|
| 155 |
+
json.dump(payload, file, ensure_ascii=False, indent=2)
|
| 156 |
+
|
| 157 |
+
print("=" * 88)
|
| 158 |
+
print(f"[done] wrote {output_path}")
|
| 159 |
+
print(f"[done] passed={payload['passed_videos']} failed={payload['failed_videos']}")
|
| 160 |
+
return 0 if payload["failed_videos"] == 0 else 1
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
+
def discover_videos(tests_dir: Path) -> list[Path]:
|
| 164 |
+
return sorted(
|
| 165 |
+
path
|
| 166 |
+
for path in tests_dir.rglob("*")
|
| 167 |
+
if path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def orientation_label(video_path: Path) -> str:
|
| 172 |
+
try:
|
| 173 |
+
processor = VideoProcessor()
|
| 174 |
+
needs_flip = processor._detect_orientation(str(video_path))
|
| 175 |
+
except Exception as exc:
|
| 176 |
+
return f"unknown: {type(exc).__name__}: {exc}"
|
| 177 |
+
return "head-left -> flipped" if needs_flip else "head-right/no-flip"
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def compact_result(
|
| 181 |
+
*,
|
| 182 |
+
label: str,
|
| 183 |
+
video_path: Path,
|
| 184 |
+
orientation: str,
|
| 185 |
+
elapsed_seconds: float,
|
| 186 |
+
result: dict[str, Any],
|
| 187 |
+
) -> dict[str, Any]:
|
| 188 |
+
if result.get("error"):
|
| 189 |
+
expected_rejection = label in EXPECTED_REJECTION_LABELS
|
| 190 |
+
return {
|
| 191 |
+
"label": label,
|
| 192 |
+
"video_path": project_relative_path(video_path),
|
| 193 |
+
"orientation": orientation,
|
| 194 |
+
"elapsed_seconds": round(elapsed_seconds, 2),
|
| 195 |
+
"ok": expected_rejection,
|
| 196 |
+
"expected_rejection": expected_rejection,
|
| 197 |
+
"error": result["error"],
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
return {
|
| 201 |
+
"label": label,
|
| 202 |
+
"video_path": project_relative_path(video_path),
|
| 203 |
+
"orientation": orientation,
|
| 204 |
+
"elapsed_seconds": round(elapsed_seconds, 2),
|
| 205 |
+
"ok": True,
|
| 206 |
+
"expected_rejection": False,
|
| 207 |
+
"error": None,
|
| 208 |
+
"overall_score_pct": result.get("overall_score_pct"),
|
| 209 |
+
"student_reps": result.get("student_reps"),
|
| 210 |
+
"expert_reps": result.get("expert_reps"),
|
| 211 |
+
"good_reps": result.get("good_reps"),
|
| 212 |
+
"serious_reps": result.get("serious_reps"),
|
| 213 |
+
"summary": result.get("summary", ""),
|
| 214 |
+
"main_errors": result.get("main_errors", []),
|
| 215 |
+
"student_video_path": result.get("student_video_path", ""),
|
| 216 |
+
"rep_results": [compact_rep(rep) for rep in result.get("rep_results", [])],
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def compact_rep(rep: dict[str, Any]) -> dict[str, Any]:
|
| 221 |
+
return {
|
| 222 |
+
"rep_num": rep.get("rep_num"),
|
| 223 |
+
"score_pct": rep.get("score_pct"),
|
| 224 |
+
"rule_score_pct": rep.get("rule_score_pct"),
|
| 225 |
+
"dtw_score_pct": rep.get("dtw_score_pct"),
|
| 226 |
+
"status": rep.get("status"),
|
| 227 |
+
"primary_error": rep.get("primary_error"),
|
| 228 |
+
"error_labels": rep.get("error_labels", []),
|
| 229 |
+
"rule_feedback": rep.get("rule_feedback") or rep.get("feedback", ""),
|
| 230 |
+
"llm_feedback": rep.get("llm_feedback", ""),
|
| 231 |
+
"llm_feedback_source": rep.get("llm_feedback_source", ""),
|
| 232 |
+
"llm_feedback_error": rep.get("llm_feedback_error", ""),
|
| 233 |
+
"llm_visual_error_label": rep.get("llm_visual_error_label", ""),
|
| 234 |
+
"llm_arrow": rep.get("llm_arrow"),
|
| 235 |
+
"student_frame_path": rep.get("student_frame_path", ""),
|
| 236 |
+
"expert_frame_path": rep.get("expert_frame_path", ""),
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def print_case_summary(entry: dict[str, Any]) -> None:
|
| 241 |
+
if not entry.get("ok"):
|
| 242 |
+
print(f"[result] ERROR: {entry.get('error')}")
|
| 243 |
+
print(f"[time] {entry.get('elapsed_seconds')}s")
|
| 244 |
+
return
|
| 245 |
+
|
| 246 |
+
if entry.get("expected_rejection"):
|
| 247 |
+
print(f"[result] EXPECTED_REJECTION: {entry.get('error')}")
|
| 248 |
+
print(f"[time] {entry.get('elapsed_seconds')}s")
|
| 249 |
+
return
|
| 250 |
+
|
| 251 |
+
print(
|
| 252 |
+
"[result] "
|
| 253 |
+
f"overall={entry.get('overall_score_pct')}%, "
|
| 254 |
+
f"student_reps={entry.get('student_reps')}, "
|
| 255 |
+
f"expert_reps={entry.get('expert_reps')}, "
|
| 256 |
+
f"good_reps={entry.get('good_reps')}, "
|
| 257 |
+
f"serious_reps={entry.get('serious_reps')}"
|
| 258 |
+
)
|
| 259 |
+
print(f"[summary] {entry.get('summary', '')}")
|
| 260 |
+
for rep in entry.get("rep_results", []):
|
| 261 |
+
errors = ", ".join(rep.get("error_labels") or []) or "none"
|
| 262 |
print(
|
| 263 |
+
" "
|
| 264 |
+
f"rep={int(rep['rep_num']):02d} "
|
| 265 |
+
f"score={rep.get('score_pct')}% "
|
| 266 |
+
f"status={rep.get('status')} "
|
| 267 |
+
f"errors={errors}"
|
|
|
|
| 268 |
)
|
| 269 |
+
print(f"[time] {entry.get('elapsed_seconds')}s")
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def error_distribution(results: list[dict[str, Any]]) -> dict[str, int]:
|
| 273 |
+
counter: Counter[str] = Counter()
|
| 274 |
+
for result in results:
|
| 275 |
+
for error in result.get("main_errors", []):
|
| 276 |
+
label = error.get("label") or error.get("type") or "unknown"
|
| 277 |
+
counter[label] += int(error.get("count") or 0)
|
| 278 |
+
return dict(counter.most_common())
|
| 279 |
+
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
+
def project_relative_path(path: Path) -> str:
|
| 282 |
+
try:
|
| 283 |
+
return path.resolve().relative_to(ROOT).as_posix()
|
| 284 |
+
except ValueError:
|
| 285 |
+
return path.resolve().as_posix()
|
| 286 |
|
| 287 |
|
| 288 |
if __name__ == "__main__":
|