SentAI / index.html
Solar-Prince's picture
Upload 2 files
7c3874c verified
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#050816" />
<title>SentAI</title>
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/face-api.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/ort.min.js"></script>
<style>
:root {
--bg-a: #050816;
--bg-b: #0d1b2f;
--card: rgba(15, 23, 42, 0.76);
--card-strong: rgba(15, 23, 42, 0.94);
--stroke: rgba(148, 163, 184, 0.24);
--text: #f8fafc;
--muted: #a7b4c8;
--soft: rgba(255,255,255,0.08);
--accent: #22d3ee;
--accent-2: #a78bfa;
--good: #2dd4bf;
--warn: #fbbf24;
--bad: #fb7185;
--shadow: 0 24px 90px rgba(0,0,0,0.42);
--radius: 28px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--text);
background:
radial-gradient(circle at 12% 10%, rgba(34, 211, 238, 0.26), transparent 34%),
radial-gradient(circle at 92% 2%, rgba(167, 139, 250, 0.23), transparent 34%),
linear-gradient(135deg, var(--bg-a), var(--bg-b) 52%, #112236);
overflow-x: hidden;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: radial-gradient(circle at 50% 20%, black 0%, transparent 75%);
}
.app-shell {
width: min(1460px, calc(100% - 32px));
margin: 0 auto;
padding: 34px 0 42px;
position: relative;
z-index: 1;
}
header.hero {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 22px;
margin-bottom: 26px;
}
.brand h1 {
margin: 0;
font-size: clamp(4.2rem, 9vw, 8rem);
line-height: 0.88;
letter-spacing: -0.08em;
font-weight: 950;
text-shadow: 0 24px 70px rgba(34, 211, 238, 0.10);
}
.brand p {
margin: 18px 0 0;
max-width: 960px;
color: var(--muted);
font-size: clamp(1rem, 1.7vw, 1.22rem);
line-height: 1.55;
}
.status-stack {
display: grid;
gap: 10px;
justify-items: end;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 10px;
border: 1px solid var(--stroke);
background: rgba(15, 23, 42, 0.64);
backdrop-filter: blur(18px);
border-radius: 999px;
padding: 12px 16px;
color: var(--muted);
box-shadow: var(--shadow);
white-space: nowrap;
font-weight: 850;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--warn);
box-shadow: 0 0 18px var(--warn);
}
.dot.ready { background: var(--good); box-shadow: 0 0 18px var(--good); }
.dot.error { background: var(--bad); box-shadow: 0 0 18px var(--bad); }
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 18px;
}
button {
appearance: none;
border: 1px solid var(--stroke);
background: rgba(15, 23, 42, 0.76);
color: var(--text);
border-radius: 999px;
padding: 13px 18px;
font-size: 0.98rem;
font-weight: 850;
letter-spacing: -0.01em;
cursor: pointer;
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, opacity 160ms ease;
min-height: 48px;
}
button:hover { transform: translateY(-1px); border-color: rgba(34, 211, 238, 0.55); }
button:active { transform: translateY(0); }
button.primary { background: linear-gradient(135deg, rgba(34,211,238,0.96), rgba(167,139,250,0.94)); color: #06111f; border-color: transparent; }
button.danger { color: #fecdd3; }
button:disabled { opacity: 0.46; cursor: not-allowed; transform: none; }
.camera-tag {
padding: 12px 16px;
min-height: 48px;
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid var(--stroke);
background: rgba(255,255,255,0.055);
color: var(--muted);
font-weight: 850;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1.42fr) minmax(360px, 0.76fr);
gap: 18px;
align-items: stretch;
}
.panel {
background: var(--card);
border: 1px solid var(--stroke);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(22px);
overflow: hidden;
}
.stage-panel { padding: 14px; }
.stage {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
border-radius: calc(var(--radius) - 10px);
overflow: hidden;
background:
radial-gradient(circle at 50% 30%, rgba(34,211,238,0.09), transparent 36%),
#020617;
border: 1px solid rgba(148, 163, 184, 0.18);
}
video, canvas#overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
video { background: #020617; }
canvas#overlay { pointer-events: none; }
.empty-state {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 28px;
text-align: center;
color: var(--muted);
pointer-events: none;
}
.empty-card {
max-width: 620px;
border: 1px solid var(--stroke);
background: rgba(15,23,42,0.70);
border-radius: 24px;
padding: 28px;
}
.empty-card strong {
display: block;
color: var(--text);
font-size: clamp(1.35rem, 3vw, 2.2rem);
margin-bottom: 12px;
}
.side {
padding: 18px;
display: grid;
gap: 14px;
align-content: start;
}
.side h2 {
margin: 2px 0 4px;
font-size: 1.35rem;
letter-spacing: -0.04em;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.metric {
border: 1px solid var(--stroke);
background: rgba(255,255,255,0.055);
border-radius: 22px;
padding: 16px;
min-height: 112px;
}
.metric span {
display: block;
color: var(--muted);
font-size: 0.82rem;
font-weight: 850;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.metric strong {
display: block;
margin-top: 10px;
font-size: clamp(1.55rem, 3.5vw, 2.25rem);
letter-spacing: -0.06em;
line-height: 1;
}
.metric small {
display: block;
margin-top: 7px;
color: var(--muted);
font-weight: 750;
}
.wide { grid-column: 1 / -1; }
.bars, .note {
border: 1px solid var(--stroke);
background: rgba(255,255,255,0.055);
border-radius: 22px;
padding: 16px;
}
.bars h3 {
margin: 0 0 14px;
font-size: 1rem;
color: var(--text);
letter-spacing: -0.02em;
}
.bar-row {
display: grid;
grid-template-columns: 86px 1fr 48px;
gap: 10px;
align-items: center;
margin: 11px 0;
color: var(--muted);
font-size: 0.9rem;
font-weight: 850;
}
.bar-track {
height: 9px;
border-radius: 99px;
background: rgba(148,163,184,0.2);
overflow: hidden;
}
.bar-fill {
width: 0%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
transition: width 180ms ease;
}
.note {
color: #cffafe;
background: rgba(34,211,238,0.08);
border-color: rgba(34,211,238,0.24);
line-height: 1.5;
font-size: 0.94rem;
}
.toast {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%) translateY(30px);
opacity: 0;
z-index: 10;
background: rgba(2, 6, 23, 0.94);
border: 1px solid var(--stroke);
border-radius: 999px;
padding: 13px 18px;
box-shadow: var(--shadow);
color: var(--text);
font-weight: 850;
transition: opacity 180ms ease, transform 180ms ease;
max-width: min(92vw, 760px);
text-align: center;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
@media (max-width: 980px) {
.app-shell { width: min(100% - 20px, 760px); padding-top: 22px; }
header.hero { grid-template-columns: 1fr; }
.status-stack { justify-items: start; }
.status-pill { width: fit-content; }
.grid { grid-template-columns: 1fr; }
.stage-panel { padding: 10px; }
.side { padding: 14px; }
.toolbar { gap: 9px; }
button, .camera-tag { flex: 1 1 150px; justify-content: center; }
}
@media (max-width: 560px) {
.brand h1 { font-size: clamp(4rem, 22vw, 5.4rem); }
.brand p { font-size: 0.98rem; }
.metric-grid { grid-template-columns: 1fr; }
.bar-row { grid-template-columns: 76px 1fr 44px; font-size: 0.82rem; }
.empty-card { padding: 20px; }
.app-shell { width: min(100% - 14px, 760px); }
body { background-attachment: fixed; }
}
</style>
</head>
<body>
<main class="app-shell">
<header class="hero" aria-label="SentAI heading">
<div class="brand">
<h1>SentAI</h1>
<p>Live facial analysis with automatic high-precision models for expression, apparent age range, and male/female estimate. No manual model switches.</p>
</div>
<div class="status-stack">
<div class="status-pill" aria-live="polite"><span id="aiDot" class="dot"></span><span id="aiStatus">Preparing AI models...</span></div>
<div class="status-pill" aria-live="polite"><span id="precisionDot" class="dot"></span><span id="precisionStatus">Precision models loading automatically</span></div>
</div>
</header>
<section class="toolbar" aria-label="Camera controls">
<button id="startBtn" class="primary" disabled>Start camera</button>
<button id="switchBtn" disabled>Switch front/rear</button>
<button id="stopBtn" class="danger" disabled>Stop</button>
<span id="cameraTag" class="camera-tag">Camera: not started</span>
</section>
<section class="grid">
<div class="panel stage-panel">
<div id="stage" class="stage">
<video id="video" autoplay muted playsinline></video>
<canvas id="overlay"></canvas>
<div id="emptyState" class="empty-state">
<div class="empty-card">
<strong>Ready for live analysis</strong>
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.
</div>
</div>
</div>
</div>
<aside class="panel side" aria-label="Live analysis panel">
<h2>Live details</h2>
<div class="metric-grid">
<div class="metric">
<span>Possible feeling</span>
<strong id="feelingValue">-</strong>
<small id="feelingConfidence">Waiting</small>
</div>
<div class="metric">
<span>Gender</span>
<strong id="genderValue">-</strong>
<small id="genderConfidence">Waiting</small>
</div>
<div class="metric">
<span>Apparent age</span>
<strong id="ageValue">-</strong>
<small id="ageSource">Waiting</small>
</div>
<div class="metric">
<span>Faces</span>
<strong id="facesValue">0</strong>
<small id="faceScore">No face yet</small>
</div>
<div class="metric">
<span>FPS</span>
<strong id="fpsValue">-</strong>
<small>Live loop</small>
</div>
<div class="metric">
<span>Latency</span>
<strong id="latencyValue">-</strong>
<small id="latencySource">Model time</small>
</div>
</div>
<div class="bars wide">
<h3>Feeling scores</h3>
<div id="emotionBars"></div>
</div>
<div class="note wide">
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.
</div>
</aside>
</section>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script type="module">
import { pipeline as xenovaPipeline, env as xenovaEnv } from "https://cdn.jsdelivr.net/npm/@xenova/[email protected]";
import { AutoModel, AutoProcessor, load_image, env as hfEnv } from "https://cdn.jsdelivr.net/npm/@huggingface/[email protected]";
const FACE_API_MODEL_URL = "https://cdn.jsdelivr.net/gh/justadudewhohacks/[email protected]/weights";
const EMOTION_TRANSFORMER_MODEL_ID = "Xenova/facial_emotions_image_detection";
const AGE_GENDER_MODEL_ID = "onnx-community/age-gender-prediction-ONNX";
const OPENCV_FER_MODEL_URL = "https://huggingface.co/opencv/facial_expression_recognition/resolve/main/facial_expression_recognition_mobilefacenet_2022july.onnx";
const els = {
aiDot: document.getElementById("aiDot"),
aiStatus: document.getElementById("aiStatus"),
precisionDot: document.getElementById("precisionDot"),
precisionStatus: document.getElementById("precisionStatus"),
startBtn: document.getElementById("startBtn"),
switchBtn: document.getElementById("switchBtn"),
stopBtn: document.getElementById("stopBtn"),
cameraTag: document.getElementById("cameraTag"),
video: document.getElementById("video"),
overlay: document.getElementById("overlay"),
stage: document.getElementById("stage"),
emptyState: document.getElementById("emptyState"),
feelingValue: document.getElementById("feelingValue"),
feelingConfidence: document.getElementById("feelingConfidence"),
genderValue: document.getElementById("genderValue"),
genderConfidence: document.getElementById("genderConfidence"),
ageValue: document.getElementById("ageValue"),
ageSource: document.getElementById("ageSource"),
facesValue: document.getElementById("facesValue"),
faceScore: document.getElementById("faceScore"),
fpsValue: document.getElementById("fpsValue"),
latencyValue: document.getElementById("latencyValue"),
latencySource: document.getElementById("latencySource"),
emotionBars: document.getElementById("emotionBars"),
toast: document.getElementById("toast"),
};
const emotionLabels = ["Happy", "Sad", "Fear", "Anger", "Confused", "Disgust"];
const ferLabels = ["Anger", "Disgust", "Fear", "Happy", "Confused", "Sad", "Confused"];
const detectorConfig = { inputSize: 416, scoreThreshold: 0.32, intervalMs: 65, precisionIntervalMs: 650, smoothing: 0.26 };
const ctx = els.overlay.getContext("2d");
let coreReady = false;
let stream = null;
let running = false;
let detecting = false;
let currentFacing = "user";
let lastLoopTime = performance.now();
let fpsSmooth = 0;
let lastDetections = [];
let emotionEma = null;
let genderMaleEma = null;
let lastBaseAgeSample = 0;
let latestPrimary = null;
const ageHistory = [];
const precision = {
loading: false,
readyCount: 0,
ferSession: null,
emotionPipe: null,
ageModel: null,
ageProcessor: null,
lastRun: 0,
busy: false,
ferScores: null,
ferAt: 0,
transformerScores: null,
transformerAt: 0,
age: null,
ageAt: 0,
gender: null,
genderScore: 0,
genderAt: 0,
latency: 0,
};
function setPill(dot, label, text, kind = "loading") {
label.textContent = text;
dot.classList.remove("ready", "error");
if (kind === "ready") dot.classList.add("ready");
if (kind === "error") dot.classList.add("error");
}
function showToast(message) {
els.toast.textContent = message;
els.toast.classList.add("show");
clearTimeout(showToast.timer);
showToast.timer = setTimeout(() => els.toast.classList.remove("show"), 3400);
}
function clamp01(value) {
return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
}
function percent(value) {
return `${Math.round(clamp01(value) * 100)}%`;
}
function sigmoid(x) {
return 1 / (1 + Math.exp(-x));
}
function softmax(values) {
const list = Array.from(values || []).map(v => Number(v));
if (!list.length) return [];
const max = Math.max(...list);
const exps = list.map(v => Math.exp(v - max));
const sum = exps.reduce((a, b) => a + b, 0) || 1;
return exps.map(v => v / sum);
}
function median(values) {
const list = values.filter(Number.isFinite).slice().sort((a, b) => a - b);
if (!list.length) return NaN;
const mid = Math.floor(list.length / 2);
return list.length % 2 ? list[mid] : (list[mid - 1] + list[mid]) / 2;
}
function blankScores() {
return Object.fromEntries(emotionLabels.map(label => [label, 0]));
}
function normalizeScores(scores) {
const out = blankScores();
for (const label of emotionLabels) out[label] = clamp01(scores[label] || 0);
return out;
}
function renormalize(scores) {
const out = normalizeScores(scores);
const sum = emotionLabels.reduce((a, label) => a + out[label], 0);
if (sum <= 0) return out;
for (const label of emotionLabels) out[label] = out[label] / sum;
return out;
}
function ageRange(age, source = "core", samples = 1) {
if (!Number.isFinite(age)) return "-";
const corrected = Math.max(0, Math.min(100, age));
const half = source === "precision model" ? (samples >= 3 ? 4 : 5) : (samples >= 6 ? 6 : 8);
const lo = Math.max(0, Math.round(corrected - half));
const hi = Math.min(100, Math.round(corrected + half));
if (hi <= 12) return "0-12";
return `${lo}-${hi}`;
}
function calibrateFaceApiExpressions(expressions = {}) {
const raw = {
happy: clamp01(expressions.happy),
sad: clamp01(expressions.sad),
fearful: clamp01(expressions.fearful),
angry: clamp01(expressions.angry),
disgusted: clamp01(expressions.disgusted),
surprised: clamp01(expressions.surprised),
neutral: clamp01(expressions.neutral),
};
const nonNeutralTop = Math.max(raw.happy, raw.sad, raw.fearful, raw.angry, raw.disgusted, raw.surprised);
const confused = Math.min(0.38, raw.neutral * 0.24 + raw.surprised * 0.42 + Math.max(0, 1 - nonNeutralTop) * 0.05);
return normalizeScores({
Happy: Math.pow(raw.happy, 1.10),
Sad: Math.pow(raw.sad, 0.96),
Fear: Math.max(Math.pow(raw.fearful, 0.98), raw.surprised * raw.fearful * 0.55),
Anger: Math.pow(raw.angry, 1.02),
Disgust: Math.pow(raw.disgusted, 0.96),
Confused: confused,
});
}
function normalizeExternalEmotion(outputs) {
const scores = blankScores();
const list = Array.isArray(outputs) ? outputs : [outputs];
for (const item of list) {
const label = String(item.label || item.class || "").toLowerCase();
const score = clamp01(item.score || item.probability || 0);
if (label.includes("happy") || label.includes("happiness") || label.includes("joy")) scores.Happy = Math.max(scores.Happy, score);
else if (label.includes("sad") || label.includes("sadness")) scores.Sad = Math.max(scores.Sad, score);
else if (label.includes("fear") || label.includes("fearful")) scores.Fear = Math.max(scores.Fear, score);
else if (label.includes("angry") || label.includes("anger")) scores.Anger = Math.max(scores.Anger, score);
else if (label.includes("disgust") || label.includes("disgusted")) scores.Disgust = Math.max(scores.Disgust, score);
else if (label.includes("surprise") || label.includes("neutral")) {
const scaled = label.includes("neutral") ? score * 0.45 : score * 0.52;
scores.Confused = Math.max(scores.Confused, Math.min(0.58, scaled));
}
}
return normalizeScores(scores);
}
function calibrateFinalEmotion(scores) {
const priors = { Happy: 0.86, Sad: 1.18, Fear: 1.24, Anger: 0.96, Confused: 0.72, Disgust: 1.34 };
const out = blankScores();
for (const label of emotionLabels) {
let v = clamp01(scores[label] || 0);
if (["Sad", "Fear", "Disgust"].includes(label)) v = Math.pow(v, 0.92);
if (label === "Happy") v = Math.pow(v, 1.08);
if (label === "Confused") v = Math.pow(v, 1.04);
out[label] = clamp01(v * priors[label]);
}
return renormalize(out);
}
function combineEmotionScores(faceScores) {
const now = performance.now();
const ferFresh = precision.ferScores && (now - precision.ferAt < 3400);
const transformerFresh = precision.transformerScores && (now - precision.transformerAt < 5000);
const combined = blankScores();
let totalWeight = 0;
const addWeighted = (scores, weight) => {
if (!scores || weight <= 0) return;
totalWeight += weight;
for (const label of emotionLabels) combined[label] += (scores[label] || 0) * weight;
};
if (ferFresh && transformerFresh) {
addWeighted(precision.ferScores, 0.55);
addWeighted(precision.transformerScores, 0.34);
addWeighted(faceScores, 0.11);
} else if (ferFresh) {
addWeighted(precision.ferScores, 0.76);
addWeighted(faceScores, 0.24);
} else if (transformerFresh) {
addWeighted(precision.transformerScores, 0.78);
addWeighted(faceScores, 0.22);
} else {
addWeighted(faceScores, 1.0);
}
if (totalWeight > 0) {
for (const label of emotionLabels) combined[label] /= totalWeight;
}
const calibrated = calibrateFinalEmotion(combined);
if (!emotionEma) {
emotionEma = calibrated;
} else {
for (const label of emotionLabels) {
emotionEma[label] = emotionEma[label] * (1 - detectorConfig.smoothing) + calibrated[label] * detectorConfig.smoothing;
}
}
return renormalize(emotionEma);
}
function topEmotion(scores) {
const entries = Object.entries(scores).sort((a, b) => b[1] - a[1]);
let [label, score] = entries[0] || ["Confused", 0];
const nonConfused = entries.filter(([name]) => name !== "Confused");
const [altLabel, altScore] = nonConfused[0] || [label, score];
if (label === "Confused" && altScore >= 0.22 && (score - altScore) < 0.10) {
label = altLabel;
score = altScore;
}
if (score < 0.18) {
label = "Confused";
score = Math.max(scores.Confused || 0.16, 0.16);
}
return { label, score: clamp01(score) };
}
function pushAgeSample(age, source, weight = 1) {
if (!Number.isFinite(age) || age < 0 || age > 100) return;
const now = performance.now();
ageHistory.push({ age, source, weight, t: now });
while (ageHistory.length > 50) ageHistory.shift();
const cutoff = now - 45000;
while (ageHistory.length && ageHistory[0].t < cutoff) ageHistory.shift();
}
function stableAgeEstimate() {
const precisionSamples = ageHistory.filter(s => s.source === "precision model");
const usable = precisionSamples.length >= 2 ? precisionSamples : ageHistory;
if (!usable.length) return { age: NaN, source: "Waiting", samples: 0 };
const expanded = [];
for (const sample of usable) {
const repeats = Math.max(1, Math.round(sample.weight || 1));
for (let i = 0; i < repeats; i += 1) expanded.push(sample.age);
}
return {
age: median(expanded),
source: precisionSamples.length >= 2 ? "precision model" : "core model",
samples: usable.length,
};
}
function updateGenderEstimate(label, confidence, weight = 1) {
if (!label) return;
const pMale = label.toLowerCase() === "male" ? clamp01(confidence) : 1 - clamp01(confidence);
const alpha = Math.min(0.68, 0.15 * weight);
genderMaleEma = genderMaleEma === null ? pMale : genderMaleEma * (1 - alpha) + pMale * alpha;
}
function currentGender() {
if (genderMaleEma === null) return { label: "-", confidence: 0 };
const label = genderMaleEma >= 0.5 ? "Male" : "Female";
const confidence = Math.max(genderMaleEma, 1 - genderMaleEma);
return { label, confidence };
}
function makeInsight(det) {
const faceScores = calibrateFaceApiExpressions(det.expressions || {});
const scores = combineEmotionScores(faceScores);
const emotion = topEmotion(scores);
const now = performance.now();
if (Number.isFinite(det.age) && now - lastBaseAgeSample > 700) {
pushAgeSample(det.age, "core model", 1);
lastBaseAgeSample = now;
}
const coreGender = (det.gender || "").toLowerCase() === "female" ? "Female" : "Male";
updateGenderEstimate(coreGender, det.genderProbability || 0, 1);
if (precision.gender && (now - precision.genderAt < 9000)) updateGenderEstimate(precision.gender, precision.genderScore, 3.8);
const age = stableAgeEstimate();
const gender = currentGender();
return {
emotionLabel: emotion.label,
emotionScore: emotion.score,
emotionScores: scores,
gender: gender.label,
genderScore: gender.confidence,
ageRange: ageRange(age.age, age.source, age.samples),
ageSource: age.samples ? `${age.source}, ${age.samples} samples` : "Waiting",
faceScore: det.detection && det.detection.score ? det.detection.score : 0,
};
}
function renderBars(scores = {}) {
els.emotionBars.innerHTML = emotionLabels.map(label => {
const value = scores[label] || 0;
return `
<div class="bar-row">
<span>${label}</span>
<div class="bar-track"><div class="bar-fill" style="width:${Math.round(value * 100)}%"></div></div>
<span>${percent(value)}</span>
</div>`;
}).join("");
}
function resetDetails() {
emotionEma = null;
genderMaleEma = null;
ageHistory.length = 0;
els.feelingValue.textContent = "-";
els.feelingConfidence.textContent = "Waiting";
els.genderValue.textContent = "-";
els.genderConfidence.textContent = "Waiting";
els.ageValue.textContent = "-";
els.ageSource.textContent = "Waiting";
els.facesValue.textContent = "0";
els.faceScore.textContent = "No face yet";
els.latencyValue.textContent = "-";
els.latencySource.textContent = "Model time";
renderBars({});
}
function choosePrimary(detections) {
if (!detections || !detections.length) return null;
return detections.slice().sort((a, b) => {
const ab = a.detection.box;
const bb = b.detection.box;
return (bb.width * bb.height) - (ab.width * ab.height);
})[0];
}
function updatePrecisionStatus() {
const parts = [];
if (precision.ferSession) parts.push("MobileFaceNet emotion");
if (precision.emotionPipe) parts.push("ViT emotion");
if (precision.ageModel && precision.ageProcessor) parts.push("ViT age/gender");
if (parts.length >= 2) setPill(els.precisionDot, els.precisionStatus, `Precision ready: ${parts.join(" + ")}`, "ready");
else if (parts.length === 1) setPill(els.precisionDot, els.precisionStatus, `Precision partially ready: ${parts[0]}`, "ready");
else if (precision.loading) setPill(els.precisionDot, els.precisionStatus, "Precision models loading automatically", "loading");
else setPill(els.precisionDot, els.precisionStatus, "Precision models unavailable; using core AI", "error");
}
function updateDetails(detections, elapsedMs) {
els.facesValue.textContent = String(detections.length);
els.latencyValue.textContent = `${Math.round(elapsedMs)}ms`;
els.latencySource.textContent = precision.latency ? `Core + precision ${Math.round(precision.latency)}ms` : "Core model time";
const now = performance.now();
const instantFps = 1000 / Math.max(1, now - lastLoopTime);
lastLoopTime = now;
fpsSmooth = fpsSmooth ? (fpsSmooth * 0.82 + instantFps * 0.18) : instantFps;
els.fpsValue.textContent = fpsSmooth.toFixed(1);
const primary = choosePrimary(detections);
latestPrimary = primary;
if (!primary) {
els.feelingValue.textContent = "-";
els.feelingConfidence.textContent = "No face";
els.genderValue.textContent = "-";
els.genderConfidence.textContent = "No face";
els.ageValue.textContent = "-";
els.ageSource.textContent = "No face";
els.faceScore.textContent = "No face yet";
renderBars({});
return;
}
maybeRunPrecision(primary);
const insight = makeInsight(primary);
els.feelingValue.textContent = insight.emotionLabel;
els.feelingConfidence.textContent = `${percent(insight.emotionScore)} confidence`;
els.genderValue.textContent = insight.gender;
els.genderConfidence.textContent = insight.gender === "-" ? "Waiting" : `${percent(insight.genderScore)} confidence`;
els.ageValue.textContent = insight.ageRange;
els.ageSource.textContent = insight.ageSource;
els.faceScore.textContent = `${percent(insight.faceScore)} face score`;
renderBars(insight.emotionScores);
}
function drawRoundRect(context, x, y, w, h, r) {
const radius = Math.min(r, w / 2, h / 2);
context.beginPath();
context.moveTo(x + radius, y);
context.arcTo(x + w, y, x + w, y + h, radius);
context.arcTo(x + w, y + h, x, y + h, radius);
context.arcTo(x, y + h, x, y, radius);
context.arcTo(x, y, x + w, y, radius);
context.closePath();
}
function drawCornerBox(x, y, w, h) {
const line = Math.max(4, els.overlay.width / 180);
const len = Math.max(26, Math.min(w, h) * 0.22);
ctx.lineWidth = line;
ctx.strokeStyle = "#22d3ee";
ctx.shadowColor = "rgba(34,211,238,0.85)";
ctx.shadowBlur = 14;
ctx.beginPath();
ctx.moveTo(x, y + len); ctx.lineTo(x, y); ctx.lineTo(x + len, y);
ctx.moveTo(x + w - len, y); ctx.lineTo(x + w, y); ctx.lineTo(x + w, y + len);
ctx.moveTo(x + w, y + h - len); ctx.lineTo(x + w, y + h); ctx.lineTo(x + w - len, y + h);
ctx.moveTo(x + len, y + h); ctx.lineTo(x, y + h); ctx.lineTo(x, y + h - len);
ctx.stroke();
ctx.shadowBlur = 0;
ctx.lineWidth = Math.max(2, line * 0.55);
ctx.strokeStyle = "rgba(255,255,255,0.95)";
drawRoundRect(ctx, x, y, w, h, 16);
ctx.stroke();
}
function drawLabel(x, y, w, insight) {
const scale = Math.max(1, els.overlay.width / 850);
const padX = 12 * scale;
const padY = 8 * scale;
const font1 = Math.round(18 * scale);
const font2 = Math.round(14 * scale);
const lineH = font1 + 9 * scale;
const label1 = `${insight.emotionLabel} ${percent(insight.emotionScore)}`;
const label2 = `${insight.gender} ${percent(insight.genderScore)} | Age ${insight.ageRange}`;
ctx.font = `900 ${font1}px Inter, system-ui, sans-serif`;
const textW = Math.max(ctx.measureText(label1).width, ctx.measureText(label2).width);
const boxW = Math.min(Math.max(textW + padX * 2, w * 0.72), els.overlay.width - 16);
const boxH = lineH * 2 + padY * 1.6;
const bx = Math.max(8, Math.min(x, els.overlay.width - boxW - 8));
const by = Math.max(8, y - boxH - 8);
drawRoundRect(ctx, bx, by, boxW, boxH, 14 * scale);
ctx.fillStyle = "rgba(2, 6, 23, 0.84)";
ctx.fill();
ctx.strokeStyle = "rgba(34, 211, 238, 0.65)";
ctx.lineWidth = 1.5 * scale;
ctx.stroke();
ctx.fillStyle = "#ecfeff";
ctx.font = `900 ${font1}px Inter, system-ui, sans-serif`;
ctx.fillText(label1, bx + padX, by + padY + font1);
ctx.fillStyle = "#a7b4c8";
ctx.font = `800 ${font2}px Inter, system-ui, sans-serif`;
ctx.fillText(label2, bx + padX, by + padY + font1 + lineH);
}
function drawDetections(detections) {
ctx.clearRect(0, 0, els.overlay.width, els.overlay.height);
lastDetections = detections;
for (const det of detections) {
const box = det.detection.box;
const x = Math.max(0, box.x);
const y = Math.max(0, box.y);
const w = Math.min(box.width, els.overlay.width - x);
const h = Math.min(box.height, els.overlay.height - y);
const insight = makeInsight(det);
drawCornerBox(x, y, w, h);
drawLabel(x, y, w, insight);
}
}
function fitCanvas() {
const vw = els.video.videoWidth || 640;
const vh = els.video.videoHeight || 480;
if (els.overlay.width !== vw || els.overlay.height !== vh) {
els.overlay.width = vw;
els.overlay.height = vh;
}
els.stage.style.aspectRatio = `${vw} / ${vh}`;
if (lastDetections.length) drawDetections(lastDetections);
}
function cropFaceCanvas(det, targetSize = 288, pad = 0.28, filter = "none", mirror = false) {
const box = det.detection.box;
const videoW = els.video.videoWidth || els.overlay.width;
const videoH = els.video.videoHeight || els.overlay.height;
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
const side = Math.max(box.width, box.height) * (1 + pad * 2);
const sx = Math.max(0, Math.round(cx - side / 2));
const sy = Math.max(0, Math.round(cy - side / 2));
const sw = Math.min(videoW - sx, Math.round(side));
const sh = Math.min(videoH - sy, Math.round(side));
const canvas = document.createElement("canvas");
canvas.width = targetSize;
canvas.height = targetSize;
const c = canvas.getContext("2d", { willReadFrequently: true });
c.fillStyle = "#000";
c.fillRect(0, 0, targetSize, targetSize);
c.filter = filter;
if (mirror) {
c.translate(targetSize, 0);
c.scale(-1, 1);
c.drawImage(els.video, sx, sy, sw, sh, 0, 0, targetSize, targetSize);
c.setTransform(1, 0, 0, 1, 0, 0);
} else {
c.drawImage(els.video, sx, sy, sw, sh, 0, 0, targetSize, targetSize);
}
c.filter = "none";
return canvas;
}
function canvasToBlobUrl(canvas) {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (!blob) reject(new Error("Could not create face crop"));
else resolve(URL.createObjectURL(blob));
}, "image/jpeg", 0.92);
});
}
async function waitForGlobal(name, timeoutMs = 14000) {
const started = performance.now();
while (!window[name]) {
if (performance.now() - started > timeoutMs) throw new Error(`${name} did not load`);
await new Promise(resolve => setTimeout(resolve, 80));
}
return window[name];
}
async function loadCoreModels() {
try {
await waitForGlobal("faceapi");
await Promise.all([
faceapi.nets.ssdMobilenetv1.loadFromUri(FACE_API_MODEL_URL),
faceapi.nets.tinyFaceDetector.loadFromUri(FACE_API_MODEL_URL),
faceapi.nets.faceLandmark68TinyNet.loadFromUri(FACE_API_MODEL_URL),
faceapi.nets.faceExpressionNet.loadFromUri(FACE_API_MODEL_URL),
faceapi.nets.ageGenderNet.loadFromUri(FACE_API_MODEL_URL),
]);
coreReady = true;
setPill(els.aiDot, els.aiStatus, "AI ready", "ready");
els.startBtn.disabled = false;
renderBars({});
loadPrecisionModels();
} catch (err) {
console.error(err);
setPill(els.aiDot, els.aiStatus, "AI model loading failed", "error");
showToast("Model loading failed. Refresh the Space and check browser network access.");
}
}
async function loadOpenCvFerModel() {
await waitForGlobal("ort", 16000);
ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/[email protected]/dist/";
ort.env.wasm.numThreads = Math.max(1, Math.min(4, navigator.hardwareConcurrency || 2));
precision.ferSession = await ort.InferenceSession.create(OPENCV_FER_MODEL_URL, { executionProviders: ["wasm"] });
updatePrecisionStatus();
}
async function loadTransformerEmotionModel() {
if (xenovaEnv) {
xenovaEnv.allowLocalModels = false;
xenovaEnv.useBrowserCache = true;
xenovaEnv.backends ??= {};
xenovaEnv.backends.onnx ??= {};
xenovaEnv.backends.onnx.wasm ??= {};
xenovaEnv.backends.onnx.wasm.numThreads = Math.max(1, Math.min(4, navigator.hardwareConcurrency || 2));
}
precision.emotionPipe = await xenovaPipeline("image-classification", EMOTION_TRANSFORMER_MODEL_ID, { quantized: true });
updatePrecisionStatus();
}
async function loadAgeGenderModel() {
if (hfEnv) {
hfEnv.allowLocalModels = false;
hfEnv.useBrowserCache = true;
hfEnv.backends ??= {};
hfEnv.backends.onnx ??= {};
hfEnv.backends.onnx.wasm ??= {};
hfEnv.backends.onnx.wasm.numThreads = Math.max(1, Math.min(4, navigator.hardwareConcurrency || 2));
}
const opts = navigator.gpu ? { device: "webgpu", dtype: "q8" } : { device: "wasm", dtype: "q8" };
try {
precision.ageModel = await AutoModel.from_pretrained(AGE_GENDER_MODEL_ID, opts);
} catch (err) {
console.warn("Age/gender preferred backend failed; retrying wasm", err);
precision.ageModel = await AutoModel.from_pretrained(AGE_GENDER_MODEL_ID, { device: "wasm", dtype: "q8" });
}
precision.ageProcessor = await AutoProcessor.from_pretrained(AGE_GENDER_MODEL_ID);
updatePrecisionStatus();
}
async function loadPrecisionModels() {
if (precision.loading) return;
precision.loading = true;
updatePrecisionStatus();
const tasks = [
loadOpenCvFerModel().catch(err => console.warn("OpenCV FER model unavailable", err)),
loadTransformerEmotionModel().catch(err => console.warn("Transformer emotion model unavailable", err)),
loadAgeGenderModel().catch(err => console.warn("Age/gender transformer unavailable", err)),
];
await Promise.allSettled(tasks);
precision.loading = false;
updatePrecisionStatus();
if (precision.ferSession || precision.emotionPipe || precision.ageModel) showToast("Precision models are ready.");
}
function averagePoint(points) {
const list = (points || []).map(p => ({ x: p.x, y: p.y })).filter(p => Number.isFinite(p.x) && Number.isFinite(p.y));
if (!list.length) return null;
return {
x: list.reduce((a, p) => a + p.x, 0) / list.length,
y: list.reduce((a, p) => a + p.y, 0) / list.length,
};
}
function getLandmarkPoints(det) {
if (!det.landmarks) return null;
const leftEye = averagePoint(det.landmarks.getLeftEye?.() || []);
const rightEye = averagePoint(det.landmarks.getRightEye?.() || []);
const noseList = det.landmarks.getNose?.() || [];
const mouthList = det.landmarks.getMouth?.() || [];
const noseTip = noseList.length ? noseList[Math.min(3, noseList.length - 1)] : null;
if (!leftEye || !rightEye || !noseTip || !mouthList.length) return null;
const eyes = [leftEye, rightEye].sort((a, b) => a.x - b.x);
const mouths = mouthList.slice().sort((a, b) => a.x - b.x);
const leftMouth = mouths[0];
const rightMouth = mouths[mouths.length - 1];
return [eyes[0], eyes[1], noseTip, leftMouth, rightMouth];
}
function solveLinearSystem(A, b) {
const n = b.length;
const M = A.map((row, i) => row.concat([b[i]]));
for (let col = 0; col < n; col += 1) {
let pivot = col;
for (let r = col + 1; r < n; r += 1) if (Math.abs(M[r][col]) > Math.abs(M[pivot][col])) pivot = r;
if (Math.abs(M[pivot][col]) < 1e-8) return null;
[M[col], M[pivot]] = [M[pivot], M[col]];
const div = M[col][col];
for (let c = col; c <= n; c += 1) M[col][c] /= div;
for (let r = 0; r < n; r += 1) {
if (r === col) continue;
const factor = M[r][col];
for (let c = col; c <= n; c += 1) M[r][c] -= factor * M[col][c];
}
}
return M.map(row => row[n]);
}
function estimateAffine(srcPts, dstPts) {
const rows = [];
const vals = [];
for (let i = 0; i < srcPts.length; i += 1) {
const sx = srcPts[i].x;
const sy = srcPts[i].y;
const dx = dstPts[i].x;
const dy = dstPts[i].y;
rows.push([sx, sy, 1, 0, 0, 0]); vals.push(dx);
rows.push([0, 0, 0, sx, sy, 1]); vals.push(dy);
}
const ATA = Array.from({ length: 6 }, () => Array(6).fill(0));
const ATb = Array(6).fill(0);
for (let r = 0; r < rows.length; r += 1) {
for (let i = 0; i < 6; i += 1) {
ATb[i] += rows[r][i] * vals[r];
for (let j = 0; j < 6; j += 1) ATA[i][j] += rows[r][i] * rows[r][j];
}
}
return solveLinearSystem(ATA, ATb);
}
function makeAlignedFaceCanvas(det, targetSize = 112) {
const points = getLandmarkPoints(det);
if (!points) return cropFaceCanvas(det, targetSize, 0.25, "contrast(1.08)");
const dst = [
{ x: 38.2946, y: 51.6963 },
{ x: 73.5318, y: 51.5014 },
{ x: 56.0252, y: 71.7366 },
{ x: 41.5493, y: 92.3655 },
{ x: 70.7299, y: 92.2041 },
];
const sol = estimateAffine(points, dst);
if (!sol) return cropFaceCanvas(det, targetSize, 0.25, "contrast(1.08)");
const canvas = document.createElement("canvas");
canvas.width = targetSize;
canvas.height = targetSize;
const c = canvas.getContext("2d", { willReadFrequently: true });
c.fillStyle = "#000";
c.fillRect(0, 0, targetSize, targetSize);
c.setTransform(sol[0], sol[3], sol[1], sol[4], sol[2], sol[5]);
c.filter = "contrast(1.08) saturate(0.95)";
c.drawImage(els.video, 0, 0);
c.setTransform(1, 0, 0, 1, 0, 0);
c.filter = "none";
return canvas;
}
function canvasToNchwTensor(canvas) {
const c = canvas.getContext("2d", { willReadFrequently: true });
const { data } = c.getImageData(0, 0, canvas.width, canvas.height);
const size = canvas.width * canvas.height;
const out = new Float32Array(3 * size);
for (let i = 0; i < size; i += 1) {
const j = i * 4;
out[i] = (data[j] / 255 - 0.5) / 0.5;
out[size + i] = (data[j + 1] / 255 - 0.5) / 0.5;
out[size * 2 + i] = (data[j + 2] / 255 - 0.5) / 0.5;
}
return out;
}
async function runOpenCvFer(det) {
if (!precision.ferSession || !window.ort) return null;
const canvas = makeAlignedFaceCanvas(det, 112);
const input = canvasToNchwTensor(canvas);
const tensor = new ort.Tensor("float32", input, [1, 3, 112, 112]);
const feeds = {};
feeds[precision.ferSession.inputNames[0]] = tensor;
const results = await precision.ferSession.run(feeds);
const outputName = precision.ferSession.outputNames[0];
const logits = Array.from(results[outputName].data).slice(0, 7);
const probs = softmax(logits);
const scores = blankScores();
for (let i = 0; i < probs.length && i < ferLabels.length; i += 1) {
const label = ferLabels[i];
scores[label] = Math.max(scores[label], probs[i]);
}
return normalizeScores(scores);
}
async function runTransformerEmotion(det) {
if (!precision.emotionPipe) return null;
const variants = [
cropFaceCanvas(det, 288, 0.14, "contrast(1.10) saturate(0.95)", false),
cropFaceCanvas(det, 288, 0.30, "contrast(1.16) saturate(0.92)", false),
cropFaceCanvas(det, 288, 0.08, "contrast(1.18) brightness(1.02)", false),
cropFaceCanvas(det, 288, 0.20, "contrast(1.12) saturate(0.94)", true),
];
const aggregate = blankScores();
const urls = [];
let count = 0;
try {
for (const variant of variants) {
const url = await canvasToBlobUrl(variant);
urls.push(url);
let output;
try {
output = await precision.emotionPipe(url, { topK: 7 });
} catch (_) {
output = await precision.emotionPipe(url);
}
const scores = normalizeExternalEmotion(output);
for (const label of emotionLabels) aggregate[label] += scores[label] || 0;
count += 1;
}
} finally {
for (const url of urls) URL.revokeObjectURL(url);
}
if (!count) return null;
for (const label of emotionLabels) aggregate[label] /= count;
return normalizeScores(aggregate);
}
async function tensorValues(tensor) {
if (!tensor) return [];
if (tensor.tolist) {
const listed = tensor.tolist();
return Array.isArray(listed) ? listed.flat(Infinity) : [];
}
if (tensor.data) return Array.from(tensor.data);
return [];
}
async function runAgeGender(det) {
if (!precision.ageModel || !precision.ageProcessor || !load_image) return;
const canvas = cropFaceCanvas(det, 384, 0.22, "contrast(1.07) saturate(0.96)");
let url = null;
try {
url = await canvasToBlobUrl(canvas);
const image = await load_image(url);
const inputs = await precision.ageProcessor(image);
const output = await precision.ageModel(inputs);
const values = await tensorValues(output.logits || output[0]);
if (values.length < 2) return;
const rawAge = Number(values[0]);
const rawGender = Number(values[1]);
if (Number.isFinite(rawAge)) {
const age = Math.max(0, Math.min(100, Math.round(rawAge)));
precision.age = age;
precision.ageAt = performance.now();
pushAgeSample(age, "precision model", 5);
}
if (Number.isFinite(rawGender)) {
const pFemale = rawGender >= 0 && rawGender <= 1 ? rawGender : sigmoid(rawGender);
precision.gender = pFemale >= 0.5 ? "Female" : "Male";
precision.genderScore = Math.max(pFemale, 1 - pFemale);
precision.genderAt = performance.now();
}
} finally {
if (url) URL.revokeObjectURL(url);
}
}
async function maybeRunPrecision(primary) {
const now = performance.now();
if (!primary || precision.busy || (!precision.ferSession && !precision.emotionPipe && !precision.ageModel)) return;
if (now - precision.lastRun < detectorConfig.precisionIntervalMs) return;
precision.busy = true;
precision.lastRun = now;
const started = performance.now();
try {
if (precision.ferSession) {
const fer = await runOpenCvFer(primary);
if (fer) {
precision.ferScores = fer;
precision.ferAt = performance.now();
}
}
if (precision.emotionPipe) {
const transformer = await runTransformerEmotion(primary);
if (transformer) {
precision.transformerScores = transformer;
precision.transformerAt = performance.now();
}
}
if (precision.ageModel && precision.ageProcessor) await runAgeGender(primary);
} catch (err) {
console.warn("Precision inference failed", err);
} finally {
precision.latency = performance.now() - started;
precision.busy = false;
}
}
function stopStream() {
if (stream) for (const track of stream.getTracks()) track.stop();
stream = null;
running = false;
detecting = false;
els.video.srcObject = null;
ctx.clearRect(0, 0, els.overlay.width, els.overlay.height);
els.emptyState.style.display = "grid";
els.startBtn.disabled = !coreReady;
els.switchBtn.disabled = true;
els.stopBtn.disabled = true;
els.cameraTag.textContent = "Camera: stopped";
resetDetails();
}
async function startCamera(facing = currentFacing) {
if (!coreReady) {
showToast("Please wait until the AI models finish loading.");
return;
}
stopStream();
currentFacing = facing;
els.cameraTag.textContent = `Camera: ${currentFacing === "user" ? "front" : "rear"} requested`;
const constraints = {
audio: false,
video: {
facingMode: { ideal: currentFacing },
width: { ideal: 1280 },
height: { ideal: 720 },
}
};
try {
stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch (firstErr) {
console.warn("Preferred camera failed, falling back", firstErr);
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: true });
} catch (err) {
console.error(err);
showToast("Camera permission failed. Allow camera access in your browser settings.");
els.cameraTag.textContent = "Camera: permission needed";
return;
}
}
els.video.srcObject = stream;
await els.video.play();
fitCanvas();
running = true;
els.emptyState.style.display = "none";
els.startBtn.disabled = true;
els.switchBtn.disabled = false;
els.stopBtn.disabled = false;
els.cameraTag.textContent = `Camera: ${currentFacing === "user" ? "front" : "rear"}`;
lastLoopTime = performance.now();
fpsSmooth = 0;
detectLoop();
}
async function detectFacesAccurate() {
try {
const options = new faceapi.SsdMobilenetv1Options({ minConfidence: 0.42 });
return await faceapi
.detectAllFaces(els.video, options)
.withFaceLandmarks(true)
.withFaceExpressions()
.withAgeAndGender();
} catch (err) {
console.warn("SSD detector failed; using tiny detector", err);
const tiny = new faceapi.TinyFaceDetectorOptions({ inputSize: detectorConfig.inputSize, scoreThreshold: detectorConfig.scoreThreshold });
return await faceapi
.detectAllFaces(els.video, tiny)
.withFaceLandmarks(true)
.withFaceExpressions()
.withAgeAndGender();
}
}
async function detectLoop() {
if (!running || !coreReady || detecting) return;
detecting = true;
const started = performance.now();
try {
fitCanvas();
const raw = await detectFacesAccurate();
const displaySize = { width: els.overlay.width, height: els.overlay.height };
const resized = faceapi.resizeResults(raw, displaySize);
const elapsed = performance.now() - started;
drawDetections(resized);
updateDetails(resized, elapsed);
setTimeout(() => {
detecting = false;
requestAnimationFrame(detectLoop);
}, detectorConfig.intervalMs);
} catch (err) {
console.error(err);
detecting = false;
showToast("Live analysis hit an error. Restarting camera may fix it.");
setTimeout(() => requestAnimationFrame(detectLoop), 250);
}
}
els.startBtn.addEventListener("click", () => startCamera(currentFacing));
els.switchBtn.addEventListener("click", () => {
currentFacing = currentFacing === "user" ? "environment" : "user";
startCamera(currentFacing);
});
els.stopBtn.addEventListener("click", stopStream);
els.video.addEventListener("loadedmetadata", fitCanvas);
window.addEventListener("resize", fitCanvas);
resetDetails();
renderBars({});
setPill(els.precisionDot, els.precisionStatus, "Precision models loading automatically", "loading");
loadCoreModels();
</script>
</body>
</html>