Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Dally — Dog Emotion & Voice</title> | |
| <style> | |
| /* Brand tokens mirror content/brand_kit.py (the single source for the web app, | |
| the Flutter theme, and the Atlas site). Kept literal here because a Space is | |
| standalone and cannot import the kit. */ | |
| :root { | |
| --bg:#0b0f17; --panel:#141b27; --line:#1f2937; | |
| --text:#e5e7eb; --muted:#9ca3af; --accent:#22d3ee; | |
| --ok:#10b981; --watch:#f59e0b; --critical:#ef4444; | |
| } | |
| @media (prefers-color-scheme: light) { | |
| :root { --bg:#f8fafc; --panel:#fff; --line:#e2e8f0; | |
| --text:#0f172a; --muted:#64748b; --accent:#0e7490; } | |
| } | |
| *{box-sizing:border-box} | |
| body{margin:0;background:var(--bg);color:var(--text); | |
| font:15px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif} | |
| .wrap{max-width:820px;margin:0 auto;padding:1.5rem 1rem 4rem} | |
| header{display:flex;align-items:center;gap:.7rem;margin-bottom:.25rem} | |
| header svg{width:34px;height:34px;flex:none} | |
| h1{font-size:1.4rem;margin:0;font-weight:700} | |
| .sub{color:var(--muted);font-size:.9rem;margin:0 0 1.25rem} | |
| .privacy{background:color-mix(in srgb,var(--ok) 12%,transparent); | |
| border:1px solid color-mix(in srgb,var(--ok) 45%,transparent); | |
| border-radius:.6rem;padding:.7rem .9rem;font-size:.86rem;margin-bottom:1.25rem} | |
| .tabs{display:flex;gap:.4rem;margin-bottom:1rem;border-bottom:1px solid var(--line)} | |
| .tab{background:none;border:0;border-bottom:2px solid transparent;color:var(--muted); | |
| padding:.6rem .9rem;font:inherit;cursor:pointer} | |
| .tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent);font-weight:600} | |
| .card{background:var(--panel);border:1px solid var(--line);border-radius:.75rem;padding:1.1rem} | |
| .drop{display:block;border:1.5px dashed var(--line);border-radius:.6rem;padding:1.6rem 1rem; | |
| text-align:center;cursor:pointer;transition:border-color .15s,background .15s} | |
| .drop:hover,.drop.over{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 7%,transparent)} | |
| .drop input{display:none} | |
| .muted{color:var(--muted);font-size:.85rem} | |
| button.go{background:var(--accent);color:#04121a;border:0;border-radius:.5rem; | |
| padding:.6rem 1.1rem;font:inherit;font-weight:600;cursor:pointer;margin-top:.9rem} | |
| button.go[disabled]{opacity:.5;cursor:not-allowed} | |
| #preview,#waveHint{margin-top:1rem;display:none} | |
| #preview img{max-width:100%;max-height:280px;border-radius:.5rem;display:block} | |
| .row{display:flex;align-items:center;gap:.6rem;margin:.45rem 0} | |
| .row .lab{width:7.5rem;flex:none;font-size:.9rem;text-transform:capitalize} | |
| .bar{flex:1;height:9px;background:var(--line);border-radius:5px;overflow:hidden} | |
| .bar span{display:block;height:100%;background:var(--accent);width:0;transition:width .45s ease} | |
| .row.top .bar span{background:var(--ok)} | |
| .pct{width:3.4rem;text-align:right;font-variant-numeric:tabular-nums;font-size:.85rem;color:var(--muted)} | |
| .verdict{font-size:1.05rem;font-weight:600;margin:.9rem 0 .2rem} | |
| .meaning{color:var(--muted);font-size:.9rem} | |
| .status{margin-top:.9rem;font-size:.86rem;color:var(--muted);min-height:1.3em} | |
| .warn{background:color-mix(in srgb,var(--watch) 12%,transparent); | |
| border:1px solid color-mix(in srgb,var(--watch) 45%,transparent); | |
| border-radius:.5rem;padding:.6rem .8rem;font-size:.84rem;margin-top:1rem} | |
| footer{margin-top:2rem;color:var(--muted);font-size:.82rem;border-top:1px solid var(--line);padding-top:1rem} | |
| a{color:var(--accent)} | |
| .hidden{display:none} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <header> | |
| <svg viewBox="0 0 64 64" aria-hidden="true"><g style="fill:var(--accent)"> | |
| <ellipse cx="14" cy="22" rx="6.5" ry="8.5" transform="rotate(-22 14 22)"/> | |
| <ellipse cx="50" cy="22" rx="6.5" ry="8.5" transform="rotate(22 50 22)"/> | |
| <ellipse cx="25" cy="13" rx="5.5" ry="7" transform="rotate(-10 25 13)"/> | |
| <ellipse cx="39" cy="13" rx="5.5" ry="7" transform="rotate(10 39 13)"/> | |
| <path d="M32 28C20 28 13 38 13 47c0 7 5 11 11 9 3-1 5-2 8-2s5 1 8 2c6 2 11-2 11-9 0-9-7-19-19-19Z"/> | |
| </g></svg> | |
| <h1>Dally — dog emotion & voice</h1> | |
| </header> | |
| <p class="sub">Two models from a privacy-first canine health platform, running on your device.</p> | |
| <div class="privacy"> | |
| <strong>Nothing leaves your device.</strong> Both models run in this browser tab — | |
| there is no server here to send a photo or a recording to. That is Dally's actual | |
| architecture, not a demo shortcut. | |
| </div> | |
| <div class="tabs" role="tablist"> | |
| <button class="tab" role="tab" aria-selected="true" data-panel="p-emotion">Emotion (photo)</button> | |
| <button class="tab" role="tab" aria-selected="false" data-panel="p-voice">Voice (audio)</button> | |
| </div> | |
| <!-- ================= EMOTION ================= --> | |
| <section id="p-emotion" class="card"> | |
| <label class="drop" id="imgDrop"> | |
| <input type="file" id="imgInput" accept="image/*" /> | |
| <div><strong>Drop a dog photo</strong> or click to choose</div> | |
| <div class="muted">JPEG or PNG. A clear, front-on face works best.</div> | |
| </label> | |
| <div id="preview"><img id="previewImg" alt="Selected dog photo" /></div> | |
| <button class="go" id="imgGo" disabled>Analyse photo</button> | |
| <div class="status" id="imgStatus">Model loads on first use (~8 MB).</div> | |
| <div id="imgOut" class="hidden"> | |
| <div class="verdict" id="imgVerdict"></div> | |
| <div class="meaning" id="imgMeaning"></div> | |
| <div id="imgBars" style="margin-top:.8rem"></div> | |
| </div> | |
| <div class="warn"> | |
| <strong>≈83% test accuracy</strong> on 4 balanced classes (MobileNetV2 fine-tuned | |
| on ~4,000 images) — a research prototype, not a clinical instrument. Roughly one | |
| photo in six is misread, and it will be confidently wrong on unusual angles, | |
| breeds and lighting. | |
| </div> | |
| </section> | |
| <!-- ================= VOICE ================= --> | |
| <section id="p-voice" class="card hidden"> | |
| <label class="drop" id="audDrop"> | |
| <input type="file" id="audInput" accept="audio/*" /> | |
| <div><strong>Drop an audio clip</strong> or click to choose</div> | |
| <div class="muted">WAV, MP3, OGG or FLAC. At least one second.</div> | |
| </label> | |
| <div id="waveHint" class="muted"></div> | |
| <button class="go" id="audGo" disabled>Analyse audio</button> | |
| <div class="status" id="audStatus">Model loads on first use (~90 MB, cached afterwards).</div> | |
| <div id="audOut" class="hidden"> | |
| <div class="verdict" id="audVerdict"></div> | |
| <div class="meaning" id="audMeaning"></div> | |
| <div id="audBars" style="margin-top:.8rem"></div> | |
| <div class="muted" id="audTop" style="margin-top:.6rem"></div> | |
| </div> | |
| <div class="warn"> | |
| General-purpose AudioSet filtered to its dog-vocalization classes. It hears | |
| <em>bark</em>; it does not know <em>why</em> your dog is barking. | |
| </div> | |
| </section> | |
| <footer> | |
| <strong>Not veterinary advice.</strong> Dally is decision-support — it never names a | |
| drug, dose, route or duration, and it does not diagnose. Built by Feme Solutions, | |
| Bengaluru. Emotion model fine-tuned from <code>Dewa/Dog_Emotion_Dataset_v2</code>; | |
| audio via <a href="https://huggingface.co/MIT/ast-finetuned-audioset-10-10-0.4593">MIT AST</a> | |
| on AudioSet. | |
| </footer> | |
| </div> | |
| <script type="module"> | |
| import * as ort from "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.19.2/dist/ort.webgpu.min.mjs"; | |
| import { pipeline, env } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.3.3"; | |
| env.allowLocalModels = false; // fetch the AST weights from the Hub CDN | |
| /* ---------------- tabs ---------------- */ | |
| const tabs = [...document.querySelectorAll(".tab")]; | |
| tabs.forEach(t => t.addEventListener("click", () => { | |
| tabs.forEach(x => { | |
| const on = x === t; | |
| x.setAttribute("aria-selected", String(on)); | |
| document.getElementById(x.dataset.panel).classList.toggle("hidden", !on); | |
| }); | |
| })); | |
| /* ---------------- shared helpers ---------------- */ | |
| // Class order is baked by the exporter — see deploy/hf/export_onnx.py. Do not reorder. | |
| const EMOTION_CLASSES = ["sad", "angry", "relaxed", "happy"]; | |
| const EMOTION_MEANING = { | |
| happy: "I'm happy and content right now!", | |
| relaxed: "I'm calm and relaxed.", | |
| sad: "I'm feeling a bit down — some attention would help.", | |
| angry: "I'm agitated or uncomfortable — give me space and check what's wrong.", | |
| }; | |
| function renderBars(host, scores) { | |
| const entries = Object.entries(scores).sort((a, b) => b[1] - a[1]); | |
| host.innerHTML = entries.map(([k, v], i) => ` | |
| <div class="row ${i === 0 ? "top" : ""}"> | |
| <span class="lab">${k}</span> | |
| <span class="bar"><span style="width:${(v * 100).toFixed(1)}%"></span></span> | |
| <span class="pct">${(v * 100).toFixed(1)}%</span> | |
| </div>`).join(""); | |
| } | |
| function wireDrop(dropEl, inputEl, onFile) { | |
| dropEl.addEventListener("dragover", e => { e.preventDefault(); dropEl.classList.add("over"); }); | |
| dropEl.addEventListener("dragleave", () => dropEl.classList.remove("over")); | |
| dropEl.addEventListener("drop", e => { | |
| e.preventDefault(); dropEl.classList.remove("over"); | |
| if (e.dataTransfer.files[0]) { inputEl.files = e.dataTransfer.files; onFile(e.dataTransfer.files[0]); } | |
| }); | |
| inputEl.addEventListener("change", () => inputEl.files[0] && onFile(inputEl.files[0])); | |
| } | |
| /* ================= EMOTION (onnxruntime-web) ================= */ | |
| const imgStatus = document.getElementById("imgStatus"); | |
| const imgGo = document.getElementById("imgGo"); | |
| let session = null, imgFile = null; | |
| wireDrop(document.getElementById("imgDrop"), document.getElementById("imgInput"), f => { | |
| imgFile = f; | |
| const pv = document.getElementById("preview"); | |
| document.getElementById("previewImg").src = URL.createObjectURL(f); | |
| pv.style.display = "block"; | |
| imgGo.disabled = false; | |
| imgStatus.textContent = f.name; | |
| }); | |
| async function getSession() { | |
| if (session) return session; | |
| imgStatus.textContent = "Loading emotion model (~8 MB)…"; | |
| // WebGPU where available, WASM everywhere else. | |
| try { | |
| session = await ort.InferenceSession.create("dog_emotion.onnx", { executionProviders: ["webgpu", "wasm"] }); | |
| } catch { | |
| session = await ort.InferenceSession.create("dog_emotion.onnx", { executionProviders: ["wasm"] }); | |
| } | |
| return session; | |
| } | |
| // Must match server/app/ml/emotion.py exactly: resize 224x224, ToTensor, ImageNet normalise. | |
| function preprocess(img) { | |
| const c = document.createElement("canvas"); | |
| c.width = 224; c.height = 224; | |
| const ctx = c.getContext("2d", { willReadFrequently: true }); | |
| ctx.drawImage(img, 0, 0, 224, 224); | |
| const { data } = ctx.getImageData(0, 0, 224, 224); | |
| const mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225]; | |
| const out = new Float32Array(3 * 224 * 224); | |
| for (let i = 0; i < 224 * 224; i++) { | |
| for (let ch = 0; ch < 3; ch++) { | |
| out[ch * 224 * 224 + i] = (data[i * 4 + ch] / 255 - mean[ch]) / std[ch]; | |
| } | |
| } | |
| return new ort.Tensor("float32", out, [1, 3, 224, 224]); | |
| } | |
| const softmax = a => { | |
| const m = Math.max(...a), e = a.map(v => Math.exp(v - m)), s = e.reduce((x, y) => x + y, 0); | |
| return e.map(v => v / s); | |
| }; | |
| imgGo.addEventListener("click", async () => { | |
| if (!imgFile) return; | |
| imgGo.disabled = true; | |
| try { | |
| const s = await getSession(); | |
| imgStatus.textContent = "Analysing…"; | |
| const bitmap = await createImageBitmap(imgFile); | |
| const res = await s.run({ input: preprocess(bitmap) }); | |
| const probs = softmax(Array.from(res[Object.keys(res)[0]].data)); | |
| const scores = Object.fromEntries(EMOTION_CLASSES.map((c, i) => [c, probs[i]])); | |
| const top = Object.entries(scores).sort((a, b) => b[1] - a[1])[0]; | |
| document.getElementById("imgVerdict").textContent = | |
| `${top[0]} — ${(top[1] * 100).toFixed(1)}% confident`; | |
| document.getElementById("imgMeaning").textContent = EMOTION_MEANING[top[0]] || ""; | |
| renderBars(document.getElementById("imgBars"), scores); | |
| document.getElementById("imgOut").classList.remove("hidden"); | |
| imgStatus.textContent = "Ran on your device."; | |
| } catch (e) { | |
| imgStatus.textContent = "Failed: " + e.message; | |
| } finally { | |
| imgGo.disabled = false; | |
| } | |
| }); | |
| /* ================= VOICE (transformers.js) ================= */ | |
| const audStatus = document.getElementById("audStatus"); | |
| const audGo = document.getElementById("audGo"); | |
| let classifier = null, audFile = null; | |
| // Mirrors server/app/vocal.py — AudioSet label keyword -> (short label, owner-facing meaning). | |
| const DOG_MEANING = [ | |
| ["bark", ["Barking", "Alert barking — wants attention, or reacting to something."]], | |
| ["bow-wow", ["Barking", "Barking — wants attention or is reacting to something."]], | |
| ["yip", ["Yipping", "High-pitched yips — excited, playful, or startled."]], | |
| ["howl", ["Howling", "Howling — calling out or responding to sounds."]], | |
| ["bay", ["Baying", "Baying — a long howl-bark, often when tracking or calling."]], | |
| ["growl", ["Growling", "Growling — feeling threatened or guarding; give space and check why."]], | |
| ["whimper", ["Whimpering", "Whimpering — anxious, uncomfortable, or seeking comfort."]], | |
| ["whine", ["Whining", "Whining — anxious or asking for something."]], | |
| ]; | |
| const dogHit = label => { | |
| const low = label.toLowerCase(); | |
| for (const [kw, v] of DOG_MEANING) if (low.includes(kw)) return v; | |
| return null; | |
| }; | |
| wireDrop(document.getElementById("audDrop"), document.getElementById("audInput"), f => { | |
| audFile = f; | |
| const h = document.getElementById("waveHint"); | |
| h.textContent = f.name; | |
| h.style.display = "block"; | |
| audGo.disabled = false; | |
| }); | |
| // Decode to the 16 kHz mono Float32 the AST feature extractor expects. | |
| async function decodeMono16k(file) { | |
| const buf = await file.arrayBuffer(); | |
| const Ctx = window.AudioContext || window.webkitAudioContext; | |
| const decoded = await new Ctx({ sampleRate: 16000 }).decodeAudioData(buf); | |
| return decoded.getChannelData(0); | |
| } | |
| audGo.addEventListener("click", async () => { | |
| if (!audFile) return; | |
| audGo.disabled = true; | |
| try { | |
| if (!classifier) { | |
| audStatus.textContent = "Loading audio model (~90 MB, first time only)…"; | |
| classifier = await pipeline("audio-classification", | |
| "Xenova/ast-finetuned-audioset-10-10-0.4593", { dtype: "q8" }); | |
| } | |
| audStatus.textContent = "Analysing…"; | |
| const wav = await decodeMono16k(audFile); | |
| if (wav.length < 1600) { audStatus.textContent = "Clip too short — record at least a second."; return; } | |
| // topk:0 returns every AudioSet class so we can filter to dog vocalizations, | |
| // exactly as the server does. | |
| const all = await classifier(wav, { topk: 0 }); | |
| const scores = {}; let best = null, dogMass = 0; | |
| for (const { label, score } of all) { | |
| const hit = dogHit(label); | |
| if (!hit) continue; | |
| const [short, meaning] = hit; | |
| scores[short] = Math.max(scores[short] || 0, score); | |
| dogMass += score; | |
| if (!best || score > best.score) best = { short, meaning, score }; | |
| } | |
| if (!best) { audStatus.textContent = "No dog vocalization recognised in that clip."; return; } | |
| document.getElementById("audVerdict").textContent = | |
| `${best.short} — ${(best.score * 100).toFixed(1)}% confident`; | |
| document.getElementById("audMeaning").textContent = best.meaning; | |
| renderBars(document.getElementById("audBars"), scores); | |
| document.getElementById("audTop").textContent = dogMass < 0.10 | |
| ? "Low dog-vocalization confidence overall — this may not be a dog." | |
| : `Loudest overall AudioSet class: ${all[0].label}`; | |
| document.getElementById("audOut").classList.remove("hidden"); | |
| audStatus.textContent = "Ran on your device."; | |
| } catch (e) { | |
| audStatus.textContent = "Failed: " + e.message; | |
| } finally { | |
| audGo.disabled = false; | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |