macbook commited on
Commit
b1b1d12
·
1 Parent(s): ba6da72

UI Refinement: Lumina Glass theme restoration, Branding updates to WQF7006, Layout compaction, and Sidebar swap

Browse files
Files changed (6) hide show
  1. GITHUB_AUTH.md +18 -0
  2. README.md +67 -0
  3. app.py +74 -30
  4. static/style.css +384 -144
  5. templates/index.html +449 -267
  6. video_processor.py +55 -0
GITHUB_AUTH.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GitHub Authentication Instructions
2
+
3
+ ## Step 1: Copy Your Code
4
+ ```
5
+ E2FF-39B2
6
+ ```
7
+
8
+ ## Step 2: Open Browser
9
+ Go to: https://github.com/login/device
10
+
11
+ ## Step 3: Enter Code
12
+ Paste the code `E2FF-39B2` when prompted
13
+
14
+ ## Step 4: Authorize
15
+ Click "Authorize" to allow GitHub CLI access
16
+
17
+ ## Step 5: Return Here
18
+ Once completed, type "done" and I'll push your code to GitHub!
README.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Malaysian Sign Language Recognition (CVSLR)
2
+
3
+ A real-time Malaysian Sign Language recognition system using Computer Vision and Deep Learning.
4
+
5
+ ## Features
6
+
7
+ - **Real-time Recognition**: Live camera feed processing with gesture detection
8
+ - **Video Upload**: Support for single video and batch playlist processing
9
+ - **High Accuracy**: CNN-Transformer hybrid model with 50 gesture classes
10
+ - **Modern UI**: Elegant glassmorphism design with gradient accents
11
+ - **Session Logging**: Track detected gestures with confidence scores
12
+
13
+ ## Technology Stack
14
+
15
+ - **Backend**: Flask, Python 3.11
16
+ - **ML/AI**: PyTorch, MediaPipe, OpenCV
17
+ - **Frontend**: HTML5, CSS3, JavaScript
18
+ - **Model**: CNN-Transformer Hybrid Architecture
19
+
20
+ ## Local Setup
21
+
22
+ 1. **Clone the repository**
23
+ ```bash
24
+ git clone https://github.com/WondosenAm/CVSLR.git
25
+ cd CVSLR
26
+ ```
27
+
28
+ 2. **Create virtual environment**
29
+ ```bash
30
+ python -m venv venv
31
+ source venv/bin/activate # On Windows: venv\Scripts\activate
32
+ ```
33
+
34
+ 3. **Install dependencies**
35
+ ```bash
36
+ pip install -r requirements.txt
37
+ ```
38
+
39
+ 4. **Run the application**
40
+ ```bash
41
+ python app.py
42
+ ```
43
+
44
+ 5. **Access the app**
45
+ - Open browser: `http://127.0.0.1:8181`
46
+
47
+ ## Deployment
48
+
49
+ See [DEPLOYMENT.md](DEPLOYMENT.md) for instructions on deploying to Render or other platforms.
50
+
51
+ ## Model Information
52
+
53
+ - **Architecture**: CNN-Transformer Hybrid
54
+ - **Input**: 30-frame sequences of pose + hand landmarks (258 features)
55
+ - **Output**: 50 Malaysian Sign Language gestures
56
+ - **Confidence Threshold**: 60%
57
+
58
+ ## Credits
59
+
60
+ **Developed by**: Group 4 (Vision)
61
+ **Institution**: Faculty of Computer Science and Information Technology
62
+ **Department**: Artificial Intelligence
63
+ **University**: University Malaya
64
+
65
+ ## License
66
+
67
+ This project is for educational purposes.
app.py CHANGED
@@ -5,7 +5,7 @@ import cv2
5
  import threading
6
  import time
7
  import atexit
8
- from video_processor import GestureRecognizer, GESTURE_NAMES
9
 
10
  app = Flask(__name__)
11
  app.config['UPLOAD_FOLDER'] = 'uploads'
@@ -70,6 +70,11 @@ class CameraStream:
70
  def update(self):
71
  global outputFrame
72
  global latest_prediction
 
 
 
 
 
73
  while True:
74
  if self.running and self.video is not None and self.video.isOpened():
75
  success, frame = self.video.read()
@@ -78,56 +83,95 @@ class CameraStream:
78
  if self.source == 0:
79
  frame = cv2.flip(frame, 1)
80
 
81
- # Process frame
 
 
 
82
  try:
83
  result = self.recognizer.predict(frame)
84
 
85
- # Update global prediction state
86
- probs = result['probabilities']
87
- top_3 = []
88
- if probs is not None:
89
- top_indices = probs.argsort()[-3:][::-1]
90
- top_3 = [
91
- {"name": GESTURE_NAMES.get(i, f"G{i}"), "prob": float(probs[i])}
92
- for i in top_indices
93
- ]
94
-
95
- # Enforce Threshold
96
- gesture_name = result['gesture_name']
97
- confidence = float(result['confidence'])
98
-
99
- if gesture_name is None or confidence < 0.6:
100
- gesture_name = "Unknown"
101
-
102
- latest_prediction = {
103
- "gesture": gesture_name,
104
- "confidence": confidence,
105
- "top_3": top_3
106
- }
107
-
108
  # Draw results
109
  annotated_frame = self.recognizer.draw_landmarks(frame, result['pose_result'], result['hand_result'])
110
 
111
  with lock:
112
  outputFrame = annotated_frame.copy()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  except Exception as e:
114
  print(f"Error processing frame: {e}")
115
 
116
  else:
117
  # Video finished
118
  if isinstance(self.source, str):
119
- print(f"Finished: {self.source}")
 
 
 
 
 
 
 
 
 
 
 
 
 
120
 
121
  # Check queue
122
  if self.queue:
123
- print(f"Starting next in queue. Remaining: {len(self.queue)}")
124
  self.video.release()
125
  self.source = self.queue.pop(0)
126
  self.video = cv2.VideoCapture(self.source)
127
- # Loop continues immediately with new source
128
  else:
129
- print("Playlist finished.")
130
- self.running = False # Internal stop without clearing queue (already empty)
131
  if self.video:
132
  self.video.release()
133
  self.video = None
 
5
  import threading
6
  import time
7
  import atexit
8
+ from video_processor import GestureRecognizer, GESTURE_NAMES, GESTURE_TRANSLATIONS
9
 
10
  app = Flask(__name__)
11
  app.config['UPLOAD_FOLDER'] = 'uploads'
 
70
  def update(self):
71
  global outputFrame
72
  global latest_prediction
73
+
74
+ # Track predictions for continuous gloss-level prediction
75
+ frame_buffer = [] # Buffer to collect 30 frames
76
+ gloss_predictions = [] # Store all gloss predictions with confidence
77
+
78
  while True:
79
  if self.running and self.video is not None and self.video.isOpened():
80
  success, frame = self.video.read()
 
83
  if self.source == 0:
84
  frame = cv2.flip(frame, 1)
85
 
86
+ # Add frame to buffer
87
+ frame_buffer.append(frame.copy())
88
+
89
+ # Process frame for visualization
90
  try:
91
  result = self.recognizer.predict(frame)
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  # Draw results
94
  annotated_frame = self.recognizer.draw_landmarks(frame, result['pose_result'], result['hand_result'])
95
 
96
  with lock:
97
  outputFrame = annotated_frame.copy()
98
+
99
+ # CONTINUOUS GLOSS-LEVEL PREDICTION
100
+ # When we have 30 frames, predict the gloss
101
+ if len(frame_buffer) >= 30:
102
+ # Process the 30-frame window for gloss prediction
103
+ window_result = self.recognizer.predict(frame_buffer[-1]) # Use last frame's result
104
+
105
+ probs = window_result['probabilities']
106
+ top_3 = []
107
+ if probs is not None:
108
+ top_indices = probs.argsort()[-3:][::-1]
109
+ top_3 = [
110
+ {"name": GESTURE_NAMES.get(i, f"G{i}"), "prob": float(probs[i])}
111
+ for i in top_indices
112
+ ]
113
+
114
+ gesture_name = window_result['gesture_name']
115
+ confidence = float(window_result['confidence'])
116
+
117
+ if gesture_name is None or confidence < 0.6:
118
+ gesture_name = "Unknown"
119
+
120
+ # For webcam: update immediately
121
+ # For video: accumulate glosses and show highest confidence
122
+ if self.source == 0:
123
+ # Webcam: real-time updates
124
+ latest_prediction = {
125
+ "gesture": gesture_name,
126
+ "confidence": confidence,
127
+ "top_3": top_3
128
+ }
129
+ else:
130
+ # Video: accumulate gloss predictions
131
+ if gesture_name != "Unknown":
132
+ gloss_predictions.append({
133
+ "gesture": gesture_name,
134
+ "confidence": confidence,
135
+ "top_3": top_3
136
+ })
137
+
138
+ # Update display with highest confidence gloss so far
139
+ best_gloss = max(gloss_predictions, key=lambda x: x['confidence'])
140
+ latest_prediction = best_gloss
141
+
142
+ # Slide the window (remove oldest 10 frames, keep overlap)
143
+ frame_buffer = frame_buffer[10:]
144
+
145
  except Exception as e:
146
  print(f"Error processing frame: {e}")
147
 
148
  else:
149
  # Video finished
150
  if isinstance(self.source, str):
151
+ print(f"Finished processing video: {self.source}")
152
+
153
+ # Final gloss prediction: highest confidence from all windows
154
+ if gloss_predictions:
155
+ best_prediction = max(gloss_predictions, key=lambda x: x['confidence'])
156
+ latest_prediction = best_prediction
157
+ print(f"FINAL GLOSS: {best_prediction['gesture']} (confidence: {best_prediction['confidence']:.2%})")
158
+ print(f"Total gloss predictions: {len(gloss_predictions)}")
159
+ else:
160
+ print("No valid gloss predictions found")
161
+
162
+ # Clear buffers for next video
163
+ frame_buffer = []
164
+ gloss_predictions = []
165
 
166
  # Check queue
167
  if self.queue:
168
+ print(f"Starting next video. Remaining: {len(self.queue)}")
169
  self.video.release()
170
  self.source = self.queue.pop(0)
171
  self.video = cv2.VideoCapture(self.source)
 
172
  else:
173
+ print("All videos processed.")
174
+ self.running = False
175
  if self.video:
176
  self.video.release()
177
  self.video = None
static/style.css CHANGED
@@ -1,5 +1,5 @@
1
  :root {
2
- /* Elegant Modern Palette */
3
  --bg-core: #f8f9fc;
4
  --bg-panel: #ffffff;
5
  --nav-border: rgba(0, 0, 0, 0.06);
@@ -19,147 +19,184 @@
19
  --active: #667eea;
20
  --warning: #f59e0b;
21
 
22
- /* Enhanced Shadows */
23
- --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.04);
24
- --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06);
25
- --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.08);
26
- --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.12);
27
-
28
- --font-main: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
29
- --radius: 16px;
30
  --radius-lg: 24px;
 
 
 
 
 
31
  }
32
 
33
  * {
34
  margin: 0;
35
  padding: 0;
36
  box-sizing: border-box;
 
37
  }
38
 
39
  body {
40
- background-color: var(--bg-core);
41
- font-family: var(--font-main);
42
- color: var(--accent-primary);
43
- color: var(--accent-primary);
44
  height: 100vh;
45
- /* Fixed height to keep everything "in frame" */
46
- width: 100vw;
47
- display: flex;
48
- justify-content: center;
49
- align-items: center;
50
  overflow: hidden;
51
- /* Prevent body scroll */
52
-
53
- /* Professional Animated Gradient */
54
- background: linear-gradient(-45deg, #f5f7fa, #e4e9f2, #d6dce8, #f0f4f8);
55
- background-size: 400% 400%;
56
- animation: gradientBG 25s ease infinite;
 
 
57
  }
58
 
59
- @keyframes gradientBG {
60
  0% {
61
- background-position: 0% 50%;
62
- }
63
-
64
- 50% {
65
- background-position: 100% 50%;
66
  }
67
 
68
  100% {
69
- background-position: 0% 50%;
70
  }
71
  }
72
 
73
- /* New Header Styles */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  .main-header {
75
- background: transparent;
76
- padding: 1.5rem 2rem;
77
  display: flex;
78
- justify-content: flex-start;
79
  align-items: center;
80
- border-bottom: 1px solid var(--nav-border);
 
 
 
 
 
 
 
 
81
  position: relative;
82
  z-index: 20;
83
  }
84
 
85
- .header-content h1 {
86
- font-size: 1.5rem;
87
- font-weight: 700;
88
- letter-spacing: -0.02em;
89
- margin-bottom: 0.25rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  }
91
 
92
- .header-content p {
93
- font-size: 0.85rem;
94
- color: var(--accent-secondary);
95
- text-transform: uppercase;
96
- letter-spacing: 0.1em;
97
  }
98
 
99
  .uni-logo {
100
- height: 60px;
101
- width: auto;
102
  }
103
 
104
  /* Layout Updates for 3-Column Dashboard */
105
  .container {
106
  width: 100%;
107
- height: 100%;
 
 
 
 
 
 
 
 
 
108
  display: grid;
109
- grid-template-columns: 280px 1fr 300px;
110
- /* Left Menu | Main | Right Output */
111
- grid-template-rows: 100%;
112
- background: transparent;
113
- gap: 1rem;
114
- padding: 1rem;
115
  }
116
 
117
- /* Enhanced Sidebar Glassmorphism */
118
  .sidebar {
119
- background: rgba(255, 255, 255, 0.75);
120
- backdrop-filter: blur(40px) saturate(180%);
121
- -webkit-backdrop-filter: blur(40px) saturate(180%);
122
- border: 1px solid rgba(255, 255, 255, 0.6);
123
- border-image: linear-gradient(135deg, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.2)) 1;
124
- padding: 2rem;
125
  display: flex;
126
  flex-direction: column;
127
- gap: 1.75rem;
128
- overflow-y: auto;
129
- overflow-x: hidden;
 
130
  z-index: 10;
 
 
 
131
  border-radius: var(--radius-lg);
132
- box-shadow: var(--shadow-lg),
133
- inset 0 1px 0 rgba(255, 255, 255, 0.8);
134
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
135
  }
136
 
137
  .sidebar:hover {
138
- box-shadow: var(--shadow-xl),
139
- inset 0 1px 0 rgba(255, 255, 255, 0.9);
140
- transform: translateY(-2px);
141
- }
142
-
143
- .left-sidebar {
144
- margin: 1rem 0 1rem 1rem;
145
- /* Float Left */
146
- }
147
-
148
- .right-sidebar {
149
- margin: 1rem 1rem 1rem 0;
150
- /* Float Right */
151
  }
152
 
153
  .logo {
154
- font-size: 1rem;
155
- font-weight: 700;
156
- letter-spacing: 0.05em;
157
- color: var(--accent-primary);
158
  display: flex;
159
  align-items: center;
160
- gap: 0.5rem;
161
- padding-bottom: 1rem;
162
- border-bottom: 1px solid var(--nav-border);
 
 
 
 
 
 
 
 
 
 
 
 
163
  }
164
 
165
  .logo i {
@@ -170,46 +207,56 @@ body {
170
  .main-view {
171
  position: relative;
172
  background: transparent !important;
173
- /* Override */
174
  border: none !important;
175
  box-shadow: none !important;
176
- padding: 1rem !important;
177
- /* Add padding for breathing room */
 
 
 
 
178
  }
179
 
180
  .video-feed {
181
- border-radius: 16px;
182
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
183
  max-width: 100%;
184
  max-height: 100%;
185
  width: auto;
186
  height: auto;
187
  object-fit: contain;
188
- /* Ensure Aspect Ratio is kept */
 
 
 
 
 
189
  }
190
 
191
- /* Premium HUD Pill */
192
  .hud-pill {
193
- position: absolute;
194
- top: 2rem;
195
- left: 50%;
196
- transform: translateX(-50%);
197
- background: rgba(255, 255, 255, 0.9);
198
- backdrop-filter: blur(30px) saturate(180%);
199
- -webkit-backdrop-filter: blur(30px) saturate(180%);
200
- padding: 1rem 2.5rem;
201
  border-radius: 100px;
202
- box-shadow: var(--shadow-lg),
203
- 0 0 0 1px rgba(255, 255, 255, 0.8) inset;
204
  display: flex;
205
  flex-direction: column;
206
  align-items: center;
207
- justify-content: center;
 
208
  z-index: 100;
209
- border: 1px solid rgba(255, 255, 255, 0.7);
210
- min-width: 240px;
211
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
212
- animation: breathe 4s ease-in-out infinite;
 
 
 
 
 
213
  }
214
 
215
  @keyframes breathe {
@@ -224,12 +271,6 @@ body {
224
  }
225
  }
226
 
227
- .hud-pill.pulse {
228
- transform: translateX(-50%) scale(1.08);
229
- box-shadow: var(--shadow-xl),
230
- 0 0 20px rgba(102, 126, 234, 0.3),
231
- 0 0 0 1px rgba(255, 255, 255, 0.9) inset;
232
- }
233
 
234
  .hud-label {
235
  font-size: 0.65rem;
@@ -251,6 +292,14 @@ body {
251
  filter: drop-shadow(0 2px 4px rgba(102, 126, 234, 0.2));
252
  }
253
 
 
 
 
 
 
 
 
 
254
  @media (max-width: 900px) {
255
  .container {
256
  grid-template-columns: 1fr;
@@ -305,17 +354,53 @@ body {
305
  }
306
 
307
  .history-item {
308
- font-size: 0.8rem;
309
  display: flex;
310
  justify-content: space-between;
311
- padding: 0.75rem 1rem;
312
- background: #ffffff;
313
- border: 1px solid var(--nav-border);
314
- border-radius: 10px;
315
- color: var(--accent-secondary);
316
- box-shadow: var(--shadow-sm);
317
- transition: all 0.2s ease;
318
- animation: slideIn 0.3s ease-out;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  }
320
 
321
  @keyframes slideIn {
@@ -359,25 +444,38 @@ body {
359
  }
360
 
361
  .btn-elegant {
362
- background: #ffffff;
363
- border: 1.5px solid var(--nav-border);
364
- color: var(--accent-primary);
365
- padding: 1rem 1.25rem;
366
- font-family: inherit;
367
- font-size: 0.875rem;
368
- font-weight: 600;
369
- border-radius: var(--radius);
 
370
  cursor: pointer;
371
  display: flex;
372
  align-items: center;
373
- justify-content: flex-start;
374
- gap: 0.875rem;
375
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
376
- box-shadow: var(--shadow-sm);
377
  position: relative;
378
  overflow: hidden;
379
  }
380
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  .btn-elegant::before {
382
  content: '';
383
  position: absolute;
@@ -412,27 +510,169 @@ body {
412
  transition: transform 0.3s ease;
413
  }
414
 
415
- .btn-elegant:hover i {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  transform: scale(1.1);
417
  }
418
 
419
- /* CSS Cleanup: Removed duplicate main-view and video-feed definitions */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
 
421
  .overlay-status {
422
  position: absolute;
423
  top: 2rem;
424
  right: 2rem;
425
- background: rgba(0, 0, 0, 0.7);
426
- padding: 0.5rem 1rem;
 
427
  border-radius: 100px;
428
  display: flex;
429
  align-items: center;
430
- gap: 0.5rem;
 
 
431
  }
432
 
433
  .status-dot {
434
- width: 8px;
435
- height: 8px;
436
  background: var(--active);
437
  border-radius: 50%;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  }
 
1
  :root {
2
+ /* Elegant Modern Palette (Lumina Glass) */
3
  --bg-core: #f8f9fc;
4
  --bg-panel: #ffffff;
5
  --nav-border: rgba(0, 0, 0, 0.06);
 
19
  --active: #667eea;
20
  --warning: #f59e0b;
21
 
22
+ --error: #ef4444;
23
+ --bg-main: #f8fafc;
24
+ --nav-border: rgba(226, 232, 240, 0.5);
25
+ --text-primary: #0f172a;
26
+ --text-main: #334155;
 
 
 
27
  --radius-lg: 24px;
28
+ --radius-md: 16px;
29
+ --shadow-sm: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
30
+ --shadow-md: 0 10px 15px -3px rgba(0, 0, 0, 0.08);
31
+ --shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
32
+ --shadow-xl: 0 25px 50px -12px rgba(0, 0, 0, 0.15);
33
  }
34
 
35
  * {
36
  margin: 0;
37
  padding: 0;
38
  box-sizing: border-box;
39
+ font-family: 'Outfit', 'Inter', sans-serif;
40
  }
41
 
42
  body {
43
+ background: #fdfeff;
44
+ color: var(--text-main);
 
 
45
  height: 100vh;
 
 
 
 
 
46
  overflow: hidden;
47
+ position: relative;
48
+ background-image:
49
+ radial-gradient(at 0% 0%, rgba(102, 126, 234, 0.08) 0, transparent 40%),
50
+ radial-gradient(at 100% 0%, rgba(118, 75, 162, 0.08) 0, transparent 40%),
51
+ radial-gradient(at 50% 100%, rgba(79, 172, 254, 0.05) 0, transparent 40%),
52
+ radial-gradient(at 0% 100%, rgba(240, 147, 251, 0.05) 0, transparent 40%);
53
+ background-size: 200% 200%;
54
+ animation: aurora-shift 20s ease infinite alternate;
55
  }
56
 
57
+ @keyframes aurora-shift {
58
  0% {
59
+ background-position: 0% 0%;
 
 
 
 
60
  }
61
 
62
  100% {
63
+ background-position: 100% 100%;
64
  }
65
  }
66
 
67
+ .dashboard {
68
+ display: flex;
69
+ height: 100vh;
70
+ padding: 1.5rem;
71
+ gap: 1.5rem;
72
+ background: radial-gradient(circle at center, transparent 0%, rgba(255, 255, 255, 0.2) 100%);
73
+ }
74
+
75
+ /* Glass Panels with improved depth */
76
+ /* Hyper-Premium Glass Morphism */
77
+ .glass-panel {
78
+ background: rgba(255, 255, 255, 0.45);
79
+ backdrop-filter: blur(40px) saturate(200%);
80
+ -webkit-backdrop-filter: blur(40px) saturate(200%);
81
+ border-radius: var(--radius-lg);
82
+ border: 1px solid rgba(255, 255, 255, 0.6);
83
+ box-shadow:
84
+ 0 8px 32px 0 rgba(31, 38, 135, 0.04),
85
+ inset 0 0 0 1px rgba(255, 255, 255, 0.4);
86
+ transition: all 0.5s cubic-bezier(0.19, 1, 0.22, 1);
87
+ }
88
+
89
  .main-header {
 
 
90
  display: flex;
 
91
  align-items: center;
92
+ padding: 1rem 3.5rem;
93
+ background: rgba(255, 255, 255, 0.55);
94
+ backdrop-filter: blur(30px) saturate(180%);
95
+ border: 1px solid rgba(255, 255, 255, 0.8);
96
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.04), inset 0 1px 2px rgba(255, 255, 255, 0.5);
97
+ border-radius: 1000px;
98
+ margin: 0 auto 0.25rem;
99
+ width: fit-content;
100
+ min-width: 94%;
101
  position: relative;
102
  z-index: 20;
103
  }
104
 
105
+ /* Animation Removed for Stability */
106
+
107
+ .logo-circle-container {
108
+ width: 70px;
109
+ height: 70px;
110
+ background: white;
111
+ border-radius: 50%;
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ margin-right: 1.5rem;
116
+ box-shadow:
117
+ var(--shadow-md),
118
+ 0 0 0 3px rgba(255, 255, 255, 0.4);
119
+ border: 1px solid rgba(255, 255, 255, 0.8);
120
+ overflow: hidden;
121
+ flex-shrink: 0;
122
+ transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
123
  }
124
 
125
+ .logo-circle-container:hover {
126
+ transform: scale(1.1) rotate(-5deg);
 
 
 
127
  }
128
 
129
  .uni-logo {
130
+ width: 50px;
131
+ height: auto;
132
  }
133
 
134
  /* Layout Updates for 3-Column Dashboard */
135
  .container {
136
  width: 100%;
137
+ height: 100vh;
138
+ display: flex;
139
+ flex-direction: column;
140
+ padding: 0.75rem 2rem 2rem;
141
+ box-sizing: border-box;
142
+ overflow: hidden;
143
+ background: radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.1) 0%, transparent 80%);
144
+ }
145
+
146
+ .content-row {
147
  display: grid;
148
+ grid-template-columns: 300px 1fr 320px;
149
+ gap: 2.5rem;
150
+ flex: 1;
151
+ min-height: 0;
152
+ align-items: stretch;
153
+ /* Make both sidebars equal height */
154
  }
155
 
156
+ /* Sidebars - Integrated & Floating */
157
  .sidebar {
158
+ background: rgba(255, 255, 255, 0.45);
159
+ backdrop-filter: blur(50px) saturate(220%);
160
+ -webkit-backdrop-filter: blur(50px) saturate(220%);
161
+ border: 1px solid rgba(255, 255, 255, 0.8);
162
+ padding: 2.25rem;
 
163
  display: flex;
164
  flex-direction: column;
165
+ gap: 2rem;
166
+ width: 100%;
167
+ height: 100%;
168
+ max-height: 90vh;
169
  z-index: 10;
170
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.04), inset 0 0 0 1px rgba(255, 255, 255, 0.4);
171
+ transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
172
+ box-sizing: border-box;
173
  border-radius: var(--radius-lg);
 
 
 
174
  }
175
 
176
  .sidebar:hover {
177
+ transform: translateY(-5px);
178
+ box-shadow: 0 25px 50px rgba(0, 0, 0, 0.08);
179
+ border-color: rgba(255, 255, 255, 0.8);
 
 
 
 
 
 
 
 
 
 
180
  }
181
 
182
  .logo {
 
 
 
 
183
  display: flex;
184
  align-items: center;
185
+ gap: 0.75rem;
186
+ color: var(--accent-primary);
187
+ padding: 1.5rem 2.25rem;
188
+ margin: -2.25rem -2.25rem 1rem;
189
+ background: rgba(255, 255, 255, 0.5);
190
+ border-bottom: 1px solid rgba(0, 0, 0, 0.05);
191
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
192
+ }
193
+
194
+ .logo span {
195
+ font-size: 0.75rem;
196
+ font-weight: 800;
197
+ text-transform: uppercase;
198
+ letter-spacing: 0.2em;
199
+ color: var(--accent-secondary);
200
  }
201
 
202
  .logo i {
 
207
  .main-view {
208
  position: relative;
209
  background: transparent !important;
 
210
  border: none !important;
211
  box-shadow: none !important;
212
+ padding: 0 !important;
213
+ padding-top: 0;
214
+ display: flex;
215
+ flex-direction: column;
216
+ align-items: center;
217
+ justify-content: flex-start;
218
  }
219
 
220
  .video-feed {
221
+ border-radius: 20px;
222
+ box-shadow: 0 30px 100px rgba(0, 0, 0, 0.12);
223
  max-width: 100%;
224
  max-height: 100%;
225
  width: auto;
226
  height: auto;
227
  object-fit: contain;
228
+ border: 1px solid rgba(255, 255, 255, 0.5);
229
+ position: relative;
230
+ }
231
+
232
+ .video-feed.active {
233
+ box-shadow: 0 0 80px rgba(102, 126, 234, 0.15), 0 30px 100px rgba(0, 0, 0, 0.12);
234
  }
235
 
236
+ /* Futuristic HUD Pill */
237
  .hud-pill {
238
+ position: relative;
239
+ margin: 0 auto 0.5rem;
240
+ background: rgba(255, 255, 255, 0.4);
241
+ backdrop-filter: blur(25px);
242
+ padding: 0.75rem 3rem;
 
 
 
243
  border-radius: 100px;
244
+ border: 1px solid rgba(255, 255, 255, 0.6);
 
245
  display: flex;
246
  flex-direction: column;
247
  align-items: center;
248
+ gap: 2px;
249
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.03), inset 0 1px 1px rgba(255, 255, 255, 0.8);
250
  z-index: 100;
251
+ transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
252
+ width: fit-content;
253
+ }
254
+
255
+ .hud-pill.pulse {
256
+ transform: scale(1.05);
257
+ background: rgba(255, 255, 255, 0.6);
258
+ border-color: var(--active);
259
+ box-shadow: 0 0 50px rgba(102, 126, 234, 0.15), 0 10px 30px rgba(0, 0, 0, 0.03);
260
  }
261
 
262
  @keyframes breathe {
 
271
  }
272
  }
273
 
 
 
 
 
 
 
274
 
275
  .hud-label {
276
  font-size: 0.65rem;
 
292
  filter: drop-shadow(0 2px 4px rgba(102, 126, 234, 0.2));
293
  }
294
 
295
+ .hud-translation {
296
+ font-size: 0.95rem;
297
+ font-weight: 600;
298
+ color: var(--accent-secondary);
299
+ margin-top: 0.25rem;
300
+ letter-spacing: 0.02em;
301
+ }
302
+
303
  @media (max-width: 900px) {
304
  .container {
305
  grid-template-columns: 1fr;
 
354
  }
355
 
356
  .history-item {
357
+ font-size: 0.85rem;
358
  display: flex;
359
  justify-content: space-between;
360
+ padding: 0.85rem 1.25rem;
361
+ background: rgba(255, 255, 255, 0.8);
362
+ backdrop-filter: blur(10px);
363
+ border: 1px solid rgba(0, 51, 153, 0.05);
364
+ border-radius: 14px;
365
+ color: var(--text-heritage-secondary);
366
+ box-shadow: 0 4px 15px rgba(0, 51, 153, 0.02);
367
+ transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
368
+ animation: historyEntry 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
369
+ }
370
+
371
+ @keyframes historyEntry {
372
+ 0% {
373
+ opacity: 0;
374
+ transform: translateY(10px) scale(0.98);
375
+ filter: blur(5px);
376
+ }
377
+
378
+ 100% {
379
+ opacity: 1;
380
+ transform: translateY(0) scale(1);
381
+ filter: blur(0);
382
+ }
383
+ }
384
+
385
+ .video-container {
386
+ padding: 1rem;
387
+ background: rgba(255, 255, 255, 0.5);
388
+ border-radius: var(--radius-zenith);
389
+ border: 1px solid rgba(255, 255, 255, 1);
390
+ box-shadow: 0 20px 50px rgba(0, 51, 153, 0.05);
391
+ position: relative;
392
+ overflow: hidden;
393
+ }
394
+
395
+ .video-container::after {
396
+ content: '';
397
+ position: absolute;
398
+ top: -50%;
399
+ left: -50%;
400
+ width: 200%;
401
+ height: 200%;
402
+ background: radial-gradient(circle, rgba(74, 144, 226, 0.05) 0%, transparent 70%);
403
+ pointer-events: none;
404
  }
405
 
406
  @keyframes slideIn {
 
444
  }
445
 
446
  .btn-elegant {
447
+ width: 100%;
448
+ padding: 1.15rem 1.75rem;
449
+ border-radius: var(--radius-md);
450
+ border: 1px solid rgba(0, 51, 153, 0.1);
451
+ background: rgba(255, 255, 255, 0.9);
452
+ backdrop-filter: blur(10px);
453
+ color: var(--um-blue);
454
+ font-weight: 800;
455
+ font-size: 0.9rem;
456
  cursor: pointer;
457
  display: flex;
458
  align-items: center;
459
+ justify-content: center;
460
+ gap: 0.85rem;
461
+ transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
462
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.02);
463
  position: relative;
464
  overflow: hidden;
465
  }
466
 
467
+ .btn-elegant:hover {
468
+ background: rgba(255, 255, 255, 0.9);
469
+ transform: translateY(-3px) scale(1.02);
470
+ box-shadow: 0 15px 30px rgba(0, 0, 0, 0.05);
471
+ border-color: rgba(255, 255, 255, 1);
472
+ }
473
+
474
+ .btn-elegant i {
475
+ font-size: 1.1rem;
476
+ color: var(--active);
477
+ }
478
+
479
  .btn-elegant::before {
480
  content: '';
481
  position: absolute;
 
510
  transition: transform 0.3s ease;
511
  }
512
 
513
+
514
+
515
+ /* Radial Gauge */
516
+ .radial-gauge {
517
+ position: relative;
518
+ width: 120px;
519
+ height: 120px;
520
+ margin: 0 auto;
521
+ }
522
+
523
+ .radial-gauge svg {
524
+ width: 100%;
525
+ height: 100%;
526
+ transform: rotate(-90deg);
527
+ }
528
+
529
+ .gauge-bg {
530
+ fill: none;
531
+ stroke: var(--nav-border);
532
+ stroke-width: 8;
533
+ }
534
+
535
+ .gauge-progress {
536
+ fill: none;
537
+ stroke: var(--active);
538
+ stroke-width: 8;
539
+ stroke-linecap: round;
540
+ stroke-dasharray: 283;
541
+ stroke-dashoffset: 283;
542
+ transition: stroke-dashoffset 0.5s ease;
543
+ }
544
+
545
+ .gauge-value {
546
+ position: absolute;
547
+ top: 50%;
548
+ left: 50%;
549
+ transform: translate(-50%, -50%);
550
+ font-size: 1.25rem;
551
+ font-weight: 700;
552
+ color: var(--accent-primary);
553
+ }
554
+
555
+ /* Log Controls */
556
+ .log-control-btn {
557
+ background: none;
558
+ border: none;
559
+ color: var(--accent-secondary);
560
+ cursor: pointer;
561
+ font-size: 0.9rem;
562
+ padding: 4px;
563
+ transition: all 0.2s ease;
564
+ }
565
+
566
+ .log-control-btn:hover {
567
+ color: var(--active);
568
  transform: scale(1.1);
569
  }
570
 
571
+ /* Modal */
572
+ .modal {
573
+ display: none;
574
+ position: fixed;
575
+ top: 0;
576
+ left: 0;
577
+ width: 100%;
578
+ height: 100%;
579
+ background: rgba(0, 0, 0, 0.4);
580
+ backdrop-filter: blur(8px);
581
+ z-index: 1000;
582
+ justify-content: center;
583
+ align-items: center;
584
+ }
585
+
586
+ /* Cloud-Morph Modal */
587
+ .modal-content {
588
+ background: rgba(255, 255, 255, 0.6);
589
+ backdrop-filter: blur(60px) saturate(210%);
590
+ -webkit-backdrop-filter: blur(60px) saturate(210%);
591
+ border-radius: 40px;
592
+ width: 95%;
593
+ max-width: 650px;
594
+ padding: 4rem;
595
+ position: relative;
596
+ box-shadow: 0 50px 100px rgba(0, 0, 0, 0.1), inset 0 0 0 1px rgba(255, 255, 255, 0.6);
597
+ border: 1px solid rgba(255, 255, 255, 0.4);
598
+ max-height: 85vh;
599
+ overflow-y: auto;
600
+ animation: modalPop 0.6s cubic-bezier(0.16, 1, 0.3, 1);
601
+ }
602
+
603
+ @keyframes modalPop {
604
+ 0% {
605
+ opacity: 0;
606
+ transform: scale(0.9) translateY(20px);
607
+ filter: blur(10px);
608
+ }
609
+
610
+ 100% {
611
+ opacity: 1;
612
+ transform: scale(1) translateY(0);
613
+ filter: blur(0);
614
+ }
615
+ }
616
+
617
+ .modal-title {
618
+ font-size: 2.25rem;
619
+ font-weight: 800;
620
+ margin-bottom: 2rem;
621
+ color: var(--accent-primary);
622
+ letter-spacing: -0.02em;
623
+ }
624
 
625
  .overlay-status {
626
  position: absolute;
627
  top: 2rem;
628
  right: 2rem;
629
+ background: rgba(15, 23, 42, 0.1);
630
+ backdrop-filter: blur(10px);
631
+ padding: 0.6rem 1.25rem;
632
  border-radius: 100px;
633
  display: flex;
634
  align-items: center;
635
+ gap: 0.75rem;
636
+ border: 1px solid rgba(255, 255, 255, 0.3);
637
+ z-index: 10;
638
  }
639
 
640
  .status-dot {
641
+ width: 10px;
642
+ height: 10px;
643
  background: var(--active);
644
  border-radius: 50%;
645
+ box-shadow: 0 0 15px var(--active);
646
+ animation: heartBeat 2s infinite ease-in-out;
647
+ }
648
+
649
+ @keyframes heartBeat {
650
+
651
+ 0%,
652
+ 100% {
653
+ transform: scale(1);
654
+ opacity: 1;
655
+ }
656
+
657
+ 50% {
658
+ transform: scale(1.3);
659
+ opacity: 0.6;
660
+ box-shadow: 0 0 25px var(--active);
661
+ }
662
+ }
663
+
664
+ .metric-group {
665
+ display: flex;
666
+ flex-direction: column;
667
+ gap: 1.25rem;
668
+ }
669
+
670
+ .label {
671
+ font-size: 0.7rem;
672
+ font-weight: 800;
673
+ text-transform: uppercase;
674
+ letter-spacing: 0.2em;
675
+ color: var(--accent-secondary);
676
+ display: block;
677
+ margin-bottom: 0.25rem;
678
  }
templates/index.html CHANGED
@@ -4,319 +4,501 @@
4
  <head>
5
  <meta charset="UTF-8">
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <title>CVSLR | Elegant AI</title>
8
- <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v={{ range(1, 10000) | random }}">
9
- <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@200;300;400;600&display=swap" rel="stylesheet">
10
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
 
 
 
 
11
  </head>
12
 
13
  <body>
14
  <div class="container">
15
- <!-- LEFT SIDEBAR: CONTROLS & INPUT -->
16
- <aside class="sidebar left-sidebar glass-panel">
17
- <div class="logo">
18
- <div style="display: flex; flex-direction: column;">
19
- <span style="font-size: 1.3rem; font-weight: 800; letter-spacing: 0.1em;">CVSLR</span>
20
- <span style="font-size: 0.7rem; font-weight: 500; opacity: 0.8; margin-top: 2px;">Computer Vision &
21
- Image Processing</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  </div>
23
- <i class="fa-solid fa-gamepad" style="margin-left: auto;"></i>
24
  </div>
25
-
26
- <div class="control-area" style="border-top: none; margin-top: 0; padding-top: 0;">
27
- <!-- Live Stream -->
28
- <div class="input-group">
29
- <label class="input-group-label">LIVE CAMERA</label>
30
- <button id="cam-toggle" class="btn-elegant" onmousedown="toggleCamera()"
31
- ontouchstart="toggleCamera()">
32
- <i class="fa-solid fa-video"></i> Start Live Stream
33
- </button>
34
- <div id="status-text" class="status-text">Ready</div>
 
 
35
  </div>
36
 
37
- <!-- Single Video Upload -->
38
- <div class="input-group">
39
- <label class="input-group-label">UPLOAD VIDEO</label>
40
- <button class="btn-elegant" onclick="document.getElementById('single-upload').click()">
41
- <i class="fa-solid fa-upload"></i> Upload File
42
- </button>
43
- <input type="file" id="single-upload" accept="video/*" style="display: none;"
44
- onchange="handleSingleUpload(this)">
45
- </div>
 
 
 
 
 
46
 
47
- <!-- Batch Upload -->
48
- <div class="input-group">
49
- <label class="input-group-label">BATCH PLAYLIST</label>
50
- <button class="btn-elegant" onclick="document.getElementById('batch-upload').click()">
51
- <i class="fa-solid fa-layer-group"></i> Upload Payload
52
- </button>
53
- <input type="file" id="batch-upload" accept="video/*" multiple style="display: none;"
54
- onchange="handleBatchUpload(this)">
55
- </div>
56
 
57
- <div id="upload-status"></div>
58
- </div>
59
- </aside>
 
 
 
 
 
 
60
 
61
- <!-- CENTER: VIDEO & HEADER -->
62
- <main class="main-view glass-panel" style="display:flex; flex-direction:column; padding:0; gap:0;">
63
- <header class="main-header">
64
- <img src="{{ url_for('static', filename='um_logo.png') }}" alt="University Malaya Logo" class="uni-logo"
65
- style="margin-right: 1.5rem;">
66
- <div class="header-content">
67
- <h1 style="font-size: 1.2rem; font-weight: 700; margin-bottom: 0.25rem;">Faculty of Computer Science
68
- and Information Technology</h1>
69
- <p style="font-size: 0.85rem; color: var(--accent-secondary); font-weight: 500;">Department of
70
- Artificial Intelligence</p>
71
- </div>
72
- </header>
73
-
74
- <div
75
- style="flex:1; position:relative; width:100%; display:flex; flex-direction:column; justify-content:center; align-items:center;">
76
- <!-- New Creative HUD Output -->
77
- <div class="hud-pill" id="hud-pill">
78
- <span class="hud-label">DETECTED GESTURE</span>
79
- <span class="hud-value" id="hud-gesture">Waiting...</span>
80
- </div>
81
 
82
- <div style="position:relative;">
83
- <img src="{{ url_for('video_feed') }}" alt="Live Feed" class="video-feed" id="video-feed">
84
- <div class="overlay-status">
85
- <span class="label">LIVE</span>
86
- <div class="status-dot"></div>
 
 
87
  </div>
88
  </div>
 
89
 
 
 
 
90
  <div
91
- style="margin-top: 1rem; font-size: 0.8rem; color: var(--accent-secondary); font-weight: 500; letter-spacing: 0.05em;">
92
- Developed by Group 4 (Vision)
93
- </div>
94
- </div>
95
- </main>
 
96
 
97
- <!-- RIGHT SIDEBAR: OUTPUT & LOGS -->
98
- <aside class="sidebar right-sidebar glass-panel">
99
- <div class="logo">
100
- <div style="display:flex; flex-direction:column;">
101
- <span style="font-size: 0.9rem; font-weight: 800; letter-spacing: 0.1em;">SESSION LOG</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  </div>
103
- <i class="fa-solid fa-list-ul" style="margin-left: auto;"></i>
104
- </div>
105
 
106
- <div class="metric-group">
107
- <span class="label">Confidence</span>
108
- <div class="value-display" id="confidence-val" style="font-size: 1.5rem; color: var(--text-main);">0%
 
 
 
 
 
 
109
  </div>
110
- </div>
111
 
112
- <!-- Top Candidates -->
113
- <div class="metric-group" style="flex-grow: 0;">
114
- <span class="label">Top Candidates</span>
115
- <div class="top-predictions" id="top-predictions">
116
- <!-- Populated by JS -->
 
117
  </div>
118
- </div>
119
 
120
- <div class="metric-group" style="flex-grow: 1; display:flex; flex-direction:column; min-height: 0;">
121
- <span class="label">Session Log</span>
122
- <div class="history-log" id="history-log">
123
- <!-- Items added here -->
 
124
  </div>
125
- </div>
126
- </aside>
127
- </div>
128
-
129
- <script>
130
- let isCameraRunning = true;
131
- let lastLoggedGesture = null;
132
-
133
- function toggleCamera() {
134
- const btn = document.getElementById('cam-toggle');
135
- const action = isCameraRunning ? 'stop' : 'start';
136
-
137
- fetch('/api/camera/control', {
138
- method: 'POST',
139
- headers: { 'Content-Type': 'application/json' },
140
- body: JSON.stringify({ action: action, source: 'webcam' })
141
- })
142
- .then(res => res.json())
143
- .then(data => {
144
- const vid = document.getElementById('video-feed');
145
- if (data.status === 'stopped') {
146
- isCameraRunning = false;
147
- vid.classList.remove('active');
148
- btn.innerHTML = '<i class="fa-solid fa-power-off"></i> Resume Stream';
149
- document.querySelector('.status-dot').style.background = '#333';
150
- } else {
151
- isCameraRunning = true;
152
- vid.classList.add('active');
153
- btn.innerHTML = '<i class="fa-solid fa-power-off"></i> Terminate Stream';
154
- document.querySelector('.status-dot').style.background = 'var(--accent-gold)';
155
- }
156
  })
157
- .catch(err => console.error(err));
158
- }
159
-
160
- function handleSingleUpload(input) {
161
- console.log("Single upload triggered with file:", input.files[0]);
162
- handleUpload(input, false);
163
- input.value = ''; // Reset to allow re-selecting same file
164
- }
165
-
166
- function handleBatchUpload(input) {
167
- console.log("Batch upload triggered with files:", input.files.length);
168
- handleUpload(input, true);
169
- input.value = ''; // Reset
170
- }
171
-
172
- function handleUpload(input, isBatch) {
173
- const files = input.files;
174
- if (!files.length) {
175
- alert("No file selected!");
176
- return;
177
  }
178
 
179
- const statusEl = document.getElementById('upload-status');
180
- statusEl.textContent = isBatch ? `Uploading ${files.length} files...` : 'Uploading video...';
181
- statusEl.style.opacity = '1';
 
 
 
 
 
 
 
 
 
 
182
 
183
- const formData = new FormData();
184
- if (isBatch) {
185
- for (let i = 0; i < files.length; i++) {
186
- formData.append('files[]', files[i]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  }
188
- } else {
189
- formData.append('file', files[0]);
190
  }
191
 
192
- fetch('/api/upload', {
193
- method: 'POST',
194
- body: formData
195
- })
196
- .then(res => {
197
- if (!res.ok) throw new Error(`Server Error: ${res.statusText}`);
198
- return res.json();
199
- })
200
- .then(data => {
201
- console.log("Upload response:", data);
202
- if (data.error) throw new Error(data.error);
203
 
204
- statusEl.textContent = 'Initializing Queuer...';
 
 
 
 
205
 
206
- // Construct Payload
207
- let payload = { action: 'start' };
 
 
 
 
208
 
209
- if (isBatch || (data.filenames && data.filenames.length > 0)) {
210
- payload.source = 'playlist';
211
- payload.filenames = data.filenames || [data.filename];
212
- } else {
213
- payload.source = 'video';
214
- payload.filename = data.filename;
 
 
215
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
 
217
- console.log("Sending Control Payload:", payload);
218
 
219
- return fetch('/api/camera/control', {
220
- method: 'POST',
221
- headers: { 'Content-Type': 'application/json' },
222
- body: JSON.stringify(payload)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  });
224
- })
225
- .then(res => res.json())
226
- .then(data => {
227
- console.log("Control response:", data);
228
- if (data.status === 'started') {
229
- isCameraRunning = true;
230
- const btn = document.getElementById('cam-toggle');
231
- btn.innerHTML = '<i class="fa-solid fa-stop"></i> Stop Source';
232
- document.getElementById('video-feed').classList.add('active');
233
-
234
- const type = isBatch ? "Batch Playlist" : "Single Video";
235
- addToLog(`New Source: ${type}`, "INFO");
236
- statusEl.textContent = 'Processing Started';
237
- } else {
238
- alert("Failed to start video: " + (data.error || "Unknown error"));
239
- }
240
- setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
241
- })
242
- .catch(err => {
243
- console.error("Upload/Control Error:", err);
244
- statusEl.textContent = 'Error!';
245
- alert("Upload Failed: " + err.message);
246
- });
247
- }
248
 
249
- function addToLog(gesture, confidence) {
250
- const log = document.getElementById('history-log');
251
- const item = document.createElement('div');
252
- item.className = 'history-item';
253
 
254
- const time = new Date().toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
255
 
256
- item.innerHTML = `
257
- <span>${gesture}</span>
258
- <span>${confidence}</span>
 
 
 
259
  `;
260
 
261
- log.prepend(item);
262
- }
263
-
264
- function updateStatus() {
265
- fetch('/api/status')
266
- .then(response => response.json())
267
- .then(data => {
268
- // Update Gesture Name (HUD)
269
- const gestureEl = document.getElementById('hud-gesture');
270
- const confEl = document.getElementById('confidence-val');
271
-
272
- if (data.gesture) {
273
- const gestureName = data.gesture.toUpperCase().replace(/_/g, ' ');
274
- gestureEl.textContent = gestureName;
275
-
276
- // Add animation class briefly
277
- const hudPill = document.getElementById('hud-pill');
278
- hudPill.classList.add('pulse');
279
- setTimeout(() => hudPill.classList.remove('pulse'), 300);
280
-
281
- const confVal = (data.confidence * 100).toFixed(1) + '%';
282
- confEl.textContent = confVal;
283
-
284
- // Logic for logging: Log if it's a new gesture and high confidence
285
- if (gestureName !== lastLoggedGesture && data.confidence > 0.85) {
286
- lastLoggedGesture = gestureName;
287
- addToLog(gestureName, confVal);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  }
289
- }
290
 
291
- // Update Top Predictions (Restored Feature)
292
- const listEl = document.getElementById('top-predictions');
293
- listEl.innerHTML = '';
294
- if (data.top_3 && data.top_3.length > 0) {
295
- data.top_3.forEach(item => {
296
- const div = document.createElement('div');
297
- div.className = 'pred-item';
298
- const name = item.name.replace(/_/g, ' ');
299
- const prob = (item.prob * 100).toFixed(0);
300
-
301
- div.innerHTML = `
302
- <span>${name}</span>
303
- <div style="display:flex; align-items:center; gap:0.5rem;">
304
- <span>${prob}%</span>
305
- <div class="pred-bar-bg">
306
- <div class="pred-bar" style="width: ${prob}%"></div>
307
- </div>
308
- </div>
309
- `;
310
- listEl.appendChild(div);
311
- });
312
- }
313
- })
314
- .catch(err => console.error(err));
315
- }
 
 
316
 
317
- setInterval(updateStatus, 150);
318
- document.getElementById('video-feed').classList.add('active'); // Init active
319
- </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  </body>
321
 
322
  </html>
 
4
  <head>
5
  <meta charset="UTF-8">
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>WQF7006 | Elegant AI</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link
11
+ href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Inter:wght@400;600&display=swap"
12
+ rel="stylesheet">
13
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
14
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
15
  </head>
16
 
17
  <body>
18
  <div class="container">
19
+ <!-- TOP HEADER -->
20
+ <header class="main-header" style="justify-content: center; position: relative;">
21
+ <div class="logo-circle-container" style="position: absolute; left: 4rem; width: 85px; height: 85px;">
22
+ <img src="{{ url_for('static', filename='um_logo.png') }}" alt="University Malaya Logo" class="uni-logo"
23
+ style="width: 58px;">
24
+ </div>
25
+ <div class="header-content" style="text-align: center;">
26
+ <h1
27
+ style="font-size: 1.8rem; font-weight: 800; margin-bottom: 0.2rem; color: var(--accent-primary); letter-spacing: -0.01em; white-space: nowrap;">
28
+ AI-BASED MALAYSIAN SIGN LANGUAGE SYSTEM
29
+ </h1>
30
+ <div
31
+ style="display: flex; align-items: center; justify-content: center; gap: 0.75rem; font-size: 0.8rem; font-weight: 600; color: var(--accent-secondary); letter-spacing: 0.02em;">
32
+ <span>UNIVERSITI MALAYA</span>
33
+ <span style="opacity: 0.3;">|</span>
34
+ <span>FACULTY OF COMPUTER SCIENCE & IT</span>
35
+ <span style="opacity: 0.3;">|</span>
36
+ <span>DEPARTMENT OF AI</span>
37
+ <i class="fa-solid fa-circle-info info-icon-btn" onclick="openAboutModal()"
38
+ style="font-size: 0.9rem; margin-left: 0.5rem; color: var(--active);"></i>
39
  </div>
 
40
  </div>
41
+ </header>
42
+
43
+ <div class="content-row">
44
+ <!-- LEFT SIDEBAR: CONTROLS & INPUT -->
45
+ <aside class="sidebar left-sidebar glass-panel">
46
+ <div class="logo">
47
+ <div style="display: flex; flex-direction: column;">
48
+ <span style="font-size: 1.3rem; font-weight: 800; letter-spacing: 0.1em;">WQF7006</span>
49
+ <span style="font-size: 0.7rem; font-weight: 500; opacity: 0.8; margin-top: 2px;">Computer
50
+ Vision & Image Processing</span>
51
+ </div>
52
+ <i class="fa-solid fa-gamepad" style="margin-left: auto;"></i>
53
  </div>
54
 
55
+ <div class="control-area" style="border-top: none; margin-top: 0; padding-top: 0;">
56
+ <!-- Live Stream -->
57
+ <div class="input-group">
58
+ <label class="input-group-label" style="display: flex; align-items: center; gap: 0.5rem;">
59
+ <span class="status-dot"
60
+ style="width: 8px; height: 8px; background: var(--accent-gold); border-radius: 50%; box-shadow: 0 0 10px var(--accent-gold);"></span>
61
+ LIVE FROM CAMERA
62
+ </label>
63
+ <button id="cam-toggle" class="btn-elegant" onmousedown="toggleCamera()"
64
+ ontouchstart="toggleCamera()">
65
+ <i class="fa-solid fa-video"></i> Start Camera Feed
66
+ </button>
67
+ <div id="status-text" class="status-text">Ready</div>
68
+ </div>
69
 
70
+ <!-- Single Video Upload -->
71
+ <div class="input-group">
72
+ <label class="input-group-label">UPLOAD VIDEO</label>
73
+ <button class="btn-elegant" onclick="document.getElementById('single-upload').click()">
74
+ <i class="fa-solid fa-upload"></i> Upload File
75
+ </button>
76
+ <input type="file" id="single-upload" accept="video/*" style="display: none;"
77
+ onchange="handleSingleUpload(this)">
78
+ </div>
79
 
80
+ <!-- Batch Upload -->
81
+ <div class="input-group">
82
+ <label class="input-group-label">BATCH PLAYLIST</label>
83
+ <button class="btn-elegant" onclick="document.getElementById('batch-upload').click()">
84
+ <i class="fa-solid fa-layer-group"></i> Upload Payload
85
+ </button>
86
+ <input type="file" id="batch-upload" accept="video/*" multiple style="display: none;"
87
+ onchange="handleBatchUpload(this)">
88
+ </div>
89
 
90
+ <div id="upload-status"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
+ <!-- System Utility -->
93
+ <div style="margin-top: 2rem; border-top: 1px solid rgba(0,0,0,0.05); padding-top: 1.5rem;">
94
+ <button id="stop-all" class="btn-elegant"
95
+ style="background: rgba(102, 126, 234, 0.08); color: #667eea; border: 1px solid rgba(102, 126, 234, 0.15);"
96
+ onclick="stopAllSources()">
97
+ <i class="fa-solid fa-rotate-right"></i> Reset System
98
+ </button>
99
  </div>
100
  </div>
101
+ </aside>
102
 
103
+ <!-- CENTER: VIDEO VIEW -->
104
+ <main class="main-view glass-panel"
105
+ style="display:flex; flex-direction:column; padding:0; gap:0; background: transparent !important; border:none !important; box-shadow:none !important;">
106
  <div
107
+ style="flex:1; position:relative; width:100%; display:flex; flex-direction:column; justify-content:center; align-items:center;">
108
+ <!-- HUD Output -->
109
+ <div class="hud-pill" id="hud-pill">
110
+ <span class="hud-label">DETECTED GESTURE</span>
111
+ <span class="hud-value" id="hud-gesture">Waiting...</span>
112
+ </div>
113
 
114
+ <div style="position:relative;">
115
+ <img src="{{ url_for('video_feed') }}" alt="Live Feed" class="video-feed" id="video-feed">
116
+ <div class="overlay-status">
117
+ <span class="label">LIVE</span>
118
+ <div class="status-dot"></div>
119
+ </div>
120
+ </div>
121
+
122
+ <div
123
+ style="margin-top: 1rem; font-size: 0.8rem; color: var(--accent-secondary); font-weight: 500; letter-spacing: 0.05em;">
124
+ Developed by OCC2 Group 4 (Vision)
125
+ </div>
126
+ </div>
127
+ </main>
128
+
129
+ <!-- RIGHT SIDEBAR: OUTPUT & LOGS -->
130
+ <aside class="sidebar right-sidebar glass-panel">
131
+ <div class="logo">
132
+ <div style="display:flex; flex-direction:column;">
133
+ <span style="font-size: 0.9rem; font-weight: 800; letter-spacing: 0.1em;">RECOGNITION
134
+ HISTORY</span>
135
+ </div>
136
+ <div style="margin-left: auto; display: flex; gap: 0.5rem;">
137
+ <button class="log-control-btn" onclick="exportLog()" title="Export CSV">
138
+ <i class="fa-solid fa-file-export"></i>
139
+ </button>
140
+ <button class="log-control-btn" onclick="clearLog()" title="Clear Log">
141
+ <i class="fa-solid fa-trash-can"></i>
142
+ </button>
143
+ </div>
144
  </div>
 
 
145
 
146
+ <div class="metric-group" style="align-items: center;">
147
+ <span class="label">Confidence</span>
148
+ <div class="radial-gauge">
149
+ <svg viewBox="0 0 100 100">
150
+ <circle class="gauge-bg" cx="50" cy="50" r="45"></circle>
151
+ <circle class="gauge-progress" id="gauge-progress" cx="50" cy="50" r="45"></circle>
152
+ </svg>
153
+ <div class="gauge-value" id="confidence-val">0%</div>
154
+ </div>
155
  </div>
 
156
 
157
+ <!-- Top Candidates -->
158
+ <div class="metric-group" style="flex-grow: 0;">
159
+ <span class="label">Top Candidates</span>
160
+ <div class="top-predictions" id="top-predictions">
161
+ <!-- Populated by JS -->
162
+ </div>
163
  </div>
 
164
 
165
+ <div class="metric-group" style="flex-grow: 1; display:flex; flex-direction:column; min-height: 0;">
166
+ <span class="label">Recognition History</span>
167
+ <div class="history-log" id="history-log">
168
+ <!-- Items added here -->
169
+ </div>
170
  </div>
171
+ </aside>
172
+ </div>
173
+
174
+ <script>
175
+ let isCameraRunning = true;
176
+ let lastLoggedGesture = null;
177
+
178
+ function toggleCamera() {
179
+ const btn = document.getElementById('cam-toggle');
180
+ const action = isCameraRunning ? 'stop' : 'start';
181
+
182
+ console.log(`Toggle Camera: ${action}`);
183
+
184
+ fetch('/api/camera/control', {
185
+ method: 'POST',
186
+ headers: { 'Content-Type': 'application/json' },
187
+ body: JSON.stringify({ action: action, source: 'webcam' })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  })
189
+ .then(res => res.json())
190
+ .then(data => {
191
+ updateUIState(data.status === 'started', 'webcam');
192
+ })
193
+ .catch(err => console.error(err));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  }
195
 
196
+ function stopAllSources() {
197
+ console.log("Stopping all sources...");
198
+ fetch('/api/camera/control', {
199
+ method: 'POST',
200
+ headers: { 'Content-Type': 'application/json' },
201
+ body: JSON.stringify({ action: 'stop' })
202
+ })
203
+ .then(res => res.json())
204
+ .then(data => {
205
+ updateUIState(false, null);
206
+ addToLog("All sources stopped", "INFO");
207
+ });
208
+ }
209
 
210
+ function updateUIState(running, type) {
211
+ isCameraRunning = running;
212
+ const vid = document.getElementById('video-feed');
213
+ const camBtn = document.getElementById('cam-toggle');
214
+ const dot = document.querySelector('.status-dot');
215
+ const statusText = document.getElementById('status-text');
216
+
217
+ if (running) {
218
+ vid.classList.add('active');
219
+ dot.style.background = 'var(--accent-gold)';
220
+ dot.style.boxShadow = '0 0 10px var(--accent-gold)';
221
+
222
+ if (type === 'webcam') {
223
+ camBtn.innerHTML = '<i class="fa-solid fa-video-slash"></i> Stop Camera Feed';
224
+ statusText.textContent = 'Live Camera Active';
225
+ } else {
226
+ camBtn.innerHTML = '<i class="fa-solid fa-video"></i> Start Camera Feed';
227
+ statusText.textContent = type === 'playlist' ? 'Batch Playlist Playing' : 'Video Playing';
228
+ }
229
+ } else {
230
+ vid.classList.remove('active');
231
+ dot.style.background = '#333';
232
+ dot.style.boxShadow = 'none';
233
+ camBtn.innerHTML = '<i class="fa-solid fa-video"></i> Start Camera Feed';
234
+ statusText.textContent = 'Ready';
235
  }
 
 
236
  }
237
 
238
+ function handleSingleUpload(input) {
239
+ console.log("Single upload triggered with file:", input.files[0]);
240
+ handleUpload(input, false);
241
+ input.value = ''; // Reset to allow re-selecting same file
242
+ }
 
 
 
 
 
 
243
 
244
+ function handleBatchUpload(input) {
245
+ console.log("Batch upload triggered with files:", input.files.length);
246
+ handleUpload(input, true);
247
+ input.value = ''; // Reset
248
+ }
249
 
250
+ function handleUpload(input, isBatch) {
251
+ const files = input.files;
252
+ if (!files.length) {
253
+ alert("No file selected!");
254
+ return;
255
+ }
256
 
257
+ const statusEl = document.getElementById('upload-status');
258
+ statusEl.textContent = isBatch ? `Uploading ${files.length} files...` : 'Uploading video...';
259
+ statusEl.style.opacity = '1';
260
+
261
+ const formData = new FormData();
262
+ if (isBatch) {
263
+ for (let i = 0; i < files.length; i++) {
264
+ formData.append('files[]', files[i]);
265
  }
266
+ } else {
267
+ formData.append('file', files[0]);
268
+ }
269
+
270
+ fetch('/api/upload', {
271
+ method: 'POST',
272
+ body: formData
273
+ })
274
+ .then(res => {
275
+ if (!res.ok) throw new Error(`Server Error: ${res.statusText}`);
276
+ return res.json();
277
+ })
278
+ .then(data => {
279
+ console.log("Upload response:", data);
280
+ if (data.error) throw new Error(data.error);
281
+
282
+ statusEl.textContent = 'Initializing Queuer...';
283
+
284
+ // Construct Payload
285
+ let payload = { action: 'start' };
286
+
287
+ if (isBatch || (data.filenames && data.filenames.length > 0)) {
288
+ payload.source = 'playlist';
289
+ payload.filenames = data.filenames || [data.filename];
290
+ } else {
291
+ payload.source = 'video';
292
+ payload.filename = data.filename;
293
+ }
294
 
295
+ console.log("Sending Control Payload:", payload);
296
 
297
+ return fetch('/api/camera/control', {
298
+ method: 'POST',
299
+ headers: { 'Content-Type': 'application/json' },
300
+ body: JSON.stringify(payload)
301
+ });
302
+ })
303
+ .then(res => res.json())
304
+ .then(data => {
305
+ console.log("Control response:", data);
306
+ if (data.status === 'started') {
307
+ const type = isBatch ? "Batch Playlist" : "Single Video";
308
+ updateUIState(true, isBatch ? 'playlist' : 'video');
309
+ addToLog(`New Source: ${type}`, "INFO");
310
+ statusEl.textContent = 'Processing Started';
311
+ } else {
312
+ alert("Failed to start video: " + (data.error || "Unknown error"));
313
+ }
314
+ setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
315
+ })
316
+ .catch(err => {
317
+ console.error("Upload/Control Error:", err);
318
+ statusEl.textContent = 'Error!';
319
+ alert("Upload Failed: " + err.message);
320
  });
321
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
 
323
+ function addToLog(gesture, confidence) {
324
+ const log = document.getElementById('history-log');
325
+ const item = document.createElement('div');
326
+ item.className = 'history-item';
327
 
328
+ const time = new Date().toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
329
 
330
+ item.innerHTML = `
331
+ <div style="display: flex; flex-direction: column; gap: 2px;">
332
+ <span style="color: var(--accent-primary); font-weight: 600;">${gesture}</span>
333
+ <span class="item-time">${time}</span>
334
+ </div>
335
+ <span style="font-weight: 700; color: var(--active);">${confidence}</span>
336
  `;
337
 
338
+ log.prepend(item);
339
+ }
340
+
341
+ function clearLog() {
342
+ if (confirm("Are you sure you want to clear the recognition history?")) {
343
+ document.getElementById('history-log').innerHTML = '';
344
+ lastLoggedGesture = null;
345
+ addToLog("Log Cleared", "INFO");
346
+ }
347
+ }
348
+
349
+ function exportLog() {
350
+ const items = document.querySelectorAll('.history-item');
351
+ if (items.length === 0) {
352
+ alert("No data to export!");
353
+ return;
354
+ }
355
+
356
+ let csv = "Time,Gesture,Confidence\n";
357
+ items.forEach(item => {
358
+ const gesture = item.querySelector('span:first-child').textContent;
359
+ const time = item.querySelector('.item-time').textContent;
360
+ const conf = item.querySelector('span:last-child').textContent;
361
+ csv += `${time},${gesture},${conf}\n`;
362
+ });
363
+
364
+ const blob = new Blob([csv], { type: 'text/csv' });
365
+ const url = window.URL.createObjectURL(blob);
366
+ const a = document.createElement('a');
367
+ a.setAttribute('hidden', '');
368
+ a.setAttribute('href', url);
369
+ a.setAttribute('download', `cvslr_session_${new Date().getTime()}.csv`);
370
+ document.body.appendChild(a);
371
+ a.click();
372
+ document.body.removeChild(a);
373
+ }
374
+
375
+ function openAboutModal() {
376
+ document.getElementById('about-modal').style.display = 'flex';
377
+ }
378
+
379
+ function closeAboutModal() {
380
+ document.getElementById('about-modal').style.display = 'none';
381
+ }
382
+
383
+ // Close modal on outside click
384
+ window.onclick = function (event) {
385
+ const modal = document.getElementById('about-modal');
386
+ if (event.target == modal) {
387
+ closeAboutModal();
388
+ }
389
+ }
390
+
391
+ function updateStatus() {
392
+ fetch('/api/status')
393
+ .then(response => response.json())
394
+ .then(data => {
395
+ // Update Gesture Name (HUD)
396
+ const gestureEl = document.getElementById('hud-gesture');
397
+ const confEl = document.getElementById('confidence-val');
398
+
399
+ if (data.gesture) {
400
+ const gestureName = data.gesture.toUpperCase().replace(/_/g, ' ');
401
+
402
+ gestureEl.textContent = gestureName;
403
+
404
+ // Add animation class briefly
405
+ const hudPill = document.getElementById('hud-pill');
406
+ hudPill.classList.add('pulse');
407
+ setTimeout(() => hudPill.classList.remove('pulse'), 300);
408
+
409
+ const confVal = (data.confidence * 100).toFixed(1);
410
+ confEl.textContent = confVal + '%';
411
+
412
+ // Update Radial Gauge
413
+ const circle = document.getElementById('gauge-progress');
414
+ const radius = circle.r.baseVal.value;
415
+ const circumference = 2 * Math.PI * radius;
416
+ const offset = circumference - (data.confidence * circumference);
417
+ circle.style.strokeDashoffset = offset;
418
+
419
+ // Change gauge color based on confidence
420
+ if (data.confidence > 0.85) circle.style.stroke = 'var(--success)';
421
+ else if (data.confidence > 0.6) circle.style.stroke = 'var(--active)';
422
+ else circle.style.stroke = 'var(--warning)';
423
+
424
+ // Logic for logging: Log if it's a new gesture and high confidence
425
+ if (gestureName !== lastLoggedGesture && data.confidence > 0.85 && gestureName !== 'UNKNOWN') {
426
+ lastLoggedGesture = gestureName;
427
+ addToLog(gestureName, confVal + '%');
428
+ }
429
  }
 
430
 
431
+ // Update Top Predictions (Restored Feature)
432
+ const listEl = document.getElementById('top-predictions');
433
+ listEl.innerHTML = '';
434
+ if (data.top_3 && data.top_3.length > 0) {
435
+ data.top_3.forEach(item => {
436
+ if (item.prob > 0) {
437
+ const div = document.createElement('div');
438
+ div.className = 'pred-item';
439
+ const name = item.name.replace(/_/g, ' ');
440
+ const prob = (item.prob * 100).toFixed(2);
441
+
442
+ div.innerHTML = `
443
+ <span>${name}</span>
444
+ <div style="display:flex; align-items:center; gap:0.5rem;">
445
+ <span>${prob}%</span>
446
+ <div class="pred-bar-bg">
447
+ <div class="pred-bar" style="width: ${prob}%"></div>
448
+ </div>
449
+ </div>
450
+ `;
451
+ listEl.appendChild(div);
452
+ }
453
+ });
454
+ }
455
+ })
456
+ .catch(err => console.error(err));
457
+ }
458
 
459
+ setInterval(updateStatus, 150);
460
+ document.getElementById('video-feed').classList.add('active'); // Init active
461
+ </script>
462
+ <!-- ABOUT MODAL -->
463
+ <div id="about-modal" class="modal">
464
+ <div class="modal-content glass-panel">
465
+ <span class="modal-close" onclick="closeAboutModal()">&times;</span>
466
+ <div class="modal-title">ABOUT WQF7006 SYSTEM</div>
467
+
468
+ <div style="font-size: 0.95rem; color: var(--accent-secondary); line-height: 1.6;">
469
+ <p style="margin-bottom: 1rem;">
470
+ <strong>WQF7006</strong> (Computer Vision Sign Language Recognition) is an advanced AI system
471
+ developed by <strong>OCC2 Group 4 (Vision)</strong> at the University of Malaya.
472
+ </p>
473
+
474
+ <h3 style="color: var(--accent-primary); margin: 1.5rem 0 0.5rem; font-size: 1rem;">Core Technology
475
+ </h3>
476
+ <p>Hybrid <strong>CNN-Transformer</strong> architecture optimized for spatial-temporal sign language
477
+ features.</p>
478
+
479
+ <h3 style="color: var(--accent-primary); margin: 1.5rem 0 0.5rem; font-size: 1rem;">How to Use</h3>
480
+ <ul style="padding-left: 1.2rem;">
481
+ <li><strong>Live Camera</strong>: Real-time recognition using your webcam.</li>
482
+ <li><strong>Upload Video</strong>: Processes video files using a 30-frame sliding window for
483
+ continuous gloss prediction.</li>
484
+ <li><strong>Batch Playlist</strong>: Queue multiple videos for sequential processing.</li>
485
+ </ul>
486
+
487
+ <h3 style="color: var(--accent-primary); margin: 1.5rem 0 0.5rem; font-size: 1rem;">Key Features
488
+ </h3>
489
+ <ul style="padding-left: 1.2rem;">
490
+ <li>Supports 50 Malaysian Sign Language (MSL) glosses.</li>
491
+ <li>Continuous prediction with sliding window logic.</li>
492
+ <li>Premium dashboard with real-time confidence visualization.</li>
493
+ </ul>
494
+ </div>
495
+
496
+ <div
497
+ style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--nav-border); font-size: 0.8rem; text-align: center;">
498
+ &copy; 2026 Department of AI, University Malaya
499
+ </div>
500
+ </div>
501
+ </div>
502
  </body>
503
 
504
  </html>
video_processor.py CHANGED
@@ -159,6 +159,61 @@ GESTURE_NAMES = {
159
  49: 'tandas'
160
  }
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  class GestureRecognizer:
163
  def __init__(self, model_path='best_cnn_transformer_hybrid.pth'):
164
  self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
 
159
  49: 'tandas'
160
  }
161
 
162
+ # English translations for Malaysian Sign Language gestures
163
+ GESTURE_TRANSLATIONS = {
164
+ 'abang': 'Brother',
165
+ 'anak_lelaki': 'Son',
166
+ 'anak_perempuan': 'Daughter',
167
+ 'apa': 'What',
168
+ 'apa_khabar': 'How are you',
169
+ 'assalamualaikum': 'Peace be upon you',
170
+ 'ayah': 'Father',
171
+ 'bagaimana': 'How',
172
+ 'bahasa_isyarat': 'Sign Language',
173
+ 'baik': 'Good',
174
+ 'bapa_saudara': 'Uncle',
175
+ 'beli': 'Buy',
176
+ 'beli_2': 'Buy (variant)',
177
+ 'berapa': 'How much',
178
+ 'bila': 'When',
179
+ 'bomba': 'Firefighter',
180
+ 'buat': 'Do/Make',
181
+ 'emak': 'Mother',
182
+ 'emak_saudara': 'Aunt',
183
+ 'hari': 'Day',
184
+ 'hi': 'Hi',
185
+ 'hujan': 'Rain',
186
+ 'jahat': 'Bad',
187
+ 'jangan': 'Don\'t',
188
+ 'kakak': 'Sister',
189
+ 'keluarga': 'Family',
190
+ 'kereta': 'Car',
191
+ 'lelaki': 'Male',
192
+ 'lemak': 'Fat',
193
+ 'main': 'Play',
194
+ 'mana': 'Where',
195
+ 'masalah': 'Problem',
196
+ 'nasi': 'Rice',
197
+ 'nasi_lemak': 'Nasi Lemak',
198
+ 'panas': 'Hot',
199
+ 'panas_2': 'Hot (variant)',
200
+ 'pandai': 'Smart',
201
+ 'pandai_2': 'Smart (variant)',
202
+ 'payung': 'Umbrella',
203
+ 'perempuan': 'Female',
204
+ 'perlahan': 'Slow',
205
+ 'perlahan_2': 'Slow (variant)',
206
+ 'pinjam': 'Borrow',
207
+ 'polis': 'Police',
208
+ 'pukul': 'Hit/Time',
209
+ 'ribut': 'Storm',
210
+ 'saudara': 'Sibling',
211
+ 'sejuk': 'Cold',
212
+ 'siapa': 'Who',
213
+ 'tandas': 'Toilet',
214
+ 'Unknown': 'Unknown'
215
+ }
216
+
217
  class GestureRecognizer:
218
  def __init__(self, model_path='best_cnn_transformer_hybrid.pth'):
219
  self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")