Solar-Prince commited on
Commit
7c3874c
·
verified ·
1 Parent(s): d8765bb

Upload 2 files

Browse files
Files changed (2) hide show
  1. README.md +3 -3
  2. index.html +452 -373
README.md CHANGED
@@ -5,11 +5,11 @@ colorFrom: blue
5
  colorTo: purple
6
  sdk: static
7
  pinned: false
8
- short_description: Live facial expression and age estimates
9
  ---
10
 
11
  # SentAI
12
 
13
- Static browser-side live face analysis demo for Hugging Face Spaces.
14
 
15
- Phase 3C adds multi-crop transformer expression scoring and calibration to reduce Happy/Confused dominance, with better sensitivity for Sad, Fear, and Disgust.
 
5
  colorTo: purple
6
  sdk: static
7
  pinned: false
8
+ short_description: Live face emotion age gender estimates
9
  ---
10
 
11
  # SentAI
12
 
13
+ Simple browser-side live face analysis for Hugging Face Spaces.
14
 
15
+ Phase 4 removes manual model and accuracy controls. Precision models load automatically in the background. The app estimates visible facial expression, apparent age range, and male/female presentation from live camera frames.
index.html CHANGED
@@ -6,6 +6,7 @@
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;
@@ -69,18 +70,18 @@
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: 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
 
@@ -102,7 +103,7 @@
102
  color: var(--muted);
103
  box-shadow: var(--shadow);
104
  white-space: nowrap;
105
- font-weight: 800;
106
  }
107
 
108
  .dot {
@@ -124,7 +125,7 @@
124
  margin-bottom: 18px;
125
  }
126
 
127
- button, select {
128
  appearance: none;
129
  border: 1px solid var(--stroke);
130
  background: rgba(15, 23, 42, 0.76);
@@ -139,10 +140,9 @@
139
  min-height: 48px;
140
  }
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
 
@@ -160,7 +160,7 @@
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
  }
@@ -211,9 +211,9 @@
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
  }
@@ -278,14 +278,14 @@
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);
@@ -318,21 +318,6 @@
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);
@@ -371,15 +356,14 @@
371
  .stage-panel { padding: 10px; }
372
  .side { padding: 14px; }
373
  .toolbar { gap: 9px; }
374
- button, select, .camera-tag { flex: 1 1 150px; justify-content: center; }
375
  }
376
 
377
  @media (max-width: 560px) {
378
- .brand h1 { font-size: clamp(3.6rem, 19vw, 5.4rem); }
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,29 +375,18 @@
391
  <header class="hero" aria-label="SentAI heading">
392
  <div class="brand">
393
  <h1>SentAI</h1>
394
- <p>Higher-accuracy live face analysis with multi-crop expression scoring, apparent age range, and male/female presentation estimate. Phase 3C is tuned to reduce Happy/Confused dominance.</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="emotionMode" aria-label="Emotion scoring mode">
408
- <option value="sensitive" selected>Boost sad/fear/disgust</option>
409
- <option value="balanced">Balanced emotions</option>
410
- <option value="raw">Raw model scores</option>
411
- </select>
412
- <select id="modeSelect" aria-label="Performance mode">
413
- <option value="fast">Fast mode</option>
414
- <option value="balanced">Balanced mode</option>
415
- <option value="accurate" selected>Accurate mode</option>
416
- </select>
417
  <span id="cameraTag" class="camera-tag">Camera: not started</span>
418
  </section>
419
 
@@ -425,7 +398,7 @@
425
  <div id="emptyState" class="empty-state">
426
  <div class="empty-card">
427
  <strong>Ready for live analysis</strong>
428
- 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.
429
  </div>
430
  </div>
431
  </div>
@@ -436,17 +409,17 @@
436
  <div class="metric-grid">
437
  <div class="metric">
438
  <span>Possible feeling</span>
439
- <strong id="feelingValue"></strong>
440
  <small id="feelingConfidence">Waiting</small>
441
  </div>
442
  <div class="metric">
443
  <span>Gender</span>
444
- <strong id="genderValue"></strong>
445
  <small id="genderConfidence">Waiting</small>
446
  </div>
447
  <div class="metric">
448
  <span>Apparent age</span>
449
- <strong id="ageValue"></strong>
450
  <small id="ageSource">Waiting</small>
451
  </div>
452
  <div class="metric">
@@ -456,13 +429,13 @@
456
  </div>
457
  <div class="metric">
458
  <span>FPS</span>
459
- <strong id="fpsValue"></strong>
460
- <small>Detection loop</small>
461
  </div>
462
  <div class="metric">
463
  <span>Latency</span>
464
- <strong id="latencyValue"></strong>
465
- <small id="latencySource">Core model time</small>
466
  </div>
467
  </div>
468
 
@@ -471,17 +444,8 @@
471
  <div id="emotionBars"></div>
472
  </div>
473
 
474
- <div class="calibration wide">
475
- <h3>Age fine tune</h3>
476
- <div class="calibration-row">
477
- <span>Offset</span>
478
- <input id="ageOffset" type="range" min="-12" max="12" value="0" step="1" aria-label="Age offset calibration" />
479
- <b id="ageOffsetValue">0y</b>
480
- </div>
481
- </div>
482
-
483
  <div class="note wide">
484
- The app estimates visible facial expression and apparent age from camera frames. Phase 3C uses multi-crop transformer averaging and sad/fear/disgust calibration to reduce Happy/Confused dominance. It still cannot know a person's true internal feeling.
485
  </div>
486
  </aside>
487
  </section>
@@ -491,22 +455,21 @@
491
 
492
  <script type="module">
493
  import { pipeline as xenovaPipeline, env as xenovaEnv } from "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2";
 
 
494
  const FACE_API_MODEL_URL = "https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights";
495
- const TRANSFORMERS_CDN = "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2";
496
- const EMOTION_MODEL_ID = "Xenova/facial_emotions_image_detection";
497
- const AGE_GENDER_MODEL_ID = null; // Phase 3B keeps age on the stable face-api model and improves it with smoothing + calibration.
498
 
499
  const els = {
500
- coreDot: document.getElementById("coreDot"),
501
- coreStatus: document.getElementById("coreStatus"),
502
- proDot: document.getElementById("proDot"),
503
- proStatus: document.getElementById("proStatus"),
504
  startBtn: document.getElementById("startBtn"),
505
  switchBtn: document.getElementById("switchBtn"),
506
- accuracyBtn: document.getElementById("accuracyBtn"),
507
  stopBtn: document.getElementById("stopBtn"),
508
- emotionMode: document.getElementById("emotionMode"),
509
- modeSelect: document.getElementById("modeSelect"),
510
  cameraTag: document.getElementById("cameraTag"),
511
  video: document.getElementById("video"),
512
  overlay: document.getElementById("overlay"),
@@ -524,18 +487,12 @@
524
  latencyValue: document.getElementById("latencyValue"),
525
  latencySource: document.getElementById("latencySource"),
526
  emotionBars: document.getElementById("emotionBars"),
527
- ageOffset: document.getElementById("ageOffset"),
528
- ageOffsetValue: document.getElementById("ageOffsetValue"),
529
  toast: document.getElementById("toast"),
530
  };
531
 
532
- const modes = {
533
- fast: { inputSize: 224, scoreThreshold: 0.52, interval: 22, proInterval: 2800, smoothing: 0.18 },
534
- balanced: { inputSize: 320, scoreThreshold: 0.45, interval: 42, proInterval: 2100, smoothing: 0.24 },
535
- accurate: { inputSize: 416, scoreThreshold: 0.38, interval: 65, proInterval: 1550, smoothing: 0.30 },
536
- };
537
-
538
  const emotionLabels = ["Happy", "Sad", "Fear", "Anger", "Confused", "Disgust"];
 
 
539
  const ctx = els.overlay.getContext("2d");
540
 
541
  let coreReady = false;
@@ -549,26 +506,28 @@
549
  let emotionEma = null;
550
  let genderMaleEma = null;
551
  let lastBaseAgeSample = 0;
 
552
 
553
  const ageHistory = [];
554
- const pro = {
555
  loading: false,
556
- tried: false,
 
557
  emotionPipe: null,
558
- ageGenderModel: null,
559
- ageGenderProcessor: null,
560
- loadImage: null,
561
  lastRun: 0,
562
  busy: false,
563
- emotionScores: null,
564
- emotionAt: 0,
 
 
565
  age: null,
566
  ageAt: 0,
567
  gender: null,
568
  genderScore: 0,
569
  genderAt: 0,
570
  latency: 0,
571
- device: "wasm",
572
  };
573
 
574
  function setPill(dot, label, text, kind = "loading") {
@@ -582,7 +541,7 @@
582
  els.toast.textContent = message;
583
  els.toast.classList.add("show");
584
  clearTimeout(showToast.timer);
585
- showToast.timer = setTimeout(() => els.toast.classList.remove("show"), 3600);
586
  }
587
 
588
  function clamp01(value) {
@@ -597,6 +556,15 @@
597
  return 1 / (1 + Math.exp(-x));
598
  }
599
 
 
 
 
 
 
 
 
 
 
600
  function median(values) {
601
  const list = values.filter(Number.isFinite).slice().sort((a, b) => a - b);
602
  if (!list.length) return NaN;
@@ -604,17 +572,6 @@
604
  return list.length % 2 ? list[mid] : (list[mid - 1] + list[mid]) / 2;
605
  }
606
 
607
- function ageRange(age, source = "core", samples = 1) {
608
- if (!Number.isFinite(age)) return "—";
609
- const offset = Number(els.ageOffset.value || 0);
610
- const corrected = Math.max(0, Math.min(100, age + offset));
611
- const half = source === "accuracy pack" ? (samples >= 3 ? 4 : 5) : (samples >= 6 ? 6 : 8);
612
- const lo = Math.max(0, Math.round(corrected - half));
613
- const hi = Math.min(100, Math.round(corrected + half));
614
- if (hi <= 12) return "0-12";
615
- return `${lo}-${hi}`;
616
- }
617
-
618
  function blankScores() {
619
  return Object.fromEntries(emotionLabels.map(label => [label, 0]));
620
  }
@@ -625,6 +582,24 @@
625
  return out;
626
  }
627
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
628
  function calibrateFaceApiExpressions(expressions = {}) {
629
  const raw = {
630
  happy: clamp01(expressions.happy),
@@ -635,24 +610,21 @@
635
  surprised: clamp01(expressions.surprised),
636
  neutral: clamp01(expressions.neutral),
637
  };
638
-
639
  const nonNeutralTop = Math.max(raw.happy, raw.sad, raw.fearful, raw.angry, raw.disgusted, raw.surprised);
640
- const uncertainty = clamp01((1 - nonNeutralTop) * 0.10 + raw.surprised * 0.42 + raw.neutral * 0.05);
641
-
642
  return normalizeScores({
643
- Happy: Math.pow(raw.happy, 1.08),
644
- Sad: Math.pow(raw.sad, 1.02),
645
- Fear: Math.max(Math.pow(raw.fearful, 1.04), raw.surprised * raw.fearful * 0.55),
646
- Anger: Math.pow(raw.angry, 1.03),
647
- Disgust: Math.pow(raw.disgusted, 1.02),
648
- Confused: Math.min(0.42, uncertainty),
649
  });
650
  }
651
 
652
  function normalizeExternalEmotion(outputs) {
653
  const scores = blankScores();
654
  const list = Array.isArray(outputs) ? outputs : [outputs];
655
-
656
  for (const item of list) {
657
  const label = String(item.label || item.class || "").toLowerCase();
658
  const score = clamp01(item.score || item.probability || 0);
@@ -662,85 +634,80 @@
662
  else if (label.includes("angry") || label.includes("anger")) scores.Anger = Math.max(scores.Anger, score);
663
  else if (label.includes("disgust") || label.includes("disgusted")) scores.Disgust = Math.max(scores.Disgust, score);
664
  else if (label.includes("surprise") || label.includes("neutral")) {
665
- // Neutral/surprise should not dominate. They only become Confused when no clear emotion wins.
666
- const scaled = label.includes("neutral") ? score * 0.045 : score * 0.30;
667
- scores.Confused = Math.max(scores.Confused, Math.min(0.30, scaled));
668
  }
669
  }
670
  return normalizeScores(scores);
671
  }
672
 
673
- function emotionWeights() {
674
- const mode = els.emotionMode?.value || "sensitive";
675
- if (mode === "raw") {
676
- return { Happy: 1.00, Sad: 1.00, Fear: 1.00, Anger: 1.00, Confused: 1.00, Disgust: 1.00 };
677
- }
678
- if (mode === "balanced") {
679
- return { Happy: 0.88, Sad: 1.28, Fear: 1.38, Anger: 0.95, Confused: 0.58, Disgust: 1.48 };
680
- }
681
- // Default: compensate for webcam models over-predicting smile/neutral/anger and under-predicting subtle negative expressions.
682
- return { Happy: 0.70, Sad: 1.62, Fear: 1.82, Anger: 0.86, Confused: 0.42, Disgust: 2.05 };
683
- }
684
-
685
- function applyEmotionCalibration(scores) {
686
- const mode = els.emotionMode?.value || "sensitive";
687
- if (mode === "raw") return normalizeScores(scores);
688
- const weights = emotionWeights();
689
  const out = blankScores();
690
  for (const label of emotionLabels) {
691
  let v = clamp01(scores[label] || 0);
692
- // Make low but consistent sad/fear/disgust evidence visible instead of crushed by happy/confused.
693
- if (["Sad", "Fear", "Disgust"].includes(label)) v = Math.pow(v, 0.82);
694
- if (label === "Happy") v = Math.pow(v, 1.12);
695
- if (label === "Confused") v = Math.pow(v, 1.18);
696
- out[label] = clamp01(v * weights[label]);
697
  }
698
- // Confused is a fallback label, not a high-confidence emotion class.
699
- out.Confused = Math.min(out.Confused, mode === "balanced" ? 0.34 : 0.24);
700
- return normalizeScores(out);
701
  }
702
 
703
  function combineEmotionScores(faceScores) {
704
  const now = performance.now();
705
- const freshPro = pro.emotionScores && (now - pro.emotionAt < 6500);
 
706
  const combined = blankScores();
707
- for (const label of emotionLabels) {
708
- const base = faceScores[label] || 0;
709
- const proValue = freshPro ? (pro.emotionScores[label] || 0) : 0;
710
- // Trust the transformer crop classifier more than face-api expressions when it is fresh.
711
- combined[label] = freshPro ? (proValue * 0.88 + base * 0.12) : base;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
712
  }
713
 
714
- const calibrated = applyEmotionCalibration(combined);
715
- const cfg = modes[els.modeSelect.value] || modes.accurate;
 
 
716
  if (!emotionEma) {
717
  emotionEma = calibrated;
718
  } else {
719
  for (const label of emotionLabels) {
720
- emotionEma[label] = emotionEma[label] * (1 - cfg.smoothing) + calibrated[label] * cfg.smoothing;
721
  }
722
  }
723
- return normalizeScores(emotionEma);
724
  }
725
 
726
  function topEmotion(scores) {
727
- const nonConfused = Object.entries(scores).filter(([label]) => label !== "Confused").sort((a, b) => b[1] - a[1]);
728
- let [label, score] = nonConfused[0] || ["Confused", 0];
729
- const rare = ["Disgust", "Fear", "Sad"].map(name => [name, scores[name] || 0]).sort((a, b) => b[1] - a[1])[0];
730
- const mode = els.emotionMode?.value || "sensitive";
731
- if (mode !== "raw" && rare && rare[1] >= 0.24) {
732
- const rescueMargin = mode === "sensitive" ? 0.20 : 0.12;
733
- if (rare[1] >= score - rescueMargin) {
734
- label = rare[0];
735
- score = rare[1];
736
- }
737
  }
738
- if (score < 0.16) {
739
- label = "Confused";
740
- score = Math.max(scores.Confused || 0, 0.16);
741
- } else if ((scores.Confused || 0) > score && score < 0.26) {
742
  label = "Confused";
743
- score = Math.min(scores.Confused || 0.22, 0.28);
744
  }
745
  return { label, score: clamp01(score) };
746
  }
@@ -749,14 +716,14 @@
749
  if (!Number.isFinite(age) || age < 0 || age > 100) return;
750
  const now = performance.now();
751
  ageHistory.push({ age, source, weight, t: now });
752
- while (ageHistory.length > 40) ageHistory.shift();
753
- const cutoff = now - 30000;
754
  while (ageHistory.length && ageHistory[0].t < cutoff) ageHistory.shift();
755
  }
756
 
757
  function stableAgeEstimate() {
758
- const proSamples = ageHistory.filter(s => s.source === "accuracy pack");
759
- const usable = proSamples.length >= 2 ? proSamples : ageHistory;
760
  if (!usable.length) return { age: NaN, source: "Waiting", samples: 0 };
761
  const expanded = [];
762
  for (const sample of usable) {
@@ -765,7 +732,7 @@
765
  }
766
  return {
767
  age: median(expanded),
768
- source: proSamples.length >= 2 ? "accuracy pack" : "core model",
769
  samples: usable.length,
770
  };
771
  }
@@ -773,12 +740,12 @@
773
  function updateGenderEstimate(label, confidence, weight = 1) {
774
  if (!label) return;
775
  const pMale = label.toLowerCase() === "male" ? clamp01(confidence) : 1 - clamp01(confidence);
776
- const alpha = Math.min(0.6, 0.16 * weight);
777
  genderMaleEma = genderMaleEma === null ? pMale : genderMaleEma * (1 - alpha) + pMale * alpha;
778
  }
779
 
780
  function currentGender() {
781
- if (genderMaleEma === null) return { label: "", confidence: 0 };
782
  const label = genderMaleEma >= 0.5 ? "Male" : "Female";
783
  const confidence = Math.max(genderMaleEma, 1 - genderMaleEma);
784
  return { label, confidence };
@@ -790,20 +757,17 @@
790
  const emotion = topEmotion(scores);
791
 
792
  const now = performance.now();
793
- if (Number.isFinite(det.age) && now - lastBaseAgeSample > 550) {
794
  pushAgeSample(det.age, "core model", 1);
795
  lastBaseAgeSample = now;
796
  }
797
 
798
  const coreGender = (det.gender || "").toLowerCase() === "female" ? "Female" : "Male";
799
  updateGenderEstimate(coreGender, det.genderProbability || 0, 1);
800
- if (pro.gender && (now - pro.genderAt < 8000)) {
801
- updateGenderEstimate(pro.gender, pro.genderScore, 3.2);
802
- }
803
 
804
  const age = stableAgeEstimate();
805
  const gender = currentGender();
806
-
807
  return {
808
  emotionLabel: emotion.label,
809
  emotionScore: emotion.score,
@@ -832,16 +796,16 @@
832
  emotionEma = null;
833
  genderMaleEma = null;
834
  ageHistory.length = 0;
835
- els.feelingValue.textContent = "";
836
  els.feelingConfidence.textContent = "Waiting";
837
- els.genderValue.textContent = "";
838
  els.genderConfidence.textContent = "Waiting";
839
- els.ageValue.textContent = "";
840
  els.ageSource.textContent = "Waiting";
841
  els.facesValue.textContent = "0";
842
  els.faceScore.textContent = "No face yet";
843
- els.latencyValue.textContent = "";
844
- els.latencySource.textContent = "Core model time";
845
  renderBars({});
846
  }
847
 
@@ -854,10 +818,21 @@
854
  })[0];
855
  }
856
 
 
 
 
 
 
 
 
 
 
 
 
857
  function updateDetails(detections, elapsedMs) {
858
  els.facesValue.textContent = String(detections.length);
859
  els.latencyValue.textContent = `${Math.round(elapsedMs)}ms`;
860
- els.latencySource.textContent = pro.latency ? `Core + accuracy pack ${Math.round(pro.latency)}ms` : "Core model time";
861
 
862
  const now = performance.now();
863
  const instantFps = 1000 / Math.max(1, now - lastLoopTime);
@@ -866,24 +841,25 @@
866
  els.fpsValue.textContent = fpsSmooth.toFixed(1);
867
 
868
  const primary = choosePrimary(detections);
 
869
  if (!primary) {
870
- els.feelingValue.textContent = "";
871
  els.feelingConfidence.textContent = "No face";
872
- els.genderValue.textContent = "";
873
  els.genderConfidence.textContent = "No face";
874
- els.ageValue.textContent = "";
875
  els.ageSource.textContent = "No face";
876
  els.faceScore.textContent = "No face yet";
877
  renderBars({});
878
  return;
879
  }
880
 
881
- maybeRunAccuracyPack(primary);
882
  const insight = makeInsight(primary);
883
  els.feelingValue.textContent = insight.emotionLabel;
884
  els.feelingConfidence.textContent = `${percent(insight.emotionScore)} confidence`;
885
  els.genderValue.textContent = insight.gender;
886
- els.genderConfidence.textContent = insight.gender === "" ? "Waiting" : `${percent(insight.genderScore)} confidence`;
887
  els.ageValue.textContent = insight.ageRange;
888
  els.ageSource.textContent = insight.ageSource;
889
  els.faceScore.textContent = `${percent(insight.faceScore)} face score`;
@@ -930,7 +906,7 @@
930
  const font2 = Math.round(14 * scale);
931
  const lineH = font1 + 9 * scale;
932
  const label1 = `${insight.emotionLabel} ${percent(insight.emotionScore)}`;
933
- const label2 = `${insight.gender} ${percent(insight.genderScore)} · Age ${insight.ageRange}`;
934
 
935
  ctx.font = `900 ${font1}px Inter, system-ui, sans-serif`;
936
  const textW = Math.max(ctx.measureText(label1).width, ctx.measureText(label2).width);
@@ -980,7 +956,7 @@
980
  if (lastDetections.length) drawDetections(lastDetections);
981
  }
982
 
983
- function cropFaceCanvas(det, targetSize = 256, pad = 0.32, filter = "none", mirror = false) {
984
  const box = det.detection.box;
985
  const videoW = els.video.videoWidth || els.overlay.width;
986
  const videoH = els.video.videoHeight || els.overlay.height;
@@ -1020,228 +996,323 @@
1020
  });
1021
  }
1022
 
1023
- async function waitForFaceApi() {
1024
  const started = performance.now();
1025
- while (!window.faceapi) {
1026
- if (performance.now() - started > 12000) throw new Error("face-api.js did not load");
1027
  await new Promise(resolve => setTimeout(resolve, 80));
1028
  }
 
1029
  }
1030
 
1031
  async function loadCoreModels() {
1032
  try {
1033
- await waitForFaceApi();
1034
  await Promise.all([
 
1035
  faceapi.nets.tinyFaceDetector.loadFromUri(FACE_API_MODEL_URL),
1036
  faceapi.nets.faceLandmark68TinyNet.loadFromUri(FACE_API_MODEL_URL),
1037
  faceapi.nets.faceExpressionNet.loadFromUri(FACE_API_MODEL_URL),
1038
  faceapi.nets.ageGenderNet.loadFromUri(FACE_API_MODEL_URL),
1039
  ]);
1040
  coreReady = true;
1041
- setPill(els.coreDot, els.coreStatus, "Core models ready", "ready");
1042
  els.startBtn.disabled = false;
1043
- els.accuracyBtn.disabled = false;
1044
  renderBars({});
1045
- setTimeout(() => loadAccuracyPack(true), 600);
1046
  } catch (err) {
1047
  console.error(err);
1048
- setPill(els.coreDot, els.coreStatus, "Core model loading failed", "error");
1049
- showToast("Core model loading failed. Refresh the Space and check CDN access.");
1050
  }
1051
  }
1052
 
1053
- async function getTransformersModule() {
1054
- // Use the legacy Xenova build because the selected emotion model was published for it.
1055
- // This avoids the Phase 3A failure where the newer package could import but could not initialize the model.
1056
- const mod = { pipeline: xenovaPipeline, env: xenovaEnv };
1057
- if (mod.env) {
1058
- mod.env.allowLocalModels = false;
1059
- mod.env.useBrowserCache = true;
1060
- mod.env.backends ??= {};
1061
- mod.env.backends.onnx ??= {};
1062
- mod.env.backends.onnx.wasm ??= {};
1063
- mod.env.backends.onnx.wasm.numThreads = Math.max(1, Math.min(4, navigator.hardwareConcurrency || 2));
1064
- }
1065
- return mod;
1066
  }
1067
 
1068
- async function loadPipelineWithFallback(pipeline, task, modelId) {
1069
- const attempts = [
1070
- { quantized: true, progress_callback: p => { if (p?.status) console.log("accuracy pack", p.status, p.file || ""); } },
1071
- { quantized: true },
1072
- {},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1073
  ];
1074
- let lastErr = null;
1075
- for (const opts of attempts) {
1076
- try {
1077
- const pipe = await pipeline(task, modelId, opts);
1078
- pro.device = "wasm";
1079
- return pipe;
1080
- } catch (err) {
1081
- lastErr = err;
1082
- console.warn(`Failed loading ${modelId} with`, opts, err);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1083
  }
1084
  }
1085
- throw lastErr || new Error(`Could not load ${modelId}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1086
  }
1087
 
1088
- async function loadAgeGenderWithFallback(mod) {
1089
- const preferWebGpu = !!navigator.gpu;
1090
- const attempts = preferWebGpu
1091
- ? [{ device: "webgpu", dtype: "q4" }, { device: "wasm", dtype: "q4" }, { device: "wasm", dtype: "q8" }, { device: "wasm" }]
1092
- : [{ device: "wasm", dtype: "q4" }, { device: "wasm", dtype: "q8" }, { device: "wasm" }];
1093
- let model = null;
1094
- let lastErr = null;
1095
- for (const opts of attempts) {
1096
- try {
1097
- model = await mod.AutoModel.from_pretrained(AGE_GENDER_MODEL_ID, opts);
1098
- pro.device = opts.device || "wasm";
1099
- break;
1100
- } catch (err) {
1101
- lastErr = err;
1102
- console.warn("Age/gender model load attempt failed", opts, err);
1103
- }
1104
  }
1105
- if (!model) throw lastErr || new Error("Could not load age/gender model");
1106
- const processor = await mod.AutoProcessor.from_pretrained(AGE_GENDER_MODEL_ID);
1107
- return { model, processor };
1108
  }
1109
 
1110
- async function loadAccuracyPack(auto = false) {
1111
- if (pro.loading || (pro.emotionPipe && pro.ageGenderModel)) return;
1112
- pro.loading = true;
1113
- pro.tried = true;
1114
- els.accuracyBtn.disabled = true;
1115
- setPill(els.proDot, els.proStatus, auto ? "Emotion accuracy pack: loading in background..." : "Emotion accuracy pack: loading...", "loading");
1116
- if (!auto) showToast("Loading higher-accuracy transformer models. First load can take time.");
 
 
 
 
 
 
 
 
 
 
 
1117
 
 
 
 
 
 
 
 
 
 
 
 
1118
  try {
1119
- const mod = await getTransformersModule();
1120
- pro.loadImage = mod.load_image || mod.RawImage?.fromURL;
1121
-
1122
- try {
1123
- pro.emotionPipe = await loadPipelineWithFallback(mod.pipeline, "image-classification", EMOTION_MODEL_ID);
1124
- } catch (emotionErr) {
1125
- console.warn("Emotion transformer unavailable", emotionErr);
1126
- }
1127
-
1128
- try {
1129
- if (AGE_GENDER_MODEL_ID && mod.AutoModel && mod.AutoProcessor && (mod.load_image || mod.RawImage?.fromURL)) {
1130
- const pair = await loadAgeGenderWithFallback(mod);
1131
- pro.ageGenderModel = pair.model;
1132
- pro.ageGenderProcessor = pair.processor;
1133
  }
1134
- } catch (ageErr) {
1135
- console.warn("Age/gender transformer unavailable", ageErr);
 
1136
  }
 
 
 
 
 
 
 
1137
 
1138
- if (pro.emotionPipe || pro.ageGenderModel) {
1139
- const parts = [];
1140
- if (pro.emotionPipe) parts.push("emotion");
1141
- if (pro.ageGenderModel) parts.push("age/gender");
1142
- setPill(els.proDot, els.proStatus, `Accuracy pack ready: ${parts.join(" + ")} (${pro.device})`, "ready");
1143
- showToast(`Accuracy pack ready: ${parts.join(" + ")}`);
1144
- } else {
1145
- throw new Error("No accuracy models loaded");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1146
  }
1147
- } catch (err) {
1148
- console.error(err);
1149
- setPill(els.proDot, els.proStatus, "Emotion accuracy pack unavailable; using core model", "error");
1150
- if (!auto) showToast("Accuracy pack could not load. The app will keep using the core model.");
1151
  } finally {
1152
- pro.loading = false;
1153
- els.accuracyBtn.disabled = !!(pro.emotionPipe && pro.ageGenderModel);
1154
  }
1155
  }
1156
 
1157
- async function maybeRunAccuracyPack(primary) {
1158
- const cfg = modes[els.modeSelect.value] || modes.accurate;
1159
  const now = performance.now();
1160
- if (!primary || pro.busy || (!pro.emotionPipe && !pro.ageGenderModel)) return;
1161
- if (now - pro.lastRun < cfg.proInterval) return;
1162
- pro.busy = true;
1163
- pro.lastRun = now;
1164
-
1165
- let url = null;
1166
  const started = performance.now();
1167
  try {
1168
- const crop = cropFaceCanvas(primary, 288);
1169
- url = await canvasToBlobUrl(crop);
1170
-
1171
- if (pro.emotionPipe) {
1172
- const cropVariants = [
1173
- cropFaceCanvas(primary, 288, 0.16, "contrast(1.10) saturate(0.96)", false),
1174
- cropFaceCanvas(primary, 288, 0.34, "contrast(1.18) saturate(0.92)", false),
1175
- cropFaceCanvas(primary, 288, 0.06, "contrast(1.22) brightness(1.03)", false),
1176
- cropFaceCanvas(primary, 288, 0.22, "contrast(1.14) saturate(0.92)", true),
1177
- ];
1178
- const urls = [];
1179
- const aggregate = blankScores();
1180
- let count = 0;
1181
- try {
1182
- for (const variant of cropVariants) {
1183
- const variantUrl = await canvasToBlobUrl(variant);
1184
- urls.push(variantUrl);
1185
- let output;
1186
- try {
1187
- output = await pro.emotionPipe(variantUrl, { topK: 7 });
1188
- } catch (_) {
1189
- output = await pro.emotionPipe(variantUrl);
1190
- }
1191
- const scores = normalizeExternalEmotion(output);
1192
- for (const label of emotionLabels) aggregate[label] += scores[label] || 0;
1193
- count += 1;
1194
- }
1195
- } finally {
1196
- for (const variantUrl of urls) URL.revokeObjectURL(variantUrl);
1197
  }
1198
- for (const label of emotionLabels) aggregate[label] = count ? aggregate[label] / count : 0;
1199
- pro.emotionScores = normalizeScores(aggregate);
1200
- pro.emotionAt = performance.now();
1201
  }
1202
-
1203
- if (pro.ageGenderModel && pro.ageGenderProcessor && pro.loadImage) {
1204
- let image;
1205
- if (typeof pro.loadImage === "function") {
1206
- image = await pro.loadImage(url);
1207
- }
1208
- const inputs = await pro.ageGenderProcessor(image);
1209
- const output = await pro.ageGenderModel(inputs);
1210
- const logits = output.logits || output.last_hidden_state || output[0];
1211
- let values = [];
1212
- if (logits?.tolist) {
1213
- values = logits.tolist().flat(Infinity);
1214
- } else if (logits?.data) {
1215
- values = Array.from(logits.data);
1216
- }
1217
- const rawAge = Number(values[0]);
1218
- const rawGender = Number(values[1]);
1219
- if (Number.isFinite(rawAge)) {
1220
- const age = Math.max(0, Math.min(100, Math.round(rawAge)));
1221
- pro.age = age;
1222
- pro.ageAt = performance.now();
1223
- pushAgeSample(age, "accuracy pack", 4);
1224
- }
1225
- if (Number.isFinite(rawGender)) {
1226
- const pFemale = rawGender >= 0 && rawGender <= 1 ? rawGender : sigmoid(rawGender);
1227
- pro.gender = pFemale >= 0.5 ? "Female" : "Male";
1228
- pro.genderScore = Math.max(pFemale, 1 - pFemale);
1229
- pro.genderAt = performance.now();
1230
  }
1231
  }
 
1232
  } catch (err) {
1233
- console.warn("Accuracy inference failed", err);
1234
  } finally {
1235
- if (url) URL.revokeObjectURL(url);
1236
- pro.latency = performance.now() - started;
1237
- pro.busy = false;
1238
  }
1239
  }
1240
 
1241
  function stopStream() {
1242
- if (stream) {
1243
- for (const track of stream.getTracks()) track.stop();
1244
- }
1245
  stream = null;
1246
  running = false;
1247
  detecting = false;
@@ -1257,7 +1328,7 @@
1257
 
1258
  async function startCamera(facing = currentFacing) {
1259
  if (!coreReady) {
1260
- showToast("Please wait until the core models finish loading.");
1261
  return;
1262
  }
1263
 
@@ -1269,7 +1340,7 @@
1269
  audio: false,
1270
  video: {
1271
  facingMode: { ideal: currentFacing },
1272
- width: { ideal: 960 },
1273
  height: { ideal: 720 },
1274
  }
1275
  };
@@ -1302,19 +1373,32 @@
1302
  detectLoop();
1303
  }
1304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1305
  async function detectLoop() {
1306
  if (!running || !coreReady || detecting) return;
1307
  detecting = true;
1308
  const started = performance.now();
1309
  try {
1310
  fitCanvas();
1311
- const cfg = modes[els.modeSelect.value] || modes.accurate;
1312
- const options = new faceapi.TinyFaceDetectorOptions({ inputSize: cfg.inputSize, scoreThreshold: cfg.scoreThreshold });
1313
- const raw = await faceapi
1314
- .detectAllFaces(els.video, options)
1315
- .withFaceLandmarks(true)
1316
- .withFaceExpressions()
1317
- .withAgeAndGender();
1318
  const displaySize = { width: els.overlay.width, height: els.overlay.height };
1319
  const resized = faceapi.resizeResults(raw, displaySize);
1320
  const elapsed = performance.now() - started;
@@ -1323,7 +1407,7 @@
1323
  setTimeout(() => {
1324
  detecting = false;
1325
  requestAnimationFrame(detectLoop);
1326
- }, cfg.interval);
1327
  } catch (err) {
1328
  console.error(err);
1329
  detecting = false;
@@ -1337,18 +1421,13 @@
1337
  currentFacing = currentFacing === "user" ? "environment" : "user";
1338
  startCamera(currentFacing);
1339
  });
1340
- els.accuracyBtn.addEventListener("click", () => loadAccuracyPack(false));
1341
  els.stopBtn.addEventListener("click", stopStream);
1342
  els.video.addEventListener("loadedmetadata", fitCanvas);
1343
  window.addEventListener("resize", fitCanvas);
1344
- els.ageOffset.addEventListener("input", () => {
1345
- const value = Number(els.ageOffset.value || 0);
1346
- els.ageOffsetValue.textContent = `${value > 0 ? "+" : ""}${value}y`;
1347
- });
1348
 
1349
  resetDetails();
1350
  renderBars({});
1351
- setPill(els.proDot, els.proStatus, "Accuracy pack: idle", "loading");
1352
  loadCoreModels();
1353
  </script>
1354
  </body>
 
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
+ <script defer src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.min.js"></script>
10
  <style>
11
  :root {
12
  --bg-a: #050816;
 
70
 
71
  .brand h1 {
72
  margin: 0;
73
+ font-size: clamp(4.2rem, 9vw, 8rem);
74
+ line-height: 0.88;
75
  letter-spacing: -0.08em;
76
  font-weight: 950;
77
+ text-shadow: 0 24px 70px rgba(34, 211, 238, 0.10);
78
  }
79
 
80
  .brand p {
81
  margin: 18px 0 0;
82
+ max-width: 960px;
83
  color: var(--muted);
84
+ font-size: clamp(1rem, 1.7vw, 1.22rem);
85
  line-height: 1.55;
86
  }
87
 
 
103
  color: var(--muted);
104
  box-shadow: var(--shadow);
105
  white-space: nowrap;
106
+ font-weight: 850;
107
  }
108
 
109
  .dot {
 
125
  margin-bottom: 18px;
126
  }
127
 
128
+ button {
129
  appearance: none;
130
  border: 1px solid var(--stroke);
131
  background: rgba(15, 23, 42, 0.76);
 
140
  min-height: 48px;
141
  }
142
 
143
+ button:hover { transform: translateY(-1px); border-color: rgba(34, 211, 238, 0.55); }
144
  button:active { transform: translateY(0); }
145
  button.primary { background: linear-gradient(135deg, rgba(34,211,238,0.96), rgba(167,139,250,0.94)); color: #06111f; border-color: transparent; }
 
146
  button.danger { color: #fecdd3; }
147
  button:disabled { opacity: 0.46; cursor: not-allowed; transform: none; }
148
 
 
160
 
161
  .grid {
162
  display: grid;
163
+ grid-template-columns: minmax(0, 1.42fr) minmax(360px, 0.76fr);
164
  gap: 18px;
165
  align-items: stretch;
166
  }
 
211
  }
212
 
213
  .empty-card {
214
+ max-width: 620px;
215
  border: 1px solid var(--stroke);
216
+ background: rgba(15,23,42,0.70);
217
  border-radius: 24px;
218
  padding: 28px;
219
  }
 
278
 
279
  .wide { grid-column: 1 / -1; }
280
 
281
+ .bars, .note {
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 {
289
  margin: 0 0 14px;
290
  font-size: 1rem;
291
  color: var(--text);
 
318
  transition: width 180ms ease;
319
  }
320
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  .note {
322
  color: #cffafe;
323
  background: rgba(34,211,238,0.08);
 
356
  .stage-panel { padding: 10px; }
357
  .side { padding: 14px; }
358
  .toolbar { gap: 9px; }
359
+ button, .camera-tag { flex: 1 1 150px; justify-content: center; }
360
  }
361
 
362
  @media (max-width: 560px) {
363
+ .brand h1 { font-size: clamp(4rem, 22vw, 5.4rem); }
364
  .brand p { font-size: 0.98rem; }
365
  .metric-grid { grid-template-columns: 1fr; }
366
  .bar-row { grid-template-columns: 76px 1fr 44px; font-size: 0.82rem; }
 
367
  .empty-card { padding: 20px; }
368
  .app-shell { width: min(100% - 14px, 760px); }
369
  body { background-attachment: fixed; }
 
375
  <header class="hero" aria-label="SentAI heading">
376
  <div class="brand">
377
  <h1>SentAI</h1>
378
+ <p>Live facial analysis with automatic high-precision models for expression, apparent age range, and male/female estimate. No manual model switches.</p>
379
  </div>
380
  <div class="status-stack">
381
+ <div class="status-pill" aria-live="polite"><span id="aiDot" class="dot"></span><span id="aiStatus">Preparing AI models...</span></div>
382
+ <div class="status-pill" aria-live="polite"><span id="precisionDot" class="dot"></span><span id="precisionStatus">Precision models loading automatically</span></div>
383
  </div>
384
  </header>
385
 
386
  <section class="toolbar" aria-label="Camera controls">
387
  <button id="startBtn" class="primary" disabled>Start camera</button>
388
  <button id="switchBtn" disabled>Switch front/rear</button>
 
389
  <button id="stopBtn" class="danger" disabled>Stop</button>
 
 
 
 
 
 
 
 
 
 
390
  <span id="cameraTag" class="camera-tag">Camera: not started</span>
391
  </section>
392
 
 
398
  <div id="emptyState" class="empty-state">
399
  <div class="empty-card">
400
  <strong>Ready for live analysis</strong>
401
+ Tap <b>Start camera</b>. Use bright front lighting, keep one face centered, and hold each expression for a moment so the neural ensemble can stabilize.
402
  </div>
403
  </div>
404
  </div>
 
409
  <div class="metric-grid">
410
  <div class="metric">
411
  <span>Possible feeling</span>
412
+ <strong id="feelingValue">-</strong>
413
  <small id="feelingConfidence">Waiting</small>
414
  </div>
415
  <div class="metric">
416
  <span>Gender</span>
417
+ <strong id="genderValue">-</strong>
418
  <small id="genderConfidence">Waiting</small>
419
  </div>
420
  <div class="metric">
421
  <span>Apparent age</span>
422
+ <strong id="ageValue">-</strong>
423
  <small id="ageSource">Waiting</small>
424
  </div>
425
  <div class="metric">
 
429
  </div>
430
  <div class="metric">
431
  <span>FPS</span>
432
+ <strong id="fpsValue">-</strong>
433
+ <small>Live loop</small>
434
  </div>
435
  <div class="metric">
436
  <span>Latency</span>
437
+ <strong id="latencyValue">-</strong>
438
+ <small id="latencySource">Model time</small>
439
  </div>
440
  </div>
441
 
 
444
  <div id="emotionBars"></div>
445
  </div>
446
 
 
 
 
 
 
 
 
 
 
447
  <div class="note wide">
448
+ SentAI estimates visible facial expression and apparent age from camera frames. It cannot know a person's true internal feeling, but this version uses an automatic multi-model deep-learning ensemble to improve sad, fear, and disgust detection.
449
  </div>
450
  </aside>
451
  </section>
 
455
 
456
  <script type="module">
457
  import { pipeline as xenovaPipeline, env as xenovaEnv } from "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2";
458
+ import { AutoModel, AutoProcessor, load_image, env as hfEnv } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1";
459
+
460
  const FACE_API_MODEL_URL = "https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights";
461
+ const EMOTION_TRANSFORMER_MODEL_ID = "Xenova/facial_emotions_image_detection";
462
+ const AGE_GENDER_MODEL_ID = "onnx-community/age-gender-prediction-ONNX";
463
+ const OPENCV_FER_MODEL_URL = "https://huggingface.co/opencv/facial_expression_recognition/resolve/main/facial_expression_recognition_mobilefacenet_2022july.onnx";
464
 
465
  const els = {
466
+ aiDot: document.getElementById("aiDot"),
467
+ aiStatus: document.getElementById("aiStatus"),
468
+ precisionDot: document.getElementById("precisionDot"),
469
+ precisionStatus: document.getElementById("precisionStatus"),
470
  startBtn: document.getElementById("startBtn"),
471
  switchBtn: document.getElementById("switchBtn"),
 
472
  stopBtn: document.getElementById("stopBtn"),
 
 
473
  cameraTag: document.getElementById("cameraTag"),
474
  video: document.getElementById("video"),
475
  overlay: document.getElementById("overlay"),
 
487
  latencyValue: document.getElementById("latencyValue"),
488
  latencySource: document.getElementById("latencySource"),
489
  emotionBars: document.getElementById("emotionBars"),
 
 
490
  toast: document.getElementById("toast"),
491
  };
492
 
 
 
 
 
 
 
493
  const emotionLabels = ["Happy", "Sad", "Fear", "Anger", "Confused", "Disgust"];
494
+ const ferLabels = ["Anger", "Disgust", "Fear", "Happy", "Confused", "Sad", "Confused"];
495
+ const detectorConfig = { inputSize: 416, scoreThreshold: 0.32, intervalMs: 65, precisionIntervalMs: 650, smoothing: 0.26 };
496
  const ctx = els.overlay.getContext("2d");
497
 
498
  let coreReady = false;
 
506
  let emotionEma = null;
507
  let genderMaleEma = null;
508
  let lastBaseAgeSample = 0;
509
+ let latestPrimary = null;
510
 
511
  const ageHistory = [];
512
+ const precision = {
513
  loading: false,
514
+ readyCount: 0,
515
+ ferSession: null,
516
  emotionPipe: null,
517
+ ageModel: null,
518
+ ageProcessor: null,
 
519
  lastRun: 0,
520
  busy: false,
521
+ ferScores: null,
522
+ ferAt: 0,
523
+ transformerScores: null,
524
+ transformerAt: 0,
525
  age: null,
526
  ageAt: 0,
527
  gender: null,
528
  genderScore: 0,
529
  genderAt: 0,
530
  latency: 0,
 
531
  };
532
 
533
  function setPill(dot, label, text, kind = "loading") {
 
541
  els.toast.textContent = message;
542
  els.toast.classList.add("show");
543
  clearTimeout(showToast.timer);
544
+ showToast.timer = setTimeout(() => els.toast.classList.remove("show"), 3400);
545
  }
546
 
547
  function clamp01(value) {
 
556
  return 1 / (1 + Math.exp(-x));
557
  }
558
 
559
+ function softmax(values) {
560
+ const list = Array.from(values || []).map(v => Number(v));
561
+ if (!list.length) return [];
562
+ const max = Math.max(...list);
563
+ const exps = list.map(v => Math.exp(v - max));
564
+ const sum = exps.reduce((a, b) => a + b, 0) || 1;
565
+ return exps.map(v => v / sum);
566
+ }
567
+
568
  function median(values) {
569
  const list = values.filter(Number.isFinite).slice().sort((a, b) => a - b);
570
  if (!list.length) return NaN;
 
572
  return list.length % 2 ? list[mid] : (list[mid - 1] + list[mid]) / 2;
573
  }
574
 
 
 
 
 
 
 
 
 
 
 
 
575
  function blankScores() {
576
  return Object.fromEntries(emotionLabels.map(label => [label, 0]));
577
  }
 
582
  return out;
583
  }
584
 
585
+ function renormalize(scores) {
586
+ const out = normalizeScores(scores);
587
+ const sum = emotionLabels.reduce((a, label) => a + out[label], 0);
588
+ if (sum <= 0) return out;
589
+ for (const label of emotionLabels) out[label] = out[label] / sum;
590
+ return out;
591
+ }
592
+
593
+ function ageRange(age, source = "core", samples = 1) {
594
+ if (!Number.isFinite(age)) return "-";
595
+ const corrected = Math.max(0, Math.min(100, age));
596
+ const half = source === "precision model" ? (samples >= 3 ? 4 : 5) : (samples >= 6 ? 6 : 8);
597
+ const lo = Math.max(0, Math.round(corrected - half));
598
+ const hi = Math.min(100, Math.round(corrected + half));
599
+ if (hi <= 12) return "0-12";
600
+ return `${lo}-${hi}`;
601
+ }
602
+
603
  function calibrateFaceApiExpressions(expressions = {}) {
604
  const raw = {
605
  happy: clamp01(expressions.happy),
 
610
  surprised: clamp01(expressions.surprised),
611
  neutral: clamp01(expressions.neutral),
612
  };
 
613
  const nonNeutralTop = Math.max(raw.happy, raw.sad, raw.fearful, raw.angry, raw.disgusted, raw.surprised);
614
+ const confused = Math.min(0.38, raw.neutral * 0.24 + raw.surprised * 0.42 + Math.max(0, 1 - nonNeutralTop) * 0.05);
 
615
  return normalizeScores({
616
+ Happy: Math.pow(raw.happy, 1.10),
617
+ Sad: Math.pow(raw.sad, 0.96),
618
+ Fear: Math.max(Math.pow(raw.fearful, 0.98), raw.surprised * raw.fearful * 0.55),
619
+ Anger: Math.pow(raw.angry, 1.02),
620
+ Disgust: Math.pow(raw.disgusted, 0.96),
621
+ Confused: confused,
622
  });
623
  }
624
 
625
  function normalizeExternalEmotion(outputs) {
626
  const scores = blankScores();
627
  const list = Array.isArray(outputs) ? outputs : [outputs];
 
628
  for (const item of list) {
629
  const label = String(item.label || item.class || "").toLowerCase();
630
  const score = clamp01(item.score || item.probability || 0);
 
634
  else if (label.includes("angry") || label.includes("anger")) scores.Anger = Math.max(scores.Anger, score);
635
  else if (label.includes("disgust") || label.includes("disgusted")) scores.Disgust = Math.max(scores.Disgust, score);
636
  else if (label.includes("surprise") || label.includes("neutral")) {
637
+ const scaled = label.includes("neutral") ? score * 0.45 : score * 0.52;
638
+ scores.Confused = Math.max(scores.Confused, Math.min(0.58, scaled));
 
639
  }
640
  }
641
  return normalizeScores(scores);
642
  }
643
 
644
+ function calibrateFinalEmotion(scores) {
645
+ const priors = { Happy: 0.86, Sad: 1.18, Fear: 1.24, Anger: 0.96, Confused: 0.72, Disgust: 1.34 };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
  const out = blankScores();
647
  for (const label of emotionLabels) {
648
  let v = clamp01(scores[label] || 0);
649
+ if (["Sad", "Fear", "Disgust"].includes(label)) v = Math.pow(v, 0.92);
650
+ if (label === "Happy") v = Math.pow(v, 1.08);
651
+ if (label === "Confused") v = Math.pow(v, 1.04);
652
+ out[label] = clamp01(v * priors[label]);
 
653
  }
654
+ return renormalize(out);
 
 
655
  }
656
 
657
  function combineEmotionScores(faceScores) {
658
  const now = performance.now();
659
+ const ferFresh = precision.ferScores && (now - precision.ferAt < 3400);
660
+ const transformerFresh = precision.transformerScores && (now - precision.transformerAt < 5000);
661
  const combined = blankScores();
662
+
663
+ let totalWeight = 0;
664
+ const addWeighted = (scores, weight) => {
665
+ if (!scores || weight <= 0) return;
666
+ totalWeight += weight;
667
+ for (const label of emotionLabels) combined[label] += (scores[label] || 0) * weight;
668
+ };
669
+
670
+ if (ferFresh && transformerFresh) {
671
+ addWeighted(precision.ferScores, 0.55);
672
+ addWeighted(precision.transformerScores, 0.34);
673
+ addWeighted(faceScores, 0.11);
674
+ } else if (ferFresh) {
675
+ addWeighted(precision.ferScores, 0.76);
676
+ addWeighted(faceScores, 0.24);
677
+ } else if (transformerFresh) {
678
+ addWeighted(precision.transformerScores, 0.78);
679
+ addWeighted(faceScores, 0.22);
680
+ } else {
681
+ addWeighted(faceScores, 1.0);
682
  }
683
 
684
+ if (totalWeight > 0) {
685
+ for (const label of emotionLabels) combined[label] /= totalWeight;
686
+ }
687
+ const calibrated = calibrateFinalEmotion(combined);
688
  if (!emotionEma) {
689
  emotionEma = calibrated;
690
  } else {
691
  for (const label of emotionLabels) {
692
+ emotionEma[label] = emotionEma[label] * (1 - detectorConfig.smoothing) + calibrated[label] * detectorConfig.smoothing;
693
  }
694
  }
695
+ return renormalize(emotionEma);
696
  }
697
 
698
  function topEmotion(scores) {
699
+ const entries = Object.entries(scores).sort((a, b) => b[1] - a[1]);
700
+ let [label, score] = entries[0] || ["Confused", 0];
701
+ const nonConfused = entries.filter(([name]) => name !== "Confused");
702
+ const [altLabel, altScore] = nonConfused[0] || [label, score];
703
+
704
+ if (label === "Confused" && altScore >= 0.22 && (score - altScore) < 0.10) {
705
+ label = altLabel;
706
+ score = altScore;
 
 
707
  }
708
+ if (score < 0.18) {
 
 
 
709
  label = "Confused";
710
+ score = Math.max(scores.Confused || 0.16, 0.16);
711
  }
712
  return { label, score: clamp01(score) };
713
  }
 
716
  if (!Number.isFinite(age) || age < 0 || age > 100) return;
717
  const now = performance.now();
718
  ageHistory.push({ age, source, weight, t: now });
719
+ while (ageHistory.length > 50) ageHistory.shift();
720
+ const cutoff = now - 45000;
721
  while (ageHistory.length && ageHistory[0].t < cutoff) ageHistory.shift();
722
  }
723
 
724
  function stableAgeEstimate() {
725
+ const precisionSamples = ageHistory.filter(s => s.source === "precision model");
726
+ const usable = precisionSamples.length >= 2 ? precisionSamples : ageHistory;
727
  if (!usable.length) return { age: NaN, source: "Waiting", samples: 0 };
728
  const expanded = [];
729
  for (const sample of usable) {
 
732
  }
733
  return {
734
  age: median(expanded),
735
+ source: precisionSamples.length >= 2 ? "precision model" : "core model",
736
  samples: usable.length,
737
  };
738
  }
 
740
  function updateGenderEstimate(label, confidence, weight = 1) {
741
  if (!label) return;
742
  const pMale = label.toLowerCase() === "male" ? clamp01(confidence) : 1 - clamp01(confidence);
743
+ const alpha = Math.min(0.68, 0.15 * weight);
744
  genderMaleEma = genderMaleEma === null ? pMale : genderMaleEma * (1 - alpha) + pMale * alpha;
745
  }
746
 
747
  function currentGender() {
748
+ if (genderMaleEma === null) return { label: "-", confidence: 0 };
749
  const label = genderMaleEma >= 0.5 ? "Male" : "Female";
750
  const confidence = Math.max(genderMaleEma, 1 - genderMaleEma);
751
  return { label, confidence };
 
757
  const emotion = topEmotion(scores);
758
 
759
  const now = performance.now();
760
+ if (Number.isFinite(det.age) && now - lastBaseAgeSample > 700) {
761
  pushAgeSample(det.age, "core model", 1);
762
  lastBaseAgeSample = now;
763
  }
764
 
765
  const coreGender = (det.gender || "").toLowerCase() === "female" ? "Female" : "Male";
766
  updateGenderEstimate(coreGender, det.genderProbability || 0, 1);
767
+ if (precision.gender && (now - precision.genderAt < 9000)) updateGenderEstimate(precision.gender, precision.genderScore, 3.8);
 
 
768
 
769
  const age = stableAgeEstimate();
770
  const gender = currentGender();
 
771
  return {
772
  emotionLabel: emotion.label,
773
  emotionScore: emotion.score,
 
796
  emotionEma = null;
797
  genderMaleEma = null;
798
  ageHistory.length = 0;
799
+ els.feelingValue.textContent = "-";
800
  els.feelingConfidence.textContent = "Waiting";
801
+ els.genderValue.textContent = "-";
802
  els.genderConfidence.textContent = "Waiting";
803
+ els.ageValue.textContent = "-";
804
  els.ageSource.textContent = "Waiting";
805
  els.facesValue.textContent = "0";
806
  els.faceScore.textContent = "No face yet";
807
+ els.latencyValue.textContent = "-";
808
+ els.latencySource.textContent = "Model time";
809
  renderBars({});
810
  }
811
 
 
818
  })[0];
819
  }
820
 
821
+ function updatePrecisionStatus() {
822
+ const parts = [];
823
+ if (precision.ferSession) parts.push("MobileFaceNet emotion");
824
+ if (precision.emotionPipe) parts.push("ViT emotion");
825
+ if (precision.ageModel && precision.ageProcessor) parts.push("ViT age/gender");
826
+ if (parts.length >= 2) setPill(els.precisionDot, els.precisionStatus, `Precision ready: ${parts.join(" + ")}`, "ready");
827
+ else if (parts.length === 1) setPill(els.precisionDot, els.precisionStatus, `Precision partially ready: ${parts[0]}`, "ready");
828
+ else if (precision.loading) setPill(els.precisionDot, els.precisionStatus, "Precision models loading automatically", "loading");
829
+ else setPill(els.precisionDot, els.precisionStatus, "Precision models unavailable; using core AI", "error");
830
+ }
831
+
832
  function updateDetails(detections, elapsedMs) {
833
  els.facesValue.textContent = String(detections.length);
834
  els.latencyValue.textContent = `${Math.round(elapsedMs)}ms`;
835
+ els.latencySource.textContent = precision.latency ? `Core + precision ${Math.round(precision.latency)}ms` : "Core model time";
836
 
837
  const now = performance.now();
838
  const instantFps = 1000 / Math.max(1, now - lastLoopTime);
 
841
  els.fpsValue.textContent = fpsSmooth.toFixed(1);
842
 
843
  const primary = choosePrimary(detections);
844
+ latestPrimary = primary;
845
  if (!primary) {
846
+ els.feelingValue.textContent = "-";
847
  els.feelingConfidence.textContent = "No face";
848
+ els.genderValue.textContent = "-";
849
  els.genderConfidence.textContent = "No face";
850
+ els.ageValue.textContent = "-";
851
  els.ageSource.textContent = "No face";
852
  els.faceScore.textContent = "No face yet";
853
  renderBars({});
854
  return;
855
  }
856
 
857
+ maybeRunPrecision(primary);
858
  const insight = makeInsight(primary);
859
  els.feelingValue.textContent = insight.emotionLabel;
860
  els.feelingConfidence.textContent = `${percent(insight.emotionScore)} confidence`;
861
  els.genderValue.textContent = insight.gender;
862
+ els.genderConfidence.textContent = insight.gender === "-" ? "Waiting" : `${percent(insight.genderScore)} confidence`;
863
  els.ageValue.textContent = insight.ageRange;
864
  els.ageSource.textContent = insight.ageSource;
865
  els.faceScore.textContent = `${percent(insight.faceScore)} face score`;
 
906
  const font2 = Math.round(14 * scale);
907
  const lineH = font1 + 9 * scale;
908
  const label1 = `${insight.emotionLabel} ${percent(insight.emotionScore)}`;
909
+ const label2 = `${insight.gender} ${percent(insight.genderScore)} | Age ${insight.ageRange}`;
910
 
911
  ctx.font = `900 ${font1}px Inter, system-ui, sans-serif`;
912
  const textW = Math.max(ctx.measureText(label1).width, ctx.measureText(label2).width);
 
956
  if (lastDetections.length) drawDetections(lastDetections);
957
  }
958
 
959
+ function cropFaceCanvas(det, targetSize = 288, pad = 0.28, filter = "none", mirror = false) {
960
  const box = det.detection.box;
961
  const videoW = els.video.videoWidth || els.overlay.width;
962
  const videoH = els.video.videoHeight || els.overlay.height;
 
996
  });
997
  }
998
 
999
+ async function waitForGlobal(name, timeoutMs = 14000) {
1000
  const started = performance.now();
1001
+ while (!window[name]) {
1002
+ if (performance.now() - started > timeoutMs) throw new Error(`${name} did not load`);
1003
  await new Promise(resolve => setTimeout(resolve, 80));
1004
  }
1005
+ return window[name];
1006
  }
1007
 
1008
  async function loadCoreModels() {
1009
  try {
1010
+ await waitForGlobal("faceapi");
1011
  await Promise.all([
1012
+ faceapi.nets.ssdMobilenetv1.loadFromUri(FACE_API_MODEL_URL),
1013
  faceapi.nets.tinyFaceDetector.loadFromUri(FACE_API_MODEL_URL),
1014
  faceapi.nets.faceLandmark68TinyNet.loadFromUri(FACE_API_MODEL_URL),
1015
  faceapi.nets.faceExpressionNet.loadFromUri(FACE_API_MODEL_URL),
1016
  faceapi.nets.ageGenderNet.loadFromUri(FACE_API_MODEL_URL),
1017
  ]);
1018
  coreReady = true;
1019
+ setPill(els.aiDot, els.aiStatus, "AI ready", "ready");
1020
  els.startBtn.disabled = false;
 
1021
  renderBars({});
1022
+ loadPrecisionModels();
1023
  } catch (err) {
1024
  console.error(err);
1025
+ setPill(els.aiDot, els.aiStatus, "AI model loading failed", "error");
1026
+ showToast("Model loading failed. Refresh the Space and check browser network access.");
1027
  }
1028
  }
1029
 
1030
+ async function loadOpenCvFerModel() {
1031
+ await waitForGlobal("ort", 16000);
1032
+ ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/";
1033
+ ort.env.wasm.numThreads = Math.max(1, Math.min(4, navigator.hardwareConcurrency || 2));
1034
+ precision.ferSession = await ort.InferenceSession.create(OPENCV_FER_MODEL_URL, { executionProviders: ["wasm"] });
1035
+ updatePrecisionStatus();
 
 
 
 
 
 
 
1036
  }
1037
 
1038
+ async function loadTransformerEmotionModel() {
1039
+ if (xenovaEnv) {
1040
+ xenovaEnv.allowLocalModels = false;
1041
+ xenovaEnv.useBrowserCache = true;
1042
+ xenovaEnv.backends ??= {};
1043
+ xenovaEnv.backends.onnx ??= {};
1044
+ xenovaEnv.backends.onnx.wasm ??= {};
1045
+ xenovaEnv.backends.onnx.wasm.numThreads = Math.max(1, Math.min(4, navigator.hardwareConcurrency || 2));
1046
+ }
1047
+ precision.emotionPipe = await xenovaPipeline("image-classification", EMOTION_TRANSFORMER_MODEL_ID, { quantized: true });
1048
+ updatePrecisionStatus();
1049
+ }
1050
+
1051
+ async function loadAgeGenderModel() {
1052
+ if (hfEnv) {
1053
+ hfEnv.allowLocalModels = false;
1054
+ hfEnv.useBrowserCache = true;
1055
+ hfEnv.backends ??= {};
1056
+ hfEnv.backends.onnx ??= {};
1057
+ hfEnv.backends.onnx.wasm ??= {};
1058
+ hfEnv.backends.onnx.wasm.numThreads = Math.max(1, Math.min(4, navigator.hardwareConcurrency || 2));
1059
+ }
1060
+ const opts = navigator.gpu ? { device: "webgpu", dtype: "q8" } : { device: "wasm", dtype: "q8" };
1061
+ try {
1062
+ precision.ageModel = await AutoModel.from_pretrained(AGE_GENDER_MODEL_ID, opts);
1063
+ } catch (err) {
1064
+ console.warn("Age/gender preferred backend failed; retrying wasm", err);
1065
+ precision.ageModel = await AutoModel.from_pretrained(AGE_GENDER_MODEL_ID, { device: "wasm", dtype: "q8" });
1066
+ }
1067
+ precision.ageProcessor = await AutoProcessor.from_pretrained(AGE_GENDER_MODEL_ID);
1068
+ updatePrecisionStatus();
1069
+ }
1070
+
1071
+ async function loadPrecisionModels() {
1072
+ if (precision.loading) return;
1073
+ precision.loading = true;
1074
+ updatePrecisionStatus();
1075
+ const tasks = [
1076
+ loadOpenCvFerModel().catch(err => console.warn("OpenCV FER model unavailable", err)),
1077
+ loadTransformerEmotionModel().catch(err => console.warn("Transformer emotion model unavailable", err)),
1078
+ loadAgeGenderModel().catch(err => console.warn("Age/gender transformer unavailable", err)),
1079
  ];
1080
+ await Promise.allSettled(tasks);
1081
+ precision.loading = false;
1082
+ updatePrecisionStatus();
1083
+ if (precision.ferSession || precision.emotionPipe || precision.ageModel) showToast("Precision models are ready.");
1084
+ }
1085
+
1086
+ function averagePoint(points) {
1087
+ const list = (points || []).map(p => ({ x: p.x, y: p.y })).filter(p => Number.isFinite(p.x) && Number.isFinite(p.y));
1088
+ if (!list.length) return null;
1089
+ return {
1090
+ x: list.reduce((a, p) => a + p.x, 0) / list.length,
1091
+ y: list.reduce((a, p) => a + p.y, 0) / list.length,
1092
+ };
1093
+ }
1094
+
1095
+ function getLandmarkPoints(det) {
1096
+ if (!det.landmarks) return null;
1097
+ const leftEye = averagePoint(det.landmarks.getLeftEye?.() || []);
1098
+ const rightEye = averagePoint(det.landmarks.getRightEye?.() || []);
1099
+ const noseList = det.landmarks.getNose?.() || [];
1100
+ const mouthList = det.landmarks.getMouth?.() || [];
1101
+ const noseTip = noseList.length ? noseList[Math.min(3, noseList.length - 1)] : null;
1102
+ if (!leftEye || !rightEye || !noseTip || !mouthList.length) return null;
1103
+ const eyes = [leftEye, rightEye].sort((a, b) => a.x - b.x);
1104
+ const mouths = mouthList.slice().sort((a, b) => a.x - b.x);
1105
+ const leftMouth = mouths[0];
1106
+ const rightMouth = mouths[mouths.length - 1];
1107
+ return [eyes[0], eyes[1], noseTip, leftMouth, rightMouth];
1108
+ }
1109
+
1110
+ function solveLinearSystem(A, b) {
1111
+ const n = b.length;
1112
+ const M = A.map((row, i) => row.concat([b[i]]));
1113
+ for (let col = 0; col < n; col += 1) {
1114
+ let pivot = col;
1115
+ for (let r = col + 1; r < n; r += 1) if (Math.abs(M[r][col]) > Math.abs(M[pivot][col])) pivot = r;
1116
+ if (Math.abs(M[pivot][col]) < 1e-8) return null;
1117
+ [M[col], M[pivot]] = [M[pivot], M[col]];
1118
+ const div = M[col][col];
1119
+ for (let c = col; c <= n; c += 1) M[col][c] /= div;
1120
+ for (let r = 0; r < n; r += 1) {
1121
+ if (r === col) continue;
1122
+ const factor = M[r][col];
1123
+ for (let c = col; c <= n; c += 1) M[r][c] -= factor * M[col][c];
1124
  }
1125
  }
1126
+ return M.map(row => row[n]);
1127
+ }
1128
+
1129
+ function estimateAffine(srcPts, dstPts) {
1130
+ const rows = [];
1131
+ const vals = [];
1132
+ for (let i = 0; i < srcPts.length; i += 1) {
1133
+ const sx = srcPts[i].x;
1134
+ const sy = srcPts[i].y;
1135
+ const dx = dstPts[i].x;
1136
+ const dy = dstPts[i].y;
1137
+ rows.push([sx, sy, 1, 0, 0, 0]); vals.push(dx);
1138
+ rows.push([0, 0, 0, sx, sy, 1]); vals.push(dy);
1139
+ }
1140
+ const ATA = Array.from({ length: 6 }, () => Array(6).fill(0));
1141
+ const ATb = Array(6).fill(0);
1142
+ for (let r = 0; r < rows.length; r += 1) {
1143
+ for (let i = 0; i < 6; i += 1) {
1144
+ ATb[i] += rows[r][i] * vals[r];
1145
+ for (let j = 0; j < 6; j += 1) ATA[i][j] += rows[r][i] * rows[r][j];
1146
+ }
1147
+ }
1148
+ return solveLinearSystem(ATA, ATb);
1149
+ }
1150
+
1151
+ function makeAlignedFaceCanvas(det, targetSize = 112) {
1152
+ const points = getLandmarkPoints(det);
1153
+ if (!points) return cropFaceCanvas(det, targetSize, 0.25, "contrast(1.08)");
1154
+ const dst = [
1155
+ { x: 38.2946, y: 51.6963 },
1156
+ { x: 73.5318, y: 51.5014 },
1157
+ { x: 56.0252, y: 71.7366 },
1158
+ { x: 41.5493, y: 92.3655 },
1159
+ { x: 70.7299, y: 92.2041 },
1160
+ ];
1161
+ const sol = estimateAffine(points, dst);
1162
+ if (!sol) return cropFaceCanvas(det, targetSize, 0.25, "contrast(1.08)");
1163
+ const canvas = document.createElement("canvas");
1164
+ canvas.width = targetSize;
1165
+ canvas.height = targetSize;
1166
+ const c = canvas.getContext("2d", { willReadFrequently: true });
1167
+ c.fillStyle = "#000";
1168
+ c.fillRect(0, 0, targetSize, targetSize);
1169
+ c.setTransform(sol[0], sol[3], sol[1], sol[4], sol[2], sol[5]);
1170
+ c.filter = "contrast(1.08) saturate(0.95)";
1171
+ c.drawImage(els.video, 0, 0);
1172
+ c.setTransform(1, 0, 0, 1, 0, 0);
1173
+ c.filter = "none";
1174
+ return canvas;
1175
  }
1176
 
1177
+ function canvasToNchwTensor(canvas) {
1178
+ const c = canvas.getContext("2d", { willReadFrequently: true });
1179
+ const { data } = c.getImageData(0, 0, canvas.width, canvas.height);
1180
+ const size = canvas.width * canvas.height;
1181
+ const out = new Float32Array(3 * size);
1182
+ for (let i = 0; i < size; i += 1) {
1183
+ const j = i * 4;
1184
+ out[i] = (data[j] / 255 - 0.5) / 0.5;
1185
+ out[size + i] = (data[j + 1] / 255 - 0.5) / 0.5;
1186
+ out[size * 2 + i] = (data[j + 2] / 255 - 0.5) / 0.5;
 
 
 
 
 
 
1187
  }
1188
+ return out;
 
 
1189
  }
1190
 
1191
+ async function runOpenCvFer(det) {
1192
+ if (!precision.ferSession || !window.ort) return null;
1193
+ const canvas = makeAlignedFaceCanvas(det, 112);
1194
+ const input = canvasToNchwTensor(canvas);
1195
+ const tensor = new ort.Tensor("float32", input, [1, 3, 112, 112]);
1196
+ const feeds = {};
1197
+ feeds[precision.ferSession.inputNames[0]] = tensor;
1198
+ const results = await precision.ferSession.run(feeds);
1199
+ const outputName = precision.ferSession.outputNames[0];
1200
+ const logits = Array.from(results[outputName].data).slice(0, 7);
1201
+ const probs = softmax(logits);
1202
+ const scores = blankScores();
1203
+ for (let i = 0; i < probs.length && i < ferLabels.length; i += 1) {
1204
+ const label = ferLabels[i];
1205
+ scores[label] = Math.max(scores[label], probs[i]);
1206
+ }
1207
+ return normalizeScores(scores);
1208
+ }
1209
 
1210
+ async function runTransformerEmotion(det) {
1211
+ if (!precision.emotionPipe) return null;
1212
+ const variants = [
1213
+ cropFaceCanvas(det, 288, 0.14, "contrast(1.10) saturate(0.95)", false),
1214
+ cropFaceCanvas(det, 288, 0.30, "contrast(1.16) saturate(0.92)", false),
1215
+ cropFaceCanvas(det, 288, 0.08, "contrast(1.18) brightness(1.02)", false),
1216
+ cropFaceCanvas(det, 288, 0.20, "contrast(1.12) saturate(0.94)", true),
1217
+ ];
1218
+ const aggregate = blankScores();
1219
+ const urls = [];
1220
+ let count = 0;
1221
  try {
1222
+ for (const variant of variants) {
1223
+ const url = await canvasToBlobUrl(variant);
1224
+ urls.push(url);
1225
+ let output;
1226
+ try {
1227
+ output = await precision.emotionPipe(url, { topK: 7 });
1228
+ } catch (_) {
1229
+ output = await precision.emotionPipe(url);
 
 
 
 
 
 
1230
  }
1231
+ const scores = normalizeExternalEmotion(output);
1232
+ for (const label of emotionLabels) aggregate[label] += scores[label] || 0;
1233
+ count += 1;
1234
  }
1235
+ } finally {
1236
+ for (const url of urls) URL.revokeObjectURL(url);
1237
+ }
1238
+ if (!count) return null;
1239
+ for (const label of emotionLabels) aggregate[label] /= count;
1240
+ return normalizeScores(aggregate);
1241
+ }
1242
 
1243
+ async function tensorValues(tensor) {
1244
+ if (!tensor) return [];
1245
+ if (tensor.tolist) {
1246
+ const listed = tensor.tolist();
1247
+ return Array.isArray(listed) ? listed.flat(Infinity) : [];
1248
+ }
1249
+ if (tensor.data) return Array.from(tensor.data);
1250
+ return [];
1251
+ }
1252
+
1253
+ async function runAgeGender(det) {
1254
+ if (!precision.ageModel || !precision.ageProcessor || !load_image) return;
1255
+ const canvas = cropFaceCanvas(det, 384, 0.22, "contrast(1.07) saturate(0.96)");
1256
+ let url = null;
1257
+ try {
1258
+ url = await canvasToBlobUrl(canvas);
1259
+ const image = await load_image(url);
1260
+ const inputs = await precision.ageProcessor(image);
1261
+ const output = await precision.ageModel(inputs);
1262
+ const values = await tensorValues(output.logits || output[0]);
1263
+ if (values.length < 2) return;
1264
+ const rawAge = Number(values[0]);
1265
+ const rawGender = Number(values[1]);
1266
+ if (Number.isFinite(rawAge)) {
1267
+ const age = Math.max(0, Math.min(100, Math.round(rawAge)));
1268
+ precision.age = age;
1269
+ precision.ageAt = performance.now();
1270
+ pushAgeSample(age, "precision model", 5);
1271
+ }
1272
+ if (Number.isFinite(rawGender)) {
1273
+ const pFemale = rawGender >= 0 && rawGender <= 1 ? rawGender : sigmoid(rawGender);
1274
+ precision.gender = pFemale >= 0.5 ? "Female" : "Male";
1275
+ precision.genderScore = Math.max(pFemale, 1 - pFemale);
1276
+ precision.genderAt = performance.now();
1277
  }
 
 
 
 
1278
  } finally {
1279
+ if (url) URL.revokeObjectURL(url);
 
1280
  }
1281
  }
1282
 
1283
+ async function maybeRunPrecision(primary) {
 
1284
  const now = performance.now();
1285
+ if (!primary || precision.busy || (!precision.ferSession && !precision.emotionPipe && !precision.ageModel)) return;
1286
+ if (now - precision.lastRun < detectorConfig.precisionIntervalMs) return;
1287
+ precision.busy = true;
1288
+ precision.lastRun = now;
 
 
1289
  const started = performance.now();
1290
  try {
1291
+ if (precision.ferSession) {
1292
+ const fer = await runOpenCvFer(primary);
1293
+ if (fer) {
1294
+ precision.ferScores = fer;
1295
+ precision.ferAt = performance.now();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1296
  }
 
 
 
1297
  }
1298
+ if (precision.emotionPipe) {
1299
+ const transformer = await runTransformerEmotion(primary);
1300
+ if (transformer) {
1301
+ precision.transformerScores = transformer;
1302
+ precision.transformerAt = performance.now();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1303
  }
1304
  }
1305
+ if (precision.ageModel && precision.ageProcessor) await runAgeGender(primary);
1306
  } catch (err) {
1307
+ console.warn("Precision inference failed", err);
1308
  } finally {
1309
+ precision.latency = performance.now() - started;
1310
+ precision.busy = false;
 
1311
  }
1312
  }
1313
 
1314
  function stopStream() {
1315
+ if (stream) for (const track of stream.getTracks()) track.stop();
 
 
1316
  stream = null;
1317
  running = false;
1318
  detecting = false;
 
1328
 
1329
  async function startCamera(facing = currentFacing) {
1330
  if (!coreReady) {
1331
+ showToast("Please wait until the AI models finish loading.");
1332
  return;
1333
  }
1334
 
 
1340
  audio: false,
1341
  video: {
1342
  facingMode: { ideal: currentFacing },
1343
+ width: { ideal: 1280 },
1344
  height: { ideal: 720 },
1345
  }
1346
  };
 
1373
  detectLoop();
1374
  }
1375
 
1376
+ async function detectFacesAccurate() {
1377
+ try {
1378
+ const options = new faceapi.SsdMobilenetv1Options({ minConfidence: 0.42 });
1379
+ return await faceapi
1380
+ .detectAllFaces(els.video, options)
1381
+ .withFaceLandmarks(true)
1382
+ .withFaceExpressions()
1383
+ .withAgeAndGender();
1384
+ } catch (err) {
1385
+ console.warn("SSD detector failed; using tiny detector", err);
1386
+ const tiny = new faceapi.TinyFaceDetectorOptions({ inputSize: detectorConfig.inputSize, scoreThreshold: detectorConfig.scoreThreshold });
1387
+ return await faceapi
1388
+ .detectAllFaces(els.video, tiny)
1389
+ .withFaceLandmarks(true)
1390
+ .withFaceExpressions()
1391
+ .withAgeAndGender();
1392
+ }
1393
+ }
1394
+
1395
  async function detectLoop() {
1396
  if (!running || !coreReady || detecting) return;
1397
  detecting = true;
1398
  const started = performance.now();
1399
  try {
1400
  fitCanvas();
1401
+ const raw = await detectFacesAccurate();
 
 
 
 
 
 
1402
  const displaySize = { width: els.overlay.width, height: els.overlay.height };
1403
  const resized = faceapi.resizeResults(raw, displaySize);
1404
  const elapsed = performance.now() - started;
 
1407
  setTimeout(() => {
1408
  detecting = false;
1409
  requestAnimationFrame(detectLoop);
1410
+ }, detectorConfig.intervalMs);
1411
  } catch (err) {
1412
  console.error(err);
1413
  detecting = false;
 
1421
  currentFacing = currentFacing === "user" ? "environment" : "user";
1422
  startCamera(currentFacing);
1423
  });
 
1424
  els.stopBtn.addEventListener("click", stopStream);
1425
  els.video.addEventListener("loadedmetadata", fitCanvas);
1426
  window.addEventListener("resize", fitCanvas);
 
 
 
 
1427
 
1428
  resetDetails();
1429
  renderBars({});
1430
+ setPill(els.precisionDot, els.precisionStatus, "Precision models loading automatically", "loading");
1431
  loadCoreModels();
1432
  </script>
1433
  </body>