jayantjain052005 commited on
Commit
16f65fc
Β·
1 Parent(s): b50509d

Add ASL detector app

Browse files
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies for OpenCV and MediaPipe
6
+ RUN apt-get update && apt-get install -y \
7
+ libglib2.0-0 \
8
+ libsm6 \
9
+ libxext6 \
10
+ libxrender-dev \
11
+ libgomp1 \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ # Copy requirements first for layer caching
15
+ COPY requirements.txt .
16
+ RUN pip install --no-cache-dir -r requirements.txt
17
+
18
+ # Copy app files
19
+ COPY . .
20
+
21
+ # Hugging Face Spaces runs on port 7860
22
+ EXPOSE 7860
23
+
24
+ CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", "--timeout", "120", "app:app"]
README.md CHANGED
@@ -1,12 +1,25 @@
1
  ---
2
- title: Asl Detector
3
- emoji: πŸƒ
4
- colorFrom: green
5
- colorTo: red
6
- sdk: gradio
7
- sdk_version: 6.11.0
8
- app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: ASL Sign Language Detector
3
+ emoji: 🀟
4
+ colorFrom: cyan
5
+ colorTo: purple
6
+ sdk: docker
 
 
7
  pinned: false
8
  ---
9
 
10
+ # ASL Real-Time Sign Language Detector
11
+
12
+ Real-time American Sign Language (ASL) detection using MediaPipe hand landmarks and a scikit-learn classifier.
13
+
14
+ ## How to Use
15
+ 1. Click **Start Camera** to enable your webcam
16
+ 2. Show your hand sign to the camera
17
+ 3. The detected letter appears instantly with confidence score
18
+ 4. Use the **Sign Reference** panel on the right to see all signs
19
+ 5. Letters are automatically added to history when held for 1.5 seconds
20
+
21
+ ## Tech Stack
22
+ - **MediaPipe** β€” hand landmark extraction (21 keypoints)
23
+ - **scikit-learn** β€” Random Forest / MLP classifier
24
+ - **Flask** β€” web server
25
+ - **OpenCV** β€” image processing
app.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import cv2
3
+ import numpy as np
4
+ import joblib
5
+ import mediapipe as mp
6
+ import base64
7
+ from flask import Flask, render_template, request, jsonify
8
+
9
+ app = Flask(__name__)
10
+
11
+ # ── Load model ───────────────────────────────────────────────────
12
+ MODEL_PATH = "sign_model.pkl"
13
+ bundle = joblib.load(MODEL_PATH)
14
+ model = bundle["model"]
15
+ le = bundle["label_encoder"]
16
+ CLASSES = list(le.classes_)
17
+ print(f"βœ… Model loaded. Classes: {CLASSES}")
18
+
19
+ # ── MediaPipe setup ──────────────────────────────────────────────
20
+ mp_hands = mp.solutions.hands
21
+
22
+ def get_landmark_features(hand_landmarks):
23
+ lm = hand_landmarks.landmark
24
+ wrist = lm[0]
25
+ points = []
26
+ for point in lm:
27
+ points.append([
28
+ point.x - wrist.x,
29
+ point.y - wrist.y,
30
+ point.z - wrist.z
31
+ ])
32
+ points = np.array(points)
33
+ scale = np.max(np.linalg.norm(points, axis=1))
34
+ if scale > 0:
35
+ points = points / scale
36
+ return points.flatten().reshape(1, -1)
37
+
38
+ @app.route("/")
39
+ def index():
40
+ return render_template("index.html", classes=CLASSES)
41
+
42
+ @app.route("/predict", methods=["POST"])
43
+ def predict():
44
+ try:
45
+ data = request.get_json()
46
+ img_data = data["image"].split(",")[1]
47
+ img_bytes = base64.b64decode(img_data)
48
+ img_array = np.frombuffer(img_bytes, dtype=np.uint8)
49
+ img = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
50
+
51
+ if img is None:
52
+ return jsonify({"error": "Could not decode image"}), 400
53
+
54
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
55
+
56
+ with mp_hands.Hands(
57
+ static_image_mode=True,
58
+ max_num_hands=1,
59
+ min_detection_confidence=0.5
60
+ ) as hands:
61
+ result = hands.process(img_rgb)
62
+
63
+ if not result.multi_hand_landmarks:
64
+ return jsonify({"detected": False, "label": None, "confidence": 0, "all_probs": []})
65
+
66
+ hand_lm = result.multi_hand_landmarks[0]
67
+ features = get_landmark_features(hand_lm)
68
+ pred_idx = model.predict(features)[0]
69
+ probas = model.predict_proba(features)[0]
70
+ confidence = float(probas[pred_idx])
71
+ label = le.inverse_transform([pred_idx])[0]
72
+
73
+ all_probs = [
74
+ {"label": le.classes_[i], "prob": float(probas[i])}
75
+ for i in np.argsort(probas)[::-1][:5]
76
+ ]
77
+
78
+ return jsonify({
79
+ "detected": True,
80
+ "label": label,
81
+ "confidence": confidence,
82
+ "all_probs": all_probs
83
+ })
84
+
85
+ except Exception as e:
86
+ return jsonify({"error": str(e)}), 500
87
+
88
+ if __name__ == "__main__":
89
+ app.run(host="0.0.0.0", port=7860, debug=False)
requirements.txt ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ flask==3.0.3
2
+ numpy==1.26.4
3
+ opencv-python-headless==4.9.0.80
4
+ mediapipe==0.10.9
5
+ scikit-learn==1.4.2
6
+ joblib==1.4.2
7
+ gunicorn==22.0.0
8
+ # ============================================================
9
+ # Sign Language Detection - Stable Requirements
10
+ # Python 3.10 | Windows + Conda | No TensorFlow needed
11
+ # ============================================================
12
+
13
+ # Core computer vision
14
+ mediapipe==0.10.9
15
+ opencv-python==4.9.0.80
16
+
17
+ # Numpy pinned BEFORE mediapipe can upgrade it
18
+ numpy==1.26.4
19
+
20
+ # ML classifier (no TF needed)
21
+ scikit-learn==1.4.2
22
+
23
+ # Serialization
24
+ joblib==1.4.2
25
+
26
+ # Jupyter support
27
+ ipykernel==6.29.4
28
+ ipywidgets==8.1.2
29
+
30
+ # Optional: progress bars in notebook
31
+ tqdm==4.66.4
sign_model.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:eb14e71d3fdff1acb189cdcbee08e82a7bcda01ac8dda9f513009264596601c3
3
+ size 1430469
static/signs/A_test.jpg ADDED
static/signs/B_test.jpg ADDED
static/signs/C_test.jpg ADDED
static/signs/D_test.jpg ADDED
static/signs/E_test.jpg ADDED
static/signs/F_test.jpg ADDED
static/signs/G_test.jpg ADDED
static/signs/H_test.jpg ADDED
static/signs/I_test.jpg ADDED
static/signs/K_test.jpg ADDED
static/signs/L_test.jpg ADDED
static/signs/M_test.jpg ADDED
static/signs/N_test.jpg ADDED
static/signs/O_test.jpg ADDED
static/signs/P_test.jpg ADDED
static/signs/Q_test.jpg ADDED
static/signs/R_test.jpg ADDED
static/signs/S_test.jpg ADDED
static/signs/T_test.jpg ADDED
static/signs/U_test.jpg ADDED
static/signs/V_test.jpg ADDED
static/signs/W_test.jpg ADDED
static/signs/X_test.jpg ADDED
static/signs/Y_test.jpg ADDED
static/signs/space_test.jpg ADDED
templates/index.html ADDED
@@ -0,0 +1,809 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>ASL Sign Language Detector</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
8
+ <style>
9
+ :root {
10
+ --bg: #0a0a0f;
11
+ --surface: #12121a;
12
+ --surface2: #1a1a26;
13
+ --border: #2a2a3a;
14
+ --accent: #00e5ff;
15
+ --accent2: #7c3aed;
16
+ --green: #00e676;
17
+ --orange: #ff9800;
18
+ --text: #e8e8f0;
19
+ --muted: #6b6b8a;
20
+ --card: #16161f;
21
+ }
22
+
23
+ * { margin: 0; padding: 0; box-sizing: border-box; }
24
+
25
+ body {
26
+ background: var(--bg);
27
+ color: var(--text);
28
+ font-family: 'Syne', sans-serif;
29
+ min-height: 100vh;
30
+ overflow-x: hidden;
31
+ }
32
+
33
+ /* ── Background grid ── */
34
+ body::before {
35
+ content: '';
36
+ position: fixed;
37
+ inset: 0;
38
+ background-image:
39
+ linear-gradient(rgba(0,229,255,0.03) 1px, transparent 1px),
40
+ linear-gradient(90deg, rgba(0,229,255,0.03) 1px, transparent 1px);
41
+ background-size: 40px 40px;
42
+ pointer-events: none;
43
+ z-index: 0;
44
+ }
45
+
46
+ .app-wrapper {
47
+ position: relative;
48
+ z-index: 1;
49
+ max-width: 1400px;
50
+ margin: 0 auto;
51
+ padding: 24px 20px;
52
+ }
53
+
54
+ /* ── Header ── */
55
+ header {
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: space-between;
59
+ margin-bottom: 28px;
60
+ padding-bottom: 20px;
61
+ border-bottom: 1px solid var(--border);
62
+ }
63
+
64
+ .logo {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 12px;
68
+ }
69
+
70
+ .logo-icon {
71
+ width: 42px; height: 42px;
72
+ background: linear-gradient(135deg, var(--accent), var(--accent2));
73
+ border-radius: 10px;
74
+ display: flex; align-items: center; justify-content: center;
75
+ font-size: 22px;
76
+ }
77
+
78
+ .logo h1 {
79
+ font-size: 1.5rem;
80
+ font-weight: 800;
81
+ letter-spacing: -0.5px;
82
+ }
83
+
84
+ .logo span { color: var(--accent); }
85
+
86
+ .status-badge {
87
+ display: flex;
88
+ align-items: center;
89
+ gap: 8px;
90
+ background: var(--surface2);
91
+ border: 1px solid var(--border);
92
+ border-radius: 20px;
93
+ padding: 6px 14px;
94
+ font-size: 0.8rem;
95
+ font-family: 'DM Mono', monospace;
96
+ color: var(--muted);
97
+ }
98
+
99
+ .status-dot {
100
+ width: 8px; height: 8px;
101
+ border-radius: 50%;
102
+ background: var(--green);
103
+ box-shadow: 0 0 8px var(--green);
104
+ animation: pulse 2s infinite;
105
+ }
106
+
107
+ @keyframes pulse {
108
+ 0%, 100% { opacity: 1; }
109
+ 50% { opacity: 0.4; }
110
+ }
111
+
112
+ /* ── Main layout ── */
113
+ .main-grid {
114
+ display: grid;
115
+ grid-template-columns: 1fr 340px;
116
+ gap: 20px;
117
+ align-items: start;
118
+ }
119
+
120
+ /* ── Left column ── */
121
+ .left-col { display: flex; flex-direction: column; gap: 16px; }
122
+
123
+ /* ── Camera + prediction row ── */
124
+ .cam-pred-row {
125
+ display: grid;
126
+ grid-template-columns: 1fr 220px;
127
+ gap: 16px;
128
+ align-items: start;
129
+ }
130
+
131
+ /* ── Camera card ── */
132
+ .camera-card {
133
+ background: var(--card);
134
+ border: 1px solid var(--border);
135
+ border-radius: 16px;
136
+ overflow: hidden;
137
+ position: relative;
138
+ }
139
+
140
+ .camera-card-header {
141
+ display: flex;
142
+ align-items: center;
143
+ justify-content: space-between;
144
+ padding: 12px 16px;
145
+ border-bottom: 1px solid var(--border);
146
+ }
147
+
148
+ .camera-card-header span {
149
+ font-size: 0.75rem;
150
+ font-family: 'DM Mono', monospace;
151
+ color: var(--muted);
152
+ text-transform: uppercase;
153
+ letter-spacing: 1px;
154
+ }
155
+
156
+ .rec-dot {
157
+ width: 8px; height: 8px;
158
+ background: #ff4444;
159
+ border-radius: 50%;
160
+ box-shadow: 0 0 8px #ff4444;
161
+ animation: pulse 1s infinite;
162
+ }
163
+
164
+ .video-wrapper {
165
+ position: relative;
166
+ background: #000;
167
+ }
168
+
169
+ #videoEl {
170
+ width: 100%;
171
+ display: block;
172
+ border-radius: 0;
173
+ transform: scaleX(-1);
174
+ }
175
+
176
+ #canvasEl { display: none; }
177
+
178
+ .video-overlay {
179
+ position: absolute;
180
+ inset: 0;
181
+ pointer-events: none;
182
+ }
183
+
184
+ /* Corner brackets */
185
+ .corner {
186
+ position: absolute;
187
+ width: 24px; height: 24px;
188
+ border-color: var(--accent);
189
+ border-style: solid;
190
+ opacity: 0.7;
191
+ }
192
+ .corner-tl { top: 12px; left: 12px; border-width: 2px 0 0 2px; }
193
+ .corner-tr { top: 12px; right: 12px; border-width: 2px 2px 0 0; }
194
+ .corner-bl { bottom: 12px; left: 12px; border-width: 0 0 2px 2px; }
195
+ .corner-br { bottom: 12px; right: 12px; border-width: 0 2px 2px 0; }
196
+
197
+ /* ── Prediction panel ── */
198
+ .pred-panel {
199
+ display: flex;
200
+ flex-direction: column;
201
+ gap: 12px;
202
+ }
203
+
204
+ .pred-card {
205
+ background: var(--card);
206
+ border: 1px solid var(--border);
207
+ border-radius: 16px;
208
+ padding: 20px 16px;
209
+ text-align: center;
210
+ transition: border-color 0.3s;
211
+ }
212
+
213
+ .pred-card.active { border-color: var(--accent); box-shadow: 0 0 20px rgba(0,229,255,0.1); }
214
+
215
+ .pred-label-sm {
216
+ font-size: 0.7rem;
217
+ font-family: 'DM Mono', monospace;
218
+ color: var(--muted);
219
+ text-transform: uppercase;
220
+ letter-spacing: 1px;
221
+ margin-bottom: 10px;
222
+ }
223
+
224
+ .pred-letter {
225
+ font-size: 5rem;
226
+ font-weight: 800;
227
+ line-height: 1;
228
+ color: var(--accent);
229
+ text-shadow: 0 0 30px rgba(0,229,255,0.4);
230
+ transition: all 0.2s;
231
+ min-height: 80px;
232
+ display: flex;
233
+ align-items: center;
234
+ justify-content: center;
235
+ }
236
+
237
+ .pred-letter.no-detect { color: var(--muted); font-size: 2.5rem; }
238
+
239
+ /* Confidence bar */
240
+ .conf-bar-wrap {
241
+ background: var(--card);
242
+ border: 1px solid var(--border);
243
+ border-radius: 16px;
244
+ padding: 16px;
245
+ }
246
+
247
+ .conf-bar-label {
248
+ display: flex;
249
+ justify-content: space-between;
250
+ align-items: center;
251
+ margin-bottom: 10px;
252
+ font-size: 0.75rem;
253
+ font-family: 'DM Mono', monospace;
254
+ color: var(--muted);
255
+ }
256
+
257
+ .conf-value { color: var(--text); font-weight: 500; }
258
+
259
+ .conf-track {
260
+ height: 8px;
261
+ background: var(--surface2);
262
+ border-radius: 4px;
263
+ overflow: hidden;
264
+ }
265
+
266
+ .conf-fill {
267
+ height: 100%;
268
+ border-radius: 4px;
269
+ width: 0%;
270
+ transition: width 0.3s ease, background 0.3s ease;
271
+ background: var(--accent);
272
+ }
273
+
274
+ /* Top 5 probs */
275
+ .top5-card {
276
+ background: var(--card);
277
+ border: 1px solid var(--border);
278
+ border-radius: 16px;
279
+ padding: 14px;
280
+ }
281
+
282
+ .top5-title {
283
+ font-size: 0.7rem;
284
+ font-family: 'DM Mono', monospace;
285
+ color: var(--muted);
286
+ text-transform: uppercase;
287
+ letter-spacing: 1px;
288
+ margin-bottom: 10px;
289
+ }
290
+
291
+ .top5-row {
292
+ display: flex;
293
+ align-items: center;
294
+ gap: 8px;
295
+ margin-bottom: 6px;
296
+ }
297
+
298
+ .top5-lbl {
299
+ font-family: 'DM Mono', monospace;
300
+ font-size: 0.8rem;
301
+ font-weight: 500;
302
+ color: var(--text);
303
+ width: 28px;
304
+ text-align: center;
305
+ }
306
+
307
+ .top5-track {
308
+ flex: 1;
309
+ height: 5px;
310
+ background: var(--surface2);
311
+ border-radius: 3px;
312
+ overflow: hidden;
313
+ }
314
+
315
+ .top5-fill {
316
+ height: 100%;
317
+ border-radius: 3px;
318
+ background: var(--accent2);
319
+ transition: width 0.3s ease;
320
+ }
321
+
322
+ .top5-pct {
323
+ font-family: 'DM Mono', monospace;
324
+ font-size: 0.7rem;
325
+ color: var(--muted);
326
+ width: 36px;
327
+ text-align: right;
328
+ }
329
+
330
+ /* ── History bar ── */
331
+ .history-card {
332
+ background: var(--card);
333
+ border: 1px solid var(--border);
334
+ border-radius: 16px;
335
+ padding: 16px;
336
+ }
337
+
338
+ .history-header {
339
+ display: flex;
340
+ align-items: center;
341
+ justify-content: space-between;
342
+ margin-bottom: 12px;
343
+ }
344
+
345
+ .history-title {
346
+ font-size: 0.75rem;
347
+ font-family: 'DM Mono', monospace;
348
+ color: var(--muted);
349
+ text-transform: uppercase;
350
+ letter-spacing: 1px;
351
+ }
352
+
353
+ .clear-btn {
354
+ background: none;
355
+ border: 1px solid var(--border);
356
+ color: var(--muted);
357
+ font-family: 'DM Mono', monospace;
358
+ font-size: 0.7rem;
359
+ padding: 3px 10px;
360
+ border-radius: 6px;
361
+ cursor: pointer;
362
+ transition: all 0.2s;
363
+ }
364
+ .clear-btn:hover { border-color: var(--accent); color: var(--accent); }
365
+
366
+ .history-letters {
367
+ display: flex;
368
+ flex-wrap: wrap;
369
+ gap: 6px;
370
+ min-height: 36px;
371
+ }
372
+
373
+ .history-chip {
374
+ background: var(--surface2);
375
+ border: 1px solid var(--border);
376
+ border-radius: 8px;
377
+ padding: 4px 10px;
378
+ font-family: 'DM Mono', monospace;
379
+ font-size: 0.85rem;
380
+ font-weight: 500;
381
+ color: var(--text);
382
+ animation: chipIn 0.2s ease;
383
+ }
384
+
385
+ @keyframes chipIn {
386
+ from { opacity: 0; transform: scale(0.8); }
387
+ to { opacity: 1; transform: scale(1); }
388
+ }
389
+
390
+ .history-word {
391
+ margin-top: 10px;
392
+ padding-top: 10px;
393
+ border-top: 1px solid var(--border);
394
+ font-size: 1.1rem;
395
+ font-weight: 700;
396
+ color: var(--accent);
397
+ letter-spacing: 2px;
398
+ min-height: 28px;
399
+ font-family: 'DM Mono', monospace;
400
+ }
401
+
402
+ /* ── Right column β€” Sign reference ── */
403
+ .ref-panel {
404
+ background: var(--card);
405
+ border: 1px solid var(--border);
406
+ border-radius: 16px;
407
+ overflow: hidden;
408
+ position: sticky;
409
+ top: 20px;
410
+ }
411
+
412
+ .ref-header {
413
+ padding: 14px 16px;
414
+ border-bottom: 1px solid var(--border);
415
+ display: flex;
416
+ align-items: center;
417
+ justify-content: space-between;
418
+ }
419
+
420
+ .ref-title {
421
+ font-size: 0.75rem;
422
+ font-family: 'DM Mono', monospace;
423
+ color: var(--muted);
424
+ text-transform: uppercase;
425
+ letter-spacing: 1px;
426
+ }
427
+
428
+ .ref-grid {
429
+ display: grid;
430
+ grid-template-columns: repeat(4, 1fr);
431
+ gap: 4px;
432
+ padding: 10px;
433
+ max-height: 580px;
434
+ overflow-y: auto;
435
+ }
436
+
437
+ .ref-grid::-webkit-scrollbar { width: 4px; }
438
+ .ref-grid::-webkit-scrollbar-track { background: transparent; }
439
+ .ref-grid::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
440
+
441
+ .sign-tile {
442
+ background: var(--surface2);
443
+ border: 1px solid var(--border);
444
+ border-radius: 8px;
445
+ padding: 6px 4px;
446
+ text-align: center;
447
+ cursor: pointer;
448
+ transition: all 0.2s;
449
+ position: relative;
450
+ }
451
+
452
+ .sign-tile:hover {
453
+ border-color: var(--accent);
454
+ background: rgba(0,229,255,0.05);
455
+ transform: scale(1.05);
456
+ }
457
+
458
+ .sign-tile.highlighted {
459
+ border-color: var(--accent);
460
+ background: rgba(0,229,255,0.12);
461
+ box-shadow: 0 0 12px rgba(0,229,255,0.2);
462
+ }
463
+
464
+ .sign-tile img {
465
+ width: 100%;
466
+ aspect-ratio: 1;
467
+ object-fit: cover;
468
+ border-radius: 5px;
469
+ display: block;
470
+ margin-bottom: 4px;
471
+ background: var(--surface);
472
+ }
473
+
474
+ .sign-tile .sign-img-placeholder {
475
+ width: 100%;
476
+ aspect-ratio: 1;
477
+ border-radius: 5px;
478
+ background: var(--surface);
479
+ display: flex;
480
+ align-items: center;
481
+ justify-content: center;
482
+ font-size: 1.4rem;
483
+ margin-bottom: 4px;
484
+ }
485
+
486
+ .sign-tile-lbl {
487
+ font-family: 'DM Mono', monospace;
488
+ font-size: 0.7rem;
489
+ font-weight: 500;
490
+ color: var(--muted);
491
+ }
492
+
493
+ .sign-tile.highlighted .sign-tile-lbl { color: var(--accent); }
494
+
495
+ /* ── Controls ── */
496
+ .controls {
497
+ display: flex;
498
+ gap: 10px;
499
+ padding: 12px 16px;
500
+ border-top: 1px solid var(--border);
501
+ }
502
+
503
+ .btn {
504
+ flex: 1;
505
+ padding: 10px;
506
+ border-radius: 10px;
507
+ font-family: 'Syne', sans-serif;
508
+ font-size: 0.85rem;
509
+ font-weight: 600;
510
+ cursor: pointer;
511
+ border: none;
512
+ transition: all 0.2s;
513
+ }
514
+
515
+ .btn-primary {
516
+ background: linear-gradient(135deg, var(--accent), #0097a7);
517
+ color: #000;
518
+ }
519
+ .btn-primary:hover { opacity: 0.9; transform: translateY(-1px); }
520
+
521
+ .btn-secondary {
522
+ background: var(--surface2);
523
+ color: var(--text);
524
+ border: 1px solid var(--border);
525
+ }
526
+ .btn-secondary:hover { border-color: var(--accent); }
527
+
528
+ /* ── Footer ── */
529
+ footer {
530
+ margin-top: 24px;
531
+ padding-top: 16px;
532
+ border-top: 1px solid var(--border);
533
+ text-align: center;
534
+ font-size: 0.75rem;
535
+ font-family: 'DM Mono', monospace;
536
+ color: var(--muted);
537
+ }
538
+
539
+ @media (max-width: 900px) {
540
+ .main-grid { grid-template-columns: 1fr; }
541
+ .cam-pred-row { grid-template-columns: 1fr; }
542
+ .ref-panel { position: static; }
543
+ .ref-grid { max-height: 300px; }
544
+ }
545
+ </style>
546
+ </head>
547
+ <body>
548
+ <div class="app-wrapper">
549
+
550
+ <!-- Header -->
551
+ <header>
552
+ <div class="logo">
553
+ <div class="logo-icon">🀟</div>
554
+ <h1>ASL <span>Detector</span></h1>
555
+ </div>
556
+ <div class="status-badge">
557
+ <div class="status-dot"></div>
558
+ MODEL READY β€” {{ classes|length }} SIGNS
559
+ </div>
560
+ </header>
561
+
562
+ <!-- Main grid -->
563
+ <div class="main-grid">
564
+
565
+ <!-- LEFT COLUMN -->
566
+ <div class="left-col">
567
+
568
+ <!-- Camera + Prediction row -->
569
+ <div class="cam-pred-row">
570
+
571
+ <!-- Camera -->
572
+ <div class="camera-card">
573
+ <div class="camera-card-header">
574
+ <span>Live Feed</span>
575
+ <div class="rec-dot"></div>
576
+ </div>
577
+ <div class="video-wrapper">
578
+ <video id="videoEl" autoplay playsinline muted></video>
579
+ <canvas id="canvasEl"></canvas>
580
+ <div class="video-overlay">
581
+ <div class="corner corner-tl"></div>
582
+ <div class="corner corner-tr"></div>
583
+ <div class="corner corner-bl"></div>
584
+ <div class="corner corner-br"></div>
585
+ </div>
586
+ </div>
587
+ <div class="controls">
588
+ <button class="btn btn-primary" id="startBtn" onclick="startCamera()">Start Camera</button>
589
+ <button class="btn btn-secondary" onclick="addToHistory()">Add Letter</button>
590
+ </div>
591
+ </div>
592
+
593
+ <!-- Prediction panel -->
594
+ <div class="pred-panel">
595
+
596
+ <!-- Big letter -->
597
+ <div class="pred-card" id="predCard">
598
+ <div class="pred-label-sm">Detected Sign</div>
599
+ <div class="pred-letter no-detect" id="predLetter">β€”</div>
600
+ </div>
601
+
602
+ <!-- Confidence bar -->
603
+ <div class="conf-bar-wrap">
604
+ <div class="conf-bar-label">
605
+ <span>Confidence</span>
606
+ <span class="conf-value" id="confValue">0%</span>
607
+ </div>
608
+ <div class="conf-track">
609
+ <div class="conf-fill" id="confFill"></div>
610
+ </div>
611
+ </div>
612
+
613
+ <!-- Top 5 -->
614
+ <div class="top5-card">
615
+ <div class="top5-title">Top Predictions</div>
616
+ <div id="top5List"></div>
617
+ </div>
618
+
619
+ </div>
620
+ </div>
621
+
622
+ <!-- History -->
623
+ <div class="history-card">
624
+ <div class="history-header">
625
+ <div class="history-title">Sign History</div>
626
+ <button class="clear-btn" onclick="clearHistory()">Clear</button>
627
+ </div>
628
+ <div class="history-letters" id="historyLetters"></div>
629
+ <div class="history-word" id="historyWord"></div>
630
+ </div>
631
+
632
+ </div>
633
+
634
+ <!-- RIGHT COLUMN β€” Sign Reference -->
635
+ <div class="ref-panel">
636
+ <div class="ref-header">
637
+ <span class="ref-title">Sign Reference</span>
638
+ <span style="font-size:0.7rem;font-family:'DM Mono',monospace;color:var(--muted)">{{ classes|length }} signs</span>
639
+ </div>
640
+ <div class="ref-grid" id="refGrid">
641
+ {% for sign in classes %}
642
+ <div class="sign-tile" id="tile-{{ sign }}" title="{{ sign }}">
643
+ <img
644
+ src="/static/signs/{{ sign }}_test.jpg"
645
+ alt="{{ sign }}"
646
+ onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"
647
+ >
648
+ <div class="sign-img-placeholder" style="display:none">{{ sign }}</div>
649
+ <div class="sign-tile-lbl">{{ sign }}</div>
650
+ </div>
651
+ {% endfor %}
652
+ </div>
653
+ </div>
654
+
655
+ </div>
656
+
657
+ <footer>
658
+ ASL Sign Language Detector &nbsp;Β·&nbsp; MediaPipe + scikit-learn &nbsp;Β·&nbsp; Real-time hand landmark classification
659
+ </footer>
660
+
661
+ </div>
662
+
663
+ <script>
664
+ let stream = null;
665
+ let predInterval = null;
666
+ let currentLabel = null;
667
+ let history = [];
668
+ let lastHighlighted = null;
669
+ const SMOOTHING = 5;
670
+ const predBuffer = [];
671
+
672
+ // ── Camera ────────────────────────────────────────────────────
673
+ async function startCamera() {
674
+ try {
675
+ stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } });
676
+ document.getElementById("videoEl").srcObject = stream;
677
+ document.getElementById("startBtn").textContent = "Camera On";
678
+ document.getElementById("startBtn").disabled = true;
679
+ predInterval = setInterval(sendFrame, 150);
680
+ } catch (e) {
681
+ alert("Could not access camera: " + e.message);
682
+ }
683
+ }
684
+
685
+ // ── Send frame to server ──────────────────────────────────────
686
+ function sendFrame() {
687
+ const video = document.getElementById("videoEl");
688
+ const canvas = document.getElementById("canvasEl");
689
+ if (video.readyState < 2) return;
690
+
691
+ canvas.width = video.videoWidth;
692
+ canvas.height = video.videoHeight;
693
+ const ctx = canvas.getContext("2d");
694
+ ctx.drawImage(video, 0, 0);
695
+ const dataUrl = canvas.toDataURL("image/jpeg", 0.7);
696
+
697
+ fetch("/predict", {
698
+ method: "POST",
699
+ headers: { "Content-Type": "application/json" },
700
+ body: JSON.stringify({ image: dataUrl })
701
+ })
702
+ .then(r => r.json())
703
+ .then(updateUI)
704
+ .catch(console.error);
705
+ }
706
+
707
+ // ── Update UI ─────────────────────────────────────────────────
708
+ function updateUI(data) {
709
+ const letterEl = document.getElementById("predLetter");
710
+ const confFill = document.getElementById("confFill");
711
+ const confValue = document.getElementById("confValue");
712
+ const predCard = document.getElementById("predCard");
713
+ const top5 = document.getElementById("top5List");
714
+
715
+ if (!data.detected) {
716
+ letterEl.textContent = "β€”";
717
+ letterEl.className = "pred-letter no-detect";
718
+ confFill.style.width = "0%";
719
+ confValue.textContent = "0%";
720
+ predCard.classList.remove("active");
721
+ top5.innerHTML = "";
722
+ highlightTile(null);
723
+ currentLabel = null;
724
+ return;
725
+ }
726
+
727
+ // Smoothing buffer
728
+ predBuffer.push(data.label);
729
+ if (predBuffer.length > SMOOTHING) predBuffer.shift();
730
+ const counts = {};
731
+ predBuffer.forEach(l => counts[l] = (counts[l] || 0) + 1);
732
+ const smoothed = Object.entries(counts).sort((a,b) => b[1]-a[1])[0][0];
733
+
734
+ currentLabel = smoothed;
735
+ letterEl.textContent = smoothed;
736
+ letterEl.className = "pred-letter";
737
+
738
+ const pct = Math.round(data.confidence * 100);
739
+ confFill.style.width = pct + "%";
740
+ confFill.style.background = pct > 80 ? "var(--green)" : pct > 50 ? "var(--accent)" : "var(--orange)";
741
+ confValue.textContent = pct + "%";
742
+ predCard.classList.add("active");
743
+
744
+ // Top 5
745
+ top5.innerHTML = data.all_probs.map(p => `
746
+ <div class="top5-row">
747
+ <span class="top5-lbl">${p.label}</span>
748
+ <div class="top5-track"><div class="top5-fill" style="width:${Math.round(p.prob*100)}%"></div></div>
749
+ <span class="top5-pct">${Math.round(p.prob*100)}%</span>
750
+ </div>
751
+ `).join("");
752
+
753
+ // Highlight reference tile
754
+ highlightTile(smoothed);
755
+ }
756
+
757
+ // ── Highlight sign tile ───────────────────────────────────────
758
+ function highlightTile(label) {
759
+ if (lastHighlighted) {
760
+ const prev = document.getElementById("tile-" + lastHighlighted);
761
+ if (prev) prev.classList.remove("highlighted");
762
+ }
763
+ if (label) {
764
+ const tile = document.getElementById("tile-" + label);
765
+ if (tile) {
766
+ tile.classList.add("highlighted");
767
+ tile.scrollIntoView({ block: "nearest", behavior: "smooth" });
768
+ }
769
+ }
770
+ lastHighlighted = label;
771
+ }
772
+
773
+ // ── History ─────────────────────────────────────────────���─────
774
+ function addToHistory() {
775
+ if (!currentLabel) return;
776
+ history.push(currentLabel);
777
+ renderHistory();
778
+ }
779
+
780
+ function clearHistory() {
781
+ history = [];
782
+ renderHistory();
783
+ }
784
+
785
+ function renderHistory() {
786
+ const container = document.getElementById("historyLetters");
787
+ const wordEl = document.getElementById("historyWord");
788
+ container.innerHTML = history.map(l =>
789
+ `<span class="history-chip">${l}</span>`
790
+ ).join("");
791
+ wordEl.textContent = history.filter(l => l !== "space").join("") || "";
792
+ }
793
+
794
+ // Auto-add on stable detection (hold same sign for 1.5s)
795
+ let holdTimer = null;
796
+ let holdLabel = null;
797
+ setInterval(() => {
798
+ if (currentLabel && currentLabel === holdLabel) return;
799
+ holdLabel = currentLabel;
800
+ clearTimeout(holdTimer);
801
+ if (currentLabel && currentLabel !== "space") {
802
+ holdTimer = setTimeout(() => {
803
+ if (currentLabel === holdLabel) addToHistory();
804
+ }, 1500);
805
+ }
806
+ }, 200);
807
+ </script>
808
+ </body>
809
+ </html>