Spaces:
Sleeping
Sleeping
htrnguyen commited on
Commit ·
d674fad
0
Parent(s):
Fresh deployment with Git LFS
Browse files- .gitattributes +1 -0
- .gitignore +62 -0
- Dockerfile +28 -0
- README.md +32 -0
- analyze.py +224 -0
- api_server.py +86 -0
- coach.py +109 -0
- extract.py +211 -0
- main.py +143 -0
- models/mobilenet_v2.pth.tar +3 -0
- models/swingnet_1800.pth.tar +3 -0
- reengineer.py +236 -0
- requirements.txt +11 -0
- src/MobileNetV2.py +129 -0
- src/dataloader.py +113 -0
- src/eval.py +70 -0
- src/model.py +74 -0
- src/util.py +53 -0
- templates/index.html +303 -0
.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>
|