Spaces:
Running
Running
| """ | |
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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.") | |