Spaces:
Running
Running
Upload 2 files
Browse files- README.md +3 -0
- index.html +30 -20
README.md
CHANGED
|
@@ -24,3 +24,6 @@ SentAI is a browser-based live camera application for face detection, expression
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
Phase 3B fixes the accuracy-pack loader by using the Transformers.js package version that the selected ONNX emotion model was published for.
|
index.html
CHANGED
|
@@ -485,10 +485,11 @@
|
|
| 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/@
|
| 490 |
const EMOTION_MODEL_ID = "Xenova/facial_emotions_image_detection";
|
| 491 |
-
const AGE_GENDER_MODEL_ID =
|
| 492 |
|
| 493 |
const els = {
|
| 494 |
coreDot: document.getElementById("coreDot"),
|
|
@@ -630,7 +631,7 @@
|
|
| 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.
|
| 634 |
|
| 635 |
return normalizeScores({
|
| 636 |
Happy: Math.pow(raw.happy, 1.08),
|
|
@@ -638,7 +639,7 @@
|
|
| 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.
|
| 642 |
});
|
| 643 |
}
|
| 644 |
|
|
@@ -655,8 +656,8 @@
|
|
| 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.
|
| 659 |
-
scores.Confused = Math.max(scores.Confused, Math.min(0.
|
| 660 |
}
|
| 661 |
}
|
| 662 |
return normalizeScores(scores);
|
|
@@ -689,9 +690,13 @@
|
|
| 689 |
const second = sorted[1] || ["", 0];
|
| 690 |
let label = top[0];
|
| 691 |
let score = top[1];
|
| 692 |
-
if (score < 0.
|
| 693 |
label = "Confused";
|
| 694 |
-
score = Math.max(scores.Confused || 0, 0.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 695 |
}
|
| 696 |
return { label, score: clamp01(score) };
|
| 697 |
}
|
|
@@ -994,26 +999,31 @@
|
|
| 994 |
}
|
| 995 |
|
| 996 |
async function getTransformersModule() {
|
| 997 |
-
|
|
|
|
|
|
|
| 998 |
if (mod.env) {
|
| 999 |
mod.env.allowLocalModels = false;
|
| 1000 |
-
|
| 1001 |
-
|
| 1002 |
-
}
|
|
|
|
|
|
|
| 1003 |
}
|
| 1004 |
return mod;
|
| 1005 |
}
|
| 1006 |
|
| 1007 |
async function loadPipelineWithFallback(pipeline, task, modelId) {
|
| 1008 |
-
const
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
|
|
|
|
| 1012 |
let lastErr = null;
|
| 1013 |
for (const opts of attempts) {
|
| 1014 |
try {
|
| 1015 |
const pipe = await pipeline(task, modelId, opts);
|
| 1016 |
-
pro.device =
|
| 1017 |
return pipe;
|
| 1018 |
} catch (err) {
|
| 1019 |
lastErr = err;
|
|
@@ -1050,7 +1060,7 @@
|
|
| 1050 |
pro.loading = true;
|
| 1051 |
pro.tried = true;
|
| 1052 |
els.accuracyBtn.disabled = true;
|
| 1053 |
-
setPill(els.proDot, els.proStatus, auto ? "
|
| 1054 |
if (!auto) showToast("Loading higher-accuracy transformer models. First load can take time.");
|
| 1055 |
|
| 1056 |
try {
|
|
@@ -1064,7 +1074,7 @@
|
|
| 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;
|
|
@@ -1084,7 +1094,7 @@
|
|
| 1084 |
}
|
| 1085 |
} catch (err) {
|
| 1086 |
console.error(err);
|
| 1087 |
-
setPill(els.proDot, els.proStatus, "
|
| 1088 |
if (!auto) showToast("Accuracy pack could not load. The app will keep using the core model.");
|
| 1089 |
} finally {
|
| 1090 |
pro.loading = false;
|
|
|
|
| 485 |
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
| 486 |
|
| 487 |
<script type="module">
|
| 488 |
+
import { pipeline as xenovaPipeline, env as xenovaEnv } from "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2";
|
| 489 |
const FACE_API_MODEL_URL = "https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights";
|
| 490 |
+
const TRANSFORMERS_CDN = "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2";
|
| 491 |
const EMOTION_MODEL_ID = "Xenova/facial_emotions_image_detection";
|
| 492 |
+
const AGE_GENDER_MODEL_ID = null; // Phase 3B keeps age on the stable face-api model and improves it with smoothing + calibration.
|
| 493 |
|
| 494 |
const els = {
|
| 495 |
coreDot: document.getElementById("coreDot"),
|
|
|
|
| 631 |
};
|
| 632 |
|
| 633 |
const nonNeutralTop = Math.max(raw.happy, raw.sad, raw.fearful, raw.angry, raw.disgusted, raw.surprised);
|
| 634 |
+
const uncertainty = clamp01((1 - nonNeutralTop) * 0.10 + raw.surprised * 0.42 + raw.neutral * 0.05);
|
| 635 |
|
| 636 |
return normalizeScores({
|
| 637 |
Happy: Math.pow(raw.happy, 1.08),
|
|
|
|
| 639 |
Fear: Math.max(Math.pow(raw.fearful, 1.04), raw.surprised * raw.fearful * 0.55),
|
| 640 |
Anger: Math.pow(raw.angry, 1.03),
|
| 641 |
Disgust: Math.pow(raw.disgusted, 1.02),
|
| 642 |
+
Confused: Math.min(0.42, uncertainty),
|
| 643 |
});
|
| 644 |
}
|
| 645 |
|
|
|
|
| 656 |
else if (label.includes("angry") || label.includes("anger")) scores.Anger = Math.max(scores.Anger, score);
|
| 657 |
else if (label.includes("disgust")) scores.Disgust = Math.max(scores.Disgust, score);
|
| 658 |
else if (label.includes("surprise") || label.includes("neutral")) {
|
| 659 |
+
const scaled = label.includes("neutral") ? score * 0.10 : score * 0.55;
|
| 660 |
+
scores.Confused = Math.max(scores.Confused, Math.min(0.48, scaled));
|
| 661 |
}
|
| 662 |
}
|
| 663 |
return normalizeScores(scores);
|
|
|
|
| 690 |
const second = sorted[1] || ["", 0];
|
| 691 |
let label = top[0];
|
| 692 |
let score = top[1];
|
| 693 |
+
if (score < 0.18) {
|
| 694 |
label = "Confused";
|
| 695 |
+
score = Math.max(scores.Confused || 0, 0.18);
|
| 696 |
+
} else if (score - second[1] < 0.035 && label !== "Confused") {
|
| 697 |
+
// When the visible expression is ambiguous, mark it as confused but keep confidence modest.
|
| 698 |
+
label = "Confused";
|
| 699 |
+
score = Math.max(Math.min(scores.Confused || 0, 0.34), 0.22);
|
| 700 |
}
|
| 701 |
return { label, score: clamp01(score) };
|
| 702 |
}
|
|
|
|
| 999 |
}
|
| 1000 |
|
| 1001 |
async function getTransformersModule() {
|
| 1002 |
+
// Use the legacy Xenova build because the selected emotion model was published for it.
|
| 1003 |
+
// This avoids the Phase 3A failure where the newer package could import but could not initialize the model.
|
| 1004 |
+
const mod = { pipeline: xenovaPipeline, env: xenovaEnv };
|
| 1005 |
if (mod.env) {
|
| 1006 |
mod.env.allowLocalModels = false;
|
| 1007 |
+
mod.env.useBrowserCache = true;
|
| 1008 |
+
mod.env.backends ??= {};
|
| 1009 |
+
mod.env.backends.onnx ??= {};
|
| 1010 |
+
mod.env.backends.onnx.wasm ??= {};
|
| 1011 |
+
mod.env.backends.onnx.wasm.numThreads = Math.max(1, Math.min(4, navigator.hardwareConcurrency || 2));
|
| 1012 |
}
|
| 1013 |
return mod;
|
| 1014 |
}
|
| 1015 |
|
| 1016 |
async function loadPipelineWithFallback(pipeline, task, modelId) {
|
| 1017 |
+
const attempts = [
|
| 1018 |
+
{ quantized: true, progress_callback: p => { if (p?.status) console.log("accuracy pack", p.status, p.file || ""); } },
|
| 1019 |
+
{ quantized: true },
|
| 1020 |
+
{},
|
| 1021 |
+
];
|
| 1022 |
let lastErr = null;
|
| 1023 |
for (const opts of attempts) {
|
| 1024 |
try {
|
| 1025 |
const pipe = await pipeline(task, modelId, opts);
|
| 1026 |
+
pro.device = "wasm";
|
| 1027 |
return pipe;
|
| 1028 |
} catch (err) {
|
| 1029 |
lastErr = err;
|
|
|
|
| 1060 |
pro.loading = true;
|
| 1061 |
pro.tried = true;
|
| 1062 |
els.accuracyBtn.disabled = true;
|
| 1063 |
+
setPill(els.proDot, els.proStatus, auto ? "Emotion accuracy pack: loading in background..." : "Emotion accuracy pack: loading...", "loading");
|
| 1064 |
if (!auto) showToast("Loading higher-accuracy transformer models. First load can take time.");
|
| 1065 |
|
| 1066 |
try {
|
|
|
|
| 1074 |
}
|
| 1075 |
|
| 1076 |
try {
|
| 1077 |
+
if (AGE_GENDER_MODEL_ID && mod.AutoModel && mod.AutoProcessor && (mod.load_image || mod.RawImage?.fromURL)) {
|
| 1078 |
const pair = await loadAgeGenderWithFallback(mod);
|
| 1079 |
pro.ageGenderModel = pair.model;
|
| 1080 |
pro.ageGenderProcessor = pair.processor;
|
|
|
|
| 1094 |
}
|
| 1095 |
} catch (err) {
|
| 1096 |
console.error(err);
|
| 1097 |
+
setPill(els.proDot, els.proStatus, "Emotion accuracy pack unavailable; using core model", "error");
|
| 1098 |
if (!auto) showToast("Accuracy pack could not load. The app will keep using the core model.");
|
| 1099 |
} finally {
|
| 1100 |
pro.loading = false;
|