derpiestme commited on
Commit
8491f89
·
verified ·
1 Parent(s): 1e54f50

Upload 23 files

Browse files

Initial Deployment Commit

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 %}