hist / app.py
jehadcheyi's picture
Update app.py
f3ae485 verified
Raw
History Blame Contribute Delete
8.1 kB
"""
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)