""" BRACS WSI Explainability — Hugging Face Space (Gradio). Two input modes: • Upload a pre-extracted .h5 feature file → fast, always works. • Upload a whole slide (.svs/.tiff/.ndpi/.mrxs) → app extracts features with TITAN+CONCH (slow on CPU; needs HF_TOKEN secret). Outputs: prediction + a downloadable PDF explainability report. Research use only — NOT for clinical diagnosis. """ import os, tempfile, traceback import pickle import gradio as gr import engine # ---- Model source: private HF model repo (downloaded with HF_TOKEN) ---------- # Set these as Space env vars / secrets: # HF_TOKEN : token with read access to your private model repo (required) # MODEL_REPO_ID : e.g. "your-username/bracs-models" # MODEL_FILENAME : default "bracs_v2_model.pkl" # ABMIL_FILENAME : default "abmil_model.pkl" (optional) # MODEL_PATH : optional local override (skips download if file exists) MODEL_REPO_ID = os.environ.get("MODEL_REPO_ID", "jehadcheyi/bc_models") MODEL_FILENAME = os.environ.get("bc_models", "bracs_v2_model.pkl") ABMIL_FILENAME = os.environ.get("bc_models", "abmil_model.pkl") MODEL_PATH = os.environ.get("MODEL_PATH", "") # local fallback HF_TOKEN = os.environ.get("jj1", "").strip() or None THUMB_MAX = int(os.environ.get("THUMB_MAX", "4000")) ZOOM_CONTEXT = int(os.environ.get("ZOOM_CONTEXT", "4")) WSI_EXTS = (".svs", ".tif", ".tiff", ".ndpi", ".mrxs", ".scn", ".bif") def _resolve_model_path(): """Return a local path to the classifier .pkl, downloading from the private HF model repo if needed.""" # 1) explicit local override if MODEL_PATH and os.path.isfile(MODEL_PATH): return MODEL_PATH # 2) already sitting next to app.py (if user uploaded it into the Space) if os.path.isfile(MODEL_FILENAME): return MODEL_FILENAME # 3) download from the private model repo if not MODEL_REPO_ID: raise RuntimeError( "No model found. Set MODEL_REPO_ID (and HF_TOKEN) to your private " "HF model repo, or upload the .pkl into the Space." ) from huggingface_hub import hf_hub_download return hf_hub_download(repo_id=MODEL_REPO_ID, filename=MODEL_FILENAME, repo_type="model", token=HF_TOKEN) def _resolve_abmil_path(): """Optional ABMIL bundle; returns None if unavailable.""" if os.path.isfile(ABMIL_FILENAME): return ABMIL_FILENAME if not MODEL_REPO_ID: return None try: from huggingface_hub import hf_hub_download return hf_hub_download(repo_id=MODEL_REPO_ID, filename=ABMIL_FILENAME, repo_type="model", token=HF_TOKEN) except Exception: return None # Load the classifier bundle once. _BUNDLE = None def get_bundle(): global _BUNDLE if _BUNDLE is None: path = _resolve_model_path() with open(path, "rb") as f: _BUNDLE = pickle.load(f) return _BUNDLE def _result_markdown(info): badge = "🔴" if info["prediction"].lower().startswith("malig") else "🟢" return ( f"## {badge} {info['prediction']}\n" f"- **Confidence:** {info['confidence']*100:.1f}%\n" f"- **P(malignant):** {info['p_malignant']:.3f}\n" f"- **Stability:** {info['stability']*100:.0f}%\n" f"- **Patches analyzed:** {info['n_patches']}\n" f"- **Operating threshold:** {info['threshold']:.2f}\n" f"- **WSI image in report:** {'yes' if info['had_wsi'] else 'no (.h5 only)'}\n\n" f"_Research use only — not for clinical diagnosis._" ) def run_h5(h5_file, wsi_file, progress=gr.Progress()): """Path A: user uploaded an .h5 (optionally a WSI for the images).""" if h5_file is None: return "Please upload an .h5 feature file.", None try: progress(0.1, desc="Loading features…") slide = engine.load_slide_h5(h5_file.name) bundle = get_bundle() wsi_path = wsi_file.name if wsi_file is not None else None out_pdf = os.path.join(tempfile.gettempdir(), f"{slide['slide_id']}_report.pdf") progress(0.5, desc="Predicting & building report…") info = engine.build_report(bundle, slide, wsi_path, out_pdf, thumb_max=THUMB_MAX, zoom_context=ZOOM_CONTEXT) progress(1.0, desc="Done.") return _result_markdown(info), out_pdf except Exception as e: return f"**Error:** {e}\n\n```\n{traceback.format_exc()}\n```", None def run_wsi(wsi_file, progress=gr.Progress()): """Path B: user uploaded a whole slide; we extract features then report.""" if wsi_file is None: return "Please upload a whole-slide image.", None if not wsi_file.name.lower().endswith(WSI_EXTS): return f"Unsupported file type. Supported: {', '.join(WSI_EXTS)}", None try: import extract msgs = {"t": 0.0} def prog(msg): msgs["t"] = min(0.95, msgs["t"] + 0.05) progress(msgs["t"], desc=msg) out_h5 = os.path.join(tempfile.gettempdir(), "extracted_features.h5") progress(0.05, desc="Starting extraction (slow on CPU)…") extract.extract_to_h5(wsi_file.name, out_h5, progress=prog) slide = engine.load_slide_h5(out_h5) bundle = get_bundle() out_pdf = os.path.join(tempfile.gettempdir(), f"{slide['slide_id']}_report.pdf") progress(0.97, desc="Building report…") info = engine.build_report(bundle, slide, wsi_file.name, out_pdf, thumb_max=THUMB_MAX, zoom_context=ZOOM_CONTEXT) progress(1.0, desc="Done.") return _result_markdown(info), out_pdf except Exception as e: return f"**Error during extraction/prediction:** {e}\n\n```\n{traceback.format_exc()}\n```", None INTRO = """ # 🔬 BRACS Breast Histopathology Classifier Upload a whole-slide image **or** a pre-extracted `.h5` feature file. The app predicts **Benign vs Malignant** and generates a downloadable **PDF explainability report** with an attention heatmap, high-resolution tissue zooms, and a patch-level relevance map. ⚠️ **Research use only — not for clinical diagnosis.** """ with gr.Blocks(title="BRACS WSI Classifier", theme=gr.themes.Soft()) as demo: gr.Markdown(INTRO) with gr.Tab("⚡ Upload .h5 features (fast)"): gr.Markdown( "Upload an `.h5` produced by the BRACS extraction pipeline. " "Optionally add the matching slide image so the report includes the " "tissue heatmap and zoom panels." ) with gr.Row(): h5_in = gr.File(label="Feature file (.h5)", file_types=[".h5"]) wsi_opt = gr.File(label="Slide image (optional, for visuals)", file_types=list(WSI_EXTS) + [".png", ".jpg", ".jpeg"]) h5_btn = gr.Button("Predict & build report", variant="primary") h5_out = gr.Markdown() h5_pdf = gr.File(label="📄 Download PDF report") h5_btn.click(run_h5, inputs=[h5_in, wsi_opt], outputs=[h5_out, h5_pdf]) with gr.Tab("🧫 Upload whole slide (extract on CPU — slow)"): gr.Markdown( "Upload a whole-slide image. The app extracts TITAN+CONCH features, then " "predicts. **This is slow on free CPU and may time out for large slides.** " "For `.mrxs`, the companion data folder must be present.\n\n" "Requires the `HF_TOKEN` secret (TITAN is a gated model)." ) wsi_in = gr.File(label="Whole slide", file_types=list(WSI_EXTS)) wsi_btn = gr.Button("Extract, predict & build report", variant="primary") wsi_out = gr.Markdown() wsi_pdf = gr.File(label="📄 Download PDF report") wsi_btn.click(run_wsi, inputs=[wsi_in], outputs=[wsi_out, wsi_pdf]) gr.Markdown( "---\nModel: LR_concat (TITAN + pooled CONCH). " "Built from the BRACS patient-grouped pipeline." ) if __name__ == "__main__": demo.queue(max_size=8).launch(show_api=False, ssr_mode=False)