Spaces:
Runtime error
Runtime error
Add application file
Browse files- .docker-compose.yml +30 -0
- .dockerignore +90 -0
- Dockerfile +73 -0
- app.py +348 -0
- requirements.txt +7 -0
.docker-compose.yml
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
squat-analyzer:
|
| 5 |
+
build:
|
| 6 |
+
context: .
|
| 7 |
+
dockerfile: Dockerfile
|
| 8 |
+
ports:
|
| 9 |
+
- "7860:7860"
|
| 10 |
+
volumes:
|
| 11 |
+
# Mount temporary directory for video processing
|
| 12 |
+
- /tmp/squat-analyzer:/tmp/gradio
|
| 13 |
+
environment:
|
| 14 |
+
- GRADIO_SERVER_NAME=0.0.0.0
|
| 15 |
+
- GRADIO_SERVER_PORT=7860
|
| 16 |
+
healthcheck:
|
| 17 |
+
test: ["CMD", "curl", "-f", "http://localhost:7860/"]
|
| 18 |
+
interval: 30s
|
| 19 |
+
timeout: 10s
|
| 20 |
+
retries: 3
|
| 21 |
+
start_period: 30s
|
| 22 |
+
restart: unless-stopped
|
| 23 |
+
deploy:
|
| 24 |
+
resources:
|
| 25 |
+
limits:
|
| 26 |
+
memory: 2G
|
| 27 |
+
cpus: '1.0'
|
| 28 |
+
reservations:
|
| 29 |
+
memory: 1G
|
| 30 |
+
cpus: '0.5'
|
.dockerignore
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
share/python-wheels/
|
| 20 |
+
*.egg-info/
|
| 21 |
+
.installed.cfg
|
| 22 |
+
*.egg
|
| 23 |
+
MANIFEST
|
| 24 |
+
|
| 25 |
+
# Virtual environments
|
| 26 |
+
.env
|
| 27 |
+
.venv
|
| 28 |
+
env/
|
| 29 |
+
venv/
|
| 30 |
+
ENV/
|
| 31 |
+
env.bak/
|
| 32 |
+
venv.bak/
|
| 33 |
+
|
| 34 |
+
# IDE
|
| 35 |
+
.vscode/
|
| 36 |
+
.idea/
|
| 37 |
+
*.swp
|
| 38 |
+
*.swo
|
| 39 |
+
*~
|
| 40 |
+
|
| 41 |
+
# OS
|
| 42 |
+
.DS_Store
|
| 43 |
+
.DS_Store?
|
| 44 |
+
._*
|
| 45 |
+
.Spotlight-V100
|
| 46 |
+
.Trashes
|
| 47 |
+
ehthumbs.db
|
| 48 |
+
Thumbs.db
|
| 49 |
+
|
| 50 |
+
# Git
|
| 51 |
+
.git/
|
| 52 |
+
.gitignore
|
| 53 |
+
.gitattributes
|
| 54 |
+
|
| 55 |
+
# Documentation
|
| 56 |
+
README.md
|
| 57 |
+
*.md
|
| 58 |
+
docs/
|
| 59 |
+
|
| 60 |
+
# Test files
|
| 61 |
+
tests/
|
| 62 |
+
test_*.py
|
| 63 |
+
*_test.py
|
| 64 |
+
|
| 65 |
+
# Jupyter notebooks
|
| 66 |
+
*.ipynb
|
| 67 |
+
.ipynb_checkpoints/
|
| 68 |
+
|
| 69 |
+
# Temporary files
|
| 70 |
+
tmp/
|
| 71 |
+
temp/
|
| 72 |
+
*.tmp
|
| 73 |
+
*.temp
|
| 74 |
+
|
| 75 |
+
# Logs
|
| 76 |
+
*.log
|
| 77 |
+
logs/
|
| 78 |
+
|
| 79 |
+
# Media files (examples)
|
| 80 |
+
*.mp4
|
| 81 |
+
*.avi
|
| 82 |
+
*.mov
|
| 83 |
+
*.mkv
|
| 84 |
+
*.wmv
|
| 85 |
+
*.flv
|
| 86 |
+
*.webm
|
| 87 |
+
|
| 88 |
+
# Cache
|
| 89 |
+
.cache/
|
| 90 |
+
*.cache
|
Dockerfile
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.9-slim
|
| 2 |
+
|
| 3 |
+
# Create non-root user for security
|
| 4 |
+
RUN useradd -m -u 1000 user
|
| 5 |
+
|
| 6 |
+
# Install system dependencies needed for OpenCV and MediaPipe
|
| 7 |
+
RUN apt-get update && apt-get install -y \
|
| 8 |
+
libglib2.0-0 \
|
| 9 |
+
libsm6 \
|
| 10 |
+
libxext6 \
|
| 11 |
+
libxrender-dev \
|
| 12 |
+
libgomp1 \
|
| 13 |
+
libgstreamer1.0-0 \
|
| 14 |
+
libgstreamer-plugins-base1.0-0 \
|
| 15 |
+
libgstreamer-plugins-good1.0-0 \
|
| 16 |
+
libgstreamer-plugins-bad1.0-0 \
|
| 17 |
+
gstreamer1.0-plugins-ugly \
|
| 18 |
+
gstreamer1.0-tools \
|
| 19 |
+
ffmpeg \
|
| 20 |
+
libgl1-mesa-glx \
|
| 21 |
+
libglib2.0-0 \
|
| 22 |
+
wget \
|
| 23 |
+
curl \
|
| 24 |
+
&& apt-get clean \
|
| 25 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 26 |
+
|
| 27 |
+
# Set working directory
|
| 28 |
+
WORKDIR /app
|
| 29 |
+
|
| 30 |
+
# Create necessary directories with proper permissions
|
| 31 |
+
RUN mkdir -p /tmp/matplotlib \
|
| 32 |
+
&& mkdir -p /tmp/mediapipe \
|
| 33 |
+
&& mkdir -p /tmp/gradio \
|
| 34 |
+
&& mkdir -p /home/user/.cache/matplotlib \
|
| 35 |
+
&& mkdir -p /home/user/.cache/pip \
|
| 36 |
+
&& mkdir -p /home/user/.local/lib/python3.9/site-packages \
|
| 37 |
+
&& chown -R user:user /app \
|
| 38 |
+
&& chown -R user:user /tmp \
|
| 39 |
+
&& chown -R user:user /home/user
|
| 40 |
+
|
| 41 |
+
# Set environment variables for headless operation
|
| 42 |
+
ENV OPENCV_IO_ENABLE_JASPER=1
|
| 43 |
+
ENV OPENCV_IO_ENABLE_OPENEXR=1
|
| 44 |
+
ENV MPLCONFIGDIR=/home/user/.cache/matplotlib
|
| 45 |
+
ENV MEDIAPIPE_CACHE_DIR=/tmp/mediapipe
|
| 46 |
+
ENV GRADIO_TEMP_DIR=/tmp/gradio
|
| 47 |
+
ENV HOME=/home/user
|
| 48 |
+
ENV PATH="/home/user/.local/bin:${PATH}"
|
| 49 |
+
ENV PYTHONPATH="${PYTHONPATH}:/home/user/.local/lib/python3.9/site-packages"
|
| 50 |
+
|
| 51 |
+
# Switch to non-root user
|
| 52 |
+
USER user
|
| 53 |
+
|
| 54 |
+
# Copy and install Python dependencies
|
| 55 |
+
COPY --chown=user:user requirements.txt .
|
| 56 |
+
RUN pip install --no-cache-dir --user --upgrade pip
|
| 57 |
+
RUN pip install --no-cache-dir --user -r requirements.txt
|
| 58 |
+
|
| 59 |
+
# Pre-download MediaPipe models to avoid runtime downloads
|
| 60 |
+
RUN python -c "import mediapipe as mp; mp.solutions.pose.Pose()"
|
| 61 |
+
|
| 62 |
+
# Copy application code
|
| 63 |
+
COPY --chown=user:user app.py .
|
| 64 |
+
|
| 65 |
+
# Expose the port that Gradio uses
|
| 66 |
+
EXPOSE 7860
|
| 67 |
+
|
| 68 |
+
# Health check to ensure the service is running
|
| 69 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
| 70 |
+
CMD curl -f http://localhost:7860/ || exit 1
|
| 71 |
+
|
| 72 |
+
# Run the application with proper Gradio server settings for Hugging Face
|
| 73 |
+
CMD ["python", "app.py"]
|
app.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import cv2
|
| 3 |
+
import numpy as np
|
| 4 |
+
import mediapipe as mp
|
| 5 |
+
import tempfile
|
| 6 |
+
import os
|
| 7 |
+
from typing import List, Tuple, Dict
|
| 8 |
+
import math
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class SquatFormAnalyzer:
|
| 13 |
+
def __init__(self):
|
| 14 |
+
self.mp_pose = mp.solutions.pose
|
| 15 |
+
self.pose = self.mp_pose.Pose(
|
| 16 |
+
static_image_mode=False,
|
| 17 |
+
model_complexity=2,
|
| 18 |
+
enable_segmentation=False,
|
| 19 |
+
min_detection_confidence=0.5,
|
| 20 |
+
min_tracking_confidence=0.5
|
| 21 |
+
)
|
| 22 |
+
self.mp_drawing = mp.solutions.drawing_utils
|
| 23 |
+
|
| 24 |
+
def calculate_angle(self, a, b, c):
|
| 25 |
+
"""Calculate angle between three points"""
|
| 26 |
+
a = np.array(a)
|
| 27 |
+
b = np.array(b)
|
| 28 |
+
c = np.array(c)
|
| 29 |
+
|
| 30 |
+
radians = np.arctan2(c[1] - b[1], c[0] - b[0]) - np.arctan2(a[1] - b[1], a[0] - b[0])
|
| 31 |
+
angle = np.abs(radians * 180.0 / np.pi)
|
| 32 |
+
|
| 33 |
+
if angle > 180.0:
|
| 34 |
+
angle = 360 - angle
|
| 35 |
+
|
| 36 |
+
return angle
|
| 37 |
+
|
| 38 |
+
def analyze_knee_alignment(self, landmarks, frame_width, frame_height):
|
| 39 |
+
"""Check for knees caving inward"""
|
| 40 |
+
try:
|
| 41 |
+
# Get hip, knee, and ankle landmarks
|
| 42 |
+
left_hip = [landmarks[self.mp_pose.PoseLandmark.LEFT_HIP.value].x * frame_width,
|
| 43 |
+
landmarks[self.mp_pose.PoseLandmark.LEFT_HIP.value].y * frame_height]
|
| 44 |
+
right_hip = [landmarks[self.mp_pose.PoseLandmark.RIGHT_HIP.value].x * frame_width,
|
| 45 |
+
landmarks[self.mp_pose.PoseLandmark.RIGHT_HIP.value].y * frame_height]
|
| 46 |
+
left_knee = [landmarks[self.mp_pose.PoseLandmark.LEFT_KNEE.value].x * frame_width,
|
| 47 |
+
landmarks[self.mp_pose.PoseLandmark.LEFT_KNEE.value].y * frame_height]
|
| 48 |
+
right_knee = [landmarks[self.mp_pose.PoseLandmark.RIGHT_KNEE.value].x * frame_width,
|
| 49 |
+
landmarks[self.mp_pose.PoseLandmark.RIGHT_KNEE.value].y * frame_height]
|
| 50 |
+
left_ankle = [landmarks[self.mp_pose.PoseLandmark.LEFT_ANKLE.value].x * frame_width,
|
| 51 |
+
landmarks[self.mp_pose.PoseLandmark.LEFT_ANKLE.value].y * frame_height]
|
| 52 |
+
right_ankle = [landmarks[self.mp_pose.PoseLandmark.RIGHT_ANKLE.value].x * frame_width,
|
| 53 |
+
landmarks[self.mp_pose.PoseLandmark.RIGHT_ANKLE.value].y * frame_height]
|
| 54 |
+
|
| 55 |
+
# Calculate knee angles relative to hip-ankle line
|
| 56 |
+
left_knee_angle = self.calculate_angle(left_hip, left_knee, left_ankle)
|
| 57 |
+
right_knee_angle = self.calculate_angle(right_hip, right_knee, right_ankle)
|
| 58 |
+
|
| 59 |
+
# Also check knee width ratio
|
| 60 |
+
hip_width = abs(left_hip[0] - right_hip[0])
|
| 61 |
+
knee_width = abs(left_knee[0] - right_knee[0])
|
| 62 |
+
ankle_width = abs(left_ankle[0] - right_ankle[0])
|
| 63 |
+
|
| 64 |
+
# Multiple criteria for knee valgus
|
| 65 |
+
knee_ratio = knee_width / hip_width if hip_width > 0 else 1
|
| 66 |
+
ankle_ratio = knee_width / ankle_width if ankle_width > 0 else 1
|
| 67 |
+
|
| 68 |
+
# Knees cave in if they're too close relative to hips and ankles
|
| 69 |
+
knee_valgus = (knee_ratio < 0.75 or ankle_ratio < 0.9 or
|
| 70 |
+
left_knee_angle < 160 or right_knee_angle < 160)
|
| 71 |
+
|
| 72 |
+
return knee_valgus
|
| 73 |
+
|
| 74 |
+
except (AttributeError, IndexError, ZeroDivisionError):
|
| 75 |
+
return False
|
| 76 |
+
|
| 77 |
+
def analyze_forward_lean(self, landmarks):
|
| 78 |
+
"""Check for excessive forward lean"""
|
| 79 |
+
try:
|
| 80 |
+
# Get shoulder, hip, and ankle landmarks
|
| 81 |
+
left_shoulder = [landmarks[self.mp_pose.PoseLandmark.LEFT_SHOULDER.value].x,
|
| 82 |
+
landmarks[self.mp_pose.PoseLandmark.LEFT_SHOULDER.value].y]
|
| 83 |
+
right_shoulder = [landmarks[self.mp_pose.PoseLandmark.RIGHT_SHOULDER.value].x,
|
| 84 |
+
landmarks[self.mp_pose.PoseLandmark.RIGHT_SHOULDER.value].y]
|
| 85 |
+
left_hip = [landmarks[self.mp_pose.PoseLandmark.LEFT_HIP.value].x,
|
| 86 |
+
landmarks[self.mp_pose.PoseLandmark.LEFT_HIP.value].y]
|
| 87 |
+
right_hip = [landmarks[self.mp_pose.PoseLandmark.RIGHT_HIP.value].x,
|
| 88 |
+
landmarks[self.mp_pose.PoseLandmark.RIGHT_HIP.value].y]
|
| 89 |
+
|
| 90 |
+
# Calculate average positions
|
| 91 |
+
avg_shoulder = [(left_shoulder[0] + right_shoulder[0]) / 2,
|
| 92 |
+
(left_shoulder[1] + right_shoulder[1]) / 2]
|
| 93 |
+
avg_hip = [(left_hip[0] + right_hip[0]) / 2,
|
| 94 |
+
(left_hip[1] + right_hip[1]) / 2]
|
| 95 |
+
|
| 96 |
+
# Calculate forward lean angle
|
| 97 |
+
horizontal_distance = abs(avg_shoulder[0] - avg_hip[0])
|
| 98 |
+
vertical_distance = abs(avg_shoulder[1] - avg_hip[1])
|
| 99 |
+
|
| 100 |
+
if vertical_distance > 0:
|
| 101 |
+
lean_angle = math.degrees(math.atan(horizontal_distance / vertical_distance))
|
| 102 |
+
return lean_angle > 15 # More than 15 degrees forward lean
|
| 103 |
+
|
| 104 |
+
return False
|
| 105 |
+
|
| 106 |
+
except (AttributeError, IndexError, ZeroDivisionError):
|
| 107 |
+
return False
|
| 108 |
+
|
| 109 |
+
def analyze_depth(self, landmarks):
|
| 110 |
+
"""Check if squat depth is adequate"""
|
| 111 |
+
try:
|
| 112 |
+
# Get hip, knee, and ankle landmarks
|
| 113 |
+
left_hip = [landmarks[self.mp_pose.PoseLandmark.LEFT_HIP.value].x,
|
| 114 |
+
landmarks[self.mp_pose.PoseLandmark.LEFT_HIP.value].y]
|
| 115 |
+
right_hip = [landmarks[self.mp_pose.PoseLandmark.RIGHT_HIP.value].x,
|
| 116 |
+
landmarks[self.mp_pose.PoseLandmark.RIGHT_HIP.value].y]
|
| 117 |
+
left_knee = [landmarks[self.mp_pose.PoseLandmark.LEFT_KNEE.value].x,
|
| 118 |
+
landmarks[self.mp_pose.PoseLandmark.LEFT_KNEE.value].y]
|
| 119 |
+
right_knee = [landmarks[self.mp_pose.PoseLandmark.RIGHT_KNEE.value].x,
|
| 120 |
+
landmarks[self.mp_pose.PoseLandmark.RIGHT_KNEE.value].y]
|
| 121 |
+
|
| 122 |
+
# Calculate average positions
|
| 123 |
+
avg_hip = [(left_hip[0] + right_hip[0]) / 2, (left_hip[1] + right_hip[1]) / 2]
|
| 124 |
+
avg_knee = [(left_knee[0] + right_knee[0]) / 2, (left_knee[1] + right_knee[1]) / 2]
|
| 125 |
+
|
| 126 |
+
# Check if hips drop below knees (good depth)
|
| 127 |
+
return avg_hip[1] > avg_knee[1] # In image coordinates, y increases downward
|
| 128 |
+
|
| 129 |
+
except (AttributeError, IndexError):
|
| 130 |
+
return False
|
| 131 |
+
|
| 132 |
+
def process_video(self, video_path):
|
| 133 |
+
"""Process video and analyze squat form"""
|
| 134 |
+
cap = cv2.VideoCapture(video_path)
|
| 135 |
+
|
| 136 |
+
# Get video properties
|
| 137 |
+
fps = int(cap.get(cv2.CAP_PROP_FPS))
|
| 138 |
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 139 |
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 140 |
+
|
| 141 |
+
# Create output video writer
|
| 142 |
+
output_path = tempfile.mktemp(suffix='.mp4')
|
| 143 |
+
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
| 144 |
+
out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
|
| 145 |
+
|
| 146 |
+
frame_count = 0
|
| 147 |
+
analysis_results = {
|
| 148 |
+
'knee_inward': [],
|
| 149 |
+
'forward_lean': [],
|
| 150 |
+
'depth_issues': [],
|
| 151 |
+
'total_frames': 0
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
while cap.isOpened():
|
| 155 |
+
ret, frame = cap.read()
|
| 156 |
+
if not ret:
|
| 157 |
+
break
|
| 158 |
+
|
| 159 |
+
frame_count += 1
|
| 160 |
+
|
| 161 |
+
# Convert BGR to RGB
|
| 162 |
+
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
| 163 |
+
|
| 164 |
+
# Process frame with MediaPipe
|
| 165 |
+
results = self.pose.process(rgb_frame)
|
| 166 |
+
|
| 167 |
+
# Initialize error flags
|
| 168 |
+
knee_error = False
|
| 169 |
+
lean_error = False
|
| 170 |
+
depth_ok = True
|
| 171 |
+
|
| 172 |
+
if results.pose_landmarks:
|
| 173 |
+
# Draw pose landmarks
|
| 174 |
+
annotated_frame = frame.copy()
|
| 175 |
+
self.mp_drawing.draw_landmarks(
|
| 176 |
+
annotated_frame, results.pose_landmarks, self.mp_pose.POSE_CONNECTIONS)
|
| 177 |
+
|
| 178 |
+
# Analyze form
|
| 179 |
+
knee_error = self.analyze_knee_alignment(results.pose_landmarks.landmark, width, height)
|
| 180 |
+
lean_error = self.analyze_forward_lean(results.pose_landmarks.landmark)
|
| 181 |
+
depth_ok = self.analyze_depth(results.pose_landmarks.landmark)
|
| 182 |
+
|
| 183 |
+
# Add analysis results
|
| 184 |
+
analysis_results['knee_inward'].append(knee_error)
|
| 185 |
+
analysis_results['forward_lean'].append(lean_error)
|
| 186 |
+
analysis_results['depth_issues'].append(not depth_ok)
|
| 187 |
+
|
| 188 |
+
# Add status indicators
|
| 189 |
+
y_offset = 30
|
| 190 |
+
if knee_error:
|
| 191 |
+
cv2.putText(annotated_frame, "KNEE VALGUS", (10, y_offset),
|
| 192 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
|
| 193 |
+
cv2.circle(annotated_frame, (width - 50, y_offset - 10), 15, (0, 0, 255), -1)
|
| 194 |
+
y_offset += 40
|
| 195 |
+
|
| 196 |
+
if lean_error:
|
| 197 |
+
cv2.putText(annotated_frame, "FORWARD LEAN", (10, y_offset),
|
| 198 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
|
| 199 |
+
cv2.circle(annotated_frame, (width - 50, y_offset - 10), 15, (0, 0, 255), -1)
|
| 200 |
+
y_offset += 40
|
| 201 |
+
|
| 202 |
+
if not depth_ok:
|
| 203 |
+
cv2.putText(annotated_frame, "SHALLOW DEPTH", (10, y_offset),
|
| 204 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 165, 0), 2)
|
| 205 |
+
cv2.circle(annotated_frame, (width - 50, y_offset - 10), 15, (255, 165, 0), -1)
|
| 206 |
+
|
| 207 |
+
# Green light if form is good
|
| 208 |
+
if not knee_error and not lean_error and depth_ok:
|
| 209 |
+
cv2.putText(annotated_frame, "GOOD FORM", (10, 30),
|
| 210 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
|
| 211 |
+
cv2.circle(annotated_frame, (width - 50, 20), 15, (0, 255, 0), -1)
|
| 212 |
+
|
| 213 |
+
out.write(annotated_frame)
|
| 214 |
+
else:
|
| 215 |
+
out.write(frame)
|
| 216 |
+
|
| 217 |
+
cap.release()
|
| 218 |
+
out.release()
|
| 219 |
+
|
| 220 |
+
analysis_results['total_frames'] = frame_count
|
| 221 |
+
|
| 222 |
+
return output_path, analysis_results
|
| 223 |
+
|
| 224 |
+
def generate_summary(self, analysis_results):
|
| 225 |
+
"""Generate analysis summary"""
|
| 226 |
+
total_frames = analysis_results['total_frames']
|
| 227 |
+
if total_frames == 0:
|
| 228 |
+
return "No pose detected in video"
|
| 229 |
+
|
| 230 |
+
knee_issues = sum(analysis_results['knee_inward'])
|
| 231 |
+
lean_issues = sum(analysis_results['forward_lean'])
|
| 232 |
+
depth_issues = sum(analysis_results['depth_issues'])
|
| 233 |
+
|
| 234 |
+
knee_percentage = (knee_issues / total_frames) * 100
|
| 235 |
+
lean_percentage = (lean_issues / total_frames) * 100
|
| 236 |
+
depth_percentage = (depth_issues / total_frames) * 100
|
| 237 |
+
|
| 238 |
+
summary = f"""
|
| 239 |
+
## Squat Form Analysis Results
|
| 240 |
+
|
| 241 |
+
**Total Frames Analyzed:** {total_frames}
|
| 242 |
+
|
| 243 |
+
### Form Issues Detected:
|
| 244 |
+
|
| 245 |
+
𦡠**Knee Valgus (Knees Inward):** {knee_percentage:.1f}% of frames
|
| 246 |
+
{'β Significant issue - Focus on pushing knees out' if knee_percentage > 20 else 'β
Good knee alignment' if knee_percentage < 10 else 'β οΈ Minor issue - Monitor knee tracking'}
|
| 247 |
+
|
| 248 |
+
π **Forward Lean:** {lean_percentage:.1f}% of frames
|
| 249 |
+
{'β Excessive forward lean detected' if lean_percentage > 30 else 'β
Good posture' if lean_percentage < 15 else 'β οΈ Some forward lean - Keep chest up'}
|
| 250 |
+
|
| 251 |
+
π **Squat Depth:** {depth_percentage:.1f}% of frames with shallow depth
|
| 252 |
+
{'β Insufficient depth - Go lower' if depth_percentage > 50 else 'β
Good depth' if depth_percentage < 20 else 'β οΈ Inconsistent depth'}
|
| 253 |
+
|
| 254 |
+
### Recommendations:
|
| 255 |
+
"""
|
| 256 |
+
|
| 257 |
+
if knee_percentage > 20:
|
| 258 |
+
summary += "\n- Focus on pushing your knees out in line with your toes"
|
| 259 |
+
summary += "\n- Strengthen your glutes and hip external rotators"
|
| 260 |
+
|
| 261 |
+
if lean_percentage > 30:
|
| 262 |
+
summary += "\n- Work on ankle mobility and thoracic spine extension"
|
| 263 |
+
summary += "\n- Keep your chest up and maintain a neutral spine"
|
| 264 |
+
|
| 265 |
+
if depth_percentage > 50:
|
| 266 |
+
summary += "\n- Improve ankle and hip mobility"
|
| 267 |
+
summary += "\n- Practice bodyweight squats to full depth"
|
| 268 |
+
|
| 269 |
+
if knee_percentage < 10 and lean_percentage < 15 and depth_percentage < 20:
|
| 270 |
+
summary += "\nβ
**Excellent squat form! Keep up the great work!**"
|
| 271 |
+
|
| 272 |
+
return summary
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# Initialize the analyzer
|
| 276 |
+
analyzer = SquatFormAnalyzer()
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def analyze_squat_video(video_file):
|
| 280 |
+
"""Main function to process uploaded video"""
|
| 281 |
+
if video_file is None:
|
| 282 |
+
return None, "Please upload a video file."
|
| 283 |
+
|
| 284 |
+
# Check file size (limit to 100MB)
|
| 285 |
+
file_size = os.path.getsize(video_file) / (1024 * 1024) # Size in MB
|
| 286 |
+
if file_size > 100:
|
| 287 |
+
return None, "File size too large. Please upload a video smaller than 100MB."
|
| 288 |
+
|
| 289 |
+
try:
|
| 290 |
+
# Process the video
|
| 291 |
+
output_video, results = analyzer.process_video(video_file)
|
| 292 |
+
|
| 293 |
+
# Generate summary
|
| 294 |
+
summary = analyzer.generate_summary(results)
|
| 295 |
+
|
| 296 |
+
return output_video, summary
|
| 297 |
+
|
| 298 |
+
except Exception as e:
|
| 299 |
+
return None, f"Error processing video: {str(e)}. Please ensure the video format is supported (MP4, AVI, MOV)."
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
# Create Gradio interface
|
| 303 |
+
demo = gr.Interface(
|
| 304 |
+
fn=analyze_squat_video,
|
| 305 |
+
inputs=[
|
| 306 |
+
gr.Video(
|
| 307 |
+
label="Upload Squat Video",
|
| 308 |
+
format="mp4"
|
| 309 |
+
)
|
| 310 |
+
],
|
| 311 |
+
outputs=[
|
| 312 |
+
gr.Video(
|
| 313 |
+
label="Analyzed Video",
|
| 314 |
+
format="mp4"
|
| 315 |
+
),
|
| 316 |
+
gr.Markdown(
|
| 317 |
+
label="Analysis Report"
|
| 318 |
+
)
|
| 319 |
+
],
|
| 320 |
+
title="ποΈ Squat Form Analyzer",
|
| 321 |
+
description="""
|
| 322 |
+
Upload a video of yourself performing squats to get real-time form analysis.
|
| 323 |
+
|
| 324 |
+
**The app will analyze:**
|
| 325 |
+
- Knee alignment (knees caving inward)
|
| 326 |
+
- Forward lean of the torso
|
| 327 |
+
- Squat depth
|
| 328 |
+
|
| 329 |
+
**Instructions:**
|
| 330 |
+
1. Upload a clear video showing your full body
|
| 331 |
+
2. Perform squats in the side view for best results
|
| 332 |
+
3. Ensure good lighting and minimal background clutter
|
| 333 |
+
""",
|
| 334 |
+
examples=[],
|
| 335 |
+
cache_examples=False
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
if __name__ == "__main__":
|
| 339 |
+
# Launch with settings optimized for Docker and Hugging Face Spaces
|
| 340 |
+
demo.launch(
|
| 341 |
+
server_name="0.0.0.0", # Allow external connections
|
| 342 |
+
server_port=7860, # Standard port for Hugging Face Spaces
|
| 343 |
+
share=False, # Don't create public URLs in production
|
| 344 |
+
show_error=True, # Show detailed error messages
|
| 345 |
+
quiet=False, # Enable logging
|
| 346 |
+
enable_queue=True, # Handle multiple requests
|
| 347 |
+
max_threads=4 # Limit concurrent processing
|
| 348 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio==4.44.0
|
| 2 |
+
opencv-python-headless==4.8.1.78
|
| 3 |
+
mediapipe==0.10.8
|
| 4 |
+
numpy==1.24.3
|
| 5 |
+
Pillow==10.0.1
|
| 6 |
+
setuptools==68.2.2
|
| 7 |
+
wheel==0.41.2
|