htrnguyen commited on
Commit
d674fad
·
0 Parent(s):

Fresh deployment with Git LFS

Browse files
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ models/*.pth.tar filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Virtual Environment
24
+ venv/
25
+ ENV/
26
+ env/
27
+
28
+ # IDE
29
+ .vscode/
30
+ .idea/
31
+ *.swp
32
+ *.swo
33
+ *~
34
+
35
+ # Output files
36
+ output/
37
+ *.mp4
38
+ *.avi
39
+ *.mov
40
+
41
+ # Temporary files
42
+ *.tmp
43
+ temp/
44
+ tmp/
45
+
46
+ # OS
47
+ .DS_Store
48
+ Thumbs.db
49
+
50
+ # Logs
51
+ *.log
52
+
53
+ # Data files (if large)
54
+ data/
55
+
56
+ # Jupyter Notebook
57
+ .ipynb_checkpoints
58
+
59
+ # Whitelist for Demo
60
+ !demo/
61
+ !demo/*.mp4
62
+ !demo/*.json
Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Sử dụng Python 3.10 slim để dung lượng nhẹ hơn
2
+ FROM python:3.10-slim
3
+
4
+ # Cài đặt các thư viện hệ thống cần thiết cho OpenCV và MediaPipe
5
+ RUN apt-get update && apt-get install -y \
6
+ libgl1 \
7
+ libglib2.0-0 \
8
+ libsm6 \
9
+ libxext6 \
10
+ libxrender-dev \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Tạo người dùng không có quyền root (Yêu cầu của Hugging Face Spaces)
14
+ RUN useradd -m -u 1000 user
15
+ USER user
16
+ ENV PATH="/home/user/.local/bin:$PATH"
17
+
18
+ WORKDIR /app
19
+
20
+ # Copy các dependencies trước để tối ưu hóa Docker cache
21
+ COPY --chown=user requirements.txt .
22
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
23
+
24
+ # Copy toàn bộ code vào container
25
+ COPY --chown=user . .
26
+
27
+ # Hugging Face Spaces lắng nghe trên cổng 7860 mặc định
28
+ CMD ["uvicorn", "api_server:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Golf Tech Analysis
3
+ emoji: 🏌️
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # Golf Tech Analysis AI
11
+
12
+ Hệ thống phân tích kỹ thuật Golf sử dụng AI (SwingNet & MediaPipe).
13
+
14
+ ## Tính năng
15
+
16
+ - Phân tích 8 giai đoạn của cú Swing.
17
+ - Trích xuất dữ liệu khung xương (Skeleton) dưới dạng JSON.
18
+ - Phân tích tư thế và đưa ra nhận xét chuyên môn.
19
+ - Toàn bộ dữ liệu được xóa ngay sau khi xử lý (Stateless).
20
+
21
+ ## Cách sử dụng
22
+
23
+ 1. Tải video cú đánh của bạn lên qua giao diện Web.
24
+ 2. Chờ hệ thống AI phân tích.
25
+ 3. Tải về file `master_data.json` để sử dụng cho các công cụ phân tích hoặc render video.
26
+
27
+ ## Deploy local
28
+
29
+ ```bash
30
+ pip install -r requirements.txt
31
+ python api_server.py
32
+ ```
analyze.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ sys.stdout.reconfigure(encoding="utf-8")
3
+ import cv2
4
+ import mediapipe as mp
5
+ import numpy as np
6
+ import os
7
+ import json
8
+
9
+ class GolfDiagnosticEngine:
10
+ """
11
+ Hệ thống chẩn đoán kỹ thuật Golf dựa trên tư thế (Pose Estimation).
12
+ Sử dụng MediaPipe để trích xuất điểm mốc và heuristics hình học để chấm điểm.
13
+ """
14
+ def __init__(self):
15
+ self.mp_pose = mp.solutions.pose
16
+ self.mp_drawing = mp.solutions.drawing_utils
17
+ self.pose = self.mp_pose.Pose(static_image_mode=True, min_detection_confidence=0.5)
18
+ self.labels = ['1_Address', '2_Toe-up', '3_Mid-Backswing', '4_Top',
19
+ '5_Mid-Downswing', '6_Impact', '7_Mid-Follow-Through', '8_Finish']
20
+
21
+ def detect_view_angle(self, landmarks):
22
+ """
23
+ Xác định góc quay (Face-on hay Down-the-line) dựa trên độ rộng vai ở Address.
24
+ Face-on: Hai vai cách xa nhau theo trục X.
25
+ DTL: Hai vai gần nhau (che khuất).
26
+ """
27
+ if not landmarks: return "Unknown"
28
+
29
+ l_shoulder = landmarks[self.mp_pose.PoseLandmark.LEFT_SHOULDER.value]
30
+ r_shoulder = landmarks[self.mp_pose.PoseLandmark.RIGHT_SHOULDER.value]
31
+ l_hip = landmarks[self.mp_pose.PoseLandmark.LEFT_HIP.value]
32
+ r_hip = landmarks[self.mp_pose.PoseLandmark.RIGHT_HIP.value]
33
+
34
+ shoulder_width = abs(l_shoulder.x - r_shoulder.x)
35
+
36
+ # Tính chiều cao thân người (Torso Height) để chuẩn hóa
37
+ mid_shoulder_y = (l_shoulder.y + r_shoulder.y) / 2
38
+ mid_hip_y = (l_hip.y + r_hip.y) / 2
39
+ torso_height = abs(mid_shoulder_y - mid_hip_y)
40
+
41
+ if torso_height == 0: return "Unknown"
42
+
43
+ # Tỷ lệ chiều rộng vai / chiều cao thân
44
+ ratio = shoulder_width / torso_height
45
+
46
+ # Heuristic: Nếu tỷ lệ > 0.5 (vai rộng bằng nửa lưng) -> Face-on
47
+ if ratio > 0.4: # Hạ threshold xuống chút cho an toàn
48
+ return "Face-on (Trực diện)"
49
+ else:
50
+ return "Down-the-Line (Dọc)"
51
+
52
+
53
+ def calculate_angle(self, a, b, c):
54
+ """Tính góc (độ) giữa 3 điểm a, b, c (b là đỉnh)"""
55
+ a = np.array(a)
56
+ b = np.array(b)
57
+ c = np.array(c)
58
+
59
+ radians = np.arctan2(c[1]-b[1], c[0]-b[0]) - np.arctan2(a[1]-b[1], a[0]-b[0])
60
+ angle = np.abs(radians*180.0/np.pi)
61
+
62
+ if angle > 180.0:
63
+ angle = 360-angle
64
+
65
+ return angle
66
+
67
+ def get_landmarks(self, image):
68
+ results = self.pose.process(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
69
+ if not results.pose_landmarks:
70
+ return None, None
71
+ return results.pose_landmarks.landmark, results.pose_landmarks
72
+
73
+ def draw_annotations(self, image, landmarks_proto, phase_name, analysis):
74
+ """Vẽ landmarks tinh tế và chuyên nghiệp hơn"""
75
+ annotated_image = image.copy()
76
+
77
+ # Cấu hình phong cách vẽ tinh tế (mỏng hơn, nhỏ hơn)
78
+ landmark_style = self.mp_drawing.DrawingSpec(color=(0, 255, 0), thickness=1, circle_radius=1) # Neon Green thon gọn
79
+ connection_style = self.mp_drawing.DrawingSpec(color=(255, 255, 255), thickness=1) # Đường nối trắng mỏng
80
+
81
+ # Vẽ khung xương
82
+ self.mp_drawing.draw_landmarks(
83
+ annotated_image, landmarks_proto, self.mp_pose.POSE_CONNECTIONS,
84
+ landmark_style,
85
+ connection_style
86
+ )
87
+
88
+ return annotated_image
89
+
90
+ def analyze_phase(self, phase_name, landmarks):
91
+ if not landmarks:
92
+ return {"score": 0, "comments": ["Không tìm thấy tư thế"], "data": {}}
93
+
94
+ analysis = {"score": 10.0, "comments": [], "data": {}, "raw_landmarks": []}
95
+
96
+ # Trích xuất tọa độ thô của toàn bộ khớp xương để kỹ sư khác sử dụng
97
+ for i, lm in enumerate(landmarks):
98
+ analysis["raw_landmarks"].append({
99
+ "id": i,
100
+ "name": self.mp_pose.PoseLandmark(i).name,
101
+ "x": round(lm.x, 4),
102
+ "y": round(lm.y, 4),
103
+ "z": round(lm.z, 4),
104
+ "visibility": round(lm.visibility, 4)
105
+ })
106
+
107
+ # Lấy các điểm mốc quan trọng để tính toán logic nội bộ
108
+ l_shoulder = [landmarks[self.mp_pose.PoseLandmark.LEFT_SHOULDER.value].x, landmarks[self.mp_pose.PoseLandmark.LEFT_SHOULDER.value].y]
109
+ r_shoulder = [landmarks[self.mp_pose.PoseLandmark.RIGHT_SHOULDER.value].x, landmarks[self.mp_pose.PoseLandmark.RIGHT_SHOULDER.value].y]
110
+ l_elbow = [landmarks[self.mp_pose.PoseLandmark.LEFT_ELBOW.value].x, landmarks[self.mp_pose.PoseLandmark.LEFT_ELBOW.value].y]
111
+ r_elbow = [landmarks[self.mp_pose.PoseLandmark.RIGHT_ELBOW.value].x, landmarks[self.mp_pose.PoseLandmark.RIGHT_ELBOW.value].y]
112
+ l_wrist = [landmarks[self.mp_pose.PoseLandmark.LEFT_WRIST.value].x, landmarks[self.mp_pose.PoseLandmark.LEFT_WRIST.value].y]
113
+ r_wrist = [landmarks[self.mp_pose.PoseLandmark.RIGHT_WRIST.value].x, landmarks[self.mp_pose.PoseLandmark.RIGHT_WRIST.value].y]
114
+ l_hip = [landmarks[self.mp_pose.PoseLandmark.LEFT_HIP.value].x, landmarks[self.mp_pose.PoseLandmark.LEFT_HIP.value].y]
115
+ r_hip = [landmarks[self.mp_pose.PoseLandmark.RIGHT_HIP.value].x, landmarks[self.mp_pose.PoseLandmark.RIGHT_HIP.value].y]
116
+ l_ankle = [landmarks[self.mp_pose.PoseLandmark.LEFT_ANKLE.value].x, landmarks[self.mp_pose.PoseLandmark.LEFT_ANKLE.value].y]
117
+ r_ankle = [landmarks[self.mp_pose.PoseLandmark.RIGHT_ANKLE.value].x, landmarks[self.mp_pose.PoseLandmark.RIGHT_ANKLE.value].y]
118
+
119
+ # Phân tích cụ thể từng pha (Thang điểm 10)
120
+ # Sử dụng endswith để khớp với tên có hoặc không có số thứ tự
121
+ if phase_name.endswith('Address'):
122
+ # 1. Độ rộng chân (so với vai)
123
+ shoulder_width = np.abs(l_shoulder[0] - r_shoulder[0])
124
+ stance_width = np.abs(l_ankle[0] - r_ankle[0])
125
+ ratio = stance_width / shoulder_width if shoulder_width > 0 else 0
126
+ analysis["data"]["stance_ratio"] = ratio
127
+ if ratio < 0.8:
128
+ analysis["score"] -= 1.0
129
+ analysis["comments"].append("Tư thế đứng quá hẹp")
130
+ elif ratio > 1.4:
131
+ analysis["score"] -= 1.0
132
+ analysis["comments"].append("Tư thế đứng quá rộng")
133
+
134
+ elif phase_name.endswith('Top'):
135
+ # 2. Độ thẳng tay trái (Lead arm - giả sử golfer thuận tay phải)
136
+ lead_arm_angle = self.calculate_angle(l_shoulder, l_elbow, l_wrist)
137
+ analysis["data"]["lead_arm_angle"] = lead_arm_angle
138
+ if lead_arm_angle < 150:
139
+ analysis["score"] -= 2.0
140
+ analysis["comments"].append("Tay trái bị cong quá nhiều (Chicken Wing)")
141
+
142
+ # 3. Góc xoay vai (đơn giản hóa)
143
+ shoulder_tilt = np.abs(l_shoulder[1] - r_shoulder[1])
144
+ analysis["data"]["shoulder_tilt"] = shoulder_tilt
145
+
146
+ elif phase_name.endswith('Impact'):
147
+ # 4. Độ thẳng tay trái lúc Impact
148
+ impact_arm_angle = self.calculate_angle(l_shoulder, l_elbow, l_wrist)
149
+ if impact_arm_angle < 160:
150
+ analysis["score"] -= 2.5
151
+ analysis["comments"].append("Tay trái không thẳng, mất lực")
152
+
153
+ # 5. Hip rotation (Lead hip vs Back hip)
154
+ hip_openness = l_hip[0] - r_hip[0]
155
+ analysis["data"]["hip_openness"] = hip_openness
156
+
157
+ elif phase_name.endswith('Finish'):
158
+ # 6. Thăng bằng (Trọng tâm dồn về chân trái)
159
+ # Kiểm tra thăng bằng đơn giản bằng vị trí hông so với chân lead
160
+ if l_hip[0] > l_ankle[0] + 0.1 or l_hip[0] < l_ankle[0] - 0.1:
161
+ analysis["score"] -= 1.5
162
+ analysis["comments"].append("Kết thúc thiếu thăng bằng")
163
+
164
+ if not analysis["comments"]:
165
+ analysis["comments"].append("Đạt chuẩn")
166
+
167
+ analysis["score"] = round(max(0, analysis["score"]), 1)
168
+ return analysis
169
+
170
+ def process_video_results(self, output_dir):
171
+ # Lấy video_id từ tên thư mục output
172
+ video_id = os.path.basename(output_dir)
173
+ extraction_dir = os.path.join(output_dir, 'phases')
174
+
175
+ report = {"video_id": video_id, "phases": {}, "overall_score": 0, "view_angle": "Unknown"}
176
+
177
+ total_score = 0
178
+ valid_phases = 0
179
+
180
+ # Mảng lưu landmarks Address để detect view
181
+ address_landmarks = None
182
+
183
+ for label in self.labels:
184
+ img_path = os.path.join(extraction_dir, label, f"{video_id}.jpg")
185
+ if not os.path.exists(img_path):
186
+ continue
187
+
188
+ image = cv2.imread(img_path)
189
+ landmarks_list, _ = self.get_landmarks(image)
190
+
191
+ # Lưu landmarks của Address
192
+ if label == '1_Address' and landmarks_list:
193
+ address_landmarks = landmarks_list
194
+
195
+ phase_analysis = self.analyze_phase(label, landmarks_list)
196
+
197
+ report["phases"][label] = phase_analysis
198
+ total_score += phase_analysis["score"]
199
+ valid_phases += 1
200
+
201
+ if valid_phases > 0:
202
+ report["overall_score"] = round(total_score / valid_phases, 1)
203
+
204
+ # Detect View Angle một lần duy nhất
205
+ if address_landmarks:
206
+ report["view_angle"] = self.detect_view_angle(address_landmarks)
207
+
208
+ # Lưu báo cáo vào thư mục output của video
209
+ report_path = os.path.join(output_dir, 'report.json')
210
+ with open(report_path, 'w', encoding='utf-8') as f:
211
+ json.dump(report, f, indent=4, ensure_ascii=False)
212
+
213
+ return report
214
+
215
+ if __name__ == "__main__":
216
+ import sys
217
+ import argparse
218
+ parser = argparse.ArgumentParser()
219
+ parser.add_argument('output_dir', help='Thư mục output của video (ví dụ: output/video_01)')
220
+ args = parser.parse_args()
221
+
222
+ engine = GolfDiagnosticEngine()
223
+ result = engine.process_video_results(args.output_dir)
224
+ print(json.dumps(result, indent=4, ensure_ascii=False))
api_server.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ import uuid
4
+ import json
5
+ from fastapi import FastAPI, UploadFile, File, HTTPException, Request
6
+ from fastapi.responses import JSONResponse, FileResponse
7
+ from fastapi.templating import Jinja2Templates
8
+ import subprocess
9
+ import sys
10
+
11
+ app = FastAPI(title="Golf Tech Analysis API - Stateless")
12
+
13
+ UPLOAD_DIR = "uploads"
14
+ OUTPUT_DIR = "output"
15
+ TEMPLATES_DIR = "templates"
16
+
17
+ # Đảm bảo các thư mục tồn tại (chúng sẽ chỉ chứa file tạm thời cực ngắn)
18
+ os.makedirs(UPLOAD_DIR, exist_ok=True)
19
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
20
+ os.makedirs(TEMPLATES_DIR, exist_ok=True)
21
+
22
+ templates = Jinja2Templates(directory=TEMPLATES_DIR)
23
+
24
+ @app.post("/")
25
+ async def analyze_video(file: UploadFile = File(...)):
26
+ # 1. Tạo job tạm thời
27
+ job_id = str(uuid.uuid4())
28
+ video_ext = os.path.splitext(file.filename)[1]
29
+ video_path = os.path.join(UPLOAD_DIR, f"{job_id}{video_ext}")
30
+
31
+ try:
32
+ # 2. Lưu video tạm thời
33
+ with open(video_path, "wb") as buffer:
34
+ shutil.copyfileobj(file.file, buffer)
35
+
36
+ # 3. Chạy Pipeline phân tích
37
+ base_path = os.path.dirname(os.path.abspath(__file__))
38
+ main_py_path = os.path.join(base_path, "main.py")
39
+ video_abs_path = os.path.abspath(video_path)
40
+
41
+ process = subprocess.run(
42
+ [sys.executable, main_py_path, video_abs_path],
43
+ capture_output=True,
44
+ text=True,
45
+ encoding="utf-8"
46
+ )
47
+
48
+ if process.returncode != 0:
49
+ raise HTTPException(status_code=500, detail=f"Pipeline Error: {process.stderr}")
50
+
51
+ # 4. Đọc kết quả Master JSON
52
+ video_id = os.path.splitext(os.path.basename(video_path))[0]
53
+ job_output_dir = os.path.join(OUTPUT_DIR, video_id)
54
+ master_json_path = os.path.join(job_output_dir, "master_data.json")
55
+
56
+ if not os.path.exists(master_json_path):
57
+ raise HTTPException(status_code=500, detail="Không tìm thấy master_data.json.")
58
+
59
+ with open(master_json_path, 'r', encoding='utf-8') as f:
60
+ master_report = json.load(f)
61
+
62
+ # 5. DỌN DẸP TUYỆT ĐỐI NGAY LẬP TỨC (Không lưu lại bất cứ gì)
63
+ shutil.rmtree(job_output_dir, ignore_errors=True)
64
+ if os.path.exists(video_path): os.remove(video_path)
65
+
66
+ # Xóa các file rác khác trong uploads nếu có
67
+ # (Chỉ xóa file của chính job này để đảm bảo concurrency)
68
+
69
+ # Trả về JSON Data trực tiếp (Browser sẽ nhận được text/json)
70
+ return master_report
71
+
72
+ except Exception as e:
73
+ # Cleanup nếu có lỗi
74
+ if os.path.exists(video_path): os.remove(video_path)
75
+ video_id = os.path.splitext(os.path.basename(video_path))[0]
76
+ shutil.rmtree(os.path.join(OUTPUT_DIR, video_id), ignore_errors=True)
77
+ raise HTTPException(status_code=500, detail=str(e))
78
+
79
+ @app.get("/")
80
+ async def root(request: Request):
81
+ return templates.TemplateResponse("index.html", {"request": request})
82
+
83
+ if __name__ == "__main__":
84
+ import uvicorn
85
+ # Hugging Face Spaces yêu cầu cổng 7860
86
+ uvicorn.run(app, host="0.0.0.0", port=7860)
coach.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ sys.stdout.reconfigure(encoding="utf-8")
3
+ import json
4
+ import os
5
+
6
+ class GolfCoachEngine:
7
+ """
8
+ Công cụ Huấn luyện Golf: Chấm điểm và gợi ý bài tập.
9
+ - Áp dụng trọng số cho từng pha (Impact quan trọng nhất).
10
+ - So sánh lỗi với 'Drill Library' để đưa ra bài tập khắc phục.
11
+ """
12
+ def __init__(self):
13
+ # Trọng số cho từng pha (Impact là quan trọng nhất)
14
+ self.weights = {
15
+ '1_Address': 0.1,
16
+ '2_Toe-up': 0.05,
17
+ '3_Mid-Backswing': 0.05,
18
+ '4_Top': 0.2,
19
+ '5_Mid-Downswing': 0.1,
20
+ '6_Impact': 0.35,
21
+ '7_Mid-Follow-Through': 0.05,
22
+ '8_Finish': 0.1
23
+ }
24
+
25
+ # Thư viện bài tập gợi ý
26
+ self.drill_library = {
27
+ "Tư thế đứng quá rộng": "Bài tập đứng trên hai đầu gối hoặc khép chân để cảm nhận sự xoay trục.",
28
+ "Tư thế đứng quá hẹp": "Bài tập bước rộng chân và xoay hông tại chỗ để tạo nền tảng vững chắc.",
29
+ "Tay trái bị cong quá nhiều ở đỉnh swing (Chicken Wing)": "Kẹp một chiếc khăn hoặc quả bóng tập giữa hai cánh tay ở nách để giữ sự kết nối.",
30
+ "Tay trái không thẳng lúc tiếp bóng, mất lực": "Bài tập Half-swing (swing nửa vòng) tập trung vào việc giữ tay lead thẳng và xoay hông.",
31
+ "Kết thúc không vững, trọng tâm chưa dồn hết về chân trái": "Bài tập Hold Finish - giữ nguyên tư thế kết thúc trong 3 giây cho đến khi thăng bằng hoàn toàn."
32
+ }
33
+
34
+ def generate_report(self, diagnostic_json_path):
35
+ with open(diagnostic_json_path, 'r', encoding='utf-8') as f:
36
+ data = json.load(f)
37
+
38
+ phases = data.get("phases", {})
39
+ weighted_score = 0
40
+ all_comments = []
41
+ drills = set()
42
+
43
+ # Tính toán điểm có trọng số
44
+ for phase, analysis in phases.items():
45
+ score = analysis.get("score", 0)
46
+ weight = self.weights.get(phase, 0.1)
47
+ weighted_score += score * weight
48
+
49
+ # Thu thập nhận xét và drills
50
+ for comment in analysis.get("comments", []):
51
+ all_comments.append(f"[{phase}] {comment}")
52
+ if comment in self.drill_library:
53
+ drills.add(self.drill_library[comment])
54
+
55
+ # Phân loại trình độ dựa trên điểm (Thang điểm 10)
56
+ score = round(weighted_score, 1)
57
+ if score >= 9.0: level = "Professional / Low Handicap"
58
+ elif score >= 7.5: level = "Mid Handicap"
59
+ elif score >= 5.5: level = "High Handicap"
60
+ else: level = "Beginner"
61
+
62
+ final_report = {
63
+ "video_id": data.get("video_id"),
64
+ "final_score": score,
65
+ "skill_level": level,
66
+ "key_faults": all_comments,
67
+ "recommended_drills": list(drills),
68
+ "summary": self._generate_summary(score, all_comments)
69
+ }
70
+
71
+ return final_report
72
+
73
+ def _generate_summary(self, score, faults):
74
+ if not faults or faults[0].endswith("Đạt chuẩn"):
75
+ return f"Cú swing của bạn đạt {score}/10. Kỹ thuật rất chuẩn, hãy tiếp tục duy trì!"
76
+
77
+ summary = f"Điểm kỹ thuật của bạn đạt {score}/10. "
78
+ if score < 7.0:
79
+ summary += "Hệ thống phát hiện một số lỗi cần khắc phục để nâng cao hiệu suất. "
80
+ else:
81
+ summary += "Kỹ thuật của bạn khá tốt, chỉ cần tinh chỉnh một vài chi tiết nhỏ. "
82
+
83
+ fault_text = faults[0].split('] ')[1] if faults else ""
84
+ if fault_text != "Đạt chuẩn":
85
+ summary += f"Lỗi ưu tiên cần sửa: {fault_text}."
86
+ return summary
87
+
88
+ if __name__ == "__main__":
89
+ import sys
90
+ import argparse
91
+ parser = argparse.ArgumentParser()
92
+ parser.add_argument('output_dir', help='Thư mục output của video')
93
+ args = parser.parse_args()
94
+
95
+ coach = GolfCoachEngine()
96
+
97
+ input_path = os.path.join(args.output_dir, 'report.json')
98
+ if os.path.exists(input_path):
99
+ final_result = coach.generate_report(input_path)
100
+ print(json.dumps(final_result, indent=4, ensure_ascii=False))
101
+
102
+ # Lưu báo cáo cuối cùng vào thư mục output của video
103
+ output_path = os.path.join(args.output_dir, 'FINAL_report.json')
104
+ with open(output_path, 'w', encoding='utf-8') as f:
105
+ json.dump(final_result, f, indent=4, ensure_ascii=False)
106
+ print(f"\nBáo cáo coaching cuối cùng đã lưu tại {output_path}")
107
+ else:
108
+ print(f"Lỗi: Không tìm thấy file: {input_path}")
109
+ sys.exit(1)
extract.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ sys.stdout.reconfigure(encoding="utf-8")
4
+ import torch
5
+ import cv2
6
+ import numpy as np
7
+ import torch.nn.functional as F
8
+ from torchvision import transforms
9
+
10
+ # Thêm đường dẫn src để import model
11
+ sys.path.insert(0, os.path.abspath('src'))
12
+
13
+ from model import EventDetector
14
+ from eval import ToTensor, Normalize
15
+ from tqdm import tqdm
16
+
17
+ import argparse
18
+ import json
19
+
20
+ def smooth_probs(probs, window_size=5):
21
+ """Làm mượt xác suất bằng Moving Average"""
22
+ smoothed = np.zeros_like(probs)
23
+ for i in range(probs.shape[1]):
24
+ smoothed[:, i] = np.convolve(probs[:, i], np.ones(window_size)/window_size, mode='same')
25
+ # Chia lại để tổng xác suất mỗi frame = 1
26
+ return smoothed / smoothed.sum(axis=1, keepdims=True)
27
+
28
+ def run_ai_extraction(video_path, slow_factor=1.0, output_dir=None):
29
+ if not os.path.exists(video_path):
30
+ print(f"Lỗi: Không tìm thấy file {video_path}")
31
+ return
32
+
33
+ video_name = os.path.basename(video_path)
34
+ video_prefix = os.path.splitext(video_name)[0]
35
+
36
+ # Nếu không có output_dir, dùng mặc định
37
+ if output_dir is None:
38
+ output_dir = os.path.join('output', video_prefix)
39
+
40
+ model_path = 'models/swingnet_1800.pth.tar'
41
+ phases_dir = os.path.join(output_dir, 'phases')
42
+
43
+ # Thêm số thứ tự để đảm bảo sắp xếp đúng trong thư mục
44
+ labels = ['1_Address', '2_Toe-up', '3_Mid-Backswing', '4_Top',
45
+ '5_Mid-Downswing', '6_Impact', '7_Mid-Follow-Through', '8_Finish']
46
+ for label in labels:
47
+ os.makedirs(os.path.join(phases_dir, label), exist_ok=True)
48
+
49
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
50
+ model = EventDetector(pretrain=False, width_mult=1., lstm_layers=1, lstm_hidden=256, bidirectional=True, dropout=False)
51
+
52
+ save_dict = torch.load(model_path, map_location=device)
53
+ model.load_state_dict(save_dict['model_state_dict'])
54
+ model.to(device).eval()
55
+ print(f"Mô hình đã sẵn sàng trên {device} (Slow factor: {slow_factor})")
56
+
57
+ transform = transforms.Compose([ToTensor(), Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])])
58
+
59
+ print(f"Đang đọc video {video_path}...")
60
+ cap = cv2.VideoCapture(video_path)
61
+ raw_frames = []
62
+ while True:
63
+ ret, img = cap.read()
64
+ if not ret: break
65
+ raw_frames.append(img)
66
+ cap.release()
67
+
68
+ if not raw_frames:
69
+ print("Không tìm thấy frame nào.")
70
+ return
71
+
72
+ # Frame Interpolation (Nội suy tuyến tính)
73
+ if slow_factor < 1.0:
74
+ print(f"Đang nội suy frame (Slow-mo {slow_factor}x)...")
75
+ steps = int(1.0 / slow_factor)
76
+ interpolated_full_res = []
77
+ for i in range(len(raw_frames) - 1):
78
+ f1 = raw_frames[i]
79
+ f2 = raw_frames[i+1]
80
+ for s in range(steps):
81
+ alpha = s / steps
82
+ mixed = cv2.addWeighted(f1, 1-alpha, f2, alpha, 0)
83
+ interpolated_full_res.append(mixed)
84
+ interpolated_full_res.append(raw_frames[-1])
85
+ full_res_frames = interpolated_full_res
86
+
87
+ # Lưu file video slow-motion vật lý
88
+ slow_video_path = os.path.join(output_dir, "slow_motion.mp4")
89
+ print(f"Đang ghi file slow motion: {slow_video_path}")
90
+
91
+ # Lấy lại FPS gốc (hoặc mặc định 30)
92
+ cap = cv2.VideoCapture(video_path)
93
+ fps = cap.get(cv2.CAP_PROP_FPS)
94
+ cap.release()
95
+ if fps <= 0: fps = 30
96
+
97
+ h, w = full_res_frames[0].shape[:2]
98
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
99
+ out = cv2.VideoWriter(slow_video_path, fourcc, fps, (w, h))
100
+
101
+ for frame in full_res_frames:
102
+ out.write(frame)
103
+ out.release()
104
+ print(f"Đã tạo video slow motion: {slow_video_path}")
105
+
106
+ else:
107
+ full_res_frames = raw_frames
108
+
109
+ # Tiền xử lý cho AI
110
+ print("Đang tiền xử lý cho AI...")
111
+ images = []
112
+ input_size = 160
113
+ for img in tqdm(full_res_frames):
114
+ h, w = img.shape[:2]
115
+ ratio = input_size / max(h, w)
116
+ new_size = (int(w * ratio), int(h * ratio))
117
+ resized = cv2.resize(img, new_size)
118
+ delta_w, delta_h = input_size - new_size[0], input_size - new_size[1]
119
+ top, bottom, left, right = delta_h // 2, delta_h - (delta_h // 2), delta_w // 2, delta_w - (delta_w // 2)
120
+ b_img = cv2.copyMakeBorder(resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value=[0.406*255, 0.456*255, 0.485*255])
121
+ images.append(cv2.cvtColor(b_img, cv2.COLOR_BGR2RGB))
122
+
123
+ sample = transform({'images': np.asarray(images), 'labels': np.zeros(len(images))})
124
+ img_tensor = sample['images'].unsqueeze(0).to(device)
125
+
126
+ print("Đang chạy AI Inference...")
127
+ with torch.no_grad():
128
+ seq_length, batch, all_logits = 48, 0, []
129
+ while batch * seq_length < img_tensor.shape[1]:
130
+ end_idx = min((batch + 1) * seq_length, img_tensor.shape[1])
131
+ all_logits.append(model(img_tensor[:, batch * seq_length:end_idx, :, :, :]).cpu().numpy())
132
+ batch += 1
133
+
134
+ logits_concat = np.concatenate(all_logits, axis=0)
135
+ probs = F.softmax(torch.tensor(logits_concat), dim=1).numpy()
136
+
137
+ # Làm mượt kết quả để giảm nhiễu (đặc biệt cho DTL)
138
+ probs = smooth_probs(probs, window_size=5)
139
+
140
+ # Áp dụng thuật toán Anchor-based Bidirectional Search
141
+ # 1. Tìm sự kiện "Neo" (Anchor) đáng tin cậy nhất (thường là Impact hoặc Top)
142
+ # Chỉ xét 8 lớp sự kiện đầu tiên, bỏ qua lớp số 9 (No Event/Background)
143
+ probs_events = probs[:, :8]
144
+ max_probs = np.max(probs_events, axis=0) # Max prob của từng class sự kiện
145
+ anchor_class = np.argmax(max_probs) # Class có độ tự tin cao nhất trong 8 sự kiện
146
+ anchor_frame = np.argmax(probs_events[:, anchor_class])
147
+
148
+ print(f"Detected Anchor: {labels[anchor_class]} at frame {anchor_frame} (conf: {max_probs[anchor_class]:.2f})")
149
+
150
+ events = np.zeros(8, dtype=int)
151
+ events[anchor_class] = anchor_frame
152
+
153
+ # 2. Đi lùi: Tìm các pha trước Anchor
154
+ current_limit = anchor_frame
155
+ for i in range(anchor_class - 1, -1, -1):
156
+ if current_limit > 0:
157
+ # Tìm max trong vùng cho phép [0, current_limit]
158
+ segment = probs[0:current_limit, i]
159
+ if len(segment) > 0:
160
+ events[i] = np.argmax(segment)
161
+ else:
162
+ events[i] = 0
163
+ else:
164
+ events[i] = 0
165
+ current_limit = events[i]
166
+
167
+ # 3. Đi tiến: Tìm các pha sau Anchor
168
+ current_limit = anchor_frame
169
+ total_frames = probs.shape[0]
170
+ for i in range(anchor_class + 1, 8):
171
+ if current_limit < total_frames - 1:
172
+ # Tìm max trong vùng cho phép [current_limit + 1, end]
173
+ # Bắt buộc tiến ít nhất 1 frame
174
+ segment = probs[current_limit + 1:, i]
175
+ if len(segment) > 0:
176
+ events[i] = (current_limit + 1) + np.argmax(segment)
177
+ else:
178
+ events[i] = total_frames - 1
179
+ else:
180
+ events[i] = total_frames - 1
181
+ current_limit = events[i]
182
+
183
+ print(f"Detected Events (Frames): {events}")
184
+
185
+ # Lưu thông tin frame index để visual_report sử dụng
186
+ event_metadata = {}
187
+ for i, frame_idx in enumerate(events):
188
+ if frame_idx < len(full_res_frames):
189
+ cv2.imwrite(os.path.join(phases_dir, labels[i], f"{video_prefix}.jpg"), full_res_frames[frame_idx])
190
+ event_metadata[labels[i]] = int(frame_idx)
191
+
192
+ # Lấy FPS để đồng bộ phía frontend
193
+ cap = cv2.VideoCapture(video_path)
194
+ fps = cap.get(cv2.CAP_PROP_FPS)
195
+ cap.release()
196
+ if fps <= 0: fps = 30
197
+
198
+ metadata_path = os.path.join(output_dir, "metadata.json")
199
+ with open(metadata_path, 'w') as f:
200
+ json.dump({"event_frames": event_metadata, "slow_factor": slow_factor, "fps": fps}, f)
201
+
202
+ print(f"Xong! Ảnh trích xuất lưu tại {phases_dir}")
203
+
204
+ if __name__ == "__main__":
205
+ parser = argparse.ArgumentParser()
206
+ parser.add_argument('video_path', nargs='?', default='data/output_video_-M5SITXMA2Y.mp4')
207
+ parser.add_argument('--slow', type=float, default=1.0, help='Tỉ lệ làm chậm video (0.5, 0.2, ...)')
208
+ parser.add_argument('--output_dir', type=str, default=None, help='Đường dẫn thư mục đầu ra')
209
+ args = parser.parse_args()
210
+
211
+ run_ai_extraction(args.video_path, args.slow, args.output_dir)
main.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ sys.stdout.reconfigure(encoding="utf-8")
4
+ import json
5
+ import subprocess
6
+ import argparse
7
+
8
+ def run_step(command, step_desc):
9
+ """
10
+ Thực thi một lệnh shell và in kết quả gọn gàng.
11
+ """
12
+ print(f"{step_desc}...", end=" ", flush=True)
13
+
14
+ # Force UTF-8 encoding for subprocess
15
+ env = os.environ.copy()
16
+ env["PYTHONIOENCODING"] = "utf-8"
17
+
18
+ result = subprocess.run(command, capture_output=True, text=True, errors='replace', encoding='utf-8', env=env)
19
+
20
+ if result.returncode != 0:
21
+ print("FAILED")
22
+ print(f"Lỗi chi tiết:\n{result.stderr}")
23
+ return False
24
+
25
+ print("DONE")
26
+ return True
27
+
28
+ def main():
29
+ # Cấu hình stdout để hỗ trợ tiếng Việt trên Windows terminal
30
+ if sys.stdout.encoding != 'utf-8':
31
+ try:
32
+ sys.stdout.reconfigure(encoding='utf-8')
33
+ sys.stderr.reconfigure(encoding='utf-8')
34
+ except AttributeError:
35
+ import io
36
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
37
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
38
+
39
+ parser = argparse.ArgumentParser(description='Hệ thống Phân tích Golf thông minh (End-to-End)')
40
+ parser.add_argument('video_path', help='Đường dẫn đến file video .mp4')
41
+ parser.add_argument('--slow', type=float, default=1.0, help='Tỷ lệ làm chậm (0.5 hoặc 0.2 để tăng độ chính xác)')
42
+ args = parser.parse_args()
43
+
44
+ video_path = args.video_path
45
+ video_name = os.path.basename(video_path)
46
+ video_id = os.path.splitext(video_name)[0]
47
+
48
+ # Tạo thư mục output riêng cho video này
49
+ output_dir = os.path.join('output', video_id)
50
+ os.makedirs(output_dir, exist_ok=True)
51
+
52
+ print("====================================================")
53
+ print(f"BẮT ĐẦU PHÂN TÍCH VIDEO: {video_name}")
54
+ print(f"Thư mục kết quả: {output_dir}")
55
+ print("====================================================")
56
+
57
+ # Bước 1: Trích xuất giai đoạn bằng AI
58
+ if not run_step([sys.executable, 'extract.py', video_path, '--slow', str(args.slow), '--output_dir', output_dir],
59
+ "[1/4] Nhận diện giai đoạn"):
60
+ sys.exit(1)
61
+
62
+ # Bước 2: Chẩn đoán tư thế (Landmark Analysis)
63
+ if not run_step([sys.executable, 'analyze.py', output_dir],
64
+ "[2/4] Phân tích tư thế"):
65
+ sys.exit(1)
66
+
67
+ # Tự động dọn dẹp thư mục phases để tiết kiệm dung lượng
68
+ import shutil
69
+ phases_dir = os.path.join(output_dir, 'phases')
70
+ if os.path.exists(phases_dir):
71
+ try:
72
+ shutil.rmtree(phases_dir)
73
+ except Exception as e:
74
+ pass # Silent cleanup
75
+
76
+ # Bước 3: Đưa ra nhận xét và chấm điểm (Coaching Engine)
77
+ if not run_step([sys.executable, 'coach.py', output_dir],
78
+ "[3/4] Chấm điểm & Phân tích"):
79
+ sys.exit(1)
80
+
81
+ # Bước 4: Tạo video báo cáo trực quan (Timeline) - ĐÃ LOẠI BỎ ĐỂ CHUYỂN SANG OVERLAY PHÍA CLIENT
82
+ # slow_video_path = os.path.join(output_dir, "slow_motion.mp4")
83
+ # target_video_for_report = video_path
84
+ # if os.path.exists(slow_video_path):
85
+ # target_video_for_report = slow_video_path
86
+ # if not run_step([sys.executable, 'report.py', target_video_for_report, output_dir],
87
+ # "[4/4] Tạo báo cáo video"):
88
+ # sys.exit(1)
89
+
90
+ # 4. TỔNG HỢP VÀ DỌN DẸP -> CHỈ GIỮ LẠI MASTER JSON
91
+ final_report_path = os.path.join(output_dir, "FINAL_report.json")
92
+ if os.path.exists(final_report_path):
93
+ try:
94
+ with open(final_report_path, 'r', encoding='utf-8') as f:
95
+ report_data = json.load(f)
96
+
97
+ master_data = {
98
+ "status": "success",
99
+ "job_id": video_id,
100
+ "metadata": {},
101
+ "analysis": {},
102
+ "coaching": report_data
103
+ }
104
+
105
+ # Đọc Metadata
106
+ metadata_path = os.path.join(output_dir, "metadata.json")
107
+ if os.path.exists(metadata_path):
108
+ with open(metadata_path, 'r', encoding='utf-8') as f:
109
+ master_data["metadata"] = json.load(f)
110
+
111
+ # Đọc Analysis chi tiết
112
+ report_detail_path = os.path.join(output_dir, "report.json")
113
+ if os.path.exists(report_detail_path):
114
+ with open(report_detail_path, 'r', encoding='utf-8') as f:
115
+ master_data["analysis"] = json.load(f)
116
+
117
+ # Lưu Master JSON
118
+ master_json_path = os.path.join(output_dir, "master_data.json")
119
+ with open(master_json_path, 'w', encoding='utf-8') as f:
120
+ json.dump(master_data, f, indent=4, ensure_ascii=False)
121
+
122
+ # --- DỌN DẸP FILE TRUNG GIAN (CHỈ GIỮ MASTER JSON) ---
123
+ annotated_dir = os.path.join(output_dir, "annotated")
124
+ files_to_delete = [metadata_path, report_detail_path, final_report_path]
125
+
126
+ # Xóa các file JSON trung gian
127
+ for f_path in files_to_delete:
128
+ if os.path.exists(f_path):
129
+ os.remove(f_path)
130
+
131
+ # Xóa thư mục annotated nếu tồn tại
132
+ if os.path.exists(annotated_dir):
133
+ shutil.rmtree(annotated_dir, ignore_errors=True)
134
+
135
+ print(f"\n[MASTER_JSON_CREATED]: {master_json_path}")
136
+
137
+ except Exception as e:
138
+ print(f"Lưu Master JSON hoặc Dọn dẹp thất bại: {e}")
139
+ else:
140
+ print(f"\n[ERROR]: Không tìm thấy FINAL_report.json để tạo Master JSON.")
141
+
142
+ if __name__ == "__main__":
143
+ main()
models/mobilenet_v2.pth.tar ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ecbe2b568c8602549fa9e1d5833c63848f490a48d92e5d224d1eb2063e152cf8
3
+ size 14205652
models/swingnet_1800.pth.tar ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6331e303a9e86d0c19f183899f958bf2a71cf5a7070d46899e25e1ac877b23d4
3
+ size 63280059
reengineer.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import json
3
+ import os
4
+ import sys
5
+ import numpy as np
6
+ from PIL import Image, ImageDraw, ImageFont
7
+ import textwrap
8
+ import argparse
9
+
10
+ # Cấu hình encoding cho stdout để in tiếng Việt
11
+ if sys.stdout.encoding != 'utf-8':
12
+ try:
13
+ sys.stdout.reconfigure(encoding='utf-8')
14
+ except AttributeError:
15
+ import io
16
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
17
+
18
+ POSE_CONNECTIONS = [
19
+ (11, 12), (11, 13), (13, 15), (12, 14), (14, 16), # Vai - Khuỷu - Cổ tay
20
+ (11, 23), (12, 24), (23, 24), # Vai - Hông - Hông
21
+ (23, 25), (24, 26), (25, 27), (26, 28), # Hông - Gối - Cổ chân
22
+ ]
23
+
24
+ def draw_vietnamese_text(img_cv, text, position, font_size=24, color=(255, 255, 255), max_width=None):
25
+ """Vẽ tiếng Việt lên ảnh OpenCV sử dụng Pillow"""
26
+ img_pil = Image.fromarray(cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB))
27
+ draw = ImageDraw.Draw(img_pil)
28
+
29
+ try:
30
+ # Sử dụng font Arial mặc định (Tìm trong thư mục dự án hoặc hệ thống)
31
+ base_dir = os.path.dirname(os.path.abspath(__file__))
32
+ font_path = os.path.join(base_dir, 'font', 'arial.ttf')
33
+ if not os.path.exists(font_path):
34
+ font_path = "C:\\Windows\\Fonts\\arial.ttf"
35
+
36
+ font = ImageFont.truetype(font_path, font_size)
37
+ except:
38
+ font = ImageFont.load_default()
39
+
40
+ if max_width:
41
+ avg_char_width = font_size * 0.5
42
+ chars_per_line = int(max_width / avg_char_width)
43
+ wrapped_lines = textwrap.wrap(text, width=chars_per_line)
44
+ text = "\n".join(wrapped_lines)
45
+
46
+ draw.text(position, text, font=font, fill=color, spacing=6)
47
+ return cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)
48
+
49
+ def draw_glass_panel(img, pt1, pt2, color=(0, 0, 0), alpha=0.5):
50
+ """Vẽ bảng điều khiển bán trong suốt (Glassmorphism effect)"""
51
+ overlay = img.copy()
52
+ cv2.rectangle(overlay, pt1, pt2, color, -1)
53
+ return cv2.addWeighted(overlay, alpha, img, 1 - alpha, 0)
54
+
55
+ def reengineer_video(json_path, video_path, output_path=None):
56
+ """
57
+ Áp dụng dữ liệu JSON lên video gốc (nguyên kích thước) với overlay cao cấp.
58
+ """
59
+ if not os.path.exists(json_path):
60
+ print(f"Lỗi: Không tìm thấy file JSON tại {json_path}")
61
+ return
62
+ if not os.path.exists(video_path):
63
+ print(f"Lỗi: Không tìm thấy file video tại {video_path}")
64
+ return
65
+
66
+ # 1. Đọc Master JSON
67
+ with open(json_path, 'r', encoding='utf-8') as f:
68
+ data = json.load(f)
69
+
70
+ metadata = data.get("metadata", {})
71
+ analysis = data.get("analysis", {})
72
+ event_frames = metadata.get("event_frames", {})
73
+ fps_meta = metadata.get("fps", 30)
74
+
75
+ # 2. Mở video gốc
76
+ cap = cv2.VideoCapture(video_path)
77
+ if not cap.isOpened():
78
+ print("Lỗi: Không thể mở video.")
79
+ return
80
+
81
+ vw = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
82
+ vh = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
83
+ fps_video = cap.get(cv2.CAP_PROP_FPS)
84
+ if fps_video <= 0: fps_video = fps_meta
85
+
86
+ # Xử lý ghi đè (Overwrite logic)
87
+ is_overwrite = False
88
+ final_output_path = output_path
89
+
90
+ if output_path is None:
91
+ # Mặc định ghi đè (overwrite) lên chính video gốc
92
+ final_output_path = video_path
93
+
94
+ # Nếu đường dẫn output trùng với video gốc, cần dùng file tạm
95
+ if os.path.abspath(final_output_path) == os.path.abspath(video_path):
96
+ is_overwrite = True
97
+ temp_output_path = final_output_path + ".tmp.mp4"
98
+ else:
99
+ temp_output_path = final_output_path
100
+
101
+ print(f"--- Đang tạo video Premium: {os.path.basename(video_path)} ({vw}x{vh}) ---")
102
+
103
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
104
+ out = cv2.VideoWriter(temp_output_path, fourcc, fps_video, (vw, vh))
105
+
106
+ idx_to_phase = {int(v): k for k, v in event_frames.items()}
107
+ current_phase = "1_Address"
108
+
109
+ # Tính toán font size động (Tỉ lệ với chiều cao video)
110
+ base_font_size = max(18, int(vh / 25))
111
+ small_font_size = int(base_font_size * 0.7)
112
+ title_font_size = int(base_font_size * 1.2)
113
+ line_spacing = int(base_font_size * 1.5)
114
+ margin = int(vw * 0.05)
115
+
116
+ frame_idx = 0
117
+ while True:
118
+ ret, frame = cap.read()
119
+ if not ret: break
120
+
121
+ # 3. KIỂM TRA EVENT ĐỂ TẠO CÚ KHỰNG (FREEZE-FRAME)
122
+ if frame_idx in idx_to_phase:
123
+ current_phase = idx_to_phase[frame_idx]
124
+
125
+ # Tạo frame đặc biệt có overlay cao cấp
126
+ pause_frame = frame.copy()
127
+
128
+ # --- VẼ SKELETON (MỎNG & TINH TẾ) ---
129
+ phase_data = analysis.get("phases", {}).get(current_phase)
130
+ if phase_data and "raw_landmarks" in phase_data:
131
+ landmarks = phase_data["raw_landmarks"]
132
+ # Bones (Line thickness: 2)
133
+ for start_idx, end_idx in POSE_CONNECTIONS:
134
+ if start_idx < len(landmarks) and end_idx < len(landmarks):
135
+ l1 = landmarks[start_idx]
136
+ l2 = landmarks[end_idx]
137
+ if l1["visibility"] > 0.5 and l2["visibility"] > 0.5:
138
+ p1 = (int(l1["x"] * vw), int(l1["y"] * vh))
139
+ p2 = (int(l2["x"] * vw), int(l2["y"] * vh))
140
+ cv2.line(pause_frame, p1, p2, (0, 255, 157), 2)
141
+
142
+ # Joints (Ultra-sleek dots)
143
+ # 1. Xử lý khuôn mặt: 1 chấm mũi + 1 viền tròn đầu
144
+ nose = landmarks[0]
145
+ ear_l = landmarks[7]
146
+ ear_r = landmarks[8]
147
+ if nose["visibility"] > 0.5:
148
+ p_nose = (int(nose["x"] * vw), int(nose["y"] * vh))
149
+ # Vẽ chấm mũi
150
+ cv2.circle(pause_frame, p_nose, 2, (255, 255, 255), -1)
151
+
152
+ # Vẽ viền tròn đầu (Face outline)
153
+ if ear_l["visibility"] > 0.5 and ear_r["visibility"] > 0.5:
154
+ p_l = np.array([ear_l["x"] * vw, ear_l["y"] * vh])
155
+ p_r = np.array([ear_r["x"] * vw, ear_r["y"] * vh])
156
+ radius = int(np.linalg.norm(p_l - p_r) * 0.8)
157
+ cv2.circle(pause_frame, p_nose, radius, (0, 255, 157), 1)
158
+
159
+ # 2. Xử lý các khớp còn lại (Body)
160
+ for i, lm in enumerate(landmarks):
161
+ if lm["visibility"] > 0.5:
162
+ # Bỏ qua các điểm mặt 0-10 vì đã xử lý riêng
163
+ if i <= 10:
164
+ continue
165
+
166
+ p = (int(lm["x"] * vw), int(lm["y"] * vh))
167
+ # Các khớp thân mình mỏng và nhỏ (Radius: 2)
168
+ cv2.circle(pause_frame, p, 2, (255, 255, 255), -1)
169
+ cv2.circle(pause_frame, p, 3, (0, 255, 157), 1)
170
+
171
+ # --- VẼ BẢNG THÔNG TIN (GLASS OVERLAY - TOP LEFT) ---
172
+ # Chuyển bảng thông tin lên góc trên bên trái
173
+ panel_w = int(vw * 0.45)
174
+ panel_h = int(vh * 0.35)
175
+ panel_margin = 10
176
+
177
+ # Vẽ panel mờ ở góc trên trái
178
+ pause_frame = draw_glass_panel(pause_frame, (panel_margin, panel_margin),
179
+ (panel_margin + panel_w, panel_margin + panel_h), alpha=0.6)
180
+
181
+ phase_display_parts = current_phase.split('_', 1)
182
+ phase_display_name = phase_display_parts[1].upper() if len(phase_display_parts) > 1 else current_phase.upper()
183
+
184
+ # Text nội dung
185
+ text_x = panel_margin + 20
186
+ text_y = panel_margin + 40
187
+
188
+ # Title
189
+ pause_frame = draw_vietnamese_text(pause_frame, f"GIAI ĐOẠN: {phase_display_name}",
190
+ (text_x, text_y), title_font_size, (0, 255, 157))
191
+
192
+ if phase_data:
193
+ score = phase_data.get("score", 0)
194
+ comments = " | ".join(phase_data.get("comments", []))
195
+ # Điểm số
196
+ pause_frame = draw_vietnamese_text(pause_frame, f"ĐIỂM: {score}/10",
197
+ (text_x, text_y + line_spacing), base_font_size, (255, 255, 255))
198
+ # Nhận xét
199
+ pause_frame = draw_vietnamese_text(pause_frame, f"NHẬN XÉT: {comments}",
200
+ (text_x, text_y + line_spacing * 2), small_font_size, (200, 200, 200), max_width=panel_w - 40)
201
+ # Freeze for 2 seconds
202
+ for _ in range(int(fps_video * 2.0)):
203
+ out.write(pause_frame)
204
+
205
+ # 4. GHI FRAME VIDEO BÌNH THƯỜNG
206
+ display_frame = frame.copy()
207
+ phase_display_parts = current_phase.split('_', 1)
208
+ phase_display_name = phase_display_parts[1].upper() if len(phase_display_parts) > 1 else current_phase.upper()
209
+
210
+ # Nhãn nhỏ góc trên
211
+ label_w = int(vw * 0.3)
212
+ display_frame = draw_glass_panel(display_frame, (20, 20), (20 + label_w, 20 + line_spacing), alpha=0.4)
213
+ display_frame = draw_vietnamese_text(display_frame, f"PHA: {phase_display_name}",
214
+ (30, 25), small_font_size, (0, 255, 157))
215
+
216
+ out.write(display_frame)
217
+ frame_idx += 1
218
+
219
+ cap.release()
220
+ out.release()
221
+
222
+ if is_overwrite:
223
+ if os.path.exists(video_path):
224
+ os.remove(video_path)
225
+ os.rename(temp_output_path, final_output_path)
226
+
227
+ print(f"--- HOÀN TẤT! Video lưu tại: {final_output_path} ---")
228
+
229
+ if __name__ == "__main__":
230
+ parser = argparse.ArgumentParser(description="Golf Video Re-engineer Tool")
231
+ parser.add_argument("--json", required=True, help="Đường dẫn file master_data.json")
232
+ parser.add_argument("--video", required=True, help="Đường dẫn video gốc .mp4")
233
+ parser.add_argument("--output", help="Đường dẫn file đầu ra")
234
+
235
+ args = parser.parse_args()
236
+ reengineer_video(args.json, args.video, args.output)
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-multipart
4
+ torch
5
+ torchvision
6
+ opencv-python
7
+ mediapipe==0.10.11
8
+ numpy
9
+ pillow
10
+ tqdm
11
+ jinja2
src/MobileNetV2.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch.nn as nn
2
+ import math
3
+
4
+ """
5
+ https://github.com/tonylins/pytorch-mobilenet-v2
6
+ """
7
+
8
+ def conv_bn(inp, oup, stride):
9
+ return nn.Sequential(
10
+ nn.Conv2d(inp, oup, 3, stride, 1, bias=False),
11
+ nn.BatchNorm2d(oup),
12
+ nn.ReLU6(inplace=True)
13
+ )
14
+
15
+
16
+ def conv_1x1_bn(inp, oup):
17
+ return nn.Sequential(
18
+ nn.Conv2d(inp, oup, 1, 1, 0, bias=False),
19
+ nn.BatchNorm2d(oup),
20
+ nn.ReLU6(inplace=True)
21
+ )
22
+
23
+
24
+ class InvertedResidual(nn.Module):
25
+ def __init__(self, inp, oup, stride, expand_ratio):
26
+ super(InvertedResidual, self).__init__()
27
+ self.stride = stride
28
+ assert stride in [1, 2]
29
+
30
+ hidden_dim = round(inp * expand_ratio)
31
+ self.use_res_connect = self.stride == 1 and inp == oup
32
+
33
+ if expand_ratio == 1:
34
+ self.conv = nn.Sequential(
35
+ # dw
36
+ nn.Conv2d(hidden_dim, hidden_dim, 3, stride, 1, groups=hidden_dim, bias=False),
37
+ nn.BatchNorm2d(hidden_dim),
38
+ nn.ReLU6(inplace=True),
39
+ # pw-linear
40
+ nn.Conv2d(hidden_dim, oup, 1, 1, 0, bias=False),
41
+ nn.BatchNorm2d(oup),
42
+ )
43
+ else:
44
+ self.conv = nn.Sequential(
45
+ # pw
46
+ nn.Conv2d(inp, hidden_dim, 1, 1, 0, bias=False),
47
+ nn.BatchNorm2d(hidden_dim),
48
+ nn.ReLU6(inplace=True),
49
+ # dw
50
+ nn.Conv2d(hidden_dim, hidden_dim, 3, stride, 1, groups=hidden_dim, bias=False),
51
+ nn.BatchNorm2d(hidden_dim),
52
+ nn.ReLU6(inplace=True),
53
+ # pw-linear
54
+ nn.Conv2d(hidden_dim, oup, 1, 1, 0, bias=False),
55
+ nn.BatchNorm2d(oup),
56
+ )
57
+
58
+ def forward(self, x):
59
+ if self.use_res_connect:
60
+ return x + self.conv(x)
61
+ else:
62
+ return self.conv(x)
63
+
64
+
65
+ class MobileNetV2(nn.Module):
66
+ def __init__(self, n_class=1000, input_size=224, width_mult=1.):
67
+ super(MobileNetV2, self).__init__()
68
+ block = InvertedResidual
69
+ min_depth = 16
70
+ input_channel = 32
71
+ last_channel = 1280
72
+ interverted_residual_setting = [
73
+ # t, c, n, s
74
+ [1, 16, 1, 1],
75
+ [6, 24, 2, 2],
76
+ [6, 32, 3, 2],
77
+ [6, 64, 4, 2],
78
+ [6, 96, 3, 1],
79
+ [6, 160, 3, 2],
80
+ [6, 320, 1, 1],
81
+ ]
82
+
83
+ # building first layer
84
+ assert input_size % 32 == 0
85
+ input_channel = int(input_channel * width_mult) if width_mult >= 1.0 else input_channel
86
+ self.last_channel = int(last_channel * width_mult) if width_mult > 1.0 else last_channel
87
+ self.features = [conv_bn(3, input_channel, 2)]
88
+ # building inverted residual blocks
89
+ for t, c, n, s in interverted_residual_setting:
90
+ output_channel = max(int(c * width_mult), min_depth)
91
+ for i in range(n):
92
+ if i == 0:
93
+ self.features.append(block(input_channel, output_channel, s, expand_ratio=t))
94
+ else:
95
+ self.features.append(block(input_channel, output_channel, 1, expand_ratio=t))
96
+ input_channel = output_channel
97
+ # building last several layers
98
+ self.features.append(conv_1x1_bn(input_channel, self.last_channel))
99
+ # make it nn.Sequential
100
+ self.features = nn.Sequential(*self.features)
101
+
102
+ # building classifier
103
+ self.classifier = nn.Sequential(
104
+ nn.Dropout(0.2),
105
+ nn.Linear(self.last_channel, n_class),
106
+ )
107
+
108
+ self._initialize_weights()
109
+
110
+ def forward(self, x):
111
+ x = self.features(x)
112
+ x = x.mean(3).mean(2)
113
+ x = self.classifier(x)
114
+ return x
115
+
116
+ def _initialize_weights(self):
117
+ for m in self.modules():
118
+ if isinstance(m, nn.Conv2d):
119
+ n = m.kernel_size[0] * m.kernel_size[1] * m.out_channels
120
+ m.weight.data.normal_(0, math.sqrt(2. / n))
121
+ if m.bias is not None:
122
+ m.bias.data.zero_()
123
+ elif isinstance(m, nn.BatchNorm2d):
124
+ m.weight.data.fill_(1)
125
+ m.bias.data.zero_()
126
+ elif isinstance(m, nn.Linear):
127
+ n = m.weight.size(1)
128
+ m.weight.data.normal_(0, 0.01)
129
+ m.bias.data.zero_()
src/dataloader.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os.path as osp
2
+ import cv2
3
+ import pandas as pd
4
+ import numpy as np
5
+ import torch
6
+ from torch.utils.data import Dataset, DataLoader
7
+ from torchvision import transforms
8
+
9
+
10
+ class GolfDB(Dataset):
11
+ def __init__(self, data_file, vid_dir, seq_length, transform=None, train=True):
12
+ self.df = pd.read_pickle(data_file)
13
+ self.vid_dir = vid_dir
14
+ self.seq_length = seq_length
15
+ self.transform = transform
16
+ self.train = train
17
+
18
+ def __len__(self):
19
+ return len(self.df)
20
+
21
+ def __getitem__(self, idx):
22
+ a = self.df.loc[idx, :] # annotation info
23
+ events = a['events']
24
+ events -= events[0] # now frame #s correspond to frames in preprocessed video clips
25
+
26
+ images, labels = [], []
27
+ cap = cv2.VideoCapture(osp.join(self.vid_dir, '{}.mp4'.format(a['id'])))
28
+
29
+ if self.train:
30
+ # random starting position, sample 'seq_length' frames
31
+ start_frame = np.random.randint(events[-1] + 1)
32
+ cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
33
+ pos = start_frame
34
+ while len(images) < self.seq_length:
35
+ ret, img = cap.read()
36
+ if ret:
37
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
38
+ images.append(img)
39
+ if pos in events[1:-1]:
40
+ labels.append(np.where(events[1:-1] == pos)[0][0])
41
+ else:
42
+ labels.append(8)
43
+ pos += 1
44
+ else:
45
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
46
+ pos = 0
47
+ cap.release()
48
+ else:
49
+ # full clip
50
+ for pos in range(int(cap.get(cv2.CAP_PROP_FRAME_COUNT))):
51
+ _, img = cap.read()
52
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
53
+ images.append(img)
54
+ if pos in events[1:-1]:
55
+ labels.append(np.where(events[1:-1] == pos)[0][0])
56
+ else:
57
+ labels.append(8)
58
+ cap.release()
59
+
60
+ sample = {'images':np.asarray(images), 'labels':np.asarray(labels)}
61
+ if self.transform:
62
+ sample = self.transform(sample)
63
+ return sample
64
+
65
+
66
+ class ToTensor(object):
67
+ """Convert ndarrays in sample to Tensors."""
68
+ def __call__(self, sample):
69
+ images, labels = sample['images'], sample['labels']
70
+ images = images.transpose((0, 3, 1, 2))
71
+ return {'images': torch.from_numpy(images).float().div(255.),
72
+ 'labels': torch.from_numpy(labels).long()}
73
+
74
+
75
+ class Normalize(object):
76
+ def __init__(self, mean, std):
77
+ self.mean = torch.tensor(mean, dtype=torch.float32)
78
+ self.std = torch.tensor(std, dtype=torch.float32)
79
+
80
+ def __call__(self, sample):
81
+ images, labels = sample['images'], sample['labels']
82
+ images.sub_(self.mean[None, :, None, None]).div_(self.std[None, :, None, None])
83
+ return {'images': images, 'labels': labels}
84
+
85
+
86
+ if __name__ == '__main__':
87
+
88
+ norm = Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) # ImageNet mean and std (RGB)
89
+
90
+ dataset = GolfDB(data_file='data/train_split_1.pkl',
91
+ vid_dir='data/videos_160/',
92
+ seq_length=64,
93
+ transform=transforms.Compose([ToTensor(), norm]),
94
+ train=False)
95
+
96
+ data_loader = DataLoader(dataset, batch_size=1, shuffle=False, num_workers=6, drop_last=False)
97
+
98
+ for i, sample in enumerate(data_loader):
99
+ images, labels = sample['images'], sample['labels']
100
+ events = np.where(labels.squeeze() < 8)[0]
101
+ print('{} events: {}'.format(len(events), events))
102
+
103
+
104
+
105
+
106
+
107
+
108
+
109
+
110
+
111
+
112
+
113
+
src/eval.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from model import EventDetector
2
+ import torch
3
+ from torch.utils.data import DataLoader
4
+ from torchvision import transforms
5
+ from dataloader import GolfDB, ToTensor, Normalize
6
+ import torch.nn.functional as F
7
+ import numpy as np
8
+ from util import correct_preds
9
+
10
+
11
+ def eval(model, split, seq_length, n_cpu, disp):
12
+ dataset = GolfDB(data_file='data/val_split_{}.pkl'.format(split),
13
+ vid_dir='data/videos_160/',
14
+ seq_length=seq_length,
15
+ transform=transforms.Compose([ToTensor(),
16
+ Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])]),
17
+ train=False)
18
+
19
+ data_loader = DataLoader(dataset,
20
+ batch_size=1,
21
+ shuffle=False,
22
+ num_workers=n_cpu,
23
+ drop_last=False)
24
+
25
+ correct = []
26
+
27
+ for i, sample in enumerate(data_loader):
28
+ images, labels = sample['images'], sample['labels']
29
+ # full samples do not fit into GPU memory so evaluate sample in 'seq_length' batches
30
+ batch = 0
31
+ while batch * seq_length < images.shape[1]:
32
+ if (batch + 1) * seq_length > images.shape[1]:
33
+ image_batch = images[:, batch * seq_length:, :, :, :]
34
+ else:
35
+ image_batch = images[:, batch * seq_length:(batch + 1) * seq_length, :, :, :]
36
+ logits = model(image_batch.cuda())
37
+ if batch == 0:
38
+ probs = F.softmax(logits.data, dim=1).cpu().numpy()
39
+ else:
40
+ probs = np.append(probs, F.softmax(logits.data, dim=1).cpu().numpy(), 0)
41
+ batch += 1
42
+ _, _, _, _, c = correct_preds(probs, labels.squeeze())
43
+ if disp:
44
+ print(i, c)
45
+ correct.append(c)
46
+ PCE = np.mean(correct)
47
+ return PCE
48
+
49
+
50
+ if __name__ == '__main__':
51
+
52
+ split = 1
53
+ seq_length = 64
54
+ n_cpu = 6
55
+
56
+ model = EventDetector(pretrain=True,
57
+ width_mult=1.,
58
+ lstm_layers=1,
59
+ lstm_hidden=256,
60
+ bidirectional=True,
61
+ dropout=False)
62
+
63
+ save_dict = torch.load('models/swingnet_1800.pth.tar')
64
+ model.load_state_dict(save_dict['model_state_dict'])
65
+ model.cuda()
66
+ model.eval()
67
+ PCE = eval(model, split, seq_length, n_cpu, True)
68
+ print('Average PCE: {}'.format(PCE))
69
+
70
+
src/model.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ from torch.autograd import Variable
4
+ from MobileNetV2 import MobileNetV2
5
+ import os
6
+
7
+ class EventDetector(nn.Module):
8
+ def __init__(self, pretrain, width_mult, lstm_layers, lstm_hidden, bidirectional=True, dropout=True):
9
+ super(EventDetector, self).__init__()
10
+ self.width_mult = width_mult
11
+ self.lstm_layers = lstm_layers
12
+ self.lstm_hidden = lstm_hidden
13
+ self.bidirectional = bidirectional
14
+ self.dropout = dropout
15
+
16
+ net = MobileNetV2(width_mult=width_mult)
17
+
18
+ # Get the directory of the current script (src/)
19
+ current_script_directory = os.path.dirname(os.path.abspath(__file__))
20
+ # Path to models folder (outside src/)
21
+ relative_directory = '../models/mobilenet_v2.pth.tar'
22
+
23
+ # Construct the absolute path
24
+ model_dir = os.path.normpath(os.path.join(current_script_directory, relative_directory))
25
+
26
+ # Check cuda availability
27
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
28
+
29
+ # Ensure loading model with GPU or CPU
30
+ state_dict_mobilenet = torch.load(model_dir,map_location=device)
31
+
32
+ if pretrain:
33
+ net.load_state_dict(state_dict_mobilenet)
34
+
35
+ self.cnn = nn.Sequential(*list(net.children())[0][:19])
36
+ self.rnn = nn.LSTM(int(1280*width_mult if width_mult > 1.0 else 1280),
37
+ self.lstm_hidden, self.lstm_layers,
38
+ batch_first=True, bidirectional=bidirectional)
39
+ if self.bidirectional:
40
+ self.lin = nn.Linear(2*self.lstm_hidden, 9)
41
+ else:
42
+ self.lin = nn.Linear(self.lstm_hidden, 9)
43
+ if self.dropout:
44
+ self.drop = nn.Dropout(0.5)
45
+
46
+ def init_hidden(self, batch_size, device):
47
+ if self.bidirectional:
48
+ return (Variable(torch.zeros(2*self.lstm_layers, batch_size, self.lstm_hidden).to(device), requires_grad=True),
49
+ Variable(torch.zeros(2*self.lstm_layers, batch_size, self.lstm_hidden).to(device), requires_grad=True))
50
+ else:
51
+ return (Variable(torch.zeros(self.lstm_layers, batch_size, self.lstm_hidden).to(device), requires_grad=True),
52
+ Variable(torch.zeros(self.lstm_layers, batch_size, self.lstm_hidden).to(device), requires_grad=True))
53
+
54
+ def forward(self, x, lengths=None):
55
+ batch_size, timesteps, C, H, W = x.size()
56
+ self.hidden = self.init_hidden(batch_size, x.device)
57
+
58
+ # CNN forward
59
+ c_in = x.view(batch_size * timesteps, C, H, W)
60
+ c_out = self.cnn(c_in)
61
+ c_out = c_out.mean(3).mean(2)
62
+ if self.dropout:
63
+ c_out = self.drop(c_out)
64
+
65
+ # LSTM forward
66
+ r_in = c_out.view(batch_size, timesteps, -1)
67
+ r_out, states = self.rnn(r_in, self.hidden)
68
+ out = self.lin(r_out)
69
+ out = out.view(batch_size*timesteps,9)
70
+
71
+ return out
72
+
73
+
74
+
src/util.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+
4
+ class AverageMeter(object):
5
+ """Computes and stores the average and current value"""
6
+ def __init__(self):
7
+ self.reset()
8
+
9
+ def reset(self):
10
+ self.val = 0
11
+ self.avg = 0
12
+ self.sum = 0
13
+ self.count = 0
14
+
15
+ def update(self, val, n=1):
16
+ self.val = val
17
+ self.sum += val * n
18
+ self.count += n
19
+ self.avg = self.sum / self.count
20
+
21
+
22
+ def correct_preds(probs, labels, tol=-1):
23
+ """
24
+ Gets correct events in full-length sequence using tolerance based on number of frames from address to impact.
25
+ Used during validation only.
26
+ :param probs: (sequence_length, 9)
27
+ :param labels: (sequence_length,)
28
+ :return: array indicating correct events in predicted sequence (8,)
29
+ """
30
+
31
+ events = np.where(labels < 8)[0]
32
+ preds = np.zeros(len(events))
33
+ if tol == -1:
34
+ tol = int(max(np.round((events[5] - events[0])/30), 1))
35
+ for i in range(len(events)):
36
+ preds[i] = np.argsort(probs[:, i])[-1]
37
+ deltas = np.abs(events-preds)
38
+ correct = (deltas <= tol).astype(np.uint8)
39
+ return events, preds, deltas, tol, correct
40
+
41
+
42
+ def freeze_layers(num_freeze, net):
43
+ # print("Freezing {:2d} layers".format(num_freeze))
44
+ i = 1
45
+ for child in net.children():
46
+ if i ==1:
47
+ j = 1
48
+ for child_child in child.children():
49
+ if j <= num_freeze:
50
+ for param in child_child.parameters():
51
+ param.requires_grad = False
52
+ j += 1
53
+ i += 1
templates/index.html ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Golf AI Analysis - Premium Experience</title>
7
+ <link
8
+ href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap"
9
+ rel="stylesheet"
10
+ />
11
+ <style>
12
+ :root {
13
+ --primary: #00ff9d;
14
+ --primary-dark: #00cc7d;
15
+ --bg: #050505;
16
+ --glass: rgba(255, 255, 255, 0.05);
17
+ --glass-border: rgba(255, 255, 255, 0.1);
18
+ }
19
+
20
+ * {
21
+ margin: 0;
22
+ padding: 0;
23
+ box-sizing: border-box;
24
+ font-family: "Plus Jakarta Sans", sans-serif;
25
+ }
26
+
27
+ body {
28
+ background: var(--bg);
29
+ color: #fff;
30
+ min-height: 100vh;
31
+ display: flex;
32
+ justify-content: center;
33
+ align-items: center;
34
+ overflow: hidden;
35
+ }
36
+
37
+ /* Abstract Background */
38
+ .bg-glow {
39
+ position: fixed;
40
+ top: 50%;
41
+ left: 50%;
42
+ transform: translate(-50%, -50%);
43
+ width: 800px;
44
+ height: 800px;
45
+ background: radial-gradient(
46
+ circle,
47
+ rgba(0, 255, 157, 0.1) 0%,
48
+ transparent 70%
49
+ );
50
+ z-index: -1;
51
+ filter: blur(100px);
52
+ }
53
+
54
+ .container {
55
+ width: 100%;
56
+ max-width: 500px;
57
+ padding: 2rem;
58
+ z-index: 1;
59
+ }
60
+
61
+ .card {
62
+ background: var(--glass);
63
+ backdrop-filter: blur(20px);
64
+ border: 1px solid var(--glass-border);
65
+ border-radius: 24px;
66
+ padding: 3rem 2rem;
67
+ text-align: center;
68
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
69
+ }
70
+
71
+ h1 {
72
+ font-size: 2rem;
73
+ font-weight: 700;
74
+ margin-bottom: 0.5rem;
75
+ background: linear-gradient(to right, #fff, var(--primary));
76
+ -webkit-background-clip: text;
77
+ -webkit-text-fill-color: transparent;
78
+ }
79
+
80
+ p {
81
+ color: #888;
82
+ margin-bottom: 2.5rem;
83
+ font-size: 0.95rem;
84
+ }
85
+
86
+ .upload-area {
87
+ border: 2px dashed var(--glass-border);
88
+ border-radius: 20px;
89
+ padding: 3rem 1rem;
90
+ cursor: pointer;
91
+ transition: all 0.3s ease;
92
+ position: relative;
93
+ }
94
+
95
+ .upload-area:hover,
96
+ .upload-area.dragover {
97
+ border-color: var(--primary);
98
+ background: rgba(0, 255, 157, 0.02);
99
+ transform: translateY(-2px);
100
+ }
101
+
102
+ .upload-icon {
103
+ font-size: 3rem;
104
+ margin-bottom: 1rem;
105
+ display: block;
106
+ }
107
+
108
+ .upload-text {
109
+ font-weight: 500;
110
+ color: #ccc;
111
+ }
112
+
113
+ #file-input {
114
+ display: none;
115
+ }
116
+
117
+ .status-container {
118
+ display: none;
119
+ margin-top: 2rem;
120
+ }
121
+
122
+ .progress-bar-bg {
123
+ width: 100%;
124
+ height: 6px;
125
+ background: var(--glass-border);
126
+ border-radius: 10px;
127
+ overflow: hidden;
128
+ margin-bottom: 1rem;
129
+ }
130
+
131
+ .progress-bar-fill {
132
+ width: 0%;
133
+ height: 100%;
134
+ background: var(--primary);
135
+ box-shadow: 0 0 15px var(--primary);
136
+ transition: width 0.3s ease;
137
+ }
138
+
139
+ .status-text {
140
+ font-size: 0.85rem;
141
+ color: var(--primary);
142
+ font-weight: 600;
143
+ text-transform: uppercase;
144
+ letter-spacing: 1px;
145
+ }
146
+
147
+ .result-btn {
148
+ display: none;
149
+ width: 100%;
150
+ padding: 1rem;
151
+ border-radius: 12px;
152
+ border: none;
153
+ background: var(--primary);
154
+ color: #000;
155
+ font-weight: 700;
156
+ cursor: pointer;
157
+ margin-top: 1.5rem;
158
+ transition: all 0.3s ease;
159
+ }
160
+
161
+ .result-btn:hover {
162
+ background: var(--primary-dark);
163
+ transform: translateY(-2px);
164
+ box-shadow: 0 10px 20px rgba(0, 255, 157, 0.2);
165
+ }
166
+
167
+ /* Animations */
168
+ @keyframes pulse {
169
+ 0% {
170
+ opacity: 1;
171
+ }
172
+ 50% {
173
+ opacity: 0.5;
174
+ }
175
+ 100% {
176
+ opacity: 1;
177
+ }
178
+ }
179
+
180
+ .analyzing {
181
+ animation: pulse 1.5s infinite ease-in-out;
182
+ }
183
+ </style>
184
+ </head>
185
+ <body>
186
+ <div class="bg-glow"></div>
187
+ <div class="container">
188
+ <div class="card">
189
+ <h1>Golf AI Analysis</h1>
190
+ <p>Tải video swing để nhận phản hồi chuyên sâu</p>
191
+
192
+ <div class="upload-area" id="drop-zone">
193
+ <span class="upload-icon">🚀</span>
194
+ <span class="upload-text">Kéo thả video hoặc nhấn để chọn</span>
195
+ <input
196
+ type="file"
197
+ id="file-input"
198
+ accept="video/mp4,video/quicktime"
199
+ />
200
+ </div>
201
+
202
+ <div class="status-container" id="status-container">
203
+ <div class="progress-bar-bg">
204
+ <div class="progress-bar-fill" id="progress-fill"></div>
205
+ </div>
206
+ <div class="status-text" id="status-text">Đang chuẩn bị...</div>
207
+ </div>
208
+
209
+ <!-- Simplified Success View -->
210
+ <div id="success-view" style="display: none; margin-top: 2rem; text-align: center;">
211
+ <div style="font-size: 4rem; margin-bottom: 1rem;">✅</div>
212
+ <h2 style="color: var(--primary); margin-bottom: 0.5rem;">PHÂN TÍCH HOÀN TẤT</h2>
213
+ <p style="margin-bottom: 1.5rem;">File <strong>master_data.json</strong> đã được tải xuống tự động.</p>
214
+ <button class="result-btn" onclick="location.reload()" style="display: block;">TẢI VIDEO MỚI</button>
215
+ </div>
216
+ </div>
217
+ </div>
218
+
219
+ <script>
220
+ const dropZone = document.getElementById("drop-zone");
221
+ const fileInput = document.getElementById("file-input");
222
+ const statusContainer = document.getElementById("status-container");
223
+ const progressFill = document.getElementById("progress-fill");
224
+ const statusText = document.getElementById("status-text");
225
+ const successView = document.getElementById("success-view");
226
+
227
+ dropZone.onclick = () => fileInput.click();
228
+
229
+ dropZone.ondragover = (e) => { e.preventDefault(); dropZone.classList.add("dragover"); };
230
+ dropZone.ondragleave = () => { dropZone.classList.remove("dragover"); };
231
+ dropZone.ondrop = (e) => {
232
+ e.preventDefault();
233
+ dropZone.classList.remove("dragover");
234
+ if (e.dataTransfer.files.length) handleUpload(e.dataTransfer.files[0]);
235
+ };
236
+
237
+ fileInput.onchange = () => {
238
+ if (fileInput.files.length) handleUpload(fileInput.files[0]);
239
+ };
240
+
241
+ async function handleUpload(file) {
242
+ dropZone.style.display = "none";
243
+ statusContainer.style.display = "block";
244
+ progressFill.style.width = "10%";
245
+ statusText.innerText = "Đang tải video lên...";
246
+
247
+ const formData = new FormData();
248
+ formData.append("file", file);
249
+
250
+ try {
251
+ let progress = 10;
252
+ const interval = setInterval(() => {
253
+ if (progress < 95) {
254
+ progress += 2;
255
+ progressFill.style.width = `${progress}%`;
256
+ if (progress > 30) statusText.innerText = "Đang chạy SwingNet AI...";
257
+ if (progress > 70) statusText.innerText = "Đang tổng hợp Master JSON...";
258
+ }
259
+ }, 500);
260
+
261
+ const response = await fetch("/", { method: "POST", body: formData });
262
+ clearInterval(interval);
263
+
264
+ if (!response.ok) throw new Error("Phân tích thất bại");
265
+
266
+ // Xử lý JSON Response trực tiếp từ server (Stateless)
267
+ const resultData = await response.json();
268
+
269
+ // Trigger download file JSON từ dữ liệu trong bộ nhớ
270
+ const jsonString = JSON.stringify(resultData, null, 4);
271
+ const blob = new Blob([jsonString], { type: 'application/json' });
272
+ const url = window.URL.createObjectURL(blob);
273
+ const a = document.createElement('a');
274
+ a.style.display = 'none';
275
+ a.href = url;
276
+ a.download = 'master_data.json';
277
+ document.body.appendChild(a);
278
+ a.click();
279
+ window.URL.revokeObjectURL(url);
280
+ document.body.removeChild(a);
281
+
282
+ // Hiển thị giao diện thành công
283
+ showSuccess();
284
+
285
+ } catch (error) {
286
+ statusText.innerText = "LỖI: " + error.message;
287
+ statusText.style.color = "#ff4d4d";
288
+ }
289
+ }
290
+
291
+ function showSuccess() {
292
+ progressFill.style.width = '100%';
293
+ statusText.innerText = 'THÀNH CÔNG!';
294
+ statusText.style.color = '#00ff9d';
295
+
296
+ setTimeout(() => {
297
+ statusContainer.style.display = "none";
298
+ successView.style.display = "block";
299
+ }, 800);
300
+ }
301
+ </script>
302
+ </body>
303
+ </html>