Download static/index.html from Shoaib-33/NeuroScan: direct link, hf CLI and curl.
- Browser
- Download file 28.7 kB
-
https://huggingface.co/spaces/Shoaib-33/NeuroScan/resolve/main/static/index.html
- Command line
-
hf download hf://spaces/Shoaib-33/NeuroScan/static/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Shoaib-33/NeuroScan/resolve/main/static/index.html
28.7 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"/> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"/> | |
| <title>NeuroScan — Brain Tumor Classification</title> | |
| <link href="https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet"/> | |
| <style> | |
| *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} | |
| :root{ | |
| --bg:#0a0c10; | |
| --bg2:#11141a; | |
| --bg3:#181c24; | |
| --bg4:#1e2330; | |
| --border:#ffffff12; | |
| --border2:#ffffff20; | |
| --text:#e8eaf0; | |
| --text2:#8b90a0; | |
| --text3:#555b6e; | |
| --accent:#00e5ff; | |
| --accent2:#00b4cc; | |
| --accent-dim:#00e5ff18; | |
| --green:#00e676; | |
| --green-dim:#00e67615; | |
| --amber:#ffab40; | |
| --amber-dim:#ffab4015; | |
| --red:#ff5252; | |
| --red-dim:#ff525215; | |
| --purple:#ce93d8; | |
| --purple-dim:#ce93d815; | |
| --radius:10px; | |
| --shadow:0 4px 24px #00000060; | |
| } | |
| html{scroll-behavior:smooth} | |
| body{background:var(--bg);color:var(--text);font-family:'DM Mono',monospace;font-size:14px;line-height:1.7;min-height:100vh;overflow-x:hidden} | |
| body::before{ | |
| content:'';position:fixed;inset:0; | |
| background:repeating-linear-gradient(0deg,transparent,transparent 2px,#00000018 2px,#00000018 4px); | |
| pointer-events:none;z-index:9999;opacity:.35 | |
| } | |
| .shell{max-width:1100px;margin:0 auto;padding:0 24px 80px} | |
| header{ | |
| padding:40px 0 32px; | |
| border-bottom:1px solid var(--border); | |
| display:flex;align-items:center;justify-content:space-between;gap:16px; | |
| flex-wrap:wrap | |
| } | |
| .logo{display:flex;align-items:center;gap:14px} | |
| .logo-mark{ | |
| width:42px;height:42px;border-radius:8px; | |
| background:linear-gradient(135deg,var(--accent),#0066ff); | |
| display:grid;place-items:center;font-size:20px;flex-shrink:0 | |
| } | |
| .logo-text{font-family:'Syne',sans-serif;font-size:22px;font-weight:800; | |
| background:linear-gradient(90deg,var(--accent),#80d8ff); | |
| -webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text | |
| } | |
| .logo-sub{font-size:11px;color:var(--text3);letter-spacing:.12em;text-transform:uppercase;margin-top:-2px} | |
| .header-badges{display:flex;gap:8px;flex-wrap:wrap} | |
| .badge{ | |
| padding:4px 10px;border-radius:20px;font-size:11px;letter-spacing:.06em; | |
| border:1px solid var(--border2);color:var(--text2);background:var(--bg3) | |
| } | |
| .badge.live{border-color:#00e67640;color:var(--green);background:var(--green-dim)} | |
| .badge.live::before{content:'● ';font-size:8px} | |
| .section-title{ | |
| font-family:'Syne',sans-serif;font-size:12px;font-weight:600; | |
| letter-spacing:.14em;text-transform:uppercase; | |
| color:var(--accent);margin-bottom:16px; | |
| display:flex;align-items:center;gap:8px | |
| } | |
| .section-title::before{content:'';width:20px;height:1px;background:var(--accent)} | |
| .grid-2{display:grid;grid-template-columns:1fr 1fr;gap:20px} | |
| .grid-3{display:grid;grid-template-columns:repeat(3,1fr);gap:16px} | |
| @media(max-width:700px){.grid-2,.grid-3{grid-template-columns:1fr}} | |
| .card{ | |
| background:var(--bg2);border:1px solid var(--border); | |
| border-radius:var(--radius);padding:24px; | |
| } | |
| .card-sm{padding:16px 20px} | |
| #upload-zone{ | |
| border:2px dashed var(--border2);border-radius:var(--radius); | |
| padding:48px 24px;text-align:center;cursor:pointer; | |
| transition:border-color .2s,background .2s;position:relative | |
| } | |
| #upload-zone:hover,#upload-zone.drag{border-color:var(--accent);background:var(--accent-dim)} | |
| #upload-zone input{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%} | |
| .upload-icon{font-size:36px;margin-bottom:12px;opacity:.5} | |
| .upload-label{font-family:'Syne',sans-serif;font-size:15px;font-weight:600;color:var(--text)} | |
| .upload-hint{font-size:12px;color:var(--text3);margin-top:6px} | |
| #preview-area{display:none;gap:12px;flex-direction:column} | |
| .preview-img-wrap{position:relative;border-radius:8px;overflow:hidden;background:var(--bg3)} | |
| .preview-img-wrap img{width:100%;display:block;border-radius:8px;max-height:260px;object-fit:contain} | |
| .img-filename{font-size:11px;color:var(--text3);margin-top:6px;word-break:break-all} | |
| .backend-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:20px} | |
| .backend-btn{ | |
| padding:10px 12px;border-radius:8px;border:1px solid var(--border2); | |
| background:var(--bg3);color:var(--text2);cursor:pointer; | |
| font-family:'DM Mono',monospace;font-size:12px;text-align:left; | |
| transition:border-color .15s,background .15s,color .15s;line-height:1.4 | |
| } | |
| .backend-btn:hover{border-color:var(--accent2);color:var(--text)} | |
| .backend-btn.active{border-color:var(--accent);background:var(--accent-dim);color:var(--accent)} | |
| .backend-btn.disabled{ | |
| opacity:.35;cursor:not-allowed;pointer-events:none | |
| } | |
| .backend-btn .b-name{font-weight:500;display:block} | |
| .backend-btn .b-tag{font-size:10px;opacity:.6;margin-top:2px} | |
| #analyse-btn{ | |
| width:100%;padding:14px;border-radius:8px;border:none;cursor:pointer; | |
| font-family:'Syne',sans-serif;font-size:14px;font-weight:700; | |
| letter-spacing:.08em;text-transform:uppercase; | |
| background:linear-gradient(90deg,var(--accent),#0080ff); | |
| color:#000;transition:opacity .2s,transform .1s | |
| } | |
| #analyse-btn:hover{opacity:.9} | |
| #analyse-btn:active{transform:scale(.98)} | |
| #analyse-btn:disabled{opacity:.35;cursor:not-allowed} | |
| #results-panel{display:none} | |
| .prediction-hero{ | |
| display:flex;align-items:center;gap:16px; | |
| padding:20px;border-radius:8px;margin-bottom:20px | |
| } | |
| .prediction-hero.glioma{background:var(--red-dim);border:1px solid #ff525230} | |
| .prediction-hero.meningioma{background:var(--amber-dim);border:1px solid #ffab4030} | |
| .prediction-hero.notumor{background:var(--green-dim);border:1px solid #00e67630} | |
| .prediction-hero.pituitary{background:var(--purple-dim);border:1px solid #ce93d830} | |
| .pred-class{ | |
| font-family:'Syne',sans-serif;font-size:22px;font-weight:800; | |
| text-transform:uppercase;letter-spacing:.06em | |
| } | |
| .pred-class.glioma{color:var(--red)} | |
| .pred-class.meningioma{color:var(--amber)} | |
| .pred-class.notumor{color:var(--green)} | |
| .pred-class.pituitary{color:var(--purple)} | |
| .pred-conf{font-size:13px;color:var(--text2);margin-top:2px} | |
| .pred-badge{ | |
| margin-left:auto;padding:6px 14px;border-radius:20px; | |
| font-size:11px;font-weight:500;flex-shrink:0 | |
| } | |
| .pred-badge.high{background:var(--green-dim);color:var(--green);border:1px solid #00e67640} | |
| .pred-badge.mid{background:var(--amber-dim);color:var(--amber);border:1px solid #ffab4040} | |
| .pred-badge.low{background:var(--red-dim);color:var(--red);border:1px solid #ff525240} | |
| .prob-row{margin-bottom:10px} | |
| .prob-header{display:flex;justify-content:space-between;margin-bottom:4px;font-size:12px} | |
| .prob-name{color:var(--text2)} | |
| .prob-val{color:var(--text)} | |
| .prob-track{height:6px;background:var(--bg4);border-radius:3px;overflow:hidden} | |
| .prob-fill{height:100%;border-radius:3px;transition:width .8s cubic-bezier(.4,0,.2,1)} | |
| .prob-fill.glioma{background:var(--red)} | |
| .prob-fill.meningioma{background:var(--amber)} | |
| .prob-fill.notumor{background:var(--green)} | |
| .prob-fill.pituitary{background:var(--purple)} | |
| .stats-row{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:16px} | |
| .stat-card{ | |
| background:var(--bg3);border:1px solid var(--border); | |
| border-radius:8px;padding:14px;text-align:center | |
| } | |
| .stat-val{font-family:'Syne',sans-serif;font-size:18px;font-weight:700;color:var(--accent)} | |
| .stat-lbl{font-size:11px;color:var(--text3);margin-top:2px;text-transform:uppercase;letter-spacing:.08em} | |
| .gradcam-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:16px} | |
| .gradcam-item{text-align:center} | |
| .gradcam-item img{width:100%;border-radius:8px;display:block} | |
| .gradcam-lbl{font-size:11px;color:var(--text3);margin-top:6px;text-transform:uppercase;letter-spacing:.08em} | |
| .model-table{width:100%;border-collapse:collapse;font-size:12px} | |
| .model-table th{color:var(--text3);font-weight:400;text-align:left;padding:8px 10px; | |
| border-bottom:1px solid var(--border);text-transform:uppercase;letter-spacing:.08em} | |
| .model-table td{padding:10px;border-bottom:1px solid var(--border)} | |
| .model-table tr:last-child td{border-bottom:none} | |
| .model-table tr:hover td{background:var(--bg3)} | |
| .tag{ | |
| padding:2px 8px;border-radius:4px;font-size:10px; | |
| background:var(--accent-dim);color:var(--accent);border:1px solid var(--accent)30 | |
| } | |
| .tag.green{background:var(--green-dim);color:var(--green);border-color:#00e67630} | |
| .tag.amber{background:var(--amber-dim);color:var(--amber);border-color:#ffab4030} | |
| .spinner{display:none;width:20px;height:20px;border:2px solid #ffffff20; | |
| border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite} | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| .btn-inner{display:flex;align-items:center;justify-content:center;gap:10px} | |
| #toast{ | |
| position:fixed;bottom:28px;right:28px;z-index:1000; | |
| padding:12px 20px;border-radius:8px;font-size:13px; | |
| background:var(--bg3);border:1px solid var(--border2); | |
| transform:translateY(80px);opacity:0;transition:transform .3s,opacity .3s | |
| } | |
| #toast.show{transform:translateY(0);opacity:1} | |
| #toast.error{border-color:#ff525240;color:var(--red)} | |
| #toast.success{border-color:#00e67640;color:var(--green)} | |
| .tabs{display:flex;gap:4px;border-bottom:1px solid var(--border);margin-bottom:20px} | |
| .tab{ | |
| padding:8px 16px;cursor:pointer;font-size:12px; | |
| color:var(--text3);border-bottom:2px solid transparent; | |
| margin-bottom:-1px;transition:color .15s,border-color .15s; | |
| text-transform:uppercase;letter-spacing:.08em | |
| } | |
| .tab.active{color:var(--accent);border-bottom-color:var(--accent)} | |
| .tab-panel{display:none} | |
| .tab-panel.active{display:block} | |
| .info-block{ | |
| background:var(--bg3);border:1px solid var(--border); | |
| border-radius:8px;padding:14px 16px;font-size:12px;color:var(--text2); | |
| line-height:1.8;margin-top:16px | |
| } | |
| .info-block strong{color:var(--text)} | |
| .divider{height:1px;background:var(--border);margin:28px 0} | |
| .compare-row{ | |
| display:flex;align-items:center;gap:12px;padding:10px 0; | |
| border-bottom:1px solid var(--border) | |
| } | |
| .compare-row:last-child{border-bottom:none} | |
| .compare-name{width:180px;flex-shrink:0;font-size:12px;color:var(--text2)} | |
| .compare-bar-wrap{flex:1;height:8px;background:var(--bg4);border-radius:4px;overflow:hidden} | |
| .compare-bar{height:100%;border-radius:4px;background:var(--accent);transition:width .9s} | |
| .compare-val{width:60px;text-align:right;font-size:12px;color:var(--text);flex-shrink:0} | |
| @keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}} | |
| .fade-in{animation:fadeUp .4s ease both} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="shell"> | |
| <header> | |
| <div class="logo"> | |
| <div class="logo-mark">🧠</div> | |
| <div> | |
| <div class="logo-text">NeuroScan</div> | |
| <div class="logo-sub">Brain Tumor Classification · v1.0</div> | |
| </div> | |
| </div> | |
| <div class="header-badges"> | |
| <span class="badge live" id="api-status">API Connected</span> | |
| <span class="badge">TF 2.10</span> | |
| <span class="badge">ONNX FP32</span> | |
| <span class="badge">Grad-CAM XAI</span> | |
| </div> | |
| </header> | |
| <div style="height:36px"></div> | |
| <div class="grid-2" style="align-items:start"> | |
| <div> | |
| <div class="section-title">MRI Input</div> | |
| <div class="card" style="margin-bottom:20px"> | |
| <div id="upload-zone"> | |
| <input type="file" id="file-input" accept="image/*"/> | |
| <div class="upload-icon">⬆</div> | |
| <div class="upload-label">Drop MRI image here</div> | |
| <div class="upload-hint">PNG, JPG, JPEG — any brain MRI scan</div> | |
| </div> | |
| <div id="preview-area" style="margin-top:16px"> | |
| <div class="preview-img-wrap"> | |
| <img id="preview-img" src="" alt="MRI preview"/> | |
| </div> | |
| <div class="img-filename" id="img-filename"></div> | |
| </div> | |
| </div> | |
| <div class="section-title">Inference Backend</div> | |
| <div class="card" style="margin-bottom:20px"> | |
| <div class="backend-grid"> | |
| <button class="backend-btn active" data-backend="tensorflow" onclick="selectBackend(this)"> | |
| <span class="b-name">TensorFlow</span> | |
| <span class="b-tag">FP32 · Grad-CAM</span> | |
| </button> | |
| <button class="backend-btn" data-backend="onnx_fp32" onclick="selectBackend(this)"> | |
| <span class="b-name">ONNX FP32</span> | |
| <span class="b-tag">Full precision</span> | |
| </button> | |
| <button class="backend-btn" id="dynamic-btn" data-backend="onnx_dynamic" onclick="selectBackend(this)"> | |
| <span class="b-name">Dynamic INT8</span> | |
| <span class="b-tag">Quantized · faster</span> | |
| </button> | |
| <button class="backend-btn" data-backend="onnx_static" onclick="selectBackend(this)"> | |
| <span class="b-name">Static INT8</span> | |
| <span class="b-tag">Calibrated · smallest</span> | |
| </button> | |
| </div> | |
| <div id="gradcam-toggle-wrap" style="display:flex;align-items:center;gap:10px;margin-bottom:20px"> | |
| <label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;color:var(--text2)"> | |
| <div style="position:relative;width:36px;height:20px"> | |
| <input type="checkbox" id="gradcam-toggle" style="opacity:0;position:absolute;width:100%;height:100%;cursor:pointer;margin:0" onchange="updateGradcamUI()"/> | |
| <div id="toggle-track" style="width:36px;height:20px;background:var(--bg4);border-radius:10px;border:1px solid var(--border2);transition:background .2s"></div> | |
| <div id="toggle-thumb" style="position:absolute;top:3px;left:3px;width:14px;height:14px;background:var(--text3);border-radius:50%;transition:transform .2s,background .2s"></div> | |
| </div> | |
| Show Grad-CAM heatmap | |
| </label> | |
| <span id="gradcam-note" style="font-size:11px;color:var(--text3)">(TF backend only)</span> | |
| </div> | |
| <button id="analyse-btn" onclick="analyse()" disabled> | |
| <div class="btn-inner"> | |
| <div class="spinner" id="spinner"></div> | |
| <span id="btn-label">Upload an image first</span> | |
| </div> | |
| </button> | |
| </div> | |
| <div class="section-title">Quantization Info</div> | |
| <div class="card card-sm"> | |
| <div class="tabs"> | |
| <div class="tab active" onclick="switchTab(this,'q-dynamic')">Dynamic INT8</div> | |
| <div class="tab" onclick="switchTab(this,'q-static')">Static INT8</div> | |
| <div class="tab" onclick="switchTab(this,'q-compare')">Compare</div> | |
| </div> | |
| <div id="q-dynamic" class="tab-panel active"> | |
| <div class="info-block"> | |
| <strong>Dynamic Quantization</strong> converts model <strong>weights</strong> to INT8 at export time. Activations remain FP32 at runtime and are quantized on-the-fly. No calibration data needed. | |
| </div> | |
| </div> | |
| <div id="q-static" class="tab-panel"> | |
| <div class="info-block"> | |
| <strong>Static Quantization</strong> quantizes both <strong>weights and activations</strong> to INT8. Requires calibration data. It can be smaller and faster, but accuracy may vary by architecture. | |
| </div> | |
| </div> | |
| <div id="q-compare" class="tab-panel"> | |
| <div id="benchmark-data" style="padding:4px 0"> | |
| <div style="color:var(--text3);font-size:12px">Loading benchmark...</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="section-title">Analysis Results</div> | |
| <div id="placeholder" class="card" style="text-align:center;padding:60px 24px"> | |
| <div style="font-size:48px;opacity:.2;margin-bottom:16px">🔬</div> | |
| <div style="font-family:'Syne',sans-serif;font-size:15px;color:var(--text3)"> | |
| Upload an MRI scan and click analyse | |
| </div> | |
| <div style="font-size:12px;color:var(--text3);margin-top:8px;opacity:.6"> | |
| Supports glioma · meningioma · no tumor · pituitary | |
| </div> | |
| </div> | |
| <div id="results-panel" class="fade-in"> | |
| <div id="pred-hero" class="prediction-hero card"> | |
| <div> | |
| <div class="pred-class" id="pred-class-text"></div> | |
| <div class="pred-conf" id="pred-conf-text"></div> | |
| </div> | |
| <div class="pred-badge" id="pred-badge"></div> | |
| </div> | |
| <div class="card" style="margin-bottom:16px"> | |
| <div class="section-title">Class Probabilities</div> | |
| <div id="prob-bars"></div> | |
| <div class="stats-row" id="stats-row"> | |
| <div class="stat-card"> | |
| <div class="stat-val" id="stat-conf">—</div> | |
| <div class="stat-lbl">Confidence</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-val" id="stat-latency">—</div> | |
| <div class="stat-lbl">Latency</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-val" id="stat-backend">—</div> | |
| <div class="stat-lbl">Backend</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="card" id="gradcam-card" style="display:none;margin-bottom:16px"> | |
| <div class="section-title">Grad-CAM Explanation</div> | |
| <div class="gradcam-grid"> | |
| <div class="gradcam-item"> | |
| <img id="gradcam-overlay-img" src="" alt="Grad-CAM overlay"/> | |
| <div class="gradcam-lbl">Activation Overlay</div> | |
| </div> | |
| <div class="gradcam-item"> | |
| <img id="gradcam-heatmap-img" src="" alt="Heatmap"/> | |
| <div class="gradcam-lbl">Raw Heatmap</div> | |
| </div> | |
| </div> | |
| <div class="info-block" style="margin-top:12px"> | |
| <strong>Grad-CAM</strong> highlights regions the model focused on. | |
| </div> | |
| </div> | |
| <div class="card card-sm"> | |
| <div class="section-title">Class Reference</div> | |
| <table class="model-table"> | |
| <thead><tr> | |
| <th>Class</th><th>Description</th><th>Severity</th> | |
| </tr></thead> | |
| <tbody> | |
| <tr><td><span style="color:var(--red)">Glioma</span></td> | |
| <td style="color:var(--text2)">Tumor of glial cells</td> | |
| <td><span class="tag">High</span></td></tr> | |
| <tr><td><span style="color:var(--amber)">Meningioma</span></td> | |
| <td style="color:var(--text2)">Tumor of meninges</td> | |
| <td><span class="tag amber">Medium</span></td></tr> | |
| <tr><td><span style="color:var(--green)">No Tumor</span></td> | |
| <td style="color:var(--text2)">Normal brain MRI</td> | |
| <td><span class="tag green">None</span></td></tr> | |
| <tr><td><span style="color:var(--purple)">Pituitary</span></td> | |
| <td style="color:var(--text2)">Pituitary gland tumor</td> | |
| <td><span class="tag amber">Medium</span></td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="divider"></div> | |
| <div class="section-title">Model Performance Comparison</div> | |
| <div class="card"> | |
| <div id="model-compare"> | |
| <div style="color:var(--text3);font-size:12px">Loading model info...</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="toast"></div> | |
| <script> | |
| const API = ''; | |
| let selectedBackend = 'tensorflow'; | |
| let selectedFile = null; | |
| let healthInfo = null; | |
| async function checkHealth(){ | |
| try{ | |
| const r = await fetch(`${API}/health`); | |
| if(!r.ok) throw new Error(); | |
| const d = await r.json(); | |
| healthInfo = d; | |
| document.getElementById('api-status').textContent = `API · ${d.loaded_backends.length} backends`; | |
| const failed = d.failed_backends || {}; | |
| if (failed.onnx_dynamic) { | |
| const btn = document.getElementById('dynamic-btn'); | |
| if (btn) { | |
| btn.classList.add('disabled'); | |
| btn.title = 'Dynamic INT8 is not available in this runtime'; | |
| } | |
| if (selectedBackend === 'onnx_dynamic') { | |
| selectedBackend = 'tensorflow'; | |
| document.querySelectorAll('.backend-btn').forEach(b => b.classList.remove('active')); | |
| document.querySelector('.backend-btn[data-backend="tensorflow"]').classList.add('active'); | |
| } | |
| } | |
| } catch(e){ | |
| const el = document.getElementById('api-status'); | |
| el.textContent = 'API Offline'; | |
| el.classList.remove('live'); | |
| } | |
| } | |
| async function loadModelInfo(){ | |
| try{ | |
| const r = await fetch(`${API}/models/info`); | |
| if(!r.ok) throw new Error(); | |
| const d = await r.json(); | |
| const results = d.all_results || {}; | |
| const values = Object.values(results); | |
| const max = values.length ? Math.max(...values) : null; | |
| let html = ''; | |
| for(const [name, acc] of Object.entries(results)){ | |
| const pct = (acc*100).toFixed(2); | |
| const isB = acc === max; | |
| html += `<div class="compare-row"> | |
| <div class="compare-name">${name}${isB ? ' <span class="tag green">best</span>' : ''}</div> | |
| <div class="compare-bar-wrap"><div class="compare-bar" style="width:${pct}%;background:${isB?'var(--green)':'var(--accent)'}"></div></div> | |
| <div class="compare-val">${pct}%</div> | |
| </div>`; | |
| } | |
| document.getElementById('model-compare').innerHTML = html || '<div style="color:var(--text3);font-size:12px">No data</div>'; | |
| } catch(e){ | |
| document.getElementById('model-compare').innerHTML = '<div style="color:var(--text3);font-size:12px">Run train.py to generate model info.</div>'; | |
| } | |
| } | |
| async function loadBenchmark(){ | |
| try{ | |
| const r = await fetch(`${API}/benchmark`); | |
| if(!r.ok) throw new Error(); | |
| const d = await r.json(); | |
| let html = '<table class="model-table"><thead><tr><th>Format</th><th>Accuracy</th><th>Latency</th><th>Size</th></tr></thead><tbody>'; | |
| for(const [name, v] of Object.entries(d)){ | |
| const acc = v.accuracy !== undefined ? (v.accuracy*100).toFixed(2)+'%' : '—'; | |
| const lat = v.latency_ms !== undefined ? v.latency_ms.toFixed(1)+' ms' : '—'; | |
| const size = v.size_mb !== null && v.size_mb !== undefined ? v.size_mb.toFixed(1)+' MB' : '—'; | |
| html += `<tr><td>${name}</td><td>${acc}</td><td>${lat}</td><td>${size}</td></tr>`; | |
| } | |
| html += '</tbody></table>'; | |
| document.getElementById('benchmark-data').innerHTML = html; | |
| } catch(e){ | |
| document.getElementById('benchmark-data').innerHTML = '<div style="color:var(--text3);font-size:12px">Run export_onnx.py to generate benchmark data.</div>'; | |
| } | |
| } | |
| const fileInput = document.getElementById('file-input'); | |
| const uploadZone = document.getElementById('upload-zone'); | |
| fileInput.addEventListener('change', e => { | |
| if(e.target.files[0]) handleFile(e.target.files[0]); | |
| }); | |
| uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('drag'); }); | |
| uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('drag')); | |
| uploadZone.addEventListener('drop', e => { | |
| e.preventDefault(); | |
| uploadZone.classList.remove('drag'); | |
| if(e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]); | |
| }); | |
| function handleFile(file){ | |
| selectedFile = file; | |
| const reader = new FileReader(); | |
| reader.onload = ev => { | |
| document.getElementById('preview-img').src = ev.target.result; | |
| document.getElementById('img-filename').textContent = file.name; | |
| document.getElementById('preview-area').style.display = 'flex'; | |
| document.getElementById('analyse-btn').disabled = false; | |
| document.getElementById('btn-label').textContent = 'Analyse MRI'; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| function selectBackend(btn){ | |
| if (btn.classList.contains('disabled')) return; | |
| document.querySelectorAll('.backend-btn').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| selectedBackend = btn.dataset.backend; | |
| updateGradcamUI(); | |
| } | |
| function updateGradcamUI(){ | |
| const isTF = selectedBackend === 'tensorflow'; | |
| const toggle = document.getElementById('gradcam-toggle'); | |
| const track = document.getElementById('toggle-track'); | |
| const thumb = document.getElementById('toggle-thumb'); | |
| const note = document.getElementById('gradcam-note'); | |
| note.style.opacity = isTF ? '0' : '1'; | |
| if(!isTF){ toggle.checked = false; } | |
| toggle.disabled = !isTF; | |
| const on = toggle.checked && isTF; | |
| track.style.background = on ? 'var(--accent2)' : 'var(--bg4)'; | |
| thumb.style.transform = on ? 'translateX(16px)' : 'none'; | |
| thumb.style.background = on ? '#000' : 'var(--text3)'; | |
| } | |
| document.getElementById('gradcam-toggle').addEventListener('change', updateGradcamUI); | |
| async function analyse(){ | |
| if(!selectedFile) return; | |
| const btn = document.getElementById('analyse-btn'); | |
| const spinner = document.getElementById('spinner'); | |
| const label = document.getElementById('btn-label'); | |
| btn.disabled = true; | |
| spinner.style.display = 'block'; | |
| label.textContent = 'Analysing...'; | |
| const useGradcam = document.getElementById('gradcam-toggle').checked && selectedBackend === 'tensorflow'; | |
| const formData = new FormData(); | |
| formData.append('file', selectedFile); | |
| const endpoint = useGradcam | |
| ? `${API}/predict/gradcam` | |
| : selectedBackend === 'tensorflow' ? `${API}/predict` | |
| : selectedBackend === 'onnx_fp32' ? `${API}/predict/onnx` | |
| : selectedBackend === 'onnx_dynamic' ? `${API}/predict/dynamic` | |
| : `${API}/predict/static`; | |
| try{ | |
| const r = await fetch(endpoint, { method:'POST', body: formData }); | |
| if(!r.ok){ | |
| let message = 'API error'; | |
| try{ | |
| const err = await r.json(); | |
| message = err.detail || message; | |
| } catch(_) {} | |
| throw new Error(message); | |
| } | |
| const data = await r.json(); | |
| renderResults(data, useGradcam); | |
| showToast('Analysis complete', 'success'); | |
| } catch(e){ | |
| showToast(`Error: ${e.message}`, 'error'); | |
| } finally{ | |
| btn.disabled = false; | |
| spinner.style.display = 'none'; | |
| label.textContent = 'Analyse MRI'; | |
| } | |
| } | |
| function renderResults(data, hasGradcam){ | |
| const cls = data.predicted_class.toLowerCase().replace(' ',''); | |
| const conf = data.confidence; | |
| const confPct = `${conf.toFixed(1)}%`; | |
| document.getElementById('placeholder').style.display = 'none'; | |
| document.getElementById('results-panel').style.display = 'block'; | |
| document.getElementById('results-panel').classList.add('fade-in'); | |
| const hero = document.getElementById('pred-hero'); | |
| hero.className = `prediction-hero card ${cls}`; | |
| const classText = document.getElementById('pred-class-text'); | |
| classText.className = `pred-class ${cls}`; | |
| classText.textContent = data.predicted_class.toUpperCase(); | |
| document.getElementById('pred-conf-text').textContent = | |
| `Confidence: ${confPct} · via ${data.backend}`; | |
| const badge = document.getElementById('pred-badge'); | |
| const level = conf >= 80 ? 'high' : conf >= 50 ? 'mid' : 'low'; | |
| badge.className = `pred-badge ${level}`; | |
| badge.textContent = conf >= 80 ? 'High confidence' : conf >= 50 ? 'Moderate' : 'Low confidence'; | |
| const probs = data.all_probabilities; | |
| let barsHtml = ''; | |
| for(const [name, p] of Object.entries(probs).sort((a,b) => b[1]-a[1])){ | |
| const clsKey = name.toLowerCase().replace(' ',''); | |
| const pct = p.toFixed(1); | |
| barsHtml += `<div class="prob-row"> | |
| <div class="prob-header"> | |
| <span class="prob-name">${name}</span> | |
| <span class="prob-val">${pct}%</span> | |
| </div> | |
| <div class="prob-track"> | |
| <div class="prob-fill ${clsKey}" style="width:${p}%"></div> | |
| </div> | |
| </div>`; | |
| } | |
| document.getElementById('prob-bars').innerHTML = barsHtml; | |
| document.getElementById('stat-conf').textContent = confPct; | |
| document.getElementById('stat-latency').textContent = data.latency_ms ? `${data.latency_ms}ms` : '—'; | |
| const backendShort = { | |
| tensorflow:'TF FP32', | |
| onnx_fp32:'ONNX FP32', | |
| onnx_dynamic:'Dynamic INT8', | |
| onnx_static:'Static INT8' | |
| }[data.backend] || data.backend; | |
| document.getElementById('stat-backend').textContent = backendShort; | |
| const gcCard = document.getElementById('gradcam-card'); | |
| if(hasGradcam && data.gradcam_b64){ | |
| document.getElementById('gradcam-overlay-img').src = `data:image/png;base64,${data.gradcam_b64}`; | |
| document.getElementById('gradcam-heatmap-img').src = `data:image/png;base64,${data.heatmap_b64}`; | |
| gcCard.style.display = 'block'; | |
| } else { | |
| gcCard.style.display = 'none'; | |
| } | |
| } | |
| function switchTab(tab, panelId){ | |
| const parent = tab.closest('.card'); | |
| parent.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); | |
| parent.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); | |
| tab.classList.add('active'); | |
| document.getElementById(panelId).classList.add('active'); | |
| } | |
| function showToast(msg, type='success'){ | |
| const t = document.getElementById('toast'); | |
| t.textContent = msg; | |
| t.className = `show ${type}`; | |
| setTimeout(() => t.className = '', 3500); | |
| } | |
| checkHealth(); | |
| loadModelInfo(); | |
| loadBenchmark(); | |
| updateGradcamUI(); | |
| </script> | |
| </body> | |
| </html> |