Solar-Prince commited on
Commit
94cea84
·
verified ·
1 Parent(s): a50ab29

Upload 2 files

Browse files
Files changed (2) hide show
  1. README.md +13 -23
  2. index.html +527 -123
README.md CHANGED
@@ -1,36 +1,26 @@
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.
 
1
  ---
2
  title: SentAI
3
+ emoji: 🤖
4
  colorFrom: blue
5
+ colorTo: purple
6
  sdk: static
 
7
  pinned: false
8
+ short_description: Live face expression age and gender estimates
 
 
 
 
 
9
  ---
10
 
11
  # SentAI
12
 
13
+ SentAI is a browser-based live camera application for face detection, expression scoring, apparent age range, and male/female presentation estimate.
14
 
15
+ ## Phase 3 changes
16
 
17
+ - Keeps the fast on-device face box loop.
18
+ - Adds a background accuracy pack using Transformers.js-compatible ONNX models.
19
+ - Uses temporal smoothing so expression scores and age range do not flicker frame-by-frame.
20
+ - Reduces the old neutral-to-confused behavior so "Confused" is not overconfident just because the face is neutral.
21
+ - Adds an age offset control for quick calibration during demos.
22
+ - Supports mobile camera permission and front/rear camera switching when the browser exposes it.
23
 
24
+ ## Notes
 
 
 
 
 
 
 
25
 
26
+ The app estimates visible facial expression and apparent age from camera frames. It cannot know a person's true internal feeling. Lighting, pose, camera quality, glasses, occlusion, and model bias can affect results.
 
 
index.html CHANGED
@@ -10,9 +10,9 @@
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);
@@ -34,7 +34,7 @@
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
  }
@@ -52,7 +52,7 @@
52
  }
53
 
54
  .app-shell {
55
- width: min(1440px, calc(100% - 32px));
56
  margin: 0 auto;
57
  padding: 34px 0 42px;
58
  position: relative;
@@ -72,31 +72,37 @@
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 {
@@ -121,12 +127,12 @@
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;
@@ -135,7 +141,8 @@
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
 
@@ -148,12 +155,12 @@
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
  }
@@ -189,13 +196,8 @@
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;
@@ -209,9 +211,9 @@
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
  }
@@ -254,7 +256,7 @@
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
  }
@@ -271,19 +273,19 @@
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);
@@ -298,7 +300,7 @@
298
  margin: 11px 0;
299
  color: var(--muted);
300
  font-size: 0.9rem;
301
- font-weight: 800;
302
  }
303
 
304
  .bar-track {
@@ -316,12 +318,25 @@
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
  }
@@ -339,9 +354,9 @@
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
 
@@ -350,6 +365,7 @@
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; }
@@ -363,6 +379,7 @@
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; }
@@ -374,19 +391,23 @@
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>
@@ -399,7 +420,7 @@
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>
@@ -421,7 +442,7 @@
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>
@@ -431,12 +452,12 @@
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
 
@@ -445,8 +466,17 @@
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>
@@ -454,14 +484,20 @@
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"),
@@ -474,23 +510,28 @@
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;
@@ -498,77 +539,230 @@
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
  }
@@ -586,13 +780,19 @@
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
 
@@ -608,6 +808,7 @@
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);
@@ -622,17 +823,20 @@
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
  }
@@ -727,33 +931,227 @@
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
 
@@ -767,7 +1165,7 @@
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";
@@ -775,8 +1173,8 @@
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
 
@@ -822,12 +1220,12 @@
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)
@@ -856,13 +1254,19 @@
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>
 
10
  :root {
11
  --bg-a: #050816;
12
  --bg-b: #0d1b2f;
13
+ --card: rgba(15, 23, 42, 0.76);
14
+ --card-strong: rgba(15, 23, 42, 0.94);
15
+ --stroke: rgba(148, 163, 184, 0.24);
16
  --text: #f8fafc;
17
  --muted: #a7b4c8;
18
  --soft: rgba(255,255,255,0.08);
 
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 92% 2%, rgba(167, 139, 250, 0.23), transparent 34%),
38
  linear-gradient(135deg, var(--bg-a), var(--bg-b) 52%, #112236);
39
  overflow-x: hidden;
40
  }
 
52
  }
53
 
54
  .app-shell {
55
+ width: min(1460px, calc(100% - 32px));
56
  margin: 0 auto;
57
  padding: 34px 0 42px;
58
  position: relative;
 
72
  font-size: clamp(3rem, 8vw, 7rem);
73
  line-height: 0.92;
74
  letter-spacing: -0.08em;
75
+ font-weight: 950;
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: 920px;
82
  color: var(--muted);
83
+ font-size: clamp(1rem, 1.8vw, 1.24rem);
84
  line-height: 1.55;
85
  }
86
 
87
+ .status-stack {
88
+ display: grid;
89
+ gap: 10px;
90
+ justify-items: end;
91
+ }
92
+
93
  .status-pill {
94
  display: inline-flex;
95
  align-items: center;
96
  gap: 10px;
97
  border: 1px solid var(--stroke);
98
+ background: rgba(15, 23, 42, 0.64);
99
  backdrop-filter: blur(18px);
100
  border-radius: 999px;
101
  padding: 12px 16px;
102
  color: var(--muted);
103
  box-shadow: var(--shadow);
104
  white-space: nowrap;
105
+ font-weight: 800;
106
  }
107
 
108
  .dot {
 
127
  button, select {
128
  appearance: none;
129
  border: 1px solid var(--stroke);
130
+ background: rgba(15, 23, 42, 0.76);
131
  color: var(--text);
132
  border-radius: 999px;
133
  padding: 13px 18px;
134
  font-size: 0.98rem;
135
+ font-weight: 850;
136
  letter-spacing: -0.01em;
137
  cursor: pointer;
138
  transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, opacity 160ms ease;
 
141
 
142
  button:hover, select:hover { transform: translateY(-1px); border-color: rgba(34, 211, 238, 0.55); }
143
  button:active { transform: translateY(0); }
144
+ button.primary { background: linear-gradient(135deg, rgba(34,211,238,0.96), rgba(167,139,250,0.94)); color: #06111f; border-color: transparent; }
145
+ button.accent { color: #cffafe; border-color: rgba(34, 211, 238, 0.4); }
146
  button.danger { color: #fecdd3; }
147
  button:disabled { opacity: 0.46; cursor: not-allowed; transform: none; }
148
 
 
155
  border: 1px solid var(--stroke);
156
  background: rgba(255,255,255,0.055);
157
  color: var(--muted);
158
+ font-weight: 850;
159
  }
160
 
161
  .grid {
162
  display: grid;
163
+ grid-template-columns: minmax(0, 1.44fr) minmax(360px, 0.74fr);
164
  gap: 18px;
165
  align-items: stretch;
166
  }
 
196
  object-fit: contain;
197
  }
198
 
199
+ video { background: #020617; }
200
+ canvas#overlay { pointer-events: none; }
 
 
 
 
 
201
 
202
  .empty-state {
203
  position: absolute;
 
211
  }
212
 
213
  .empty-card {
214
+ max-width: 600px;
215
  border: 1px solid var(--stroke);
216
+ background: rgba(15,23,42,0.7);
217
  border-radius: 24px;
218
  padding: 28px;
219
  }
 
256
  display: block;
257
  color: var(--muted);
258
  font-size: 0.82rem;
259
+ font-weight: 850;
260
  text-transform: uppercase;
261
  letter-spacing: 0.08em;
262
  }
 
273
  display: block;
274
  margin-top: 7px;
275
  color: var(--muted);
276
+ font-weight: 750;
277
  }
278
 
279
  .wide { grid-column: 1 / -1; }
280
 
281
+ .bars, .note, .calibration {
282
  border: 1px solid var(--stroke);
283
  background: rgba(255,255,255,0.055);
284
  border-radius: 22px;
285
  padding: 16px;
286
  }
287
 
288
+ .bars h3, .calibration h3 {
289
  margin: 0 0 14px;
290
  font-size: 1rem;
291
  color: var(--text);
 
300
  margin: 11px 0;
301
  color: var(--muted);
302
  font-size: 0.9rem;
303
+ font-weight: 850;
304
  }
305
 
306
  .bar-track {
 
318
  transition: width 180ms ease;
319
  }
320
 
321
+ .calibration-row {
322
+ display: grid;
323
+ grid-template-columns: 90px 1fr 60px;
324
+ gap: 12px;
325
+ align-items: center;
326
+ color: var(--muted);
327
+ font-weight: 800;
328
+ margin-top: 10px;
329
+ }
330
+
331
+ input[type="range"] {
332
+ width: 100%;
333
+ accent-color: var(--accent);
334
+ }
335
+
336
  .note {
 
 
337
  color: #cffafe;
338
+ background: rgba(34,211,238,0.08);
339
+ border-color: rgba(34,211,238,0.24);
340
  line-height: 1.5;
341
  font-size: 0.94rem;
342
  }
 
354
  padding: 13px 18px;
355
  box-shadow: var(--shadow);
356
  color: var(--text);
357
+ font-weight: 850;
358
  transition: opacity 180ms ease, transform 180ms ease;
359
+ max-width: min(92vw, 760px);
360
  text-align: center;
361
  }
362
 
 
365
  @media (max-width: 980px) {
366
  .app-shell { width: min(100% - 20px, 760px); padding-top: 22px; }
367
  header.hero { grid-template-columns: 1fr; }
368
+ .status-stack { justify-items: start; }
369
  .status-pill { width: fit-content; }
370
  .grid { grid-template-columns: 1fr; }
371
  .stage-panel { padding: 10px; }
 
379
  .brand p { font-size: 0.98rem; }
380
  .metric-grid { grid-template-columns: 1fr; }
381
  .bar-row { grid-template-columns: 76px 1fr 44px; font-size: 0.82rem; }
382
+ .calibration-row { grid-template-columns: 78px 1fr 52px; }
383
  .empty-card { padding: 20px; }
384
  .app-shell { width: min(100% - 14px, 760px); }
385
  body { background-attachment: fixed; }
 
391
  <header class="hero" aria-label="SentAI heading">
392
  <div class="brand">
393
  <h1>SentAI</h1>
394
+ <p>Higher-accuracy live face analysis with stabilized expression scores, apparent age range, and male/female presentation estimate. The fast face box runs continuously; heavier transformer models run on cropped faces in the background.</p>
395
+ </div>
396
+ <div class="status-stack">
397
+ <div class="status-pill" aria-live="polite"><span id="coreDot" class="dot"></span><span id="coreStatus">Loading core models...</span></div>
398
+ <div class="status-pill" aria-live="polite"><span id="proDot" class="dot"></span><span id="proStatus">Accuracy pack: idle</span></div>
399
  </div>
 
400
  </header>
401
 
402
  <section class="toolbar" aria-label="Camera controls">
403
  <button id="startBtn" class="primary" disabled>Start camera</button>
404
  <button id="switchBtn" disabled>Switch front/rear</button>
405
+ <button id="accuracyBtn" class="accent" disabled>Load accuracy pack</button>
406
  <button id="stopBtn" class="danger" disabled>Stop</button>
407
  <select id="modeSelect" aria-label="Performance mode">
408
  <option value="fast">Fast mode</option>
409
+ <option value="balanced">Balanced mode</option>
410
+ <option value="accurate" selected>Accurate mode</option>
411
  </select>
412
  <span id="cameraTag" class="camera-tag">Camera: not started</span>
413
  </section>
 
420
  <div id="emptyState" class="empty-state">
421
  <div class="empty-card">
422
  <strong>Ready for live analysis</strong>
423
+ Tap <b>Start camera</b>. For best accuracy, use strong front lighting, keep one face centered, and load the <b>accuracy pack</b>. On phones, use <b>Switch front/rear</b> when supported by the browser.
424
  </div>
425
  </div>
426
  </div>
 
442
  <div class="metric">
443
  <span>Apparent age</span>
444
  <strong id="ageValue">—</strong>
445
+ <small id="ageSource">Waiting</small>
446
  </div>
447
  <div class="metric">
448
  <span>Faces</span>
 
452
  <div class="metric">
453
  <span>FPS</span>
454
  <strong id="fpsValue">—</strong>
455
+ <small>Detection loop</small>
456
  </div>
457
  <div class="metric">
458
  <span>Latency</span>
459
  <strong id="latencyValue">—</strong>
460
+ <small id="latencySource">Core model time</small>
461
  </div>
462
  </div>
463
 
 
466
  <div id="emotionBars"></div>
467
  </div>
468
 
469
+ <div class="calibration wide">
470
+ <h3>Age fine tune</h3>
471
+ <div class="calibration-row">
472
+ <span>Offset</span>
473
+ <input id="ageOffset" type="range" min="-12" max="12" value="0" step="1" aria-label="Age offset calibration" />
474
+ <b id="ageOffsetValue">0y</b>
475
+ </div>
476
+ </div>
477
+
478
  <div class="note wide">
479
+ The app estimates visible facial expression and apparent age from camera frames. It cannot know a person's true internal feeling. The accuracy pack improves expression and age estimates but may download larger ONNX model files the first time.
480
  </div>
481
  </aside>
482
  </section>
 
484
 
485
  <div id="toast" class="toast" role="status" aria-live="polite"></div>
486
 
487
+ <script type="module">
488
+ const FACE_API_MODEL_URL = "https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights";
489
+ const TRANSFORMERS_CDN = "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0";
490
+ const EMOTION_MODEL_ID = "Xenova/facial_emotions_image_detection";
491
+ const AGE_GENDER_MODEL_ID = "onnx-community/age-gender-prediction-ONNX";
492
 
493
  const els = {
494
+ coreDot: document.getElementById("coreDot"),
495
+ coreStatus: document.getElementById("coreStatus"),
496
+ proDot: document.getElementById("proDot"),
497
+ proStatus: document.getElementById("proStatus"),
498
  startBtn: document.getElementById("startBtn"),
499
  switchBtn: document.getElementById("switchBtn"),
500
+ accuracyBtn: document.getElementById("accuracyBtn"),
501
  stopBtn: document.getElementById("stopBtn"),
502
  modeSelect: document.getElementById("modeSelect"),
503
  cameraTag: document.getElementById("cameraTag"),
 
510
  genderValue: document.getElementById("genderValue"),
511
  genderConfidence: document.getElementById("genderConfidence"),
512
  ageValue: document.getElementById("ageValue"),
513
+ ageSource: document.getElementById("ageSource"),
514
  facesValue: document.getElementById("facesValue"),
515
  faceScore: document.getElementById("faceScore"),
516
  fpsValue: document.getElementById("fpsValue"),
517
  latencyValue: document.getElementById("latencyValue"),
518
+ latencySource: document.getElementById("latencySource"),
519
  emotionBars: document.getElementById("emotionBars"),
520
+ ageOffset: document.getElementById("ageOffset"),
521
+ ageOffsetValue: document.getElementById("ageOffsetValue"),
522
  toast: document.getElementById("toast"),
523
  };
524
 
525
  const modes = {
526
+ fast: { inputSize: 224, scoreThreshold: 0.52, interval: 22, proInterval: 2800, smoothing: 0.18 },
527
+ balanced: { inputSize: 320, scoreThreshold: 0.45, interval: 42, proInterval: 2100, smoothing: 0.24 },
528
+ accurate: { inputSize: 416, scoreThreshold: 0.38, interval: 65, proInterval: 1550, smoothing: 0.30 },
529
  };
530
 
531
  const emotionLabels = ["Happy", "Sad", "Fear", "Anger", "Confused", "Disgust"];
532
  const ctx = els.overlay.getContext("2d");
533
+
534
+ let coreReady = false;
535
  let stream = null;
536
  let running = false;
537
  let detecting = false;
 
539
  let lastLoopTime = performance.now();
540
  let fpsSmooth = 0;
541
  let lastDetections = [];
542
+ let emotionEma = null;
543
+ let genderMaleEma = null;
544
+ let lastBaseAgeSample = 0;
545
+
546
+ const ageHistory = [];
547
+ const pro = {
548
+ loading: false,
549
+ tried: false,
550
+ emotionPipe: null,
551
+ ageGenderModel: null,
552
+ ageGenderProcessor: null,
553
+ loadImage: null,
554
+ lastRun: 0,
555
+ busy: false,
556
+ emotionScores: null,
557
+ emotionAt: 0,
558
+ age: null,
559
+ ageAt: 0,
560
+ gender: null,
561
+ genderScore: 0,
562
+ genderAt: 0,
563
+ latency: 0,
564
+ device: "wasm",
565
+ };
566
 
567
+ function setPill(dot, label, text, kind = "loading") {
568
+ label.textContent = text;
569
+ dot.classList.remove("ready", "error");
570
+ if (kind === "ready") dot.classList.add("ready");
571
+ if (kind === "error") dot.classList.add("error");
572
  }
573
 
574
  function showToast(message) {
575
  els.toast.textContent = message;
576
  els.toast.classList.add("show");
577
  clearTimeout(showToast.timer);
578
+ showToast.timer = setTimeout(() => els.toast.classList.remove("show"), 3600);
579
+ }
580
+
581
+ function clamp01(value) {
582
+ return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
583
  }
584
 
585
  function percent(value) {
586
+ return `${Math.round(clamp01(value) * 100)}%`;
587
+ }
588
+
589
+ function sigmoid(x) {
590
+ return 1 / (1 + Math.exp(-x));
591
+ }
592
+
593
+ function median(values) {
594
+ const list = values.filter(Number.isFinite).slice().sort((a, b) => a - b);
595
+ if (!list.length) return NaN;
596
+ const mid = Math.floor(list.length / 2);
597
+ return list.length % 2 ? list[mid] : (list[mid - 1] + list[mid]) / 2;
598
  }
599
 
600
+ function ageRange(age, source = "core", samples = 1) {
601
  if (!Number.isFinite(age)) return "—";
602
+ const offset = Number(els.ageOffset.value || 0);
603
+ const corrected = Math.max(0, Math.min(100, age + offset));
604
+ const half = source === "accuracy pack" ? (samples >= 3 ? 4 : 5) : (samples >= 6 ? 6 : 8);
605
+ const lo = Math.max(0, Math.round(corrected - half));
606
+ const hi = Math.min(100, Math.round(corrected + half));
607
+ if (hi <= 12) return "0-12";
608
+ return `${lo}-${hi}`;
609
+ }
610
+
611
+ function blankScores() {
612
+ return Object.fromEntries(emotionLabels.map(label => [label, 0]));
613
+ }
614
+
615
+ function normalizeScores(scores) {
616
+ const out = blankScores();
617
+ for (const label of emotionLabels) out[label] = clamp01(scores[label] || 0);
618
+ return out;
619
+ }
620
+
621
+ function calibrateFaceApiExpressions(expressions = {}) {
622
  const raw = {
623
+ happy: clamp01(expressions.happy),
624
+ sad: clamp01(expressions.sad),
625
+ fearful: clamp01(expressions.fearful),
626
+ angry: clamp01(expressions.angry),
627
+ disgusted: clamp01(expressions.disgusted),
628
+ surprised: clamp01(expressions.surprised),
629
+ neutral: clamp01(expressions.neutral),
630
  };
631
 
632
+ const nonNeutralTop = Math.max(raw.happy, raw.sad, raw.fearful, raw.angry, raw.disgusted, raw.surprised);
633
+ const uncertainty = clamp01((1 - nonNeutralTop) * 0.28 + raw.surprised * 0.54 + raw.neutral * 0.16);
634
+
635
+ return normalizeScores({
636
+ Happy: Math.pow(raw.happy, 1.08),
637
+ Sad: Math.pow(raw.sad, 1.02),
638
+ Fear: Math.max(Math.pow(raw.fearful, 1.04), raw.surprised * raw.fearful * 0.55),
639
+ Anger: Math.pow(raw.angry, 1.03),
640
+ Disgust: Math.pow(raw.disgusted, 1.02),
641
+ Confused: Math.min(0.62, uncertainty),
642
+ });
643
+ }
644
+
645
+ function normalizeExternalEmotion(outputs) {
646
+ const scores = blankScores();
647
+ const list = Array.isArray(outputs) ? outputs : [outputs];
648
+
649
+ for (const item of list) {
650
+ const label = String(item.label || item.class || "").toLowerCase();
651
+ const score = clamp01(item.score || item.probability || 0);
652
+ if (label.includes("happy") || label.includes("joy")) scores.Happy = Math.max(scores.Happy, score);
653
+ else if (label.includes("sad")) scores.Sad = Math.max(scores.Sad, score);
654
+ else if (label.includes("fear")) scores.Fear = Math.max(scores.Fear, score);
655
+ else if (label.includes("angry") || label.includes("anger")) scores.Anger = Math.max(scores.Anger, score);
656
+ else if (label.includes("disgust")) scores.Disgust = Math.max(scores.Disgust, score);
657
+ else if (label.includes("surprise") || label.includes("neutral")) {
658
+ const scaled = label.includes("neutral") ? score * 0.30 : score * 0.72;
659
+ scores.Confused = Math.max(scores.Confused, Math.min(0.68, scaled));
660
+ }
661
+ }
662
+ return normalizeScores(scores);
663
+ }
664
+
665
+ function combineEmotionScores(faceScores) {
666
+ const now = performance.now();
667
+ const freshPro = pro.emotionScores && (now - pro.emotionAt < 6500);
668
+ const combined = blankScores();
669
+ for (const label of emotionLabels) {
670
+ const base = faceScores[label] || 0;
671
+ const proValue = freshPro ? (pro.emotionScores[label] || 0) : 0;
672
+ combined[label] = freshPro ? (proValue * 0.76 + base * 0.24) : base;
673
+ }
674
+
675
+ const cfg = modes[els.modeSelect.value] || modes.accurate;
676
+ if (!emotionEma) {
677
+ emotionEma = combined;
678
+ } else {
679
+ for (const label of emotionLabels) {
680
+ emotionEma[label] = emotionEma[label] * (1 - cfg.smoothing) + combined[label] * cfg.smoothing;
681
+ }
682
+ }
683
+ return normalizeScores(emotionEma);
684
+ }
685
 
686
+ function topEmotion(scores) {
687
+ const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
688
  const top = sorted[0] || ["Confused", 0];
689
  const second = sorted[1] || ["", 0];
690
+ let label = top[0];
691
+ let score = top[1];
692
+ if (score < 0.22 || (score - second[1] < 0.055 && label !== "Confused")) {
693
+ label = "Confused";
694
+ score = Math.max(scores.Confused || 0, 0.24);
695
+ }
696
+ return { label, score: clamp01(score) };
697
+ }
698
+
699
+ function pushAgeSample(age, source, weight = 1) {
700
+ if (!Number.isFinite(age) || age < 0 || age > 100) return;
701
+ const now = performance.now();
702
+ ageHistory.push({ age, source, weight, t: now });
703
+ while (ageHistory.length > 40) ageHistory.shift();
704
+ const cutoff = now - 30000;
705
+ while (ageHistory.length && ageHistory[0].t < cutoff) ageHistory.shift();
706
+ }
707
+
708
+ function stableAgeEstimate() {
709
+ const proSamples = ageHistory.filter(s => s.source === "accuracy pack");
710
+ const usable = proSamples.length >= 2 ? proSamples : ageHistory;
711
+ if (!usable.length) return { age: NaN, source: "Waiting", samples: 0 };
712
+ const expanded = [];
713
+ for (const sample of usable) {
714
+ const repeats = Math.max(1, Math.round(sample.weight || 1));
715
+ for (let i = 0; i < repeats; i += 1) expanded.push(sample.age);
716
  }
717
+ return {
718
+ age: median(expanded),
719
+ source: proSamples.length >= 2 ? "accuracy pack" : "core model",
720
+ samples: usable.length,
721
+ };
722
+ }
723
+
724
+ function updateGenderEstimate(label, confidence, weight = 1) {
725
+ if (!label) return;
726
+ const pMale = label.toLowerCase() === "male" ? clamp01(confidence) : 1 - clamp01(confidence);
727
+ const alpha = Math.min(0.6, 0.16 * weight);
728
+ genderMaleEma = genderMaleEma === null ? pMale : genderMaleEma * (1 - alpha) + pMale * alpha;
729
+ }
730
+
731
+ function currentGender() {
732
+ if (genderMaleEma === null) return { label: "—", confidence: 0 };
733
+ const label = genderMaleEma >= 0.5 ? "Male" : "Female";
734
+ const confidence = Math.max(genderMaleEma, 1 - genderMaleEma);
735
+ return { label, confidence };
736
  }
737
 
738
  function makeInsight(det) {
739
+ const faceScores = calibrateFaceApiExpressions(det.expressions || {});
740
+ const scores = combineEmotionScores(faceScores);
741
+ const emotion = topEmotion(scores);
742
+
743
+ const now = performance.now();
744
+ if (Number.isFinite(det.age) && now - lastBaseAgeSample > 550) {
745
+ pushAgeSample(det.age, "core model", 1);
746
+ lastBaseAgeSample = now;
747
+ }
748
+
749
+ const coreGender = (det.gender || "").toLowerCase() === "female" ? "Female" : "Male";
750
+ updateGenderEstimate(coreGender, det.genderProbability || 0, 1);
751
+ if (pro.gender && (now - pro.genderAt < 8000)) {
752
+ updateGenderEstimate(pro.gender, pro.genderScore, 3.2);
753
+ }
754
+
755
+ const age = stableAgeEstimate();
756
+ const gender = currentGender();
757
+
758
  return {
759
  emotionLabel: emotion.label,
760
  emotionScore: emotion.score,
761
+ emotionScores: scores,
762
+ gender: gender.label,
763
+ genderScore: gender.confidence,
764
+ ageRange: ageRange(age.age, age.source, age.samples),
765
+ ageSource: age.samples ? `${age.source}, ${age.samples} samples` : "Waiting",
766
  faceScore: det.detection && det.detection.score ? det.detection.score : 0,
767
  };
768
  }
 
780
  }
781
 
782
  function resetDetails() {
783
+ emotionEma = null;
784
+ genderMaleEma = null;
785
+ ageHistory.length = 0;
786
  els.feelingValue.textContent = "—";
787
  els.feelingConfidence.textContent = "Waiting";
788
  els.genderValue.textContent = "—";
789
  els.genderConfidence.textContent = "Waiting";
790
  els.ageValue.textContent = "—";
791
+ els.ageSource.textContent = "Waiting";
792
  els.facesValue.textContent = "0";
793
  els.faceScore.textContent = "No face yet";
794
+ els.latencyValue.textContent = "—";
795
+ els.latencySource.textContent = "Core model time";
796
  renderBars({});
797
  }
798
 
 
808
  function updateDetails(detections, elapsedMs) {
809
  els.facesValue.textContent = String(detections.length);
810
  els.latencyValue.textContent = `${Math.round(elapsedMs)}ms`;
811
+ els.latencySource.textContent = pro.latency ? `Core + accuracy pack ${Math.round(pro.latency)}ms` : "Core model time";
812
 
813
  const now = performance.now();
814
  const instantFps = 1000 / Math.max(1, now - lastLoopTime);
 
823
  els.genderValue.textContent = "—";
824
  els.genderConfidence.textContent = "No face";
825
  els.ageValue.textContent = "—";
826
+ els.ageSource.textContent = "No face";
827
  els.faceScore.textContent = "No face yet";
828
  renderBars({});
829
  return;
830
  }
831
 
832
+ maybeRunAccuracyPack(primary);
833
  const insight = makeInsight(primary);
834
  els.feelingValue.textContent = insight.emotionLabel;
835
  els.feelingConfidence.textContent = `${percent(insight.emotionScore)} confidence`;
836
  els.genderValue.textContent = insight.gender;
837
+ els.genderConfidence.textContent = insight.gender === "—" ? "Waiting" : `${percent(insight.genderScore)} confidence`;
838
  els.ageValue.textContent = insight.ageRange;
839
+ els.ageSource.textContent = insight.ageSource;
840
  els.faceScore.textContent = `${percent(insight.faceScore)} face score`;
841
  renderBars(insight.emotionScores);
842
  }
 
931
  if (lastDetections.length) drawDetections(lastDetections);
932
  }
933
 
934
+ function cropFaceCanvas(det, targetSize = 256) {
935
+ const box = det.detection.box;
936
+ const videoW = els.video.videoWidth || els.overlay.width;
937
+ const videoH = els.video.videoHeight || els.overlay.height;
938
+ const pad = 0.32;
939
+ const cx = box.x + box.width / 2;
940
+ const cy = box.y + box.height / 2;
941
+ const side = Math.max(box.width, box.height) * (1 + pad * 2);
942
+ const sx = Math.max(0, Math.round(cx - side / 2));
943
+ const sy = Math.max(0, Math.round(cy - side / 2));
944
+ const sw = Math.min(videoW - sx, Math.round(side));
945
+ const sh = Math.min(videoH - sy, Math.round(side));
946
+
947
+ const canvas = document.createElement("canvas");
948
+ canvas.width = targetSize;
949
+ canvas.height = targetSize;
950
+ const c = canvas.getContext("2d", { willReadFrequently: true });
951
+ c.fillStyle = "#000";
952
+ c.fillRect(0, 0, targetSize, targetSize);
953
+ c.drawImage(els.video, sx, sy, sw, sh, 0, 0, targetSize, targetSize);
954
+ return canvas;
955
+ }
956
+
957
+ function canvasToBlobUrl(canvas) {
958
+ return new Promise((resolve, reject) => {
959
+ canvas.toBlob(blob => {
960
+ if (!blob) reject(new Error("Could not create face crop"));
961
+ else resolve(URL.createObjectURL(blob));
962
+ }, "image/jpeg", 0.92);
963
+ });
964
+ }
965
 
966
+ async function waitForFaceApi() {
967
+ const started = performance.now();
968
+ while (!window.faceapi) {
969
+ if (performance.now() - started > 12000) throw new Error("face-api.js did not load");
970
+ await new Promise(resolve => setTimeout(resolve, 80));
971
+ }
972
+ }
973
+
974
+ async function loadCoreModels() {
975
+ try {
976
  await waitForFaceApi();
977
  await Promise.all([
978
+ faceapi.nets.tinyFaceDetector.loadFromUri(FACE_API_MODEL_URL),
979
+ faceapi.nets.faceLandmark68TinyNet.loadFromUri(FACE_API_MODEL_URL),
980
+ faceapi.nets.faceExpressionNet.loadFromUri(FACE_API_MODEL_URL),
981
+ faceapi.nets.ageGenderNet.loadFromUri(FACE_API_MODEL_URL),
982
  ]);
983
+ coreReady = true;
984
+ setPill(els.coreDot, els.coreStatus, "Core models ready", "ready");
985
  els.startBtn.disabled = false;
986
+ els.accuracyBtn.disabled = false;
987
  renderBars({});
988
+ setTimeout(() => loadAccuracyPack(true), 600);
989
  } catch (err) {
990
  console.error(err);
991
+ setPill(els.coreDot, els.coreStatus, "Core model loading failed", "error");
992
+ showToast("Core model loading failed. Refresh the Space and check CDN access.");
993
+ }
994
+ }
995
+
996
+ async function getTransformersModule() {
997
+ const mod = await import(TRANSFORMERS_CDN);
998
+ if (mod.env) {
999
+ mod.env.allowLocalModels = false;
1000
+ if (mod.env.backends && mod.env.backends.onnx && mod.env.backends.onnx.wasm) {
1001
+ mod.env.backends.onnx.wasm.numThreads = Math.max(1, Math.min(4, navigator.hardwareConcurrency || 2));
1002
+ }
1003
+ }
1004
+ return mod;
1005
+ }
1006
+
1007
+ async function loadPipelineWithFallback(pipeline, task, modelId) {
1008
+ const preferWebGpu = !!navigator.gpu;
1009
+ const attempts = preferWebGpu
1010
+ ? [{ device: "webgpu", dtype: "q8" }, { device: "wasm", dtype: "q8" }, { device: "wasm" }]
1011
+ : [{ device: "wasm", dtype: "q8" }, { device: "wasm" }];
1012
+ let lastErr = null;
1013
+ for (const opts of attempts) {
1014
+ try {
1015
+ const pipe = await pipeline(task, modelId, opts);
1016
+ pro.device = opts.device || "wasm";
1017
+ return pipe;
1018
+ } catch (err) {
1019
+ lastErr = err;
1020
+ console.warn(`Failed loading ${modelId} with`, opts, err);
1021
+ }
1022
+ }
1023
+ throw lastErr || new Error(`Could not load ${modelId}`);
1024
+ }
1025
+
1026
+ async function loadAgeGenderWithFallback(mod) {
1027
+ const preferWebGpu = !!navigator.gpu;
1028
+ const attempts = preferWebGpu
1029
+ ? [{ device: "webgpu", dtype: "q4" }, { device: "wasm", dtype: "q4" }, { device: "wasm", dtype: "q8" }, { device: "wasm" }]
1030
+ : [{ device: "wasm", dtype: "q4" }, { device: "wasm", dtype: "q8" }, { device: "wasm" }];
1031
+ let model = null;
1032
+ let lastErr = null;
1033
+ for (const opts of attempts) {
1034
+ try {
1035
+ model = await mod.AutoModel.from_pretrained(AGE_GENDER_MODEL_ID, opts);
1036
+ pro.device = opts.device || "wasm";
1037
+ break;
1038
+ } catch (err) {
1039
+ lastErr = err;
1040
+ console.warn("Age/gender model load attempt failed", opts, err);
1041
+ }
1042
+ }
1043
+ if (!model) throw lastErr || new Error("Could not load age/gender model");
1044
+ const processor = await mod.AutoProcessor.from_pretrained(AGE_GENDER_MODEL_ID);
1045
+ return { model, processor };
1046
+ }
1047
+
1048
+ async function loadAccuracyPack(auto = false) {
1049
+ if (pro.loading || (pro.emotionPipe && pro.ageGenderModel)) return;
1050
+ pro.loading = true;
1051
+ pro.tried = true;
1052
+ els.accuracyBtn.disabled = true;
1053
+ setPill(els.proDot, els.proStatus, auto ? "Accuracy pack: loading in background..." : "Accuracy pack: loading...", "loading");
1054
+ if (!auto) showToast("Loading higher-accuracy transformer models. First load can take time.");
1055
+
1056
+ try {
1057
+ const mod = await getTransformersModule();
1058
+ pro.loadImage = mod.load_image || mod.RawImage?.fromURL;
1059
+
1060
+ try {
1061
+ pro.emotionPipe = await loadPipelineWithFallback(mod.pipeline, "image-classification", EMOTION_MODEL_ID);
1062
+ } catch (emotionErr) {
1063
+ console.warn("Emotion transformer unavailable", emotionErr);
1064
+ }
1065
+
1066
+ try {
1067
+ if (mod.AutoModel && mod.AutoProcessor && (mod.load_image || mod.RawImage?.fromURL)) {
1068
+ const pair = await loadAgeGenderWithFallback(mod);
1069
+ pro.ageGenderModel = pair.model;
1070
+ pro.ageGenderProcessor = pair.processor;
1071
+ }
1072
+ } catch (ageErr) {
1073
+ console.warn("Age/gender transformer unavailable", ageErr);
1074
+ }
1075
+
1076
+ if (pro.emotionPipe || pro.ageGenderModel) {
1077
+ const parts = [];
1078
+ if (pro.emotionPipe) parts.push("emotion");
1079
+ if (pro.ageGenderModel) parts.push("age/gender");
1080
+ setPill(els.proDot, els.proStatus, `Accuracy pack ready: ${parts.join(" + ")} (${pro.device})`, "ready");
1081
+ showToast(`Accuracy pack ready: ${parts.join(" + ")}`);
1082
+ } else {
1083
+ throw new Error("No accuracy models loaded");
1084
+ }
1085
+ } catch (err) {
1086
+ console.error(err);
1087
+ setPill(els.proDot, els.proStatus, "Accuracy pack unavailable; using core model", "error");
1088
+ if (!auto) showToast("Accuracy pack could not load. The app will keep using the core model.");
1089
+ } finally {
1090
+ pro.loading = false;
1091
+ els.accuracyBtn.disabled = !!(pro.emotionPipe && pro.ageGenderModel);
1092
+ }
1093
+ }
1094
+
1095
+ async function maybeRunAccuracyPack(primary) {
1096
+ const cfg = modes[els.modeSelect.value] || modes.accurate;
1097
+ const now = performance.now();
1098
+ if (!primary || pro.busy || (!pro.emotionPipe && !pro.ageGenderModel)) return;
1099
+ if (now - pro.lastRun < cfg.proInterval) return;
1100
+ pro.busy = true;
1101
+ pro.lastRun = now;
1102
+
1103
+ let url = null;
1104
+ const started = performance.now();
1105
+ try {
1106
+ const crop = cropFaceCanvas(primary, 288);
1107
+ url = await canvasToBlobUrl(crop);
1108
+
1109
+ if (pro.emotionPipe) {
1110
+ let output;
1111
+ try {
1112
+ output = await pro.emotionPipe(url, { topK: 7 });
1113
+ } catch (_) {
1114
+ output = await pro.emotionPipe(url);
1115
+ }
1116
+ pro.emotionScores = normalizeExternalEmotion(output);
1117
+ pro.emotionAt = performance.now();
1118
+ }
1119
+
1120
+ if (pro.ageGenderModel && pro.ageGenderProcessor && pro.loadImage) {
1121
+ let image;
1122
+ if (typeof pro.loadImage === "function") {
1123
+ image = await pro.loadImage(url);
1124
+ }
1125
+ const inputs = await pro.ageGenderProcessor(image);
1126
+ const output = await pro.ageGenderModel(inputs);
1127
+ const logits = output.logits || output.last_hidden_state || output[0];
1128
+ let values = [];
1129
+ if (logits?.tolist) {
1130
+ values = logits.tolist().flat(Infinity);
1131
+ } else if (logits?.data) {
1132
+ values = Array.from(logits.data);
1133
+ }
1134
+ const rawAge = Number(values[0]);
1135
+ const rawGender = Number(values[1]);
1136
+ if (Number.isFinite(rawAge)) {
1137
+ const age = Math.max(0, Math.min(100, Math.round(rawAge)));
1138
+ pro.age = age;
1139
+ pro.ageAt = performance.now();
1140
+ pushAgeSample(age, "accuracy pack", 4);
1141
+ }
1142
+ if (Number.isFinite(rawGender)) {
1143
+ const pFemale = rawGender >= 0 && rawGender <= 1 ? rawGender : sigmoid(rawGender);
1144
+ pro.gender = pFemale >= 0.5 ? "Female" : "Male";
1145
+ pro.genderScore = Math.max(pFemale, 1 - pFemale);
1146
+ pro.genderAt = performance.now();
1147
+ }
1148
+ }
1149
+ } catch (err) {
1150
+ console.warn("Accuracy inference failed", err);
1151
+ } finally {
1152
+ if (url) URL.revokeObjectURL(url);
1153
+ pro.latency = performance.now() - started;
1154
+ pro.busy = false;
1155
  }
1156
  }
1157
 
 
1165
  els.video.srcObject = null;
1166
  ctx.clearRect(0, 0, els.overlay.width, els.overlay.height);
1167
  els.emptyState.style.display = "grid";
1168
+ els.startBtn.disabled = !coreReady;
1169
  els.switchBtn.disabled = true;
1170
  els.stopBtn.disabled = true;
1171
  els.cameraTag.textContent = "Camera: stopped";
 
1173
  }
1174
 
1175
  async function startCamera(facing = currentFacing) {
1176
+ if (!coreReady) {
1177
+ showToast("Please wait until the core models finish loading.");
1178
  return;
1179
  }
1180
 
 
1220
  }
1221
 
1222
  async function detectLoop() {
1223
+ if (!running || !coreReady || detecting) return;
1224
  detecting = true;
1225
  const started = performance.now();
1226
  try {
1227
  fitCanvas();
1228
+ const cfg = modes[els.modeSelect.value] || modes.accurate;
1229
  const options = new faceapi.TinyFaceDetectorOptions({ inputSize: cfg.inputSize, scoreThreshold: cfg.scoreThreshold });
1230
  const raw = await faceapi
1231
  .detectAllFaces(els.video, options)
 
1254
  currentFacing = currentFacing === "user" ? "environment" : "user";
1255
  startCamera(currentFacing);
1256
  });
1257
+ els.accuracyBtn.addEventListener("click", () => loadAccuracyPack(false));
1258
  els.stopBtn.addEventListener("click", stopStream);
1259
  els.video.addEventListener("loadedmetadata", fitCanvas);
1260
  window.addEventListener("resize", fitCanvas);
1261
+ els.ageOffset.addEventListener("input", () => {
1262
+ const value = Number(els.ageOffset.value || 0);
1263
+ els.ageOffsetValue.textContent = `${value > 0 ? "+" : ""}${value}y`;
1264
+ });
1265
 
1266
  resetDetails();
1267
  renderBars({});
1268
+ setPill(els.proDot, els.proStatus, "Accuracy pack: idle", "loading");
1269
+ loadCoreModels();
1270
  </script>
1271
  </body>
1272
  </html>