/** * 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 = ' 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 = 'Analyzing clinical data...'; 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 ${conf} 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 ${volume}. Compared to the historical baseline (-3 months), this represents a growth velocity of ${growth}.`; } 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 = ``; } if (segMask) segMask.innerHTML = 'Running U-Net...'; if (segOverlay) segOverlay.innerHTML = 'Running U-Net...'; 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 = ``; } if (!this.currentSegmentation) { segMask.innerHTML = 'Upload an image and click "Run Analysis" to see the U-Net mask.'; segOverlay.innerHTML = ''; return; } const seg = this.currentSegmentation; if (seg.error) { segMask.innerHTML = `Error: ${seg.error}`; segOverlay.innerHTML = ''; if (dice) dice.textContent = '--'; if (iou) iou.textContent = '--'; if (area) area.textContent = '--'; return; } const payload = seg.result || {}; if (payload.mask) { segMask.innerHTML = ``; } if (payload.overlay) { segOverlay.innerHTML = ``; } 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 = `${this.escapeHtml(e.verdict)}`; 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 = ` ${idx} ${this.escapeHtml(e.filename.length > 36 ? e.filename.slice(0, 33) + '...' : e.filename)} ${e.verdict === 'tumor' ? 'Tumor' : (e.verdict === 'no_tumor' ? 'No Tumor' : 'Mixed')} ${this.escapeHtml(this.getModelLabel(e.bestModel))} ${meanStr} ${stdStr} ${entStr} ${verdictBadge} ${this.escapeHtml(e.band || '')} ${e.elapsedSeconds}s `; 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 `

Layered Pipeline Output

-- --

Impression

--

Overall Confidence

--
--

--

Structured Findings

Grade-Evidence Score

--

Differential Diagnosis (citation-checked)

Recommendation

--

Classifier Agreement

--

LLM Pass Status

Disclaimer

Not a medical diagnosis. Research / educational only.

Polished summary (verified prose, may equal Impression if LLM polish rejected)

Raw deterministic features (JSON)

                
`; } 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]) => `
${label}
${this.escapeHtml(fmap[k])}
`) .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 = '
No differential hints returned.
'; } else { diff.innerHTML = items.map(d => { const origin = d.origin || 'rule-based'; const originLabel = origin === 'llm-citation-checked' ? 'LLM · citation-checked' : 'Rule-based'; const confTag = d.confidence ? `${this.escapeHtml(d.confidence)}` : ''; const cites = (d.supported_by || []).map(c => `${this.escapeHtml(String(c))}` ).join(' '); return `
${originLabel}${confTag}
${this.escapeHtml(d.statement || '')}
Supported by: ${cites || '(no citations)'}
`; }).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 `
  • ${region} — ${text} [${claim}]
  • `; }).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 `
  • ${text} conflicts with: ${conflicts}
  • `; }).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 ? ` ${this.escapeHtml(p.model)}` : ''; let detail = ''; if (status === 'error' || status === 'skipped_insufficient_ram') { detail = `
    ${this.escapeHtml(p.error || p.recovery_hint || '')}
    `; } else if (status === 'rejected') { detail = `
    Rejected: ${this.escapeHtml((p.warnings || []).join('; '))}
    `; } else if (status === 'ok' && k === 'differential_expansion') { detail = `
    Accepted ${p.accepted_count || 0} · Rejected ${p.rejected_count || 0}
    `; } else if (status === 'ok' && k === 'visual_observer') { detail = `
    ${p.observation_count || 0} observations · ${p.disagreement_count || 0} disagreements
    `; } else if (status === 'skipped') { detail = `
    ${this.escapeHtml(p.reason || 'skipped')}
    `; } return `
    ${labels[k]}${model}
    ${this.escapeHtml(status)}
    ${detail}
    `; }).join(''); passesEl.innerHTML = items || '
    No LLM passes run.
    '; } // --- 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 => `
  • ${this.escapeHtml(w)}
  • `).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, '''); } 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 = `
    ${this.currentResults.models.map(model => { const fmt = (v) => (v == null || Number.isNaN(v)) ? 'N/A' : `${(v * 100).toFixed(1)}%`; return ` `; }).join('')}
    Model Prediction Confidence Accuracy ROC AUC Status
    ${model.modelLabel} ${model.prediction} ${fmt(model.confidence)} ${fmt(model.accuracy)} ${fmt(model.auc)} ● ${model.status === 'positive' ? 'Positive' : 'Negative'}
    `; } } showModelDetails() { const content = document.getElementById('comparisonContent'); if (this.currentResults) { content.innerHTML = `
    ${this.currentResults.models.map(model => { const fmt = (v) => (v == null || Number.isNaN(v)) ? 'N/A' : `${(v * 100).toFixed(1)}%`; return `

    ${model.modelLabel}

    Confidence
    ${fmt(model.confidence)}
    Accuracy
    ${fmt(model.accuracy)}
    ROC AUC
    ${fmt(model.auc)}
    Prediction
    ● ${model.prediction}
    `; }).join('')}
    `; } } 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 = ` ${this.escapeHtml(title)}

    ${this.escapeHtml(description || '')}

    `; 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(`
    Inference Runtime ${ortOk ? `ONNX ${provider}` : 'PyTorch'}
    `); // Loaded sessions rows.push(`
    Loaded Sessions ${ort.sessions_loaded ?? 0}
    `); // 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(`
    GPU Memory ${usedGb} / ${totalGb} GB
    `); } else { rows.push(`
    GPU CPU mode
    `); } // 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(`
    Classifiers Ready ${clsCount} / 3
    `); // Segmentation const segs = s.segmentation_models || []; const segCount = segs.filter(m => m.onnx || m.pt_size_mb).length; rows.push(`
    Segmentation ${segCount} model${segCount === 1 ? '' : 's'}
    `); // 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(`
    LLM Explanation ${llmStatus}
    `); 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 = `
    Server Unreachable --
    `; } } } /** * 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 = '
    No scans yet. Upload an MRI to begin.
    '; return; } el.innerHTML = items.map(s => { const tumor = s.isPositive; const ago = this.formatRelativeTime(s.timestamp); return `
    ${tumor ? 'T' : 'N'}
    ${this.escapeHtml(s.id)} ${ago}
    ${tumor ? 'Tumor' : 'Normal'}
    `; }).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 += `
    ${msg}
    `; 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 += `
    ${data.reply}
    `; windowEl.scrollTop = windowEl.scrollHeight; } catch (e) { windowEl.innerHTML += `
    Sorry, the AI is offline right now.
    `; } } 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.'); } }