Mohamed-ENNHIRI
Initial commit: code, metric logs, and report
35839ff
Raw
History Blame Contribute Delete
19.6 kB
"""
Streamlit dashboard for the data-scaling study.
Run from the experiments/data_scaling_study/ directory:
streamlit run dashboard/app.py
Three sections:
1. Learning curves β€” per-epoch metrics for every (model, share) run
2. Data share vs final β€” best val mIoU/Dice/IoU/PixelAcc as a function of data share
3. Inference β€” upload an image, see all 6 segmentations side-by-side
Reads logs from ../logs and checkpoints from ../checkpoints. Sections gracefully
degrade when runs are missing β€” useful while training is still in flight.
Note on the 100% rows
─────────────────────
The 100% checkpoints are not retrained here β€” they are bootstrapped from the
existing pv_panel_models/ baselines via bootstrap_100.py. Per-epoch metrics at
100% are parsed from the old text logs (per-batch averaging) and mIoU is null
per epoch (the old trainer didn't compute it). The single comparable number on
the scaling chart for 100% is read from the bootstrap's `recomputed_val_metrics`
field, which uses the same global confusion-matrix metric code as the 25/50%
runs. Banners in each tab explain.
"""
import io
import json
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
import torch
from PIL import Image
from torchvision import transforms
THIS_DIR = Path(__file__).resolve().parent
EXP_DIR = THIS_DIR.parent
LOGS_DIR = EXP_DIR / "logs"
CKPT_DIR = EXP_DIR / "checkpoints"
sys.path.insert(0, str(EXP_DIR))
from models import MODEL_REGISTRY # noqa: E402
MODELS = ["unet", "segformer_b0"]
SHARES = [25, 50, 100]
PRETTY_MODEL = {"unet": "U-Net", "segformer_b0": "SegFormer-B0"}
METRICS = [
("dice", "Dice"),
("miou", "mIoU"),
("iou", "Foreground IoU"),
("pixel_acc", "Pixel Accuracy"),
("loss", "Loss"),
]
st.set_page_config(page_title="Data Scaling Study", layout="wide")
# ── Loaders ────────────────────────────────────────────────────────────────
@st.cache_data(show_spinner=False)
def load_log(model: str, share: int):
p = LOGS_DIR / f"{model}_{share}.json"
if not p.is_file():
return None
with open(p) as f:
return json.load(f)
def log_to_df(log):
df = pd.DataFrame(log["epochs"])
df["model"] = log["model"]
df["share"] = log["share"]
return df
def is_bootstrapped(log):
return log.get("bootstrapped_from") is not None
@st.cache_data(show_spinner=False)
def load_all_logs():
logs = {}
for m in MODELS:
for s in SHARES:
log = load_log(m, s)
if log is not None:
logs[(m, s)] = log
return logs
def fmt_hms(seconds):
if seconds is None:
return "β€”"
seconds = int(round(seconds))
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
return f"{h:d}:{m:02d}:{s:02d}" if h else f"{m:d}:{s:02d}"
def scaling_row(log):
"""Best-checkpoint val metrics for the scaling chart.
For trained 25/50% runs: read the per-epoch maximum from the JSON.
For bootstrapped 100% runs: read from `recomputed_val_metrics` so the
metric definition matches the 25/50% runs.
"""
epochs = log["epochs"]
row = {
"model": PRETTY_MODEL[log["model"]],
"share": log["share"],
"source": "bootstrapped" if is_bootstrapped(log) else "trained",
}
if is_bootstrapped(log) and log.get("recomputed_val_metrics") is not None:
rv = log["recomputed_val_metrics"]
row.update({
"best_val_dice": rv.get("dice"),
"best_val_miou": rv.get("miou"),
"best_val_iou": rv.get("iou"),
"best_val_pixel_acc": rv.get("pixel_acc"),
})
elif epochs:
# Best-by-Dice index (matches the saved best.pth selection)
idx = max(range(len(epochs)), key=lambda i: epochs[i].get("val_dice", -1) or -1)
best = epochs[idx]
row.update({
"best_val_dice": best.get("val_dice"),
"best_val_miou": best.get("val_miou"),
"best_val_iou": best.get("val_iou"),
"best_val_pixel_acc": best.get("val_pixel_acc"),
})
else:
row.update({k: None for k in (
"best_val_dice", "best_val_miou", "best_val_iou", "best_val_pixel_acc"
)})
# Wall-clock timing
wall = log.get("wall_clock_seconds") # trained runs
if wall is None:
wall = log.get("val_recompute_seconds") # bootstrapped runs
row["wall_clock_seconds"] = wall
row["wall_clock"] = fmt_hms(wall)
if epochs:
per_epoch = [e.get("epoch_seconds") for e in epochs if e.get("epoch_seconds") is not None]
row["sec_per_epoch"] = (sum(per_epoch) / len(per_epoch)) if per_epoch else None
else:
row["sec_per_epoch"] = None
return row
# ── Inference helpers ──────────────────────────────────────────────────────
@st.cache_resource(show_spinner=False)
def load_best(model_name: str, share: int, device: str):
p = CKPT_DIR / f"{model_name}_{share}_best.pth"
if not p.is_file():
return None
builder = MODEL_REGISTRY[model_name]
model, _ = builder()
state = torch.load(p, map_location=device, weights_only=False)
model.load_state_dict(state["model_state_dict"])
model.to(device).eval()
return model
def preprocess(image: Image.Image, image_size: int = 128):
tf = transforms.Compose([
transforms.Resize((image_size, image_size)),
transforms.ToTensor(),
])
return tf(image.convert("RGB")).unsqueeze(0)
def run_inference(model, image_tensor, device, threshold=0.5):
"""Returns (probs_2d, mask_2d) both as 2-D float numpy arrays in [0,1]."""
with torch.no_grad():
logits = model(image_tensor.to(device))
probs = torch.sigmoid(logits).squeeze().cpu().numpy()
if probs.ndim != 2:
probs = probs.reshape(probs.shape[-2], probs.shape[-1])
mask = (probs > threshold).astype(np.float32)
return probs, mask
def overlay(rgb: np.ndarray, mask: np.ndarray, color=(0, 255, 0), alpha=0.45):
out = rgb.copy()
m = mask.astype(bool)
out[m] = (alpha * np.array(color) + (1 - alpha) * out[m]).astype(np.uint8)
return out
def heatmap(probs: np.ndarray) -> np.ndarray:
"""Map a [0,1] probability map to a 3-channel uint8 RGB image (red→hot)."""
p = np.clip(probs, 0.0, 1.0)
rgb = np.zeros((p.shape[0], p.shape[1], 3), dtype=np.uint8)
rgb[..., 0] = (p * 255).astype(np.uint8) # R
rgb[..., 1] = (np.maximum(0, 1 - 2 * np.abs(p - 0.5)) * 255).astype(np.uint8) # G
rgb[..., 2] = ((1 - p) * 255).astype(np.uint8) # B
return rgb
# ── UI ─────────────────────────────────────────────────────────────────────
st.title("πŸ“Š Data-Scaling Study β€” U-Net vs SegFormer-B0")
st.caption(
"How does training-set size affect segmentation quality? "
"Two architectures, three data shares (25 / 50 / 100 %), shared validation set. "
"100% checkpoints are bootstrapped from the existing pv_panel_models baselines."
)
logs = load_all_logs()
if not logs:
st.warning(
"No logs found in `../logs/`. "
"Run training first (`./run_all.sh`) and bootstrap "
"the 100% point (`python bootstrap_100.py`)."
)
tab_curves, tab_scaling, tab_infer = st.tabs(
["1 Β· Learning curves", "2 Β· Data share vs final", "3 Β· Inference"]
)
# ── Tab 1: Learning curves ─────────────────────────────────────────────────
with tab_curves:
st.subheader("Per-epoch metrics")
if any(is_bootstrapped(l) for l in logs.values()):
st.info(
"**Note on 100%:** per-epoch metrics are parsed from the existing text logs "
"and use the old per-batch averaging (Dice/IoU/PixelAcc only). "
"mIoU is null per epoch and is omitted from the chart for 100%. "
"Use the scaling chart in tab 2 for fair cross-share comparisons."
)
if not logs:
st.info("Waiting for training logs.")
else:
col_m, col_split = st.columns([2, 2])
with col_m:
metric_key, metric_label = st.selectbox(
"Metric",
METRICS,
format_func=lambda x: x[1],
)
with col_split:
split = st.radio("Split", ["val", "train", "both"], horizontal=True, index=0)
for model in MODELS:
available = [s for s in SHARES if (model, s) in logs]
if not available:
continue
st.markdown(f"#### {PRETTY_MODEL[model]}")
fig = go.Figure()
for share in available:
df = log_to_df(logs[(model, share)])
bootstrapped = is_bootstrapped(logs[(model, share)])
if split in ("val", "both"):
col = f"val_{metric_key}"
if col in df.columns and df[col].notna().any():
sub = df.dropna(subset=[col])
suffix = " val (old-def)" if bootstrapped else " val"
fig.add_trace(go.Scatter(
x=sub["epoch"], y=sub[col],
mode="lines",
name=f"{share}%{suffix}",
))
if split in ("train", "both"):
col = f"train_{metric_key}"
if col in df.columns and df[col].notna().any():
sub = df.dropna(subset=[col])
suffix = " train (old-def)" if bootstrapped else " train"
fig.add_trace(go.Scatter(
x=sub["epoch"], y=sub[col],
mode="lines", line=dict(dash="dot"),
name=f"{share}%{suffix}",
))
fig.update_layout(
xaxis_title="Epoch",
yaxis_title=metric_label,
height=380,
margin=dict(l=10, r=10, t=10, b=10),
legend=dict(orientation="h", y=-0.2),
)
st.plotly_chart(fig, use_container_width=True)
# ── Tab 2: Data share vs final ─────────────────────────────────────────────
with tab_scaling:
st.subheader("Best-checkpoint val metrics vs data share")
st.caption(
"Each point is the best-epoch validation score for one (model, share) run. "
"All numbers use the same global confusion-matrix metric code, including the 100% "
"points (recomputed via bootstrap_100.py)."
)
if not logs:
st.info("Waiting for training logs.")
else:
rows = [scaling_row(log) for log in logs.values()]
df = pd.DataFrame(rows).sort_values(["model", "share"]).reset_index(drop=True)
# Display table β€” show formatted wall clock; hide raw seconds.
display_df = df.drop(columns=["wall_clock_seconds", "sec_per_epoch"])
st.dataframe(display_df, use_container_width=True, hide_index=True)
# Timing summary
trained_seconds = df.loc[df["source"] == "trained", "wall_clock_seconds"].sum()
if trained_seconds:
st.caption(
f"⏱ Total training wall-clock across the four 25/50% runs: "
f"**{fmt_hms(trained_seconds)}** ({trained_seconds:,.0f} s)"
)
col1, col2 = st.columns(2)
with col1:
fig1 = px.line(
df.dropna(subset=["best_val_miou"]),
x="share", y="best_val_miou", color="model",
markers=True, title="Best val mIoU",
labels={"share": "Training data (%)", "best_val_miou": "Val mIoU"},
)
fig1.update_xaxes(tickvals=SHARES)
st.plotly_chart(fig1, use_container_width=True)
with col2:
fig2 = px.line(
df.dropna(subset=["best_val_dice"]),
x="share", y="best_val_dice", color="model",
markers=True, title="Best val Dice",
labels={"share": "Training data (%)", "best_val_dice": "Val Dice"},
)
fig2.update_xaxes(tickvals=SHARES)
st.plotly_chart(fig2, use_container_width=True)
col3, col4 = st.columns(2)
with col3:
fig3 = px.bar(
df.dropna(subset=["best_val_iou"]),
x="share", y="best_val_iou", color="model", barmode="group",
title="Best val foreground IoU",
labels={"share": "Training data (%)", "best_val_iou": "Val IoU (foreground)"},
)
fig3.update_xaxes(tickvals=SHARES)
st.plotly_chart(fig3, use_container_width=True)
with col4:
fig4 = px.bar(
df.dropna(subset=["best_val_pixel_acc"]),
x="share", y="best_val_pixel_acc", color="model", barmode="group",
title="Best val pixel accuracy",
labels={"share": "Training data (%)", "best_val_pixel_acc": "Val pixel acc"},
)
fig4.update_xaxes(tickvals=SHARES)
st.plotly_chart(fig4, use_container_width=True)
st.markdown("##### Training time")
time_df = df[df["source"] == "trained"].dropna(subset=["wall_clock_seconds"])
if not time_df.empty:
time_df = time_df.assign(wall_minutes=time_df["wall_clock_seconds"] / 60.0)
tcol1, tcol2 = st.columns(2)
with tcol1:
fig_t1 = px.bar(
time_df,
x="share", y="wall_minutes", color="model", barmode="group",
title="Total training time (minutes)",
labels={"share": "Training data (%)", "wall_minutes": "Wall clock (min)"},
)
fig_t1.update_xaxes(tickvals=SHARES)
st.plotly_chart(fig_t1, use_container_width=True)
with tcol2:
fig_t2 = px.bar(
time_df.dropna(subset=["sec_per_epoch"]),
x="share", y="sec_per_epoch", color="model", barmode="group",
title="Average seconds per epoch",
labels={"share": "Training data (%)", "sec_per_epoch": "Seconds / epoch"},
)
fig_t2.update_xaxes(tickvals=SHARES)
st.plotly_chart(fig_t2, use_container_width=True)
else:
st.caption("No timing data yet β€” runs in progress will populate this once the first one finishes.")
# ── Tab 3: Inference ───────────────────────────────────────────────────────
with tab_infer:
st.subheader("Upload an image β€” see all 6 segmentations")
st.caption(
"Each cell uses the best-epoch checkpoint of one (model, data-share) combination. "
"The 100% cells use the bootstrapped checkpoint (existing pv_panel_models baseline)."
)
col_a, col_b, col_c = st.columns([2, 2, 2])
with col_a:
threshold = st.slider("Threshold", 0.0, 1.0, 0.5, 0.05, key="infer_thr")
with col_b:
view = st.radio(
"View",
["mask", "overlay", "heatmap"],
horizontal=True,
key="infer_view",
)
with col_c:
cell_w = st.select_slider(
"Cell size (px)", options=[160, 200, 240, 280, 320], value=240, key="infer_cell"
)
uploaded = st.file_uploader(
"Drop an image (jpg/png)", type=["jpg", "jpeg", "png"], key="infer_upload"
)
debug = st.checkbox("debug", value=True, key="infer_debug")
if uploaded is not None:
if debug:
st.write("βœ“ uploaded is not None β€” entering inference block")
device = "cuda" if torch.cuda.is_available() else "cpu"
if debug:
st.write(f"βœ“ device = `{device}`")
try:
raw_bytes = uploaded.getvalue()
img = Image.open(io.BytesIO(raw_bytes)).convert("RGB")
except Exception as e:
st.error(f"Could not decode uploaded image: {e}")
st.exception(e)
st.stop()
if debug:
st.write(f"βœ“ image decoded β€” {img.size[0]}Γ—{img.size[1]} px, {len(raw_bytes)/1024:.1f} KB")
st.caption(f"πŸ“ `{uploaded.name}` β€” {img.size[0]}Γ—{img.size[1]} px, {len(raw_bytes)/1024:.1f} KB")
# Input preview (no nested columns β€” flat render so nothing gets swallowed)
st.markdown("**Input (original / resized to 128Γ—128 the models see)**")
try:
x = preprocess(img, image_size=128)
rgb_small = (x.squeeze().permute(1, 2, 0).numpy() * 255).astype(np.uint8)
except Exception as e:
st.error(f"preprocess failed: {e}")
st.exception(e)
st.stop()
st.image([img, rgb_small], width=cell_w, caption=["original", "128Γ—128"])
if debug:
st.write(f"βœ“ tensor shape = {tuple(x.shape)}, rgb_small shape = {rgb_small.shape}")
st.write(f"βœ“ MODELS = {MODELS}, SHARES = {SHARES}")
st.markdown("##### Predictions (one row per model+share)")
def render_cell(probs, mask, rgb):
if view == "mask":
return (mask * 255).astype(np.uint8)
if view == "overlay":
return overlay(rgb, mask)
return heatmap(probs)
# Single flat row of 6 cells β€” no nested columns.
cells = []
for model_name in MODELS:
for share in SHARES:
cells.append((model_name, share))
if debug:
st.write(f"βœ“ rendering {len(cells)} cells: {cells}")
cols = st.columns(len(cells))
for col, (model_name, share) in zip(cols, cells):
with col:
st.markdown(f"**{PRETTY_MODEL[model_name]}** \n*{share}%*")
try:
if debug:
st.write(f"loading {model_name}_{share}…")
m = load_best(model_name, share, device)
if m is None:
st.warning(f"missing `{model_name}_{share}_best.pth`")
continue
if debug:
st.write("running…")
probs, mask = run_inference(m, x, device, threshold=threshold)
cell_img = render_cell(probs, mask, rgb_small)
st.image(cell_img, width=cell_w)
st.caption(
f"cov={float(mask.mean())*100:.1f}% "
f"p[{probs.min():.2f},{probs.max():.2f}]"
)
except Exception as e:
st.error(f"{model_name} {share}% failed")
st.exception(e)
if debug:
st.write("βœ“ render loop complete")
else:
st.info("Upload an image to run inference across all six trained models.")