bagdatli commited on
Commit
fdf3e3d
·
verified ·
1 Parent(s): 29db505

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +926 -766
app.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- BecomeAPro - AI-Powered Exercise Tracker (Hugging Face Space)
3
  Streamlit + WebRTC for in-browser real-time pose detection.
4
  """
5
  import json
@@ -11,14 +11,8 @@ from collections import Counter, deque
11
  from pathlib import Path
12
  from threading import Lock
13
 
14
- import av
15
- import cv2
16
- import mediapipe as mp
17
  import numpy as np
18
  import streamlit as st
19
- from joblib import load
20
- from mediapipe.tasks import python as mp_python
21
- from mediapipe.tasks.python import vision
22
  from streamlit_webrtc import WebRtcMode, webrtc_streamer
23
 
24
  try:
@@ -26,6 +20,9 @@ try:
26
  except ImportError:
27
  get_twilio_ice_servers = None
28
 
 
 
 
29
  logger = logging.getLogger(__name__)
30
 
31
  ROOT = Path(__file__).resolve().parent
@@ -93,872 +90,1076 @@ POSE_TO_TURKISH = {
93
  }
94
 
95
  EXERCISES = [
96
- {"name": "Sinav", "en": "Push-ups", "icon": "\U0001f4aa",
97
- "desc": "Gogus, omuz ve triceps kaslari icin temel egzersiz.", "color": "#00d4aa"},
98
- {"name": "Mekik", "en": "Sit-ups", "icon": "\U0001f504",
99
- "desc": "Karin kaslari icin etkili bir core egzersizi.", "color": "#7c3aed"},
100
- {"name": "Squat", "en": "Squats", "icon": "\U0001f9b5",
101
- "desc": "Bacak ve kalca kaslari icin en etkili hareket.", "color": "#f59e0b"},
102
- {"name": "Barfiks", "en": "Pull-ups", "icon": "\U0001f9d7",
103
- "desc": "Sirt ve biceps kaslarini guclendiren egzersiz.", "color": "#ef4444"},
104
- {"name": "Ziplama", "en": "Jumping Jacks", "icon": "\U0001f938",
105
- "desc": "Tam vucut kardiyo ve koordinasyon egzersizi.", "color": "#3b82f6"},
106
  ]
107
 
108
- # ---------------------------------------------------------------------------
109
- # Page config (must be first st call)
110
- # ---------------------------------------------------------------------------
111
-
112
  st.set_page_config(
113
  page_title="BecomeAPro | AI Exercise Tracker",
114
- page_icon="\U0001f3cb\ufe0f",
115
  layout="wide",
116
  initial_sidebar_state="collapsed",
117
  )
118
 
119
- # ---------------------------------------------------------------------------
120
- # CSS
121
- # ---------------------------------------------------------------------------
122
-
123
  CUSTOM_CSS = """\
124
  <style>
125
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
126
- #MainMenu, footer, header {visibility: hidden;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  .block-container {
128
  padding-top: 0 !important;
129
- max-width: 1100px;
130
  margin: 0 auto;
131
  padding-left: 2rem !important;
132
  padding-right: 2rem !important;
133
  }
134
 
135
  .stApp {
136
- background: linear-gradient(180deg, #080810 0%, #0d0d1a 40%, #080810 100%);
137
- color: #e0e0e8;
138
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
139
  }
140
 
141
- /* Streamlit column gap normalization */
142
- [data-testid="stHorizontalBlock"] {
143
- gap: 1rem !important;
144
- align-items: stretch !important;
145
- }
146
- [data-testid="stColumn"] {
147
- display: flex !important;
148
- flex-direction: column !important;
149
- }
150
- [data-testid="stColumn"] > div {
151
- flex: 1;
152
- }
153
 
154
- /* Hero */
155
  .hero {
156
- text-align: center;
157
- padding: 4rem 1rem 2rem;
 
 
 
 
158
  position: relative;
159
- overflow: hidden;
160
  }
161
- .hero::before {
162
  content: '';
163
  position: absolute;
164
- top: -60%; left: -30%; width: 160%; height: 220%;
165
- background:
166
- radial-gradient(ellipse at 30% 50%, rgba(0,212,170,0.07) 0%, transparent 50%),
167
- radial-gradient(ellipse at 70% 50%, rgba(124,58,237,0.07) 0%, transparent 50%);
168
- animation: drift 10s ease-in-out infinite alternate;
169
- pointer-events: none;
170
  }
171
- @keyframes drift {
172
- from { transform: translate(0,0) rotate(0deg); }
173
- to { transform: translate(-3%,2%) rotate(1deg); }
 
 
 
 
 
 
 
 
174
  }
175
- .hero-badge {
 
176
  display: inline-block;
177
- background: rgba(0,212,170,0.08);
178
- border: 1px solid rgba(0,212,170,0.25);
179
- border-radius: 50px;
180
- padding: 6px 20px;
181
- font-size: 0.82rem;
182
- color: #00d4aa;
183
- font-weight: 600;
184
- margin-bottom: 1.6rem;
185
- letter-spacing: 1.2px;
186
- text-transform: uppercase;
187
  }
188
  .hero h1 {
189
- font-size: clamp(2.2rem, 5vw, 3.8rem);
190
- font-weight: 800;
191
- line-height: 1.08;
192
- margin: 0 0 1.1rem;
193
- color: #ffffff;
194
- position: relative;
195
- }
196
- .hero h1 .grad {
197
- background: linear-gradient(135deg, #00d4aa 0%, #7c3aed 55%, #3b82f6 100%);
198
- -webkit-background-clip: text;
199
- -webkit-text-fill-color: transparent;
200
- background-clip: text;
201
  }
 
202
  .hero-sub {
203
- font-size: 1.08rem;
204
- color: #7a7a95;
205
- max-width: 540px;
206
- margin: 0 auto;
207
- line-height: 1.7;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  position: relative;
209
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
- /* Section Titles */
212
- .sec-title {
213
- font-size: 1.75rem;
214
- font-weight: 700;
215
- text-align: center;
216
- margin: 2.5rem 0 0.4rem;
217
- color: #fff;
 
218
  }
219
- .sec-sub {
220
- text-align: center;
221
- color: #7a7a95;
222
- font-size: 0.92rem;
223
- margin-bottom: 1.8rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  }
225
 
226
- /* Glass Card */
227
- .g-card {
228
- background: rgba(18,18,30,0.65);
229
- backdrop-filter: blur(14px);
230
- -webkit-backdrop-filter: blur(14px);
231
- border: 1px solid rgba(255,255,255,0.055);
232
- border-radius: 16px;
233
- padding: 1.6rem 1.3rem;
234
- transition: all 0.35s cubic-bezier(.4,0,.2,1);
235
  position: relative;
236
  overflow: hidden;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  height: 100%;
238
- box-sizing: border-box;
 
 
239
  }
240
- .g-card:hover {
241
- border-color: rgba(0,212,170,0.18);
242
  transform: translateY(-4px);
243
- box-shadow: 0 16px 48px rgba(0,0,0,0.25);
 
244
  }
245
-
246
- /* Step Cards */
247
- .step-num {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  display: inline-flex;
249
  align-items: center;
250
  justify-content: center;
251
- width: 44px; height: 44px;
252
- border-radius: 12px;
253
- background: linear-gradient(135deg, #00d4aa, #7c3aed);
254
- color: #fff;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  font-weight: 700;
256
- font-size: 1.1rem;
257
- margin-bottom: 0.8rem;
258
  }
259
- .step-t { font-size: 1.05rem; font-weight: 600; color: #fff; margin-bottom: 0.4rem; }
260
- .step-d { font-size: 0.85rem; color: #7a7a95; line-height: 1.6; }
261
-
262
- /* Exercise Cards */
263
- .accent-top {
264
- position: absolute;
265
- top: 0; left: 0; right: 0;
266
- height: 3px;
267
- border-radius: 16px 16px 0 0;
268
- opacity: 0.7;
269
- transition: opacity 0.3s;
270
  }
271
- .g-card:hover .accent-top { opacity: 1; }
272
- .ex-icon { font-size: 2.2rem; margin-bottom: 0.6rem; display: block; }
273
- .ex-name { font-size: 1rem; font-weight: 600; color: #fff; margin-bottom: 0.1rem; }
274
- .ex-en { font-size: 0.75rem; color: #5a5a7a; margin-bottom: 0.4rem; }
275
- .ex-desc { font-size: 0.8rem; color: #7a7a95; line-height: 1.5; }
276
-
277
- /* CTA Section */
278
- .cta-box {
279
- text-align: center;
280
- padding: 2.5rem 2rem 1.2rem;
281
- background: linear-gradient(135deg, rgba(0,212,170,0.04), rgba(124,58,237,0.04));
282
- border: 1px solid rgba(255,255,255,0.04);
283
- border-radius: 20px;
284
- margin: 2rem 0 0;
285
  position: relative;
286
- overflow: hidden;
287
  }
288
- .cta-box::before {
289
  content: '';
290
  position: absolute;
291
- inset: -1px;
292
- border-radius: 20px;
293
- background: linear-gradient(135deg, rgba(0,212,170,0.12), transparent 40%, rgba(124,58,237,0.12));
294
- z-index: 0;
295
- pointer-events: none;
296
  }
297
- .cta-t { font-size: 1.6rem; font-weight: 700; color: #fff; margin-bottom: 0.4rem; position: relative; }
298
- .cta-d { color: #7a7a95; margin-bottom: 0.2rem; font-size: 0.9rem; position: relative; }
299
-
300
- /* Metric Card */
301
- .m-val {
302
- font-size: 2rem;
303
- font-weight: 700;
304
- background: linear-gradient(135deg, #00d4aa, #7c3aed);
305
- -webkit-background-clip: text;
306
- -webkit-text-fill-color: transparent;
307
- background-clip: text;
308
  }
309
- .m-lbl { font-size: 0.82rem; color: #7a7a95; font-weight: 500; margin-top: 4px; }
 
 
 
 
 
 
 
 
310
 
311
- /* Primary Button */
312
- div.stButton > button[kind="primary"],
313
- div.stButton > button[data-testid="stBaseButton-primary"] {
314
- background: linear-gradient(135deg, #00d4aa 0%, #00b894 100%) !important;
315
- border: none !important;
316
- border-radius: 14px !important;
317
- padding: 0.85rem 2.8rem !important;
318
- font-size: 1.08rem !important;
319
- font-weight: 600 !important;
320
- font-family: 'Inter', sans-serif !important;
321
- color: #080810 !important;
322
- box-shadow: 0 4px 24px rgba(0,212,170,0.22) !important;
323
- transition: all 0.3s cubic-bezier(.4,0,.2,1) !important;
324
- letter-spacing: 0.3px !important;
325
- min-height: 56px !important;
 
 
326
  }
327
- div.stButton > button[kind="primary"]:hover,
328
- div.stButton > button[data-testid="stBaseButton-primary"]:hover {
329
- box-shadow: 0 8px 36px rgba(0,212,170,0.38) !important;
330
- transform: translateY(-2px) !important;
 
 
 
 
 
 
 
331
  }
 
332
 
333
- /* Tip Box */
334
- .tip-box {
335
- background: rgba(59,130,246,0.06);
336
- border: 1px solid rgba(59,130,246,0.15);
337
- border-radius: 14px;
338
- padding: 1rem 1.4rem;
339
- color: #8ab4f8;
340
- font-size: 0.86rem;
341
- line-height: 1.6;
342
- margin: 0.8rem 0;
343
  }
344
- .tip-box strong { color: #a8ccff; }
345
-
346
- /* Helpers */
347
- .sep {
348
- height: 1px;
349
- background: linear-gradient(90deg, transparent, rgba(255,255,255,0.06), transparent);
350
- margin: 2rem 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  }
352
 
353
- /* Footer */
354
- .foot {
355
- text-align: center;
356
- padding: 2rem 0 1.5rem;
357
- color: #444460;
358
- font-size: 0.82rem;
359
- margin-top: 2.5rem;
360
- border-top: 1px solid rgba(255,255,255,0.04);
 
 
 
 
 
361
  }
362
- .foot a { color: #00d4aa; text-decoration: none; }
363
 
364
- /* Scrollbar */
365
- ::-webkit-scrollbar { width: 6px; }
366
- ::-webkit-scrollbar-track { background: transparent; }
367
- ::-webkit-scrollbar-thumb { background: #2a2a3e; border-radius: 3px; }
368
- ::-webkit-scrollbar-thumb:hover { background: #3a3a52; }
369
-
370
- /* Camera Section */
371
- .cam-wrapper {
372
- background: rgba(12,12,22,0.8);
373
- border: 1px solid rgba(255,255,255,0.06);
374
- border-radius: 20px;
375
- padding: 1.5rem;
376
- margin: 1.2rem auto 0;
377
- max-width: 720px;
378
- position: relative;
379
  overflow: hidden;
 
 
 
380
  }
381
- .cam-wrapper::before {
382
- content: '';
383
- position: absolute;
384
- inset: -1px;
385
- border-radius: 20px;
386
- background: linear-gradient(135deg, rgba(0,212,170,0.15), transparent 40%, rgba(124,58,237,0.15));
387
- z-index: 0;
388
- pointer-events: none;
 
 
 
 
 
 
 
 
 
 
 
 
389
  }
390
- .cam-header {
 
391
  display: flex;
392
  align-items: center;
393
- gap: 10px;
394
- margin-bottom: 1rem;
395
- position: relative;
396
- z-index: 1;
397
  }
398
- .cam-dot {
399
- width: 10px; height: 10px;
400
- border-radius: 50%;
401
- background: #00d4aa;
402
- box-shadow: 0 0 8px rgba(0,212,170,0.5);
403
- animation: camPulse 2s ease-in-out infinite;
 
404
  }
405
- .cam-dot.off { background: #555; box-shadow: none; animation: none; }
406
- @keyframes camPulse {
407
- 0%,100% { opacity: 1; transform: scale(1); }
408
- 50% { opacity: 0.5; transform: scale(0.85); }
 
 
 
409
  }
410
- .cam-label {
411
- font-size: 0.85rem;
412
- font-weight: 600;
413
- color: #a0a0b8;
414
- letter-spacing: 0.5px;
415
  text-transform: uppercase;
 
 
416
  }
417
- .cam-guide {
418
- display: flex;
419
- gap: 1.2rem;
420
- margin: 1.2rem 0;
421
- position: relative;
422
- z-index: 1;
423
- }
424
- .cam-guide-step {
425
- flex: 1;
426
- background: rgba(255,255,255,0.03);
427
- border: 1px solid rgba(255,255,255,0.05);
428
- border-radius: 12px;
429
- padding: 1rem 0.8rem;
430
- text-align: center;
431
  }
432
- .cam-guide-icon { font-size: 1.5rem; margin-bottom: 0.4rem; display: block; }
433
- .cam-guide-text { font-size: 0.78rem; color: #7a7a95; line-height: 1.4; }
434
 
435
- /* WebRTC component wrapper */
436
- iframe[title*="webrtc"] {
437
- border: 1px solid rgba(255,255,255,0.06) !important;
438
- border-radius: 14px !important;
439
- background: rgba(10,10,20,0.9) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  }
441
 
442
- /* Status Pill */
443
- .status-pill {
444
- display: inline-flex;
445
- align-items: center;
446
- gap: 8px;
447
- background: rgba(0,212,170,0.08);
448
- border: 1px solid rgba(0,212,170,0.25);
449
- border-radius: 50px;
450
- padding: 6px 16px;
451
- color: #00d4aa;
452
- font-size: 0.82rem;
453
- font-weight: 500;
454
  }
455
- .status-dot {
456
- width: 8px; height: 8px;
457
- border-radius: 50%;
458
- background: #00d4aa;
459
- animation: camPulse 1.5s ease-in-out infinite;
460
- }
461
-
462
- /* Troubleshoot Card */
463
- .trouble-card {
464
- background: rgba(245,158,11,0.06);
465
- border: 1px solid rgba(245,158,11,0.15);
466
- border-radius: 14px;
467
- padding: 1.1rem 1.4rem;
468
- color: #f5c96a;
469
- font-size: 0.84rem;
470
- line-height: 1.65;
471
- margin: 0.8rem 0;
 
472
  }
473
- .trouble-card strong { color: #fad683; }
474
 
475
- /* Onboarding Card */
476
- .onboard-card {
477
- background: linear-gradient(135deg, rgba(124,58,237,0.08), rgba(0,212,170,0.08));
478
- border: 1px solid rgba(124,58,237,0.15);
479
- border-radius: 16px;
480
- padding: 2rem;
481
- text-align: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  }
483
- .onboard-card h3 { color: #fff; font-size: 1.3rem; margin-bottom: 0.6rem; }
484
- .onboard-card p { color: #7a7a95; font-size: 0.92rem; line-height: 1.6; }
 
 
485
  </style>
486
  """
487
 
488
-
489
- # ---------------------------------------------------------------------------
490
- # ICE / TURN configuration
491
- # ---------------------------------------------------------------------------
492
-
493
-
494
  def get_ice_config() -> dict:
495
- """Return WebRTC ICE configuration with Twilio TURN servers when available,
496
- falling back to Google STUN for local development."""
497
  if get_twilio_ice_servers is not None:
498
  try:
499
- sid = os.environ.get("TWILIO_ACCOUNT_SID", "")
500
- token = os.environ.get("TWILIO_AUTH_TOKEN", "")
501
- if not sid:
502
- sid = st.secrets.get("TWILIO_ACCOUNT_SID", "")
503
- if not token:
504
- token = st.secrets.get("TWILIO_AUTH_TOKEN", "")
505
  if sid and token:
506
  ice = get_twilio_ice_servers(twilio_sid=sid, twilio_token=token)
507
- logger.info("Using Twilio TURN servers (%d entries)", len(ice))
508
  return {"iceServers": ice}
509
  except Exception as exc:
510
- logger.warning("Twilio ICE fetch failed, falling back to STUN: %s", exc)
511
  return {"iceServers": [{"urls": ["stun:stun.l.google.com:19302"]}]}
512
 
513
-
514
- # ---------------------------------------------------------------------------
515
- # Pose detection helpers (from camera_demo.py, adapted for WebRTC)
516
- # ---------------------------------------------------------------------------
517
-
518
-
519
  def label_to_turkish(label: str) -> str:
520
  return POSE_TO_TURKISH.get(label, label)
521
 
522
-
523
  def ensure_pose_model() -> str:
 
524
  if POSE_MODEL_PATH.exists():
525
  return str(POSE_MODEL_PATH)
526
  MODELS_DIR.mkdir(parents=True, exist_ok=True)
527
- logger.info("Downloading pose_landmarker model...")
528
- urllib.request.urlretrieve(POSE_MODEL_URL, POSE_MODEL_PATH)
529
- logger.info("Download complete.")
 
 
 
 
 
 
530
  return str(POSE_MODEL_PATH)
531
 
532
-
533
  def landmarks_to_vector(landmark_list, feature_columns):
534
  name_to_idx = {name: i for i, name in enumerate(MP_INDEX_TO_NAME)}
535
  for alias, canonical in NAME_ALIASES.items():
536
  name_to_idx[alias] = name_to_idx.get(canonical, 0)
537
-
538
  values = []
539
  for col in feature_columns:
540
  if not col.startswith(("x_", "y_", "z_")):
541
  continue
542
- axis = col[0]
543
- name = col[2:].strip()
544
- name = NAME_ALIASES.get(name, name)
545
  idx = name_to_idx.get(name, -1)
546
- if idx < 0:
547
- values.append(0.0)
548
- continue
549
  lm = landmark_list[idx]
550
  x_val = lm.x if lm.x is not None else 0.0
551
  y_val = lm.y if lm.y is not None else 0.0
552
  z_val = lm.z if lm.z is not None else 0.0
553
- if axis == "x":
554
- values.append((x_val - 0.5) * SCALE_XY)
555
- elif axis == "y":
556
- values.append((y_val - 0.5) * SCALE_XY)
557
- else:
558
- values.append(z_val * SCALE_Z)
559
  return np.array(values, dtype=np.float32).reshape(1, -1)
560
 
561
-
562
  def predict_single(ml_model, encoder, scaler, model_type, X, buffer):
563
- """Run prediction with smoothing over the last N frames."""
564
  X_scaled = scaler.transform(X)
565
  if model_type == "xgboost":
566
  pred_idx = ml_model.predict(X_scaled)[0]
567
- probs = ml_model.predict_proba(X_scaled)[0]
568
  else:
569
  import torch
570
  with torch.no_grad():
571
- X_t = torch.from_numpy(X_scaled.astype(np.float32))
572
  logits = ml_model(X_t)
573
- probs = torch.softmax(logits, dim=1).numpy()[0]
574
  pred_idx = int(np.argmax(probs))
575
-
576
  conf = float(probs[pred_idx])
577
- if conf < CONFIDENCE_THRESHOLD:
578
- buffer.append("Belirsiz")
579
- else:
580
- label = encoder.inverse_transform([pred_idx])[0]
581
- buffer.append(label)
582
-
583
- counted = Counter(buffer)
584
- mode_label = counted.most_common(1)[0][0]
585
  return mode_label, conf
586
 
587
-
588
  def draw_overlay_panel(frame, label, conf, reps=None):
 
589
  h, w = frame.shape[:2]
590
  has_reps = reps is not None and reps > 0
591
  panel_h = 120 if has_reps else 90
592
  panel_w = min(400, w - 20)
593
- x1, y1 = 10, 10
594
- x2, y2 = x1 + panel_w, y1 + panel_h
595
-
596
  overlay = frame.copy()
597
- cv2.rectangle(overlay, (x1, y1), (x2, y2), (30, 30, 30), -1)
598
- cv2.addWeighted(overlay, 0.75, frame, 0.25, 0, frame)
599
- cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 200, 100), 2)
600
-
601
  turkce = label_to_turkish(label)
602
- font = cv2.FONT_HERSHEY_SIMPLEX
603
- color = (0, 255, 150) if label != "Belirsiz" else (100, 100, 100)
604
-
605
- cv2.putText(frame, f"Hareket: {turkce}", (x1 + 12, y1 + 38),
606
- font, 0.9, color, 2)
607
- cv2.putText(frame, f"Guven: %{conf * 100:.0f}", (x1 + 12, y1 + 72),
608
- font, 0.7, (200, 200, 200), 2)
609
-
610
  if has_reps:
611
- cv2.putText(frame, f"Tekrar: {reps}", (x1 + 12, y1 + 106),
612
- font, 0.8, (0, 212, 170), 2)
613
-
614
 
615
  def draw_center_counter(frame, reps, frames_since_rep):
616
- """Draw a large fading rep number in the center of the frame."""
617
- if frames_since_rep >= REP_DISPLAY_FRAMES:
618
- return
619
  alpha = 1.0 - (frames_since_rep / REP_DISPLAY_FRAMES)
620
  h, w = frame.shape[:2]
621
  text = str(reps)
622
- font = cv2.FONT_HERSHEY_SIMPLEX
623
- scale = 4.0
624
- thickness = 8
625
- (tw, th), _ = cv2.getTextSize(text, font, scale, thickness)
626
- tx = (w - tw) // 2
627
- ty = (h + th) // 2
628
-
629
  overlay = frame.copy()
630
- cv2.putText(overlay, text, (tx, ty), font, scale, (200, 200, 200), thickness)
631
- cv2.addWeighted(overlay, alpha * 0.6, frame, 1.0 - alpha * 0.6, 0, frame)
632
-
633
-
634
- # ---------------------------------------------------------------------------
635
- # Thread-safe model & pose landmarker loader
636
- # ---------------------------------------------------------------------------
637
-
638
 
 
639
  @st.cache_resource
640
- def load_all_artifacts():
641
- """Load ML model, scaler, encoder, feature columns, and MediaPipe pose landmarker.
642
- Returns None tuple if model files are missing."""
643
- meta_path = MODELS_DIR / "meta.pkl"
644
  metadata_path = MODELS_DIR / "metadata.json"
645
  if not meta_path.exists() or not metadata_path.exists():
646
- return None, None, None, None, None, None, None
647
-
648
- meta = load(meta_path)
649
- encoder = load(MODELS_DIR / "encoder.pkl")
650
- scaler = load(MODELS_DIR / "scaler.pkl")
651
- model_type = meta.get("model_type", "xgboost")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
 
653
- model_path = meta.get("model_path")
654
- if model_path:
655
- filename = model_path.replace("\\", "/").split("/")[-1]
656
- model_path = MODELS_DIR / filename
657
 
658
- if model_type == "xgboost":
659
- ml_model = load(model_path)
660
- else:
661
- import torch
662
- input_size = meta.get("input_size", 99)
663
- num_classes = meta.get("num_classes", 10)
664
- from torch import nn
665
- ml_model = nn.Sequential(
666
- nn.Linear(input_size, 200),
667
- nn.ReLU(),
668
- nn.Linear(200, num_classes),
669
  )
670
- ml_model.load_state_dict(torch.load(model_path, map_location="cpu"))
671
- ml_model.eval()
 
 
672
 
673
- with open(metadata_path, encoding="utf-8") as f:
674
- feature_columns = json.load(f).get("feature_columns", [])
675
-
676
- pose_model_path = ensure_pose_model()
677
- base_options = mp_python.BaseOptions(model_asset_path=pose_model_path)
678
- options = vision.PoseLandmarkerOptions(
679
- base_options=base_options,
680
- running_mode=vision.RunningMode.IMAGE,
681
- )
682
- pose_landmarker = vision.PoseLandmarker.create_from_options(options)
683
 
 
 
 
 
 
 
 
 
684
  return ml_model, encoder, scaler, model_type, feature_columns, pose_landmarker, meta
685
 
686
-
687
- # ---------------------------------------------------------------------------
688
- # WebRTC video callback
689
- # ---------------------------------------------------------------------------
690
-
691
  _buffer_lock = Lock()
692
  _prediction_buffer: deque = deque(maxlen=BUFFER_SIZE)
693
 
694
-
695
  def _draw_body_skeleton(img, pose_landmarks):
696
- """Draw only the 14 essential body landmarks and connections (skip face/hands/feet)."""
697
  h, w = img.shape[:2]
698
  points = {}
699
  for idx in BODY_LANDMARK_INDICES:
700
  lm = pose_landmarks[idx]
701
  px, py = int(lm.x * w), int(lm.y * h)
702
  points[idx] = (px, py)
703
- cv2.circle(img, (px, py), 5, (0, 212, 170), -1)
704
- cv2.circle(img, (px, py), 7, (0, 212, 170), 1)
705
-
706
  for a, b in BODY_CONNECTIONS:
707
  if a in points and b in points:
708
- cv2.line(img, points[a], points[b], (0, 255, 0), 2)
709
-
710
 
711
- def make_video_frame_callback(ml_model, encoder, scaler, model_type,
712
- feature_columns, pose_landmarker):
713
- """Create a closure that captures loaded artifacts for the WebRTC callback."""
714
  frame_counter = [0]
715
- cached_label = ["Belirsiz"]
716
- cached_conf = [0.0]
717
-
718
  rep_state = {
719
- "phase": "idle",
720
- "reps": 0,
721
- "debounce_count": 0,
722
- "pending_phase": None,
723
- "frames_since_rep": REP_DISPLAY_FRAMES,
724
- "exercise_reps": {},
725
- "start_time": None,
726
  }
727
 
728
  def _update_rep_counter(label):
729
- """State machine: idle -> down -> up (rep++) -> down -> up (rep++) ..."""
730
  phase = rep_state["phase"]
731
-
732
  if rep_state["start_time"] is None and label != "Belirsiz":
733
  rep_state["start_time"] = time.time()
734
-
735
  exercise = label.rsplit("_", 1)[0] if "_" in label else None
736
-
737
- if label.endswith("_down"):
738
- target = "down"
739
- elif label.endswith("_up"):
740
- target = "up"
741
  else:
742
- rep_state["debounce_count"] = 0
743
- rep_state["pending_phase"] = None
744
- return
745
-
746
- if phase == "idle" and target == "down":
747
- _try_transition("down", exercise)
748
  elif phase == "down" and target == "up":
749
  if _try_transition("up", exercise):
750
- rep_state["reps"] += 1
751
- rep_state["frames_since_rep"] = 0
752
  if exercise:
753
- rep_state["exercise_reps"][exercise] = (
754
- rep_state["exercise_reps"].get(exercise, 0) + 1
755
- )
756
- elif phase == "up" and target == "down":
757
- _try_transition("down", exercise)
758
 
759
  def _try_transition(target, exercise=None):
760
- if rep_state["pending_phase"] == target:
761
- rep_state["debounce_count"] += 1
762
- else:
763
- rep_state["pending_phase"] = target
764
- rep_state["debounce_count"] = 1
765
-
766
  if rep_state["debounce_count"] >= REP_DEBOUNCE:
767
- rep_state["phase"] = target
768
- rep_state["pending_phase"] = None
769
- rep_state["debounce_count"] = 0
770
- return True
771
  return False
772
 
773
- def video_frame_callback(frame: av.VideoFrame) -> av.VideoFrame:
 
 
 
774
  img = frame.to_ndarray(format="bgr24")
775
- img = cv2.flip(img, 1)
776
-
777
- frame_counter[0] += 1
778
- rep_state["frames_since_rep"] += 1
779
- should_process = (frame_counter[0] % FRAME_SKIP == 0)
780
-
781
- if not should_process:
782
- draw_overlay_panel(img, cached_label[0], cached_conf[0],
783
- reps=rep_state["reps"])
784
- draw_center_counter(img, rep_state["reps"],
785
- rep_state["frames_since_rep"])
786
- return av.VideoFrame.from_ndarray(img, format="bgr24")
787
-
788
- rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
789
- mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
790
-
791
  try:
792
  detection_result = pose_landmarker.detect(mp_image)
793
  except Exception:
794
  draw_overlay_panel(img, "Belirsiz", 0.0, reps=rep_state["reps"])
795
- return av.VideoFrame.from_ndarray(img, format="bgr24")
796
-
797
  if detection_result.pose_landmarks:
798
  pose_landmarks = detection_result.pose_landmarks[0]
799
  _draw_body_skeleton(img, pose_landmarks)
800
-
801
  try:
802
  X = landmarks_to_vector(pose_landmarks, feature_columns)
803
  if X.shape[1] == scaler.n_features_in_:
804
  with _buffer_lock:
805
- label, conf = predict_single(
806
- ml_model, encoder, scaler, model_type,
807
- X, _prediction_buffer,
808
- )
809
- cached_label[0] = label
810
- cached_conf[0] = conf
811
  _update_rep_counter(label)
812
- draw_overlay_panel(img, label, conf,
813
- reps=rep_state["reps"])
814
  except Exception as e:
815
- cv2.putText(img, f"Error: {e}", (10, 30),
816
- cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1)
817
  else:
818
- cached_label[0] = "Belirsiz"
819
- cached_conf[0] = 0.0
820
  draw_overlay_panel(img, "Belirsiz", 0.0, reps=rep_state["reps"])
821
  h, w = img.shape[:2]
822
- cv2.putText(img, "Tam vucut gorunumunde durun",
823
- (10, h - 25), cv2.FONT_HERSHEY_SIMPLEX,
824
- 0.55, (0, 165, 255), 1)
825
-
826
- draw_center_counter(img, rep_state["reps"],
827
- rep_state["frames_since_rep"])
828
- return av.VideoFrame.from_ndarray(img, format="bgr24")
829
 
830
  return video_frame_callback, rep_state
831
 
832
-
833
- # ---------------------------------------------------------------------------
834
- # UI Sections
835
- # ---------------------------------------------------------------------------
836
-
837
-
838
  def render_hero():
839
- st.markdown(
840
- """
841
  <div class="hero">
842
- <div class="hero-badge">AI-Powered Fitness</div>
843
- <h1>
844
- Egzersizlerini<br>
845
- <span class="grad">Yapay Zeka ile Takip Et</span>
846
- </h1>
847
- <p class="hero-sub">
848
- Kamerani ac, egzersizini yap. Yapay zeka hareketlerini anlik olarak tanir,
849
- tekrarlarini sayar ve performansini takip eder.
850
- </p>
 
 
 
 
 
 
851
  </div>
852
- """,
853
- unsafe_allow_html=True,
854
- )
855
 
856
 
857
  def render_stats():
858
- cols = st.columns(3)
859
- stats = [
860
- ("5", "Desteklenen Egzersiz"),
861
- ("10", "Hareket Pozisyonu"),
862
- ("33", "Vucut Noktasi Takibi"),
863
- ]
864
- for col, (val, label) in zip(cols, stats):
865
- with col:
866
- st.markdown(
867
- f"""
868
- <div class="g-card" style="text-align:center; padding:1.4rem 1rem;">
869
- <div class="m-val">{val}</div>
870
- <div class="m-lbl">{label}</div>
871
- </div>
872
- """,
873
- unsafe_allow_html=True,
874
- )
875
 
876
 
877
  def render_how_it_works():
878
- st.markdown('<div class="sep"></div>', unsafe_allow_html=True)
879
- st.markdown('<div class="sec-title">Nasil Calisir?</div>', unsafe_allow_html=True)
880
- st.markdown(
881
- '<div class="sec-sub">Uc basit adimda antrenmanina basla</div>',
882
- unsafe_allow_html=True,
883
- )
 
 
884
  steps = [
885
- ("1", "Kamerayi Baslat",
886
- "Asagidaki START butonuna tiklayarak tarayici kameranizi acin. "
887
- "Kameranin tam vucudunuzu gorecegi bir konumda durun."),
888
- ("2", "Egzersizini Yap",
889
- "Sinav, mekik, squat veya baska bir egzersiz yapmaya baslayin. "
890
- "AI modeli hareketlerinizi anlik olarak tanir."),
891
- ("3", "Sonuclarini Gor",
892
- "Hareket tipi ve guven orani video uzerinde "
893
- "canli olarak gosterilir."),
894
  ]
895
  cols = st.columns(3)
896
- for col, (num, title, desc) in zip(cols, steps):
897
  with col:
898
- st.markdown(
899
- f"""
900
- <div class="g-card" style="text-align:center;">
901
- <div class="step-num">{num}</div>
902
- <div class="step-t">{title}</div>
903
- <div class="step-d">{desc}</div>
 
904
  </div>
905
- """,
906
- unsafe_allow_html=True,
907
- )
908
 
909
 
910
  def render_exercises():
911
- st.markdown('<div class="sep"></div>', unsafe_allow_html=True)
912
- st.markdown(
913
- '<div class="sec-title">Desteklenen Egzersizler</div>',
914
- unsafe_allow_html=True,
915
- )
916
- st.markdown(
917
- '<div class="sec-sub">AI modelimiz asagidaki hareketleri taniyor</div>',
918
- unsafe_allow_html=True,
919
- )
920
  cols = st.columns(5)
921
  for col, ex in zip(cols, EXERCISES):
922
  with col:
923
- st.markdown(
924
- f"""
925
- <div class="g-card" style="text-align:center; padding:1.6rem 0.8rem;">
926
- <div class="accent-top" style="background:{ex['color']};"></div>
927
- <div class="ex-icon">{ex['icon']}</div>
928
- <div class="ex-name">{ex['name']}</div>
929
  <div class="ex-en">{ex['en']}</div>
930
- <div class="ex-desc">{ex['desc']}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
931
  </div>
932
- """,
933
- unsafe_allow_html=True,
934
- )
935
-
936
-
937
- def render_camera_section(ml_model, encoder, scaler, model_type,
938
- feature_columns, pose_landmarker):
939
- st.markdown('<div class="sep"></div>', unsafe_allow_html=True)
940
- st.markdown(
941
- """
942
- <div class="cta-box">
943
- <div class="cta-t">Antrenmanina Basla</div>
944
- <div class="cta-d">
945
- START butonuna tiklayarak kameranizi acin ve egzersize baslayin
946
  </div>
947
- </div>
948
- """,
949
- unsafe_allow_html=True,
950
- )
951
-
952
- st.markdown(
953
- """
954
- <div class="tip-box" style="margin-top:1rem; text-align:center;">
955
- <strong>Ipucu:</strong> Iyi aydinlatilmis bir ortamda
956
- tam vucut gorunumunde durmaniz en iyi sonuclari verir.
957
- Tarayiciniz kamera izni isteyecektir.
958
- </div>
959
- """,
960
- unsafe_allow_html=True,
961
- )
962
 
963
  callback, rep_state = make_video_frame_callback(
964
  ml_model, encoder, scaler, model_type, feature_columns, pose_landmarker,
@@ -966,16 +1167,13 @@ def render_camera_section(ml_model, encoder, scaler, model_type,
966
 
967
  _pad_l, cam_col, _pad_r = st.columns([1, 6, 1])
968
  with cam_col:
969
- st.markdown(
970
- """
971
- <div class="cam-wrapper">
972
- <div class="cam-header">
973
- <div class="cam-dot off" id="camDot"></div>
974
- <span class="cam-label">Kamera</span>
975
  </div>
976
- """,
977
- unsafe_allow_html=True,
978
- )
979
 
980
  webrtc_ctx = webrtc_streamer(
981
  key="exercise-detection",
@@ -987,28 +1185,18 @@ def render_camera_section(ml_model, encoder, scaler, model_type,
987
  },
988
  async_processing=True,
989
  rtc_configuration=get_ice_config(),
990
- translations={
991
- "start": "START",
992
- "stop": "STOP",
993
- "select_device": "Kamera Sec",
994
- },
995
  )
996
-
997
  st.markdown("</div>", unsafe_allow_html=True)
998
 
999
  if webrtc_ctx.state.playing:
1000
  with cam_col:
1001
- st.markdown(
1002
- """
1003
- <div style="text-align:center; margin-top:0.6rem;">
1004
- <div class="status-pill">
1005
- <span class="status-dot"></span>
1006
- Kamera aktif &mdash; Egzersize baslayin
1007
- </div>
1008
  </div>
1009
- """,
1010
- unsafe_allow_html=True,
1011
- )
1012
  if rep_state["start_time"] is None:
1013
  rep_state["start_time"] = time.time()
1014
  st.session_state["rep_state_snapshot"] = {
@@ -1017,32 +1205,24 @@ def render_camera_section(ml_model, encoder, scaler, model_type,
1017
  "start_time": rep_state["start_time"],
1018
  }
1019
  else:
 
1020
  with cam_col:
1021
- st.markdown(
1022
- """
1023
- <div class="cam-guide">
1024
- <div class="cam-guide-step">
1025
- <span class="cam-guide-icon">&#x1F4F7;</span>
1026
- <span class="cam-guide-text">Kamera iznini<br>onaylayin</span>
1027
- </div>
1028
- <div class="cam-guide-step">
1029
- <span class="cam-guide-icon">&#x1F9CD;</span>
1030
- <span class="cam-guide-text">Tam vucut<br>gorunumunde durun</span>
1031
- </div>
1032
- <div class="cam-guide-step">
1033
- <span class="cam-guide-icon">&#x1F3CB;</span>
1034
- <span class="cam-guide-text">Egzersizinizi<br>yapmaya baslayin</span>
1035
- </div>
1036
  </div>
1037
- <div class="trouble-card" style="text-align:center;">
1038
- <strong>Baglanti sorunu mu yasiyorsunuz?</strong><br>
1039
- Tarayicinizin kamera erisim izni verdiginizden emin olun.
1040
- Chrome veya Edge kullanmaniz onerilir.
1041
- Sorun devam ederse sayfayi yenileyip tekrar deneyin.
1042
- </div>
1043
- """,
1044
- unsafe_allow_html=True,
1045
- )
1046
 
1047
  if st.session_state.get("rep_state_snapshot"):
1048
  snap = st.session_state["rep_state_snapshot"]
@@ -1051,105 +1231,81 @@ def render_camera_section(ml_model, encoder, scaler, model_type,
1051
  _render_workout_summary(snap, elapsed)
1052
  st.session_state["rep_state_snapshot"] = None
1053
 
 
 
1054
 
1055
  def _render_workout_summary(snap, elapsed_seconds):
1056
- """Render workout summary after camera stops."""
1057
  mins = int(elapsed_seconds) // 60
1058
  secs = int(elapsed_seconds) % 60
1059
-
1060
  total_kcal = 0.0
1061
- rows_html = ""
1062
  exercise_names = {
1063
- "pushups": "Sinav",
1064
- "situp": "Mekik",
1065
- "squats": "Squat",
1066
- "pullups": "Barfiks",
1067
- "jumping_jacks": "Ziplama",
1068
  }
 
1069
  for ex, count in snap["exercise_reps"].items():
1070
- name = exercise_names.get(ex, ex)
1071
- kcal = count * KCAL_PER_REP.get(ex, 0.3)
1072
  total_kcal += kcal
1073
  rows_html += f"""
1074
- <div style="display:flex; justify-content:space-between; padding:8px 0;
1075
- border-bottom:1px solid rgba(255,255,255,0.06);">
1076
- <span>{name}</span>
1077
- <span style="color:#00d4aa; font-weight:600;">{count} tekrar</span>
1078
  </div>"""
1079
-
1080
- st.markdown(
1081
- f"""
1082
- <div style="background:rgba(18,18,30,0.85); border:1px solid rgba(0,212,170,0.2);
1083
- border-radius:20px; padding:2rem; margin:1.5rem 0;
1084
- max-width:500px; margin-left:auto; margin-right:auto;">
1085
- <div style="text-align:center; margin-bottom:1.2rem;">
1086
- <div style="font-size:1.4rem; font-weight:700; color:#fff;">
1087
- Antrenman Ozeti
1088
- </div>
1089
- <div style="color:#7a7a95; font-size:0.9rem; margin-top:4px;">
1090
- Sure: {mins} dk {secs} sn
1091
- </div>
1092
  </div>
1093
- {rows_html}
1094
- <div style="display:flex; justify-content:space-between; padding:12px 0 0;
1095
- margin-top:8px;">
1096
- <span style="font-weight:600; color:#fff;">Tahmini Kalori</span>
1097
- <span style="color:#f59e0b; font-weight:700; font-size:1.1rem;">
1098
- {total_kcal:.1f} kcal
1099
- </span>
1100
  </div>
1101
  </div>
1102
- """,
1103
- unsafe_allow_html=True,
1104
- )
1105
 
1106
 
1107
  def render_footer():
1108
- st.markdown(
1109
- """
1110
  <div class="foot">
1111
- <strong>BecomeAPro</strong> &mdash; AI-Powered Exercise Tracker<br>
1112
- <span style="font-size:0.78rem; margin-top:4px; display:inline-block;">
1113
- MediaPipe &bull; XGBoost / PyTorch &bull; Streamlit &bull; WebRTC
1114
- </span>
1115
  </div>
1116
- """,
1117
- unsafe_allow_html=True,
1118
- )
1119
 
1120
 
1121
  def render_model_missing():
1122
- st.markdown('<div class="sep"></div>', unsafe_allow_html=True)
1123
  _p1, col_c, _p2 = st.columns([1, 3, 1])
1124
  with col_c:
1125
- st.markdown(
1126
- """
1127
- <div class="onboard-card">
1128
- <h3>Model Dosyalari Bulunamadi</h3>
1129
- <p>
1130
- Uygulamanin calisabilmesi icin egitilmis model dosyalarinin
1131
- <code>models/</code> klasorune eklenmesi gerekiyor.
1132
- </p>
1133
- <p style="margin-top:1rem; font-size:0.85rem; color:#5a5a7a;">
1134
- Gerekli dosyalar: meta.pkl, encoder.pkl, scaler.pkl,
1135
- final_model.pkl, metadata.json
1136
- </p>
 
 
1137
  </div>
1138
- """,
1139
- unsafe_allow_html=True,
1140
- )
1141
-
1142
-
1143
- # ---------------------------------------------------------------------------
1144
- # Main
1145
- # ---------------------------------------------------------------------------
1146
 
1147
 
 
1148
  def main():
1149
  st.markdown(CUSTOM_CSS, unsafe_allow_html=True)
1150
 
1151
- result = load_all_artifacts()
1152
- ml_model = result[0]
1153
 
1154
  render_hero()
1155
 
@@ -1158,16 +1314,20 @@ def main():
1158
  render_footer()
1159
  return
1160
 
1161
- ml_model, encoder, scaler, model_type, feature_columns, pose_landmarker, _ = result
1162
-
1163
  render_stats()
1164
  render_how_it_works()
1165
  render_exercises()
1166
- render_camera_section(
1167
- ml_model, encoder, scaler, model_type, feature_columns, pose_landmarker,
1168
- )
 
 
 
 
 
 
1169
  render_footer()
1170
 
1171
 
1172
  if __name__ == "__main__":
1173
- main()
 
1
  """
2
+ BecomeAPro - AI-Powered Exercise Tracker (Redesigned UI)
3
  Streamlit + WebRTC for in-browser real-time pose detection.
4
  """
5
  import json
 
11
  from pathlib import Path
12
  from threading import Lock
13
 
 
 
 
14
  import numpy as np
15
  import streamlit as st
 
 
 
16
  from streamlit_webrtc import WebRtcMode, webrtc_streamer
17
 
18
  try:
 
20
  except ImportError:
21
  get_twilio_ice_servers = None
22
 
23
+ # Heavy libraries are imported lazily inside functions to minimize startup memory
24
+ # av, cv2, mediapipe, joblib are NOT imported at module level
25
+
26
  logger = logging.getLogger(__name__)
27
 
28
  ROOT = Path(__file__).resolve().parent
 
90
  }
91
 
92
  EXERCISES = [
93
+ {"name": "Sinav", "en": "Push-ups", "icon": "💪", "code": "PUSH", "desc": "Göğüs, omuz ve triceps kasları için temel egzersiz."},
94
+ {"name": "Mekik", "en": "Sit-ups", "icon": "🔄", "code": "SIT", "desc": "Karın kasları için etkili bir core egzersizi."},
95
+ {"name": "Squat", "en": "Squats", "icon": "🦵", "code": "SQUAT", "desc": "Bacak ve kalça kasları için en etkili hareket."},
96
+ {"name": "Barfiks", "en": "Pull-ups", "icon": "🧗", "code": "PULL", "desc": "Sırt ve biceps kaslarını güçlendiren egzersiz."},
97
+ {"name": "Ziplama", "en": "Jumping Jacks", "icon": "🤸", "code": "JUMP", "desc": "Tam vücut kardiyo ve koordinasyon egzersizi."},
 
 
 
 
 
98
  ]
99
 
100
+ # ── Page config ────────────────────────────────────────────────────────────
 
 
 
101
  st.set_page_config(
102
  page_title="BecomeAPro | AI Exercise Tracker",
103
+ page_icon="🏋️",
104
  layout="wide",
105
  initial_sidebar_state="collapsed",
106
  )
107
 
108
+ # ── CSS ────────────────────────────────────────────────────────────────────
 
 
 
109
  CUSTOM_CSS = """\
110
  <style>
111
+ @import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Sans:ital,wght@0,300;0,400;0,500;0,600;1,400&family=JetBrains+Mono:wght@400;700&display=swap');
112
+
113
+ *, *::before, *::after { box-sizing: border-box; }
114
+
115
+ :root {
116
+ --bg: #0b0f0e;
117
+ --bg2: #111916;
118
+ --bg3: #162018;
119
+ --surface: rgba(255,255,255,0.032);
120
+ --border: rgba(255,255,255,0.07);
121
+ --border-acc: rgba(180,255,60,0.22);
122
+ --lime: #b4ff3c;
123
+ --lime-dim: #7ab828;
124
+ --lime-glow: rgba(180,255,60,0.12);
125
+ --amber: #ffb830;
126
+ --muted: #4a5550;
127
+ --sub: #7a9a8e;
128
+ --text: #dde8e3;
129
+ --white: #ffffff;
130
+ --r: 14px;
131
+ --rl: 22px;
132
+ }
133
+
134
+ #MainMenu, footer, header { visibility: hidden; }
135
  .block-container {
136
  padding-top: 0 !important;
137
+ max-width: 1060px;
138
  margin: 0 auto;
139
  padding-left: 2rem !important;
140
  padding-right: 2rem !important;
141
  }
142
 
143
  .stApp {
144
+ background-color: var(--bg);
145
+ color: var(--text);
146
+ font-family: 'DM Sans', sans-serif;
147
  }
148
 
149
+ [data-testid="stHorizontalBlock"] { gap: 1rem !important; align-items: stretch !important; }
150
+ [data-testid="stColumn"] { display: flex !important; flex-direction: column !important; }
151
+ [data-testid="stColumn"] > div { flex: 1; }
 
 
 
 
 
 
 
 
 
152
 
153
+ /* ── HERO ── */
154
  .hero {
155
+ padding: 4.5rem 0 3rem;
156
+ display: grid;
157
+ grid-template-columns: 1fr auto;
158
+ align-items: end;
159
+ gap: 2rem;
160
+ border-bottom: 1px solid var(--border);
161
  position: relative;
 
162
  }
163
+ .hero::after {
164
  content: '';
165
  position: absolute;
166
+ bottom: -1px; left: 0;
167
+ width: 180px; height: 2px;
168
+ background: var(--lime);
 
 
 
169
  }
170
+ .hero-eyebrow {
171
+ display: inline-flex;
172
+ align-items: center;
173
+ gap: 8px;
174
+ font-family: 'JetBrains Mono', monospace;
175
+ font-size: 0.7rem;
176
+ color: var(--lime);
177
+ letter-spacing: 2.5px;
178
+ text-transform: uppercase;
179
+ margin-bottom: 1.2rem;
180
+ opacity: 0.9;
181
  }
182
+ .hero-eyebrow::before {
183
+ content: '';
184
  display: inline-block;
185
+ width: 18px; height: 2px;
186
+ background: var(--lime);
187
+ flex-shrink: 0;
 
 
 
 
 
 
 
188
  }
189
  .hero h1 {
190
+ font-family: 'Bebas Neue', sans-serif;
191
+ font-size: clamp(3.2rem, 7.5vw, 5.8rem);
192
+ line-height: 0.95;
193
+ letter-spacing: 1.5px;
194
+ color: var(--white);
195
+ margin: 0 0 1.2rem;
196
+ font-weight: 400;
 
 
 
 
 
197
  }
198
+ .hero h1 em { font-style: normal; color: var(--lime); }
199
  .hero-sub {
200
+ font-size: 0.97rem;
201
+ color: var(--sub);
202
+ line-height: 1.75;
203
+ font-weight: 300;
204
+ max-width: 400px;
205
+ }
206
+ .hero-meta {
207
+ text-align: right;
208
+ padding-bottom: 0.4rem;
209
+ }
210
+ .hero-version {
211
+ display: inline-block;
212
+ border: 1px solid var(--border);
213
+ border-radius: 6px;
214
+ padding: 3px 10px;
215
+ font-family: 'JetBrains Mono', monospace;
216
+ font-size: 0.65rem;
217
+ color: var(--muted);
218
+ margin-bottom: 0.6rem;
219
+ }
220
+ .hero-tags {
221
+ font-family: 'JetBrains Mono', monospace;
222
+ font-size: 0.65rem;
223
+ color: var(--muted);
224
+ line-height: 2.4;
225
+ letter-spacing: 0.5px;
226
+ }
227
+
228
+ /* ── STATS ── */
229
+ .stats-bar {
230
+ display: grid;
231
+ grid-template-columns: repeat(3, 1fr);
232
+ border-bottom: 1px solid var(--border);
233
+ }
234
+ .stat-item {
235
+ padding: 2rem 1.5rem;
236
  position: relative;
237
  }
238
+ .stat-item:not(:last-child)::after {
239
+ content: '';
240
+ position: absolute;
241
+ right: 0; top: 22%; bottom: 22%;
242
+ width: 1px;
243
+ background: var(--border);
244
+ }
245
+ .stat-num {
246
+ font-family: 'Bebas Neue', sans-serif;
247
+ font-size: 3.8rem;
248
+ line-height: 1;
249
+ color: var(--white);
250
+ letter-spacing: 1px;
251
+ }
252
+ .stat-num sup {
253
+ font-size: 1.4rem;
254
+ color: var(--lime);
255
+ vertical-align: super;
256
+ }
257
+ .stat-label {
258
+ font-size: 0.72rem;
259
+ color: var(--muted);
260
+ margin-top: 8px;
261
+ font-weight: 600;
262
+ text-transform: uppercase;
263
+ letter-spacing: 1.5px;
264
+ }
265
 
266
+ /* ── SECTION HEADER ── */
267
+ .sec-hdr {
268
+ padding: 3rem 0 1.8rem;
269
+ display: flex;
270
+ align-items: baseline;
271
+ gap: 0.8rem;
272
+ border-bottom: 1px solid var(--border);
273
+ margin-bottom: 1.5rem;
274
  }
275
+ .sec-idx {
276
+ font-family: 'JetBrains Mono', monospace;
277
+ font-size: 0.65rem;
278
+ color: var(--lime-dim);
279
+ letter-spacing: 2px;
280
+ flex-shrink: 0;
281
+ }
282
+ .sec-ttl {
283
+ font-family: 'Bebas Neue', sans-serif;
284
+ font-size: 2.1rem;
285
+ letter-spacing: 1.5px;
286
+ color: var(--white);
287
+ font-weight: 400;
288
+ line-height: 1;
289
+ }
290
+ .sec-note {
291
+ font-size: 0.8rem;
292
+ color: var(--muted);
293
+ margin-left: auto;
294
+ flex-shrink: 0;
295
+ font-weight: 400;
296
  }
297
 
298
+ /* ── STEP CARDS ── */
299
+ .step-card {
300
+ border: 1px solid var(--border);
301
+ border-radius: var(--r);
302
+ padding: 1.8rem 1.5rem 1.5rem;
303
+ background: var(--surface);
304
+ height: 100%;
 
 
305
  position: relative;
306
  overflow: hidden;
307
+ transition: border-color 0.25s, background 0.25s;
308
+ }
309
+ .step-card:hover {
310
+ border-color: var(--border-acc);
311
+ background: var(--bg3);
312
+ }
313
+ .step-card:hover .step-bg-n { color: rgba(180,255,60,0.08); }
314
+ .step-bg-n {
315
+ font-family: 'Bebas Neue', sans-serif;
316
+ font-size: 5rem;
317
+ line-height: 1;
318
+ color: rgba(255,255,255,0.03);
319
+ position: absolute;
320
+ bottom: -0.5rem; right: 1rem;
321
+ letter-spacing: 1px;
322
+ pointer-events: none;
323
+ transition: color 0.3s;
324
+ user-select: none;
325
+ }
326
+ .step-tag {
327
+ font-family: 'JetBrains Mono', monospace;
328
+ font-size: 0.6rem;
329
+ color: var(--lime-dim);
330
+ letter-spacing: 2px;
331
+ text-transform: uppercase;
332
+ margin-bottom: 0.9rem;
333
+ display: block;
334
+ }
335
+ .step-ico { font-size: 1.5rem; margin-bottom: 0.8rem; display: block; }
336
+ .step-title {
337
+ font-size: 1rem;
338
+ font-weight: 600;
339
+ color: var(--white);
340
+ margin-bottom: 0.45rem;
341
+ line-height: 1.3;
342
+ }
343
+ .step-desc { font-size: 0.82rem; color: var(--sub); line-height: 1.65; font-weight: 300; }
344
+
345
+ /* ── EXERCISE CARDS ── */
346
+ .ex-card {
347
+ border: 1px solid var(--border);
348
+ border-radius: var(--r);
349
+ padding: 1.4rem 1.1rem 1.3rem;
350
+ background: var(--surface);
351
  height: 100%;
352
+ position: relative;
353
+ overflow: hidden;
354
+ transition: all 0.3s ease;
355
  }
356
+ .ex-card:hover {
357
+ border-color: var(--border-acc);
358
  transform: translateY(-4px);
359
+ background: var(--bg3);
360
+ box-shadow: 0 20px 50px rgba(0,0,0,0.3), 0 0 0 1px rgba(180,255,60,0.1);
361
  }
362
+ .ex-code {
363
+ font-family: 'JetBrains Mono', monospace;
364
+ font-size: 0.58rem;
365
+ color: var(--lime-dim);
366
+ letter-spacing: 2.5px;
367
+ text-transform: uppercase;
368
+ margin-bottom: 0.8rem;
369
+ display: block;
370
+ opacity: 0.8;
371
+ }
372
+ .ex-ico { font-size: 1.8rem; margin-bottom: 0.9rem; display: block; }
373
+ .ex-tr {
374
+ font-family: 'Bebas Neue', sans-serif;
375
+ font-size: 1.4rem;
376
+ color: var(--white);
377
+ letter-spacing: 1px;
378
+ line-height: 1;
379
+ margin-bottom: 0.15rem;
380
+ font-weight: 400;
381
+ }
382
+ .ex-en { font-size: 0.7rem; color: var(--muted); margin-bottom: 0.7rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; }
383
+ .ex-info { font-size: 0.78rem; color: var(--sub); line-height: 1.5; font-weight: 300; }
384
+
385
+ /* ── CTA SECTION ── */
386
+ .cta-section {
387
+ margin-top: 3rem;
388
+ border: 1px solid var(--border);
389
+ border-radius: var(--rl);
390
+ overflow: hidden;
391
+ background: var(--bg2);
392
+ }
393
+ .cta-top {
394
+ padding: 2.8rem 3rem 2.4rem;
395
+ background: radial-gradient(ellipse 55% 90% at 5% 10%, rgba(180,255,60,0.05) 0%, transparent 65%);
396
+ border-bottom: 1px solid var(--border);
397
+ display: grid;
398
+ grid-template-columns: 1fr auto;
399
+ align-items: center;
400
+ gap: 3rem;
401
+ }
402
+ .cta-text { }
403
+ .cta-tag {
404
+ font-family: 'JetBrains Mono', monospace;
405
+ font-size: 0.66rem;
406
+ color: var(--lime);
407
+ letter-spacing: 2.5px;
408
+ text-transform: uppercase;
409
+ margin-bottom: 0.7rem;
410
+ display: flex;
411
+ align-items: center;
412
+ gap: 8px;
413
+ }
414
+ .cta-tag::before { content: ''; width: 14px; height: 2px; background: var(--lime); display: inline-block; flex-shrink: 0; }
415
+ .cta-ttl {
416
+ font-family: 'Bebas Neue', sans-serif;
417
+ font-size: 2.8rem;
418
+ letter-spacing: 1.5px;
419
+ color: var(--white);
420
+ font-weight: 400;
421
+ margin-bottom: 0.6rem;
422
+ line-height: 1;
423
+ }
424
+ .cta-sub {
425
+ font-size: 0.87rem;
426
+ color: var(--sub);
427
+ line-height: 1.7;
428
+ max-width: 400px;
429
+ font-weight: 300;
430
+ margin-bottom: 1.6rem;
431
+ }
432
+ /* Fake start button shown in CTA header (decorative, real one is from webrtc below) */
433
+ .cta-start-btn {
434
+ display: inline-flex;
435
+ align-items: center;
436
+ gap: 10px;
437
+ background: var(--lime);
438
+ color: #0b0f0e;
439
+ font-family: 'DM Sans', sans-serif;
440
+ font-size: 0.9rem;
441
+ font-weight: 700;
442
+ letter-spacing: 0.5px;
443
+ padding: 0.8rem 1.8rem;
444
+ border-radius: 10px;
445
+ cursor: default;
446
+ pointer-events: none;
447
+ opacity: 0.95;
448
+ }
449
+ .cta-start-btn .btn-ico {
450
  display: inline-flex;
451
  align-items: center;
452
  justify-content: center;
453
+ width: 22px; height: 22px;
454
+ border-radius: 50%;
455
+ background: rgba(0,0,0,0.15);
456
+ font-size: 0.75rem;
457
+ }
458
+ .cta-start-btn .btn-hint {
459
+ font-size: 0.7rem;
460
+ font-weight: 400;
461
+ opacity: 0.6;
462
+ margin-left: 4px;
463
+ font-family: 'JetBrains Mono', monospace;
464
+ letter-spacing: 0;
465
+ }
466
+ /* checklist items */
467
+ .cta-checks {
468
+ display: flex;
469
+ flex-direction: column;
470
+ gap: 0.5rem;
471
+ margin-bottom: 1.8rem;
472
+ }
473
+ .cta-check {
474
+ display: flex;
475
+ align-items: center;
476
+ gap: 8px;
477
+ font-size: 0.82rem;
478
+ color: var(--sub);
479
+ font-weight: 300;
480
+ }
481
+ .cta-check::before {
482
+ content: '✓';
483
+ display: inline-flex;
484
+ align-items: center;
485
+ justify-content: center;
486
+ width: 18px; height: 18px;
487
+ border-radius: 5px;
488
+ background: rgba(180,255,60,0.1);
489
+ border: 1px solid rgba(180,255,60,0.2);
490
+ color: var(--lime);
491
+ font-size: 0.65rem;
492
  font-weight: 700;
493
+ flex-shrink: 0;
 
494
  }
495
+ /* right side visual */
496
+ .cta-visual {
497
+ display: flex;
498
+ flex-direction: column;
499
+ align-items: center;
500
+ gap: 1rem;
 
 
 
 
 
501
  }
502
+ .cta-cam-icon {
503
+ width: 110px; height: 110px;
504
+ border: 1px solid var(--border-acc);
505
+ border-radius: 50%;
506
+ display: flex;
507
+ align-items: center;
508
+ justify-content: center;
509
+ background: rgba(180,255,60,0.03);
 
 
 
 
 
 
510
  position: relative;
511
+ font-size: 2.8rem;
512
  }
513
+ .cta-cam-icon::before {
514
  content: '';
515
  position: absolute;
516
+ inset: -10px;
517
+ border-radius: 50%;
518
+ border: 1px dashed rgba(180,255,60,0.12);
519
+ animation: spin 12s linear infinite;
 
520
  }
521
+ .cta-cam-icon::after {
522
+ content: '';
523
+ position: absolute;
524
+ inset: -20px;
525
+ border-radius: 50%;
526
+ border: 1px dashed rgba(180,255,60,0.06);
527
+ animation: spin 18s linear infinite reverse;
 
 
 
 
528
  }
529
+ @keyframes spin { to { transform: rotate(360deg); } }
530
+ .cta-cam-label {
531
+ font-family: 'JetBrains Mono', monospace;
532
+ font-size: 0.62rem;
533
+ color: var(--lime-dim);
534
+ letter-spacing: 2px;
535
+ text-transform: uppercase;
536
+ }
537
+ .cta-body { padding: 2rem 3rem 2.5rem; }
538
 
539
+ /* ── TROUBLE TOGGLE ── */
540
+ .trouble-toggle {
541
+ display: inline-flex;
542
+ align-items: center;
543
+ gap: 7px;
544
+ font-family: 'JetBrains Mono', monospace;
545
+ font-size: 0.68rem;
546
+ color: var(--muted);
547
+ letter-spacing: 1px;
548
+ cursor: pointer;
549
+ border: 1px solid var(--border);
550
+ border-radius: 8px;
551
+ padding: 6px 14px;
552
+ background: transparent;
553
+ transition: border-color 0.2s, color 0.2s;
554
+ margin-top: 1rem;
555
+ width: fit-content;
556
  }
557
+ .trouble-toggle:hover { border-color: rgba(255,184,48,0.2); color: #b0a080; }
558
+ .trouble-panel {
559
+ display: none;
560
+ margin-top: 0.8rem;
561
+ background: rgba(255,184,48,0.03);
562
+ border: 1px solid rgba(255,184,48,0.1);
563
+ border-radius: 10px;
564
+ padding: 0.85rem 1.2rem;
565
+ font-size: 0.79rem;
566
+ color: #a09060;
567
+ line-height: 1.65;
568
  }
569
+ .trouble-panel strong { color: #c4a468; }
570
 
571
+ /* ── CAM ── */
572
+ .cam-wrap {
573
+ border: 1px solid var(--border);
574
+ border-radius: var(--r);
575
+ overflow: hidden;
576
+ background: #070d0a;
 
 
 
 
577
  }
578
+ .cam-bar {
579
+ padding: 0.65rem 1rem;
580
+ border-bottom: 1px solid var(--border);
581
+ display: flex;
582
+ align-items: center;
583
+ gap: 8px;
584
+ background: rgba(0,0,0,0.25);
585
+ }
586
+ .cam-pulse {
587
+ width: 7px; height: 7px;
588
+ border-radius: 50%;
589
+ background: var(--lime);
590
+ box-shadow: 0 0 6px var(--lime);
591
+ animation: pulse 1.8s ease-in-out infinite;
592
+ }
593
+ .cam-pulse.off { background: var(--muted); box-shadow: none; animation: none; }
594
+ @keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:0.35;transform:scale(0.75)} }
595
+ .cam-lbl {
596
+ font-family: 'JetBrains Mono', monospace;
597
+ font-size: 0.62rem;
598
+ color: var(--muted);
599
+ letter-spacing: 2px;
600
+ text-transform: uppercase;
601
+ }
602
+ .cam-live {
603
+ margin-left: auto;
604
+ font-family: 'JetBrains Mono', monospace;
605
+ font-size: 0.62rem;
606
+ color: var(--lime);
607
+ letter-spacing: 2px;
608
+ text-transform: uppercase;
609
  }
610
 
611
+
612
+
613
+ /* ── STATUS PILL ── */
614
+ .status-row {
615
+ display: flex;
616
+ align-items: center;
617
+ justify-content: center;
618
+ gap: 10px;
619
+ padding: 0.6rem 0;
620
+ font-family: 'JetBrains Mono', monospace;
621
+ font-size: 0.68rem;
622
+ color: var(--lime-dim);
623
+ letter-spacing: 0.5px;
624
  }
625
+ .s-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--lime); animation: pulse 1.5s ease-in-out infinite; }
626
 
627
+ /* ── SUMMARY ── */
628
+ .summary {
629
+ border: 1px solid var(--border-acc);
630
+ border-radius: var(--rl);
 
 
 
 
 
 
 
 
 
 
 
631
  overflow: hidden;
632
+ max-width: 500px;
633
+ margin: 2rem auto;
634
+ background: var(--bg2);
635
  }
636
+ .sum-head {
637
+ padding: 1.4rem 1.8rem;
638
+ background: rgba(180,255,60,0.04);
639
+ border-bottom: 1px solid var(--border);
640
+ display: flex;
641
+ align-items: baseline;
642
+ justify-content: space-between;
643
+ }
644
+ .sum-title {
645
+ font-family: 'Bebas Neue', sans-serif;
646
+ font-size: 1.5rem;
647
+ letter-spacing: 1.5px;
648
+ color: var(--white);
649
+ font-weight: 400;
650
+ }
651
+ .sum-dur {
652
+ font-family: 'JetBrains Mono', monospace;
653
+ font-size: 0.68rem;
654
+ color: var(--muted);
655
+ letter-spacing: 1px;
656
  }
657
+ .sum-body { padding: 0 1.8rem; }
658
+ .sum-row {
659
  display: flex;
660
  align-items: center;
661
+ justify-content: space-between;
662
+ padding: 0.8rem 0;
663
+ border-bottom: 1px solid var(--border);
 
664
  }
665
+ .sum-row:last-child { border-bottom: none; }
666
+ .sum-ex { font-size: 0.87rem; color: var(--text); font-weight: 500; }
667
+ .sum-rep {
668
+ font-family: 'JetBrains Mono', monospace;
669
+ font-size: 0.88rem;
670
+ color: var(--lime);
671
+ font-weight: 700;
672
  }
673
+ .sum-foot {
674
+ padding: 1.1rem 1.8rem;
675
+ border-top: 1px solid var(--border);
676
+ background: var(--surface);
677
+ display: flex;
678
+ align-items: center;
679
+ justify-content: space-between;
680
  }
681
+ .sum-kcal-lbl {
682
+ font-size: 0.72rem;
683
+ color: var(--muted);
 
 
684
  text-transform: uppercase;
685
+ letter-spacing: 1.5px;
686
+ font-weight: 600;
687
  }
688
+ .sum-kcal-val {
689
+ font-family: 'Bebas Neue', sans-serif;
690
+ font-size: 2rem;
691
+ color: var(--amber);
692
+ letter-spacing: 1px;
 
 
 
 
 
 
 
 
 
693
  }
694
+ .sum-kcal-unit { font-size: 0.72rem; color: var(--muted); margin-left: 4px; }
 
695
 
696
+ /* ── BUTTONS ── */
697
+ div.stButton > button[kind="primary"],
698
+ div.stButton > button[data-testid="stBaseButton-primary"] {
699
+ background: var(--lime) !important;
700
+ border: none !important;
701
+ border-radius: 10px !important;
702
+ padding: 0.8rem 2.4rem !important;
703
+ font-size: 0.9rem !important;
704
+ font-weight: 600 !important;
705
+ font-family: 'DM Sans', sans-serif !important;
706
+ color: #0b0f0e !important;
707
+ letter-spacing: 0.2px !important;
708
+ min-height: 50px !important;
709
+ transition: all 0.2s !important;
710
+ }
711
+ div.stButton > button[kind="primary"]:hover,
712
+ div.stButton > button[data-testid="stBaseButton-primary"]:hover {
713
+ box-shadow: 0 0 30px rgba(180,255,60,0.28) !important;
714
+ transform: translateY(-2px) !important;
715
+ background: #c4ff52 !important;
716
  }
717
 
718
+ /* ── NO MODEL ── */
719
+ .no-model {
720
+ border: 1px dashed rgba(180,255,60,0.12);
721
+ border-radius: var(--rl);
722
+ padding: 3.5rem 2rem;
723
+ text-align: center;
724
+ background: var(--surface);
 
 
 
 
 
725
  }
726
+ .no-model-ico { font-size: 2.4rem; margin-bottom: 1rem; display: block; opacity: 0.6; }
727
+ .no-model h3 {
728
+ font-family: 'Bebas Neue', sans-serif;
729
+ font-size: 1.9rem;
730
+ color: var(--white);
731
+ font-weight: 400;
732
+ margin-bottom: 0.6rem;
733
+ letter-spacing: 1.5px;
734
+ }
735
+ .no-model p { font-size: 0.84rem; color: var(--sub); line-height: 1.7; max-width: 420px; margin: 0 auto 0.5rem; }
736
+ .no-model code {
737
+ font-family: 'JetBrains Mono', monospace;
738
+ font-size: 0.76rem;
739
+ background: rgba(180,255,60,0.06);
740
+ color: var(--lime-dim);
741
+ padding: 2px 8px;
742
+ border-radius: 5px;
743
+ border: 1px solid rgba(180,255,60,0.12);
744
  }
 
745
 
746
+ /* ── FOOTER ── */
747
+ .foot {
748
+ padding: 2rem 0 1.8rem;
749
+ border-top: 1px solid var(--border);
750
+ margin-top: 4rem;
751
+ display: flex;
752
+ align-items: center;
753
+ justify-content: space-between;
754
+ }
755
+ .foot-brand {
756
+ font-family: 'Bebas Neue', sans-serif;
757
+ font-size: 1rem;
758
+ color: var(--muted);
759
+ letter-spacing: 3px;
760
+ }
761
+ .foot-stack {
762
+ font-family: 'JetBrains Mono', monospace;
763
+ font-size: 0.62rem;
764
+ color: var(--muted);
765
+ letter-spacing: 1px;
766
+ opacity: 0.7;
767
  }
768
+
769
+ ::-webkit-scrollbar { width: 4px; }
770
+ ::-webkit-scrollbar-track { background: transparent; }
771
+ ::-webkit-scrollbar-thumb { background: #1c2820; border-radius: 3px; }
772
  </style>
773
  """
774
 
775
+ # ── ICE / TURN ─────────────────────────────────────────────────────────────
 
 
 
 
 
776
  def get_ice_config() -> dict:
 
 
777
  if get_twilio_ice_servers is not None:
778
  try:
779
+ sid = os.environ.get("TWILIO_ACCOUNT_SID", "") or st.secrets.get("TWILIO_ACCOUNT_SID", "")
780
+ token = os.environ.get("TWILIO_AUTH_TOKEN", "") or st.secrets.get("TWILIO_AUTH_TOKEN", "")
 
 
 
 
781
  if sid and token:
782
  ice = get_twilio_ice_servers(twilio_sid=sid, twilio_token=token)
 
783
  return {"iceServers": ice}
784
  except Exception as exc:
785
+ logger.warning("Twilio ICE fetch failed: %s", exc)
786
  return {"iceServers": [{"urls": ["stun:stun.l.google.com:19302"]}]}
787
 
788
+ # ── Pose helpers ────────────────────────────────────────────────────────────
 
 
 
 
 
789
  def label_to_turkish(label: str) -> str:
790
  return POSE_TO_TURKISH.get(label, label)
791
 
 
792
  def ensure_pose_model() -> str:
793
+ """Download pose model if not present. Uses spinner on first download."""
794
  if POSE_MODEL_PATH.exists():
795
  return str(POSE_MODEL_PATH)
796
  MODELS_DIR.mkdir(parents=True, exist_ok=True)
797
+ tmp_path = POSE_MODEL_PATH.with_suffix(".task.tmp")
798
+ try:
799
+ with st.spinner("Pose modeli indiriliyor (ilk acilis)..."):
800
+ urllib.request.urlretrieve(POSE_MODEL_URL, tmp_path)
801
+ tmp_path.rename(POSE_MODEL_PATH)
802
+ except Exception as exc:
803
+ if tmp_path.exists():
804
+ tmp_path.unlink(missing_ok=True)
805
+ raise RuntimeError(f"Pose model indirilemedi: {exc}") from exc
806
  return str(POSE_MODEL_PATH)
807
 
 
808
  def landmarks_to_vector(landmark_list, feature_columns):
809
  name_to_idx = {name: i for i, name in enumerate(MP_INDEX_TO_NAME)}
810
  for alias, canonical in NAME_ALIASES.items():
811
  name_to_idx[alias] = name_to_idx.get(canonical, 0)
 
812
  values = []
813
  for col in feature_columns:
814
  if not col.startswith(("x_", "y_", "z_")):
815
  continue
816
+ axis = col[0]; name = NAME_ALIASES.get(col[2:].strip(), col[2:].strip())
 
 
817
  idx = name_to_idx.get(name, -1)
818
+ if idx < 0: values.append(0.0); continue
 
 
819
  lm = landmark_list[idx]
820
  x_val = lm.x if lm.x is not None else 0.0
821
  y_val = lm.y if lm.y is not None else 0.0
822
  z_val = lm.z if lm.z is not None else 0.0
823
+ if axis == "x": values.append((x_val - 0.5) * SCALE_XY)
824
+ elif axis == "y": values.append((y_val - 0.5) * SCALE_XY)
825
+ else: values.append(z_val * SCALE_Z)
 
 
 
826
  return np.array(values, dtype=np.float32).reshape(1, -1)
827
 
 
828
  def predict_single(ml_model, encoder, scaler, model_type, X, buffer):
 
829
  X_scaled = scaler.transform(X)
830
  if model_type == "xgboost":
831
  pred_idx = ml_model.predict(X_scaled)[0]
832
+ probs = ml_model.predict_proba(X_scaled)[0]
833
  else:
834
  import torch
835
  with torch.no_grad():
836
+ X_t = torch.from_numpy(X_scaled.astype(np.float32))
837
  logits = ml_model(X_t)
838
+ probs = torch.softmax(logits, dim=1).numpy()[0]
839
  pred_idx = int(np.argmax(probs))
 
840
  conf = float(probs[pred_idx])
841
+ buffer.append("Belirsiz" if conf < CONFIDENCE_THRESHOLD else encoder.inverse_transform([pred_idx])[0])
842
+ mode_label = Counter(buffer).most_common(1)[0][0]
 
 
 
 
 
 
843
  return mode_label, conf
844
 
 
845
  def draw_overlay_panel(frame, label, conf, reps=None):
846
+ import cv2 as _cv2
847
  h, w = frame.shape[:2]
848
  has_reps = reps is not None and reps > 0
849
  panel_h = 120 if has_reps else 90
850
  panel_w = min(400, w - 20)
851
+ x1, y1, x2, y2 = 10, 10, 10 + panel_w, 10 + panel_h
 
 
852
  overlay = frame.copy()
853
+ _cv2.rectangle(overlay, (x1, y1), (x2, y2), (15, 22, 18), -1)
854
+ _cv2.addWeighted(overlay, 0.78, frame, 0.22, 0, frame)
855
+ _cv2.rectangle(frame, (x1, y1), (x2, y2), (90, 220, 40), 2)
 
856
  turkce = label_to_turkish(label)
857
+ font = _cv2.FONT_HERSHEY_SIMPLEX
858
+ color = (90, 255, 60) if label != "Belirsiz" else (80, 80, 80)
859
+ _cv2.putText(frame, f"Hareket: {turkce}", (x1+12, y1+38), font, 0.9, color, 2)
860
+ _cv2.putText(frame, f"Guven: %{conf*100:.0f}", (x1+12, y1+72), font, 0.7, (180,200,180), 2)
 
 
 
 
861
  if has_reps:
862
+ _cv2.putText(frame, f"Tekrar: {reps}", (x1+12, y1+106), font, 0.8, (90,220,40), 2)
 
 
863
 
864
  def draw_center_counter(frame, reps, frames_since_rep):
865
+ import cv2 as _cv2
866
+ if frames_since_rep >= REP_DISPLAY_FRAMES: return
 
867
  alpha = 1.0 - (frames_since_rep / REP_DISPLAY_FRAMES)
868
  h, w = frame.shape[:2]
869
  text = str(reps)
870
+ font = _cv2.FONT_HERSHEY_SIMPLEX
871
+ scale, thickness = 4.0, 8
872
+ (tw, th), _ = _cv2.getTextSize(text, font, scale, thickness)
 
 
 
 
873
  overlay = frame.copy()
874
+ _cv2.putText(overlay, text, ((w-tw)//2, (h+th)//2), font, scale, (180,255,60), thickness)
875
+ _cv2.addWeighted(overlay, alpha*0.65, frame, 1.0-alpha*0.65, 0, frame)
 
 
 
 
 
 
876
 
877
+ # ── Artifacts ───────────────────────────────────────────────────────────────
878
  @st.cache_resource
879
+ def load_ml_artifacts():
880
+ """Load only the ML model files (no network calls). Returns None tuple if missing."""
881
+ from joblib import load as jload
882
+ meta_path = MODELS_DIR / "meta.pkl"
883
  metadata_path = MODELS_DIR / "metadata.json"
884
  if not meta_path.exists() or not metadata_path.exists():
885
+ return None, None, None, None, None, None
886
+ try:
887
+ meta = jload(meta_path)
888
+ encoder = jload(MODELS_DIR / "encoder.pkl")
889
+ scaler = jload(MODELS_DIR / "scaler.pkl")
890
+ model_type = meta.get("model_type", "xgboost")
891
+ model_path = meta.get("model_path")
892
+ if model_path:
893
+ filename = model_path.replace("\\", "/").split("/")[-1]
894
+ model_path = MODELS_DIR / filename
895
+ if model_type == "xgboost":
896
+ ml_model = jload(model_path)
897
+ else:
898
+ import torch
899
+ from torch import nn
900
+ input_size = meta.get("input_size", 99)
901
+ num_classes = meta.get("num_classes", 10)
902
+ ml_model = nn.Sequential(
903
+ nn.Linear(input_size, 200), nn.ReLU(), nn.Linear(200, num_classes)
904
+ )
905
+ ml_model.load_state_dict(
906
+ torch.load(model_path, map_location="cpu", weights_only=True)
907
+ )
908
+ ml_model.eval()
909
+ with open(metadata_path, encoding="utf-8") as f:
910
+ feature_columns = json.load(f).get("feature_columns", [])
911
+ return ml_model, encoder, scaler, model_type, feature_columns, meta
912
+ except Exception as exc:
913
+ logger.error("ML artifact load failed: %s", exc)
914
+ return None, None, None, None, None, None
915
 
 
 
 
 
916
 
917
+ @st.cache_resource
918
+ def load_pose_landmarker():
919
+ """Load MediaPipe pose landmarker (downloads model on first run). Lazy import."""
920
+ try:
921
+ from mediapipe.tasks import python as _mp_python
922
+ from mediapipe.tasks.python import vision as _vision
923
+ pose_model_path = ensure_pose_model()
924
+ base_options = _mp_python.BaseOptions(model_asset_path=pose_model_path)
925
+ options = _vision.PoseLandmarkerOptions(
926
+ base_options=base_options,
927
+ running_mode=_vision.RunningMode.IMAGE,
928
  )
929
+ return _vision.PoseLandmarker.create_from_options(options)
930
+ except Exception as exc:
931
+ logger.error("Pose landmarker load failed: %s", exc)
932
+ return None
933
 
 
 
 
 
 
 
 
 
 
 
934
 
935
+ def load_all_artifacts():
936
+ """Compatibility wrapper — loads ML artifacts then pose landmarker separately."""
937
+ ml_model, encoder, scaler, model_type, feature_columns, meta = load_ml_artifacts()
938
+ if ml_model is None:
939
+ return None, None, None, None, None, None, None
940
+ pose_landmarker = load_pose_landmarker()
941
+ if pose_landmarker is None:
942
+ return None, None, None, None, None, None, None
943
  return ml_model, encoder, scaler, model_type, feature_columns, pose_landmarker, meta
944
 
945
+ # ── WebRTC callback ─────────────────────────────────────────────────────────
 
 
 
 
946
  _buffer_lock = Lock()
947
  _prediction_buffer: deque = deque(maxlen=BUFFER_SIZE)
948
 
 
949
  def _draw_body_skeleton(img, pose_landmarks):
950
+ import cv2 as _cv2
951
  h, w = img.shape[:2]
952
  points = {}
953
  for idx in BODY_LANDMARK_INDICES:
954
  lm = pose_landmarks[idx]
955
  px, py = int(lm.x * w), int(lm.y * h)
956
  points[idx] = (px, py)
957
+ _cv2.circle(img, (px, py), 5, (90, 220, 40), -1)
958
+ _cv2.circle(img, (px, py), 7, (90, 220, 40), 1)
 
959
  for a, b in BODY_CONNECTIONS:
960
  if a in points and b in points:
961
+ _cv2.line(img, points[a], points[b], (60, 200, 20), 2)
 
962
 
963
+ def make_video_frame_callback(ml_model, encoder, scaler, model_type, feature_columns, pose_landmarker):
 
 
964
  frame_counter = [0]
965
+ cached_label = ["Belirsiz"]
966
+ cached_conf = [0.0]
 
967
  rep_state = {
968
+ "phase": "idle", "reps": 0, "debounce_count": 0,
969
+ "pending_phase": None, "frames_since_rep": REP_DISPLAY_FRAMES,
970
+ "exercise_reps": {}, "start_time": None,
 
 
 
 
971
  }
972
 
973
  def _update_rep_counter(label):
 
974
  phase = rep_state["phase"]
 
975
  if rep_state["start_time"] is None and label != "Belirsiz":
976
  rep_state["start_time"] = time.time()
 
977
  exercise = label.rsplit("_", 1)[0] if "_" in label else None
978
+ if label.endswith("_down"): target = "down"
979
+ elif label.endswith("_up"): target = "up"
 
 
 
980
  else:
981
+ rep_state["debounce_count"] = 0; rep_state["pending_phase"] = None; return
982
+ if phase == "idle" and target == "down": _try_transition("down", exercise)
 
 
 
 
983
  elif phase == "down" and target == "up":
984
  if _try_transition("up", exercise):
985
+ rep_state["reps"] += 1; rep_state["frames_since_rep"] = 0
 
986
  if exercise:
987
+ rep_state["exercise_reps"][exercise] = rep_state["exercise_reps"].get(exercise, 0) + 1
988
+ elif phase == "up" and target == "down": _try_transition("down", exercise)
 
 
 
989
 
990
  def _try_transition(target, exercise=None):
991
+ if rep_state["pending_phase"] == target: rep_state["debounce_count"] += 1
992
+ else: rep_state["pending_phase"] = target; rep_state["debounce_count"] = 1
 
 
 
 
993
  if rep_state["debounce_count"] >= REP_DEBOUNCE:
994
+ rep_state["phase"] = target; rep_state["pending_phase"] = None; rep_state["debounce_count"] = 0; return True
 
 
 
995
  return False
996
 
997
+ def video_frame_callback(frame):
998
+ import av as _av
999
+ import cv2 as _cv2
1000
+ import mediapipe as _mp
1001
  img = frame.to_ndarray(format="bgr24")
1002
+ img = _cv2.flip(img, 1)
1003
+ frame_counter[0] += 1; rep_state["frames_since_rep"] += 1
1004
+ if frame_counter[0] % FRAME_SKIP != 0:
1005
+ draw_overlay_panel(img, cached_label[0], cached_conf[0], reps=rep_state["reps"])
1006
+ draw_center_counter(img, rep_state["reps"], rep_state["frames_since_rep"])
1007
+ return _av.VideoFrame.from_ndarray(img, format="bgr24")
1008
+ rgb = _cv2.cvtColor(img, _cv2.COLOR_BGR2RGB)
1009
+ mp_image = _mp.Image(image_format=_mp.ImageFormat.SRGB, data=rgb)
 
 
 
 
 
 
 
 
1010
  try:
1011
  detection_result = pose_landmarker.detect(mp_image)
1012
  except Exception:
1013
  draw_overlay_panel(img, "Belirsiz", 0.0, reps=rep_state["reps"])
1014
+ return _av.VideoFrame.from_ndarray(img, format="bgr24")
 
1015
  if detection_result.pose_landmarks:
1016
  pose_landmarks = detection_result.pose_landmarks[0]
1017
  _draw_body_skeleton(img, pose_landmarks)
 
1018
  try:
1019
  X = landmarks_to_vector(pose_landmarks, feature_columns)
1020
  if X.shape[1] == scaler.n_features_in_:
1021
  with _buffer_lock:
1022
+ label, conf = predict_single(ml_model, encoder, scaler, model_type, X, _prediction_buffer)
1023
+ cached_label[0] = label; cached_conf[0] = conf
 
 
 
 
1024
  _update_rep_counter(label)
1025
+ draw_overlay_panel(img, label, conf, reps=rep_state["reps"])
 
1026
  except Exception as e:
1027
+ _cv2.putText(img, f"Err: {e}"[:60], (10,30), _cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,0,255), 1)
 
1028
  else:
1029
+ cached_label[0] = "Belirsiz"; cached_conf[0] = 0.0
 
1030
  draw_overlay_panel(img, "Belirsiz", 0.0, reps=rep_state["reps"])
1031
  h, w = img.shape[:2]
1032
+ _cv2.putText(img, "Tam vucut gorunumunde durun", (10, h-25), _cv2.FONT_HERSHEY_SIMPLEX, 0.55, (60,160,255), 1)
1033
+ draw_center_counter(img, rep_state["reps"], rep_state["frames_since_rep"])
1034
+ return _av.VideoFrame.from_ndarray(img, format="bgr24")
 
 
 
 
1035
 
1036
  return video_frame_callback, rep_state
1037
 
1038
+ # ── UI Sections ─────────────────────────────────────────────────────────────
 
 
 
 
 
1039
  def render_hero():
1040
+ st.markdown("""
 
1041
  <div class="hero">
1042
+ <div>
1043
+ <div class="hero-eyebrow">AI-Powered Fitness Tracker</div>
1044
+ <h1>EGZERSIZINI<br><em>YAPAY ZEKA</em><br>ILE TAKIP ET</h1>
1045
+ <p class="hero-sub">
1046
+ Kameranı aç, egzersizini yap.&nbsp;
1047
+ Yapay zeka hareketlerini anlık olarak tanır,
1048
+ tekrarlarını sayar ve performansını takip eder.
1049
+ </p>
1050
+ </div>
1051
+ <div class="hero-meta">
1052
+ <div class="hero-version">v2.0</div>
1053
+ <div class="hero-tags">
1054
+ MediaPipe<br>XGBoost / PyTorch<br>Streamlit WebRTC
1055
+ </div>
1056
+ </div>
1057
  </div>
1058
+ """, unsafe_allow_html=True)
 
 
1059
 
1060
 
1061
  def render_stats():
1062
+ st.markdown("""
1063
+ <div class="stats-bar">
1064
+ <div class="stat-item">
1065
+ <div class="stat-num">5<sup>✦</sup></div>
1066
+ <div class="stat-label">Desteklenen Egzersiz</div>
1067
+ </div>
1068
+ <div class="stat-item">
1069
+ <div class="stat-num">10</div>
1070
+ <div class="stat-label">Hareket Pozisyonu</div>
1071
+ </div>
1072
+ <div class="stat-item">
1073
+ <div class="stat-num">33</div>
1074
+ <div class="stat-label">Vücut Noktası Takibi</div>
1075
+ </div>
1076
+ </div>
1077
+ """, unsafe_allow_html=True)
 
1078
 
1079
 
1080
  def render_how_it_works():
1081
+ st.markdown("""
1082
+ <div class="sec-hdr">
1083
+ <span class="sec-idx">01 —</span>
1084
+ <span class="sec-ttl">NASIL ÇALIŞIR</span>
1085
+ <span class="sec-note">3 adımda antrenman</span>
1086
+ </div>
1087
+ """, unsafe_allow_html=True)
1088
+
1089
  steps = [
1090
+ ("📷", "ADIM 01", "Kamerayı Başlat",
1091
+ "START butonuna tıklayarak tarayıcı kameranızı açın. "
1092
+ "Kameranın tam vücudunuzu göreceği bir konumda durun."),
1093
+ ("🏋️", "ADIM 02", "Egzersizini Yap",
1094
+ "Şınav, mekik, squat veya başka bir egzersiz yapmaya başlayın. "
1095
+ "AI modeli hareketlerinizi anlık olarak tanır."),
1096
+ ("📊", "ADIM 03", "Sonuçlarını Gör",
1097
+ "Hareket tipi, güven oranı ve tekrar sayısı video üzerinde "
1098
+ "canlı olarak gösterilir. Durduğunda özet ekrana gelir."),
1099
  ]
1100
  cols = st.columns(3)
1101
+ for col, (ico, tag, title, desc) in zip(cols, steps):
1102
  with col:
1103
+ st.markdown(f"""
1104
+ <div class="step-card">
1105
+ <span class="step-bg-n">{tag[-2:]}</span>
1106
+ <span class="step-tag">{tag}</span>
1107
+ <span class="step-ico">{ico}</span>
1108
+ <div class="step-title">{title}</div>
1109
+ <div class="step-desc">{desc}</div>
1110
  </div>
1111
+ """, unsafe_allow_html=True)
 
 
1112
 
1113
 
1114
  def render_exercises():
1115
+ st.markdown("""
1116
+ <div class="sec-hdr">
1117
+ <span class="sec-idx">02 </span>
1118
+ <span class="sec-ttl">DESTEKlENEN EGZERSİZLER</span>
1119
+ <span class="sec-note">AI destekli tanıma</span>
1120
+ </div>
1121
+ """, unsafe_allow_html=True)
1122
+
 
1123
  cols = st.columns(5)
1124
  for col, ex in zip(cols, EXERCISES):
1125
  with col:
1126
+ st.markdown(f"""
1127
+ <div class="ex-card">
1128
+ <span class="ex-code">{ex['code']}</span>
1129
+ <span class="ex-ico">{ex['icon']}</span>
1130
+ <div class="ex-tr">{ex['name']}</div>
 
1131
  <div class="ex-en">{ex['en']}</div>
1132
+ <div class="ex-info">{ex['desc']}</div>
1133
+ </div>
1134
+ """, unsafe_allow_html=True)
1135
+
1136
+
1137
+ def render_camera_section(ml_model, encoder, scaler, model_type, feature_columns, pose_landmarker):
1138
+ # ── CTA header ──
1139
+ st.markdown("""
1140
+ <div class="cta-section">
1141
+ <div class="cta-top">
1142
+ <div class="cta-text">
1143
+ <div class="cta-tag">03 — Antrenman Modu</div>
1144
+ <div class="cta-ttl">ANTRENMANINA<br>BAŞLA</div>
1145
+ <div class="cta-sub">
1146
+ Kameranı açmak için aşağıdaki
1147
+ <strong style="color:var(--lime)">KAMERAYI BAŞLAT</strong> butonuna tıkla,
1148
+ izni onayla ve egzersizine başla.
1149
+ </div>
1150
+ <div class="cta-checks">
1151
+ <span class="cta-check">İyi aydınlatılmış bir ortamda dur</span>
1152
+ <span class="cta-check">Tam vücut kameraya görünsün</span>
1153
+ <span class="cta-check">Chrome veya Edge önerilir</span>
1154
+ </div>
1155
+ </div>
1156
+ <div class="cta-visual">
1157
+ <div class="cta-cam-icon">📷</div>
1158
+ <span class="cta-cam-label">Canlı Takip</span>
1159
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1160
  </div>
1161
+ <div class="cta-body">
1162
+ """, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
1163
 
1164
  callback, rep_state = make_video_frame_callback(
1165
  ml_model, encoder, scaler, model_type, feature_columns, pose_landmarker,
 
1167
 
1168
  _pad_l, cam_col, _pad_r = st.columns([1, 6, 1])
1169
  with cam_col:
1170
+ st.markdown("""
1171
+ <div class="cam-wrap">
1172
+ <div class="cam-bar">
1173
+ <div class="cam-pulse off"></div>
1174
+ <span class="cam-lbl">Kamera</span>
 
1175
  </div>
1176
+ """, unsafe_allow_html=True)
 
 
1177
 
1178
  webrtc_ctx = webrtc_streamer(
1179
  key="exercise-detection",
 
1185
  },
1186
  async_processing=True,
1187
  rtc_configuration=get_ice_config(),
1188
+ translations={"start": "KAMERAYI BAŞLAT", "stop": "DURDUR", "select_device": "Kamera Seç"},
 
 
 
 
1189
  )
 
1190
  st.markdown("</div>", unsafe_allow_html=True)
1191
 
1192
  if webrtc_ctx.state.playing:
1193
  with cam_col:
1194
+ st.markdown("""
1195
+ <div class="status-row">
1196
+ <span class="s-dot"></span>
1197
+ KAMERA AKTİF — EGZERSİZE BAŞLAYIN
 
 
 
1198
  </div>
1199
+ """, unsafe_allow_html=True)
 
 
1200
  if rep_state["start_time"] is None:
1201
  rep_state["start_time"] = time.time()
1202
  st.session_state["rep_state_snapshot"] = {
 
1205
  "start_time": rep_state["start_time"],
1206
  }
1207
  else:
1208
+ # Troubleshoot toggle (collapsible via JS)
1209
  with cam_col:
1210
+ st.markdown("""
1211
+ <button class="trouble-toggle"
1212
+ onclick="
1213
+ var p=this.nextElementSibling;
1214
+ p.style.display=p.style.display==='block'?'none':'block';
1215
+ this.style.borderColor=p.style.display==='block'?'rgba(255,184,48,0.25)':'var(--border)';
1216
+ this.style.color=p.style.display==='block'?'#c4a468':'var(--muted)';
1217
+ ">
1218
+ Sorun mu yaşıyorsun?
1219
+ </button>
1220
+ <div class="trouble-panel">
1221
+ <strong>Bağlantı sorunu mu?</strong>
1222
+ Tarayıcınızın kamera erişimine izin verdiğinden emin olun.
1223
+ Sorun devam ederse sayfayı yenileyip tekrar deneyin.
 
1224
  </div>
1225
+ """, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
1226
 
1227
  if st.session_state.get("rep_state_snapshot"):
1228
  snap = st.session_state["rep_state_snapshot"]
 
1231
  _render_workout_summary(snap, elapsed)
1232
  st.session_state["rep_state_snapshot"] = None
1233
 
1234
+ st.markdown("</div></div>", unsafe_allow_html=True) # close cta-body + cta-section
1235
+
1236
 
1237
  def _render_workout_summary(snap, elapsed_seconds):
 
1238
  mins = int(elapsed_seconds) // 60
1239
  secs = int(elapsed_seconds) % 60
 
1240
  total_kcal = 0.0
 
1241
  exercise_names = {
1242
+ "pushups": "Şınav", "situp": "Mekik",
1243
+ "squats": "Squat", "pullups": "Barfiks", "jumping_jacks": "Zıplama",
 
 
 
1244
  }
1245
+ rows_html = ""
1246
  for ex, count in snap["exercise_reps"].items():
1247
+ name = exercise_names.get(ex, ex)
1248
+ kcal = count * KCAL_PER_REP.get(ex, 0.3)
1249
  total_kcal += kcal
1250
  rows_html += f"""
1251
+ <div class="sum-row">
1252
+ <span class="sum-ex">{name}</span>
1253
+ <span class="sum-rep">{count} tekrar</span>
 
1254
  </div>"""
1255
+ st.markdown(f"""
1256
+ <div class="summary">
1257
+ <div class="sum-head">
1258
+ <span class="sum-title">ANTRENMAN ÖZETİ</span>
1259
+ <span class="sum-dur">{mins:02d}:{secs:02d}</span>
 
 
 
 
 
 
 
 
1260
  </div>
1261
+ <div class="sum-body">{rows_html}</div>
1262
+ <div class="sum-foot">
1263
+ <span class="sum-kcal-lbl">Tahmini Kalori</span>
1264
+ <div>
1265
+ <span class="sum-kcal-val">{total_kcal:.1f}</span>
1266
+ <span class="sum-kcal-unit">kcal</span>
1267
+ </div>
1268
  </div>
1269
  </div>
1270
+ """, unsafe_allow_html=True)
 
 
1271
 
1272
 
1273
  def render_footer():
1274
+ st.markdown("""
 
1275
  <div class="foot">
1276
+ <span class="foot-brand">BECOMEAPRO</span>
1277
+ <span class="foot-stack">MediaPipe &nbsp;·&nbsp; XGBoost / PyTorch &nbsp;·&nbsp; Streamlit &nbsp;·&nbsp; WebRTC</span>
 
 
1278
  </div>
1279
+ """, unsafe_allow_html=True)
 
 
1280
 
1281
 
1282
  def render_model_missing():
 
1283
  _p1, col_c, _p2 = st.columns([1, 3, 1])
1284
  with col_c:
1285
+ st.markdown("""
1286
+ <div style="margin-top: 3rem;">
1287
+ <div class="no-model">
1288
+ <span class="no-model-ico">📂</span>
1289
+ <h3>MODEL DOSYALARI BULUNAMADI</h3>
1290
+ <p>
1291
+ Uygulamanın çalışabilmesi için eğitilmiş model dosyalarının
1292
+ <code>models/</code> klasörüne eklenmesi gerekiyor.
1293
+ </p>
1294
+ <p style="margin-top:0.8rem;">
1295
+ Gerekli dosyalar: <code>meta.pkl</code> · <code>encoder.pkl</code> ·
1296
+ <code>scaler.pkl</code> · <code>final_model.pkl</code> · <code>metadata.json</code>
1297
+ </p>
1298
+ </div>
1299
  </div>
1300
+ """, unsafe_allow_html=True)
 
 
 
 
 
 
 
1301
 
1302
 
1303
+ # ── Main ────────────────────────────────────────────────────────────────────
1304
  def main():
1305
  st.markdown(CUSTOM_CSS, unsafe_allow_html=True)
1306
 
1307
+ # Step 1: Load ML model files only (fast, no network I/O) — page renders immediately
1308
+ ml_model, encoder, scaler, model_type, feature_columns, meta = load_ml_artifacts()
1309
 
1310
  render_hero()
1311
 
 
1314
  render_footer()
1315
  return
1316
 
 
 
1317
  render_stats()
1318
  render_how_it_works()
1319
  render_exercises()
1320
+
1321
+ # Step 2: Load pose landmarker (may download ~8 MB on first run — spinner shown inside)
1322
+ pose_landmarker = load_pose_landmarker()
1323
+ if pose_landmarker is None:
1324
+ st.error("Pose modeli yuklenemedi. Lutfen sayfayi yenileyip tekrar deneyin.")
1325
+ render_footer()
1326
+ return
1327
+
1328
+ render_camera_section(ml_model, encoder, scaler, model_type, feature_columns, pose_landmarker)
1329
  render_footer()
1330
 
1331
 
1332
  if __name__ == "__main__":
1333
+ main()