Spaces:
Running
Running
| <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> | |