CoolFace
Apppublic

sandy45/ChestViT-Explainable-XRay-AI

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
app.py612 linesDownload Raw Back to root
1"""
2app/gradio_app.py
3------------------
4Gradio demo dashboard for ChestViT — Explainable Chest X-Ray Analysis.
5
6Features:
7  ┌────────────────────────────────────────────────────────────────┐
8  │  Upload X-Ray  │  Attention Rollout Heatmap Overlay           │
9  ├────────────────────────────────────────────────────────────────┤
10  │  CLAHE Preview │  Disease Probability Bar Chart (14 classes)  │
11  └────────────────────────────────────────────────────────────────┘
12
13  + Top diagnoses summary text
14  + Model info sidebar
15  + MLflow metrics link
16
17Run locally:
18  python app/gradio_app.py
19
20Requirements:
21  - Trained model checkpoint at checkpoints/best_model.pt
22  - OR set DEMO_MODE=1 to run with random weights for UI preview
23"""
24
25import os
26import sys
27import time
28from pathlib import Path
29from huggingface_hub import hf_hub_download
30import torch
31
32# Fix Windows console encoding (cp1252 can't handle emoji/Unicode)
33if sys.platform == "win32":
34    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
35    sys.stderr.reconfigure(encoding="utf-8", errors="replace")
36
37import numpy as np
38import torch
39import gradio as gr
40from explainability.gradcam import generate_gradcam_heatmap
41import numpy as np
42import cv2
43import matplotlib
44matplotlib.use("Agg")  # Non-interactive backend for server use
45import matplotlib.pyplot as plt
46import matplotlib.patches as mpatches
47from PIL import Image
48
49
50
51from config_loader import load_config
52from data.preprocessing import load_and_preprocess_raw, get_val_transforms, denormalize, apply_clahe
53from models.vit_model import ChestViT, load_checkpoint
54from explainability.attention_rollout import explain_prediction, rollout_to_heatmap
55from data.dataset import DISEASE_LABELS
56
57# ── Config ────────────────────────────────────────────────────────────────────
58cfg = load_config()
59DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
60
61
62
63# ── CSS Styling ───────────────────────────────────────────────────────────────
64CUSTOM_CSS = """
65:root {
66    --primary:    #6366f1;
67    --primary-dark: #4f46e5;
68    --surface:    #1e1e2e;
69    --surface-2:  #2a2a3e;
70    --text:       #e2e8f0;
71    --text-muted: #94a3b8;
72    --accent:     #22d3ee;
73    --danger:     #ef4444;
74    --warning:    #f97316;
75    --success:    #22c55e;
76    --border:     rgba(99, 102, 241, 0.25);
77}
78
79body, .gradio-container {
80    background: #0f0f1a !important;
81    font-family: 'Inter', 'Segoe UI', sans-serif;
82}
83
84.gr-form, .gr-panel {
85    background: var(--surface) !important;
86    border: 1px solid var(--border) !important;
87    border-radius: 16px !important;
88}
89
90.gr-button-primary {
91    background: linear-gradient(135deg, var(--primary), var(--primary-dark)) !important;
92    border: none !important;
93    border-radius: 10px !important;
94    font-weight: 600 !important;
95    letter-spacing: 0.5px !important;
96    box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4) !important;
97    transition: all 0.2s ease !important;
98}
99.gr-button-primary:hover {
100    transform: translateY(-1px) !important;
101    box-shadow: 0 6px 20px rgba(99, 102, 241, 0.6) !important;
102}
103
104label, .label-wrap span {
105    color: var(--text-muted) !important;
106    font-size: 0.85rem !important;
107    font-weight: 500 !important;
108    text-transform: uppercase !important;
109    letter-spacing: 0.5px !important;
110}
111
112h1, h2, h3 { color: var(--text) !important; }
113
114.header-title {
115    font-size: 2.2rem;
116    font-weight: 800;
117    background: linear-gradient(135deg, #6366f1, #22d3ee, #22c55e);
118    -webkit-background-clip: text;
119    -webkit-text-fill-color: transparent;
120    text-align: center;
121    margin-bottom: 0.5rem;
122}
123.header-sub {
124    color: var(--text-muted);
125    text-align: center;
126    font-size: 0.95rem;
127    margin-bottom: 1.5rem;
128}
129.stat-card {
130    background: var(--surface-2);
131    border: 1px solid var(--border);
132    border-radius: 12px;
133    padding: 12px 16px;
134    margin: 4px;
135    text-align: center;
136}
137"""
138
139HEADER_HTML = """
140<div style="text-align:center; padding: 20px 0 10px 0;">
141    <div style="font-size:2.4rem; font-weight:800; background:linear-gradient(135deg,#6366f1,#22d3ee,#22c55e);
142                -webkit-background-clip:text; -webkit-text-fill-color:transparent;">
143        🫁 ChestViT — Explainable X-Ray AI
144    </div>
145    <div style="color:#94a3b8; font-size:0.95rem; margin-top:8px;">
146        ViT-Base-16 · 14-Disease Multi-Label Classification · Attention Rollout Explainability
147    </div>
148    <div style="display:flex; justify-content:center; gap:16px; margin-top:14px; flex-wrap:wrap;">
149        <span style="background:#1e1e2e; border:1px solid rgba(99,102,241,0.3); border-radius:8px;
150                     padding:6px 14px; color:#a5b4fc; font-size:0.82rem; font-weight:600;">
151            🤖 google/vit-base-patch16-224-in21k
152        </span>
153        <span style="background:#1e1e2e; border:1px solid rgba(34,211,238,0.3); border-radius:8px;
154                     padding:6px 14px; color:#67e8f9; font-size:0.82rem; font-weight:600;">
155            📊 NIH ChestX-ray14 Dataset
156        </span>
157        <span style="background:#1e1e2e; border:1px solid rgba(34,197,94,0.3); border-radius:8px;
158                     padding:6px 14px; color:#86efac; font-size:0.82rem; font-weight:600;">
159            🔥 Attention Rollout XAI
160        </span>
161    </div>
162</div>
163"""
164
165FOOTER_HTML = """
166<div style="text-align:center; color:#475569; font-size:0.8rem; padding:16px 0 8px 0; border-top:1px solid rgba(99,102,241,0.15); margin-top:16px;">
167    ⚠️ <strong>Research / Educational Use Only.</strong>
168    This tool is NOT a medical device and should NOT be used for clinical diagnosis.
169    Always consult a qualified radiologist.
170</div>
171"""
172
173
174# ── Model Loading ─────────────────────────────────────────────────────────────
175
176def load_model() -> ChestViT:
177    """Load trained model from Hugging Face Model Hub."""
178
179    ckpt_path = hf_hub_download(
180        repo_id="sandy45/ChestViT-ViTBase-NIH14",
181        filename="best_model.pt"
182    )
183
184    model = load_checkpoint(ckpt_path, DEVICE)
185
186    model.to(DEVICE)
187    model.eval()
188
189    print("🔥 Loaded trained model from Hugging Face Hub")
190
191    return model
192
193
194# Load model once at startup
195print(f"\nLoading model on {DEVICE}...")
196MODEL = load_model()
197VAL_TRANSFORM = get_val_transforms(cfg.dataset.image_size)
198print("Model ready.\n")
199
200
201# ── Inference Pipeline ────────────────────────────────────────────────────────
202
203def preprocess_uploaded_image(pil_image: Image.Image) -> tuple[np.ndarray, torch.Tensor]:
204    """
205    Convert a PIL image (from Gradio upload) to:
206      1. CLAHE-enhanced numpy array for display
207      2. Normalized tensor for model input
208
209    Returns:
210        (clahe_rgb, input_tensor) where:
211          clahe_rgb: (H, W, 3) uint8 numpy array
212          input_tensor: (1, 3, 224, 224) float32 tensor
213    """
214    # Convert to numpy
215    img_np = np.array(pil_image.convert("RGB"))
216
217    # To grayscale → CLAHE → back to RGB
218    gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
219    if gray.dtype == np.uint16:
220        gray = (gray / 256).astype(np.uint8)
221    clahe_gray = apply_clahe(gray, clip_limit=2.0, tile_size=8)
222    clahe_gray = cv2.resize(clahe_gray, (224, 224), interpolation=cv2.INTER_AREA)
223    clahe_rgb = cv2.cvtColor(clahe_gray, cv2.COLOR_GRAY2RGB)
224
225    # Normalize for model
226    augmented = VAL_TRANSFORM(image=clahe_rgb)
227    tensor = augmented["image"].unsqueeze(0)  # (1, 3, 224, 224)
228
229    return clahe_rgb, tensor
230
231
232def make_probability_figure(probs: np.ndarray, threshold: float = 0.5) -> plt.Figure:
233    """
234    Create a beautiful dark-themed horizontal bar chart of disease probabilities.
235    """
236    sorted_idx = np.argsort(probs)  # ascending for bottom-to-top barh
237    sorted_probs = probs[sorted_idx]
238    sorted_names = [DISEASE_LABELS[i] for i in sorted_idx]
239
240    fig, ax = plt.subplots(figsize=(7, 6))
241    fig.patch.set_facecolor("#0f0f1a")
242    ax.set_facecolor("#1a1a2e")
243
244    # Color code by probability
245    colors = []
246    for p in sorted_probs:
247        if p >= 0.7:
248            colors.append("#ef4444")   # Red — high confidence positive
249        elif p >= 0.5:
250            colors.append("#f97316")   # Orange — positive
251        elif p >= 0.3:
252            colors.append("#eab308")   # Yellow — uncertain
253        else:
254            colors.append("#3b82f6")   # Blue — likely negative
255
256    bars = ax.barh(range(len(sorted_names)), sorted_probs, color=colors,
257                   edgecolor="none", height=0.65)
258
259    # Threshold line
260    ax.axvline(x=threshold, color="#a855f7", linestyle="--", linewidth=1.5,
261               alpha=0.8, label=f"Threshold ({threshold})")
262
263    # Labels
264    ax.set_yticks(range(len(sorted_names)))
265    ax.set_yticklabels(sorted_names, color="#e2e8f0", fontsize=9.5)
266    ax.set_xlabel("Probability", color="#94a3b8", fontsize=10)
267    ax.set_title("Disease Probability Scores", color="white",
268                 fontsize=12, fontweight="bold", pad=12)
269    ax.set_xlim(0, 1.0)
270    ax.tick_params(axis="x", colors="#94a3b8", labelsize=9)
271
272    # Value labels on bars
273    for bar, p in zip(bars, sorted_probs):
274        ax.text(min(p + 0.02, 0.95), bar.get_y() + bar.get_height() / 2,
275                f"{p:.3f}", va="center", color="white", fontsize=8.5, fontweight="bold")
276
277    # Legend
278    patches = [
279        mpatches.Patch(color="#ef4444", label="High confidence (≥0.7)"),
280        mpatches.Patch(color="#f97316", label="Positive (≥0.5)"),
281        mpatches.Patch(color="#eab308", label="Uncertain (0.3–0.5)"),
282        mpatches.Patch(color="#3b82f6", label="Likely negative (<0.3)"),
283    ]
284    ax.legend(handles=patches, loc="lower right", fontsize=7.5,
285              facecolor="#0f0f1a", labelcolor="white", framealpha=0.8)
286
287    for spine in ax.spines.values():
288        spine.set_edgecolor("#2d2d4e")
289
290    plt.tight_layout()
291    return fig
292
293
294def make_heatmap_figure(
295    clahe_rgb: np.ndarray,
296    rollout: np.ndarray,
297    overlay: np.ndarray,
298) -> plt.Figure:
299    """
300    3-panel figure: original | raw rollout | overlay.
301    """
302    fig, axes = plt.subplots(1, 3, figsize=(12, 4.5))
303    fig.patch.set_facecolor("#0f0f1a")
304
305    titles = ["CLAHE-Enhanced X-Ray", "Attention Rollout Map", "Heatmap Overlay"]
306    for ax, title in zip(axes, titles):
307        ax.set_facecolor("#1a1a2e")
308        ax.set_title(title, color="white", fontsize=10, fontweight="bold", pad=8)
309        ax.axis("off")
310
311    axes[0].imshow(clahe_rgb)
312    axes[0].text(5, 218, "Input", color="#94a3b8", fontsize=8,
313                 va="bottom", ha="left", fontweight="bold")
314
315    rollout_display = cv2.resize(rollout, (224, 224), interpolation=cv2.INTER_CUBIC)
316    im = axes[1].imshow(rollout_display, cmap="inferno", vmin=0, vmax=1)
317    plt.colorbar(im, ax=axes[1], fraction=0.046, pad=0.04,
318                 label="Attention Weight")
319
320    axes[2].imshow(overlay)
321    axes[2].text(5, 218, "ViT Attention Rollout", color="white",
322                 fontsize=7.5, va="bottom", ha="left",
323                 bbox=dict(boxstyle="round,pad=2", facecolor="#0f0f1a", alpha=0.7))
324
325    plt.tight_layout(pad=1.5)
326    return fig
327
328
329def analyze_xray(
330    pil_image: Image.Image,
331    head_fusion: str,
332    discard_ratio: float,
333    threshold: float,
334    explainability_method: str = "Attention Rollout",
335    target_disease: str = "None (Highest Score)",
336) -> tuple:
337    """
338    Main inference function called by Gradio.
339
340    Returns:
341        (heatmap_figure, prob_figure, diagnosis_text, status_text)
342    """
343    if pil_image is None:
344        return None, None, "⬆ Please upload a chest X-ray image.", ""
345
346    start_time = time.time()
347
348    try:
349        # Preprocess
350        clahe_rgb, input_tensor = preprocess_uploaded_image(pil_image)
351
352        # Inference
353        model_was_training = MODEL.training
354        MODEL.eval()
355
356        with torch.no_grad():
357            logits, attentions = MODEL(input_tensor.to(DEVICE), output_attentions=True)
358            probs = torch.sigmoid(logits).squeeze().cpu().numpy()
359
360        target_idx = np.argmax(probs)
361        if target_disease != "None (Highest Score)" and target_disease in DISEASE_LABELS:
362            target_idx = DISEASE_LABELS.index(target_disease)
363
364        if explainability_method == "Attention Rollout":
365            with torch.no_grad():
366                _, rollout, overlay = explain_prediction(
367                    model=MODEL,
368                    image_tensor=input_tensor,
369                    original_image=clahe_rgb,
370                    device=DEVICE,
371                    disease_names=DISEASE_LABELS,
372                    head_fusion=head_fusion,
373                    discard_ratio=discard_ratio,
374                )
375        else: # Grad-CAM
376            MODEL.zero_grad()
377            overlay = generate_gradcam_heatmap(
378                model=MODEL,
379                image_tensor=input_tensor.to(DEVICE),
380                target_class=target_idx,
381                original_image=clahe_rgb
382            )
383            # Create a dummy rollout to satisfy the function if Grad-CAM
384            rollout = np.zeros((14, 14))
385
386        if model_was_training:
387            MODEL.train()
388
389
390        elapsed = time.time() - start_time
391
392        # Build heatmap figure
393        heatmap_fig = make_heatmap_figure(clahe_rgb, rollout, overlay)
394
395        # Build probability figure
396        prob_fig = make_probability_figure(probs, threshold=threshold)
397
398        # Build diagnosis summary text
399        positives = [
400            (DISEASE_LABELS[i], probs[i])
401            for i in range(14)
402            if probs[i] >= threshold
403        ]
404        positives.sort(key=lambda x: x[1], reverse=True)
405
406        if positives:
407            diag_lines = [f"### 🔴 Detected Findings (confidence ≥ {threshold:.0%})"]
408            for disease, prob in positives:
409                bar = "█" * int(prob * 20) + "░" * (20 - int(prob * 20))
410                diag_lines.append(f"**{disease}**: {bar} `{prob:.1%}`")
411        else:
412            diag_lines = [
413                f"### 🟢 No Findings Detected",
414                f"All disease probabilities below threshold ({threshold:.0%}).",
415                "This may indicate a normal chest X-ray.",
416            ]
417
418        diag_lines.append(f"\n---\n*Inference time: {elapsed:.2f}s · Device: {DEVICE}*")
419        diag_text = "\n\n".join(diag_lines)
420
421        status = (
422            f"✅ Analysis complete in {elapsed:.2f}s | "
423            f"Device: {str(DEVICE).upper()} | "
424            f"{"🔥 Trained ChestViT • AUROC 0.789"}"
425        )
426
427        return heatmap_fig, prob_fig, diag_text, status
428
429    except Exception as e:
430        import traceback
431        err = traceback.format_exc()
432        return None, None, f"❌ Error during analysis:\n```\n{err}\n```", "Error"
433
434
435# ── Gradio Interface ──────────────────────────────────────────────────────────
436
437def build_interface() -> gr.Blocks:
438    with gr.Blocks(
439        title="ChestViT -- Explainable Chest X-Ray AI",
440        theme=gr.themes.Base(
441            primary_hue="indigo",
442            secondary_hue="cyan",
443            neutral_hue="slate",
444            font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif"],
445        ),
446        css=CUSTOM_CSS,
447    ) as demo:
448
449        # Header
450        gr.HTML(HEADER_HTML)
451
452        with gr.Row():
453            # ── Left Column: Input ───────────────────────────────────────────
454            with gr.Column(scale=1, min_width=300):
455                gr.Markdown("### 📤 Upload Chest X-Ray")
456                image_input = gr.Image(
457                    type="pil",
458                    label="Chest X-Ray (PNG/JPEG/DICOM-exported PNG)",
459                    height=280,
460                    sources=["upload", "clipboard"],
461                )
462
463                gr.Markdown("### ⚙️ Explainability Settings")
464                with gr.Group():
465                    explainability_method = gr.Radio(
466                        choices=["Attention Rollout", "Grad-CAM"],
467                        value="Attention Rollout",
468                        label="Explainability Method",
469                        info="Choose between Transformer-native Attention Rollout or Grad-CAM"
470                    )
471                    target_disease = gr.Dropdown(
472                        choices=["None (Highest Score)"] + DISEASE_LABELS,
473                        value="None (Highest Score)",
474                        label="Target Disease for Grad-CAM",
475                        info="Forces Grad-CAM to explain this specific disease"
476                    )
477                    head_fusion = gr.Radio(
478                        choices=["mean", "max", "min"],
479                        value="mean",
480                        label="Attention Head Fusion",
481                        info="[Attention Rollout] How to combine 12 attention heads into one map",
482                    )
483                    discard_ratio = gr.Slider(
484                        minimum=0.0, maximum=0.99, value=0.9, step=0.05,
485                        label="Low-Attention Discard Ratio",
486                        info="[Attention Rollout] Zeroes out lowest-attention patches (noise reduction)",
487                    )
488                    threshold = gr.Slider(
489                        minimum=0.1, maximum=0.9, value=0.5, step=0.05,
490                        label="Prediction Threshold",
491                        info="Sigmoid probability cutoff for positive prediction",
492                    )
493
494                analyze_btn = gr.Button(
495                    "🔬 Analyze X-Ray",
496                    variant="primary",
497                    size="lg",
498                )
499                status_text = gr.Textbox(
500                    label="Status",
501                    interactive=False,
502                    show_label=True,
503                    max_lines=2,
504                )
505
506                # Sample images info
507                gr.Markdown(
508                    """
509                    > **💡 Tips**
510                    > - Use frontal (PA or AP) chest X-ray images
511                    > - PNG or JPEG format accepted
512                    > - Best results with 1024×1024 pixel images
513                    > - Works with exported DICOM screenshots
514                    """
515                )
516
517            # ── Right Column: Output ─────────────────────────────────────────
518            with gr.Column(scale=2, min_width=600):
519                gr.Markdown("### 🔥 Attention Rollout Visualization")
520                heatmap_output = gr.Plot(
521                    label="Attention Rollout Analysis",
522                    show_label=False,
523                )
524
525                gr.Markdown("### 📊 Disease Probability Scores")
526                prob_output = gr.Plot(
527                    label="Disease Probabilities",
528                    show_label=False,
529                )
530
531                gr.Markdown("### 🩺 Diagnosis Summary")
532                diagnosis_output = gr.Markdown(
533                    value="*Upload an X-ray and click Analyze to see results.*"
534                )
535
536        # ── How It Works ──────────────────────────────────────────────────────
537        with gr.Accordion("📖 How It Works", open=False):
538            gr.Markdown("""
539            ## Architecture
540
541            | Component | Details |
542            |---|---|
543            | **Model** | ViT-Base-16 (google/vit-base-patch16-224-in21k) |
544            | **Pre-training** | ImageNet-21k (14M images, 21K classes) |
545            | **Fine-tuning** | NIH ChestX-ray14 (112,120 frontal X-rays) |
546            | **Task** | Multi-label classification — 14 simultaneous disease predictions |
547            | **Loss** | Weighted Binary Cross-Entropy (handles severe class imbalance) |
548            | **Preprocessing** | CLAHE contrast enhancement → Albumentations augmentation |
549            | **Explainability** | Attention Rollout (Abnar & Zuidema, 2020) |
550
551            ## Attention Rollout Algorithm
552
553            Standard Grad-CAM doesn't work well with pure Vision Transformers because
554            ViTs don't have intermediate spatial feature maps like CNNs.
555
556            **Attention Rollout** instead:
557            1. Extracts raw attention weights from all 12 transformer layers
558            2. Averages across all 12 attention heads per layer
559            3. Adds an identity matrix (modeling residual/skip connections)
560            4. Re-normalizes each row
561            5. Multiplies all 12 matrices in sequence → propagates attention end-to-end
562            6. Reads the `[CLS]` token row → shows which 14×14 patches it attends to
563            7. Upsamples 14×14 → 224×224 and overlays as a heatmap
564
565            This shows **where in the X-ray the model is looking** when it makes each prediction.
566
567            ## NIH Dataset — 14 Disease Labels
568
569            ```
570            Atelectasis · Cardiomegaly · Effusion · Infiltration · Mass · Nodule
571            Pneumonia · Pneumothorax · Consolidation · Edema · Emphysema
572            Fibrosis · Pleural_Thickening · Hernia
573            ```
574
575            ## References
576
577            - Wang et al. (2017). *ChestX-ray8: Hospital-scale Chest X-ray Database and Benchmarks*. CVPR.
578            - Dosovitskiy et al. (2021). *An Image is Worth 16x16 Words*. ICLR.
579            - Abnar & Zuidema (2020). *Quantifying Attention Flow in Transformers*. arXiv:2005.00928.
580            """)
581
582        gr.HTML(FOOTER_HTML)
583
584        # ── Event Binding ─────────────────────────────────────────────────────
585        analyze_btn.click(
586            fn=analyze_xray,
587            inputs=[image_input, head_fusion, discard_ratio, threshold, explainability_method, target_disease],
588            outputs=[heatmap_output, prob_output, diagnosis_output, status_text],
589            api_name="analyze",
590        )
591
592        # Also trigger on image upload (optional — comment out to disable auto-run)
593        # image_input.change(
594        #     fn=analyze_xray,
595        #     inputs=[image_input, head_fusion, discard_ratio, threshold],
596        #     outputs=[heatmap_output, prob_output, diagnosis_output, status_text],
597        # )
598
599    return demo
600
601
602# ── Entry Point ───────────────────────────────────────────────────────────────
603
604if __name__ == "__main__":
605    demo = build_interface()
606    demo.launch(
607        server_port=cfg.inference.gradio_port,
608        share=cfg.inference.gradio_share,
609        show_error=True,
610        inbrowser=True,
611    )
612