Spaces:
Sleeping
Sleeping
Upload 23 files
Browse filesInitial Deployment Commit
- app.py +476 -0
- dockerfile.txt +40 -0
- readme.md +32 -0
- requirements.txt +9 -0
- static/.DS_Store +0 -0
- static/css/.DS_Store +0 -0
- static/css/.Rhistory +0 -0
- static/css/styles.css +55 -0
- static/img/.DS_Store +0 -0
- static/img/bench.png +0 -0
- static/img/deadlift.png +0 -0
- static/img/leg_extension.png +0 -0
- static/img/pushup.png +0 -0
- static/img/shoulder_press.png +0 -0
- static/img/squat.png +0 -0
- static/img/weightlift.png +0 -0
- static/js/.DS_Store +0 -0
- static/js/cursor.js +47 -0
- static/js/detection.js +212 -0
- static/js/particles.js +72 -0
- static/js/predict.js +74 -0
- static/js/scroll-reveal.js +15 -0
- templates/index.html +173 -0
app.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
# CRITICAL: Set environment variables BEFORE any imports to prevent training
|
| 3 |
+
os.environ['YOLO_VERBOSE'] = 'False'
|
| 4 |
+
os.environ['ULTRALYTICS_AUTOINSTALL'] = 'False'
|
| 5 |
+
|
| 6 |
+
# Force all HF caches to a writable place
|
| 7 |
+
_cache = "/data/hf-cache" if os.getenv("HF_SPACE") else os.getenv("HF_CACHE_DIR", "/tmp/hf-cache")
|
| 8 |
+
for var in ["HF_HOME", "HUGGINGFACE_HUB_CACHE", "HF_HUB_CACHE", "HF_CACHE_DIR", "XDG_CACHE_HOME"]:
|
| 9 |
+
os.environ.setdefault(var, _cache)
|
| 10 |
+
os.makedirs(_cache, exist_ok=True)
|
| 11 |
+
|
| 12 |
+
from flask import Flask, render_template, request, jsonify, send_from_directory, url_for, Response
|
| 13 |
+
from werkzeug.utils import secure_filename
|
| 14 |
+
import os
|
| 15 |
+
from PIL import Image
|
| 16 |
+
import io
|
| 17 |
+
import torch
|
| 18 |
+
import cv2
|
| 19 |
+
import numpy as np
|
| 20 |
+
from datetime import datetime
|
| 21 |
+
from huggingface_hub import hf_hub_download
|
| 22 |
+
import time
|
| 23 |
+
from collections import deque
|
| 24 |
+
import shutil
|
| 25 |
+
|
| 26 |
+
app = Flask(__name__)
|
| 27 |
+
app.config["UPLOAD_FOLDER"] = os.environ.get("UPLOAD_DIR", "/data/uploads")
|
| 28 |
+
app.config["VIDEO_FOLDER"] = os.path.join(app.config["UPLOAD_FOLDER"], "videos")
|
| 29 |
+
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
|
| 30 |
+
os.makedirs(app.config["VIDEO_FOLDER"], exist_ok=True)
|
| 31 |
+
|
| 32 |
+
# Exercise classes
|
| 33 |
+
CLASSES = [
|
| 34 |
+
"benchpress",
|
| 35 |
+
"deadlift",
|
| 36 |
+
"squat",
|
| 37 |
+
"leg_ext",
|
| 38 |
+
"pushup",
|
| 39 |
+
"shoulder_press"
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'webp'}
|
| 43 |
+
|
| 44 |
+
# OPTIMIZED Performance settings
|
| 45 |
+
SKIP_FRAMES = 4
|
| 46 |
+
TARGET_FPS = 15
|
| 47 |
+
INFERENCE_SIZE = 416
|
| 48 |
+
JPEG_QUALITY = 75
|
| 49 |
+
CONF_THRESHOLD = 0.25
|
| 50 |
+
IOU_THRESHOLD = 0.5
|
| 51 |
+
|
| 52 |
+
# Global variables
|
| 53 |
+
model = None
|
| 54 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 55 |
+
frame_times = deque(maxlen=30)
|
| 56 |
+
last_frame_cache = None
|
| 57 |
+
|
| 58 |
+
def allowed_file(filename: str) -> bool:
|
| 59 |
+
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
| 60 |
+
|
| 61 |
+
def allowed_video(filename: str) -> bool:
|
| 62 |
+
VIDEO_EXTENSIONS = {'mp4', 'avi', 'mov', 'mkv', 'webm'}
|
| 63 |
+
return "." in filename and filename.rsplit(".", 1)[1].lower() in VIDEO_EXTENSIONS
|
| 64 |
+
|
| 65 |
+
def load_model():
|
| 66 |
+
"""Load the trained object detection model with STRICT anti-training safeguards"""
|
| 67 |
+
global model
|
| 68 |
+
|
| 69 |
+
print("\n" + "=" * 60)
|
| 70 |
+
print("STARTING MODEL LOAD (INFERENCE-ONLY MODE)")
|
| 71 |
+
print("=" * 60)
|
| 72 |
+
|
| 73 |
+
# CRITICAL: Set anti-training environment variables
|
| 74 |
+
os.environ['YOLO_VERBOSE'] = 'False'
|
| 75 |
+
os.environ['ULTRALYTICS_AUTOINSTALL'] = 'False'
|
| 76 |
+
|
| 77 |
+
try:
|
| 78 |
+
# IMPORTANT: Update this with YOUR model repo
|
| 79 |
+
if os.getenv("HF_SPACE"):
|
| 80 |
+
print("Running in Hugging Face Space")
|
| 81 |
+
# Download from your model repo
|
| 82 |
+
checkpoint_path = hf_hub_download(
|
| 83 |
+
repo_id="gym-vision/Object-Detection-Space", # ← CHANGE THIS!
|
| 84 |
+
filename="best_v4.pt",
|
| 85 |
+
repo_type="model",
|
| 86 |
+
cache_dir=os.environ["HF_CACHE_DIR"]
|
| 87 |
+
)
|
| 88 |
+
else:
|
| 89 |
+
checkpoint_path = "best_v4.pt"
|
| 90 |
+
print(f"Local mode - Model at: {os.path.abspath(checkpoint_path)}")
|
| 91 |
+
if not os.path.exists(checkpoint_path):
|
| 92 |
+
raise FileNotFoundError(f"Model not found: {checkpoint_path}")
|
| 93 |
+
|
| 94 |
+
print(f"Device: {device}")
|
| 95 |
+
|
| 96 |
+
from ultralytics import YOLO
|
| 97 |
+
|
| 98 |
+
# Load model
|
| 99 |
+
model = YOLO(checkpoint_path)
|
| 100 |
+
model.to(device)
|
| 101 |
+
|
| 102 |
+
# Force evaluation mode
|
| 103 |
+
if hasattr(model, 'model'):
|
| 104 |
+
model.model.eval()
|
| 105 |
+
model.model.requires_grad_(False)
|
| 106 |
+
for param in model.model.parameters():
|
| 107 |
+
param.requires_grad = False
|
| 108 |
+
|
| 109 |
+
# Disable trainer
|
| 110 |
+
if hasattr(model, 'trainer'):
|
| 111 |
+
model.trainer = None
|
| 112 |
+
|
| 113 |
+
# Override ALL settings
|
| 114 |
+
if hasattr(model, 'overrides'):
|
| 115 |
+
model.overrides = {
|
| 116 |
+
'task': 'detect',
|
| 117 |
+
'mode': 'predict',
|
| 118 |
+
'model': checkpoint_path,
|
| 119 |
+
'data': None,
|
| 120 |
+
'epochs': 0,
|
| 121 |
+
'save': False,
|
| 122 |
+
'save_txt': False,
|
| 123 |
+
'save_conf': False,
|
| 124 |
+
'save_crop': False,
|
| 125 |
+
'show': False,
|
| 126 |
+
'plots': False,
|
| 127 |
+
'verbose': False,
|
| 128 |
+
'conf': CONF_THRESHOLD,
|
| 129 |
+
'iou': IOU_THRESHOLD,
|
| 130 |
+
'max_det': 10,
|
| 131 |
+
'half': device.type == 'cuda',
|
| 132 |
+
'device': device.type,
|
| 133 |
+
'augment': False,
|
| 134 |
+
'visualize': False,
|
| 135 |
+
'batch': 1,
|
| 136 |
+
'imgsz': INFERENCE_SIZE,
|
| 137 |
+
'workers': 0,
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
if hasattr(model, 'predictor'):
|
| 141 |
+
model.predictor = None
|
| 142 |
+
|
| 143 |
+
print("✓ Model loaded in INFERENCE-ONLY mode")
|
| 144 |
+
|
| 145 |
+
# Warmup
|
| 146 |
+
print("\nWarming up model...")
|
| 147 |
+
dummy_img = np.random.randint(0, 255, (INFERENCE_SIZE, INFERENCE_SIZE, 3), dtype=np.uint8)
|
| 148 |
+
|
| 149 |
+
with torch.no_grad():
|
| 150 |
+
try:
|
| 151 |
+
_ = model(dummy_img, verbose=False)
|
| 152 |
+
except:
|
| 153 |
+
pass
|
| 154 |
+
|
| 155 |
+
print("\n" + "=" * 60)
|
| 156 |
+
print("MODEL READY FOR INFERENCE")
|
| 157 |
+
print(f"Device: {device}")
|
| 158 |
+
print("=" * 60 + "\n")
|
| 159 |
+
|
| 160 |
+
return True
|
| 161 |
+
|
| 162 |
+
except Exception as e:
|
| 163 |
+
print("\n" + "=" * 60)
|
| 164 |
+
print("MODEL LOADING FAILED")
|
| 165 |
+
print(f"Error: {e}")
|
| 166 |
+
import traceback
|
| 167 |
+
traceback.print_exc()
|
| 168 |
+
print("=" * 60 + "\n")
|
| 169 |
+
model = None
|
| 170 |
+
return False
|
| 171 |
+
|
| 172 |
+
# Pre-define colors for faster lookup (BGR format)
|
| 173 |
+
COLORS_BGR = {
|
| 174 |
+
"benchpress": (107, 107, 255),
|
| 175 |
+
"deadlift": (196, 205, 78),
|
| 176 |
+
"squat": (209, 183, 69),
|
| 177 |
+
"leg_ext": (122, 160, 255),
|
| 178 |
+
"pushup": (200, 216, 152),
|
| 179 |
+
"shoulder_press": (111, 220, 247)
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
def draw_detections_fast(image, detections):
|
| 183 |
+
"""Optimized drawing with smart label positioning"""
|
| 184 |
+
if isinstance(image, Image.Image):
|
| 185 |
+
image = np.array(image)
|
| 186 |
+
|
| 187 |
+
img_h, img_w = image.shape[:2]
|
| 188 |
+
font = cv2.FONT_HERSHEY_SIMPLEX
|
| 189 |
+
font_scale = 0.6
|
| 190 |
+
thickness = 2
|
| 191 |
+
|
| 192 |
+
for det in detections:
|
| 193 |
+
x1, y1, x2, y2 = det['bbox']
|
| 194 |
+
label = det['label']
|
| 195 |
+
conf = det['confidence']
|
| 196 |
+
|
| 197 |
+
color = COLORS_BGR.get(label, (255, 255, 255))
|
| 198 |
+
cv2.rectangle(image, (x1, y1), (x2, y2), color, 2)
|
| 199 |
+
|
| 200 |
+
text = f"{label} {conf:.2f}"
|
| 201 |
+
(text_w, text_h), _ = cv2.getTextSize(text, font, font_scale, thickness)
|
| 202 |
+
|
| 203 |
+
label_margin = 8
|
| 204 |
+
|
| 205 |
+
if y1 - text_h - label_margin >= 0:
|
| 206 |
+
label_y1 = y1 - text_h - label_margin
|
| 207 |
+
label_y2 = y1
|
| 208 |
+
text_y = y1 - 4
|
| 209 |
+
elif y2 + text_h + label_margin <= img_h:
|
| 210 |
+
label_y1 = y2
|
| 211 |
+
label_y2 = y2 + text_h + label_margin
|
| 212 |
+
text_y = y2 + text_h + 2
|
| 213 |
+
else:
|
| 214 |
+
label_y1 = y1
|
| 215 |
+
label_y2 = y1 + text_h + label_margin
|
| 216 |
+
text_y = y1 + text_h + 2
|
| 217 |
+
|
| 218 |
+
label_x2 = min(x1 + text_w + 4, img_w)
|
| 219 |
+
cv2.rectangle(image, (x1, label_y1), (label_x2, label_y2), color, -1)
|
| 220 |
+
cv2.putText(image, text, (x1 + 2, text_y), font, font_scale, (0, 0, 0), thickness)
|
| 221 |
+
|
| 222 |
+
return image
|
| 223 |
+
|
| 224 |
+
@torch.no_grad()
|
| 225 |
+
def detect_objects_fast(image_array, verbose=False):
|
| 226 |
+
"""Optimized object detection"""
|
| 227 |
+
if model is None:
|
| 228 |
+
return []
|
| 229 |
+
|
| 230 |
+
try:
|
| 231 |
+
start_time = time.time()
|
| 232 |
+
detections = []
|
| 233 |
+
|
| 234 |
+
# Use model call
|
| 235 |
+
results = model(image_array, verbose=False, imgsz=INFERENCE_SIZE)
|
| 236 |
+
|
| 237 |
+
if results and len(results) > 0:
|
| 238 |
+
result = results[0]
|
| 239 |
+
|
| 240 |
+
if hasattr(result, 'boxes') and result.boxes is not None:
|
| 241 |
+
boxes = result.boxes
|
| 242 |
+
|
| 243 |
+
for box in boxes:
|
| 244 |
+
xyxy = box.xyxy[0].cpu().numpy()
|
| 245 |
+
x1, y1, x2, y2 = map(int, xyxy)
|
| 246 |
+
conf = float(box.conf[0].cpu().numpy())
|
| 247 |
+
cls_id = int(box.cls[0].cpu().numpy())
|
| 248 |
+
|
| 249 |
+
label = model.names[cls_id] if hasattr(model, 'names') and cls_id < len(model.names) else CLASSES[cls_id]
|
| 250 |
+
|
| 251 |
+
detections.append({
|
| 252 |
+
'bbox': [x1, y1, x2, y2],
|
| 253 |
+
'label': label,
|
| 254 |
+
'confidence': conf
|
| 255 |
+
})
|
| 256 |
+
|
| 257 |
+
inference_time = (time.time() - start_time) * 1000
|
| 258 |
+
|
| 259 |
+
if verbose:
|
| 260 |
+
print(f"Inference: {inference_time:.1f}ms | Detections: {len(detections)}")
|
| 261 |
+
|
| 262 |
+
return detections
|
| 263 |
+
|
| 264 |
+
except Exception as e:
|
| 265 |
+
print(f"Detection error: {e}")
|
| 266 |
+
return []
|
| 267 |
+
|
| 268 |
+
def process_frame_optimized(frame, frame_count=0):
|
| 269 |
+
"""Optimized frame processing with caching"""
|
| 270 |
+
global last_frame_cache
|
| 271 |
+
|
| 272 |
+
if frame_count % SKIP_FRAMES != 0 and last_frame_cache is not None:
|
| 273 |
+
return last_frame_cache['annotated'], last_frame_cache['detections']
|
| 274 |
+
|
| 275 |
+
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
| 276 |
+
detections = detect_objects_fast(rgb_frame)
|
| 277 |
+
annotated_frame = draw_detections_fast(rgb_frame.copy(), detections)
|
| 278 |
+
|
| 279 |
+
last_frame_cache = {
|
| 280 |
+
'annotated': annotated_frame,
|
| 281 |
+
'detections': detections
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
return annotated_frame, detections
|
| 285 |
+
|
| 286 |
+
@app.route("/")
|
| 287 |
+
def index():
|
| 288 |
+
return render_template("index.html")
|
| 289 |
+
|
| 290 |
+
@app.route("/uploads/<path:filename>")
|
| 291 |
+
def uploaded_file(filename):
|
| 292 |
+
return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
|
| 293 |
+
|
| 294 |
+
@app.route("/webcam_feed")
|
| 295 |
+
def webcam_feed():
|
| 296 |
+
"""Note: Webcam will not work in Hugging Face Spaces (no camera access)"""
|
| 297 |
+
def generate():
|
| 298 |
+
global last_frame_cache
|
| 299 |
+
last_frame_cache = None
|
| 300 |
+
|
| 301 |
+
if model is None:
|
| 302 |
+
print("ERROR: Model not loaded")
|
| 303 |
+
return
|
| 304 |
+
|
| 305 |
+
cap = cv2.VideoCapture(0)
|
| 306 |
+
|
| 307 |
+
if not cap.isOpened():
|
| 308 |
+
print("ERROR: Could not open webcam")
|
| 309 |
+
return
|
| 310 |
+
|
| 311 |
+
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
|
| 312 |
+
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
|
| 313 |
+
cap.set(cv2.CAP_PROP_FPS, 30)
|
| 314 |
+
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
| 315 |
+
|
| 316 |
+
frame_count = 0
|
| 317 |
+
encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY]
|
| 318 |
+
|
| 319 |
+
try:
|
| 320 |
+
while True:
|
| 321 |
+
success, frame = cap.read()
|
| 322 |
+
if not success:
|
| 323 |
+
break
|
| 324 |
+
|
| 325 |
+
annotated_frame, detections = process_frame_optimized(frame, frame_count)
|
| 326 |
+
_, buffer = cv2.imencode('.jpg', cv2.cvtColor(annotated_frame, cv2.COLOR_RGB2BGR), encode_param)
|
| 327 |
+
frame_bytes = buffer.tobytes()
|
| 328 |
+
|
| 329 |
+
frame_count += 1
|
| 330 |
+
yield (b'--frame\r\n'
|
| 331 |
+
b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
|
| 332 |
+
|
| 333 |
+
finally:
|
| 334 |
+
cap.release()
|
| 335 |
+
last_frame_cache = None
|
| 336 |
+
|
| 337 |
+
return Response(generate(), mimetype='multipart/x-mixed-replace; boundary=frame')
|
| 338 |
+
|
| 339 |
+
@app.route("/analyze_image", methods=["POST"])
|
| 340 |
+
def analyze_image():
|
| 341 |
+
"""Analyze uploaded image"""
|
| 342 |
+
if model is None:
|
| 343 |
+
return jsonify({"ok": False, "error": "Model not loaded"}), 500
|
| 344 |
+
|
| 345 |
+
if "image" not in request.files:
|
| 346 |
+
return jsonify({"ok": False, "error": "No file part"}), 400
|
| 347 |
+
|
| 348 |
+
file = request.files["image"]
|
| 349 |
+
if file.filename == "" or not allowed_file(file.filename):
|
| 350 |
+
return jsonify({"ok": False, "error": "Invalid file"}), 400
|
| 351 |
+
|
| 352 |
+
try:
|
| 353 |
+
image_bytes = file.read()
|
| 354 |
+
filename = secure_filename(file.filename)
|
| 355 |
+
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}_{filename}"
|
| 356 |
+
save_path = os.path.join(app.config["UPLOAD_FOLDER"], filename)
|
| 357 |
+
|
| 358 |
+
with open(save_path, 'wb') as f:
|
| 359 |
+
f.write(image_bytes)
|
| 360 |
+
|
| 361 |
+
image = Image.open(io.BytesIO(image_bytes)).convert('RGB')
|
| 362 |
+
image_array = np.array(image)
|
| 363 |
+
|
| 364 |
+
detections = detect_objects_fast(image_array, verbose=True)
|
| 365 |
+
|
| 366 |
+
annotated_array = draw_detections_fast(image_array.copy(), detections)
|
| 367 |
+
annotated_image = Image.fromarray(annotated_array)
|
| 368 |
+
|
| 369 |
+
annotated_filename = f"annotated_{filename}"
|
| 370 |
+
annotated_path = os.path.join(app.config["UPLOAD_FOLDER"], annotated_filename)
|
| 371 |
+
annotated_image.save(annotated_path, quality=95)
|
| 372 |
+
|
| 373 |
+
tips = {
|
| 374 |
+
"benchpress": "Feet planted, slight arch, shoulder blades retracted; control bar path.",
|
| 375 |
+
"deadlift": "Hinge at hips, bar close to shins, lats tight; push the floor, don't jerk.",
|
| 376 |
+
"squat": "Keep knees tracking over toes; brace your core; maintain neutral spine.",
|
| 377 |
+
"leg_ext": "Control the movement, don't swing; focus on squeezing the quadriceps.",
|
| 378 |
+
"pushup": "Keep body straight, engage core; lower chest to floor with control.",
|
| 379 |
+
"shoulder_press": "Keep core tight, don't arch back excessively; press straight up."
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
detected_exercises = list(set([d['label'] for d in detections]))
|
| 383 |
+
exercise_tips = [tips.get(ex, "") for ex in detected_exercises]
|
| 384 |
+
|
| 385 |
+
return jsonify({
|
| 386 |
+
"ok": True,
|
| 387 |
+
"original_image": url_for("uploaded_file", filename=filename),
|
| 388 |
+
"annotated_image": url_for("uploaded_file", filename=annotated_filename),
|
| 389 |
+
"detections": detections,
|
| 390 |
+
"tips": exercise_tips
|
| 391 |
+
})
|
| 392 |
+
|
| 393 |
+
except Exception as e:
|
| 394 |
+
print(f"Error: {e}")
|
| 395 |
+
import traceback
|
| 396 |
+
traceback.print_exc()
|
| 397 |
+
return jsonify({"ok": False, "error": str(e)}), 500
|
| 398 |
+
|
| 399 |
+
@app.route("/upload_video", methods=["POST"])
|
| 400 |
+
def upload_video():
|
| 401 |
+
"""Upload video"""
|
| 402 |
+
if model is None:
|
| 403 |
+
return jsonify({"ok": False, "error": "Model not loaded"}), 500
|
| 404 |
+
|
| 405 |
+
if "video" not in request.files:
|
| 406 |
+
return jsonify({"ok": False, "error": "No video file"}), 400
|
| 407 |
+
|
| 408 |
+
file = request.files["video"]
|
| 409 |
+
if not file.filename or not allowed_video(file.filename):
|
| 410 |
+
return jsonify({"ok": False, "error": "Invalid video"}), 400
|
| 411 |
+
|
| 412 |
+
filename = secure_filename(file.filename)
|
| 413 |
+
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}_{filename}"
|
| 414 |
+
save_path = os.path.join(app.config["VIDEO_FOLDER"], filename)
|
| 415 |
+
file.save(save_path)
|
| 416 |
+
|
| 417 |
+
return jsonify({"ok": True, "video_id": filename})
|
| 418 |
+
|
| 419 |
+
@app.route("/video_feed/<video_id>")
|
| 420 |
+
def video_feed(video_id):
|
| 421 |
+
"""Optimized video streaming"""
|
| 422 |
+
global last_frame_cache
|
| 423 |
+
|
| 424 |
+
if model is None:
|
| 425 |
+
return jsonify({"ok": False, "error": "Model not loaded"}), 500
|
| 426 |
+
|
| 427 |
+
video_path = os.path.join(app.config["VIDEO_FOLDER"], video_id)
|
| 428 |
+
|
| 429 |
+
def generate():
|
| 430 |
+
global last_frame_cache
|
| 431 |
+
last_frame_cache = None
|
| 432 |
+
|
| 433 |
+
cap = cv2.VideoCapture(video_path)
|
| 434 |
+
|
| 435 |
+
frame_count = 0
|
| 436 |
+
encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY]
|
| 437 |
+
|
| 438 |
+
while cap.isOpened():
|
| 439 |
+
success, frame = cap.read()
|
| 440 |
+
if not success:
|
| 441 |
+
break
|
| 442 |
+
|
| 443 |
+
annotated_frame, detections = process_frame_optimized(frame, frame_count)
|
| 444 |
+
_, buffer = cv2.imencode('.jpg', cv2.cvtColor(annotated_frame, cv2.COLOR_RGB2BGR), encode_param)
|
| 445 |
+
frame_bytes = buffer.tobytes()
|
| 446 |
+
|
| 447 |
+
frame_count += 1
|
| 448 |
+
|
| 449 |
+
yield (b'--frame\r\n'
|
| 450 |
+
b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
|
| 451 |
+
|
| 452 |
+
time.sleep(1.0 / TARGET_FPS)
|
| 453 |
+
|
| 454 |
+
cap.release()
|
| 455 |
+
last_frame_cache = None
|
| 456 |
+
|
| 457 |
+
return Response(generate(), mimetype='multipart/x-mixed-replace; boundary=frame')
|
| 458 |
+
|
| 459 |
+
# Load model on startup
|
| 460 |
+
print("\n" + "="*60)
|
| 461 |
+
print("FLASK APP STARTING")
|
| 462 |
+
print("="*60)
|
| 463 |
+
model_loaded = load_model()
|
| 464 |
+
|
| 465 |
+
if model_loaded:
|
| 466 |
+
print("\n✓ App ready for inference")
|
| 467 |
+
print(f"Device: {device}")
|
| 468 |
+
else:
|
| 469 |
+
print("\n✗ Model failed to load")
|
| 470 |
+
|
| 471 |
+
print("="*60 + "\n")
|
| 472 |
+
|
| 473 |
+
if __name__ == "__main__":
|
| 474 |
+
# IMPORTANT: Hugging Face Spaces requires port 7860
|
| 475 |
+
port = int(os.environ.get("PORT", 7860))
|
| 476 |
+
app.run(debug=False, host="0.0.0.0", port=port, threaded=True)
|
dockerfile.txt
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.11 slim image
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install system dependencies
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
libgl1-mesa-glx \
|
| 10 |
+
libglib2.0-0 \
|
| 11 |
+
libsm6 \
|
| 12 |
+
libxext6 \
|
| 13 |
+
libxrender-dev \
|
| 14 |
+
libgomp1 \
|
| 15 |
+
git \
|
| 16 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
+
|
| 18 |
+
# Copy requirements first for better caching
|
| 19 |
+
COPY requirements.txt .
|
| 20 |
+
|
| 21 |
+
# Install Python dependencies
|
| 22 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 23 |
+
|
| 24 |
+
# Copy application files
|
| 25 |
+
COPY . .
|
| 26 |
+
|
| 27 |
+
# Create necessary directories
|
| 28 |
+
RUN mkdir -p /data/uploads /data/uploads/videos /data/hf-cache
|
| 29 |
+
|
| 30 |
+
# Expose port 7860 (required by Hugging Face Spaces)
|
| 31 |
+
EXPOSE 7860
|
| 32 |
+
|
| 33 |
+
# Set environment variables
|
| 34 |
+
ENV HF_SPACE=1
|
| 35 |
+
ENV HF_CACHE_DIR=/data/hf-cache
|
| 36 |
+
ENV UPLOAD_DIR=/data/uploads
|
| 37 |
+
ENV PYTHONUNBUFFERED=1
|
| 38 |
+
|
| 39 |
+
# Run the application
|
| 40 |
+
CMD ["python", "app.py"]
|
readme.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: GymVision Exercise Detection
|
| 3 |
+
emoji: 🏋️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# GymVision - Exercise Detection System
|
| 12 |
+
|
| 13 |
+
Real-time exercise detection using YOLOv8 to identify and track gym exercises.
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
- 🎥 Webcam detection (real-time)
|
| 17 |
+
- 📷 Image upload analysis
|
| 18 |
+
- 🎬 Video upload processing
|
| 19 |
+
|
| 20 |
+
## Detected Exercises
|
| 21 |
+
- Bench Press
|
| 22 |
+
- Deadlift
|
| 23 |
+
- Squat
|
| 24 |
+
- Leg Extension
|
| 25 |
+
- Push-up
|
| 26 |
+
- Shoulder Press
|
| 27 |
+
|
| 28 |
+
## Technical Stack
|
| 29 |
+
- YOLOv8 object detection
|
| 30 |
+
- Flask web server
|
| 31 |
+
- PyTorch inference
|
| 32 |
+
- OpenCV processing
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask==3.0.0
|
| 2 |
+
werkzeug==3.0.1
|
| 3 |
+
torch==2.3.1
|
| 4 |
+
torchvision==0.18.1
|
| 5 |
+
ultralytics==8.3.0
|
| 6 |
+
opencv-python-headless==4.9.0.80
|
| 7 |
+
pillow==10.2.0
|
| 8 |
+
numpy==1.26.4
|
| 9 |
+
huggingface-hub==0.20.3
|
static/.DS_Store
ADDED
|
Binary file (12.3 kB). View file
|
|
|
static/css/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
static/css/.Rhistory
ADDED
|
File without changes
|
static/css/styles.css
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--ring-size: 32px;
|
| 3 |
+
--dot-size: 6px;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
* { cursor: none; } /* use custom cursor */
|
| 7 |
+
|
| 8 |
+
/* Custom cursor */
|
| 9 |
+
#cursor-dot, #cursor-ring {
|
| 10 |
+
position: fixed;
|
| 11 |
+
pointer-events: none;
|
| 12 |
+
z-index: 50;
|
| 13 |
+
transition: transform 0.08s ease-out, opacity 0.2s ease-out;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
#cursor-dot {
|
| 17 |
+
width: var(--dot-size);
|
| 18 |
+
height: var(--dot-size);
|
| 19 |
+
margin-left: calc(var(--dot-size) * -0.5);
|
| 20 |
+
margin-top: calc(var(--dot-size) * -0.5);
|
| 21 |
+
background: white;
|
| 22 |
+
border-radius: 9999px;
|
| 23 |
+
mix-blend-mode: difference; /* crisp candy feel */
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
#cursor-ring {
|
| 27 |
+
width: var(--ring-size);
|
| 28 |
+
height: var(--ring-size);
|
| 29 |
+
margin-left: calc(var(--ring-size) * -0.5);
|
| 30 |
+
margin-top: calc(var(--ring-size) * -0.5);
|
| 31 |
+
border: 1px solid rgba(255,255,255,0.6);
|
| 32 |
+
border-radius: 9999px;
|
| 33 |
+
backdrop-filter: blur(2px);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* Scroll reveal defaults; JS will remove opacity/translate */
|
| 37 |
+
.reveal {
|
| 38 |
+
will-change: transform, opacity;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/* file input nicer in dark bg (safari fix) */
|
| 42 |
+
input[type="file"]::file-selector-button {
|
| 43 |
+
cursor: pointer;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/* Reduce selection glow */
|
| 47 |
+
::selection {
|
| 48 |
+
background: rgba(255,255,255,0.2);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/* Smooth fonts */
|
| 52 |
+
html { font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, 'Helvetica Neue', Arial, 'Apple Color Emoji', 'Segoe UI Emoji'; }
|
| 53 |
+
|
| 54 |
+
* { cursor: none !important; }
|
| 55 |
+
input, button, textarea, select, label { cursor: none !important; }
|
static/img/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
static/img/bench.png
ADDED
|
static/img/deadlift.png
ADDED
|
static/img/leg_extension.png
ADDED
|
static/img/pushup.png
ADDED
|
static/img/shoulder_press.png
ADDED
|
static/img/squat.png
ADDED
|
static/img/weightlift.png
ADDED
|
static/js/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
static/js/cursor.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(() => {
|
| 2 |
+
// Keep native cursor on touch devices
|
| 3 |
+
if ('ontouchstart' in window) return;
|
| 4 |
+
|
| 5 |
+
// Hide the old dot and ring (blurry circle)
|
| 6 |
+
const dot = document.getElementById('cursor-dot');
|
| 7 |
+
const ring = document.getElementById('cursor-ring');
|
| 8 |
+
if (dot) dot.style.display = 'none';
|
| 9 |
+
if (ring) ring.style.display = 'none';
|
| 10 |
+
|
| 11 |
+
// Create a simple white triangle cursor
|
| 12 |
+
const cursor = document.createElement('div');
|
| 13 |
+
cursor.style.position = 'fixed';
|
| 14 |
+
cursor.style.left = '0';
|
| 15 |
+
cursor.style.top = '0';
|
| 16 |
+
cursor.style.width = '0';
|
| 17 |
+
cursor.style.height = '0';
|
| 18 |
+
cursor.style.pointerEvents = 'none';
|
| 19 |
+
cursor.style.zIndex = '50';
|
| 20 |
+
cursor.style.transition = 'transform 0.08s ease-out';
|
| 21 |
+
cursor.innerHTML = `
|
| 22 |
+
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
| 23 |
+
<!-- Classic arrow-style cursor pointing top-left -->
|
| 24 |
+
<polygon points="1,1 1,21 6,16 9,22 12,21 9,15 19,15" fill="white" shape-rendering="crispEdges"/>
|
| 25 |
+
</svg>
|
| 26 |
+
`;
|
| 27 |
+
document.body.appendChild(cursor);
|
| 28 |
+
|
| 29 |
+
// Smooth follow
|
| 30 |
+
let mx = window.innerWidth / 2;
|
| 31 |
+
let my = window.innerHeight / 2;
|
| 32 |
+
let ax = mx, ay = my;
|
| 33 |
+
const lerp = (a, b, t) => a + (b - a) * t;
|
| 34 |
+
|
| 35 |
+
function raf() {
|
| 36 |
+
ax = lerp(ax, mx, 0.25);
|
| 37 |
+
ay = lerp(ay, my, 0.25);
|
| 38 |
+
cursor.style.transform = `translate(${ax}px, ${ay}px)`;
|
| 39 |
+
requestAnimationFrame(raf);
|
| 40 |
+
}
|
| 41 |
+
requestAnimationFrame(raf);
|
| 42 |
+
|
| 43 |
+
window.addEventListener('mousemove', (e) => {
|
| 44 |
+
mx = e.clientX;
|
| 45 |
+
my = e.clientY;
|
| 46 |
+
});
|
| 47 |
+
})();
|
static/js/detection.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Object Detection Interface Handler
|
| 2 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 3 |
+
// Mode switching
|
| 4 |
+
const modeButtons = document.querySelectorAll('.mode-btn');
|
| 5 |
+
const sections = {
|
| 6 |
+
'mode-webcam': document.getElementById('webcam-section'),
|
| 7 |
+
'mode-video': document.getElementById('video-section'),
|
| 8 |
+
'mode-image': document.getElementById('image-section')
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
modeButtons.forEach(btn => {
|
| 12 |
+
btn.addEventListener('click', () => {
|
| 13 |
+
// Update button styles
|
| 14 |
+
modeButtons.forEach(b => {
|
| 15 |
+
b.classList.remove('active', 'bg-white', 'text-zinc-900');
|
| 16 |
+
b.classList.add('bg-zinc-800', 'text-white');
|
| 17 |
+
});
|
| 18 |
+
btn.classList.add('active', 'bg-white', 'text-zinc-900');
|
| 19 |
+
btn.classList.remove('bg-zinc-800', 'text-white');
|
| 20 |
+
|
| 21 |
+
// Show/hide sections
|
| 22 |
+
Object.values(sections).forEach(s => s?.classList.add('hidden'));
|
| 23 |
+
sections[btn.id]?.classList.remove('hidden');
|
| 24 |
+
|
| 25 |
+
// Stop webcam if switching away
|
| 26 |
+
if (btn.id !== 'mode-webcam') {
|
| 27 |
+
stopWebcam();
|
| 28 |
+
}
|
| 29 |
+
});
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
// ===== WEBCAM MODE =====
|
| 33 |
+
const startWebcamBtn = document.getElementById('start-webcam');
|
| 34 |
+
const stopWebcamBtn = document.getElementById('stop-webcam');
|
| 35 |
+
const webcamStream = document.getElementById('webcam-stream');
|
| 36 |
+
const webcamStatus = document.getElementById('webcam-status');
|
| 37 |
+
let webcamInterval = null;
|
| 38 |
+
|
| 39 |
+
startWebcamBtn?.addEventListener('click', startWebcam);
|
| 40 |
+
stopWebcamBtn?.addEventListener('click', stopWebcam);
|
| 41 |
+
|
| 42 |
+
function startWebcam() {
|
| 43 |
+
webcamStatus.textContent = 'Starting webcam...';
|
| 44 |
+
webcamStream.src = '/webcam_feed';
|
| 45 |
+
webcamStream.classList.remove('hidden');
|
| 46 |
+
startWebcamBtn.classList.add('hidden');
|
| 47 |
+
stopWebcamBtn.classList.remove('hidden');
|
| 48 |
+
webcamStatus.textContent = 'Webcam active - detecting exercises...';
|
| 49 |
+
|
| 50 |
+
// Handle errors
|
| 51 |
+
webcamStream.onerror = () => {
|
| 52 |
+
webcamStatus.textContent = 'Error: Could not access webcam';
|
| 53 |
+
stopWebcam();
|
| 54 |
+
};
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function stopWebcam() {
|
| 58 |
+
if (webcamStream) {
|
| 59 |
+
webcamStream.src = '';
|
| 60 |
+
webcamStream.classList.add('hidden');
|
| 61 |
+
}
|
| 62 |
+
if (webcamInterval) {
|
| 63 |
+
clearInterval(webcamInterval);
|
| 64 |
+
webcamInterval = null;
|
| 65 |
+
}
|
| 66 |
+
startWebcamBtn?.classList.remove('hidden');
|
| 67 |
+
stopWebcamBtn?.classList.add('hidden');
|
| 68 |
+
webcamStatus.textContent = '';
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// ===== VIDEO UPLOAD MODE =====
|
| 72 |
+
const videoForm = document.getElementById('video-form');
|
| 73 |
+
const videoInput = document.getElementById('video-input');
|
| 74 |
+
const videoStream = document.getElementById('video-stream');
|
| 75 |
+
const videoStatus = document.getElementById('video-status');
|
| 76 |
+
|
| 77 |
+
videoForm?.addEventListener('submit', async (e) => {
|
| 78 |
+
e.preventDefault();
|
| 79 |
+
|
| 80 |
+
const file = videoInput.files?.[0];
|
| 81 |
+
if (!file) return;
|
| 82 |
+
|
| 83 |
+
videoStatus.textContent = 'Uploading video...';
|
| 84 |
+
|
| 85 |
+
try {
|
| 86 |
+
const formData = new FormData();
|
| 87 |
+
formData.append('video', file);
|
| 88 |
+
|
| 89 |
+
const res = await fetch('/upload_video', {
|
| 90 |
+
method: 'POST',
|
| 91 |
+
body: formData
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
const data = await res.json();
|
| 95 |
+
|
| 96 |
+
if (!res.ok || !data.ok) {
|
| 97 |
+
throw new Error(data.error || 'Upload failed');
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
videoStatus.textContent = 'Processing video...';
|
| 101 |
+
videoStream.src = `/video_feed/${data.video_id}`;
|
| 102 |
+
videoStream.classList.remove('hidden');
|
| 103 |
+
videoStatus.textContent = 'Video processing complete!';
|
| 104 |
+
|
| 105 |
+
} catch (err) {
|
| 106 |
+
console.error(err);
|
| 107 |
+
videoStatus.textContent = `Error: ${err.message}`;
|
| 108 |
+
}
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
// ===== IMAGE UPLOAD MODE =====
|
| 112 |
+
const imageForm = document.getElementById('image-form');
|
| 113 |
+
const imageInput = document.getElementById('image-input');
|
| 114 |
+
const previewBox = document.getElementById('preview');
|
| 115 |
+
const previewImg = document.getElementById('preview-img');
|
| 116 |
+
const imageStatus = document.getElementById('image-status');
|
| 117 |
+
const imageResult = document.getElementById('image-result');
|
| 118 |
+
|
| 119 |
+
// Preview handler
|
| 120 |
+
imageInput?.addEventListener('change', () => {
|
| 121 |
+
const file = imageInput.files?.[0];
|
| 122 |
+
if (!file) return;
|
| 123 |
+
|
| 124 |
+
const url = URL.createObjectURL(file);
|
| 125 |
+
previewImg.src = url;
|
| 126 |
+
previewBox.classList.remove('hidden');
|
| 127 |
+
imageStatus.textContent = '';
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
// Submit handler
|
| 131 |
+
imageForm?.addEventListener('submit', async (e) => {
|
| 132 |
+
e.preventDefault();
|
| 133 |
+
|
| 134 |
+
const file = imageInput.files?.[0];
|
| 135 |
+
if (!file) return;
|
| 136 |
+
|
| 137 |
+
imageStatus.textContent = 'Analyzing image...';
|
| 138 |
+
imageResult.innerHTML = '<div class="text-zinc-400 text-sm">Processing...</div>';
|
| 139 |
+
|
| 140 |
+
try {
|
| 141 |
+
const formData = new FormData();
|
| 142 |
+
formData.append('image', file);
|
| 143 |
+
|
| 144 |
+
const res = await fetch('/analyze_image', {
|
| 145 |
+
method: 'POST',
|
| 146 |
+
body: formData
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
const data = await res.json();
|
| 150 |
+
|
| 151 |
+
if (!res.ok || !data.ok) {
|
| 152 |
+
throw new Error(data.error || 'Analysis failed');
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// Display results
|
| 156 |
+
const detections = data.detections || [];
|
| 157 |
+
const detectionCount = detections.length;
|
| 158 |
+
|
| 159 |
+
let resultsHTML = `
|
| 160 |
+
<div class="space-y-4">
|
| 161 |
+
<div class="flex justify-center">
|
| 162 |
+
<img src="${data.annotated_image}"
|
| 163 |
+
alt="Detected exercises"
|
| 164 |
+
class="rounded-lg border border-white/10 max-w-full">
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
<div class="text-lg font-semibold">
|
| 168 |
+
Found ${detectionCount} exercise${detectionCount !== 1 ? 's' : ''}
|
| 169 |
+
</div>
|
| 170 |
+
`;
|
| 171 |
+
|
| 172 |
+
if (detections.length > 0) {
|
| 173 |
+
resultsHTML += '<div class="space-y-3">';
|
| 174 |
+
|
| 175 |
+
detections.forEach((det, idx) => {
|
| 176 |
+
const conf = (det.confidence * 100).toFixed(1);
|
| 177 |
+
resultsHTML += `
|
| 178 |
+
<div class="bg-zinc-800/50 rounded-lg p-4 border border-white/5">
|
| 179 |
+
<div class="flex justify-between items-center">
|
| 180 |
+
<span class="font-semibold text-lg capitalize">${det.label}</span>
|
| 181 |
+
<span class="text-zinc-400">${conf}% confident</span>
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
`;
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
resultsHTML += '</div>';
|
| 188 |
+
|
| 189 |
+
// Add tips
|
| 190 |
+
if (data.tips && data.tips.length > 0) {
|
| 191 |
+
resultsHTML += '<div class="mt-4 space-y-2">';
|
| 192 |
+
resultsHTML += '<div class="font-semibold">Form Tips:</div>';
|
| 193 |
+
data.tips.forEach(tip => {
|
| 194 |
+
if (tip) {
|
| 195 |
+
resultsHTML += `<p class="text-zinc-300 text-sm">• ${tip}</p>`;
|
| 196 |
+
}
|
| 197 |
+
});
|
| 198 |
+
resultsHTML += '</div>';
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
resultsHTML += '</div>';
|
| 203 |
+
imageResult.innerHTML = resultsHTML;
|
| 204 |
+
imageStatus.textContent = '';
|
| 205 |
+
|
| 206 |
+
} catch (err) {
|
| 207 |
+
console.error(err);
|
| 208 |
+
imageStatus.textContent = `Error: ${err.message}`;
|
| 209 |
+
imageResult.innerHTML = '<div class="text-red-400 text-sm">Analysis failed. Please try again.</div>';
|
| 210 |
+
}
|
| 211 |
+
});
|
| 212 |
+
});
|
static/js/particles.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Animated dumbbell particles on a Canvas background
|
| 2 |
+
(() => {
|
| 3 |
+
const canvas = document.getElementById('bg-canvas');
|
| 4 |
+
const ctx = canvas.getContext('2d', { alpha: true });
|
| 5 |
+
|
| 6 |
+
function resize() {
|
| 7 |
+
const dpr = window.devicePixelRatio || 1;
|
| 8 |
+
canvas.width = Math.floor(innerWidth * dpr);
|
| 9 |
+
canvas.height = Math.floor(innerHeight * dpr);
|
| 10 |
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
| 11 |
+
}
|
| 12 |
+
window.addEventListener('resize', resize);
|
| 13 |
+
resize();
|
| 14 |
+
|
| 15 |
+
// Create particles shaped like tiny dumbbells
|
| 16 |
+
const NUM = Math.min(70, Math.floor((innerWidth * innerHeight) / 22000));
|
| 17 |
+
const particles = Array.from({ length: NUM }, () => spawn());
|
| 18 |
+
|
| 19 |
+
function spawn() {
|
| 20 |
+
const s = 0.5 + Math.random() * 1.2; // scale
|
| 21 |
+
return {
|
| 22 |
+
x: Math.random() * innerWidth,
|
| 23 |
+
y: Math.random() * innerHeight,
|
| 24 |
+
vx: -0.4 + Math.random() * 0.8,
|
| 25 |
+
vy: -0.4 + Math.random() * 0.8,
|
| 26 |
+
rot: Math.random() * Math.PI * 2,
|
| 27 |
+
vr: (-0.004 + Math.random() * 0.008),
|
| 28 |
+
s,
|
| 29 |
+
alpha: 0.15 + Math.random() * 0.35
|
| 30 |
+
};
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function drawDumbbell(x, y, scale, rot, alpha) {
|
| 34 |
+
ctx.save();
|
| 35 |
+
ctx.translate(x, y);
|
| 36 |
+
ctx.rotate(rot);
|
| 37 |
+
ctx.scale(scale, scale);
|
| 38 |
+
ctx.globalAlpha = alpha;
|
| 39 |
+
|
| 40 |
+
// bar
|
| 41 |
+
ctx.lineWidth = 2;
|
| 42 |
+
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
| 43 |
+
ctx.beginPath();
|
| 44 |
+
ctx.moveTo(-16, 0);
|
| 45 |
+
ctx.lineTo(16, 0);
|
| 46 |
+
ctx.stroke();
|
| 47 |
+
|
| 48 |
+
// plates
|
| 49 |
+
ctx.fillStyle = 'rgba(255,255,255,0.25)';
|
| 50 |
+
ctx.beginPath(); ctx.arc(-18, 0, 4, 0, Math.PI*2); ctx.fill();
|
| 51 |
+
ctx.beginPath(); ctx.arc(18, 0, 4, 0, Math.PI*2); ctx.fill();
|
| 52 |
+
|
| 53 |
+
ctx.restore();
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function tick() {
|
| 57 |
+
ctx.clearRect(0, 0, innerWidth, innerHeight);
|
| 58 |
+
for (const p of particles) {
|
| 59 |
+
p.x += p.vx; p.y += p.vy; p.rot += p.vr;
|
| 60 |
+
|
| 61 |
+
// wrap-around
|
| 62 |
+
if (p.x < -30) p.x = innerWidth + 30;
|
| 63 |
+
if (p.x > innerWidth + 30) p.x = -30;
|
| 64 |
+
if (p.y < -30) p.y = innerHeight + 30;
|
| 65 |
+
if (p.y > innerHeight + 30) p.y = -30;
|
| 66 |
+
|
| 67 |
+
drawDumbbell(p.x, p.y, p.s, p.rot, p.alpha);
|
| 68 |
+
}
|
| 69 |
+
requestAnimationFrame(tick);
|
| 70 |
+
}
|
| 71 |
+
requestAnimationFrame(tick);
|
| 72 |
+
})();
|
static/js/predict.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 2 |
+
const form = document.getElementById('upload-form');
|
| 3 |
+
const fileInput = document.getElementById('file-input');
|
| 4 |
+
const previewBox = document.getElementById('preview');
|
| 5 |
+
const previewImg = document.getElementById('preview-img');
|
| 6 |
+
const statusEl = document.getElementById('upload-status');
|
| 7 |
+
const resultEl = document.getElementById('result');
|
| 8 |
+
const submitBtn = form?.querySelector('button[type="submit"]');
|
| 9 |
+
|
| 10 |
+
if (!form) return;
|
| 11 |
+
|
| 12 |
+
// Local preview
|
| 13 |
+
let lastPreviewSrc = null;
|
| 14 |
+
fileInput.addEventListener('change', () => {
|
| 15 |
+
const f = fileInput.files?.[0];
|
| 16 |
+
if (!f) return;
|
| 17 |
+
lastPreviewSrc = URL.createObjectURL(f);
|
| 18 |
+
previewImg.src = lastPreviewSrc;
|
| 19 |
+
previewBox.classList.remove('hidden');
|
| 20 |
+
statusEl.textContent = '';
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
let busy = false;
|
| 24 |
+
form.addEventListener('submit', async (e) => {
|
| 25 |
+
e.preventDefault();
|
| 26 |
+
if (busy) return;
|
| 27 |
+
busy = true;
|
| 28 |
+
if (submitBtn) submitBtn.disabled = true;
|
| 29 |
+
statusEl.textContent = 'Analyzing…';
|
| 30 |
+
resultEl.innerHTML = '';
|
| 31 |
+
|
| 32 |
+
try {
|
| 33 |
+
const fd = new FormData(form); // contains "image"
|
| 34 |
+
const url = form.action || '/analyze'; // action set in HTML
|
| 35 |
+
const res = await fetch(url, { method: 'POST', body: fd });
|
| 36 |
+
|
| 37 |
+
let data;
|
| 38 |
+
try { data = await res.json(); }
|
| 39 |
+
catch { throw new Error(`Bad JSON (HTTP ${res.status})`); }
|
| 40 |
+
|
| 41 |
+
if (!res.ok || data?.ok === false) {
|
| 42 |
+
throw new Error(data?.error || `HTTP ${res.status}`);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const pred = data?.prediction?.label ?? data?.label ?? '—';
|
| 46 |
+
const conf = data?.prediction?.confidence ?? data?.confidence;
|
| 47 |
+
const confTxt = (conf != null) ? ` (${(Number(conf) * 100).toFixed(1)}%)` : '';
|
| 48 |
+
|
| 49 |
+
// Prefer server-provided image; fall back to local preview
|
| 50 |
+
const imgSrc = data.image_url
|
| 51 |
+
? data.image_url
|
| 52 |
+
: (data.image_b64 ? `data:image/png;base64,${data.image_b64}` : lastPreviewSrc);
|
| 53 |
+
|
| 54 |
+
resultEl.innerHTML = `
|
| 55 |
+
<div class="grid md:grid-cols-2 gap-4 items-start">
|
| 56 |
+
${imgSrc ? `<img src="${imgSrc}" alt="Analyzed image"
|
| 57 |
+
class="rounded-lg border border-white/10 max-h-72 object-contain bg-black/20">` : ''}
|
| 58 |
+
<div>
|
| 59 |
+
<div class="text-xl font-semibold">Prediction: ${pred}${confTxt}</div>
|
| 60 |
+
${data.form?.note ? `<p class="mt-2 text-zinc-400">${data.form.note}</p>` : ''}
|
| 61 |
+
${data.tip ? `<p class="mt-2 text-zinc-300">${data.tip}</p>` : ''}
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
`;
|
| 65 |
+
statusEl.textContent = '';
|
| 66 |
+
} catch (err) {
|
| 67 |
+
console.error(err);
|
| 68 |
+
statusEl.textContent = err.message || 'Something went wrong. Please try again.';
|
| 69 |
+
} finally {
|
| 70 |
+
busy = false;
|
| 71 |
+
if (submitBtn) submitBtn.disabled = false;
|
| 72 |
+
}
|
| 73 |
+
});
|
| 74 |
+
});
|
static/js/scroll-reveal.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Fade-in on scroll (IntersectionObserver)
|
| 2 |
+
(() => {
|
| 3 |
+
const els = document.querySelectorAll('.reveal');
|
| 4 |
+
const io = new IntersectionObserver((entries) => {
|
| 5 |
+
entries.forEach((entry) => {
|
| 6 |
+
if (entry.isIntersecting) {
|
| 7 |
+
entry.target.classList.remove('opacity-0', 'translate-y-4');
|
| 8 |
+
entry.target.classList.add('transition', 'duration-700', 'ease-out');
|
| 9 |
+
io.unobserve(entry.target);
|
| 10 |
+
}
|
| 11 |
+
});
|
| 12 |
+
}, { threshold: 0.18 });
|
| 13 |
+
|
| 14 |
+
els.forEach(el => io.observe(el));
|
| 15 |
+
})();
|
templates/index.html
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
|
| 4 |
+
<!-- Hero -->
|
| 5 |
+
<section class="relative overflow-hidden">
|
| 6 |
+
<div class="max-w-7xl mx-auto px-6 pt-20 pb-24 text-center">
|
| 7 |
+
<h1 class="text-4xl sm:text-5xl md:text-6xl font-extrabold tracking-tight leading-tight">
|
| 8 |
+
Real-time Exercise Detection
|
| 9 |
+
</h1>
|
| 10 |
+
<p class="mt-6 text-zinc-300 max-w-2xl mx-auto">
|
| 11 |
+
Detect exercises in real-time from your webcam, upload a video, or analyze a static image with bounding boxes and labels.
|
| 12 |
+
</p>
|
| 13 |
+
|
| 14 |
+
<a href="#detection"
|
| 15 |
+
class="inline-block mt-10 rounded-full px-6 py-3 bg-white text-zinc-900 font-semibold shadow-lg shadow-white/10 hover:shadow-white/20 transition hover:-translate-y-0.5">
|
| 16 |
+
Start Detection
|
| 17 |
+
</a>
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
<div class="h-[1px] w-full bg-gradient-to-r from-transparent via-white/20 to-transparent"></div>
|
| 21 |
+
</section>
|
| 22 |
+
|
| 23 |
+
<!-- Supported Exercises -->
|
| 24 |
+
<section id="supported" class="max-w-7xl mx-auto px-6 py-16">
|
| 25 |
+
<h2 class="text-3xl font-extrabold mb-10">Supported Exercises</h2>
|
| 26 |
+
|
| 27 |
+
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-8">
|
| 28 |
+
<article class="reveal opacity-0 translate-y-4 bg-zinc-900/50 rounded-2xl border border-white/5 overflow-hidden hover:border-white/10 transition">
|
| 29 |
+
<img src="{{ url_for('static', filename='img/squat.png') }}" alt="Squat" class="w-full h-48 object-cover">
|
| 30 |
+
<div class="p-6">
|
| 31 |
+
<h3 class="text-xl font-bold">Squats</h3>
|
| 32 |
+
<p class="mt-2 text-zinc-300 text-sm">Detects barbell back/front squats.</p>
|
| 33 |
+
</div>
|
| 34 |
+
</article>
|
| 35 |
+
|
| 36 |
+
<article class="reveal opacity-0 translate-y-4 bg-zinc-900/50 rounded-2xl border border-white/5 overflow-hidden hover:border-white/10 transition">
|
| 37 |
+
<img src="{{ url_for('static', filename='img/bench.png') }}" alt="Bench Press" class="w-full h-48 object-cover">
|
| 38 |
+
<div class="p-6">
|
| 39 |
+
<h3 class="text-xl font-bold">Bench Press</h3>
|
| 40 |
+
<p class="mt-2 text-zinc-300 text-sm">Detects flat bench press.</p>
|
| 41 |
+
</div>
|
| 42 |
+
</article>
|
| 43 |
+
|
| 44 |
+
<article class="reveal opacity-0 translate-y-4 bg-zinc-900/50 rounded-2xl border border-white/5 overflow-hidden hover:border-white/10 transition">
|
| 45 |
+
<img src="{{ url_for('static', filename='img/deadlift.png') }}" alt="Deadlift" class="w-full h-48 object-cover">
|
| 46 |
+
<div class="p-6">
|
| 47 |
+
<h3 class="text-xl font-bold">Deadlifts</h3>
|
| 48 |
+
<p class="mt-2 text-zinc-300 text-sm">Detects conventional deadlifts.</p>
|
| 49 |
+
</div>
|
| 50 |
+
</article>
|
| 51 |
+
|
| 52 |
+
<article class="reveal opacity-0 translate-y-4 bg-zinc-900/50 rounded-2xl border border-white/5 overflow-hidden hover:border-white/10 transition">
|
| 53 |
+
<img src="{{ url_for('static', filename='img/leg_extension.png') }}" alt="Leg Extension" class="w-full h-48 object-cover">
|
| 54 |
+
<div class="p-6">
|
| 55 |
+
<h3 class="text-xl font-bold">Leg Extension</h3>
|
| 56 |
+
<p class="mt-2 text-zinc-300 text-sm">Detects machine-based leg extensions.</p>
|
| 57 |
+
</div>
|
| 58 |
+
</article>
|
| 59 |
+
|
| 60 |
+
<article class="reveal opacity-0 translate-y-4 bg-zinc-900/50 rounded-2xl border border-white/5 overflow-hidden hover:border-white/10 transition">
|
| 61 |
+
<img src="{{ url_for('static', filename='img/shoulder_press.png') }}" alt="Shoulder Press" class="w-full h-48 object-cover">
|
| 62 |
+
<div class="p-6">
|
| 63 |
+
<h3 class="text-xl font-bold">Shoulder Press</h3>
|
| 64 |
+
<p class="mt-2 text-zinc-300 text-sm">Detects seated shoulder press.</p>
|
| 65 |
+
</div>
|
| 66 |
+
</article>
|
| 67 |
+
|
| 68 |
+
<article class="reveal opacity-0 translate-y-4 bg-zinc-900/50 rounded-2xl border border-white/5 overflow-hidden hover:border-white/10 transition">
|
| 69 |
+
<img src="{{ url_for('static', filename='img/pushup.png') }}" alt="Push-up" class="w-full h-48 object-cover">
|
| 70 |
+
<div class="p-6">
|
| 71 |
+
<h3 class="text-xl font-bold">Push-up</h3>
|
| 72 |
+
<p class="mt-2 text-zinc-300 text-sm">Detects standard push-ups and knee variations.</p>
|
| 73 |
+
</div>
|
| 74 |
+
</article>
|
| 75 |
+
</div>
|
| 76 |
+
</section>
|
| 77 |
+
|
| 78 |
+
<!-- Detection Section -->
|
| 79 |
+
<section id="detection" class="max-w-7xl mx-auto px-6 pb-24">
|
| 80 |
+
<!-- Mode Selector -->
|
| 81 |
+
<div class="reveal opacity-0 translate-y-4 mb-8">
|
| 82 |
+
<div class="flex justify-center gap-4">
|
| 83 |
+
<button id="mode-webcam" class="mode-btn active px-6 py-3 rounded-xl bg-white text-zinc-900 font-semibold transition hover:-translate-y-0.5">
|
| 84 |
+
Webcam
|
| 85 |
+
</button>
|
| 86 |
+
<button id="mode-video" class="mode-btn px-6 py-3 rounded-xl bg-zinc-800 text-white font-semibold transition hover:-translate-y-0.5">
|
| 87 |
+
Video Upload
|
| 88 |
+
</button>
|
| 89 |
+
<button id="mode-image" class="mode-btn px-6 py-3 rounded-xl bg-zinc-800 text-white font-semibold transition hover:-translate-y-0.5">
|
| 90 |
+
Image Upload
|
| 91 |
+
</button>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<!-- Webcam Mode -->
|
| 96 |
+
<div id="webcam-section" class="detection-section">
|
| 97 |
+
<div class="reveal opacity-0 translate-y-4 bg-zinc-900/50 rounded-2xl border border-white/5 p-6">
|
| 98 |
+
<h3 class="text-2xl font-bold mb-4">Live Webcam Detection</h3>
|
| 99 |
+
<div class="flex justify-center mb-4">
|
| 100 |
+
<button id="start-webcam" class="px-6 py-3 rounded-xl bg-green-600 text-white font-semibold transition hover:bg-green-700">
|
| 101 |
+
Start Webcam
|
| 102 |
+
</button>
|
| 103 |
+
<button id="stop-webcam" class="hidden ml-4 px-6 py-3 rounded-xl bg-red-600 text-white font-semibold transition hover:bg-red-700">
|
| 104 |
+
Stop Webcam
|
| 105 |
+
</button>
|
| 106 |
+
</div>
|
| 107 |
+
<div class="flex justify-center">
|
| 108 |
+
<img id="webcam-stream" class="hidden rounded-lg border border-white/10 max-w-full" alt="Webcam feed">
|
| 109 |
+
</div>
|
| 110 |
+
<div id="webcam-status" class="mt-4 text-sm text-zinc-400 text-center"></div>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<!-- Video Upload Mode -->
|
| 115 |
+
<div id="video-section" class="detection-section hidden">
|
| 116 |
+
<div class="reveal opacity-0 translate-y-4 bg-zinc-900/50 rounded-2xl border border-white/5 p-6">
|
| 117 |
+
<h3 class="text-2xl font-bold mb-4">Video Detection</h3>
|
| 118 |
+
<form id="video-form">
|
| 119 |
+
<label class="block">
|
| 120 |
+
<span class="text-sm text-zinc-300">Choose video file (MP4, AVI, MOV, MKV, WEBM)</span>
|
| 121 |
+
<input id="video-input" type="file" accept=".mp4,.avi,.mov,.mkv,.webm"
|
| 122 |
+
class="mt-2 w-full rounded-lg bg-zinc-800 border border-white/10 p-3 file:mr-4 file:rounded file:border-0 file:bg-white file:text-zinc-900 file:px-4 file:py-2 hover:cursor-pointer" required>
|
| 123 |
+
</label>
|
| 124 |
+
<button type="submit" class="mt-4 w-full rounded-xl bg-white text-zinc-900 font-semibold py-3 hover:-translate-y-0.5 transition">
|
| 125 |
+
Process Video
|
| 126 |
+
</button>
|
| 127 |
+
<p id="video-status" class="mt-2 text-sm text-zinc-400"></p>
|
| 128 |
+
</form>
|
| 129 |
+
<div class="flex justify-center mt-6">
|
| 130 |
+
<img id="video-stream" class="hidden rounded-lg border border-white/10 max-w-full" alt="Video feed">
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
<!-- Image Upload Mode -->
|
| 136 |
+
<div id="image-section" class="detection-section hidden">
|
| 137 |
+
<div class="grid lg:grid-cols-2 gap-10 items-start">
|
| 138 |
+
<!-- Upload panel -->
|
| 139 |
+
<div class="reveal opacity-0 translate-y-4 bg-zinc-900/50 rounded-2xl border border-white/5 p-6">
|
| 140 |
+
<h3 class="text-2xl font-bold">Upload an Image</h3>
|
| 141 |
+
<p class="text-zinc-300 text-sm mt-1">JPG/PNG up to ~10MB.</p>
|
| 142 |
+
|
| 143 |
+
<form id="image-form">
|
| 144 |
+
<label class="block">
|
| 145 |
+
<span class="text-sm text-zinc-300">Choose file</span>
|
| 146 |
+
<input id="image-input" type="file" accept=".jpg,.jpeg,.png,.webp"
|
| 147 |
+
class="mt-2 w-full rounded-lg bg-zinc-800 border border-white/10 p-3 file:mr-4 file:rounded file:border-0 file:bg-white file:text-zinc-900 file:px-4 file:py-2 hover:cursor-pointer" required>
|
| 148 |
+
</label>
|
| 149 |
+
|
| 150 |
+
<div id="preview" class="hidden mt-4">
|
| 151 |
+
<img id="preview-img" class="rounded-lg border border-white/10 max-h-64 object-contain mx-auto" alt="Preview">
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<button type="submit"
|
| 155 |
+
class="mt-4 w-full rounded-xl bg-white text-zinc-900 font-semibold py-3 hover:-translate-y-0.5 transition shadow-lg shadow-white/10 hover:shadow-white/20">
|
| 156 |
+
Analyze
|
| 157 |
+
</button>
|
| 158 |
+
<p id="image-status" class="mt-2 text-sm text-zinc-400"></p>
|
| 159 |
+
</form>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
<!-- Results panel -->
|
| 163 |
+
<div class="reveal opacity-0 translate-y-4 bg-zinc-900/50 rounded-2xl border border-white/5 p-6">
|
| 164 |
+
<h3 class="text-2xl font-bold">Detection Results</h3>
|
| 165 |
+
<div id="image-result" class="mt-4 space-y-4">
|
| 166 |
+
<div class="text-zinc-400 text-sm">Upload an image to see detections.</div>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
</section>
|
| 172 |
+
|
| 173 |
+
{% endblock %}
|