"""
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"""
"""
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('',
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('',
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('',
unsafe_allow_html=True)
for obs in report.observations:
st.markdown(f'🔬 {obs}
',
unsafe_allow_html=True)
# ── Recommendations ───────────────────────────────────────────────────────
st.markdown('',
unsafe_allow_html=True)
for rec in report.recommendations:
st.markdown(f'✅ {rec}
',
unsafe_allow_html=True)
# ── Caveats ───────────────────────────────────────────────────────────────
st.markdown('',
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('',
unsafe_allow_html=True)
df = pd.DataFrame(report.cell_details)
st.dataframe(df, use_container_width=True)
# ── Downloads ─────────────────────────────────────────────────────────────
st.markdown('',
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('',
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('',
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)