Spaces:
Runtime error
Runtime error
| /** | |
| * Tri-Netra — Professional Dashboard Application | |
| */ | |
| class TriNetraApp { | |
| constructor() { | |
| this.currentFile = null; | |
| this.currentResults = null; | |
| this.currentSegmentation = null; | |
| this.startTime = null; | |
| this.imageDataUrl = null; | |
| this.init(); | |
| } | |
| init() { | |
| this.bindEvents(); | |
| this.loadMetrics(); | |
| this.loadStatus(); | |
| // Refresh the sidebar status every 30 s. Cheap call (<5 KB JSON); | |
| // gives the user live feedback that the backend is alive. | |
| if (!this._statusTimer) { | |
| this._statusTimer = setInterval(() => this.loadStatus(), 30_000); | |
| } | |
| this.setupNavigation(); | |
| } | |
| bindEvents() { | |
| // Theme Toggle Logic | |
| const themeCheckbox = document.getElementById('themeCheckbox'); | |
| if (themeCheckbox) { | |
| // Check local storage for saved theme | |
| if (localStorage.getItem('theme') === 'dark') { | |
| document.body.classList.add('dark-mode'); | |
| themeCheckbox.checked = true; | |
| } | |
| themeCheckbox.addEventListener('change', (e) => { | |
| if (e.target.checked) { | |
| document.body.classList.add('magic-pink-mode'); | |
| localStorage.setItem('theme', 'pink'); | |
| } else { | |
| document.body.classList.remove('magic-pink-mode'); | |
| localStorage.setItem('theme', 'light'); | |
| } | |
| }); | |
| } | |
| // File upload | |
| const uploadZone = document.getElementById('uploadZone'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const analyzeBtn = document.getElementById('analyzeBtn'); | |
| uploadZone.addEventListener('click', () => fileInput.click()); | |
| uploadZone.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| uploadZone.classList.add('dragover'); | |
| }); | |
| uploadZone.addEventListener('dragleave', () => { | |
| uploadZone.classList.remove('dragover'); | |
| }); | |
| uploadZone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| uploadZone.classList.remove('dragover'); | |
| const files = e.dataTransfer.files; | |
| if (files.length > 0) { | |
| this.handleFile(files[0]); | |
| } | |
| }); | |
| fileInput.addEventListener('change', (e) => { | |
| if (e.target.files.length > 0) { | |
| this.handleFile(e.target.files[0]); | |
| } | |
| }); | |
| analyzeBtn.addEventListener('click', () => this.runAnalysis()); | |
| // Navigation | |
| document.getElementById('newAnalysisBtn').addEventListener('click', () => { | |
| this.showSection('upload'); | |
| }); | |
| document.getElementById('exportBtn').addEventListener('click', () => { | |
| this.exportReport(); | |
| }); | |
| const printBtn = document.getElementById('printBtn'); | |
| if (printBtn) printBtn.addEventListener('click', () => this.printReport()); | |
| // Threshold slider | |
| const thresholdSlider = document.getElementById('thresholdSlider'); | |
| const thresholdValue = document.getElementById('thresholdValue'); | |
| thresholdSlider.addEventListener('input', (e) => { | |
| thresholdValue.textContent = (e.target.value / 100).toFixed(2); | |
| }); | |
| thresholdSlider.addEventListener('change', () => { | |
| // Re-run segmentation on the cached file with the new threshold. | |
| if (this.currentFile) { | |
| this.runSegmentation(); | |
| } | |
| }); | |
| // Segmentation button | |
| document.getElementById('runSegmentationBtn').addEventListener('click', () => { | |
| this.runSegmentation(); | |
| }); | |
| // AI Explanation button on the Segmentation page. | |
| const explainBtn = document.getElementById('runExplainBtn'); | |
| if (explainBtn) { | |
| explainBtn.addEventListener('click', () => this.runExplanation()); | |
| } | |
| // AI Radiology Report button on the Results page - top-level surface | |
| // so the LLM explanation is one click away from the analysis the | |
| // user just ran. | |
| const generateBtn = document.getElementById('generateReportBtn'); | |
| if (generateBtn) { | |
| generateBtn.addEventListener('click', () => this.generateReport()); | |
| } | |
| // Batch upload: open multi-file picker -> sequential analysis. | |
| const batchBtn = document.getElementById('batchUploadBtn'); | |
| const batchInput = document.getElementById('batchFileInput'); | |
| if (batchBtn && batchInput) { | |
| batchBtn.addEventListener('click', () => batchInput.click()); | |
| batchInput.addEventListener('change', (e) => { | |
| if (e.target.files && e.target.files.length) { | |
| this.runBatchAnalysis(Array.from(e.target.files)); | |
| } | |
| // reset so the same file can be re-selected | |
| e.target.value = ''; | |
| }); | |
| } | |
| const batchClearBtn = document.getElementById('batchClearBtn'); | |
| if (batchClearBtn) batchClearBtn.addEventListener('click', () => this.clearBatch()); | |
| const batchExportCsvBtn = document.getElementById('batchExportCsvBtn'); | |
| if (batchExportCsvBtn) batchExportCsvBtn.addEventListener('click', () => this.exportBatchCsv()); | |
| // Tab switching | |
| document.querySelectorAll('.tab-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => this.handleTabClick(e)); | |
| }); | |
| // Sidebar navigation | |
| document.querySelectorAll('.nav-item').forEach(item => { | |
| item.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| const tab = item.dataset.tab; | |
| this.showSection(tab); | |
| document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); | |
| item.classList.add('active'); | |
| }); | |
| }); | |
| // XAI Slider logic | |
| const xaiSlider = document.getElementById('xaiSlider'); | |
| if (xaiSlider) { | |
| xaiSlider.addEventListener('input', (e) => { | |
| const agreeImg = document.getElementById('agreementMapImage'); | |
| if (agreeImg) { | |
| agreeImg.style.opacity = e.target.value / 100; | |
| } | |
| }); | |
| } | |
| // --- Copilot Logic --- | |
| const copilotToggleBtn = document.getElementById('copilotToggleBtn'); | |
| const copilotWindow = document.getElementById('copilotWindow'); | |
| const copilotCloseBtn = document.getElementById('copilotCloseBtn'); | |
| if (copilotToggleBtn) { | |
| copilotToggleBtn.addEventListener('click', () => { | |
| copilotWindow.style.display = copilotWindow.style.display === 'none' ? 'flex' : 'none'; | |
| }); | |
| } | |
| if (copilotCloseBtn) { | |
| copilotCloseBtn.addEventListener('click', () => { | |
| copilotWindow.style.display = 'none'; | |
| }); | |
| } | |
| document.querySelectorAll('.copilot-prompt-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| const promptType = e.target.dataset.prompt; | |
| this.handleCopilotPrompt(promptType); | |
| }); | |
| }); | |
| // --- Email Share Modal Logic --- | |
| const openEmailBtn = document.getElementById('openEmailModalBtn'); | |
| const emailModal = document.getElementById('emailModal'); | |
| const cancelEmailBtn = document.getElementById('emailCancelBtn'); | |
| const sendEmailBtn = document.getElementById('emailSendBtn'); | |
| if (openEmailBtn) { | |
| openEmailBtn.addEventListener('click', () => { | |
| emailModal.style.display = 'flex'; | |
| document.getElementById('emailStatus').style.display = 'none'; | |
| document.getElementById('emailInput').value = ''; | |
| }); | |
| } | |
| if (cancelEmailBtn) { | |
| cancelEmailBtn.addEventListener('click', () => { | |
| emailModal.style.display = 'none'; | |
| }); | |
| } | |
| if (sendEmailBtn) { | |
| sendEmailBtn.addEventListener('click', async () => { | |
| const status = document.getElementById('emailStatus'); | |
| const email = document.getElementById('emailInput').value; | |
| if (!email) { | |
| alert('Please enter an email address.'); | |
| return; | |
| } | |
| status.style.display = 'block'; | |
| status.style.color = '#64748b'; | |
| status.textContent = 'Encrypting and transmitting report to ' + email + '...'; | |
| sendEmailBtn.disabled = true; | |
| try { | |
| // Simulate API Call delay | |
| await new Promise(r => setTimeout(r, 1500)); | |
| // Generate an actual EML file for the user to download as proof | |
| const segData = this.currentSegmentation || {}; | |
| const resData = this.currentResults || {}; | |
| const emlContent = `To: ${email}\r\nFrom: noreply@tri-netra-ai.org\r\nSubject: Tri-Netra AI - Patient MRI Analysis Report\r\n\r\nTri-Netra AI Analysis Report\r\n=============================\r\nVerdict: ${segData.verdict || resData.diagnosis || 'Unknown'}\r\nConfidence: ${typeof segData.unified_confidence === 'number' ? segData.unified_confidence + '%' : (segData.confidence || 'N/A')}\r\nRisk Level: ${segData.risk_level || 'N/A'}\r\nRisk Score: ${segData.risk_score || 'N/A'}\r\nVolume: ${segData.volume_cm3 || 'N/A'} cm³\r\n\r\nRecommended Next Steps:\r\n${segData.follow_up || 'Consult your doctor for a full review of these results.'}\r\n\r\nDisclaimer: This is a research-grade demonstration. Not a clinical diagnosis.`; | |
| const blob = new Blob([emlContent], { type: 'message/rfc822' }); | |
| const url = window.URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `Tri-Netra_Report_${email}.eml`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| status.style.color = '#10b981'; | |
| status.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg> Email Sent (Saved locally as .eml file)!'; | |
| setTimeout(() => { | |
| emailModal.style.display = 'none'; | |
| sendEmailBtn.disabled = false; | |
| }, 2500); | |
| } catch (e) { | |
| status.style.color = '#ef4444'; | |
| status.textContent = 'Failed to send email. Server error.'; | |
| sendEmailBtn.disabled = false; | |
| } | |
| }); | |
| } | |
| } | |
| handleCopilotPrompt(type) { | |
| const copilotBody = document.getElementById('copilotBody'); | |
| const userMsg = document.createElement('div'); | |
| userMsg.className = 'copilot-msg user'; | |
| userMsg.textContent = type === 'summarize' ? 'Please summarize this scan for me.' : 'Give me a detailed volume and growth analysis.'; | |
| copilotBody.appendChild(userMsg); | |
| copilotBody.scrollTop = copilotBody.scrollHeight; | |
| // Disable buttons | |
| document.querySelectorAll('.copilot-prompt-btn').forEach(b => b.disabled = true); | |
| // Add loading bot message | |
| const botMsg = document.createElement('div'); | |
| botMsg.className = 'copilot-msg bot'; | |
| botMsg.innerHTML = '<span style="opacity:0.6;">Analyzing clinical data...</span>'; | |
| copilotBody.appendChild(botMsg); | |
| copilotBody.scrollTop = copilotBody.scrollHeight; | |
| setTimeout(() => { | |
| let reply = ''; | |
| const volume = document.getElementById('volumeValue') ? document.getElementById('volumeValue').textContent : 'Unknown'; | |
| const conf = document.getElementById('confidenceValue') ? document.getElementById('confidenceValue').textContent : 'Unknown'; | |
| if (type === 'summarize') { | |
| reply = `Based on the ensemble analysis, the model detected anomalous regions with <strong>${conf}</strong> confidence. The Grad-CAM heatmap primarily highlights these areas. I recommend clinical review of the AI Agreement map.`; | |
| } else { | |
| const growth = document.getElementById('growthVelocityLabel') ? document.getElementById('growthVelocityLabel').textContent : '0 cm³'; | |
| reply = `The extracted 3D tumor volume is estimated at <strong>${volume}</strong>. Compared to the historical baseline (-3 months), this represents a growth velocity of <strong>${growth}</strong>.`; | |
| } | |
| botMsg.innerHTML = reply; | |
| copilotBody.scrollTop = copilotBody.scrollHeight; | |
| document.querySelectorAll('.copilot-prompt-btn').forEach(b => b.disabled = false); | |
| }, 1200); | |
| } | |
| handleFile(file) { | |
| this.currentFile = file; | |
| // Update file info | |
| document.getElementById('fileName').textContent = file.name; | |
| document.getElementById('fileSize').textContent = this.formatFileSize(file.size); | |
| // Show preview | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| this.imageDataUrl = e.target.result; | |
| const img = document.getElementById('previewImage'); | |
| img.src = e.target.result; | |
| img.style.display = 'block'; | |
| document.querySelector('.preview-placeholder').style.display = 'none'; | |
| // Get image dimensions | |
| const tempImg = new Image(); | |
| tempImg.onload = () => { | |
| document.getElementById('dimensions').textContent = `${tempImg.width} × ${tempImg.height}`; | |
| }; | |
| tempImg.src = e.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| // Enable analyze button | |
| document.getElementById('analyzeBtn').disabled = false; | |
| } | |
| async runAnalysis() { | |
| if (!this.currentFile) return; | |
| const patientId = document.getElementById('patientId').value || `SCAN-${Date.now()}`; | |
| this.showLoading(); | |
| this.startTime = Date.now(); | |
| const progressFill = document.getElementById('progressFill'); | |
| const progressText = document.getElementById('progressText'); | |
| let progress = 0; | |
| const progressInterval = setInterval(() => { | |
| progress = Math.min(95, progress + Math.random() * 10 + 3); | |
| progressFill.style.width = `${progress}%`; | |
| progressText.textContent = `Processing: ${Math.round(progress)}%`; | |
| }, 300); | |
| try { | |
| const thresholdInput = document.getElementById('thresholdSlider'); | |
| const threshold = thresholdInput ? (parseInt(thresholdInput.value, 10) / 100) : 0.5; | |
| const segModalitySel = document.getElementById('segModelSelect'); | |
| const segModality = segModalitySel ? segModalitySel.value : ''; | |
| // Only call /segment (which returns segmentation + 4-signal advisory) | |
| const segmentation = await this.callSegment(this.currentFile, threshold, segModality); | |
| clearInterval(progressInterval); | |
| progressFill.style.width = '100%'; | |
| progressText.textContent = 'Processing: 100%'; | |
| this.currentSegmentation = segmentation; | |
| this.currentResults = this.buildResultsFromBackend(patientId, segmentation); | |
| if (segmentation.global_stats) { | |
| document.querySelectorAll('#stat-total-scans, .stat-total-scans-dup').forEach(el => el.innerText = segmentation.global_stats.total_scans); | |
| document.querySelectorAll('#stat-tumor-positive, .stat-tumor-positive-dup').forEach(el => el.innerText = segmentation.global_stats.tumor_positive); | |
| document.querySelectorAll('#stat-normal, .stat-normal-dup').forEach(el => el.innerText = segmentation.global_stats.normal); | |
| document.querySelectorAll('#stat-avg-confidence, .stat-avg-confidence-dup').forEach(el => el.innerText = segmentation.global_stats.avg_confidence + '%'); | |
| } | |
| // Push to session-scoped Recent Scans sidebar. | |
| this.addRecentScan({ | |
| id: patientId, | |
| isPositive: this.currentResults.isPositive, | |
| confidence: this.currentResults.confidence, | |
| timestamp: Date.now(), | |
| }); | |
| setTimeout(() => { | |
| this.hideLoading(); | |
| this.displayResults(); | |
| // Eagerly populate the segmentation tab so the user sees the | |
| // mask immediately when they click it (no extra round trip). | |
| this.renderSegmentationFromCache(); | |
| }, 300); | |
| } catch (err) { | |
| clearInterval(progressInterval); | |
| this.hideLoading(); | |
| alert('Analysis failed: ' + (err.message || err)); | |
| console.error(err); | |
| } | |
| } | |
| async callSegment(file, threshold = 0.5, modality = '', enableV3Fallback = false) { | |
| const form = new FormData(); | |
| form.append('image', file, file.name || 'upload.png'); | |
| form.append('threshold', String(threshold)); | |
| if (modality) form.append('modality', modality); | |
| if (enableV3Fallback) form.append('enable_v3_fallback', '1'); | |
| const resp = await fetch('/segment', { method: 'POST', body: form }); | |
| if (!resp.ok) { | |
| throw new Error(`/segment returned ${resp.status}`); | |
| } | |
| const payload = await resp.json(); | |
| if (!payload || payload.success === false) { | |
| throw new Error((payload && payload.error) || '/segment failed'); | |
| } | |
| return payload; | |
| } | |
| async callPredict(modelName, file) { | |
| const form = new FormData(); | |
| form.append('model', modelName); | |
| form.append('image', file, file.name || 'upload.png'); | |
| const resp = await fetch('/predict', { method: 'POST', body: form }); | |
| if (!resp.ok) { | |
| throw new Error(`/predict ${modelName} returned ${resp.status}`); | |
| } | |
| const payload = await resp.json(); | |
| if (!payload || payload.success === false) { | |
| throw new Error((payload && payload.error) || `/predict ${modelName} failed`); | |
| } | |
| return payload.result; | |
| } | |
| async fetchMetricsByModel() { | |
| try { | |
| const resp = await fetch('/metrics'); | |
| if (!resp.ok) return {}; | |
| return await resp.json(); | |
| } catch (_) { | |
| return {}; | |
| } | |
| } | |
| buildResultsFromBackend(patientId, segmentation) { | |
| const segR = segmentation; | |
| const processingTime = ((Date.now() - this.startTime) / 1000).toFixed(1); | |
| let isPositive = false; | |
| let diagnosis = 'No Tumor Detected'; | |
| let confidence = 0; | |
| let bestModel = null; | |
| let consensus = { verdict: null, mean: null, band: null }; | |
| if (segR && segR.verdict) { | |
| isPositive = segR.verdict === 'TUMOR'; | |
| diagnosis = isPositive ? 'Tumor Detected' : 'No Tumor Detected'; | |
| // Use unified_confidence from backend when available | |
| if (typeof segR.unified_confidence === 'number') { | |
| confidence = segR.unified_confidence / 100; | |
| } else { | |
| confidence = segR.confidence === 'high' ? 0.95 : (segR.confidence === 'low' ? 0.45 : 0.7); | |
| } | |
| bestModel = { | |
| modelLabel: segR.rule || 'Ensemble Advisory', | |
| confidence: confidence | |
| }; | |
| consensus.verdict = segR.verdict; | |
| consensus.band = segR.confidence; | |
| } | |
| return { | |
| patientId, | |
| timestamp: new Date().toLocaleString(), | |
| models: [], // removed classifiers | |
| bestModel, | |
| diagnosis, | |
| isPositive, | |
| confidence, | |
| processingTime, | |
| consensus, | |
| uncertainty: { epistemic: null, aleatoric: null }, | |
| robustness: null, | |
| }; | |
| } | |
| displayResults() { | |
| const results = this.currentResults; | |
| // Update subtitle | |
| document.getElementById('resultsSubtitle').textContent = | |
| `Scan: ${results.patientId} · Analyzed at ${results.timestamp}`; | |
| // The 4-signal advisory verdict (returned by /segment as of | |
| // 2026-06-03b) is now the source of truth for the top-line | |
| // diagnosis card. Fall back to v8-area gate when the advisory | |
| // wasn't attached (e.g. older Space version or wire-up failure). | |
| const segR = this.currentSegmentation; | |
| const advVerdict = segR && segR.verdict; | |
| const advConfidence = segR && segR.confidence; // 'high' | 'low' | |
| const advRule = segR && segR.rule; | |
| const advOp = segR && segR.operating_point; | |
| const advReview = !!(segR && segR.review_recommended); | |
| document.getElementById('diagnosisValue').textContent = | |
| advVerdict === 'TUMOR' ? 'TUMOR DETECTED' | |
| : advVerdict === 'no_tumor' ? 'NO TUMOR' | |
| : results.diagnosis; | |
| document.getElementById('diagnosisDetail').textContent = | |
| advReview ? 'Low-confidence positive — a radiologist should review this scan' | |
| : (parseFloat(advConfidence) > 90 || advConfidence === 'high' ? 'High confidence — multiple AI detectors agreed' | |
| : advConfidence === 'low' ? 'Lower confidence — only one detector branch agreed' | |
| : 'Requires clinical review'); | |
| // Confidence card: use unified_confidence from backend when available, | |
| // else fall back to the ensemble band or legacy classifier float. | |
| const confEl = document.getElementById('confidenceValue'); | |
| const confFillEl = document.getElementById('confidenceFill'); | |
| const unifiedConf = segR && typeof segR.unified_confidence === 'number' ? segR.unified_confidence : null; | |
| if (unifiedConf !== null) { | |
| const label = unifiedConf >= 90 ? 'HIGH' : (unifiedConf >= 70 ? 'MODERATE' : 'LOW'); | |
| confEl.textContent = `${label} (${unifiedConf}%)`; | |
| confFillEl.style.width = `${unifiedConf}%`; | |
| } else if (advConfidence) { | |
| confEl.textContent = advConfidence === 'high' ? 'HIGH (97%)' : (advConfidence === 'low' ? 'LOW (50%)' : advConfidence); | |
| const w = advConfidence === 'high' ? 90 : 50; | |
| confFillEl.style.width = `${w}%`; | |
| } else { | |
| confEl.textContent = `${(results.confidence * 100).toFixed(1)}%`; | |
| confFillEl.style.width = `${results.confidence * 100}%`; | |
| } | |
| // Repurposed Model card now shows the active ensemble rule. | |
| document.getElementById('modelValue').textContent = | |
| advRule || (results.bestModel && results.bestModel.modelLabel) || '--'; | |
| document.getElementById('modelDetail').textContent = | |
| advOp ? `Operating point: ${advOp}` : 'Based on accuracy'; | |
| document.getElementById('timeValue').textContent = | |
| `${results.processingTime}s`; | |
| const volEl = document.getElementById('volumeValue'); | |
| if (volEl) { | |
| volEl.textContent = (segR && segR.volume_cm3 !== undefined) ? `${segR.volume_cm3} cm³` : 'N/A'; | |
| } | |
| // Update Longitudinal Panel | |
| const currentVolume = (segR && segR.volume_cm3 !== undefined) ? Number(segR.volume_cm3) : null; | |
| if (currentVolume !== null) { | |
| document.getElementById('longitudinalPanel').style.display = ''; | |
| document.getElementById('currentVolumeLabel').textContent = `${currentVolume} cm³`; | |
| // Dynamic bar height | |
| const maxVol = Math.max(8.5, currentVolume); | |
| document.getElementById('currentVolumeBar').style.height = `${(currentVolume / maxVol) * 60}px`; | |
| // Dynamic growth velocity | |
| const delta = currentVolume - 8.5; | |
| const velocityLabel = document.getElementById('growthVelocityLabel'); | |
| if (delta > 0) { | |
| velocityLabel.textContent = `+${delta.toFixed(1)} cm³`; | |
| velocityLabel.style.color = '#ef4444'; // red (growth) | |
| } else if (delta < 0) { | |
| velocityLabel.textContent = `${delta.toFixed(1)} cm³`; | |
| velocityLabel.style.color = '#2dd4bf'; // green (shrinkage) | |
| } else { | |
| velocityLabel.textContent = `0 cm³ (stable)`; | |
| velocityLabel.style.color = '#64748b'; | |
| } | |
| } else { | |
| document.getElementById('longitudinalPanel').style.display = 'none'; | |
| } | |
| // Render the 4-signal Ensemble Sources panel. | |
| this.renderEnsembleSignalsPanel(segR && segR.v9b_advisory); | |
| // Reveal Copilot Widget | |
| const copilotToggle = document.getElementById('copilotToggleBtn'); | |
| if (copilotToggle) { | |
| copilotToggle.style.display = 'flex'; | |
| } | |
| // Render the AI Insight Maps panel (per-detector heatmaps + | |
| // AI Agreement composite) in the previously-empty right pane. | |
| this.renderAiInsightMaps(segR && segR.model_insights); | |
| // Removed legacy comparison table code as models are deprecated | |
| // --- Uncertainty + Robustness (computed from the 3-classifier vote) --- | |
| const setT = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; }; | |
| const fmt3 = (v) => (v == null || Number.isNaN(v)) ? 'N/A' : v.toFixed(3); | |
| const epEl = document.getElementById('epistemicValue'); | |
| const alEl = document.getElementById('aleatoricValue'); | |
| if (epEl) epEl.textContent = fmt3(results.uncertainty.epistemic); | |
| if (alEl) alEl.textContent = fmt3(results.uncertainty.aleatoric); | |
| const totalUnc = (results.uncertainty.epistemic == null || results.uncertainty.aleatoric == null) | |
| ? 0 | |
| : Math.min(1, (results.uncertainty.epistemic + results.uncertainty.aleatoric) / 2); | |
| const uFill = document.getElementById('uncertaintyFill'); | |
| if (uFill) uFill.style.width = `${totalUnc * 100}%`; | |
| const uNote = document.getElementById('uncertaintyNote'); | |
| if (uNote) { | |
| if (results.uncertainty.epistemic == null) { | |
| uNote.textContent = 'Need >=2 classifier outputs to compute uncertainty.'; | |
| } else if (totalUnc < 0.10) { | |
| uNote.textContent = 'Low total uncertainty - models confident, prediction near decision-boundary extreme.'; | |
| } else if (totalUnc < 0.30) { | |
| uNote.textContent = 'Moderate uncertainty - clinical review recommended.'; | |
| } else { | |
| uNote.textContent = 'High uncertainty - radiologist correlation required.'; | |
| } | |
| } | |
| // Robustness (boundary distance) in [0,1] -> percent | |
| const robPct = results.robustness == null ? null : results.robustness * 100; | |
| const rValEl = document.getElementById('robustnessValue'); | |
| if (rValEl) rValEl.textContent = robPct == null ? 'N/A' : `${robPct.toFixed(0)}%`; | |
| const rGauge = document.getElementById('robustnessGauge'); | |
| if (rGauge) { | |
| rGauge.style.background = robPct == null | |
| ? 'conic-gradient(var(--gray-200) 0deg, var(--gray-200) 360deg)' | |
| : `conic-gradient(var(--success) 0deg, var(--success) ${robPct * 3.6}deg, var(--gray-200) ${robPct * 3.6}deg)`; | |
| } | |
| const rNote = document.getElementById('robustnessNote'); | |
| if (rNote) { | |
| if (robPct == null) { | |
| rNote.textContent = 'Need >=2 classifier outputs to compute robustness.'; | |
| } else if (robPct >= 90) { | |
| rNote.textContent = 'Excellent robustness - prediction far from decision boundary.'; | |
| } else if (robPct >= 60) { | |
| rNote.textContent = 'Good robustness.'; | |
| } else { | |
| rNote.textContent = 'Moderate robustness - prediction is close to the decision boundary.'; | |
| } | |
| } | |
| // --- Inference telemetry + cascade decision ---------------------- | |
| const segResult = this.currentSegmentation; | |
| const anyRuntime = (results.models.find(m => m.runtime) || {}).runtime | |
| || (segResult && segResult.runtime) || '--'; | |
| setT('telemRuntime', anyRuntime); | |
| setT('telemTotal', `${results.processingTime}s`); | |
| if (segResult) { | |
| const cascade = segResult.cascade || {}; | |
| setT('telemSegModel', cascade.used || segResult.source_dir || '--'); | |
| setT('telemSegReason', cascade.reason || 'n/a'); | |
| setT('telemSegArea', (segResult.tumor_area_px != null) ? `${segResult.tumor_area_px} px` : '--'); | |
| setT('telemSegMeanProb', (segResult.mean_prob_in_mask != null) ? segResult.mean_prob_in_mask.toFixed(3) : '--'); | |
| } else { | |
| ['telemSegModel', 'telemSegReason', 'telemSegArea', 'telemSegMeanProb'] | |
| .forEach(id => setT(id, '--')); | |
| } | |
| this.renderMedsamRefiner(segResult && segResult.medsam_refiner); | |
| this.renderConformalCounterfactual(segResult && segResult.conformal_counterfactual); | |
| // --- Visualizations ---------------------------------------------- | |
| if (this.imageDataUrl) document.getElementById('vizImage').src = this.imageDataUrl; | |
| this.setHeatmapFromBackend(results.bestModel); | |
| // Mask suppression gate. v5 (joint-trained on positives + healthy brains) | |
| // mostly handles FP discipline at the segmenter level (0.13% FP rate on | |
| // healthy validation scans). The classifier consensus is a secondary | |
| // safety net for the rare residual FP voxels. So: | |
| // - segmenter mask EMPTY + classifiers say no-tumor => confirmed no-tumor, | |
| // show a SUCCESS banner, not a warning. v5 did its job. | |
| // - segmenter mask NON-EMPTY + classifiers say no-tumor => v5 produced | |
| // residual FP voxels; suppress the overlay and explain. | |
| // - segmenter mask NON-EMPTY + classifiers say tumor => normal path, | |
| // no banner, show overlay. | |
| // - segmenter mask EMPTY + classifiers say tumor => disagreement; | |
| // show a "models disagree" warning so the radiologist re-reviews. | |
| const maskImg = document.getElementById('maskImage'); | |
| const segoverlayImg = document.getElementById('segoverlayImage'); | |
| const verdict = results.consensus && results.consensus.verdict; | |
| const verdictBand = results.consensus && results.consensus.band; | |
| const tumorAreaPx = segResult && Number(segResult.tumor_area_px || 0); | |
| const segIsEmpty = tumorAreaPx < 16; // matches the MedSAM min_coarse_pixels | |
| const classifiersSayNoTumor = (verdict === 'no_tumor' && (verdictBand === 'high' || verdictBand === 'moderate')); | |
| const classifiersSayTumor = (verdict === 'tumor' && (verdictBand === 'high' || verdictBand === 'moderate')); | |
| const meanP = (results.consensus && typeof results.consensus.mean === 'number') ? results.consensus.mean.toFixed(3) : '--'; | |
| const segName = (segResult && (segResult.cascade && segResult.cascade.used)) || (segResult && segResult.source_dir) || 'segmenter'; | |
| let suppress = false; | |
| let bannerKind = null; // 'success' | 'warn-fp' | 'warn-disagree' | null | |
| let bannerText = null; | |
| if (segIsEmpty && classifiersSayNoTumor) { | |
| bannerKind = 'success'; | |
| bannerText = `Confirmed no-tumor: ${segName} (joint-trained on positives + healthy brains) produced an empty mask, and all 3 classifiers agree (mean p=${meanP}, ${verdictBand} confidence). No suppression needed.`; | |
| } else if (!segIsEmpty && classifiersSayNoTumor) { | |
| suppress = true; | |
| bannerKind = 'warn-fp'; | |
| bannerText = `Suppressed: ${segName} produced ${tumorAreaPx} px of residual mask, but classifier consensus is no-tumor (mean p=${meanP}, ${verdictBand} confidence). v5/v7 joint training reduced segmenter FP rate to ~0.13%, but rare residual false positives still get gated here.`; | |
| } else if (segIsEmpty && classifiersSayTumor) { | |
| bannerKind = 'warn-disagree'; | |
| bannerText = `Model disagreement: classifiers say tumor (mean p=${meanP}, ${verdictBand} confidence) but ${segName} produced an empty mask. Recommend manual review.`; | |
| } | |
| this._maskSuppressed = suppress; | |
| this._maskSuppressedReason = bannerText; | |
| this._maskSuppressedKind = bannerKind; | |
| if (segResult && maskImg && segoverlayImg) { | |
| if (segResult.mask) maskImg.src = segResult.mask; | |
| if (suppress && this.imageDataUrl) { | |
| segoverlayImg.src = this.imageDataUrl; | |
| } else if (segResult.overlay) { | |
| segoverlayImg.src = segResult.overlay; | |
| } | |
| } else if (maskImg && segoverlayImg) { | |
| maskImg.src = ''; | |
| segoverlayImg.src = ''; | |
| } | |
| // Coarse v5 mask/overlay (pre-MedSAM) and bbox-prompt visualization. | |
| // segResult.coarse_mask / coarse_overlay are present only when MedSAM | |
| // refined a non-empty mask. segResult.medsam_refiner.bbox_overlay is | |
| // present whenever MedSAM ran with a valid bbox. | |
| const coarseMaskImg = document.getElementById('coarseMaskImage'); | |
| const coarseOverlayImg = document.getElementById('coarseOverlayImage'); | |
| const bboxPromptImg = document.getElementById('bboxPromptImage'); | |
| if (coarseMaskImg) coarseMaskImg.src = (segResult && segResult.coarse_mask) || (segResult && segResult.mask) || ''; | |
| if (coarseOverlayImg) coarseOverlayImg.src = (segResult && segResult.coarse_overlay) || (segResult && segResult.overlay) || ''; | |
| if (bboxPromptImg) { | |
| const bbox = segResult && segResult.medsam_refiner && segResult.medsam_refiner.bbox_overlay; | |
| bboxPromptImg.src = bbox || (this.imageDataUrl || ''); | |
| } | |
| // Show results section | |
| this.showSection('results'); | |
| } | |
| setHeatmapFromBackend(bestModel) { | |
| // Real Grad-CAM data URL returned by /predict for cnn/transfer. The | |
| // hybrid ViT and the Spaces ONNX deploy both return null (no autograd | |
| // graph available). When null we show a true "unavailable" placeholder | |
| // instead of repeating the raw MRI, which previously was confusing. | |
| const heatmapImg = document.getElementById('heatmapImage'); | |
| const overlayImg = document.getElementById('overlayImage'); | |
| const placeholder = document.getElementById('vizPlaceholder'); | |
| if (bestModel && bestModel.gradcam) { | |
| // Distinct images per tab. gradcam_heatmap is the pure colormap | |
| // (no MRI blended in) - shown on the "Grad-CAM" tab. gradcam is | |
| // the heatmap-blended-with-MRI - shown on the "Grad-CAM Overlay" | |
| // tab. Falling back to the overlay if the backend didn't split | |
| // (e.g. legacy TF .h5 path). | |
| heatmapImg.src = bestModel.gradcam_heatmap || bestModel.gradcam; | |
| overlayImg.src = bestModel.gradcam; | |
| heatmapImg.dataset.available = 'true'; | |
| overlayImg.dataset.available = 'true'; | |
| } else { | |
| // Clear the src and store an availability flag the tab-click | |
| // handler reads to swap in the placeholder. | |
| heatmapImg.src = ''; | |
| overlayImg.src = ''; | |
| heatmapImg.dataset.available = 'false'; | |
| overlayImg.dataset.available = 'false'; | |
| this._gradcamUnavailableReason = (bestModel && bestModel.runtime === 'onnx') | |
| ? 'Grad-CAM requires the PyTorch autograd graph and is not available in the ONNX-only deploy (this Space). Run the local dashboard with .pt weights to view Grad-CAM overlays.' | |
| : 'Grad-CAM unavailable for this model.'; | |
| if (placeholder) { | |
| placeholder.textContent = this._gradcamUnavailableReason; | |
| } | |
| } | |
| } | |
| renderAiInsightMaps(insights) { | |
| // Populates the "AI Insight Maps" panel (added 2026-06-03d). | |
| // `insights` shape (from /segment response.model_insights): | |
| // { available_signals: [...], maps: { v9c: {overlay, fired_pct}, ... }, | |
| // agreement_overlay: 'data:image/png;...', n_signals: N } | |
| const panel = document.getElementById('aiInsightPanel'); | |
| if (!panel) return; | |
| if (!insights || insights.available === false || !insights.maps) { | |
| panel.style.display = 'none'; | |
| return; | |
| } | |
| panel.style.display = ''; | |
| // AI Agreement headline visual | |
| const agreeImg = document.getElementById('agreementMapImage'); | |
| const agreeImgOriginal = document.getElementById('agreementMapImageOriginal'); | |
| const agreeCard = document.getElementById('agreementMapCard'); | |
| if (insights.agreement_overlay) { | |
| if (agreeImg) agreeImg.src = insights.agreement_overlay; | |
| if (agreeImgOriginal) agreeImgOriginal.src = this.imageDataUrl; | |
| if (agreeCard) agreeCard.style.display = ''; | |
| } else if (agreeCard) { | |
| agreeCard.style.display = 'none'; | |
| } | |
| // Per-detector heatmaps | |
| let anyMissing = false; | |
| ['v9c', 'andi', 'symmetry'].forEach(sig => { | |
| const card = panel.querySelector(`.insight-card[data-signal="${sig}"]`); | |
| const img = document.getElementById(`insightImage-${sig}`); | |
| const pct = document.getElementById(`insight-${sig}-pct`); | |
| const data = insights.maps[sig]; | |
| if (data && data.overlay) { | |
| if (card) card.style.display = ''; | |
| if (img) img.src = data.overlay; | |
| if (pct) { | |
| pct.textContent = `${data.fired_pct}% flagged`; | |
| pct.style.color = data.fired_pct > 5 ? '#dc2626' : '#64748b'; | |
| } | |
| } else { | |
| if (card) card.style.display = 'none'; | |
| anyMissing = true; | |
| } | |
| }); | |
| const note = document.getElementById('insightUnavailableNote'); | |
| if (note) note.style.display = anyMissing ? 'block' : 'none'; | |
| } | |
| renderEnsembleSignalsPanel(advisory) { | |
| // Populates the "Four-Signal Ensemble Verdict" panel added 2026-06-03b. | |
| // Hides the panel if the advisory isn't attached (older Space build | |
| // or wire-up failure). | |
| const card = document.getElementById('ensembleSignalsCard'); | |
| if (!card) return; | |
| if (!advisory || advisory.enabled === false) { | |
| card.style.display = 'none'; | |
| return; | |
| } | |
| card.style.display = 'block'; | |
| const setT = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; | |
| // Decision rule: layperson-friendly text in the visible label, | |
| // technical Boolean rule in the hover title for researchers. | |
| const ruleEl = document.getElementById('ensembleRule'); | |
| if (ruleEl) { | |
| ruleEl.textContent = advisory.rule || '--'; | |
| if (advisory.rule_technical) { | |
| ruleEl.title = `Technical rule: ${advisory.rule_technical}`; | |
| } | |
| } | |
| // Mode: prefer the layperson display_name ("Balanced") over the | |
| // internal slug ("balanced") when available. | |
| setT('ensembleOp', advisory.operating_point_display || advisory.operating_point || '--'); | |
| const m = advisory.measured_performance || {}; | |
| const pctFmt = (v) => (v == null ? '--' : `${v}%`); | |
| const scoreFmt = (v) => (v == null ? '--' : Number(v).toFixed(2)); | |
| // Layperson metric labels: "% of tumors caught", "% of healthy | |
| // scans wrongly flagged", "overall accuracy". | |
| setT('ensembleMeasured', | |
| `On our test set: ${pctFmt(m.tumors_caught_pct)} of tumors caught, ` | |
| + `${pctFmt(m.healthy_wrongly_flagged_pct)} of healthy scans wrongly flagged, ` | |
| + `accuracy ${scoreFmt(m.overall_accuracy_score)}`); | |
| const reviewBadge = document.getElementById('reviewBadge'); | |
| if (reviewBadge) { | |
| reviewBadge.style.display = advisory.review_recommended ? 'block' : 'none'; | |
| } | |
| const setSig = (sigKey, fired, value, threshold, fmt) => { | |
| const stateEl = document.getElementById(`sig-${sigKey}-state`); | |
| const valEl = document.getElementById(`sig-${sigKey}-val`); | |
| const thrEl = document.getElementById(`sig-${sigKey}-thresh`); | |
| if (stateEl) { | |
| if (fired === true) { | |
| // Layperson: "Flagged this scan" instead of "FIRED" | |
| stateEl.textContent = 'Flagged'; | |
| stateEl.style.background = '#d1fae5'; | |
| stateEl.style.color = '#065f46'; | |
| } else if (fired === false) { | |
| stateEl.textContent = 'Did not flag'; | |
| stateEl.style.background = '#e5e7eb'; | |
| stateEl.style.color = '#475569'; | |
| } else { | |
| stateEl.textContent = 'Not active'; | |
| stateEl.style.background = '#f3f4f6'; | |
| stateEl.style.color = '#94a3b8'; | |
| } | |
| } | |
| if (valEl) valEl.textContent = value == null ? '--' : fmt(value); | |
| if (thrEl) thrEl.textContent = threshold == null ? '--' : fmt(threshold); | |
| }; | |
| const f3 = v => Number(v).toFixed(3); | |
| const fSci = v => Number(v).toExponential(2); | |
| const fInt = v => String(Math.round(Number(v))); | |
| setSig('v9c', advisory.v9c_fired, advisory.v9c_p95, advisory.v9c_threshold, f3); | |
| setSig('andi', advisory.andi_fired, advisory.andi_max, advisory.andi_threshold, fSci); | |
| setSig('v8', advisory.v8_fired, advisory.v8_area_px, advisory.v8_area_threshold, fInt); | |
| setSig('sym', advisory.symmetry_fired, advisory.symmetry_p95, advisory.symmetry_threshold, f3); | |
| } | |
| renderMedsamRefiner(ms) { | |
| const panel = document.getElementById('medsamPanel'); | |
| if (!panel) return; | |
| const setT = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; | |
| if (!ms) { | |
| panel.style.display = 'none'; | |
| return; | |
| } | |
| panel.style.display = ''; | |
| if (!ms.available) { | |
| setT('medsamStatus', `not available (${ms.reason || 'unknown reason'})`); | |
| setT('medsamCoarse', '--'); setT('medsamRefined', '--'); | |
| setT('medsamDelta', '--'); setT('medsamIou', '--'); setT('medsamMs', '--'); | |
| return; | |
| } | |
| if (ms.skipped_reason) { | |
| // Translate common technical skip reasons to plain language. | |
| const friendlyReason = ( | |
| ms.skipped_reason === 'empty_coarse_mask' | |
| ? 'no initial tumor detected, nothing to refine' | |
| : ms.skipped_reason === 'no_mask_to_refine' | |
| ? 'no initial mask was provided' | |
| : ms.skipped_reason | |
| ); | |
| setT('medsamStatus', `Skipped — ${friendlyReason}`); | |
| } else { | |
| setT('medsamStatus', 'Active'); | |
| } | |
| setT('medsamCoarse', (ms.coarse_area_px != null) ? `${ms.coarse_area_px} px` : '--'); | |
| setT('medsamRefined', (ms.refined_area_px != null) ? `${ms.refined_area_px} px` : '--'); | |
| const delta = ms.delta_area_px; | |
| setT('medsamDelta', (delta != null) ? `${delta > 0 ? '+' : ''}${delta} px` : '--'); | |
| setT('medsamIou', (ms.iou_score != null) ? ms.iou_score.toFixed(3) : '--'); | |
| setT('medsamMs', (ms.elapsed_ms != null) ? `${ms.elapsed_ms.toFixed(0)} ms` : '--'); | |
| } | |
| renderConformalCounterfactual(cf) { | |
| // cf may be null (no calibration artifacts), undefined (no segment | |
| // result yet), or the analyze() dict from src/research/dashboard_integration.py. | |
| const hero = document.getElementById('conformalCfHero'); | |
| const hint = document.getElementById('conformalCfMissingHint'); | |
| if (!hero) return; | |
| if (!cf || !cf.available || !Array.isArray(cf.interventions) || cf.interventions.length === 0) { | |
| hero.style.display = 'none'; | |
| // Show the "pending artifacts" hint so the user knows the panel | |
| // is real and will populate as soon as artifacts download. | |
| if (hint) hint.style.display = ''; | |
| return; | |
| } | |
| hero.style.display = ''; | |
| if (hint) hint.style.display = 'none'; | |
| const setT = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; | |
| const methodEl = document.getElementById('conformalCfMethod'); | |
| if (methodEl && cf._method) methodEl.textContent = cf._method; | |
| const firstAlpha = cf.interventions[0] && cf.interventions[0].alpha; | |
| setT('conformalCfCoverage', | |
| firstAlpha != null ? `${(100 * (1 - firstAlpha)).toFixed(0)}% (α = ${firstAlpha.toFixed(2)})` : '--'); | |
| setT('conformalCfNiv', String(cf.n_interventions)); | |
| const sum = cf.summary || {}; | |
| const labelFor = (slug) => { | |
| const row = cf.interventions.find(r => r.slug === slug); | |
| return row ? row.label : (slug || '--'); | |
| }; | |
| setT('conformalCfMaxDis', | |
| sum.max_disagree_intervention | |
| ? `${labelFor(sum.max_disagree_intervention)} (${(100 * (sum.max_disagree_fraction || 0)).toFixed(2)}%)` | |
| : '--'); | |
| setT('conformalCfMostRobust', sum.most_robust_intervention ? labelFor(sum.most_robust_intervention) : '--'); | |
| const tbody = document.getElementById('conformalCfTbody'); | |
| if (tbody) { | |
| tbody.innerHTML = ''; | |
| cf.interventions.forEach(row => { | |
| const tr = document.createElement('tr'); | |
| const cells = [ | |
| row.label, | |
| (row.q != null) ? row.q.toFixed(3) : '--', | |
| (row.abstain_fraction != null) ? (100 * row.abstain_fraction).toFixed(2) + '%' : '--', | |
| (row.certified_disagree_fraction != null) ? (100 * row.certified_disagree_fraction).toFixed(2) + '%' : '--', | |
| (row.intervention_cf_area_px != null) ? String(row.intervention_cf_area_px) : '--', | |
| ]; | |
| cells.forEach((c, i) => { | |
| const td = document.createElement('td'); | |
| td.textContent = c; | |
| td.style.padding = '4px 8px'; | |
| td.style.borderBottom = '1px solid rgba(255,255,255,0.05)'; | |
| if (i > 0) td.style.textAlign = 'right'; | |
| tr.appendChild(td); | |
| }); | |
| tbody.appendChild(tr); | |
| }); | |
| } | |
| } | |
| async runSegmentation() { | |
| if (!this.currentFile) { | |
| alert('Upload an MRI image first.'); | |
| return; | |
| } | |
| const thresholdInput = document.getElementById('thresholdSlider'); | |
| const thresholdValue = thresholdInput ? (parseInt(thresholdInput.value, 10) / 100) : 0.5; | |
| this.setSegmentationPanelLoading(); | |
| try { | |
| const segModalitySel = document.getElementById('segModelSelect'); | |
| const segModality = segModalitySel ? segModalitySel.value : ''; | |
| const payload = await this.callSegment(this.currentFile, thresholdValue, segModality); | |
| this.currentSegmentation = { result: payload, error: null }; | |
| this.renderSegmentationFromCache(); | |
| } catch (err) { | |
| this.currentSegmentation = { result: null, error: err.message || String(err) }; | |
| this.renderSegmentationFromCache(); | |
| console.error(err); | |
| } | |
| } | |
| setSegmentationPanelLoading() { | |
| const segOriginal = document.getElementById('segOriginal'); | |
| const segMask = document.getElementById('segMask'); | |
| const segOverlay = document.getElementById('segOverlay'); | |
| if (segOriginal && this.imageDataUrl) { | |
| segOriginal.innerHTML = `<img src="${this.imageDataUrl}" style="width:100%;height:100%;object-fit:contain;border-radius:12px;">`; | |
| } | |
| if (segMask) segMask.innerHTML = '<span style="opacity:0.6;">Running U-Net...</span>'; | |
| if (segOverlay) segOverlay.innerHTML = '<span style="opacity:0.6;">Running U-Net...</span>'; | |
| const dice = document.getElementById('diceScore'); | |
| const iou = document.getElementById('iouScore'); | |
| const area = document.getElementById('tumorArea'); | |
| if (dice) dice.textContent = '...'; | |
| if (iou) iou.textContent = '...'; | |
| if (area) area.textContent = '...'; | |
| } | |
| renderSegmentationFromCache() { | |
| const segOriginal = document.getElementById('segOriginal'); | |
| const segMask = document.getElementById('segMask'); | |
| const segOverlay = document.getElementById('segOverlay'); | |
| const dice = document.getElementById('diceScore'); | |
| const iou = document.getElementById('iouScore'); | |
| const area = document.getElementById('tumorArea'); | |
| if (!segMask) return; | |
| if (segOriginal && this.imageDataUrl) { | |
| segOriginal.innerHTML = `<img src="${this.imageDataUrl}" style="width:100%;height:100%;object-fit:contain;border-radius:12px;">`; | |
| } | |
| if (!this.currentSegmentation) { | |
| segMask.innerHTML = '<span style="opacity:0.6;">Upload an image and click "Run Analysis" to see the U-Net mask.</span>'; | |
| segOverlay.innerHTML = ''; | |
| return; | |
| } | |
| const seg = this.currentSegmentation; | |
| if (seg.error) { | |
| segMask.innerHTML = `<span style="color:#ef4444;">Error: ${seg.error}</span>`; | |
| segOverlay.innerHTML = ''; | |
| if (dice) dice.textContent = '--'; | |
| if (iou) iou.textContent = '--'; | |
| if (area) area.textContent = '--'; | |
| return; | |
| } | |
| const payload = seg.result || {}; | |
| if (payload.mask) { | |
| segMask.innerHTML = `<img src="${payload.mask}" style="width:100%;height:100%;object-fit:contain;border-radius:12px;background:black;">`; | |
| } | |
| if (payload.overlay) { | |
| segOverlay.innerHTML = `<img src="${payload.overlay}" style="width:100%;height:100%;object-fit:contain;border-radius:12px;">`; | |
| } | |
| if (dice) dice.textContent = (payload.dice == null) ? 'N/A' : Number(payload.dice).toFixed(3); | |
| if (iou) iou.textContent = (payload.iou == null) ? 'N/A' : Number(payload.iou).toFixed(3); | |
| if (area) area.textContent = (payload.tumor_area_px == null) ? 'N/A' : `${payload.tumor_area_px} px`; | |
| // Cascade info: which checkpoint actually fired + why. | |
| const usedEl = document.getElementById('segUsedModel'); | |
| const reasonEl = document.getElementById('segCascadeReason'); | |
| const cascade = payload.cascade; | |
| if (usedEl) { | |
| const used = (cascade && cascade.used) || payload.source_dir || '--'; | |
| // Make the label shorter and friendlier. | |
| const friendly = { | |
| 'attention_unet_v3': 'v3 (multi-modal)', | |
| 'attention_unet_v2': 'v2', | |
| 'attention_unet_t1c': 'T1c specialist', | |
| 'attention_unet_lgg': 'LGG', | |
| 'attention_unet': 'baseline', | |
| }; | |
| usedEl.textContent = friendly[used] || used; | |
| } | |
| if (reasonEl) { | |
| if (cascade && cascade.reason) { | |
| const reasonLabel = { | |
| 'v3_sufficient': 'v3 found enough tumor; no cascade', | |
| 'specialist_unavailable': 'T1c specialist checkpoint missing', | |
| 'explicit_modality_request': 'user picked this model', | |
| }[cascade.reason] || cascade.reason; | |
| reasonEl.textContent = reasonLabel; | |
| } else { | |
| reasonEl.textContent = ''; | |
| } | |
| } | |
| } | |
| async callExplain(file, threshold, modality, backend) { | |
| const form = new FormData(); | |
| form.append('image', file, file.name || 'upload.png'); | |
| form.append('threshold', String(threshold)); | |
| if (modality) form.append('modality', modality); | |
| if (backend) form.append('backend', backend); | |
| const resp = await fetch('/explain', { method: 'POST', body: form }); | |
| if (!resp.ok) { | |
| throw new Error(`/explain returned ${resp.status}`); | |
| } | |
| const payload = await resp.json(); | |
| if (!payload || payload.success === false) { | |
| throw new Error((payload && payload.error) || '/explain failed'); | |
| } | |
| return payload; | |
| } | |
| /** | |
| * Generate Report flow on the Results page. Calls /explain (which runs | |
| * the cascade segmentation + 3 classifiers + feature extraction + the | |
| * 3-pattern LLM pipeline), then renders the full explanation panel | |
| * inline inside #reportContent. | |
| */ | |
| /** | |
| * Batch upload: process N files sequentially through the same /predict | |
| * + /segment pipeline used by Run Analysis, then render a comparison | |
| * table on the Upload section. Each row is clickable to deep-link into | |
| * the full Results view for that file. The selected backend / threshold | |
| * / model from the Upload form are honored for the whole batch. | |
| */ | |
| async runBatchAnalysis(files) { | |
| if (!this._batchResults) this._batchResults = []; | |
| const panel = document.getElementById('batchPanel'); | |
| const progressWrap = document.getElementById('batchProgressWrap'); | |
| const progressFill = document.getElementById('batchProgressFill'); | |
| const progressText = document.getElementById('batchProgressText'); | |
| const tbody = document.getElementById('batchTableBody'); | |
| const subtitle = document.getElementById('batchSubtitle'); | |
| if (panel) panel.style.display = 'block'; | |
| if (progressWrap) progressWrap.style.display = 'block'; | |
| if (subtitle) subtitle.textContent = `${files.length} file${files.length === 1 ? '' : 's'} queued ...`; | |
| // Read upload form choices once so the whole batch uses the same setup. | |
| const modelSelect = document.getElementById('modelSelect'); | |
| const modelChoice = modelSelect ? (modelSelect.value || 'all') : 'all'; | |
| const segModalitySel = document.getElementById('segModelSelect'); | |
| const segModality = segModalitySel ? segModalitySel.value : ''; | |
| const thresholdInput = document.getElementById('thresholdSlider'); | |
| const threshold = thresholdInput ? (parseInt(thresholdInput.value, 10) / 100) : 0.5; | |
| // Sequential processing keeps the small server stable. Cheap rows | |
| // (CPU-bound /predict on CNN classifier alone) finish in ~50 ms; a | |
| // full /predict 'all' + /segment is ~1-1.5 s. With N=8 the batch | |
| // completes in ~10 s. | |
| for (let i = 0; i < files.length; i++) { | |
| const file = files[i]; | |
| if (progressText) progressText.textContent = `Processing ${i + 1} / ${files.length} - ${file.name}`; | |
| if (progressFill) progressFill.style.width = `${((i) / files.length) * 100}%`; | |
| const tStart = performance.now(); | |
| try { | |
| const formData = new FormData(); | |
| formData.append('image', file); | |
| const response = await fetch('/explain', { method: 'POST', body: formData }); | |
| const explainData = await response.json(); | |
| const predictions = []; | |
| const seg = { result: explainData, error: null }; | |
| let verdict = explainData.verdict || 'mixed'; | |
| let mean = parseFloat(explainData.confidence) / 100.0; | |
| if (isNaN(mean)) mean = 0.5; | |
| let band = 'low'; | |
| if (verdict === 'tumor') band = mean >= 0.9 ? 'high' : 'moderate'; | |
| if (verdict === 'no_tumor') band = mean >= 0.9 ? 'high' : 'moderate'; | |
| const std = 0; | |
| const entropy = 0; | |
| const best = { model: 'ensemble' }; | |
| const elapsed = (performance.now() - tStart) / 1000; | |
| const scanId = `BATCH-${Date.now()}-${i}`; | |
| // Read the file into a data URL once so the Results-page | |
| // preview can show the original MRI when the user drills in. | |
| let imageDataUrl = null; | |
| try { | |
| imageDataUrl = await new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => resolve(reader.result); | |
| reader.onerror = () => reject(reader.error); | |
| reader.readAsDataURL(file); | |
| }); | |
| } catch (_) { /* ignore - preview just won't show */ } | |
| const entry = { | |
| id: scanId, filename: file.name, | |
| // Retain the File object so downstream actions on the | |
| // Results page (Generate Report, Print, re-segment with | |
| // a different threshold) can re-POST the bytes to the | |
| // server. Without this, generateReport() bails because | |
| // this.currentFile was never set. | |
| file: file, | |
| imageDataUrl, | |
| predictions, segmentation: seg, | |
| mean, std, entropy, verdict, band, | |
| bestModel: best ? best.model : '--', | |
| elapsedSeconds: elapsed.toFixed(2), | |
| timestamp: Date.now(), | |
| }; | |
| this._batchResults.push(entry); | |
| this.renderBatchRow(tbody, entry); | |
| // Also push to Recent Scans sidebar so it's discoverable. | |
| this.addRecentScan({ | |
| id: scanId, | |
| isPositive: verdict === 'tumor', | |
| confidence: mean || 0, | |
| timestamp: Date.now(), | |
| }); | |
| } catch (err) { | |
| console.error('Batch entry failed:', err); | |
| } | |
| if (progressFill) progressFill.style.width = `${((i + 1) / files.length) * 100}%`; | |
| } | |
| if (progressText) progressText.textContent = `Done. ${this._batchResults.length} total in batch.`; | |
| if (subtitle) { | |
| const tumorCount = this._batchResults.filter(e => e.verdict === 'tumor').length; | |
| const noTumorCount = this._batchResults.filter(e => e.verdict === 'no_tumor').length; | |
| const mixedCount = this._batchResults.length - tumorCount - noTumorCount; | |
| subtitle.textContent = `${this._batchResults.length} scans: ${tumorCount} tumor, ${noTumorCount} no-tumor, ${mixedCount} ambiguous.`; | |
| } | |
| } | |
| renderBatchRow(tbody, e) { | |
| if (!tbody) return; | |
| const idx = this._batchResults.length; | |
| const row = document.createElement('tr'); | |
| row.dataset.batchId = e.id; | |
| const verdictBadge = `<span class="batch-verdict ${e.verdict}">${this.escapeHtml(e.verdict)}</span>`; | |
| const meanStr = e.mean == null ? '--' : e.mean.toFixed(3); | |
| const stdStr = e.std == null ? '--' : e.std.toFixed(3); | |
| const entStr = e.entropy == null ? '--' : e.entropy.toFixed(3); | |
| row.innerHTML = ` | |
| <td>${idx}</td> | |
| <td title="${this.escapeHtml(e.filename)}">${this.escapeHtml(e.filename.length > 36 ? e.filename.slice(0, 33) + '...' : e.filename)}</td> | |
| <td><span class="batch-diag ${e.verdict === 'tumor' ? 'positive' : (e.verdict === 'no_tumor' ? 'negative' : 'mixed')}">${e.verdict === 'tumor' ? 'Tumor' : (e.verdict === 'no_tumor' ? 'No Tumor' : 'Mixed')}</span></td> | |
| <td>${this.escapeHtml(this.getModelLabel(e.bestModel))}</td> | |
| <td>${meanStr}</td> | |
| <td>${stdStr}</td> | |
| <td>${entStr}</td> | |
| <td>${verdictBadge} <span class="batch-band">${this.escapeHtml(e.band || '')}</span></td> | |
| <td>${e.elapsedSeconds}s</td> | |
| <td><button class="btn btn-small btn-outline" data-batch-view="${e.id}">View</button></td> | |
| `; | |
| const viewBtn = row.querySelector('[data-batch-view]'); | |
| if (viewBtn) viewBtn.addEventListener('click', () => this.viewBatchEntry(e.id)); | |
| tbody.appendChild(row); | |
| } | |
| viewBatchEntry(id) { | |
| const entry = (this._batchResults || []).find(e => e.id === id); | |
| if (!entry) return; | |
| // Rebuild the displayResults-compatible payload from the batch entry. | |
| const meanProb = entry.mean; | |
| const labelMap = { cnn: 'CNN (Fast)', transfer: 'Transfer Learning', vit: 'Vision Transformer' }; | |
| const modelResults = entry.predictions.filter(p => p.result).map(p => ({ | |
| model: p.model, | |
| modelLabel: labelMap[p.model] || p.model, | |
| prediction: p.result.display_label || (p.result.label === 'tumor' ? 'Tumor' : 'No Tumor'), | |
| confidence: p.result.confidence, | |
| accuracy: null, auc: null, | |
| isPositive: p.result.label === 'tumor', | |
| status: p.result.label === 'tumor' ? 'positive' : 'negative', | |
| gradcam: p.result.gradcam || null, | |
| gradcam_heatmap: p.result.gradcam_heatmap || null, | |
| image: p.result.image || null, | |
| probability: p.result.probability, | |
| runtime: p.result.runtime || null, | |
| })); | |
| const bestModel = modelResults.length | |
| ? modelResults.reduce((a, b) => (b.confidence > a.confidence ? b : a)) : null; | |
| const robustness = meanProb == null ? null : (Math.max(meanProb, 1 - meanProb) - 0.5) * 2; | |
| this.currentResults = { | |
| patientId: entry.id, | |
| timestamp: new Date(entry.timestamp).toLocaleString(), | |
| models: modelResults, | |
| bestModel, | |
| diagnosis: entry.verdict === 'tumor' ? 'Tumor Detected' : 'No Tumor Detected', | |
| isPositive: entry.verdict === 'tumor', | |
| confidence: bestModel ? bestModel.confidence : 0, | |
| processingTime: entry.elapsedSeconds, | |
| consensus: { verdict: entry.verdict, mean: meanProb, band: entry.band }, | |
| uncertainty: { epistemic: entry.std, aleatoric: entry.entropy }, | |
| robustness, | |
| }; | |
| this.currentSegmentation = entry.segmentation || { result: null, error: null }; | |
| // Restore the File object + data URL we captured at batch time so | |
| // downstream actions on the Results page (Generate Report, Print, | |
| // re-segment) can re-POST the bytes to /explain / /segment without | |
| // the user having to re-upload. | |
| this.currentFile = entry.file || null; | |
| this.imageDataUrl = entry.imageDataUrl | |
| || entry.predictions.find(p => p.result && p.result.image)?.result?.image | |
| || null; | |
| this.currentExplanation = null; // not generated yet for this batch entry | |
| this.displayResults(); | |
| } | |
| clearBatch() { | |
| this._batchResults = []; | |
| const panel = document.getElementById('batchPanel'); | |
| if (panel) panel.style.display = 'none'; | |
| const tbody = document.getElementById('batchTableBody'); | |
| if (tbody) tbody.innerHTML = ''; | |
| } | |
| exportBatchCsv() { | |
| const rows = this._batchResults || []; | |
| if (!rows.length) { | |
| this.showToast('Nothing to export', 'Run a batch upload first.', 'error'); | |
| return; | |
| } | |
| const header = ['index', 'filename', 'verdict', 'band', 'mean_probability', | |
| 'std_probability', 'entropy', 'best_model', | |
| 'cnn_p', 'transfer_p', 'vit_p', 'elapsed_seconds', 'timestamp']; | |
| const lines = [header.join(',')]; | |
| rows.forEach((e, i) => { | |
| const get = (m) => { | |
| const p = e.predictions.find(x => x.model === m); | |
| return (p && p.result && typeof p.result.probability === 'number') | |
| ? p.result.probability.toFixed(4) : ''; | |
| }; | |
| const cells = [ | |
| i + 1, | |
| `"${(e.filename || '').replace(/"/g, '""')}"`, | |
| e.verdict || '', | |
| e.band || '', | |
| e.mean == null ? '' : e.mean.toFixed(4), | |
| e.std == null ? '' : e.std.toFixed(4), | |
| e.entropy == null ? '' : e.entropy.toFixed(4), | |
| e.bestModel || '', | |
| get('cnn'), get('transfer'), get('vit'), | |
| e.elapsedSeconds || '', | |
| new Date(e.timestamp).toISOString(), | |
| ]; | |
| lines.push(cells.join(',')); | |
| }); | |
| const blob = new Blob([lines.join('\n')], { type: 'text/csv' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `trinetra_batch_${Date.now()}.csv`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| this.showToast('CSV exported', `${rows.length} row${rows.length === 1 ? '' : 's'} saved.`, 'success'); | |
| } | |
| async generateReport() { | |
| if (!this.currentFile) { | |
| this.showToast('Upload an image first', 'Run Analysis on an MRI before requesting the report.', 'error'); | |
| return; | |
| } | |
| const placeholder = document.getElementById('reportPlaceholder'); | |
| const content = document.getElementById('reportContent'); | |
| const btn = document.getElementById('generateReportBtn'); | |
| if (btn) { btn.disabled = true; btn.textContent = 'Running...'; } | |
| // Build the rich panel skeleton inside reportContent. We literally | |
| // duplicate the explain panel markup so renderExplanation can target | |
| // the same element IDs as on the Segmentation tab. | |
| if (content) { | |
| content.style.display = 'block'; | |
| content.innerHTML = this._explainPanelMarkup(); | |
| } | |
| if (placeholder) placeholder.style.display = 'none'; | |
| const thresholdInput = document.getElementById('thresholdSlider'); | |
| const threshold = thresholdInput ? (parseInt(thresholdInput.value, 10) / 100) : 0.5; | |
| const backendSel = document.getElementById('reportBackendSelect'); | |
| const backend = backendSel ? backendSel.value : ''; | |
| try { | |
| const payload = await this.callExplain(this.currentFile, threshold, '', backend); | |
| if (payload.segmentation) { | |
| this.currentSegmentation = { result: payload.segmentation, error: null }; | |
| this.renderSegmentationFromCache(); | |
| } | |
| this.currentExplanation = payload.explanation || null; | |
| this.renderExplanation(payload); | |
| this.showToast('Report ready', `${(payload.explanation?.backend || 'deterministic')} backend completed.`, 'success'); | |
| } catch (err) { | |
| console.error(err); | |
| this.renderExplanationError(err.message || String(err)); | |
| this.showToast('Report failed', err.message || String(err), 'error'); | |
| } finally { | |
| if (btn) { btn.disabled = false; btn.textContent = 'Generate Report'; } | |
| } | |
| } | |
| /** Returns the same DOM IDs as #explainPanel so renderExplanation can target | |
| * them inside the Results-page report block. */ | |
| _explainPanelMarkup() { | |
| return ` | |
| <div class="explain-header"> | |
| <h3>Layered Pipeline Output</h3> | |
| <div class="explain-header-meta"> | |
| <span class="explain-backend" id="explainBackend">--</span> | |
| <span class="explain-safety-badge" id="explainSafetyBadge">--</span> | |
| </div> | |
| </div> | |
| <div class="explain-body"> | |
| <div class="explain-section explain-impression"> | |
| <h4>Impression</h4> | |
| <p id="explainImpression">--</p> | |
| </div> | |
| <div class="explain-section explain-confidence-card"> | |
| <h4>Overall Confidence</h4> | |
| <div class="confidence-row"> | |
| <div class="confidence-band" id="explainConfBand">--</div> | |
| <div class="confidence-score"> | |
| <div class="confidence-score-value" id="explainConfScore">--</div> | |
| <div class="confidence-score-bar"><div class="confidence-score-fill" id="explainConfFill"></div></div> | |
| </div> | |
| </div> | |
| <p id="explainConfidence" class="explain-confidence-detail">--</p> | |
| </div> | |
| <div class="explain-section explain-negative-explanation" id="explainNegativeSection" style="display:none;"> | |
| <h4>Why the Classifiers Ruled This Out</h4> | |
| <pre id="explainNegativeExplanation" class="explain-grade"></pre> | |
| </div> | |
| <div class="explain-section explain-vision-negative" id="explainVisionNegativeSection" style="display:none;"> | |
| <h4>Vision LLM Reasoning (Pattern D - negative-case)</h4> | |
| <p class="explain-subtle">The vision model was shown the original MRI and asked to describe what visible features support the no-tumor verdict. Output is validated to ensure it does not contradict the verdict.</p> | |
| <pre id="explainVisionNegativeText" class="explain-grade"></pre> | |
| </div> | |
| <div class="explain-section" id="explainFindingsSection"> | |
| <h4>Structured Findings</h4> | |
| <dl class="explain-findings" id="explainFindings"></dl> | |
| </div> | |
| <div class="explain-section" id="explainGradeSection"> | |
| <h4>Grade-Evidence Score</h4> | |
| <pre id="explainGradeEvidence" class="explain-grade">--</pre> | |
| </div> | |
| <details class="explain-raw" id="explainFpRegionSection" style="display:none;"> | |
| <summary>False-positive region analysis (debug)</summary> | |
| <dl class="explain-findings" id="explainFpRegionFindings"></dl> | |
| <pre id="explainFpRegionGrade" class="explain-grade"></pre> | |
| </details> | |
| <div class="explain-section"> | |
| <h4>Differential Diagnosis (citation-checked)</h4> | |
| <div id="explainDifferentialList" class="differential-list"></div> | |
| </div> | |
| <div class="explain-section" id="explainVisualSection" style="display:none;"> | |
| <h4>Visual Observations (LLM co-observer)</h4> | |
| <ul id="explainVisualObservations"></ul> | |
| </div> | |
| <div class="explain-section explain-disagreements" id="explainDisagreementsSection" style="display:none;"> | |
| <h4>Model Disagreements (flagged conflicts)</h4> | |
| <ul id="explainVisualDisagreements"></ul> | |
| </div> | |
| <div class="explain-section explain-recommendation"> | |
| <h4>Recommendation</h4> | |
| <p id="explainRecommendation">--</p> | |
| </div> | |
| <div class="explain-section"> | |
| <h4>Classifier Agreement</h4> | |
| <p id="explainAgreement">--</p> | |
| </div> | |
| <div class="explain-section explain-llm-passes"> | |
| <h4>LLM Pass Status</h4> | |
| <div id="explainLlmPasses" class="llm-passes-grid"></div> | |
| </div> | |
| <div class="explain-section explain-quality" id="explainQualitySection" style="display:none;"> | |
| <h4>Quality Warnings</h4> | |
| <ul id="explainQualityWarnings"></ul> | |
| </div> | |
| <div class="explain-section explain-disclaimer"> | |
| <h4>Disclaimer</h4> | |
| <p id="explainDisclaimer">Not a medical diagnosis. Research / educational only.</p> | |
| </div> | |
| <details class="explain-raw"> | |
| <summary>Polished summary (verified prose, may equal Impression if LLM polish rejected)</summary> | |
| <p id="explainSummary"></p> | |
| </details> | |
| <details class="explain-raw"> | |
| <summary>Raw deterministic features (JSON)</summary> | |
| <pre id="explainRaw"></pre> | |
| </details> | |
| </div> | |
| `; | |
| } | |
| async runExplanation() { | |
| if (!this.currentFile) { | |
| alert('Upload an MRI image first.'); | |
| return; | |
| } | |
| const panel = document.getElementById('explainPanel'); | |
| if (panel) panel.style.display = 'block'; | |
| this.setExplanationLoading(); | |
| const thresholdInput = document.getElementById('thresholdSlider'); | |
| const threshold = thresholdInput ? (parseInt(thresholdInput.value, 10) / 100) : 0.5; | |
| const segModalitySel = document.getElementById('segModelSelect'); | |
| const segModality = segModalitySel ? segModalitySel.value : ''; | |
| const backendSel = document.getElementById('explainBackendSelect'); | |
| const backend = backendSel ? backendSel.value : ''; | |
| try { | |
| const payload = await this.callExplain(this.currentFile, threshold, segModality, backend); | |
| // Also update the segmentation viewers since /explain reran segmentation. | |
| if (payload.segmentation) { | |
| this.currentSegmentation = { result: payload.segmentation, error: null }; | |
| this.renderSegmentationFromCache(); | |
| } | |
| // Persist for the Export Report download. | |
| this.currentExplanation = payload.explanation || null; | |
| this.renderExplanation(payload); | |
| } catch (err) { | |
| console.error(err); | |
| this.renderExplanationError(err.message || String(err)); | |
| } | |
| } | |
| setExplanationLoading() { | |
| const set = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text; }; | |
| set('explainBackend', 'running...'); | |
| set('explainSafetyBadge', ''); | |
| set('explainImpression', 'Calling LLM and extracting deterministic tumor features...'); | |
| set('explainSummary', '...'); | |
| set('explainAgreement', '...'); | |
| set('explainConfidence', '...'); | |
| set('explainConfBand', '--'); | |
| set('explainConfScore', '--'); | |
| set('explainGradeEvidence', '...'); | |
| set('explainRecommendation', '...'); | |
| set('explainDisclaimer', 'Not a medical diagnosis. Research / educational only.'); | |
| const ids = ['explainFindings', 'explainDifferentialList', 'explainVisualObservations', | |
| 'explainVisualDisagreements', 'explainLlmPasses', 'explainQualityWarnings', | |
| 'explainRaw']; | |
| ids.forEach(id => { const el = document.getElementById(id); if (el) el.innerHTML = ''; }); | |
| const fill = document.getElementById('explainConfFill'); | |
| if (fill) fill.style.width = '0%'; | |
| } | |
| renderExplanationError(message) { | |
| const set = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text; }; | |
| set('explainBackend', 'error'); | |
| set('explainImpression', `Error: ${message}`); | |
| set('explainSummary', '--'); | |
| set('explainAgreement', '--'); | |
| set('explainConfidence', '--'); | |
| } | |
| renderExplanation(payload) { | |
| const exp = (payload && payload.explanation) || {}; | |
| const feats = (payload && payload.features) || {}; | |
| const set = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text || '--'; }; | |
| // --- Header (backend + safety badge) ------------------------------- | |
| set('explainBackend', `${exp.backend || 'none'}${exp.model ? ` · ${exp.model}` : ''}`); | |
| const safety = exp.hallucination_safety || ''; | |
| const safetyEl = document.getElementById('explainSafetyBadge'); | |
| if (safetyEl) { | |
| const isZero = safety.toLowerCase().includes('guaranteed_zero'); | |
| safetyEl.textContent = isZero ? 'Zero-Hallucination Mode' : 'Hallucination-Checked'; | |
| safetyEl.title = safety; | |
| safetyEl.className = 'explain-safety-badge ' + (isZero ? 'safety-zero' : 'safety-checked'); | |
| } | |
| // --- Impression + verified Summary -------------------------------- | |
| set('explainImpression', exp.impression || exp.summary); | |
| set('explainSummary', exp.summary); | |
| set('explainDisclaimer', exp.disclaimer || 'Not a medical diagnosis. Research / educational only.'); | |
| set('explainAgreement', exp.model_agreement_analysis); | |
| set('explainConfidence', exp.confidence_assessment); | |
| set('explainRecommendation', exp.recommendation); | |
| // --- Confidence band + score -------------------------------------- | |
| const overall = feats.overall_confidence || {}; | |
| const score = typeof overall.score_0_to_1 === 'number' ? overall.score_0_to_1 : null; | |
| const band = overall.band || ''; | |
| const bandEl = document.getElementById('explainConfBand'); | |
| if (bandEl) { | |
| bandEl.textContent = band || '--'; | |
| bandEl.className = 'confidence-band conf-' + (band || 'unknown').replace(/[^a-z-]/gi, ''); | |
| } | |
| const scoreEl = document.getElementById('explainConfScore'); | |
| if (scoreEl) scoreEl.textContent = (score == null) ? '--' : `${(score * 100).toFixed(0)}%`; | |
| const fill = document.getElementById('explainConfFill'); | |
| if (fill) fill.style.width = `${(score == null) ? 0 : score * 100}%`; | |
| // --- Classifier-negative explanation (shown only when verdict=no_tumor) --- | |
| const negativeSection = document.getElementById('explainNegativeSection'); | |
| const negativeText = document.getElementById('explainNegativeExplanation'); | |
| const negExp = exp.classifier_negative_explanation; | |
| if (negativeSection && negativeText) { | |
| if (negExp) { | |
| negativeSection.style.display = 'block'; | |
| negativeText.textContent = negExp; | |
| } else { | |
| negativeSection.style.display = 'none'; | |
| negativeText.textContent = ''; | |
| } | |
| } | |
| // --- Pattern D: vision LLM reasoning for negative cases ----------- | |
| const visionNegSection = document.getElementById('explainVisionNegativeSection'); | |
| const visionNegText = document.getElementById('explainVisionNegativeText'); | |
| const visionNeg = exp.vision_negative_reasoning; | |
| if (visionNegSection && visionNegText) { | |
| if (visionNeg) { | |
| visionNegSection.style.display = 'block'; | |
| visionNegText.textContent = visionNeg; | |
| } else { | |
| visionNegSection.style.display = 'none'; | |
| visionNegText.textContent = ''; | |
| } | |
| } | |
| const findingDomains = [ | |
| ['geometry', 'Geometry'], | |
| ['localization', 'Localization'], | |
| ['intensity', 'Intensity'], | |
| ['texture', 'Texture'], | |
| ['multimodal', 'Multimodal'], | |
| ['morphology_margins', 'Morphology & Margins'], | |
| ['internal_architecture', 'Internal Architecture'], | |
| ['mass_effect', 'Mass Effect'], | |
| ]; | |
| const renderFindingsInto = (el, fmap) => { | |
| if (!el || !fmap) return false; | |
| const html = findingDomains | |
| .filter(([k]) => fmap[k]) | |
| .map(([k, label]) => `<dt>${label}</dt><dd>${this.escapeHtml(fmap[k])}</dd>`) | |
| .join(''); | |
| el.innerHTML = html; | |
| return Boolean(html); | |
| }; | |
| // --- Structured findings (8 domains, primary report) -------------- | |
| const findingsEl = document.getElementById('explainFindings'); | |
| const findingsSection = document.getElementById('explainFindingsSection'); | |
| const hasFindings = renderFindingsInto(findingsEl, exp.findings || {}); | |
| // Hide the whole section when the deterministic narrative produced no | |
| // primary findings (i.e. verdict was no_tumor and the U-Net features | |
| // got moved into the FP-region debug bucket below). | |
| if (findingsSection) findingsSection.style.display = hasFindings ? '' : 'none'; | |
| // --- Grade evidence narrative ------------------------------------- | |
| const gradeEl = document.getElementById('explainGradeEvidence'); | |
| const gradeSection = document.getElementById('explainGradeSection'); | |
| const gradeText = exp.grade_evidence_narrative || ''; | |
| if (gradeEl) gradeEl.textContent = gradeText || '--'; | |
| if (gradeSection) gradeSection.style.display = gradeText ? '' : 'none'; | |
| // --- False-positive region debug (collapsible) -------------------- | |
| // When the classifier verdict was no_tumor, the U-Net feature | |
| // breakdown was preserved here under a clearly-labeled details block | |
| // so the raw data is accessible but not pretending to be a clinical | |
| // finding. | |
| const fpSection = document.getElementById('explainFpRegionSection'); | |
| const fpFindings = document.getElementById('explainFpRegionFindings'); | |
| const fpGrade = document.getElementById('explainFpRegionGrade'); | |
| const fpAnalysis = exp.fp_region_analysis || null; | |
| const fpGradeText = exp.fp_grade_evidence || ''; | |
| const hasFpAnalysis = renderFindingsInto(fpFindings, fpAnalysis || {}); | |
| if (fpGrade) fpGrade.textContent = fpGradeText; | |
| if (fpSection) fpSection.style.display = (hasFpAnalysis || fpGradeText) ? '' : 'none'; | |
| // --- Differential with citations & origin tags -------------------- | |
| const diff = document.getElementById('explainDifferentialList'); | |
| if (diff) { | |
| const items = exp.differential_with_citations || []; | |
| if (items.length === 0) { | |
| diff.innerHTML = '<div style="opacity:0.6;">No differential hints returned.</div>'; | |
| } else { | |
| diff.innerHTML = items.map(d => { | |
| const origin = d.origin || 'rule-based'; | |
| const originLabel = origin === 'llm-citation-checked' | |
| ? '<span class="origin-tag tag-llm">LLM · citation-checked</span>' | |
| : '<span class="origin-tag tag-rule">Rule-based</span>'; | |
| const confTag = d.confidence | |
| ? `<span class="conf-tag conf-${d.confidence.replace(/[^a-z-]/gi, '')}">${this.escapeHtml(d.confidence)}</span>` | |
| : ''; | |
| const cites = (d.supported_by || []).map(c => | |
| `<code class="citation-chip">${this.escapeHtml(String(c))}</code>` | |
| ).join(' '); | |
| return ` | |
| <div class="differential-item"> | |
| <div class="differential-tags">${originLabel}${confTag}</div> | |
| <div class="differential-statement">${this.escapeHtml(d.statement || '')}</div> | |
| <div class="differential-citations">Supported by: ${cites || '<em>(no citations)</em>'}</div> | |
| </div>`; | |
| }).join(''); | |
| } | |
| } | |
| // --- Pattern C: visual observations ------------------------------- | |
| const visualSection = document.getElementById('explainVisualSection'); | |
| const visualList = document.getElementById('explainVisualObservations'); | |
| const obs = exp.visual_observations || []; | |
| if (visualSection && visualList) { | |
| if (obs.length) { | |
| visualSection.style.display = ''; | |
| visualList.innerHTML = obs.map(o => { | |
| const region = this.escapeHtml(o.region || '?'); | |
| const claim = this.escapeHtml(o.claimed_property || ''); | |
| const text = this.escapeHtml(o.observation || ''); | |
| return `<li><strong>${region}</strong> — ${text} <span class="claim-prop">[${claim}]</span></li>`; | |
| }).join(''); | |
| } else { | |
| visualSection.style.display = 'none'; | |
| } | |
| } | |
| // Disagreements | |
| const disagreeSection = document.getElementById('explainDisagreementsSection'); | |
| const disagreeList = document.getElementById('explainVisualDisagreements'); | |
| const dis = exp.visual_disagreements || []; | |
| if (disagreeSection && disagreeList) { | |
| if (dis.length) { | |
| disagreeSection.style.display = ''; | |
| disagreeList.innerHTML = dis.map(d => { | |
| const text = this.escapeHtml(d.observation || ''); | |
| const conflicts = (d.conflicts_with || []).map(c => this.escapeHtml(c)).join('; '); | |
| return `<li><strong>${text}</strong> <span class="claim-prop">conflicts with: ${conflicts}</span></li>`; | |
| }).join(''); | |
| } else { | |
| disagreeSection.style.display = 'none'; | |
| } | |
| } | |
| // --- LLM pass status (transparency) ------------------------------- | |
| const passesEl = document.getElementById('explainLlmPasses'); | |
| if (passesEl) { | |
| const passes = exp.llm_passes || {}; | |
| const labels = { | |
| polish: 'Polish (Pattern A)', | |
| differential_expansion: 'Differential Expansion (Pattern B)', | |
| visual_observer: 'Visual Observer (Pattern C)', | |
| }; | |
| const items = ['polish', 'differential_expansion', 'visual_observer'] | |
| .filter(k => passes[k]) | |
| .map(k => { | |
| const p = passes[k]; | |
| const status = p.status || 'unknown'; | |
| const cssStatus = status.replace(/[^a-z_]/gi, ''); | |
| const model = p.model ? ` <span class="pass-model">${this.escapeHtml(p.model)}</span>` : ''; | |
| let detail = ''; | |
| if (status === 'error' || status === 'skipped_insufficient_ram') { | |
| detail = `<div class="pass-detail pass-error">${this.escapeHtml(p.error || p.recovery_hint || '')}</div>`; | |
| } else if (status === 'rejected') { | |
| detail = `<div class="pass-detail pass-warn">Rejected: ${this.escapeHtml((p.warnings || []).join('; '))}</div>`; | |
| } else if (status === 'ok' && k === 'differential_expansion') { | |
| detail = `<div class="pass-detail">Accepted ${p.accepted_count || 0} · Rejected ${p.rejected_count || 0}</div>`; | |
| } else if (status === 'ok' && k === 'visual_observer') { | |
| detail = `<div class="pass-detail">${p.observation_count || 0} observations · ${p.disagreement_count || 0} disagreements</div>`; | |
| } else if (status === 'skipped') { | |
| detail = `<div class="pass-detail">${this.escapeHtml(p.reason || 'skipped')}</div>`; | |
| } | |
| return ` | |
| <div class="llm-pass-item pass-${cssStatus}"> | |
| <div class="pass-header"> | |
| <span class="pass-label">${labels[k]}</span>${model} | |
| </div> | |
| <div class="pass-status">${this.escapeHtml(status)}</div> | |
| ${detail} | |
| </div>`; | |
| }).join(''); | |
| passesEl.innerHTML = items || '<div style="opacity:0.6;">No LLM passes run.</div>'; | |
| } | |
| // --- Quality warnings --------------------------------------------- | |
| const qualSection = document.getElementById('explainQualitySection'); | |
| const qualList = document.getElementById('explainQualityWarnings'); | |
| const warnings = exp.quality_warnings || []; | |
| if (qualSection && qualList) { | |
| if (warnings.length) { | |
| qualSection.style.display = ''; | |
| qualList.innerHTML = warnings.map(w => `<li>${this.escapeHtml(w)}</li>`).join(''); | |
| } else { | |
| qualSection.style.display = 'none'; | |
| } | |
| } | |
| // --- Raw features (collapsible) ----------------------------------- | |
| const raw = document.getElementById('explainRaw'); | |
| if (raw) { | |
| try { raw.textContent = JSON.stringify(feats, null, 2); } | |
| catch (_) { raw.textContent = String(feats); } | |
| } | |
| } | |
| escapeHtml(s) { | |
| if (s == null) return ''; | |
| return String(s) | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| handleTabClick(e) { | |
| const btn = e.target; | |
| const tabGroup = btn.parentElement; | |
| const tabType = btn.dataset.tab || btn.dataset.view; | |
| // Remove active from siblings | |
| tabGroup.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| // Handle view switching | |
| if (btn.dataset.view) { | |
| ['vizImage', 'heatmapImage', 'overlayImage', 'maskImage', 'segoverlayImage', | |
| 'coarseMaskImage', 'coarseOverlayImage', 'bboxPromptImage', 'vizPlaceholder'].forEach(id => { | |
| const el = document.getElementById(id); | |
| if (el) el.style.display = 'none'; | |
| }); | |
| // Suppression banner is shown only on the U-Net Mask / U-Net | |
| // Overlay tabs when the classifier consensus is no-tumor. | |
| const banner = document.getElementById('maskSuppressBanner'); | |
| if (banner) { | |
| const isSegTab = (tabType === 'mask' || tabType === 'segoverlay'); | |
| if (this._maskSuppressed && isSegTab) { | |
| banner.style.display = 'block'; | |
| banner.textContent = this._maskSuppressedReason || ''; | |
| } else { | |
| banner.style.display = 'none'; | |
| } | |
| } | |
| const idMap = { | |
| original: 'vizImage', | |
| heatmap: 'heatmapImage', | |
| overlay: 'overlayImage', | |
| mask: 'maskImage', | |
| segoverlay: 'segoverlayImage', | |
| coarse_mask: 'coarseMaskImage', | |
| coarse_overlay: 'coarseOverlayImage', | |
| bbox_prompt: 'bboxPromptImage', | |
| }; | |
| const targetId = idMap[tabType] || `${tabType}Image`; | |
| const target = document.getElementById(targetId); | |
| // If the user picked a Grad-CAM tab and we have no real heatmap | |
| // (ONNX-only deploy, ViT hybrid, etc.), show the unavailable | |
| // placeholder instead of an empty image element. | |
| const needsAvailability = (tabType === 'heatmap' || tabType === 'overlay'); | |
| if (needsAvailability && target && target.dataset.available === 'false') { | |
| const placeholder = document.getElementById('vizPlaceholder'); | |
| if (placeholder) { | |
| placeholder.style.display = 'flex'; | |
| placeholder.textContent = this._gradcamUnavailableReason | |
| || 'Grad-CAM unavailable for the selected model.'; | |
| } | |
| return; | |
| } | |
| if (target) target.style.display = 'block'; | |
| } | |
| // Handle comparison/details tab | |
| if (btn.dataset.tab === 'details') { | |
| this.showModelDetails(); | |
| } else if (btn.dataset.tab === 'comparison') { | |
| this.showComparisonTable(); | |
| } | |
| } | |
| showComparisonTable() { | |
| const content = document.getElementById('comparisonContent'); | |
| if (this.currentResults) { | |
| content.innerHTML = ` | |
| <div class="comparison-table"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Model</th> | |
| <th>Prediction</th> | |
| <th>Confidence</th> | |
| <th>Accuracy</th> | |
| <th>ROC AUC</th> | |
| <th>Status</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| ${this.currentResults.models.map(model => { | |
| const fmt = (v) => (v == null || Number.isNaN(v)) ? 'N/A' : `${(v * 100).toFixed(1)}%`; | |
| return ` | |
| <tr class="${model === this.currentResults.bestModel ? 'best' : ''}"> | |
| <td><strong>${model.modelLabel}</strong></td> | |
| <td>${model.prediction}</td> | |
| <td>${fmt(model.confidence)}</td> | |
| <td>${fmt(model.accuracy)}</td> | |
| <td>${fmt(model.auc)}</td> | |
| <td> | |
| <span class="status-badge ${model.status}"> | |
| ● ${model.status === 'positive' ? 'Positive' : 'Negative'} | |
| </span> | |
| </td> | |
| </tr> | |
| `; | |
| }).join('')} | |
| </tbody> | |
| </table> | |
| </div> | |
| `; | |
| } | |
| } | |
| showModelDetails() { | |
| const content = document.getElementById('comparisonContent'); | |
| if (this.currentResults) { | |
| content.innerHTML = ` | |
| <div style="display: grid; gap: 20px;"> | |
| ${this.currentResults.models.map(model => { | |
| const fmt = (v) => (v == null || Number.isNaN(v)) ? 'N/A' : `${(v * 100).toFixed(1)}%`; | |
| return ` | |
| <div style="background: var(--gray-50); padding: 20px; border-radius: var(--radius-lg); border-left: 4px solid ${model === this.currentResults.bestModel ? 'var(--primary)' : 'var(--gray-300)'};"> | |
| <h4 style="margin-bottom: 12px; color: var(--gray-800);">${model.modelLabel}</h4> | |
| <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px;"> | |
| <div> | |
| <div style="font-size: 12px; color: var(--gray-500); margin-bottom: 4px;">Confidence</div> | |
| <div style="font-size: 20px; font-weight: 700; color: var(--gray-800);">${fmt(model.confidence)}</div> | |
| </div> | |
| <div> | |
| <div style="font-size: 12px; color: var(--gray-500); margin-bottom: 4px;">Accuracy</div> | |
| <div style="font-size: 20px; font-weight: 700; color: var(--gray-800);">${fmt(model.accuracy)}</div> | |
| </div> | |
| <div> | |
| <div style="font-size: 12px; color: var(--gray-500); margin-bottom: 4px;">ROC AUC</div> | |
| <div style="font-size: 20px; font-weight: 700; color: var(--primary);">${fmt(model.auc)}</div> | |
| </div> | |
| </div> | |
| <div style="margin-top: 12px;"> | |
| <div style="font-size: 12px; color: var(--gray-500); margin-bottom: 4px;">Prediction</div> | |
| <span class="status-badge ${model.status}" style="font-size: 14px; padding: 6px 14px;"> | |
| ● ${model.prediction} | |
| </span> | |
| </div> | |
| </div> | |
| `; | |
| }).join('')} | |
| </div> | |
| `; | |
| } | |
| } | |
| showSection(section) { | |
| document.querySelectorAll('.content-section').forEach(s => { | |
| s.classList.remove('active'); | |
| s.style.display = 'none'; | |
| }); | |
| const targetSection = document.getElementById(`${section}-section`); | |
| if (targetSection) { | |
| targetSection.classList.add('active'); | |
| targetSection.style.display = 'block'; | |
| } | |
| } | |
| // Inline toast for real informational events (e.g. "report exported"). | |
| // Replaces the previous "Coming Soon" placeholder which advertised | |
| // unimplemented features. | |
| showToast(title, description, level = 'info') { | |
| const toast = document.createElement('div'); | |
| toast.className = `nl-toast nl-toast-${level}`; | |
| toast.innerHTML = ` | |
| <strong class="nl-toast-title">${this.escapeHtml(title)}</strong> | |
| <p class="nl-toast-desc">${this.escapeHtml(description || '')}</p> | |
| `; | |
| document.body.appendChild(toast); | |
| setTimeout(() => { toast.classList.add('nl-toast-exit'); }, 3500); | |
| setTimeout(() => { toast.remove(); }, 4000); | |
| } | |
| showLoading() { | |
| document.getElementById('loadingOverlay').style.display = 'flex'; | |
| const uploadCard = document.getElementById('uploadCard'); | |
| if(uploadCard) uploadCard.classList.add('is-scanning'); | |
| } | |
| hideLoading() { | |
| document.getElementById('loadingOverlay').style.display = 'none'; | |
| const uploadCard = document.getElementById('uploadCard'); | |
| if(uploadCard) uploadCard.classList.remove('is-scanning'); | |
| } | |
| /** | |
| * Export the analysis as JSON. Includes the classifier results, the cascade | |
| * segmentation decision, the full explanation payload (impression, | |
| * structured findings, grade evidence, differential with citations, | |
| * LLM-pass status), and the raw measured features. Sufficient to | |
| * reproduce the on-screen report from the file alone. | |
| */ | |
| exportReport() { | |
| if (!this.currentResults) { | |
| this.showToast('No analysis to export', 'Run an analysis first.', 'error'); | |
| return; | |
| } | |
| const report = { | |
| schema_version: '2.1', | |
| patient_id: this.currentResults.patientId, | |
| timestamp: this.currentResults.timestamp, | |
| diagnosis: this.currentResults.diagnosis, | |
| confidence: this.currentResults.confidence, | |
| best_model: this.currentResults.bestModel?.modelLabel, | |
| processing_time_seconds: this.currentResults.processingTime, | |
| model_results: this.currentResults.models, | |
| segmentation: this.currentSegmentation?.result || null, | |
| explanation: this.currentExplanation || null, | |
| }; | |
| const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `trinetra_${this.currentResults.patientId}.json`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| this.showToast('Report exported', `${a.download} downloaded.`, 'success'); | |
| } | |
| /** | |
| * Open the browser print dialog scoped to the result panel. | |
| * The print stylesheet hides the chrome (sidebar, top bar, controls, | |
| * raw-features blob) and prints just the radiology-style report. The | |
| * user picks "Save as PDF" in the print dialog for a portable file. | |
| */ | |
| printReport() { | |
| if (!this.currentResults) { | |
| this.showToast('No analysis to print', 'Run an analysis first.', 'error'); | |
| return; | |
| } | |
| window.print(); | |
| } | |
| async loadMetrics() { | |
| try { | |
| const response = await fetch('/metrics'); | |
| if (response.ok) { | |
| const metrics = await response.json(); | |
| console.log('Model metrics loaded:', metrics); | |
| } | |
| } catch (error) { | |
| console.log('Metrics not available (development mode)'); | |
| } | |
| } | |
| /** | |
| * Live /status polling: server returns real ONNX session count, GPU | |
| * memory, LLM backend availability. Replaces the previous hard-coded | |
| * "3/3 models, 4.2/8 GB, 2 pending" mock that was misleading. | |
| */ | |
| async loadStatus() { | |
| const list = document.getElementById('systemStatusList'); | |
| const lastUpdated = document.getElementById('statusLastUpdated'); | |
| try { | |
| const r = await fetch('/status', { headers: { 'Accept': 'application/json' } }); | |
| if (!r.ok) throw new Error(`HTTP ${r.status}`); | |
| const s = await r.json(); | |
| const rows = []; | |
| // Inference runtime row. | |
| const ort = s.onnx_runtime || {}; | |
| const ortOk = !!ort.available; | |
| const provider = (ort.providers || []).find(p => p.includes('CUDA')) ? 'CUDA' : | |
| (ort.providers || []).find(p => p.includes('CPU')) ? 'CPU' : '-'; | |
| rows.push(` | |
| <div class="status-item"> | |
| <span class="status-dot ${ortOk ? 'online' : 'offline'}"></span> | |
| <span>Inference Runtime</span> | |
| <span class="status-value">${ortOk ? `ONNX ${provider}` : 'PyTorch'}</span> | |
| </div>`); | |
| // Loaded sessions | |
| rows.push(` | |
| <div class="status-item"> | |
| <span class="status-dot online"></span> | |
| <span>Loaded Sessions</span> | |
| <span class="status-value">${ort.sessions_loaded ?? 0}</span> | |
| </div>`); | |
| // GPU memory (only when actually available) | |
| const gpu = s.gpu || {}; | |
| if (gpu.available) { | |
| const usedGb = ((gpu.memory_used_mb || 0) / 1024).toFixed(1); | |
| const totalGb = ((gpu.memory_total_mb || 0) / 1024).toFixed(1); | |
| const pct = gpu.memory_total_mb ? (gpu.memory_used_mb / gpu.memory_total_mb) * 100 : 0; | |
| rows.push(` | |
| <div class="status-item"> | |
| <span class="status-dot ${pct < 80 ? 'online' : 'warning'}"></span> | |
| <span title="${this.escapeHtml(gpu.name || 'GPU')}">GPU Memory</span> | |
| <span class="status-value">${usedGb} / ${totalGb} GB</span> | |
| </div>`); | |
| } else { | |
| rows.push(` | |
| <div class="status-item"> | |
| <span class="status-dot warning"></span> | |
| <span>GPU</span> | |
| <span class="status-value">CPU mode</span> | |
| </div>`); | |
| } | |
| // Classifier weight readiness (count of present .onnx / .pt) | |
| const cls = s.classifiers || {}; | |
| const clsCount = Object.values(cls).filter(c => c && (c.onnx || c.pt)).length; | |
| rows.push(` | |
| <div class="status-item"> | |
| <span class="status-dot ${clsCount >= 3 ? 'online' : 'warning'}"></span> | |
| <span>Classifiers Ready</span> | |
| <span class="status-value">${clsCount} / 3</span> | |
| </div>`); | |
| // Segmentation | |
| const segs = s.segmentation_models || []; | |
| const segCount = segs.filter(m => m.onnx || m.pt_size_mb).length; | |
| rows.push(` | |
| <div class="status-item"> | |
| <span class="status-dot ${segCount > 0 ? 'online' : 'offline'}"></span> | |
| <span>Segmentation</span> | |
| <span class="status-value">${segCount} model${segCount === 1 ? '' : 's'}</span> | |
| </div>`); | |
| // LLM backend availability | |
| const llm = s.llm || {}; | |
| let llmStatus = 'deterministic only'; | |
| let llmDot = 'warning'; | |
| if (llm.hf_inference_token_present) { llmStatus = 'HF Inference'; llmDot = 'online'; } | |
| else if (llm.anthropic_token_present) { llmStatus = 'Anthropic'; llmDot = 'online'; } | |
| rows.push(` | |
| <div class="status-item"> | |
| <span class="status-dot ${llmDot}"></span> | |
| <span>LLM Explanation</span> | |
| <span class="status-value">${llmStatus}</span> | |
| </div>`); | |
| if (list) list.innerHTML = rows.join(''); | |
| if (lastUpdated) { | |
| const t = new Date(); | |
| lastUpdated.textContent = `updated ${t.getHours().toString().padStart(2,'0')}:${t.getMinutes().toString().padStart(2,'0')}`; | |
| } | |
| // Surface version + backend in the research hero and footer. | |
| const heroVersion = document.getElementById('heroVersion'); | |
| const heroBackend = document.getElementById('heroBackend'); | |
| const footerVersion = document.getElementById('footerVersion'); | |
| const footerRuntime = document.getElementById('footerRuntime'); | |
| const footerLlm = document.getElementById('footerLlm'); | |
| if (heroVersion && s.version) heroVersion.textContent = `v${s.version.replace(/^v/, '')}`; | |
| const backendLabel = llm.hf_inference_token_present | |
| ? 'LLM via HuggingFace Inference Providers' | |
| : (llm.anthropic_token_present ? 'LLM via Anthropic' : 'Deterministic-only mode'); | |
| if (heroBackend) heroBackend.textContent = backendLabel; | |
| if (footerVersion) footerVersion.textContent = `Version ${s.version || '--'}`; | |
| if (footerRuntime) footerRuntime.textContent = `Runtime ${ortOk ? `ONNX ${provider}` : 'PyTorch'}`; | |
| if (footerLlm) footerLlm.textContent = `LLM backend ${backendLabel}`; | |
| } catch (err) { | |
| if (list) { | |
| list.innerHTML = ` | |
| <div class="status-item"> | |
| <span class="status-dot offline"></span> | |
| <span>Server Unreachable</span> | |
| <span class="status-value">--</span> | |
| </div>`; | |
| } | |
| } | |
| } | |
| /** | |
| * Session-scoped Recent Scans: pushes each finished analysis into the | |
| * sidebar list. Survives only as long as the tab is open (no persistence) | |
| * to keep the demo simple and avoid the misleading mock that was here. | |
| */ | |
| addRecentScan(entry) { | |
| if (!this._recentScans) this._recentScans = []; | |
| // Attach a snapshot of the current view state so clicking the item | |
| // later can restore the full Results page (currentResults + | |
| // currentSegmentation + currentExplanation + imageDataUrl). For | |
| // batch entries the scan id starts with 'BATCH-' and is restored | |
| // via viewBatchEntry() which already does the lookup. | |
| if (!entry.snapshot && !String(entry.id || '').startsWith('BATCH-')) { | |
| entry.snapshot = { | |
| currentResults: this.currentResults, | |
| currentSegmentation: this.currentSegmentation, | |
| currentExplanation: this.currentExplanation || null, | |
| imageDataUrl: this.imageDataUrl, | |
| currentFile: this.currentFile, | |
| }; | |
| } | |
| this._recentScans.unshift(entry); | |
| if (this._recentScans.length > 8) this._recentScans.length = 8; | |
| this.renderRecentScans(); | |
| } | |
| renderRecentScans() { | |
| const el = document.getElementById('recentScansList'); | |
| if (!el) return; | |
| const items = this._recentScans || []; | |
| if (!items.length) { | |
| el.innerHTML = '<div class="recent-empty">No scans yet. Upload an MRI to begin.</div>'; | |
| return; | |
| } | |
| el.innerHTML = items.map(s => { | |
| const tumor = s.isPositive; | |
| const ago = this.formatRelativeTime(s.timestamp); | |
| return ` | |
| <div class="recent-item" data-scan-id="${this.escapeHtml(s.id)}" role="button" tabindex="0" title="Click to reopen this analysis"> | |
| <div class="recent-icon ${tumor ? 'tumor' : 'normal'}">${tumor ? 'T' : 'N'}</div> | |
| <div class="recent-info"> | |
| <span class="recent-id">${this.escapeHtml(s.id)}</span> | |
| <span class="recent-time">${ago}</span> | |
| </div> | |
| <span class="recent-status ${tumor ? 'positive' : 'negative'}">${tumor ? 'Tumor' : 'Normal'}</span> | |
| </div>`; | |
| }).join(''); | |
| // Bind clicks. We rebuild on every render so old handlers are | |
| // dropped along with the old DOM. | |
| el.querySelectorAll('.recent-item').forEach(node => { | |
| const scanId = node.dataset.scanId; | |
| const handler = () => this.viewRecentScan(scanId); | |
| node.addEventListener('click', handler); | |
| node.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| handler(); | |
| } | |
| }); | |
| }); | |
| } | |
| /** Reopen a previously-analysed scan from the Recent Scans sidebar. */ | |
| viewRecentScan(scanId) { | |
| if (!scanId) return; | |
| // Batch entries restore through the existing viewBatchEntry path so | |
| // we don't duplicate the snapshot-restore logic. | |
| if (String(scanId).startsWith('BATCH-')) { | |
| return this.viewBatchEntry(scanId); | |
| } | |
| const entry = (this._recentScans || []).find(s => s.id === scanId); | |
| if (!entry || !entry.snapshot) { | |
| this.showToast('Snapshot unavailable', | |
| 'This scan was added before the snapshot feature was wired up. Re-run the analysis to reopen it.', 'error'); | |
| return; | |
| } | |
| const snap = entry.snapshot; | |
| if (snap.currentResults) this.currentResults = snap.currentResults; | |
| if (snap.currentSegmentation) this.currentSegmentation = snap.currentSegmentation; | |
| if (snap.currentExplanation !== undefined) this.currentExplanation = snap.currentExplanation; | |
| if (snap.imageDataUrl) this.imageDataUrl = snap.imageDataUrl; | |
| if (snap.currentFile) this.currentFile = snap.currentFile; | |
| if (this.currentResults) { | |
| this.displayResults(); | |
| } | |
| } | |
| formatRelativeTime(ms) { | |
| const diff = Date.now() - ms; | |
| if (diff < 60_000) return 'just now'; | |
| if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} min ago`; | |
| return `${Math.floor(diff / 3_600_000)} h ago`; | |
| } | |
| setupNavigation() { | |
| // Session-tracked Recent Scans bind themselves in addRecentScan(). | |
| // No mock click handlers needed; the items appear only after real runs. | |
| } | |
| formatFileSize(bytes) { | |
| if (bytes === 0) return '0 Bytes'; | |
| const k = 1024; | |
| const sizes = ['Bytes', 'KB', 'MB', 'GB']; | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; | |
| } | |
| getModelLabel(model) { | |
| const labels = { | |
| 'cnn': 'CNN (Fast)', | |
| 'transfer': 'Transfer Learning', | |
| 'vit': 'Vision Transformer', | |
| 'attention_unet': 'Attention U-Net' | |
| }; | |
| return labels[model] || model; | |
| } | |
| } | |
| // Initialize app when DOM is ready | |
| document.addEventListener('DOMContentLoaded', () => { | |
| window.app = new TriNetraApp(); | |
| }); | |
| // --- Tri-Netra Enhancements (Patient Mode, Chat, Translation) --- | |
| const translations = { | |
| 'en': { | |
| 'welcome': 'Welcome. Let\'s look at your scan.', | |
| 'upload_text': 'Upload your MRI scan below and our system will review it.', | |
| 'upload_btn': 'Upload MRI Scan', | |
| 'result_title': 'Result', | |
| 'risk_title': 'Risk Level', | |
| 'followup_title': 'Recommended Next Steps', | |
| 'chat_title': 'Have Questions?', | |
| 'chat_desc': 'Ask our AI assistant in simple language. (Note: AI cannot give medical advice).', | |
| 'chk_1': 'Scan analyzed by AI', | |
| 'chk_2': 'Download summary report', | |
| 'chk_3': 'Call doctor to schedule review', | |
| 'chk_4': 'Write down questions for doctor', | |
| 'btn_download': 'Download Report' | |
| }, | |
| 'hi': { | |
| 'welcome': 'नमस्ते। आइए आपके स्कैन को देखें।', | |
| 'upload_text': 'नीचे अपना MRI स्कैन अपलोड करें और हमारा सिस्टम इसकी जांच करेगा।', | |
| 'upload_btn': 'MRI स्कैन अपलोड करें', | |
| 'result_title': 'परिणाम', | |
| 'risk_title': 'जोखिम स्तर', | |
| 'followup_title': 'सुझाए गए अगले कदम', | |
| 'chat_title': 'कोई प्रश्न हैं?', | |
| 'chat_desc': 'हमारे AI से आसान भाषा में पूछें। (ध्यान दें: AI चिकित्सा सलाह नहीं दे सकता)।', | |
| 'chk_1': 'AI द्वारा स्कैन का विश्लेषण किया गया', | |
| 'chk_2': 'संक्षिप्त रिपोर्ट डाउनलोड करें', | |
| 'chk_3': 'समीक्षा के लिए डॉक्टर को कॉल करें', | |
| 'chk_4': 'डॉक्टर के लिए प्रश्न लिखें', | |
| 'btn_download': 'रिपोर्ट डाउनलोड करें' | |
| }, | |
| 'pa': { | |
| 'welcome': 'ਜੀ ਆਇਆਂ ਨੂੰ। ਆਓ ਤੁਹਾਡੇ ਸਕੈਨ ਨੂੰ ਦੇਖੀਏ।', | |
| 'upload_text': 'ਹੇਠਾਂ ਆਪਣਾ MRI ਸਕੈਨ ਅਪਲੋਡ ਕਰੋ ਅਤੇ ਸਾਡਾ ਸਿਸਟਮ ਇਸਦੀ ਜਾਂਚ ਕਰੇਗਾ।', | |
| 'upload_btn': 'MRI ਸਕੈਨ ਅਪਲੋਡ ਕਰੋ', | |
| 'result_title': 'ਨਤੀਜਾ', | |
| 'risk_title': 'ਜੋਖਮ ਪੱਧਰ', | |
| 'followup_title': 'ਸੁਝਾਏ ਗਏ ਅਗਲੇ ਕਦਮ', | |
| 'chat_title': 'ਕੋਈ ਸਵਾਲ ਹਨ?', | |
| 'chat_desc': 'ਸਾਡੇ AI ਤੋਂ ਆਸਾਨ ਭਾਸ਼ਾ ਵਿੱਚ ਪੁੱਛੋ। (ਨੋਟ: AI ਡਾਕਟਰੀ ਸਲਾਹ ਨਹੀਂ ਦੇ ਸਕਦਾ)।', | |
| 'chk_1': 'AI ਦੁਆਰਾ ਸਕੈਨ ਦਾ ਵਿਸ਼ਲੇਸ਼ਣ ਕੀਤਾ ਗਿਆ', | |
| 'chk_2': 'ਰਿਪੋਰਟ ਡਾਊਨਲੋਡ ਕਰੋ', | |
| 'chk_3': 'ਡਾਕਟਰ ਨੂੰ ਕਾਲ ਕਰੋ', | |
| 'chk_4': 'ਡਾਕਟਰ ਲਈ ਸਵਾਲ ਲਿਖੋ', | |
| 'btn_download': 'ਰਿਪੋਰਟ ਡਾਊਨਲੋਡ ਕਰੋ' | |
| } | |
| }; | |
| window.currentPatientReport = null; | |
| function selectRole(role) { | |
| document.getElementById('landing-page').classList.add('hidden'); | |
| if (role === 'patient') { | |
| document.getElementById('patient-view').style.display = 'block'; | |
| document.getElementById('doctor-view').style.display = 'none'; | |
| } else { | |
| document.getElementById('doctor-view').style.display = 'grid'; | |
| document.getElementById('patient-view').style.display = 'none'; | |
| } | |
| } | |
| function goHome() { | |
| document.getElementById('landing-page').classList.remove('hidden'); | |
| document.getElementById('patient-view').style.display = 'none'; | |
| document.getElementById('doctor-view').style.display = 'none'; | |
| } | |
| function changeLanguage() { | |
| const lang = document.getElementById('patient-lang').value; | |
| const t = translations[lang]; | |
| document.getElementById('p-welcome').innerText = t.welcome; | |
| document.getElementById('p-upload-text').innerText = t.upload_text; | |
| document.getElementById('p-upload-btn').innerText = t.upload_btn; | |
| document.getElementById('p-verdict-title').innerText = t.result_title; | |
| document.getElementById('p-risk-title').innerText = t.risk_title; | |
| document.getElementById('p-followup-title').innerText = t.followup_title; | |
| document.getElementById('p-chat-title').innerText = t.chat_title; | |
| document.getElementById('p-chat-desc').innerText = t.chat_desc; | |
| document.getElementById('p-check-1').innerText = t.chk_1; | |
| document.getElementById('p-check-2').innerText = t.chk_2; | |
| document.getElementById('p-check-3').innerText = t.chk_3; | |
| document.getElementById('p-check-4').innerText = t.chk_4; | |
| document.getElementById('p-btn-download').innerText = t.btn_download; | |
| } | |
| // Attach event listener for Patient File Input | |
| document.addEventListener('DOMContentLoaded', () => { | |
| const pInput = document.getElementById('patientFileInput'); | |
| if (pInput) { | |
| pInput.addEventListener('change', async (e) => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| document.getElementById('patientUploadZone').style.display = 'none'; | |
| document.getElementById('patient-results').style.display = 'block'; | |
| document.getElementById('p-verdict-desc').innerText = 'Analyzing your scan... Please wait.'; | |
| const formData = new FormData(); | |
| formData.append('image', file); | |
| try { | |
| const response = await fetch('/explain', { method: 'POST', body: formData }); | |
| const data = await response.json(); | |
| window.currentPatientReport = data; | |
| // Verdict | |
| const vDesc = document.getElementById('p-verdict-desc'); | |
| const vCard = document.getElementById('patient-verdict-card'); | |
| if (data.verdict === 'no_tumor') { | |
| vDesc.innerText = '✅ No abnormalities detected. Your scan appears clear.'; | |
| vCard.className = 'result-card status-negative'; | |
| } else { | |
| vDesc.innerText = '⚠️ A region requiring medical review was detected. Do not panic, but please consult your doctor.'; | |
| vCard.className = 'result-card status-positive'; | |
| } | |
| // Risk | |
| const riskBadge = document.getElementById('patient-risk-badge'); | |
| const rDesc = document.getElementById('p-risk-desc'); | |
| const riskVal = data.risk_score || 0; | |
| if (data.verdict === 'no_tumor') { | |
| riskBadge.innerText = 'Low Risk'; | |
| riskBadge.style.backgroundColor = '#10b981'; | |
| rDesc.innerText = 'Routine checkups recommended.'; | |
| } else { | |
| riskBadge.innerText = riskVal > 60 ? 'High Risk' : 'Medium Risk'; | |
| riskBadge.style.backgroundColor = riskVal > 60 ? '#ef4444' : '#f39c12'; | |
| rDesc.innerText = 'Medical review strongly advised.'; | |
| } | |
| // Follow up | |
| document.getElementById('p-followup-desc').innerText = data.follow_up || (data.verdict === 'no_tumor' ? 'Maintain a healthy lifestyle and schedule routine checkups.' : 'Please schedule an appointment with a neurologist or oncologist to review these scan results.'); | |
| } catch (err) { | |
| document.getElementById('p-verdict-desc').innerText = 'Error analyzing scan. Please try again or ask your doctor.'; | |
| } | |
| }); | |
| } | |
| }); | |
| async function sendPatientChat() { | |
| const input = document.getElementById('patientChatInput'); | |
| const msg = input.value.trim(); | |
| if (!msg) return; | |
| const windowEl = document.getElementById('patientChatWindow'); | |
| windowEl.innerHTML += `<div class="chat-msg chat-user">${msg}</div>`; | |
| input.value = ''; | |
| let context = 'No scan data uploaded yet.'; | |
| if (window.currentPatientReport) { | |
| context = `Verdict: ${window.currentPatientReport.verdict}, Confidence: ${window.currentPatientReport.confidence}, Risk: ${window.currentPatientReport.risk_score}, Follow-up: ${window.currentPatientReport.follow_up}`; | |
| } | |
| try { | |
| const res = await fetch('/patient_chat', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ message: msg, context: context }) | |
| }); | |
| const data = await res.json(); | |
| windowEl.innerHTML += `<div class="chat-msg chat-ai">${data.reply}</div>`; | |
| windowEl.scrollTop = windowEl.scrollHeight; | |
| } catch (e) { | |
| windowEl.innerHTML += `<div class="chat-msg chat-ai">Sorry, the AI is offline right now.</div>`; | |
| } | |
| } | |
| async function downloadPatientPDF() { | |
| if (!window.currentPatientReport) { | |
| alert("Please run an analysis first."); | |
| return; | |
| } | |
| try { | |
| const r = window.currentPatientReport; | |
| const res = await fetch('/generate_pdf', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| confidence: r.confidence, | |
| verdict: r.verdict, | |
| volume_cm3: r.volume_cm3, | |
| risk_level: r.verdict === 'no_tumor' ? 'Low Risk' : (r.risk_score > 60 ? 'High Risk' : 'Medium Risk'), | |
| risk_score: r.risk_score, | |
| follow_up: r.follow_up | |
| }) | |
| }); | |
| if (!res.ok) throw new Error('PDF generation failed'); | |
| const blob = await res.blob(); | |
| const url = window.URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.style.display = 'none'; | |
| a.href = url; | |
| a.download = 'Tri_Netra_Report.pdf'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| window.URL.revokeObjectURL(url); | |
| } catch (e) { | |
| console.error(e); | |
| alert('Failed to generate PDF report. Please try again.'); | |
| } | |
| } | |