Hoang Duc Hung commited on
Commit
350d731
·
1 Parent(s): 56251f6

feat: stabilize push-up feedback and VLM handling

Browse files
.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
- NVIDIA_MODEL=nvidia/nvidia-nemotron-nano-9b-v2
 
 
 
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 Sports Coach - Taekwondo Biomechanics Analysis
2
 
3
- Ứng dụng sử dụng AI để phân tích hướng dẫn tập luyện Taekwondo thông qua video.
4
 
5
- ## Tính năng
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
- ## Cài đặt
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- 1. Cài đặt các thư viện cần thiết:
14
- ```bash
 
 
 
15
  pip install -r requirements.txt
16
  ```
17
 
18
- 2. Chạy ứng dụng Streamlit:
19
- ```bash
20
- streamlit run app.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  ```
22
 
23
- ## Cấu trúc dự án
24
- - `src/core/pose.py`: Xử lý AI nhận diện khung xương.
25
- - `src/core/compare.py`: Thuật toán so sánh và đồng bộ hóa.
26
- - `src/core/overlay.py`: Engine vẽ và chồng video.
27
- - `src/core/preprocess_reference.py`: Công cụ tạo dữ liệu mẫu từ video chuyên gia.
 
 
 
 
 
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 + LangGraph Feedback Plan
2
 
3
- ## Goal
4
 
5
- Add personalized coaching feedback without changing the current computer-vision core.
6
-
7
- Current core stays deterministic:
8
 
9
  ```text
10
- video -> MediaPipe pose -> push-up reps -> rule errors + scores -> annotated frames
 
11
  ```
12
 
13
- LLM is added only after analysis:
14
-
15
- ```text
16
- analysis result JSON -> LangGraph feedback graph -> Vietnamese coaching text
17
- ```
18
 
19
- The LLM must not detect pose errors from scratch. It only explains validated errors that already come from `push_up/rules.py` and `push_up/evaluator.py`.
20
 
21
- ## Recommended NVIDIA Model
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
- NVIDIA_MODEL=nvidia/nvidia-nemotron-nano-9b-v2
53
- ```
54
-
55
- Dependencies:
56
-
57
- ```text
58
- langgraph
59
- langchain-nvidia-ai-endpoints
60
  ```
61
 
62
- ## LangGraph Design
63
 
64
- Use a small graph, not a free-form agent loop.
65
 
66
- ```text
67
- START
68
- -> build_evidence
69
- -> generate_feedback
70
- -> validate_feedback
71
- -> END
72
- ```
73
 
74
- ### State
75
 
76
- ```python
77
- class FeedbackState(TypedDict):
78
- analysis: dict
79
- evidence: dict
80
- feedback: str
81
- warnings: list[str]
82
- ```
83
 
84
- ### Node: build_evidence
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
- push_up/feedback_graph.py
 
129
  ```
130
 
131
- Expose:
132
-
133
- ```python
134
- def generate_coach_feedback(analysis_result: dict) -> str:
135
- ...
136
- ```
137
 
138
- Then in `push_up/analysis_service.py`, after `payload` is built:
139
 
140
- ```python
141
- payload["coach_feedback"] = generate_coach_feedback(payload)
 
 
 
 
 
 
142
  ```
143
 
144
- In `reflex_frontend/state.py`, add:
145
 
146
- ```python
147
- coach_feedback: str = ""
148
- ```
149
 
150
- Set it after analysis:
151
 
152
- ```python
153
- self.coach_feedback = result.get("coach_feedback", "")
154
- ```
155
 
156
- In `reflex_frontend/ui.py`, show it in `results_panel()`.
 
 
157
 
158
- ## Why Not Let LLM Decide The Error Location?
159
 
160
- The arrow location should remain deterministic.
161
 
162
- Correct responsibility split:
 
 
 
 
163
 
164
- ```text
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
- This avoids hallucinated arrows and keeps the UI explainable.
172
 
173
- ## Test Plan
174
 
175
- Run:
176
 
177
  ```powershell
178
- python scripts/run_pushup_eval_tests.py
179
  ```
180
 
181
- Expected behavior:
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
- Log should include:
194
-
195
- - compared video names
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
- ## MCP
4
 
5
- - Context7 MCP was added to `~/.codex/config.toml`:
6
 
7
- ```toml
8
- [mcp_servers.context7]
9
- command = "npx"
10
- args = ["-y", "@upstash/context7-mcp"]
 
 
 
 
 
 
11
  ```
12
 
13
- - A dedicated Reflex MCP package was not found during npm search. Fallback is to use Context7 where available, inspect Reflex source in `.venv`, and keep local docs in this repo.
 
 
 
 
14
 
15
- ## Repo Config
16
 
17
- - `.codex/config.toml` was not created because `.codex` in this repo is currently a file, not a directory.
18
- - No Stitch package was added. The design direction is captured in `docs/UI_REDESIGN.md` for later Stitch/Figma reference.
19
 
20
- ## Running
21
 
22
- ```bash
23
- source .venv/bin/activate
24
- reflex run
 
 
 
 
 
 
 
 
 
25
  ```
26
 
27
- If ports `3000` or `8000` are busy, Reflex will choose the next available ports.
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
- ## Runtime Directories
30
 
31
- - `.web/` is generated by Reflex. It contains the compiled frontend app, copied static assets, and Node packages. It is not app data and can be deleted; `reflex run` recreates it.
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
- ## Video Storage Policy
36
 
37
- - Template source video: `data/templates/push_up_template.mp4`.
38
- - Template preprocessed cache: `data/processed/pushup_template.pkl`.
39
- - Browser-facing template video: `assets/push_up_template.mp4`.
40
- - Student videos are written only to `/tmp/pushupai_reflex_uploads/students` so the browser can preview them. They are deleted when choosing another video, and stale temp previews are cleaned after 6 hours.
41
- - Per-rep comparison images are written to `/tmp/pushupai_reflex_uploads/analysis` so the result UI can show them. They are deleted when choosing another video, and stale temp artifacts are cleaned after 6 hours.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 Redesign
2
 
3
- ## Direction
4
 
5
- The new UI should feel like a focused AI coaching tool, not a technical demo. Keep the experience bright, clean, and task-first.
6
 
7
- ## Screens
 
 
8
 
9
- - Home: short introduction and one clear action to start analysis.
10
- - Exercise catalog: list supported exercises.
11
- - Exercise analysis: sample video, user video upload, and analysis result in the same workflow.
12
 
13
- ## Visual Style
 
 
14
 
15
- - Brand name: AI Coach.
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
- ## Asset Placeholders
22
 
23
- - Hero image placeholder: person doing push-up or home workout.
24
- - Exercise thumbnail placeholder: still frame from the push-up template video.
25
- - Analysis placeholder: simple visual showing video comparison or AI movement analysis.
 
 
 
 
26
 
27
- These placeholders are represented in UI as clean visual blocks until real assets are added.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Kế hoạch Thực thi Dự án: Huấn luyện viên thể thao AI (Taekwondo)
2
 
3
- Dựa trên yêu cầu từ `problem_statement.md`, dưới đây là bản phân tích và kế hoạch xây dựng phần mềm chi tiết.
4
 
5
- ## 1. Tổng quan Dự án (Project Overview)
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
- ## 2. Kiến trúc Hệ thống (System Architecture)
15
 
16
- ```mermaid
17
- graph TD
18
- A[Người dùng - Mobile Web] -->|Upload Video| B(Streamlit App / Frontend)
19
- B -->|Tiền xử lý| C[Hệ thống Phân tích Video cốt lõi]
20
-
21
- subgraph AI/CV Core
22
- C --> D{Trích xuất Landmark}
23
- D -->|Video User| E[MediaPipe Pose]
24
- D -->|Video Mẫu| F[MediaPipe Pose/Cache DB]
25
- E --> G[Biomechanics Matcher]
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
- ## 3. Lộ trình Triển khai (Implementation Phases)
37
 
38
- ### Giai đoạn 1: Chuẩn bị Môi trường & Khai phá dữ liệu (Setup & Data Pre-processing)
39
- - Thiết lập môi trường Python 3.14, cài đặt các thư viện: `mediapipe`, `opencv-python`, `numpy`, `streamlit`, `scipy`.
40
- - **Dữ liệu Video Mẫu:** Chuẩn bị tối thiểu một vài video thực hiện bài quyền Taekwondo (phân giải ổn định, FPS chuẩn).
41
- - **Trích xuất thông số Mẫu:** Viết một script chạy MediaPipe để lấy ra toàn bộ tọa độ các khớp xương theo từng frame của video mẫu và lưu dưới dạng JSON/CSV hoặc Numpy Array để tải nhanh ở lần đối chiếu sau.
 
 
 
 
 
42
 
43
- ### Giai đoạn 2: Xây dựng Bộ Module Phân tích Cốt lõi (Core AI & Biomechanics Engine)
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
- ### Giai đoạn 3: Phân tích Lỗi Overlay Video (Correction & Result Generation)
52
- - **Scoring (Tính tỷ lệ % lỗi):**
53
- - Đánh giá sự khác biệt của các góc khớp so sánh với video mẫu.
54
- - Trung bình hóa sự khác biệt để cho ra phần trăm độ lệch tổng quan.
55
- - **Video Overlay:**
56
- - Làm mờ (alpha blending) video của sinh viên với video của chuyên gia.
57
- - Hiển thị thông số tĩnh (Text/HUD) và **khoanh vùng màu đỏ** trực tiếp tại vị trí khớp xương bị lỗi (ví dụ: khoanh tròn đỏ ở cánh tay nếu "Tay đang nâng quá cao") để tạo hiệu ứng trực quan rõ rệt điểm cần sửa trên khung hình.
58
- - Xuất frame sang file `.mp4` trả về cho UI.
 
 
 
 
 
 
 
59
 
60
- ### Giai đoạn 4: Giao diện Người dùng Streamlit (Frontend & Mobile Mode)
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
- ### Giai đoạn 5: Tối ưu hóa hiệu năng & Kiểm thử (Testing & Optimization)
69
- - Xử vấn đề bộ nhớ/RAM khi parse video lớn trên Streamlit. Cắt giới hạn video tải lên (VD: tối đa 60 giây / 50MB).
70
- - Tối ưu UI/UX: Áp dụng hiệu ứng skeleton loading UX mượt .
71
- - Kiểm thử các trường hợp video chất lượng thấp, môi trường ánh sáng yếu ảnh hưởng tới BlazePose.
 
72
 
73
- ## 4. Cấu trúc thư mục dự kiến
74
- ```text
75
- /
76
- ├── src/
77
- │ ├── app.py # File chạy chính của trang Streamlit
78
- │ ├── core/
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
- - **Tên dự án:** Huấn luyện viên thể thao AI thông qua phân tích video.
2
- - **Mục tiêu:** Giải quyết vấn đề thiếu hướng dẫn cá nhân hóa cho sinh viên thể thao trong việc nhận diện và điều chỉnh các động tác thực hiện bài quyền Taekwondo chưa chuẩn so với video mẫu chuyên nghiệp.
3
- - **Yêu cầu nghiệp vụ:** Ứng dụng di động (Mobile App) ứng dụng AI/Computer Vision để phân tích video, so sánh với dữ liệu mẫu chuyên nghiệp, và cung cấp phản hồi tinh chỉnh trực quan bao gồm phần trăm độ lệch giữa video của người dùng và video mẫu, và video overlay để người dùng dễ dàng nhận biết lỗi sai.
4
- - **Tech stack:** mediapipe, blazepose, biomechanics analysis, video overlay engine, streamlit, Python 3.14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- student_frame = student_data[rep["w_pair"][0]]
 
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
- _save_pose_frame(
 
 
127
  processor=processor,
128
  video_path=student_video_path,
129
  frame_idx=student_frame["frame_idx"],
130
- destination=upload_root / student_artifact,
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=upload_root / expert_artifact,
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
- ) -> None:
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
- if not directory.exists():
 
 
235
  return
236
 
237
  cutoff = time.time() - max_age_seconds
238
- for path in directory.rglob("*"):
239
- if path.is_file() and path.stat().st_mtime < cutoff:
 
 
 
 
 
 
 
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.box(
643
- rx.text(rep["feedback"], color=COLORS["muted"],
644
- font_size="0.93rem", line_height="1.55"),
645
- background=COLORS["surface_soft"],
646
- border_radius="12px",
647
- padding="12px",
648
- width="100%",
 
 
 
 
 
 
 
 
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
- TEST_CASES = [
17
- ("template_vs_template", TEMPLATE_SOURCE),
18
- ("hv01_cuoi_dau_thap", ROOT / "data" / "tests" / "hv01_cuoi_dau_thap.mp4"),
19
- ("hv01_hit_nua_rep", ROOT / "data" / "tests" / "hv01_hit_nua_rep.mp4"),
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 _orientation_label(video_path: Path) -> str:
30
- processor = VideoProcessor()
31
- needs_flip = processor._detect_orientation(str(video_path))
32
- return "head-left -> flipped" if needs_flip else "head-right/no-flip"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
 
35
  def main() -> int:
36
- print(f"[setup] template: {TEMPLATE_SOURCE}")
37
- prepare_template_cache()
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
- missing = [path for _, path in TEST_CASES if not path.exists()]
40
- if missing:
41
- print("[error] Missing test videos:")
42
- for path in missing:
43
- print(f" - {path}")
44
  return 1
45
 
46
- for label, video_path in TEST_CASES:
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  print("=" * 88)
48
- print(f"[case] {label}")
49
- print(f"[compare] template={TEMPLATE_SOURCE.name} vs student={video_path.name}")
50
- print(f"[orientation] {video_path.name}: {_orientation_label(video_path)}")
51
  started = time.perf_counter()
52
- result = analyze_pushup(video_path, save_artifacts=False)
53
- elapsed = time.perf_counter() - started
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "[result] "
62
- f"overall={result['overall_score_pct']}%, "
63
- f"student_reps={result['student_reps']}, "
64
- f"expert_reps={result['expert_reps']}, "
65
- f"good_reps={result['good_reps']}, "
66
- f"serious_reps={result['serious_reps']}"
67
  )
68
- print(f"[summary] {result['summary']}")
69
- print("[rep_details]")
70
- for rep in result["rep_results"]:
71
- errors = ", ".join(rep["error_labels"]) if rep["error_labels"] else "none"
72
- print(
73
- " "
74
- f"rep={rep['rep_num']:02d} "
75
- f"score={rep['score_pct']:>5}% "
76
- f"rule={rep['rule_score_pct']:>5}% "
77
- f"dtw={rep['dtw_score_pct']:>5}% "
78
- f"status={rep['status']} "
79
- f"errors={errors}"
80
- )
81
- print(f"[time] {elapsed:.1f}s")
82
 
83
- return 0
 
 
 
 
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__":