""" app/gradio_app.py ------------------ Gradio demo dashboard for ChestViT — Explainable Chest X-Ray Analysis. Features: ┌────────────────────────────────────────────────────────────────┐ │ Upload X-Ray │ Attention Rollout Heatmap Overlay │ ├────────────────────────────────────────────────────────────────┤ │ CLAHE Preview │ Disease Probability Bar Chart (14 classes) │ └────────────────────────────────────────────────────────────────┘ + Top diagnoses summary text + Model info sidebar + MLflow metrics link Run locally: python app/gradio_app.py Requirements: - Trained model checkpoint at checkpoints/best_model.pt - OR set DEMO_MODE=1 to run with random weights for UI preview """ import os import sys import time from pathlib import Path from huggingface_hub import hf_hub_download import torch # Fix Windows console encoding (cp1252 can't handle emoji/Unicode) if sys.platform == "win32": sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stderr.reconfigure(encoding="utf-8", errors="replace") import numpy as np import torch import gradio as gr from explainability.gradcam import generate_gradcam_heatmap import numpy as np import cv2 import matplotlib matplotlib.use("Agg") # Non-interactive backend for server use import matplotlib.pyplot as plt import matplotlib.patches as mpatches from PIL import Image from config_loader import load_config from data.preprocessing import load_and_preprocess_raw, get_val_transforms, denormalize, apply_clahe from models.vit_model import ChestViT, load_checkpoint from explainability.attention_rollout import explain_prediction, rollout_to_heatmap from data.dataset import DISEASE_LABELS # ── Config ──────────────────────────────────────────────────────────────────── cfg = load_config() DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") # ── CSS Styling ─────────────────────────────────────────────────────────────── CUSTOM_CSS = """ :root { --primary: #6366f1; --primary-dark: #4f46e5; --surface: #1e1e2e; --surface-2: #2a2a3e; --text: #e2e8f0; --text-muted: #94a3b8; --accent: #22d3ee; --danger: #ef4444; --warning: #f97316; --success: #22c55e; --border: rgba(99, 102, 241, 0.25); } body, .gradio-container { background: #0f0f1a !important; font-family: 'Inter', 'Segoe UI', sans-serif; } .gr-form, .gr-panel { background: var(--surface) !important; border: 1px solid var(--border) !important; border-radius: 16px !important; } .gr-button-primary { background: linear-gradient(135deg, var(--primary), var(--primary-dark)) !important; border: none !important; border-radius: 10px !important; font-weight: 600 !important; letter-spacing: 0.5px !important; box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4) !important; transition: all 0.2s ease !important; } .gr-button-primary:hover { transform: translateY(-1px) !important; box-shadow: 0 6px 20px rgba(99, 102, 241, 0.6) !important; } label, .label-wrap span { color: var(--text-muted) !important; font-size: 0.85rem !important; font-weight: 500 !important; text-transform: uppercase !important; letter-spacing: 0.5px !important; } h1, h2, h3 { color: var(--text) !important; } .header-title { font-size: 2.2rem; font-weight: 800; background: linear-gradient(135deg, #6366f1, #22d3ee, #22c55e); -webkit-background-clip: text; -webkit-text-fill-color: transparent; text-align: center; margin-bottom: 0.5rem; } .header-sub { color: var(--text-muted); text-align: center; font-size: 0.95rem; margin-bottom: 1.5rem; } .stat-card { background: var(--surface-2); border: 1px solid var(--border); border-radius: 12px; padding: 12px 16px; margin: 4px; text-align: center; } """ HEADER_HTML = """
🫁 ChestViT — Explainable X-Ray AI
ViT-Base-16 · 14-Disease Multi-Label Classification · Attention Rollout Explainability
🤖 google/vit-base-patch16-224-in21k 📊 NIH ChestX-ray14 Dataset 🔥 Attention Rollout XAI
""" FOOTER_HTML = """
⚠️ Research / Educational Use Only. This tool is NOT a medical device and should NOT be used for clinical diagnosis. Always consult a qualified radiologist.
""" # ── Model Loading ───────────────────────────────────────────────────────────── def load_model() -> ChestViT: """Load trained model from Hugging Face Model Hub.""" ckpt_path = hf_hub_download( repo_id="sandy45/ChestViT-ViTBase-NIH14", filename="best_model.pt" ) model = load_checkpoint(ckpt_path, DEVICE) model.to(DEVICE) model.eval() print("🔥 Loaded trained model from Hugging Face Hub") return model # Load model once at startup print(f"\nLoading model on {DEVICE}...") MODEL = load_model() VAL_TRANSFORM = get_val_transforms(cfg.dataset.image_size) print("Model ready.\n") # ── Inference Pipeline ──────────────────────────────────────────────────────── def preprocess_uploaded_image(pil_image: Image.Image) -> tuple[np.ndarray, torch.Tensor]: """ Convert a PIL image (from Gradio upload) to: 1. CLAHE-enhanced numpy array for display 2. Normalized tensor for model input Returns: (clahe_rgb, input_tensor) where: clahe_rgb: (H, W, 3) uint8 numpy array input_tensor: (1, 3, 224, 224) float32 tensor """ # Convert to numpy img_np = np.array(pil_image.convert("RGB")) # To grayscale → CLAHE → back to RGB gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) if gray.dtype == np.uint16: gray = (gray / 256).astype(np.uint8) clahe_gray = apply_clahe(gray, clip_limit=2.0, tile_size=8) clahe_gray = cv2.resize(clahe_gray, (224, 224), interpolation=cv2.INTER_AREA) clahe_rgb = cv2.cvtColor(clahe_gray, cv2.COLOR_GRAY2RGB) # Normalize for model augmented = VAL_TRANSFORM(image=clahe_rgb) tensor = augmented["image"].unsqueeze(0) # (1, 3, 224, 224) return clahe_rgb, tensor def make_probability_figure(probs: np.ndarray, threshold: float = 0.5) -> plt.Figure: """ Create a beautiful dark-themed horizontal bar chart of disease probabilities. """ sorted_idx = np.argsort(probs) # ascending for bottom-to-top barh sorted_probs = probs[sorted_idx] sorted_names = [DISEASE_LABELS[i] for i in sorted_idx] fig, ax = plt.subplots(figsize=(7, 6)) fig.patch.set_facecolor("#0f0f1a") ax.set_facecolor("#1a1a2e") # Color code by probability colors = [] for p in sorted_probs: if p >= 0.7: colors.append("#ef4444") # Red — high confidence positive elif p >= 0.5: colors.append("#f97316") # Orange — positive elif p >= 0.3: colors.append("#eab308") # Yellow — uncertain else: colors.append("#3b82f6") # Blue — likely negative bars = ax.barh(range(len(sorted_names)), sorted_probs, color=colors, edgecolor="none", height=0.65) # Threshold line ax.axvline(x=threshold, color="#a855f7", linestyle="--", linewidth=1.5, alpha=0.8, label=f"Threshold ({threshold})") # Labels ax.set_yticks(range(len(sorted_names))) ax.set_yticklabels(sorted_names, color="#e2e8f0", fontsize=9.5) ax.set_xlabel("Probability", color="#94a3b8", fontsize=10) ax.set_title("Disease Probability Scores", color="white", fontsize=12, fontweight="bold", pad=12) ax.set_xlim(0, 1.0) ax.tick_params(axis="x", colors="#94a3b8", labelsize=9) # Value labels on bars for bar, p in zip(bars, sorted_probs): ax.text(min(p + 0.02, 0.95), bar.get_y() + bar.get_height() / 2, f"{p:.3f}", va="center", color="white", fontsize=8.5, fontweight="bold") # Legend patches = [ mpatches.Patch(color="#ef4444", label="High confidence (≥0.7)"), mpatches.Patch(color="#f97316", label="Positive (≥0.5)"), mpatches.Patch(color="#eab308", label="Uncertain (0.3–0.5)"), mpatches.Patch(color="#3b82f6", label="Likely negative (<0.3)"), ] ax.legend(handles=patches, loc="lower right", fontsize=7.5, facecolor="#0f0f1a", labelcolor="white", framealpha=0.8) for spine in ax.spines.values(): spine.set_edgecolor("#2d2d4e") plt.tight_layout() return fig def make_heatmap_figure( clahe_rgb: np.ndarray, rollout: np.ndarray, overlay: np.ndarray, ) -> plt.Figure: """ 3-panel figure: original | raw rollout | overlay. """ fig, axes = plt.subplots(1, 3, figsize=(12, 4.5)) fig.patch.set_facecolor("#0f0f1a") titles = ["CLAHE-Enhanced X-Ray", "Attention Rollout Map", "Heatmap Overlay"] for ax, title in zip(axes, titles): ax.set_facecolor("#1a1a2e") ax.set_title(title, color="white", fontsize=10, fontweight="bold", pad=8) ax.axis("off") axes[0].imshow(clahe_rgb) axes[0].text(5, 218, "Input", color="#94a3b8", fontsize=8, va="bottom", ha="left", fontweight="bold") rollout_display = cv2.resize(rollout, (224, 224), interpolation=cv2.INTER_CUBIC) im = axes[1].imshow(rollout_display, cmap="inferno", vmin=0, vmax=1) plt.colorbar(im, ax=axes[1], fraction=0.046, pad=0.04, label="Attention Weight") axes[2].imshow(overlay) axes[2].text(5, 218, "ViT Attention Rollout", color="white", fontsize=7.5, va="bottom", ha="left", bbox=dict(boxstyle="round,pad=2", facecolor="#0f0f1a", alpha=0.7)) plt.tight_layout(pad=1.5) return fig def analyze_xray( pil_image: Image.Image, head_fusion: str, discard_ratio: float, threshold: float, explainability_method: str = "Attention Rollout", target_disease: str = "None (Highest Score)", ) -> tuple: """ Main inference function called by Gradio. Returns: (heatmap_figure, prob_figure, diagnosis_text, status_text) """ if pil_image is None: return None, None, "⬆ Please upload a chest X-ray image.", "" start_time = time.time() try: # Preprocess clahe_rgb, input_tensor = preprocess_uploaded_image(pil_image) # Inference model_was_training = MODEL.training MODEL.eval() with torch.no_grad(): logits, attentions = MODEL(input_tensor.to(DEVICE), output_attentions=True) probs = torch.sigmoid(logits).squeeze().cpu().numpy() target_idx = np.argmax(probs) if target_disease != "None (Highest Score)" and target_disease in DISEASE_LABELS: target_idx = DISEASE_LABELS.index(target_disease) if explainability_method == "Attention Rollout": with torch.no_grad(): _, rollout, overlay = explain_prediction( model=MODEL, image_tensor=input_tensor, original_image=clahe_rgb, device=DEVICE, disease_names=DISEASE_LABELS, head_fusion=head_fusion, discard_ratio=discard_ratio, ) else: # Grad-CAM MODEL.zero_grad() overlay = generate_gradcam_heatmap( model=MODEL, image_tensor=input_tensor.to(DEVICE), target_class=target_idx, original_image=clahe_rgb ) # Create a dummy rollout to satisfy the function if Grad-CAM rollout = np.zeros((14, 14)) if model_was_training: MODEL.train() elapsed = time.time() - start_time # Build heatmap figure heatmap_fig = make_heatmap_figure(clahe_rgb, rollout, overlay) # Build probability figure prob_fig = make_probability_figure(probs, threshold=threshold) # Build diagnosis summary text positives = [ (DISEASE_LABELS[i], probs[i]) for i in range(14) if probs[i] >= threshold ] positives.sort(key=lambda x: x[1], reverse=True) if positives: diag_lines = [f"### 🔴 Detected Findings (confidence ≥ {threshold:.0%})"] for disease, prob in positives: bar = "█" * int(prob * 20) + "░" * (20 - int(prob * 20)) diag_lines.append(f"**{disease}**: {bar} `{prob:.1%}`") else: diag_lines = [ f"### 🟢 No Findings Detected", f"All disease probabilities below threshold ({threshold:.0%}).", "This may indicate a normal chest X-ray.", ] diag_lines.append(f"\n---\n*Inference time: {elapsed:.2f}s · Device: {DEVICE}*") diag_text = "\n\n".join(diag_lines) status = ( f"✅ Analysis complete in {elapsed:.2f}s | " f"Device: {str(DEVICE).upper()} | " f"{"🔥 Trained ChestViT • AUROC 0.789"}" ) return heatmap_fig, prob_fig, diag_text, status except Exception as e: import traceback err = traceback.format_exc() return None, None, f"❌ Error during analysis:\n```\n{err}\n```", "Error" # ── Gradio Interface ────────────────────────────────────────────────────────── def build_interface() -> gr.Blocks: with gr.Blocks( title="ChestViT -- Explainable Chest X-Ray AI", theme=gr.themes.Base( primary_hue="indigo", secondary_hue="cyan", neutral_hue="slate", font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif"], ), css=CUSTOM_CSS, ) as demo: # Header gr.HTML(HEADER_HTML) with gr.Row(): # ── Left Column: Input ─────────────────────────────────────────── with gr.Column(scale=1, min_width=300): gr.Markdown("### 📤 Upload Chest X-Ray") image_input = gr.Image( type="pil", label="Chest X-Ray (PNG/JPEG/DICOM-exported PNG)", height=280, sources=["upload", "clipboard"], ) gr.Markdown("### ⚙️ Explainability Settings") with gr.Group(): explainability_method = gr.Radio( choices=["Attention Rollout", "Grad-CAM"], value="Attention Rollout", label="Explainability Method", info="Choose between Transformer-native Attention Rollout or Grad-CAM" ) target_disease = gr.Dropdown( choices=["None (Highest Score)"] + DISEASE_LABELS, value="None (Highest Score)", label="Target Disease for Grad-CAM", info="Forces Grad-CAM to explain this specific disease" ) head_fusion = gr.Radio( choices=["mean", "max", "min"], value="mean", label="Attention Head Fusion", info="[Attention Rollout] How to combine 12 attention heads into one map", ) discard_ratio = gr.Slider( minimum=0.0, maximum=0.99, value=0.9, step=0.05, label="Low-Attention Discard Ratio", info="[Attention Rollout] Zeroes out lowest-attention patches (noise reduction)", ) threshold = gr.Slider( minimum=0.1, maximum=0.9, value=0.5, step=0.05, label="Prediction Threshold", info="Sigmoid probability cutoff for positive prediction", ) analyze_btn = gr.Button( "🔬 Analyze X-Ray", variant="primary", size="lg", ) status_text = gr.Textbox( label="Status", interactive=False, show_label=True, max_lines=2, ) # Sample images info gr.Markdown( """ > **💡 Tips** > - Use frontal (PA or AP) chest X-ray images > - PNG or JPEG format accepted > - Best results with 1024×1024 pixel images > - Works with exported DICOM screenshots """ ) # ── Right Column: Output ───────────────────────────────────────── with gr.Column(scale=2, min_width=600): gr.Markdown("### 🔥 Attention Rollout Visualization") heatmap_output = gr.Plot( label="Attention Rollout Analysis", show_label=False, ) gr.Markdown("### 📊 Disease Probability Scores") prob_output = gr.Plot( label="Disease Probabilities", show_label=False, ) gr.Markdown("### 🩺 Diagnosis Summary") diagnosis_output = gr.Markdown( value="*Upload an X-ray and click Analyze to see results.*" ) # ── How It Works ────────────────────────────────────────────────────── with gr.Accordion("📖 How It Works", open=False): gr.Markdown(""" ## Architecture | Component | Details | |---|---| | **Model** | ViT-Base-16 (google/vit-base-patch16-224-in21k) | | **Pre-training** | ImageNet-21k (14M images, 21K classes) | | **Fine-tuning** | NIH ChestX-ray14 (112,120 frontal X-rays) | | **Task** | Multi-label classification — 14 simultaneous disease predictions | | **Loss** | Weighted Binary Cross-Entropy (handles severe class imbalance) | | **Preprocessing** | CLAHE contrast enhancement → Albumentations augmentation | | **Explainability** | Attention Rollout (Abnar & Zuidema, 2020) | ## Attention Rollout Algorithm Standard Grad-CAM doesn't work well with pure Vision Transformers because ViTs don't have intermediate spatial feature maps like CNNs. **Attention Rollout** instead: 1. Extracts raw attention weights from all 12 transformer layers 2. Averages across all 12 attention heads per layer 3. Adds an identity matrix (modeling residual/skip connections) 4. Re-normalizes each row 5. Multiplies all 12 matrices in sequence → propagates attention end-to-end 6. Reads the `[CLS]` token row → shows which 14×14 patches it attends to 7. Upsamples 14×14 → 224×224 and overlays as a heatmap This shows **where in the X-ray the model is looking** when it makes each prediction. ## NIH Dataset — 14 Disease Labels ``` Atelectasis · Cardiomegaly · Effusion · Infiltration · Mass · Nodule Pneumonia · Pneumothorax · Consolidation · Edema · Emphysema Fibrosis · Pleural_Thickening · Hernia ``` ## References - Wang et al. (2017). *ChestX-ray8: Hospital-scale Chest X-ray Database and Benchmarks*. CVPR. - Dosovitskiy et al. (2021). *An Image is Worth 16x16 Words*. ICLR. - Abnar & Zuidema (2020). *Quantifying Attention Flow in Transformers*. arXiv:2005.00928. """) gr.HTML(FOOTER_HTML) # ── Event Binding ───────────────────────────────────────────────────── analyze_btn.click( fn=analyze_xray, inputs=[image_input, head_fusion, discard_ratio, threshold, explainability_method, target_disease], outputs=[heatmap_output, prob_output, diagnosis_output, status_text], api_name="analyze", ) # Also trigger on image upload (optional — comment out to disable auto-run) # image_input.change( # fn=analyze_xray, # inputs=[image_input, head_fusion, discard_ratio, threshold], # outputs=[heatmap_output, prob_output, diagnosis_output, status_text], # ) return demo # ── Entry Point ─────────────────────────────────────────────────────────────── if __name__ == "__main__": demo = build_interface() demo.launch( server_port=cfg.inference.gradio_port, share=cfg.inference.gradio_share, show_error=True, inbrowser=True, )