""" app.py ------ Step 9: Streamlit Web Application Purpose: Interactive web interface for the Brightfield Cell Analysis System. User selects a preloaded unseen sample image from the sidebar. The app runs the full inference pipeline and displays: - Segmentation overlay (green=healthy, yellow=stressed, red=apoptotic) - GradCAM heatmap (U-Net attention visualisation) - Population metrics with benchmark comparison - Health distribution chart - Biological observations (prominently displayed) - Downloadable PDF and JSON reports Usage: Local : streamlit run src/app.py HF : streamlit run app.py """ import streamlit as st import numpy as np import cv2 import sys import json from pathlib import Path import matplotlib.pyplot as plt import pandas as pd # ── Paths (HF compatible) ───────────────────────────────────────────────────── BASE_DIR = Path(__file__).resolve().parent.parent SAMPLE_DIR = BASE_DIR / "samples" OUTPUT_DIR = BASE_DIR / "outputs" OUTPUT_DIR.mkdir(exist_ok=True) # Add src to path sys.path.insert(0, str(Path(__file__).resolve().parent)) from inference import run_inference # ── Page config ─────────────────────────────────────────────────────────────── st.set_page_config( page_title="Brightfield Cell Analyser", page_icon="🔬", layout="wide", initial_sidebar_state="expanded", ) # ── CSS ─────────────────────────────────────────────────────────────────────── st.markdown(""" """, unsafe_allow_html=True) # ── Helpers ─────────────────────────────────────────────────────────────────── def numpy_to_bytes(arr): if arr.ndim == 3: arr = cv2.cvtColor(arr, cv2.COLOR_BGR2RGB) _, buf = cv2.imencode(".png", arr) return buf.tobytes() def health_donut(healthy, stressed, apoptotic): fig, ax = plt.subplots(figsize=(3.5, 3.5)) fig.patch.set_facecolor("#111827") ax.set_facecolor("#111827") sizes = [max(healthy, 0.01), max(stressed, 0.01), max(apoptotic, 0.01)] colors = ["#68d391", "#f6e05e", "#fc8181"] labels = [f"Healthy\n{healthy:.0f}%", f"Stressed\n{stressed:.0f}%", f"Apoptotic\n{apoptotic:.0f}%"] wedges, _ = ax.pie( sizes, colors=colors, startangle=90, wedgeprops=dict(width=0.55, edgecolor="#0a0e1a", linewidth=2) ) ax.legend(wedges, labels, loc="lower center", bbox_to_anchor=(0.5, -0.18), ncol=3, fontsize=8, framealpha=0, labelcolor="white") plt.tight_layout() return fig def metric_box(label, value, unit=""): return f"""
{label}
{value} {unit}
""" def status_banner(status, filename): css_map = { "healthy_population" : "status-healthy", "mildly_suboptimal" : "status-mild", "suboptimal" : "status-suboptimal", "stressed_or_abnormal": "status-stressed", } desc_map = { "healthy_population" : "Cell population metrics are within reference ranges.", "mildly_suboptimal" : "Minor deviations from expected healthy culture parameters.", "suboptimal" : "Several metrics fall outside reference ranges.", "stressed_or_abnormal": "Multiple indicators of cellular stress detected.", } css = css_map.get(status, "status-mild") label = status.replace("_", " ").upper() desc = desc_map.get(status, "") return f"""

{label}

{desc}  ·  {filename}

""" # ── Sidebar ─────────────────────────────────────────────────────────────────── with st.sidebar: st.markdown("## 🔬 Sample Images") st.caption("10 unseen images — not used in training") sample_files = sorted(SAMPLE_DIR.glob("*_w1*.tif")) \ if SAMPLE_DIR.exists() else [] sample_names = ["— choose an image —"] + \ [p.name[:45] for p in sample_files] selected = st.selectbox("Select image", sample_names, label_visibility="collapsed", key="sample_select") st.divider() st.markdown("## ⚙️ Settings") threshold = st.slider("Segmentation threshold", 0.3, 0.8, 0.5, 0.05) image_size = st.selectbox("Resolution", [256, 512], index=0) show_gradcam = st.checkbox("Show GradCAM", value=True) show_cells = st.checkbox("Show per-cell table", value=False) st.divider() st.caption( "**Dataset:** BBBC006 z_16\n\n" "**Model:** U-Net · DiceBCE Loss\n\n" "**Classifier:** Random Forest (99.96% acc)\n\n" "**References:** Caicedo 2017 · Freshney 2016" ) # ── Main ────────────────────────────────────────────────────────────────────── st.markdown('
🔬 Brightfield Cell Analysis
', unsafe_allow_html=True) st.markdown( '
U-Net segmentation · Health classification · ' 'Benchmark-referenced biological insight
', unsafe_allow_html=True ) # ── Resolve input ───────────────────────────────────────────────────────────── tmp_path = None if selected != "— choose an image —": idx = sample_names.index(selected) - 1 tmp_path = str(sample_files[idx]) # ── Run analysis ────────────────────────────────────────────────────────────── if tmp_path: with st.spinner("Running full pipeline..."): try: report = run_inference( tmp_path, size=image_size, threshold=threshold, save_pdf=True, save_gradcam=True, ) except Exception as e: st.error(f"Pipeline error: {e}") st.stop() # ── Status banner ───────────────────────────────────────────────────────── st.markdown(status_banner(report.overall_status, report.filename), unsafe_allow_html=True) # ── Images ──────────────────────────────────────────────────────────────── if show_gradcam: img_col1, img_col2 = st.columns(2) with img_col1: st.markdown("**Segmentation overlay**") st.caption("🟢 Healthy   🟡 Stressed   🔴 Apoptotic") if report.overlay_image is not None: st.image(numpy_to_bytes(report.overlay_image), use_container_width=True) with img_col2: st.markdown("**GradCAM — U-Net attention map**") st.caption("Red/yellow = regions the model focused on") if report.gradcam_image is not None: st.image(numpy_to_bytes(report.gradcam_image), use_container_width=True) else: if report.overlay_image is not None: st.image(numpy_to_bytes(report.overlay_image), use_container_width=True) # ── Metrics ─────────────────────────────────────────────────────────────── st.markdown('
Population Metrics
', unsafe_allow_html=True) m1, m2, m3, m4, m5, m6, m7, m8 = st.columns(8) m1.markdown(metric_box("Cells", report.n_cells, ""), unsafe_allow_html=True) m2.markdown(metric_box("Confluency", f"{report.confluency_pct:.1f}", "%"), unsafe_allow_html=True) m3.markdown(metric_box("Area", f"{report.mean_area:.0f}", "px²"), unsafe_allow_html=True) m4.markdown(metric_box("Confidence", f"{report.mean_confidence:.2f}", ""), unsafe_allow_html=True) m5.markdown(metric_box("Circularity", f"{report.mean_circularity:.3f}",""), unsafe_allow_html=True) m6.markdown(metric_box("Solidity", f"{report.mean_solidity:.3f}", ""), unsafe_allow_html=True) m7.markdown(metric_box("Intensity", f"{report.mean_intensity:.3f}", ""), unsafe_allow_html=True) m8.markdown(metric_box("Healthy", f"{report.healthy_pct:.0f}", "%"), unsafe_allow_html=True) # ── Health distribution ─────────────────────────────────────────────────── st.markdown('
Health Distribution
', unsafe_allow_html=True) donut_col, tag_col = st.columns([1, 2]) with donut_col: fig = health_donut(report.healthy_pct, report.stressed_pct, report.apoptotic_pct) st.pyplot(fig, use_container_width=True) with tag_col: st.markdown("

", unsafe_allow_html=True) st.markdown( f'Healthy   {report.healthy_pct:.1f}%' f'   ' f'Stressed   {report.stressed_pct:.1f}%' f'   ' f'Apoptotic   {report.apoptotic_pct:.1f}%', unsafe_allow_html=True ) st.markdown("
", unsafe_allow_html=True) st.markdown( f"**{report.n_cells}** cells detected across the field of view. " f"Classifier mean confidence: **{report.mean_confidence:.3f}** " f"({'high' if report.mean_confidence > 0.85 else 'moderate'})." ) # ── Observations ────────────────────────────────────────────────────────── st.markdown('
Biological Observations
', unsafe_allow_html=True) for obs in report.observations: st.markdown(f'
🔬   {obs}
', unsafe_allow_html=True) # ── Recommendations ─────────────────────────────────────────────────────── st.markdown('
Recommendations
', unsafe_allow_html=True) for rec in report.recommendations: st.markdown(f'
✅   {rec}
', unsafe_allow_html=True) # ── Caveats ─────────────────────────────────────────────────────────────── st.markdown('
Caveats
', unsafe_allow_html=True) for cav in report.caveats: st.markdown(f'
⚑   {cav}
', unsafe_allow_html=True) # ── Per-cell table ──────────────────────────────────────────────────────── if show_cells and report.cell_details: st.markdown('
Per-cell Details
', unsafe_allow_html=True) df = pd.DataFrame(report.cell_details) st.dataframe(df, use_container_width=True) # ── Downloads ───────────────────────────────────────────────────────────── st.markdown('
Downloads
', unsafe_allow_html=True) dl1, dl2, _ = st.columns([1, 1, 2]) pdf_path = OUTPUT_DIR / "report.pdf" if pdf_path.exists(): with open(pdf_path, "rb") as f: dl1.download_button( label="⬇ PDF Report", data=f.read(), file_name=f"cell_analysis_{Path(report.filename).stem}.pdf", mime="application/pdf", use_container_width=True, ) dl2.download_button( label="⬇ JSON Report", data=json.dumps(report.to_dict(), indent=2), file_name=f"cell_analysis_{Path(report.filename).stem}.json", mime="application/json", use_container_width=True, ) else: # ── Landing page ────────────────────────────────────────────────────────── st.markdown("""
🔬
Select a sample image from the sidebar to begin analysis
""", unsafe_allow_html=True) st.markdown('
What this system does
', unsafe_allow_html=True) f1, f2, f3, f4 = st.columns(4) f1.markdown("""
🧬
Segmentation
U-Net predicts cell vs background mask
""", unsafe_allow_html=True) f2.markdown("""
🏥
Classification
Random Forest classifies cell health state
""", unsafe_allow_html=True) f3.markdown("""
📊
Benchmarking
Metrics compared to published reference ranges
""", unsafe_allow_html=True) f4.markdown("""
📄
Report
PDF + JSON report with recommendations
""", unsafe_allow_html=True) st.markdown('
Reference ranges
', unsafe_allow_html=True) refs = pd.DataFrame([ ["Confluency", "5–20%", "BBBC006 sparse plate format"], ["Circularity", "≥ 0.65", "Caicedo et al. 2017"], ["Solidity", "≥ 0.85", "Standard adherent cell morphology"], ["Apoptotic rate", "< 20%", "Normal culture baseline"], ["Healthy rate", "≥ 60%", "Normal culture baseline"], ], columns=["Metric", "Normal Range", "Source"]) st.table(refs)