Solar-Prince commited on
Commit
a50ab29
·
verified ·
1 Parent(s): 7fd00f2

Upload 2 files

Browse files
Files changed (2) hide show
  1. README.md +26 -16
  2. index.html +868 -0
README.md CHANGED
@@ -1,26 +1,36 @@
1
  ---
2
- title: FaceSense Live
3
- emoji: 🚀
4
  colorFrom: blue
5
  colorTo: green
6
- sdk: gradio
7
- sdk_version: 5.49.1
8
- app_file: app.py
9
  pinned: false
10
- license: mit
 
 
 
 
 
11
  ---
12
 
13
- # FaceSense Live
14
 
15
- Phase 1 validates live webcam processing on Hugging Face Spaces.
16
 
17
- ## How to use
18
 
19
- 1. Open the Space.
20
- 2. Allow camera permission.
21
- 3. Click **Record** in the webcam panel.
22
- 4. Keep your face centered and well lit.
23
- 5. The annotated output should update with face bounding boxes.
24
 
25
- On mobile, the browser controls camera permission and may allow switching between front and rear cameras.
26
- A later phase can add a custom WebRTC frontend if a dedicated in-app front/rear toggle is required.
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: SentAI
3
+ emoji: 🧠
4
  colorFrom: blue
5
  colorTo: green
6
+ sdk: static
7
+ app_file: index.html
 
8
  pinned: false
9
+ short_description: Live face expression, age and gender estimates
10
+ tags:
11
+ - computer-vision
12
+ - face-analysis
13
+ - webcam
14
+ - mobile
15
  ---
16
 
17
+ # SentAI
18
 
19
+ Phase 2 switches SentAI from a server-side Gradio prototype to a browser-side static app.
20
 
21
+ The browser handles the camera stream and runs face-api.js models directly on the device, which should improve FPS and enables a front/rear camera toggle on many mobile browsers.
22
 
23
+ ## Current Phase 2 features
 
 
 
 
24
 
25
+ - Live webcam video
26
+ - Face bounding boxes
27
+ - Possible feeling: Happy, Sad, Fear, Anger, Confused, Disgust
28
+ - Apparent age range
29
+ - Male/Female gender estimate
30
+ - Front/rear camera toggle where supported by the browser
31
+ - Responsive mobile-first UI
32
+ - On-device FPS and latency display
33
+
34
+ ## Accuracy note
35
+
36
+ These are appearance-based estimates from a camera frame. Lighting, face angle, glasses, camera quality, and dataset/model bias can affect results.
index.html ADDED
@@ -0,0 +1,868 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, viewport-fit=cover" />
6
+ <meta name="theme-color" content="#050816" />
7
+ <title>SentAI</title>
8
+ <script defer src="https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js"></script>
9
+ <style>
10
+ :root {
11
+ --bg-a: #050816;
12
+ --bg-b: #0d1b2f;
13
+ --card: rgba(15, 23, 42, 0.74);
14
+ --card-strong: rgba(15, 23, 42, 0.92);
15
+ --stroke: rgba(148, 163, 184, 0.22);
16
+ --text: #f8fafc;
17
+ --muted: #a7b4c8;
18
+ --soft: rgba(255,255,255,0.08);
19
+ --accent: #22d3ee;
20
+ --accent-2: #a78bfa;
21
+ --good: #2dd4bf;
22
+ --warn: #fbbf24;
23
+ --bad: #fb7185;
24
+ --shadow: 0 24px 90px rgba(0,0,0,0.42);
25
+ --radius: 28px;
26
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
27
+ }
28
+
29
+ * { box-sizing: border-box; }
30
+
31
+ body {
32
+ margin: 0;
33
+ min-height: 100vh;
34
+ color: var(--text);
35
+ background:
36
+ radial-gradient(circle at 12% 10%, rgba(34, 211, 238, 0.26), transparent 34%),
37
+ radial-gradient(circle at 90% 0%, rgba(167, 139, 250, 0.24), transparent 34%),
38
+ linear-gradient(135deg, var(--bg-a), var(--bg-b) 52%, #112236);
39
+ overflow-x: hidden;
40
+ }
41
+
42
+ body::before {
43
+ content: "";
44
+ position: fixed;
45
+ inset: 0;
46
+ pointer-events: none;
47
+ background-image:
48
+ linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
49
+ linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
50
+ background-size: 44px 44px;
51
+ mask-image: radial-gradient(circle at 50% 20%, black 0%, transparent 75%);
52
+ }
53
+
54
+ .app-shell {
55
+ width: min(1440px, calc(100% - 32px));
56
+ margin: 0 auto;
57
+ padding: 34px 0 42px;
58
+ position: relative;
59
+ z-index: 1;
60
+ }
61
+
62
+ header.hero {
63
+ display: grid;
64
+ grid-template-columns: 1fr auto;
65
+ align-items: center;
66
+ gap: 22px;
67
+ margin-bottom: 26px;
68
+ }
69
+
70
+ .brand h1 {
71
+ margin: 0;
72
+ font-size: clamp(3rem, 8vw, 7rem);
73
+ line-height: 0.92;
74
+ letter-spacing: -0.08em;
75
+ font-weight: 900;
76
+ text-shadow: 0 24px 70px rgba(34, 211, 238, 0.1);
77
+ }
78
+
79
+ .brand p {
80
+ margin: 18px 0 0;
81
+ max-width: 870px;
82
+ color: var(--muted);
83
+ font-size: clamp(1rem, 1.8vw, 1.3rem);
84
+ line-height: 1.55;
85
+ }
86
+
87
+ .status-pill {
88
+ display: inline-flex;
89
+ align-items: center;
90
+ gap: 10px;
91
+ border: 1px solid var(--stroke);
92
+ background: rgba(15, 23, 42, 0.6);
93
+ backdrop-filter: blur(18px);
94
+ border-radius: 999px;
95
+ padding: 12px 16px;
96
+ color: var(--muted);
97
+ box-shadow: var(--shadow);
98
+ white-space: nowrap;
99
+ font-weight: 700;
100
+ }
101
+
102
+ .dot {
103
+ width: 10px;
104
+ height: 10px;
105
+ border-radius: 50%;
106
+ background: var(--warn);
107
+ box-shadow: 0 0 18px var(--warn);
108
+ }
109
+
110
+ .dot.ready { background: var(--good); box-shadow: 0 0 18px var(--good); }
111
+ .dot.error { background: var(--bad); box-shadow: 0 0 18px var(--bad); }
112
+
113
+ .toolbar {
114
+ display: flex;
115
+ flex-wrap: wrap;
116
+ gap: 12px;
117
+ align-items: center;
118
+ margin-bottom: 18px;
119
+ }
120
+
121
+ button, select {
122
+ appearance: none;
123
+ border: 1px solid var(--stroke);
124
+ background: rgba(15, 23, 42, 0.74);
125
+ color: var(--text);
126
+ border-radius: 999px;
127
+ padding: 13px 18px;
128
+ font-size: 0.98rem;
129
+ font-weight: 800;
130
+ letter-spacing: -0.01em;
131
+ cursor: pointer;
132
+ transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, opacity 160ms ease;
133
+ min-height: 48px;
134
+ }
135
+
136
+ button:hover, select:hover { transform: translateY(-1px); border-color: rgba(34, 211, 238, 0.55); }
137
+ button:active { transform: translateY(0); }
138
+ button.primary { background: linear-gradient(135deg, rgba(34,211,238,0.95), rgba(167,139,250,0.92)); color: #06111f; border-color: transparent; }
139
+ button.danger { color: #fecdd3; }
140
+ button:disabled { opacity: 0.46; cursor: not-allowed; transform: none; }
141
+
142
+ .camera-tag {
143
+ padding: 12px 16px;
144
+ min-height: 48px;
145
+ display: inline-flex;
146
+ align-items: center;
147
+ border-radius: 999px;
148
+ border: 1px solid var(--stroke);
149
+ background: rgba(255,255,255,0.055);
150
+ color: var(--muted);
151
+ font-weight: 800;
152
+ }
153
+
154
+ .grid {
155
+ display: grid;
156
+ grid-template-columns: minmax(0, 1.45fr) minmax(330px, 0.72fr);
157
+ gap: 18px;
158
+ align-items: stretch;
159
+ }
160
+
161
+ .panel {
162
+ background: var(--card);
163
+ border: 1px solid var(--stroke);
164
+ border-radius: var(--radius);
165
+ box-shadow: var(--shadow);
166
+ backdrop-filter: blur(22px);
167
+ overflow: hidden;
168
+ }
169
+
170
+ .stage-panel { padding: 14px; }
171
+
172
+ .stage {
173
+ position: relative;
174
+ width: 100%;
175
+ aspect-ratio: 4 / 3;
176
+ border-radius: calc(var(--radius) - 10px);
177
+ overflow: hidden;
178
+ background:
179
+ radial-gradient(circle at 50% 30%, rgba(34,211,238,0.09), transparent 36%),
180
+ #020617;
181
+ border: 1px solid rgba(148, 163, 184, 0.18);
182
+ }
183
+
184
+ video, canvas#overlay {
185
+ position: absolute;
186
+ inset: 0;
187
+ width: 100%;
188
+ height: 100%;
189
+ object-fit: contain;
190
+ }
191
+
192
+ video {
193
+ background: #020617;
194
+ }
195
+
196
+ canvas#overlay {
197
+ pointer-events: none;
198
+ }
199
+
200
+ .empty-state {
201
+ position: absolute;
202
+ inset: 0;
203
+ display: grid;
204
+ place-items: center;
205
+ padding: 28px;
206
+ text-align: center;
207
+ color: var(--muted);
208
+ pointer-events: none;
209
+ }
210
+
211
+ .empty-card {
212
+ max-width: 560px;
213
+ border: 1px solid var(--stroke);
214
+ background: rgba(15,23,42,0.68);
215
+ border-radius: 24px;
216
+ padding: 28px;
217
+ }
218
+
219
+ .empty-card strong {
220
+ display: block;
221
+ color: var(--text);
222
+ font-size: clamp(1.35rem, 3vw, 2.2rem);
223
+ margin-bottom: 12px;
224
+ }
225
+
226
+ .side {
227
+ padding: 18px;
228
+ display: grid;
229
+ gap: 14px;
230
+ align-content: start;
231
+ }
232
+
233
+ .side h2 {
234
+ margin: 2px 0 4px;
235
+ font-size: 1.35rem;
236
+ letter-spacing: -0.04em;
237
+ }
238
+
239
+ .metric-grid {
240
+ display: grid;
241
+ grid-template-columns: repeat(2, minmax(0, 1fr));
242
+ gap: 12px;
243
+ }
244
+
245
+ .metric {
246
+ border: 1px solid var(--stroke);
247
+ background: rgba(255,255,255,0.055);
248
+ border-radius: 22px;
249
+ padding: 16px;
250
+ min-height: 112px;
251
+ }
252
+
253
+ .metric span {
254
+ display: block;
255
+ color: var(--muted);
256
+ font-size: 0.82rem;
257
+ font-weight: 800;
258
+ text-transform: uppercase;
259
+ letter-spacing: 0.08em;
260
+ }
261
+
262
+ .metric strong {
263
+ display: block;
264
+ margin-top: 10px;
265
+ font-size: clamp(1.55rem, 3.5vw, 2.25rem);
266
+ letter-spacing: -0.06em;
267
+ line-height: 1;
268
+ }
269
+
270
+ .metric small {
271
+ display: block;
272
+ margin-top: 7px;
273
+ color: var(--muted);
274
+ font-weight: 700;
275
+ }
276
+
277
+ .wide { grid-column: 1 / -1; }
278
+
279
+ .bars {
280
+ border: 1px solid var(--stroke);
281
+ background: rgba(255,255,255,0.055);
282
+ border-radius: 22px;
283
+ padding: 16px;
284
+ }
285
+
286
+ .bars h3 {
287
+ margin: 0 0 14px;
288
+ font-size: 1rem;
289
+ color: var(--text);
290
+ letter-spacing: -0.02em;
291
+ }
292
+
293
+ .bar-row {
294
+ display: grid;
295
+ grid-template-columns: 86px 1fr 48px;
296
+ gap: 10px;
297
+ align-items: center;
298
+ margin: 11px 0;
299
+ color: var(--muted);
300
+ font-size: 0.9rem;
301
+ font-weight: 800;
302
+ }
303
+
304
+ .bar-track {
305
+ height: 9px;
306
+ border-radius: 99px;
307
+ background: rgba(148,163,184,0.2);
308
+ overflow: hidden;
309
+ }
310
+
311
+ .bar-fill {
312
+ width: 0%;
313
+ height: 100%;
314
+ border-radius: inherit;
315
+ background: linear-gradient(90deg, var(--accent), var(--accent-2));
316
+ transition: width 180ms ease;
317
+ }
318
+
319
+ .note {
320
+ border: 1px solid rgba(34,211,238,0.25);
321
+ background: rgba(34,211,238,0.08);
322
+ color: #cffafe;
323
+ border-radius: 22px;
324
+ padding: 15px 16px;
325
+ line-height: 1.5;
326
+ font-size: 0.94rem;
327
+ }
328
+
329
+ .toast {
330
+ position: fixed;
331
+ left: 50%;
332
+ bottom: 22px;
333
+ transform: translateX(-50%) translateY(30px);
334
+ opacity: 0;
335
+ z-index: 10;
336
+ background: rgba(2, 6, 23, 0.94);
337
+ border: 1px solid var(--stroke);
338
+ border-radius: 999px;
339
+ padding: 13px 18px;
340
+ box-shadow: var(--shadow);
341
+ color: var(--text);
342
+ font-weight: 800;
343
+ transition: opacity 180ms ease, transform 180ms ease;
344
+ max-width: min(92vw, 720px);
345
+ text-align: center;
346
+ }
347
+
348
+ .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
349
+
350
+ @media (max-width: 980px) {
351
+ .app-shell { width: min(100% - 20px, 760px); padding-top: 22px; }
352
+ header.hero { grid-template-columns: 1fr; }
353
+ .status-pill { width: fit-content; }
354
+ .grid { grid-template-columns: 1fr; }
355
+ .stage-panel { padding: 10px; }
356
+ .side { padding: 14px; }
357
+ .toolbar { gap: 9px; }
358
+ button, select, .camera-tag { flex: 1 1 150px; justify-content: center; }
359
+ }
360
+
361
+ @media (max-width: 560px) {
362
+ .brand h1 { font-size: clamp(3.6rem, 19vw, 5.4rem); }
363
+ .brand p { font-size: 0.98rem; }
364
+ .metric-grid { grid-template-columns: 1fr; }
365
+ .bar-row { grid-template-columns: 76px 1fr 44px; font-size: 0.82rem; }
366
+ .empty-card { padding: 20px; }
367
+ .app-shell { width: min(100% - 14px, 760px); }
368
+ body { background-attachment: fixed; }
369
+ }
370
+ </style>
371
+ </head>
372
+ <body>
373
+ <main class="app-shell">
374
+ <header class="hero" aria-label="SentAI heading">
375
+ <div class="brand">
376
+ <h1>SentAI</h1>
377
+ <p>Live face boxes with possible feeling, apparent age range, and male/female gender estimate. Runs in the browser for faster desktop and mobile camera performance.</p>
378
+ </div>
379
+ <div class="status-pill" aria-live="polite"><span id="modelDot" class="dot"></span><span id="modelStatus">Loading AI models...</span></div>
380
+ </header>
381
+
382
+ <section class="toolbar" aria-label="Camera controls">
383
+ <button id="startBtn" class="primary" disabled>Start camera</button>
384
+ <button id="switchBtn" disabled>Switch front/rear</button>
385
+ <button id="stopBtn" class="danger" disabled>Stop</button>
386
+ <select id="modeSelect" aria-label="Performance mode">
387
+ <option value="fast">Fast mode</option>
388
+ <option value="balanced" selected>Balanced mode</option>
389
+ <option value="accurate">Accurate mode</option>
390
+ </select>
391
+ <span id="cameraTag" class="camera-tag">Camera: not started</span>
392
+ </section>
393
+
394
+ <section class="grid">
395
+ <div class="panel stage-panel">
396
+ <div id="stage" class="stage">
397
+ <video id="video" autoplay muted playsinline></video>
398
+ <canvas id="overlay"></canvas>
399
+ <div id="emptyState" class="empty-state">
400
+ <div class="empty-card">
401
+ <strong>Ready for live analysis</strong>
402
+ Tap <b>Start camera</b>, allow permission, then keep one face well-lit and centered. On phones, use <b>Switch front/rear</b> to toggle cameras when supported by the browser.
403
+ </div>
404
+ </div>
405
+ </div>
406
+ </div>
407
+
408
+ <aside class="panel side" aria-label="Live analysis panel">
409
+ <h2>Live details</h2>
410
+ <div class="metric-grid">
411
+ <div class="metric">
412
+ <span>Possible feeling</span>
413
+ <strong id="feelingValue">—</strong>
414
+ <small id="feelingConfidence">Waiting</small>
415
+ </div>
416
+ <div class="metric">
417
+ <span>Gender</span>
418
+ <strong id="genderValue">—</strong>
419
+ <small id="genderConfidence">Waiting</small>
420
+ </div>
421
+ <div class="metric">
422
+ <span>Apparent age</span>
423
+ <strong id="ageValue">—</strong>
424
+ <small>Range estimate</small>
425
+ </div>
426
+ <div class="metric">
427
+ <span>Faces</span>
428
+ <strong id="facesValue">0</strong>
429
+ <small id="faceScore">No face yet</small>
430
+ </div>
431
+ <div class="metric">
432
+ <span>FPS</span>
433
+ <strong id="fpsValue">—</strong>
434
+ <small>On-device loop</small>
435
+ </div>
436
+ <div class="metric">
437
+ <span>Latency</span>
438
+ <strong id="latencyValue">—</strong>
439
+ <small>Model time</small>
440
+ </div>
441
+ </div>
442
+
443
+ <div class="bars wide">
444
+ <h3>Feeling scores</h3>
445
+ <div id="emotionBars"></div>
446
+ </div>
447
+
448
+ <div class="note wide">
449
+ Results are estimates from facial appearance and expression. Lighting, pose, glasses, camera quality, and model bias can affect accuracy.
450
+ </div>
451
+ </aside>
452
+ </section>
453
+ </main>
454
+
455
+ <div id="toast" class="toast" role="status" aria-live="polite"></div>
456
+
457
+ <script>
458
+ const MODEL_URL = "https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights";
459
+
460
+ const els = {
461
+ modelDot: document.getElementById("modelDot"),
462
+ modelStatus: document.getElementById("modelStatus"),
463
+ startBtn: document.getElementById("startBtn"),
464
+ switchBtn: document.getElementById("switchBtn"),
465
+ stopBtn: document.getElementById("stopBtn"),
466
+ modeSelect: document.getElementById("modeSelect"),
467
+ cameraTag: document.getElementById("cameraTag"),
468
+ video: document.getElementById("video"),
469
+ overlay: document.getElementById("overlay"),
470
+ stage: document.getElementById("stage"),
471
+ emptyState: document.getElementById("emptyState"),
472
+ feelingValue: document.getElementById("feelingValue"),
473
+ feelingConfidence: document.getElementById("feelingConfidence"),
474
+ genderValue: document.getElementById("genderValue"),
475
+ genderConfidence: document.getElementById("genderConfidence"),
476
+ ageValue: document.getElementById("ageValue"),
477
+ facesValue: document.getElementById("facesValue"),
478
+ faceScore: document.getElementById("faceScore"),
479
+ fpsValue: document.getElementById("fpsValue"),
480
+ latencyValue: document.getElementById("latencyValue"),
481
+ emotionBars: document.getElementById("emotionBars"),
482
+ toast: document.getElementById("toast"),
483
+ };
484
+
485
+ const modes = {
486
+ fast: { inputSize: 224, scoreThreshold: 0.48, interval: 35 },
487
+ balanced: { inputSize: 320, scoreThreshold: 0.45, interval: 55 },
488
+ accurate: { inputSize: 416, scoreThreshold: 0.4, interval: 90 },
489
+ };
490
+
491
+ const emotionLabels = ["Happy", "Sad", "Fear", "Anger", "Confused", "Disgust"];
492
+ const ctx = els.overlay.getContext("2d");
493
+ let modelsReady = false;
494
+ let stream = null;
495
+ let running = false;
496
+ let detecting = false;
497
+ let currentFacing = "user";
498
+ let lastLoopTime = performance.now();
499
+ let fpsSmooth = 0;
500
+ let lastDetections = [];
501
+
502
+ function setModelStatus(text, kind = "loading") {
503
+ els.modelStatus.textContent = text;
504
+ els.modelDot.classList.remove("ready", "error");
505
+ if (kind === "ready") els.modelDot.classList.add("ready");
506
+ if (kind === "error") els.modelDot.classList.add("error");
507
+ }
508
+
509
+ function showToast(message) {
510
+ els.toast.textContent = message;
511
+ els.toast.classList.add("show");
512
+ clearTimeout(showToast.timer);
513
+ showToast.timer = setTimeout(() => els.toast.classList.remove("show"), 3200);
514
+ }
515
+
516
+ function percent(value) {
517
+ return `${Math.round(Math.max(0, Math.min(1, value || 0)) * 100)}%`;
518
+ }
519
+
520
+ function ageRange(age) {
521
+ if (!Number.isFinite(age)) return "—";
522
+ const a = Math.max(0, Math.round(age));
523
+ if (a <= 12) return "0-12";
524
+ if (a <= 17) return "13-17";
525
+ if (a <= 24) return "18-24";
526
+ if (a <= 34) return "25-34";
527
+ if (a <= 44) return "35-44";
528
+ if (a <= 54) return "45-54";
529
+ if (a <= 64) return "55-64";
530
+ return "65+";
531
+ }
532
+
533
+ function makeEmotionScores(expressions = {}) {
534
+ const raw = {
535
+ happy: expressions.happy || 0,
536
+ sad: expressions.sad || 0,
537
+ fearful: expressions.fearful || 0,
538
+ angry: expressions.angry || 0,
539
+ disgusted: expressions.disgusted || 0,
540
+ surprised: expressions.surprised || 0,
541
+ neutral: expressions.neutral || 0,
542
+ };
543
+
544
+ const mapped = {
545
+ Happy: raw.happy,
546
+ Sad: raw.sad,
547
+ Fear: raw.fearful,
548
+ Anger: raw.angry,
549
+ Disgust: raw.disgusted,
550
+ Confused: Math.max(raw.surprised, raw.neutral * 0.86),
551
+ };
552
+
553
+ const sorted = Object.entries(mapped).sort((a, b) => b[1] - a[1]);
554
+ const top = sorted[0] || ["Confused", 0];
555
+ const second = sorted[1] || ["", 0];
556
+ if ((top[1] - second[1]) < 0.08 || top[1] < 0.34) {
557
+ return { label: "Confused", score: Math.max(mapped.Confused, 0.42), mapped };
558
+ }
559
+ return { label: top[0], score: top[1], mapped };
560
+ }
561
+
562
+ function makeInsight(det) {
563
+ const emotion = makeEmotionScores(det.expressions);
564
+ const genderRaw = (det.gender || "").toLowerCase() === "female" ? "Female" : "Male";
565
+ return {
566
+ emotionLabel: emotion.label,
567
+ emotionScore: emotion.score,
568
+ emotionScores: emotion.mapped,
569
+ gender: genderRaw,
570
+ genderScore: det.genderProbability || 0,
571
+ ageRange: ageRange(det.age),
572
+ faceScore: det.detection && det.detection.score ? det.detection.score : 0,
573
+ };
574
+ }
575
+
576
+ function renderBars(scores = {}) {
577
+ els.emotionBars.innerHTML = emotionLabels.map(label => {
578
+ const value = scores[label] || 0;
579
+ return `
580
+ <div class="bar-row">
581
+ <span>${label}</span>
582
+ <div class="bar-track"><div class="bar-fill" style="width:${Math.round(value * 100)}%"></div></div>
583
+ <span>${percent(value)}</span>
584
+ </div>`;
585
+ }).join("");
586
+ }
587
+
588
+ function resetDetails() {
589
+ els.feelingValue.textContent = "—";
590
+ els.feelingConfidence.textContent = "Waiting";
591
+ els.genderValue.textContent = "—";
592
+ els.genderConfidence.textContent = "Waiting";
593
+ els.ageValue.textContent = "—";
594
+ els.facesValue.textContent = "0";
595
+ els.faceScore.textContent = "No face yet";
596
+ renderBars({});
597
+ }
598
+
599
+ function choosePrimary(detections) {
600
+ if (!detections || !detections.length) return null;
601
+ return detections.slice().sort((a, b) => {
602
+ const ab = a.detection.box;
603
+ const bb = b.detection.box;
604
+ return (bb.width * bb.height) - (ab.width * ab.height);
605
+ })[0];
606
+ }
607
+
608
+ function updateDetails(detections, elapsedMs) {
609
+ els.facesValue.textContent = String(detections.length);
610
+ els.latencyValue.textContent = `${Math.round(elapsedMs)}ms`;
611
+
612
+ const now = performance.now();
613
+ const instantFps = 1000 / Math.max(1, now - lastLoopTime);
614
+ lastLoopTime = now;
615
+ fpsSmooth = fpsSmooth ? (fpsSmooth * 0.82 + instantFps * 0.18) : instantFps;
616
+ els.fpsValue.textContent = fpsSmooth.toFixed(1);
617
+
618
+ const primary = choosePrimary(detections);
619
+ if (!primary) {
620
+ els.feelingValue.textContent = "—";
621
+ els.feelingConfidence.textContent = "No face";
622
+ els.genderValue.textContent = "—";
623
+ els.genderConfidence.textContent = "No face";
624
+ els.ageValue.textContent = "—";
625
+ els.faceScore.textContent = "No face yet";
626
+ renderBars({});
627
+ return;
628
+ }
629
+
630
+ const insight = makeInsight(primary);
631
+ els.feelingValue.textContent = insight.emotionLabel;
632
+ els.feelingConfidence.textContent = `${percent(insight.emotionScore)} confidence`;
633
+ els.genderValue.textContent = insight.gender;
634
+ els.genderConfidence.textContent = `${percent(insight.genderScore)} confidence`;
635
+ els.ageValue.textContent = insight.ageRange;
636
+ els.faceScore.textContent = `${percent(insight.faceScore)} face score`;
637
+ renderBars(insight.emotionScores);
638
+ }
639
+
640
+ function drawRoundRect(context, x, y, w, h, r) {
641
+ const radius = Math.min(r, w / 2, h / 2);
642
+ context.beginPath();
643
+ context.moveTo(x + radius, y);
644
+ context.arcTo(x + w, y, x + w, y + h, radius);
645
+ context.arcTo(x + w, y + h, x, y + h, radius);
646
+ context.arcTo(x, y + h, x, y, radius);
647
+ context.arcTo(x, y, x + w, y, radius);
648
+ context.closePath();
649
+ }
650
+
651
+ function drawCornerBox(x, y, w, h) {
652
+ const line = Math.max(4, els.overlay.width / 180);
653
+ const len = Math.max(26, Math.min(w, h) * 0.22);
654
+ ctx.lineWidth = line;
655
+ ctx.strokeStyle = "#22d3ee";
656
+ ctx.shadowColor = "rgba(34,211,238,0.85)";
657
+ ctx.shadowBlur = 14;
658
+ ctx.beginPath();
659
+ ctx.moveTo(x, y + len); ctx.lineTo(x, y); ctx.lineTo(x + len, y);
660
+ ctx.moveTo(x + w - len, y); ctx.lineTo(x + w, y); ctx.lineTo(x + w, y + len);
661
+ ctx.moveTo(x + w, y + h - len); ctx.lineTo(x + w, y + h); ctx.lineTo(x + w - len, y + h);
662
+ ctx.moveTo(x + len, y + h); ctx.lineTo(x, y + h); ctx.lineTo(x, y + h - len);
663
+ ctx.stroke();
664
+ ctx.shadowBlur = 0;
665
+
666
+ ctx.lineWidth = Math.max(2, line * 0.55);
667
+ ctx.strokeStyle = "rgba(255,255,255,0.95)";
668
+ drawRoundRect(ctx, x, y, w, h, 16);
669
+ ctx.stroke();
670
+ }
671
+
672
+ function drawLabel(x, y, w, insight) {
673
+ const scale = Math.max(1, els.overlay.width / 850);
674
+ const padX = 12 * scale;
675
+ const padY = 8 * scale;
676
+ const font1 = Math.round(18 * scale);
677
+ const font2 = Math.round(14 * scale);
678
+ const lineH = font1 + 9 * scale;
679
+ const label1 = `${insight.emotionLabel} ${percent(insight.emotionScore)}`;
680
+ const label2 = `${insight.gender} ${percent(insight.genderScore)} · Age ${insight.ageRange}`;
681
+
682
+ ctx.font = `900 ${font1}px Inter, system-ui, sans-serif`;
683
+ const textW = Math.max(ctx.measureText(label1).width, ctx.measureText(label2).width);
684
+ const boxW = Math.min(Math.max(textW + padX * 2, w * 0.72), els.overlay.width - 16);
685
+ const boxH = lineH * 2 + padY * 1.6;
686
+ const bx = Math.max(8, Math.min(x, els.overlay.width - boxW - 8));
687
+ const by = Math.max(8, y - boxH - 8);
688
+
689
+ drawRoundRect(ctx, bx, by, boxW, boxH, 14 * scale);
690
+ ctx.fillStyle = "rgba(2, 6, 23, 0.84)";
691
+ ctx.fill();
692
+ ctx.strokeStyle = "rgba(34, 211, 238, 0.65)";
693
+ ctx.lineWidth = 1.5 * scale;
694
+ ctx.stroke();
695
+
696
+ ctx.fillStyle = "#ecfeff";
697
+ ctx.font = `900 ${font1}px Inter, system-ui, sans-serif`;
698
+ ctx.fillText(label1, bx + padX, by + padY + font1);
699
+ ctx.fillStyle = "#a7b4c8";
700
+ ctx.font = `800 ${font2}px Inter, system-ui, sans-serif`;
701
+ ctx.fillText(label2, bx + padX, by + padY + font1 + lineH);
702
+ }
703
+
704
+ function drawDetections(detections) {
705
+ ctx.clearRect(0, 0, els.overlay.width, els.overlay.height);
706
+ lastDetections = detections;
707
+ for (const det of detections) {
708
+ const box = det.detection.box;
709
+ const x = Math.max(0, box.x);
710
+ const y = Math.max(0, box.y);
711
+ const w = Math.min(box.width, els.overlay.width - x);
712
+ const h = Math.min(box.height, els.overlay.height - y);
713
+ const insight = makeInsight(det);
714
+ drawCornerBox(x, y, w, h);
715
+ drawLabel(x, y, w, insight);
716
+ }
717
+ }
718
+
719
+ function fitCanvas() {
720
+ const vw = els.video.videoWidth || 640;
721
+ const vh = els.video.videoHeight || 480;
722
+ if (els.overlay.width !== vw || els.overlay.height !== vh) {
723
+ els.overlay.width = vw;
724
+ els.overlay.height = vh;
725
+ }
726
+ els.stage.style.aspectRatio = `${vw} / ${vh}`;
727
+ if (lastDetections.length) drawDetections(lastDetections);
728
+ }
729
+
730
+ async function loadModels() {
731
+ try {
732
+ const waitForFaceApi = () => new Promise((resolve, reject) => {
733
+ const started = performance.now();
734
+ const tick = () => {
735
+ if (window.faceapi) return resolve();
736
+ if (performance.now() - started > 12000) return reject(new Error("face-api.js did not load"));
737
+ setTimeout(tick, 80);
738
+ };
739
+ tick();
740
+ });
741
+
742
+ await waitForFaceApi();
743
+ await Promise.all([
744
+ faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
745
+ faceapi.nets.faceLandmark68TinyNet.loadFromUri(MODEL_URL),
746
+ faceapi.nets.faceExpressionNet.loadFromUri(MODEL_URL),
747
+ faceapi.nets.ageGenderNet.loadFromUri(MODEL_URL),
748
+ ]);
749
+ modelsReady = true;
750
+ setModelStatus("AI models ready", "ready");
751
+ els.startBtn.disabled = false;
752
+ renderBars({});
753
+ } catch (err) {
754
+ console.error(err);
755
+ setModelStatus("Model loading failed", "error");
756
+ showToast("Model loading failed. Refresh the Space and check that the browser can access the CDN.");
757
+ }
758
+ }
759
+
760
+ function stopStream() {
761
+ if (stream) {
762
+ for (const track of stream.getTracks()) track.stop();
763
+ }
764
+ stream = null;
765
+ running = false;
766
+ detecting = false;
767
+ els.video.srcObject = null;
768
+ ctx.clearRect(0, 0, els.overlay.width, els.overlay.height);
769
+ els.emptyState.style.display = "grid";
770
+ els.startBtn.disabled = !modelsReady;
771
+ els.switchBtn.disabled = true;
772
+ els.stopBtn.disabled = true;
773
+ els.cameraTag.textContent = "Camera: stopped";
774
+ resetDetails();
775
+ }
776
+
777
+ async function startCamera(facing = currentFacing) {
778
+ if (!modelsReady) {
779
+ showToast("Please wait until the AI models finish loading.");
780
+ return;
781
+ }
782
+
783
+ stopStream();
784
+ currentFacing = facing;
785
+ els.cameraTag.textContent = `Camera: ${currentFacing === "user" ? "front" : "rear"} requested`;
786
+
787
+ const constraints = {
788
+ audio: false,
789
+ video: {
790
+ facingMode: { ideal: currentFacing },
791
+ width: { ideal: 960 },
792
+ height: { ideal: 720 },
793
+ }
794
+ };
795
+
796
+ try {
797
+ stream = await navigator.mediaDevices.getUserMedia(constraints);
798
+ } catch (firstErr) {
799
+ console.warn("Preferred camera failed, falling back", firstErr);
800
+ try {
801
+ stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: true });
802
+ } catch (err) {
803
+ console.error(err);
804
+ showToast("Camera permission failed. Allow camera access in your browser settings.");
805
+ els.cameraTag.textContent = "Camera: permission needed";
806
+ return;
807
+ }
808
+ }
809
+
810
+ els.video.srcObject = stream;
811
+ await els.video.play();
812
+ fitCanvas();
813
+ running = true;
814
+ els.emptyState.style.display = "none";
815
+ els.startBtn.disabled = true;
816
+ els.switchBtn.disabled = false;
817
+ els.stopBtn.disabled = false;
818
+ els.cameraTag.textContent = `Camera: ${currentFacing === "user" ? "front" : "rear"}`;
819
+ lastLoopTime = performance.now();
820
+ fpsSmooth = 0;
821
+ detectLoop();
822
+ }
823
+
824
+ async function detectLoop() {
825
+ if (!running || !modelsReady || detecting) return;
826
+ detecting = true;
827
+ const started = performance.now();
828
+ try {
829
+ fitCanvas();
830
+ const cfg = modes[els.modeSelect.value] || modes.balanced;
831
+ const options = new faceapi.TinyFaceDetectorOptions({ inputSize: cfg.inputSize, scoreThreshold: cfg.scoreThreshold });
832
+ const raw = await faceapi
833
+ .detectAllFaces(els.video, options)
834
+ .withFaceLandmarks(true)
835
+ .withFaceExpressions()
836
+ .withAgeAndGender();
837
+ const displaySize = { width: els.overlay.width, height: els.overlay.height };
838
+ const resized = faceapi.resizeResults(raw, displaySize);
839
+ const elapsed = performance.now() - started;
840
+ drawDetections(resized);
841
+ updateDetails(resized, elapsed);
842
+ setTimeout(() => {
843
+ detecting = false;
844
+ requestAnimationFrame(detectLoop);
845
+ }, cfg.interval);
846
+ } catch (err) {
847
+ console.error(err);
848
+ detecting = false;
849
+ showToast("Live analysis hit an error. Restarting camera may fix it.");
850
+ setTimeout(() => requestAnimationFrame(detectLoop), 250);
851
+ }
852
+ }
853
+
854
+ els.startBtn.addEventListener("click", () => startCamera(currentFacing));
855
+ els.switchBtn.addEventListener("click", () => {
856
+ currentFacing = currentFacing === "user" ? "environment" : "user";
857
+ startCamera(currentFacing);
858
+ });
859
+ els.stopBtn.addEventListener("click", stopStream);
860
+ els.video.addEventListener("loadedmetadata", fitCanvas);
861
+ window.addEventListener("resize", fitCanvas);
862
+
863
+ resetDetails();
864
+ renderBars({});
865
+ loadModels();
866
+ </script>
867
+ </body>
868
+ </html>