yozkut commited on
Commit
a1ed761
·
verified ·
1 Parent(s): c206f78

Sync from GitHub via huggingface-sync-action

Browse files
demo.js CHANGED
@@ -106,23 +106,53 @@ async function init() {
106
  cameraBtn.disabled = true;
107
  startBtn.disabled = true;
108
 
 
 
 
109
  // Show loader
110
  showLoader('Loading YOLO26m model...');
111
  statusText.textContent = 'Loading AI model...';
112
  statusIndicator.className = 'status-dot loading';
113
 
114
- // Load YOLO model
115
- model = await AutoModel.from_pretrained('onnx-community/yolo26m-ONNX', {
116
- device: 'webgpu',
117
- dtype: 'fp16'
118
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
  showLoader('Loading processor...');
121
  processor = await AutoProcessor.from_pretrained('onnx-community/yolo26m-ONNX');
122
 
123
  // Hide loader
124
  hideLoader();
125
- statusText.textContent = 'Model ready! Open camera to begin';
 
 
126
  statusIndicator.className = 'status-dot ready';
127
  cameraBtn.disabled = false;
128
 
@@ -284,9 +314,8 @@ async function detectAndProcess() {
284
  async function detectPhoneAndGetBoxes() {
285
  try {
286
  // Resize for faster inference (trade accuracy for speed)
287
- const targetWidth = 320; // Smaller = faster (try 320, 416, or 640)
288
  const targetHeight = Math.round((targetWidth / offscreen.width) * offscreen.height);
289
- console.log(`Detection resolution: ${targetWidth}x${targetHeight}`);
290
 
291
  // Create smaller canvas for YOLO
292
  const smallCanvas = document.createElement('canvas');
@@ -531,5 +560,27 @@ startBtn.addEventListener('click', async () => {
531
  }
532
  });
533
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
  // Initialize on load
535
  init();
 
106
  cameraBtn.disabled = true;
107
  startBtn.disabled = true;
108
 
109
+ // Detect mobile device
110
+ const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
111
+
112
  // Show loader
113
  showLoader('Loading YOLO26m model...');
114
  statusText.textContent = 'Loading AI model...';
115
  statusIndicator.className = 'status-dot loading';
116
 
117
+ // Try WebGPU first on desktop, use WASM directly on mobile
118
+ let modelLoaded = false;
119
+
120
+ if (!isMobile) {
121
+ // Desktop: Try WebGPU first (faster)
122
+ try {
123
+ console.log('Attempting to load model with WebGPU...');
124
+ model = await AutoModel.from_pretrained('onnx-community/yolo26m-ONNX', {
125
+ device: 'webgpu',
126
+ dtype: 'fp16'
127
+ });
128
+ console.log('✅ Model loaded on WebGPU (fast)');
129
+ modelLoaded = true;
130
+ } catch (webgpuError) {
131
+ console.warn('WebGPU failed, falling back to WASM:', webgpuError);
132
+ }
133
+ } else {
134
+ console.log('Mobile device detected, using WASM backend');
135
+ }
136
+
137
+ // Fallback to WASM (works everywhere, including mobile)
138
+ if (!modelLoaded) {
139
+ showLoader(isMobile ? 'Loading model (mobile-optimized)...' : 'WebGPU not available, using WASM...');
140
+ console.log('Loading model with WASM backend...');
141
+
142
+ model = await AutoModel.from_pretrained('onnx-community/yolo26m-ONNX', {
143
+ device: 'wasm'
144
+ });
145
+ console.log('✅ Model loaded on WASM (compatible)');
146
+ }
147
 
148
  showLoader('Loading processor...');
149
  processor = await AutoProcessor.from_pretrained('onnx-community/yolo26m-ONNX');
150
 
151
  // Hide loader
152
  hideLoader();
153
+ statusText.textContent = isMobile
154
+ ? 'Model ready (WASM)! Open camera to begin'
155
+ : 'Model ready! Open camera to begin';
156
  statusIndicator.className = 'status-dot ready';
157
  cameraBtn.disabled = false;
158
 
 
314
  async function detectPhoneAndGetBoxes() {
315
  try {
316
  // Resize for faster inference (trade accuracy for speed)
317
+ const targetWidth = 256; // Smaller = faster (256 for +5 FPS boost)
318
  const targetHeight = Math.round((targetWidth / offscreen.width) * offscreen.height);
 
319
 
320
  // Create smaller canvas for YOLO
321
  const smallCanvas = document.createElement('canvas');
 
560
  }
561
  });
562
 
563
+ // Page Visibility API - pause ONLY on mobile when tab hidden
564
+ // Desktop: Keep running in background (users want continuous monitoring while working)
565
+ // Mobile: Pause to prevent browser from killing tab
566
+ const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
567
+
568
+ if (isMobile) {
569
+ document.addEventListener('visibilitychange', () => {
570
+ if (document.hidden && isMonitoring) {
571
+ console.log('Mobile: Tab hidden, pausing to prevent browser from killing tab');
572
+ isMonitoring = false;
573
+ btnIcon.textContent = '▶️';
574
+ btnText.textContent = 'Start Detection';
575
+ statusText.textContent = 'Paused (tab hidden)';
576
+ statusIndicator.className = 'status-dot ready';
577
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
578
+ }
579
+ });
580
+ console.log('Mobile detected: Will auto-pause when tab hidden');
581
+ } else {
582
+ console.log('Desktop: Will keep monitoring in background tabs');
583
+ }
584
+
585
  // Initialize on load
586
  init();
index.html CHANGED
@@ -55,7 +55,8 @@
55
  </div>
56
  <div class="hero-tech" style="margin-top: 1.5rem;">
57
  <div class="hero-tech-stack">
58
- <span class="tech-pill">YOLO26n</span>
 
59
  <span class="tech-pill">OpenCV</span>
60
  <span class="tech-pill">Groq (Llama 3.1)</span>
61
  <span class="tech-pill">Edge TTS</span>
 
55
  </div>
56
  <div class="hero-tech" style="margin-top: 1.5rem;">
57
  <div class="hero-tech-stack">
58
+ <span class="tech-pill">YOLO26m</span>
59
+ <span class="tech-pill">NVIDIA TensorRT</span>
60
  <span class="tech-pill">OpenCV</span>
61
  <span class="tech-pill">Groq (Llama 3.1)</span>
62
  <span class="tech-pill">Edge TTS</span>
judgy_reachy_no_phone/detection.py CHANGED
@@ -3,7 +3,7 @@
3
  import time
4
  import logging
5
  from collections import deque
6
- from typing import Optional
7
 
8
  import cv2
9
  import numpy as np
@@ -16,10 +16,16 @@ class PhoneDetector:
16
 
17
  PHONE_CLASS_ID = 67 # "cell phone" in COCO dataset
18
 
19
- def __init__(self, confidence: float = 0.5):
20
- self.confidence = confidence
 
 
 
 
 
21
  self.yolo_model = None
22
  self._initialized = False
 
23
 
24
  # State tracking
25
  self.phone_visible = False
@@ -31,53 +37,206 @@ class PhoneDetector:
31
  # History for robust detection
32
  self.history = deque(maxlen=30)
33
 
 
 
 
 
34
  # For visualization
35
  self.last_detections = []
36
 
 
 
 
 
37
  def initialize(self):
38
- """Load YOLO model."""
39
  if self._initialized:
40
  return True
41
 
42
  try:
 
 
 
 
 
 
 
43
  import torch
44
  from ultralytics import YOLO
 
45
 
46
  # Auto-detect best device (supports CUDA, MPS, and CPU)
47
  if torch.cuda.is_available():
48
  device = 'cuda' # NVIDIA GPU
 
49
  elif torch.backends.mps.is_available():
50
  device = 'mps' # Apple Silicon GPU
 
51
  else:
52
  device = 'cpu' # Fallback to CPU
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
- # Use pretrained YOLO26n model
55
- self.yolo_model = YOLO("yolo26n.pt").to(device)
56
  self._initialized = True
57
- logger.info(f"YOLO model loaded on {device.upper()}")
58
  return True
 
59
  except Exception as e:
 
 
 
 
 
60
  logger.error(f"Failed to load YOLO: {e}")
61
  return False
62
 
63
  def detect_phone(self, frame: np.ndarray) -> bool:
64
- """Check if phone is in frame."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  if not self._initialized:
66
  if not self.initialize():
67
- return False
68
 
69
  try:
70
- results = self.yolo_model(frame, verbose=False, conf=self.confidence)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  self.last_detections = results # Save for visualization
72
 
 
 
 
 
 
73
  for result in results:
 
 
 
74
  for box in result.boxes:
75
  if int(box.cls) == self.PHONE_CLASS_ID:
76
- return True
77
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  except Exception as e:
79
- logger.debug(f"YOLO detection error: {e}")
80
- return False
81
 
82
  def draw_detections(self, frame: np.ndarray) -> np.ndarray:
83
  """Draw detection boxes on frame."""
@@ -126,7 +285,9 @@ class PhoneDetector:
126
  "put_down" - Phone just put down (optional praise)
127
  None - No state change
128
  """
129
- phone_in_frame = self.detect_phone(frame)
 
 
130
 
131
  # Add to history
132
  self.history.append(phone_in_frame)
@@ -150,6 +311,14 @@ class PhoneDetector:
150
  self.last_reaction_time = now
151
  return "picked_up"
152
 
 
 
 
 
 
 
 
 
153
  # Check for phone put down (slow to confirm - avoids flickering)
154
  if self.consecutive_no_phone >= putdown_threshold and self.phone_visible:
155
  self.phone_visible = False
@@ -172,3 +341,23 @@ class PhoneDetector:
172
  def reset_count(self):
173
  """Reset daily count."""
174
  self.phone_count = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import time
4
  import logging
5
  from collections import deque
6
+ from typing import Optional, Dict, Any
7
 
8
  import cv2
9
  import numpy as np
 
16
 
17
  PHONE_CLASS_ID = 67 # "cell phone" in COCO dataset
18
 
19
+ # Adaptive confidence thresholds (like demo.js)
20
+ DETECTION_CONFIDENCE = 0.5 # Initial detection threshold
21
+ TRACKING_CONFIDENCE = 0.2 # Lower threshold when tracking existing phone
22
+ TRACKING_PERSIST_FRAMES = 3 # Keep tracking for N frames after losing detection
23
+
24
+ def __init__(self, confidence: float = 0.5, loading_callback=None):
25
+ self.confidence = confidence # Kept for backward compatibility
26
  self.yolo_model = None
27
  self._initialized = False
28
+ self.loading_callback = loading_callback # Callback to report loading progress
29
 
30
  # State tracking
31
  self.phone_visible = False
 
37
  # History for robust detection
38
  self.history = deque(maxlen=30)
39
 
40
+ # Tracking persistence (like demo.js)
41
+ self.last_phone_box: Optional[Dict[str, Any]] = None
42
+ self.frames_without_detection = 0
43
+
44
  # For visualization
45
  self.last_detections = []
46
 
47
+ # Loading state (like demo.js)
48
+ self.loading_status = "idle" # idle, loading, ready, error
49
+ self.loading_message = ""
50
+
51
  def initialize(self):
52
+ """Load YOLO model with progress reporting and TensorRT support."""
53
  if self._initialized:
54
  return True
55
 
56
  try:
57
+ # Report loading start
58
+ self.loading_status = "loading"
59
+ self.loading_message = "Loading YOLO26m model..."
60
+ if self.loading_callback:
61
+ self.loading_callback("loading", "Loading YOLO26m model...")
62
+ logger.info("Starting YOLO model initialization...")
63
+
64
  import torch
65
  from ultralytics import YOLO
66
+ import os
67
 
68
  # Auto-detect best device (supports CUDA, MPS, and CPU)
69
  if torch.cuda.is_available():
70
  device = 'cuda' # NVIDIA GPU
71
+ use_tensorrt = True
72
  elif torch.backends.mps.is_available():
73
  device = 'mps' # Apple Silicon GPU
74
+ use_tensorrt = False
75
  else:
76
  device = 'cpu' # Fallback to CPU
77
+ use_tensorrt = False
78
+
79
+ # TensorRT optimization for NVIDIA GPUs (2-3x faster!)
80
+ if use_tensorrt:
81
+ engine_path = "yolo26m.engine"
82
+
83
+ # Check if TensorRT engine already exists
84
+ if os.path.exists(engine_path):
85
+ try:
86
+ logger.info("Found existing TensorRT engine, loading...")
87
+ self.loading_message = "Loading TensorRT engine..."
88
+ if self.loading_callback:
89
+ self.loading_callback("loading", "Loading TensorRT engine...")
90
+
91
+ self.yolo_model = YOLO(engine_path)
92
+ logger.info("✅ Loaded TensorRT engine (2-3x faster!)")
93
+
94
+ except Exception as e:
95
+ logger.warning(f"TensorRT engine load failed: {e}, falling back to PyTorch")
96
+ use_tensorrt = False
97
+ else:
98
+ # Export to TensorRT engine (one-time, takes 1-2 minutes)
99
+ try:
100
+ logger.info("TensorRT engine not found, exporting (one-time setup, ~1-2 min)...")
101
+ self.loading_message = "Exporting to TensorRT (first time, ~1-2 min)..."
102
+ if self.loading_callback:
103
+ self.loading_callback("loading", "Exporting to TensorRT (first time, ~1-2 min)...")
104
+
105
+ # Load PyTorch model first
106
+ temp_model = YOLO("yolo26m.pt")
107
+
108
+ # Export to TensorRT
109
+ temp_model.export(format='engine', device=0, half=True, workspace=4)
110
+ logger.info("✅ TensorRT export complete!")
111
+
112
+ # Load the exported engine
113
+ self.yolo_model = YOLO(engine_path)
114
+ logger.info("✅ Loaded TensorRT engine (2-3x faster!)")
115
+
116
+ except Exception as e:
117
+ logger.warning(f"TensorRT export failed: {e}, using PyTorch instead")
118
+ use_tensorrt = False
119
+
120
+ # Fallback to PyTorch (if not NVIDIA GPU or TensorRT failed)
121
+ if not use_tensorrt:
122
+ self.loading_message = f"Loading YOLO26m on {device.upper()}..."
123
+ if self.loading_callback:
124
+ self.loading_callback("loading", f"Loading YOLO26m on {device.upper()}...")
125
+
126
+ self.yolo_model = YOLO("yolo26m.pt").to(device)
127
+ logger.info(f"Loaded YOLO26m on {device.upper()} (PyTorch)")
128
+
129
+ # Report success
130
+ backend = "TensorRT" if use_tensorrt else device.upper()
131
+ self.loading_status = "ready"
132
+ self.loading_message = f"Model ready on {backend}"
133
+ if self.loading_callback:
134
+ self.loading_callback("ready", f"Model ready on {backend}")
135
 
 
 
136
  self._initialized = True
137
+ logger.info(f"YOLO26m model loaded on {backend}")
138
  return True
139
+
140
  except Exception as e:
141
+ # Report error
142
+ self.loading_status = "error"
143
+ self.loading_message = f"Failed to load model: {str(e)}"
144
+ if self.loading_callback:
145
+ self.loading_callback("error", f"Failed to load model: {str(e)}")
146
  logger.error(f"Failed to load YOLO: {e}")
147
  return False
148
 
149
  def detect_phone(self, frame: np.ndarray) -> bool:
150
+ """
151
+ Check if phone is in frame (backward compatible).
152
+
153
+ For new tracking features, use detect_phone_with_tracking() instead.
154
+ """
155
+ detections = self.detect_phone_with_tracking(frame)
156
+ return len(detections) > 0
157
+
158
+ def detect_phone_with_tracking(self, frame: np.ndarray) -> list:
159
+ """
160
+ Detect phone with YOLO's built-in ByteTrack tracking + adaptive confidence.
161
+
162
+ Returns:
163
+ List of detection dicts with keys: x1, y1, x2, y2, confidence, class_name, track_id
164
+
165
+ NOTE: To revert to custom tracking, see git history or the old implementation
166
+ that used manual tracking persistence (TRACKING_PERSIST_FRAMES approach).
167
+ """
168
  if not self._initialized:
169
  if not self.initialize():
170
+ return []
171
 
172
  try:
173
+ # Adaptive confidence: lower threshold when we have active tracks
174
+ confidence_threshold = (
175
+ self.TRACKING_CONFIDENCE if self.last_phone_box
176
+ else self.DETECTION_CONFIDENCE
177
+ )
178
+
179
+ # Use YOLO's built-in tracker (ByteTrack) instead of manual tracking
180
+ # persist=True keeps track IDs across frames, tracker="bytetrack.yaml"
181
+ results = self.yolo_model.track(
182
+ frame,
183
+ persist=True, # Maintain track IDs across frames
184
+ conf=confidence_threshold, # Adaptive confidence
185
+ tracker="bytetrack.yaml", # ByteTrack algorithm (robust, fast)
186
+ verbose=False,
187
+ classes=[self.PHONE_CLASS_ID] # Only track phones
188
+ )
189
  self.last_detections = results # Save for visualization
190
 
191
+ # Collect tracked phones with their IDs
192
+ new_detections = []
193
+ best_phone = None
194
+ best_score = 0.0
195
+
196
  for result in results:
197
+ if result.boxes is None or len(result.boxes) == 0:
198
+ continue
199
+
200
  for box in result.boxes:
201
  if int(box.cls) == self.PHONE_CLASS_ID:
202
+ conf = float(box.conf)
203
+ x1, y1, x2, y2 = map(int, box.xyxy[0])
204
+
205
+ # Get track ID (ByteTrack assigns persistent IDs)
206
+ track_id = int(box.id[0]) if box.id is not None else None
207
+
208
+ detection = {
209
+ 'x1': x1,
210
+ 'y1': y1,
211
+ 'x2': x2,
212
+ 'y2': y2,
213
+ 'confidence': conf,
214
+ 'class_name': 'cell phone',
215
+ 'track_id': track_id
216
+ }
217
+
218
+ new_detections.append(detection)
219
+
220
+ # Track the most confident phone for state tracking
221
+ if conf > best_score:
222
+ best_score = conf
223
+ best_phone = detection
224
+
225
+ # Update last_phone_box with the best detection (for adaptive confidence)
226
+ if best_phone:
227
+ self.last_phone_box = best_phone
228
+ self.frames_without_detection = 0
229
+ else:
230
+ # ByteTrack handles occlusion, but we still track when we lose all detections
231
+ self.frames_without_detection += 1
232
+ if self.frames_without_detection >= self.TRACKING_PERSIST_FRAMES:
233
+ self.last_phone_box = None
234
+
235
+ return new_detections
236
+
237
  except Exception as e:
238
+ logger.debug(f"YOLO tracking error: {e}")
239
+ return []
240
 
241
  def draw_detections(self, frame: np.ndarray) -> np.ndarray:
242
  """Draw detection boxes on frame."""
 
285
  "put_down" - Phone just put down (optional praise)
286
  None - No state change
287
  """
288
+ # Use new tracking-enabled detection
289
+ detections = self.detect_phone_with_tracking(frame)
290
+ phone_in_frame = len(detections) > 0
291
 
292
  # Add to history
293
  self.history.append(phone_in_frame)
 
311
  self.last_reaction_time = now
312
  return "picked_up"
313
 
314
+ # Periodic reactions while STILL holding phone (like demo.js)
315
+ if self.phone_visible and phone_in_frame:
316
+ now = time.time()
317
+ if now - self.last_reaction_time >= cooldown:
318
+ self.phone_count += 1
319
+ self.last_reaction_time = now
320
+ return "picked_up" # Shame again!
321
+
322
  # Check for phone put down (slow to confirm - avoids flickering)
323
  if self.consecutive_no_phone >= putdown_threshold and self.phone_visible:
324
  self.phone_visible = False
 
341
  def reset_count(self):
342
  """Reset daily count."""
343
  self.phone_count = 0
344
+
345
+ def reset_tracking(self):
346
+ """Reset tracking state (useful when stopping/starting monitoring)."""
347
+ self.phone_visible = False
348
+ self.consecutive_phone = 0
349
+ self.consecutive_no_phone = 0
350
+ self.last_phone_box = None
351
+ self.frames_without_detection = 0
352
+ self.last_reaction_time = 0
353
+
354
+ # Reset ByteTrack tracker (clear track IDs)
355
+ if self.yolo_model and hasattr(self.yolo_model, 'predictor'):
356
+ try:
357
+ # This resets the tracker's internal state
358
+ self.yolo_model.predictor.trackers = []
359
+ logger.debug("ByteTrack tracker reset")
360
+ except Exception as e:
361
+ logger.debug(f"Tracker reset error (non-critical): {e}")
362
+
363
+ logger.debug("Tracking state reset")
judgy_reachy_no_phone/main.py CHANGED
@@ -42,8 +42,17 @@ class JudgyReachyNoPhone(ReachyMiniApp):
42
  super().__init__()
43
  self.config = Config()
44
 
45
- # Components
46
- self.detector = PhoneDetector(confidence=self.config.DETECTION_CONFIDENCE)
 
 
 
 
 
 
 
 
 
47
  self.llm = LLMResponder(api_key=self.config.GROQ_API_KEY, personality="pure_reachy")
48
  # Don't pass config voice defaults - let personalities use their own defaults
49
  self.tts = TextToSpeech(
@@ -80,6 +89,12 @@ class JudgyReachyNoPhone(ReachyMiniApp):
80
  self.camera_fps = 0
81
  self.detection_event_queue = []
82
 
 
 
 
 
 
 
83
  # Register API endpoint for personalities (must be before server starts)
84
  @self.settings_app.get("/api/personalities")
85
  def get_personalities():
@@ -222,7 +237,8 @@ class JudgyReachyNoPhone(ReachyMiniApp):
222
  )
223
  ui_thread.start()
224
 
225
- # Initialize detector
 
226
  self.detector.initialize()
227
 
228
  # Auto-detect: Use laptop webcam in simulation, robot camera otherwise
@@ -231,12 +247,19 @@ class JudgyReachyNoPhone(ReachyMiniApp):
231
 
232
  if is_simulation:
233
  logger.info("Simulation mode detected - using laptop webcam...")
 
 
 
234
  webcam = cv2.VideoCapture(0)
235
  if not webcam.isOpened():
236
  logger.error("Failed to open laptop webcam!")
 
 
237
  webcam = None
238
  else:
239
  logger.info("Laptop webcam opened successfully!")
 
 
240
  self.camera_running = True
241
 
242
  # Start fast camera thread
@@ -248,7 +271,12 @@ class JudgyReachyNoPhone(ReachyMiniApp):
248
  camera_thread.start()
249
  else:
250
  logger.info("Real robot detected - using robot camera...")
 
 
 
251
  self.camera_running = True
 
 
252
 
253
  # Start camera thread with robot's media system
254
  camera_thread = threading.Thread(
@@ -408,6 +436,20 @@ class JudgyReachyNoPhone(ReachyMiniApp):
408
  reset: bool = False # If True, reset all stats (Start Fresh)
409
  personality: str = "pure_reachy" # Robot personality
410
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  # API endpoint: Get video frame
412
  @self.settings_app.get("/api/video-frame")
413
  def get_video_frame():
@@ -440,7 +482,7 @@ class JudgyReachyNoPhone(ReachyMiniApp):
440
  else:
441
  status_text = "✅ Phone-free"
442
 
443
- mode_text = f"YOLO26n | {'LLM + TTS' if self.llm.client else 'Pre-written lines'} → {'ElevenLabs' if self.tts.eleven_client else 'Edge TTS'}"
444
 
445
  # Determine button text
446
  if self.is_monitoring:
@@ -622,7 +664,7 @@ class JudgyReachyNoPhone(ReachyMiniApp):
622
  # Build mode string
623
  llm_text = "LLM + TTS" if result["groq_valid"] else "Pre-written lines"
624
  tts_text = "ElevenLabs" if result["eleven_valid"] else "Edge TTS"
625
- result["mode"] = f"YOLO26n | {llm_text} → {tts_text}"
626
 
627
  return result
628
 
 
42
  super().__init__()
43
  self.config = Config()
44
 
45
+ # Loading state tracking (like demo.js)
46
+ self.model_loading_status = "idle" # idle, loading, ready, error
47
+ self.model_loading_message = ""
48
+ self.camera_loading_status = "idle" # idle, connecting, ready, error
49
+ self.camera_loading_message = "Waiting for camera connection..."
50
+
51
+ # Components (pass loading callback to detector)
52
+ self.detector = PhoneDetector(
53
+ confidence=self.config.DETECTION_CONFIDENCE,
54
+ loading_callback=self._on_model_loading
55
+ )
56
  self.llm = LLMResponder(api_key=self.config.GROQ_API_KEY, personality="pure_reachy")
57
  # Don't pass config voice defaults - let personalities use their own defaults
58
  self.tts = TextToSpeech(
 
89
  self.camera_fps = 0
90
  self.detection_event_queue = []
91
 
92
+ def _on_model_loading(self, status: str, message: str):
93
+ """Callback for model loading progress (like demo.js)."""
94
+ self.model_loading_status = status
95
+ self.model_loading_message = message
96
+ logger.info(f"Model loading: {status} - {message}")
97
+
98
  # Register API endpoint for personalities (must be before server starts)
99
  @self.settings_app.get("/api/personalities")
100
  def get_personalities():
 
237
  )
238
  ui_thread.start()
239
 
240
+ # Initialize detector (reports loading progress)
241
+ logger.info("Initializing YOLO model...")
242
  self.detector.initialize()
243
 
244
  # Auto-detect: Use laptop webcam in simulation, robot camera otherwise
 
247
 
248
  if is_simulation:
249
  logger.info("Simulation mode detected - using laptop webcam...")
250
+ self.camera_loading_status = "connecting"
251
+ self.camera_loading_message = "Opening laptop webcam..."
252
+
253
  webcam = cv2.VideoCapture(0)
254
  if not webcam.isOpened():
255
  logger.error("Failed to open laptop webcam!")
256
+ self.camera_loading_status = "error"
257
+ self.camera_loading_message = "Failed to open webcam"
258
  webcam = None
259
  else:
260
  logger.info("Laptop webcam opened successfully!")
261
+ self.camera_loading_status = "ready"
262
+ self.camera_loading_message = "Camera connected"
263
  self.camera_running = True
264
 
265
  # Start fast camera thread
 
271
  camera_thread.start()
272
  else:
273
  logger.info("Real robot detected - using robot camera...")
274
+ self.camera_loading_status = "connecting"
275
+ self.camera_loading_message = "Connecting to robot camera..."
276
+
277
  self.camera_running = True
278
+ self.camera_loading_status = "ready"
279
+ self.camera_loading_message = "Camera connected"
280
 
281
  # Start camera thread with robot's media system
282
  camera_thread = threading.Thread(
 
436
  reset: bool = False # If True, reset all stats (Start Fresh)
437
  personality: str = "pure_reachy" # Robot personality
438
 
439
+ # API endpoint: Get loading status (like demo.js)
440
+ @self.settings_app.get("/api/loading-status")
441
+ def get_loading_status():
442
+ return {
443
+ "model_status": self.model_loading_status,
444
+ "model_message": self.model_loading_message,
445
+ "camera_status": self.camera_loading_status,
446
+ "camera_message": self.camera_loading_message,
447
+ "overall_ready": (
448
+ self.model_loading_status == "ready" and
449
+ self.camera_loading_status == "ready"
450
+ )
451
+ }
452
+
453
  # API endpoint: Get video frame
454
  @self.settings_app.get("/api/video-frame")
455
  def get_video_frame():
 
482
  else:
483
  status_text = "✅ Phone-free"
484
 
485
+ mode_text = f"YOLO26m | {'LLM + TTS' if self.llm.client else 'Pre-written lines'} → {'ElevenLabs' if self.tts.eleven_client else 'Edge TTS'}"
486
 
487
  # Determine button text
488
  if self.is_monitoring:
 
664
  # Build mode string
665
  llm_text = "LLM + TTS" if result["groq_valid"] else "Pre-written lines"
666
  tts_text = "ElevenLabs" if result["eleven_valid"] else "Edge TTS"
667
+ result["mode"] = f"YOLO26m | {llm_text} → {tts_text}"
668
 
669
  return result
670
 
judgy_reachy_no_phone/static/index.html CHANGED
@@ -51,8 +51,14 @@
51
  <path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"></path>
52
  <line x1="1" y1="1" x2="23" y2="23"></line>
53
  </svg>
54
- <div class="camera-status">Camera Inactive</div>
55
- <div class="camera-message">Waiting for camera connection...</div>
 
 
 
 
 
 
56
  </div>
57
  </div>
58
  </div>
 
51
  <path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"></path>
52
  <line x1="1" y1="1" x2="23" y2="23"></line>
53
  </svg>
54
+ <div class="camera-status" id="camera-status">Camera Inactive</div>
55
+ <div class="camera-message" id="camera-message">Waiting for camera connection...</div>
56
+ </div>
57
+
58
+ <!-- Loading Overlay (like demo.js) -->
59
+ <div id="loader-overlay" class="loader-overlay">
60
+ <div class="spinner"></div>
61
+ <p id="loader-text" class="loader-text">Initializing...</p>
62
  </div>
63
  </div>
64
  </div>
judgy_reachy_no_phone/static/main.js CHANGED
@@ -3,6 +3,9 @@ let selectedPersonality = 'pure_reachy';
3
  let currentVoicePersonality = null;
4
  let voiceOverrides = JSON.parse(localStorage.getItem('voiceOverrides') || '{}');
5
 
 
 
 
6
  // Load personalities from config dynamically
7
  async function loadPersonalities() {
8
  try {
@@ -74,7 +77,7 @@ async function updateUIForAPIKeys() {
74
 
75
  // If no API keys at all, show default message
76
  if (!groqKey && !elevenKey) {
77
- document.getElementById('mode-text').textContent = 'YOLO26n | Pre-written personality lines → Edge TTS';
78
  document.getElementById('api-notice').classList.remove('hidden');
79
  // Keep personalities enabled - they still have different voices and pre-written lines
80
  return;
@@ -421,6 +424,65 @@ async function updatePersonalityWhileRunning() {
421
  }
422
  }
423
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  // Initial update - wait for personalities to load first
425
  async function initialize() {
426
  // Load personalities first
@@ -451,6 +513,10 @@ async function initialize() {
451
  updateVideo();
452
  updateUIForAPIKeys();
453
 
 
 
 
 
454
  // Auto-update every 100ms for smooth video
455
  setInterval(() => {
456
  updateVideo();
 
3
  let currentVoicePersonality = null;
4
  let voiceOverrides = JSON.parse(localStorage.getItem('voiceOverrides') || '{}');
5
 
6
+ // Loading state (like demo.js)
7
+ let loadingCheckInterval = null;
8
+
9
  // Load personalities from config dynamically
10
  async function loadPersonalities() {
11
  try {
 
77
 
78
  // If no API keys at all, show default message
79
  if (!groqKey && !elevenKey) {
80
+ document.getElementById('mode-text').textContent = 'YOLO26m | Pre-written personality lines → Edge TTS';
81
  document.getElementById('api-notice').classList.remove('hidden');
82
  // Keep personalities enabled - they still have different voices and pre-written lines
83
  return;
 
424
  }
425
  }
426
 
427
+ // Loading status functions (like demo.js)
428
+ const showLoader = (text) => {
429
+ const loader = document.getElementById('loader-overlay');
430
+ const loaderText = document.getElementById('loader-text');
431
+ loaderText.textContent = text;
432
+ loader.classList.add('visible');
433
+ };
434
+
435
+ const hideLoader = () => {
436
+ const loader = document.getElementById('loader-overlay');
437
+ loader.classList.remove('visible');
438
+ };
439
+
440
+ // Check loading status and update UI
441
+ async function checkLoadingStatus() {
442
+ try {
443
+ const response = await fetch('/api/loading-status');
444
+ const data = await response.json();
445
+
446
+ const cameraStatus = document.getElementById('camera-status');
447
+ const cameraMessage = document.getElementById('camera-message');
448
+
449
+ // Update loader overlay
450
+ if (data.model_status === 'loading') {
451
+ showLoader(data.model_message);
452
+ } else if (data.camera_status === 'connecting') {
453
+ showLoader(data.camera_message);
454
+ } else if (data.overall_ready) {
455
+ hideLoader();
456
+ // Stop polling once everything is ready
457
+ if (loadingCheckInterval) {
458
+ clearInterval(loadingCheckInterval);
459
+ loadingCheckInterval = null;
460
+ }
461
+ } else if (data.model_status === 'error' || data.camera_status === 'error') {
462
+ hideLoader();
463
+ if (loadingCheckInterval) {
464
+ clearInterval(loadingCheckInterval);
465
+ loadingCheckInterval = null;
466
+ }
467
+ }
468
+
469
+ // Update camera placeholder status
470
+ if (data.camera_status === 'connecting') {
471
+ cameraStatus.textContent = 'Connecting...';
472
+ cameraMessage.textContent = data.camera_message;
473
+ } else if (data.camera_status === 'ready') {
474
+ cameraStatus.textContent = 'Camera Ready';
475
+ cameraMessage.textContent = data.camera_message;
476
+ } else if (data.camera_status === 'error') {
477
+ cameraStatus.textContent = 'Camera Error';
478
+ cameraMessage.textContent = data.camera_message;
479
+ }
480
+
481
+ } catch (e) {
482
+ console.error('Loading status check failed:', e);
483
+ }
484
+ }
485
+
486
  // Initial update - wait for personalities to load first
487
  async function initialize() {
488
  // Load personalities first
 
513
  updateVideo();
514
  updateUIForAPIKeys();
515
 
516
+ // Start loading status check (poll every 500ms until ready)
517
+ checkLoadingStatus(); // Check immediately
518
+ loadingCheckInterval = setInterval(checkLoadingStatus, 500);
519
+
520
  // Auto-update every 100ms for smooth video
521
  setInterval(() => {
522
  updateVideo();
judgy_reachy_no_phone/static/style.css CHANGED
@@ -729,6 +729,53 @@ body {
729
  50% { opacity: 0.5; }
730
  }
731
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732
  /* Responsive */
733
  @media (max-width: 1200px) {
734
  .main-container {
 
729
  50% { opacity: 0.5; }
730
  }
731
 
732
+ /* Loading Overlay (like demo.js) */
733
+ .loader-overlay {
734
+ position: absolute;
735
+ top: 0;
736
+ left: 0;
737
+ width: 100%;
738
+ height: 100%;
739
+ background: rgba(0, 0, 0, 0.9);
740
+ backdrop-filter: blur(8px);
741
+ display: flex;
742
+ flex-direction: column;
743
+ align-items: center;
744
+ justify-content: center;
745
+ z-index: 10;
746
+ opacity: 0;
747
+ visibility: hidden;
748
+ transition: opacity 0.3s ease, visibility 0.3s ease;
749
+ }
750
+
751
+ .loader-overlay.visible {
752
+ opacity: 1;
753
+ visibility: visible;
754
+ }
755
+
756
+ .spinner {
757
+ width: 50px;
758
+ height: 50px;
759
+ border: 4px solid rgba(255, 255, 255, 0.1);
760
+ border-top-color: #667eea;
761
+ border-radius: 50%;
762
+ animation: spin 1s linear infinite;
763
+ }
764
+
765
+ @keyframes spin {
766
+ to {
767
+ transform: rotate(360deg);
768
+ }
769
+ }
770
+
771
+ .loader-text {
772
+ color: #f1f5f9;
773
+ font-size: 1rem;
774
+ font-weight: 600;
775
+ margin-top: 1.5rem;
776
+ text-align: center;
777
+ }
778
+
779
  /* Responsive */
780
  @media (max-width: 1200px) {
781
  .main-container {
style.css CHANGED
@@ -335,46 +335,6 @@ section {
335
  margin-right: auto;
336
  }
337
 
338
- /* Callout Box */
339
- .callout-box {
340
- background: linear-gradient(135deg, rgba(255, 107, 74, 0.1), rgba(155, 126, 232, 0.08));
341
- border: 2px solid rgba(255, 107, 74, 0.3);
342
- border-radius: var(--radius-lg);
343
- padding: 2.5rem;
344
- text-align: center;
345
- box-shadow: var(--shadow-lg);
346
- height: 100%;
347
- display: flex;
348
- flex-direction: column;
349
- justify-content: center;
350
- }
351
-
352
- .callout-icon {
353
- font-size: 3.5rem;
354
- margin-bottom: 1.25rem;
355
- }
356
-
357
- .callout-title {
358
- font-family: 'Inter', sans-serif;
359
- font-size: 1.75rem;
360
- font-weight: 700;
361
- margin-bottom: 1.25rem;
362
- color: var(--text-primary);
363
- line-height: 1.3;
364
- }
365
-
366
- .callout-text {
367
- font-size: 1rem;
368
- line-height: 1.7;
369
- color: var(--text-body);
370
- margin: 0;
371
- }
372
-
373
- .callout-text strong {
374
- color: var(--coral-light);
375
- font-weight: 700;
376
- }
377
-
378
  /* Configuration Section */
379
  .config-section {
380
  text-align: center;
@@ -431,12 +391,6 @@ section {
431
  opacity: 1;
432
  }
433
 
434
- .config-icon {
435
- font-size: 3rem;
436
- margin-bottom: 1.25rem;
437
- display: inline-block;
438
- }
439
-
440
  .config-card h3 {
441
  font-family: 'Inter', sans-serif;
442
  font-size: 1.5rem;
@@ -751,47 +705,6 @@ section {
751
  color: var(--coral);
752
  }
753
 
754
- text-align: center;
755
- }
756
-
757
- .tech-grid {
758
- display: grid;
759
- grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
760
- gap: 1rem;
761
- max-width: 800px;
762
- margin: 0 auto;
763
- }
764
-
765
- .tech-badge {
766
- background: var(--bg-card);
767
- border: 2px solid var(--border-soft);
768
- border-radius: var(--radius-md);
769
- padding: 1.5rem 1.25rem;
770
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
771
- box-shadow: var(--shadow-sm);
772
- }
773
-
774
- .tech-badge:hover {
775
- border-color: var(--coral);
776
- transform: translateY(-3px) scale(1.02);
777
- box-shadow: var(--shadow-md);
778
- }
779
-
780
- .tech-label {
781
- font-size: 0.7rem;
782
- text-transform: uppercase;
783
- letter-spacing: 0.06em;
784
- color: var(--coral);
785
- margin-bottom: 0.5rem;
786
- font-weight: 700;
787
- }
788
-
789
- .tech-value {
790
- font-family: 'Inter', sans-serif;
791
- font-size: 0.95rem;
792
- font-weight: 600;
793
- color: var(--text-primary);
794
- }
795
 
796
  /* Footer */
797
  .footer {
@@ -837,20 +750,6 @@ section {
837
  }
838
 
839
  /* Responsive Design */
840
- @media (max-width: 900px) {
841
- .steps-flow {
842
- grid-template-columns: repeat(2, 1fr);
843
- gap: 1rem;
844
- }
845
-
846
- .steps-flow::before {
847
- display: none;
848
- }
849
-
850
- .step-box {
851
- margin: 0;
852
- }
853
- }
854
 
855
  @media (max-width: 768px) {
856
  .hero-grid {
@@ -909,14 +808,6 @@ section {
909
  .api-link-inline {
910
  display: none;
911
  }
912
-
913
- .steps-flow {
914
- grid-template-columns: 1fr;
915
- }
916
-
917
- .tech-grid {
918
- grid-template-columns: repeat(2, 1fr);
919
- }
920
  }
921
 
922
  @media (max-width: 480px) {
@@ -928,10 +819,6 @@ section {
928
  font-size: 1.75rem;
929
  }
930
 
931
- .tech-grid {
932
- grid-template-columns: 1fr;
933
- }
934
-
935
  .logo-text {
936
  font-size: 0.9rem;
937
  }
@@ -1196,262 +1083,6 @@ section {
1196
  font-style: italic;
1197
  }
1198
 
1199
- /* Sidebar */
1200
- .demo-sidebar {
1201
- display: flex;
1202
- flex-direction: column;
1203
- gap: 1.5rem;
1204
- }
1205
-
1206
- .demo-card {
1207
- background: #1e293b;
1208
- border: 2px solid #334155;
1209
- border-radius: 16px;
1210
- padding: 1.5rem;
1211
- }
1212
-
1213
- .demo-card-title {
1214
- color: #f1f5f9;
1215
- font-size: 1.1rem;
1216
- font-weight: 700;
1217
- margin-bottom: 1rem;
1218
- }
1219
-
1220
- /* Stats */
1221
- .stat-grid {
1222
- display: grid;
1223
- gap: 0.75rem;
1224
- }
1225
-
1226
- .stat-item {
1227
- display: flex;
1228
- justify-content: space-between;
1229
- align-items: center;
1230
- padding: 0.75rem 0;
1231
- border-bottom: 1px solid #334155;
1232
- }
1233
-
1234
- .stat-item:last-child {
1235
- border-bottom: none;
1236
- }
1237
-
1238
- .stat-label {
1239
- color: #94a3b8;
1240
- font-size: 0.9rem;
1241
- }
1242
-
1243
- .stat-value {
1244
- color: #f1f5f9;
1245
- font-weight: 700;
1246
- font-size: 1.1rem;
1247
- }
1248
-
1249
- /* Select */
1250
- .demo-select {
1251
- width: 100%;
1252
- padding: 0.75rem 1rem;
1253
- background: #0f172a;
1254
- border: 2px solid #334155;
1255
- border-radius: 8px;
1256
- color: #f1f5f9;
1257
- font-size: 1rem;
1258
- cursor: pointer;
1259
- transition: all 0.2s ease;
1260
- }
1261
-
1262
- .demo-select:hover, .demo-select:focus {
1263
- border-color: #667eea;
1264
- outline: none;
1265
- }
1266
-
1267
- /* Settings */
1268
- .setting-item {
1269
- margin-bottom: 1.5rem;
1270
- }
1271
-
1272
- .setting-item:last-child {
1273
- margin-bottom: 0;
1274
- }
1275
-
1276
- .setting-label {
1277
- display: block;
1278
- color: #94a3b8;
1279
- font-size: 0.9rem;
1280
- margin-bottom: 0.5rem;
1281
- }
1282
-
1283
- .demo-slider {
1284
- width: 100%;
1285
- height: 6px;
1286
- border-radius: 3px;
1287
- background: #334155;
1288
- outline: none;
1289
- -webkit-appearance: none;
1290
- }
1291
-
1292
- .demo-slider::-webkit-slider-thumb {
1293
- -webkit-appearance: none;
1294
- appearance: none;
1295
- width: 20px;
1296
- height: 20px;
1297
- border-radius: 50%;
1298
- background: #667eea;
1299
- cursor: pointer;
1300
- transition: all 0.2s ease;
1301
- }
1302
-
1303
- .demo-slider::-webkit-slider-thumb:hover {
1304
- background: #764ba2;
1305
- transform: scale(1.1);
1306
- }
1307
-
1308
- .demo-slider::-moz-range-thumb {
1309
- width: 20px;
1310
- height: 20px;
1311
- border-radius: 50%;
1312
- background: #667eea;
1313
- cursor: pointer;
1314
- border: none;
1315
- transition: all 0.2s ease;
1316
- }
1317
-
1318
- .demo-slider::-moz-range-thumb:hover {
1319
- background: #764ba2;
1320
- transform: scale(1.1);
1321
- }
1322
-
1323
- .setting-checkbox {
1324
- display: flex;
1325
- align-items: center;
1326
- gap: 0.75rem;
1327
- color: #f1f5f9;
1328
- cursor: pointer;
1329
- user-select: none;
1330
- }
1331
-
1332
- .setting-checkbox input[type="checkbox"] {
1333
- width: 20px;
1334
- height: 20px;
1335
- cursor: pointer;
1336
- accent-color: #667eea;
1337
- }
1338
-
1339
- /* Note */
1340
- .demo-note {
1341
- background: rgba(102, 126, 234, 0.1);
1342
- border: 2px solid rgba(102, 126, 234, 0.3);
1343
- border-radius: 12px;
1344
- padding: 1rem;
1345
- color: #cbd5e1;
1346
- font-size: 0.85rem;
1347
- line-height: 1.6;
1348
- }
1349
-
1350
- .demo-note strong {
1351
- color: #f1f5f9;
1352
- display: block;
1353
- margin-bottom: 0.5rem;
1354
- }
1355
-
1356
- .demo-note p {
1357
- margin: 0;
1358
- }
1359
-
1360
- /* API Keys Section */
1361
- .api-note {
1362
- color: #94a3b8;
1363
- font-size: 0.85rem;
1364
- margin-bottom: 1rem;
1365
- line-height: 1.5;
1366
- }
1367
-
1368
- .demo-input {
1369
- width: 100%;
1370
- padding: 0.75rem 1rem;
1371
- background: #0f172a;
1372
- border: 2px solid #334155;
1373
- border-radius: 8px;
1374
- color: #f1f5f9;
1375
- font-size: 0.9rem;
1376
- transition: all 0.2s ease;
1377
- }
1378
-
1379
- .demo-input:hover,
1380
- .demo-input:focus {
1381
- border-color: #667eea;
1382
- outline: none;
1383
- }
1384
-
1385
- .demo-input::placeholder {
1386
- color: #475569;
1387
- }
1388
-
1389
- .api-link-inline {
1390
- color: #667eea;
1391
- text-decoration: none;
1392
- font-size: 0.85rem;
1393
- margin-left: 0.5rem;
1394
- transition: color 0.2s ease;
1395
- }
1396
-
1397
- .api-link-inline:hover {
1398
- color: #764ba2;
1399
- text-decoration: underline;
1400
- }
1401
-
1402
- .mode-indicator {
1403
- margin-top: 1rem;
1404
- padding: 0.75rem;
1405
- background: rgba(102, 126, 234, 0.1);
1406
- border-radius: 8px;
1407
- text-align: center;
1408
- }
1409
-
1410
- #mode-text {
1411
- color: #cbd5e1;
1412
- font-size: 0.85rem;
1413
- font-weight: 600;
1414
- }
1415
-
1416
- /* Secondary Controls Row */
1417
- .demo-controls-secondary {
1418
- display: flex;
1419
- gap: 1rem;
1420
- flex-wrap: wrap;
1421
- margin-top: 1rem;
1422
- }
1423
-
1424
- .demo-controls-secondary .btn-demo {
1425
- flex: 1;
1426
- min-width: 120px;
1427
- }
1428
-
1429
- /* Pure Reachy Mode Info */
1430
- .reachy-description {
1431
- color: #cbd5e1;
1432
- font-size: 0.85rem;
1433
- line-height: 1.6;
1434
- margin: 0;
1435
- }
1436
-
1437
- /* Demo Download Button */
1438
- .demo-download-btn {
1439
- display: inline-block;
1440
- margin-top: 1rem;
1441
- padding: 0.75rem 1.5rem;
1442
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1443
- color: white;
1444
- text-decoration: none;
1445
- border-radius: 8px;
1446
- font-weight: 600;
1447
- transition: all 0.2s ease;
1448
- }
1449
-
1450
- .demo-download-btn:hover {
1451
- transform: translateY(-2px);
1452
- box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
1453
- }
1454
-
1455
  .demo-section .section-subtitle {
1456
  max-width: 1500px;
1457
  line-height: 1.8;
@@ -1514,43 +1145,6 @@ section {
1514
  transform: translateY(-3px);
1515
  }
1516
 
1517
- /* Scroll Indicator */
1518
- .scroll-indicator {
1519
- position: absolute;
1520
- bottom: 2rem;
1521
- left: 50%;
1522
- transform: translateX(-50%);
1523
- z-index: 10;
1524
- }
1525
-
1526
- .scroll-arrow {
1527
- display: flex;
1528
- align-items: center;
1529
- justify-content: center;
1530
- width: 48px;
1531
- height: 48px;
1532
- border-radius: 50%;
1533
- background: rgba(255, 255, 255, 0.1);
1534
- border: 2px solid rgba(255, 255, 255, 0.3);
1535
- color: white;
1536
- text-decoration: none;
1537
- transition: all 0.3s ease;
1538
- animation: bounce 2s infinite;
1539
- }
1540
-
1541
- .scroll-arrow:hover {
1542
- background: rgba(255, 255, 255, 0.2);
1543
- border-color: rgba(255, 255, 255, 0.5);
1544
- }
1545
-
1546
- @keyframes bounce {
1547
- 0%, 100% {
1548
- transform: translateY(0);
1549
- }
1550
- 50% {
1551
- transform: translateY(-10px);
1552
- }
1553
- }
1554
 
1555
  /* Make hero section position relative for scroll indicator */
1556
  .hero-section {
@@ -1562,14 +1156,6 @@ section {
1562
  max-width: 1450px; /* Wider for more spread out content */
1563
  }
1564
 
1565
- /* Robot Animation Container */
1566
- .robot-container {
1567
- display: flex;
1568
- flex-direction: column;
1569
- align-items: center;
1570
- gap: 1rem;
1571
- }
1572
-
1573
  /* Green background container (stationary, vertical rectangle) */
1574
  .robot-bg {
1575
  background: #3DDE99; /* Reachy green */
@@ -1588,7 +1174,7 @@ section {
1588
  height: 150%;
1589
  display: block;
1590
  object-fit: cover; /* Fill the container */
1591
- transform-origin: center center;
1592
  margin: -25%; /* Re-center after zoom */
1593
  animation: robot-idle 3s ease-in-out infinite;
1594
  }
@@ -1871,22 +1457,6 @@ section {
1871
  }
1872
  }
1873
 
1874
- /* Mobile-specific subtitle */
1875
- .mobile-subtitle {
1876
- display: none; /* Hidden on desktop */
1877
- }
1878
-
1879
- @media (max-width: 640px) {
1880
- .mobile-subtitle {
1881
- display: block; /* Show on mobile */
1882
- color: #cbd5e1;
1883
- font-size: 0.85rem;
1884
- line-height: 1.5;
1885
- text-align: center;
1886
- margin-bottom: 2rem;
1887
- padding: 0 1rem;
1888
- }
1889
- }
1890
 
1891
  /* Mobile: Tighter hero spacing and GIF closer to top */
1892
  @media (max-width: 640px) {
@@ -1985,119 +1555,6 @@ h4, h5, h6, strong {
1985
  font-weight: 500; /* Medium for buttons */
1986
  }
1987
 
1988
- /* Mobile: Smaller 3-column personalities */
1989
- @media (max-width: 640px) {
1990
- .personalities-grid {
1991
- grid-template-columns: repeat(3, 1fr);
1992
- gap: 0.25rem; /* Tighter spacing */
1993
- }
1994
-
1995
- .personality-card {
1996
- padding: 0.5rem 0.25rem; /* Minimal padding */
1997
- border: none;
1998
- background: transparent;
1999
- box-shadow: none;
2000
- }
2001
-
2002
- .personality-card:hover {
2003
- transform: none;
2004
- box-shadow: none;
2005
- }
2006
-
2007
- .personality-emoji {
2008
- font-size: 1.5rem;
2009
- margin-bottom: 0.25rem;
2010
- }
2011
-
2012
- .personality-card h3 {
2013
- font-size: 0.75rem;
2014
- margin-bottom: 0.25rem;
2015
- }
2016
-
2017
- .personality-card p {
2018
- display: none; /* Hide description on mobile */
2019
- }
2020
-
2021
- .personality-tags {
2022
- display: none; /* Hide tags to save space */
2023
- }
2024
- }
2025
-
2026
- /* Mobile: Compact features (3 columns) */
2027
- @media (max-width: 640px) {
2028
- .features-grid {
2029
- grid-template-columns: repeat(3, 1fr);
2030
- gap: 0.25rem;
2031
- }
2032
-
2033
- .feature-item {
2034
- padding: 0.75rem 0.5rem;
2035
- border: none;
2036
- background: transparent;
2037
- box-shadow: none;
2038
- }
2039
-
2040
- .feature-item:hover {
2041
- transform: none;
2042
- }
2043
-
2044
- .feature-icon-badge {
2045
- width: 36px;
2046
- height: 36px;
2047
- margin-bottom: 0.5rem;
2048
- border-radius: 8px;
2049
- }
2050
-
2051
- .feature-icon-badge svg {
2052
- width: 16px;
2053
- height: 16px;
2054
- }
2055
-
2056
- .feature-item h3 {
2057
- font-size: 0.75rem;
2058
- margin-bottom: 0.25rem;
2059
- }
2060
-
2061
- .feature-item p {
2062
- display: none; /* Hide description */
2063
- }
2064
- }
2065
-
2066
- /* Mobile: Compact steps (2x2 grid for 4 steps) */
2067
- @media (max-width: 640px) {
2068
- .steps-flow {
2069
- grid-template-columns: repeat(2, 1fr);
2070
- gap: 0.5rem;
2071
- }
2072
-
2073
- .step-box {
2074
- padding: 1rem 0.75rem;
2075
- margin: 0;
2076
- border: none;
2077
- background: transparent;
2078
- box-shadow: none;
2079
- }
2080
-
2081
- .step-box:hover {
2082
- transform: none;
2083
- }
2084
-
2085
- .step-num {
2086
- width: 32px;
2087
- height: 32px;
2088
- font-size: 0.9rem;
2089
- margin-bottom: 0.5rem;
2090
- }
2091
-
2092
- .step-box h3 {
2093
- font-size: 0.75rem;
2094
- margin-bottom: 0.25rem;
2095
- }
2096
-
2097
- .step-box p {
2098
- font-size: 0.65rem;
2099
- }
2100
- }
2101
 
2102
  /* Mobile: Prevent all overflow */
2103
  @media (max-width: 640px) {
@@ -2126,7 +1583,6 @@ h4, h5, h6, strong {
2126
  @media (max-width: 640px) {
2127
  .config-section,
2128
  .features-section,
2129
- .how-section,
2130
  .personalities-section {
2131
  display: none;
2132
  }
 
335
  margin-right: auto;
336
  }
337
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  /* Configuration Section */
339
  .config-section {
340
  text-align: center;
 
391
  opacity: 1;
392
  }
393
 
 
 
 
 
 
 
394
  .config-card h3 {
395
  font-family: 'Inter', sans-serif;
396
  font-size: 1.5rem;
 
705
  color: var(--coral);
706
  }
707
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
708
 
709
  /* Footer */
710
  .footer {
 
750
  }
751
 
752
  /* Responsive Design */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
753
 
754
  @media (max-width: 768px) {
755
  .hero-grid {
 
808
  .api-link-inline {
809
  display: none;
810
  }
 
 
 
 
 
 
 
 
811
  }
812
 
813
  @media (max-width: 480px) {
 
819
  font-size: 1.75rem;
820
  }
821
 
 
 
 
 
822
  .logo-text {
823
  font-size: 0.9rem;
824
  }
 
1083
  font-style: italic;
1084
  }
1085
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1086
  .demo-section .section-subtitle {
1087
  max-width: 1500px;
1088
  line-height: 1.8;
 
1145
  transform: translateY(-3px);
1146
  }
1147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1148
 
1149
  /* Make hero section position relative for scroll indicator */
1150
  .hero-section {
 
1156
  max-width: 1450px; /* Wider for more spread out content */
1157
  }
1158
 
 
 
 
 
 
 
 
 
1159
  /* Green background container (stationary, vertical rectangle) */
1160
  .robot-bg {
1161
  background: #3DDE99; /* Reachy green */
 
1174
  height: 150%;
1175
  display: block;
1176
  object-fit: cover; /* Fill the container */
1177
+ transform-origin: center center;
1178
  margin: -25%; /* Re-center after zoom */
1179
  animation: robot-idle 3s ease-in-out infinite;
1180
  }
 
1457
  }
1458
  }
1459
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1460
 
1461
  /* Mobile: Tighter hero spacing and GIF closer to top */
1462
  @media (max-width: 640px) {
 
1555
  font-weight: 500; /* Medium for buttons */
1556
  }
1557
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1558
 
1559
  /* Mobile: Prevent all overflow */
1560
  @media (max-width: 640px) {
 
1583
  @media (max-width: 640px) {
1584
  .config-section,
1585
  .features-section,
 
1586
  .personalities-section {
1587
  display: none;
1588
  }