dally-model-demo / index.html
johnpitteera's picture
Fix broken dashed drop-zone border (label was display:inline)
52ae79c verified
Raw
History Blame Contribute Delete
15.9 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dally — Dog Emotion &amp; 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 &amp; 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>