Spaces:
Sleeping
Sleeping
initial commit
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +3 -0
- Dockerfile +42 -0
- LICENSE +21 -0
- README.md +249 -4
- app.py +632 -0
- create_static_folders.py +63 -0
- db/__init__.py +0 -0
- db/__pycache__/workout_logger.cpython-311.pyc +0 -0
- db/__pycache__/workout_logger.cpython-312.pyc +0 -0
- db/__pycache__/workout_logger.cpython-39.pyc +0 -0
- db/workout_logger.py +0 -0
- exercises/__init__.py +0 -0
- exercises/__pycache__/hammer_curl.cpython-311.pyc +0 -0
- exercises/__pycache__/hammer_curl.cpython-312.pyc +0 -0
- exercises/__pycache__/hammer_curl.cpython-39.pyc +0 -0
- exercises/__pycache__/push_up.cpython-311.pyc +0 -0
- exercises/__pycache__/push_up.cpython-312.pyc +0 -0
- exercises/__pycache__/push_up.cpython-39.pyc +0 -0
- exercises/__pycache__/squat.cpython-311.pyc +0 -0
- exercises/__pycache__/squat.cpython-312.pyc +0 -0
- exercises/__pycache__/squat.cpython-39.pyc +0 -0
- exercises/hammer_curl.py +114 -0
- exercises/push_up.py +130 -0
- exercises/squat.py +142 -0
- feedback/__init__.py +0 -0
- feedback/__pycache__/indicators.cpython-311.pyc +0 -0
- feedback/__pycache__/indicators.cpython-312.pyc +0 -0
- feedback/__pycache__/indicators.cpython-39.pyc +0 -0
- feedback/__pycache__/information.cpython-311.pyc +0 -0
- feedback/__pycache__/information.cpython-312.pyc +0 -0
- feedback/__pycache__/information.cpython-39.pyc +0 -0
- feedback/__pycache__/layout.cpython-311.pyc +0 -0
- feedback/__pycache__/layout.cpython-312.pyc +0 -0
- feedback/__pycache__/layout.cpython-39.pyc +0 -0
- feedback/indicators.py +50 -0
- feedback/information.py +41 -0
- feedback/layout.py +16 -0
- live_test.html +72 -0
- main.py +82 -0
- output/.DS_Store +0 -0
- output/images/Screenshot 2024-09-08 030742.png +3 -0
- output/images/Screenshot 2024-09-08 030816.png +3 -0
- output/images/Screenshot 2024-09-08 030836.png +3 -0
- pose_estimation/__init__.py +0 -0
- pose_estimation/__pycache__/angle_calculation.cpython-311.pyc +0 -0
- pose_estimation/__pycache__/angle_calculation.cpython-312.pyc +0 -0
- pose_estimation/__pycache__/angle_calculation.cpython-39.pyc +0 -0
- pose_estimation/__pycache__/estimation.cpython-311.pyc +0 -0
- pose_estimation/__pycache__/estimation.cpython-312.pyc +0 -0
- pose_estimation/__pycache__/estimation.cpython-39.pyc +0 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
output/images/Screenshot[[:space:]]2024-09-08[[:space:]]030742.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
output/images/Screenshot[[:space:]]2024-09-08[[:space:]]030816.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
output/images/Screenshot[[:space:]]2024-09-08[[:space:]]030836.png filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use an official Python runtime as a parent image
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# Set the working directory in the container
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Create static folder structure (if needed by original UI, less critical for pure API)
|
| 8 |
+
RUN mkdir -p static/images
|
| 9 |
+
|
| 10 |
+
# Install system dependencies required by OpenCV and other libraries
|
| 11 |
+
RUN apt-get update && \
|
| 12 |
+
apt-get install -y --no-install-recommends \
|
| 13 |
+
libgl1-mesa-glx \
|
| 14 |
+
libglib2.0-0 \
|
| 15 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
+
|
| 17 |
+
# Copy the requirements file into the container at /app
|
| 18 |
+
COPY requirements.txt .
|
| 19 |
+
|
| 20 |
+
# Install any needed packages specified in requirements.txt
|
| 21 |
+
# Ensure your requirements.txt is clean and includes gunicorn
|
| 22 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 23 |
+
|
| 24 |
+
# Copy the rest of the application's source code
|
| 25 |
+
COPY app.py .
|
| 26 |
+
COPY exercises ./exercises
|
| 27 |
+
COPY pose_estimation ./pose_estimation
|
| 28 |
+
COPY feedback ./feedback
|
| 29 |
+
COPY utils ./utils
|
| 30 |
+
COPY db ./db
|
| 31 |
+
COPY static ./static
|
| 32 |
+
COPY templates ./templates
|
| 33 |
+
|
| 34 |
+
# Make port 8080 available to the world outside this container (Cloud Run default)
|
| 35 |
+
EXPOSE 8080
|
| 36 |
+
|
| 37 |
+
# Define environment variable for the port
|
| 38 |
+
ENV PORT 8080
|
| 39 |
+
ENV MPLCONFIGDIR /tmp/matplotlib_config_cache
|
| 40 |
+
|
| 41 |
+
# Run app.py when the container launches using Gunicorn WSGI server
|
| 42 |
+
CMD ["gunicorn", "--worker-class", "eventlet", "-w", "1", "--bind", "0.0.0.0:${PORT}", "--timeout", "0", "app:app"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 Yakup Zengin
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,10 +1,255 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Fitness Trainer Pose Estimation
|
| 3 |
+
emoji: 💪
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_file: app.py
|
| 8 |
+
health_check_path: /healthz
|
| 9 |
+
app_port: 8080 # Or the port Gunicorn is set to in your Dockerfile CMD
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# Fitness Trainer with Pose Estimation
|
| 14 |
+
|
| 15 |
+
An AI-powered web application that tracks exercises using computer vision and provides real-time feedback. This version now includes **real-time feedback via WebSockets** for an enhanced live training experience.
|
| 16 |
+
|
| 17 |
+
## Features
|
| 18 |
+
|
| 19 |
+
- Real-time pose estimation using MediaPipe.
|
| 20 |
+
- Multiple exercise types: Squats, Push-ups, and Hammer Curls.
|
| 21 |
+
- Customizable sets and repetitions (via UI or API parameters).
|
| 22 |
+
- Exercise form feedback.
|
| 23 |
+
- Progress tracking (if database features are enabled).
|
| 24 |
+
- Web interface for easy access, including a live test page using WebSockets.
|
| 25 |
+
- HTTP API for single frame analysis and stateful exercise tracking.
|
| 26 |
+
- **WebSocket API for low-latency, interactive exercise tracking.**
|
| 27 |
+
|
| 28 |
+
## Installation
|
| 29 |
+
|
| 30 |
+
1. Clone the repository:
|
| 31 |
+
```bash
|
| 32 |
+
git clone https://github.com/yourusername/fitness-trainer-pose-estimation.git # Replace with actual repo URL
|
| 33 |
+
cd fitness-trainer-pose-estimation
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
2. Install dependencies:
|
| 37 |
+
```bash
|
| 38 |
+
pip install -r requirements.txt
|
| 39 |
+
```
|
| 40 |
+
This will install all necessary Python packages, including Flask, Flask-SocketIO, Gunicorn, eventlet, OpenCV, and MediaPipe.
|
| 41 |
+
|
| 42 |
+
3. Set up the static folder structure (if not already present):
|
| 43 |
+
```bash
|
| 44 |
+
mkdir -p static/images
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
4. Add exercise images to the `static/images` folder (if using the original UI templates):
|
| 48 |
+
- `squat.png`
|
| 49 |
+
- `push_up.png`
|
| 50 |
+
- `hammer_curl.png`
|
| 51 |
+
|
| 52 |
+
## Usage
|
| 53 |
+
|
| 54 |
+
### Running Locally (Development)
|
| 55 |
+
|
| 56 |
+
1. Start the Flask-SocketIO development server:
|
| 57 |
+
```bash
|
| 58 |
+
python app.py
|
| 59 |
+
```
|
| 60 |
+
This runs the app with Flask's built-in server, suitable for development. It will typically be available at `http://127.0.0.1:5000`.
|
| 61 |
+
|
| 62 |
+
2. Access the application:
|
| 63 |
+
* **Main UI (if developed):** Open a web browser and navigate to `http://127.0.0.1:5000/`.
|
| 64 |
+
* **Live WebSocket Test Page:**
|
| 65 |
+
1. To use `live_test.html`, it's recommended to serve it via a Flask route. You can add a simple route to `app.py` like this:
|
| 66 |
+
```python
|
| 67 |
+
@app.route('/live')
|
| 68 |
+
def live_test_page():
|
| 69 |
+
return render_template('live_test.html')
|
| 70 |
+
```
|
| 71 |
+
And move `live_test.html` into the `templates` folder.
|
| 72 |
+
Then access it via `http://127.0.0.1:5000/live`.
|
| 73 |
+
2. Alternatively, if you prefer to keep `live_test.html` in the root and test without modifying `app.py` for serving it, you might be able to open the `live_test.html` file directly in your browser. However, ensure the Flask server (`python app.py`) is running, as the JavaScript in `live_test.html` needs to connect to the WebSocket server at `ws://127.0.0.1:5000`. Relative paths to static assets like CSS or JS within the HTML might not work as expected if opened as a direct file, which is why serving it via Flask is more robust.
|
| 74 |
+
|
| 75 |
+
3. Using the Live Test Page (`live_test.html`):
|
| 76 |
+
* Select an exercise type from the dropdown.
|
| 77 |
+
* Click "Start Trainer".
|
| 78 |
+
* Allow webcam access if prompted.
|
| 79 |
+
* Position yourself in front of your camera so that your full body is visible.
|
| 80 |
+
* Observe the real-time feedback (reps, stage, messages) updated via WebSockets.
|
| 81 |
+
* Click "Stop Trainer" to end the session.
|
| 82 |
+
|
| 83 |
+
## API Usage
|
| 84 |
+
|
| 85 |
+
The application offers two primary ways to interact with its pose estimation capabilities: a traditional HTTP API and a real-time WebSocket API.
|
| 86 |
+
|
| 87 |
+
### 1. HTTP API
|
| 88 |
+
|
| 89 |
+
This API is suitable for scenarios where you want to send individual frames or manage exercise sessions via HTTP requests.
|
| 90 |
+
|
| 91 |
+
#### a. Stateless Single-Frame Analysis (`/api/analyze_frame`)
|
| 92 |
+
|
| 93 |
+
Analyzes a single image frame for pose landmarks without maintaining state.
|
| 94 |
+
|
| 95 |
+
* **URL:** `http://127.0.0.1:5000/api/analyze_frame`
|
| 96 |
+
* **Method:** `POST`
|
| 97 |
+
* **Payload (JSON):**
|
| 98 |
+
* `image` (string): Base64 encoded image.
|
| 99 |
+
* `exercise_type` (string, optional): "squat", "push_up", "hammer_curl". Defaults to "squat".
|
| 100 |
+
* **Success Response (JSON):** `{"success": true, "landmarks": [...]}`
|
| 101 |
+
* **Error Response (JSON):** `{"error": "Error message"}`
|
| 102 |
+
|
| 103 |
+
#### b. Stateful HTTP Exercise Tracking (`/api/track_exercise_stream` and `/api/end_exercise_session`)
|
| 104 |
+
|
| 105 |
+
These endpoints allow tracking an exercise over a series of frames, maintaining state on the server.
|
| 106 |
+
|
| 107 |
+
* **`/api/track_exercise_stream` (POST):**
|
| 108 |
+
* **Payload (JSON):**
|
| 109 |
+
* `session_id` (string): Unique ID for the session.
|
| 110 |
+
* `exercise_type` (string): Exercise type.
|
| 111 |
+
* `image` (string): Base64 encoded image of the current frame.
|
| 112 |
+
* `frame_width` (int): Width of the video frame.
|
| 113 |
+
* `frame_height` (int): Height of the video frame.
|
| 114 |
+
* **Success Response (JSON):** Contains `success`, `landmarks_detected`, and exercise-specific `data`.
|
| 115 |
+
* **`/api/end_exercise_session` (POST):**
|
| 116 |
+
* **Payload (JSON):** `session_id` (string).
|
| 117 |
+
* **Success Response (JSON):** Confirmation message.
|
| 118 |
+
|
| 119 |
+
(For detailed request/response structures of the HTTP API, refer to the previous README version or inspect the `app.py` code.)
|
| 120 |
+
|
| 121 |
+
### 2. WebSocket API for Live Training
|
| 122 |
+
|
| 123 |
+
This API provides a low-latency, bidirectional communication channel for real-time exercise tracking, as used by `live_test.html`.
|
| 124 |
+
|
| 125 |
+
* **Endpoint Connection:** The client connects to the server's root URL, e.g., `ws://127.0.0.1:5000/` (or `wss://your-deployed-app.com/`). Flask-SocketIO handles this on the default namespace.
|
| 126 |
+
* **Client-side Setup:**
|
| 127 |
+
1. Include the Socket.IO client library in your HTML:
|
| 128 |
+
```html
|
| 129 |
+
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
|
| 130 |
+
```
|
| 131 |
+
2. Connect to the server:
|
| 132 |
+
```javascript
|
| 133 |
+
const socket = io(); // Or io.connect('http://your-server-address');
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
* **Key WebSocket Events:**
|
| 137 |
+
|
| 138 |
+
* **Client to Server:**
|
| 139 |
+
* `socket.emit('start_exercise_session', { exercise_type: 'squat' });`
|
| 140 |
+
* Call this to initiate a new exercise session.
|
| 141 |
+
* `exercise_type` can be 'squat', 'push_up', or 'hammer_curl'.
|
| 142 |
+
* `socket.emit('process_frame', { image: 'base64img_data', exercise_type: 'squat', frame_width: width, frame_height: height });`
|
| 143 |
+
* Send a video frame for processing.
|
| 144 |
+
* `image`: Base64 encoded string of the JPEG frame.
|
| 145 |
+
* `exercise_type`: The current exercise.
|
| 146 |
+
* `frame_width`, `frame_height`: Dimensions of the original video frame.
|
| 147 |
+
|
| 148 |
+
* **Server to Client:**
|
| 149 |
+
* `socket.on('session_started', (data) => { ... });`
|
| 150 |
+
* Confirmation that the session has started.
|
| 151 |
+
* `data` typically includes: `{ session_id: 'server_assigned_sid', exercise_type: 'squat' }`. The `request.sid` on the server is used as the session identifier for WebSocket communications.
|
| 152 |
+
* `socket.on('exercise_update', (data) => { ... });`
|
| 153 |
+
* Provides feedback for the processed frame.
|
| 154 |
+
* `data` structure:
|
| 155 |
+
```json
|
| 156 |
+
{
|
| 157 |
+
"success": true,
|
| 158 |
+
"landmarks_detected": true, // or false
|
| 159 |
+
"data": {
|
| 160 |
+
// Structure depends on the exercise_type (matches HTTP API's track_exercise_stream response data)
|
| 161 |
+
// Example for 'squat':
|
| 162 |
+
// "counter": 0, "stage": "down", "angle_left": 85.0, "feedback": "Good depth!"
|
| 163 |
+
// Example for 'push_up':
|
| 164 |
+
// "counter": 0, "stage": "up", "angle_body_left": 170.0, "feedback": "Keep body straight."
|
| 165 |
+
// Example for 'hammer_curl':
|
| 166 |
+
// "counter_left": 1, "stage_left": "up", "angle_left_curl": 45.0, "feedback_left": "Good curl!", ... (and right side)
|
| 167 |
+
},
|
| 168 |
+
"message": "Optional message, e.g., 'No landmarks detected.'" // if landmarks_detected is false
|
| 169 |
+
}
|
| 170 |
+
```
|
| 171 |
+
* `socket.on('session_error', (data) => { ... });`
|
| 172 |
+
* Sent if there's an error starting a session (e.g., invalid exercise type, server busy).
|
| 173 |
+
* `data`: `{ error: "Error message description" }`
|
| 174 |
+
* `socket.on('frame_error', (data) => { ... });`
|
| 175 |
+
* Sent if there's an error processing a specific frame (e.g., bad image data, unexpected server error).
|
| 176 |
+
* `data`: `{ error: "Error message description" }`
|
| 177 |
+
* `socket.on('disconnect', () => { ... });`
|
| 178 |
+
* Standard Socket.IO event. The client is disconnected. Server-side, the `disconnect` handler in `app.py` cleans up the `active_exercise_sessions` entry for that client (`request.sid`).
|
| 179 |
+
|
| 180 |
+
## Project Structure
|
| 181 |
+
|
| 182 |
+
- `app.py` - Main Flask application with HTTP routes and SocketIO event handlers.
|
| 183 |
+
- `templates/` - HTML templates (e.g., `index.html`, `live_test.html` if moved here).
|
| 184 |
+
- `static/` - CSS, client-side JavaScript (e.g., `websocket_trainer.js`), and images.
|
| 185 |
+
- `pose_estimation/` - Pose estimation modules.
|
| 186 |
+
- `exercises/` - Exercise tracking classes.
|
| 187 |
+
- `feedback/` - User feedback modules.
|
| 188 |
+
- `utils/` - Helper functions and utilities.
|
| 189 |
+
- `Dockerfile` - For building the application container.
|
| 190 |
+
- `requirements.txt` - Python dependencies.
|
| 191 |
+
- `live_test.html` - Client-side example for testing WebSocket functionality (currently in root, consider moving to `templates/` and serving via a route).
|
| 192 |
+
|
| 193 |
+
## Technologies Used
|
| 194 |
+
|
| 195 |
+
- Flask - Web framework.
|
| 196 |
+
- Flask-SocketIO - For WebSocket communication.
|
| 197 |
+
- Gunicorn + eventlet - For concurrent WSGI server suitable for WebSockets.
|
| 198 |
+
- OpenCV - Computer vision tasks.
|
| 199 |
+
- MediaPipe - Pose estimation.
|
| 200 |
+
- HTML/CSS/JavaScript - Frontend.
|
| 201 |
+
|
| 202 |
+
## Deployment
|
| 203 |
+
|
| 204 |
+
Containerization with Docker is highly recommended for deployment.
|
| 205 |
+
|
| 206 |
+
### Dockerfile
|
| 207 |
+
|
| 208 |
+
The provided `Dockerfile` sets up the Python environment, installs dependencies, and configures Gunicorn to run the application.
|
| 209 |
+
**Key change for WebSockets:** The Dockerfile now uses `eventlet` as the worker class for Gunicorn, which is essential for Flask-SocketIO:
|
| 210 |
+
```dockerfile
|
| 211 |
+
# ... (other Dockerfile instructions) ...
|
| 212 |
+
|
| 213 |
+
# Run app.py when the container launches using Gunicorn WSGI server
|
| 214 |
+
CMD ["gunicorn", "--worker-class", "eventlet", "-w", "1", "--bind", "0.0.0.0:${PORT}", "--timeout", "0", "app:app"]
|
| 215 |
+
```
|
| 216 |
+
* `--worker-class eventlet`: Uses the asynchronous eventlet worker.
|
| 217 |
+
* `-w 1`: Starts 1 worker process. For eventlet, a single worker can handle many concurrent connections.
|
| 218 |
+
* `--timeout 0`: Disables worker timeouts, important for long-lived WebSocket connections.
|
| 219 |
+
|
| 220 |
+
### Hosting Options
|
| 221 |
+
|
| 222 |
+
#### 1. Hugging Face Spaces
|
| 223 |
+
|
| 224 |
+
- **Setup:** Push your code (including the `Dockerfile` and `app.py`) to a Hugging Face Space repository. Select "Docker" as the Space SDK.
|
| 225 |
+
- **WebSocket Support:** Hugging Face Spaces generally supports WebSockets. The application should be accessible.
|
| 226 |
+
- **Port Configuration:** Ensure your `CMD` in the Dockerfile uses a port variable like `${PORT}` (which HF Spaces sets, often to 7860 or 8080) or a fixed port that HF Spaces can map. The `app_port: 8080` in the README metadata is a common choice.
|
| 227 |
+
|
| 228 |
+
#### 2. Google Cloud Run
|
| 229 |
+
|
| 230 |
+
- **Setup:** Build your Docker container and push it to Google Container Registry (GCR) or Artifact Registry. Deploy the image from there to Cloud Run.
|
| 231 |
+
- **WebSocket Support & Session Affinity:**
|
| 232 |
+
- Cloud Run supports WebSockets.
|
| 233 |
+
- **Session Affinity:** For applications using Flask-SocketIO with its default in-memory session management (`active_exercise_sessions` in this app, keyed by `request.sid`), session affinity is crucial if you scale to more than one instance. Session affinity directs requests from a specific client to the same container instance. Enable it in your Cloud Run service settings.
|
| 234 |
+
- **Statelessness Consideration:** While session affinity helps, be aware that `active_exercise_sessions` are still tied to individual instances. If an instance restarts or you scale down and up, that specific instance's memory is lost. For more robust session management across multiple instances or instance restarts, an external store (like Redis via Memorystore) for `active_exercise_sessions` would be necessary. For the current implementation, session affinity is the primary mechanism to make WebSockets work with multiple instances.
|
| 235 |
+
- **Gunicorn Command:** The `CMD` in the Dockerfile is already configured for Cloud Run (binds to `0.0.0.0:${PORT}`).
|
| 236 |
+
|
| 237 |
+
#### 3. Other PaaS/VPS
|
| 238 |
+
|
| 239 |
+
- Ensure the platform supports Python and Docker (or allows manual setup of Python, eventlet, Gunicorn).
|
| 240 |
+
- Configure your reverse proxy (like Nginx) correctly to handle WebSocket connections (Upgrade and Connection headers) if you place one in front of Gunicorn.
|
| 241 |
+
|
| 242 |
+
### Note on Session Management (General)
|
| 243 |
+
|
| 244 |
+
The current application uses an in-memory Python dictionary (`active_exercise_sessions`) to store user session data, keyed by the WebSocket `request.sid`. This is simple but has limitations:
|
| 245 |
+
* **Scalability:** Data is not shared across multiple Gunicorn worker processes (if you were to increase `-w` beyond 1, though with `eventlet` one worker is standard) or multiple server instances (e.g., when load balancing or on platforms like Cloud Run with multiple instances).
|
| 246 |
+
* **Persistence:** Sessions are lost if the server or worker restarts.
|
| 247 |
+
For production environments requiring high availability or scalability, consider implementing session management using an external store like Redis or Memcached.
|
| 248 |
+
|
| 249 |
+
## Contributing
|
| 250 |
+
|
| 251 |
+
Contributions are welcome! Please feel free to submit a Pull Request. Ensure your contributions align with the project's structure and coding style. If adding new features, please update relevant documentation and consider adding tests.
|
| 252 |
+
|
| 253 |
+
## License
|
| 254 |
+
|
| 255 |
+
This project is licensed under the MIT License - see the `LICENSE` file for details (if one exists in the repository).
|
app.py
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, render_template, Response, request, jsonify, session, redirect, url_for
|
| 2 |
+
from flask_socketio import SocketIO
|
| 3 |
+
import cv2
|
| 4 |
+
import base64
|
| 5 |
+
import io
|
| 6 |
+
import numpy as np
|
| 7 |
+
from PIL import Image
|
| 8 |
+
import threading
|
| 9 |
+
import time
|
| 10 |
+
import uuid
|
| 11 |
+
import sys
|
| 12 |
+
import traceback
|
| 13 |
+
import logging
|
| 14 |
+
from flask_cors import CORS
|
| 15 |
+
|
| 16 |
+
# Set up logging
|
| 17 |
+
logging.basicConfig(level=logging.DEBUG,
|
| 18 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 19 |
+
handlers=[logging.StreamHandler()])
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
# Import attempt with error handling
|
| 23 |
+
try:
|
| 24 |
+
from pose_estimation.estimation import PoseEstimator
|
| 25 |
+
from exercises.squat import Squat
|
| 26 |
+
from exercises.hammer_curl import HammerCurl
|
| 27 |
+
from exercises.push_up import PushUp
|
| 28 |
+
from feedback.information import get_exercise_info
|
| 29 |
+
from feedback.layout import layout_indicators
|
| 30 |
+
from utils.draw_text_with_background import draw_text_with_background
|
| 31 |
+
logger.info("Successfully imported pose estimation modules")
|
| 32 |
+
except ImportError as e:
|
| 33 |
+
logger.error(f"Failed to import required modules: {e}")
|
| 34 |
+
traceback.print_exc()
|
| 35 |
+
sys.exit(1)
|
| 36 |
+
|
| 37 |
+
# Try to import WorkoutLogger with fallback
|
| 38 |
+
try:
|
| 39 |
+
from db.workout_logger import WorkoutLogger
|
| 40 |
+
workout_logger = WorkoutLogger()
|
| 41 |
+
logger.info("Successfully initialized workout logger")
|
| 42 |
+
except ImportError:
|
| 43 |
+
logger.warning("WorkoutLogger import failed, creating dummy class")
|
| 44 |
+
|
| 45 |
+
class DummyWorkoutLogger:
|
| 46 |
+
def __init__(self):
|
| 47 |
+
pass
|
| 48 |
+
def log_workout(self, *args, **kwargs):
|
| 49 |
+
return {}
|
| 50 |
+
def get_recent_workouts(self, *args, **kwargs):
|
| 51 |
+
return []
|
| 52 |
+
def get_weekly_stats(self, *args, **kwargs):
|
| 53 |
+
return {}
|
| 54 |
+
def get_exercise_distribution(self, *args, **kwargs):
|
| 55 |
+
return {}
|
| 56 |
+
def get_user_stats(self, *args, **kwargs):
|
| 57 |
+
return {'total_workouts': 0, 'total_exercises': 0, 'streak_days': 0}
|
| 58 |
+
|
| 59 |
+
workout_logger = DummyWorkoutLogger()
|
| 60 |
+
|
| 61 |
+
logger.info("Setting up Flask application")
|
| 62 |
+
app = Flask(__name__)
|
| 63 |
+
app.secret_key = 'fitness_trainer_secret_key' # Required for sessions
|
| 64 |
+
CORS(app, origins="*", methods=["GET", "POST", "OPTIONS"], allow_headers=["Content-Type", "Authorization"])
|
| 65 |
+
socketio = SocketIO(app, cors_allowed_origins="*")
|
| 66 |
+
|
| 67 |
+
pose_estimator_api = None
|
| 68 |
+
active_exercise_sessions = {}
|
| 69 |
+
# Max number of concurrent sessions to avoid memory issues
|
| 70 |
+
MAX_SESSIONS = 100
|
| 71 |
+
|
| 72 |
+
def get_pose_estimator_api_instance():
|
| 73 |
+
global pose_estimator_api
|
| 74 |
+
if pose_estimator_api is None:
|
| 75 |
+
logger.info("Initializing PoseEstimator for API")
|
| 76 |
+
pose_estimator_api = PoseEstimator()
|
| 77 |
+
return pose_estimator_api
|
| 78 |
+
|
| 79 |
+
# Global variables
|
| 80 |
+
camera = None
|
| 81 |
+
output_frame = None
|
| 82 |
+
lock = threading.Lock()
|
| 83 |
+
exercise_running = False
|
| 84 |
+
current_exercise = None
|
| 85 |
+
current_exercise_data = None
|
| 86 |
+
exercise_counter = 0
|
| 87 |
+
exercise_goal = 0
|
| 88 |
+
sets_completed = 0
|
| 89 |
+
sets_goal = 0
|
| 90 |
+
workout_start_time = None
|
| 91 |
+
|
| 92 |
+
def initialize_camera():
|
| 93 |
+
global camera
|
| 94 |
+
if camera is None:
|
| 95 |
+
camera = cv2.VideoCapture(0)
|
| 96 |
+
return camera
|
| 97 |
+
|
| 98 |
+
def release_camera():
|
| 99 |
+
global camera
|
| 100 |
+
if camera is not None:
|
| 101 |
+
camera.release()
|
| 102 |
+
camera = None
|
| 103 |
+
|
| 104 |
+
def generate_frames():
|
| 105 |
+
global output_frame, lock, exercise_running, current_exercise, current_exercise_data
|
| 106 |
+
global exercise_counter, exercise_goal, sets_completed, sets_goal
|
| 107 |
+
|
| 108 |
+
pose_estimator = PoseEstimator()
|
| 109 |
+
|
| 110 |
+
while True:
|
| 111 |
+
if camera is None:
|
| 112 |
+
continue
|
| 113 |
+
|
| 114 |
+
success, frame = camera.read()
|
| 115 |
+
if not success:
|
| 116 |
+
continue
|
| 117 |
+
|
| 118 |
+
# Only process frames if an exercise is running
|
| 119 |
+
if exercise_running and current_exercise:
|
| 120 |
+
# Process with pose estimation
|
| 121 |
+
results = pose_estimator.estimate_pose(frame, current_exercise_data['type'])
|
| 122 |
+
|
| 123 |
+
if results.pose_landmarks:
|
| 124 |
+
# Track exercise based on type
|
| 125 |
+
if current_exercise_data['type'] == "squat":
|
| 126 |
+
counter, angle, stage = current_exercise.track_squat(results.pose_landmarks.landmark, frame)
|
| 127 |
+
layout_indicators(frame, current_exercise_data['type'], (counter, angle, stage))
|
| 128 |
+
exercise_counter = counter
|
| 129 |
+
|
| 130 |
+
elif current_exercise_data['type'] == "push_up":
|
| 131 |
+
counter, angle, stage = current_exercise.track_push_up(results.pose_landmarks.landmark, frame)
|
| 132 |
+
layout_indicators(frame, current_exercise_data['type'], (counter, angle, stage))
|
| 133 |
+
exercise_counter = counter
|
| 134 |
+
|
| 135 |
+
elif current_exercise_data['type'] == "hammer_curl":
|
| 136 |
+
(counter_right, angle_right, counter_left, angle_left,
|
| 137 |
+
warning_message_right, warning_message_left, progress_right,
|
| 138 |
+
progress_left, stage_right, stage_left) = current_exercise.track_hammer_curl(
|
| 139 |
+
results.pose_landmarks.landmark, frame)
|
| 140 |
+
layout_indicators(frame, current_exercise_data['type'],
|
| 141 |
+
(counter_right, angle_right, counter_left, angle_left,
|
| 142 |
+
warning_message_right, warning_message_left,
|
| 143 |
+
progress_right, progress_left, stage_right, stage_left))
|
| 144 |
+
exercise_counter = max(counter_right, counter_left)
|
| 145 |
+
|
| 146 |
+
# Display exercise information
|
| 147 |
+
exercise_info = get_exercise_info(current_exercise_data['type'])
|
| 148 |
+
draw_text_with_background(frame, f"Exercise: {exercise_info.get('name', 'N/A')}", (40, 50),
|
| 149 |
+
cv2.FONT_HERSHEY_DUPLEX, 0.7, (255, 255, 255), (118, 29, 14), 1)
|
| 150 |
+
draw_text_with_background(frame, f"Reps Goal: {exercise_goal}", (40, 80),
|
| 151 |
+
cv2.FONT_HERSHEY_DUPLEX, 0.7, (255, 255, 255), (118, 29, 14), 1)
|
| 152 |
+
draw_text_with_background(frame, f"Sets Goal: {sets_goal}", (40, 110),
|
| 153 |
+
cv2.FONT_HERSHEY_DUPLEX, 0.7, (255, 255, 255), (118, 29, 14), 1)
|
| 154 |
+
draw_text_with_background(frame, f"Current Set: {sets_completed + 1}", (40, 140),
|
| 155 |
+
cv2.FONT_HERSHEY_DUPLEX, 0.7, (255, 255, 255), (118, 29, 14), 1)
|
| 156 |
+
|
| 157 |
+
# Check if rep goal is reached for current set
|
| 158 |
+
if exercise_counter >= exercise_goal:
|
| 159 |
+
sets_completed += 1
|
| 160 |
+
exercise_counter = 0
|
| 161 |
+
# Reset exercise counter in the appropriate exercise object
|
| 162 |
+
if current_exercise_data['type'] == "squat" or current_exercise_data['type'] == "push_up":
|
| 163 |
+
current_exercise.counter = 0
|
| 164 |
+
elif current_exercise_data['type'] == "hammer_curl":
|
| 165 |
+
current_exercise.counter_right = 0
|
| 166 |
+
current_exercise.counter_left = 0
|
| 167 |
+
|
| 168 |
+
# Check if all sets are completed
|
| 169 |
+
if sets_completed >= sets_goal:
|
| 170 |
+
exercise_running = False
|
| 171 |
+
draw_text_with_background(frame, "WORKOUT COMPLETE!", (frame.shape[1]//2 - 150, frame.shape[0]//2),
|
| 172 |
+
cv2.FONT_HERSHEY_DUPLEX, 1.2, (255, 255, 255), (0, 200, 0), 2)
|
| 173 |
+
else:
|
| 174 |
+
draw_text_with_background(frame, f"SET {sets_completed} COMPLETE! Rest for 30 sec",
|
| 175 |
+
(frame.shape[1]//2 - 200, frame.shape[0]//2),
|
| 176 |
+
cv2.FONT_HERSHEY_DUPLEX, 1.0, (255, 255, 255), (0, 0, 200), 2)
|
| 177 |
+
# We could add rest timer functionality here
|
| 178 |
+
else:
|
| 179 |
+
# Display welcome message if no exercise is running
|
| 180 |
+
cv2.putText(frame, "Select an exercise to begin", (frame.shape[1]//2 - 150, frame.shape[0]//2),
|
| 181 |
+
cv2.FONT_HERSHEY_DUPLEX, 0.8, (255, 255, 255), 1)
|
| 182 |
+
|
| 183 |
+
# Encode the frame in JPEG format
|
| 184 |
+
with lock:
|
| 185 |
+
output_frame = frame.copy()
|
| 186 |
+
|
| 187 |
+
# Yield the frame in byte format
|
| 188 |
+
ret, buffer = cv2.imencode('.jpg', output_frame)
|
| 189 |
+
frame = buffer.tobytes()
|
| 190 |
+
yield (b'--frame\r\n'
|
| 191 |
+
b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n')
|
| 192 |
+
|
| 193 |
+
@app.route('/')
|
| 194 |
+
def index():
|
| 195 |
+
"""Home page with exercise selection"""
|
| 196 |
+
logger.info("Rendering index page")
|
| 197 |
+
try:
|
| 198 |
+
return render_template('index.html')
|
| 199 |
+
except Exception as e:
|
| 200 |
+
logger.error(f"Error rendering index: {e}")
|
| 201 |
+
return f"Error rendering template: {str(e)}", 500
|
| 202 |
+
|
| 203 |
+
@app.route('/dashboard')
|
| 204 |
+
def dashboard():
|
| 205 |
+
"""Dashboard page with workout statistics"""
|
| 206 |
+
logger.info("Rendering dashboard page")
|
| 207 |
+
try:
|
| 208 |
+
# Get data for the dashboard
|
| 209 |
+
recent_workouts = workout_logger.get_recent_workouts(5)
|
| 210 |
+
weekly_stats = workout_logger.get_weekly_stats()
|
| 211 |
+
exercise_distribution = workout_logger.get_exercise_distribution()
|
| 212 |
+
user_stats = workout_logger.get_user_stats()
|
| 213 |
+
|
| 214 |
+
# Format workouts for display
|
| 215 |
+
formatted_workouts = []
|
| 216 |
+
for workout in recent_workouts:
|
| 217 |
+
formatted_workouts.append({
|
| 218 |
+
'date': workout['date'],
|
| 219 |
+
'exercise': workout['exercise_type'].replace('_', ' ').title(),
|
| 220 |
+
'sets': workout['sets'],
|
| 221 |
+
'reps': workout['reps'],
|
| 222 |
+
'duration': f"{workout['duration_seconds'] // 60}:{workout['duration_seconds'] % 60:02d}"
|
| 223 |
+
})
|
| 224 |
+
|
| 225 |
+
# Calculate total workouts this week
|
| 226 |
+
weekly_workout_count = sum(day['workout_count'] for day in weekly_stats.values())
|
| 227 |
+
|
| 228 |
+
return render_template('dashboard.html',
|
| 229 |
+
recent_workouts=formatted_workouts,
|
| 230 |
+
weekly_workouts=weekly_workout_count,
|
| 231 |
+
total_workouts=user_stats['total_workouts'],
|
| 232 |
+
total_exercises=user_stats['total_exercises'],
|
| 233 |
+
streak_days=user_stats['streak_days'])
|
| 234 |
+
except Exception as e:
|
| 235 |
+
logger.error(f"Error in dashboard: {e}")
|
| 236 |
+
traceback.print_exc()
|
| 237 |
+
return f"Error loading dashboard: {str(e)}", 500
|
| 238 |
+
|
| 239 |
+
@app.route('/video_feed')
|
| 240 |
+
def video_feed():
|
| 241 |
+
"""Video streaming route"""
|
| 242 |
+
return Response(generate_frames(),
|
| 243 |
+
mimetype='multipart/x-mixed-replace; boundary=frame')
|
| 244 |
+
|
| 245 |
+
@app.route('/start_exercise', methods=['POST'])
|
| 246 |
+
def start_exercise():
|
| 247 |
+
"""Start a new exercise based on user selection"""
|
| 248 |
+
global exercise_running, current_exercise, current_exercise_data
|
| 249 |
+
global exercise_counter, exercise_goal, sets_completed, sets_goal
|
| 250 |
+
global workout_start_time
|
| 251 |
+
|
| 252 |
+
data = request.json
|
| 253 |
+
exercise_type = data.get('exercise_type')
|
| 254 |
+
sets_goal = int(data.get('sets', 3))
|
| 255 |
+
exercise_goal = int(data.get('reps', 10))
|
| 256 |
+
|
| 257 |
+
# Initialize camera if not already done
|
| 258 |
+
initialize_camera()
|
| 259 |
+
|
| 260 |
+
# Reset counters
|
| 261 |
+
exercise_counter = 0
|
| 262 |
+
sets_completed = 0
|
| 263 |
+
workout_start_time = time.time()
|
| 264 |
+
|
| 265 |
+
# Initialize the appropriate exercise class
|
| 266 |
+
if exercise_type == "squat":
|
| 267 |
+
current_exercise = Squat()
|
| 268 |
+
elif exercise_type == "push_up":
|
| 269 |
+
current_exercise = PushUp()
|
| 270 |
+
elif exercise_type == "hammer_curl":
|
| 271 |
+
current_exercise = HammerCurl()
|
| 272 |
+
else:
|
| 273 |
+
return jsonify({'success': False, 'error': 'Invalid exercise type'})
|
| 274 |
+
|
| 275 |
+
# Store exercise data
|
| 276 |
+
current_exercise_data = {
|
| 277 |
+
'type': exercise_type,
|
| 278 |
+
'sets': sets_goal,
|
| 279 |
+
'reps': exercise_goal
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
# Start the exercise
|
| 283 |
+
exercise_running = True
|
| 284 |
+
|
| 285 |
+
return jsonify({'success': True})
|
| 286 |
+
|
| 287 |
+
@app.route('/stop_exercise', methods=['POST'])
|
| 288 |
+
def stop_exercise():
|
| 289 |
+
"""Stop the current exercise and log the workout"""
|
| 290 |
+
global exercise_running, current_exercise_data, workout_start_time
|
| 291 |
+
global exercise_counter, exercise_goal, sets_completed, sets_goal
|
| 292 |
+
|
| 293 |
+
if exercise_running and current_exercise_data:
|
| 294 |
+
# Calculate duration
|
| 295 |
+
duration = int(time.time() - workout_start_time) if workout_start_time else 0
|
| 296 |
+
|
| 297 |
+
# Log the workout
|
| 298 |
+
workout_logger.log_workout(
|
| 299 |
+
exercise_type=current_exercise_data['type'],
|
| 300 |
+
sets=sets_completed + (1 if exercise_counter > 0 else 0), # Include partial set
|
| 301 |
+
reps=exercise_goal,
|
| 302 |
+
duration_seconds=duration
|
| 303 |
+
)
|
| 304 |
+
release_camera()
|
| 305 |
+
exercise_running = False
|
| 306 |
+
return jsonify({'success': True})
|
| 307 |
+
|
| 308 |
+
@app.route('/get_status', methods=['GET'])
|
| 309 |
+
def get_status():
|
| 310 |
+
"""Return current exercise status"""
|
| 311 |
+
global exercise_counter, sets_completed, exercise_goal, sets_goal, exercise_running
|
| 312 |
+
|
| 313 |
+
return jsonify({
|
| 314 |
+
'exercise_running': exercise_running,
|
| 315 |
+
'current_reps': exercise_counter,
|
| 316 |
+
'current_set': sets_completed + 1 if exercise_running else 0,
|
| 317 |
+
'total_sets': sets_goal,
|
| 318 |
+
'rep_goal': exercise_goal
|
| 319 |
+
})
|
| 320 |
+
|
| 321 |
+
@app.route('/profile')
|
| 322 |
+
def profile():
|
| 323 |
+
"""User profile page - placeholder for future development"""
|
| 324 |
+
return "Profile page - Coming soon!"
|
| 325 |
+
|
| 326 |
+
@app.route('/healthz')
|
| 327 |
+
def health_check():
|
| 328 |
+
logger.info("Health check endpoint called successfully.")
|
| 329 |
+
return "OK", 200
|
| 330 |
+
|
| 331 |
+
@app.route('/api/analyze_frame', methods=['POST'])
|
| 332 |
+
def analyze_frame():
|
| 333 |
+
logger.info("API call to /api/analyze_frame")
|
| 334 |
+
try:
|
| 335 |
+
data = request.json
|
| 336 |
+
if not data or 'image' not in data:
|
| 337 |
+
logger.warning("API /api/analyze_frame: No image provided or invalid JSON.")
|
| 338 |
+
return jsonify({'error': 'No image provided in JSON payload (expected base64 string under "image" key)'}), 400
|
| 339 |
+
|
| 340 |
+
# Use 'squat' as a default if not provided. This primarily affects server-side drawing, not the landmarks themselves.
|
| 341 |
+
exercise_type_for_api = data.get('exercise_type', 'squat')
|
| 342 |
+
if exercise_type_for_api not in ["squat", "push_up", "hammer_curl"]: # Validate against known types if necessary
|
| 343 |
+
logger.warning(f"API /api/analyze_frame: Invalid exercise_type '{exercise_type_for_api}'. Defaulting to 'squat'.")
|
| 344 |
+
exercise_type_for_api = "squat"
|
| 345 |
+
|
| 346 |
+
image_data = data['image']
|
| 347 |
+
|
| 348 |
+
# Add padding if missing for base64 decoding
|
| 349 |
+
missing_padding = len(image_data) % 4
|
| 350 |
+
if missing_padding:
|
| 351 |
+
image_data += '=' * (4 - missing_padding)
|
| 352 |
+
|
| 353 |
+
try:
|
| 354 |
+
# Decode the base64 string
|
| 355 |
+
image_bytes = base64.b64decode(image_data)
|
| 356 |
+
pil_image = Image.open(io.BytesIO(image_bytes))
|
| 357 |
+
# Convert PIL image (RGB) to OpenCV frame (BGR)
|
| 358 |
+
frame = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
|
| 359 |
+
except Exception as e:
|
| 360 |
+
logger.error(f"API /api/analyze_frame: Error decoding base64 image: {e}")
|
| 361 |
+
return jsonify({'error': f'Invalid base64 image data: {str(e)}'}), 400
|
| 362 |
+
|
| 363 |
+
estimator = get_pose_estimator_api_instance()
|
| 364 |
+
|
| 365 |
+
# Process with pose estimation
|
| 366 |
+
results = estimator.estimate_pose(frame, exercise_type_for_api)
|
| 367 |
+
|
| 368 |
+
landmarks_list = []
|
| 369 |
+
if results.pose_landmarks:
|
| 370 |
+
for i, landmark in enumerate(results.pose_landmarks.landmark):
|
| 371 |
+
landmarks_list.append({
|
| 372 |
+
'index': i,
|
| 373 |
+
'x': landmark.x,
|
| 374 |
+
'y': landmark.y,
|
| 375 |
+
'z': landmark.z,
|
| 376 |
+
'visibility': landmark.visibility if hasattr(landmark, 'visibility') else None
|
| 377 |
+
})
|
| 378 |
+
logger.info(f"API /api/analyze_frame: Successfully processed image, found {len(landmarks_list)} landmarks for exercise type '{exercise_type_for_api}'.")
|
| 379 |
+
return jsonify({'success': True, 'landmarks': landmarks_list})
|
| 380 |
+
else:
|
| 381 |
+
logger.info(f"API /api/analyze_frame: No landmarks detected in the provided image for exercise type '{exercise_type_for_api}'.")
|
| 382 |
+
return jsonify({'success': True, 'landmarks': []})
|
| 383 |
+
|
| 384 |
+
except Exception as e:
|
| 385 |
+
logger.error(f"API /api/analyze_frame: Error: {e}")
|
| 386 |
+
traceback.print_exc()
|
| 387 |
+
return jsonify({'error': f'Internal server error: {str(e)}'}), 500
|
| 388 |
+
|
| 389 |
+
@app.route('/api/track_exercise_stream', methods=['POST'])
|
| 390 |
+
def track_exercise_stream():
|
| 391 |
+
logger.info("API call to /api/track_exercise_stream")
|
| 392 |
+
data = {} # Initialize data to ensure it's defined for logging in case of early errors
|
| 393 |
+
try:
|
| 394 |
+
data = request.json
|
| 395 |
+
if not data:
|
| 396 |
+
return jsonify({'error': 'No JSON data provided'}), 400
|
| 397 |
+
|
| 398 |
+
session_id = data.get('session_id')
|
| 399 |
+
exercise_type = data.get('exercise_type')
|
| 400 |
+
image_data_base64 = data.get('image')
|
| 401 |
+
frame_width = data.get('frame_width')
|
| 402 |
+
frame_height = data.get('frame_height')
|
| 403 |
+
|
| 404 |
+
if not all([session_id, exercise_type, image_data_base64, frame_width, frame_height]):
|
| 405 |
+
return jsonify({'error': 'Missing required fields: session_id, exercise_type, image, frame_width, frame_height'}), 400
|
| 406 |
+
|
| 407 |
+
if not isinstance(frame_width, int) or not isinstance(frame_height, int) or frame_width <= 0 or frame_height <= 0:
|
| 408 |
+
return jsonify({'error': 'Invalid frame_width or frame_height'}), 400
|
| 409 |
+
|
| 410 |
+
# Manage session limit
|
| 411 |
+
if len(active_exercise_sessions) >= MAX_SESSIONS and session_id not in active_exercise_sessions:
|
| 412 |
+
logger.warning(f"Max sessions ({MAX_SESSIONS}) reached. Rejecting new session {session_id}.")
|
| 413 |
+
# Optional: could try to evict oldest session
|
| 414 |
+
return jsonify({'error': 'Server busy, max sessions reached. Please try again later.'}), 503
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
# Get or create exercise session object
|
| 418 |
+
if session_id not in active_exercise_sessions:
|
| 419 |
+
logger.info(f"Creating new session {session_id} for exercise {exercise_type}")
|
| 420 |
+
if exercise_type == 'squat':
|
| 421 |
+
active_exercise_sessions[session_id] = Squat()
|
| 422 |
+
elif exercise_type == 'push_up':
|
| 423 |
+
active_exercise_sessions[session_id] = PushUp()
|
| 424 |
+
elif exercise_type == 'hammer_curl':
|
| 425 |
+
active_exercise_sessions[session_id] = HammerCurl()
|
| 426 |
+
else:
|
| 427 |
+
logger.warning(f"Invalid exercise type: {exercise_type} for session {session_id}")
|
| 428 |
+
return jsonify({'error': 'Invalid exercise_type'}), 400
|
| 429 |
+
|
| 430 |
+
exercise_session = active_exercise_sessions[session_id]
|
| 431 |
+
|
| 432 |
+
# Decode image (similar to /api/analyze_frame)
|
| 433 |
+
missing_padding = len(image_data_base64) % 4
|
| 434 |
+
if missing_padding:
|
| 435 |
+
image_data_base64 += '=' * (4 - missing_padding)
|
| 436 |
+
try:
|
| 437 |
+
image_bytes = base64.b64decode(image_data_base64)
|
| 438 |
+
pil_image = Image.open(io.BytesIO(image_bytes))
|
| 439 |
+
frame_for_estimation = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) # For PoseEstimator
|
| 440 |
+
except Exception as e:
|
| 441 |
+
logger.error(f"Session {session_id}: Error decoding base64 image: {e}")
|
| 442 |
+
return jsonify({'error': f'Invalid base64 image data: {str(e)}'}), 400
|
| 443 |
+
|
| 444 |
+
# Get pose landmarks
|
| 445 |
+
pose_estimator = get_pose_estimator_api_instance() # Reusing the existing estimator instance getter
|
| 446 |
+
# The 'squat' here is just a default for PoseEstimator's internal drawing logic, which we don't use for API response.
|
| 447 |
+
# The actual exercise type for tracking is handled by `exercise_session` object.
|
| 448 |
+
results = pose_estimator.estimate_pose(frame_for_estimation, 'squat')
|
| 449 |
+
|
| 450 |
+
if not results.pose_landmarks:
|
| 451 |
+
logger.info(f"Session {session_id}: No landmarks detected.")
|
| 452 |
+
# Return current state even if no new landmarks, or specific message
|
| 453 |
+
return jsonify({
|
| 454 |
+
'success': True,
|
| 455 |
+
'landmarks_detected': False,
|
| 456 |
+
'message': 'No landmarks detected in this frame.',
|
| 457 |
+
# Optionally, could return last known state from exercise_session if needed
|
| 458 |
+
})
|
| 459 |
+
|
| 460 |
+
exercise_data = None
|
| 461 |
+
if exercise_type == 'squat':
|
| 462 |
+
exercise_data = exercise_session.track_squat(results.pose_landmarks.landmark, frame_width, frame_height)
|
| 463 |
+
elif exercise_type == 'push_up':
|
| 464 |
+
exercise_data = exercise_session.track_push_up(results.pose_landmarks.landmark, frame_width, frame_height)
|
| 465 |
+
elif exercise_type == 'hammer_curl':
|
| 466 |
+
exercise_data = exercise_session.track_hammer_curl(results.pose_landmarks.landmark, frame_width, frame_height)
|
| 467 |
+
|
| 468 |
+
if exercise_data:
|
| 469 |
+
logger.debug(f"Session {session_id}: Exercise data: {exercise_data}")
|
| 470 |
+
return jsonify({'success': True, 'landmarks_detected': True, 'data': exercise_data})
|
| 471 |
+
else:
|
| 472 |
+
# Should not happen if exercise_type is valid and session initialized
|
| 473 |
+
logger.error(f"Session {session_id}: Could not get exercise_data for {exercise_type}")
|
| 474 |
+
return jsonify({'error': 'Failed to process exercise frame.'}), 500
|
| 475 |
+
|
| 476 |
+
except Exception as e:
|
| 477 |
+
session_id_log = data.get('session_id', 'unknown_session') if isinstance(data, dict) else 'unknown_session'
|
| 478 |
+
logger.error(f"API /api/track_exercise_stream Error for session {session_id_log}: {e}")
|
| 479 |
+
traceback.print_exc()
|
| 480 |
+
return jsonify({'error': f'Internal server error: {str(e)}'}), 500
|
| 481 |
+
|
| 482 |
+
@app.route('/api/end_exercise_session', methods=['POST'])
|
| 483 |
+
def end_exercise_session():
|
| 484 |
+
logger.info("API call to /api/end_exercise_session")
|
| 485 |
+
try:
|
| 486 |
+
data = request.json
|
| 487 |
+
if not data:
|
| 488 |
+
return jsonify({'error': 'No JSON data provided'}), 400
|
| 489 |
+
|
| 490 |
+
session_id = data.get('session_id')
|
| 491 |
+
if not session_id:
|
| 492 |
+
return jsonify({'error': 'Missing session_id'}), 400
|
| 493 |
+
|
| 494 |
+
if session_id in active_exercise_sessions:
|
| 495 |
+
del active_exercise_sessions[session_id]
|
| 496 |
+
logger.info(f"Ended and removed session: {session_id}")
|
| 497 |
+
return jsonify({'success': True, 'message': f'Session {session_id} ended.'})
|
| 498 |
+
else:
|
| 499 |
+
logger.warning(f"Attempted to end non-existent session: {session_id}")
|
| 500 |
+
return jsonify({'success': False, 'message': f'Session {session_id} not found.'}), 404
|
| 501 |
+
except Exception as e:
|
| 502 |
+
logger.error(f"API /api/end_exercise_session Error: {e}")
|
| 503 |
+
traceback.print_exc()
|
| 504 |
+
return jsonify({'error': f'Internal server error: {str(e)}'}), 500
|
| 505 |
+
|
| 506 |
+
# SocketIO Event Handlers
|
| 507 |
+
|
| 508 |
+
@socketio.on('connect')
|
| 509 |
+
def handle_connect():
|
| 510 |
+
logger.info(f"Client connected: {request.sid}")
|
| 511 |
+
socketio.emit('connection_ack', {'message': 'Connected to server', 'sid': request.sid}, room=request.sid)
|
| 512 |
+
|
| 513 |
+
@socketio.on('disconnect')
|
| 514 |
+
def handle_disconnect():
|
| 515 |
+
logger.info(f"Client disconnected: {request.sid}")
|
| 516 |
+
if request.sid in active_exercise_sessions:
|
| 517 |
+
del active_exercise_sessions[request.sid]
|
| 518 |
+
logger.info(f"Cleaned up exercise session for disconnected client {request.sid}")
|
| 519 |
+
|
| 520 |
+
@socketio.on('start_exercise_session')
|
| 521 |
+
def handle_start_exercise_session(data):
|
| 522 |
+
exercise_type = data.get('exercise_type')
|
| 523 |
+
logger.info(f"Attempting to start exercise session for {request.sid} with type: {exercise_type}")
|
| 524 |
+
|
| 525 |
+
if not exercise_type:
|
| 526 |
+
logger.warning(f"Session start for {request.sid} failed: no exercise_type provided.")
|
| 527 |
+
socketio.emit('session_error', {'error': 'exercise_type is required.'}, room=request.sid)
|
| 528 |
+
return
|
| 529 |
+
|
| 530 |
+
if len(active_exercise_sessions) >= MAX_SESSIONS and request.sid not in active_exercise_sessions:
|
| 531 |
+
logger.warning(f"Max sessions ({MAX_SESSIONS}) reached. Rejecting new session for {request.sid}.")
|
| 532 |
+
socketio.emit('session_error', {'error': 'Server busy, max sessions reached.'}, room=request.sid)
|
| 533 |
+
return
|
| 534 |
+
|
| 535 |
+
if request.sid in active_exercise_sessions:
|
| 536 |
+
logger.info(f"Session for {request.sid} already exists. Re-initializing for new exercise: {exercise_type}")
|
| 537 |
+
# Optionally, could end the previous exercise type cleanly if needed
|
| 538 |
+
# For now, just overwrite with the new one or ensure client manages this flow.
|
| 539 |
+
|
| 540 |
+
if exercise_type == 'squat':
|
| 541 |
+
active_exercise_sessions[request.sid] = {'exercise': Squat(), 'type': exercise_type}
|
| 542 |
+
elif exercise_type == 'push_up':
|
| 543 |
+
active_exercise_sessions[request.sid] = {'exercise': PushUp(), 'type': exercise_type}
|
| 544 |
+
elif exercise_type == 'hammer_curl':
|
| 545 |
+
active_exercise_sessions[request.sid] = {'exercise': HammerCurl(), 'type': exercise_type}
|
| 546 |
+
else:
|
| 547 |
+
logger.warning(f"Invalid exercise type: {exercise_type} for session {request.sid}")
|
| 548 |
+
socketio.emit('session_error', {'error': 'Invalid exercise_type'}, room=request.sid)
|
| 549 |
+
return
|
| 550 |
+
|
| 551 |
+
logger.info(f"Successfully created exercise session for {request.sid}, type: {exercise_type}")
|
| 552 |
+
socketio.emit('session_started', {'session_id': request.sid, 'exercise_type': exercise_type}, room=request.sid)
|
| 553 |
+
|
| 554 |
+
@socketio.on('process_frame')
|
| 555 |
+
def handle_process_frame(data):
|
| 556 |
+
# logger.debug(f"Received frame from {request.sid} for processing.") # Too verbose for every frame
|
| 557 |
+
if request.sid not in active_exercise_sessions:
|
| 558 |
+
logger.warning(f"Frame received from {request.sid} without an active session.")
|
| 559 |
+
socketio.emit('frame_error', {'error': 'No active session. Please start an exercise session first.'}, room=request.sid)
|
| 560 |
+
return
|
| 561 |
+
|
| 562 |
+
session_info = active_exercise_sessions.get(request.sid)
|
| 563 |
+
exercise_session = session_info['exercise']
|
| 564 |
+
session_exercise_type = session_info['type']
|
| 565 |
+
|
| 566 |
+
image_data_base64 = data.get('image')
|
| 567 |
+
frame_width = data.get('frame_width')
|
| 568 |
+
frame_height = data.get('frame_height')
|
| 569 |
+
# client_exercise_type = data.get('exercise_type') # Client sends this, ensure it matches session
|
| 570 |
+
|
| 571 |
+
# if client_exercise_type != session_exercise_type:
|
| 572 |
+
# logger.warning(f"Mismatched exercise type from client {request.sid}. Expected {session_exercise_type}, got {client_exercise_type}")
|
| 573 |
+
# socketio.emit('frame_error', {'error': f'Mismatched exercise type. Current session is for {session_exercise_type}.'}, room=request.sid)
|
| 574 |
+
# return
|
| 575 |
+
|
| 576 |
+
if not all([image_data_base64, frame_width, frame_height]):
|
| 577 |
+
logger.warning(f"Missing data in process_frame for {request.sid}: image, frame_width, or frame_height missing.")
|
| 578 |
+
socketio.emit('frame_error', {'error': 'Missing image, frame_width, or frame_height.'}, room=request.sid)
|
| 579 |
+
return
|
| 580 |
+
|
| 581 |
+
try:
|
| 582 |
+
missing_padding = len(image_data_base64) % 4
|
| 583 |
+
if missing_padding:
|
| 584 |
+
image_data_base64 += '=' * (4 - missing_padding)
|
| 585 |
+
|
| 586 |
+
image_bytes = base64.b64decode(image_data_base64)
|
| 587 |
+
pil_image = Image.open(io.BytesIO(image_bytes))
|
| 588 |
+
frame_for_estimation = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
|
| 589 |
+
except Exception as e:
|
| 590 |
+
logger.error(f"Error decoding base64 image for {request.sid}: {e}")
|
| 591 |
+
socketio.emit('frame_error', {'error': f'Invalid base64 image data: {str(e)}'}, room=request.sid)
|
| 592 |
+
return
|
| 593 |
+
|
| 594 |
+
pose_estimator = get_pose_estimator_api_instance()
|
| 595 |
+
# The 'squat' parameter to estimate_pose is a default for drawing, actual logic is in exercise_session
|
| 596 |
+
results = pose_estimator.estimate_pose(frame_for_estimation, session_exercise_type)
|
| 597 |
+
|
| 598 |
+
exercise_data_result = None
|
| 599 |
+
if results.pose_landmarks:
|
| 600 |
+
if session_exercise_type == 'squat':
|
| 601 |
+
exercise_data_result = exercise_session.track_squat(results.pose_landmarks.landmark, frame_width, frame_height)
|
| 602 |
+
elif session_exercise_type == 'push_up':
|
| 603 |
+
exercise_data_result = exercise_session.track_push_up(results.pose_landmarks.landmark, frame_width, frame_height)
|
| 604 |
+
elif session_exercise_type == 'hammer_curl':
|
| 605 |
+
exercise_data_result = exercise_session.track_hammer_curl(results.pose_landmarks.landmark, frame_width, frame_height)
|
| 606 |
+
|
| 607 |
+
if exercise_data_result:
|
| 608 |
+
# logger.debug(f"Exercise data for {request.sid}: {exercise_data_result}")
|
| 609 |
+
socketio.emit('exercise_update', {'success': True, 'landmarks_detected': True, 'data': exercise_data_result}, room=request.sid)
|
| 610 |
+
else:
|
| 611 |
+
# This case should ideally not be reached if landmarks are detected and exercise type is valid
|
| 612 |
+
logger.error(f"Could not get exercise data for {request.sid}, type {session_exercise_type}, despite landmarks.")
|
| 613 |
+
socketio.emit('exercise_update', {'success': False, 'landmarks_detected': True, 'message': 'Error processing landmarks.'}, room=request.sid)
|
| 614 |
+
else:
|
| 615 |
+
# logger.info(f"No landmarks detected for {request.sid}.")
|
| 616 |
+
socketio.emit('exercise_update', {'success': True, 'landmarks_detected': False, 'message': 'No landmarks detected.'}, room=request.sid)
|
| 617 |
+
|
| 618 |
+
except Exception as e:
|
| 619 |
+
logger.error(f"Error processing frame for {request.sid}: {e}")
|
| 620 |
+
traceback.print_exc()
|
| 621 |
+
socketio.emit('frame_error', {'error': f'Internal server error during frame processing: {str(e)}'}, room=request.sid)
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
if __name__ == '__main__':
|
| 625 |
+
try:
|
| 626 |
+
logger.info("Starting the Flask application on http://127.0.0.1:5000")
|
| 627 |
+
print("Starting Fitness Trainer app, please wait...")
|
| 628 |
+
print("Open http://127.0.0.1:5000 in your web browser when the server starts")
|
| 629 |
+
socketio.run(app, debug=True, host='0.0.0.0', port=5000)
|
| 630 |
+
except Exception as e:
|
| 631 |
+
logger.error(f"Failed to start application: {e}")
|
| 632 |
+
traceback.print_exc()
|
create_static_folders.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import shutil
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
def create_directory_structure():
|
| 6 |
+
"""Create the necessary directory structure for static files."""
|
| 7 |
+
base_dir = Path(__file__).parent
|
| 8 |
+
|
| 9 |
+
# Create static directories
|
| 10 |
+
static_dir = base_dir / 'static'
|
| 11 |
+
css_dir = static_dir / 'css'
|
| 12 |
+
js_dir = static_dir / 'js'
|
| 13 |
+
images_dir = static_dir / 'images'
|
| 14 |
+
|
| 15 |
+
# Create directories if they don't exist
|
| 16 |
+
for directory in [static_dir, css_dir, js_dir, images_dir]:
|
| 17 |
+
directory.mkdir(exist_ok=True)
|
| 18 |
+
print(f"Created directory: {directory}")
|
| 19 |
+
|
| 20 |
+
# Create sample images if they don't exist
|
| 21 |
+
create_placeholder_image(images_dir / 'squat.png', "Squat")
|
| 22 |
+
create_placeholder_image(images_dir / 'push_up.png', "Push Up")
|
| 23 |
+
create_placeholder_image(images_dir / 'hammer_curl.png', "Hammer Curl")
|
| 24 |
+
|
| 25 |
+
print("\nDirectory structure created successfully!")
|
| 26 |
+
print(f"Static files should be placed in: {static_dir}")
|
| 27 |
+
print("Make sure the following files exist:")
|
| 28 |
+
print(f" - {images_dir / 'squat.png'}")
|
| 29 |
+
print(f" - {images_dir / 'push_up.png'}")
|
| 30 |
+
print(f" - {images_dir / 'hammer_curl.png'}")
|
| 31 |
+
|
| 32 |
+
def create_placeholder_image(filepath, text="Exercise"):
|
| 33 |
+
"""Create a simple placeholder image using PIL."""
|
| 34 |
+
try:
|
| 35 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 36 |
+
|
| 37 |
+
# Create a blank image with a colored background
|
| 38 |
+
img = Image.new('RGB', (200, 200), color=(73, 109, 137))
|
| 39 |
+
d = ImageDraw.Draw(img)
|
| 40 |
+
|
| 41 |
+
# Try to use a default font
|
| 42 |
+
try:
|
| 43 |
+
font = ImageFont.truetype("arial.ttf", 18)
|
| 44 |
+
except IOError:
|
| 45 |
+
font = ImageFont.load_default()
|
| 46 |
+
|
| 47 |
+
# Draw text in the center of the image
|
| 48 |
+
d.text((100, 100), text, fill=(255, 255, 255), anchor="mm", font=font)
|
| 49 |
+
|
| 50 |
+
# Save the image
|
| 51 |
+
img.save(filepath)
|
| 52 |
+
print(f"Created placeholder image: {filepath}")
|
| 53 |
+
|
| 54 |
+
except ImportError:
|
| 55 |
+
# If PIL is not available, create an empty file
|
| 56 |
+
print("PIL not installed. Installing empty image files.")
|
| 57 |
+
with open(filepath, 'wb') as f:
|
| 58 |
+
f.write(b'')
|
| 59 |
+
print(f"Created empty file: {filepath}")
|
| 60 |
+
|
| 61 |
+
if __name__ == "__main__":
|
| 62 |
+
create_directory_structure()
|
| 63 |
+
print("\nRun 'pip install pillow' if you want to generate proper placeholder images.")
|
db/__init__.py
ADDED
|
File without changes
|
db/__pycache__/workout_logger.cpython-311.pyc
ADDED
|
Binary file (221 Bytes). View file
|
|
|
db/__pycache__/workout_logger.cpython-312.pyc
ADDED
|
Binary file (170 Bytes). View file
|
|
|
db/__pycache__/workout_logger.cpython-39.pyc
ADDED
|
Binary file (188 Bytes). View file
|
|
|
db/workout_logger.py
ADDED
|
File without changes
|
exercises/__init__.py
ADDED
|
File without changes
|
exercises/__pycache__/hammer_curl.cpython-311.pyc
ADDED
|
Binary file (7.86 kB). View file
|
|
|
exercises/__pycache__/hammer_curl.cpython-312.pyc
ADDED
|
Binary file (7.52 kB). View file
|
|
|
exercises/__pycache__/hammer_curl.cpython-39.pyc
ADDED
|
Binary file (3.8 kB). View file
|
|
|
exercises/__pycache__/push_up.cpython-311.pyc
ADDED
|
Binary file (5.83 kB). View file
|
|
|
exercises/__pycache__/push_up.cpython-312.pyc
ADDED
|
Binary file (5.49 kB). View file
|
|
|
exercises/__pycache__/push_up.cpython-39.pyc
ADDED
|
Binary file (2.86 kB). View file
|
|
|
exercises/__pycache__/squat.cpython-311.pyc
ADDED
|
Binary file (5.22 kB). View file
|
|
|
exercises/__pycache__/squat.cpython-312.pyc
ADDED
|
Binary file (4.84 kB). View file
|
|
|
exercises/__pycache__/squat.cpython-39.pyc
ADDED
|
Binary file (2.52 kB). View file
|
|
|
exercises/hammer_curl.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import numpy as np
|
| 3 |
+
from pose_estimation.angle_calculation import calculate_angle
|
| 4 |
+
from voice_feedback.feedback import provide_hammer_curl_feedback , speak
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class HammerCurl:
|
| 8 |
+
def __init__(self):
|
| 9 |
+
self.counter_right = 0
|
| 10 |
+
self.counter_left = 0
|
| 11 |
+
self.stage_right = None # 'up' or 'down' for right arm
|
| 12 |
+
self.stage_left = None # 'up' or 'down' for left arm
|
| 13 |
+
|
| 14 |
+
self.angle_threshold = 40 # Angle threshold for misalignment
|
| 15 |
+
self.flexion_angle_up = 155 # Flexion angle for 'up' stage
|
| 16 |
+
self.flexion_angle_down = 35 # Flexion angle for 'down' stage
|
| 17 |
+
|
| 18 |
+
self.angle_threshold_up = 155 # Upper threshold for 'up' stage
|
| 19 |
+
self.angle_threshold_down = 47 # Lower threshold for 'down' stage
|
| 20 |
+
|
| 21 |
+
def calculate_shoulder_elbow_hip_angle(self, shoulder, elbow, hip):
|
| 22 |
+
"""Calculate the angle between shoulder, elbow, and hip."""
|
| 23 |
+
return calculate_angle(elbow, shoulder, hip)
|
| 24 |
+
|
| 25 |
+
def calculate_shoulder_elbow_wrist(self, shoulder, elbow, wrist):
|
| 26 |
+
"""Calculate the angle between shoulder, elbow, and wrist."""
|
| 27 |
+
return calculate_angle(shoulder, elbow, wrist)
|
| 28 |
+
|
| 29 |
+
def track_hammer_curl(self, landmarks, frame):
|
| 30 |
+
# Right arm landmarks (shoulder, elbow, hip, wrist)
|
| 31 |
+
shoulder_right = [int(landmarks[11].x * frame.shape[1]), int(landmarks[11].y * frame.shape[0])]
|
| 32 |
+
elbow_right = [int(landmarks[13].x * frame.shape[1]), int(landmarks[13].y * frame.shape[0])]
|
| 33 |
+
hip_right = [int(landmarks[23].x * frame.shape[1]), int(landmarks[23].y * frame.shape[0])]
|
| 34 |
+
wrist_right = [int(landmarks[15].x * frame.shape[1]), int(landmarks[15].y * frame.shape[0])]
|
| 35 |
+
|
| 36 |
+
# Left arm landmarks (shoulder, elbow, hip, wrist)
|
| 37 |
+
shoulder_left = [int(landmarks[12].x * frame.shape[1]), int(landmarks[12].y * frame.shape[0])]
|
| 38 |
+
elbow_left = [int(landmarks[14].x * frame.shape[1]), int(landmarks[14].y * frame.shape[0])]
|
| 39 |
+
hip_left = [int(landmarks[24].x * frame.shape[1]), int(landmarks[24].y * frame.shape[0])]
|
| 40 |
+
wrist_left = [int(landmarks[16].x * frame.shape[1]), int(landmarks[16].y * frame.shape[0])]
|
| 41 |
+
|
| 42 |
+
# Calculate the angle for counting (elbow flexion angle)
|
| 43 |
+
angle_right_counter = self.calculate_shoulder_elbow_wrist(shoulder_right, elbow_right, wrist_right)
|
| 44 |
+
angle_left_counter = self.calculate_shoulder_elbow_wrist(shoulder_left, elbow_left, wrist_left)
|
| 45 |
+
|
| 46 |
+
# Calculate the angle for the right arm (shoulder, elbow, hip)
|
| 47 |
+
angle_right = self.calculate_shoulder_elbow_hip_angle(shoulder_right, elbow_right, hip_right)
|
| 48 |
+
|
| 49 |
+
# Calculate the angle for the left arm (shoulder, elbow, hip)
|
| 50 |
+
angle_left = self.calculate_shoulder_elbow_hip_angle(shoulder_left, elbow_left, hip_left)
|
| 51 |
+
|
| 52 |
+
# Draw lines with improved style
|
| 53 |
+
self.draw_line_with_style(frame, shoulder_left, elbow_left, (0, 0, 255), 4)
|
| 54 |
+
self.draw_line_with_style(frame, elbow_left, wrist_left, (0, 0, 255), 4)
|
| 55 |
+
|
| 56 |
+
self.draw_line_with_style(frame, shoulder_right, elbow_right, (0, 0, 255), 4)
|
| 57 |
+
self.draw_line_with_style(frame, elbow_right, wrist_right, (0, 0, 255), 4)
|
| 58 |
+
|
| 59 |
+
# Add circles to highlight key points
|
| 60 |
+
self.draw_circle(frame, shoulder_left, (0, 0, 255), 8)
|
| 61 |
+
self.draw_circle(frame, elbow_left, (0, 0, 255), 8)
|
| 62 |
+
self.draw_circle(frame, wrist_left, (0, 0, 255), 8)
|
| 63 |
+
|
| 64 |
+
self.draw_circle(frame, shoulder_right, (0, 0, 255), 8)
|
| 65 |
+
self.draw_circle(frame, elbow_right, (0, 0, 255), 8)
|
| 66 |
+
self.draw_circle(frame, wrist_right, (0, 0, 255), 8)
|
| 67 |
+
|
| 68 |
+
# Convert the angles to integers and update the text positions
|
| 69 |
+
angle_text_position_left = (elbow_left[0] + 10, elbow_left[1] - 10)
|
| 70 |
+
cv2.putText(frame, f'Angle: {int(angle_left_counter)}', angle_text_position_left, cv2.FONT_HERSHEY_SIMPLEX, 0.5,
|
| 71 |
+
(255, 255, 255), 2)
|
| 72 |
+
|
| 73 |
+
angle_text_position_right = (elbow_right[0] + 10, elbow_right[1] - 10)
|
| 74 |
+
cv2.putText(frame, f'Angle: {int(angle_right_counter)}', angle_text_position_right, cv2.FONT_HERSHEY_SIMPLEX,
|
| 75 |
+
0.5,
|
| 76 |
+
(255, 255, 255), 2)
|
| 77 |
+
|
| 78 |
+
warning_message_right = None
|
| 79 |
+
warning_message_left = None
|
| 80 |
+
|
| 81 |
+
# Check for misalignment based on shoulder-elbow-hip angle
|
| 82 |
+
if abs(angle_right) > self.angle_threshold:
|
| 83 |
+
warning_message_right = f"Right Shoulder-Elbow-Hip Misalignment! Angle: {angle_right:.2f}°"
|
| 84 |
+
if abs(angle_left) > self.angle_threshold:
|
| 85 |
+
warning_message_left = f"Left Shoulder-Elbow-Hip Misalignment! Angle: {angle_left:.2f}°"
|
| 86 |
+
|
| 87 |
+
if angle_right_counter > self.angle_threshold_up:
|
| 88 |
+
self.stage_right = "Flex"
|
| 89 |
+
elif self.angle_threshold_down < angle_right_counter < self.angle_threshold_up and self.stage_right == "Flex":
|
| 90 |
+
self.stage_right = "Up"
|
| 91 |
+
elif angle_right_counter < self.angle_threshold_down and self.stage_right=="Up":
|
| 92 |
+
self.stage_right = "Down"
|
| 93 |
+
self.counter_right +=1
|
| 94 |
+
|
| 95 |
+
if angle_left_counter > self.angle_threshold_up:
|
| 96 |
+
self.stage_left = "Flex"
|
| 97 |
+
elif self.angle_threshold_down < angle_left_counter < self.angle_threshold_up and self.stage_left == "Flex":
|
| 98 |
+
self.stage_left = "Up"
|
| 99 |
+
elif angle_left_counter < self.angle_threshold_down and self.stage_left == "Up":
|
| 100 |
+
self.stage_left = "Down"
|
| 101 |
+
self.counter_left +=1
|
| 102 |
+
|
| 103 |
+
# Progress percentages: 1 for "up", 0 for "down"
|
| 104 |
+
progress_right = 1 if self.stage_right == "up" else 0
|
| 105 |
+
progress_left = 1 if self.stage_left == "up" else 0
|
| 106 |
+
|
| 107 |
+
return self.counter_right, angle_right_counter, self.counter_left, angle_left_counter, warning_message_right, warning_message_left, progress_right, progress_left, self.stage_right, self.stage_left
|
| 108 |
+
|
| 109 |
+
def draw_line_with_style(self, frame, start_point, end_point, color, thickness):
|
| 110 |
+
cv2.line(frame, start_point, end_point, color, thickness, lineType=cv2.LINE_AA)
|
| 111 |
+
|
| 112 |
+
def draw_circle(self, frame, center, color, radius):
|
| 113 |
+
"""Draw a circle with specified style."""
|
| 114 |
+
cv2.circle(frame, center, radius, color, -1) # -1 to fill the circle
|
exercises/push_up.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import mediapipe as mp
|
| 2 |
+
import time
|
| 3 |
+
from pose_estimation.angle_calculation import calculate_angle
|
| 4 |
+
|
| 5 |
+
class PushUp:
|
| 6 |
+
def __init__(self):
|
| 7 |
+
self.counter = 0
|
| 8 |
+
self.stage = "up" # Changed from "Initial"
|
| 9 |
+
self.angle_threshold_up = 150
|
| 10 |
+
self.angle_threshold_down = 70
|
| 11 |
+
self.last_counter_update = time.time()
|
| 12 |
+
self.mp_pose = mp.solutions.pose # Added
|
| 13 |
+
|
| 14 |
+
def calculate_shoulder_elbow_wrist_angle(self, shoulder, elbow, wrist):
|
| 15 |
+
"""Calculate the angle between shoulder, elbow, and wrist."""
|
| 16 |
+
return calculate_angle(shoulder, elbow, wrist)
|
| 17 |
+
|
| 18 |
+
def track_push_up(self, landmarks_mp, frame_width, frame_height):
|
| 19 |
+
lm = landmarks_mp # shortcut
|
| 20 |
+
|
| 21 |
+
# Left side landmarks
|
| 22 |
+
shoulder_left = [int(lm[self.mp_pose.PoseLandmark.LEFT_SHOULDER.value].x * frame_width),
|
| 23 |
+
int(lm[self.mp_pose.PoseLandmark.LEFT_SHOULDER.value].y * frame_height)]
|
| 24 |
+
elbow_left = [int(lm[self.mp_pose.PoseLandmark.LEFT_ELBOW.value].x * frame_width),
|
| 25 |
+
int(lm[self.mp_pose.PoseLandmark.LEFT_ELBOW.value].y * frame_height)]
|
| 26 |
+
wrist_left = [int(lm[self.mp_pose.PoseLandmark.LEFT_WRIST.value].x * frame_width),
|
| 27 |
+
int(lm[self.mp_pose.PoseLandmark.LEFT_WRIST.value].y * frame_height)]
|
| 28 |
+
|
| 29 |
+
# Right side landmarks
|
| 30 |
+
shoulder_right = [int(lm[self.mp_pose.PoseLandmark.RIGHT_SHOULDER.value].x * frame_width),
|
| 31 |
+
int(lm[self.mp_pose.PoseLandmark.RIGHT_SHOULDER.value].y * frame_height)]
|
| 32 |
+
elbow_right = [int(lm[self.mp_pose.PoseLandmark.RIGHT_ELBOW.value].x * frame_width),
|
| 33 |
+
int(lm[self.mp_pose.PoseLandmark.RIGHT_ELBOW.value].y * frame_height)]
|
| 34 |
+
wrist_right = [int(lm[self.mp_pose.PoseLandmark.RIGHT_WRIST.value].x * frame_width),
|
| 35 |
+
int(lm[self.mp_pose.PoseLandmark.RIGHT_WRIST.value].y * frame_height)]
|
| 36 |
+
|
| 37 |
+
# Calculate angles
|
| 38 |
+
angle_left = self.calculate_shoulder_elbow_wrist_angle(shoulder_left, elbow_left, wrist_left)
|
| 39 |
+
angle_right = self.calculate_shoulder_elbow_wrist_angle(shoulder_right, elbow_right, wrist_right)
|
| 40 |
+
|
| 41 |
+
# Stage and Counter Logic
|
| 42 |
+
current_angle_for_logic = angle_left
|
| 43 |
+
current_time = time.time()
|
| 44 |
+
|
| 45 |
+
if current_angle_for_logic > self.angle_threshold_up:
|
| 46 |
+
self.stage = "up"
|
| 47 |
+
elif self.angle_threshold_down < current_angle_for_logic < self.angle_threshold_up and self.stage == "up":
|
| 48 |
+
self.stage = "down"
|
| 49 |
+
elif current_angle_for_logic < self.angle_threshold_down and self.stage == "down":
|
| 50 |
+
if current_time - self.last_counter_update > 1: # 1 second debounce
|
| 51 |
+
self.counter += 1
|
| 52 |
+
self.last_counter_update = current_time
|
| 53 |
+
self.stage = "up" # Transition back to up
|
| 54 |
+
|
| 55 |
+
feedback = self._get_push_up_feedback(angle_left, angle_right, self.stage)
|
| 56 |
+
|
| 57 |
+
return {
|
| 58 |
+
"counter": self.counter,
|
| 59 |
+
"stage": self.stage,
|
| 60 |
+
"angle_left": angle_left,
|
| 61 |
+
"angle_right": angle_right,
|
| 62 |
+
"feedback": feedback
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
def _get_push_up_feedback(self, angle_left, angle_right, stage):
|
| 66 |
+
feedback = "Keep going!" # Default
|
| 67 |
+
|
| 68 |
+
if stage == "down":
|
| 69 |
+
if min(angle_left, angle_right) < self.angle_threshold_down - 5:
|
| 70 |
+
feedback = "Good depth!"
|
| 71 |
+
elif min(angle_left, angle_right) > self.angle_threshold_down + 10:
|
| 72 |
+
feedback = "Go lower."
|
| 73 |
+
elif stage == "up":
|
| 74 |
+
feedback = "Push up!" # Or "Ready"
|
| 75 |
+
|
| 76 |
+
if abs(angle_left - angle_right) > 25:
|
| 77 |
+
feedback += " Try to keep your push-up even." if feedback != "Keep going!" else "Try to keep your push-up even."
|
| 78 |
+
|
| 79 |
+
return feedback.strip()
|
| 80 |
+
|
| 81 |
+
def get_drawing_annotations(self, landmarks_mp, frame_width, frame_height, exercise_data_dict):
|
| 82 |
+
annotations = []
|
| 83 |
+
lm = landmarks_mp # shortcut
|
| 84 |
+
|
| 85 |
+
# Pixel coordinates
|
| 86 |
+
shoulder_left = [int(lm[self.mp_pose.PoseLandmark.LEFT_SHOULDER.value].x * frame_width),
|
| 87 |
+
int(lm[self.mp_pose.PoseLandmark.LEFT_SHOULDER.value].y * frame_height)]
|
| 88 |
+
elbow_left = [int(lm[self.mp_pose.PoseLandmark.LEFT_ELBOW.value].x * frame_width),
|
| 89 |
+
int(lm[self.mp_pose.PoseLandmark.LEFT_ELBOW.value].y * frame_height)]
|
| 90 |
+
wrist_left = [int(lm[self.mp_pose.PoseLandmark.LEFT_WRIST.value].x * frame_width),
|
| 91 |
+
int(lm[self.mp_pose.PoseLandmark.LEFT_WRIST.value].y * frame_height)]
|
| 92 |
+
|
| 93 |
+
shoulder_right = [int(lm[self.mp_pose.PoseLandmark.RIGHT_SHOULDER.value].x * frame_width),
|
| 94 |
+
int(lm[self.mp_pose.PoseLandmark.RIGHT_SHOULDER.value].y * frame_height)]
|
| 95 |
+
elbow_right = [int(lm[self.mp_pose.PoseLandmark.RIGHT_ELBOW.value].x * frame_width),
|
| 96 |
+
int(lm[self.mp_pose.PoseLandmark.RIGHT_ELBOW.value].y * frame_height)]
|
| 97 |
+
wrist_right = [int(lm[self.mp_pose.PoseLandmark.RIGHT_WRIST.value].x * frame_width),
|
| 98 |
+
int(lm[self.mp_pose.PoseLandmark.RIGHT_WRIST.value].y * frame_height)]
|
| 99 |
+
|
| 100 |
+
# Lines (original colors: left (0,0,255) -> BGR [255,0,0], right (102,0,0) -> BGR [0,0,102])
|
| 101 |
+
annotations.append({"type": "line", "start_point": shoulder_left, "end_point": elbow_left, "color_bgr": [255, 0, 0], "thickness": 2})
|
| 102 |
+
annotations.append({"type": "line", "start_point": elbow_left, "end_point": wrist_left, "color_bgr": [255, 0, 0], "thickness": 2})
|
| 103 |
+
annotations.append({"type": "line", "start_point": shoulder_right, "end_point": elbow_right, "color_bgr": [0, 0, 102], "thickness": 2})
|
| 104 |
+
annotations.append({"type": "line", "start_point": elbow_right, "end_point": wrist_right, "color_bgr": [0, 0, 102], "thickness": 2})
|
| 105 |
+
|
| 106 |
+
# Circles
|
| 107 |
+
annotations.append({"type": "circle", "center_point": shoulder_left, "radius": 8, "color_bgr": [255, 0, 0], "filled": True})
|
| 108 |
+
annotations.append({"type": "circle", "center_point": elbow_left, "radius": 8, "color_bgr": [255, 0, 0], "filled": True})
|
| 109 |
+
annotations.append({"type": "circle", "center_point": wrist_left, "radius": 8, "color_bgr": [255, 0, 0], "filled": True})
|
| 110 |
+
annotations.append({"type": "circle", "center_point": shoulder_right, "radius": 8, "color_bgr": [0, 0, 102], "filled": True})
|
| 111 |
+
annotations.append({"type": "circle", "center_point": elbow_right, "radius": 8, "color_bgr": [0, 0, 102], "filled": True})
|
| 112 |
+
annotations.append({"type": "circle", "center_point": wrist_right, "radius": 8, "color_bgr": [0, 0, 102], "filled": True})
|
| 113 |
+
|
| 114 |
+
# Text for angles
|
| 115 |
+
if 'angle_left' in exercise_data_dict:
|
| 116 |
+
annotations.append({"type": "text", "text_content": f"Angle L: {int(exercise_data_dict['angle_left'])}",
|
| 117 |
+
"position": [elbow_left[0] + 10, elbow_left[1] - 10],
|
| 118 |
+
"font_scale": 0.5, "color_bgr": [255, 255, 255], "thickness": 2})
|
| 119 |
+
if 'angle_right' in exercise_data_dict:
|
| 120 |
+
annotations.append({"type": "text", "text_content": f"Angle R: {int(exercise_data_dict['angle_right'])}",
|
| 121 |
+
"position": [elbow_right[0] + 10, elbow_right[1] - 10],
|
| 122 |
+
"font_scale": 0.5, "color_bgr": [255, 255, 255], "thickness": 2})
|
| 123 |
+
|
| 124 |
+
# Display main feedback from exercise_data_dict
|
| 125 |
+
if 'feedback' in exercise_data_dict:
|
| 126 |
+
annotations.append({"type": "text", "text_content": exercise_data_dict['feedback'],
|
| 127 |
+
"position": [frame_width // 2 - 150, frame_height - 40], # Adjusted for longer text
|
| 128 |
+
"font_scale": 0.7, "color_bgr": [0, 255, 0], "thickness": 2}) # Green for feedback
|
| 129 |
+
|
| 130 |
+
return annotations
|
exercises/squat.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import mediapipe as mp
|
| 2 |
+
from pose_estimation.angle_calculation import calculate_angle
|
| 3 |
+
|
| 4 |
+
class Squat:
|
| 5 |
+
def __init__(self):
|
| 6 |
+
self.counter = 0
|
| 7 |
+
self.stage = "up" # Initial stage
|
| 8 |
+
self.mp_pose = mp.solutions.pose # Added for convenience
|
| 9 |
+
|
| 10 |
+
def calculate_angle(self, point1, point2, point3): # Assuming these are pixel coordinates
|
| 11 |
+
return calculate_angle(point1, point2, point3)
|
| 12 |
+
|
| 13 |
+
def track_squat(self, landmarks_mp, frame_width, frame_height):
|
| 14 |
+
lm = landmarks_mp # shortcut
|
| 15 |
+
|
| 16 |
+
# Left side landmarks
|
| 17 |
+
shoulder_left = [int(lm[self.mp_pose.PoseLandmark.LEFT_SHOULDER.value].x * frame_width),
|
| 18 |
+
int(lm[self.mp_pose.PoseLandmark.LEFT_SHOULDER.value].y * frame_height)]
|
| 19 |
+
hip_left = [int(lm[self.mp_pose.PoseLandmark.LEFT_HIP.value].x * frame_width),
|
| 20 |
+
int(lm[self.mp_pose.PoseLandmark.LEFT_HIP.value].y * frame_height)]
|
| 21 |
+
knee_left = [int(lm[self.mp_pose.PoseLandmark.LEFT_KNEE.value].x * frame_width),
|
| 22 |
+
int(lm[self.mp_pose.PoseLandmark.LEFT_KNEE.value].y * frame_height)]
|
| 23 |
+
# ankle_left = [int(lm[self.mp_pose.PoseLandmark.LEFT_ANKLE.value].x * frame_width),
|
| 24 |
+
# int(lm[self.mp_pose.PoseLandmark.LEFT_ANKLE.value].y * frame_height)]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# Right side landmarks
|
| 28 |
+
shoulder_right = [int(lm[self.mp_pose.PoseLandmark.RIGHT_SHOULDER.value].x * frame_width),
|
| 29 |
+
int(lm[self.mp_pose.PoseLandmark.RIGHT_SHOULDER.value].y * frame_height)]
|
| 30 |
+
hip_right = [int(lm[self.mp_pose.PoseLandmark.RIGHT_HIP.value].x * frame_width),
|
| 31 |
+
int(lm[self.mp_pose.PoseLandmark.RIGHT_HIP.value].y * frame_height)]
|
| 32 |
+
knee_right = [int(lm[self.mp_pose.PoseLandmark.RIGHT_KNEE.value].x * frame_width),
|
| 33 |
+
int(lm[self.mp_pose.PoseLandmark.RIGHT_KNEE.value].y * frame_height)]
|
| 34 |
+
# ankle_right = [int(lm[self.mp_pose.PoseLandmark.RIGHT_ANKLE.value].x * frame_width),
|
| 35 |
+
# int(lm[self.mp_pose.PoseLandmark.RIGHT_ANKLE.value].y * frame_height)]
|
| 36 |
+
|
| 37 |
+
# Calculate angles
|
| 38 |
+
angle_left = self.calculate_angle(shoulder_left, hip_left, knee_left)
|
| 39 |
+
angle_right = self.calculate_angle(shoulder_right, hip_right, knee_right)
|
| 40 |
+
|
| 41 |
+
# Stage and counter logic (using angle_left for primary logic)
|
| 42 |
+
current_angle_for_logic = angle_left
|
| 43 |
+
if current_angle_for_logic > 170:
|
| 44 |
+
self.stage = "up"
|
| 45 |
+
elif 90 < current_angle_for_logic < 170 and self.stage == "up":
|
| 46 |
+
self.stage = "down"
|
| 47 |
+
elif current_angle_for_logic < 90 and self.stage == "down":
|
| 48 |
+
self.stage = "up"
|
| 49 |
+
self.counter += 1
|
| 50 |
+
|
| 51 |
+
feedback_message = self._get_squat_feedback(angle_left, angle_right, self.stage,
|
| 52 |
+
knee_left, hip_left, shoulder_left,
|
| 53 |
+
knee_right, hip_right, shoulder_right)
|
| 54 |
+
|
| 55 |
+
return {
|
| 56 |
+
"counter": self.counter,
|
| 57 |
+
"stage": self.stage,
|
| 58 |
+
"angle_left": angle_left,
|
| 59 |
+
"angle_right": angle_right,
|
| 60 |
+
"feedback": feedback_message
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
def _get_squat_feedback(self, angle_left, angle_right, stage,
|
| 64 |
+
knee_left, hip_left, shoulder_left,
|
| 65 |
+
knee_right, hip_right, shoulder_right): # Added points for future use
|
| 66 |
+
feedback = "Keep going." # Default feedback
|
| 67 |
+
|
| 68 |
+
if stage == "down":
|
| 69 |
+
if min(angle_left, angle_right) < 80:
|
| 70 |
+
feedback = "Good depth!"
|
| 71 |
+
elif min(angle_left, angle_right) > 100: # Knees should be more bent
|
| 72 |
+
feedback = "Go lower."
|
| 73 |
+
|
| 74 |
+
if abs(angle_left - angle_right) > 20: # Check for uneven squat
|
| 75 |
+
# Adding a check to see if there's significant movement, e.g., not in "up" stage fully extended
|
| 76 |
+
if not (stage == "up" and min(angle_left, angle_right) > 160): # Avoid this message if standing straight
|
| 77 |
+
feedback += " Try to keep your squat even." if feedback != "Keep going." else "Try to keep your squat even."
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# Placeholder for more advanced feedback using the passed points:
|
| 81 |
+
# E.g., Knee valgus: check if knee_left.x < hip_left.x and knee_left.x > shoulder_left.x (simplified)
|
| 82 |
+
# E.g., Back posture: calculate angle shoulder-hip-ankle (requires ankle points)
|
| 83 |
+
|
| 84 |
+
return feedback.strip()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def get_drawing_annotations(self, landmarks_mp, frame_width, frame_height, exercise_data_dict):
|
| 88 |
+
annotations = []
|
| 89 |
+
lm = landmarks_mp # shortcut
|
| 90 |
+
|
| 91 |
+
# Re-calculate or retrieve necessary points (pixel coordinates)
|
| 92 |
+
# For simplicity, re-calculating here. Could be optimized by passing from track_squat.
|
| 93 |
+
shoulder_left = [int(lm[self.mp_pose.PoseLandmark.LEFT_SHOULDER.value].x * frame_width),
|
| 94 |
+
int(lm[self.mp_pose.PoseLandmark.LEFT_SHOULDER.value].y * frame_height)]
|
| 95 |
+
hip_left = [int(lm[self.mp_pose.PoseLandmark.LEFT_HIP.value].x * frame_width),
|
| 96 |
+
int(lm[self.mp_pose.PoseLandmark.LEFT_HIP.value].y * frame_height)]
|
| 97 |
+
knee_left = [int(lm[self.mp_pose.PoseLandmark.LEFT_KNEE.value].x * frame_width),
|
| 98 |
+
int(lm[self.mp_pose.PoseLandmark.LEFT_KNEE.value].y * frame_height)]
|
| 99 |
+
|
| 100 |
+
shoulder_right = [int(lm[self.mp_pose.PoseLandmark.RIGHT_SHOULDER.value].x * frame_width),
|
| 101 |
+
int(lm[self.mp_pose.PoseLandmark.RIGHT_SHOULDER.value].y * frame_height)]
|
| 102 |
+
hip_right = [int(lm[self.mp_pose.PoseLandmark.RIGHT_HIP.value].x * frame_width),
|
| 103 |
+
int(lm[self.mp_pose.PoseLandmark.RIGHT_HIP.value].y * frame_height)]
|
| 104 |
+
knee_right = [int(lm[self.mp_pose.PoseLandmark.RIGHT_KNEE.value].x * frame_width),
|
| 105 |
+
int(lm[self.mp_pose.PoseLandmark.RIGHT_KNEE.value].y * frame_height)]
|
| 106 |
+
|
| 107 |
+
# Lines for left side (original color: (178, 102, 255) -> BGR: [255, 102, 178])
|
| 108 |
+
annotations.append({"type": "line", "start_point": shoulder_left, "end_point": hip_left, "color_bgr": [255, 102, 178], "thickness": 2})
|
| 109 |
+
annotations.append({"type": "line", "start_point": hip_left, "end_point": knee_left, "color_bgr": [255, 102, 178], "thickness": 2})
|
| 110 |
+
|
| 111 |
+
# Lines for right side (original color: (51, 153, 255) -> BGR: [255, 153, 51])
|
| 112 |
+
annotations.append({"type": "line", "start_point": shoulder_right, "end_point": hip_right, "color_bgr": [255, 153, 51], "thickness": 2})
|
| 113 |
+
annotations.append({"type": "line", "start_point": hip_right, "end_point": knee_right, "color_bgr": [255, 153, 51], "thickness": 2})
|
| 114 |
+
|
| 115 |
+
# Circles for left side
|
| 116 |
+
annotations.append({"type": "circle", "center_point": shoulder_left, "radius": 8, "color_bgr": [255, 102, 178], "filled": True})
|
| 117 |
+
annotations.append({"type": "circle", "center_point": hip_left, "radius": 8, "color_bgr": [255, 102, 178], "filled": True})
|
| 118 |
+
annotations.append({"type": "circle", "center_point": knee_left, "radius": 8, "color_bgr": [255, 102, 178], "filled": True})
|
| 119 |
+
|
| 120 |
+
# Circles for right side
|
| 121 |
+
annotations.append({"type": "circle", "center_point": shoulder_right, "radius": 8, "color_bgr": [255, 153, 51], "filled": True})
|
| 122 |
+
annotations.append({"type": "circle", "center_point": hip_right, "radius": 8, "color_bgr": [255, 153, 51], "filled": True})
|
| 123 |
+
annotations.append({"type": "circle", "center_point": knee_right, "radius": 8, "color_bgr": [255, 153, 51], "filled": True})
|
| 124 |
+
|
| 125 |
+
# Text for angles
|
| 126 |
+
if 'angle_left' in exercise_data_dict:
|
| 127 |
+
annotations.append({"type": "text", "text_content": f"Angle L: {int(exercise_data_dict['angle_left'])}",
|
| 128 |
+
"position": [knee_left[0] + 10, knee_left[1] - 10],
|
| 129 |
+
"font_scale": 0.5, "color_bgr": [255, 255, 255], "thickness": 2})
|
| 130 |
+
if 'angle_right' in exercise_data_dict:
|
| 131 |
+
annotations.append({"type": "text", "text_content": f"Angle R: {int(exercise_data_dict['angle_right'])}",
|
| 132 |
+
"position": [knee_right[0] + 10, knee_right[1] - 10],
|
| 133 |
+
"font_scale": 0.5, "color_bgr": [255, 255, 255], "thickness": 2})
|
| 134 |
+
|
| 135 |
+
# Display main feedback from exercise_data_dict
|
| 136 |
+
if 'feedback' in exercise_data_dict:
|
| 137 |
+
annotations.append({"type": "text", "text_content": exercise_data_dict['feedback'],
|
| 138 |
+
"position": [frame_width // 2 - 100, frame_height - 40], # Centered at bottom
|
| 139 |
+
"font_scale": 0.7, "color_bgr": [0, 0, 255], "thickness": 2})
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
return annotations
|
feedback/__init__.py
ADDED
|
File without changes
|
feedback/__pycache__/indicators.cpython-311.pyc
ADDED
|
Binary file (2.96 kB). View file
|
|
|
feedback/__pycache__/indicators.cpython-312.pyc
ADDED
|
Binary file (2.52 kB). View file
|
|
|
feedback/__pycache__/indicators.cpython-39.pyc
ADDED
|
Binary file (1.93 kB). View file
|
|
|
feedback/__pycache__/information.cpython-311.pyc
ADDED
|
Binary file (1.14 kB). View file
|
|
|
feedback/__pycache__/information.cpython-312.pyc
ADDED
|
Binary file (1.04 kB). View file
|
|
|
feedback/__pycache__/information.cpython-39.pyc
ADDED
|
Binary file (951 Bytes). View file
|
|
|
feedback/__pycache__/layout.cpython-311.pyc
ADDED
|
Binary file (1.17 kB). View file
|
|
|
feedback/__pycache__/layout.cpython-312.pyc
ADDED
|
Binary file (992 Bytes). View file
|
|
|
feedback/__pycache__/layout.cpython-39.pyc
ADDED
|
Binary file (814 Bytes). View file
|
|
|
feedback/indicators.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# feedback/indicators.py
|
| 2 |
+
from utils.drawing_utils import draw_gauge_meter,draw_progress_bar,display_stage,display_counter
|
| 3 |
+
|
| 4 |
+
display_counter_poisiton=(40, 240)
|
| 5 |
+
display_stage_poisiton=(40, 270)
|
| 6 |
+
display_counter_angel_color=(255,255,0)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def draw_squat_indicators(frame, counter, angle, stage):
|
| 10 |
+
# Counter
|
| 11 |
+
display_counter(frame,counter, position=display_counter_poisiton, color=(0, 0, 0),background_color=(192,192,192))
|
| 12 |
+
|
| 13 |
+
# Stage
|
| 14 |
+
display_stage(frame, stage,"Stage", position=display_stage_poisiton, color=(0, 0, 0),background_color=(192,192,192))
|
| 15 |
+
|
| 16 |
+
draw_progress_bar(frame, exercise="squat", value=counter, position=(40, 170), size=(200, 20), color=(163, 245, 184, 1),background_color=(255,255,255))
|
| 17 |
+
|
| 18 |
+
draw_gauge_meter(frame, angle=angle, text="Squat Gauge Meter", position=(135, 415), radius=75, color=(0, 0, 255))
|
| 19 |
+
|
| 20 |
+
def draw_pushup_indicators(frame, counter, angle, stage):
|
| 21 |
+
# Counter
|
| 22 |
+
display_counter(frame,counter, position=display_counter_poisiton, color=(0, 0, 0),background_color=(192,192,192))
|
| 23 |
+
|
| 24 |
+
display_stage(frame, stage,"Stage", position=display_stage_poisiton, color=(0, 0, 0),background_color=(192,192,192))
|
| 25 |
+
draw_progress_bar(frame, exercise="push_up", value=counter, position=(40, 170), size=(200, 20), color=(163, 245, 184, 1),background_color=(255,255,255))
|
| 26 |
+
|
| 27 |
+
text = "Push-u Gauge Meter"
|
| 28 |
+
draw_gauge_meter(frame, angle=angle,text=text, position=(350,80), radius=50, color=(0, 102, 204))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def draw_hammercurl_indicators(frame, counter_right, angle_right, counter_left, angle_left, stage_right, stage_left):
|
| 32 |
+
display_counter_poisiton_left_arm = (40, 300)
|
| 33 |
+
|
| 34 |
+
# Right Arm Indicators
|
| 35 |
+
display_counter(frame, counter_right, position=display_counter_poisiton, color=(0, 0, 0),background_color=(192,192,192))
|
| 36 |
+
|
| 37 |
+
display_stage(frame, stage_right,"Right Stage", position=display_stage_poisiton, color=(0, 0, 0),background_color=(192,192,192))
|
| 38 |
+
display_stage(frame, stage_left,"Left Stage", position=display_counter_poisiton_left_arm, color=(0, 0, 0),background_color=(192,192,192))
|
| 39 |
+
|
| 40 |
+
# Progress Bars
|
| 41 |
+
draw_progress_bar(frame, exercise="hammer_curl", value=(counter_right+counter_left)/2, position=(40, 170), size=(200, 20), color=(163, 245, 184, 1),background_color=(255,255,255))
|
| 42 |
+
|
| 43 |
+
text_right = "Right Gauge Meter"
|
| 44 |
+
text_left = "Left Gauge Meter"
|
| 45 |
+
|
| 46 |
+
# Gauge Meters for Angles
|
| 47 |
+
draw_gauge_meter(frame, angle=angle_right,text=text_right, position=(1200,80), radius=50, color=(0, 102, 204))
|
| 48 |
+
draw_gauge_meter(frame, angle=angle_left,text=text_left, position=(1200,240), radius=50, color=(0, 102, 204))
|
| 49 |
+
|
| 50 |
+
|
feedback/information.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def get_exercise_info(exercise_type):
|
| 2 |
+
exercises = {
|
| 3 |
+
"hammer_curl": {
|
| 4 |
+
"name": "Hammer Curl",
|
| 5 |
+
"target_muscles": ["Biceps", "Brachialis"],
|
| 6 |
+
"equipment": "Dumbbells",
|
| 7 |
+
"reps": 8,
|
| 8 |
+
"sets": 1,
|
| 9 |
+
"rest_time": "60 seconds",
|
| 10 |
+
"benefits": [
|
| 11 |
+
"Improves bicep and forearm strength",
|
| 12 |
+
"Enhances grip strength"
|
| 13 |
+
]
|
| 14 |
+
},
|
| 15 |
+
"push_up": {
|
| 16 |
+
"name": "Push-Up",
|
| 17 |
+
"target_muscles": ["Chest", "Triceps", "Shoulders"],
|
| 18 |
+
"equipment": "Bodyweight",
|
| 19 |
+
"reps": 10,
|
| 20 |
+
"sets": 1,
|
| 21 |
+
"rest_time": "45 seconds",
|
| 22 |
+
"benefits": [
|
| 23 |
+
"Builds upper body strength",
|
| 24 |
+
"Improves core stability"
|
| 25 |
+
]
|
| 26 |
+
},
|
| 27 |
+
"squat": {
|
| 28 |
+
"name": "Squat",
|
| 29 |
+
"target_muscles": ["Quads", "Glutes", "Hamstrings"],
|
| 30 |
+
"equipment": "Bodyweight or Barbell",
|
| 31 |
+
"reps": 2,
|
| 32 |
+
"sets": 3,
|
| 33 |
+
"rest_time": "60 seconds",
|
| 34 |
+
"benefits": [
|
| 35 |
+
"Builds lower body strength",
|
| 36 |
+
"Improves mobility and balance"
|
| 37 |
+
]
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
return exercises.get(exercise_type, {})
|
feedback/layout.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# feedback/layout.py
|
| 2 |
+
|
| 3 |
+
from feedback.indicators import draw_squat_indicators, draw_pushup_indicators, draw_hammercurl_indicators
|
| 4 |
+
|
| 5 |
+
def layout_indicators(frame, exercise_type, exercise_data):
|
| 6 |
+
if exercise_type == "squat":
|
| 7 |
+
counter, angle, stage = exercise_data
|
| 8 |
+
draw_squat_indicators(frame, counter, angle, stage)
|
| 9 |
+
elif exercise_type == "push_up":
|
| 10 |
+
counter, angle, stage = exercise_data
|
| 11 |
+
draw_pushup_indicators(frame, counter, angle, stage)
|
| 12 |
+
elif exercise_type == "hammer_curl":
|
| 13 |
+
(counter_right, angle_right, counter_left, angle_left,
|
| 14 |
+
warning_message_right, warning_message_left, progress_right, progress_left,stage_right,stage_left) = exercise_data
|
| 15 |
+
draw_hammercurl_indicators(frame, counter_right, angle_right, counter_left, angle_left, stage_right,stage_left)
|
| 16 |
+
|
live_test.html
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Live Fitness Trainer Test (WebSocket)</title>
|
| 7 |
+
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
|
| 8 |
+
<style>
|
| 9 |
+
body { font-family: sans-serif; display: flex; flex-direction: column; align-items: center; margin: 0; padding: 20px; background-color: #f4f4f4; }
|
| 10 |
+
#controls { margin-bottom: 20px; padding: 15px; background-color: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
| 11 |
+
label, select, button { font-size: 1em; margin: 5px; }
|
| 12 |
+
button { padding: 10px 15px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
| 13 |
+
button:disabled { background-color: #ccc; }
|
| 14 |
+
button:hover:not(:disabled) { background-color: #0056b3; }
|
| 15 |
+
#videoContainer { display: flex; flex-wrap: wrap; justify-content: center; gap: 20px; margin-bottom: 20px; }
|
| 16 |
+
video { border: 2px solid #007bff; transform: scaleX(-1); border-radius: 8px; background-color: #000; } /* Flip video for mirror effect */
|
| 17 |
+
#feedbackArea { border: 1px solid #ccc; padding: 15px; width: 320px; background-color: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
| 18 |
+
#feedbackArea h3 { margin-top: 0; color: #007bff; }
|
| 19 |
+
#feedbackArea p { margin: 8px 0; }
|
| 20 |
+
#feedbackArea span { font-weight: bold; color: #333; }
|
| 21 |
+
/* .hidden class is not strictly needed as JS controls display style directly */
|
| 22 |
+
/* However, if used by JS: .hidden { display: none; } */
|
| 23 |
+
</style>
|
| 24 |
+
</head>
|
| 25 |
+
<body>
|
| 26 |
+
<h1>Live Fitness Trainer Test (WebSocket)</h1>
|
| 27 |
+
|
| 28 |
+
<div id="controls">
|
| 29 |
+
<label for="exerciseTypeSelect">Exercise:</label>
|
| 30 |
+
<select id="exerciseTypeSelect">
|
| 31 |
+
<option value="squat">Squat</option>
|
| 32 |
+
<option value="push_up">Push Up</option>
|
| 33 |
+
<option value="hammer_curl">Hammer Curl</option>
|
| 34 |
+
</select>
|
| 35 |
+
<button id="startButton">Start Trainer</button>
|
| 36 |
+
<button id="stopButton" disabled>Stop Trainer</button>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
<div id="videoContainer">
|
| 40 |
+
<div>
|
| 41 |
+
<h3>Your Webcam</h3>
|
| 42 |
+
<video id="videoElement" width="320" height="240" autoplay playsinline></video>
|
| 43 |
+
</div>
|
| 44 |
+
<div id="feedbackArea">
|
| 45 |
+
<h3>Feedback & Status</h3>
|
| 46 |
+
<!-- These are not directly updated by websocket_trainer.js but can be kept for manual/other updates if needed -->
|
| 47 |
+
<p>Session ID: <span id="sessionIdDisplay">-</span></p> <!-- websocket_trainer.js doesn't update this -->
|
| 48 |
+
<p>Status: <span id="statusDisplay">Idle</span></p> <!-- websocket_trainer.js doesn't update this -->
|
| 49 |
+
<hr>
|
| 50 |
+
|
| 51 |
+
<!-- Generic UI for Squat/Push-up -->
|
| 52 |
+
<div class="generic-exercise-specific">
|
| 53 |
+
<p>Reps: <span id="repsDisplay">0</span></p>
|
| 54 |
+
<p>Stage: <span id="stageDisplay">-</span></p>
|
| 55 |
+
<p>Feedback: <span id="feedbackDisplay">-</span></p>
|
| 56 |
+
<p>Angle: <span id="angleDisplay">-</span></p>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<!-- Specific UI for Hammer Curl -->
|
| 60 |
+
<div class="hammer-curl-specific" style="display: none;"> <!-- Initially hidden by style, JS will manage -->
|
| 61 |
+
<p>Reps Left: <span id="repsLeftDisplay">0</span> | Reps Right: <span id="repsRightDisplay">0</span></p>
|
| 62 |
+
<p>Stage Left: <span id="stageLeftDisplay">-</span> | Stage Right: <span id="stageRightDisplay">-</span></p>
|
| 63 |
+
<p>Feedback Left: <span id="feedbackLeftDisplay">-</span></p>
|
| 64 |
+
<p>Feedback Right: <span id="feedbackRightDisplay">-</span></p>
|
| 65 |
+
<!-- The generic 'angleDisplay' is used for hammer curl angles by websocket_trainer.js -->
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<script src="static/js/websocket_trainer.js"></script>
|
| 71 |
+
</body>
|
| 72 |
+
</html>
|
main.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
from pose_estimation.estimation import PoseEstimator
|
| 3 |
+
from exercises.squat import Squat
|
| 4 |
+
from exercises.hammer_curl import HammerCurl
|
| 5 |
+
from exercises.push_up import PushUp
|
| 6 |
+
from feedback.layout import layout_indicators
|
| 7 |
+
from feedback.information import get_exercise_info
|
| 8 |
+
from utils.draw_text_with_background import draw_text_with_background
|
| 9 |
+
|
| 10 |
+
def main():
|
| 11 |
+
video_path = r"C:\Users\yakupzengin\Fitness-Trainer\data\squat.mp4"
|
| 12 |
+
video_path = r"C:\Users\yakupzengin\Fitness-Trainer\data\push_up.mp4"
|
| 13 |
+
video_path = r"C:\Users\yakupzengin\Fitness-Trainer\data\dumbel-workout.mp4"
|
| 14 |
+
|
| 15 |
+
exercise_type = "hammer_curl" # Egzersiz türünü belirleyin ("hammer_curl", "squat", "push_up")
|
| 16 |
+
|
| 17 |
+
cap = cv2.VideoCapture(0)
|
| 18 |
+
pose_estimator = PoseEstimator()
|
| 19 |
+
|
| 20 |
+
if exercise_type == "hammer_curl":
|
| 21 |
+
exercise = HammerCurl()
|
| 22 |
+
elif exercise_type == "squat":
|
| 23 |
+
exercise = Squat()
|
| 24 |
+
elif exercise_type == "push_up":
|
| 25 |
+
exercise = PushUp()
|
| 26 |
+
else:
|
| 27 |
+
print("Invalid exercise type.")
|
| 28 |
+
return
|
| 29 |
+
|
| 30 |
+
exercise_info = get_exercise_info(exercise_type)
|
| 31 |
+
|
| 32 |
+
fourcc = cv2.VideoWriter_fourcc(*'XVID')
|
| 33 |
+
output_file = r"C:\Users\yakupzengin\Fitness-Trainer\output\push-up.avi"
|
| 34 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 35 |
+
frame_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 36 |
+
frame_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 37 |
+
out = cv2.VideoWriter(output_file, fourcc, fps, (frame_width, frame_height))
|
| 38 |
+
|
| 39 |
+
while cap.isOpened():
|
| 40 |
+
ret, frame = cap.read()
|
| 41 |
+
if not ret:
|
| 42 |
+
break
|
| 43 |
+
|
| 44 |
+
results = pose_estimator.estimate_pose(frame, exercise_type)
|
| 45 |
+
if results.pose_landmarks:
|
| 46 |
+
if exercise_type == "squat":
|
| 47 |
+
counter, angle, stage = exercise.track_squat(results.pose_landmarks.landmark, frame)
|
| 48 |
+
layout_indicators(frame, exercise_type, (counter, angle, stage))
|
| 49 |
+
elif exercise_type == "hammer_curl":
|
| 50 |
+
(counter_right, angle_right, counter_left, angle_left,
|
| 51 |
+
warning_message_right, warning_message_left, progress_right, progress_left, stage_right, stage_left) = exercise.track_hammer_curl(
|
| 52 |
+
results.pose_landmarks.landmark, frame)
|
| 53 |
+
layout_indicators(frame, exercise_type,
|
| 54 |
+
(counter_right, angle_right, counter_left, angle_left,
|
| 55 |
+
warning_message_right, warning_message_left, progress_right, progress_left, stage_right, stage_left))
|
| 56 |
+
elif exercise_type == "push_up":
|
| 57 |
+
counter, angle, stage = exercise.track_push_up(results.pose_landmarks.landmark, frame)
|
| 58 |
+
layout_indicators(frame, exercise_type, (counter, angle, stage))
|
| 59 |
+
|
| 60 |
+
draw_text_with_background(frame, f"Exercise: {exercise_info.get('name', 'N/A')}", (40, 50),
|
| 61 |
+
cv2.FONT_HERSHEY_DUPLEX, 0.7, (255, 255, 255,), (118, 29, 14, 0.79), 1)
|
| 62 |
+
draw_text_with_background(frame, f"Reps: {exercise_info.get('reps', 0)}", (40, 80),
|
| 63 |
+
cv2.FONT_HERSHEY_DUPLEX, 0.7, (255, 255, 255,), (118, 29, 14, 0.79), 1)
|
| 64 |
+
draw_text_with_background(frame, f"Sets: {exercise_info.get('sets', 0)}", (40, 110),
|
| 65 |
+
cv2.FONT_HERSHEY_DUPLEX, 0.7, (255, 255, 255,), (118, 29, 14, 0.79),1 )
|
| 66 |
+
|
| 67 |
+
out.write(frame)
|
| 68 |
+
|
| 69 |
+
cv2.namedWindow(f"{exercise_type.replace('_', ' ').title()} Tracker", cv2.WINDOW_NORMAL)
|
| 70 |
+
cv2.resizeWindow(f"{exercise_type.replace('_', ' ').title()} Tracker", 1920, 1080)
|
| 71 |
+
cv2.imshow(f"{exercise_type.replace('_', ' ').title()} Tracker", frame)
|
| 72 |
+
|
| 73 |
+
if cv2.waitKey(10) & 0xFF == ord('q'):
|
| 74 |
+
break
|
| 75 |
+
|
| 76 |
+
cap.release()
|
| 77 |
+
out.release()
|
| 78 |
+
cv2.destroyAllWindows()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
if __name__ == '__main__':
|
| 82 |
+
main()
|
output/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
output/images/Screenshot 2024-09-08 030742.png
ADDED
|
Git LFS Details
|
output/images/Screenshot 2024-09-08 030816.png
ADDED
|
Git LFS Details
|
output/images/Screenshot 2024-09-08 030836.png
ADDED
|
Git LFS Details
|
pose_estimation/__init__.py
ADDED
|
File without changes
|
pose_estimation/__pycache__/angle_calculation.cpython-311.pyc
ADDED
|
Binary file (1.2 kB). View file
|
|
|
pose_estimation/__pycache__/angle_calculation.cpython-312.pyc
ADDED
|
Binary file (1.04 kB). View file
|
|
|
pose_estimation/__pycache__/angle_calculation.cpython-39.pyc
ADDED
|
Binary file (642 Bytes). View file
|
|
|
pose_estimation/__pycache__/estimation.cpython-311.pyc
ADDED
|
Binary file (8.2 kB). View file
|
|
|
pose_estimation/__pycache__/estimation.cpython-312.pyc
ADDED
|
Binary file (8.09 kB). View file
|
|
|
pose_estimation/__pycache__/estimation.cpython-39.pyc
ADDED
|
Binary file (3.23 kB). View file
|
|
|